@igstack/app-catalog-backend-core 1.0.1 → 2.0.0-alpha-20260914211846

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 (62) hide show
  1. package/dist/generated/prisma/client.mjs.map +1 -1
  2. package/dist/generated/prisma/internal/class.d.mts +12 -0
  3. package/dist/generated/prisma/internal/class.d.mts.map +1 -1
  4. package/dist/generated/prisma/internal/class.mjs +4 -4
  5. package/dist/generated/prisma/internal/class.mjs.map +1 -1
  6. package/dist/generated/prisma/internal/prismaNamespace.d.mts +91 -3
  7. package/dist/generated/prisma/internal/prismaNamespace.d.mts.map +1 -1
  8. package/dist/generated/prisma/models/DbComment.d.mts +1234 -0
  9. package/dist/generated/prisma/models/DbComment.d.mts.map +1 -0
  10. package/dist/generated/prisma/models/DbResource.d.mts +229 -2
  11. package/dist/generated/prisma/models/DbResource.d.mts.map +1 -1
  12. package/dist/generated/prisma/models.d.mts +2 -1
  13. package/dist/index.d.mts +3 -1
  14. package/dist/index.mjs +3 -1
  15. package/dist/middleware/createAcMiddleware.d.mts.map +1 -1
  16. package/dist/middleware/createAcMiddleware.mjs +4 -2
  17. package/dist/middleware/createAcMiddleware.mjs.map +1 -1
  18. package/dist/middleware/featureRegistry.mjs +1 -7
  19. package/dist/middleware/featureRegistry.mjs.map +1 -1
  20. package/dist/modules/comments/commentsRouter.d.mts +47 -0
  21. package/dist/modules/comments/commentsRouter.d.mts.map +1 -0
  22. package/dist/modules/comments/commentsRouter.mjs +55 -0
  23. package/dist/modules/comments/commentsRouter.mjs.map +1 -0
  24. package/dist/modules/comments/service.d.mts +25 -0
  25. package/dist/modules/comments/service.d.mts.map +1 -0
  26. package/dist/modules/comments/service.mjs +86 -0
  27. package/dist/modules/comments/service.mjs.map +1 -0
  28. package/dist/modules/comments/visitorIdentity.d.mts +15 -0
  29. package/dist/modules/comments/visitorIdentity.d.mts.map +1 -0
  30. package/dist/modules/comments/visitorIdentity.mjs +121 -0
  31. package/dist/modules/comments/visitorIdentity.mjs.map +1 -0
  32. package/dist/server/acTrpcContext.d.mts +10 -1
  33. package/dist/server/acTrpcContext.d.mts.map +1 -1
  34. package/dist/server/acTrpcContext.mjs +3 -2
  35. package/dist/server/acTrpcContext.mjs.map +1 -1
  36. package/dist/server/controller.d.mts +40 -0
  37. package/dist/server/controller.d.mts.map +1 -1
  38. package/dist/server/controller.mjs +2 -0
  39. package/dist/server/controller.mjs.map +1 -1
  40. package/dist/utils/cookies.mjs +12 -0
  41. package/dist/utils/cookies.mjs.map +1 -0
  42. package/package.json +4 -4
  43. package/prisma/migrations/20260914000000_add_comments/migration.sql +22 -0
  44. package/prisma/schema.prisma +27 -0
  45. package/src/__tests__/comments.test.ts +73 -0
  46. package/src/generated/prisma/browser.ts +10 -0
  47. package/src/generated/prisma/client.ts +10 -0
  48. package/src/generated/prisma/internal/class.ts +14 -4
  49. package/src/generated/prisma/internal/prismaNamespace.ts +91 -1
  50. package/src/generated/prisma/internal/prismaNamespaceBrowser.ts +15 -0
  51. package/src/generated/prisma/models/DbComment.ts +1452 -0
  52. package/src/generated/prisma/models/DbResource.ts +238 -0
  53. package/src/generated/prisma/models.ts +1 -0
  54. package/src/index.ts +10 -0
  55. package/src/middleware/createAcMiddleware.ts +5 -0
  56. package/src/middleware/featureRegistry.ts +1 -11
  57. package/src/modules/comments/commentsRouter.ts +85 -0
  58. package/src/modules/comments/service.ts +154 -0
  59. package/src/modules/comments/visitorIdentity.ts +146 -0
  60. package/src/server/acTrpcContext.ts +10 -1
  61. package/src/server/controller.ts +3 -0
  62. package/src/utils/cookies.ts +10 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service.d.mts","names":[],"sources":["../../../src/modules/comments/service.ts"],"mappings":";AAIA;AAAA,cAAa,cAAA;AAAA,cAEA,eAAA;;UAGI,WAAA;EACf,EAAA;EACA,UAAA;EACA,IAAA;EACA,SAAA;EAP0B;EAS1B,QAAA;EAN0B;EAQ1B,MAAA;EAR0B;;;;;;EAe1B,YAAA;AAAA"}
