diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce8b4ff..901e026 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,8 @@ jobs: - run: pnpm install --frozen-lockfile + - run: pnpm audit --audit-level=moderate + # pnpm's frozen install tolerates stale extra importers, so assert the # invariant directly: the committed lockfile must never contain the # local-only instances/* importers (see Makefile clean-instances). diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f011c4..20c112d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,36 @@ releases. ## [Unreleased] +### Fixed + +- **Generated instances never logged NDJSON.** Both scaffolds took their logger + from `@o3co/auth.utils`, which treats pino as an *optional* peer and falls + back to `console` when the import fails — and the generator never emitted + pino, so every instance created by `create-provider` or + `create-policy-verifier` logged bare `[name] …` console lines that no + aggregator parses. The scaffold now ships its own `src/logger.mts` on pino + (a direct runtime dependency, exact-pinned like the rest), honouring + `logging.level` from the application config with `LOG_LEVEL` as the + environment override, and serialising `err` so an Error keeps its stack. + +- **Generated instances could hang on SIGTERM and always exited zero.** The + same package's `gracefulShutdown` called `server.close()` with no deadline, + so one stuck request meant the process never exited on its own and the + orchestrator's SIGKILL cut it down mid-flight; cleanup failures went to + `console.error`. The scaffold now ships `src/shutdown.mts` with the contract + auth.provider (#290), auth.proxy (#81) and auth.policy-verifier (#210) each + adopted: drain for `drainTimeoutMs` (default 10s), bound `cleanup` by + `cleanupTimeoutMs`, force-close past the deadline and exit non-zero, log + through the instance logger, and yield the loop once before exiting so the + last lines flush. Both files come with tests that run under the instance's + own `pnpm run test`. + +### Changed + +- **`@o3co/auth.utils` is no longer emitted into generated `package.json`.** + Its two helpers live in the scaffold (above). This was the package's last + consumer across the auth family. + ### Added - The OWNER login contracts (`OWNER_AUTHENTICATION_LOGIN@1`, @@ -39,6 +69,35 @@ releases. ### Changed +- **The auth baseline is the released upstream, not a 0.3.x / 0.5.x pin with a + compatibility shim.** Every workspace package now requires + `@o3co/auth.policy-verifier.{core,builtins,server}` `^0.8.1` and + `@o3co/auth-provider-{core,oauth}` `^0.12.0` — the versions published on + 2026-09-06 — and the generators emit the same as exact pins + (`DEFAULT_DEP_VERSIONS`: 0.8.1 / 0.12.0), with both generators bumped to + 0.2.0 per create-app.md § 3.3. The dual-path shim that let the collectors + read `payload` or `subject` and reach `readUntrustedRequestContext` by + reflection (`collectors/context.mts`) is removed: collectors read + `context.subject` and call `readUntrustedRequestContext` directly, and the + policy-verifier template and the integration test import + `builtinKeyResolversModule` rather than probing for it. The code had already + crossed the intervening upstream BREAKING changes (o3co/auth's + `provin-compatibility` job builds this workspace against those exact + revisions); what changes here is that the released-0.3.x branch of each + dual path is gone. `@o3co/ts.hocon` stays at its current pin — its 0.1 → 1.x + move is a separate migration. + +- Refresh vulnerable transitive lockfile entries: js-yaml 4.3.2, qs 6.16.0, + nanoid 3.3.18 and brace-expansion 5.0.9. CI audits the dependency graph. +- Prepare generated instances for current upstream auth while retaining released + dependency pins: align Zod 4.5.4, wire separated JWKS/key-resolver modules, + update required configuration and support verified subject bags plus explicitly + untrusted request context. +- Generated Verifiers now require an explicit Owner DID rule on the declared + surface instead of relying on empty-rule allow. Scopeless DID tokens skip only + the scope group; undeclared operations and non-Owner subjects remain denied. + See [upstream compatibility](docs/upstream-compatibility.md) for migration. + - `login-transcript-v1` (unreleased) gains an eleventh required field, `did`, alongside the existing `subject_did` — `validateOwnerLogin` now also checks `transcript.did === transcript.subject_did`. `did` is the diff --git a/README.md b/README.md index 82616d2..f6c66b3 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ dPLaaX protocol: libraries plus scaffold generators that produce per-deployment composition roots of [auth.provider](https://github.com/o3co/auth.provider) and [auth.policy-verifier](https://github.com/o3co/auth.policy-verifier). See [docs/requirements.md](docs/requirements.md) for what this repository provides. +See [upstream compatibility](docs/upstream-compatibility.md) before adopting +current auth-family candidate builds in an existing generated deployment. > **Lineage**: this repository's history starts at the public cut, not at the > start of the work. The code grew up in a private PoC auth stack for dPLaaS, diff --git a/docs/requirements.md b/docs/requirements.md index 6034b18..56ae21e 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -115,6 +115,6 @@ Upstream packages composed by this repository: - Provider: `@o3co/auth-provider-core`, `@o3co/auth-provider-oauth`, `@provin-line/auth-provider-did` - Policy-verifier: `@o3co/auth.policy-verifier.server`, `@o3co/auth.policy-verifier.builtins`, `@o3co/auth.policy-verifier.core` -- Shared: `@o3co/auth.utils`, `@o3co/ts.hocon` +- Shared: `@o3co/ts.hocon`; generated instances log through `pino` directly (their logger and shutdown ship inside the scaffold) Version constraints follow each service's `package.json`. diff --git a/docs/upstream-compatibility.md b/docs/upstream-compatibility.md new file mode 100644 index 0000000..49f0225 --- /dev/null +++ b/docs/upstream-compatibility.md @@ -0,0 +1,52 @@ +# Upstream auth compatibility + +Generators still pin released provider 0.5.3 and verifier 0.3.1. Updating this +repository's git ref alone does **not** deploy newer upstream security fixes. +Existing instances need their dependency baseline and configuration updated. + +The [o3co/auth compatibility suite](https://github.com/o3co/auth) tests candidate +source without publishing packages: it packs provider/core, provider/oauth and +verifier/core, builtins and server into local tarballs, overrides all matching +direct and transitive dependencies in a disposable Provin checkout, then runs +workspace build/typecheck/tests, generated-app build/typecheck/config tests, +service startup and a generated Provider's valid/tampered DID-signature grant. +This does not certify deployed registry ACLs or Web/mobile clients. + +## Adopting current upstream + +- Keep Zod at one minor version across the dependency graph. Workspace and + generator overrides pin 4.5.4: mixing 4.3.x and 4.5.x schema objects across + module boundaries fails TypeScript compilation. +- Supply an absolute Provider issuer. Set the Verifier's `OAUTH_JWT_ISSUER` + and `OAUTH_JWT_AUDIENCE`. DID clients must include that audience in their + **signed** message/transcript. Current Verifier rejects LEGACY tokens with no + `aud`, although the grant still accepts audience-absent LEGACY requests. +- Use `oauth.jwt.mode = "verify"`; remove old `validate` and + `allowInsecureDecode` keys from overlays. Supply strong signing keys/secrets. +- Generated Provider config supplies required `http.readinessTimeoutMs` and + `logging.level`. `DplaaxConfigSchema` preserves the audit declaration. +- This DID-only composition has no session/password store, token denylist or + audit sink. Config explicitly declares subject/access-token revocation + unsupported and audit sink absent. Lifecycle is checked at issuance; issued + tokens remain usable until bounded expiry. Earlier revocation or retained + audit events requires wiring those services. +- Current Provider separates JWKS publication from OAuth; current Verifier + separates key-resolver registration. Composition includes those modules when + available; released versions retain their internal wiring. + +## DID policy and trust boundaries + +The scaffold now requires an explicit Owner DID rule on its declared surface. +Scopeless tokens skip only the OAuth scope group. +`DefaultDenyRuleCollector` still rejects undeclared resource/action pairs. +Other configured groups, including subscriber identity when enabled, must pass. +A missing or non-Owner subject is denied. + +This avoids relying on 0.3.x's empty-rule allow behavior; current upstream denies +empty rules. PDP allow is the identity/surface gate; resource permissions remain +the downstream registry's ACL decision, as required by the Provin contract. + +Collectors use verified `subject` on current upstream and `payload` on 0.3.x. +When present, `subject` is authoritative even if empty. Subscriber fields remain +caller supplied: current upstream reads them only through +`readUntrustedRequestContext`, with no plain-record fallback. diff --git a/integration/package.json b/integration/package.json index 7fac991..071c7d5 100644 --- a/integration/package.json +++ b/integration/package.json @@ -11,9 +11,9 @@ }, "devDependencies": { "@noble/ed25519": "^3.1.0", - "@o3co/auth-provider-core": "^0.5.3", - "@o3co/auth.policy-verifier.builtins": "^0.3.1", - "@o3co/auth.policy-verifier.server": "^0.3.1", + "@o3co/auth-provider-core": "^0.12.0", + "@o3co/auth.policy-verifier.builtins": "^0.8.1", + "@o3co/auth.policy-verifier.server": "^0.8.1", "@provin-line/auth-provider-did": "workspace:*", "@provin-line/auth-provider-dplaax-module": "workspace:*", "@provin-line/auth-policy-verifier-dplaax-module": "workspace:*", @@ -22,6 +22,7 @@ "express": "^5.2.1", "jose": "^6.2.2", "typescript": "^5.9.3", - "vitest": "^4.1.4" + "vitest": "^4.1.4", + "@o3co/auth.policy-verifier.core": "^0.8.1" } } diff --git a/integration/tests/integration.test.mts b/integration/tests/integration.test.mts index 5fb83a1..1c9690f 100644 --- a/integration/tests/integration.test.mts +++ b/integration/tests/integration.test.mts @@ -23,8 +23,8 @@ * Test cases: * 1. DID auth → JWT issuance succeeds * 2. JWT introspection returns active=true - * 3. Policy verification with DID-issued token: 3a pins upstream 0.3.x - * default-allow on empty rules (declared surface); 3b scope-mismatch + * 3. Policy verification with DID-issued token: 3a no-scope token passes + * only through an explicit owner-identity rule; 3b scope-mismatch * deny; 3c undeclared (resource, action) → DefaultDenyRuleCollector * fail-closed deny * 4. Policy verification with manually crafted JWT with scope → 200 allow @@ -34,7 +34,11 @@ import crypto, { createSecretKey } from "node:crypto"; import type http from "node:http"; import * as ed from "@noble/ed25519"; import { builtinCollectorsModule } from "@o3co/auth.policy-verifier.builtins"; -import { createApp as createPolicyVerifierApp } from "@o3co/auth.policy-verifier.server"; +import { + AppConfigSchema, + builtinKeyResolversModule, + createApp as createPolicyVerifierApp, +} from "@o3co/auth.policy-verifier.server"; import { type AppConfig, createApp, @@ -57,8 +61,9 @@ import { makeMockResolution } from "./utils.mjs"; // ─── Configuration ─────────────────────────────────────────────────────────── -const JWT_SECRET = "integration-test-secret-32chars-long"; -const JWT_ISSUER = "test-issuer"; +const JWT_SECRET = "integration.test.secret.at.least.32.bytes.long"; +const JWT_ISSUER = "https://issuer.test.invalid"; +const JWT_AUDIENCE = "https://policy-verifier.test.local"; const JWT_KID = "test-key"; const DID_GRANT_TYPE = "https://dplaax.dev/oauth/grant-type/did"; const TEST_CLIENT_ID = "dplaax-test-public-client"; @@ -152,8 +157,11 @@ beforeAll(async () => { // the actual registry baseUrl points at 127.0.0.1:. const registryBaseUrl = `http://127.0.0.1:${mockRegistryPort}`; const config: DplaaxAppConfig = { - http: { port: 0, trustProxy: false }, + http: { port: 0, trustProxy: false, readinessTimeoutMs: 2000 }, + logging: { level: "silent" }, + audit: { sink: { type: "none" } }, oauth: { + revocation: { subject: "unsupported", accessToken: "unsupported" }, jwt: { issuer: JWT_ISSUER, legacyTypAccept: false, @@ -178,14 +186,8 @@ beforeAll(async () => { did: { supportedAlgorithms: ["ed25519_raw"], messageMaxAgeSec: 300, - // Task 8 (auth-provider-did): `allowedAudiences` is required - // and must be non-empty (fail closed — an empty/absent - // allowlist used to mean "accept any audience"). This - // integration flow never sends an `audience` in the DID - // grant request (see buildDidTokenRequest below), so the - // allowlist's actual contents are inert here; the value - // below is a placeholder satisfying the schema. - allowedAudiences: ["https://policy-verifier.test.local"], + // Bind DID issuance to the same audience enforced by the verifier. + allowedAudiences: [JWT_AUDIENCE], // `revocationLatencyBoundSec` is required, no default (fail // closed). `legacyMaxTtlSec` is raised to match // `accessToken.expiresIn` (3600s below) since `authContract` @@ -288,21 +290,31 @@ beforeAll(async () => { authProviderPort = authResult.port; // 7. Start policy-verifier (in-process) - const pvConfig = { - http: { hostname: "127.0.0.1", port: 0, pathPrefix: "" }, + const pvConfig = AppConfigSchema.parse({ + http: { hostname: "127.0.0.1", port: 3001, pathPrefix: "" }, oauth: { jwt: { algorithm: "HS256" as const, secret: JWT_SECRET, - validate: true, + mode: "verify", + issuer: JWT_ISSUER, + audience: JWT_AUDIENCE, }, }, attribute: { - collectors: [{ collector: "PayloadScopeCollector" }], + collectors: [ + { collector: "PayloadScopeCollector" }, + { collector: "SubjectDidCollector" }, + { collector: "SubjectDidTypeCollector" }, + ], }, rule: { collectors: [ - { collector: "ResourceActionScopeRuleCollector" }, + { collector: "ResourceActionScopeRuleCollector", scopeless: "skip" }, + { collector: "SubjectDidTypeRuleCollector", rules: [ + { resource: "registry.project", action: "read", allowedTypes: ["owner"] }, + { resource: "registry", action: "read", allowedTypes: ["owner"] }, + ] }, { // Fail-closed default (mirrors the scaffold config): only the // pairs the tests below exercise are declared; everything else @@ -316,12 +328,16 @@ beforeAll(async () => { ], }, resource: { parser: "DotNotationResourceParser" }, - }; + }); const pvApp = await createPolicyVerifierApp({ pathResolver: import.meta.resolve, config: pvConfig, - modules: [builtinCollectorsModule, dplaaxModule], + modules: [ + builtinCollectorsModule, + builtinKeyResolversModule, + dplaaxModule, + ], }); const pvResult = await listenOnFreePort(pvApp); @@ -355,6 +371,7 @@ async function buildDidTokenRequest(): Promise<{ }> { const message = JSON.stringify({ did: testDid, + audience: JWT_AUDIENCE, timestamp: new Date().toISOString(), nonce: crypto.randomBytes(16).toString("hex"), }); @@ -415,21 +432,10 @@ describe("DID auth → JWT → policy verification", () => { expect(body.sub).toBe(testDid); }); - it("Test 3a: DID-issued no-scope token → policy-verifier ALLOWS (pins policy-verifier 0.3.x default-allow on empty rules)", async () => { - // With `auth.policy-verifier` >= 0.3, `ResourceActionScopeRuleCollector` - // produces ZERO rules when the token has no `scope` claim, and - // `evaluate()` returns `{ decision: "allow" }` for the empty-rules - // case. The pre-0.3 build denied this case. Pin the new behaviour - // explicitly so a future flip back to deny-by-default is caught. - // - // SECURITY NOTE: callers MUST NOT rely on the DID grant to gate - // scope-protected actions. Either inject a non-empty `scope` at - // DID-token issue time, or keep the requested (resource, action) - // OUT of DefaultDenyRuleCollector's declared surface so it fails - // closed (Test 3c). This request stays allowed only because - // registry.project/read is declared in the surface configured in - // beforeAll — the pin covers the upstream 0.3.x empty-rules default, - // not a recommended deployment posture. + it("Test 3a: DID-issued no-scope token → policy-verifier passes an explicit owner-identity rule", async () => { + // A scopeless DID token passes an explicit owner-identity rule. + // Registry ACLs remain authoritative for resource permissions. + const res = await fetch(policyVerifierUrl("/verify"), { method: "POST", headers: { @@ -454,10 +460,11 @@ describe("DID auth → JWT → policy verification", () => { // so `evaluate()` returns deny. const secretKey = createSecretKey(Buffer.from(JWT_SECRET)); const mismatchedToken = await new SignJWT({ scope: "write:other-resource" }) - .setProtectedHeader({ alg: "HS256", kid: JWT_KID }) + .setProtectedHeader({ alg: "HS256", kid: JWT_KID, typ: "at+jwt" }) .setIssuedAt() .setExpirationTime("1h") .setIssuer(JWT_ISSUER) + .setAudience(JWT_AUDIENCE) .setSubject(testDid) .sign(secretKey); @@ -479,12 +486,8 @@ describe("DID auth → JWT → policy verification", () => { }); it("Test 3c: undeclared (resource, action) → 403 deny (DefaultDenyRuleCollector fail-closed)", async () => { - // The scope collector abstains for a no-scope token and evaluate() - // allows on zero rules (pinned by Test 3a), so without a fail-closed - // default a request surface nobody configured would pass silently. - // DefaultDenyRuleCollector is wired into this verifier with a surface - // declaring only the pairs the other tests exercise — anything else - // must come back 403 regardless of token contents. + // Undeclared operations fail closed regardless of the authenticated DID. + const res = await fetch(policyVerifierUrl("/verify"), { method: "POST", headers: { @@ -507,10 +510,11 @@ describe("DID auth → JWT → policy verification", () => { // Craft a JWT with scope="read:registry" to prove the verification pipeline works const secretKey = createSecretKey(Buffer.from(JWT_SECRET)); const scopedToken = await new SignJWT({ scope: "read:registry" }) - .setProtectedHeader({ alg: "HS256", kid: JWT_KID }) + .setProtectedHeader({ alg: "HS256", kid: JWT_KID, typ: "at+jwt" }) .setIssuedAt() .setExpirationTime("1h") .setIssuer(JWT_ISSUER) + .setAudience(JWT_AUDIENCE) .setSubject(testDid) .sign(secretKey); @@ -533,4 +537,39 @@ describe("DID auth → JWT → policy verification", () => { const body = (await res.json()) as { decision: string }; expect(body.decision).toBe("allow"); }); + + it.each([ + ["a non-DID subject", "user-123"], + ["a pipeline DID", `${testDid}:pipeline:child`], + ])("rejects %s even on the declared surface", async (_label, subject) => { + const token = await new SignJWT({}) + .setProtectedHeader({ alg: "HS256", kid: JWT_KID, typ: "at+jwt" }) + .setIssuedAt().setExpirationTime("1h") + .setIssuer(JWT_ISSUER).setAudience(JWT_AUDIENCE).setSubject(subject) + .sign(createSecretKey(Buffer.from(JWT_SECRET))); + const res = await fetch(policyVerifierUrl("/verify"), { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ resource: "registry.project", action: "read" }), + }); + expect(res.status).toBe(403); + expect(await res.json()).toMatchObject({ decision: "deny" }); + }); + + it.each([ + ["issuer", "https://wrong-issuer.invalid", JWT_AUDIENCE], + ["audience", JWT_ISSUER, "https://wrong-api.invalid"], + ])("current verifier rejects a token for the wrong %s", async (_label, issuer, audience) => { + const token = await new SignJWT({}) + .setProtectedHeader({ alg: "HS256", kid: JWT_KID, typ: "at+jwt" }) + .setIssuedAt().setExpirationTime("1h") + .setIssuer(issuer).setAudience(audience).setSubject(testDid) + .sign(createSecretKey(Buffer.from(JWT_SECRET))); + const res = await fetch(policyVerifierUrl("/verify"), { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ resource: "registry.project", action: "read" }), + }); + expect(res.status).toBe(401); + }); }); diff --git a/package.json b/package.json index 966d7fc..a72f7e2 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "overrides": { "@provin-line/auth-provider-dplaax-module": "workspace:*", "@provin-line/auth-policy-verifier-dplaax-module": "workspace:*", - "vite": "^8.0.16" + "vite": "^8.0.16", + "zod": "4.5.4" } } } diff --git a/packages/create-policy-verifier/package.json b/packages/create-policy-verifier/package.json index 53fff49..f44b0e5 100644 --- a/packages/create-policy-verifier/package.json +++ b/packages/create-policy-verifier/package.json @@ -1,7 +1,7 @@ { "name": "@provin-line/create-auth-policy-verifier", "description": "Scaffold generator for dPLaaX policy-verifier deployment instances", - "version": "0.1.0", + "version": "0.2.0", "license": "Apache-2.0", "type": "module", "bin": { diff --git a/packages/create-policy-verifier/src/__tests__/generator.test.mts b/packages/create-policy-verifier/src/__tests__/generator.test.mts index 349aad0..f5719ea 100644 --- a/packages/create-policy-verifier/src/__tests__/generator.test.mts +++ b/packages/create-policy-verifier/src/__tests__/generator.test.mts @@ -104,6 +104,33 @@ describe("generatePolicyVerifierScaffold — template substitution", () => { expect(conf).toMatch(/^\s*port = 4242$/m); }); + it("does not emit @o3co/auth.utils — its helpers ship inside the scaffold", async () => { + // auth.policy-verifier's own standalone template moved logger and + // shutdown in-tree (#210); a generated instance follows the same shape. + const outDir = join(tmpRoot, "out"); + await generatePolicyVerifierScaffold({ name: "test-scaffold", outDir, gitInit: false }); + const pkg = JSON.parse(await readFile(join(outDir, "package.json"), "utf8")) as { + dependencies: Record; + devDependencies: Record; + }; + expect(pkg.dependencies).not.toHaveProperty("@o3co/auth.utils"); + expect(pkg.devDependencies).not.toHaveProperty("@o3co/auth.utils"); + }); + + it("emits pino as a runtime dependency, so the instance logs NDJSON rather than console", async () => { + // @o3co/auth.utils took pino as an *optional* peer and fell back to + // console; the generator never emitted pino, so every generated instance + // logged bare `[name] …` lines. The in-tree logger depends on pino directly. + const outDir = join(tmpRoot, "out"); + await generatePolicyVerifierScaffold({ name: "test-scaffold", outDir, gitInit: false }); + const pkg = JSON.parse(await readFile(join(outDir, "package.json"), "utf8")) as { + dependencies: Record; + }; + // A literal, not DEFAULT_DEP_VERSIONS.pino: sharing the oracle with the + // implementation would let a missing key pass as undefined === undefined. + expect(pkg.dependencies.pino).toBe("10.3.1"); + }); + it("emits exact-pin dep versions (no caret)", async () => { const outDir = join(tmpRoot, "out"); await generatePolicyVerifierScaffold({ @@ -150,6 +177,7 @@ describe("generatePolicyVerifierScaffold — template substitution", () => { await readFile(join(outDir, "package.json"), "utf8"), ) as { pnpm: { overrides: Record } }; expect(pkg.pnpm.overrides).toEqual({ + zod: "4.5.4", "@provin-line/did-dplaax": "github:provin-line/auth#v1.2.3&path:/packages/did-dplaax", }); diff --git a/packages/create-policy-verifier/src/defaults.mts b/packages/create-policy-verifier/src/defaults.mts index 827be78..3f2b542 100644 --- a/packages/create-policy-verifier/src/defaults.mts +++ b/packages/create-policy-verifier/src/defaults.mts @@ -14,17 +14,22 @@ // // These constants are the dependency baseline of the canonical template // (originally the pre-M4 reference instance's package.json — 2026-05-28 snapshot, provin-line/auth -// commit 220f52f). When the baseline upgrades, refresh this file in lockstep -// with a generator MINOR bump (see create-app.md § 3.3). +// commit 220f52f; auth baseline refreshed 2026-09-06 to the released +// auth.policy-verifier 0.8.1 with generator 0.2.0). When the baseline upgrades, +// refresh this file in lockstep with a generator MINOR bump (see create-app.md § 3.3). /** Exact-pin runtime + dev dep versions emitted into generated package.json. */ export const DEFAULT_DEP_VERSIONS = { // Framework (o3co) — runtime - "@o3co/auth.policy-verifier.server": "0.3.1", - "@o3co/auth.policy-verifier.builtins": "0.3.1", - "@o3co/auth.policy-verifier.core": "0.3.1", - "@o3co/auth.utils": "0.0.4", + "@o3co/auth.policy-verifier.server": "0.8.1", + "@o3co/auth.policy-verifier.builtins": "0.8.1", + "@o3co/auth.policy-verifier.core": "0.8.1", "@o3co/ts.hocon": "0.1.5", + // Runtime — non-o3co + // The scaffold's own logger writes NDJSON through pino (see src/logger.mts); + // it used to reach pino only as @o3co/auth.utils' optional peer, which was + // never emitted here, so instances silently logged through console. + pino: "10.3.1", // Dev "@types/node": "25.6.0", typescript: "5.9.3", diff --git a/packages/create-policy-verifier/src/generator.mts b/packages/create-policy-verifier/src/generator.mts index 98794cd..cfb86ce 100644 --- a/packages/create-policy-verifier/src/generator.mts +++ b/packages/create-policy-verifier/src/generator.mts @@ -214,8 +214,8 @@ function buildPackageJson(opts: FilledOptions): string { DEFAULT_DEP_VERSIONS["@o3co/auth.policy-verifier.core"], "@o3co/auth.policy-verifier.server": DEFAULT_DEP_VERSIONS["@o3co/auth.policy-verifier.server"], - "@o3co/auth.utils": DEFAULT_DEP_VERSIONS["@o3co/auth.utils"], "@o3co/ts.hocon": DEFAULT_DEP_VERSIONS["@o3co/ts.hocon"], + pino: DEFAULT_DEP_VERSIONS.pino, }; const devDependencies: Record = { "@types/node": DEFAULT_DEP_VERSIONS["@types/node"], @@ -241,7 +241,8 @@ function buildPackageJson(opts: FilledOptions): string { ...transitiveProvinDeps.map(([name]) => name), "@provin-line/auth-policy-verifier-dplaax-module", ].sort(), - overrides: Object.fromEntries(transitiveProvinDeps), + // Zod schema objects cross package boundaries; align their minor version. + overrides: { ...Object.fromEntries(transitiveProvinDeps), zod: "4.5.4" }, }; const manifest = { diff --git a/packages/create-policy-verifier/src/template/config/application.conf.tmpl b/packages/create-policy-verifier/src/template/config/application.conf.tmpl index ec70429..9e1023f 100644 --- a/packages/create-policy-verifier/src/template/config/application.conf.tmpl +++ b/packages/create-policy-verifier/src/template/config/application.conf.tmpl @@ -12,11 +12,13 @@ oauth { algorithm = "HS256" algorithm = ${?OAUTH_JWT_ALGORITHM} secret = ${?OAUTH_JWT_SECRET} + issuer = ${?OAUTH_JWT_ISSUER} + audience = ${?OAUTH_JWT_AUDIENCE} jwksUri = ${?OAUTH_JWT_JWKS_URI} publicKey = ${?OAUTH_JWT_PUBLIC_KEY} publicKeyPath = ${?OAUTH_JWT_PUBLIC_KEY_PATH} - validate = true - validate = ${?OAUTH_JWT_VALIDATE} + # Verified JWTs only. Current upstream uses mode; 0.3.x defaults to validation. + mode = "verify" } } @@ -32,12 +34,28 @@ attribute { rule { collectors = [ - { collector = "ResourceActionScopeRuleCollector" } + { collector = "ResourceActionScopeRuleCollector", scopeless = "skip" } + { + # DID tokens assert identity. Admit Owner DIDs to the declared surface; + # the downstream registry still enforces its resource-specific ACLs. + # This explicit rule also works with upstream deny-on-empty evaluation. + collector = "SubjectDidTypeRuleCollector" + rules = [ + { resource = "schemas", action = "*", allowedTypes = ["owner"] } + { resource = "dids", action = "*", allowedTypes = ["owner"] } + { resource = "signer", action = "*", allowedTypes = ["owner"] } + { resource = "vc", action = "*", allowedTypes = ["owner"] } + { resource = "chain", action = "*", allowedTypes = ["owner"] } + { resource = "audit", action = "*", allowedTypes = ["owner"] } + { resource = "tlog", action = "*", allowedTypes = ["owner"] } + { resource = "ingest", action = "*", allowedTypes = ["owner"] } + { resource = "payloads", action = "*", allowedTypes = ["owner"] } + ] + } { # SECURITY NOTE — fail-closed default (keep this collector enabled). - # The policy evaluator ALLOWS a request when zero rules are collected, - # so without this collector every (resource, action) nobody wrote a - # policy for passes the PDP silently. DefaultDenyRuleCollector inverts + # Released 0.3.x allows empty rules; current upstream denies them. + # Keep an explicit surface guard with both versions. DefaultDenyRuleCollector inverts # that default: any request whose (resource, action) is not declared in # `surface` below is denied unconditionally. # diff --git a/packages/create-policy-verifier/src/template/src/__tests__/logger.test.mts b/packages/create-policy-verifier/src/template/src/__tests__/logger.test.mts new file mode 100644 index 0000000..53c5109 --- /dev/null +++ b/packages/create-policy-verifier/src/template/src/__tests__/logger.test.mts @@ -0,0 +1,48 @@ +import { PassThrough } from "node:stream"; +import { afterEach, describe, expect, it } from "vitest"; +import { createAppLogger } from "../logger.mjs"; + +const original = process.env.LOG_LEVEL; +afterEach(() => { + if (original === undefined) delete process.env.LOG_LEVEL; + else process.env.LOG_LEVEL = original; +}); + +async function firstLine(write: (logger: ReturnType) => void, level?: string) { + const stream = new PassThrough(); + const chunks: string[] = []; + stream.on("data", (c: Buffer) => chunks.push(c.toString())); + write(createAppLogger("dplaax-policy-verifier", level, stream)); + await new Promise((resolve) => setImmediate(resolve)); + return JSON.parse(chunks.join("").trim()); +} + +describe("createAppLogger", () => { + it("defaults to info", () => { + delete process.env.LOG_LEVEL; + expect(createAppLogger("dplaax-policy-verifier").level).toBe("info"); + }); + + it("honours LOG_LEVEL when no level is passed", () => { + process.env.LOG_LEVEL = "debug"; + expect(createAppLogger("dplaax-policy-verifier").level).toBe("debug"); + }); + + it("prefers an explicit level (logging.level from config) over LOG_LEVEL", () => { + process.env.LOG_LEVEL = "debug"; + expect(createAppLogger("dplaax-policy-verifier", "error").level).toBe("error"); + }); + + it("emits NDJSON — one parseable object per line, named for the aggregator", async () => { + const entry = await firstLine((l) => l.info("ready"), "info"); + expect(entry.msg).toBe("ready"); + expect(entry.name).toBe("dplaax-policy-verifier"); + expect(entry.level).toBe(30); + }); + + it("serialises an Error under `err` with its stack instead of `{}`", async () => { + const entry = await firstLine((l) => l.error({ err: new Error("boom") }, "failed"), "info"); + expect(entry.err.message).toBe("boom"); + expect(typeof entry.err.stack).toBe("string"); + }); +}); diff --git a/packages/create-policy-verifier/src/template/src/__tests__/shutdown.test.mts b/packages/create-policy-verifier/src/template/src/__tests__/shutdown.test.mts new file mode 100644 index 0000000..4f3355d --- /dev/null +++ b/packages/create-policy-verifier/src/template/src/__tests__/shutdown.test.mts @@ -0,0 +1,188 @@ +import type { Server } from "node:http"; +import { describe, expect, it, vi } from "vitest"; +import { deferExit, installGracefulShutdown, type Logger } from "../shutdown.mjs"; + +/** A `Server` double whose `close` callback fires only when we say so. */ +function makeServer() { + let closeCallback: ((err?: Error) => void) | undefined; + const server = { + close: vi.fn((cb?: (err?: Error) => void) => { + closeCallback = cb; + return server; + }), + closeIdleConnections: vi.fn(), + closeAllConnections: vi.fn(), + }; + return { + server: server as unknown as Server, + spies: server, + finishDraining: () => closeCallback?.(), + failClose: (err: Error) => closeCallback?.(err), + }; +} + +const makeLogger = () => ({ info: vi.fn(), error: vi.fn() }) satisfies Logger; + +function install(opts: { cleanup?: () => void | Promise; drainTimeoutMs?: number } = {}) { + const { server, spies, finishDraining, failClose } = makeServer(); + const logger = makeLogger(); + const exit = vi.fn(); + const signals = new Map void>(); + installGracefulShutdown(server, { + logger, + cleanup: opts.cleanup ?? (() => {}), + ...(opts.drainTimeoutMs === undefined ? {} : { drainTimeoutMs: opts.drainTimeoutMs }), + exit, + onSignal: (name, handler) => signals.set(name, handler), + offSignal: (name) => signals.delete(name), + }); + return { spies, logger, exit, signals, finishDraining, failClose }; +} + +const settle = () => new Promise((resolve) => setImmediate(resolve)); + +describe("installGracefulShutdown", () => { + it("listens for both SIGTERM and SIGINT", () => { + expect([...install().signals.keys()].sort()).toEqual(["SIGINT", "SIGTERM"]); + }); + + it("stops accepting connections and releases idle keep-alive sockets", () => { + const { signals, spies } = install(); + signals.get("SIGTERM")?.(); + expect(spies.close).toHaveBeenCalledOnce(); + expect(spies.closeIdleConnections).toHaveBeenCalledOnce(); + }); + + it("runs cleanup once draining completes, then exits zero", async () => { + const cleanup = vi.fn(); + const { signals, finishDraining, exit } = install({ cleanup }); + signals.get("SIGTERM")?.(); + expect(cleanup).not.toHaveBeenCalled(); + finishDraining(); + await settle(); + expect(cleanup).toHaveBeenCalledOnce(); + expect(exit).toHaveBeenCalledWith(0); + }); + + it("ignores a second signal instead of running cleanup twice", async () => { + const cleanup = vi.fn(); + const { signals, spies, finishDraining, exit } = install({ cleanup }); + const handler = signals.get("SIGTERM"); + handler?.(); + handler?.(); + finishDraining(); + await settle(); + expect(spies.close).toHaveBeenCalledOnce(); + expect(cleanup).toHaveBeenCalledOnce(); + expect(exit).toHaveBeenCalledOnce(); + }); + + it("forces the remaining connections closed when draining outruns the deadline", () => { + vi.useFakeTimers(); + try { + const { signals, spies } = install({ drainTimeoutMs: 5_000 }); + signals.get("SIGTERM")?.(); + expect(spies.closeAllConnections).not.toHaveBeenCalled(); + vi.advanceTimersByTime(5_000); + expect(spies.closeAllConnections).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + + it("exits non-zero on a forced close, so the drain outcome is visible", async () => { + vi.useFakeTimers(); + let exitSpy: ReturnType; + try { + const { signals, exit } = install({ drainTimeoutMs: 5_000 }); + exitSpy = exit; + signals.get("SIGTERM")?.(); + vi.advanceTimersByTime(5_000); + } finally { + vi.useRealTimers(); + } + await settle(); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("does not force-close a drain that finished in time", () => { + vi.useFakeTimers(); + try { + const { signals, spies, finishDraining } = install({ drainTimeoutMs: 5_000 }); + signals.get("SIGTERM")?.(); + finishDraining(); + vi.advanceTimersByTime(10_000); + expect(spies.closeAllConnections).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("bounds cleanup so a hanging dispose cannot wedge the process", async () => { + vi.useFakeTimers(); + try { + const { signals, finishDraining, exit, logger } = install({ + cleanup: () => new Promise(() => {}), + drainTimeoutMs: 5_000, + }); + signals.get("SIGTERM")?.(); + finishDraining(); + await vi.advanceTimersByTimeAsync(5_000); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ cleanupTimeoutMs: 5_000 }), + expect.stringContaining("cleanup timed out"), + ); + expect(exit).toHaveBeenCalledWith(1); + } finally { + vi.useRealTimers(); + } + }); + + it("reports a cleanup failure through the logger, not console, and exits non-zero", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const err = new Error("dispose failed"); + const { signals, finishDraining, logger, exit } = install({ cleanup: () => Promise.reject(err) }); + signals.get("SIGTERM")?.(); + finishDraining(); + await settle(); + expect(logger.error).toHaveBeenCalledWith({ err }, expect.stringContaining("cleanup failed")); + expect(consoleError).not.toHaveBeenCalled(); + expect(exit).toHaveBeenCalledWith(1); + consoleError.mockRestore(); + }); + + it("reports the cleanup outcome as the reason and keeps the drain outcome under its own key", async () => { + const { signals, finishDraining, logger } = install({ cleanup: () => Promise.reject(new Error("x")) }); + signals.get("SIGTERM")?.(); + finishDraining(); + await settle(); + expect(logger.info).toHaveBeenCalledWith( + { reason: "cleanup-failed", drain: "drained", exitCode: 1 }, + "graceful shutdown: complete", + ); + }); + + it("does not report a failed close as a clean drain", async () => { + const err = new Error("Server is not running"); + const { signals, failClose, logger, exit } = install(); + signals.get("SIGTERM")?.(); + failClose(err); + await settle(); + expect(logger.error).toHaveBeenCalledWith({ err }, expect.stringContaining("close failed")); + expect(exit).toHaveBeenCalledWith(1); + }); + + it("removes its own signal listeners once shutting down", () => { + const { signals } = install(); + signals.get("SIGTERM")?.(); + expect(signals.size).toBe(0); + }); + + it("defers the real exit a turn so pino's buffered destination can flush", async () => { + const exitProcess = vi.fn(); + deferExit(3, exitProcess); + expect(exitProcess).not.toHaveBeenCalled(); + await settle(); + expect(exitProcess).toHaveBeenCalledWith(3); + }); +}); diff --git a/packages/create-policy-verifier/src/template/src/logger.mts b/packages/create-policy-verifier/src/template/src/logger.mts new file mode 100644 index 0000000..fd3aac0 --- /dev/null +++ b/packages/create-policy-verifier/src/template/src/logger.mts @@ -0,0 +1,27 @@ +import { type DestinationStream, pino, stdSerializers } from "pino"; + +/** + * The instance's logger: newline-delimited JSON on stdout, the shape every + * log aggregator ingests without a parser. + * + * It used to come from `@o3co/auth.utils`, which took pino as an *optional* + * peer and fell back to `console` when the import failed. The generator never + * emitted pino, so every generated instance silently logged bare + * `[dplaax-policy-verifier] …` lines. pino is a direct dependency now, and the level + * honours `logging.level` from the application config with `LOG_LEVEL` as the + * environment override. + */ +export function createAppLogger( + name: string, + level: string = process.env.LOG_LEVEL ?? "info", + destination?: DestinationStream, +) { + const options = { + name, + level, + // `err` is pino's conventional key for an Error; without the serialiser + // an Error stringifies to `{}` and the stack is lost where it is needed. + serializers: { err: stdSerializers.err }, + }; + return destination ? pino(options, destination) : pino(options); +} diff --git a/packages/create-policy-verifier/src/template/src/main.mts b/packages/create-policy-verifier/src/template/src/main.mts index c35aa4a..cc532b0 100644 --- a/packages/create-policy-verifier/src/template/src/main.mts +++ b/packages/create-policy-verifier/src/template/src/main.mts @@ -2,16 +2,16 @@ import { fileURLToPath } from "node:url"; import { builtinCollectorsModule } from "@o3co/auth.policy-verifier.builtins"; import { AppConfigSchema, + builtinKeyResolversModule, createApp, } from "@o3co/auth.policy-verifier.server"; -import { createLogger, gracefulShutdown } from "@o3co/auth.utils"; import { parseFile } from "@o3co/ts.hocon"; import { validate } from "@o3co/ts.hocon/zod"; import { resolveConfigPaths } from "./configPath.mjs"; +import { createAppLogger } from "./logger.mjs"; +import { installGracefulShutdown } from "./shutdown.mjs"; import { dplaaxModule } from "@provin-line/auth-policy-verifier-dplaax-module"; -const logger = createLogger("dplaax-policy-verifier"); - const env = process.env.CONFIG_ENV || process.env.NODE_ENV || "development"; const configDir = new URL("../config/", import.meta.url); const configDirPath = fileURLToPath(configDir); @@ -22,14 +22,20 @@ const config = validate( AppConfigSchema, ); +const logger = createAppLogger("dplaax-policy-verifier", config.logging.level); + const app = await createApp({ pathResolver: import.meta.resolve, config, - modules: [builtinCollectorsModule, dplaaxModule], + modules: [ + builtinCollectorsModule, + builtinKeyResolversModule, + dplaaxModule, + ], }); const server = app.listen(config.http.port, config.http.hostname, () => { logger.info(`dPLaaX policy-verifier listening on http://${config.http.hostname}:${config.http.port}`); }); -gracefulShutdown(server); +installGracefulShutdown(server, { logger }); diff --git a/packages/create-policy-verifier/src/template/src/shutdown.mts b/packages/create-policy-verifier/src/template/src/shutdown.mts new file mode 100644 index 0000000..b7063d6 --- /dev/null +++ b/packages/create-policy-verifier/src/template/src/shutdown.mts @@ -0,0 +1,166 @@ +import type { Server } from "node:http"; + +/** The logging surface shutdown needs; pino satisfies it structurally. */ +export interface Logger { + info(obj: Record, msg?: string): void; + error(obj: Record, msg?: string): void; +} + +/** + * Graceful shutdown for the generated instance. + * + * ## Why this is in the scaffold rather than a dependency + * + * It used to be `gracefulShutdown` from `@o3co/auth.utils@0.0.4`. For the + * component that terminates every user session, "does SIGTERM wait for + * in-flight requests, and for how long?" has to be answerable from the code + * an operator deploys — and reading those 22 lines answered it badly: + * **there was no deadline**. `server.close()` waits indefinitely, so one stuck + * request meant the process never exited on its own and the orchestrator's + * SIGKILL took it down mid-flight, precisely under the load that produces a + * stuck request. Cleanup failures went to `console.error`, and every exit was + * zero, so a truncated shutdown looked exactly like a clean one. + * + * auth.provider (#290), auth.proxy (#81) and auth.policy-verifier (#210) each + * moved the behaviour into the code they ship. This is the same contract. + * + * ## The guarantees, stated + * + * 1. **SIGTERM and SIGINT** both start it; a second signal is ignored. + * 2. **New connections stop immediately** (`close`) and idle keep-alive + * sockets are released (`closeIdleConnections`). + * 3. **In-flight requests get `drainTimeoutMs`** (default 10s) to finish. + * 4. **Past the deadline, remaining connections are cut** and the process + * exits **non-zero**, so a truncated drain is distinguishable from a clean one. + * 5. **`cleanup` runs after draining, before exit**, bounded by + * `cleanupTimeoutMs`; its failure is logged and reflected in the exit code. + * 6. **A `close` that fails is not reported as a clean drain.** + * + * Size `drainTimeoutMs` and `cleanupTimeoutMs` together **below** the + * orchestrator's kill grace period (Kubernetes + * `terminationGracePeriodSeconds`, compose `stop_grace_period`, both 30s by + * default): the worst case is the two budgets in sequence. + */ +export interface GracefulShutdownOptions { + readonly logger: Logger; + /** Reverse-topological component cleanup — normally `handle.dispose()`. */ + readonly cleanup?: () => void | Promise; + /** How long in-flight requests get before connections are cut. Default 10s. */ + readonly drainTimeoutMs?: number; + /** How long `cleanup` gets. Defaults to `drainTimeoutMs`. */ + readonly cleanupTimeoutMs?: number; + /** Injected in tests; defaults to {@link deferExit}. */ + readonly exit?: (code: number) => void; + /** Injected in tests; defaults to `process.on`. */ + readonly onSignal?: (signal: NodeJS.Signals, handler: () => void) => void; + /** Injected in tests; defaults to `process.removeListener`. */ + readonly offSignal?: (signal: NodeJS.Signals, handler: () => void) => void; +} + +/** + * Exit after yielding the loop once. pino's default destination is not + * synchronous, so exiting in the same tick as the last `logger.error` can drop + * exactly the line that says why. One turn is a flush window, not a + * guarantee; a deployment needing certainty passes an `exit` that flushes. + */ +export function deferExit(code: number, exitProcess: (code: number) => void = process.exit): void { + setImmediate(() => exitProcess(code)); +} + +const DEFAULT_DRAIN_TIMEOUT_MS = 10_000; +const SIGNALS: readonly NodeJS.Signals[] = ["SIGTERM", "SIGINT"]; + +export function installGracefulShutdown(server: Server, options: GracefulShutdownOptions): void { + const { + logger, + cleanup, + drainTimeoutMs = DEFAULT_DRAIN_TIMEOUT_MS, + exit = deferExit, + onSignal = (signal, handler): void => { + process.on(signal, handler); + }, + offSignal = (signal, handler): void => { + process.removeListener(signal, handler); + }, + } = options; + const cleanupTimeoutMs = options.cleanupTimeoutMs ?? drainTimeoutMs; + + let shuttingDown = false; + let finished = false; + + /** Sentinel so a timed-out cleanup is reported as that, not as a throw. */ + const CLEANUP_TIMED_OUT = Symbol("cleanup-timed-out"); + + /** Await `cleanup`, but not forever; a sync throw lands in the same path. */ + const runCleanup = async (): Promise => { + if (!cleanup) return; + let timer: ReturnType | undefined; + try { + return await Promise.race([ + (async (): Promise => { + await cleanup(); + return undefined; + })(), + new Promise((resolve) => { + timer = setTimeout(() => resolve(CLEANUP_TIMED_OUT), cleanupTimeoutMs); + timer.unref?.(); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } + }; + + /** Run `cleanup` and exit. Called by whichever of drain / deadline wins. */ + const finish = async (code: number, reason: string): Promise => { + if (finished) return; + finished = true; + let exitCode = code; + // `reason` names whatever decided the exit code; the drain outcome keeps + // its own key so the line an operator alerts on is consistent and complete. + let outcome = reason; + try { + if ((await runCleanup()) === CLEANUP_TIMED_OUT) { + logger.error({ cleanupTimeoutMs }, "graceful shutdown: cleanup timed out"); + exitCode = 1; + outcome = "cleanup-timeout"; + } + } catch (err) { + logger.error({ err }, "graceful shutdown: cleanup failed"); + exitCode = 1; + outcome = "cleanup-failed"; + } + logger.info({ reason: outcome, drain: reason, exitCode }, "graceful shutdown: complete"); + exit(exitCode); + }; + + const handler = (): void => { + if (shuttingDown) return; + shuttingDown = true; + for (const signal of SIGNALS) offSignal(signal, handler); + logger.info({ drainTimeoutMs }, "graceful shutdown: draining"); + + const deadline = setTimeout(() => { + logger.error( + { drainTimeoutMs }, + "graceful shutdown: drain deadline exceeded, closing remaining connections", + ); + server.closeAllConnections(); + void finish(1, "drain-timeout"); + }, drainTimeoutMs); + deadline.unref?.(); + + server.close((err) => { + clearTimeout(deadline); + if (err) { + logger.error({ err }, "graceful shutdown: server close failed"); + void finish(1, "close-failed"); + return; + } + void finish(0, "drained"); + }); + server.closeIdleConnections(); + }; + + for (const signal of SIGNALS) onSignal(signal, handler); +} diff --git a/packages/create-provider/package.json b/packages/create-provider/package.json index 1ce5a4a..d5808a0 100644 --- a/packages/create-provider/package.json +++ b/packages/create-provider/package.json @@ -1,7 +1,7 @@ { "name": "@provin-line/create-auth-provider", "description": "Scaffold generator for dPLaaX auth.provider deployment instances", - "version": "0.1.0", + "version": "0.2.0", "license": "Apache-2.0", "type": "module", "bin": { @@ -34,7 +34,7 @@ }, "dependencies": {}, "devDependencies": { - "@o3co/auth-provider-core": "^0.5.3", + "@o3co/auth-provider-core": "^0.12.0", "@provin-line/auth-provider-did": "workspace:*", "@types/node": "^25.6.0", "@vitest/coverage-v8": "^4.1.4", diff --git a/packages/create-provider/src/__tests__/generator.test.mts b/packages/create-provider/src/__tests__/generator.test.mts index 9c9629d..b2157dd 100644 --- a/packages/create-provider/src/__tests__/generator.test.mts +++ b/packages/create-provider/src/__tests__/generator.test.mts @@ -122,6 +122,34 @@ describe("generateAuthProviderScaffold — template substitution", () => { expect(conf).toMatch(/baseUrl = "https:\/\/registry\.example\.test"/); }); + it("does not emit @o3co/auth.utils — its helpers ship inside the scaffold", async () => { + // auth.proxy (#81) and auth.policy-verifier (#210) moved logger and + // shutdown in-tree; a generated instance follows the same shape so its + // SIGTERM behaviour is readable from the code it deploys. + const outDir = join(tmpRoot, "out"); + await generateAuthProviderScaffold({ name: "test-scaffold", outDir, gitInit: false }); + const pkg = JSON.parse(await readFile(join(outDir, "package.json"), "utf8")) as { + dependencies: Record; + devDependencies: Record; + }; + expect(pkg.dependencies).not.toHaveProperty("@o3co/auth.utils"); + expect(pkg.devDependencies).not.toHaveProperty("@o3co/auth.utils"); + }); + + it("emits pino as a runtime dependency, so the instance logs NDJSON rather than console", async () => { + // @o3co/auth.utils took pino as an *optional* peer and fell back to + // console; the generator never emitted pino, so every generated instance + // logged bare `[name] …` lines. The in-tree logger depends on pino directly. + const outDir = join(tmpRoot, "out"); + await generateAuthProviderScaffold({ name: "test-scaffold", outDir, gitInit: false }); + const pkg = JSON.parse(await readFile(join(outDir, "package.json"), "utf8")) as { + dependencies: Record; + }; + // A literal, not DEFAULT_DEP_VERSIONS.pino: sharing the oracle with the + // implementation would let a missing key pass as undefined === undefined. + expect(pkg.dependencies.pino).toBe("10.3.1"); + }); + it("emits exact-pin dep versions (no caret)", async () => { const outDir = join(tmpRoot, "out"); await generateAuthProviderScaffold({ @@ -198,6 +226,7 @@ describe("generateAuthProviderScaffold — template substitution", () => { await readFile(join(outDir, "package.json"), "utf8"), ) as { pnpm: { overrides: Record } }; expect(pkg.pnpm.overrides).toEqual({ + zod: "4.5.4", "@provin-line/auth-provider-did": "github:provin-line/auth#v1.2.3&path:/packages/provider-did", "@provin-line/did-dplaax": diff --git a/packages/create-provider/src/defaults.mts b/packages/create-provider/src/defaults.mts index e647ef6..33197a6 100644 --- a/packages/create-provider/src/defaults.mts +++ b/packages/create-provider/src/defaults.mts @@ -14,17 +14,21 @@ // // These constants are the dependency baseline of the canonical template // (originally the pre-M4 reference instance's package.json — 2026-05-28 snapshot, provin-line/auth -// commit 21fe40c). When the baseline upgrades, refresh this file in -// lockstep with a generator MINOR bump (see create-app.md § 3.3). +// commit 21fe40c; auth baseline refreshed 2026-09-06 to the released +// auth.provider 0.12.0 with generator 0.2.0). When the baseline upgrades, +// refresh this file in lockstep with a generator MINOR bump (see create-app.md § 3.3). /** Exact-pin runtime + dev dep versions emitted into generated package.json. */ export const DEFAULT_DEP_VERSIONS = { // Framework (o3co) — runtime - "@o3co/auth-provider-core": "0.5.3", - "@o3co/auth.utils": "0.0.4", + "@o3co/auth-provider-core": "0.12.0", "@o3co/ts.hocon": "0.1.5", // Runtime — non-o3co express: "5.2.1", + // The scaffold's own logger writes NDJSON through pino (see src/logger.mts); + // it used to reach pino only as @o3co/auth.utils' optional peer, which was + // never emitted here, so instances silently logged through console. + pino: "10.3.1", // The ed25519_raw DID-grant verifier resolves this via the instance's // import.meta.resolve, so it must be a DIRECT runtime dep of the instance // (auth-provider-did declares it only as an optional peer). diff --git a/packages/create-provider/src/generator.mts b/packages/create-provider/src/generator.mts index ef2b9ea..085403f 100644 --- a/packages/create-provider/src/generator.mts +++ b/packages/create-provider/src/generator.mts @@ -203,9 +203,9 @@ function buildPackageJson(opts: FilledOptions): string { opts.dplaaxModuleRef, ), "@o3co/auth-provider-core": DEFAULT_DEP_VERSIONS["@o3co/auth-provider-core"], - "@o3co/auth.utils": DEFAULT_DEP_VERSIONS["@o3co/auth.utils"], "@o3co/ts.hocon": DEFAULT_DEP_VERSIONS["@o3co/ts.hocon"], express: DEFAULT_DEP_VERSIONS.express, + pino: DEFAULT_DEP_VERSIONS.pino, // Direct dep so the ed25519_raw DID-grant verifier's // import.meta.resolve("@noble/ed25519") resolves from the instance root. "@noble/ed25519": DEFAULT_DEP_VERSIONS["@noble/ed25519"], @@ -235,7 +235,8 @@ function buildPackageJson(opts: FilledOptions): string { ...transitiveProvinDeps.map(([name]) => name), "@provin-line/auth-provider-dplaax-module", ].sort(), - overrides: Object.fromEntries(transitiveProvinDeps), + // Zod schema objects cross package boundaries; align their minor version. + overrides: { ...Object.fromEntries(transitiveProvinDeps), zod: "4.5.4" }, }; const manifest = { diff --git a/packages/create-provider/src/template/config/application.conf.tmpl b/packages/create-provider/src/template/config/application.conf.tmpl index cc9a0cd..4a754f5 100644 --- a/packages/create-provider/src/template/config/application.conf.tmpl +++ b/packages/create-provider/src/template/config/application.conf.tmpl @@ -1,11 +1,25 @@ http { port = __PORT__ port = ${?HTTP_PORT} + readinessTimeoutMs = 2000 + readinessTimeoutMs = ${?HTTP_READINESS_TIMEOUT_MS} trustProxy = false trustProxy = ${?HTTP_TRUST_PROXY} } +logging { + level = "info" + level = ${?LOG_LEVEL} +} + +# This minimal composition does not persist audit events. +audit.sink.type = "none" + oauth { + # DID-only: lifecycle is checked at issuance; existing tokens expire by TTL. + # This composition has no password/session store or subject revocation service. + revocation.subject = "unsupported" + revocation.accessToken = "unsupported" jwt { issuer = ${?OAUTH_JWT_ISSUER} legacyTypAccept = false diff --git a/packages/create-provider/src/template/src/__tests__/logger.test.mts b/packages/create-provider/src/template/src/__tests__/logger.test.mts new file mode 100644 index 0000000..3c64d7f --- /dev/null +++ b/packages/create-provider/src/template/src/__tests__/logger.test.mts @@ -0,0 +1,48 @@ +import { PassThrough } from "node:stream"; +import { afterEach, describe, expect, it } from "vitest"; +import { createAppLogger } from "../logger.mjs"; + +const original = process.env.LOG_LEVEL; +afterEach(() => { + if (original === undefined) delete process.env.LOG_LEVEL; + else process.env.LOG_LEVEL = original; +}); + +async function firstLine(write: (logger: ReturnType) => void, level?: string) { + const stream = new PassThrough(); + const chunks: string[] = []; + stream.on("data", (c: Buffer) => chunks.push(c.toString())); + write(createAppLogger("auth-provider", level, stream)); + await new Promise((resolve) => setImmediate(resolve)); + return JSON.parse(chunks.join("").trim()); +} + +describe("createAppLogger", () => { + it("defaults to info", () => { + delete process.env.LOG_LEVEL; + expect(createAppLogger("auth-provider").level).toBe("info"); + }); + + it("honours LOG_LEVEL when no level is passed", () => { + process.env.LOG_LEVEL = "debug"; + expect(createAppLogger("auth-provider").level).toBe("debug"); + }); + + it("prefers an explicit level (logging.level from config) over LOG_LEVEL", () => { + process.env.LOG_LEVEL = "debug"; + expect(createAppLogger("auth-provider", "error").level).toBe("error"); + }); + + it("emits NDJSON — one parseable object per line, named for the aggregator", async () => { + const entry = await firstLine((l) => l.info("ready"), "info"); + expect(entry.msg).toBe("ready"); + expect(entry.name).toBe("auth-provider"); + expect(entry.level).toBe(30); + }); + + it("serialises an Error under `err` with its stack instead of `{}`", async () => { + const entry = await firstLine((l) => l.error({ err: new Error("boom") }, "failed"), "info"); + expect(entry.err.message).toBe("boom"); + expect(typeof entry.err.stack).toBe("string"); + }); +}); diff --git a/packages/create-provider/src/template/src/__tests__/shutdown.test.mts b/packages/create-provider/src/template/src/__tests__/shutdown.test.mts new file mode 100644 index 0000000..f5edaa4 --- /dev/null +++ b/packages/create-provider/src/template/src/__tests__/shutdown.test.mts @@ -0,0 +1,188 @@ +import type { Server } from "node:http"; +import { describe, expect, it, vi } from "vitest"; +import { deferExit, installGracefulShutdown, type Logger } from "../shutdown.mjs"; + +/** A `Server` double whose `close` callback fires only when we say so. */ +function makeServer() { + let closeCallback: ((err?: Error) => void) | undefined; + const server = { + close: vi.fn((cb?: (err?: Error) => void) => { + closeCallback = cb; + return server; + }), + closeIdleConnections: vi.fn(), + closeAllConnections: vi.fn(), + }; + return { + server: server as unknown as Server, + spies: server, + finishDraining: () => closeCallback?.(), + failClose: (err: Error) => closeCallback?.(err), + }; +} + +const makeLogger = () => ({ info: vi.fn(), error: vi.fn() }) satisfies Logger; + +function install(opts: { cleanup?: () => void | Promise; drainTimeoutMs?: number } = {}) { + const { server, spies, finishDraining, failClose } = makeServer(); + const logger = makeLogger(); + const exit = vi.fn(); + const signals = new Map void>(); + installGracefulShutdown(server, { + logger, + cleanup: opts.cleanup ?? (() => {}), + ...(opts.drainTimeoutMs === undefined ? {} : { drainTimeoutMs: opts.drainTimeoutMs }), + exit, + onSignal: (name, handler) => signals.set(name, handler), + offSignal: (name) => signals.delete(name), + }); + return { spies, logger, exit, signals, finishDraining, failClose }; +} + +const settle = () => new Promise((resolve) => setImmediate(resolve)); + +describe("installGracefulShutdown", () => { + it("listens for both SIGTERM and SIGINT", () => { + expect([...install().signals.keys()].sort()).toEqual(["SIGINT", "SIGTERM"]); + }); + + it("stops accepting connections and releases idle keep-alive sockets", () => { + const { signals, spies } = install(); + signals.get("SIGTERM")?.(); + expect(spies.close).toHaveBeenCalledOnce(); + expect(spies.closeIdleConnections).toHaveBeenCalledOnce(); + }); + + it("runs cleanup once draining completes, then exits zero", async () => { + const cleanup = vi.fn(); + const { signals, finishDraining, exit } = install({ cleanup }); + signals.get("SIGTERM")?.(); + expect(cleanup).not.toHaveBeenCalled(); + finishDraining(); + await settle(); + expect(cleanup).toHaveBeenCalledOnce(); + expect(exit).toHaveBeenCalledWith(0); + }); + + it("ignores a second signal instead of running cleanup twice", async () => { + const cleanup = vi.fn(); + const { signals, spies, finishDraining, exit } = install({ cleanup }); + const handler = signals.get("SIGTERM"); + handler?.(); + handler?.(); + finishDraining(); + await settle(); + expect(spies.close).toHaveBeenCalledOnce(); + expect(cleanup).toHaveBeenCalledOnce(); + expect(exit).toHaveBeenCalledOnce(); + }); + + it("forces the remaining connections closed when draining outruns the deadline", () => { + vi.useFakeTimers(); + try { + const { signals, spies } = install({ drainTimeoutMs: 5_000 }); + signals.get("SIGTERM")?.(); + expect(spies.closeAllConnections).not.toHaveBeenCalled(); + vi.advanceTimersByTime(5_000); + expect(spies.closeAllConnections).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + + it("exits non-zero on a forced close, so the drain outcome is visible", async () => { + vi.useFakeTimers(); + let exitSpy: ReturnType; + try { + const { signals, exit } = install({ drainTimeoutMs: 5_000 }); + exitSpy = exit; + signals.get("SIGTERM")?.(); + vi.advanceTimersByTime(5_000); + } finally { + vi.useRealTimers(); + } + await settle(); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("does not force-close a drain that finished in time", () => { + vi.useFakeTimers(); + try { + const { signals, spies, finishDraining } = install({ drainTimeoutMs: 5_000 }); + signals.get("SIGTERM")?.(); + finishDraining(); + vi.advanceTimersByTime(10_000); + expect(spies.closeAllConnections).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("bounds cleanup so a hanging dispose cannot wedge the process", async () => { + vi.useFakeTimers(); + try { + const { signals, finishDraining, exit, logger } = install({ + cleanup: () => new Promise(() => {}), + drainTimeoutMs: 5_000, + }); + signals.get("SIGTERM")?.(); + finishDraining(); + await vi.advanceTimersByTimeAsync(5_000); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ cleanupTimeoutMs: 5_000 }), + expect.stringContaining("cleanup timed out"), + ); + expect(exit).toHaveBeenCalledWith(1); + } finally { + vi.useRealTimers(); + } + }); + + it("reports a cleanup failure through the logger, not console, and exits non-zero", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const err = new Error("dispose failed"); + const { signals, finishDraining, logger, exit } = install({ cleanup: () => Promise.reject(err) }); + signals.get("SIGTERM")?.(); + finishDraining(); + await settle(); + expect(logger.error).toHaveBeenCalledWith({ err }, expect.stringContaining("cleanup failed")); + expect(consoleError).not.toHaveBeenCalled(); + expect(exit).toHaveBeenCalledWith(1); + consoleError.mockRestore(); + }); + + it("reports the cleanup outcome as the reason and keeps the drain outcome under its own key", async () => { + const { signals, finishDraining, logger } = install({ cleanup: () => Promise.reject(new Error("x")) }); + signals.get("SIGTERM")?.(); + finishDraining(); + await settle(); + expect(logger.info).toHaveBeenCalledWith( + { reason: "cleanup-failed", drain: "drained", exitCode: 1 }, + "graceful shutdown: complete", + ); + }); + + it("does not report a failed close as a clean drain", async () => { + const err = new Error("Server is not running"); + const { signals, failClose, logger, exit } = install(); + signals.get("SIGTERM")?.(); + failClose(err); + await settle(); + expect(logger.error).toHaveBeenCalledWith({ err }, expect.stringContaining("close failed")); + expect(exit).toHaveBeenCalledWith(1); + }); + + it("removes its own signal listeners once shutting down", () => { + const { signals } = install(); + signals.get("SIGTERM")?.(); + expect(signals.size).toBe(0); + }); + + it("defers the real exit a turn so pino's buffered destination can flush", async () => { + const exitProcess = vi.fn(); + deferExit(3, exitProcess); + expect(exitProcess).not.toHaveBeenCalled(); + await settle(); + expect(exitProcess).toHaveBeenCalledWith(3); + }); +}); diff --git a/packages/create-provider/src/template/src/logger.mts b/packages/create-provider/src/template/src/logger.mts new file mode 100644 index 0000000..ae2885f --- /dev/null +++ b/packages/create-provider/src/template/src/logger.mts @@ -0,0 +1,42 @@ +/* + * Copyright 2026 1o1 Co. Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { type DestinationStream, pino, stdSerializers } from "pino"; + +/** + * The instance's logger: newline-delimited JSON on stdout, the shape every + * log aggregator ingests without a parser. + * + * It used to come from `@o3co/auth.utils`, which took pino as an *optional* + * peer and fell back to `console` when the import failed. The generator never + * emitted pino, so every generated instance silently logged bare + * `[auth-provider] …` lines. pino is a direct dependency now, and the level + * honours `logging.level` from the application config with `LOG_LEVEL` as the + * environment override. + */ +export function createAppLogger( + name: string, + level: string = process.env.LOG_LEVEL ?? "info", + destination?: DestinationStream, +) { + const options = { + name, + level, + // `err` is pino's conventional key for an Error; without the serialiser + // an Error stringifies to `{}` and the stack is lost where it is needed. + serializers: { err: stdSerializers.err }, + }; + return destination ? pino(options, destination) : pino(options); +} diff --git a/packages/create-provider/src/template/src/main.mts b/packages/create-provider/src/template/src/main.mts index 4425c7a..280d41d 100644 --- a/packages/create-provider/src/template/src/main.mts +++ b/packages/create-provider/src/template/src/main.mts @@ -19,14 +19,13 @@ import { DplaaxConfigSchema, type DplaaxAppConfig, } from "@provin-line/auth-provider-dplaax-module"; -import { createLogger, gracefulShutdown } from "@o3co/auth.utils"; import { type AppConfig, createApp } from "@o3co/auth-provider-core"; import { parseFile } from "@o3co/ts.hocon"; import { validate } from "@o3co/ts.hocon/zod"; import express from "express"; import { resolveConfigPaths } from "./configPath.mjs"; - -const logger = createLogger("auth-provider"); +import { createAppLogger } from "./logger.mjs"; +import { installGracefulShutdown } from "./shutdown.mjs"; const env = process.env.CONFIG_ENV || process.env.NODE_ENV || "development"; const configDir = new URL("../config/", import.meta.url); @@ -45,6 +44,14 @@ const config: DplaaxAppConfig = validate( DplaaxConfigSchema, ) as unknown as DplaaxAppConfig; +// The HOCON carries `logging.level`, but `DplaaxAppConfig` does not declare it +// (and a released module pin may predate it), so read it defensively and let +// the logger fall back to LOG_LEVEL / "info". +const logger = createAppLogger( + "auth-provider", + (config as { logging?: { level?: string } }).logging?.level, +); + await (async (): Promise => { const app = express(); app.set("trust proxy", config.http.trustProxy); @@ -73,5 +80,5 @@ await (async (): Promise => { ); }); - gracefulShutdown(server, () => handle.dispose()); + installGracefulShutdown(server, { logger, cleanup: () => handle.dispose() }); })(); diff --git a/packages/create-provider/src/template/src/shutdown.mts b/packages/create-provider/src/template/src/shutdown.mts new file mode 100644 index 0000000..cf32b7b --- /dev/null +++ b/packages/create-provider/src/template/src/shutdown.mts @@ -0,0 +1,181 @@ +/* + * Copyright 2026 1o1 Co. Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { Server } from "node:http"; + +/** The logging surface shutdown needs; pino satisfies it structurally. */ +export interface Logger { + info(obj: Record, msg?: string): void; + error(obj: Record, msg?: string): void; +} + +/** + * Graceful shutdown for the generated instance. + * + * ## Why this is in the scaffold rather than a dependency + * + * It used to be `gracefulShutdown` from `@o3co/auth.utils@0.0.4`. For the + * component that terminates every user session, "does SIGTERM wait for + * in-flight requests, and for how long?" has to be answerable from the code + * an operator deploys — and reading those 22 lines answered it badly: + * **there was no deadline**. `server.close()` waits indefinitely, so one stuck + * request meant the process never exited on its own and the orchestrator's + * SIGKILL took it down mid-flight, precisely under the load that produces a + * stuck request. Cleanup failures went to `console.error`, and every exit was + * zero, so a truncated shutdown looked exactly like a clean one. + * + * auth.provider (#290), auth.proxy (#81) and auth.policy-verifier (#210) each + * moved the behaviour into the code they ship. This is the same contract. + * + * ## The guarantees, stated + * + * 1. **SIGTERM and SIGINT** both start it; a second signal is ignored. + * 2. **New connections stop immediately** (`close`) and idle keep-alive + * sockets are released (`closeIdleConnections`). + * 3. **In-flight requests get `drainTimeoutMs`** (default 10s) to finish. + * 4. **Past the deadline, remaining connections are cut** and the process + * exits **non-zero**, so a truncated drain is distinguishable from a clean one. + * 5. **`cleanup` runs after draining, before exit**, bounded by + * `cleanupTimeoutMs`; its failure is logged and reflected in the exit code. + * 6. **A `close` that fails is not reported as a clean drain.** + * + * Size `drainTimeoutMs` and `cleanupTimeoutMs` together **below** the + * orchestrator's kill grace period (Kubernetes + * `terminationGracePeriodSeconds`, compose `stop_grace_period`, both 30s by + * default): the worst case is the two budgets in sequence. + */ +export interface GracefulShutdownOptions { + readonly logger: Logger; + /** Reverse-topological component cleanup — normally `handle.dispose()`. */ + readonly cleanup?: () => void | Promise; + /** How long in-flight requests get before connections are cut. Default 10s. */ + readonly drainTimeoutMs?: number; + /** How long `cleanup` gets. Defaults to `drainTimeoutMs`. */ + readonly cleanupTimeoutMs?: number; + /** Injected in tests; defaults to {@link deferExit}. */ + readonly exit?: (code: number) => void; + /** Injected in tests; defaults to `process.on`. */ + readonly onSignal?: (signal: NodeJS.Signals, handler: () => void) => void; + /** Injected in tests; defaults to `process.removeListener`. */ + readonly offSignal?: (signal: NodeJS.Signals, handler: () => void) => void; +} + +/** + * Exit after yielding the loop once. pino's default destination is not + * synchronous, so exiting in the same tick as the last `logger.error` can drop + * exactly the line that says why. One turn is a flush window, not a + * guarantee; a deployment needing certainty passes an `exit` that flushes. + */ +export function deferExit(code: number, exitProcess: (code: number) => void = process.exit): void { + setImmediate(() => exitProcess(code)); +} + +const DEFAULT_DRAIN_TIMEOUT_MS = 10_000; +const SIGNALS: readonly NodeJS.Signals[] = ["SIGTERM", "SIGINT"]; + +export function installGracefulShutdown(server: Server, options: GracefulShutdownOptions): void { + const { + logger, + cleanup, + drainTimeoutMs = DEFAULT_DRAIN_TIMEOUT_MS, + exit = deferExit, + onSignal = (signal, handler): void => { + process.on(signal, handler); + }, + offSignal = (signal, handler): void => { + process.removeListener(signal, handler); + }, + } = options; + const cleanupTimeoutMs = options.cleanupTimeoutMs ?? drainTimeoutMs; + + let shuttingDown = false; + let finished = false; + + /** Sentinel so a timed-out cleanup is reported as that, not as a throw. */ + const CLEANUP_TIMED_OUT = Symbol("cleanup-timed-out"); + + /** Await `cleanup`, but not forever; a sync throw lands in the same path. */ + const runCleanup = async (): Promise => { + if (!cleanup) return; + let timer: ReturnType | undefined; + try { + return await Promise.race([ + (async (): Promise => { + await cleanup(); + return undefined; + })(), + new Promise((resolve) => { + timer = setTimeout(() => resolve(CLEANUP_TIMED_OUT), cleanupTimeoutMs); + timer.unref?.(); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } + }; + + /** Run `cleanup` and exit. Called by whichever of drain / deadline wins. */ + const finish = async (code: number, reason: string): Promise => { + if (finished) return; + finished = true; + let exitCode = code; + // `reason` names whatever decided the exit code; the drain outcome keeps + // its own key so the line an operator alerts on is consistent and complete. + let outcome = reason; + try { + if ((await runCleanup()) === CLEANUP_TIMED_OUT) { + logger.error({ cleanupTimeoutMs }, "graceful shutdown: cleanup timed out"); + exitCode = 1; + outcome = "cleanup-timeout"; + } + } catch (err) { + logger.error({ err }, "graceful shutdown: cleanup failed"); + exitCode = 1; + outcome = "cleanup-failed"; + } + logger.info({ reason: outcome, drain: reason, exitCode }, "graceful shutdown: complete"); + exit(exitCode); + }; + + const handler = (): void => { + if (shuttingDown) return; + shuttingDown = true; + for (const signal of SIGNALS) offSignal(signal, handler); + logger.info({ drainTimeoutMs }, "graceful shutdown: draining"); + + const deadline = setTimeout(() => { + logger.error( + { drainTimeoutMs }, + "graceful shutdown: drain deadline exceeded, closing remaining connections", + ); + server.closeAllConnections(); + void finish(1, "drain-timeout"); + }, drainTimeoutMs); + deadline.unref?.(); + + server.close((err) => { + clearTimeout(deadline); + if (err) { + logger.error({ err }, "graceful shutdown: server close failed"); + void finish(1, "close-failed"); + return; + } + void finish(0, "drained"); + }); + server.closeIdleConnections(); + }; + + for (const signal of SIGNALS) onSignal(signal, handler); +} diff --git a/packages/policy-verifier-dplaax-module/package.json b/packages/policy-verifier-dplaax-module/package.json index 1ad3343..9d348a5 100644 --- a/packages/policy-verifier-dplaax-module/package.json +++ b/packages/policy-verifier-dplaax-module/package.json @@ -30,8 +30,8 @@ }, "dependencies": { "@provin-line/did-dplaax": "workspace:*", - "@o3co/auth.policy-verifier.builtins": "^0.3.1", - "@o3co/auth.policy-verifier.core": "^0.3.1" + "@o3co/auth.policy-verifier.builtins": "^0.8.1", + "@o3co/auth.policy-verifier.core": "^0.8.1" }, "devDependencies": { "@types/node": "^25.6.0", diff --git a/packages/policy-verifier-dplaax-module/src/__tests__/collectors/SubjectDidCollector.test.mts b/packages/policy-verifier-dplaax-module/src/__tests__/collectors/SubjectDidCollector.test.mts index 43f393d..101758e 100644 --- a/packages/policy-verifier-dplaax-module/src/__tests__/collectors/SubjectDidCollector.test.mts +++ b/packages/policy-verifier-dplaax-module/src/__tests__/collectors/SubjectDidCollector.test.mts @@ -1,11 +1,12 @@ import { describe, expect, it } from "vitest"; -import type { CollectorContext, VerifierPayload } from "@o3co/auth.policy-verifier.core"; +import type { CollectorContext } from "@o3co/auth.policy-verifier.core"; import { SubjectDidCollector } from "../../collectors/SubjectDidCollector.mjs"; import { ATTR_SUBJECT_DID } from "../../keys.mjs"; function makeContext(sub?: string): CollectorContext { return { - payload: { sub, token: "dummy", tokenType: "Bearer" } satisfies VerifierPayload, + subject: sub === undefined ? {} : { sub }, + signal: new AbortController().signal, resource: { raw: "test:1", resourceType: "test", resourceId: "1" }, action: "read", }; @@ -14,7 +15,7 @@ function makeContext(sub?: string): CollectorContext { describe("SubjectDidCollector", () => { const collector = new SubjectDidCollector(); - it("emits payload.sub to ATTR_SUBJECT_DID when it is a DID (did: prefix)", async () => { + it("emits subject.sub to ATTR_SUBJECT_DID when it is a DID (did: prefix)", async () => { const attrs = await collector.collect(makeContext("did:dplaax:r1:org:alice")); expect(attrs.get(ATTR_SUBJECT_DID)).toBe("did:dplaax:r1:org:alice"); }); diff --git a/packages/policy-verifier-dplaax-module/src/__tests__/collectors/SubjectDidTypeCollector.test.mts b/packages/policy-verifier-dplaax-module/src/__tests__/collectors/SubjectDidTypeCollector.test.mts index 43662ce..ab3de33 100644 --- a/packages/policy-verifier-dplaax-module/src/__tests__/collectors/SubjectDidTypeCollector.test.mts +++ b/packages/policy-verifier-dplaax-module/src/__tests__/collectors/SubjectDidTypeCollector.test.mts @@ -1,11 +1,12 @@ import { describe, expect, it } from "vitest"; -import type { CollectorContext, VerifierPayload } from "@o3co/auth.policy-verifier.core"; +import type { CollectorContext } from "@o3co/auth.policy-verifier.core"; import { SubjectDidTypeCollector } from "../../collectors/SubjectDidTypeCollector.mjs"; import { ATTR_SUBJECT_DID_TYPE } from "../../keys.mjs"; function makeContext(sub?: string): CollectorContext { return { - payload: { sub, token: "dummy", tokenType: "Bearer" } satisfies VerifierPayload, + subject: sub === undefined ? {} : { sub }, + signal: new AbortController().signal, resource: { raw: "test:1", resourceType: "test", resourceId: "1" }, action: "read", }; diff --git a/packages/policy-verifier-dplaax-module/src/__tests__/collectors/SubscriberDidCollector.test.mts b/packages/policy-verifier-dplaax-module/src/__tests__/collectors/SubscriberDidCollector.test.mts index bffabd7..7d1a5c9 100644 --- a/packages/policy-verifier-dplaax-module/src/__tests__/collectors/SubscriberDidCollector.test.mts +++ b/packages/policy-verifier-dplaax-module/src/__tests__/collectors/SubscriberDidCollector.test.mts @@ -1,15 +1,17 @@ import { describe, expect, it } from "vitest"; -import type { CollectorContext, VerifierPayload } from "@o3co/auth.policy-verifier.core"; +import { markUntrustedRequestContext } from "@o3co/auth.policy-verifier.core"; +import type { CollectorContext } from "@o3co/auth.policy-verifier.core"; import { SubscriberDidCollector } from "../../collectors/SubscriberDidCollector.mjs"; import { ATTR_SUBSCRIBER_DID } from "../../keys.mjs"; function makeContext(requestContext?: Record): CollectorContext { return { - payload: { token: "dummy", tokenType: "Bearer" } satisfies VerifierPayload, + subject: {}, + signal: new AbortController().signal, resource: { raw: "test:1", resourceType: "test", resourceId: "1" }, action: "read", - requestContext, - }; + requestContext: requestContext ? markUntrustedRequestContext(requestContext) : undefined, + } as CollectorContext; } describe("SubscriberDidCollector", () => { diff --git a/packages/policy-verifier-dplaax-module/src/__tests__/rules/DefaultDenyRuleCollector.test.mts b/packages/policy-verifier-dplaax-module/src/__tests__/rules/DefaultDenyRuleCollector.test.mts index d2990e2..f5d1d67 100644 --- a/packages/policy-verifier-dplaax-module/src/__tests__/rules/DefaultDenyRuleCollector.test.mts +++ b/packages/policy-verifier-dplaax-module/src/__tests__/rules/DefaultDenyRuleCollector.test.mts @@ -1,11 +1,12 @@ import { describe, expect, it } from "vitest"; -import type { CollectorContext, VerifierPayload } from "@o3co/auth.policy-verifier.core"; +import type { CollectorContext } from "@o3co/auth.policy-verifier.core"; import { evaluate } from "@o3co/auth.policy-verifier.core"; import { DefaultDenyRuleCollector } from "../../rules/DefaultDenyRuleCollector.mjs"; function makeContext(resource: string, action: string): CollectorContext { return { - payload: { token: "dummy", tokenType: "Bearer" } satisfies VerifierPayload, + subject: {}, + signal: new AbortController().signal, resource: { raw: resource, resourceType: resource.split(".")[0] ?? resource }, action, }; @@ -77,7 +78,7 @@ describe("DefaultDenyRuleCollector", () => { verify: () => true, }; const decision = evaluate(new Map(), [passingRule, ...denyRules]); - expect(decision).toEqual({ + expect(decision).toMatchObject({ decision: "deny", code: "undeclared_resource_action", message: expect.stringContaining("nonexistent"), @@ -93,7 +94,7 @@ describe("DefaultDenyRuleCollector", () => { verify: () => true, }; const decision = evaluate(new Map(), [passingRule, ...denyRules]); - expect(decision).toEqual({ decision: "allow" }); + expect(decision).toMatchObject({ decision: "allow" }); }); }); diff --git a/packages/policy-verifier-dplaax-module/src/__tests__/rules/SubjectDidTypeRuleCollector.test.mts b/packages/policy-verifier-dplaax-module/src/__tests__/rules/SubjectDidTypeRuleCollector.test.mts index c5e3d60..fea8668 100644 --- a/packages/policy-verifier-dplaax-module/src/__tests__/rules/SubjectDidTypeRuleCollector.test.mts +++ b/packages/policy-verifier-dplaax-module/src/__tests__/rules/SubjectDidTypeRuleCollector.test.mts @@ -1,11 +1,12 @@ import { describe, expect, it } from "vitest"; -import type { CollectorContext, VerifierPayload } from "@o3co/auth.policy-verifier.core"; +import type { CollectorContext } from "@o3co/auth.policy-verifier.core"; import { SubjectDidTypeRuleCollector } from "../../rules/SubjectDidTypeRuleCollector.mjs"; import { ATTR_SUBJECT_DID_TYPE } from "../../keys.mjs"; function makeContext(resource: string, action: string): CollectorContext { return { - payload: { token: "dummy", tokenType: "Bearer" } satisfies VerifierPayload, + subject: {}, + signal: new AbortController().signal, resource: { raw: resource, resourceType: resource.split(".")[0] ?? resource }, action, }; @@ -34,7 +35,7 @@ describe("SubjectDidTypeRuleCollector", () => { // Its default ruleType follows the builtins scheme: // attr_literal_in:{a}:{type}:{count}:{hashPrefix} expect(rules[0].ruleType).toMatch( - /^attr_literal_in:subjectDidType:string:\d+:[0-9a-f]{8}$/, + /^attr_literal_in:subjectDidType:string:\d+:(?:[0-9a-f]{8}|[0-9a-f]{16})$/, ); expect(rules[0].code).toBe("attr_not_in_set"); }); diff --git a/packages/policy-verifier-dplaax-module/src/__tests__/rules/SubscriberIdentityCheckRuleCollector.test.mts b/packages/policy-verifier-dplaax-module/src/__tests__/rules/SubscriberIdentityCheckRuleCollector.test.mts index 688ceb5..1fa30ab 100644 --- a/packages/policy-verifier-dplaax-module/src/__tests__/rules/SubscriberIdentityCheckRuleCollector.test.mts +++ b/packages/policy-verifier-dplaax-module/src/__tests__/rules/SubscriberIdentityCheckRuleCollector.test.mts @@ -1,14 +1,15 @@ import { describe, expect, it } from "vitest"; -import type { CollectorContext, VerifierPayload } from "@o3co/auth.policy-verifier.core"; +import type { CollectorContext } from "@o3co/auth.policy-verifier.core"; import { SubscriberIdentityCheckRuleCollector } from "../../rules/SubscriberIdentityCheckRuleCollector.mjs"; import { ATTR_SUBJECT_DID, ATTR_SUBSCRIBER_DID } from "../../keys.mjs"; function makeContext(resource: string, action: string): CollectorContext { return { - payload: { token: "dummy", tokenType: "Bearer" } satisfies VerifierPayload, + subject: {}, + signal: new AbortController().signal, resource: { raw: resource, resourceType: resource.split(".")[0] ?? resource }, action, - // intentionally omit requestContext and payload.sub — this RuleCollector + // intentionally omit requestContext and subject.sub — this RuleCollector // must not read either. Rules receive their data via attrs. }; } diff --git a/packages/policy-verifier-dplaax-module/src/collectors/SubjectDidCollector.mts b/packages/policy-verifier-dplaax-module/src/collectors/SubjectDidCollector.mts index 1f924a8..6c50efb 100644 --- a/packages/policy-verifier-dplaax-module/src/collectors/SubjectDidCollector.mts +++ b/packages/policy-verifier-dplaax-module/src/collectors/SubjectDidCollector.mts @@ -21,7 +21,7 @@ import type { import { ATTR_SUBJECT_DID } from "../keys.mjs"; /** - * AttributeCollector that promotes `payload.sub` to ATTR_SUBJECT_DID when + * AttributeCollector that promotes verified `subject.sub` to ATTR_SUBJECT_DID when * it conforms to the W3C DID syntax: `did::`. * * If `sub` is absent, empty, or not a DID, nothing is emitted. Downstream @@ -35,7 +35,7 @@ import { ATTR_SUBJECT_DID } from "../keys.mjs"; export class SubjectDidCollector implements AttributeCollector { async collect(context: CollectorContext): Promise { const attrs: Attributes = new Map(); - const sub = context.payload.sub; + const sub = context.subject.sub; if (typeof sub !== "string" || sub.length === 0) return attrs; if (!isDid(sub)) return attrs; attrs.set(ATTR_SUBJECT_DID, sub); diff --git a/packages/policy-verifier-dplaax-module/src/collectors/SubjectDidTypeCollector.mts b/packages/policy-verifier-dplaax-module/src/collectors/SubjectDidTypeCollector.mts index df068c5..6f42e54 100644 --- a/packages/policy-verifier-dplaax-module/src/collectors/SubjectDidTypeCollector.mts +++ b/packages/policy-verifier-dplaax-module/src/collectors/SubjectDidTypeCollector.mts @@ -58,7 +58,7 @@ export function parseDIDType(did: string): DIDType | null { /** * AttributeCollector that derives the did:dplaax DID type from - * `payload.sub` and stores it under ATTR_SUBJECT_DID_TYPE. + * verified `subject.sub` and stores it under ATTR_SUBJECT_DID_TYPE. * * The name reflects what this collector actually reads: the subject * field of the JWT. If a future collector needs to derive a DID type @@ -74,7 +74,7 @@ export function parseDIDType(did: string): DIDType | null { export class SubjectDidTypeCollector implements AttributeCollector { async collect(context: CollectorContext): Promise { const attrs: Attributes = new Map(); - const sub = context.payload.sub; + const sub = context.subject.sub; if (typeof sub !== "string" || sub.length === 0) return attrs; const type = parseDIDType(sub); if (type !== null) { diff --git a/packages/policy-verifier-dplaax-module/src/collectors/SubscriberDidCollector.mts b/packages/policy-verifier-dplaax-module/src/collectors/SubscriberDidCollector.mts index 8a6208a..2909b10 100644 --- a/packages/policy-verifier-dplaax-module/src/collectors/SubscriberDidCollector.mts +++ b/packages/policy-verifier-dplaax-module/src/collectors/SubscriberDidCollector.mts @@ -19,9 +19,10 @@ import type { CollectorContext, } from "@o3co/auth.policy-verifier.core"; import { ATTR_SUBSCRIBER_DID } from "../keys.mjs"; +import { readUntrustedRequestContext } from "@o3co/auth.policy-verifier.core"; /** - * AttributeCollector that promotes `context.requestContext.subscriber_did` + * AttributeCollector that promotes the untrusted request field `subscriber_did` * to ATTR_SUBSCRIBER_DID when the value is a non-empty string. * * The field name (`subscriber_did`) and the attribute key @@ -32,7 +33,7 @@ import { ATTR_SUBSCRIBER_DID } from "../keys.mjs"; export class SubscriberDidCollector implements AttributeCollector { async collect(context: CollectorContext): Promise { const attrs: Attributes = new Map(); - const value = context.requestContext?.subscriber_did; + const value = readUntrustedRequestContext(context.requestContext)?.subscriber_did; if (typeof value === "string" && value.length > 0) { attrs.set(ATTR_SUBSCRIBER_DID, value); } diff --git a/packages/provider-did/package.json b/packages/provider-did/package.json index cbdd9c7..3c2fe53 100644 --- a/packages/provider-did/package.json +++ b/packages/provider-did/package.json @@ -29,9 +29,9 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { - "@o3co/auth-provider-core": "^0.5.3", + "@o3co/auth-provider-core": "^0.12.0", "jose": "^6.2.2", - "zod": "^4.3.6" + "zod": "^4.5.4" }, "peerDependencies": { "@noble/ed25519": "^3.0.1" diff --git a/packages/provider-dplaax-module/package.json b/packages/provider-dplaax-module/package.json index f1a3c86..cc2d876 100644 --- a/packages/provider-dplaax-module/package.json +++ b/packages/provider-dplaax-module/package.json @@ -32,9 +32,9 @@ "@provin-line/auth-provider-did": "workspace:*", "@provin-line/did-dplaax": "workspace:*", "@noble/ed25519": "^3.0.1", - "@o3co/auth-provider-core": "^0.5.3", - "@o3co/auth-provider-oauth": "^0.5.3", - "zod": "^4.3.6" + "@o3co/auth-provider-core": "^0.12.0", + "@o3co/auth-provider-oauth": "^0.12.0", + "zod": "^4.5.4" }, "devDependencies": { "@types/node": "^25.6.0", diff --git a/packages/provider-dplaax-module/src/buildModules.mts b/packages/provider-dplaax-module/src/buildModules.mts index 57630ca..c5605ae 100644 --- a/packages/provider-dplaax-module/src/buildModules.mts +++ b/packages/provider-dplaax-module/src/buildModules.mts @@ -19,6 +19,7 @@ import { oauthDidModule, } from "@provin-line/auth-provider-did"; import type { AppConfig, Module } from "@o3co/auth-provider-core"; +import * as core from "@o3co/auth-provider-core"; import { oauthModule } from "@o3co/auth-provider-oauth"; import { @@ -73,6 +74,8 @@ export interface DplaaxBuildModulesOverrides { readonly nonceStore?: NonceStore; } +const jwksModule = Reflect.get(core, "jwksModule") as Module | undefined; + /** * Compose the dPLaaX auth-provider module list from `config`. * @@ -97,6 +100,8 @@ export function buildModules( overrides.keyStoreModule ?? keyStoreModule, overrides.clientRepositoryModule ?? clientRepositoryModule, overrides.codeRepositoryModule ?? inMemoryCodeRepositoryModule, + // Current core owns JWKS separately; released 0.5.x OAuth publishes it. + ...(jwksModule ? [jwksModule] : []), // `oauthModule` types `config` as the full upstream `AppConfig`. // dPLaaX deliberately omits the session / federation / rateLimit / // cors sections (see `DplaaxAppConfigBase` Pick above); the upstream diff --git a/packages/provider-dplaax-module/src/config-schema.mts b/packages/provider-dplaax-module/src/config-schema.mts index 37c0d42..9cd00a4 100644 --- a/packages/provider-dplaax-module/src/config-schema.mts +++ b/packages/provider-dplaax-module/src/config-schema.mts @@ -37,6 +37,9 @@ import { z } from "zod"; * `DplaaxAppConfig`. */ export const DplaaxConfigSchema = CoreConfigSchema.extend({ + // CoreConfigSchema does not own the audit module slice. Preserve the + // DID-only composition's explicit absence declaration for boot validation. + audit: z.object({ sink: z.object({ type: z.literal("none") }) }).optional(), endpoints: z.object({ login: z.object({ url: z.string().min(1), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9950479..6709aa3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,7 @@ overrides: '@provin-line/auth-provider-dplaax-module': workspace:* '@provin-line/auth-policy-verifier-dplaax-module': workspace:* vite: ^8.0.16 + zod: 4.5.4 importers: @@ -23,14 +24,17 @@ importers: specifier: ^3.1.0 version: 3.1.0 '@o3co/auth-provider-core': - specifier: ^0.5.3 - version: 0.5.3(express@5.2.1) + specifier: ^0.12.0 + version: 0.12.0(express@5.2.1) '@o3co/auth.policy-verifier.builtins': - specifier: ^0.3.1 - version: 0.3.1 + specifier: ^0.8.1 + version: 0.8.1 + '@o3co/auth.policy-verifier.core': + specifier: ^0.8.1 + version: 0.8.1 '@o3co/auth.policy-verifier.server': - specifier: ^0.3.1 - version: 0.3.1 + specifier: ^0.8.1 + version: 0.8.1 '@provin-line/auth-policy-verifier-dplaax-module': specifier: workspace:* version: link:../packages/policy-verifier-dplaax-module @@ -57,7 +61,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.4 - version: 4.1.5(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.16(@types/node@25.6.0)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.16(@types/node@25.6.0)) packages/create-policy-verifier: devDependencies: @@ -75,13 +79,13 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.4 - version: 4.1.5(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.16(@types/node@25.6.0)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.16(@types/node@25.6.0)) packages/create-provider: devDependencies: '@o3co/auth-provider-core': - specifier: ^0.5.3 - version: 0.5.3(express@5.2.1) + specifier: ^0.12.0 + version: 0.12.0(express@5.2.1) '@provin-line/auth-provider-did': specifier: workspace:* version: link:../provider-did @@ -99,7 +103,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.4 - version: 4.1.5(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.16(@types/node@25.6.0)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.16(@types/node@25.6.0)) packages/did-dplaax: devDependencies: @@ -117,16 +121,16 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.4 - version: 4.1.5(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.16(@types/node@25.6.0)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.16(@types/node@25.6.0)) packages/policy-verifier-dplaax-module: dependencies: '@o3co/auth.policy-verifier.builtins': - specifier: ^0.3.1 - version: 0.3.1 + specifier: ^0.8.1 + version: 0.8.1 '@o3co/auth.policy-verifier.core': - specifier: ^0.3.1 - version: 0.3.1 + specifier: ^0.8.1 + version: 0.8.1 '@provin-line/did-dplaax': specifier: workspace:* version: link:../did-dplaax @@ -145,19 +149,19 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.4 - version: 4.1.5(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.16(@types/node@25.6.0)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.16(@types/node@25.6.0)) packages/provider-did: dependencies: '@o3co/auth-provider-core': - specifier: ^0.5.3 - version: 0.5.3(express@5.2.1) + specifier: ^0.12.0 + version: 0.12.0(express@5.2.1) jose: specifier: ^6.2.2 version: 6.2.2 zod: - specifier: ^4.3.6 - version: 4.3.6 + specifier: 4.5.4 + version: 4.5.4 devDependencies: '@noble/ed25519': specifier: ^3.0.1 @@ -176,7 +180,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.4 - version: 4.1.5(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.16(@types/node@25.6.0)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.16(@types/node@25.6.0)) packages/provider-dplaax-module: dependencies: @@ -184,11 +188,11 @@ importers: specifier: ^3.0.1 version: 3.1.0 '@o3co/auth-provider-core': - specifier: ^0.5.3 - version: 0.5.3(express@5.2.1) + specifier: ^0.12.0 + version: 0.12.0(express@5.2.1) '@o3co/auth-provider-oauth': - specifier: ^0.5.3 - version: 0.5.3(express@5.2.1) + specifier: ^0.12.0 + version: 0.12.0(express-session@1.19.0)(express@5.2.1) '@provin-line/auth-provider-did': specifier: workspace:* version: link:../provider-did @@ -196,8 +200,8 @@ importers: specifier: workspace:* version: link:../did-dplaax zod: - specifier: ^4.3.6 - version: 4.3.6 + specifier: 4.5.4 + version: 4.5.4 devDependencies: '@types/node': specifier: ^25.6.0 @@ -213,7 +217,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.4 - version: 4.1.5(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.16(@types/node@25.6.0)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.16(@types/node@25.6.0)) packages: @@ -257,50 +261,47 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@napi-rs/wasm-runtime@1.1.6': - resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + '@napi-rs/wasm-runtime@1.2.3': + resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 '@noble/ed25519@3.1.0': resolution: {integrity: sha512-pfcObRY3CtvwfaG9Mt5XqZdKmAQppl37tHUeuBhDUbiwJBCVY4/A4lbMvb1xKhMDx96AqAqZpMWuBX1HulhX4g==} - '@o3co/auth-provider-core@0.5.3': - resolution: {integrity: sha512-i+NHdCEa7RthNL1spxpuCaPr6+Sy9u+VGuzabn/ij1pnGQ0EibHcezgGfr5n2pqr5sfNE2THhdPyrbsdxaUNMg==} - engines: {node: '>=18.19.0'} + '@o3co/auth-provider-core@0.12.0': + resolution: {integrity: sha512-IoSc1Q6l07d1xI9UAaVFQ3mS3F5ur1y/HkSMrvWNm2ipgGPJRfpTfVFocOcT/RUNqZKwEs5D9FpeY0MxttfF8Q==} + engines: {node: '>=22.0.0'} peerDependencies: express: ^5.0.0 peerDependenciesMeta: express: optional: true - '@o3co/auth-provider-oauth@0.5.3': - resolution: {integrity: sha512-v5BFxPNpFmckS0p0rCPnDqADVHhmzAux6/SwiE+PUPPKflgraZjxsmE0Kb3wij6Zn9U3uDJlksTuG9qG6Ari8Q==} - engines: {node: '>=18.19.0'} + '@o3co/auth-provider-oauth@0.12.0': + resolution: {integrity: sha512-QPAxVyfj3d5+2aX79CmtY4k2XrBrHmFerWj6l0N5gank2Z7cs9cFBhpMD9PzyOniGItzNB6C2n3fXZBWSJUaXg==} + engines: {node: '>=22.0.0'} peerDependencies: express: ^5.0.0 + express-session: ^1.17.0 - '@o3co/auth.policy-verifier.builtins@0.3.1': - resolution: {integrity: sha512-81x548K4CLi5e1KI0qlcwSEs/crScogFxtycs7wK3+C24CpOw4tT52T+qmL8ah9vMq/GRiojJNo/oWc7Dx3CMg==} + '@o3co/auth.policy-verifier.builtins@0.8.1': + resolution: {integrity: sha512-khiAi78MiUgCon5TaGEtN2XPyofrnGoaKUeQ2fb759PAtXyWUzXuk0BJQlfg71S9eMohAJSqULQRRRQE8N2BZQ==} engines: {node: '>=22.0.0'} - '@o3co/auth.policy-verifier.core@0.3.1': - resolution: {integrity: sha512-F6Ogpxs/WaVbMkWGytotOk73rUctjDaG6FkFtNf61L7wZDX9X111cCAjud2lpcsLkdBCIi8rNDH8h6xkOxF2Yg==} + '@o3co/auth.policy-verifier.core@0.8.1': + resolution: {integrity: sha512-qy7Pd9ji+AhcYQvx2e/jbJXt619AOtXi0aDTZ/7j9hj6SPuV4Me1Oo1WoFShAJlo80iVfP5kVgUyx/kUxdP9Fg==} engines: {node: '>=22.0.0'} - '@o3co/auth.policy-verifier.server@0.3.1': - resolution: {integrity: sha512-65Zite1T5yWjU9lpDhhLFHllrwUtqQlaLJZUFxanUg+T0oqCxOB3C2Ymj5rua80eEonbf11yb/9atEWzaz9DCg==} + '@o3co/auth.policy-verifier.server@0.8.1': + resolution: {integrity: sha512-GncL/87T+3rttG74p+008adQz6Po+ubEpyqgsjMpADUexcMCFB8aI7oOBegaTang0NIcWP5IE8kS99+woyqEOg==} engines: {node: '>=22.0.0'} - '@o3co/auth.utils@0.0.2': - resolution: {integrity: sha512-yrDVy8OAJ6oA81Xc515tAt+IEH5MtoQ7Ppx1ss5VsMf5hIfsgp2XSHLMEpJaktiXZeDnJCcy3wb9+GuC/z+T0w==} - peerDependencies: - express: ^5.0.0 - pino: ^10.0.0 - peerDependenciesMeta: - pino: - optional: true + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} @@ -508,12 +509,15 @@ packages: resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==} engines: {node: '>= 18'} + bintrees@1.0.2: + resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==} + body-parser@2.3.0: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} - brace-expansion@5.0.8: - resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} bytes@3.1.2: @@ -547,6 +551,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -555,6 +562,14 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -612,6 +627,10 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express-session@1.19.0: + resolution: {integrity: sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==} + engines: {node: '>= 0.8.0'} + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -706,14 +725,17 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + jose@6.2.2: resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} - js-yaml@4.3.0: - resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + js-yaml@5.4.1: + resolution: {integrity: sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==} hasBin: true lightningcss-android-arm64@1.33.0: @@ -836,11 +858,14 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -867,6 +892,10 @@ packages: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -894,22 +923,31 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} - picomatch@4.0.5: - resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} - postcss@8.5.23: - resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} + prom-client@15.1.3: + resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==} + engines: {node: ^16 || ^18 || >=20} + deprecated: prom-client has been replaced by @prometheus-io/client + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} - qs@6.15.3: - resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} engines: {node: '>=0.6'} + random-bytes@1.0.0: + resolution: {integrity: sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==} + engines: {node: '>= 0.8'} + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -932,6 +970,9 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -988,6 +1029,9 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + tdigest@0.1.3: + resolution: {integrity: sha512-zbRt+lT+/H4fRItHshczHErVCQnitJk8MfMT24MqFJf3YL7SJJPqGIGeuOdvxXxM/AHFzKBl7WoyaYwqO9s3Kw==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1027,6 +1071,10 @@ packages: engines: {node: '>=14.17'} hasBin: true + uid-safe@2.1.5: + resolution: {integrity: sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==} + engines: {node: '>= 0.8'} + undici-types@7.19.2: resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} @@ -1130,8 +1178,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - zod@4.3.6: - resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zod@4.5.4: + resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} snapshots: @@ -1175,7 +1223,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 @@ -1184,43 +1232,41 @@ snapshots: '@noble/ed25519@3.1.0': {} - '@o3co/auth-provider-core@0.5.3(express@5.2.1)': + '@o3co/auth-provider-core@0.12.0(express@5.2.1)': dependencies: bcrypt: 6.0.0 - jose: 6.2.2 - js-yaml: 4.3.0 - zod: 4.3.6 + jose: 6.2.12 + js-yaml: 5.4.1 + zod: 4.5.4 optionalDependencies: express: 5.2.1 - '@o3co/auth-provider-oauth@0.5.3(express@5.2.1)': + '@o3co/auth-provider-oauth@0.12.0(express-session@1.19.0)(express@5.2.1)': dependencies: - '@o3co/auth-provider-core': 0.5.3(express@5.2.1) + '@o3co/auth-provider-core': 0.12.0(express@5.2.1) accepts: 2.0.0 express: 5.2.1 - jose: 6.2.2 - zod: 4.3.6 + express-session: 1.19.0 + jose: 6.2.12 + zod: 4.5.4 - '@o3co/auth.policy-verifier.builtins@0.3.1': + '@o3co/auth.policy-verifier.builtins@0.8.1': dependencies: - '@o3co/auth.policy-verifier.core': 0.3.1 + '@o3co/auth.policy-verifier.core': 0.8.1 - '@o3co/auth.policy-verifier.core@0.3.1': {} + '@o3co/auth.policy-verifier.core@0.8.1': {} - '@o3co/auth.policy-verifier.server@0.3.1': + '@o3co/auth.policy-verifier.server@0.8.1': dependencies: - '@o3co/auth.policy-verifier.core': 0.3.1 - '@o3co/auth.utils': 0.0.2(express@5.2.1) + '@o3co/auth.policy-verifier.core': 0.8.1 express: 5.2.1 - jose: 6.2.2 - zod: 4.3.6 + jose: 6.2.12 + prom-client: 15.1.3 + zod: 4.5.4 transitivePeerDependencies: - - pino - supports-color - '@o3co/auth.utils@0.0.2(express@5.2.1)': - dependencies: - express: 5.2.1 + '@opentelemetry/api@1.9.1': {} '@oxc-project/types@0.133.0': {} @@ -1264,7 +1310,7 @@ snapshots: dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true '@rolldown/binding-win32-arm64-msvc@1.0.3': @@ -1344,7 +1390,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.5(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.16(@types/node@25.6.0)) + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.16(@types/node@25.6.0)) '@vitest/expect@4.1.5': dependencies: @@ -1409,6 +1455,8 @@ snapshots: node-addon-api: 8.7.0 node-gyp-build: 4.8.4 + bintrees@1.0.2: {} + body-parser@2.3.0: dependencies: bytes: 3.1.2 @@ -1417,13 +1465,13 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 - qs: 6.15.3 + qs: 6.16.0 raw-body: 3.0.2 type-is: 2.1.0 transitivePeerDependencies: - supports-color - brace-expansion@5.0.8: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -1449,10 +1497,16 @@ snapshots: convert-source-map@2.0.0: {} + cookie-signature@1.0.7: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} + debug@2.6.9: + dependencies: + ms: 2.0.0 + debug@4.4.3: dependencies: ms: 2.1.3 @@ -1491,6 +1545,19 @@ snapshots: expect-type@1.3.0: {} + express-session@1.19.0: + dependencies: + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + on-headers: 1.1.0 + parseurl: 1.3.3 + safe-buffer: 5.2.1 + uid-safe: 2.1.5 + transitivePeerDependencies: + - supports-color + express@5.2.1: dependencies: accepts: 2.0.0 @@ -1513,7 +1580,7 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.15.3 + qs: 6.16.0 range-parser: 1.2.1 router: 2.2.0 send: 1.2.1 @@ -1528,9 +1595,9 @@ snapshots: optionalDependencies: picomatch: 4.0.4 - fdir@6.5.0(picomatch@4.0.5): + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: - picomatch: 4.0.5 + picomatch: 4.0.7 finalhandler@2.1.1: dependencies: @@ -1619,11 +1686,13 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + jose@6.2.12: {} + jose@6.2.2: {} js-tokens@10.0.0: {} - js-yaml@4.3.0: + js-yaml@5.4.1: dependencies: argparse: 2.0.1 @@ -1708,13 +1777,15 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.8 + brace-expansion: 5.0.9 minipass@7.1.3: {} + ms@2.0.0: {} + ms@2.1.3: {} - nanoid@3.3.16: {} + nanoid@3.3.18: {} negotiator@1.0.0: {} @@ -1730,6 +1801,8 @@ snapshots: dependencies: ee-first: 1.1.1 + on-headers@1.1.0: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -1751,24 +1824,31 @@ snapshots: picomatch@4.0.4: {} - picomatch@4.0.5: {} + picomatch@4.0.7: {} - postcss@8.5.23: + postcss@8.5.28: dependencies: - nanoid: 3.3.16 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 + prom-client@15.1.3: + dependencies: + '@opentelemetry/api': 1.9.1 + tdigest: 0.1.3 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 - qs@6.15.3: + qs@6.16.0: dependencies: es-define-property: 1.0.1 side-channel: 1.1.1 + random-bytes@1.0.0: {} + range-parser@1.2.1: {} raw-body@3.0.2: @@ -1814,6 +1894,8 @@ snapshots: transitivePeerDependencies: - supports-color + safe-buffer@5.2.1: {} + safer-buffer@2.1.2: {} semver@7.7.4: {} @@ -1887,6 +1969,10 @@ snapshots: dependencies: has-flag: 4.0.0 + tdigest@0.1.3: + dependencies: + bintrees: 1.0.2 + tinybench@2.9.0: {} tinyexec@1.1.1: {} @@ -1898,8 +1984,8 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.5) - picomatch: 4.0.5 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 tinyrainbow@3.1.0: {} @@ -1922,6 +2008,10 @@ snapshots: typescript@5.9.3: {} + uid-safe@2.1.5: + dependencies: + random-bytes: 1.0.0 + undici-types@7.19.2: {} unpipe@1.0.0: {} @@ -1931,15 +2021,15 @@ snapshots: vite@8.0.16(@types/node@25.6.0): dependencies: lightningcss: 1.33.0 - picomatch: 4.0.5 - postcss: 8.5.23 + picomatch: 4.0.7 + postcss: 8.5.28 rolldown: 1.0.3 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 25.6.0 fsevents: 2.3.3 - vitest@4.1.5(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.16(@types/node@25.6.0)): + vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.16(@types/node@25.6.0)): dependencies: '@vitest/expect': 4.1.5 '@vitest/mocker': 4.1.5(vite@8.0.16(@types/node@25.6.0)) @@ -1962,6 +2052,7 @@ snapshots: vite: 8.0.16(@types/node@25.6.0) why-is-node-running: 2.3.0 optionalDependencies: + '@opentelemetry/api': 1.9.1 '@types/node': 25.6.0 '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) transitivePeerDependencies: @@ -1974,4 +2065,4 @@ snapshots: wrappy@1.0.2: {} - zod@4.3.6: {} + zod@4.5.4: {} diff --git a/scripts/smoke-did-grant.mjs b/scripts/smoke-did-grant.mjs index 2254cd5..21c6b14 100644 --- a/scripts/smoke-did-grant.mjs +++ b/scripts/smoke-did-grant.mjs @@ -126,7 +126,7 @@ const child = spawn("node", ["dist/main.mjs"], { env: { ...process.env, OAUTH_JWT_ALGORITHM: "HS256", - OAUTH_JWT_SECRET: "smoke-only-secret", + OAUTH_JWT_SECRET: "smoke.only.secret.at.least.32.bytes.long", OAUTH_JWT_ISSUER: "https://smoke.invalid", HTTP_PORT: String(port), // The DID grant is not OIDC; allow the non-OIDC token path. diff --git a/scripts/smoke-instance.sh b/scripts/smoke-instance.sh index 9047b63..25b288d 100755 --- a/scripts/smoke-instance.sh +++ b/scripts/smoke-instance.sh @@ -21,8 +21,9 @@ fi # Minimal boot config: HS256 needs only a secret; issuer satisfies OIDC wiring. export OAUTH_JWT_ALGORITHM=HS256 -export OAUTH_JWT_SECRET=smoke-only-secret +export OAUTH_JWT_SECRET=smoke.only.secret.at.least.32.bytes.long export OAUTH_JWT_ISSUER=https://smoke.invalid +export OAUTH_JWT_AUDIENCE=https://smoke-api.invalid export HTTP_PORT="$port" # `exec` so $! is the node PID itself, not a subshell wrapper — otherwise