@stacksjs/defaults 0.75.181 → 0.75.183

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.
@@ -337,18 +337,43 @@ const result = await actions.resetPassword(token, newPassword)
337
337
  - `logout(): Promise<void>`
338
338
  - `refresh(): Promise<void>`
339
339
 
340
- ## Passkey/WebAuthn (passkey.ts)
341
-
342
- - `getUserPasskeys(userId): Promise<PasskeyAttribute[]>`
343
- - `getUserPasskey(userId, passkeyId): Promise<PasskeyAttribute | undefined>`
344
- - `setCurrentRegistrationOptions(user, verified): Promise<void>`
345
-
346
- ### Re-exported from @stacksjs/ts-auth
347
- - `generateRegistrationOptions`, `generateAuthenticationOptions`
348
- - `verifyRegistrationResponse`, `verifyAuthenticationResponse`
349
- - `startRegistration`, `startAuthentication` (browser)
350
- - `browserSupportsWebAuthn`, `browserSupportsWebAuthnAutofill`
351
- - `platformAuthenticatorIsAvailable`
340
+ ## Passkey/WebAuthn
341
+
342
+ Opt in with `config.auth.passkeys: { enabled: true, origin?: string, name?: string }`.
343
+ The canonical origin defaults to `config.app.url`; production requires HTTPS.
344
+ `buddy migrate` / `buddy auth:setup` create `passkey_ceremonies` and `passkeys`.
345
+
346
+ - `beginPasskeyRegistration({ id, email?, name? }, { origin, name })` returns
347
+ `{ ceremony, options }` as JSON. Call only for the authenticated account.
348
+ - `finishPasskeyRegistration(userId, ceremony, credentialJSON, party)` consumes
349
+ the account-bound challenge and saves only verified resident credentials.
350
+ - `beginPasskeyAuthentication(party)` returns discoverable options without an
351
+ email lookup, usable with conditional mediation and `autocomplete="username webauthn"`.
352
+ - `finishPasskeyAuthentication(ceremony, credentialJSON, party)` returns the
353
+ verified user ID; the application then issues its usual session.
354
+ - `listPasskeys(userId)` returns display-safe metadata, never public-key material.
355
+ - `removePasskey(userId, id, hasAlternativeSignIn?)` serializes removals and
356
+ refuses to remove the last key without another login method. Supply a callback
357
+ when password hashes may be unusable placeholders or other factors exist.
358
+
359
+ Default routes under `/auth/passkeys` expose registration/options, registration,
360
+ authentication/options, authentication (POST), listing (GET) and deletion (DELETE
361
+ `/{id}`). Public authentication endpoints require the canonical Origin header;
362
+ enrollment/removal also require auth and CSRF protection. Deleting the last key
363
+ requires proving the fallback password in the request. All responses are no-store.
364
+
365
+ Browser code imports **`@stacksjs/auth/passkeys-browser`**, never the server barrel.
366
+ It exports `startRegistration`, `startAuthentication(options, { signal?, mediation? })`,
367
+ `browserSupportsWebAuthn`, `browserSupportsWebAuthnAutofill`, the JSON option/credential
368
+ codecs and types. Abort a pending conditional request before explicit sign-in or
369
+ password submission, and on unmount. Serialize results using
370
+ `registrationCredentialToJSON` / `authenticationCredentialToJSON` before POSTing.
371
+ Native JS contexts require a platform passkey bridge; web support alone does not
372
+ provide one. Keep existing native sign-in available until that bridge exists.
373
+
374
+ Legacy helpers (`getUserPasskeys`, `getUserPasskey`, `storeWebAuthnChallenge`,
375
+ `consumeWebAuthnChallenge`) remain for existing integrations. New flows should
376
+ use the ceremony API, which supports concurrent anonymous browser attempts.
352
377
 
353
378
  ## Auth Middleware (middleware.ts)
354
379
 
@@ -452,7 +452,14 @@ option the original metres heuristic remains for unlabelled inputs.
452
452
  - `<NativeSheet v-model:open="editing" title="…">` raises a bottom sheet for a
453
453
  task that belongs to the screen: dragged down from its grabber or header to
454
454
  dismiss, the page behind inert. `detents="medium large"` makes it full
