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