@speakspec/astro 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +193 -0
  3. package/dist/index.d.ts +10 -0
  4. package/dist/index.js +12 -0
  5. package/dist/middleware/index.d.ts +1 -0
  6. package/dist/middleware/index.js +2 -0
  7. package/dist/runtime/config.d.ts +46 -0
  8. package/dist/runtime/config.js +81 -0
  9. package/dist/runtime/middleware/ai-bot-detect.d.ts +2 -0
  10. package/dist/runtime/middleware/ai-bot-detect.js +73 -0
  11. package/dist/runtime/server/cache-store.d.ts +8 -0
  12. package/dist/runtime/server/cache-store.js +27 -0
  13. package/dist/runtime/server/routes/webhook.d.ts +2 -0
  14. package/dist/runtime/server/routes/webhook.js +90 -0
  15. package/dist/runtime/server/routes/well-known-aidp.d.ts +2 -0
  16. package/dist/runtime/server/routes/well-known-aidp.js +79 -0
  17. package/dist/runtime/server/routes/well-known-content.d.ts +2 -0
  18. package/dist/runtime/server/routes/well-known-content.js +84 -0
  19. package/dist/runtime/server/routes/well-known-directory.d.ts +2 -0
  20. package/dist/runtime/server/routes/well-known-directory.js +99 -0
  21. package/dist/runtime/server/utils/aidp-verify.d.ts +152 -0
  22. package/dist/runtime/server/utils/aidp-verify.js +332 -0
  23. package/dist/runtime/server/utils/bot-detect.d.ts +26 -0
  24. package/dist/runtime/server/utils/bot-detect.js +75 -0
  25. package/dist/runtime/server/utils/cache.d.ts +35 -0
  26. package/dist/runtime/server/utils/cache.js +80 -0
  27. package/dist/runtime/server/utils/content-registry.d.ts +3 -0
  28. package/dist/runtime/server/utils/content-registry.js +24 -0
  29. package/dist/runtime/server/utils/fetch-content.d.ts +14 -0
  30. package/dist/runtime/server/utils/fetch-content.js +53 -0
  31. package/dist/runtime/server/utils/fetch-directive.d.ts +21 -0
  32. package/dist/runtime/server/utils/fetch-directive.js +52 -0
  33. package/dist/runtime/server/utils/fetch-directory.d.ts +21 -0
  34. package/dist/runtime/server/utils/fetch-directory.js +59 -0
  35. package/dist/runtime/server/utils/hmac-verify.d.ts +37 -0
  36. package/dist/runtime/server/utils/hmac-verify.js +63 -0
  37. package/dist/runtime/server/utils/impression-queue.d.ts +33 -0
  38. package/dist/runtime/server/utils/impression-queue.js +145 -0
  39. package/dist/runtime/server/utils/query.d.ts +14 -0
  40. package/dist/runtime/server/utils/query.js +33 -0
  41. package/dist/runtime/version.d.ts +2 -0
  42. package/dist/runtime/version.js +2 -0
  43. package/package.json +62 -0
  44. package/src/components/AidpContent.astro +23 -0
  45. package/src/components/AidpLinks.astro +10 -0