455
- height with detents, the page pressed into a card at large.
455
+ height with detents, the page pressed into a card at large. An open sheet is
456
+ a layer on stx's overlay stack (`window.stx.overlays`) with Dialog, Drawer,
457
+ stxConfirm and the HTML alerts: the stack locks the page, makes it inert,
458
+ gives Escape and Tab to the top layer and returns focus, so a confirm opened
459
+ from a sheet closes alone. `lockBackground` and `trapFocus` no longer exist;
460
+ an app's own overlay uses `openOverlay` / `closeOverlay` from
461
+ `@stacksjs/components/overlays` (in `<script client>`), passing `root` for
462
+ an element lifted into `<body>` and `zIndex` to stack by opening order.
456
463
  - `<NativeContextMenu :items="[...]" @select="…">` opens a menu on a
457
464
  half-second press: UIMenu in the shell, an HTML menu elsewhere.
458
465
  - `<NativeActionSheet v-model:open="choosing" :actions="[...]" @select="…">`
@@ -18,7 +18,7 @@ Browser runtime bindings are documented separately in `stacks-auto-imports`.
18
18
  | `@stacksjs/api` | [`stacks-api`](../stacks-api/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/api/src/index.ts) | 7 |
19
19
  | `@stacksjs/arrays` | [`stacks-arrays`](../stacks-arrays/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/arrays/src/index.ts) | 6 |
20
20
  | `@stacksjs/audio` | [`stacks-audio`](../stacks-audio/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/audio/src/index.ts) | 2 |
21
- | `@stacksjs/auth` | [`stacks-auth`](../stacks-auth/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/index.ts) | 112 |
21
+ | `@stacksjs/auth` | [`stacks-auth`](../stacks-auth/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/index.ts) | 113 |
22
22
  | `@stacksjs/browser` | [`stacks-browser`](../stacks-browser/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/browser/src/index.ts) | 8 |
23
23
  | `@stacksjs/browser-extension` | [`stacks-browser-extension`](../stacks-browser-extension/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/browser-extension/src/index.ts) | 7 |
24
24
  | `@stacksjs/buddy` | [`stacks-buddy`](../stacks-buddy/SKILL.md) | [entrypoint](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/buddy/src/index.ts) | 145 |
