@stacksjs/defaults 0.75.228 → 0.75.229

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-actions
3
- description: Use when working with Stacks server actions - creating actions in app/Actions/, auto-generated API actions from the useApi model trait, the 670 default framework actions (auth, dashboard, commerce, content, deployment, jobs), action request/response handling, or action registration. Covers @stacksjs/actions and storage/framework/defaults/app/Actions/.
3
+ description: Use when working with Stacks server actions - creating actions in app/Actions/, auto-generated API actions from the useApi model trait, the 672 default framework actions (auth, dashboard, commerce, content, deployment, jobs), action request/response handling, or action registration. Covers @stacksjs/actions and storage/framework/defaults/app/Actions/.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -127,18 +127,35 @@ Evidence: `tests/rbac-default-store.test.ts`, `rbac-guard-sync.test.ts`,
127
127
 
128
128
  ## Social identity linking and referrals
129
129
 
130
- `resolveSocialSignIn(provider, identity, store?)` resolves durable provider-id
131
- links first, then applies `config.auth.socials.matching`: link/create/refuse.
132
- An email match is subject to the identity verification policy; copying an email
133
- from a provider payload is not sufficient proof to link an existing account.
134
- Use SocialSignInRefusedError to distinguish configured refusal from infrastructure
135
- failure. `stacks-socials` owns provider authorization and callback exchange.
130
+ `resolveSocialSignIn(provider, identity, store?, { tx? })` resolves durable
131
+ provider-id links first, then applies `config.auth.socials.matching`:
132
+ link/create/refuse. An email match links only when the provider vouches for the
133
+ address (`emailVerified === true`, else `'unverified-provider-email'`) AND the
134
+ local account verified it (`users.email_verified_at`, else
135
+ `'unverified-local-email'`). Neither guard is configurable. A user created from
136
+ a provider-verified address starts with `email_verified_at` set (`createUser`
137
+ receives `emailVerified`). Every store method receives the `tx`, so lookup, user
138
+ and link commit together; a store may implement `isUserEmailVerified(userId, tx)`
139
+ and otherwise gets the default that reads `users.email_verified_at`
140
+ (`defaultSocialSignInStore()` is exported). Use SocialSignInRefusedError's
141
+ `reason` to distinguish refusal from infrastructure failure. `stacks-socials`
142
+ owns provider authorization, the flow state and callback exchange.
143
+
144
+ After resolving the user, a redirect flow hands the browser a one-use code, not
145
+ tokens: `createSessionHandoff(userId, { bindTo: 'cookie' | { challenge } })`
146
+ returns `{ code, cookie, expiresAt }` (60 seconds, single use, bound to the
147
+ HttpOnly cookie or to the SHA-256 hex of a native client's verifier), and
148
+ `redeemSessionHandoff(code, secret)` returns `{ userId, data } | null` once.
149
+ `readSessionHandoffCookie(header, code)` / `clearSessionHandoffCookie(code)` read
150
+ and clear the binding cookie. The default `POST /auth/handoff`
151
+ (SessionHandoffAction) redeems and mints the session; mint nothing before it.
136
152
 
137
153
  Referrals expose createReferralCode(ownerId), attributeReferral(newUserId, input),
138
154
  qualifyReferral(referredUserId) and referralSummary(ownerId), with optional
139
155
  database injection. Normalize/attribute at registration and qualify at the
140
156
  application's real conversion milestone. Attribution alone is not a paid reward.
141
157
 
142
- Source: `src/socials.ts`, `referrals.ts`. Evidence: `tests/social-linking.test.ts`,
158
+ Source: `src/socials.ts`, `session-handoff.ts`, `referrals.ts`. Evidence:
159
+ `tests/social-linking.test.ts`, `social-callback-flow.test.ts`,
143
160
  `referrals.test.ts`. All account features remain subject to model loading,
144
161
  selected route bundles, config gates and applied migrations.
@@ -66,8 +66,8 @@ declaring `useAuth` can hold tokens - not only `User`.
66
66
  - `tokenCan(scope)` / `tokenCanAll` / `tokenCanAny` / `tokenAbilities` - check
67
67
  the current request's token.
68
68
  - `revokeToken`, `revokeTokenById`, `revokeAllTokens(id, type?)`,
69
- `revokeOtherTokens(id, type?)` - revocation also revokes the paired refresh
70
- token, which a raw row delete does not.
69
+ `revokeOtherTokens(id, type?, { exceptTokenId? })` - revocation also revokes
70
+ the paired refresh token, which a raw row delete does not.
71
71
  - `setTrailActor(id)` - attribute writes in a queue job or CLI run that has no
72
72
  request to read a user from.
73
73
 
@@ -106,7 +106,7 @@ routes: minting and revoking both carry semantics a generic route does not.
106
106
  - `Auth.revokeToken(token: string): Promise<void>`
107
107
  - `Auth.revokeTokenById(tokenId: number): Promise<void>`
108
108
  - `Auth.revokeAllTokens(userId?: number): Promise<void>`
109
- - `Auth.revokeOtherTokens(userId?: number): Promise<void>`
109
+ - `Auth.revokeOtherTokens(userId?: number, { exceptTokenId? }): Promise<{ keptTokenId: number | null }>` - keeps `exceptTokenId`, else the request's own token; when neither resolves it revokes every token (fail closed)
110
110
  - `Auth.pruneExpiredTokens(): Promise<number>`
111
111
  - `Auth.pruneRevokedTokens(): Promise<number>`
112
112
  - `Auth.rotateToken(oldToken: string): Promise<AuthToken | null>`
@@ -138,7 +138,7 @@ routes: minting and revoking both carry semantics a generic route does not.
138
138
  - `revokeToken(plainTextToken): Promise<void>`
139
139
  - `revokeTokenById(tokenId): Promise<void>`
140
140
  - `revokeAllTokens(userId): Promise<void>`
141
- - `revokeOtherTokens(userId): Promise<void>`
141
+ - `revokeOtherTokens(userId, type?, { exceptTokenId? }): Promise<{ keptTokenId: number | null }>` - same rule as `Auth.revokeOtherTokens`
142
142
  - `deleteExpiredTokens(): Promise<number>`
143
143
  - `deleteRevokedTokens(daysOld?): Promise<number>`
144
144
 
@@ -318,10 +318,37 @@ Internal: database-backed `sessions` rows with expiry, optional IP/User-Agent fi
318
318
  ```typescript
319
319
  const actions = passwordResets(email)
320
320
  await actions.sendEmail()
321
+ // or: an invitation's first password, a custom expiry, your own delivery
322
+ await actions.sendEmail({
323
+ purpose: 'setup', // 'reset' (default) | 'setup'
324
+ expiresAt: new Date(Date.now() + 3 * 864e5),
325
+ deliver: async ({ purpose, userId, email, token, url, expiresAt }) => { /* localized mail, ledger */ },
326
+ })
321
327
  const valid = await actions.verifyToken(token)
322
328
  const result = await actions.resetPassword(token, newPassword)
323
329
  ```
324
330
 
331
+ `deliver` runs after the token commits, instead of the framework mail; the
332
+ `token`/`url` are the live credential, never log them. `sendEmail` stays a
333
+ silent no-op for unknown or throttled addresses (anti-enumeration).
334
+
335
+ ### One credential sweep (password/credentials.ts)
336
+
337
+ - `revokeAccountCredentials(userId, { exceptTokenId?, exceptSessionId?, tx? })`
338
+ stamps `password_changed_at` and revokes two-factor challenges, magic links,
339
+ OAuth grants and codes, access/refresh tokens and sessions, in one transaction
340
+ (joins the caller's when `tx` is given; a `tx` outside its transaction
341
+ throws). The kept token and its refresh token are re-bound to the stamp.
342
+ Returns `{ passwordChangedAt, keptTokenId }`. `resetPassword` runs it.
343
+ - `changePassword(userId, current, next, { keepTokenId?, keepSessionId?, notify? })`
344
+ verifies `current`, commits only if that hash is still stored, runs the sweep,
345
+ then notifies after commit (`notify`: `true` = the `password-changed` mail, a
346
+ function = yours, `false` = nothing). Returns `{ success: true, keptTokenId }`
347
+ or `{ success: false, reason: 'invalid-current-password' | 'user-not-found' | 'invalid-new-password' }`.
348
+ Enforce the new password's policy first.
349
+ - Any other path that changes how an account proves who it is (recovery, an
350
+ admin forcing a sign-out, an email change) should call the sweep too.
351
+
325
352
  ## Registration (register.ts)
326
353
 
327
354
  - `register(credentials: NewUser): Promise<{ token: AuthToken }>`
@@ -398,9 +425,12 @@ const locked = await RateLimiter.isRateLimited(email)
398
425
  await RateLimiter.resetAttempts(email)
399
426
  ~~~
400
427
 
401
- Five failed attempts lock an email for fifteen minutes. The configured store
402
- selects atomic Redis when the cache config selects Redis, otherwise process-local
403
- memory. `useSharedStore(options?)` explicitly chooses Redis;
428
+ Five failed attempts (`maxFailedAttempts()`, times
429
+ `config.security.rateLimit.scale` / `STACKS_RATE_LIMIT_SCALE`) lock an email for
430
+ fifteen minutes. The configured store selects atomic Redis when the cache config
431
+ selects Redis, wrapped in `FallbackRateLimiterStore` so a Redis outage degrades
432
+ to process memory (warned once) instead of failing every sign-in; otherwise
433
+ process-local memory. `useSharedStore(options?)` explicitly chooses Redis;
404
434
  `useStore(custom)` installs a store with atomic recordFailedAttempt;
405
435
  `useMemoryStore()` and `useConfiguredStore()` reset that choice.
406
436
  Shared deployments need a shared atomic store. Partial counters expire, and
@@ -528,6 +558,10 @@ is the declarative form and the one the ability-name completions come from.
528
558
  - `DELETE /auth/tokens/{id}` → RevokeTokenAction (auth middleware)
529
559
  - `GET /me` → GetMeAction (auth middleware)
530
560
  - `POST /logout` → LogoutAction (auth middleware)
561
+ - Social bundle: `GET /auth/{provider}` → SocialRedirectAction (issues the
562
+ browser-bound OAuth state), `GET|POST /auth/{provider}/callback` →
563
+ SocialCallbackAction (redeems it, redirects with `#stx_handoff=<code>`),
564
+ `POST /auth/handoff` → SessionHandoffAction (redeems the code for the session)
531
565
 
532
566
  ## User Model Traits
533
567
 
@@ -545,7 +579,7 @@ traits: {
545
579
 
546
580
  - Auth depends on `@stacksjs/ts-auth` for TOTP and passkey functions
547
581
  - Password hashing defaults to bcrypt with 12 rounds (config/hashing.ts)
548
- - Rate limiting uses a process-local memory store by default. Production deployments with multiple workers should configure the atomic Redis store or provide a custom atomic store.
582
+ - Rate limiting uses a process-local memory store unless `config/cache.ts` selects Redis, which the lockout and route limits then share. Production deployments with multiple workers should configure Redis or provide a custom atomic store. Test suites raise `config.security.rateLimit.scale` rather than disabling limits.
549
583
  - Session auth is database-backed through the `sessions` table, so it survives server restarts and is shared across workers.
550
584
  - New personal and delegated access tokens are opaque 40-byte hex bearers hashed at rest. Legacy `jwt:encryptedId` bearers remain readable during migration.
551
585
  - Token validation hashes the bearer directly. Do not parse or expose token contents, and never log plaintext bearer values.
@@ -44,16 +44,38 @@ Browser state uses imported storage helpers, not direct document/window mutation
44
44
  inside STX scripts.
45
45
 
46
46
  refreshSession coalesces concurrent refresh attempts. A server refusal clears the
47
- session and returns false; a transport failure throws and keeps the existing
48
- credential. authFetch retries the eligible request after refresh using the native
49
- credential/CSRF flow. Cookie/session behavior and refresh-token availability
50
- depend on server browser-session policy. A fixed-lifetime login cannot be made
51
- renewable by keeping an old client refresh token.
47
+ session and returns false; a network failure, 5xx, 408 or 429 keeps the existing
48
+ credential and returns false. authFetch retries the eligible request after
49
+ refresh using the native credential/CSRF flow. Cookie/session behavior and
50
+ refresh-token availability depend on server browser-session policy. A
51
+ fixed-lifetime login cannot be made renewable by keeping an old client refresh
52
+ token.
53
+
54
+ Only a 401 ends a session. fetchAuthUser shares one in-flight /api/me request
55
+ between concurrent callers. A network failure, 5xx, 403 or other non-401 answer
56
+ keeps token, user and isAuthenticated and resolves the last known user. 403 is
57
+ an authorization decision about a recognised identity, so the app should show
58
+ a forbidden state, not send the user to log in again. An unchanged /me answer
59
+ keeps the same user object, so effects do not re-run. A same-origin authFetch
60
+ 401 that refresh cannot cure re-checks /api/me instead of signing out (signed
61
+ URLs and step-up checks also answer 401). Only that check's 401 clears the
62
+ session. A burst of such 401s makes one /me request, one clear and one
63
+ onSessionExpired(listener) call. Answers to requests sent before a new sign-in
64
+ are ignored. logout() does not fire onSessionExpired.
52
65
 
53
66
  completeSocialLogin applies the native handoff through the same storage refs and
54
- strips its URL fragment. Use socialHandoffRedirect on the server; a hand-built
55
- inline script writing token/user keys duplicates the encoding/security contract.
67
+ strips its URL fragment. The default callback sends a one-use stx_handoff code.
68
+ Called with no argument, completeSocialLogin redeems it with a credentialed POST
69
+ to /auth/handoff (AUTH_HANDOFF_PATH); a refused or unreachable redemption
70
+ resolves null and leaves the existing session alone. A stx_auth pack from a
71
+ custom callback still works. Use socialHandoffRedirect on the server; a
72
+ hand-built inline script writing token/user keys duplicates the
73
+ encoding/security contract.
74
+
56
75
  authGuard controls client navigation; guard server pages/actions independently.
76
+ With a stored but unconfirmed token it checks /api/me instead of redirecting,
77
+ so a network blip on page load keeps the user on the page. A 401 then, or any
78
+ later session expiry on that page, redirects to /login once.
57
79
 
58
80
  ## API transports and errors
59
81
 
@@ -88,5 +110,6 @@ composable skill instead of copying another fixed inventory of globals.
88
110
 
89
111
  `storage/framework/core/browser/src/index.ts`, auto-init.ts, model-loader.ts,
90
112
  composables/useAuth.ts/useApi.ts/csrf.ts/request-error.ts and utils/fetch.ts.
91
- Tests: client-bundle-safety.test.ts, use-auth-refresh.test.ts, csrf.test.ts,
113
+ Tests: client-bundle-safety.test.ts, use-auth-refresh.test.ts (refresh, session
114
+ resilience, burst expiry, authGuard, handoff code), auth.test.ts, csrf.test.ts,
92
115
  request-error.test.ts and api-url.test.ts under core/browser/tests.
@@ -129,6 +129,14 @@ inspection, append a newline, and complete before an immediate `process.exit()`.
129
129
  The narrow import needs no application configuration. Use `log` for application
130
130
  logging with levels, context and transports.
131
131
 
132
+ For large results or piped output, use `await printAsync(...args)` and
133
+ `await printErrorAsync(...args)` from the same entrypoint. Their signatures are
134
+ `(...args: unknown[]) => Promise<void>`. They append a newline, preserve Node
135
+ formatting, wait for stream writes under backpressure, and reject write errors
136
+ such as `EPIPE`. Await them before `process.exit()`. The synchronous helpers
137
+ remain for small, bounded immediate-exit messages and may throw `EAGAIN` when
138
+ a nonblocking pipe is full. These helpers are also exported by `@stacksjs/cli`.
139
+
132
140
  ## Output Formatting
133
141
 
134
142
  Via `@stacksjs/utils` color functions (available in CLI context):
@@ -371,6 +371,10 @@ or dialect-aware raw helpers. sql.as validates an alias; raw text remains a
371
371
  trusted-code operation. sqlDateTime/parseSqlDateTime provide the canonical
372
372
  application timestamp representation. Mixing SQLite datetime text with
373
373
  application ISO text in raw comparisons can give wrong expiry boundaries.
374
+ parseSqlDate(value) reads a DATE column back as the YYYY-MM-DD it stores,
375
+ whether the driver built the Date at UTC midnight (Bun's Postgres driver) or at
376
+ the host's local midnight; toISOString().slice(0, 10) shifts the latter a day
377
+ back east of UTC. Null for missing, invalid or impossible input.
374
378
  Source: core/database/src/types.ts and sql-helpers.ts; see stacks-query-builder
375
379
  for typed tables, raw binding, transaction handles and replica routing.
376
380
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-datetime
3
- description: Use when working with dates and times in Stacks - the DateTime class with Carbon-like API (add/sub, comparison, formatting, start/end of day/month/year), date parsing, format tokens, or timezone handling. Covers @stacksjs/datetime.
3
+ description: Use when working with dates and times in Stacks - the DateTime class with Carbon-like API (add/sub, comparison, formatting, start/end of day/month/year), date parsing, format tokens, timezone handling, wall-clock times in a named zone across DST, or YYYY-MM-DD calendar dates (today in a zone, day arithmetic, ISO weeks). Covers @stacksjs/datetime.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -24,10 +24,14 @@ export { parse } from './parse'
24
24
  export { format as dateFormat } from './format' // convenience alias
25
25
  ```
26
26
 
27
- Three source files:
27
+ Source files:
28
28
  - `now.ts` - `DateTime` class and `now()` helper
29
29
  - `format.ts` - standalone `format()` function with token-based formatting and timezone support
30
30
  - `parse.ts` - standalone `parse()` function with token-based parsing
31
+ - `zone.ts` - wall clock in a named zone to an instant and back (`zonedTimeToUtc`, `isoInZone`, `zoneOffsetMinutes`)
32
+ - `calendar.ts` - `addCalendarMonths` and the `YYYY-MM-DD` helpers (`todayIn`, `addDays`, `daysBetween`, `startOfIsoWeek`, `isCalendarDate`, `isTimeZone`, `minutesUntil`)
33
+ - `local.ts` - `localDateTime` (site and organization clocks)
34
+ - `schedule.ts` - recurrences, coverage and local reporting periods
31
35
 
32
36
  ## DateTime Class (`now.ts`)
33
37
 
@@ -252,10 +256,70 @@ The root additionally exports `zoneOffsetMinutes(instant, timeZone)`,
252
256
  `addCalendarMonths(date, count, { utc? })`. A wall-clock string has no supplied
253
257
  offset; the named IANA zone converts it into an instant. `isoInZone` retains
254
258
  that zone's offset in serialized output rather than merely displaying a date.
255
- Review `zone.ts` and its DST behavior when ambiguous/gap local times matter.
256
259
  `addCalendarMonths` clones and clamps the day; ordinary `DateTime.addMonths`
257
260
  preserves native Date overflow instead. Do not interchange those contracts.
258
261
 
262
+ `zonedTimeToUtc` resolves DST edges the same way in every zone (RFC 5545 and
263
+ Temporal's `'compatible'` rule):
264
+
265
+ | Wall clock | Zone | Result | Why |
266
+ |---|---|---|---|
267
+ | `2026-10-25T02:30` | `Europe/Berlin` | `2026-10-25T00:30:00Z` (`+02:00`) | Repeated hour: the EARLIER instant |
268
+ | `2026-11-01T01:30` | `America/New_York` | `2026-11-01T05:30:00Z` (`-04:00`) | Repeated hour: the EARLIER instant |
269
+ | `2026-03-29T02:30` | `Europe/Berlin` | `2026-03-29T01:30:00Z`, shown as `03:30+02:00` | Skipped: read with the offset before the gap |
270
+ | `2026-03-08T02:30` | `America/New_York` | `2026-03-08T07:30:00Z`, shown as `03:30-04:00` | Skipped: read with the offset before the gap |
271
+
272
+ Releases up to 0.75.228 returned the LATER instant of a repeated hour in zones
273
+ at or east of UTC (Berlin, London, Sydney) while New York got the earlier one.
274
+
275
+ ```typescript
276
+ import { zonedTimeToUtc } from '@stacksjs/datetime'
277
+
278
+ zonedTimeToUtc('2026-10-01T20:00:00', 'America/New_York').toISOString() // '2026-10-02T00:00:00.000Z'
279
+ zonedTimeToUtc('2026-10-25T02:30', 'Europe/Berlin').toISOString() // '2026-10-25T00:30:00.000Z'
280
+ ```
281
+
282
+ ## Calendar dates (`YYYY-MM-DD` strings)
283
+
284
+ A date column, a training day or a membership period is a day on a calendar,
285
+ not an instant. Keep it a `YYYY-MM-DD` string: these helpers do their arithmetic
286
+ on the UTC calendar (every day 24 hours, whatever the server zone) and only meet
287
+ a time zone where a real clock is involved (`todayIn`, `minutesUntil`). Source:
288
+ `src/calendar.ts`.
289
+
290
+ ```typescript
291
+ import { addDays, daysBetween, isCalendarDate, isTimeZone, minutesUntil, startOfIsoWeek, todayIn } from '@stacksjs/datetime'
292
+
293
+ isCalendarDate('2026-02-31') // false (a type guard; also rejects '2026-2-3')
294
+ isTimeZone('Europe/Berlin') // true (a type guard; '' and unknown zones are false)
295
+ todayIn('Pacific/Auckland', now) // the date there, e.g. '2026-10-19' while UTC is on the 18th
296
+ addDays('2026-12-31', 1) // '2027-01-01'
297
+ daysBetween('2026-10-18', '2026-10-25') // 7 (signed: negative when the second is earlier)
298
+ startOfIsoWeek('2026-10-18') // '2026-10-12' (the Monday; a Sunday belongs to the week before)
299
+
300
+ // Real minutes from now until 09:00 on a date in a zone, correct across DST:
301
+ minutesUntil('2026-10-25', '09:00', 'Europe/Berlin', new Date('2026-10-24T22:00:00Z')) // 600, not 540
302
+ ```
303
+
304
+ - Every helper throws `RangeError` for an impossible date, a malformed clock
305
+ time (`HH:mm` or `HH:mm:ss`, 00-23 hours), an unknown zone or an invalid
306
+ instant. Validate input with `isCalendarDate` / `isTimeZone` first.
307
+ - `todayIn` takes one zone. To fall back (a site's zone, then its
308
+ organization's, then UTC) use `localDateTime(now, ...zones).date`.
309
+ - `minutesUntil` keeps the seconds of `now` as a fraction; round where you
310
+ display or compare. Negative once the moment has passed.
311
+ - `addDays` refuses a result past 9999-12-31.
312
+
313
+ ### Reading `DATE` columns: `parseSqlDate` (`@stacksjs/database`)
314
+
315
+ Drivers return a `DATE` column as a JS `Date`, at UTC midnight (Bun's Postgres
316
+ driver) or at the host's local midnight (others). `date.toISOString().slice(0, 10)`
317
+ is only right for the first: on a host east of UTC a local-midnight `Date` moves
318
+ to the day before. `parseSqlDate(value)` from `@stacksjs/database` returns the
319
+ stored `YYYY-MM-DD` either way (local midnight reads local fields, anything else
320
+ UTC fields), keeps the date part of strings, and returns null for missing,
321
+ invalid or impossible input. It sits beside `parseSqlDateTime`.
322
+
259
323
  ## Current-time helper example
260
324
 
261
325
  ```typescript