-- 017: multi-use, expiring workspace invite links (Telegram-style). -- One link, many claimants: anyone holding the token may join with the link's -- role until the link expires, is revoked, or exhausts max_uses (null = -- unlimited until expiry). Claimers arrive unauthenticated, so the claim path -- is the SECURITY DEFINER analogue of login (see invite_link_for_claim below): -- workspace_invite_links is FORCE RLS and no tenant context exists pre-join. CREATE TABLE workspace_invite_links ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, role text NOT NULL, token text NOT NULL UNIQUE, created_by uuid NOT NULL REFERENCES users(id), created_at timestamptz NOT NULL DEFAULT now(), expires_at timestamptz NOT NULL, max_uses int CHECK (max_uses IS NULL OR max_uses > 0), use_count int NOT NULL DEFAULT 0 CHECK (use_count >= 0), revoked_at timestamptz ); CREATE TRIGGER trg_invite_link_ws_immutable BEFORE UPDATE ON workspace_invite_links FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); -- Same fail-closed tenant isolation as every tenant table (001/006 style): -- no app.workspace_id => NULL => no rows. FORCE binds sp_app too. ALTER TABLE workspace_invite_links ENABLE ROW LEVEL SECURITY; ALTER TABLE workspace_invite_links FORCE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON workspace_invite_links USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); GRANT SELECT, INSERT, UPDATE, DELETE ON workspace_invite_links TO sp_app; -- Claim runs with NO tenant context (the claimer has no membership yet), and -- the table is FORCE RLS: ordinary SELECTs see zero rows. Like -- auth_user_memberships (007) and publishing_schedule_workspace (006), this -- narrow SECURITY DEFINER lookup exposes exactly one row keyed by the secret -- token — workspace_id discovery only; the caller enters the tenant context -- afterwards and every subsequent statement is RLS-bound as sp_app. CREATE FUNCTION invite_link_for_claim(p_token text) RETURNS TABLE(id uuid, workspace_id uuid, role text, expires_at timestamptz, max_uses int, use_count int, revoked_at timestamptz) LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$ SELECT l.id, l.workspace_id, l.role, l.expires_at, l.max_uses, l.use_count, l.revoked_at FROM workspace_invite_links l WHERE l.token = p_token $$; REVOKE ALL ON FUNCTION invite_link_for_claim(text) FROM PUBLIC; GRANT EXECUTE ON FUNCTION invite_link_for_claim(text) TO sp_app; -- Token lookup at claim time: point lookup served by the backing index of the -- inline UNIQUE constraint on token (auto-named workspace_invite_links_token_key). -- Statement for documentation/parity with 016's users_username_key — do NOT run twice: -- CREATE UNIQUE INDEX workspace_invite_links_token_key ON workspace_invite_links (token); -- Workspace listing (admin GET /team/invite-links), newest first: CREATE INDEX workspace_invite_links_ws_created ON workspace_invite_links (workspace_id, created_at DESC); -- Expiry sweep / active-link diagnostics, mirroring the partial-index style of -- audit_log (001) and scheduled_publications_due (006): CREATE INDEX workspace_invite_links_expiry ON workspace_invite_links (expires_at) WHERE revoked_at IS NULL;