-- EncryptID PostgreSQL Schema -- Run once to initialize the database CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT UNIQUE NOT NULL, display_name TEXT, did TEXT, email TEXT, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); -- Profile columns (added for user profile management) ALTER TABLE users ADD COLUMN IF NOT EXISTS bio TEXT; ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url TEXT; ALTER TABLE users ADD COLUMN IF NOT EXISTS profile_email TEXT; ALTER TABLE users ADD COLUMN IF NOT EXISTS profile_email_is_recovery BOOLEAN DEFAULT FALSE; ALTER TABLE users ADD COLUMN IF NOT EXISTS wallet_address TEXT; ALTER TABLE users ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW(); -- Email forwarding (Mailcow alias) ALTER TABLE users ADD COLUMN IF NOT EXISTS email_forward_enabled BOOLEAN DEFAULT FALSE; ALTER TABLE users ADD COLUMN IF NOT EXISTS email_forward_mailcow_id TEXT; CREATE TABLE IF NOT EXISTS credentials ( credential_id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, public_key TEXT NOT NULL, counter INTEGER DEFAULT 0, transports TEXT[], created_at TIMESTAMPTZ DEFAULT NOW(), last_used TIMESTAMPTZ ); CREATE INDEX IF NOT EXISTS idx_credentials_user_id ON credentials(user_id); CREATE TABLE IF NOT EXISTS challenges ( challenge TEXT PRIMARY KEY, user_id TEXT, type TEXT NOT NULL CHECK (type IN ('registration', 'authentication', 'device_registration', 'wallet_link')), created_at TIMESTAMPTZ DEFAULT NOW(), expires_at TIMESTAMPTZ NOT NULL ); -- Auto-clean expired challenges (run periodically or use pg_cron) CREATE INDEX IF NOT EXISTS idx_challenges_expires_at ON challenges(expires_at); CREATE TABLE IF NOT EXISTS recovery_tokens ( token TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, type TEXT NOT NULL CHECK (type IN ('email_verify', 'account_recovery')), created_at TIMESTAMPTZ DEFAULT NOW(), expires_at TIMESTAMPTZ NOT NULL, used BOOLEAN DEFAULT FALSE ); CREATE INDEX IF NOT EXISTS idx_recovery_tokens_user_id ON recovery_tokens(user_id); CREATE INDEX IF NOT EXISTS idx_recovery_tokens_expires_at ON recovery_tokens(expires_at); -- Space membership: source of truth for user roles across the r*.online ecosystem CREATE TABLE IF NOT EXISTS space_members ( space_slug TEXT NOT NULL, user_did TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN ('viewer', 'participant', 'moderator', 'admin')), joined_at TIMESTAMPTZ DEFAULT NOW(), granted_by TEXT, PRIMARY KEY (space_slug, user_did) ); CREATE INDEX IF NOT EXISTS idx_space_members_user_did ON space_members(user_did); CREATE INDEX IF NOT EXISTS idx_space_members_space_slug ON space_members(space_slug); -- ============================================================================ -- GUARDIAN RECOVERY (2-of-3 social recovery) -- ============================================================================ -- Guardians: up to 3 trusted contacts per account CREATE TABLE IF NOT EXISTS guardians ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, -- display name (e.g. "Mom", "Alex") email TEXT, -- for sending invite/approval emails guardian_user_id TEXT REFERENCES users(id), -- if they have an EncryptID account status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'revoked')), invite_token TEXT UNIQUE, -- token for invite link invite_expires_at TIMESTAMPTZ, accepted_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_guardians_user_id ON guardians(user_id); CREATE INDEX IF NOT EXISTS idx_guardians_guardian_user_id ON guardians(guardian_user_id); CREATE INDEX IF NOT EXISTS idx_guardians_invite_token ON guardians(invite_token); -- Recovery requests: when a user needs to recover their account CREATE TABLE IF NOT EXISTS recovery_requests ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'cancelled', 'completed', 'expired')), threshold INTEGER NOT NULL DEFAULT 2, -- approvals needed (2-of-3) approval_count INTEGER NOT NULL DEFAULT 0, initiated_at TIMESTAMPTZ DEFAULT NOW(), expires_at TIMESTAMPTZ NOT NULL, -- 7 days to collect approvals completed_at TIMESTAMPTZ ); CREATE INDEX IF NOT EXISTS idx_recovery_requests_user_id ON recovery_requests(user_id); -- Recovery approvals: guardian votes on a recovery request CREATE TABLE IF NOT EXISTS recovery_approvals ( request_id TEXT NOT NULL REFERENCES recovery_requests(id) ON DELETE CASCADE, guardian_id TEXT NOT NULL REFERENCES guardians(id) ON DELETE CASCADE, approval_token TEXT UNIQUE, -- token sent via email/link for one-click approve approved_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW(), PRIMARY KEY (request_id, guardian_id) ); CREATE INDEX IF NOT EXISTS idx_recovery_approvals_token ON recovery_approvals(approval_token); -- Device link tokens: for linking a second device via QR or email CREATE TABLE IF NOT EXISTS device_links ( token TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at TIMESTAMPTZ DEFAULT NOW(), expires_at TIMESTAMPTZ NOT NULL, -- 10 minutes used BOOLEAN DEFAULT FALSE ); CREATE INDEX IF NOT EXISTS idx_device_links_user_id ON device_links(user_id); -- ============================================================================ -- ENCRYPTED POSTAL ADDRESSES (zero-knowledge address storage) -- ============================================================================ -- Addresses are encrypted client-side with passkey-derived AES-256-GCM keys. -- The server stores opaque ciphertext + cleartext metadata (label, isDefault) for UI listing. CREATE TABLE IF NOT EXISTS encrypted_addresses ( id TEXT NOT NULL, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, ciphertext TEXT NOT NULL, iv TEXT NOT NULL, label TEXT NOT NULL CHECK (label IN ('home', 'work', 'shipping', 'billing', 'other')), label_custom TEXT, is_default BOOLEAN DEFAULT FALSE, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), PRIMARY KEY (id, user_id) ); CREATE INDEX IF NOT EXISTS idx_encrypted_addresses_user_id ON encrypted_addresses(user_id); -- ============================================================================ -- ROLE RENAME: participant → member -- ============================================================================ UPDATE space_members SET role = 'member' WHERE role = 'participant'; ALTER TABLE space_members DROP CONSTRAINT IF EXISTS space_members_role_check; ALTER TABLE space_members ADD CONSTRAINT space_members_role_check CHECK (role IN ('viewer', 'member', 'moderator', 'admin')); -- ============================================================================ -- SPACE INVITES -- ============================================================================ CREATE TABLE IF NOT EXISTS space_invites ( id TEXT PRIMARY KEY, space_slug TEXT NOT NULL, email TEXT, role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('viewer', 'member', 'moderator', 'admin')), token TEXT UNIQUE NOT NULL, invited_by TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'expired', 'revoked')), created_at TIMESTAMPTZ DEFAULT NOW(), expires_at TIMESTAMPTZ NOT NULL, accepted_at TIMESTAMPTZ, accepted_by_did TEXT ); CREATE INDEX IF NOT EXISTS idx_space_invites_token ON space_invites(token); CREATE INDEX IF NOT EXISTS idx_space_invites_space ON space_invites(space_slug); -- ============================================================================ -- NOTIFICATIONS -- ============================================================================ CREATE TABLE IF NOT EXISTS notifications ( id TEXT PRIMARY KEY, user_did TEXT NOT NULL, category TEXT NOT NULL CHECK (category IN ('space', 'module', 'system', 'social')), event_type TEXT NOT NULL, title TEXT NOT NULL, body TEXT, space_slug TEXT, module_id TEXT, action_url TEXT, actor_did TEXT, actor_username TEXT, metadata JSONB DEFAULT '{}', read BOOLEAN DEFAULT FALSE, dismissed BOOLEAN DEFAULT FALSE, delivered_ws BOOLEAN DEFAULT FALSE, delivered_email BOOLEAN DEFAULT FALSE, delivered_push BOOLEAN DEFAULT FALSE, created_at TIMESTAMPTZ DEFAULT NOW(), read_at TIMESTAMPTZ, expires_at TIMESTAMPTZ ); CREATE INDEX IF NOT EXISTS idx_notif_user_unread ON notifications(user_did, read) WHERE NOT read AND NOT dismissed; CREATE INDEX IF NOT EXISTS idx_notif_user_created ON notifications(user_did, created_at DESC); CREATE INDEX IF NOT EXISTS idx_notif_expires ON notifications(expires_at) WHERE expires_at IS NOT NULL; CREATE TABLE IF NOT EXISTS notification_preferences ( user_did TEXT PRIMARY KEY, email_enabled BOOLEAN DEFAULT TRUE, push_enabled BOOLEAN DEFAULT TRUE, quiet_hours_start TEXT, quiet_hours_end TEXT, muted_spaces TEXT[] DEFAULT '{}', muted_categories TEXT[] DEFAULT '{}', digest_frequency TEXT DEFAULT 'none' CHECK (digest_frequency IN ('none', 'daily', 'weekly')), updated_at TIMESTAMPTZ DEFAULT NOW() ); -- ============================================================================ -- FUND CLAIMS (on-ramp claim-via-EncryptID flow) -- ============================================================================ CREATE TABLE IF NOT EXISTS fund_claims ( id TEXT PRIMARY KEY, token TEXT UNIQUE NOT NULL, email_hash TEXT NOT NULL, email TEXT, -- nulled after claim or 7 days wallet_address TEXT NOT NULL, openfort_player_id TEXT, fiat_amount TEXT, fiat_currency TEXT DEFAULT 'USD', session_id TEXT, provider TEXT, -- 'coinbase' | 'transak' status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'claimed', 'expired', 'resent')), claimed_by_user_id TEXT REFERENCES users(id), created_at TIMESTAMPTZ DEFAULT NOW(), expires_at TIMESTAMPTZ NOT NULL, claimed_at TIMESTAMPTZ ); CREATE INDEX IF NOT EXISTS idx_fund_claims_token ON fund_claims(token); CREATE INDEX IF NOT EXISTS idx_fund_claims_email_hash ON fund_claims(email_hash); CREATE INDEX IF NOT EXISTS idx_fund_claims_expires ON fund_claims(expires_at); -- ============================================================================ -- OIDC PROVIDER (Authorization Code flow for Postiz, etc.) -- ============================================================================ CREATE TABLE IF NOT EXISTS oidc_clients ( client_id TEXT PRIMARY KEY, client_secret TEXT NOT NULL, name TEXT NOT NULL, redirect_uris TEXT[] NOT NULL, allowed_emails TEXT[] DEFAULT '{}', -- empty = unrestricted created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS oidc_auth_codes ( code TEXT PRIMARY KEY, client_id TEXT NOT NULL REFERENCES oidc_clients(client_id), user_id TEXT NOT NULL REFERENCES users(id), redirect_uri TEXT NOT NULL, scope TEXT DEFAULT 'openid profile email', created_at TIMESTAMPTZ DEFAULT NOW(), expires_at TIMESTAMPTZ NOT NULL, used BOOLEAN DEFAULT FALSE ); CREATE INDEX IF NOT EXISTS idx_oidc_auth_codes_expires ON oidc_auth_codes(expires_at); -- ============================================================================ -- IDENTITY INVITES ("Claim your rSpace" flow) -- ============================================================================ CREATE TABLE IF NOT EXISTS identity_invites ( id TEXT PRIMARY KEY, token TEXT UNIQUE NOT NULL, email TEXT NOT NULL, invited_by_user_id TEXT NOT NULL REFERENCES users(id), invited_by_username TEXT NOT NULL, message TEXT, -- optional personal message space_slug TEXT, -- auto-join this space on claim space_role TEXT DEFAULT 'member' CHECK (space_role IN ('viewer', 'member', 'moderator', 'admin')), status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'claimed', 'expired', 'revoked')), claimed_by_user_id TEXT REFERENCES users(id), created_at TIMESTAMPTZ DEFAULT NOW(), expires_at TIMESTAMPTZ NOT NULL, claimed_at TIMESTAMPTZ ); CREATE INDEX IF NOT EXISTS idx_identity_invites_token ON identity_invites(token); CREATE INDEX IF NOT EXISTS idx_identity_invites_email ON identity_invites(email); CREATE INDEX IF NOT EXISTS idx_identity_invites_invited_by ON identity_invites(invited_by_user_id); CREATE INDEX IF NOT EXISTS idx_identity_invites_expires ON identity_invites(expires_at); -- OIDC client invite: optional link to an OIDC client for "Accept Your Role" flow ALTER TABLE identity_invites ADD COLUMN IF NOT EXISTS client_id TEXT REFERENCES oidc_clients(client_id); CREATE INDEX IF NOT EXISTS idx_identity_invites_client_id ON identity_invites(client_id); -- ============================================================================ -- CHALLENGES: extend type constraint to include 'wallet_link' -- ============================================================================ ALTER TABLE challenges DROP CONSTRAINT IF EXISTS challenges_type_check; ALTER TABLE challenges ADD CONSTRAINT challenges_type_check CHECK (type IN ('registration', 'authentication', 'device_registration', 'wallet_link', 'legacy_migration')); -- ============================================================================ -- LINKED EXTERNAL WALLETS (SIWE-verified wallet associations) -- ============================================================================ -- Server stores encrypted blobs + address hash for dedup. -- Cleartext wallet data is never stored server-side. CREATE TABLE IF NOT EXISTS linked_wallets ( id TEXT NOT NULL, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, ciphertext TEXT NOT NULL, iv TEXT NOT NULL, address_hash TEXT NOT NULL, source TEXT NOT NULL CHECK (source IN ('external-eoa', 'external-safe')), verified BOOLEAN DEFAULT FALSE, linked_at TIMESTAMPTZ DEFAULT NOW(), PRIMARY KEY (id, user_id) ); CREATE INDEX IF NOT EXISTS idx_linked_wallets_user_id ON linked_wallets(user_id); CREATE INDEX IF NOT EXISTS idx_linked_wallets_address_hash ON linked_wallets(address_hash); -- ============================================================================ -- PUSH SUBSCRIPTIONS (Web Push notifications) -- ============================================================================ CREATE TABLE IF NOT EXISTS push_subscriptions ( id TEXT PRIMARY KEY, user_did TEXT NOT NULL, endpoint TEXT NOT NULL, key_p256dh TEXT NOT NULL, key_auth TEXT NOT NULL, user_agent TEXT, created_at TIMESTAMPTZ DEFAULT NOW(), last_used TIMESTAMPTZ, UNIQUE (user_did, endpoint) ); CREATE INDEX IF NOT EXISTS idx_push_sub_user ON push_subscriptions(user_did); -- Prevent duplicate wallet links per user (application-level check + DB enforcement) DO $$ BEGIN ALTER TABLE linked_wallets ADD CONSTRAINT linked_wallets_user_address_unique UNIQUE (user_id, address_hash); EXCEPTION WHEN duplicate_table THEN NULL; END $$; -- ============================================================================ -- DELEGATIVE TRUST (person-to-person liquid democracy) -- ============================================================================ -- Delegations: person-to-person authority delegation within a space CREATE TABLE IF NOT EXISTS delegations ( id TEXT PRIMARY KEY, delegator_did TEXT NOT NULL, delegate_did TEXT NOT NULL, authority TEXT NOT NULL CHECK (authority IN ('gov-ops', 'fin-ops', 'dev-ops', 'custom')), weight REAL NOT NULL CHECK (weight > 0 AND weight <= 1), max_depth INTEGER NOT NULL DEFAULT 3, retain_authority BOOLEAN NOT NULL DEFAULT TRUE, space_slug TEXT NOT NULL, state TEXT NOT NULL DEFAULT 'active' CHECK (state IN ('active', 'paused', 'revoked')), custom_scope TEXT, expires_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE (delegator_did, delegate_did, authority, space_slug), CHECK (delegator_did != delegate_did) ); CREATE INDEX IF NOT EXISTS idx_delegations_delegator ON delegations(delegator_did, space_slug); CREATE INDEX IF NOT EXISTS idx_delegations_delegate ON delegations(delegate_did, space_slug); CREATE INDEX IF NOT EXISTS idx_delegations_space ON delegations(space_slug, authority); CREATE INDEX IF NOT EXISTS idx_delegations_expires ON delegations(expires_at) WHERE expires_at IS NOT NULL; -- Trust events: append-only log of trust-relevant actions CREATE TABLE IF NOT EXISTS trust_events ( id TEXT PRIMARY KEY, source_did TEXT NOT NULL, target_did TEXT NOT NULL, event_type TEXT NOT NULL CHECK (event_type IN ( 'delegation_created', 'delegation_increased', 'delegation_decreased', 'delegation_revoked', 'delegation_paused', 'delegation_resumed', 'endorsement', 'flag', 'collaboration', 'guardian_link' )), authority TEXT, weight_delta REAL, space_slug TEXT NOT NULL, metadata JSONB DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_trust_events_source ON trust_events(source_did, created_at DESC); CREATE INDEX IF NOT EXISTS idx_trust_events_target ON trust_events(target_did, created_at DESC); CREATE INDEX IF NOT EXISTS idx_trust_events_space ON trust_events(space_slug, created_at DESC); -- Trust scores: materialized aggregation of delegation + transitive trust CREATE TABLE IF NOT EXISTS trust_scores ( source_did TEXT NOT NULL, target_did TEXT NOT NULL, authority TEXT NOT NULL, space_slug TEXT NOT NULL, score REAL NOT NULL DEFAULT 0, direct_weight REAL NOT NULL DEFAULT 0, transitive_weight REAL NOT NULL DEFAULT 0, last_computed TIMESTAMPTZ DEFAULT NOW(), PRIMARY KEY (source_did, target_did, authority, space_slug) ); CREATE INDEX IF NOT EXISTS idx_trust_scores_target ON trust_scores(target_did, authority, space_slug); CREATE INDEX IF NOT EXISTS idx_trust_scores_space ON trust_scores(space_slug, authority); -- ============================================================================ -- LEGACY IDENTITY LINKS (CryptID → EncryptID migration) -- ============================================================================ CREATE TABLE IF NOT EXISTS legacy_identities ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, provider TEXT NOT NULL CHECK (provider IN ('cryptid')), legacy_username TEXT NOT NULL, legacy_public_key TEXT NOT NULL, legacy_public_key_hash TEXT NOT NULL, verified BOOLEAN DEFAULT FALSE, migrated_data BOOLEAN DEFAULT FALSE, linked_at TIMESTAMPTZ DEFAULT NOW(), verified_at TIMESTAMPTZ, UNIQUE (provider, legacy_public_key_hash) ); CREATE INDEX IF NOT EXISTS idx_legacy_identities_user ON legacy_identities(user_id); CREATE INDEX IF NOT EXISTS idx_legacy_identities_pubkey ON legacy_identities(legacy_public_key_hash); -- ============================================================================ -- MIGRATION: Rename authority verticals (voting/moderation/curation/treasury/membership → gov-ops/fin-ops/dev-ops) -- ============================================================================ -- Drop old CHECK constraint first so UPDATEs can set new values ALTER TABLE delegations DROP CONSTRAINT IF EXISTS delegations_authority_check; -- Migrate existing delegation data UPDATE delegations SET authority = 'gov-ops' WHERE authority IN ('voting', 'moderation'); UPDATE delegations SET authority = 'fin-ops' WHERE authority = 'treasury'; UPDATE delegations SET authority = 'dev-ops' WHERE authority IN ('curation', 'membership'); -- Migrate existing trust_scores data UPDATE trust_scores SET authority = 'gov-ops' WHERE authority IN ('voting', 'moderation'); UPDATE trust_scores SET authority = 'fin-ops' WHERE authority = 'treasury'; UPDATE trust_scores SET authority = 'dev-ops' WHERE authority IN ('curation', 'membership'); -- Migrate existing trust_events data UPDATE trust_events SET authority = 'gov-ops' WHERE authority IN ('voting', 'moderation'); UPDATE trust_events SET authority = 'fin-ops' WHERE authority = 'treasury'; UPDATE trust_events SET authority = 'dev-ops' WHERE authority IN ('curation', 'membership'); -- Add new CHECK constraint with updated values ALTER TABLE delegations ADD CONSTRAINT delegations_authority_check CHECK (authority IN ('gov-ops', 'fin-ops', 'dev-ops', 'custom'));