@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,169 @@
1
+ import { Hono } from "hono";
2
+ import { randomUUID, randomBytes, createHash } from "node:crypto";
3
+ import { ID_PREFIX } from "../vendor/types/names.js";
4
+ function fail(c, status, code, message) {
5
+ return c.json({ error: { code, message, requestId: c.get("requestId") } }, status);
6
+ }
7
+ const sha256 = (s) => createHash("sha256").update(s, "utf8").digest();
8
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9
+ const rowId = (wire, prefix) => {
10
+ const bare = wire.startsWith(prefix) ? wire.slice(prefix.length) : wire;
11
+ return UUID.test(bare) ? bare.toLowerCase() : null;
12
+ };
13
+ /** §3.4's six. Anything else is refused rather than stored — an unknown
14
+ * permission in the column would be silently dropped at exchange, which reads
15
+ * to the operator as "I granted it and it did nothing". */
16
+ const KNOWN = [
17
+ "thread:create",
18
+ "comment:create",
19
+ "thread:resolve",
20
+ "thread:delete:own",
21
+ "thread:private",
22
+ "thread:assign",
23
+ ];
24
+ const DEFAULT_PERMISSIONS = ["thread:create", "comment:create", "thread:resolve"];
25
+ /** Never `token`, never `tokenHash`. */
26
+ const shape = (r) => ({
27
+ id: ID_PREFIX.link + r.id,
28
+ ...(r.round_id ? { roundId: ID_PREFIX.round + r.round_id } : {}),
29
+ name: r.name,
30
+ ...(r.email ? { email: r.email } : {}),
31
+ environments: r.environments,
32
+ permissions: r.permissions,
33
+ allowNameOverride: r.allow_name_override,
34
+ maxUses: r.max_uses,
35
+ useCount: r.use_count,
36
+ createdAt: r.created_at.toISOString(),
37
+ expiresAt: r.expires_at.toISOString(),
38
+ revokedAt: r.revoked_at?.toISOString() ?? null,
39
+ lastUsedAt: r.last_used_at?.toISOString() ?? null,
40
+ });
41
+ export function reviewLinkRoutes(deps) {
42
+ const app = new Hono();
43
+ const { sql, projects } = deps;
44
+ const now = deps.now ?? (() => new Date());
45
+ const DAY = 86_400_000;
46
+ /**
47
+ * Owner-only, and owner OF THIS PROJECT (same pattern as `rounds.ts`).
48
+ *
49
+ * `requireOwner()` only proves SOME org's owner is signed in — nothing
50
+ * here compared `owner.orgId` to the project's before this fix, so an
51
+ * owner of org A could mint, list, or revoke org B's links by uid. 404
52
+ * rather than 403 on a mismatch: a project uid existing at all is not
53
+ * information another org gets to learn.
54
+ */
55
+ const owned = async (c) => {
56
+ const uid = c.req.param("uid");
57
+ if (!uid)
58
+ return null;
59
+ const p = await projects.get(uid).catch(() => null);
60
+ if (!p)
61
+ return null;
62
+ const owner = c.get("owner");
63
+ return owner && owner.orgId === p.org_id ? p : null;
64
+ };
65
+ app.post("/api/v1/projects/:uid/review-links", async (c) => {
66
+ const project = await owned(c);
67
+ if (!project)
68
+ return fail(c, 404, "project_not_found", "No project with that uid.");
69
+ const body = (await c.req.json().catch(() => null));
70
+ const name = typeof body?.name === "string" ? body.name.trim().slice(0, 120) : "";
71
+ if (!name) {
72
+ return fail(c, 400, "validation_failed", "name is required — it labels the LINK (\"Client review — round 2\"), not a person.");
73
+ }
74
+ const permissions = Array.isArray(body?.permissions) ? body.permissions : DEFAULT_PERMISSIONS;
75
+ const unknown = permissions.filter((p) => !KNOWN.includes(p));
76
+ if (unknown.length) {
77
+ return fail(c, 422, "validation_failed", `Unknown permission(s): ${unknown.join(", ")}.`);
78
+ }
79
+ // Clamped ON WRITE, per §4.5.1's closing note: one default (14), one
80
+ // ceiling (90), and the clamp lives here rather than being trusted to a UI.
81
+ const ceiling = now().getTime() + deps.maxTtlDays * DAY;
82
+ const asked = typeof body?.expiresAt === "string" ? Date.parse(body.expiresAt) : NaN;
83
+ const expiresAt = new Date(Math.min(Number.isFinite(asked) ? asked : now().getTime() + deps.ttlDays * DAY, ceiling));
84
+ if (expiresAt <= now()) {
85
+ return fail(c, 400, "validation_failed", "expiresAt must be in the future.");
86
+ }
87
+ let roundId = null;
88
+ if (typeof body?.roundId === "string" && body.roundId) {
89
+ roundId = rowId(body.roundId, ID_PREFIX.round);
90
+ if (!roundId)
91
+ return fail(c, 400, "validation_failed", "roundId is not a valid id.");
92
+ const [round] = await sql `
93
+ SELECT id FROM rounds WHERE id = ${roundId} AND project_id = ${project.id} AND deleted_at IS NULL
94
+ `;
95
+ if (!round)
96
+ return fail(c, 404, "not_found", "That round is not in this project.");
97
+ }
98
+ // 32 bytes, unpadded base64url, 43 chars — foundations §7. Stored as
99
+ // sha256 only, which is why this response is the token's one appearance.
100
+ const token = randomBytes(32).toString("base64url");
101
+ const id = randomUUID();
102
+ const [row] = await sql `
103
+ INSERT INTO review_links
104
+ (id, org_id, project_id, round_id, token_hash, name, email, environments, permissions,
105
+ allow_name_override, max_uses, expires_at)
106
+ VALUES
107
+ (${id}, ${project.org_id}, ${project.id}, ${roundId}, ${sha256(token)}, ${name},
108
+ ${typeof body?.email === "string" ? body.email : null},
109
+ ${sql.array(Array.isArray(body?.environments) && body.environments.length ? body.environments : ["*"])},
110
+ ${sql.array(permissions)},
111
+ ${body?.allowNameOverride === false ? false : true},
112
+ ${Number.isInteger(body?.maxUses) && body?.maxUses > 0 ? body.maxUses : null},
113
+ ${expiresAt})
114
+ RETURNING id, round_id, name, email, environments, permissions, allow_name_override,
115
+ max_uses, use_count, created_at, expires_at, revoked_at, last_used_at
116
+ `;
117
+ c.header("Location", `/api/v1/review-links/${ID_PREFIX.link}${id}`);
118
+ return c.json({
119
+ ...shape(row),
120
+ // The one and only time. The dashboard builds `<page>#snag_k=<token>` —
121
+ // the server does not know which page of the reviewed site to point at.
122
+ token,
123
+ fragment: `#snag_k=${token}`,
124
+ }, 201);
125
+ });
126
+ app.get("/api/v1/projects/:uid/review-links", async (c) => {
127
+ const project = await owned(c);
128
+ if (!project)
129
+ return fail(c, 404, "project_not_found", "No project with that uid.");
130
+ const rows = await sql `
131
+ SELECT id, round_id, name, email, environments, permissions, allow_name_override,
132
+ max_uses, use_count, created_at, expires_at, revoked_at, last_used_at
133
+ FROM review_links WHERE project_id = ${project.id} ORDER BY created_at DESC
134
+ `;
135
+ return c.json({ data: rows.map(shape), nextCursor: null });
136
+ });
137
+ app.delete("/api/v1/review-links/:id", async (c) => {
138
+ const id = rowId(c.req.param("id"), ID_PREFIX.link);
139
+ if (!id)
140
+ return fail(c, 404, "not_found", "No such review link.");
141
+ // This route is not project-scoped in the URL (a link id is looked up
142
+ // directly), so the org check has to happen here rather than via
143
+ // `owned()` — org A's owner must not be able to revoke org B's link by
144
+ // guessing/enumerating a `lnk_` id. `review_links.org_id` is denormalised
145
+ // onto the row for exactly this filter.
146
+ const owner = c.get("owner");
147
+ const [link] = await sql `
148
+ SELECT rl.id, rl.project_id, p.uid, rl.revoked_at
149
+ FROM review_links rl JOIN projects p ON p.id = rl.project_id
150
+ WHERE rl.id = ${id} AND rl.org_id = ${owner.orgId}
151
+ `;
152
+ if (!link)
153
+ return fail(c, 404, "not_found", "No such review link.");
154
+ // BOTH, in one transaction. `revoked_at` stops further exchanges; the epoch
155
+ // bump kills the sessions already issued. Doing only the first leaves every
156
+ // reviewer who already redeemed the link working for up to 12 hours.
157
+ await sql.begin(async (tx) => {
158
+ await tx `UPDATE review_links SET revoked_at = now() WHERE id = ${id} AND revoked_at IS NULL`;
159
+ await tx `UPDATE projects SET session_epoch = session_epoch + 1 WHERE id = ${link.project_id}`;
160
+ });
161
+ // So the operator who clicked sees it on THIS node without waiting out the
162
+ // 10 s TTL. Other nodes catch up on their own expiry, which is the bound
163
+ // §4.4 states.
164
+ projects.invalidate(link.uid);
165
+ return c.body(null, 204);
166
+ });
167
+ return app;
168
+ }
169
+ //# sourceMappingURL=reviewLinks.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reviewLinks.js","sourceRoot":"","sources":["../../src/routes/reviewLinks.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAgB,MAAM,MAAM,CAAC;AAC1C,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAElE,OAAO,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAqCjD,SAAS,IAAI,CAAC,CAAM,EAAE,MAAc,EAAE,IAAY,EAAE,OAAe;IACjE,OAAO,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,SAAS,EAAE,CAAC,CAAC,GAAG,CAAC,WAAW,CAAC,EAAE,EAAE,EAAE,MAAa,CAAC,CAAC;AAC5F,CAAC;AAED,MAAM,MAAM,GAAG,CAAC,CAAS,EAAU,EAAE,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC,MAAM,EAAE,CAAC;AAEtF,MAAM,IAAI,GAAG,iEAAiE,CAAC;AAC/E,MAAM,KAAK,GAAG,CAAC,IAAY,EAAE,MAAc,EAAiB,EAAE;IAC5D,MAAM,IAAI,GAAG,IAAI,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IACxE,OAAO,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;AACrD,CAAC,CAAC;AAEF;;4DAE4D;AAC5D,MAAM,KAAK,GAAG;IACZ,eAAe;IACf,gBAAgB;IAChB,gBAAgB;IAChB,mBAAmB;IACnB,gBAAgB;IAChB,eAAe;CAChB,CAAC;AACF,MAAM,mBAAmB,GAAG,CAAC,eAAe,EAAE,gBAAgB,EAAE,gBAAgB,CAAC,CAAC;AAkBlF,wCAAwC;AACxC,MAAM,KAAK,GAAG,CAAC,CAAM,EAAE,EAAE,CAAC,CAAC;IACzB,EAAE,EAAE,SAAS,CAAC,IAAI,GAAG,CAAC,CAAC,EAAE;IACzB,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,SAAS,CAAC,KAAK,GAAG,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;IAChE,IAAI,EAAE,CAAC,CAAC,IAAI;IACZ,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;IACtC,YAAY,EAAE,CAAC,CAAC,YAAY;IAC5B,WAAW,EAAE,CAAC,CAAC,WAAW;IAC1B,iBAAiB,EAAE,CAAC,CAAC,mBAAmB;IACxC,OAAO,EAAE,CAAC,CAAC,QAAQ;IACnB,QAAQ,EAAE,CAAC,CAAC,SAAS;IACrB,SAAS,EAAE,CAAC,CAAC,UAAU,CAAC,WAAW,EAAE;IACrC,SAAS,EAAE,CAAC,CAAC,UAAU,CAAC,WAAW,EAAE;IACrC,SAAS,EAAE,CAAC,CAAC,UAAU,EAAE,WAAW,EAAE,IAAI,IAAI;IAC9C,UAAU,EAAE,CAAC,CAAC,YAAY,EAAE,WAAW,EAAE,IAAI,IAAI;CAClD,CAAC,CAAC;AAEH,MAAM,UAAU,gBAAgB,CAAC,IAAoB;IACnD,MAAM,GAAG,GAAG,IAAI,IAAI,EAAO,CAAC;IAC5B,MAAM,EAAE,GAAG,EAAE,QAAQ,EAAE,GAAG,IAAI,CAAC;IAC/B,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC;IAC3C,MAAM,GAAG,GAAG,UAAU,CAAC;IAEvB;;;;;;;;OAQG;IACH,MAAM,KAAK,GAAG,KAAK,EAAE,CAAM,EAAE,EAAE;QAC7B,MAAM,GAAG,GAAG,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;QAC/B,IAAI,CAAC,GAAG;YAAE,OAAO,IAAI,CAAC;QACtB,MAAM,CAAC,GAAG,MAAM,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAC;QACpD,IAAI,CAAC,CAAC;YAAE,OAAO,IAAI,CAAC;QACpB,MAAM,KAAK,GAAG,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QAC7B,OAAO,KAAK,IAAI,KAAK,CAAC,KAAK,KAAK,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IACtD,CAAC,CAAC;IAEF,GAAG,CAAC,IAAI,CAAC,oCAAoC,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE;QACzD,MAAM,OAAO,GAAG,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC;QAC/B,IAAI,CAAC,OAAO;YAAE,OAAO,IAAI,CAAC,CAAC,EAAE,GAAG,EAAE,mBAAmB,EAAE,2BAA2B,CAAC,CAAC;QAEpF,MAAM,IAAI,GAAG,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAA+B,CAAC;QAClF,MAAM,IAAI,GAAG,OAAO,IAAI,EAAE,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAClF,IAAI,CAAC,IAAI,EAAE,CAAC;YACV,OAAO,IAAI,CACT,CAAC,EACD,GAAG,EACH,mBAAmB,EACnB,oFAAoF,CACrF,CAAC;QACJ,CAAC;QAED,MAAM,WAAW,GAAa,KAAK,CAAC,OAAO,CAAC,IAAI,EAAE,WAAW,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,mBAAmB,CAAC;QACxG,MAAM,OAAO,GAAG,WAAW,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC;QAC9D,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;YACnB,OAAO,IAAI,CAAC,CAAC,EAAE,GAAG,EAAE,mBAAmB,EAAE,0BAA0B,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAC5F,CAAC;QAED,qEAAqE;QACrE,4EAA4E;QAC5E,MAAM,OAAO,GAAG,GAAG,EAAE,CAAC,OAAO,EAAE,GAAG,IAAI,CAAC,UAAU,GAAG,GAAG,CAAC;QACxD,MAAM,KAAK,GAAG,OAAO,IAAI,EAAE,SAAS,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC;QACrF,MAAM,SAAS,GAAG,IAAI,IAAI,CACxB,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC,OAAO,EAAE,GAAG,IAAI,CAAC,OAAO,GAAG,GAAG,EAAE,OAAO,CAAC,CACzF,CAAC;QACF,IAAI,SAAS,IAAI,GAAG,EAAE,EAAE,CAAC;YACvB,OAAO,IAAI,CAAC,CAAC,EAAE,GAAG,EAAE,mBAAmB,EAAE,kCAAkC,CAAC,CAAC;QAC/E,CAAC;QAED,IAAI,OAAO,GAAkB,IAAI,CAAC;QAClC,IAAI,OAAO,IAAI,EAAE,OAAO,KAAK,QAAQ,IAAI,IAAI,CAAC,OAAO,EAAE,CAAC;YACtD,OAAO,GAAG,KAAK,CAAC,IAAI,CAAC,OAAO,EAAE,SAAS,CAAC,KAAK,CAAC,CAAC;YAC/C,IAAI,CAAC,OAAO;gBAAE,OAAO,IAAI,CAAC,CAAC,EAAE,GAAG,EAAE,mBAAmB,EAAE,4BAA4B,CAAC,CAAC;YACrF,MAAM,CAAC,KAAK,CAAC,GAAG,MAAM,GAAG,CAAkB;2CACN,OAAO,qBAAqB,OAAO,CAAC,EAAE;OAC1E,CAAC;YACF,IAAI,CAAC,KAAK;gBAAE,OAAO,IAAI,CAAC,CAAC,EAAE,GAAG,EAAE,WAAW,EAAE,oCAAoC,CAAC,CAAC;QACrF,CAAC;QAED,qEAAqE;QACrE,yEAAyE;QACzE,MAAM,KAAK,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;QACpD,MAAM,EAAE,GAAG,UAAU,EAAE,CAAC;QAExB,MAAM,CAAC,GAAG,CAAC,GAAG,MAAM,GAAG,CAAO;;;;;WAKvB,EAAE,KAAK,OAAO,CAAC,MAAM,KAAK,OAAO,CAAC,EAAE,KAAK,OAAO,KAAK,MAAM,CAAC,KAAK,CAAC,KAAK,IAAI;WAC3E,OAAO,IAAI,EAAE,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI;WACnD,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,EAAE,YAAY,CAAC,IAAI,IAAI,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;WACpG,GAAG,CAAC,KAAK,CAAC,WAAW,CAAC;WACtB,IAAI,EAAE,iBAAiB,KAAK,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI;WAChD,MAAM,CAAC,SAAS,CAAC,IAAI,EAAE,OAAO,CAAC,IAAK,IAAI,EAAE,OAAkB,GAAG,CAAC,CAAC,CAAC,CAAC,IAAK,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI;WACvF,SAAS;;;KAGf,CAAC;QAEF,CAAC,CAAC,MAAM,CAAC,UAAU,EAAE,wBAAwB,SAAS,CAAC,IAAI,GAAG,EAAE,EAAE,CAAC,CAAC;QACpE,OAAO,CAAC,CAAC,IAAI,CACX;YACE,GAAG,KAAK,CAAC,GAAI,CAAC;YACd,wEAAwE;YACxE,wEAAwE;YACxE,KAAK;YACL,QAAQ,EAAE,WAAW,KAAK,EAAE;SAC7B,EACD,GAAG,CACJ,CAAC;IACJ,CAAC,CAAC,CAAC;IAEH,GAAG,CAAC,GAAG,CAAC,oCAAoC,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE;QACxD,MAAM,OAAO,GAAG,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC;QAC/B,IAAI,CAAC,OAAO;YAAE,OAAO,IAAI,CAAC,CAAC,EAAE,GAAG,EAAE,mBAAmB,EAAE,2BAA2B,CAAC,CAAC;QACpF,MAAM,IAAI,GAAG,MAAM,GAAG,CAAO;;;6CAGY,OAAO,CAAC,EAAE;KAClD,CAAC;QACF,OAAO,CAAC,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC,CAAC;IAC7D,CAAC,CAAC,CAAC;IAEH,GAAG,CAAC,MAAM,CAAC,0BAA0B,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE;QACjD,MAAM,EAAE,GAAG,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,SAAS,CAAC,IAAI,CAAC,CAAC;QACpD,IAAI,CAAC,EAAE;YAAE,OAAO,IAAI,CAAC,CAAC,EAAE,GAAG,EAAE,WAAW,EAAE,sBAAsB,CAAC,CAAC;QAElE,sEAAsE;QACtE,iEAAiE;QACjE,uEAAuE;QACvE,0EAA0E;QAC1E,wCAAwC;QACxC,MAAM,KAAK,GAAG,CAAC,CAAC,GAAG,CAAC,OAAO,CAAE,CAAC;QAC9B,MAAM,CAAC,IAAI,CAAC,GAAG,MAAM,GAAG,CAA4E;;;sBAGlF,EAAE,oBAAoB,KAAK,CAAC,KAAK;KAClD,CAAC;QACF,IAAI,CAAC,IAAI;YAAE,OAAO,IAAI,CAAC,CAAC,EAAE,GAAG,EAAE,WAAW,EAAE,sBAAsB,CAAC,CAAC;QAEpE,4EAA4E;QAC5E,4EAA4E;QAC5E,qEAAqE;QACrE,MAAM,GAAG,CAAC,KAAK,CAAC,KAAK,EAAE,EAAE,EAAE,EAAE;YAC3B,MAAM,EAAE,CAAA,yDAAyD,EAAE,yBAAyB,CAAC;YAC7F,MAAM,EAAE,CAAA,oEAAoE,IAAI,CAAC,UAAU,EAAE,CAAC;QAChG,CAAC,CAAC,CAAC;QACH,2EAA2E;QAC3E,yEAAyE;QACzE,eAAe;QACf,QAAQ,CAAC,UAAU,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAE9B,OAAO,CAAC,CAAC,IAAI,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;IAC3B,CAAC,CAAC,CAAC;IAEH,OAAO,GAAG,CAAC;AACb,CAAC"}
@@ -0,0 +1,137 @@
1
+ import { Hono } from "hono";
2
+ import { randomUUID } from "node:crypto";
3
+ import { ID_PREFIX } from "../vendor/types/names.js";
4
+ function fail(c, status, code, message) {
5
+ return c.json({ error: { code, message, requestId: c.get("requestId") } }, status);
6
+ }
7
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8
+ const rowId = (wire) => {
9
+ const bare = wire.startsWith(ID_PREFIX.round) ? wire.slice(ID_PREFIX.round.length) : wire;
10
+ return UUID.test(bare) ? bare.toLowerCase() : null;
11
+ };
12
+ const shape = (r) => ({
13
+ id: ID_PREFIX.round + r.id,
14
+ number: r.number,
15
+ name: r.name,
16
+ closed: !!r.closed_at,
17
+ closedAt: r.closed_at?.toISOString(),
18
+ createdAt: r.created_at.toISOString(),
19
+ threads: r.threads,
20
+ // D2's safeguard, server-computed. A closed round still reports this — the
21
+ // whole point is that closing a round must not make its unfinished work
22
+ // disappear from view.
23
+ unresolved: r.unresolved,
24
+ });
25
+ export function roundRoutes(deps) {
26
+ const app = new Hono();
27
+ const { sql, projects } = deps;
28
+ /** Owner-only, and owner OF THIS PROJECT. Returns the project or null. */
29
+ const owned = async (c) => {
30
+ const uid = c.req.param("uid");
31
+ if (!uid)
32
+ return null;
33
+ const p = await projects.get(uid).catch(() => null);
34
+ if (!p)
35
+ return null;
36
+ const owner = c.get("owner");
37
+ return owner && owner.orgId === p.org_id ? p : null;
38
+ };
39
+ /** Every round on the project, newest first, each with its counts. */
40
+ app.get("/api/v1/projects/:uid/rounds", async (c) => {
41
+ const p = await owned(c);
42
+ if (!p)
43
+ return fail(c, 404, "project_not_found", "No project with that uid.");
44
+ const rows = await sql `
45
+ SELECT r.id, r.number, r.name, r.closed_at, r.created_at,
46
+ count(t.id)::int AS threads,
47
+ count(t.id) FILTER (WHERE t.status <> 'resolved')::int AS unresolved
48
+ FROM rounds r
49
+ LEFT JOIN threads t ON t.round_id = r.id AND t.deleted_at IS NULL
50
+ WHERE r.project_id = ${p.id} AND r.deleted_at IS NULL
51
+ GROUP BY r.id
52
+ ORDER BY r.number DESC
53
+ `;
54
+ return c.json({ data: rows.map(shape) });
55
+ });
56
+ /**
57
+ * Open a new round. The previous open one is CLOSED in the same transaction,
58
+ * because "one active round at a time" is the model and two open rounds have
59
+ * no defined answer to "where does the next thread go".
60
+ *
61
+ * Numbering is allocated the same way thread keys are (§2.1): an UPDATE that
62
+ * row-locks the project, not a sequence, because gapless is the property and
63
+ * a sequence cannot promise it.
64
+ */
65
+ app.post("/api/v1/projects/:uid/rounds", async (c) => {
66
+ const p = await owned(c);
67
+ if (!p)
68
+ return fail(c, 404, "project_not_found", "No project with that uid.");
69
+ const body = (await c.req.json().catch(() => null));
70
+ const name = typeof body?.name === "string" ? body.name.trim().slice(0, 80) : "";
71
+ const owner = c.get("owner");
72
+ const row = await sql.begin(async (tx) => {
73
+ await tx `SELECT set_config('app.org_id', ${p.org_id}, true)`;
74
+ const [next] = await tx `
75
+ SELECT COALESCE(max(number), 0) + 1 AS n FROM rounds WHERE project_id = ${p.id}
76
+ `;
77
+ const number = next.n;
78
+ const id = randomUUID();
79
+ const [created] = await tx `
80
+ WITH closed AS (
81
+ UPDATE rounds SET closed_at = now(), updated_at = now()
82
+ WHERE project_id = ${p.id}::uuid AND closed_at IS NULL AND deleted_at IS NULL
83
+ RETURNING id
84
+ ),
85
+ made AS (
86
+ INSERT INTO rounds (id, org_id, project_id, number, name, created_by)
87
+ VALUES (${id}::uuid, ${p.org_id}::uuid, ${p.id}::uuid, ${number}::int,
88
+ ${name || `Round ${number}`}::text, ${owner.userId}::uuid)
89
+ RETURNING id, number, name, closed_at, created_at
90
+ )
91
+ SELECT made.*, 0::int AS threads, 0::int AS unresolved FROM made
92
+ `;
93
+ return created;
94
+ });
95
+ return c.json(shape(row), 201);
96
+ });
97
+ /**
98
+ * Rename, close or reopen. Never renumber, and never move threads: `number`
99
+ * is display order minted once, and a thread's round is immutable (D2).
100
+ */
101
+ app.patch("/api/v1/rounds/:id", async (c) => {
102
+ const owner = c.get("owner");
103
+ if (!owner)
104
+ return fail(c, 401, "unauthorized", "Sign in to the dashboard first.");
105
+ const id = rowId(c.req.param("id"));
106
+ if (!id)
107
+ return fail(c, 404, "not_found", "No such round.");
108
+ const body = (await c.req.json().catch(() => null));
109
+ const name = typeof body?.name === "string" ? body.name.trim().slice(0, 80) : null;
110
+ const closed = typeof body?.closed === "boolean" ? body.closed : null;
111
+ if (name === null && closed === null) {
112
+ return fail(c, 400, "validation_failed", "Send a name, or closed: true/false.");
113
+ }
114
+ if (name !== null && !name)
115
+ return fail(c, 400, "validation_failed", "A round needs a name.");
116
+ const [row] = await sql `
117
+ WITH upd AS (
118
+ UPDATE rounds SET
119
+ name = ${name ?? sql `name`},
120
+ closed_at = ${closed === null ? sql `closed_at` : closed ? sql `now()` : sql `NULL`},
121
+ updated_at = now()
122
+ WHERE id = ${id}::uuid AND org_id = ${owner.orgId}::uuid AND deleted_at IS NULL
123
+ RETURNING id, number, name, closed_at, created_at
124
+ )
125
+ SELECT upd.*,
126
+ (SELECT count(*)::int FROM threads t WHERE t.round_id = upd.id AND t.deleted_at IS NULL) AS threads,
127
+ (SELECT count(*)::int FROM threads t
128
+ WHERE t.round_id = upd.id AND t.deleted_at IS NULL AND t.status <> 'resolved') AS unresolved
129
+ FROM upd
130
+ `;
131
+ if (!row)
132
+ return fail(c, 404, "not_found", "No such round.");
133
+ return c.json(shape(row));
134
+ });
135
+ return app;
136
+ }
137
+ //# sourceMappingURL=rounds.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rounds.js","sourceRoot":"","sources":["../../src/routes/rounds.ts"],"names":[],"mappings":"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"}