@stacksjs/defaults 0.75.180 → 0.75.182

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
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stacks-dashboard
3
- description: Use when building or customizing the Stacks admin dashboard, including dashboard pages, model management views, analytics widgets, commerce dashboards, content management, settings panels, deployment monitoring, job/queue management, or the 414 built-in dashboard components. Covers the dashboard system at storage/framework/defaults/.
3
+ description: Use when building or customizing the Stacks admin dashboard, including dashboard pages, model management views, analytics widgets, commerce dashboards, content management, settings panels, deployment monitoring, job/queue management, or the 413 built-in dashboard components. Covers the dashboard system at storage/framework/defaults/.
4
4
  license: MIT
5
5
  compatibility: Bun >= 1.3.0, TypeScript
6
6
  allowed-tools: Read Edit Write Bash Grep Glob
@@ -8,7 +8,7 @@ allowed-tools: Read Edit Write Bash Grep Glob
8
8
 
9
9
  # Stacks Dashboard
10
10
 
11
- The Stacks admin dashboard provides a full-featured admin panel with 100+ route views, 414 components, and a multi-section layout.
11
+ The Stacks admin dashboard provides a full-featured admin panel with 100+ route views, 413 components, and a multi-section layout.
12
12
 
13
13
  ## Key Paths
14
14
  - Dashboard components: `storage/framework/defaults/resources/components/Dashboard/`
@@ -466,10 +466,12 @@ option the original metres heuristic remains for unlabelled inputs.
466
466
  - A screen that is a task of its own (a workout player, a composer) marks an
467
467
  element `data-native-hide-tab-bar`, and the tab bar steps away while it is
468
468
  shown, its reserved space with it.
469
- - `<Video :src="current.url">` is the framework's player (ts-video-player):
470
- YouTube, Vimeo, HLS, DASH or a file. `src` is reactive, so one player in a
471
- sheet can show whichever video is chosen; clearing it pauses the player.
472
- Craft lets an https iframe load inside the app, so embeds play inline.
469
+ - `<Video :src="current.url" autoplay>` is the player from
470
+ `@stacksjs/components` (ts-video-player): YouTube, Vimeo, HLS, DASH or a
471
+ file, with controls in the page's language. `src`, `poster`, `aspectRatio`
472
+ and `label` are reactive, so one player in a sheet can show whichever video
473
+ is chosen, `autoplay` starts each one, and clearing `src` pauses it. Craft
474
+ lets an https iframe load inside the app, so embeds play inline.
473
475
 
474
476
  `<NativeTabItem match="/m/workout">` keeps a tab lit on the detail screens
475
477
  opened from it. A tab bar is a `<nav>`, where a link is otherwise current only
@@ -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.180",
5
+ "version": "0.75.182",
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.180",
5
+ "version": "0.75.182",
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.180",
58
+ "@stacksjs/mobile": "0.75.182",
59
59
  "@stacksjs/sanitizer": "^0.2.483",
60
60
  "@xterm/addon-fit": "^0.11.0",
61
61
  "@xterm/xterm": "^6.0.0",
@@ -27,4 +27,3 @@ export { default as NativeSheet } from './NativeSheet.stx'
27
27
  export { default as NativeSymbol } from './NativeSymbol.stx'
28
28
  export { default as NativeTabBar } from './NativeTabBar.stx'
29
29
  export { default as NativeTabItem } from './NativeTabItem.stx'
