@stacksjs/defaults 0.70.293 → 0.70.296

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.
Files changed (58) hide show
  1. package/ai/skills/stacks-auth/SKILL.md +1 -1
  2. package/ai/skills/stacks-development/SKILL.md +3 -3
  3. package/ai/skills/stacks-stx/SKILL.md +3 -3
  4. package/ai/skills/stacks-ui/SKILL.md +3 -3
  5. package/app/Actions/Auth/LoginAction.ts +8 -2
  6. package/app/Actions/Auth/RegisterAction.ts +3 -2
  7. package/app/Actions/Auth/SocialCallbackAction.ts +75 -0
  8. package/app/Actions/Auth/SocialRedirectAction.ts +37 -0
  9. package/app/Actions/Dashboard/Analytics/request-analytics.ts +6 -1
  10. package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +7 -0
  11. package/app/Actions/Dashboard/Commerce/commerce-product-records.ts +3 -1
  12. package/app/Actions/Dashboard/Commerce/commerce-record.ts +13 -0
  13. package/app/Actions/Dashboard/Infrastructure/InsightsAction.ts +3 -3
  14. package/app/Actions/Dashboard/Jobs/job-records.ts +5 -1
  15. package/app/Actions/Dashboard/Kanban/BoardShowAction.ts +9 -7
  16. package/app/Actions/Dashboard/Kanban/BoardStoreAction.ts +1 -1
  17. package/app/Actions/Dashboard/Kanban/BoardsIndexAction.ts +6 -4
  18. package/app/Actions/Dashboard/Kanban/CardShowAction.ts +4 -2
  19. package/app/Actions/Dashboard/Library/GetAverageReleaseTime.ts +1 -1
  20. package/app/Actions/Dashboard/Marketing/CampaignIndexAction.ts +2 -2
  21. package/app/Actions/Dashboard/Marketing/ListIndexAction.ts +1 -1
  22. package/app/Actions/Dashboard/Queries/query-dashboard.ts +5 -3
  23. package/app/Actions/Password/PasswordResetAction.ts +29 -7
  24. package/app/Middleware/Auth.ts +4 -2
  25. package/app/Middleware/Can.ts +58 -5
  26. package/app/Models/Content/Author.ts +5 -0
  27. package/app/Models/Content/Page.ts +5 -0
  28. package/app/Models/Content/Post.ts +5 -0
  29. package/app/Models/Release.ts +5 -0
  30. package/app/Models/SocialAccount.ts +70 -0
  31. package/app/Models/Tag.ts +5 -0
  32. package/app/Models/User.ts +7 -4
  33. package/app/Models/commerce/Category.ts +5 -0
  34. package/app/Models/commerce/DeliveryRoute.ts +34 -0
  35. package/app/Models/commerce/DeliveryStop.ts +166 -0
  36. package/app/Models/commerce/Driver.ts +72 -2
  37. package/app/Models/commerce/DriverPing.ts +103 -0
  38. package/app/Models/commerce/LoyaltyReward.ts +5 -0
  39. package/app/Models/commerce/Manufacturer.ts +5 -0
  40. package/app/Models/commerce/Order.ts +44 -3
  41. package/app/Models/commerce/Product.ts +5 -0
  42. package/app/Models/commerce/ProductUnit.ts +5 -0
  43. package/app/Models/commerce/ProductVariant.ts +5 -0
  44. package/app/Models/commerce/ShippingMethod.ts +5 -0
  45. package/app/Models/commerce/ShippingRate.ts +5 -0
  46. package/app/Models/commerce/ShippingZone.ts +5 -0
  47. package/app/Models/commerce/TaxRate.ts +5 -0
  48. package/app/password-policy.ts +46 -0
  49. package/bootstrap.ts +67 -19
  50. package/ide/vscode/package.json +1 -1
  51. package/package.json +1 -1
  52. package/routes/auth.ts +83 -0
  53. package/routes/dashboard.ts +0 -61
  54. package/routes/socials.ts +32 -0
  55. package/vcs/github/CONTRIBUTING.md +1 -1
  56. package/vcs/github/workflows/README.md +8 -9
  57. package/vcs/github/workflows/release.yml +4 -181
  58. package/vcs/github/workflows/export-size.yml +0 -25
