@pleri/olam-cli 0.1.136 → 0.1.138

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.
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "auth": "sha256:47f58fad2635727a03382a75bbd0db3cd1357e18c4173f29302c8648c3a01d43",
3
- "devbox": "sha256:be650e59173f67754df1ae543d8a5ee543cfc431ed8ae636e912c6441400959c",
4
- "devbox-base": "sha256:78a8795dd68815f8e8f1077dd1e78d10c5b34765d96048f41e38c3a74f6658c5",
5
- "host-cp": "sha256:08e7fbcd68bc23a24c579067ed2bd451c51cb4dff1e7940af2c743c88d5b7835",
3
+ "devbox": "sha256:370210d8adad83d6f974c720a1988bfa4e4bc18b2fb5e026eb1820fb4c40d5f4",
4
+ "devbox-base": "sha256:33de7809b0e53090f2d4d2efb36b4c916ee2a5cc3583606031ca934110b5d16f",
5
+ "host-cp": "sha256:eeddba15e412e2b913dbcbcc1d314cb2dd83a534fce8168b7445e72c4341e1f5",
6
+ "kg-service": "sha256:db2219624e4eb423c171391d61f0b46da416b1414b56469a73d67bf6e31fd8ea",
6
7
  "mcp-auth": "sha256:452b10df72ac8f7e8f249c171e068e51c7ca846a9b287df81e253b7df1169a95",
7
8
  "$schema_version": 1,
8
- "$published_version": "0.1.136",
9
+ "$published_version": "0.1.138",
9
10
  "$registry": "ghcr.io/pleri"
10
11
  }
@@ -3,8 +3,14 @@
3
3
  // POST /v1/chunks — bearer-auth write API; server-injects actor_id +
4
4
  // actor_type from the bearer principal, validates the
5
5
  // payload, INSERTs into a source Postgres.
6
- // GET /v1/shape — proxied straight to a backing Electric SQL server
7
- // (read-only by Electric's contract; no extra auth).
6
+ // GET /v1/shape — bearer-auth read API; requires (world_id,
7
+ // session_id) query params; rewrites the upstream
8
+ // Electric `where` clause server-side so a caller
9
+ // can never enumerate cross-world chunks (PB2).
10
+ // Streams the upstream response without buffering
11
+ // so Electric's `live=true` long-poll round-trips
12
+ // stay reactive (PB1). Phase B B1 closes both
13
+ // A2-CP3 audit HIGHs.
8
14
  // GET /livez — liveness probe (200 on healthy; matches the
9
15
  // agent-memory-service shape so host-cp uses one
10
16
  // probe pattern for both sidecars).
@@ -22,6 +28,7 @@
22
28
  // OLAM_PLAN_CHAT_SECRET_PATH (default ~/.olam/plan-chat-secret)
23
29
 
24
30
  import http from 'node:http';
31
+ import { Readable } from 'node:stream';
25
32
  import { URL } from 'node:url';
26
33
  import pg from 'pg';
27
34
  import { ensureSecret, timingSafeEqual, SECRET_PATH } from './plan-chat-secret.mjs';
@@ -33,6 +40,27 @@ const DEFAULT_ELECTRIC_URL = 'http://localhost:30001';
33
40
  const ACTOR_TYPES = new Set(['agent', 'operator', 'codex', 'system']);
34
41
  const ROLES = new Set(['user', 'assistant', 'tool', 'system']);
35
42
 
