diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b4acf84 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 + +updates: + # Weekly rather than daily: this repo has a single npm tree at the root, and a + # daily cadence produces more PR noise than a maintainer team this size can + # triage without starting to rubber stamp them. + - package-ecosystem: npm + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2efc228..c926308 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,3 +42,23 @@ jobs: - name: Test run: npm test + audit: + name: Dependency audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: '22.x', cache: npm } + - run: npm ci + # Production tree gates the build; the dev tree is advisory so a vitest + # advisory cannot wedge every PR on an unrelated change. + # + # Moderate rather than high, since the production tree is now at zero and a + # threshold only holds the line it is set at. It was `high`, and that is exactly + # how three moderate `qs` advisories sat in the shipped tree while this job + # reported green: reachable through Express's query parser, so on the request path + # of every deployment using the Express adapter. + - name: Audit production dependencies + run: npm audit --omit=dev --audit-level=moderate + - name: Audit all dependencies (advisory) + run: npm audit --audit-level=moderate || true diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..0147b70 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,55 @@ +name: CodeQL + +# Separate from ci.yml deliberately. CodeQL takes minutes rather than seconds, and +# a weekly schedule only makes sense on its own workflow; folding it into the PR +# job would make every pull request wait on an analysis that rarely changes its +# answer between commits. +on: + push: + branches: [main, master, develop] + pull_request: + branches: [main, master, develop] + schedule: + # Monday 07:00 UTC. The scheduled run is the one that matters: it re-analyses + # unchanged code against updated queries, which is how a newly published + # vulnerability class gets found in code nobody has touched. + - cron: '0 7 * * 1' + +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + # Least privilege: the analysis needs to read the tree and write findings, + # and nothing else. + actions: read + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: ['javascript-typescript'] + + steps: + - uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + # security-extended over the default pack. The default set is tuned to + # keep false positives near zero on any repository; this one is an + # authentication library, so a quieter scan is the wrong trade. + queries: security-extended + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@v3 + with: + category: /language:${{ matrix.language }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ed2726..29c978e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 All packages in this workspace share a single version. -## [Unreleased] +## [0.11.0] - 2026-09-24 + +Minor rather than patch, and the reason is one line of behaviour: the session cookie now +carries `Secure` by default. A browser will not send a `Secure` cookie over `http://`, so a +deployment that terminates TLS nowhere loses its sessions on upgrade. That is a real break +even though the change is strictly more secure, and it is the case to read before adopting. + +The other changes are additive or bug fixes. `InMemoryChallengeStore` and +`InMemorySessionStore` gained an optional constructor argument, and both now drop records +past their retention bound, so a consumer holding a `challenge_id` past its TTL sees `null` +where it previously saw a stale record. ### Added @@ -139,6 +149,85 @@ All packages in this workspace share a single version. ### Fixed +- **A malformed NIP-98 `u` tag is a mismatch, not an unhandled exception** (#33). + `exactUrlMatch()` called `new URL()` on the `u` tag with no guard, and that tag is + attacker-supplied, so a completion carrying `u: "not-a-url"` threw `TypeError` out of + `verifyNip98Completion()` instead of returning `NAP_COMPLETE_URL_MISMATCH`. On an + unauthenticated endpoint that cost three things at once: the adapter answered `500` rather + than the uniform `401`, the response skipped the `padAuthResponse()` floor that makes + failures indistinguishable (RFC §15), and the throw happened before `logFailure()` so the + request produced no audit record at all. A valid signature is needed to reach the check, + but any throwaway key will do. + + Half the added tests exist to stop the fix going the other way: this is the audience + binding, so making the function total by loosening the comparison would be an + authentication bypass. A trailing slash, a different path, host, scheme or port, and + userinfo must all still fail. `nap-java` was unaffected, having always caught here. + +- **`writeNapCookieSuccess` now defaults to a protected cookie, and merges caller options + over those defaults** (#34). The default path emitted `session=TOKEN; Path=/`, with no + `HttpOnly`, `Secure` or `SameSite`, so the access token was readable by any script on the + page, travelled in cleartext, and rode along on cross-site requests. The helper's whole + stated purpose is keeping that credential away from script, and `toPublicSessionView` + already omits `access_token` from `GET /auth/session` on the assumption of an `HttpOnly` + cookie the default did not produce. + + The second half was worse: partial options replaced the attributes rather than adding to + them, so `{ domain: '.example.com' }` (a caller setting one attribute, the case a real + deployment hits) silently dropped all three protections. Options are now spread over + `{ httpOnly: true, secure: true, sameSite: 'lax', path: '/' }`, which also leaves an + explicit `httpOnly: false` winning for local development. `nap-java` already defaulted + this way, so the divergence where one deployment was safe on the JVM and not on Node is + closed. Both adapters fixed, with the partial-options case covered as a regression test. + +- **The in-memory stores evict** (#35). `InMemoryChallengeStore` and `InMemorySessionStore` + never removed a record: challenges were marked expired and sessions stamped `revoked_at`, + and both stayed resident for the life of the process. Both maps are filled by + unauthenticated traffic, and the outstanding-challenge caps do not help because they count + only records still in `issued`, so they bound concurrency rather than memory. + + The retention bounds are deliberate rather than plain expiry. A challenge is kept until + `result_cache_until` when it was redeemed, because that window is what makes a client + retry idempotent under RFC §13.3. A session is kept until `expires_at` or + `refresh_expires_at`, whichever is later, because `getByRefreshToken()` answers for + revoked sessions so a replay stays recognisable, and evicting at the access window would + turn a detected reuse into a merely unknown token. Both sweep on the write path as well as + the read path: sweeping only on reads left a server that takes logins and serves no + guarded requests growing without bound, which is the shape of the attack rather than an + edge case. + +- **`/auth/session` reads expiry from the server's clock** (#38). Both adapters built the + handler's guard options without `clock`, so a deployment on an injected clock judged + expiry by the wall clock on exactly that one endpoint, while `/auth/logout` two functions + away passed it correctly. A session live on the injected clock answered `401`. The route + options now come from one builder, so the call sites cannot drift apart again. + +### Security + +- **Production dependency advisories cleared, and scanning added to CI** (#36). `npm audit + --omit=dev` went from four high-severity findings to none. Two of them bore directly on + controls this repository implements: Fastify's `request.protocol` and `request.host` + spoofing sits underneath `createRequestDerivedBaseUrlResolver()`, and `body-parser` + silently disabling size enforcement on an invalid limit sits underneath the 1 kB cap + `createNapExpressJsonParser()` applies to an unauthenticated endpoint. + + `vitest` moved 2 to 4 and `testcontainers` 10 to 12, both semver-major, clearing the + critical advisory on the test runner. CI now audits the production tree as a gate and the + full tree advisorily, which is the split that keeps it tuned: a dev-only advisory should + not wedge every unrelated pull request. CodeQL and Dependabot added, and `SECURITY.md` + records the controls that are repository settings rather than files. + + **Amended after CI ran.** "None" above was true at `--audit-level=high`, which is where the + gate was set, and three moderate `qs` advisories were sitting under it the whole time: + GHSA-4mjr-xmp4-gh2g, GHSA-q8mj-m7cp-5q26 and GHSA-x5fp-wj9c-mxmx. `qs` is Express's query + parser, so they are on the request path of every deployment using the Express adapter, not + a build-time concern. + + Express pins `qs` at `~6.14.0` and `~` locks the minor, so no upgrade of Express reaches + the fix. An `overrides` entry scoped to `express` pulls it to 6.16.0. The production gate + now runs at `--audit-level=moderate`, because a threshold only holds the line it is set at, + and the production tree is at zero rather than at "nothing above high". + - **`maxSessionLifetimeSeconds` now clamps the tokens it issues**, so the ceiling is a wall rather than an estimate. It previously gated only the *decision* to refresh: a refresh one second before the ceiling minted a full-length access token, and guarded requests kept diff --git a/README.md b/README.md index 445ae8c..45cefa6 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,9 @@ on it and version skew surfaces as confusing `verifyEvent` failures. ## Documentation +- [UPGRADING.md](UPGRADING.md) — what breaks between releases and what to do about it. + Read this before taking 0.11.0: the session cookie now defaults to `Secure`, which ends + sessions on any deployment serving over plain `http`. - [docs/tutorials/](docs/tutorials/README.md) — the tutorial series, in order, with what each one gets you. - [docs/NAP-v2-RFC.md](docs/NAP-v2-RFC.md) — the protocol specification. The authority. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..cbfc7e2 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,72 @@ +# Security + +## Reporting a vulnerability + +Do not open a public issue for an exploitable vulnerability in NAP. Report it privately +through GitHub's [private vulnerability reporting][pvr] on this repository, or to the +maintainer directly. + +NAP is an authentication protocol implementation, so a flaw here is a flaw in every +deployment that depends on it. Please include the version, whether the issue is in the +protocol or in one adapter, and a reproduction if you have one. + +[pvr]: https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability + +## Automated checks + +Three run in CI and are visible in the repository: + +| Check | Where | Gates a PR? | +| --- | --- | --- | +| Dependency audit (production tree) | `.github/workflows/ci.yml` | Yes, at `--audit-level=high` | +| Dependency audit (all, incl. dev) | `.github/workflows/ci.yml` | No, advisory only | +| CodeQL (`security-extended`) | `.github/workflows/codeql.yml` | Findings surface in the Security tab | + +The production/dev split is deliberate. A dev-only advisory (a test runner, a bundler) +should not wedge every unrelated pull request, because a check developers learn to click +past has negative value. The production tree is small, actionable, and should always be +green. + +## Settings that cannot live in this file + +These are repository settings rather than files, so they have to be enabled in the GitHub +UI. They are listed here because a control nobody wrote down is a control nobody turns +back on after it is disabled. + +**Secret scanning and push protection** +`Settings > Code security and analysis > Secret scanning`, both the scan and push +protection. A manual scan of the tree found nothing committed as of the 0.10.1 audit, +which is the right moment to turn the guard on rather than the reason to skip it. + +**Private vulnerability reporting** +`Settings > Code security and analysis > Private vulnerability reporting`. Without it a +reporter's only options are a public issue or nothing, and the first is worse. + +**Dependabot alerts and security updates** +`Settings > Code security and analysis`. `.github/dependabot.yml` schedules version +updates; alerts are the separate switch that surfaces a CVE between scheduled runs. + +**Branch protection on the default branch** +Require the build and audit checks to pass before merge. Without this the CI jobs are +advisory in practice no matter what they return. Note that `validate` is a matrix job, so +it reports one check per Node version (`Validate (Node 20.19.0)` and `Validate (Node 22.x)` +today) rather than a single `Validate`; select the ones the UI actually lists rather than +typing a name. `Dependency audit` is a single check. + +## Scope notes for anyone auditing this repository + +Worth knowing before you start, from the 0.10.1 audit: + +- **The audience binding is the highest-severity surface.** `createAudienceHostAllowlist()` + and `createRequestDerivedBaseUrlResolver()` decide what every NIP-98 proof is checked + against, from a client-supplied `Host` header. Both refuse an empty allowlist at wiring + time rather than per request; that is deliberate and should stay that way. +- **The voucher extension reaches the network.** `nap-voucher` makes outbound calls to + mint URLs that arrive in the request body. The ordering in `resolver.ts` (allowlist + first, always) is a security property, not a style choice. +- **Retention is not yet solved for SQL stores.** The in-memory stores evict; the Postgres + store has no `DELETE` path. See the open issue on store retention. +- **The response floor is load-bearing.** `padAuthResponse()` exists so a failed + authentication cannot be distinguished by latency. Anything that returns early, throws, + or answers on a different schedule undermines it, which is how the malformed `u` tag bug + (a 500 escaping the floor, unaudited) mattered more than it first looked. diff --git a/UPGRADING.md b/UPGRADING.md new file mode 100644 index 0000000..4b0c0aa --- /dev/null +++ b/UPGRADING.md @@ -0,0 +1,76 @@ +# Upgrading + +## 0.10.1 to 0.11.0 + +One change breaks a working deployment, and it is the one that sounds harmless: the session +cookie now carries `Secure` by default. Everything else is additive or a bug fix. + +No data migration. All packages in the workspace share a version, so upgrade them together. + +### Before you deploy + +**1. If anything serves over plain `http://`, say so explicitly.** + +`writeNapCookieSuccess()` used to default to a session cookie with no `HttpOnly`, no +`Secure`, and no `SameSite`. It now defaults to all three, and a browser will not send a +`Secure` cookie over `http://`. A deployment terminating TLS nowhere stops authenticating +the moment it upgrades, and it does so silently: the login succeeds, the cookie is set, and +the next request simply arrives without it. + +Local development and anything genuinely behind plain http needs the escape hatch: + +```ts +// Cookie options are the second argument, after the cookie name. +writeNapCookieSuccess('nap_session', { secure: false }) +``` + +Production should terminate TLS instead. The escape hatch is per-attribute, so turning off +`secure` leaves `httpOnly` and `sameSite` in place. + +**2. If you pass cookie options, re-read them.** + +Partial options used to *replace* the defaults rather than merge with them, so passing +`{ maxAge }` alone silently dropped every security attribute. They now merge, which means +an option you pass explicitly still wins, but the ones you leave out are no longer discarded. + +Worth an actual look: if you were compensating for the old behaviour by restating +attributes you did not otherwise care about, those restatements are now redundant rather +than load-bearing. + +**3. Expect expired challenges and sessions to disappear.** + +`InMemoryChallengeStore` and `InMemorySessionStore` now evict records past their retention +bound, where they previously grew without limit. Code holding a `challenge_id` past its TTL +now sees `null` where it used to see a stale record. The bound is derived from the +record itself (`result_cache_until` for a redeemed challenge, `expires_at` otherwise), so +RFC §13.3 retry safety is preserved: a redeemed challenge still inside its result-cache +window survives. + +Both constructors take an optional `{ clock }`. If you inject a clock into +`NapServerOptions`, pass the same one here. A store sweeping on a different clock from the +server either keeps records the server has written off or drops ones it still considers +live. + +This only affects the in-memory stores. The SQL stores still retain expired rows (nap#39), +which is tracked separately. + +### Deploying alongside `nap-java` + +Both implementations now put the access token in the cookie and authenticate with +`getByAccessToken()`. Before this release they disagreed, so a cookie minted by one server +could not be presented to the other. + +If both run against one session store, deploy `nap` 0.11.0 and `nap-java` 0.9.0 together. +A mixed fleet mid-rollout rejects cookies issued by the other side, which presents as users +being logged out at random rather than once. + +### Verifying afterwards + +```bash +curl -si https://your-host/auth/session | grep -i set-cookie +``` + +You want `HttpOnly`, `Secure`, and `SameSite` on that line. If sessions stop working right +after the upgrade and that line is present, the likely cause is the first item above: the +cookie is being set correctly and withheld on the next request because the origin is not +`https`. diff --git a/examples/merchant-app/package.json b/examples/merchant-app/package.json index 24dfaa8..4d62e5b 100644 --- a/examples/merchant-app/package.json +++ b/examples/merchant-app/package.json @@ -1,7 +1,7 @@ { "name": "@imani/nap-example-merchant-app", "private": true, - "version": "0.10.1", + "version": "0.11.0", "type": "module", "description": "The runnable example that docs/tutorials/ is built around.", "exports": { @@ -16,13 +16,13 @@ "db:down": "docker compose down -v" }, "dependencies": { - "@imani/nap-adapter-express": "0.10.1", - "@imani/nap-client-nip46": "0.10.1", - "@imani/nap-client-web": "0.10.1", - "@imani/nap-core": "0.10.1", - "@imani/nap-react": "0.10.1", - "@imani/nap-server": "0.10.1", - "@imani/nap-store-postgres": "0.10.1", + "@imani/nap-adapter-express": "0.11.0", + "@imani/nap-client-nip46": "0.11.0", + "@imani/nap-client-web": "0.11.0", + "@imani/nap-core": "0.11.0", + "@imani/nap-react": "0.11.0", + "@imani/nap-server": "0.11.0", + "@imani/nap-store-postgres": "0.11.0", "express": "^4.21.2", "nostr-tools": "^2.23.0", "pg": "^8.13.1", @@ -30,7 +30,7 @@ "react-dom": "^19.0.0" }, "devDependencies": { - "@imani/nap-client-http": "0.10.1", + "@imani/nap-client-http": "0.11.0", "@types/express": "^5.0.0", "@types/pg": "^8.11.10", "@types/react": "^19.0.0", diff --git a/package-lock.json b/package-lock.json index c946659..70ee8d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nap", - "version": "0.10.1", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nap", - "version": "0.10.1", + "version": "0.11.0", "workspaces": [ "packages/*", "examples/*" @@ -15,24 +15,24 @@ "@types/node": "^22.10.2", "@types/pg": "^8.20.0", "@types/ws": "^8.18.1", - "testcontainers": "^10.28.0", + "testcontainers": "^12.0.4", "tsx": "^4.19.2", "typescript": "^5.7.2", - "vitest": "^2.1.9", + "vitest": "^4.1.11", "ws": "^8.21.3" } }, "examples/merchant-app": { "name": "@imani/nap-example-merchant-app", - "version": "0.10.1", - "dependencies": { - "@imani/nap-adapter-express": "0.10.1", - "@imani/nap-client-nip46": "0.10.1", - "@imani/nap-client-web": "0.10.1", - "@imani/nap-core": "0.10.1", - "@imani/nap-react": "0.10.1", - "@imani/nap-server": "0.10.1", - "@imani/nap-store-postgres": "0.10.1", + "version": "0.11.0", + "dependencies": { + "@imani/nap-adapter-express": "0.11.0", + "@imani/nap-client-nip46": "0.11.0", + "@imani/nap-client-web": "0.11.0", + "@imani/nap-core": "0.11.0", + "@imani/nap-react": "0.11.0", + "@imani/nap-server": "0.11.0", + "@imani/nap-store-postgres": "0.11.0", "express": "^4.21.2", "nostr-tools": "^2.23.0", "pg": "^8.13.1", @@ -40,7 +40,7 @@ "react-dom": "^19.0.0" }, "devDependencies": { - "@imani/nap-client-http": "0.10.1", + "@imani/nap-client-http": "0.11.0", "@types/express": "^5.0.0", "@types/pg": "^8.11.10", "@types/react": "^19.0.0", @@ -961,9 +961,9 @@ } }, "node_modules/@grpc/grpc-js": { - "version": "1.14.4", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", - "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "version": "1.14.5", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.5.tgz", + "integrity": "sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1135,6 +1135,16 @@ "url": "https://opencollective.com/js-sdsl" } }, + "node_modules/@kwsites/file-exists": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1" + } + }, "node_modules/@noble/ciphers": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", @@ -1673,6 +1683,13 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1729,6 +1746,17 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -1746,6 +1774,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/docker-modem": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/@types/docker-modem/-/docker-modem-3.0.6.tgz", @@ -1758,9 +1793,9 @@ } }, "node_modules/@types/dockerode": { - "version": "3.3.47", - "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.47.tgz", - "integrity": "sha512-ShM1mz7rCjdssXt7Xz0u1/R2BJC7piWa3SJpUBiVjCf2A3XNn4cP6pUVaD8bLanpPVVn4IKzJuw3dOvkJ8IbYw==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-4.0.1.tgz", + "integrity": "sha512-cmUpB+dPN955PxBEuXE3f6lKO1hHiIGYJA46IVF3BJpNsZGvtBDcRnlrHYHtOH/B6vtDOyl2kZ2ShAu3mgc27Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1893,9 +1928,9 @@ } }, "node_modules/@types/ssh2": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", - "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.6.tgz", + "integrity": "sha512-oGdxhBqcRTwSTKFm+9EiKzkNVYRLEFkcW44lhguvBalGJbWfGnDt/ezwSUZc+SF9m9bMc3VyklNAtp7zICjS5w==", "dev": true, "license": "MIT", "dependencies": { @@ -1985,113 +2020,86 @@ } }, "node_modules/@vitest/expect": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", - "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", - "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "2.1.9", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.12" + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } } }, "node_modules/@vitest/pretty-format": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", - "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^1.2.0" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", - "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "2.1.9", - "pathe": "^1.1.2" + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", - "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.9", - "magic-string": "^0.30.12", - "pathe": "^1.1.2" + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/spy": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", - "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^3.0.2" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", - "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.9", - "loupe": "^3.1.2", - "tinyrainbow": "^1.2.0" + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -2513,9 +2521,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "version": "1.20.8", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", + "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -2526,7 +2534,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.14.0", + "qs": "~6.16.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -2551,6 +2559,22 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/brace-expansion": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", @@ -2659,16 +2683,6 @@ "node": ">= 0.8" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2720,32 +2734,15 @@ "license": "CC-BY-4.0" }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", @@ -3046,16 +3043,6 @@ } } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3106,9 +3093,9 @@ } }, "node_modules/docker-compose": { - "version": "0.24.8", - "resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-0.24.8.tgz", - "integrity": "sha512-plizRs/Vf15H+GCVxq2EUvyPK7ei9b/cVesHvjnX4xaXjM9spHe2Ytq0BitndFgvTJ3E3NljPNUEl7BAN43iZw==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-1.5.0.tgz", + "integrity": "sha512-O+eD6c63+BJORUWddXA7uAlI6H1KLDPd/aS14k73nXYpFliM48C3xrrKB3sXsR1Fp/rHUZQe7+fJ+QA7+jepyw==", "dev": true, "license": "MIT", "dependencies": { @@ -3150,9 +3137,9 @@ } }, "node_modules/dockerode": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.12.tgz", - "integrity": "sha512-/bCZd6KlGcjZO8Buqmi/vXuqEGVEZ0PNjx/biBNqJD3MhK9DmdiAuKxqfNhflgDESDIiBz3qF+0e55+CpnrUcw==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-5.0.1.tgz", + "integrity": "sha512-avsq/xk4YPIrn0CgleX5bjT9Y8IT1p9PxrNQ++RBQ2WEyFfHCTDsT9kmyxz+H/axnjAwg8wJWEIuPGOUuNupiA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -3161,11 +3148,10 @@ "@grpc/proto-loader": "^0.7.13", "docker-modem": "^5.0.7", "protobufjs": "^7.3.2", - "tar-fs": "^2.1.4", - "uuid": "^10.0.0" + "tar-fs": "^2.1.4" }, "engines": { - "node": ">= 8.0" + "node": ">= 14.17" } }, "node_modules/dockerode/node_modules/readable-stream": { @@ -3292,9 +3278,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, @@ -3561,9 +3547,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "funding": [ { "type": "github", @@ -3577,9 +3563,9 @@ "license": "BSD-3-Clause" }, "node_modules/fastify": { - "version": "5.8.2", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.2.tgz", - "integrity": "sha512-lZmt3navvZG915IE+f7/TIVamxIwmBd+OMB+O9WBzcpIwOo6F0LTh0sluoMFk5VkrKTvvrwIaoJPkir4Z+jtAg==", + "version": "5.12.5", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.5.tgz", + "integrity": "sha512-OB2k1dlxs5/NAABqeKV2FUHkSD2BbENsCak8yULVcymn3fHIPDVa9TI3SDnJSWYSllZmSYuZXy2gTnsT+Sut1A==", "funding": [ { "type": "github", @@ -3598,17 +3584,57 @@ "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", - "fast-json-stringify": "^6.0.0", - "find-my-way": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", "light-my-request": "^6.0.0", "pino": "^9.14.0 || ^10.1.0", - "process-warning": "^5.0.0", + "process-warning": "^5.1.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } }, + "node_modules/fastify/node_modules/fast-json-stringify": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", + "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fastify/node_modules/fast-uri": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.2.1.tgz", + "integrity": "sha512-TmHQgewjHtMq1E5QKA0tOE0yeYGQs25KZC/ziJpubRtWI15W92e6vPFydWOeZBVROSHBYw/QhD9d5OefdD6LDg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -3618,6 +3644,24 @@ "reusify": "^1.0.4" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -3652,9 +3696,9 @@ "license": "MIT" }, "node_modules/find-my-way": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz", - "integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==", + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.9.0.tgz", + "integrity": "sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -3683,17 +3727,17 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -3811,13 +3855,13 @@ } }, "node_modules/get-port": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz", - "integrity": "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", + "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=16" + "node": ">=8" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -3919,9 +3963,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -4226,13 +4270,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -4349,16 +4386,19 @@ } }, "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", "dev": true, "license": "MIT", "bin": { - "mkdirp": "bin/cmd.js" + "mkdirp": "dist/cjs/src/bin.js" }, "engines": { "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/mkdirp-classic": { @@ -4383,9 +4423,9 @@ "optional": true }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "dev": true, "funding": [ { @@ -4471,6 +4511,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -4553,28 +4607,18 @@ "license": "ISC" }, "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, "node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/pg": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", @@ -4671,6 +4715,19 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pino": { "version": "10.3.1", "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", @@ -4709,9 +4766,9 @@ "license": "MIT" }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "dev": true, "funding": [ { @@ -4729,7 +4786,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4794,9 +4851,9 @@ "license": "MIT" }, "node_modules/process-warning": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", - "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", "funding": [ { "type": "github", @@ -4829,16 +4886,17 @@ "license": "ISC" }, "node_modules/properties-reader": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/properties-reader/-/properties-reader-2.3.0.tgz", - "integrity": "sha512-z597WicA7nDZxK12kZqHr2TcvwNU1GCfA5UwfDY/HDp3hXPoPlb5rlEx9bwGTiJnc0OqbBTkU975jDToth8Gxw==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/properties-reader/-/properties-reader-3.0.1.tgz", + "integrity": "sha512-WPn+h9RGEExOKdu4bsF4HksG/uzd3cFq3MFtq8PsFeExPse5Ha/VOjQNyHhjboBFwGXGev6muJYTSPAOkROq2g==", "dev": true, "license": "MIT", "dependencies": { - "mkdirp": "^1.0.4" + "@kwsites/file-exists": "^1.1.1", + "mkdirp": "^3.0.1" }, "engines": { - "node": ">=14" + "node": ">=18" }, "funding": { "type": "github", @@ -4894,12 +4952,13 @@ } }, "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -5127,9 +5186,9 @@ "license": "MIT" }, "node_modules/safe-regex2": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.0.tgz", - "integrity": "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", "funding": [ { "type": "github", @@ -5281,14 +5340,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -5300,13 +5359,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -5464,9 +5523,9 @@ } }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, "license": "MIT" }, @@ -5694,40 +5753,37 @@ } }, "node_modules/testcontainers": { - "version": "10.28.0", - "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.28.0.tgz", - "integrity": "sha512-1fKrRRCsgAQNkarjHCMKzBKXSJFmzNTiTbhb5E/j5hflRXChEtHvkefjaHlgkNUjfw92/Dq8LTgwQn6RDBFbMg==", + "version": "12.0.4", + "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-12.0.4.tgz", + "integrity": "sha512-QIR/8xF1+F/26cIM+9B4yyxNTbKJxAv3hygZyhPRgZ8Q2AhlPZjDdpXRuk16V37X4bgJRI3hXFhoEICMBA7Adg==", "dev": true, "license": "MIT", "dependencies": { "@balena/dockerignore": "^1.0.2", - "@types/dockerode": "^3.3.35", + "@types/dockerode": "^4.0.1", "archiver": "^7.0.1", "async-lock": "^1.4.1", "byline": "^5.0.0", - "debug": "^4.3.5", - "docker-compose": "^0.24.8", - "dockerode": "^4.0.5", - "get-port": "^7.1.0", + "debug": "^4.4.3", + "docker-compose": "^1.4.2", + "dockerode": "^5.0.0", + "get-port": "^5.1.1", "proper-lockfile": "^4.1.2", - "properties-reader": "^2.3.0", + "properties-reader": "^3.0.1", "ssh-remote-port-forward": "^1.0.4", - "tar-fs": "^3.0.7", - "tmp": "^0.2.3", - "undici": "^5.29.0" + "tar-fs": "^3.1.2", + "tmp": "^0.2.7", + "undici": "^8.5.0" } }, "node_modules/testcontainers/node_modules/undici": { - "version": "5.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", - "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", "dev": true, "license": "MIT", - "dependencies": { - "@fastify/busboy": "^2.0.0" - }, "engines": { - "node": ">=14.0" + "node": ">=22.19.0" } }, "node_modules/text-decoder": { @@ -5760,36 +5816,36 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, "license": "MIT", "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": ">=18" } }, - "node_modules/tinyrainbow": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", - "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, "engines": { - "node": ">=14.0.0" + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", "dev": true, "license": "MIT", "engines": { @@ -6468,91 +6524,678 @@ } } }, - "node_modules/vite-node": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", - "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.7", - "es-module-lexer": "^1.5.4", - "pathe": "^1.1.2", - "vite": "^5.0.0" + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" }, "bin": { - "vite-node": "vite-node.mjs" + "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vitest": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", - "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "2.1.9", - "@vitest/mocker": "2.1.9", - "@vitest/pretty-format": "^2.1.9", - "@vitest/runner": "2.1.9", - "@vitest/snapshot": "2.1.9", - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "debug": "^4.3.7", - "expect-type": "^1.1.0", - "magic-string": "^0.30.12", - "pathe": "^1.1.2", - "std-env": "^3.8.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.1", - "tinypool": "^1.0.1", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0", - "vite-node": "2.1.9", - "why-is-node-running": "^2.3.0" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" }, "bin": { - "vitest": "vitest.mjs" + "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" }, "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.9", - "@vitest/ui": "2.1.9", - "happy-dom": "*", - "jsdom": "*" + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { - "@edge-runtime/vm": { + "@types/node": { "optional": true }, - "@types/node": { + "jiti": { "optional": true }, - "@vitest/browser": { + "less": { "optional": true }, - "@vitest/ui": { + "lightningcss": { "optional": true }, - "happy-dom": { + "sass": { "optional": true }, - "jsdom": { + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { "optional": true } } @@ -6744,9 +7387,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", "dev": true, "license": "ISC", "bin": { @@ -6850,9 +7493,9 @@ }, "packages/nap-adapter-express": { "name": "@imani/nap-adapter-express", - "version": "0.10.1", + "version": "0.11.0", "dependencies": { - "@imani/nap-server": "0.10.1", + "@imani/nap-server": "0.11.0", "express": "^4.21.2" }, "devDependencies": { @@ -6871,9 +7514,9 @@ }, "packages/nap-adapter-fastify": { "name": "@imani/nap-adapter-fastify", - "version": "0.10.1", + "version": "0.11.0", "dependencies": { - "@imani/nap-server": "0.10.1", + "@imani/nap-server": "0.11.0", "cookie": "^1.0.2", "fastify": "^5.2.1" }, @@ -6901,9 +7544,9 @@ }, "packages/nap-client-http": { "name": "@imani/nap-client-http", - "version": "0.10.1", + "version": "0.11.0", "dependencies": { - "@imani/nap-core": "0.10.1", + "@imani/nap-core": "0.11.0", "nostr-tools": "^2.23.0" }, "peerDependencies": { @@ -6917,10 +7560,10 @@ }, "packages/nap-client-nip46": { "name": "@imani/nap-client-nip46", - "version": "0.10.1", + "version": "0.11.0", "dependencies": { - "@imani/nap-client-web": "0.10.1", - "@imani/nap-core": "0.10.1", + "@imani/nap-client-web": "0.11.0", + "@imani/nap-core": "0.11.0", "nostr-tools": "^2.23.0" }, "peerDependencies": { @@ -6934,10 +7577,10 @@ }, "packages/nap-client-web": { "name": "@imani/nap-client-web", - "version": "0.10.1", + "version": "0.11.0", "dependencies": { - "@imani/nap-client-http": "0.10.1", - "@imani/nap-core": "0.10.1", + "@imani/nap-client-http": "0.11.0", + "@imani/nap-core": "0.11.0", "nostr-tools": "^2.23.0" }, "peerDependencies": { @@ -6951,7 +7594,7 @@ }, "packages/nap-core": { "name": "@imani/nap-core", - "version": "0.10.1", + "version": "0.11.0", "dependencies": { "@noble/hashes": "^1.6.1", "nostr-tools": "^2.23.0" @@ -6979,9 +7622,9 @@ }, "packages/nap-react": { "name": "@imani/nap-react", - "version": "0.10.1", + "version": "0.11.0", "dependencies": { - "@imani/nap-client-web": "0.10.1" + "@imani/nap-client-web": "0.11.0" }, "devDependencies": { "@types/react": "^19.0.0" @@ -6997,9 +7640,9 @@ }, "packages/nap-server": { "name": "@imani/nap-server", - "version": "0.10.1", + "version": "0.11.0", "dependencies": { - "@imani/nap-core": "0.10.1", + "@imani/nap-core": "0.11.0", "nostr-tools": "^2.23.0" }, "peerDependencies": { @@ -7013,10 +7656,10 @@ }, "packages/nap-store-postgres": { "name": "@imani/nap-store-postgres", - "version": "0.10.1", + "version": "0.11.0", "dependencies": { - "@imani/nap-core": "0.10.1", - "@imani/nap-server": "0.10.1", + "@imani/nap-core": "0.11.0", + "@imani/nap-server": "0.11.0", "pg": "^8.13.1" }, "peerDependencies": { @@ -7030,16 +7673,16 @@ }, "packages/nap-voucher": { "name": "@imani/nap-voucher", - "version": "0.10.1", + "version": "0.11.0", "dependencies": { "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1" }, "devDependencies": { - "@imani/nap-adapter-express": "0.10.1", - "@imani/nap-client-http": "0.10.1", - "@imani/nap-core": "0.10.1", - "@imani/nap-server": "0.10.1", + "@imani/nap-adapter-express": "0.11.0", + "@imani/nap-client-http": "0.11.0", + "@imani/nap-core": "0.11.0", + "@imani/nap-server": "0.11.0", "@types/supertest": "^6.0.3", "@types/ws": "^8.5.13", "express": "^4.21.2", @@ -7057,6 +7700,175 @@ "optional": true } } + }, + "packages/nap-voucher/node_modules/@types/dockerode": { + "version": "3.3.47", + "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.47.tgz", + "integrity": "sha512-ShM1mz7rCjdssXt7Xz0u1/R2BJC7piWa3SJpUBiVjCf2A3XNn4cP6pUVaD8bLanpPVVn4IKzJuw3dOvkJ8IbYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/docker-modem": "*", + "@types/node": "*", + "@types/ssh2": "*" + } + }, + "packages/nap-voucher/node_modules/docker-compose": { + "version": "0.24.8", + "resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-0.24.8.tgz", + "integrity": "sha512-plizRs/Vf15H+GCVxq2EUvyPK7ei9b/cVesHvjnX4xaXjM9spHe2Ytq0BitndFgvTJ3E3NljPNUEl7BAN43iZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "yaml": "^2.2.2" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "packages/nap-voucher/node_modules/dockerode": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.12.tgz", + "integrity": "sha512-/bCZd6KlGcjZO8Buqmi/vXuqEGVEZ0PNjx/biBNqJD3MhK9DmdiAuKxqfNhflgDESDIiBz3qF+0e55+CpnrUcw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@balena/dockerignore": "^1.0.2", + "@grpc/grpc-js": "^1.11.1", + "@grpc/proto-loader": "^0.7.13", + "docker-modem": "^5.0.7", + "protobufjs": "^7.3.2", + "tar-fs": "^2.1.4", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "packages/nap-voucher/node_modules/dockerode/node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "packages/nap-voucher/node_modules/get-port": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz", + "integrity": "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/nap-voucher/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "packages/nap-voucher/node_modules/properties-reader": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/properties-reader/-/properties-reader-2.3.0.tgz", + "integrity": "sha512-z597WicA7nDZxK12kZqHr2TcvwNU1GCfA5UwfDY/HDp3hXPoPlb5rlEx9bwGTiJnc0OqbBTkU975jDToth8Gxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mkdirp": "^1.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/properties?sponsor=1" + } + }, + "packages/nap-voucher/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "packages/nap-voucher/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "packages/nap-voucher/node_modules/testcontainers": { + "version": "10.28.0", + "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.28.0.tgz", + "integrity": "sha512-1fKrRRCsgAQNkarjHCMKzBKXSJFmzNTiTbhb5E/j5hflRXChEtHvkefjaHlgkNUjfw92/Dq8LTgwQn6RDBFbMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@balena/dockerignore": "^1.0.2", + "@types/dockerode": "^3.3.35", + "archiver": "^7.0.1", + "async-lock": "^1.4.1", + "byline": "^5.0.0", + "debug": "^4.3.5", + "docker-compose": "^0.24.8", + "dockerode": "^4.0.5", + "get-port": "^7.1.0", + "proper-lockfile": "^4.1.2", + "properties-reader": "^2.3.0", + "ssh-remote-port-forward": "^1.0.4", + "tar-fs": "^3.0.7", + "tmp": "^0.2.3", + "undici": "^5.29.0" + } + }, + "packages/nap-voucher/node_modules/testcontainers/node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } } } } diff --git a/package.json b/package.json index 3eedfde..2c7a4c6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nap", "private": true, - "version": "0.10.1", + "version": "0.11.0", "type": "module", "workspaces": [ "packages/*", @@ -13,14 +13,30 @@ "typecheck": "tsc --noEmit", "test:integration": "NAP_INTEGRATION=1 vitest run packages/nap-voucher/test/integration.test.ts" }, + "comments": { + "overrides": [ + "express pins qs at ~6.14.0, and ~ locks the minor, so the tree cannot reach", + "6.16.0 where the last of three moderate advisories is fixed:", + "GHSA-4mjr-xmp4-gh2g, GHSA-q8mj-m7cp-5q26 and GHSA-x5fp-wj9c-mxmx.", + "qs is Express's query parser, so this is on the request path of every", + "deployment using the Express adapter, not a build-time concern.", + "Scoped under express rather than applied globally so it constrains only the", + "dependency that needs it. Drop this once express 4.x relaxes the pin." + ] + }, + "overrides": { + "express": { + "qs": "6.16.0" + } + }, "devDependencies": { "@types/node": "^22.10.2", "@types/pg": "^8.20.0", "@types/ws": "^8.18.1", - "testcontainers": "^10.28.0", + "testcontainers": "^12.0.4", "tsx": "^4.19.2", "typescript": "^5.7.2", - "vitest": "^2.1.9", + "vitest": "^4.1.11", "ws": "^8.21.3" } } diff --git a/packages/nap-adapter-express/package.json b/packages/nap-adapter-express/package.json index b28156c..1193963 100644 --- a/packages/nap-adapter-express/package.json +++ b/packages/nap-adapter-express/package.json @@ -1,11 +1,11 @@ { "name": "@imani/nap-adapter-express", - "version": "0.10.1", + "version": "0.11.0", "type": "module", "exports": "./src/index.ts", "types": "./src/index.ts", "dependencies": { - "@imani/nap-server": "0.10.1", + "@imani/nap-server": "0.11.0", "express": "^4.21.2" }, "devDependencies": { diff --git a/packages/nap-adapter-express/src/adapter.ts b/packages/nap-adapter-express/src/adapter.ts index 18e63b0..999ecdb 100644 --- a/packages/nap-adapter-express/src/adapter.ts +++ b/packages/nap-adapter-express/src/adapter.ts @@ -319,6 +319,27 @@ function parseCookieValue(header: string | undefined, cookieName: string): strin return null; } +/** + * Guard options for the router's own routes, built from the server config. + * + * One builder rather than an object literal per route because the three call + * sites drifted: `/auth/session` omitted `clock` while logout passed it, so a + * server on an injected clock judged expiry by the wall clock on exactly one + * endpoint. Every field a route needs belongs here, so adding one cannot reach + * some routes and miss others. + * + * `clock` matters twice over: `loadSession` decides whether a session has + * expired, and `revoked_at` is a timestamp the store keeps, so a handler on the + * wall clock writes a revocation dated years from every other stored timestamp. + */ +function routeGuardOptions(options: NapExpressOptions): NapExpressGuardOptions { + return { + sessionStore: options.server.sessionStore, + cookieName: options.cookieName, + clock: options.server.clock, + }; +} + async function loadSession( req: Request, options: NapExpressGuardOptions @@ -622,10 +643,7 @@ export function createNapExpressCompleteHandler(options: NapExpressOptions): Req export function createNapExpressSessionHandler(options: NapExpressOptions): RequestHandler { return async (req, res, next) => { try { - const session = await loadSession(req, { - sessionStore: options.server.sessionStore, - cookieName: options.cookieName, - }); + const session = await loadSession(req, routeGuardOptions(options)); if (!session) { unauthorized(res); @@ -649,15 +667,7 @@ export function createNapExpressSessionHandler(options: NapExpressOptions): Requ export function createNapExpressLogoutHandler(options: NapExpressOptions): RequestHandler { return async (req, res, next) => { try { - // Shares the server's clock: `loadSession` decides whether the session is - // already expired, and `revoked_at` is a timestamp the store keeps. A - // logout stamping wall-clock time into a store the server reads on an - // injected clock writes a revocation dated in the future or the past. - const guardOptions = { - sessionStore: options.server.sessionStore, - cookieName: options.cookieName, - clock: options.server.clock, - }; + const guardOptions = routeGuardOptions(options); const session = await loadSession(req, guardOptions); if (session) { @@ -946,6 +956,21 @@ export function createPermissionsRouter(registry: PermissionRegistry): Router { return router; } +/** + * What the cookie gets unless the caller says otherwise. + * + * The shortest call that compiles has to be the safe one: this cookie carries the access + * token, so an unset `httpOnly` hands it to any script on the page, an unset `secure` puts + * it on the wire in cleartext, and an unset `sameSite` attaches it to cross-site requests. + * `nap-java` defaults the same way, so the same deployment behaves alike on both runtimes. + */ +const SECURE_COOKIE_DEFAULTS = { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', +} as const; + /** * Puts the access token in a cookie and replies `{ status: 'ok' }`, so no credential * reaches script. @@ -961,18 +986,19 @@ export function writeNapCookieSuccess( cookieName: string, cookieOptions?: CookieOptions, transformBody?: (body: ReturnType) => unknown -): NapExpressOptions['writeSuccess'] { - // Snapshotted here, and used by both the set below and the logout clear that reads the - // stamp. Holding the caller's object instead would let a mutation after wiring move one - // of the two without the other — the drift this whole pairing exists to prevent. - const attrs = cookieOptions ? { ...cookieOptions } : undefined; +): NonNullable { + // Merged over the secure defaults, not replacing them: a caller passing + // `{ domain: '.example.com' }` means to add a domain, not to drop HttpOnly, Secure and + // SameSite from the one cookie that carries the access token. Spreading last still lets + // an explicit `httpOnly: false` win, which is the local-development escape hatch. + // + // Snapshotted here too, and used by both the set below and the logout clear that reads + // the stamp. Holding the caller's object instead would let a mutation after wiring move + // one of the two without the other — the drift this whole pairing exists to prevent. + const attrs: CookieOptions = { ...SECURE_COOKIE_DEFAULTS, ...cookieOptions }; const write: NonNullable = ({ res, body }) => { - if (attrs) { - res.cookie(cookieName, body.access_token, attrs); - } else { - res.cookie(cookieName, body.access_token); - } + res.cookie(cookieName, body.access_token, attrs); res.status(200).json(transformBody ? transformBody(body) : { status: 'ok' }); }; @@ -982,9 +1008,7 @@ export function writeNapCookieSuccess( } // So the logout handler can clear with what the set used, instead of guessing `path: '/'`. - if (attrs) { - Object.defineProperty(write, COOKIE_ATTRS, { value: attrs }); - } + Object.defineProperty(write, COOKIE_ATTRS, { value: attrs }); Object.defineProperty(write, COOKIE_NAME, { value: cookieName }); diff --git a/packages/nap-adapter-express/test/adapter.test.ts b/packages/nap-adapter-express/test/adapter.test.ts index 701635d..3f23f09 100644 --- a/packages/nap-adapter-express/test/adapter.test.ts +++ b/packages/nap-adapter-express/test/adapter.test.ts @@ -399,6 +399,52 @@ describe('nap-adapter-express', () => { expect(logout.headers['set-cookie']?.[0]).not.toContain('Domain='); }); + it('protects the cookie when the caller passes no options at all', async () => { + const app = express(); + const write = writeNapCookieSuccess('session'); + app.get('/x', (req, res) => { + void write({ req, res, body: { access_token: 'SECRET' } as never }); + }); + + const setCookie = (await request(app).get('/x')).headers['set-cookie']?.[0] ?? ''; + + expect(setCookie).toContain('HttpOnly'); + expect(setCookie).toContain('Secure'); + expect(setCookie).toContain('SameSite=Lax'); + }); + + it('keeps the protections when the caller sets only a domain', async () => { + const app = express(); + // The case a real deployment hits: one attribute supplied, and replacing instead of + // merging would drop all three protections from the cookie holding the access token. + const write = writeNapCookieSuccess('session', { domain: '.example.com' }); + app.get('/x', (req, res) => { + void write({ req, res, body: { access_token: 'SECRET' } as never }); + }); + + const setCookie = (await request(app).get('/x')).headers['set-cookie']?.[0] ?? ''; + + expect(setCookie).toContain('Domain=.example.com'); + expect(setCookie).toContain('HttpOnly'); + expect(setCookie).toContain('Secure'); + expect(setCookie).toContain('SameSite=Lax'); + }); + + it('lets an explicit httpOnly: false win over the default', async () => { + const app = express(); + // Plain-HTTP local development is the reason the escape hatch exists. + const write = writeNapCookieSuccess('session', { httpOnly: false, secure: false }); + app.get('/x', (req, res) => { + void write({ req, res, body: { access_token: 'SECRET' } as never }); + }); + + const setCookie = (await request(app).get('/x')).headers['set-cookie']?.[0] ?? ''; + + expect(setCookie).not.toContain('HttpOnly'); + expect(setCookie).not.toContain('Secure'); + expect(setCookie).toContain('SameSite=Lax'); + }); + it('returns 204 from POST /auth/logout when no session exists', async () => { const app = createApp(buildServerOptions()); diff --git a/packages/nap-adapter-express/test/guardAudit.test.ts b/packages/nap-adapter-express/test/guardAudit.test.ts index b4942f7..5953bc0 100644 --- a/packages/nap-adapter-express/test/guardAudit.test.ts +++ b/packages/nap-adapter-express/test/guardAudit.test.ts @@ -235,7 +235,10 @@ describe('guard audit logging (CONTEXT.md finding 12)', () => { // next guarded request. Found by the extension-0001 end-to-end test, where // the server clock is pinned to a fixed timestamp. const now = 1_710_000_000; - const sessionStore = new InMemorySessionStore(); + // The store shares the clock too: its eviction sweep would otherwise read + // the wall clock and collect this fixture as long expired. + const clock = { nowUnix: () => now }; + const sessionStore = new InMemorySessionStore({ clock }); await seedSession(sessionStore, { issued_at: now, expires_at: now + 900 }); const { logger, events } = recordingAuditLogger(); const app = express(); @@ -244,7 +247,7 @@ describe('guard audit logging (CONTEXT.md finding 12)', () => { requirePermission('voucher:issue', { sessionStore, auditLogger: logger, - clock: { nowUnix: () => now }, + clock, }), (_req, res) => res.status(200).json({ status: 'ok' }) ); @@ -284,16 +287,17 @@ describe('guard audit logging (CONTEXT.md finding 12)', () => { // store. Found by auditing the remaining currentEpochSeconds() calls after // fixing the guard paths. const now = 1_710_000_000; - const sessionStore = new InMemorySessionStore(); + const clock = { nowUnix: () => now }; + const sessionStore = new InMemorySessionStore({ clock }); await seedSession(sessionStore, { issued_at: now, expires_at: now + 900 }); const app = express(); app.use( '/auth', createNapExpressRouter({ server: { - challengeStore: new InMemoryChallengeStore(), + challengeStore: new InMemoryChallengeStore({ clock }), sessionStore, - clock: { nowUnix: () => now }, + clock, } as unknown as NapServerOptions, getExternalBaseUrl: () => 'https://api.example.com', }) @@ -406,3 +410,55 @@ describe('guard audit logging (CONTEXT.md finding 12)', () => { ]); }); }); + +/** + * `GET /auth/session` used to build its guard options without `clock`, so a + * server with an injected clock had exactly one endpoint judging expiry by the + * wall clock. The pair of tests is the point: the first alone could be passed + * by simply not checking expiry at all. + */ +describe('/auth/session honours the server clock (#38)', () => { + const now = 1_710_000_000; + + function buildApp(sessionStore: InMemorySessionStore, clock: { nowUnix(): number }) { + const app = express(); + app.use( + '/auth', + createNapExpressRouter({ + server: { + challengeStore: new InMemoryChallengeStore({ clock }), + sessionStore, + clock, + } as unknown as NapServerOptions, + getExternalBaseUrl: () => 'https://api.example.com', + }) + ); + + return app; + } + + it('returns the session when the injected clock says it is live', async () => { + const clock = { nowUnix: () => now }; + const sessionStore = new InMemorySessionStore({ clock }); + await seedSession(sessionStore, { issued_at: now, expires_at: now + 900 }); + + const response = await request(buildApp(sessionStore, clock)) + .get('/auth/session') + .set('cookie', 'session=token-1'); + + expect(response.status).toBe(200); + expect(response.body.expires_at).toBe(now + 900); + }); + + it('still refuses a session the injected clock has moved past', async () => { + const clock = { nowUnix: () => now + 901 }; + const sessionStore = new InMemorySessionStore({ clock: { nowUnix: () => now } }); + await seedSession(sessionStore, { issued_at: now, expires_at: now + 900 }); + + const response = await request(buildApp(sessionStore, clock)) + .get('/auth/session') + .set('cookie', 'session=token-1'); + + expect(response.status).toBe(401); + }); +}); diff --git a/packages/nap-adapter-fastify/package.json b/packages/nap-adapter-fastify/package.json index 66e8fc1..1564789 100644 --- a/packages/nap-adapter-fastify/package.json +++ b/packages/nap-adapter-fastify/package.json @@ -1,11 +1,11 @@ { "name": "@imani/nap-adapter-fastify", - "version": "0.10.1", + "version": "0.11.0", "type": "module", "exports": "./src/index.ts", "types": "./src/index.ts", "dependencies": { - "@imani/nap-server": "0.10.1", + "@imani/nap-server": "0.11.0", "cookie": "^1.0.2", "fastify": "^5.2.1" }, diff --git a/packages/nap-adapter-fastify/src/adapter.ts b/packages/nap-adapter-fastify/src/adapter.ts index c053c7a..cb6f87c 100644 --- a/packages/nap-adapter-fastify/src/adapter.ts +++ b/packages/nap-adapter-fastify/src/adapter.ts @@ -248,6 +248,26 @@ function parseCookieValue(header: string | undefined, cookieName: string): strin return null; } +/** + * Guard options for the router's own routes, built from the server config. + * + * One builder rather than an object literal per route, for the same reason as + * the Express adapter: the three call sites drifted, `/auth/session` omitting + * `clock` while logout passed it, so a server on an injected clock judged + * expiry by the wall clock on exactly one endpoint. + * + * `clock` matters twice over: `loadSession` decides whether a session has + * expired, and `revoked_at` is a timestamp the store keeps, so a handler on the + * wall clock writes a revocation dated years from every other stored timestamp. + */ +function routeGuardOptions(options: NapFastifyOptions): NapFastifyGuardOptions { + return { + sessionStore: options.server.sessionStore, + cookieName: options.cookieName, + clock: options.server.clock, + }; +} + async function loadSession( req: FastifyRequest, options: NapFastifyGuardOptions @@ -538,15 +558,35 @@ export function createRequestDerivedBaseUrlResolver( return (req) => allow(req.headers.host, req.protocol); } +/** + * What the cookie gets unless the caller says otherwise. + * + * The shortest call that compiles has to be the safe one: this cookie carries the access + * token, so an unset `httpOnly` hands it to any script on the page, an unset `secure` puts + * it on the wire in cleartext, and an unset `sameSite` attaches it to cross-site requests. + * `nap-java` defaults the same way, so the same deployment behaves alike on both runtimes. + */ +const SECURE_COOKIE_DEFAULTS = { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', +} as const; + export function writeNapCookieSuccess( cookieName: string, cookieOptions?: SerializeOptions, transformBody?: (body: ReturnType) => unknown -): NapFastifyOptions['writeSuccess'] { - // Snapshotted here, and used by both the set below and the logout clear that reads the - // stamp. Holding the caller's object instead would let a mutation after wiring move one - // of the two without the other — the drift this whole pairing exists to prevent. - const attrs = cookieOptions ? { ...cookieOptions } : undefined; +): NonNullable { + // Merged over the secure defaults, not replacing them: a caller passing + // `{ domain: '.example.com' }` means to add a domain, not to drop HttpOnly, Secure and + // SameSite from the one cookie that carries the access token. Spreading last still lets + // an explicit `httpOnly: false` win, which is the local-development escape hatch. + // + // Snapshotted here too, and used by both the set below and the logout clear that reads + // the stamp. Holding the caller's object instead would let a mutation after wiring move + // one of the two without the other — the drift this whole pairing exists to prevent. + const attrs: SerializeOptions = { ...SECURE_COOKIE_DEFAULTS, ...cookieOptions }; const write: NonNullable = ({ reply, body }) => { reply.header('set-cookie', serialize(cookieName, body.access_token, attrs)); @@ -559,9 +599,7 @@ export function writeNapCookieSuccess( } // So the logout handler can clear with what the set used, instead of guessing `path: '/'`. - if (attrs) { - Object.defineProperty(write, COOKIE_ATTRS, { value: attrs }); - } + Object.defineProperty(write, COOKIE_ATTRS, { value: attrs }); Object.defineProperty(write, COOKIE_NAME, { value: cookieName }); @@ -727,10 +765,7 @@ export function createNapFastifyRefreshHandler(options: NapFastifyOptions): Rout */ export function createNapFastifySessionHandler(options: NapFastifyOptions): RouteHandlerMethod { return async (req, reply) => { - const session = await loadSession(req, { - sessionStore: options.server.sessionStore, - cookieName: options.cookieName, - }); + const session = await loadSession(req, routeGuardOptions(options)); if (!session) { unauthorized(reply); @@ -750,15 +785,7 @@ export function createNapFastifySessionHandler(options: NapFastifyOptions): Rout */ export function createNapFastifyLogoutHandler(options: NapFastifyOptions): RouteHandlerMethod { return async (req, reply) => { - // Shares the server's clock, for the same reason as the Express adapter: - // `revoked_at` is a stored timestamp, and a logout stamping wall-clock time - // into a store the server reads on an injected clock writes a revocation - // dated in the future or the past. - const guardOptions = { - sessionStore: options.server.sessionStore, - cookieName: options.cookieName, - clock: options.server.clock, - }; + const guardOptions = routeGuardOptions(options); const session = await loadSession(req, guardOptions); if (session) { diff --git a/packages/nap-adapter-fastify/test/adapter.test.ts b/packages/nap-adapter-fastify/test/adapter.test.ts index 62c1ff3..39321c3 100644 --- a/packages/nap-adapter-fastify/test/adapter.test.ts +++ b/packages/nap-adapter-fastify/test/adapter.test.ts @@ -16,6 +16,7 @@ import { import { createRequestDerivedBaseUrlResolver, napFastifyPlugin, + type NapFastifyOptions, permissionsFastifyPlugin, requirePermission, requireRole, @@ -96,6 +97,21 @@ async function createApp(options: NapServerOptions) { return app; } +/** Drives a cookie writer on a bare route, so the attributes it sets can be read back. */ +async function setCookieFrom( + write: NonNullable +): Promise { + const app = Fastify(); + app.get('/x', async (req, reply) => { + await write({ req, reply, body: { access_token: 'SECRET' } as never }); + }); + + const response = await app.inject({ method: 'GET', url: '/x' }); + await app.close(); + + return response.headers['set-cookie'] as string; +} + describe('nap-adapter-fastify', () => { beforeEach(() => { resetPermissionValidationState(); @@ -653,6 +669,38 @@ describe('nap-adapter-fastify', () => { await app.close(); }); + it('protects the cookie when the caller passes no options at all', async () => { + const setCookie = await setCookieFrom(writeNapCookieSuccess('session')); + + expect(setCookie).toContain('HttpOnly'); + expect(setCookie).toContain('Secure'); + expect(setCookie).toContain('SameSite=Lax'); + }); + + it('keeps the protections when the caller sets only a domain', async () => { + // The case a real deployment hits: one attribute supplied, and replacing instead of + // merging would drop all three protections from the cookie holding the access token. + const setCookie = await setCookieFrom( + writeNapCookieSuccess('session', { domain: '.example.com' }) + ); + + expect(setCookie).toContain('Domain=.example.com'); + expect(setCookie).toContain('HttpOnly'); + expect(setCookie).toContain('Secure'); + expect(setCookie).toContain('SameSite=Lax'); + }); + + it('lets an explicit httpOnly: false win over the default', async () => { + // Plain-HTTP local development is the reason the escape hatch exists. + const setCookie = await setCookieFrom( + writeNapCookieSuccess('session', { httpOnly: false, secure: false }) + ); + + expect(setCookie).not.toContain('HttpOnly'); + expect(setCookie).not.toContain('Secure'); + expect(setCookie).toContain('SameSite=Lax'); + }); + it('returns 204 from POST /auth/logout when no session exists', async () => { const app = await createApp(buildServerOptions()); diff --git a/packages/nap-adapter-fastify/test/guardAudit.test.ts b/packages/nap-adapter-fastify/test/guardAudit.test.ts index 6a1a181..cbf1c6e 100644 --- a/packages/nap-adapter-fastify/test/guardAudit.test.ts +++ b/packages/nap-adapter-fastify/test/guardAudit.test.ts @@ -7,9 +7,11 @@ import { InMemorySessionStore, type AclResolver, type AuditLogger, + type NapServerOptions, type PermissionRegistry, } from '@imani/nap-server'; import { + createNapFastifySessionHandler, requirePermission, requireRole, requireSession, @@ -211,7 +213,10 @@ describe('fastify guard audit logging (CONTEXT.md finding 12)', () => { // Same regression as the Express adapter: `clock` reached // resolveEffectiveAcl but session expiry read the wall clock. const now = 1_710_000_000; - const sessionStore = new InMemorySessionStore(); + // The store shares the clock too: its eviction sweep would otherwise read + // the wall clock and collect this fixture as long expired. + const clock = { nowUnix: () => now }; + const sessionStore = new InMemorySessionStore({ clock }); await seedSession(sessionStore, { issued_at: now, expires_at: now + 900 }); const { logger, events } = recordingAuditLogger(); const app = Fastify(); @@ -219,7 +224,7 @@ describe('fastify guard audit logging (CONTEXT.md finding 12)', () => { preHandler: requirePermission('voucher:issue', { sessionStore, auditLogger: logger, - clock: { nowUnix: () => now }, + clock, }), handler: async () => ({ status: 'ok' }), }); @@ -290,8 +295,10 @@ describe('fastify guard audit logging (CONTEXT.md finding 12)', () => { describe('guards pass the session to the resolver', () => { const NOW = 1_710_000_000; + const clock = { nowUnix: () => NOW }; + const seed = async () => { - const sessionStore = new InMemorySessionStore(); + const sessionStore = new InMemorySessionStore({ clock }); await sessionStore.createForChallenge({ challenge_id: 'c1', session_id: 's1', @@ -312,7 +319,7 @@ describe('guards pass the session to the resolver', () => { let seen: { session?: { roles: string[]; permissions: string[] } } | undefined; const options = { sessionStore: await seed(), - clock: { nowUnix: () => NOW }, + clock, aclResolver: { async resolve(_npub: string, _pubkey: string, context?: typeof seen) { seen = context; @@ -344,3 +351,51 @@ describe('guards pass the session to the resolver', () => { expect(seen?.session).toEqual({ roles: ['holder'], permissions: ['thing:read'] }); }); }); + +/** + * `GET /auth/session` used to build its guard options without `clock`, so a + * server with an injected clock had exactly one endpoint judging expiry by the + * wall clock. The pair of tests is the point: the first alone could be passed + * by simply not checking expiry at all. + */ +describe('/auth/session honours the server clock (#38)', () => { + const now = 1_710_000_000; + + async function buildApp( + sessionStore: InMemorySessionStore, + clock: { nowUnix(): number } + ) { + const app = Fastify(); + app.get( + '/auth/session', + createNapFastifySessionHandler({ + server: { sessionStore, clock } as unknown as NapServerOptions, + getExternalBaseUrl: () => 'https://api.example.com', + }) + ); + + return app; + } + + it('returns the session when the injected clock says it is live', async () => { + const clock = { nowUnix: () => now }; + const sessionStore = new InMemorySessionStore({ clock }); + await seedSession(sessionStore, { issued_at: now, expires_at: now + 900 }); + const app = await buildApp(sessionStore, clock); + + const response = await app.inject({ method: 'GET', url: '/auth/session', headers: HEADERS }); + + expect(response.statusCode).toBe(200); + expect(response.json().expires_at).toBe(now + 900); + }); + + it('still refuses a session the injected clock has moved past', async () => { + const sessionStore = new InMemorySessionStore({ clock: { nowUnix: () => now } }); + await seedSession(sessionStore, { issued_at: now, expires_at: now + 900 }); + const app = await buildApp(sessionStore, { nowUnix: () => now + 901 }); + + const response = await app.inject({ method: 'GET', url: '/auth/session', headers: HEADERS }); + + expect(response.statusCode).toBe(401); + }); +}); diff --git a/packages/nap-client-http/package.json b/packages/nap-client-http/package.json index 9c913a5..a04cc1f 100644 --- a/packages/nap-client-http/package.json +++ b/packages/nap-client-http/package.json @@ -1,11 +1,11 @@ { "name": "@imani/nap-client-http", - "version": "0.10.1", + "version": "0.11.0", "type": "module", "exports": "./src/index.ts", "types": "./src/index.ts", "dependencies": { - "@imani/nap-core": "0.10.1", + "@imani/nap-core": "0.11.0", "nostr-tools": "^2.23.0" }, "peerDependencies": { diff --git a/packages/nap-client-nip46/package.json b/packages/nap-client-nip46/package.json index 54ece90..9de87f6 100644 --- a/packages/nap-client-nip46/package.json +++ b/packages/nap-client-nip46/package.json @@ -1,12 +1,12 @@ { "name": "@imani/nap-client-nip46", - "version": "0.10.1", + "version": "0.11.0", "type": "module", "exports": "./src/index.ts", "types": "./src/index.ts", "dependencies": { - "@imani/nap-client-web": "0.10.1", - "@imani/nap-core": "0.10.1", + "@imani/nap-client-web": "0.11.0", + "@imani/nap-core": "0.11.0", "nostr-tools": "^2.23.0" }, "peerDependencies": { diff --git a/packages/nap-client-web/package.json b/packages/nap-client-web/package.json index e25bc99..d0aefc8 100644 --- a/packages/nap-client-web/package.json +++ b/packages/nap-client-web/package.json @@ -1,12 +1,12 @@ { "name": "@imani/nap-client-web", - "version": "0.10.1", + "version": "0.11.0", "type": "module", "exports": "./src/index.ts", "types": "./src/index.ts", "dependencies": { - "@imani/nap-client-http": "0.10.1", - "@imani/nap-core": "0.10.1", + "@imani/nap-client-http": "0.11.0", + "@imani/nap-core": "0.11.0", "nostr-tools": "^2.23.0" }, "peerDependencies": { diff --git a/packages/nap-core/package.json b/packages/nap-core/package.json index e249121..2939f83 100644 --- a/packages/nap-core/package.json +++ b/packages/nap-core/package.json @@ -1,6 +1,6 @@ { "name": "@imani/nap-core", - "version": "0.10.1", + "version": "0.11.0", "type": "module", "exports": "./src/index.ts", "types": "./src/index.ts", diff --git a/packages/nap-core/src/url.ts b/packages/nap-core/src/url.ts index 6bbde3d..3f29965 100644 --- a/packages/nap-core/src/url.ts +++ b/packages/nap-core/src/url.ts @@ -2,7 +2,27 @@ export function normalizeAbsoluteUrl(value: string): string { return new URL(value).toString(); } +/** + * Whether two URLs are the same absolute URL. + * + * Total by construction. The left-hand side is the NIP-98 `u` tag, which is + * attacker-supplied: `new URL()` throws on anything that is not an absolute + * URL, and an unhandled throw here escapes `verifyNip98Completion()` entirely. + * That costs three things at once — the adapter answers 500 instead of the + * uniform 401, the response skips the `padAuthResponse()` floor that makes + * failures indistinguishable (RFC §15), and no audit record is written because + * the throw happens before `logFailure()`. + * + * A `u` tag that will not parse cannot match the audience, so `false` is the + * honest answer and it reaches the client through the same padded, audited + * `NAP_COMPLETE_URL_MISMATCH` as every other mismatch. This is the same reason + * `parseVoucherSecret()` returns null rather than throwing on hostile input. + */ export function exactUrlMatch(left: string, right: string): boolean { - return normalizeAbsoluteUrl(left) === normalizeAbsoluteUrl(right); + try { + return normalizeAbsoluteUrl(left) === normalizeAbsoluteUrl(right); + } catch { + return false; + } } diff --git a/packages/nap-core/test/url.test.ts b/packages/nap-core/test/url.test.ts new file mode 100644 index 0000000..e4009fa --- /dev/null +++ b/packages/nap-core/test/url.test.ts @@ -0,0 +1,125 @@ +import { finalizeEvent } from 'nostr-tools'; +import { describe, expect, it } from 'vitest'; +import { + encodeBase64String, + exactUrlMatch, + hexToBytes, + sha256Hex, + utf8Bytes, + verifyNip98Completion, + type AuthCompleteRequest, +} from '../src/index.js'; + +const PRIVATE_KEY_BYTES = hexToBytes( + '1111111111111111111111111111111111111111111111111111111111111111' +); +const AUDIENCE = 'https://api.example.com/auth/complete'; + +/** + * A completion whose `u` tag is whatever the caller passes, signed correctly. + * + * The signature has to be valid for the request to reach the URL check at all, + * which is the point: a throwaway key costs an attacker nothing, so "you need a + * valid signature first" is not a barrier to reaching this code path. + */ +function signedCompletionWithUrlTag(urlTag: string): { + authorization: string; + rawBody: Uint8Array; + body: AuthCompleteRequest; +} { + const body: AuthCompleteRequest = { challenge_id: 'challenge-id-1' }; + const rawBody = utf8Bytes(JSON.stringify(body)); + const event = finalizeEvent( + { + kind: 27235, + created_at: 1_710_000_005, + tags: [ + ['u', urlTag], + ['method', 'POST'], + ['payload', sha256Hex(rawBody)], + ['challenge', 'challenge-123'], + ['challenge_id', body.challenge_id], + ], + content: '', + }, + PRIVATE_KEY_BYTES + ); + + return { authorization: `Nostr ${encodeBase64String(JSON.stringify(event))}`, rawBody, body }; +} + +describe('exactUrlMatch', () => { + it('is total: an unparseable URL is a mismatch, not a thrown error', () => { + // `new URL()` throws on each of these. Before the fix that throw escaped + // `verifyNip98Completion()` and became a 500 at the adapter. + for (const malformed of ['not-a-url', '', ' ', '///', 'http://', '::::']) { + expect(() => exactUrlMatch(malformed, AUDIENCE)).not.toThrow(); + expect(exactUrlMatch(malformed, AUDIENCE)).toBe(false); + } + }); + + it('is total when the configured audience is the unparseable side', () => { + expect(() => exactUrlMatch(AUDIENCE, 'not-a-url')).not.toThrow(); + expect(exactUrlMatch(AUDIENCE, 'not-a-url')).toBe(false); + }); + + it('still matches identical absolute URLs', () => { + expect(exactUrlMatch(AUDIENCE, AUDIENCE)).toBe(true); + }); + + it('still normalizes scheme and host case', () => { + expect(exactUrlMatch('HTTPS://API.example.com/auth/complete', AUDIENCE)).toBe(true); + }); + + // The guard against "fixing" the throw by loosening the comparison. A path, + // scheme, or host difference must still be a mismatch -- this is the audience + // binding, so a false positive here is an authentication bypass. + it.each([ + ['trailing slash', 'https://api.example.com/auth/complete/'], + ['different path', 'https://api.example.com/auth/other'], + ['different host', 'https://evil.example.com/auth/complete'], + ['different scheme', 'http://api.example.com/auth/complete'], + ['different port', 'https://api.example.com:8443/auth/complete'], + ['userinfo', 'https://user@api.example.com/auth/complete'], + ])('still rejects a %s', (_label, candidate) => { + expect(exactUrlMatch(candidate, AUDIENCE)).toBe(false); + }); +}); + +describe('verifyNip98Completion with a malformed u tag', () => { + it('returns NAP_COMPLETE_URL_MISMATCH rather than throwing', () => { + const { authorization, rawBody, body } = signedCompletionWithUrlTag('not-a-url'); + + const result = verifyNip98Completion({ + authorization, + method: 'POST', + url: AUDIENCE, + body, + rawBody, + now: 1_710_000_005, + }); + + // The whole point: this reaches the caller as an ordinary failure, so the + // adapter answers the uniform padded 401 and `logFailure()` records a code. + expect(result).toEqual( + expect.objectContaining({ ok: false, code: 'NAP_COMPLETE_URL_MISMATCH' }) + ); + }); + + it('does not throw for any unparseable u tag', () => { + for (const malformed of ['not-a-url', '', 'http://', 'javascript:alert(1)']) { + const { authorization, rawBody, body } = signedCompletionWithUrlTag(malformed); + + expect(() => + verifyNip98Completion({ + authorization, + method: 'POST', + url: AUDIENCE, + body, + rawBody, + now: 1_710_000_005, + }) + ).not.toThrow(); + } + }); +}); diff --git a/packages/nap-react/package.json b/packages/nap-react/package.json index 2a1d82c..d79d7a8 100644 --- a/packages/nap-react/package.json +++ b/packages/nap-react/package.json @@ -1,6 +1,6 @@ { "name": "@imani/nap-react", - "version": "0.10.1", + "version": "0.11.0", "type": "module", "exports": { ".": { @@ -10,7 +10,7 @@ }, "types": "./src/index.ts", "dependencies": { - "@imani/nap-client-web": "0.10.1" + "@imani/nap-client-web": "0.11.0" }, "peerDependencies": { "typescript": ">=5.7" diff --git a/packages/nap-server/package.json b/packages/nap-server/package.json index 32402ba..2c11b10 100644 --- a/packages/nap-server/package.json +++ b/packages/nap-server/package.json @@ -1,11 +1,11 @@ { "name": "@imani/nap-server", - "version": "0.10.1", + "version": "0.11.0", "type": "module", "exports": "./src/index.ts", "types": "./src/index.ts", "dependencies": { - "@imani/nap-core": "0.10.1", + "@imani/nap-core": "0.11.0", "nostr-tools": "^2.23.0" }, "peerDependencies": { diff --git a/packages/nap-server/src/index.ts b/packages/nap-server/src/index.ts index 5ed978e..ec479f0 100644 --- a/packages/nap-server/src/index.ts +++ b/packages/nap-server/src/index.ts @@ -3,6 +3,7 @@ export { InMemoryChallengeStore, InMemorySessionStore, } from './memory.js'; +export type { InMemoryStoreOptions } from './memory.js'; export { createRegistryAclResolver, createRevokingAclStore, diff --git a/packages/nap-server/src/memory.ts b/packages/nap-server/src/memory.ts index fc38594..53c0802 100644 --- a/packages/nap-server/src/memory.ts +++ b/packages/nap-server/src/memory.ts @@ -3,16 +3,71 @@ import type { AclRecord, AclStore, ChallengeStore, + Clock, OutstandingChallengeFilter, RecordChallengeFailureResult, RotateRefreshTokenParams, SessionStore, } from './types.js'; +const systemClock: Clock = { nowUnix: () => Math.floor(Date.now() / 1000) }; + +export interface InMemoryStoreOptions { + /** + * Clock the eviction sweep reads. Defaults to the wall clock. Pass the same + * clock you gave `NapServerOptions` when you inject one: a store sweeping on + * a different clock from the server either keeps records the server has + * already written off or drops ones it still considers live. + */ + clock?: Clock; +} + export class InMemoryChallengeStore implements ChallengeStore { private readonly records = new Map(); + private readonly clock: Clock; + private lastSweptAt: number | null = null; + + constructor(options: InMemoryStoreOptions = {}) { + this.clock = options.clock ?? systemClock; + } + + /** + * Drop challenges nothing can still ask about, so an unauthenticated flood of + * `/auth/init` grows this map by a bounded amount rather than for ever. + * + * The retention bound is `result_cache_until` when the challenge was redeemed + * and `expires_at` otherwise. A redeemed challenge inside its result-cache + * window must survive: RFC §13.3 retry safety is exactly the promise that a + * client repeating a completion it already made gets the same answer back + * instead of a "not found", and evicting the row breaks that for honest + * clients on a flaky connection. + * + * At most once per clock tick, for the reason documented on `prune()` in + * `rateLimit.ts`: a scan on every call is O(entries) per request, which turns + * the component that should absorb a flood into the thing that amplifies it. + * Records surviving a tick longer cost nothing, since every read path already + * checks the timestamps itself. + */ + private sweep(now: number): void { + if (this.lastSweptAt !== null && now <= this.lastSweptAt) { + return; + } + + this.lastSweptAt = now; + + for (const [challengeId, record] of this.records) { + const retainUntil = record.result_cache_until ?? record.expires_at; + + if (retainUntil < now) { + this.records.delete(challengeId); + } + } + } async create(record: ChallengeRecord): Promise { + // Swept from here because `create()` is the method an attacker drives: the + // work of cleaning up is then paid by the same traffic that made the mess. + this.sweep(this.clock.nowUnix()); this.records.set(record.challenge_id, { ...record }); } @@ -112,8 +167,67 @@ export class InMemorySessionStore implements SessionStore { private readonly sessionsByAccessToken = new Map(); /** Holds the current *and* previous token per session, so a replay is recognisable. */ private readonly sessionsByRefreshToken = new Map(); + private readonly clock: Clock; + private lastSweptAt: number | null = null; + + constructor(options: InMemoryStoreOptions = {}) { + this.clock = options.clock ?? systemClock; + } + + /** + * Drop sessions nothing can still act on. Revoking or expiring a session only + * flags the record, so without this every login ever served stays resident. + * + * The bound is `expires_at`, extended to `refresh_expires_at` where refresh is + * enabled. The later bound is the point of the reuse detection in + * `rotateRefreshToken()`: a stolen refresh token replayed just after the + * access token lapsed has to still be *recognised* as belonging to this + * lineage, and a row deleted at `expires_at` would make it merely unknown, + * which is the quiet failure mode rather than the loud one. + * + * All four index maps are swept together. They are views on the same record, + * and dropping one while another still points at it is a leak that also lets + * a token resolve through the surviving index. + * + * Once per clock tick, for the reason documented on `prune()` in + * `rateLimit.ts`. + */ + private sweep(now: number): void { + if (this.lastSweptAt !== null && now <= this.lastSweptAt) { + return; + } + + this.lastSweptAt = now; + + for (const [sessionId, session] of this.sessionsById) { + const retainUntil = Math.max(session.expires_at, session.refresh_expires_at ?? 0); + + if (retainUntil >= now) { + continue; + } + + this.sessionsById.delete(sessionId); + this.sessionsByChallengeId.delete(session.challenge_id); + this.sessionsByAccessToken.delete(session.access_token); + + if (session.refresh_token) { + this.sessionsByRefreshToken.delete(session.refresh_token); + } + + if (session.previous_refresh_token) { + this.sessionsByRefreshToken.delete(session.previous_refresh_token); + } + } + } async createForChallenge(record: SessionRecord): Promise { + // Swept from the write path as well as the read path below. `createForChallenge` + // is what a login flood drives, and it was the gap: a server taking logins but + // serving no guarded requests never called `getByAccessToken`, so nothing swept + // and every expired session stayed resident. Verified at 500 logins retaining + // 500 dead sessions before this line existed. + this.sweep(this.clock.nowUnix()); + const existing = this.sessionsByChallengeId.get(record.challenge_id); if (existing) { @@ -137,6 +251,10 @@ export class InMemorySessionStore implements SessionStore { } async getByAccessToken(token: string): Promise { + // Swept from here because every guarded request passes through it, so the + // sweep runs on live traffic without needing a timer holding the process + // open. The once-per-tick bound keeps the cost off the hot path. + this.sweep(this.clock.nowUnix()); return this.sessionsByAccessToken.get(token) ?? null; } diff --git a/packages/nap-server/test/memoryEviction.test.ts b/packages/nap-server/test/memoryEviction.test.ts new file mode 100644 index 0000000..cce8582 --- /dev/null +++ b/packages/nap-server/test/memoryEviction.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'vitest'; +import type { ChallengeRecord, SessionRecord } from '@imani/nap-core'; +import { InMemoryChallengeStore, InMemorySessionStore } from '../src/index.js'; + +const NOW = 1_710_000_000; + +/** Mutable clock, so a test can step time past a retention bound deliberately. */ +function stubClock(start: number): { nowUnix(): number; set(value: number): void } { + let current = start; + + return { + nowUnix: () => current, + set(value: number) { + current = value; + }, + }; +} + +function challenge(id: string, overrides: Partial = {}): ChallengeRecord { + return { + challenge_id: id, + challenge: `challenge-${id}`, + npub: 'npub1example', + pubkey: 'ff'.repeat(32), + auth_url: 'https://api.example.com/auth/complete', + auth_method: 'POST', + issued_at: NOW, + expires_at: NOW + 120, + state: 'issued', + ...overrides, + }; +} + +function session(id: string, overrides: Partial = {}): SessionRecord { + return { + session_id: id, + challenge_id: `challenge-${id}`, + access_token: `access-${id}`, + principal_npub: 'npub1example', + principal_pubkey: 'ff'.repeat(32), + roles: [], + permissions: [], + issued_at: NOW, + expires_at: NOW + 900, + ...overrides, + }; +} + +describe('InMemoryChallengeStore eviction', () => { + it('drops expired challenges while keeping the live one readable', async () => { + const clock = stubClock(NOW); + const store = new InMemoryChallengeStore({ clock }); + + for (let index = 0; index < 50; index += 1) { + await store.create(challenge(`expired-${index}`, { expires_at: NOW + 60 })); + } + + await store.create(challenge('live', { expires_at: NOW + 10_000 })); + + // Past every short expiry, and the sweep needs a fresh tick to run at all. + clock.set(NOW + 61); + await store.create(challenge('trigger', { expires_at: NOW + 10_000 })); + + expect(await store.get('expired-0')).toBeNull(); + expect(await store.get('expired-49')).toBeNull(); + expect((await store.get('live'))?.challenge_id).toBe('live'); + }); + + it('keeps a redeemed challenge inside its result-cache window', async () => { + const clock = stubClock(NOW); + const store = new InMemoryChallengeStore({ clock }); + + await store.create(challenge('redeemed', { expires_at: NOW + 60 })); + + const outcome = await store.redeem('redeemed', { + eventId: 'event-1', + sessionId: 'session-1', + now: NOW, + resultCacheUntil: NOW + 600, + }); + + expect(outcome.status).toBe('redeemed'); + + // The challenge itself has expired, but RFC §13.3 retry safety says the + // cached result must still answer a client repeating its completion. + clock.set(NOW + 120); + await store.create(challenge('trigger', { expires_at: NOW + 10_000 })); + + const retained = await store.get('redeemed'); + expect(retained?.state).toBe('redeemed'); + expect(retained?.redeemed_session_id).toBe('session-1'); + + // Once the cache window itself lapses, the record is finally collectable. + clock.set(NOW + 601); + await store.create(challenge('trigger-2', { expires_at: NOW + 10_000 })); + expect(await store.get('redeemed')).toBeNull(); + }); +}); + +describe('InMemorySessionStore eviction', () => { + it('evicts expired sessions from every index and leaves live ones alone', async () => { + const clock = stubClock(NOW); + const store = new InMemorySessionStore({ clock }); + + await store.createForChallenge(session('stale', { expires_at: NOW + 100 })); + await store.createForChallenge(session('live', { expires_at: NOW + 10_000 })); + + clock.set(NOW + 101); + expect(await store.getByAccessToken('access-stale')).toBeNull(); + expect(await store.getBySessionId('stale')).toBeNull(); + + expect((await store.getByAccessToken('access-live'))?.session_id).toBe('live'); + }); + + it('keeps a refresh token recognisable as a replay until its own expiry', async () => { + const clock = stubClock(NOW); + const store = new InMemorySessionStore({ clock }); + + await store.createForChallenge( + session('refreshable', { + expires_at: NOW + 100, + refresh_token: 'refresh-1', + refresh_expires_at: NOW + 3600, + }) + ); + + await store.rotateRefreshToken('refreshable', { + expectedRefreshToken: 'refresh-1', + refreshToken: 'refresh-2', + accessToken: 'access-2', + now: NOW, + expiresAt: NOW + 100, + refreshExpiresAt: NOW + 3600, + roles: [], + permissions: [], + }); + + // The access token has lapsed, but reuse detection only works while the + // superseded token still resolves to its lineage. + clock.set(NOW + 200); + await store.getByAccessToken('unrelated'); + + expect((await store.getByRefreshToken('refresh-1'))?.session_id).toBe('refreshable'); + + clock.set(NOW + 3601); + await store.getByAccessToken('unrelated'); + expect(await store.getByRefreshToken('refresh-1')).toBeNull(); + }); + + /** + * The growth path is `createForChallenge`, not `getByAccessToken`. + * + * Sweeping only from the read path left the actual attack uncovered: a server + * taking logins but serving no guarded requests never calls + * `getByAccessToken`, so nothing swept and every expired session stayed + * resident. Measured at 500 logins retaining all 500 before the write path + * swept too. + * + * Asserting a constant rather than a threshold is what makes this meaningful. + * Residue is the sliding retention window (one tick plus `expires_at`), so it + * depends on the session TTL and not on how much traffic has been served. Ten + * times the logins must leave the same amount behind. + */ + it('bounds growth on the write path, independent of login volume', async () => { + const residentAfter = async (logins: number): Promise => { + const clock = stubClock(NOW); + const store = new InMemorySessionStore({ clock }); + + for (let index = 0; index < logins; index += 1) { + await store.createForChallenge(session(`s${index}`, { + issued_at: clock.nowUnix(), + expires_at: clock.nowUnix() + 60, + })); + clock.set(clock.nowUnix() + 1); + } + + let alive = 0; + for (let index = 0; index < logins; index += 1) { + if (await store.getBySessionId(`s${index}`)) { + alive += 1; + } + } + return alive; + }; + + const tenfold = await residentAfter(5_000); + + expect(tenfold).toBe(await residentAfter(500)); + expect(tenfold).toBeLessThan(100); + }); +}); diff --git a/packages/nap-server/test/refresh.test.ts b/packages/nap-server/test/refresh.test.ts index f000795..2b8fd0f 100644 --- a/packages/nap-server/test/refresh.test.ts +++ b/packages/nap-server/test/refresh.test.ts @@ -37,7 +37,8 @@ interface Harness { function buildHarness(overrides: Partial = {}): Harness { let now = NOW; - const sessionStore = new InMemorySessionStore(); + const clock = { nowUnix: () => now }; + const sessionStore = new InMemorySessionStore({ clock }); const harness: Harness = { sessionStore, setNow(value) { @@ -45,7 +46,7 @@ function buildHarness(overrides: Partial = {}): Harness { }, acl: { allowed: true, roles: ['user'], permissions: ['read'] }, options: { - challengeStore: new InMemoryChallengeStore(), + challengeStore: new InMemoryChallengeStore({ clock }), sessionStore, aclResolver: { async resolve() { @@ -55,7 +56,7 @@ function buildHarness(overrides: Partial = {}): Harness { refreshTtlSeconds: REFRESH_TTL, minAuthResponseMillis: 0, responseJitterMillis: 0, - clock: { nowUnix: () => now }, + clock, randomSource: { randomBytes(length: number) { const seed = counter++; diff --git a/packages/nap-server/test/security.test.ts b/packages/nap-server/test/security.test.ts index c46586d..3f958b5 100644 --- a/packages/nap-server/test/security.test.ts +++ b/packages/nap-server/test/security.test.ts @@ -35,10 +35,14 @@ const OTHER_KEY_BYTES = hexToBytes( const PUBKEY = getPublicKey(PRIVATE_KEY_BYTES); const NPUB = nip19.npubEncode(PUBKEY); +/** Shared by the stores too: a store sweeping on the wall clock would evict + * these fixed-timestamp fixtures the moment it ran. */ +const FIXED_CLOCK = { nowUnix: () => NOW }; + function buildOptions(overrides: Partial = {}): NapServerOptions { return { - challengeStore: new InMemoryChallengeStore(), - sessionStore: new InMemorySessionStore(), + challengeStore: new InMemoryChallengeStore({ clock: FIXED_CLOCK }), + sessionStore: new InMemorySessionStore({ clock: FIXED_CLOCK }), aclResolver: { async resolve() { return { allowed: true, roles: ['merchant'], permissions: ['voucher:issue'] }; @@ -46,7 +50,7 @@ function buildOptions(overrides: Partial = {}): NapServerOptio }, minAuthResponseMillis: 0, responseJitterMillis: 0, - clock: { nowUnix: () => NOW }, + clock: FIXED_CLOCK, randomSource: { // Distinct per call so challenge ids do not collide across iterations. randomBytes(length: number) { @@ -128,7 +132,7 @@ describe('rate limiting (RFC §17.1)', () => { const options = buildOptions({ rateLimiter: createInMemoryRateLimiter({ maxPerWindow: 2, - clock: { nowUnix: () => NOW }, + clock: FIXED_CLOCK, }), }); @@ -147,7 +151,7 @@ describe('rate limiting (RFC §17.1)', () => { it('caps a caller address independently of the principal', async () => { const limiter = createInMemoryRateLimiter({ maxPerWindow: 1, - clock: { nowUnix: () => NOW }, + clock: FIXED_CLOCK, }); const options = buildOptions({ rateLimiter: limiter }); const otherNpub = nip19.npubEncode(getPublicKey(OTHER_KEY_BYTES)); @@ -167,7 +171,7 @@ describe('rate limiting (RFC §17.1)', () => { const options = buildOptions({ rateLimiter: createInMemoryRateLimiter({ maxPerWindow: 1, - clock: { nowUnix: () => NOW }, + clock: FIXED_CLOCK, }), }); @@ -190,7 +194,7 @@ describe('rate limiting (RFC §17.1)', () => { const options = buildOptions({ rateLimiter: createInMemoryRateLimiter({ maxPerWindow: 2, - clock: { nowUnix: () => NOW }, + clock: FIXED_CLOCK, }), }); const first = await issue(options, '203.0.113.7'); @@ -257,7 +261,7 @@ describe('rate limiting (RFC §17.1)', () => { const limiter = createInMemoryRateLimiter({ windowSeconds: 60, maxPerWindow: 1, - clock: { nowUnix: () => NOW }, + clock: FIXED_CLOCK, }); limiter.check({ scope: 'init', npub: NPUB }); @@ -476,7 +480,7 @@ describe('per-request permission evaluation (RFC §15)', () => { }); it('denies and revokes the principal once access is affirmatively removed', async () => { - const sessionStore = new InMemorySessionStore(); + const sessionStore = new InMemorySessionStore({ clock: FIXED_CLOCK }); await sessionStore.createForChallenge(session); const acl = await resolveEffectiveAcl(session, { @@ -486,7 +490,7 @@ describe('per-request permission evaluation (RFC §15)', () => { }, }, sessionStore, - clock: { nowUnix: () => NOW }, + clock: FIXED_CLOCK, }); expect(acl).toBeNull(); @@ -495,7 +499,7 @@ describe('per-request permission evaluation (RFC §15)', () => { }); it('denies without revoking when the resolver is not certain', async () => { - const sessionStore = new InMemorySessionStore(); + const sessionStore = new InMemorySessionStore({ clock: FIXED_CLOCK }); await sessionStore.createForChallenge(session); // A resolver that cannot read the ACL answers "denied". Revoking on that @@ -507,7 +511,7 @@ describe('per-request permission evaluation (RFC §15)', () => { }, }, sessionStore, - clock: { nowUnix: () => NOW }, + clock: FIXED_CLOCK, }); expect(acl).toBeNull(); @@ -528,7 +532,7 @@ describe('createRevokingAclStore', () => { } async function seed(): Promise<{ store: ReturnType; sessionStore: InMemorySessionStore }> { - const sessionStore = new InMemorySessionStore(); + const sessionStore = new InMemorySessionStore({ clock: FIXED_CLOCK }); await sessionStore.createForChallenge({ session_id: 'session-1', challenge_id: 'challenge-1', diff --git a/packages/nap-server/test/securityRemediation.test.ts b/packages/nap-server/test/securityRemediation.test.ts new file mode 100644 index 0000000..3e45d53 --- /dev/null +++ b/packages/nap-server/test/securityRemediation.test.ts @@ -0,0 +1,295 @@ +import Fastify from 'fastify'; +import express from 'express'; +import request from 'supertest'; +import { describe, expect, it } from 'vitest'; + +import { exactUrlMatch } from '@imani/nap-core'; +import { + createNapExpressLogoutHandler, + createNapExpressSessionHandler, + writeNapCookieSuccess as writeExpressCookie, +} from '@imani/nap-adapter-express'; +import { + createNapFastifyLogoutHandler, + createNapFastifySessionHandler, + writeNapCookieSuccess as writeFastifyCookie, +} from '@imani/nap-adapter-fastify'; +import { InMemoryChallengeStore, InMemorySessionStore } from '@imani/nap-server'; +import type { SessionRecord } from '@imani/nap-core'; + +/** + * Cross-cutting checks over the whole security result, through public entry points. + * + * Every other suite here tests one package. This one exists because the two defects the + * remediation actually shipped were both invisible from inside a single package: the + * session store swept on the read path but not the write path, and the Express adapter + * gained a fix the Fastify adapter did not. Neither is findable by testing either side + * alone, and both are the same shape, which is two things that should agree and do not. + * + * So the rule here is that every case either drives both adapters from one table, or + * asserts a property rather than an implementation. Imports come from the package names a + * consumer would use, so a broken `exports` map fails this file before it reaches anyone. + */ + +const NOW = 1_710_000_000; +const pinned = (now: number) => ({ nowUnix: () => now }); + +function session(overrides: Partial = {}): SessionRecord { + return { + session_id: 's1', + challenge_id: 'c1', + access_token: 'TOK', + principal_npub: 'npub1example', + principal_pubkey: 'ff'.repeat(32), + roles: [], + permissions: [], + issued_at: NOW - 10, + expires_at: NOW + 900, + ...overrides, + }; +} + +/** A store that answers for exactly one session, so the adapters can be driven uniformly. */ +function storeFor(record: SessionRecord | null) { + return { + getByAccessToken: async (token: string) => + record && token === record.access_token ? record : null, + revokeBySessionId: async () => undefined, + } as never; +} + +/** + * The two adapters, behind one interface. + * + * Both entries must answer identically for every case below. Adding a third adapter means + * adding a row here, and the existing cases then cover it. + */ +const adapters = [ + { + name: 'express', + async setCookie(options?: Record): Promise { + const app = express(); + const write = writeExpressCookie('session', options as never); + app.get('/x', (req, res) => { + void write({ req, res, body: { access_token: 'TOK' } as never }); + }); + const response = await request(app).get('/x'); + return response.headers['set-cookie']?.[0] ?? ''; + }, + async clearCookie(options?: Record): Promise { + const app = express(); + app.post( + '/auth/logout', + createNapExpressLogoutHandler({ + server: { sessionStore: storeFor(null) }, + cookieName: 'session', + writeSuccess: writeExpressCookie('session', options as never), + } as never) + ); + const response = await request(app).post('/auth/logout'); + return response.headers['set-cookie']?.[0] ?? ''; + }, + async sessionStatus(expiresAt: number, clockNow: number): Promise { + const app = express(); + app.get( + '/auth/session', + createNapExpressSessionHandler({ + server: { sessionStore: storeFor(session({ expires_at: expiresAt })), clock: pinned(clockNow) }, + getExternalBaseUrl: () => 'https://api.example.com', + } as never) + ); + const response = await request(app).get('/auth/session').set('Authorization', 'Bearer TOK'); + return response.status; + }, + }, + { + name: 'fastify', + async setCookie(options?: Record): Promise { + const app = Fastify(); + const write = writeFastifyCookie('session', options as never); + app.get('/x', async (req, reply) => { + await write({ req, reply, body: { access_token: 'TOK' } as never }); + }); + const response = await app.inject({ method: 'GET', url: '/x' }); + await app.close(); + return (response.headers['set-cookie'] as string) ?? ''; + }, + async clearCookie(options?: Record): Promise { + const app = Fastify(); + app.post( + '/auth/logout', + createNapFastifyLogoutHandler({ + server: { sessionStore: storeFor(null) }, + cookieName: 'session', + writeSuccess: writeFastifyCookie('session', options as never), + } as never) + ); + const response = await app.inject({ method: 'POST', url: '/auth/logout' }); + await app.close(); + return (response.headers['set-cookie'] as string) ?? ''; + }, + async sessionStatus(expiresAt: number, clockNow: number): Promise { + const app = Fastify(); + app.get( + '/auth/session', + createNapFastifySessionHandler({ + server: { sessionStore: storeFor(session({ expires_at: expiresAt })), clock: pinned(clockNow) }, + getExternalBaseUrl: () => 'https://api.example.com', + } as never) + ); + const response = await app.inject({ + method: 'GET', + url: '/auth/session', + headers: { authorization: 'Bearer TOK' }, + }); + await app.close(); + return response.statusCode; + }, + }, +] as const; + +describe.each(adapters)('$name adapter: cookie and clock parity', (adapter) => { + it('defaults the session cookie to HttpOnly, Secure and SameSite', async () => { + const cookie = await adapter.setCookie(); + + expect(cookie).toMatch(/httponly/i); + expect(cookie).toMatch(/secure/i); + expect(cookie).toMatch(/samesite/i); + }); + + // The case a real deployment hits, and the one the original fix got wrong: setting a + // single attribute must add to the defaults rather than replace them. + it('merges partial options over the defaults rather than replacing them', async () => { + const cookie = await adapter.setCookie({ domain: '.example.com' }); + + expect(cookie).toMatch(/domain=\.example\.com/i); + expect(cookie).toMatch(/httponly/i); + expect(cookie).toMatch(/secure/i); + expect(cookie).toMatch(/samesite/i); + }); + + // The escape hatch has to survive, or local development over plain HTTP is impossible + // and someone reaches for a worse workaround. + it('lets an explicit opt-out win', async () => { + const cookie = await adapter.setCookie({ httpOnly: false, secure: false }); + + expect(cookie).not.toMatch(/httponly/i); + expect(cookie).not.toMatch(/secure/i); + }); + + /** + * A browser matches a deletion against name, domain and path. A clear that omits the + * domain leaves the cookie in the jar, so logout returns 204 and does not log out. + */ + it('clears with the attributes the set wrote, and without a live Max-Age', async () => { + const cookie = await adapter.clearCookie({ domain: '.example.com' }); + + expect(cookie).toMatch(/domain=\.example\.com/i); + expect(cookie).not.toMatch(/max-age=(?!0)\d/i); + }); + + it('judges /auth/session expiry on the injected clock', async () => { + expect(await adapter.sessionStatus(NOW + 900, NOW)).toBe(200); + }); + + // The pair is the point: the case above alone is satisfied by not checking expiry. + it('still refuses a session the injected clock has moved past', async () => { + expect(await adapter.sessionStatus(NOW + 900, NOW + 901)).toBe(401); + }); +}); + +describe('store growth is bounded on the path that actually grows', () => { + /** + * Both stores are filled by unauthenticated traffic, and the write path is what an + * attacker drives. Sweeping only on reads left a server taking logins and serving no + * guarded requests growing without bound, which is the shape of the attack rather than + * an edge case. + * + * Asserting equality across a tenfold difference is what makes this meaningful: residue + * is the retention window, so it tracks the TTL and not the traffic. A threshold would + * pass against an unbounded store at small volumes. + */ + it.each([ + { + label: 'challenges', + resident: async (count: number) => { + const clock = { now: NOW, nowUnix: () => clock.now }; + const store = new InMemoryChallengeStore({ clock }); + for (let index = 0; index < count; index += 1) { + await store.create({ + challenge_id: `c${index}`, + challenge: 'x', + npub: 'npub1example', + pubkey: 'ff'.repeat(32), + auth_url: 'https://api.example.com/auth/complete', + auth_method: 'POST', + state: 'issued', + issued_at: clock.now, + expires_at: clock.now + 60, + }); + clock.now += 1; + } + let alive = 0; + for (let index = 0; index < count; index += 1) { + if (await store.get(`c${index}`)) alive += 1; + } + return alive; + }, + }, + { + label: 'sessions', + resident: async (count: number) => { + const clock = { now: NOW, nowUnix: () => clock.now }; + const store = new InMemorySessionStore({ clock }); + for (let index = 0; index < count; index += 1) { + await store.createForChallenge(session({ + session_id: `s${index}`, + challenge_id: `c${index}`, + access_token: `a${index}`, + issued_at: clock.now, + expires_at: clock.now + 60, + })); + clock.now += 1; + } + let alive = 0; + for (let index = 0; index < count; index += 1) { + if (await store.getBySessionId(`s${index}`)) alive += 1; + } + return alive; + }, + }, + ])('$label: residue does not scale with volume', async ({ resident }) => { + const small = await resident(500); + const large = await resident(5_000); + + expect(large).toBe(small); + expect(large).toBeLessThan(100); + }); +}); + +describe('the audience binding stayed strict while becoming total', () => { + // Making exactUrlMatch total is only correct if it did not also become permissive: + // this is what every NIP-98 proof is checked against, so a false positive is an + // authentication bypass rather than a cosmetic bug. + it.each([ + ['unparseable', 'not-a-url'], + ['empty', ''], + ['scheme only', 'http://'], + ['trailing slash', 'https://api.example.com/auth/complete/'], + ['different host', 'https://evil.example.com/auth/complete'], + ['different scheme', 'http://api.example.com/auth/complete'], + ['different port', 'https://api.example.com:8443/auth/complete'], + ['userinfo', 'https://user@api.example.com/auth/complete'], + ])('rejects %s without throwing', (_label, candidate) => { + const audience = 'https://api.example.com/auth/complete'; + + expect(() => exactUrlMatch(candidate, audience)).not.toThrow(); + expect(exactUrlMatch(candidate, audience)).toBe(false); + }); + + it('still matches the audience it is supposed to match', () => { + expect( + exactUrlMatch('HTTPS://API.example.com/auth/complete', 'https://api.example.com/auth/complete') + ).toBe(true); + }); +}); diff --git a/packages/nap-store-postgres/package.json b/packages/nap-store-postgres/package.json index c2cd74e..08103a1 100644 --- a/packages/nap-store-postgres/package.json +++ b/packages/nap-store-postgres/package.json @@ -1,12 +1,12 @@ { "name": "@imani/nap-store-postgres", - "version": "0.10.1", + "version": "0.11.0", "type": "module", "exports": "./src/index.ts", "types": "./src/index.ts", "dependencies": { - "@imani/nap-core": "0.10.1", - "@imani/nap-server": "0.10.1", + "@imani/nap-core": "0.11.0", + "@imani/nap-server": "0.11.0", "pg": "^8.13.1" }, "peerDependencies": { diff --git a/packages/nap-voucher/package.json b/packages/nap-voucher/package.json index 5da3bfd..ed60abc 100644 --- a/packages/nap-voucher/package.json +++ b/packages/nap-voucher/package.json @@ -1,6 +1,6 @@ { "name": "@imani/nap-voucher", - "version": "0.10.1", + "version": "0.11.0", "type": "module", "exports": "./src/index.ts", "types": "./src/index.ts", @@ -17,10 +17,10 @@ "@noble/hashes": "^2.0.1" }, "devDependencies": { - "@imani/nap-adapter-express": "0.10.1", - "@imani/nap-client-http": "0.10.1", - "@imani/nap-core": "0.10.1", - "@imani/nap-server": "0.10.1", + "@imani/nap-adapter-express": "0.11.0", + "@imani/nap-client-http": "0.11.0", + "@imani/nap-core": "0.11.0", + "@imani/nap-server": "0.11.0", "express": "^4.21.2", "supertest": "^7.1.0", "@types/supertest": "^6.0.3", diff --git a/packages/nap-voucher/test/acceptance.test.ts b/packages/nap-voucher/test/acceptance.test.ts index b3aac76..45f430c 100644 --- a/packages/nap-voucher/test/acceptance.test.ts +++ b/packages/nap-voucher/test/acceptance.test.ts @@ -105,10 +105,13 @@ describe('a voucher-bound login, end to end', () => { const secret = issueVoucher(HOLDER); const minted = mintProof(secret); const audit: Array<{ code: string }> = []; - const sessionStore = new InMemorySessionStore(); + // Stores share the pinned clock: an eviction sweep on the wall clock would + // collect these fixed-timestamp records before the guard ever reads them. + const clock = { nowUnix: () => NOW }; + const sessionStore = new InMemorySessionStore({ clock }); const options: NapServerOptions = { - challengeStore: new InMemoryChallengeStore(), + challengeStore: new InMemoryChallengeStore({ clock }), sessionStore, auditLogger: { log: (event) => void audit.push({ code: event.code }) }, aclResolver: createVoucherAclResolver({ @@ -133,7 +136,7 @@ describe('a voucher-bound login, end to end', () => { }), }), minAuthResponseMillis: 0, - clock: { nowUnix: () => NOW }, + clock, }; const app = express(); @@ -145,7 +148,7 @@ describe('a voucher-bound login, end to end', () => { getExternalBaseUrl: createRequestDerivedBaseUrlResolver(['api.example.com']), }) ); - const guard = { sessionStore, clock: { nowUnix: () => NOW } }; + const guard = { sessionStore, clock }; app.get('/data', requirePermission('voucher:view:sat:1000', guard), (_req, res) => res.json({ ok: true }) ); diff --git a/packages/nap-voucher/test/endToEnd.test.ts b/packages/nap-voucher/test/endToEnd.test.ts index febee2e..fe7c854 100644 --- a/packages/nap-voucher/test/endToEnd.test.ts +++ b/packages/nap-voucher/test/endToEnd.test.ts @@ -393,14 +393,17 @@ describe('extension 0001 end to end: a voucher authorizes a real NAP login', () credentials, }); - const sessionStore = new InMemorySessionStore(); + // Stores share the pinned clock: an eviction sweep on the wall clock would + // collect these fixed-timestamp records before the guard ever reads them. + const clock = { nowUnix: () => now }; + const sessionStore = new InMemorySessionStore({ clock }); const serverOptions: NapServerOptions = { - challengeStore: new InMemoryChallengeStore(), + challengeStore: new InMemoryChallengeStore({ clock }), sessionStore, aclResolver, auditLogger, minAuthResponseMillis: 0, - clock: { nowUnix: () => now }, + clock, randomSource: { randomBytes: (length: number) => new Uint8Array(Array.from({ length }, (_, index) => (index + 7) % 255)), @@ -422,7 +425,7 @@ describe('extension 0001 end to end: a voucher authorizes a real NAP login', () sessionStore, aclResolver, auditLogger, - clock: { nowUnix: () => now }, + clock, }), (_req, res) => { res.status(200).json({ status: 'ok' }); diff --git a/specs/003-event-ticketing/spec.md b/specs/003-event-ticketing/spec.md new file mode 100644 index 0000000..f936974 --- /dev/null +++ b/specs/003-event-ticketing/spec.md @@ -0,0 +1,522 @@ +# Event Ticketing on Voucher-Bound Authorization + +> **New to this?** Read [how NAP uses Cashu mint authorisation](../../docs/explanation/mint-backed-authorisation.md) +> and [extension 0001](../../docs/extensions/0001-voucher-bound-authorization.md) first. This +> document assumes both. + +**Status:** Draft, for review. Nothing implemented. + +**Depends on:** Extension 0001 (`P2PK_VOUCHER` secret, the voucher ACL resolver), NUT-07 state +check, NUT-11 P2PK, NUT-12 DLEQ. + +**Applies to:** an application built *on* NAP. This is not a protocol change and adds nothing to +the RFC. Two items in §13 would be, and are called out where they arise. + +**Conferencing engine:** BigBlueButton (LGPL-3.0). §6 and §8 name its API directly; every other +section is engine-agnostic. + +Each requirement carries one of three markers: + +- **[built]** — extension 0001 or `@imani/nap-voucher` already does this. Wire it. +- **[build]** — application code, specified here. +- **[gap]** — needs something NAP does not have yet. §13 tracks each one. + +--- + +## 1. Summary + +A paid ticket to a live online event is a `P2PK_VOUCHER` proof. Presenting it at +`/auth/complete` yields a NAP session whose permissions admit the holder to one event. The +session, not the proof, is the seat: admission does not spend the ticket, so a dropped +connection costs nothing and a rejoin is free. + +Three properties fall out that no ticketing platform currently offers together: + +- **No attendee record.** The organiser never learns who attended. There is no guest list to + breach, subpoena, or sell. +- **Tickets transfer without a platform.** Resale is an ordinary Cashu swap between two + parties. Nobody arbitrates whether a transfer is permitted, and the seat count is preserved + because the number of live proofs is fixed. +- **Capacity is arithmetic, not policy.** *N* proofs issued means at most *N* simultaneous + attendees, enforced by the mint's double-spend database and the conferencing engine's own + participant cap rather than by a rule someone could misconfigure. + +The one-line framing: **the ticket is the entitlement, the session is the seat, and the mint is +the turnstile.** + +## 2. Terminology + +| Term | Meaning | +| --- | --- | +| **Ticket** | A `P2PK_VOUCHER` proof whose tags name an event. | +| **`K`** | The keypair a ticket is P2PK-locked to. Freshly generated per ticket (§4.3). | +| **Holder** | Whoever controls `K`. Deliberately not "the buyer" — they may differ after a transfer. | +| **Admission** | The NAP login that converts a ticket into a session. | +| **Join** | The redirect from an admitted session into the conferencing engine. | +| **Seat** | One concurrent occupancy of the meeting, keyed on ticket. | +| **Organiser** | Whoever issues tickets and runs the event. Also the voucher issuer. | + +## 3. Actors and trust boundaries + +``` + Holder Platform (NAP) Mint Conferencing engine + │ │ │ │ + │ ── admission ────────► │ │ │ + │ (NIP-98 by K, │ ── state check ───► │ │ + │ ticket in body) │ ◄── UNSPENT ─────── │ │ + │ ◄── session cookie ─── │ │ │ + │ │ │ │ + │ ── GET /join ────────► │ ── create/join ───────────────────────────► │ + │ ◄── 302 ───────────────│ (shared secret, server-side) │ + │ ─────────────────────────────── join URL ──────────────────────────► │ +``` + +Three boundaries, and what each side is trusted for: + +- **Holder → Platform.** Untrusted. Everything the holder sends is attacker-controlled, + including `mint_url` (§5.2.1). +- **Platform → Mint.** Trusted for liveness only, and only for mints on the allowlist. The mint + learns that *a* ticket was state-checked, and when. It does not learn who holds it. +- **Platform → Engine.** The platform holds the engine's shared secret. The engine has no user + model of its own and trusts any correctly-checksummed call. The platform is therefore the + entire authorization layer, and the engine's join URL is the point at which NAP's guarantees + end (§6.2). + +The organiser is **not** in the trusted path for admission. They issued the ticket; they cannot +retroactively decide who gets in, short of ending the meeting. + +## 4. The ticket + +### 4.1 Secret + +**T-1 [built]** A ticket MUST be a NUT-10 secret of kind `P2PK_VOUCHER`, per ADR 0003. Neither +a bare `VOUCHER` secret carrying P2PK tags (the lock goes unenforced by the mint) nor a `P2PK` +secret carrying voucher tags (the issuer signature covers bytes that never appear on the wire) +is acceptable. + +**T-2 [built]** The secret's `data` field MUST be the P2PK lock key `K`. + +### 4.2 Tags + +Extension 0001's tag vocabulary carries the ticket without extension. Nothing below is a new +tag. + +| Tag | Required | Ticket meaning | +| --- | --- | --- | +| `voucher_id` | yes | Opaque unique id. Also the seat key (§6.3) and the engine `userID` seed. | +| `issuer` | yes | The organiser. | +| `issuer_pubkey`, `issuer_sig` | yes | Issuer authority. Verified locally at step (e). | +| `expires_at` | yes | Doors close, plus grace. See T-5. | +| `unit` | yes | The tier discriminator — e.g. `ga`, `speaker`, `press`. | +| `face_value` | yes | See §4.4. | +| `memo` | no | Human-readable event name, shown in wallets. Not authoritative. | +| `merchant_metadata` | yes | Carries `event_id`. Issuer-signed, therefore not forgeable. | + +**T-3 [build]** `event_id` MUST live in an issuer-signed tag. `merchant_metadata` is the +designated home. It MUST NOT be inferred from the request path, the session, or anything else +the holder controls — a ticket for a €5 workshop must not admit its holder to a €500 masterclass +because the path said so. + +**T-4 [build]** `unit` is the tier. The mapping from `unit` to roles is `grant()` (§7), and it +MUST be total: an unrecognised `unit` denies rather than defaulting to general admission. + +**T-5 [built]** `expires_at` MUST be the event end plus an operator-chosen grace window, not the +event start. A ticket that expires at doors-open cannot readmit someone who drops at minute +three. + +### 4.3 Key generation + +**T-6 [build]** `K` MUST be freshly generated per ticket and MUST NOT be the holder's long-term +Nostr identity key. + +This is the single requirement most likely to be quietly violated by a wallet implementation, +and violating it forfeits the entire privacy claim: a ticket locked to a personal npub is a +named attendee record, and no amount of server-side care recovers it. Extension 0001 states the +same rule and nothing enforces it there either. The platform SHOULD reject at issuance any lock +key that matches a key it has seen on another ticket, which catches the careless case without +pretending to catch the determined one. + +**T-7 [build]** The platform MUST NOT retain the mapping from purchaser to `voucher_id` after +issuance settles. Retaining it reconstructs the attendee list this design exists to abolish, in +the one place best positioned to do so. + +### 4.4 What `face_value` means + +**T-8 [build] — decision required.** Two models, and the choice is architectural: + +**(a) Ticket as entitlement (recommended).** The buyer pays through an ordinary channel; the +organiser then issues a ticket with nominal `face_value`. The organiser has their money at +issuance and never needs to redeem. Resale is a pure entitlement transfer, with payment between +the parties handled separately and invisibly to the platform. `backing_strategy` and +`issuance_ratio` document the arrangement. + +**(b) Ticket as ecash.** The ticket carries real value and the organiser swaps it for settlement. +Because admission MUST NOT spend (§5.2.4), settlement is a separate batch after doors close — +which means an attendee who transfers their ticket mid-event leaves the organiser holding a proof +someone else already spent. + +Model (a) unless there is a specific reason otherwise. Model (b) is not specified further here. + +## 5. Lifecycle + +### 5.1 Issuance + +**L-1 [build]** The organiser declares `capacity`. Exactly `capacity` tickets are minted. This +number is the only capacity control that cannot be misconfigured later, because it is the count +of things that exist. + +**L-2 [build]** Issuance is out of band with respect to NAP. The platform's issuance endpoint is +guarded by the organiser's own session — an ordinary stored-ACL NAP login, not a voucher one. +Ticketing does not make the organiser anonymous, and should not try to. + +### 5.2 Admission + +Admission is extension 0001's verification procedure, unmodified. It is restated here only for +the ordering, which is load-bearing. + +**L-3 [built]** `/auth/init` issues the challenge. `/auth/complete` carries the NIP-98 event +signed by `K`, with the ticket in the body. + +**L-4 [built]** Verification runs in extension 0001's order: (a) mint allowlist, (c) parse +secret, (d) P2PK key equals completion signer, (g) `expires_at`, (e) issuer signature, +(f) `(mint, issuer)` allowlist, (b) DLEQ, (h) NUT-07 state, (i) grant. Steps (a)–(i) run **only +after** RFC steps 1–12 have proven key control. + +Three orderings carry weight and MUST NOT be rearranged: + +**5.2.1 [built]** The mint allowlist runs before any outbound call. `mint_url` arrives in the +request; fetching it first is server-side request forgery from inside the perimeter. Both +allowlists throw at construction when empty. The mint allowlist MUST reject `http:` — an +attacker on the path can otherwise forge `UNSPENT`. + +**5.2.2 [built]** The binding check (d) runs before the network call (h). It is local and free, +and running the round trip first would tell a mint that someone is probing a proof already known +to be invalid. + +**5.2.3 [built]** Everything runs after key control is proven. Otherwise `/auth/complete` is a +free oracle for state-checking arbitrary proofs against a mint the caller does not control. + +**5.2.4 [built]** **Admission MUST NOT spend the ticket.** NUT-07 is read-only. Spending at the +door means a dropped connection costs a ticket, and it would make NAP's retry-safe completion +path destructive — a duplicate completion is guaranteed to return the same session, which is not +possible if the first one burned the proof. + +**L-5 [build]** On success the session's lifetime MUST be capped at the event duration plus +grace, via `maxSessionLifetimeSeconds`. This is the bound on §7.1 of extension 0001: a ticket +transferred mid-event leaves the transferor's session live until the ceiling, and the ceiling is +the only thing that ends it. + +### 5.3 Join + +**L-6 [build]** `GET /events/:id/join` MUST be guarded by `requirePermission` for the permission +`grant()` derived from *this ticket's* `event_id` (§7.1), and MUST pass the same `aclResolver` +the server was configured with. A guard without an explicit resolver reads the login-time +snapshot only. + +**L-7 [build]** The join handler MUST: + +1. Verify the meeting exists and is running; create it if not (§6.1). +2. Run the seat check (§6.3). Refuse if the ticket already occupies a seat. +3. Construct the engine's `join` call server-side, signed with the shared secret. +4. Respond `302` to the resulting URL. + +**L-8 [build]** The join URL MUST NOT be returned to the client as data — no JSON field, no +template variable, no `fetch` response. A `302` consumed immediately is the only form in which +it leaves the server. This does not make it secret (§6.2); it removes the obvious ways it gets +copied. + +### 5.4 Transfer and resale + +**L-9 [built, at the mint]** Transfer is an ordinary Cashu swap and involves the platform not at +all. The holder presents the proof locked to `K`; the mint marks it spent and issues a +replacement locked to the recipient's `K'`. The old ticket is dead on its next state check. + +**L-10 [build]** The platform MUST NOT offer a transfer endpoint, a transfer approval step, or a +transfer record. Every one of those reintroduces the arbitration this design removes, and none is +necessary for the swap to work. + +**L-11 [build]** The transferor's live session survives the swap until the §L-5 ceiling. The +platform MUST NOT treat this as a defect to be patched with per-request mint checks; extension +0001 §7.1 establishes the ceiling as the intended bound. Operators wanting a tighter bound +shorten the ceiling, at the cost of re-admission prompts mid-event. + +### 5.5 Refund and cancellation + +**L-12 [gap]** There is no automatic refund path. NUT-11 `locktime` with a refund pubkey is the +mechanism — the holder can spend until `T`, after which only the organiser can, or the reverse +for a cancellation refund — and `packages/nap-voucher` implements neither tag. `expires_at` is a +local clock check on an issuer-signed value, not a mint-enforced spending condition, and cannot +substitute. + +Until §13.1 closes, cancellation refunds are manual and out of band. + +## 6. Seat enforcement + +### 6.1 Meeting creation + +**S-1 [build]** The meeting MUST be created with `maxParticipants` equal to the number of tickets +issued for the event. + +This is the arithmetic cap and the most valuable single line in the integration: it is enforced +by the engine, needs no platform state, and cannot drift from the ticket count if it is derived +from it. + +**S-2 [build]** The meeting SHOULD set `duration` to bound the event server-side, `logoutURL` +back into the platform, and `meta_endCallbackUrl` for end-of-meeting reconciliation. + +**S-3 [build]** `create` MUST be called server-side and MUST be idempotent from the platform's +side — call it immediately before the first join rather than on a schedule, and retain the +returned `createTime`. + +### 6.2 The join URL is a bearer credential + +**S-4 [build] — stated so it is not discovered later.** The engine's join URL is checksummed but +bearer: it is reusable for the life of the meeting instance, it carries `role` in the clear, and +the engine will honour it from any browser. Everything NAP proves about `K` terminates at the +`302`. + +This is not a defect to be fixed; it is the boundary. Defence is layered and each layer is +independently worth having: + +| Layer | Stops | Does not stop | +| --- | --- | --- | +| `maxParticipants` = tickets issued | Any over-capacity attendance, however achieved | The wrong *person* attending within capacity | +| Seat check on `userID` (§6.3) | A second concurrent join on one ticket | Sequential handoff of one ticket | +| `createTime` on the join URL | A saved URL admitting to a later meeting | Reuse within the same instance | +| `302`, never data | Casual copying out of the UI | Devtools, browser history | + +**S-5 [build]** The platform MUST NOT claim single-use join links to organisers. Sequential +handoff of one ticket between two people, one at a time, is not prevented by this design and +SHOULD be documented as such. + +### 6.3 Seat check + +**S-6 [build]** The engine `userID` MUST be `sha256(voucher_id)`, truncated to the engine's +identifier limits. It MUST be stable for the lifetime of a ticket and MUST NOT be derived from +`K`, the purchaser, or anything that survives a transfer — a transferred ticket keeps its seat, +which is correct. + +**S-7 [build]** Before issuing a join, the platform MUST call `getMeetingInfo` and refuse if this +`userID` already appears among the attendees. + +Polling `getMeetingInfo` at join time is deliberately chosen over the webhooks module: it needs +no additional deployment, no callback endpoint, and no delivery guarantees. It leaves a race +window of a few seconds during which two joins on one ticket could both pass. `maxParticipants` +is the backstop, and the residual harm — one ticket briefly seating two people inside an +already-capped meeting — does not justify the operational surface of webhooks. + +**S-8 [build]** Refusal at the seat check MUST be distinguishable to the holder from refusal at +admission. Admission failures are deliberately uniform 401s (§10); a seat refusal is not a +security signal and the holder needs to know their ticket is in use elsewhere rather than +invalid. + +## 7. Grant policy + +### 7.1 `grant()` + +**G-1 [build]** `grant()` maps a verified ticket to roles and permissions. It is the +application's policy and lives outside the library by design. + +```ts +grant(v: VerifiedVoucher): VoucherGrant { + const eventId = eventIdFrom(v.secret); // merchant_metadata, issuer-signed + const tier = TIERS[v.unit ?? '']; // total, or deny + if (!eventId || !tier) return { roles: [], permissions: [] }; + return { roles: [tier.role], permissions: [`event:join:${eventId}`] }; +} +``` + +**G-2 [build]** The permission MUST name the event. A bare `event:join` admits any ticket to any +event, and the ticket's own `event_id` is then decoration. + +**G-3 [build]** An empty grant MUST deny. A ticket for an unrecognised tier or a missing +`event_id` is not a general-admission ticket. + +### 7.2 Tiers + +**G-4 [build]** Tier maps to the engine's role: + +| `unit` | NAP role | Engine role | +| --- | --- | --- | +| `ga` | `attendee` | `VIEWER` | +| `speaker` | `presenter` | `MODERATOR` | +| `press` | `attendee` | `VIEWER` | + +**G-5 [build]** `MODERATOR` is a destructive capability in this engine — a moderator can end the +meeting, mute everyone, and start recording. It MUST appear in `destructivePermissions` for the +availability policy (§8) and MUST NOT be reachable from any degraded path. + +### 7.3 Registry validation + +**G-6 [build] — decision required.** ADR 0004 validates `grant()` output against the permission +registry at grant time. A per-event permission key (`event:join:evt_042`) means the registry must +either enumerate every event or be omitted. + +Two options: + +- **Enumerate.** Register `event:join:${id}` when the event is created. Keeps the typo guard; + couples event creation to registry mutation. +- **Coarse permission plus a session claim.** Grant `event:join` and carry `event_id` in the + session, checked in the handler. Keeps the registry static; moves one check out of the guard + and into application code, where it is easier to forget. + +Enumerate, unless events are created by an untrusted path. The typo guard is worth more than the +coupling, and G-2's failure mode — every ticket admitting to every event — is exactly what a +registry check catches. + +## 8. Availability + +**A-1 [build]** Admission depends on the mint. If the mint is unreachable, nobody is admitted, +and unlike recorded media the event does not wait. + +**A-2 [build]** `onMintUnavailable: 'degrade'` is defensible here in a way it is not for a paid +recording. A degraded grant admits a holder whose ticket may already have been transferred; +turning away an entire audience is the larger harm for a live event that happens once. + +**A-3 [build]** If degrade is enabled, the degraded grant MUST admit at `VIEWER` only. +`MODERATOR`, and every permission that can end or record the meeting, MUST be listed in +`destructivePermissions` and `destructiveRoles`, which throw at wiring time on overlap. + +A degraded session is one where the platform does not know whether the ticket is live. Handing +that session the ability to end the event is the failure mode extension 0001 §7.3 forbids, in its +most literal form. + +**A-4 [build]** Degrade MUST be per-event configuration, not global. A free community call and a +paid masterclass do not want the same answer. + +## 9. Privacy + +**P-1 [build]** The engine's `fullName` is required and is shown to every other attendee. It MUST +be a display name the attendee supplies at the door. It MUST NOT be derived from the purchase, +the mint, `K`, or `voucher_id`. + +**P-2 [build]** `meta_*` parameters are retrievable from `getRecordings` indefinitely. Ticket +ids, `voucher_id`, lock keys and purchaser data MUST NOT appear in any `meta_*` value. + +**P-3 [build]** The platform MUST NOT log the association between a session and a `voucher_id` +beyond the seat table's lifetime, and the seat table MUST be discarded when the meeting ends. + +**P-4 [built]** Audit logging records denial codes and the completion pubkey. Because that pubkey +is a per-ticket burner (T-6), it is not an identity — provided T-6 actually holds. + +**P-5 [build]** The mint learns that a ticket was state-checked, and when. Admission is once per +session rather than once per request, which bounds the pattern to roughly one observation per +attendee per event. This is the residual leak and it is not removable without blind presentation, +which is out of scope. + +## 10. Failure codes + +**F-1 [built]** Every admission failure MUST return a byte-identical 401 — same status, same +body, same headers. Extension 0001's ten codes exist only in the `AuditLogger`. + +**F-2 [build]** Ticket-specific denials — unrecognised tier, missing `event_id`, wrong event — +MUST return through the same uniform 401. Together the codes are an oracle: they would tell a +caller whether a mint is allowlisted, whether an issuer is trusted, and, most sensitively, +whether a given proof has been spent. + +**F-3 [build]** The seat check (§S-8) is the one deliberate exception, and it sits after +admission has already succeeded. It reveals nothing about ticket validity. + +## 11. Interfaces + +**Data.** Two tables, both discardable: + +```ts +interface Event { + id: string; + title: string; + startsAt: number; + endsAt: number; + capacity: number; // == tickets minted, == maxParticipants + degradeOnMintUnavailable: boolean; + meetingId?: string; // engine-side, set on first create + createTime?: string; // from create, pinned into join URLs +} + +interface Seat { + eventId: string; + userIdHash: string; // sha256(voucher_id) + occupiedAt: number; +} +``` + +**HTTP.** + +| Method | Path | Guard | Notes | +| --- | --- | --- | --- | +| `POST` | `/events` | organiser session | Creates event, mints `capacity` tickets | +| `GET` | `/events/:id` | none | Public event page. No attendee data | +| `POST` | `/auth/init` | none | NAP, unchanged | +| `POST` | `/auth/complete` | none | NAP, unchanged. Ticket rides in the body | +| `GET` | `/events/:id/join` | `requirePermission` | 302 to the engine | + +No transfer endpoint (L-10). No attendee list endpoint, at any privilege level. + +**Wiring trap [built].** The ticket rides in the `/auth/complete` body and the NIP-98 `payload` +tag is `sha256(rawBody)`. A global `express.json()` ahead of the NAP router breaks every +admission with `NAP_COMPLETE_PAYLOAD_MISMATCH`. The engine has its own version of the same class +of bug: its checksum covers the query string, not the POST body, and duplicating a parameter +across both throws `checksumError`. + +## 12. Acceptance + +The spike is §12.5. Everything before it is plumbing that obviously works. + +1. **Issue.** Mint three tickets for one event, each locked to a distinct fresh `K`. +2. **Admit.** Each ticket yields a session carrying `event:join:` and the tier's role. +3. **Join.** Each session receives a `302` and lands in the meeting. +4. **Cap.** A fourth ticket, minted outside `capacity`, is admitted by NAP and refused by + `maxParticipants`. *(Both halves matter: NAP admitting it is correct — the ticket is valid; + the engine refusing it is where capacity lives.)* +5. **Negative cases, all four:** + - A ticket already swapped at the mint → `NAP_VOUCHER_SPENT`, uniform 401. + - A valid ticket presented with a different keypair → `NAP_VOUCHER_BINDING_MISMATCH`, + uniform 401, byte-identical to the above. + - A ticket for event A used against event B's join → refused by the guard. + - A second concurrent join on ticket #1 → refused by the seat check, with a distinguishable + message. +6. **Degrade.** With the mint unreachable and degrade enabled, a `speaker` ticket admits at + `VIEWER`, not `MODERATOR`. + +Step 5 is the whole security claim. Steps 1–4 and 6 are configuration. + +## 13. Open questions + +**13.1 Locktime and refund.** §L-12. Adding `locktime` and `refund_pubkey` to the +`P2PK_VOUCHER` tag vocabulary is a protocol change: it touches extension 0001, needs the +matching change in `nap-java`, and — critically — needs the mint to enforce the composite kind's +spending condition. Mint-side behaviour for `P2PK_VOUCHER` is the least settled part of the +stack. Until then, no automatic refunds. + +**13.2 Java parity.** `nap-java` has no voucher module. Extension 0001 is TypeScript-only, so +nothing here can be served by the JVM implementation. Interop is not merely untested; the feature +is absent. + +**13.3 Does the platform need to know `capacity` at all?** If tickets are the only thing that +exist, `maxParticipants` could be derived by counting issued proofs at the mint rather than +stored. That removes the one number that can drift from reality, at the cost of a mint call +during meeting creation. Worth deciding before the seat table is written. + +**13.4 Sequential handoff.** §S-5. One ticket used by two people in turn is not prevented. It may +not be worth preventing — it is exactly what lending a physical ticket looks like, and the +capacity guarantee holds regardless. But it should be a decision, not an omission. + +**13.5 What does a recording cost?** Post-event access to a recording is the encrypted-segment +design from the DRM note, and the engine's own recording playback URLs are plain bearer links. +Turning `record=true` on without deciding this hands out a permanent, transferable, unmetered +copy to anyone who ever held a ticket. + +**13.6 Interactive or broadcast?** If most attendees are passive, an SFU carries broadcast +traffic at SFU cost, and the ticket would be better gating a segment key than a conference join. +The ticketing half of this spec is unchanged either way; §6 is not. + +## 14. Out of scope + +- **Payment.** How the buyer pays for a ticket is model (a)'s separate channel. Admission never + spends. +- **Anonymous credentials.** The mint learns a ticket was checked. Blind presentation is not + attempted. +- **Single-use across servers.** Nothing prevents one ticket admitting on two platforms that both + honour the same issuer. Extension 0001 §7.4 applies unchanged. +- **Hosting and branding of the conferencing engine.** Deliberately excluded; the integration + surface is one base URL and one shared secret, and is swappable. diff --git a/specs/004-broadcast-ticketing/spec.md b/specs/004-broadcast-ticketing/spec.md new file mode 100644 index 0000000..af05d48 --- /dev/null +++ b/specs/004-broadcast-ticketing/spec.md @@ -0,0 +1,339 @@ +# Ticket-Gated Broadcast + +> **Read [003 event ticketing](../003-event-ticketing/spec.md) first.** This document is a +> variant of it, not a replacement. §§1–5, 9 and 10 of 003 apply unchanged and are not restated. + +**Status:** Draft, for review. Nothing implemented. + +**Relationship to 003:** 003 targets a conferencing engine with a real authorization boundary +(BigBlueButton). This document targets a broadcast platform with none. The ticket, its issuance, +admission, transfer and privacy rules are identical; everything downstream of the session is +different. + +**Engine:** zap.stream / `zap-stream-core` (GPL-3.0). RTMP ingest, HLS output, NIP-53 kind 30311 +announcements on Nostr, NIP-98 API auth. + +Requirement ids in this document use the `B-` prefix and do not collide with 003's. + +--- + +## 1. The finding + +`zap-stream-core` has **no viewer-side access control of any kind**. There is no paywall, no +allowlist, no entitlement check, no signed playback URL, and no per-viewer token anywhere in its +API. `content_warning` is descriptive metadata, not enforcement. The only access control the API +implements separates a broadcaster from an admin; it never separates one viewer from another. +Money is present, but exclusively as broadcaster billing — account balance, top-up, withdraw, +per-endpoint `cost`. + +Discovery compounds it. The playback URL is published in the `streaming` tag of a NIP-53 kind +30311 event on public relays. Anyone who reads the announcement has the stream. + +**B-1.** Therefore: hosted zap.stream **cannot** serve a ticketed event. A stream it ingests is +public by construction, and gating a second copy of a stream that is already free accomplishes +nothing. This document assumes self-hosted `zap-stream-core`, where the operator controls both +`overseer.nsec` (which publishes the 30311) and `overseer.advertise` (which offers the server to +zap.stream's directory). + +This inverts the work relative to 003. BBB gave an authorization boundary with a bearer-URL +weakness at the end of it. Here there is no boundary, so §6 of 003 is not adapted — it is +replaced by §§4–5 below. + +## 2. Inherited from 003, unchanged + +The following apply verbatim. Where this document contradicts them, this document is wrong. + +| 003 | Subject | +| --- | --- | +| §4, T-1 … T-8 | The ticket: `P2PK_VOUCHER` secret, tags, `event_id` in `merchant_metadata`, fresh `K` per ticket, `face_value` model | +| §5.1, L-1 … L-2 | Issuance | +| §5.2, L-3 … L-5 | Admission, verification order, **admission MUST NOT spend** | +| §5.4, L-9 … L-11 | Transfer and resale, and the session ceiling | +| §5.5, L-12 | No automatic refund path — still a gap | +| §7, G-1 … G-3, G-6 | `grant()`, event-named permissions, empty grant denies, registry validation | +| §9, P-2 … P-5 | Privacy, with P-5 amended by B-16 | +| §10, F-1 … F-3 | Uniform 401s | + +**Amended:** G-4, G-5 (§6 below), A-1 … A-4 (§7), P-1 (moot — §8), and the whole of 003 §6. + +## 3. Where zap.stream is the better answer + +Recorded so the tradeoff is legible, because §§4–5 are a larger build than 003's. + +**B-2.** The 30311 is the poster and NAP is the box office. Publish the announcement publicly — +`title`, `image`, `starts`, `status`, and a purchase link — and withhold only `streaming`. Every +Nostr client shows the event; the ticket gates the media. This is a genuine fit rather than a +workaround, and it gives a ticketed event the discovery surface that ticketing platforms +normally have to buy. + +**B-3.** One auth vocabulary. `zap-stream-core` already authenticates its API with NIP-98 kind +27235, the same primitive NAP uses. Broadcaster-side only, so it does not help admission, but it +means one signing model across the stack. + +**B-4.** This settles 003 §13.6. HLS from a CDN scales the way a ticketed audience needs; an SFU +does not. Broadcast is the committed answer here. + +## 4. Architecture: encrypted HLS, gated key + +### 4.1 Integration depth + +**B-5 — decision required.** Two depths, neither requiring a fork: + +**(a) Forward to your own packager (recommended).** `POST /api/v1/forward` adds an RTMP forward +target. `zap-stream-core` ingests and transcodes; a copy goes to your packager, which does the +encryption and serves the gated HLS. Requires that the operator's own output is **not** published +— `overseer.advertise` off, and the 30311 emitted without a usable `streaming` tag (B-7). + +**(b) Announcement only.** Use `zap-stream-core` for nothing but the 30311, and run ingest +yourself. Fewer moving parts and no per-endpoint cost, at the price of rebuilding ingest and +transcode. + +Take (a). It keeps ingest, transcode and the ABR ladder as someone else's problem, which is the +only part of this stack that is genuinely hard and genuinely solved. + +**B-6.** `zap-stream-core` is GPL-3.0 — stricter than BBB's LGPL, but with no network clause. +Running a modified copy as a service is not distribution. Neither (a) nor (b) requires modifying +it at all. + +### 4.2 The announcement + +**B-7.** The published kind 30311 MUST NOT carry a playable `streaming` URL. It MAY carry a URL +pointing at the platform's own gateway, which requires a session and redirects or 401s. + +**B-8.** The 30311 MUST NOT carry any tag that identifies ticket holders. NIP-53 `p` tags name +participants with roles; for a ticketed broadcast they name the host and speakers only. A `p` tag +per attendee would publish the attendee list to public relays — the exact artefact 003 T-7 exists +to prevent, in the most durable place available. + +**B-9.** `status` and `current_participants` MAY be published. `current_participants` is an +aggregate and leaks nothing about who; it is also the only public signal that the event is +selling. + +### 4.3 Encryption + +**B-10.** Segments MUST be encrypted with HLS's native `#EXT-X-KEY:METHOD=AES-128`. Every player +implements it, so there is no custom player and none of the MSE work in the DRM note's §08. + +**B-11.** The `URI` attribute of `#EXT-X-KEY` MUST point at the platform's own origin, at an +endpoint guarded by `requirePermission` for this event's permission. + +**B-12.** The manifest itself SHOULD also be gated, but MUST NOT be relied on as the boundary. A +manifest is one request; the segments are many, and a manifest leaked after fetch names every +segment URL. The key is the boundary; the manifest is a speed bump. + +### 4.4 The key endpoint + +**B-13.** NAP's session id is an HttpOnly cookie, so a same-origin key request carries it with no +header plumbing on the player side. This is the whole reason the design is small. + +Two paths need verifying early, because they fail differently: + +- **hls.js** — key fetches go through the library's loader. `xhrSetup` sets `withCredentials` + where the key origin differs from the page; same-origin needs nothing beyond a `SameSite` value + that permits it. +- **Native HLS (Safari, iOS)** — the key request is issued by the platform media stack, not by + script, and the cookie behaviour is not under the page's control. **This MUST be tested before + the design is committed to**, because on iOS there is no fallback to hls.js. + +**B-14.** The key endpoint MUST enforce the concurrency rule in §5. It is the only component that +sees every viewer continuously and therefore the only place capacity can be enforced at all. + +### 4.5 The ceiling, stated + +**B-15.** An AES-128 HLS key passes through the player and is reachable from script or devtools. +Extraction is not prevented, and the platform MUST NOT be described to organisers as if it were. + +This is the same Widevine-L3 tier the DRM note establishes, and it is the price of not writing a +custom player: the non-extractable `CryptoKey` path in that note's §07 is not reachable through +standard HLS. Rotation (§4.6) bounds what one extracted key is worth; nothing bounds the +determined case. + +### 4.6 Rotation + +**B-16.** Keys MUST rotate. A new `#EXT-X-KEY` line mid-playlist rekeys every segment after it, +which is standard packager behaviour and needs no client support. + +**B-17.** The rotation period is the primary tuning knob and trades three things against each +other: + +| Shorter period | Longer period | +| --- | --- | +| Smaller blast radius per extracted key | Fewer key requests | +| Finer concurrency signal (§5) | Less observation of viewers (§8) | +| More load on the key endpoint | Coarser capacity enforcement | + +Start at one rotation per 30–60 seconds of media. It gives a usable concurrency heartbeat without +turning the key endpoint into a per-segment request stream. + +## 5. Capacity and concurrency + +This section replaces 003 §6 entirely. + +**B-18.** There is no `maxParticipants` equivalent. HLS has no participant cap. 003 S-1 — the +single most valuable line in the BBB integration, because capacity was enforced by the engine and +could not drift — **has no analogue here**. Capacity stops being arithmetic and becomes +application logic that can have a bug. + +This is the largest single regression against 003 and MUST be stated to organisers in those +terms. + +**B-19.** Concurrency MUST be derived from key requests. A viewer playing the stream requests a +key every rotation period; a viewer who has stopped does not. Therefore: + +``` +concurrent(event) = |{ ticket : ticket requested a key within the last 2 rotation periods }| +``` + +Two periods, not one, so a single dropped request does not evict a live viewer. + +**B-20.** A key request from a ticket beyond `capacity` concurrent tickets MUST be refused. The +refusal MUST be distinguishable from an admission failure, per 003 S-8 — it is not a security +signal, and the holder needs to know their ticket is in use elsewhere. + +**B-21.** Concurrency is a *better* seat check than 003's, and this should be recognised rather +than mourned. 003 S-7 checked only at join; a viewer who joined and left held their seat until +the meeting noticed. Key requests are continuous, so a seat is released within two rotation +periods of the viewer actually stopping, with no webhook, no callback endpoint, and no delivery +guarantees. + +**B-22.** The concurrency table MUST hold only `(event_id, sha256(voucher_id), last_seen)` and +MUST be discarded when the event ends. See B-27. + +## 6. Tiers + +**B-23.** 003 G-4 and G-5 do not apply. zap.stream has no viewer-side role, and in particular no +destructive one — there is no `MODERATOR` equivalent, nothing a viewer can end, mute or record. + +**B-24.** Tiers, if offered, map to things the packager controls rather than to engine roles: +a bitrate ceiling in the ABR ladder, early access before `starts`, or access to the recording +(§12.3). The mapping MUST still be total per 003 T-4 — an unrecognised `unit` denies. + +## 7. Availability + +**B-25.** 003 A-1 applies: the mint is an availability dependency of admission, and the event +does not wait. + +**B-26.** 003 A-3's constraint on degraded grants largely evaporates, because B-23 leaves no +destructive role to withhold. `onMintUnavailable: 'degrade'` is therefore materially safer here +than in 003, and SHOULD be the default for a live broadcast. + +`destructivePermissions` MUST still be configured, because a degraded grant must not carry +whatever tier permission unlocks the recording — that outlives the outage and is exactly the +value-bearing grant extension 0001 §7.3 forbids issuing on unknown liveness. + +## 8. Privacy + +003 P-2 through P-5 apply. Two changes and one collision. + +**B-27 — amends 003 P-5.** The mint learns less here and the platform learns much more. Admission +is still one mint observation per session, but the key endpoint now sees each ticket **every +rotation period for the duration of the event** — a continuous attendance record of exactly the +kind this design exists to avoid, held by the one party best placed to abuse it. + +Therefore: the key endpoint MUST NOT log per-request. The concurrency table (B-22) holds +`last_seen` only, overwritten in place, never appended. There MUST be no request log, access log +or metrics series keyed on `sha256(voucher_id)`. + +**B-28.** 003 P-1 is moot. HLS viewers supply no name and appear in no participant list. This is +a straightforward privacy improvement over 003. + +**B-29 — the collision.** NIP-53 live chat is kind 1311, published to public relays and signed by +the poster's own key. 003 T-6 gives every ticket a burner keypair precisely so attendance is +unlinkable — and the first chat message, signed with the holder's real Nostr identity, publishes +the association to a public relay, permanently, where the platform cannot retract it. + +Worse, 1311 is open in both directions: anyone can read the chat and anyone can post to it, +ticket or not. + +**B-30.** Therefore the platform MUST NOT present NIP-53 live chat as part of a ticketed event +without stating both properties to holders. If ticket-holders are to have a private chat, it +cannot be built on 1311 as specified, and is out of scope here (§13). + +## 9. Interfaces + +**Data.** Replaces 003 §11's `Seat`: + +```ts +interface BroadcastEvent { + id: string; + title: string; + startsAt: number; + endsAt: number; + capacity: number; + rotationSeconds: number; // B-17 + naddr: string; // the published 30311 + degradeOnMintUnavailable: boolean; +} + +interface Presence { + eventId: string; + userIdHash: string; // sha256(voucher_id) + lastSeen: number; // overwritten in place, never appended (B-27) +} +``` + +**HTTP.** 003 §11's table, with the join row replaced: + +| Method | Path | Guard | Notes | +| --- | --- | --- | --- | +| `GET` | `/events/:id/manifest.m3u8` | `requirePermission` | Gated, but not the boundary (B-12) | +| `GET` | `/events/:id/key/:seq` | `requirePermission` | **The boundary.** Enforces concurrency (B-14, B-19) | + +Segments themselves need no guard: they are ciphertext, and per the DRM note they can sit on any +host and be mirrored freely. + +No transfer endpoint. No attendee list endpoint. No per-request access log on the key path. + +## 10. Acceptance + +003 §12 steps 1–2 and 5 apply unchanged — issuance, admission, and the four negative cases. +Steps 3, 4 and 6 are replaced: + +3. **Play.** An admitted session fetches the manifest, fetches a key, and decrypts. Verified in + hls.js **and** in native Safari on iOS (B-13). A failure in the second is a design-level + failure, not a bug. +4. **Capacity.** With `capacity = 3` and three tickets streaming, a fourth ticket admits + successfully and is refused at the key endpoint, distinguishably. +5. **Release.** One of the three stops playing. Within two rotation periods the fourth ticket's + key request succeeds. +6. **Rotation.** A key captured from rotation *n* fails to decrypt a segment from rotation + *n + 1*. +7. **No leak.** The published 30311 is fetched from a public relay by an unauthenticated client + and yields no playable URL and no attendee pubkeys. + +Step 7 is the equivalent of 003's step 5: it is where the claim either holds or does not, because +everything else assumes the announcement is not itself the leak. + +## 11. Open questions + +**11.1 Does native HLS carry the cookie?** B-13. If iOS Safari will not send the session cookie +on the key request, the options are a token in the key URI — which puts a credential in the +manifest and is a different design — or no iOS support. This is the highest-risk unknown in the +document and should be tested before anything else is built. + +**11.2 What is the recording worth?** 003 §13.5 was a footnote; here it is the main event. The +same key infrastructure gates a recording, but a recording is unbounded in time — an extracted +key is worth a permanent copy rather than 60 seconds. Per-title rotation on the recording, or no +recording at all, are both defensible and the choice is a product decision. + +**11.3 Can the ticket gate the chat?** B-29 says not on 1311. A NIP-44 encrypted chat keyed to +ticket holders is possible in principle and is a second protocol design, not a configuration +choice. + +**11.4 Is `capacity` meaningful at all for broadcast?** 003 §13.3 asked whether capacity should +be derived from the mint. Here the question is sharper: a broadcast has no physical seat limit, +so capacity is a pricing decision rather than a constraint. If the answer is "sell as many as we +like", B-18's regression stops mattering and §5 simplifies to presence-tracking with no cap. + +**11.5 Does forwarding double the bill?** B-5 option (a) has `zap-stream-core` transcoding and +forwarding, and the packager transcoding again if the ladder is rebuilt. Worth measuring before +committing; a passthrough forward that reuses the existing ladder avoids it. + +## 12. Out of scope + +- Everything in 003 §14. +- **Private live chat.** §11.3. +- **Custom player work.** B-15 accepts the standard-HLS ceiling explicitly. The DRM note's + non-extractable `CryptoKey` path is available only to a bespoke MSE player and is not attempted + here. +- **Modifying `zap-stream-core`.** Neither integration depth requires it. diff --git a/vitest.config.ts b/vitest.config.ts index 2031be0..58a9d57 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,11 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { include: ['packages/*/test/**/*.test.ts', 'examples/*/test/**/*.test.ts'], + // The parity and docs tests drive the real TypeScript compiler per case, and + // under vitest 4 a cold compile can exceed the 5s default (observed ~9s on + // the first case of a file). The work is genuinely slow, not hung, so raise + // the ceiling rather than let unrelated machine speed decide the result. + testTimeout: 60_000, coverage: { reporter: ['text', 'html'], },