agentic-drive 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ Read the breaking changes before you upgrade. The migration steps follow them.
6
+
7
+ ### Breaking changes
8
+
9
+ - **agentic-identity 0.4.0.** A grant has a kind and an id, and a person and an agent with the same id are two subjects. The range is now `^0.4.0`. Run identity's `migrateGrants` once on a store with grants from 0.3.0. See the migration steps.
10
+ - **`shareWith` and `unshare` take a subject.** The subject is `{ kind: "human" | "agent", id }`, not a bare id. A subject with no kind, or with another kind, fails with `INVALID_SHARE`. The HTTP routes take `subjectKind` next to `subjectId`: in the body of `POST /shares`, and in the query of `DELETE /shares`. `GET /shares` lists `subjectKind` on each grant.
11
+ - **Erase and export read grants by kind.** A person's erase and export touch the grants of that person and of their agents with their own identity. A grant of an agent with the same id as a person stays.
12
+
13
+ ### Added
14
+
15
+ - **`agentic-drive/client`.** A typed client for the HTTP API: `createClient({ baseUrl, token, fetch?, retries? })`. It has one method for each bearer route and each shared route, a typed `DriveApiError` with the API `code` and the HTTP `status`, and `AbortSignal` support. It sends a write once. It uses only `fetch`.
16
+
17
+ ### Changed
18
+
19
+ - A purge, `deleteAll`, and `erasePerson` erase each item and the drive root through identity's `deleteResource`. The grants, links, invites, and audit entries of the item go with it, so none survives the item. Before, the audit entries stayed. The shares go while the item is marked for erase, before its row goes, so a failed call leaves a marked item and the same call runs again. A caller who does not own the item, and an agent drive that `erasePerson` removes, get the same result from a sweep. A purge costs more calls for each item. Only the owner of the drive, in person, can finish a purge that failed after the shares were erased. An agent gets `NOT_FOUND` for that item.
20
+ - New exports `subjectKindOf` and `parseSubject`. `subjectKindOf` accepts the kind of the root of a chain, `human` or `agent`, and throws on any other kind, a delegated one too.
21
+ - An export lists `subjectKind` on each grant a person holds.
22
+
23
+ ### Migration
24
+
25
+ Run the steps in this order. The old Worker keeps writing grants in the old format until step 3, so the migration in step 4 must come after the deploy.
26
+
27
+ 1. Install the new versions: `npm install agentic-drive@0.3.0 agentic-identity@^0.4.0`.
28
+ 2. Change every call of `shareWith` and `unshare` in your code to pass `{ kind, id }`. The build fails on a bare id, and a bare id in JavaScript fails with `INVALID_SHARE`.
29
+ 3. Deploy the 0.3.0 Worker. **From now until step 4, every owner loses access to their own drive.** Owner grants from before 0.3.0 are in the old format too, and identity 0.4.0 does not honor them. Everybody gets `NOT_FOUND`, and `erasePerson` and the export skip those grants. Run step 4 right after the deploy, or plan a maintenance window.
30
+ 4. Run `migrateGrants` in a one-off Worker set up as in the 0.2.0 steps. Add it to the `steps` of that Worker, for example `grants: (store) => migrateGrants(store)`, with `import { migrateGrants } from "agentic-identity"`. It rewrites grants that have no kind. If the call fails or runs out of time, call it again until `migrated` is 0. It skips what is done. Delete the one-off Worker after it.
31
+ 5. Delete from the `grants` collection each doc id that `migrateGrants` lists in `unresolved`. Its subject is gone, and identity never honors it. The share list leaves such a grant out.
32
+ 6. Change every HTTP client of `POST /shares` and `DELETE /shares` to send `subjectKind`. The new client does this.
33
+
3
34
  ## 0.2.0
4
35
 
5
36
  Read the breaking changes before you upgrade. The migration steps follow them.
package/README.md CHANGED
@@ -66,7 +66,7 @@ every request. A missing, expired, or revoked credential, or a revoked or expire
66
66
  A session cookie is refused: only a bearer token counts. An agent acts in its owner's drive with the narrower scopes of its grant.
67
67
  Scopes are `drive:read`, `drive:write`, and `drive:admin`.
68
68
 
69
- Sharing routes: `GET /shares?path=`, `POST /shares`, `DELETE /shares?path=&subjectId=`, `POST /invites`,
69
+ Sharing routes: `GET /shares?path=`, `POST /shares`, `DELETE /shares?path=&subjectId=&subjectKind=`, `POST /invites`,
70
70
  `POST /invites/accept`, `POST /links`, `DELETE /links?id=`. A link visitor reads with the header
71
71
  `x-link-token` on `GET /shared/:id`, `GET /shared/:id/items`, and `GET /shared/:id/content`.
72
72
  Name a shared item by its id. A path can start at a folder id: `<folder id>/notes/a.txt`.
@@ -74,6 +74,33 @@ Without `DRIVE_URL_SECRET` or `DRIVE_PUBLIC_URL`, every request gets 503.
74
74
  Create the R2 bucket with `--jurisdiction eu` and the D1 database with `--location weur`.
75
75
  The Worker declares region `eu` and refuses a D1 query that a non-EU region served. `DRIVE_REGION=unknown` turns this off for local runs.
76
76
 
77
+ ## SDK
78
+
79
+ `agentic-drive/client` is a typed client for the HTTP API. It uses only `fetch`, so it runs in browsers, Workers, and Node 22.
80
+
81
+ ```ts
82
+ import { createClient, DriveApiError } from "agentic-drive/client";
83
+
84
+ const drive = createClient({ baseUrl: "https://drive.example.com", token: process.env.DRIVE_TOKEN });
85
+ await drive.mkdir("/docs", { parents: true });
86
+ const file = await drive.upload("/docs/a.txt", "hello", { mime: "text/plain" });
87
+ const { data } = await drive.download(file.id);
88
+ try {
89
+ await drive.stat("/missing");
90
+ } catch (error) {
91
+ if (error instanceof DriveApiError) console.log(error.code, error.status); // NOT_FOUND 404
92
+ }
93
+ ```
94
+
95
+ - One method for each bearer route and each shared route: `list`, `stat`, `mkdir`, `upload`, `download`, `url`, `move`, `trash`, `listTrash`, `restore`, `purge`, `search`, `export`, `deleteAccount`, `home`, `registerDocument`, `forgetDocument`, `shares`, `share`, `unshare`, `invite`, `acceptInvite`, `createLink`, `revokeLink`, `openShared`, `listShared`, and `readShared`.
96
+ - Each method has typed input and output. The client checks the shape of every answer, and `download` checks the bytes against their hash (it needs WebCrypto). `readShared` does not: the Worker sends no hash there. An answer that fails a check throws `BAD_RESPONSE`. `url` gives a signed link that you fetch yourself. The last argument takes `{ signal }` to stop the call with an `AbortSignal`.
97
+ - A failed call throws `DriveApiError`. It has the `code` of the API and the HTTP `status`. A call that gets no answer has the code `NETWORK_ERROR` and status 0. An abort stays the abort error of `fetch`. A code that the client does not know gives `BAD_RESPONSE`.
98
+ - The client never sends a write again. `retries` (default 0) sends a read (GET) again after a network error or a 502, 503, or 504, with a short wait. A redirect is an error (`BAD_RESPONSE`, with the status of the redirect, or 0 in a browser): set `baseUrl` to the final URL.
99
+ - `fetch` is an option, for tests and for runtimes with their own. `openShared`, `listShared`, and `readShared` work without a token, with a `linkToken`.
100
+ - A stream body for `upload` is refused by some browsers. The error says so, and nothing is sent. Items from before 0.2.0 need `backfillAuthorIds`, and grants from before 0.3.0 need identity's `migrateGrants`. Without them, an answer that holds one fails with `BAD_RESPONSE`.
101
+ - A browser page needs the Worker on its own origin for now: the Worker sends no CORS headers.
102
+ - Share with a subject: `share("/pub", { kind: "human", id: "u2" }, "viewer")`.
103
+
77
104
  ## Features
