@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.
- package/ai/skills/stacks-auth/SKILL.md +37 -12
- package/ai/skills/stacks-dashboard/SKILL.md +2 -2
- package/ai/skills/stacks-mobile/SKILL.md +6 -4
- package/ai/skills/stacks-native/CATALOG.md +3 -3
- package/app/Actions/Auth/PasskeyAuthenticationAction.ts +23 -0
- package/app/Actions/Auth/PasskeyAuthenticationOptionsAction.ts +17 -0
- package/app/Actions/Auth/PasskeyDestroyAction.ts +24 -0
- package/app/Actions/Auth/PasskeyIndexAction.ts +18 -0
- package/app/Actions/Auth/PasskeyRegistrationAction.ts +20 -0
- package/app/Actions/Auth/PasskeyRegistrationOptionsAction.ts +18 -0
- package/app/Actions/Auth/passkey-config.ts +13 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/resources/components/index.ts +0 -1
- package/routes/auth.ts +9 -0
- package/resources/components/Video.stx +0 -132
- package/resources/components/Video.test.ts +0 -36
|
@@ -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
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
- `
|
|
349
|
-
-
|
|
350
|
-
- `
|
|
351
|
-
|
|
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
|
|
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,
|
|
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
|
|
470
|
-
YouTube, Vimeo, HLS, DASH or a
|
|
471
|
-
|
|
472
|
-
|
|
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) |
|
|
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: [
|
|
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
|
+
}
|
package/ide/vscode/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"publisher": "Stacks",
|
|
3
3
|
"name": "vscode-stacks",
|
|
4
4
|
"displayName": "Stacks",
|
|
5
|
-
"version": "0.75.
|
|
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.
|
|
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.
|
|
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
|
-
})
|