43
+ // PB2 — scope-ID shape. world_id + session_id query params are interpolated
44
+ // into the upstream Electric `where` clause; the regex IS the SQL-injection
45
+ // defence (single-quotes, semicolons, comments can't pass). Same shape as
46
+ // @olam/chunks schema accepts and as measure-in-world.mjs validates. Bump
47
+ // only if the chunks schema bumps; the two must move together.
48
+ const SCOPE_ID_RE = /^[A-Za-z0-9_.-]+$/;
49
+
50
+ // PB2 — query params the server OWNS on /v1/shape. All three are stripped
51
+ // from what we forward to upstream Electric:
52
+ // - `world_id` + `session_id` are SEEDS for the server-derived `where`
53
+ // clause; Electric doesn't read them natively, so dropping them from
54
+ // the upstream URL is a no-op for Electric semantics. (Per CP3-B1-M2:
55
+ // this drop is intentional — if a future Electric version adopts these
56
+ // as native scope params, split this set into SEEDS vs DROPPED.)
57
+ // - `where` is the actual SQL predicate; we drop the client's value and
58
+ // replace with the server-derived one. Operator can't bypass scope by
59
+ // passing their own.
60
+ // Everything else (`table`, `offset`, `handle`, `live`, future Electric
61
+ // params) forwards through verbatim.
62
+ const SCOPED_QUERY_PARAMS = new Set(['world_id', 'session_id', 'where']);
63
+
36
64
  /**
37
65
  * Resolve the bearer principal to (actor_id, actor_type). The minimal-A2
38
66
  * implementation treats every authenticated bearer as the same shared
@@ -188,24 +216,103 @@ export function createHandler({ pool, bearer, electricUrl }) {
188
216
  }
189
217
 
190
218
  async function handleGetShape(req, res, url) {
191
- // Proxy directly to Electric read-path is shape-server's responsibility,
192
- // not ours; we just preserve the URL and add no auth (Electric is read-only).
219
+ // PB2 bearer required. Same checkAuth as POST /v1/chunks; rejects with
220
+ // a constant-time comparison via timingSafeEqual.
221
+ if (!checkAuth(req)) return unauthorized(res);
222
+
223
+ // PB2 — server-derived scope. world_id + session_id query params are
224
+ // mandatory; the regex check is the SQL-injection defence for the
225
+ // upstream `where` interpolation below.
226
+ const worldId = url.searchParams.get('world_id');
227
+ const sessionId = url.searchParams.get('session_id');
228
+ if (!worldId || !SCOPE_ID_RE.test(worldId)) {
229
+ return badRequest(
230
+ res,
231
+ 'world_id query param required (alphanumerics + _ - . only)',
232
+ );
233
+ }
234
+ if (!sessionId || !SCOPE_ID_RE.test(sessionId)) {
235
+ return badRequest(
236
+ res,
237
+ 'session_id query param required (alphanumerics + _ - . only)',
238
+ );
239
+ }
240
+
241
+ // Forward all client query params EXCEPT the scoped set. Client-supplied
242
+ // `where` is silently dropped — server-derived `where` always wins.
243
+ // Operators inspecting traffic see the rewrite in the upstream URL; we
244
+ // don't 400 on `where=` presence because legitimate Electric clients may
245
+ // pass it via library defaults.
193
246
  const upstream = new URL(electricBase);
194
247
  upstream.pathname = '/v1/shape';
195
- for (const [key, value] of url.searchParams) upstream.searchParams.set(key, value);
248
+ for (const [key, value] of url.searchParams) {
249
+ if (SCOPED_QUERY_PARAMS.has(key)) continue;
250
+ upstream.searchParams.set(key, value);
251
+ }
252
+ // PB2 — server-derived `where` clause; safe because SCOPE_ID_RE has
253
+ // already rejected single-quotes, semicolons, and SQL meta-characters.
254
+ upstream.searchParams.set(
255
+ 'where',
256
+ `world_id='${worldId}' AND session_id='${sessionId}'`,
257
+ );
258
+
259
+ let upstreamRes;
196
260
  try {
197
- const upstreamRes = await fetch(upstream, {
261
+ upstreamRes = await fetch(upstream, {
198
262
  method: 'GET',
199
263
  headers: { accept: 'application/json' },
200
264
  });
201
- const text = await upstreamRes.text();
202
- for (const header of ['electric-handle', 'electric-offset', 'electric-up-to-date', 'electric-schema']) {
203
- const value = upstreamRes.headers.get(header);
204
- if (value) res.setHeader(header, value);
205
- }
206
- send(res, upstreamRes.status, text || '[]');
207
265
  } catch (err) {
208
- send(res, 502, { error: 'shape-upstream-unreachable', message: String(err?.message ?? err) });
266
+ return send(res, 502, {
267
+ error: 'shape-upstream-unreachable',
268
+ message: String(err?.message ?? err),
269
+ });
270
+ }
271
+
272
+ // Forward upstream electric-* headers BEFORE the stream starts. Once
273
+ // res.writeHead fires, headers are locked.
274
+ for (const header of [
275
+ 'electric-handle',
276
+ 'electric-offset',
277
+ 'electric-up-to-date',
278
+ 'electric-schema',
279
+ ]) {
280
+ const value = upstreamRes.headers.get(header);
281
+ if (value) res.setHeader(header, value);
282
+ }
283
+ const upstreamContentType = upstreamRes.headers.get('content-type');
284
+ if (upstreamContentType) res.setHeader('content-type', upstreamContentType);
285
+
286
+ res.writeHead(upstreamRes.status);
287
+
288
+ // PB1 — stream the body without buffering. Electric SQL long-poll
289
+ // responses (`live=true`) can sit open for tens of seconds before the
290
+ // first row arrives; the old `fetch().text()` blocked the response until
291
+ // upstream closed, which broke reactive UX. `Readable.fromWeb` wraps
292
+ // upstream's web ReadableStream as a Node Readable; `.pipe(res)` ends
293
+ // the response automatically when the upstream stream closes.
294
+ //
295
+ // CP3-B1-H1 fix: attach an `error` listener BEFORE piping. Without one,
296
+ // a mid-stream upstream error (Electric SQL restart, network blip mid
297
+ // long-poll, upstream socket reset after headers) becomes an
298
+ // uncaughtException and kills the host-cp process — DoS class. On
299
+ // upstream error we destroy the response so the client sees a
300
+ // truncated body (the only honest signal once headers are sent) and
301
+ // the process stays up. Mirrors the established pattern in
302
+ // packages/host-cp/src/proxy.mjs:205.
303
+ if (upstreamRes.body) {
304
+ const src = Readable.fromWeb(upstreamRes.body);
305
+ src.on('error', (err) => {
306
+ try {
307
+ res.destroy(err);
308
+ } catch {
309
+ /* response already destroyed; fine */
310
+ }
311
+ });
312
+ src.pipe(res);
313
+ } else {
314
+ // Defensive — no body (e.g. HEAD-shaped empty response). Close out.
315
+ res.end();
209
316
  }
210
317
  }
211
318
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pleri/olam-cli",
3
- "version": "0.1.136",
3
+ "version": "0.1.138",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "olam": "./bin/olam.cjs"