@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,313 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { ID_PREFIX } from "../vendor/types/names.js";
|
|
4
|
+
import { csrfOk } from "./owner.js";
|
|
5
|
+
import { mcpAllows, scopeDenied, checkWriteBudget, budgetExhausted, recordAgentWrite, agentReviewer, } from "./mcpAuth.js";
|
|
6
|
+
import { queueCommentNotice } from "../mail/notify.js";
|
|
7
|
+
function fail(c, status, code, message, details) {
|
|
8
|
+
return c.json({ error: { code, message, ...(details ? { details } : {}), requestId: c.get("requestId") } }, status);
|
|
9
|
+
}
|
|
10
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
11
|
+
function rowId(wire, prefix) {
|
|
12
|
+
if (typeof wire !== "string")
|
|
13
|
+
return null;
|
|
14
|
+
const bare = wire.startsWith(prefix) ? wire.slice(prefix.length) : wire;
|
|
15
|
+
return UUID.test(bare) ? bare.toLowerCase() : null;
|
|
16
|
+
}
|
|
17
|
+
export function commentRoutes(deps) {
|
|
18
|
+
const app = new Hono();
|
|
19
|
+
const { sql } = deps;
|
|
20
|
+
const handle = async (c) => {
|
|
21
|
+
const threadRow = rowId(c.req.param("id"), ID_PREFIX.thread);
|
|
22
|
+
if (!threadRow)
|
|
23
|
+
return fail(c, 404, "thread_not_found", "No such thread.");
|
|
24
|
+
const [thread] = await sql `
|
|
25
|
+
SELECT t.id, t.project_id, t.org_id, t.private, p.uid AS project_uid,
|
|
26
|
+
t.key,
|
|
27
|
+
-- The page URL lives INSIDE the capture context, not in a column
|
|
28
|
+
-- of its own (05 §3.5). Two arrows, not one, so it arrives as
|
|
29
|
+
-- text rather than as a JSON-quoted string.
|
|
30
|
+
t.context->>'url' AS page_url
|
|
31
|
+
FROM threads t JOIN projects p ON p.id = t.project_id
|
|
32
|
+
WHERE t.id = ${threadRow}::uuid AND t.deleted_at IS NULL
|
|
33
|
+
`;
|
|
34
|
+
if (!thread)
|
|
35
|
+
return fail(c, 404, "thread_not_found", "No such thread.");
|
|
36
|
+
// The widget's URL always carries `:uid`; the dashboard's canonical form
|
|
37
|
+
// never does. When it is present it must be THIS thread's project — a uid
|
|
38
|
+
// that resolves to a different project is treated the same as a thread
|
|
39
|
+
// that does not exist, not as a permission question, so a uid's validity
|
|
40
|
+
// leaks nothing about a thread that belongs to somebody else's project.
|
|
41
|
+
const uidParam = c.req.param("uid");
|
|
42
|
+
if (uidParam && uidParam !== thread.project_uid) {
|
|
43
|
+
return fail(c, 404, "thread_not_found", "No such thread in this project.");
|
|
44
|
+
}
|
|
45
|
+
const body = (await c.req.json().catch(() => null));
|
|
46
|
+
// D2, same refusal PATCH already gives a caller that tries to move a
|
|
47
|
+
// thread's round: a comment does not choose one either. `attachmentIds` is
|
|
48
|
+
// 05 §6.2's bind-at-write path (`threads.ts`'s `bound` CTE); this route
|
|
49
|
+
// does not implement it yet, and accepting-then-dropping it would leave a
|
|
50
|
+
// reviewer believing their screenshot attached when it never bound to
|
|
51
|
+
// anything (§6.9's `field_not_permitted` is the code for "refused, not
|
|
52
|
+
// silently ignored"). `mentions` is genuinely inert for now — 05 §3.6
|
|
53
|
+
// parses it from `body` at write time server-side (§2.11), so a client
|
|
54
|
+
// sending it costs nothing and is left for that later slice to wire up.
|
|
55
|
+
for (const field of ["round", "roundId", "roundNumber", "attachmentIds"]) {
|
|
56
|
+
if (body && field in body) {
|
|
57
|
+
return fail(c, 422, "field_not_permitted", `"${field}" is not accepted on this route yet.`, {
|
|
58
|
+
field,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const owner = c.get("owner");
|
|
63
|
+
const session = c.get("session");
|
|
64
|
+
const mcp = c.get("mcp");
|
|
65
|
+
const asOwner = !!owner && owner.orgId === thread.org_id;
|
|
66
|
+
const asSession = !!session && session.aud === thread.project_uid && session.perm.includes("comment:create");
|
|
67
|
+
/**
|
|
68
|
+
* THREE CREDENTIALS NOW. `comment_on_thread` (07 §8.3) is one half of an
|
|
69
|
+
* agent's entire write surface — the other is `mark_ready` — and it lands
|
|
70
|
+
* on this handler like everyone else, because 07 §8.1 is explicit that MCP
|
|
71
|
+
* *"is not a second API"*.
|
|
72
|
+
*/
|
|
73
|
+
const asAgent = mcpAllows(mcp, thread.org_id, "comments:write");
|
|
74
|
+
if (mcp && !asAgent)
|
|
75
|
+
return scopeDenied(c, "comments:write");
|
|
76
|
+
if (!asOwner && !asSession && !asAgent) {
|
|
77
|
+
if (!owner && !session && !mcp) {
|
|
78
|
+
return fail(c, 401, "unauthorized", "Sign in, or open your review link, and try again.");
|
|
79
|
+
}
|
|
80
|
+
return fail(c, 403, "forbidden", owner ? "You cannot comment on another org's project." : "This review link does not allow replies.");
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* An agent may not reply on a thread the studio marked private, even
|
|
84
|
+
* holding `comments:write`.
|
|
85
|
+
*
|
|
86
|
+
* The same argument as the read side: `private` is the studio talking
|
|
87
|
+
* about the client, and the agent's reply would join that conversation
|
|
88
|
+
* having read none of it (this route's caller cannot see private comments
|
|
89
|
+
* either). Refused rather than silently made public, because a comment
|
|
90
|
+
* that lands somewhere other than where it was addressed is worse than one
|
|
91
|
+
* that does not land.
|
|
92
|
+
*/
|
|
93
|
+
if (asAgent && !asOwner && thread.private) {
|
|
94
|
+
return fail(c, 403, "forbidden", "This thread is team-only. An agent cannot reply on it.");
|
|
95
|
+
}
|
|
96
|
+
// 07 §8.4's write budget, checked before the write. §8.6 mitigation 5.
|
|
97
|
+
if (asAgent && !asOwner && !asSession) {
|
|
98
|
+
const spent = await checkWriteBudget(sql, mcp.tokenId);
|
|
99
|
+
if (spent !== null)
|
|
100
|
+
return budgetExhausted(c);
|
|
101
|
+
}
|
|
102
|
+
// The CSRF half of `requireOwner()`, run by hand — see the file header for
|
|
103
|
+
// why that middleware cannot be mounted on a path a reviewer session also
|
|
104
|
+
// posts to. Every branch that reaches here is a mutation (POST-only route).
|
|
105
|
+
if (asOwner && !csrfOk(c)) {
|
|
106
|
+
return fail(c, 403, "csrf_failed", "That request could not be verified. Reload the dashboard and try again.");
|
|
107
|
+
}
|
|
108
|
+
// TEAM-ONLY REPLY (FOLDBACK §A5, §6.4a). Studio-only to set, same as a
|
|
109
|
+
// thread's own `private` — an owner always may; a session needs the link
|
|
110
|
+
// permission that names it, exactly like `thread:assign`/`thread:private`
|
|
111
|
+
// gate the thread-level fields (05 §6.4 step 4).
|
|
112
|
+
//
|
|
113
|
+
// A thread that is ALREADY private forces every reply on it private too —
|
|
114
|
+
// §6.4 is explicit that the field is then ignored, not merely defaulted.
|
|
115
|
+
// The first cut read `thread.private` off the row and never used it, so
|
|
116
|
+
// `wantPrivate` came only from the body: a reviewer session replying on a
|
|
117
|
+
// private thread with no `private` field in the body got a PUBLIC comment
|
|
118
|
+
// stored on a thread the client should never have been able to see existed,
|
|
119
|
+
// and the 403 below fired only when the body explicitly asked for private
|
|
120
|
+
// — never when the thread's own privacy should have applied silently.
|
|
121
|
+
const wantPrivate = thread.private || body?.private === true;
|
|
122
|
+
// `session!` below is safe only because an agent is excluded first: an MCP
|
|
123
|
+
// caller has no session at all, so the non-null assertion would have thrown
|
|
124
|
+
// a 500 on `session!.perm` the first time an agent sent `private: true`.
|
|
125
|
+
// It may not set the field regardless — a team-only reply is the studio's
|
|
126
|
+
// register, and 07 §8.2 is emphatic that an agent write is never laundered
|
|
127
|
+
// into a trust tier it did not earn.
|
|
128
|
+
if (asAgent && !asOwner && body?.private === true) {
|
|
129
|
+
return fail(c, 403, "field_not_permitted", "An agent cannot post a team-only reply.", {
|
|
130
|
+
field: "private",
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
if (!thread.private && body?.private === true && !asOwner && !session.perm.includes("thread:private")) {
|
|
134
|
+
return fail(c, 403, "field_not_permitted", "This review link does not allow team-only replies.", {
|
|
135
|
+
field: "private",
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
const commentBody = typeof body?.body === "string" ? body.body : "";
|
|
139
|
+
if (!commentBody)
|
|
140
|
+
return fail(c, 400, "validation_failed", "body is required.");
|
|
141
|
+
// §3.5's budget, enforced here rather than left to the CHECK: a client
|
|
142
|
+
// gets a code it can act on instead of a raw constraint-violation 500.
|
|
143
|
+
if (commentBody.length > 10_000) {
|
|
144
|
+
return fail(c, 400, "body_too_large", "A comment may be at most 10,000 characters.");
|
|
145
|
+
}
|
|
146
|
+
const commentId = body?.id === undefined ? randomUUID() : rowId(body.id, ID_PREFIX.comment);
|
|
147
|
+
if (!commentId) {
|
|
148
|
+
return fail(c, 400, "validation_failed", `id must be a UUIDv7, optionally prefixed "${ID_PREFIX.comment}".`);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* A RETRY IS NOT AN ERROR — the same measured bug as `POST /threads`.
|
|
152
|
+
*
|
|
153
|
+
* A client-minted comment id sent twice hit the primary key and came back
|
|
154
|
+
* `500 internal_error`, with the reply already stored. On the reply
|
|
155
|
+
* composer that is a double-tapped Send, or a connection that dropped
|
|
156
|
+
* between the write and the response — and the reviewer is told their
|
|
157
|
+
* reply was lost while it sits in the thread.
|
|
158
|
+
*
|
|
159
|
+
* Checked BEFORE the write here rather than caught after, unlike the
|
|
160
|
+
* thread route, because this transaction also bumps two counters and
|
|
161
|
+
* queues a notification: relying on the whole thing rolling back to keep
|
|
162
|
+
* those correct is a much larger claim than relying on one INSERT to fail.
|
|
163
|
+
* The cost is one indexed lookup on a route that is not the hot path.
|
|
164
|
+
*/
|
|
165
|
+
const [alreadyThere] = await sql `
|
|
166
|
+
SELECT id, body, created_at FROM comments
|
|
167
|
+
WHERE id = ${commentId}::uuid AND thread_id = ${thread.id}::uuid AND deleted_at IS NULL
|
|
168
|
+
`;
|
|
169
|
+
if (alreadyThere) {
|
|
170
|
+
const [author] = await sql `
|
|
171
|
+
SELECT r.id, r.name, r.kind FROM reviewers r
|
|
172
|
+
JOIN comments c ON c.author_id = r.id WHERE c.id = ${commentId}::uuid
|
|
173
|
+
`;
|
|
174
|
+
return c.json({
|
|
175
|
+
id: ID_PREFIX.comment + alreadyThere.id,
|
|
176
|
+
body: alreadyThere.body,
|
|
177
|
+
createdAt: alreadyThere.created_at.toISOString(),
|
|
178
|
+
author: {
|
|
179
|
+
id: ID_PREFIX.reviewer + author.id,
|
|
180
|
+
name: author.name,
|
|
181
|
+
kind: author.kind,
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
// 200, not 201: this request created nothing.
|
|
185
|
+
200);
|
|
186
|
+
}
|
|
187
|
+
const created = await sql.begin(async (tx) => {
|
|
188
|
+
await tx `SELECT set_config('app.org_id', ${thread.org_id}, true)`;
|
|
189
|
+
// The author. A session's `sub` IS a `reviewers.id` (05 §4.4) — nothing
|
|
190
|
+
// to resolve. An OWNER has no reviewer row yet the first time they post
|
|
191
|
+
// from the dashboard, so one is found-or-created here: `kind='verified'`
|
|
192
|
+
// with `user_id` set satisfies 0001_init.sql's CHECK on that kind
|
|
193
|
+
// without a `holder_secret_hash`, which only non-verified kinds need.
|
|
194
|
+
let reviewerId;
|
|
195
|
+
if (session) {
|
|
196
|
+
reviewerId = session.sub;
|
|
197
|
+
}
|
|
198
|
+
else if (asAgent && !asOwner) {
|
|
199
|
+
// 05 §9.6's representation, via the one helper that knows it:
|
|
200
|
+
// kind='guest', is_agent, agent_mcp_token_id. NEVER 'verified'.
|
|
201
|
+
reviewerId = await agentReviewer(tx, {
|
|
202
|
+
orgId: thread.org_id,
|
|
203
|
+
projectId: thread.project_id,
|
|
204
|
+
tokenId: mcp.tokenId,
|
|
205
|
+
tokenName: mcp.name,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
const o = owner;
|
|
210
|
+
const [existing] = await tx `
|
|
211
|
+
SELECT id FROM reviewers
|
|
212
|
+
WHERE project_id = ${thread.project_id}::uuid AND user_id = ${o.userId}::uuid AND kind = 'verified'
|
|
213
|
+
`;
|
|
214
|
+
if (existing) {
|
|
215
|
+
reviewerId = existing.id;
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
reviewerId = randomUUID();
|
|
219
|
+
await tx `
|
|
220
|
+
INSERT INTO reviewers (id, org_id, project_id, kind, name, email, user_id)
|
|
221
|
+
VALUES (${reviewerId}::uuid, ${thread.org_id}::uuid, ${thread.project_id}::uuid, 'verified',
|
|
222
|
+
${o.name || o.email}, ${o.email}, ${o.userId}::uuid)
|
|
223
|
+
`;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// Locked here, not read-then-trusted: `seq` and both counters are
|
|
227
|
+
// derived from THIS row under the lock, so two replies landing at once
|
|
228
|
+
// serialise instead of racing to the same `seq` (§6.4, verbatim).
|
|
229
|
+
const [row] = await tx `
|
|
230
|
+
SELECT comment_count, comment_count_public FROM threads WHERE id = ${thread.id}::uuid FOR UPDATE
|
|
231
|
+
`;
|
|
232
|
+
const seq = row.comment_count + 1;
|
|
233
|
+
const publicCount = wantPrivate ? row.comment_count_public : row.comment_count_public + 1;
|
|
234
|
+
const [comment] = await tx `
|
|
235
|
+
INSERT INTO comments (id, org_id, project_id, thread_id, seq, author_id, body, private)
|
|
236
|
+
VALUES (${commentId}::uuid, ${thread.org_id}::uuid, ${thread.project_id}::uuid, ${thread.id}::uuid,
|
|
237
|
+
${seq}, ${reviewerId}::uuid, ${commentBody}, ${wantPrivate})
|
|
238
|
+
RETURNING id, body, created_at
|
|
239
|
+
`;
|
|
240
|
+
await tx `
|
|
241
|
+
UPDATE threads
|
|
242
|
+
SET comment_count = comment_count + 1,
|
|
243
|
+
comment_count_public = ${publicCount},
|
|
244
|
+
version = version + 1,
|
|
245
|
+
updated_at = now()
|
|
246
|
+
WHERE id = ${thread.id}::uuid
|
|
247
|
+
`;
|
|
248
|
+
const [author] = await tx `
|
|
249
|
+
SELECT id, name, kind FROM reviewers WHERE id = ${reviewerId}::uuid
|
|
250
|
+
`;
|
|
251
|
+
// 07 §8.6 mitigation 8, in THIS transaction — so the audit row and the
|
|
252
|
+
// comment it records commit together and the budget above can never see
|
|
253
|
+
// fewer writes than actually happened.
|
|
254
|
+
if (asAgent && !asOwner && !asSession) {
|
|
255
|
+
await recordAgentWrite(tx, {
|
|
256
|
+
orgId: thread.org_id,
|
|
257
|
+
projectId: thread.project_id,
|
|
258
|
+
type: "comment.created",
|
|
259
|
+
subjectType: "comment",
|
|
260
|
+
subjectId: comment.id,
|
|
261
|
+
reviewerId,
|
|
262
|
+
tokenId: mcp.tokenId,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* The notification, queued in THIS transaction (07 §2.5).
|
|
267
|
+
*
|
|
268
|
+
* A comment that commits without its outbox row is a notification that
|
|
269
|
+
* never happens; a row that commits without its comment is mail about
|
|
270
|
+
* something that does not exist. One transaction rules out both, and it
|
|
271
|
+
* is the reason `queueCommentNotice` takes `tx` rather than a pool.
|
|
272
|
+
*
|
|
273
|
+
* PRIVATE COMMENTS ARE NOT MAILED. A private reply is visible only to
|
|
274
|
+
* the owner's side of the conversation, and while the owner is exactly
|
|
275
|
+
* who this mail goes to, the body would then sit in an inbox — and in
|
|
276
|
+
* whatever forwards, backs up or indexes it — for a comment the product
|
|
277
|
+
* promises is private. Not worth the surprise for a notification.
|
|
278
|
+
*/
|
|
279
|
+
if (!wantPrivate && deps.notifications) {
|
|
280
|
+
await queueCommentNotice(tx, {
|
|
281
|
+
orgId: thread.org_id,
|
|
282
|
+
projectId: thread.project_id,
|
|
283
|
+
threadId: thread.id,
|
|
284
|
+
commentId: comment.id,
|
|
285
|
+
threadKey: thread.key,
|
|
286
|
+
authorId: reviewerId,
|
|
287
|
+
authorName: author?.name ?? "Someone",
|
|
288
|
+
body: comment.body,
|
|
289
|
+
pageUrl: thread.page_url ?? "",
|
|
290
|
+
}, randomUUID());
|
|
291
|
+
}
|
|
292
|
+
return { comment: comment, author: author };
|
|
293
|
+
});
|
|
294
|
+
// The SAME shape the list route embeds per comment (`threads.ts`'s
|
|
295
|
+
// `shape()`) — a client merging this response into a thread it already
|
|
296
|
+
// holds must not learn a different Comment type depending on which route
|
|
297
|
+
// produced it.
|
|
298
|
+
return c.json({
|
|
299
|
+
id: ID_PREFIX.comment + created.comment.id,
|
|
300
|
+
body: created.comment.body,
|
|
301
|
+
createdAt: created.comment.created_at.toISOString(),
|
|
302
|
+
author: {
|
|
303
|
+
id: ID_PREFIX.reviewer + created.author.id,
|
|
304
|
+
name: created.author.name,
|
|
305
|
+
kind: created.author.kind,
|
|
306
|
+
},
|
|
307
|
+
}, 201);
|
|
308
|
+
};
|
|
309
|
+
app.post("/api/v1/threads/:id/comments", handle);
|
|
310
|
+
app.post("/api/v1/projects/:uid/threads/:id/comments", handle);
|
|
311
|
+
return app;
|
|
312
|
+
}
|
|
313
|
+
//# sourceMappingURL=comments.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"comments.js","sourceRoot":"","sources":["../../src/routes/comments.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { HTTP_HEADER } from "../vendor/types/names.js";
|
|
2
|
+
/**
|
|
3
|
+
* The request headers a widget may send — 05 §2.3's request rows, plus the two
|
|
4
|
+
* standard ones. Built from the registry rather than typed out, so adding a
|
|
5
|
+
* header to a request without listing it here is not possible.
|
|
6
|
+
*/
|
|
7
|
+
const ALLOWED_REQUEST_HEADERS = [
|
|
8
|
+
"content-type",
|
|
9
|
+
"authorization",
|
|
10
|
+
HTTP_HEADER.client,
|
|
11
|
+
HTTP_HEADER.schema,
|
|
12
|
+
HTTP_HEADER.idempotencyKey,
|
|
13
|
+
HTTP_HEADER.identity,
|
|
14
|
+
HTTP_HEADER.attachmentKind,
|
|
15
|
+
HTTP_HEADER.attachmentWidth,
|
|
16
|
+
HTTP_HEADER.attachmentHeight,
|
|
17
|
+
].join(", ");
|
|
18
|
+
/**
|
|
19
|
+
* 05 §5.1's CORS classes, and 08 §3.5's D2 — *"origin suggestion, never origin
|
|
20
|
+
* auto-allow"*.
|
|
21
|
+
*
|
|
22
|
+
* | Class | Allow-Origin |
|
|
23
|
+
* |----------------|-------------------------------------------------|
|
|
24
|
+
* | Public-read | echo any Origin (`GET …/config`, `GET …/meta`) |
|
|
25
|
+
* | Project-scoped | echo ONLY if allow-listed; otherwise omit |
|
|
26
|
+
* | Dashboard | no CORS headers at all (`/api/v1/setup`, `/auth`)|
|
|
27
|
+
*
|
|
28
|
+
* "Otherwise omit the header entirely" is the load-bearing half, and it is why
|
|
29
|
+
* this returns a header rather than a 403: the browser is the enforcement
|
|
30
|
+
* point. A response with no `Access-Control-Allow-Origin` is unreadable to the
|
|
31
|
+
* calling page no matter what its status says, and a 403 with a body would
|
|
32
|
+
* leak whether the uid exists to any origin that asks.
|
|
33
|
+
*
|
|
34
|
+
* The reason it is a suggestion and never an auto-allow is foundations §8:
|
|
35
|
+
* `Origin` is attacker-controllable. Auto-allowing would be an
|
|
36
|
+
* attacker-controlled header driving a state transition that GRANTS access —
|
|
37
|
+
* the mirror image of the denial-of-service case §8 already forbids. Recording
|
|
38
|
+
* it is inert data; a human clicking "Add this origin" is the transition.
|
|
39
|
+
*/
|
|
40
|
+
/** 05 §3.7: exact origin, or a single-level wildcard host. Scheme mandatory. */
|
|
41
|
+
function matches(pattern, origin) {
|
|
42
|
+
if (pattern === origin)
|
|
43
|
+
return true;
|
|
44
|
+
if (!pattern.includes("*"))
|
|
45
|
+
return false;
|
|
46
|
+
// `https://*.acme.com` matches `https://staging.acme.com` and NOT
|
|
47
|
+
// `https://a.b.acme.com` — single level, because a wildcard that crosses
|
|
48
|
+
// dots allows any subdomain a subdomain-takeover can mint.
|
|
49
|
+
const [scheme, host] = pattern.split("://");
|
|
50
|
+
const [oScheme, oHost] = origin.split("://");
|
|
51
|
+
if (scheme !== oScheme || !host || !oHost)
|
|
52
|
+
return false;
|
|
53
|
+
const suffix = host.slice(1); // ".acme.com"
|
|
54
|
+
return oHost.endsWith(suffix) && !oHost.slice(0, -suffix.length).includes(".");
|
|
55
|
+
}
|
|
56
|
+
export async function allowedOrigins(sql, projectId) {
|
|
57
|
+
const rows = await sql `
|
|
58
|
+
SELECT pattern FROM project_origins WHERE project_id = ${projectId}
|
|
59
|
+
`;
|
|
60
|
+
return rows.map((r) => r.pattern);
|
|
61
|
+
}
|
|
62
|
+
export function isAllowed(patterns, origin) {
|
|
63
|
+
return patterns.some((p) => matches(p, origin.toLowerCase()));
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* 08 §3.5 D2's ledger. Capped at 5 distinct origins per project (05 owns the
|
|
67
|
+
* number), so a page that iterates hostnames cannot grow the table.
|
|
68
|
+
*
|
|
69
|
+
* `write_attempted` distinguishes the two cases the dashboard phrases
|
|
70
|
+
* differently: a bare `/config` fetch is *"add this origin?"*, while a write
|
|
71
|
+
* from an unknown origin is *"your project uid is in use somewhere else"*.
|
|
72
|
+
*/
|
|
73
|
+
export async function recordOrigin(sql, orgId, projectId, origin, wroteAttempt) {
|
|
74
|
+
try {
|
|
75
|
+
await sql `
|
|
76
|
+
INSERT INTO origin_violations (org_id, project_id, origin, write_attempted)
|
|
77
|
+
SELECT ${orgId}, ${projectId}, ${origin}, ${wroteAttempt}
|
|
78
|
+
WHERE (SELECT count(*) FROM origin_violations WHERE project_id = ${projectId}) < 5
|
|
79
|
+
ON CONFLICT (project_id, origin) DO UPDATE
|
|
80
|
+
SET hits = origin_violations.hits + 1,
|
|
81
|
+
last_seen_at = now(),
|
|
82
|
+
write_attempted = origin_violations.write_attempted OR EXCLUDED.write_attempted
|
|
83
|
+
`;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// A ledger write must never fail the request it is observing. The
|
|
87
|
+
// suggestion is a convenience; the CORS decision above it already happened.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The middleware, mounted at the APP level rather than inside a route group.
|
|
92
|
+
*
|
|
93
|
+
* It lived inside `threadRoutes` first, and the sessions route — registered
|
|
94
|
+
* before it — answered without an `Access-Control-Allow-Origin` header. The
|
|
95
|
+
* preflight still passed, because the OPTIONS handler was in the group that
|
|
96
|
+
* had the middleware, so the browser reported `net::ERR_FAILED` on the POST
|
|
97
|
+
* with a green OPTIONS above it: a CORS failure that looks like a network
|
|
98
|
+
* fault. Route-group middleware only covers the routes registered after it in
|
|
99
|
+
* that group, and a credential exchange is exactly the route you cannot afford
|
|
100
|
+
* to leave outside.
|
|
101
|
+
*/
|
|
102
|
+
export function corsForProjects(sql, lookup) {
|
|
103
|
+
/**
|
|
104
|
+
* The allow-list, memoised for 10 s — the same bargain and the same number as
|
|
105
|
+
* the project cache (05 §4.4).
|
|
106
|
+
*
|
|
107
|
+
* This was the last uncached query on the widget's hot path: one SELECT
|
|
108
|
+
* before every request, to read three rows that change when an operator runs
|
|
109
|
+
* `snagset allow-origin`. Ten seconds is the lag that buys, and it errs the
|
|
110
|
+
* safe way round — a NEWLY allowed origin waits up to ten seconds to work,
|
|
111
|
+
* which reads as "it took a moment"; a REVOKED one is not the control that
|
|
112
|
+
* matters here, since removing an origin is not how access is withdrawn
|
|
113
|
+
* (that is the link revoke, and it bumps `session_epoch`).
|
|
114
|
+
*
|
|
115
|
+
* Scoped to this middleware instance, so an app built by a test starts cold.
|
|
116
|
+
*/
|
|
117
|
+
const ORIGINS_TTL_MS = 10_000;
|
|
118
|
+
const originCache = new Map();
|
|
119
|
+
const patternsFor = async (projectId) => {
|
|
120
|
+
const hit = originCache.get(projectId);
|
|
121
|
+
if (hit && Date.now() - hit.at < ORIGINS_TTL_MS)
|
|
122
|
+
return hit.patterns;
|
|
123
|
+
const patterns = await allowedOrigins(sql, projectId);
|
|
124
|
+
originCache.set(projectId, { at: Date.now(), patterns });
|
|
125
|
+
return patterns;
|
|
126
|
+
};
|
|
127
|
+
return async (c, next) => {
|
|
128
|
+
const origin = c.req.header("Origin");
|
|
129
|
+
// PUBLIC-READ (§5.1): `/config` echoes any origin, because that fetch is
|
|
130
|
+
// how an unknown origin becomes a suggestion in the first place — refusing
|
|
131
|
+
// it means the operator never sees the origin they are about to approve.
|
|
132
|
+
// The session exchange is PROJECT-SCOPED: it mints a credential.
|
|
133
|
+
const isPublicRead = c.req.path.endsWith("/config");
|
|
134
|
+
const uid = c.req.param("uid");
|
|
135
|
+
if (origin && uid) {
|
|
136
|
+
const project = await lookup(uid).catch(() => null);
|
|
137
|
+
if (project) {
|
|
138
|
+
const patterns = await patternsFor(project.id);
|
|
139
|
+
const ok = isAllowed(patterns, origin);
|
|
140
|
+
/**
|
|
141
|
+
* The verdict, stashed for the config route (05 §7.2's origin-aware
|
|
142
|
+
* body, D69). Computed HERE and not re-derived in the route because
|
|
143
|
+
* "is this origin allowed" answered in two places is how the two
|
|
144
|
+
* answers drift — and this one gates what the response discloses.
|
|
145
|
+
*/
|
|
146
|
+
c.set("originAllowed", ok);
|
|
147
|
+
if (ok || isPublicRead) {
|
|
148
|
+
c.header("Access-Control-Allow-Origin", origin);
|
|
149
|
+
c.header("Vary", "Origin");
|
|
150
|
+
// §5.1: "Credentials: never" on both classes. The bearer travels in
|
|
151
|
+
// an Authorization header, not a cookie, which is also why no widget
|
|
152
|
+
// route needs CSRF machinery.
|
|
153
|
+
// EVERY header the widget sends, not just the obvious two. A
|
|
154
|
+
// preflight that omits one makes the browser block the real
|
|
155
|
+
// request while the OPTIONS still answers 204 — the failure looks
|
|
156
|
+
// like a network fault, and it is exactly how the screenshot
|
|
157
|
+
// upload failed after the session exchange had already taught the
|
|
158
|
+
// same lesson one layer up. Generated from the registry so a new
|
|
159
|
+
// header cannot be added to a request without appearing here.
|
|
160
|
+
c.header("Access-Control-Allow-Headers", ALLOWED_REQUEST_HEADERS);
|
|
161
|
+
c.header("Access-Control-Allow-Methods", "GET,POST,PATCH,OPTIONS");
|
|
162
|
+
c.header("Access-Control-Max-Age", "600");
|
|
163
|
+
}
|
|
164
|
+
// 08 §3.5 D2 — inert data plus a human decision, never a transition.
|
|
165
|
+
if (!ok)
|
|
166
|
+
void recordOrigin(sql, project.org_id, project.id, origin.toLowerCase(), !isPublicRead);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (c.req.method === "OPTIONS")
|
|
170
|
+
return c.body(null, 204);
|
|
171
|
+
return next();
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
//# sourceMappingURL=cors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cors.js","sourceRoot":"","sources":["../../src/routes/cors.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { runDueJobs, summarise } from "../jobs/index.js";
|
|
3
|
+
import { l1Jobs } from "../jobs/reaper.js";
|
|
4
|
+
import { selectMailer } from "../mail/index.js";
|
|
5
|
+
import { deliverEmail } from "../mail/notify.js";
|
|
6
|
+
import { getBucket } from "../storage/settings.js";
|
|
7
|
+
import { createKeyring } from "../keys.js";
|
|
8
|
+
import { resolveSecretKey } from "../boot.js";
|
|
9
|
+
export function jobsRoutes(deps) {
|
|
10
|
+
const app = new Hono();
|
|
11
|
+
const { sql, cfg } = deps;
|
|
12
|
+
app.get("/api/jobs/run", async (c) => {
|
|
13
|
+
const secret = process.env["CRON_SECRET"];
|
|
14
|
+
if (!secret) {
|
|
15
|
+
return c.json({
|
|
16
|
+
error: "cron_not_configured",
|
|
17
|
+
message: "CRON_SECRET is not set, so this endpoint refuses every request. Set it in the " +
|
|
18
|
+
"deployment's environment; the platform sends it as a bearer token on scheduled " +
|
|
19
|
+
"invocations. An open jobs endpoint is a way to make this instance send mail on demand.",
|
|
20
|
+
}, 503);
|
|
21
|
+
}
|
|
22
|
+
// Both forms: platforms differ on whether they prefix, and accepting both
|
|
23
|
+
// costs nothing while saving an operator an hour on a 401 with no body.
|
|
24
|
+
const offered = (c.req.header("authorization") ?? "").replace(/^Bearer\s+/i, "");
|
|
25
|
+
if (!timingSafeEqualStr(offered, secret)) {
|
|
26
|
+
return c.json({ error: "unauthorized", message: "Bad or missing cron token." }, 401);
|
|
27
|
+
}
|
|
28
|
+
const keys = createKeyring(resolveSecretKey(cfg, null, () => { }), cfg.get("SNAGSET_SECRET_KEY_PREVIOUS"));
|
|
29
|
+
const bucket = await getBucket(sql, keys).catch(() => null);
|
|
30
|
+
const mailer = selectMailer(cfg);
|
|
31
|
+
const jobs = [...l1Jobs(bucket), deliverEmail(mailer, cfg.get("SNAGSET_BASE_URL") ?? "", keys)];
|
|
32
|
+
// Well inside a platform's function ceiling. A tick that runs out of budget
|
|
33
|
+
// leaves the rest for the next minute, which is what `runDueJobs` is built
|
|
34
|
+
// for; the alternative is a function killed mid-transaction.
|
|
35
|
+
const summary = await runDueJobs(sql, jobs, { budgetMs: 25_000, log: (m) => console.log(m) });
|
|
36
|
+
if (summary.ran.some((r) => r.did > 0) || summary.failed.length)
|
|
37
|
+
console.log(summarise(summary));
|
|
38
|
+
return c.json({
|
|
39
|
+
ms: summary.ms,
|
|
40
|
+
ran: summary.ran,
|
|
41
|
+
skipped: summary.skipped,
|
|
42
|
+
failed: summary.failed,
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
return app;
|
|
46
|
+
}
|
|
47
|
+
/** Constant-time compare, so the token cannot be recovered by timing. */
|
|
48
|
+
function timingSafeEqualStr(a, b) {
|
|
49
|
+
if (a.length !== b.length)
|
|
50
|
+
return false;
|
|
51
|
+
let diff = 0;
|
|
52
|
+
for (let i = 0; i < a.length; i++)
|
|
53
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
54
|
+
return diff === 0;
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=jobs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"jobs.js","sourceRoot":"","sources":["../../src/routes/jobs.ts"],"names":[],"mappings":"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"}
|