@igstack/app-catalog-backend-core 6.3.0 → 6.4.0

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 (106) hide show
  1. package/dist/db/syncAppCatalog.mjs +1 -0
  2. package/dist/db/syncAppCatalog.mjs.map +1 -1
  3. package/dist/generated/prisma/client.mjs.map +1 -1
  4. package/dist/generated/prisma/commonInputTypes.d.mts +72 -72
  5. package/dist/generated/prisma/commonInputTypes.d.mts.map +1 -1
  6. package/dist/generated/prisma/internal/class.d.mts +17 -5
  7. package/dist/generated/prisma/internal/class.d.mts.map +1 -1
  8. package/dist/generated/prisma/internal/class.mjs +4 -4
  9. package/dist/generated/prisma/internal/class.mjs.map +1 -1
  10. package/dist/generated/prisma/internal/prismaNamespace.d.mts +145 -51
  11. package/dist/generated/prisma/internal/prismaNamespace.d.mts.map +1 -1
  12. package/dist/generated/prisma/models/DbApprovalMethod.d.mts +30 -3
  13. package/dist/generated/prisma/models/DbApprovalMethod.d.mts.map +1 -1
  14. package/dist/generated/prisma/models/DbAsset.d.mts +2 -11
  15. package/dist/generated/prisma/models/DbAsset.d.mts.map +1 -1
  16. package/dist/generated/prisma/models/DbFeedback.d.mts +1565 -0
  17. package/dist/generated/prisma/models/DbFeedback.d.mts.map +1 -0
  18. package/dist/generated/prisma/models/DbFeedbackAttachment.d.mts +1431 -0
  19. package/dist/generated/prisma/models/DbFeedbackAttachment.d.mts.map +1 -0
  20. package/dist/generated/prisma/models/DbResource.d.mts +108 -60
  21. package/dist/generated/prisma/models/DbResource.d.mts.map +1 -1
  22. package/dist/generated/prisma/models/SourceReference.d.mts +1 -1
  23. package/dist/generated/prisma/models.d.mts +4 -3
  24. package/dist/index.d.mts +5 -3
  25. package/dist/index.mjs +5 -3
  26. package/dist/middleware/createAcMiddleware.mjs +2 -2
  27. package/dist/middleware/createAcMiddleware.mjs.map +1 -1
  28. package/dist/middleware/featureRegistry.mjs +3 -1
  29. package/dist/middleware/featureRegistry.mjs.map +1 -1
  30. package/dist/modules/appCatalog/service.d.mts.map +1 -1
  31. package/dist/modules/appCatalog/service.mjs +1 -0
  32. package/dist/modules/appCatalog/service.mjs.map +1 -1
  33. package/dist/modules/feedback/feedbackAttachmentController.d.mts +22 -0
  34. package/dist/modules/feedback/feedbackAttachmentController.d.mts.map +1 -0
  35. package/dist/modules/feedback/feedbackAttachmentController.mjs +192 -0
  36. package/dist/modules/feedback/feedbackAttachmentController.mjs.map +1 -0
  37. package/dist/modules/feedback/feedbackRouter.d.mts +54 -0
  38. package/dist/modules/feedback/feedbackRouter.d.mts.map +1 -0
  39. package/dist/modules/feedback/feedbackRouter.mjs +59 -0
  40. package/dist/modules/feedback/feedbackRouter.mjs.map +1 -0
  41. package/dist/modules/feedback/service.d.mts +42 -0
  42. package/dist/modules/feedback/service.d.mts.map +1 -0
  43. package/dist/modules/feedback/service.mjs +165 -0
  44. package/dist/modules/feedback/service.mjs.map +1 -0
  45. package/dist/modules/visitor/visitorIdentity.d.mts +86 -0
  46. package/dist/modules/visitor/visitorIdentity.d.mts.map +1 -0
  47. package/dist/modules/{comments → visitor}/visitorIdentity.mjs +53 -2
  48. package/dist/modules/visitor/visitorIdentity.mjs.map +1 -0
  49. package/dist/server/acTrpcContext.d.mts +8 -7
  50. package/dist/server/acTrpcContext.d.mts.map +1 -1
  51. package/dist/server/acTrpcContext.mjs +2 -2
  52. package/dist/server/acTrpcContext.mjs.map +1 -1
  53. package/dist/server/controller.d.mts +15 -8
  54. package/dist/server/controller.d.mts.map +1 -1
  55. package/dist/server/controller.mjs +2 -2
  56. package/dist/server/controller.mjs.map +1 -1
  57. package/dist/types/common/appCatalogTypes.d.mts +12 -0
  58. package/dist/types/common/appCatalogTypes.d.mts.map +1 -1
  59. package/dist/types/common/approvalMethodTypes.d.mts +12 -0
  60. package/dist/types/common/approvalMethodTypes.d.mts.map +1 -1
  61. package/package.json +5 -5
  62. package/prisma/migrations/20261004000000_feedback_replaces_comments/migration.sql +74 -0
  63. package/prisma/schema.prisma +84 -24
  64. package/src/__tests__/feedbackAttachmentUpload.test.ts +192 -0
  65. package/src/__tests__/{comments.test.ts → visitorIdentity.test.ts} +1 -1
  66. package/src/db/syncAppCatalog.ts +1 -0
  67. package/src/generated/prisma/browser.ts +22 -7
  68. package/src/generated/prisma/client.ts +22 -7
  69. package/src/generated/prisma/commonInputTypes.ts +85 -85
  70. package/src/generated/prisma/internal/class.ts +18 -8
  71. package/src/generated/prisma/internal/prismaNamespace.ts +143 -47
  72. package/src/generated/prisma/internal/prismaNamespaceBrowser.ts +24 -3
  73. package/src/generated/prisma/models/DbApprovalMethod.ts +29 -1
  74. package/src/generated/prisma/models/DbAsset.ts +0 -12
  75. package/src/generated/prisma/models/DbFeedback.ts +1814 -0
  76. package/src/generated/prisma/models/DbFeedbackAttachment.ts +1661 -0
  77. package/src/generated/prisma/models/DbResource.ts +107 -58
  78. package/src/generated/prisma/models.ts +2 -1
  79. package/src/index.ts +24 -4
  80. package/src/middleware/createAcMiddleware.ts +4 -3
  81. package/src/middleware/featureRegistry.ts +7 -0
  82. package/src/modules/appCatalog/service.ts +1 -0
  83. package/src/modules/feedback/feedbackAttachmentController.ts +248 -0
  84. package/src/modules/feedback/feedbackRouter.ts +102 -0
  85. package/src/modules/feedback/service.ts +295 -0
  86. package/src/modules/{comments → visitor}/visitorIdentity.ts +84 -0
  87. package/src/server/acTrpcContext.ts +9 -8
  88. package/src/server/controller.ts +2 -2
  89. package/src/types/common/appCatalogTypes.ts +12 -0
  90. package/src/types/common/approvalMethodTypes.ts +12 -0
  91. package/dist/generated/prisma/models/DbComment.d.mts +0 -1340
  92. package/dist/generated/prisma/models/DbComment.d.mts.map +0 -1
  93. package/dist/modules/comments/commentsRouter.d.mts +0 -47
  94. package/dist/modules/comments/commentsRouter.d.mts.map +0 -1
  95. package/dist/modules/comments/commentsRouter.mjs +0 -55
  96. package/dist/modules/comments/commentsRouter.mjs.map +0 -1
  97. package/dist/modules/comments/service.d.mts +0 -40
  98. package/dist/modules/comments/service.d.mts.map +0 -1
  99. package/dist/modules/comments/service.mjs +0 -88
  100. package/dist/modules/comments/service.mjs.map +0 -1
  101. package/dist/modules/comments/visitorIdentity.d.mts +0 -15
  102. package/dist/modules/comments/visitorIdentity.d.mts.map +0 -1
  103. package/dist/modules/comments/visitorIdentity.mjs.map +0 -1
  104. package/src/generated/prisma/models/DbComment.ts +0 -1561
  105. package/src/modules/comments/commentsRouter.ts +0 -85
  106. package/src/modules/comments/service.ts +0 -174
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@igstack/app-catalog-backend-core",
3
- "version": "6.3.0",
3
+ "version": "6.4.0",
4
4
  "description": "Backend core library for App Catalog",
