@stacksjs/defaults 0.75.89 → 0.75.90

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-actions
3
- description: Use when working with Stacks server actions - creating actions in app/Actions/, auto-generated API actions from the useApi model trait, the 658 default framework actions (auth, dashboard, commerce, content, deployment, jobs), action request/response handling, or action registration. Covers @stacksjs/actions and storage/framework/defaults/app/Actions/.
3
+ description: Use when working with Stacks server actions - creating actions in app/Actions/, auto-generated API actions from the useApi model trait, the 660 default framework actions (auth, dashboard, commerce, content, deployment, jobs), action request/response handling, or action registration. Covers @stacksjs/actions and storage/framework/defaults/app/Actions/.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -66,7 +66,7 @@ globalThis.toggleDark = toggleDark
66
66
  - **Custom Functions**: From `resources/functions/` (counter, dark mode, GPX, geo utilities)
67
67
 
68
68
  ### Server Auto-Imports (100+)
69
- - **All ORM Models**: User, Post, Author, Product, Order, Payment, Customer, etc. (105 models)
69
+ - **All ORM Models**: User, Post, Author, Product, Order, Payment, Customer, etc. (106 models)
70
70
  - **Request Models**: UserRequest, PostRequest, OrderRequest, etc.
71
71
  - **Actions**: Action types and helpers
72
72
  - **Schema**: validation schema builder
@@ -12,7 +12,7 @@ allowed-tools: Read Edit Write Bash Grep Glob
12
12
  - Database package: `storage/framework/core/database/src/`
13
13
  - Configuration: `config/database.ts`
14
14
  - QB config: `config/query-builder.ts`
15
- - Migrations: `database/migrations/` (235 migration files, `.sql` format)
15
+ - Migrations: `database/migrations/` (236 migration files, `.sql` format)
16
16
  - QB state: `.qb/`
17
17
  - ORM: `storage/framework/orm/`
18
18
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-migrations
3
- description: Use when working with database migrations in a Stacks application - creating migration files, running migrations, fresh migration (drop + recreate), seeding after migration, migration file naming conventions, or the 235 built-in migration files. For the database API itself (queries, connections, SQL helpers), see stacks-database.
3
+ description: Use when working with database migrations in a Stacks application - creating migration files, running migrations, fresh migration (drop + recreate), seeding after migration, migration file naming conventions, or the 236 built-in migration files. For the database API itself (queries, connections, SQL helpers), see stacks-database.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript, SQLite >= 3.47.2
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -119,6 +119,6 @@ The framework includes migrations for all built-in models:
119
119
  - Do not commit a second snapshot under `.qb/`; that indicates a missing `snapshotDir` configuration
120
120
  - If a generated SQLite migration rebuilds tables, test it against a copy of the current database and run `PRAGMA integrity_check` plus `PRAGMA foreign_key_check`
121
121
  - `--seed` flag after `migrate:fresh` seeds the database with factory data
122
- - 235 migration files exist by default for all framework models
122
+ - 236 migration files exist by default for all framework models
123
123
  - SQLite >= 3.47.2 is required (system requirement)
124
124
  - For the database API (queries, connections), see the `stacks-database` skill
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-models
3
- description: Use when working with data models in Stacks - the defineModel() API, model attributes with validation and factories, relationships (hasOne/hasMany/belongsTo/belongsToMany), traits (useAuth, useUuid, useTimestamps, useSearch, useApi, billable, taggable, categorizable, commentable, likeable, observe), computed properties (get/set), model generation, and the 105 built-in framework models. Covers model definitions and storage/framework/defaults/app/Models/.
3
+ description: Use when working with data models in Stacks - the defineModel() API, model attributes with validation and factories, relationships (hasOne/hasMany/belongsTo/belongsToMany), traits (useAuth, useUuid, useTimestamps, useSearch, useApi, billable, taggable, categorizable, commentable, likeable, observe), computed properties (get/set), model generation, and the 106 built-in framework models. Covers model definitions and storage/framework/defaults/app/Models/.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript, SQLite >= 3.47.2
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -304,7 +304,7 @@ honoured: fewer rows are seeded rather than a duplicate combination.
304
304
  ## Built-in models by category
