@stacksjs/defaults 0.75.227 → 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.
@@ -136,8 +136,9 @@ const changed = await Payment.changeSubscription(user, 'enterprise-monthly')
136
136
  ```typescript
137
137
  import { manageConnectedAccount, manageDestinationCharge, manageDestinationSubscription } from '@stacksjs/payments'
138
138
 
139
- // Onboard: create once (deterministic idempotency key), then a hosted onboarding link
140
- const account = await manageConnectedAccount.createConnectedAccount(user)
139
+ // Onboard: create once (deterministic idempotency key), then a hosted onboarding link.
140
+ // `reference` names which of the user's accounts this is (a gym, a coach storefront).
141
+ const account = await manageConnectedAccount.createConnectedAccount(user, {}, { reference: `gym:${gym.id}` })
141
142
  const link = await manageConnectedAccount.accountOnboardingLink(account.id, { refreshUrl, returnUrl })
142
143
  const status = await manageConnectedAccount.accountStatus(account.id) // chargesEnabled, payoutsEnabled, currentlyDue...
143
144
 
@@ -156,11 +157,118 @@ const session = await manageDestinationSubscription.checkout(user, {
156
157
  })
157
158
  ```
158
159
 
160
+ - `createConnectedAccount(user, params?, { reference? })` and `createTestConnectedAccount` key their idempotency on the user, `reference` and a fingerprint of the merged creation params (`connectedAccountIdempotencyKey`). A retry of the same call returns the original account; another reference or other params create another. Keyed by user alone, a user's second account inside Stripe's 24-hour window collided with the first. Pass a stable `reference` whenever one user can own more than one account.
159
161
  - A destination subscription lives on the platform with `transfer_data.destination`, so renewals (`invoice.paid`), failed payments (`invoice.payment_failed`), status changes and cancellation (`customer.subscription.updated` / `.deleted`) arrive on the platform's normal webhook endpoint, not the Connect one. `destinationOf(subscription)` (also `connect.subscriptionDestination`) tells a merchant's subscription from the platform's own.
160
162
  - Its fee is `application_fee_percent` (`feePercentFor`, at most two decimals; 0 sends no fee field), applied to every invoice, unlike a charge's fee amount.
161
163
  - Cancel, resume and read it with `manageSubscription` (`cancelAtPeriodEnd`, `resume`, `cancel`); entitlement from `subscriptionGrantsAccess(status)` and dates from `subscriptionPeriod(subscription)`.
162
164
  - Events about a connected account itself (`account.updated`, `payout.*`) come to the Connect endpoint with `event.account`; read it with `getConnectedAccountId(event)`.
163
165
 
