@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,101 @@
1
+ import { randomBytes, randomUUID } from "node:crypto";
2
+ import { ID_PREFIX } from "./vendor/types/names.js";
3
+ /**
4
+ * `prj_` + 12 Crockford base32 characters — foundations §2.10, and §3.3's
5
+ * `CHECK (uid ~ '^prj_[0-9abcdefghjkmnpqrstvwxyz]{12}$')` is the enforcement.
6
+ * Crockford excludes I, L, O and U so a uid read aloud or copied off a screen
7
+ * cannot be misheard; the lowercase form is what the CHECK wants.
8
+ *
9
+ * Rejection sampling, not `% 32`: a byte modulo 32 makes the first 8 symbols
10
+ * of the alphabet fractionally likelier than the rest, which is 60 bits of
11
+ * entropy quietly becoming fewer for no reason at all.
12
+ */
13
+ const CROCKFORD = "0123456789abcdefghjkmnpqrstvwxyz";
14
+ export function wireId(prefix, length = 12) {
15
+ let out = "";
16
+ while (out.length < length) {
17
+ for (const byte of randomBytes(length)) {
18
+ if (byte >= 256 - (256 % 32))
19
+ continue;
20
+ out += CROCKFORD[byte % 32];
21
+ if (out.length === length)
22
+ break;
23
+ }
24
+ }
25
+ return prefix + out;
26
+ }
27
+ /**
28
+ * Validate an origin list, throwing a sentence rather than returning a filtered
29
+ * one.
30
+ *
31
+ * A silently-dropped origin is the worst outcome available here: the site is
32
+ * added, the tag is pasted, and every request from it is refused by CORS with
33
+ * no indication that the origin the operator typed is not the origin stored.
34
+ * Mirrors 0001's own CHECK so the failure arrives with a fix rather than as a
35
+ * constraint violation.
36
+ */
37
+ export function validateOrigins(origins) {
38
+ const out = [];
39
+ for (const raw of origins) {
40
+ const origin = raw.trim();
41
+ if (!origin)
42
+ continue;
43
+ let parsed;
44
+ try {
45
+ parsed = new URL(origin);
46
+ }
47
+ catch {
48
+ throw new Error(`${JSON.stringify(origin)} is not an origin. It needs a scheme: https://staging.acme.com, ` +
49
+ `not staging.acme.com.`);
50
+ }
51
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
52
+ throw new Error(`${JSON.stringify(origin)} must be http:// or https://.`);
53
+ }
54
+ if (parsed.pathname !== "/" || parsed.search) {
55
+ throw new Error(`${JSON.stringify(origin)} has a path. An origin is scheme + host + port and nothing else.`);
56
+ }
57
+ // Rebuilt from the parse rather than passed through, so a trailing slash,
58
+ // an upper-case host or a default port cannot produce two rows that are
59
+ // the same origin spelled differently — `projects_origins` is UNIQUE on
60
+ // (project_id, pattern) and would happily hold both.
61
+ out.push(parsed.origin.toLowerCase());
62
+ }
63
+ return out;
64
+ }
65
+ export async function createProject(tx, input) {
66
+ const id = randomUUID();
67
+ const uid = wireId(ID_PREFIX.project);
68
+ const name = input.name.trim() || "Untitled site";
69
+ const origins = validateOrigins(input.origins ?? []);
70
+ await tx `
71
+ INSERT INTO projects (id, org_id, uid, name, ip_salt, config_etag, write_mode_downgrade_at)
72
+ VALUES (${id}, ${input.orgId}, ${uid}, ${name},
73
+ ${randomBytes(32)}, ${randomBytes(8).toString("hex")},
74
+ now() + ${input.graceHours}::int * interval '1 hour')
75
+ `;
76
+ // 05 §6.10 step 2. `threads.environment_id` is NOT NULL.
77
+ for (const [key, label, isDefault] of [
78
+ ["production", "Production", true],
79
+ ["preview", "Preview", false],
80
+ ]) {
81
+ await tx `
82
+ INSERT INTO environments (id, org_id, project_id, key, label, is_default)
83
+ VALUES (${randomUUID()}, ${input.orgId}, ${id}, ${key}, ${label}, ${isDefault})
84
+ `;
85
+ }
86
+ // Round 1. D2 makes every thread belong to exactly one round and
87
+ // `threads.round_id` is NOT NULL — this line is the difference between a
88
+ // project that works and one that appears to.
89
+ await tx `
90
+ INSERT INTO rounds (id, org_id, project_id, number, name, created_by)
91
+ VALUES (${randomUUID()}, ${input.orgId}, ${id}, 1, 'Round 1', ${input.createdBy})
92
+ `;
93
+ for (const origin of origins) {
94
+ await tx `
95
+ INSERT INTO project_origins (id, org_id, project_id, pattern, is_wildcard)
96
+ VALUES (${randomUUID()}, ${input.orgId}, ${id}, ${origin}, ${origin.includes("*.")})
97
+ `;
98
+ }
99
+ return { id, uid, name };
100
+ }
101
+ //# sourceMappingURL=projects.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"projects.js","sourceRoot":"","sources":["../src/projects.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,159 @@
1
+ import { Hono } from "hono";
2
+ import { randomUUID, createHash } from "node:crypto";
3
+ import { HTTP_HEADER, ID_PREFIX } from "../vendor/types/names.js";
4
+ import { getBucket, storageStatus } from "../storage/settings.js";
5
+ import { storageKeyFor } from "../storage/bucket.js";
6
+ function fail(c, status, code, message) {
7
+ return c.json({ error: { code, message, requestId: c.get("requestId") } }, status);
8
+ }
9
+ const EXT = {
10
+ "image/webp": "webp",
11
+ "image/jpeg": "jpg",
12
+ "image/png": "png",
13
+ };
14
+ export function attachmentRoutes(deps) {
15
+ const app = new Hono();
16
+ const { sql, keys, projects } = deps;
17
+ /**
18
+ * Upload one image. `kind` is `screenshot` or `image` — the automatic
19
+ * capture and a reviewer's own picture, which differ only in provenance.
20
+ */
21
+ app.post("/api/v1/projects/:uid/attachments", async (c) => {
22
+ const project = await projects.get(c.req.param("uid"));
23
+ if (!project)
24
+ return fail(c, 404, "project_not_found", "No project with that uid.");
25
+ const session = c.get("session");
26
+ if (!session)
27
+ return fail(c, 401, "unauthorized", "Open your review link, or reload the page, and try again.");
28
+ const bucket = await getBucket(sql, keys);
29
+ if (!bucket) {
30
+ // Annotation mode. 409 rather than 500: the instance is working exactly
31
+ // as configured, and the fix is an operator action, not a retry.
32
+ return fail(c, 409, "storage_not_configured", "This instance has no bucket connected, so it does not store pictures. Comments still work.");
33
+ }
34
+ const kind = c.req.header(HTTP_HEADER.attachmentKind) ?? "screenshot";
35
+ if (kind !== "screenshot" && kind !== "image") {
36
+ return fail(c, 422, "validation_failed", `Unsupported attachment kind "${kind}" on this route.`);
37
+ }
38
+ const contentType = (c.req.header("content-type") ?? "").split(";")[0].trim();
39
+ const ext = EXT[contentType];
40
+ if (!ext) {
41
+ return fail(c, 415, "unsupported_media_type", `Expected an image; got "${contentType || "nothing"}".`);
42
+ }
43
+ const body = new Uint8Array(await c.req.arrayBuffer());
44
+ if (!body.length)
45
+ return fail(c, 400, "validation_failed", "The body was empty.");
46
+ if (body.length > deps.maxImageBytes) {
47
+ // The widget encodes to A9b′'s 256 KB budget; this is the check that
48
+ // makes the budget true rather than merely intended.
49
+ return fail(c, 413, "body_too_large", `That image is ${Math.round(body.length / 1024)} KB; the limit is ${Math.round(deps.maxImageBytes / 1024)} KB.`);
50
+ }
51
+ // LOCK L4's lifetime quota, counted in bucket bytes. Checked BEFORE the
52
+ // write, because a quota enforced after the fact is a bill.
53
+ if (project.id) {
54
+ const [p] = await sql `
55
+ SELECT storage_bytes_used AS used, storage_bytes_limit AS limit FROM projects WHERE id = ${project.id}
56
+ `;
57
+ const limit = Number(p?.limit ?? 0);
58
+ if (limit > 0 && Number(p.used) + body.length > limit) {
59
+ return fail(c, 413, "storage_quota_exceeded", "This project has used its picture storage. Existing comments are unaffected.");
60
+ }
61
+ }
62
+ // The same digest the storage key is derived from — stored so integrity can
63
+ // be checked without re-reading the object, and so a duplicate upload is
64
+ // recognisable by content rather than by key alone.
65
+ const digest = createHash("sha256").update(body).digest();
66
+ const key = storageKeyFor(project.id, body, ext);
67
+ const width = Number(c.req.header(HTTP_HEADER.attachmentWidth)) || null;
68
+ const height = Number(c.req.header(HTTP_HEADER.attachmentHeight)) || null;
69
+ try {
70
+ await bucket.put(key, body, contentType);
71
+ }
72
+ catch (e) {
73
+ // The bucket is the operator's, so its failure is theirs to see —
74
+ // and the reviewer's comment must survive it (the widget falls back
75
+ // to sending the thread without a picture).
76
+ console.error(`attachment upload failed [${c.get("requestId")}]`, e);
77
+ return fail(c, 502, "storage_unavailable", "The picture could not be stored. Your comment can still be sent.");
78
+ }
79
+ const id = randomUUID();
80
+ await sql.begin(async (tx) => {
81
+ await tx `SELECT set_config('app.org_id', ${project.org_id}, true)`;
82
+ await tx `
83
+ INSERT INTO attachments (id, org_id, project_id, kind, state, storage_key, storage_backend,
84
+ content_type, bytes, width, height, sha256, uploaded_by)
85
+ -- 'pending', and the schema is what taught me that. §3.6's CHECKs say
86
+ -- state='bound' requires bound_at AND exactly one of thread_id or
87
+ -- comment_id -- and at upload time the thread does not exist yet,
88
+ -- because the widget uploads the picture and THEN creates the thread
89
+ -- so the attachment ref can ride the same write. So a row is born
90
+ -- pending and is bound by the thread create (05 §6.6: v1's wire binds
91
+ -- attachments relationally after the fact).
92
+ VALUES (${id}, ${project.org_id}, ${project.id}, ${kind}::snag_attachment_kind, 'pending',
93
+ ${key}, 's3', ${contentType}, ${body.length}, ${width}, ${height},
94
+ ${digest}, ${session.sub})
95
+ ON CONFLICT (storage_key) DO NOTHING
96
+ `;
97
+ // Two reviewers screenshotting the identical viewport produce the same
98
+ // hash and therefore the same key — one object, and the byte count is
99
+ // only charged once.
100
+ await tx `
101
+ UPDATE projects SET storage_bytes_used = storage_bytes_used + ${body.length}
102
+ WHERE id = ${project.id}
103
+ AND EXISTS (SELECT 1 FROM attachments WHERE id = ${id})
104
+ `;
105
+ });
106
+ const [row] = await sql `
107
+ SELECT id FROM attachments WHERE storage_key = ${key}
108
+ `;
109
+ return c.json({
110
+ id: ID_PREFIX.attachment + (row?.id ?? id),
111
+ kind,
112
+ contentType,
113
+ byteSize: body.length,
114
+ ...(width ? { width } : {}),
115
+ ...(height ? { height } : {}),
116
+ }, 201);
117
+ });
118
+ /**
119
+ * Serve the bytes back, proxied — M2-23(b): *"images proxied through the
120
+ * blob route, cacheable"*.
121
+ *
122
+ * Proxied rather than redirected to a presigned URL because an image in a
123
+ * comment thread is fetched by an `<img>` on our own origin, and a redirect
124
+ * to a short-lived URL breaks the moment the page outlives the signature.
125
+ */
126
+ app.get("/api/v1/attachments/:id/blob", async (c) => {
127
+ const wire = c.req.param("id");
128
+ const id = wire.startsWith(ID_PREFIX.attachment) ? wire.slice(ID_PREFIX.attachment.length) : wire;
129
+ const [row] = await sql `
130
+ SELECT storage_key, content_type, project_id FROM attachments
131
+ WHERE id = ${id} AND deleted_at IS NULL
132
+ `;
133
+ if (!row)
134
+ return fail(c, 404, "not_found", "No such attachment.");
135
+ const bucket = await getBucket(sql, keys);
136
+ if (!bucket)
137
+ return fail(c, 409, "storage_not_configured", "This instance has no bucket connected.");
138
+ const bytes = await bucket.get(row.storage_key).catch(() => null);
139
+ if (!bytes)
140
+ return fail(c, 404, "not_found", "That picture is not in the bucket.");
141
+ // Immutable: the key is a content hash, so the bytes at it cannot change.
142
+ c.header("Cache-Control", "public, max-age=31536000, immutable");
143
+ c.header("Content-Type", row.content_type);
144
+ // Honest note: this route has NO auth. The control is that attachment ids
145
+ // are unguessable UUIDs (a capability URL) plus ACAO: * so an <img> on any
146
+ // origin can render it. A signed/expiring form is attachment_shares
147
+ // (M2-23c) and is not built yet.
148
+ c.header("Cross-Origin-Resource-Policy", "cross-origin");
149
+ c.header("Access-Control-Allow-Origin", "*");
150
+ return c.body(bytes);
151
+ });
152
+ return app;
153
+ }
154
+ /** A9b: derived server-side from bucket presence, never probed by the widget. */
155
+ export async function features(sql, keys) {
156
+ const st = await storageStatus(sql, keys);
157
+ return { screenshots: st.connected, uploads: st.connected };
158
+ }
159
+ //# sourceMappingURL=attachments.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attachments.js","sourceRoot":"","sources":["../../src/routes/attachments.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,55 @@
1
+ import { verifySession } from "../session.js";
2
+ /**
3
+ * Bearer auth for the widget's project routes — 05 §4.4.
4
+ *
5
+ * Verify, then compare `epo` against the 10-second project cache. That
6
+ * comparison is the entire revocation mechanism and it costs no query: the
7
+ * cache entry was already read to resolve the project.
8
+ *
9
+ * OPTIONAL BY DESIGN. This attaches a session when one is presented and
10
+ * verifies; it does not refuse when none is. Refusing here would make every
11
+ * READ require a credential, and 05 §4.2's `open` write mode exists precisely
12
+ * so a fresh project works before anyone has been sent a link. The ROUTES
13
+ * decide what they require — thread creation demands a session, listing does
14
+ * not — which keeps "who are you" and "what may you do" as two questions with
15
+ * two answers rather than one conflated gate.
16
+ *
17
+ * A presented-but-INVALID bearer is different from none at all, and is
18
+ * reported: a reviewer whose link was revoked mid-session needs F-19, not a
19
+ * silent downgrade to read-only that looks like the widget quietly breaking.
20
+ */
21
+ export function bearerAuth(keys, projects, absoluteTtlS) {
22
+ return async (c, next) => {
23
+ const token = /^Bearer (.+)$/i.exec(c.req.header("Authorization") ?? "")?.[1];
24
+ const uid = c.req.param("uid");
25
+ if (!token || !uid)
26
+ return next();
27
+ const project = await projects.get(uid).catch(() => null);
28
+ if (!project)
29
+ return next();
30
+ const v = await verifySession(keys, token, {
31
+ projectUid: uid,
32
+ expectedEpoch: project.session_epoch,
33
+ absoluteTtlS,
34
+ });
35
+ if (v.ok) {
36
+ c.set("session", v.claims);
37
+ return next();
38
+ }
39
+ // Revocation is the one failure that must not be silent. `expired` is
40
+ // handled by the client refreshing, and a malformed or foreign token is
41
+ // treated as no credential — but "you were signed out" has to reach the
42
+ // reviewer, or a revoked link looks like a broken widget.
43
+ if (v.reason === "session_revoked") {
44
+ return c.json({
45
+ error: {
46
+ code: "session_revoked",
47
+ message: "This review link was turned off. Ask whoever sent it for a new one.",
48
+ requestId: c.get("requestId"),
49
+ },
50
+ }, 401);
51
+ }
52
+ return next();
53
+ };
54
+ }
55
+ //# sourceMappingURL=auth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.js","sourceRoot":"","sources":["../../src/routes/auth.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,aAAa,EAAsB,MAAM,eAAe,CAAC;AAElE;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,UAAU,UAAU,CAAC,IAAa,EAAE,QAAsB,EAAE,YAAoB;IACpF,OAAO,KAAK,EAAE,CAAC,EAAE,IAAI,EAAE,EAAE;QACvB,MAAM,KAAK,GAAG,gBAAgB,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,eAAe,CAAC,IAAI,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;QAC9E,MAAM,GAAG,GAAG,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;QAC/B,IAAI,CAAC,KAAK,IAAI,CAAC,GAAG;YAAE,OAAO,IAAI,EAAE,CAAC;QAElC,MAAM,OAAO,GAAG,MAAM,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAC;QAC1D,IAAI,CAAC,OAAO;YAAE,OAAO,IAAI,EAAE,CAAC;QAE5B,MAAM,CAAC,GAAG,MAAM,aAAa,CAAC,IAAI,EAAE,KAAK,EAAE;YACzC,UAAU,EAAE,GAAG;YACf,aAAa,EAAE,OAAO,CAAC,aAAa;YACpC,YAAY;SACb,CAAC,CAAC;QAEH,IAAI,CAAC,CAAC,EAAE,EAAE,CAAC;YACT,CAAC,CAAC,GAAG,CAAC,SAAS,EAAE,CAAC,CAAC,MAAuB,CAAC,CAAC;YAC5C,OAAO,IAAI,EAAE,CAAC;QAChB,CAAC;QAED,sEAAsE;QACtE,wEAAwE;QACxE,wEAAwE;QACxE,0DAA0D;QAC1D,IAAI,CAAC,CAAC,MAAM,KAAK,iBAAiB,EAAE,CAAC;YACnC,OAAO,CAAC,CAAC,IAAI,CACX;gBACE,KAAK,EAAE;oBACL,IAAI,EAAE,iBAAiB;oBACvB,OAAO,EAAE,qEAAqE;oBAC9E,SAAS,EAAE,CAAC,CAAC,GAAG,CAAC,WAAW,CAAC;iBAC9B;aACF,EACD,GAAG,CACJ,CAAC;QACJ,CAAC;QACD,OAAO,IAAI,EAAE,CAAC;IAChB,CAAC,CAAC;AACJ,CAAC"}
@@ -0,0 +1,201 @@
1
+ import { Hono } from "hono";
2
+ import { ID_PREFIX } from "../vendor/types/names.js";
3
+ import { csrfOk } from "./owner.js";
4
+ /** §4.5's author window. */
5
+ export const EDIT_WINDOW_MS = 15 * 60 * 1000;
6
+ function fail(c, status, code, message, details) {
7
+ return c.json({ error: { code, message, ...(details ? { details } : {}), requestId: c.get("requestId") } }, status);
8
+ }
9
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
10
+ function rowId(wire, prefix) {
11
+ if (typeof wire !== "string")
12
+ return null;
13
+ const bare = wire.startsWith(prefix) ? wire.slice(prefix.length) : wire;
14
+ return UUID.test(bare) ? bare.toLowerCase() : null;
15
+ }
16
+ export function commentEditRoutes(deps) {
17
+ const app = new Hono();
18
+ const { sql } = deps;
19
+ const now = deps.now ?? (() => new Date());
20
+ /** The comment, its author and its thread's privacy, in one round trip. */
21
+ async function load(wire) {
22
+ const id = rowId(wire, ID_PREFIX.comment);
23
+ if (!id)
24
+ return null;
25
+ const [row] = await sql `
26
+ SELECT c.id, c.org_id, c.project_id, c.thread_id, c.author_id, c.body, c.private,
27
+ c.created_at, c.deleted_at,
28
+ t.private AS thread_private,
29
+ r.name AS author_name, r.kind AS author_kind
30
+ FROM comments c
31
+ JOIN threads t ON t.id = c.thread_id
32
+ JOIN reviewers r ON r.id = c.author_id
33
+ WHERE c.id = ${id}::uuid
34
+ `;
35
+ return row ?? null;
36
+ }
37
+ /**
38
+ * Can this caller act on this comment, and how?
39
+ *
40
+ * Returns the reason it cannot, or null. Deliberately one function for both
41
+ * routes: "who owns a comment" answered in two places is how the two answers
42
+ * drift, and the drift is always in the permissive direction.
43
+ */
44
+ function refuse(c, row, action) {
45
+ const owner = c.get("owner");
46
+ const session = c.get("session");
47
+ const asOwner = !!owner && owner.orgId === row.org_id;
48
+ const isAuthor = !!session && session.sub === row.author_id;
49
+ if (!asOwner && !session) {
50
+ return fail(c, 401, "unauthorized", "Sign in, or open your review link, and try again.");
51
+ }
52
+ // The owner cookie's half of `requireOwner()`, run inline — this path is
53
+ // also reached by a reviewer session, so the middleware cannot be mounted.
54
+ if (asOwner && !csrfOk(c)) {
55
+ return fail(c, 403, "csrf_failed", "That request could not be verified. Reload the dashboard and try again.");
56
+ }
57
+ if (asOwner) {
58
+ /**
59
+ * An owner may DELETE anything and EDIT only their own.
60
+ *
61
+ * Editing somebody else's words while their name stays on them is not a
62
+ * moderation power, it is a forgery — and the honest tool for a comment
63
+ * the studio does not want is the one next to it.
64
+ */
65
+ if (action === "delete" || isAuthor)
66
+ return null;
67
+ return fail(c, 403, "forbidden", "You can delete this comment, but not rewrite it — it would still carry its author's name.");
68
+ }
69
+ if (!isAuthor) {
70
+ // Not "you may not": a reviewer cannot be told which comments exist and
71
+ // belong to somebody else.
72
+ return fail(c, 403, "forbidden", "You can only change your own comments.");
73
+ }
74
+ // §4.5's window. A comment somebody has already read and acted on must not
75
+ // silently become a different comment.
76
+ const age = now().getTime() - row.created_at.getTime();
77
+ if (age > EDIT_WINDOW_MS) {
78
+ return fail(c, 403, "edit_window_expired", `Comments can be changed for ${EDIT_WINDOW_MS / 60000} minutes after they are written. ` +
79
+ `Add a reply instead — it keeps the conversation readable.`);
80
+ }
81
+ return null;
82
+ }
83
+ /** §3.11's wire shape, for a live comment or a tombstone. */
84
+ const shape = (row) => row.deleted_at
85
+ ? {
86
+ id: ID_PREFIX.comment + row.id,
87
+ deletedAt: row.deleted_at.toISOString(),
88
+ body: null,
89
+ // The NAME survives, so the conversation still reads. Nothing else
90
+ // does — and `seq` is not here, because with private replies
91
+ // interleaved it would let a client count what it cannot read.
92
+ author: { name: row.author_name },
93
+ }
94
+ : {
95
+ id: ID_PREFIX.comment + row.id,
96
+ body: row.body,
97
+ ...(row.created_at ? { createdAt: row.created_at.toISOString() } : {}),
98
+ author: {
99
+ id: ID_PREFIX.reviewer + row.author_id,
100
+ name: row.author_name,
101
+ kind: row.author_kind,
102
+ },
103
+ };
104
+ /** Endpoint 12 — `PATCH /api/v1/comments/{id}`. */
105
+ const patch = async (c) => {
106
+ const row = await load(c.req.param("id"));
107
+ if (!row || row.deleted_at)
108
+ return fail(c, 404, "comment_not_found", "No such comment.");
109
+ const refused = refuse(c, row, "edit");
110
+ if (refused)
111
+ return refused;
112
+ const body = (await c.req.json().catch(() => null));
113
+ const next = typeof body?.["body"] === "string" ? body["body"] : "";
114
+ if (!next.trim())
115
+ return fail(c, 400, "validation_failed", "body is required.");
116
+ if (next.length > 10_000) {
117
+ return fail(c, 400, "body_too_large", "A comment may be at most 10,000 characters.");
118
+ }
119
+ // `private` is not editable here. Changing a reply's audience after people
120
+ // have read it is a different decision from fixing its words, and folding
121
+ // them into one route means a typo correction can silently change who can
122
+ // see it.
123
+ if (body && "private" in body) {
124
+ return fail(c, 422, "field_not_permitted", "A comment's audience is not changed by editing it.", {
125
+ field: "private",
126
+ });
127
+ }
128
+ const [updated] = await sql.begin(async (tx) => {
129
+ await tx `SELECT set_config('app.org_id', ${row.org_id}, true)`;
130
+ // `version` and `updated_at` on the THREAD, so a `?since=` poll carries
131
+ // the edit. Without this the corrected text would reach nobody until
132
+ // something else touched the thread.
133
+ await tx `
134
+ UPDATE threads SET version = version + 1, updated_at = now() WHERE id = ${row.thread_id}::uuid
135
+ `;
136
+ return tx `
137
+ UPDATE comments SET body = ${next}, edited_at = now()
138
+ WHERE id = ${row.id}::uuid
139
+ RETURNING id, body, created_at, deleted_at,
140
+ ${row.author_id}::uuid AS author_id,
141
+ ${row.author_name}::text AS author_name,
142
+ ${row.author_kind}::text AS author_kind
143
+ `;
144
+ });
145
+ return c.json({ ...shape(updated), editedAt: new Date().toISOString() });
146
+ };
147
+ /** Endpoint 13 — `DELETE /api/v1/comments/{id}`. */
148
+ const remove = async (c) => {
149
+ const row = await load(c.req.param("id"));
150
+ if (!row)
151
+ return fail(c, 404, "comment_not_found", "No such comment.");
152
+ // Deleting twice is the same outcome as deleting once, and a reviewer who
153
+ // taps twice on a slow connection should not see an error for getting what
154
+ // they asked for.
155
+ if (row.deleted_at)
156
+ return c.json(shape(row));
157
+ const refused = refuse(c, row, "delete");
158
+ if (refused)
159
+ return refused;
160
+ const [tombstone] = await sql.begin(async (tx) => {
161
+ await tx `SELECT set_config('app.org_id', ${row.org_id}, true)`;
162
+ /**
163
+ * The counters, in the same statement as the tombstone.
164
+ *
165
+ * `comment_count` is every reply and `comment_count_public` is the ones a
166
+ * client may read — so a private comment being deleted must decrement the
167
+ * first and not the second. Getting this wrong is invisible until a
168
+ * thread shows "3 replies" above two of them.
169
+ *
170
+ * `GREATEST(…, 0)` because a counter that has already drifted must not go
171
+ * negative and start rendering as "-1 replies" forever.
172
+ */
173
+ await tx `
174
+ UPDATE threads
175
+ SET comment_count = GREATEST(comment_count - 1, 0),
176
+ comment_count_public = GREATEST(comment_count_public - ${row.private ? 0 : 1}, 0),
177
+ version = version + 1,
178
+ updated_at = now()
179
+ WHERE id = ${row.thread_id}::uuid
180
+ `;
181
+ return tx `
182
+ UPDATE comments SET body = NULL, deleted_at = now()
183
+ WHERE id = ${row.id}::uuid
184
+ RETURNING id, body, created_at, deleted_at,
185
+ ${row.author_id}::uuid AS author_id,
186
+ ${row.author_name}::text AS author_name,
187
+ ${row.author_kind}::text AS author_kind
188
+ `;
189
+ });
190
+ return c.json(shape(tombstone));
191
+ };
192
+ // 05's canonical unscoped form, plus the project-scoped one the widget's URL
193
+ // builder produces — the same pairing `comments.ts` documents, and for the
194
+ // same reason: the widget builds every URL as root + path.
195
+ app.patch("/api/v1/comments/:id", patch);
196
+ app.patch("/api/v1/projects/:uid/comments/:id", patch);
197
+ app.delete("/api/v1/comments/:id", remove);
198
+ app.delete("/api/v1/projects/:uid/comments/:id", remove);
199
+ return app;
200
+ }
201
+ //# sourceMappingURL=commentEdit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"commentEdit.js","sourceRoot":"","sources":["../../src/routes/commentEdit.ts"],"names":[],"mappings":"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"}