@crewhaus/checkpoint-store 0.1.1 → 0.1.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/checkpoint-store",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "description": "File-backed checkpoint store for graph-engine — save/load/list/branch over per-graph-run JSON files",
6
6
  "main": "src/index.ts",
@@ -12,13 +12,14 @@
12
12
  "test": "bun test src"
13
13
  },
14
14
  "dependencies": {
15
- "@crewhaus/errors": "0.1.1"
15
+ "@crewhaus/errors": "0.1.3",
16
+ "@crewhaus/tenancy": "0.1.3"
16
17
  },
17
18
  "license": "Apache-2.0",
18
19
  "author": {
19
20
  "name": "Max Meier",
20
- "email": "max@studiomax.io",
21
- "url": "https://studiomax.io"
21
+ "email": "max@crewhaus.ai",
22
+ "url": "https://crewhaus.ai"
22
23
  },
23
24
  "repository": {
24
25
  "type": "git",
@@ -30,12 +31,7 @@
30
31
  "url": "https://github.com/crewhaus/factory/issues"
31
32
  },
32
33
  "publishConfig": {
33
- "access": "restricted"
34
+ "access": "public"
34
35
  },
35
- "files": [
36
- "src",
37
- "README.md",
38
- "LICENSE",
39
- "NOTICE"
40
- ]
36
+ "files": ["src", "README.md", "LICENSE", "NOTICE"]
41
37
  }
package/src/index.test.ts CHANGED
@@ -3,9 +3,15 @@ import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { RuntimeError } from "@crewhaus/errors";
6
+ import { TenancyError, buildTenant, withTenant } from "@crewhaus/tenancy";
6
7
  import {
8
+ type Checkpoint,
7
9
  type CheckpointStore,
10
+ type CheckpointStoreAdapter,
8
11
  CheckpointStoreError,
12
+ type GraphRunId,
13
+ type GraphRunMeta,
14
+ type ListOptions,
9
15
  createCheckpointStore,
10
16
  newCheckpointId,
11
17
  newGraphRunId,
@@ -206,3 +212,190 @@ describe("stress (T7-lite)", () => {
206
212
  expect(list[499]?.state).toEqual({ i: 499 });
207
213
  }, 15_000);
208
214
  });
