@stacksjs/defaults 0.74.27 → 0.74.29

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
@@ -102,7 +102,6 @@ generated model types stay precise.
102
102
  | `useApi` | Generates REST actions and routes: `{ uri, routes, middleware? }` |
103
103
  | `useSearch` (alias `searchable`) | Search-engine indexing: `{ displayable, searchable, sortable, filterable }` |
104
104
  | `useSocials` | OAuth identities, e.g. `['github']` |
105
- | `useActivityLog` | Writes an `Activity` row per change |
106
105
  | `observe` | Emits `{model}:created` / `:updated` / `:deleted` events |
107
106
  | `billable` | Stripe methods (`checkout()`, `activeSubscription()`, ...) |
108
107
  | `taggable` / `categorizable` / `commentable` / `likeable` | Pivot tables and their relation methods |
@@ -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,18 @@ 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='
66
+ const CSRF_COOKIE_SUFFIX = '; Path=/; SameSite=Lax; Max-Age=7200'
67
+ const CSRF_COOKIE_SECURE_SUFFIX = `${CSRF_COOKIE_SUFFIX}; Secure`
63
68
  const TOKEN_BYTES = 32
69
+ const TOKEN_HEX_LENGTH = TOKEN_BYTES * 2
70
+ const TOKEN_RANDOM_BYTES = Buffer.allocUnsafe(TOKEN_BYTES)
64
71
  const TOKEN_ENCODER = new TextEncoder()
72
+ const LEFT_TOKEN_BYTES = new Uint8Array(TOKEN_HEX_LENGTH)
73
+ const RIGHT_TOKEN_BYTES = new Uint8Array(TOKEN_HEX_LENGTH)
65
74
 
66
75
  /**
67
76
  * Generate a fresh CSRF token (hex-encoded, 32 random bytes → 64 chars).
@@ -73,7 +82,8 @@ const TOKEN_ENCODER = new TextEncoder()
73
82
  * ```
74
83
  */
75
84
  export function generateCsrfToken(): string {
76
- return randomBytes(TOKEN_BYTES).toString('hex')
85
+ crypto.getRandomValues(TOKEN_RANDOM_BYTES)
86
+ return TOKEN_RANDOM_BYTES.toHex()
77
87
  }
78
88
 
