@stacksjs/defaults 0.74.27 → 0.74.28

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.
@@ -378,7 +378,7 @@ import { defineGates } from '@stacksjs/auth'
378
378
 
379
379
  export default defineGates({
380
380
  gates: {
381
- 'access-admin': user => user?.email?.endsWith('@stacksjs.org') ?? false,
381
+ 'access-admin': user => user?.email?.endsWith('@stacksjs.com') ?? false,
382
382
  'edit-settings': user => !!user,
383
383
  'view-dashboard': user => !!user,
384
384
  },
@@ -225,7 +225,7 @@ buddy migrate:fresh -s/--seed # seed after fresh migration
225
225
  buddy migrate:fresh -a/--auth # include auth tables
226
226
  buddy migrate:fresh -d/--diff # show SQL without running
227
227
 
228
- buddy migrate:dns # DNS migration for APP_URL domain
228
+ buddy dns:sync # create the config/dns.ts records at the registrar
229
229
  ```
230
230
 
231
231
  Both `migrate` and `migrate:fresh` validate that models exist in `app/Models` or `storage/framework/defaults/app/Models` before running.
@@ -54,7 +54,7 @@ export default {
54
54
  // Navigation
55
55
  nav: [
56
56
  { text: 'Changelog', link: 'https://github.com/stacksjs/stacks/blob/main/CHANGELOG.md' },
57
- { text: 'Blog', link: 'https://updates.stacksjs.org' },
57
+ { text: 'Blog', link: 'https://updates.stacksjs.com' },
58
58
  ],
59
59
 
60
60
  // Markdown settings
@@ -290,7 +290,7 @@ are enforced rather than using their legacy warn-once compatibility path.
290
290
  Reprocessing refreshes existing messages instead of skipping them, preserves their read state, and repairs body and attachment metadata written by older versions. The dashboard receives opaque attachment IDs, resolves them against the stored message before download, and never accepts arbitrary S3 keys from a client.
291
291
 
292
292
  ## CLI Commands
293
- - `buddy email` / `buddy mail` - email management
293
+ - `buddy email` - email management (there is no `buddy mail` alias: `mail:*` is the separate mail-server namespace)
294
294
  - `buddy email:verify` - check domain verification
295
295
  - `buddy email:test [recipient]` - send test email
296
296
  - `buddy email:list` - list mailboxes
@@ -155,6 +155,29 @@ route.group({ prefix: '/api/v1', middleware: ['auth', 'throttle'] }, () => {
155
155
  })
156
156
  ```
157
157
 
158
+ ### STX Pages
159
+
160
+ The frontend and API share the same middleware definitions. Stacks loads the
161
+ aliases from `app/Middleware.ts` and the class-style handlers from
162
+ `app/Middleware/` into the normal stx dev and production page servers.
163
+
164
+ Declare an alias from that registry in a page's server metadata:
165
+
166
+ ```stx
167
+ <script server>
168
+ definePageMeta({ middleware: ['auth', 'verified', 'role:admin'] })
169
+ </script>
170
+ ```
171
+
172
+ Do not create a second frontend-only middleware registry. A custom middleware
173
+ is defined once with `new Middleware({ name, priority, handle })`, added to
174
+ `app/Middleware.ts`, and may then guard an API route, an stx page, or both.
175
+
176
+ STX prepares the incoming request with the same EnhancedRequest helpers before
177
+ calling `handle`, sorts the combined page chain by priority, writes parameters
178
+ to `request._middlewareParams`, supports exact aliases containing colons and
179
+ `!alias` inversion, and fails closed when an alias is missing.
180
+
158
181
  Group middleware is prepended to all routes inside the callback. Groups can be nested — middleware accumulates.
159
182
 
160
183
  ### Parameterized Middleware
@@ -23,7 +23,7 @@ buddy migrate --diff # show SQL without running
23
23
  buddy migrate --auth # include auth tables
24
24
  buddy migrate:fresh # drop ALL tables and re-migrate
25
25
  buddy migrate:fresh --seed # drop, migrate, then seed
26
- buddy migrate:dns # DNS-specific migration
26
+ buddy dns:pull # live zone as a config/dns.ts block
27
27
  buddy make:migration <name> # create new migration file
28
28
  buddy seed # seed database
29
29
  buddy generate:migrations # generate migrations from model diffs
@@ -54,7 +54,7 @@ describe('campaign records', () => {
54
54
  emailListId: '4',
55
55
  scheduledAt: '2030-01-01 09:00:00',
56
56
  fromName: 'Product team',
57
- fromAddress: 'product@stacksjs.org',
57
+ fromAddress: 'product@stacksjs.com',
58
58
  currency: 'usd',
59
59
  })
60
60
 
@@ -62,7 +62,7 @@ describe('campaign records', () => {
62
62
  email_list_id: 4,
63
63
  scheduled_at: '2030-01-01 09:00:00',
64
64
  from_name: 'Product team',
65
- from_address: 'product@stacksjs.org',
65
+ from_address: 'product@stacksjs.com',
66
66
  currency: 'USD',
67
67
  })
68
68
  expect(validateCampaignWriteData(data, new Date('2029-01-01T00:00:00'))).toBe('')
package/app/Gates.ts CHANGED
@@ -11,7 +11,7 @@ import { defineGates } from '@stacksjs/auth'
11
11
  * Registered at boot by `initializeAuthorization()`, which every entry point
12
12
  * comes through - HTTP, `buddy seed`, a scheduled job, a console command.
13
13
  *
14
- * @see https://stacksjs.org/docs/security/authorization
14
+ * @see https://stacksjs.com/docs/security/authorization
15
15
  */
16
16
  export default defineGates({
17
17
  /**
@@ -30,7 +30,7 @@ export default defineGates({
30
30
  gates: {
31
31
  /** Check if user can access admin area */
32
32
  'access-admin': (user: UserModel | null) => {
33
- return user?.email?.endsWith('@stacksjs.org') ?? false
33
+ return user?.email?.endsWith('@stacksjs.com') ?? false
34
34
  },
35
35
 
36
36
  /** Check if user can edit application settings */
@@ -1,4 +1,4 @@
1
- import { randomBytes, timingSafeEqual } from 'node:crypto'
1
+ import { timingSafeEqual } from 'node:crypto'
2
2
  import { HttpError } from '@stacksjs/error-handling'
3
3
  import type { EnhancedRequest } from '@stacksjs/bun-router'
4
4
  import { Middleware } from '@stacksjs/router'
@@ -59,9 +59,16 @@ import { Middleware } from '@stacksjs/router'
59
59
  */
60
60
 
61
61
  export const CSRF_COOKIE_NAME = 'X-CSRF-Token'
62
+ const CSRF_SECURE_TRANSPORT = Symbol.for('@stacksjs/router:csrf-secure-transport')
62
63
  const CSRF_HEADER_NAME = 'x-csrf-token'
64
+ const CSRF_COOKIE_PREFIX = `${CSRF_COOKIE_NAME}=`
65
+ const LEGACY_CSRF_COOKIE_PREFIX = 'csrf-token='
63
66
  const TOKEN_BYTES = 32
67
+ const TOKEN_HEX_LENGTH = TOKEN_BYTES * 2
68
+ const TOKEN_RANDOM_BYTES = Buffer.allocUnsafe(TOKEN_BYTES)
64
69
  const TOKEN_ENCODER = new TextEncoder()
70
+ const LEFT_TOKEN_BYTES = new Uint8Array(TOKEN_HEX_LENGTH)
71
+ const RIGHT_TOKEN_BYTES = new Uint8Array(TOKEN_HEX_LENGTH)
65
72
 
66
73
  /**
67
74
  * Generate a fresh CSRF token (hex-encoded, 32 random bytes → 64 chars).
@@ -73,7 +80,8 @@ const TOKEN_ENCODER = new TextEncoder()
73
80
  * ```
74
81
  */
75
82
  export function generateCsrfToken(): string {
76
- return randomBytes(TOKEN_BYTES).toString('hex')
83
+ crypto.getRandomValues(TOKEN_RANDOM_BYTES)
84
+ return TOKEN_RANDOM_BYTES.toString('hex')
77
85
  }
78
86
 
79
87
  /**
@@ -120,16 +128,25 @@ function responseAlreadySeeds(response: Response): boolean {
120
128
  )
121
129
  }
122
130
 
123
- export function seedCsrfCookieIfMissing(req: Request, response: Response, minted?: string): Response {
124
- const cookieHeader = req.headers.get('cookie') || ''
131
+ export function createCsrfCookie(req: Request, minted?: string): string {
132
+ const token = minted || generateCsrfToken()
133
+ const knownSecureTransport = (req as unknown as Record<symbol, unknown>)[CSRF_SECURE_TRANSPORT]
134
+ const secure = knownSecureTransport === true || (knownSecureTransport === undefined && req.url.startsWith('https://'))
135
+ ? '; Secure'
136
+ : ''
137
+ return `${CSRF_COOKIE_NAME}=${token}; Path=/; SameSite=Lax; Max-Age=7200${secure}`
138
+ }
125
139
 
140
+ export function seedCsrfCookieIfMissing(req: Request, response: Response, minted?: string, responseHasNoCookies = false): Response {
126
141
  // A token the router minted before rendering wins over "the header already
127
142
  // has one", because it put that value in the header itself - and the page
128
143
  // has already embedded it in every form it drew. Generating a second token
129
144
  // here would store one string in the browser while the page carries another,
130
145
  // which fails in a way indistinguishable from having no token at all.
131
- if (!minted && (cookieHeader.includes(`${CSRF_COOKIE_NAME}=`) || cookieHeader.includes('csrf-token='))) {
132
- return response
146
+ if (!minted) {
147
+ const cookieHeader = req.headers.get('cookie') || ''
148
+ if (cookieHeader.includes(`${CSRF_COOKIE_NAME}=`) || cookieHeader.includes('csrf-token='))
149
+ return response
133
150
  }
134
151
 
135
152
  // A token already on its way to the browser counts as present, exactly like
@@ -138,18 +155,10 @@ export function seedCsrfCookieIfMissing(req: Request, response: Response, minted
138
155
  // and appending a second here would leave the browser storing the last
139
156
  // Set-Cookie while the page embedded the first. Two tokens fail the same way
140
157
  // no token does, and are far harder to see.
141
- if (responseAlreadySeeds(response))
158
+ if (!responseHasNoCookies && responseAlreadySeeds(response))
142
159
  return response
143
160
 
144
- const token = minted || generateCsrfToken()
145
- const isSecure = req.url.startsWith('https://')
146
- const cookie = [
147
- `${CSRF_COOKIE_NAME}=${token}`,
148
- 'Path=/',
149
- 'SameSite=Lax',
150
- 'Max-Age=7200',
151
- isSecure ? 'Secure' : null,
152
- ].filter(Boolean).join('; ')
161
+ const cookie = createCsrfCookie(req, minted)
153
162
 
154
163
  // Append (not Set) so multiple Set-Cookie headers can coexist with any
155
164
  // cookies the action handler set itself.
@@ -175,6 +184,17 @@ export function seedCsrfCookieIfMissing(req: Request, response: Response, minted
175
184
  function csrfCookieToken(req: Request): string {
176
185
  const header = req.headers.get('cookie')
177
186
  if (!header) return ''
187
+ // The cookie this framework emits has one exact, whitespace-free shape.
188
+ // Recognize it before scanning for separators or trimming; browsers send
189
+ // this common single-cookie form on every authenticated unsafe request.
190
+ if (header.length === CSRF_COOKIE_PREFIX.length + TOKEN_HEX_LENGTH && header.startsWith(CSRF_COOKIE_PREFIX))
191
+ return header.slice(CSRF_COOKIE_PREFIX.length)
192
+ if (!header.includes(';')) {
193
+ if (header.startsWith(CSRF_COOKIE_PREFIX))
194
+ return header.slice(CSRF_COOKIE_PREFIX.length).trim()
195
+ if (header.startsWith(LEGACY_CSRF_COOKIE_PREFIX))
196
+ return header.slice(LEGACY_CSRF_COOKIE_PREFIX.length).trim()
197
+ }
178
198
  let canonical = ''
179
199
  let legacy = ''
180
200
  for (const part of header.split(';')) {
@@ -197,6 +217,22 @@ function safeEqual(a: string, b: string): boolean {
197
217
  if (typeof a !== 'string' || typeof b !== 'string') return false
198
218
  if (a.length !== b.length) return false
199
219
  try {
220
+ // Generated tokens are fixed-width ASCII hex. Reuse module-local scratch
221
+ // arrays for that dominant path: this function is synchronous, so another
222
+ // request cannot interleave between encoding and comparison. If either
223
+ // value is not 64 bytes of UTF-8, retain the allocation-based fallback.
224
+ if (a.length === TOKEN_HEX_LENGTH) {
225
+ const left = TOKEN_ENCODER.encodeInto(a, LEFT_TOKEN_BYTES)
226
+ const right = TOKEN_ENCODER.encodeInto(b, RIGHT_TOKEN_BYTES)
227
+ if (
228
+ left.read === TOKEN_HEX_LENGTH
229
+ && left.written === TOKEN_HEX_LENGTH
230
+ && right.read === TOKEN_HEX_LENGTH
231
+ && right.written === TOKEN_HEX_LENGTH
232
+ ) {
233
+ return timingSafeEqual(LEFT_TOKEN_BYTES, RIGHT_TOKEN_BYTES)
234
+ }
235
+ }
200
236
  return timingSafeEqual(TOKEN_ENCODER.encode(a), TOKEN_ENCODER.encode(b))
201
237
  }
202
238
  catch {
@@ -210,12 +246,10 @@ function safeEqual(a: string, b: string): boolean {
210
246
  * an ambient cookie credential, so cross-site forgery doesn't apply).
211
247
  */
212
248
  function hasBearerToken(req: Request): boolean {
213
- const auth = req.headers.get('authorization') || req.headers.get('Authorization')
214
- return typeof auth === 'string' && auth.toLowerCase().startsWith('bearer ')
249
+ const auth = req.headers.get('authorization')
250
+ return typeof auth === 'string' && /^bearer /i.test(auth)
215
251
  }
216
252
 
217
- const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
218
-
219
253
  /**
220
254
  * Validate one request with the framework's native CSRF contract.
221
255
  *
@@ -223,18 +257,16 @@ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
223
257
  * the router, such as the local dashboard config editor, can enforce the same
224
258
  * double-submit and bearer-token rules without duplicating security logic.
225
259
  */
226
- export async function validateCsrfRequest(request: Request | EnhancedRequest): Promise<void> {
227
- const method = request.method.toUpperCase()
260
+ function assertValidCsrfRequest(request: Request | EnhancedRequest): void {
261
+ // Fetch normalizes standard methods when constructing the Request.
262
+ const method = request.method
228
263
 
229
264
  // Safe methods don't mutate state — no token check needed.
230
265
  // Token *seeding* (set the cookie if it's missing) happens after
231
266
  // the response is built; we don't have a post-response hook
232
267
  // here, so action handlers / SPAs can call `generateCsrfToken()`
233
268
  // themselves on the first GET they need it for.
234
- if (SAFE_METHODS.has(method)) return
235
-
236
- // API clients with a bearer token are exempt — see header docstring.
237
- if (hasBearerToken(request)) return
269
+ if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') return
238
270
 
239
271
  // Per-action opt-out: an action exporting `skipCsrf: true` (or
240
272
  // `csrf: false`) has declared it can't participate in CSRF
@@ -247,8 +279,13 @@ export async function validateCsrfRequest(request: Request | EnhancedRequest): P
247
279
  // Look up the submitted token. Header is the SPA path; body field
248
280
  // is the traditional form-post path. We accept either.
249
281
  const headerToken = request.headers.get(CSRF_HEADER_NAME)
250
- || request.headers.get('X-CSRF-Token')
251
- || request.headers.get('X-Csrf-Token')
282
+
283
+ // A browser or SPA that supplied an explicit CSRF header should take the
284
+ // matching-token path first. Bearer-only clients still exit immediately,
285
+ // while a malformed CSRF header on a bearer request retains the exemption
286
+ // after validation fails below.
287
+ if (!headerToken && hasBearerToken(request)) return
288
+
252
289
  const body = enhanced.jsonBody || enhanced.formBody || {}
253
290
  // A parsed body is `unknown`-valued: the token is validated as a string
254
291
  // two lines down, so read it as one rather than asserting it is one.
@@ -262,13 +299,20 @@ export async function validateCsrfRequest(request: Request | EnhancedRequest): P
262
299
 
263
300
  const cookieToken = csrfCookieToken(request)
264
301
 
265
- if (!submitted || !cookieToken || !safeEqual(submitted, cookieToken)) {
266
- // 419 is the convention Laravel popularized for "CSRF token
267
- // mismatch" — it's not in the IANA list but most SPAs already
268
- // know how to refresh on 419. We use 403 for the strict-correct
269
- // status code instead (419 is non-standard).
270
- throw new HttpError(403, 'CSRF token mismatch')
271
- }
302
+ if (submitted && cookieToken && safeEqual(submitted, cookieToken)) return
303
+
304
+ // Preserve bearer precedence when a client happens to send both headers.
305
+ if (headerToken && hasBearerToken(request)) return
306
+
307
+ // 419 is the convention Laravel popularized for "CSRF token
308
+ // mismatch" — it's not in the IANA list but most SPAs already
309
+ // know how to refresh on 419. We use 403 for the strict-correct
310
+ // status code instead (419 is non-standard).
311
+ throw new HttpError(403, 'CSRF token mismatch')
312
+ }
313
+
314
+ export async function validateCsrfRequest(request: Request | EnhancedRequest): Promise<void> {
315
+ assertValidCsrfRequest(request)
272
316
  }
273
317
 
274
318
  export default new Middleware({
@@ -277,7 +321,7 @@ export default new Middleware({
277
321
  // we're going to reject anyway. After maintenance/throttle though.
278
322
  priority: 2,
279
323
 
280
- async handle(request) {
281
- await validateCsrfRequest(request)
324
+ handle(request) {
325
+ assertValidCsrfRequest(request)
282
326
  },
283
327
  })
package/app/Middleware.ts CHANGED
@@ -4,8 +4,9 @@ import { defineMiddleware } from '@stacksjs/router'
4
4
  * The application's middleware aliases.
5
5
  *
6
6
  * Aliases may be used instead of class names to conveniently assign middleware
7
- * to routes and groups. Each one names a class under `app/Middleware/`, or one
8
- * of the framework defaults behind it, and the name is checked.
7
+ * to API routes, route groups, and stx pages through `definePageMeta`. Each one
8
+ * names a class under `app/Middleware/`, or one of the framework defaults
9
+ * behind it, and the name is checked.
9
10
  *
10
11
  * This map is MERGED over the framework defaults rather than replacing them,
11
12
  * so an alias the framework adds later is available here without an edit.
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.74.27",
5
+ "version": "0.74.28",
6
6
  "description": "A modern Stacks development environment.",
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.74.27",
5
+ "version": "0.74.28",
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.27",
58
- "@stacksjs/mobile": "^0.74.27",
58
+ "@stacksjs/mobile": "^0.74.28",
59
59
  "@stacksjs/sanitizer": "^0.2.113",
60
60
  "ts-qr-codes": "^0.1.8"
61
61
  }
@@ -39,7 +39,7 @@ const resourceLinks = [
39
39
  {
40
40
  title: 'Documentation',
41
41
  description: 'Guides and API references',
42
- href: 'https://docs.stacksjs.org',
42
+ href: 'https://docs.stacksjs.com',
43
43
  icon: 'i-hugeicons-book-02',
44
44
  tone: 'blue',
45
45
  },