@@ -0,0 +1,2 @@
1
+ import type { APIRoute } from 'astro';
2
+ export declare function aidpContentRoute(): APIRoute;
@@ -0,0 +1,84 @@
1
+ // Astro API route factory for /.well-known/aidp/content/[id].json
2
+ //
3
+ // Usage:
4
+ // // src/pages/.well-known/aidp/content/[id].json.ts
5
+ // import { aidpContentRoute } from '@speakspec/astro'
6
+ // export const GET = aidpContentRoute()
7
+ //
8
+ // export function getStaticPaths() { return [] } // for SSG hybrid
9
+ import { fetchContentEnvelope } from '../utils/fetch-content';
10
+ import { cacheKey, isFresh, isUpstream4xx, respondWithCache, } from '../utils/cache';
11
+ import { getCacheStore } from '../cache-store';
12
+ import { readConfig, buildCacheControl } from '../../config';
13
+ export function aidpContentRoute() {
14
+ return async ({ request, params }) => {
15
+ const config = readConfig();
16
+ if (!config.entityId) {
17
+ return errorResponse(503, 'AIDP module not configured: missing entityId');
18
+ }
19
+ const FRESH_CACHE_CONTROL = buildCacheControl(config.cache.contentMaxAge, config.cache.contentSwr);
20
+ const STALE_CACHE_CONTROL = buildCacheControl(10, 60);
21
+ const ttlMs = config.cache.ttlSec * 1000;
22
+ const rawId = (params.id ?? '');
23
+ if (!rawId) {
24
+ return errorResponse(400, 'content id is required');
25
+ }
26
+ // Astro file `.well-known/aidp/content/[id].json.ts` already strips
27
+ // the .json suffix from the param. Defensive trim in case the
28
+ // host wires the route differently.
29
+ const contentId = rawId.endsWith('.json') ? rawId.slice(0, -5) : rawId;
30
+ const inboundIfNoneMatch = request.headers.get('if-none-match') ?? undefined;
31
+ const store = getCacheStore();
32
+ const key = cacheKey('content', `${config.entityId}:${contentId}`);
33
+ const cached = await store.getItem(key);
34
+ if (isFresh(cached)) {
35
+ return respondWithCache(cached.etag, cached.payload, FRESH_CACHE_CONTROL, inboundIfNoneMatch);
36
+ }
37
+ const upstreamIfNoneMatch = cached?.etag || undefined;
38
+ let result;
39
+ try {
40
+ result = await fetchContentEnvelope({
41
+ endpoint: config.endpoint,
42
+ entityId: config.entityId,
43
+ contentId,
44
+ apiKey: config.apiKey || undefined,
45
+ ifNoneMatch: upstreamIfNoneMatch,
46
+ });
47
+ }
48
+ catch (err) {
49
+ if (isUpstream4xx(err)) {
50
+ const status = err.response?.status;
51
+ return errorResponse(502, `AIDP upstream rejected the content fetch (${status})`);
52
+ }
53
+ if (cached) {
54
+ return respondWithCache(cached.etag, cached.payload, STALE_CACHE_CONTROL, inboundIfNoneMatch);
55
+ }
56
+ return errorResponse(502, 'AIDP upstream unreachable and no cached payload available');
57
+ }
58
+ if (result.notModified && cached) {
59
+ const refreshed = {
60
+ payload: cached.payload,
61
+ etag: cached.etag,
62
+ expiresAt: Date.now() + ttlMs,
63
+ };
64
+ await store.setItem(key, refreshed);
65
+ return respondWithCache(refreshed.etag, refreshed.payload, FRESH_CACHE_CONTROL, inboundIfNoneMatch);
66
+ }
67
+ if (!result.payload) {
68
+ return errorResponse(502, 'AIDP upstream returned empty payload');
69
+ }
70
+ const fresh = {
71
+ payload: result.payload,
72
+ etag: result.etag,
73
+ expiresAt: Date.now() + ttlMs,
74
+ };
75
+ await store.setItem(key, fresh);
76
+ return respondWithCache(fresh.etag, fresh.payload, FRESH_CACHE_CONTROL, inboundIfNoneMatch);
77
+ };
78
+ }
79
+ function errorResponse(status, message) {
80
+ return new Response(JSON.stringify({ error: { statusCode: status, statusMessage: message } }), {
81
+ status,
82
+ headers: { 'content-type': 'application/json; charset=utf-8' },
83
+ });
84
+ }
@@ -0,0 +1,2 @@
1
+ import type { APIRoute } from 'astro';
2
+ export declare function aidpDirectoryRoute(): APIRoute;
@@ -0,0 +1,99 @@
1
+ // Astro API route factory for /.well-known/aidp/content/
2
+ //
3
+ // Usage:
4
+ // // src/pages/.well-known/aidp/content/index.ts
5
+ // import { aidpDirectoryRoute } from '@speakspec/astro'
6
+ // export const GET = aidpDirectoryRoute()
7
+ import { fetchContentDirectory } from '../utils/fetch-directory';
8
+ import { parsePositiveInt } from '../utils/query';
9
+ import { cacheKey, isFresh, isUpstream4xx, respondWithCache, } from '../utils/cache';
10
+ import { getCacheStore } from '../cache-store';
11
+ import { readConfig, buildCacheControl } from '../../config';
12
+ const ALLOWED_QUERY = new Set(['page', 'page_size', 'type', 'language', 'updated_since']);
13
+ export function aidpDirectoryRoute() {
14
+ return async ({ request, url }) => {
15
+ const config = readConfig();
16
+ if (!config.entityId) {
17
+ return errorResponse(503, 'AIDP module not configured: missing entityId');
18
+ }
19
+ const FRESH_CACHE_CONTROL = buildCacheControl(config.cache.directoryMaxAge, config.cache.directorySwr);
20
+ const STALE_CACHE_CONTROL = buildCacheControl(10, 60);
21
+ const ttlMs = config.cache.ttlSec * 1000;
22
+ for (const k of url.searchParams.keys()) {
23
+ if (!ALLOWED_QUERY.has(k)) {
24
+ return errorResponse(400, `unsupported filter: ${k}`);
25
+ }
26
+ }
27
+ let page;
28
+ let pageSize;
29
+ try {
30
+ page = parsePositiveInt(url.searchParams.get('page'), 'page');
31
+ pageSize = parsePositiveInt(url.searchParams.get('page_size'), 'page_size');
32
+ }
33
+ catch (err) {
34
+ const httpErr = err;
35
+ return errorResponse(httpErr.statusCode ?? 400, httpErr.statusMessage ?? 'invalid query');
36
+ }
37
+ const contentType = url.searchParams.get('type') ?? undefined;
38
+ const language = url.searchParams.get('language') ?? undefined;
39
+ const updatedSince = url.searchParams.get('updated_since') ?? undefined;
40
+ const fingerprint = JSON.stringify({ page, pageSize, contentType, language, updatedSince });
41
+ const inboundIfNoneMatch = request.headers.get('if-none-match') ?? undefined;
42
+ const store = getCacheStore();
43
+ const key = cacheKey('directory', `${config.entityId}:${fingerprint}`);
44
+ const cached = await store.getItem(key);
45
+ if (isFresh(cached)) {
46
+ return respondWithCache(cached.etag, cached.payload, FRESH_CACHE_CONTROL, inboundIfNoneMatch);
47
+ }
48
+ const upstreamIfNoneMatch = cached?.etag || undefined;
49
+ let result;
50
+ try {
51
+ result = await fetchContentDirectory({
52
+ endpoint: config.endpoint,
53
+ entityId: config.entityId,
54
+ apiKey: config.apiKey || undefined,
55
+ page,
56
+ pageSize,
57
+ contentType,
58
+ language,
59
+ updatedSince,
60
+ ifNoneMatch: upstreamIfNoneMatch,
61
+ });
62
+ }
63
+ catch (err) {
64
+ if (isUpstream4xx(err)) {
65
+ const status = err.response?.status;
66
+ return errorResponse(502, `AIDP upstream rejected the directory fetch (${status})`);
67
+ }
68
+ if (cached) {
69
+ return respondWithCache(cached.etag, cached.payload, STALE_CACHE_CONTROL, inboundIfNoneMatch);
70
+ }
71
+ return errorResponse(502, 'AIDP upstream unreachable and no cached payload available');
72
+ }
73
+ if (result.notModified && cached) {
74
+ const refreshed = {
75
+ payload: cached.payload,
76
+ etag: cached.etag,
77
+ expiresAt: Date.now() + ttlMs,
78
+ };
79
+ await store.setItem(key, refreshed);
80
+ return respondWithCache(refreshed.etag, refreshed.payload, FRESH_CACHE_CONTROL, inboundIfNoneMatch);
81
+ }
82
+ if (!result.payload) {
83
+ return errorResponse(502, 'AIDP upstream returned empty payload');
84
+ }
85
+ const fresh = {
86
+ payload: result.payload,
87
+ etag: result.etag,
88
+ expiresAt: Date.now() + ttlMs,
89
+ };
90
+ await store.setItem(key, fresh);
91
+ return respondWithCache(fresh.etag, fresh.payload, FRESH_CACHE_CONTROL, inboundIfNoneMatch);
92
+ };
93
+ }
94
+ function errorResponse(status, message) {
95
+ return new Response(JSON.stringify({ error: { statusCode: status, statusMessage: message } }), {
96
+ status,
97
+ headers: { 'content-type': 'application/json; charset=utf-8' },
98
+ });
99
+ }
@@ -0,0 +1,152 @@
1
+ import { Buffer } from 'node:buffer';
2
+ export interface JWKSKey {
3
+ kid: string;
4
+ kty: string;
5
+ crv?: string;
6
+ x?: string;
7
+ use?: string;
8
+ alg?: string;
9
+ valid_from?: string;
10
+ valid_until?: string;
11
+ rotation?: string;
12
+ }
13
+ export interface JWKS {
14
+ $aidp?: string;
15
+ '@type'?: string;
16
+ issuer?: string;
17
+ keys: JWKSKey[];
18
+ }
19
+ export interface AIDPProof {
20
+ type: string;
21
+ issuer: string;
22
+ key_id: string;
23
+ issued_at: string;
24
+ expires_at: string;
25
+ canonical_url?: string;
26
+ signature: string;
27
+ signed_fields: string[];
28
+ }
29
+ export interface VerifyOk {
30
+ valid: true;
31
+ kid: string;
32
+ issuer: string;
33
+ expiresAt: string;
34
+ signedFields: string[];
35
+ }
36
+ /**
37
+ * Discriminated union of every reason a `verifyBundle` can fail. New
38
+ * reasons must be added here (and to the JSDoc on `verifyBundle`) so
39
+ * callers stay exhaustive at the type level.
40
+ */
41
+ export type VerifyFailReason = 'missing-proof' | 'mixed-proof' | 'multi-proof-not-supported' | 'missing-canonical-url' | 'bad-algorithm' | 'unknown-kid' | 'key-out-of-window' | 'shape-error' | 'canonical-error' | 'bad-key' | 'bad-signature' | 'expired';
42
+ export interface VerifyFail {
43
+ valid: false;
44
+ reason: VerifyFailReason;
45
+ detail?: string;
46
+ }
47
+ export type VerifyResult = VerifyOk | VerifyFail;
48
+ export interface FetchOptions {
49
+ /** SSR / CLI fetch budget (default 5s). */
50
+ timeoutMs?: number;
51
+ /** Optional User-Agent override; CLI sets `<sdk>/<ver> (validator)`. */
52
+ userAgent?: string;
53
+ /** Hard cap on response body size in bytes. Defaults to 1 MB; the
54
+ * helpers below override per endpoint (10 MB for revocation lists).
55
+ * Larger responses abort with `response too large`. */
56
+ maxBytes?: number;
57
+ }
58
+ /**
59
+ * Fetches a JSON document with a hard timeout and a body-size cap so
60
+ * a hostile or misconfigured upstream cannot DoS the SSR worker by
61
+ * streaming an unbounded body. Throws on non-2xx, network failure,
62
+ * size overrun, or invalid JSON. Error messages are human-readable
63
+ * for direct CLI surfacing.
64
+ */
65
+ export declare function fetchJson<T = unknown>(url: string, opts?: FetchOptions): Promise<T>;
66
+ /**
67
+ * Returns the canonical JWKS URL for an issuer. Trims trailing
68
+ * slashes so `${issuer}/.well-known/aidp-keys` never double-slashes.
69
+ */
70
+ export declare function jwksUrl(issuer: string): string;
71
+ /** Returns the canonical revocation list URL for an issuer. */
72
+ export declare function revocationUrl(issuer: string): string;
73
+ export declare function fetchJwks(issuer: string, opts?: FetchOptions): Promise<JWKS>;
74
+ export declare function fetchRevocationList<T = unknown>(issuer: string, opts?: FetchOptions): Promise<T>;
75
+ /** Find an active key by kid. Returns null when not present. */
76
+ export declare function findKey(jwks: JWKS, kid: string): JWKSKey | null;
77
+ /**
78
+ * Resolve a dot-path inside a payload. Returns `null` when any
79
+ * intermediate segment is absent — matches §4.8.4 step 2.
80
+ *
81
+ * §4.8.4 step 2 explicitly excludes `_proof` / `_proofs` from
82
+ * resolution: a malicious signer could otherwise list its own proof
83
+ * block as a signed field and produce a self-referential signature.
84
+ * We refuse such paths up front rather than relying on the spec
85
+ * compliance of upstream signers.
86
+ */
87
+ export declare function resolveDotPath(payload: unknown, path: string): unknown;
88
+ /**
89
+ * Canonical signed-string per §4.8.4:
90
+ * {key_id}\n{issued_at}\n{expires_at}\n{f1}\n{f2}\n...
91
+ * where each `fi` is the RFC 8785 canonical JSON of the value at the
92
+ * i-th `signed_fields` dot-path. For null we emit JSON `null`. For
93
+ * strings we use JSON.stringify (matches the server's
94
+ * SetEscapeHTML(false) since V8's JSON.stringify never HTML-escapes).
95
+ *
96
+ * Phase 3.6 covers string-typed, number/boolean, and null-valued
97
+ * fields — every shape the server currently signs. Objects and arrays
98
+ * throw rather than silently produce a non-canonical encoding (V8's
99
+ * JSON.stringify does not sort keys, so it would diverge from a
100
+ * proper RFC 8785 implementation and the verifier would silently
101
+ * disagree with the signer). Re-enable when JCS lands.
102
+ */
103
+ export declare function buildCanonicalInput(payload: unknown, proof: AIDPProof): Buffer;
104
+ /** Parse the spec's `ed25519:{86-char-base64url}` signature form. */
105
+ export declare function parseSignature(sig: string): Buffer;
106
+ /**
107
+ * Verify an ed25519 signature given a JWK with `{kty:'OKP', crv:'Ed25519', x:'<base64url>'}`.
108
+ * Returns true on match, false otherwise. Throws when the JWK shape
109
+ * is invalid (so the caller can surface "key shape" vs "bad signature"
110
+ * errors distinctly).
111
+ */
112
+ export declare function verifyEd25519(jwk: JWKSKey, message: Buffer, signature: Buffer): boolean;
113
+ /**
114
+ * True when `expiresAt` is at or before `now`. Unparseable inputs
115
+ * fail-safe to true so a malformed `expires_at` is never accepted as
116
+ * fresh — the verifier will surface `reason: 'expired'` and the CLI
117
+ * will exit non-zero rather than silently treating garbage as valid.
118
+ */
119
+ export declare function isExpired(expiresAt: string, now?: Date): boolean;
120
+ /**
121
+ * Verify a signed payload against the issuer's JWKS. The payload must
122
+ * carry a `_proof` block (§4.8.1). Multi-proof `_proofs` (§4.8.5) is
123
+ * recognised but not yet verified — the verifier returns the
124
+ * dedicated `multi-proof-not-supported` reason rather than the
125
+ * misleading `missing-proof`.
126
+ *
127
+ * Failure modes (each returns `{valid:false, reason}`):
128
+ * - missing-proof: payload has no `_proof` and no `_proofs`
129
+ * - mixed-proof: both `_proof` and `_proofs` present (§4.8.5
130
+ * forbids the combination)
131
+ * - multi-proof-not-supported: `_proofs` (plural) present without `_proof`
132
+ * - missing-canonical-url: `_proof.canonical_url` absent (§4.8.2 marks
133
+ * it required)
134
+ * - bad-algorithm: `_proof.type` !== 'ed25519-jws'
135
+ * - unknown-kid: no JWKS entry matches `_proof.key_id`
136
+ * - key-out-of-window: JWKS entry exists but `_proof.issued_at`
137
+ * falls outside [valid_from, valid_until]
138
+ * - bad-signature: ed25519 verification returned false
139
+ * - expired: now > `_proof.expires_at`
140
+ * - bad-key: JWKS entry exists but has unsupported shape
141
+ * - shape-error: signature parsing failed (wrong prefix / length)
142
+ * - canonical-error: canonical-input construction threw (e.g.
143
+ * object-typed signed_field encountered)
144
+ *
145
+ * `_proof.issuer` is intentionally NOT in the canonical signed input
146
+ * per §4.8.4, so an attacker who controls a JWKS at a different
147
+ * issuer URL with the same `kid` could in theory substitute the
148
+ * issuer. Spec-conformant behaviour; callers that don't trust the
149
+ * issuer field must verify it externally (the CLI surfaces it for
150
+ * the operator to check).
151
+ */
152
+ export declare function verifyBundle(payload: Record<string, unknown>, jwks: JWKS, now?: Date): VerifyResult;
@@ -0,0 +1,332 @@
1
+ // AIDP 0.3 verification helpers — shared between the bin/speakspec
2
+ // CLI and runtime callers. Pure functions: HTTP fetches go through the
3
+ // global `fetch` (Node 18+), signature verification through `node:crypto`.
4
+ //
5
+ // Implements:
6
+ // - JWKS fetch + key lookup (§8.11)
7
+ // - canonical signed-string construction per §4.8.4
8
+ // - ed25519 signature verification
9
+ // - bundle expiry check (§4.8.4 verification step 4)
10
+ // - revocation list fetch (§8.13)
11
+ //
12
+ // The verifier is conservative: any failure (network / shape / algo /
13
+ // signature mismatch / expiry) yields a structured `VerifyResult` with
14
+ // a machine-readable reason. Callers decide how to react — the spec
15
+ // (§4.8.4) requires treating failures as "unsigned" rather than
16
+ // rejecting the payload outright, but the CLI surfaces them so a
17
+ // customer running `speakspec verify-bundle` can fix the issue.
18
+ import { createPublicKey, verify as cryptoVerify } from 'node:crypto';
19
+ import { Buffer } from 'node:buffer';
20
+ import { SDK_USER_AGENT } from '../../version';
21
+ const DEFAULT_TIMEOUT_MS = 5000;
22
+ const DEFAULT_MAX_BYTES = 1 * 1024 * 1024;
23
+ const REVOCATION_MAX_BYTES = 10 * 1024 * 1024;
24
+ /**
25
+ * Fetches a JSON document with a hard timeout and a body-size cap so
26
+ * a hostile or misconfigured upstream cannot DoS the SSR worker by
27
+ * streaming an unbounded body. Throws on non-2xx, network failure,
28
+ * size overrun, or invalid JSON. Error messages are human-readable
29
+ * for direct CLI surfacing.
30
+ */
31
+ export async function fetchJson(url, opts = {}) {
32
+ const ctrl = new AbortController();
33
+ const timeout = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
34
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
35
+ try {
36
+ const res = await fetch(url, {
37
+ headers: {
38
+ 'user-agent': opts.userAgent ?? SDK_USER_AGENT,
39
+ 'accept': 'application/json',
40
+ },
41
+ signal: ctrl.signal,
42
+ });
43
+ if (!res.ok) {
44
+ throw new Error(`GET ${url} → ${res.status} ${res.statusText}`);
45
+ }
46
+ // Content-Length pre-check is skipped for encoded responses
47
+ // (gzip, br, ...) — Content-Length there is the compressed size,
48
+ // which can be much smaller than the post-decode body. The
49
+ // streaming cap below catches over-large decoded bodies anyway.
50
+ const declared = Number(res.headers.get('content-length') ?? '');
51
+ const encoded = !!res.headers.get('content-encoding');
52
+ if (!encoded && Number.isFinite(declared) && declared > maxBytes) {
53
+ throw new Error(`GET ${url}: response too large (Content-Length ${declared} > cap ${maxBytes})`);
54
+ }
55
+ const buf = await readCapped(res, maxBytes, url);
56
+ try {
57
+ return JSON.parse(buf.toString('utf8'));
58
+ }
59
+ catch (err) {
60
+ throw new Error(`GET ${url}: invalid JSON (${err.message})`, { cause: err });
61
+ }
62
+ }
63
+ finally {
64
+ clearTimeout(timeout);
65
+ }
66
+ }
67
+ async function readCapped(res, maxBytes, url) {
68
+ const reader = res.body?.getReader();
69
+ if (!reader) {
70
+ const ab = await res.arrayBuffer();
71
+ if (ab.byteLength > maxBytes) {
72
+ throw new Error(`GET ${url}: response too large (${ab.byteLength} > cap ${maxBytes})`);
73
+ }
74
+ return Buffer.from(ab);
75
+ }
76
+ const chunks = [];
77
+ let total = 0;
78
+ for (;;) {
79
+ const { done, value } = await reader.read();
80
+ if (done)
81
+ break;
82
+ total += value.byteLength;
83
+ if (total > maxBytes) {
84
+ try {
85
+ await reader.cancel();
86
+ }
87
+ catch { /* ignore — already aborted */ }
88
+ throw new Error(`GET ${url}: response too large (>${maxBytes} bytes)`);
89
+ }
90
+ chunks.push(Buffer.from(value));
91
+ }
92
+ return Buffer.concat(chunks);
93
+ }
94
+ /**
95
+ * Returns the canonical JWKS URL for an issuer. Trims trailing
96
+ * slashes so `${issuer}/.well-known/aidp-keys` never double-slashes.
97
+ */
98
+ export function jwksUrl(issuer) {
99
+ return `${stripTrailingSlash(issuer)}/.well-known/aidp-keys`;
100
+ }
101
+ /** Returns the canonical revocation list URL for an issuer. */
102
+ export function revocationUrl(issuer) {
103
+ return `${stripTrailingSlash(issuer)}/.well-known/aidp-revocation`;
104
+ }
105
+ export async function fetchJwks(issuer, opts = {}) {
106
+ return fetchJson(jwksUrl(issuer), opts);
107
+ }
108
+ export async function fetchRevocationList(issuer, opts = {}) {
109
+ return fetchJson(revocationUrl(issuer), { maxBytes: REVOCATION_MAX_BYTES, ...opts });
110
+ }
111
+ /** Find an active key by kid. Returns null when not present. */
112
+ export function findKey(jwks, kid) {
113
+ if (!jwks?.keys)
114
+ return null;
115
+ return jwks.keys.find(k => k.kid === kid) ?? null;
116
+ }
117
+ /**
118
+ * Resolve a dot-path inside a payload. Returns `null` when any
119
+ * intermediate segment is absent — matches §4.8.4 step 2.
120
+ *
121
+ * §4.8.4 step 2 explicitly excludes `_proof` / `_proofs` from
122
+ * resolution: a malicious signer could otherwise list its own proof
123
+ * block as a signed field and produce a self-referential signature.
124
+ * We refuse such paths up front rather than relying on the spec
125
+ * compliance of upstream signers.
126
+ */
127
+ export function resolveDotPath(payload, path) {
128
+ if (payload == null)
129
+ return null;
130
+ if (path === '_proof' || path === '_proofs' || path.startsWith('_proof.') || path.startsWith('_proofs.')) {
131
+ return null;
132
+ }
133
+ let cur = payload;
134
+ for (const seg of path.split('.')) {
135
+ if (cur && typeof cur === 'object' && !Array.isArray(cur) && seg in cur) {
136
+ cur = cur[seg];
137
+ }
138
+ else {
139
+ return null;
140
+ }
141
+ }
142
+ return cur ?? null;
143
+ }
144
+ /**
145
+ * Canonical signed-string per §4.8.4:
146
+ * {key_id}\n{issued_at}\n{expires_at}\n{f1}\n{f2}\n...
147
+ * where each `fi` is the RFC 8785 canonical JSON of the value at the
148
+ * i-th `signed_fields` dot-path. For null we emit JSON `null`. For
149
+ * strings we use JSON.stringify (matches the server's
150
+ * SetEscapeHTML(false) since V8's JSON.stringify never HTML-escapes).
151
+ *
152
+ * Phase 3.6 covers string-typed, number/boolean, and null-valued
153
+ * fields — every shape the server currently signs. Objects and arrays
154
+ * throw rather than silently produce a non-canonical encoding (V8's
155
+ * JSON.stringify does not sort keys, so it would diverge from a
156
+ * proper RFC 8785 implementation and the verifier would silently
157
+ * disagree with the signer). Re-enable when JCS lands.
158
+ */
159
+ export function buildCanonicalInput(payload, proof) {
160
+ const parts = [proof.key_id, proof.issued_at, proof.expires_at];
161
+ for (const path of proof.signed_fields) {
162
+ const val = resolveDotPath(payload, path);
163
+ parts.push(canonicalJson(val, path));
164
+ }
165
+ return Buffer.from(parts.join('\n'), 'utf8');
166
+ }
167
+ function canonicalJson(v, path) {
168
+ if (v === null || v === undefined)
169
+ return 'null';
170
+ const t = typeof v;
171
+ if (t === 'string' || t === 'number' || t === 'boolean')
172
+ return JSON.stringify(v);
173
+ throw new Error(`canonical JSON for non-scalar field "${path}" not supported (RFC 8785 JCS for objects/arrays not yet implemented)`);
174
+ }
175
+ /** Parse the spec's `ed25519:{86-char-base64url}` signature form. */
176
+ export function parseSignature(sig) {
177
+ if (typeof sig !== 'string' || !sig.startsWith('ed25519:')) {
178
+ throw new Error('signature must use the `ed25519:` prefix per §4.8.1');
179
+ }
180
+ const b64 = sig.slice('ed25519:'.length);
181
+ if (b64.length !== 86) {
182
+ throw new Error(`signature payload must be exactly 86 base64url chars, got ${b64.length}`);
183
+ }
184
+ const buf = Buffer.from(base64urlToStandard(b64), 'base64');
185
+ if (buf.length !== 64) {
186
+ throw new Error(`decoded signature must be 64 bytes, got ${buf.length}`);
187
+ }
188
+ return buf;
189
+ }
190
+ function base64urlToStandard(s) {
191
+ // Inputs reach here only after the strict 86-char length check in
192
+ // parseSignature, so the padding is always exactly two `=` (since
193
+ // 86 % 4 === 2). Pad explicitly to satisfy strict base64 decoders
194
+ // (Deno, Bun, browser polyfills) that reject unpadded input even
195
+ // when the byte length is otherwise valid.
196
+ return s.replace(/-/g, '+').replace(/_/g, '/') + '==';
197
+ }
198
+ /**
199
+ * Verify an ed25519 signature given a JWK with `{kty:'OKP', crv:'Ed25519', x:'<base64url>'}`.
200
+ * Returns true on match, false otherwise. Throws when the JWK shape
201
+ * is invalid (so the caller can surface "key shape" vs "bad signature"
202
+ * errors distinctly).
203
+ */
204
+ export function verifyEd25519(jwk, message, signature) {
205
+ if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519' || !jwk.x) {
206
+ throw new Error(`unsupported JWK: kty=${jwk.kty} crv=${jwk.crv}`);
207
+ }
208
+ const publicKey = createPublicKey({ key: { kty: jwk.kty, crv: jwk.crv, x: jwk.x }, format: 'jwk' });
209
+ return cryptoVerify(null, message, publicKey, signature);
210
+ }
211
+ /**
212
+ * True when `expiresAt` is at or before `now`. Unparseable inputs
213
+ * fail-safe to true so a malformed `expires_at` is never accepted as
214
+ * fresh — the verifier will surface `reason: 'expired'` and the CLI
215
+ * will exit non-zero rather than silently treating garbage as valid.
216
+ */
217
+ export function isExpired(expiresAt, now = new Date()) {
218
+ const t = Date.parse(expiresAt);
219
+ if (Number.isNaN(t))
220
+ return true;
221
+ return t <= now.getTime();
222
+ }
223
+ /**
224
+ * Verify a signed payload against the issuer's JWKS. The payload must
225
+ * carry a `_proof` block (§4.8.1). Multi-proof `_proofs` (§4.8.5) is
226
+ * recognised but not yet verified — the verifier returns the
227
+ * dedicated `multi-proof-not-supported` reason rather than the
228
+ * misleading `missing-proof`.
229
+ *
230
+ * Failure modes (each returns `{valid:false, reason}`):
231
+ * - missing-proof: payload has no `_proof` and no `_proofs`
232
+ * - mixed-proof: both `_proof` and `_proofs` present (§4.8.5
233
+ * forbids the combination)
234
+ * - multi-proof-not-supported: `_proofs` (plural) present without `_proof`
235
+ * - missing-canonical-url: `_proof.canonical_url` absent (§4.8.2 marks
236
+ * it required)
237
+ * - bad-algorithm: `_proof.type` !== 'ed25519-jws'
238
+ * - unknown-kid: no JWKS entry matches `_proof.key_id`
239
+ * - key-out-of-window: JWKS entry exists but `_proof.issued_at`
240
+ * falls outside [valid_from, valid_until]
241
+ * - bad-signature: ed25519 verification returned false
242
+ * - expired: now > `_proof.expires_at`
243
+ * - bad-key: JWKS entry exists but has unsupported shape
244
+ * - shape-error: signature parsing failed (wrong prefix / length)
245
+ * - canonical-error: canonical-input construction threw (e.g.
246
+ * object-typed signed_field encountered)
247
+ *
248
+ * `_proof.issuer` is intentionally NOT in the canonical signed input
249
+ * per §4.8.4, so an attacker who controls a JWKS at a different
250
+ * issuer URL with the same `kid` could in theory substitute the
251
+ * issuer. Spec-conformant behaviour; callers that don't trust the
252
+ * issuer field must verify it externally (the CLI surfaces it for
253
+ * the operator to check).
254
+ */
255
+ export function verifyBundle(payload, jwks, now = new Date()) {
256
+ const proof = payload?._proof;
257
+ const proofs = payload?._proofs;
258
+ if (proof && proofs !== undefined) {
259
+ return { valid: false, reason: 'mixed-proof', detail: '§4.8.5 forbids `_proof` and `_proofs` coexisting' };
260
+ }
261
+ if (!proof && Array.isArray(proofs) && proofs.length > 0) {
262
+ return { valid: false, reason: 'multi-proof-not-supported', detail: '§4.8.5 multi-proof verification is not yet implemented' };
263
+ }
264
+ if (!proof)
265
+ return { valid: false, reason: 'missing-proof' };
266
+ if (proof.type !== 'ed25519-jws') {
267
+ return { valid: false, reason: 'bad-algorithm', detail: `unsupported proof type: ${proof.type}` };
268
+ }
269
+ if (typeof proof.canonical_url !== 'string' || proof.canonical_url.length === 0) {
270
+ return { valid: false, reason: 'missing-canonical-url', detail: '§4.8.2 requires _proof.canonical_url' };
271
+ }
272
+ const key = findKey(jwks, proof.key_id);
273
+ if (!key) {
274
+ return { valid: false, reason: 'unknown-kid', detail: `kid=${proof.key_id} not found in JWKS` };
275
+ }
276
+ // §8.11: JWKS already excludes revoked keys, but a stale cached
277
+ // copy or a misconfigured trust provider could still surface a key
278
+ // outside its [valid_from, valid_until] window. Reject if the
279
+ // signature was issued outside the key's validity period.
280
+ const issuedAtMs = Date.parse(proof.issued_at);
281
+ if (!Number.isNaN(issuedAtMs)) {
282
+ if (key.valid_from) {
283
+ const fromMs = Date.parse(key.valid_from);
284
+ if (!Number.isNaN(fromMs) && issuedAtMs < fromMs) {
285
+ return { valid: false, reason: 'key-out-of-window', detail: `issued_at=${proof.issued_at} before key valid_from=${key.valid_from}` };
286
+ }
287
+ }
288
+ if (key.valid_until) {
289
+ const untilMs = Date.parse(key.valid_until);
290
+ if (!Number.isNaN(untilMs) && issuedAtMs > untilMs) {
291
+ return { valid: false, reason: 'key-out-of-window', detail: `issued_at=${proof.issued_at} after key valid_until=${key.valid_until}` };
292
+ }
293
+ }
294
+ }
295
+ let signature;
296
+ try {
297
+ signature = parseSignature(proof.signature);
298
+ }
299
+ catch (err) {
300
+ return { valid: false, reason: 'shape-error', detail: err.message };
301
+ }
302
+ let message;
303
+ try {
304
+ message = buildCanonicalInput(payload, proof);
305
+ }
306
+ catch (err) {
307
+ return { valid: false, reason: 'canonical-error', detail: err.message };
308
+ }
309
+ let ok;
310
+ try {
311
+ ok = verifyEd25519(key, message, signature);
312
+ }
313
+ catch (err) {
314
+ return { valid: false, reason: 'bad-key', detail: err.message };
315
+ }
316
+ if (!ok) {
317
+ return { valid: false, reason: 'bad-signature' };
318
+ }
319
+ if (isExpired(proof.expires_at, now)) {
320
+ return { valid: false, reason: 'expired', detail: `expires_at=${proof.expires_at}` };
321
+ }
322
+ return {
323
+ valid: true,
324
+ kid: proof.key_id,
325
+ issuer: proof.issuer,
326
+ expiresAt: proof.expires_at,
327
+ signedFields: proof.signed_fields,
328
+ };
329
+ }
330
+ function stripTrailingSlash(s) {
331
+ return s.endsWith('/') ? s.slice(0, -1) : s;
332
+ }