79
89
  /**
@@ -120,16 +130,25 @@ function responseAlreadySeeds(response: Response): boolean {
120
130
  )
121
131
  }
122
132
 
123
- export function seedCsrfCookieIfMissing(req: Request, response: Response, minted?: string): Response {
124
- const cookieHeader = req.headers.get('cookie') || ''
133
+ export function createCsrfCookie(req: Request, minted?: string): string {
134
+ const token = minted || generateCsrfToken()
135
+ const knownSecureTransport = (req as unknown as Record<symbol, unknown>)[CSRF_SECURE_TRANSPORT]
136
+ const suffix = knownSecureTransport === true || (knownSecureTransport === undefined && req.url.startsWith('https://'))
137
+ ? CSRF_COOKIE_SECURE_SUFFIX
138
+ : CSRF_COOKIE_SUFFIX
139
+ return `${CSRF_COOKIE_PREFIX}${token}${suffix}`
140
+ }
125
141
 
142
+ export function seedCsrfCookieIfMissing(req: Request, response: Response, minted?: string, responseHasNoCookies = false): Response {
126
143
  // A token the router minted before rendering wins over "the header already
127
144
  // has one", because it put that value in the header itself - and the page
128
145
  // has already embedded it in every form it drew. Generating a second token
129
146
  // here would store one string in the browser while the page carries another,
130
147
  // 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
148
+ if (!minted) {
149
+ const cookieHeader = req.headers.get('cookie') || ''
150
+ if (cookieHeader.includes(`${CSRF_COOKIE_NAME}=`) || cookieHeader.includes('csrf-token='))
151
+ return response
133
152
  }
134
153
 
135
154
  // A token already on its way to the browser counts as present, exactly like
@@ -138,18 +157,10 @@ export function seedCsrfCookieIfMissing(req: Request, response: Response, minted
138
157
  // and appending a second here would leave the browser storing the last
139
158
  // Set-Cookie while the page embedded the first. Two tokens fail the same way
140
159
  // no token does, and are far harder to see.
141
- if (responseAlreadySeeds(response))
160
+ if (!responseHasNoCookies && responseAlreadySeeds(response))
142
161
  return response
143
162
 
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('; ')
163
+ const cookie = createCsrfCookie(req, minted)
153
164
 
154
165
  // Append (not Set) so multiple Set-Cookie headers can coexist with any
155
166
  // cookies the action handler set itself.
@@ -175,6 +186,17 @@ export function seedCsrfCookieIfMissing(req: Request, response: Response, minted
175
186
  function csrfCookieToken(req: Request): string {
176
187
  const header = req.headers.get('cookie')
177
188
  if (!header) return ''
189
+ // The cookie this framework emits has one exact, whitespace-free shape.
190
+ // Recognize it before scanning for separators or trimming; browsers send
191
+ // this common single-cookie form on every authenticated unsafe request.
192
+ if (header.length === CSRF_COOKIE_PREFIX.length + TOKEN_HEX_LENGTH && header.startsWith(CSRF_COOKIE_PREFIX))
193
+ return header.slice(CSRF_COOKIE_PREFIX.length)
194
+ if (!header.includes(';')) {
195
+ if (header.startsWith(CSRF_COOKIE_PREFIX))
196
+ return header.slice(CSRF_COOKIE_PREFIX.length).trim()
197
+ if (header.startsWith(LEGACY_CSRF_COOKIE_PREFIX))
198
+ return header.slice(LEGACY_CSRF_COOKIE_PREFIX.length).trim()
199
+ }
178
200
  let canonical = ''
179
201
  let legacy = ''
180
202
  for (const part of header.split(';')) {
@@ -197,6 +219,22 @@ function safeEqual(a: string, b: string): boolean {
197
219
  if (typeof a !== 'string' || typeof b !== 'string') return false
198
220
  if (a.length !== b.length) return false
199
221
  try {
222
+ // Generated tokens are fixed-width ASCII hex. Reuse module-local scratch
223
+ // arrays for that dominant path: this function is synchronous, so another
224
+ // request cannot interleave between encoding and comparison. If either
225
+ // value is not 64 bytes of UTF-8, retain the allocation-based fallback.
226
+ if (a.length === TOKEN_HEX_LENGTH) {
227
+ const left = TOKEN_ENCODER.encodeInto(a, LEFT_TOKEN_BYTES)
228
+ const right = TOKEN_ENCODER.encodeInto(b, RIGHT_TOKEN_BYTES)
229
+ if (
230
+ left.read === TOKEN_HEX_LENGTH
231
+ && left.written === TOKEN_HEX_LENGTH
232
+ && right.read === TOKEN_HEX_LENGTH
233
+ && right.written === TOKEN_HEX_LENGTH
234
+ ) {
235
+ return timingSafeEqual(LEFT_TOKEN_BYTES, RIGHT_TOKEN_BYTES)
236
+ }
237
+ }
200
238
  return timingSafeEqual(TOKEN_ENCODER.encode(a), TOKEN_ENCODER.encode(b))
201
239
  }
202
240
  catch {
@@ -210,12 +248,10 @@ function safeEqual(a: string, b: string): boolean {
210
248
  * an ambient cookie credential, so cross-site forgery doesn't apply).
211
249
  */
212
250
  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 ')
251
+ const auth = req.headers.get('authorization')
252
+ return typeof auth === 'string' && /^bearer /i.test(auth)
215
253
  }
216
254
 
217
- const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
218
-
219
255
  /**
220
256
  * Validate one request with the framework's native CSRF contract.
221
257
  *
@@ -223,18 +259,16 @@ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
223
259
  * the router, such as the local dashboard config editor, can enforce the same
224
260
  * double-submit and bearer-token rules without duplicating security logic.
225
261
  */