215
+
216
+ describe("cross-tenant fencing (CWE-1230)", () => {
217
+ test("inside tenantA, a store rooted under tenantB fails closed", async () => {
218
+ const tenantsRoot = mkdtempSync(join(tmpdir(), "checkpoint-tenants-"));
219
+ try {
220
+ const tenantA = buildTenant("tenant-a", { tenantsRoot });
221
+ const tenantB = buildTenant("tenant-b", { tenantsRoot });
222
+ // Store rooted under tenantB while tenantA is active — every resolved
223
+ // graph-run directory escapes tenantA's sessionRoot, so it fails closed.
224
+ const fenced = createCheckpointStore({ rootDir: tenantB.sessionRoot });
225
+ await withTenant(tenantA, async () => {
226
+ const grun = newGraphRunId();
227
+ await expect(fenced.save({ graphRunId: grun, nodeName: "a", state: 1 })).rejects.toThrow(
228
+ TenancyError,
229
+ );
230
+ await expect(fenced.load(grun)).rejects.toThrow(/cross-tenant access denied/);
231
+ });
232
+ } finally {
233
+ rmSync(tenantsRoot, { recursive: true, force: true });
234
+ }
235
+ });
236
+
237
+ test("inside tenantA, a store rooted under tenantA round-trips", async () => {
238
+ const tenantsRoot = mkdtempSync(join(tmpdir(), "checkpoint-tenants-"));
239
+ try {
240
+ const tenantA = buildTenant("tenant-a", { tenantsRoot });
241
+ const ok = createCheckpointStore({ rootDir: tenantA.sessionRoot });
242
+ await withTenant(tenantA, async () => {
243
+ const grun = newGraphRunId();
244
+ const cp = await ok.save({ graphRunId: grun, nodeName: "a", state: { x: 1 } });
245
+ const loaded = await ok.load(grun, cp.id);
246
+ expect(loaded?.id).toBe(cp.id);
247
+ });
248
+ } finally {
249
+ rmSync(tenantsRoot, { recursive: true, force: true });
250
+ }
251
+ });
252
+
253
+ test("no active tenant — behaviour is unchanged (no fencing)", async () => {
254
+ const grun = newGraphRunId();
255
+ const cp = await store.save({ graphRunId: grun, nodeName: "a", state: { x: 1 } });
256
+ const loaded = await store.load(grun, cp.id);
257
+ expect(loaded?.id).toBe(cp.id);
258
+ });
259
+ });
260
+
261
+ describe("list: since filter", () => {
262
+ // A controllable clock keeps checkpoint timestamps deterministic so the
263
+ // `since` boundary is exercised without depending on the real wall clock.
264
+ test("skips checkpoints created strictly before `since`", async () => {
265
+ const clock = { ms: Date.parse("2026-01-01T00:00:00.000Z") };
266
+ const clocked = createCheckpointStore({
267
+ rootDir: tmp,
268
+ now: () => new Date(clock.ms),
269
+ });
270
+ const grun = newGraphRunId();
271
+
272
+ const older = await clocked.save({ graphRunId: grun, nodeName: "old", state: 1 });
273
+ clock.ms += 60_000; // advance one minute
274
+ const newer = await clocked.save({ graphRunId: grun, nodeName: "new", state: 2 });
275
+
276
+ // `since` lands between the two checkpoints: the older one is filtered out
277
+ // (the `continue` branch), the newer one is kept.
278
+ const cutoff = new Date(clock.ms - 30_000).toISOString();
279
+ const list = await clocked.list(grun, { since: cutoff });
280
+ expect(list.map((c) => c.id)).toEqual([newer.id]);
281
+ expect(list.map((c) => c.id)).not.toContain(older.id);
282
+ });
283
+
284
+ test("`since` in the future drops everything", async () => {
285
+ const grun = newGraphRunId();
286
+ await store.save({ graphRunId: grun, nodeName: "a", state: 1 });
287
+ const list = await store.list(grun, { since: "2999-01-01T00:00:00.000Z" });
288
+ expect(list).toEqual([]);
289
+ });
290
+ });
291
+
292
+ describe("save: parentCheckpointId validation", () => {
293
+ test("rejects a malformed parentCheckpointId before any write", async () => {
294
+ const grun = newGraphRunId();
295
+ await expect(
296
+ store.save({ graphRunId: grun, nodeName: "a", state: 1, parentCheckpointId: "../../bad" }),
297
+ ).rejects.toBeInstanceOf(RuntimeError);
298
+ // Nothing was persisted for this run.
299
+ expect(await store.meta(grun)).toBeUndefined();
300
+ });
301
+ });
302
+
303
+ describe("pluggable adapter", () => {
304
+ // A minimal in-memory adapter proves `createCheckpointStore({ adapter })`
305
+ // wires a non-filesystem backend through every public method, and lets us
306
+ // reach states the file-backed adapter never produces on its own.
307
+ function makeMemoryAdapter(): {
308
+ adapter: CheckpointStoreAdapter;
309
+ checkpoints: Map<string, Checkpoint>;
310
+ metas: Map<GraphRunId, GraphRunMeta>;
311
+ } {
312
+ const checkpoints = new Map<string, Checkpoint>();
313
+ const metas = new Map<GraphRunId, GraphRunMeta>();
314
+ const key = (g: GraphRunId, c: string): string => `${g}/${c}`;
315
+ const adapter: CheckpointStoreAdapter = {
316
+ async save(c: Checkpoint): Promise<void> {
317
+ checkpoints.set(key(c.graphRunId, c.id), c);
318
+ },
319
+ async load(g: GraphRunId, c: string): Promise<Checkpoint | undefined> {
320
+ return checkpoints.get(key(g, c));
321
+ },
322
+ async list(g: GraphRunId, _opts: ListOptions): Promise<ReadonlyArray<Checkpoint>> {
323
+ return [...checkpoints.values()].filter((c) => c.graphRunId === g);
324
+ },
325
+ async loadMeta(g: GraphRunId): Promise<GraphRunMeta | undefined> {
326
+ return metas.get(g);
327
+ },
328
+ async saveMeta(m: GraphRunMeta): Promise<void> {
329
+ metas.set(m.graphRunId, m);
330
+ },
331
+ async drop(g: GraphRunId): Promise<void> {
332
+ metas.delete(g);
333
+ for (const k of [...checkpoints.keys()]) {
334
+ if (k.startsWith(`${g}/`)) checkpoints.delete(k);
335
+ }
336
+ },
337
+ };
338
+ return { adapter, checkpoints, metas };
339
+ }
340
+
341
+ test("save → load → branch → drop all route through the custom adapter", async () => {
342
+ const { adapter, checkpoints, metas } = makeMemoryAdapter();
343
+ const custom = createCheckpointStore({ adapter });
344
+
345
+ const grun = newGraphRunId();
346
+ const cp = await custom.save({ graphRunId: grun, nodeName: "plan", state: { a: 1 } });
347
+ expect(checkpoints.get(`${grun}/${cp.id}`)?.nodeName).toBe("plan");
348
+ expect(metas.get(grun)?.head).toBe(cp.id);
349
+
350
+ expect((await custom.load(grun, cp.id))?.id).toBe(cp.id);
351
+ expect((await custom.load(grun))?.id).toBe(cp.id);
352
+
353
+ const { newGraphRunId: child, head } = await custom.branch(grun, cp.id);
354
+ expect(metas.get(child)?.branchedFrom).toEqual({ graphRunId: grun, checkpointId: cp.id });
355
+ expect(head.state).toEqual({ a: 1 });
356
+
357
+ await custom.drop(grun);
358
+ expect(metas.has(grun)).toBe(false);
359
+ expect(await custom.load(grun, cp.id)).toBeUndefined();
360
+ });
361
+
362
+ test("load without checkpointId returns undefined when meta has no head", async () => {
363
+ const { adapter, metas } = makeMemoryAdapter();
364
+ const custom = createCheckpointStore({ adapter });
365
+ const grun = newGraphRunId();
366
+ // Seed a meta with no head — a state a freshly-created run can hold before
367
+ // its first commit. `load(grun)` must short-circuit to undefined.
368
+ metas.set(grun, { version: 1, graphRunId: grun, createdAt: new Date(0).toISOString() });
369
+ expect(await custom.load(grun)).toBeUndefined();
370
+ });
371
+
372
+ test("save preserves an existing meta's branchedFrom when advancing head", async () => {
373
+ const { adapter, metas } = makeMemoryAdapter();
374
+ const custom = createCheckpointStore({ adapter });
375
+ const grun = newGraphRunId();
376
+ const branchedFrom = { graphRunId: newGraphRunId(), checkpointId: newCheckpointId() };
377
+ // Pre-seed a branched-run meta (head absent), then commit: ensureMeta must
378
+ // reuse the existing meta so branchedFrom survives the head update.
379
+ metas.set(grun, {
380
+ version: 1,
381
+ graphRunId: grun,
382
+ createdAt: new Date(0).toISOString(),
383
+ branchedFrom,
384
+ });
385
+ const cp = await custom.save({ graphRunId: grun, nodeName: "n", state: 1 });
386
+ expect(metas.get(grun)?.head).toBe(cp.id);
387
+ expect(metas.get(grun)?.branchedFrom).toEqual(branchedFrom);
388
+ });
389
+ });
390
+
391
+ describe("CheckpointStoreError", () => {
392
+ test("carries the config code and serializes its cause chain", () => {
393
+ const root = new Error("disk gone");
394
+ const err = new CheckpointStoreError("save failed", root);
395
+ expect(err).toBeInstanceOf(CheckpointStoreError);
396
+ expect(err.code).toBe("config");
397
+ expect(err.name).toBe("CheckpointStoreError");
398
+ const json = err.toJSON();
399
+ expect(json.cause).toEqual({ name: "Error", message: "disk gone" });
400
+ });
401
+ });
package/src/index.ts CHANGED
@@ -38,8 +38,9 @@ import {
38
38
  statSync,
39
39
  writeFileSync,
40
40
  } from "node:fs";
