@stacksjs/defaults 0.75.89 → 0.75.91

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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-actions
3
- description: Use when working with Stacks server actions - creating actions in app/Actions/, auto-generated API actions from the useApi model trait, the 658 default framework actions (auth, dashboard, commerce, content, deployment, jobs), action request/response handling, or action registration. Covers @stacksjs/actions and storage/framework/defaults/app/Actions/.
3
+ description: Use when working with Stacks server actions - creating actions in app/Actions/, auto-generated API actions from the useApi model trait, the 663 default framework actions (auth, dashboard, commerce, content, deployment, jobs), action request/response handling, or action registration. Covers @stacksjs/actions and storage/framework/defaults/app/Actions/.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -66,7 +66,7 @@ globalThis.toggleDark = toggleDark
66
66
  - **Custom Functions**: From `resources/functions/` (counter, dark mode, GPX, geo utilities)
67
67
 
68
68
  ### Server Auto-Imports (100+)
69
- - **All ORM Models**: User, Post, Author, Product, Order, Payment, Customer, etc. (105 models)
69
+ - **All ORM Models**: User, Post, Author, Product, Order, Payment, Customer, etc. (107 models)
70
70
  - **Request Models**: UserRequest, PostRequest, OrderRequest, etc.
71
71
  - **Actions**: Action types and helpers
72
72
  - **Schema**: validation schema builder
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-dashboard
3
- description: Use when building or customizing the Stacks admin dashboard, including dashboard pages, model management views, analytics widgets, commerce dashboards, content management, settings panels, deployment monitoring, job/queue management, or the 408 built-in dashboard components. Covers the dashboard system at storage/framework/defaults/.
3
+ description: Use when building or customizing the Stacks admin dashboard, including dashboard pages, model management views, analytics widgets, commerce dashboards, content management, settings panels, deployment monitoring, job/queue management, or the 409 built-in dashboard components. Covers the dashboard system at storage/framework/defaults/.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -8,7 +8,7 @@ allowed-tools: Read Edit Write Bash Grep Glob
8
8
 
9
9
  # Stacks Dashboard
10
10
 
11
- The Stacks admin dashboard provides a full-featured admin panel with 100+ route views, 408 components, and a multi-section layout.
11
+ The Stacks admin dashboard provides a full-featured admin panel with 100+ route views, 409 components, and a multi-section layout.
12
12
 
13
13
  ## Key Paths
14
14
  - Dashboard components: `storage/framework/defaults/resources/components/Dashboard/`
@@ -12,7 +12,7 @@ allowed-tools: Read Edit Write Bash Grep Glob
12
12
  - Database package: `storage/framework/core/database/src/`
13
13
  - Configuration: `config/database.ts`
14
14
  - QB config: `config/query-builder.ts`
15
- - Migrations: `database/migrations/` (235 migration files, `.sql` format)
15
+ - Migrations: `database/migrations/` (237 migration files, `.sql` format)
16
16
  - QB state: `.qb/`
17
17
  - ORM: `storage/framework/orm/`
18
18
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-migrations
3
- description: Use when working with database migrations in a Stacks application - creating migration files, running migrations, fresh migration (drop + recreate), seeding after migration, migration file naming conventions, or the 235 built-in migration files. For the database API itself (queries, connections, SQL helpers), see stacks-database.
3
+ description: Use when working with database migrations in a Stacks application - creating migration files, running migrations, fresh migration (drop + recreate), seeding after migration, migration file naming conventions, or the 237 built-in migration files. For the database API itself (queries, connections, SQL helpers), see stacks-database.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript, SQLite >= 3.47.2
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -119,6 +119,6 @@ The framework includes migrations for all built-in models:
119
119
  - Do not commit a second snapshot under `.qb/`; that indicates a missing `snapshotDir` configuration
120
120
  - If a generated SQLite migration rebuilds tables, test it against a copy of the current database and run `PRAGMA integrity_check` plus `PRAGMA foreign_key_check`
121
121
  - `--seed` flag after `migrate:fresh` seeds the database with factory data