@@ -0,0 +1,86 @@
1
+ import { getDbClient } from "../../db/client.mjs";
2
+
3
+ //#region src/modules/comments/service.ts
4
+ /** How long an author may edit or delete their own comment. */
5
+ const EDIT_WINDOW_MS = 3600 * 1e3;
6
+ const MAX_BODY_LENGTH = 1e3;
7
+ function toView(row, visitor) {
8
+ const isMine = !!visitor && row.authorHash === visitor.hash;
9
+ return {
10
+ id: row.id,
11
+ authorName: row.authorAlias,
12
+ body: row.body,
13
+ createdAt: row.createdAt.toISOString(),
14
+ editedAt: row.editedAt?.toISOString() ?? null,
15
+ isMine,
16
+ canEditUntil: isMine ? new Date(row.createdAt.getTime() + EDIT_WINDOW_MS).toISOString() : null
17
+ };
18
+ }
19
+ async function resourceIdForSlug(slug) {
20
+ return (await getDbClient().dbResource.findUnique({
21
+ where: { slug },
22
+ select: { id: true }
23
+ }))?.id ?? null;
24
+ }
25
+ /** Oldest first: a comment thread reads top to bottom. */
26
+ async function listComments(resourceSlug, visitor) {
27
+ const resourceId = await resourceIdForSlug(resourceSlug);
28
+ if (!resourceId) return [];
29
+ return (await getDbClient().dbComment.findMany({
30
+ where: { resourceId },
31
+ orderBy: { createdAt: "asc" }
32
+ })).map((row) => toView(row, visitor));
33
+ }
34
+ var CommentError = class extends Error {
35
+ constructor(reason, message) {
36
+ super(message);
37
+ this.reason = reason;
38
+ }
39
+ };
40
+ function normaliseBody(body) {
41
+ const trimmed = body.trim();
42
+ if (!trimmed) throw new CommentError("empty", "A comment cannot be empty");
43
+ return trimmed.slice(0, MAX_BODY_LENGTH);
44
+ }
45
+ async function addComment(resourceSlug, body, visitor) {
46
+ const resourceId = await resourceIdForSlug(resourceSlug);
47
+ if (!resourceId) throw new CommentError("not-found", `Unknown resource: ${resourceSlug}`);
48
+ return toView(await getDbClient().dbComment.create({ data: {
49
+ resourceId,
50
+ authorHash: visitor.hash,
51
+ authorAlias: visitor.alias,
52
+ body: normaliseBody(body)
53
+ } }), visitor);
54
+ }
55
+ /**
56
+ * Load a comment and check this visitor may still change it.
57
+ *
58
+ * The window is enforced here, not in the UI: the controls disappearing is a
59
+ * courtesy, and a request that arrives a second late must still be refused.
60
+ */
61
+ async function loadOwn(id, visitor) {
62
+ const row = await getDbClient().dbComment.findUnique({ where: { id } });
63
+ if (!row) throw new CommentError("not-found", "Comment no longer exists");
64
+ if (row.authorHash !== visitor.hash) throw new CommentError("not-author", "Only the author can change a comment");
65
+ if (Date.now() > row.createdAt.getTime() + 36e5) throw new CommentError("window-closed", "A comment can only be changed in the first hour");
66
+ return row;
67
+ }
68
+ async function editComment(id, body, visitor) {
69
+ await loadOwn(id, visitor);
70
+ return toView(await getDbClient().dbComment.update({
71
+ where: { id },
72
+ data: {
73
+ body: normaliseBody(body),
74
+ editedAt: /* @__PURE__ */ new Date()
75
+ }
76
+ }), visitor);
77
+ }
78
+ async function deleteComment(id, visitor) {
79
+ await loadOwn(id, visitor);
80
+ await getDbClient().dbComment.delete({ where: { id } });
81
+ return { id };
82
+ }
83
+
84
+ //#endregion
85
+ export { CommentError, EDIT_WINDOW_MS, MAX_BODY_LENGTH, addComment, deleteComment, editComment, listComments };
86
+ //# sourceMappingURL=service.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service.mjs","names":[],"sources":["../../../src/modules/comments/service.ts"],"sourcesContent":["import { getDbClient } from '../../db/client'\nimport type { Visitor } from './visitorIdentity'\n\n/** How long an author may edit or delete their own comment. */\nexport const EDIT_WINDOW_MS = 60 * 60 * 1000\n\nexport const MAX_BODY_LENGTH = 1000\n\n/** One comment as a client sees it. Never carries an author hash. */\nexport interface CommentView {\n id: string\n authorName: string\n body: string\n createdAt: string\n /** Null when the comment has never been edited. */\n editedAt: string | null\n /** Whether this browser wrote it — what makes Edit and Delete appear. */\n isMine: boolean\n /**\n * The absolute instant this browser's edit window closes, or null when it is not\n * this browser's comment. Absolute rather than a remaining duration so a client\n * with a skewed clock still hides the controls at the same moment the server\n * starts refusing the edit.\n */\n canEditUntil: string | null\n}\n\nfunction toView(\n row: {\n id: string\n authorHash: string\n authorAlias: string\n body: string\n editedAt: Date | null\n createdAt: Date\n },\n visitor: Visitor | null,\n): CommentView {\n const isMine = !!visitor && row.authorHash === visitor.hash\n return {\n id: row.id,\n authorName: row.authorAlias,\n body: row.body,\n createdAt: row.createdAt.toISOString(),\n editedAt: row.editedAt?.toISOString() ?? null,\n isMine,\n canEditUntil: isMine\n ? new Date(row.createdAt.getTime() + EDIT_WINDOW_MS).toISOString()\n : null,\n }\n}\n\nasync function resourceIdForSlug(slug: string): Promise<string | null> {\n const resource = await getDbClient().dbResource.findUnique({\n where: { slug },\n select: { id: true },\n })\n return resource?.id ?? null\n}\n\n/** Oldest first: a comment thread reads top to bottom. */\nexport async function listComments(\n resourceSlug: string,\n visitor: Visitor | null,\n): Promise<CommentView[]> {\n const resourceId = await resourceIdForSlug(resourceSlug)\n if (!resourceId) return []\n\n const rows = await getDbClient().dbComment.findMany({\n where: { resourceId },\n orderBy: { createdAt: 'asc' },\n })\n return rows.map((row) => toView(row, visitor))\n}\n\nexport class CommentError extends Error {\n constructor(\n readonly reason: 'not-found' | 'not-author' | 'window-closed' | 'empty',\n message: string,\n ) {\n super(message)\n }\n}\n\nfunction normaliseBody(body: string): string {\n const trimmed = body.trim()\n if (!trimmed) throw new CommentError('empty', 'A comment cannot be empty')\n return trimmed.slice(0, MAX_BODY_LENGTH)\n}\n\nexport async function addComment(\n resourceSlug: string,\n body: string,\n visitor: Visitor,\n): Promise<CommentView> {\n const resourceId = await resourceIdForSlug(resourceSlug)\n if (!resourceId) {\n throw new CommentError('not-found', `Unknown resource: ${resourceSlug}`)\n }\n\n const row = await getDbClient().dbComment.create({\n data: {\n resourceId,\n authorHash: visitor.hash,\n authorAlias: visitor.alias,\n body: normaliseBody(body),\n },\n })\n return toView(row, visitor)\n}\n\n/**\n * Load a comment and check this visitor may still change it.\n *\n * The window is enforced here, not in the UI: the controls disappearing is a\n * courtesy, and a request that arrives a second late must still be refused.\n */\nasync function loadOwn(id: string, visitor: Visitor) {\n const row = await getDbClient().dbComment.findUnique({ where: { id } })\n if (!row) throw new CommentError('not-found', 'Comment no longer exists')\n if (row.authorHash !== visitor.hash) {\n throw new CommentError('not-author', 'Only the author can change a comment')\n }\n if (Date.now() > row.createdAt.getTime() + EDIT_WINDOW_MS) {\n throw new CommentError(\n 'window-closed',\n 'A comment can only be changed in the first hour',\n )\n }\n return row\n}\n\nexport async function editComment(\n id: string,\n body: string,\n visitor: Visitor,\n): Promise<CommentView> {\n await loadOwn(id, visitor)\n\n const row = await getDbClient().dbComment.update({\n where: { id },\n data: { body: normaliseBody(body), editedAt: new Date() },\n })\n return toView(row, visitor)\n}\n\nexport async function deleteComment(\n id: string,\n visitor: Visitor,\n): Promise<{ id: string }> {\n await loadOwn(id, visitor)\n await getDbClient().dbComment.delete({ where: { id } })\n return { id }\n}\n"],"mappings":";;;;AAIA,MAAa,iBAAiB,OAAU;AAExC,MAAa,kBAAkB;AAqB/B,SAAS,OACP,KAQA,SACa;CACb,MAAM,SAAS,CAAC,CAAC,WAAW,IAAI,eAAe,QAAQ;AACvD,QAAO;EACL,IAAI,IAAI;EACR,YAAY,IAAI;EAChB,MAAM,IAAI;EACV,WAAW,IAAI,UAAU,aAAa;EACtC,UAAU,IAAI,UAAU,aAAa,IAAI;EACzC;EACA,cAAc,SACV,IAAI,KAAK,IAAI,UAAU,SAAS,GAAG,eAAe,CAAC,aAAa,GAChE;EACL;;AAGH,eAAe,kBAAkB,MAAsC;AAKrE,SAJiB,MAAM,aAAa,CAAC,WAAW,WAAW;EACzD,OAAO,EAAE,MAAM;EACf,QAAQ,EAAE,IAAI,MAAM;EACrB,CAAC,GACe,MAAM;;;AAIzB,eAAsB,aACpB,cACA,SACwB;CACxB,MAAM,aAAa,MAAM,kBAAkB,aAAa;AACxD,KAAI,CAAC,WAAY,QAAO,EAAE;AAM1B,SAJa,MAAM,aAAa,CAAC,UAAU,SAAS;EAClD,OAAO,EAAE,YAAY;EACrB,SAAS,EAAE,WAAW,OAAO;EAC9B,CAAC,EACU,KAAK,QAAQ,OAAO,KAAK,QAAQ,CAAC;;AAGhD,IAAa,eAAb,cAAkC,MAAM;CACtC,YACE,AAAS,QACT,SACA;AACA,QAAM,QAAQ;EAHL;;;AAOb,SAAS,cAAc,MAAsB;CAC3C,MAAM,UAAU,KAAK,MAAM;AAC3B,KAAI,CAAC,QAAS,OAAM,IAAI,aAAa,SAAS,4BAA4B;AAC1E,QAAO,QAAQ,MAAM,GAAG,gBAAgB;;AAG1C,eAAsB,WACpB,cACA,MACA,SACsB;CACtB,MAAM,aAAa,MAAM,kBAAkB,aAAa;AACxD,KAAI,CAAC,WACH,OAAM,IAAI,aAAa,aAAa,qBAAqB,eAAe;AAW1E,QAAO,OARK,MAAM,aAAa,CAAC,UAAU,OAAO,EAC/C,MAAM;EACJ;EACA,YAAY,QAAQ;EACpB,aAAa,QAAQ;EACrB,MAAM,cAAc,KAAK;EAC1B,EACF,CAAC,EACiB,QAAQ;;;;;;;;AAS7B,eAAe,QAAQ,IAAY,SAAkB;CACnD,MAAM,MAAM,MAAM,aAAa,CAAC,UAAU,WAAW,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;AACvE,KAAI,CAAC,IAAK,OAAM,IAAI,aAAa,aAAa,2BAA2B;AACzE,KAAI,IAAI,eAAe,QAAQ,KAC7B,OAAM,IAAI,aAAa,cAAc,uCAAuC;AAE9E,KAAI,KAAK,KAAK,GAAG,IAAI,UAAU,SAAS,QACtC,OAAM,IAAI,aACR,iBACA,kDACD;AAEH,QAAO;;AAGT,eAAsB,YACpB,IACA,MACA,SACsB;AACtB,OAAM,QAAQ,IAAI,QAAQ;AAM1B,QAAO,OAJK,MAAM,aAAa,CAAC,UAAU,OAAO;EAC/C,OAAO,EAAE,IAAI;EACb,MAAM;GAAE,MAAM,cAAc,KAAK;GAAE,0BAAU,IAAI,MAAM;GAAE;EAC1D,CAAC,EACiB,QAAQ;;AAG7B,eAAsB,cACpB,IACA,SACyB;AACzB,OAAM,QAAQ,IAAI,QAAQ;AAC1B,OAAM,aAAa,CAAC,UAAU,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;AACvD,QAAO,EAAE,IAAI"}
@@ -0,0 +1,15 @@
1
+ //#region src/modules/comments/visitorIdentity.d.ts
2
+ /**
3
+ * Who the server thinks is commenting.
4
+ *
5
+ * The catalog is browsable without logging in, so there is no session to hang a
6
+ * comment on. `hash` is the only thing that authorises editing or deleting a
7
+ * comment later, and `alias` is the only name a reader ever sees.
8
+ */
9
+ interface Visitor {
10
+ hash: string;
11
+ alias: string;
12
+ }
13
+ //#endregion
14
+ export { Visitor };
15
+ //# sourceMappingURL=visitorIdentity.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"visitorIdentity.d.mts","names":[],"sources":["../../../src/modules/comments/visitorIdentity.ts"],"mappings":";;;;;;;;UAeiB,OAAA;EACf,IAAA;EACA,KAAA;AAAA"}
@@ -0,0 +1,121 @@
1
+ import { getCookie } from "../../utils/cookies.mjs";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+
4
+ //#region src/modules/comments/visitorIdentity.ts
5
+ /** Opaque per-browser token. httpOnly, so page scripts cannot read or forge it. */
6
+ const VISITOR_COOKIE = "ac_cid";
7
+ const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1e3;
8
+ const ADJECTIVES = [
9
+ "Ancient",
10
+ "Brave",
11
+ "Calm",
12
+ "Clever",
13
+ "Curious",
14
+ "Eager",
15
+ "Gentle",
16
+ "Happy",
17
+ "Honest",
18
+ "Humble",
19
+ "Jolly",
20
+ "Keen",
21
+ "Kind",
22
+ "Lively",
23
+ "Loyal",
24
+ "Merry",
25
+ "Mighty",
26
+ "Modest",
27
+ "Noble",
28
+ "Patient",
29
+ "Polite",
30
+ "Proud",
31
+ "Quiet",
32
+ "Rapid",
33
+ "Silent",
34
+ "Smooth",
35
+ "Steady",
36
+ "Subtle",
37
+ "Sunny",
38
+ "Swift",
39
+ "Tidy",
40
+ "Witty"
41
+ ];
42
+ const ANIMALS = [
43
+ "Badger",
44
+ "Beaver",
45
+ "Bison",
46
+ "Crane",
47
+ "Dolphin",
48
+ "Falcon",
49
+ "Ferret",
50
+ "Finch",
51
+ "Fox",
52
+ "Gecko",
53
+ "Heron",
54
+ "Ibex",
55
+ "Jackal",
56
+ "Kestrel",
57
+ "Lemur",
58
+ "Lynx",
59
+ "Magpie",
60
+ "Marten",
61
+ "Mole",
62
+ "Narwhal",
63
+ "Ocelot",
64
+ "Osprey",
65
+ "Otter",
66
+ "Panda",
67
+ "Puffin",
68
+ "Raven",
69
+ "Seal",
70
+ "Shrew",
71
+ "Stoat",
72
+ "Tapir",
73
+ "Vole",
74
+ "Walrus"
75
+ ];
76
+ /**
77
+ * A stable, readable name for a visitor hash, e.g. "Curious Ferret".
78
+ *
79
+ * Deterministic, so the same browser keeps the same name across comments — but it
80
+ * is derived once and stored on the row, because widening these lists later would
81
+ * otherwise rename every comment ever written.
82
+ */
83
+ function aliasForHash(hash) {
84
+ return `${ADJECTIVES[parseInt(hash.slice(0, 8), 16) % ADJECTIVES.length]} ${ANIMALS[parseInt(hash.slice(8, 16), 16) % ANIMALS.length]}`;
85
+ }
86
+ function hashToken(token) {
87
+ return createHash("sha256").update(token).digest("hex");
88
+ }
89
+ /**
90
+ * Read the visitor token, issuing one when this browser has none yet.
91
+ *
92
+ * Called on every tRPC request rather than only when commenting: the cookie has to
93
+ * already exist by the time someone clicks Post, and issuing it on the read that
94
+ * renders the comment list is the one moment we know a page is open.
95
+ *
96
+ * `res` is absent on internally-created contexts (see createAcMiddleware), and
97
+ * those never comment — they get an identity derived from the token they carry, if
98
+ * any, and cannot be issued a new one.
99
+ */
100
+ function resolveVisitor(req, res) {
101
+ let token = getCookie(req, VISITOR_COOKIE);
102
+ if (!token) {
103
+ if (!res) return null;
104
+ token = randomUUID();
105
+ res.cookie(VISITOR_COOKIE, token, {
106
+ httpOnly: true,
107
+ sameSite: "lax",
108
+ path: "/",
109
+ maxAge: ONE_YEAR_MS
110
+ });
111
+ }
112
+ const hash = hashToken(token);
113
+ return {
114
+ hash,
115
+ alias: aliasForHash(hash)
116
+ };
117
+ }
118
+
119
+ //#endregion
120
+ export { resolveVisitor };
121
+ //# sourceMappingURL=visitorIdentity.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"visitorIdentity.mjs","names":[],"sources":["../../../src/modules/comments/visitorIdentity.ts"],"sourcesContent":["import { createHash, randomUUID } from 'node:crypto'\nimport { getCookie } from '../../utils/cookies'\n\n/** Opaque per-browser token. httpOnly, so page scripts cannot read or forge it. */\nexport const VISITOR_COOKIE = 'ac_cid'\n\nconst ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000\n\n/**\n * Who the server thinks is commenting.\n *\n * The catalog is browsable without logging in, so there is no session to hang a\n * comment on. `hash` is the only thing that authorises editing or deleting a\n * comment later, and `alias` is the only name a reader ever sees.\n */\nexport interface Visitor {\n hash: string\n alias: string\n}\n\nconst ADJECTIVES = [\n 'Ancient',\n 'Brave',\n 'Calm',\n 'Clever',\n 'Curious',\n 'Eager',\n 'Gentle',\n 'Happy',\n 'Honest',\n 'Humble',\n 'Jolly',\n 'Keen',\n 'Kind',\n 'Lively',\n 'Loyal',\n 'Merry',\n 'Mighty',\n 'Modest',\n 'Noble',\n 'Patient',\n 'Polite',\n 'Proud',\n 'Quiet',\n 'Rapid',\n 'Silent',\n 'Smooth',\n 'Steady',\n 'Subtle',\n 'Sunny',\n 'Swift',\n 'Tidy',\n 'Witty',\n]\n\nconst ANIMALS = [\n 'Badger',\n 'Beaver',\n 'Bison',\n 'Crane',\n 'Dolphin',\n 'Falcon',\n 'Ferret',\n 'Finch',\n 'Fox',\n 'Gecko',\n 'Heron',\n 'Ibex',\n 'Jackal',\n 'Kestrel',\n 'Lemur',\n 'Lynx',\n 'Magpie',\n 'Marten',\n 'Mole',\n 'Narwhal',\n 'Ocelot',\n 'Osprey',\n 'Otter',\n 'Panda',\n 'Puffin',\n 'Raven',\n 'Seal',\n 'Shrew',\n 'Stoat',\n 'Tapir',\n 'Vole',\n 'Walrus',\n]\n\n/**\n * A stable, readable name for a visitor hash, e.g. \"Curious Ferret\".\n *\n * Deterministic, so the same browser keeps the same name across comments — but it\n * is derived once and stored on the row, because widening these lists later would\n * otherwise rename every comment ever written.\n */\nexport function aliasForHash(hash: string): string {\n // Two independent slices of the hash, so the two words vary independently.\n const adjective =\n ADJECTIVES[parseInt(hash.slice(0, 8), 16) % ADJECTIVES.length]\n const animal = ANIMALS[parseInt(hash.slice(8, 16), 16) % ANIMALS.length]\n return `${adjective} ${animal}`\n}\n\nfunction hashToken(token: string): string {\n return createHash('sha256').update(token).digest('hex')\n}\n\n/**\n * Read the visitor token, issuing one when this browser has none yet.\n *\n * Called on every tRPC request rather than only when commenting: the cookie has to\n * already exist by the time someone clicks Post, and issuing it on the read that\n * renders the comment list is the one moment we know a page is open.\n *\n * `res` is absent on internally-created contexts (see createAcMiddleware), and\n * those never comment — they get an identity derived from the token they carry, if\n * any, and cannot be issued a new one.\n */\nexport function resolveVisitor(\n req: { headers: { cookie?: string } },\n res?: {\n cookie: (\n name: string,\n value: string,\n options: Record<string, unknown>,\n ) => unknown\n },\n): Visitor | null {\n let token = getCookie(req, VISITOR_COOKIE)\n\n if (!token) {\n if (!res) return null\n token = randomUUID()\n res.cookie(VISITOR_COOKIE, token, {\n httpOnly: true,\n sameSite: 'lax',\n path: '/',\n maxAge: ONE_YEAR_MS,\n })\n }\n\n const hash = hashToken(token)\n return { hash, alias: aliasForHash(hash) }\n}\n"],"mappings":";;;;;AAIA,MAAa,iBAAiB;AAE9B,MAAM,cAAc,MAAM,KAAK,KAAK,KAAK;AAczC,MAAM,aAAa;CACjB;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACD;AAED,MAAM,UAAU;CACd;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACD;;;;;;;;AASD,SAAgB,aAAa,MAAsB;AAKjD,QAAO,GAFL,WAAW,SAAS,KAAK,MAAM,GAAG,EAAE,EAAE,GAAG,GAAG,WAAW,QAErC,GADL,QAAQ,SAAS,KAAK,MAAM,GAAG,GAAG,EAAE,GAAG,GAAG,QAAQ;;AAInE,SAAS,UAAU,OAAuB;AACxC,QAAO,WAAW,SAAS,CAAC,OAAO,MAAM,CAAC,OAAO,MAAM;;;;;;;;;;;;;AAczD,SAAgB,eACd,KACA,KAOgB;CAChB,IAAI,QAAQ,UAAU,KAAK,eAAe;AAE1C,KAAI,CAAC,OAAO;AACV,MAAI,CAAC,IAAK,QAAO;AACjB,UAAQ,YAAY;AACpB,MAAI,OAAO,gBAAgB,OAAO;GAChC,UAAU;GACV,UAAU;GACV,MAAM;GACN,QAAQ;GACT,CAAC;;CAGJ,MAAM,OAAO,UAAU,MAAM;AAC7B,QAAO;EAAE;EAAM,OAAO,aAAa,KAAK;EAAE"}
@@ -1,4 +1,5 @@
1
1
  import { AppCatalogCompanySpecificBackend } from "../types/backend/companySpecificBackend.mjs";