41
- import { join } from "node:path";
41
+ import { join, resolve } from "node:path";
42
42
  import { CrewhausError, RuntimeError } from "@crewhaus/errors";
43
+ import { assertSamePath, currentTenantContext, requireTenant } from "@crewhaus/tenancy";
43
44
 
44
45
  export const DEFAULT_ROOT_DIR = ".crewhaus/graphs";
45
46
 
@@ -160,7 +161,16 @@ class FileSystemAdapter implements CheckpointStoreAdapter {
160
161
 
161
162
  private dir(graphRunId: GraphRunId): string {
162
163
  validateGraphRunId(graphRunId);
163
- return join(this.rootDir, graphRunId);
164
+ const dir = join(this.rootDir, graphRunId);
165
+ // When a tenant context is active, fail closed on a resolved path that
166
+ // escapes the tenant's sessionRoot (CWE-1230). Every checkpoint/meta path
167
+ // is built under this directory, so fencing here covers every read/write.
168
+ // Outside a tenant scope (the common CLI case) this is a no-op so
169
+ // non-tenant behaviour is unchanged.
170
+ if (currentTenantContext() !== undefined) {
171
+ assertSamePath(resolve(dir), requireTenant().sessionRoot);
172
+ }
173
+ return dir;
164
174
  }
165
175
 
166
176
  private metaPath(graphRunId: GraphRunId): string {