@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.
- package/ai/skills/stacks-actions/SKILL.md +1 -1
- package/ai/skills/stacks-auth/ACCOUNT-WORKFLOWS.md +24 -7
- package/ai/skills/stacks-auth/SKILL.md +42 -8
- package/ai/skills/stacks-browser/SKILL.md +31 -8
- package/ai/skills/stacks-cli/SKILL.md +8 -0
- package/ai/skills/stacks-database/SKILL.md +4 -0
- package/ai/skills/stacks-datetime/SKILL.md +67 -3
- package/ai/skills/stacks-native/CATALOG.md +32 -32
- package/ai/skills/stacks-notifications/SKILL.md +16 -1
- package/ai/skills/stacks-payments/SKILL.md +120 -5
- package/ai/skills/stacks-router/SKILL.md +23 -1
- package/ai/skills/stacks-security/SKILL.md +44 -1
- package/ai/skills/stacks-socials/SKILL.md +55 -19
- package/ai/skills/stacks-storage/SKILL.md +14 -0
- package/ai/skills/stacks-testing/SKILL.md +95 -4
- package/app/Actions/Auth/SessionHandoffAction.ts +65 -0
- package/app/Actions/Auth/SocialCallbackAction.ts +104 -37
- package/app/Actions/Auth/SocialRedirectAction.ts +41 -6
- package/app/Actions/Auth/social-flow.ts +49 -0
- package/app/Middleware/Throttle.ts +4 -3
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/resources/emails/password-reset.stx +16 -6
- package/routes/socials.ts +14 -4
|
@@ -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
|
-
|
|
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()`
|
|
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
|
-
-
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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 `
|
|
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
|
+
})
|