2
+ import { Visitor } from "../modules/comments/visitorIdentity.mjs";
2
3
  import { User } from "better-auth/types";
3
4
 
4
5
  //#region src/server/acTrpcContext.d.ts
@@ -6,16 +7,24 @@ interface AcTrpcContext {
6
7
  companySpecificBackend: AppCatalogCompanySpecificBackend;
7
8
  user: User | null;
8
9
  isAdmin: boolean;
10
+ /**
11
+ * The anonymous browser behind this request, independent of `user` — the catalog
12
+ * is browsable logged out, and comments are attributed to this instead. Null when
13
+ * the context was built without a response to issue the cookie on.
14
+ */
15
+ visitor: Visitor | null;
9
16
  }
10
17
  interface AcTrpcContextOptions {
11
18
  companySpecificBackend: AppCatalogCompanySpecificBackend;
12
19
  user?: User | null;
13
20
  isAdmin?: boolean;
21
+ visitor?: Visitor | null;
14
22
  }
15
23
  declare function createAcTrpcContext({
16
24
  companySpecificBackend,
17
25
  user,
18
- isAdmin
26
+ isAdmin,
27
+ visitor
19
28
  }: AcTrpcContextOptions): AcTrpcContext;
20
29
  //#endregion
21
30
  export { AcTrpcContext, AcTrpcContextOptions, createAcTrpcContext };
