@desplega.ai/agent-swarm 1.79.0 → 1.79.2

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 (46) hide show
  1. package/README.md +2 -0
  2. package/openapi.json +559 -1
  3. package/package.json +4 -4
  4. package/plugin/skills/kv-storage/SKILL.md +168 -0
  5. package/plugin/skills/pages/SKILL.md +149 -0
  6. package/src/artifact-sdk/browser-sdk.ts +292 -0
  7. package/src/be/db.ts +309 -0
  8. package/src/be/migrations/061_kv_store.sql +34 -0
  9. package/src/be/migrations/062_pages_view_count.sql +9 -0
  10. package/src/commands/provider-credentials.ts +1 -1
  11. package/src/http/index.ts +2 -0
  12. package/src/http/kv.ts +658 -0
  13. package/src/http/page-proxy.ts +5 -0
  14. package/src/http/pages-public.ts +50 -6
  15. package/src/http/status.ts +1 -1
  16. package/src/providers/claude-adapter.ts +138 -7
  17. package/src/providers/pi-mono-adapter.ts +3 -3
  18. package/src/providers/pi-mono-extension.ts +1 -1
  19. package/src/server.ts +20 -1
  20. package/src/tasks/context-key.ts +28 -0
  21. package/src/telemetry.ts +65 -1
  22. package/src/tests/claude-adapter-binary.test.ts +628 -0
  23. package/src/tests/context-key.test.ts +17 -0
  24. package/src/tests/kv-http.test.ts +331 -0
  25. package/src/tests/kv-namespace-resolution.test.ts +172 -0
  26. package/src/tests/kv-page-proxy.test.ts +212 -0
  27. package/src/tests/kv-storage.test.ts +227 -0
  28. package/src/tests/kv-tool.test.ts +217 -0
  29. package/src/tests/page-proxy.test.ts +5 -1
  30. package/src/tests/page-session.test.ts +10 -5
  31. package/src/tests/pages-authed-mode.test.ts +5 -1
  32. package/src/tests/pages-public-html.test.ts +10 -1
  33. package/src/tests/pages-view-count.test.ts +220 -0
  34. package/src/tests/swarm-diff.test.ts +303 -0
  35. package/src/tests/telemetry-init.test.ts +149 -0
  36. package/src/tools/kv/index.ts +5 -0
  37. package/src/tools/kv/kv-delete.ts +89 -0
  38. package/src/tools/kv/kv-get.ts +64 -0
  39. package/src/tools/kv/kv-incr.ts +116 -0
  40. package/src/tools/kv/kv-list.ts +81 -0
  41. package/src/tools/kv/kv-set.ts +194 -0
  42. package/src/tools/kv/resolve-namespace.ts +58 -0
  43. package/src/tools/tool-config.ts +7 -0
  44. package/src/types.ts +53 -0
  45. package/src/utils/internal-ai/complete-structured.ts +7 -10
  46. package/src/utils/internal-ai/credentials.ts +3 -3
