@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.
- package/ai/skills/stacks-actions/SKILL.md +1 -1
- package/ai/skills/stacks-auto-imports/SKILL.md +1 -1
- package/ai/skills/stacks-dashboard/SKILL.md +2 -2
- package/ai/skills/stacks-database/SKILL.md +1 -1
- package/ai/skills/stacks-migrations/SKILL.md +2 -2
- package/ai/skills/stacks-models/SKILL.md +2 -2
- package/ai/skills/stacks-orm/SKILL.md +2 -2
- package/app/Actions/Dashboard/Feedback/FeedbackStoreAction.ts +229 -0
- package/app/Actions/Dashboard/Feedback/feedback-attachment.test.ts +192 -0
- package/app/Actions/Dashboard/Feedback/feedback-attachment.ts +161 -0
- package/app/Actions/Dashboard/Feedback/feedback-notifier.test.ts +144 -0
- package/app/Actions/Dashboard/Feedback/feedback-notifier.ts +211 -0
- package/app/Actions/Dashboard/Feedback/feedback-token.test.ts +166 -0
- package/app/Actions/Dashboard/Feedback/feedback-token.ts +149 -0
- package/app/Actions/Dashboard/Kanban/CardShowAction.ts +12 -6
- package/app/Actions/Dashboard/Kanban/card-attachments.test.ts +125 -0
- package/app/Actions/Dashboard/Kanban/card-attachments.ts +107 -0
- package/app/Models/CardAttachment.ts +103 -0
- package/app/Models/FeedbackToken.ts +129 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/resources/components/Dashboard/Feedback/FeedbackForm.stx +278 -0
- package/resources/components/Dashboard/Kanban/KanbanCardDialog.stx +30 -0
- package/resources/views/feedback/[token].stx +25 -0
- package/routes/dashboard.ts +15 -0
- package/views/dashboard/stores/kanban.ts +25 -1
|
@@ -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
|
+
})
|