@fora-protocol/sdk 1.0.3

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 (115) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +37 -0
  3. package/gen/ts/vocab/functiontokens.js +78 -0
  4. package/gen/ts/vocab/functiontokens.ts +84 -0
  5. package/gen/ts/vocab/geographytokens.js +30 -0
  6. package/gen/ts/vocab/geographytokens.ts +37 -0
  7. package/gen/ts/vocab/pricingunits.js +56 -0
  8. package/gen/ts/vocab/pricingunits.ts +63 -0
  9. package/gen/ts/vocab/quotametrics.js +40 -0
  10. package/gen/ts/vocab/quotametrics.ts +47 -0
  11. package/gen/ts/vocab/usertypes.js +40 -0
  12. package/gen/ts/vocab/usertypes.ts +46 -0
  13. package/gen/ts/wire/base.js +245 -0
  14. package/gen/ts/wire/base.ts +255 -0
  15. package/gen/ts/wire/names.js +41 -0
  16. package/gen/ts/wire/names.ts +42 -0
  17. package/gen/ts/wire/schemas.js +111 -0
  18. package/gen/ts/wire/schemas.ts +216 -0
  19. package/package.json +164 -0
  20. package/sdk/ts/client/content.d.ts +74 -0
  21. package/sdk/ts/client/content.js +466 -0
  22. package/sdk/ts/client/errors.d.ts +79 -0
  23. package/sdk/ts/client/errors.js +148 -0
  24. package/sdk/ts/client/index.d.ts +241 -0
  25. package/sdk/ts/client/index.js +740 -0
  26. package/sdk/ts/client/route.d.ts +41 -0
  27. package/sdk/ts/client/route.js +107 -0
  28. package/sdk/ts/client/send.d.ts +64 -0
  29. package/sdk/ts/client/send.js +142 -0
  30. package/sdk/ts/client/transport.d.ts +187 -0
  31. package/sdk/ts/client/transport.js +381 -0
  32. package/sdk/ts/core/multisig-parse.d.ts +47 -0
  33. package/sdk/ts/core/multisig-parse.js +234 -0
  34. package/sdk/ts/core/sign-request.d.ts +83 -0
  35. package/sdk/ts/core/sign-request.js +146 -0
  36. package/sdk/ts/core/sign.d.ts +31 -0
  37. package/sdk/ts/core/sign.js +82 -0
  38. package/sdk/ts/core/signing-transport.d.ts +75 -0
  39. package/sdk/ts/core/signing-transport.js +172 -0
  40. package/sdk/ts/core/verifier.d.ts +177 -0
  41. package/sdk/ts/core/verifier.js +269 -0
  42. package/sdk/ts/core/verify-multisig-request.d.ts +72 -0
  43. package/sdk/ts/core/verify-multisig-request.js +112 -0
  44. package/sdk/ts/core/verify-request.d.ts +181 -0
  45. package/sdk/ts/core/verify-request.js +284 -0
  46. package/sdk/ts/core/window.d.ts +22 -0
  47. package/sdk/ts/core/window.js +43 -0
  48. package/sdk/ts/core/wire-canon.d.ts +20 -0
  49. package/sdk/ts/core/wire-canon.js +174 -0
  50. package/sdk/ts/hono/middleware.d.ts +38 -0
  51. package/sdk/ts/hono/middleware.js +64 -0
  52. package/sdk/ts/resolvers/errors.d.ts +130 -0
  53. package/sdk/ts/resolvers/errors.js +150 -0
  54. package/sdk/ts/resolvers/http.d.ts +98 -0
  55. package/sdk/ts/resolvers/http.js +274 -0
  56. package/sdk/ts/resolvers/index.d.ts +9 -0
  57. package/sdk/ts/resolvers/index.js +15 -0
  58. package/sdk/ts/resolvers/jwks.d.ts +2 -0
  59. package/sdk/ts/resolvers/jwks.js +43 -0
  60. package/sdk/ts/resolvers/offer-key-cache.d.ts +68 -0
  61. package/sdk/ts/resolvers/offer-key-cache.js +152 -0
  62. package/sdk/ts/resolvers/registration-requirements.d.ts +47 -0
  63. package/sdk/ts/resolvers/registration-requirements.js +220 -0
  64. package/sdk/ts/resolvers/ssrf.d.ts +28 -0
  65. package/sdk/ts/resolvers/ssrf.js +235 -0
  66. package/sdk/ts/resolvers/static.d.ts +7 -0
  67. package/sdk/ts/resolvers/static.js +16 -0
  68. package/sdk/ts/resolvers/wba.d.ts +134 -0
  69. package/sdk/ts/resolvers/wba.js +506 -0
  70. package/sdk/ts/resolvers/wellknown.d.ts +28 -0
  71. package/sdk/ts/resolvers/wellknown.js +169 -0
  72. package/sdk/ts/src/acceptance.d.ts +46 -0
  73. package/sdk/ts/src/acceptance.js +162 -0
  74. package/sdk/ts/src/base64url.d.ts +19 -0
  75. package/sdk/ts/src/base64url.js +55 -0
  76. package/sdk/ts/src/crossfield.d.ts +28 -0
  77. package/sdk/ts/src/crossfield.js +215 -0
  78. package/sdk/ts/src/endpoint-rule.d.ts +30 -0
  79. package/sdk/ts/src/endpoint-rule.js +63 -0
  80. package/sdk/ts/src/errordetail.d.ts +115 -0
  81. package/sdk/ts/src/errordetail.js +256 -0
  82. package/sdk/ts/src/hashurl.d.ts +5 -0
  83. package/sdk/ts/src/hashurl.js +15 -0
  84. package/sdk/ts/src/host-ref.d.ts +70 -0
  85. package/sdk/ts/src/host-ref.js +320 -0
  86. package/sdk/ts/src/hosts.d.ts +138 -0
  87. package/sdk/ts/src/hosts.js +214 -0
  88. package/sdk/ts/src/idempotency.d.ts +11 -0
  89. package/sdk/ts/src/idempotency.js +27 -0
  90. package/sdk/ts/src/jsondepth.d.ts +27 -0
  91. package/sdk/ts/src/jsondepth.js +77 -0
  92. package/sdk/ts/src/licenseterm.d.ts +113 -0
  93. package/sdk/ts/src/licenseterm.js +433 -0
  94. package/sdk/ts/src/money.d.ts +21 -0
  95. package/sdk/ts/src/money.js +60 -0
  96. package/sdk/ts/src/offer-sign.d.ts +11 -0
  97. package/sdk/ts/src/offer-sign.js +31 -0
  98. package/sdk/ts/src/opaque-url.d.ts +3 -0
  99. package/sdk/ts/src/opaque-url.js +20 -0
  100. package/sdk/ts/src/pop.d.ts +51 -0
  101. package/sdk/ts/src/pop.js +166 -0
  102. package/sdk/ts/src/regschema.d.ts +229 -0
  103. package/sdk/ts/src/regschema.js +1315 -0
  104. package/sdk/ts/src/scopes.d.ts +20 -0
  105. package/sdk/ts/src/scopes.js +45 -0
  106. package/sdk/ts/src/signurl.d.ts +23 -0
  107. package/sdk/ts/src/signurl.js +107 -0
  108. package/sdk/ts/src/thumbprint.d.ts +14 -0
  109. package/sdk/ts/src/thumbprint.js +24 -0
  110. package/sdk/ts/src/verify.d.ts +49 -0
  111. package/sdk/ts/src/verify.js +109 -0
  112. package/sdk/ts/src/wire-names.d.ts +1 -0
  113. package/sdk/ts/src/wire-names.js +16 -0
  114. package/sdk/ts/src/wire.d.ts +68 -0
  115. package/sdk/ts/src/wire.js +114 -0
