a2a-exposed 0.7.14 → 0.7.15

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/main.mjs CHANGED
@@ -192,6 +192,7 @@ Control plane account (only when that deployment's login is on)
192
192
  that token after a confirmation and a recent sign-in. The new token is
193
193
  shown once on that agent's page. The previous one stops working immediately.
194
194
  The token is shown only when the stored hash is still that token.
195
+ Deleting an agent asks for its name and a recent sign-in. A self-hosted inbox has no such delete.
195
196
  That page is not stored.
196
197
  FREE_AGENT_LIMIT=2 caps a free account at 2 agents. An account's own limit overrides it.
197
198
  Unset, there is no limit. The database rejects another insert.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.7.14",
3
+ "version": "0.7.15",
4
4
  "description": "Give any AI agent a public A2A (Agent2Agent) endpoint: Cloudflare Worker inbox + wake webhook or scheduled inbox checks",
5
5
  "type": "module",
6
6
  "bin": {
@@ -100,7 +100,7 @@ const OMIT_COLUMNS: Record<string, readonly string[]> = {
100
100
  outbound: ["push_token_hash"],
101
101
  mcp_grants: ["access_hash", "refresh_hash"],
102
102
  outbound_peers: ["token_enc"],
103
- device_requests: ["replaces_hash"],
103
+ device_requests: ["replaces_hash", "ip", "country"],
104
104
  };
105
105
 
106
106
  const SETUP_SUB = /([?&])t=[A-Za-z0-9_-]{16,}/g;
@@ -120,7 +120,7 @@ function placeholder(table: string, row: Record<string, unknown>, index: number)
120
120
 
121
121
  /**
122
122
  * A copy of `file` safe to show on the dashboard.
123
- * Nullable credential columns are removed, including `device_requests.replaces_hash` (the hash of the token a re-pair would replace).
123
+ * Nullable credential columns are removed, including `device_requests.replaces_hash` (the hash of the token a re-pair would replace) and `device_requests.ip` and `device_requests.country` (the requester's address).
124
124
  * `peers.token_hash` and `device_requests.device_hash` are NOT NULL, so they become `redacted:…` (not the hash, and not a token that authenticates).
125
125
  * `oauth_codes` and `oidc_txns` are emptied. The approval-password row and the password-setup row (its value is the hash of the live setup token) are removed.
126
126
  * Token-shaped text, including a hosted owner token (`a2aot_`), is scrubbed. The input is not changed: the CLI dump stays complete.
@@ -269,6 +269,9 @@ export class TenantStore extends DurableObject<WorkerBindings> {
269
269
  async pushConfig(body: TenantConfigPush): Promise<{ version: number; applied: boolean }> {
270
270
  return await this.ctx.blockConcurrencyWhile(async () => {
271
271
  checkPush(body);
272
+ const deleted = await (this.ctx.storage as unknown as { get(key: string): Promise<unknown> }).get("tenant_deleted");
273
+ // deleteStorage leaves this key after wiping SQL. A later push must not recreate the agent.
274
+ if (deleted != null) return { version: 0, applied: false };
272
275
  const current = this.#row ?? readRow(this.#sql);
273
276
  if (current && body.version <= current.version) {
274
277
  // A previous push may have stored this version and then failed while migrating. Catch up; don't rewrite the row.
@@ -322,6 +325,30 @@ export class TenantStore extends DurableObject<WorkerBindings> {
322
325
  return { configured: this.#row !== null, version: this.#row?.version ?? 0, tables: appTables(this.#sql) };
323
326
  }
324
327
 
328
+ /**
329
+ * Drop this object's alarm and storage. A second call on an empty object succeeds.
330
+ * Not an owner HTTP route. Self-host never calls it.
331
+ */
332
+ async deleteStorage(): Promise<{ deleted: true }> {
333
+ return await this.ctx.blockConcurrencyWhile(async () => {
334
+ const storage = this.ctx.storage as unknown as {
335
+ deleteAlarm(): Promise<void>;
336
+ deleteAll(): Promise<void>;
337
+ put(key: string, value: string): Promise<void>;
338
+ };
339
+ const tenantId = this.#row?.tenantId ?? null;
340
+ await storage.deleteAlarm();
341
+ await storage.deleteAll();
342
+ const left = readRow(this.#sql);
343
+ if (left) throw new Error("deleteStorage: data remains");
344
+ // After SQL is gone. A later pushConfig sees this and does not recreate the agent.
345
+ await storage.put("tenant_deleted", "1");
346
+ this.#row = null;
347
+ console.log(JSON.stringify({ ts: new Date().toISOString(), event: "tenant_storage_deleted", tenant: tenantId }));
348
+ return { deleted: true };
349
+ });
350
+ }
351
+
325
352
  /** SQL dump of the application tables. Refuses an object that has no config row. */
326
353
  async exportSql(): Promise<{ sql: string; exportedAt: string }> {
327
354
  return await this.ctx.blockConcurrencyWhile(async () => {
@@ -88,7 +88,7 @@ test("redactExport drops credential material and leaves the full dump unchanged"
88
88
  await DB.prepare("INSERT INTO settings (key, value) VALUES ('approval_password', ?)").bind(JSON.stringify({ hash: "x".repeat(40) })).run();
89
89
  await DB.prepare("INSERT INTO settings (key, value) VALUES ('password_setup', ?)").bind(JSON.stringify({ hash: setupHash, createdMs: 1, expiresMs: 2, failures: 0 })).run();
90
90
  await DB.prepare("INSERT INTO settings (key, value) VALUES ('theme', 'kept')").run();
91
- await DB.prepare("INSERT INTO device_requests (device_hash, user_code, status, created_ms, expires_ms, interval_s, replaces_label, replaces_hash) VALUES ('device-hash-secret', 'WDJB4827', 'pending', 1, 2, 5, 'ada', ?)").bind(replaces).run();
91
+ await DB.prepare("INSERT INTO device_requests (device_hash, user_code, status, created_ms, expires_ms, interval_s, replaces_label, replaces_hash, ip, country) VALUES ('device-hash-secret', 'WDJB4827', 'pending', 1, 2, 5, 'ada', ?, '203.0.113.50', 'XQ')").bind(replaces).run();
92
92
  await DB.prepare("INSERT INTO oauth_codes (code_hash, client_id, redirect_uri, code_challenge, scope, resource, created_ms, expires_ms) VALUES ('code-hash', 'client', 'https://client.example/cb', 'challenge-secret-value', 'mcp', 'https://agent.example/mcp', 1, 2)").run();
93
93
  await DB.prepare("INSERT INTO mcp_grants (label, client_id, scope, access_hash, refresh_hash, created_at) VALUES ('mcp-claude', 'client', 'mcp', 'access-hash-secret', 'refresh-hash-secret', ?)").bind(now).run();
94
94
  await DB.prepare("INSERT INTO outbound_peers (alias, url, token_enc, created_at, updated_at) VALUES ('ada', 'https://ada.example', 'ciphertext-secret-value', ?, ?)").bind(now, now).run();
@@ -97,12 +97,16 @@ test("redactExport drops credential material and leaves the full dump unchanged"
97
97
  const redacted = redactExport(file);
98
98
  assert.equal(file.tables.peers?.[0]?.token_hash, hash, "the CLI dump still has the hash");
99
99
  assert.equal(file.tables.device_requests?.[0]?.replaces_hash, replaces);
100
+ assert.equal(file.tables.device_requests?.[0]?.ip, "203.0.113.50");
101
+ assert.equal(file.tables.device_requests?.[0]?.country, "XQ");
100
102
  assert.equal(file.tables.settings?.some((row) => row.key === "password_setup"), true);
101
103
  assert.equal(file.tables.oidc_txns?.[0]?.verifier, "verifier-secret-value");
102
104
  assert.equal(redacted.tables.peers?.[0]?.token_hash, "redacted:ada");
103
105
  assert.equal(redacted.tables.peers?.[0]?.label, "ada");
104
106
  assert.equal(redacted.tables.device_requests?.[0]?.device_hash, "redacted:WDJB4827");
105
107
  assert.equal(redacted.tables.device_requests?.[0]?.replaces_hash, undefined);
108
+ assert.equal(Object.hasOwn(redacted.tables.device_requests?.[0] ?? {}, "ip"), false);
109
+ assert.equal(Object.hasOwn(redacted.tables.device_requests?.[0] ?? {}, "country"), false);
106
110
  assert.equal(redacted.tables.device_requests?.[0]?.replaces_label, "ada");
107
111
  assert.equal(redacted.tables.device_requests?.[0]?.user_code, "WDJB4827");
108
112
  assert.equal(redacted.tables.tasks?.[0]?.push_configs_json, undefined);
@@ -130,6 +134,8 @@ test("redactExport drops credential material and leaves the full dump unchanged"
130
134
  assert.equal(sql.includes("oauth_codes"), false);
131
135
  assert.equal(sql.includes("oidc_txns"), false);
132
136
  assert.equal(sql.includes("replaces_hash"), false);
137
+ assert.equal(sql.includes("203.0.113.50"), false);
138
+ assert.equal(sql.includes("XQ"), false);
133
139
  assert.equal(sql.includes("password_setup"), false);
134
140
  assert.equal(ownerResponseLeaks({ sql }), false);
135
141
  for (const secret of [token, owner, hash, replaces, setupHash]) {
@@ -138,6 +144,8 @@ test("redactExport drops credential material and leaves the full dump unchanged"
138
144
  assert.match(toSql(file), /verifier-secret-value/);
139
145
  assert.match(toSql(file), new RegExp(hash));
140
146
  assert.match(toSql(file), /replaces_hash/);
147
+ assert.match(toSql(file), /203\.0\.113\.50/);
148
+ assert.match(toSql(file), /'XQ'/);
141
149
  });
142
150
 
143
151
  test("scrubSecretText redacts a hosted owner token on the log and export paths", () => {
@@ -173,19 +181,36 @@ test("redactExport removes device_requests.replaces_hash", () => {
173
181
  status: "pending",
174
182
  replaces_label: "ada",
175
183
  replaces_hash: replaces,
184
+ ip: "203.0.113.50",
185
+ country: "XQ",
176
186
  }],
177
187
  },
178
188
  });
179
189
  const row = redacted.tables.device_requests?.[0] ?? {};
180
190
  assert.equal(Object.hasOwn(row, "replaces_hash"), false);
191
+ assert.equal(Object.hasOwn(row, "ip"), false);
192
+ assert.equal(Object.hasOwn(row, "country"), false);
181
193
  assert.equal(row.replaces_label, "ada");
182
194
  assert.equal(row.device_hash, "redacted:WDJB4827");
183
195
  const sql = toSql(redacted, { skip: REDACTED_SQL_SKIP });
184
196
  assert.equal(sql.includes("replaces_hash"), false);
197
+ assert.equal(sql.includes("203.0.113.50"), false);
185
198
  assert.equal(sql.includes(replaces), false);
186
199
  assert.equal(JSON.stringify(redacted).includes(replaces), false);
187
200
  });
188
201
 
202
+ test("self-host owner HTTP has no delete route", async () => {
203
+ const DB = db();
204
+ const env = { DB, PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret" };
205
+ const res = await worker.fetch(new Request(BASE + "/owner/delete", {
206
+ method: "POST",
207
+ headers: { authorization: "Bearer owner-secret" },
208
+ }) as never, env as never, ectx as never);
209
+ assert.equal(res.status, 404);
210
+ const tables = await DB.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'peers'").all<{ name: string }>();
211
+ assert.equal(tables.results.length, 1);
212
+ });
213
+
189
214
  test("exportRows reads every application table in one batch", async () => {
190
215
  const DB = db();
191
216
  await DB.prepare("INSERT INTO peers (label, token_hash, created_at) VALUES ('ada', 'h', 't')").run();
@@ -750,3 +750,58 @@ test("TenantStore.owner runs an allowlisted owner call and does not return a pee
750
750
  assert.equal(await saw('"reason":"account"'), true);
751
751
  assert.equal(lines.some((l) => l.includes("../acct")), false);
752
752
  });
753
+
754
+ test("TenantStore.deleteStorage wipes the object and a second call succeeds", async (t) => {
755
+ const mf = await start({
756
+ bindings: {
757
+ TENANCY: "host",
758
+ TENANT_DOMAIN: "example.com",
759
+ OWNER_TOKEN: "platform-owner",
760
+ },
761
+ kvNamespaces: { TENANT_DIRECTORY: "tenants-delete" },
762
+ durableObjects: { TENANT_DO: { className: "TenantStore", useSQLite: true } },
763
+ });
764
+ t.after(() => mf.dispose());
765
+ await mf.ready;
766
+ const kv = await mf.getKVNamespace("TENANT_DIRECTORY");
767
+ const ns = await mf.getDurableObjectNamespace("TENANT_DO");
768
+ const hash = await sha256("owner-delta");
769
+ type Stub = {
770
+ pushConfig(body: Record<string, unknown>): Promise<{ version: number; applied: boolean }>;
771
+ deleteStorage(): Promise<{ deleted: boolean }>;
772
+ storageStatus(): Promise<{ configured: boolean; tables: string[] }>;
773
+ };
774
+ const stub = ns.get(ns.idFromName("id-delta")) as unknown as Stub;
775
+ await stub.pushConfig({
776
+ version: 1, tenantId: "id-delta", name: "delta", status: "active", ownerTokenHash: hash,
777
+ config: { AGENT_NAME: "delta" },
778
+ });
779
+ await kv.put("tenant:delta", JSON.stringify({ id: "id-delta", status: "active", region: "default", version: 1 }));
780
+ const before = await stub.storageStatus();
781
+ assert.equal(before.configured, true);
782
+ assert.ok(before.tables.includes("tenant_config"));
783
+ const http = await mf.dispatchFetch("https://delta.example.com/owner/delete", {
784
+ method: "POST",
785
+ headers: { authorization: "Bearer owner-delta" },
786
+ });
787
+ assert.equal(http.status, 404);
788
+ assert.equal((await stub.storageStatus()).configured, true, "the owner HTTP API does not wipe storage");
789
+ const wiped = await stub.deleteStorage();
790
+ assert.equal(wiped.deleted, true);
791
+ const after = await stub.storageStatus();
792
+ assert.equal(after.configured, false);
793
+ assert.deepEqual(after.tables, []);
794
+ const again = await stub.deleteStorage();
795
+ assert.equal(again.deleted, true);
796
+ assert.equal((await stub.storageStatus()).configured, false);
797
+ assert.deepEqual((await stub.storageStatus()).tables, []);
798
+ const refused = await stub.pushConfig({
799
+ version: 2, tenantId: "id-delta", name: "delta", status: "active", ownerTokenHash: hash,
800
+ config: { AGENT_NAME: "delta" },
801
+ });
802
+ assert.equal(refused.applied, false);
803
+ assert.equal(refused.version, 0);
804
+ const stayed = await stub.storageStatus();
805
+ assert.equal(stayed.configured, false);
806
+ assert.deepEqual(stayed.tables, []);
807
+ });