@fungi.computer/veil 0.0.0-stage → 0.1.0

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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +171 -2
  3. package/dist/best-effort-cleanup.d.ts +2 -0
  4. package/dist/best-effort-cleanup.js +5 -0
  5. package/dist/event-verifiers.d.ts +80 -0
  6. package/dist/event-verifiers.js +168 -0
  7. package/dist/gatekeeper.d.ts +202 -0
  8. package/dist/gatekeeper.js +231 -0
  9. package/dist/index.d.ts +254 -0
  10. package/dist/index.js +267 -0
  11. package/dist/model-credentials.d.ts +226 -0
  12. package/dist/model-credentials.js +133 -0
  13. package/dist/model-key-grants.d.ts +56 -0
  14. package/dist/model-key-grants.js +107 -0
  15. package/dist/model-key-network.d.ts +33 -0
  16. package/dist/model-key-network.js +202 -0
  17. package/dist/model-keys.d.ts +42 -0
  18. package/dist/model-keys.js +115 -0
  19. package/dist/nango-custody-http.d.ts +16 -0
  20. package/dist/nango-custody-http.js +93 -0
  21. package/dist/nango-git.d.ts +19 -0
  22. package/dist/nango-git.js +125 -0
  23. package/dist/nango-model-keys.d.ts +13 -0
  24. package/dist/nango-model-keys.js +48 -0
  25. package/dist/nango-world-seat.d.ts +14 -0
  26. package/dist/nango-world-seat.js +47 -0
  27. package/dist/nango.d.ts +16 -0
  28. package/dist/nango.js +300 -0
  29. package/dist/openapi-contract.d.ts +64 -0
  30. package/dist/openapi-contract.js +11 -0
  31. package/dist/openapi-effect.d.ts +9 -0
  32. package/dist/openapi-effect.js +284 -0
  33. package/dist/openapi-http.d.ts +2 -0
  34. package/dist/openapi-http.js +37 -0
  35. package/dist/openapi-secrets.d.ts +6 -0
  36. package/dist/openapi-secrets.js +80 -0
  37. package/dist/openapi.d.ts +5 -0
  38. package/dist/openapi.js +24 -0
  39. package/dist/owned-infrastructure.d.ts +13 -0
  40. package/dist/owned-infrastructure.js +17 -0
  41. package/dist/provider-egress.d.ts +51 -0
  42. package/dist/provider-egress.js +155 -0
  43. package/dist/public-egress-policy.d.ts +13 -0
  44. package/dist/public-egress-policy.js +101 -0
  45. package/dist/public-fetch.d.ts +90 -0
  46. package/dist/public-fetch.js +347 -0
  47. package/dist/webhook-trigger-secrets.d.ts +23 -0
  48. package/dist/webhook-trigger-secrets.js +53 -0
  49. package/dist/world-seat.d.ts +40 -0
  50. package/dist/world-seat.js +82 -0
  51. package/package.json +140 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jamstack Consulting LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,172 @@