226
- export async function validateCsrfRequest(request: Request | EnhancedRequest): Promise<void> {
227
- const method = request.method.toUpperCase()
262
+ function assertValidCsrfRequest(request: Request | EnhancedRequest): void {
263
+ // Fetch normalizes standard methods when constructing the Request.
264
+ const method = request.method
228
265
 
229
266
  // Safe methods don't mutate state — no token check needed.
230
267
  // Token *seeding* (set the cookie if it's missing) happens after
231
268
  // the response is built; we don't have a post-response hook
232
269
  // here, so action handlers / SPAs can call `generateCsrfToken()`
233
270
  // 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
271
+ if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') return
238
272
 
239
273
  // Per-action opt-out: an action exporting `skipCsrf: true` (or
240
274
  // `csrf: false`) has declared it can't participate in CSRF
@@ -247,8 +281,13 @@ export async function validateCsrfRequest(request: Request | EnhancedRequest): P
247
281
  // Look up the submitted token. Header is the SPA path; body field
248
282
  // is the traditional form-post path. We accept either.
249
283
  const headerToken = request.headers.get(CSRF_HEADER_NAME)
250
- || request.headers.get('X-CSRF-Token')
251
- || request.headers.get('X-Csrf-Token')
284
+
285
+ // A browser or SPA that supplied an explicit CSRF header should take the
286
+ // matching-token path first. Bearer-only clients still exit immediately,
287
+ // while a malformed CSRF header on a bearer request retains the exemption
288
+ // after validation fails below.
289
+ if (!headerToken && hasBearerToken(request)) return
290
+
252
291
  const body = enhanced.jsonBody || enhanced.formBody || {}
253
292
  // A parsed body is `unknown`-valued: the token is validated as a string
254
293
  // two lines down, so read it as one rather than asserting it is one.
@@ -262,13 +301,20 @@ export async function validateCsrfRequest(request: Request | EnhancedRequest): P
262
301
 
263
302
  const cookieToken = csrfCookieToken(request)
264
303
 
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
- }
304
+ if (submitted && cookieToken && safeEqual(submitted, cookieToken)) return
305
+
306
+ // Preserve bearer precedence when a client happens to send both headers.
307
+ if (headerToken && hasBearerToken(request)) return
308
+
309
+ // 419 is the convention Laravel popularized for "CSRF token
310
+ // mismatch" — it's not in the IANA list but most SPAs already
311
+ // know how to refresh on 419. We use 403 for the strict-correct
312
+ // status code instead (419 is non-standard).
313
+ throw new HttpError(403, 'CSRF token mismatch')
314
+ }
315
+
316
+ export async function validateCsrfRequest(request: Request | EnhancedRequest): Promise<void> {
317
+ assertValidCsrfRequest(request)
272
318
  }
273
319
 
274
320
  export default new Middleware({
@@ -277,7 +323,7 @@ export default new Middleware({
277
323
  // we're going to reject anyway. After maintenance/throttle though.
278
324
  priority: 2,
279
325
 
280
- async handle(request) {
281
- await validateCsrfRequest(request)
326
+ handle(request) {
327
+ assertValidCsrfRequest(request)
282
328
  },
283
329
  })
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.29",
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.29",
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.29",
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
  },
@@ -19,7 +19,48 @@ const candidates = [
19
19
  join(process.cwd(), 'node_modules/@stacksjs/components/src/ui'),
20
20
  ].filter((dir): dir is string => Boolean(dir))
21
21
 
22
+ const library = candidates.find(dir => existsSync(dir)) ?? candidates[candidates.length - 1]
23
+
24
+ /**
25
+ * Component directories contributed by installed packages.
26
+ *
27
+ * Wrapped on its own rather than left to the plugin loader's catch. stx wraps
28
+ * plugin loading in `try { … } catch { console.warn(…) }`, so an exception
29
+ * raised here would drop the WHOLE plugin - and this plugin is what supplies
30
+ * the dashboard's sidebar. Degrading to "no package components" keeps the
31
+ * library and the framework defaults working.
32
+ *
33
+ * `@stacksjs/config` is imported for the resolution rather than re-implemented,
34
+ * because it carries the guard that refuses a path escaping the package.
35
+ */
36
+ function packageDirs(): string[] {
37
+ try {
38
+ // eslint-disable-next-line ts/no-require-imports
39
+ const { packageComponentRoots } = require('@stacksjs/config')
40
+ return packageComponentRoots().map((root: { dir: string }) => root.dir)
41
+ }
42
+ catch {
43
+ return []
44
+ }
45
+ }
46
+
22
47
  export default {
23
48
  name: '@stacksjs/components',
24
- components: candidates.find(dir => existsSync(dir)) ?? candidates[candidates.length - 1],
49
+ /*
50
+ * Order is the precedence, because stx searches this list and takes the
51
+ * first match.
52
+ *
53
+ * The framework's own components are listed explicitly, ahead of any
54
+ * package's. They already resolve today through stx's fallback directory, so
55
+ * naming them here changes nothing on its own - what it does is put a floor
56
+ * under the package roots, making an installed package purely ADDITIVE. A
57
+ * package shipping its own `Button.stx` gets it used where nothing else
58
+ * defines `<Button />`, and cannot quietly replace the framework's.
59
+ */
60
+ components: [
61
+ library,
62
+ // Relative to this file, which is what stx resolves plugin entries against.
63
+ 'resources/components',
64
+ ...packageDirs(),
65
+ ],
25
66
  }