agentic-drive 0.2.0 → 0.4.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,69 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ Read the breaking changes before you upgrade or deploy.
6
+
7
+ ### Breaking changes
8
+
9
+ - **The CLI acts as a real principal.** The `--owner`, `--actor`, and `--actor-kind` flags and the `AGENTIC_DRIVE_OWNER`, `_ACTOR`, and `_KIND` variables are gone. Pass an API key with `--token` or `AGENTIC_DRIVE_TOKEN`. agentic-identity resolves the key. With no key, or a revoked or unknown key, the CLI and `mcp` exit with code 3. The default owner `me` is gone.
10
+ - **`openLocal(dir)` takes a folder and returns `{ drive, store }`.** Use `resolveToken(store, token)` to get the `ctx`.
11
+ - **The Worker serves the web UI from `ui-dist`.** `wrangler.jsonc` names `ui-dist` as the assets folder. Run `npm run build:ui` before each `wrangler deploy`. Without the build, the deploy fails because `ui-dist` does not exist. A `ui-dist` left from an older build ships an old UI.
12
+ - **Paths other than `/api/*` and `/health` get the UI.** The assets answer every other path. An unknown GET path returns the UI page with status 200, not a 404 from the Worker. A client that expects a 404 for such a path must change.
13
+
14
+ ### Added
15
+
16
+ - **`principal add`, `principal revoke`, `key issue`, and `key revoke`** make local principals and keys. `principal add` takes `--of <person>` for an agent and `--scope <s>`, which repeats. `key issue` takes `--label`. The package root also exports `addPrincipal`, `revokePrincipal`, `issueKey`, `revokeKey`, and `AuthError`.
17
+ - **A web UI.** The Worker serves it from `ui-dist` (`npm run build:ui`). It uses agentic-design and the typed client. It has a folder tree, breadcrumbs, a list and a grid, upload by button and by drop, new folder, rename, move, trash and restore, and a share dialog. An item that an agent made shows a "Created by agent" label while a person is its last editor. "Edited by" names the last editor, a person or an agent. A Playwright test with an axe WCAG 2.2 AA check covers each view in light and dark.
18
+ - **A content security policy.** The UI loads scripts and fonts only from its own origin. It allows inline styles. The UI refuses to be framed. It also sends `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer`.
19
+ - **Same-origin downloads.** The UI refuses a download link on another origin. Set `DRIVE_PUBLIC_URL` to the origin that serves the UI.
20
+ - **The UI ships in the package.** `npm pack` runs `build:ui` in `prepack`, and the tarball has `ui-dist`.
21
+
22
+ ### Changed
23
+
24
+ - `npm run ci:local` and CI also build the UI and run `npm run test:e2e`. The E2E test needs Chromium.
25
+ - The package adds no runtime dependency. The UI build and test tools are dev dependencies.
26
+ - The MCP registry entry names `AGENTIC_DRIVE_TOKEN` as a required secret, and `AGENTIC_DRIVE_DIR` as optional.
27
+
28
+ ### Migration
29
+
30
+ Local data from 0.3.0 belongs to the owner `me`, or to the id you passed to `--owner`. The data folder does not change. Without the key of that owner, the CLI shows an empty drive. Make the principal and a key, then use the key:
31
+
32
+ 1. Run `npx agentic-drive principal add me`, with your old owner id if it was not `me`. Pass the same `--dir` or `AGENTIC_DRIVE_DIR` as before, if you set one.
33
+ 2. Run `export AGENTIC_DRIVE_TOKEN=$(npx agentic-drive key issue me | jq -r .token)`.
34
+ 3. Put the same key in each MCP client config as `AGENTIC_DRIVE_TOKEN`, and the same data folder as `AGENTIC_DRIVE_DIR`, if you set one.
35
+
36
+ ## 0.3.0
37
+
38
+ Read the breaking changes before you upgrade. The migration steps follow them.
39
+
40
+ ### Breaking changes
41
+
42
+ - **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.
43
+ - **`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.
44
+ - **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.
45
+
46
+ ### Added
47
+
48
+ - **`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`.
49
+
50
+ ### Changed
51
+
52
+ - 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.
53
+ - 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.
54
+ - An export lists `subjectKind` on each grant a person holds.
55
+
56
+ ### Migration
57
+
58
+ 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.
59
+
60
+ 1. Install the new versions: `npm install agentic-drive@0.3.0 agentic-identity@^0.4.0`.
61
+ 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`.
62
+ 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.
63
+ 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.
64
+ 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.
65
+ 6. Change every HTTP client of `POST /shares` and `DELETE /shares` to send `subjectKind`. The new client does this.
66
+
3
67
  ## 0.2.0
