@snagset/server 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +83 -0
  2. package/dist/app.js +453 -0
  3. package/dist/app.js.map +1 -0
  4. package/dist/bin.js +951 -0
  5. package/dist/bin.js.map +1 -0
  6. package/dist/boot.js +321 -0
  7. package/dist/boot.js.map +1 -0
  8. package/dist/config.generated.js +116 -0
  9. package/dist/config.generated.js.map +1 -0
  10. package/dist/config.js +180 -0
  11. package/dist/config.js.map +1 -0
  12. package/dist/crypto.js +26 -0
  13. package/dist/crypto.js.map +1 -0
  14. package/dist/db/connect.js +49 -0
  15. package/dist/db/connect.js.map +1 -0
  16. package/dist/db/migrate.js +210 -0
  17. package/dist/db/migrate.js.map +1 -0
  18. package/dist/db/migrations/0001_init.sql +1060 -0
  19. package/dist/db/migrations/0002_maintenance_claim.sql +109 -0
  20. package/dist/db/migrations/0003_email_provider.sql +35 -0
  21. package/dist/db/migrations/0004_unsubscribe.sql +24 -0
  22. package/dist/db/rls.js +58 -0
  23. package/dist/db/rls.js.map +1 -0
  24. package/dist/jobs/index.js +171 -0
  25. package/dist/jobs/index.js.map +1 -0
  26. package/dist/jobs/reaper.js +182 -0
  27. package/dist/jobs/reaper.js.map +1 -0
  28. package/dist/keys.js +77 -0
  29. package/dist/keys.js.map +1 -0
  30. package/dist/mail/index.js +75 -0
  31. package/dist/mail/index.js.map +1 -0
  32. package/dist/mail/mailer.js +27 -0
  33. package/dist/mail/mailer.js.map +1 -0
  34. package/dist/mail/notify.js +405 -0
  35. package/dist/mail/notify.js.map +1 -0
  36. package/dist/mail/resend.js +95 -0
  37. package/dist/mail/resend.js.map +1 -0
  38. package/dist/mail/smtp.js +315 -0
  39. package/dist/mail/smtp.js.map +1 -0
  40. package/dist/mail/unsubscribe.js +134 -0
  41. package/dist/mail/unsubscribe.js.map +1 -0
  42. package/dist/projectCache.js +50 -0
  43. package/dist/projectCache.js.map +1 -0
  44. package/dist/projects.js +101 -0
  45. package/dist/projects.js.map +1 -0
  46. package/dist/routes/attachments.js +159 -0
  47. package/dist/routes/attachments.js.map +1 -0
  48. package/dist/routes/auth.js +55 -0
  49. package/dist/routes/auth.js.map +1 -0
  50. package/dist/routes/commentEdit.js +201 -0
  51. package/dist/routes/commentEdit.js.map +1 -0
  52. package/dist/routes/comments.js +313 -0
  53. package/dist/routes/comments.js.map +1 -0
  54. package/dist/routes/cors.js +174 -0
  55. package/dist/routes/cors.js.map +1 -0
  56. package/dist/routes/jobs.js +56 -0
  57. package/dist/routes/jobs.js.map +1 -0
  58. package/dist/routes/mcpAuth.js +187 -0
  59. package/dist/routes/mcpAuth.js.map +1 -0
  60. package/dist/routes/mcpToken.js +94 -0
  61. package/dist/routes/mcpToken.js.map +1 -0
  62. package/dist/routes/owner.js +288 -0
  63. package/dist/routes/owner.js.map +1 -0
  64. package/dist/routes/reviewLinks.js +169 -0
  65. package/dist/routes/reviewLinks.js.map +1 -0
  66. package/dist/routes/rounds.js +137 -0
  67. package/dist/routes/rounds.js.map +1 -0
  68. package/dist/routes/sessions.js +349 -0
  69. package/dist/routes/sessions.js.map +1 -0
  70. package/dist/routes/sites.js +192 -0
  71. package/dist/routes/sites.js.map +1 -0
  72. package/dist/routes/threads.js +1080 -0
  73. package/dist/routes/threads.js.map +1 -0
  74. package/dist/routes/widget.js +132 -0
  75. package/dist/routes/widget.js.map +1 -0
  76. package/dist/serverless.js +97 -0
  77. package/dist/serverless.js.map +1 -0
  78. package/dist/session.js +152 -0
  79. package/dist/session.js.map +1 -0
  80. package/dist/storage/bucket.js +116 -0
  81. package/dist/storage/bucket.js.map +1 -0
  82. package/dist/storage/settings.js +132 -0
  83. package/dist/storage/settings.js.map +1 -0
  84. package/dist/vendor/adapters/index.js +1 -0
  85. package/dist/vendor/adapters/outbound/fence.js +44 -0
  86. package/dist/vendor/adapters/outbound/sanitise.js +233 -0
  87. package/dist/vendor/types/index.js +4 -0
  88. package/dist/vendor/types/names.js +254 -0
  89. package/dist/vendor/widget/object.js +38 -0
  90. package/dist/vendor/widget/predicates.js +377 -0
  91. package/package.json +53 -0
  92. package/static/dashboard/assets/index-CaPfKhFP.css +1 -0
  93. package/static/dashboard/assets/index-DfTNjHCK.js +1 -0
  94. package/static/dashboard/index.html +14 -0
  95. package/static/widget/snag.anchor-ext.js +1 -0
  96. package/static/widget/snag.anchor-fuzzy.js +1 -0
  97. package/static/widget/snag.capture.js +4 -0
  98. package/static/widget/snag.crumbs.js +4 -0
  99. package/static/widget/snag.dock.js +5 -0
  100. package/static/widget/snag.js +5 -0
  101. package/static/widget/snag.panels.js +2 -0
  102. package/static/widget/snag.runtime.js +2 -0
  103. package/static/widget/snag.screenshot.js +1 -0
  104. package/static/widget/snag.text.js +2 -0
  105. package/vercel.json +6 -0
@@ -0,0 +1,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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"}