305
305
 
306
306
  The ones below are worth knowing by name. They are a selection, not the set:
307
- `storage/framework/defaults/app/Models/` holds 105, and that directory is the
307
+ `storage/framework/defaults/app/Models/` holds 106, and that directory is the
308
308
  authority. This section said "All 62 built-in models by category" while
309
309
  listing fewer than that against 102 on disk, so an agent reading to the end
310
310
  had no way to tell it was short.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-orm
3
- description: Use when working with the Stacks ORM - defining models with defineModel(), model relationships (hasOne, hasMany, belongsTo, belongsToMany, morphOne, hasManyThrough), attributes, traits, factories, computed properties, query building, transactions, or the 105 built-in models. Covers @stacksjs/orm, storage/framework/orm/, and storage/framework/defaults/app/Models/.
3
+ description: Use when working with the Stacks ORM - defining models with defineModel(), model relationships (hasOne, hasMany, belongsTo, belongsToMany, morphOne, hasManyThrough), attributes, traits, factories, computed properties, query building, transactions, or the 106 built-in models. Covers @stacksjs/orm, storage/framework/orm/, and storage/framework/defaults/app/Models/.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript, SQLite >= 3.47.2
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -11,7 +11,7 @@ allowed-tools: Read Edit Write Bash Grep Glob
11
11
  ## Key Paths
12
12
  - Core ORM package: `storage/framework/core/orm/src/`
13
13
  - ORM implementation: `storage/framework/orm/`
14
- - Model definitions: `storage/framework/defaults/app/Models/` (105 models)
14
+ - Model definitions: `storage/framework/defaults/app/Models/` (106 models)
15
15
  - Application models: `app/Models/`
16
16
  - Default model templates: `storage/framework/defaults/app/Models/`
17
17
  - ORM type globals: `storage/framework/types/orm-globals.d.ts`