122
- - 235 migration files exist by default for all framework models
122
+ - 237 migration files exist by default for all framework models
123
123
  - SQLite >= 3.47.2 is required (system requirement)
124
124
  - For the database API (queries, connections), see the `stacks-database` skill
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-models
3
- description: Use when working with data models in Stacks - the defineModel() API, model attributes with validation and factories, relationships (hasOne/hasMany/belongsTo/belongsToMany), traits (useAuth, useUuid, useTimestamps, useSearch, useApi, billable, taggable, categorizable, commentable, likeable, observe), computed properties (get/set), model generation, and the 105 built-in framework models. Covers model definitions and storage/framework/defaults/app/Models/.
3
+ description: Use when working with data models in Stacks - the defineModel() API, model attributes with validation and factories, relationships (hasOne/hasMany/belongsTo/belongsToMany), traits (useAuth, useUuid, useTimestamps, useSearch, useApi, billable, taggable, categorizable, commentable, likeable, observe), computed properties (get/set), model generation, and the 107 built-in framework models. Covers model definitions and storage/framework/defaults/app/Models/.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript, SQLite >= 3.47.2
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -304,7 +304,7 @@ honoured: fewer rows are seeded rather than a duplicate combination.
304
304
  ## Built-in models by category
305
305
 
306
306
  The ones below are worth knowing by name. They are a selection, not the set:
307
- `storage/framework/defaults/app/Models/` holds 105, and that directory is the
307
+ `storage/framework/defaults/app/Models/` holds 107, and that directory is the
308
308
  authority. This section said "All 62 built-in models by category" while
309
309
  listing fewer than that against 102 on disk, so an agent reading to the end
310
310
  had no way to tell it was short.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-orm
3
- description: Use when working with the Stacks ORM - defining models with defineModel(), model relationships (hasOne, hasMany, belongsTo, belongsToMany, morphOne, hasManyThrough), attributes, traits, factories, computed properties, query building, transactions, or the 105 built-in models. Covers @stacksjs/orm, storage/framework/orm/, and storage/framework/defaults/app/Models/.
3
+ description: Use when working with the Stacks ORM - defining models with defineModel(), model relationships (hasOne, hasMany, belongsTo, belongsToMany, morphOne, hasManyThrough), attributes, traits, factories, computed properties, query building, transactions, or the 107 built-in models. Covers @stacksjs/orm, storage/framework/orm/, and storage/framework/defaults/app/Models/.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript, SQLite >= 3.47.2
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -11,7 +11,7 @@ allowed-tools: Read Edit Write Bash Grep Glob
11
11
  ## Key Paths
12
12
  - Core ORM package: `storage/framework/core/orm/src/`
13
13
  - ORM implementation: `storage/framework/orm/`
14
- - Model definitions: `storage/framework/defaults/app/Models/` (105 models)
14
+ - Model definitions: `storage/framework/defaults/app/Models/` (107 models)
15
15
  - Application models: `app/Models/`
16
16
  - Default model templates: `storage/framework/defaults/app/Models/`
17
17
  - ORM type globals: `storage/framework/types/orm-globals.d.ts`
