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.
@@ -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 can = deps.can ?? ((ctx, _action, node) => node.owner === ctx.owner);
79
- const rootNode = (ctx) => ({
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: ctx.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: ctx.actor,
94
- updatedBy: ctx.actor,
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 = id === ROOT_ID ? rootNode(ctx) : await ops.get(NODES, id);
102
- return node && (purgeAgain || node.purging !== true) && await can(ctx, "read", node) ? node : null;
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, ctx, parentId, key) => (await claimed(ops, ctx.owner, parentId, key)).node;
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 fetch(ops, ctx, n.parentId);
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.startsWith("/")) {
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
- let node = rootNode(ctx);
139
- for (const seg of splitPath(loc)) {
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, ctx, node.id, seg.key);
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, ctx, parentId, name) {
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, ctx.owner, parentId, parseName(candidate).key)).taken) return candidate;
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 = rootNode(ctx);
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, ctx, node.id, seg.key);
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, ctx.owner, parent.id, key);
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(), updatedBy: who(ctx) };
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, ctx, parent.id, name);
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: newId(),
189
- owner: ctx.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(ctx.owner, parent.id, key), { nodeId: node.id, owner: ctx.owner, parentId: parent.id });
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 = splitPath(path);
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 fetch(ops, ctx, n.parentId);
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
- await need(ctx, "write", dest);
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, ctx.owner, dest.id, key);
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, ctx, dest.id, name);
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(), updatedBy: who(ctx) };
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(ctx.owner, node.parentId, node.nameKey), await claimId(ctx.owner, dest.id, key)];
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: ctx.owner, parentId: dest.id });
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(ctx, blobKey) {
356
+ async function isUnreferenced(blobKey) {
248
357
  try {
249
- return (await store.query(NODES, { where: { owner: ctx.owner, blobKey }, limit: 1 })).items.length === 0;
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 ownerNodes(ctx, withMarked = false) {
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: ctx.owner }, limit: 100, cursor });
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
- async function removeNodes(nodes, top) {
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 += CHUNK) {
297
- const slice = nodes.slice(i, i + CHUNK);
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 += CHUNK) {
327
- const chunk = rows.slice(i, i + CHUNK);
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, ctx, parent.id, parseName(name).key);
358
- if (opts.parents && existing?.kind === "folder") return existing;
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 = `${ctx.owner}/${newId()}`;
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(ctx, blobKey)) await dropBlob(blobKey);
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
- const renamed = await relocate(tx, w, ctx, node, node.parentId, { name });
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, "delete", node);
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, updatedBy: who(ctx) };
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 && await can(ctx, "read", data)) out.push(data);
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 fetch(tx, ctx, opts.parentId ?? node.parentId);
474
- if (!parent || parent.kind !== "folder" || !await isLive(tx, ctx, parent)) {
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, ctx.owner, parent.id, key)).taken) {
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, ctx, parent.id, name);
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(), updatedBy: who(ctx) };
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(ctx.owner, parent.id, key), { nodeId: restored.id, owner: ctx.owner, parentId: parent.id });
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
- if (ctx.actor.kind !== "human" || ctx.actor.id !== ctx.owner) throw new DriveError("FORBIDDEN", "only the owner in person can delete the whole drive");
524
- await need(ctx, "delete", rootNode(ctx));
525
- let removed = 0;
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 top = await fetch(store, ctx, id, true);
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
- return api;
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 manifest = { format: "agentic-drive-export", version: 1, exportedAt: now(), owner: ctx.owner, items };
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,