@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
package/dist/projects.js
ADDED
|
@@ -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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"}
|