@@ -0,0 +1,229 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { Action } from '@stacksjs/actions/runtime'
4
+ import { db, getDatabaseDialect, sqlHelpers } from '@stacksjs/database/runtime'
5
+ import { response } from '@stacksjs/router'
6
+ import { kanbanActionError } from '../Kanban/kanban-response'
7
+ import {
8
+ acceptAttachment,
9
+ acceptAttachmentSize,
10
+ FEEDBACK_ATTACHMENT_MAX_FILES,
11
+ feedbackAttachmentPath,
12
+ SNIFF_BYTES,
13
+ } from './feedback-attachment'
14
+ import { notifyFeedbackFiled } from './feedback-notifier'
15
+ import {
16
+ authorizeFeedbackToken,
17
+ FEEDBACK_MAX_DESCRIPTION as MAX_DESCRIPTION,
18
+ FEEDBACK_MAX_TITLE as MAX_TITLE,
19
+ hashFeedbackToken,
20
+ looksLikeFeedbackToken,
21
+ refusalMessage,
22
+ } from './feedback-token'
23
+
24
+ interface FeedbackInput {
25
+ title?: unknown
26
+ description?: unknown
27
+ }
28
+
29
+ /**
30
+ * Write the screenshot and record it against the card.
31
+ *
32
+ * Runs after the card exists, because the path is keyed by card id. A failure
33
+ * here is logged and swallowed: the report is already saved and is worth more
34
+ * than the picture, and telling the reviewer their submission failed would
35
+ * invite a resubmit that files the card twice.
36
+ */
37
+ async function storeAttachment(
38
+ cardId: number,
39
+ attachment: { bytes: Uint8Array, mimeType: string, extension: string },
40
+ ): Promise<void> {
41
+ try {
42
+ const { dashboard } = await import('@stacksjs/config')
43
+ // `local` is private storage under `storage/app`. The default is not read
44
+ // from `filesystems.driver`, which an app may point at `public`: an
45
+ // unauthenticated stranger's upload does not belong in a web-served
46
+ // directory.
47
+ const disk = String((dashboard as any)?.feedback?.attachments?.disk || 'local')
48
+ const path = feedbackAttachmentPath(cardId, randomUUID(), attachment.extension)
49
+
50
+ const { Storage } = await import('@stacksjs/storage')
51
+ await Storage.disk(disk as never).write(path, attachment.bytes)
52
+
53
+ const { CardAttachment } = await import('@stacksjs/orm')
54
+ await CardAttachment.create({
55
+ cardId,
56
+ disk,
57
+ path,
58
+ mimeType: attachment.mimeType,
59
+ sizeBytes: attachment.bytes.byteLength,
60
+ })
61
+ }
62
+ catch (err) {
63
+ console.warn(`[FeedbackStoreAction] card ${cardId} was filed without its screenshot: ${err instanceof Error ? err.message : String(err)}`)
64
+ }
65
+ }
66
+
67
+ /**
68
+ * `POST /api/feedback/{token}`.
69
+ *
70
+ * The one unauthenticated write path the dashboard has, so it is deliberately
71
+ * narrow: a feedback link may add a card to the one board its token names,
72
+ * and may not read anything (stacksjs/stacks#2872).
73
+ *
74
+ * Every refusal answers 404 with the same sentence. A 401 would confirm the
75
+ * link once existed, and distinguishing unknown from revoked from expired
76
+ * tells a holder of a guessed token which half of the guess was right, or
77
+ * tells a revoked reviewer they were revoked rather than that the link simply
78
+ * stopped working. The reason is logged, not returned.
79
+ *
80
+ * Rate limited at the route rather than here, the way the other
81
+ * unauthenticated endpoints are.
82
+ */
83
+ export default new Action({
84
+ name: 'Feedback Store',
85
+ description: 'Files a card on one board from a revocable feedback link.',
86
+ method: 'POST',
87
+ apiResponse: true,
88
+
89
+ async handle(request: RequestInstance<FeedbackInput>) {
90
+ const raw = String(request.getParam('token') || '')
91
+
92
+ // Shape before lookup, so a submit carrying a sentence, a path or a SQL
93
+ // fragment costs no query.
94
+ if (!looksLikeFeedbackToken(raw))
95
+ return response.json({ message: refusalMessage() }, 404)
96
+
97
+ const body = request.all()
98
+ const title = typeof body.title === 'string' ? body.title.trim() : ''
99
+ if (!title || title.length > MAX_TITLE)
100
+ return response.json({ message: `A title is required, up to ${MAX_TITLE} characters.` }, 400)
101
+
102
+ // Rejected rather than truncated. Silently dropping the end of somebody's
103
+ // report is worse than telling them it was too long.
104
+ const rawDescription = typeof body.description === 'string' ? body.description.trim() : ''
105
+ if (rawDescription.length > MAX_DESCRIPTION)
106
+ return response.json({ message: `The description must be ${MAX_DESCRIPTION} characters or fewer.` }, 400)
107
+ const description = rawDescription || undefined
108
+
109
+ // The screenshot is judged BEFORE anything is written. Filing the card
110
+ // first and then refusing the file would show the reviewer an error for a
111
+ // report that was in fact saved, and the obvious thing to do about an
112
+ // error is to send it again.
113
+ const sent = request.getFiles('screenshot')
114
+ const screenshot = sent.length > 0 ? sent[0] : request.file('screenshot')
115
+ if (sent.length > FEEDBACK_ATTACHMENT_MAX_FILES)
116
+ return response.json({ message: `Attach ${FEEDBACK_ATTACHMENT_MAX_FILES === 1 ? 'one screenshot' : `${FEEDBACK_ATTACHMENT_MAX_FILES} screenshots`} at most.` }, 400)
117
+
118
+ let attachment: { bytes: Uint8Array, mimeType: string, extension: string } | undefined
119
+ if (screenshot) {
120
+ // Size first, and on its own. The router hands this an `UploadedFile`
121
+ // wrapper, which reports `size` synchronously and has no `slice`, so
122
+ // reading the leading bytes means reading the whole file. Sniffing
123
+ // first would load a 2 GB upload into memory to decide it was too
124
+ // large.
125
+ const size = Number(screenshot.size || 0)
126
+ const sized = acceptAttachmentSize(size)
127
+ if (!sized.ok)
128
+ return response.json({ message: sized.message }, 400)
129
+
130
+ const bytes = await screenshot.bytes()
131
+ const verdict = acceptAttachment({ size, head: bytes.subarray(0, SNIFF_BYTES) })
132
+ if (!verdict.ok)
133
+ return response.json({ message: verdict.message }, 400)
134
+
135
+ attachment = {
136
+ bytes,
137
+ // From the bytes, never from `screenshot.mimeType`, which the
138
+ // submitter writes. The dashboard serves the file as this.
139
+ mimeType: verdict.type,
140
+ extension: verdict.extension,
141
+ }
142
+ }
143
+
144
+ try {
145
+ const token = await (db as any)
146
+ .selectFrom('feedback_tokens')
147
+ .where('token', '=', hashFeedbackToken(raw))
148
+ .select(['id', 'board_id', 'revoked_at', 'expires_at', 'label'])
149
+ .executeTakeFirst()
150
+
151
+ const verdict = authorizeFeedbackToken(token)
152
+ if (!verdict.ok) {
153
+ // Logged with its reason and never with the token, so an operator can
154
+ // tell a revoked link from a guessed one without the log holding a
155
+ // live credential.
156
+ console.warn(`[FeedbackStoreAction] refused a submission: ${verdict.reason}`)
157
+ return response.json({ message: refusalMessage() }, 404)
158
+ }
159
+
160
+ const { param } = sqlHelpers(getDatabaseDialect())
161
+
162
+ // The board is checked rather than assumed. `feedback_tokens.board_id`
163
+ // cascades on delete, but SQLite enforces that only with
164
+ // `foreign_keys = ON`, which the framework sets while renaming and not
165
+ // otherwise, so a token can outlive its board.
166
+ const column = await db.unsafe(
167
+ `SELECT id FROM board_columns WHERE board_id = ${param(1)} ORDER BY position ASC, id ASC LIMIT 1`,
168
+ [verdict.boardId],
169
+ ).execute() as Array<{ id: number }>
170
+ const columnId = Number(column?.[0]?.id)
171
+ if (!Number.isInteger(columnId) || columnId <= 0) {
172
+ // The link is valid and there is nowhere to put the card. Not the
173
+ // submitter's problem to diagnose, so they get the same sentence.
174
+ console.warn(`[FeedbackStoreAction] board ${verdict.boardId} has no column to file into`)
175
+ return response.json({ message: refusalMessage() }, 404)
176
+ }
177
+
178
+ const maxRow = await db.unsafe(
179
+ `SELECT COALESCE(MAX(position), -1) AS m FROM cards WHERE column_id = ${param(1)}`,
180
+ [columnId],
181
+ ).execute() as Array<{ m: number }>
182
+ const nextPosition = (Number(maxRow?.[0]?.m ?? -1) + 1) || 0
183
+
184
+ const { Card } = await import('@stacksjs/orm')
185
+ const card = await Card.create({
186
+ columnId,
187
+ boardId: verdict.boardId,
188
+ title,
189
+ description,
190
+ position: nextPosition,
191
+ // No user. A feedback link authenticates nobody, which is the point
192
+ // of it, so the card is attributed by its board and its label rather
193
+ // than by an account that does not exist.
194
+ createdByUserId: null,
195
+ dueDate: null,
196
+ archived: false,
197
+ })
198
+
199
+ await db.unsafe(
200
+ `UPDATE feedback_tokens SET last_used_at = ${param(1)} WHERE id = ${param(2)}`,
201
+ [new Date().toISOString().slice(0, 19).replace('T', ' '), Number(token.id)],
202
+ ).execute()
203
+
204
+ const cardId = Number(card.get('id'))
205
+
206
+ if (attachment)
207
+ await storeAttachment(cardId, attachment)
208
+
209
+ // `void`: the card is saved, so the reviewer is told it went through
210
+ // whatever the notification does. Blocking on an SMTP round-trip would
211
+ // make a mail outage look like a failed submission, and failing on one
212
+ // would be worse.
213
+ void notifyFeedbackFiled({
214
+ boardId: verdict.boardId,
215
+ cardId,
216
+ title,
217
+ description,
218
+ label: String(token.label ?? ''),
219
+ })
220
+
221
+ // Acknowledgement only. Returning the card, its position or anything
222
+ // about the board would make this a read endpoint, which it is not.
223
+ return { filed: true, id: cardId }
224
+ }
225
+ catch (err) {
226
+ return kanbanActionError(err, 'FeedbackStoreAction')
227
+ }
228
+ },
229
+ })
@@ -0,0 +1,192 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import {
3
+ acceptAttachment,
4
+ acceptAttachmentSize,
5
+ FEEDBACK_ATTACHMENT_MAX_BYTES,
6
+ FEEDBACK_ATTACHMENT_TYPES,
7
+ feedbackAttachmentPath,
8
+ sniffImageType,
9
+ SNIFF_BYTES,
10
+ } from './feedback-attachment'
11
+
12
+ /**
13
+ * The attachment rules, which are the whole security boundary of an
14
+ * unauthenticated upload.
15
+ *
16
+ * Every one of these is reachable only by POSTing a crafted file to a live
17
+ * feedback link, so left in the action they would be rules nobody ever
18
+ * checked (stacksjs/stacks#2872).
19
+ */
20
+
21
+ /** Leading bytes of a real file of each type, padded to the sniff window. */
22
+ const HEADS: Record<string, number[]> = {
23
+ png: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D],
24
+ jpeg: [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01],
25
+ webp: [0x52, 0x49, 0x46, 0x46, 0x24, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50],
26
+ gif: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00],
27
+ }
28
+
29
+ const head = (name: keyof typeof HEADS) => new Uint8Array(HEADS[name])
30
+ const bytes = (text: string) => new TextEncoder().encode(text.padEnd(SNIFF_BYTES, ' '))
31
+
32
+ describe('sniffImageType', () => {
33
+ it('reads each accepted format from its own leading bytes', () => {
34
+ expect(sniffImageType(head('png'))).toBe('image/png')
35
+ expect(sniffImageType(head('jpeg'))).toBe('image/jpeg')
36
+ expect(sniffImageType(head('webp'))).toBe('image/webp')
37
+ expect(sniffImageType(head('gif'))).toBe('image/gif')
38
+ })
39
+
40
+ it('reads GIF87a as well as GIF89a', () => {
41
+ const gif87 = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x37, 0x61, 0, 0, 0, 0, 0, 0])
42
+ expect(sniffImageType(gif87)).toBe('image/gif')
43
+ })
44
+
45
+ it('refuses a RIFF container that is not WebP', () => {
46
+ // A WAV file is RIFF too. Checking only the first four bytes would store
47
+ // audio as an image.
48
+ const wav = new Uint8Array([0x52, 0x49, 0x46, 0x46, 0x24, 0, 0, 0, 0x57, 0x41, 0x56, 0x45])
49
+ expect(sniffImageType(wav)).toBeNull()
50
+ })
51
+
52
+ it('refuses SVG, however it is dressed up', () => {
53
+ // SVG is XML and can carry script. It would be uploaded by an
54
+ // unauthenticated stranger and opened by a signed-in operator, which is a
55
+ // stored XSS rather than a screenshot. This must never start passing.
56
+ expect(sniffImageType(bytes('<svg xmlns='))).toBeNull()
57
+ expect(sniffImageType(bytes('<?xml versio'))).toBeNull()
58
+ expect(sniffImageType(bytes('<svg>'))).toBeNull()
59
+ })
60
+
61
+ it('refuses the things people try to pass off as images', () => {
62
+ for (const probe of ['<!DOCTYPE htm', '<html><body>', 'PK\u0003\u0004zip', '%PDF-1.7', '#!/bin/sh', 'GIF', ''])
63
+ expect(sniffImageType(bytes(probe))).toBeNull()
64
+ })
65
+
66
+ it('refuses a file too short to identify rather than reading past its end', () => {
67
+ expect(sniffImageType(new Uint8Array([0x89, 0x50]))).toBeNull()
68
+ expect(sniffImageType(new Uint8Array([0x52, 0x49, 0x46, 0x46]))).toBeNull()
69
+ expect(sniffImageType(new Uint8Array())).toBeNull()
70
+ })
71
+ })
72
+
73
+ describe('acceptAttachmentSize', () => {
74
+ it('is the gate that passes before a byte is read', () => {
75
+ // The router hands an action an `UploadedFile` wrapper, which reports
76
+ // `size` synchronously and has NO `slice` - reading the leading bytes
77
+ // means reading the whole file. The first version of the action sniffed
78
+ // first and threw `screenshot.slice is not a function` on every upload,
79
+ // which only a real multipart POST found: every unit test here passes a
80
+ // plain Uint8Array, and so did the one the action was written against.
81
+ expect(acceptAttachmentSize(120_000)).toEqual({ ok: true })
82
+ expect(acceptAttachmentSize(FEEDBACK_ATTACHMENT_MAX_BYTES)).toEqual({ ok: true })
83
+ })
84
+
85
+ it('refuses an empty or absurd size', () => {
86
+ for (const size of [0, -1, Number.NaN, Number.POSITIVE_INFINITY])
87
+ expect(acceptAttachmentSize(size)).toMatchObject({ ok: false, reason: 'empty' })
88
+ })
89
+
90
+ it('refuses one byte over the cap', () => {
91
+ expect(acceptAttachmentSize(FEEDBACK_ATTACHMENT_MAX_BYTES + 1))
92
+ .toMatchObject({ ok: false, reason: 'too-large' })
93
+ })
94
+
95
+ it('agrees with acceptAttachment, so the order of refusals is the same either way', () => {
96
+ for (const size of [0, 1, FEEDBACK_ATTACHMENT_MAX_BYTES, FEEDBACK_ATTACHMENT_MAX_BYTES + 1]) {
97
+ const gate = acceptAttachmentSize(size)
98
+ const full = acceptAttachment({ size, head: head('png') })
99
+ expect(full.ok).toBe(gate.ok)
100
+ if (!gate.ok && !full.ok)
101
+ expect(full.reason).toBe(gate.reason)
102
+ }
103
+ })
104
+ })
105
+
106
+ describe('acceptAttachment', () => {
107
+ it('accepts a real screenshot and names what to store it as', () => {
108
+ expect(acceptAttachment({ size: 120_000, head: head('png') }))
109
+ .toEqual({ ok: true, type: 'image/png', extension: 'png' })
110
+ // JPEG stores as `jpg`, which is the extension and not the type name.
111
+ expect(acceptAttachment({ size: 1, head: head('jpeg') }))
112
+ .toEqual({ ok: true, type: 'image/jpeg', extension: 'jpg' })
113
+ })
114
+
115
+ it('refuses an empty file', () => {
116
+ for (const size of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
117
+ const verdict = acceptAttachment({ size, head: head('png') })
118
+ expect(verdict).toMatchObject({ ok: false, reason: 'empty' })
119
+ }
120
+ })
121
+
122
+ it('refuses one byte over the cap, and accepts the cap itself', () => {
123
+ expect(acceptAttachment({ size: FEEDBACK_ATTACHMENT_MAX_BYTES, head: head('png') }).ok).toBe(true)
124
+ const over = acceptAttachment({ size: FEEDBACK_ATTACHMENT_MAX_BYTES + 1, head: head('png') })
125
+ expect(over).toMatchObject({ ok: false, reason: 'too-large' })
126
+ expect((over as { message: string }).message).toContain('5 MB')
127
+ })
128
+
129
+ it('checks the size before the bytes, so an enormous upload costs nothing to refuse', () => {
130
+ // Head deliberately empty: a verdict of `too-large` rather than
131
+ // `unsupported` is what says the size was decided first.
132
+ expect(acceptAttachment({ size: 2 ** 31, head: new Uint8Array() }))
133
+ .toMatchObject({ ok: false, reason: 'too-large' })
134
+ })
135
+
136
+ it('refuses a file whose bytes are not an accepted image', () => {
137
+ const verdict = acceptAttachment({ size: 400, head: bytes('%PDF-1.7') })
138
+ expect(verdict).toMatchObject({ ok: false, reason: 'unsupported' })
139
+ // Says which rule was hit. "Could not attach that" leaves a reviewer
140
+ // guessing between the size and the format.
141
+ expect((verdict as { message: string }).message).toContain('PNG')
142
+ })
143
+
144
+ it('tells a reviewer which rule they hit, in words', () => {
145
+ for (const probe of [
146
+ { size: 0, head: head('png') },
147
+ { size: FEEDBACK_ATTACHMENT_MAX_BYTES * 2, head: head('png') },
148
+ { size: 10, head: bytes('nope') },
149
+ ]) {
150
+ const verdict = acceptAttachment(probe)
151
+ expect(verdict.ok).toBe(false)
152
+ expect((verdict as { message: string }).message.length).toBeGreaterThan(10)
153
+ // User-visible copy: no em-dashes, per the project's own rule.
154
+ expect((verdict as { message: string }).message).not.toMatch(/[–—]/)
155
+ }
156
+ })
157
+ })
158
+
159
+ describe('feedbackAttachmentPath', () => {
160
+ const id = '0190f3b4-7c21-4f0a-9a1e-3d5c7b8e9f00'
161
+
162
+ it('writes under a fixed prefix, keyed by card', () => {
163
+ expect(feedbackAttachmentPath(42, id, 'png')).toBe(`feedback/42/${id}.png`)
164
+ })
165
+
166
+ it('lower-cases the generated id so one file has one path', () => {
167
+ expect(feedbackAttachmentPath(42, id.toUpperCase(), 'png')).toBe(`feedback/42/${id}.png`)
168
+ })
169
+
170
+ it('refuses an id that is not one it generated', () => {
171
+ // The submitter contributes no segment of the path, so anything shaped
172
+ // like traversal or an absolute path is a bug upstream rather than input
173
+ // to sanitise.
174
+ for (const bad of ['../../etc/passwd', 'a/b', '/abs', '', 'short', 'id with spaces', 'x'.repeat(65)])
175
+ expect(() => feedbackAttachmentPath(42, bad, 'png')).toThrow('Refusing to build')
176
+ })
177
+
178
+ it('refuses an extension it did not choose', () => {
179
+ for (const bad of ['', 'svg+xml', '../', 'PNG', 'phtml'])
180
+ expect(() => feedbackAttachmentPath(42, id, bad)).toThrow('Refusing to build')
181
+ })
182
+
183
+ it('refuses to build a path without a card', () => {
184
+ for (const bad of [0, -1, 1.5, Number.NaN])
185
+ expect(() => feedbackAttachmentPath(bad, id, 'png')).toThrow('needs the id of the card')
186
+ })
187
+
188
+ it('builds a path for every extension it may be handed', () => {
189
+ for (const extension of Object.values(FEEDBACK_ATTACHMENT_TYPES))
190
+ expect(feedbackAttachmentPath(1, id, extension)).toBe(`feedback/1/${id}.${extension}`)
191
+ })
192
+ })