rspace-online/src/encryptid/schema.sql

480 lines
20 KiB
SQL

-- 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'));