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

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,154 @@
1
+ import { getDbClient } from '../../db/client'
2
+ import type { Visitor } from './visitorIdentity'
3
+
4
+ /** How long an author may edit or delete their own comment. */
5
+ export const EDIT_WINDOW_MS = 60 * 60 * 1000
6
+
7
+ export const MAX_BODY_LENGTH = 1000
8
+
9
+ /** One comment as a client sees it. Never carries an author hash. */
10
+ export interface CommentView {
11
+ id: string
12
+ authorName: string
13
+ body: string
14
+ createdAt: string
15
+ /** Null when the comment has never been edited. */
16
+ editedAt: string | null
17
+ /** Whether this browser wrote it — what makes Edit and Delete appear. */
18
+ isMine: boolean
19
+ /**
20
+ * The absolute instant this browser's edit window closes, or null when it is not
21
+ * this browser's comment. Absolute rather than a remaining duration so a client
22
+ * with a skewed clock still hides the controls at the same moment the server
23
+ * starts refusing the edit.
24
+ */
25
+ canEditUntil: string | null
26
+ }
27
+
28
+ function toView(
29
+ row: {
30
+ id: string
31
+ authorHash: string
32
+ authorAlias: string
33
+ body: string
34
+ editedAt: Date | null
35
+ createdAt: Date
36
+ },
37
+ visitor: Visitor | null,
38
+ ): CommentView {
39
+ const isMine = !!visitor && row.authorHash === visitor.hash
40
+ return {
41
+ id: row.id,
42
+ authorName: row.authorAlias,
43
+ body: row.body,
44
+ createdAt: row.createdAt.toISOString(),
45
+ editedAt: row.editedAt?.toISOString() ?? null,
46
+ isMine,
47
+ canEditUntil: isMine
48
+ ? new Date(row.createdAt.getTime() + EDIT_WINDOW_MS).toISOString()
49
+ : null,
50
+ }
51
+ }
52
+
53
+ async function resourceIdForSlug(slug: string): Promise<string | null> {
54
+ const resource = await getDbClient().dbResource.findUnique({
55
+ where: { slug },
56
+ select: { id: true },
57
+ })
58
+ return resource?.id ?? null
59
+ }
60
+
61
+ /** Oldest first: a comment thread reads top to bottom. */
62
+ export async function listComments(
63
+ resourceSlug: string,
64
+ visitor: Visitor | null,
65
+ ): Promise<CommentView[]> {
66
+ const resourceId = await resourceIdForSlug(resourceSlug)
67
+ if (!resourceId) return []
68
+
69
+ const rows = await getDbClient().dbComment.findMany({
70
+ where: { resourceId },
71
+ orderBy: { createdAt: 'asc' },
72
+ })
73
+ return rows.map((row) => toView(row, visitor))
74
+ }
75
+
76
+ export class CommentError extends Error {
77
+ constructor(
78
+ readonly reason: 'not-found' | 'not-author' | 'window-closed' | 'empty',
79
+ message: string,
80
+ ) {
81
+ super(message)
82
+ }
83
+ }
84
+
85
+ function normaliseBody(body: string): string {
86
+ const trimmed = body.trim()
87
+ if (!trimmed) throw new CommentError('empty', 'A comment cannot be empty')
88
+ return trimmed.slice(0, MAX_BODY_LENGTH)
89
+ }
90
+
91
+ export async function addComment(
92
+ resourceSlug: string,
93
+ body: string,
94
+ visitor: Visitor,
95
+ ): Promise<CommentView> {
96
+ const resourceId = await resourceIdForSlug(resourceSlug)
97
+ if (!resourceId) {
98
+ throw new CommentError('not-found', `Unknown resource: ${resourceSlug}`)
99
+ }
100
+
101
+ const row = await getDbClient().dbComment.create({
102
+ data: {
103
+ resourceId,
104
+ authorHash: visitor.hash,
105
+ authorAlias: visitor.alias,
106
+ body: normaliseBody(body),
107
+ },
108
+ })
109
+ return toView(row, visitor)
110
+ }
111
+
112
+ /**
113
+ * Load a comment and check this visitor may still change it.
114
+ *
115
+ * The window is enforced here, not in the UI: the controls disappearing is a
116
+ * courtesy, and a request that arrives a second late must still be refused.
117
+ */
118
+ async function loadOwn(id: string, visitor: Visitor) {
119
+ const row = await getDbClient().dbComment.findUnique({ where: { id } })
120
+ if (!row) throw new CommentError('not-found', 'Comment no longer exists')
121
+ if (row.authorHash !== visitor.hash) {
122
+ throw new CommentError('not-author', 'Only the author can change a comment')
123
+ }
124
+ if (Date.now() > row.createdAt.getTime() + EDIT_WINDOW_MS) {
125
+ throw new CommentError(
126
+ 'window-closed',
127
+ 'A comment can only be changed in the first hour',
128
+ )
129
+ }
130
+ return row
131
+ }
132
+
133
+ export async function editComment(
134
+ id: string,
135
+ body: string,
136
+ visitor: Visitor,
137
+ ): Promise<CommentView> {
138
+ await loadOwn(id, visitor)
139
+
140
+ const row = await getDbClient().dbComment.update({
141
+ where: { id },
142
+ data: { body: normaliseBody(body), editedAt: new Date() },
143
+ })
144
+ return toView(row, visitor)
145
+ }
146
+
147
+ export async function deleteComment(
148
+ id: string,
149
+ visitor: Visitor,
150
+ ): Promise<{ id: string }> {
151
+ await loadOwn(id, visitor)
152
+ await getDbClient().dbComment.delete({ where: { id } })
153
+ return { id }
154
+ }
@@ -0,0 +1,146 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { getCookie } from '../../utils/cookies'
3
+
4
+ /** Opaque per-browser token. httpOnly, so page scripts cannot read or forge it. */
5
+ export const VISITOR_COOKIE = 'ac_cid'
6
+
7
+ const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000
8
+
9
+ /**
10
+ * Who the server thinks is commenting.
11
+ *
12
+ * The catalog is browsable without logging in, so there is no session to hang a
13
+ * comment on. `hash` is the only thing that authorises editing or deleting a
14
+ * comment later, and `alias` is the only name a reader ever sees.
15
+ */
16
+ export interface Visitor {
17
+ hash: string
18
+ alias: string
19
+ }
20
+
21
+ const ADJECTIVES = [
22
+ 'Ancient',
23
+ 'Brave',
24
+ 'Calm',
25
+ 'Clever',
26
+ 'Curious',
27
+ 'Eager',
28
+ 'Gentle',
29
+ 'Happy',
30
+ 'Honest',
31
+ 'Humble',
32
+ 'Jolly',
33
+ 'Keen',
34
+ 'Kind',
35
+ 'Lively',
36
+ 'Loyal',
37
+ 'Merry',
38
+ 'Mighty',
39
+ 'Modest',
40
+ 'Noble',
41
+ 'Patient',
42
+ 'Polite',
43
+ 'Proud',
44
+ 'Quiet',
45
+ 'Rapid',
46
+ 'Silent',
47
+ 'Smooth',
48
+ 'Steady',
49
+ 'Subtle',
50
+ 'Sunny',
51
+ 'Swift',
52
+ 'Tidy',
53
+ 'Witty',
54
+ ]
55
+
56
+ const ANIMALS = [
57
+ 'Badger',
58
+ 'Beaver',
59
+ 'Bison',
60
+ 'Crane',
61
+ 'Dolphin',
62
+ 'Falcon',
63
+ 'Ferret',
64
+ 'Finch',
65
+ 'Fox',
66
+ 'Gecko',
67
+ 'Heron',
68
+ 'Ibex',
69
+ 'Jackal',
70
+ 'Kestrel',
71
+ 'Lemur',
72
+ 'Lynx',
73
+ 'Magpie',
74
+ 'Marten',
75
+ 'Mole',
76
+ 'Narwhal',
77
+ 'Ocelot',
78
+ 'Osprey',
79
+ 'Otter',
80
+ 'Panda',
81
+ 'Puffin',
82
+ 'Raven',
83
+ 'Seal',
84
+ 'Shrew',
85
+ 'Stoat',
86
+ 'Tapir',
87
+ 'Vole',
88
+ 'Walrus',
89
+ ]
90
+
91
+ /**
92
+ * A stable, readable name for a visitor hash, e.g. "Curious Ferret".
93
+ *
94
+ * Deterministic, so the same browser keeps the same name across comments — but it
95
+ * is derived once and stored on the row, because widening these lists later would
96
+ * otherwise rename every comment ever written.
97
+ */
98
+ export function aliasForHash(hash: string): string {
99
+ // Two independent slices of the hash, so the two words vary independently.
100
+ const adjective =
101
+ ADJECTIVES[parseInt(hash.slice(0, 8), 16) % ADJECTIVES.length]
102
+ const animal = ANIMALS[parseInt(hash.slice(8, 16), 16) % ANIMALS.length]
103
+ return `${adjective} ${animal}`
104
+ }
105
+
106
+ function hashToken(token: string): string {
107
+ return createHash('sha256').update(token).digest('hex')
108
+ }
109
+
110
+ /**
111
+ * Read the visitor token, issuing one when this browser has none yet.
112
+ *
113
+ * Called on every tRPC request rather than only when commenting: the cookie has to
114
+ * already exist by the time someone clicks Post, and issuing it on the read that
115
+ * renders the comment list is the one moment we know a page is open.
116
+ *
117
+ * `res` is absent on internally-created contexts (see createAcMiddleware), and
118
+ * those never comment — they get an identity derived from the token they carry, if
119
+ * any, and cannot be issued a new one.
120
+ */
121
+ export function resolveVisitor(
122
+ req: { headers: { cookie?: string } },
123
+ res?: {
124
+ cookie: (
125
+ name: string,
126
+ value: string,
127
+ options: Record<string, unknown>,
128
+ ) => unknown
129
+ },
130
+ ): Visitor | null {
131
+ let token = getCookie(req, VISITOR_COOKIE)
132
+
133
+ if (!token) {
134
+ if (!res) return null
135
+ token = randomUUID()
136
+ res.cookie(VISITOR_COOKIE, token, {
137
+ httpOnly: true,
138
+ sameSite: 'lax',
139
+ path: '/',
140
+ maxAge: ONE_YEAR_MS,
141
+ })
142
+ }
143
+
144
+ const hash = hashToken(token)
145
+ return { hash, alias: aliasForHash(hash) }
146
+ }
@@ -1,22 +1,31 @@
1
1
  import type { AppCatalogCompanySpecificBackend } from '../types'
