diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1561217..31f14e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,17 +35,30 @@ jobs: - name: Install dependencies run: npm ci - - name: Install Chromium - run: npx playwright install chromium + - name: Install browser engines + run: npx playwright install --with-deps chromium firefox webkit - name: Format check run: npm run fmt --workspaces --if-present -- --check - name: Lint - run: npm run lint + run: | + npm run lint + npm run lint:qualification - name: Build run: npm run build + - name: Compiled runtime acceptance + # These process-lifecycle checks use Unix SIGTERM semantics. + if: runner.os == 'Linux' || runner.os == 'macOS' + run: npm run test:runtime + - name: Test - run: npm test && npm run typecheck && npm run test:journey && npm run openapi:check && npm run test:browser + run: npm run test:qualification && npm test && npm run typecheck && npm run test:journey && npm run openapi:check && npm run test:browser + + - name: Installed provider contracts + run: npm run test:provider-installed && npm run test:provider-types + + - name: Signed provider browser journeys + run: npm run test:provider-browser diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f5c9640 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,49 @@ +# Changelog + +## Unreleased + +### Breaking changes + +- Require the coordinated Askr 0.5 platform packages. Generate ordinary locks + against the frozen candidate registry until publication; a published 0.4 + installation cannot provide the new provider presets and session contracts. +- Provider authentication uses server-owned sessions and public provider presets. + Browser token SDKs and the retired `@askrjs/auth/browser` and + `@askrjs/auth/auth0` paths are not consumer contracts for these applications. + +### Added + +- A separately installable Auth0, Microsoft, Google and Apple application with + SSR and SPA modes, server actions, HTTPS, and signed simulated provider tests. +- Independent stage installation, lock replay and compiled API, MCP and SSG + runtime qualification against the frozen package graph. + +### Fixed + +- Retire private provider-page state and recover through login with `next` when + a live private-query request returns 401 after session expiry. Ignore late + unauthorized responses from requests belonging to a retired identity. + +- Declare SPA's own jsdom dependency and keep installed/generated files outside + each stage's lint and format scope. +- Preserve the API/SSR DOMPurify override for standalone installation. +- Mark the SSG root ready after hydration completes so runtime checks can prove + server-node reuse and exactly one interactive acknowledgment. +- Run the provider application's signed Chromium, Firefox and WebKit journeys, + installed public surface and both TypeScript compiler gates in ordinary CI. +- Consume Vite's installed virtual-server declaration without duplicate local + shims and align typed lifecycle test doubles with OpenAPI 3.1.2. +- Resolve the declared TypeScript 6 compiler in both standalone and hoisted + workspace installations. +- Send independent-stage audits to the public advisory database while retaining + the frozen candidate registry for package installation. +- Qualify all 54 signed provider browser cases through an explicit simulated + provider handoff and strict loopback CONNECT transport. Preserve Apple's + cross-site `form_post`, canonical authorization parameters, actual callback + cookies, exact private-request cancellation and bounded cleanup. +- Start Google sign-in with identity scopes, then request Calendar permission + through the first server action's explicit consent flow. +- Verify each candidate archive's served SHA-256 and SRI before independent + application installation, retaining an archive receipt and rejecting redirects. +- Map the provider example's shared environment settings to `askr add auth` + output while preserving matching credential names and callback routes. diff --git a/README.md b/README.md index 2039e44..dea7361 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Install and run all repository gates from this directory: npm install npm run check npm run build +npm run test:runtime npm run test:browser ``` @@ -29,12 +30,37 @@ npm run test:browser - [`api-only/`](api-only/) is the small server reference. Its health and user API patterns are reused directly by `api-ssr`. - [`ssg/`](ssg/) generates public runbook pages, expands parameterized entries, emits metadata, and hydrates a small interactive control. +- [`mcp-server/`](mcp-server/) runs compiled MCP servers over stdio or HTTP, with an optional stateful session and progress stream. +- [`provider-auth/`](provider-auth/) extends the authenticated journey with one server-owned Auth0, Microsoft, Google or Apple session, explicit SSR/SPA boot, server-only API permissions and local logout. -Every workspace can also be copied, installed, tested, and run from its own directory. Local `file:` dependencies point at sibling platform packages during platform development. +Every workspace can also be copied, installed, tested, and run from its own directory. Dependencies use ordinary registry ranges. API/SSR owns its DOMPurify override so copying the stage preserves the same patched dependency as the root workspace. ## Platform boundary The examples intentionally exclude developer tools, databases and ORMs, -identity-provider ownership, vendor deployment adapters, WebSockets, and +vendor deployment adapters, WebSockets, and proprietary telemetry backends. Those remain application or adapter choices, not hidden framework defaults. + +## Consumer qualification + +`npm run acceptance` runs the existing workspace contracts, production builds, compiled API and MCP transport checks, built SSG browser hydration and incremental generation, and the SPA/SSR/API-SSR browser journeys. The runtime gate starts fresh processes and records evidence in a printed temporary directory. It requires the Playwright Chromium binary (`npx playwright install chromium`). SSG marks `#app` ready only after `hydrateSPA` completes; its browser check preserves actual server-created nodes and observes one acknowledgment toggle. + +See [`scripts/qualification/README.md`](scripts/qualification/README.md) for standalone stage installs and the separate final release-candidate mode. Published 0.4 baseline results do not qualify the unpublished 0.5 graph. + +## Provider session extension + +The provider example is a separate, independently installable workspace. Its +[README](provider-auth/README.md) explains the single server authority, HTTPS +setup and provider registration. It preserves the earlier deterministic login +example as a small teaching stage rather than replacing it. + +`npm run check` and `npm run build` include the new stage. After installing all +three Playwright engines, `npm run test:provider-types` checks its own strict +TypeScript 6 and 7 compilers, and `npm run test:provider-browser` builds the +native fixture and exercises all four signed simulated providers in both SSR +and pure SPA modes on Chromium, Firefox and WebKit. `npm run acceptance` includes +these gates. The fixtures use real HTTPS, signatures, PKCE and server actions; +they do not establish live-provider compatibility. No provider credentials are +needed for the simulated suite. Producer retirement and final 0.5 package graph +qualification remain prerequisites for running this newly prepared extension. diff --git a/api-only/.gitignore b/api-only/.gitignore new file mode 100644 index 0000000..cfd2ac6 --- /dev/null +++ b/api-only/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +.vite/ +.vitest/ +coverage/ +playwright-report/ +test-results/ diff --git a/api-only/package.json b/api-only/package.json index 6be2b8f..92bba6c 100644 --- a/api-only/package.json +++ b/api-only/package.json @@ -16,13 +16,13 @@ "check": "npm run lint && npm run typecheck && npm test && npm run openapi:check" }, "dependencies": { - "@askrjs/auth": ">=0.4.0 <0.5.0", - "@askrjs/node": ">=0.4.0 <0.5.0", - "@askrjs/schema": ">=0.4.0 <0.5.0", - "@askrjs/server": ">=0.4.0 <0.5.0" + "@askrjs/auth": ">=0.5.0 <0.6.0", + "@askrjs/node": ">=0.5.0 <0.6.0", + "@askrjs/schema": ">=0.5.0 <0.6.0", + "@askrjs/server": ">=0.5.0 <0.6.0" }, "devDependencies": { - "@askrjs/cli": ">=0.4.0 <0.5.0", + "@askrjs/cli": ">=0.5.0 <0.6.0", "tsx": "^4.23.12", "typescript": "^7.0.2", "vite-plus": "^0.3.1", diff --git a/api-only/tests/api-only.test.ts b/api-only/tests/api-only.test.ts index 8fb27a3..522671a 100644 --- a/api-only/tests/api-only.test.ts +++ b/api-only/tests/api-only.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "vite-plus/test"; import { createApp } from "../src/app.js"; import { createDependencies } from "../src/boot/dependencies.js"; import api from "../src/api.js"; diff --git a/api-ssr/.gitignore b/api-ssr/.gitignore new file mode 100644 index 0000000..cfd2ac6 --- /dev/null +++ b/api-ssr/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +.vite/ +.vitest/ +coverage/ +playwright-report/ +test-results/ diff --git a/api-ssr/package.json b/api-ssr/package.json index fb71b1d..1a6f9f1 100644 --- a/api-ssr/package.json +++ b/api-ssr/package.json @@ -17,30 +17,33 @@ "check": "npm run lint && npm run typecheck && npm test && npm run openapi:check" }, "dependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0", - "@askrjs/auth": ">=0.4.0 <0.5.0", - "@askrjs/charts": ">=0.4.0 <0.5.0", - "@askrjs/i18n": ">=0.4.0 <0.5.0", - "@askrjs/lucide": ">=0.4.0 <0.5.0", - "@askrjs/monaco": ">=0.4.0 <0.5.0", - "@askrjs/node": ">=0.4.0 <0.5.0", - "@askrjs/otel": ">=0.4.0 <0.5.0", - "@askrjs/schema": ">=0.4.0 <0.5.0", - "@askrjs/server": ">=0.4.0 <0.5.0", - "@askrjs/themes": ">=0.4.0 <0.5.0", - "@askrjs/ui": ">=0.4.0 <0.5.0", + "@askrjs/askr": ">=0.5.0 <0.6.0", + "@askrjs/auth": ">=0.5.0 <0.6.0", + "@askrjs/charts": ">=0.5.0 <0.6.0", + "@askrjs/i18n": ">=0.5.0 <0.6.0", + "@askrjs/lucide": ">=0.5.0 <0.6.0", + "@askrjs/monaco": ">=0.5.0 <0.6.0", + "@askrjs/node": ">=0.5.0 <0.6.0", + "@askrjs/otel": ">=0.5.0 <0.6.0", + "@askrjs/schema": ">=0.5.0 <0.6.0", + "@askrjs/server": ">=0.5.0 <0.6.0", + "@askrjs/themes": ">=0.5.0 <0.6.0", + "@askrjs/ui": ">=0.5.0 <0.6.0", "@opentelemetry/api": "^1.9.1", "monaco-editor": "^0.56.0" }, "devDependencies": { - "@askrjs/cli": ">=0.4.0 <0.5.0", - "@askrjs/vite": ">=0.4.0 <0.5.0", + "@askrjs/cli": ">=0.5.0 <0.6.0", + "@askrjs/vite": ">=0.5.0 <0.6.0", "tsx": "^4.23.12", "typescript": "^7.0.2", "vite": "npm:@voidzero-dev/vite-plus-core@^0.3.1", "vite-plus": "^0.3.1", "vitest": "^4.1.11" }, + "overrides": { + "dompurify": "3.4.16" + }, "engines": { "node": ">=24.0.0" }, diff --git a/api-ssr/src/vite-server.d.ts b/api-ssr/src/vite-server.d.ts index 3f87ae0..b462f11 100644 --- a/api-ssr/src/vite-server.d.ts +++ b/api-ssr/src/vite-server.d.ts @@ -1,5 +1 @@ -declare module "virtual:askr-server" { - import type { ServerApp } from "@askrjs/server"; - export const app: ServerApp; - export default app; -} +/// diff --git a/api-ssr/tests/api-ssr.test.ts b/api-ssr/tests/api-ssr.test.ts index a35fd0d..7dd3010 100644 --- a/api-ssr/tests/api-ssr.test.ts +++ b/api-ssr/tests/api-ssr.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "vite-plus/test"; import { createDataRuntime, createMutation, diff --git a/api-ssr/tests/queries.test.ts b/api-ssr/tests/queries.test.ts index 17f3428..fa2b26b 100644 --- a/api-ssr/tests/queries.test.ts +++ b/api-ssr/tests/queries.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { activityQuery, dashboardQuery, diff --git a/api-ssr/tests/redirect.test.ts b/api-ssr/tests/redirect.test.ts index 2467b3a..99df8a6 100644 --- a/api-ssr/tests/redirect.test.ts +++ b/api-ssr/tests/redirect.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "vite-plus/test"; import { safeNextPath } from "../src/features/session/redirect.js"; describe("safeNextPath", () => { diff --git a/docs/0.5.0-candidate-inventory.json b/docs/0.5.0-candidate-inventory.json new file mode 100644 index 0000000..86b18b0 --- /dev/null +++ b/docs/0.5.0-candidate-inventory.json @@ -0,0 +1,114 @@ +{ + "schemaVersion": 1, + "version": "0.5.0", + "packages": { + "@askrjs/askr": { + "version": "0.5.0", + "integrity": "sha512-VaMVh23Qj/pik7j1y57t0JzKpKhYq8zO+0UmVfzleDVNme73pI/C0iNOYtJuaYYaAV5+fsSCUpvX7TPgeCuYSA==", + "tarballSha256": "cbd1fa943c75cdf5781eb4d63f2cc74bae308afc9f13c9f447f58220843d7984", + "sourceCommit": "86da1b1b6bb03afcbc43531c3df0d05fb120ee3b" + }, + "@askrjs/auth": { + "version": "0.5.0", + "integrity": "sha512-2quLsDz8mH/6eyKSdcKLJcJQFPcNU2P5H8lS56/IzMzPOt3eihO17e1W5DDnVNMYbNaGVc5FJwWj9Snz0++ygw==", + "tarballSha256": "804da85620e477eb97504e309dc7ac8525b25501fe915e5b81ad68b02bcbbb1e", + "sourceCommit": "e2c14a36628b6a27e62e6aa64d7186fd332ae0b3" + }, + "@askrjs/schema": { + "version": "0.5.0", + "integrity": "sha512-JMciK1KmJJ7Jy/GY+ph3ibFf/75qFA1HXMuC0tI/QMxnqpVTbIY694AXk9KDODrY1qm8WaF9ktpjKlY7ciENgA==", + "tarballSha256": "8f0ec7ff67a22d8c670cff3fac7d77543a9d2bd7f882ec67694b7f7a01c97202", + "sourceCommit": "1db2180372325b8187c0d36c12142425972d0f4e" + }, + "@askrjs/server": { + "version": "0.5.0", + "integrity": "sha512-piD546hjlQElDs+7fNbTCBE85qdnpZRnLmcOUAoalI1l7h6+Elpp0/+nkfVVim2BMnNs8CTqW01Cwpxv/hv9pA==", + "tarballSha256": "a35847ca7dadeae61fa77c48ab4bff8025d34b02f213617f1c8d18af879f50fd", + "sourceCommit": "583b438a86c16961c86c555836fe16bd8d736a52" + }, + "@askrjs/logos": { + "version": "0.5.0", + "integrity": "sha512-Ko3Cf12+qwfoFx1QgDoEANPtFEILyGfSsA1/mwKD8AjTXH3n58cH0bXBv4s6FvfFH46FSIc9xzOr0eTC2YuLkw==", + "tarballSha256": "8d9d770c03e9abcbe8582aadcb6763e9084ffaab9ed5c88e74811794e21161e6", + "sourceCommit": "fbd1b359d7dfb3228fb8c8ee232e1d9bae12d8af" + }, + "@askrjs/lucide": { + "version": "0.5.0", + "integrity": "sha512-naFre5an65bHCVYL9lSpdk/InU2PMbfigiY+b4oafNBQpcJWfEFKtZ0EayaVLlGoxDxlg2t4O23DParn8eKQgQ==", + "tarballSha256": "12507f68d30a8e066f242557c411ae7fc633f5b6d12b1488d0991934c2e6b8b0", + "sourceCommit": "af6e8b598c46e7c9ed46dcc0c2621e889650a576" + }, + "@askrjs/fetch": { + "version": "0.5.0", + "integrity": "sha512-/eAjeP7x3xPfKaIaa0elg5mjNxx5ZJ51YZR1W5X2BhhVfiIXSxXRMBBa+6QIY9oNbsquzzP3BbJc6TIMlDyFgA==", + "tarballSha256": "a7b659e6d69421e93c42621bf5204e6e051fc264afdeb7ee3cd09f30f08ba3d5", + "sourceCommit": "2ead2cb26c7a4e9879ef20a82aff6c9bdf6e4e23" + }, + "@askrjs/testing": { + "version": "0.5.0", + "integrity": "sha512-GR3kKzfmcExylK+jWfIc8fAHK6kqtUDfzP60uNM86HSk0Pc+FFZ+a/ZUULhmqIdv/cZdI29XUejEUQSMY8Ojqg==", + "tarballSha256": "0a6d419b604e143886f32a06ebcaf520a31a7bba96f0b7422393792c8970bf52", + "sourceCommit": "ac32742f7ae23d0f2c76d3f32056cf16e0b1d633" + }, + "@askrjs/i18n": { + "version": "0.5.0", + "integrity": "sha512-DN6zf0Ok1zBLFGgm3EY35bITsOpA35qpGWvY7HNLAqw/qLjBn2ijvKEBalhc9+Fvmw72hwjgL/d2df+hWlJYKQ==", + "tarballSha256": "69905691f80f2e5027c64d2eb43d49141bcae29405715740b830fe2c5bccf686", + "sourceCommit": "f8a0f6a9b403bddf1560a5a79a1bcabc334351b7" + }, + "@askrjs/orm": { + "version": "0.5.0", + "integrity": "sha512-4T1UfgCdXZN7EjVVUYmcu2wIyQ+rYpipDKJTsOx5yzxKdPcuDAjtAN8uWdgJpsukxHnb0C2aZl0dk+q6Fmu0/Q==", + "tarballSha256": "4790caf6e8dbcfa53e6959b4bb56a3f7810b2502a9f492eae16fcdbf458083fb", + "sourceCommit": "817e1e970a9921df53c8eb756fe1ad4e228a8f6b" + }, + "@askrjs/otel": { + "version": "0.5.0", + "integrity": "sha512-sGKn5ny+SlClKG5GnC9fsyE00hAui4SeCm30f4kOHnVHW9CXvJdfggFAK7tG+FZlORtuJnHGjjPf+/59dihMtg==", + "tarballSha256": "f65530a47d83a3462e66dbc7f28eee7b833c62891e7941ab72092630a7927d5f", + "sourceCommit": "8097652fd804e5eb522a8afc8773ec7c1ff31b96" + }, + "@askrjs/node": { + "version": "0.5.0", + "integrity": "sha512-HnsI+53V/JPWiEuPk954VTLhHHf+jK0in8Mvq2FKu+fBsaUhJENRiray8BC9LMex5Lvt+czX1XvAF/f4RdBpag==", + "tarballSha256": "d7a00f0a7037062a58126a0fb64b68fba78278b975039b3c23f915c82a0306bf", + "sourceCommit": "335a7c4d85c25a34e0d8a38198eeed01fb7bce52" + }, + "@askrjs/vite": { + "version": "0.5.0", + "integrity": "sha512-7V7rS+oF8so2O0Kjr3CQUKtYUJIogP5taogHJTu9kqFrjfuBNpI9aMZkQcS1FwsQnJ9hHOVK1OE0JyDmZ6A92g==", + "tarballSha256": "5cba162e1b4895e98d8c18ecc815dbe59280fec919b665b9faf45667c2773008", + "sourceCommit": "408403aa5feeeea416c00bdc62d58ca343d5d5a9" + }, + "@askrjs/ui": { + "version": "0.5.0", + "integrity": "sha512-Q3yvvcah8rp9iWEq7H0uQHU4to4uLx9lT5B8GpXz43qh2i9UVtxjrpJ5rdPWfNiIPCYEkqjDnhpT3JOlMnMOsg==", + "tarballSha256": "df948ddcee3d79a84fa0d6b70bbe884828ceb85849f679251ac3255330b04284", + "sourceCommit": "34ef542427c15dd388ae459181b0565cb27bd997" + }, + "@askrjs/monaco": { + "version": "0.5.0", + "integrity": "sha512-oKAtmvvS5ZdB+V4B/Qt1v0781ftS1ZLPU2ErQNZdaVFSox7lXFax1iZYnA2yvuXlATPtax43fLaLwt0WmPiBBQ==", + "tarballSha256": "0749cbe4e5f46cbc4bf2f24349c2ac405166a9b89c456132905e3a3192685a76", + "sourceCommit": "b66806c6b49cd4a19f8ea7f47931f7f213a74845" + }, + "@askrjs/charts": { + "version": "0.5.0", + "integrity": "sha512-/pwEk71WlxSGG8FZ71D71kLNDMPqA13eB5i8jxFMuzaY0o1D15nZybziJD5v1S9vlervOryH0AulCmI64Ulfwg==", + "tarballSha256": "1631cfd242f59cd831d4e086e771a394e01a7f49178072827f8a663a4ab5593a", + "sourceCommit": "7992395004b5e725a3870f4b57836a2bfb4630ef" + }, + "@askrjs/themes": { + "version": "0.5.0", + "integrity": "sha512-qqR1q8cdl0w8ayXRh45WvEmCgk3WPAcOCeNcOGPm9ymCY7iO5aNjrwxz/hJ9fdrAB1vAGb3FQF5K/uddciZphw==", + "tarballSha256": "b154553c86b1242f251c6185dea3c55b8885eee796c772387258970b2191a8a8", + "sourceCommit": "c71d6686445630cc7b539a9f9ce3140c9c12535e" + }, + "@askrjs/cli": { + "version": "0.5.0", + "integrity": "sha512-gn+I+ejpvQ3qI0QbQW4EEEzOawXEBL5+ww7vCx6PDLt0bGBfkufiZ2e5ZYj1+vr60KV0EcvHaRyDKtUB8JFNkg==", + "tarballSha256": "cd0b24b4c91b02067d15bf7e7666947254c5000109dcaf283fc39945b6a3b55d", + "sourceCommit": "9d363290883a183291e53a2cfe60339c391e658f" + } + } +} diff --git a/docs/0.5.0-consumer-review.md b/docs/0.5.0-consumer-review.md new file mode 100644 index 0000000..57ba50f --- /dev/null +++ b/docs/0.5.0-consumer-review.md @@ -0,0 +1,57 @@ +# 0.5.0 application consumer review + +These applications consume the platform API. They publish no package API of their own. + +| Manifest | Decision | Public exports | Reason | +| ------------- | ------------------------ | -------------- | --------------------------------------------------------------------------------- | +| Root | KEEP private workspace | None | Orchestrates the progressive application gates. | +| SPA | KEEP private application | None | Teaches browser routing and reactive local state. | +| SSR-only | KEEP private application | None | Carries the SPA forward into server rendering and hydration. | +| API/SSR | KEEP private application | None | Adds authenticated schemas, actions, queries and production serving. | +| API-only | KEEP private application | None | Demonstrates a compiled HTTP server with explicit repositories and preconditions. | +| SSG | KEEP private application | None | Demonstrates registered static routes, full documents and interactive hydration. | +| MCP | KEEP private application | None | Demonstrates compiled stdio and HTTP MCP transports. | +| Provider auth | KEEP private application | None | Demonstrates server-owned provider sessions in SSR and SPA modes. | + +All eight manifests are `private:true`, version `0.0.1`, and have no `exports` map. There are no published named/type exports or subpaths to remove here. Private application versions need not equal platform dependency versions. + +## Retained consumer seams + +| Stage | Deliberate public platform owner | Observable qualification | +| ------------- | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| SPA | Core boot/router/state/control, Lucide, Themes components | Navigation and reactive activity filtering. | +| SSR-only | Core boot/SSR, Server production entry, Node adapter, Vite named plugin | Pre-JavaScript HTML, same server-created node after hydration, client navigation. | +| API/SSR | Server schema/auth/action/query integration, Node, Auth, Schema, i18n, Charts, Monaco | Native form failures, authenticated actions, adopted SSR data without a duplicate fetch, locale/theme persistence, deferred errors and actual editor save. | +| API-only | Server/router/middleware, Schema, Node HTTP adapter | Actual compiled entry, probes/security headers, ETag update, malformed/missing/stale preconditions, non-mutated state after rejection, clean process shutdown. | +| SSG | Core SSG/boot, installed CLI, Themes SSR | Three served documents and assets, 404, real hydration preserving heading/list/button identities, one acknowledgment click, keyless incremental rendering where Core reports `written:false` and published HTML bytes remain identical. | +| MCP | Server MCP, Node stdio/HTTP adapters, Schema | Actual compiled stdio protocol, stateless HTTP, stateful session/SSE progress, invalid requests and expired-session rejection, clean shutdown. | +| Provider auth | Core router/session binding/data/actions, Auth presets, Server composer, Node adapter, Vite document composition, Schema | Four signed providers in SSR/SPA on Chromium/Firefox/WebKit; exact initial session reads, hydration identity, consent/expiry recovery, server-only API access, local logout/private cancellation, copied-callback rejection, native installed exports and TypeScript 6/7 contracts. | + +The reviewed source inventory found no retired named import or removed-path consumer requiring a cosmetic rewrite. Source compatibility does not prove final packed resolution or behavior. All platform dependency declarations target `>=0.5.0 <0.6.0`; ordinary locks must be generated against the frozen candidate registry before qualification. The retained public provider entrypoints are checked by `provider-auth/scripts/installed-surface.mjs`, including native module identity and rejection of the retired Auth browser/auth0 paths. + +## Confirmed independent-stage fixes + +SPA's Vitest environment requires jsdom. A standalone install/test failed because the dependency existed only at the workspace root; SPA now declares the same jsdom range itself. Each standalone stage owns its dependency/build ignore rules, preventing copied-stage lint/format commands from traversing installed dependencies or generated output. + +API/SSR uses Monaco and now owns the same DOMPurify 3.4.16 override as the root. An independent install previously lost the root override and failed its production audit. The root lock also takes compatible source-map-js and http-cache-semantics patches without changing application/platform ranges. + +API-only's explicit Node types setting was inspected and its ordinary standalone typecheck passed using the installed dependency graph. It is not reported as a reproduced missing-type defect. + +## Qualification boundary + +`npm run acceptance` exercises the installed graph. The historical `npm run test:stages -- --baseline ...` mode requires a separate source revision with published 0.4 declarations and deliberately rejects this 0.5 source. See [the runner instructions](../scripts/qualification/README.md) for exact commands and evidence. The known Vite+ alias development peer metadata diagnostic is retained separately from passing application gates; other peer problems fail the runner. + +Final candidate mode rejects 0.4 declarations and requires the frozen versions and archive SRIs of all 18 reviewed packages. Before closing the release migration, replay independent seven-stage installs/checks/builds/runtime fixtures and root browser acceptance against that exact graph, then qualify installed-CLI-created starters, Website and Destroyer separately. No publication is authorized by these checks. + +The signed provider browser matrix now passes all 54 cases locally, using an +explicit simulated-provider handoff and a strict loopback CONNECT transport. +The preset's canonical authorization URL/query and actual app 303/cookies are +checked before the handoff, and the separate reserved provider site preserves +Apple's cross-site POST cookie assertions. See the +[provider qualification boundary](../provider-auth/README.md#reproduce-acceptance-after-the-final-graph-is-available). +The complete seven-stage gates must still be replayed against the exact final +producer graph. Local signed mocks do not establish hosted acceptance or live +provider compatibility; live credentials are deferred in Auth #71. + +The exact local source results and remaining graph qualification are recorded in +[the source acceptance report](./0.5.0-source-acceptance.md). diff --git a/docs/0.5.0-source-acceptance.md b/docs/0.5.0-source-acceptance.md new file mode 100644 index 0000000..bb8b88c --- /dev/null +++ b/docs/0.5.0-source-acceptance.md @@ -0,0 +1,291 @@ +# 0.5.0 source acceptance — 2026-10-12 + +This report covers the Examples source for [#29](https://github.com/askrjs/askr-examples/issues/29), +[#30](https://github.com/askrjs/askr-examples/issues/30), +[#32](https://github.com/askrjs/askr-examples/issues/32), and the signed simulated-provider +matrix in [Auth #64](https://github.com/askrjs/askr-auth/issues/64). +Live qualification remains deferred in [Auth #71](https://github.com/askrjs/askr-auth/issues/71). + +## Executed source gates + +The fresh worktree started at develop `2b6ec256e1780a72ab5ae3c646b99ac8d03aff55`. +The executing toolchain was Node **24.21.0**, npm **12.0.1**, on macOS Apple Silicon. +A normal root `npm ci`, using a new cache, installed the prior packed 0.5 candidates. +No sibling source aliases, linked platform dependencies, force or legacy-peer flags +were supplied. + +| Gate | Result | +| ------------------------------------------------------- | -------------------------------------------------------- | +| Workspace format check | All seven stages pass | +| Lint, strict typecheck, OpenAPI checks | Pass | +| Workspace unit tests | 161 pass, including 111 provider application tests | +| Root journey contract tests | 10 pass | +| All seven application builds | Pass | +| Installed provider exports and native module identities | Pass; retired browser paths rejected; vendor SDKs absent | +| Provider TypeScript 6 and TypeScript 7 contracts | Pass | +| Compiled API-only, MCP and served SSG runtime fixtures | Pass | +| Existing root Chromium browser journeys | 4 pass | +| Signed provider Chromium, Firefox and WebKit journeys | 54 pass; no skips, expected failures or retries | + +The final `npm run acceptance` receipt is +`/tmp/examples-complete-google-acceptance.log`, SHA-256 +`bd1bd5e81a5265940faf06174821957bc9f47bd22031bfddcac4d3fd9a8c171b`. +The workspace format receipt is `/tmp/examples-complete-format-check.log`, SHA-256 +`6c1a7dd1d1ea7cb69ce49a3a37a73e4cf97cfd6bd7d40479d998fb0d3c5d7cc8`. +These are retained local receipts; the commands and behavioral assertions live in +the repository and can be replayed. + +## Signed browser matrix + +Each provider/mode has two scenarios: login/boot/private cancellation/logout/reload, +and copied callback/safe return/expiry/consent recovery. Each engine additionally +runs two malformed boot-marker controls. + +| Provider | Mode | Chromium | Firefox | WebKit | +| ------------------- | --------- | --------- | --------- | --------- | +| Auth0 | SSR | 2/2 | 2/2 | 2/2 | +| Auth0 | SPA | 2/2 | 2/2 | 2/2 | +| Microsoft | SSR | 2/2 | 2/2 | 2/2 | +| Microsoft | SPA | 2/2 | 2/2 | 2/2 | +| Google | SSR | 2/2 | 2/2 | 2/2 | +| Google | SPA | 2/2 | 2/2 | 2/2 | +| Apple | SSR | 2/2 | 2/2 | 2/2 | +| Apple | SPA | 2/2 | 2/2 | 2/2 | +| Invalid boot marker | SSR + SPA | 2/2 | 2/2 | 2/2 | +| Total | | **18/18** | **18/18** | **18/18** | + +The simulator preserves canonical preset URL/query validation, signed token exchange, +state, nonce, PKCE and Apple's `response_mode=form_post`. An explicit fixture handoff +enters a separate reserved provider site through a strict loopback CONNECT proxy. +This prevents macOS AppSSO from taking ownership of the simulated Apple flow and +avoids WebKit's interrupted cross-site redirect metadata. Apple still submits a +native cross-site POST: the actual HTTPS owner must receive the sealed transaction +cookie and must not receive the Lax application session cookie. Browser URL, method +and body must match that server receipt. Unknown traffic, unexpected request failures, +secret leakage, incomplete cancellation and unbounded cleanup remain failures. +See [the fixture boundary](../provider-auth/README.md#reproduce-acceptance-after-the-final-graph-is-available). + +## Reproduced defects and corrections + +- The original redirect-interception probe failed all six browser/redirect cases: + Chromium/Firefox followed the redirect beyond interception, and WebKit rejected + fulfilled redirects. The native CONNECT experiment exposed macOS Apple AppSSO + and WebKit native-navigation reporting. Replacing transport with an owned + simulation handoff retained strict assertions and produced the complete green matrix. +- A first provider alias used the application's site and failed all six Apple + callback-cookie assertions: the Lax session cookie was sent. The final alias is + on a separate reserved site; those assertions remain intact. +- Google's initial login incorrectly requested Calendar permission. The focused + browser test failed because identity-only login contained the Calendar scope. + Login now requests only identity scopes for Google and Auth0. Their first server + operation requests separate API consent, consent does not resubmit the operation, + and the next deliberate click produces exactly one server API call. The focused + Google test, 14 signed HTTP tests and all 54 browser cases pass. +- Auth0/Microsoft provider logout had no application-level evidence. Two signed + HTTP cases now prove exact canonical end-session destinations and public query + fields, session-cookie clearing, retired old-cookie authority and absence of + tokens in redirect URLs. The simulator never follows those destinations. The + README documents explicit provider logout/revocation requests separately from + the application's local logout button. + +The focused Google RED receipt is `/tmp/examples-complete-google-consent-red.log` +(SHA-256 `d91053a8fd738111845d33796d9db03cb23b5ec9188a63d025522f4cea3a3562`); +GREEN is `/tmp/examples-complete-google-consent-green.log` +(`b5967909dfd0b0b51a32afcc50241b8d2fcdece3a3f376e9c391962d2e9e062f`). + +## Initial graph limitations + +The preceding pass used the prior 18-package inventory +`final18-g16-manifest-v1/actual/0.5.0-candidate-graph.json`, SHA-256 +`0d7556eadee7caec4630a4ebd1ea8f99a434e7a413412dcec2b933cee94977ca`. +That graph is stale for subsequent Auth, Schema, ORM and OTel producer changes. +That pass was source integration evidence, not final package-graph acceptance or a +portable final-lock claim. A stale standalone replay was stopped without claiming +completion. The follow-up below later qualified a frozen graph from the active +producer candidate heads. After those producer PRs integrate into `develop`, the +graph and downstream locks must be regenerated and requalified against the integrated +commits. + +After the source PRs merge, the release coordinator must regenerate one immutable +18-package inventory with each exact source commit, tarball SHA-256 and SRI; generate +ordinary locks against its loopback registry; then replay root acceptance and all +seven independent install/fresh-cache-ci stages through +`scripts/qualification/independent-stages.mjs`. Its documented inventory interface +and installed-package version/SRI, physical-directory and lock-replay assertions +are preserved; the follow-up below adds explicit archive SHA-256 preflight. +Installed CLI starters, Website and Destroyer are separate consumers of that same +graph. Hosted CI uses ordinary public installs and may fail while 0.5 archives are +unpublished; this change adds no hosted sibling bootstrap. No tags or publication +are part of these source results. + +## Independent follow-up audit — 2026-10-13 UTC + +The follow-up started from PR #34 head +`3dc67cb25046f46916dddbb3a3b28334046226ed` in a new isolated worktree. The +existing PR worktree, source checkout and older candidate generations were +preserved. The issue bodies, PR implementation/assertions and producer-consumer +boundaries were reviewed before adding coverage. + +The independent-stage runner required a frozen `tarballSha256` but only checked +its shape. A regression executed the real runner with a valid archive SRI and a +wrong SHA-256: it reached npm installation. Candidate mode now hashes the actual +served bytes of all 18 archives against both inventory hashes before any install, +rejects redirects/non-200 responses and records the measured archive receipt. +The coordinator's producer commit binding remains a separate inventory assertion. +Seven native HTTP/runner tests cover this boundary in `npm run check` and CI. + +`npm run acceptance` passed again using Node **24.21.0**, npm **12.0.1**, macOS +Apple Silicon and a dedicated npm cache. All 14 platform archives required by the +existing root lock were independently checked against that lock's SRI before cache +loading. Normal canonical-public-URL `npm ci` replayed the unchanged lock. This +still uses historical G16 producer bytes and establishes source integration only. + +| Follow-up gate | Result | +| --------------------------------------------------------- | ------------------------------------------- | +| Archive verifier regressions | 7 pass | +| Lint, strict types, OpenAPI and installed provider checks | Pass | +| Workspace unit tests / root journey contracts | 161 / 10 pass | +| Application builds and compiled API/MCP/SSG fixtures | All seven builds / all three fixtures pass | +| Root browser / signed provider browser journeys | 4 / 54 pass, three provider browser engines | +| Provider strict TypeScript 6 and TypeScript 7 contracts | Pass | + +Receipts are retained under `/tmp/askr-examples34-audit-20261013/`: + +- `archive-preflight-red.log`, SHA-256 + `6e355c66ed110b12f7f92efa92416810e9be6a92d366b87f43ffb8fe4271e337`. +- `archive-preflight-green.log`, SHA-256 + `c371610d2d2687f446bbcfed1ec89e98c2b31f37c92f7cdcbfd4d75f24c02d49`. +- `acceptance.log`, SHA-256 + `82b4fc4f9a0f727bd364cadceb33a2933b155a8f5e7427ecd328d2d0f0a9b8ba`. +- `historical-installed-archives.json`, SHA-256 + `b9106db85ae2ab0e040040c6dd6c421128a0c4d8649a37ebaf76cc707c34156b`. + +CLI develop `636a492e8eba712e701c09739b93d99df4896e4b` was inspected directly. +Its provider credential names and callback/logout destinations agree with this +example. The provider README now maps generated `APP_ORIGIN` and +`AUTH_SESSION_SECRET` to this explicit application's settings. + +### Issue acceptance boundaries at the initial follow-up + +| Issue | Source evidence completed | Evidence still required | +| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| #29 | All eight private manifests inventoried; retained import owners and retired paths documented; installed provider surface and both strict compiler contracts pass | Refresh the complete graph, root lock and all seven independent stage locks; prove intended packed entries against those exact archives and coordinate CLI starters/docs consumers | +| #30 | Actual API/MCP/SSG wire and hydration assertions inspected and replayed; private/cancellation/provider assertions replayed; confirmed archive-hash gap reproduced and fixed | Clean independent install and fresh-cache lock replay for every stage against the same current 18-package inventory, plus installed-CLI-created starters and the other release consumers | +| #32 | Runnable four-provider SSR/SPA source, environment/HTTPS instructions, signed exchange/error/expiry/consent/logout coverage and 54 three-engine browser cases pass locally; ordinary CI includes them | Repeat against the refreshed graph, then obtain exact-head hosted CI after public dependencies are available; use this application source for the separately deferred live qualification in Auth #71 | + +No issue was closed by that initial follow-up. The current qualification below +supersedes its consumer-graph status while preserving the separate post-integration, +publication and live-provider boundaries. + +## Frozen candidate graph qualification — 2026-10-13 UTC + +The coordinator froze one 18-package candidate graph from the current producer PR +heads. `docs/0.5.0-candidate-inventory.json` records each package's exact source +commit, version, tarball SHA-256 and SRI. The inventory SHA-256 is +`589d5b2be0e096b438ad7a28c8ff845033b9883594085db0a50958ec96b6b3fb`; the separate +source-binding report SHA-256 is +`9b0f82e9d3df0123c35489edad8a1ce1403d5d7979651b151123f7905e21e518`. All 18 archive +hashes were checked against their bytes, all packed manifests matched their recorded +source commits, and the candidate catalog SHA-256 was +`96aef8aa55b1e3b575a73fe8b6d31d141b63c8b6c01455ab16ba0ac52c16691c`. + +These are source-branch candidates, not merged `develop` commits. The public registry +does not yet contain these versions. A loopback read-only registry supplied their +bytes for local installation; consumer lockfiles retain canonical npm URLs and exact +integrity values. No force, legacy peer or hosted sibling-install workaround was used. + +### Examples graph and independent stages + +Using Node **24.21.0** and npm **12.0.1**, the fresh exact-graph Examples lock +(`package-lock.json` SHA-256 +`4aa21aa7cc5d1fcbf62561da5ceea69d1397f852d3ef039fe0280ddc00ecd33a`) passed normal +clean `npm ci` and `npm run acceptance`. The root gate passed 161 workspace unit +tests, 10 journey tests, four existing Chromium journeys, all 54 signed provider +cases across Chromium/Firefox/WebKit, lint, strict types, OpenAPI, seven application +builds, API-only/MCP/SSG runtime fixtures and installed provider checks. + +The independent-stage runner passed all seven clean installs and fresh-cache lock +replays against the same 18 archives. It verified exact package versions/SRIs, +physical installed package directories, canonical URLs, full and production audits, +stage check/build gates, runtime fixtures, and provider TypeScript 6/7 plus all 54 +browser cases. Runner receipt SHA-256 is +`c99b9f95bfbbf96c6cfa4bafeba35b1c6fb3e19439b4f97c26f847de74ddc020`; archive +preflight receipt SHA-256 is +`88ad1033fcfe836849c5f9d2f258f55d595ec256fd1f1946a457d9779e3ddaf0`. + +The candidate source acceptance log is retained at +`/tmp/askr-examples-final18-requal-20261013/acceptance-node24.log` (SHA-256 +`2af005f22b0b5a548754e440ae48c67eb1c13b6e589a1529d47b4413abf46f8d`). The runner's +full local evidence remains at `/tmp/askr-examples-final18-stages-20261013-1/`. + +### Other consumers on the same graph + +The installed CLI-created starters and two official downstream applications were +qualified against this candidate graph: + +| Consumer | Develop PR | Local result | +| --- | --- | --- | +| CLI | [#188](https://github.com/askrjs/askr-cli/pull/188) | All local gates passed, including 599 tests, five packed templates, installed types, generator cancellation/access checks, 22 benchmark budgets and zero audit findings. The 0.5.0 CLI archive was byte-identical across two normal packs and a clean committed-tree repack. | +| Website | [#71](https://github.com/askrjs/website/pull/71) | Fresh exact-graph install and `npm run check` passed: 40 unit tests, 41 Chromium browser tests, 478 SSG routes, build and generated-output checks. Full and production audits reported zero vulnerabilities. Lock SHA-256: `3b70fff9b38367ddd5fd4cf4c7858447dbed8e7cd693f83e777fb4b19d7c64ab`. | +| Destroyer | [#25](https://github.com/askrjs/destroyer/pull/25) | Fresh exact-graph install and `npm run acceptance` passed: lint, typecheck, 53 unit tests, build, four production tests, 72 browser tests and three development finding tests. One out-of-scope CSP nonce case remained skipped. Full and production audits reported zero vulnerabilities. Lock SHA-256: `26135ba0165027dced1fc4053ac2dddb8b794e985a0276a79dcfb1a3d73214f3`. | + +Website and Destroyer workflows previously listened only for PRs targeting `main`, +so their develop rulesets had no status runs. Both workflows now include `develop` +in their pull-request triggers. The resulting Website CI run +[38337855829](https://github.com/askrjs/website/actions/runs/38337855829) and +Destroyer run [38337854744](https://github.com/askrjs/destroyer/actions/runs/38337854744) +failed at clean install with E404 for the unpublished `@askrjs/vite@0.5.0`; later +steps were skipped. This is the expected prepublication limitation. Active develop +rulesets require these statuses, so neither PR is mergeable until the package exists +in the public registry and the required checks pass. No ruleset was bypassed. The +At Examples PR head `556bcc806a582d7ab792b02c707111f72881aa9b`, the exact-head +[three-platform CI run 38338506239](https://github.com/askrjs/askr-examples/actions/runs/38338506239) +failed at `npm ci` with the same E404 on Linux, macOS and Windows. Its exact-head +CodeQL checks [passed](https://github.com/askrjs/askr-examples/actions/runs/38338503560). +That report update changed documentation only; the source candidate and lock +qualification were unchanged at that head. +Rerun these consumers after producer PRs integrate, then use public registry clean +installs when the versions are available. + +### Current issue closure evidence + +| Issue | Current evidence and disposition | +| --- | --- | +| #29 | All eight private app manifests and retained consumer seams are inventoried in `0.5.0-consumer-review.md`; no Examples package exports or stale removed imports remain. Exact packed candidates, installed CLI starters, Website and Destroyer passed their consumer gates. Candidate acceptance is complete; close after this PR integrates and final post-integration qualification passes. | +| #30 | The seven independent stages, archive SHA/SRI preflight, root runtime/browser acceptance and the installed CLI/Website/Destroyer consumers passed on this graph. The failed-SHA regression was reproduced and the runner now verifies archive bytes before installation. Candidate acceptance is complete; close after integration and the final graph replay. | +| #32 | Four provider presets passed signed SSR/SPA coverage in all three browser engines (54/54), including protocol, cookie, consent, expiry, API access, logout and cancellation assertions. Publicly credentialed live checks remain deferred by Auth #71; every provider stays documented as pending live qualification. Close after this PR integrates; live checks remain outside 0.5.0. | + +The exact source integration, issue criteria and local gates for #29, #30 and #32 +are now recorded. Requalification against integrated `develop` commits and public +registry installation remain part of the overall release gate in Core #744. Live +provider evidence remains outside the 0.5.0 boundary under Auth #71. No main merge, +tag or publication is performed by these consumer PRs. + +## Live-page session expiry regression — 2026-10-13 + +A signed Google SPA browser reproduction advanced the server clock past session +expiry and refreshed private data without reloading the document. The actual +`/api/private` response was 401, but the private page and its cached data remained +visible. The earlier expiry assertion only exercised document reload. + +Each example registry now owns its private query and connects an active request's +401 to that registry's session recovery. Recovery retires private DOM and data and +redirects to `/login?next=/account`. Cancelled requests cannot recover a retired +identity; 403 and 500 remain ordinary query failures. Five focused query cases +exercise those boundaries, and the existing browser expiry scenarios now refresh +private data before reload for every provider, mode and engine. Retirement can +abort the response body of the exact request that returned 401; the browser +recorder recognizes that request while retaining other unexpected-failure checks. + +On the existing installed 0.5.0 candidate graph, `npm run check` passed 166 +workspace tests, 10 journey contracts, seven archive checks, strict types, lint, +OpenAPI and installed-provider checks. All seven application builds passed. +Provider TypeScript 6 and 7 checks and all 54 signed Chromium/Firefox/WebKit +journeys passed. Checks and browser runs used Node 26.0.0/npm 11.12.1; the full +application build also passed on Node 24.21.0/npm 12.0.1. + +These runs reused installed candidates and do not establish a new clean-install +or independent-stage receipt. The separate Core session-transport fix merged in +[Core #783](https://github.com/askrjs/askr/pull/783); the frozen inventory above +predates that integration. Final integrated-graph and public-registry acceptance +remain pending. diff --git a/mcp-server/.gitignore b/mcp-server/.gitignore new file mode 100644 index 0000000..cfd2ac6 --- /dev/null +++ b/mcp-server/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +.vite/ +.vitest/ +coverage/ +playwright-report/ +test-results/ diff --git a/mcp-server/package.json b/mcp-server/package.json index e02485e..7561578 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -13,9 +13,9 @@ "check": "npm run lint && npm run typecheck && npm test" }, "dependencies": { - "@askrjs/node": ">=0.4.0 <0.5.0", - "@askrjs/schema": ">=0.4.0 <0.5.0", - "@askrjs/server": ">=0.4.0 <0.5.0" + "@askrjs/node": ">=0.5.0 <0.6.0", + "@askrjs/schema": ">=0.5.0 <0.6.0", + "@askrjs/server": ">=0.5.0 <0.6.0" }, "devDependencies": { "@types/node": "^26.3.0", diff --git a/mcp-server/tests/conformance.test.ts b/mcp-server/tests/conformance.test.ts index 9e82acb..6c0c27c 100644 --- a/mcp-server/tests/conformance.test.ts +++ b/mcp-server/tests/conformance.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "vite-plus/test"; import { conformanceMcp } from "../src/conformance.js"; const messages: unknown[] = []; diff --git a/mcp-server/tests/server.test.ts b/mcp-server/tests/server.test.ts index a947eac..c55a7ff 100644 --- a/mcp-server/tests/server.test.ts +++ b/mcp-server/tests/server.test.ts @@ -1,4 +1,4 @@ -import { expect, it } from "vitest"; +import { expect, it } from "vite-plus/test"; import { mcp } from "../src/server.js"; it("should expose the same definition to either transport", async () => { diff --git a/package-lock.json b/package-lock.json index c401df5..40af4f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,11 +14,12 @@ "api-ssr", "api-only", "ssg", - "mcp-server" + "mcp-server", + "provider-auth" ], "devDependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0", - "@askrjs/schema": ">=0.4.0 <0.5.0", + "@askrjs/askr": ">=0.5.0 <0.6.0", + "@askrjs/schema": ">=0.5.0 <0.6.0", "@playwright/test": "^1.62.1", "@types/node": "^26.3.0", "jsdom": "^30.0.1", @@ -37,13 +38,13 @@ "name": "@askrjs/example-api-only", "version": "0.0.1", "dependencies": { - "@askrjs/auth": ">=0.4.0 <0.5.0", - "@askrjs/node": ">=0.4.0 <0.5.0", - "@askrjs/schema": ">=0.4.0 <0.5.0", - "@askrjs/server": ">=0.4.0 <0.5.0" + "@askrjs/auth": ">=0.5.0 <0.6.0", + "@askrjs/node": ">=0.5.0 <0.6.0", + "@askrjs/schema": ">=0.5.0 <0.6.0", + "@askrjs/server": ">=0.5.0 <0.6.0" }, "devDependencies": { - "@askrjs/cli": ">=0.4.0 <0.5.0", + "@askrjs/cli": ">=0.5.0 <0.6.0", "tsx": "^4.23.12", "typescript": "^7.0.2", "vite-plus": "^0.3.1", @@ -57,24 +58,24 @@ "name": "@askrjs/example-api-ssr", "version": "0.0.1", "dependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0", - "@askrjs/auth": ">=0.4.0 <0.5.0", - "@askrjs/charts": ">=0.4.0 <0.5.0", - "@askrjs/i18n": ">=0.4.0 <0.5.0", - "@askrjs/lucide": ">=0.4.0 <0.5.0", - "@askrjs/monaco": ">=0.4.0 <0.5.0", - "@askrjs/node": ">=0.4.0 <0.5.0", - "@askrjs/otel": ">=0.4.0 <0.5.0", - "@askrjs/schema": ">=0.4.0 <0.5.0", - "@askrjs/server": ">=0.4.0 <0.5.0", - "@askrjs/themes": ">=0.4.0 <0.5.0", - "@askrjs/ui": ">=0.4.0 <0.5.0", + "@askrjs/askr": ">=0.5.0 <0.6.0", + "@askrjs/auth": ">=0.5.0 <0.6.0", + "@askrjs/charts": ">=0.5.0 <0.6.0", + "@askrjs/i18n": ">=0.5.0 <0.6.0", + "@askrjs/lucide": ">=0.5.0 <0.6.0", + "@askrjs/monaco": ">=0.5.0 <0.6.0", + "@askrjs/node": ">=0.5.0 <0.6.0", + "@askrjs/otel": ">=0.5.0 <0.6.0", + "@askrjs/schema": ">=0.5.0 <0.6.0", + "@askrjs/server": ">=0.5.0 <0.6.0", + "@askrjs/themes": ">=0.5.0 <0.6.0", + "@askrjs/ui": ">=0.5.0 <0.6.0", "@opentelemetry/api": "^1.9.1", "monaco-editor": "^0.56.0" }, "devDependencies": { - "@askrjs/cli": ">=0.4.0 <0.5.0", - "@askrjs/vite": ">=0.4.0 <0.5.0", + "@askrjs/cli": ">=0.5.0 <0.6.0", + "@askrjs/vite": ">=0.5.0 <0.6.0", "tsx": "^4.23.12", "typescript": "^7.0.2", "vite": "npm:@voidzero-dev/vite-plus-core@^0.3.1", @@ -85,27 +86,6 @@ "node": ">=24.0.0" } }, - "api-ssr/node_modules/@askrjs/themes": { - "version": "0.4.0", - "license": "Apache-2.0", - "engines": { - "node": ">=24.0.0" - }, - "peerDependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0", - "@askrjs/ui": ">=0.4.0 <0.5.0" - } - }, - "api-ssr/node_modules/@askrjs/ui": { - "version": "0.4.0", - "license": "Apache-2.0", - "engines": { - "node": ">=24.0.0" - }, - "peerDependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0" - } - }, "api-ssr/node_modules/vite": { "name": "@voidzero-dev/vite-plus-core", "version": "0.3.3", @@ -212,9 +192,9 @@ "name": "@askrjs/example-mcp-server", "version": "0.0.1", "dependencies": { - "@askrjs/node": ">=0.4.0 <0.5.0", - "@askrjs/schema": ">=0.4.0 <0.5.0", - "@askrjs/server": ">=0.4.0 <0.5.0" + "@askrjs/node": ">=0.5.0 <0.6.0", + "@askrjs/schema": ">=0.5.0 <0.6.0", + "@askrjs/server": ">=0.5.0 <0.6.0" }, "devDependencies": { "@types/node": "^26.3.0", @@ -229,15 +209,15 @@ } }, "node_modules/@asamuzakjp/css-color": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.2.tgz", - "integrity": "sha512-99DHAnXDB5z6EEK+9GMpVI7Mw4oxj97dY5bpOzMnjADQWxI8rN6TvTduuFLUhUMlS7/CfVZ06tcZsus6cltnNw==", + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.5.tgz", + "integrity": "sha512-jCpAvj8apa7SKHZYogk0rbXB3Kb0hLFrpkWF+Ap+vBA5rirItnpv9NKyAyEuDuFizvl0qwnj6JWR70RGJ0rOlw==", "dev": true, "license": "MIT", "dependencies": { - "@csstools/css-calc": "^3.4.1", - "@csstools/css-color-parser": "^4.2.4", - "@csstools/css-parser-algorithms": "^4.0.1", + "@csstools/css-calc": "^3.4.3", + "@csstools/css-color-parser": "^4.2.6", + "@csstools/css-parser-algorithms": "^4.0.2", "@csstools/css-tokenizer": "^4.0.2", "lru-cache": "^11.5.3" }, @@ -246,9 +226,9 @@ } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.2.tgz", - "integrity": "sha512-lSWTBMjAcmu2xn5yEDU7jh6QDV+C8GEKtdJ4pIQhXh26RkKQ7S3FuQlt+zZkUbTdJ4d3XyV1kPI/G0zWXxqaqw==", + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.4.tgz", + "integrity": "sha512-YnVzxDxLaqL0t7Q4wfcgHZjg55Wm6y5EaO7Bhu3q9J7wq/8iw3PhsnWiOQhYekx4VexnbkgWpTT6n+QIV7kczw==", "dev": true, "license": "MIT", "dependencies": { @@ -262,16 +242,16 @@ } }, "node_modules/@askrjs/askr": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@askrjs/askr/-/askr-0.4.0.tgz", - "integrity": "sha512-n149uqj9beMcK/3129jhUD/casQzxuXCKY18ipezyThoZ0dc/NYlukZP7hTF/lmDUTbP+aTCWlkDWAL00VbmIA==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@askrjs/askr/-/askr-0.5.0.tgz", + "integrity": "sha512-VaMVh23Qj/pik7j1y57t0JzKpKhYq8zO+0UmVfzleDVNme73pI/C0iNOYtJuaYYaAV5+fsSCUpvX7TPgeCuYSA==", "license": "Apache-2.0", "engines": { "node": ">=24.0.0" }, "peerDependencies": { - "@askrjs/auth": ">=0.4.0 <0.5.0", - "@askrjs/schema": ">=0.4.0 <0.5.0" + "@askrjs/auth": ">=0.5.0 <0.6.0", + "@askrjs/schema": ">=0.5.0 <0.6.0" }, "peerDependenciesMeta": { "@askrjs/auth": { @@ -283,9 +263,9 @@ } }, "node_modules/@askrjs/auth": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@askrjs/auth/-/auth-0.4.0.tgz", - "integrity": "sha512-OStU/Hdt1BShWZDOjLA37VIp09+ybHWa1IY+ZXjP4f+U7vchbhuUik1kTrMyfBHTqMcBKkkUnnikyyIeHh6j8w==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@askrjs/auth/-/auth-0.5.0.tgz", + "integrity": "sha512-2quLsDz8mH/6eyKSdcKLJcJQFPcNU2P5H8lS56/IzMzPOt3eihO17e1W5DDnVNMYbNaGVc5FJwWj9Snz0++ygw==", "license": "Apache-2.0", "dependencies": { "@xmldom/xmldom": "0.9.12", @@ -297,21 +277,21 @@ } }, "node_modules/@askrjs/charts": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@askrjs/charts/-/charts-0.4.0.tgz", - "integrity": "sha512-Dxqxzm3kynFVDB85VINWqMdY+BzPaL2tMi1lxOypXpYPjSpGotRMLEohr25BxPeOvpVw5eXYDby+OzsftiXQKA==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@askrjs/charts/-/charts-0.5.0.tgz", + "integrity": "sha512-/pwEk71WlxSGG8FZ71D71kLNDMPqA13eB5i8jxFMuzaY0o1D15nZybziJD5v1S9vlervOryH0AulCmI64Ulfwg==", "license": "Apache-2.0", "engines": { "node": ">=24.0.0" }, "peerDependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0" + "@askrjs/askr": ">=0.5.0 <0.6.0" } }, "node_modules/@askrjs/cli": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@askrjs/cli/-/cli-0.4.0.tgz", - "integrity": "sha512-KquJOAxR4eOyK6yjCBy6L2q50r6EmWO8T/EL79iFErdTfGpwOnm0FefZO4VVaS2whRrleIAABaWybXU1f3N9dA==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@askrjs/cli/-/cli-0.5.0.tgz", + "integrity": "sha512-gn+I+ejpvQ3qI0QbQW4EEEzOawXEBL5+ww7vCx6PDLt0bGBfkufiZ2e5ZYj1+vr60KV0EcvHaRyDKtUB8JFNkg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -332,7 +312,7 @@ "node": ">=24.0.0" }, "peerDependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0" + "@askrjs/askr": ">=0.5.0 <0.6.0" }, "peerDependenciesMeta": { "@askrjs/askr": { @@ -366,6 +346,10 @@ "resolved": "mcp-server", "link": true }, + "node_modules/@askrjs/example-provider-auth": { + "resolved": "provider-auth", + "link": true + }, "node_modules/@askrjs/example-spa": { "resolved": "spa", "link": true @@ -379,50 +363,50 @@ "link": true }, "node_modules/@askrjs/i18n": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@askrjs/i18n/-/i18n-0.4.0.tgz", - "integrity": "sha512-RQm9CVV2aTmdP4QnsKMpF6K4BWlrqOEijXLEaHBfOA0MRD+rxrBRL0xSbifV+AQzI7Dfmdlb+ZJRRFeEhTaFuA==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@askrjs/i18n/-/i18n-0.5.0.tgz", + "integrity": "sha512-DN6zf0Ok1zBLFGgm3EY35bITsOpA35qpGWvY7HNLAqw/qLjBn2ijvKEBalhc9+Fvmw72hwjgL/d2df+hWlJYKQ==", "license": "Apache-2.0", "engines": { "node": ">=24.0.0" }, "peerDependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0" + "@askrjs/askr": ">=0.5.0 <0.6.0" } }, "node_modules/@askrjs/lucide": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@askrjs/lucide/-/lucide-0.4.0.tgz", - "integrity": "sha512-pSBe6I3FY8nfIH0aAVL+qu8rrDGY8QR3zJyuVA9JqWflPg9xcqhJg/CsZOES2YhKhFKWYJFIDvIsDP5O0NG74w==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@askrjs/lucide/-/lucide-0.5.0.tgz", + "integrity": "sha512-naFre5an65bHCVYL9lSpdk/InU2PMbfigiY+b4oafNBQpcJWfEFKtZ0EayaVLlGoxDxlg2t4O23DParn8eKQgQ==", "license": "Apache-2.0", "engines": { "node": ">=24.0.0" }, "peerDependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0" + "@askrjs/askr": ">=0.5.0 <0.6.0" } }, "node_modules/@askrjs/monaco": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@askrjs/monaco/-/monaco-0.4.0.tgz", - "integrity": "sha512-Br6yECjn5qh7O9Bri3DkN797/leTlHF2i3yrCsNJ/fSEYFf5lkiSO6MM3Jesie/21jQY6AjpLwyhffG0a/7y8g==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@askrjs/monaco/-/monaco-0.5.0.tgz", + "integrity": "sha512-oKAtmvvS5ZdB+V4B/Qt1v0781ftS1ZLPU2ErQNZdaVFSox7lXFax1iZYnA2yvuXlATPtax43fLaLwt0WmPiBBQ==", "license": "Apache-2.0", "engines": { "node": ">=24.0.0" }, "peerDependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0", + "@askrjs/askr": ">=0.5.0 <0.6.0", "monaco-editor": "^0.56.0" } }, "node_modules/@askrjs/node": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@askrjs/node/-/node-0.4.0.tgz", - "integrity": "sha512-gRdFGwMDiVadJatJSveLJL8ZAmxMxFTJW+ShVT8mXZa46ZSP3p28ZKGYa2TWH8j3nlhf3H1CJW+FSUmjfL1i/w==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@askrjs/node/-/node-0.5.0.tgz", + "integrity": "sha512-HnsI+53V/JPWiEuPk954VTLhHHf+jK0in8Mvq2FKu+fBsaUhJENRiray8BC9LMex5Lvt+czX1XvAF/f4RdBpag==", "license": "Apache-2.0", "dependencies": { - "@askrjs/auth": ">=0.4.0 <0.5.0", - "@askrjs/server": ">=0.4.0 <0.5.0", + "@askrjs/auth": ">=0.5.0 <0.6.0", + "@askrjs/server": ">=0.5.0 <0.6.0", "@types/ws": "^8.18.1", "ws": "^8.21.3" }, @@ -431,9 +415,9 @@ } }, "node_modules/@askrjs/otel": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@askrjs/otel/-/otel-0.4.0.tgz", - "integrity": "sha512-wyYRbYj84BjU9AAFXYXEM4XQNxo19V3kY5gkCV61KxrqmTNYlVwh8eRtw39DkLPpzYCKhHm5+vOVphPLQ2QnGA==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@askrjs/otel/-/otel-0.5.0.tgz", + "integrity": "sha512-sGKn5ny+SlClKG5GnC9fsyE00hAui4SeCm30f4kOHnVHW9CXvJdfggFAK7tG+FZlORtuJnHGjjPf+/59dihMtg==", "license": "Apache-2.0", "engines": { "node": ">=24.0.0" @@ -443,28 +427,28 @@ } }, "node_modules/@askrjs/schema": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@askrjs/schema/-/schema-0.4.0.tgz", - "integrity": "sha512-vZfUeaGuMk0uREajsYOT/5TgrOAe+YKZMjXYLCK86YeY2P7GLy6Sv76lEoVU+AZoAb7odUMaVdruU8yLxMy9oQ==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@askrjs/schema/-/schema-0.5.0.tgz", + "integrity": "sha512-JMciK1KmJJ7Jy/GY+ph3ibFf/75qFA1HXMuC0tI/QMxnqpVTbIY694AXk9KDODrY1qm8WaF9ktpjKlY7ciENgA==", "license": "Apache-2.0", "engines": { "node": ">=24.0.0" } }, "node_modules/@askrjs/server": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@askrjs/server/-/server-0.4.0.tgz", - "integrity": "sha512-MjTomOPW/8vn8xvz1N2P/pruybCwfy94HpaavfVaBRos10ih/2I+jBKQdrt3PmwQR/iVZdqqiRgk+Chy2eaw5Q==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@askrjs/server/-/server-0.5.0.tgz", + "integrity": "sha512-piD546hjlQElDs+7fNbTCBE85qdnpZRnLmcOUAoalI1l7h6+Elpp0/+nkfVVim2BMnNs8CTqW01Cwpxv/hv9pA==", "license": "Apache-2.0", "dependencies": { - "@askrjs/auth": ">=0.4.0 <0.5.0", - "@askrjs/schema": ">=0.4.0 <0.5.0" + "@askrjs/auth": ">=0.5.0 <0.6.0", + "@askrjs/schema": ">=0.5.0 <0.6.0" }, "engines": { "node": ">=24.0.0" }, "peerDependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0" + "@askrjs/askr": ">=0.5.0 <0.6.0" }, "peerDependenciesMeta": { "@askrjs/askr": { @@ -472,14 +456,39 @@ } } }, + "node_modules/@askrjs/themes": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@askrjs/themes/-/themes-0.5.0.tgz", + "integrity": "sha512-qqR1q8cdl0w8ayXRh45WvEmCgk3WPAcOCeNcOGPm9ymCY7iO5aNjrwxz/hJ9fdrAB1vAGb3FQF5K/uddciZphw==", + "license": "Apache-2.0", + "engines": { + "node": ">=24.0.0" + }, + "peerDependencies": { + "@askrjs/askr": ">=0.5.0 <0.6.0", + "@askrjs/ui": ">=0.5.0 <0.6.0" + } + }, + "node_modules/@askrjs/ui": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@askrjs/ui/-/ui-0.5.0.tgz", + "integrity": "sha512-Q3yvvcah8rp9iWEq7H0uQHU4to4uLx9lT5B8GpXz43qh2i9UVtxjrpJ5rdPWfNiIPCYEkqjDnhpT3JOlMnMOsg==", + "license": "Apache-2.0", + "engines": { + "node": ">=24.0.0" + }, + "peerDependencies": { + "@askrjs/askr": ">=0.5.0 <0.6.0" + } + }, "node_modules/@askrjs/vite": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@askrjs/vite/-/vite-0.4.0.tgz", - "integrity": "sha512-PGbVZ2Kguvq7KN+dYO+wzjCJcAgvQyHr9ngEyksxUQQXHBgFnfSvxgbm0GjNVWP0Z4J4mY3ZKvvQxB+jOzsLmg==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@askrjs/vite/-/vite-0.5.0.tgz", + "integrity": "sha512-7V7rS+oF8so2O0Kjr3CQUKtYUJIogP5taogHJTu9kqFrjfuBNpI9aMZkQcS1FwsQnJ9hHOVK1OE0JyDmZ6A92g==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@askrjs/node": ">=0.4.0 <0.5.0", + "@askrjs/node": ">=0.5.0 <0.6.0", "oxc-parser": "^0.147.0", "parse5": "^8.0.1" }, @@ -487,10 +496,10 @@ "node": ">=24.0.0" }, "peerDependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0", + "@askrjs/askr": ">=0.5.0 <0.6.0", "sharp": "^0.35.3", - "vite": "^8.2.2", - "vite-plus": "^0.3.1" + "vite": "^8.2.2 || ^0.3.1 || ^1.1.0", + "vite-plus": "^0.3.1 || ^1.1.0" }, "peerDependenciesMeta": { "sharp": { @@ -530,9 +539,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", - "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", "dev": true, "license": "MIT", "engines": { @@ -547,13 +556,13 @@ "license": "MIT" }, "node_modules/@bramus/specificity": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", - "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz", + "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==", "dev": true, "license": "MIT", "dependencies": { - "css-tree": "^3.0.0" + "css-tree": "^3.2.1" }, "bin": { "specificity": "bin/cli.js" @@ -580,9 +589,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.1.tgz", - "integrity": "sha512-EtC7SoN1j6J4E4DCwg5QgbO5TGxgxIA1RXqe+W+qUM+BUcezx9wT+/tiQ/WO2yCX4i5X+Cuf9ciJ22aP4UEwWw==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", "dev": true, "funding": [ { @@ -599,14 +608,14 @@ "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.2", "@csstools/css-tokenizer": "^4.0.2" } }, "node_modules/@csstools/css-color-parser": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.4.tgz", - "integrity": "sha512-DyefytAZ735mX4Dq/WcDAXFtXhaEFvme0ZS9tVEBAc2whxUthXr0R0L2rmEPm59SrMBkGrFzQviBXMs/UtnABQ==", + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", + "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", "dev": true, "funding": [ { @@ -621,20 +630,20 @@ "license": "MIT", "dependencies": { "@csstools/color-helpers": "^6.1.2", - "@csstools/css-calc": "^3.4.1" + "@csstools/css-calc": "^3.4.3" }, "engines": { "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.2", "@csstools/css-tokenizer": "^4.0.2" } }, "node_modules/@csstools/css-parser-algorithms": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.1.tgz", - "integrity": "sha512-ShL8BqPfbKJrJiKFH0xBbN0i7Nrh9HXYRuF+pzyj93R/BL2YAsUxJeqANErzqM+0JGl7vjHp+3OIgd/DL69YIA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", "dev": true, "funding": [ { @@ -655,9 +664,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz", - "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", "dev": true, "funding": [ { @@ -1194,9 +1203,9 @@ } }, "node_modules/@npmcli/config": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/@npmcli/config/-/config-11.1.0.tgz", - "integrity": "sha512-xw2VW5qFHnkQL7svD7JY2hOu79golhbSsVFPe+yvMp4pShY31+VHGjh9jpdO3xhYgRUyo9gjmHD6a6UxeDwYjQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@npmcli/config/-/config-11.2.0.tgz", + "integrity": "sha512-PZtUL2rXqThZUT5KGsbVEGdrJswgBoY56X8hXIozwoayHC/rRsaJCIz4nsBzHJvNpCjGEbufJqStaqB7Qdd4ww==", "dev": true, "license": "ISC", "dependencies": { @@ -2482,13 +2491,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", - "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz", + "integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.63.0" + "playwright": "1.64.0" }, "bin": { "playwright": "cli.js" @@ -2505,9 +2514,9 @@ "license": "MIT" }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", - "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", "cpu": [ "arm" ], @@ -2522,9 +2531,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", - "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", "cpu": [ "arm64" ], @@ -2539,9 +2548,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", - "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", "cpu": [ "arm64" ], @@ -2556,9 +2565,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", - "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", "cpu": [ "x64" ], @@ -2573,9 +2582,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", - "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", "cpu": [ "x64" ], @@ -2590,9 +2599,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", - "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", "cpu": [ "arm" ], @@ -2607,9 +2616,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", - "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", "cpu": [ "arm64" ], @@ -2627,9 +2636,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", - "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", "cpu": [ "arm64" ], @@ -2647,9 +2656,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", - "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", "cpu": [ "ppc64" ], @@ -2667,9 +2676,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", - "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", "cpu": [ "s390x" ], @@ -2687,9 +2696,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", - "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", "cpu": [ "x64" ], @@ -2707,9 +2716,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", - "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", "cpu": [ "x64" ], @@ -2727,9 +2736,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", - "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", "cpu": [ "arm64" ], @@ -2744,9 +2753,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", - "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", "cpu": [ "arm64" ], @@ -2761,9 +2770,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", - "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", "cpu": [ "x64" ], @@ -2858,9 +2867,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz", - "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==", + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", "license": "MIT", "dependencies": { "undici-types": "~8.9.0" @@ -2874,9 +2883,9 @@ "optional": true }, "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", "license": "MIT", "dependencies": { "@types/node": "*" @@ -3564,6 +3573,7 @@ "cpu": [ "arm64" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -3578,6 +3588,7 @@ "cpu": [ "arm64" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -3592,6 +3603,7 @@ "cpu": [ "x64" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -3606,6 +3618,7 @@ "cpu": [ "x64" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -3620,6 +3633,7 @@ "cpu": [ "arm" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "libc": [ "glibc" @@ -3637,6 +3651,7 @@ "cpu": [ "arm" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "libc": [ "musl" @@ -3654,6 +3669,7 @@ "cpu": [ "arm64" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "libc": [ "glibc" @@ -3671,6 +3687,7 @@ "cpu": [ "arm64" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "libc": [ "musl" @@ -3688,6 +3705,7 @@ "cpu": [ "x64" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "libc": [ "glibc" @@ -3705,6 +3723,7 @@ "cpu": [ "x64" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "libc": [ "musl" @@ -3722,6 +3741,7 @@ "cpu": [ "arm64" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -3736,6 +3756,7 @@ "cpu": [ "x64" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -3750,6 +3771,7 @@ "cpu": [ "arm64" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -3764,6 +3786,7 @@ "cpu": [ "arm64" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -3778,6 +3801,7 @@ "cpu": [ "x64" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -3792,6 +3816,7 @@ "cpu": [ "x64" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -3806,6 +3831,7 @@ "cpu": [ "arm" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "libc": [ "glibc" @@ -3823,6 +3849,7 @@ "cpu": [ "arm" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "libc": [ "musl" @@ -3840,6 +3867,7 @@ "cpu": [ "arm64" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "libc": [ "glibc" @@ -3857,6 +3885,7 @@ "cpu": [ "arm64" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "libc": [ "musl" @@ -3874,6 +3903,7 @@ "cpu": [ "x64" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "libc": [ "glibc" @@ -3891,6 +3921,7 @@ "cpu": [ "x64" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "libc": [ "musl" @@ -3908,6 +3939,7 @@ "cpu": [ "arm64" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -3922,6 +3954,7 @@ "cpu": [ "x64" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -4062,9 +4095,9 @@ } }, "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", "engines": { @@ -4123,32 +4156,17 @@ } }, "node_modules/data-urls": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", - "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz", + "integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==", "dev": true, "license": "MIT", "dependencies": { "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0" + "whatwg-url": "^17.1.2" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/data-urls/node_modules/whatwg-url": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", - "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.11.0", - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^22.14.0 || >=24.0.0" } }, "node_modules/debug": { @@ -4204,9 +4222,9 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.4.13", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", - "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", + "version": "3.4.16", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", + "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -4391,9 +4409,9 @@ } }, "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz", + "integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==", "dev": true, "license": "BSD-2-Clause" }, @@ -4446,9 +4464,9 @@ } }, "node_modules/ignore": { - "version": "7.0.10", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.10.tgz", - "integrity": "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==", + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", "dev": true, "license": "MIT", "engines": { @@ -4466,9 +4484,9 @@ } }, "node_modules/ip-address": { - "version": "10.7.2", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", - "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", + "version": "10.7.4", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz", + "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==", "dev": true, "license": "MIT", "engines": { @@ -4500,9 +4518,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.2.tgz", - "integrity": "sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==", + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.3.tgz", + "integrity": "sha512-yzzNtgczwgoVHNigCyKTXcQJU4Y8t/ikU4UxXW0BvrLfHTGWZhsNcOT2s/a7vn81/4KqVnNmHjaUEPlvWnNwVw==", "dev": true, "funding": [ { @@ -4523,31 +4541,31 @@ } }, "node_modules/jsdom": { - "version": "30.1.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.1.tgz", - "integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==", + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.2.tgz", + "integrity": "sha512-0FFE/jE1rppmVfUrJUgxqXjcwolZYIGtAgj1pTussMhNZxIxi7W/PvfWaMNroGi2B36X1IKHbexpZ9DhkoOPiQ==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^7.0.0", - "@asamuzakjp/dom-selector": "^9.2.1", + "@asamuzakjp/css-color": "^7.1.3", + "@asamuzakjp/dom-selector": "^9.2.4", "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.13", - "@exodus/bytes": "^1.15.1", + "@csstools/css-syntax-patches-for-csstree": "^1.1.15", + "@exodus/bytes": "^1.16.0", "css-tree": "^3.2.1", - "data-urls": "^7.0.0", + "data-urls": "^8.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^7.0.0", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.5.2", + "lru-cache": "^11.5.3", "parse5": "^8.0.1", "saxes": "^6.0.0", "tough-cookie": "^6.0.2", - "undici": "^8.10.2", + "undici": "^8.11.2", "w3c-xmlserializer": "^6.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^17.1.1", + "whatwg-url": "^17.2.0", "xml-name-validator": "^5.0.0" }, "engines": { @@ -5091,9 +5109,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.19", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", - "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -5398,9 +5416,9 @@ } }, "node_modules/p-map": { - "version": "7.0.8", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.8.tgz", - "integrity": "sha512-MitaVsCuCFIvOLLPIU7NnfrZvS9H9h7kwMUkDo+T2pEISaJD48IV9S8iIdXB7PsvvdxyYcsSTTrr90XKsbulNw==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.1.1.tgz", + "integrity": "sha512-6Lh3jQPLthYujSrlw6ccWXBGZTkoTCz27Hd92g79LH/kjAbgoEu0t442cCCNbegPSJsA0lwDX8z0bcKtkpA32g==", "dev": true, "license": "MIT", "engines": { @@ -5468,13 +5486,13 @@ } }, "node_modules/playwright": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", - "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", + "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.63.0" + "playwright-core": "1.64.0" }, "bin": { "playwright": "cli.js" @@ -5484,9 +5502,9 @@ } }, "node_modules/playwright-core": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", - "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5507,9 +5525,9 @@ } }, "node_modules/postcss": { - "version": "8.5.28", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", - "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", "dev": true, "funding": [ { @@ -5527,9 +5545,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.18", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -5606,13 +5624,13 @@ } }, "node_modules/rolldown": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", - "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.151.0", + "@oxc-project/types": "=0.153.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -5622,27 +5640,27 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.11", - "@rolldown/binding-android-arm64": "1.2.11", - "@rolldown/binding-darwin-arm64": "1.2.11", - "@rolldown/binding-darwin-x64": "1.2.11", - "@rolldown/binding-freebsd-x64": "1.2.11", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", - "@rolldown/binding-linux-arm64-gnu": "1.2.11", - "@rolldown/binding-linux-arm64-musl": "1.2.11", - "@rolldown/binding-linux-ppc64-gnu": "1.2.11", - "@rolldown/binding-linux-s390x-gnu": "1.2.11", - "@rolldown/binding-linux-x64-gnu": "1.2.11", - "@rolldown/binding-linux-x64-musl": "1.2.11", - "@rolldown/binding-openharmony-arm64": "1.2.11", - "@rolldown/binding-win32-arm64-msvc": "1.2.11", - "@rolldown/binding-win32-x64-msvc": "1.2.11" + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" } }, "node_modules/rolldown/node_modules/@oxc-project/types": { - "version": "0.151.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", - "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", "dev": true, "license": "MIT", "funding": { @@ -5747,9 +5765,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -5802,9 +5820,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", - "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", "dev": true, "license": "MIT" }, @@ -5853,9 +5871,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", - "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", "dev": true, "license": "MIT", "engines": { @@ -5863,22 +5881,22 @@ } }, "node_modules/tldts": { - "version": "7.4.16", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.16.tgz", - "integrity": "sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==", + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.18.tgz", + "integrity": "sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.4.16" + "tldts-core": "^7.4.18" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.4.16", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.16.tgz", - "integrity": "sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==", + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.18.tgz", + "integrity": "sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==", "dev": true, "license": "MIT" }, @@ -5906,16 +5924,16 @@ } }, "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz", + "integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==", "dev": true, "license": "MIT", "dependencies": { "punycode": "^2.3.1" }, "engines": { - "node": ">=20" + "node": "^22.14.0 || >=24.0.0" } }, "node_modules/tsx": { @@ -5972,6 +5990,21 @@ "@typescript/typescript-win32-x64": "7.0.2" } }, + "node_modules/typescript6": { + "name": "typescript", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/undici": { "version": "8.11.2", "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", @@ -5999,16 +6032,16 @@ } }, "node_modules/vite": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", - "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", - "postcss": "^8.5.28", - "rolldown": "~1.2.9", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", "tinyglobby": "^0.2.17" }, "bin": { @@ -6368,14 +6401,14 @@ } }, "node_modules/whatwg-url": { - "version": "17.1.2", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.2.tgz", - "integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==", + "version": "17.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.2.0.tgz", + "integrity": "sha512-IhLIHNcBrzIixC5XXsbr4DxdjajyA5vQ5RwGZMtrUp+GbtE5Nrsge9QKau7jXrC0aTXLYGV0xiW7Tb4YS2aomw==", "dev": true, "license": "MIT", "dependencies": { - "@exodus/bytes": "^1.15.1", - "tr46": "^6.0.0", + "@exodus/bytes": "^1.16.0", + "tr46": "^7.0.0", "webidl-conversions": "^8.0.1" }, "engines": { @@ -6570,46 +6603,151 @@ "@yuku-parser/binding-win32-x64": "0.9.5" } }, - "spa": { - "name": "@askrjs/example-spa", + "provider-auth": { + "name": "@askrjs/example-provider-auth", "version": "0.0.1", "dependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0", - "@askrjs/auth": ">=0.4.0 <0.5.0", - "@askrjs/lucide": ">=0.4.0 <0.5.0", - "@askrjs/themes": ">=0.4.0 <0.5.0", - "@askrjs/ui": ">=0.4.0 <0.5.0" + "@askrjs/askr": ">=0.5.0 <0.6.0", + "@askrjs/auth": ">=0.5.0 <0.6.0", + "@askrjs/node": ">=0.5.0 <0.6.0", + "@askrjs/schema": ">=0.5.0 <0.6.0", + "@askrjs/server": ">=0.5.0 <0.6.0" }, "devDependencies": { - "@askrjs/vite": ">=0.4.0 <0.5.0", + "@askrjs/vite": ">=0.5.0 <0.6.0", + "@playwright/test": "^1.62.1", + "@types/node": "^26.3.0", "typescript": "^7.0.2", - "vite": "npm:@voidzero-dev/vite-plus-core@^0.3.1", - "vite-plus": "^0.3.1", - "vitest": "^4.1.11" + "typescript6": "npm:typescript@6.0.3", + "vite": "npm:@voidzero-dev/vite-plus-core@0.3.3", + "vite-plus": "0.3.3" }, "engines": { "node": ">=24.0.0" } }, - "spa/node_modules/@askrjs/themes": { - "version": "0.4.0", - "license": "Apache-2.0", + "provider-auth/node_modules/vite": { + "name": "@voidzero-dev/vite-plus-core", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-core/-/vite-plus-core-0.3.3.tgz", + "integrity": "sha512-Ui92VENIZG+p0nMJuVGMsHBHrQqgUNxXObdo5PlSDEBOdRmD8AHKr8tOClZTVU1U5blLQKtHKplxtTun0yZEmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/runtime": "=0.150.0", + "@oxc-project/types": "=0.150.0", + "lightningcss": "^1.33.0", + "postcss": "^8.5.6", + "yuku-codegen": "^0.9.3", + "yuku-parser": "^0.9.3" + }, "engines": { - "node": ">=24.0.0" + "node": "^20.19.0 || ^22.18.0 || >=24.11.0" + }, + "optionalDependencies": { + "@voidzero-dev/vite-plus-darwin-arm64": "0.3.3", + "@voidzero-dev/vite-plus-darwin-x64": "0.3.3", + "@voidzero-dev/vite-plus-linux-arm64-gnu": "0.3.3", + "@voidzero-dev/vite-plus-linux-arm64-musl": "0.3.3", + "@voidzero-dev/vite-plus-linux-x64-gnu": "0.3.3", + "@voidzero-dev/vite-plus-linux-x64-musl": "0.3.3", + "@voidzero-dev/vite-plus-win32-arm64-msvc": "0.3.3", + "@voidzero-dev/vite-plus-win32-x64-msvc": "0.3.3", + "fsevents": "~2.3.3" }, "peerDependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0", - "@askrjs/ui": ">=0.4.0 <0.5.0" + "@arethetypeswrong/core": "^0.18.1", + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "publint": "^0.3.8", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "typescript": "^5.0.0 || ^6.0.0 || ^7.0.0", + "unplugin-unused": ">=0.5.0", + "unrun": "*", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@arethetypeswrong/core": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "publint": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "typescript": { + "optional": true + }, + "unplugin-unused": { + "optional": true + }, + "unrun": { + "optional": true + }, + "yaml": { + "optional": true + } } }, - "spa/node_modules/@askrjs/ui": { - "version": "0.4.0", - "license": "Apache-2.0", + "spa": { + "name": "@askrjs/example-spa", + "version": "0.0.1", + "dependencies": { + "@askrjs/askr": ">=0.5.0 <0.6.0", + "@askrjs/auth": ">=0.5.0 <0.6.0", + "@askrjs/lucide": ">=0.5.0 <0.6.0", + "@askrjs/themes": ">=0.5.0 <0.6.0", + "@askrjs/ui": ">=0.5.0 <0.6.0" + }, + "devDependencies": { + "@askrjs/vite": ">=0.5.0 <0.6.0", + "jsdom": "^30.0.1", + "typescript": "^7.0.2", + "vite": "npm:@voidzero-dev/vite-plus-core@^0.3.1", + "vite-plus": "^0.3.1", + "vitest": "^4.1.11" + }, "engines": { "node": ">=24.0.0" - }, - "peerDependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0" } }, "spa/node_modules/vite": { @@ -6718,14 +6856,14 @@ "name": "@askrjs/example-ssg", "version": "0.0.1", "dependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0", - "@askrjs/lucide": ">=0.4.0 <0.5.0", - "@askrjs/themes": ">=0.4.0 <0.5.0", - "@askrjs/ui": ">=0.4.0 <0.5.0" + "@askrjs/askr": ">=0.5.0 <0.6.0", + "@askrjs/lucide": ">=0.5.0 <0.6.0", + "@askrjs/themes": ">=0.5.0 <0.6.0", + "@askrjs/ui": ">=0.5.0 <0.6.0" }, "devDependencies": { - "@askrjs/cli": ">=0.4.0 <0.5.0", - "@askrjs/vite": ">=0.4.0 <0.5.0", + "@askrjs/cli": ">=0.5.0 <0.6.0", + "@askrjs/vite": ">=0.5.0 <0.6.0", "typescript": "^7.0.2", "vite": "npm:@voidzero-dev/vite-plus-core@^0.3.1", "vite-plus": "^0.3.1", @@ -6735,27 +6873,6 @@ "node": ">=24.0.0" } }, - "ssg/node_modules/@askrjs/themes": { - "version": "0.4.0", - "license": "Apache-2.0", - "engines": { - "node": ">=24.0.0" - }, - "peerDependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0", - "@askrjs/ui": ">=0.4.0 <0.5.0" - } - }, - "ssg/node_modules/@askrjs/ui": { - "version": "0.4.0", - "license": "Apache-2.0", - "engines": { - "node": ">=24.0.0" - }, - "peerDependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0" - } - }, "ssg/node_modules/vite": { "name": "@voidzero-dev/vite-plus-core", "version": "0.3.3", @@ -6862,15 +6979,15 @@ "name": "@askrjs/example-ssr-only", "version": "0.0.1", "dependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0", - "@askrjs/lucide": ">=0.4.0 <0.5.0", - "@askrjs/node": ">=0.4.0 <0.5.0", - "@askrjs/server": ">=0.4.0 <0.5.0", - "@askrjs/themes": ">=0.4.0 <0.5.0", - "@askrjs/ui": ">=0.4.0 <0.5.0" + "@askrjs/askr": ">=0.5.0 <0.6.0", + "@askrjs/lucide": ">=0.5.0 <0.6.0", + "@askrjs/node": ">=0.5.0 <0.6.0", + "@askrjs/server": ">=0.5.0 <0.6.0", + "@askrjs/themes": ">=0.5.0 <0.6.0", + "@askrjs/ui": ">=0.5.0 <0.6.0" }, "devDependencies": { - "@askrjs/vite": ">=0.4.0 <0.5.0", + "@askrjs/vite": ">=0.5.0 <0.6.0", "tsx": "^4.23.12", "typescript": "^7.0.2", "vite": "npm:@voidzero-dev/vite-plus-core@^0.3.1", @@ -6881,27 +6998,6 @@ "node": ">=24.0.0" } }, - "ssr-only/node_modules/@askrjs/themes": { - "version": "0.4.0", - "license": "Apache-2.0", - "engines": { - "node": ">=24.0.0" - }, - "peerDependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0", - "@askrjs/ui": ">=0.4.0 <0.5.0" - } - }, - "ssr-only/node_modules/@askrjs/ui": { - "version": "0.4.0", - "license": "Apache-2.0", - "engines": { - "node": ">=24.0.0" - }, - "peerDependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0" - } - }, "ssr-only/node_modules/vite": { "name": "@voidzero-dev/vite-plus-core", "version": "0.3.3", diff --git a/package.json b/package.json index 48d921a..f2aacc6 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "api-ssr", "api-only", "ssg", - "mcp-server" + "mcp-server", + "provider-auth" ], "type": "module", "scripts": { @@ -23,12 +24,19 @@ "openapi:check": "npm run openapi:check --workspaces --if-present", "test:journey": "vp test run -c vitest.config.ts", "test:browser": "playwright test", - "check": "npm run lint && npm run typecheck && npm test && npm run test:journey && npm run openapi:check", - "acceptance": "npm run check && npm run build && npm run test:browser" + "check": "npm run lint && npm run lint:qualification && npm run test:qualification && npm run typecheck && npm test && npm run test:journey && npm run openapi:check && npm run test:provider-installed", + "acceptance": "npm run check && npm run build && npm run test:runtime && npm run test:browser && npm run test:provider-types && npm run test:provider-browser", + "lint:qualification": "vp lint scripts/qualification tests/qualification playwright.config.ts playwright", + "test:qualification": "node --test tests/qualification/*.test.mjs", + "test:runtime": "node scripts/qualification/workspace-runtime.mjs", + "test:stages": "node scripts/qualification/independent-stages.mjs --source .", + "test:provider-browser": "npm run test:browser --workspace @askrjs/example-provider-auth", + "test:provider-types": "npm run test:types6 --workspace @askrjs/example-provider-auth && npm run test:types7 --workspace @askrjs/example-provider-auth", + "test:provider-installed": "npm run test:installed --workspace @askrjs/example-provider-auth" }, "devDependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0", - "@askrjs/schema": ">=0.4.0 <0.5.0", + "@askrjs/askr": ">=0.5.0 <0.6.0", + "@askrjs/schema": ">=0.5.0 <0.6.0", "@playwright/test": "^1.62.1", "@types/node": "^26.3.0", "jsdom": "^30.0.1", @@ -40,7 +48,7 @@ "vitest": "^4.1.11" }, "overrides": { - "dompurify": "3.4.13" + "dompurify": "3.4.16" }, "engines": { "node": ">=24.0.0" diff --git a/provider-auth/.env.example b/provider-auth/.env.example new file mode 100644 index 0000000..1a802a1 --- /dev/null +++ b/provider-auth/.env.example @@ -0,0 +1,27 @@ +# Copy to .env.local; values remain application-owned and server-only. +ASKR_PROVIDER= +ASKR_RENDER_MODE=ssr +ASKR_ORIGIN= +ASKR_LISTEN_PORT=3443 +ASKR_SESSION_SECRETS= +ASKR_ACTION_CSRF_SECRET= +ASKR_TLS_CERT_PATH= +ASKR_TLS_KEY_PATH= +AUTH0_DOMAIN= +AUTH0_CLIENT_ID= +AUTH0_CLIENT_SECRET= +# Optional Auth0 API target: set all three together. +# AUTH0_API_AUDIENCE= +# AUTH0_API_SCOPES= +# AUTH0_API_URL= +# AUTH0_ORGANIZATION= +MICROSOFT_TENANT_ID= +MICROSOFT_CLIENT_ID= +MICROSOFT_CLIENT_SECRET= +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +# GOOGLE_HOSTED_DOMAIN= +APPLE_SERVICES_ID= +APPLE_TEAM_ID= +APPLE_KEY_ID= +APPLE_PRIVATE_KEY_PATH= diff --git a/provider-auth/.gitignore b/provider-auth/.gitignore new file mode 100644 index 0000000..bc6ce70 --- /dev/null +++ b/provider-auth/.gitignore @@ -0,0 +1,6 @@ +.env.local +.tls/ +dist/ +node_modules/ +test-results/ +playwright-report/ diff --git a/provider-auth/README.md b/provider-auth/README.md new file mode 100644 index 0000000..8994e21 --- /dev/null +++ b/provider-auth/README.md @@ -0,0 +1,244 @@ +# One server-owned provider session + +This Northstar extension uses one `createAskrApp` for provider login, callback, +session resolution, logout, protected pages, APIs and actions. Select Auth0, +Microsoft, Google or Apple explicitly. Browser code receives a minimal identity +snapshot and application display results. Provider access, refresh and ID tokens, +session IDs, client secrets and signing keys stay on the server. + +This source targets the coordinated 0.5 consumer graph and declares +`>=0.5.0 <0.6.0` platform dependencies. Before executing it, generate ordinary +locks against the reviewed final archives through the candidate registry. The +four preset packages, SDK retirement, Server action auth type and Core fresh CSRF +resolver must be accepted first. Nothing here publishes a package. + +## Follow the application + +1. `src/server/environment.ts` validates one discriminated, server-only provider + configuration. `providers.ts` selects its public preset; Apple reads its P-256 + key file here, never in browser code. +2. `src/server/app.ts` creates the sole engine through Server, registers the same + route factory used by the client, declares `/api/action-csrf` and `/api/private`, + and handles the protected `provider.read-summary` action. +3. `src/registry.tsx` owns `/`, `/login`, `/account`, `/account/details` and `/*`. + Both private routes explicitly require a user and allow the action. The + returned `sessionAuth` object is passed unchanged to the registry and boot. +4. `src/server/production.ts` imports the Vite-composed document and the exact raw + entry singleton. `compose.ts` forwards composed fetch and raw engine cleanup; + it never creates a second engine. Public provider/mode meta markers are filled + after composition without exposing server configuration. +5. `src/client.tsx` validates those synchronous markers before taking ownership + of `#app`. SSR calls `hydrateSPA`; pure SPA calls `createSPA`. Invalid markers + clear the still-unowned root and fail before a session request or private boot. +6. `src/server/https.ts` loads a finite regular-file asset map, applies the public + Node adapter's Host policy to assets and app together, and owns bounded shutdown. + No request path is joined to the filesystem and no server/key/source-map files + are served. + +The default provider authority and display hints are in memory. This is a +single-process example: restarting invalidates its sessions, and two independent +engines do not share authority even when they use the same encryption keys. +Production multi-process deployments need the explicit shared provider store and +its atomic ownership/locking contract; a shared cookie secret alone is not one. + +## Configure HTTPS and one provider + +Use Node **24.21.0** and npm **12.0.1** for the qualified Vite+ toolchain. All +build, lint, format and unit-test commands use Vite+ 0.3.3. TypeScript compilers, +native Node serving and Playwright remain their explicit compiler/runtime gates. + +```sh +cp .env.example .env.local +# Fill only the selected provider's credentials and all common settings. +npm install +npm run dev # Vite+ client + SSR build, then the same HTTPS server +# Or perform these two steps separately: +npm run build +npm start +``` + +`dev` deliberately builds then starts. This example claims neither HMR nor a +Vite development proxy path. Both modes use the same production HTTPS adapter. +The listener binds `127.0.0.1`; `ASKR_LISTEN_PORT` is its local port. `ASKR_ORIGIN` +is the browser-visible HTTPS origin, including an explicit port when appropriate. +Use a certificate trusted by your browser for that hostname and set its server +certificate/key paths. Keep `.env.local`, `.tls` and private keys out of source +control. Never disable Node/browser TLS verification globally. + +Generate `ASKR_SESSION_SECRETS` with `crypto.randomBytes(32).toString('base64url')`: +one to four distinct canonical keys, newest first and comma-separated. Set a +separate unpredictable `ASKR_ACTION_CSRF_SECRET`. Session rotation and action CSRF +have separate owners. Error messages name required settings without printing +values. `.env.example` intentionally contains blank credentials. + +Register the exact callback `${ASKR_ORIGIN}/auth/callback/` in the +provider application. Provider redirect URIs contain no fragment. The final +application `returnTo` may retain a safe path, query and fragment; it rejects +scheme-relative, foreign-origin, raw/encoded backslash, control and traversal +targets and falls back to `/account`. + +### CLI-generated starter settings + +For a supported CLI starter, `askr add auth google --origin https://app.example` +prints the same `/auth/callback/google`, local POST `/auth/logout`, and `/` +post-logout destination used here. Repeat `--origin` for each environment and +register each exact callback in the provider console. The generator writes blank +environment names and preserves server ownership of secrets. + +This explicitly assembled example names its shared application settings as follows: + +| CLI starter setting | This example's setting | +| --------------------- | ------------------------------------------------------------ | +| `APP_ORIGIN` | `ASKR_ORIGIN`, the same exact HTTPS origin | +| `AUTH_SESSION_SECRET` | `ASKR_SESSION_SECRETS`, initially containing that single key | + +The provider credential names in `.env.example` match the CLI generator for all +four providers. `ASKR_PROVIDER` selects this example's provider, and its render +mode, TLS paths, listen port and separate action CSRF secret remain explicit +application settings. Set these values in the example's `.env.local`; the CLI's +generated `.env.example` belongs to its starter. Both setups use confidential Web +credentials even when rendering in SPA mode, and Apple's callback requires a +registered HTTPS domain rather than `localhost`. + +### Provider registration and API consent + +| Selection | Required provider settings | Explicit server API operation | +| ----------- | ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `auth0` | `AUTH0_DOMAIN` as a DNS hostname only (no scheme, path or port), client ID and secret; optional expected organization ID | Optional: set audience, exact space-separated API scopes and HTTPS API URL together. No API target is guessed. | +| `microsoft` | A specific tenant GUID, client ID and secret; register a confidential **Web** callback even when this app uses SPA rendering | Graph `https://graph.microsoft.com`, permission `https://graph.microsoft.com/User.Read`, `/v1.0/me?$select=displayName` | +| `google` | Web client ID and secret; optional exact `GOOGLE_HOSTED_DOMAIN` | `https://www.googleapis.com`, CalendarList readonly permission, CalendarList endpoint | +| `apple` | Services ID, Team ID, Key ID and server-readable P-256 `.p8` private-key path | Identity only; the action displays the bounded application-owned profile hint or identity result. No API token capability. | + +Auth0 login starts identity-only because its optional API policy is server-owned. +A server action needing API consent returns the typed canonical resource/scopes; +Core performs one explicit document recovery. Auth0 refresh-family reuse requires +provider-side audience/scope policies, offline access and refresh-token rotation. +Its [MRRT documentation](https://auth0.com/docs/secure/tokens/refresh-tokens/multi-resource-refresh-token) +limits this feature to first-party apps and APIs that allow skipping consent. +The simulated fixture deliberately assumes that policy for its configured API; +it does not establish your tenant's configuration. + +Microsoft uses a fixed tenant and fully qualified configured Graph permissions. +Google starts with OIDC identity only. The first Calendar server action requests +its separate explicit API consent; returning from consent does not resubmit that +action. The next deliberate click makes one API call. Its bounded recovery policy +also handles later expired permissions. See the +primary [Microsoft authorization-code protocol](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow) +and [Google web-server flow](https://developers.google.com/identity/protocols/oauth2/web-server). +An API permission does not become a browser role merely because a provider issued +an ID token or because a scope was requested. + +Apple requires a registered public HTTPS hostname; localhost and IP literals are +rejected by this example's configuration. For local testing, use an appropriately +registered public tunnel/hostname and a trusted certificate, forwarding to the +loopback listener. The real browser returns a cross-site `form_post`; the sealed +transaction cookie is usable without the Lax application cookie. The preset +validates the signed identity and ES256 client-secret lifecycle. Its name hint is +unsigned, bounded display data and never authorization. Only deliberately +validated email/boolean fields and the separate name hint reach `onSignIn`; the +application stores a bounded label for at most eight hours. The persisted and +SSR principal remains minimal. Apple's private daily account validation is +family-locked; the default eight-hour app lifetime expires before a daily check +would normally become due. The engine/preset suite separately tests longer-lived +checks. Live Apple PKCE/provider behavior remains pending, alongside all other +live-provider compatibility claims. + +## SSR and pure SPA + +Set `ASKR_RENDER_MODE=ssr` or `spa`. SSR resolves authority and prefetches the +private query on the server; hydration adopts that snapshot without an initial +`GET /auth/session`. Pure SPA uses an explicit middleware only for known document +GET/HEAD routes: it returns an empty fragment and executes no SSR page, loader, +query or snapshot. Browser boot then makes exactly one initial session request. +Auth/API/action/asset routes and unsafe methods continue through the same app. + +The private query belongs to Core's current app data runtime. Logout publishes +anonymous state locally, retires private DOM and aborts in-flight private work; +reload confirms the server authority is gone. This is local logout. Provider +revocation/logout is a separate explicit server capability, not implied by that +button. The existing same-origin server route accepts the following deliberate +JSON POST bodies at `/auth/logout`, with the application's HttpOnly cookie and +same-origin `Origin` header: + +| Provider | Explicit provider operation | +| --------- | ---------------------------------------------------------------------------------- | +| Auth0 | `{ "provider": "auth0", "revoke": true, "providerLogout": true, "returnTo": "/" }` | +| Microsoft | `{ "provider": "microsoft", "providerLogout": true, "returnTo": "/" }` | +| Google | `{ "provider": "google", "revoke": true }` | +| Apple | `{ "provider": "apple", "revoke": true }` | + +Invoke this from an application-owned server route/action when implementing a +provider-wide sign-out control. Auth0/Microsoft return a 303 to their canonical +end-session endpoint after retiring local authority; Google/Apple revocation +returns 204. The end-session URL contains only the public client ID and registered +return URL. It never contains an ID/access/refresh token. Preserve the response's +Set-Cookie headers when forwarding it. The runnable signed HTTP tests exercise +these exact server calls, local retirement and secret-free redirect fields; +simulated qualification never follows those redirects to a live provider. + +Every action submission resolves a fresh CSRF token from `/api/action-csrf`, with +same-origin credentials, no-store, redirect errors and the supplied AbortSignal. +It never caches an SSR token; explicit `undefined` prevents fallback when no +session exists. API bearer tokens are obtained by `ctx.auth.token` and used only +by the server fetch with the same request signal. The application cancels the +upstream response body and returns a deliberate bounded message. It does not +proxy arbitrary provider JSON. Action error handling explicitly passes its +Problem Details cause to `sessionAuth.handleAuthFailure`; there is no global fetch +wrapper or automatic token-refresh retry. + +## Reproduce acceptance after the final graph is available + +```sh +npm run fmt -- --check +npm run check # lint, strict typecheck, units, native installed surface +npm run test:types6 # this stage's actual TypeScript 6 compiler +npm run test:types7 # this stage's actual TypeScript 7 compiler +npm run build # real client and Vite-composed production server +npm exec playwright install chromium firefox webkit +npm run test:browser # pretest builds the real server fixture entry +``` + +The browser suite covers four providers × SSR/SPA × three engines, exact initial +session GET counts, SSR node identity, safe next/query/hash, secure HttpOnly/Lax +session cookies, Apple's cross-site POST without that application cookie, server +API actions, copied-callback rejection, consent, expiry during a live private-query +refresh (retiring private DOM and data before login recovery), private-query cancellation, +logout/reload and bundle/console secret absence. An additional malformed-marker +control clears a previously signed-in server root before boot. HTTP tests exercise +actual signatures, state, nonce, PKCE, Apple ES256, rotation/revocation, engine +isolation, action-CSRF, strict Host/assets and shutdown. Existing producer suites +remain responsible for exhaustive protocol and authority race coverage. + +`tests/signed-provider.ts` generates an owned temporary CA with OpenSSL, serves +signed protocol responses over real loopback HTTPS, and forwards only exact +allowlisted canonical server endpoints through a pinned CA. The browser uses an +explicit simulated-provider handoff: the fixture verifies the preset's canonical +authorization URL, preserves the application's actual 303 and Set-Cookie headers, +and changes its Location to an owned same-site handoff page. Its visible link +enters `https://provider.example-provider.test`, a separate reserved site. The +provider then submits the real callback form: POST for Apple and GET for the +other presets. The complete canonical authorization URL/query must match the +signed provider receipt, including state, nonce, PKCE and Apple's `form_post`. + +The handoff avoids macOS Apple AppSSO and WebKit's interrupted cross-site redirect +metadata. Neither feature belongs to this simulated HTTP provider. A strict +CONNECT proxy exposes only the two owned loopback listeners, with no DNS or +external fallback; canonical live provider authorities are rejected. Its +ephemeral certificate is accepted only in that proxy-bound browser context; +Node continues to verify the pinned CA. No global DNS/trust setting or provider +credential is changed. Playwright routes only the script/private-query barriers; +actual HTTPS redirects, cookies and callback bodies use native browser transport. +Apple's Cookie assertions use headers received by the actual HTTPS application, +because WebKit omits cross-site Cookie from automation request metadata; callback +URL, method and body must also match the browser request. + +Unknown traffic, unexpected request failures and leaked secrets fail acceptance. +Every hold is released in finally; routes, CONNECT sockets and application engines +drain within owned bounds. Native installed checks use public package export paths +and module identities, not source aliases. The seven-stage final candidate helper +also checks ordinary independent install/fresh-ci/lock/SRI/physical graph provenance. + +All 54 signed browser cases pass locally on Node 24.21.0/npm 12.0.1. Replay them +against the exact coordinated candidate graph before release. They establish +simulated protocol/application evidence. Live service qualification is deferred +in [Auth #71](https://github.com/askrjs/askr-auth/issues/71). diff --git a/provider-auth/index.html b/provider-auth/index.html new file mode 100644 index 0000000..7aab297 --- /dev/null +++ b/provider-auth/index.html @@ -0,0 +1,14 @@ + + + + + + + + + + +
+ + + diff --git a/provider-auth/package.json b/provider-auth/package.json new file mode 100644 index 0000000..19d1ae7 --- /dev/null +++ b/provider-auth/package.json @@ -0,0 +1,43 @@ +{ + "name": "@askrjs/example-provider-auth", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "npm run build && npm start", + "build": "vp build --configLoader native && vp build --ssr src/server/production.ts --outDir dist/server --configLoader native", + "start": "node --env-file=.env.local serve.mjs", + "fmt": "vp fmt .", + "lint": "vp lint .", + "test": "vp test run", + "typecheck": "tsc --noEmit --pretty false", + "check": "npm run lint && npm run typecheck && npm test && npm run test:installed", + "test:browser": "playwright test --config tests/browser/playwright.config.ts", + "test:types6": "node scripts/typescript6.mjs --noEmit --pretty false", + "test:types7": "tsc --noEmit --pretty false", + "build:fixture": "vp build --ssr tests/browser/app-host.ts --outDir dist/fixture --configLoader native", + "pretest:browser": "npm run build:fixture", + "acceptance": "npm run check && npm run test:types6 && npm run test:types7 && npm run build && npm run test:browser", + "test:installed": "node scripts/installed-surface.mjs" + }, + "dependencies": { + "@askrjs/askr": ">=0.5.0 <0.6.0", + "@askrjs/auth": ">=0.5.0 <0.6.0", + "@askrjs/node": ">=0.5.0 <0.6.0", + "@askrjs/schema": ">=0.5.0 <0.6.0", + "@askrjs/server": ">=0.5.0 <0.6.0" + }, + "devDependencies": { + "@askrjs/vite": ">=0.5.0 <0.6.0", + "@playwright/test": "^1.62.1", + "@types/node": "^26.3.0", + "typescript": "^7.0.2", + "typescript6": "npm:typescript@6.0.3", + "vite": "npm:@voidzero-dev/vite-plus-core@0.3.3", + "vite-plus": "0.3.3" + }, + "engines": { + "node": ">=24.0.0" + }, + "packageManager": "npm@12.0.1" +} diff --git a/provider-auth/scripts/installed-surface.mjs b/provider-auth/scripts/installed-surface.mjs new file mode 100644 index 0000000..bd1f8a2 --- /dev/null +++ b/provider-auth/scripts/installed-surface.mjs @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { createHash } from "node:crypto"; +import { realpath, readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { createAuth0Provider } from "@askrjs/auth/providers/auth0"; +import { createMicrosoftProvider } from "@askrjs/auth/providers/microsoft"; +import { createGoogleProvider } from "@askrjs/auth/providers/google"; +import { createAppleProvider } from "@askrjs/auth/providers/apple"; +import { createProviderSession } from "@askrjs/auth/server"; +import { createDocumentApp } from "@askrjs/vite/server"; +const require = createRequire(import.meta.url); +const surfaces = [ + ["@askrjs/auth/providers/auth0", "createAuth0Provider", createAuth0Provider], + ["@askrjs/auth/providers/microsoft", "createMicrosoftProvider", createMicrosoftProvider], + ["@askrjs/auth/providers/google", "createGoogleProvider", createGoogleProvider], + ["@askrjs/auth/providers/apple", "createAppleProvider", createAppleProvider], + ["@askrjs/auth/server", "createProviderSession", createProviderSession], + ["@askrjs/vite/server", "createDocumentApp", createDocumentApp], +]; +const resolved = []; +for (const [specifier, name, value] of surfaces) { + assert.equal(typeof value, "function"); + const url = import.meta.resolve(specifier); + const physical = await realpath(fileURLToPath(url)); + assert.match(physical, /node_modules[/\\]@askrjs[/\\]/u); + assert.equal((await import(url))[name], value, `Native ESM identity: ${specifier}`); + resolved.push({ specifier, export: name, url, physical }); +} +const sdks = [ + "@auth0/auth0-spa-js", + "@azure/msal-browser", + "@azure/msal-node", + "google-auth-library", + "passport", + "openid-client", +]; +for (const name of sdks) { + assert.throws(() => require.resolve(name), `No CommonJS SDK resolution: ${name}`); + assert.throws(() => import.meta.resolve(name), `No ESM SDK resolution: ${name}`); +} +let directory = fileURLToPath(new URL("../", import.meta.url)); +let lock; +let lockPath; +while (!lock) { + lockPath = join(directory, "package-lock.json"); + try { + lock = await readFile(lockPath); + } catch (error) { + if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") + throw error; + const parent = dirname(directory); + assert.notEqual(parent, directory, "Native consumer requires its ordinary generated lock"); + directory = parent; + } +} +const records = JSON.parse(lock).packages; +for (const location of Object.keys(records)) + for (const name of sdks) + assert.ok( + !location.endsWith(`node_modules/${name}`), + `No installed SDK lock record: ${location}`, + ); +const lockSha256 = createHash("sha256").update(lock).digest("hex"); +for (const specifier of ["@askrjs/auth/browser", "@askrjs/auth/auth0"]) + assert.throws(() => import.meta.resolve(specifier), { code: "ERR_PACKAGE_PATH_NOT_EXPORTED" }); +const manifests = []; +for (const name of ["askr", "auth", "node", "schema", "server", "vite"]) { + const path = require.resolve(`@askrjs/${name}/package.json`); + const manifest = JSON.parse(await readFile(path, "utf8")); + manifests.push({ name: manifest.name, version: manifest.version, path: await realpath(path) }); +} +console.log( + JSON.stringify( + { + node: process.version, + resolved, + manifests, + lock: { path: lockPath, sha256: lockSha256 }, + sdkAbsent: true, + retiredBrowserPathsDenied: true, + }, + null, + 2, + ), +); diff --git a/provider-auth/scripts/typescript6.mjs b/provider-auth/scripts/typescript6.mjs new file mode 100644 index 0000000..5a0addf --- /dev/null +++ b/provider-auth/scripts/typescript6.mjs @@ -0,0 +1,2 @@ +// Resolve the declared compiler through Node in standalone and hoisted workspace installs. +import "typescript6/bin/tsc"; diff --git a/provider-auth/serve.mjs b/provider-auth/serve.mjs new file mode 100644 index 0000000..db6766e --- /dev/null +++ b/provider-auth/serve.mjs @@ -0,0 +1,22 @@ +import { app, environment, startHttps } from "./dist/server/production.js"; +const serving = await startHttps(app, environment); +console.log(`Provider application listening at ${environment.origin}`); +let closing; +function stop() { + closing ??= serving.close().then( + () => { + process.exitCode = 0; + }, + () => { + console.error("Provider application shutdown failed"); + process.exitCode = 1; + }, + ); + return closing; +} +process.once("SIGTERM", () => { + void stop(); +}); +process.once("SIGINT", () => { + void stop(); +}); diff --git a/provider-auth/src/actions.ts b/provider-auth/src/actions.ts new file mode 100644 index 0000000..1c8168c --- /dev/null +++ b/provider-auth/src/actions.ts @@ -0,0 +1,35 @@ +import { defineAction } from "@askrjs/askr/actions"; +import { schema } from "@askrjs/schema"; +export const readProviderAction = defineAction({ + id: "provider.read-summary", + input: schema.object({}), + invalidates: ["provider-private"], +}); +export interface ProviderSummary { + readonly message: string; +} +/** Resolve a fresh session-bound token for every submission; undefined never falls back to SSR. */ +export async function actionCsrf(signal: AbortSignal): Promise { + const response = await fetch("/api/action-csrf", { + signal, + credentials: "same-origin", + cache: "no-store", + redirect: "error", + }); + if (!response.ok) throw new Error("The action could not obtain CSRF protection"); + const body: unknown = await response.json(); + if ( + !body || + typeof body !== "object" || + Array.isArray(body) || + Object.keys(body).some((key) => key !== "csrfToken") + ) + throw new Error("Invalid action CSRF response"); + if (!("csrfToken" in body)) return undefined; + if (typeof body.csrfToken !== "string" || !/^[\x21-\x7e]+$/u.test(body.csrfToken)) + throw new Error("Invalid action CSRF response"); + return body.csrfToken; +} +export function actionProblem(error: unknown): unknown { + return error instanceof Error ? error.cause : undefined; +} diff --git a/provider-auth/src/client.tsx b/provider-auth/src/client.tsx new file mode 100644 index 0000000..5fa1004 --- /dev/null +++ b/provider-auth/src/client.tsx @@ -0,0 +1,17 @@ +import { createSPA, hydrateSPA } from "@askrjs/askr/boot"; +import { readPublicConfig } from "./public-config.js"; +import { createExampleRegistry } from "./registry.js"; +import "./styles.css"; +const root = document.getElementById("app"); +if (!root) throw new Error("Missing #app root element"); +let config; +try { + config = readPublicConfig(document); +} catch (error) { + root.replaceChildren(); + throw error; +} +const { registry, sessionAuth } = createExampleRegistry(config); +if (config.mode === "ssr") await hydrateSPA({ root, registry, auth: sessionAuth }); +else await createSPA({ root, registry, auth: sessionAuth }); +root.dataset.askrReady = "true"; diff --git a/provider-auth/src/private-query.ts b/provider-auth/src/private-query.ts new file mode 100644 index 0000000..d30e28b --- /dev/null +++ b/provider-auth/src/private-query.ts @@ -0,0 +1,28 @@ +import { defineQuery } from "@askrjs/askr/data"; +export const privateValue = Object.freeze({ + message: "Private query belongs to the current signed-in application", +}); +export function createPrivateQuery(onUnauthorized: () => void) { + return defineQuery, { readonly message: string }>({ + key: () => "provider-private", + fetch: async (_input, { signal }) => { + const response = await fetch("/api/private", { + signal, + credentials: "same-origin", + cache: "no-store", + redirect: "error", + }); + if (response.status === 401 && !signal.aborted) onUnauthorized(); + if (!response.ok) throw new Error("Private query is unavailable"); + const data: unknown = await response.json(); + if ( + !data || + typeof data !== "object" || + !("message" in data) || + typeof data.message !== "string" + ) + throw new Error("Invalid private query response"); + return { message: data.message }; + }, + }); +} diff --git a/provider-auth/src/public-config.ts b/provider-auth/src/public-config.ts new file mode 100644 index 0000000..0f076d3 --- /dev/null +++ b/provider-auth/src/public-config.ts @@ -0,0 +1,53 @@ +export type ProviderName = "auth0" | "microsoft" | "google" | "apple"; +export interface PublicConfig { + readonly provider: ProviderName; + readonly mode: "ssr" | "spa"; + readonly api?: { readonly resource: string; readonly scopes: readonly string[] }; +} +export const providerMarker = ''; +export const modeMarker = ''; +export function examplePublicConfig(provider: ProviderName, mode: "ssr" | "spa"): PublicConfig { + return { + provider, + mode, + ...(provider === "microsoft" + ? { + api: { + resource: "https://graph.microsoft.com", + scopes: ["https://graph.microsoft.com/User.Read"], + }, + } + : provider === "google" + ? { + api: { + resource: "https://www.googleapis.com", + scopes: ["https://www.googleapis.com/auth/calendar.calendarlist.readonly"], + }, + } + : {}), + }; +} +export function readPublicConfig(document: Document): PublicConfig { + if ( + document.querySelectorAll('meta[name="askr-example-provider"]').length !== 1 || + document.querySelectorAll('meta[name="askr-example-mode"]').length !== 1 + ) + throw new Error("Invalid provider application boot configuration"); + const provider = document + .querySelector('meta[name="askr-example-provider"]') + ?.getAttribute("content"); + const mode = document.querySelector('meta[name="askr-example-mode"]')?.getAttribute("content"); + if ( + !["auth0", "microsoft", "google", "apple"].includes(provider ?? "") || + (mode !== "ssr" && mode !== "spa") + ) + throw new Error("Invalid provider application boot configuration"); + if ( + provider !== "auth0" && + provider !== "microsoft" && + provider !== "google" && + provider !== "apple" + ) + throw new Error("Invalid provider application boot configuration"); + return examplePublicConfig(provider, mode); +} diff --git a/provider-auth/src/redirect.ts b/provider-auth/src/redirect.ts new file mode 100644 index 0000000..90b09ff --- /dev/null +++ b/provider-auth/src/redirect.ts @@ -0,0 +1,28 @@ +/** Same local path/query/fragment boundary used by the qualified api-ssr stage. */ +export function safeReturnTo(value: string | null | undefined, origin: string): string { + const fallback = "/account"; + if (!value?.startsWith("/") || value.startsWith("//")) return fallback; + const end = Math.min( + ...[value.indexOf("?"), value.indexOf("#")].map((index) => (index < 0 ? value.length : index)), + ); + const path = value.slice(0, end); + if (path.includes("\\")) return fallback; + try { + const decoded = decodeURIComponent(value); + const decodedPath = decodeURIComponent(path); + if ( + decodedPath.includes("\\") || + /[\x00-\x1f\x7f]/u.test(decoded) || + decodedPath.startsWith("//") || + /^(?:\/)*(?:[a-z][a-z\d+.-]*:)/iu.test(decodedPath) || + /(?:^|\/)\.\.(?:\/|$)/u.test(decodedPath) + ) + return fallback; + const base = new URL(origin); + const target = new URL(value, base); + if (target.origin !== base.origin || target.pathname.startsWith("//")) return fallback; + return `${target.pathname}${target.search}${target.hash}`; + } catch { + return fallback; + } +} diff --git a/provider-auth/src/registry.tsx b/provider-auth/src/registry.tsx new file mode 100644 index 0000000..facfe0e --- /dev/null +++ b/provider-auth/src/registry.tsx @@ -0,0 +1,143 @@ +import { + createRouteRegistry, + createSessionAuth, + currentAuth, + currentRoute, + Link, + route, +} from "@askrjs/askr/router"; +import { createQuery } from "@askrjs/askr/data"; +import { action } from "@askrjs/askr/actions"; +import { state } from "@askrjs/askr"; +import { requireUser } from "@askrjs/auth"; +import { readProviderAction, actionCsrf, actionProblem, type ProviderSummary } from "./actions.js"; +import { createPrivateQuery } from "./private-query.js"; +import { safeReturnTo } from "./redirect.js"; +import type { PublicConfig } from "./public-config.js"; + +export function createExampleRegistry(config: PublicConfig) { + const sessionAuth = createSessionAuth({ loginPath: "/login" }); + const privateQuery = createPrivateQuery(() => { + sessionAuth.handleAuthFailure({ + code: "interaction-required", + reason: "login", + provider: config.provider, + scopes: [], + }); + }); + function Home() { + return ( +
+

Northstar provider sessions

+

One server authority, four explicit providers.

+ Open private account + Sign in +
+ ); + } + function Login() { + const location = currentRoute(); + const next = safeReturnTo(location.query.get("next"), "https://askr.invalid"); + // Google and Auth0 request API consent only after a server-owned action needs it. + const href = sessionAuth.loginHref(config.provider, { + returnTo: next, + ...(config.provider === "microsoft" && config.api + ? { resource: config.api.resource, scopes: config.api.scopes } + : {}), + }); + return ( +
+

Sign in to Northstar

+

+ Provider: {config.provider} +

+ + Continue with {config.provider} + + Home +
+ ); + } + function Account() { + const query = createQuery(privateQuery, {}); + const operation = action, ProviderSummary>(readProviderAction, { + csrfToken: actionCsrf, + }); + const status = state(""); + const auth = currentAuth(); + const submit = async (event: Event) => { + event.preventDefault(); + try { + await operation.submit({}); + } catch (error) { + if (!sessionAuth.handleAuthFailure(actionProblem(error))) + status.set("The provider operation could not be completed."); + } + }; + const refresh = async () => { + try { + await query.refresh(); + } catch (error) { + if (!(error instanceof Error && error.name === "AbortError")) + status.set("Private query refresh failed."); + } + }; + const logout = async () => { + try { + await sessionAuth.logout(); + } catch { + status.set("Sign out could not be confirmed. Reload before retrying."); + } + }; + return ( +
+

Private account

+

{auth.principal?.subject ?? "Signed-in identity"}

+

+ {query.loading + ? "Loading private query…" + : (query.data?.message ?? "Private query is unavailable")} +

+ Account details + Account overview + +
+ +
+ {operation.state().result ? ( +

+ {operation.state().result?.message} +

+ ) : null} + +

{status()}

+
+ ); + } + const registry = createRouteRegistry( + () => { + route("/", Home); + route("/login", Login); + for (const path of ["/account", "/account/details"]) + route(path, Account, { + auth: requireUser(), + actions: [readProviderAction], + preload: ({ data }) => data.prefetch(privateQuery, {}), + }); + route("/*", () => ( +
+

Page not found

+ Home +
+ )); + }, + { auth: sessionAuth }, + ); + return Object.freeze({ registry, sessionAuth, privateQuery }); +} diff --git a/provider-auth/src/server/app.ts b/provider-auth/src/server/app.ts new file mode 100644 index 0000000..0aec1ca --- /dev/null +++ b/provider-auth/src/server/app.ts @@ -0,0 +1,126 @@ +import { createAskrApp, handleAction } from "@askrjs/server/askr"; +import { createCsrfToken, securityHeaders } from "@askrjs/server/middleware"; +import { defineServerQueries, serveQuery } from "@askrjs/askr/data"; +import { requireUser } from "@askrjs/auth"; +import { security } from "@askrjs/server/openapi"; +import { examplePublicConfig } from "../public-config.js"; +import { schema } from "@askrjs/schema"; +import { createExampleRegistry } from "../registry.js"; +import { readProviderAction } from "../actions.js"; +import { privateValue } from "../private-query.js"; +import { selectedProvider } from "./providers.js"; +import { createDisplayProfiles } from "./profile-store.js"; +import type { ProviderEnvironment } from "./environment.js"; +import type { Middleware } from "@askrjs/server"; + +/** Application services are explicit server-owned dependencies; production uses native fetch/clock. */ +export interface ApplicationServices { + readonly fetch?: typeof globalThis.fetch; + readonly clock?: () => number; +} +export async function createExampleApp( + env: ProviderEnvironment, + services: ApplicationServices = {}, +) { + const transport = services.fetch ?? globalThis.fetch; + const clock = services.clock ?? Date.now; + const provider = await selectedProvider(env); + const profiles = createDisplayProfiles(clock); + const publicConfig = examplePublicConfig(env.provider, env.mode); + const { registry, sessionAuth, privateQuery } = createExampleRegistry(publicConfig); + const spaDocument: Middleware = async (context, next) => { + if ( + env.mode === "spa" && + ["GET", "HEAD"].includes(context.request.method) && + ["/", "/login", "/account", "/account/details"].includes(context.url.pathname) + ) + return new Response(null, { + headers: { + "content-type": "text/html; charset=utf-8; askr-fragment=1", + "cache-control": "no-store", + }, + }); + return next(); + }; + const app = createAskrApp({ + name: "Northstar provider sessions", + version: "1.0.0", + dependencies: { env, transport, profiles }, + pages: registry, + auth: { + origin: env.origin, + providers: [provider], + session: { secrets: env.sessionSecrets }, + clock, + fetch: transport, + onSignIn({ principal, profileHint }) { + profiles.remember(principal.id, principal, profileHint); + }, + }, + queryRegistry: defineServerQueries(serveQuery(privateQuery, () => privateValue)), + api: { + securitySchemes: { providerSession: security.apiKey("__Host-askr-session", "cookie") }, + define(api) { + api + .get("/action-csrf", async (context) => + context.json( + context.auth.authenticated && context.auth.session + ? { + csrfToken: await createCsrfToken(env.actionCsrfSecret, context.auth.session.id), + } + : {}, + { headers: { "cache-control": "no-store" } }, + ), + ) + .operationId("actionCsrf") + .ok(schema.object({ csrfToken: schema.optional(schema.string()) })); + api + .get("/private", (context) => + context.json(privateValue, { headers: { "cache-control": "no-store" } }), + ) + .operationId("privateQuery") + .access(requireUser(), security.require("providerSession")) + .ok(schema.object({ message: schema.string() })); + }, + }, + actions: { + csrf: { secret: env.actionCsrfSecret }, + handlers: [ + handleAction(readProviderAction, async (context) => { + if (!context.auth.principal) throw new Error("A signed-in identity is required"); + if (!env.api) + return { + result: { + message: + profiles.label(context.auth.principal.id) ?? + "The server confirmed the provider identity.", + }, + }; + const token = await context.auth.token(env.provider, { + resource: env.api.resource, + scopes: env.api.scopes, + signal: context.signal, + }); + const response = await transport(env.api.url, { + headers: { authorization: `Bearer ${token}`, accept: "application/json" }, + redirect: "error", + signal: context.signal, + }); + if (!response.ok) throw new Error("The provider API could not be completed"); + // Display an application-owned outcome; neither arbitrary upstream bodies nor bearer tokens cross the boundary. + await response.body?.cancel(); + return { result: { message: "The provider API accepted the server-owned permission." } }; + }), + ], + }, + middleware: [ + securityHeaders({ + contentSecurityPolicy: + "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'", + }), + spaDocument, + ], + close: () => profiles.close(), + }); + return Object.freeze({ app, registry, sessionAuth, publicConfig }); +} diff --git a/provider-auth/src/server/compose.ts b/provider-auth/src/server/compose.ts new file mode 100644 index 0000000..fb81fbe --- /dev/null +++ b/provider-auth/src/server/compose.ts @@ -0,0 +1,44 @@ +import type { AskrApp } from "@askrjs/server/askr"; +import { providerMarker, modeMarker, type PublicConfig } from "../public-config.js"; +/** virtual:askr-server and raw are two views of the exact same source app singleton. */ +export function withApplicationLifecycle( + composed: Pick, + raw: AskrApp, + config: PublicConfig, +): AskrApp { + return Object.freeze({ + async fetch(request: Request) { + const response = await composed.fetch(request); + if (response.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() !== "text/html") + return response; + const headers = new Headers(response.headers); + headers.delete("content-length"); + headers.delete("etag"); + headers.set("cache-control", "no-store"); + if (request.method === "HEAD") + return new Response(null, { + status: response.status, + statusText: response.statusText, + headers, + }); + let html = await response.text(); + for (const [marker, value] of [ + [providerMarker, config.provider], + [modeMarker, config.mode], + ] as const) { + if (html.split(marker).length !== 2) + throw new Error( + "The authored provider document must contain each boot marker exactly once", + ); + html = html.replace(marker, marker.replace(/__ASKR_(?:PROVIDER|MODE)__/u, value)); + } + return new Response(html, { + status: response.status, + statusText: response.statusText, + headers, + }); + }, + close: () => raw.close(), + toOpenApiDocument: () => raw.toOpenApiDocument(), + }); +} diff --git a/provider-auth/src/server/entry-server.ts b/provider-auth/src/server/entry-server.ts new file mode 100644 index 0000000..ebe422f --- /dev/null +++ b/provider-auth/src/server/entry-server.ts @@ -0,0 +1,6 @@ +import { readProviderEnvironment } from "./environment.js"; +import { createExampleApp } from "./app.js"; +export const environment = readProviderEnvironment(process.env); +const application = await createExampleApp(environment); +export const app = application.app; +export default app; diff --git a/provider-auth/src/server/environment.ts b/provider-auth/src/server/environment.ts new file mode 100644 index 0000000..9a1295f --- /dev/null +++ b/provider-auth/src/server/environment.ts @@ -0,0 +1,242 @@ +import { isIP } from "node:net"; + +export interface ProviderApi { + readonly resource: string; + readonly scopes: readonly string[]; + readonly url: string; +} + +interface ApplicationEnvironment { + readonly mode: "ssr" | "spa"; + readonly origin: string; + readonly listenPort: number; + readonly sessionSecrets: readonly string[]; + readonly actionCsrfSecret: string; + readonly tls: { + readonly certPath: string; + readonly keyPath: string; + }; + readonly api?: ProviderApi; +} + +export type ProviderEnvironment = ApplicationEnvironment & + ( + | { + readonly provider: "auth0"; + readonly credentials: { + readonly domain: string; + readonly clientId: string; + readonly clientSecret: string; + readonly organization?: string; + }; + } + | { + readonly provider: "microsoft"; + readonly credentials: { + readonly tenantId: string; + readonly clientId: string; + readonly clientSecret: string; + }; + } + | { + readonly provider: "google"; + readonly credentials: { + readonly clientId: string; + readonly clientSecret: string; + readonly hostedDomain?: string; + }; + } + | { + readonly provider: "apple"; + readonly credentials: { + readonly servicesId: string; + readonly teamId: string; + readonly keyId: string; + readonly privateKeyPath: string; + }; + } + ); + +/** Read server-only configuration. Error messages name keys and never values. */ +export function readProviderEnvironment( + env: Readonly>, +): ProviderEnvironment { + const provider = env.ASKR_PROVIDER; + if ( + provider !== "auth0" && + provider !== "microsoft" && + provider !== "google" && + provider !== "apple" + ) { + throw new Error("ASKR_PROVIDER must select auth0, microsoft, google or apple"); + } + const mode = env.ASKR_RENDER_MODE ?? "ssr"; + if (mode !== "ssr" && mode !== "spa") { + throw new Error("ASKR_RENDER_MODE must select ssr or spa"); + } + const publicOrigin = required(env, "ASKR_ORIGIN"); + const publicUrl = httpsUrl(publicOrigin, "ASKR_ORIGIN"); + if ( + !/^https:\/\/[^/\\?#\s]+\/?$/i.test(publicOrigin) || + publicUrl.pathname !== "/" || + publicUrl.search || + publicUrl.hash + ) { + throw new Error("ASKR_ORIGIN must contain only an HTTPS origin"); + } + const hostname = publicUrl.hostname.replace(/^\[|\]$/g, "").replace(/\.+$/, ""); + if ( + provider === "apple" && + (isIP(hostname) !== 0 || hostname === "localhost" || hostname.endsWith(".localhost")) + ) { + throw new Error("ASKR_ORIGIN requires a registered public hostname for Apple"); + } + const listenPort = env.ASKR_LISTEN_PORT ?? "3443"; + if (!/^[1-9][0-9]{0,4}$/.test(listenPort) || Number(listenPort) > 65535) { + throw new Error("ASKR_LISTEN_PORT must be a port from 1 to 65535"); + } + const sessionSecrets = required(env, "ASKR_SESSION_SECRETS").split(","); + if ( + sessionSecrets.length > 4 || + new Set(sessionSecrets).size !== sessionSecrets.length || + sessionSecrets.some((secret) => !isSessionSecret(secret)) + ) { + throw new Error( + "ASKR_SESSION_SECRETS must contain one to four distinct 32-byte base64url keys in rotation order", + ); + } + const common: ApplicationEnvironment = { + mode, + origin: publicUrl.origin, + listenPort: Number(listenPort), + sessionSecrets, + actionCsrfSecret: required(env, "ASKR_ACTION_CSRF_SECRET"), + tls: { + certPath: required(env, "ASKR_TLS_CERT_PATH"), + keyPath: required(env, "ASKR_TLS_KEY_PATH"), + }, + }; + switch (provider) { + case "auth0": { + const apiKeys = ["AUTH0_API_AUDIENCE", "AUTH0_API_SCOPES", "AUTH0_API_URL"] as const; + const hasApi = apiKeys.some((key) => env[key] !== undefined); + return { + ...common, + provider, + credentials: { + domain: required(env, "AUTH0_DOMAIN"), + clientId: required(env, "AUTH0_CLIENT_ID"), + clientSecret: required(env, "AUTH0_CLIENT_SECRET"), + ...(env.AUTH0_ORGANIZATION === undefined + ? {} + : { organization: required(env, "AUTH0_ORGANIZATION") }), + }, + ...(hasApi + ? { + api: { + resource: resource(required(env, "AUTH0_API_AUDIENCE"), "AUTH0_API_AUDIENCE"), + scopes: scopes(required(env, "AUTH0_API_SCOPES"), "AUTH0_API_SCOPES"), + url: httpsUrl(required(env, "AUTH0_API_URL"), "AUTH0_API_URL").href, + }, + } + : {}), + }; + } + case "microsoft": + return { + ...common, + provider, + credentials: { + tenantId: required(env, "MICROSOFT_TENANT_ID"), + clientId: required(env, "MICROSOFT_CLIENT_ID"), + clientSecret: required(env, "MICROSOFT_CLIENT_SECRET"), + }, + api: { + resource: "https://graph.microsoft.com", + scopes: ["https://graph.microsoft.com/User.Read"], + url: "https://graph.microsoft.com/v1.0/me?$select=displayName", + }, + }; + case "google": + return { + ...common, + provider, + credentials: { + clientId: required(env, "GOOGLE_CLIENT_ID"), + clientSecret: required(env, "GOOGLE_CLIENT_SECRET"), + ...(env.GOOGLE_HOSTED_DOMAIN === undefined + ? {} + : { hostedDomain: required(env, "GOOGLE_HOSTED_DOMAIN") }), + }, + api: { + resource: "https://www.googleapis.com", + scopes: ["https://www.googleapis.com/auth/calendar.calendarlist.readonly"], + url: "https://www.googleapis.com/calendar/v3/users/me/calendarList", + }, + }; + case "apple": + return { + ...common, + provider, + credentials: { + servicesId: required(env, "APPLE_SERVICES_ID"), + teamId: required(env, "APPLE_TEAM_ID"), + keyId: required(env, "APPLE_KEY_ID"), + privateKeyPath: required(env, "APPLE_PRIVATE_KEY_PATH"), + }, + }; + } +} + +function required(env: Readonly>, key: string): string { + const value = env[key]; + if (value === undefined || value.length === 0 || value.trim().length === 0) { + throw new Error(`${key} is required`); + } + return value; +} + +function httpsUrl(value: string, key: string): URL { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new Error(`${key} must be an HTTPS URL`); + } + if ( + /[\x00-\x20\x7f]/.test(value) || + parsed.protocol !== "https:" || + parsed.username || + parsed.password || + /^https:\/\/[^/?#]*@/i.test(value) || + value.includes("#") + ) { + throw new Error(`${key} must be an HTTPS URL without credentials`); + } + return parsed; +} + +function isSessionSecret(value: string): boolean { + if (!/^[A-Za-z0-9_-]{43}$/.test(value)) return false; + const bytes = Buffer.from(value, "base64url"); + return bytes.length === 32 && bytes.toString("base64url") === value; +} + +function resource(value: string, key: string): string { + if (value.length > 2048 || !/^[\x21-\x7e]+$/.test(value)) { + throw new Error(`${key} must be a nonempty resource identifier`); + } + return value; +} + +function scopes(value: string, key: string): readonly string[] { + const parts = value.split(" "); + if ( + parts.length > 32 || + parts.some((part) => !/^[\x21\x23-\x5b\x5d-\x7e]+$/.test(part)) || + new Set(parts).size !== parts.length + ) { + throw new Error(`${key} must contain distinct space-separated OAuth scopes`); + } + return parts; +} diff --git a/provider-auth/src/server/https.ts b/provider-auth/src/server/https.ts new file mode 100644 index 0000000..880284e --- /dev/null +++ b/provider-auth/src/server/https.ts @@ -0,0 +1,172 @@ +import { createServer } from "node:https"; +import { readFile, readdir, lstat } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { join } from "node:path"; +import { createNodeHandler } from "@askrjs/node"; +import type { AskrApp } from "@askrjs/server/askr"; +import type { ProviderEnvironment } from "./environment.js"; + +const mime: Readonly> = { + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".webp": "image/webp", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".woff2": "font/woff2", +}; +export async function publicAssets(directory: string) { + const entries = new Map; type: string }>(); + let total = 0; + async function visit(path: string, parts: string[]) { + const info = await lstat(path); + if (info.isSymbolicLink()) throw new Error("Public assets must not contain symlinks"); + if (info.isDirectory()) { + for (const name of await readdir(path)) await visit(join(path, name), [...parts, name]); + return; + } + if (!info.isFile()) throw new Error("Public assets must be regular files"); + if (parts.at(-1)?.endsWith(".map")) return; + const extension = /\.[^.]+$/u.exec(parts.at(-1) ?? "")?.[0]; + if (!extension || !mime[extension]) throw new Error("Unsupported public asset type"); + total += info.size; + if (entries.size >= 512 || total > 32 * 1024 * 1024) + throw new Error("The example public asset map is too large"); + entries.set(`/assets/${parts.map(encodeURIComponent).join("/")}`, { + body: new Uint8Array(await readFile(path)), + type: mime[extension], + }); + } + await visit(directory, []); + return entries; +} +export async function startHttps( + app: AskrApp, + env: Pick, + assetsDirectory = fileURLToPath(new URL("../assets/", import.meta.url)), +) { + let server: ReturnType | undefined; + try { + const assets = await publicAssets(assetsDirectory); + const controllers = new Set(); + const active = new Set>(); + let closing = false; + const combined = { + fetch(request: Request) { + if (closing) return Promise.resolve(new Response("Server is closing", { status: 503 })); + const controller = new AbortController(); + controllers.add(controller); + const owned = new Request(request, { + signal: AbortSignal.any([request.signal, controller.signal]), + }); + const work = (async () => { + const path = new URL(owned.url).pathname; + if (path.startsWith("/assets/")) { + if (!["GET", "HEAD"].includes(owned.method)) + return new Response(null, { status: 405, headers: { allow: "GET, HEAD" } }); + const entry = assets.get(path); + return entry + ? new Response(owned.method === "HEAD" ? null : entry.body.slice(), { + headers: { "content-type": entry.type, "x-content-type-options": "nosniff" }, + }) + : new Response(null, { status: 404 }); + } + return app.fetch(owned); + })(); + active.add(work); + void work + .finally(() => { + controllers.delete(controller); + active.delete(work); + }) + .catch(() => undefined); + return work; + }, + }; + const listening = createServer( + { cert: await readFile(env.tls.certPath), key: await readFile(env.tls.keyPath) }, + createNodeHandler(combined, { + baseUrl: env.origin, + allowedHosts: [new URL(env.origin).host], + }), + ); + server = listening; + await new Promise((resolve, reject) => { + listening.once("error", reject); + listening.listen(env.listenPort, "127.0.0.1", () => { + listening.off("error", reject); + resolve(); + }); + }); + let shutdown: Promise | undefined; + return { + server: listening, + close() { + return (shutdown ??= (async () => { + closing = true; + for (const controller of controllers) controller.abort(); + const errors: unknown[] = []; + const listener = new Promise((resolve, reject) => + listening.close((error) => (error ? reject(error) : resolve())), + ); + // Start sole engine cleanup even if listener drain stalls. Every promise gets an immediate rejection observer. + const engine = Promise.resolve().then(() => app.close()); + const drain = Promise.allSettled([...active, listener, engine]); + let timer: ReturnType | undefined; + try { + const completed = await Promise.race([ + drain, + new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), 2000); + }), + ]); + if (completed === undefined) { + listening.closeAllConnections(); + errors.push(new Error("HTTPS shutdown exceeded its owned drain bound")); + } else + for (const result of completed) + if (result.status === "rejected") errors.push(result.reason); + } finally { + if (timer) clearTimeout(timer); + listening.closeAllConnections(); + } + if (errors.length) + throw new AggregateError(errors, "Provider application shutdown failed"); + })()); + }, + }; + } catch (startupError) { + // Ownership begins at entry: assets, TLS or listen failure must also close the sole app. + const engine = Promise.resolve().then(() => app.close()); + const listener = server; + listener?.closeAllConnections(); + const closed = listener + ? new Promise((resolve) => listener.close(() => resolve())) + : Promise.resolve(); + let timer: ReturnType | undefined; + try { + const result = await Promise.race([ + Promise.allSettled([engine, closed]), + new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), 2000); + }), + ]); + const failures = + result === undefined + ? [new Error("HTTPS startup cleanup exceeded its owned drain bound")] + : result + .filter((value): value is PromiseRejectedResult => value.status === "rejected") + .map((value) => value.reason); + if (failures.length) + throw new AggregateError( + [startupError, ...failures], + "Provider application startup failed", + ); + } finally { + if (timer) clearTimeout(timer); + listener?.closeAllConnections(); + } + throw startupError; + } +} diff --git a/provider-auth/src/server/production.ts b/provider-auth/src/server/production.ts new file mode 100644 index 0000000..99e6731 --- /dev/null +++ b/provider-auth/src/server/production.ts @@ -0,0 +1,10 @@ +import composed from "virtual:askr-server"; +import raw, { environment } from "./entry-server.js"; +import { withApplicationLifecycle } from "./compose.js"; +export { environment }; +export { startHttps } from "./https.js"; +export const app = withApplicationLifecycle(composed, raw, { + provider: environment.provider, + mode: environment.mode, +}); +export default app; diff --git a/provider-auth/src/server/profile-store.ts b/provider-auth/src/server/profile-store.ts new file mode 100644 index 0000000..17ad64e --- /dev/null +++ b/provider-auth/src/server/profile-store.ts @@ -0,0 +1,38 @@ +/** Single-process display storage, never a source of authentication or permissions. */ +export function createDisplayProfiles(clock: () => number) { + const values = new Map(); + const prune = () => { + for (const [key, value] of values) if (value.expiresAt <= clock()) values.delete(key); + }; + return { + remember( + id: string, + principal: Readonly>, + hint?: { givenName?: string; familyName?: string }, + ) { + prune(); + const name = [hint?.givenName, hint?.familyName] + .filter((part): part is string => typeof part === "string") + .join(" ") + .slice(0, 128); + const email = + principal.email_verified === true && typeof principal.email === "string" + ? principal.email.slice(0, 128) + : ""; + const label = name || email; + if (!label) return; + if (!values.has(id) && values.size >= 1000) { + const oldest = values.keys().next().value; + if (oldest !== undefined) values.delete(oldest); + } + values.set(id, { label, expiresAt: clock() + 8 * 60 * 60 * 1000 }); + }, + label(id: string) { + prune(); + return values.get(id)?.label; + }, + close() { + values.clear(); + }, + }; +} diff --git a/provider-auth/src/server/providers.ts b/provider-auth/src/server/providers.ts new file mode 100644 index 0000000..49ef9a8 --- /dev/null +++ b/provider-auth/src/server/providers.ts @@ -0,0 +1,35 @@ +import { readFile } from "node:fs/promises"; +import { createPrivateKey } from "node:crypto"; +import { createAuth0Provider } from "@askrjs/auth/providers/auth0"; +import { createMicrosoftProvider } from "@askrjs/auth/providers/microsoft"; +import { createGoogleProvider } from "@askrjs/auth/providers/google"; +import { createAppleProvider } from "@askrjs/auth/providers/apple"; +import type { ProviderEnvironment } from "./environment.js"; +export async function selectedProvider(env: ProviderEnvironment) { + const resources = env.api ? [{ resource: env.api.resource, scopes: env.api.scopes }] : []; + switch (env.provider) { + case "auth0": + return createAuth0Provider({ id: "auth0", ...env.credentials, resources }); + case "microsoft": + return createMicrosoftProvider({ id: "microsoft", ...env.credentials, resources }); + case "google": + return createGoogleProvider({ id: "google", ...env.credentials, resources }); + case "apple": { + let privateKey: JsonWebKey; + try { + privateKey = createPrivateKey(await readFile(env.credentials.privateKeyPath)).export({ + format: "jwk", + }); + } catch { + throw new Error("APPLE_PRIVATE_KEY_PATH must contain a server-readable P-256 private key"); + } + return createAppleProvider({ + id: "apple", + servicesId: env.credentials.servicesId, + teamId: env.credentials.teamId, + keyId: env.credentials.keyId, + privateKey, + }); + } + } +} diff --git a/provider-auth/src/styles.css b/provider-auth/src/styles.css new file mode 100644 index 0000000..a0138ba --- /dev/null +++ b/provider-auth/src/styles.css @@ -0,0 +1,29 @@ +html { + font: + 16px/1.6 system-ui, + sans-serif; + color: #142c38; + background: #f4f7f8; +} +body { + margin: 0; +} +main { + max-width: 48rem; + margin: 4rem auto; + padding: 2rem; + background: white; + border: 1px solid #d4e0e5; + border-radius: 1rem; +} +a, +button { + margin: 0.4rem 0.8rem 0.4rem 0; +} +button { + padding: 0.6rem 1rem; + cursor: pointer; +} +[role="alert"] { + color: #9c2531; +} diff --git a/provider-auth/src/vite-server.d.ts b/provider-auth/src/vite-server.d.ts new file mode 100644 index 0000000..2984585 --- /dev/null +++ b/provider-auth/src/vite-server.d.ts @@ -0,0 +1,3 @@ +/// + +declare module "*.css"; diff --git a/provider-auth/tests/action-csrf.test.ts b/provider-auth/tests/action-csrf.test.ts new file mode 100644 index 0000000..e1a67e3 --- /dev/null +++ b/provider-auth/tests/action-csrf.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { actionCsrf, actionProblem } from "../src/actions.js"; +afterEach(() => vi.unstubAllGlobals()); +describe("fresh action CSRF transport", () => { + it("uses a fresh same-origin no-store request and the supplied cancellation signal for every submit", async () => { + const transport = vi + .fn() + .mockResolvedValueOnce(Response.json({ csrfToken: "first" })) + .mockResolvedValueOnce(Response.json({ csrfToken: "second" })) + .mockResolvedValueOnce(Response.json({})); + vi.stubGlobal("fetch", transport); + const signal = new AbortController().signal; + expect(await actionCsrf(signal)).toBe("first"); + expect(await actionCsrf(signal)).toBe("second"); + expect(await actionCsrf(signal)).toBeUndefined(); + expect(transport).toHaveBeenCalledTimes(3); + for (const call of transport.mock.calls) + expect(call).toEqual([ + "/api/action-csrf", + { signal, credentials: "same-origin", cache: "no-store", redirect: "error" }, + ]); + }); + it.each([null, [], { session: "private" }, { csrfToken: "" }, { csrfToken: "bad token" }])( + "rejects malformed responses without a fallback token", + async (body) => { + vi.stubGlobal("fetch", async () => Response.json(body)); + await expect(actionCsrf(new AbortController().signal)).rejects.toThrow( + "Invalid action CSRF response", + ); + }, + ); + it("passes only the decoded action cause to explicit recovery", () => { + const cause = { code: "interaction-required", reason: "consent" }; + expect(actionProblem(new Error("Action failed", { cause }))).toBe(cause); + expect(actionProblem(cause)).toBeUndefined(); + }); +}); diff --git a/provider-auth/tests/browser/app-host.ts b/provider-auth/tests/browser/app-host.ts new file mode 100644 index 0000000..d5fa100 --- /dev/null +++ b/provider-auth/tests/browser/app-host.ts @@ -0,0 +1,5 @@ +// Fixture entry is built by Vite+ against the same ordinary installed producer graph. +export { createExampleApp } from "../../src/server/app.js"; +export { withApplicationLifecycle } from "../../src/server/compose.js"; +export { startHttps } from "../../src/server/https.js"; +export { createDocumentApp } from "@askrjs/vite/server"; diff --git a/provider-auth/tests/browser/loopback-proxy.ts b/provider-auth/tests/browser/loopback-proxy.ts new file mode 100644 index 0000000..e8d6fd8 --- /dev/null +++ b/provider-auth/tests/browser/loopback-proxy.ts @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { connect, type Socket } from "node:net"; +import { bounded } from "../signed-provider.js"; + +/** Native CONNECT tunnels preserve HTTPS origins, redirects and browser cookie handling. + * Only fixture-owned loopback listeners are reachable; there is no DNS or external fallback. + * Provider listeners independently enforce the exact signed-fixture endpoint allowlist. + */ +export async function createLoopbackProxy() { + let destinations: ReadonlyMap | undefined; + let closing = false; + let closed: Promise | undefined; + const sockets = new Set(); + const failures: string[] = []; + const server = createServer((request, response) => { + failures.push(`Unexpected non-CONNECT request: ${request.url}`); + response.writeHead(403).end(); + }); + server.on("connection", (socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + socket.on("error", () => socket.destroy()); + }); + server.on("connect", (request, client, head) => { + const destination = request.url && destinations?.get(request.url); + if (!destination || closing) { + if (!closing) failures.push(`Unknown CONNECT destination: ${request.url}`); + client.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n"); + return; + } + const upstream = connect({ host: "127.0.0.1", port: destination }); + sockets.add(upstream); + upstream.once("close", () => sockets.delete(upstream)); + upstream.on("error", (error) => { + if (!closing) failures.push(`Owned CONNECT failed: ${error.message}`); + client.destroy(); + }); + client.once("close", () => upstream.destroy()); + upstream.once("close", () => client.destroy()); + upstream.once("connect", () => { + client.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + if (head.length) upstream.write(head); + client.pipe(upstream); + upstream.pipe(client); + }); + }); + await bounded( + new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }), + "browser proxy listen", + ); + const address = server.address(); + assert.ok(address && typeof address === "object"); + return { + url: `http://127.0.0.1:${address.port}`, + bind(owned: ReadonlyMap) { + assert.equal(destinations, undefined, "A proxy belongs to exactly one signed fixture"); + destinations = new Map(owned); + }, + close() { + return (closed ??= (async () => { + closing = true; + const stopped = new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + for (const socket of sockets) socket.destroy(); + await bounded(stopped, "browser proxy shutdown"); + assert.deepEqual(failures, []); + })()); + }, + }; +} diff --git a/provider-auth/tests/browser/playwright.config.ts b/provider-auth/tests/browser/playwright.config.ts new file mode 100644 index 0000000..6e7f493 --- /dev/null +++ b/provider-auth/tests/browser/playwright.config.ts @@ -0,0 +1,14 @@ +import { defineConfig, devices } from "@playwright/test"; +export default defineConfig({ + testDir: ".", + testMatch: "**/*.spec.ts", + fullyParallel: false, + workers: 1, + reporter: "list", + use: { trace: "retain-on-failure" }, + projects: [ + { name: "chromium", use: { ...devices["Desktop Chrome"] } }, + { name: "firefox", use: { ...devices["Desktop Firefox"] } }, + { name: "webkit", use: { ...devices["Desktop Safari"] } }, + ], +}); diff --git a/provider-auth/tests/browser/provider-auth.spec.ts b/provider-auth/tests/browser/provider-auth.spec.ts new file mode 100644 index 0000000..fb6d95c --- /dev/null +++ b/provider-auth/tests/browser/provider-auth.spec.ts @@ -0,0 +1,588 @@ +import { readFile, readdir } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; +import { join } from "node:path"; +import { test as base, expect } from "@playwright/test"; +import { createLoopbackProxy } from "./loopback-proxy.js"; +import type { + BrowserContext, + Page, + ElementHandle, + Request as BrowserRequest, +} from "@playwright/test"; +import { + signedProviderFixture, + bounded, + canaries, + type FixtureOwners, +} from "../signed-provider.js"; +import type { ProviderName } from "../../src/public-config.js"; + +const test = base.extend<{ browserWire: Awaited> }>({ + // Playwright discovers fixture dependencies through this required object pattern. + // eslint-disable-next-line no-empty-pattern + browserWire: async ({}, use) => { + const proxy = await createLoopbackProxy(); + try { + await use(proxy); + } finally { + await proxy.close(); + } + }, + proxy: async ({ browserWire }, use) => use({ server: browserWire.url }), + // This context can reach only the owned CONNECT listeners. Their ephemeral + // fixture CA is not installed in OS/browser trust. Node's pinned CA remains strict. + ignoreHTTPSErrors: true, +}); + +async function bundleText(directory: string): Promise { + let result = ""; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) result += await bundleText(path); + else if (entry.name.endsWith(".js") || entry.name.endsWith(".css")) + result += await readFile(path, "utf8"); + } + return result; +} +async function start( + context: BrowserContext, + provider: ProviderName, + mode: "ssr" | "spa", + browserWire: Awaited>, + invalidBoot = false, +) { + const imported: unknown = await import( + pathToFileURL(join(process.cwd(), "dist/fixture/app-host.js")).href + ); + if ( + !imported || + typeof imported !== "object" || + !("createExampleApp" in imported) || + !("withApplicationLifecycle" in imported) || + !("startHttps" in imported) || + !("createDocumentApp" in imported) + ) + throw new Error("Build the actual fixture owner entry before browser acceptance"); + // Runtime shape validation is a dynamic built-module boundary, not a producer API cast. + if ( + typeof imported.createExampleApp !== "function" || + typeof imported.withApplicationLifecycle !== "function" || + typeof imported.startHttps !== "function" || + typeof imported.createDocumentApp !== "function" + ) + throw new Error("Invalid built fixture owner entry"); + const built = imported as FixtureOwners; + const owners: FixtureOwners = invalidBoot + ? { + ...built, + withApplicationLifecycle(...args) { + const app = built.withApplicationLifecycle(...args); + return { + ...app, + async fetch(request) { + const response = await app.fetch(request); + if (!response.headers.get("content-type")?.startsWith("text/html")) return response; + const headers = new Headers(response.headers); + headers.delete("content-length"); + return new Response( + (await response.text()).replace( + 'name="askr-example-provider" content="google"', + 'name="askr-example-provider" content="foreign"', + ), + { status: response.status, statusText: response.statusText, headers }, + ); + }, + }; + }, + } + : built; + const document = await readFile(join(process.cwd(), "dist/index.html"), "utf8"); + const client = await bundleText(join(process.cwd(), "dist/assets")); + for (const canary of [ + ...canaries, + "OWNED_ACTION_CSRF_SECRET", + "BEGIN PRIVATE KEY", + "client_secret", + "privateKeyPath", + ]) + expect(client).not.toContain(canary); + const f = await signedProviderFixture( + provider, + mode, + owners, + document, + join(process.cwd(), "dist/assets"), + "https://app.example.test", + true, + ); + browserWire.bind(f.browserDestinations); + try { + for (const secret of f.secrets()) expect(client).not.toContain(secret); + const sessionGets: string[] = []; + const actionRequests: string[] = []; + const unauthorizedPrivateRequests = new Set(); + const browserErrors: string[] = []; + const consoleMessages: string[] = []; + let scriptGate: Promise | undefined; + let releaseScripts: (() => void) | undefined; + let privateHold: + | { + entered: Promise; + enter(): void; + release(): void; + released: Promise; + request?: BrowserRequest; + expectedCancellation: boolean; + cancelled: Promise; + cancel(): void; + } + | undefined; + context.on("response", (response) => { + const url = new URL(response.url()); + if (response.status() === 401 && url.origin === f.origin && url.pathname === "/api/private") + unauthorizedPrivateRequests.add(response.request()); + }); + context.on("requestfailed", (request) => { + if (privateHold?.request === request && privateHold.expectedCancellation) + privateHold.cancel(); + // Retirement aborts the exact request whose 401 exposed the expired session. + else if (unauthorizedPrivateRequests.delete(request)) return; + else browserErrors.push(`Unexpected browser request failure: ${request.url()}`); + }); + function observe(page: Page) { + page.on("pageerror", (error) => browserErrors.push(error.message)); + page.on("console", (message) => consoleMessages.push(message.text())); + } + context.on("page", observe); + for (const page of context.pages()) observe(page); + context.on("request", (request) => { + const url = new URL(request.url()); + if (!["data:", "about:"].includes(url.protocol)) + try { + f.assertBrowserDestination(request.url()); + } catch (error) { + browserErrors.push(error instanceof Error ? error.message : String(error)); + } + if (url.origin === f.origin && url.pathname === "/auth/session" && request.method() === "GET") + sessionGets.push(request.url()); + if ( + url.origin === f.origin && + request.method() === "POST" && + request.headers()["x-askr-action"] === "provider.read-summary" + ) + actionRequests.push(request.url()); + }); + await context.route( + (url) => + url.origin === f.origin && + (url.pathname === "/api/private" || + (url.pathname.startsWith("/assets/") && url.pathname.endsWith(".js"))), + async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (url.protocol === "data:" || url.protocol === "about:") return route.continue(); + if ( + scriptGate && + url.origin === f.origin && + url.pathname.startsWith("/assets/") && + url.pathname.endsWith(".js") + ) + await scriptGate; + const held = + privateHold && url.origin === f.origin && url.pathname === "/api/private" + ? privateHold + : undefined; + if (held) { + if (held.request) throw new Error("Only one private request may own this barrier"); + held.request = request; + held.enter(); + await held.released; + } + if (held?.expectedCancellation) { + await route.abort("aborted").catch(() => undefined); + return; + } + try { + f.assertBrowserDestination(request.url()); + await route.continue(); + } catch (error) { + if (held?.expectedCancellation && held.request === request) { + await route.abort("aborted").catch(() => undefined); + } else { + browserErrors.push(error instanceof Error ? error.message : String(error)); + await route.abort("failed").catch(() => undefined); + } + } + }, + ); + return { + f, + sessionGets, + actionRequests, + browserErrors, + consoleMessages, + blockScripts() { + if (releaseScripts) throw new Error("Previous script barrier is still owned"); + scriptGate = new Promise((resolve) => { + releaseScripts = resolve; + }); + }, + releaseScripts() { + releaseScripts?.(); + releaseScripts = undefined; + scriptGate = undefined; + }, + holdPrivate() { + let cancel!: () => void; + const cancelled = new Promise((resolve) => { + cancel = resolve; + }); + let enter!: () => void; + let release!: () => void; + const entered = new Promise((resolve) => { + enter = resolve; + }); + const released = new Promise((resolve) => { + release = resolve; + }); + const hold = { + entered, + enter, + released, + cancelled, + cancel, + expectedCancellation: false, + expectCancellation() { + hold.expectedCancellation = true; + }, + release: () => { + release(); + }, + }; + privateHold = hold; + return hold; + }, + async close() { + releaseScripts?.(); + privateHold?.release(); + const cleanupFailures: unknown[] = []; + try { + await bounded(context.unrouteAll({ behavior: "wait" }), "browser route drain"); + } catch (error) { + cleanupFailures.push(error); + } + try { + await browserWire.close(); + } catch (error) { + cleanupFailures.push(error); + } + try { + await f.close(); + } catch (error) { + cleanupFailures.push(error); + } + if (cleanupFailures.length) + throw new AggregateError(cleanupFailures, "Browser fixture cleanup failed"); + for (const canary of [...canaries, ...f.secrets()]) + expect(consoleMessages.join("\n")).not.toContain(canary); + expect( + f.events + .filter((event) => event.kind === "browser_authorize_redirect") + .map((event) => event.target), + ).toEqual( + f.events.filter((event) => event.kind === "authorize").map((event) => event.target), + ); + if (invalidBoot) + expect(browserErrors).toEqual(["Invalid provider application boot configuration"]); + else expect(browserErrors).toEqual([]); + }, + }; + } catch (error) { + await f.close(); + throw error; + } +} +async function ready(page: Page) { + await expect(page.locator("#app")).toHaveAttribute("data-askr-ready", "true"); +} +async function completeProvider(page: Page) { + await page.waitForURL((url) => url.pathname === "/__fixture/authorize", { + waitUntil: "domcontentloaded", + }); + await page.locator("#provider-continue").click({ noWaitAfter: true }); + await page.waitForURL((url) => url.origin === "https://provider.example-provider.test", { + waitUntil: "domcontentloaded", + }); + await page.locator("#continue").click({ noWaitAfter: true }); +} +async function completeLogin(page: Page, origin: string) { + const target = page.waitForURL( + (url) => url.origin === origin && url.pathname.startsWith("/account"), + { waitUntil: "commit" }, + ); + void target.catch(() => undefined); + const click = page.getByTestId("sign-in").click({ noWaitAfter: true }); + void click.catch(() => undefined); + await completeProvider(page); + await target; + await click; +} +async function completeApiConsent(page: Page, owned: Awaited>) { + const { f } = owned; + const actionCount = owned.actionRequests.length; + const authorizationCount = f.events.filter((event) => event.kind === "authorize").length; + const returned = page.waitForEvent("framenavigated", { + predicate: (frame) => + frame === page.mainFrame() && + new URL(frame.url()).origin === f.origin && + new URL(frame.url()).pathname.startsWith("/account"), + }); + const callback = page.waitForResponse( + (response) => + new URL(response.url()).origin === f.origin && + new URL(response.url()).pathname === `/auth/callback/${f.provider}` && + response.request().isNavigationRequest(), + ); + void returned.catch(() => undefined); + void callback.catch(() => undefined); + const click = page.getByTestId("provider-action").click({ noWaitAfter: true }); + void click.catch(() => undefined); + await completeProvider(page); + const [, response] = await bounded( + Promise.all([returned, callback, click]), + "explicit API consent return", + ); + expect(response.status()).toBe(303); + await ready(page); + expect(f.events.filter((event) => event.kind === "authorize").length).toBe( + authorizationCount + 1, + ); + const authorization = f.events.filter((event) => event.kind === "authorize").at(-1); + const parameters = new URL(authorization!.target).searchParams; + if (f.provider === "auth0") expect(parameters.get("audience")).toBe(f.env.api!.resource); + else { + expect(parameters.has("audience")).toBe(false); + expect(parameters.has("resource")).toBe(false); + } + expect(parameters.get("scope")?.split(" ")).toContain(f.env.api!.scopes[0]); + expect(owned.actionRequests.length).toBe(actionCount + 1); + await expect(page.getByTestId("provider-result")).toHaveCount(0); + // Consent grants the permission; it never resubmits the original action. + expect(f.events.some((event) => event.target === f.env.api!.url)).toBe(false); +} +for (const provider of ["auth0", "microsoft", "google", "apple"] as const) + for (const mode of ["ssr", "spa"] as const) { + test(`${provider} ${mode}: signed native login, exact boot, private retirement and reload`, async ({ + page, + context, + browserWire, + }) => { + const owned = await start(context, provider, mode, browserWire); + const { f } = owned; + try { + await page.goto( + `${f.origin}/login?next=${encodeURIComponent("/account/details?view=full#panel")}`, + ); + await ready(page); + expect(owned.sessionGets.length).toBe(mode === "spa" ? 1 : 0); + owned.blockScripts(); + await completeLogin(page, f.origin); + let heading: ElementHandle | null = null; + if (mode === "ssr") { + await expect(page.getByRole("heading", { name: "Private account" })).toBeVisible(); + heading = await page.getByRole("heading", { name: "Private account" }).elementHandle(); + } else expect(await page.locator("#app").innerHTML()).toBe(""); + owned.releaseScripts(); + await ready(page); + if (provider === "google") + expect( + new URL(f.events.find((event) => event.kind === "authorize")!.target).searchParams + .get("scope") + ?.split(" "), + ).not.toContain(f.env.api!.scopes[0]); + if (heading) + expect(await heading.evaluate((node) => node === document.querySelector("h1"))).toBe( + true, + ); + expect(page.url()).toContain("/account/details?view=full#panel"); + expect(owned.sessionGets.length).toBe(mode === "spa" ? 2 : 0); + await expect(page.getByTestId("private-query")).toContainText("Private query belongs"); + const before = owned.sessionGets.length; + await page.getByRole("link", { name: "Account overview", exact: true }).click(); + await ready(page); + expect(owned.sessionGets.length).toBe(before); + if (provider === "auth0" || provider === "google") await completeApiConsent(page, owned); + const actionCount = owned.actionRequests.length; + await page.getByTestId("provider-action").click(); + await expect(page.getByTestId("provider-result")).toBeVisible(); + expect(owned.actionRequests.length).toBe(actionCount + 1); + if (provider !== "apple") + expect(f.events.filter((event) => event.target === f.env.api!.url)).toHaveLength(1); + const privateBody = await page.locator("body").innerText(); + expect(privateBody).not.toMatch(/MOCK_.*PRIVATE_CANARY/u); + for (const secret of f.secrets()) expect(privateBody).not.toContain(secret); + if (provider === "apple") { + const callback = f.events.find((event) => event.kind === "authorization_code"); + expect(callback).toBeTruthy(); + const browserCookie = await context.cookies(f.origin); + expect( + browserCookie.some( + (cookie) => + cookie.name === "__Host-askr-session" && + cookie.secure && + cookie.httpOnly && + cookie.sameSite === "Lax", + ), + ).toBe(true); + } + // A new private query is held before HTTP. Local logout must retire DOM without waiting for it. + const hold = owned.holdPrivate(); + const refresh = page.getByTestId("reload-private").click(); + void refresh.catch(() => undefined); + try { + await bounded(hold.entered, "private request barrier"); + hold.expectCancellation(); + await page.getByTestId("logout").click(); + await expect(page.getByTestId("private-owner")).toHaveCount(0); + await bounded(hold.cancelled, "exact held request cancellation"); + } finally { + hold.release(); + await bounded(refresh, "refresh click drain"); + } + await expect(page).toHaveURL(/\/login\?next=/u); + await page.reload(); + await ready(page); + await expect(page.getByTestId("private-owner")).toHaveCount(0); + const direct = await f.request("/auth/session", { + headers: { + cookie: (await context.cookies(f.origin)) + .map((cookie) => `${cookie.name}=${cookie.value}`) + .join("; "), + }, + }); + expect((await direct.json()).authenticated).toBe(false); + } finally { + owned.releaseScripts(); + await owned.close(); + } + }); + test(`${provider} ${mode}: copied callback, safe errors, expiry and provider consent`, async ({ + page, + context, + browserWire, + }) => { + const owned = await start(context, provider, mode, browserWire); + const { f } = owned; + try { + const callbackRequest = page.waitForRequest( + (request) => new URL(request.url()).pathname === `/auth/callback/${provider}`, + ); + void callbackRequest.catch(() => undefined); + await page.goto(`${f.origin}/login?next=${encodeURIComponent("//foreign.example.test")}`); + await ready(page); + await completeLogin(page, f.origin); + await ready(page); + if (provider === "google") + expect( + new URL(f.events.find((event) => event.kind === "authorize")!.target).searchParams + .get("scope") + ?.split(" "), + ).not.toContain(f.env.api!.scopes[0]); + expect(new URL(page.url()).pathname).toBe("/account"); + const actual = await bounded(callbackRequest, "actual callback request"); + // The HTTPS owner records the headers actually received on the wire. + // WebKit omits cross-site Cookie from its automation request metadata. + const callback = f.callbackRequests.at(-1)!; + expect(callback).toBeTruthy(); + expect(callback.url).toBe(actual.url()); + expect(callback.method).toBe(actual.method()); + expect(Buffer.from(callback.body ?? [])).toEqual( + actual.postDataBuffer() ?? Buffer.alloc(0), + ); + if (provider === "apple") { + expect(callback.method).toBe("POST"); + expect(callback.headers.cookie ?? "").toContain("__Host-askr-txn-apple="); + expect(callback.headers.cookie ?? "").not.toContain("__Host-askr-session="); + } + const exchanges = f.exchanges; + const replay = await f.request(callback.url, { + method: callback.method, + headers: callback.headers, + body: callback.body, + }); + expect(replay.status).toBe(400); + expect(f.exchanges).toBe(exchanges); + if (provider !== "apple") { + if (provider === "auth0" || provider === "google") await completeApiConsent(page, owned); + f.advance(2000); + f.refreshFailure("consent_required"); + const authorizations = f.events.filter((event) => event.kind === "authorize").length; + const navigation = page.waitForEvent("framenavigated", { + predicate: (frame) => + frame === page.mainFrame() && new URL(frame.url()).origin === f.origin, + }); + void navigation.catch(() => undefined); + await page.getByTestId("provider-action").click({ noWaitAfter: true }); + await completeProvider(page); + await expect + .poll(() => f.events.filter((event) => event.kind === "authorize").length) + .toBeGreaterThan(authorizations); + await navigation; + await ready(page); + f.refreshFailure(undefined); + const authorization = f.events.filter((event) => event.kind === "authorize").at(-1); + expect(authorization).toBeTruthy(); + const consent = new URL(authorization!.target); + expect(consent.searchParams.get("scope")).toContain( + provider === "microsoft" + ? "https://graph.microsoft.com/User.Read" + : provider === "google" + ? "calendar.calendarlist.readonly" + : "read:summary", + ); + expect(f.refreshes).toBe(1); // There is no automatic refresh retry. + } + f.advance(8 * 60 * 60 * 1000); + await page.getByTestId("reload-private").click(); + await expect(page.getByTestId("private-owner")).toHaveCount(0); + await expect(page).toHaveURL(/\/login\?next=/u); + expect(new URL(page.url()).searchParams.get("next")).toBe("/account"); + await page.reload(); + await ready(page); + await expect(page.getByTestId("private-owner")).toHaveCount(0); + await expect(page).toHaveURL(/\/login\?next=/u); + } finally { + await owned.close(); + } + }); + } + +for (const mode of ["ssr", "spa"] as const) + test(`invalid ${mode} boot configuration clears the still-unowned private root`, async ({ + page, + context, + browserWire, + }) => { + const owned = await start(context, "google", mode, browserWire, true); + try { + const signed = await owned.f.login(); + const cookies = signed.cookie.split("; ").map((value) => { + const index = value.indexOf("="); + return { + name: value.slice(0, index), + value: value.slice(index + 1), + url: owned.f.origin, + secure: true, + httpOnly: true, + sameSite: "Lax" as const, + }; + }); + await context.addCookies(cookies); + await page.goto(`${owned.f.origin}/account`); + await expect.poll(() => owned.browserErrors.length).toBe(1); + await expect(page.locator("#app")).toBeEmpty(); + expect(owned.sessionGets).toEqual([]); + } finally { + await owned.close(); + } + }); diff --git a/provider-auth/tests/composition.test.ts b/provider-auth/tests/composition.test.ts new file mode 100644 index 0000000..dfc95e3 --- /dev/null +++ b/provider-auth/tests/composition.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { withApplicationLifecycle } from "../src/server/compose.js"; +import { providerMarker, modeMarker } from "../src/public-config.js"; +import { createExampleRegistry } from "../src/registry.js"; +import type { AskrApp } from "@askrjs/server/askr"; +const document = `${providerMarker}${modeMarker}
private
`; +function rawOwner() { + const close = vi.fn(async () => {}); + const raw = { + fetch: vi.fn(async () => new Response("raw")), + close, + toOpenApiDocument: vi.fn((): ReturnType => ({ + openapi: "3.1.2", + info: { title: "owned", version: "1" }, + paths: {}, + components: { schemas: {} }, + })), + } satisfies AskrApp; + return raw; +} +describe("one app document/lifecycle composition", () => { + it("passes one explicit auth object to the same registry and keeps protected action declarations", () => { + const { registry, sessionAuth } = createExampleRegistry({ provider: "apple", mode: "ssr" }); + expect(registry.manifest.auth).toBe(sessionAuth); + expect(registry.manifest.records.map((record) => record.path).sort()).toEqual( + ["/", "/login", "/account", "/account/details", "/*"].sort(), + ); + for (const record of registry.manifest.records.filter((record) => + record.path.startsWith("/account"), + )) { + expect(record.options.auth).toBeTypeOf("function"); + expect(record.options.actions?.map((action) => action.id)).toEqual(["provider.read-summary"]); + } + }); + it("forwards composed fetch but the exact raw singleton close/schema methods", async () => { + const raw = rawOwner(); + const composed = { + fetch: vi.fn( + async () => + new Response(document, { + headers: { + "content-type": "text/html", + "content-length": "1", + etag: '"old"', + "set-cookie": "owned=value; HttpOnly", + }, + }), + ), + }; + const app = withApplicationLifecycle(composed, raw, { provider: "google", mode: "spa" }); + composed.fetch.mockImplementationOnce(async () => { + const headers = new Headers({ + "content-type": "text/html", + "content-length": "1", + etag: '\"old\"', + }); + headers.append("set-cookie", "owned=value; HttpOnly"); + headers.append("set-cookie", "transaction=other; HttpOnly"); + return new Response(document, { headers }); + }); + const request = new Request("https://app.example.test/"); + const response = await app.fetch(request); + expect(composed.fetch).toHaveBeenCalledWith(request); + expect(raw.fetch).not.toHaveBeenCalled(); + expect(await response.text()).toContain('name="askr-example-provider" content="google"'); + expect(response.headers.getSetCookie()).toEqual([ + "owned=value; HttpOnly", + "transaction=other; HttpOnly", + ]); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.has("etag")).toBe(false); + expect(response.headers.has("content-length")).toBe(false); + app.toOpenApiDocument(); + await app.close(); + expect(raw.toOpenApiDocument).toHaveBeenCalledOnce(); + expect(raw.close).toHaveBeenCalledOnce(); + }); + it.each(["", `${document}${providerMarker}`])( + "rejects missing/ambiguous public markers", + async (body) => { + const app = withApplicationLifecycle( + { fetch: async () => new Response(body, { headers: { "content-type": "text/html" } }) }, + rawOwner(), + { provider: "apple", mode: "ssr" }, + ); + await expect(app.fetch(new Request("https://app.example.test/"))).rejects.toThrow( + "exactly once", + ); + }, + ); + it("returns auth/API/redirect responses unchanged and preserves HEAD headers without a body", async () => { + const response = new Response(null, { + status: 303, + headers: { location: "/login", "set-cookie": "owned=value" }, + }); + const app = withApplicationLifecycle({ fetch: async () => response }, rawOwner(), { + provider: "auth0", + mode: "ssr", + }); + expect(await app.fetch(new Request("https://app.example.test/auth/login/auth0"))).toBe( + response, + ); + const head = withApplicationLifecycle( + { + fetch: async () => + new Response(null, { + headers: { "content-type": "text/html", "set-cookie": "owned=value" }, + }), + }, + rawOwner(), + { provider: "auth0", mode: "ssr" }, + ); + const result = await head.fetch(new Request("https://app.example.test/", { method: "HEAD" })); + expect(await result.text()).toBe(""); + expect(result.headers.get("set-cookie")).toBe("owned=value"); + }); +}); diff --git a/provider-auth/tests/configuration.test.ts b/provider-auth/tests/configuration.test.ts new file mode 100644 index 0000000..c8b4e7a --- /dev/null +++ b/provider-auth/tests/configuration.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from "vite-plus/test"; +import { readProviderEnvironment } from "../src/server/environment.js"; + +const secret = Buffer.alloc(32, 7).toString("base64url"); +function environment(provider = "auth0"): Record { + return { + ASKR_PROVIDER: provider, + ASKR_ORIGIN: "https://app.example.test:3443", + ASKR_SESSION_SECRETS: secret, + ASKR_ACTION_CSRF_SECRET: "application-owned-action-csrf-secret", + ASKR_TLS_CERT_PATH: "/local/certificate.pem", + ASKR_TLS_KEY_PATH: "/local/key.pem", + AUTH0_DOMAIN: "tenant.example.test", + AUTH0_CLIENT_ID: "auth0-client", + AUTH0_CLIENT_SECRET: "auth0-private-secret", + MICROSOFT_TENANT_ID: "01234567-89ab-cdef-0123-456789abcdef", + MICROSOFT_CLIENT_ID: "microsoft-client", + MICROSOFT_CLIENT_SECRET: "microsoft-private-secret", + GOOGLE_CLIENT_ID: "google-client", + GOOGLE_CLIENT_SECRET: "google-private-secret", + APPLE_SERVICES_ID: "test.example.signin", + APPLE_TEAM_ID: "TEAM123456", + APPLE_KEY_ID: "KEY1234567", + APPLE_PRIVATE_KEY_PATH: "/local/apple.p8", + }; +} + +describe("explicit provider application configuration", () => { + it.each(["auth0", "microsoft", "google", "apple"])( + "selects %s without inspecting unrelated credentials", + (provider) => { + const env = environment(provider); + for (const key of Object.keys(env)) { + if ( + /^(AUTH0|MICROSOFT|GOOGLE|APPLE)_/.test(key) && + !key.startsWith(`${provider.toUpperCase()}_`) + ) + delete env[key]; + } + expect(readProviderEnvironment(env)).toMatchObject({ + provider, + mode: "ssr", + origin: "https://app.example.test:3443", + listenPort: 3443, + sessionSecrets: [secret], + }); + }, + ); + + it.each(["ssr", "spa"])("selects %s rendering explicitly", (mode) => { + expect(readProviderEnvironment({ ...environment(), ASKR_RENDER_MODE: mode }).mode).toBe(mode); + }); + + it.each(["", "oidc", "Auth0", "../../secret"])("rejects an unknown provider %j", (provider) => { + expect(() => readProviderEnvironment(environment(provider))).toThrow("ASKR_PROVIDER"); + }); + + it("rejects an unknown render mode", () => { + expect(() => readProviderEnvironment({ ...environment(), ASKR_RENDER_MODE: "ssg" })).toThrow( + "ASKR_RENDER_MODE", + ); + }); + + it("keeps the public Apple tunnel origin separate from the local HTTPS listener", () => { + expect( + readProviderEnvironment({ + ...environment("apple"), + ASKR_ORIGIN: "https://signin.example.test", + }), + ).toMatchObject({ + origin: "https://signin.example.test", + listenPort: 3443, + }); + }); + + it("uses an explicitly configured local listening port without changing the callback origin", () => { + expect( + readProviderEnvironment({ + ...environment(), + ASKR_ORIGIN: "https://app.example.test", + ASKR_LISTEN_PORT: "8443", + }), + ).toMatchObject({ + origin: "https://app.example.test", + listenPort: 8443, + }); + }); + + it.each(["", "0", "-1", "65536", "3443.5", "https", " 3443 "])( + "rejects an invalid listening port %j", + (listenPort) => { + expect(() => + readProviderEnvironment({ ...environment(), ASKR_LISTEN_PORT: listenPort }), + ).toThrow("ASKR_LISTEN_PORT"); + }, + ); + + it.each([ + "http://app.example.test:3443", + "https://name:password@app.example.test:3443", + "https://app.example.test:3443/private", + "https://app.example.test:3443/?secret=value", + "https://app.example.test:3443/#private", + "https://app.example.test:3443/?", + "https://app.example.test:3443/#", + "https://app.example.test:3443/private/..", + "https://@app.example.test:3443", + "https://app.example.test:3443/\n", + ])("requires a single HTTPS origin: %s", (origin) => { + expect(() => readProviderEnvironment({ ...environment(), ASKR_ORIGIN: origin })).toThrow( + "ASKR_ORIGIN", + ); + }); + + it.each([ + "https://localhost:3443", + "https://localhost.:3443", + "https://app.localhost.:3443", + "https://127.0.0.1:3443", + "https://[::1]:3443", + ])("rejects Apple live loopback origins: %s", (origin) => { + expect(() => readProviderEnvironment({ ...environment("apple"), ASKR_ORIGIN: origin })).toThrow( + "ASKR_ORIGIN", + ); + }); + + it.each([ + "ASKR_SESSION_SECRETS", + "ASKR_ACTION_CSRF_SECRET", + "ASKR_TLS_CERT_PATH", + "ASKR_TLS_KEY_PATH", + "AUTH0_CLIENT_SECRET", + ])("requires %s without disclosing other secrets", (key) => { + const env = environment(); + env[key] = ""; + let message = ""; + try { + readProviderEnvironment(env); + } catch (error) { + message = (error as Error).message; + } + expect(message).toContain(key); + expect(message).not.toContain(env.AUTH0_CLIENT_SECRET || "auth0-private-secret"); + expect(message).not.toContain(secret); + }); + + it("retains the ordered current and previous encryption keys", () => { + const previous = Buffer.alloc(32, 8).toString("base64url"); + expect( + readProviderEnvironment({ ...environment(), ASKR_SESSION_SECRETS: `${secret},${previous}` }) + .sessionSecrets, + ).toEqual([secret, previous]); + }); + + it("limits the rotation key set even when every key is different", () => { + const keys = [1, 2, 3, 4, 5].map((byte) => Buffer.alloc(32, byte).toString("base64url")); + expect(() => + readProviderEnvironment({ ...environment(), ASKR_SESSION_SECRETS: keys.join(",") }), + ).toThrow("ASKR_SESSION_SECRETS"); + }); + + it("does not read malformed unrelated provider configuration", () => { + const env = { + ...environment("microsoft"), + AUTH0_DOMAIN: "bad domain", + AUTH0_API_URL: "http://bad.example.test", + APPLE_PRIVATE_KEY_PATH: "", + GOOGLE_CLIENT_SECRET: "", + }; + expect(readProviderEnvironment(env).provider).toBe("microsoft"); + }); + + it.each(["MICROSOFT_CLIENT_SECRET", "GOOGLE_CLIENT_SECRET", "APPLE_PRIVATE_KEY_PATH"])( + "requires the selected provider credential %s", + (key) => { + const provider = key.split("_")[0].toLowerCase(); + expect(() => readProviderEnvironment({ ...environment(provider), [key]: "" })).toThrow(key); + }, + ); + + it.each(["demo", " ", `${secret},${secret}`, Array(5).fill(secret).join(",")])( + "rejects malformed or repeated encryption keys", + (keys) => { + expect(() => + readProviderEnvironment({ ...environment(), ASKR_SESSION_SECRETS: keys }), + ).toThrow("ASKR_SESSION_SECRETS"); + }, + ); + + it("requires the complete Auth0 API configuration when an audience is supplied", () => { + expect(() => + readProviderEnvironment({ ...environment(), AUTH0_API_AUDIENCE: "https://api.example.test" }), + ).toThrow("AUTH0_API_"); + }); + + it("rejects non-HTTPS Auth0 server API endpoints", () => { + expect(() => + readProviderEnvironment({ + ...environment(), + AUTH0_API_AUDIENCE: "urn:example:api", + AUTH0_API_SCOPES: "read:profile", + AUTH0_API_URL: "http://api.example.test/profile", + }), + ).toThrow("AUTH0_API_URL"); + }); + + it("keeps the exact Auth0 audience and configured scopes", () => { + expect( + readProviderEnvironment({ + ...environment(), + AUTH0_API_AUDIENCE: "urn:example:api", + AUTH0_API_SCOPES: "read:profile read:summary", + AUTH0_API_URL: "https://api.example.test/profile", + }), + ).toMatchObject({ + api: { + resource: "urn:example:api", + scopes: ["read:profile", "read:summary"], + url: "https://api.example.test/profile", + }, + }); + }); + + it("uses the explicit Microsoft Graph resource and qualified permission", () => { + expect(readProviderEnvironment(environment("microsoft"))).toMatchObject({ + api: { + resource: "https://graph.microsoft.com", + scopes: ["https://graph.microsoft.com/User.Read"], + url: "https://graph.microsoft.com/v1.0/me?$select=displayName", + }, + }); + }); + + it("uses the narrow Google calendar-list permission", () => { + expect(readProviderEnvironment(environment("google"))).toMatchObject({ + api: { + resource: "https://www.googleapis.com", + scopes: ["https://www.googleapis.com/auth/calendar.calendarlist.readonly"], + url: "https://www.googleapis.com/calendar/v3/users/me/calendarList", + }, + }); + }); + + it("does not invent an Apple API bearer-token target", () => { + expect(readProviderEnvironment(environment("apple")).api).toBeUndefined(); + }); +}); diff --git a/provider-auth/tests/https.test.ts b/provider-auth/tests/https.test.ts new file mode 100644 index 0000000..e2d5079 --- /dev/null +++ b/provider-auth/tests/https.test.ts @@ -0,0 +1,42 @@ +import { mkdtemp, mkdir, writeFile, symlink, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vite-plus/test"; +import { publicAssets } from "../src/server/https.js"; +describe("finite public asset boundary", () => { + it("admits only regular public assets and never exposes source maps", async () => { + const root = await mkdtemp(join(tmpdir(), "askr-assets-")); + try { + await mkdir(join(root, "nested")); + await writeFile(join(root, "nested", "雪 file.js"), "export {};"); + await writeFile(join(root, "private.map"), "PRIVATE_CANARY"); + const assets = await publicAssets(root); + expect([...assets.keys()]).toEqual(["/assets/nested/%E9%9B%AA%20file.js"]); + expect(assets.get("/assets/nested/%E9%9B%AA%20file.js")?.type).toBe( + "text/javascript; charset=utf-8", + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it("rejects file and directory symlinks at startup", async () => { + const root = await mkdtemp(join(tmpdir(), "askr-assets-")); + try { + await mkdir(join(root, "public")); + await writeFile(join(root, "outside.js"), "private"); + await symlink(join(root, "outside.js"), join(root, "public", "escape.js")); + await expect(publicAssets(join(root, "public"))).rejects.toThrow("symlinks"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + it("refuses server/TLS/unknown file types", async () => { + const root = await mkdtemp(join(tmpdir(), "askr-assets-")); + try { + await writeFile(join(root, "private.pem"), "private"); + await expect(publicAssets(root)).rejects.toThrow("Unsupported"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/provider-auth/tests/loopback-proxy.test.ts b/provider-auth/tests/loopback-proxy.test.ts new file mode 100644 index 0000000..1915a46 --- /dev/null +++ b/provider-auth/tests/loopback-proxy.test.ts @@ -0,0 +1,88 @@ +import { createServer, connect, type Socket } from "node:net"; +import { describe, expect, it } from "vite-plus/test"; +import { createLoopbackProxy } from "./browser/loopback-proxy.js"; +import { bounded } from "./signed-provider.js"; + +async function exchange(proxyUrl: string, request: string, complete: string) { + const target = new URL(proxyUrl); + let socket: Socket | undefined; + try { + return await bounded( + new Promise((resolve, reject) => { + socket = connect({ host: target.hostname, port: Number(target.port) }); + let received = ""; + socket.once("error", reject); + socket.once("connect", () => socket!.write(request)); + socket.on("data", (data) => { + received += data.toString(); + if (received.includes(complete)) resolve(received); + }); + socket.once("end", () => resolve(received)); + }), + "test CONNECT exchange", + ); + } finally { + socket?.destroy(); + } +} + +describe("owned browser CONNECT transport", () => { + it("forwards the exact allowed authority and buffered bytes to its loopback listener", async () => { + const listener = createServer((socket) => + socket.once("data", (data) => { + expect(data.toString()).toBe("probe"); + socket.end("owned response"); + }), + ); + await new Promise((resolve) => listener.listen(0, "127.0.0.1", resolve)); + const address = listener.address(); + if (!address || typeof address !== "object") throw new Error("Missing test listener"); + const proxy = await createLoopbackProxy(); + proxy.bind(new Map([["provider.example-provider.test:443", address.port]])); + try { + expect( + await exchange( + proxy.url, + "CONNECT provider.example-provider.test:443 HTTP/1.1\r\nHost: provider.example-provider.test:443\r\n\r\nprobe", + "owned response", + ), + ).toBe("HTTP/1.1 200 Connection Established\r\n\r\nowned response"); + } finally { + await proxy.close(); + await bounded( + new Promise((resolve) => listener.close(() => resolve())), + "test listener shutdown", + ); + } + }); + + it("rejects canonical provider authorities without a DNS or external fallback", async () => { + const proxy = await createLoopbackProxy(); + proxy.bind(new Map()); + try { + expect( + await exchange( + proxy.url, + "CONNECT appleid.apple.com:443 HTTP/1.1\r\nHost: appleid.apple.com:443\r\n\r\n", + "403 Forbidden", + ), + ).toContain("403 Forbidden"); + } finally { + await expect(proxy.close()).rejects.toThrow( + "Unknown CONNECT destination: appleid.apple.com:443", + ); + } + }); + + it("rejects ordinary HTTP traffic and reports it at cleanup", async () => { + const proxy = await createLoopbackProxy(); + proxy.bind(new Map()); + try { + const response = await fetch(proxy.url); + expect(response.status).toBe(403); + await response.arrayBuffer(); + } finally { + await expect(proxy.close()).rejects.toThrow("Unexpected non-CONNECT request: /"); + } + }); +}); diff --git a/provider-auth/tests/private-query.test.ts b/provider-auth/tests/private-query.test.ts new file mode 100644 index 0000000..199afdc --- /dev/null +++ b/provider-auth/tests/private-query.test.ts @@ -0,0 +1,36 @@ +import { afterEach, expect, it, vi } from "vite-plus/test"; +import { createPrivateQuery, privateValue } from "../src/private-query.js"; + +afterEach(() => vi.unstubAllGlobals()); + +it.each([401, 403, 500])("recovers only an unauthorized private query (%s)", async (status) => { + vi.stubGlobal("fetch", async () => new Response(null, { status })); + const recover = vi.fn(); + const query = createPrivateQuery(recover); + await expect(query.fetch({}, { signal: new AbortController().signal })).rejects.toThrow( + "Private query is unavailable", + ); + expect(recover).toHaveBeenCalledTimes(status === 401 ? 1 : 0); +}); + +it("does not recover from a retired request whose transport ignores cancellation", async () => { + let respond!: (response: Response) => void; + vi.stubGlobal("fetch", () => new Promise((resolve) => (respond = resolve))); + const recover = vi.fn(); + const query = createPrivateQuery(recover); + const controller = new AbortController(); + const pending = query.fetch({}, { signal: controller.signal }); + controller.abort(); + respond(new Response(null, { status: 401 })); + await expect(pending).rejects.toThrow("Private query is unavailable"); + expect(recover).not.toHaveBeenCalled(); +}); + +it("returns private data without a login recovery when the request succeeds", async () => { + vi.stubGlobal("fetch", async () => Response.json(privateValue)); + const recover = vi.fn(); + expect( + await createPrivateQuery(recover).fetch({}, { signal: new AbortController().signal }), + ).toEqual(privateValue); + expect(recover).not.toHaveBeenCalled(); +}); diff --git a/provider-auth/tests/production-owner.test.ts b/provider-auth/tests/production-owner.test.ts new file mode 100644 index 0000000..0e8b5b2 --- /dev/null +++ b/provider-auth/tests/production-owner.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import type { AskrApp } from "@askrjs/server/askr"; +const owners = vi.hoisted(() => ({ + fetch: vi.fn(async () => new Response(null, { status: 204 })), + close: vi.fn(async () => {}), + schema: vi.fn((): ReturnType => ({ + openapi: "3.1.2", + info: { title: "owner", version: "1" }, + paths: {}, + components: { schemas: {} }, + })), +})); +vi.mock("../src/server/entry-server.js", () => { + const app = { + fetch: vi.fn(), + close: owners.close, + toOpenApiDocument: owners.schema, + } satisfies AskrApp; + return { app, default: app, environment: { provider: "google", mode: "ssr" } }; +}); +vi.mock("virtual:askr-server", () => { + const app = { fetch: owners.fetch }; + return { app, default: app }; +}); +describe("production document singleton import boundary", () => { + it("uses the composed fetch and exact raw entry cleanup without creating another engine", async () => { + const source = await import("../src/server/entry-server.js"); + const production = await import("../src/server/production.js"); + expect(production.environment).toBe(source.environment); + const request = new Request("https://app.example.test/auth/session"); + expect((await production.app.fetch(request)).status).toBe(204); + expect(owners.fetch).toHaveBeenCalledWith(request); + expect(source.app.fetch).not.toHaveBeenCalled(); + await production.app.close(); + expect(source.app.close).toHaveBeenCalledOnce(); + expect(owners.close).toHaveBeenCalledOnce(); + production.app.toOpenApiDocument(); + expect(owners.schema).toHaveBeenCalledOnce(); + }); +}); diff --git a/provider-auth/tests/profile-store.test.ts b/provider-auth/tests/profile-store.test.ts new file mode 100644 index 0000000..3bde694 --- /dev/null +++ b/provider-auth/tests/profile-store.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vite-plus/test"; +import { createDisplayProfiles } from "../src/server/profile-store.js"; +describe("bounded display hints", () => { + it("keeps hint data separate from authority and expires it absolutely", () => { + let now = 0; + const store = createDisplayProfiles(() => now); + store.remember( + "principal", + { email: "signed@test", email_verified: true, roles: ["never-copy"] }, + { givenName: "Unsigned", familyName: "Hint" }, + ); + expect(store.label("principal")).toBe("Unsigned Hint"); + now += 8 * 60 * 60 * 1000; + expect(store.label("principal")).toBeUndefined(); + }); + it("bounds storage and only falls back to validated email", () => { + const store = createDisplayProfiles(() => 0); + store.remember("unverified", { email: "private@test", email_verified: false }); + expect(store.label("unverified")).toBeUndefined(); + for (let index = 0; index < 1001; index++) + store.remember(String(index), { email: "confirmed@test", email_verified: true }); + expect(store.label("0")).toBeUndefined(); + expect(store.label("1000")).toBe("confirmed@test"); + store.close(); + expect(store.label("1000")).toBeUndefined(); + }); +}); diff --git a/provider-auth/tests/redirect.test.ts b/provider-auth/tests/redirect.test.ts new file mode 100644 index 0000000..07c2032 --- /dev/null +++ b/provider-auth/tests/redirect.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vite-plus/test"; +import { safeReturnTo } from "../src/redirect.js"; +describe("safe provider return paths", () => { + it.each(["/account", "/account/details?a=1&a=2#panel", "/login?next=%2Faccount"])( + "retains safe path/query/hash %s", + (value) => { + expect(safeReturnTo(value, "https://app.example.test")).toBe(value); + }, + ); + it.each([ + undefined, + null, + "", + "https://foreign.test", + "//foreign.test", + "/\\foreign.test", + "/%5cforeign.test", + "/%2f%2fforeign.test", + "/https%3a/foreign.test", + "/%2e%2e/secret", + "/account?x=%00", + "/account#%7f", + "/%", + "/\naccount", + ])("rejects an unsafe target %j", (value) => { + expect(safeReturnTo(value, "https://app.example.test")).toBe("/account"); + }); +}); diff --git a/provider-auth/tests/shutdown.test.ts b/provider-auth/tests/shutdown.test.ts new file mode 100644 index 0000000..89fa20a --- /dev/null +++ b/provider-auth/tests/shutdown.test.ts @@ -0,0 +1,118 @@ +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import type { AskrApp } from "@askrjs/server/askr"; +const transport = vi.hoisted(() => ({ + stall: false, + fail: false, + connections: vi.fn(), + close: vi.fn(), +})); +vi.mock("node:https", async () => { + const { EventEmitter: Emitter } = await import("node:events"); + return { + createServer() { + const server = new Emitter(); + return Object.assign(server, { + listen(_port: number, _host: string, done: () => void) { + queueMicrotask(() => + transport.fail ? server.emit("error", new Error("owned listen failure")) : done(), + ); + }, + close(done: (error?: Error) => void) { + transport.close(); + if (!transport.stall) queueMicrotask(() => done()); + }, + closeAllConnections() { + transport.connections(); + }, + }); + }, + }; +}); +import { startHttps } from "../src/server/https.js"; +const directories: string[] = []; +afterEach(async () => { + vi.useRealTimers(); + transport.stall = false; + transport.fail = false; + vi.clearAllMocks(); + await Promise.all( + directories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); +async function setup() { + const path = await mkdtemp(join(tmpdir(), "askr-shutdown-")); + directories.push(path); + await writeFile(join(path, "owned.js"), "export {};"); + const close = vi.fn(async () => {}); + const app = { + fetch: async () => new Response("owned"), + close, + toOpenApiDocument: () => ({ + openapi: "3.1.2", + info: { title: "owned", version: "1" }, + paths: {}, + components: { schemas: {} }, + }), + } satisfies AskrApp; + return { + app, + close, + path, + env: { + origin: "https://app.example.test", + listenPort: 3443, + tls: { certPath: join(path, "owned.js"), keyPath: join(path, "owned.js") }, + }, + }; +} +describe("HTTPS sole-owner cleanup", () => { + it("closes the engine on asset, TLS and listener startup failure", async () => { + const first = await setup(); + await expect(startHttps(first.app, first.env, join(first.path, "missing"))).rejects.toThrow(); + expect(first.close).toHaveBeenCalledOnce(); + const second = await setup(); + await expect( + startHttps( + second.app, + { ...second.env, tls: { certPath: "missing-owned-cert", keyPath: "missing-owned-key" } }, + second.path, + ), + ).rejects.toThrow(); + expect(second.close).toHaveBeenCalledOnce(); + const third = await setup(); + transport.fail = true; + await expect(startHttps(third.app, third.env, third.path)).rejects.toThrow( + "owned listen failure", + ); + expect(third.close).toHaveBeenCalledOnce(); + }); + it("memoizes shutdown and starts engine cleanup despite a stalled listener", async () => { + const f = await setup(); + const owner = await startHttps(f.app, f.env, f.path); + transport.stall = true; + vi.useFakeTimers(); + const first = owner.close(); + const observed = first.then( + () => undefined, + (error) => error, + ); + expect(owner.close()).toBe(first); + await Promise.resolve(); + expect(f.close).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(2000); + expect(await observed).toBeInstanceOf(AggregateError); + expect(transport.connections).toHaveBeenCalled(); + expect(f.close).toHaveBeenCalledOnce(); + }); + it("still closes the listener when engine cleanup rejects", async () => { + const f = await setup(); + f.close.mockRejectedValueOnce(new Error("owned engine close failure")); + const owner = await startHttps(f.app, f.env, f.path); + await expect(owner.close()).rejects.toThrow("shutdown failed"); + expect(transport.close).toHaveBeenCalledOnce(); + expect(transport.connections).toHaveBeenCalled(); + }); +}); diff --git a/provider-auth/tests/signed-application.test.ts b/provider-auth/tests/signed-application.test.ts new file mode 100644 index 0000000..546efa6 --- /dev/null +++ b/provider-auth/tests/signed-application.test.ts @@ -0,0 +1,278 @@ +import { readFile } from "node:fs/promises"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { createDocumentApp } from "@askrjs/vite/server"; +import { createExampleApp } from "../src/server/app.js"; +import { withApplicationLifecycle } from "../src/server/compose.js"; +import { startHttps } from "../src/server/https.js"; +import { signedProviderFixture, bounded, canaries } from "./signed-provider.js"; +import type { ProviderName } from "../src/public-config.js"; +const owned: Array>> = []; +afterEach(async () => { + const results = await Promise.allSettled(owned.splice(0).map((fixture) => fixture.close())); + for (const result of results) if (result.status === "rejected") throw result.reason; +}); +async function fixture(provider: ProviderName, mode: "ssr" | "spa" = "ssr", publicOrigin?: string) { + const f = await signedProviderFixture( + provider, + mode, + { createExampleApp, withApplicationLifecycle, startHttps, createDocumentApp }, + await readFile(new URL("../index.html", import.meta.url), "utf8"), + undefined, + publicOrigin, + ); + owned.push(f); + return f; +} +function actionHeaders(origin: string, cookie: string, csrf: string) { + return { + cookie, + origin, + "content-type": "application/json", + accept: "application/vnd.askr.action+json;v=1", + "x-askr-action": "provider.read-summary", + "x-askr-csrf-token": csrf, + }; +} +describe("real signed provider application transport", () => { + it.each(["auth0", "microsoft"] as const)( + "retires local authority before %s provider logout without exposing tokens", + async (provider) => { + const f = await fixture(provider); + const signed = await f.login(); + const response = await f.request("/auth/logout", { + method: "POST", + headers: { cookie: signed.cookie, origin: f.origin, "content-type": "application/json" }, + body: Buffer.from( + JSON.stringify({ + provider, + providerLogout: true, + revoke: provider === "auth0", + returnTo: "/", + }), + ), + }); + expect(response.status).toBe(303); + const location = response.headers.get("location")!; + const destination = new URL(location); + expect(`${destination.origin}${destination.pathname}`).toBe( + provider === "auth0" + ? "https://tenant.example.test/oidc/logout" + : "https://login.microsoftonline.com/01234567-89ab-cdef-0123-456789abcdef/oauth2/v2.0/logout", + ); + expect(Object.fromEntries(destination.searchParams)).toEqual({ + client_id: "fixture-client", + post_logout_redirect_uri: `${f.origin}/`, + }); + for (const secret of [...canaries, ...f.secrets()]) expect(location).not.toContain(secret); + expect( + response.headers + .getSetCookie() + .some((line) => line.startsWith("__Host-askr-session=;") && line.includes("Max-Age=0")), + ).toBe(true); + const retired = await f.request("/auth/session", { headers: { cookie: signed.cookie } }); + expect((await retired.json()).authenticated).toBe(false); + // The simulator never follows an end-session redirect to a live service. + expect( + f.events.some((event) => + event.target.startsWith(`${destination.origin}${destination.pathname}`), + ), + ).toBe(false); + }, + ); + it.each(["auth0", "microsoft", "google", "apple"] as const)( + "completes %s signed HTTPS state/PKCE/JWKS and contains snapshots", + async (provider) => { + const f = await fixture(provider); + const returnTo = "/account/details?view=full#panel"; + const signed = await bounded(f.login(returnTo, f.env.api), "signed login"); + expect(signed.response.status).toBe(303); + expect(signed.response.headers.get("location")).toBe(new URL(returnTo, f.origin).href); + const session = await f.request("/auth/session", { headers: { cookie: signed.cookie } }); + expect(session.headers.get("cache-control")).toBe("no-store"); + const snapshot = await session.text(); + expect(JSON.parse(snapshot)).toMatchObject({ + authenticated: true, + principal: { subject: "fixture-account" }, + }); + expect(JSON.parse(snapshot)).not.toHaveProperty("session"); + for (const canary of [...canaries, ...f.secrets()]) expect(snapshot).not.toContain(canary); + const csrf = await ( + await f.request("/api/action-csrf", { headers: { cookie: signed.cookie } }) + ).json(); + expect(Object.keys(csrf)).toEqual(["csrfToken"]); + const action = await f.request("/account", { + method: "POST", + headers: actionHeaders(f.origin, signed.cookie, csrf.csrfToken), + body: Buffer.from("{}"), + }); + expect(action.status).toBe(200); + const result = await action.text(); + for (const canary of [...canaries, ...f.secrets()]) expect(result).not.toContain(canary); + if (provider !== "apple") + expect( + f.events.some((event) => + event.target.includes( + provider === "microsoft" + ? "graph.microsoft.com/v1.0/me" + : provider === "google" + ? "calendar/v3" + : "api.example.test/summary", + ), + ), + ).toBe(true); + const logout = await f.request("/auth/logout", { + method: "POST", + headers: { cookie: signed.cookie, origin: f.origin, "content-type": "application/json" }, + body: Buffer.from(JSON.stringify({ provider, revoke: provider !== "microsoft" })), + }); + expect(logout.status).toBe(204); + const retired = await ( + await f.request("/auth/session", { headers: { cookie: signed.cookie } }) + ).json(); + expect(retired.authenticated).toBe(false); + }, + ); + it.each(["ssr", "spa"] as const)( + "composes the %s document without exposing private server configuration", + async (mode) => { + const f = await fixture("google", mode); + const signed = await f.login(); + const response = await f.request("/account", { headers: { cookie: signed.cookie } }); + const text = await response.text(); + expect(text).toContain(`name="askr-example-provider" content="google"`); + expect(text).toContain(`name="askr-example-mode" content="${mode}"`); + if (mode === "spa") { + expect(text).toContain('
'); + expect(text).not.toContain("Private account"); + expect(text).not.toContain("fixture-account"); + } else { + expect(text).toContain("Private account"); + expect(text).toContain("fixture-account"); + } + for (const canary of [...canaries, ...f.secrets()]) expect(text).not.toContain(canary); + expect(text).not.toContain("OWNED_ACTION_CSRF_SECRET"); + expect(response.headers.get("cache-control")).toBe("no-store"); + }, + ); + it("rejects wrong Host for assets and auth before dispatch and keeps static methods finite", async () => { + const f = await fixture("auth0"); + // TLS servername remains pinned while the supplied HTTP Host deliberately differs. + expect((await f.request("/assets/owned.js")).status).toBe(200); + expect(await (await f.request("/assets/owned.js", { method: "HEAD" })).text()).toBe(""); + expect((await f.request("/assets/owned.js", { method: "POST" })).status).toBe(405); + expect((await f.request("/assets/%252e%252e/server/production.js")).status).not.toBe(200); + expect((await f.request("/assets/missing.js")).status).toBe(404); + expect( + (await f.request("/assets/owned.js", { headers: { host: "foreign.example.test" } })).status, + ).toBe(400); + expect( + (await f.request("/auth/session", { headers: { host: "foreign.example.test" } })).status, + ).toBe(400); + }); + it("rejects actual signed identity corruption with no successful session", async () => { + const f = await fixture("auth0"); + f.invalidSignature(true); + const failed = await f.login(); + expect(failed.response.status).toBe(400); + expect( + (await (await f.request("/auth/session", { headers: { cookie: failed.cookie } })).json()) + .authenticated, + ).toBe(false); + }); + it("isolates two app engines even when a copied encrypted cookie is supplied", async () => { + const first = await fixture("google"); + const second = await fixture("google", "ssr", first.origin); + expect(second.origin).toBe(first.origin); + expect(second.env.sessionSecrets).toEqual(first.env.sessionSecrets); + expect(second.env.listenPort).not.toBe(first.env.listenPort); + const signed = await first.login(); + expect( + (await (await first.request("/auth/session", { headers: { cookie: signed.cookie } })).json()) + .authenticated, + ).toBe(true); + expect( + (await (await second.request("/auth/session", { headers: { cookie: signed.cookie } })).json()) + .authenticated, + ).toBe(false); + }); + it.each(["auth0", "google"] as const)( + "requires explicit API consent after an identity-only %s sign-in", + async (provider) => { + const f = await fixture(provider); + const signed = await f.login(); + expect(signed.response.status).toBe(303); + const authorization = f.events.find((event) => event.kind === "authorize"); + expect(authorization).toBeTruthy(); + expect(new URL(authorization!.target).searchParams.has("audience")).toBe(false); + expect(new URL(authorization!.target).searchParams.get("scope")?.split(" ")).not.toContain( + f.env.api!.scopes[0], + ); + const csrf = await ( + await f.request("/api/action-csrf", { headers: { cookie: signed.cookie } }) + ).json(); + const action = await f.request("/account", { + method: "POST", + headers: actionHeaders(f.origin, signed.cookie, csrf.csrfToken), + body: Buffer.from("{}"), + }); + expect(action.status).toBe(401); + expect(await action.json()).toMatchObject({ + code: "interaction-required", + reason: "consent", + provider, + resource: f.env.api!.resource, + scopes: f.env.api!.scopes, + }); + expect(f.refreshes).toBe(0); + expect(f.events.some((event) => event.target === f.env.api!.url)).toBe(false); + }, + ); + it("aborts an allocated refresh before a logout response can revive private state", async () => { + const f = await fixture("google"); + const signed = await f.login("/account", f.env.api); + const csrf = await ( + await f.request("/api/action-csrf", { headers: { cookie: signed.cookie } }) + ).json(); + f.advance(2000); + const hold = f.holdRefresh(); + const controller = new AbortController(); + const action = f.request("/account", { + method: "POST", + headers: actionHeaders(f.origin, signed.cookie, csrf.csrfToken), + body: Buffer.from("{}"), + signal: controller.signal, + }); + const observed = action.then( + (value) => ({ value }), + (error) => ({ error }), + ); + try { + await bounded(hold.entered, "refresh allocation"); + const logout = await bounded( + f.request("/auth/logout", { + method: "POST", + headers: { cookie: signed.cookie, origin: f.origin, "content-type": "application/json" }, + body: Buffer.from("{}"), + }), + "logout during refresh", + ); + expect(logout.status).toBe(204); + await bounded(hold.aborted, "identity-owned upstream transport abort"); + const settled = await bounded(observed, "retired action settlement"); + if ("value" in settled && settled.value) { + expect(settled.value.status).toBeGreaterThanOrEqual(400); + const body = await settled.value.text(); + for (const canary of canaries) expect(body).not.toContain(canary); + expect(body).not.toContain("The provider API accepted"); + } else expect("error" in settled).toBe(true); + } finally { + controller.abort(); + hold.release(); + await bounded(observed, "released action drain"); + } + expect( + (await (await f.request("/auth/session", { headers: { cookie: signed.cookie } })).json()) + .authenticated, + ).toBe(false); + }); +}); diff --git a/provider-auth/tests/signed-provider.ts b/provider-auth/tests/signed-provider.ts new file mode 100644 index 0000000..5b7f103 --- /dev/null +++ b/provider-auth/tests/signed-provider.ts @@ -0,0 +1,905 @@ +import assert from "node:assert/strict"; +import { generateKeyPairSync, createHash, sign, verify } from "node:crypto"; +import { request as httpsRequest, createServer as createHttpsServer } from "node:https"; +import { createServer } from "node:net"; +import { mkdtemp, readFile, writeFile, mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import type { createExampleApp } from "../src/server/app.js"; +import type { withApplicationLifecycle } from "../src/server/compose.js"; +import type { startHttps } from "../src/server/https.js"; +import type { ProviderName } from "../src/public-config.js"; +import type { AskrApp } from "@askrjs/server/askr"; +import type { ProviderEnvironment } from "../src/server/environment.js"; + +const tenant = "01234567-89ab-cdef-0123-456789abcdef"; +const scopes = { + auth0: "read:summary", + microsoft: "https://graph.microsoft.com/User.Read", + google: "https://www.googleapis.com/auth/calendar.calendarlist.readonly", + apple: "", +}; +const identityScopes = { + auth0: "openid profile email offline_access", + microsoft: "openid profile email offline_access", + google: "openid profile email", + apple: "openid name email", +}; +export const canaries = [ + "MOCK_CLIENT_PRIVATE_CANARY", + "MOCK_ACCESS_PRIVATE_CANARY", + "MOCK_REFRESH_PRIVATE_CANARY", + "MOCK_ID_PRIVATE_CANARY", + "MOCK_JWT_PRIVATE_CANARY", + "MOCK_SESSION_ENCRYPTION_CANARY", +]; +export interface MockEvent { + readonly kind: string; + readonly target: string; + readonly fields?: Readonly>; +} +export async function bounded(operation: Promise, label: string, ms = 2500): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + operation, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(`Owned fixture deadline: ${label}`)), ms); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} +async function port() { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + assert.ok(address && typeof address === "object"); + const value = address.port; + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + return value; +} +function cookieHeaders(lines: readonly string[]) { + return lines.map((line) => line.split(";")[0]).join("; "); +} +function html(value: string) { + return value + .replaceAll("&", "&") + .replaceAll('"', """) + .replaceAll("<", "<") + .replaceAll(">", ">"); +} +export interface FixtureOwners { + readonly createExampleApp: typeof createExampleApp; + readonly withApplicationLifecycle: typeof withApplicationLifecycle; + readonly startHttps: typeof startHttps; + readonly createDocumentApp: ( + app: Pick, + document: string, + ) => Pick; +} +export async function signedProviderFixture( + provider: ProviderName, + mode: "ssr" | "spa", + owners: FixtureOwners, + document: string, + assetsDirectory?: string, + publicOrigin?: string, + browserSimulation = false, +) { + // A different reserved site preserves SameSite semantics without invoking a + // provider's OS sign-in extension. This is never a live provider origin. + const browserAuthorizationOrigin = browserSimulation + ? "https://provider.example-provider.test" + : undefined; + const directory = await mkdtemp(join(tmpdir(), "askr-provider-app-")); + // The complete allocation phase is covered, including certificate, key and listener setup. + let cleanupEarly: (() => Promise) | undefined; + try { + const events: MockEvent[] = []; + const callbackRequests: Array<{ + url: string; + method: string; + headers: Record; + body?: Uint8Array; + }> = []; + const failures: unknown[] = []; + const unknownTraffic: string[] = []; + const releaseHolds = new Set<() => void>(); + let now = Date.now(); + let deny = false; + let invalidSignature = false; + let refreshError: string | undefined; + let refreshes = 0; + let exchanges = 0; + let revoked = false; + let hold: + | { + entered: Promise; + enter(): void; + released: Promise; + release(): void; + aborted: Promise; + abort(): void; + } + | undefined; + const rsa = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const apple = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); + const jwk = { + ...rsa.publicKey.export({ format: "jwk" }), + kid: "fixture-rsa", + alg: "RS256", + use: "sig", + }; + const caPath = join(directory, "ca.pem"); + const keyPath = join(directory, "tls.key"); + const applePath = join(directory, "apple.p8"); + const generated = spawnSync( + "openssl", + [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-days", + "2", + "-subj", + "/CN=Askr owned protocol fixture", + "-addext", + "subjectAltName=DNS:app.example.test,DNS:provider.example-provider.test,DNS:tenant.example.test,DNS:login.microsoftonline.com,DNS:accounts.google.com,DNS:oauth2.googleapis.com,DNS:www.googleapis.com,DNS:appleid.apple.com,DNS:api.example.test,DNS:graph.microsoft.com", + "-keyout", + keyPath, + "-out", + caPath, + ], + { encoding: "utf8", timeout: 30_000 }, + ); + if (generated.status !== 0) { + await rm(directory, { recursive: true, force: true }); + throw new Error("Owned TLS fixture requires a working openssl executable"); + } + await writeFile(applePath, apple.privateKey.export({ format: "pem", type: "pkcs8" })); + const ca = await readFile(caPath); + const appPort = await port(); + const origin = publicOrigin ?? `https://app.example.test:${appPort}`; + const issuer = + provider === "auth0" + ? "https://tenant.example.test/" + : provider === "microsoft" + ? `https://login.microsoftonline.com/${tenant}/v2.0` + : provider === "google" + ? "https://accounts.google.com" + : "https://appleid.apple.com"; + const authorize = + provider === "auth0" + ? `${issuer}authorize` + : provider === "microsoft" + ? `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize` + : provider === "google" + ? `${issuer}/o/oauth2/v2/auth` + : `${issuer}/auth/authorize`; + const token = + provider === "auth0" + ? `${issuer}oauth/token` + : provider === "microsoft" + ? `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token` + : provider === "google" + ? "https://oauth2.googleapis.com/token" + : `${issuer}/auth/token`; + const jwks = + provider === "auth0" + ? `${issuer}.well-known/jwks.json` + : provider === "microsoft" + ? "https://login.microsoftonline.com/common/discovery/v2.0/keys" + : provider === "google" + ? "https://www.googleapis.com/oauth2/v3/certs" + : `${issuer}/auth/keys`; + const revoke = + provider === "auth0" + ? `${issuer}oauth/revoke` + : provider === "google" + ? "https://oauth2.googleapis.com/revoke" + : provider === "apple" + ? `${issuer}/auth/revoke` + : undefined; + const api = + provider === "auth0" + ? "https://api.example.test/summary" + : provider === "microsoft" + ? "https://graph.microsoft.com/v1.0/me?$select=displayName" + : provider === "google" + ? "https://www.googleapis.com/calendar/v3/users/me/calendarList" + : undefined; + const discovery = `${issuer.replace(/\/$/u, "")}/.well-known/openid-configuration`; + const browserAuthorize = browserAuthorizationOrigin + ? new URL(new URL(authorize).pathname, browserAuthorizationOrigin).href + : authorize; + const allowed = new Set([ + discovery, + authorize, + token, + jwks, + ...(revoke ? [revoke] : []), + ...(api ? [api] : []), + ]); + const codes = new Map< + string, + { nonce: string; challenge: string; redirect: string; scope: string } + >(); + let sequence = 0; + let last: { nonce: string; challenge: string; redirect: string; scope: string } | undefined; + let refreshToken = "MOCK_REFRESH_PRIVATE_CANARY_0"; + const accessTokens = new Set(); + const idTokens = new Set(); + const jwt = (claims: Record) => { + const header = Buffer.from( + JSON.stringify({ alg: "RS256", kid: "fixture-rsa", typ: "JWT" }), + ).toString("base64url"); + const payload = Buffer.from(JSON.stringify(claims)).toString("base64url"); + const signature = sign( + "RSA-SHA256", + Buffer.from(`${header}.${payload}`), + invalidSignature + ? generateKeyPairSync("rsa", { modulusLength: 2048 }).privateKey + : rsa.privateKey, + ).toString("base64url"); + return `${header}.${payload}.${signature}`; + }; + const mock = createHttpsServer( + { cert: ca, key: await readFile(keyPath) }, + async (request, response) => { + try { + const target = new URL(request.url ?? "/", `https://${request.headers.host}`); + if (browserAuthorizationOrigin && target.origin === browserAuthorizationOrigin) { + assert.equal(`${target.origin}${target.pathname}`, browserAuthorize); + const canonical = new URL(authorize); + target.host = canonical.host; + target.protocol = canonical.protocol; + } + const exact = `${target.origin}${target.pathname}${target.search}`; + const endpoint = `${target.origin}${target.pathname}`; + assert.ok(allowed.has(endpoint) || allowed.has(exact), "Unknown provider endpoint"); + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of request) { + const bytes = Buffer.from(chunk); + size += bytes.length; + assert.ok(size <= 32768); + chunks.push(bytes); + } + const fields = new URLSearchParams(Buffer.concat(chunks).toString()); + events.push({ + kind: + endpoint === authorize + ? "authorize" + : endpoint === token + ? (fields.get("grant_type") ?? "token") + : "request", + target: exact, + fields: Object.fromEntries(endpoint === authorize ? target.searchParams : fields), + }); + response.setHeader("content-type", "application/json"); + if (endpoint === discovery) + return response.end( + JSON.stringify({ + issuer, + authorization_endpoint: authorize, + token_endpoint: token, + jwks_uri: jwks, + response_types_supported: ["code"], + response_modes_supported: ["query", "form_post"], + subject_types_supported: ["public"], + id_token_signing_alg_values_supported: ["RS256"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["client_secret_post"], + scopes_supported: identityScopes[provider].split(" "), + ...(provider === "microsoft" + ? { + end_session_endpoint: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/logout`, + } + : {}), + }), + ); + if (endpoint === jwks) return response.end(JSON.stringify({ keys: [jwk] })); + if (endpoint === authorize) { + const query = target.searchParams; + assert.equal(query.get("response_type"), "code"); + assert.equal(query.get("code_challenge_method"), "S256"); + assert.equal(query.get("redirect_uri"), `${origin}/auth/callback/${provider}`); + assert.ok(query.get("state")); + assert.ok(query.get("nonce")); + assert.ok(query.get("code_challenge")); + if (provider === "auth0" && query.has("audience")) { + assert.equal(query.get("audience"), "urn:fixture:api"); + assert.ok(query.get("scope")?.split(" ").includes(scopes.auth0)); + } + if (provider === "microsoft" || provider === "google") { + assert.equal(query.has("resource"), false); + assert.equal(query.has("audience"), false); + } + const code = `owned-code-${++sequence}`; + const transaction = { + nonce: query.get("nonce")!, + challenge: query.get("code_challenge")!, + redirect: query.get("redirect_uri")!, + scope: query.get("scope")!, + }; + codes.set(code, transaction); + last = transaction; + const result = new URLSearchParams({ + state: query.get("state")!, + ...(deny ? { error: "access_denied" } : { code }), + }); + if (provider === "apple") assert.equal(query.get("response_mode"), "form_post"); + if (provider === "apple" || browserAuthorizationOrigin) { + response.setHeader("content-type", "text/html; charset=utf-8"); + return response.end( + `
${[...result, ...(provider === "apple" ? [["user", JSON.stringify({ name: { firstName: "Unsigned", lastName: "Display Hint" } })]] : [])].map(([key, value]) => ``).join("")}
`, + ); + } + response.writeHead(303, { location: `${transaction.redirect}?${result}` }); + return response.end(); + } + if (endpoint === token || endpoint === revoke) { + assert.equal(fields.get("client_id"), "fixture-client"); + if (provider === "apple") { + const secret = fields.get("client_secret")!; + const parts = secret.split("."); + assert.equal(parts.length, 3); + assert.ok( + verify( + "sha256", + Buffer.from(`${parts[0]}.${parts[1]}`), + { key: apple.publicKey, dsaEncoding: "ieee-p1363" }, + Buffer.from(parts[2]!, "base64url"), + ), + ); + const header = JSON.parse(Buffer.from(parts[0]!, "base64url").toString()); + const claims = JSON.parse(Buffer.from(parts[1]!, "base64url").toString()); + assert.equal(header.alg, "ES256"); + assert.equal(header.kid, "KEY1234567"); + assert.equal(claims.iss, "TEAM123456"); + assert.equal(claims.sub, "fixture-client"); + assert.equal(claims.aud, issuer); + assert.ok(claims.exp > now / 1000 && claims.exp <= now / 1000 + 300); + } else assert.equal(fields.get("client_secret"), "MOCK_CLIENT_PRIVATE_CANARY"); + if (endpoint === revoke) { + assert.equal(fields.get("token"), refreshToken); + revoked = true; + response.statusCode = 200; + return response.end("{}"); + } + let transaction = last; + if (fields.get("grant_type") === "authorization_code") { + exchanges++; + const code = fields.get("code")!; + transaction = codes.get(code); + codes.delete(code); + assert.ok(transaction); + assert.equal(fields.get("redirect_uri"), transaction.redirect); + assert.equal( + createHash("sha256").update(fields.get("code_verifier")!).digest("base64url"), + transaction.challenge, + ); + } else { + assert.equal(fields.get("grant_type"), "refresh_token"); + refreshes++; + if (provider === "auth0") { + assert.equal(fields.get("audience"), "urn:fixture:api"); + assert.ok(fields.get("scope")?.split(" ").includes(scopes.auth0)); + } + if (provider === "microsoft" || provider === "google") { + assert.equal(fields.has("resource"), false); + assert.equal(fields.has("audience"), false); + assert.ok(fields.get("scope")?.split(" ").includes(scopes[provider])); + } + assert.equal(fields.get("refresh_token"), refreshToken); + if (hold) { + const owned = hold; + response.once("close", () => { + if (!response.writableEnded) owned.abort(); + }); + owned.enter(); + await owned.released; + } + if (refreshError || revoked) { + response.statusCode = 400; + return response.end(JSON.stringify({ error: refreshError ?? "invalid_grant" })); + } + } + assert.ok(transaction); + refreshToken = `MOCK_REFRESH_PRIVATE_CANARY_${exchanges + refreshes}`; + const access = `MOCK_ACCESS_PRIVATE_CANARY_${exchanges + refreshes}`; + accessTokens.add(access); + const selected = fields.get("scope") ?? transaction.scope; + const idToken = jwt({ + iss: issuer, + aud: "fixture-client", + sub: "fixture-account", + nonce: transaction.nonce, + iat: Math.floor(now / 1000), + exp: Math.floor(now / 1000) + 3600, + ...(provider === "microsoft" ? { tid: tenant } : {}), + email: "signed@example.test", + email_verified: "true", + is_private_email: "false", + private_claim: "MOCK_JWT_PRIVATE_CANARY", + }); + idTokens.add(idToken); + return response.end( + JSON.stringify({ + access_token: access, + token_type: "Bearer", + expires_in: 1, + refresh_token: refreshToken, + id_token: idToken, + ...(provider === "apple" ? {} : { scope: selected }), + }), + ); + } + assert.equal(exact, api); + assert.ok( + accessTokens.has((request.headers.authorization ?? "").replace(/^Bearer /u, "")), + ); + response.end( + JSON.stringify({ + displayName: "Explicit API display", + private: "MOCK_ID_PRIVATE_CANARY", + }), + ); + } catch (error) { + failures.push(error); + response.statusCode = 500; + response.end('{"error":"owned-provider-fixture"}'); + } + }, + ); + cleanupEarly = async () => { + mock.closeAllConnections(); + await bounded( + new Promise((resolve) => mock.close(() => resolve())), + "early mock shutdown", + ); + }; + await bounded( + new Promise((resolve, reject) => { + mock.once("error", reject); + mock.listen(0, "127.0.0.1", resolve); + }), + "mock listener setup", + ); + const address = mock.address(); + assert.ok(address && typeof address === "object"); + const mockPort = address.port; + async function pinned( + url: URL, + init: { + method?: string; + headers?: Record; + body?: Uint8Array; + signal?: AbortSignal; + }, + destination: number, + ) { + return new Promise((resolve, reject) => { + const request = httpsRequest( + { + hostname: "127.0.0.1", + port: destination, + servername: url.hostname, + ca, + method: init.method ?? "GET", + path: `${url.pathname}${url.search}`, + headers: { ...init.headers, host: init.headers?.host ?? url.host }, + signal: init.signal, + }, + (response) => { + const chunks: Buffer[] = []; + response.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + response.once("error", reject); + response.once("end", () => { + const headers = new Headers(); + for (let index = 0; index < response.rawHeaders.length; index += 2) + headers.append(response.rawHeaders[index]!, response.rawHeaders[index + 1]!); + resolve( + new Response( + init.method === "HEAD" || [204, 205, 304].includes(response.statusCode!) + ? null + : new Uint8Array(Buffer.concat(chunks)), + { status: response.statusCode, headers }, + ), + ); + }); + }, + ); + request.once("error", reject); + request.end(init.body); + }); + } + const transport: typeof fetch = async (input, init) => { + const request = new Request(input, init); + const url = new URL(request.url); + if (!allowed.has(`${url.origin}${url.pathname}`) && !allowed.has(url.href)) { + unknownTraffic.push(url.href); + throw new Error("Unallowlisted provider transport"); + } + return pinned( + url, + { + method: request.method, + headers: Object.fromEntries(request.headers), + body: request.body ? new Uint8Array(await request.arrayBuffer()) : undefined, + signal: request.signal, + }, + mockPort, + ); + }; + const base = { + mode, + origin, + listenPort: appPort, + sessionSecrets: [Buffer.alloc(32, 9).toString("base64url")], + actionCsrfSecret: "OWNED_ACTION_CSRF_SECRET", + tls: { certPath: caPath, keyPath }, + }; + const env: ProviderEnvironment = + provider === "auth0" + ? { + ...base, + provider, + credentials: { + domain: "tenant.example.test", + clientId: "fixture-client", + clientSecret: "MOCK_CLIENT_PRIVATE_CANARY", + }, + api: { resource: "urn:fixture:api", scopes: [scopes.auth0], url: api! }, + } + : provider === "microsoft" + ? { + ...base, + provider, + credentials: { + tenantId: tenant, + clientId: "fixture-client", + clientSecret: "MOCK_CLIENT_PRIVATE_CANARY", + }, + api: { + resource: "https://graph.microsoft.com", + scopes: [scopes.microsoft], + url: api!, + }, + } + : provider === "google" + ? { + ...base, + provider, + credentials: { + clientId: "fixture-client", + clientSecret: "MOCK_CLIENT_PRIVATE_CANARY", + }, + api: { resource: "https://www.googleapis.com", scopes: [scopes.google], url: api! }, + } + : { + ...base, + provider, + credentials: { + servicesId: "fixture-client", + teamId: "TEAM123456", + keyId: "KEY1234567", + privateKeyPath: applePath, + }, + }; + let serving: Awaited> | undefined; + let app: AskrApp | undefined; + try { + const application = await owners.createExampleApp(env, { + fetch: transport, + clock: () => now, + }); + const applicationOwner = owners.withApplicationLifecycle( + owners.createDocumentApp(application.app, document), + application.app, + application.publicConfig, + ); + // The preset still constructs and validates canonical endpoints. Only the + // simulated browser hop uses a reserved origin, keeping OS provider SDKs + // outside this signed HTTP fixture (notably Apple's macOS AppSSO). + app = browserAuthorizationOrigin + ? { + ...applicationOwner, + async fetch(request) { + const requestUrl = new URL(request.url); + if ( + requestUrl.origin === origin && + requestUrl.pathname === `/auth/callback/${provider}` + ) { + callbackRequests.push({ + url: request.url, + method: request.method, + headers: Object.fromEntries(request.headers), + ...(request.body + ? { body: new Uint8Array(await request.clone().arrayBuffer()) } + : {}), + }); + } + if (requestUrl.origin === origin && requestUrl.pathname === "/__fixture/authorize") { + assert.equal(request.method, "GET"); + const simulated = new URL(browserAuthorize); + simulated.search = requestUrl.search; + return new Response( + `Continue to signed simulated provider`, + { + headers: { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + }, + }, + ); + } + const response = await applicationOwner.fetch(request); + const location = response.headers.get("location"); + if (!location) return response; + const target = new URL(location, origin); + if (target.origin === origin) return response; + assert.equal(`${target.origin}${target.pathname}`, authorize); + assert.equal(response.status, 303); + events.push({ kind: "browser_authorize_redirect", target: target.href }); + // WebKit reports a native cross-site 303 navigation as interrupted. + // Keep the actual response/cookies and make the simulator handoff + // explicit before a browser gesture enters the separate provider site. + const handoff = new URL("/__fixture/authorize", origin); + handoff.search = target.search; + const headers = new Headers(response.headers); + headers.set("location", handoff.href); + return new Response(response.body, { status: response.status, headers }); + }, + } + : applicationOwner; + const assets = assetsDirectory ?? join(directory, "assets"); + if (!assetsDirectory) { + await mkdir(assets); + await writeFile(join(assets, "owned.js"), "export {};\n"); + } + serving = await owners.startHttps(app, env, assets); + } catch (error) { + for (const release of releaseHolds) release(); + const results = await Promise.allSettled([ + bounded( + Promise.resolve().then(() => app?.close()), + "early app shutdown", + ), + cleanupEarly(), + ]); + const cleanupFailures = results + .filter((result): result is PromiseRejectedResult => result.status === "rejected") + .map((result) => result.reason); + cleanupEarly = undefined; + if (cleanupFailures.length) + throw new AggregateError([error, ...cleanupFailures], "Fixture startup failed"); + throw error; + } + let closed: Promise | undefined; + return { + provider, + mode, + origin, + env, + events, + callbackRequests, + failures, + unknownTraffic, + browserDestinations: new Map([ + [`${new URL(origin).hostname}:${new URL(origin).port || "443"}`, appPort], + ...[...(browserAuthorizationOrigin ? [browserAuthorize] : allowed)].map( + (endpoint): [string, number] => { + const url = new URL(endpoint); + return [`${url.hostname}:${url.port || "443"}`, mockPort]; + }, + ), + ]), + assertBrowserDestination(url: string) { + const target = new URL(url); + if ( + target.origin !== origin && + (browserAuthorizationOrigin + ? `${target.origin}${target.pathname}` !== browserAuthorize + : !allowed.has(`${target.origin}${target.pathname}`) && !allowed.has(target.href)) + ) { + unknownTraffic.push(target.href); + throw new Error("Unknown browser destination"); + } + }, + browserAuthorizationOrigin: new URL(browserAuthorize).origin, + secrets() { + return [ + "MOCK_CLIENT_PRIVATE_CANARY", + env.actionCsrfSecret, + ...env.sessionSecrets, + ...accessTokens, + ...idTokens, + refreshToken, + ]; + }, + get exchanges() { + return exchanges; + }, + get refreshes() { + return refreshes; + }, + advance(ms: number) { + now += ms; + }, + deny(value: boolean) { + deny = value; + }, + invalidSignature(value: boolean) { + invalidSignature = value; + }, + refreshFailure(value?: string) { + refreshError = value; + }, + holdRefresh() { + assert.equal(hold, undefined); + let enter!: () => void; + let release!: () => void; + const entered = new Promise((resolve) => { + enter = resolve; + }); + const released = new Promise((resolve) => { + release = resolve; + }); + let abort!: () => void; + const aborted = new Promise((resolve) => { + abort = resolve; + }); + const owned = { + entered, + enter, + released, + aborted, + abort, + release: () => { + release(); + if (hold === owned) hold = undefined; + }, + }; + hold = owned; + releaseHolds.add(owned.release); + return owned; + }, + async request( + path: string, + init: { + method?: string; + headers?: Record; + body?: Uint8Array; + signal?: AbortSignal; + } = {}, + ) { + return pinned(new URL(path, origin), init, appPort); + }, + async browserResponse( + url: string, + init: { method: string; headers: Record; body?: Uint8Array }, + ) { + const target = new URL(url); + if (target.origin === origin) return pinned(target, init, appPort); + if (allowed.has(`${target.origin}${target.pathname}`) || allowed.has(target.href)) + return pinned(target, init, mockPort); + unknownTraffic.push(target.href); + throw new Error("Unknown browser destination"); + }, + async login( + returnTo = "/account", + api?: { readonly resource: string; readonly scopes: readonly string[] }, + ) { + const query = new URLSearchParams({ returnTo }); + if (api) { + query.set("resource", api.resource); + for (const scope of api.scopes) query.append("scope", scope); + } + const login = await pinned( + new URL(`/auth/login/${provider}?${query}`, origin), + {}, + appPort, + ); + assert.equal(login.status, 303); + const cookies = cookieHeaders(login.headers.getSetCookie()); + const authorization = await pinned( + new URL( + browserSimulation + ? events.filter((event) => event.kind === "browser_authorize_redirect").at(-1)!.target + : login.headers.get("location")!, + ), + {}, + mockPort, + ); + let callback: Response; + if (provider === "apple" || browserAuthorizationOrigin) { + const page = await authorization.text(); + const fields = new URLSearchParams(); + for (const match of page.matchAll(/name="([^"]+)" value="([^"]*)"/gu)) + fields.append( + match[1]!, + match[2]! + .replaceAll(""", '"') + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("&", "&"), + ); + const callbackUrl = new URL(`/auth/callback/${provider}`, origin); + if (provider !== "apple") callbackUrl.search = fields.toString(); + callback = await pinned( + callbackUrl, + { + method: provider === "apple" ? "POST" : "GET", + headers: { + cookie: + provider === "apple" + ? cookies + .split("; ") + .filter((value) => value.startsWith("__Host-askr-txn-")) + .join("; ") + : cookies, + "content-type": "application/x-www-form-urlencoded", + origin: new URL(browserAuthorize).origin, + }, + ...(provider === "apple" ? { body: Buffer.from(fields.toString()) } : {}), + }, + appPort, + ); + } else + callback = await pinned( + new URL(authorization.headers.get("location")!), + { headers: { cookie: cookies } }, + appPort, + ); + return { + response: callback, + cookie: cookieHeaders(callback.headers.getSetCookie()), + loginCookie: cookies, + authorization, + }; + }, + close() { + return (closed ??= (async () => { + for (const release of releaseHolds) release(); + const results = await Promise.allSettled([ + bounded(serving!.close(), "application shutdown"), + bounded( + new Promise((resolve, reject) => { + mock.close((error) => (error ? reject(error) : resolve())); + mock.closeAllConnections(); + }), + "mock shutdown", + ), + ]); + await rm(directory, { recursive: true, force: true }); + for (const result of results) + if (result.status === "rejected") failures.push(result.reason); + assert.deepEqual(unknownTraffic, []); + assert.deepEqual(failures, []); + })()); + }, + }; + } catch (error) { + let cleanupError: unknown; + try { + await cleanupEarly?.(); + } catch (failure) { + cleanupError = failure; + } finally { + await rm(directory, { recursive: true, force: true }); + } + if (cleanupError) throw new AggregateError([error, cleanupError], "Fixture allocation failed"); + throw error; + } +} diff --git a/provider-auth/tests/types/public-contract.tsx b/provider-auth/tests/types/public-contract.tsx new file mode 100644 index 0000000..e098897 --- /dev/null +++ b/provider-auth/tests/types/public-contract.tsx @@ -0,0 +1,50 @@ +import { action } from "@askrjs/askr/actions"; +import { createSPA, hydrateSPA } from "@askrjs/askr/boot"; +import { createExampleRegistry } from "../../src/registry.js"; +import { readProviderAction, actionCsrf, type ProviderSummary } from "../../src/actions.js"; +import { handleAction } from "@askrjs/server/askr"; +import { createAuth0Provider } from "@askrjs/auth/providers/auth0"; +import { createMicrosoftProvider } from "@askrjs/auth/providers/microsoft"; +import { createGoogleProvider } from "@askrjs/auth/providers/google"; +import { createAppleProvider } from "@askrjs/auth/providers/apple"; +import type { RenderableChild } from "@askrjs/askr"; +export function PublicPanel() { + action, ProviderSummary>(readProviderAction, { csrfToken: actionCsrf }); + // @ts-expect-error A fresh resolver must be asynchronous with an AbortSignal argument. + action(readProviderAction, { csrfToken: 123 }); + return

One public scoped Askr JSX owner

; +} +export const valueChildren: RenderableChild = ; +export function boot(root: HTMLElement) { + const { registry, sessionAuth } = createExampleRegistry({ provider: "google", mode: "spa" }); + return [ + createSPA({ root, registry, auth: sessionAuth }), + hydrateSPA({ root, registry, auth: sessionAuth }), + ]; +} +export const apiAction = handleAction(readProviderAction, async (context) => { + const token: string = await context.auth.token("google", { + resource: "https://www.googleapis.com", + scopes: ["https://www.googleapis.com/auth/calendar.calendarlist.readonly"], + signal: context.signal, + }); + // Token remains a server local; the action only returns a deliberate display result. + return { result: { message: token.length ? "Provider permission confirmed" : "Unavailable" } }; +}); +export const factoryTypes = [ + createAuth0Provider, + createMicrosoftProvider, + createGoogleProvider, + createAppleProvider, +] as const; +// @ts-expect-error Provider-specific credentials cannot be omitted. +createMicrosoftProvider({ id: "microsoft" }); +createAppleProvider({ + id: "apple", + servicesId: "app", + teamId: "TEAM123456", + keyId: "KEY1234567", + privateKey: {}, + // @ts-expect-error Apple cannot be configured as an API resource provider. + resources: [{ resource: "api", scopes: ["read"] }], +}); diff --git a/provider-auth/tests/virtual-server.ts b/provider-auth/tests/virtual-server.ts new file mode 100644 index 0000000..9e67ef7 --- /dev/null +++ b/provider-auth/tests/virtual-server.ts @@ -0,0 +1,8 @@ +import type { AskrApp } from "@askrjs/server/askr"; +/** Unit-only module identity for the production document boundary; every caller must provide its explicit mock. */ +export const app: Pick = { + async fetch() { + throw new Error("Production document unit tests require the explicit composed owner mock"); + }, +}; +export default app; diff --git a/provider-auth/tsconfig.json b/provider-auth/tsconfig.json new file mode 100644 index 0000000..202dbe6 --- /dev/null +++ b/provider-auth/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "jsx": "react-jsx", + "jsxImportSource": "@askrjs/askr", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "skipLibCheck": false + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"] +} diff --git a/provider-auth/vite.config.ts b/provider-auth/vite.config.ts new file mode 100644 index 0000000..be83de7 --- /dev/null +++ b/provider-auth/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vite-plus"; +import { askr } from "@askrjs/vite"; +import { askrServer } from "@askrjs/vite/server"; +export default defineConfig({ + plugins: [askr(), askrServer({ entry: "./src/server/entry-server.ts" })], + build: { manifest: true, sourcemap: false }, +}); diff --git a/provider-auth/vitest.config.ts b/provider-auth/vitest.config.ts new file mode 100644 index 0000000..d148c48 --- /dev/null +++ b/provider-auth/vitest.config.ts @@ -0,0 +1,10 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite-plus"; +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + alias: { + "virtual:askr-server": fileURLToPath(new URL("./tests/virtual-server.ts", import.meta.url)), + }, + }, +}); diff --git a/scripts/qualification/README.md b/scripts/qualification/README.md new file mode 100644 index 0000000..8a6c0e8 --- /dev/null +++ b/scripts/qualification/README.md @@ -0,0 +1,89 @@ +# Application consumer checks + +Run `npm run check` and `npm run build` first. `npm run test:runtime` then exercises the actual compiled API-only server, MCP stdio/stateless HTTP/stateful HTTP with SSE progress and session deletion, and the served SSG output in Chromium. Each child process must shut down cleanly. SSG checks the three generated documents, JavaScript/CSS MIME and bytes, missing-route 404, pre-module node identity through hydration, exactly one acknowledgment click, and unchanged installed-CLI incremental generation. It uses the application's explicit completion marker rather than framework internals. + +`npm run test:browser` preserves the existing SPA, SSR-only and authenticated API/SSR journeys, including native forms with JavaScript disabled, server node reuse, no duplicate dashboard fetch, actions, theme/locale persistence, deferred success/failure, and Monaco save. `npm run acceptance` includes both runtime gates. + +## Standalone published baseline + +This historical mode requires a source revision with published 0.4 dependency +declarations. It deliberately rejects the current 0.5 manifests; use frozen +candidate mode below for this revision. + +Use an absolute path to this workspace's installed Playwright module, and a new output directory outside this checkout: + +```sh +npm run test:stages -- --baseline --out /tmp/askr-examples-baseline-new --playwright-module /absolute/checkout/node_modules/playwright/index.mjs +``` + +This copies all six stages independently, installs only each stage's own manifest, then replays the generated lock with `npm ci` in a separate directory and a fresh cache. It verifies physical installed packages stay inside the copied stage, canonical registry URLs, package versions and archive integrity, the exact lock replay, normal peer trees, full and production audits, and each stage's check/build commands. API-only, MCP and SSG also execute the runtime fixtures. Select stages with `--stages api-only,mcp-server,ssg`; select a supported SSG browser with `--engine chromium|firefox|webkit` after installing that binary. + +The runner records `npm ls --all --json` diagnostics. The existing Vite+ 0.3.3 alias triggers one known development-only metadata conflict: `@vitest/mocker@4.1.11` advertises Vite 6/7/8 peers. That exact diagnostic is reported as a limitation, separately from normal install/ci and application gates; any other peer problem fails the runner. No strict-peer flag, force, or legacy-peer flag is supplied. + +These SSG routes have no invalidation keys: unchanged incremental generation deliberately rerenders all three routes, reports three cache hits with Core's `written:false`, and preserves the published HTML bytes. CLI atomic publication may replace physical files, so this does not assert unchanged inodes or modification times. It does not claim three skipped routes. + +CI lints these helpers on all three operating systems and runs the compiled runtime acceptance after building on Linux and macOS. The process-lifecycle checks use Unix SIGTERM semantics; Windows retains its existing application checks without claiming this runtime gate is portable there. + +Baseline mode explicitly requires the current `>=0.4.0 <0.5.0` declarations and published 0.4 packages. It records the actual Node/npm versions; `packageManager` metadata is not substituted for the executing npm version. The helpers remove `NODE_PATH`/`NODE_OPTIONS`, supply no application dependencies or overrides, and do not rewrite manifests. Existing root browser journeys remain a separate gate. + +## Frozen release candidate + +The current manifests declare the coordinated 0.5 dependencies. Replace +`--baseline` with: + +```sh +--inventory /absolute/frozen-graph.json --registry http://127.0.0.1:4873/ +``` + +Candidate mode rejects 0.4 declarations and requires all 18 reviewed packages in the frozen inventory: + +```json +{ + "schemaVersion": 1, + "version": "0.5.0", + "packages": { + "@askrjs/askr": { + "version": "0.5.0", + "integrity": "sha512-...", + "tarballSha256": "64 hex characters", + "sourceCommit": "40 hex characters" + } + } +} +``` + +Before installing any stage, the runner downloads all 18 archives from the loopback +registry at their canonical npm tarball paths. Each archive must return HTTP 200 +without redirects and match both the inventory's SHA-256 and SHA-512 SRI. It writes +the measured hashes, byte counts, canonical/served URLs and inventory producer +commits to `candidate-archives.json`; the final stage receipt includes the same +records. The coordinator must establish the source-commit-to-archive binding when +producing the frozen inventory: hashing package bytes cannot derive a Git commit. + +The loopback registry must serve those exact archives while preserving canonical npm tarball URLs in generated locks. Every installed Askr package must match the frozen version and SRI; no `file:`/link dependencies, forced installs, optional-peer suppression, or extra application dependencies are introduced. An explicit `--npm-cli /absolute/npm/bin/npm-cli.js` selects a toolchain when invoking the helper directly; `npm run test:stages` uses its executing npm CLI. + +`npm run test:qualification` exercises the archive boundary with owned HTTP +servers. Its runner regression supplies a valid SRI with a wrong SHA-256 and +asserts rejection before npm installation. Additional cases cover all 18 package +names, changed bytes, a wrong SRI, a missing archive and a rejected redirect. +`npm run check` and ordinary CI include these tests. + +Full and production audits always query the public npm advisory database. The +candidate registry serves archive and metadata GETs only, so audit POSTs must +not be sent to it. + +Final 0.5 qualification also needs the existing root browser acceptance, installed-CLI-created starters, Website and Destroyer against the same frozen graph. A successful published baseline does not complete that release gate. + +## Provider session candidate extension + +Candidate mode also copies `provider-auth` as a seventh independent stage. It +runs that stage's normal install, fresh-directory/cache `npm ci`, exact producer +version/SRI/physical-directory checks, audits, lint/unit/type/build gates, both +stage-owned TypeScript compilers, and the signed HTTPS browser suite. Install +Chromium, Firefox and WebKit for the stage-owned Playwright version beforehand; +OpenSSL must be available for the owned temporary CA. The stage supplies all its +own dependencies and never borrows a root dependency to repair its manifest. +The final API/action-CSRF/preset/SDK-retirement producers must be present in the +frozen candidate graph. Its declared ranges and normal generated lock must move +together during final preparation. Published-baseline mode deliberately keeps +its original six stages because published 0.4 lacks these new provider surfaces. diff --git a/scripts/qualification/api-only-wire.mjs b/scripts/qualification/api-only-wire.mjs new file mode 100644 index 0000000..72001de --- /dev/null +++ b/scripts/qualification/api-only-wire.mjs @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { + args, + required, + entryIdentity, + childProcess, + availablePort, + waitTcp, + fetchRecorded, + guard, +} from "./common.mjs"; + +const options = args(); +if (options.help) { + console.log( + "node api-only-wire.mjs --stage /external/isolated/api-only --out /external/evidence/api-only", + ); +} else { + const stage = required(options, "stage"); + const out = required(options, "out"); + await guard(out, "api-only-wire.json", async () => { + const identity = await entryIdentity(stage, "dist/server.js"); + const port = await availablePort(); + const origin = `http://127.0.0.1:${port}`; + const server = await childProcess(process.execPath, ["dist/server.js"], { + cwd: stage, + out, + label: "compiled-api", + env: { PORT: String(port), HOST: "127.0.0.1" }, + }); + const wire = []; + let shutdown; + try { + await waitTcp(port, server); + const get = async (path, init) => { + const response = await fetchRecorded(origin + path, init); + wire.push({ path, method: init?.method ?? "GET", ...response }); + return response; + }; + const health = await get("/api/health"); + assert.equal(health.status, 200); + assert.match(health.headers["x-request-id"], /^[0-9a-f-]{36}$/); + assert.equal(health.headers["x-content-type-options"], "nosniff"); + for (const path of ["/livez", "/readyz", "/startupz"]) { + const response = await get(path); + assert.equal(response.status, 200); + assert.equal(response.headers["cache-control"], "no-store"); + } + const user = await get("/api/users/1"); + assert.equal(user.status, 200); + assert.equal(user.headers.etag, '"1"'); + assert.equal(user.body.name, "Ada Lovelace"); + const patch = (name, etag) => ({ + method: "PATCH", + headers: { "content-type": "application/json", ...(etag ? { "if-match": etag } : {}) }, + body: JSON.stringify({ name }), + }); + const missing = await get("/api/users/1", patch("No Version")); + assert.equal(missing.status, 428); + assert.match(missing.headers["content-type"], /application\/problem\+json/); + assert.equal(missing.body.status, 428); + assert.match(missing.headers["x-request-id"], /^[0-9a-f-]{36}$/); + const malformed = await get("/api/users/1", patch("Bad Version", "nonnumeric")); + assert.equal(malformed.status, 400); + const updated = await get("/api/users/1", patch("Wire Qualified", '"1"')); + assert.equal(updated.status, 200); + assert.equal(updated.headers.etag, '"2"'); + assert.equal(updated.body.name, "Wire Qualified"); + const stale = await get("/api/users/1", patch("Stale Writer", '"1"')); + assert.equal(stale.status, 409); + assert.equal(stale.body.status, 409); + const after = await get("/api/users/1"); + assert.equal(after.body.name, "Wire Qualified"); + assert.equal(after.body.version, 2); + const unknown = await get("/api/users/404"); + assert.equal(unknown.status, 404); + assert.equal(unknown.body.status, 404); + } finally { + shutdown = await server.stop(); + } + return { identity, origin, wire, shutdown }; + }); +} diff --git a/scripts/qualification/candidate-archives.mjs b/scripts/qualification/candidate-archives.mjs new file mode 100644 index 0000000..dad2445 --- /dev/null +++ b/scripts/qualification/candidate-archives.mjs @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; + +// Hash the served bytes before installing any stage. npm separately enforces the +// same SRI during each fresh-cache install and canonical-lock replay. +export async function verifyCandidateArchives(inventory, registry) { + const archives = []; + for (const [name, candidate] of Object.entries(inventory.packages)) { + assert.match(name, /^@askrjs\/[a-z][a-z0-9-]*$/); + assert.equal(candidate.version, "0.5.0"); + const shortName = name.slice("@askrjs/".length); + const canonicalUrl = new URL( + `https://registry.npmjs.org/${name}/-/${shortName}-${candidate.version}.tgz`, + ); + const servedUrl = new URL(canonicalUrl.pathname, registry); + const response = await fetch(servedUrl, { + redirect: "error", + signal: AbortSignal.timeout(30_000), + }); + if (response.status !== 200) await response.body?.cancel(); + assert.equal(response.status, 200, `candidate archive HTTP status: ${name}`); + const bytes = Buffer.from(await response.arrayBuffer()); + const tarballSha256 = createHash("sha256").update(bytes).digest("hex"); + const integrity = `sha512-${createHash("sha512").update(bytes).digest("base64")}`; + assert.equal(tarballSha256, candidate.tarballSha256, `archive SHA256 mismatch: ${name}`); + assert.equal(integrity, candidate.integrity, `archive SRI mismatch: ${name}`); + archives.push({ + name, + version: candidate.version, + // The coordinator binds this asserted producer commit to the frozen + // inventory. Archive hashing cannot derive a Git commit from package bytes. + sourceCommit: candidate.sourceCommit, + canonicalUrl: canonicalUrl.href, + servedUrl: servedUrl.href, + bytes: bytes.length, + tarballSha256, + integrity, + }); + } + return archives; +} diff --git a/scripts/qualification/common.mjs b/scripts/qualification/common.mjs new file mode 100644 index 0000000..c68e25f --- /dev/null +++ b/scripts/qualification/common.mjs @@ -0,0 +1,205 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createServer, createConnection } from "node:net"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { appendFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { createHash } from "node:crypto"; + +export function args(argv = process.argv.slice(2)) { + const result = {}; + for (let i = 0; i < argv.length; i += 1) { + assert.match(argv[i], /^--[a-z][a-z-]*$/); + const name = argv[i].slice(2); + result[name] = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : true; + } + return result; +} +export function required(options, name) { + assert.equal(typeof options[name], "string", `--${name} is required`); + return resolve(options[name]); +} +export async function json(path) { + return JSON.parse(await readFile(path, "utf8")); +} +export function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} +export async function save(path, value) { + await writeFile(path, JSON.stringify(value, null, 2) + "\n"); +} +export function cleanEnv(extra = {}) { + const env = { ...process.env, ...extra }; + delete env.NODE_PATH; + delete env.NODE_OPTIONS; + for (const key of Object.keys(env)) if (env[key] === undefined) delete env[key]; + return env; +} +export function timeout(promise, ms, label) { + let timer; + return Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(Error(`${label} timed out after ${ms}ms`)), ms); + }), + ]).finally(() => clearTimeout(timer)); +} +export const delay = (ms) => new Promise((resolveDelay) => setTimeout(resolveDelay, ms)); +export async function availablePort() { + const server = createServer(); + await new Promise((resolveListen, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolveListen); + }); + const port = server.address().port; + await new Promise((resolveClose, reject) => + server.close((error) => (error ? reject(error) : resolveClose())), + ); + return port; +} +export async function waitTcp(port, child, ms = 10000) { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + assert.equal(child.exited, false, "server exited before becoming ready"); + if ( + await new Promise((done) => { + const socket = createConnection({ host: "127.0.0.1", port }); + socket.setTimeout(100); + socket.once("connect", () => { + socket.destroy(); + done(true); + }); + socket.once("error", () => { + socket.destroy(); + done(false); + }); + socket.once("timeout", () => { + socket.destroy(); + done(false); + }); + }) + ) + return; + await delay(40); + } + throw Error(`port ${port} did not become ready`); +} +export async function childProcess(command, commandArgs, { cwd, out, label, env = {} }) { + await mkdir(out, { recursive: true }); + const stdoutPath = join(out, `${label}.stdout.log`); + const stderrPath = join(out, `${label}.stderr.log`); + await writeFile(stdoutPath, ""); + await writeFile(stderrPath, ""); + const child = spawn(command, commandArgs, { + cwd, + env: cleanEnv(env), + stdio: ["pipe", "pipe", "pipe"], + shell: false, + }); + const result = { + command: [command, ...commandArgs], + cwd, + exited: false, + stdout: "", + stderr: "", + child, + }; + result.closed = new Promise((done, reject) => { + child.once("error", (error) => { + result.exited = true; + reject(error); + }); + child.once("close", (code, signal) => { + result.exited = true; + result.code = code; + result.signal = signal; + done({ code, signal }); + }); + }); + // Keep rejection observed even when a readiness assertion fails first. + result.closed.catch(() => {}); + child.stdout.on("data", (data) => { + result.stdout += data; + appendFileSync(stdoutPath, data); + }); + child.stderr.on("data", (data) => { + result.stderr += data; + appendFileSync(stderrPath, data); + }); + result.stop = async (stdinEof = false) => { + if (!result.exited) { + if (stdinEof) child.stdin.end(); + else child.kill("SIGTERM"); + } + try { + const exit = await timeout(result.closed, 6000, `${label} graceful shutdown`); + assert.equal(exit.code, 0, `${label} did not exit cleanly: ${JSON.stringify(exit)}`); + return exit; + } catch (error) { + if (!result.exited) child.kill("SIGKILL"); + await result.closed.catch(() => {}); + throw error; + } + }; + return result; +} +export async function run(command, commandArgs, options) { + const child = await childProcess(command, commandArgs, options); + child.child.stdin.end(); + let exit; + try { + exit = await timeout(child.closed, options.timeoutMs ?? 120000, options.label); + } catch (error) { + if (!child.exited) child.child.kill("SIGKILL"); + await child.closed.catch(() => {}); + throw error; + } + assert.ok( + (options.allowedExitCodes ?? [0]).includes(exit.code), + `${options.label} exited ${exit.code}; see ${options.out}/${options.label}.stderr.log`, + ); + return { command: child.command, code: exit.code, stdout: child.stdout, stderr: child.stderr }; +} +export async function entryIdentity(stage, entry) { + const path = join(stage, entry); + const bytes = await readFile(path); + return { + stage, + entry: path, + sha256: sha256(bytes), + node: process.version, + package: await json(join(stage, "package.json")), + }; +} +export async function fetchRecorded(url, init = {}) { + const response = await fetch(url, { ...init, signal: init.signal ?? AbortSignal.timeout(8000) }); + const text = await response.text(); + let body; + try { + body = JSON.parse(text); + } catch { + body = text; + } + return { status: response.status, headers: Object.fromEntries(response.headers), body }; +} +export async function guard(out, file, action) { + await mkdir(out, { recursive: true }); + const started = new Date().toISOString(); + try { + const evidence = await action(); + await save(join(out, file), { + status: "passed", + started, + finished: new Date().toISOString(), + ...evidence, + }); + } catch (error) { + await save(join(out, file), { + status: "failed", + started, + finished: new Date().toISOString(), + error: error.stack ?? String(error), + }); + throw error; + } +} diff --git a/scripts/qualification/independent-stages.mjs b/scripts/qualification/independent-stages.mjs new file mode 100644 index 0000000..645ae2a --- /dev/null +++ b/scripts/qualification/independent-stages.mjs @@ -0,0 +1,375 @@ +import assert from "node:assert/strict"; +import { cp, lstat, mkdir, readFile, realpath, writeFile } from "node:fs/promises"; +import { basename, dirname, join, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { args, required, json, sha256, save, run, guard } from "./common.mjs"; +import { verifyCandidateArchives } from "./candidate-archives.mjs"; + +const BASELINE_STAGES = ["spa", "ssr-only", "api-ssr", "api-only", "ssg", "mcp-server"]; +const ALL_STAGES = [...BASELINE_STAGES, "provider-auth"]; +const ALL_PACKAGES = [ + "askr", + "auth", + "charts", + "cli", + "fetch", + "i18n", + "logos", + "lucide", + "monaco", + "node", + "orm", + "otel", + "schema", + "server", + "testing", + "themes", + "ui", + "vite", +].map((name) => `@askrjs/${name}`); +const excluded = new Set([ + ".git", + ".DS_Store", + "node_modules", + "dist", + ".askr", + "coverage", + "playwright-report", + "test-results", +]); +const helperRoot = dirname(fileURLToPath(import.meta.url)); + +async function copyStage(source, destination) { + await cp(source, destination, { + recursive: true, + errorOnExist: true, + force: false, + filter: (path) => !excluded.has(basename(path)), + }); +} +function declaredAskr(manifest) { + const result = []; + for (const field of [ + "dependencies", + "devDependencies", + "optionalDependencies", + "peerDependencies", + ]) { + for (const [name, spec] of Object.entries(manifest[field] ?? {})) + if (name.startsWith("@askrjs/")) result.push({ field, name, spec }); + } + return result; +} +async function verifyInstall(root, inventory, baseline) { + const lockPath = join(root, "package-lock.json"); + const lockBytes = await readFile(lockPath); + const lock = JSON.parse(lockBytes); + assert.equal(lock.lockfileVersion, 3); + const installed = []; + const rootReal = await realpath(root); + for (const [location, record] of Object.entries(lock.packages)) { + const match = location.match(/(?:^|\/)node_modules\/(@askrjs\/[^/]+)$/); + if (!match) continue; + const name = match[1]; + const candidate = inventory?.packages[name]; + if (baseline) { + assert.match(record.version, /^0\.4\./, `baseline must use published 0.4 packages: ${name}`); + assert.match(record.integrity, /^sha512-[A-Za-z0-9+/]+=*$/); + } else { + assert.ok(candidate, `unexpected Askr package ${name}`); + assert.equal(record.version, candidate.version, `wrong candidate version at ${location}`); + assert.equal(record.integrity, candidate.integrity, `archive SRI mismatch at ${location}`); + } + assert.equal( + record.link, + undefined, + `linked package is not a normal installed consumer: ${location}`, + ); + const resolvedUrl = new URL(record.resolved); + assert.equal(resolvedUrl.protocol, "https:"); + assert.equal( + resolvedUrl.hostname, + "registry.npmjs.org", + "final locks must preserve canonical public registry URLs", + ); + const path = join(root, location); + assert.equal((await lstat(path)).isSymbolicLink(), false, `source symlink at ${location}`); + const actualPath = await realpath(path); + assert.ok( + actualPath.startsWith(rootReal + sep), + `dependency resolves outside isolated consumer: ${actualPath}`, + ); + const manifest = await json(join(path, "package.json")); + assert.equal(manifest.name, name); + assert.equal(manifest.version, record.version); + installed.push({ + name, + location, + version: record.version, + integrity: record.integrity, + resolved: record.resolved, + realpath: actualPath, + }); + } + assert.ok(installed.length > 0); + for (const declaration of declaredAskr(await json(join(root, "package.json")))) { + if (declaration.field !== "peerDependencies" && declaration.field !== "optionalDependencies") { + assert.ok( + installed.some((item) => item.name === declaration.name), + `missing declared package ${declaration.name}`, + ); + } + } + return { lockSha256: sha256(lockBytes), installed }; +} + +const options = args(); +if (options.help) { + console.log( + "npm run test:stages -- --out /new/external/run --baseline --playwright-module /browser-toolchain/node_modules/playwright/index.mjs [--stages api-only,mcp-server,ssg]\nFinal candidate: replace --baseline with --inventory /frozen/graph.json --registry http://127.0.0.1:4873/ after migrating stage ranges.", + ); +} else { + const source = required(options, "source"); + const out = required(options, "out"); + const baseline = options.baseline === true; + const inventoryPath = baseline ? undefined : required(options, "inventory"); + const npmCli = options["npm-cli"] ? required(options, "npm-cli") : process.env.npm_execpath; + assert.ok(npmCli, "run through npm run test:stages, or supply --npm-cli"); + assert.ok( + out !== source && !out.startsWith(source + sep), + "qualification output must be outside its source tree", + ); + const registry = new URL(options.registry ?? (baseline ? "https://registry.npmjs.org/" : "")); + if (baseline) { + assert.equal(registry.href, "https://registry.npmjs.org/"); + } else { + assert.ok( + ["127.0.0.1", "localhost", "[::1]"].includes(registry.hostname), + "candidate registry must be loopback", + ); + assert.equal(registry.protocol, "http:"); + } + assert.equal( + registry.username + registry.password, + "", + "candidate registry must not embed credentials", + ); + const selected = options.stages + ? options.stages.split(",") + : baseline + ? BASELINE_STAGES + : ALL_STAGES; + assert.equal(new Set(selected).size, selected.length); + assert.ok( + selected.length > 0 && + selected.every((stage) => (baseline ? BASELINE_STAGES : ALL_STAGES).includes(stage)), + ); + const browserModule = selected.includes("ssg") + ? required(options, "playwright-module") + : undefined; + const inventoryBytes = baseline ? undefined : await readFile(inventoryPath); + const inventory = baseline ? undefined : JSON.parse(inventoryBytes); + if (!baseline) { + assert.equal(inventory.schemaVersion, 1); + assert.equal(inventory.version, "0.5.0"); + assert.deepEqual(Object.keys(inventory.packages).sort(), ALL_PACKAGES.sort()); + for (const [name, candidate] of Object.entries(inventory.packages)) { + assert.equal( + candidate.version, + inventory.version, + `${name} is not the coordinated candidate`, + ); + assert.match(candidate.integrity, /^sha512-[A-Za-z0-9+/]+=*$/); + assert.match(candidate.tarballSha256, /^[a-f0-9]{64}$/); + assert.match(candidate.sourceCommit, /^[a-f0-9]{40}$/); + } + } + const preflight = []; + // Neither mode rewrites application ranges or adds dependencies. Final candidate mode rejects 0.4 manifests. + for (const stage of selected) { + const manifest = await json(join(source, stage, "package.json")); + assert.equal(manifest.packageManager, "npm@12.0.1"); + const declarations = declaredAskr(manifest); + assert.ok(declarations.length > 0); + for (const declaration of declarations) { + if (baseline) { + assert.equal( + declaration.spec, + ">=0.4.0 <0.5.0", + `baseline declaration changed: ${stage} ${declaration.name}`, + ); + } else { + assert.ok(inventory.packages[declaration.name]); + assert.ok( + [">=0.5.0 <0.6.0", "^0.5.0", "0.5.0"].includes(declaration.spec), + `unmigrated/nonregistry ${stage} ${declaration.name}: ${declaration.spec}`, + ); + } + } + preflight.push({ + stage, + manifestSha256: sha256(await readFile(join(source, stage, "package.json"))), + declarations, + }); + } + await mkdir(out, { recursive: false }); + await guard(out, "independent-stages.json", async () => { + const archives = baseline ? null : await verifyCandidateArchives(inventory, registry); + if (archives) await save(join(out, "candidate-archives.json"), { archives }); + const tool = join(out, "toolchain"); + await mkdir(join(tool, "bin"), { recursive: true }); + const quote = (value) => "'" + value.replaceAll("'", "'\"'\"'") + "'"; + await writeFile( + join(tool, "bin/npm"), + `#!/bin/sh\nexec ${quote(process.execPath)} ${quote(npmCli)} "$@"\n`, + { mode: 0o755 }, + ); + const userConfig = join(tool, "empty-user.npmrc"); + const globalConfig = join(tool, "empty-global.npmrc"); + await writeFile(userConfig, ""); + await writeFile(globalConfig, ""); + const env = Object.fromEntries( + Object.keys(process.env) + .filter((key) => /^npm_config_/i.test(key)) + .map((key) => [key, undefined]), + ); + Object.assign(env, { + PATH: join(tool, "bin") + ":" + process.env.PATH, + NPM_CONFIG_USERCONFIG: userConfig, + NPM_CONFIG_GLOBALCONFIG: globalConfig, + NPM_CONFIG_REGISTRY: registry.href, + }); + const version = await run(process.execPath, [npmCli, "--version"], { + cwd: out, + out, + label: "npm-version", + env, + }); + assert.match(version.stdout.trim(), /^\d+\.\d+\.\d+$/); + assert.ok(Number(process.versions.node.split(".")[0]) >= 24); + const results = []; + for (const { stage } of preflight) { + const first = join(out, "install", stage); + const replay = join(out, "ci", stage); + const evidence = join(out, "evidence", stage); + await mkdir(dirname(first), { recursive: true }); + await copyStage(join(source, stage), first); + await mkdir(evidence, { recursive: true }); + const npm = (root, command, label, extra = [], runOptions = {}) => + run(process.execPath, [npmCli, command, ...extra], { + cwd: root, + out: evidence, + label, + env, + timeoutMs: 600000, + ...runOptions, + }); + const ordinary = ["--registry", registry.href, "--audit=false", "--fund=false"]; + await npm(first, "install", "normal-install", [ + ...ordinary, + "--cache", + join(out, "cache-install", stage), + ]); + const initial = await verifyInstall(first, inventory, baseline); + await mkdir(dirname(replay), { recursive: true }); + await copyStage(first, replay); + await npm(replay, "ci", "fresh-cache-ci", [ + ...ordinary, + "--cache", + join(out, "cache-ci", stage), + ]); + const clean = await verifyInstall(replay, inventory, baseline); + assert.equal(clean.lockSha256, initial.lockSha256, "ci must replay the exact generated lock"); + const peerTree = await npm(replay, "ls", "normal-peer-tree", ["--all", "--json"], { + allowedExitCodes: [0, 1], + }); + const peers = JSON.parse(peerTree.stdout); + let developmentPeerLimitation = null; + if (peerTree.code !== 0) { + assert.equal(peers.error?.code, "ELSPROBLEMS"); + assert.ok(peers.problems?.length > 0); + for (const problem of peers.problems) { + assert.match(problem, /^invalid: vite@npm:@voidzero-dev\/vite-plus-core@0\.3\.3 /); + } + const mocker = await json(join(replay, "node_modules/@vitest/mocker/package.json")); + assert.equal(mocker.version, "4.1.11"); + assert.equal(mocker.peerDependencies.vite, "^6.0.0 || ^7.0.0 || ^8.0.0"); + developmentPeerLimitation = { + reason: + "Upstream Vitest mocker peer metadata excludes the supported Vite+ alias; ordinary npm install/ci and app gates are qualified separately.", + problems: peers.problems, + }; + } + // Candidate archives are GET-only; the public registry owns advisory POSTs. + const advisoryRegistry = ["--registry", "https://registry.npmjs.org/"]; + await npm(replay, "audit", "audit-all", [...advisoryRegistry, "--json"]); + await npm(replay, "audit", "audit-production", [...advisoryRegistry, "--omit=dev", "--json"]); + const manifest = await json(join(replay, "package.json")); + const gates = manifest.scripts.check + ? ["check", "build"] + : ["lint", "typecheck", "test", "build"]; + if (stage === "provider-auth") gates.push("test:types6", "test:types7", "test:browser"); + for (const script of gates) await npm(replay, "run", `stage-${script}`, [script]); + const fixture = + stage === "api-only" + ? "api-only-wire.mjs" + : stage === "mcp-server" + ? "mcp-wire.mjs" + : stage === "ssg" + ? "ssg-runtime.mjs" + : null; + if (fixture) { + const fixtureArgs = [join(helperRoot, fixture), "--stage", replay, "--out", evidence]; + if (stage === "ssg") + fixtureArgs.push( + "--playwright-module", + browserModule, + "--engine", + options.engine ?? "chromium", + ); + await run(process.execPath, fixtureArgs, { + cwd: out, + out: evidence, + label: "runtime-fixture", + env, + timeoutMs: 600000, + }); + } + assert.equal( + sha256(await readFile(join(replay, "package.json"))), + preflight.find((item) => item.stage === stage).manifestSha256, + "stage gates must not rewrite its declared dependencies", + ); + results.push({ + stage, + source: join(source, stage), + install: first, + ci: replay, + declaredManifestSha256: preflight.find((item) => item.stage === stage).manifestSha256, + initial, + clean, + gates, + peerTree: { exit: peerTree.code, developmentPeerLimitation }, + fixture, + evidence, + }); + await save(join(out, "completed-stages.json"), { status: "partial", results }); + } + return { + node: process.version, + npm: version.stdout.trim(), + registry: registry.href, + mode: baseline ? "published-0.4-baseline" : "frozen-0.5-candidate", + inventory: baseline + ? null + : { path: inventoryPath, sha256: sha256(inventoryBytes), version: inventory.version }, + archives, + source, + selected, + preflight, + results, + scope: + "Normal independent stage installations, stage checks/builds, compiled API/MCP and built SSG runtime fixtures. The provider-auth stage also runs its own strict TS6/TS7 and all-three-engine signed HTTPS acceptance. Existing root browser acceptance and all producer contracts remain separate gates.", + }; + }); +} diff --git a/scripts/qualification/mcp-wire.mjs b/scripts/qualification/mcp-wire.mjs new file mode 100644 index 0000000..f3f5b92 --- /dev/null +++ b/scripts/qualification/mcp-wire.mjs @@ -0,0 +1,287 @@ +import assert from "node:assert/strict"; +import { + args, + required, + entryIdentity, + childProcess, + availablePort, + waitTcp, + timeout, + fetchRecorded, + guard, +} from "./common.mjs"; + +const REVISION = "2025-11-25"; +const initialize = (id = 1) => ({ + jsonrpc: "2.0", + id, + method: "initialize", + params: { + protocolVersion: REVISION, + capabilities: {}, + clientInfo: { name: "askr-packed-qualification", version: "0.5.0" }, + }, +}); +const rpc = (id, method, params) => ({ + jsonrpc: "2.0", + id, + method, + ...(params === undefined ? {} : { params }), +}); +function envelope(message, id) { + assert.equal(message.jsonrpc, "2.0"); + assert.equal(message.id, id); + return message; +} +function initialized(message, name) { + envelope(message, 1); + assert.equal(message.result.protocolVersion, REVISION); + assert.equal(message.result.serverInfo.name, name); + assert.ok(message.result.capabilities.tools); +} +async function ordinaryChecks(call) { + const list = await call(rpc(2, "tools/list")); + envelope(list, 2); + assert.deepEqual( + list.result.tools.map((tool) => tool.name), + ["greet"], + ); + assert.equal(list.result.tools[0].inputSchema.type, "object"); + const greeting = await call(rpc(3, "tools/call", { name: "greet", arguments: { name: "Ada" } })); + envelope(greeting, 3); + assert.deepEqual(greeting.result.structuredContent, { greeting: "Hello, Ada!" }); + assert.deepEqual(greeting.result.content, [{ type: "text", text: "Hello, Ada!" }]); + const resource = await call(rpc(4, "resources/read", { uri: "askr://guide" })); + envelope(resource, 4); + assert.equal(resource.result.contents[0].uri, "askr://guide"); + assert.equal(resource.result.contents[0].text, "Build MCP servers with Askr."); + const invalid = await call(rpc(5, "tools/call", { name: "greet", arguments: { name: 42 } })); + assert.equal(envelope(invalid, 5).error.code, -32602); + const unknown = await call(rpc(6, "qualification/unknown")); + assert.equal(envelope(unknown, 6).error.code, -32601); + assert.deepEqual(envelope(await call(rpc(7, "ping")), 7).result, {}); +} +async function stdio(stage, out) { + const process = await childProcess(globalThis.process.execPath, ["dist/main.js", "--stdio"], { + cwd: stage, + out, + label: "mcp-stdio", + }); + const messages = []; + const waiting = new Map(); + let buffer = ""; + let protocolFailure; + process.child.stdout.setEncoding("utf8"); + process.child.stdout.on("data", (chunk) => { + buffer += chunk; + while (buffer.includes("\n")) { + const end = buffer.indexOf("\n"); + const line = buffer.slice(0, end).replace(/\r$/, ""); + buffer = buffer.slice(end + 1); + if (!line) continue; + try { + const message = JSON.parse(line); + assert.equal(message.jsonrpc, "2.0", "stdio stdout must contain protocol only"); + messages.push(message); + const request = waiting.get(message.id); + if (request) { + waiting.delete(message.id); + request.resolve(message); + } + } catch (error) { + protocolFailure = error; + for (const request of waiting.values()) request.reject(error); + waiting.clear(); + } + } + }); + process.closed.then( + (exit) => { + for (const request of waiting.values()) + request.reject(Error(`MCP closed before response: ${JSON.stringify(exit)}`)); + waiting.clear(); + }, + () => {}, + ); + const call = (message) => { + if (protocolFailure) throw protocolFailure; + const response = new Promise((resolve, reject) => waiting.set(message.id, { resolve, reject })); + process.child.stdin.write(JSON.stringify(message) + "\n"); + return timeout(response, 8000, `stdio ${message.method}`).finally(() => + waiting.delete(message.id), + ); + }; + let shutdown; + try { + initialized(await call(initialize()), "askr-example"); + process.child.stdin.write( + JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n", + ); + await ordinaryChecks(call); + } finally { + shutdown = await process.stop(true); + } + assert.equal(protocolFailure, undefined); + assert.equal(buffer.trim(), "", "stdio ended with a partial/non-newline protocol output"); + return { messages, shutdown }; +} +async function http(stage, out, stateful) { + const port = await availablePort(); + const origin = `http://127.0.0.1:${port}`; + const process = await childProcess( + globalThis.process.execPath, + ["dist/main.js", ...(stateful ? ["--stateful", "--conformance"] : [])], + { + cwd: stage, + out, + label: stateful ? "mcp-http-stateful" : "mcp-http-stateless", + env: { + PORT: String(port), + HOST_HEADER: `127.0.0.1:${port}`, + MCP_ORIGIN: origin, + MCP_RESOURCE: `${origin}/mcp`, + }, + }, + ); + const wire = []; + let session; + let shutdown; + let streamAbort; + let reader; + let streamPromise; + const headers = () => ({ + origin, + accept: "application/json, text/event-stream", + "content-type": "application/json", + "mcp-protocol-version": REVISION, + ...(session ? { "mcp-session-id": session } : {}), + }); + const request = async (path, init) => { + const response = await fetchRecorded(origin + path, init); + wire.push({ path, method: init?.method ?? "GET", ...response }); + return response; + }; + const post = async (message) => { + const response = await request("/mcp", { + method: "POST", + headers: headers(), + body: JSON.stringify(message), + }); + assert.equal(response.status, message.id === undefined ? 202 : 200); + if (message.id !== undefined) + assert.match(response.headers["content-type"], /application\/json/); + return response; + }; + try { + await waitTcp(port, process); + const blocked = await request("/mcp", { + method: "POST", + headers: { + ...headers(), + origin: "https://not-allowed.invalid", + }, + body: JSON.stringify(initialize()), + }); + assert.equal(blocked.status, 403); + const response = await post(initialize()); + initialized(response.body, stateful ? "askr-conformance" : "askr-example"); + session = response.headers["mcp-session-id"]; + if (stateful) assert.match(session, /^[0-9a-f-]{36}$/); + else assert.equal(session, undefined); + await post({ jsonrpc: "2.0", method: "notifications/initialized" }); + const call = async (message) => (await post(message)).body; + if (!stateful) { + await ordinaryChecks(call); + const get = await request("/mcp", { headers: { origin, accept: "text/event-stream" } }); + assert.equal(get.status, 405); + } else { + const list = await call(rpc(2, "tools/list")); + assert.equal(envelope(list, 2).result.tools.length, 8); + const resource = await call(rpc(3, "resources/read", { uri: "test://static-text" })); + assert.equal( + envelope(resource, 3).result.contents[0].text, + "This is the content of the static text resource.", + ); + streamAbort = new AbortController(); + const stream = await timeout( + fetch(`${origin}/mcp`, { + headers: { ...headers(), accept: "text/event-stream" }, + signal: streamAbort.signal, + }), + 8000, + "stateful SSE response headers", + ); + assert.equal(stream.status, 200); + assert.match(stream.headers.get("content-type"), /text\/event-stream/); + reader = stream.body.getReader(); + const decoder = new TextDecoder(); + let sse = ""; + let ready; + const connected = new Promise((resolve) => { + ready = resolve; + }); + streamPromise = (async () => { + while (true) { + const { value, done } = await reader.read(); + if (done) return sse; + sse += decoder.decode(value, { stream: true }); + if (sse.includes("connected")) ready(); + const progress = [...sse.matchAll(/^data: ?([^\r\n]+)\r?\n/gm)] + .map((match) => JSON.parse(match[1])) + .filter((message) => message.method === "notifications/progress"); + if (progress.length >= 3) return { sse, progress }; + } + })(); + streamPromise.catch(() => {}); + await timeout(connected, 8000, "stateful SSE connected comment"); + const progressResult = await call( + rpc(4, "tools/call", { + name: "test_tool_with_progress", + arguments: {}, + _meta: { progressToken: "packed-wire" }, + }), + ); + assert.equal( + envelope(progressResult, 4).result.content[0].text, + "Tool execution completed with progress.", + ); + const events = await timeout(streamPromise, 8000, "stateful SSE progress"); + assert.deepEqual( + events.progress.map((message) => message.params.progress), + [0, 50, 100], + ); + assert.ok(events.progress.every((message) => message.params.progressToken === "packed-wire")); + wire.push({ transport: "SSE", ...events }); + await reader.cancel(); + reader = undefined; + const deleted = await request("/mcp", { method: "DELETE", headers: headers() }); + assert.equal(deleted.status, 204); + const expired = await request("/mcp", { + headers: { ...headers(), accept: "text/event-stream" }, + }); + assert.equal(expired.status, 404); + } + } finally { + streamAbort?.abort(); + await reader?.cancel().catch(() => {}); + await streamPromise?.catch(() => {}); + shutdown = await process.stop(); + } + return { origin, wire, shutdown }; +} + +const options = args(); +if (options.help) { + console.log( + "node mcp-wire.mjs --stage /external/isolated/mcp-server --out /external/evidence/mcp", + ); +} else { + const stage = required(options, "stage"); + const out = required(options, "out"); + await guard(out, "mcp-wire.json", async () => ({ + identity: await entryIdentity(stage, "dist/main.js"), + stdio: await stdio(stage, out), + statelessHttp: await http(stage, out, false), + statefulHttpSse: await http(stage, out, true), + })); +} diff --git a/scripts/qualification/ssg-runtime.mjs b/scripts/qualification/ssg-runtime.mjs new file mode 100644 index 0000000..197bac3 --- /dev/null +++ b/scripts/qualification/ssg-runtime.mjs @@ -0,0 +1,256 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { readFile } from "node:fs/promises"; +import { join, resolve, sep } from "node:path"; +import { pathToFileURL } from "node:url"; +import { + args, + required, + json, + sha256, + childProcess, + availablePort, + waitTcp, + fetchRecorded, + run, + guard, + save, +} from "./common.mjs"; + +const routes = [ + { path: "/", file: "index.html", title: "Public operations runbooks" }, + { + path: "/runbooks/api-recovery", + file: "runbooks/api-recovery/index.html", + title: "API recovery", + }, + { + path: "/runbooks/policy-rollback", + file: "runbooks/policy-rollback/index.html", + title: "Policy rollback", + }, +]; +const options = args(); +if (options.help) { + console.log( + "node ssg-runtime.mjs --stage /external/isolated/ssg --out /external/evidence/ssg --playwright-module /external/browser-toolchain/node_modules/playwright/index.mjs [--engine chromium|firefox|webkit]", + ); +} else { + const stage = required(options, "stage"); + const out = required(options, "out"); + const playwrightPath = required(options, "playwright-module"); + const engine = options.engine ?? "chromium"; + assert.ok(["chromium", "firefox", "webkit"].includes(engine)); + await guard(out, `ssg-runtime-${engine}.json`, async () => { + const generated = join(stage, "dist/static"); + const before = {}; + for (const route of routes) { + const html = await readFile(join(generated, route.file), "utf8"); + assert.match(html, //i); + assert.ok(html.includes(route.title)); + assert.match(html, /data-askr-style-registry="true"/); + assert.match(html, /`; + await page.route(origin + "/runbooks/api-recovery", async (route) => { + const response = await route.fetch(); + const html = await response.text(); + assert.ok(html.includes("")); + await route.fulfill({ response, body: html.replace("", capture + "") }); + }); + await page.goto(origin + "/runbooks/api-recovery", { waitUntil: "load" }); + // This is the app's explicit completion marker, never a fabricated Core hydration global. + await page.locator('#app[data-askr-ready="true"]').waitFor({ timeout: 10000 }); + const identity = await page.evaluate(() => { + const saved = window.__askrQualificationNodes; + return { + captured: Boolean(saved.heading && saved.step && saved.button), + readyAtCapture: saved.readyAtCapture ?? null, + heading: saved.heading === document.querySelector("#app h1"), + step: + saved.step === + Array.from(document.querySelectorAll("#app li")).find( + (n) => n.textContent === "Confirm the readiness probe is failing.", + ), + button: saved.button === document.querySelector("#app button[aria-pressed]"), + }; + }); + assert.deepEqual(identity, { + captured: true, + readyAtCapture: null, + heading: true, + step: true, + button: true, + }); + const button = page.getByRole("button", { name: "Acknowledge this runbook", exact: true }); + assert.equal(await button.getAttribute("aria-pressed"), "false"); + await button.click(); + const acknowledged = page.getByRole("button", { name: "Runbook acknowledged", exact: true }); + await acknowledged.waitFor({ timeout: 10000 }); + assert.equal(await acknowledged.getAttribute("aria-pressed"), "true"); + assert.equal(await page.getByRole("status").textContent(), "Interactive control hydrated"); + assert.equal( + await page.evaluate( + () => + window.__askrQualificationNodes.button === + document.querySelector("#app button[aria-pressed]"), + ), + true, + ); + // One click on a fresh, unacknowledged SSR button must produce the acknowledged state. + assert.deepEqual(pageErrors, []); + assert.deepEqual(consoleErrors, []); + assert.deepEqual(requestFailures, []); + assert.deepEqual(badResponses, []); + browserEvidence = { + engine, + browserVersion: browser.version(), + playwrightModule: playwrightPath, + identity, + clicks: 1, + ariaPressed: "true", + status: "Interactive control hydrated", + pageErrors, + consoleErrors, + requestFailures, + badResponses, + }; + } finally { + await browser?.close(); + shutdown = await server.stop(); + } + await save(join(out, `ssg-browser-${engine}.json`), { + browser: browserEvidence, + wire, + shutdown, + }); + // Keyless routes rerender; Core reports written:false and published HTML bytes remain identical. + // CLI atomic publication may replace physical files without changing those bytes. + const cliRoot = resolve( + createRequire(join(stage, "package.json")).resolve("@askrjs/cli/package.json"), + "..", + ); + const cliManifest = await json(join(cliRoot, "package.json")); + const bin = typeof cliManifest.bin === "string" ? cliManifest.bin : cliManifest.bin.askr; + const cliEntry = resolve(cliRoot, bin); + assert.ok(cliEntry.startsWith(cliRoot + sep)); + const incremental = await run( + process.execPath, + [ + cliEntry, + "ssg", + "--config", + "./ssg.config.ts", + "--output", + "./dist/static", + "--incremental", + ], + { + cwd: stage, + out, + label: "ssg-installed-cli-incremental", + timeoutMs: 120000, + }, + ); + assert.match(incremental.stdout, /Mode:\s+incremental/); + assert.match(incremental.stdout, /Rebuilt:\s+3 routes/); + assert.match(incremental.stdout, /Skipped:\s+0 routes/); + assert.match(incremental.stdout, /CacheHit:\s+3 routes/); + const metadataAfter = await json(join(generated, "metadata.json")); + assert.equal(metadataAfter.rebuilt, 3); + assert.equal(metadataAfter.skipped, 0); + assert.equal(metadataAfter.cacheHits, 3); + assert.equal(metadataAfter.failed, 0); + assert.equal(metadataAfter.routes.length, 3); + for (const route of metadataAfter.routes) { + assert.equal(route.reason, "no-keys"); + assert.equal(route.written, false); + } + for (const route of routes) + assert.equal(sha256(await readFile(join(generated, route.file))), before[route.path]); + return { + node: process.version, + stage, + generatedHtml: before, + assetHashes, + metadataBefore, + wire, + browser: browserEvidence, + shutdown, + installedCli: { + version: cliManifest.version, + entry: cliEntry, + sha256: sha256(await readFile(cliEntry)), + }, + incremental: { command: incremental.command, metadata: metadataAfter }, + }; + }); +} diff --git a/scripts/qualification/workspace-runtime.mjs b/scripts/qualification/workspace-runtime.mjs new file mode 100644 index 0000000..629d26e --- /dev/null +++ b/scripts/qualification/workspace-runtime.mjs @@ -0,0 +1,38 @@ +import { mkdtemp } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { args, required, run, save } from "./common.mjs"; + +const options = args(); +const source = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const out = options.out + ? required(options, "out") + : await mkdtemp(join(tmpdir(), "askr-examples-runtime-")); +const browserModule = createRequire(join(source, "package.json")).resolve("playwright"); +const results = []; +console.log(`Compiled application runtime evidence: ${out}`); +for (const [stage, fixture] of [ + ["api-only", "api-only-wire.mjs"], + ["mcp-server", "mcp-wire.mjs"], + ["ssg", "ssg-runtime.mjs"], +]) { + const commandArgs = [ + join(source, "scripts/qualification", fixture), + "--stage", + join(source, stage), + "--out", + join(out, stage), + ]; + if (stage === "ssg") commandArgs.push("--playwright-module", browserModule); + const result = await run(process.execPath, commandArgs, { + cwd: source, + out, + label: stage, + timeoutMs: 120000, + }); + results.push({ stage, command: result.command, exit: result.code }); + console.log(`${stage}: passed`); +} +await save(join(out, "workspace-runtime.json"), { node: process.version, source, results }); diff --git a/spa/.gitignore b/spa/.gitignore new file mode 100644 index 0000000..cfd2ac6 --- /dev/null +++ b/spa/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +.vite/ +.vitest/ +coverage/ +playwright-report/ +test-results/ diff --git a/spa/package.json b/spa/package.json index 22f82fd..92461b4 100644 --- a/spa/package.json +++ b/spa/package.json @@ -13,14 +13,15 @@ "typecheck": "tsc --noEmit --pretty false" }, "dependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0", - "@askrjs/auth": ">=0.4.0 <0.5.0", - "@askrjs/lucide": ">=0.4.0 <0.5.0", - "@askrjs/themes": ">=0.4.0 <0.5.0", - "@askrjs/ui": ">=0.4.0 <0.5.0" + "@askrjs/askr": ">=0.5.0 <0.6.0", + "@askrjs/auth": ">=0.5.0 <0.6.0", + "@askrjs/lucide": ">=0.5.0 <0.6.0", + "@askrjs/themes": ">=0.5.0 <0.6.0", + "@askrjs/ui": ">=0.5.0 <0.6.0" }, "devDependencies": { - "@askrjs/vite": ">=0.4.0 <0.5.0", + "@askrjs/vite": ">=0.5.0 <0.6.0", + "jsdom": "^30.0.1", "typescript": "^7.0.2", "vite": "npm:@voidzero-dev/vite-plus-core@^0.3.1", "vite-plus": "^0.3.1", diff --git a/spa/tests/spa.test.ts b/spa/tests/spa.test.ts index 1c34335..fdb2fdd 100644 --- a/spa/tests/spa.test.ts +++ b/spa/tests/spa.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vite-plus/test"; import { createSPA } from "@askrjs/askr/boot"; import { pageRegistry } from "../src/application/routes.js"; diff --git a/spa/vitest.config.ts b/spa/vitest.config.ts index 9b9ad4d..2365984 100644 --- a/spa/vitest.config.ts +++ b/spa/vitest.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from "vitest/config"; +import { defineConfig } from "vite-plus"; export default defineConfig({ resolve: { preserveSymlinks: true }, diff --git a/ssg/.gitignore b/ssg/.gitignore new file mode 100644 index 0000000..e028938 --- /dev/null +++ b/ssg/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +.vite/ +.vitest/ +coverage/ +playwright-report/ +test-results/ +.askr/ diff --git a/ssg/package.json b/ssg/package.json index ff58190..ae387e5 100644 --- a/ssg/package.json +++ b/ssg/package.json @@ -17,14 +17,14 @@ "check": "npm run lint && npm run typecheck && npm test" }, "dependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0", - "@askrjs/lucide": ">=0.4.0 <0.5.0", - "@askrjs/themes": ">=0.4.0 <0.5.0", - "@askrjs/ui": ">=0.4.0 <0.5.0" + "@askrjs/askr": ">=0.5.0 <0.6.0", + "@askrjs/lucide": ">=0.5.0 <0.6.0", + "@askrjs/themes": ">=0.5.0 <0.6.0", + "@askrjs/ui": ">=0.5.0 <0.6.0" }, "devDependencies": { - "@askrjs/cli": ">=0.4.0 <0.5.0", - "@askrjs/vite": ">=0.4.0 <0.5.0", + "@askrjs/cli": ">=0.5.0 <0.6.0", + "@askrjs/vite": ">=0.5.0 <0.6.0", "typescript": "^7.0.2", "vite": "npm:@voidzero-dev/vite-plus-core@^0.3.1", "vite-plus": "^0.3.1", diff --git a/ssg/src/client.tsx b/ssg/src/client.tsx index 3a1d87d..38b1acb 100644 --- a/ssg/src/client.tsx +++ b/ssg/src/client.tsx @@ -2,4 +2,8 @@ import "@askrjs/themes/default"; import { hydrateSPA } from "@askrjs/askr/boot"; import { pageRegistry } from "./routes.js"; -await hydrateSPA({ root: "#app", registry: pageRegistry }); +const root = document.getElementById("app"); +if (!root) throw new Error("Missing #app root"); + +await hydrateSPA({ root, registry: pageRegistry }); +root.dataset.askrReady = "true"; diff --git a/ssg/tests/ssg.test.ts b/ssg/tests/ssg.test.ts index 90d087a..862f9df 100644 --- a/ssg/tests/ssg.test.ts +++ b/ssg/tests/ssg.test.ts @@ -1,7 +1,7 @@ import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vite-plus/test"; import { createStaticGen } from "@askrjs/askr/ssg"; import { withThemeStyles } from "@askrjs/themes/ssr"; import { pageRegistry } from "../src/routes.js"; diff --git a/ssr-only/.gitignore b/ssr-only/.gitignore new file mode 100644 index 0000000..cfd2ac6 --- /dev/null +++ b/ssr-only/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +.vite/ +.vitest/ +coverage/ +playwright-report/ +test-results/ diff --git a/ssr-only/package.json b/ssr-only/package.json index b49af57..a5ec1d5 100644 --- a/ssr-only/package.json +++ b/ssr-only/package.json @@ -15,15 +15,15 @@ "check": "npm run lint && npm run typecheck && npm test" }, "dependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0", - "@askrjs/lucide": ">=0.4.0 <0.5.0", - "@askrjs/node": ">=0.4.0 <0.5.0", - "@askrjs/server": ">=0.4.0 <0.5.0", - "@askrjs/themes": ">=0.4.0 <0.5.0", - "@askrjs/ui": ">=0.4.0 <0.5.0" + "@askrjs/askr": ">=0.5.0 <0.6.0", + "@askrjs/lucide": ">=0.5.0 <0.6.0", + "@askrjs/node": ">=0.5.0 <0.6.0", + "@askrjs/server": ">=0.5.0 <0.6.0", + "@askrjs/themes": ">=0.5.0 <0.6.0", + "@askrjs/ui": ">=0.5.0 <0.6.0" }, "devDependencies": { - "@askrjs/vite": ">=0.4.0 <0.5.0", + "@askrjs/vite": ">=0.5.0 <0.6.0", "tsx": "^4.23.12", "typescript": "^7.0.2", "vite": "npm:@voidzero-dev/vite-plus-core@^0.3.1", diff --git a/ssr-only/src/vite-server.d.ts b/ssr-only/src/vite-server.d.ts index 3f87ae0..b462f11 100644 --- a/ssr-only/src/vite-server.d.ts +++ b/ssr-only/src/vite-server.d.ts @@ -1,5 +1 @@ -declare module "virtual:askr-server" { - import type { ServerApp } from "@askrjs/server"; - export const app: ServerApp; - export default app; -} +/// diff --git a/ssr-only/tests/ssr-only.test.ts b/ssr-only/tests/ssr-only.test.ts index 3362c9a..cb21cd1 100644 --- a/ssr-only/tests/ssr-only.test.ts +++ b/ssr-only/tests/ssr-only.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "vite-plus/test"; import app from "../src/server/entry-server.js"; describe("SSR-only operations workspace", () => { diff --git a/tests/journey-contract.test.ts b/tests/journey-contract.test.ts index 4a9bbf9..c852039 100644 --- a/tests/journey-contract.test.ts +++ b/tests/journey-contract.test.ts @@ -1,5 +1,5 @@ import { readFile, readdir } from "node:fs/promises"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "vite-plus/test"; import { renderRouteRequestToString } from "@askrjs/askr/ssr"; import { pageRegistry as spaRegistry } from "../spa/src/application/routes.js"; import { pageRegistry as ssrRegistry } from "../ssr-only/src/application/routes.js"; @@ -136,3 +136,60 @@ describe("progressive example journey contract", () => { } }); }); + +describe("provider session application journey", () => { + it("declares a separately installable stage whose root build and strict gates include it", async () => { + const root = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")); + const stage = JSON.parse( + await readFile(new URL("../provider-auth/package.json", import.meta.url), "utf8"), + ); + expect(root.workspaces).toContain("provider-auth"); + expect(stage.private).toBe(true); + for (const name of [ + "@askrjs/askr", + "@askrjs/auth", + "@askrjs/node", + "@askrjs/schema", + "@askrjs/server", + ]) + expect(stage.dependencies[name]).toBeTruthy(); + expect(stage.devDependencies["@askrjs/vite"]).toBeTruthy(); + expect(stage.devDependencies.vitest).toBeUndefined(); + expect(stage.scripts.dev).toBe("npm run build && npm start"); + expect(stage.scripts.build).toContain("vp build"); + expect(root.scripts.acceptance).toContain("test:provider-types"); + expect(root.scripts.acceptance).toContain("test:provider-browser"); + }); + it("keeps provider secrets and transport in server modules with public package imports", async () => { + const browser = [ + "client.tsx", + "registry.tsx", + "public-config.ts", + "actions.ts", + "private-query.ts", + ]; + for (const file of browser) { + const source = await readFile( + new URL(`../provider-auth/src/${file}`, import.meta.url), + "utf8", + ); + expect(source, file).not.toMatch( + /process\.env|node:|@askrjs\/server|@askrjs\/auth\/providers|clientSecret|privateKeyPath/u, + ); + expect(source, file).not.toMatch(/__askr_|@askrjs\/[^'"]+\/src/u); + } + const server = await readFile( + new URL("../provider-auth/src/server/app.ts", import.meta.url), + "utf8", + ); + expect(server.match(/createAskrApp\(/gu)).toHaveLength(1); + expect(server).toContain("context.auth.token"); + const entry = await readFile( + new URL("../provider-auth/src/server/production.ts", import.meta.url), + "utf8", + ); + expect(entry).toContain('from "virtual:askr-server"'); + expect(entry).toContain('from "./entry-server.js"'); + expect(entry).toContain("withApplicationLifecycle"); + }); +}); diff --git a/tests/qualification/candidate-archive-preflight.test.mjs b/tests/qualification/candidate-archive-preflight.test.mjs new file mode 100644 index 0000000..2e40a21 --- /dev/null +++ b/tests/qualification/candidate-archive-preflight.test.mjs @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +test("candidate qualification rejects a wrong archive SHA256 before invoking npm install", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "askr-archive-preflight-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const bytes = Buffer.from("owned candidate archive bytes"); + const server = createServer((_request, response) => response.end(bytes)); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + t.after(() => new Promise((resolve) => server.close(resolve))); + const source = join(directory, "source"); + await mkdir(join(source, "api-only"), { recursive: true }); + await writeFile( + join(source, "api-only/package.json"), + JSON.stringify({ + packageManager: "npm@12.0.1", + dependencies: { "@askrjs/askr": ">=0.5.0 <0.6.0" }, + }), + ); + const packages = Object.fromEntries( + [ + "askr", + "auth", + "charts", + "cli", + "fetch", + "i18n", + "logos", + "lucide", + "monaco", + "node", + "orm", + "otel", + "schema", + "server", + "testing", + "themes", + "ui", + "vite", + ].map((name) => [ + `@askrjs/${name}`, + { + version: "0.5.0", + sourceCommit: "e".repeat(40), + integrity: `sha512-${createHash("sha512").update(bytes).digest("base64")}`, + tarballSha256: createHash("sha256").update(bytes).digest("hex"), + }, + ]), + ); + packages["@askrjs/askr"].tarballSha256 = "0".repeat(64); + const inventory = join(directory, "inventory.json"); + await writeFile(inventory, JSON.stringify({ schemaVersion: 1, version: "0.5.0", packages })); + const installMarker = join(directory, "install-was-invoked"); + const npmCli = join(directory, "npm.mjs"); + await writeFile( + npmCli, + `import { writeFileSync } from "node:fs";\nif (process.argv[2] === "--version") console.log("12.0.1");\nelse { writeFileSync(${JSON.stringify(installMarker)}, "invoked"); process.exitCode = 1; }\n`, + ); + const child = spawn( + process.execPath, + [ + fileURLToPath(new URL("../../scripts/qualification/independent-stages.mjs", import.meta.url)), + "--source", + source, + "--out", + join(directory, "out"), + "--inventory", + inventory, + "--registry", + `http://127.0.0.1:${server.address().port}/`, + "--npm-cli", + npmCli, + "--stages", + "api-only", + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + const timer = setTimeout(() => child.kill("SIGKILL"), 10_000); + t.after(() => clearTimeout(timer)); + t.after(() => child.kill()); + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.stdout.resume(); + const code = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); + }); + assert.equal(code, 1); + assert.match(stderr, /archive SHA256 mismatch: @askrjs\/askr/); + await assert.rejects(readFile(installMarker), { code: "ENOENT" }); + const receipt = JSON.parse(await readFile(join(directory, "out/independent-stages.json"))); + assert.equal(receipt.status, "failed"); + assert.match(receipt.error, /archive SHA256 mismatch/); +}); diff --git a/tests/qualification/candidate-archives.test.mjs b/tests/qualification/candidate-archives.test.mjs new file mode 100644 index 0000000..9f04f97 --- /dev/null +++ b/tests/qualification/candidate-archives.test.mjs @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { createServer } from "node:http"; +import { test } from "node:test"; +import { verifyCandidateArchives } from "../../scripts/qualification/candidate-archives.mjs"; + +const names = [ + "askr", + "auth", + "charts", + "cli", + "fetch", + "i18n", + "logos", + "lucide", + "monaco", + "node", + "orm", + "otel", + "schema", + "server", + "testing", + "themes", + "ui", + "vite", +]; +const bytes = Buffer.from("owned archive bytes\0\xff", "latin1"); +const sha256 = createHash("sha256").update(bytes).digest("hex"); +const integrity = `sha512-${createHash("sha512").update(bytes).digest("base64")}`; +function inventory(selected = ["askr"]) { + return { + packages: Object.fromEntries( + selected.map((name) => [ + `@askrjs/${name}`, + { version: "0.5.0", sourceCommit: "e".repeat(40), tarballSha256: sha256, integrity }, + ]), + ), + }; +} +async function registry(t, handler) { + const server = createServer(handler); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + t.after(() => new Promise((resolve) => server.close(resolve))); + return new URL(`http://127.0.0.1:${server.address().port}/`); +} + +test("all 18 served archives bind both hashes to canonical URLs and asserted producer commits", async (t) => { + const requests = []; + const url = await registry(t, (request, response) => { + requests.push(request.url); + response.end(bytes); + }); + const archives = await verifyCandidateArchives(inventory(names), url); + assert.equal(archives.length, 18); + for (const [index, name] of names.entries()) { + const path = `/@askrjs/${name}/-/${name}-0.5.0.tgz`; + assert.equal(requests[index], path); + assert.deepEqual(archives[index], { + name: `@askrjs/${name}`, + version: "0.5.0", + sourceCommit: "e".repeat(40), + canonicalUrl: `https://registry.npmjs.org${path}`, + servedUrl: new URL(path, url).href, + bytes: bytes.length, + tarballSha256: sha256, + integrity, + }); + } +}); +test("matching SRI cannot conceal a wrong inventory SHA256", async (t) => { + const url = await registry(t, (_request, response) => response.end(bytes)); + const graph = inventory(); + graph.packages["@askrjs/askr"].tarballSha256 = "0".repeat(64); + await assert.rejects(verifyCandidateArchives(graph, url), /archive SHA256 mismatch/); +}); +test("matching SHA256 cannot conceal a wrong inventory SRI", async (t) => { + const url = await registry(t, (_request, response) => response.end(bytes)); + const graph = inventory(); + graph.packages["@askrjs/askr"].integrity = `sha512-${Buffer.alloc(64).toString("base64")}`; + await assert.rejects(verifyCandidateArchives(graph, url), /archive SRI mismatch/); +}); +test("changed archive bytes fail rather than producing a receipt", async (t) => { + const url = await registry(t, (_request, response) => response.end("different bytes")); + await assert.rejects(verifyCandidateArchives(inventory(), url), /archive SHA256 mismatch/); +}); +test("missing archive fails with its package identity", async (t) => { + const url = await registry(t, (_request, response) => { + response.statusCode = 404; + response.end("missing"); + }); + await assert.rejects( + verifyCandidateArchives(inventory(), url), + /candidate archive HTTP status: @askrjs\/askr/, + ); +}); +test("archive redirects cannot escape the selected registry", async (t) => { + let destinationRequests = 0; + const destination = await registry(t, (_request, response) => { + destinationRequests++; + response.end(bytes); + }); + const url = await registry(t, (_request, response) => { + response.writeHead(302, { location: destination.href }); + response.end(); + }); + await assert.rejects(verifyCandidateArchives(inventory(), url), /fetch failed/); + assert.equal(destinationRequests, 0); +});