diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index d193ae162..dee40e988 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -37,7 +37,7 @@ pub use error::VerificationError; pub use event::StoredEvent; pub use nostr::{Event, EventId, Filter, Keys, Kind, PublicKey}; pub use presence::PresenceStatus; -pub use tenant::{CommunityId, TenantContext}; +pub use tenant::{normalize_host, CommunityId, TenantContext}; pub use verification::verify_event; #[cfg(any(test, feature = "test-utils"))] diff --git a/crates/buzz-core/src/tenant.rs b/crates/buzz-core/src/tenant.rs index 32cec5d80..e7dbb630a 100644 --- a/crates/buzz-core/src/tenant.rs +++ b/crates/buzz-core/src/tenant.rs @@ -10,12 +10,19 @@ //! model (conformance "row zero"): a request's community is *resolved from the //! connection host by the server*, never supplied or influenced by the client. //! -//! [`TenantContext`] encodes that invariant in the type system. It has no -//! `Default`, no `Deserialize`, and no public constructor other than -//! [`TenantContext::resolved`], which is meant to be called *only* from the -//! host-resolution path. Downstream code receives `&TenantContext` and can read -//! the community but cannot mint one — so "the client chose this community" -//! cannot type-check anywhere outside resolution. +//! [`TenantContext`] expresses that invariant in the type system as far as the +//! type system can carry it: there is no `Default`, no `Deserialize`, and no +//! way to *parse* a community from client input. A `CommunityId` only ever +//! comes from host resolution or from a DB row the server already scoped. +//! +//! This is a **lint-and-review fence, not a compiler fence.** +//! [`TenantContext::resolved`] and [`CommunityId::from_uuid`] are public so the +//! host-resolution path (in another crate) can call them — which means a +//! determined caller elsewhere *could* call them too. The migration-lint +//! harness forbids constructing a `TenantContext` outside host resolution and +//! tests; the type only removes the *accidental* path (deserializing a +//! client-chosen community), and review/lint closes the deliberate one. We say +//! this plainly rather than overclaim a guarantee the `pub` API doesn't give. use std::fmt; use uuid::Uuid; @@ -90,6 +97,46 @@ impl TenantContext { } } +/// Normalize a connection `Host` into the canonical form used as the community +/// lookup key. +/// +/// This is the *one* normalization rule shared by both sides of the fence: +/// the `communities.host` column is stored already-normalized, and host +/// resolution normalizes the incoming `Host` header with this same function +/// before looking it up. Because both sides agree by construction, +/// `Relay.Example`, `relay.example.`, and `relay.example:443` all resolve to +/// the one community — they can never split into distinct tenants. +/// +/// Rules (host only — the caller has already split off any path/scheme): +/// - ASCII-lowercase (hosts are case-insensitive per RFC 3986); +/// - strip a single trailing dot (the FQDN root label); +/// - strip a default port suffix (`:80`, `:443`) — non-default ports are kept, +/// since a deployment may legitimately serve different communities on +/// different ports of the same name. +/// +/// The input is trimmed of surrounding whitespace. An empty result (e.g. the +/// caller passed `""`) is returned as-is; resolution treats an empty or +/// unmapped host as a fail-closed rejection, never a default tenant. +#[must_use] +pub fn normalize_host(host: &str) -> String { + let host = host.trim(); + let mut host = host.to_ascii_lowercase(); + // Strip default ports. We only touch a `:port` suffix that is exactly a + // default port, so IPv6 literals like `[::1]` (which contain colons but no + // trailing `:80`/`:443`) are left intact. + if let Some(stripped) = host + .strip_suffix(":443") + .or_else(|| host.strip_suffix(":80")) + { + host = stripped.to_string(); + } + // Strip a single trailing FQDN-root dot. + if let Some(stripped) = host.strip_suffix('.') { + host = stripped.to_string(); + } + host +} + #[cfg(test)] mod tests { use super::*; @@ -109,4 +156,44 @@ mod tests { assert_eq!(ctx.community().as_uuid(), &u); assert_eq!(ctx.host(), "relay.example"); } + + #[test] + fn normalize_host_collapses_tenant_split_variants() { + // All of these are the SAME tenant and must normalize identically — + // this is the property that stops accidental split-tenant. + let canonical = "relay.example"; + for variant in [ + "relay.example", + "Relay.Example", + "RELAY.EXAMPLE", + "relay.example.", // trailing FQDN root dot + "relay.example:443", // default https port + "relay.example:80", // default http port + "Relay.Example.:443", + " relay.example ", // surrounding whitespace + ] { + assert_eq!(normalize_host(variant), canonical, "variant {variant:?}"); + } + } + + #[test] + fn normalize_host_keeps_nondefault_port() { + // A non-default port is a legitimate distinct selector — keep it. + assert_eq!(normalize_host("relay.example:8443"), "relay.example:8443"); + assert_eq!(normalize_host("relay.example:3000"), "relay.example:3000"); + } + + #[test] + fn normalize_host_leaves_ipv6_literal_intact() { + // IPv6 literals contain colons but no trailing default-port suffix. + assert_eq!(normalize_host("[::1]"), "[::1]"); + assert_eq!(normalize_host("[::1]:443"), "[::1]"); + } + + #[test] + fn normalize_host_empty_stays_empty() { + // Empty / whitespace-only resolves to empty; resolution fails closed. + assert_eq!(normalize_host(""), ""); + assert_eq!(normalize_host(" "), ""); + } } diff --git a/migrations/0001_initial_schema.sql b/migrations/0001_initial_schema.sql index 93644cba2..edda65c77 100644 --- a/migrations/0001_initial_schema.sql +++ b/migrations/0001_initial_schema.sql @@ -1,6 +1,25 @@ --- Buzz initial Postgres schema. +-- Buzz initial Postgres schema — multi-tenant. -- --- This migration is the source of truth for fresh database setup. +-- Source of truth for fresh database setup. This is a clean, from-scratch +-- schema in which `community_id` is a first-class, server-resolved key on +-- every tenant-scoped row. It is NOT additive over the single-community +-- schema; the rewrite replaces it. Existing single-community deployments +-- migrate via the documented backfill migration (0002), which assigns all +-- pre-existing rows to one default community. +-- +-- The governing contract is docs/multi-tenant-conformance.md. Every table +-- below cites the conformance surface it implements. The invariant behind the +-- whole schema (conformance "row zero"): a request's community is resolved +-- from the connection host by the server, never supplied by the client, and +-- every scoped row carries that immutable `community_id`. +-- +-- Migration-lint obligations enforced by the Lane 0 lint harness: +-- 1. Every tenant-scoped table has `community_id NOT NULL`. +-- 2. No UNIQUE / PRIMARY KEY / FK on a scoped table is observable across +-- communities: each leads with `community_id` (or, for child rows whose +-- parent already pins the community, joins carry the community tuple). +-- 3. `channels.community_id` is immutable (trigger below; no UPDATE path). +-- 4. Operator-global tables are named in the explicit allowlist, not implied. CREATE EXTENSION IF NOT EXISTS pgcrypto; @@ -17,10 +36,42 @@ CREATE TYPE subscription_status AS ENUM ('active', 'paused', 'deleted'); CREATE TYPE pause_reason AS ENUM ('user', 'system', 'rate_limit'); CREATE TYPE channel_add_policy AS ENUM ('anyone', 'owner_only', 'nobody'); +-- ── Communities ─────────────────────────────────────────────────────────────── +-- Conformance: row zero (host binding). The host map. `resolve_host(host)` +-- reads exactly one row here to mint the request's TenantContext. This table +-- is OPERATOR-GLOBAL: it is the registry of tenants, not itself tenant-scoped, +-- so it carries no `community_id` of its own (its `id` IS the community key). +-- Listed in the lint allowlist as operator-global. +-- +-- Host normalization (Lane 0 contract): `host` is stored already-normalized — +-- ASCII-lowercased, trailing dot stripped, default port omitted. The UNIQUE is +-- on `lower(host)` belt-and-suspenders so `Relay.Example` and `relay.example` +-- can never become two tenants even if a writer forgets to normalize. +-- `resolve_host()` (buzz-core) applies the identical normalization before +-- lookup, so resolution and storage agree by construction. + +CREATE TABLE communities ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + host VARCHAR(255) NOT NULL, + signing_key BYTEA, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_communities_id_not_nil CHECK (id <> '00000000-0000-0000-0000-000000000000'::uuid) +); + +CREATE UNIQUE INDEX idx_communities_host ON communities (lower(host)); + -- ── Channels ────────────────────────────────────────────────────────────────── +-- Conformance: "Channels and channel membership". `community_id` immutable. +-- Channel UUIDs stay valid wire identifiers, but they are NOT globally unique: +-- the PK is `(community_id, id)`, so the same UUID may legitimately exist in two +-- communities (conformance lists "same channel UUID collision in two +-- communities" as a required isolation test). Handlers always carry `ctx`, so +-- `(ctx.community, h)` names exactly one channel; a client-supplied `h` can +-- never reach another community's channel. CREATE TABLE channels ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + id UUID NOT NULL DEFAULT gen_random_uuid(), + community_id UUID NOT NULL REFERENCES communities(id), name VARCHAR(255) NOT NULL, channel_type channel_type NOT NULL DEFAULT 'stream', visibility channel_visibility NOT NULL DEFAULT 'open', @@ -31,7 +82,7 @@ CREATE TABLE channels ( updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), archived_at TIMESTAMPTZ, deleted_at TIMESTAMPTZ, - nip29_group_id VARCHAR(255) UNIQUE, + nip29_group_id VARCHAR(255), topic_required BOOLEAN NOT NULL DEFAULT FALSE, max_members INT, topic TEXT, @@ -43,20 +94,44 @@ CREATE TABLE channels ( participant_hash BYTEA, ttl_seconds INT, ttl_deadline TIMESTAMPTZ, + PRIMARY KEY (community_id, id), CONSTRAINT chk_channels_id_not_nil CHECK (id <> '00000000-0000-0000-0000-000000000000'::uuid) ); -CREATE INDEX idx_channels_type ON channels (channel_type); -CREATE INDEX idx_channels_visibility ON channels (visibility); -CREATE INDEX idx_channels_created_by ON channels (created_by); -CREATE UNIQUE INDEX idx_channels_dm_hash ON channels (participant_hash); +-- nip29 group id and DM participant hash are unique WITHIN a community, not globally. +CREATE UNIQUE INDEX idx_channels_nip29_group ON channels (community_id, nip29_group_id) + WHERE nip29_group_id IS NOT NULL; +CREATE UNIQUE INDEX idx_channels_dm_hash ON channels (community_id, participant_hash) + WHERE participant_hash IS NOT NULL; +CREATE INDEX idx_channels_community_type ON channels (community_id, channel_type); +CREATE INDEX idx_channels_community_visibility ON channels (community_id, visibility); +CREATE INDEX idx_channels_created_by ON channels (community_id, created_by); CREATE INDEX idx_channels_ttl_expiry ON channels (ttl_deadline) WHERE ttl_seconds IS NOT NULL AND archived_at IS NULL AND deleted_at IS NULL; +-- channels.community_id is immutable: a channel can never be re-tenanted. +-- (Conformance: "Migration lint forbids channel re-tenanting except through an +-- explicitly modeled admission path." We have no such path, so: hard block.) +CREATE FUNCTION channels_community_id_immutable() RETURNS TRIGGER AS $$ +BEGIN + IF NEW.community_id IS DISTINCT FROM OLD.community_id THEN + RAISE EXCEPTION 'channels.community_id is immutable (channel % cannot be re-tenanted)', OLD.id + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_channels_community_id_immutable + BEFORE UPDATE ON channels + FOR EACH ROW EXECUTE FUNCTION channels_community_id_immutable(); + -- ── Channel members ─────────────────────────────────────────────────────────── +-- Conformance: "Channels and channel membership". PK leads with community_id. CREATE TABLE channel_members ( - channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + community_id UUID NOT NULL REFERENCES communities(id), + channel_id UUID NOT NULL, pubkey BYTEA NOT NULL, role member_role NOT NULL DEFAULT 'member', joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), @@ -64,35 +139,56 @@ CREATE TABLE channel_members ( removed_at TIMESTAMPTZ, removed_by BYTEA, hidden_at TIMESTAMPTZ, - PRIMARY KEY (channel_id, pubkey) + PRIMARY KEY (community_id, channel_id, pubkey), + FOREIGN KEY (community_id, channel_id) + REFERENCES channels (community_id, id) ON DELETE CASCADE ); -CREATE INDEX idx_channel_members_pubkey ON channel_members (pubkey) +CREATE INDEX idx_channel_members_pubkey ON channel_members (community_id, pubkey) WHERE removed_at IS NULL; -- ── Users ───────────────────────────────────────────────────────────────────── +-- Conformance: "Users, profiles, NIP-05, and user search". One profile per +-- (community, pubkey): the same key reposts kind:0 in each community it joins. CREATE TABLE users ( - pubkey BYTEA PRIMARY KEY, - nip05_handle VARCHAR(255) UNIQUE, + community_id UUID NOT NULL REFERENCES communities(id), + pubkey BYTEA NOT NULL, + nip05_handle VARCHAR(255), display_name VARCHAR(255), avatar_url TEXT, about TEXT, agent_type VARCHAR(255), capabilities JSONB, - okta_user_id VARCHAR(255) UNIQUE, + okta_user_id VARCHAR(255), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), deactivated_at TIMESTAMPTZ, metadata_event_id BYTEA, - agent_owner_pubkey BYTEA REFERENCES users(pubkey) ON DELETE SET NULL, + agent_owner_pubkey BYTEA, channel_add_policy channel_add_policy NOT NULL DEFAULT 'anyone', - CONSTRAINT chk_users_pubkey_len CHECK (LENGTH(pubkey) = 32) + PRIMARY KEY (community_id, pubkey), + CONSTRAINT chk_users_pubkey_len CHECK (LENGTH(pubkey) = 32), + -- agent owner is a user in the SAME community. + FOREIGN KEY (community_id, agent_owner_pubkey) + REFERENCES users (community_id, pubkey) ON DELETE SET NULL ); +-- NIP-05 handle and Okta id unique within a community, not globally. +CREATE UNIQUE INDEX idx_users_nip05 ON users (community_id, lower(nip05_handle)) + WHERE nip05_handle IS NOT NULL; +CREATE UNIQUE INDEX idx_users_okta ON users (community_id, okta_user_id) + WHERE okta_user_id IS NOT NULL; + -- ── Events (partitioned by month on created_at) ────────────────────────────── +-- Conformance: "Channel-less global events and DMs". `community_id` leads the +-- PK and every hot-path index. Partition stays BY RANGE (created_at) — the +-- monthly partition manager is unchanged (Max's call, plan §5/Lane0 contract). +-- Cross-community dedup: same signed event may exist in two communities; +-- (community_id, created_at, id) dedupes within one, allows across. CREATE TABLE events ( + community_id UUID NOT NULL REFERENCES communities(id), id BYTEA NOT NULL, pubkey BYTEA NOT NULL, created_at TIMESTAMPTZ NOT NULL, @@ -104,7 +200,9 @@ CREATE TABLE events ( channel_id UUID, deleted_at TIMESTAMPTZ, d_tag TEXT, - PRIMARY KEY (created_at, id) + not_before BIGINT, + delivered_at BIGINT, + PRIMARY KEY (community_id, created_at, id) ) PARTITION BY RANGE (created_at); CREATE TABLE events_p_past PARTITION OF events @@ -124,33 +222,55 @@ CREATE TABLE events_p2026_06 PARTITION OF events CREATE TABLE events_p_future PARTITION OF events FOR VALUES FROM ('2026-07-01') TO (MAXVALUE); -CREATE INDEX idx_events_pubkey_kind_created ON events (pubkey, kind, created_at); -CREATE INDEX idx_events_channel_created ON events (channel_id, created_at); -CREATE INDEX idx_events_kind_created ON events (kind, created_at); -CREATE INDEX idx_events_id ON events (id); -CREATE INDEX idx_events_deleted ON events (deleted_at); -CREATE INDEX idx_events_addressable ON events (kind, pubkey, channel_id, deleted_at); -CREATE INDEX idx_events_parameterized ON events (kind, pubkey, d_tag, deleted_at) WHERE d_tag IS NOT NULL; +-- Direct id lookup: the PK can't serve `WHERE id=$1` because created_at sits +-- between community_id and id. This index makes the scoped form +-- `WHERE community_id=$ AND id=$` index-served, not a partition scan. +CREATE INDEX idx_events_community_id ON events (community_id, id, created_at DESC); +-- Hot-path indexes, all community-leading. +CREATE INDEX idx_events_community_channel_created + ON events (community_id, channel_id, created_at DESC, id); +CREATE INDEX idx_events_community_pubkey_kind_created + ON events (community_id, pubkey, kind, created_at DESC, id); +CREATE INDEX idx_events_community_kind_created + ON events (community_id, kind, created_at DESC, id); +CREATE INDEX idx_events_community_deleted ON events (community_id, deleted_at); +-- Addressable (replaceable) and NIP-33 parameterized lookups. +CREATE INDEX idx_events_addressable + ON events (community_id, kind, pubkey, channel_id, deleted_at); +CREATE INDEX idx_events_parameterized + ON events (community_id, kind, pubkey, d_tag, created_at DESC, id) + WHERE d_tag IS NOT NULL AND deleted_at IS NULL; +CREATE INDEX idx_events_not_before ON events (community_id, not_before) + WHERE not_before IS NOT NULL AND deleted_at IS NULL AND delivered_at IS NULL; -- ── Event mentions ──────────────────────────────────────────────────────────── +-- Conformance: "Channel-less global events and DMs" (#p fan-out). The join to +-- events MUST carry the community tuple (e.community_id = m.community_id AND +-- e.id = m.event_id) — bare e.id = m.event_id would leak cross-community +-- mentions (Max, verified at event.rs:222). CREATE TABLE event_mentions ( + community_id UUID NOT NULL REFERENCES communities(id), pubkey_hex VARCHAR(64) NOT NULL, event_id BYTEA NOT NULL, event_created_at TIMESTAMPTZ NOT NULL, channel_id UUID, event_kind INT, - PRIMARY KEY (pubkey_hex, event_id) + PRIMARY KEY (community_id, pubkey_hex, event_id) ); -CREATE INDEX idx_event_mentions_pubkey_created ON event_mentions (pubkey_hex, event_created_at DESC); -CREATE INDEX idx_event_mentions_pubkey_kind_created ON event_mentions (pubkey_hex, event_kind, event_created_at DESC); +CREATE INDEX idx_event_mentions_pubkey_created + ON event_mentions (community_id, pubkey_hex, event_created_at DESC); +CREATE INDEX idx_event_mentions_pubkey_kind_created + ON event_mentions (community_id, pubkey_hex, event_kind, event_created_at DESC); -- ── Subscriptions ───────────────────────────────────────────────────────────── +-- Conformance: "Mesh, agents, ACP/MCP, and CLI" (persisted subscriptions). CREATE TABLE subscriptions ( - id VARCHAR(255) PRIMARY KEY, - owner_pubkey BYTEA NOT NULL REFERENCES users(pubkey), + community_id UUID NOT NULL REFERENCES communities(id), + id VARCHAR(255) NOT NULL, + owner_pubkey BYTEA NOT NULL, filter_kinds JSONB, filter_authors JSONB, filter_channel_ids JSONB, @@ -163,12 +283,17 @@ CREATE TABLE subscriptions ( delivered_count BIGINT NOT NULL DEFAULT 0, error_count BIGINT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, id), + FOREIGN KEY (community_id, owner_pubkey) REFERENCES users (community_id, pubkey) ); -- ── Delivery log (partitioned by month on delivered_at) ────────────────────── +-- Conformance: subscription delivery audit. community_id carried for tenant +-- attribution; child of subscriptions. CREATE TABLE delivery_log ( + community_id UUID NOT NULL REFERENCES communities(id), id BIGINT GENERATED ALWAYS AS IDENTITY, subscription_id VARCHAR(255), event_id BYTEA, @@ -194,28 +319,40 @@ CREATE TABLE delivery_log_p2026_06 PARTITION OF delivery_log CREATE TABLE delivery_log_p_future PARTITION OF delivery_log FOR VALUES FROM ('2026-07-01') TO (MAXVALUE); +CREATE INDEX idx_delivery_log_community_sub ON delivery_log (community_id, subscription_id); + -- ── Workflows ───────────────────────────────────────────────────────────────── +-- Conformance: "Workflows, runs, approvals, webhooks, schedules". Definition's +-- community fixed at create from req.community; runs/approvals inherit it. CREATE TABLE workflows ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + community_id UUID NOT NULL REFERENCES communities(id), + id UUID NOT NULL DEFAULT gen_random_uuid(), name VARCHAR(255) NOT NULL, - owner_pubkey BYTEA NOT NULL REFERENCES users(pubkey), - channel_id UUID REFERENCES channels(id), + owner_pubkey BYTEA NOT NULL, + channel_id UUID, definition JSONB NOT NULL, definition_hash BYTEA NOT NULL, status workflow_status NOT NULL DEFAULT 'active', enabled BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, id), + FOREIGN KEY (community_id, owner_pubkey) REFERENCES users (community_id, pubkey), + FOREIGN KEY (community_id, channel_id) REFERENCES channels (community_id, id) ); -CREATE INDEX idx_workflows_channel_active ON workflows (channel_id, status, enabled); +CREATE INDEX idx_workflows_channel_active ON workflows (community_id, channel_id, status, enabled); +-- Scheduler scans enabled schedule workflows; community_id returned per row so +-- side effects run under the owning tenant's context (Lane0 contract §4a.5). +CREATE INDEX idx_workflows_enabled ON workflows (enabled, status) WHERE enabled; -- ── Workflow runs ───────────────────────────────────────────────────────────── CREATE TABLE workflow_runs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - workflow_id UUID NOT NULL REFERENCES workflows(id) ON DELETE CASCADE, + community_id UUID NOT NULL REFERENCES communities(id), + id UUID NOT NULL DEFAULT gen_random_uuid(), + workflow_id UUID NOT NULL, status run_status NOT NULL DEFAULT 'pending', trigger_event_id BYTEA, current_step INT NOT NULL DEFAULT 0, @@ -224,18 +361,24 @@ CREATE TABLE workflow_runs ( started_at TIMESTAMPTZ, completed_at TIMESTAMPTZ, error_message TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, id), + FOREIGN KEY (community_id, workflow_id) + REFERENCES workflows (community_id, id) ON DELETE CASCADE ); -CREATE INDEX idx_workflow_runs_workflow ON workflow_runs (workflow_id); -CREATE INDEX idx_workflow_runs_status ON workflow_runs (status); +CREATE INDEX idx_workflow_runs_workflow ON workflow_runs (community_id, workflow_id); +CREATE INDEX idx_workflow_runs_status ON workflow_runs (community_id, status); -- ── Workflow approvals ──────────────────────────────────────────────────────── +-- token-hash lookup scoped: approval token grants cannot act on another +-- community's same hash (conformance). CREATE TABLE workflow_approvals ( - token BYTEA PRIMARY KEY, - workflow_id UUID NOT NULL REFERENCES workflows(id) ON DELETE CASCADE, - run_id UUID NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE, + community_id UUID NOT NULL REFERENCES communities(id), + token BYTEA NOT NULL, + workflow_id UUID NOT NULL, + run_id UUID NOT NULL, step_id VARCHAR(64) NOT NULL, step_index INT NOT NULL, approver_spec TEXT NOT NULL, @@ -245,19 +388,47 @@ CREATE TABLE workflow_approvals ( granted_at TIMESTAMPTZ, denied_at TIMESTAMPTZ, expires_at TIMESTAMPTZ NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, token), + FOREIGN KEY (community_id, workflow_id) + REFERENCES workflows (community_id, id) ON DELETE CASCADE, + FOREIGN KEY (community_id, run_id) + REFERENCES workflow_runs (community_id, id) ON DELETE CASCADE ); -CREATE INDEX idx_workflow_approvals_workflow ON workflow_approvals (workflow_id); -CREATE INDEX idx_workflow_approvals_run ON workflow_approvals (run_id); -CREATE INDEX idx_workflow_approvals_status ON workflow_approvals (status); +CREATE INDEX idx_workflow_approvals_workflow ON workflow_approvals (community_id, workflow_id); +CREATE INDEX idx_workflow_approvals_run ON workflow_approvals (community_id, run_id); +CREATE INDEX idx_workflow_approvals_status ON workflow_approvals (community_id, status); + +-- ── Scheduled workflow fires (cron claim) ───────────────────────────────────── +-- Plan §5: the at-most-once cron fire claim. UNIQUE (community_id, workflow_id, +-- scheduled_for) — only the pod that wins the claim insert creates the run. +-- Restart-safe (DB-durable). community resolved server-side from workflow_id, +-- never a caller-supplied claim parameter (S1 tenant binding). + +CREATE TABLE scheduled_workflow_fires ( + community_id UUID NOT NULL REFERENCES communities(id), + workflow_id UUID NOT NULL, + scheduled_for TIMESTAMPTZ NOT NULL, + claimed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, workflow_id, scheduled_for), + FOREIGN KEY (community_id, workflow_id) + REFERENCES workflows (community_id, id) ON DELETE CASCADE +); + +-- The interval anchor reads MAX(scheduled_for) per workflow; the janitor prunes +-- by claimed_at globally (operator concern). See plan §5 retention coupling. +CREATE INDEX idx_scheduled_fires_claimed_at ON scheduled_workflow_fires (claimed_at); -- ── API tokens ──────────────────────────────────────────────────────────────── +-- Conformance: "API tokens and NIP-98 replay". token_hash uniqueness scoped to +-- (community_id, token_hash); channel claims reference channels in same community. CREATE TABLE api_tokens ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - token_hash BYTEA NOT NULL UNIQUE, - owner_pubkey BYTEA NOT NULL REFERENCES users(pubkey), + community_id UUID NOT NULL REFERENCES communities(id), + id UUID NOT NULL DEFAULT gen_random_uuid(), + token_hash BYTEA NOT NULL, + owner_pubkey BYTEA NOT NULL, name VARCHAR(255) NOT NULL, scopes JSONB NOT NULL, channel_ids JSONB, @@ -267,13 +438,21 @@ CREATE TABLE api_tokens ( revoked_at TIMESTAMPTZ, revoked_by BYTEA, created_by_self_mint BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (community_id, id), + FOREIGN KEY (community_id, owner_pubkey) REFERENCES users (community_id, pubkey), CONSTRAINT chk_api_tokens_hash_len CHECK (LENGTH(token_hash) = 32) ); +CREATE UNIQUE INDEX idx_api_tokens_hash ON api_tokens (community_id, token_hash); + -- ── Rate limit violations ───────────────────────────────────────────────────── +-- OPERATOR-GLOBAL: a deployment-health / abuse table, never tenant-observable. +-- Listed in the lint allowlist. Carries community_id as an attribution label +-- only (nullable, no uniqueness over it). CREATE TABLE rate_limit_violations ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + community_id UUID, pubkey BYTEA, violation_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), limit_type VARCHAR(64), @@ -283,11 +462,13 @@ CREATE TABLE rate_limit_violations ( ); -- ── Thread metadata ─────────────────────────────────────────────────────────── +-- Conformance: thread lookups filter by community before event matching. CREATE TABLE thread_metadata ( + community_id UUID NOT NULL REFERENCES communities(id), event_created_at TIMESTAMPTZ NOT NULL, event_id BYTEA NOT NULL, - channel_id UUID NOT NULL REFERENCES channels(id), + channel_id UUID NOT NULL, parent_event_id BYTEA, parent_event_created_at TIMESTAMPTZ, root_event_id BYTEA, @@ -297,17 +478,21 @@ CREATE TABLE thread_metadata ( descendant_count INT NOT NULL DEFAULT 0, last_reply_at TIMESTAMPTZ, broadcast BOOLEAN NOT NULL DEFAULT FALSE, - PRIMARY KEY (event_created_at, event_id) + PRIMARY KEY (community_id, event_created_at, event_id), + FOREIGN KEY (community_id, channel_id) REFERENCES channels (community_id, id) ); -CREATE INDEX idx_thread_metadata_parent ON thread_metadata (parent_event_id); -CREATE INDEX idx_thread_metadata_root ON thread_metadata (root_event_id); -CREATE INDEX idx_thread_metadata_channel_depth ON thread_metadata (channel_id, depth, event_created_at); -CREATE INDEX idx_thread_metadata_event_id ON thread_metadata (event_id); +CREATE INDEX idx_thread_metadata_parent ON thread_metadata (community_id, parent_event_id); +CREATE INDEX idx_thread_metadata_root ON thread_metadata (community_id, root_event_id); +CREATE INDEX idx_thread_metadata_channel_depth + ON thread_metadata (community_id, channel_id, depth, event_created_at); +CREATE INDEX idx_thread_metadata_event_id ON thread_metadata (community_id, event_id); -- ── Reactions ───────────────────────────────────────────────────────────────── +-- Conformance: reactions filter by community before event/pubkey matching. CREATE TABLE reactions ( + community_id UUID NOT NULL REFERENCES communities(id), event_created_at TIMESTAMPTZ NOT NULL, event_id BYTEA NOT NULL, pubkey BYTEA NOT NULL, @@ -315,42 +500,92 @@ CREATE TABLE reactions ( created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), removed_at TIMESTAMPTZ, reaction_event_id BYTEA, - PRIMARY KEY (event_created_at, event_id, pubkey, emoji) + PRIMARY KEY (community_id, event_created_at, event_id, pubkey, emoji) ); -CREATE INDEX idx_reactions_event ON reactions (event_id, event_created_at); -CREATE INDEX idx_reactions_pubkey ON reactions (pubkey); -CREATE UNIQUE INDEX idx_reactions_source_event ON reactions (reaction_event_id); +CREATE INDEX idx_reactions_event ON reactions (community_id, event_id, event_created_at); +CREATE INDEX idx_reactions_pubkey ON reactions (community_id, pubkey); +-- A reaction's source event id is unique within a community. +CREATE UNIQUE INDEX idx_reactions_source_event ON reactions (community_id, reaction_event_id) + WHERE reaction_event_id IS NOT NULL; -- ── Pubkey allowlist ────────────────────────────────────────────────────────── +-- Conformance: "Relay membership, pubkey allowlist, archived identities". +-- PK becomes (community_id, pubkey). CREATE TABLE pubkey_allowlist ( - pubkey BYTEA PRIMARY KEY, + community_id UUID NOT NULL REFERENCES communities(id), + pubkey BYTEA NOT NULL, added_by BYTEA, added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - note TEXT + note TEXT, + PRIMARY KEY (community_id, pubkey) ); -- ── Relay members (NIP-43) ──────────────────────────────────────────────────── +-- Conformance: membership gate, community-scoped. pubkey stored as hex TEXT +-- (unchanged wire form). PK (community_id, pubkey). -CREATE TABLE IF NOT EXISTS relay_members ( - pubkey TEXT PRIMARY KEY, +CREATE TABLE relay_members ( + community_id UUID NOT NULL REFERENCES communities(id), + pubkey TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')), added_by TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, pubkey) ); -CREATE INDEX IF NOT EXISTS idx_relay_members_role ON relay_members(role); +CREATE INDEX idx_relay_members_role ON relay_members (community_id, role); -- ── Archived identities (NIP-IA) ────────────────────────────────────────────── +-- Conformance: archive cannot hide a key in another community. PK scoped. -CREATE TABLE IF NOT EXISTS archived_identities ( - pubkey TEXT PRIMARY KEY, +CREATE TABLE archived_identities ( + community_id UUID NOT NULL REFERENCES communities(id), + pubkey TEXT NOT NULL, consent_path TEXT NOT NULL CHECK (consent_path IN ('self', 'owner', 'admin')), actor TEXT NOT NULL, reason TEXT, replaced_by TEXT, request_event_id TEXT NOT NULL, - archived_at TIMESTAMPTZ NOT NULL DEFAULT now() + archived_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, pubkey) ); + +-- ── Audit log ───────────────────────────────────────────────────────────────── +-- Conformance: "Audit log and observability". Per-community hash chain: +-- uniqueness (community_id, seq) and (community_id, hash). One chain per tenant. +-- (Lane Audit/Dawn builds the chain logic; Lane 0 fixes the scoped schema.) + +CREATE TABLE audit_log ( + community_id UUID NOT NULL REFERENCES communities(id), + seq BIGINT NOT NULL, + hash BYTEA NOT NULL, + prev_hash BYTEA, + action VARCHAR(64) NOT NULL, + actor_pubkey BYTEA, + object_id TEXT, + detail JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, seq) +); + +CREATE UNIQUE INDEX idx_audit_log_hash ON audit_log (community_id, hash); + +-- ── Lint allowlist registry ─────────────────────────────────────────────────── +-- The explicit registry of tables that are deliberately operator-global (NOT +-- tenant-scoped). The migration-lint harness reads this: any table NOT listed +-- here MUST carry a NOT NULL community_id and lead its uniques with it. Making +-- the allowlist a DB table (not a hard-coded list in the linter) keeps the +-- registry next to the schema it governs and reviewable in one migration diff. + +CREATE TABLE _operator_global_tables ( + table_name TEXT PRIMARY KEY, + reason TEXT NOT NULL +); + +INSERT INTO _operator_global_tables (table_name, reason) VALUES + ('communities', 'the tenant registry itself; id IS the community key'), + ('rate_limit_violations', 'deployment abuse/health; never tenant-observable; community_id is an attribution label only'), + ('_operator_global_tables', 'the registry table itself');