78
105
 
79
106
  - Folders, files, and typed references to documents in other apps (`docs:<id>`).
@@ -83,14 +110,14 @@ The Worker declares region `eu` and refuses a D1 query that a non-EU region serv
83
110
  - Each item records its size, type, owner, and who made and last changed it, with the actor kind.
84
111
  - Storage sits behind the `Store` and `Blobs` ports of `agentic-identity`. Tests use its memory adapters. The CLI uses a local file adapter that passes the same contract suites. The Worker uses its D1 and R2 adapters.
85
112
  - Roles: viewer, commenter, editor, owner. A role on a folder reaches everything under it, and follows an item that moves. Identity decides every request through `can()`, which also writes the audit entry. A caller who cannot read an item gets `NOT_FOUND`. A caller who can read it but not change it gets `FORBIDDEN`. Editors write and trash. An item takes the roles of its new folder, so moving an item into a folder where the caller is owner needs owner on the item. Only owners share, purge, and read the share list.
86
- - Share by person or agent id, by invite (an email for a person, a handle for an agent), or by link with a role, an expiry, and an optional sign-in. The SDK calls are `shareWith`, `unshare`, `invite`, `acceptInvite`, `createLink`, `revokeLink`, `shares`, `openShared`, `listShared`, and `readShared`.
113
+ - Share by a subject (`{ kind: "human" | "agent", id }`), by invite (an email for a person, a handle for an agent), or by link with a role, an expiry, and an optional sign-in. The SDK calls are `shareWith`, `unshare`, `invite`, `acceptInvite`, `createLink`, `revokeLink`, `shares`, `openShared`, `listShared`, and `readShared`.
87
114
  - Two live items never share a name in one folder. A claim document holds each name. It is written in the same transaction as the item, and it is deleted when the item goes to the trash.
88
115
  - `upload()` reads a stream with a size cap, finds the type from the first bytes, and stores a SHA-256 hash.
89
116
  - `downloadUrl()` returns a signed URL. The URL signs an attachment header with the file name and the stored type. The Worker verifies it and serves the bytes with those headers, so the link holder cannot change how the bytes are served. A link lasts 15 minutes by default and 1 hour at most. Trashing a file does not revoke a link already issued.
90
117
  - `search()` finds live items by name, type, kind, size, and who made them.
91
118
  - App documents: an app calls `registerDocument({ ref: "docs:<id>", name, folder })` to put a document in the drive, and `forgetDocument(ref)` when it deletes the document. `home({ app })` lists files and every app's documents together, newest change first, with a count per app. `search({ app })` filters the same way. The CLI has `home`, `register`, and `forget`. The MCP server has `drive_home`, `drive_register_document`, and `drive_forget_document`.
92
119
  - `buildExport()` writes everything one person has to a zip with a `manifest.json` (format version 2). It names the person by id and kind. It lists the person's own items (trashed ones are flagged, and a file whose bytes are gone is marked `missing`). For the person in person it also lists the ids of what they or their agents made or changed in other drives (no names, and nothing of the people who own them), the shares they hold, the invites they sent, and the audit entries of what they and their agents did. An agent gets `partial: true` and none of that. Invitee emails and link tokens are never in it. Entries are named by item id.
93
- - `erasePerson()` erases a person for good, and only the person in person can call it (the CLI `delete-all` and `DELETE /account` call it). It deletes their drive with all its shares, the shares they hold, and the invites they or their agents sent, on drive items only: the grants of other apps stay for those apps and for identity. What they or their agents made in other drives stays, because it belongs to those drives: the id becomes `erased`, and the kind stays, so machine-made work keeps its label. Agents are found through the `principals` collection, as identity finds them: an agent with its own identity names the person as `ownerId`, and a delegated agent names its parent. An agent with its own identity belongs to the person, and so does its drive: erase deletes it, and the export lists its items (no bytes). A person with more than 1000 agents cannot be erased here: the call fails and deletes nothing more. The audit trail stays: it holds ids and kinds only. A failed call can run again. `deleteAll()` is the drive part alone.
120
+ - `erasePerson()` erases a person for good, and only the person in person can call it (the CLI `delete-all` and `DELETE /account` call it). It deletes their drive with all its shares, the shares they hold, and the invites they or their agents sent, on drive items only: the grants of other apps stay for those apps and for identity. What they or their agents made in other drives stays, because it belongs to those drives: the id becomes `erased`, and the kind stays, so machine-made work keeps its label. Agents are found through the `principals` collection, as identity finds them: an agent with its own identity names the person as `ownerId`, and a delegated agent names its parent. An agent with its own identity belongs to the person, and so does its drive: erase deletes it, and the export lists its items (no bytes). A person with more than 1000 agents cannot be erased here: the call fails and deletes nothing more. The audit entries of the erased items go with them, through identity `deleteResource`. The entries about what the person did in other drives stay: they hold ids and kinds only. A failed call can run again. `deleteAll()` is the drive part alone.
94
121
  - A `can()` hook decides who may read, write, or delete. Without `sharing`, the default allows the owner only.
95
122
 
96
123
  ## Limits
package/SKILL.md CHANGED
@@ -18,6 +18,17 @@ const me = { owner: "u1", actor: { id: "u1", kind: "human" } };
18
18
  await drive.mkdir(me, "/notes");
