@snagset/server 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -0
- package/dist/app.js +453 -0
- package/dist/app.js.map +1 -0
- package/dist/bin.js +951 -0
- package/dist/bin.js.map +1 -0
- package/dist/boot.js +321 -0
- package/dist/boot.js.map +1 -0
- package/dist/config.generated.js +116 -0
- package/dist/config.generated.js.map +1 -0
- package/dist/config.js +180 -0
- package/dist/config.js.map +1 -0
- package/dist/crypto.js +26 -0
- package/dist/crypto.js.map +1 -0
- package/dist/db/connect.js +49 -0
- package/dist/db/connect.js.map +1 -0
- package/dist/db/migrate.js +210 -0
- package/dist/db/migrate.js.map +1 -0
- package/dist/db/migrations/0001_init.sql +1060 -0
- package/dist/db/migrations/0002_maintenance_claim.sql +109 -0
- package/dist/db/migrations/0003_email_provider.sql +35 -0
- package/dist/db/migrations/0004_unsubscribe.sql +24 -0
- package/dist/db/rls.js +58 -0
- package/dist/db/rls.js.map +1 -0
- package/dist/jobs/index.js +171 -0
- package/dist/jobs/index.js.map +1 -0
- package/dist/jobs/reaper.js +182 -0
- package/dist/jobs/reaper.js.map +1 -0
- package/dist/keys.js +77 -0
- package/dist/keys.js.map +1 -0
- package/dist/mail/index.js +75 -0
- package/dist/mail/index.js.map +1 -0
- package/dist/mail/mailer.js +27 -0
- package/dist/mail/mailer.js.map +1 -0
- package/dist/mail/notify.js +405 -0
- package/dist/mail/notify.js.map +1 -0
- package/dist/mail/resend.js +95 -0
- package/dist/mail/resend.js.map +1 -0
- package/dist/mail/smtp.js +315 -0
- package/dist/mail/smtp.js.map +1 -0
- package/dist/mail/unsubscribe.js +134 -0
- package/dist/mail/unsubscribe.js.map +1 -0
- package/dist/projectCache.js +50 -0
- package/dist/projectCache.js.map +1 -0
- package/dist/projects.js +101 -0
- package/dist/projects.js.map +1 -0
- package/dist/routes/attachments.js +159 -0
- package/dist/routes/attachments.js.map +1 -0
- package/dist/routes/auth.js +55 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/commentEdit.js +201 -0
- package/dist/routes/commentEdit.js.map +1 -0
- package/dist/routes/comments.js +313 -0
- package/dist/routes/comments.js.map +1 -0
- package/dist/routes/cors.js +174 -0
- package/dist/routes/cors.js.map +1 -0
- package/dist/routes/jobs.js +56 -0
- package/dist/routes/jobs.js.map +1 -0
- package/dist/routes/mcpAuth.js +187 -0
- package/dist/routes/mcpAuth.js.map +1 -0
- package/dist/routes/mcpToken.js +94 -0
- package/dist/routes/mcpToken.js.map +1 -0
- package/dist/routes/owner.js +288 -0
- package/dist/routes/owner.js.map +1 -0
- package/dist/routes/reviewLinks.js +169 -0
- package/dist/routes/reviewLinks.js.map +1 -0
- package/dist/routes/rounds.js +137 -0
- package/dist/routes/rounds.js.map +1 -0
- package/dist/routes/sessions.js +349 -0
- package/dist/routes/sessions.js.map +1 -0
- package/dist/routes/sites.js +192 -0
- package/dist/routes/sites.js.map +1 -0
- package/dist/routes/threads.js +1080 -0
- package/dist/routes/threads.js.map +1 -0
- package/dist/routes/widget.js +132 -0
- package/dist/routes/widget.js.map +1 -0
- package/dist/serverless.js +97 -0
- package/dist/serverless.js.map +1 -0
- package/dist/session.js +152 -0
- package/dist/session.js.map +1 -0
- package/dist/storage/bucket.js +116 -0
- package/dist/storage/bucket.js.map +1 -0
- package/dist/storage/settings.js +132 -0
- package/dist/storage/settings.js.map +1 -0
- package/dist/vendor/adapters/index.js +1 -0
- package/dist/vendor/adapters/outbound/fence.js +44 -0
- package/dist/vendor/adapters/outbound/sanitise.js +233 -0
- package/dist/vendor/types/index.js +4 -0
- package/dist/vendor/types/names.js +254 -0
- package/dist/vendor/widget/object.js +38 -0
- package/dist/vendor/widget/predicates.js +377 -0
- package/package.json +53 -0
- package/static/dashboard/assets/index-CaPfKhFP.css +1 -0
- package/static/dashboard/assets/index-DfTNjHCK.js +1 -0
- package/static/dashboard/index.html +14 -0
- package/static/widget/snag.anchor-ext.js +1 -0
- package/static/widget/snag.anchor-fuzzy.js +1 -0
- package/static/widget/snag.capture.js +4 -0
- package/static/widget/snag.crumbs.js +4 -0
- package/static/widget/snag.dock.js +5 -0
- package/static/widget/snag.js +5 -0
- package/static/widget/snag.panels.js +2 -0
- package/static/widget/snag.runtime.js +2 -0
- package/static/widget/snag.screenshot.js +1 -0
- package/static/widget/snag.text.js +2 -0
- package/vercel.json +6 -0
|
@@ -0,0 +1,1060 @@
|
|
|
1
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
2
|
+
-- GENERATED FILE — DO NOT EDIT BY HAND.
|
|
3
|
+
-- Source of truth: specs/05-api-data.md §3 (the schema itself, not prose
|
|
4
|
+
-- about one). Regenerate: pnpm gen:migration (CI runs gen:migration:check)
|
|
5
|
+
--
|
|
6
|
+
-- Edit the SPEC to change the database. That is the whole point: 05 §3
|
|
7
|
+
-- carries the reasoning for every column beside the column, and a migration
|
|
8
|
+
-- transcribed by hand is a second source of truth that is wrong by the
|
|
9
|
+
-- second edit.
|
|
10
|
+
--
|
|
11
|
+
-- 32 tables · 15 enums · 58 indexes
|
|
12
|
+
--
|
|
13
|
+
-- 1 statement(s) in §3 are gated to a later release and are NOT here:
|
|
14
|
+
-- v2: CREATE INDEX threads_anchor_text_trgm ON threads
|
|
15
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
-- 08 §2.3: zero extensions. UUIDs are minted by the CLIENT (UUIDv7, ordered
|
|
18
|
+
-- by time), so there is no pgcrypto, no uuid-ossp, and nothing to install
|
|
19
|
+
-- on a managed Postgres that may not permit installing anything.
|
|
20
|
+
|
|
21
|
+
CREATE TYPE snag_project_status AS ENUM ('active','paused','disabled','deleting');
|
|
22
|
+
CREATE TYPE snag_write_mode AS ENUM ('open','link-required','verified-only');
|
|
23
|
+
CREATE TYPE snag_thread_status AS ENUM ('open','progress','review','resolved');
|
|
24
|
+
CREATE TYPE snag_anchor_state AS ENUM ('anchored','approximate','orphaned');
|
|
25
|
+
CREATE TYPE snag_reviewer_kind AS ENUM ('guest','link','verified');
|
|
26
|
+
CREATE TYPE snag_attachment_kind AS ENUM ('screenshot','image','file','dom-snapshot'); -- 'file' added by D18
|
|
27
|
+
CREATE TYPE snag_attachment_state AS ENUM ('pending','bound','orphaned');
|
|
28
|
+
CREATE TYPE snag_moderation AS ENUM ('visible','held','spam');
|
|
29
|
+
CREATE TYPE snag_provider AS ENUM ('github','linear','slack','webhook');
|
|
30
|
+
CREATE TYPE snag_ref_kind AS ENUM ('issue','pr','message','attachment','delivery');
|
|
31
|
+
CREATE TYPE snag_ref_status AS ENUM ('synced','pending','failed','detached');
|
|
32
|
+
CREATE TYPE snag_member_role AS ENUM ('owner','admin','member','viewer');
|
|
33
|
+
CREATE TYPE snag_outbox_status AS ENUM ('pending','inflight','done','failed','dropped');
|
|
34
|
+
CREATE TYPE snag_sync_direction AS ENUM ('inbound','outbound');
|
|
35
|
+
CREATE TYPE snag_sync_entity AS ENUM ('thread','comment','status');
|
|
36
|
+
|
|
37
|
+
CREATE TABLE orgs (
|
|
38
|
+
id uuid PRIMARY KEY,
|
|
39
|
+
name text NOT NULL,
|
|
40
|
+
slug text NOT NULL UNIQUE CHECK (slug ~ '^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$'),
|
|
41
|
+
plan text NOT NULL DEFAULT 'selfhost',
|
|
42
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
43
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
44
|
+
-- ── The pixel tier's bucket (A9b, LOCK L2/L3, D18) ──────────────────────
|
|
45
|
+
--
|
|
46
|
+
-- INSTANCE-level, not per project: one bucket serves every project, and a
|
|
47
|
+
-- v1 self-host has exactly one org. `features: {screenshots, uploads}` in
|
|
48
|
+
-- the bootstrap config is derived from `storage_connected_at IS NOT NULL`
|
|
49
|
+
-- — server-side, because A9b is explicit that "the widget never probes
|
|
50
|
+
-- storage".
|
|
51
|
+
--
|
|
52
|
+
-- These columns exist because nothing else could hold them. Sweep R4
|
|
53
|
+
-- deleted every `SNAGSET_STORAGE_S3_*` variable on the grounds that "the
|
|
54
|
+
-- bucket is connected in the admin UI and stored encrypted under
|
|
55
|
+
-- credential-enc" — but no table had a column for it, and `integrations`
|
|
56
|
+
-- is per-project with a `provider` enum that has no storage member. So the
|
|
57
|
+
-- credentials had nowhere to live at all, on any deployment, which made
|
|
58
|
+
-- the pixel tier unreachable rather than merely un-built.
|
|
59
|
+
--
|
|
60
|
+
-- Endpoint, bucket, region, and the public base URL if one is configured.
|
|
61
|
+
-- Non-secret by construction; the keys are in the encrypted pair below.
|
|
62
|
+
storage_config jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
63
|
+
-- AES-256-GCM under HKDF(SNAGSET_SECRET_KEY, 'credential-enc') — the same
|
|
64
|
+
-- purpose and the same shape as `integrations.credentials`, deliberately,
|
|
65
|
+
-- so there is one way to hold a secret at rest rather than two.
|
|
66
|
+
storage_credentials bytea,
|
|
67
|
+
storage_credentials_iv bytea,
|
|
68
|
+
-- NULL = annotation mode. Disconnecting nulls this and leaves the
|
|
69
|
+
-- attachment rows' `storage_key`s intact (LOCK L2), so reconnecting the
|
|
70
|
+
-- same bucket restores the pictures rather than orphaning them.
|
|
71
|
+
storage_connected_at timestamptz,
|
|
72
|
+
CHECK ((storage_credentials IS NULL) = (storage_credentials_iv IS NULL)),
|
|
73
|
+
CHECK (storage_connected_at IS NULL OR storage_credentials IS NOT NULL),
|
|
74
|
+
deleted_at timestamptz
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
CREATE TABLE users ( -- dashboard/owner identity. NOT reviewers.
|
|
78
|
+
id uuid PRIMARY KEY,
|
|
79
|
+
org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE, -- HOME org (§3.0)
|
|
80
|
+
email text NOT NULL,
|
|
81
|
+
email_verified boolean NOT NULL DEFAULT false,
|
|
82
|
+
name text NOT NULL DEFAULT '',
|
|
83
|
+
avatar_url text,
|
|
84
|
+
password_hash text, -- argon2id; NULL when OAuth-only
|
|
85
|
+
totp_secret text, -- v2
|
|
86
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
87
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
88
|
+
deleted_at timestamptz,
|
|
89
|
+
CHECK (email = lower(email))
|
|
90
|
+
);
|
|
91
|
+
CREATE UNIQUE INDEX users_email_uniq ON users (lower(email)) WHERE deleted_at IS NULL;
|
|
92
|
+
|
|
93
|
+
CREATE TABLE user_identities ( -- OAuth links (v1: github, google)
|
|
94
|
+
id uuid PRIMARY KEY,
|
|
95
|
+
org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
|
|
96
|
+
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
97
|
+
provider text NOT NULL,
|
|
98
|
+
subject text NOT NULL,
|
|
99
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
100
|
+
UNIQUE (provider, subject)
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
CREATE TABLE memberships (
|
|
104
|
+
org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
|
|
105
|
+
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
106
|
+
role snag_member_role NOT NULL DEFAULT 'member',
|
|
107
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
108
|
+
PRIMARY KEY (org_id, user_id)
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
CREATE TABLE user_sessions ( -- opaque server-side sessions, NOT JWT
|
|
112
|
+
id uuid PRIMARY KEY,
|
|
113
|
+
org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
|
|
114
|
+
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
115
|
+
token_hash bytea NOT NULL UNIQUE, -- sha256 of the cookie value
|
|
116
|
+
user_agent text,
|
|
117
|
+
ip_hash bytea,
|
|
118
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
119
|
+
last_seen_at timestamptz NOT NULL DEFAULT now(),
|
|
120
|
+
expires_at timestamptz NOT NULL,
|
|
121
|
+
revoked_at timestamptz
|
|
122
|
+
);
|
|
123
|
+
CREATE INDEX user_sessions_user_idx ON user_sessions (user_id) WHERE revoked_at IS NULL;
|
|
124
|
+
|
|
125
|
+
CREATE TABLE setup_tokens ( -- first-run claim, §6.10. 08 §3.4 prints it.
|
|
126
|
+
id uuid PRIMARY KEY,
|
|
127
|
+
token_hash bytea NOT NULL UNIQUE,
|
|
128
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
129
|
+
expires_at timestamptz,
|
|
130
|
+
consumed_at timestamptz
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
CREATE TABLE projects (
|
|
134
|
+
id uuid PRIMARY KEY,
|
|
135
|
+
org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
|
|
136
|
+
uid text NOT NULL UNIQUE, -- PUBLIC site UID, 'prj_' + 12 (foundations §2.10)
|
|
137
|
+
name text NOT NULL,
|
|
138
|
+
status snag_project_status NOT NULL DEFAULT 'active',
|
|
139
|
+
kind text NOT NULL DEFAULT 'standard' CHECK (kind IN ('standard','demo')),
|
|
140
|
+
|
|
141
|
+
-- write gating (§4.2). New projects start 'open' and auto-downgrade after
|
|
142
|
+
-- SNAGSET_WRITE_MODE_GRACE_HOURS (default 24).
|
|
143
|
+
write_mode snag_write_mode NOT NULL DEFAULT 'open',
|
|
144
|
+
write_mode_downgrade_at timestamptz,
|
|
145
|
+
read_mode snag_write_mode, -- NULL = follow write_mode
|
|
146
|
+
|
|
147
|
+
-- human thread ids (foundations §2.10: Thread.key = 'SN-14')
|
|
148
|
+
-- 'SN', from names.ts's THREAD_REF_PREFIX. 'CD' was rename residue from the
|
|
149
|
+
-- cedilla era and contradicted this spec's own sentence six sections up
|
|
150
|
+
-- ('reads SN-14'); a project created under it would have minted CD-1.
|
|
151
|
+
key_prefix text NOT NULL DEFAULT 'SN' CHECK (key_prefix ~ '^[A-Z][A-Z0-9]{0,7}$'),
|
|
152
|
+
thread_seq integer NOT NULL DEFAULT 0,
|
|
153
|
+
|
|
154
|
+
-- IP hashing salt (§9.2, §13.1). Present from migration 0001, per §14.2b:
|
|
155
|
+
-- retrofitting it means every historical ip_hash was computed under a different
|
|
156
|
+
-- salt, which breaks erasure and comparability. 32 random bytes, per project.
|
|
157
|
+
-- The 'ip-hash' HKDF purpose stays deleted (foundations §7) -- this is data.
|
|
158
|
+
ip_salt bytea NOT NULL,
|
|
159
|
+
|
|
160
|
+
-- JSONB config blobs, all validated in the app layer (§3.10)
|
|
161
|
+
capture jsonb NOT NULL DEFAULT '{}'::jsonb, -- categories + masking (04 owns the vocabulary)
|
|
162
|
+
routing jsonb NOT NULL DEFAULT '{}'::jsonb, -- significant query params, normalisers (03)
|
|
163
|
+
branding jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
164
|
+
flags jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
165
|
+
settings jsonb NOT NULL DEFAULT '{}'::jsonb, -- limits overrides, moderation, retiredUids
|
|
166
|
+
|
|
167
|
+
-- identity handshake (§4.6). Verification needs the PLAINTEXT secret, so it is
|
|
168
|
+
-- stored encrypted under HKDF(SNAGSET_SECRET_KEY,'credential-enc'), never only hashed.
|
|
169
|
+
identity_secret_enc bytea, -- NULL = HMAC identity disabled
|
|
170
|
+
identity_secret_enc_iv bytea,
|
|
171
|
+
identity_secret_prev_enc bytea, -- 24 h rotation overlap
|
|
172
|
+
identity_secret_prev_enc_iv bytea,
|
|
173
|
+
identity_secret_prev_expires_at timestamptz,
|
|
174
|
+
identity_secret_hint text, -- last 4 chars, for the dashboard only
|
|
175
|
+
|
|
176
|
+
-- cache control for the bootstrap (§7)
|
|
177
|
+
config_version integer NOT NULL DEFAULT 1,
|
|
178
|
+
config_etag text NOT NULL,
|
|
179
|
+
-- session invalidation epoch (§4.4)
|
|
180
|
+
session_epoch integer NOT NULL DEFAULT 1,
|
|
181
|
+
-- abuse
|
|
182
|
+
shared_suffix_origin boolean NOT NULL DEFAULT false,
|
|
183
|
+
paused_reason text,
|
|
184
|
+
-- retention & storage quota
|
|
185
|
+
retention_days integer CHECK (retention_days IS NULL OR retention_days BETWEEN 1 AND 3650),
|
|
186
|
+
storage_bytes_limit bigint NOT NULL DEFAULT 268435456, -- 256 MB (A9b); 0 = unlimited
|
|
187
|
+
storage_bytes_used bigint NOT NULL DEFAULT 0,
|
|
188
|
+
purge_after timestamptz,
|
|
189
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
190
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
191
|
+
deleted_at timestamptz,
|
|
192
|
+
CHECK (uid ~ '^prj_[0-9abcdefghjkmnpqrstvwxyz]{12}$'),
|
|
193
|
+
CHECK (octet_length(ip_salt) = 32),
|
|
194
|
+
-- a project whose allow-list contains a shared public suffix (*.vercel.app)
|
|
195
|
+
-- may never be in 'open' write mode. See §4.9.
|
|
196
|
+
CHECK (NOT shared_suffix_origin OR write_mode <> 'open'),
|
|
197
|
+
CHECK (kind <> 'demo' OR retention_days IS NOT NULL),
|
|
198
|
+
CHECK ((identity_secret_enc IS NULL) = (identity_secret_enc_iv IS NULL))
|
|
199
|
+
);
|
|
200
|
+
CREATE INDEX projects_org_idx ON projects (org_id) WHERE deleted_at IS NULL;
|
|
201
|
+
CREATE INDEX projects_purge_idx ON projects (purge_after) WHERE purge_after IS NOT NULL;
|
|
202
|
+
CREATE INDEX projects_downgrade_idx ON projects (write_mode_downgrade_at)
|
|
203
|
+
WHERE write_mode_downgrade_at IS NOT NULL;
|
|
204
|
+
|
|
205
|
+
CREATE TABLE api_keys ( -- CI, relay, scripts. NOT the MCP server (§9.6).
|
|
206
|
+
id uuid PRIMARY KEY,
|
|
207
|
+
org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
|
|
208
|
+
project_id uuid REFERENCES projects(id) ON DELETE CASCADE, -- NULL = org-wide
|
|
209
|
+
name text NOT NULL,
|
|
210
|
+
prefix text NOT NULL, -- first 8 chars after API_KEY_PREFIX, shown in UI
|
|
211
|
+
token_hash bytea NOT NULL UNIQUE,
|
|
212
|
+
scopes text[] NOT NULL DEFAULT '{}',
|
|
213
|
+
created_by uuid REFERENCES users(id),
|
|
214
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
215
|
+
last_used_at timestamptz,
|
|
216
|
+
last_used_ip bytea, -- hashed, never raw (§9.2)
|
|
217
|
+
expires_at timestamptz,
|
|
218
|
+
revoked_at timestamptz
|
|
219
|
+
);
|
|
220
|
+
CREATE INDEX api_keys_org_idx ON api_keys (org_id) WHERE revoked_at IS NULL;
|
|
221
|
+
|
|
222
|
+
-- foundations §14.2 assigns MCP credentials to 07's scoped token + this table;
|
|
223
|
+
-- 05 writes the DDL, 07 owns the scope vocabulary and the transports. Shape is
|
|
224
|
+
-- 07 §8.2's, verbatim, plus the remote-write gate §9.6 requires.
|
|
225
|
+
-- Ships in migration 0001; no route accepts an MCP token before v2 (§6.1 #42).
|
|
226
|
+
CREATE TABLE mcp_tokens (
|
|
227
|
+
id uuid PRIMARY KEY,
|
|
228
|
+
org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
|
|
229
|
+
project_id uuid REFERENCES projects(id) ON DELETE CASCADE, -- NULL = org-wide
|
|
230
|
+
name text NOT NULL,
|
|
231
|
+
prefix text NOT NULL,
|
|
232
|
+
token_hash bytea NOT NULL UNIQUE,
|
|
233
|
+
scopes text[] NOT NULL DEFAULT '{}', -- vocabulary owned by 07
|
|
234
|
+
environments text[] NOT NULL DEFAULT '{*}', -- 07 §8.2
|
|
235
|
+
-- 07 §8.2's human-in-the-loop flag: the agent must confirm before writing.
|
|
236
|
+
confirm_writes boolean NOT NULL DEFAULT true,
|
|
237
|
+
-- §9.6: write scopes are refused over a remote (non-loopback) MCP transport
|
|
238
|
+
-- unless this is explicitly true. Distinct from confirm_writes: that one is a
|
|
239
|
+
-- UX obligation on the agent, this one is a server-enforced transport gate.
|
|
240
|
+
allow_remote_writes boolean NOT NULL DEFAULT false,
|
|
241
|
+
created_by uuid REFERENCES users(id),
|
|
242
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
243
|
+
last_used_at timestamptz,
|
|
244
|
+
last_used_ip bytea, -- hashed (07 F29 reads it)
|
|
245
|
+
expires_at timestamptz,
|
|
246
|
+
revoked_at timestamptz
|
|
247
|
+
);
|
|
248
|
+
CREATE INDEX mcp_tokens_org_idx ON mcp_tokens (org_id) WHERE revoked_at IS NULL;
|
|
249
|
+
|
|
250
|
+
CREATE TABLE project_origins (
|
|
251
|
+
id uuid PRIMARY KEY,
|
|
252
|
+
org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
|
|
253
|
+
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
254
|
+
-- Exact origin ('https://staging.acme.com') or single-level wildcard host
|
|
255
|
+
-- ('https://*.acme.com'). Scheme is mandatory. Port is part of the origin.
|
|
256
|
+
pattern text NOT NULL,
|
|
257
|
+
is_wildcard boolean NOT NULL DEFAULT false,
|
|
258
|
+
label text,
|
|
259
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
260
|
+
UNIQUE (project_id, pattern),
|
|
261
|
+
CHECK (pattern ~ '^https?://[a-z0-9*.\-]+(:[0-9]{1,5})?$'),
|
|
262
|
+
CHECK (pattern = lower(pattern))
|
|
263
|
+
);
|
|
264
|
+
CREATE INDEX project_origins_project_idx ON project_origins (project_id);
|
|
265
|
+
|
|
266
|
+
CREATE TABLE environments (
|
|
267
|
+
id uuid PRIMARY KEY,
|
|
268
|
+
org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
|
|
269
|
+
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
270
|
+
key text NOT NULL, -- 'preview','staging','production'
|
|
271
|
+
label text NOT NULL,
|
|
272
|
+
-- origin/branch matchers used to auto-classify an incoming page (§6.5)
|
|
273
|
+
matchers jsonb NOT NULL DEFAULT '[]'::jsonb,
|
|
274
|
+
is_default boolean NOT NULL DEFAULT false,
|
|
275
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
276
|
+
deleted_at timestamptz,
|
|
277
|
+
UNIQUE (project_id, key),
|
|
278
|
+
CHECK (key ~ '^[a-z0-9][a-z0-9._-]{0,62}$')
|
|
279
|
+
);
|
|
280
|
+
CREATE UNIQUE INDEX environments_one_default ON environments (project_id) WHERE is_default;
|
|
281
|
+
|
|
282
|
+
CREATE TABLE rounds (
|
|
283
|
+
id uuid PRIMARY KEY, -- wire form 'rnd_…' (foundations §2.10)
|
|
284
|
+
org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
|
|
285
|
+
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
286
|
+
-- Display order and the default: the latest round is the one you land in.
|
|
287
|
+
-- A per-project sequence, minted at creation, never recomputed.
|
|
288
|
+
number integer NOT NULL,
|
|
289
|
+
-- "Round 1" is the generated name; the studio may rename it "Pre-launch".
|
|
290
|
+
name text NOT NULL,
|
|
291
|
+
-- Per-round thread numbering (01 §5.2, AC-1-12): "the round number is a
|
|
292
|
+
-- per-round sequence minted at creation and never recomputed; a deleted
|
|
293
|
+
-- thread leaves a gap". The counter lives here rather than being computed
|
|
294
|
+
-- from the threads, because computing it is what makes numbers move.
|
|
295
|
+
thread_seq integer NOT NULL DEFAULT 0,
|
|
296
|
+
-- A closed round still shows its unresolved count (D2's one safeguard) and
|
|
297
|
+
-- is still selectable; closing only means new threads go elsewhere.
|
|
298
|
+
closed_at timestamptz,
|
|
299
|
+
-- A STUDIO act, so `users`, not `reviewers`: a reviewer comments, a round is
|
|
300
|
+
-- opened and closed from the dashboard. It is also the only workable
|
|
301
|
+
-- reference at setup, where the first round is created inside the claim
|
|
302
|
+
-- transaction and no reviewer exists yet (§6.10 step 2) — a reviewers FK
|
|
303
|
+
-- there is unsatisfiable, which is how this was caught.
|
|
304
|
+
-- NULLable with SET NULL: the round outlives whoever opened it.
|
|
305
|
+
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
|
306
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
307
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
308
|
+
deleted_at timestamptz,
|
|
309
|
+
UNIQUE (project_id, number),
|
|
310
|
+
CHECK (number > 0),
|
|
311
|
+
CHECK (thread_seq >= 0)
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
-- The selector reads this: rounds of a project, newest first.
|
|
315
|
+
CREATE INDEX rounds_project_idx ON rounds (project_id, number DESC) WHERE deleted_at IS NULL;
|
|
316
|
+
|
|
317
|
+
CREATE TABLE review_links (
|
|
318
|
+
id uuid PRIMARY KEY,
|
|
319
|
+
org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
|
|
320
|
+
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
321
|
+
token_hash bytea NOT NULL UNIQUE, -- sha256(raw token). Raw is never stored.
|
|
322
|
+
-- The link's LABEL, not an invitee's name: "Client review — round 2".
|
|
323
|
+
-- D3 removed per-person minting, and a field that asks for a person's name
|
|
324
|
+
-- reintroduces invite management through the back door. The dashboard shows
|
|
325
|
+
-- ONE current link per project (M2-27) and offers revoke-and-replace; the
|
|
326
|
+
-- table stays many-rowed so an outgoing link can stay live through a
|
|
327
|
+
-- handover, which M2-27's own DoD requires ("minting a replacement link does
|
|
328
|
+
-- not revoke the old one").
|
|
329
|
+
name text NOT NULL,
|
|
330
|
+
-- Optional, and only so §6.4b's placeholder attach can match on lower(email)
|
|
331
|
+
-- when the studio happens to know it. Never required to mint.
|
|
332
|
+
email text,
|
|
333
|
+
environments text[] NOT NULL DEFAULT '{*}',
|
|
334
|
+
-- D2/M2-18: a link may be PINNED to a round, so a client invited for round 2
|
|
335
|
+
-- lands in round 2 rather than in whatever is active when they open it.
|
|
336
|
+
-- NULL = every round, which is the default and the common case.
|
|
337
|
+
--
|
|
338
|
+
-- Present from 0001 rather than added later: §3 IS the migration
|
|
339
|
+
-- (apps/server/scripts/gen-migration.mjs generates it from these blocks, and
|
|
340
|
+
-- CI runs gen:migration:check), so a column added afterwards is a second
|
|
341
|
+
-- migration on a table this feature owns. The plan asserted round pinning in
|
|
342
|
+
-- M2-18's DoD while no column existed to carry it — caught by reading the
|
|
343
|
+
-- DoD against the DDL.
|
|
344
|
+
--
|
|
345
|
+
-- SET NULL, not CASCADE: deleting a round must not silently delete the link
|
|
346
|
+
-- a client is holding. It widens to every round instead, which is visible.
|
|
347
|
+
round_id uuid REFERENCES rounds(id) ON DELETE SET NULL,
|
|
348
|
+
-- The DEFAULT is the client link. A "team link" -- the studio reviewing its
|
|
349
|
+
-- own work through the widget rather than the dashboard -- additionally
|
|
350
|
+
-- carries 'thread:private' and 'thread:assign' (§6.4a, §6.4b). Those two are
|
|
351
|
+
-- the ONLY route by which a widget session sees or writes team-only content.
|
|
352
|
+
permissions text[] NOT NULL DEFAULT '{thread:create,comment:create,thread:resolve}',
|
|
353
|
+
allow_name_override boolean NOT NULL DEFAULT true,
|
|
354
|
+
max_uses integer, -- NULL = unlimited. Counts PEOPLE (§4.5).
|
|
355
|
+
use_count integer NOT NULL DEFAULT 0,
|
|
356
|
+
created_by uuid REFERENCES users(id),
|
|
357
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
358
|
+
-- default now() + SNAGSET_REVIEW_LINK_TTL_DAYS (14), clamped by
|
|
359
|
+
-- SNAGSET_REVIEW_LINK_MAX_TTL_DAYS (90). Foundations §7's two numbers.
|
|
360
|
+
expires_at timestamptz NOT NULL,
|
|
361
|
+
revoked_at timestamptz,
|
|
362
|
+
last_used_at timestamptz,
|
|
363
|
+
CHECK (expires_at > created_at),
|
|
364
|
+
CHECK (max_uses IS NULL OR max_uses > 0)
|
|
365
|
+
);
|
|
366
|
+
CREATE INDEX review_links_project_idx ON review_links (project_id, created_at DESC);
|
|
367
|
+
CREATE INDEX review_links_expiry_idx ON review_links (expires_at) WHERE revoked_at IS NULL;
|
|
368
|
+
|
|
369
|
+
CREATE TABLE reviewers (
|
|
370
|
+
id uuid PRIMARY KEY,
|
|
371
|
+
org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
|
|
372
|
+
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
373
|
+
kind snag_reviewer_kind NOT NULL,
|
|
374
|
+
name text NOT NULL,
|
|
375
|
+
email text,
|
|
376
|
+
avatar_url text,
|
|
377
|
+
external_id text, -- host-app user id, kind='verified' only
|
|
378
|
+
review_link_id uuid REFERENCES review_links(id) ON DELETE SET NULL,
|
|
379
|
+
-- STUDIO IDENTITY (FOLDBACK §A3). A dashboard user's reviewer row in this
|
|
380
|
+
-- project. Set => this person is team: they are in the assignee pool without
|
|
381
|
+
-- having commented, and they are the half of §6.4b's union that is not
|
|
382
|
+
-- "everyone who has commented". It is also the structural answer to "is this
|
|
383
|
+
-- person studio?", which §6.4a's private predicate needs.
|
|
384
|
+
--
|
|
385
|
+
-- RESTRICT, not SET NULL, and the CHECKs below are why. `user_id` is not a
|
|
386
|
+
-- decoration on a verified row, it IS the verification — "vouched for by the
|
|
387
|
+
-- host app (external_id) OR one of our own dashboard users (user_id)". Null
|
|
388
|
+
-- it and the row asserts a verification nothing backs, which is exactly what
|
|
389
|
+
-- `CHECK (kind <> 'verified' OR external_id IS NOT NULL OR user_id IS NOT
|
|
390
|
+
-- NULL)` forbids. Draft 4 had SET NULL here, and the two rules contradicted
|
|
391
|
+
-- for one reachable combination: a studio member replies from the dashboard
|
|
392
|
+
-- (kind='verified', user_id set, external_id null), then somebody deletes
|
|
393
|
+
-- that member — the FK nulls the column, the CHECK rejects the row, and the
|
|
394
|
+
-- DELETE fails with a constraint violation instead of a sentence. Found on
|
|
395
|
+
-- 2026-08-21 by deleting a throwaway owner after a test.
|
|
396
|
+
--
|
|
397
|
+
-- So a person who has authored work cannot be HARD-deleted, which is the
|
|
398
|
+
-- honest rule: `users.deleted_at` exists, `ownerAuth` already filters on it,
|
|
399
|
+
-- and soft delete is how somebody leaves a studio without their replies
|
|
400
|
+
-- losing their name.
|
|
401
|
+
user_id uuid REFERENCES users(id) ON DELETE RESTRICT,
|
|
402
|
+
-- PLACEHOLDER (DESIGN.md Q4's '+ Add person'). A named person who has not yet
|
|
403
|
+
-- written anything and holds no credential: assignable, not authenticable.
|
|
404
|
+
-- Attaches to a real identity on a later 'link'/'verified' exchange (§6.4b).
|
|
405
|
+
--
|
|
406
|
+
-- RESTRICT for the same reason one line up, via the SECOND check: a
|
|
407
|
+
-- placeholder's only claim to existing is `invited_by`, so nulling it leaves
|
|
408
|
+
-- a row with no credential, no user and no inviter — which
|
|
409
|
+
-- `CHECK (kind = 'verified' OR holder_secret_hash IS NOT NULL OR user_id IS
|
|
410
|
+
-- NOT NULL OR invited_by IS NOT NULL)` forbids. Same shape, same fix, found
|
|
411
|
+
-- by reading rather than by breaking.
|
|
412
|
+
invited_by uuid REFERENCES users(id) ON DELETE RESTRICT,
|
|
413
|
+
-- One row per PERSON. For kind='guest' and kind='link' this is sha256 of a
|
|
414
|
+
-- 32-byte secret the holder keeps in localStorage 'snag:identity' (§4.4.1, §4.5).
|
|
415
|
+
holder_secret_hash bytea,
|
|
416
|
+
-- MCP/agent authorship (§9.6, v2 feature; columns exist from 0001 so the
|
|
417
|
+
-- behaviour and its CHECK ship together rather than as a later ALTER).
|
|
418
|
+
is_agent boolean NOT NULL DEFAULT false,
|
|
419
|
+
agent_mcp_token_id uuid REFERENCES mcp_tokens(id) ON DELETE SET NULL,
|
|
420
|
+
-- per-identity revocation without nuking the project epoch (§4.6)
|
|
421
|
+
identity_valid_after timestamptz,
|
|
422
|
+
blocked_at timestamptz,
|
|
423
|
+
erased_at timestamptz, -- §13 subject erasure
|
|
424
|
+
first_seen_at timestamptz NOT NULL DEFAULT now(),
|
|
425
|
+
last_seen_at timestamptz NOT NULL DEFAULT now(),
|
|
426
|
+
thread_count integer NOT NULL DEFAULT 0,
|
|
427
|
+
comment_count integer NOT NULL DEFAULT 0,
|
|
428
|
+
-- A verified reviewer is vouched for by the host app (external_id) OR is one
|
|
429
|
+
-- of our own dashboard users (user_id). Draft 4 required external_id, which
|
|
430
|
+
-- left a studio member with no representable reviewer identity at all.
|
|
431
|
+
CHECK (kind <> 'verified' OR external_id IS NOT NULL OR user_id IS NOT NULL),
|
|
432
|
+
-- Every non-verified row is one of: a credential holder, a studio member, or
|
|
433
|
+
-- a placeholder somebody named. Nothing else may exist.
|
|
434
|
+
CHECK (kind = 'verified' OR holder_secret_hash IS NOT NULL
|
|
435
|
+
OR user_id IS NOT NULL
|
|
436
|
+
OR invited_by IS NOT NULL),
|
|
437
|
+
CHECK (kind <> 'link' OR review_link_id IS NOT NULL),
|
|
438
|
+
CHECK (NOT is_agent OR kind = 'guest'),
|
|
439
|
+
-- A studio identity is never an agent and never a placeholder.
|
|
440
|
+
CHECK (user_id IS NULL OR (NOT is_agent AND invited_by IS NULL))
|
|
441
|
+
);
|
|
442
|
+
CREATE UNIQUE INDEX reviewers_verified_uniq ON reviewers (project_id, external_id)
|
|
443
|
+
WHERE kind = 'verified' AND external_id IS NOT NULL;
|
|
444
|
+
-- One studio reviewer row per user per project (FOLDBACK §A3).
|
|
445
|
+
CREATE UNIQUE INDEX reviewers_user_uniq ON reviewers (project_id, user_id)
|
|
446
|
+
WHERE user_id IS NOT NULL;
|
|
447
|
+
-- ONE ROW PER PERSON PER LINK, not one row per link. See §4.5.
|
|
448
|
+
CREATE UNIQUE INDEX reviewers_link_holder_uniq
|
|
449
|
+
ON reviewers (project_id, review_link_id, holder_secret_hash) WHERE kind = 'link';
|
|
450
|
+
CREATE UNIQUE INDEX reviewers_guest_holder_uniq
|
|
451
|
+
ON reviewers (project_id, holder_secret_hash)
|
|
452
|
+
WHERE kind = 'guest' AND holder_secret_hash IS NOT NULL;
|
|
453
|
+
CREATE INDEX reviewers_project_idx ON reviewers (project_id, last_seen_at DESC);
|
|
454
|
+
CREATE INDEX reviewers_link_idx ON reviewers (review_link_id) WHERE kind = 'link';
|
|
455
|
+
-- The assignee pool query (§6.4b): studio people first, then anyone who wrote.
|
|
456
|
+
CREATE INDEX reviewers_pool_idx ON reviewers (project_id, last_seen_at DESC)
|
|
457
|
+
WHERE blocked_at IS NULL AND erased_at IS NULL
|
|
458
|
+
AND (user_id IS NOT NULL OR invited_by IS NOT NULL
|
|
459
|
+
OR thread_count > 0 OR comment_count > 0);
|
|
460
|
+
-- Attaching a placeholder to the person it was created for (§6.4b), by an email
|
|
461
|
+
-- the SERVER supplied -- never one a guest typed about themselves.
|
|
462
|
+
CREATE INDEX reviewers_email_idx ON reviewers (project_id, lower(email))
|
|
463
|
+
WHERE email IS NOT NULL AND erased_at IS NULL;
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
CREATE TABLE threads (
|
|
468
|
+
id uuid PRIMARY KEY, -- client-minted UUIDv7; wire form 'thr_…'
|
|
469
|
+
org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
|
|
470
|
+
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
471
|
+
key text NOT NULL, -- 'SN-14' (foundations §2.10)
|
|
472
|
+
-- Monotonic revision, bumped in the same transaction as ANY mutation of this
|
|
473
|
+
-- thread or of its comments (§14.2b; 07 §2.5 builds its outbox dedupe_key from
|
|
474
|
+
-- it). Also the ETag source for If-Match (§6.4).
|
|
475
|
+
version integer NOT NULL DEFAULT 1,
|
|
476
|
+
environment_id uuid NOT NULL REFERENCES environments(id) ON DELETE RESTRICT,
|
|
477
|
+
-- Thread.scope.round (foundations §5, D2). NOT NULL and RESTRICT: a thread
|
|
478
|
+
-- belongs to exactly one round and threads never move, so there is no
|
|
479
|
+
-- reassignment path and deleting a round with threads in it is refused
|
|
480
|
+
-- rather than silently orphaning them.
|
|
481
|
+
round_id uuid NOT NULL REFERENCES rounds(id) ON DELETE RESTRICT,
|
|
482
|
+
-- Creation order WITHIN the round (01 §5.2) — the number on the pin. Minted
|
|
483
|
+
-- from rounds.thread_seq at creation, never recomputed: AC-1-12 requires
|
|
484
|
+
-- that deleting comment 4 leaves comment 5 called 5, everywhere.
|
|
485
|
+
round_number integer NOT NULL,
|
|
486
|
+
branch text,
|
|
487
|
+
-- AnchorRecord (foundations §5), stored verbatim, upcast on read (§11.3)
|
|
488
|
+
anchor jsonb NOT NULL,
|
|
489
|
+
anchor_schema text NOT NULL DEFAULT 'snagset.v1',
|
|
490
|
+
anchor_state snag_anchor_state NOT NULL DEFAULT 'anchored',
|
|
491
|
+
-- monotonic-merge scope for anchor_state (§6.7). NULL = no build reported yet.
|
|
492
|
+
anchor_state_build text,
|
|
493
|
+
last_anchor_check timestamptz,
|
|
494
|
+
-- CaptureContext (foundations §5), already redacted client-side (04/A11)
|
|
495
|
+
context jsonb NOT NULL,
|
|
496
|
+
context_schema text NOT NULL DEFAULT 'snagset.v1',
|
|
497
|
+
status snag_thread_status NOT NULL DEFAULT 'open',
|
|
498
|
+
moderation snag_moderation NOT NULL DEFAULT 'visible',
|
|
499
|
+
-- TEAM-ONLY (foundations §5, FOLDBACK §A4). Excluded for a client AT THE DATA
|
|
500
|
+
-- LAYER by §6.4a's predicate, never rendered-then-hidden. Studio-only to set.
|
|
501
|
+
private boolean NOT NULL DEFAULT false,
|
|
502
|
+
created_by uuid NOT NULL REFERENCES reviewers(id) ON DELETE RESTRICT,
|
|
503
|
+
-- Pool is studio people ∪ everyone who has commented (FOLDBACK §A3, §6.4b).
|
|
504
|
+
assignee_id uuid REFERENCES reviewers(id) ON DELETE SET NULL, -- foundations §5
|
|
505
|
+
-- DEFERRED to v2 (FOLDBACK §A2). The column, the CHECK and foundations §5's
|
|
506
|
+
-- field all survive so that re-adding is additive; NO v1 route reads or
|
|
507
|
+
-- writes it, and no v1 request body may carry it (§6.2, §6.4, §11.1 rule 3).
|
|
508
|
+
labels text[] NOT NULL DEFAULT '{}', -- foundations §5
|
|
509
|
+
resolved_by uuid REFERENCES reviewers(id) ON DELETE SET NULL,
|
|
510
|
+
comment_count integer NOT NULL DEFAULT 0,
|
|
511
|
+
-- The same count under §6.4a's predicate. Maintained in the same transaction
|
|
512
|
+
-- as comment_count; it is what a client is told, so that a client can never
|
|
513
|
+
-- count replies it is not allowed to read (§6.4a).
|
|
514
|
+
comment_count_public integer NOT NULL DEFAULT 0,
|
|
515
|
+
-- derived, indexable projections of the anchor. Generated → always consistent.
|
|
516
|
+
route text GENERATED ALWAYS AS (anchor #>> '{page,route}') STORED,
|
|
517
|
+
pathname text GENERATED ALWAYS AS (anchor #>> '{page,pathname}') STORED,
|
|
518
|
+
origin text GENERATED ALWAYS AS (anchor #>> '{page,origin}') STORED,
|
|
519
|
+
-- AnchorRecord.label (foundations §5, FOLDBACK §A7): '"Browse the autumn
|
|
520
|
+
-- range"', 'an image', 'the h1'. Projected because the orphan list, the
|
|
521
|
+
-- export and v2 search all read it AFTER the element it describes is gone.
|
|
522
|
+
-- DISPLAY ONLY. Never a re-anchor input -- matching is 03's, and this string
|
|
523
|
+
-- is a human description, not a selector.
|
|
524
|
+
anchor_label text GENERATED ALWAYS AS (anchor #>> '{label}') STORED,
|
|
525
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
526
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
527
|
+
resolved_at timestamptz,
|
|
528
|
+
deleted_at timestamptz,
|
|
529
|
+
UNIQUE (project_id, key),
|
|
530
|
+
UNIQUE (round_id, round_number),
|
|
531
|
+
CHECK (version > 0),
|
|
532
|
+
CHECK (round_number > 0),
|
|
533
|
+
CHECK (jsonb_typeof(anchor) = 'object' AND anchor ? 'offset' AND anchor ? 'page'),
|
|
534
|
+
CHECK (jsonb_typeof(context) = 'object'),
|
|
535
|
+
CHECK (octet_length(anchor::text) <= 32768),
|
|
536
|
+
CHECK (octet_length(context::text) <= 262144),
|
|
537
|
+
CHECK (array_length(labels, 1) IS NULL OR array_length(labels, 1) <= 32),
|
|
538
|
+
CHECK ((status = 'resolved') = (resolved_at IS NOT NULL)),
|
|
539
|
+
CHECK (comment_count_public >= 0 AND comment_count_public <= comment_count),
|
|
540
|
+
-- A team-only thread's replies are all team-only by construction, so its
|
|
541
|
+
-- public count is zero. Cheap, and it makes the leak in §6.4a impossible
|
|
542
|
+
-- rather than merely unlikely.
|
|
543
|
+
CHECK (NOT private OR comment_count_public = 0)
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
CREATE TABLE comments (
|
|
547
|
+
id uuid PRIMARY KEY, -- wire form 'cmt_…'
|
|
548
|
+
org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
|
|
549
|
+
thread_id uuid NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
|
550
|
+
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE, -- denormalised for RLS + rate accounting
|
|
551
|
+
body text, -- NULL when soft-deleted (tombstone)
|
|
552
|
+
body_hash bytea, -- sha256, duplicate detection (§9.4)
|
|
553
|
+
-- parsed at write time from @[Name](snag:user/<reviewerId>) in the body, so
|
|
554
|
+
-- notification fan-out never re-parses markdown (foundations §5, §2.11)
|
|
555
|
+
mentions uuid[] NOT NULL DEFAULT '{}',
|
|
556
|
+
author_id uuid NOT NULL REFERENCES reviewers(id) ON DELETE RESTRICT,
|
|
557
|
+
moderation snag_moderation NOT NULL DEFAULT 'visible',
|
|
558
|
+
-- TEAM-ONLY REPLY (foundations §5, FOLDBACK §A5). A private reply on a PUBLIC
|
|
559
|
+
-- thread is studio-only; the thread itself stays visible to the client. Same
|
|
560
|
+
-- data-layer exclusion as threads (§6.4a).
|
|
561
|
+
private boolean NOT NULL DEFAULT false,
|
|
562
|
+
seq integer NOT NULL, -- 1-based position inside the thread
|
|
563
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
564
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
565
|
+
edited_at timestamptz,
|
|
566
|
+
deleted_at timestamptz,
|
|
567
|
+
UNIQUE (thread_id, seq),
|
|
568
|
+
CHECK (body IS NULL OR char_length(body) <= 10000), -- foundations §6 budget
|
|
569
|
+
CHECK ((body IS NULL) = (deleted_at IS NOT NULL))
|
|
570
|
+
);
|
|
571
|
+
|
|
572
|
+
CREATE TABLE attachments (
|
|
573
|
+
id uuid PRIMARY KEY, -- client-minted; wire form 'att_…'
|
|
574
|
+
org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
|
|
575
|
+
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
576
|
+
thread_id uuid REFERENCES threads(id) ON DELETE CASCADE,
|
|
577
|
+
comment_id uuid REFERENCES comments(id) ON DELETE CASCADE,
|
|
578
|
+
kind snag_attachment_kind NOT NULL,
|
|
579
|
+
state snag_attachment_state NOT NULL DEFAULT 'pending',
|
|
580
|
+
storage_key text NOT NULL UNIQUE,
|
|
581
|
+
content_type text NOT NULL,
|
|
582
|
+
original_filename text, -- L3: downloads need a name; keys are hashes
|
|
583
|
+
storage_backend text NOT NULL DEFAULT 's3'
|
|
584
|
+
CHECK (storage_backend = 's3'), -- D18: bytes never live in Postgres
|
|
585
|
+
bytes bigint NOT NULL CHECK (bytes > 0), -- per-kind ceilings below
|
|
586
|
+
width integer,
|
|
587
|
+
height integer,
|
|
588
|
+
sha256 bytea NOT NULL,
|
|
589
|
+
uploaded_by uuid REFERENCES reviewers(id) ON DELETE SET NULL,
|
|
590
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
591
|
+
bound_at timestamptz,
|
|
592
|
+
deleted_at timestamptz,
|
|
593
|
+
CHECK ((state = 'bound') = (bound_at IS NOT NULL)),
|
|
594
|
+
-- FOLDBACK §A6: an attachment hangs off EXACTLY ONE of a thread or a comment.
|
|
595
|
+
-- Draft 4 left both nullable with nothing enforcing the choice, which is how
|
|
596
|
+
-- a screenshot ends up counted twice and rendered in two places.
|
|
597
|
+
CHECK (state <> 'bound' OR ((thread_id IS NOT NULL) <> (comment_id IS NOT NULL))),
|
|
598
|
+
CHECK (state = 'bound' OR (thread_id IS NULL AND comment_id IS NULL)),
|
|
599
|
+
-- D18: image kinds keep the closed type list and the 256 KB encode budget.
|
|
600
|
+
-- kind='file' stores as application/octet-stream in the bucket (content_type here
|
|
601
|
+
-- is an icon hint only, never a serving header) and carries the operator cap,
|
|
602
|
+
-- enforced at presign + complete time rather than in DDL.
|
|
603
|
+
CHECK (kind NOT IN ('screenshot','image') OR content_type IN ('image/webp','image/png','image/jpeg')),
|
|
604
|
+
CHECK (kind NOT IN ('screenshot','image') OR bytes <= 262144),
|
|
605
|
+
CHECK (kind <> 'file' OR original_filename IS NOT NULL), -- L3: the download filename
|
|
606
|
+
CHECK (kind <> 'dom-snapshot') -- dropped by the v2 migration that ships dom-snapshot
|
|
607
|
+
);
|
|
608
|
+
CREATE INDEX attachments_reap_idx ON attachments (created_at) WHERE state = 'pending';
|
|
609
|
+
CREATE INDEX attachments_dedupe_idx ON attachments (project_id, sha256);
|
|
610
|
+
-- Enforcing "max 3 per item" (FOLDBACK §A6) at bind time; see §6.6's bind rules.
|
|
611
|
+
CREATE INDEX attachments_thread_idx ON attachments (thread_id) WHERE deleted_at IS NULL;
|
|
612
|
+
CREATE INDEX attachments_comment_idx ON attachments (comment_id) WHERE deleted_at IS NULL;
|
|
613
|
+
|
|
614
|
+
-- Revocable, per-consumer references to an attachment (§6.6, §6.8). Foundations
|
|
615
|
+
-- §14.2: ONE mechanism for embedding a screenshot in an external system.
|
|
616
|
+
-- 05 owns the table and the routes; 07 and 08 consume and define nothing.
|
|
617
|
+
CREATE TABLE attachment_shares (
|
|
618
|
+
id uuid PRIMARY KEY,
|
|
619
|
+
org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
|
|
620
|
+
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
621
|
+
attachment_id uuid NOT NULL REFERENCES attachments(id) ON DELETE CASCADE,
|
|
622
|
+
provider snag_provider NOT NULL,
|
|
623
|
+
external_ref_id uuid, -- the issue/message this was embedded in
|
|
624
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
625
|
+
expires_at timestamptz, -- NULL = until revoked (SNAGSET_ATTACHMENT_SHARE_TTL_DAYS=0)
|
|
626
|
+
revoked_at timestamptz,
|
|
627
|
+
last_fetched_at timestamptz,
|
|
628
|
+
fetch_count integer NOT NULL DEFAULT 0,
|
|
629
|
+
UNIQUE (attachment_id, provider, external_ref_id)
|
|
630
|
+
);
|
|
631
|
+
CREATE INDEX attachment_shares_att_idx ON attachment_shares (attachment_id)
|
|
632
|
+
WHERE revoked_at IS NULL;
|
|
633
|
+
|
|
634
|
+
CREATE TABLE integrations (
|
|
635
|
+
id uuid PRIMARY KEY,
|
|
636
|
+
org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
|
|
637
|
+
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
638
|
+
provider snag_provider NOT NULL,
|
|
639
|
+
-- Non-secret config (repo, labels, channel id, attachment share TTL,
|
|
640
|
+
-- includeScreenshot). 07 owns the shape; §3.10 validates per provider.
|
|
641
|
+
config jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
642
|
+
credentials bytea, -- AES-256-GCM, HKDF(SNAGSET_SECRET_KEY,'credential-enc')
|
|
643
|
+
credentials_iv bytea,
|
|
644
|
+
enabled boolean NOT NULL DEFAULT true,
|
|
645
|
+
install_state text NOT NULL DEFAULT 'ok', -- ok | needs_reauth | error
|
|
646
|
+
last_error text,
|
|
647
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
648
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
649
|
+
deleted_at timestamptz,
|
|
650
|
+
UNIQUE (project_id, provider)
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
CREATE TABLE external_refs (
|
|
654
|
+
id uuid PRIMARY KEY,
|
|
655
|
+
org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
|
|
656
|
+
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
657
|
+
thread_id uuid NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
|
658
|
+
provider snag_provider NOT NULL,
|
|
659
|
+
kind snag_ref_kind NOT NULL, -- foundations §5, five members
|
|
660
|
+
status snag_ref_status NOT NULL DEFAULT 'synced',
|
|
661
|
+
external_id text NOT NULL,
|
|
662
|
+
url text NOT NULL,
|
|
663
|
+
synced_at timestamptz NOT NULL DEFAULT now(),
|
|
664
|
+
last_error text,
|
|
665
|
+
-- §14.2b: the compound unique is the idempotency key for "we already recorded
|
|
666
|
+
-- THIS remote object". It permits a thread to carry a GitHub issue AND several
|
|
667
|
+
-- PR links AND attachment backlinks.
|
|
668
|
+
UNIQUE (thread_id, provider, kind, external_id)
|
|
669
|
+
);
|
|
670
|
+
-- …and exactly one canonical issue and one canonical message per (thread, provider),
|
|
671
|
+
-- because "which issue is this thread's issue?" must have one answer. Draft 3's
|
|
672
|
+
-- blanket UNIQUE (thread_id, provider, kind) forbade an issue AND a PR; this pair
|
|
673
|
+
-- is 07's shape and permits it.
|
|
674
|
+
CREATE UNIQUE INDEX external_refs_canonical
|
|
675
|
+
ON external_refs (thread_id, provider, kind)
|
|
676
|
+
WHERE kind IN ('issue','message');
|
|
677
|
+
CREATE INDEX external_refs_lookup ON external_refs (provider, external_id);
|
|
678
|
+
|
|
679
|
+
-- Transactional outbox (v1), 07 §2.5's shape verbatim. Rows are inserted in the
|
|
680
|
+
-- SAME transaction as the thread/comment mutation -- that transactionality plus
|
|
681
|
+
-- the unique below IS the idempotency guarantee. §6.2 step 10 does it.
|
|
682
|
+
CREATE TABLE outbox (
|
|
683
|
+
id uuid PRIMARY KEY, -- UUIDv7. NOT bigserial (foundations §2.10).
|
|
684
|
+
org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
|
|
685
|
+
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
686
|
+
integration_id uuid REFERENCES integrations(id) ON DELETE CASCADE,
|
|
687
|
+
thread_id uuid REFERENCES threads(id) ON DELETE CASCADE,
|
|
688
|
+
provider snag_provider NOT NULL,
|
|
689
|
+
topic text NOT NULL, -- vocabulary owned by 07
|
|
690
|
+
-- 07's dedupe key: '<topic>:<entityId>:<threads.version>'. Its COMPOSITION is
|
|
691
|
+
-- 07's; its uniqueness is enforced here.
|
|
692
|
+
dedupe_key text NOT NULL,
|
|
693
|
+
event jsonb NOT NULL DEFAULT '{}'::jsonb, -- the raw DomainEvent, sanitised at push time
|
|
694
|
+
status snag_outbox_status NOT NULL DEFAULT 'pending',
|
|
695
|
+
attempts integer NOT NULL DEFAULT 0,
|
|
696
|
+
next_attempt_at timestamptz NOT NULL DEFAULT now(),
|
|
697
|
+
last_error text,
|
|
698
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
699
|
+
completed_at timestamptz,
|
|
700
|
+
-- THE idempotency guarantee against duplicate issue creation (§14.2b).
|
|
701
|
+
-- Draft 3 dropped it while still calling the outbox "the idempotency guarantee".
|
|
702
|
+
UNIQUE (provider, dedupe_key),
|
|
703
|
+
CHECK (octet_length(event::text) <= 65536)
|
|
704
|
+
);
|
|
705
|
+
CREATE INDEX outbox_due ON outbox (status, next_attempt_at) WHERE status = 'pending';
|
|
706
|
+
|
|
707
|
+
-- Sync ledger, 07 §2.5's shape. OUTBOUND half is v1 (it is what makes v2's loop
|
|
708
|
+
-- prevention a pure addition rather than a retrofit); the INBOUND half ships with
|
|
709
|
+
-- two-way sync in v2 (foundations §14.2).
|
|
710
|
+
CREATE TABLE sync_ledger (
|
|
711
|
+
id uuid PRIMARY KEY,
|
|
712
|
+
org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
|
|
713
|
+
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
714
|
+
provider snag_provider NOT NULL,
|
|
715
|
+
direction snag_sync_direction NOT NULL,
|
|
716
|
+
delivery_id text NOT NULL, -- provider delivery id (outbound: our outbox id)
|
|
717
|
+
entity_kind snag_sync_entity NOT NULL,
|
|
718
|
+
entity_id uuid, -- thread or comment row id
|
|
719
|
+
external_id text,
|
|
720
|
+
-- THE loop-prevention key (§14.2b): sha256 of the normalised body. Without it
|
|
721
|
+
-- a comment synced out and read back in is re-applied, and echoes forever.
|
|
722
|
+
content_hash bytea NOT NULL,
|
|
723
|
+
thread_id uuid REFERENCES threads(id) ON DELETE SET NULL,
|
|
724
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
725
|
+
UNIQUE (provider, direction, delivery_id)
|
|
726
|
+
);
|
|
727
|
+
CREATE INDEX sync_ledger_content
|
|
728
|
+
ON sync_ledger (provider, entity_kind, content_hash, created_at DESC);
|
|
729
|
+
|
|
730
|
+
-- 07's name (draft 3 called this 'webhooks'), §14.2b. The ROUTE stays
|
|
731
|
+
-- /projects/{uid}/webhooks (endpoint 26) because that is the resource name 07
|
|
732
|
+
-- cites and customers type; the table is the endpoint record.
|
|
733
|
+
CREATE TABLE webhook_endpoints (
|
|
734
|
+
id uuid PRIMARY KEY,
|
|
735
|
+
org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
|
|
736
|
+
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
737
|
+
url text NOT NULL,
|
|
738
|
+
secret bytea NOT NULL, -- encrypted under 'credential-enc'
|
|
739
|
+
secret_iv bytea NOT NULL,
|
|
740
|
+
event_types text[] NOT NULL DEFAULT '{*}',
|
|
741
|
+
enabled boolean NOT NULL DEFAULT true,
|
|
742
|
+
failure_streak integer NOT NULL DEFAULT 0,
|
|
743
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
744
|
+
disabled_at timestamptz,
|
|
745
|
+
disabled_reason text,
|
|
746
|
+
-- http:// is permitted AT THE DB LEVEL so that SNAGSET_WEBHOOKS_ALLOW_HTTP
|
|
747
|
+
-- (foundations §2.9, registered in 08 §4.6, default false) is not dead
|
|
748
|
+
-- configuration. The scheme rule is enforced in the app (§6.12) together with
|
|
749
|
+
-- the SSRF address-space guard, which is a separate switch.
|
|
750
|
+
CHECK (url ~ '^https?://')
|
|
751
|
+
);
|
|
752
|
+
CREATE INDEX webhook_endpoints_project_idx ON webhook_endpoints (project_id)
|
|
753
|
+
WHERE disabled_at IS NULL;
|
|
754
|
+
|
|
755
|
+
CREATE TABLE events (
|
|
756
|
+
id uuid PRIMARY KEY, -- UUIDv7; the row's identity
|
|
757
|
+
seq bigint GENERATED ALWAYS AS IDENTITY UNIQUE, -- total-order cursor (webhook replay, audit paging); pre-R6 it also fed SSE
|
|
758
|
+
org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
|
|
759
|
+
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
760
|
+
environment_id uuid REFERENCES environments(id) ON DELETE SET NULL,
|
|
761
|
+
type text NOT NULL, -- see §8.2
|
|
762
|
+
actor_kind text NOT NULL, -- reviewer|user|system|integration
|
|
763
|
+
actor_id uuid,
|
|
764
|
+
subject_type text NOT NULL, -- thread|comment|project|review_link|attachment
|
|
765
|
+
subject_id uuid,
|
|
766
|
+
route text,
|
|
767
|
+
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
768
|
+
ip_hash bytea, -- sha256(ip || projects.ip_salt); never raw IP
|
|
769
|
+
user_agent text,
|
|
770
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
771
|
+
CHECK (octet_length(payload::text) <= 16384)
|
|
772
|
+
);
|
|
773
|
+
CREATE INDEX events_project_seq ON events (project_id, seq);
|
|
774
|
+
|
|
775
|
+
-- 07 §7.4's delivery log, verbatim.
|
|
776
|
+
CREATE TABLE webhook_deliveries (
|
|
777
|
+
id uuid PRIMARY KEY, -- this IS the Standard Webhooks 'webhook-id'
|
|
778
|
+
org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
|
|
779
|
+
endpoint_id uuid NOT NULL REFERENCES webhook_endpoints(id) ON DELETE CASCADE,
|
|
780
|
+
event_id uuid REFERENCES events(id) ON DELETE SET NULL,
|
|
781
|
+
event_type text NOT NULL,
|
|
782
|
+
attempts integer NOT NULL DEFAULT 0,
|
|
783
|
+
status text NOT NULL DEFAULT 'pending', -- pending|delivered|failed|dropped
|
|
784
|
+
request_headers jsonb NOT NULL DEFAULT '{}'::jsonb, -- secret-free by construction
|
|
785
|
+
request_body_hash bytea NOT NULL,
|
|
786
|
+
request_body_excerpt text, -- first 8 KB of the ALREADY-SANITISED payload
|
|
787
|
+
response_code integer,
|
|
788
|
+
response_body_excerpt text, -- first 2 KB
|
|
789
|
+
duration_ms integer,
|
|
790
|
+
error_class text,
|
|
791
|
+
next_attempt_at timestamptz NOT NULL DEFAULT now(),
|
|
792
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
793
|
+
delivered_at timestamptz,
|
|
794
|
+
replay_of uuid REFERENCES webhook_deliveries(id) ON DELETE SET NULL,
|
|
795
|
+
CHECK (octet_length(coalesce(request_body_excerpt,'')) <= 8192),
|
|
796
|
+
CHECK (octet_length(coalesce(response_body_excerpt,'')) <= 2048)
|
|
797
|
+
);
|
|
798
|
+
CREATE INDEX webhook_deliveries_due ON webhook_deliveries (next_attempt_at) WHERE status = 'pending';
|
|
799
|
+
CREATE INDEX webhook_deliveries_endpoint ON webhook_deliveries (endpoint_id, created_at DESC);
|
|
800
|
+
|
|
801
|
+
CREATE INDEX events_env_seq ON events (project_id, environment_id, seq);
|
|
802
|
+
CREATE INDEX events_subject ON events (subject_type, subject_id, seq DESC);
|
|
803
|
+
CREATE INDEX events_created ON events (created_at);
|
|
804
|
+
|
|
805
|
+
-- Append-only re-anchor outcomes. Backs Thread.anchorHistory (foundations §5),
|
|
806
|
+
-- 03's re-anchor reporting, and 08 §6's cedilla_anchor_resolutions_total metric.
|
|
807
|
+
CREATE TABLE anchor_events (
|
|
808
|
+
id uuid PRIMARY KEY,
|
|
809
|
+
org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
|
|
810
|
+
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
811
|
+
thread_id uuid NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
|
812
|
+
at timestamptz NOT NULL DEFAULT now(),
|
|
813
|
+
state snag_anchor_state NOT NULL, -- settled only; 'pending' is rejected upstream
|
|
814
|
+
confidence real NOT NULL CHECK (confidence >= 0 AND confidence <= 1),
|
|
815
|
+
strategy text NOT NULL, -- validated against 03's AnchorStrategy enum
|
|
816
|
+
actor_kind text NOT NULL, -- 'runtime' (automatic) | 'reviewer' (repair, v2)
|
|
817
|
+
actor_id uuid REFERENCES reviewers(id) ON DELETE SET NULL,
|
|
818
|
+
build text, -- CaptureContext.build.commit when supplied
|
|
819
|
+
ms integer, -- cascade wall-clock, for 03's telemetry
|
|
820
|
+
applied boolean NOT NULL DEFAULT true, -- false when the monotonic merge declined it
|
|
821
|
+
previous_anchor jsonb -- only on 'manual' repair, so a bad repair is undoable
|
|
822
|
+
);
|
|
823
|
+
CREATE INDEX anchor_events_thread_idx ON anchor_events (thread_id, at DESC);
|
|
824
|
+
CREATE INDEX anchor_events_applied_idx ON anchor_events (thread_id, at DESC) WHERE applied;
|
|
825
|
+
CREATE INDEX anchor_events_project_idx ON anchor_events (project_id, at DESC);
|
|
826
|
+
|
|
827
|
+
CREATE TABLE idempotency_keys (
|
|
828
|
+
org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
|
|
829
|
+
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
830
|
+
key text NOT NULL,
|
|
831
|
+
method text NOT NULL,
|
|
832
|
+
path text NOT NULL,
|
|
833
|
+
request_hash bytea NOT NULL, -- sha256(body) — detects key reuse with a different body
|
|
834
|
+
status_code integer,
|
|
835
|
+
response_body jsonb,
|
|
836
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
837
|
+
expires_at timestamptz NOT NULL,
|
|
838
|
+
PRIMARY KEY (project_id, key)
|
|
839
|
+
);
|
|
840
|
+
CREATE INDEX idempotency_expiry ON idempotency_keys (expires_at);
|
|
841
|
+
|
|
842
|
+
CREATE TABLE identity_nonces ( -- HMAC handshake replay protection (§4.6)
|
|
843
|
+
org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
|
|
844
|
+
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
845
|
+
nonce text NOT NULL,
|
|
846
|
+
expires_at timestamptz NOT NULL,
|
|
847
|
+
PRIMARY KEY (project_id, nonce)
|
|
848
|
+
);
|
|
849
|
+
CREATE INDEX identity_nonces_expiry ON identity_nonces (expires_at);
|
|
850
|
+
|
|
851
|
+
-- stream_tickets: DELETED with the SSE fan-out (sweep R6). The DDL that stood
|
|
852
|
+
-- here is preserved in §8.3's history note; migration 0001 does not create it.
|
|
853
|
+
|
|
854
|
+
CREATE TABLE export_jobs ( -- endpoint 28b (async export)
|
|
855
|
+
id uuid PRIMARY KEY,
|
|
856
|
+
org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
|
|
857
|
+
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
858
|
+
requested_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
|
859
|
+
format text NOT NULL CHECK (format IN ('json','md','csv','zip')),
|
|
860
|
+
status text NOT NULL DEFAULT 'queued', -- queued|running|ready|failed|expired
|
|
861
|
+
storage_key text,
|
|
862
|
+
bytes bigint,
|
|
863
|
+
sha256 bytea,
|
|
864
|
+
error text,
|
|
865
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
866
|
+
finished_at timestamptz,
|
|
867
|
+
expires_at timestamptz NOT NULL -- artefact deleted after this; default +24 h
|
|
868
|
+
);
|
|
869
|
+
CREATE INDEX export_jobs_project_idx ON export_jobs (project_id, created_at DESC);
|
|
870
|
+
CREATE INDEX export_jobs_expiry_idx ON export_jobs (expires_at);
|
|
871
|
+
|
|
872
|
+
-- v2, shipped by the v2 migration with endpoint 41. Declared here because
|
|
873
|
+
-- [01](01-product.md) §6.4 renders reactions and reported that 05 carried
|
|
874
|
+
-- neither a table nor a route; the route is endpoint 41 and this is the table.
|
|
875
|
+
CREATE TABLE reactions (
|
|
876
|
+
org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
|
|
877
|
+
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
878
|
+
comment_id uuid NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
|
|
879
|
+
reviewer_id uuid NOT NULL REFERENCES reviewers(id) ON DELETE CASCADE,
|
|
880
|
+
emoji text NOT NULL CHECK (char_length(emoji) <= 16),
|
|
881
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
882
|
+
PRIMARY KEY (comment_id, reviewer_id, emoji)
|
|
883
|
+
);
|
|
884
|
+
CREATE INDEX reactions_comment_idx ON reactions (comment_id);
|
|
885
|
+
|
|
886
|
+
CREATE TABLE rate_counters ( -- durable tier only (§9.2). Org-scoped by §3.0.
|
|
887
|
+
org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
|
|
888
|
+
bucket_key text NOT NULL,
|
|
889
|
+
window_start timestamptz NOT NULL,
|
|
890
|
+
count bigint NOT NULL DEFAULT 0,
|
|
891
|
+
expires_at timestamptz NOT NULL,
|
|
892
|
+
PRIMARY KEY (org_id, bucket_key)
|
|
893
|
+
);
|
|
894
|
+
CREATE INDEX rate_counters_expiry ON rate_counters (expires_at);
|
|
895
|
+
|
|
896
|
+
-- "Someone pasted your UID elsewhere" AND "we saw your widget on an origin you
|
|
897
|
+
-- have not allowed — add it?" are the same fact, so they are one table
|
|
898
|
+
-- (foundations §14.2b deletes the separate origin_suggestions). CAP 5 PER
|
|
899
|
+
-- PROJECT, which also bounds what a forged-Origin flood can write (§4.9).
|
|
900
|
+
CREATE TABLE origin_violations (
|
|
901
|
+
org_id uuid NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
|
|
902
|
+
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
903
|
+
origin text NOT NULL,
|
|
904
|
+
first_seen_at timestamptz NOT NULL DEFAULT now(),
|
|
905
|
+
last_seen_at timestamptz NOT NULL DEFAULT now(),
|
|
906
|
+
hits bigint NOT NULL DEFAULT 1,
|
|
907
|
+
-- false = it only ever fetched /config (the "add this origin?" suggestion case)
|
|
908
|
+
-- true = it attempted a write (the "your UID is in use elsewhere" case)
|
|
909
|
+
write_attempted boolean NOT NULL DEFAULT false,
|
|
910
|
+
notified_at timestamptz,
|
|
911
|
+
PRIMARY KEY (project_id, origin)
|
|
912
|
+
);
|
|
913
|
+
|
|
914
|
+
CREATE INDEX threads_hot ON threads (project_id, environment_id, route, status, updated_at DESC)
|
|
915
|
+
WHERE deleted_at IS NULL AND moderation = 'visible';
|
|
916
|
+
|
|
917
|
+
-- The client-role hot path (§6.4a). A partial index rather than widening
|
|
918
|
+
-- threads_hot with a boolean: `private` is overwhelmingly false, so the partial
|
|
919
|
+
-- index is smaller than the base one and the predicate is free at plan time.
|
|
920
|
+
CREATE INDEX threads_hot_public ON threads (project_id, environment_id, route, status, updated_at DESC)
|
|
921
|
+
WHERE deleted_at IS NULL AND moderation = 'visible' AND private = false;
|
|
922
|
+
|
|
923
|
+
CREATE INDEX threads_sync ON threads (project_id, environment_id, updated_at DESC, id DESC)
|
|
924
|
+
WHERE deleted_at IS NULL;
|
|
925
|
+
|
|
926
|
+
-- Delta polling for a CLIENT session: the same window, public rows only. A
|
|
927
|
+
-- client's `since` poll must never touch a private row, including to discover
|
|
928
|
+
-- that its updated_at moved.
|
|
929
|
+
CREATE INDEX threads_sync_public ON threads (project_id, environment_id, updated_at DESC, id DESC)
|
|
930
|
+
WHERE deleted_at IS NULL AND private = false;
|
|
931
|
+
|
|
932
|
+
-- Comments under the visibility predicate (§6.4a). comments_thread stays for
|
|
933
|
+
-- the studio read; this one serves the client read without a filter step.
|
|
934
|
+
CREATE INDEX comments_thread_public ON comments (thread_id, seq)
|
|
935
|
+
WHERE deleted_at IS NULL AND private = false;
|
|
936
|
+
|
|
937
|
+
CREATE INDEX threads_env_recent ON threads (project_id, environment_id, created_at DESC)
|
|
938
|
+
WHERE deleted_at IS NULL;
|
|
939
|
+
|
|
940
|
+
CREATE INDEX threads_anchor_testid ON threads (project_id, (anchor->>'testId'))
|
|
941
|
+
WHERE deleted_at IS NULL AND anchor ? 'testId';
|
|
942
|
+
|
|
943
|
+
CREATE INDEX threads_anchor_domid ON threads (project_id, (anchor->>'domId'))
|
|
944
|
+
WHERE deleted_at IS NULL AND anchor ? 'domId';
|
|
945
|
+
|
|
946
|
+
CREATE INDEX threads_orphans ON threads (project_id, environment_id, updated_at DESC)
|
|
947
|
+
WHERE deleted_at IS NULL AND anchor_state = 'orphaned';
|
|
948
|
+
|
|
949
|
+
CREATE INDEX threads_author ON threads (created_by, created_at DESC);
|
|
950
|
+
CREATE INDEX comments_author ON comments (author_id, created_at DESC);
|
|
951
|
+
|
|
952
|
+
CREATE INDEX comments_thread ON comments (thread_id, seq)
|
|
953
|
+
WHERE deleted_at IS NULL;
|
|
954
|
+
|
|
955
|
+
CREATE INDEX comments_dupe ON comments (author_id, body_hash, created_at DESC);
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
-- ── Row-level security ───────────────────────────────────────────────────
|
|
959
|
+
--
|
|
960
|
+
-- 08 §9.2: defence in depth BEHIND the application's own scoping, possible
|
|
961
|
+
-- only because the tenant key was there from the first migration rather
|
|
962
|
+
-- than retrofitted. A32's smoke test is the acceptance criterion: with
|
|
963
|
+
-- app.org_id set to org A, a query for org B's threads returns zero rows.
|
|
964
|
+
|
|
965
|
+
ALTER TABLE orgs ENABLE ROW LEVEL SECURITY;
|
|
966
|
+
ALTER TABLE orgs FORCE ROW LEVEL SECURITY;
|
|
967
|
+
CREATE POLICY orgs_org ON orgs USING (id = current_setting('app.org_id', true)::uuid);
|
|
968
|
+
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
|
969
|
+
ALTER TABLE users FORCE ROW LEVEL SECURITY;
|
|
970
|
+
CREATE POLICY users_org ON users USING (id IN (SELECT user_id FROM memberships WHERE org_id = current_setting('app.org_id', true)::uuid));
|
|
971
|
+
ALTER TABLE user_identities ENABLE ROW LEVEL SECURITY;
|
|
972
|
+
ALTER TABLE user_identities FORCE ROW LEVEL SECURITY;
|
|
973
|
+
CREATE POLICY user_identities_org ON user_identities USING (org_id = current_setting('app.org_id', true)::uuid);
|
|
974
|
+
ALTER TABLE memberships ENABLE ROW LEVEL SECURITY;
|
|
975
|
+
ALTER TABLE memberships FORCE ROW LEVEL SECURITY;
|
|
976
|
+
CREATE POLICY memberships_org ON memberships USING (org_id = current_setting('app.org_id', true)::uuid);
|
|
977
|
+
ALTER TABLE user_sessions ENABLE ROW LEVEL SECURITY;
|
|
978
|
+
ALTER TABLE user_sessions FORCE ROW LEVEL SECURITY;
|
|
979
|
+
CREATE POLICY user_sessions_org ON user_sessions USING (org_id = current_setting('app.org_id', true)::uuid);
|
|
980
|
+
-- setup_tokens: no RLS. It is pre-tenant (05 §3.2 — "the ONE table with
|
|
981
|
+
-- no org_id, because it exists precisely when no org does"), so there is
|
|
982
|
+
-- nothing to scope by. Its safety is the token hash and single use.
|
|
983
|
+
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
|
|
984
|
+
ALTER TABLE projects FORCE ROW LEVEL SECURITY;
|
|
985
|
+
CREATE POLICY projects_org ON projects USING (org_id = current_setting('app.org_id', true)::uuid);
|
|
986
|
+
ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY;
|
|
987
|
+
ALTER TABLE api_keys FORCE ROW LEVEL SECURITY;
|
|
988
|
+
CREATE POLICY api_keys_org ON api_keys USING (org_id = current_setting('app.org_id', true)::uuid);
|
|
989
|
+
ALTER TABLE mcp_tokens ENABLE ROW LEVEL SECURITY;
|
|
990
|
+
ALTER TABLE mcp_tokens FORCE ROW LEVEL SECURITY;
|
|
991
|
+
CREATE POLICY mcp_tokens_org ON mcp_tokens USING (org_id = current_setting('app.org_id', true)::uuid);
|
|
992
|
+
ALTER TABLE project_origins ENABLE ROW LEVEL SECURITY;
|
|
993
|
+
ALTER TABLE project_origins FORCE ROW LEVEL SECURITY;
|
|
994
|
+
CREATE POLICY project_origins_org ON project_origins USING (org_id = current_setting('app.org_id', true)::uuid);
|
|
995
|
+
ALTER TABLE environments ENABLE ROW LEVEL SECURITY;
|
|
996
|
+
ALTER TABLE environments FORCE ROW LEVEL SECURITY;
|
|
997
|
+
CREATE POLICY environments_org ON environments USING (org_id = current_setting('app.org_id', true)::uuid);
|
|
998
|
+
ALTER TABLE rounds ENABLE ROW LEVEL SECURITY;
|
|
999
|
+
ALTER TABLE rounds FORCE ROW LEVEL SECURITY;
|
|
1000
|
+
CREATE POLICY rounds_org ON rounds USING (org_id = current_setting('app.org_id', true)::uuid);
|
|
1001
|
+
ALTER TABLE review_links ENABLE ROW LEVEL SECURITY;
|
|
1002
|
+
ALTER TABLE review_links FORCE ROW LEVEL SECURITY;
|
|
1003
|
+
CREATE POLICY review_links_org ON review_links USING (org_id = current_setting('app.org_id', true)::uuid);
|
|
1004
|
+
ALTER TABLE reviewers ENABLE ROW LEVEL SECURITY;
|
|
1005
|
+
ALTER TABLE reviewers FORCE ROW LEVEL SECURITY;
|
|
1006
|
+
CREATE POLICY reviewers_org ON reviewers USING (org_id = current_setting('app.org_id', true)::uuid);
|
|
1007
|
+
ALTER TABLE threads ENABLE ROW LEVEL SECURITY;
|
|
1008
|
+
ALTER TABLE threads FORCE ROW LEVEL SECURITY;
|
|
1009
|
+
CREATE POLICY threads_org ON threads USING (org_id = current_setting('app.org_id', true)::uuid);
|
|
1010
|
+
ALTER TABLE comments ENABLE ROW LEVEL SECURITY;
|
|
1011
|
+
ALTER TABLE comments FORCE ROW LEVEL SECURITY;
|
|
1012
|
+
CREATE POLICY comments_org ON comments USING (org_id = current_setting('app.org_id', true)::uuid);
|
|
1013
|
+
ALTER TABLE attachments ENABLE ROW LEVEL SECURITY;
|
|
1014
|
+
ALTER TABLE attachments FORCE ROW LEVEL SECURITY;
|
|
1015
|
+
CREATE POLICY attachments_org ON attachments USING (org_id = current_setting('app.org_id', true)::uuid);
|
|
1016
|
+
ALTER TABLE attachment_shares ENABLE ROW LEVEL SECURITY;
|
|
1017
|
+
ALTER TABLE attachment_shares FORCE ROW LEVEL SECURITY;
|
|
1018
|
+
CREATE POLICY attachment_shares_org ON attachment_shares USING (org_id = current_setting('app.org_id', true)::uuid);
|
|
1019
|
+
ALTER TABLE integrations ENABLE ROW LEVEL SECURITY;
|
|
1020
|
+
ALTER TABLE integrations FORCE ROW LEVEL SECURITY;
|
|
1021
|
+
CREATE POLICY integrations_org ON integrations USING (org_id = current_setting('app.org_id', true)::uuid);
|
|
1022
|
+
ALTER TABLE external_refs ENABLE ROW LEVEL SECURITY;
|
|
1023
|
+
ALTER TABLE external_refs FORCE ROW LEVEL SECURITY;
|
|
1024
|
+
CREATE POLICY external_refs_org ON external_refs USING (org_id = current_setting('app.org_id', true)::uuid);
|
|
1025
|
+
ALTER TABLE outbox ENABLE ROW LEVEL SECURITY;
|
|
1026
|
+
ALTER TABLE outbox FORCE ROW LEVEL SECURITY;
|
|
1027
|
+
CREATE POLICY outbox_org ON outbox USING (org_id = current_setting('app.org_id', true)::uuid);
|
|
1028
|
+
ALTER TABLE sync_ledger ENABLE ROW LEVEL SECURITY;
|
|
1029
|
+
ALTER TABLE sync_ledger FORCE ROW LEVEL SECURITY;
|
|
1030
|
+
CREATE POLICY sync_ledger_org ON sync_ledger USING (org_id = current_setting('app.org_id', true)::uuid);
|
|
1031
|
+
ALTER TABLE webhook_endpoints ENABLE ROW LEVEL SECURITY;
|
|
1032
|
+
ALTER TABLE webhook_endpoints FORCE ROW LEVEL SECURITY;
|
|
1033
|
+
CREATE POLICY webhook_endpoints_org ON webhook_endpoints USING (org_id = current_setting('app.org_id', true)::uuid);
|
|
1034
|
+
ALTER TABLE events ENABLE ROW LEVEL SECURITY;
|
|
1035
|
+
ALTER TABLE events FORCE ROW LEVEL SECURITY;
|
|
1036
|
+
CREATE POLICY events_org ON events USING (org_id = current_setting('app.org_id', true)::uuid);
|
|
1037
|
+
ALTER TABLE webhook_deliveries ENABLE ROW LEVEL SECURITY;
|
|
1038
|
+
ALTER TABLE webhook_deliveries FORCE ROW LEVEL SECURITY;
|
|
1039
|
+
CREATE POLICY webhook_deliveries_org ON webhook_deliveries USING (org_id = current_setting('app.org_id', true)::uuid);
|
|
1040
|
+
ALTER TABLE anchor_events ENABLE ROW LEVEL SECURITY;
|
|
1041
|
+
ALTER TABLE anchor_events FORCE ROW LEVEL SECURITY;
|
|
1042
|
+
CREATE POLICY anchor_events_org ON anchor_events USING (org_id = current_setting('app.org_id', true)::uuid);
|
|
1043
|
+
ALTER TABLE idempotency_keys ENABLE ROW LEVEL SECURITY;
|
|
1044
|
+
ALTER TABLE idempotency_keys FORCE ROW LEVEL SECURITY;
|
|
1045
|
+
CREATE POLICY idempotency_keys_org ON idempotency_keys USING (org_id = current_setting('app.org_id', true)::uuid);
|
|
1046
|
+
ALTER TABLE identity_nonces ENABLE ROW LEVEL SECURITY;
|
|
1047
|
+
ALTER TABLE identity_nonces FORCE ROW LEVEL SECURITY;
|
|
1048
|
+
CREATE POLICY identity_nonces_org ON identity_nonces USING (org_id = current_setting('app.org_id', true)::uuid);
|
|
1049
|
+
ALTER TABLE export_jobs ENABLE ROW LEVEL SECURITY;
|
|
1050
|
+
ALTER TABLE export_jobs FORCE ROW LEVEL SECURITY;
|
|
1051
|
+
CREATE POLICY export_jobs_org ON export_jobs USING (org_id = current_setting('app.org_id', true)::uuid);
|
|
1052
|
+
ALTER TABLE reactions ENABLE ROW LEVEL SECURITY;
|
|
1053
|
+
ALTER TABLE reactions FORCE ROW LEVEL SECURITY;
|
|
1054
|
+
CREATE POLICY reactions_org ON reactions USING (org_id = current_setting('app.org_id', true)::uuid);
|
|
1055
|
+
ALTER TABLE rate_counters ENABLE ROW LEVEL SECURITY;
|
|
1056
|
+
ALTER TABLE rate_counters FORCE ROW LEVEL SECURITY;
|
|
1057
|
+
CREATE POLICY rate_counters_org ON rate_counters USING (org_id = current_setting('app.org_id', true)::uuid);
|
|
1058
|
+
ALTER TABLE origin_violations ENABLE ROW LEVEL SECURITY;
|
|
1059
|
+
ALTER TABLE origin_violations FORCE ROW LEVEL SECURITY;
|
|
1060
|
+
CREATE POLICY origin_violations_org ON origin_violations USING (org_id = current_setting('app.org_id', true)::uuid);
|