a2a-exposed 0.7.34 → 0.8.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.
- package/lib/account.mjs +2 -2
- package/lib/feedback.mjs +3 -3
- package/lib/main.mjs +9 -39
- package/package.json +1 -1
- package/worker/cloudflare.config.ts +3 -6
- package/worker/deploy.env.example +2 -22
- package/worker/src/approval-oidc.ts +3 -3
- package/worker/src/feedback.ts +2 -2
- package/worker/src/hosted.ts +8 -9
- package/worker/src/index.ts +4 -4
- package/worker/src/owner-rpc.ts +4 -4
- package/worker/src/storage.ts +1 -1
- package/worker/src/tenancy.ts +26 -27
- package/worker/src/version.ts +1 -1
- package/worker/src/wake-secrets.ts +2 -2
- package/worker/test/hosted.test.ts +2 -2
package/lib/account.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Device-flow sign-in (RFC 8628). The CLI is a public client (`a2a-cli`).
|
|
2
2
|
// The session token is written to config.env and is never printed.
|
|
3
3
|
import * as C from "./config.mjs";
|
|
4
4
|
import { CLI, die } from "./a2a.mjs";
|
|
@@ -6,7 +6,7 @@ import { CLI, die } from "./a2a.mjs";
|
|
|
6
6
|
export const CLI_CLIENT_ID = "a2a-cli";
|
|
7
7
|
export const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
8
8
|
|
|
9
|
-
/** https origin with no userinfo, or "" when the value
|
|
9
|
+
/** https origin with no userinfo, or "" when the value has a path, query, or userinfo. */
|
|
10
10
|
export function controlOrigin(value) {
|
|
11
11
|
const raw = String(value || "").trim();
|
|
12
12
|
if (!raw) return "";
|
package/lib/feedback.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Feedback: post
|
|
1
|
+
// Feedback: post when this inbox names a feedback URL, otherwise print a GitHub issue link.
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import readline from "node:readline/promises";
|
|
4
4
|
import { CliError } from "./a2a.mjs";
|
|
@@ -29,7 +29,7 @@ export function scrubFeedbackText(value) {
|
|
|
29
29
|
|
|
30
30
|
const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
31
31
|
|
|
32
|
-
/** The repository that will receive the issue. The prefilled form is ISSUE_REPO. A hosted post uses the
|
|
32
|
+
/** The repository that will receive the issue. The prefilled form is ISSUE_REPO. A hosted post uses the name that URL returns. */
|
|
33
33
|
export function publicIssueWarning(repo = ISSUE_REPO) {
|
|
34
34
|
const name = typeof repo === "string" && REPO_RE.test(repo) && repo.length <= 100 && !repo.includes("..") ? repo : ISSUE_REPO;
|
|
35
35
|
return `This report is filed as a public GitHub issue on ${name}. Do not include confidential information: secrets, tokens, owner or pairing tokens, private URLs or hostnames, message contents, or personal data.`;
|
|
@@ -233,7 +233,7 @@ export async function feedbackPublicRepo(input) {
|
|
|
233
233
|
}
|
|
234
234
|
const body = named && named.ok ? await readJson(named) : null;
|
|
235
235
|
const repo = namedRepo(body?.repo);
|
|
236
|
-
if (!repo) throw new CliError("the
|
|
236
|
+
if (!repo) throw new CliError("the inbox did not name the public repository; feedback was not sent");
|
|
237
237
|
return repo;
|
|
238
238
|
}
|
|
239
239
|
|
package/lib/main.mjs
CHANGED
|
@@ -172,41 +172,11 @@ Outbound (agents you call)
|
|
|
172
172
|
poll --to <alias|url> <taskId> [--proto 0.3|1.0]
|
|
173
173
|
outbound <taskId> stored state of a task you sent (incl. pushed updates)
|
|
174
174
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
as CONTROL_TOKEN in config.env and is not printed. signup and login
|
|
181
|
-
are the same flow; signup is how the first account is created.
|
|
182
|
-
--json prints the control origin and email, not the token.
|
|
183
|
-
A browser sign-in that does not complete returns to /app?error=<code>.
|
|
184
|
-
The page allows each enabled provider origin in form-action, so the
|
|
185
|
-
browser can follow the redirect. Buttons say Continue with that
|
|
186
|
-
provider. Until the check finishes they stay muted and the page says
|
|
187
|
-
Waiting for the check…. A reused token says The check expired. Please try again.
|
|
188
|
-
After sign-in, /app shows the account and its agents. Each agent is listed
|
|
189
|
-
by its hostname. A hosted name opens
|
|
190
|
-
that agent's page: MCP connectors (revoke needs a recent sign-in), a
|
|
191
|
-
message log of recent history. Opening this log leaves a pending wake in place until you list the inbox. The SQL download
|
|
192
|
-
is a redacted copy and replaces a pasted owner token. Peer times are labeled UTC. A pairing
|
|
193
|
-
token shows its code. An empty outbound list says peers added with the CLI
|
|
194
|
-
on your machine stay in that CLI. Sign out is on that
|
|
195
|
-
page. Creating an agent shows the owner token once. The agent page rotates
|
|
196
|
-
that token after a confirmation and a recent sign-in. The new token is
|
|
197
|
-
shown once on that agent's page. The previous one stops working immediately.
|
|
198
|
-
The token is shown only when the stored hash is still that token. A Copy button sits next to it. Without JavaScript the value still copies.
|
|
199
|
-
Deleting an agent asks for its name and a recent sign-in. The account page says how many hosted slots remain. A self-hosted inbox has no such delete.
|
|
200
|
-
That page is not stored.
|
|
201
|
-
FREE_AGENT_LIMIT=2 caps a free account at 2 hosted agents. A Cloudflare agent stays outside that count. An account's own limit overrides it.
|
|
202
|
-
Unset, there is no limit. The database rejects another hosted insert.
|
|
203
|
-
SIGNUP_ALLOWLIST, when set, admits only those verified emails. Unset, sign-in stays open.
|
|
204
|
-
Sign-in uses GitHub, Google, or email. Connect Cloudflare from the account page after you sign in.
|
|
205
|
-
A refusal returns to /app?error=invite and the page says Sign-in is invite-only for now.
|
|
206
|
-
The control Worker logs social_sign_in_failed before the
|
|
207
|
-
provider page, social_sign_in_timeout when that sign-in does not
|
|
208
|
-
return, and oauth_callback_failed when the provider sends the browser
|
|
209
|
-
back
|
|
175
|
+
Owner
|
|
176
|
+
One inbox. The owner token from init authenticates inbox, reply, export, and the other owner
|
|
177
|
+
commands. GET / in a browser shows the agent card. /device approves or denies a pairing request.
|
|
178
|
+
pair set-password sets the approval password. pair set-oidc adds OpenID Connect on /device and
|
|
179
|
+
the MCP consent page when the issuer, client id, client secret, and allowlist are all set.
|
|
210
180
|
|
|
211
181
|
Feedback
|
|
212
182
|
feedback "message" [--kind bug|idea|other] [--contact x]
|
|
@@ -215,15 +185,15 @@ Feedback
|
|
|
215
185
|
[--connection webhook|proxy|polling] [--yes]
|
|
216
186
|
report a bug or an idea. A bug needs --steps.
|
|
217
187
|
The report is a public GitHub issue. The warning names
|
|
218
|
-
the repository before it sends. A hosted inbox
|
|
219
|
-
|
|
188
|
+
the repository before it sends. A hosted inbox names
|
|
189
|
+
that repository. Otherwise the link opens
|
|
220
190
|
the form on telegraphic-dev/a2a-exposed. Do not include
|
|
221
191
|
confidential information: secrets, tokens, owner or pairing
|
|
222
192
|
tokens, private URLs or hostnames, message contents, or
|
|
223
193
|
personal data. Prints that warning and asks before sending,
|
|
224
194
|
unless --yes is set. Off a terminal, --yes is required.
|
|
225
|
-
Posts
|
|
226
|
-
|
|
195
|
+
Posts when this inbox names a feedback URL.
|
|
196
|
+
Otherwise prints a link to the bug
|
|
227
197
|
report form and does not open a browser. The CLI adds its
|
|
228
198
|
version, the inbox Worker version when the inbox reports it,
|
|
229
199
|
the OS, the Node version, the connection (webhook, proxy, or
|
package/package.json
CHANGED
|
@@ -46,10 +46,7 @@ const plain: Record<string, string> = {
|
|
|
46
46
|
// Access (never a Tailnet / LAN URL: the Worker can't reach those, and they never appear in the public card)
|
|
47
47
|
UPSTREAM_URL: v("A2A_UPSTREAM_URL"),
|
|
48
48
|
UPSTREAM_CARD_URL: v("A2A_UPSTREAM_CARD_URL"), // default <upstream origin>/.well-known/agent-card.json
|
|
49
|
-
//
|
|
50
|
-
// TENANCY=host switches the entrypoint to src/hosted.ts and adds TENANT_DO + TENANT_DIRECTORY below.
|
|
51
|
-
// QUOTAS, USAGE_SINK, SIGNUP_URL and BRANDING are parsed and reserved.
|
|
52
|
-
// FEEDBACK_URL is an https origin. Only a hosted tenant posts feedback there. Unset on a self-hosted inbox.
|
|
49
|
+
// Optional gates. Empty values are omitted, so a deploy of one inbox is unchanged.
|
|
53
50
|
// WAKE_TARGET_POLICY=public-https refuses a wake that is not public https. The socket opens to an address from that check, and TLS names the hostname (see wake-target.ts).
|
|
54
51
|
// APPROVAL_OIDC_* turn on OpenID Connect approval when issuer, client id, secret and allowlist are all set.
|
|
55
52
|
TENANCY: v("TENANCY"),
|
|
@@ -78,8 +75,8 @@ if (hosted) {
|
|
|
78
75
|
envBindings.TENANT_DO = bindings.durableObject({ worker: name, exportName: "TenantStore" });
|
|
79
76
|
envBindings.TENANT_DIRECTORY = bindings.kv();
|
|
80
77
|
}
|
|
81
|
-
// Optional service binding
|
|
82
|
-
//
|
|
78
|
+
// Optional service binding for OpenID Connect discovery, token, and JWKS when the issuer shares
|
|
79
|
+
// the tenant zone. Unset: those calls use fetch. Set A2A_CONTROL_SERVICE to the issuer Worker's name
|
|
83
80
|
// so a tenant on the same zone does not fetch the issuer through the public route.
|
|
84
81
|
const controlService = v("A2A_CONTROL_SERVICE");
|
|
85
82
|
if (controlService) envBindings.CONTROL_SERVICE = bindings.worker({ worker: controlService });
|
|
@@ -32,26 +32,10 @@ WAKE_PRESET=generic
|
|
|
32
32
|
# UPSTREAM_ACCESS_CLIENT_ID / UPSTREAM_ACCESS_CLIENT_SECRET) go in a separate chmod-600
|
|
33
33
|
# file passed with `cf deploy --secrets-file <file>`; never commit them.
|
|
34
34
|
#
|
|
35
|
-
#
|
|
36
|
-
# never sets them). Unset means today's single-tenant Worker on D1, and the build does not export a Durable Object.
|
|
37
|
-
# TENANCY=host switches the entry to the multi-tenant router and adds the TENANT_DO and TENANT_DIRECTORY bindings.
|
|
38
|
-
# QUOTAS, USAGE_SINK, SIGNUP_URL and BRANDING are reserved (parsed, not enforced yet).
|
|
39
|
-
# WAKE_TARGET_POLICY=public-https is enforced: a wake must be https to a public address. The Worker
|
|
35
|
+
# WAKE_TARGET_POLICY=public-https requires a wake to be https to a public address. The Worker
|
|
40
36
|
# connects to an address from that check. A redirect is followed only on the same origin. Leave it
|
|
41
37
|
# unset for a self-hosted inbox.
|
|
42
|
-
# Do not set TENANCY for a self-hosted inbox.
|
|
43
|
-
# TENANCY=
|
|
44
|
-
# TENANT_DOMAIN=example.com
|
|
45
|
-
# DATA_REGION=eu
|
|
46
|
-
# Written onto a directory entry the first time that entry has no region. After that the stored region places the tenant.
|
|
47
|
-
# QUOTAS=on
|
|
48
|
-
# USAGE_SINK=d1
|
|
49
38
|
# WAKE_TARGET_POLICY=public-https
|
|
50
|
-
# SIGNUP_URL=https://example.com
|
|
51
|
-
# BRANDING=hosted
|
|
52
|
-
# https origin, no path. Only a hosted tenant (TENANCY=host) posts feedback there.
|
|
53
|
-
# A self-hosted inbox ignores it and the CLI prints a GitHub issue link instead.
|
|
54
|
-
# FEEDBACK_URL=https://control.example.com
|
|
55
39
|
# OpenID Connect approval (optional). All four of issuer, client id, allowlist and the client secret
|
|
56
40
|
# must be set or the pages stay password-only. APPROVAL_METHODS=oidc hides the password once that
|
|
57
41
|
# config is complete; APPROVAL_METHODS=password keeps the password even if an issuer is set.
|
|
@@ -59,11 +43,7 @@ WAKE_PRESET=generic
|
|
|
59
43
|
# APPROVAL_OIDC_CLIENT_ID=
|
|
60
44
|
# APPROVAL_OIDC_ALLOWED_SUBJECTS=
|
|
61
45
|
# APPROVAL_METHODS=password
|
|
62
|
-
# Secrets (same secrets file, never here): APPROVAL_OIDC_CLIENT_SECRET
|
|
63
|
-
# Hosted approval uses the tenant's pushed approval object, not these Worker settings.
|
|
64
|
-
# When that issuer is on the same zone as the tenant host, set this to the control Worker's name.
|
|
65
|
-
# Discovery, token and JWKS then use the CONTROL_SERVICE binding. Unset: those calls use fetch.
|
|
66
|
-
# A2A_CONTROL_SERVICE=a2a-exposed-control
|
|
46
|
+
# Secrets (same secrets file, never here): APPROVAL_OIDC_CLIENT_SECRET.
|
|
67
47
|
# Daily SQL snapshots to R2. Unset: no BACKUP_BUCKET binding and no extra cron. The minute wake cron stays A2A_ENABLE_CRON.
|
|
68
48
|
# A2A_BACKUP_BUCKET=1
|
|
69
49
|
# A2A_BACKUP_BUCKET_NAME=a2a-exposed-backups
|
|
@@ -174,9 +174,9 @@ async function takeBody(res: Response, signal: AbortSignal, max: number): Promis
|
|
|
174
174
|
|
|
175
175
|
/**
|
|
176
176
|
* Discovery, token and JWKS for an issuer on this tenant's zone go through CONTROL_SERVICE.
|
|
177
|
-
* A public fetch of that zone can miss the
|
|
177
|
+
* A public fetch of that zone can miss the issuer (wildcard route, 522, or another Worker on the zone).
|
|
178
178
|
* Any other issuer, and a deploy with the binding unset, uses fetch.
|
|
179
|
-
* The binding request sets Host to the issuer host. Without that header
|
|
179
|
+
* The binding request sets Host to the issuer host. Without that header an issuer on the
|
|
180
180
|
* zone apex sees no Host, and the subrequest does not finish. Every call, binding or fetch, stops
|
|
181
181
|
* after FETCH_MS and the page says the provider could not be reached.
|
|
182
182
|
*/
|
|
@@ -209,7 +209,7 @@ function publicFetch(url: string, init: RequestInit, signal: AbortSignal): Promi
|
|
|
209
209
|
async function bindingFetch(binding: NonNullable<ReturnType<TenantContext["controlService"]>>, url: string, init: RequestInit, signal: AbortSignal): Promise<Response> {
|
|
210
210
|
const headers = new Headers(init.headers);
|
|
211
211
|
// `new Request(url)` does not keep Host across a service binding. The apex issuer is then
|
|
212
|
-
// a request with no Host, which does not match the
|
|
212
|
+
// a request with no Host, which does not match the issuer route or ISSUER, and the
|
|
213
213
|
// subrequest never completes. Set Host to the URL host. `redirect` and `signal` stay off the
|
|
214
214
|
// Request (they do not cross the binding). `signal` is passed to fetch so the runtime cancels it.
|
|
215
215
|
headers.set("host", new URL(url).host);
|
package/worker/src/feedback.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Feedback from an owner-only MCP tool.
|
|
1
|
+
// Feedback from an owner-only MCP tool. When a feedback URL is set, the owner posts there.
|
|
2
2
|
// Any other deploy returns a prefilled GitHub issue URL and does not call out.
|
|
3
3
|
import { reachesControl } from "./approval-oidc.ts";
|
|
4
4
|
import { scrubSecretText } from "./export.ts";
|
|
@@ -247,7 +247,7 @@ async function postFeedback(ctx: TenantContext, endpoint: string, body: string,
|
|
|
247
247
|
}
|
|
248
248
|
|
|
249
249
|
/**
|
|
250
|
-
* File the note when this tenant is hosted and the
|
|
250
|
+
* File the note when this tenant is hosted and the feedback URL accepts it.
|
|
251
251
|
* No endpoint, or a 404, returns the prefilled issue URL and does not throw.
|
|
252
252
|
* Anything else throws a short error with none of the message.
|
|
253
253
|
*/
|
package/worker/src/hosted.ts
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Optional entrypoint. Used only when the deploy sets TENANCY=host (see cloudflare.config.ts).
|
|
2
2
|
// With that set and a TENANT_DO binding, `<name>.<TENANT_DOMAIN>` is looked up in the KV directory and forwarded
|
|
3
|
-
// to that
|
|
4
|
-
// against its own database. Anything else — TENANCY unset, or no TENANT_DO binding — calls the
|
|
3
|
+
// to that name's SQLite Durable Object. The object runs the same inbox as a single deploy (`dispatch` in index.ts)
|
|
4
|
+
// against its own database. Anything else — TENANCY unset, or no TENANT_DO binding — calls the single-inbox fetch
|
|
5
5
|
// handler on env.DB.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
7
|
+
// Config is a versioned row pushed into the object (`pushConfig`). It does not travel on the request.
|
|
8
8
|
// An object with no row answers 404 and does not create application tables.
|
|
9
9
|
//
|
|
10
|
-
//
|
|
11
|
-
// while TENANCY=host, so there is no shared database to flush.
|
|
10
|
+
// Wake debounce and housekeeping run from this object's alarm. The Worker minute cron stays a no-op
|
|
11
|
+
// while TENANCY=host, so there is no shared database to flush. A single inbox still uses its minute cron.
|
|
12
12
|
// The daily backup cron runs only when BACKUP_BUCKET is bound, and it returns before any flush.
|
|
13
|
-
//
|
|
14
|
-
// `approval` object), uses the same pages as self-host.
|
|
13
|
+
// OpenID Connect approval, when the issuer, client id, client secret, and allowlist are set, uses the same pages.
|
|
15
14
|
import { DurableObject } from "cloudflare:workers";
|
|
16
15
|
import worker, { cronFlush, dispatch, nextCronAt, onWakeWrite, runOwnerRpc } from "./index.ts";
|
|
17
16
|
import { accountIdOf, type OwnerRpcRequest, type OwnerRpcResult } from "./owner-rpc.ts";
|
|
@@ -34,7 +33,7 @@ const gone = () => Response.json({ error: "this agent is gone" }, { status: 410,
|
|
|
34
33
|
|
|
35
34
|
const TENANT_ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
36
35
|
|
|
37
|
-
/** Versioned config pushed
|
|
36
|
+
/** Versioned config pushed into the object. `secretsEnc` is decrypted into the wake credentials when the context is built. */
|
|
38
37
|
export interface TenantConfigPush {
|
|
39
38
|
version: number;
|
|
40
39
|
tenantId: string;
|
package/worker/src/index.ts
CHANGED
|
@@ -108,7 +108,7 @@ async function wakeBudgetOk(ctx: TenantContext): Promise<boolean> {
|
|
|
108
108
|
|
|
109
109
|
const wakeWriteHooks = new WeakMap<object, () => Promise<void>>();
|
|
110
110
|
|
|
111
|
-
/** A
|
|
111
|
+
/** A caller registers this so a wake row updates the alarm in the same turn as the write. */
|
|
112
112
|
export function onWakeWrite(ectx: object, fn: () => Promise<void>): void {
|
|
113
113
|
wakeWriteHooks.set(ectx, fn);
|
|
114
114
|
}
|
|
@@ -204,7 +204,7 @@ async function wake(ctx: TenantContext, ectx: ExecutionContext, contextId: strin
|
|
|
204
204
|
await noteWakeWrite(ectx);
|
|
205
205
|
const delay = Math.max(0, (cur?.last_sent_ms || now) + deb - now) + 100;
|
|
206
206
|
// waitUntil can outlive the response by ~30s. Longer windows are flushed by a later request, the optional
|
|
207
|
-
//
|
|
207
|
+
// single-inbox cron, or the registered alarm (set above).
|
|
208
208
|
if (delay <= 25000) ectx.waitUntil(new Promise((res) => setTimeout(res, delay)).then(() => flushWake(ctx, ectx, contextId)));
|
|
209
209
|
}
|
|
210
210
|
|
|
@@ -255,7 +255,7 @@ async function minMs(ctx: TenantContext, sql: string, shift: (t: number) => numb
|
|
|
255
255
|
|
|
256
256
|
/**
|
|
257
257
|
* When the next flush or housekeeping delete becomes due, or null when nothing is waiting.
|
|
258
|
-
*
|
|
258
|
+
* Per-inbox storage sets its alarm to this time. A single inbox keeps the minute cron and does not call this.
|
|
259
259
|
*/
|
|
260
260
|
export async function nextCronAt(ctx: TenantContext, now = Date.now()): Promise<number | null> {
|
|
261
261
|
const times: number[] = [];
|
|
@@ -2180,7 +2180,7 @@ async function mcpGrantOf(ctx: TenantContext, header: string | null): Promise<Js
|
|
|
2180
2180
|
}
|
|
2181
2181
|
|
|
2182
2182
|
/**
|
|
2183
|
-
* Hosted owner RPC. The
|
|
2183
|
+
* Hosted owner RPC. The caller has already checked that `accountId` owns this tenant.
|
|
2184
2184
|
* Only an allowlisted path runs, and the response is withheld when it contains a peer token or a secret.
|
|
2185
2185
|
* Self-host (`mode` single) is refused here; its owner API stays the bearer check in `handleOwner`.
|
|
2186
2186
|
*/
|
package/worker/src/owner-rpc.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
// Hosted owner RPC allowlist. The
|
|
2
|
-
//
|
|
1
|
+
// Hosted owner RPC allowlist. The caller has checked that the account owns the tenant.
|
|
2
|
+
// This list is the only paths that call may run, and it is stricter than
|
|
3
3
|
// the owner HTTP API: routes that mint a peer token, return a setup secret, or accept a plaintext peer
|
|
4
4
|
// token are not here. GET /owner/export is here only because runOwnerRpc redacts it first. Self-host never calls this.
|
|
5
5
|
|
|
6
6
|
import { ID_RE } from "./a2a.ts";
|
|
7
7
|
import { credentialIsMasked, previewHeaderPlain } from "./wake.ts";
|
|
8
8
|
|
|
9
|
-
/**
|
|
9
|
+
/** Account id. Slashes and dots are refused so the value cannot be a path. */
|
|
10
10
|
export const ACCOUNT_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
11
11
|
|
|
12
12
|
const LABEL = /^[A-Za-z0-9_-]{1,32}$/;
|
|
@@ -42,7 +42,7 @@ export interface OwnerRpcRequest {
|
|
|
42
42
|
/** Refused. The owner HTTP API is where a body is accepted. */
|
|
43
43
|
body?: unknown;
|
|
44
44
|
/**
|
|
45
|
-
*
|
|
45
|
+
* Approve or deny after a recent sign-in.
|
|
46
46
|
* Any other value is refused. Absent keeps the owner HTTP API's actor (`agent` or `owner`).
|
|
47
47
|
*/
|
|
48
48
|
actor?: "human";
|
package/worker/src/storage.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// D1-shaped database over a Durable Object's SQLite (`ctx.storage.sql`).
|
|
2
2
|
// Handlers keep calling prepare().bind().first()/all()/run() and batch(). Self-host keeps the real D1 binding;
|
|
3
|
-
// a
|
|
3
|
+
// a per-inbox Durable Object uses this adapter on its own object. `migrateDo` matches `cf d1 migrations apply`: the same
|
|
4
4
|
// `d1_migrations` table, unrecorded files in numeric order, one transaction per file.
|
|
5
5
|
|
|
6
6
|
/** The slice of the Workers `SqlStorage` API this adapter uses. Tests can implement it over node:sqlite. */
|
package/worker/src/tenancy.ts
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Context for one inbox.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// Default (TENANCY unset): one inbox. Config and OWNER_TOKEN come from the Worker env.
|
|
4
4
|
// Handlers receive this object instead of reading env.* themselves. With every gate unset, owner checks and the
|
|
5
5
|
// outbound peer-token key are the same operations as before (existing ciphertext still decrypts).
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// the issuer, client id, client secret and an allowlist are all set (approval-oidc.ts).
|
|
7
|
+
// When a pushed config is set, and only when TENANCY is "host" and TENANT_DO is bound, owner auth compares a
|
|
8
|
+
// token hash and the peer-token key is derived from TENANT_SECRETS_KEY and the inbox id. The fetch path does not
|
|
9
|
+
// build that overlay. Settings below are parsed either way. Some of them stay unused until a caller reads
|
|
10
|
+
// ctx.gates. OpenID Connect approval is enforced by the approval pages when the issuer, client id, client secret,
|
|
11
|
+
// and an allowlist are all set (approval-oidc.ts).
|
|
13
12
|
|
|
14
13
|
import * as A from "./a2a.ts";
|
|
15
14
|
|
|
@@ -46,9 +45,9 @@ export type ConfigKey = (typeof CONFIG_KEYS)[number];
|
|
|
46
45
|
export type ConfigFields = { [K in ConfigKey]?: string };
|
|
47
46
|
|
|
48
47
|
/**
|
|
49
|
-
* Non-secret
|
|
50
|
-
* Wake secrets, upstream secrets and OWNER_TOKEN are never
|
|
51
|
-
* fields come from decrypting `secrets_enc
|
|
48
|
+
* Non-secret defaults used when a pushed config omits them.
|
|
49
|
+
* Wake secrets, upstream secrets, and OWNER_TOKEN are never copied. Secret wake
|
|
50
|
+
* fields come from decrypting `secrets_enc`. A plaintext copy in the pushed
|
|
52
51
|
* config is used only when that ciphertext is absent.
|
|
53
52
|
*/
|
|
54
53
|
const HOSTED_PLATFORM_DEFAULTS: readonly ConfigKey[] = ["MAX_BODY", "RATE_PER_MIN", "PBKDF2_ITERATIONS", "MCP", "PAIRING_APPROVAL"];
|
|
@@ -86,17 +85,17 @@ export interface WorkerBindings extends ConfigFields {
|
|
|
86
85
|
TENANT_DO?: DoNamespace;
|
|
87
86
|
/** unset | eu | fedramp. Applied as a DO jurisdiction when the hosted router addresses a tenant. */
|
|
88
87
|
DATA_REGION?: string;
|
|
89
|
-
/**
|
|
88
|
+
/** Parsed and unused by this Worker. */
|
|
90
89
|
QUOTAS?: string;
|
|
91
|
-
/**
|
|
90
|
+
/** Parsed and unused by this Worker. */
|
|
92
91
|
USAGE_SINK?: string;
|
|
93
92
|
/** "public-https" refuses non-https, private, and metadata wake targets. Anything else is unset. */
|
|
94
93
|
WAKE_TARGET_POLICY?: string;
|
|
95
|
-
/** https URL
|
|
94
|
+
/** https URL. Parsed and unused by this Worker. */
|
|
96
95
|
SIGNUP_URL?: string;
|
|
97
|
-
/** https origin, no path.
|
|
96
|
+
/** https origin, no path. Posts go there when this deploy sets the URL. */
|
|
98
97
|
FEEDBACK_URL?: string;
|
|
99
|
-
/**
|
|
98
|
+
/** Parsed string. Not rendered by this Worker. */
|
|
100
99
|
BRANDING?: string;
|
|
101
100
|
APPROVAL_OIDC_ISSUER?: string;
|
|
102
101
|
APPROVAL_OIDC_CLIENT_ID?: string;
|
|
@@ -106,8 +105,8 @@ export interface WorkerBindings extends ConfigFields {
|
|
|
106
105
|
/** Comma-separated: password, oidc. Unset keeps the password and also offers OIDC once it is fully configured. */
|
|
107
106
|
APPROVAL_METHODS?: string;
|
|
108
107
|
/**
|
|
109
|
-
* Service binding
|
|
110
|
-
*
|
|
108
|
+
* Service binding for OpenID Connect discovery, token, and JWKS when the issuer is on this
|
|
109
|
+
* tenant's zone. Unset: those calls use `fetch`.
|
|
111
110
|
*/
|
|
112
111
|
CONTROL_SERVICE?: ControlFetcher;
|
|
113
112
|
}
|
|
@@ -145,15 +144,15 @@ export interface ApprovalOidcGate {
|
|
|
145
144
|
methods: string[];
|
|
146
145
|
/** True only when the setting listed at least one of password or oidc. Unset is false. */
|
|
147
146
|
methodsSpecified: boolean;
|
|
148
|
-
/** Short button label from
|
|
147
|
+
/** Short button label from the pushed approval config, or "". */
|
|
149
148
|
label: string;
|
|
150
149
|
}
|
|
151
150
|
|
|
152
151
|
export interface FeatureGates {
|
|
153
152
|
tenancy: TenancyMode;
|
|
154
153
|
/**
|
|
155
|
-
* True only when TENANCY is exactly "host" and TENANT_DO is bound. That
|
|
156
|
-
* Any other combination, including TENANCY=host alone, keeps the
|
|
154
|
+
* True only when TENANCY is exactly "host" and TENANT_DO is bound. That switches storage off env.DB.
|
|
155
|
+
* Any other combination, including TENANCY=host alone, keeps the single-inbox path on env.DB.
|
|
157
156
|
*/
|
|
158
157
|
hostedStorage: boolean;
|
|
159
158
|
quotas: boolean;
|
|
@@ -161,20 +160,20 @@ export interface FeatureGates {
|
|
|
161
160
|
wakeTargetPolicy: "" | "public-https";
|
|
162
161
|
signupUrl: string;
|
|
163
162
|
branding: "" | "hosted";
|
|
164
|
-
/** https origin
|
|
163
|
+
/** https origin that accepts POST /api/feedback, or "". Ignored unless mode is host. */
|
|
165
164
|
feedbackUrl: string;
|
|
166
165
|
dataRegion: DataRegion;
|
|
167
166
|
tenantDomain: string;
|
|
168
167
|
approvalOidc: ApprovalOidcGate;
|
|
169
168
|
}
|
|
170
169
|
|
|
171
|
-
/** Pushed tenant row
|
|
170
|
+
/** Pushed tenant row. Tests build a context from this; the Durable Object stores it. */
|
|
172
171
|
export interface HostedOverlay {
|
|
173
172
|
id: string;
|
|
174
173
|
db: SqlDb;
|
|
175
174
|
publicUrl: string;
|
|
176
175
|
ownerTokenHash?: string | null;
|
|
177
|
-
/** Non-secret and
|
|
176
|
+
/** Non-secret and secret config fields. Never OWNER_TOKEN. */
|
|
178
177
|
config?: Record<string, string | undefined>;
|
|
179
178
|
limits?: Record<string, unknown>;
|
|
180
179
|
approval?: Record<string, unknown>;
|
|
@@ -252,7 +251,7 @@ function subjectsOf(v: unknown): string[] {
|
|
|
252
251
|
return list.map((s) => s.trim()).filter(Boolean).slice(0, 100);
|
|
253
252
|
}
|
|
254
253
|
|
|
255
|
-
/** Approval gate from a
|
|
254
|
+
/** Approval gate from a pushed `approval` object (camelCase). The client secret is returned beside the gate. */
|
|
256
255
|
export function approvalFromRecord(raw: Record<string, unknown> | undefined): { gate: ApprovalOidcGate; clientSecret: string; publicRecord: Record<string, unknown> } {
|
|
257
256
|
const { methods, specified } = recognizedMethods(Array.isArray(raw?.methods) ? raw!.methods.map((s) => String(s)) : typeof raw?.methods === "string" ? csv(raw.methods) : []);
|
|
258
257
|
const clientSecret = typeof raw?.clientSecret === "string" ? raw.clientSecret.trim() : "";
|
|
@@ -376,7 +375,7 @@ export class TenantContext implements ConfigFields {
|
|
|
376
375
|
readonly #secretsKey?: string;
|
|
377
376
|
/** OpenID Connect client secret. Never logged and never copied onto `gates`. */
|
|
378
377
|
readonly #approvalClientSecret?: string;
|
|
379
|
-
/**
|
|
378
|
+
/** Service binding for the issuer. Not a secret. Absent on a self-host deploy. */
|
|
380
379
|
readonly #controlService?: ControlFetcher;
|
|
381
380
|
#peerKey: Promise<CryptoKey | null> | null = null;
|
|
382
381
|
|
|
@@ -407,7 +406,7 @@ export class TenantContext implements ConfigFields {
|
|
|
407
406
|
return this.#approvalClientSecret || "";
|
|
408
407
|
}
|
|
409
408
|
|
|
410
|
-
/**
|
|
409
|
+
/** Issuer service binding, or undefined when this deploy has none. */
|
|
411
410
|
controlService(): ControlFetcher | undefined {
|
|
412
411
|
return this.#controlService;
|
|
413
412
|
}
|
package/worker/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Inbox build. Keep this equal to `version` in cli/package.json. */
|
|
2
|
-
export const VERSION = "0.
|
|
2
|
+
export const VERSION = "0.8.0";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Wake secrets sealed for one
|
|
2
|
-
// object decrypts it when it builds the
|
|
1
|
+
// Wake secrets sealed for one inbox record. The writer stores `secrets_enc`; the inbox
|
|
2
|
+
// object decrypts it when it builds the context. A single-inbox deploy does not use this.
|
|
3
3
|
// Keep control/src/tenants/wake-secrets.ts in step with this file.
|
|
4
4
|
|
|
5
5
|
const INFO = "tenant-wake-secrets v1";
|
|
@@ -271,7 +271,7 @@ test("hosted approval reaches a same-zone issuer through CONTROL_SERVICE", async
|
|
|
271
271
|
return new Response("missing", { status: 404 });
|
|
272
272
|
},
|
|
273
273
|
},
|
|
274
|
-
// A public fetch of another host on this zone does not reach the
|
|
274
|
+
// A public fetch of another host on this zone does not reach the issuer. If it is attempted, answer 522.
|
|
275
275
|
outboundService: async (request: Request) => {
|
|
276
276
|
publicHosts.push(new URL(request.url).hostname);
|
|
277
277
|
return new Response("origin is unreachable", { status: 522 });
|
|
@@ -367,7 +367,7 @@ test("hosted approval reaches a same-zone issuer through CONTROL_SERVICE", async
|
|
|
367
367
|
assert.match(await missedPost.text(), /The identity provider could not be reached/);
|
|
368
368
|
});
|
|
369
369
|
|
|
370
|
-
test("an apex issuer is fetched with that Host, and a
|
|
370
|
+
test("an apex issuer is fetched with that Host, and a binding that does not answer still returns", async (t) => {
|
|
371
371
|
const issuer = "https://example.com";
|
|
372
372
|
const tenant = "https://northwind.example.com";
|
|
373
373
|
const control = `
|