agentic-drive 0.6.1 → 0.8.0

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,9 +1,34 @@
1
1
  // src/drive.ts
2
- import { EraseRefusedError, MAX_FOLDER_DEPTH, PRINCIPAL_KINDS, SUBJECT_KINDS } from "agentic-identity";
2
+ import { EraseRefusedError, StoreConflictError, MAX_FOLDER_DEPTH, PRINCIPAL_KINDS, ROLES, SUBJECT_KINDS } from "agentic-identity";
3
3
  var ROOT_ID = "root";
4
4
  var NODES = "drive_nodes";
5
5
  var NAMES = "drive_names";
6
6
  var ACTOR_KINDS = PRINCIPAL_KINDS;
7
+ var DECIDED = /* @__PURE__ */ Symbol("decided");
8
+ var ERROR_STATUS = Object.freeze({
9
+ CROSS_DRIVE: 409,
10
+ INVALID_SHARE: 400,
11
+ TOO_MANY_ITEMS: 409,
12
+ TOO_DEEP: 400,
13
+ INVALID_EXPIRY: 400,
14
+ BUSY: 409,
15
+ BLOB_EXISTS: 500,
16
+ NOT_FOUND: 404,
17
+ NAME_TAKEN: 409,
18
+ INVALID_NAME: 400,
19
+ INVALID_PATH: 400,
20
+ INVALID_REF: 400,
21
+ NOT_A_FOLDER: 400,
22
+ NOT_A_FILE: 400,
23
+ MOVE_INTO_SELF: 409,
24
+ PARENT_GONE: 409,
25
+ TRASHED: 409,
26
+ NOT_TRASHED: 409,
27
+ IS_ROOT: 400,
28
+ FORBIDDEN: 403,
29
+ BLOB_MISSING: 500,
30
+ TOO_LARGE: 413
31
+ });
7
32
  var DriveError = class extends Error {
8
33
  constructor(code, message) {
9
34
  super(message);
@@ -59,6 +84,20 @@ function splitPath(path) {
59
84
  if (parts.length > MAX_PATH_SEGMENTS) throw new DriveError("TOO_DEEP", `a path has at most ${MAX_PATH_SEGMENTS} segments`);
60
85
  return parts.map(parseName);
61
86
  }
87
+ var Ask = class extends Error {
88
+ constructor(key, ctx, action, node) {
89
+ super("access answer needed");
90
+ this.key = key;
91
+ this.ctx = ctx;
92
+ this.action = action;
93
+ this.node = node;
94
+ }
95
+ key;
96
+ ctx;
97
+ action;
98
+ node;
99
+ };
100
+ var MAX_ASKS = 100;
62
101
  function batch() {
63
102
  const writes = [];
64
103
  const fresh = /* @__PURE__ */ new Set();
@@ -101,7 +140,24 @@ function createDrive(deps) {
101
140
  const onOrphan = deps.onOrphan ?? ((key, error) => console.error(`orphan blob ${key}: ${error instanceof Error ? error.name : "delete failed"}`));
102
141
  const sharing = deps.sharing;
103
142
  const resourceOf = async (node) => node.id === ROOT_ID ? rootResource(node.owner) : node.id;
104
- const can = deps.can ?? (sharing ? async (ctx, action, node) => ctx.principal !== void 0 && (await sharing.can(ctx.principal, ACTION_TO_IDENTITY[action], await resourceOf(node))).allowed : (ctx, _action, node) => node.owner === ctx.owner);
143
+ const askCan = deps.can ?? (sharing ? async (ctx, action, node) => ctx.principal !== void 0 && (await sharing.can(ctx.principal, ACTION_TO_IDENTITY[action], await resourceOf(node))).allowed : (ctx, _action, node) => node.owner === ctx.owner);
144
+ const can = async (ctx, action, node) => {
145
+ const held = ctx[DECIDED];
146
+ if (!held) return askCan(ctx, action, node);
147
+ const chain = [];
148
+ if (sharing) {
149
+ for (let id = await resourceOf(node), i = 0; id && i <= MAX_ITEM_DEPTH + 1; i++) {
150
+ const doc = await held.tx.get("resources", id);
151
+ chain.push(doc?.parentId ?? null);
152
+ id = typeof doc?.parentId === "string" ? doc.parentId : void 0;
153
+ }
154
+ }
155
+ const key = JSON.stringify([action, node.id, node.owner, node.parentId, node.updatedAt, node.trashed, chain, ctx.owner, ctx.actor, ctx.principal ?? null]);
156
+ const known = held.answers.get(key);
157
+ if (known !== void 0) return known;
158
+ const { [DECIDED]: _held, ...plain } = ctx;
159
+ throw new Ask(key, plain, action, node);
160
+ };
105
161
  const rootNode = (owner, by) => ({
106
162
  id: ROOT_ID,
107
163
  owner,
@@ -397,10 +453,30 @@ function createDrive(deps) {
397
453
  try {
398
454
  return await store.transaction(fn);
399
455
  } catch (error) {
400
- if (error instanceof Error && /transaction kept conflicting/.test(error.message)) throw new DriveError("BUSY", "the drive changed at the same time. Try again.");
456
+ if (error instanceof StoreConflictError || error instanceof Error && /transaction kept conflicting/.test(error.message)) throw new DriveError("BUSY", "the drive changed at the same time. Try again.");
401
457
  throw error;
402
458
  }
403
459
  }
460
+ async function transactAs(ctx, fn) {
461
+ const answers = /* @__PURE__ */ new Map();
462
+ let runs = 0;
463
+ let asked = false;
464
+ for (; ; ) {
465
+ try {
466
+ return await transact((tx) => {
467
+ if (++runs > 2 * MAX_ASKS) throw new DriveError("BUSY", "the drive changed at the same time. Try again.");
468
+ if (runs > 1 && !asked) answers.clear();
469
+ asked = false;
470
+ return fn(tx, { ...ctx, [DECIDED]: { answers, tx } });
471
+ });
472
+ } catch (error) {
473
+ if (!(error instanceof Ask)) throw error;
474
+ asked = true;
475
+ if (answers.size >= MAX_ASKS) throw new DriveError("BUSY", "the drive needed too many access checks. Try again.");
476
+ answers.set(error.key, await askCan(error.ctx, error.action, error.node));
477
+ }
478
+ }
479
+ }
404
480
  function assertInPerson(ctx, what) {
405
481
  if (!isInPerson(ctx) || ctx.actor.kind !== "human") throw new DriveError("FORBIDDEN", `only the owner in person can ${what}`);
406
482
  }
@@ -550,15 +626,15 @@ function createDrive(deps) {
550
626
  return (await children(store, folder.owner, folder.id)).filter((n) => !n.trashed && n.purging !== true).sort((a, b) => a.nameKey < b.nameKey ? -1 : a.nameKey > b.nameKey ? 1 : a.name < b.name ? -1 : 1);
551
627
  },
552
628
  async mkdir(ctx, path, opts = {}) {
553
- return transact(async (tx) => {
629
+ return transactAs(ctx, async (tx, ctx2) => {
554
630
  const w = batch();
555
- const { parent, name } = await parentAndName(tx, w, ctx, path, opts.parents ?? false);
631
+ const { parent, name } = await parentAndName(tx, w, ctx2, path, opts.parents ?? false);
556
632
  const existing = w.fresh.has(parent.id) ? null : await liveChild(tx, parent, parseName(name).key);
557
633
  if (opts.parents && existing?.kind === "folder") {
558
- if (!await can(ctx, "read", existing)) throw new DriveError("NOT_FOUND", `no item ${path}`);
634
+ if (!await can(ctx2, "read", existing)) throw new DriveError("NOT_FOUND", `no item ${path}`);
559
635
  return existing;
560
636
  }
561
- const { node } = await insert(tx, w, ctx, parent, name, { kind: "folder" }, "fail");
637
+ const { node } = await insert(tx, w, ctx2, parent, name, { kind: "folder" }, "fail");
562
638
  await w.apply(tx);
563
639
  return node;
564
640
  });
@@ -575,10 +651,10 @@ function createDrive(deps) {
575
651
  if (!stored) throw new DriveError("BLOB_EXISTS", "a blob with this key already exists");
576
652
  let committed;
577
653
  try {
578
- committed = await transact(async (tx) => {
654
+ committed = await transactAs(ctx, async (tx, ctx2) => {
579
655
  const w = batch();
580
- const { parent, name } = await parentAndName(tx, w, ctx, path, opts.parents ?? false);
581
- const result = await insert(tx, w, ctx, parent, name, { kind: "file", size: data.byteLength, mime, hash, blobKey }, opts.onConflict ?? "fail");
656
+ const { parent, name } = await parentAndName(tx, w, ctx2, path, opts.parents ?? false);
657
+ const result = await insert(tx, w, ctx2, parent, name, { kind: "file", size: data.byteLength, mime, hash, blobKey }, opts.onConflict ?? "fail");
582
658
  await w.apply(tx);
583
659
  return result;
584
660
  });
@@ -592,10 +668,10 @@ function createDrive(deps) {
592
668
  /** A reference points at a document in another app, such as `docs:<id>`. */
593
669
  async putRef(ctx, path, ref, opts = {}) {
594
670
  if (!REF.test(ref)) throw new DriveError("INVALID_REF", `a reference looks like "docs:<id>", got ${JSON.stringify(ref)}`);
595
- return transact(async (tx) => {
671
+ return transactAs(ctx, async (tx, ctx2) => {
596
672
  const w = batch();
597
- const { parent, name } = await parentAndName(tx, w, ctx, path, opts.parents ?? false);
598
- const { node } = await insert(tx, w, ctx, parent, name, { kind: "ref", ref }, opts.onConflict === "replace" ? "fail" : opts.onConflict ?? "fail");
673
+ const { parent, name } = await parentAndName(tx, w, ctx2, path, opts.parents ?? false);
674
+ const { node } = await insert(tx, w, ctx2, parent, name, { kind: "ref", ref }, opts.onConflict === "replace" ? "fail" : opts.onConflict ?? "fail");
599
675
  await w.apply(tx);
600
676
  return node;
601
677
  });
@@ -629,35 +705,35 @@ function createDrive(deps) {
629
705
  async move(ctx, src, destLoc, opts = {}) {
630
706
  const probe = sharing ? await resolve(store, ctx, src) : null;
631
707
  const height = probe ? await heightFor(ctx, probe, destLoc) : 0;
632
- return transact(async (tx) => {
708
+ return transactAs(ctx, async (tx, ctx2) => {
633
709
  const w = batch();
634
- const moved = await relocate(tx, w, ctx, await resolve(tx, ctx, src), destLoc, { ...opts, height });
710
+ const moved = await relocate(tx, w, ctx2, await resolve(tx, ctx2, src), destLoc, { ...opts, height });
635
711
  await w.apply(tx);
636
712
  return moved;
637
713
  });
638
714
  },
639
715
  async rename(ctx, loc, name) {
640
- return transact(async (tx) => {
716
+ return transactAs(ctx, async (tx, ctx2) => {
641
717
  const w = batch();
642
- const node = await resolve(tx, ctx, loc);
718
+ const node = await resolve(tx, ctx2, loc);
643
719
  if (node.id === ROOT_ID) throw new DriveError("IS_ROOT", "cannot change the root");
644
- const parent = await rawNode(tx, node.parentId, node.owner, ctx.actor);
720
+ const parent = await rawNode(tx, node.parentId, node.owner, ctx2.actor);
645
721
  if (!parent) throw new DriveError("NOT_FOUND", `the folder of ${node.name} is missing`);
646
- const renamed = await relocate(tx, w, ctx, node, parent, { name });
722
+ const renamed = await relocate(tx, w, ctx2, node, parent, { name });
647
723
  await w.apply(tx);
648
724
  return renamed;
649
725
  });
650
726
  },
651
727
  /** Hides an item and, for a folder, everything under it. Nothing else changes. */
652
728
  async trash(ctx, loc) {
653
- return transact(async (tx) => {
654
- const node = await resolve(tx, ctx, loc);
729
+ return transactAs(ctx, async (tx, ctx2) => {
730
+ const node = await resolve(tx, ctx2, loc);
655
731
  if (node.id === ROOT_ID) throw new DriveError("IS_ROOT", "cannot trash the root");
656
- await need(ctx, "write", node);
732
+ await need(ctx2, "write", node);
657
733
  const claim = await claimId(node.owner, node.parentId, node.nameKey);
658
734
  const held = await tx.get(NAMES, claim);
659
735
  const t = now();
660
- const trashed = { ...node, trashed: true, trashedAt: t, updatedAt: t, ...stamp(ctx, node) };
736
+ const trashed = { ...node, trashed: true, trashedAt: t, updatedAt: t, ...stamp(ctx2, node) };
661
737
  await tx.put(NODES, node.id, trashed);
662
738
  if (held?.nodeId === node.id) await tx.delete(NAMES, claim);
663
739
  return trashed;
@@ -671,6 +747,15 @@ function createDrive(deps) {
671
747
  for (const { data } of page.items) if (data.purging !== true) out.push(data);
672
748
  return out.sort((a, b) => (b.trashedAt ?? 0) - (a.trashedAt ?? 0));
673
749
  },
750
+ /** Everything that is in the trash, newest first, each with the path it had before. It reads the whole drive, so a big drive costs more than `listTrash`. */
751
+ async listTrashWithPaths(ctx) {
752
+ const all = await ownerNodes(ctx);
753
+ const byId = new Map(all.map((n) => [n.id, n]));
754
+ return all.filter((n) => n.trashed).sort((a, b) => (b.trashedAt ?? 0) - (a.trashedAt ?? 0)).flatMap((node) => {
755
+ const path = pathIn(byId, node, false);
756
+ return path === null ? [] : [{ node, path }];
757
+ });
758
+ },
674
759
  /** Puts a trashed item back. It needs a live parent folder. */
675
760
  async restore(ctx, id, opts = {}) {
676
761
  let height = 0;
@@ -679,20 +764,20 @@ function createDrive(deps) {
679
764
  const target = await rawNode(store, opts.parentId, ctx.owner, ctx.actor);
680
765
  if (probe?.trashed && target && await can(ctx, "read", target)) height = await heightFor(ctx, probe, target);
681
766
  }
682
- return transact(async (tx) => {
683
- const node = await fetch(tx, ctx, id);
767
+ return transactAs(ctx, async (tx, ctx2) => {
768
+ const node = await fetch(tx, ctx2, id);
684
769
  if (!node || node.id === ROOT_ID) throw new DriveError("NOT_FOUND", `no item ${id}`);
685
770
  if (!node.trashed) throw new DriveError("NOT_TRASHED", `${node.name} is not in the trash`);
686
- await need(ctx, "write", node);
687
- const parent = opts.parentId === void 0 ? await rawNode(tx, node.parentId, node.owner, ctx.actor) : await rawNode(tx, opts.parentId, ctx.owner, ctx.actor);
688
- const visible = parent !== null && (opts.parentId === void 0 || await can(ctx, "read", parent));
689
- if (!parent || !visible || parent.kind !== "folder" || !await isLive(tx, ctx, parent)) {
771
+ await need(ctx2, "write", node);
772
+ const parent = opts.parentId === void 0 ? await rawNode(tx, node.parentId, node.owner, ctx2.actor) : await rawNode(tx, opts.parentId, ctx2.owner, ctx2.actor);
773
+ const visible = parent !== null && (opts.parentId === void 0 || await can(ctx2, "read", parent));
774
+ if (!parent || !visible || parent.kind !== "folder" || !await isLive(tx, ctx2, parent)) {
690
775
  throw new DriveError("PARENT_GONE", `the folder of ${node.name} is gone or in the trash`);
691
776
  }
692
777
  if (parent.owner !== node.owner) throw new DriveError("CROSS_DRIVE", "an item cannot move into another person's drive");
693
778
  if (parent.id !== node.parentId) {
694
- await need(ctx, "write", parent);
695
- await noClimb(ctx, node, parent);
779
+ await need(ctx2, "write", parent);
780
+ await noClimb(ctx2, node, parent);
696
781
  await fitsUnder(tx, batch(), parent, height);
697
782
  }
698
783
  let { name, key } = parseName(node.name);
@@ -701,7 +786,7 @@ function createDrive(deps) {
701
786
  name = await freeName(tx, parent.owner, parent.id, name);
702
787
  key = parseName(name).key;
703
788
  }
704
- const restored = { ...node, parentId: parent.id, name, nameKey: key, trashed: false, trashedAt: null, updatedAt: now(), ...stamp(ctx, node) };
789
+ const restored = { ...node, parentId: parent.id, name, nameKey: key, trashed: false, trashedAt: null, updatedAt: now(), ...stamp(ctx2, node) };
705
790
  await tx.put(NODES, restored.id, restored);
706
791
  await tx.put(NAMES, await claimId(parent.owner, parent.id, key), { nodeId: restored.id, owner: parent.owner, parentId: parent.id });
707
792
  if (sharing && parent.id !== node.parentId) await tx.put("resources", restored.id, await resourceDoc(restored, parent));
@@ -803,6 +888,58 @@ function createDrive(deps) {
803
888
  }
804
889
  return api.putRef(ctx, dir.endsWith("/") ? `${dir}${input.name}` : `${dir}/${input.name}`, input.ref, { parents: true, onConflict: "rename" });
805
890
  },
891
+ /** The live references to `ref` in the caller's own drive that the caller can read, with their paths, sorted by path. */
892
+ async findByRef(ctx, ref) {
893
+ if (!REF.test(ref)) throw new DriveError("INVALID_REF", `a reference looks like "docs:<id>", got ${JSON.stringify(ref)}`);
894
+ await need(ctx, "read", rootNode(ctx.owner, ctx.actor));
895
+ const hits = [];
896
+ const seen = /* @__PURE__ */ new Map();
897
+ const folder = async (id) => {
898
+ if (!seen.has(id)) seen.set(id, await rawNode(store, id, ctx.owner, ctx.actor));
899
+ return seen.get(id) ?? null;
900
+ };
901
+ for (const { data } of await allWhere(store, NODES, { owner: ctx.owner, ref })) {
902
+ const node = data;
903
+ if (node.kind !== "ref" || node.trashed || node.purging === true) continue;
904
+ const names = [node.name];
905
+ let up = node;
906
+ for (let depth = 0; up && up.parentId !== ROOT_ID && depth < maxDepth; depth++) {
907
+ up = await folder(up.parentId);
908
+ if (up === null || up.trashed) break;
909
+ names.push(up.name);
910
+ }
911
+ if (up === null || up.trashed || up.parentId !== ROOT_ID) continue;
912
+ if (await can(ctx, "read", node)) hits.push({ node, path: `/${names.reverse().join("/")}` });
913
+ }
914
+ return hits.sort((a, b) => a.path < b.path ? -1 : 1);
915
+ },
916
+ /**
917
+ * Puts a reference at `path` and gives `claim.subject` the role `claim.role` on it, as one step. If the
918
+ * grant fails (the caller is not an owner of the item, or the subject is invalid), the reference is
919
+ * deleted again and the error is thrown, so no half-made item stays. Needs `sharing`. Folders that
920
+ * `parents` made stay, `onConflict: "replace"` acts as `"fail"` (a rollback could not bring back a replaced item), and an invalid role or subject is refused before anything is written.
921
+ */
922
+ async claimRef(ctx, path, ref, claim, opts = {}) {
923
+ if (!sharing) throw new DriveError("INVALID_SHARE", "sharing is not set up");
924
+ if (!ctx.principal) throw new DriveError("FORBIDDEN", "sign in to share");
925
+ const subject = parseSubject(claim.subject);
926
+ if (!ROLES.includes(claim.role)) throw new DriveError("INVALID_SHARE", `a role is one of ${ROLES.join(", ")}`);
927
+ const node = await api.putRef(ctx, path, ref, opts);
928
+ try {
929
+ await viaSharing(ctx, (s, p) => s.grant(p, node.id, subject, claim.role));
930
+ } catch (error) {
931
+ let removed = 0;
932
+ let gone = false;
933
+ for (let i = 0; i < 3 && removed === 0 && !gone; i++) {
934
+ const current = await rawNode(store, node.id, node.owner, ctx.actor, true);
935
+ if (current === null) gone = true;
936
+ else removed = await removeNodes([current], void 0, ctx.principal).catch(() => 0);
937
+ }
938
+ if (removed === 0 && !gone) console.error(`claimRef could not remove ${node.id} after a failed share. Purge it.`);
939
+ throw error;
940
+ }
941
+ return node;
942
+ },
806
943
  /** An app deleted a document. Its references in this drive go to the trash. Returns how many. */
807
944
  async forgetDocument(ctx, ref) {
808
945
  if (!REF.test(ref)) throw new DriveError("INVALID_REF", `a reference looks like "docs:<id>", got ${JSON.stringify(ref)}`);
@@ -992,167 +1129,6 @@ function createDrive(deps) {
992
1129
  return Object.fromEntries(Object.entries(api).map(([name, call]) => [name, open.has(name) ? call : guarded(call)]));
993
1130
  }
994
1131
 
995
- // src/adapters/fs.ts
996
- import { createHash } from "crypto";
997
- import { appendFileSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, truncateSync, writeFileSync, writeSync } from "fs";
998
- import { join } from "path";
999
- import { assertBlobPutOptions, assertResponseHeaders, createMemoryStore, parseRegion } from "agentic-identity";
1000
- function createFileStore(dir, options = {}) {
1001
- mkdirSync(dir, { recursive: true });
1002
- const file = join(dir, "store.jsonl");
1003
- const inner = createMemoryStore(options);
1004
- const ready = (async () => {
1005
- if (!existsSync(file)) return;
1006
- const text = readFileSync(file, "utf8");
1007
- const lines = text.split("\n");
1008
- const tail = lines.pop();
1009
- const apply = async (changes2) => {
1010
- for (const [collection, id, doc] of changes2) await (doc ? inner.put(collection, id, doc) : inner.delete(collection, id));
1011
- };
1012
- for (const [i, line] of lines.entries()) {
1013
- if (line === "") continue;
1014
- try {
1015
- await apply(JSON.parse(line));
1016
- } catch {
1017
- throw new Error(`the journal ${file} has a bad line ${i + 1}`);
1018
- }
1019
- }
1020
- if (tail === "") return;
1021
- let changes;
1022
- try {
1023
- changes = JSON.parse(tail);
1024
- } catch {
1025
- truncateSync(file, Buffer.byteLength(text) - Buffer.byteLength(tail));
1026
- return;
1027
- }
1028
- try {
1029
- await apply(changes);
1030
- } catch {
1031
- throw new Error(`the journal ${file} has a bad last line`);
1032
- }
1033
- appendFileSync(file, "\n");
1034
- })();
1035
- let failed;
1036
- const keep = (changes) => {
1037
- if (changes.length === 0) return;
1038
- try {
1039
- const fd = openSync(file, "a");
1040
- try {
1041
- const line = Buffer.from(`${JSON.stringify(changes)}
1042
- `);
1043
- for (let done = 0; done < line.length; ) {
1044
- const wrote = writeSync(fd, line, done);
1045
- if (wrote <= 0) throw new Error("the journal write stopped short");
1046
- done += wrote;
1047
- }
1048
- fsyncSync(fd);
1049
- } finally {
1050
- closeSync(fd);
1051
- }
1052
- } catch (error) {
1053
- failed = error;
1054
- throw error;
1055
- }
1056
- };
1057
- const open = async () => {
1058
- await ready;
1059
- if (failed !== void 0) throw new Error("the journal could not be written, so the store is closed. Open it again.");
1060
- };
1061
- const recording = (ops, changes) => ({
1062
- ...ops,
1063
- put: async (collection, id, doc) => {
1064
- await ops.put(collection, id, doc);
1065
- changes.push([collection, id, doc]);
1066
- },
1067
- delete: async (collection, id) => {
1068
- await ops.delete(collection, id);
1069
- changes.push([collection, id, null]);
1070
- }
1071
- });
1072
- return {
1073
- region: inner.region,
1074
- get: async (c, id) => (await open(), inner.get(c, id)),
1075
- query: async (c, q) => (await open(), inner.query(c, q)),
1076
- async put(collection, id, doc) {
1077
- await open();
1078
- await inner.put(collection, id, doc);
1079
- keep([[collection, id, doc]]);
1080
- },
1081
- async delete(collection, id) {
1082
- await open();
1083
- await inner.delete(collection, id);
1084
- keep([[collection, id, null]]);
1085
- },
1086
- async transaction(fn) {
1087
- await open();
1088
- let changes = [];
1089
- const result = await inner.transaction((tx) => {
1090
- changes = [];
1091
- return fn(recording(tx, changes));
1092
- });
1093
- keep(changes);
1094
- return result;
1095
- }
1096
- };
1097
- }
1098
- var versionOf = (data) => createHash("sha256").update(data).digest("hex");
1099
- function createFileBlobs(dir, options = {}) {
1100
- mkdirSync(dir, { recursive: true });
1101
- const path = (key) => join(dir, createHash("sha256").update(key).digest("hex"));
1102
- const need = (key) => {
1103
- if (key === "") throw new Error("blob key must not be empty");
1104
- };
1105
- const now = options.now ?? Date.now;
1106
- return {
1107
- region: options.region === void 0 ? "unknown" : parseRegion(options.region),
1108
- async put(key, data, opts) {
1109
- need(key);
1110
- assertBlobPutOptions(opts);
1111
- const current = existsSync(path(key)) ? versionOf(readFileSync(path(key))) : void 0;
1112
- if (opts?.ifNoneMatch === "*" && current !== void 0 || opts?.ifMatch !== void 0 && current !== opts.ifMatch) return null;
1113
- const tmp = `${path(key)}.tmp`;
1114
- writeFileSync(tmp, data);
1115
- if (opts?.contentType) writeFileSync(`${path(key)}.type`, opts.contentType);
1116
- else rmSync(`${path(key)}.type`, { force: true });
1117
- renameSync(tmp, path(key));
1118
- return { version: versionOf(data) };
1119
- },
1120
- async get(key) {
1121
- need(key);
1122
- if (!existsSync(path(key))) return null;
1123
- const type = existsSync(`${path(key)}.type`) ? readFileSync(`${path(key)}.type`, "utf8") : void 0;
1124
- const data = new Uint8Array(readFileSync(path(key)));
1125
- return { data, version: versionOf(data), ...type ? { contentType: type } : {} };
1126
- },
1127
- async head(key) {
1128
- need(key);
1129
- if (!existsSync(path(key))) return null;
1130
- const type = existsSync(`${path(key)}.type`) ? readFileSync(`${path(key)}.type`, "utf8") : void 0;
1131
- const data = readFileSync(path(key));
1132
- return { size: data.length, version: versionOf(data), ...type ? { contentType: type } : {} };
1133
- },
1134
- async delete(key) {
1135
- need(key);
1136
- rmSync(path(key), { force: true });
1137
- rmSync(`${path(key)}.type`, { force: true });
1138
- },
1139
- /** A local path with its expiry. Nothing serves or checks it: it exists so the port is whole. */
1140
- async signedUrl(key, opts) {
1141
- need(key);
1142
- const { method, expiresInSeconds } = opts;
1143
- assertResponseHeaders(opts);
1144
- if (!Number.isInteger(expiresInSeconds) || expiresInSeconds < 1 || expiresInSeconds > 604800) {
1145
- throw new RangeError("expiresInSeconds must be an integer from 1 to 604800");
1146
- }
1147
- const extra = [
1148
- opts.responseContentDisposition === void 0 ? "" : `&disposition=${encodeURIComponent(opts.responseContentDisposition)}`,
1149
- opts.responseContentType === void 0 ? "" : `&type=${encodeURIComponent(opts.responseContentType)}`
1150
- ].join("");
1151
- return `file://${path(key)}?method=${method}&expires=${now() + expiresInSeconds * 1e3}${extra}`;
1152
- }
1153
- };
1154
- }
1155
-
1156
1132
  // src/upload.ts
1157
1133
  var DEFAULT_MAX_BYTES = 25 * 1024 * 1024;
1158
1134
  var SIGNATURES = [
@@ -1353,14 +1329,93 @@ async function authenticate(request, store, now) {
1353
1329
  if (principal instanceof Response) return null;
1354
1330
  return { owner: principal.chain[0].id, actor: { id: principal.principalId, kind: principal.kind }, principal };
1355
1331
  }
1332
+ async function inviteIdentity(store, actorId) {
1333
+ const user = await store.get("users", actorId);
1334
+ const agent = await store.get("principals", actorId);
1335
+ return {
1336
+ ...user?.emailVerified === true && typeof user.email === "string" ? { email: user.email } : {},
1337
+ ...typeof agent?.handle === "string" ? { handle: agent.handle } : {}
1338
+ };
1339
+ }
1340
+
1341
+ // src/migrate.ts
1342
+ var PAGE = 10;
1343
+ var isText = (v) => typeof v === "string" && v !== "";
1344
+ async function walk(store, options, fix) {
1345
+ const { maxPages } = options;
1346
+ if (maxPages !== void 0 && !(Number.isSafeInteger(maxPages) && maxPages > 0)) {
1347
+ throw new Error("maxPages must be a positive whole number");
1348
+ }
1349
+ let fixed = 0;
1350
+ let cursor = options.cursor;
1351
+ for (let pages = 0; maxPages === void 0 || pages < maxPages; pages++) {
1352
+ const page = await store.query("drive_nodes", { limit: PAGE, cursor });
1353
+ fixed += await store.transaction(async (tx) => {
1354
+ const items = [];
1355
+ for (const item of page.items) {
1356
+ const data = await tx.get("drive_nodes", item.id);
1357
+ if (data) items.push({ id: item.id, data });
1358
+ }
1359
+ return fix(tx, items);
1360
+ });
1361
+ cursor = page.nextCursor;
1362
+ if (!cursor) return { fixed };
1363
+ }
1364
+ return { fixed, nextCursor: cursor };
1365
+ }
1366
+ function backfillAuthorIds(store, options = {}) {
1367
+ return walk(store, options, async (tx, items) => {
1368
+ let count = 0;
1369
+ for (const { id, data } of items) {
1370
+ const created = data.createdBy?.id;
1371
+ const updated = data.updatedBy?.id;
1372
+ if (!isText(created) || !isText(updated)) {
1373
+ throw new Error(`item ${id} has no author. Fix it before you run the backfill again.`);
1374
+ }
1375
+ if (data.createdById === created && data.updatedById === updated) continue;
1376
+ await tx.put("drive_nodes", id, { ...data, createdById: created, updatedById: updated });
1377
+ count++;
1378
+ }
1379
+ return count;
1380
+ });
1381
+ }
1382
+ function backfillResources(store, options = {}) {
1383
+ return walk(store, options, async (tx, items) => {
1384
+ const missing = [];
1385
+ for (const { id, data } of items) {
1386
+ if (!isText(data.owner) || !isText(data.parentId)) {
1387
+ throw new Error(`item ${id} has no owner or parent. Fix it before you run the backfill again.`);
1388
+ }
1389
+ if (await tx.get("resources", id)) continue;
1390
+ missing.push({ id, parentId: data.parentId === ROOT_ID ? await rootResource(data.owner) : data.parentId });
1391
+ }
1392
+ for (const { id, parentId } of missing) await tx.put("resources", id, { id, type: "drive", parentId });
1393
+ return missing.length;
1394
+ });
1395
+ }
1356
1396
 
1357
1397
  // src/local.ts
1358
- import { createHash as createHash2 } from "crypto";
1359
- import { join as join2 } from "path";
1360
- import { isMachineMade, isScope, issueCredential, revokeCredential } from "agentic-identity";
1361
- function openLocal(dir) {
1362
- const store = createFileStore(join2(dir, "db"));
1363
- return { store, drive: createDrive({ store, blobs: createFileBlobs(join2(dir, "blobs")) }) };
1398
+ import { createHash } from "crypto";
1399
+ import { existsSync } from "fs";
1400
+ import { join } from "path";
1401
+ import { createSharing, isMachineMade, isScope, issueCredential, revokeCredential } from "agentic-identity";
1402
+ import { createFileBlobs, createFileStore } from "agentic-identity/file";
1403
+ function openLocal(dir, opts = {}) {
1404
+ if (!opts.beforeImport && existsSync(join(dir, "db", "store.jsonl")) && !existsSync(join(dir, "store.json"))) {
1405
+ throw new Error(`${dir} holds data from an older version. Run importLegacyLocal(dir, store, blobs) first: open it with openLocal(dir, { beforeImport: true }).`);
1406
+ }
1407
+ const store = createFileStore({ path: join(dir, "store.json") });
1408
+ const blobs = createFileBlobs({ dir: join(dir, "blobs") });
1409
+ return { store, blobs, drive: createDrive({ store, blobs, sharing: createSharing(store) }) };
1410
+ }
1411
+ async function ensureSharing(store) {
1412
+ if (await store.get("drive_meta", "sharing")) return;
1413
+ try {
1414
+ await backfillResources(store);
1415
+ } catch (error) {
1416
+ throw new Error(`the one-time sharing backfill of local items failed: ${error instanceof Error ? error.message : String(error)}`);
1417
+ }
1418
+ await store.put("drive_meta", "sharing", { backfilledAt: Date.now() });
1364
1419
  }
1365
1420
  var AuthError = class extends Error {
1366
1421
  code = "UNAUTHENTICATED";
@@ -1402,7 +1457,7 @@ async function issueKey(store, principalId, label) {
1402
1457
  return issueCredential(store, { principalId, kind: "api_key", ...label ? { label } : {} });
1403
1458
  }
1404
1459
  async function revokeKey(store, token) {
1405
- if (!await store.get("credentials", createHash2("sha256").update(token).digest("hex"))) throw new DriveError("NOT_FOUND", "no such key");
1460
+ if (!await store.get("credentials", createHash("sha256").update(token).digest("hex"))) throw new DriveError("NOT_FOUND", "no such key");
1406
1461
  await revokeCredential(store, token, Date.now());
1407
1462
  }
1408
1463
  function publicNode(node, path) {
@@ -1414,16 +1469,14 @@ function publicNode(node, path) {
1414
1469
  export {
1415
1470
  ROOT_ID,
1416
1471
  ACTOR_KINDS,
1472
+ ERROR_STATUS,
1417
1473
  DriveError,
1418
1474
  DEFAULT_URL_SECONDS,
1419
1475
  safeMime,
1420
1476
  parseName,
1421
- rootResource,
1422
1477
  subjectKindOf,
1423
1478
  parseSubject,
1424
1479
  createDrive,
1425
- createFileStore,
1426
- createFileBlobs,
1427
1480
  DEFAULT_MAX_BYTES,
1428
1481
  sniffMime,
1429
1482
  upload,
@@ -1431,7 +1484,11 @@ export {
1431
1484
  readZip,
1432
1485
  buildExport,
1433
1486
  authenticate,
1487
+ inviteIdentity,
1488
+ backfillAuthorIds,
1489
+ backfillResources,
1434
1490
  openLocal,
1491
+ ensureSharing,
1435
1492
  AuthError,
1436
1493
  NO_TOKEN,
1437
1494
  resolveToken,