@@ -333,7 +333,7 @@ await authUser.authorize('edit-post', post) // throws if denied
333
333
  tokenExpiry: 30, // days, AUTH_TOKEN_EXPIRY env
334
334
  tokenRotation: 7, // days, AUTH_TOKEN_ROTATION env
335
335
  defaultAbilities: ['*'],
336
- defaultTokenName: 'auth_token',
336
+ defaultTokenName: 'auth-token',
337
337
  passwordReset: { expire: 60, throttle: 60 }
338
338
  }
339
339
  ```
@@ -279,9 +279,9 @@ Default configurations provided at `storage/framework/defaults/ide/`:
279
279
 
280
280
  ```typescript
281
281
  {
282
- componentsDir: 'resources/components',
283
- layoutsDir: 'resources/layouts',
284
- partialsDir: 'resources/partials',
282
+ componentsDir: 'components',
283
+ layoutsDir: 'layouts',
284
+ partialsDir: 'partials',
285
285
  }
286
286
  ```
287
287
 
@@ -127,9 +127,9 @@ dedicated signal for each binding. Arbitrary expressions such as
127
127
  import type { StxOptions } from '@stacksjs/stx'
128
128
 
129
129
  export default {
130
- componentsDir: 'resources/components',
131
- layoutsDir: 'resources/layouts',
132
- partialsDir: 'resources/partials',
130
+ componentsDir: 'components',
131
+ layoutsDir: 'layouts',
132
+ partialsDir: 'partials',
133
133
  } satisfies StxOptions
134
134
  ```
135
135
 
