@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.
Files changed (105) hide show
  1. package/README.md +83 -0
  2. package/dist/app.js +453 -0
  3. package/dist/app.js.map +1 -0
  4. package/dist/bin.js +951 -0
  5. package/dist/bin.js.map +1 -0
  6. package/dist/boot.js +321 -0
  7. package/dist/boot.js.map +1 -0
  8. package/dist/config.generated.js +116 -0
  9. package/dist/config.generated.js.map +1 -0
  10. package/dist/config.js +180 -0
  11. package/dist/config.js.map +1 -0
  12. package/dist/crypto.js +26 -0
  13. package/dist/crypto.js.map +1 -0
  14. package/dist/db/connect.js +49 -0
  15. package/dist/db/connect.js.map +1 -0
  16. package/dist/db/migrate.js +210 -0
  17. package/dist/db/migrate.js.map +1 -0
  18. package/dist/db/migrations/0001_init.sql +1060 -0
  19. package/dist/db/migrations/0002_maintenance_claim.sql +109 -0
  20. package/dist/db/migrations/0003_email_provider.sql +35 -0
  21. package/dist/db/migrations/0004_unsubscribe.sql +24 -0
  22. package/dist/db/rls.js +58 -0
  23. package/dist/db/rls.js.map +1 -0
  24. package/dist/jobs/index.js +171 -0
  25. package/dist/jobs/index.js.map +1 -0
  26. package/dist/jobs/reaper.js +182 -0
  27. package/dist/jobs/reaper.js.map +1 -0
  28. package/dist/keys.js +77 -0
  29. package/dist/keys.js.map +1 -0
  30. package/dist/mail/index.js +75 -0
  31. package/dist/mail/index.js.map +1 -0
  32. package/dist/mail/mailer.js +27 -0
  33. package/dist/mail/mailer.js.map +1 -0
  34. package/dist/mail/notify.js +405 -0
  35. package/dist/mail/notify.js.map +1 -0
  36. package/dist/mail/resend.js +95 -0
  37. package/dist/mail/resend.js.map +1 -0
  38. package/dist/mail/smtp.js +315 -0
  39. package/dist/mail/smtp.js.map +1 -0
  40. package/dist/mail/unsubscribe.js +134 -0
  41. package/dist/mail/unsubscribe.js.map +1 -0
  42. package/dist/projectCache.js +50 -0
  43. package/dist/projectCache.js.map +1 -0
  44. package/dist/projects.js +101 -0
  45. package/dist/projects.js.map +1 -0
  46. package/dist/routes/attachments.js +159 -0
  47. package/dist/routes/attachments.js.map +1 -0
  48. package/dist/routes/auth.js +55 -0
  49. package/dist/routes/auth.js.map +1 -0
  50. package/dist/routes/commentEdit.js +201 -0
  51. package/dist/routes/commentEdit.js.map +1 -0
  52. package/dist/routes/comments.js +313 -0
  53. package/dist/routes/comments.js.map +1 -0
  54. package/dist/routes/cors.js +174 -0
  55. package/dist/routes/cors.js.map +1 -0
  56. package/dist/routes/jobs.js +56 -0
  57. package/dist/routes/jobs.js.map +1 -0
  58. package/dist/routes/mcpAuth.js +187 -0
  59. package/dist/routes/mcpAuth.js.map +1 -0
  60. package/dist/routes/mcpToken.js +94 -0
  61. package/dist/routes/mcpToken.js.map +1 -0
  62. package/dist/routes/owner.js +288 -0
  63. package/dist/routes/owner.js.map +1 -0
  64. package/dist/routes/reviewLinks.js +169 -0
  65. package/dist/routes/reviewLinks.js.map +1 -0
  66. package/dist/routes/rounds.js +137 -0
  67. package/dist/routes/rounds.js.map +1 -0
  68. package/dist/routes/sessions.js +349 -0
  69. package/dist/routes/sessions.js.map +1 -0
  70. package/dist/routes/sites.js +192 -0
  71. package/dist/routes/sites.js.map +1 -0
  72. package/dist/routes/threads.js +1080 -0
  73. package/dist/routes/threads.js.map +1 -0
  74. package/dist/routes/widget.js +132 -0
  75. package/dist/routes/widget.js.map +1 -0
  76. package/dist/serverless.js +97 -0
  77. package/dist/serverless.js.map +1 -0
  78. package/dist/session.js +152 -0
  79. package/dist/session.js.map +1 -0
  80. package/dist/storage/bucket.js +116 -0
  81. package/dist/storage/bucket.js.map +1 -0
  82. package/dist/storage/settings.js +132 -0
  83. package/dist/storage/settings.js.map +1 -0
  84. package/dist/vendor/adapters/index.js +1 -0
  85. package/dist/vendor/adapters/outbound/fence.js +44 -0
  86. package/dist/vendor/adapters/outbound/sanitise.js +233 -0
  87. package/dist/vendor/types/index.js +4 -0
  88. package/dist/vendor/types/names.js +254 -0
  89. package/dist/vendor/widget/object.js +38 -0
  90. package/dist/vendor/widget/predicates.js +377 -0
  91. package/package.json +53 -0
  92. package/static/dashboard/assets/index-CaPfKhFP.css +1 -0
  93. package/static/dashboard/assets/index-DfTNjHCK.js +1 -0
  94. package/static/dashboard/index.html +14 -0
  95. package/static/widget/snag.anchor-ext.js +1 -0
  96. package/static/widget/snag.anchor-fuzzy.js +1 -0
  97. package/static/widget/snag.capture.js +4 -0
  98. package/static/widget/snag.crumbs.js +4 -0
  99. package/static/widget/snag.dock.js +5 -0
  100. package/static/widget/snag.js +5 -0
  101. package/static/widget/snag.panels.js +2 -0
  102. package/static/widget/snag.runtime.js +2 -0
  103. package/static/widget/snag.screenshot.js +1 -0
  104. package/static/widget/snag.text.js +2 -0
  105. package/vercel.json +6 -0