4
68
 
5
69
  Read the breaking changes before you upgrade. The migration steps follow them.
package/README.md CHANGED
@@ -7,8 +7,22 @@
7
7
 
8
8
  Store, sort, and share files and folders.
9
9
 
10
- > Status: early. The library, CLI, MCP server, and HTTP API work, with sharing in the SDK and the HTTP API.
11
- > The web UI is not built yet.
10
+ > Status: 0.4.0, early. The library, CLI, MCP server, and HTTP API work, with sharing in the SDK and the HTTP API.
11
+ > A small web UI works. See [UI](#ui).
12
+
13
+ ## Live demo
14
+
15
+ A demo Worker runs at https://agentic-drive-demo.hello-fd1.workers.dev. `GET /health` answers 200, and the web UI is served at `/`.
16
+ The API needs a bearer token, so a call without one gets 401. The demo holds throwaway data only, and it can be reset at any time.
17
+
18
+ To redeploy it, set `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` in your shell, then run:
19
+
20
+ ```sh
21
+ npm run deploy:demo
22
+ ```
23
+
24
+ This uses `wrangler.demo.jsonc` (D1 in `weur`, R2 in the EU jurisdiction, `workers_dev` on). The URL signing secret lives in the Worker:
25
+ `openssl rand -hex 32 | npx wrangler secret put DRIVE_URL_SECRET -c wrangler.demo.jsonc`. Deploys are manual. No CI secret is used.
12
26
 
13
27
  ## Install
14
28
 
@@ -42,8 +56,26 @@ npx agentic-drive mcp
42
56
  ```
43
57
 
44
58
  Every command prints JSON. A failure prints `{"error":{"code","message"}}` on stderr and exits 1.
45
- Set `--dir`, `--owner`, `--actor`, and `--actor-kind` (or the `AGENTIC_DRIVE_*` variables).
46
- An agent sets `--actor-kind agent_of_human`. Each item then records that a machine made it.
59
+ Exit code 2 means bad usage. Exit code 3 means no principal, or a key that is not valid.
60
+
61
+ Every command except `help`, `version`, `principal`, and `key` needs a principal. Pass an API key with
62
+ `--token <key>` or `AGENTIC_DRIVE_TOKEN`. Prefer the variable: a flag shows in `ps`.
63
+ `agentic-identity` resolves the key. The drive belongs to the person at the top of the
64
+ principal's chain. An agent key marks each item it makes as made by a machine. A revoked,
65
+ expired, or unknown key fails with exit code 3.
66
+
67
+ ```sh
68
+ npx agentic-drive principal add alice
69
+ export AGENTIC_DRIVE_TOKEN=$(npx agentic-drive key issue alice | jq -r .token)
70
+ npx agentic-drive principal add helper --of alice # an agent of alice
71
+ npx agentic-drive key issue helper # the key shows once
72
+ npx agentic-drive key revoke <key>
73
+ npx agentic-drive principal revoke helper # stops its keys, and its agents
74
+ ```
75
+
76
+ `principal` and `key` need no key: anyone who can read the `--dir` folder can run them.
77
+ Set `--dir` (or `AGENTIC_DRIVE_DIR`) to choose the data folder.
78
+ The local drive checks the owner only. It does not apply the scopes of an agent key.
47
79
 
48
80
  ## HTTP API
49
81
 
@@ -66,14 +98,98 @@ every request. A missing, expired, or revoked credential, or a revoked or expire
66
98
  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
99
  Scopes are `drive:read`, `drive:write`, and `drive:admin`.
68
100
 
69
- Sharing routes: `GET /shares?path=`, `POST /shares`, `DELETE /shares?path=&subjectId=`, `POST /invites`,
101
+ Sharing routes: `GET /shares?path=`, `POST /shares`, `DELETE /shares?path=&subjectId=&subjectKind=`, `POST /invites`,
70
102
  `POST /invites/accept`, `POST /links`, `DELETE /links?id=`. A link visitor reads with the header
71
103
  `x-link-token` on `GET /shared/:id`, `GET /shared/:id/items`, and `GET /shared/:id/content`.
72
104
  Name a shared item by its id. A path can start at a folder id: `<folder id>/notes/a.txt`.
73
- Without `DRIVE_URL_SECRET` or `DRIVE_PUBLIC_URL`, every request gets 503.
105
+ Without `DRIVE_URL_SECRET` or `DRIVE_PUBLIC_URL`, every API request gets 503. `/health` still answers.
74
106
  Create the R2 bucket with `--jurisdiction eu` and the D1 database with `--location weur`.
75
107
  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
108
 
109
+ ## SDK
110
+
111
+ `agentic-drive/client` is a typed client for the HTTP API. It uses only `fetch`, so it runs in browsers, Workers, and Node 22.
112
+
113
+ ```ts
114
+ import { createClient, DriveApiError } from "agentic-drive/client";
115
+
116
+ const drive = createClient({ baseUrl: "https://drive.example.com", token: process.env.DRIVE_TOKEN });
117
+ await drive.mkdir("/docs", { parents: true });
118
+ const file = await drive.upload("/docs/a.txt", "hello", { mime: "text/plain" });
119
+ const { data } = await drive.download(file.id);
120
+ try {
121
+ await drive.stat("/missing");
122
+ } catch (error) {
123
+ if (error instanceof DriveApiError) console.log(error.code, error.status); // NOT_FOUND 404
124
+ }
125
+ ```
126
+
127
+ - 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`.
128
+ - 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`.
129
+ - 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`.
130
+ - 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.
131
+ - `fetch` is an option, for tests and for runtimes with their own. `openShared`, `listShared`, and `readShared` work without a token, with a `linkToken`.
132
+ - 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`.
133
+ - A browser page needs the Worker on its own origin for now: the Worker sends no CORS headers.
134
+ - Share with a subject: `share("/pub", { kind: "human", id: "u2" }, "viewer")`.
135
+
136
+ ## UI
137
+
138
+ The Worker serves a small web UI. It uses the [agentic-design](https://www.npmjs.com/package/agentic-design) package and the typed client of this package (`agentic-drive/client`). It has a folder tree, breadcrumbs, a file list and grid, upload by button or by drag and drop, new folder, rename, move, trash and restore, and a share dialog with roles and a link. An item that an agent made shows a "Created by agent" label while a person is its last editor. "Edited by" names the last editor, a person or an agent. It works with the keyboard. An automated axe check for WCAG 2.2 AA passes on each view in the light and dark themes.
139
+
140
+ Run it locally:
141
+
142
+ ```sh
143
+ npm ci
144
+ npm run build:ui
145
+ WRANGLER_SEND_METRICS=false npx wrangler dev --local \
146
+ --var DRIVE_PUBLIC_URL:http://127.0.0.1:8787 \
147
+ --var DRIVE_URL_SECRET:<32 or more characters> --var DRIVE_REGION:unknown
148
+ ```
149
+
150
+ Open `http://127.0.0.1:8787` and sign in with a bearer token. The tab keeps the token until you close it. A fresh local database has no tokens. `e2e/serve.mjs` shows how to seed a person and an agent.
151
+
152
+ Test it with `npm run test:e2e`. The test starts `wrangler dev` on a seeded database, drives the UI in Chromium, and runs an axe WCAG 2.2 AA check on each view in both themes. Run `npx playwright install chromium` once first. The run also writes the screenshots in [docs/ui](docs/ui).
153
+
154
+ Run `npm run build:ui` before `npm run test:e2e` or `wrangler deploy`, because the Worker config names the `ui-dist` folder. The UI answers with a content security policy that loads scripts and fonts only from its own origin, and it refuses to be framed.
155
+
156
+ Set `DRIVE_PUBLIC_URL` to the origin that serves the UI. A download link on another origin is refused.
157
+
158
+ ### Serve the UI from the package
159
+
160
+ The npm package ships the built UI in `ui-dist`. The package does not ship a `wrangler.jsonc`, so a project that deploys its own Worker adds the assets block to its config. Its Worker re-exports the package Worker. In a workspace, the package can sit in the root `node_modules`. Then point `assets.directory` at that folder, such as `../../node_modules/agentic-drive/ui-dist`:
161
+
162
+ ```js
163
+ // src/worker.js
164
+ export { default } from "agentic-drive/worker";
165
+ ```
166
+
167
+ ```jsonc
168
+ // wrangler.jsonc: the Worker reads the bindings DB (D1) and FILES (R2). Name yours the same.
169
+ // Create the bucket with `wrangler r2 bucket create agentic-drive-files --jurisdiction eu`.
170
+ // Create the database with `wrangler d1 create agentic-drive --location weur`, then put its id below.
171
+ {
172
+ "name": "agentic-drive",
173
+ "main": "src/worker.js",
174
+ "compatibility_date": "2026-01-01",
175
+ "compatibility_flags": ["nodejs_compat"],
176
+ "d1_databases": [{ "binding": "DB", "database_name": "agentic-drive", "database_id": "<your id>" }],
177
+ "r2_buckets": [{ "binding": "FILES", "bucket_name": "agentic-drive-files", "jurisdiction": "eu" }],
178
+ // In a workspace, point this at the root node_modules instead.
179
+ "assets": { "directory": "./node_modules/agentic-drive/ui-dist", "not_found_handling": "single-page-application", "run_worker_first": ["/api/*", "/health"] }
180
+ }
181
+ ```
182
+
183
+ Set `DRIVE_URL_SECRET` and `DRIVE_PUBLIC_URL` with `npx wrangler secret put`. `DRIVE_URL_SECRET` needs 32 bytes or more. `DRIVE_PUBLIC_URL` is the origin of this Worker. The Worker declares region `eu` unless `DRIVE_REGION` says otherwise, so keep the EU jurisdiction and location above. Sign-in uses agentic-identity credentials in the same D1 database. Create a token there first, or every bearer API request gets 401. In the D1 database:
184
+
185
+ 1. Create the table, unless a request has already run: `wrangler d1 execute agentic-drive --remote --command "CREATE TABLE IF NOT EXISTS store_docs (collection TEXT NOT NULL, id TEXT NOT NULL, body TEXT NOT NULL, version INTEGER NOT NULL DEFAULT 1, PRIMARY KEY (collection, id)) WITHOUT ROWID"`.
186
+ 2. Insert a row into `store_docs` with `collection` `principals` and `id` `me`. Its body is `{ "id": "me", "kind": "human", "scopes": ["drive:admin"] }`.
187
+ 3. Make the token with `openssl rand -hex 32`. A token may use only letters, digits, `_`, and `-`, up to 512 characters. Insert a row into `store_docs` with `collection` `credentials`. Its `id` is the lowercase hex SHA-256 of the token, which `printf %s "$TOKEN" | sha256sum` prints. Its body is `{ "principalId": "me", "kind": "api_key" }`.
188
+
189
+ The [seed script](https://github.com/pooriaarab/agentic-drive/blob/main/e2e/serve.mjs) shows these rows. Its tokens are for tests only. Never use them in a deployment. Without both secrets, the API answers 503. With this config, `/` serves the UI, and `/health` and `/api/v1/drive/*` answer from the Worker.
190
+
191
+ The UI does not make snapshot links yet. The address of a share link shows once, when you create it.
192
+
77
193
  ## Features
78
194
 
79
195
  - Folders, files, and typed references to documents in other apps (`docs:<id>`).
@@ -83,14 +199,14 @@ The Worker declares region `eu` and refuses a D1 query that a non-EU region serv
83
199
  - Each item records its size, type, owner, and who made and last changed it, with the actor kind.
84
200
  - 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
201
  - 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`.
202
+ - 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
203
  - 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
204
  - `upload()` reads a stream with a size cap, finds the type from the first bytes, and stores a SHA-256 hash.
89
205
  - `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
206
  - `search()` finds live items by name, type, kind, size, and who made them.
91
207
  - 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
208
  - `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.
209
+ - `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
210
  - A `can()` hook decides who may read, write, or delete. Without `sharing`, the default allows the owner only.
95
211
 
96
212
  ## 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,9 +1,9 @@
1
1
  // src/drive.ts
2
- import { MAX_FOLDER_DEPTH } from "agentic-identity";
2
+ import { EraseRefusedError, MAX_FOLDER_DEPTH, PRINCIPAL_KINDS, SUBJECT_KINDS } from "agentic-identity";
3
3
  var ROOT_ID = "root";
4
4
  var NODES = "drive_nodes";
5
5
  var NAMES = "drive_names";
6
- var ACTOR_KINDS = ["human", "agent_of_human", "agent_of_agent", "agent", "user_token"];
6
+ var ACTOR_KINDS = PRINCIPAL_KINDS;
7
7
  var DriveError = class extends Error {
8
8
  constructor(code, message) {
9
9
  super(message);
@@ -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
  };
@@ -1254,15 +1283,59 @@ function createFileBlobs(dir, options = {}) {
1254
1283
  };
1255
1284
  }
1256
1285
 
1286
+ // src/auth.ts
1287
+ import { createStoreAuth } from "agentic-identity";
1288
+ async function authenticate(request, store, now) {
1289
+ if (!/^Bearer [A-Za-z0-9_-]{1,512}$/.test(request.headers.get("authorization") ?? "")) return null;
1290
+ const principal = await createStoreAuth(store, now ? { now } : {}).authenticate(request);
1291
+ return principal ? { owner: principal.chain[0].id, actor: { id: principal.principalId, kind: principal.kind }, principal } : null;
1292
+ }
1293
+
1257
1294
  // src/local.ts
1295
+ import { createHash as createHash2 } from "crypto";
1258
1296
  import { join as join2 } from "path";
1259
- function openLocal(options) {
1260
- const kind = options.kind ?? "human";
1261
- if (!ACTOR_KINDS.includes(kind)) throw new Error(`unknown actor kind "${kind}"`);
1262
- return {
1263
- drive: createDrive({ store: createFileStore(join2(options.dir, "db")), blobs: createFileBlobs(join2(options.dir, "blobs")) }),
1264
- ctx: { owner: options.owner, actor: { id: options.actor ?? options.owner, kind } }
1265
- };
1297
+ import { isScope, issueCredential, revokeCredential } from "agentic-identity";
1298
+ function openLocal(dir) {
1299
+ const store = createFileStore(join2(dir, "db"));
1300
+ return { store, drive: createDrive({ store, blobs: createFileBlobs(join2(dir, "blobs")) }) };
1301
+ }
1302
+ var AuthError = class extends Error {
1303
+ code = "UNAUTHENTICATED";
1304
+ };
1305
+ var NO_TOKEN = "no principal: pass --token <key> or set AGENTIC_DRIVE_TOKEN to an API key";
1306
+ var BAD_TOKEN = "the key is not valid: it is unknown, revoked, or expired, or its principal is revoked";
1307
+ async function resolveToken(store, token) {
1308
+ if (!token) throw new AuthError(NO_TOKEN);
1309
+ if (!/^[A-Za-z0-9_-]{1,512}$/.test(token)) throw new AuthError(BAD_TOKEN);
1310
+ const ctx = await authenticate(new Request("http://local", { headers: { authorization: `Bearer ${token}` } }), store);
1311
+ if (!ctx) throw new AuthError(BAD_TOKEN);
1312
+ return ctx;
1313
+ }
1314
+ async function addPrincipal(store, id, opts) {
1315
+ if (!/^[A-Za-z0-9_.@-]{1,128}$/.test(id)) throw new Error(`invalid principal id "${id}"`);
1316
+ const scopes = opts.scopes?.length ? opts.scopes : [opts.parent ? "drive:write" : "drive:admin"];
1317
+ const bad = scopes.find((s) => !isScope(s));
1318
+ if (bad !== void 0) throw new Error(`invalid scope "${bad}"`);
1319
+ if (await store.get("principals", id)) throw new Error(`principal "${id}" exists`);
1320
+ if (opts.parent && !await store.get("principals", opts.parent)) throw new Error(`parent "${opts.parent}" does not exist`);
1321
+ const parent = opts.parent ? await store.get("principals", opts.parent) : null;
1322
+ if (parent && parent.kind !== "human") throw new Error(`parent "${opts.parent}" is not a person`);
1323
+ const doc = opts.parent ? { id, kind: "agent_of_human", parentId: opts.parent, scopes } : { id, kind: "human", scopes };
1324
+ await store.put("principals", id, doc);
1325
+ return doc;
1326
+ }
1327
+ async function revokePrincipal(store, id) {
1328
+ const doc = await store.get("principals", id);
1329
+ if (!doc) throw new DriveError("NOT_FOUND", `principal "${id}" does not exist`);
1330
+ await store.put("principals", id, { ...doc, revokedAt: Date.now() });
1331
+ }
1332
+ async function issueKey(store, principalId, label) {
1333
+ if (!await store.get("principals", principalId)) throw new Error(`principal "${principalId}" does not exist`);
1334
+ return issueCredential(store, { principalId, kind: "api_key", ...label ? { label } : {} });
1335
+ }
1336
+ async function revokeKey(store, token) {
1337
+ if (!await store.get("credentials", createHash2("sha256").update(token).digest("hex"))) throw new DriveError("NOT_FOUND", "no such key");
1338
+ await revokeCredential(store, token, Date.now());
1266
1339
  }
1267
1340
  function publicNode(node, path) {
1268
1341
  const { nameKey: _nameKey, blobKey: _blobKey, purging: _purging, ...rest } = node;
@@ -1337,13 +1410,23 @@ export {
1337
1410
  safeMime,
1338
1411
  parseName,
1339
1412
  rootResource,
1413
+ subjectKindOf,
1414
+ parseSubject,
1340
1415
  createDrive,
1341
1416
  writeZip,
1342
1417
  readZip,
1343
1418
  buildExport,
1344
1419
  createFileStore,
1345
1420
  createFileBlobs,
1421
+ authenticate,
1346
1422
  openLocal,
1423
+ AuthError,
1424
+ NO_TOKEN,
1425
+ resolveToken,
1426
+ addPrincipal,
1427
+ revokePrincipal,
1428
+ issueKey,
1429
+ revokeKey,
1347
1430
  publicNode,
1348
1431
  DEFAULT_MAX_BYTES,
1349
1432
  sniffMime,
@@ -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.4.0" : readPackageVersion();
4
4
 
5
5
  export {
6
6
  VERSION
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  ROOT_ID,
3
3
  rootResource
4
- } from "./chunk-6CWBHSJ7.js";
4
+ } from "./chunk-ATJDMCRV.js";
5
5
 
6
6
  // src/migrate.ts
7
7
  var PAGE = 10;