- Restructure operator sidebar into a flat task list with indented children; fold plan topology into plan ladders - Expand member catalog non-plan section to all published non-tier products; require recurring Stripe-mapped prices for purchase - Add operator domains placements and terminal-claims ledger; redirect /domains to the FedWiki Sites Domains anchor - Apply canonical vocabulary and chrome/form conventions; migrate seeded FedWiki Sites display name
884 lines
30 KiB
Go
884 lines
30 KiB
Go
// Code generated by sqlc. DO NOT EDIT.
|
|
// versions:
|
|
// sqlc v1.29.0
|
|
// source: claims.sql
|
|
|
|
package domains
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/lib/pq"
|
|
)
|
|
|
|
const countPendingClaimsByWorkspace = `-- name: CountPendingClaimsByWorkspace :one
|
|
SELECT COUNT(*) FROM domains.claims
|
|
WHERE workspace_id = $1 AND status = 'pending'
|
|
`
|
|
|
|
// Soft cap on concurrent external verifications — each is a live 24-hour
|
|
// polling workflow.
|
|
func (q *Queries) CountPendingClaimsByWorkspace(ctx context.Context, workspaceID string) (int64, error) {
|
|
row := q.db.QueryRowContext(ctx, countPendingClaimsByWorkspace, workspaceID)
|
|
var count int64
|
|
err := row.Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
const createClaim = `-- name: CreateClaim :one
|
|
INSERT INTO domains.claims (
|
|
workspace_id, root_fqdn, reversed_labels, kind, parent_claim_id,
|
|
status, token, expires_at, verified_at
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
RETURNING claim_id, workspace_id, root_fqdn, reversed_labels, kind, parent_claim_id, status, created_at, updated_at, token, expires_at, verified_at, txt_state, txt_observed, connect_state, connect_observed, last_checked_at, evidence_at, abandoned_at, system_canceled_at
|
|
`
|
|
|
|
type CreateClaimParams struct {
|
|
WorkspaceID string `json:"workspace_id"`
|
|
RootFqdn string `json:"root_fqdn"`
|
|
ReversedLabels string `json:"reversed_labels"`
|
|
Kind string `json:"kind"`
|
|
ParentClaimID uuid.NullUUID `json:"parent_claim_id"`
|
|
Status string `json:"status"`
|
|
Token string `json:"token"`
|
|
ExpiresAt sql.NullTime `json:"expires_at"`
|
|
VerifiedAt sql.NullTime `json:"verified_at"`
|
|
}
|
|
|
|
// Callers pass normalized names (trimmed, lowercased, one trailing dot
|
|
// stripped) and the matching label reversal; the registry owns both.
|
|
func (q *Queries) CreateClaim(ctx context.Context, arg CreateClaimParams) (Claim, error) {
|
|
row := q.db.QueryRowContext(ctx, createClaim,
|
|
arg.WorkspaceID,
|
|
arg.RootFqdn,
|
|
arg.ReversedLabels,
|
|
arg.Kind,
|
|
arg.ParentClaimID,
|
|
arg.Status,
|
|
arg.Token,
|
|
arg.ExpiresAt,
|
|
arg.VerifiedAt,
|
|
)
|
|
var i Claim
|
|
err := row.Scan(
|
|
&i.ClaimID,
|
|
&i.WorkspaceID,
|
|
&i.RootFqdn,
|
|
&i.ReversedLabels,
|
|
&i.Kind,
|
|
&i.ParentClaimID,
|
|
&i.Status,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.Token,
|
|
&i.ExpiresAt,
|
|
&i.VerifiedAt,
|
|
&i.TxtState,
|
|
&i.TxtObserved,
|
|
&i.ConnectState,
|
|
&i.ConnectObserved,
|
|
&i.LastCheckedAt,
|
|
&i.EvidenceAt,
|
|
&i.AbandonedAt,
|
|
&i.SystemCanceledAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const getClaimByID = `-- name: GetClaimByID :one
|
|
SELECT claim_id, workspace_id, root_fqdn, reversed_labels, kind, parent_claim_id, status, created_at, updated_at, token, expires_at, verified_at, txt_state, txt_observed, connect_state, connect_observed, last_checked_at, evidence_at, abandoned_at, system_canceled_at FROM domains.claims
|
|
WHERE claim_id = $1
|
|
`
|
|
|
|
func (q *Queries) GetClaimByID(ctx context.Context, claimID string) (Claim, error) {
|
|
row := q.db.QueryRowContext(ctx, getClaimByID, claimID)
|
|
var i Claim
|
|
err := row.Scan(
|
|
&i.ClaimID,
|
|
&i.WorkspaceID,
|
|
&i.RootFqdn,
|
|
&i.ReversedLabels,
|
|
&i.Kind,
|
|
&i.ParentClaimID,
|
|
&i.Status,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.Token,
|
|
&i.ExpiresAt,
|
|
&i.VerifiedAt,
|
|
&i.TxtState,
|
|
&i.TxtObserved,
|
|
&i.ConnectState,
|
|
&i.ConnectObserved,
|
|
&i.LastCheckedAt,
|
|
&i.EvidenceAt,
|
|
&i.AbandonedAt,
|
|
&i.SystemCanceledAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const getLiveClaimByRoot = `-- name: GetLiveClaimByRoot :one
|
|
SELECT claim_id, workspace_id, root_fqdn, reversed_labels, kind, parent_claim_id, status, created_at, updated_at, token, expires_at, verified_at, txt_state, txt_observed, connect_state, connect_observed, last_checked_at, evidence_at, abandoned_at, system_canceled_at FROM domains.claims
|
|
WHERE root_fqdn = $1 AND status IN ('pending', 'active')
|
|
`
|
|
|
|
// A pending or active row holds the name (the partial unique index enforces
|
|
// at most one); terminal rows do not block re-claiming.
|
|
func (q *Queries) GetLiveClaimByRoot(ctx context.Context, rootFqdn string) (Claim, error) {
|
|
row := q.db.QueryRowContext(ctx, getLiveClaimByRoot, rootFqdn)
|
|
var i Claim
|
|
err := row.Scan(
|
|
&i.ClaimID,
|
|
&i.WorkspaceID,
|
|
&i.RootFqdn,
|
|
&i.ReversedLabels,
|
|
&i.Kind,
|
|
&i.ParentClaimID,
|
|
&i.Status,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.Token,
|
|
&i.ExpiresAt,
|
|
&i.VerifiedAt,
|
|
&i.TxtState,
|
|
&i.TxtObserved,
|
|
&i.ConnectState,
|
|
&i.ConnectObserved,
|
|
&i.LastCheckedAt,
|
|
&i.EvidenceAt,
|
|
&i.AbandonedAt,
|
|
&i.SystemCanceledAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const listAbandonmentsInScope = `-- name: ListAbandonmentsInScope :many
|
|
SELECT abandoned_at FROM domains.claims
|
|
WHERE workspace_id = $1
|
|
AND abandoned_at IS NOT NULL
|
|
AND abandoned_at >= $2::timestamptz
|
|
AND (reversed_labels = $3::text OR reversed_labels LIKE $3::text || '.%')
|
|
ORDER BY abandoned_at DESC
|
|
LIMIT $4::bigint
|
|
`
|
|
|
|
type ListAbandonmentsInScopeParams struct {
|
|
WorkspaceID string `json:"workspace_id"`
|
|
Since time.Time `json:"since"`
|
|
ScopeRev string `json:"scope_rev"`
|
|
Budget int64 `json:"budget"`
|
|
}
|
|
|
|
// The abandonment ledger read (design D3): one workspace's abandonments
|
|
// inside a candidate's scope, NEWEST first, capped at the budget.
|
|
//
|
|
// The scope predicate is the scope root AND everything beneath it, so cycling
|
|
// sibling names (a.example.org, b.example.org, …) counts as cycling one name;
|
|
// an ancestor-only or per-name predicate would not close that. Callers pass
|
|
// the budget as the limit: exactly @budget rows back means the budget is
|
|
// exhausted.
|
|
//
|
|
// Newest first, so the LAST row is the entry whose ageing out reopens the
|
|
// scope. Oldest-first would name the globally oldest entry, which is the wrong
|
|
// answer whenever a workspace holds MORE entries than the budget — the pending
|
|
// cap alone lets it bank five against a budget of three, and the member would
|
|
// be told to come back at a time they are still refused. The scope reopens
|
|
// when fewer than @budget entries remain in the window, which is when the
|
|
// @budget-th newest ages out.
|
|
func (q *Queries) ListAbandonmentsInScope(ctx context.Context, arg ListAbandonmentsInScopeParams) ([]sql.NullTime, error) {
|
|
rows, err := q.db.QueryContext(ctx, listAbandonmentsInScope,
|
|
arg.WorkspaceID,
|
|
arg.Since,
|
|
arg.ScopeRev,
|
|
arg.Budget,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []sql.NullTime{}
|
|
for rows.Next() {
|
|
var abandoned_at sql.NullTime
|
|
if err := rows.Scan(&abandoned_at); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, abandoned_at)
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listExpiredPendingClaims = `-- name: ListExpiredPendingClaims :many
|
|
SELECT claim_id, workspace_id, root_fqdn, expires_at
|
|
FROM domains.claims
|
|
WHERE status = 'pending' AND expires_at < $1::timestamptz
|
|
ORDER BY expires_at
|
|
`
|
|
|
|
type ListExpiredPendingClaimsRow struct {
|
|
ClaimID string `json:"claim_id"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
RootFqdn string `json:"root_fqdn"`
|
|
ExpiresAt sql.NullTime `json:"expires_at"`
|
|
}
|
|
|
|
// The expiry sweep's candidate read (claim-expiry-and-carve-guards D1). This
|
|
// is the query idx_claims_pending_expires_at was created for: the partial
|
|
// index over (expires_at) WHERE status = 'pending' answers it directly, and
|
|
// nothing else in the registry asks a question shaped like it.
|
|
//
|
|
// @deadline is now minus the sweep's grace period, not now. A healthy
|
|
// verification workflow marks its own claim expired within one poll of the
|
|
// deadline and carries the final probe's evidence verdict with it; the sweep
|
|
// carries none, so it must not win that race. Trailing the deadline by more
|
|
// than the poll cap makes the sweep the backstop it is meant to be, for claims
|
|
// whose workflow is gone.
|
|
//
|
|
// Deliberately unlimited: every row a pass finds it makes terminal, so the
|
|
// candidate set drains rather than accumulating.
|
|
func (q *Queries) ListExpiredPendingClaims(ctx context.Context, deadline time.Time) ([]ListExpiredPendingClaimsRow, error) {
|
|
rows, err := q.db.QueryContext(ctx, listExpiredPendingClaims, deadline)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListExpiredPendingClaimsRow{}
|
|
for rows.Next() {
|
|
var i ListExpiredPendingClaimsRow
|
|
if err := rows.Scan(
|
|
&i.ClaimID,
|
|
&i.WorkspaceID,
|
|
&i.RootFqdn,
|
|
&i.ExpiresAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listLiveClaimsByRoots = `-- name: ListLiveClaimsByRoots :many
|
|
SELECT claim_id, workspace_id, root_fqdn, reversed_labels, kind, parent_claim_id, status, created_at, updated_at, token, expires_at, verified_at, txt_state, txt_observed, connect_state, connect_observed, last_checked_at, evidence_at, abandoned_at, system_canceled_at FROM domains.claims
|
|
WHERE root_fqdn = ANY($1::text[]) AND status IN ('pending', 'active')
|
|
ORDER BY length(root_fqdn) DESC
|
|
`
|
|
|
|
// Ancestor walk: the caller passes the candidate name's suffixes (at most
|
|
// 127) and reads the deepest match. Exact equality keeps this on the
|
|
// live-root unique index.
|
|
func (q *Queries) ListLiveClaimsByRoots(ctx context.Context, roots []string) ([]Claim, error) {
|
|
rows, err := q.db.QueryContext(ctx, listLiveClaimsByRoots, pq.Array(roots))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []Claim{}
|
|
for rows.Next() {
|
|
var i Claim
|
|
if err := rows.Scan(
|
|
&i.ClaimID,
|
|
&i.WorkspaceID,
|
|
&i.RootFqdn,
|
|
&i.ReversedLabels,
|
|
&i.Kind,
|
|
&i.ParentClaimID,
|
|
&i.Status,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.Token,
|
|
&i.ExpiresAt,
|
|
&i.VerifiedAt,
|
|
&i.TxtState,
|
|
&i.TxtObserved,
|
|
&i.ConnectState,
|
|
&i.ConnectObserved,
|
|
&i.LastCheckedAt,
|
|
&i.EvidenceAt,
|
|
&i.AbandonedAt,
|
|
&i.SystemCanceledAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listLiveClaimsByWorkspace = `-- name: ListLiveClaimsByWorkspace :many
|
|
SELECT c.claim_id, c.workspace_id, c.root_fqdn, c.reversed_labels, c.kind, c.parent_claim_id, c.status, c.created_at, c.updated_at, c.token, c.expires_at, c.verified_at, c.txt_state, c.txt_observed, c.connect_state, c.connect_observed, c.last_checked_at, c.evidence_at, c.abandoned_at, c.system_canceled_at,
|
|
(SELECT COUNT(*) FROM domains.placements p WHERE p.claim_id = c.claim_id) AS placement_count
|
|
FROM domains.claims c
|
|
WHERE c.workspace_id = $1 AND c.status IN ('pending', 'active')
|
|
ORDER BY c.created_at DESC
|
|
`
|
|
|
|
type ListLiveClaimsByWorkspaceRow struct {
|
|
ClaimID string `json:"claim_id"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
RootFqdn string `json:"root_fqdn"`
|
|
ReversedLabels string `json:"reversed_labels"`
|
|
Kind string `json:"kind"`
|
|
ParentClaimID uuid.NullUUID `json:"parent_claim_id"`
|
|
Status string `json:"status"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
Token string `json:"token"`
|
|
ExpiresAt sql.NullTime `json:"expires_at"`
|
|
VerifiedAt sql.NullTime `json:"verified_at"`
|
|
TxtState string `json:"txt_state"`
|
|
TxtObserved string `json:"txt_observed"`
|
|
ConnectState string `json:"connect_state"`
|
|
ConnectObserved string `json:"connect_observed"`
|
|
LastCheckedAt sql.NullTime `json:"last_checked_at"`
|
|
EvidenceAt sql.NullTime `json:"evidence_at"`
|
|
AbandonedAt sql.NullTime `json:"abandoned_at"`
|
|
SystemCanceledAt sql.NullTime `json:"system_canceled_at"`
|
|
PlacementCount int64 `json:"placement_count"`
|
|
}
|
|
|
|
// The member Domains surface: live claims with the placement count that
|
|
// gates release, counted inline to avoid an N+1 per row.
|
|
func (q *Queries) ListLiveClaimsByWorkspace(ctx context.Context, workspaceID string) ([]ListLiveClaimsByWorkspaceRow, error) {
|
|
rows, err := q.db.QueryContext(ctx, listLiveClaimsByWorkspace, workspaceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListLiveClaimsByWorkspaceRow{}
|
|
for rows.Next() {
|
|
var i ListLiveClaimsByWorkspaceRow
|
|
if err := rows.Scan(
|
|
&i.ClaimID,
|
|
&i.WorkspaceID,
|
|
&i.RootFqdn,
|
|
&i.ReversedLabels,
|
|
&i.Kind,
|
|
&i.ParentClaimID,
|
|
&i.Status,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.Token,
|
|
&i.ExpiresAt,
|
|
&i.VerifiedAt,
|
|
&i.TxtState,
|
|
&i.TxtObserved,
|
|
&i.ConnectState,
|
|
&i.ConnectObserved,
|
|
&i.LastCheckedAt,
|
|
&i.EvidenceAt,
|
|
&i.AbandonedAt,
|
|
&i.SystemCanceledAt,
|
|
&i.PlacementCount,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listLiveClaimsForOperator = `-- name: ListLiveClaimsForOperator :many
|
|
SELECT c.claim_id, c.root_fqdn, c.kind, c.status, c.created_at, c.evidence_at,
|
|
c.workspace_id, w.name AS workspace_name,
|
|
o.org_id, o.name AS org_name,
|
|
(SELECT COUNT(*) FROM domains.placements p WHERE p.claim_id = c.claim_id) AS placement_count
|
|
FROM domains.claims c
|
|
JOIN core.workspaces w ON w.workspace_id = c.workspace_id
|
|
JOIN core.organizations o ON o.org_id = w.org_id
|
|
WHERE c.status IN ('pending', 'active')
|
|
ORDER BY (c.status = 'pending') DESC, c.created_at DESC
|
|
`
|
|
|
|
type ListLiveClaimsForOperatorRow struct {
|
|
ClaimID string `json:"claim_id"`
|
|
RootFqdn string `json:"root_fqdn"`
|
|
Kind string `json:"kind"`
|
|
Status string `json:"status"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
EvidenceAt sql.NullTime `json:"evidence_at"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
WorkspaceName string `json:"workspace_name"`
|
|
OrgID string `json:"org_id"`
|
|
OrgName string `json:"org_name"`
|
|
PlacementCount int64 `json:"placement_count"`
|
|
}
|
|
|
|
// The operator moderation surface (design D7): every live claim in the
|
|
// deployment with the holder attached, pending first and newest first inside
|
|
// each group — a name under contest is a fresh pending claim, so that is the
|
|
// order an operator scans.
|
|
//
|
|
// This is the one query in the stream that reads core (the domains migration
|
|
// grants core_reader to domains_writer for exactly this kind of read). It
|
|
// projects columns rather than whole rows so no core model enters this
|
|
// package's API — sqlc.yaml documents the rename entries a whole-row join
|
|
// would otherwise need.
|
|
func (q *Queries) ListLiveClaimsForOperator(ctx context.Context) ([]ListLiveClaimsForOperatorRow, error) {
|
|
rows, err := q.db.QueryContext(ctx, listLiveClaimsForOperator)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListLiveClaimsForOperatorRow{}
|
|
for rows.Next() {
|
|
var i ListLiveClaimsForOperatorRow
|
|
if err := rows.Scan(
|
|
&i.ClaimID,
|
|
&i.RootFqdn,
|
|
&i.Kind,
|
|
&i.Status,
|
|
&i.CreatedAt,
|
|
&i.EvidenceAt,
|
|
&i.WorkspaceID,
|
|
&i.WorkspaceName,
|
|
&i.OrgID,
|
|
&i.OrgName,
|
|
&i.PlacementCount,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listLiveClaimsInSubtree = `-- name: ListLiveClaimsInSubtree :many
|
|
SELECT claim_id, workspace_id, root_fqdn, reversed_labels, kind, parent_claim_id, status, created_at, updated_at, token, expires_at, verified_at, txt_state, txt_observed, connect_state, connect_observed, last_checked_at, evidence_at, abandoned_at, system_canceled_at FROM domains.claims
|
|
WHERE status IN ('pending', 'active')
|
|
AND reversed_labels LIKE $1::text || '.%'
|
|
ORDER BY reversed_labels
|
|
`
|
|
|
|
// Descendant scan via the reversed-label prefix ("cafe.wiki" matches
|
|
// "cafe.wiki.alice"). Excludes the subtree root itself — an exact-root
|
|
// collision is the unique index's business.
|
|
func (q *Queries) ListLiveClaimsInSubtree(ctx context.Context, reversedRoot string) ([]Claim, error) {
|
|
rows, err := q.db.QueryContext(ctx, listLiveClaimsInSubtree, reversedRoot)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []Claim{}
|
|
for rows.Next() {
|
|
var i Claim
|
|
if err := rows.Scan(
|
|
&i.ClaimID,
|
|
&i.WorkspaceID,
|
|
&i.RootFqdn,
|
|
&i.ReversedLabels,
|
|
&i.Kind,
|
|
&i.ParentClaimID,
|
|
&i.Status,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.Token,
|
|
&i.ExpiresAt,
|
|
&i.VerifiedAt,
|
|
&i.TxtState,
|
|
&i.TxtObserved,
|
|
&i.ConnectState,
|
|
&i.ConnectObserved,
|
|
&i.LastCheckedAt,
|
|
&i.EvidenceAt,
|
|
&i.AbandonedAt,
|
|
&i.SystemCanceledAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listRecentClaimInitiations = `-- name: ListRecentClaimInitiations :many
|
|
SELECT created_at FROM domains.claims
|
|
WHERE workspace_id = $1
|
|
AND kind = 'external'
|
|
AND created_at >= $2::timestamptz
|
|
AND expires_at IS NOT NULL
|
|
AND system_canceled_at IS NULL
|
|
ORDER BY created_at DESC
|
|
LIMIT $3::bigint
|
|
`
|
|
|
|
type ListRecentClaimInitiationsParams struct {
|
|
WorkspaceID string `json:"workspace_id"`
|
|
Since time.Time `json:"since"`
|
|
Budget int64 `json:"budget"`
|
|
}
|
|
|
|
// The breadth budget read (design D5): external claims this workspace started
|
|
// in the rolling window, newest first, capped at the budget. Counted from
|
|
// created_at, so a claim still pending counts exactly like one already given
|
|
// up — the cap bounds how fast a workspace may spray new scopes at all.
|
|
//
|
|
// Newest first for the same reason as ListAbandonmentsInScope: the LAST row is
|
|
// the one whose ageing out reopens the budget, and a workspace can hold more
|
|
// rows than the budget whenever an operator lowers it mid-window.
|
|
//
|
|
// Two kinds of row are NOT initiations and are excluded:
|
|
//
|
|
// - `expires_at IS NULL` is a grandfathered adoption. Boot reconciliation
|
|
// writes an active external claim per pre-registry custom domain (design
|
|
// D8) with no token and no window, so a workspace with ten adopted domains
|
|
// would otherwise be refused every new claim for a day after each restart.
|
|
// Every claim a member initiates carries the window it was created with;
|
|
// no grandfathered claim carries one.
|
|
// - `system_canceled_at IS NOT NULL` is a claim the console rolled back
|
|
// because it could not start the verification workflow. D4 is that a
|
|
// console failure is never charged to the member, and the initiation
|
|
// budget is a charge.
|
|
func (q *Queries) ListRecentClaimInitiations(ctx context.Context, arg ListRecentClaimInitiationsParams) ([]time.Time, error) {
|
|
rows, err := q.db.QueryContext(ctx, listRecentClaimInitiations, arg.WorkspaceID, arg.Since, arg.Budget)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []time.Time{}
|
|
for rows.Next() {
|
|
var created_at time.Time
|
|
if err := rows.Scan(&created_at); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, created_at)
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listTerminalClaimsForOperator = `-- name: ListTerminalClaimsForOperator :many
|
|
SELECT c.claim_id, c.root_fqdn, c.kind, c.status, c.created_at,
|
|
c.evidence_at, c.abandoned_at, c.system_canceled_at,
|
|
c.workspace_id, w.name AS workspace_name,
|
|
o.org_id, o.name AS org_name
|
|
FROM domains.claims c
|
|
JOIN core.workspaces w ON w.workspace_id = c.workspace_id
|
|
JOIN core.organizations o ON o.org_id = w.org_id
|
|
WHERE c.status IN ('expired', 'canceled', 'released')
|
|
ORDER BY c.created_at DESC
|
|
LIMIT 200
|
|
`
|
|
|
|
type ListTerminalClaimsForOperatorRow struct {
|
|
ClaimID string `json:"claim_id"`
|
|
RootFqdn string `json:"root_fqdn"`
|
|
Kind string `json:"kind"`
|
|
Status string `json:"status"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
EvidenceAt sql.NullTime `json:"evidence_at"`
|
|
AbandonedAt sql.NullTime `json:"abandoned_at"`
|
|
SystemCanceledAt sql.NullTime `json:"system_canceled_at"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
WorkspaceName string `json:"workspace_name"`
|
|
OrgID string `json:"org_id"`
|
|
OrgName string `json:"org_name"`
|
|
}
|
|
|
|
// The operator claims ledger section (ux-ia-naming task 9.2): every claim
|
|
// that has left a live state, deployment-wide, newest first. Today only live
|
|
// claims render on the moderation surface; this read makes expired,
|
|
// canceled, and released claims reachable too, in a list segregated from the
|
|
// live one.
|
|
//
|
|
// The three ledger columns ride along unconditionally (not just for external
|
|
// claims) so the handler can decide presentation; blame is read from these
|
|
// columns, never inferred from status (invariant 9 -- status alone says
|
|
// nothing about fault). Capped at 200: this is a page display read, not a
|
|
// business-rule cap like the budget queries above, and terminal rows only
|
|
// accumulate, so an unbounded read would eventually make the section
|
|
// unusable rather than just long.
|
|
func (q *Queries) ListTerminalClaimsForOperator(ctx context.Context) ([]ListTerminalClaimsForOperatorRow, error) {
|
|
rows, err := q.db.QueryContext(ctx, listTerminalClaimsForOperator)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListTerminalClaimsForOperatorRow{}
|
|
for rows.Next() {
|
|
var i ListTerminalClaimsForOperatorRow
|
|
if err := rows.Scan(
|
|
&i.ClaimID,
|
|
&i.RootFqdn,
|
|
&i.Kind,
|
|
&i.Status,
|
|
&i.CreatedAt,
|
|
&i.EvidenceAt,
|
|
&i.AbandonedAt,
|
|
&i.SystemCanceledAt,
|
|
&i.WorkspaceID,
|
|
&i.WorkspaceName,
|
|
&i.OrgID,
|
|
&i.OrgName,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const markClaimActive = `-- name: MarkClaimActive :execrows
|
|
WITH verified AS (
|
|
SELECT workspace_id, reversed_labels
|
|
FROM domains.claims
|
|
WHERE claim_id = $1 AND status = 'pending'
|
|
), cleared AS (
|
|
UPDATE domains.claims c
|
|
SET abandoned_at = NULL, updated_at = NOW()
|
|
FROM verified v
|
|
WHERE c.workspace_id = v.workspace_id
|
|
AND c.abandoned_at IS NOT NULL
|
|
AND (c.reversed_labels = v.reversed_labels OR c.reversed_labels LIKE v.reversed_labels || '.%')
|
|
)
|
|
UPDATE domains.claims activated
|
|
SET status = 'active', verified_at = NOW(), updated_at = NOW()
|
|
WHERE activated.claim_id = $1 AND activated.status = 'pending'
|
|
`
|
|
|
|
// Verification success. Guarded on pending so a late workflow write never
|
|
// reanimates a canceled or expired claim.
|
|
//
|
|
// Proving control wipes this workspace's abandonment slate for the verified
|
|
// name AND ITS OWN SUBTREE (design D3) — deliberately NOT for the wider scope
|
|
// the ledger counts under. The reach is read from the row being activated, so
|
|
// the clear and the activation commit or roll back together.
|
|
//
|
|
// The two widths differ on purpose. Counting by scope is what makes cycling
|
|
// sibling names cost something; clearing by scope would hand that straight
|
|
// back to anyone holding ONE name under a shared root. A workspace that owns
|
|
// attacker.co.uk could burn the co.uk-scoped budget aimed at bakery.co.uk,
|
|
// verify its own name, watch the ledger null out, and cycle forever. Control
|
|
// of one name proves nothing about its siblings, so it clears nothing about
|
|
// them.
|
|
//
|
|
// The two UPDATEs touch disjoint rows: `cleared` only ever matches terminal
|
|
// rows carrying abandoned_at, and the claim being activated is pending, so its
|
|
// abandoned_at is NULL.
|
|
func (q *Queries) MarkClaimActive(ctx context.Context, claimID string) (int64, error) {
|
|
result, err := q.db.ExecContext(ctx, markClaimActive, claimID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected()
|
|
}
|
|
|
|
const markClaimCanceled = `-- name: MarkClaimCanceled :execrows
|
|
UPDATE domains.claims
|
|
SET status = 'canceled', updated_at = NOW(),
|
|
evidence_at = COALESCE(evidence_at, CASE WHEN $2::boolean THEN NOW() END),
|
|
abandoned_at = CASE WHEN evidence_at IS NULL AND NOT $2::boolean THEN NOW() END
|
|
WHERE claim_id = $1 AND status = 'pending'
|
|
`
|
|
|
|
type MarkClaimCanceledParams struct {
|
|
ClaimID string `json:"claim_id"`
|
|
Evidence bool `json:"evidence"`
|
|
}
|
|
|
|
// Member-initiated. Guarded on pending: a cancel that races the active
|
|
// transition loses and updates nothing (design D4).
|
|
//
|
|
// @evidence carries what a fresh cancel-time probe saw (design D5), on the
|
|
// SAME final-probe-decides contract MarkClaimExpired documents above: a
|
|
// member can cancel before any background probe ever ran, so the stored
|
|
// latch alone has nothing to read in that case, and the caller must gather
|
|
// its own evidence immediately before this write. It latches evidence_at on
|
|
// the same COALESCE terms as a probe write, and abandoned_at is stamped only
|
|
// when neither the stored latch nor this final probe found evidence — the
|
|
// evidence-latch parity this comment used to claim without carrying the
|
|
// parameter to back it.
|
|
func (q *Queries) MarkClaimCanceled(ctx context.Context, arg MarkClaimCanceledParams) (int64, error) {
|
|
result, err := q.db.ExecContext(ctx, markClaimCanceled, arg.ClaimID, arg.Evidence)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected()
|
|
}
|
|
|
|
const markClaimCanceledForce = `-- name: MarkClaimCanceledForce :execrows
|
|
UPDATE domains.claims
|
|
SET status = 'canceled', updated_at = NOW(), abandoned_at = NOW()
|
|
WHERE claim_id = $1 AND status = 'pending'
|
|
`
|
|
|
|
// Operator moderation (design D7): the terminal write for a force-released
|
|
// PENDING claim. Stamps the ledger unconditionally, where the member-facing
|
|
// cancel exempts a claim carrying evidence — an operator taking a name back is
|
|
// itself the finding that the claim was unwanted, and a squatter who published
|
|
// a decoy challenge record must not buy immunity from moderation with it.
|
|
// Pending-guarded like every other terminal write: a force-release that loses
|
|
// its race to verification updates nothing.
|
|
func (q *Queries) MarkClaimCanceledForce(ctx context.Context, claimID string) (int64, error) {
|
|
result, err := q.db.ExecContext(ctx, markClaimCanceledForce, claimID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected()
|
|
}
|
|
|
|
const markClaimCanceledSystem = `-- name: MarkClaimCanceledSystem :execrows
|
|
UPDATE domains.claims
|
|
SET status = 'canceled', system_canceled_at = NOW(), updated_at = NOW()
|
|
WHERE claim_id = $1 AND status = 'pending'
|
|
`
|
|
|
|
// Console-initiated rollback: the claim committed but its verification
|
|
// workflow could not be started, so the name is freed again. Deliberately
|
|
// never stamps abandoned_at — a Temporal outage is not the member's fault and
|
|
// must never debit the ledger (design D4).
|
|
//
|
|
// It stamps system_canceled_at instead, which is the same fact stated
|
|
// positively: the ledger is one budget, and ListRecentClaimInitiations is the
|
|
// other. That one counts every external claim this workspace created, so
|
|
// without a mark on the row an outage during ten attempts would still lock the
|
|
// workspace out for a day — charging the member for the console's failure by a
|
|
// different door.
|
|
func (q *Queries) MarkClaimCanceledSystem(ctx context.Context, claimID string) (int64, error) {
|
|
result, err := q.db.ExecContext(ctx, markClaimCanceledSystem, claimID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected()
|
|
}
|
|
|
|
const markClaimExpired = `-- name: MarkClaimExpired :execrows
|
|
UPDATE domains.claims
|
|
SET status = 'expired', updated_at = NOW(),
|
|
evidence_at = COALESCE(evidence_at, CASE WHEN $2::boolean THEN NOW() END),
|
|
abandoned_at = CASE WHEN evidence_at IS NULL AND NOT $2::boolean THEN NOW() END
|
|
WHERE claim_id = $1 AND status = 'pending'
|
|
`
|
|
|
|
type MarkClaimExpiredParams struct {
|
|
ClaimID string `json:"claim_id"`
|
|
Evidence bool `json:"evidence"`
|
|
}
|
|
|
|
// The window elapsed with no TXT match. Records an abandonment unless the
|
|
// claim earned the evidence latch (design D3): a member who published our
|
|
// challenge value demonstrably worked on their DNS and is never charged.
|
|
//
|
|
// @evidence carries what the FINAL probe saw, because the latch's usual
|
|
// recovery ("the next probe re-stamps a lost write") has no next probe here:
|
|
// this statement runs immediately after that probe, and a RecordClaimProbe
|
|
// write that failed would otherwise be paid for by the member. It latches
|
|
// evidence_at on the same COALESCE terms as a probe write, so the row an
|
|
// operator reads afterwards says what was actually observed.
|
|
func (q *Queries) MarkClaimExpired(ctx context.Context, arg MarkClaimExpiredParams) (int64, error) {
|
|
result, err := q.db.ExecContext(ctx, markClaimExpired, arg.ClaimID, arg.Evidence)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected()
|
|
}
|
|
|
|
const markClaimReleased = `-- name: MarkClaimReleased :execrows
|
|
UPDATE domains.claims
|
|
SET status = 'released', updated_at = NOW()
|
|
WHERE claim_id = $1 AND status = 'active'
|
|
`
|
|
|
|
// Terminal state for a claim freed after being active (auto-release of an
|
|
// emptied carved claim, or an explicit member release). Guarded on active
|
|
// so it cannot short-circuit a pending verification.
|
|
func (q *Queries) MarkClaimReleased(ctx context.Context, claimID string) (int64, error) {
|
|
result, err := q.db.ExecContext(ctx, markClaimReleased, claimID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected()
|
|
}
|
|
|
|
const recordClaimProbe = `-- name: RecordClaimProbe :execrows
|
|
UPDATE domains.claims
|
|
SET txt_state = $2, txt_observed = $3,
|
|
connect_state = $4, connect_observed = $5,
|
|
evidence_at = COALESCE(evidence_at, CASE WHEN $6::boolean THEN NOW() END),
|
|
last_checked_at = NOW(), updated_at = NOW()
|
|
WHERE claim_id = $1 AND status = 'pending'
|
|
`
|
|
|
|
type RecordClaimProbeParams struct {
|
|
ClaimID string `json:"claim_id"`
|
|
TxtState string `json:"txt_state"`
|
|
TxtObserved string `json:"txt_observed"`
|
|
ConnectState string `json:"connect_state"`
|
|
ConnectObserved string `json:"connect_observed"`
|
|
Evidence bool `json:"evidence"`
|
|
}
|
|
|
|
// Latest per-record probe result, written by the verification workflow.
|
|
// Guarded on pending so a late probe never touches a terminal row.
|
|
//
|
|
// @evidence latches the first probe that observed OUR challenge value prefix
|
|
// at the challenge name (design D2); the workflow computes it, because only
|
|
// the raw observations distinguish our record from anything else published
|
|
// there. COALESCE makes the latch sticky and the write idempotent, so a lost
|
|
// write is recovered by the next probe.
|
|
func (q *Queries) RecordClaimProbe(ctx context.Context, arg RecordClaimProbeParams) (int64, error) {
|
|
result, err := q.db.ExecContext(ctx, recordClaimProbe,
|
|
arg.ClaimID,
|
|
arg.TxtState,
|
|
arg.TxtObserved,
|
|
arg.ConnectState,
|
|
arg.ConnectObserved,
|
|
arg.Evidence,
|
|
)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected()
|
|
}
|