@@ -183,9 +183,9 @@ Guidance: [`stacks-auth`](../stacks-auth/SKILL.md). Source: [storage/framework/c
183
183
 
184
184
  Declared runtime exports: `DEFAULT_ROLE_PACKS`, `createBqbRbacStore`, `generateTOTP`, `generateTOTPSecret`, `seedDefaultRoles`, `totpKeyUri`, `verifyTOTP`.
185
185
 
186
- Local source modules: [src/authentication.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/authentication.ts), [src/authenticator.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/authenticator.ts), [src/authorizable.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/authorizable.ts), [src/browser-session.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/browser-session.ts), [src/client.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/client.ts), [src/cookie-auth.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/cookie-auth.ts), [src/credential-user.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/credential-user.ts), [src/email-verification.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/email-verification.ts), [src/gate.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/gate.ts), [src/magic-link.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/magic-link.ts), [src/middleware.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/middleware.ts), [src/oauth-authorization-codes.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-authorization-codes.ts), [src/oauth-authorization-endpoint.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-authorization-endpoint.ts), [src/oauth-authorization-requests.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-authorization-requests.ts), [src/oauth-authorization-response.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-authorization-response.ts), [src/oauth-authorization.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-authorization.ts), [src/oauth-browser-session.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-browser-session.ts), [src/oauth-client-registration.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-client-registration.ts), [src/oauth-consent.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-consent.ts), [src/oauth-delegated-access.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-delegated-access.ts), [src/oauth-grants.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-grants.ts), [src/oauth-introspection.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-introspection.ts), [src/oauth-metadata.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-metadata.ts), [src/oauth-pkce.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-pkce.ts), [src/oauth-provider.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-provider.ts), [src/oauth-pruning.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-pruning.ts), [src/oauth-revocation.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-revocation.ts), [src/oauth-token-endpoint.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-token-endpoint.ts), [src/oauth-token-exchange.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-token-exchange.ts), [src/oauth-token-request.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-token-request.ts), [src/oauth-token-response.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-token-response.ts), [src/page-gate.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/page-gate.ts), [src/passkey.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/passkey.ts), [src/password/reset.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/password/reset.ts), [src/permissions.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/permissions.ts), [src/policy.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/policy.ts), [src/rate-limiter.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/rate-limiter.ts), [src/rbac-seed.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/rbac-seed.ts), [src/rbac-store-bqb.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/rbac-store-bqb.ts), [src/rbac.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/rbac.ts), [src/referrals.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/referrals.ts), [src/register.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/register.ts), [src/request-token.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/request-token.ts), [src/session-auth.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/session-auth.ts), [src/socials.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/socials.ts), [src/team.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/team.ts), [src/tokens.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/tokens.ts), [src/two-factor.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/two-factor.ts), [src/user.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/user.ts).
186
+ Local source modules: [src/authentication.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/authentication.ts), [src/authenticator.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/authenticator.ts), [src/authorizable.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/authorizable.ts), [src/browser-session.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/browser-session.ts), [src/client.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/client.ts), [src/cookie-auth.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/cookie-auth.ts), [src/credential-user.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/credential-user.ts), [src/email-verification.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/email-verification.ts), [src/gate.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/gate.ts), [src/magic-link.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/magic-link.ts), [src/middleware.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/middleware.ts), [src/oauth-authorization-codes.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-authorization-codes.ts), [src/oauth-authorization-endpoint.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-authorization-endpoint.ts), [src/oauth-authorization-requests.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-authorization-requests.ts), [src/oauth-authorization-response.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-authorization-response.ts), [src/oauth-authorization.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-authorization.ts), [src/oauth-browser-session.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-browser-session.ts), [src/oauth-client-registration.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-client-registration.ts), [src/oauth-consent.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-consent.ts), [src/oauth-delegated-access.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-delegated-access.ts), [src/oauth-grants.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-grants.ts), [src/oauth-introspection.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-introspection.ts), [src/oauth-metadata.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-metadata.ts), [src/oauth-pkce.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-pkce.ts), [src/oauth-provider.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-provider.ts), [src/oauth-pruning.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-pruning.ts), [src/oauth-revocation.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-revocation.ts), [src/oauth-token-endpoint.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-token-endpoint.ts), [src/oauth-token-exchange.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-token-exchange.ts), [src/oauth-token-request.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-token-request.ts), [src/oauth-token-response.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/oauth-token-response.ts), [src/page-gate.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/page-gate.ts), [src/passkey.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/passkey.ts), [src/passkeys.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/passkeys.ts), [src/password/reset.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/password/reset.ts), [src/permissions.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/permissions.ts), [src/policy.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/policy.ts), [src/rate-limiter.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/rate-limiter.ts), [src/rbac-seed.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/rbac-seed.ts), [src/rbac-store-bqb.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/rbac-store-bqb.ts), [src/rbac.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/rbac.ts), [src/referrals.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/referrals.ts), [src/register.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/register.ts), [src/request-token.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/request-token.ts), [src/session-auth.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/session-auth.ts), [src/socials.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/socials.ts), [src/team.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/team.ts), [src/tokens.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/tokens.ts), [src/two-factor.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/two-factor.ts), [src/user.ts](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/src/user.ts).
187
187
 
188
- Evidence directory: [112 retained tests](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/tests). These files are evidence pointers, not a claim that all tests were run for this catalog.
188
+ Evidence directory: [113 retained tests](https://github.com/stacksjs/stacks/blob/main/storage/framework/core/auth/tests). These files are evidence pointers, not a claim that all tests were run for this catalog.
189
189
 
190
190
  ### @stacksjs/browser
191
191
 
@@ -0,0 +1,23 @@
1
+ import { Action } from '@stacksjs/actions/runtime'
2
+ import { finishPasskeyAuthentication, Auth, resolveBrowserSessionPolicy, authCookieForBrowserSession } from '@stacksjs/auth'
3
+ import { passkeyParty, passkeyFailure } from './passkey-config'
4
+
5
+ export default new Action({
6
+ name: 'PasskeyAuthentication',
7
+ method: 'POST',
8
+ async handle(request) {
9
+ const party = passkeyParty()
10
+ if (!party) return new Response(null, { status: 404 })
11
+ if (request.headers.get('origin') !== new URL(party.origin).origin) return new Response(null, { status: 403 })
12
+ try {
13
+ const body = request.all()
14
+ const id = await finishPasskeyAuthentication(body.ceremony, body.credential, party)
15
+ const policy = resolveBrowserSessionPolicy(body.remember)
16
+ const session = await Auth.loginUsingId(id, { expiresInMinutes: policy.expiresInMinutes, withRefreshToken: policy.withRefreshToken })
17
+ if (!session) return new Response(null, { status: 401 })
18
+ return Response.json({ token: session.token, user: { id: session.user?.id, email: session.user?.email, name: session.user?.name }, expires_in: session.expiresIn, remembered: policy.remembered },
19
+ { headers: { 'Cache-Control': 'no-store', 'Set-Cookie': authCookieForBrowserSession(session.token, session.expiresIn) } })
20
+ }
21
+ catch (error) { return passkeyFailure(error) }
22
+ },
23
+ })
@@ -0,0 +1,17 @@
1
+ import { Action } from '@stacksjs/actions/runtime'
2
+ import { beginPasskeyAuthentication } from '@stacksjs/auth'
3
+ import { passkeyParty, passkeyFailure, passkeyJSON } from './passkey-config'
4
+
5
+ export default new Action({
6
+ name: 'PasskeyAuthenticationOptions',
7
+ method: 'POST',
8
+ async handle(request) {
9
+ const party = passkeyParty()
10
+ if (!party) return new Response(null, { status: 404 })
11
+ if (request.headers.get('origin') !== new URL(party.origin).origin) return new Response(null, { status: 403 })
12
+ try {
13
+ return passkeyJSON(await beginPasskeyAuthentication(party))
14
+ }
15
+ catch (error) { return passkeyFailure(error) }
16
+ },
17
+ })
@@ -0,0 +1,24 @@
1
+ import { Action } from '@stacksjs/actions/runtime'
2
+ import { verifyHash } from '@stacksjs/security'
3
+ import { removePasskey } from '@stacksjs/auth'
4
+ import { passkeyParty, passkeyFailure, passkeyJSON } from './passkey-config'
5
+
6
+ export default new Action({
7
+ name: 'PasskeyDestroy',
8
+ method: 'DELETE',
9
+ async handle(request) {
10
+ const party = passkeyParty()
11
+ if (!party) return new Response(null, { status: 404 })
12
+ try {
13
+ const user = await request.user()
14
+ if (!user) return new Response(null, { status: 401 })
15
+ const id = request.getParam('id')
16
+ if (typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,1366}$/.test(id)) return new Response(null, { status: 422 })
17
+ return await removePasskey(Number(user.id), id, async row => {
18
+ const password = request.get('password')
19
+ return typeof password === 'string' && !!password && typeof row.password === 'string' && await verifyHash(password, row.password)
20
+ }) ? passkeyJSON({ ok: true }) : new Response(null, { status: 404 })
21
+ }
22
+ catch (error) { return passkeyFailure(error) }
23
+ },
24
+ })
@@ -0,0 +1,18 @@
1
+ import { Action } from '@stacksjs/actions/runtime'
2
+ import { listPasskeys } from '@stacksjs/auth'
3
+ import { passkeyParty, passkeyFailure, passkeyJSON } from './passkey-config'
4
+
5
+ export default new Action({
6
+ name: 'PasskeyIndex',
7
+ method: 'GET',
8
+ async handle(request) {
9
+ const party = passkeyParty()
10
+ if (!party) return new Response(null, { status: 404 })
11
+ try {
12
+ const user = await request.user()
13
+ if (!user) return new Response(null, { status: 401 })
14
+ return passkeyJSON({ passkeys: await listPasskeys(Number(user.id)) })
15
+ }
16
+ catch (error) { return passkeyFailure(error) }
17
+ },
18
+ })
@@ -0,0 +1,20 @@
1
+ import { Action } from '@stacksjs/actions/runtime'
2
+ import { finishPasskeyRegistration } from '@stacksjs/auth'
3
+ import { passkeyParty, passkeyFailure, passkeyJSON } from './passkey-config'
4
+
5
+ export default new Action({
6
+ name: 'PasskeyRegistration',
7
+ method: 'POST',
8
+ async handle(request) {
9
+ const party = passkeyParty()
10
+ if (!party) return new Response(null, { status: 404 })
11
+ try {
12
+ const user = await request.user()
13
+ if (!user) return new Response(null, { status: 401 })
14
+ const body = request.all()
15
+ await finishPasskeyRegistration(Number(user.id), body.ceremony, body.credential, party)
16
+ return passkeyJSON({ ok: true })
17
+ }
18
+ catch (error) { return passkeyFailure(error) }
19
+ },
20
+ })
@@ -0,0 +1,18 @@
1
+ import { Action } from '@stacksjs/actions/runtime'
2
+ import { beginPasskeyRegistration } from '@stacksjs/auth'
3
+ import { passkeyParty, passkeyFailure, passkeyJSON } from './passkey-config'
4
+
5
+ export default new Action({
6
+ name: 'PasskeyRegistrationOptions',
7
+ method: 'POST',
8
+ async handle(request) {
9
+ const party = passkeyParty()
10
+ if (!party) return new Response(null, { status: 404 })
11
+ try {
12
+ const user = await request.user()
13
+ if (!user) return new Response(null, { status: 401 })
14
+ return passkeyJSON(await beginPasskeyRegistration({ id: Number(user.id), email: user.email, name: user.name }, party))
15
+ }
16
+ catch (error) { return passkeyFailure(error) }
17
+ },
18
+ })
@@ -0,0 +1,13 @@
1
+ import { config } from '@stacksjs/config'
2
+ import { PasskeyError } from '@stacksjs/auth'
3
+ export function passkeyParty() {
4
+ const options = config.auth.passkeys
5
+ return options?.enabled ? { origin: options.origin || config.app.url || 'http://localhost:3333', name: options.name || config.app.name || 'Stacks' } : null
6
+ }
7
+ export function passkeyFailure(error: unknown): Response {
8
+ if (!(error instanceof PasskeyError)) throw error
9
+ return Response.json({ error: error.message }, { status: 400, headers: { 'Cache-Control': 'no-store' } })
10
+ }
11
+ export function passkeyJSON(value: unknown): Response {
12
+ return Response.json(value, { headers: { 'Cache-Control': 'no-store' } })
13
+ }
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.75.181",
5
+ "version": "0.75.183",
6
6
  "description": "Stacks, built into VS Code: stx templates, pickier linting and formatting, encrypted env files, the dev server preview and every buddy command. No other extensions required.",
7
7
  "license": "MIT",
8
8
  "funding": "https://github.com/sponsors/chrisbbreuer",
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/defaults",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.181",
5
+ "version": "0.75.183",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/stacksjs/stacks.git",
@@ -55,7 +55,7 @@
55
55
  "dependencies": {
56
56
  "@iconify-json/f7": "^1.2.2",
57
57
  "@iconify-json/hugeicons": "^1.2.35",
58
- "@stacksjs/mobile": "0.75.181",
58
+ "@stacksjs/mobile": "0.75.183",
59
59
  "@stacksjs/sanitizer": "^0.2.483",
60
60
  "@xterm/addon-fit": "^0.11.0",
61
61
  "@xterm/xterm": "^6.0.0",
@@ -118,10 +118,15 @@ describe('Native components', () => {
118
118
  expect(bar).toContain("useReactiveProp<boolean>('titleOnScroll', false)")
119
119
  })
120
120
 
121
- it('lifts an open sheet out of the router\'s stacking context', () => {
121
+ it('lifts a sheet into the body when it mounts, out of the router\'s stacking context', () => {
122
122
  const sheet = read('NativeSheet')
123
- expect(sheet).toContain('main:has(.native-sheet.is-open) { view-transition-name: none; }')
124
123
  expect(sheet).toContain("useModel('open'")
124
+ // Lifted before it can open, so it never draws inside <main>, which the
125
+ // router makes a stacking context; <main> no longer has to give up its
126
+ // view-transition name while a sheet is up.
127
+ const mounted = sheet.slice(sheet.indexOf('onMount(() => {'))
128
+ expect(mounted.indexOf('putBack ??= liftToBody(element)')).toBeLessThan(mounted.indexOf('if (open()) present()'))
129
+ expect(sheet).not.toContain('main:has(')
125
130
  })
126
131
 
127
132
  it('hides the native tab bar under a shell that has none, like a sign-in screen', () => {
@@ -272,12 +277,32 @@ describe('Native components feel native', () => {
272
277
  expect(html).toContain('data-stx-ref="nativeSheetHeader"')
273
278
  })
274
279
 
275
- it('opens a sheet without a haptic, holds the page behind it, and keeps focus inside', () => {
280
+ it('opens a sheet without a haptic, as a layer on the stx overlay stack', () => {
276
281
  const sheet = read('NativeSheet')
277
282
  expect(sheet).not.toContain('haptics')
278
- expect(sheet).toContain('releaseBackground = lockBackground(element)')
279
- expect(sheet).toContain('trapFocus(panel.current as HTMLElement, event)')
280
283
  expect(sheet).toContain('putBack ??= liftToBody(element)')
284
+ // The stack holds the page, traps Tab, answers Escape for the top layer
285
+ // and hands focus back, shared with Dialog, Drawer and stxConfirm. The
286
+ // sheet's own Escape listener, Tab trap, background lock and focus
287
+ // restore each fought the stack when one overlay opened over another.
288
+ expect(sheet).toContain("import { closeOverlay, openOverlay } from '@stacksjs/components/overlays'")
289
+ expect(sheet).toContain('openOverlay(overlayId, {')
290
+ expect(sheet).toContain('root: element,')
291
+ expect(sheet).toContain('zIndex: SHEET_LAYER,')
292
+ expect(sheet).toContain('onEscape: close,')
293
+ expect(sheet).toContain('initialFocus: sheetPanel,')
294
+ for (const gone of ['lockBackground', 'trapFocus', 'previousFocus', "useEventListener('keydown'", 'releaseBackground'])
295
+ expect(sheet).not.toContain(gone)
296
+ })
297
+
298
+ it('leaves the overlay stack when it closes, and when it is removed while up', () => {
299
+ const sheet = read('NativeSheet')
300
+ const dismissed = sheet.slice(sheet.indexOf('function dismissed(): void {'), sheet.indexOf('let wasOpen = false'))
301
+ expect(dismissed).toContain('closeOverlay(overlayId)')
302
+ const destroyed = sheet.slice(sheet.indexOf('onDestroy(() => {'), sheet.indexOf("useEventListener('stx:navigate'"))
303
+ expect(destroyed).toContain('closeOverlay(overlayId)')
304
+ // Each sheet gives back only its own hold on the card.
305
+ expect(destroyed).toContain('presentPageAsCard(element.ownerDocument, false)')
281
306
  })
282
307
 
283
308
  it('slides a sheet up from where it was lifted to, rather than appearing up', () => {
@@ -285,8 +310,8 @@ describe('Native components feel native', () => {
285
310
  // is-open follows `shown`, which present() sets only after the lifted
286
311
  // sheet's closed style is worked out in the body.
287
312
  expect(sheet).toContain("const sheetClass = derived(() => `${shown() ? 'is-open' : ''}")
288
- expect(sheet).toContain('sheetPanel.getBoundingClientRect()')
289
- expect(sheet.indexOf('sheetPanel.getBoundingClientRect()')).toBeLessThan(sheet.indexOf('shown.set(true)'))
313
+ expect(sheet).toContain('sheetPanel()?.getBoundingClientRect()')
314
+ expect(sheet.indexOf('sheetPanel()?.getBoundingClientRect()')).toBeLessThan(sheet.indexOf('shown.set(true)'))
290
315
  expect(sheet.indexOf('putBack ??= liftToBody(element)')).toBeLessThan(sheet.indexOf('shown.set(true)'))
291
316
  })
292
317
 
@@ -1,6 +1,7 @@
1
1
  <script client>
2
2
  import type { SheetDetent } from '@stacksjs/mobile'
3
- import { coverTabBar, liftToBody, lockBackground, observeSheetDrag, presentPageAsCard, trapFocus } from '@stacksjs/mobile'
3
+ import { closeOverlay, openOverlay } from '@stacksjs/components/overlays'
4
+ import { coverTabBar, liftToBody, observeSheetDrag, presentPageAsCard } from '@stacksjs/mobile'
4
5
 
5
6
  /**
6
7
  * A bottom sheet: a task that belongs to this screen, raised over it.
@@ -19,6 +20,15 @@ import { coverTabBar, liftToBody, lockBackground, observeSheetDrag, presentPageA
19
20
  * it is up. It belongs to the screen it was opened on, so leaving that screen
20
21
  * (a link from outside, a tab switch) closes it rather than carrying it over
21
22
  * the next one.
23
+ *
24
+ * An open sheet is a layer on the page's overlay stack (stx's
25
+ * `window.stx.overlays`), shared with Dialog, Drawer, stxConfirm, the web
26
+ * stand-ins for native alerts and the app's own overlays. The stack holds the
27
+ * page still from the first layer to the last, makes the rest of the page
28
+ * inert, gives Escape and Tab to whichever layer is on top, and hands focus
29
+ * back to whatever opened each one. A confirmation opened from a sheet, or a
30
+ * sheet opened over a drawer, closes with one Escape and leaves the layer
31
+ * under it up, locked and in charge of the keyboard.
22
32
  */
23
33
  const emit = defineEmits()
24
34
  const open = useModel('open', { default: false })
@@ -44,9 +54,15 @@ const panel = useRef('nativeSheetPanel')
44
54
  const grabber = useRef('nativeSheetGrabber')
45
55
  const header = useRef('nativeSheetHeader')
46
56
 
47
- let releaseBackground: (() => void) | null = null
57
+ // This sheet's id on the overlay stack.
58
+ const overlayId = useId('native-sheet')
59
+ // Sheets stack above the page at 70 (the stylesheet's z-index); the stack
60
+ // adds each open sheet's place to it, so a sheet opened over another draws
61
+ // above it wherever the two were lifted into the body.
62
+ const SHEET_LAYER = 70
63
+
64
+ let presented = false
48
65
  let putBack: (() => void) | null = null
49
- let previousFocus: HTMLElement | null = null
50
66
  let carded = false
51
67
  let stopDrag: (() => void) | null = null
52
68
  let openedOn = ''
@@ -59,35 +75,47 @@ function root(): HTMLElement | null {
59
75
  return sheetRoot.current as HTMLElement | null
60
76
  }
61
77
 
78
+ function sheetPanel(): HTMLElement | null {
79
+ return panel.current as HTMLElement | null
80
+ }
81
+
62
82
  function present(): void {
63
83
  const element = root()
64
- if (!element || releaseBackground) return
84
+ if (!element || presented) return
85
+ presented = true
65
86
  detent.set(detents()[0] ?? 'large')
66
- previousFocus = element.ownerDocument.activeElement as HTMLElement | null
67
87
  // Out of the page's stacking contexts and transforms (the card below
68
- // scales the page), and everything else made inert behind it.
88
+ // scales the page). A child of the body, it is what the overlay stack keeps
89
+ // usable while everything else goes inert.
69
90
  putBack ??= liftToBody(element)
70
- releaseBackground = lockBackground(element)
71
91
  openedOn = currentPath()
72
92
  coverTabBar(element, true)
73
93
  // A node moved in the document starts over with no style to transition
74
94
  // from, so a sheet lifted and marked open in one go appears already up.
75
95
  // Work its closed style out where it now is, then open it: the panel
76
96
  // slides from there.
77
- const sheetPanel = panel.current as HTMLElement | null
78
- if (sheetPanel) void sheetPanel.getBoundingClientRect()
97
+ void sheetPanel()?.getBoundingClientRect()
79
98
  shown.set(true)
80
- void nextTick(() => (panel.current as HTMLElement | null)?.focus())
99
+ // Focus goes to the panel itself, not its first control: on a phone a
100
+ // focused field raises the keyboard over the sheet before it is up.
101
+ openOverlay(overlayId, {
102
+ panel: sheetPanel,
103
+ root: element,
104
+ zIndex: SHEET_LAYER,
105
+ initialFocus: sheetPanel,
106
+ onEscape: close,
107
+ })
81
108
  }
82
109
 
83
110
  function dismissed(): void {
111
+ if (!presented) return
112
+ presented = false
84
113
  shown.set(false)
85
114
  const element = root()
86
115
  if (element) coverTabBar(element, false)
87
- releaseBackground?.()
88
- releaseBackground = null
89
- previousFocus?.focus?.()
90
- previousFocus = null
116
+ // Off the stack: the layer underneath (or the page) gets the keyboard back,
117
+ // and focus returns to whatever opened the sheet.
118
+ closeOverlay(overlayId)
91
119
  }
92
120
 
93
121
  let wasOpen = false
@@ -98,9 +126,9 @@ effect(() => {
98
126
  wasOpen = isOpen
99
127
  })
100
128
 
101
- // The page behind becomes a card while a full-height sheet is at large. Only
102
- // this sheet's own change is applied, so a closed sheet never undoes an open
103
- // one's card.
129
+ // The page behind becomes a card while a full-height sheet is at large. Each
130
+ // sheet hands back only its own hold, so a sheet closing over another never
131
+ // undoes the card the other still wants.
104
132
  effect(() => {
105
133
  const element = root()
106
134
  const want = open() && hasDetents() && detent() === 'large'
@@ -117,13 +145,15 @@ function close(): void {
117
145
  onMount(() => {
118
146
  // Give the sheet its permanent presentation parent before its slot loads
119
147
  // media. Reparenting an already loading iframe or video interrupts playback.
148
+ // A closed sheet waits in the body like this, so one nested in another's
149
+ // content can open over it without leaving the document mid-transition.
120
150
  const element = root()
121
151
  if (element) putBack ??= liftToBody(element)
122
- const sheetPanel = panel.current as HTMLElement | null
123
- if (!sheetPanel) return
152
+ const dragPanel = sheetPanel()
153
+ if (!dragPanel) return
124
154
  const handles = [grabber.current, header.current].filter(Boolean) as HTMLElement[]
125
155
  stopDrag = observeSheetDrag({
126
- panel: sheetPanel,
156
+ panel: dragPanel,
127
157
  handles,
128
158
  detents: () => detents(),
129
159
  detent: () => detent(),
@@ -137,10 +167,16 @@ onMount(() => {
137
167
 
138
168
  onDestroy(() => {
139
169
  stopDrag?.()
140
- releaseBackground?.()
141
170
  const element = root()
142
- if (element && open()) coverTabBar(element, false)
143
- if (carded && element) presentPageAsCard(element.ownerDocument, false)
171
+ if (element && presented) coverTabBar(element, false)
172
+ // Removed while up: off the stack, so the page is not left locked under a
173
+ // sheet that is gone.
174
+ presented = false
175
+ closeOverlay(overlayId)
176
+ if (carded && element) {
177
+ carded = false
178
+ presentPageAsCard(element.ownerDocument, false)
179
+ }
144
180
  putBack?.()
145
181
  })
146
182
 
@@ -153,12 +189,6 @@ useEventListener('stx:navigate', (event: Event) => {
153
189
  if (to !== openedOn) close()
154
190
  })
155
191
 
156
- useEventListener('keydown', (event: KeyboardEvent) => {
157
- if (!open()) return
158
- if (event.key === 'Escape') close()
159
- else if (panel.current) trapFocus(panel.current as HTMLElement, event)
160
- })
161
-
162
192
  const sheetClass = derived(() => `${shown() ? 'is-open' : ''}${hasDetents() ? ' has-detents' : ''}${detent() === 'medium' ? ' is-medium' : ''}`.trim())
163
193
  </script>
164
194
 
@@ -308,9 +338,4 @@ const sheetClass = derived(() => `${shown() ? 'is-open' : ''}${hasDetents() ? '
308
338
  :root[data-native-reduce-motion] .native-sheet-panel { transition: none; }
309
339
  :root[data-native-reduce-motion][data-native-sheet-card] .native-app-content { transform: none; border-radius: 0; }
310
340
 
311
- /* The router names the page's <main> for view transitions, which makes it a
312
- stacking context: a sheet inside it stacked under the tab bar whatever its
313
- z-index. While a sheet is open <main> gives the name up. Nothing navigates
314
- with a sheet open, so no transition is lost. */
315
- main:has(.native-sheet.is-open) { view-transition-name: none; }
316
341
  </style>
package/routes/auth.ts CHANGED
@@ -121,3 +121,12 @@ route.group({ prefix: '/password' }, () => {
121
121
  route.post('/reset', 'Actions/Password/PasswordResetAction').rateLimit(5, 'minute')
122
122
  route.post('/verify-token', 'Actions/Password/VerifyResetTokenAction').rateLimit(10, 'minute')
123
123
  })
124
+
125
+ // Discoverable passkeys: canonical JSON ceremonies, independent concurrent browser attempts.
126
+ // Public flows carry a one-use ceremony capability; enrollment and removal also require auth/CSRF.
127
+ route.post('/auth/passkeys/authentication/options', 'Actions/Auth/PasskeyAuthenticationOptionsAction').skipCsrf().rateLimit(10, 'minute')
128
+ route.post('/auth/passkeys/authentication', 'Actions/Auth/PasskeyAuthenticationAction').skipCsrf().rateLimit(10, 'minute')
129
+ route.post('/auth/passkeys/registration/options', 'Actions/Auth/PasskeyRegistrationOptionsAction').middleware('auth').rateLimit(5, 'minute')
130
+ route.post('/auth/passkeys/registration', 'Actions/Auth/PasskeyRegistrationAction').middleware('auth').rateLimit(5, 'minute')
131
+ route.get('/auth/passkeys', 'Actions/Auth/PasskeyIndexAction').middleware('auth')
132
+ route.delete('/auth/passkeys/{id}', 'Actions/Auth/PasskeyDestroyAction').middleware('auth').rateLimit(10, 'minute')