@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.
@@ -0,0 +1,161 @@
1
+ /**
2
+ * What a feedback submission may attach, and where it is written.
3
+ *
4
+ * A screenshot is most of what a reviewer sends, which is why
5
+ * stacksjs/stacks#2872 asks for it. It is also the one part of this feature
6
+ * that is an unauthenticated write, so every decision is here rather than in
7
+ * the action: a limit that can only be reached through an HTTP request with a
8
+ * crafted file is a limit nobody tests.
9
+ *
10
+ * The existing `FileUploadAction` is deliberately not reused. It takes `disk`
11
+ * and `path` from the request, which is right for a signed-in dashboard user
12
+ * and an arbitrary-write primitive for a feedback link.
13
+ */
14
+
15
+ /**
16
+ * One file per submission.
17
+ *
18
+ * A reviewer sends one screenshot of one thing; the brief asks for one thing
19
+ * per message so each can be picked up on its own. More than one is a
20
+ * multi-file upload API on an endpoint that authenticates nobody.
21
+ */
22
+ export const FEEDBACK_ATTACHMENT_MAX_FILES = 1
23
+
24
+ /**
25
+ * 5 MB, which is a generous full-window PNG screenshot on a retina display
26
+ * and far below what would make this worth using as free object storage. The
27
+ * route's rate limit caps the rest.
28
+ */
29
+ export const FEEDBACK_ATTACHMENT_MAX_BYTES = 5 * 1024 * 1024
30
+
31
+ /**
32
+ * The image types accepted, and the extension each is stored under.
33
+ *
34
+ * SVG is deliberately absent and must stay absent. It is XML, it can carry
35
+ * script, and it would be uploaded by an unauthenticated stranger and later
36
+ * opened by a signed-in operator looking at their own dashboard, which is
37
+ * the shape of a stored cross-site scripting bug rather than a screenshot.
38
+ *
39
+ * Nothing here is decided from the declared `Content-Type`, which the
40
+ * submitter writes.
41
+ */
42
+ export const FEEDBACK_ATTACHMENT_TYPES = {
43
+ 'image/png': 'png',
44
+ 'image/jpeg': 'jpg',
45
+ 'image/webp': 'webp',
46
+ 'image/gif': 'gif',
47
+ } as const
48
+
49
+ export type FeedbackAttachmentType = keyof typeof FEEDBACK_ATTACHMENT_TYPES
50
+
51
+ /** How many leading bytes {@link sniffImageType} needs. */
52
+ export const SNIFF_BYTES = 12
53
+
54
+ function startsWith(bytes: Uint8Array, signature: readonly number[], offset = 0): boolean {
55
+ if (bytes.length < offset + signature.length)
56
+ return false
57
+ return signature.every((byte, index) => bytes[offset + index] === byte)
58
+ }
59
+
60
+ /**
61
+ * The type the bytes actually are, or null.
62
+ *
63
+ * Read from the file's own leading bytes, because the declared content type
64
+ * and the filename are both written by whoever is uploading. A `.png` that is
65
+ * an HTML document would otherwise be stored as an image and served as one.
66
+ */
67
+ export function sniffImageType(bytes: Uint8Array): FeedbackAttachmentType | null {
68
+ // \x89PNG\r\n\x1a\n
69
+ if (startsWith(bytes, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]))
70
+ return 'image/png'
71
+ // JPEG's SOI, then any marker.
72
+ if (startsWith(bytes, [0xFF, 0xD8, 0xFF]))
73
+ return 'image/jpeg'
74
+ // RIFF....WEBP: the size field sits between the two, so the second
75
+ // signature is checked at its own offset rather than appended.
76
+ if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes, [0x57, 0x45, 0x42, 0x50], 8))
77
+ return 'image/webp'
78
+ // GIF87a and GIF89a share these four.
79
+ if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38]))
80
+ return 'image/gif'
81
+ return null
82
+ }
83
+
84
+ export type FeedbackAttachmentRefusal = 'empty' | 'too-large' | 'unsupported'
85
+
86
+ export type FeedbackAttachmentVerdict =
87
+ | { ok: true, type: FeedbackAttachmentType, extension: string }
88
+ | { ok: false, reason: FeedbackAttachmentRefusal, message: string }
89
+
90
+ /**
91
+ * Whether a file of this size may be attached at all.
92
+ *
93
+ * Separate from {@link acceptAttachment} because it is the gate that has to
94
+ * be passed BEFORE the bytes are read. The router hands an action an
95
+ * `UploadedFile` wrapper, which exposes `size` synchronously and has no
96
+ * `slice`, so reading the leading bytes means reading the whole file: a
97
+ * caller that sniffed first would load a 2 GB upload into memory to decide
98
+ * it was too large. Checked by itself, nothing is read.
99
+ */
100
+ export function acceptAttachmentSize(size: number): { ok: true } | { ok: false, reason: FeedbackAttachmentRefusal, message: string } {
101
+ if (!Number.isFinite(size) || size <= 0)
102
+ return { ok: false, reason: 'empty', message: 'That file is empty.' }
103
+
104
+ if (size > FEEDBACK_ATTACHMENT_MAX_BYTES) {
105
+ const mb = Math.floor(FEEDBACK_ATTACHMENT_MAX_BYTES / (1024 * 1024))
106
+ return { ok: false, reason: 'too-large', message: `A screenshot must be ${mb} MB or smaller.` }
107
+ }
108
+
109
+ return { ok: true }
110
+ }
111
+
112
+ /**
113
+ * Whether this file may be attached.
114
+ *
115
+ * `size` is the whole file's length and `head` its leading bytes. The size is
116
+ * judged first, through {@link acceptAttachmentSize}, so the order of the
117
+ * refusals is the same whether a caller gates on size separately or not.
118
+ */
119
+ export function acceptAttachment(input: { size: number, head: Uint8Array }): FeedbackAttachmentVerdict {
120
+ const sized = acceptAttachmentSize(input.size)
121
+ if (!sized.ok)
122
+ return sized
123
+
124
+ const type = sniffImageType(input.head)
125
+ if (!type) {
126
+ // Named rather than vague: a reviewer who tried to attach a PDF or a zip
127
+ // should know which of the two rules they hit.
128
+ return { ok: false, reason: 'unsupported', message: 'Attach a PNG, JPEG, WebP or GIF image.' }
129
+ }
130
+
131
+ return { ok: true, type, extension: FEEDBACK_ATTACHMENT_TYPES[type] }
132
+ }
133
+
134
+ const ALLOWED_EXTENSIONS: ReadonlySet<string> = new Set(Object.values(FEEDBACK_ATTACHMENT_TYPES))
135
+
136
+ /**
137
+ * Where the file is written.
138
+ *
139
+ * Every segment is generated. The submitter contributes no part of the path,
140
+ * not even the extension, which comes from the sniffed type: a filename is an
141
+ * untrusted string and path traversal through an upload name is the oldest
142
+ * bug in this category.
143
+ *
144
+ * Under a fixed `feedback/` prefix so an operator can find, audit or sweep
145
+ * the whole lot, and keyed by card so a deleted card's screenshot is one
146
+ * prefix to remove.
147
+ */
148
+ export function feedbackAttachmentPath(cardId: number, id: string, extension: string): string {
149
+ if (!Number.isSafeInteger(cardId) || cardId <= 0)
150
+ throw new Error('A feedback attachment needs the id of the card it belongs to.')
151
+ if (!/^[0-9a-f-]{8,64}$/i.test(id))
152
+ throw new Error(`Refusing to build an attachment path from ${JSON.stringify(id)}.`)
153
+ // An allowlist, not a shape check. `^[a-z0-9]{2,5}$` admitted `phtml`,
154
+ // which is a shape a `.png` can never have and a suffix some web servers
155
+ // will happily execute. The extension always comes from
156
+ // FEEDBACK_ATTACHMENT_TYPES, so membership is the real rule.
157
+ if (!ALLOWED_EXTENSIONS.has(extension))
158
+ throw new Error(`Refusing to build an attachment path with extension ${JSON.stringify(extension)}.`)
159
+
160
+ return `feedback/${cardId}/${id.toLowerCase()}.${extension}`
161
+ }
@@ -0,0 +1,144 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { feedbackNotification, planFeedbackFanout } from './feedback-notifier'
3
+
4
+ /**
5
+ * Who gets told when feedback arrives, and what they are told.
6
+ *
7
+ * Both halves are reachable only by filing a card through a live link against
8
+ * configured channels, so left in the fan-out they would be tested by a
9
+ * reviewer noticing that nobody heard from them.
10
+ */
11
+
12
+ describe('planFeedbackFanout', () => {
13
+ it('sends nothing while it is off', () => {
14
+ expect(planFeedbackFanout(undefined)).toEqual({ send: false, reason: 'disabled' })
15
+ expect(planFeedbackFanout({})).toEqual({ send: false, reason: 'disabled' })
16
+ expect(planFeedbackFanout({ enabled: false, channels: ['chat'] })).toEqual({ send: false, reason: 'disabled' })
17
+ })
18
+
19
+ it('broadcasts chat once, with no recipient, however many are listed', () => {
20
+ // Every listed person would otherwise post the same message into the same
21
+ // Slack channel.
22
+ const plan = planFeedbackFanout({
23
+ enabled: true,
24
+ channels: ['chat'],
25
+ recipients: [{ email: 'a@example.com' }, { email: 'b@example.com' }],
26
+ })
27
+
28
+ expect(plan).toEqual({ send: true, targets: [{ recipient: {}, channels: ['chat'] }], unreachable: [] })
29
+ })
30
+
31
+ it('defaults to chat when no channel is named', () => {
32
+ const plan = planFeedbackFanout({ enabled: true })
33
+ expect(plan.send).toBe(true)
34
+ expect((plan as any).targets).toEqual([{ recipient: {}, channels: ['chat'] }])
35
+ })
36
+
37
+ it('offers a recipient only the channels whose contact field it carries', () => {
38
+ // `notify()` throws when the email channel gets a recipient with no
39
+ // address, and one throw per card is how a half-filled list becomes no
40
+ // notifications at all.
41
+ const plan = planFeedbackFanout({
42
+ enabled: true,
43
+ channels: ['email', 'sms', 'database'],
44
+ recipients: [
45
+ { email: 'chris@example.com' },
46
+ { phone: '+15550000' },
47
+ { userId: 7 },
48
+ { email: 'both@example.com', phone: '+15551111' },
49
+ ],
50
+ })
51
+
52
+ expect(plan.send).toBe(true)
53
+ expect((plan as any).targets).toEqual([
54
+ { recipient: { email: 'chris@example.com' }, channels: ['email'] },
55
+ { recipient: { phone: '+15550000' }, channels: ['sms'] },
56
+ { recipient: { userId: 7 }, channels: ['database'] },
57
+ { recipient: { email: 'both@example.com', phone: '+15551111' }, channels: ['email', 'sms'] },
58
+ ])
59
+ expect((plan as any).unreachable).toEqual([])
60
+ })
61
+
62
+ it('leaves out a recipient whose contact field is blank', () => {
63
+ const plan = planFeedbackFanout({
64
+ enabled: true,
65
+ channels: ['email'],
66
+ recipients: [{ email: ' ' }, { email: 'real@example.com' }],
67
+ })
68
+
69
+ expect((plan as any).targets).toEqual([{ recipient: { email: 'real@example.com' }, channels: ['email'] }])
70
+ })
71
+
72
+ it('reports a channel configured with nobody able to receive it', () => {
73
+ // "Configured and delivering nothing" and "not configured" look the same
74
+ // from outside, and only one of them is a mistake.
75
+ const plan = planFeedbackFanout({
76
+ enabled: true,
77
+ channels: ['chat', 'email'],
78
+ recipients: [{ phone: '+15550000' }],
79
+ })
80
+
81
+ expect(plan.send).toBe(true)
82
+ expect((plan as any).unreachable).toEqual(['email'])
83
+ })
84
+
85
+ it('refuses a channel list that can reach nobody at all', () => {
86
+ expect(planFeedbackFanout({ enabled: true, channels: ['email'] }))
87
+ .toEqual({ send: false, reason: 'no-recipients' })
88
+ expect(planFeedbackFanout({ enabled: true, channels: ['email'], recipients: [{ phone: '+1' }] }))
89
+ .toEqual({ send: false, reason: 'no-recipients' })
90
+ expect(planFeedbackFanout({ enabled: true, channels: [] }))
91
+ .toEqual({ send: false, reason: 'no-channels' })
92
+ })
93
+
94
+ it('drops a channel name it does not know rather than handing it to notify', () => {
95
+ const plan = planFeedbackFanout({ enabled: true, channels: ['chat', 'carrier-pigeon' as any] })
96
+ expect((plan as any).targets).toEqual([{ recipient: {}, channels: ['chat'] }])
97
+ })
98
+ })
99
+
100
+ describe('feedbackNotification', () => {
101
+ const base = {
102
+ boardId: 4,
103
+ cardId: 42,
104
+ title: 'Checkout button does nothing on iPhone',
105
+ label: 'Pawel',
106
+ }
107
+
108
+ it('names the board, the card and the link it came through', () => {
109
+ const { subject, body, data } = feedbackNotification({ ...base, boardName: 'Feedback' })
110
+
111
+ expect(subject).toBe('New feedback on Feedback: Checkout button does nothing on iPhone')
112
+ expect(body).toContain('Board: Feedback (/kanban/4)')
113
+ expect(body).toContain('Card: 42')
114
+ expect(body).toContain('Through the link for: Pawel')
115
+ expect(data).toEqual({ boardId: 4, cardId: 42, label: 'Pawel' })
116
+ })
117
+
118
+ it('falls back to the board id when the name could not be read', () => {
119
+ for (const boardName of [undefined, null, ' ']) {
120
+ const { subject, body } = feedbackNotification({ ...base, boardName })
121
+ expect(subject).toContain('board 4')
122
+ expect(body).toContain('Board: board 4 (/kanban/4)')
123
+ }
124
+ })
125
+
126
+ it('quotes the description and cuts a long one', () => {
127
+ const short = feedbackNotification({ ...base, description: 'Tapped it twice, nothing happened.' })
128
+ expect(short.body).toContain('Tapped it twice, nothing happened.')
129
+
130
+ const long = feedbackNotification({ ...base, description: 'x'.repeat(5000) })
131
+ expect(long.body).toContain('...')
132
+ // A 10k-character report must not become a 10k-character SMS.
133
+ expect(long.body.length).toBeLessThan(1200)
134
+ })
135
+
136
+ it('carries no token, by having none to carry', () => {
137
+ // The whole message is built from the board, the card and the label. An
138
+ // operator revokes by label, so the raw token never needs to appear and
139
+ // notifications are stored and forwarded by things nobody audits.
140
+ const { subject, body, data } = feedbackNotification({ ...base, boardName: 'Feedback', description: 'd' })
141
+ const everything = `${subject}\n${body}\n${JSON.stringify(data)}`
142
+ expect(everything).not.toMatch(/[0-9a-f]{64}/)
143
+ })
144
+ })
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Telling somebody that feedback arrived.
3
+ *
4
+ * A card filed through a feedback link lands on a board nobody is necessarily
5
+ * looking at, so the feature's own brief asks for a notification "so feedback
6
+ * is not found by accident" (stacksjs/stacks#2872).
7
+ *
8
+ * The fan-out mirrors `dashboard.ci.notifications`, down to the channel and
9
+ * recipient shapes, rather than inventing a second convention for the same
10
+ * job.
11
+ *
12
+ * Two deliberate differences from the CI notifier:
13
+ *
14
+ * - No cooldown. CI needs one because a flapping repo re-reports the same
15
+ * fact; every feedback card is a different person saying a different thing,
16
+ * so a cooldown would not de-duplicate anything, it would silently drop
17
+ * reports. The route's rate limit is what bounds the volume.
18
+ * - A recipient is matched to the channels it can actually carry. `notify()`
19
+ * throws when the `email` channel gets a recipient with no address, and a
20
+ * throw per card is how a half-filled recipient list turns into no
21
+ * notifications at all.
22
+ */
23
+
24
+ export type FeedbackNotificationChannel = 'email' | 'sms' | 'chat' | 'database'
25
+
26
+ export interface FeedbackNotificationRecipient {
27
+ email?: string
28
+ phone?: string
29
+ userId?: number
30
+ }
31
+
32
+ export interface FeedbackNotificationSettings {
33
+ enabled?: boolean
34
+ channels?: FeedbackNotificationChannel[]
35
+ recipients?: FeedbackNotificationRecipient[]
36
+ }
37
+
38
+ /** What each channel needs on a recipient before `notify()` will take it. */
39
+ const REQUIRES: Record<FeedbackNotificationChannel, keyof FeedbackNotificationRecipient | null> = {
40
+ email: 'email',
41
+ sms: 'phone',
42
+ database: 'userId',
43
+ // Already scoped to one Slack channel by its own env, so it carries no
44
+ // recipient at all.
45
+ chat: null,
46
+ }
47
+
48
+ export interface FeedbackFanoutTarget {
49
+ recipient: FeedbackNotificationRecipient
50
+ channels: FeedbackNotificationChannel[]
51
+ }
52
+
53
+ export type FeedbackFanout =
54
+ | { send: false, reason: 'disabled' | 'no-channels' | 'no-recipients' }
55
+ | { send: true, targets: FeedbackFanoutTarget[], unreachable: FeedbackNotificationChannel[] }
56
+
57
+ /**
58
+ * Who gets told, and through what.
59
+ *
60
+ * Pure, so the awkward combinations can be read off a test rather than
61
+ * discovered by filing feedback: a channel list with nobody who can receive
62
+ * it, a recipient carrying the wrong contact field for the channel it was
63
+ * listed under, `chat` alongside channels that do need a recipient.
64
+ *
65
+ * `unreachable` names the configured channels that reached nobody. Returned
66
+ * rather than dropped: "configured and delivering nothing" and "not
67
+ * configured" look identical from the outside, and only one of them is a
68
+ * mistake.
69
+ */
70
+ export function planFeedbackFanout(settings: FeedbackNotificationSettings | undefined): FeedbackFanout {
71
+ if (!settings?.enabled)
72
+ return { send: false, reason: 'disabled' }
73
+
74
+ const channels = (settings.channels ?? ['chat']).filter(channel => channel in REQUIRES)
75
+ if (channels.length === 0)
76
+ return { send: false, reason: 'no-channels' }
77
+
78
+ const targets: FeedbackFanoutTarget[] = []
79
+ const reached = new Set<FeedbackNotificationChannel>()
80
+
81
+ // `chat` goes out once with no recipient, however many people are listed:
82
+ // every one of them would otherwise post the same message to the same
83
+ // Slack channel.
84
+ const broadcast = channels.filter(channel => REQUIRES[channel] === null)
85
+ if (broadcast.length > 0) {
86
+ targets.push({ recipient: {}, channels: broadcast })
87
+ for (const channel of broadcast) reached.add(channel)
88
+ }
89
+
90
+ const addressed = channels.filter(channel => REQUIRES[channel] !== null)
91
+ for (const recipient of settings.recipients ?? []) {
92
+ const usable = addressed.filter((channel) => {
93
+ const field = REQUIRES[channel]!
94
+ const value = recipient[field]
95
+ return typeof value === 'string' ? value.trim().length > 0 : value !== undefined && value !== null
96
+ })
97
+ if (usable.length === 0)
98
+ continue
99
+ targets.push({ recipient, channels: usable })
100
+ for (const channel of usable) reached.add(channel)
101
+ }
102
+
103
+ if (targets.length === 0)
104
+ return { send: false, reason: 'no-recipients' }
105
+
106
+ return { send: true, targets, unreachable: channels.filter(channel => !reached.has(channel)) }
107
+ }
108
+
109
+ export interface FeedbackNotificationInput {
110
+ boardId: number
111
+ boardName?: string | null
112
+ cardId: number
113
+ title: string
114
+ description?: string
115
+ /** The link's label, so two reviewers' feedback can be told apart. */
116
+ label: string
117
+ }
118
+
119
+ /** How long a quoted description runs before it is cut. */
120
+ const EXCERPT = 600
121
+
122
+ /**
123
+ * The message itself.
124
+ *
125
+ * No URL. The board lives at `/kanban/{id}` of whatever origin serves the
126
+ * dashboard, which is not always the app's own, and a notification carrying a
127
+ * link that 404s for everybody is worse than one carrying a path.
128
+ *
129
+ * Never the token. The notification says which link a card came through by
130
+ * its label, which is what an operator revokes by.
131
+ */
132
+ export function feedbackNotification(input: FeedbackNotificationInput): {
133
+ subject: string
134
+ body: string
135
+ data: Record<string, unknown>
136
+ } {
137
+ const board = input.boardName?.trim() || `board ${input.boardId}`
138
+ const description = input.description?.trim()
139
+
140
+ const lines = [`${input.title}`, '']
141
+ if (description)
142
+ lines.push(description.length > EXCERPT ? `${description.slice(0, EXCERPT)}...` : description, '')
143
+ lines.push(`Board: ${board} (/kanban/${input.boardId})`)
144
+ lines.push(`Card: ${input.cardId}`)
145
+ lines.push(`Through the link for: ${input.label}`)
146
+
147
+ return {
148
+ subject: `New feedback on ${board}: ${input.title}`,
149
+ body: lines.join('\n'),
150
+ data: {
151
+ boardId: input.boardId,
152
+ cardId: input.cardId,
153
+ label: input.label,
154
+ },
155
+ }
156
+ }
157
+
158
+ /** The board's own name, for the subject line. Null if it cannot be read. */
159
+ async function boardName(boardId: number): Promise<string | null> {
160
+ try {
161
+ const { db } = await import('@stacksjs/database/runtime')
162
+ const row = await (db as any).selectFrom('boards').select(['name']).where('id', '=', boardId).executeTakeFirst()
163
+ const name = row?.name
164
+ return typeof name === 'string' && name.trim() ? name : null
165
+ }
166
+ catch {
167
+ // The message reads fine without it, and a notification is not worth
168
+ // failing over a name.
169
+ return null
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Fan the notification out. Call as `void notifyFeedbackFiled(...)`.
175
+ *
176
+ * The card is already saved by the time this runs, so nothing here may fail
177
+ * the submission: a reviewer must not be told their report did not go through
178
+ * because an SMTP host was down. Every error is logged and swallowed, which is
179
+ * the same contract `runCiFailureNotifier` has.
180
+ */
181
+ export async function notifyFeedbackFiled(input: FeedbackNotificationInput): Promise<void> {
182
+ try {
183
+ const { dashboard } = await import('@stacksjs/config')
184
+ const plan = planFeedbackFanout((dashboard as any)?.feedback?.notifications)
185
+ if (!plan.send)
186
+ return
187
+
188
+ for (const channel of plan.unreachable)
189
+ console.warn(`[feedback-notifier] the ${channel} channel is configured but nobody can receive it`)
190
+
191
+ const { notify } = await import('@stacksjs/notifications')
192
+ // Only now, because it is a query the submission does not need: the hot
193
+ // path has already answered the reviewer, and an app with notifications
194
+ // off never runs this at all.
195
+ const payload = feedbackNotification({ ...input, boardName: input.boardName ?? await boardName(input.boardId) })
196
+
197
+ for (const target of plan.targets) {
198
+ try {
199
+ // `ignorePreferences`, as the CI notifier does: this is an operator
200
+ // alert on a surface they switched on, not a per-user subscription.
201
+ await notify(target.recipient, payload, target.channels, { ignorePreferences: true })
202
+ }
203
+ catch (err) {
204
+ console.warn(`[feedback-notifier] notify failed: ${err instanceof Error ? err.message : String(err)}`)
205
+ }
206
+ }
207
+ }
208
+ catch (err) {
209
+ console.warn(`[feedback-notifier] could not notify: ${err instanceof Error ? err.message : String(err)}`)
210
+ }
211
+ }
@@ -0,0 +1,166 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { createHash } from 'node:crypto'
3
+ import {
4
+ authorizeFeedbackToken,
5
+ hashFeedbackToken,
6
+ looksLikeFeedbackToken,
7
+ mintFeedbackToken,
8
+ parseTokenTime,
9
+ refusalMessage,
10
+ } from './feedback-token'
11
+
12
+ /**
13
+ * The security boundary for an unauthenticated write path.
14
+ *
15
+ * Every branch is tested here rather than through the action, because a
16
+ * decision reachable only via an HTTP request is a decision that does not get
17
+ * tested properly, and this one decides whether a stranger may write to a
18
+ * board (stacksjs/stacks#2872).
19
+ */
20
+
21
+ const VALID = 'a'.repeat(64)
22
+ const FUTURE = new Date(Date.now() + 86_400_000).toISOString()
23
+ const PAST = new Date(Date.now() - 86_400_000).toISOString()
24
+
25
+ describe('hashFeedbackToken', () => {
26
+ it('is SHA-256 hex of the raw value', () => {
27
+ expect(hashFeedbackToken('hello')).toBe(createHash('sha256').update('hello').digest('hex'))
28
+ expect(hashFeedbackToken(VALID)).toHaveLength(64)
29
+ })
30
+ })
31
+
32
+ describe('mintFeedbackToken', () => {
33
+ it('mints a raw token that matches its own hash', () => {
34
+ const { raw, hash } = mintFeedbackToken()
35
+
36
+ expect(looksLikeFeedbackToken(raw)).toBe(true)
37
+ expect(hash).toBe(hashFeedbackToken(raw))
38
+ })
39
+
40
+ it('does not repeat itself', () => {
41
+ // 256 bits, so a collision here means the generator is broken rather
42
+ // than that we got unlucky.
43
+ const mints = new Set(Array.from({ length: 50 }, () => mintFeedbackToken().raw))
44
+ expect(mints.size).toBe(50)
45
+ })
46
+ })
47
+
48
+ describe('looksLikeFeedbackToken', () => {
49
+ it('accepts a raw token', () => {
50
+ expect(looksLikeFeedbackToken(VALID)).toBe(true)
51
+ })
52
+
53
+ it('rejects anything that is not one, before any lookup happens', () => {
54
+ // The point of the shape check: a submit carrying a sentence, a path or a
55
+ // SQL fragment is refused without touching the database.
56
+ for (const candidate of [
57
+ '',
58
+ 'a'.repeat(63),
59
+ 'a'.repeat(65),
60
+ 'A'.repeat(64),
61
+ `${'a'.repeat(63)}g`,
62
+ '../../etc/passwd',
63
+ "' OR 1=1 --",
64
+ null,
65
+ undefined,
66
+ 42,
67
+ { toString: () => VALID },
68
+ ])
69
+ expect(looksLikeFeedbackToken(candidate)).toBe(false)
70
+ })
71
+ })
72
+
73
+ describe('authorizeFeedbackToken', () => {
74
+ it('admits a live token and reports its board', () => {
75
+ expect(authorizeFeedbackToken({ boardId: 7, expiresAt: FUTURE })).toEqual({ ok: true, boardId: 7 })
76
+ expect(authorizeFeedbackToken({ boardId: 7 })).toEqual({ ok: true, boardId: 7 })
77
+ })
78
+
79
+ it('refuses a token it never found', () => {
80
+ expect(authorizeFeedbackToken(null)).toEqual({ ok: false, reason: 'unknown' })
81
+ expect(authorizeFeedbackToken(undefined)).toEqual({ ok: false, reason: 'unknown' })
82
+ })
83
+
84
+ it('refuses a revoked token, which is the point of the design', () => {
85
+ expect(authorizeFeedbackToken({ boardId: 7, revokedAt: PAST })).toEqual({ ok: false, reason: 'revoked' })
86
+ })
87
+
88
+ it('reports revocation ahead of expiry when a token is both', () => {
89
+ // An operator who pulled a link wants to see that they pulled it, not
90
+ // that it later lapsed on its own.
91
+ const verdict = authorizeFeedbackToken({ boardId: 7, revokedAt: PAST, expiresAt: PAST })
92
+ expect(verdict).toEqual({ ok: false, reason: 'revoked' })
93
+ })
94
+
95
+ it('refuses an expired token, counting the exact moment as expired', () => {
96
+ const now = new Date('2026-10-07T12:00:00.000Z')
97
+ expect(authorizeFeedbackToken({ boardId: 7, expiresAt: PAST }, now)).toEqual({ ok: false, reason: 'expired' })
98
+ expect(authorizeFeedbackToken({ boardId: 7, expiresAt: now.toISOString() }, now)).toEqual({ ok: false, reason: 'expired' })
99
+ })
100
+
101
+ it('still reads a stored timestamp when deciding expiry', () => {
102
+ const now = new Date('2026-10-07T12:00:00.000Z')
103
+ expect(authorizeFeedbackToken({ boardId: 7, expires_at: '2026-10-07 18:00:00' }, now)).toEqual({ ok: true, boardId: 7 })
104
+ expect(authorizeFeedbackToken({ boardId: 7, expires_at: '2026-10-07 06:00:00' }, now)).toEqual({ ok: false, reason: 'expired' })
105
+ })
106
+
107
+ it('refuses a token bound to no usable board', () => {
108
+ // Without this the action would resolve a board of `NaN` and the card
109
+ // would land nowhere, or worse, somewhere.
110
+ for (const row of [{}, { boardId: 0 }, { boardId: -1 }, { boardId: 'the board' }, { boardId: 1.5 }])
111
+ expect(authorizeFeedbackToken(row)).toEqual({ ok: false, reason: 'unbound' })
112
+ })
113
+
114
+ it('accepts either spelling the ORM returns', () => {
115
+ expect(authorizeFeedbackToken({ board_id: 9 })).toEqual({ ok: true, boardId: 9 })
116
+ expect(authorizeFeedbackToken({ board_id: 9, revoked_at: PAST })).toEqual({ ok: false, reason: 'revoked' })
117
+ })
118
+
119
+ it('never carries the token into its verdict', () => {
120
+ // The verdict is logged. A reason and a board id are safe; the thing
121
+ // being checked is not.
122
+ const verdict = authorizeFeedbackToken({ boardId: 7, revokedAt: PAST })
123
+ expect(JSON.stringify(verdict)).not.toContain(VALID)
124
+ expect(JSON.stringify(verdict)).not.toContain('revokedAt')
125
+ })
126
+ })
127
+
128
+ describe('parseTokenTime', () => {
129
+ it('reads a SQLite datetime as UTC, in any timezone', () => {
130
+ // `YYYY-MM-DD HH:MM:SS` is what SQLite hands back, and `Date` parses it
131
+ // as local time on some engines. Left alone, a token expires hours early
132
+ // or late depending on the dialect and the machine's clock.
133
+ //
134
+ // Asserted here rather than through a verdict: both readings of a row
135
+ // usually fall on the same side of `now`, so the verdict-level version of
136
+ // this test passed with the normalisation deleted.
137
+ expect(parseTokenTime('2026-10-07 09:00:00')).toBe(Date.parse('2026-10-07T09:00:00Z'))
138
+ expect(parseTokenTime('2026-10-07 23:59:59')).toBe(Date.parse('2026-10-07T23:59:59Z'))
139
+ })
140
+
141
+ it('passes through the forms that are already unambiguous', () => {
142
+ expect(parseTokenTime('2026-10-07T09:00:00Z')).toBe(Date.parse('2026-10-07T09:00:00Z'))
143
+ expect(parseTokenTime(new Date('2026-10-07T09:00:00Z'))).toBe(Date.parse('2026-10-07T09:00:00Z'))
144
+ expect(parseTokenTime(1_760_000_000_000)).toBe(1_760_000_000_000)
145
+ })
146
+
147
+ it('reports an unreadable value as absent rather than as epoch zero', () => {
148
+ // `null` means "does not expire". A timestamp that failed to parse must
149
+ // not become 1970, which would read as expired and lock out a live link.
150
+ expect(parseTokenTime('not a date')).toBeNull()
151
+ expect(parseTokenTime(null)).toBeNull()
152
+ expect(parseTokenTime(undefined)).toBeNull()
153
+ expect(parseTokenTime({})).toBeNull()
154
+ })
155
+ })
156
+
157
+ describe('refusalMessage', () => {
158
+ it('says the same thing however the token failed', () => {
159
+ // Distinguishing unknown from revoked from expired tells someone holding
160
+ // a guessed token which half of the guess was right, and tells a revoked
161
+ // reviewer they were revoked rather than that the link simply stopped
162
+ // working.
163
+ expect(refusalMessage()).toBe('This feedback link is not valid.')
164
+ expect(refusalMessage()).not.toMatch(/revoke|expire|unknown|board/i)
165
+ })
166
+ })