5
5
  "homepage": "https://github.com/lislon/app-catalog",
6
6
  "repository": {
@@ -44,8 +44,8 @@
44
44
  "tsyringe": "^4.10.0",
45
45
  "yaml": "^2.8.0",
46
46
  "zod": "^4.3.5",
47
- "@igstack/app-catalog-table-sync": "6.3.0",
48
- "@igstack/app-catalog-shared-core": "6.3.0"
47
+ "@igstack/app-catalog-shared-core": "6.4.0",
48
+ "@igstack/app-catalog-table-sync": "6.4.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@tanstack/vite-config": "^0.4.3",
@@ -72,7 +72,7 @@
72
72
  "engines": {
73
73
  "node": ">=24"
74
74
  },
75
- "gitHead": "1d77cb454ad5fe4c483f1c158c60aa2bc21f72b9",
75
+ "gitHead": "b4737345fd290c2e1616fcb51c4d2e6f4e6ac57c",
76
76
  "scripts": {
77
77
  "build": "tsdown",
78
78
  "build:lenient": "tsdown",
@@ -80,7 +80,7 @@
80
80
  "compile": "tsc --build",
81
81
  "dev": "tsx watch src/index.ts",
82
82
  "fix": "prettier --write ./src && eslint --fix ./src",
83
- "prisma:generate": "prisma generate",
83
+ "prisma:generate": "rm -rf src/generated && prisma generate",
84
84
  "test:eslint": "eslint ./src --max-warnings=0",
85
85
  "test:types": "tsc --noEmit",
86
86
  "test:unit": "vitest",
@@ -0,0 +1,74 @@
1
+ -- Comments become feedback: one object for a note, a correction, or an ask for something
2
+ -- the catalog does not have yet. Ids are carried over rather than regenerated, because the
3
+ -- review tooling prints them and a maintainer may be holding one in a terminal scrollback.
4
+
5
+ -- CreateTable
6
+ CREATE TABLE "DbFeedback" (
7
+ "id" TEXT NOT NULL,
8
+ "resourceId" TEXT,
9
+ "subject" TEXT,
10
+ "authorHash" TEXT NOT NULL,
11
+ "authorAlias" TEXT NOT NULL,
12
+ "body" TEXT,
13
+ "editedAt" TIMESTAMP(3),
14
+ "status" "CommentStatus",
15
+ "reviewerReply" TEXT,
16
+ "reviewedAt" TIMESTAMP(3),
17
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
18
+ "updatedAt" TIMESTAMP(3) NOT NULL,
19
+
20
+ CONSTRAINT "DbFeedback_pkey" PRIMARY KEY ("id")
21
+ );
22
+
23
+ -- CreateTable
24
+ CREATE TABLE "DbFeedbackAttachment" (
25
+ "id" TEXT NOT NULL,
26
+ "feedbackId" TEXT,
27
+ "authorHash" TEXT NOT NULL,
28
+ "content" BYTEA NOT NULL,
29
+ "mimeType" TEXT NOT NULL,
30
+ "fileSize" INTEGER NOT NULL,
31
+ "checksum" TEXT NOT NULL,
32
+ "width" INTEGER,
33
+ "height" INTEGER,
34
+ "originalFilename" TEXT NOT NULL,
35
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
36
+
37
+ CONSTRAINT "DbFeedbackAttachment_pkey" PRIMARY KEY ("id")
38
+ );
39
+
40
+ -- CreateIndex
41
+ CREATE INDEX "DbFeedback_resourceId_createdAt_idx" ON "DbFeedback"("resourceId", "createdAt");
42
+
43
+ -- CreateIndex
44
+ CREATE INDEX "DbFeedback_authorHash_idx" ON "DbFeedback"("authorHash");
45
+
46
+ -- CreateIndex
47
+ CREATE INDEX "DbFeedback_status_createdAt_idx" ON "DbFeedback"("status", "createdAt");
48
+
49
+ -- CreateIndex
50
+ CREATE INDEX "DbFeedbackAttachment_feedbackId_idx" ON "DbFeedbackAttachment"("feedbackId");
51
+
52
+ -- CreateIndex
53
+ -- Covers both the ownership check on submit and the orphan sweep.
54
+ CREATE INDEX "DbFeedbackAttachment_authorHash_createdAt_idx" ON "DbFeedbackAttachment"("authorHash", "createdAt");
55
+
56
+ -- AddForeignKey
57
+ ALTER TABLE "DbFeedback" ADD CONSTRAINT "DbFeedback_resourceId_fkey" FOREIGN KEY ("resourceId") REFERENCES "DbResource"("id") ON DELETE CASCADE ON UPDATE CASCADE;
58
+
59
+ -- AddForeignKey
60
+ ALTER TABLE "DbFeedbackAttachment" ADD CONSTRAINT "DbFeedbackAttachment_feedbackId_fkey" FOREIGN KEY ("feedbackId") REFERENCES "DbFeedback"("id") ON DELETE CASCADE ON UPDATE CASCADE;
61
+
62
+ -- Carry every existing comment across, ids and review state intact. `body` was NOT NULL on
63
+ -- DbComment and is nullable here, so nothing needs coercing.
64
+ INSERT INTO "DbFeedback" (
65
+ "id", "resourceId", "subject", "authorHash", "authorAlias", "body",
66
+ "editedAt", "status", "reviewerReply", "reviewedAt", "createdAt", "updatedAt"
67
+ )
68
+ SELECT
69
+ "id", "resourceId", NULL, "authorHash", "authorAlias", "body",
70
+ "editedAt", "status", "reviewerReply", "reviewedAt", "createdAt", "updatedAt"
71
+ FROM "DbComment";
72
+
73
+ -- DropTable
74
+ DROP TABLE "DbComment";
@@ -115,6 +115,7 @@ model DbApprovalMethod {
115
115
  slug String @id
116
116
  type ApprovalMethodType
117
117
  displayName String
118
+ shortName String? // One or two words for tight spots (the detail card's access tab); falls back to displayName
118
119
  /// [ApprovalMethodConfig] - Type-specific configuration
119
120
  config Json?
120
121
  deprecatedAliases String[] @default([]) // Old URLs that should redirect to this method
@@ -156,8 +157,9 @@ model DbResource {
156
157
  tiers Json?
157
158
  /// [QuickJump[]]
158
159
  quickJumps Json?
160
+ childrenLabel String? // Plural noun for this resource's children ("Accounts"); set on the parent, names its tab
159
161
  sourceRefs SourceReference[]
160
- comments DbComment[]
162
+ feedback DbFeedback[]
161
163
  // Self-referential tree: sub-resources point to their parent
162
164
  parentSlug String?
163
165
  parent DbResource? @relation("ResourceTree", fields: [parentSlug], references: [slug])
@@ -195,37 +197,95 @@ enum CommentStatus {
195
197
  acknowledged
196
198
  }
197
199
 
198
- /// A visitor's comment on one resource.
200
+ /// One piece of visitor input about the catalog: a note, a correction, or an ask for
201
+ /// something that is not in the catalog yet.
199
202
  ///
200
- /// The catalog is browsable without logging in, so a comment carries no user id.
201
- /// Instead the server issues an opaque visitor token in an httpOnly cookie and
202
- /// stores its hash: enough to let an author edit or delete their own comment for
203
- /// a short window, and nothing a reader can forge.
204
- model DbComment {
205
- id String @id @default(cuid())
206
- // The resource's id, not its slug — a slug is user-facing and gets renamed.
207
- resourceId String
208
- resource DbResource @relation(fields: [resourceId], references: [id], onDelete: Cascade)
209
- // sha256 of the visitor token. Authorises edit/delete; never sent to a client.
203
+ /// Browsable without logging in, so this carries no user id. The server issues an
204
+ /// opaque visitor token in an httpOnly cookie and stores its hash: enough to let an
205
+ /// author edit or withdraw their own, and nothing a reader can forge.
206
+ ///
207
+ /// There is deliberately no `kind` discriminator. "Is anyone waiting on this" is
208
+ /// `status IS NULL`, and "is this about an entry or about the catalog" is whether
209
+ /// `resourceId` is set — so an enum would encode nothing the row does not already say,
210
+ /// and asking a visitor to classify their own report costs more than it buys.
211
+ model DbFeedback {
212
+ id String @id @default(cuid())
213
+
214
+ /// Null for a catalog-level ask ("please add X") — there is no entry to hang it on.
215
+ /// The resource's id, not its slug: a slug is user-facing and gets renamed.
216
+ resourceId String?
217
+ resource DbResource? @relation(fields: [resourceId], references: [id], onDelete: Cascade)
218
+
219
+ /// What the visitor was looking for when nothing matched. Set only when resourceId is
220
+ /// null — it is the whole substance of a new-app ask, and the thing we research.
221
+ subject String?
222
+
223
+ /// sha256 of the visitor token. Authorises edit/withdraw; never sent to a client.
210
224
  authorHash String
211
- // The pseudonym shown next to the comment, stored rather than derived at render
212
- // time so editing the wordlist later cannot rename everybody's history.
225
+ /// The pseudonym shown alongside, stored rather than derived at render time so
226
+ /// widening the wordlist later cannot rename everybody's history.
213
227
  authorAlias String
214
- body String
215
- // Set on a later edit, so the UI can mark a comment as edited. Null when never edited.
216
- editedAt DateTime?
217
- // What a maintainer did with the comment. Null until someone reviews it; the UI
218
- // shows a label for the other two so a commenter can see their note landed.
219
- // Written out of band (review tooling), never by a visitor.
220
- status CommentStatus?
221
- // One short answer from the maintainers, shown under the comment body.
228
+
229
+ /// Optional: a bare flag with no words is a valid signal, and the cheapest one a
230
+ /// passing visitor can send.
231
+ body String?
232
+
233
+ attachments DbFeedbackAttachment[]
234
+
235
+ /// Set on a later edit, so the UI can mark it edited. Null when never edited.
236
+ editedAt DateTime?
237
+
238
+ /// What a maintainer did with it. Null until reviewed — which is also what "open"
239
+ /// means, so pending needs no stored value. Written out of band by review tooling,
240
+ /// never by a visitor.
241
+ status CommentStatus?
222
242
  reviewerReply String?
223
243
  reviewedAt DateTime?
224
- createdAt DateTime @default(now())
225
- updatedAt DateTime @updatedAt
244
+
245
+ createdAt DateTime @default(now())
246
+ updatedAt DateTime @updatedAt
226
247
 
227
248
  @@index([resourceId, createdAt])
228
249
  @@index([authorHash])
250
+ /// Drives the unreviewed counts on both surfaces: open items, newest first.
251
+ @@index([status, createdAt])
252
+ }
253
+
254
+ /// An image attached to one piece of feedback.
255
+ ///
256
+ /// Deliberately not a DbAsset: that model's `name` is `@unique` and that namespace holds
257
+ /// published, reviewed assets, so an unreviewed stranger's upload must not be able to
258
+ /// squat `jira.svg`. Promoting an attached logo into a real icon is an explicit
259
+ /// maintainer step through the existing upsertAsset.
260
+ model DbFeedbackAttachment {
261
+ id String @id @default(cuid())
262
+
263
+ /// Null between upload and submit: an image is stored before the feedback row exists,
264
+ /// so abandoning the form leaves an orphan image rather than half-written feedback.
265
+ /// A sweep deletes orphans older than a day.
266
+ feedbackId String?
267
+ feedback DbFeedback? @relation(fields: [feedbackId], references: [id], onDelete: Cascade)
268
+
269
+ /// Who uploaded it, so submitting can only claim your OWN unclaimed images. Without
270
+ /// this, `feedbackId` being a single FK means attaching a guessed id would MOVE that
271
+ /// image off somebody else's feedback.
272
+ authorHash String
273
+
274
+ /// Re-encoded by sharp before storage, so this is always one of our own formats and
275
+ /// never whatever bytes arrived.
276
+ content Bytes
277
+ mimeType String
278
+ fileSize Int
279
+ checksum String
280
+ width Int?
281
+ height Int?
282
+ /// The uploader's filename, shown to a maintainer. Never used as a path or a key.
283
+ originalFilename String
284
+
285
+ createdAt DateTime @default(now())
286
+
287
+ @@index([feedbackId])
288
+ @@index([authorHash, createdAt])
229
289
  }
230
290
 
231
291
  model DbAppTagDefinition {
@@ -0,0 +1,192 @@
1
+ // @vitest-environment node
2
+ import express from 'express'
3
+ import type { AddressInfo } from 'node:net'
4
+ import sharp from 'sharp'
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6
+ import * as dbClient from '../db/client'
7
+ import type { PrismaClient } from '../generated/prisma/client'
8
+ import { registerFeedbackAttachmentController } from '../modules/feedback/feedbackAttachmentController'
9
+
10
+ /**
11
+ * Drives the real route over HTTP — multer, sharp and all — because every property
12
+ * worth asserting here is about what the middleware chain does to bytes a stranger
13
+ * supplied, and a unit test of the handler alone would skip all of it.
14
+ */
15
+ const startApp = async () => {
16
+ const app = express()
17
+ const router = express.Router()
18
+ registerFeedbackAttachmentController(router, {
19
+ basePath: '/api/feedback-attachments',
20
+ })
21
+ app.use(router)
22
+ const server = app.listen(0)
23
+ await new Promise((resolve) => server.once('listening', resolve))
24
+ const { port } = server.address() as AddressInfo
25
+ return { server, base: `http://127.0.0.1:${port}` }
26
+ }
27
+
28
+ const create = vi.fn()
29
+ const deleteMany = vi.fn()
30
+
31
+ /** The bytes handed to persistence — i.e. what would actually be stored. */
32
+ const storedRows = () => create.mock.calls.map(([{ data }]) => data)
33
+
34
+ const postImages = (
35
+ base: string,
36
+ files: { body: Buffer; type: string; name: string }[],
37
+ cookie?: string,
38
+ ) => {
39
+ const form = new FormData()
40
+ for (const f of files) {
41
+ form.append(
42
+ 'image',
43
+ new Blob([new Uint8Array(f.body)], { type: f.type }),
44
+ f.name,
45
+ )
46
+ }
47
+ return fetch(`${base}/api/feedback-attachments/upload`, {
48
+ method: 'POST',
49
+ body: form,
50
+ headers: cookie ? { cookie } : {},
51
+ })
52
+ }
53
+
54
+ /** The visitor cookie the first response minted, in a form fetch will send back. */
55
+ const visitorCookie = (res: Response): string => {
56
+ const raw = res.headers.getSetCookie().find((c) => c.startsWith('ac_cid='))
57
+ return raw ? (raw.split(';')[0] ?? '') : ''
58
+ }
59
+
60
+ const png = (width: number, height: number) =>
61
+ sharp({
62
+ create: { width, height, channels: 3, background: { r: 10, g: 20, b: 30 } },
63
+ })
64
+ .png()
65
+ .toBuffer()
66
+
67
+ describe('feedback attachment upload', () => {
68
+ let server: Awaited<ReturnType<typeof startApp>>['server']
69
+ let base: string
70
+
71
+ beforeEach(async () => {
72
+ create.mockReset()
73
+ deleteMany.mockReset()
74
+ deleteMany.mockResolvedValue({ count: 0 })
75
+ create.mockImplementation(({ data }) => ({
76
+ id: `att-${create.mock.calls.length}`,
77
+ ...data,
78
+ }))
79
+ vi.spyOn(dbClient, 'getDbClient').mockReturnValue({
80
+ dbFeedbackAttachment: { create, deleteMany },
81
+ } as unknown as PrismaClient)
82
+ vi.spyOn(console, 'error').mockImplementation(() => {})
83
+ ;({ server, base } = await startApp())
84
+ })
85
+
86
+ afterEach(() => {
87
+ server.close()
88
+ vi.mocked(console.error).mockRestore()
89
+ })
90
+
91
+ /**
92
+ * The whole security model in one assertion: nothing a visitor uploads is ever the
93
+ * thing we store. Decoding to pixels and re-encoding is what drops EXIF, trailing
94
+ * data and anything polyglot — a content-type check would wave all of it through.
95
+ */
96
+ it('stores its own re-encoded webp, never the uploaded bytes', async () => {
97
+ const original = await png(120, 80)
98
+ const res = await postImages(base, [
99
+ { body: original, type: 'image/png', name: 'shot.png' },
100
+ ])
101
+
102
+ expect(res.status).toBe(201)
103
+ const [row] = storedRows()
104
+ expect(row.mimeType).toBe('image/webp')
105
+ expect(Buffer.from(row.content).equals(original)).toBe(false)
106
+ // Really a webp, by its own magic bytes rather than by what we labelled it.
107
+ expect(Buffer.from(row.content).subarray(8, 12).toString()).toBe('WEBP')
108
+ expect(row.width).toBe(120)
109
+ expect(row.height).toBe(80)
110
+ expect(row.fileSize).toBe(row.content.byteLength)
111
+ })
112
+
113
+ it('rasterises a script-bearing SVG, so no script survives storage', async () => {
114
+ const svg = Buffer.from(
115
+ '<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40">' +
116
+ '<rect width="40" height="40" fill="green"/><script>alert(1)</script></svg>',
117
+ )
118
+ const res = await postImages(base, [
119
+ { body: svg, type: 'image/svg+xml', name: 'logo.svg' },
120
+ ])
121
+
122
+ expect(res.status).toBe(201)
123
+ const [row] = storedRows()
124
+ expect(row.mimeType).toBe('image/webp')
125
+ expect(Buffer.from(row.content).includes('alert')).toBe(false)
126
+ })
127
+
128
+ it('refuses something that is not an image, whatever it claims to be', async () => {
129
+ const res = await postImages(base, [
130
+ { body: Buffer.from('just text'), type: 'image/png', name: 'fake.png' },
131
+ ])
132
+
133
+ expect(res.status).toBe(400)
134
+ expect(await res.json()).toEqual({
135
+ error: 'Not a readable image: fake.png',
136
+ })
137
+ expect(storedRows()).toHaveLength(0)
138
+ })
139
+
140
+ /**
141
+ * Multer's limit errors used to reach express's default handler, which answers a
142
+ * public endpoint with an HTML stack trace carrying absolute server paths.
143
+ */
144
+ it('answers its own limits as JSON, with no stack trace', async () => {
145
+ const small = await png(10, 10)
146
+ const res = await postImages(
147
+ base,
148
+ Array.from({ length: 4 }, (_, i) => ({
149
+ body: small,
150
+ type: 'image/png',
151
+ name: `s${i}.png`,
152
+ })),
153
+ )
154
+
155
+ expect(res.status).toBe(400)
156
+ expect(await res.json()).toEqual({ error: 'Up to 3 images' })
157
+ expect(res.headers.get('content-type')).toContain('application/json')
158
+ })
159
+
160
+ it('records the uploader, and leaves the image unclaimed until it is submitted', async () => {
161
+ const res = await postImages(base, [
162
+ { body: await png(20, 20), type: 'image/png', name: 'a.png' },
163
+ ])
164
+
165
+ expect(res.status).toBe(201)
166
+ const [row] = storedRows()
167
+ expect(row.authorHash).toMatch(/^[0-9a-f]{16,}$/)
168
+ // Unset on purpose: the feedback row does not exist yet, so abandoning the form
169
+ // leaves an orphan image rather than half-written feedback.
170
+ expect(row.feedbackId).toBeUndefined()
171
+ })
172
+
173
+ it('cuts a browser off after its hourly budget', async () => {
174
+ const small = await png(8, 8)
175
+ const file = { body: small, type: 'image/png', name: 'r.png' }
176
+
177
+ // The first request mints the visitor; reusing its cookie is what makes the
178
+ // remaining 20 count against ONE browser rather than twenty strangers.
179
+ const first = await postImages(base, [file])
180
+ expect(first.status).toBe(201)
181
+ const cookie = visitorCookie(first)
182
+ expect(cookie).not.toBe('')
183
+
184
+ const codes = [first.status]
185
+ for (let i = 0; i < 20; i++) {
186
+ codes.push((await postImages(base, [file], cookie)).status)
187
+ }
188
+
189
+ expect(codes.filter((c) => c === 201)).toHaveLength(20)
190
+ expect(codes.at(-1)).toBe(429)
191
+ })
192
+ })
@@ -3,7 +3,7 @@ import {
3
3
  VISITOR_COOKIE,
4
4
  aliasForHash,
5
5
  resolveVisitor,
6
- } from '../modules/comments/visitorIdentity'
6
+ } from '../modules/visitor/visitorIdentity'
7
7
 
8
8
  /**
9
9
  * A comment carries no user id — the catalog is browsable logged out. The visitor
@@ -313,6 +313,7 @@ export async function syncAppCatalog(
313
313
  urlIssues: resource.urlIssues ?? [],
314
314
  tiers: resource.tiers ?? null,
315
315
  quickJumps: resource.quickJumps ?? null,
316
+ childrenLabel: resource.childrenLabel ?? null,
316
317
  // Fields from former SubResource
317
318
  parentSlug: resource.parentSlug ?? null,
318
319
  tier: resource.tier ?? null,
@@ -63,15 +63,30 @@ export type DbApprovalMethod = Prisma.DbApprovalMethodModel
63
63
  */
64
64
  export type DbResource = Prisma.DbResourceModel
65
65
  /**
66
- * Model DbComment
67
- * A visitor's comment on one resource.
66
+ * Model DbFeedback
67
+ * One piece of visitor input about the catalog: a note, a correction, or an ask for
68
+ * something that is not in the catalog yet.
68
69
  *
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.
70
+ * Browsable without logging in, so this carries no user id. The server issues an
71
+ * opaque visitor token in an httpOnly cookie and stores its hash: enough to let an
72
+ * author edit or withdraw their own, and nothing a reader can forge.
73
+ *
74
+ * There is deliberately no `kind` discriminator. "Is anyone waiting on this" is
75
+ * `status IS NULL`, and "is this about an entry or about the catalog" is whether
76
+ * `resourceId` is set — so an enum would encode nothing the row does not already say,
77
+ * and asking a visitor to classify their own report costs more than it buys.
78
+ */
79
+ export type DbFeedback = Prisma.DbFeedbackModel
80
+ /**
81
+ * Model DbFeedbackAttachment
82
+ * An image attached to one piece of feedback.
83
+ *
84
+ * Deliberately not a DbAsset: that model's `name` is `@unique` and that namespace holds
85
+ * published, reviewed assets, so an unreviewed stranger's upload must not be able to
86
+ * squat `jira.svg`. Promoting an attached logo into a real icon is an explicit
87
+ * maintainer step through the existing upsertAsset.
73
88
  */
74
- export type DbComment = Prisma.DbCommentModel
89
+ export type DbFeedbackAttachment = Prisma.DbFeedbackAttachmentModel
75
90
  /**
76
91
  * Model DbAppTagDefinition
77
92
  *
@@ -87,15 +87,30 @@ export type DbApprovalMethod = Prisma.DbApprovalMethodModel
87
87
  */
88
88
  export type DbResource = Prisma.DbResourceModel
89
89
  /**
90
- * Model DbComment
91
- * A visitor's comment on one resource.
90
+ * Model DbFeedback
91
+ * One piece of visitor input about the catalog: a note, a correction, or an ask for
92
+ * something that is not in the catalog yet.
92
93
  *
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.
94
+ * Browsable without logging in, so this carries no user id. The server issues an
95
+ * opaque visitor token in an httpOnly cookie and stores its hash: enough to let an
96
+ * author edit or withdraw their own, and nothing a reader can forge.
97
+ *
98
+ * There is deliberately no `kind` discriminator. "Is anyone waiting on this" is
99
+ * `status IS NULL`, and "is this about an entry or about the catalog" is whether
100
+ * `resourceId` is set — so an enum would encode nothing the row does not already say,
101
+ * and asking a visitor to classify their own report costs more than it buys.
102
+ */
103
+ export type DbFeedback = Prisma.DbFeedbackModel
104
+ /**
105
+ * Model DbFeedbackAttachment
106
+ * An image attached to one piece of feedback.
107
+ *
108
+ * Deliberately not a DbAsset: that model's `name` is `@unique` and that namespace holds
109
+ * published, reviewed assets, so an unreviewed stranger's upload must not be able to
110
+ * squat `jira.svg`. Promoting an attached logo into a real icon is an explicit
111
+ * maintainer step through the existing upsertAsset.
97
112
  */
98
- export type DbComment = Prisma.DbCommentModel
113
+ export type DbFeedbackAttachment = Prisma.DbFeedbackAttachmentModel
99
114
  /**
100
115
  * Model DbAppTagDefinition
101
116
  *