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.
- package/CHANGELOG.md +32 -0
- package/README.md +13 -11
- package/dist/{chunk-H6JK62SP.js → chunk-6UQ6CDLJ.js} +1 -1
- package/dist/chunk-AB2RTTHJ.js +59 -0
- package/dist/{chunk-S4AGCKXW.js → chunk-LELTSSZF.js} +6 -0
- package/dist/{chunk-EAKQK7ST.js → chunk-R2DUVOJT.js} +260 -203
- package/dist/cli.js +99 -7
- package/dist/client.d.ts +15 -2
- package/dist/client.js +1 -1
- package/dist/{drive-BtRyexUP.d.ts → drive-Dxy0gL0_.d.ts} +26 -1
- package/dist/embed.css +42 -42
- package/dist/embed.js +3 -3
- package/dist/index.d.ts +17 -20
- package/dist/index.js +10 -10
- package/dist/mcp.d.ts +4 -3
- package/dist/mcp.js +56 -5
- package/dist/worker.d.ts +2 -1
- package/dist/worker.js +21 -36
- package/package.json +8 -3
- package/ui-dist/assets/index-GxwjdcYH.css +1 -0
- package/ui-dist/assets/index-mHs8RJgV.js +49 -0
- package/ui-dist/index.html +2 -2
- package/dist/chunk-5Z3SFBUK.js +0 -65
- package/ui-dist/assets/index-B9nizMSQ.css +0 -1
- package/ui-dist/assets/index-DQzn7NQP.js +0 -49
|
@@ -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
|
|
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
|
|
629
|
+
return transactAs(ctx, async (tx, ctx2) => {
|
|
554
630
|
const w = batch();
|
|
555
|
-
const { parent, name } = await parentAndName(tx, w,
|
|
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(
|
|
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,
|
|
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
|
|
654
|
+
committed = await transactAs(ctx, async (tx, ctx2) => {
|
|
579
655
|
const w = batch();
|
|
580
|
-
const { parent, name } = await parentAndName(tx, w,
|
|
581
|
-
const result = await insert(tx, w,
|
|
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
|
|
671
|
+
return transactAs(ctx, async (tx, ctx2) => {
|
|
596
672
|
const w = batch();
|
|
597
|
-
const { parent, name } = await parentAndName(tx, w,
|
|
598
|
-
const { node } = await insert(tx, w,
|
|
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
|
|
708
|
+
return transactAs(ctx, async (tx, ctx2) => {
|
|
633
709
|
const w = batch();
|
|
634
|
-
const moved = await relocate(tx, w,
|
|
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
|
|
716
|
+
return transactAs(ctx, async (tx, ctx2) => {
|
|
641
717
|
const w = batch();
|
|
642
|
-
const node = await resolve(tx,
|
|
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,
|
|
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,
|
|
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
|
|
654
|
-
const node = await resolve(tx,
|
|
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(
|
|
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(
|
|
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
|
|
683
|
-
const node = await fetch(tx,
|
|
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(
|
|
687
|
-
const parent = opts.parentId === void 0 ? await rawNode(tx, node.parentId, node.owner,
|
|
688
|
-
const visible = parent !== null && (opts.parentId === void 0 || await can(
|
|
689
|
-
if (!parent || !visible || parent.kind !== "folder" || !await isLive(tx,
|
|
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(
|
|
695
|
-
await noClimb(
|
|
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(
|
|
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
|
|
1359
|
-
import {
|
|
1360
|
-
import {
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
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",
|
|
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,
|