@stacksjs/defaults 0.74.46 → 0.74.47

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.
@@ -122,9 +122,9 @@ if (!result.ok) {
122
122
  // result.coupon reflects the post-redemption state.
123
123
  ```
124
124
 
125
- `redeem` bumps `usage_count` and enforces `max_uses`, `is_active` and the
125
+ `redeem` bumps `usage_count` and enforces `usage_limit`, `is_active` and the
126
126
  start/end dates in the WHERE clause, so the database decides the race. A
127
- `max_uses` of `NULL` means unlimited. Do not fetch, check and then call
127
+ `usage_limit` of `NULL` means unlimited. Do not fetch, check and then call
128
128
  `update()` to increment: that is the exact pattern this replaced.
129
129
 
130
130
  ### Spending or reloading a gift card
@@ -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/` (228 migration files, `.sql` format)
15
+ - Migrations: `database/migrations/` (229 migration files, `.sql` format)
16
16
  - QB state: `.qb/`
17
17
  - ORM: `storage/framework/orm/`
18
18
 
@@ -262,6 +262,10 @@ Entity-centric API for single-table design:
262
262
  // skips query hooks unless persistent history is explicitly enabled.
263
263
  enabled: env.DB_QUERY_LOGGING_ENABLED ?? !['production', 'prod'].includes(env.APP_ENV || ''),
264
264
  captureAllTraces: false, // slow and failed queries always keep traces
265
+ // Bound values in query_logs.bindings, credentials stored as `<redacted>`
266
+ // (by name and shape, so not every secret); production keeps only each
267
+ // value's type unless this is enabled. Env takes true/false, 1/0, yes/no, on/off.
268
+ captureBindings: env.DB_QUERY_LOGGING_CAPTURE_BINDINGS ?? !['production', 'prod'].includes(env.APP_ENV || ''),
265
269
  slowThreshold: 100, // ms
266
270
  retention: 7, // days
267
271
  pruneFrequency: 24, // hours
@@ -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 228 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 229 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
- - 228 migration files exist by default for all framework models
122
+ - 229 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,23 +1,26 @@
1
1
  import { Action } from '@stacksjs/actions'
2
- import { Auth, clearAuthCookie } from '@stacksjs/auth'
3
- import { response } from '@stacksjs/router'
2
+ import { Auth, clearAuthCookie, requestToken } from '@stacksjs/auth'
3
+ import { getCurrentRequest, response } from '@stacksjs/router'
4
4
 
5
5
  export default new Action({
6
6
  name: 'LogoutAction',
7
7
  description: 'Logout from the application',
8
8
  method: 'POST',
9
9
  async handle() {
10
- // `Auth.logout()` resolves the token from the Authorization header or the
11
- // auth cookie, so this revokes the session either way.
10
+ const request = getCurrentRequest()
11
+ const usesDatabaseSession = !requestToken(request) && !!request?.cookie?.('session_id')
12
12
  await Auth.logout()
13
13
 
14
14
  // Clearing is separate from revoking, and both are needed. Revoking alone
15
15
  // leaves the browser sending a dead cookie on every request; clearing alone
16
16
  // leaves a copied cookie valid for the token's whole lifetime, which would
17
17
  // make "log out on a shared computer" mean only "hide the key".
18
- return response.json(
18
+ const result = response.json(
19
19
  { message: 'Successfully logged out' },
20
20
  { headers: { 'Set-Cookie': clearAuthCookie() } },
21
21
  )
22
+ if (usesDatabaseSession)
23
+ result.headers.append('Set-Cookie', clearAuthCookie({ name: 'session_id' }))
24
+ return result
22
25
  },
23
26
  })
@@ -1,10 +1,10 @@
1
1
  import type { RequestInstance } from '@stacksjs/types'
2
2
  import { Action } from '@stacksjs/actions'
3
- import { db } from '@stacksjs/database'
3
+ import { db, getDatabaseDialect, sqlHelpers } from '@stacksjs/database'
4
4
  import { response } from '@stacksjs/router'
5
- import { TeamStateConflictError, teamOperationalError } from '../../Teams/team-response'
5
+ import { teamOperationalError } from '../../Teams/team-response'
6
6
  import { syncTeamMemberCount } from './team-member-count'
7
- import { changedRows, normalizeInvitationRole, parsePositiveId, sqlTimestamp } from './team-records'
7
+ import { normalizeInvitationRole, parsePositiveId, sqlTimestamp } from './team-records'
8
8
 
9
9
  interface UpdateInput {
10
10
  role?: unknown
@@ -27,12 +27,41 @@ export default new Action({
27
27
  try {
28
28
  return await db.transaction(async (rawTrx) => {
29
29
  const trx = rawTrx as unknown as typeof db
30
- const member = await trx
30
+ /*
31
+ * Lock the member for the rest of this transaction, then decide.
32
+ *
33
+ * This used to read the row unlocked and let the UPDATE's own
34
+ * `role != 'owner'` predicate catch a concurrent ownership transfer,
35
+ * treating an affected-row count other than 1 as "changed underneath
36
+ * us" and answering 409. That count cannot carry the decision: MySQL
37
+ * reports rows CHANGED, not rows MATCHED, so saving a member with the
38
+ * role and status they already have reported 0, and a double submit
39
+ * or any save in the same second as their last write (`sqlTimestamp`
40
+ * has second precision) came back 409 "The team member changed before
41
+ * they could be updated" for a member nobody had touched
42
+ * (stacksjs/stacks#2639).
43
+ *
44
+ * With the row locked, nothing can change it between this read and
45
+ * the write, so the checks below are made against the state the write
46
+ * will actually land on, and the count is not needed at all. A
47
+ * concurrent ownership transfer now waits for this transaction and
48
+ * then applies, instead of racing it. Measured on PostgreSQL 16 and
49
+ * MySQL 8.4.5: unlocked, a second connection made the member an owner
50
+ * mid-transaction; locked, it hit its lock timeout.
51
+ *
52
+ * SQLite has no `FOR UPDATE` and does not need one: a writer that
53
+ * commits after this read makes the UPDATE below fail when it tries to
54
+ * upgrade the transaction, so nothing is silently overwritten there
55
+ * either. auth/src/tokens.ts takes the same lock the same way.
56
+ */
57
+ let lookup = trx
31
58
  .selectFrom('team_members')
32
59
  .where('id', '=', memberId)
33
60
  .where('team_id', '=', teamId)
34
61
  .select(['id', 'role', 'status'])
35
- .executeTakeFirst()
62
+ if (!sqlHelpers(getDatabaseDialect()).isSqlite)
63
+ lookup = lookup.lockForUpdate()
64
+ const member = await lookup.executeTakeFirst()
36
65
  if (!member)
37
66
  return response.json({ message: 'Team member not found.' }, 404)
38
67
  if (member.role === 'owner')
@@ -45,23 +74,21 @@ export default new Action({
45
74
  if (!['active', 'suspended'].includes(status))
46
75
  return response.json({ message: 'Choose a valid member status.' }, 422)
47
76
 
48
- const updated = await trx
77
+ // The owner predicate stays as a second line of defence; under the
78
+ // lock it always matches, which is why its count is not read.
79
+ await trx
49
80
  .updateTable('team_members')
50
81
  .set({ role, status, updated_at: sqlTimestamp() })
51
82
  .where('id', '=', memberId)
52
83
  .where('team_id', '=', teamId)
53
84
  .where('role', '!=', 'owner')
54
- .executeTakeFirst()
55
- if (changedRows(updated) !== 1)
56
- throw new TeamStateConflictError('The team member changed before they could be updated.')
85
+ .execute()
57
86
 
58
87
  await syncTeamMemberCount(teamId, trx)
59
88
  return { member: { id: memberId, role, status } }
60
89
  })
61
90
  }
62
91
  catch (error) {
63
- if (error instanceof TeamStateConflictError)
64
- return response.json({ message: error.message }, 409)
65
92
  return teamOperationalError(error, 'The team member could not be updated.', 'TeamMemberUpdateAction', 500)
66
93
  }
67
94
  },
@@ -1,7 +1,43 @@
1
1
  import { config } from '@stacksjs/config'
2
- import { db, sql } from '@stacksjs/database'
2
+ import { db, mutationCount, sql, sqlDateTime } from '@stacksjs/database'
3
3
  import { Controller } from '@stacksjs/server'
4
4
 
5
+ const DAY_MS = 86_400_000
6
+ /** `2026-09-10T11` - the stored timestamp truncated to its hour. */
7
+ const HOUR_PREFIX = 13
8
+ /** `2026-09-10` - truncated to its day. */
9
+ const DAY_PREFIX = 10
10
+
11
+ /**
12
+ * The hour bucket as the dashboard reads it: `2026-09-10 11:00:00`.
13
+ *
14
+ * Buckets are prefixes of the stored timestamp rather than the output of a
15
+ * date function, because `strftime` and `datetime` exist only on SQLite. The
16
+ * stored format is fixed (see `sqlDateTime`), so a prefix is exact, and the
17
+ * label is restored here rather than in SQL.
18
+ */
19
+ function hourLabel(bucket: string): string {
20
+ return `${String(bucket).replace('T', ' ')}:00:00`
21
+ }
22
+
23
+ /**
24
+ * The query type is the first element of the JSON array in `tags`, written by
25
+ * the query logger. Read here rather than in SQL: the JSON functions differ
26
+ * per dialect and PostgreSQL has none by these names.
27
+ */
28
+ function queryTypeFromTags(tags: string | null | undefined): string {
29
+ if (!tags)
30
+ return 'unknown'
31
+ try {
32
+ const parsed = JSON.parse(tags) as unknown
33
+ const first = Array.isArray(parsed) ? parsed[0] : undefined
34
+ return typeof first === 'string' && first ? first : 'unknown'
35
+ }
36
+ catch {
37
+ return 'unknown'
38
+ }
39
+ }
40
+
5
41
  export default class QueryController extends Controller {
6
42
  /**
7
43
  * Get query statistics for the dashboard
@@ -14,15 +50,30 @@ export default class QueryController extends Controller {
14
50
  .select(db.fn.count('id').as('count'))
15
51
  .executeTakeFirstOrThrow()
16
52
 
17
- // Get counts by query type
18
- const queryTypeStats = await db
53
+ // `tags` is a string column holding a JSON array whose first element is
54
+ // the query type. Grouping by the whole column keeps this portable:
55
+ // `json_extract` is SQLite and MySQL only, and PostgreSQL has no such
56
+ // function, so the previous projection made this endpoint fail there.
57
+ // The distinct tag sets are few, so folding them by type here is cheap.
58
+ const tagStats = await db
19
59
  .selectFrom('query_logs')
20
60
  .select([
21
- sql`json_extract(tags, "$[0]")`.as('type'),
61
+ 'tags',
22
62
  db.fn.count('id').as('count'),
63
+ db.fn.avg('duration').as('avg_duration'),
23
64
  ])
24
- .groupBy(sql`json_extract(tags, "$[0]")`)
25
- .execute()
65
+ .groupBy('tags')
66
+ .execute() as Array<{ tags: string | null, count: number | string, avg_duration: number | string | null }>
67
+
68
+ const byType = new Map<string, { count: number, durationTotal: number }>()
69
+ for (const row of tagStats) {
70
+ const type = queryTypeFromTags(row.tags)
71
+ const count = Number(row.count) || 0
72
+ const entry = byType.get(type) ?? { count: 0, durationTotal: 0 }
73
+ entry.count += count
74
+ entry.durationTotal += Number(row.avg_duration ?? 0) * count
75
+ byType.set(type, entry)
76
+ }
26
77
 
27
78
  // Get counts by status
28
79
  const statusStats = await db
@@ -31,35 +82,31 @@ export default class QueryController extends Controller {
31
82
  .groupBy('status')
32
83
  .execute()
33
84
 
34
- // Get average duration by query type
35
- const durationStats = await db
36
- .selectFrom('query_logs')
37
- .select([
38
- sql`json_extract(tags, "$[0]")`.as('type'),
39
- db.fn.avg('duration').as('avg_duration'),
40
- ])
41
- .groupBy(sql`json_extract(tags, "$[0]")`)
42
- .execute()
43
-
44
85
  // Get count of slow queries over time (last 24 hours)
45
86
  const slowQueriesTimeline = await db
46
87
  .selectFrom('query_logs')
47
88
  .select([
48
- sql`strftime("%Y-%m-%d %H:00:00", executed_at)`.as('hour'),
89
+ sql`substr(executed_at, 1, 13)`.as('hour'),
49
90
  db.fn.count('id').as('count'),
50
91
  ])
51
92
  .where('status', '=', 'slow')
52
- .whereRaw(sql`executed_at >= datetime("now", "-1 day")`)
53
- .groupBy(sql`strftime("%Y-%m-%d %H:00:00", executed_at)`)
93
+ .where('executed_at', '>=', sqlDateTime(new Date(Date.now() - 86_400_000)))
94
+ .groupBy('hour')
54
95
  .orderBy('hour')
55
- .execute()
96
+ .execute() as Array<{ hour: string, count: number | string }>
56
97
 
57
98
  return {
58
99
  totalQueries: totalQueries.count,
59
- byType: queryTypeStats,
100
+ byType: [...byType].map(([type, entry]) => ({ type, count: entry.count })),
60
101
  byStatus: statusStats,
61
- avgDuration: durationStats,
62
- slowQueriesTimeline,
102
+ avgDuration: [...byType].map(([type, entry]) => ({
103
+ type,
104
+ avg_duration: entry.count === 0 ? 0 : entry.durationTotal / entry.count,
105
+ })),
106
+ slowQueriesTimeline: slowQueriesTimeline.map(row => ({
107
+ hour: hourLabel(row.hour),
108
+ count: Number(row.count) || 0,
109
+ })),
63
110
  // Include system settings for reference
64
111
  settings: {
65
112
  slowThreshold: config.database?.queryLogging?.slowThreshold || 100,
@@ -278,38 +325,43 @@ export default class QueryController extends Controller {
278
325
  type = 'all',
279
326
  }) {
280
327
  try {
281
- let interval: string
282
- let timeConstraint: string
328
+ let bucketWidth: number
329
+ let windowMs: number
283
330
 
284
- // Set time grouping format and constraint based on timeframe
331
+ // Set the bucket width and how far back to look, per timeframe.
285
332
  switch (timeframe) {
286
333
  case 'week':
287
- interval = '%Y-%m-%d'
288
- timeConstraint = '-7 day'
334
+ bucketWidth = DAY_PREFIX
335
+ windowMs = 7 * DAY_MS
289
336
  break
290
337
  case 'month':
291
- interval = '%Y-%m-%d'
292
- timeConstraint = '-30 day'
338
+ bucketWidth = DAY_PREFIX
339
+ windowMs = 30 * DAY_MS
293
340
  break
294
341
  case 'day':
295
342
  default:
296
- interval = '%Y-%m-%d %H:00:00'
297
- timeConstraint = '-24 hour'
343
+ bucketWidth = HOUR_PREFIX
344
+ windowMs = DAY_MS
298
345
  break
299
346
  }
300
347
 
301
348
  let query = db
302
349
  .selectFrom('query_logs')
303
350
  .select([
304
- // `interval` and `timeConstraint` are chosen from the fixed set a
305
- // few lines above, never from the request, so interpolating them
306
- // into the fragment is safe. `sql.literal` does not exist on this
307
- // tag, which is what made this a type error.
308
- sql`strftime('${interval}', executed_at)`.as('time_interval'),
351
+ // Timestamps are stored in one canonical format, so the bucket is a
352
+ // prefix of the stored text: 13 characters for an hour, 10 for a
353
+ // day. `strftime` and `datetime` are SQLite-only, and the value in
354
+ // `sql`...`` was interpolated as a quoted `'${interval}'`, which the
355
+ // tag rendered as a literal `'?'`, so this returned nothing at all,
356
+ // on every dialect.
357
+ // Two fixed fragments rather than one interpolated width: a value
358
+ // inside the tag is bound as a parameter, and a parameter here
359
+ // throws the builder's own count off.
360
+ (bucketWidth === HOUR_PREFIX ? sql`substr(executed_at, 1, 13)` : sql`substr(executed_at, 1, 10)`).as('time_interval'),
309
361
  db.fn.count('id').as('count'),
310
362
  db.fn.avg('duration').as('avg_duration'),
311
363
  ])
312
- .whereRaw(sql`executed_at >= datetime("now", '${timeConstraint}')`)
364
+ .where('executed_at', '>=', sqlDateTime(new Date(Date.now() - windowMs)))
313
365
  .groupBy('time_interval')
314
366
  .orderBy('time_interval')
315
367
 
@@ -317,10 +369,13 @@ export default class QueryController extends Controller {
317
369
  if (type !== 'all')
318
370
  query = query.where('tags', 'like', `%"${type}"%`)
319
371
 
320
- const results = await query.execute()
372
+ const results = await query.execute() as Array<{ time_interval: string, count: number | string, avg_duration: number | string | null }>
321
373
 
322
374
  return {
323
- data: results,
375
+ data: results.map(row => ({
376
+ ...row,
377
+ time_interval: bucketWidth === HOUR_PREFIX ? hourLabel(row.time_interval) : row.time_interval,
378
+ })),
324
379
  meta: {
325
380
  timeframe,
326
381
  type,
@@ -397,16 +452,23 @@ export default class QueryController extends Controller {
397
452
  try {
398
453
  const retentionDays = config.database?.queryLogging?.retention || 7
399
454
 
400
- // Parameterized via db.unsafe: the delete builder can neither
401
- // bind a datetime() expression nor render raw WHERE fragments.
402
- const statement = await (db as any).unsafe(
403
- `DELETE FROM query_logs WHERE executed_at < datetime("now", ?)`,
404
- [`-${retentionDays} day`],
405
- )
406
- const result = typeof statement?.execute === 'function' ? await statement.execute() : statement
455
+ // The cutoff is computed here rather than in SQL. `datetime("now", ?)`
456
+ // is SQLite syntax, so this statement was a syntax error on PostgreSQL
457
+ // and MySQL and pruned nothing there. It was not right on SQLite either:
458
+ // `datetime()` renders `2026-09-10 11:00:00` while the logger writes
459
+ // `sqlDateTime()`'s `2026-09-10T11:00:00.000`, and comparing those as
460
+ // text keeps rows up to a day past their retention, because 'T' > ' '.
461
+ // A bound cutoff in the stored format compares correctly everywhere.
462
+ const cutoff = sqlDateTime(new Date(Date.now() - retentionDays * 86_400_000))
463
+ const result = await db
464
+ .deleteFrom('query_logs')
465
+ .where('executed_at', '<', cutoff)
466
+ .executeTakeFirst()
407
467
 
408
468
  return {
409
- pruned: Number(result?.changes ?? result?.numDeletedRows ?? 0),
469
+ // Each driver names the affected-row count differently, and the pair
470
+ // read here covered only SQLite.
471
+ pruned: mutationCount(result),
410
472
  retentionDays,
411
473
  }
412
474
  }
@@ -14,6 +14,24 @@ export default defineModel({
14
14
  { name: 'categorizables_type_slug_unique', columns: ['categorizable_type', 'slug'], unique: true },
15
15
  { name: 'categorizables_owner_slug_unique', columns: ['categorizable_type', 'categorizable_id', 'slug'], unique: true },
16
16
  ],
17
+ // The inverse of Post.belongsToMany.categories, mirroring Tag.belongsToMany.posts.
18
+ // It lives here because this model owns the ids `category_id` stores, and the
19
+ // declaring model's table is what the generator references for `foreignKey`.
20
+ belongsToMany: {
21
+ posts: {
22
+ model: 'Post',
23
+ table: 'categorizable_models',
24
+ foreignKey: 'category_id',
25
+ relatedKey: 'categorizable_id',
26
+ pivot: {
27
+ columns: {
28
+ categorizable_type: { default: 'posts' },
29
+ },
30
+ timestamps: true,
31
+ uniques: [['category_id', 'categorizable_id', 'categorizable_type']],
32
+ },
33
+ },
34
+ },
17
35
  attributes: {
18
36
  name: {
19
37
  required: true,
@@ -41,21 +41,12 @@ export default defineModel({
41
41
  },
42
42
 
43
43
  hasMany: ['Product'],
44
- belongsToMany: {
45
- posts: {
46
- model: 'Post',
47
- table: 'categorizable_models',
48
- foreignKey: 'category_id',
49
- relatedKey: 'categorizable_id',
50
- pivot: {
51
- columns: {
52
- categorizable_type: { default: 'posts' },
53
- },
54
- timestamps: true,
55
- uniques: [['category_id', 'categorizable_id', 'categorizable_type']],
56
- },
57
- },
58
- },
44
+
45
+ // No `belongsToMany.posts` here. Post categories live in the CMS pivot
46
+ // `categorizable_models`, whose `category_id` holds `categorizables` ids, so
47
+ // the inverse belongs on Categorizable. Declaring it on this model made the
48
+ // generator emit `category_id REFERENCES "categories"`, which rejected every
49
+ // CMS category link once foreign keys were enforced (stacksjs/stacks#2593).
59
50
 
60
51
  attributes: {
61
52
  name: {
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.74.46",
5
+ "version": "0.74.47",
6
6
  "description": "A modern Stacks development environment.",
7
7
  "license": "MIT",
8
8
  "funding": "https://github.com/sponsors/chrisbbreuer",
@@ -74,6 +74,6 @@
74
74
  "streetsidesoftware.code-spell-checker"
75
75
  ],
76
76
  "devDependencies": {
77
- "@vscode/vsce": "^3.7.2-12"
77
+ "@vscode/vsce": "^4.0.0"
78
78
  }
79
79
  }
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.46",
5
+ "version": "0.74.47",
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.46",
58
+ "@stacksjs/mobile": "^0.74.47",
59
59
  "@stacksjs/sanitizer": "^0.2.113",
60
60
  "ts-qr-codes": "^0.1.8"
61
61
  }
@@ -16,6 +16,7 @@
16
16
  * unauthenticated.
17
17
  */
18
18
 
19
+ import { isLocalDeployment } from '@stacksjs/env'
19
20
  import { route } from '@stacksjs/router'
20
21
 
21
22
  // The `/api/dashboard/*` surface is unauthenticated by design for the local
@@ -25,8 +26,15 @@ import { route } from '@stacksjs/router'
25
26
  // (assign-any-role-to-any-user = privilege escalation) and the model-row dump
26
27
  // (arbitrary DB read) — must be gated server-side. In a local/dev/test env the
27
28
  // guard is a no-op so the dev dashboard keeps working without a token.
28
- const APP_ENV = (process.env.APP_ENV ?? process.env.NODE_ENV ?? '').toLowerCase()
29
- const IS_LOCAL_ENV = APP_ENV === '' || APP_ENV === 'local' || APP_ENV === 'development' || APP_ENV === 'dev' || APP_ENV === 'test' || APP_ENV === 'testing'
29
+ //
30
+ // The gate is the deployment, not the environment NAME. `.env.example` ships
31
+ // `APP_ENV=development`, so every app that never edited that line called
32
+ // itself development in production - and this gate then attached no
33
+ // middleware at all to the 300-plus routes below, including the ones that
34
+ // read and rewrite the project's `.env`, write the deploy script, sync RBAC
35
+ // roles and dump arbitrary model rows. `@stacksjs/auth`'s cookie policy hit
36
+ // the same shape in stacksjs/stacks#2275 and moved to the URL; this follows.
37
+ const IS_LOCAL_ENV = isLocalDeployment()
30
38
 
31
39
  // Apply auth + admin-role middleware to a sensitive route outside local envs.
32
40
  // Returns the route builder so calls read as `guard(route.post(...))`.
@@ -15,7 +15,7 @@
15
15
  * ```
16
16
  */
17
17
 
18
- import process from 'node:process'
18
+ import { isLocalDeployment } from '@stacksjs/env'
19
19
  import { route } from '@stacksjs/router'
20
20
 
21
21
  // ============================================================================
@@ -86,8 +86,10 @@ route.health()
86
86
  // Apps that intentionally want either route in production can
87
87
  // re-register the path in `routes/api.ts` — user routes load first,
88
88
  // so their copy wins.
89
- const APP_ENV = (process.env.APP_ENV ?? process.env.NODE_ENV ?? '').toLowerCase()
90
- const IS_LOCAL_ENV = APP_ENV === '' || APP_ENV === 'local' || APP_ENV === 'development' || APP_ENV === 'dev' || APP_ENV === 'test' || APP_ENV === 'testing'
89
+ // Same rule as dashboard-api.ts: a deployment is local when its URL says so.
90
+ // The environment name alone sent these two to production in any app whose
91
+ // `.env` still carried the `APP_ENV=development` that `.env.example` ships.
92
+ const IS_LOCAL_ENV = isLocalDeployment()
91
93
 
92
94
  if (IS_LOCAL_ENV) {
93
95
  route.get('/install', 'Actions/InstallAction')