@@ -0,0 +1,1080 @@
1
+ import { Hono } from "hono";
2
+ import { randomUUID } from "node:crypto";
3
+ import { THREAD_REF_PREFIX } from "../vendor/types/names.js";
4
+ import { ID_PREFIX } from "../vendor/types/names.js";
5
+ import { queueCommentNotice, queueResolvedNotice } from "../mail/notify.js";
6
+ import { effectiveWriteMode } from "./sessions.js";
7
+ import { mcpAllows, scopeDenied, checkWriteBudget, budgetExhausted, recordAgentWrite, agentReviewer, } from "./mcpAuth.js";
8
+ /**
9
+ * Wire id ⇄ row id — 05 §3.5, verbatim: *"id uuid PRIMARY KEY — client-minted
10
+ * UUIDv7; wire form 'thr_…'"*.
11
+ *
12
+ * The row is a bare uuid so it indexes and joins as one; the prefixed form is
13
+ * the identity everything OUTSIDE the database uses, because a bare uuid in a
14
+ * webhook payload, a GitHub issue marker or a support ticket tells you nothing
15
+ * about what you are looking at (foundations §2.10).
16
+ *
17
+ * Both directions live here rather than at each call site. The widget mints
18
+ * the id, so the very first POST arrives prefixed, and a server that stores
19
+ * only what it happens to strip is a server whose ids diverge by route.
20
+ */
21
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
22
+ function rowId(wire, prefix) {
23
+ if (typeof wire !== "string")
24
+ return null;
25
+ const bare = wire.startsWith(prefix) ? wire.slice(prefix.length) : wire;
26
+ return UUID.test(bare) ? bare.toLowerCase() : null;
27
+ }
28
+ const wireId_ = (row, prefix) => prefix + row;
29
+ /**
30
+ * §2.2's envelope. `details` was missing from this file's copy while
31
+ * `comments.ts` had it, so every `field_not_permitted` here — including the
32
+ * long-standing refusal to move a thread between rounds — shipped without the
33
+ * `details.field` §6.9 registers for that code. A client cannot mark the
34
+ * offending input without it.
35
+ */
36
+ function fail(c, status, code, message, details) {
37
+ return c.json({ error: { code, message, ...(details ? { details } : {}), requestId: c.get("requestId") } }, status);
38
+ }
39
+ /**
40
+ * Every request runs inside a transaction with `app.org_id` set, which is what
41
+ * makes 08 §9.2's RLS policies do anything at all. It is set even where the
42
+ * connecting role can bypass them (D50) — the day someone points DATABASE_URL
43
+ * at a properly restricted role, every query here has to already be correct,
44
+ * and a `SET LOCAL` added later would be added under incident conditions.
45
+ */
46
+ async function inOrg(sql, orgId, fn) {
47
+ return sql.begin(async (tx) => {
48
+ await tx `SELECT set_config('app.org_id', ${orgId}, true)`;
49
+ return fn(tx);
50
+ });
51
+ }
52
+ export function threadRoutes(sql,
53
+ /** The 10-second cache (05 §4.4), not a fresh SELECT per handler. Every
54
+ * route here starts by resolving `{uid}`, and on a database one round trip
55
+ * away that lookup was costing more than the work it introduced. */
56
+ projects,
57
+ /** A9b: `features` is derived SERVER-side from bucket presence and the
58
+ * widget renders from it. Injected rather than imported so this file does
59
+ * not need to know how storage is configured. */
60
+ features,
61
+ /**
62
+ * Is a mail transport configured (08 §4.8)? Same gate `commentRoutes` takes,
63
+ * and for the same reason: with no transport, 08 §4.8 wants notifications
64
+ * "off and say so, rather than silently queueing" — rows that would otherwise
65
+ * accumulate for the life of the install and arrive in one burst the day
66
+ * somebody configures Resend.
67
+ */
68
+ notifications = false) {
69
+ const app = new Hono();
70
+ /** Resolve `{uid}`, and 404 rather than leak whether a uid exists. */
71
+ const project = (uid) => projects.get(uid);
72
+ // NO CORS MIDDLEWARE HERE. It used to live in this group and it was a second
73
+ // copy: `corsForProjects` is mounted at the app level (app.ts), which is the
74
+ // only mounting that covers `sessionRoutes` too. The copy cost two queries a
75
+ // request and, worse, ran AFTER the real one and overwrote
76
+ // `Access-Control-Allow-Headers` with a hand-typed `content-type` — harmless
77
+ // only because a preflight never reaches this far.
78
+ /** 05 §6.1 — what the widget reads at mount to know what this deployment can do. */
79
+ app.get("/api/v1/projects/:uid/config", async (c) => {
80
+ const p = await project(c.req.param("uid"));
81
+ if (!p)
82
+ return fail(c, 404, "project_not_found", "No project with that uid.");
83
+ c.header("x-snagset-config-version", String(p.config_version));
84
+ c.header("ETag", `"${p.config_etag}"`);
85
+ /**
86
+ * CACHEABLE, briefly — D66 makes this the stub's visibility check, fetched
87
+ * once per visitor per page-load-cluster rather than per page. 60 seconds:
88
+ * long enough that a browsing session costs one request, short enough that
89
+ * flipping the audience in the dashboard lands within a minute.
90
+ */
91
+ c.header("Cache-Control", "public, max-age=60");
92
+ // Unconditionally, not only when the CORS middleware set it: a shared
93
+ // cache that stored a no-Origin response WITHOUT Vary would replay the
94
+ // full body to a stranger origin, and the split below would be theatre.
95
+ c.header("Vary", "Origin");
96
+ /**
97
+ * 05 §7.2's ORIGIN-AWARE BODY, finally conformed to (D69) — Eurico's
98
+ * question, verbatim: "the api gets the url that should be there and then
99
+ * just validates in the frontend?" Which is this design: the server never
100
+ * learns the page's URL (the browser sends only its origin, which CORS
101
+ * needs anyway), it evaluates that origin against the allow-list it holds,
102
+ * and ships the VERDICT — never the list, because the list is the
103
+ * customer's own map of their staging hostnames and the uid needed to ask
104
+ * for it is printed in their page source.
105
+ *
106
+ * A stranger origin still gets a 200 with the public subset (writeMode
107
+ * included) rather than a 403: the fetch is also how D2's origin
108
+ * suggestion is born, and the stub on an archive.org mirror needs a
109
+ * readable "writable: false" to stay dormant — a CORS-omitted header would
110
+ * do that too, but this route is public-read so the suggestion flow works.
111
+ * `originAllowed` is absent (never false) when there is no Origin header:
112
+ * same-origin pages and curl are not strangers.
113
+ */
114
+ const writable = c.get("originAllowed") !== false;
115
+ const base = {
116
+ uid: p.uid,
117
+ // A9b, and it is a fact about THIS instance rather than a constant:
118
+ // derived from whether a bucket is connected. False hides the screenshot
119
+ // control rather than offering one whose upload would fail — annotation
120
+ // mode is a complete product, not a degraded one.
121
+ features: await features(),
122
+ /**
123
+ * EFFECTIVE, not the raw column — D66 turns this field into the thing
124
+ * that decides whether every visitor sees the toolbar, and the raw value
125
+ * lies during D1's grace: a site 25 hours old still says `open` while
126
+ * every write is already refused. The session route learned this first
127
+ * (see effectiveWriteMode's own comment); the config route repeating the
128
+ * raw column would show a toolbar nobody can type into.
129
+ */
130
+ writeMode: effectiveWriteMode(p, new Date()),
131
+ writable,
132
+ };
133
+ if (!writable)
134
+ return c.json({ ...base, reason: "origin_not_allowed" });
135
+ // T10/M2-22: the round HISTORY rides the config the widget already fetches
136
+ // at mount, so the dock's selector costs no extra request. Behind the
137
+ // origin split with `keyPrefix` (§7.2): the least sensitive PRIVATE things
138
+ // on the project, which is not the same as public.
139
+ const rounds = await sql `
140
+ SELECT number, name, closed_at IS NOT NULL AS closed
141
+ FROM rounds WHERE project_id = ${p.id} AND deleted_at IS NULL
142
+ ORDER BY number DESC
143
+ `;
144
+ return c.json({
145
+ ...base,
146
+ keyPrefix: p.key_prefix,
147
+ rounds: rounds.map((r) => ({ number: r.number, name: r.name, closed: r.closed })),
148
+ });
149
+ });
150
+ /**
151
+ * 05 §6.4 — the list, endpoint 7, which the route table marks **W/D**.
152
+ *
153
+ * ONE endpoint, TWO audiences, and the credential decides the scope. That is
154
+ * the spec's design and not a shortcut: a dashboard is not a different kind
155
+ * of reader, it is a reader with more of the project in view.
156
+ *
157
+ * | Caller | Environment | Routes | Private | Order |
158
+ * |------------------|-------------|--------|---------|----------|
159
+ * | reviewer session | the one asked for, else default | the one asked for | never | oldest first, so pins paint in creation order |
160
+ * | owner cookie | every one, unless asked | every one, unless asked | included | NEWEST first — an inbox is read from the top |
161
+ *
162
+ * A widget caller cannot reach the dashboard scope by asking: the branch is
163
+ * on the credential, and an owner cookie cannot arrive from a reviewed site
164
+ * in a readable way — `Access-Control-Allow-Credentials` is never set, so a
165
+ * cross-origin credentialed read is refused by the browser whatever this
166
+ * returns.
167
+ *
168
+ * `route` is the generated projection off the anchor (§3.5), so filtering by
169
+ * it is an index scan rather than a JSONB walk on every row.
170
+ */
171
+ app.get("/api/v1/projects/:uid/threads", async (c) => {
172
+ const p = await project(c.req.param("uid"));
173
+ if (!p)
174
+ return fail(c, 404, "project_not_found", "No project with that uid.");
175
+ const route = c.req.query("route");
176
+ const env = c.req.query("env");
177
+ // The owner must own THIS project. A valid session for another org is not
178
+ // a credential for this one, and `/api/v1/me` is scoped the same way.
179
+ const owner = c.get("owner");
180
+ const asOwner = !!owner && owner.orgId === p.org_id;
181
+ /**
182
+ * An MCP token with `threads:read` (07 §8.2) reads with the studio's
183
+ * FILTERS but not the studio's PRIVACY, and the split is deliberate.
184
+ *
185
+ * Filters: an agent asked to "list the open snags on checkout" needs
186
+ * `status`, `q` and every round, exactly as the dashboard has them.
187
+ * Denying those would make `list_threads` return the latest open round of
188
+ * the default environment and call it the project.
189
+ *
190
+ * Privacy: `private` threads and team-only replies (§6.4a) stay OUT. 07's
191
+ * scope table grants "list and read threads, comments, anchor state" and
192
+ * says nothing about private, while §8.6 mitigation 7 is *scope
193
+ * minimalism* and §8.2 separates `context:read` precisely because the
194
+ * personal-data-bearing half should not ride along by default. A team-only
195
+ * note is the studio talking about the client; an agent that reads one and
196
+ * quotes it into a pull request has published it.
197
+ */
198
+ const mcp = c.get("mcp");
199
+ const asAgent = mcpAllows(mcp, p.org_id, "threads:read");
200
+ const asStudio = asOwner || asAgent;
201
+ // A token that presented itself and lacks the scope is REFUSED, not
202
+ // quietly served the anonymous view. This route answers without any
203
+ // credential at all (an `open` project's widget reads it that way), so the
204
+ // silent path exists and is the wrong one here: an agent handed the
205
+ // default environment's latest open round would report it as the project.
206
+ // Better a sentence naming the missing scope than a confident short list.
207
+ if (mcp && !asAgent)
208
+ return scopeDenied(c, "threads:read");
209
+ // §6.4's filters. `status` and `q` stay dashboard-only. `round` used to be
210
+ // gated the same way (`asOwner ? … : null`), which is the M2-22 bug: a
211
+ // review-link session's `?round` was silently discarded and every reader
212
+ // without an owner cookie saw every round's pins forever, including ones
213
+ // long since closed. A reviewer session may now ask for a round exactly
214
+ // like an owner can — a link pins the LANDING round, it does not imprison
215
+ // the reviewer on it (plan T8 step 2).
216
+ const status = asStudio ? c.req.query("status") : undefined;
217
+ const requestedRound = Number(c.req.query("round")) || null;
218
+ const q = asStudio ? (c.req.query("q") ?? "").trim() : "";
219
+ /**
220
+ * `?since=` — 05 §6.3's delta poll, and A10's whole realtime story.
221
+ *
222
+ * *"`GET …/threads?since=<iso>` returns everything with `updated_at >
223
+ * since`, plus the `deleted[]` tombstone array. The client keeps
224
+ * `serverTime` from the previous response as the next `since`, never its
225
+ * own clock."*
226
+ *
227
+ * Without it the widget refetched every thread on every poll — correct,
228
+ * and O(project) per reviewer per interval on the one route that runs
229
+ * forever in a background tab.
230
+ */
231
+ /**
232
+ * `?key=SN-14` — 05 §6.3's server-side permalink, and the thing that makes
233
+ * a shared link survive a round.
234
+ *
235
+ * `?snag_t=SN-14` resolves LOCALLY in the widget, against the list it
236
+ * already holds. That list is round-scoped: a non-owner reader defaults to
237
+ * the latest OPEN round. So a permalink to a thread from a closed round —
238
+ * which is most permalinks anyone bothers to share, since they share the
239
+ * ones that mattered — found nothing and showed F-06's "we could not find
240
+ * that comment". The thread was there the whole time, one round back.
241
+ *
242
+ * An exact match on `UNIQUE (project_id, key)`, so at most one row, and it
243
+ * deliberately IGNORES the round and environment defaults: the caller
244
+ * named a specific thread, which is a stronger statement of intent than
245
+ * any default. Privacy is not ignored — a private thread is absent for a
246
+ * non-owner, which reads as "no such key" rather than confirming it exists
247
+ * (§6.4a's rule for `GET /threads/{id}`).
248
+ */
249
+ const key = c.req.query("key")?.trim().toUpperCase();
250
+ const sinceRaw = c.req.query("since");
251
+ let since = null;
252
+ if (sinceRaw !== undefined) {
253
+ const parsed = new Date(sinceRaw);
254
+ if (Number.isNaN(parsed.getTime())) {
255
+ return fail(c, 400, "validation_failed", "`since` must be an ISO timestamp — pass back the `serverTime` from your last response.");
256
+ }
257
+ since = parsed;
258
+ }
259
+ /**
260
+ * ── WHY `visible` IS A COLUMN AND NOT A FILTER ──
261
+ *
262
+ * 05 §6.4a, acceptance A36: a thread the studio marks `private` must land
263
+ * in the caller's `deleted[]` so a cached copy is DROPPED. With the
264
+ * predicate in the WHERE clause the row simply stops being returned, which
265
+ * is indistinguishable from "nothing changed" — the client keeps showing a
266
+ * thread it should no longer have, forever, because a poll that returns
267
+ * nothing is the normal case.
268
+ *
269
+ * Only the two genuine VISIBILITY conditions are computed. Status, search,
270
+ * environment and round are FILTERS: status does not hide a thread, and a
271
+ * thread can never change environment or round (D2 — `environment_id` is
272
+ * set at creation and a thread never moves), so none of them can take a
273
+ * client from seeing a thread to not seeing it.
274
+ *
275
+ * This block comment lives out here rather than inside the SQL because a
276
+ * backtick inside a comment inside a template literal ENDS the template.
277
+ * That has now cost this repository five separate debugging sessions, and
278
+ * the two words `deleted[]` and `private` are unavoidable in the
279
+ * explanation.
280
+ */
281
+ const rows = await inOrg(sql, p.org_id, async (tx) => {
282
+ return tx `
283
+ SELECT t.id, t.key, t.status, t.version, t.anchor, t.anchor_state, t.context, t.round_number,
284
+ t.created_at, t.updated_at, t.resolved_at, t.comment_count_public,
285
+ t.route, e.key AS environment_key, rd.number AS round, rd.name AS round_name,
286
+ r.name AS created_by_name, r.kind AS created_by_kind, r.id AS created_by_id,
287
+ COALESCE(
288
+ (SELECT json_agg(json_build_object(
289
+ 'id', c.id, 'body', c.body, 'createdAt', c.created_at,
290
+ 'author', json_build_object('id', cr.id, 'name', cr.name, 'kind', cr.kind))
291
+ ORDER BY c.seq)
292
+ FROM comments c JOIN reviewers cr ON cr.id = c.author_id
293
+ WHERE c.thread_id = t.id AND c.deleted_at IS NULL
294
+ -- Team-only replies (05 section 6.4a) are for the studio's
295
+ -- eyes: a private=true comment must never reach a reviewer
296
+ -- session. This subquery used to filter only deleted_at IS
297
+ -- NULL, so every non-owner caller got every private reply
298
+ -- too -- proved live: one owner reply with private=true and
299
+ -- a link session's list carried it, just with commentCount
300
+ -- one short of the comment array's length. Fixed the same
301
+ -- way the thread-level private filter above already works:
302
+ -- owner sees all, everyone else sees only the public ones.
303
+ ${asOwner ? tx `` : tx `AND c.private = false`}),
304
+ '[]'::json) AS comments,
305
+ COALESCE(
306
+ (SELECT json_agg(json_build_object(
307
+ 'id', a.id, 'kind', a.kind, 'contentType', a.content_type,
308
+ 'width', a.width, 'height', a.height)
309
+ ORDER BY a.created_at)
310
+ FROM attachments a
311
+ WHERE a.thread_id = t.id AND a.deleted_at IS NULL AND a.state = 'bound'),
312
+ '[]'::json) AS attachments
313
+ ,
314
+ -- VISIBLE, computed rather than filtered. See the note above
315
+ -- the query: a thread that becomes private must be TOMBSTONED,
316
+ -- and a WHERE clause can only make it vanish.
317
+ (t.deleted_at IS NULL AND ${asOwner ? tx `true` : tx `t.private = false`}) AS visible
318
+ FROM threads t
319
+ JOIN reviewers r ON r.id = t.created_by
320
+ JOIN environments e ON e.id = t.environment_id
321
+ -- Joined for the round filter, and the join is the fix for a real
322
+ -- trap: threads.round_number is the thread's position WITHIN its round
323
+ -- (01 §5.2 -- the number on the pin), while rounds.number is which
324
+ -- round it is. Two different integers whose names differ by one word,
325
+ -- and filtering on the first is how round=1 returned the single thread
326
+ -- that happened to be first in its round.
327
+ JOIN rounds rd ON rd.id = t.round_id
328
+ WHERE t.project_id = ${p.id}
329
+ -- On a POLL these two move into the visible column above, so a
330
+ -- thread that just became invisible still comes back and can be
331
+ -- tombstoned. On a full list they stay here, where they cost nothing.
332
+ ${since ? tx `AND t.updated_at > ${since}` : tx `AND t.deleted_at IS NULL`}
333
+ ${since || asOwner ? tx `` : tx `AND t.private = false`}
334
+ -- KEY OVERRIDES EVERY OTHER FILTER, and that is the contract: the
335
+ -- caller named one thread, which is a stronger statement of intent
336
+ -- than any default or any filter left over in a URL. It returns that
337
+ -- thread or nothing. Privacy is NOT overridden -- see the visible
338
+ -- column above.
339
+ AND ${key
340
+ ? tx `t.key = ${key}`
341
+ : env
342
+ ? tx `e.key = ${env}`
343
+ : asStudio
344
+ ? tx `true`
345
+ : tx `e.is_default`}
346
+ ${key || !route ? tx `` : tx `AND t.route = ${route}`}
347
+ ${key || !status ? tx `` : tx `AND t.status = ${status}::snag_thread_status`}
348
+ -- Absent round query param: an OWNER still sees every round
349
+ -- (unchanged), but a non-owner reader defaults to the latest OPEN
350
+ -- round, so a fresh page load is D2-clean rather than showing a
351
+ -- round that closed weeks ago. The ORDER BY number DESC LIMIT 1
352
+ -- correlated subquery is cheap here -- one project's round table
353
+ -- is a handful of rows, never a scan of threads.
354
+ ${key
355
+ ? tx ``
356
+ : requestedRound
357
+ ? tx `AND rd.number = ${requestedRound}`
358
+ : asStudio
359
+ ? tx ``
360
+ : tx `AND rd.number = (
361
+ SELECT number FROM rounds
362
+ WHERE project_id = ${p.id} AND closed_at IS NULL
363
+ ORDER BY number DESC LIMIT 1
364
+ )`}
365
+ ${q && !key
366
+ ? tx `AND (t.key ILIKE ${"%" + q + "%"}
367
+ OR t.anchor_label ILIKE ${"%" + q + "%"}
368
+ OR EXISTS (SELECT 1 FROM comments cq
369
+ WHERE cq.thread_id = t.id AND cq.deleted_at IS NULL
370
+ AND cq.body ILIKE ${"%" + q + "%"}))`
371
+ : tx ``}
372
+ ORDER BY ${asStudio ? tx `t.created_at DESC` : tx `t.created_at`}
373
+ LIMIT 200
374
+ `;
375
+ });
376
+ // A NAMED environment that does not exist is an error, not an empty list.
377
+ // The POST already refuses it; a GET that answers `[]` instead tells a host
378
+ // with a typo in `data-snag-env` that their project is empty — which is the
379
+ // same sentence a correctly-configured empty project gets, and the two are
380
+ // very different problems.
381
+ //
382
+ // Checked only when the list came back EMPTY, because a row that came back
383
+ // proves the environment exists. The check used to run first, on every
384
+ // request, to answer a question the result already answers in the common
385
+ // case — a round trip per page load for a typo nobody made.
386
+ // Not on a poll: an empty delta is the normal case and says nothing about
387
+ // whether the environment exists.
388
+ if (!rows.length && env && !since) {
389
+ const known = await sql `
390
+ SELECT id FROM environments
391
+ WHERE project_id = ${p.id} AND key = ${env} AND deleted_at IS NULL
392
+ `;
393
+ if (!known.length) {
394
+ return fail(c, 400, "environment_not_found", `This project has no environment called "${env}". Check data-snag-env, or create it.`);
395
+ }
396
+ }
397
+ /**
398
+ * `serverTime` comes from POSTGRES, not from this process.
399
+ *
400
+ * The client hands it straight back as the next `since`, and `since` is
401
+ * compared against `updated_at`, which Postgres wrote with `now()`. Two
402
+ * clocks, one comparison: if the app server ran even a second ahead of the
403
+ * database, every thread updated inside that second would be skipped on the
404
+ * next poll and never returned again — a permanently missing comment, on a
405
+ * skew nobody would think to look for. 05 §12.3 says the client must never
406
+ * use its own clock; the same argument applies one layer down.
407
+ */
408
+ const [clock] = await sql `SELECT now() AS now`;
409
+ // §6.3's tombstones. Ids only — the client already has these threads, and
410
+ // a body would be handing back the private content that made it invisible.
411
+ const visible = rows.filter((r) => r["visible"] !== false);
412
+ const deleted = rows
413
+ .filter((r) => r["visible"] === false)
414
+ .map((r) => ID_PREFIX.thread + String(r["id"]));
415
+ return c.json({
416
+ data: visible.map(shape),
417
+ nextCursor: null,
418
+ deleted,
419
+ serverTime: (clock?.now ?? new Date()).toISOString(),
420
+ });
421
+ });
422
+ /**
423
+ * 05 §6.2 — create. The whole thing is one transaction because a thread
424
+ * without its first comment is not a snag, it is a pin nobody can read.
425
+ */
426
+ app.post("/api/v1/projects/:uid/threads", async (c) => {
427
+ const p = await project(c.req.param("uid"));
428
+ if (!p)
429
+ return fail(c, 404, "project_not_found", "No project with that uid.");
430
+ const session = c.get("session");
431
+ if (!session) {
432
+ return fail(c, 401, "unauthorized", "Open your review link, or reload the page, and try again.");
433
+ }
434
+ if (!session.perm.includes("thread:create")) {
435
+ // §4.8's matrix. A read-only session gets a sentence about permission,
436
+ // not about authentication — they ARE signed in.
437
+ return fail(c, 403, "forbidden", "This review link does not allow new comments.");
438
+ }
439
+ const body = (await c.req.json().catch(() => null));
440
+ if (!body?.anchor || !body?.context) {
441
+ return fail(c, 400, "validation_failed", "anchor and context are required.");
442
+ }
443
+ // Absent, not "production": the widget only knows an environment name if
444
+ // the host put one on the script tag, and inventing one here would put a
445
+ // comment in an environment the dashboard is not looking at.
446
+ const envKey = typeof body.scope?.environment === "string" ? body.scope.environment : null;
447
+ // A client-minted id that is not a UUID is a bug in the client, not
448
+ // something to paper over with a server-minted one: the widget's optimistic
449
+ // copy would then be a different snag from the stored one. Parsed out here,
450
+ // before a transaction is opened, so a malformed body costs no round trip.
451
+ const threadId = body.id === undefined ? randomUUID() : rowId(body.id, ID_PREFIX.thread);
452
+ if (!threadId) {
453
+ return fail(c, 400, "validation_failed", `id must be a UUIDv7, optionally prefixed "${ID_PREFIX.thread}".`);
454
+ }
455
+ const commentBody = typeof body.comment?.body === "string" && body.comment.body ? body.comment.body : null;
456
+ const commentId = rowId(body.comment?.id, ID_PREFIX.comment) ?? randomUUID();
457
+ // The attachments uploaded moments ago, to be BOUND by the write below (05
458
+ // §6.6). Until that runs they are `pending` rows with no owner: §3.6's
459
+ // CHECK forbids a bound attachment without exactly one of
460
+ // thread_id/comment_id, which is what makes an orphan detectable rather
461
+ // than merely unlikely. The bind is scoped to this project AND to rows
462
+ // still pending, so a thread cannot adopt another project's picture or
463
+ // steal one already bound elsewhere.
464
+ const attachmentIds = (Array.isArray(body.attachments) ? body.attachments : [])
465
+ .map((a) => rowId(typeof a === "string" ? a : a?.id, ID_PREFIX.attachment))
466
+ .filter((x) => Boolean(x));
467
+ // THE AUTHOR IS THE BEARER'S `sub`, not anything in the body.
468
+ //
469
+ // This used to read `createdBy.holderSecret` off the request and mint a
470
+ // reviewer from it — a second, weaker answer to "who is this?" sitting
471
+ // beside the session that already answers it. 05 §4.4 is explicit that a
472
+ // session's identity is decided at exchange and never taken from a request,
473
+ // and leaving the body path in place would have meant a caller could author
474
+ // as anyone by presenting a secret they had seen once.
475
+ const reviewerId = session.sub;
476
+ try {
477
+ const created = await inOrg(sql, p.org_id, async (tx) => {
478
+ /*
479
+ * TWO statements, where there were eleven.
480
+ *
481
+ * Every one of them was a network round trip held open inside a
482
+ * transaction, and the measurement that forced this was a database
483
+ * moved from ap-southeast-1 to eu-west-2: 80 ms a trip became 270 ms,
484
+ * and leaving one comment took 3.1 seconds of almost pure waiting. The
485
+ * work never was the cost. The *count* was.
486
+ *
487
+ * What survives is the ORDER, because the order is load-bearing —
488
+ * §2.1's allocation still row-locks the project before the insert
489
+ * reads it, still serialises creation per project, and still yields a
490
+ * gapless human id. A CTE does not relax that: data-modifying CTEs run
491
+ * exactly once, to completion, and the insert that reads pseq/rseq
492
+ * cannot begin before they have produced their rows.
493
+ */
494
+ const [ctx] = await tx `
495
+ SELECT
496
+ (SELECT id FROM environments
497
+ WHERE project_id = ${p.id} AND deleted_at IS NULL
498
+ AND ${envKey ? tx `key = ${envKey}` : tx `is_default`}
499
+ LIMIT 1) AS environment_id,
500
+ r.id AS round_id
501
+ FROM (SELECT 1) one
502
+ LEFT JOIN LATERAL (
503
+ SELECT id FROM rounds
504
+ WHERE project_id = ${p.id} AND deleted_at IS NULL AND closed_at IS NULL
505
+ ORDER BY number DESC LIMIT 1
506
+ ) r ON true
507
+ `;
508
+ // Kept as its own statement rather than folded into the write below,
509
+ // because these two are the only failures a caller can act on and
510
+ // §6.9 gives them distinct codes. A single statement that inserted
511
+ // nothing would have to report "something was missing".
512
+ if (!ctx?.environment_id) {
513
+ throw new HttpError(400, "environment_not_found", envKey
514
+ ? `This project has no environment called "${envKey}".`
515
+ : "This project has no default environment.");
516
+ }
517
+ // The active round: highest number, not closed. D2 — a thread belongs
518
+ // to whichever round was active when it was created, and never moves.
519
+ if (!ctx.round_id)
520
+ throw new HttpError(409, "no_active_round", "This project has no open round.");
521
+ const counted = commentBody === null ? 0 : 1;
522
+ const [row] = await tx `
523
+ WITH pseq AS (
524
+ UPDATE projects SET thread_seq = thread_seq + 1
525
+ WHERE id = ${p.id}::uuid
526
+ RETURNING thread_seq
527
+ ),
528
+ rseq AS (
529
+ UPDATE rounds SET thread_seq = thread_seq + 1
530
+ WHERE id = ${ctx.round_id}::uuid
531
+ RETURNING thread_seq
532
+ ),
533
+ ins AS (
534
+ INSERT INTO threads (id, org_id, project_id, key, environment_id, round_id, round_number,
535
+ anchor, context, created_by, anchor_state,
536
+ comment_count, comment_count_public)
537
+ SELECT ${threadId}::uuid, ${p.org_id}::uuid, ${p.id}::uuid,
538
+ ${p.key_prefix}::text || '-' || pseq.thread_seq::text,
539
+ ${ctx.environment_id}::uuid, ${ctx.round_id}::uuid, rseq.thread_seq,
540
+ ${tx.json(body.anchor)}::jsonb, ${tx.json(body.context)}::jsonb,
541
+ ${reviewerId}::uuid, ${body.anchorState ?? "anchored"}::snag_anchor_state,
542
+ ${counted}::int, ${counted}::int
543
+ FROM pseq, rseq
544
+ RETURNING id, key, status, version, anchor, anchor_state, context, round_number,
545
+ created_at, updated_at, resolved_at, comment_count_public
546
+ ),
547
+ bound AS (
548
+ UPDATE attachments
549
+ SET thread_id = ${threadId}::uuid, state = 'bound', bound_at = now()
550
+ WHERE id = ANY(${attachmentIds}::uuid[])
551
+ AND project_id = ${p.id}::uuid AND state = 'pending' AND deleted_at IS NULL
552
+ AND EXISTS (SELECT 1 FROM ins)
553
+ RETURNING id
554
+ ),
555
+ com AS (
556
+ INSERT INTO comments (id, org_id, project_id, thread_id, seq, author_id, body)
557
+ SELECT ${commentId}::uuid, ${p.org_id}::uuid, ${p.id}::uuid, ${threadId}::uuid, 1,
558
+ ${reviewerId}::uuid, ${commentBody}::text
559
+ FROM ins WHERE ${commentBody}::text IS NOT NULL
560
+ RETURNING id, body, created_at
561
+ ),
562
+ touched AS (
563
+ UPDATE reviewers SET last_seen_at = now() WHERE id = ${reviewerId}::uuid RETURNING id
564
+ )
565
+ SELECT ins.*, rv.id AS created_by_id, rv.name AS created_by_name, rv.kind AS created_by_kind,
566
+ COALESCE(
567
+ (SELECT json_agg(json_build_object(
568
+ 'id', com.id, 'body', com.body, 'createdAt', com.created_at,
569
+ 'author', json_build_object('id', rv.id, 'name', rv.name, 'kind', rv.kind)))
570
+ FROM com),
571
+ '[]'::json) AS comments
572
+ FROM ins, reviewers rv
573
+ WHERE rv.id = ${reviewerId}::uuid
574
+ `;
575
+ /**
576
+ * THE STUDIO IS TOLD A SNAG EXISTS.
577
+ *
578
+ * `queueCommentNotice` was wired into the REPLY route and not into
579
+ * this one, so the only comment that never generated mail was the
580
+ * first one — the one that says a client found something. Paste the
581
+ * tag, hand over the link, and the first you hear of a round is when
582
+ * somebody replies to a comment you were never told about.
583
+ *
584
+ * Same topic and same dedupe key as a reply (`comment.created:<id>`),
585
+ * because it IS a `comment.created` — the thread is the container, and
586
+ * splitting it into a second topic would mean a second template, a
587
+ * second subscription rule and two ways to be silent.
588
+ *
589
+ * In THIS transaction, like the reply's: a comment that commits
590
+ * without its outbox row is a notification that never happens.
591
+ *
592
+ * A body-less thread queues nothing. There is no sentence to send, and
593
+ * "somebody pinned something" is not worth an email.
594
+ */
595
+ if (notifications && commentBody) {
596
+ await queueCommentNotice(tx, {
597
+ orgId: p.org_id,
598
+ projectId: p.id,
599
+ threadId,
600
+ commentId,
601
+ threadKey: String(row["key"]),
602
+ authorId: reviewerId,
603
+ authorName: String(row["created_by_name"] ?? "Someone"),
604
+ body: commentBody,
605
+ pageUrl: body.context?.url ?? "",
606
+ }, randomUUID());
607
+ }
608
+ return row;
609
+ });
610
+ c.header("x-snagset-config-version", String(p.config_version));
611
+ return c.json(shape(created), 201);
612
+ }
613
+ catch (e) {
614
+ if (e instanceof HttpError)
615
+ return fail(c, e.status, e.code, e.message);
616
+ /**
617
+ * A RETRY IS NOT AN ERROR — 05 §11's idempotency, by natural key.
618
+ *
619
+ * A double-tapped Send, or a reviewer on a train whose request landed
620
+ * while their connection dropped before the response did, sends the same
621
+ * body twice with the same CLIENT-MINTED id. Measured before this
622
+ * existed: the first was `201`, the second was **`500 internal_error`**,
623
+ * and the thread was in the database exactly once. So the snag was saved
624
+ * and the person who wrote it was told it had failed — which on a flaky
625
+ * connection is the moment they try again, or give up and stop using it.
626
+ *
627
+ * The id makes this decidable with no header and no client change: the
628
+ * widget already mints it (§6.2), so a second POST carrying an id we
629
+ * already hold IS the same request. Returning what the first one created
630
+ * is the whole of the fix.
631
+ *
632
+ * Caught rather than pre-checked, so the happy path costs nothing. A
633
+ * SELECT before every insert would put a round trip on the hot write to
634
+ * rule out something that almost never happens.
635
+ */
636
+ const code = e?.code;
637
+ if (code === "23505") {
638
+ const [existing] = await inOrg(sql, p.org_id, async (tx) => tx `
639
+ SELECT t.id, t.key, t.status, t.version, t.anchor, t.anchor_state, t.context, t.round_number,
640
+ t.created_at, t.updated_at, t.resolved_at, t.comment_count_public,
641
+ t.route, e.key AS environment_key, rd.number AS round, rd.name AS round_name,
642
+ r.id AS created_by_id, r.name AS created_by_name, r.kind AS created_by_kind,
643
+ COALESCE(
644
+ (SELECT json_agg(json_build_object(
645
+ 'id', c2.id, 'body', c2.body, 'createdAt', c2.created_at,
646
+ 'author', json_build_object('id', cr.id, 'name', cr.name, 'kind', cr.kind))
647
+ ORDER BY c2.seq)
648
+ FROM comments c2 JOIN reviewers cr ON cr.id = c2.author_id
649
+ WHERE c2.thread_id = t.id AND c2.deleted_at IS NULL AND c2.private = false),
650
+ '[]'::json) AS comments
651
+ FROM threads t
652
+ JOIN reviewers r ON r.id = t.created_by
653
+ JOIN environments e ON e.id = t.environment_id
654
+ JOIN rounds rd ON rd.id = t.round_id
655
+ WHERE t.id = ${threadId}::uuid
656
+ -- AND ours. An id that belongs to another project is a genuine
657
+ -- conflict, not a retry, and answering with somebody else's
658
+ -- thread would be far worse than the 500 this replaces.
659
+ AND t.project_id = ${p.id}::uuid
660
+ AND t.deleted_at IS NULL
661
+ `);
662
+ if (existing) {
663
+ c.header("x-snagset-config-version", String(p.config_version));
664
+ // 200, not 201: the second request created nothing. A client that
665
+ // branches on the status learns the truth, and one that does not
666
+ // gets the same body either way.
667
+ return c.json(shape(existing), 200);
668
+ }
669
+ }
670
+ throw e;
671
+ }
672
+ });
673
+ /**
674
+ * 05 §6.4 — status changes, endpoint 9, marked **W/D** like the list.
675
+ *
676
+ * IT SHIPPED WITH NO AUTHENTICATION CHECK AT ALL. Not a weak one — none: the
677
+ * handler read the uid, read the body and wrote. `bearerAuth` was mounted on
678
+ * this path, so a session was ATTACHED when one was presented, and nothing
679
+ * ever asked whether one had been. Anybody who could reach the port could
680
+ * resolve every comment in the project, which is the one write that makes a
681
+ * reviewer's report disappear from the list the studio works from.
682
+ *
683
+ * Two credentials are legitimate here and they are checked separately: an
684
+ * OWNER of this project's org, and a reviewer session carrying
685
+ * `thread:resolve` (§4.8's matrix — it is in the default link grant, because
686
+ * a client marking their own snag done is the loop D4 is built around).
687
+ */
688
+ app.patch("/api/v1/projects/:uid/threads/:id", async (c) => {
689
+ const p = await project(c.req.param("uid"));
690
+ if (!p)
691
+ return fail(c, 404, "project_not_found", "No project with that uid.");
692
+ const owner = c.get("owner");
693
+ const session = c.get("session");
694
+ const mcp = c.get("mcp");
695
+ const asOwner = !!owner && owner.orgId === p.org_id;
696
+ /**
697
+ * `mark_ready` (07 §8.3) lands here — and this is the whole of an agent's
698
+ * write surface on a thread, `threads:write`.
699
+ *
700
+ * §8.6 mitigation 4: *"an agent can propose, never dispose"*. There is
701
+ * deliberately no tool that resolves a thread, and the refusal below is
702
+ * where that is actually true rather than merely absent from a tool list:
703
+ * an agent PATCHing `status: 'resolved'` on this route is refused even
704
+ * holding `threads:write`. D1a — resolution is a human act, and a machine
705
+ * that can set `resolved` can be talked into setting it on everything.
706
+ */
707
+ const asAgent = mcpAllows(mcp, p.org_id, "threads:write");
708
+ if (mcp && !asAgent)
709
+ return scopeDenied(c, "threads:write");
710
+ if (!asOwner && !asAgent && !session) {
711
+ return fail(c, 401, "unauthorized", "Open your review link, or reload the page, and try again.");
712
+ }
713
+ if (!asOwner && !asAgent && !session.perm.includes("thread:resolve")) {
714
+ return fail(c, 403, "forbidden", "This review link does not allow changing status.");
715
+ }
716
+ const body = (await c.req.json().catch(() => null));
717
+ // D2: a thread belongs to exactly one round and NEVER moves. Refused
718
+ // explicitly rather than ignored, because a caller that sends `round` and
719
+ // gets a 200 will believe it moved — and the whole model rests on the fact
720
+ // that it did not. §6.9's `field_not_permitted` is the registered code.
721
+ for (const field of ["round", "roundId", "roundNumber"]) {
722
+ if (body && field in body) {
723
+ return fail(c, 422, "field_not_permitted", "A comment stays in the round it was left in. Rounds are periods of time, not folders.", { field });
724
+ }
725
+ }
726
+ /**
727
+ * `private` — 05 §6.4, and the write that makes `deleted[]` mean something.
728
+ *
729
+ * Studio-only, like the thread-level field it is: an owner always may, a
730
+ * reviewer session needs the link permission that names it. §6.4 is
731
+ * explicit that this is `403 field_not_permitted` with `details.field`
732
+ * rather than a silently ignored field, because a caller who sends
733
+ * `private: true` and gets a 200 will believe the thread is private.
734
+ */
735
+ const wantsPrivate = typeof body?.private === "boolean" ? body.private : undefined;
736
+ if (wantsPrivate !== undefined && !asOwner && !session?.perm.includes("thread:private")) {
737
+ return fail(c, 403, "field_not_permitted", "This review link does not allow team-only threads.", {
738
+ field: "private",
739
+ });
740
+ }
741
+ if (wantsPrivate !== undefined && asAgent && !asOwner) {
742
+ // §8.2: an agent write is never laundered into a trust tier it did not
743
+ // earn, and team-only is the studio's register.
744
+ return fail(c, 403, "field_not_permitted", "An agent cannot make a thread team-only.", {
745
+ field: "private",
746
+ });
747
+ }
748
+ const status = typeof body?.status === "string" ? body.status : undefined;
749
+ // `private` alone is a valid patch — the status is only required when it is
750
+ // the thing being changed.
751
+ if (status === undefined && wantsPrivate === undefined) {
752
+ return fail(c, 400, "validation_failed", "Send status, private, or both.");
753
+ }
754
+ if (status !== undefined && !["open", "progress", "review", "resolved"].includes(status)) {
755
+ return fail(c, 400, "validation_failed", "status must be one of open, progress, review, resolved.");
756
+ }
757
+ /**
758
+ * F-26's expected version — 01 §1221, AC-1-11's "no silent overwrite".
759
+ *
760
+ * `threads.version` has been incremented on every write since 0001 and
761
+ * READ by nothing: the column existed, the guarantee did not. Two studio
762
+ * members with the same thread open both pressed Resolve and the second
763
+ * write landed on top of the first without either of them learning that
764
+ * the other had touched it.
765
+ *
766
+ * OPTIONAL, deliberately. A caller that sends no `version` is saying "I am
767
+ * not editing a copy I read earlier" — the CLI, an agent acting on a fresh
768
+ * read, a curl. Making it mandatory would break every existing caller to
769
+ * protect a case they are not in, and 05 §11.1's additive rule forbids
770
+ * that. The dashboard and the widget send it because they ARE editing a
771
+ * copy they read earlier, which is exactly the population F-26 is about.
772
+ */
773
+ const expected = typeof body?.version === "number" ? body.version : undefined;
774
+ if (body && "version" in body && expected === undefined) {
775
+ return fail(c, 400, "validation_failed", "version must be a number when sent.");
776
+ }
777
+ const threadRow = rowId(c.req.param("id"), ID_PREFIX.thread);
778
+ if (!threadRow)
779
+ return fail(c, 404, "thread_not_found", "No such thread in this project.");
780
+ // The agent half of the two rules above, now that the status is known.
781
+ if (asAgent && !asOwner) {
782
+ if (status === "resolved") {
783
+ return fail(c, 403, "forbidden", "An agent may mark a snag ready for review, but not resolve it. " +
784
+ "Whoever reported it decides when it is fixed — tell your user it is ready.");
785
+ }
786
+ // 07 §8.4's stop signal, spent before the write rather than after it.
787
+ const spent = await checkWriteBudget(sql, mcp.tokenId);
788
+ if (spent !== null)
789
+ return budgetExhausted(c);
790
+ }
791
+ /*
792
+ * Returns the WHOLE thread, exactly as the list does.
793
+ *
794
+ * It used to return the updated columns and then fill the rest with
795
+ * `{ comments: [], created_by_name: "" }` — placeholders for fields the
796
+ * UPDATE had not selected. A caller merging that response into its list, as
797
+ * any optimistic client will, replaced a real comment with an empty array
798
+ * and a real author with nobody: resolving a snag in the dashboard made its
799
+ * text disappear from the row until the next reload. A response that
800
+ * carries a shape it does not mean is worse than one that carries less.
801
+ *
802
+ * One statement, because the alternative is an UPDATE and a SELECT and
803
+ * this project has already paid for that lesson once today.
804
+ */
805
+ // Set by the transaction when F-26 fires. A flag rather than a sentinel
806
+ // row, because "no row" already means 404 here and a conflict reported as
807
+ // "no such thread" would send a client into a refetch loop for a thread
808
+ // that is very much there.
809
+ let conflict;
810
+ const [row] = await inOrg(sql, p.org_id, async (tx) => {
811
+ /**
812
+ * F-26's check, INSIDE the transaction and holding the row.
813
+ *
814
+ * `FOR UPDATE` is what makes this a guarantee rather than a narrower
815
+ * race: read-then-write without the lock is the classic
816
+ * check-then-act — two writers can both read version 3, both find it
817
+ * matches, and both proceed. Locking the row serialises them, so the
818
+ * second one reads 4 and is refused. The lock is released by the
819
+ * transaction a few statements later either way.
820
+ */
821
+ if (expected !== undefined) {
822
+ const [current] = await tx `
823
+ SELECT version FROM threads
824
+ WHERE id = ${threadRow}::uuid AND project_id = ${p.id}::uuid AND deleted_at IS NULL
825
+ FOR UPDATE
826
+ `;
827
+ // A missing row is not a conflict — it is the 404 the outer code
828
+ // already knows how to say, and reporting it as a conflict would send
829
+ // a client into a refetch loop for a thread that is gone.
830
+ if (current && current.version !== expected) {
831
+ conflict = current.version;
832
+ return [];
833
+ }
834
+ }
835
+ /**
836
+ * §6.4 consequence TWO of three, and it runs before the CTE rather than
837
+ * inside it.
838
+ *
839
+ * *"every comment on a thread becoming private is set `private = true` in
840
+ * the same statement, since a public reply under a team-only thread is
841
+ * unreachable content."* Unreachable, and therefore invisible to the
842
+ * studio too — the reply route already forces `wantPrivate` when the
843
+ * thread is private, so new replies are fine; these are the ones written
844
+ * before the flip.
845
+ *
846
+ * A data-modifying CTE cannot see another sub-statement's writes, so
847
+ * folding this into the CTE would have counted `comment_count_public`
848
+ * against comments this statement was in the middle of hiding. Same
849
+ * transaction, two statements, in the order the arithmetic needs.
850
+ *
851
+ * Making a thread public again deliberately does NOT un-private the
852
+ * replies. "Share the thread" and "share what we said about it
853
+ * internally" are different decisions, and conflating them is the
854
+ * expensive direction to be wrong in.
855
+ */
856
+ if (wantsPrivate === true) {
857
+ await tx `
858
+ UPDATE comments SET private = true
859
+ WHERE thread_id = ${threadRow}::uuid AND private = false AND deleted_at IS NULL
860
+ `;
861
+ }
862
+ const updated = await tx `
863
+ WITH upd AS (
864
+ UPDATE threads
865
+ SET status = ${status === undefined ? tx `status` : tx `${status}::snag_thread_status`},
866
+ resolved_at = ${status === undefined
867
+ ? tx `resolved_at`
868
+ : status === "resolved"
869
+ ? tx `now()`
870
+ : tx `NULL`},
871
+ private = ${wantsPrivate === undefined ? tx `private` : tx `${wantsPrivate}`},
872
+ -- 05 section 6.4, consequence one of three: a private thread has
873
+ -- no public replies to count, and a thread made public again
874
+ -- counts only the replies that are themselves public.
875
+ comment_count_public = ${wantsPrivate === undefined
876
+ ? tx `comment_count_public`
877
+ : wantsPrivate
878
+ ? tx `0`
879
+ : tx `(SELECT count(*)::int FROM comments c
880
+ WHERE c.thread_id = threads.id AND c.deleted_at IS NULL AND c.private = false)`},
881
+ version = version + 1, updated_at = now()
882
+ WHERE id = ${threadRow}::uuid AND project_id = ${p.id}::uuid AND deleted_at IS NULL
883
+ -- EVERY changing column comes out of RETURNING, and that is the whole
884
+ -- point. The first version returned only the id and re-read the row
885
+ -- in the outer SELECT -- which reports the row as it was BEFORE this
886
+ -- statement, because the sub-statements of a data-modifying CTE all
887
+ -- see the snapshot the statement began with. It looked right on a
888
+ -- first click and returned the PREVIOUS status on every one after:
889
+ -- pressing Reopen answered "resolved". The joins below are safe
890
+ -- precisely because this UPDATE does not touch what they read.
891
+ RETURNING id, key, status, version, anchor, anchor_state, context, round_number,
892
+ created_at, updated_at, resolved_at, comment_count_public,
893
+ route, created_by, environment_id
894
+ )
895
+ SELECT t.id, t.key, t.status, t.version, t.anchor, t.anchor_state, t.context, t.round_number,
896
+ t.created_at, t.updated_at, t.resolved_at, t.comment_count_public,
897
+ t.route, e.key AS environment_key,
898
+ r.name AS created_by_name, r.kind AS created_by_kind, r.id AS created_by_id,
899
+ COALESCE(
900
+ (SELECT json_agg(json_build_object(
901
+ 'id', c.id, 'body', c.body, 'createdAt', c.created_at,
902
+ 'author', json_build_object('id', cr.id, 'name', cr.name, 'kind', cr.kind))
903
+ ORDER BY c.seq)
904
+ FROM comments c JOIN reviewers cr ON cr.id = c.author_id
905
+ WHERE c.thread_id = t.id AND c.deleted_at IS NULL
906
+ -- Same team-only leak as the GET list above, and reachable
907
+ -- the same way: a session with thread:resolve can PATCH a
908
+ -- thread it can also see private replies on, since this
909
+ -- route returns the whole thread. asOwner is already in
910
+ -- scope from the auth check above.
911
+ ${asOwner ? tx `` : tx `AND c.private = false`}),
912
+ '[]'::json) AS comments,
913
+ COALESCE(
914
+ (SELECT json_agg(json_build_object(
915
+ 'id', a.id, 'kind', a.kind, 'contentType', a.content_type,
916
+ 'width', a.width, 'height', a.height)
917
+ ORDER BY a.created_at)
918
+ FROM attachments a
919
+ WHERE a.thread_id = t.id AND a.deleted_at IS NULL AND a.state = 'bound'),
920
+ '[]'::json) AS attachments
921
+ FROM upd t
922
+ JOIN reviewers r ON r.id = t.created_by
923
+ JOIN environments e ON e.id = t.environment_id
924
+ `;
925
+ /**
926
+ * `thread.resolved` — B2's second topic, queued in the SAME transaction
927
+ * as the status change (07 §2.5), so a resolve that commits without its
928
+ * notification is impossible and a notification about a resolve that
929
+ * rolled back cannot exist.
930
+ *
931
+ * Only on the transition INTO `resolved`, and only when a mail transport
932
+ * is configured. `progress` and `review` are working states the studio
933
+ * moves through on its own; mailing on each would train people to filter
934
+ * the sender before the one message that matters arrives.
935
+ */
936
+ if (notifications && status === "resolved" && updated?.[0]) {
937
+ const t = updated[0];
938
+ await queueResolvedNotice(tx, {
939
+ orgId: p.org_id,
940
+ projectId: p.id,
941
+ threadId: threadRow,
942
+ threadKey: String(t["key"] ?? ""),
943
+ version: Number(t["version"] ?? 0),
944
+ // A reviewer resolving their own snag should not be mailed about
945
+ // it. An OWNER resolving from the dashboard has no reviewer row in
946
+ // scope here, and `threadRow` is a uuid that matches no reviewer —
947
+ // so it excludes nobody, which is the right answer: everyone who
948
+ // took part hears that the studio closed it.
949
+ actorId: session?.sub ?? threadRow,
950
+ actorName: asOwner ? "The studio" : "Someone",
951
+ pageUrl: String(t["context"]?.["url"] ?? ""),
952
+ });
953
+ }
954
+ return updated;
955
+ });
956
+ /**
957
+ * F-26, on the wire. 409 and not 412: `If-Match` semantics would make this
958
+ * a header concern, and 01 §1221 describes a product event ("someone else
959
+ * updated this while you were typing") that the client renders with **Post
960
+ * anyway** and **See what changed** — a conflict, not a failed
961
+ * precondition. `currentVersion` rides along so the refetch has something
962
+ * to compare against, and so a caller that legitimately wants to overwrite
963
+ * can re-send with it — which IS "Post anyway", expressed on the wire.
964
+ */
965
+ if (conflict !== undefined) {
966
+ return fail(c, 409, "version_conflict", "Someone else updated this while you were working on it. Reload to see their change, then try again.", { currentVersion: conflict, sentVersion: expected });
967
+ }
968
+ if (!row)
969
+ return fail(c, 404, "thread_not_found", "No such thread in this project.");
970
+ /**
971
+ * 07 §8.6 mitigation 8: every agent write lands in `events` with the
972
+ * reviewer id and the token id.
973
+ *
974
+ * A SECOND transaction, and this is the one place that is not ideal: the
975
+ * UPDATE above is a single self-contained statement inside `inOrg`, and
976
+ * threading the audit insert into it would mean restructuring a CTE whose
977
+ * comment block records what happened the last time someone did. The
978
+ * budget stays sound either way — it counts events, so an audit row that
979
+ * fails to write makes the budget stricter, never looser. A status change
980
+ * that commits without its audit row is a real gap; it is a smaller one
981
+ * than re-opening that CTE, and `mark_ready` is idempotent.
982
+ */
983
+ if (asAgent && !asOwner) {
984
+ await inOrg(sql, p.org_id, async (tx) => {
985
+ const reviewerId = await agentReviewer(tx, {
986
+ orgId: p.org_id,
987
+ projectId: p.id,
988
+ tokenId: mcp.tokenId,
989
+ tokenName: mcp.name,
990
+ });
991
+ await recordAgentWrite(tx, {
992
+ orgId: p.org_id,
993
+ projectId: p.id,
994
+ type: "thread.status_changed",
995
+ subjectType: "thread",
996
+ subjectId: String(row["id"]),
997
+ reviewerId,
998
+ tokenId: mcp.tokenId,
999
+ });
1000
+ });
1001
+ }
1002
+ return c.json(shape(row));
1003
+ });
1004
+ return app;
1005
+ }
1006
+ /**
1007
+ * Fields declared and assigned, NOT constructor parameter properties.
1008
+ *
1009
+ * The server runs under `node --experimental-strip-types`, which ERASES types
1010
+ * and transforms nothing. `constructor(readonly status: number)` is a
1011
+ * transform — it emits an assignment that is not in the source — so Node
1012
+ * refuses it outright (`ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`) at import time,
1013
+ * long after tsc has said the file is fine. Same rule bars enums, namespaces
1014
+ * and decorators everywhere under `apps/server`.
1015
+ */
1016
+ class HttpError extends Error {
1017
+ status;
1018
+ code;
1019
+ constructor(status, code, message) {
1020
+ super(message);
1021
+ this.status = status;
1022
+ this.code = code;
1023
+ }
1024
+ }
1025
+ /** Rows → 00 §5's `Thread`. The shape the widget already renders, unchanged. */
1026
+ function shape(r) {
1027
+ return {
1028
+ id: wireId_(r.id, ID_PREFIX.thread),
1029
+ key: r.key,
1030
+ projectId: undefined,
1031
+ status: r.status,
1032
+ anchor: r.anchor,
1033
+ anchorState: r.anchor_state,
1034
+ context: r.context,
1035
+ roundNumber: r.round_number,
1036
+ comments: (r.comments ?? []).map((c) => ({
1037
+ ...c,
1038
+ id: wireId_(c.id, ID_PREFIX.comment),
1039
+ author: { ...c.author, id: wireId_(c.author.id, ID_PREFIX.reviewer) },
1040
+ })),
1041
+ createdBy: {
1042
+ id: r.created_by_id ? wireId_(r.created_by_id, ID_PREFIX.reviewer) : undefined,
1043
+ name: r.created_by_name,
1044
+ kind: r.created_by_kind,
1045
+ },
1046
+ createdAt: new Date(r.created_at).toISOString(),
1047
+ updatedAt: new Date(r.updated_at).toISOString(),
1048
+ // F-26's handle. On the LIST as well as the detail, because the dashboard
1049
+ // patches straight from a list row — an editor that has to fetch the
1050
+ // detail first just to learn the version would make every Resolve two
1051
+ // round trips.
1052
+ version: r.version,
1053
+ resolvedAt: r.resolved_at ? new Date(r.resolved_at).toISOString() : undefined,
1054
+ commentCount: r.comment_count_public ?? 0,
1055
+ // Present on the LIST too, not only the detail: the dock draws a thumbnail
1056
+ // from it, and before this the picture reappeared only on the machine that
1057
+ // took it — everyone else saw a comment with no screenshot.
1058
+ ...(r.attachments?.length
1059
+ ? {
1060
+ attachments: r.attachments.map((a) => ({
1061
+ ...a,
1062
+ id: wireId_(a.id, ID_PREFIX.attachment),
1063
+ // The proxy route, never a bucket URL: the key is a content hash
1064
+ // and the object is stored with `content-disposition: attachment`
1065
+ // from an origin nobody should be fetching directly.
1066
+ url: `/api/v1/attachments/${wireId_(a.id, ID_PREFIX.attachment)}/blob`,
1067
+ })),
1068
+ }
1069
+ : {}),
1070
+ // Dashboard-side grouping, and harmless to a widget that ignores them.
1071
+ ...(r.route !== undefined && r.route !== null ? { route: r.route } : {}),
1072
+ ...(r.environment_key ? { environment: r.environment_key } : {}),
1073
+ // `round` is WHICH round; `roundNumber` above is the thread's place inside
1074
+ // it. Both are on the wire because the dashboard groups by the first and
1075
+ // draws the second on the pin.
1076
+ ...(r.round !== undefined && r.round !== null ? { round: r.round, roundName: r.round_name } : {}),
1077
+ };
1078
+ }
1079
+ export const THREAD_KEY_PREFIX = THREAD_REF_PREFIX;
1080
+ //# sourceMappingURL=threads.js.map