@@ -1 +1 @@
1
- {"version":3,"file":"acTrpcContext.d.mts","names":[],"sources":["../../src/server/acTrpcContext.ts"],"mappings":";;;;UAGiB,aAAA;EACf,sBAAA,EAAwB,gCAAA;EACxB,IAAA,EAAM,IAAA;EACN,OAAA;AAAA;AAAA,UAGe,oBAAA;EACf,sBAAA,EAAwB,gCAAA;EACxB,IAAA,GAAO,IAAA;EACP,OAAA;AAAA;AAAA,iBAGc,mBAAA,CAAA;EACd,sBAAA;EACA,IAAA;EACA;AAAA,GACC,oBAAA,GAAuB,aAAA"}
1
+ {"version":3,"file":"acTrpcContext.d.mts","names":[],"sources":["../../src/server/acTrpcContext.ts"],"mappings":";;;;;UAIiB,aAAA;EACf,sBAAA,EAAwB,gCAAA;EACxB,IAAA,EAAM,IAAA;EACN,OAAA;;;;;;EAMA,OAAA,EAAS,OAAA;AAAA;AAAA,UAGM,oBAAA;EACf,sBAAA,EAAwB,gCAAA;EACxB,IAAA,GAAO,IAAA;EACP,OAAA;EACA,OAAA,GAAU,OAAA;AAAA;AAAA,iBAGI,mBAAA,CAAA;EACd,sBAAA;EACA,IAAA;EACA,OAAA;EACA;AAAA,GACC,oBAAA,GAAuB,aAAA"}
@@ -1,9 +1,10 @@
1
1
  //#region src/server/acTrpcContext.ts