19
19
  ```
20
20
 
21
+ To call a deployed Worker, use the remote client. It runs in browsers, Workers, and Node 22:
22
+
23
+ ```js
24
+ import { createClient } from "agentic-drive/client";
25
+
26
+ const remote = createClient({ baseUrl: "https://drive.example.com", token });
27
+ await remote.mkdir("/notes");
28
+ ```
29
+
30
+ A failed call throws `DriveApiError`. Read its `code` and `status`.
31
+
21
32
  ## Rules
22
33
 
23
34
  - A locator with a `/` is a path: `/x` from the root, or `<folder id>/x` from a folder. Any other locator is an id.
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  ROOT_ID,
3
3
  rootResource
4
- } from "./chunk-6CWBHSJ7.js";
4
+ } from "./chunk-VSVIJBMJ.js";
5
5
 
6
6
  // src/migrate.ts
7
7
  var PAGE = 10;
@@ -1,6 +1,6 @@
1
1
  // src/version.ts
2
2
  import { createRequire } from "module";
3
- var VERSION = true ? "0.2.0" : readPackageVersion();
3
+ var VERSION = true ? "0.3.0" : readPackageVersion();
4
4
 
5
5
  export {
6
6
  VERSION
@@ -1,5 +1,5 @@
1
1
  // src/drive.ts
2
- import { MAX_FOLDER_DEPTH } from "agentic-identity";
2
+ import { EraseRefusedError, MAX_FOLDER_DEPTH, SUBJECT_KINDS } from "agentic-identity";
3
3
  var ROOT_ID = "root";
4
4
  var NODES = "drive_nodes";
5
5
  var NAMES = "drive_names";
@@ -80,6 +80,17 @@ var MAX_ITEM_DEPTH = MAX_FOLDER_DEPTH - 1;
80
80
  var VISITOR = { id: "", kind: "human" };
81
81
  var appOf = (node) => node.kind === "ref" && node.ref ? node.ref.split(":")[0] : null;
82
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
+ }
83
94
  var SHARE_ERROR = /^(invalid|give an|expiresAt|expiresInMs|resource exists|the last owner)/;
84
95
  function createDrive(deps) {
85
96
  const { store, blobs } = deps;
@@ -393,6 +404,7 @@ function createDrive(deps) {
393
404
  if (!isInPerson(ctx) || ctx.actor.kind !== "human") throw new DriveError("FORBIDDEN", `only the owner in person can ${what}`);
394
405
  }
395
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")])];
396
408
  async function allWhere(ops, collection, where, orderBy) {
397
409
  const out = [];
398
410
  for (let cursor; ; ) {
@@ -419,23 +431,39 @@ function createDrive(deps) {
419
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);
420
432
  return [...made.values()];
421
433
  }
422
- async function eraseDrive(owner) {
434
+ async function eraseDrive(owner, principal) {
423
435
  let removed = 0;
424
436
  for (let pass = 0; pass < 5; pass++) {
425
437
  const all = await rawOwnerNodes(owner, true);
426
438
  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
- });
439
+ await finishResource(principal, await rootResource(owner));
433
440
  return removed;
434
441
  }
435
- removed += await removeNodes(all);
442
+ removed += await removeNodes(all, void 0, principal);
436
443
  }
437
444
  throw new DriveError("BUSY", "the drive kept changing while it was deleted. Run it again.");
438
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
+ }
439
467
  async function sharesOf(ops, resourceId) {
440
468
  const out = [];
441
469
  for (const collection of ["grants", "links", "invites"]) {
@@ -448,7 +476,7 @@ function createDrive(deps) {
448
476
  }
449
477
  return out;
450
478
  }
451
- async function removeNodes(nodes, top) {
479
+ async function removeNodes(nodes, top, principal) {
452
480
  const unchanged = (now2, seen) => now2 !== null && now2.trashed === seen.trashed && now2.parentId === seen.parentId && now2.updatedAt === seen.updatedAt;
453
481
  const size = sharing ? SHARED_CHUNK : CHUNK;
454
482
  const marked = [];
@@ -486,18 +514,16 @@ function createDrive(deps) {
486
514
  for (let i = 0; i < rows.length; i += size) {
487
515
  const chunk = rows.slice(i, i + size);
488
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);
489
518
  await transact(async (tx) => {
490
519
  const held = [];
491
520
  for (const node of chunk) {
492
521
  const claim = node.trashed ? null : await claimId(node.owner, node.parentId, node.nameKey);
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 });
522
+ held.push({ node, claim, owns: claim !== null && (await tx.get(NAMES, claim))?.nodeId === node.id });
495
523
  }
496
- for (const { node, claim, owns, shares } of held) {
524
+ for (const { node, claim, owns } of held) {
497
525
  await tx.delete(NODES, node.id);
498
526
  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);
501
527
  }
502
528
  });
503
529
  }
@@ -718,21 +744,23 @@ function createDrive(deps) {
718
744
  async deleteAll(ctx) {
719
745
  assertInPerson(ctx, "delete the whole drive");
720
746
  await need(ctx, "delete", rootNode(ctx.owner, ctx.actor));
721
- return { removed: await eraseDrive(ctx.owner) };
747
+ return { removed: await eraseDrive(ctx.owner, ctx.principal) };
722
748
  },
723
749
  /** Deletes a trashed item for good, with everything under it and all their bytes. */
724
750
  async purge(ctx, id) {
725
- 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);
726
754
  if (!top || top.id === ROOT_ID) throw new DriveError("NOT_FOUND", `no item ${id}`);
727
755
  if (!top.trashed) throw new DriveError("NOT_TRASHED", `${top.name} is not in the trash`);
728
- await need(ctx, "delete", top);
756
+ if (!resumed) await need(ctx, "delete", top);
729
757
  const nodes = [];
730
758
  for (const queue = [top]; queue.length; ) {
731
759
  const node = queue.pop();
732
760
  nodes.push(node);
733
761
  if (node.kind === "folder") queue.push(...await children(store, node.owner, node.id));
734
762
  }
735
- return { removed: await removeNodes(nodes, top) };
763
+ return { removed: await removeNodes(nodes, top, ctx.principal) };
736
764
  },
737
765
  /**
738
766
  * What the home view shows: every file and every reference to an app's document, newest change first.
@@ -812,7 +840,7 @@ function createDrive(deps) {
812
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))
813
841
  })).sort((x, y) => x.id < y.id ? -1 : 1);
814
842
  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 });
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 });
816
844
  const agents = [];
817
845
  for (const agent of ownedAgents) {
818
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 }));
@@ -836,7 +864,7 @@ function createDrive(deps) {
836
864
  * they hold and the invites they or their agents sent, on drive items only: the grants of other apps stay for
837
865
  * those apps and for identity. What they or their agents made in other drives stays, because it belongs to
838
866
  * 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.
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.
840
868
  */
841
869
  async erasePerson(ctx) {
842
870
  assertInPerson(ctx, "erase a person");
@@ -859,7 +887,7 @@ function createDrive(deps) {
859
887
  };
860
888
  let grants = 0;
861
889
  let invites = 0;
862
- for (const actor of actors) grants += await sweep(await ofDrive(await allWhere(store, "grants", { subjectId: actor })), "grants");
890
+ for (const [actor, kind] of heldBy(subjectOf(ctx), ownedAgents)) grants += await sweep(await ofDrive(await allWhere(store, "grants", { subjectId: actor, subjectKind: kind })), "grants");
863
891
  for (const actor of actors) invites += await sweep(await ofDrive(await allWhere(store, "invites", { createdBy: actor })), "invites");
864
892
  const todo = await madeBy(store, actors);
865
893
  for (let i = 0; i < todo.length; i += CHUNK) {
@@ -880,13 +908,13 @@ function createDrive(deps) {
880
908
  return { removed, grants, invites, anonymized: todo.length };
881
909
  },
882
910
  /** 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) {
911
+ async shareWith(ctx, loc, subject, role) {
884
912
  const resource = await resourceOf(await resolve(store, ctx, loc));
885
- await viaSharing(ctx, (s, p) => s.grant(p, resource, subjectId, role));
913
+ await viaSharing(ctx, (s, p) => s.grant(p, resource, parseSubject(subject), role));
886
914
  },
887
- async unshare(ctx, loc, subjectId) {
915
+ async unshare(ctx, loc, subject) {
888
916
  const resource = await resourceOf(await resolve(store, ctx, loc));
889
- await viaSharing(ctx, (s, p) => s.revokeGrant(p, resource, subjectId));
917
+ await viaSharing(ctx, (s, p) => s.revokeGrant(p, resource, parseSubject(subject)));
890
918
  },
891
919
  /** Invite by `email` (a person) or `handle` (an agent). The invite lasts `expiresInMs`, if given. It sends no message. */
892
920
  async invite(ctx, loc, input) {
@@ -912,7 +940,8 @@ function createDrive(deps) {
912
940
  const where = { resourceId: await resourceOf(node) };
913
941
  const rows = async (collection) => (await store.query(collection, { where, limit: 100 })).items.map((item) => item.data);
914
942
  return {
915
- grants: (await rows("grants")).map((g) => ({ subjectId: g.subjectId, role: g.role })),
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 })),
916
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 })),
917
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 }))
918
947
  };
@@ -1337,6 +1366,8 @@ export {
1337
1366
  safeMime,
1338
1367
  parseName,
1339
1368
  rootResource,
1369
+ subjectKindOf,
1370
+ parseSubject,
1340
1371
  createDrive,
1341
1372
  writeZip,
1342
1373
  readZip,
package/dist/cli.js CHANGED
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  VERSION
4
- } from "./chunk-RWMBGIAS.js";
4
+ } from "./chunk-GMA5I5L6.js";
5
5
  import {
6
6
  DriveError,
7
7
  buildExport,
8
8
  openLocal,
9
9
  publicNode,
10
10
  upload
11
- } from "./chunk-6CWBHSJ7.js";
11
+ } from "./chunk-VSVIJBMJ.js";
12
12
 
13
13
  // src/cli.ts
14
14
  import { createReadStream, realpathSync, writeFileSync } from "fs";
@@ -0,0 +1,173 @@
1
+ import { E as ErrorCode, N as Node, S as SearchQuery } from './drive-Dlzwukjq.js';
2
+ import 'agentic-identity';
3
+
4
+ type Role = "viewer" | "commenter" | "editor" | "owner";
5
+ type OnConflict = "fail" | "rename" | "replace";
6
+ type SubjectKind = "human" | "agent";
7
+ type Subject = {
8
+ kind: SubjectKind;
9
+ id: string;
10
+ };
11
+ /** An item as the API prints it: no storage keys. */
12
+ type Item = Omit<Node, "nameKey" | "blobKey" | "purging">;
13
+ /** A search hit, or a home item: an item with its path. */
14
+ type PathItem = Item & {
15
+ path: string;
16
+ };
17
+ /** The codes of the drive, and the ones that only the Worker raises. */
18
+ type ApiErrorCode = ErrorCode | "UNAUTHORIZED" | "NOT_CONFIGURED" | "INVALID_LINK" | "INTERNAL";
19
+ /** `NETWORK_ERROR` means no answer came (status 0). `BAD_RESPONSE` means an answer that is not the API's. */
20
+ type ClientErrorCode = ApiErrorCode | "NETWORK_ERROR" | "BAD_RESPONSE";
21
+ /** The error of every failed call. `code` is the code of the API, and `status` is the HTTP status. */
22
+ declare class DriveApiError extends Error {
23
+ readonly code: ClientErrorCode;
24
+ readonly status: number;
25
+ constructor(code: ClientErrorCode, status: number, message: string);
26
+ }
27
+ interface ClientOptions {
28
+ /** The origin of the Worker, such as `https://drive.example.com`. */
29
+ baseUrl: string;
30
+ /** A bearer token. It is optional only for the link routes, `openShared`, `listShared`, and `readShared`. */
31
+ token?: string;
32
+ /** Defaults to the global `fetch`. */
33
+ fetch?: typeof fetch;
34
+ /** How often a read (GET) call tries again after a network error or a 502, 503, or 504. The default is 0. A write is never tried again. */
35
+ retries?: number;
36
+ }
37
+ interface CallOptions {
38
+ signal?: AbortSignal;
39
+ }
40
+ type UploadBody = string | Uint8Array | ArrayBuffer | Blob | ReadableStream<Uint8Array>;
41
+ interface Downloaded {
42
+ data: Uint8Array;
43
+ contentType: string;
44
+ sha256: string;
45
+ }
46
+ declare function createClient(options: ClientOptions): {
47
+ /** The live items of a folder. */
48
+ list: (path?: string, o?: CallOptions) => Promise<Item[]>;
49
+ stat: (path: string, o?: CallOptions) => Promise<Item>;
50
+ mkdir: (path: string, opts?: {
51
+ parents?: boolean;
52
+ }, o?: CallOptions) => Promise<Item>;
53
+ /** Writes a file. A `ReadableStream` body streams in runtimes that allow it. */
54
+ upload: (path: string, body: UploadBody, opts?: {
55
+ mime?: string;
56
+ parents?: boolean;
57
+ onConflict?: OnConflict;
58
+ }, o?: CallOptions) => Promise<Item>;
59
+ /** The bytes. They are checked against the hash that the server sends. */
60
+ download: (path: string, o?: CallOptions) => Promise<Downloaded>;
61
+ /** A signed link that works without a token, for `expires` seconds. You fetch the link yourself. */
62
+ url: (path: string, expires?: number, o?: CallOptions) => Promise<{
63
+ url: string;
64
+ expiresInSeconds: number;
65
+ }>;
66
+ move: (src: string, folder: string, opts?: {
67
+ name?: string;
68
+ onConflict?: OnConflict;
69
+ }, o?: CallOptions) => Promise<Item>;
70
+ trash: (path: string, o?: CallOptions) => Promise<Item>;
71
+ listTrash: (o?: CallOptions) => Promise<Item[]>;
72
+ restore: (itemId: string, opts?: {
73
+ parentId?: string;
74
+ onConflict?: OnConflict;
75
+ }, o?: CallOptions) => Promise<Item>;
76
+ /** Deletes a trashed item for good. */
77
+ purge: (itemId: string, o?: CallOptions) => Promise<{
78
+ removed: number;
79
+ }>;
80
+ search: (q?: Pick<SearchQuery, "name" | "mime" | "kind" | "minSize" | "maxSize" | "createdByKind" | "app" | "limit">, o?: CallOptions) => Promise<PathItem[]>;
81
+ /** Everything the caller has, as a zip. */
82
+ export: (o?: CallOptions) => Promise<Uint8Array<ArrayBufferLike>>;
83
+ /** Erases the caller for good. The client sends `confirm=yes`. */
84
+ deleteAccount: (o?: CallOptions) => Promise<{
85
+ removed: number;
86
+ grants: number;
87
+ invites: number;
88
+ anonymized: number;
89
+ }>;
90
+ home: (q?: {
91
+ app?: string;
92
+ limit?: number;
93
+ }, o?: CallOptions) => Promise<{
94
+ apps: Record<string, number>;
95
+ files: number;
96
+ items: (PathItem & {
97
+ app: string | null;
98
+ })[];
99
+ }>;
100
+ registerDocument: (input: {
101
+ ref: string;
102
+ name: string;
103
+ folder?: string;
104
+ }, o?: CallOptions) => Promise<Item>;
105
+ forgetDocument: (ref: string, o?: CallOptions) => Promise<{
106
+ trashed: number;
107
+ }>;
108
+ shares: (path: string, o?: CallOptions) => Promise<{
109
+ grants: {
110
+ subjectKind: SubjectKind;
111
+ subjectId: string;
112
+ role: Role;
113
+ }[];
114
+ links: {
115
+ id: string;
116
+ role: Role;
117
+ signedInOnly: boolean;
118
+ expiresAt: number | null;
119
+ revoked: boolean;
120
+ }[];
121
+ invites: {
122
+ id: string;
123
+ role: Role;
124
+ email: string | null;
125
+ handle: string | null;
126
+ expiresAt: number | null;
127
+ accepted: boolean;
128
+ }[];
129
+ }>;
130
+ share: (path: string, subject: Subject, role: Role, o?: CallOptions) => Promise<{
131
+ ok: true;
132
+ }>;
133
+ unshare: (path: string, subject: Subject, o?: CallOptions) => Promise<{
134
+ ok: true;
135
+ }>;
136
+ /** Invite by `email` (a person) or `handle` (an agent). It sends no message. */
137
+ invite: (path: string, input: {
138
+ role: Role;
139
+ email?: string;
140
+ handle?: string;
141
+ expiresInMs?: number;
142
+ }, o?: CallOptions) => Promise<{
143
+ id: string;
144
+ }>;
145
+ acceptInvite: (inviteId: string, o?: CallOptions) => Promise<{
146
+ ok: true;
147
+ }>;
148
+ /** The token of a link comes back once. */
149
+ createLink: (path: string, input: {
150
+ role: Role;
151
+ expiresInMs?: number;
152
+ signedInOnly?: boolean;
153
+ }, o?: CallOptions) => Promise<{
154
+ id: string;
155
+ token: string;
156
+ }>;
157
+ revokeLink: (linkId: string, o?: CallOptions) => Promise<{
158
+ ok: true;
159
+ }>;
160
+ /** The next three read a shared item by id. Pass a link token, a bearer token, or both. */
161
+ openShared: (itemId: string, opts?: {
162
+ linkToken?: string;
163
+ } & CallOptions) => Promise<Item>;
164
+ listShared: (itemId: string, opts?: {
165
+ linkToken?: string;
166
+ } & CallOptions) => Promise<Item[]>;
167
+ readShared: (itemId: string, opts?: {
168
+ linkToken?: string;
169
+ } & CallOptions) => Promise<Omit<Downloaded, "sha256">>;
170
+ };
171
+ type DriveClient = ReturnType<typeof createClient>;
172
+
173
+ export { type ApiErrorCode, type CallOptions, type ClientErrorCode, type ClientOptions, type Downloaded, DriveApiError, type DriveClient, type Item, type OnConflict, type PathItem, type Role, type Subject, type SubjectKind, type UploadBody, createClient };
package/dist/client.js ADDED
@@ -0,0 +1,240 @@
1
+ // src/client.ts
2
+ var DriveApiError = class extends Error {
3
+ constructor(code, status, message) {
4
+ super(message);
5
+ this.code = code;
6
+ this.status = status;
7
+ this.name = "DriveApiError";
8
+ }
9
+ code;
10
+ status;
11
+ };
12
+ var BASE = "/api/v1/drive";
13
+ var CODES = {
14
+ NOT_FOUND: true,
15
+ NAME_TAKEN: true,
16
+ BLOB_EXISTS: true,
17
+ INVALID_NAME: true,
18
+ INVALID_PATH: true,
19
+ INVALID_REF: true,
20
+ NOT_A_FOLDER: true,
21
+ NOT_A_FILE: true,
22
+ MOVE_INTO_SELF: true,
23
+ TOO_DEEP: true,
24
+ PARENT_GONE: true,
25
+ TRASHED: true,
26
+ NOT_TRASHED: true,
27
+ IS_ROOT: true,
28
+ FORBIDDEN: true,
29
+ BLOB_MISSING: true,
30
+ TOO_LARGE: true,
31
+ INVALID_EXPIRY: true,
32
+ BUSY: true,
33
+ CROSS_DRIVE: true,
34
+ INVALID_SHARE: true,
35
+ TOO_MANY_ITEMS: true,
36
+ UNAUTHORIZED: true,
37
+ NOT_CONFIGURED: true,
38
+ INVALID_LINK: true,
39
+ INTERNAL: true
40
+ };
41
+ var KNOWN_CODES = new Set(Object.keys(CODES));
42
+ var record = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
43
+ var text = (v) => typeof v === "string";
44
+ var orNull = (test) => (v) => v === null || test(v);
45
+ var num = (v) => typeof v === "number";
46
+ var ACTOR_KINDS = ["human", "agent_of_human", "agent_of_agent", "agent", "user_token"];
47
+ var ROLES = ["viewer", "commenter", "editor", "owner"];
48
+ var oneOf = (list) => (v) => typeof v === "string" && list.includes(v);
49
+ var actor = (v) => record(v) && text(v.id) && oneOf(ACTOR_KINDS)(v.kind);
50
+ var isItem = (v) => record(v) && text(v.id) && text(v.owner) && text(v.parentId) && text(v.name) && (v.kind === "folder" || v.kind === "file" || v.kind === "ref") && num(v.size) && orNull(text)(v.mime) && orNull(text)(v.hash) && orNull(text)(v.ref) && num(v.createdAt) && num(v.updatedAt) && actor(v.createdBy) && actor(v.updatedBy) && text(v.createdById) && text(v.updatedById) && typeof v.trashed === "boolean" && orNull(num)(v.trashedAt);
51
+ var isPathItem = (v) => isItem(v) && typeof v.path === "string";
52
+ var listOf = (check) => (v) => Array.isArray(v) && v.every(check);
53
+ var shape = (test) => (v) => record(v) && test(v);
54
+ var numbers = (...keys) => shape((v) => keys.every((k) => typeof v[k] === "number"));
55
+ var isOk = shape((v) => v.ok === true);
56
+ var RETRY_STATUS = /* @__PURE__ */ new Set([502, 503, 504]);
57
+ function createClient(options) {
58
+ let origin;
59
+ try {
60
+ const url = new URL(options.baseUrl);
61
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("not http");
62
+ origin = url.origin;
63
+ } catch {
64
+ throw new TypeError("baseUrl must be an http or https URL");
65
+ }
66
+ const { token } = options;
67
+ if (token !== void 0 && (typeof token !== "string" || !/^[A-Za-z0-9_-]{1,512}$/.test(token))) throw new TypeError("token must be 1 to 512 letters, digits, - or _");
68
+ const retries = options.retries ?? 0;
69
+ if (!Number.isInteger(retries) || retries < 0 || retries > 5) throw new RangeError("retries must be an integer from 0 to 5");
70
+ const doFetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
71
+ const query = (params) => {
72
+ const search = new URLSearchParams();
73
+ for (const [key, value] of Object.entries(params)) if (value !== void 0) search.set(key, String(value));
74
+ const text2 = search.toString();
75
+ return text2 === "" ? "" : `?${text2}`;
76
+ };
77
+ async function send(method, path, init) {
78
+ if (init.auth !== false && token === void 0) throw new TypeError("this call needs a token");
79
+ const headers = { ...token === void 0 ? {} : { authorization: `Bearer ${token}` }, ...init.headers };
80
+ let body = init.body;
81
+ if (init.json !== void 0) {
82
+ headers["content-type"] = "application/json";
83
+ body = JSON.stringify(init.json);
84
+ }
85
+ const attempts = method === "GET" ? retries + 1 : 1;
86
+ for (let attempt = 1; ; attempt++) {
87
+ let response;
88
+ try {
89
+ response = await doFetch(`${origin}${BASE}${path}`, { method, headers, body, signal: init.signal, redirect: "manual", ...body instanceof ReadableStream ? { duplex: "half" } : {} });
90
+ } catch (error) {
91
+ if (init.signal?.aborted || error instanceof Error && error.name === "AbortError") throw error;
92
+ if (attempt < attempts) {
93
+ await pause(attempt, init.signal);
94
+ continue;
95
+ }
96
+ const refused = body instanceof ReadableStream ? " (a stream body may be refused by this runtime: send bytes)" : "";
97
+ throw new DriveApiError("NETWORK_ERROR", 0, `${error instanceof Error ? `no answer: ${error.message}` : "no answer"}${refused}`);
98
+ }
99
+ if (attempt < attempts && RETRY_STATUS.has(response.status)) {
100
+ await response.body?.cancel();
101
+ await pause(attempt, init.signal);
102
+ continue;
103
+ }
104
+ if (response.type === "opaqueredirect" || response.status >= 300 && response.status < 400) {
105
+ await response.body?.cancel();
106
+ throw new DriveApiError("BAD_RESPONSE", response.status, "the server redirected. Set baseUrl to the final URL.");
107
+ }
108
+ if (response.ok) return response;
109
+ throw await errorOf(response, init.signal);
110
+ }
111
+ }
112
+ async function pause(attempt, signal) {
113
+ signal?.throwIfAborted();
114
+ await new Promise((resolve, reject) => {
115
+ const timer = setTimeout(() => {
116
+ signal?.removeEventListener("abort", stop);
117
+ resolve();
118
+ }, Math.min(100 * 2 ** (attempt - 1), 1e3));
119
+ const stop = () => {
120
+ clearTimeout(timer);
121
+ reject(signal?.reason);
122
+ };
123
+ signal?.addEventListener("abort", stop, { once: true });
124
+ });
125
+ }
126
+ async function errorOf(response, signal) {
127
+ let code = "BAD_RESPONSE";
128
+ let message = `the server answered ${response.status}`;
129
+ try {
130
+ const parsed = await response.json();
131
+ if (typeof parsed.error?.code === "string" && KNOWN_CODES.has(parsed.error.code)) code = parsed.error.code;
132
+ else if (typeof parsed.error?.code === "string") message = `the server answered ${response.status} with an unknown code, ${JSON.stringify(parsed.error.code.slice(0, 40))}`;
133
+ if (code !== "BAD_RESPONSE" && typeof parsed.error?.message === "string") message = parsed.error.message;
134
+ } catch (error) {
135
+ if (signal?.aborted) throw error;
136
+ }
137
+ return new DriveApiError(code, response.status, message);
138
+ }
139
+ async function json(method, path, init, check) {
140
+ const response = await send(method, path, init);
141
+ let value;
142
+ try {
143
+ value = await response.json();
144
+ } catch (error) {
145
+ if (init.signal?.aborted) throw error;
146
+ if (!(error instanceof SyntaxError)) throw new DriveApiError("NETWORK_ERROR", 0, "the connection dropped while the body came in");
147
+ throw new DriveApiError("BAD_RESPONSE", response.status, "the answer is not JSON");
148
+ }
149
+ if (!check(value)) throw new DriveApiError("BAD_RESPONSE", response.status, "the answer is not the shape that the API sends");
150
+ return value;
151
+ }
152
+ async function bytes(path, init, verify = false) {
153
+ const response = await send("GET", path, init);
154
+ let data;
155
+ try {
156
+ data = new Uint8Array(await response.arrayBuffer());
157
+ } catch (error) {
158
+ if (init.signal?.aborted) throw error;
159
+ throw new DriveApiError("NETWORK_ERROR", 0, "the connection dropped while the body came in");
160
+ }
161
+ const sha256 = response.headers.get("x-content-sha256") ?? "";
162
+ if (verify) {
163
+ if (!/^[0-9a-f]{64}$/.test(sha256)) throw new DriveApiError("BAD_RESPONSE", response.status, "the answer has no valid x-content-sha256");
164
+ if (typeof crypto === "undefined" || !crypto.subtle) throw new DriveApiError("BAD_RESPONSE", response.status, "this runtime has no WebCrypto, so the bytes cannot be checked. Use https.");
165
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", data));
166
+ if (Array.from(digest, (b) => b.toString(16).padStart(2, "0")).join("") !== sha256) throw new DriveApiError("BAD_RESPONSE", response.status, "the bytes do not match their hash");
167
+ }
168
+ return { data, contentType: response.headers.get("content-type") ?? "application/octet-stream", sha256 };
169
+ }
170
+ const link = (token2) => {
171
+ if (token2 === void 0) return void 0;
172
+ if (!/^[A-Za-z0-9_-]{1,512}$/.test(token2)) throw new TypeError("a link token has 1 to 512 letters, digits, - or _");
173
+ return { "x-link-token": token2 };
174
+ };
175
+ const id = (value) => {
176
+ if (typeof value !== "string" || !/^[A-Za-z0-9_-][A-Za-z0-9_.-]*$/.test(value) || value === "." || value === "..") throw new TypeError("an item id has letters, digits, -, _ and . (not first), and is not . or ..");
177
+ return value;
178
+ };
179
+ const isUrl = shape((v) => typeof v.url === "string" && /^https?:\/\//.test(v.url) && URL.canParse(v.url) && typeof v.expiresInSeconds === "number");
180
+ const isShares = shape(
181
+ (v) => listOf(shape((g) => (g.subjectKind === "human" || g.subjectKind === "agent") && text(g.subjectId) && oneOf(ROLES)(g.role)))(v.grants) && listOf(shape((l) => text(l.id) && oneOf(ROLES)(l.role) && typeof l.signedInOnly === "boolean" && orNull(num)(l.expiresAt) && typeof l.revoked === "boolean"))(v.links) && listOf(shape((i) => text(i.id) && oneOf(ROLES)(i.role) && orNull(text)(i.email) && orNull(text)(i.handle) && orNull(num)(i.expiresAt) && typeof i.accepted === "boolean"))(v.invites)
182
+ );
183
+ const isHome = shape((v) => record(v.apps) && Object.values(v.apps).every(num) && num(v.files) && listOf(shape((i) => isPathItem(i) && orNull(text)(i.app)))(v.items));
184
+ const isId = shape((v) => typeof v.id === "string");
185
+ const isLink = shape((v) => typeof v.id === "string" && typeof v.token === "string");
186
+ return {
187
+ /** The live items of a folder. */
188
+ list: (path = "/", o = {}) => json("GET", `/items${query({ path })}`, o, listOf(isItem)),
189
+ stat: (path, o = {}) => json("GET", `/stat${query({ path })}`, o, isItem),
190
+ mkdir: (path, opts = {}, o = {}) => json("POST", "/folders", { json: { path, parents: opts.parents === true }, ...o }, isItem),
191
+ /** Writes a file. A `ReadableStream` body streams in runtimes that allow it. */
192
+ upload: (path, body, opts = {}, o = {}) => json("PUT", `/files${query({ path, mime: opts.mime, parents: opts.parents === true ? true : void 0, onConflict: opts.onConflict })}`, {
193
+ body: typeof body === "string" ? new TextEncoder().encode(body) : body,
194
+ headers: { "content-type": "application/octet-stream" },
195
+ ...o
196
+ }, isItem),
197
+ /** The bytes. They are checked against the hash that the server sends. */
198
+ download: (path, o = {}) => bytes(`/files${query({ path })}`, o, true),
199
+ /** A signed link that works without a token, for `expires` seconds. You fetch the link yourself. */
200
+ url: (path, expires, o = {}) => json("GET", `/url${query({ path, expires })}`, o, isUrl),
201
+ move: (src, folder, opts = {}, o = {}) => json("POST", "/move", { json: { src, folder, ...opts }, ...o }, isItem),
202
+ trash: (path, o = {}) => json("POST", "/trash", { json: { path }, ...o }, isItem),
203
+ listTrash: (o = {}) => json("GET", "/trash", o, listOf(isItem)),
204
+ restore: (itemId, opts = {}, o = {}) => json("POST", "/restore", { json: { id: itemId, ...opts }, ...o }, isItem),
205
+ /** Deletes a trashed item for good. */
206
+ purge: (itemId, o = {}) => json("POST", "/purge", { json: { id: itemId }, ...o }, numbers("removed")),
207
+ search: (q = {}, o = {}) => json("GET", `/search${query(q)}`, o, listOf(isPathItem)),
208
+ /** Everything the caller has, as a zip. */
209
+ export: async (o = {}) => {
210
+ const { data } = await bytes("/export", o);
211
+ if (data[0] !== 80 || data[1] !== 75) throw new DriveApiError("BAD_RESPONSE", 200, "the answer is not a zip");
212
+ return data;
213
+ },
214
+ /** Erases the caller for good. The client sends `confirm=yes`. */
215
+ deleteAccount: (o = {}) => json("DELETE", "/account?confirm=yes", o, numbers("removed", "grants", "invites", "anonymized")),
216
+ home: (q = {}, o = {}) => json("GET", `/home${query(q)}`, o, isHome),
217
+ registerDocument: (input, o = {}) => json("POST", "/documents", { json: input, ...o }, isItem),
218
+ forgetDocument: (ref, o = {}) => json("DELETE", `/documents${query({ ref })}`, o, numbers("trashed")),
219
+ shares: (path, o = {}) => json("GET", `/shares${query({ path })}`, o, isShares),
220
+ share: (path, subject, role, o = {}) => json("POST", "/shares", { json: { path, subjectId: subject.id, subjectKind: subject.kind, role }, ...o }, isOk),
221
+ unshare: (path, subject, o = {}) => json("DELETE", `/shares${query({ path, subjectId: subject.id, subjectKind: subject.kind })}`, o, isOk),
222
+ /** Invite by `email` (a person) or `handle` (an agent). It sends no message. */
223
+ invite: (path, input, o = {}) => json("POST", "/invites", { json: { path, ...input }, ...o }, isId),
224
+ acceptInvite: (inviteId, o = {}) => json("POST", "/invites/accept", { json: { id: inviteId }, ...o }, isOk),
225
+ /** The token of a link comes back once. */
226
+ createLink: (path, input, o = {}) => json("POST", "/links", { json: { path, ...input }, ...o }, isLink),
227
+ revokeLink: (linkId, o = {}) => json("DELETE", `/links${query({ id: linkId })}`, o, isOk),
228
+ /** The next three read a shared item by id. Pass a link token, a bearer token, or both. */
229
+ openShared: async (itemId, opts = {}) => json("GET", `/shared/${id(itemId)}`, { auth: false, headers: link(opts.linkToken), signal: opts.signal }, isItem),
230
+ listShared: async (itemId, opts = {}) => json("GET", `/shared/${id(itemId)}/items`, { auth: false, headers: link(opts.linkToken), signal: opts.signal }, listOf(isItem)),
231
+ readShared: async (itemId, opts = {}) => {
232
+ const { data, contentType } = await bytes(`/shared/${id(itemId)}/content`, { auth: false, headers: link(opts.linkToken), signal: opts.signal });
233
+ return { data, contentType };
234
+ }
235
+ };
236
+ }
237
+ export {
238
+ DriveApiError,
239
+ createClient
240
+ };
@@ -1,5 +1,5 @@
1
1
  import * as agentic_identity from 'agentic-identity';
2
- import { PrincipalContext, Store, Blobs, createSharing, Role } from 'agentic-identity';
2
+ import { PrincipalContext, Store, Blobs, createSharing, Subject, Role, SubjectKind } from 'agentic-identity';
3
3
 
4
4
  declare const ROOT_ID = "root";
5
5
  declare const ACTOR_KINDS: readonly ["human", "agent_of_human", "agent_of_agent", "agent", "user_token"];
@@ -87,6 +87,13 @@ declare function parseName(raw: string): {
87
87
  name: string;
88
88
  key: string;
89
89
  };
90
+ /**
91
+ * The kind that holds a role, for the kind of a principal at the root of a chain. Only the root of a chain holds a
92
+ * role, so only `human` and `agent` map. Any other kind, a delegated one too, is an error: it never defaults to a person.
93
+ */
94
+ declare function subjectKindOf(kind: unknown): SubjectKind;
95
+ /** A subject from input: both parts are checked. */
96
+ declare function parseSubject(input: unknown): Subject;
90
97
  declare function createDrive(deps: DriveDeps): {
91
98
  stat(ctx: Ctx, loc: string): Promise<Node>;
92
99
  /** Live children, sorted by name. */
@@ -193,7 +200,7 @@ declare function createDrive(deps: DriveDeps): {
193
200
  exportPerson(ctx: Ctx): Promise<{
194
201
  subject: {
195
202
  id: string;
196
- kind: "human" | "agent_of_human" | "agent_of_agent" | "agent" | "user_token" | null;
203
+ kind: "human" | "agent" | "agent_of_human" | "agent_of_agent" | "user_token" | null;
197
204
  };
198
205
  partial: boolean;
199
206
  authored: {
@@ -220,6 +227,7 @@ declare function createDrive(deps: DriveDeps): {
220
227
  }[];
221
228
  shares: {
222
229
  held: {
230
+ subjectKind: unknown;
223
231
  subjectId: unknown;
224
232
  resourceId: unknown;
225
233
  role: unknown;
@@ -247,7 +255,7 @@ declare function createDrive(deps: DriveDeps): {
247
255
  * they hold and the invites they or their agents sent, on drive items only: the grants of other apps stay for
248
256
  * those apps and for identity. What they or their agents made in other drives stays, because it belongs to
249
257
  * those drives: the id becomes "erased", and the kind stays, so machine-made work keeps its label. The audit
250
- * trail stays: it holds ids and kinds only. A failed call can run again.
258
+ * 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.
251
259
  */
252
260
  erasePerson(ctx: Ctx): Promise<{
253
261
  removed: number;
@@ -256,8 +264,8 @@ declare function createDrive(deps: DriveDeps): {
256
264
  anonymized: number;
257
265
  }>;
258
266
  /** Gives a person or an agent a role on an item and everything under it. Only an owner may. */
259
- shareWith(ctx: Ctx, loc: string, subjectId: string, role: Role): Promise<void>;
260
- unshare(ctx: Ctx, loc: string, subjectId: string): Promise<void>;
267
+ shareWith(ctx: Ctx, loc: string, subject: Subject, role: Role): Promise<void>;
268
+ unshare(ctx: Ctx, loc: string, subject: Subject): Promise<void>;
261
269
  /** Invite by `email` (a person) or `handle` (an agent). The invite lasts `expiresInMs`, if given. It sends no message. */
262
270
  invite(ctx: Ctx, loc: string, input: {
263
271
  role: Role;
@@ -285,6 +293,7 @@ declare function createDrive(deps: DriveDeps): {
285
293
  /** Who can reach an item, and by which links and invites. Only an owner may look. Link tokens are never shown. */
286
294
  shares(ctx: Ctx, loc: string): Promise<{
287
295
  grants: {
296
+ subjectKind: agentic_identity.Json;
288
297
  subjectId: agentic_identity.Json;
289
298
  role: agentic_identity.Json;
290
299
  }[];
@@ -322,4 +331,4 @@ declare function createDrive(deps: DriveDeps): {
322
331
  }>;
323
332
  };
324
333
 
325
- export { type ActorKind as A, type Ctx as C, type Drive as D, type ErrorCode as E, type Node as N, type OnConflict as O, ROOT_ID as R, type SearchQuery as S, type Actor as a, ACTOR_KINDS as b, type Action as c, type Can as d, type DriveDeps as e, DriveError as f, createDrive as g, parseName as p };
334
+ export { type ActorKind as A, type Ctx as C, type Drive as D, type ErrorCode as E, type Node as N, type OnConflict as O, ROOT_ID as R, type SearchQuery as S, type Actor as a, ACTOR_KINDS as b, type Action as c, type Can as d, type DriveDeps as e, DriveError as f, createDrive as g, parseSubject as h, parseName as p, subjectKindOf as s };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { O as OnConflict, D as Drive, C as Ctx, N as Node, A as ActorKind, a as Actor } from './drive-DKOs1FyM.js';
2
- export { b as ACTOR_KINDS, c as Action, d as Can, e as DriveDeps, f as DriveError, E as ErrorCode, R as ROOT_ID, S as SearchQuery, g as createDrive, p as parseName } from './drive-DKOs1FyM.js';
1
+ import { O as OnConflict, D as Drive, C as Ctx, N as Node, A as ActorKind, a as Actor } from './drive-Dlzwukjq.js';
2
+ export { b as ACTOR_KINDS, c as Action, d as Can, e as DriveDeps, f as DriveError, E as ErrorCode, R as ROOT_ID, S as SearchQuery, g as createDrive, p as parseName, h as parseSubject, s as subjectKindOf } from './drive-Dlzwukjq.js';
3
3
  import { AdapterOptions, Blobs, Store } from 'agentic-identity';
4
4
  export { B as BackfillOptions, a as BackfillResult, b as backfillAuthorIds, c as backfillResources } from './migrate-B79gRPMu.js';
5
5
 
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  VERSION
3
- } from "./chunk-RWMBGIAS.js";
3
+ } from "./chunk-GMA5I5L6.js";
4
4
  import {
5
5
  backfillAuthorIds,
6
6
  backfillResources
7
- } from "./chunk-3LISWG7H.js";
7
+ } from "./chunk-5KEISBS4.js";
8
8
  import {
9
9
  ACTOR_KINDS,
10
10
  DEFAULT_MAX_BYTES,
@@ -16,12 +16,14 @@ import {
16
16
  createFileStore,
17
17
  openLocal,
18
18
  parseName,
19
+ parseSubject,
19
20
  publicNode,
20
21
  readZip,
21
22
  sniffMime,
23
+ subjectKindOf,
22
24
  upload,
23
25
  writeZip
24
- } from "./chunk-6CWBHSJ7.js";
26
+ } from "./chunk-VSVIJBMJ.js";
25
27
  export {
26
28
  ACTOR_KINDS,
27
29
  DEFAULT_MAX_BYTES,
@@ -36,9 +38,11 @@ export {
36
38
  createFileStore,
37
39
  openLocal,
38
40
  parseName,
41
+ parseSubject,
39
42
  publicNode,
40
43
  readZip,
41
44
  sniffMime,
45
+ subjectKindOf,
42
46
  upload,
43
47
  writeZip
44
48
  };
package/dist/mcp.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import { D as Drive, C as Ctx } from './drive-DKOs1FyM.js';
2
+ import { D as Drive, C as Ctx } from './drive-Dlzwukjq.js';
3
3
  import 'agentic-identity';
4
4
 
5
5
  declare function createMcpServer(drive: Drive, ctx: Ctx, exportDir: string): McpServer;
package/dist/mcp.js CHANGED
@@ -1,13 +1,13 @@
1
1
  import {
2
2
  VERSION
3
- } from "./chunk-RWMBGIAS.js";
3
+ } from "./chunk-GMA5I5L6.js";
4
4
  import {
5
5
  ACTOR_KINDS,
6
6
  DriveError,
7
7
  buildExport,
8
8
  publicNode,
9
9
  upload
10
- } from "./chunk-6CWBHSJ7.js";
10
+ } from "./chunk-VSVIJBMJ.js";
11
11
 
12
12
  // src/mcp.ts
13
13
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
package/dist/worker.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Hono } from 'hono';
2
2
  import { D1Like, R2Like } from 'agentic-identity/cloudflare';
3
3
  import { Store } from 'agentic-identity';
4
- import { C as Ctx, D as Drive } from './drive-DKOs1FyM.js';
4
+ import { C as Ctx, D as Drive } from './drive-Dlzwukjq.js';
5
5
  export { B as BackfillOptions, a as BackfillResult, b as backfillAuthorIds, c as backfillResources } from './migrate-B79gRPMu.js';
6
6
 
7
7
  interface WorkerEnv {
package/dist/worker.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  backfillAuthorIds,
3
3
  backfillResources
4
- } from "./chunk-3LISWG7H.js";
4
+ } from "./chunk-5KEISBS4.js";
5
5
  import {
6
6
  DEFAULT_MAX_BYTES,
7
7
  DEFAULT_URL_SECONDS,
@@ -11,7 +11,7 @@ import {
11
11
  publicNode,
12
12
  safeMime,
13
13
  upload
14
- } from "./chunk-6CWBHSJ7.js";
14
+ } from "./chunk-VSVIJBMJ.js";
15
15
 
16
16
  // src/worker.ts
17
17
  import { Hono } from "hono";
@@ -214,11 +214,11 @@ function createApp() {
214
214
  api.get("/shares", async (c) => c.json(await c.var.drive.shares(c.var.ctx, need(q(c, "path"), "path"))));
215
215
  api.post("/shares", async (c) => {
216
216
  const b = await body(c);
217
- await c.var.drive.shareWith(c.var.ctx, str(b.path, "path"), str(b.subjectId, "subjectId"), roleOf(b.role));
217
+ await c.var.drive.shareWith(c.var.ctx, str(b.path, "path"), { kind: b.subjectKind, id: str(b.subjectId, "subjectId") }, roleOf(b.role));
218
218
  return c.json({ ok: true });
219
219
  });
220
220
  api.delete("/shares", async (c) => {
221
- await c.var.drive.unshare(c.var.ctx, need(q(c, "path"), "path"), need(q(c, "subjectId"), "subjectId"));
221
+ await c.var.drive.unshare(c.var.ctx, need(q(c, "path"), "path"), { kind: q(c, "subjectKind"), id: need(q(c, "subjectId"), "subjectId") });
222
222
  return c.json({ ok: true });
223
223
  });
224
224
  api.post("/invites", async (c) => {
package/llms.txt CHANGED
@@ -5,6 +5,7 @@ Store, sort, and share files and folders.
5
5
  Keywords: drive,files,folders,storage,upload,sharing,agents,mcp,cli,r2.
6
6
 
7
7
  - SDK: import { createDrive } from "agentic-drive".
8
+ - Remote SDK: import { createClient } from "agentic-drive/client". createClient({ baseUrl, token, fetch?, retries? }) has one method for each bearer route and each shared route (list, stat, mkdir, upload, download, move, trash, restore, purge, search, share, createLink, and more). A failed call throws DriveApiError with code and status. It runs in browsers, Workers, and Node 22.
8
9
  - CLI: agentic-drive ls /.
9
10
  - MCP: run agentic-drive mcp, then call drive_ls.
10
11
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-drive",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Store, sort, and share files and folders.",
5
5
  "keywords": [
6
6
  "drive",
@@ -44,6 +44,10 @@
44
44
  "types": "./dist/index.d.ts",
45
45
  "import": "./dist/index.js"
46
46
  },
47
+ "./client": {
48
+ "types": "./dist/client.d.ts",
49
+ "import": "./dist/client.js"
50
+ },
47
51
  "./worker": {
48
52
  "types": "./dist/worker.d.ts",
49
53
  "import": "./dist/worker.js"
@@ -59,7 +63,7 @@
59
63
  },
60
64
  "dependencies": {
61
65
  "@modelcontextprotocol/sdk": "^1.12.0",
62
- "agentic-identity": "^0.3.0",
66
+ "agentic-identity": "^0.4.0",
63
67
  "hono": "^4.13.14",
64
68
  "zod": "^3.25.0"
65
69
  },