@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,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
|