@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,149 @@
1
+ import { createHash, randomBytes } from 'node:crypto'
2
+
3
+ /**
4
+ * Whether a feedback link may still file a card, and on which board.
5
+ *
6
+ * Kept pure and separate from the action: this is the whole security boundary
7
+ * for an unauthenticated write path, and a decision that can only be reached
8
+ * through an HTTP request is a decision that does not get tested properly.
9
+ * The action does the lookup and the writing; everything that decides *yes or
10
+ * no* is here.
11
+ *
12
+ * A feedback token is a capability, not an identity. It names a board, it is
13
+ * read only by the intake action, and it never resolves to a user, so it can
14
+ * authenticate nothing. See the `FeedbackToken` model for why a personal
15
+ * access token would have been an account in disguise.
16
+ */
17
+
18
+ /** 256 bits. The raw token is the only secret, so it carries all the entropy. */
19
+ export const FEEDBACK_TOKEN_BYTES = 32
20
+
21
+ /**
22
+ * What a submission may carry, matching `cards.title` and
23
+ * `cards.description`.
24
+ *
25
+ * Here rather than in the action so the form can render the same limits it
26
+ * will be judged against. A `maxlength` the server disagrees with is the
27
+ * worst of both: the reviewer is either stopped from typing something that
28
+ * would have been accepted, or allowed to write a report that is refused on
29
+ * submit.
30
+ */
31
+ export const FEEDBACK_MAX_TITLE = 300
32
+ export const FEEDBACK_MAX_DESCRIPTION = 10_000
33
+
34
+ /** A raw token as it appears in a link, and as the column stores it. */
35
+ const RAW_TOKEN = /^[0-9a-f]{64}$/
36
+
37
+ /**
38
+ * SHA-256 hex, which is what `feedback_tokens.token` holds.
39
+ *
40
+ * Deterministic on purpose: a submit is one indexed lookup. With 256 bits of
41
+ * entropy in the raw value an offline attack on a fast hash is irrelevant,
42
+ * which is the same reasoning `MagicLinkToken` records and deliberately not
43
+ * the bcrypt-and-scan of password resets.
44
+ *
45
+ * `@stacksjs/auth` defines this same one-liner privately three times over
46
+ * (`tokens.ts`, `authentication.ts`, `magic-link.ts`) and exports none of
47
+ * them, so this is a fourth rather than a refactor of a security module in
48
+ * passing.
49
+ */
50
+ export function hashFeedbackToken(raw: string): string {
51
+ return createHash('sha256').update(raw).digest('hex')
52
+ }
53
+
54
+ /** A new link's raw token and the hash to store for it. */
55
+ export function mintFeedbackToken(): { raw: string, hash: string } {
56
+ const raw = randomBytes(FEEDBACK_TOKEN_BYTES).toString('hex')
57
+ return { raw, hash: hashFeedbackToken(raw) }
58
+ }
59
+
60
+ /**
61
+ * Does this even have the shape of a token?
62
+ *
63
+ * Checked before the lookup so a submit carrying a sentence, a path, or a SQL
64
+ * fragment is refused without touching the database.
65
+ */
66
+ export function looksLikeFeedbackToken(raw: unknown): raw is string {
67
+ return typeof raw === 'string' && RAW_TOKEN.test(raw)
68
+ }
69
+
70
+ export type FeedbackTokenRefusal = 'malformed' | 'unknown' | 'revoked' | 'expired' | 'unbound'
71
+
72
+ export type FeedbackTokenVerdict =
73
+ | { ok: true, boardId: number }
74
+ | { ok: false, reason: FeedbackTokenRefusal }
75
+
76
+ /**
77
+ * A `feedback_tokens` row, in either spelling.
78
+ *
79
+ * The ORM hands back camelCase or snake_case depending on the path taken, and
80
+ * the Kanban actions already read `boardId ?? board_id` for the same reason.
81
+ */
82
+ export interface FeedbackTokenRow {
83
+ boardId?: unknown
84
+ board_id?: unknown
85
+ revokedAt?: unknown
86
+ revoked_at?: unknown
87
+ expiresAt?: unknown
88
+ expires_at?: unknown
89
+ }
90
+
91
+ function firstDefined(...values: unknown[]): unknown {
92
+ return values.find(value => value !== undefined && value !== null)
93
+ }
94
+
95
+ /**
96
+ * A stored timestamp as epoch milliseconds, read as UTC.
97
+ *
98
+ * Exported so the UTC reading can be pinned directly. Asserting it through a
99
+ * verdict cannot: both interpretations of a given row usually land on the
100
+ * same side of `now`, so such a test passes whether or not the normalisation
101
+ * below exists, and it did.
102
+ */
103
+ export function parseTokenTime(value: unknown): number | null {
104
+ if (value === undefined || value === null) return null
105
+ if (value instanceof Date) return value.getTime()
106
+ // SQLite hands back `YYYY-MM-DD HH:MM:SS`, which `Date` parses as local
107
+ // time on some engines and UTC on others. Normalising to ISO keeps a token
108
+ // from appearing to expire an hour early or late depending on the dialect.
109
+ if (typeof value === 'string') {
110
+ const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(value) ? `${value.replace(' ', 'T')}Z` : value
111
+ const parsed = Date.parse(normalized)
112
+ return Number.isNaN(parsed) ? null : parsed
113
+ }
114
+ if (typeof value === 'number') return value
115
+ return null
116
+ }
117
+
118
+ /**
119
+ * The verdict for a row that has already been looked up by token hash.
120
+ *
121
+ * Revocation is checked before expiry, so a token that is both reports the
122
+ * reason an operator acted on. The verdict carries a board id and a reason
123
+ * and never the token, so it is safe to log.
124
+ */
125
+ export function authorizeFeedbackToken(row: FeedbackTokenRow | null | undefined, now: Date = new Date()): FeedbackTokenVerdict {
126
+ if (!row) return { ok: false, reason: 'unknown' }
127
+
128
+ if (firstDefined(row.revokedAt, row.revoked_at) !== undefined)
129
+ return { ok: false, reason: 'revoked' }
130
+
131
+ const expiresAt = parseTokenTime(firstDefined(row.expiresAt, row.expires_at))
132
+ if (expiresAt !== null && expiresAt <= now.getTime())
133
+ return { ok: false, reason: 'expired' }
134
+
135
+ const boardId = Number(firstDefined(row.boardId, row.board_id))
136
+ if (!Number.isInteger(boardId) || boardId <= 0)
137
+ return { ok: false, reason: 'unbound' }
138
+
139
+ return { ok: true, boardId }
140
+ }
141
+
142
+ /** What to tell the submitter. Deliberately the same for every refusal. */
143
+ export function refusalMessage(): string {
144
+ // One message for unknown, revoked and expired alike: distinguishing them
145
+ // tells a holder of a guessed token which half of the guess was right, and
146
+ // tells a revoked reviewer they were revoked rather than that the link is
147
+ // simply no longer valid.
148
+ return 'This feedback link is not valid.'
149
+ }
@@ -1,6 +1,7 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions/runtime'
3
3
  import { db, getDatabaseDialect, sqlHelpers } from '@stacksjs/database/runtime'