2
+ import type { Visitor } from '../modules/comments/visitorIdentity'
2
3
  import type { User } from 'better-auth/types'
3
4
 
4
5
  export interface AcTrpcContext {
5
6
  companySpecificBackend: AppCatalogCompanySpecificBackend
6
7
  user: User | null
7
8
  isAdmin: boolean
9
+ /**
10
+ * The anonymous browser behind this request, independent of `user` — the catalog
11
+ * is browsable logged out, and comments are attributed to this instead. Null when
12
+ * the context was built without a response to issue the cookie on.
13
+ */
14
+ visitor: Visitor | null
8
15
  }
9
16
 
10
17
  export interface AcTrpcContextOptions {
11
18
  companySpecificBackend: AppCatalogCompanySpecificBackend
12
19
  user?: User | null
13
20
  isAdmin?: boolean
21
+ visitor?: Visitor | null
14
22
  }
15
23
 
16
24
  export function createAcTrpcContext({
17
25
  companySpecificBackend,
18
26
  user = null,
19
27
  isAdmin = false,
28
+ visitor = null,
20
29
  }: AcTrpcContextOptions): AcTrpcContext {
21
- return { companySpecificBackend, user, isAdmin }
30
+ return { companySpecificBackend, user, isAdmin, visitor }
22
31
  }
@@ -8,6 +8,7 @@ import type { Resource } from '../types/common/appCatalogTypes'
8
8
 
9
9
  import type { BetterAuth } from '../modules/auth/auth'
10
10
  import { createAuthRouter } from '../modules/auth/authRouter.js'
11
+ import { createCommentsRouter } from '../modules/comments/commentsRouter'
11
12
  import { publicProcedure, router, t } from './trpcSetup'
12
13
  import { z } from 'zod'
13
14
 
@@ -66,6 +67,8 @@ export function createTrpcRouter(
66
67
  }),
67
68
  }),
68
69
 
70
+ comments: createCommentsRouter(),
71
+
69
72
  // Auth routes (requires auth instance)
70
73
  auth: createAuthRouter(t, auth, options),
71
74
  })
@@ -0,0 +1,10 @@
1
+ /** Parse a single cookie value from the Cookie header. */
2
+ export function getCookie(
3
+ req: { headers: { cookie?: string } },
4
+ name: string,
5
+ ): string | undefined {
6
+ const cookies = req.headers.cookie ?? ''
7
+ if (!cookies) return undefined
8
+ const match = cookies.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`))
9
+ return match?.[1] !== undefined ? decodeURIComponent(match[1]) : undefined
10
+ }