30
- export { default as Video } from './Video.stx'
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')
@@ -1,132 +0,0 @@
1
- <script server>
2
- interface VideoSource { src: string, type?: string }
3
- interface VideoDrm {
4
- widevine?: { url: string, headers?: Record<string, string> }
5
- playready?: { url: string, headers?: Record<string, string> }
6
- fairplay?: { url: string, certificateUrl: string, headers?: Record<string, string> }
7
- }
8
- interface VideoTrack { src: string, kind: 'subtitles' | 'captions' | 'descriptions' | 'chapters' | 'metadata', srclang?: string, label?: string, default?: boolean }
9
- interface VideoProps {
10
- src: string
11
- sources?: VideoSource[]
12
- drm?: VideoDrm
13
- tracks?: VideoTrack[]
14
- poster?: string
15
- title?: string
16
- width?: number
17
- height?: number
18
- aspectRatio?: string
19
- controls?: boolean
20
- autoplay?: boolean
21
- loop?: boolean
22
- muted?: boolean
23
- playsinline?: boolean
24
- preload?: 'none' | 'metadata' | 'auto'
25
- crossorigin?: 'anonymous' | 'use-credentials'
26
- controlslist?: string
27
- disablePictureInPicture?: boolean
28
- class?: string
29
- }
30
- const props = defineProps<VideoProps>()
31
- const src = props.src || ''
32
- const sources = props.sources || []
33
- const tracks = props.tracks || []
34
- const title = props.title || 'Video player'
35
- const poster = props.poster || ''
36
- const width = props.width || ''
37
- const height = props.height || ''
38
- const ratio = props.aspectRatio || (width && height ? `${width} / ${height}` : '16 / 9')
39
- const controls = props.controls !== false
40
- const inline = props.playsinline !== false
41
- const preload = props.preload || 'metadata'
42
- const crossorigin = props.crossorigin || ''
43
- const controlslist = props.controlslist || 'nodownload'
44
- const className = props.class || ''
45
- const playerSources = (sources.length ? sources : src ? [{ src }] : []).map(source => props.drm && (source.type === 'application/dash+xml' || source.src.split(/[?#]/, 1)[0].toLowerCase().endsWith('.mpd')) ? { ...source, type: 'application/dash+xml', drm: props.drm } : source)
46
- const playerTracks = tracks.map(track => ({ ...track, language: track.srclang }))
47
- // A YouTube or Vimeo link is a web page, not a media file: the native <video>
48
- // fallback cannot play it, so without the player it offers the link instead.
49
- const isEmbed = /^https?:\/\/(?:[\w-]+\.)*(?:youtube\.com|youtube-nocookie\.com|youtu\.be|vimeo\.com)\//i.test(src)
50
- const playerConfig = JSON.stringify({ sources: playerSources, tracks: playerTracks, ...(props.drm ? { drm: props.drm } : {}) }).replaceAll('<', '\\u003c').replaceAll('>', '\\u003e').replaceAll('&', '\\u0026')
51
- </script>
52
-
53
- <script client>
54
- import { registerElements } from 'ts-video-player/elements'
55
- registerElements()
56
-
57
- // `src` and `poster` follow the parent, so one player can show whichever
58
- // video is chosen: `<Video :src="current.url" />` in a sheet. An empty `src`
59
- // pauses it, which is what closing that sheet should do.
60
- const liveSrc = useReactiveProp<string>('src', '')
61
- const livePoster = useReactiveProp<string>('poster', '')
62
- const element = useRef('player')
63
-
64
- effect(() => {
65
- if (!liveSrc())
66
- (element.current as { player?: { pause: () => void } } | null)?.player?.pause()
67
- })
68
- </script>
69
-
70
- {{-- No src attribute until there is a video: an empty one resolves to the
71
- page's own URL, which the browser then downloads as the video. --}}
72
- <figure class="m-0 {{ className }}">
73
- <video-player
74
- ref="player"
75
- @if(src) src="{{ src }}" @endif
76
- :src="liveSrc() || null"
77
- :poster="livePoster() || null"
78
- data-media-config="{{ playerConfig }}"
79
- @if(poster) poster="{{ poster }}" @endif
80
- @if(controls) controls @endif
81
- @if(props.autoplay) autoplay @endif
82
- @if(props.loop) loop @endif
83
- @if(props.muted) muted @endif
84
- @if(inline) playsinline @endif
85
- preload="{{ preload }}"
86
- @if(crossorigin) crossorigin="{{ crossorigin }}" @endif
87
- controlslist="{{ controlslist }}"
88
- @if(props.disablePictureInPicture) disable-picture-in-picture @endif
89
- keep-alive
90
- aria-label="{{ title }}"
91
- class="block overflow-hidden relative w-full bg-black ring-1 ring-black/10 rounded-2xl dark:ring-white/10 focus-within:ring-2 focus-within:ring-sky-500 shadow-sm group"
92
- style="aspect-ratio: {{ ratio }};"
93
- >
94
- <media-error-display></media-error-display>
95
- @if(isEmbed)
96
- <a data-native-fallback href="{{ src }}" target="_blank" rel="noopener" class="flex items-center justify-center h-full w-full text-sm text-white underline">Watch {{ title }}</a>
97
- @else
98
- <video data-native-fallback @if(src) src="{{ src }}" @endif @if(poster) poster="{{ poster }}" @endif @if(width) width="{{ width }}" @endif @if(height) height="{{ height }}" @endif @if(controls) controls @endif @if(props.autoplay) autoplay @endif @if(props.loop) loop @endif @if(props.muted) muted @endif @if(inline) playsinline @endif preload="{{ preload }}" @if(crossorigin) crossorigin="{{ crossorigin }}" @endif controlslist="{{ controlslist }}" @if(props.disablePictureInPicture) disablepictureinpicture @endif aria-label="{{ title }}" class="object-contain h-full w-full">
99
- @foreach (sources as source)
100
- <source src="{{ source.src }}" @if(source.type) type="{{ source.type }}" @endif>
101
- @endforeach
102
- @foreach (tracks as track)
103
- <track src="{{ track.src }}" kind="{{ track.kind }}" @if(track.srclang) srclang="{{ track.srclang }}" @endif @if(track.label) label="{{ track.label }}" @endif @if(track.default) default @endif>
104
- @endforeach
105
- Your browser does not support video playback.
106
- </video>
107
- @endif
108
- <video-skin>
109
- <media-seek-button slot="left" seconds="-10" aria-label="Back 10 seconds"></media-seek-button>
110
- <media-play-button slot="left" aria-label="Play or pause"></media-play-button>
111
- <media-seek-button slot="left" seconds="10" aria-label="Forward 10 seconds"></media-seek-button>
112
- <media-live-button slot="left" aria-label="Go to live edge"></media-live-button>
113
- <media-time-group slot="center" class="font-mono tabular-nums text-xs"></media-time-group>
114
- <media-progress-bar slot="center" previews="metadata" aria-label="Video progress"></media-progress-bar>
115
- <media-mute-button slot="right" aria-label="Mute or unmute"></media-mute-button>
116
- <media-volume-slider slot="right" aria-label="Volume"></media-volume-slider>
117
- <media-playback-rate-button slot="right" rates="0.5,0.75,1,1.25,1.5,2" aria-label="Playback speed"></media-playback-rate-button>
118
- <media-settings-menu slot="right" aria-label="Video settings"></media-settings-menu>
119
- <media-airplay-button slot="right" aria-label="Play on AirPlay"></media-airplay-button>
120
- <media-remote-playback-button slot="right" aria-label="Play on another device"></media-remote-playback-button>
121
- <media-pip-button slot="right" aria-label="Picture in Picture"></media-pip-button>
122
- <media-fullscreen-button slot="right" aria-label="Fullscreen"></media-fullscreen-button>
123
- </video-skin>
124
- </video-player>
125
- <figcaption class="sr-only">{{ title }}</figcaption>
126
- </figure>
127
-
128
- <style>
129
- video-player:not(:defined) video-skin { display: none; }
130
- video-player:defined > [data-native-fallback] { display: none; }
131
- @media (prefers-reduced-motion: reduce) { video-player, video-player * { transition-duration: 0.01ms !important; } }
132
- </style>
@@ -1,36 +0,0 @@
1
- import { describe, expect, it } from 'bun:test'
2
- import { readFileSync } from 'node:fs'
3
- import { join } from 'node:path'
4
-
5
- const video = readFileSync(join(import.meta.dir, 'Video.stx'), 'utf8')
6
-
7
- describe('Video', () => {
8
- it('follows a reactive src and poster, so one player can show whichever video is chosen', () => {
9
- expect(video).toContain(`useReactiveProp<string>('src', '')`)
10
- expect(video).toContain(`:src="liveSrc() || null"`)
11
- expect(video).toContain(`:poster="livePoster() || null"`)
12
- })
13
-
14
- it('renders no src attribute without a video, which would fetch the page itself', () => {
15
- // An empty src resolves to the document's URL: the browser downloads the
16
- // whole page as the video and fails, every time a player with nothing
17
- // chosen yet renders.
18
- expect(video).not.toMatch(/(?<!@if\(src\) )src="\{\{ src \}\}"/)
19
- expect(video.match(/@if\(src\) src="\{\{ src \}\}" @endif/g)?.length).toBe(2)
20
- })
21
-
22
- it('pauses when its src is cleared, as closing a sheet does', () => {
23
- expect(video).toContain(`useRef('player')`)
24
- expect(video).toContain('ref="player"')
25
- expect(video).toMatch(/if \(!liveSrc\(\)\)[\s\S]*?\.pause\(\)/)
26
- })
27
-
28
- it('never hands a YouTube or Vimeo page to the native <video> fallback', () => {
29
- const isEmbed = new RegExp(video.match(/const isEmbed = \/(.+)\/i\.test/)![1]!, 'i')
30
- for (const url of ['https://www.youtube.com/watch?v=aclHkVaku9U', 'https://youtu.be/aclHkVaku9U', 'https://m.youtube.com/shorts/aclHkVaku9U', 'https://vimeo.com/76979871'])
31
- expect(isEmbed.test(url)).toBe(true)
32
- for (const url of ['https://cdn.example.com/clip.mp4', '/media/demo/manifest.mpd', 'https://example.com/youtube.com/fake.mp4'])
33
- expect(isEmbed.test(url)).toBe(false)
34
- expect(video).toContain('@if(isEmbed)')
35
- })
36
- })