169 lines
21 KiB
Markdown
169 lines
21 KiB
Markdown
# `@suaveplan/jws` — RFC 7515 JSON Web Signature (verify + sign) for the universal tier
|
||
|
||
**Package**: `@suaveplan/jws` (NEW)
|
||
**Status**: PROPOSED
|
||
**Type**: new-package (genesis-original; greenfield — no upstream port)
|
||
**Date**: 2026-05-11
|
||
**Path**: `packages/security/jws/`
|
||
|
||
## Why
|
||
|
||
`@suaveplan/webauthn`'s `android-safetynet` attestation verifier consumes a JWS as its input: the SafetyNet attestation statement is a compact-serialized JWS signed `RS256` whose protected header carries an `x5c` parameter listing the leaf attestation certificate plus Google's intermediate / root attestation certificates. To verify a SafetyNet attestation the verifier must (a) parse the three-segment compact form per RFC 7515 §3.1, (b) extract the `x5c` chain and validate it through `@suaveplan/x509` against Google's pinned attestation root, (c) confirm the leaf public key matches the declared `alg`, (d) recompute `BASE64URL(protected) || "." || BASE64URL(payload)` and run RSASSA-PKCS1-v1_5 verify per RFC 7518 §3.3. The current `webauthn` proposal defers server-side attestation; this package unblocks that workstream and is reusable by every other future signer of JWSes in the monorepo (signed installer manifests, cross-service attestations, signed event envelopes).
|
||
|
||
External alternatives (`jose`, `jsonwebtoken`, `node-forge`) all drag in either Node built-ins, CommonJS interop hazards, or oversize transitive dep trees that would force `webauthn` off the universal tier. An in-house, RFC-cited, zero-dependency implementation lets one canonical surface ship with one error registry, one OTel surface, one Zod boundary, and one set of test vectors anchored on RFC 7515 Appendix A.
|
||
|
||
## What changes
|
||
|
||
### New package: `@suaveplan/jws`
|
||
|
||
- ADDED: `packages/security/jws/` package scaffold (`package.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`, `README.md`).
|
||
- ADDED: `src/base64url/base64url.ts` — `base64urlEncode(bytes): string` and `base64urlDecode(str): Uint8Array` per RFC 7515 Appendix C (no padding, no `+`/`/`). Malformed input throws `JWS.BASE64URL_INVALID`.
|
||
- ADDED: `src/serialization/compact.ts` — `compactDecode(token: string): CompactJws`, `compactVerify(token, key, opts): Promise<VerifyResult>`, `compactSign(payload, key, alg, opts?): Promise<string>` per RFC 7515 §3.1 + §7.1.
|
||
- ADDED: `src/serialization/flat.ts` — `flatDecode(json): FlatJws`, `flatVerify(json, key, opts)`, `flatSign(payload, key, alg, opts?)` per RFC 7515 §3.2 + §7.2.2.
|
||
- ADDED: `src/serialization/general.ts` — `generalDecode(json): GeneralJws`, `generalVerify(json, keyResolver, opts)`, `generalSign(payload, signers[])` per RFC 7515 §3.2 + §7.2.1.
|
||
- ADDED: `src/serialization/serialization.types.ts` — `CompactJws`, `FlatJws`, `GeneralJws`, `JwsSignature`, `JwsSerialization` discriminator.
|
||
- ADDED: `src/header/header.ts` — `parseHeader(b64u): JoseHeader`, `serializeHeader(h): string`, `JoseHeader` Zod schema for the RFC 7515 §4.1 registered parameters (`alg`, `jku`, `jwk`, `kid`, `x5u`, `x5c`, `x5t`, `x5t#S256`, `typ`, `cty`, `crit`).
|
||
- ADDED: `src/header/crit.ts` — `enforceCrit(header, acceptableCriticalHeaders): void`. Empty `crit`, non-array `crit`, or string-array entries not in `acceptableCriticalHeaders` throw `JWS.CRIT_UNRECOGNIZED` / `JWS.CRIT_MALFORMED` per RFC 7515 §4.1.11.
|
||
- ADDED: `src/header/b64.ts` — RFC 7797 `b64` header support. When `b64: false`, `b64` MUST appear in `crit` or `JWS.B64_INVALID` is thrown; the signing input becomes `BASE64URL(protected) || "." || rawPayloadString` per RFC 7797 §3.
|
||
- ADDED: `src/algorithms/registry.ts` — `AlgorithmRegistry` with `register(alg, { verify, sign? })` / `lookup(alg)` / `lookupOrThrow(alg)`. Built-in algorithms: `HS256`/`HS384`/`HS512`, `RS256`/`RS384`/`RS512`, `PS256`/`PS384`/`PS512`, `ES256`/`ES384`/`ES512`, `EdDSA`, `none`. Unknown alg → `JWS.ALG_UNSUPPORTED`.
|
||
- ADDED: `src/algorithms/hmac.ts` — `HS256`/`HS384`/`HS512` per RFC 7518 §3.2; constant-time tag compare via `@suaveplan/crypto-utils.constantTimeEqual`; never `Buffer.compare`.
|
||
- ADDED: `src/algorithms/rsa.ts` — `RS256`/`RS384`/`RS512` per RFC 7518 §3.3 via `@suaveplan/crypto-curve` RSA helpers.
|
||
- ADDED: `src/algorithms/rsa-pss.ts` — `PS256`/`PS384`/`PS512` per RFC 7518 §3.5 with salt length equal to hash output length.
|
||
- ADDED: `src/algorithms/ecdsa.ts` — `ES256`/`ES384`/`ES512` per RFC 7518 §3.4. Signature MUST be fixed-length `R||S`; ASN.1 DER input in the JOSE position → `JWS.SIG_FORMAT_INVALID`.
|
||
- ADDED: `src/algorithms/eddsa.ts` — `EdDSA` per RFC 8037 §3.1 (Ed25519) via `@suaveplan/crypto-curve/ed25519`.
|
||
- ADDED: `src/algorithms/none.ts` — `"none"` per RFC 7518 §3.6. Rejected by default; requires both `algorithms: ["none"]` AND `acceptNone: true` simultaneously, else `JWS.ALG_NONE_DISABLED`.
|
||
- ADDED: `src/keys/jwk.ts` — `jwkToCryptoKey(jwk, alg): CryptoKeyLike` per RFC 7517 §4 + RFC 7518 §6. Validates `kty` / `crv` / `n,e` / `x,y` consistency with the declared `alg`; mismatch → `JWS.JWK_INVALID`.
|
||
- ADDED: `src/keys/x5c.ts` — `extractX5cLeafKey(x5c: string[], alg, opts)`. Delegates chain validation to `@suaveplan/x509.validateChain(certs, { trustAnchors, now })`; this module's responsibility is base64 decoding the chain, extracting the leaf SPKI, and confirming the leaf public-key algorithm matches the declared JOSE `alg`. Chain failure → `JWS.X5C_CHAIN_INVALID`.
|
||
- ADDED: `src/keys/resolver.ts` — `KeyResolver = (kid, header) => Promise<KeyLike | undefined>`. When `header.kid` is present and `opts.key` is omitted, the resolver is awaited; absence → `JWS.KEY_NOT_FOUND`.
|
||
- ADDED: `src/detached/detached.ts` — RFC 7515 §A.5 / Appendix F detached payload helpers; `compactSign(payload, key, alg, { detached: true })` emits a token with empty middle segment; `compactVerify(token, key, { detachedPayload })` accepts the payload out-of-band.
|
||
- ADDED: `src/verify/verify-pipeline.ts` — central `runVerifyPipeline(input, opts)` orchestrator: parse header → enforce `crit` allowlist → enforce `alg` allowlist → enforce `alg !== "none"` unless opted in → resolve key (priority: explicit `opts.key` → `header.jwk` → `header.x5c` leaf → `header.kid` via resolver) → recompute signing input → dispatch to algorithm `verify` → return `VerifyResult`.
|
||
- ADDED: `src/sign/sign-pipeline.ts` — central `runSignPipeline(payload, key, alg, opts)` orchestrator: validate options → resolve algorithm → assemble protected header → compute signing input → dispatch to algorithm `sign` → assemble serialization.
|
||
- ADDED: `src/errors/error-codes.ts` — `defineCodes("JWS", [...])` registry: `MALFORMED`, `HEADER_MALFORMED`, `BASE64URL_INVALID`, `ALG_UNSUPPORTED`, `ALG_NOT_ALLOWED`, `ALG_NONE_DISABLED`, `SIG_INVALID`, `SIG_FORMAT_INVALID`, `JWK_INVALID`, `X5C_CHAIN_INVALID`, `KEY_NOT_FOUND`, `KEY_ALG_MISMATCH`, `CRIT_UNRECOGNIZED`, `CRIT_MALFORMED`, `B64_INVALID`, `INVALID_OPTIONS`, `ABORTED`, `MULTI_SIG_FAILED`. Plus `JwsError extends SuaveplanError<JwsErrorCode>`.
|
||
- ADDED: `src/observability/metrics.ts` — `jws.verify_duration_ms{alg,outcome}` + `jws.sign_duration_ms{alg}` histograms; `jws.verify_total{alg,outcome}` + `jws.sign_total{alg,outcome}` + `jws.error_total{code}` counters. `outcome` bounded by `success | sig_invalid | alg_not_allowed | header_invalid`.
|
||
- ADDED: `src/schemas/schemas.ts` — Zod schemas for `JoseHeader`, `VerifyOptions`, `SignOptions`, `FlatJws`, `GeneralJws`, `JsonWebKey` (per RFC 7517). Validated at every public entry point.
|
||
- ADDED: `src/__benchmarks__/{verify,sign,multi-sig}.bench.ts` — mitata benchmarks for the four most-likely algorithms (`HS256`, `RS256`, `ES256`, `EdDSA`) over a 1 KiB payload, plus multi-sig general decode.
|
||
- ADDED: `src/index.ts` — explicit named re-exports of the public surface (no `export *` in feature barrels per AGENTS.md §6a).
|
||
|
||
### Subpath exports
|
||
|
||
- `.` — root barrel: `compactVerify`, `compactSign`, `compactDecode`, `flatVerify`, `flatSign`, `flatDecode`, `generalVerify`, `generalSign`, `generalDecode`, error registry, OTel.
|
||
- `./serialization/compact` — compact form.
|
||
- `./serialization/flat` — JSON-flattened form.
|
||
- `./serialization/general` — JSON-general multi-signature form.
|
||
- `./header` — `parseHeader`, `enforceCrit`, `JoseHeader` schema.
|
||
- `./algorithms` — `AlgorithmRegistry`, built-in algorithm implementations.
|
||
- `./keys` — `jwkToCryptoKey`, `extractX5cLeafKey`, `KeyResolver` type.
|
||
- `./detached` — RFC 7515 §A.5 detached payload helpers.
|
||
- `./base64url` — `base64urlEncode`, `base64urlDecode`.
|
||
- `./errors` — `JWS_CODES`, `JwsError`, `JwsErrorCode`.
|
||
|
||
### Dependencies
|
||
|
||
- `@suaveplan/x509 workspace:^` — `validateChain(certs, { trustAnchors, now })` for `x5c` header. **Blocking dep**: `x509` MUST land on `genesis` before this package archives. If absent at proposal time, the `x509` migration proposal is opened first.
|
||
- `@suaveplan/crypto-curve workspace:^` — ECDSA P-256/P-384/P-521, RSA, RSA-PSS, Ed25519 verify + sign.
|
||
- `@suaveplan/crypto-hash workspace:^` — HMAC-SHA-256/384/512 for `HS*` algorithms; SHA-2 helpers for pre-hash where the algorithm primitive needs it.
|
||
- `@suaveplan/crypto-utils workspace:^` — `constantTimeEqual`, hex / base64 helpers, `zeroize`.
|
||
- `@suaveplan/error workspace:^` — `defineCodes`, `SuaveplanError` base.
|
||
- `@suaveplan/types workspace:^` — `Clock`, `Rng`, `LoggerLike` shared interfaces.
|
||
- `@opentelemetry/api catalog:observability`.
|
||
- `zod catalog:forms` — boundary validation.
|
||
- `tslib catalog:build`.
|
||
- **No** `jose`, `jsonwebtoken`, `node-forge`, `jws` (npm), `jwa`, `@stablelib/*`, browser globals, or Node built-ins. Pure TypeScript over `Uint8Array` + `DataView` plus the workspace crypto primitives.
|
||
|
||
## Out of scope
|
||
|
||
- **JWE (RFC 7516 JSON Web Encryption)** — encryption is a distinct capability with a separate algorithm registry, separate key-management modes (RSA-OAEP, ECDH-ES, PBES2, etc.), and a four-segment compact form. **Justification**: scoping JWE into this package would double the surface area and introduce algorithm-confusion risk between JWS and JWE primitives. A separate `@suaveplan/jwe` package is the right home; this package's `crit` and header machinery is designed to be reusable when that lands.
|
||
- **JWT issuance / claim validation (RFC 7519)** — JWT is a JWS payload with a specific claim shape (`iss`/`sub`/`aud`/`exp`/`nbf`/`iat`/`jti`). The existing `@suaveplan/jwt-tenant` package already ships JWT issuance; a future generic `@suaveplan/jwt` package would consume `@suaveplan/jws` as a primitive. **Justification**: keeping the JWS layer claim-agnostic lets every JWT-adjacent consumer (CWT-over-JOSE, SD-JWT, signed installer manifests) compose on top without inheriting JWT-specific assumptions.
|
||
- **JWK Set discovery via `jku` / `x5u` URL fetch** — the package parses `jku` and `x5u` header parameters but does NOT fetch them. **Justification**: fetching introduces an IO surface (`fetch`, retry, timeout, TLS pinning) that belongs in a consumer; the package surfaces the parsed URL and lets the caller hydrate via their own HTTP plumbing.
|
||
- **JOSE Web Algorithms not registered in RFC 7518 / RFC 8037** — experimental algorithms (e.g. ML-DSA from draft-ietf-cose-dilithium, secp256k1) are not built-in. Consumers needing them register through `AlgorithmRegistry.register(alg, impl)`. **Justification**: keeping the built-in set anchored on stable RFCs prevents algorithm drift between this package and its consumers.
|
||
|
||
Every other capability called out in the brief (compact + flat + general serializations, all 11 RFC 7518 algorithms + `EdDSA` + opt-in `none`, JWK + `x5c` + `kid` resolution, `crit` enforcement, detached payload, RFC 7797 `b64`, multi-sig general, Zod boundaries, OTel, AbortSignal, 100% coverage) is IN scope for this change. There are no deferrals on anything in scope.
|
||
|
||
## Source mapping
|
||
|
||
This package is genesis-original; no `../suaveplan/enterprise/jws.md` line range exists for any feature. Per the AGENTS.md convention for greenfield packages, the source-of-record is the RFC itself.
|
||
|
||
| Requirement | RFC source | Notes |
|
||
|---|---|---|
|
||
| Compact serialization parse + assemble (3 segments, base64url) | RFC 7515 §3.1, §7.1, Appendix A | Exactly two `.` characters; empty middle segment for detached |
|
||
| JSON Flattened serialization | RFC 7515 §3.2, §7.2.2 | `protected` + optional `header` + `payload` + `signature` |
|
||
| JSON General serialization (multi-sig) | RFC 7515 §3.2, §7.2.1 | `signatures` array; per-signer `protected`/`header`/`signature` |
|
||
| base64url encoding (no padding, no `+`/`/`) | RFC 7515 Appendix C | Strict alphabet; malformed → `BASE64URL_INVALID` |
|
||
| Signing input formation `BASE64URL(protected) \|\| "." \|\| BASE64URL(payload)` | RFC 7515 §5.1 step 5 | When `b64: false`, second segment is raw payload bytes |
|
||
| Mandatory algorithm allowlist at verify time | RFC 7515 §4.1.1 + §5.2 step 6 | Per "alg confusion" CVE class; no default-accept |
|
||
| `alg: "none"` rejection by default | RFC 7518 §3.6 | Explicit two-flag opt-in required |
|
||
| `HS256` / `HS384` / `HS512` | RFC 7518 §3.2 | HMAC + constant-time compare |
|
||
| `RS256` / `RS384` / `RS512` | RFC 7518 §3.3 | RSASSA-PKCS1-v1_5 |
|
||
| `ES256` / `ES384` / `ES512` | RFC 7518 §3.4 | P-256/P-384/P-521; fixed-length `R\|\|S` |
|
||
| `PS256` / `PS384` / `PS512` | RFC 7518 §3.5 | RSASSA-PSS, salt length = hash length |
|
||
| `EdDSA` (Ed25519) | RFC 8037 §3.1, Appendix A.4 | Per RFC 8037 IANA registration |
|
||
| Registered header parameters (`alg`, `jku`, `jwk`, `kid`, `x5u`, `x5c`, `x5t`, `x5t#S256`, `typ`, `cty`, `crit`) | RFC 7515 §4.1 | All parsed; `jku`/`x5u` not fetched (see out-of-scope) |
|
||
| `jwk` header parses to a typed key | RFC 7515 §4.1.3 + RFC 7517 §4 + RFC 7518 §6 | Validates consistency with declared `alg` |
|
||
| `x5c` chain validation via `@suaveplan/x509` | RFC 7515 §4.1.6 | Chain expiry / trust-anchor delegated; leaf SPKI checked here |
|
||
| `kid` resolution via injectable resolver | RFC 7515 §4.1.4 | Caller controls the key-store model |
|
||
| `crit` enforcement against caller allowlist | RFC 7515 §4.1.11 | Empty / non-string-array / unknown entries throw |
|
||
| Detached payload | RFC 7515 §A.5, Appendix F | Empty middle segment; payload supplied out-of-band |
|
||
| `b64` header parameter | RFC 7797 §3 | MUST appear in `crit`; signing input uses raw payload |
|
||
| Algorithm registry for downstream extension | RFC 7518 §3 IANA registry semantics | Built-ins frozen; `register` adds without forking |
|
||
|
||
### Requirements with no source line
|
||
|
||
| Requirement | Justification |
|
||
|---|---|
|
||
| `defineCodes("JWS", [...])` 18-code registry | Genesis convention — every package surfaces typed errors via `@suaveplan/error`. Each code maps to a distinct §4.1 / §5.2 / §6 failure class so callers can branch on root cause. |
|
||
| Zod boundary schemas at every public option / parsed header | Genesis convention (CLAUDE.md §11). Catches caller misuse at the boundary; option misuse never silently degrades into a malformed token. |
|
||
| OTel surface (`jws.verify_duration_ms`, `jws.sign_duration_ms`, `jws.verify_total`, `jws.sign_total`, `jws.error_total`) | Genesis convention — every hot-path package emits histograms + counters with a low-cardinality outcome attribute. |
|
||
| 300-line file budget + 100/100/100/100 coverage + sibling `.md` per impl file | CLAUDE.md global rules. |
|
||
| Property-based round-trip tests (fast-check) per algorithm | CLAUDE.md "no mocks" rule; round-trip is the only test that proves sign + verify agree under arbitrary payloads. |
|
||
| Mitata benchmarks under `src/__benchmarks__/` | CLAUDE.md global benchmark requirement. |
|
||
| Algorithm registry pluggability | Future consumer extensibility — downstream packages registering experimental algorithms without modifying this package. |
|
||
| `AbortSignal` end-to-end | Genesis convention — every async public entry point honors cancellation. |
|
||
|
||
## Verification approach
|
||
|
||
Each requirement carries a `**Verified by:**` line in the spec delta naming concrete `it(...)` titles inside the test files below.
|
||
|
||
- `packages/security/jws/src/base64url/base64url.test.ts` — Appendix C alphabet round-trip + malformed input rejection.
|
||
- `packages/security/jws/src/serialization/compact.test.ts` — three-segment parsing, assembly, malformed segment rejection.
|
||
- `packages/security/jws/src/serialization/compact.spec-conformance.test.ts` — RFC 7515 Appendix A.1–A.4 vectors round-trip byte-identically.
|
||
- `packages/security/jws/src/serialization/flat.test.ts` — JSON-flat decode / verify / sign; Appendix A.7 vector.
|
||
- `packages/security/jws/src/serialization/general.test.ts` — multi-signer encode / decode / verify; Appendix A.6 vector.
|
||
- `packages/security/jws/src/header/header.test.ts` — every RFC 7515 §4.1 registered parameter parses; unknown header parameter survives round-trip.
|
||
- `packages/security/jws/src/header/crit.test.ts` — empty `crit` rejected; non-array rejected; unknown entry rejected; allowlisted entry accepted.
|
||
- `packages/security/jws/src/header/b64.test.ts` — `b64: false` without `crit` rejected; with `crit` round-trips; signing input uses raw payload.
|
||
- `packages/security/jws/src/algorithms/registry.test.ts` — built-ins registered; `register` adds; `lookup` returns registered impl; unknown alg throws.
|
||
- `packages/security/jws/src/algorithms/hmac.test.ts` — `HS256`/`HS384`/`HS512` IANA test vectors; constant-time compare verified by timing-independent property test.
|
||
- `packages/security/jws/src/algorithms/rsa.test.ts` — `RS256`/`RS384`/`RS512` round-trip against RFC 7515 Appendix A.2 vector + IANA vectors.
|
||
- `packages/security/jws/src/algorithms/rsa-pss.test.ts` — `PS256`/`PS384`/`PS512` round-trip; salt-length-mismatch rejection.
|
||
- `packages/security/jws/src/algorithms/ecdsa.test.ts` — `ES256`/`ES384`/`ES512` against RFC 7515 Appendix A.3 + IANA P-384/P-521 vectors; ASN.1 DER signature input rejected with `SIG_FORMAT_INVALID`.
|
||
- `packages/security/jws/src/algorithms/eddsa.test.ts` — RFC 8037 §A.4 vector verifies; round-trip with a generated key pair.
|
||
- `packages/security/jws/src/algorithms/none.test.ts` — default rejection; explicit two-flag opt-in accepts; missing either flag throws.
|
||
- `packages/security/jws/src/keys/jwk.test.ts` — every supported `kty` parses; `alg`/`kty` mismatch throws `JWK_INVALID`.
|
||
- `packages/security/jws/src/keys/x5c.test.ts` — leaf SPKI extracted; chain delegated to `@suaveplan/x509.validateChain` with a real Google attestation chain fixture; invalid chain throws `X5C_CHAIN_INVALID`; leaf-alg mismatch throws `KEY_ALG_MISMATCH`.
|
||
- `packages/security/jws/src/keys/resolver.test.ts` — resolver awaited when `kid` set; resolver returning `undefined` throws `KEY_NOT_FOUND`.
|
||
- `packages/security/jws/src/detached/detached.test.ts` — Appendix F vectors round-trip; missing `detachedPayload` at verify throws.
|
||
- `packages/security/jws/src/verify/verify-pipeline.test.ts` — full pipeline ordering; algorithm allowlist enforced before any crypto runs; `crit` enforced before alg dispatch.
|
||
- `packages/security/jws/src/sign/sign-pipeline.test.ts` — sign pipeline round-trip per algorithm.
|
||
- `packages/security/jws/src/errors/error-codes.test.ts` — every code in the registry is thrown by ≥1 production code path; `expectTypeOf<JwsErrorCode>().toEqualTypeOf<…>()` pins the literal union.
|
||
- `packages/security/jws/src/observability/metrics.test.ts` — histograms + counters recorded under in-memory `MeterProvider`; outcome attribute bounded.
|
||
- `packages/security/jws/src/schemas/schemas.test.ts` — every Zod boundary rejects malformed input with `INVALID_OPTIONS` / `HEADER_MALFORMED`.
|
||
- `packages/security/jws/src/verify/property.test.ts` — fast-check: arbitrary payload, sign → decode → verify is byte-identical per algorithm.
|
||
- `packages/security/jws/src/verify/abort.test.ts` — pre-aborted signal rejects with `JWS.ABORTED`; abort between header parse and signature verify rejects.
|
||
- `packages/security/jws/src/index.test.ts` — every documented subpath export resolves.
|
||
|
||
## Migration impact
|
||
|
||
None — additive only; this is the first delta of a new package. No existing code in `genesis` imports from `@suaveplan/jws`. Pending consumers:
|
||
|
||
- `@suaveplan/webauthn` (in `openspec/changes/webauthn/`) — once server-side `android-safetynet` verification is folded back in scope, that change will add the dep edge on `@suaveplan/jws` plus `@suaveplan/x509`.
|
||
|
||
## Impact
|
||
|
||
### Affected dependents
|
||
|
||
`bunx turbo ls --filter=...@suaveplan/jws` returns no current dependents (the package does not yet exist on `genesis`). Pending consumer:
|
||
|
||
- `@suaveplan/webauthn` — [ ] tests run / [ ] docs reviewed / [x] no action needed in THIS change (reason: webauthn currently defers server-side attestation per its own proposal; the dep edge lands in a follow-up `webauthn` change once SafetyNet verification is folded back in scope).
|
||
|
||
No foundation-package fan-out applies (this is a new universal-tier leaf package; foundation deps `@suaveplan/error` and `@suaveplan/types` are unchanged).
|