2
- function createAcTrpcContext({ companySpecificBackend, user = null, isAdmin = false }) {
2
+ function createAcTrpcContext({ companySpecificBackend, user = null, isAdmin = false, visitor = null }) {
3
3
  return {
4
4
  companySpecificBackend,
5
5
  user,
6
- isAdmin
6
+ isAdmin,
7
+ visitor
7
8
  };
8
9
  }
9
10
 
@@ -1 +1 @@
1
- {"version":3,"file":"acTrpcContext.mjs","names":[],"sources":["../../src/server/acTrpcContext.ts"],"sourcesContent":["import type { AppCatalogCompanySpecificBackend } from '../types'\nimport type { User } from 'better-auth/types'\n\nexport interface AcTrpcContext {\n companySpecificBackend: AppCatalogCompanySpecificBackend\n user: User | null\n isAdmin: boolean\n}\n\nexport interface AcTrpcContextOptions {\n companySpecificBackend: AppCatalogCompanySpecificBackend\n user?: User | null\n isAdmin?: boolean\n}\n\nexport function createAcTrpcContext({\n companySpecificBackend,\n user = null,\n isAdmin = false,\n}: AcTrpcContextOptions): AcTrpcContext {\n return { companySpecificBackend, user, isAdmin }\n}\n"],"mappings":";AAeA,SAAgB,oBAAoB,EAClC,wBACA,OAAO,MACP,UAAU,SAC4B;AACtC,QAAO;EAAE;EAAwB;EAAM;EAAS"}
1
+ {"version":3,"file":"acTrpcContext.mjs","names":[],"sources":["../../src/server/acTrpcContext.ts"],"sourcesContent":["import type { AppCatalogCompanySpecificBackend } from '../types'\nimport type { Visitor } from '../modules/comments/visitorIdentity'\nimport type { User } from 'better-auth/types'\n\nexport interface AcTrpcContext {\n companySpecificBackend: AppCatalogCompanySpecificBackend\n user: User | null\n isAdmin: boolean\n /**\n * The anonymous browser behind this request, independent of `user` — the catalog\n * is browsable logged out, and comments are attributed to this instead. Null when\n * the context was built without a response to issue the cookie on.\n */\n visitor: Visitor | null\n}\n\nexport interface AcTrpcContextOptions {\n companySpecificBackend: AppCatalogCompanySpecificBackend\n user?: User | null\n isAdmin?: boolean\n visitor?: Visitor | null\n}\n\nexport function createAcTrpcContext({\n companySpecificBackend,\n user = null,\n isAdmin = false,\n visitor = null,\n}: AcTrpcContextOptions): AcTrpcContext {\n return { companySpecificBackend, user, isAdmin, visitor }\n}\n"],"mappings":";AAuBA,SAAgB,oBAAoB,EAClC,wBACA,OAAO,MACP,UAAU,OACV,UAAU,QAC4B;AACtC,QAAO;EAAE;EAAwB;EAAM;EAAS;EAAS"}
@@ -1,6 +1,7 @@
1
1
  import { AppCatalogData, Resource } from "../types/common/appCatalogTypes.mjs";
2
2
  import { AcTrpcContext } from "./acTrpcContext.mjs";
3
3
  import { BetterAuth } from "../modules/auth/auth.mjs";
4
+ import { CommentView } from "../modules/comments/service.mjs";
4
5
  import * as _trpc_server0 from "@trpc/server";
5
6
 
6
7
  //#region src/server/controller.d.ts
@@ -52,6 +53,45 @@ declare function createTrpcRouter(auth?: BetterAuth, options?: {
52
53
  meta: object;
53
54
  }>;
54
55
  }>>;
56
+ comments: _trpc_server0.TRPCBuiltRouter<{
57
+ ctx: AcTrpcContext;
58
+ meta: object;
59
+ errorShape: _trpc_server0.TRPCDefaultErrorShape;
60
+ transformer: false;
61
+ }, _trpc_server0.TRPCDecorateCreateRouterOptions<{
62
+ list: _trpc_server0.TRPCQueryProcedure<{
63
+ input: {
64
+ resourceSlug: string;
65
+ };
66
+ output: CommentView[];
67
+ meta: object;
68
+ }>;
69
+ add: _trpc_server0.TRPCMutationProcedure<{
70
+ input: {
71
+ resourceSlug: string;
72
+ body: string;
73
+ };
74
+ output: CommentView;
75
+ meta: object;
76
+ }>;
77
+ edit: _trpc_server0.TRPCMutationProcedure<{
78
+ input: {
79
+ id: string;
80
+ body: string;
81
+ };
82
+ output: CommentView;
83
+ meta: object;
84
+ }>;
85
+ remove: _trpc_server0.TRPCMutationProcedure<{
86
+ input: {
87
+ id: string;
88
+ };
89
+ output: {
90
+ id: string;
91
+ };
92
+ meta: object;
93
+ }>;
94
+ }>>;
55
95
  auth: _trpc_server0.TRPCBuiltRouter<{
56
96
  ctx: AcTrpcContext;
57
97
  meta: {};
@@ -1 +1 @@
1
- {"version":3,"file":"controller.d.mts","names":[],"sources":["../../src/server/controller.ts"],"mappings":";;;;;;;;;AAkCA;iBAAgB,gBAAA,CACd,IAAA,GAAO,UAAA,EACP,OAAA;EAAY,eAAA;AAAA,kBAA2B,eAAA;OADtB,aAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAsCP,UAAA,GAAa,UAAA,QAAkB,gBAAA"}
1
+ {"version":3,"file":"controller.d.mts","names":[],"sources":["../../src/server/controller.ts"],"mappings":";;;;;;;;;;;iBAmCgB,gBAAA,CACd,IAAA,GAAO,UAAA,EACP,OAAA;EAAY,eAAA;AAAA,kBAA2B,eAAA;OADtB,aAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAwCP,UAAA,GAAa,UAAA,QAAkB,gBAAA"}
@@ -1,6 +1,7 @@
1
1
  import { getAppCatalogData, updateAiMemory, updateApp } from "../modules/appCatalog/service.mjs";
2
2
  import { createAuthRouter } from "../modules/auth/authRouter.mjs";
3
3
  import { publicProcedure, router, t } from "./trpcSetup.mjs";
4
+ import { createCommentsRouter } from "../modules/comments/commentsRouter.mjs";
4
5
  import { z } from "zod";
5
6
 
6
7
  //#region src/server/controller.ts
@@ -41,6 +42,7 @@ function createTrpcRouter(auth, options) {
41
42
  return updateAiMemory(input);
42
43
  })
43
44
  }),