@@ -0,0 +1,83 @@
1
+ export declare const COVERED_COMPONENTS: readonly ["@method", "@target-uri", "content-digest", "authorization", "signature-agent"];
2
+ /** Inputs for signRequest. All fields required; signatureAgent is "" to pin its absence. */
3
+ export interface SignRequestOptions {
4
+ method: string;
5
+ url: string;
6
+ body: Uint8Array<ArrayBuffer>;
7
+ authorization: string;
8
+ signatureAgent: string;
9
+ keyid: string;
10
+ created: number;
11
+ expires: number;
12
+ }
13
+ /**
14
+ * The RFC 9421 artifacts a signed request carries, plus the covered base bytes.
15
+ *
16
+ * EVERY covered header is here, at the value that entered the base — the two whose value
17
+ * may be empty included. A verifier rebuilds the base from the request it received, so a
18
+ * covered value bound but never sent is not bound at all; the oracle's signer reaches the
19
+ * same place by mutating the request it was handed (helpers.SignRequest), which is why it
20
+ * has no such field to omit. See docs/design-history.md, "A covered header the peer never
21
+ * receives is not bound".
22
+ */
23
+ export interface SignedRequest {
24
+ contentDigest: string;
25
+ signatureInput: string;
26
+ signature: string;
27
+ signatureBase: string;
28
+ /** Echoed from the input so a caller attaching what this returns sends what was
29
+ * signed. Empty is a value, not an absence. */
30
+ authorization: string;
31
+ /** The signer's WBA directory, echoed for the same reason. Empty is the
32
+ * static-bootstrap case and is still carried. */
33
+ signatureAgent: string;
34
+ }
35
+ export declare function contentDigest(body: Uint8Array<ArrayBuffer>): Promise<string>;
36
+ export interface ChainLink {
37
+ token: string;
38
+ value: string;
39
+ }
40
+ /** The covered request field values a 5-component FORA signature base is built over. */
41
+ export interface RequestBaseFields {
42
+ method: string;
43
+ url: string;
44
+ digestHeader: string;
45
+ authorization: string;
46
+ signatureAgent: string;
47
+ }
48
+ /**
49
+ * The RFC 9421 signature base over the FORA 5-component covered set: one
50
+ * `"name": value` line per component, joined by "\n", terminated by
51
+ * `"@signature-params": <sigParams>` with NO trailing newline. Empty
52
+ * authorization/signature-agent still render (name + ": " + "") — the trailing
53
+ * space is part of the covered bytes and must never be trimmed. Exported and
54
+ * shared with the verify sibling (core/verify-request.ts) so sign and verify
55
+ * reconstruct byte-identical bases; `sigParams` is the VERBATIM inner-list +
56
+ * params tail (the signer's own on sign, the wire's on verify).
57
+ */
58
+ export declare function buildRequestSignatureBase(fields: RequestBaseFields, sigParams: string, chainLink?: ChainLink): string;
59
+ /**
60
+ * signRequest signs opts over the FORA 5-component covered set with privKey and
61
+ * returns the RFC 9421 Content-Digest, Signature-Input, and Signature header
62
+ * values plus the exact signature base the signature covers. The Signature is
63
+ * `sig1=:<STANDARD-base64(sig)>:` (NOT b64url — do not unify with the thumbprint
64
+ * encoding); Signature-Input is `sig1=` + the same inner the base carries.
65
+ */
66
+ export declare function signRequest(privKey: CryptoKey, opts: SignRequestOptions): Promise<SignedRequest>;
67
+ /** A request's prior signature state — the Signature-Input / Signature header
68
+ * values already on the request (both "" for an unsigned request). */
69
+ export interface PriorSignatures {
70
+ signatureInput: string;
71
+ signature: string;
72
+ }
73
+ /**
74
+ * appendSignature chains sig(N+1) onto `prev` WITHOUT disturbing existing members
75
+ * (forwarding chain), the TS port of Go helpers.AppendSignature. It finds
76
+ * the next label sig(N+1) and predecessor sigN, binds the FORA base plus a
77
+ * `"signature";key="sigN"` link whose value is `:<std-base64(decoded predecessor
78
+ * sig bytes)>:` (re-encoded canonically, NOT a wire splice), Ed25519-signs, and
79
+ * returns the APPENDED Signature-Input / Signature strings. Appending to an
80
+ * unsigned request (empty prev) produces a sig1 byte-for-byte identical to
81
+ * signRequest — single-sig is the N=1 case.
82
+ */
83
+ export declare function appendSignature(privKey: CryptoKey, prev: PriorSignatures, opts: SignRequestOptions): Promise<SignedRequest>;
@@ -0,0 +1,146 @@
1
+ // sdk/ts outbound RFC 9421 request signer over the full 5-component FORA covered
2
+ // set (@method @target-uri content-digest authorization signature-agent) — the
3
+ // TS sibling of Go helpers.SignRequest and Python httpsig.sign_request. It signs
4
+ // byte-identical to the shared Go oracle so a request signed in TS verifies
5
+ // unchanged at any Go/Python broker/exchange.
6
+ //
7
+ // This is the 5-component sibling of core/sign.ts::signInbound (the 2-component
8
+ // GET PoP). It does NOT reuse pop.ts::signatureBase (hardcoded to 2 components);
9
+ // it renders its own base, mirroring Python fora_sdk/httpsig.py::sign_request.
10
+ //
11
+ // created/expires are INJECTED unix seconds (L1-pure, no wall clock).
12
+ // authorization + signature-agent are ALWAYS bound — an empty string still
13
+ // renders (with the load-bearing trailing space), so a later token/directory
14
+ // injection cannot piggy-back the signature. Values pass through verbatim: the
15
+ // caller supplies an absolute @target-uri; the signer never normalizes it.
16
+ import { opaqueUrl } from "../src/opaque-url.js";
17
+ import { maxSigLabelN, signatureBytesByLabel, } from "./multisig-parse.js";
18
+ import { stdBase64 } from "./sign.js";
19
+ // The FORA request covered set: exactly these five, in this order. No conditional
20
+ // biscuit component — it is bound only when an entitlement header is present,
21
+ // which this signer's inputs never carry. Exported so the verify sibling
22
+ // (core/verify-request.ts) enforces the SAME required set without duplicating it.
23
+ export const COVERED_COMPONENTS = [
24
+ "@method",
25
+ "@target-uri",
26
+ "content-digest",
27
+ "authorization",
28
+ "signature-agent",
29
+ ];
30
+ // RFC 9530 Content-Digest header value: `sha-256=:<STANDARD-base64(SHA-256)>:`.
31
+ // Exported so the verify sibling recomputes the digest over the exact body bytes
32
+ // and compares against the covered Content-Digest header byte-for-byte.
33
+ export async function contentDigest(body) {
34
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", body));
35
+ return `sha-256=:${stdBase64(digest)}:`;
36
+ }
37
+ // The @signature-params inner list: the covered components then keyid/alg/
38
+ // created/expires, RFC 9421 order — byte-identical to Go signatureInputInner. An
39
+ // optional chain-link token appends as the final covered component (sigN, N>1),
40
+ // defaulting absent so the single-sig (N=1) inner list stays byte-identical.
41
+ function signatureParams(keyid, created, expires, chainLinkToken) {
42
+ const tokens = COVERED_COMPONENTS.map((c) => `"${c}"`);
43
+ if (chainLinkToken !== undefined)
44
+ tokens.push(chainLinkToken);
45
+ const covered = tokens.join(" ");
46
+ return `(${covered});keyid="${keyid}";alg="ed25519";created=${created};expires=${expires}`;
47
+ }
48
+ /**
49
+ * The RFC 9421 signature base over the FORA 5-component covered set: one
50
+ * `"name": value` line per component, joined by "\n", terminated by
51
+ * `"@signature-params": <sigParams>` with NO trailing newline. Empty
52
+ * authorization/signature-agent still render (name + ": " + "") — the trailing
53
+ * space is part of the covered bytes and must never be trimmed. Exported and
54
+ * shared with the verify sibling (core/verify-request.ts) so sign and verify
55
+ * reconstruct byte-identical bases; `sigParams` is the VERBATIM inner-list +
56
+ * params tail (the signer's own on sign, the wire's on verify).
57
+ */
58
+ export function buildRequestSignatureBase(fields, sigParams, chainLink) {
59
+ const lines = [
60
+ `"@method": ${fields.method.toUpperCase()}`,
61
+ `"@target-uri": ${opaqueUrl(fields.url)}`,
62
+ `"content-digest": ${fields.digestHeader}`,
63
+ `"authorization": ${fields.authorization}`,
64
+ `"signature-agent": ${fields.signatureAgent}`,
65
+ ];
66
+ // The chain-link line is the LAST covered component BEFORE @signature-params
67
+ // (Go buildSignatureBase renders params.Covered in order, chain link last).
68
+ if (chainLink !== undefined) {
69
+ lines.push(`${chainLink.token}: ${chainLink.value}`);
70
+ }
71
+ lines.push(`"@signature-params": ${sigParams}`);
72
+ return lines.join("\n");
73
+ }
74
+ /**
75
+ * signRequest signs opts over the FORA 5-component covered set with privKey and
76
+ * returns the RFC 9421 Content-Digest, Signature-Input, and Signature header
77
+ * values plus the exact signature base the signature covers. The Signature is
78
+ * `sig1=:<STANDARD-base64(sig)>:` (NOT b64url — do not unify with the thumbprint
79
+ * encoding); Signature-Input is `sig1=` + the same inner the base carries.
80
+ */
81
+ export async function signRequest(privKey, opts) {
82
+ const digestHeader = await contentDigest(opts.body);
83
+ const sigParams = signatureParams(opts.keyid, opts.created, opts.expires);
84
+ const base = buildRequestSignatureBase({
85
+ method: opts.method,
86
+ url: opts.url,
87
+ digestHeader,
88
+ authorization: opts.authorization,
89
+ signatureAgent: opts.signatureAgent,
90
+ }, sigParams);
91
+ const sig = await crypto.subtle.sign("Ed25519", privKey, new TextEncoder().encode(base));
92
+ return {
93
+ contentDigest: digestHeader,
94
+ signatureInput: `sig1=${sigParams}`,
95
+ signature: `sig1=:${stdBase64(new Uint8Array(sig))}:`,
96
+ signatureBase: base,
97
+ authorization: opts.authorization,
98
+ signatureAgent: opts.signatureAgent,
99
+ };
100
+ }
101
+ /**
102
+ * appendSignature chains sig(N+1) onto `prev` WITHOUT disturbing existing members
103
+ * (forwarding chain), the TS port of Go helpers.AppendSignature. It finds
104
+ * the next label sig(N+1) and predecessor sigN, binds the FORA base plus a
105
+ * `"signature";key="sigN"` link whose value is `:<std-base64(decoded predecessor
106
+ * sig bytes)>:` (re-encoded canonically, NOT a wire splice), Ed25519-signs, and
107
+ * returns the APPENDED Signature-Input / Signature strings. Appending to an
108
+ * unsigned request (empty prev) produces a sig1 byte-for-byte identical to
109
+ * signRequest — single-sig is the N=1 case.
110
+ */
111
+ export async function appendSignature(privKey, prev, opts) {
112
+ const digestHeader = await contentDigest(opts.body);
113
+ const hasPrev = prev.signatureInput !== "";
114
+ const prevN = hasPrev ? maxSigLabelN(prev.signatureInput) : 0;
115
+ const label = `sig${prevN + 1}`;
116
+ let chainLink;
117
+ let chainLinkToken;
118
+ if (prevN > 0) {
119
+ const prevLabel = `sig${prevN}`;
120
+ const prevBytes = signatureBytesByLabel(prev.signature)[prevLabel];
121
+ if (!prevBytes) {
122
+ throw new Error(`appendSignature: predecessor ${prevLabel} not in Signature`);
123
+ }
124
+ chainLinkToken = `"signature";key="${prevLabel}"`;
125
+ chainLink = { token: chainLinkToken, value: `:${stdBase64(prevBytes)}:` };
126
+ }
127
+ const sigParams = signatureParams(opts.keyid, opts.created, opts.expires, chainLinkToken);
128
+ const base = buildRequestSignatureBase({
129
+ method: opts.method,
130
+ url: opts.url,
131
+ digestHeader,
132
+ authorization: opts.authorization,
133
+ signatureAgent: opts.signatureAgent,
134
+ }, sigParams, chainLink);
135
+ const sig = await crypto.subtle.sign("Ed25519", privKey, new TextEncoder().encode(base));
136
+ const memberInput = `${label}=${sigParams}`;
137
+ const memberSig = `${label}=:${stdBase64(new Uint8Array(sig))}:`;
138
+ return {
139
+ contentDigest: digestHeader,
140
+ signatureInput: hasPrev ? `${prev.signatureInput}, ${memberInput}` : memberInput,
141
+ signature: prev.signature !== "" ? `${prev.signature}, ${memberSig}` : memberSig,
142
+ signatureBase: base,
143
+ authorization: opts.authorization,
144
+ signatureAgent: opts.signatureAgent,
145
+ };
146
+ }
@@ -0,0 +1,31 @@
1
+ import { type Window } from "./window.ts";
2
+ /**
3
+ * Ed25519 sign primitive: (privateKey, message) -> signature. Injected so a
4
+ * non-WebCrypto runtime can supply its own without changing the byte contract.
5
+ * Defaults to WebCrypto crypto.subtle over a CryptoKey.
6
+ */
7
+ export type Ed25519SignFn = (message: Uint8Array) => Promise<Uint8Array>;
8
+ /** Options for signInbound — the created/expires window + an injectable clock. */
9
+ export interface SignInboundOptions {
10
+ now?: () => number;
11
+ ttlSec?: number;
12
+ /**
13
+ * An injectable signature Window sourcing (created, expires). Defaults to a
14
+ * clockWindow over `now`/`ttlSec` (both floored to integer seconds). Supply a
15
+ * monotonicWindow to keep back-to-back signatures' expires cutoffs unique.
16
+ */
17
+ window?: Window;
18
+ }
19
+ /**
20
+ * signInbound produces a genuinely RFC 9421 GET-PoP-signed inbound Request over
21
+ * @method + @target-uri, bound to the agent keypair's RFC 7638 thumbprint (keyid =
22
+ * agent_id). It is the sign side the Hono server-verify binding accepts; it returns
23
+ * a NEW Request carrying the X-FORA-Agent-Key, Signature-Input, and Signature
24
+ * headers (the Fetch Request is immutable — we clone + set headers, never mutate).
25
+ *
26
+ * The covered set is exactly ("@method" "@target-uri"), matching the L1 pop
27
+ * verifier's coversExactly, and the signature base is the L1 signatureBase byte
28
+ * contract, so the produced request verifies through verifyAgentBinding unchanged.
29
+ */
30
+ export declare function signInbound(kp: CryptoKeyPair, url: string, opts?: SignInboundOptions): Promise<Request>;
31
+ export declare function stdBase64(bytes: Uint8Array): string;
@@ -0,0 +1,82 @@
1
+ // sdk/ts/core sign seam — the client/inbound sign face over the WHATWG Fetch
2
+ // Request, mirror of the Go RoundTripper sign face (transport.go) but adapted to
3
+ // the IMMUTABLE Fetch Request: it returns a NEW signed Request (headers set on a
4
+ // clone), never mutating in place. It signs the RFC 9421 covered components over
5
+ // the EXACT request (for a GET PoP: @method + @target-uri), reusing the L1
6
+ // signatureBase byte contract so the signed bytes are byte-identical to what the
7
+ // L1 pop verifier reconstructs.
8
+ //
9
+ // ZERO framework import (no hono, no connect-es) — only WebCrypto + the L1
10
+ // helpers. The Hono binding (sdk/ts/hono) composes this seam; this seam composes
11
+ // nothing above the web standard.
12
+ import { encodeBase64Url } from "../src/base64url.js";
13
+ import { opaqueUrl } from "../src/opaque-url.js";
14
+ import { AGENT_KEY_HEADER, signatureBase } from "../src/pop.js";
15
+ import { thumbprint } from "../src/thumbprint.js";
16
+ import { clockWindow } from "./window.js";
17
+ // A default signing window (seconds) for the GET PoP created/expires params.
18
+ const DEFAULT_POP_TTL_SEC = 600;
19
+ /**
20
+ * signInbound produces a genuinely RFC 9421 GET-PoP-signed inbound Request over
21
+ * @method + @target-uri, bound to the agent keypair's RFC 7638 thumbprint (keyid =
22
+ * agent_id). It is the sign side the Hono server-verify binding accepts; it returns
23
+ * a NEW Request carrying the X-FORA-Agent-Key, Signature-Input, and Signature
24
+ * headers (the Fetch Request is immutable — we clone + set headers, never mutate).
25
+ *
26
+ * The covered set is exactly ("@method" "@target-uri"), matching the L1 pop
27
+ * verifier's coversExactly, and the signature base is the L1 signatureBase byte
28
+ * contract, so the produced request verifies through verifyAgentBinding unchanged.
29
+ */
30
+ export async function signInbound(kp, url, opts = {}) {
31
+ const rawPub = new Uint8Array(await crypto.subtle.exportKey("raw", kp.publicKey));
32
+ const agentId = await thumbprint(rawPub);
33
+ // Source (created, expires) from the injected Window, defaulting to a
34
+ // clockWindow over now (ms → seconds) and ttlSec. clockWindow floors to
35
+ // integer seconds, so the @signature-params bytes stay byte-identical to the
36
+ // historical inline `Math.floor(now()/1000)` mint.
37
+ const ttlSec = opts.ttlSec ?? DEFAULT_POP_TTL_SEC;
38
+ const window = opts.window ??
39
+ clockWindow(() => (opts.now?.() ?? Date.now()) / 1000, ttlSec);
40
+ const [created, expires] = window();
41
+ // The @signature-params inner list the verifier rebuilds verbatim: covered
42
+ // components then keyid/alg/created/expires, RFC 9421 order.
43
+ const rawParams = `("@method" "@target-uri");keyid="${agentId}";alg="ed25519";created=${created};expires=${expires}`;
44
+ // Coerce a URL-like input (a Fastly Compute request URL object) to its opaque
45
+ // string form ONCE at the boundary, so the signed @target-uri and the emitted
46
+ // Request carry the same verbatim bytes. No-op for string callers.
47
+ const target = opaqueUrl(url);
48
+ // The signature base is line-delimited and `target` is written into it
49
+ // verbatim, so a control byte would add or split a component line and the bytes
50
+ // signed here would stop describing the request a verifier reconstructs.
51
+ // Refused rather than escaped, mirroring the Go signer. Checked AFTER the
52
+ // coercion above so it inspects the bytes that actually get signed — and before
53
+ // `new Request(target, …)` below, which would otherwise throw an opaque
54
+ // TypeError instead of naming the reason.
55
+ // Scanned over the UTF-8 BYTES, not the code points, so the reported offset is
56
+ // the same number Go's strings.IndexFunc reports for the same input. Which
57
+ // inputs are refused is unaffected — a control byte is always a single UTF-8
58
+ // byte — but an unlabelled index under identical wording meant three units.
59
+ const badAt = new TextEncoder()
60
+ .encode(target)
61
+ .findIndex((b) => b < 0x20 || b === 0x7f);
62
+ if (badAt !== -1) {
63
+ throw new TypeError(`target URI carries a control byte at byte ${badAt}`);
64
+ }
65
+ const base = signatureBase("GET", target, rawParams);
66
+ const sig = await crypto.subtle.sign("Ed25519", kp.privateKey, new TextEncoder().encode(base));
67
+ const sigStd = stdBase64(new Uint8Array(sig));
68
+ const headers = new Headers();
69
+ headers.set(AGENT_KEY_HEADER, encodeBase64Url(rawPub));
70
+ headers.set("signature-input", `sig1=${rawParams}`);
71
+ headers.set("signature", `sig1=:${sigStd}:`);
72
+ return new Request(target, { method: "GET", headers });
73
+ }
74
+ // stdBase64 encodes bytes as standard (padded) base64 — the RFC 9421 Signature
75
+ // header byte-string encoding the L1 parseSignature decodes. Exported so the
76
+ // 5-component request signer (core/sign-request.ts) shares the exact encoder.
77
+ export function stdBase64(bytes) {
78
+ let bin = "";
79
+ for (let i = 0; i < bytes.length; i += 1)
80
+ bin += String.fromCharCode(bytes[i]);
81
+ return btoa(bin);
82
+ }
@@ -0,0 +1,75 @@
1
+ import { type PriorSignatures } from "./sign-request.ts";
2
+ import { type Window } from "./window.ts";
3
+ /** Inputs for signOutbound — the transport-neutral header core. */
4
+ export interface SignOutboundOptions {
5
+ privKey: CryptoKey;
6
+ keyid: string;
7
+ method: string;
8
+ url: string;
9
+ body: Uint8Array<ArrayBuffer>;
10
+ authorization: string;
11
+ signatureAgent: string;
12
+ window?: Window;
13
+ appendOnly?: boolean;
14
+ prior?: PriorSignatures;
15
+ }
16
+ /** The RFC 9421 header set (plus the untouched body) a signed request carries. */
17
+ export interface SignedOutbound {
18
+ headers: Record<string, string>;
19
+ body: Uint8Array<ArrayBuffer>;
20
+ }
21
+ /**
22
+ * signOutbound computes the RFC 9421 Content-Digest / Signature-Input / Signature
23
+ * headers for one outbound request and returns them with the body untouched — the
24
+ * transport-neutral core (Python SignedOutbound sibling). It orchestrates the
25
+ * parity-locked primitives: appendSignature when appendOnly is set OR a prior
26
+ * Signature is present (forwarding chain), signRequest otherwise.
27
+ *
28
+ * It returns EVERY covered header at the value that entered the signature base, empty
29
+ * values included — see the emit below for why. The keys are LOWERCASE, which is the
30
+ * spelling the corpus records and Python emits, and is what lets a merge over a caller's
31
+ * own headers replace rather than duplicate.
32
+ */
33
+ export declare function signOutbound(o: SignOutboundOptions): Promise<SignedOutbound>;
34
+ /** The WHATWG-fetch-shaped outbound request the transport inspects / forwards. */
35
+ export interface OutboundInit {
36
+ method?: string;
37
+ headers?: Record<string, string>;
38
+ body?: Uint8Array<ArrayBuffer>;
39
+ }
40
+ /** The seam createSigningTransport wraps: a WHATWG-fetch-shaped send(url, init). */
41
+ export type OutboundSend<R> = (url: string, init: OutboundInit) => Promise<R>;
42
+ /** What a sign predicate inspects to decide whether a request is signed. */
43
+ export interface OutboundRequest {
44
+ url: string;
45
+ method: string;
46
+ headers: Record<string, string>;
47
+ body: Uint8Array<ArrayBuffer> | undefined;
48
+ }
49
+ /**
50
+ * Options for createSigningTransport — an idiomatic TS options object, one field per
51
+ * Go WithX (transport.go:47-96). window replaces the default freshness window;
52
+ * appendOnly forces the relay append branch; signatureAgent supplies the covered
53
+ * directory value stamped SET-IF-ABSENT; predicate gates which requests are signed
54
+ * (default: sign every bodied request).
55
+ */
56
+ export interface SigningTransportOptions {
57
+ privKey: CryptoKey;
58
+ keyid: string;
59
+ window?: Window;
60
+ appendOnly?: boolean;
61
+ signatureAgent?: string;
62
+ predicate?: (req: OutboundRequest) => boolean;
63
+ }
64
+ /**
65
+ * createSigningTransport wraps a WHATWG-fetch-shaped send and returns a send with the
66
+ * same shape that auto-signs each outbound request: it buffers the body, computes
67
+ * the RFC 9421 headers via signOutbound, merges them, and forwards the SAME body
68
+ * bytes to the wrapped send. A request with no body — or one the predicate
69
+ * excludes — passes through UNSIGNED (there is nothing to bind a Content-Digest
70
+ * to); that is not an error. A request already carrying a Signature is CHAINED
71
+ * onto (appendSignature), never replaced. Naming stays family-consistent with the
72
+ * newXResolver siblings; the whole-surface newX -> create rename is a separate,
73
+ * broader change, not this module's concern.
74
+ */
75
+ export declare function createSigningTransport<R>(send: OutboundSend<R>, opts: SigningTransportOptions): OutboundSend<R>;
@@ -0,0 +1,172 @@
1
+ // sdk/ts outbound auto-sign transport — the TS sibling of Go
2
+ // core.NewSigningTransport (sdk/go/core/transport.go) and Python
3
+ // SigningTransport / SignedOutbound (sdk/python/fora_sdk/signing_transport.py).
4
+ //
5
+ // Core Invariant: this module is a pure ORCHESTRATION of the already
6
+ // byte-parity-locked primitives signRequest / appendSignature (core/sign-request.ts)
7
+ // and clockWindow / monotonicWindow (core/window.ts). It stamps EVERY covered header
8
+ // at the value that entered the signature base — Content-Digest / Signature-Input /
9
+ // Signature, and the Authorization / Signature-Agent whose values may be empty —
10
+ // byte-identical to the shared Go/Python oracle, forwards the request body UNMODIFIED,
11
+ // and adds NO new crypto and NO new signature-base rendering.
12
+ //
13
+ // Two faces, mirroring Python's transport-neutral shape (the cleaner fit for TS's
14
+ // multi-runtime edge, which has no Go http.RoundTripper):
15
+ // - signOutbound(...): the transport-NEUTRAL header core (Python SignedOutbound
16
+ // sibling) — computes the RFC 9421 headers for one request and returns them
17
+ // with the untouched body. Wraps no client.
18
+ // - createSigningTransport(send, opts): wraps a WHATWG-fetch-shaped outbound seam
19
+ // send(url, init) — buffers the body, computes headers via signOutbound, and
20
+ // forwards the SAME body bytes to send.
21
+ //
22
+ // Signature-Agent is SET-IF-ABSENT (mirror Go transport.go:143-145, NOT Python's
23
+ // always-stamp): TS carries the relay/append chain, where an upstream sig1 already
24
+ // covers its own Signature-Agent value and must never be overwritten.
25
+ import { SignatureAgentHeader } from "../src/wire.js";
26
+ import { appendSignature, signRequest, } from "./sign-request.js";
27
+ import { clockWindow } from "./window.js";
28
+ // The DEFAULT freshness window: clock-derived created + a 5-minute TTL, matching
29
+ // Go's `signWindow = 5 * time.Minute` (transport.go:18) — NOT Python's 600s. The
30
+ // clock reads seconds (Date.now()/1000); clockWindow floors to Go's .Unix().
31
+ const DEFAULT_WINDOW_TTL_SEC = 300;
32
+ function defaultWindow() {
33
+ return clockWindow(() => Date.now() / 1000, DEFAULT_WINDOW_TTL_SEC);
34
+ }
35
+ // Case-insensitive header lookup over a plain header record. Incoming requests may
36
+ // spell header names in any case; the covered values (authorization, prior
37
+ // Signature state, Signature-Agent) must be read regardless of casing.
38
+ function getHeader(headers, name) {
39
+ const lower = name.toLowerCase();
40
+ for (const k of Object.keys(headers)) {
41
+ if (k.toLowerCase() === lower)
42
+ return headers[k];
43
+ }
44
+ return undefined;
45
+ }
46
+ /**
47
+ * mergeSigned overlays the signed headers onto the caller's, REPLACING any the caller
48
+ * spelled in a different case rather than letting both survive.
49
+ *
50
+ * A plain spread would not: header names are case-insensitive on the wire, JS object keys
51
+ * are not, so a caller's `Authorization` and a signed `authorization` become two field
52
+ * lines. getHeader above already reads case-insensitively; this is the write side
53
+ * agreeing with it. See docs/design-history.md, "A covered header the peer never receives
54
+ * is not bound".
55
+ */
56
+ function mergeSigned(callerHeaders, signedHeaders) {
57
+ const claimed = new Set(Object.keys(signedHeaders).map((k) => k.toLowerCase()));
58
+ const merged = {};
59
+ for (const [name, value] of Object.entries(callerHeaders)) {
60
+ if (!claimed.has(name.toLowerCase()))
61
+ merged[name] = value;
62
+ }
63
+ return { ...merged, ...signedHeaders };
64
+ }
65
+ /**
66
+ * signOutbound computes the RFC 9421 Content-Digest / Signature-Input / Signature
67
+ * headers for one outbound request and returns them with the body untouched — the
68
+ * transport-neutral core (Python SignedOutbound sibling). It orchestrates the
69
+ * parity-locked primitives: appendSignature when appendOnly is set OR a prior
70
+ * Signature is present (forwarding chain), signRequest otherwise.
71
+ *
72
+ * It returns EVERY covered header at the value that entered the signature base, empty
73
+ * values included — see the emit below for why. The keys are LOWERCASE, which is the
74
+ * spelling the corpus records and Python emits, and is what lets a merge over a caller's
75
+ * own headers replace rather than duplicate.
76
+ */
77
+ export async function signOutbound(o) {
78
+ const [created, expires] = (o.window ?? defaultWindow())();
79
+ const signOpts = {
80
+ method: o.method,
81
+ url: o.url,
82
+ body: o.body,
83
+ authorization: o.authorization,
84
+ signatureAgent: o.signatureAgent,
85
+ keyid: o.keyid,
86
+ created,
87
+ expires,
88
+ };
89
+ const prior = o.prior ?? { signatureInput: "", signature: "" };
90
+ const chained = (o.appendOnly ?? false) || prior.signature !== "";
91
+ const signed = chained
92
+ ? await appendSignature(o.privKey, prior, signOpts)
93
+ : await signRequest(o.privKey, signOpts);
94
+ // EVERY covered header is emitted, at exactly the value that entered the signature
95
+ // base — empty values included. A verifier rebuilds the base from the request it
96
+ // received, so a value bound but never sent is not bound at all: it reads the covered
97
+ // names off signature-input, finds nothing on the wire under one of them, and refuses.
98
+ // Measured: the covered set binds authorization and signature-agent unconditionally,
99
+ // and a request carrying neither is answered `header "authorization" missing from
100
+ // request` by every conformant verifier — so the ports agreed byte-for-byte with the
101
+ // oracle on what they signed and could not complete a single call.
102
+ //
103
+ // EVERY covered header, at exactly the value that entered the signature base — empty
104
+ // values included. See docs/design-history.md, "A covered header the peer never
105
+ // receives is not bound", for why binding one without sending it is not binding it,
106
+ // and why the emitted key is LOWERCASE (a signed key spelled differently from the
107
+ // caller's survives the merge beside it, putting the name on the wire twice).
108
+ //
109
+ // Taken straight off `signed`, never re-read from `o`: the primitive echoes what it
110
+ // bound, so there is one place the emitted value can come from and no way for the two
111
+ // to drift.
112
+ const headers = {
113
+ "content-digest": signed.contentDigest,
114
+ "signature-input": signed.signatureInput,
115
+ signature: signed.signature,
116
+ authorization: signed.authorization,
117
+ [SignatureAgentHeader.toLowerCase()]: signed.signatureAgent,
118
+ };
119
+ return { headers, body: o.body };
120
+ }
121
+ /**
122
+ * createSigningTransport wraps a WHATWG-fetch-shaped send and returns a send with the
123
+ * same shape that auto-signs each outbound request: it buffers the body, computes
124
+ * the RFC 9421 headers via signOutbound, merges them, and forwards the SAME body
125
+ * bytes to the wrapped send. A request with no body — or one the predicate
126
+ * excludes — passes through UNSIGNED (there is nothing to bind a Content-Digest
127
+ * to); that is not an error. A request already carrying a Signature is CHAINED
128
+ * onto (appendSignature), never replaced. Naming stays family-consistent with the
129
+ * newXResolver siblings; the whole-surface newX -> create rename is a separate,
130
+ * broader change, not this module's concern.
131
+ */
132
+ export function createSigningTransport(send, opts) {
133
+ const window = opts.window ?? defaultWindow();
134
+ return async (url, init) => {
135
+ const body = init.body;
136
+ const method = init.method ?? "GET";
137
+ const headers = init.headers ?? {};
138
+ // No body / predicate-excluded: pass through UNSIGNED, body untouched.
139
+ const excluded = opts.predicate !== undefined &&
140
+ !opts.predicate({ url, method, headers, body });
141
+ if (body === undefined || excluded) {
142
+ return send(url, init);
143
+ }
144
+ // Signature-Agent SET-IF-ABSENT: the incoming header (an upstream sig1's
145
+ // covered directory) wins; otherwise the transport stamps its own.
146
+ const existingAgent = getHeader(headers, SignatureAgentHeader);
147
+ const signatureAgent = existingAgent ?? opts.signatureAgent ?? "";
148
+ const prior = {
149
+ signatureInput: getHeader(headers, "signature-input") ?? "",
150
+ signature: getHeader(headers, "signature") ?? "",
151
+ };
152
+ const signed = await signOutbound({
153
+ privKey: opts.privKey,
154
+ keyid: opts.keyid,
155
+ method,
156
+ url,
157
+ body,
158
+ authorization: getHeader(headers, "authorization") ?? "",
159
+ signatureAgent,
160
+ window,
161
+ appendOnly: opts.appendOnly ?? false,
162
+ prior,
163
+ });
164
+ // Forward the SAME body bytes (body integrity — buffer for the digest
165
+ // but never consume/replace the payload; Go resets req.Body + GetBody).
166
+ return send(url, {
167
+ ...init,
168
+ headers: mergeSigned(headers, signed.headers),
169
+ body,
170
+ });
171
+ };
172
+ }