agentic-drive 0.1.0 → 0.3.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 +143 -0
- package/README.md +59 -12
- package/SKILL.md +12 -1
- package/dist/chunk-5KEISBS4.js +65 -0
- package/dist/{chunk-4QE7E5UQ.js → chunk-GMA5I5L6.js} +1 -1
- package/dist/{chunk-QRGKXPLX.js → chunk-VSVIJBMJ.js} +522 -75
- package/dist/cli.js +22 -5
- package/dist/client.d.ts +173 -0
- package/dist/client.js +240 -0
- package/dist/drive-Dlzwukjq.d.ts +334 -0
- package/dist/index.d.ts +12 -4
- package/dist/index.js +12 -2
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +20 -3
- package/dist/migrate-B79gRPMu.d.ts +29 -0
- package/dist/worker.d.ts +10 -10
- package/dist/worker.js +115 -38
- package/llms.txt +1 -0
- package/package.json +8 -2
- package/dist/drive-DRQ8LiHl.d.ts +0 -144
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// src/drive.ts
|
|
2
|
+
import { EraseRefusedError, MAX_FOLDER_DEPTH, SUBJECT_KINDS } from "agentic-identity";
|
|
2
3
|
var ROOT_ID = "root";
|
|
3
4
|
var NODES = "drive_nodes";
|
|
4
5
|
var NAMES = "drive_names";
|
|
@@ -14,6 +15,8 @@ var DriveError = class extends Error {
|
|
|
14
15
|
var MAX_PATH_SEGMENTS = 64;
|
|
15
16
|
var DEFAULT_MAX_DEPTH = 256;
|
|
16
17
|
var CHUNK = 25;
|
|
18
|
+
var SHARED_CHUNK = 10;
|
|
19
|
+
var HEIGHT_BUDGET = 200;
|
|
17
20
|
var DEFAULT_URL_SECONDS = 900;
|
|
18
21
|
var MAX_URL_SECONDS = 3600;
|
|
19
22
|
var OPAQUE = "application/octet-stream";
|
|
@@ -59,8 +62,10 @@ function splitPath(path) {
|
|
|
59
62
|
function batch() {
|
|
60
63
|
const writes = [];
|
|
61
64
|
const fresh = /* @__PURE__ */ new Set();
|
|
65
|
+
const depths = /* @__PURE__ */ new Map();
|
|
62
66
|
return {
|
|
63
67
|
fresh,
|
|
68
|
+
depths,
|
|
64
69
|
put: (collection, id, doc) => void writes.push({ collection, id, doc }),
|
|
65
70
|
remove: (collection, id) => void writes.push({ collection, id, doc: null }),
|
|
66
71
|
async apply(ops) {
|
|
@@ -69,16 +74,36 @@ function batch() {
|
|
|
69
74
|
};
|
|
70
75
|
}
|
|
71
76
|
var claimId = (owner, parentId, key) => sha256Hex(new TextEncoder().encode(`${owner}\0${parentId}\0${key}`));
|
|
77
|
+
var rootResource = async (owner) => `drive-root-${(await sha256Hex(new TextEncoder().encode(owner))).slice(0, 32)}`;
|
|
78
|
+
var ACTION_TO_IDENTITY = { read: "read", write: "write", delete: "admin" };
|
|
79
|
+
var MAX_ITEM_DEPTH = MAX_FOLDER_DEPTH - 1;
|
|
80
|
+
var VISITOR = { id: "", kind: "human" };
|
|
81
|
+
var appOf = (node) => node.kind === "ref" && node.ref ? node.ref.split(":")[0] : null;
|
|
82
|
+
var ERASED = "erased";
|
|
83
|
+
function subjectKindOf(kind) {
|
|
84
|
+
if (kind === "human" || kind === "agent") return kind;
|
|
85
|
+
throw new DriveError("INVALID_SHARE", `a ${typeof kind === "string" ? kind : "missing"} principal cannot hold a role. Only the root of a chain can: human or agent`);
|
|
86
|
+
}
|
|
87
|
+
function parseSubject(input) {
|
|
88
|
+
const s = input;
|
|
89
|
+
if (!s || typeof s.id !== "string" || s.id === "" || !SUBJECT_KINDS.includes(s.kind)) {
|
|
90
|
+
throw new DriveError("INVALID_SHARE", `a subject needs an id and a kind (${SUBJECT_KINDS.join(" or ")})`);
|
|
91
|
+
}
|
|
92
|
+
return { kind: s.kind, id: s.id };
|
|
93
|
+
}
|
|
94
|
+
var SHARE_ERROR = /^(invalid|give an|expiresAt|expiresInMs|resource exists|the last owner)/;
|
|
72
95
|
function createDrive(deps) {
|
|
73
96
|
const { store, blobs } = deps;
|
|
74
97
|
const now = deps.now ?? Date.now;
|
|
75
98
|
const newId = deps.newId ?? (() => globalThis.crypto.randomUUID());
|
|
76
99
|
const maxDepth = deps.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
77
100
|
const onOrphan = deps.onOrphan ?? ((key, error) => console.error(`orphan blob ${key}: ${error instanceof Error ? error.name : "delete failed"}`));
|
|
78
|
-
const
|
|
79
|
-
const
|
|
101
|
+
const sharing = deps.sharing;
|
|
102
|
+
const resourceOf = async (node) => node.id === ROOT_ID ? rootResource(node.owner) : node.id;
|
|
103
|
+
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);
|
|
104
|
+
const rootNode = (owner, by) => ({
|
|
80
105
|
id: ROOT_ID,
|
|
81
|
-
owner
|
|
106
|
+
owner,
|
|
82
107
|
parentId: "",
|
|
83
108
|
kind: "folder",
|
|
84
109
|
name: "",
|
|
@@ -90,16 +115,24 @@ function createDrive(deps) {
|
|
|
90
115
|
blobKey: null,
|
|
91
116
|
createdAt: 0,
|
|
92
117
|
updatedAt: 0,
|
|
93
|
-
createdBy:
|
|
94
|
-
updatedBy:
|
|
118
|
+
createdBy: by,
|
|
119
|
+
updatedBy: by,
|
|
120
|
+
createdById: by.id,
|
|
121
|
+
updatedById: by.id,
|
|
95
122
|
trashed: false,
|
|
96
123
|
trashedAt: null,
|
|
97
124
|
purging: false
|
|
98
125
|
});
|
|
99
126
|
const who = (ctx) => ({ id: ctx.actor.id, kind: ctx.actor.kind });
|
|
127
|
+
const stamp = (ctx, node) => ({ createdById: node.createdById ?? node.createdBy.id, updatedBy: who(ctx), updatedById: ctx.actor.id });
|
|
128
|
+
const resourceDoc = async (node, parent) => ({ id: node.id, type: "drive", parentId: await resourceOf(parent) });
|
|
129
|
+
async function rawNode(ops, id, owner, by, withMarked = false) {
|
|
130
|
+
const node = id === ROOT_ID ? rootNode(owner, by) : await ops.get(NODES, id);
|
|
131
|
+
return node && (withMarked || node.purging !== true) ? node : null;
|
|
132
|
+
}
|
|
100
133
|
async function fetch(ops, ctx, id, purgeAgain = false) {
|
|
101
|
-
const node =
|
|
102
|
-
return node &&
|
|
134
|
+
const node = await rawNode(ops, id, ctx.owner, ctx.actor, purgeAgain);
|
|
135
|
+
return node && await can(ctx, "read", node) ? node : null;
|
|
103
136
|
}
|
|
104
137
|
async function need(ctx, action, node) {
|
|
105
138
|
if (!await can(ctx, action, node)) throw new DriveError("FORBIDDEN", `not allowed to ${action} ${node.id}`);
|
|
@@ -119,45 +152,106 @@ function createDrive(deps) {
|
|
|
119
152
|
const node = found && !found.trashed && found.purging !== true && found.parentId === parentId && found.nameKey === key ? found : null;
|
|
120
153
|
return { taken: claim !== null, node };
|
|
121
154
|
}
|
|
122
|
-
const liveChild = async (ops,
|
|
155
|
+
const liveChild = async (ops, folder, key) => (await claimed(ops, folder.owner, folder.id, key)).node;
|
|
123
156
|
async function isLive(ops, ctx, node) {
|
|
124
157
|
for (let n = node, depth = 0; n && depth < maxDepth; depth++) {
|
|
125
158
|
if (n.trashed) return false;
|
|
126
159
|
if (n.id === ROOT_ID) return true;
|
|
127
|
-
n = await
|
|
160
|
+
n = await rawNode(ops, n.parentId, n.owner, ctx.actor);
|
|
128
161
|
}
|
|
129
162
|
return false;
|
|
130
163
|
}
|
|
164
|
+
async function depthOf(ops, w, node) {
|
|
165
|
+
let depth = 0;
|
|
166
|
+
for (let n = node; n && n.id !== ROOT_ID; ) {
|
|
167
|
+
const known = w.depths.get(n.id);
|
|
168
|
+
if (known !== void 0) return depth + known;
|
|
169
|
+
if (++depth > MAX_ITEM_DEPTH) return depth;
|
|
170
|
+
n = await rawNode(ops, n.parentId, n.owner, node.createdBy);
|
|
171
|
+
if (n === null) return MAX_ITEM_DEPTH + 1;
|
|
172
|
+
}
|
|
173
|
+
return depth;
|
|
174
|
+
}
|
|
175
|
+
async function heightOf(ops, folder) {
|
|
176
|
+
let level = [folder];
|
|
177
|
+
let queries = 0;
|
|
178
|
+
for (let height = 1; height <= MAX_ITEM_DEPTH; height++) {
|
|
179
|
+
const next = [];
|
|
180
|
+
for (const f of level) {
|
|
181
|
+
for (let cursor; ; ) {
|
|
182
|
+
if (++queries > HEIGHT_BUDGET) throw new DriveError("TOO_MANY_ITEMS", `${folder.name} has too many folders to count. Move its parts one by one.`);
|
|
183
|
+
const page = await ops.query(NODES, { where: { owner: f.owner, parentId: f.id, kind: "folder" }, limit: 100, cursor });
|
|
184
|
+
next.push(...page.items.map((item) => item.data));
|
|
185
|
+
if (!page.nextCursor) break;
|
|
186
|
+
cursor = page.nextCursor;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (next.length === 0) return height;
|
|
190
|
+
level = next;
|
|
191
|
+
}
|
|
192
|
+
return MAX_ITEM_DEPTH + 1;
|
|
193
|
+
}
|
|
194
|
+
async function heightFor(ctx, node, dest) {
|
|
195
|
+
if (!sharing || node.kind !== "folder") return 0;
|
|
196
|
+
const target = typeof dest === "string" ? await resolve(store, ctx, dest) : dest;
|
|
197
|
+
if (target.id === node.parentId) return 0;
|
|
198
|
+
const w = batch();
|
|
199
|
+
if (await depthOf(store, w, target) + 1 <= await depthOf(store, w, node)) return 0;
|
|
200
|
+
return heightOf(store, node);
|
|
201
|
+
}
|
|
202
|
+
async function noClimb(ctx, node, dest) {
|
|
203
|
+
const person = ctx.principal ? { ...ctx, principal: { ...ctx.principal, scopes: ["drive:admin"] } } : ctx;
|
|
204
|
+
if (await can(person, "delete", dest) && !await can(person, "delete", node)) {
|
|
205
|
+
throw new DriveError("FORBIDDEN", "moving into a folder you own needs owner on the item");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async function fitsUnder(ops, w, dest, height) {
|
|
209
|
+
if (sharing && await depthOf(ops, w, dest) + 1 + height > MAX_ITEM_DEPTH) {
|
|
210
|
+
throw new DriveError("TOO_DEEP", `an item can sit at most ${MAX_ITEM_DEPTH} folders deep`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
async function startOf(ops, ctx, loc) {
|
|
214
|
+
if (loc.startsWith("/")) {
|
|
215
|
+
const start = rootNode(ctx.owner, ctx.actor);
|
|
216
|
+
if (!await can(ctx, "read", start)) throw new DriveError("NOT_FOUND", `no item ${loc}`);
|
|
217
|
+
return { start, segs: splitPath(loc) };
|
|
218
|
+
}
|
|
219
|
+
const slash = loc.indexOf("/");
|
|
220
|
+
const base = await resolve(ops, ctx, loc.slice(0, slash));
|
|
221
|
+
if (base.kind !== "folder") throw new DriveError("NOT_A_FOLDER", `${base.name} is not a folder`);
|
|
222
|
+
return { start: base, segs: splitPath(`/${loc.slice(slash + 1)}`) };
|
|
223
|
+
}
|
|
131
224
|
async function resolve(ops, ctx, loc) {
|
|
132
|
-
if (!loc.
|
|
225
|
+
if (!loc.includes("/")) {
|
|
133
226
|
const node2 = await fetch(ops, ctx, loc);
|
|
134
227
|
if (!node2) throw new DriveError("NOT_FOUND", `no item ${loc}`);
|
|
135
228
|
if (!await isLive(ops, ctx, node2)) throw new DriveError("TRASHED", `${loc} is in the trash`);
|
|
136
229
|
return node2;
|
|
137
230
|
}
|
|
138
|
-
|
|
139
|
-
|
|
231
|
+
const { start, segs } = await startOf(ops, ctx, loc);
|
|
232
|
+
let node = start;
|
|
233
|
+
for (const seg of segs) {
|
|
140
234
|
if (node.kind !== "folder") throw new DriveError("NOT_A_FOLDER", `${node.name} is not a folder`);
|
|
141
|
-
const next = await liveChild(ops,
|
|
235
|
+
const next = await liveChild(ops, node, seg.key);
|
|
142
236
|
if (!next) throw new DriveError("NOT_FOUND", `no item ${loc}`);
|
|
143
237
|
node = next;
|
|
144
238
|
}
|
|
145
239
|
if (!await can(ctx, "read", node)) throw new DriveError("NOT_FOUND", `no item ${loc}`);
|
|
146
240
|
return node;
|
|
147
241
|
}
|
|
148
|
-
async function freeName(ops,
|
|
242
|
+
async function freeName(ops, owner, parentId, name) {
|
|
149
243
|
const dot = name.lastIndexOf(".");
|
|
150
244
|
const [base, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, ""];
|
|
151
245
|
for (let n = 2; ; n++) {
|
|
152
246
|
const candidate = `${base} (${n})${ext}`;
|
|
153
|
-
if (!(await claimed(ops,
|
|
247
|
+
if (!(await claimed(ops, owner, parentId, parseName(candidate).key)).taken) return candidate;
|
|
154
248
|
}
|
|
155
249
|
}
|
|
156
|
-
async function folderAt(ops, w, ctx, segs, create) {
|
|
157
|
-
let node =
|
|
250
|
+
async function folderAt(ops, w, ctx, start, segs, create) {
|
|
251
|
+
let node = start;
|
|
158
252
|
for (const seg of segs) {
|
|
159
253
|
if (node.kind !== "folder") throw new DriveError("NOT_A_FOLDER", `${node.name} is not a folder`);
|
|
160
|
-
let next = w.fresh.has(node.id) ? null : await liveChild(ops,
|
|
254
|
+
let next = w.fresh.has(node.id) ? null : await liveChild(ops, node, seg.key);
|
|
161
255
|
if (!next) {
|
|
162
256
|
if (!create) throw new DriveError("NOT_FOUND", `no folder ${seg.name}`);
|
|
163
257
|
next = (await insert(ops, w, ctx, node, seg.name, { kind: "folder" }, "fail")).node;
|
|
@@ -172,21 +266,27 @@ function createDrive(deps) {
|
|
|
172
266
|
let { name, key } = parseName(rawName);
|
|
173
267
|
const empty = w.fresh.has(parent.id);
|
|
174
268
|
if (!empty) await need(ctx, "write", parent);
|
|
175
|
-
const slot = empty ? null : await claimed(ops,
|
|
269
|
+
const slot = empty ? null : await claimed(ops, parent.owner, parent.id, key);
|
|
176
270
|
if (slot?.taken) {
|
|
177
271
|
if (onConflict === "replace" && slot.node?.kind === "file" && fields.kind === "file") {
|
|
178
|
-
const node2 = { ...slot.node, ...fields, updatedAt: now(),
|
|
272
|
+
const node2 = { ...slot.node, ...fields, updatedAt: now(), ...stamp(ctx, slot.node) };
|
|
179
273
|
w.put(NODES, node2.id, node2);
|
|
180
274
|
return { node: node2, replacedBlob: slot.node.blobKey };
|
|
181
275
|
}
|
|
182
276
|
if (onConflict !== "rename") throw new DriveError("NAME_TAKEN", `"${name}" already exists`);
|
|
183
|
-
name = await freeName(ops,
|
|
277
|
+
name = await freeName(ops, parent.owner, parent.id, name);
|
|
184
278
|
key = parseName(name).key;
|
|
185
279
|
}
|
|
280
|
+
const id = newId();
|
|
281
|
+
if (sharing) {
|
|
282
|
+
const depth = await depthOf(ops, w, parent) + 1;
|
|
283
|
+
if (depth > MAX_ITEM_DEPTH) throw new DriveError("TOO_DEEP", `an item can sit at most ${MAX_ITEM_DEPTH} folders deep`);
|
|
284
|
+
w.depths.set(id, depth);
|
|
285
|
+
}
|
|
186
286
|
const t = now();
|
|
187
287
|
const node = {
|
|
188
|
-
id
|
|
189
|
-
owner:
|
|
288
|
+
id,
|
|
289
|
+
owner: parent.owner,
|
|
190
290
|
parentId: parent.id,
|
|
191
291
|
kind: "file",
|
|
192
292
|
name,
|
|
@@ -200,53 +300,62 @@ function createDrive(deps) {
|
|
|
200
300
|
updatedAt: t,
|
|
201
301
|
createdBy: who(ctx),
|
|
202
302
|
updatedBy: who(ctx),
|
|
303
|
+
createdById: ctx.actor.id,
|
|
304
|
+
updatedById: ctx.actor.id,
|
|
203
305
|
trashed: false,
|
|
204
306
|
trashedAt: null,
|
|
205
307
|
purging: false,
|
|
206
308
|
...fields
|
|
207
309
|
};
|
|
208
310
|
w.put(NODES, node.id, node);
|
|
209
|
-
w.put(NAMES, await claimId(
|
|
311
|
+
w.put(NAMES, await claimId(parent.owner, parent.id, key), { nodeId: node.id, owner: parent.owner, parentId: parent.id });
|
|
312
|
+
if (sharing) w.put("resources", node.id, await resourceDoc(node, parent));
|
|
210
313
|
return { node, replacedBlob: null };
|
|
211
314
|
}
|
|
212
315
|
async function parentAndName(ops, w, ctx, path, parents) {
|
|
213
|
-
const segs =
|
|
316
|
+
const { start, segs } = await startOf(ops, ctx, path);
|
|
214
317
|
const last = segs.pop();
|
|
215
|
-
if (!last) throw new DriveError("IS_ROOT", "the root already exists");
|
|
216
|
-
return { parent: await folderAt(ops, w, ctx, segs, parents), name: last.name };
|
|
318
|
+
if (!last) throw start.id === ROOT_ID ? new DriveError("IS_ROOT", "the root already exists") : new DriveError("INVALID_PATH", "name an item inside the folder");
|
|
319
|
+
return { parent: await folderAt(ops, w, ctx, start, segs, parents), name: last.name };
|
|
217
320
|
}
|
|
218
321
|
async function relocate(ops, w, ctx, node, destLoc, opts) {
|
|
219
322
|
if (node.id === ROOT_ID) throw new DriveError("IS_ROOT", "cannot change the root");
|
|
220
|
-
const dest = await resolve(ops, ctx, destLoc);
|
|
323
|
+
const dest = typeof destLoc === "string" ? await resolve(ops, ctx, destLoc) : destLoc;
|
|
221
324
|
if (dest.kind !== "folder") throw new DriveError("NOT_A_FOLDER", `${dest.name} is not a folder`);
|
|
325
|
+
if (dest.owner !== node.owner) throw new DriveError("CROSS_DRIVE", "an item cannot move into another person's drive");
|
|
222
326
|
for (let n = dest, depth = 0; n.id !== ROOT_ID; depth++) {
|
|
223
327
|
if (n.id === node.id) throw new DriveError("MOVE_INTO_SELF", `cannot move ${node.name} into itself`);
|
|
224
328
|
if (depth >= maxDepth) throw new DriveError("TOO_DEEP", `the destination is deeper than ${maxDepth} folders`);
|
|
225
|
-
const parent = await
|
|
329
|
+
const parent = await rawNode(ops, n.parentId, n.owner, ctx.actor);
|
|
226
330
|
if (!parent) throw new DriveError("NOT_FOUND", `a parent folder of ${dest.name} is missing`);
|
|
227
331
|
n = parent;
|
|
228
332
|
}
|
|
229
333
|
await need(ctx, "write", node);
|
|
230
|
-
|
|
334
|
+
if (dest.id !== node.parentId) {
|
|
335
|
+
await need(ctx, "write", dest);
|
|
336
|
+
await noClimb(ctx, node, dest);
|
|
337
|
+
await fitsUnder(ops, w, dest, opts.height ?? 0);
|
|
338
|
+
}
|
|
231
339
|
let { name, key } = parseName(opts.name ?? node.name);
|
|
232
|
-
const slot = await claimed(ops,
|
|
340
|
+
const slot = await claimed(ops, dest.owner, dest.id, key);
|
|
233
341
|
if (slot.taken && slot.node?.id !== node.id) {
|
|
234
342
|
if (opts.onConflict !== "rename") throw new DriveError("NAME_TAKEN", `"${name}" already exists`);
|
|
235
|
-
name = await freeName(ops,
|
|
343
|
+
name = await freeName(ops, dest.owner, dest.id, name);
|
|
236
344
|
key = parseName(name).key;
|
|
237
345
|
}
|
|
238
|
-
const moved = { ...node, parentId: dest.id, name, nameKey: key, updatedAt: now(),
|
|
346
|
+
const moved = { ...node, parentId: dest.id, name, nameKey: key, updatedAt: now(), ...stamp(ctx, node) };
|
|
239
347
|
w.put(NODES, moved.id, moved);
|
|
240
|
-
const [from, to] = [await claimId(
|
|
348
|
+
const [from, to] = [await claimId(node.owner, node.parentId, node.nameKey), await claimId(dest.owner, dest.id, key)];
|
|
241
349
|
if (from !== to) {
|
|
242
350
|
if ((await ops.get(NAMES, from))?.nodeId === node.id) w.remove(NAMES, from);
|
|
243
|
-
w.put(NAMES, to, { nodeId: moved.id, owner:
|
|
351
|
+
w.put(NAMES, to, { nodeId: moved.id, owner: dest.owner, parentId: dest.id });
|
|
244
352
|
}
|
|
353
|
+
if (sharing && dest.id !== node.parentId) w.put("resources", moved.id, await resourceDoc(moved, dest));
|
|
245
354
|
return moved;
|
|
246
355
|
}
|
|
247
|
-
async function isUnreferenced(
|
|
356
|
+
async function isUnreferenced(blobKey) {
|
|
248
357
|
try {
|
|
249
|
-
return (await store.query(NODES, { where: {
|
|
358
|
+
return (await store.query(NODES, { where: { blobKey }, limit: 1 })).items.length === 0;
|
|
250
359
|
} catch (error) {
|
|
251
360
|
onOrphan(blobKey, error);
|
|
252
361
|
return false;
|
|
@@ -259,17 +368,19 @@ function createDrive(deps) {
|
|
|
259
368
|
onOrphan(key, error);
|
|
260
369
|
}
|
|
261
370
|
}
|
|
262
|
-
async function
|
|
263
|
-
const root = rootNode(ctx);
|
|
264
|
-
if (!await can(ctx, "read", root)) throw new DriveError("FORBIDDEN", "not allowed to read this drive");
|
|
371
|
+
async function rawOwnerNodes(owner, withMarked) {
|
|
265
372
|
const out = [];
|
|
266
373
|
for (let cursor; ; ) {
|
|
267
|
-
const page = await store.query(NODES, { where: { owner
|
|
374
|
+
const page = await store.query(NODES, { where: { owner }, limit: 100, cursor });
|
|
268
375
|
out.push(...page.items.map((item) => item.data).filter((node) => withMarked || node.purging !== true));
|
|
269
376
|
if (!page.nextCursor) return out;
|
|
270
377
|
cursor = page.nextCursor;
|
|
271
378
|
}
|
|
272
379
|
}
|
|
380
|
+
async function ownerNodes(ctx, withMarked = false) {
|
|
381
|
+
if (!await can(ctx, "read", rootNode(ctx.owner, ctx.actor))) throw new DriveError("FORBIDDEN", "not allowed to read this drive");
|
|
382
|
+
return rawOwnerNodes(ctx.owner, withMarked);
|
|
383
|
+
}
|
|
273
384
|
function pathIn(byId, node, liveOnly) {
|
|
274
385
|
const names = [];
|
|
275
386
|
for (let n = node, depth = 0; depth < 1e3; depth++) {
|
|
@@ -289,12 +400,89 @@ function createDrive(deps) {
|
|
|
289
400
|
throw error;
|
|
290
401
|
}
|
|
291
402
|
}
|
|
292
|
-
|
|
403
|
+
function assertInPerson(ctx, what) {
|
|
404
|
+
if (!isInPerson(ctx) || ctx.actor.kind !== "human") throw new DriveError("FORBIDDEN", `only the owner in person can ${what}`);
|
|
405
|
+
}
|
|
406
|
+
const subjectOf = (ctx) => ({ id: ctx.owner, kind: ctx.principal?.chain[0]?.kind ?? (isInPerson(ctx) ? ctx.actor.kind : null) });
|
|
407
|
+
const heldBy = (who2, ownedAgents) => [[who2.id, subjectKindOf(who2.kind)], ...ownedAgents.map((a) => [a, subjectKindOf("agent")])];
|
|
408
|
+
async function allWhere(ops, collection, where, orderBy) {
|
|
409
|
+
const out = [];
|
|
410
|
+
for (let cursor; ; ) {
|
|
411
|
+
const page = await ops.query(collection, { where, ...orderBy ? { orderBy } : {}, limit: 100, cursor });
|
|
412
|
+
out.push(...page.items);
|
|
413
|
+
if (!page.nextCursor) return out;
|
|
414
|
+
cursor = page.nextCursor;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const isInPerson = (ctx) => ctx.principal === void 0 ? ctx.actor.kind === "human" && ctx.actor.id === ctx.owner : ctx.principal.kind === "human" && ctx.principal.chain.length === 1 && ctx.actor.id === ctx.owner;
|
|
418
|
+
const MAX_ACTORS = 1e3;
|
|
419
|
+
async function actorsOf(ops, id) {
|
|
420
|
+
const ownedAgents = (await allWhere(ops, "principals", { ownerId: id })).map((p) => p.id);
|
|
421
|
+
const actors = [id, ...ownedAgents];
|
|
422
|
+
for (let depth = 0, from = 0; from < actors.length && depth < 8; depth++) {
|
|
423
|
+
const next = actors.length;
|
|
424
|
+
for (; from < next; from++) for (const child of await allWhere(ops, "principals", { parentId: actors[from] })) if (!actors.includes(child.id)) actors.push(child.id);
|
|
425
|
+
if (actors.length > MAX_ACTORS) throw new DriveError("TOO_MANY_ITEMS", `a person can have at most ${MAX_ACTORS} agents here`);
|
|
426
|
+
}
|
|
427
|
+
return { actors, ownedAgents };
|
|
428
|
+
}
|
|
429
|
+
async function madeBy(ops, actors) {
|
|
430
|
+
const made = /* @__PURE__ */ new Map();
|
|
431
|
+
for (const actor of actors) for (const field of ["createdById", "updatedById"]) for (const { data } of await allWhere(ops, NODES, { [field]: actor })) made.set(data.id, data);
|
|
432
|
+
return [...made.values()];
|
|
433
|
+
}
|
|
434
|
+
async function eraseDrive(owner, principal) {
|
|
435
|
+
let removed = 0;
|
|
436
|
+
for (let pass = 0; pass < 5; pass++) {
|
|
437
|
+
const all = await rawOwnerNodes(owner, true);
|
|
438
|
+
if (all.length === 0) {
|
|
439
|
+
await finishResource(principal, await rootResource(owner));
|
|
440
|
+
return removed;
|
|
441
|
+
}
|
|
442
|
+
removed += await removeNodes(all, void 0, principal);
|
|
443
|
+
}
|
|
444
|
+
throw new DriveError("BUSY", "the drive kept changing while it was deleted. Run it again.");
|
|
445
|
+
}
|
|
446
|
+
async function finishResource(principal, resourceId) {
|
|
447
|
+
if (!sharing) return;
|
|
448
|
+
if (principal) {
|
|
449
|
+
try {
|
|
450
|
+
await sharing.deleteResource(principal, resourceId);
|
|
451
|
+
return;
|
|
452
|
+
} catch (error) {
|
|
453
|
+
if (!(error instanceof EraseRefusedError) && !(error instanceof Error && error.message === "forbidden")) throw error;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
await transact(async (tx) => {
|
|
457
|
+
for (const share of await sharesOf(tx, resourceId)) await tx.delete(share.collection, share.id);
|
|
458
|
+
await tx.delete("resources", resourceId);
|
|
459
|
+
});
|
|
460
|
+
for (let cursor; ; ) {
|
|
461
|
+
const page = await store.query("audit", { where: { resourceId }, limit: 100, cursor });
|
|
462
|
+
for (const entry of page.items) await store.delete("audit", entry.id);
|
|
463
|
+
if (!page.nextCursor) break;
|
|
464
|
+
cursor = page.nextCursor;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
async function sharesOf(ops, resourceId) {
|
|
468
|
+
const out = [];
|
|
469
|
+
for (const collection of ["grants", "links", "invites"]) {
|
|
470
|
+
for (let cursor; ; ) {
|
|
471
|
+
const page = await ops.query(collection, { where: { resourceId }, limit: 100, cursor });
|
|
472
|
+
out.push(...page.items.map((item) => ({ collection, id: item.id })));
|
|
473
|
+
if (!page.nextCursor) break;
|
|
474
|
+
cursor = page.nextCursor;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return out;
|
|
478
|
+
}
|
|
479
|
+
async function removeNodes(nodes, top, principal) {
|
|
293
480
|
const unchanged = (now2, seen) => now2 !== null && now2.trashed === seen.trashed && now2.parentId === seen.parentId && now2.updatedAt === seen.updatedAt;
|
|
481
|
+
const size = sharing ? SHARED_CHUNK : CHUNK;
|
|
294
482
|
const marked = [];
|
|
295
483
|
const markedIds = /* @__PURE__ */ new Set();
|
|
296
|
-
for (let i = 0; i < nodes.length; i +=
|
|
297
|
-
const slice = nodes.slice(i, i +
|
|
484
|
+
for (let i = 0; i < nodes.length; i += size) {
|
|
485
|
+
const slice = nodes.slice(i, i + size);
|
|
298
486
|
for (const node of slice) {
|
|
299
487
|
if (node.purging === true) {
|
|
300
488
|
marked.push(node);
|
|
@@ -323,9 +511,10 @@ function createDrive(deps) {
|
|
|
323
511
|
if (top && i === 0 && !markedIds.has(top.id)) throw new DriveError("BUSY", `${top.name} changed during the purge. Nothing was deleted.`);
|
|
324
512
|
}
|
|
325
513
|
const rows = marked.reverse();
|
|
326
|
-
for (let i = 0; i < rows.length; i +=
|
|
327
|
-
const chunk = rows.slice(i, i +
|
|
514
|
+
for (let i = 0; i < rows.length; i += size) {
|
|
515
|
+
const chunk = rows.slice(i, i + size);
|
|
328
516
|
for (const node of chunk) if (node.blobKey) await blobs.delete(node.blobKey);
|
|
517
|
+
for (const node of chunk) await finishResource(principal, node.id);
|
|
329
518
|
await transact(async (tx) => {
|
|
330
519
|
const held = [];
|
|
331
520
|
for (const node of chunk) {
|
|
@@ -340,6 +529,15 @@ function createDrive(deps) {
|
|
|
340
529
|
}
|
|
341
530
|
return marked.length;
|
|
342
531
|
}
|
|
532
|
+
async function openShared(principal, id, linkToken) {
|
|
533
|
+
if (!sharing) throw new DriveError("INVALID_SHARE", "sharing is not set up");
|
|
534
|
+
const node = await rawNode(store, id, "", VISITOR);
|
|
535
|
+
if (!node || node.id === ROOT_ID) throw new DriveError("NOT_FOUND", `no item ${id}`);
|
|
536
|
+
const decision = await sharing.can(principal, "read", id, linkToken === void 0 ? {} : { linkToken });
|
|
537
|
+
if (!decision.allowed) throw new DriveError("NOT_FOUND", `no item ${id}`);
|
|
538
|
+
if (!await isLive(store, { owner: node.owner, actor: node.createdBy }, node)) throw new DriveError("TRASHED", `${id} is in the trash`);
|
|
539
|
+
return node;
|
|
540
|
+
}
|
|
343
541
|
const api = {
|
|
344
542
|
async stat(ctx, loc) {
|
|
345
543
|
return resolve(store, ctx, loc);
|
|
@@ -354,8 +552,11 @@ function createDrive(deps) {
|
|
|
354
552
|
return transact(async (tx) => {
|
|
355
553
|
const w = batch();
|
|
356
554
|
const { parent, name } = await parentAndName(tx, w, ctx, path, opts.parents ?? false);
|
|
357
|
-
const existing = w.fresh.has(parent.id) ? null : await liveChild(tx,
|
|
358
|
-
if (opts.parents && existing?.kind === "folder")
|
|
555
|
+
const existing = w.fresh.has(parent.id) ? null : await liveChild(tx, parent, parseName(name).key);
|
|
556
|
+
if (opts.parents && existing?.kind === "folder") {
|
|
557
|
+
if (!await can(ctx, "read", existing)) throw new DriveError("NOT_FOUND", `no item ${path}`);
|
|
558
|
+
return existing;
|
|
559
|
+
}
|
|
359
560
|
const { node } = await insert(tx, w, ctx, parent, name, { kind: "folder" }, "fail");
|
|
360
561
|
await w.apply(tx);
|
|
361
562
|
return node;
|
|
@@ -368,7 +569,7 @@ function createDrive(deps) {
|
|
|
368
569
|
async putFile(ctx, path, data, opts = {}) {
|
|
369
570
|
const mime = opts.mime === void 0 ? null : safeMime(opts.mime);
|
|
370
571
|
const hash = await sha256Hex(data);
|
|
371
|
-
const blobKey =
|
|
572
|
+
const blobKey = `blobs/${newId()}`;
|
|
372
573
|
const stored = await blobs.put(blobKey, data, { ifNoneMatch: "*", ...mime ? { contentType: mime } : {} });
|
|
373
574
|
if (!stored) throw new DriveError("BLOB_EXISTS", "a blob with this key already exists");
|
|
374
575
|
let committed;
|
|
@@ -381,7 +582,7 @@ function createDrive(deps) {
|
|
|
381
582
|
return result;
|
|
382
583
|
});
|
|
383
584
|
} catch (error) {
|
|
384
|
-
if (await isUnreferenced(
|
|
585
|
+
if (await isUnreferenced(blobKey)) await dropBlob(blobKey);
|
|
385
586
|
throw error;
|
|
386
587
|
}
|
|
387
588
|
if (committed.replacedBlob) await dropBlob(committed.replacedBlob);
|
|
@@ -425,9 +626,11 @@ function createDrive(deps) {
|
|
|
425
626
|
});
|
|
426
627
|
},
|
|
427
628
|
async move(ctx, src, destLoc, opts = {}) {
|
|
629
|
+
const probe = sharing ? await resolve(store, ctx, src) : null;
|
|
630
|
+
const height = probe ? await heightFor(ctx, probe, destLoc) : 0;
|
|
428
631
|
return transact(async (tx) => {
|
|
429
632
|
const w = batch();
|
|
430
|
-
const moved = await relocate(tx, w, ctx, await resolve(tx, ctx, src), destLoc, opts);
|
|
633
|
+
const moved = await relocate(tx, w, ctx, await resolve(tx, ctx, src), destLoc, { ...opts, height });
|
|
431
634
|
await w.apply(tx);
|
|
432
635
|
return moved;
|
|
433
636
|
});
|
|
@@ -436,7 +639,10 @@ function createDrive(deps) {
|
|
|
436
639
|
return transact(async (tx) => {
|
|
437
640
|
const w = batch();
|
|
438
641
|
const node = await resolve(tx, ctx, loc);
|
|
439
|
-
|
|
642
|
+
if (node.id === ROOT_ID) throw new DriveError("IS_ROOT", "cannot change the root");
|
|
643
|
+
const parent = await rawNode(tx, node.parentId, node.owner, ctx.actor);
|
|
644
|
+
if (!parent) throw new DriveError("NOT_FOUND", `the folder of ${node.name} is missing`);
|
|
645
|
+
const renamed = await relocate(tx, w, ctx, node, parent, { name });
|
|
440
646
|
await w.apply(tx);
|
|
441
647
|
return renamed;
|
|
442
648
|
});
|
|
@@ -446,11 +652,11 @@ function createDrive(deps) {
|
|
|
446
652
|
return transact(async (tx) => {
|
|
447
653
|
const node = await resolve(tx, ctx, loc);
|
|
448
654
|
if (node.id === ROOT_ID) throw new DriveError("IS_ROOT", "cannot trash the root");
|
|
449
|
-
await need(ctx, "
|
|
655
|
+
await need(ctx, "write", node);
|
|
450
656
|
const claim = await claimId(node.owner, node.parentId, node.nameKey);
|
|
451
657
|
const held = await tx.get(NAMES, claim);
|
|
452
658
|
const t = now();
|
|
453
|
-
const trashed = { ...node, trashed: true, trashedAt: t, updatedAt: t,
|
|
659
|
+
const trashed = { ...node, trashed: true, trashedAt: t, updatedAt: t, ...stamp(ctx, node) };
|
|
454
660
|
await tx.put(NODES, node.id, trashed);
|
|
455
661
|
if (held?.nodeId === node.id) await tx.delete(NAMES, claim);
|
|
456
662
|
return trashed;
|
|
@@ -458,31 +664,46 @@ function createDrive(deps) {
|
|
|
458
664
|
},
|
|
459
665
|
/** Items trashed on their own, newest first. */
|
|
460
666
|
async listTrash(ctx) {
|
|
667
|
+
await need(ctx, "read", rootNode(ctx.owner, ctx.actor));
|
|
461
668
|
const page = await store.query(NODES, { where: { owner: ctx.owner, trashed: true }, limit: 1e3 });
|
|
462
669
|
const out = [];
|
|
463
|
-
for (const { data } of page.items) if (data.purging !== true
|
|
670
|
+
for (const { data } of page.items) if (data.purging !== true) out.push(data);
|
|
464
671
|
return out.sort((a, b) => (b.trashedAt ?? 0) - (a.trashedAt ?? 0));
|
|
465
672
|
},
|
|
466
673
|
/** Puts a trashed item back. It needs a live parent folder. */
|
|
467
674
|
async restore(ctx, id, opts = {}) {
|
|
675
|
+
let height = 0;
|
|
676
|
+
if (sharing && opts.parentId !== void 0) {
|
|
677
|
+
const probe = await fetch(store, ctx, id);
|
|
678
|
+
const target = await rawNode(store, opts.parentId, ctx.owner, ctx.actor);
|
|
679
|
+
if (probe?.trashed && target && await can(ctx, "read", target)) height = await heightFor(ctx, probe, target);
|
|
680
|
+
}
|
|
468
681
|
return transact(async (tx) => {
|
|
469
682
|
const node = await fetch(tx, ctx, id);
|
|
470
683
|
if (!node || node.id === ROOT_ID) throw new DriveError("NOT_FOUND", `no item ${id}`);
|
|
471
684
|
if (!node.trashed) throw new DriveError("NOT_TRASHED", `${node.name} is not in the trash`);
|
|
472
685
|
await need(ctx, "write", node);
|
|
473
|
-
const parent = await
|
|
474
|
-
|
|
686
|
+
const parent = opts.parentId === void 0 ? await rawNode(tx, node.parentId, node.owner, ctx.actor) : await rawNode(tx, opts.parentId, ctx.owner, ctx.actor);
|
|
687
|
+
const visible = parent !== null && (opts.parentId === void 0 || await can(ctx, "read", parent));
|
|
688
|
+
if (!parent || !visible || parent.kind !== "folder" || !await isLive(tx, ctx, parent)) {
|
|
475
689
|
throw new DriveError("PARENT_GONE", `the folder of ${node.name} is gone or in the trash`);
|
|
476
690
|
}
|
|
691
|
+
if (parent.owner !== node.owner) throw new DriveError("CROSS_DRIVE", "an item cannot move into another person's drive");
|
|
692
|
+
if (parent.id !== node.parentId) {
|
|
693
|
+
await need(ctx, "write", parent);
|
|
694
|
+
await noClimb(ctx, node, parent);
|
|
695
|
+
await fitsUnder(tx, batch(), parent, height);
|
|
696
|
+
}
|
|
477
697
|
let { name, key } = parseName(node.name);
|
|
478
|
-
if ((await claimed(tx,
|
|
698
|
+
if ((await claimed(tx, parent.owner, parent.id, key)).taken) {
|
|
479
699
|
if (opts.onConflict !== "rename") throw new DriveError("NAME_TAKEN", `"${name}" already exists`);
|
|
480
|
-
name = await freeName(tx,
|
|
700
|
+
name = await freeName(tx, parent.owner, parent.id, name);
|
|
481
701
|
key = parseName(name).key;
|
|
482
702
|
}
|
|
483
|
-
const restored = { ...node, parentId: parent.id, name, nameKey: key, trashed: false, trashedAt: null, updatedAt: now(),
|
|
703
|
+
const restored = { ...node, parentId: parent.id, name, nameKey: key, trashed: false, trashedAt: null, updatedAt: now(), ...stamp(ctx, node) };
|
|
484
704
|
await tx.put(NODES, restored.id, restored);
|
|
485
|
-
await tx.put(NAMES, await claimId(
|
|
705
|
+
await tx.put(NAMES, await claimId(parent.owner, parent.id, key), { nodeId: restored.id, owner: parent.owner, parentId: parent.id });
|
|
706
|
+
if (sharing && parent.id !== node.parentId) await tx.put("resources", restored.id, await resourceDoc(restored, parent));
|
|
486
707
|
return restored;
|
|
487
708
|
});
|
|
488
709
|
},
|
|
@@ -503,6 +724,7 @@ function createDrive(deps) {
|
|
|
503
724
|
if (q.minSize !== void 0 && node.size < q.minSize) continue;
|
|
504
725
|
if (q.maxSize !== void 0 && node.size > q.maxSize) continue;
|
|
505
726
|
if (q.createdByKind && node.createdBy.kind !== q.createdByKind) continue;
|
|
727
|
+
if (q.app !== void 0 && appOf(node) !== q.app) continue;
|
|
506
728
|
hits.push({ node, path });
|
|
507
729
|
}
|
|
508
730
|
return hits.sort((a, b) => a.path < b.path ? -1 : 1).slice(0, limit);
|
|
@@ -520,32 +742,253 @@ function createDrive(deps) {
|
|
|
520
742
|
},
|
|
521
743
|
/** Deletes every item and blob of the owner for good. Only the owner may do this, never an agent for them. A failed call can run again. */
|
|
522
744
|
async deleteAll(ctx) {
|
|
523
|
-
|
|
524
|
-
await need(ctx, "delete", rootNode(ctx));
|
|
525
|
-
|
|
526
|
-
for (let pass = 0; pass < 5; pass++) {
|
|
527
|
-
const all = await ownerNodes(ctx, true);
|
|
528
|
-
if (all.length === 0) return { removed };
|
|
529
|
-
removed += await removeNodes(all);
|
|
530
|
-
}
|
|
531
|
-
throw new DriveError("BUSY", "the drive kept changing while it was deleted. Run it again.");
|
|
745
|
+
assertInPerson(ctx, "delete the whole drive");
|
|
746
|
+
await need(ctx, "delete", rootNode(ctx.owner, ctx.actor));
|
|
747
|
+
return { removed: await eraseDrive(ctx.owner, ctx.principal) };
|
|
532
748
|
},
|
|
533
749
|
/** Deletes a trashed item for good, with everything under it and all their bytes. */
|
|
534
750
|
async purge(ctx, id) {
|
|
535
|
-
const
|
|
751
|
+
const marked = await rawNode(store, id, ctx.owner, ctx.actor, true);
|
|
752
|
+
const resumed = marked?.purging === true && sharing !== void 0 && isInPerson(ctx) && marked.owner === ctx.owner && await store.get("resources", marked.id) === null;
|
|
753
|
+
const top = resumed ? marked : await fetch(store, ctx, id, true);
|
|
536
754
|
if (!top || top.id === ROOT_ID) throw new DriveError("NOT_FOUND", `no item ${id}`);
|
|
537
755
|
if (!top.trashed) throw new DriveError("NOT_TRASHED", `${top.name} is not in the trash`);
|
|
538
|
-
await need(ctx, "delete", top);
|
|
756
|
+
if (!resumed) await need(ctx, "delete", top);
|
|
539
757
|
const nodes = [];
|
|
540
758
|
for (const queue = [top]; queue.length; ) {
|
|
541
759
|
const node = queue.pop();
|
|
542
760
|
nodes.push(node);
|
|
543
761
|
if (node.kind === "folder") queue.push(...await children(store, node.owner, node.id));
|
|
544
762
|
}
|
|
545
|
-
return { removed: await removeNodes(nodes, top) };
|
|
763
|
+
return { removed: await removeNodes(nodes, top, ctx.principal) };
|
|
764
|
+
},
|
|
765
|
+
/**
|
|
766
|
+
* What the home view shows: every file and every reference to an app's document, newest change first.
|
|
767
|
+
* `apps` counts references per app, and `files` counts plain files, before `app` and `limit` apply.
|
|
768
|
+
*/
|
|
769
|
+
async home(ctx, q = {}) {
|
|
770
|
+
const limit = q.limit ?? 100;
|
|
771
|
+
if (!Number.isInteger(limit) || limit < 1) throw new RangeError("limit must be an integer of 1 or more");
|
|
772
|
+
const all = await ownerNodes(ctx);
|
|
773
|
+
const byId = new Map(all.map((n) => [n.id, n]));
|
|
774
|
+
const apps = {};
|
|
775
|
+
let files = 0;
|
|
776
|
+
const items = [];
|
|
777
|
+
for (const node of all) {
|
|
778
|
+
const path = pathIn(byId, node, true);
|
|
779
|
+
if (path === null || node.kind === "folder") continue;
|
|
780
|
+
const app = appOf(node);
|
|
781
|
+
if (app === null) files++;
|
|
782
|
+
else apps[app] = (apps[app] ?? 0) + 1;
|
|
783
|
+
if (q.app === void 0 || app === q.app) items.push({ node, path, app });
|
|
784
|
+
}
|
|
785
|
+
items.sort((x, y) => y.node.updatedAt - x.node.updatedAt || (x.path < y.path ? -1 : 1));
|
|
786
|
+
return { items: items.slice(0, limit), apps, files };
|
|
787
|
+
},
|
|
788
|
+
/**
|
|
789
|
+
* An app puts one of its documents in this drive, as a reference such as `docs:<id>`. A document that is
|
|
790
|
+
* already in the drive comes back as it is, so an app can register again. The reference sits in `folder`
|
|
791
|
+
* (a path or a folder id, "/" by default) under `name`, and gets a free name when that one is taken.
|
|
792
|
+
*/
|
|
793
|
+
async registerDocument(ctx, input) {
|
|
794
|
+
if (!REF.test(input.ref)) throw new DriveError("INVALID_REF", `a reference looks like "docs:<id>", got ${JSON.stringify(input.ref)}`);
|
|
795
|
+
parseName(input.name);
|
|
796
|
+
await need(ctx, "read", rootNode(ctx.owner, ctx.actor));
|
|
797
|
+
const dir = input.folder ?? "/";
|
|
798
|
+
const owner = dir.startsWith("/") ? ctx.owner : (await resolve(store, ctx, dir.split("/")[0])).owner;
|
|
799
|
+
for (const { data } of await allWhere(store, NODES, { owner, ref: input.ref })) {
|
|
800
|
+
const node = data;
|
|
801
|
+
if (node.kind === "ref" && !node.trashed && node.purging !== true && await isLive(store, ctx, node) && await can(ctx, "read", node)) return node;
|
|
802
|
+
}
|
|
803
|
+
return api.putRef(ctx, dir.endsWith("/") ? `${dir}${input.name}` : `${dir}/${input.name}`, input.ref, { parents: true, onConflict: "rename" });
|
|
804
|
+
},
|
|
805
|
+
/** An app deleted a document. Its references in this drive go to the trash. Returns how many. */
|
|
806
|
+
async forgetDocument(ctx, ref) {
|
|
807
|
+
if (!REF.test(ref)) throw new DriveError("INVALID_REF", `a reference looks like "docs:<id>", got ${JSON.stringify(ref)}`);
|
|
808
|
+
await need(ctx, "read", rootNode(ctx.owner, ctx.actor));
|
|
809
|
+
let trashed = 0;
|
|
810
|
+
for (const { data } of await allWhere(store, NODES, { owner: ctx.owner, ref })) {
|
|
811
|
+
if (data.kind !== "ref" || data.trashed || data.purging === true) continue;
|
|
812
|
+
try {
|
|
813
|
+
await api.trash(ctx, data.id);
|
|
814
|
+
trashed++;
|
|
815
|
+
} catch (error) {
|
|
816
|
+
if (!(error instanceof DriveError && error.code === "TRASHED")) throw error;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
return { trashed };
|
|
820
|
+
},
|
|
821
|
+
/**
|
|
822
|
+
* What drive holds about the person behind a call, besides their own drive (`exportData` has that). It is
|
|
823
|
+
* only for the person in person: an agent gets `partial: true` and nothing else. It holds the ids of the
|
|
824
|
+
* items they or their agents made or changed in other drives (no names, and nothing of the people who own
|
|
825
|
+
* them), the shares they hold, the invites they sent, and the audit entries of what they and their agents
|
|
826
|
+
* did. Invitee emails and link tokens are not in it.
|
|
827
|
+
*/
|
|
828
|
+
async exportPerson(ctx) {
|
|
829
|
+
await need(ctx, "read", rootNode(ctx.owner, ctx.actor));
|
|
830
|
+
const subject = subjectOf(ctx);
|
|
831
|
+
if (!isInPerson(ctx)) return { subject, partial: true, authored: [], agents: [], shares: { held: [], invitesCreated: [] }, audit: [] };
|
|
832
|
+
const { actors, ownedAgents } = await actorsOf(store, subject.id);
|
|
833
|
+
const authored = (await madeBy(store, actors)).filter((n) => n.owner !== subject.id && n.purging !== true).map((n) => ({
|
|
834
|
+
id: n.id,
|
|
835
|
+
kind: n.kind,
|
|
836
|
+
created: actors.includes(n.createdById),
|
|
837
|
+
updated: actors.includes(n.updatedById),
|
|
838
|
+
createdAt: actors.includes(n.createdById) ? n.createdAt : null,
|
|
839
|
+
updatedAt: actors.includes(n.updatedById) ? n.updatedAt : null,
|
|
840
|
+
by: [...new Set([actors.includes(n.createdById) ? n.createdBy : null, actors.includes(n.updatedById) ? n.updatedBy : null].filter((a) => a !== null).map((a) => JSON.stringify(a)))].map((a) => JSON.parse(a))
|
|
841
|
+
})).sort((x, y) => x.id < y.id ? -1 : 1);
|
|
842
|
+
const held = [];
|
|
843
|
+
for (const [actor, kind] of heldBy(subject, ownedAgents)) for (const g of await allWhere(store, "grants", { subjectId: actor, subjectKind: kind })) held.push({ subjectKind: g.data.subjectKind, subjectId: g.data.subjectId, resourceId: g.data.resourceId, role: g.data.role });
|
|
844
|
+
const agents = [];
|
|
845
|
+
for (const agent of ownedAgents) {
|
|
846
|
+
const items = (await rawOwnerNodes(agent, false)).map((n) => ({ id: n.id, kind: n.kind, name: n.name, size: n.size, mime: n.mime, createdAt: n.createdAt, updatedAt: n.updatedAt, trashed: n.trashed }));
|
|
847
|
+
agents.push({ id: agent, items });
|
|
848
|
+
}
|
|
849
|
+
const invitesCreated = [];
|
|
850
|
+
const audit = [];
|
|
851
|
+
for (const actor of actors) {
|
|
852
|
+
for (const i of await allWhere(store, "invites", { createdBy: actor })) {
|
|
853
|
+
invitesCreated.push({ id: i.data.id, resourceId: i.data.resourceId, role: i.data.role, expiresAt: i.data.expiresAt ?? null, accepted: i.data.acceptedAt !== void 0 });
|
|
854
|
+
}
|
|
855
|
+
for (const e of await allWhere(store, "audit", { principalId: actor }, { field: "at" })) {
|
|
856
|
+
audit.push({ at: e.data.at, action: e.data.action, resourceId: e.data.resourceId ?? null, outcome: e.data.outcome, principalId: e.data.principalId, principalKind: e.data.principalKind, machineMade: e.data.machineMade });
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
audit.sort((x, y) => x.at - y.at);
|
|
860
|
+
return { subject, partial: false, authored, agents, shares: { held, invitesCreated }, audit };
|
|
861
|
+
},
|
|
862
|
+
/**
|
|
863
|
+
* Erases a person for good. Only the person in person may. Their drive and its shares go. So do the shares
|
|
864
|
+
* they hold and the invites they or their agents sent, on drive items only: the grants of other apps stay for
|
|
865
|
+
* those apps and for identity. What they or their agents made in other drives stays, because it belongs to
|
|
866
|
+
* those drives: the id becomes "erased", and the kind stays, so machine-made work keeps its label. The audit
|
|
867
|
+
* entries of the erased items go with them. The ones about what the person did in other drives stay: they hold ids and kinds only. A failed call can run again.
|
|
868
|
+
*/
|
|
869
|
+
async erasePerson(ctx) {
|
|
870
|
+
assertInPerson(ctx, "erase a person");
|
|
871
|
+
const { id } = subjectOf(ctx);
|
|
872
|
+
const { actors, ownedAgents } = await actorsOf(store, id);
|
|
873
|
+
let { removed } = await api.deleteAll(ctx);
|
|
874
|
+
for (const agent of ownedAgents) removed += await eraseDrive(agent);
|
|
875
|
+
const ofDrive = async (docs) => {
|
|
876
|
+
const out = [];
|
|
877
|
+
for (const doc of docs) if ((await store.get("resources", doc.data.resourceId))?.type === "drive") out.push({ id: doc.id });
|
|
878
|
+
return out;
|
|
879
|
+
};
|
|
880
|
+
const sweep = async (docs, collection) => {
|
|
881
|
+
for (let i = 0; i < docs.length; i += 100) {
|
|
882
|
+
await transact(async (tx) => {
|
|
883
|
+
for (const doc of docs.slice(i, i + 100)) await tx.delete(collection, doc.id);
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
return docs.length;
|
|
887
|
+
};
|
|
888
|
+
let grants = 0;
|
|
889
|
+
let invites = 0;
|
|
890
|
+
for (const [actor, kind] of heldBy(subjectOf(ctx), ownedAgents)) grants += await sweep(await ofDrive(await allWhere(store, "grants", { subjectId: actor, subjectKind: kind })), "grants");
|
|
891
|
+
for (const actor of actors) invites += await sweep(await ofDrive(await allWhere(store, "invites", { createdBy: actor })), "invites");
|
|
892
|
+
const todo = await madeBy(store, actors);
|
|
893
|
+
for (let i = 0; i < todo.length; i += CHUNK) {
|
|
894
|
+
await transact(async (tx) => {
|
|
895
|
+
const current = [];
|
|
896
|
+
for (const node of todo.slice(i, i + CHUNK)) {
|
|
897
|
+
const found = await tx.get(NODES, node.id);
|
|
898
|
+
if (found) current.push(found);
|
|
899
|
+
}
|
|
900
|
+
for (const node of current) {
|
|
901
|
+
const next = { ...node };
|
|
902
|
+
if (actors.includes(node.createdById ?? node.createdBy.id)) Object.assign(next, { createdBy: { id: ERASED, kind: node.createdBy.kind }, createdById: ERASED });
|
|
903
|
+
if (actors.includes(node.updatedById ?? node.updatedBy.id)) Object.assign(next, { updatedBy: { id: ERASED, kind: node.updatedBy.kind }, updatedById: ERASED });
|
|
904
|
+
await tx.put(NODES, node.id, next);
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
return { removed, grants, invites, anonymized: todo.length };
|
|
909
|
+
},
|
|
910
|
+
/** Gives a person or an agent a role on an item and everything under it. Only an owner may. */
|
|
911
|
+
async shareWith(ctx, loc, subject, role) {
|
|
912
|
+
const resource = await resourceOf(await resolve(store, ctx, loc));
|
|
913
|
+
await viaSharing(ctx, (s, p) => s.grant(p, resource, parseSubject(subject), role));
|
|
914
|
+
},
|
|
915
|
+
async unshare(ctx, loc, subject) {
|
|
916
|
+
const resource = await resourceOf(await resolve(store, ctx, loc));
|
|
917
|
+
await viaSharing(ctx, (s, p) => s.revokeGrant(p, resource, parseSubject(subject)));
|
|
918
|
+
},
|
|
919
|
+
/** Invite by `email` (a person) or `handle` (an agent). The invite lasts `expiresInMs`, if given. It sends no message. */
|
|
920
|
+
async invite(ctx, loc, input) {
|
|
921
|
+
const resource = await resourceOf(await resolve(store, ctx, loc));
|
|
922
|
+
return viaSharing(ctx, (s, p) => s.createInvite(p, resource, input));
|
|
923
|
+
},
|
|
924
|
+
/** `identity` is the verified email of a person, or the handle of an agent. */
|
|
925
|
+
async acceptInvite(ctx, inviteId, identity) {
|
|
926
|
+
await viaSharing(ctx, (s, p) => s.acceptInvite(p, inviteId, identity));
|
|
927
|
+
},
|
|
928
|
+
/** The token comes back once. Only its hash is stored. */
|
|
929
|
+
async createLink(ctx, loc, input) {
|
|
930
|
+
const resource = await resourceOf(await resolve(store, ctx, loc));
|
|
931
|
+
return viaSharing(ctx, (s, p) => s.createLink(p, resource, input));
|
|
932
|
+
},
|
|
933
|
+
async revokeLink(ctx, linkId) {
|
|
934
|
+
await viaSharing(ctx, (s, p) => s.revokeLink(p, linkId));
|
|
935
|
+
},
|
|
936
|
+
/** Who can reach an item, and by which links and invites. Only an owner may look. Link tokens are never shown. */
|
|
937
|
+
async shares(ctx, loc) {
|
|
938
|
+
const node = await resolve(store, ctx, loc);
|
|
939
|
+
await need(ctx, "delete", node);
|
|
940
|
+
const where = { resourceId: await resourceOf(node) };
|
|
941
|
+
const rows = async (collection) => (await store.query(collection, { where, limit: 100 })).items.map((item) => item.data);
|
|
942
|
+
return {
|
|
943
|
+
// A grant with no valid kind is from before 0.4.0 and not migrated. Identity does not honor it, so the list leaves it out.
|
|
944
|
+
grants: (await rows("grants")).filter((g) => SUBJECT_KINDS.includes(g.subjectKind)).map((g) => ({ subjectKind: g.subjectKind, subjectId: g.subjectId, role: g.role })),
|
|
945
|
+
links: (await rows("links")).map((l) => ({ id: l.id, role: l.role, signedInOnly: l.signedInOnly === true, expiresAt: l.expiresAt ?? null, revoked: l.revokedAt != null })),
|
|
946
|
+
invites: (await rows("invites")).map((i) => ({ id: i.id, role: i.role, email: i.email ?? null, handle: i.handle ?? null, expiresAt: i.expiresAt ?? null, accepted: i.acceptedAt !== void 0 }))
|
|
947
|
+
};
|
|
948
|
+
},
|
|
949
|
+
openShared,
|
|
950
|
+
/** Live children of a folder that a link visitor or a signed-in person may read, sorted by name. */
|
|
951
|
+
async listShared(principal, id, linkToken) {
|
|
952
|
+
const folder = await openShared(principal, id, linkToken);
|
|
953
|
+
if (folder.kind !== "folder") throw new DriveError("NOT_A_FOLDER", `${folder.name} is not a folder`);
|
|
954
|
+
return (await children(store, folder.owner, folder.id)).filter((n) => !n.trashed && n.purging !== true).sort((x, y) => x.nameKey < y.nameKey ? -1 : x.nameKey > y.nameKey ? 1 : x.name < y.name ? -1 : 1);
|
|
955
|
+
},
|
|
956
|
+
async readShared(principal, id, linkToken) {
|
|
957
|
+
const node = await openShared(principal, id, linkToken);
|
|
958
|
+
if (node.kind !== "file" || !node.blobKey) throw new DriveError("NOT_A_FILE", `${node.name} is not a file`);
|
|
959
|
+
const blob = await blobs.get(node.blobKey);
|
|
960
|
+
if (!blob) throw new DriveError("BLOB_MISSING", `the bytes of ${node.name} are missing`);
|
|
961
|
+
return { node, ...blob };
|
|
962
|
+
}
|
|
963
|
+
};
|
|
964
|
+
async function viaSharing(ctx, fn) {
|
|
965
|
+
if (!sharing) throw new DriveError("INVALID_SHARE", "sharing is not set up");
|
|
966
|
+
if (!ctx.principal) throw new DriveError("FORBIDDEN", "sign in to share");
|
|
967
|
+
try {
|
|
968
|
+
return await fn(sharing, ctx.principal);
|
|
969
|
+
} catch (error) {
|
|
970
|
+
const message = error instanceof Error ? error.message : "";
|
|
971
|
+
if (message === "forbidden") throw new DriveError("FORBIDDEN", "not allowed to share this item");
|
|
972
|
+
if (SHARE_ERROR.test(message)) throw new DriveError("INVALID_SHARE", message);
|
|
973
|
+
throw error;
|
|
546
974
|
}
|
|
975
|
+
}
|
|
976
|
+
async function prepare(ctx) {
|
|
977
|
+
if (!sharing || ctx.principal === void 0) return;
|
|
978
|
+
if (ctx.principal.chain[0]?.id !== ctx.owner) throw new DriveError("FORBIDDEN", "the caller does not own this drive");
|
|
979
|
+
const id = await rootResource(ctx.owner);
|
|
980
|
+
if ((await store.get("resources", id))?.type === "drive") return;
|
|
981
|
+
try {
|
|
982
|
+
await sharing.createResource(ctx.principal, { id, type: "drive" });
|
|
983
|
+
} catch {
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
const guarded = (call) => async (ctx, ...args) => {
|
|
987
|
+
await prepare(ctx);
|
|
988
|
+
return call(ctx, ...args);
|
|
547
989
|
};
|
|
548
|
-
|
|
990
|
+
const open = /* @__PURE__ */ new Set(["openShared", "listShared", "readShared"]);
|
|
991
|
+
return Object.fromEntries(Object.entries(api).map(([name, call]) => [name, open.has(name) ? call : guarded(call)]));
|
|
549
992
|
}
|
|
550
993
|
|
|
551
994
|
// src/zip.ts
|
|
@@ -674,7 +1117,8 @@ async function buildExport(drive, ctx, now = Date.now) {
|
|
|
674
1117
|
...missing ? { missing: true } : {}
|
|
675
1118
|
};
|
|
676
1119
|
});
|
|
677
|
-
const
|
|
1120
|
+
const person = await drive.exportPerson(ctx);
|
|
1121
|
+
const manifest = { format: "agentic-drive-export", version: 2, exportedAt: now(), owner: ctx.owner, subject: person.subject, partial: person.partial, items, authored: person.authored, agents: person.agents, shares: person.shares, audit: person.audit };
|
|
678
1122
|
return writeZip([{ name: "manifest.json", data: new TextEncoder().encode(JSON.stringify(manifest, null, 2)) }, ...entries], now());
|
|
679
1123
|
}
|
|
680
1124
|
|
|
@@ -921,6 +1365,9 @@ export {
|
|
|
921
1365
|
DEFAULT_URL_SECONDS,
|
|
922
1366
|
safeMime,
|
|
923
1367
|
parseName,
|
|
1368
|
+
rootResource,
|
|
1369
|
+
subjectKindOf,
|
|
1370
|
+
parseSubject,
|
|
924
1371
|
createDrive,
|
|
925
1372
|
writeZip,
|
|
926
1373
|
readZip,
|