Files
member-console/internal/domains/migrations/00001_init.sql
T
cgalo5758 dd3962990b Adopt entity keys and add invoice numbers
Replace the entity slugs on organizations, workspaces, resource pools,
and
plan ladders with nullable `key` columns and add keys to products,
prices,
and entitlement sets. Rename `providers.slug` to `provider` and add
partial
unique indexes for system and org role names.

Assign invoice numbers per billing account from a gapless transactional
counter; Stripe's number moves to the invoice mapping as an external
reference.

Seeds, fixtures, and the operator lookup address rows by key, and the
returning-login resync no longer blanks a display name when the IdP
sends
no `name` claim.
2026-08-29 20:12:04 -05:00

260 lines
12 KiB
SQL

-- +goose Up
-- +goose StatementBegin
-- Domain registry (domains-registry, M10): the single authority for the
-- deployment's DNS namespace. `claims` are disjoint subtrees with one live
-- owner each; `placements` bind an exact name inside a claim to a
-- provider-qualified resource; `name_rules` carry operator label policy.
-- FKs reference core.workspaces and core.providers only (Decision 2 — the
-- FK DAG is one-directional; this stream depends on core, never the
-- reverse, and never on an integration schema).
--
-- Role triple (Decision 7 — self-contained DB ownership): this stream
-- creates and grants its own domains_owner/writer/reader. Core always runs
-- first (core -> domains -> integrations, per internal/migrate/sources.go),
-- so core_reader exists by the time the cross-schema grant below runs. The
-- reverse runtime edge — fedwiki activities writing domains.* — is granted
-- by the fedwiki stream (fedwiki roles do not exist yet on a fresh install
-- at this point).
--
-- Roles are cluster-global, not database-scoped, so creation is guarded:
-- a second database in the same cluster must not abort this stream.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'domains_owner') THEN
CREATE ROLE domains_owner NOLOGIN;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'domains_writer') THEN
CREATE ROLE domains_writer NOLOGIN;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'domains_reader') THEN
CREATE ROLE domains_reader NOLOGIN;
END IF;
END
$$;
CREATE SCHEMA domains;
-- A claim denotes a root name AND its entire subtree. Three kinds:
-- `operator_root` (a deployment shared domain, system-owned), `member`
-- (carved strictly inside exactly one live operator root, which it records
-- as its parent), and `external` (bring-your-own, proven by DNS
-- verification).
--
-- Claims subsume the retired fedwiki.custom_domain_verifications table: the
-- verification row IS a pending claim, so the probe columns, their
-- CHECK-guarded state vocabulary, and the live-name partial unique index
-- carry over verbatim. Non-external claims are born 'active' and never
-- carry a token. `released` is the terminal state for a claim freed after
-- being active; 'expired'/'canceled' preserve verification history.
--
-- `reversed_labels` is root_fqdn with its labels reversed
-- ("alice.wiki.cafe" -> "cafe.wiki.alice"), giving descendant queries a
-- text_pattern_ops prefix scan (WHERE reversed_labels LIKE $root || '.%').
-- Ancestor checks walk the candidate's suffixes with exact root lookups.
CREATE TABLE domains.claims (
claim_id UUID PRIMARY KEY DEFAULT uuidv7(),
workspace_id UUID NOT NULL REFERENCES core.workspaces(workspace_id),
root_fqdn TEXT NOT NULL,
reversed_labels TEXT NOT NULL,
kind TEXT NOT NULL,
parent_claim_id UUID REFERENCES domains.claims(claim_id),
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Verification lifecycle. Populated for `external` claims only; the
-- other kinds keep the ''/NULL defaults. *_observed holds the values
-- the resolver actually returned, newline-joined (the store is
-- database/sql — no array columns), so the UI can show
-- observed-vs-expected on a mismatch.
token TEXT NOT NULL DEFAULT '',
expires_at TIMESTAMPTZ,
verified_at TIMESTAMPTZ,
txt_state TEXT NOT NULL DEFAULT 'unchecked',
txt_observed TEXT NOT NULL DEFAULT '',
connect_state TEXT NOT NULL DEFAULT 'unchecked',
connect_observed TEXT NOT NULL DEFAULT '',
last_checked_at TIMESTAMPTZ,
CONSTRAINT claims_kind_valid
CHECK (kind IN ('operator_root', 'member', 'external')),
CONSTRAINT claims_status_valid
CHECK (status IN ('pending', 'active', 'expired', 'canceled', 'released')),
CONSTRAINT claims_txt_state_valid
CHECK (txt_state IN ('unchecked', 'missing', 'mismatch', 'match', 'error')),
CONSTRAINT claims_connect_state_valid
CHECK (connect_state IN ('unchecked', 'missing', 'mismatch', 'match', 'error')),
-- Exactly the `member` kind carves out of a parent root; operator roots
-- and external claims are top-level by construction.
CONSTRAINT claims_parent_matches_kind
CHECK ((kind = 'member') = (parent_claim_id IS NOT NULL))
);
-- One live claim per exact root: pending/active rows hold the name,
-- terminal rows free it for re-claiming. Last-resort arbiter for
-- exact-root races that slip past the registry advisory lock.
CREATE UNIQUE INDEX idx_claims_live_root
ON domains.claims (root_fqdn)
WHERE status IN ('pending', 'active');
-- Descendant scans (does any live claim sit inside this subtree?).
-- text_pattern_ops is required for LIKE prefix matching to use the index
-- under a non-C collation.
CREATE INDEX idx_claims_live_reversed_labels
ON domains.claims (reversed_labels text_pattern_ops)
WHERE status IN ('pending', 'active');
-- Member-facing claim lists and the per-workspace pending cap.
CREATE INDEX idx_claims_workspace_id ON domains.claims (workspace_id);
CREATE TRIGGER trigger_claims_updated_at
BEFORE UPDATE ON domains.claims
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
-- A placement binds one exact name inside a claim's subtree (the root
-- itself included — the apex placement) to a provider-qualified resource.
-- `resource_ref` is opaque and provider-scoped (fedwiki: site_id as text) —
-- deliberately not a foreign key into provider storage, so the registry
-- stays generic. `servable` is provider-maintained: the registry cannot
-- join per-provider lifecycle tables, so the owning integration flips it
-- (fedwiki: active/readonly => TRUE, archived => FALSE), making
-- /domains/ask a single indexed lookup by fqdn.
--
-- The UNIQUE on fqdn is total, not partial: placements are hard-deleted on
-- release (the registry stores current state, not history).
CREATE TABLE domains.placements (
placement_id UUID PRIMARY KEY DEFAULT uuidv7(),
claim_id UUID NOT NULL REFERENCES domains.claims(claim_id),
fqdn TEXT UNIQUE NOT NULL,
reversed_labels TEXT NOT NULL,
-- References core.providers' primary key without naming the column: the
-- core stream runs to its head before this stream's 00001 does, and core
-- 00014 renamed that column from `slug` to `provider`. Naming no column
-- keeps this baseline correct across that rename and any future one.
provider TEXT NOT NULL REFERENCES core.providers,
resource_ref TEXT NOT NULL,
servable BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Auto-release re-counts a claim's placements under the registry lock.
CREATE INDEX idx_placements_claim_id ON domains.placements (claim_id);
-- Provider-side lifecycle writes (servability flips, delete sweeps) address
-- placements by their own resource identity, not by name.
CREATE INDEX idx_placements_provider_resource_ref
ON domains.placements (provider, resource_ref);
-- Descendant scans on placements as well as claims: an operator placement
-- inside a candidate subtree blocks carving it, which a claims-only column
-- cannot answer without scanning an operator root's whole placement set.
CREATE INDEX idx_placements_reversed_labels
ON domains.placements (reversed_labels text_pattern_ops);
CREATE TRIGGER trigger_placements_updated_at
BEFORE UPDATE ON domains.placements
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
-- Operator name policy: exact labels, scoped to one operator root or global
-- (root_claim_id NULL). The registry ships no built-in word blocklist —
-- mechanism, not list; deployments seed their own rows. Policy applies at
-- allocation time only, so new rules never invalidate existing claims.
CREATE TABLE domains.name_rules (
rule_id UUID PRIMARY KEY DEFAULT uuidv7(),
root_claim_id UUID REFERENCES domains.claims(claim_id),
label TEXT NOT NULL,
kind TEXT NOT NULL,
note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT name_rules_kind_valid
CHECK (kind IN ('reserved', 'blocked', 'premium')),
-- NULLS NOT DISTINCT (PG 15+; deployment runs 18) so the global scope
-- admits one rule per label rather than unbounded duplicates.
CONSTRAINT name_rules_scope_label_unique
UNIQUE NULLS NOT DISTINCT (root_claim_id, label)
);
-- Per-schema role grants.
GRANT USAGE ON SCHEMA domains TO domains_reader, domains_writer, domains_owner;
GRANT CREATE ON SCHEMA domains TO domains_owner;
GRANT ALL ON ALL TABLES IN SCHEMA domains TO domains_owner;
GRANT ALL ON ALL TABLES IN SCHEMA domains TO domains_writer;
GRANT SELECT ON ALL TABLES IN SCHEMA domains TO domains_reader;
-- Cross-schema reader inheritance: registry tables FK into core.workspaces
-- and core.providers, and this stream's code paths read core tables
-- (Decision 7).
GRANT core_reader TO domains_writer;
-- member_console is the DSN login role; state its application-level
-- privilege explicitly. Owner roles stay reserved for migrations
-- (Decision 7).
GRANT domains_writer TO member_console;
-- The external-domain gate's resource key. Platform-owned: `provider` stays
-- NULL and the name is deliberately NOT `domains_`-prefixed — platform keys
-- are unprefixed by the provider-registry contract, and a leading `domains_`
-- token would permanently foreclose `domains` as a provider slug
-- (slug-nesting rule, internal/integration/registration.go). `kind`
-- declared explicitly per core 00008.
-- ON CONFLICT: idempotent so a manual dev-stream rewind (drop schema +
-- delete goose row) replays cleanly once the key already exists.
INSERT INTO core.resource_keys (resource_key, display_name, description, unit, kind)
VALUES ('external_domain_claims', 'External Domains', 'Ability to verify and use member-owned domains.', 'flag', 'boolean')
ON CONFLICT (resource_key) DO NOTHING;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
-- Precondition (same contract as fedwiki 00002's Down): entitlement rules
-- and materialized entitlements referencing this key must be removed first —
-- their FKs have no CASCADE, so this DELETE fails (rolling the Down back)
-- rather than silently orphaning operator-authored rules.
DELETE FROM core.resource_keys WHERE resource_key = 'external_domain_claims';
REVOKE domains_writer FROM member_console;
REVOKE core_reader FROM domains_writer;
REVOKE ALL ON ALL TABLES IN SCHEMA domains FROM domains_owner;
REVOKE ALL ON ALL TABLES IN SCHEMA domains FROM domains_writer;
REVOKE ALL ON ALL TABLES IN SCHEMA domains FROM domains_reader;
REVOKE ALL ON SCHEMA domains FROM domains_reader, domains_writer, domains_owner;
DROP TABLE IF EXISTS domains.name_rules;
DROP TRIGGER IF EXISTS trigger_placements_updated_at ON domains.placements;
DROP TABLE IF EXISTS domains.placements;
DROP TRIGGER IF EXISTS trigger_claims_updated_at ON domains.claims;
DROP TABLE IF EXISTS domains.claims;
DROP SCHEMA IF EXISTS domains;
-- Roles are cluster-global: another database in this cluster may still own
-- objects or hold grants under them, in which case DROP ROLE raises
-- dependent_objects_still_exist. Leave such a role standing rather than
-- failing this database's rollback over another database's state.
DO $$
DECLARE
role_name TEXT;
BEGIN
FOREACH role_name IN ARRAY ARRAY['domains_reader', 'domains_writer', 'domains_owner'] LOOP
BEGIN
EXECUTE format('DROP ROLE IF EXISTS %I', role_name);
EXCEPTION WHEN dependent_objects_still_exist THEN
RAISE NOTICE 'role % is still in use elsewhere in this cluster; left in place', role_name;
END;
END LOOP;
END
$$;
-- +goose StatementEnd