4
+ import { cardAttachments } from './card-attachments'
4
5
  import { modelBoolean } from './kanban-model'
5
6
  import { kanbanActionError, kanbanError } from './kanban-response'
6
7
 
@@ -29,11 +30,12 @@ interface CardRow {
29
30
  * pivots) — this endpoint is for the case where the user lands
30
31
  * directly on a card URL or refreshes mid-modal.
31
32
  *
32
- * Three queries via `Promise.all`:
33
- * 1. The card itself.
34
- * 2. Labels via the card_labels pivot (with the label row).
35
- * 3. Assignees via the card_assignees pivot (with user.name/email).
36
- * 4. Comments (denormalised user name/email for display).
33
+ * The card itself, then four in parallel via `Promise.all`:
34
+ * 1. Labels via the card_labels pivot (with the label row).
35
+ * 2. Assignees via the card_assignees pivot (with user.name/email).
36
+ * 3. Comments (denormalised user name/email for display).
37
+ * 4. Attachments, each with a freshly signed, expiring URL. They live on a
38
+ * private disk, so there is no path a browser could fetch.
37
39
  */
38
40
  export default new Action({
39
41
  name: 'Kanban Card Show',
@@ -59,7 +61,7 @@ export default new Action({
59
61
  return kanbanError('Card not found', 404)
60
62
  }
61
63
 
62
- const [labels, assignees, comments] = await Promise.all([
64
+ const [labels, assignees, comments, attachments] = await Promise.all([
63
65
  db.unsafe(
64
66
  `SELECT l.id, l.name, l.color
65
67
  FROM card_labels cl
@@ -83,6 +85,9 @@ export default new Action({
83
85
  ORDER BY cc.created_at ASC, cc.id ASC`,
84
86
  [id],
85
87
  ).execute() as Promise<Array<{ id: number, uuid: string | null, user_id: number | null, body: string, created_at: string | null, updated_at: string | null, name: string | null, email: string | null }>>,
88
+ // Each one carries a freshly signed, expiring URL: the bytes are on a
89
+ // private disk, so there is no path the browser could fetch.
90
+ cardAttachments(id),
86
91
  ])
87
92
 
88
93
  return {
@@ -108,6 +113,7 @@ export default new Action({
108
113
  assignedByUserId: a.assigned_by_user_id,
109
114
  assignedAt: a.created_at,
110
115
  })),
116
+ attachments,
111
117
  comments: (comments ?? []).map(c => ({
112
118
  id: c.id,
113
119
  uuid: c.uuid,
@@ -0,0 +1,125 @@
1
+ import { afterAll, afterEach, describe, expect, it, spyOn } from 'bun:test'
2
+ import { readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { CARD_ATTACHMENT_URL_TTL_SECONDS, shapeCardAttachment, signCardAttachmentUrl } from './card-attachments'
5
+
6
+ const row = {
7
+ id: 7,
8
+ uuid: 'c0ffee00-0000-4000-8000-000000000000',
9
+ disk: 'local',
10
+ path: 'feedback/42/0190f3b4-7c21-4f0a-9a1e-3d5c7b8e9f00.png',
11
+ mime_type: 'image/png',
12
+ size_bytes: 120_000,
13
+ created_at: '2026-10-08 09:12:00',
14
+ }
15
+
16
+ describe('shapeCardAttachment', () => {
17
+ it('returns what the dialog renders and nothing else', () => {
18
+ expect(shapeCardAttachment(row, 'https://example.com/__storage/x?token=y')).toEqual({
19
+ id: 7,
20
+ uuid: 'c0ffee00-0000-4000-8000-000000000000',
21
+ mimeType: 'image/png',
22
+ sizeBytes: 120_000,
23
+ createdAt: '2026-10-08 09:12:00',
24
+ url: 'https://example.com/__storage/x?token=y',
25
+ })
26
+ })
27
+
28
+ it('never publishes the storage path or the disk', () => {
29
+ // The signed URL already carries the path it grants, so the bare path
30
+ // adds nothing a client can use and does invite trying it without a
31
+ // token.
32
+ const shaped = shapeCardAttachment(row, null) as Record<string, unknown>
33
+ expect(Object.keys(shaped).sort()).toEqual(['createdAt', 'id', 'mimeType', 'sizeBytes', 'url', 'uuid'])
34
+ expect(JSON.stringify(shaped)).not.toContain('feedback/42')
35
+ expect(JSON.stringify(shaped)).not.toContain('local')
36
+ })
37
+
38
+ it('lists an attachment whose URL could not be minted', () => {
39
+ // Null, not omitted and not thrown: "there is a screenshot nobody can
40
+ // open" is worth chasing and is a different problem from "there is no
41
+ // screenshot".
42
+ expect(shapeCardAttachment(row, null).url).toBeNull()
43
+ })
44
+
45
+ it('reads a row whose numbers arrive as strings', () => {
46
+ // SQLite and Postgres disagree about this often enough that the Kanban
47
+ // actions already normalise everywhere else.
48
+ const shaped = shapeCardAttachment({ ...row, id: '7' as never, size_bytes: '120000' as never }, null)
49
+ expect(shaped.id).toBe(7)
50
+ expect(shaped.sizeBytes).toBe(120_000)
51
+ })
52
+ })
53
+
54
+ describe('the signed URL lifetime', () => {
55
+ it('outlasts opening a card and not much more', () => {
56
+ // Long enough to look at a screenshot, short enough that a URL copied
57
+ // out of devtools is not a handout.
58
+ expect(CARD_ATTACHMENT_URL_TTL_SECONDS).toBeGreaterThanOrEqual(5 * 60)
59
+ expect(CARD_ATTACHMENT_URL_TTL_SECONDS).toBeLessThanOrEqual(60 * 60)
60
+ })
61
+ })
62
+
63
+ describe('signing one attachment', () => {
64
+ const warn = spyOn(console, 'warn').mockImplementation(() => {})
65
+ afterEach(() => warn.mockClear())
66
+ afterAll(() => warn.mockRestore())
67
+
68
+ it('asks the row\'s own disk, for the row\'s path, with the card lifetime', async () => {
69
+ const asked: unknown[] = []
70
+ const url = await signCardAttachmentUrl(row, (name) => {
71
+ asked.push(name)
72
+ return {
73
+ signedUrl: async (path, options) => {
74
+ asked.push(path, options)
75
+ return `https://app.test/__storage/${path}?token=t`
76
+ },
77
+ }
78
+ })
79
+ expect(url).toBe(`https://app.test/__storage/${row.path}?token=t`)
80
+ expect(asked).toEqual(['local', row.path, { expiresIn: CARD_ATTACHMENT_URL_TTL_SECONDS }])
81
+ })
82
+
83
+ it('is null, not a throw, for a disk with no signer', async () => {
84
+ expect(await signCardAttachmentUrl(row, () => ({}))).toBeNull()
85
+ expect(String(warn.mock.calls[0]?.[0])).toContain('does not support signedUrl')
86
+ })
87
+
88
+ it('is null, not a throw, for a disk that is no longer configured', async () => {
89
+ const url = await signCardAttachmentUrl(row, () => {
90
+ throw new Error('disk \'local\' is not configured')
91
+ })
92
+ expect(url).toBeNull()
93
+ expect(String(warn.mock.calls[0]?.[0])).toContain(`attachment ${row.id}`)
94
+ })
95
+
96
+ it('is null, not a throw, when the signer refuses', async () => {
97
+ const url = await signCardAttachmentUrl(row, () => ({
98
+ signedUrl: async () => { throw new Error('APP_KEY is too short to sign with') },
99
+ }))
100
+ expect(url).toBeNull()
101
+ })
102
+ })
103
+
104
+ describe('the card dialog', () => {
105
+ const dialog = readFileSync(
106
+ join(import.meta.dir, '../../../../resources/components/Dashboard/Kanban/KanbanCardDialog.stx'),
107
+ 'utf8',
108
+ )
109
+
110
+ it('renders the attachments it is given', () => {
111
+ expect(dialog).toContain('openCard()?.attachments')
112
+ expect(dialog).toContain(':for="attachment in cardAttachments"')
113
+ })
114
+
115
+ it('opens a signed URL in a new tab, with the referrer withheld', () => {
116
+ // The URL is a bearer credential for one file. `noreferrer` keeps it out
117
+ // of the next page's `Referer` header.
118
+ expect(dialog).toContain('rel="noopener noreferrer"')
119
+ expect(dialog).toContain('target="_blank"')
120
+ })
121
+
122
+ it('shows a placeholder rather than a broken image when there is no URL', () => {
123
+ expect(dialog).toMatch(/:if="attachment\.url"[\s\S]*?:else/)
124
+ })
125
+ })
@@ -0,0 +1,107 @@
1
+ import type { StorageAdapter } from '@stacksjs/storage'
2
+ import { db, getDatabaseDialect, sqlHelpers } from '@stacksjs/database/runtime'
3
+
4
+ /**
5
+ * Reading a card's attachments back out.
6
+ *
7
+ * The bytes sit on a private disk, because the only thing that writes them
8
+ * today is the unauthenticated feedback intake and a stranger's upload does
9
+ * not belong in a web-served directory (stacksjs/stacks#2872). So the
10
+ * dashboard cannot link to a file path; it links to a short-lived signed URL,
11
+ * minted per request against `/__storage/{path}`, which verifies the HMAC
12
+ * against both the path and the expiry.
13
+ */
14
+
15
+ export interface CardAttachmentRow {
16
+ id: number
17
+ uuid: string | null
18
+ disk: string
19
+ path: string
20
+ mime_type: string
21
+ size_bytes: number
22
+ created_at: string | null
23
+ }
24
+
25
+ export interface CardAttachmentView {
26
+ id: number
27
+ uuid: string | null
28
+ mimeType: string
29
+ sizeBytes: number
30
+ createdAt: string | null
31
+ /**
32
+ * A signed, expiring URL, or null when one could not be minted.
33
+ *
34
+ * Null rather than absent, and rather than throwing: a file whose URL
35
+ * cannot be signed is one the dashboard should still list, because "the
36
+ * card has a screenshot nobody can open" is a different problem from "the
37
+ * card has no screenshot" and only the first one is worth chasing.
38
+ */
39
+ url: string | null
40
+ }
41
+
42
+ /** How long a minted URL lasts. Long enough to open a card, short enough that a copied link is not a handout. */
43
+ export const CARD_ATTACHMENT_URL_TTL_SECONDS = 15 * 60
44
+
45
+ /**
46
+ * The response shape, with the storage path deliberately left out.
47
+ *
48
+ * The signed URL carries the path it grants, so publishing the bare path
49
+ * alongside adds nothing a client can use and does invite someone to try it
50
+ * without a token.
51
+ */
52
+ export function shapeCardAttachment(row: CardAttachmentRow, url: string | null): CardAttachmentView {
53
+ return {
54
+ id: Number(row.id),
55
+ uuid: row.uuid ?? null,
56
+ mimeType: String(row.mime_type ?? ''),
57
+ sizeBytes: Number(row.size_bytes ?? 0),
58
+ createdAt: row.created_at ?? null,
59
+ url,
60
+ }
61
+ }
62
+
63
+ /** One card's attachments, oldest first, each with a freshly signed URL. */
64
+ export async function cardAttachments(cardId: number): Promise<CardAttachmentView[]> {
65
+ const { param } = sqlHelpers(getDatabaseDialect())
66
+ const rows = await db.unsafe(
67
+ `SELECT id, uuid, disk, path, mime_type, size_bytes, created_at
68
+ FROM card_attachments
69
+ WHERE card_id = ${param(1)}
70
+ ORDER BY created_at ASC, id ASC`,
71
+ [cardId],
72
+ ).execute() as unknown as CardAttachmentRow[]
73
+
74
+ if (!rows?.length)
75
+ return []
76
+
77
+ const { Storage } = await import('@stacksjs/storage')
78
+
79
+ return Promise.all((rows ?? []).map(async row =>
80
+ shapeCardAttachment(row, await signCardAttachmentUrl(row, name => Storage.disk(name as never))),
81
+ ))
82
+ }
83
+
84
+ /**
85
+ * A signed URL for one attachment, or null when its disk cannot mint one.
86
+ *
87
+ * Per attachment, so one unreadable file does not take the card down with it.
88
+ */
89
+ export async function signCardAttachmentUrl(
90
+ row: CardAttachmentRow,
91
+ openDisk: (name: string) => Pick<StorageAdapter, 'signedUrl'>,
92
+ ): Promise<string | null> {
93
+ try {
94
+ const disk = openDisk(row.disk)
95
+ // A disk with no signer at all (a driver that cannot mint one) is the
96
+ // same outcome as one that fails to: listed, but with nothing to open.
97
+ if (typeof disk.signedUrl !== 'function')
98
+ throw new Error(`disk '${row.disk}' does not support signedUrl`)
99
+ return await disk.signedUrl(row.path, { expiresIn: CARD_ATTACHMENT_URL_TTL_SECONDS })
100
+ }
101
+ catch (err) {
102
+ // A disk that is no longer configured or cannot sign, or an APP_KEY too
103
+ // short to sign with.
104
+ console.warn(`[card-attachments] could not sign attachment ${row.id}: ${err instanceof Error ? err.message : String(err)}`)
105
+ return null
106
+ }
107
+ }
@@ -0,0 +1,103 @@
1
+ import { defineModel } from '@stacksjs/orm'
2
+ import { schema } from '@stacksjs/validation/runtime'
3
+
4
+ /**
5
+ * A file attached to a card. Today that means one screenshot on a card filed
6
+ * through a feedback link (stacksjs/stacks#2872).
7
+ *
8
+ * The bytes live on a disk and this row is the only thing that knows they
9
+ * belong to a card: a disk knows a path and some bytes, not which card a
10
+ * screenshot illustrates. Same division as `StorageItem`, for the same
11
+ * reason, and deliberately a separate table from it - `storage_items` is
12
+ * advisory metadata keyed by `(disk, path)` for the file manager, while this
13
+ * is a card's own content and is authoritative about what is attached to
14
+ * what.
15
+ *
16
+ * ## The disk is chosen here, not by the uploader
17
+ *
18
+ * `disk` is recorded rather than assumed so a row written while an app used
19
+ * local storage still resolves after it moves to S3. It is written by the
20
+ * action from configuration; nothing a submitter sends reaches it, and
21
+ * neither does any part of `path`, which is generated. See
22
+ * `feedback-attachment.ts`, which holds the rules.
23
+ *
24
+ * A feedback attachment is written to a PRIVATE disk. An unauthenticated
25
+ * stranger's upload landing under `public/` would be world-readable at a
26
+ * path the dashboard also prints.
27
+ *
28
+ * ## Erasure removes the row, not yet the bytes
29
+ *
30
+ * The `gdpr` trait deletes this row, and a screenshot is likely to contain
31
+ * personal data - it is a picture of somebody's screen. Deleting the row
32
+ * leaves the file behind, because nothing here sweeps a disk. Stated rather
33
+ * than implied, and tracked separately: a half-done erasure that looks done
34
+ * is worse than one that says what it did.
35
+ *
36
+ * No `useApi`. These rows are written by the feedback intake action and read
37
+ * by the dashboard's own board actions; a REST surface over them would
38
+ * publish a list of every uploaded file.
39
+ */
40
+ export default defineModel({
41
+ name: 'CardAttachment',
42
+ table: 'card_attachments',
43
+ primaryKey: 'id',
44
+ autoIncrement: true,
45
+
46
+ traits: {
47
+ gdpr: { erasure: 'delete', basis: 'legitimate_interests', purpose: 'A screenshot attached to a card, usually illustrating a bug report' },
48
+ useTimestamps: true,
49
+ useUuid: true,
50
+ },
51
+
52
+ // Cascade: an attachment to a card that no longer exists is a file nothing
53
+ // can reach and a row nothing can render. Declared, and not relied on -
54
+ // SQLite enforces it only with `foreign_keys = ON`, which the framework
55
+ // sets while renaming and not otherwise.
56
+ belongsTo: [{ model: 'Card', onDelete: 'cascade' }],
57
+
58
+ attributes: {
59
+ /** Which configured disk holds the bytes, e.g. `local`. */
60
+ disk: {
61
+ required: true,
62
+ order: 1,
63
+ fillable: true,
64
+ validation: { rule: schema.string().max(40) },
65
+ factory: () => 'local',
66
+ },
67
+
68
+ /**
69
+ * The path on that disk. Unique, so the same bytes cannot be claimed by
70
+ * two cards, and generated in full by `feedbackAttachmentPath`.
71
+ */
72
+ path: {
73
+ required: true,
74
+ order: 2,
75
+ fillable: true,
76
+ unique: true,
77
+ validation: { rule: schema.string().max(400) },
78
+ factory: faker => `feedback/1/${faker.string.uuid()}.png`,
79
+ },
80
+
81
+ /**
82
+ * What the bytes actually are, read from the file's own leading bytes
83
+ * rather than from the declared content type, which an uploader writes.
84
+ * The dashboard serves the file as this.
85
+ */
86
+ mimeType: {
87
+ required: true,
88
+ order: 3,
89
+ fillable: true,
90
+ validation: { rule: schema.string().max(80) },
91
+ factory: () => 'image/png',
92
+ },
93
+
94
+ /** Size as stored, so a listing does not have to stat every file. */
95
+ sizeBytes: {
96
+ required: true,
97
+ order: 4,
98
+ fillable: true,
99
+ validation: { rule: schema.number().min(0) },
100
+ factory: faker => faker.number.int({ min: 1000, max: 500_000 }),
101
+ },
102
+ },
103
+ } as const)
@@ -0,0 +1,129 @@
1
+ import { defineModel } from '@stacksjs/orm'
2
+ import { schema } from '@stacksjs/validation/runtime'
3
+
4
+ /**
5
+ * A revocable capability to add one card to one board, and nothing else.
6
+ *
7
+ * Deliberately NOT a personal access token. `tokenCan()` and the `abilities`
8
+ * middleware are opt-in per route, and the dashboard's own actions authorize
9
+ * by calling `request.user()` rather than by checking scopes, so a
10
+ * user-owned bearer token authenticates as its owner across the whole
11
+ * dashboard API. Handing an external reviewer one of those to file feedback
12
+ * with would hand them an account (stacksjs/stacks#2872).
13
+ *
14
+ * This is a capability instead: the auth stack does not know it, so it can
15
+ * authenticate nothing. It names a board, it is checked only by the feedback
16
+ * intake action, and it never resolves to a user.
17
+ *
18
+ * `token` stores the SHA-256 of the raw value - deterministic, so a submit is
19
+ * one indexed lookup, and with 256 bits of entropy in the raw token an
20
+ * offline attack on a fast hash is irrelevant. Same reasoning as
21
+ * `MagicLinkToken`, and deliberately not the bcrypt-and-scan of password
22
+ * resets.
23
+ *
24
+ * No `useApi`: these rows are minted by a command and read by the intake
25
+ * action. Exposing them over REST would publish the thing they protect.
26
+ */
27
+ export default defineModel({
28
+ name: 'FeedbackToken',
29
+ table: 'feedback_tokens',
30
+ primaryKey: 'id',
31
+ autoIncrement: true,
32
+
33
+ traits: {
34
+ gdpr: { erasure: 'delete', basis: 'legitimate_interests', purpose: 'Invites an external reviewer to file feedback on one board' },
35
+ useTimestamps: true,
36
+ useUuid: true,
37
+ },
38
+
39
+ // Cascade, so deleting a board takes its links with it: a token that
40
+ // outlives the board it names would be admitted carrying a board id that no
41
+ // longer resolves.
42
+ //
43
+ // Declared, not relied on. SQLite enforces this only with
44
+ // `foreign_keys = ON`, which the framework sets while renaming and not
45
+ // otherwise - `fk-audit.ts` exists because of exactly that. Verified with
46
+ // the pragma on (the row goes) and off (it stays), so the intake action
47
+ // checks the board resolves rather than trusting the constraint to have
48
+ // removed the token.
49
+ belongsTo: [{ model: 'Board', onDelete: 'cascade' }],
50
+
51
+ attributes: {
52
+ /** SHA-256 hex of the raw token. Never the raw value. */
53
+ token: {
54
+ required: true,
55
+ order: 1,
56
+ fillable: true,
57
+ unique: true,
58
+ validation: {
59
+ rule: schema.string().min(64).max(64),
60
+ },
61
+ factory: faker => faker.string.hexadecimal({ length: 64, prefix: '' }).toLowerCase(),
62
+ },
63
+
64
+ /**
65
+ * Who it was issued to, so an operator can tell two links apart and
66
+ * revoke the right one. Shown in `buddy feedback:tokens`, never sent to
67
+ * the submitter and never used to authenticate.
68
+ */
69
+ label: {
70
+ personal: true,
71
+ required: true,
72
+ order: 2,
73
+ fillable: true,
74
+ validation: {
75
+ rule: schema.string().max(120),
76
+ },
77
+ factory: faker => faker.person.firstName(),
78
+ },
79
+
80
+ /**
81
+ * Revocation, which is the whole point of the design: flipping this stops
82
+ * the link without a deploy. Nullable rather than a boolean so the
83
+ * operator can also see *when* it was pulled.
84
+ */
85
+ revokedAt: {
86
+ required: false,
87
+ order: 3,
88
+ fillable: true,
89
+ validation: {
90
+ rule: schema.timestamp(),
91
+ },
92
+ factory: () => null,
93
+ },
94
+
95
+ /** Unset means it does not expire on its own; revocation still applies. */
96
+ expiresAt: {
97
+ required: false,
98
+ order: 4,
99
+ fillable: true,
100
+ validation: {
101
+ rule: schema.timestamp(),
102
+ },
103
+ factory: () => null,
104
+ },
105
+
106
+ /**
107
+ * Last accepted submission. Lets an operator retire a link nobody uses,
108
+ * and tells them whether a leaked token has been exercised.
109
+ */
110
+ lastUsedAt: {
111
+ required: false,
112
+ order: 5,
113
+ fillable: true,
114
+ validation: {
115
+ rule: schema.timestamp(),
116
+ },
117
+ factory: () => null,
118
+ },
119
+
120
+ /** The user who minted it. Set server-side, surfaced in the listing. */
121
+ createdByUserId: {
122
+ required: false,
123
+ order: 6,
124
+ fillable: true,
125
+ validation: { rule: schema.number() },
126
+ factory: () => null,
127
+ },
128
+ },
129
+ } as const)
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.75.89",
5
+ "version": "0.75.91",
6
6
  "description": "Stacks, built into VS Code: stx templates, pickier linting and formatting, encrypted env files, the dev server preview and every buddy command. No other extensions required.",
7
7
  "license": "MIT",
8
8
  "funding": "https://github.com/sponsors/chrisbbreuer",