a2a-exposed 0.8.17 → 0.8.19
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/commands.mjs +4 -1
- package/lib/deploy.mjs +1 -1
- package/lib/main.mjs +13 -8
- package/lib/status.mjs +4 -4
- package/package.json +1 -1
- package/worker/cloudflare.config.ts +1 -1
- package/worker/migrations/0012_conversation_peer_owner.sql +13 -0
- package/worker/scripts/stamp-version.mjs +1 -1
- package/worker/src/index.ts +147 -40
- package/worker/src/mcp.ts +2 -1
- package/worker/src/migrations.ts +2 -0
- package/worker/src/owner-rpc.ts +11 -5
- package/worker/src/version.ts +1 -1
- package/worker/src/wake.ts +16 -4
- package/worker/test/context-peer.test.ts +198 -79
- package/worker/test/facade.test.ts +6 -2
- package/worker/test/list-tasks.test.ts +120 -0
- package/worker/test/migrations.test.ts +18 -6
- package/worker/test/owner-rpc.test.ts +4 -0
- package/worker/test/push-config.test.ts +161 -0
- package/worker/test/wake-retry.test.ts +28 -0
- package/worker/test/wake.test.ts +25 -3
package/lib/commands.mjs
CHANGED
|
@@ -55,6 +55,7 @@ async function inboxRows(o) {
|
|
|
55
55
|
return followInbox(async (cursor) => {
|
|
56
56
|
const qs = [];
|
|
57
57
|
if (o.context) qs.push("context=" + q(o.context));
|
|
58
|
+
if (o.from) qs.push("from=" + q(o.from));
|
|
58
59
|
if (o.all) qs.push("all=1");
|
|
59
60
|
if (cursor) qs.push("cursor=" + q(cursor));
|
|
60
61
|
const path = "/owner/inbox" + (qs.length ? "?" + qs.join("&") : "");
|
|
@@ -125,7 +126,9 @@ export async function reply(id, o) {
|
|
|
125
126
|
}
|
|
126
127
|
|
|
127
128
|
export async function history(ctx, o) {
|
|
128
|
-
const
|
|
129
|
+
const qs = new URLSearchParams({ n: String(Number(o.n || 50)) });
|
|
130
|
+
if (o.from) qs.set("from", o.from);
|
|
131
|
+
const rows = await owner("GET", `/owner/history/${q(ctx)}?${qs}`);
|
|
129
132
|
if (o.json) return out(rows);
|
|
130
133
|
for (const e of rows) {
|
|
131
134
|
const tag = [e.event, e.state || e.role].filter(Boolean).join("/");
|
package/lib/deploy.mjs
CHANGED
|
@@ -25,7 +25,7 @@ export const CLAUDE_CODE_NOTE = [
|
|
|
25
25
|
" Then open the new session from the routine's runs and confirm it ran the inbox command without errors.",
|
|
26
26
|
].join("\n");
|
|
27
27
|
|
|
28
|
-
const PRESETS = ["grok-bot", "claude-code", "openclaw-wake", "openclaw-agent", "hermes", "generic"];
|
|
28
|
+
const PRESETS = ["grok-bot", "claude-code", "openclaw-wake", "openclaw-agent", "hermes", "openbot", "generic"];
|
|
29
29
|
// WAKE_ACCESS_*: Cloudflare Access service token for a wake URL behind Access (set by `tunnel create`, or exported
|
|
30
30
|
// by hand for your own Access-protected endpoint)
|
|
31
31
|
export const WAKE_SECRETS = ["WAKE_WEBHOOK_URL", "WAKE_WEBHOOK_KEY", "WAKE_HMAC_SECRET", "WAKE_ACCESS_CLIENT_ID", "WAKE_ACCESS_CLIENT_SECRET"];
|
package/lib/main.mjs
CHANGED
|
@@ -92,9 +92,11 @@ https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md
|
|
|
92
92
|
wake set [--preset P] [--agent-id A] [--key-header H] [--key-prefix X] [--body-template JSON]
|
|
93
93
|
[--cli-command CMD] [--debounce S] [--max-per-hour N]
|
|
94
94
|
save wake settings, upload wake secrets from env, and redeploy (one step)
|
|
95
|
+
openbot: human supplies WAKE_HMAC_SECRET via protected env/secret storage, never chat or argv
|
|
95
96
|
--max-per-hour hourly cap; 0 or unset is unlimited, except Claude Code,
|
|
96
97
|
which defaults to 25 when this is unset
|
|
97
98
|
wake preview rendered wake request (a credential is Bearer •••• or ••••, none of the secret)
|
|
99
|
+
openbot includes a sample delivery ID and canonical signing-input description
|
|
98
100
|
+ sha256 fingerprints of the uploaded URL/key/HMAC secret; compared with
|
|
99
101
|
local env values when WAKE_WEBHOOK_URL, WAKE_WEBHOOK_KEY, and WAKE_HMAC_SECRET
|
|
100
102
|
are exported in this shell. match: the exported value is the one on the Worker.
|
|
@@ -116,7 +118,7 @@ anywhere on the account: the A2A endpoint itself can be on workers.dev or a cust
|
|
|
116
118
|
creates nothing when you run it again. A partial tunnel stops and asks for tunnel rm.
|
|
117
119
|
The tunnel, the Access app, and the service token are named "a2a-exposed wake <worker>".
|
|
118
120
|
The DNS host is wake-<word>-<word>-<4 hex>.<zone> unless --tunnel-hostname is set.
|
|
119
|
-
Export WAKE_WEBHOOK_KEY (hermes: WAKE_HMAC_SECRET) so tunnel create uploads it.
|
|
121
|
+
Export WAKE_WEBHOOK_KEY (hermes/openbot: WAKE_HMAC_SECRET) so tunnel create uploads it.
|
|
120
122
|
The connector machine needs cloudflared installed
|
|
121
123
|
(https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/).
|
|
122
124
|
Defaults: openclaw-* -> http://127.0.0.1:18789 /hooks/wake|agent, hermes -> :8644
|
|
@@ -165,9 +167,11 @@ Status
|
|
|
165
167
|
config print config (secrets masked) and its location
|
|
166
168
|
|
|
167
169
|
Inbox (owner side)
|
|
168
|
-
inbox [--context C] [--all] [--json]
|
|
169
|
-
|
|
170
|
-
|
|
170
|
+
inbox [--context C] [--from PEER] [--all] [--json]
|
|
171
|
+
unhandled inbound tasks, and a reply to a message you sent until
|
|
172
|
+
this list is read (peer text is UNTRUSTED). --from is one peer
|
|
173
|
+
(the wake hint includes it). A reply line says to read it with
|
|
174
|
+
history and continue with send. Then pending pairing
|
|
171
175
|
requests (tell your human; the dashboard URL when MANAGE_URL is set;
|
|
172
176
|
--json: rows on stdout, requests on stderr). Follows every page; past
|
|
173
177
|
10000 pages it errors instead of a short list
|
|
@@ -175,7 +179,8 @@ Inbox (owner side)
|
|
|
175
179
|
working <taskId> mark task working
|
|
176
180
|
reply <taskId> (--text T | --stdin) [--state completed|input-required|rejected|failed|working]
|
|
177
181
|
[--artifact] [--artifact-name N] [--force]
|
|
178
|
-
history <contextId> [-n 50] [--json]
|
|
182
|
+
history <contextId> [--from PEER] [-n 50] [--json]
|
|
183
|
+
conversation log. --from is one peer (the wake hint includes it)
|
|
179
184
|
contexts recent conversations
|
|
180
185
|
export [--sql] JSON dump of this A2A endpoint on stdout (peer-token hashes and
|
|
181
186
|
encrypted peer tokens are in the dump; the owner token stays
|
|
@@ -264,7 +269,7 @@ Feedback
|
|
|
264
269
|
polling), and hosted or self-hosted. --connection overrides
|
|
265
270
|
that detection. Remove tokens from the message and from --logs.
|
|
266
271
|
|
|
267
|
-
Presets: grok-bot | claude-code | openclaw-wake | openclaw-agent | hermes | generic
|
|
272
|
+
Presets: grok-bot | claude-code | openclaw-wake | openclaw-agent | hermes | openbot | generic
|
|
268
273
|
Which clients are tested end to end is in the client table:
|
|
269
274
|
https://github.com/telegraphic-dev/a2a-exposed/blob/main/docs/getting-started.md#clients
|
|
270
275
|
Config: ${C.CONFIG_FILE} (override dir with --config-dir DIR or A2A_CONFIG_DIR; env vars override file values)
|
|
@@ -312,9 +317,9 @@ const deployOpts = {
|
|
|
312
317
|
};
|
|
313
318
|
const SPEC = {
|
|
314
319
|
init: { ...deployOpts, ...tunnelOpts, tunnel: B }, deploy: deployOpts, wake: deployOpts, tunnel: tunnelOpts,
|
|
315
|
-
inbox: { context: S, all: B, json: B },
|
|
320
|
+
inbox: { context: S, from: S, all: B, json: B },
|
|
316
321
|
reply: { text: S, stdin: B, state: { type: "string", default: "completed" }, artifact: B, "artifact-name": { type: "string", default: "response" }, force: B },
|
|
317
|
-
history: { n: { type: "string", short: "n", default: "50" }, json: B },
|
|
322
|
+
history: { from: S, n: { type: "string", short: "n", default: "50" }, json: B },
|
|
318
323
|
token: { rotate: B },
|
|
319
324
|
peers: { "token-env": S, "token-stdin": B },
|
|
320
325
|
send: { to: S, text: S, context: S, task: S, push: B, proto: S },
|
package/lib/status.mjs
CHANGED
|
@@ -171,8 +171,8 @@ function nextStepCore(s) {
|
|
|
171
171
|
if (t && !t.complete) return fail(`a previous \`tunnel create\` did not finish: run \`${CLI} tunnel rm\`, then \`${CLI} tunnel create\``);
|
|
172
172
|
if (t && !t.tokenFileOk)
|
|
173
173
|
return fail(`the tunnel's connector token file (${t.tokenFile}) is missing or empty: run \`${CLI} tunnel create\` again (it downloads the token again; nothing new is created)`);
|
|
174
|
-
const agentSecret = s.wake.preset
|
|
175
|
-
const needsAgentSecret = LOCAL_PRESETS.includes(s.wake.preset) && !s.wake.hasKey && !s.wake.hasHmacSecret;
|
|
174
|
+
const agentSecret = ["hermes", "openbot"].includes(s.wake.preset) ? "WAKE_HMAC_SECRET" : "WAKE_WEBHOOK_KEY";
|
|
175
|
+
const needsAgentSecret = s.wake.preset === "openbot" ? !s.wake.hasHmacSecret : LOCAL_PRESETS.includes(s.wake.preset) && !s.wake.hasKey && !s.wake.hasHmacSecret;
|
|
176
176
|
const now = typeof s.now === "number" ? s.now : Date.now();
|
|
177
177
|
if (t && !(s.wake.hasAccessServiceToken && t.workerUrlMatches)) {
|
|
178
178
|
if (secretsPropagating(t, now) && (t.connections === 0 || t.connections == null))
|
|
@@ -184,7 +184,7 @@ function nextStepCore(s) {
|
|
|
184
184
|
if (t && t.connections === 0)
|
|
185
185
|
return fail(`the tunnel has ${t.state === "inactive" ? "never connected" : "no running connector"}: on the agent's machine run \`cloudflared tunnel run --token-file ${t.tokenFile}\` (outbound port 7844 must be open), then \`${CLI} wake test\``);
|
|
186
186
|
if (s.wake.configured && needsAgentSecret)
|
|
187
|
-
return fail(`the Worker sends no webhook auth, so the ${s.wake.preset} webhook will reject wakes: export ${agentSecret} (${s.wake.preset
|
|
187
|
+
return fail(`the Worker sends no webhook auth, so the ${s.wake.preset} webhook will reject wakes: export ${agentSecret} (${["hermes", "openbot"].includes(s.wake.preset) ? "the route secret" : "the hooks token"}), then run \`${CLI} ${t ? "tunnel create" : "wake set"}\``);
|
|
188
188
|
// not broken, but peers can't pair until the human sets the approval password
|
|
189
189
|
const also = [];
|
|
190
190
|
if (s.pairing && s.pairing.mode === "human" && !s.pairing.passwordSet)
|
|
@@ -195,7 +195,7 @@ function nextStepCore(s) {
|
|
|
195
195
|
if (!s.wake.configured) {
|
|
196
196
|
const poll = "the agent must check the A2A endpoint on a schedule (Hermes and OpenClaw have copy-paste commands in the setup references)";
|
|
197
197
|
if (s.wake.preset && !LOCAL_PRESETS.includes(s.wake.preset) && s.wake.preset !== "generic")
|
|
198
|
-
return done2(`no wake webhook: export WAKE_WEBHOOK_URL and
|
|
198
|
+
return done2(`no wake webhook: export WAKE_WEBHOOK_URL and ${agentSecret}, then run \`${CLI} wake set --preset ${s.wake.preset}\`; or ${poll}`);
|
|
199
199
|
const z = s.zones;
|
|
200
200
|
const tunnel = !z ? `a local-only webhook can use \`${CLI} tunnel create\` (needs a zone on the account)`
|
|
201
201
|
: !z.length ? "the account has no domain (zone), so the secure tunnel is not available"
|
package/package.json
CHANGED
|
@@ -28,7 +28,7 @@ const plain: Record<string, string> = {
|
|
|
28
28
|
PROVIDER_ORGANIZATION: v("A2A_PROVIDER_ORGANIZATION"),
|
|
29
29
|
PROVIDER_URL: v("A2A_PROVIDER_URL"),
|
|
30
30
|
DOCUMENTATION_URL: v("A2A_DOCUMENTATION_URL"),
|
|
31
|
-
WAKE_PRESET: v("WAKE_PRESET") || "generic", // grok-bot | claude-code | openclaw-wake | openclaw-agent | hermes | generic
|
|
31
|
+
WAKE_PRESET: v("WAKE_PRESET") || "generic", // grok-bot | claude-code | openclaw-wake | openclaw-agent | hermes | openbot | generic
|
|
32
32
|
WAKE_AGENT_ID: v("WAKE_AGENT_ID"), // openclaw presets (default "main")
|
|
33
33
|
WAKE_KEY_HEADER: v("WAKE_KEY_HEADER"), // generic preset (default "authorization")
|
|
34
34
|
WAKE_KEY_PREFIX: e.WAKE_KEY_PREFIX ?? "", // generic preset; unset = "Bearer "
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
-- One row per peer. The same context id can be a separate conversation for each peer.
|
|
2
|
+
-- 0011's single-column primary key made a second peer's insert fail, which is how a caller
|
|
3
|
+
-- could tell a fresh id from one another peer already held.
|
|
4
|
+
CREATE TABLE conversation_owners_peer (
|
|
5
|
+
context_id TEXT NOT NULL,
|
|
6
|
+
peer TEXT NOT NULL,
|
|
7
|
+
created_at TEXT NOT NULL,
|
|
8
|
+
PRIMARY KEY (context_id, peer)
|
|
9
|
+
);
|
|
10
|
+
INSERT INTO conversation_owners_peer (context_id, peer, created_at)
|
|
11
|
+
SELECT context_id, peer, created_at FROM conversation_owners;
|
|
12
|
+
DROP TABLE conversation_owners;
|
|
13
|
+
ALTER TABLE conversation_owners_peer RENAME TO conversation_owners;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Write worker/src/version.ts from cli/package.json.
|
|
1
|
+
// Write worker/src/version.ts from cli/package.json. Release preparation runs `npm run version:set`
|
|
2
2
|
// before the Worker tests and build, and prepack bundles this tree into the npm package.
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
package/worker/src/index.ts
CHANGED
|
@@ -67,17 +67,12 @@ function histStmt(ctx: TenantContext, contextId: string, e: Json) {
|
|
|
67
67
|
e.event ?? null, e.text ?? null, e.data !== undefined ? JSON.stringify(e.data) : null);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
/** Claim
|
|
71
|
-
*
|
|
70
|
+
/** Claim this peer's conversation. The primary key is (context_id, peer): another peer's row is a
|
|
71
|
+
* different conversation and is not read or replaced. The insert does nothing when this peer already holds the id. */
|
|
72
72
|
function claimContextStmt(ctx: TenantContext, contextId: string, peer: string, ts: string) {
|
|
73
73
|
return ctx.DB.prepare(
|
|
74
|
-
`INSERT INTO conversation_owners (context_id, peer, created_at)
|
|
75
|
-
|
|
76
|
-
WHERE NOT EXISTS (SELECT 1 FROM conversation_owners WHERE context_id = ?1 AND peer != ?2)
|
|
77
|
-
AND NOT EXISTS (SELECT 1 FROM tasks WHERE context_id = ?1 AND peer IS NOT NULL AND peer != '' AND peer != ?2)
|
|
78
|
-
AND NOT EXISTS (SELECT 1 FROM history WHERE context_id = ?1 AND peer IS NOT NULL AND peer != '' AND peer != ?2)
|
|
79
|
-
AND NOT EXISTS (SELECT 1 FROM outbound WHERE context_id = ?1 AND peer IS NOT NULL AND peer != '' AND peer != ?2)
|
|
80
|
-
ON CONFLICT(context_id) DO NOTHING`,
|
|
74
|
+
`INSERT INTO conversation_owners (context_id, peer, created_at) VALUES (?1, ?2, ?3)
|
|
75
|
+
ON CONFLICT(context_id, peer) DO NOTHING`,
|
|
81
76
|
).bind(contextId, peer, ts);
|
|
82
77
|
}
|
|
83
78
|
|
|
@@ -86,9 +81,9 @@ function saveTaskIfOwnerStmt(ctx: TenantContext, t: Json) {
|
|
|
86
81
|
return ctx.DB.prepare(
|
|
87
82
|
`INSERT INTO tasks (id, context_id, peer, state, protocol, created_at, updated_at, status_json, artifacts_json, metadata_json, push_configs_json)
|
|
88
83
|
SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11
|
|
89
|
-
WHERE (SELECT
|
|
84
|
+
WHERE EXISTS (SELECT 1 FROM conversation_owners WHERE context_id = ?2 AND peer = ?3)
|
|
90
85
|
ON CONFLICT(id) DO UPDATE SET state=?4, updated_at=?7, status_json=?8, artifacts_json=?9, metadata_json=?10, push_configs_json=?11
|
|
91
|
-
WHERE (SELECT
|
|
86
|
+
WHERE EXISTS (SELECT 1 FROM conversation_owners WHERE context_id = ?2 AND peer = ?3)`,
|
|
92
87
|
).bind(t.id, t.contextId, t._peer, t.status.state, t._protocol, t._createdAt, A.nowIso(), JSON.stringify(t.status),
|
|
93
88
|
JSON.stringify(t.artifacts || []), t.metadata ? JSON.stringify(t.metadata) : null, JSON.stringify(t._push || []));
|
|
94
89
|
}
|
|
@@ -97,7 +92,7 @@ function msgIfOwnerStmt(ctx: TenantContext, t: Json, m: Json) {
|
|
|
97
92
|
return ctx.DB.prepare(
|
|
98
93
|
`INSERT INTO messages (message_id, task_id, context_id, role, ts, message_json)
|
|
99
94
|
SELECT ?1, ?2, ?3, ?4, ?5, ?6
|
|
100
|
-
WHERE (SELECT
|
|
95
|
+
WHERE EXISTS (SELECT 1 FROM conversation_owners WHERE context_id = ?3 AND peer = ?7)`,
|
|
101
96
|
).bind(m.messageId, t.id, t.contextId, m.role, A.nowIso(), JSON.stringify(m), t._peer);
|
|
102
97
|
}
|
|
103
98
|
|
|
@@ -105,7 +100,7 @@ function histIfOwnerStmt(ctx: TenantContext, contextId: string, e: Json, owner:
|
|
|
105
100
|
return ctx.DB.prepare(
|
|
106
101
|
`INSERT INTO history (context_id, ts, dir, peer, task_id, role, state, event, text, data_json)
|
|
107
102
|
SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10
|
|
108
|
-
WHERE (SELECT
|
|
103
|
+
WHERE EXISTS (SELECT 1 FROM conversation_owners WHERE context_id = ?1 AND peer = ?11)`,
|
|
109
104
|
).bind(contextId, e.ts || A.nowIso(), e.dir || "local", e.peer ?? null, e.taskId ?? null, e.role ?? null, e.state ?? null,
|
|
110
105
|
e.event ?? null, e.text ?? null, e.data !== undefined ? JSON.stringify(e.data) : null, owner);
|
|
111
106
|
}
|
|
@@ -635,15 +630,15 @@ function protectedResourceMetadata(ctx: TenantContext): Json {
|
|
|
635
630
|
const V1: Record<string, string> = {
|
|
636
631
|
SendMessage: "send", GetTask: "get", CancelTask: "cancel", CreateTaskPushNotificationConfig: "pushset",
|
|
637
632
|
SetTaskPushNotificationConfig: "pushset", GetTaskPushNotificationConfig: "pushget", ListTasks: "list",
|
|
633
|
+
ListTaskPushNotificationConfig: "pushlist", ListTaskPushNotificationConfigs: "pushlist",
|
|
634
|
+
DeleteTaskPushNotificationConfig: "pushdel",
|
|
638
635
|
SendStreamingMessage: "stream", SubscribeToTask: "stream",
|
|
639
636
|
};
|
|
640
637
|
const V03: Record<string, string> = {
|
|
641
638
|
"message/send": "send", "tasks/get": "get", "tasks/cancel": "cancel", "tasks/pushNotificationConfig/set": "pushset",
|
|
642
|
-
"tasks/pushNotificationConfig/get": "pushget", "
|
|
639
|
+
"tasks/pushNotificationConfig/get": "pushget", "tasks/pushNotificationConfig/list": "pushlist",
|
|
640
|
+
"tasks/pushNotificationConfig/delete": "pushdel", "message/stream": "stream", "tasks/resubscribe": "stream",
|
|
643
641
|
};
|
|
644
|
-
// Methods only the façade forwards (the inbox has one push config per task and no delete).
|
|
645
|
-
const PROXY_V1: Record<string, string> = { ListTaskPushNotificationConfig: "pushlist", DeleteTaskPushNotificationConfig: "pushdel" };
|
|
646
|
-
const PROXY_V03: Record<string, string> = { "tasks/pushNotificationConfig/list": "pushlist", "tasks/pushNotificationConfig/delete": "pushdel" };
|
|
647
642
|
const EXTENDED_CARD_METHODS = new Set(["GetExtendedAgentCard", "agent/getAuthenticatedExtendedCard"]);
|
|
648
643
|
|
|
649
644
|
/** Header, else query `A2A-Version`. A patch (`1.0.1`, `0.3.0`) is ignored. Empty means the method name chooses the
|
|
@@ -659,11 +654,9 @@ function declaredA2AVersion(req: Request): { raw: string; version: string | null
|
|
|
659
654
|
return { raw, version: null };
|
|
660
655
|
}
|
|
661
656
|
|
|
662
|
-
function rpcOperation(method: string
|
|
657
|
+
function rpcOperation(method: string): { op: string; version: string } | null {
|
|
663
658
|
if (V1[method]) return { op: V1[method], version: "1.0" };
|
|
664
659
|
if (V03[method]) return { op: V03[method], version: "0.3" };
|
|
665
|
-
if (proxy && PROXY_V1[method]) return { op: PROXY_V1[method], version: "1.0" };
|
|
666
|
-
if (proxy && PROXY_V03[method]) return { op: PROXY_V03[method], version: "0.3" };
|
|
667
660
|
return null;
|
|
668
661
|
}
|
|
669
662
|
|
|
@@ -710,10 +703,11 @@ const rpcSend: Rpc = async (params, label, version, ctx, ectx) => {
|
|
|
710
703
|
}
|
|
711
704
|
const contextId = task ? task.contextId : msg.contextId ? A.checkId(msg.contextId, "contextId") : A.newId();
|
|
712
705
|
const ts = A.nowIso();
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
//
|
|
706
|
+
// A2A lets an agent accept a client-supplied contextId and forbids minting a replacement when it will not.
|
|
707
|
+
// This inbox accepts the id for the caller. A peer that already has it continues that conversation. An id
|
|
708
|
+
// this agent has never seen, and an id another peer already has, are the same for the caller: a new
|
|
709
|
+
// conversation of their own. The other peer's tasks, history, and wake are not read or written. The claim
|
|
710
|
+
// and the first task are one batch, so the task is stored only when this peer's owner row is present.
|
|
717
711
|
const creating = !task;
|
|
718
712
|
if (!task) task = { id: A.newId(), contextId, status: { state: "submitted", timestamp: ts }, artifacts: [], history: [], _peer: label, _protocol: version, _createdAt: ts, _push: [] };
|
|
719
713
|
msg.contextId = contextId; msg.taskId = task.id;
|
|
@@ -728,11 +722,9 @@ const rpcSend: Rpc = async (params, label, version, ctx, ectx) => {
|
|
|
728
722
|
saveTaskIfOwnerStmt(ctx, task),
|
|
729
723
|
msgIfOwnerStmt(ctx, task, msg),
|
|
730
724
|
histIfOwnerStmt(ctx, contextId, { dir: "in", peer: label, taskId: task.id, role: "user", text: preview, data: { messageId: msg.messageId } }, label),
|
|
731
|
-
ctx.DB.prepare("SELECT peer FROM conversation_owners WHERE context_id = ?").bind(contextId),
|
|
732
725
|
]);
|
|
733
|
-
const owner = (stored[4]?.results?.[0] as Json | undefined)?.peer;
|
|
734
726
|
const wrote = stored[1]?.meta?.changes ?? 0;
|
|
735
|
-
if (
|
|
727
|
+
if (wrote < 1) throw new RpcError(-32602, UNKNOWN_CONTEXT);
|
|
736
728
|
} else {
|
|
737
729
|
await ctx.DB.batch([
|
|
738
730
|
saveTaskStmt(ctx, task), msgStmt(ctx, task, msg),
|
|
@@ -774,22 +766,104 @@ const rpcPushGet: Rpc = async (p, label, version, ctx) => {
|
|
|
774
766
|
throw new RpcError(-32001, "Push notification config not found");
|
|
775
767
|
};
|
|
776
768
|
|
|
769
|
+
/** Config id for list/delete. 1.0 sends `{ taskId, id }`. 0.3 sends `{ id: taskId, pushNotificationConfigId }`. */
|
|
770
|
+
function pushConfigId(p: Json): string | undefined {
|
|
771
|
+
const id = p.pushNotificationConfigId || (p.taskId ? p.id : undefined);
|
|
772
|
+
return typeof id === "string" && id ? id : undefined;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** Cursor over this task's configs. The token is an index, not a config id, url, or token. */
|
|
776
|
+
function pushListStart(raw: unknown): number {
|
|
777
|
+
if (raw === undefined || raw === null || raw === "") return 0;
|
|
778
|
+
if (typeof raw !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(raw)) throw new RpcError(-32602, "Invalid params: pageToken");
|
|
779
|
+
let text = "";
|
|
780
|
+
try { text = b64urlDecode(raw); } catch { throw new RpcError(-32602, "Invalid params: pageToken"); }
|
|
781
|
+
const m = /^cfg\n(\d{1,6})$/.exec(text);
|
|
782
|
+
if (!m) throw new RpcError(-32602, "Invalid params: pageToken");
|
|
783
|
+
return Number(m[1]);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const rpcPushList: Rpc = async (p, label, version, ctx) => {
|
|
787
|
+
const t = await ownedTask(ctx, p.taskId || p.id, label);
|
|
788
|
+
const all = (t._push || []).map((c: Json) => pubCfg(t.id, c, version));
|
|
789
|
+
if (!version.startsWith("1")) return all;
|
|
790
|
+
const size = Math.min(Math.max(Number(p.pageSize) || 50, 1), 100);
|
|
791
|
+
const start = pushListStart(p.pageToken);
|
|
792
|
+
const configs = all.slice(start, start + size);
|
|
793
|
+
const next = start + configs.length;
|
|
794
|
+
return { configs, nextPageToken: next < all.length ? b64url(`cfg\n${next}`) : "" };
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
const rpcPushDel: Rpc = async (p, label, version, ctx) => {
|
|
798
|
+
const t = await ownedTask(ctx, p.taskId || p.id, label);
|
|
799
|
+
const id = pushConfigId(p);
|
|
800
|
+
if (!id) throw new RpcError(-32602, "Invalid params: push notification config id");
|
|
801
|
+
const kept = (t._push || []).filter((c: Json) => c.id !== id);
|
|
802
|
+
if (kept.length !== (t._push || []).length) {
|
|
803
|
+
t._push = kept;
|
|
804
|
+
await saveTaskStmt(ctx, t).run();
|
|
805
|
+
}
|
|
806
|
+
// Idempotent: a missing config on this peer's task is the same as a config already removed.
|
|
807
|
+
return version.startsWith("1") ? {} : null;
|
|
808
|
+
};
|
|
809
|
+
|
|
810
|
+
/** Keyset cursor for ListTasks, newest update first. Empty means the first page. A bad token is rejected
|
|
811
|
+
* without echoing it. The query still filters by the caller, so the cursor cannot select another peer's tasks. */
|
|
812
|
+
function listCursor(raw: unknown): { updatedAt: string; id: string } | null {
|
|
813
|
+
if (raw === undefined || raw === null || raw === "") return null;
|
|
814
|
+
if (typeof raw !== "string" || !/^[A-Za-z0-9_-]{1,256}$/.test(raw)) throw new RpcError(-32602, "Invalid params: pageToken");
|
|
815
|
+
let text = "";
|
|
816
|
+
try { text = b64urlDecode(raw); } catch { throw new RpcError(-32602, "Invalid params: pageToken"); }
|
|
817
|
+
const nl = text.indexOf("\n");
|
|
818
|
+
if (nl <= 0) throw new RpcError(-32602, "Invalid params: pageToken");
|
|
819
|
+
const updatedAt = text.slice(0, nl);
|
|
820
|
+
const id = text.slice(nl + 1);
|
|
821
|
+
if (!INBOX_STAMP.test(updatedAt) || !A.ID_RE.test(id) || id.includes("\n")) throw new RpcError(-32602, "Invalid params: pageToken");
|
|
822
|
+
return { updatedAt, id };
|
|
823
|
+
}
|
|
824
|
+
|
|
777
825
|
const rpcList: Rpc = async (p, label, version, ctx) => {
|
|
778
826
|
const size = Math.min(Math.max(Number(p.pageSize) || 50, 1), 100);
|
|
779
|
-
const
|
|
780
|
-
const
|
|
781
|
-
const
|
|
782
|
-
|
|
827
|
+
const cursor = listCursor(p.pageToken);
|
|
828
|
+
const where = ["peer = ?"];
|
|
829
|
+
const args: Json[] = [label];
|
|
830
|
+
if (p.contextId !== undefined && p.contextId !== "") {
|
|
831
|
+
if (typeof p.contextId !== "string" || !A.ID_RE.test(p.contextId)) throw new RpcError(-32602, "Invalid params: invalid contextId");
|
|
832
|
+
where.push("context_id = ?");
|
|
833
|
+
args.push(p.contextId);
|
|
834
|
+
}
|
|
835
|
+
const whereSql = where.join(" AND ");
|
|
836
|
+
const total: Json = await ctx.DB.prepare(`SELECT COUNT(*) AS n FROM tasks WHERE ${whereSql}`).bind(...args).first();
|
|
837
|
+
const pageWhere = [...where];
|
|
838
|
+
const pageArgs = [...args];
|
|
839
|
+
if (cursor) {
|
|
840
|
+
pageWhere.push("(updated_at < ? OR (updated_at = ? AND id < ?))");
|
|
841
|
+
pageArgs.push(cursor.updatedAt, cursor.updatedAt, cursor.id);
|
|
842
|
+
}
|
|
843
|
+
const rows = await ctx.DB.prepare(
|
|
844
|
+
`SELECT id, updated_at FROM tasks WHERE ${pageWhere.join(" AND ")} ORDER BY updated_at DESC, id DESC LIMIT ?`,
|
|
845
|
+
).bind(...pageArgs, size + 1).all();
|
|
846
|
+
const found = (rows.results || []) as Json[];
|
|
847
|
+
const page = found.slice(0, size);
|
|
783
848
|
const tasks = [];
|
|
784
|
-
for (const r of
|
|
849
|
+
for (const r of page) {
|
|
785
850
|
const t = A.publicTask(await loadTask(ctx, r.id), version, p.historyLength ?? 0);
|
|
786
851
|
if (!p.includeArtifacts) delete t.artifacts;
|
|
787
852
|
tasks.push(t);
|
|
788
853
|
}
|
|
789
|
-
|
|
854
|
+
let nextPageToken = "";
|
|
855
|
+
if (found.length > size && page.length) {
|
|
856
|
+
const updatedAt = String(page[page.length - 1].updated_at);
|
|
857
|
+
const id = String(page[page.length - 1].id);
|
|
858
|
+
if (INBOX_STAMP.test(updatedAt) && A.ID_RE.test(id)) nextPageToken = b64url(`${updatedAt}\n${id}`);
|
|
859
|
+
}
|
|
860
|
+
return { tasks, nextPageToken, pageSize: size, totalSize: total?.n ?? tasks.length };
|
|
790
861
|
};
|
|
791
862
|
|
|
792
|
-
const HANDLERS: Record<string, Rpc> = {
|
|
863
|
+
const HANDLERS: Record<string, Rpc> = {
|
|
864
|
+
send: rpcSend, get: rpcGet, cancel: rpcCancel, pushset: rpcPushSet, pushget: rpcPushGet,
|
|
865
|
+
pushlist: rpcPushList, pushdel: rpcPushDel, list: rpcList,
|
|
866
|
+
};
|
|
793
867
|
|
|
794
868
|
/** 401 for A2A calls. Unless pairing is off, the error says how to get a token (device flow), so a generic agent can follow it.
|
|
795
869
|
* The JSON-RPC id is echoed when the request body parses (JSON-RPC 2.0); WWW-Authenticate points to RFC 9728 metadata. */
|
|
@@ -836,7 +910,7 @@ async function handleRpc(req: Request, ctx: TenantContext, ectx: ExecutionContex
|
|
|
836
910
|
const declared = declaredA2AVersion(req);
|
|
837
911
|
if (declared.raw && !declared.version) return err(-32009, `A2A version ${declared.raw} not supported (supported: 0.3, 1.0)`);
|
|
838
912
|
if (EXTENDED_CARD_METHODS.has(method)) return err(-32007, "Extended agent card is not configured");
|
|
839
|
-
const found = rpcOperation(method
|
|
913
|
+
const found = rpcOperation(method);
|
|
840
914
|
if (!found) return err(-32601, "Method not found");
|
|
841
915
|
const version = declared.version || found.version;
|
|
842
916
|
const op = found.op;
|
|
@@ -1088,6 +1162,8 @@ async function handlePush(req: Request, ctx: TenantContext, ectx: ExecutionConte
|
|
|
1088
1162
|
|
|
1089
1163
|
// ------------------------------------------------------------------ owner API (local CLI)
|
|
1090
1164
|
const INBOX_STAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/;
|
|
1165
|
+
/** Inbound labels and outbound aliases. Used to scope a conversation read to one peer. */
|
|
1166
|
+
const PEER_FILTER = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
1091
1167
|
|
|
1092
1168
|
function inboxLimit(raw: string | null): number {
|
|
1093
1169
|
if (raw === null) return M.INBOX_MAX;
|
|
@@ -1274,6 +1350,8 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
1274
1350
|
}
|
|
1275
1351
|
if (seg[0] === "inbox" && m === "GET") {
|
|
1276
1352
|
const contextFilter = url.searchParams.get("context");
|
|
1353
|
+
const fromFilter = url.searchParams.get("from");
|
|
1354
|
+
if (fromFilter !== null && !PEER_FILTER.test(fromFilter)) throw new HttpError(400, "from is invalid");
|
|
1277
1355
|
const all = url.searchParams.get("all") === "1";
|
|
1278
1356
|
const limit = inboxLimit(url.searchParams.get("limit"));
|
|
1279
1357
|
const cursor = inboxCursor(url.searchParams.get("cursor"));
|
|
@@ -1281,6 +1359,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
1281
1359
|
const args: Json[] = [];
|
|
1282
1360
|
if (!all) q += " AND state IN ('submitted','working')";
|
|
1283
1361
|
if (contextFilter) { A.checkId(contextFilter, "contextId"); q += " AND context_id = ?"; args.push(contextFilter); }
|
|
1362
|
+
if (fromFilter) { q += " AND peer = ?"; args.push(fromFilter); }
|
|
1284
1363
|
if (cursor) { q += " AND (updated_at > ? OR (updated_at = ? AND id > ?))"; args.push(cursor.updatedAt, cursor.updatedAt, cursor.id); }
|
|
1285
1364
|
q += " ORDER BY updated_at, id LIMIT ?";
|
|
1286
1365
|
args.push(limit + 1);
|
|
@@ -1302,6 +1381,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
1302
1381
|
const oargs: Json[] = [];
|
|
1303
1382
|
if (!all) oq += " AND updates_seen = 0";
|
|
1304
1383
|
if (contextFilter) { oq += " AND context_id = ?"; oargs.push(contextFilter); }
|
|
1384
|
+
if (fromFilter) { oq += " AND peer = ?"; oargs.push(fromFilter); }
|
|
1305
1385
|
oq += " ORDER BY sent_at LIMIT ?";
|
|
1306
1386
|
oargs.push(limit);
|
|
1307
1387
|
const replies = await ctx.DB.prepare(oq).bind(...oargs).all();
|
|
@@ -1369,9 +1449,22 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
1369
1449
|
}
|
|
1370
1450
|
if (seg[0] === "history" && seg[1] && m === "GET") {
|
|
1371
1451
|
const n = Math.min(Number(url.searchParams.get("n") || "50"), 1000);
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1452
|
+
const fromFilter = url.searchParams.get("from");
|
|
1453
|
+
if (fromFilter !== null && !PEER_FILTER.test(fromFilter)) return json({ error: "from is invalid" }, 400);
|
|
1454
|
+
// Without `from`, rows for every peer that owns this context id (a task or an outbound task), plus local notes.
|
|
1455
|
+
// With `from`, one peer's rows only, so another peer's conversation is not in this read.
|
|
1456
|
+
const rows = await ctx.DB.prepare(fromFilter ? `SELECT * FROM (
|
|
1457
|
+
SELECT * FROM history WHERE context_id = ? AND (
|
|
1458
|
+
peer = ?
|
|
1459
|
+
OR (
|
|
1460
|
+
(peer IS NULL OR peer = '')
|
|
1461
|
+
AND (
|
|
1462
|
+
task_id IN (SELECT id FROM tasks WHERE context_id = ? AND peer = ?)
|
|
1463
|
+
OR task_id IN (SELECT task_id FROM outbound WHERE context_id = ? AND peer = ?)
|
|
1464
|
+
)
|
|
1465
|
+
)
|
|
1466
|
+
) ORDER BY seq DESC LIMIT ?
|
|
1467
|
+
) ORDER BY seq` : `SELECT * FROM (
|
|
1375
1468
|
SELECT * FROM history WHERE context_id = ? AND (
|
|
1376
1469
|
peer IS NULL OR peer = ''
|
|
1377
1470
|
OR peer IN (SELECT peer FROM tasks WHERE context_id = ? AND peer IS NOT NULL AND peer != '')
|
|
@@ -1381,7 +1474,9 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
1381
1474
|
AND NOT EXISTS (SELECT 1 FROM outbound WHERE context_id = ?)
|
|
1382
1475
|
)
|
|
1383
1476
|
) ORDER BY seq DESC LIMIT ?
|
|
1384
|
-
) ORDER BY seq`).bind(
|
|
1477
|
+
) ORDER BY seq`).bind(...(fromFilter
|
|
1478
|
+
? [seg[1], fromFilter, seg[1], fromFilter, seg[1], fromFilter, n]
|
|
1479
|
+
: [seg[1], seg[1], seg[1], seg[1], seg[1], n])).all();
|
|
1385
1480
|
return json((rows.results as Json[]).map((r) => ({ ...r, data: r.data_json ? JSON.parse(r.data_json) : undefined, data_json: undefined })));
|
|
1386
1481
|
}
|
|
1387
1482
|
if (seg[0] === "history" && !seg[1] && m === "POST") {
|
|
@@ -1524,6 +1619,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
1524
1619
|
fingerprints: { url: await A.fingerprint(ctx.WAKE_WEBHOOK_URL), key: await A.fingerprint(ctx.WAKE_WEBHOOK_KEY),
|
|
1525
1620
|
hmacSecret: await A.fingerprint(ctx.WAKE_HMAC_SECRET),
|
|
1526
1621
|
accessClientId: await A.fingerprint(ctx.WAKE_ACCESS_CLIENT_ID), accessClientSecret: await A.fingerprint(ctx.WAKE_ACCESS_CLIENT_SECRET) },
|
|
1622
|
+
...(preset(ctx) === "openbot" ? { signingInput: "<timestamp>.<delivery-id>.<exact-body-bytes>" } : {}),
|
|
1527
1623
|
request: req ? redact(req) : null });
|
|
1528
1624
|
}
|
|
1529
1625
|
if (seg[0] === "wake" && seg[1] === "test" && m === "POST") {
|
|
@@ -2823,6 +2919,11 @@ async function runTool(ctx: TenantContext, ectx: ExecutionContext, name: string,
|
|
|
2823
2919
|
case "inbox": {
|
|
2824
2920
|
const qs = new URLSearchParams();
|
|
2825
2921
|
if (a.contextId !== undefined) qs.set("context", A.checkId(argStr(a, "contextId"), "contextId"));
|
|
2922
|
+
if (a.from !== undefined) {
|
|
2923
|
+
const from = argStr(a, "from");
|
|
2924
|
+
if (!PEER_FILTER.test(from)) throw new ToolError("from is invalid");
|
|
2925
|
+
qs.set("from", from);
|
|
2926
|
+
}
|
|
2826
2927
|
if (a.all === true) qs.set("all", "1");
|
|
2827
2928
|
if (a.cursor !== undefined) {
|
|
2828
2929
|
const cursor = argStr(a, "cursor");
|
|
@@ -2850,7 +2951,13 @@ async function runTool(ctx: TenantContext, ectx: ExecutionContext, name: string,
|
|
|
2850
2951
|
case "history": {
|
|
2851
2952
|
const n = a.n === undefined ? 50 : Number(a.n);
|
|
2852
2953
|
if (!Number.isInteger(n) || n < 1 || n > 1000) throw new ToolError("n must be 1-1000");
|
|
2853
|
-
|
|
2954
|
+
let histPath = `/owner/history/${enc(A.checkId(argStr(a, "contextId"), "contextId"))}?n=${n}`;
|
|
2955
|
+
if (a.from !== undefined) {
|
|
2956
|
+
const from = argStr(a, "from");
|
|
2957
|
+
if (!PEER_FILTER.test(from)) throw new ToolError("from is invalid");
|
|
2958
|
+
histPath += `&from=${enc(from)}`;
|
|
2959
|
+
}
|
|
2960
|
+
return M.toolText(`History, oldest first. ${UNTRUSTED}`, must(await ownerCall(ctx, ectx, "GET", histPath)));
|
|
2854
2961
|
}
|
|
2855
2962
|
case "mark_working": {
|
|
2856
2963
|
const r = must(await ownerCall(ctx, ectx, "POST", `/owner/tasks/${enc(A.checkId(argStr(a, "taskId"), "taskId"))}/working`));
|
package/worker/src/mcp.ts
CHANGED
|
@@ -323,6 +323,7 @@ export const TOOLS: { name: string; title: string; description: string; inputSch
|
|
|
323
323
|
description: `Open tasks other agents sent to this A2A endpoint (state submitted or working), oldest first, one page at a time, plus a reply to a message you sent (kind "outbound") until this inbox lists it, plus pending pairing requests. When the result says more tasks are waiting, call inbox again with the cursor before you stop. Handle each inbound task, then answer it with reply. A reply to a message you sent: read it with history, and continue that conversation with send. ${RULES}`,
|
|
324
324
|
inputSchema: { type: "object", properties: {
|
|
325
325
|
contextId: str("Only this conversation"),
|
|
326
|
+
from: str("Only this peer (the wake's from). The same context id can be a different peer's conversation"),
|
|
326
327
|
all: { type: "boolean", description: "Include finished tasks on this page" },
|
|
327
328
|
cursor: str("Cursor from the previous inbox page, when more tasks are waiting"),
|
|
328
329
|
limit: { type: "integer", minimum: 1, maximum: INBOX_MAX, description: `Tasks in this page (default ${INBOX_PAGE}, at most ${INBOX_MAX})` },
|
|
@@ -332,7 +333,7 @@ export const TOOLS: { name: string; title: string; description: string; inputSch
|
|
|
332
333
|
inputSchema: { type: "object", properties: { taskId: str("Task id from the A2A endpoint") }, required: ["taskId"], additionalProperties: false } },
|
|
333
334
|
{ name: "history", title: "Conversation history", annotations: ro,
|
|
334
335
|
description: `The log of one conversation (both directions), oldest first. ${RULES}`,
|
|
335
|
-
inputSchema: { type: "object", properties: { contextId: str("Conversation (context) id"), n: { type: "integer", minimum: 1, maximum: 1000, description: "Entries (default 50)" } }, required: ["contextId"], additionalProperties: false } },
|
|
336
|
+
inputSchema: { type: "object", properties: { contextId: str("Conversation (context) id"), from: str("Only this peer (the wake's from)"), n: { type: "integer", minimum: 1, maximum: 1000, description: "Entries (default 50)" } }, required: ["contextId"], additionalProperties: false } },
|
|
336
337
|
{ name: "mark_working", title: "Mark task working", annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
337
338
|
description: "Tell the peer you are working on a task (state working; the peer is notified if it asked for push updates). Use for anything that takes more than a moment.",
|
|
338
339
|
inputSchema: { type: "object", properties: { taskId: str("Task id") }, required: ["taskId"], additionalProperties: false } },
|
package/worker/src/migrations.ts
CHANGED
|
@@ -11,6 +11,7 @@ import m0008 from "../migrations/0008_oidc_txns.sql?raw";
|
|
|
11
11
|
import m0009 from "../migrations/0009_outbound_seen.sql?raw";
|
|
12
12
|
import m0010 from "../migrations/0010_conversation_peer.sql?raw";
|
|
13
13
|
import m0011 from "../migrations/0011_conversation_owner.sql?raw";
|
|
14
|
+
import m0012 from "../migrations/0012_conversation_peer_owner.sql?raw";
|
|
14
15
|
import type { Migration } from "./storage.ts";
|
|
15
16
|
|
|
16
17
|
export const MIGRATIONS: Migration[] = [
|
|
@@ -25,4 +26,5 @@ export const MIGRATIONS: Migration[] = [
|
|
|
25
26
|
{ name: "0009_outbound_seen.sql", sql: m0009 },
|
|
26
27
|
{ name: "0010_conversation_peer.sql", sql: m0010 },
|
|
27
28
|
{ name: "0011_conversation_owner.sql", sql: m0011 },
|
|
29
|
+
{ name: "0012_conversation_peer_owner.sql", sql: m0012 },
|
|
28
30
|
];
|
package/worker/src/owner-rpc.ts
CHANGED
|
@@ -105,25 +105,31 @@ function noQuery(q: Record<string, string>): string | null {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
function inboxQuery(q: Record<string, string>): string | null {
|
|
108
|
-
for (const k of Object.keys(q)) if (k !== "context" && k !== "all" && k !== "limit" && k !== "cursor") return null;
|
|
108
|
+
for (const k of Object.keys(q)) if (k !== "context" && k !== "all" && k !== "limit" && k !== "cursor" && k !== "from") return null;
|
|
109
109
|
if (q.context !== undefined && !idOk(q.context)) return null;
|
|
110
110
|
if (q.all !== undefined && q.all !== "1") return null;
|
|
111
111
|
if (q.limit !== undefined && (!/^[1-9]\d{0,2}$/.test(q.limit) || Number(q.limit) > 200)) return null;
|
|
112
112
|
if (q.cursor !== undefined && !/^[A-Za-z0-9_-]{1,256}$/.test(q.cursor)) return null;
|
|
113
|
+
if (q.from !== undefined && !/^[A-Za-z0-9_.-]{1,64}$/.test(q.from)) return null;
|
|
113
114
|
const sp = new URLSearchParams();
|
|
114
115
|
if (q.context) sp.set("context", q.context);
|
|
115
116
|
if (q.all) sp.set("all", "1");
|
|
116
117
|
if (q.limit) sp.set("limit", q.limit);
|
|
117
118
|
if (q.cursor) sp.set("cursor", q.cursor);
|
|
119
|
+
if (q.from) sp.set("from", q.from);
|
|
118
120
|
const s = sp.toString();
|
|
119
121
|
return s ? `?${s}` : "";
|
|
120
122
|
}
|
|
121
123
|
|
|
122
124
|
function historyQuery(q: Record<string, string>): string | null {
|
|
123
|
-
for (const k of Object.keys(q)) if (k !== "n") return null;
|
|
124
|
-
if (q.n
|
|
125
|
-
if (!/^[
|
|
126
|
-
|
|
125
|
+
for (const k of Object.keys(q)) if (k !== "n" && k !== "from") return null;
|
|
126
|
+
if (q.n !== undefined && (!/^[1-9][0-9]{0,3}$/.test(q.n) || Number(q.n) > 1000)) return null;
|
|
127
|
+
if (q.from !== undefined && !/^[A-Za-z0-9_.-]{1,64}$/.test(q.from)) return null;
|
|
128
|
+
const sp = new URLSearchParams();
|
|
129
|
+
if (q.n) sp.set("n", q.n);
|
|
130
|
+
if (q.from) sp.set("from", q.from);
|
|
131
|
+
const s = sp.toString();
|
|
132
|
+
return s ? `?${s}` : "";
|
|
127
133
|
}
|
|
128
134
|
|
|
129
135
|
/** `format=sql` is the only export query. The RPC still calls the JSON handler and redacts before building SQL. */
|
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.8.
|
|
2
|
+
export const VERSION = "0.8.19";
|