166
+ #### Destination-charge params (pure)
167
+
168
+ When you build the Stripe call yourself (a Checkout with your own line items, a
169
+ subscription for a member who already has a card on file), spread the Connect
170
+ fields from these builders instead of hand-writing them. They validate, read no
171
+ config, call no API, and are typed with Stripe's own params (`src/connect/destination.ts`).
172
+
173
+ ```typescript
174
+ import {
175
+ checkoutLineItemsTotal,
176
+ destinationCheckoutPaymentData,
177
+ destinationCheckoutSubscriptionData,
178
+ destinationPaymentIntentParams,
179
+ destinationSubscriptionParams,
180
+ stripe,
181
+ } from '@stacksjs/payments'
182
+
183
+ const connect = { destination: 'acct_gym', feePercent: 5, rounding: 'round' as const, onBehalfOf: false }
184
+
185
+ // Checkout, mode: 'payment': the fee is an amount, worked out from the line items
186
+ await stripe.checkout.sessions.create({
187
+ mode: 'payment',
188
+ line_items,
189
+ payment_intent_data: destinationCheckoutPaymentData(checkoutLineItemsTotal(line_items), connect, { metadata }),
190
+ success_url, cancel_url,
191
+ })
192
+
193
+ // Checkout, mode: 'subscription': the fee is a percent of every invoice
194
+ await stripe.checkout.sessions.create({ mode: 'subscription', line_items, subscription_data: destinationCheckoutSubscriptionData(connect, { metadata }), success_url, cancel_url })
195
+
196
+ // Direct API calls
197
+ await stripe.subscriptions.create({ customer, items: [{ price }], ...destinationSubscriptionParams(connect) })
198
+ await stripe.paymentIntents.create({ amount: 4900, currency: 'eur', customer, ...destinationPaymentIntentParams(4900, { destination: 'acct_gym', feeCents: 250 }) })
199
+ ```
200
+
201
+ | Option | Meaning |
202
+ |---|---|
203
+ | `destination` | `acct_...`; anything else throws (`requireAccountId`) |
204
+ | `feePercent` | 0 to 100. One-off charges turn it into an amount; subscriptions send `application_fee_percent` (at most two decimals; float sums like `0.1 + 0.2` are snapped) |
205
+ | `feeCents` | Exact fee in minor units; wins on one-off charges, refused on subscriptions |
206
+ | `rounding` | `'floor'` (default, as `applicationFeeFor`) or `'round'` (nearest cent). Never exceeds the charge |
207
+ | `onBehalfOf` | Sets `on_behalf_of` to the destination (the merchant's descriptor and settlement country) |
208
+ | `transferGroup` | One-off charges only |
209
+
210
+ A zero or missing fee sends no fee field. `destinationFeeCents(amount, opts)` and
211
+ `destinationFeePercent(opts)` expose the fee arithmetic alone. The third
212
+ argument of the Checkout builders carries the session's other fields (metadata,
213
+ `trial_period_days`); the Connect fields win over it. `checkoutLineItemsTotal`
214
+ counts `price_data` lines and refuses a catalog `price` (its amount is unknown
215
+ here).
216
+
217
+ `splitFee(cents, weights, fallback?)` splits a fee into named shares that always
218
+ add up to it (largest remainder, ties to the earlier key; a negative amount, a
219
+ refunded fee, splits the same way negated; all-zero weights give it all to
220
+ `fallback`, default the first key): `splitFee(101, { tip: 2, planet: 1 })` is
221
+ `{ tip: 67, planet: 34 }`.
222
+
223
+ #### Connect-aware refunds
224
+
225
+ Refunding a destination charge like any other pays the buyer out of the
226
+ PLATFORM's balance and leaves the merchant the sale and the platform its fee.
227
+ Stripe reverses both only when asked:
228
+
229
+ ```typescript
230
+ import { connectRefund, connectRefundParams, isDestinationCharge } from '@stacksjs/payments'
231
+
232
+ // Retrieves the PaymentIntent (latest_charge expanded) or Charge, then refunds
233
+ await connectRefund('pi_xxx', { amount: 500, reason: 'requested_by_customer', idempotencyKey: `refund:${row.id}` })
234
+
235
+ // Pure, when you already hold the object
236
+ const intent = await stripe.paymentIntents.retrieve(id, { expand: ['latest_charge'] })
237
+ await stripe.refunds.create(connectRefundParams(intent, { amount: 500 }), { idempotencyKey })
238
+ ```
239
+
240
+ `reverse_transfer` is set on a destination charge (`transfer_data.destination`
241
+ on the intent or charge, or a charge's `transfer`); `refund_application_fee`
242
+ when it also took a fee. `reverseTransfer: false` / `refundApplicationFee: false`
243
+ opt out (a platform that keeps its fee on refunds). A platform charge refunds
244
+ plainly. Pass a deterministic `idempotencyKey`; without one each call is a new
245
+ refund. Also `Payment.connect.refund` / `.refundParams` / `.isDestinationCharge`.
246
+
247
+ #### Checkout outcomes: `checkoutOutcome(event)`
248
+
249
+ ```typescript
250
+ import { checkoutOutcome } from '@stacksjs/payments'
251
+
252
+ const outcome = checkoutOutcome(event) // null for anything but checkout.session.*
253
+ if (outcome?.status === 'paid') await fulfil(outcome.reference, outcome.paymentIntentId)
254
+ else if (outcome?.status === 'processing') await markPending(outcome.reference, outcome.subscriptionId)
255
+ else if (outcome?.status === 'failed' || outcome?.status === 'expired') await release(outcome.reference)
256
+ ```
257
+
258
+ | Event | `payment_status` | `status` |
259
+ |---|---|---|
260
+ | `checkout.session.completed` | `paid` / `no_payment_required` | `paid` |
261
+ | `checkout.session.completed` | `unpaid` (SEPA, Bacs, ACH, bank redirect) | `processing` |
262
+ | `checkout.session.async_payment_succeeded` | `paid` | `paid` |
263
+ | `checkout.session.async_payment_failed` | any | `failed` |
264
+ | `checkout.session.expired` | any | `expired` |
265
+
266
+ The outcome also carries `session`, `sessionId`, `reference`
267
+ (`client_reference_id`), `paymentIntentId`, `subscriptionId`, `customerId` (ids
268
+ whether expanded or not), `mode`, `amountTotal`, `currency` (lower case) and
269
+ `metadata`. It reads only the event: verify the signature and match the session
270
+ to your own record before acting. Fulfil on `paid` only.
271
+
164
272
  ### Bank debits (SEPA Direct Debit, Bacs, ACH, BECS)
165
273
 
166
274
  `src/direct-debit.ts` (also `@stacksjs/payments/direct-debit`, pure and browser-safe) holds what a debit needs next to cards:
@@ -376,7 +484,8 @@ Payment.onCharge({
376
484
  const result = await Payment.processWebhook(rawPayload, signatureHeader, {
377
485
  secret: 'whsec_xxx', tolerance: 300
378
486
  })
379
- // Returns: { success: boolean, eventType?: string, error?: string }
487
+ // Returns: { success: boolean, eventType?: string, error?: string, duplicate?: true }
488
+ // Answer 2xx on success (a duplicate is a success), non-2xx otherwise so Stripe retries.
380
489
 
381
490
  // Low-level: register any event type
382
491
  Payment.webhook.onWebhookEvent('payment_method.attached', handler)
@@ -386,6 +495,12 @@ Payment.webhook.registerWebhookHandlers({ 'payment_intent.succeeded': handler })
386
495
  import { getPaymentIntent, getSubscription, getInvoice, getCheckoutSession, getCharge, getCustomer } from '@stacksjs/payments'
387
496
  ```
388
497
 
498
+ Replays run once. Stripe delivers at least once, so `handleWebhookEvent` (and `processWebhook` through it) skips an event whose `evt_` id already ran its handlers and answers `duplicate: true`. The id is recorded only after every handler for it succeeded: a handler that throws leaves the event unrecorded, `processWebhook` reports `success: false`, and the provider's retry runs the handlers again (all of them, so keep each one safe to repeat). A second delivery arriving while the first is still running waits for it. Events without an id and events with no registered handler are not recorded.
499
+
500
+ - Store: `payment_webhook_events` under provider `stripe:handlers` (`WEBHOOK_HANDLERS_PROVIDER`), apart from commerce's `stripe` rows so the two paths never skip each other's events. Before the table is migrated it falls back to an in-process memory store with a TTL (warns once). Another database error throws, so `processWebhook` answers failure and Stripe retries.
501
+ - Opt out per call with `{ dedupe: false }` (`processWebhook` config or `handleWebhookEvent(event, options)`). Swap the store with `setWebhookEventStore(store)` or per call with `{ store }`; `memoryWebhookEventStore({ ttlSeconds, maxEntries })` and `databaseWebhookEventStore(provider?)` are exported.
502
+ - Tolerance: `processWebhook` passes `tolerance` to verification, and the driver path (`paymentDriver().verifyWebhook`) reads `payment.stripe.webhookTolerance`, then `payment.webhook.tolerance`, then Stripe's 300 s default. It used to drop it.
503
+
389
504
  Supported webhook event types: `payment_intent.succeeded`, `payment_intent.payment_failed`, `payment_intent.created`, `payment_intent.canceled`, `customer.subscription.created/updated/deleted`, `customer.subscription.trial_will_end`, `customer.created/updated/deleted`, `invoice.paid/payment_failed/finalized/created`, `checkout.session.completed/expired`, `charge.succeeded/failed/refunded`, `charge.dispute.created/closed`, `payment_method.attached/detached`, `setup_intent.succeeded/setup_failed`, plus any string.
390
505
 
391
506
  ### Setup Products from SaaS Config
@@ -589,7 +704,7 @@ for the plans it is meant for.
589
704
  - `payment_methods` -- columns: `id`, `type`, `last_four`, `brand`, `exp_year`, `exp_month`, `user_id`, `provider_id`, `is_default`
590
705
  - `payment_products` -- columns: `id`, `name`, `unit_price`
591
706
  - `payment_transactions` -- columns: `id`, `name`, `description`, `amount`, `brand`, `type`, `provider_id`, `user_id`
592
- - `payment_webhook_events` -- columns: `provider`, `event_id` (unique together), `processed_at`; commerce's webhook dedup
707
+ - `payment_webhook_events` -- columns: `provider`, `event_id` (unique together), `processed_at`; commerce's webhook dedup (provider = driver name) and the handler registry's (provider `stripe:handlers`)
593
708
 
594
709
  ## User Model Requirements
595
710
  The `UserModel` must have:
@@ -663,4 +778,4 @@ The instance methods are exactly the keys of `createBillableMethods` in
663
778
  - Subscription status checks query the local database, not Stripe directly
664
779
  - `list()` on products defaults to `active: true` only
665
780
  - Webhook handlers are stored in an in-memory Map -- register them on application startup
666
- - `processWebhook()` uses `stripe.webhooks.constructEvent()` for signature verification
781
+ - `processWebhook()` verifies with `stripe.webhooks.constructEventAsync()` (async on every runtime) and dedupes by event id; see Webhooks
@@ -61,6 +61,25 @@ route.get('/admin', handler)
61
61
  .name('admin.dashboard')
62
62
  ```
63
63
 
64
+ ### Rate limits and the client address
65
+
66
+ `.rateLimit(max, 'minute' | seconds)` on a route and `await rateLimit(key, max,
67
+ { identity? }).per('hour')` in an action count in the configured Redis cache
68
+ when `config/cache.ts` selects Redis (one budget across every worker and
69
+ instance), otherwise, and while Redis is unreachable (warned once), in process
70
+ memory. `useRateLimitStorage(storage | null)` installs any ts-rate-limiter
71
+ `StorageProvider` (call `resetRateLimiters()` after). Every `max`, the Throttle
72
+ middleware's and the login lockout's are multiplied by
73
+ `config.security.rateLimit.scale` (`STACKS_RATE_LIMIT_SCALE`, default 1):
74
+ raise it in a test suite instead of turning limits off.
75
+
76
+ The anonymous identity is `clientAddress(req)` from `@stacksjs/router`, which
77
+ shadows bun-router's export and believes forwarding headers only from
78
+ `config.security.trustedProxies` (unset: loopback, private and link-local
79
+ networks; `[]`: none). Set it to the load balancer's ranges when other hosts
80
+ share the private network. bun-router's own `request.ip()` and the server
81
+ package's maintenance allow-list still use the bun-router default.
82
+
64
83
  ### Route Groups
65
84
  ```typescript
66
85
  route.group({ prefix: '/api/v1', middleware: ['auth', 'throttle'] }, () => {
@@ -336,6 +355,9 @@ bundles, and feature gates decide the mounted surface; inspect `buddy route:list
336
355
  - `POST /login`, `POST /register`, `POST /auth/refresh`, `POST /auth/token`
337
356
  - `GET /auth/tokens`, `DELETE /auth/tokens/{id}` (auth)
338
357
  - `GET /me`, `POST /logout` (auth)
358
+ - Social bundle: `GET /auth/{provider}`, `GET|POST /auth/{provider}/callback`
359
+ (POST skips CSRF; the browser-bound OAuth state is checked instead),
360
+ `POST /auth/handoff` (redeems the callback's one-use code)
339
361
 
340
362
  ### Email
341
363
  - `POST /api/email/subscribe`, `GET /api/email/unsubscribe`
@@ -375,7 +397,7 @@ url('user.show', { id: 42 }) // '/users/42'
375
397
 
376
398
  ## Gotchas
377
399
  - Routes use `@stacksjs/bun-router` under the hood
378
- - Rate limiting via `ts-rate-limiter` is built into the router
400
+ - Rate limiting via `ts-rate-limiter` is built into the router; it is shared through Redis only when the cache driver is Redis
379
401
  - Route registry in `app/Routes.ts` maps file names to URL prefixes
380
402
  - String-based handlers (`'Actions/MyAction'`) are dynamically imported
381
403
  - Middleware priority: lower number = runs first
@@ -101,6 +101,12 @@ interface HashMakeOptions {
101
101
  ## config/security.ts
102
102
  ```typescript
103
103
  {
104
+ // Application rate limits and the login lockout are multiplied by this.
105
+ // Keep 1 in production; tests raise it (or STACKS_RATE_LIMIT_SCALE).
106
+ rateLimit: { scale: 1 },
107
+ // Proxies whose forwarding headers name the client (clientAddress()).
108
+ // Unset = loopback/private/link-local; [] = trust none.
109
+ trustedProxies: ['10.0.0.0/8'],
104
110
  firewall: {
105
111
  enabled: true,
106
112
  countryCodes: [], // block by country
@@ -151,7 +157,8 @@ import { eraseSubject, exportSubjectData, pruneRetainedData } from '@stacksjs/or
151
157
  - Base64 is encoding, not hashing. Never use `base64Encode()` for credentials.
152
158
  - MD5 and base64 password verification are intentionally absent from the main security API.
153
159
  - The firewall config is used by cloud deployment for WAF rules
154
- - Rate limiting is per-minute per-IP (500 default)
160
+ - The firewall rate limit is per-minute per-IP at the WAF (`firewall.rateLimitPerMinute`)
161
+ - Application rate limits (`.rateLimit()`, `rateLimit()`, the login lockout) share the Redis cache when it is configured and count per process otherwise; see `stacks-router`
155
162
 
156
163
 
157
164
  ## Encryption and webhook contracts
@@ -208,3 +215,39 @@ endpoints; a cloud WAF setting does not implement model row authorization.
208
215
  Capability/config validation and credential recovery have separate tests and
209
216
  failure contracts; propagate their real errors rather than interpreting an
210
217
  awaited call as proof that a security state transition persisted.
218
+
219
+ ## Shadowed security defaults
220
+
221
+ `app/` wins resolution over `storage/framework/defaults/app/`, so an app copy of
222
+ a security default freezes that code: framework security fixes to it never run.
223
+ The API server (`buddy dev` and `serve`) warns once at boot, and `buddy doctor`
224
+ reports a "Security overrides" warning, for every file in
225
+ `SECURITY_DEFAULT_FILES` (`@stacksjs/server`) that the app shadows. That list
226
+ covers the Auth, Guest, Csrf, Cors, Throttle, Signed, Abilities, Can,
227
+ Permission, Role and EnsureEmailIsVerified middleware; the sign-in, session,
228
+ token, OAuth, passkey, magic-link, social and session-handoff actions with their
229
+ helpers; and the password-reset actions.
230
+
231
+ - `identical`: a byte-for-byte copy. Delete it, since it does nothing except
232
+ block fixes.
233
+ - `modified`: diff it against the current default and port any fixes. Once
234
+ reviewed, add `// stacks-reviewed-default: <fingerprint>` (the warning prints
235
+ the value). The fingerprint is a hash of the default, so the warning comes
236
+ back when the framework changes that default.
237
+
238
+ Set `STACKS_SHADOWED_DEFAULTS_WARN=false` to silence it. To check
239
+ programmatically, call `findShadowedSecurityDefaults(appDir, defaultsDir)`,
240
+ which returns `{ path, kind, appFile, defaultFile, fingerprint }[]`. Keep app
241
+ hooks in events or app-owned actions rather than copying these files.
242
+
243
+ ## Credential and sign-in hardening
244
+
245
+ - Social sign-in state is single use, browser bound and stored as a digest
246
+ (`issueOAuthFlow` / `consumeOAuthFlow`, `stacks-socials`); the callback hands
247
+ over a one-use code, never tokens in a URL (`createSessionHandoff`,
248
+ `stacks-auth`).
249
+ - Any change to how an account proves who it is runs
250
+ `revokeAccountCredentials()` (`stacks-auth`): stamp, then revoke challenges,
251
+ magic links, OAuth grants, tokens and sessions in one transaction.
252
+ - SMS STOP is never bypassed by `ignorePreferences`; only an explicit
253
+ `ignoreOptOut` for legally required messages (`stacks-notifications`).
@@ -27,29 +27,64 @@ provider test before handling one-time Apple name data or ID-token verification.
27
27
  Meta API version is centralized in meta-graph.ts rather than an obsolete v18
28
28
  copied into every example.
29
29
 
30
- Scopes/setScopes/with/setRedirectUrl belong to AbstractProvider. Providers read
31
- their actual services config and impose provider-specific scope and credential
32
- rules. withState supplies the flow's state and validateState checks the expected
33
- and returned values; persist expected state in the native callback session.
34
- Twitter sign-in keeps a PKCE verifier on its instance; getAuthUrl must
35
- precede its exchange on the same stateful flow. Persist and validate state/PKCE
36
- through the native callback path before minting an app credential; low-level
37
- getUserByToken alone does not prove the browser initiated a login.
30
+ Scopes/setScopes/with/setRedirectUrl/withState belong to AbstractProvider and
31
+ are on ProviderInterface, so the registry's `socialProvider()` result can be
32
+ driven without casts. Every OAuth2 driver honours withState, with() and an
33
+ instance redirect. X always uses PKCE; its verifier lives in the base class
34
+ (getPkceVerifier/withCodeVerifier), so a callback on a fresh instance can
35
+ restore it. Registry descriptors say `pkce` (X) and `nonce` (Apple).
36
+
37
+ ## Flow state (login CSRF)
38
+
39
+ A callback that exchanges any `code` signs the victim into the attacker's
40
+ account. Start every flow with
41
+ `issueOAuthFlow({ purpose, userId?, data?, ttlMs, bindBrowser?, challenge?, nonce?, cookie? })`,
42
+ which returns `{ state, cookie, secret, nonce?, expiresAt }`: a random state for
43
+ `withState()`, and a `Set-Cookie` (HttpOnly, per-flow `stacks_oauth_*` name,
44
+ `SameSite=Lax`, or `None; Secure` for Apple's cross-site form_post) binding it
45
+ to the browser. Only SHA-256 digests are stored, in the auth-owned
46
+ `oauth_flows` table (`buddy migrate`). On the callback,
47
+ `consumeOAuthFlow(state, { purpose, browserSecret })` returns
48
+ `{ purpose, userId, nonce, data, expiresAt }` once, or null for an unknown,
49
+ spent, expired, foreign-purpose or other-browser state; a mismatch does not burn
50
+ the real flow. Read the secret with `readOAuthFlowCookie(cookieHeader, state)`
51
+ and clear it on every outcome with `clearOAuthFlowCookie(state, cookieOptions)`.
52
+ `challenge` binds to the SHA-256 hex of a native client's verifier instead of a
53
+ cookie. `nonce: true` mints an OIDC nonce: pass it with `with({ nonce })` and
54
+ to `getUserByToken(token, { nonce })`, which refuses a mismatched id_token.
55
+ `useOAuthFlowStore(memoryOAuthFlowStore())` swaps storage in tests;
56
+ `validateState()` remains for callers holding their own expected value.
57
+
58
+ The default routes do all of this: `GET /auth/{provider}` (optional
59
+ `?redirect_to=/path`, same-origin only) and `GET|POST /auth/{provider}/callback`
60
+ (POST skips CSRF, since the bound state is the defence). Read
61
+ `defaults/app/Actions/Auth/SocialRedirectAction.ts` and
62
+ `SocialCallbackAction.ts` before writing a custom callback.
38
63
 
39
64
  SocialUser includes emailVerified. Respect the provider's verification signal
40
65
  and the configured account-linking policy, using auth's resolveSocialSignIn
41
- instead of attaching any equal email address to an existing account. Provider
42
- errors and ConfigException/InvalidStateException are different from successful
43
- profile retrieval. Never log raw credential fields.
66
+ instead of attaching any equal email address to an existing account; it also
67
+ refuses to link onto a local account that never verified its address
68
+ (`'unverified-local-email'`). Provider errors and
69
+ ConfigException/InvalidStateException are different from successful profile
70
+ retrieval. Never log raw credential fields.
44
71
 
45
72
  ## Session handoff
46
73
 
47
- socialHandoffRedirect(pack, { redirectTo?, allowedHosts? }) emits a no-store
48
- redirect with the native encoded session handoff. Browser useAuth's
49
- completeSocialLogin consumes it and removes the fragment. isSafeHandoffTarget
50
- checks permitted redirect shape; choose a server-owned landing path. The helper
51
- also supplies a failed-sign-in redirect. These are separate from provider token
52
- exchange and the auth cookie/session issuance policy.
74
+ The default callback mints no session and puts no token in the URL. It calls
75
+ `createSessionHandoff(userId, { bindTo: 'cookie' })` (`@stacksjs/auth`) and
76
+ returns `socialHandoffCodeRedirect(code, { redirectTo?, allowedHosts? })`, a
77
+ no-store 302 to `redirectTo#stx_handoff=<code>` plus the binding cookie.
78
+ Browser `useAuth().completeSocialLogin()` strips the fragment and POSTs the code
79
+ to `POST /auth/handoff`, which redeems it once (60 seconds, this browser only)
80
+ and answers with the token pack and auth cookie. Native apps bind with
81
+ `{ challenge }` and redeem with `{ code, verifier }`.
82
+
83
+ socialHandoffRedirect(pack, ...) still emits the older fragment carrying the
84
+ bearer and refresh tokens (`stx_auth`); avoid it for new flows, since browser
85
+ history keeps the fragment. isSafeHandoffTarget checks permitted redirect
86
+ shape; choose a server-owned landing path. socialHandoffFailureRedirect supplies
87
+ the failed-sign-in redirect (`?social_error=`).
53
88
 
54
89
  ## Named publishing identities
55
90
 
@@ -83,8 +118,9 @@ conformance or successful publication for arbitrary accounts.
83
118
 
84
119
  ## Source and evidence
85
120
 
86
- `storage/framework/core/socials/src/index.ts`, registry.ts, abstract.ts,
121
+ `storage/framework/core/socials/src/index.ts`, registry.ts, abstract.ts, flows.ts,
87
122
  handoff.ts, identities.ts, publish.ts, authorize.ts, meta-graph.ts and drivers/.
88
- Tests: registry.test.ts, apple.test.ts, session-handoff.test.ts,
123
+ Tests: registry.test.ts, apple.test.ts, session-handoff.test.ts, oauth-flows.test.ts,
124
+ provider-flow-state.test.ts, auth/tests/social-callback-flow.test.ts,
89
125
  identities.test.ts, publish.test.ts, authorize.test.ts,
90
126
  credentials-stay-out-of-output.test.ts and meta-graph.test.ts.
@@ -24,6 +24,7 @@ File system abstraction with a Laravel-style Storage facade, local/S3/Azure adap
24
24
  - Bun adapter: `storage/framework/core/storage/src/adapters/bun.ts`
25
25
  - File utilities: `storage/framework/core/storage/src/files.ts`
26
26
  - Bounded local text reads: `storage/framework/core/storage/src/bounded-text-file.ts`
27
+ - Completed local artifacts: `storage/framework/core/storage/src/completed-artifact-directory.ts`
27
28
  - Folder utilities: `storage/framework/core/storage/src/folders.ts`
28
29
  - Copy: `storage/framework/core/storage/src/copy.ts`
29
30
  - Move: `storage/framework/core/storage/src/move.ts`
@@ -71,6 +72,19 @@ import * as storage from '@stacksjs/storage'
71
72
 
72
73
  ## Storage Facade (StorageManager)
73
74
 
75
+ ### Completed local artifacts
76
+
77
+ Use `writeCompletedArtifact` from `@stacksjs/storage/completed-artifact-directory`
78
+ for a bounded, new local output directory containing data files and a JSON
79
+ completion manifest. Pass `(directory, [{ path, contents }], manifestJson)`;
80
+ optional `{ directories: ['images'] }` creates empty folders. It snapshots bytes,
81
+ validates paths/collisions/budgets, writes private exclusive files, syncs files
82
+ and directories, then hard-links the synced manifest to its completion name.
83
+ Read the package README for exact limits and failure behavior. Callers own
84
+ parent-directory authorization and domain validation. This is a local hard-link
85
+ and directory-fsync protocol, not a cloud-adapter transaction or tested hardware
86
+ power-loss guarantee.
87
+
74
88
  ### Recoverable temporary workspaces
75
89
 
76
90
  Use `TemporaryWorkspaceJournal` for workers that recover temporary directories after a process interruption. Keep its private journal outside job-controlled checkouts and separate it by stable worker and controller identity. `create(key)` returns a frozen live ownership handle; stop its resources, then call `complete(handle)` for normal disposal. A restarted instance cannot forge this capability from a listed record.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-testing
3
- description: Use when writing or running tests in Stacks - test setup, database test utilities (setup, refresh, truncate), DynamoDB testing, feature test patterns, the test CLI commands, test configuration in bunfig.toml, or test environment setup. Covers @stacksjs/testing and tests/.
3
+ description: Use when writing or running tests in Stacks - test setup, database test utilities (setup, refresh, truncate), disposable SQLite/Postgres databases, running a test in an isolated child process, headless Chrome browser tests and page audits, DynamoDB testing, feature test patterns, the test CLI commands, test configuration in bunfig.toml, or test environment setup. Covers @stacksjs/testing and tests/.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -168,9 +168,95 @@ expect(result.exitCode).toBe(0)
168
168
  ```
169
169
 
170
170
  Retained implementation evidence lives under `core/testing/tests/`:
171
- `http-client`, `acting-as`, `database-assertions`, `events`, `mail`, `time`
172
- and `console` test files. Fixture mechanics live in `core/testing/src/`;
173
- `package.json` declares the root and database/DynamoDB export boundaries.
171
+ `http-client`, `acting-as`, `database-assertions`, `events`, `mail`, `time`,
172
+ `console`, `isolated`, `disposable-database` and `browser` test files. Fixture mechanics live in `core/testing/src/`;
173
+ `package.json` declares the root and database/DynamoDB/browser export boundaries.
174
+
175
+ ## Isolated processes and disposable databases
176
+
177
+ `bun test` runs every file in one process, and `mock.module()` replaces a
178
+ module for the rest of that run, so a mock in one file leaks into the next. A
179
+ framework booted against one database also stays booted. When a test needs a
180
+ clean process, run it in a child (`src/isolated.ts`); when it needs real rows,
181
+ give it a database nobody else uses (`src/disposable-database.ts`). Both are on
182
+ the root, with no framework import.
183
+
184
+ ```ts
185
+ import { assertIsolatedOk, disposablePostgresUrl, runIsolated, test, withDisposableDatabase } from '@stacksjs/testing'
186
+
187
+ for (const dialect of ['sqlite', 'postgres'] as const) {
188
+ test.skipIf(dialect === 'postgres' && !disposablePostgresUrl())(`${dialect} checkout flow`, async () => {
189
+ await withDisposableDatabase(dialect, async (database) => {
190
+ const result = await runIsolated('tests/fixtures/checkout-flow.ts', {
191
+ env: { APP_ENV: 'test', ...database.env },
192
+ timeoutMs: 45_000,
193
+ envFile: false, // --no-env-file
194
+ preload: [], // replace bunfig preloads (none)
195
+ })
196
+ assertIsolatedOk(result, 'checkout flow OK') // throws with stdout + stderr otherwise
197
+ })
198
+ }, 50_000)
199
+ }
200
+ ```
201
+
202
+ - `runIsolated(file, { env, timeoutMs = 60_000, args, cwd, mode, preload, envFile, inheritEnv, stdin })`
203
+ resolves `{ code, signal, stdout, stderr, timedOut, durationMs, command }`. `mode: 'auto'` runs
204
+ `*.test.*` / `*.spec.*` with `bun test`, anything else as a script; `env` values of `undefined`
205
+ remove a variable. The watchdog sends SIGTERM at the timeout and SIGKILL 2 s later.
206
+ - `withDisposableDatabase(dialect, fn, { url?, prefix? })`: SQLite is a file in a fresh temp
207
+ directory; Postgres is `CREATE DATABASE` on `url` or `STACKS_TEST_POSTGRES_URL`, dropped with
208
+ `DROP DATABASE ... WITH (FORCE)` after `fn`, even when it throws (the test's error wins over a
209
+ cleanup error). Only `localhost`, `127.0.0.1` and `::1` are accepted. `database.env` holds the
210
+ `DB_*` variables (`DB_CONNECTION`, `DB_DATABASE_PATH` or `DB_HOST`/`DB_PORT`/`DB_DATABASE`/
211
+ `DB_USERNAME`/`DB_PASSWORD`); `database.directory` is removed with it.
212
+ - `createDisposableDatabase` returns the same with `drop()` and `Symbol.asyncDispose`
213
+ (`await using`), for setups that span hooks.
214
+
215
+ ## Browser tests: `@stacksjs/testing/browser`
216
+
217
+ Headless Chrome over the DevTools Protocol with no browser package: the installed
218
+ Chrome and Bun's WebSocket (`src/browser.ts`). A subpath so tests that never drive
219
+ a browser never load it.
220
+
221
+ ```ts
222
+ import { auditPage, launch, navigate, pageAuditClean, settle } from '@stacksjs/testing/browser'
223
+
224
+ const { cdp, close } = await launch({ viewport: { width: 390, height: 844, mobile: true } })
225
+ try {
226
+ const off = cdp.on('Network.requestWillBeSent', params => requests.push(params.request.url))
227
+ await navigate(cdp, 'http://localhost:3000/dashboard') // settles: loaded + network quiet 1 s
228
+ expect(await cdp.evaluate('document.title')).toBe('Dashboard')
229
+ expect(cdp.errors).toEqual([]) // exceptions, console.error, failed requests, dialogs
230
+ off()
231
+ }
232
+ finally { await close() }
233
+
234
+ const report = await auditPage('http://localhost:3000/pricing') // launches its own Chrome, or pass { cdp }
235
+ expect(pageAuditClean(report)).toBe(true) // no duplicate /api/ requests, failed same-origin requests or errors
236
+ ```
237
+
238
+ - `Cdp`: `send`, `evaluate`, `on(method, fn)` (any number of listeners per method; returns an
239
+ unsubscribe), `off`, `once(method, match?, timeoutMs?)`, `errors`, `inFlight` (event streams
240
+ leave it when their response starts, so a page with live updates can still settle), `closed`
241
+ (every pending command fails at once when Chrome goes away).
242
+ - `launch(options)` enables Page/Runtime/Network/Log, sets a 1440x1000 viewport, answers stx's
243
+ live-reload stream (`*/_stx/hmr*`) with a day-long retry so a save mid-run cannot reload the
244
+ page, accepts JavaScript dialogs and records them in `errors`, removes "HeadlessChrome" from the
245
+ user agent, and passes `--use-mock-keychain`. `profile` keeps one profile across relaunches.
246
+ - `settle(cdp, { quietMs, timeoutMs })` resolves false (not throws) when a page never goes quiet.
247
+ - `auditPage(url, { cdp?, launch?, viewport?, isApiRequest?, beforeNavigate?, quietMs, timeoutMs })`
248
+ returns `{ apiRequests, duplicates, failed, errors, settled, durationMs }` and never throws for a
249
+ page problem. `beforeNavigate` is where to seed a token (`Page.addScriptToEvaluateOnNewDocument`).
250
+ `formatPageAudit(report)` prints one line.
251
+ - **macOS: the installed Chrome quits about 30 s in.** GoogleUpdater SIGTERMs every process with
252
+ Chrome's bundle id while an update is pending; to a test that looks like a hung page. When
253
+ `CHROME_PATH` is not set, `launch()` runs `prepareMacChrome()`: a `cp -cR` clone in the temp
254
+ directory with the `KS*` updater keys deleted from `Info.plist`, its own `CFBundleIdentifier`,
255
+ and `codesign --force --deep --sign -`, reused per Chrome version. `--use-mock-keychain` is what
256
+ keeps that clone from hanging on an invisible "Chrome Safe Storage" Keychain prompt at its first
257
+ http page. Measured: the installed Chrome closed at 32 s, the clone kept navigating past 60 s.
258
+ - The package's own real-Chrome tests (`core/testing/tests/browser.test.ts`) run wherever a Chrome
259
+ is found, except in CI unless `STACKS_BROWSER_TESTS=1`; `STACKS_BROWSER_TESTS=0` skips them locally.
174
260
 
175
261
  ## CLI Commands
176
262
 
@@ -182,8 +268,13 @@ bun run test # via npm script
182
268
  bun run test:ui # UI tests
183
269
  bun run test:coverage # with coverage
184
270
  bun run test:types # type tests
271
+ buddy test:page-audit --url /,/dashboard --base http://localhost:3000 # duplicate API requests, failures, console errors
272
+ buddy test:page-audit --url http://localhost:3000/pricing --mobile --json
185
273
  ```
186
274
 
275
+ `test:page-audit` exits 1 when any page has duplicates, failed same-origin requests or errors, or
276
+ never settles.
277
+
187
278
  ## Configuration (bunfig.toml)
188
279
 
189
280
  ```toml
@@ -0,0 +1,65 @@
1
+ import { Action } from '@stacksjs/actions/runtime'
2
+ import { Auth, authCookieForBrowserSession, clearSessionHandoffCookie, readSessionHandoffCookie, redeemSessionHandoff, resolveBrowserSessionPolicy } from '@stacksjs/auth'
3
+
4
+ /**
5
+ * `POST /auth/handoff` — trade a one-use sign-in code for a session.
6
+ *
7
+ * SocialCallbackAction ends by redirecting with a 60-second code in the URL
8
+ * fragment and a matching HttpOnly cookie. `useAuth().completeSocialLogin()`
9
+ * posts the code here. The code redeems once, and only with the cookie that
10
+ * was set beside it (or, for a native app whose sign-in sheet has its own
11
+ * cookie jar, the `verifier` behind the challenge it started with). A code
12
+ * copied out of history, a log or another browser answers 401.
13
+ *
14
+ * The answer is LoginAction's: an OAuth2-shaped token pack plus the auth
15
+ * cookie for server-rendered pages.
16
+ *
17
+ * CSRF is skipped on the route because the binding cookie is the proof a
18
+ * CSRF token would add: a cross-site page can neither read the code nor make
19
+ * the browser present someone else's binding cookie.
20
+ */
21
+ export default new Action({
22
+ name: 'SessionHandoffAction',
23
+ description: 'Redeems a one-use sign-in handoff code for a session.',
24
+ method: 'POST',
25
+
26
+ async handle(request: RequestInstance) {
27
+ const code = String(request.get('code') ?? '')
28
+ const verifier = request.get('verifier')
29
+ const secret = typeof verifier === 'string' && verifier
30
+ ? verifier
31
+ : readSessionHandoffCookie(request.headers.get('cookie'), code)
32
+
33
+ const headers = new Headers({ 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' })
34
+ if (/^[a-f0-9]{64}$/.test(code))
35
+ headers.append('Set-Cookie', await clearSessionHandoffCookie(code))
36
+
37
+ const handoff = await redeemSessionHandoff(code, secret)
38
+ if (!handoff)
39
+ return Response.json({ error: 'This sign-in expired or was started in another browser. Please try again.' }, { status: 401, headers })
40
+
41
+ // The baseline (not remembered) browser session, as the other sign-in paths issue.
42
+ const policy = resolveBrowserSessionPolicy(false)
43
+ const result = await Auth.loginUsingId(handoff.userId, {
44
+ expiresInMinutes: policy.expiresInMinutes,
45
+ withRefreshToken: policy.withRefreshToken,
46
+ })
47
+ if (!result?.token)
48
+ return Response.json({ error: 'Sign-in could not be completed. Please try again.' }, { status: 401, headers })
49
+
50
+ headers.append('Set-Cookie', authCookieForBrowserSession(result.token, result.expiresIn))
51
+ const user = result.user
52
+ return Response.json({
53
+ access_token: result.token,
54
+ refresh_token: result.refreshToken,
55
+ token_type: 'Bearer',
56
+ expires_in: result.expiresIn,
57
+ token: result.token,
58
+ user: {
59
+ id: user?.id,
60
+ email: user?.email,
61
+ name: user?.name,
62
+ },
63
+ }, { headers })
64
+ },
65
+ })