package/src/http/kv.ts ADDED
@@ -0,0 +1,658 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { z } from "zod";
3
+ import {
4
+ countKv,
5
+ deleteKv,
6
+ getAgentById,
7
+ getKv,
8
+ getTaskById,
9
+ incrKv,
10
+ KvTypeCollisionError,
11
+ listKv,
12
+ upsertKv,
13
+ } from "../be/db";
14
+ import { agentContextKey, pageContextKey } from "../tasks/context-key";
15
+ import { KvKeySchema, KvNamespaceSchema, KvValueTypeSchema } from "../types";
16
+ import { route } from "./route-def";
17
+ import { BODY_TOO_LARGE, enforceContentLengthCap, json, jsonError } from "./utils";
18
+
19
+ /**
20
+ * KV store HTTP surface — see plan & `src/be/migrations/061_kv_store.sql`.
21
+ *
22
+ * Two URL shapes:
23
+ *
24
+ * /api/kv/:key — namespace resolved server-side from headers
25
+ * /api/kv/_/:namespace/:key — namespace given explicitly (the `_` sentinel
26
+ * is illegal as a namespace per `KV_NAME_REGEX`
27
+ * so it can't collide with a real key/ns segment)
28
+ *
29
+ * GET /api/kv — list, header-resolved namespace
30
+ * GET /api/kv/_/:namespace — list, explicit namespace
31
+ *
32
+ * Namespace header resolution precedence:
33
+ * X-Page-Id > X-Source-Task-Id (→ task.contextKey) > X-Agent-ID
34
+ *
35
+ * `X-Page-Id` is set ONLY by `src/http/page-proxy.ts` for a verified page
36
+ * cookie. The kv handler treats it as the highest-priority namespace source
37
+ * and overrides anything in the URL/body so pages can't escape their namespace.
38
+ */
39
+
40
+ // 2 MiB cap on PUT bodies. Pre-flighted via Content-Length; the parsed JSON
41
+ // body itself is enforced too (`value` size ≤ MAX_KV_BODY_BYTES after stringify).
42
+ const MAX_KV_BODY_BYTES = 2 * 1024 * 1024;
43
+
44
+ // `limit` upper bound on list endpoints. Anything higher gets clamped silently
45
+ // — callers should paginate via offset.
46
+ const MAX_KV_LIST_LIMIT = 1000;
47
+
48
+ // ─── Schemas ─────────────────────────────────────────────────────────────────
49
+
50
+ const kvSetBodySchema = z.object({
51
+ value: z.unknown(),
52
+ valueType: KvValueTypeSchema.optional(),
53
+ expiresInSec: z.number().int().positive().optional(),
54
+ });
55
+
56
+ const kvIncrBodySchema = z
57
+ .object({
58
+ by: z.number().int().optional(),
59
+ })
60
+ .optional()
61
+ .nullable();
62
+
63
+ const kvListQuerySchema = z.object({
64
+ prefix: z.string().optional(),
65
+ limit: z.coerce.number().int().positive().max(MAX_KV_LIST_LIMIT).optional(),
66
+ offset: z.coerce.number().int().nonnegative().optional(),
67
+ });
68
+
69
+ // ─── Routes ──────────────────────────────────────────────────────────────────
70
+
71
+ const RESPONSES_GET = {
72
+ 200: { description: "KV entry" },
73
+ 404: { description: "KV entry not found or expired" },
74
+ 400: { description: "Validation error or unresolvable namespace" },
75
+ } as const;
76
+
77
+ const RESPONSES_PUT = {
78
+ 200: { description: "KV entry stored" },
79
+ 400: { description: "Validation error" },
80
+ 403: { description: "Caller may not write this namespace" },
81
+ 409: { description: "INCR collision: existing value_type is not 'integer'" },
82
+ 413: { description: "Body exceeds 2 MiB" },
83
+ } as const;
84
+
85
+ const RESPONSES_LIST = {
86
+ 200: { description: "KV entries in the resolved namespace" },
87
+ 400: { description: "Validation error or unresolvable namespace" },
88
+ } as const;
89
+
90
+ // Header-resolved (key in path)
91
+ const getKvHeader = route({
92
+ method: "get",
93
+ path: "/api/kv/{key}",
94
+ pattern: ["api", "kv", null],
95
+ summary: "Get a KV entry by key (namespace resolved from request headers)",
96
+ tags: ["KV"],
97
+ params: z.object({ key: KvKeySchema }),
98
+ responses: RESPONSES_GET,
99
+ });
100
+
101
+ const putKvHeader = route({
102
+ method: "put",
103
+ path: "/api/kv/{key}",
104
+ pattern: ["api", "kv", null],
105
+ summary: "Upsert a KV entry by key (namespace resolved from request headers)",
106
+ tags: ["KV"],
107
+ params: z.object({ key: KvKeySchema }),
108
+ body: kvSetBodySchema,
109
+ responses: RESPONSES_PUT,
110
+ });
111
+
112
+ const deleteKvHeader = route({
113
+ method: "delete",
114
+ path: "/api/kv/{key}",
115
+ pattern: ["api", "kv", null],
116
+ summary: "Delete a KV entry by key (namespace resolved from request headers)",
117
+ tags: ["KV"],
118
+ params: z.object({ key: KvKeySchema }),
119
+ responses: {
120
+ 204: { description: "KV entry deleted" },
121
+ 404: { description: "KV entry not found" },
122
+ 403: { description: "Caller may not write this namespace" },
123
+ 400: { description: "Validation error or unresolvable namespace" },
124
+ },
125
+ });
126
+
127
+ const incrKvHeader = route({
128
+ method: "post",
129
+ path: "/api/kv/{key}/incr",
130
+ pattern: ["api", "kv", null, "incr"],
131
+ summary: "Atomically increment an integer KV entry (header-resolved namespace)",
132
+ tags: ["KV"],
133
+ params: z.object({ key: KvKeySchema }),
134
+ body: kvIncrBodySchema,
135
+ responses: RESPONSES_PUT,
136
+ });
137
+
138
+ const listKvHeader = route({
139
+ method: "get",
140
+ path: "/api/kv",
141
+ pattern: ["api", "kv"],
142
+ summary: "List KV entries in the header-resolved namespace",
143
+ tags: ["KV"],
144
+ query: kvListQuerySchema,
145
+ responses: RESPONSES_LIST,
146
+ });
147
+
148
+ // Explicit-namespace variants (`/_/:namespace/...`)
149
+ const getKvExplicit = route({
150
+ method: "get",
151
+ path: "/api/kv/_/{namespace}/{key}",
152
+ pattern: ["api", "kv", "_", null, null],
153
+ summary: "Get a KV entry by explicit namespace + key",
154
+ tags: ["KV"],
155
+ params: z.object({ namespace: KvNamespaceSchema, key: KvKeySchema }),
156
+ responses: RESPONSES_GET,
157
+ });
158
+
159
+ const putKvExplicit = route({
160
+ method: "put",
161
+ path: "/api/kv/_/{namespace}/{key}",
162
+ pattern: ["api", "kv", "_", null, null],
163
+ summary: "Upsert a KV entry by explicit namespace + key",
164
+ tags: ["KV"],
165
+ params: z.object({ namespace: KvNamespaceSchema, key: KvKeySchema }),
166
+ body: kvSetBodySchema,
167
+ responses: RESPONSES_PUT,
168
+ });
169
+
170
+ const deleteKvExplicit = route({
171
+ method: "delete",
172
+ path: "/api/kv/_/{namespace}/{key}",
173
+ pattern: ["api", "kv", "_", null, null],
174
+ summary: "Delete a KV entry by explicit namespace + key",
175
+ tags: ["KV"],
176
+ params: z.object({ namespace: KvNamespaceSchema, key: KvKeySchema }),
177
+ responses: {
178
+ 204: { description: "KV entry deleted" },
179
+ 404: { description: "KV entry not found" },
180
+ 403: { description: "Caller may not write this namespace" },
181
+ },
182
+ });
183
+
184
+ const incrKvExplicit = route({
185
+ method: "post",
186
+ path: "/api/kv/_/{namespace}/{key}/incr",
187
+ pattern: ["api", "kv", "_", null, null, "incr"],
188
+ summary: "Atomically increment an integer KV entry (explicit namespace)",
189
+ tags: ["KV"],
190
+ params: z.object({ namespace: KvNamespaceSchema, key: KvKeySchema }),
191
+ body: kvIncrBodySchema,
192
+ responses: RESPONSES_PUT,
193
+ });
194
+
195
+ const listKvExplicit = route({
196
+ method: "get",
197
+ path: "/api/kv/_/{namespace}",
198
+ pattern: ["api", "kv", "_", null],
199
+ summary: "List KV entries in an explicit namespace",
200
+ tags: ["KV"],
201
+ params: z.object({ namespace: KvNamespaceSchema }),
202
+ query: kvListQuerySchema,
203
+ responses: RESPONSES_LIST,
204
+ });
205
+
206
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
207
+
208
+ /**
209
+ * Decode + re-validate a path-segment param after `route.parse()`. Path
210
+ * segments arrive percent-encoded (e.g. `task%3Aagent%3A...`); the regex on
211
+ * `KvNamespaceSchema`/`KvKeySchema` is permissive enough to accept the
212
+ * encoded form, but we want to store/compare the decoded value. Returns null
213
+ * + sends a 400 if decoding fails or the decoded value doesn't match the
214
+ * stricter validator.
215
+ */
216
+ function decodeKvSegment(
217
+ res: ServerResponse,
218
+ raw: string,
219
+ label: "namespace" | "key",
220
+ ): string | null {
221
+ let decoded: string;
222
+ try {
223
+ decoded = decodeURIComponent(raw);
224
+ } catch {
225
+ jsonError(res, `invalid ${label}: malformed percent-encoding`, 400);
226
+ return null;
227
+ }
228
+ if (!/^[a-zA-Z0-9._:/-]{1,512}$/.test(decoded)) {
229
+ jsonError(res, `invalid ${label}: must match [a-zA-Z0-9._:/-]{1,512} after decoding`, 400);
230
+ return null;
231
+ }
232
+ return decoded;
233
+ }
234
+
235
+ function singleHeader(req: IncomingMessage, name: string): string | undefined {
236
+ const raw = req.headers[name];
237
+ if (raw === undefined) return undefined;
238
+ return Array.isArray(raw) ? raw[0] : raw;
239
+ }
240
+
241
+ /**
242
+ * Resolve the namespace from request headers using the documented precedence.
243
+ * Returns `null` when no header is suitable — caller responds 400.
244
+ *
245
+ * The handler dispatch in `handleKv` calls this AFTER the explicit-path
246
+ * variants have already been ruled out; we never look at URL params here.
247
+ */
248
+ function resolveNamespaceFromHeaders(req: IncomingMessage): string | null {
249
+ const pageId = singleHeader(req, "x-page-id");
250
+ if (pageId) {
251
+ try {
252
+ return pageContextKey({ pageId });
253
+ } catch {
254
+ return null;
255
+ }
256
+ }
257
+
258
+ const sourceTaskId = singleHeader(req, "x-source-task-id");
259
+ if (sourceTaskId) {
260
+ const task = getTaskById(sourceTaskId);
261
+ if (task?.contextKey) return task.contextKey;
262
+ // Fall through to agent-id default if the task lookup didn't yield a
263
+ // contextKey — the task may be a synthetic / parentless workflow node.
264
+ if (task?.agentId) {
265
+ try {
266
+ return agentContextKey({ agentId: task.agentId });
267
+ } catch {
268
+ // no-op; fall through to header-agent resolution
269
+ }
270
+ }
271
+ }
272
+
273
+ const agentId = singleHeader(req, "x-agent-id");
274
+ if (agentId) {
275
+ try {
276
+ return agentContextKey({ agentId });
277
+ } catch {
278
+ return null;
279
+ }
280
+ }
281
+
282
+ return null;
283
+ }
284
+
285
+ interface AuthCtx {
286
+ callerAgentId: string | undefined;
287
+ hasPageHeader: boolean;
288
+ isLead: boolean;
289
+ }
290
+
291
+ function buildAuthCtx(req: IncomingMessage): AuthCtx {
292
+ const callerAgentId = singleHeader(req, "x-agent-id");
293
+ const pageId = singleHeader(req, "x-page-id");
294
+ let isLead = false;
295
+ if (callerAgentId) {
296
+ const agent = getAgentById(callerAgentId);
297
+ isLead = agent?.isLead === true;
298
+ }
299
+ return { callerAgentId, hasPageHeader: pageId !== undefined && pageId !== "", isLead };
300
+ }
301
+
302
+ /**
303
+ * Authorize a WRITE against `namespace`. Returns null on allow, or a
304
+ * `(status, message)` tuple to send back.
305
+ *
306
+ * Rules (in order):
307
+ * - `task:page:<X>` → only the page-proxy can write (it sets `X-Page-Id`).
308
+ * The proxy injects the page id and we re-derive the expected namespace
309
+ * from that header; anything else is 403.
310
+ * - `task:agent:<X>` where X ≠ caller → 403 unless caller is lead.
311
+ * - everything else → allow (any authenticated caller).
312
+ */
313
+ function authorizeWrite(
314
+ namespace: string,
315
+ ctx: AuthCtx,
316
+ ): { status: number; message: string } | null {
317
+ if (namespace.startsWith("task:page:")) {
318
+ if (!ctx.hasPageHeader) {
319
+ return { status: 403, message: "task:page:* writes require a page-proxy request" };
320
+ }
321
+ // Page-proxy requests have already been forced to their own namespace
322
+ // by the handler before we get here, so by construction the namespace
323
+ // matches the page id. Belt-and-braces: if it doesn't, refuse.
324
+ return null;
325
+ }
326
+ if (namespace.startsWith("task:agent:")) {
327
+ const target = namespace.slice("task:agent:".length);
328
+ if (ctx.callerAgentId && target === ctx.callerAgentId) return null;
329
+ if (ctx.isLead) return null;
330
+ return { status: 403, message: "writes to another agent's namespace require lead" };
331
+ }
332
+ return null;
333
+ }
334
+
335
+ function encodeValueOrError(
336
+ res: ServerResponse,
337
+ value: unknown,
338
+ valueType: "json" | "string" | "integer",
339
+ ): { stored: string; valueType: "json" | "string" | "integer" } | null {
340
+ try {
341
+ if (valueType === "json") {
342
+ const stored = JSON.stringify(value);
343
+ if (stored === undefined) {
344
+ jsonError(res, "value is not JSON-encodable", 400);
345
+ return null;
346
+ }
347
+ return { stored, valueType };
348
+ }
349
+ if (valueType === "integer") {
350
+ if (typeof value === "number") {
351
+ if (!Number.isInteger(value) || !Number.isSafeInteger(value)) {
352
+ jsonError(res, "integer value must be a JS-safe integer", 400);
353
+ return null;
354
+ }
355
+ return { stored: String(value), valueType };
356
+ }
357
+ if (typeof value === "string" && /^-?\d+$/.test(value)) {
358
+ return { stored: value, valueType };
359
+ }
360
+ jsonError(res, "integer value must be a JS-safe integer", 400);
361
+ return null;
362
+ }
363
+ // 'string'
364
+ if (typeof value !== "string") {
365
+ jsonError(res, "string value must be a string", 400);
366
+ return null;
367
+ }
368
+ return { stored: value, valueType };
369
+ } catch (err) {
370
+ const msg = err instanceof Error ? err.message : "encoding error";
371
+ jsonError(res, `value encoding error: ${msg}`, 400);
372
+ return null;
373
+ }
374
+ }
375
+
376
+ // ─── Handler ─────────────────────────────────────────────────────────────────
377
+
378
+ export async function handleKv(
379
+ req: IncomingMessage,
380
+ res: ServerResponse,
381
+ pathSegments: string[],
382
+ queryParams: URLSearchParams,
383
+ ): Promise<boolean> {
384
+ // ── Page-proxy override ───────────────────────────────────────────────────
385
+ // X-Page-Id is set ONLY by `src/http/page-proxy.ts` after verifying the
386
+ // page-session cookie. When present we MUST namespace the request under
387
+ // `task:page:<id>` regardless of what the URL says — pages can't write or
388
+ // read any other namespace. Short-circuit the explicit-ns variants by
389
+ // dropping the `_/<ns>/` prefix so the request falls through to the
390
+ // header-resolved path.
391
+ const hasPageHeader = singleHeader(req, "x-page-id") !== undefined;
392
+ if (
393
+ hasPageHeader &&
394
+ pathSegments[0] === "api" &&
395
+ pathSegments[1] === "kv" &&
396
+ pathSegments[2] === "_"
397
+ ) {
398
+ // Reshape `["api","kv","_","<ns>","<key>",...]` →
399
+ // `["api","kv","<key>",...]` so the header-resolved patterns
400
+ // match. Drop both `_` and the namespace segment.
401
+ pathSegments = [pathSegments[0]!, pathSegments[1]!, ...pathSegments.slice(4)];
402
+ }
403
+
404
+ // ── INCR ──────────────────────────────────────────────────────────────────
405
+ if (incrKvExplicit.match(req.method, pathSegments)) {
406
+ return handleIncr(req, res, pathSegments, queryParams, /* explicit */ true);
407
+ }
408
+ if (incrKvHeader.match(req.method, pathSegments)) {
409
+ return handleIncr(req, res, pathSegments, queryParams, /* explicit */ false);
410
+ }
411
+
412
+ // ── Explicit ns variants (must come first so `/_/...` doesn't fall through
413
+ // to the header-resolved single-segment patterns) ─────────────────────────
414
+ if (getKvExplicit.match(req.method, pathSegments)) {
415
+ const parsed = await getKvExplicit.parse(req, res, pathSegments, queryParams);
416
+ if (!parsed) return true;
417
+ const ns = decodeKvSegment(res, parsed.params.namespace, "namespace");
418
+ if (!ns) return true;
419
+ const key = decodeKvSegment(res, parsed.params.key, "key");
420
+ if (!key) return true;
421
+ return sendGet(res, ns, key);
422
+ }
423
+ if (putKvExplicit.match(req.method, pathSegments)) {
424
+ if (enforceContentLengthCap(req, res, MAX_KV_BODY_BYTES) === BODY_TOO_LARGE) return true;
425
+ const parsed = await putKvExplicit.parse(req, res, pathSegments, queryParams);
426
+ if (!parsed) return true;
427
+ const ns = decodeKvSegment(res, parsed.params.namespace, "namespace");
428
+ if (!ns) return true;
429
+ const key = decodeKvSegment(res, parsed.params.key, "key");
430
+ if (!key) return true;
431
+ return sendPut(req, res, ns, key, parsed.body);
432
+ }
433
+ if (deleteKvExplicit.match(req.method, pathSegments)) {
434
+ const parsed = await deleteKvExplicit.parse(req, res, pathSegments, queryParams);
435
+ if (!parsed) return true;
436
+ const ns = decodeKvSegment(res, parsed.params.namespace, "namespace");
437
+ if (!ns) return true;
438
+ const key = decodeKvSegment(res, parsed.params.key, "key");
439
+ if (!key) return true;
440
+ return sendDelete(req, res, ns, key);
441
+ }
442
+ if (listKvExplicit.match(req.method, pathSegments)) {
443
+ const parsed = await listKvExplicit.parse(req, res, pathSegments, queryParams);
444
+ if (!parsed) return true;
445
+ const ns = decodeKvSegment(res, parsed.params.namespace, "namespace");
446
+ if (!ns) return true;
447
+ return sendList(res, ns, parsed.query);
448
+ }
449
+
450
+ // ── Header-resolved variants ──────────────────────────────────────────────
451
+ if (getKvHeader.match(req.method, pathSegments)) {
452
+ const parsed = await getKvHeader.parse(req, res, pathSegments, queryParams);
453
+ if (!parsed) return true;
454
+ const ns = resolveNamespaceForRead(req);
455
+ if (!ns) {
456
+ jsonError(res, "namespace is required (pass X-Source-Task-Id or X-Agent-ID)", 400);
457
+ return true;
458
+ }
459
+ const key = decodeKvSegment(res, parsed.params.key, "key");
460
+ if (!key) return true;
461
+ return sendGet(res, ns, key);
462
+ }
463
+ if (putKvHeader.match(req.method, pathSegments)) {
464
+ if (enforceContentLengthCap(req, res, MAX_KV_BODY_BYTES) === BODY_TOO_LARGE) return true;
465
+ const parsed = await putKvHeader.parse(req, res, pathSegments, queryParams);
466
+ if (!parsed) return true;
467
+ const ns = resolveNamespaceForWrite(req);
468
+ if (!ns) {
469
+ jsonError(res, "namespace is required (pass X-Source-Task-Id or X-Agent-ID)", 400);
470
+ return true;
471
+ }
472
+ const key = decodeKvSegment(res, parsed.params.key, "key");
473
+ if (!key) return true;
474
+ return sendPut(req, res, ns, key, parsed.body);
475
+ }
476
+ if (deleteKvHeader.match(req.method, pathSegments)) {
477
+ const parsed = await deleteKvHeader.parse(req, res, pathSegments, queryParams);
478
+ if (!parsed) return true;
479
+ const ns = resolveNamespaceForWrite(req);
480
+ if (!ns) {
481
+ jsonError(res, "namespace is required (pass X-Source-Task-Id or X-Agent-ID)", 400);
482
+ return true;
483
+ }
484
+ const key = decodeKvSegment(res, parsed.params.key, "key");
485
+ if (!key) return true;
486
+ return sendDelete(req, res, ns, key);
487
+ }
488
+ if (listKvHeader.match(req.method, pathSegments)) {
489
+ const parsed = await listKvHeader.parse(req, res, pathSegments, queryParams);
490
+ if (!parsed) return true;
491
+ const ns = resolveNamespaceForRead(req);
492
+ if (!ns) {
493
+ jsonError(res, "namespace is required (pass X-Source-Task-Id or X-Agent-ID)", 400);
494
+ return true;
495
+ }
496
+ return sendList(res, ns, parsed.query);
497
+ }
498
+
499
+ return false;
500
+ }
501
+
502
+ /**
503
+ * Reads only ever resolve from headers — there's no auth distinction between
504
+ * reading your own ns and someone else's (any authenticated caller may read
505
+ * any namespace).
506
+ */
507
+ function resolveNamespaceForRead(req: IncomingMessage): string | null {
508
+ return resolveNamespaceFromHeaders(req);
509
+ }
510
+
511
+ /**
512
+ * Writes also resolve from headers, with the same precedence — but the
513
+ * page-proxy header path is what gives `task:page:*` writes their privilege
514
+ * (see `authorizeWrite`).
515
+ */
516
+ function resolveNamespaceForWrite(req: IncomingMessage): string | null {
517
+ return resolveNamespaceFromHeaders(req);
518
+ }
519
+
520
+ async function handleIncr(
521
+ req: IncomingMessage,
522
+ res: ServerResponse,
523
+ pathSegments: string[],
524
+ queryParams: URLSearchParams,
525
+ explicit: boolean,
526
+ ): Promise<boolean> {
527
+ if (enforceContentLengthCap(req, res, MAX_KV_BODY_BYTES) === BODY_TOO_LARGE) return true;
528
+ let namespace: string;
529
+ let key: string;
530
+ let body: { by?: number } | null | undefined;
531
+ if (explicit) {
532
+ const parsed = await incrKvExplicit.parse(req, res, pathSegments, queryParams);
533
+ if (!parsed) return true;
534
+ const ns = decodeKvSegment(res, parsed.params.namespace, "namespace");
535
+ if (!ns) return true;
536
+ const k = decodeKvSegment(res, parsed.params.key, "key");
537
+ if (!k) return true;
538
+ namespace = ns;
539
+ key = k;
540
+ body = parsed.body;
541
+ } else {
542
+ const parsed = await incrKvHeader.parse(req, res, pathSegments, queryParams);
543
+ if (!parsed) return true;
544
+ const ns = resolveNamespaceForWrite(req);
545
+ if (!ns) {
546
+ jsonError(res, "namespace is required (pass X-Source-Task-Id or X-Agent-ID)", 400);
547
+ return true;
548
+ }
549
+ const k = decodeKvSegment(res, parsed.params.key, "key");
550
+ if (!k) return true;
551
+ namespace = ns;
552
+ key = k;
553
+ body = parsed.body;
554
+ }
555
+
556
+ const authErr = authorizeWrite(namespace, buildAuthCtx(req));
557
+ if (authErr) {
558
+ jsonError(res, authErr.message, authErr.status);
559
+ return true;
560
+ }
561
+
562
+ const by = body?.by ?? 1;
563
+ try {
564
+ const entry = incrKv(namespace, key, by);
565
+ json(res, entry);
566
+ } catch (err) {
567
+ if (err instanceof KvTypeCollisionError) {
568
+ jsonError(res, err.message, 409);
569
+ return true;
570
+ }
571
+ const msg = err instanceof Error ? err.message : "INCR failed";
572
+ jsonError(res, msg, 400);
573
+ }
574
+ return true;
575
+ }
576
+
577
+ function sendGet(res: ServerResponse, namespace: string, key: string): boolean {
578
+ const entry = getKv(namespace, key);
579
+ if (!entry) {
580
+ jsonError(res, "not found", 404);
581
+ return true;
582
+ }
583
+ json(res, entry);
584
+ return true;
585
+ }
586
+
587
+ function sendPut(
588
+ req: IncomingMessage,
589
+ res: ServerResponse,
590
+ namespace: string,
591
+ key: string,
592
+ body: z.infer<typeof kvSetBodySchema>,
593
+ ): boolean {
594
+ const authErr = authorizeWrite(namespace, buildAuthCtx(req));
595
+ if (authErr) {
596
+ jsonError(res, authErr.message, authErr.status);
597
+ return true;
598
+ }
599
+ const valueType = body.valueType ?? "json";
600
+ const encoded = encodeValueOrError(res, body.value, valueType);
601
+ if (!encoded) return true;
602
+ // Second cap-check on the encoded bytes (post-JSON-stringify) so a tiny
603
+ // Content-Length header can't sneak a huge value past us.
604
+ if (Buffer.byteLength(encoded.stored, "utf8") > MAX_KV_BODY_BYTES) {
605
+ jsonError(res, `Payload too large (max ${MAX_KV_BODY_BYTES} bytes)`, 413);
606
+ return true;
607
+ }
608
+ const expiresAt = body.expiresInSec !== undefined ? Date.now() + body.expiresInSec * 1000 : null;
609
+ try {
610
+ const entry = upsertKv({
611
+ namespace,
612
+ key,
613
+ value: body.value,
614
+ valueType,
615
+ expiresAt,
616
+ });
617
+ json(res, entry);
618
+ } catch (err) {
619
+ const msg = err instanceof Error ? err.message : "upsert failed";
620
+ jsonError(res, msg, 400);
621
+ }
622
+ return true;
623
+ }
624
+
625
+ function sendDelete(
626
+ req: IncomingMessage,
627
+ res: ServerResponse,
628
+ namespace: string,
629
+ key: string,
630
+ ): boolean {
631
+ const authErr = authorizeWrite(namespace, buildAuthCtx(req));
632
+ if (authErr) {
633
+ jsonError(res, authErr.message, authErr.status);
634
+ return true;
635
+ }
636
+ const removed = deleteKv(namespace, key);
637
+ if (!removed) {
638
+ jsonError(res, "not found", 404);
639
+ return true;
640
+ }
641
+ res.writeHead(204);
642
+ res.end();
643
+ return true;
644
+ }
645
+
646
+ function sendList(
647
+ res: ServerResponse,
648
+ namespace: string,
649
+ query: z.infer<typeof kvListQuerySchema>,
650
+ ): boolean {
651
+ const limit = Math.min(query.limit ?? 100, MAX_KV_LIST_LIMIT);
652
+ const offset = query.offset ?? 0;
653
+ const prefix = query.prefix && query.prefix.length > 0 ? query.prefix : undefined;
654
+ const entries = listKv(namespace, { prefix, limit, offset });
655
+ const total = countKv(namespace, { prefix });
656
+ json(res, { entries, total, namespace });
657
+ return true;
658
+ }
@@ -148,9 +148,14 @@ export async function handlePageProxy(req: IncomingMessage, res: ServerResponse)
148
148
  const targetUrl = `${baseUrl}${rewrittenPath}${queryPart}`;
149
149
 
150
150
  const apiKey = process.env.API_KEY ?? "";
151
+ // `X-Page-Id` is the trust anchor for page-scoped KV: only the page-proxy
152
+ // ever sets it (any external `X-Page-Id` header is dropped because we don't
153
+ // forward the original headers). The KV handler treats this as the highest-
154
+ // priority namespace source so a page can't escape `task:page:<own>`.
151
155
  const headers: Record<string, string> = {
152
156
  Authorization: `Bearer ${apiKey}`,
153
157
  "X-Agent-ID": page.agentId,
158
+ "X-Page-Id": page.id,
154
159
  };
155
160
 
156
161
  // Forward content-type / accept verbatim for non-GET so JSON bodies work.