@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.
- package/README.md +2 -0
- package/openapi.json +559 -1
- package/package.json +4 -4
- package/plugin/skills/kv-storage/SKILL.md +168 -0
- package/plugin/skills/pages/SKILL.md +149 -0
- package/src/artifact-sdk/browser-sdk.ts +292 -0
- package/src/be/db.ts +309 -0
- package/src/be/migrations/061_kv_store.sql +34 -0
- package/src/be/migrations/062_pages_view_count.sql +9 -0
- package/src/commands/provider-credentials.ts +1 -1
- package/src/http/index.ts +2 -0
- package/src/http/kv.ts +658 -0
- package/src/http/page-proxy.ts +5 -0
- package/src/http/pages-public.ts +50 -6
- package/src/http/status.ts +1 -1
- package/src/providers/claude-adapter.ts +138 -7
- package/src/providers/pi-mono-adapter.ts +3 -3
- package/src/providers/pi-mono-extension.ts +1 -1
- package/src/server.ts +20 -1
- package/src/tasks/context-key.ts +28 -0
- package/src/telemetry.ts +65 -1
- package/src/tests/claude-adapter-binary.test.ts +628 -0
- package/src/tests/context-key.test.ts +17 -0
- package/src/tests/kv-http.test.ts +331 -0
- package/src/tests/kv-namespace-resolution.test.ts +172 -0
- package/src/tests/kv-page-proxy.test.ts +212 -0
- package/src/tests/kv-storage.test.ts +227 -0
- package/src/tests/kv-tool.test.ts +217 -0
- package/src/tests/page-proxy.test.ts +5 -1
- package/src/tests/page-session.test.ts +10 -5
- package/src/tests/pages-authed-mode.test.ts +5 -1
- package/src/tests/pages-public-html.test.ts +10 -1
- package/src/tests/pages-view-count.test.ts +220 -0
- package/src/tests/swarm-diff.test.ts +303 -0
- package/src/tests/telemetry-init.test.ts +149 -0
- package/src/tools/kv/index.ts +5 -0
- package/src/tools/kv/kv-delete.ts +89 -0
- package/src/tools/kv/kv-get.ts +64 -0
- package/src/tools/kv/kv-incr.ts +116 -0
- package/src/tools/kv/kv-list.ts +81 -0
- package/src/tools/kv/kv-set.ts +194 -0
- package/src/tools/kv/resolve-namespace.ts +58 -0
- package/src/tools/tool-config.ts +7 -0
- package/src/types.ts +53 -0
- package/src/utils/internal-ai/complete-structured.ts +7 -10
- 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
|
+
}
|
package/src/http/page-proxy.ts
CHANGED
|
@@ -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.
|