1
- # Temporary Holding Version
1
+ # Veil (`@fungi.computer/veil`)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ This package keeps provider credentials behind trusted Team doors. It owns
4
+ GitHub Connection custody, model-key grants and the Agent's anonymous public-web
5
+ fetch. A guest receives an authorized operation, not the provider token or its
6
+ private locator.
7
+
8
+ ## Set up and check
9
+
10
+ The host supplies Team Connection authority and a `CredentialCustodian` to
11
+ `createGitHubConnections`. Model input cannot select a repository or private
12
+ locator. The `@fungi.computer/veil/nango` adapter needs a configured Nango
13
+ origin, API key, webhook signing key, integration ID and public Connect URLs;
14
+ these are host secrets or configuration, never browser or model input. Without
15
+ custody configuration, report unavailable rather than manufacturing a
16
+ connection.
17
+
18
+ Run `pnpm --filter @fungi.computer/veil test` for the package's synthetic
19
+ custody, signed-webhook and public-fetch checks. For an exercised connection
20
+ lifecycle, start with `test/nango-git.test.ts`; a unit test does not prove a
21
+ live Nango service or provider account.
22
+
23
+ The root export in `src/index.ts` defines Team Connection receipts and
24
+ `createGitHubConnections`. `package.json` lists the Nango, model-key and
25
+ public-fetch subpaths used by trusted hosts; do not import private locators from
26
+ these modules into guest code.
27
+
28
+ ## Nango adapter
29
+
30
+ `@fungi.computer/veil/nango` exports `createNangoCustodian`. The trusted host
31
+ supplies a Nango server origin, API key, separate webhook signing key, one
32
+ integration ID, and the public Connect UI base and public API URL. These are
33
+ host configuration, never browser or model input. `openSession` returns the
34
+ Connect document browsers open: Nango's issued link, confined to the configured
35
+ Connect UI and pinned to the public API with `apiURL`. Unconfigured hosts must
36
+ report unavailable instead of substituting fake custody.
37
+
38
+ The adapter uses `@nangohq/node@0.71.6` to verify the raw callback body and
39
+ ordinary `fetch` with each invocation's AbortSignal for REST operations. It does
40
+ not call credential-returning connection inspection. Connect sessions carry
41
+ Team-created attempt/principal/Team tags and restrict the integration. Signed
42
+ webhooks are verified before decoding; the adapter handles only a successful
43
+ `creation` and a failed `refresh` (reconnect evidence) and refuses every other
44
+ operation as `unsupported`. It also checks end-user and organization
45
+ correlation; Team owns final activation and replay decisions. A verified
46
+ completion the Team refuses (late, unknown, superseded, or failing activation)
47
+ is deleted from custody, and the settled deletion is recorded.
48
+
49
+ Reconciliation queries `/connections` with exact integration, end-user,
50
+ organization and attempt tags and a limit of two. The selected server filters
51
+ before applying that limit: zero matches remain unresolved, multiple matches are
52
+ ambiguous, and every returned row is checked again before a single completion is
53
+ accepted. It never selects the first candidate from an arbitrary page.
54
+
55
+ The pinned public deletion endpoint returns `200 {success:true}` after deletion
56
+ and `400 {error:{code:"unknown_connection"}}` when the selected connection is
57
+ absent. Other responses, including generic 404 and `success:false`, do not
58
+ settle cleanup. This mapping is specific to the selected server implementation,
59
+ not a generic Nango error policy. Provider/proxy failures stay unavailable,
60
+ except a proxied provider `401` carrying GitHub's own error body, which marks
61
+ the Connection reconnect-required; Nango's own `401` envelope stays unavailable.
62
+
63
+ The one proxy operation is `GET /proxy/repos/{owner}/{name}` with private Nango
64
+ connection/integration headers. Veil's existing caller decodes credential-free
65
+ metadata and verifies the repository ID. Cancellation stops waiting through the
66
+ maintained transport; it does not prove upstream work stopped or rolled back.
67
+
68
+ ## Public-web door
69
+
70
+ `@fungi.computer/veil/public-fetch` is the one sanctioned egress for the Agent's
71
+ `web.fetch`. `configuredPublicFetch({ connect, fetch })` returns `get`, an
72
+ anonymous GET that carries no credential and only `Accept` and `User-Agent`
73
+ request headers. Each call resolves the name once (DNS-over-HTTPS through
74
+ `fetch`), refuses the whole name if any answer is not public unicast (IPv4 and
75
+ IPv6, including loopback, private, link-local, metadata, IPv4-mapped, NAT64,
76
+ 6to4 and Teredo forms), and passes only those checked addresses to the host's
77
+ socket primitive (`cloudflare:sockets` `connect` in the Hub). The URL's own name
78
+ stays the `Host` header and the TLS server name, so a second lookup cannot move
79
+ the connection. `get` never follows a redirect; the caller's next hop is a new
80
+ call that resolves and checks again. The response is framed by `http-parser-js`;
81
+ compressed, layered-coded and over-long-header responses are refused.
82
+
83
+ Workerd's `startTls({ expectedServerHostname })` verifies the certificate
84
+ against that name; it reports a verification failure on the first read, which
85
+ the door types as `unavailable`.
86
+
87
+ ## Model credentials and failure handling
88
+
89
+ `src/model-credentials.ts` selects which key class may serve an inference
90
+ request. Personal keys do not become an App's credential; App-originated
91
+ requests use Team-authorized custody or refuse. The custodian handles provider
92
+ egress and lifecycle. An authorized Team actor can disconnect a Connection; a
93
+ failed refresh marks it reconnect-required. A late or superseded completion is
94
+ not silently activated. A canceled request stops the caller waiting, but does
95
+ not prove the provider stopped work already admitted.
96
+
97
+ No provider credential, service startup, hosted deployment or backup recovery is
98
+ established by installing or testing this adapter. Verify the configured service
99
+ and Team owner separately. The manifest declares MIT; follow the repository's
100
+ contribution instructions.
101
+
102
+ ## OpenAPI door
103
+
104
+ `@fungi.computer/veil/openapi` exposes the private `createOpenApiDoor` Promise
105
+ facade. `./openapi-effect` owns the same typed Effect program. Only the Team
106
+ Connector owner calls this door, after checking current attachment revision,
107
+ selected operation and read/write effect. This is not a guest raw-request tool.
108
+
109
+ Anonymous, API-key header/query and bearer requests share the public-fetch
110
+ socket implementation: public HTTPS only, one checked DNS resolution, pinned
111
+ address with the original Host/TLS name, and no redirect following. The owner
112
+ passes the pinned base URL and the selected operation's declared header names.
113
+ The door refuses reserved headers, auth overrides and targets outside the base
114
+ path. Request and response bodies are capped at 256 KiB, the whole operation at
115
+ 30 seconds, and the HTTP parser caps response headers at 64 KiB. Responses carry
116
+ only text/JSON and their media type; upstream cookies and other headers are not
117
+ returned. Non-2xx responses expose `upstream_status` and the numeric status,
118
+ without upstream error text. Common credential echoes are redacted.
119
+
120
+ `configuredOpenApiDoor` reads `OPENAPI_CREDENTIAL_MASTER_KEY` only in the
121
+ Gatekeeper. This dedicated 32-byte base64url Worker secret seals credentials
122
+ with WebCrypto AES-GCM. The host supplies an `OpenApiCredentialStore` that
123
+ persists ciphertext only, keyed by Team and Connector. Authenticated data binds
124
+ it to that owner, pinned base and auth policy. The store must honor cancellation
125
+ before a write; ciphertext stays with its private owner, never an Agent fact,
126
+ browser DTO or log. Removal is idempotent. Changing the base or auth policy
127
+ requires resealing; a copied ciphertext cannot authorize another owner or host.
128
+ The host owns durable cleanup and key provisioning/recovery; this package does
129
+ not install a secret, persist plaintext or contact a remote service during
130
+ tests. Missing custody or credentials is a typed refusal, not anonymous
131
+ fallback.
132
+
133
+ OAuth is restricted to host-configured `OpenApiOAuthProvider` ports, backed by
134
+ the existing Nango proxy. The provider fixes its base; a spec cannot choose
135
+ another destination. Nango retains OAuth custody and its own egress policy. No
136
+ generic OAuth provider or new Nango instance is introduced. An unconfigured
137
+ provider refuses `oauth_not_configured`. API-key/bearer traffic never uses
138
+ Nango.
139
+
140
+ C4's typed refusals are `denied`, `invalid_input`, `invalid_response`,
141
+ `too_large`, `timeout`, `cancelled`, `unavailable`, `credentials_missing`,
142
+ `oauth_not_configured` and `upstream_status` (with numeric `status`). Errors
143
+ never carry request URLs, credential bytes, raw response text or transport
144
+ causes.
145
+
146
+ The host supplies `networkPolicy.extraPorts` (default empty),
147
+ `deniedHostSuffixes` (exact names plus child names), and `deniedCidrs` for its
148
+ own infrastructure. No deployment addresses are hardcoded. OpenAPI permits HTTPS
149
+ on 443 unless the host explicitly adds a port. The same pinned HTTP adapter
150
+ checks policy against the URL host and **every** checked DNS answer; one denied
151
+ answer refuses the whole call. CIDR parsing and matching belong to ipaddr.js,
152
+ including mapped IPv4. The older anonymous public-web GET contract also permits
153
+ ordinary HTTP port 80; arbitrary ports require host configuration. These
154
+ policies come from trusted host configuration, never Connector data.
155
+
156
+ Custody uses `keys: { activeKeyId, keys }`, with dedicated 32-byte base64url
157
+ keys indexed by a bounded non-secret id. Envelopes carry version 1 and key id;
158
+ the key id is authenticated along with Team, Connector, base and auth policy. On
159
+ rotation, retain old keys for reads while all new seals use the active key.
160
+ Retire an old key only once its remaining records have been resealed or removed.
161
+ `configuredOpenApiDoor` uses `OPENAPI_CREDENTIAL_KEY_ID` (default `primary`) for
162
+ `OPENAPI_CREDENTIAL_MASTER_KEY`, plus host-supplied `previousCredentialKeys`.
163
+ Key material never leaves this private custody port. Unknown key ids and
164
+ copied/substituted ids refuse; there is no old-envelope shim.
165
+
166
+ Responses redact raw, JSON-escaped, percent-encoded, base64 and base64url
167
+ credential echoes before returning a body. Response headers remain limited to
168
+ the media type, with sensitive media types refused; upstream errors still carry
169
+ only the numeric status. OAuth ports now receive `teamId` as well as the private
170
+ locator; the adapter must verify that the connection belongs to that Team. C7
171
+ must obtain the locator exclusively from the Team-owned Connector record and
172
+ collapse connect-unavailable/timeout into one Agent-facing code.
@@ -0,0 +1,2 @@
1
+ /** Report cleanup failures without exposing provider or transport details. */
2
+ export declare function bestEffortCleanup(operation: "nango-git-response-body" | "public-fetch-socket", cleanup: () => Promise<void>): Promise<void>;
@@ -0,0 +1,5 @@
1
+ import * as Effect from "effect/Effect";
2
+ /** Report cleanup failures without exposing provider or transport details. */
3
+ export async function bestEffortCleanup(operation, cleanup) {
4
+ return Effect.runPromise(Effect.tryPromise({ try: cleanup, catch: () => operation }).pipe(Effect.tapError(() => Effect.logError("veil.cleanup_failed", { operation })), Effect.ignore));
5
+ }
@@ -0,0 +1,80 @@
1
+ /** The ways an inbound event may prove it came from the binding's sender. */
2
+ export declare const EVENT_VERIFIERS: readonly ["fungi-v1", "github", "secret-url", "standard-webhooks"];
3
+ export type EventVerifier = (typeof EVENT_VERIFIERS)[number];
4
+ /** `secret-url` carries an unsigned shared token, so the UI labels it less secure. */
5
+ export declare const EVENT_VERIFIER_SECURITY: {
6
+ readonly "fungi-v1": "signed";
7
+ readonly github: "signed";
8
+ readonly "secret-url": "less_secure";
9
+ readonly "standard-webhooks": "signed";
10
+ };
11
+ export type EventRefusal = "missing" | "bad_signature" | "stale" | "malformed";
12
+ /** Never carries a secret, a signature or any header value. */
13
+ export type EventVerification = Readonly<{
14
+ status: "ok";
15
+ eventId: string;
16
+ }> | Readonly<{
17
+ status: "refused";
18
+ reason: EventRefusal;
19
+ }>;
20
+ /** The parts of one inbound request a verifier reads; the caller bounds `body`. */
21
+ export type EventRequest = Readonly<{
22
+ headers: Pick<Headers, "get">;
23
+ body: Uint8Array;
24
+ /** The token the caller extracted from the URL; only `secret-url` reads it. */
25
+ urlToken: string | null;
26
+ nowSeconds: number;
27
+ }>;
28
+ /** A signed timestamp further than this from the receiver's clock is refused. */
29
+ export declare const WEBHOOK_SIGNATURE_MAX_SKEW_SECONDS = 300;
30
+ /** The one comparison of secret-derived bytes. Callers pass shape-checked, fixed-length values. */
31
+ export declare function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean;
32
+ /**
33
+ * Parse the signed-request headers and refuse a stale or future timestamp.
34
+ * Needs no secret, so an ingress can refuse malformed traffic before it reads
35
+ * any binding state.
36
+ */
37
+ export declare function parseFungiSignatureHeaders(input: Readonly<{
38
+ timestamp: string | null;
39
+ signature: string | null;
40
+ nowSeconds: number;
41
+ }>): Readonly<{
42
+ ok: true;
43
+ timestamp: number;
44
+ signature: string;
45
+ }> | Readonly<{
46
+ ok: false;
47
+ reason: "missing" | "malformed" | "stale";
48
+ }>;
49
+ /**
50
+ * `x-fungi-timestamp` plus `x-fungi-signature: v1=HMAC-SHA256(secret, "<timestamp>.<body>")`,
51
+ * keyed by the decoded secret. The event id is the signature, so a replay of
52
+ * the same signed request is the same event.
53
+ */
54
+ export declare function verifyFungiV1(secret: string, request: EventRequest): EventVerification;
55
+ /**
56
+ * GitHub's `X-Hub-Signature-256: sha256=HMAC-SHA256(secret, raw body)`, keyed by
57
+ * the secret's text exactly as it was pasted into GitHub. `X-GitHub-Delivery`
58
+ * is the event id; GitHub does not sign it, so the receiver must also bound
59
+ * what one delivery id can admit.
60
+ */
61
+ export declare function verifyGithub(secret: string, request: EventRequest): EventVerification;
62
+ /**
63
+ * An unsigned shared token in the URL: anyone who sees the URL can send events,
64
+ * and the body is not authenticated. The sender supplies no event id and no
65
+ * timestamp, so every accepted request is a new event with a fresh id.
66
+ */
67
+ export declare function verifySecretUrl(secret: string, request: EventRequest): EventVerification;
68
+ /**
69
+ * The Standard Webhooks key bytes behind a binding's secret, in the
70
+ * `whsec_<base64>` form senders (including Oyster's EVENTS delivery) take.
71
+ */
72
+ export declare function standardWebhookSecret(secret: string): string;
73
+ /**
74
+ * Standard Webhooks: `webhook-id`, `webhook-timestamp` and
75
+ * `webhook-signature: v1,<base64 HMAC-SHA256(key, "id.timestamp.body")>`,
76
+ * space-separated so senders can rotate keys. The id is signed, so a sender's
77
+ * re-signed retry keeps its event id and a captured body cannot be replayed
78
+ * under a new one.
79
+ */
80
+ export declare function verifyStandardWebhooks(secret: string, request: EventRequest): EventVerification;
@@ -0,0 +1,168 @@
1
+ import { defineIdFamily } from "@fungi.computer/cairn/id";
2
+ import nodeCrypto from "node:crypto";
3
+ import { z } from "zod";
4
+ /** The ways an inbound event may prove it came from the binding's sender. */
5
+ export const EVENT_VERIFIERS = [
6
+ "fungi-v1",
7
+ "github",
8
+ "secret-url",
9
+ "standard-webhooks",
10
+ ];
11
+ /** `secret-url` carries an unsigned shared token, so the UI labels it less secure. */
12
+ export const EVENT_VERIFIER_SECURITY = {
13
+ "fungi-v1": "signed",
14
+ github: "signed",
15
+ "secret-url": "less_secure",
16
+ "standard-webhooks": "signed",
17
+ };
18
+ /** Senders that supply no delivery id (secret-url) get one minted here. */
19
+ const eventIds = defineIdFamily({
20
+ family: "veil",
21
+ version: 1,
22
+ kinds: { delivery: { prefix: "event_delivery" } },
23
+ });
24
+ const secretSchema = z.lazy(() => z.string().regex(/^[A-Za-z0-9_-]{43}$/u));
25
+ const fungiSignatureSchema = z.lazy(() => z.string().regex(/^v1=[a-f0-9]{64}$/u));
26
+ const githubSignatureSchema = z.lazy(() => z.string().regex(/^sha256=[a-f0-9]{64}$/u));
27
+ const githubDeliverySchema = z.lazy(() => z.string().regex(/^[A-Za-z0-9._-]{1,128}$/u));
28
+ const timestampSchema = z.lazy(() => z
29
+ .string()
30
+ .regex(/^\d{1,12}$/u)
31
+ .transform(Number)
32
+ .pipe(z.number().int().nonnegative()));
33
+ /** A signed timestamp further than this from the receiver's clock is refused. */
34
+ export const WEBHOOK_SIGNATURE_MAX_SKEW_SECONDS = 300;
35
+ const refused = (reason) => ({
36
+ status: "refused",
37
+ reason,
38
+ });
39
+ /** The one comparison of secret-derived bytes. Callers pass shape-checked, fixed-length values. */
40
+ export function constantTimeEqual(a, b) {
41
+ return a.length === b.length && nodeCrypto.timingSafeEqual(a, b);
42
+ }
43
+ const present = (value) => value !== null && value !== "";
44
+ /**
45
+ * Parse the signed-request headers and refuse a stale or future timestamp.
46
+ * Needs no secret, so an ingress can refuse malformed traffic before it reads
47
+ * any binding state.
48
+ */
49
+ export function parseFungiSignatureHeaders(input) {
50
+ if (!present(input.timestamp) || !present(input.signature))
51
+ return { ok: false, reason: "missing" };
52
+ const timestamp = timestampSchema.safeParse(input.timestamp);
53
+ const signature = fungiSignatureSchema.safeParse(input.signature);
54
+ if (!timestamp.success || !signature.success)
55
+ return { ok: false, reason: "malformed" };
56
+ if (Math.abs(input.nowSeconds - timestamp.data) >
57
+ WEBHOOK_SIGNATURE_MAX_SKEW_SECONDS)
58
+ return { ok: false, reason: "stale" };
59
+ return { ok: true, timestamp: timestamp.data, signature: signature.data };
60
+ }
61
+ /**
62
+ * `x-fungi-timestamp` plus `x-fungi-signature: v1=HMAC-SHA256(secret, "<timestamp>.<body>")`,
63
+ * keyed by the decoded secret. The event id is the signature, so a replay of
64
+ * the same signed request is the same event.
65
+ */
66
+ export function verifyFungiV1(secret, request) {
67
+ const headers = parseFungiSignatureHeaders({
68
+ timestamp: request.headers.get("x-fungi-timestamp"),
69
+ signature: request.headers.get("x-fungi-signature"),
70
+ nowSeconds: request.nowSeconds,
71
+ });
72
+ if (!headers.ok)
73
+ return refused(headers.reason);
74
+ const expected = nodeCrypto
75
+ .createHmac("sha256", Buffer.from(secret, "base64url"))
76
+ .update(`${String(headers.timestamp)}.`)
77
+ .update(request.body)
78
+ .digest();
79
+ const claimed = headers.signature.slice(3);
80
+ if (!constantTimeEqual(expected, Buffer.from(claimed, "hex")))
81
+ return refused("bad_signature");
82
+ return { status: "ok", eventId: claimed };
83
+ }
84
+ /**
85
+ * GitHub's `X-Hub-Signature-256: sha256=HMAC-SHA256(secret, raw body)`, keyed by
86
+ * the secret's text exactly as it was pasted into GitHub. `X-GitHub-Delivery`
87
+ * is the event id; GitHub does not sign it, so the receiver must also bound
88
+ * what one delivery id can admit.
89
+ */
90
+ export function verifyGithub(secret, request) {
91
+ const signature = request.headers.get("x-hub-signature-256");
92
+ const delivery = request.headers.get("x-github-delivery");
93
+ if (!present(signature) || !present(delivery))
94
+ return refused("missing");
95
+ const parsedSignature = githubSignatureSchema.safeParse(signature);
96
+ const parsedDelivery = githubDeliverySchema.safeParse(delivery);
97
+ if (!parsedSignature.success || !parsedDelivery.success)
98
+ return refused("malformed");
99
+ const expected = nodeCrypto
100
+ .createHmac("sha256", Buffer.from(secret, "utf8"))
101
+ .update(request.body)
102
+ .digest();
103
+ if (!constantTimeEqual(expected, Buffer.from(parsedSignature.data.slice(7), "hex")))
104
+ return refused("bad_signature");
105
+ return { status: "ok", eventId: parsedDelivery.data };
106
+ }
107
+ /**
108
+ * An unsigned shared token in the URL: anyone who sees the URL can send events,
109
+ * and the body is not authenticated. The sender supplies no event id and no
110
+ * timestamp, so every accepted request is a new event with a fresh id.
111
+ */
112
+ export function verifySecretUrl(secret, request) {
113
+ if (!present(request.urlToken))
114
+ return refused("missing");
115
+ const token = secretSchema.safeParse(request.urlToken);
116
+ if (!token.success)
117
+ return refused("malformed");
118
+ if (!constantTimeEqual(Buffer.from(token.data, "utf8"), Buffer.from(secret, "utf8")))
119
+ return refused("bad_signature");
120
+ return { status: "ok", eventId: eventIds.delivery.generate() };
121
+ }
122
+ const standardIdSchema = z.lazy(() => z.string().regex(/^[\x21-\x7e]{1,256}$/u));
123
+ const standardSignatureSchema = z.lazy(() => z
124
+ .string()
125
+ .regex(/^v1,[A-Za-z0-9+/]+={0,2}$/u)
126
+ .transform((value) => Buffer.from(value.slice(3), "base64")));
127
+ /**
128
+ * The Standard Webhooks key bytes behind a binding's secret, in the
129
+ * `whsec_<base64>` form senders (including Oyster's EVENTS delivery) take.
130
+ */
131
+ export function standardWebhookSecret(secret) {
132
+ return `whsec_${Buffer.from(secret, "base64url").toString("base64")}`;
133
+ }
134
+ /**
135
+ * Standard Webhooks: `webhook-id`, `webhook-timestamp` and
136
+ * `webhook-signature: v1,<base64 HMAC-SHA256(key, "id.timestamp.body")>`,
137
+ * space-separated so senders can rotate keys. The id is signed, so a sender's
138
+ * re-signed retry keeps its event id and a captured body cannot be replayed
139
+ * under a new one.
140
+ */
141
+ export function verifyStandardWebhooks(secret, request) {
142
+ const id = request.headers.get("webhook-id");
143
+ const timestamp = request.headers.get("webhook-timestamp");
144
+ const signatures = request.headers.get("webhook-signature");
145
+ if (!present(id) || !present(timestamp) || !present(signatures))
146
+ return refused("missing");
147
+ const parsedId = standardIdSchema.safeParse(id);
148
+ const parsedTimestamp = timestampSchema.safeParse(timestamp);
149
+ const parsedSignatures = signatures
150
+ .split(" ")
151
+ .map((value) => standardSignatureSchema.safeParse(value));
152
+ if (!parsedId.success ||
153
+ !parsedTimestamp.success ||
154
+ parsedSignatures.some((parsed) => !parsed.success))
155
+ return refused("malformed");
156
+ if (Math.abs(request.nowSeconds - parsedTimestamp.data) >
157
+ WEBHOOK_SIGNATURE_MAX_SKEW_SECONDS)
158
+ return refused("stale");
159
+ const expected = nodeCrypto
160
+ .createHmac("sha256", Buffer.from(secret, "base64url"))
161
+ .update(`${parsedId.data}.${String(parsedTimestamp.data)}.`)
162
+ .update(request.body)
163
+ .digest();
164
+ const matched = parsedSignatures.some((parsed) => parsed.success && constantTimeEqual(expected, parsed.data));
165
+ return matched
166
+ ? { status: "ok", eventId: parsedId.data }
167
+ : refused("bad_signature");
168
+ }
@@ -0,0 +1,202 @@
1
+ import { type OpenApiCredentialStore, type OpenApiOAuthProvider, type OpenApiDoorOptions } from "./openapi.js";
2
+ import { type OpenApiEffects } from "./openapi-effect.js";
3
+ import type { PublicConnect } from "./public-fetch.js";
4
+ import { type WorldSeatCustodian } from "./world-seat.js";
5
+ import type { ModelKeyCustodian } from "./model-keys.js";
6
+ import { createModelKeyEgress, type ModelKeyGrants } from "./model-key-grants.js";
7
+ import { providerFetch, type ProviderEgress, type ProviderKeyBindings, type ProviderRoute } from "./provider-egress.js";
8
+ import * as Schema from "effect/Schema";
9
+ /**
10
+ * The Gatekeeper: the one place the Hub's Nango key, webhook signing key,
11
+ * provider/model-key integration ids, and platform provider keys (Camel, the
12
+ * AI Gateway token, Parallel) are read out of the Worker environment and
13
+ * turned into a custodian or egress. These `configured*` functions are the custody
14
+ * boundary (`V1`): the Hub hands them its own `env` object opaquely and gets
15
+ * back an opaque custodian/egress handle, never the secret values themselves.
16
+ * No file outside `packages/veil` may read `env.NANGO_*` or
17
+ * `env.WEBHOOK_TRIGGER_MASTER_KEY`, `env.CAMEL_API_KEY`,
18
+ * `env.AI_GATEWAY_AUTH_TOKEN` or `env.PARALLEL_API_KEY` directly; the static
19
+ * boundary test `apps/hub/test/veil-custody-boundary.test.ts` enforces it.
20
+ * Hub code reaches a provider only through `configuredProviderEgress`.
21
+ */
22
+ export { providerFetch };
23
+ export type { ProviderEgress, ProviderRoute };
24
+ /** Private Worker bindings, never Team storage or public response fields. */
25
+ export type VeilBindings = ProviderKeyBindings & Readonly<{
26
+ WEBHOOK_TRIGGER_MASTER_KEY?: string;
27
+ /** Dedicated custody root for Team OpenAPI API-key/bearer credentials. */
28
+ OPENAPI_CREDENTIAL_MASTER_KEY?: string;
29
+ OPENAPI_CREDENTIAL_KEY_ID?: string;
30
+ NANGO_SERVER_URL?: string;
31
+ NANGO_API_KEY?: string;
32
+ NANGO_WEBHOOK_SIGNING_KEY?: string;
33
+ NANGO_GITHUB_INTEGRATION_ID?: string;
34
+ NANGO_GITHUB_GIT_INTEGRATION_ID?: string;
35
+ /** Public Connect UI base that Nango's issued links must stay under. */
36
+ NANGO_CONNECT_BASE_URL?: string;
37
+ /** Public Nango API origin the Connect UI in the browser must call. */
38
+ NANGO_PUBLIC_API_URL?: string;
39
+ /** Nango API-key integrations holding Team and personal model keys. */
40
+ NANGO_ANTHROPIC_KEY_INTEGRATION_ID?: string;
41
+ NANGO_OPENAI_KEY_INTEGRATION_ID?: string;
42
+ NANGO_OPENROUTER_KEY_INTEGRATION_ID?: string;
43
+ /** A bearer-token integration used with a per-key base URL override. */
44
+ NANGO_OPENAI_COMPATIBLE_KEY_INTEGRATION_ID?: string;
45
+ /** A bearer-token integration holding App world-seat tokens. */
46
+ NANGO_WORLD_INTEGRATION_ID?: string;
47
+ /**
48
+ * Exactly `enabled` only in a local/dev Hub (the checked-in local Worker
49
+ * config). It admits a loopback world seat endpoint; production never sets
50
+ * it and no URL can turn it on.
51
+ */
52
+ HUB_LOCAL_MODE?: string;
53
+ }>;
54
+ export declare function configuredGitHubState(env: VeilBindings): {
55
+ github: "off";
56
+ metadataIntegrationId?: never;
57
+ gitIntegrationId?: never;
58
+ } | {
59
+ github: "on";
60
+ metadataIntegrationId: string;
61
+ gitIntegrationId: string;
62
+ };
63
+ /**
64
+ * `veil.fetch` for platform provider keys. The Hub names a route and sends a
65
+ * credential-free request; Veil checks the destination and attaches the key.
66
+ * `fetch` is only the final transport (telemetry, or a test double).
67
+ */
68
+ export declare function configuredProviderEgress(env: VeilBindings, options?: Readonly<{
69
+ fetch?: typeof globalThis.fetch;
70
+ }>): ProviderEgress;
71
+ /** The Worker secret is the only custody root for per-trigger signing bytes. */
72
+ export declare function configuredWebhookTriggerSecrets(env: VeilBindings): {
73
+ kind: "unavailable";
74
+ custodian?: never;
75
+ cause?: never;
76
+ } | {
77
+ kind: "ready";
78
+ custodian: {
79
+ reveal: (input: import("./webhook-trigger-secrets.js").EventBindingAddress) => Promise<string>;
80
+ verify(input: import("./event-verifiers.js").EventRequest & Readonly<{
81
+ verifier: import("./event-verifiers.js").EventVerifier;
82
+ address: import("./webhook-trigger-secrets.js").EventBindingAddress;
83
+ }>): Promise<import("./event-verifiers.js").EventVerification>;
84
+ };
85
+ cause?: never;
86
+ } | {
87
+ kind: "unavailable";
88
+ cause: unknown;
89
+ custodian?: never;
90
+ };
91
+ /** Model API keys use their own integrations; none are required to boot. */
92
+ export declare function configuredModelKeyCustodian(env: VeilBindings): ModelKeyCustodian;
93
+ /**
94
+ * Model-key grants are sealed under a key derived from the custodian's own
95
+ * Nango credential, so only a holder of custody can issue or open one.
96
+ */
97
+ export declare function configuredModelKeyGrants(env: VeilBindings): ModelKeyGrants;
98
+ /** World-seat tokens use their own integration; it is not required to boot. */
99
+ export declare function configuredWorldSeatCustodian(env: VeilBindings): WorldSeatCustodian;
100
+ /** The seat endpoint rule for this host: public HTTPS, or loopback when local. */
101
+ export declare function configuredWorldSeatEndpoint(env: VeilBindings): (endpoint: string) => string;
102
+ /** Final model egress a runner reaches with a delivered grant, never via TeamDO. */
103
+ export declare function configuredModelKeyEgress(env: VeilBindings, options: Readonly<{
104
+ ownerIsMember: Parameters<typeof createModelKeyEgress>[0]["ownerIsMember"];
105
+ }>): {
106
+ send(grant: string, expected: Readonly<{
107
+ teamId: string;
108
+ subject: import("./model-key-grants.js").ModelKeyGrantSubject;
109
+ provider: import("./model-keys.js").ModelKeyProvider;
110
+ }>, request: Request): Promise<Response>;
111
+ };
112
+ export declare function configuredVeilCustodian(env: VeilBindings): Readonly<{
113
+ openSession: (input: Readonly<{
114
+ teamId: string;
115
+ attemptId: string;
116
+ principalId: string;
117
+ integrationId: string;
118
+ }>, signal: AbortSignal) => Promise<Readonly<{
119
+ token: string;
120
+ connectUrl: string;
121
+ expiresAt: number;
122
+ }>>;
123
+ verifyCompletion: (rawBody: string, signature: string) => Promise<import("./index.js").VerifiedCompletion>;
124
+ verifyWebhook: (rawBody: string, signature: string) => Promise<import("./index.js").VerifiedWebhook>;
125
+ findCompletion: (attempt: import("./index.js").CustodyCorrelation, signal: AbortSignal) => Promise<Schema.Schema.Type<Schema.Union<readonly [Schema.Struct<{
126
+ readonly status: Schema.Literal<"unresolved">;
127
+ }>, Schema.Struct<{
128
+ readonly status: Schema.Literal<"ambiguous">;
129
+ }>, Schema.Struct<{
130
+ readonly status: Schema.Literal<"verified">;
131
+ readonly completion: Schema.Struct<{
132
+ readonly teamId: Schema.NonEmptyString;
133
+ readonly attemptId: Schema.NonEmptyString;
134
+ readonly principalId: Schema.NonEmptyString;
135
+ readonly integrationId: Schema.NonEmptyString;
136
+ readonly locator: Schema.NonEmptyString;
137
+ }>;
138
+ }>]>>>;
139
+ deleteConnection: (locator: string, signal: AbortSignal) => Promise<"deleted" | "absent">;
140
+ readRepository: (access: import("./index.js").RepositoryAccess, signal: AbortSignal) => Promise<string>;
141
+ }>;
142
+ /** Git Connect/cleanup and final egress use the same Git-profile integration.
143
+ * No metadata Connection locator is translated into this profile. Veil confines
144
+ * the issued Connect document to the configured public Connect URLs. */
145
+ export declare function configuredComputerGitCustodian(env: VeilBindings): {
146
+ integrationId: string;
147
+ openSession: (input: Readonly<{
148
+ teamId: string;
149
+ attemptId: string;
150
+ principalId: string;
151
+ integrationId: string;
152
+ }>, signal: AbortSignal) => Promise<Readonly<{
153
+ token: string;
154
+ connectUrl: string;
155
+ expiresAt: number;
156
+ }>>;
157
+ verifyCompletion: (rawBody: string, signature: string) => Promise<import("./index.js").VerifiedCompletion>;
158
+ verifyWebhook: (rawBody: string, signature: string) => Promise<import("./index.js").VerifiedWebhook>;
159
+ findCompletion: (attempt: import("./index.js").CustodyCorrelation, signal: AbortSignal) => Promise<Schema.Schema.Type<Schema.Union<readonly [Schema.Struct<{
160
+ readonly status: Schema.Literal<"unresolved">;
161
+ }>, Schema.Struct<{
162
+ readonly status: Schema.Literal<"ambiguous">;
163
+ }>, Schema.Struct<{
164
+ readonly status: Schema.Literal<"verified">;
165
+ readonly completion: Schema.Struct<{
166
+ readonly teamId: Schema.NonEmptyString;
167
+ readonly attemptId: Schema.NonEmptyString;
168
+ readonly principalId: Schema.NonEmptyString;
169
+ readonly integrationId: Schema.NonEmptyString;
170
+ readonly locator: Schema.NonEmptyString;
171
+ }>;
172
+ }>]>>>;
173
+ deleteConnection: (locator: string, signal: AbortSignal) => Promise<"deleted" | "absent">;
174
+ readGit: (access: import("./nango-git.js").GitRepositoryAccess, request: Request) => Promise<Response>;
175
+ };
176
+ /** The decoded key selects one of the two configured profiles only. The
177
+ * selected maintained custodian still verifies the original signed body. */
178
+ export declare function verifyVeilWebhook(env: VeilBindings, rawBody: string, signature: string): Promise<{
179
+ operation: "creation" | "refresh_failed";
180
+ completion: import("./index.js").VerifiedCompletion;
181
+ profile: "computer-git";
182
+ } | {
183
+ operation: "creation" | "refresh_failed";
184
+ completion: import("./index.js").VerifiedCompletion;
185
+ profile: "agent-metadata";
186
+ }>;
187
+ export type OpenApiHostOptions = Readonly<{
188
+ connect: PublicConnect;
189
+ fetch: typeof fetch;
190
+ store: OpenApiCredentialStore;
191
+ networkPolicy?: OpenApiDoorOptions["networkPolicy"];
192
+ previousCredentialKeys?: Readonly<Record<string, string>>;
193
+ oauthProviders?: Readonly<Record<string, OpenApiOAuthProvider>>;
194
+ }>;
195
+ /** Anonymous calls need no secret; API-key/bearer calls require configured Veil custody. */
196
+ export declare function configuredOpenApiDoor(env: VeilBindings, options: OpenApiHostOptions): Readonly<{
197
+ seal: (access: import("./openapi-contract.js").OpenApiAccess, credential: string, signal: AbortSignal) => Promise<void>;
198
+ send: (access: import("./openapi-contract.js").OpenApiAccess, request: Request, declaredHeaders?: readonly string[]) => Promise<Response>;
199
+ remove: (address: import("./openapi-contract.js").OpenApiCredentialAddress, signal: AbortSignal) => Promise<"deleted" | "absent">;
200
+ }>;
201
+ /** Owned composition retains canonical Effects; secrets are read only by Gatekeeper. */
202
+ export declare function configuredOpenApiEffects(env: VeilBindings, options: OpenApiHostOptions): OpenApiEffects;