45
+ comments: createCommentsRouter(),
44
46
  auth: createAuthRouter(t, auth, options)
45
47
  });
46
48
  }
@@ -1 +1 @@
1
- {"version":3,"file":"controller.mjs","names":["updateAppService","updateAiMemoryService"],"sources":["../../src/server/controller.ts"],"sourcesContent":["import {\n getAppCatalogData,\n updateAiMemory as updateAiMemoryService,\n updateApp as updateAppService,\n} from '../modules/appCatalog/service'\nimport type { AppCatalogData } from '../types'\nimport type { Resource } from '../types/common/appCatalogTypes'\n\nimport type { BetterAuth } from '../modules/auth/auth'\nimport { createAuthRouter } from '../modules/auth/authRouter.js'\nimport { publicProcedure, router, t } from './trpcSetup'\nimport { z } from 'zod'\n\nconst updateAppInputSchema = z.object({\n id: z.string(),\n data: z\n .object({\n displayName: z.string().optional(),\n abbreviation: z.string().max(20).nullable().optional(),\n slug: z.string().optional(),\n appUrl: z.string().optional(),\n description: z.string().optional(),\n sources: z.array(z.string()).optional(),\n aiPrompt: z.string().nullable().optional(),\n })\n .refine((d) => Object.keys(d).length > 0, {\n message: 'At least one field required',\n }),\n})\n\n/**\n * Create the main tRPC router with optional auth instance\n * @param auth - Optional Better Auth instance for auth-related queries\n */\nexport function createTrpcRouter(\n auth?: BetterAuth,\n options?: { devLoginEnabled?: boolean },\n) {\n return router({\n appCatalog: router({\n getData: publicProcedure.query(\n async ({ ctx }): Promise<AppCatalogData> => {\n const baseData = await getAppCatalogData()\n const versions = await ctx.companySpecificBackend.getVersionInfo?.()\n\n return {\n ...baseData,\n ...(versions && { versions }),\n }\n },\n ),\n updateApp: publicProcedure\n .input(updateAppInputSchema)\n .mutation(async ({ input }): Promise<Resource> => {\n return updateAppService(input)\n }),\n updateAiMemory: publicProcedure\n .input(\n z.object({\n id: z.string(),\n aiMemory: z.string().nullable(),\n }),\n )\n .mutation(async ({ input }): Promise<Resource> => {\n return updateAiMemoryService(input)\n }),\n }),\n\n // Auth routes (requires auth instance)\n auth: createAuthRouter(t, auth, options),\n })\n}\n\nexport type TRPCRouter = ReturnType<typeof createTrpcRouter>\n"],"mappings":";;;;;;AAaA,MAAM,uBAAuB,EAAE,OAAO;CACpC,IAAI,EAAE,QAAQ;CACd,MAAM,EACH,OAAO;EACN,aAAa,EAAE,QAAQ,CAAC,UAAU;EAClC,cAAc,EAAE,QAAQ,CAAC,IAAI,GAAG,CAAC,UAAU,CAAC,UAAU;EACtD,MAAM,EAAE,QAAQ,CAAC,UAAU;EAC3B,QAAQ,EAAE,QAAQ,CAAC,UAAU;EAC7B,aAAa,EAAE,QAAQ,CAAC,UAAU;EAClC,SAAS,EAAE,MAAM,EAAE,QAAQ,CAAC,CAAC,UAAU;EACvC,UAAU,EAAE,QAAQ,CAAC,UAAU,CAAC,UAAU;EAC3C,CAAC,CACD,QAAQ,MAAM,OAAO,KAAK,EAAE,CAAC,SAAS,GAAG,EACxC,SAAS,+BACV,CAAC;CACL,CAAC;;;;;AAMF,SAAgB,iBACd,MACA,SACA;AACA,QAAO,OAAO;EACZ,YAAY,OAAO;GACjB,SAAS,gBAAgB,MACvB,OAAO,EAAE,UAAmC;IAC1C,MAAM,WAAW,MAAM,mBAAmB;IAC1C,MAAM,WAAW,MAAM,IAAI,uBAAuB,kBAAkB;AAEpE,WAAO;KACL,GAAG;KACH,GAAI,YAAY,EAAE,UAAU;KAC7B;KAEJ;GACD,WAAW,gBACR,MAAM,qBAAqB,CAC3B,SAAS,OAAO,EAAE,YAA+B;AAChD,WAAOA,UAAiB,MAAM;KAC9B;GACJ,gBAAgB,gBACb,MACC,EAAE,OAAO;IACP,IAAI,EAAE,QAAQ;IACd,UAAU,EAAE,QAAQ,CAAC,UAAU;IAChC,CAAC,CACH,CACA,SAAS,OAAO,EAAE,YAA+B;AAChD,WAAOC,eAAsB,MAAM;KACnC;GACL,CAAC;EAGF,MAAM,iBAAiB,GAAG,MAAM,QAAQ;EACzC,CAAC"}
1
+ {"version":3,"file":"controller.mjs","names":["updateAppService","updateAiMemoryService"],"sources":["../../src/server/controller.ts"],"sourcesContent":["import {\n getAppCatalogData,\n updateAiMemory as updateAiMemoryService,\n updateApp as updateAppService,\n} from '../modules/appCatalog/service'\nimport type { AppCatalogData } from '../types'\nimport type { Resource } from '../types/common/appCatalogTypes'\n\nimport type { BetterAuth } from '../modules/auth/auth'\nimport { createAuthRouter } from '../modules/auth/authRouter.js'\nimport { createCommentsRouter } from '../modules/comments/commentsRouter'\nimport { publicProcedure, router, t } from './trpcSetup'\nimport { z } from 'zod'\n\nconst updateAppInputSchema = z.object({\n id: z.string(),\n data: z\n .object({\n displayName: z.string().optional(),\n abbreviation: z.string().max(20).nullable().optional(),\n slug: z.string().optional(),\n appUrl: z.string().optional(),\n description: z.string().optional(),\n sources: z.array(z.string()).optional(),\n aiPrompt: z.string().nullable().optional(),\n })\n .refine((d) => Object.keys(d).length > 0, {\n message: 'At least one field required',\n }),\n})\n\n/**\n * Create the main tRPC router with optional auth instance\n * @param auth - Optional Better Auth instance for auth-related queries\n */\nexport function createTrpcRouter(\n auth?: BetterAuth,\n options?: { devLoginEnabled?: boolean },\n) {\n return router({\n appCatalog: router({\n getData: publicProcedure.query(\n async ({ ctx }): Promise<AppCatalogData> => {\n const baseData = await getAppCatalogData()\n const versions = await ctx.companySpecificBackend.getVersionInfo?.()\n\n return {\n ...baseData,\n ...(versions && { versions }),\n }\n },\n ),\n updateApp: publicProcedure\n .input(updateAppInputSchema)\n .mutation(async ({ input }): Promise<Resource> => {\n return updateAppService(input)\n }),\n updateAiMemory: publicProcedure\n .input(\n z.object({\n id: z.string(),\n aiMemory: z.string().nullable(),\n }),\n )\n .mutation(async ({ input }): Promise<Resource> => {\n return updateAiMemoryService(input)\n }),\n }),\n\n comments: createCommentsRouter(),\n\n // Auth routes (requires auth instance)\n auth: createAuthRouter(t, auth, options),\n })\n}\n\nexport type TRPCRouter = ReturnType<typeof createTrpcRouter>\n"],"mappings":";;;;;;;AAcA,MAAM,uBAAuB,EAAE,OAAO;CACpC,IAAI,EAAE,QAAQ;CACd,MAAM,EACH,OAAO;EACN,aAAa,EAAE,QAAQ,CAAC,UAAU;EAClC,cAAc,EAAE,QAAQ,CAAC,IAAI,GAAG,CAAC,UAAU,CAAC,UAAU;EACtD,MAAM,EAAE,QAAQ,CAAC,UAAU;EAC3B,QAAQ,EAAE,QAAQ,CAAC,UAAU;EAC7B,aAAa,EAAE,QAAQ,CAAC,UAAU;EAClC,SAAS,EAAE,MAAM,EAAE,QAAQ,CAAC,CAAC,UAAU;EACvC,UAAU,EAAE,QAAQ,CAAC,UAAU,CAAC,UAAU;EAC3C,CAAC,CACD,QAAQ,MAAM,OAAO,KAAK,EAAE,CAAC,SAAS,GAAG,EACxC,SAAS,+BACV,CAAC;CACL,CAAC;;;;;AAMF,SAAgB,iBACd,MACA,SACA;AACA,QAAO,OAAO;EACZ,YAAY,OAAO;GACjB,SAAS,gBAAgB,MACvB,OAAO,EAAE,UAAmC;IAC1C,MAAM,WAAW,MAAM,mBAAmB;IAC1C,MAAM,WAAW,MAAM,IAAI,uBAAuB,kBAAkB;AAEpE,WAAO;KACL,GAAG;KACH,GAAI,YAAY,EAAE,UAAU;KAC7B;KAEJ;GACD,WAAW,gBACR,MAAM,qBAAqB,CAC3B,SAAS,OAAO,EAAE,YAA+B;AAChD,WAAOA,UAAiB,MAAM;KAC9B;GACJ,gBAAgB,gBACb,MACC,EAAE,OAAO;IACP,IAAI,EAAE,QAAQ;IACd,UAAU,EAAE,QAAQ,CAAC,UAAU;IAChC,CAAC,CACH,CACA,SAAS,OAAO,EAAE,YAA+B;AAChD,WAAOC,eAAsB,MAAM;KACnC;GACL,CAAC;EAEF,UAAU,sBAAsB;EAGhC,MAAM,iBAAiB,GAAG,MAAM,QAAQ;EACzC,CAAC"}
@@ -0,0 +1,12 @@
1
+ //#region src/utils/cookies.ts
2
+ /** Parse a single cookie value from the Cookie header. */
3
+ function getCookie(req, name) {
4
+ const cookies = req.headers.cookie ?? "";
5
+ if (!cookies) return void 0;
6
+ const match = cookies.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
7
+ return match?.[1] !== void 0 ? decodeURIComponent(match[1]) : void 0;
8
+ }
9
+
10
+ //#endregion
11
+ export { getCookie };
12
+ //# sourceMappingURL=cookies.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cookies.mjs","names":[],"sources":["../../src/utils/cookies.ts"],"sourcesContent":["/** Parse a single cookie value from the Cookie header. */\nexport function getCookie(\n req: { headers: { cookie?: string } },\n name: string,\n): string | undefined {\n const cookies = req.headers.cookie ?? ''\n if (!cookies) return undefined\n const match = cookies.match(new RegExp(`(?:^|;\\\\s*)${name}=([^;]*)`))\n return match?.[1] !== undefined ? decodeURIComponent(match[1]) : undefined\n}\n"],"mappings":";;AACA,SAAgB,UACd,KACA,MACoB;CACpB,MAAM,UAAU,IAAI,QAAQ,UAAU;AACtC,KAAI,CAAC,QAAS,QAAO;CACrB,MAAM,QAAQ,QAAQ,MAAM,IAAI,OAAO,cAAc,KAAK,UAAU,CAAC;AACrE,QAAO,QAAQ,OAAO,SAAY,mBAAmB,MAAM,GAAG,GAAG"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@igstack/app-catalog-backend-core",
3
- "version": "1.0.1",
3
+ "version": "2.0.0-alpha-20260914211846",
4
4
  "description": "Backend core library for App Catalog",