@@ -0,0 +1,134 @@
1
+ import type { RequestInstance } from '@stacksjs/types'
2
+ import { Action } from '@stacksjs/actions/runtime'
3
+ import { db, getDatabaseDialect, sqlHelpers } from '@stacksjs/database/runtime'
4
+ import { response } from '@stacksjs/router'
5
+ import { kanbanActionError } from '../Kanban/kanban-response'
6
+ import {
7
+ authorizeFeedbackToken,
8
+ hashFeedbackToken,
9
+ looksLikeFeedbackToken,
10
+ refusalMessage,
11
+ } from './feedback-token'
12
+
13
+ interface FeedbackInput {
14
+ title?: unknown
15
+ description?: unknown
16
+ }
17
+
18
+ /** Matches `Card.description`, so a submission cannot be longer than the column. */
19
+ const MAX_DESCRIPTION = 10_000
20
+
21
+ /** Matches `Card.title`. */
22
+ const MAX_TITLE = 300
23
+
24
+ /**
25
+ * `POST /api/feedback/{token}`.
26
+ *
27
+ * The one unauthenticated write path the dashboard has, so it is deliberately
28
+ * narrow: a feedback link may add a card to the one board its token names,
29
+ * and may not read anything (stacksjs/stacks#2872).
30
+ *
31
+ * Every refusal answers 404 with the same sentence. A 401 would confirm the
32
+ * link once existed, and distinguishing unknown from revoked from expired
33
+ * tells a holder of a guessed token which half of the guess was right, or
34
+ * tells a revoked reviewer they were revoked rather than that the link simply
35
+ * stopped working. The reason is logged, not returned.
36
+ *
37
+ * Rate limited at the route rather than here, the way the other
38
+ * unauthenticated endpoints are.
39
+ */
40
+ export default new Action({
41
+ name: 'Feedback Store',
42
+ description: 'Files a card on one board from a revocable feedback link.',
43
+ method: 'POST',
44
+ apiResponse: true,
45
+
46
+ async handle(request: RequestInstance<FeedbackInput>) {
47
+ const raw = String(request.getParam('token') || '')
48
+
49
+ // Shape before lookup, so a submit carrying a sentence, a path or a SQL
50
+ // fragment costs no query.
51
+ if (!looksLikeFeedbackToken(raw))
52
+ return response.json({ message: refusalMessage() }, 404)
53
+
54
+ const body = request.all()
55
+ const title = typeof body.title === 'string' ? body.title.trim() : ''
56
+ if (!title || title.length > MAX_TITLE)
57
+ return response.json({ message: `A title is required, up to ${MAX_TITLE} characters.` }, 400)
58
+
59
+ // Rejected rather than truncated. Silently dropping the end of somebody's
60
+ // report is worse than telling them it was too long.
61
+ const rawDescription = typeof body.description === 'string' ? body.description.trim() : ''
62
+ if (rawDescription.length > MAX_DESCRIPTION)
63
+ return response.json({ message: `The description must be ${MAX_DESCRIPTION} characters or fewer.` }, 400)
64
+ const description = rawDescription || undefined
65
+
66
+ try {
67
+ const token = await (db as any)
68
+ .selectFrom('feedback_tokens')
69
+ .where('token', '=', hashFeedbackToken(raw))
70
+ .select(['id', 'board_id', 'revoked_at', 'expires_at'])
71
+ .executeTakeFirst()
72
+
73
+ const verdict = authorizeFeedbackToken(token)
74
+ if (!verdict.ok) {
75
+ // Logged with its reason and never with the token, so an operator can
76
+ // tell a revoked link from a guessed one without the log holding a
77
+ // live credential.
78
+ console.warn(`[FeedbackStoreAction] refused a submission: ${verdict.reason}`)
79
+ return response.json({ message: refusalMessage() }, 404)
80
+ }
81
+
82
+ const { param } = sqlHelpers(getDatabaseDialect())
83
+
84
+ // The board is checked rather than assumed. `feedback_tokens.board_id`
85
+ // cascades on delete, but SQLite enforces that only with
86
+ // `foreign_keys = ON`, which the framework sets while renaming and not
87
+ // otherwise, so a token can outlive its board.
88
+ const column = await db.unsafe(
89
+ `SELECT id FROM board_columns WHERE board_id = ${param(1)} ORDER BY position ASC, id ASC LIMIT 1`,
90
+ [verdict.boardId],
91
+ ).execute() as Array<{ id: number }>
92
+ const columnId = Number(column?.[0]?.id)
93
+ if (!Number.isInteger(columnId) || columnId <= 0) {
94
+ // The link is valid and there is nowhere to put the card. Not the
95
+ // submitter's problem to diagnose, so they get the same sentence.
96
+ console.warn(`[FeedbackStoreAction] board ${verdict.boardId} has no column to file into`)
97
+ return response.json({ message: refusalMessage() }, 404)
98
+ }
99
+
100
+ const maxRow = await db.unsafe(
101
+ `SELECT COALESCE(MAX(position), -1) AS m FROM cards WHERE column_id = ${param(1)}`,
102
+ [columnId],
103
+ ).execute() as Array<{ m: number }>
104
+ const nextPosition = (Number(maxRow?.[0]?.m ?? -1) + 1) || 0
105
+
106
+ const { Card } = await import('@stacksjs/orm')
107
+ const card = await Card.create({
108
+ columnId,
109
+ boardId: verdict.boardId,
110
+ title,
111
+ description,
112
+ position: nextPosition,
113
+ // No user. A feedback link authenticates nobody, which is the point
114
+ // of it, so the card is attributed by its board and its label rather
115
+ // than by an account that does not exist.
116
+ createdByUserId: null,
117
+ dueDate: null,
118
+ archived: false,
119
+ })
120
+
121
+ await db.unsafe(
122
+ `UPDATE feedback_tokens SET last_used_at = ${param(1)} WHERE id = ${param(2)}`,
123
+ [new Date().toISOString().slice(0, 19).replace('T', ' '), Number(token.id)],
124
+ ).execute()
125
+
126
+ // Acknowledgement only. Returning the card, its position or anything
127
+ // about the board would make this a read endpoint, which it is not.
128
+ return { filed: true, id: Number(card.get('id')) }
129
+ }
130
+ catch (err) {
131
+ return kanbanActionError(err, 'FeedbackStoreAction')
132
+ }
133
+ },
134
+ })
@@ -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
+ })
@@ -0,0 +1,136 @@
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
+ /** A raw token as it appears in a link, and as the column stores it. */
22
+ const RAW_TOKEN = /^[0-9a-f]{64}$/
23
+
24
+ /**
25
+ * SHA-256 hex, which is what `feedback_tokens.token` holds.
26
+ *
27
+ * Deterministic on purpose: a submit is one indexed lookup. With 256 bits of
28
+ * entropy in the raw value an offline attack on a fast hash is irrelevant,
29
+ * which is the same reasoning `MagicLinkToken` records and deliberately not
30
+ * the bcrypt-and-scan of password resets.
31
+ *
32
+ * `@stacksjs/auth` defines this same one-liner privately three times over
33
+ * (`tokens.ts`, `authentication.ts`, `magic-link.ts`) and exports none of
34
+ * them, so this is a fourth rather than a refactor of a security module in
35
+ * passing.
36
+ */
37
+ export function hashFeedbackToken(raw: string): string {
38
+ return createHash('sha256').update(raw).digest('hex')
39
+ }
40
+
41
+ /** A new link's raw token and the hash to store for it. */
42
+ export function mintFeedbackToken(): { raw: string, hash: string } {
43
+ const raw = randomBytes(FEEDBACK_TOKEN_BYTES).toString('hex')
44
+ return { raw, hash: hashFeedbackToken(raw) }
45
+ }
46
+
47
+ /**
48
+ * Does this even have the shape of a token?
49
+ *
50
+ * Checked before the lookup so a submit carrying a sentence, a path, or a SQL
51
+ * fragment is refused without touching the database.
52
+ */
53
+ export function looksLikeFeedbackToken(raw: unknown): raw is string {
54
+ return typeof raw === 'string' && RAW_TOKEN.test(raw)
55
+ }
56
+
57
+ export type FeedbackTokenRefusal = 'malformed' | 'unknown' | 'revoked' | 'expired' | 'unbound'
58
+
59
+ export type FeedbackTokenVerdict =
60
+ | { ok: true, boardId: number }
61
+ | { ok: false, reason: FeedbackTokenRefusal }
62
+
63
+ /**
64
+ * A `feedback_tokens` row, in either spelling.
65
+ *
66
+ * The ORM hands back camelCase or snake_case depending on the path taken, and
67
+ * the Kanban actions already read `boardId ?? board_id` for the same reason.
68
+ */
69
+ export interface FeedbackTokenRow {
70
+ boardId?: unknown
71
+ board_id?: unknown
72
+ revokedAt?: unknown
73
+ revoked_at?: unknown
74
+ expiresAt?: unknown
75
+ expires_at?: unknown
76
+ }
77
+
78
+ function firstDefined(...values: unknown[]): unknown {
79
+ return values.find(value => value !== undefined && value !== null)
80
+ }
81
+
82
+ /**
83
+ * A stored timestamp as epoch milliseconds, read as UTC.
84
+ *
85
+ * Exported so the UTC reading can be pinned directly. Asserting it through a
86
+ * verdict cannot: both interpretations of a given row usually land on the
87
+ * same side of `now`, so such a test passes whether or not the normalisation
88
+ * below exists, and it did.
89
+ */
90
+ export function parseTokenTime(value: unknown): number | null {
91
+ if (value === undefined || value === null) return null
92
+ if (value instanceof Date) return value.getTime()
93
+ // SQLite hands back `YYYY-MM-DD HH:MM:SS`, which `Date` parses as local
94
+ // time on some engines and UTC on others. Normalising to ISO keeps a token
95
+ // from appearing to expire an hour early or late depending on the dialect.
96
+ if (typeof value === 'string') {
97
+ const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(value) ? `${value.replace(' ', 'T')}Z` : value
98
+ const parsed = Date.parse(normalized)
99
+ return Number.isNaN(parsed) ? null : parsed
100
+ }
101
+ if (typeof value === 'number') return value
102
+ return null
103
+ }
104
+
105
+ /**
106
+ * The verdict for a row that has already been looked up by token hash.
107
+ *
108
+ * Revocation is checked before expiry, so a token that is both reports the
109
+ * reason an operator acted on. The verdict carries a board id and a reason
110
+ * and never the token, so it is safe to log.
111
+ */
112
+ export function authorizeFeedbackToken(row: FeedbackTokenRow | null | undefined, now: Date = new Date()): FeedbackTokenVerdict {
113
+ if (!row) return { ok: false, reason: 'unknown' }
114
+
115
+ if (firstDefined(row.revokedAt, row.revoked_at) !== undefined)
116
+ return { ok: false, reason: 'revoked' }
117
+
118
+ const expiresAt = parseTokenTime(firstDefined(row.expiresAt, row.expires_at))
119
+ if (expiresAt !== null && expiresAt <= now.getTime())
120
+ return { ok: false, reason: 'expired' }
121
+
122
+ const boardId = Number(firstDefined(row.boardId, row.board_id))
123
+ if (!Number.isInteger(boardId) || boardId <= 0)
124
+ return { ok: false, reason: 'unbound' }
125
+
126
+ return { ok: true, boardId }
127
+ }
128
+
129
+ /** What to tell the submitter. Deliberately the same for every refusal. */
130
+ export function refusalMessage(): string {
131
+ // One message for unknown, revoked and expired alike: distinguishing them
132
+ // tells a holder of a guessed token which half of the guess was right, and
133
+ // tells a revoked reviewer they were revoked rather than that the link is
134
+ // simply no longer valid.
135
+ return 'This feedback link is not valid.'
136
+ }
@@ -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.90",
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",
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/defaults",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.89",
5
+ "version": "0.75.90",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/stacksjs/stacks.git",
@@ -55,7 +55,7 @@
55
55
  "dependencies": {
56
56
  "@iconify-json/f7": "^1.2.2",
57
57
  "@iconify-json/hugeicons": "^1.2.35",
58
- "@stacksjs/mobile": "^0.75.89",
58
+ "@stacksjs/mobile": "^0.75.90",
59
59
  "@stacksjs/sanitizer": "^0.2.395",
60
60
  "@xterm/addon-fit": "^0.11.0",
61
61
  "@xterm/xterm": "^6.0.0",
@@ -47,6 +47,21 @@ route.post('/api/contact', 'Actions/ContactAction').name('contact.send').skipCsr
47
47
  route.get('/api/team-invitation-links/{token}', 'Actions/Teams/ShowInvitationAction').rateLimit(30, 'minute')
48
48
  route.post('/api/team-invitations/{token}/accept', 'Actions/Teams/AcceptInvitationAction').middleware('auth').rateLimit(10, 'minute')
49
49
 
50
+ // An external reviewer files feedback through a revocable link, without an
51
+ // account: the only unauthenticated write the dashboard has. Outside every
52
+ // `auth` group on purpose, and narrow because of it - the token names one
53
+ // board, adds a card to it, and reads nothing.
54
+ //
55
+ // CSRF is skipped for the same reason `/api/contact` above skips it, and more
56
+ // safely: there is no ambient authority to borrow here. The capability is the
57
+ // high-entropy token in the path, not a cookie the browser attaches, so a
58
+ // cross-site POST has nothing to forge with.
59
+ //
60
+ // Rate limited like the other token-bearing unauthenticated routes. The token
61
+ // is 256 bits, so this is about stopping a flood of cards from a link that
62
+ // leaked, not about guessing.
63
+ route.post('/api/feedback/{token}', 'Actions/Dashboard/Feedback/FeedbackStoreAction').name('feedback.store').skipCsrf().rateLimit(10, 'minute')
64
+
50
65
  // ============================================================================
51
66
  // Storefront (anonymous cart + multi-step checkout)
52
67
  //