@@ -179,9 +179,9 @@ export default {
179
179
 
180
180
  ```typescript
181
181
  export default {
182
- componentsDir: 'resources/components',
183
- layoutsDir: 'resources/layouts',
184
- partialsDir: 'resources/partials',
182
+ componentsDir: 'components',
183
+ layoutsDir: 'layouts',
184
+ partialsDir: 'partials',
185
185
  } satisfies StxOptions
186
186
  ```
187
187
 
@@ -3,6 +3,7 @@ import { Auth, createTwoFactorChallenge, getTwoFactorState } from '@stacksjs/aut
3
3
  import { User } from '@stacksjs/orm'
4
4
  import { response } from '@stacksjs/router'
5
5
  import { schema } from '@stacksjs/validation'
6
+ import { PASSWORD_MAX_LENGTH, PASSWORD_PRESENCE_MESSAGE } from '../../password-policy'
6
7
 
7
8
  export default new Action({
8
9
  name: 'LoginAction',
@@ -14,9 +15,14 @@ export default new Action({
14
15
  rule: schema.string().email(),
15
16
  message: 'Email must be a valid email address.',
16
17
  },
18
+ // Presence only, NOT the creation policy. Enforcing a minimum length on
19
+ // sign-in locks out every account created under a shorter one: the user is
20
+ // told their own password is too short, with a 422 raised before the
21
+ // credentials are ever checked. The policy belongs on the paths that SET a
22
+ // password (#2226).
17
23
  password: {
18
- rule: schema.string().min(6).max(255),
19
- message: 'Password must be between 6 and 255 characters.',
24
+ rule: schema.string().min(1).max(PASSWORD_MAX_LENGTH),
25
+ message: PASSWORD_PRESENCE_MESSAGE,
20
26
  },
21
27
  },
22
28
 
@@ -3,6 +3,7 @@ import { Auth, register } from '@stacksjs/auth'
3
3
  import { dispatch } from '@stacksjs/events'
4
4
  import { response } from '@stacksjs/router'
5
5
  import { schema } from '@stacksjs/validation'
6
+ import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from '../../password-policy'
6
7
 
7
8
  export default new Action({
8
9
  name: 'RegisterAction',
@@ -15,8 +16,8 @@ export default new Action({
15
16
  message: 'Email must be a valid email address.',
16
17
  },
17
18
  password: {
18
- rule: schema.string().min(6).max(255),
19
- message: 'Password must be between 6 and 255 characters.',
19
+ rule: schema.string().min(PASSWORD_MIN_LENGTH).max(PASSWORD_MAX_LENGTH),
20
+ message: PASSWORD_POLICY_MESSAGE,
20
21
  },
21
22
  name: {
22
23
  rule: schema.string().min(2).max(255),
@@ -0,0 +1,75 @@
1
+ import { Action } from '@stacksjs/actions'
2
+ import { Auth, authCookie, resolveSocialSignIn, SocialSignInRefusedError } from '@stacksjs/auth'
3
+ import { log } from '@stacksjs/logging'
4
+ import { response } from '@stacksjs/router'
5
+ import { isSocialProviderConfigured, socialHandoffFailureRedirect, socialHandoffRedirect, socialProvider } from '@stacksjs/socials'
6
+
7
+ /**
8
+ * `GET|POST /auth/{provider}/callback` — complete a social sign-in
9
+ * (stacksjs/stacks#2276).
10
+ *
11
+ * The provider redirects back here with a `code`. The driver exchanges it for
12
+ * the provider's user, `resolveSocialSignIn` decides which local user that is
13
+ * (find-or-create, with the unverified-email takeover guard), and the session
14
+ * is handed to the browser twice over: the auth cookie for server-rendered
15
+ * pages, and the fragment handoff `useAuth().completeSocialLogin()` reads for
16
+ * the SPA side. POST as well as GET because Apple mandates
17
+ * `response_mode=form_post` whenever scopes are requested.
18
+ *
19
+ * A refused sign-in redirects to /login with a `social_error` the page can
20
+ * render; it is a policy decision, not an exception. Provider/exchange
21
+ * failures land there too, with the detail in the log rather than the URL.
22
+ */
23
+ export default new Action({
24
+ name: 'SocialCallbackAction',
25
+ description: 'Completes a social provider OAuth flow and signs the browser in.',
26
+ method: 'GET',
27
+
28
+ async handle(request: RequestInstance) {
29
+ const provider = String(request.getParam('provider') ?? '').toLowerCase()
30
+
31
+ if (!isSocialProviderConfigured(provider))
32
+ return response.notFound(`Social provider "${provider}" is not configured.`)
33
+
34
+ const driver = socialProvider(provider)
35
+ if (!driver)
36
+ return response.notFound(`Social provider "${provider}" is not configured.`)
37
+
38
+ const code = String(request.get('code') ?? '')
39
+ if (!code)
40
+ return socialHandoffFailureRedirect(`${provider} did not return an authorization code.`)
41
+
42
+ try {
43
+ const accessToken = await driver.getAccessToken(code)
44
+ const identity = await driver.getUserByToken(accessToken)
45
+
46
+ const { userId } = await resolveSocialSignIn(provider, identity)
47
+
48
+ const session = await Auth.loginUsingId(userId)
49
+ if (!session?.token)
50
+ return socialHandoffFailureRedirect('Sign-in could not be completed. Please try again.')
51
+
52
+ const redirect = socialHandoffRedirect({
53
+ token: session.token,
54
+ ...(session.refreshToken && { refreshToken: session.refreshToken }),
55
+ ...(session.expiresIn !== undefined && { expiresIn: session.expiresIn }),
56
+ user: {
57
+ id: userId,
58
+ email: identity.email ?? null,
59
+ name: identity.name ?? identity.nickname ?? null,
60
+ },
61
+ })
62
+
63
+ // The cookie signs server-rendered pages in; the fragment pack the SPA.
64
+ redirect.headers.append('Set-Cookie', authCookie(session.token))
65
+ return redirect
66
+ }
67
+ catch (error) {
68
+ if (error instanceof SocialSignInRefusedError)
69
+ return socialHandoffFailureRedirect(error.message)
70
+
71
+ log.error(`[socials] ${provider} callback failed: ${(error as Error).message}`)
72
+ return socialHandoffFailureRedirect('Sign-in could not be completed. Please try again.')
73
+ }
74
+ },
75
+ })
@@ -0,0 +1,37 @@
1
+ import { Action } from '@stacksjs/actions'
2
+ import { response } from '@stacksjs/router'
3
+ import { isSocialProviderConfigured, socialProvider } from '@stacksjs/socials'
4
+
5
+ /**
6
+ * `GET /auth/{provider}` — send the browser to the provider's consent page
7
+ * (stacksjs/stacks#2276).
8
+ *
9
+ * A provider that is not fully configured 404s rather than redirecting into a
10
+ * half-built OAuth flow: the sign-in page renders its buttons from
11
+ * `configuredSocialProviders()`, so a visitor only reaches this with a name
12
+ * that works — anything else is URL guessing.
13
+ */
14
+ export default new Action({
15
+ name: 'SocialRedirectAction',
16
+ description: 'Redirects the browser to a social provider\'s OAuth consent page.',
17
+ method: 'GET',
18
+
19
+ async handle(request: RequestInstance) {
20
+ const provider = String(request.getParam('provider') ?? '').toLowerCase()
21
+
22
+ if (!isSocialProviderConfigured(provider))
23
+ return response.notFound(`Social provider "${provider}" is not configured.`)
24
+
25
+ const driver = socialProvider(provider)
26
+ if (!driver)
27
+ return response.notFound(`Social provider "${provider}" is not configured.`)
28
+
29
+ return new Response(null, {
30
+ status: 302,
31
+ headers: {
32
+ 'Location': await driver.getAuthUrl(),
33
+ 'Cache-Control': 'no-store',
34
+ },
35
+ })
36
+ },
37
+ })
@@ -105,7 +105,12 @@ export function requestAnalyticsRow(record: AnalyticsModelRecord): RequestAnalyt
105
105
  if (durationMs < 0)
106
106
  throw new TypeError('Request.duration_ms cannot be negative.')
107
107
 
108
- const createdAt = requiredRecordString(record, 'created_at')
108
+ // Postgres and MySQL drivers return Date instances for timestamp columns;
109
+ // SQLite stores TEXT and returns strings.
110
+ const createdAtValue = record.get('created_at')
111
+ const createdAt = createdAtValue instanceof Date
112
+ ? createdAtValue.toISOString()
113
+ : requiredRecordString(record, 'created_at')
109
114
  if (!Number.isFinite(timestamp(createdAt)))
110
115
  throw new TypeError('Request.created_at must be a valid timestamp.')
111
116
 
@@ -79,6 +79,13 @@ function receiptOptionalText(input: unknown, source: string, fieldName: string):
79
79
  }
80
80
 
81
81
  function receiptTimestamp(input: unknown, source: string, fieldName: string): string {
82
+ // Postgres and MySQL drivers return Date instances for timestamp columns;
83
+ // SQLite stores TEXT and returns strings. Accept both.
84
+ if (input instanceof Date) {
85
+ if (!Number.isFinite(input.getTime()))
86
+ throw receiptError(source, fieldName, 'a valid timestamp')
87
+ return input.toISOString()
88
+ }
82
89
  const raw = receiptText(input, source, fieldName)
83
90
  const date = new Date(/^\d{4}-\d{2}-\d{2} \d/.test(raw) ? `${raw.replace(' ', 'T')}Z` : raw)
84
91
  if (!Number.isFinite(date.getTime()))
@@ -1,3 +1,5 @@
1
+ import { commerceTimestamp } from './commerce-record'
2
+
1
3
  export interface CommerceProductRecord {
2
4
  id: string
3
5
  name: string
@@ -230,7 +232,7 @@ export function normalizeCommerceProductRecord(
230
232
  variantCount: variantCounts.get(id) || 0,
231
233
  unitCount: unitCounts.get(id) || 0,
232
234
  reviewCount: reviewCounts.get(id) || 0,
233
- createdAt: requiredText(value(product, 'created_at', 'createdAt'), source, 'created_at'),
235
+ createdAt: commerceTimestamp(value(product, 'created_at', 'createdAt'), source, 'created_at'),
234
236
  }
235
237
  }
236
238
 
@@ -174,6 +174,13 @@ export function commerceUrl(input: unknown, source: string, field: string): stri
174
174
  }
175
175
 
176
176
  export function commerceTimestamp(input: unknown, source: string, field = 'created_at'): string {
177
+ // Postgres and MySQL drivers return Date instances for timestamp columns;
178
+ // SQLite stores TEXT and returns strings. Accept both.
179
+ if (input instanceof Date) {
180
+ if (!Number.isFinite(input.getTime()))
181
+ throw commerceRecordError(source, field, 'a valid timestamp')
182
+ return input.toISOString()
183
+ }
177
184
  const raw = typeof input === 'number'
178
185
  ? String(input)
179
186
  : commerceRequiredString(input, source, field)
@@ -197,6 +204,12 @@ export function commerceOptionalTimestamp(input: unknown, source: string, field:
197
204
  }
198
205
 
199
206
  export function commerceDate(input: unknown, source: string, field: string): string {
207
+ // Postgres and MySQL drivers return Date instances for date columns.
208
+ if (input instanceof Date) {
209
+ if (!Number.isFinite(input.getTime()))
210
+ throw commerceRecordError(source, field, 'a valid YYYY-MM-DD date')
211
+ return input.toISOString().slice(0, 10)
212
+ }
200
213
  const raw = commerceRequiredString(input, source, field)
201
214
  if (!/^\d{4}-\d{2}-\d{2}$/.test(raw))
202
215
  throw commerceRecordError(source, field, 'a valid YYYY-MM-DD date')
@@ -56,20 +56,20 @@ export default new Action({
56
56
  db.fn.max('duration_ms').as('maximum'),
57
57
  db.fn.max('created_at').as('latest'),
58
58
  ])
59
- .where('deleted_at', 'is', null)
59
+ .whereNull('deleted_at')
60
60
  .executeTakeFirst() as Promise<NumericSummaryRow | undefined>
61
61
 
62
62
  const requestSuccessQuery = db
63
63
  .selectFrom('requests')
64
64
  .select(db.fn.count('id').as('count'))
65
- .where('deleted_at', 'is', null)
65
+ .whereNull('deleted_at')
66
66
  .where('status_code', '<', 400)
67
67
  .executeTakeFirst() as Promise<{ count: number | string } | undefined>
68
68
 
69
69
  const slowRequestsQuery = db
70
70
  .selectFrom('requests')
71
71
  .select(['id', 'method', 'path', 'status_code', 'duration_ms', 'created_at'])
72
- .where('deleted_at', 'is', null)
72
+ .whereNull('deleted_at')
73
73
  .orderBy('duration_ms', 'desc')
74
74
  .orderBy('id', 'desc')
75
75
  .limit(5)
@@ -98,7 +98,11 @@ function timestampValue(
98
98
  return undefined
99
99
 
100
100
  let time = Number.NaN
101
- if (typeof value === 'number' && Number.isInteger(value) && value >= 0)
101
+ // Postgres and MySQL drivers return Date instances for timestamp columns;
102
+ // SQLite stores TEXT and returns strings.
103
+ if (value instanceof Date)
104
+ time = value.getTime()
105
+ else if (typeof value === 'number' && Number.isInteger(value) && value >= 0)
102
106
  time = value * 1000
103
107
  else if (typeof value === 'string' && value.trim())
104
108
  time = new Date(/^\d{4}-\d{2}-\d{2} \d/.test(value) ? `${value.replace(' ', 'T')}Z` : value).getTime()
@@ -1,5 +1,6 @@
1
1
  import { Action } from '@stacksjs/actions'
2
2
  import { db } from '@stacksjs/database'
3
+ import { modelBoolean } from './kanban-model'
3
4
  import { kanbanError } from './kanban-response'
4
5
 
5
6
  interface BoardRow {
@@ -10,7 +11,8 @@ interface BoardRow {
10
11
  icon: string
11
12
  color: string
12
13
  position: number
13
- archived: number
14
+ // SQLite stores booleans as 0/1 INTEGER columns; Postgres returns real booleans.
15
+ archived: number | boolean
14
16
  created_at: string | null
15
17
  updated_at: string | null
16
18
  }
@@ -37,7 +39,7 @@ interface CardRow {
37
39
  position: number
38
40
  created_by_user_id: number | null
39
41
  due_date: string | null
40
- archived: number
42
+ archived: number | boolean
41
43
  created_at: string | null
42
44
  updated_at: string | null
43
45
  }
@@ -103,7 +105,7 @@ export default new Action({
103
105
  [id],
104
106
  ).execute() as Promise<ColumnRow[]>,
105
107
  db.unsafe(
106
- 'SELECT * FROM cards WHERE board_id = ? AND archived = 0 ORDER BY column_id ASC, position ASC, id ASC',
108
+ 'SELECT * FROM cards WHERE board_id = ? AND archived = false ORDER BY column_id ASC, position ASC, id ASC',
107
109
  [id],
108
110
  ).execute() as Promise<CardRow[]>,
109
111
  db.unsafe(
@@ -121,7 +123,7 @@ export default new Action({
121
123
  `SELECT cl.card_id, l.id, l.name, l.color
122
124
  FROM card_labels cl
123
125
  JOIN labels l ON l.id = cl.label_id
124
- WHERE cl.card_id IN (SELECT id FROM cards WHERE board_id = ? AND archived = 0)
126
+ WHERE cl.card_id IN (SELECT id FROM cards WHERE board_id = ? AND archived = false)
125
127
  ORDER BY l.name ASC`,
126
128
  [id],
127
129
  ).execute() as Promise<Array<{ card_id: number, id: number, name: string, color: string }>>,
@@ -129,7 +131,7 @@ export default new Action({
129
131
  `SELECT ca.card_id, ca.user_id, u.name, u.email
130
132
  FROM card_assignees ca
131
133
  LEFT JOIN users u ON u.id = ca.user_id
132
- WHERE ca.card_id IN (SELECT id FROM cards WHERE board_id = ? AND archived = 0)`,
134
+ WHERE ca.card_id IN (SELECT id FROM cards WHERE board_id = ? AND archived = false)`,
133
135
  [id],
134
136
  ).execute() as Promise<Array<{ card_id: number, user_id: number, name: string | null, email: string | null }>>,
135
137
  ])
@@ -175,7 +177,7 @@ export default new Action({
175
177
  position: c.position,
176
178
  createdByUserId: c.created_by_user_id,
177
179
  dueDate: c.due_date,
178
- archived: c.archived === 1,
180
+ archived: modelBoolean(c, 'archived'),
179
181
  createdAt: c.created_at,
180
182
  updatedAt: c.updated_at,
181
183
  labels: labelsByCard.get(c.id) ?? [],
@@ -192,7 +194,7 @@ export default new Action({
192
194
  icon: board.icon,
193
195
  color: board.color,
194
196
  position: board.position,
195
- archived: board.archived === 1,
197
+ archived: modelBoolean(board, 'archived'),
196
198
  createdAt: board.created_at,
197
199
  updatedAt: board.updated_at,
198
200
  },
@@ -46,7 +46,7 @@ export default new Action({
46
46
  // is a sort hint, not a unique key. Reorder via /boards/reorder
47
47
  // restores tight ordering when the user cares.
48
48
  const maxRow = await db.unsafe(
49
- 'SELECT COALESCE(MAX(position), -1) AS m FROM boards WHERE archived = 0',
49
+ 'SELECT COALESCE(MAX(position), -1) AS m FROM boards WHERE archived = false',
50
50
  ).execute() as Array<{ m: number }>
51
51
  const nextPosition = (Number(maxRow?.[0]?.m ?? -1) + 1) || 0
52
52
 
@@ -1,5 +1,6 @@
1
1
  import { Action } from '@stacksjs/actions'
2
2
  import { db } from '@stacksjs/database'
3
+ import { modelBoolean } from './kanban-model'
3
4
  import { kanbanError } from './kanban-response'
4
5
 
5
6
  interface BoardRow {
@@ -10,7 +11,8 @@ interface BoardRow {
10
11
  icon: string
11
12
  color: string
12
13
  position: number
13
- archived: number
14
+ // SQLite stores booleans as 0/1 INTEGER columns; Postgres returns real booleans.
15
+ archived: number | boolean
14
16
  created_at: string | null
15
17
  updated_at: string | null
16
18
  card_count?: number
@@ -46,9 +48,9 @@ export default new Action({
46
48
  SELECT
47
49
  b.id, b.uuid, b.name, b.description, b.icon, b.color,
48
50
  b.position, b.archived, b.created_at, b.updated_at,
49
- (SELECT COUNT(*) FROM cards c WHERE c.board_id = b.id AND c.archived = 0) AS card_count
51
+ (SELECT COUNT(*) FROM cards c WHERE c.board_id = b.id AND c.archived = false) AS card_count
50
52
  FROM boards b
51
- WHERE b.archived = 0
53
+ WHERE b.archived = false
52
54
  ORDER BY b.position ASC, b.id ASC
53
55
  `).execute() as BoardRow[]
54
56
 
@@ -60,7 +62,7 @@ export default new Action({
60
62
  icon: r.icon,
61
63
  color: r.color,
62
64
  position: r.position,
63
- archived: r.archived === 1,
65
+ archived: modelBoolean(r, 'archived'),
64
66
  cardCount: Number(r.card_count ?? 0),
65
67
  createdAt: r.created_at,
66
68
  updatedAt: r.updated_at,
@@ -1,5 +1,6 @@
1
1
  import { Action } from '@stacksjs/actions'
2
2
  import { db } from '@stacksjs/database'
3
+ import { modelBoolean } from './kanban-model'
3
4
  import { kanbanError } from './kanban-response'
4
5
 
5
6
  interface CardRow {
@@ -12,7 +13,8 @@ interface CardRow {
12
13
  position: number
13
14
  created_by_user_id: number | null
14
15
  due_date: string | null
15
- archived: number
16
+ // SQLite stores booleans as 0/1 INTEGER columns; Postgres returns real booleans.
17
+ archived: number | boolean
16
18
  created_at: string | null
17
19
  updated_at: string | null
18
20
  }
@@ -91,7 +93,7 @@ export default new Action({
91
93
  position: card.position,
92
94
  createdByUserId: card.created_by_user_id,
93
95
  dueDate: card.due_date,
94
- archived: card.archived === 1,
96
+ archived: modelBoolean(card, 'archived'),
95
97
  createdAt: card.created_at,
96
98
  updatedAt: card.updated_at,
97
99
  },
@@ -10,7 +10,7 @@ export default new Action({
10
10
  const rows = await db
11
11
  .selectFrom('releases')
12
12
  .select(['created_at'])
13
- .where('created_at', 'is not', null)
13
+ .whereNotNull('created_at')
14
14
  .orderBy('created_at')
15
15
  .execute() as unknown as Array<{ created_at: string }>
16
16
 
@@ -35,13 +35,13 @@ export default new Action({
35
35
  db
36
36
  .selectFrom('campaign_sends')
37
37
  .select(['campaign_id', db.fn.count('id').as('count')])
38
- .where('opened_at', 'is not', null)
38
+ .whereNotNull('opened_at')
39
39
  .groupBy('campaign_id')
40
40
  .execute(),
41
41
  db
42
42
  .selectFrom('campaign_sends')
43
43
  .select(['campaign_id', db.fn.count('id').as('count')])
44
- .where('clicked_at', 'is not', null)
44
+ .whereNotNull('clicked_at')
45
45
  .groupBy('campaign_id')
46
46
  .execute(),
47
47
  ])
@@ -36,7 +36,7 @@ export default new Action({
36
36
  db.fn.count('id').as('count'),
37
37
  db.fn.max('sent_at').as('last_sent_at'),
38
38
  ])
39
- .where('email_list_id', 'is not', null)
39
+ .whereNotNull('email_list_id')
40
40
  .groupBy('email_list_id')
41
41
  .execute(),
42
42
  ])
@@ -6,7 +6,8 @@ export interface QueryLogSourceRow {
6
6
  connection?: string | null
7
7
  status?: string | null
8
8
  error?: string | null
9
- executed_at: string
9
+ // SQLite returns timestamp columns as TEXT; Postgres and MySQL drivers return Date instances.
10
+ executed_at: string | Date
10
11
  model?: string | null
11
12
  method?: string | null
12
13
  rows_affected?: number | null
@@ -89,7 +90,8 @@ export function mapDashboardQueryLog(row: QueryLogSourceRow): DashboardQueryLog
89
90
  const status = queryStatus(row.status, label)
90
91
  if (typeof row.query !== 'string' || !row.query.trim())
91
92
  throw new TypeError(`${label} query must be a non-empty string`)
92
- if (typeof row.executed_at !== 'string' || !row.executed_at.trim())
93
+ const executedAt = row.executed_at instanceof Date ? row.executed_at.toISOString() : row.executed_at
94
+ if (typeof executedAt !== 'string' || !executedAt.trim())
93
95
  throw new TypeError(`${label} executed_at must be a non-empty string`)
94
96
 
95
97
  return {
@@ -101,7 +103,7 @@ export function mapDashboardQueryLog(row: QueryLogSourceRow): DashboardQueryLog
101
103
  connection: row.connection || 'unknown',
102
104
  status,
103
105
  error: row.error || '',
104
- executedAt: row.executed_at,
106
+ executedAt,
105
107
  model: row.model || '',
106
108
  method: row.method || '',
107
109
  rowsAffected: nullableFiniteNumber(row.rows_affected, `${label} rows_affected`),
@@ -1,32 +1,54 @@
1
1
  import { Action } from '@stacksjs/actions'
2
2
  import { passwordResets, RateLimiter } from '@stacksjs/auth'
3
3
  import { response } from '@stacksjs/router'
4
+ import { schema } from '@stacksjs/validation'
5
+ import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from '../../password-policy'
4
6
 
5
7
  export default new Action({
6
8
  name: 'PasswordResetAction',
7
9
  description: 'Password Reset',
8
10
  method: 'POST',
11
+
12
+ // Declared rather than hand-checked (#2226). `password.length < 8` inside
13
+ // handle() was invisible to everything that reads `validations:` — the
14
+ // client could not ask for it, and it drifted from the six every other
15
+ // password declaration used. Declaring it also means this endpoint answers a
16
+ // precognition probe, so a reset form can check the field on blur without
17
+ // spending a reset token to find out.
18
+ validations: {
19
+ email: {
20
+ rule: schema.string().required().email(),
21
+ message: 'Email must be a valid email address.',
22
+ },
23
+ token: {
24
+ rule: schema.string().required().min(1),
25
+ message: 'A reset token is required.',
26
+ },
27
+ password: {
28
+ rule: schema.string().required().min(PASSWORD_MIN_LENGTH).max(PASSWORD_MAX_LENGTH),
29
+ message: PASSWORD_POLICY_MESSAGE,
30
+ },
31
+ },
32
+
9
33
  async handle(request) {
10
34
  const token = request.get('token')
11
35
  const password = request.get('password')
12
36
  const passwordConfirmation = request.get('password_confirmation')
13
37
  const email = request.get('email')
14
38
 
15
- // Validate required fields
39
+ // Kept as a floor for direct invocation: `validations:` only runs when this
40
+ // action is reached through the router, and a test or a queued job calling
41
+ // handle() straight would otherwise pass undefined into resetPassword.
16
42
  if (!token || !password || !email) {
17
43
  return response.error('Missing required fields', 422)
18
44
  }
19
45
 
20
- // Validate password confirmation
46
+ // Cross-field, so it cannot live in `validations:` — those rules see one
47
+ // field at a time. Length and format are declared up there instead.
21
48
  if (password !== passwordConfirmation) {
22
49
  return response.error('Password confirmation does not match', 422)
23
50
  }
24
51
 
25
- // Validate password strength (minimum 8 characters)
26
- if (password.length < 8) {
27
- return response.error('Password must be at least 8 characters', 422)
28
- }
29
-
30
52
  // Rate limit password reset attempts by email
31
53
  const rateLimitKey = `password_reset_attempt:${email.toLowerCase()}`
32
54
  if (await RateLimiter.isRateLimited(rateLimitKey)) {
@@ -1,4 +1,4 @@
1
- import { Auth, sessionUser } from '@stacksjs/auth'
1
+ import { Auth, authCookieName, sessionUser } from '@stacksjs/auth'
2
2
  import { config } from '@stacksjs/config'
3
3
  import { HttpError } from '@stacksjs/error-handling'
4
4
  import { Middleware } from '@stacksjs/router'
@@ -40,7 +40,9 @@ export default new Middleware({
40
40
  return
41
41
  }
42
42
 
43
- const tokenCookieName = config.auth?.defaultTokenName || 'auth-token'
43
+ // One resolver, so the name a cookie is written under is the name it is
44
+ // read under (stacksjs/stacks#2236).
45
+ const tokenCookieName = authCookieName()
44
46
  const cookieToken = request.cookie?.(tokenCookieName)
45
47
  if (cookieToken) {
46
48
  await stampTokenUser(request, cookieToken)