5
5
  "homepage": "https://github.com/lislon/app-catalog",
6
6
  "repository": {
@@ -45,8 +45,8 @@
45
45
  "tsyringe": "^4.10.0",
46
46
  "yaml": "^2.8.0",
47
47
  "zod": "^4.3.5",
48
- "@igstack/app-catalog-shared-core": "1.0.1",
49
- "@igstack/app-catalog-table-sync": "1.0.1"
48
+ "@igstack/app-catalog-table-sync": "2.0.0-alpha-20260914211846",
49
+ "@igstack/app-catalog-shared-core": "2.0.0-alpha-20260914211846"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@tanstack/vite-config": "^0.4.3",
@@ -73,7 +73,7 @@
73
73
  "engines": {
74
74
  "node": ">=24"
75
75
  },
76
- "gitHead": "8714433058e9d88d5033d7625c7a62c143d3cea2",
76
+ "gitHead": "3f9a92b589949710c57e594dd5856b85821e5c0c",
77
77
  "scripts": {
78
78
  "build": "tsdown",
79
79
  "build:lenient": "tsdown",
@@ -0,0 +1,22 @@
1
+ -- CreateTable
2
+ CREATE TABLE "DbComment" (
3
+ "id" TEXT NOT NULL,
4
+ "resourceId" TEXT NOT NULL,
5
+ "authorHash" TEXT NOT NULL,
6
+ "authorAlias" TEXT NOT NULL,
7
+ "body" TEXT NOT NULL,
8
+ "editedAt" TIMESTAMP(3),
9
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
10
+ "updatedAt" TIMESTAMP(3) NOT NULL,
11
+
12
+ CONSTRAINT "DbComment_pkey" PRIMARY KEY ("id")
13
+ );
14
+
15
+ -- CreateIndex
16
+ CREATE INDEX "DbComment_resourceId_createdAt_idx" ON "DbComment"("resourceId", "createdAt");
17
+
18
+ -- CreateIndex
19
+ CREATE INDEX "DbComment_authorHash_idx" ON "DbComment"("authorHash");
20
+
21
+ -- AddForeignKey
22
+ ALTER TABLE "DbComment" ADD CONSTRAINT "DbComment_resourceId_fkey" FOREIGN KEY ("resourceId") REFERENCES "DbResource"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -152,6 +152,7 @@ model DbResource {
152
152
  /// [TierVariant[]]
153
153
  tiers Json?
154
154
  sourceRefs SourceReference[]
155
+ comments DbComment[]
155
156
  // Self-referential tree: sub-resources point to their parent
156
157
  parentSlug String?
157
158
  parent DbResource? @relation("ResourceTree", fields: [parentSlug], references: [slug])
@@ -181,6 +182,32 @@ model DbResource {
181
182
  @@index([parentSlug])
182
183
  }
183
184
 
185
+ /// A visitor's comment on one resource.
186
+ ///
187
+ /// The catalog is browsable without logging in, so a comment carries no user id.
188
+ /// Instead the server issues an opaque visitor token in an httpOnly cookie and
189
+ /// stores its hash: enough to let an author edit or delete their own comment for
190
+ /// a short window, and nothing a reader can forge.
191
+ model DbComment {
192
+ id String @id @default(cuid())
193
+ // The resource's id, not its slug — a slug is user-facing and gets renamed.
194
+ resourceId String
195
+ resource DbResource @relation(fields: [resourceId], references: [id], onDelete: Cascade)
196
+ // sha256 of the visitor token. Authorises edit/delete; never sent to a client.
197
+ authorHash String
198
+ // The pseudonym shown next to the comment, stored rather than derived at render
199
+ // time so editing the wordlist later cannot rename everybody's history.
200
+ authorAlias String
201
+ body String
202
+ // Set on a later edit, so the UI can mark a comment as edited. Null when never edited.
203
+ editedAt DateTime?
204
+ createdAt DateTime @default(now())
205
+ updatedAt DateTime @updatedAt
206
+
207
+ @@index([resourceId, createdAt])
208
+ @@index([authorHash])
209
+ }
210
+
184
211
  model DbAppTagDefinition {
185
212
  id String @id @default(cuid())
186
213
  prefix String @unique
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ VISITOR_COOKIE,
4
+ aliasForHash,
5
+ resolveVisitor,
6
+ } from '../modules/comments/visitorIdentity'
7
+
8
+ /**
9
+ * A comment carries no user id — the catalog is browsable logged out. The visitor
10
+ * cookie is therefore the only thing standing between an author editing their own
11
+ * comment and anyone editing anyone's, and the stored alias is the only name a
12
+ * reader ever sees.
13
+ */
14
+ describe('visitor identity', () => {
15
+ function reqWith(cookie?: string) {
16
+ return { headers: cookie ? { cookie } : {} }
17
+ }
18
+
19
+ it('issues an httpOnly cookie when the browser has none', () => {
20
+ const res = { cookie: vi.fn() }
21
+ const visitor = resolveVisitor(reqWith(), res)
22
+
23
+ expect(visitor).not.toBeNull()
24
+ expect(res.cookie).toHaveBeenCalledTimes(1)
25
+ const call = res.cookie.mock.calls[0] ?? []
26
+ expect(call[0]).toBe(VISITOR_COOKIE)
27
+ expect(call[1]).toMatch(/^[0-9a-f-]{36}$/)
28
+ expect(
29
+ call[2],
30
+ 'a token page scripts can read is a token anyone can forge',
31
+ ).toMatchObject({ httpOnly: true, sameSite: 'lax', path: '/' })
32
+ })
33
+
34
+ it('reuses an existing token without re-issuing it', () => {
35
+ const res = { cookie: vi.fn() }
36
+ const visitor = resolveVisitor(
37
+ reqWith(`other=1; ${VISITOR_COOKIE}=fixed-token; another=2`),
38
+ res,
39
+ )
40
+
41
+ expect(res.cookie).not.toHaveBeenCalled()
42
+ // sha256('fixed-token')
43
+ expect(visitor?.hash).toHaveLength(64)
44
+ expect(
45
+ resolveVisitor(reqWith(`${VISITOR_COOKIE}=fixed-token`), res)?.hash,
46
+ 'the same browser must resolve to the same author every time',
47
+ ).toBe(visitor?.hash)
48
+ })
49
+
50
+ it('never stores the raw token as the author key', () => {
51
+ const visitor = resolveVisitor(reqWith(`${VISITOR_COOKIE}=fixed-token`))
52
+ expect(visitor?.hash).not.toContain('fixed-token')
53
+ })
54
+
55
+ it('gives a request with no cookie and no response no identity at all', () => {
56
+ // Internally-created contexts have no response to set a cookie on, and must
57
+ // not silently share one identity.
58
+ expect(resolveVisitor(reqWith())).toBeNull()
59
+ })
60
+
61
+ it('derives a stable two-word alias, varying both words', () => {
62
+ const alias = aliasForHash('a'.repeat(64))
63
+ expect(alias).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/)
64
+ expect(aliasForHash('a'.repeat(64))).toBe(alias)
65
+
66
+ // The two words come from independent slices of the hash: hashes sharing a
67
+ // first slice must still differ in the second.
68
+ const shared = '00000000'
69
+ expect(aliasForHash(`${shared}00000001${'0'.repeat(48)}`)).not.toBe(
70
+ aliasForHash(`${shared}00000002${'0'.repeat(48)}`),
71
+ )
72
+ })
73
+ })
@@ -62,6 +62,16 @@ export type DbApprovalMethod = Prisma.DbApprovalMethodModel
62
62
  *
63
63
  */
64
64
  export type DbResource = Prisma.DbResourceModel
65
+ /**
66
+ * Model DbComment
67
+ * A visitor's comment on one resource.
68
+ *
69
+ * The catalog is browsable without logging in, so a comment carries no user id.
70
+ * Instead the server issues an opaque visitor token in an httpOnly cookie and
71
+ * stores its hash: enough to let an author edit or delete their own comment for
72
+ * a short window, and nothing a reader can forge.
73
+ */
74
+ export type DbComment = Prisma.DbCommentModel
65
75
  /**
66
76
  * Model DbAppTagDefinition
67
77
  *
@@ -86,6 +86,16 @@ export type DbApprovalMethod = Prisma.DbApprovalMethodModel
86
86
  *
87
87
  */
88
88
  export type DbResource = Prisma.DbResourceModel
89
+ /**
90
+ * Model DbComment
91
+ * A visitor's comment on one resource.
92
+ *
93
+ * The catalog is browsable without logging in, so a comment carries no user id.
94
+ * Instead the server issues an opaque visitor token in an httpOnly cookie and
95
+ * stores its hash: enough to let an author edit or delete their own comment for
96
+ * a short window, and nothing a reader can forge.
97
+ */
98
+ export type DbComment = Prisma.DbCommentModel
89
99
  /**
90
100
  * Model DbAppTagDefinition
91
101
  *