agentic-drive 0.4.0 → 0.6.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 +19 -0
- package/README.md +88 -15
- package/dist/{chunk-L7GASKBQ.js → chunk-BXFK3QXK.js} +1 -1
- package/dist/{chunk-DRACF66Y.js → chunk-ES3NZ7SX.js} +1 -1
- package/dist/chunk-S4AGCKXW.js +241 -0
- package/dist/{chunk-ATJDMCRV.js → chunk-WJZNXLTR.js} +202 -200
- package/dist/cli.js +2 -2
- package/dist/client.d.ts +5 -2
- package/dist/client.js +4 -236
- package/dist/{drive-Cn3mpuDW.d.ts → drive-BtRyexUP.d.ts} +1 -1
- package/dist/embed.css +154 -0
- package/dist/embed.d.ts +21 -0
- package/dist/embed.js +412 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +3 -3
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +2 -2
- package/dist/worker.d.ts +23 -4
- package/dist/worker.js +48 -23
- package/llms.txt +5 -0
- package/package.json +23 -3
- package/ui-dist/assets/{index-xUEd8lQS.css → index-B9nizMSQ.css} +1 -1
- package/ui-dist/assets/index-DQzn7NQP.js +49 -0
- package/ui-dist/index.html +2 -2
- package/ui-dist/assets/index-e_cefh0u.js +0 -49
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`createApp` takes an injected store, blobs, and auth.** `createApp({ store, blobs, auth })` from `agentic-drive/worker` lets a host serve the drive routes with its own `Store`, `Blobs`, and sign-in callback. Each part may be a function of the Worker env. With no options, the Worker works as before. A Worker with no `DB` or `FILES` binding and no injected store or blobs now answers 503 `NOT_CONFIGURED`. The package also exports the `AppOptions` type.
|
|
8
|
+
- **Mountable UI at `agentic-drive/ui`.** `mount(element, { baseUrl, getToken })` renders the drive UI in any page and returns `{ update, unmount }`. `getToken` runs before each request. The styles are in `agentic-drive/ui.css`. The embed keeps its folder in memory, so it does not change the page address. `react` and `react-dom` 19 are optional peer dependencies, and `agentic-design` is now a dependency.
|
|
9
|
+
|
|
10
|
+
## 0.5.0
|
|
11
|
+
|
|
12
|
+
Read the breaking changes before you upgrade or deploy.
|
|
13
|
+
|
|
14
|
+
### Breaking changes
|
|
15
|
+
|
|
16
|
+
- **Deploy the Worker and the client together.** Each item in the HTTP, CLI, and MCP output has a `machineMade` field next to `createdBy.kind`. The client now rejects an item with no `machineMade`. A client of 0.5.0 that talks to a Worker of 0.4.0 fails every call that returns items, such as list, stat, and search. Also, `Item` and `PathItem` in the client types now require `machineMade: boolean`. A project that builds those objects itself, such as test fixtures or mocks of the client, must add the field.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- **Machine-made label from creation on.** The web UI shows "Created by agent" on every item that an agent made, not only after a person edits it.
|
|
21
|
+
|
|
3
22
|
## 0.4.0
|
|
4
23
|
|
|
5
24
|
Read the breaking changes before you upgrade or deploy.
|
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
Store, sort, and share files and folders.
|
|
9
9
|
|
|
10
|
-
> Status: 0.
|
|
10
|
+
> Status: 0.6.0, early. The library, CLI, MCP server, and HTTP API work, with sharing in the SDK and the HTTP API.
|
|
11
11
|
> A small web UI works. See [UI](#ui).
|
|
12
12
|
|
|
13
13
|
## Live demo
|
|
@@ -47,18 +47,7 @@ await drive.trash(me, "/reports/2026");
|
|
|
47
47
|
|
|
48
48
|
## CLI and MCP
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
npx agentic-drive mkdir -p /reports
|
|
52
|
-
npx agentic-drive upload ./q1.pdf /reports/
|
|
53
|
-
npx agentic-drive search --name q1
|
|
54
|
-
npx agentic-drive export ./everything.zip
|
|
55
|
-
npx agentic-drive mcp
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
Every command prints JSON. A failure prints `{"error":{"code","message"}}` on stderr and exits 1.
|
|
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
|
|
50
|
+
Every command except `help`, `--version`, `principal`, and `key` needs a principal. Pass an API key with
|
|
62
51
|
`--token <key>` or `AGENTIC_DRIVE_TOKEN`. Prefer the variable: a flag shows in `ps`.
|
|
63
52
|
`agentic-identity` resolves the key. The drive belongs to the person at the top of the
|
|
64
53
|
principal's chain. An agent key marks each item it makes as made by a machine. A revoked,
|
|
@@ -73,10 +62,21 @@ npx agentic-drive key revoke <key>
|
|
|
73
62
|
npx agentic-drive principal revoke helper # stops its keys, and its agents
|
|
74
63
|
```
|
|
75
64
|
|
|
76
|
-
`principal` and `key` need no key: anyone who can
|
|
65
|
+
`principal` and `key` need no key: anyone who can write to the `--dir` folder can run them.
|
|
77
66
|
Set `--dir` (or `AGENTIC_DRIVE_DIR`) to choose the data folder.
|
|
78
67
|
The local drive checks the owner only. It does not apply the scopes of an agent key.
|
|
79
68
|
|
|
69
|
+
```sh
|
|
70
|
+
npx agentic-drive mkdir -p /reports
|
|
71
|
+
npx agentic-drive upload ./q1.pdf /reports/
|
|
72
|
+
npx agentic-drive search --name q1
|
|
73
|
+
npx agentic-drive export ./everything.zip
|
|
74
|
+
npx agentic-drive mcp
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Every command prints JSON, except `help`, `--version`, `mcp`, and `download` to `-`. A failure prints `{"error":{"code","message"}}` on stderr and exits 1.
|
|
78
|
+
Exit code 2 means bad usage. Exit code 3 means no principal, or a key that is not valid.
|
|
79
|
+
|
|
80
80
|
## HTTP API
|
|
81
81
|
|
|
82
82
|
A Cloudflare Worker (Hono) serves `/api/v1/drive` and mirrors the CLI. It uses D1 and R2
|
|
@@ -133,6 +133,35 @@ try {
|
|
|
133
133
|
- A browser page needs the Worker on its own origin for now: the Worker sends no CORS headers.
|
|
134
134
|
- Share with a subject: `share("/pub", { kind: "human", id: "u2" }, "viewer")`.
|
|
135
135
|
|
|
136
|
+
### Other exports
|
|
137
|
+
|
|
138
|
+
The root export, `agentic-drive`, also exports these names. The package typings give the type of each one.
|
|
139
|
+
|
|
140
|
+
- `VERSION` is the package version. `ROOT_ID` is the id of a drive's root folder, `"root"`. `ACTOR_KINDS` lists
|
|
141
|
+
the five principal kinds that `agentic-identity` names.
|
|
142
|
+
- `DEFAULT_MAX_BYTES` is the upload cap: 25 MiB (26,214,400 bytes). `sniffMime(head)` returns the type that
|
|
143
|
+
the first bytes name: PNG, JPEG, GIF, PDF, ZIP, gzip, or WebP. It returns `null` for any other bytes.
|
|
144
|
+
- `parseName(raw)` returns `{ name, key }`. It normalizes the name to NFC, and `key` is the lowercase name.
|
|
145
|
+
It throws a `DriveError` with the code `INVALID_NAME` for a blank name, `.`, `..`, a lone surrogate, a `/`,
|
|
146
|
+
a control character (U+0000 to U+001F, or U+007F), or a name over 255 bytes in UTF-8. `parseSubject(input)` returns `{ kind, id }` for a valid subject, and throws
|
|
147
|
+
`INVALID_SHARE` for any other input. `subjectKindOf(kind)` returns `human` or `agent`, and throws
|
|
148
|
+
`INVALID_SHARE` for any other kind.
|
|
149
|
+
- A `DriveError` has a `code` from `ErrorCode`, such as `NOT_FOUND` or `NAME_TAKEN`.
|
|
150
|
+
- `readZip(bytes)` reads a zip that `writeZip` made, with stored (uncompressed) entries only. It returns a `Map`
|
|
151
|
+
from each entry name to its bytes. It throws for a compressed entry. `writeZip(entries, at)` writes a zip with
|
|
152
|
+
one `{ name, data }` entry for each file, and `at` sets the time in milliseconds. It throws for an unsafe
|
|
153
|
+
name, such as one with `..` or a leading `/`.
|
|
154
|
+
- The local drive has the functions behind the CLI. `openLocal(dir)` returns `{ drive, store }` for a folder.
|
|
155
|
+
`addPrincipal(store, id, { parent?, scopes? })` makes a principal. `issueKey(store, principalId, label?)`
|
|
156
|
+
returns the key. Keep it: the store keeps no plain copy. `resolveToken(store, token)` returns the caller, or throws `AuthError`
|
|
157
|
+
with the code `UNAUTHENTICATED`. `revokeKey(store, token)` stops one key. `revokePrincipal(store, id)` stops a
|
|
158
|
+
principal, its keys, and every agent below it.
|
|
159
|
+
`publicNode(node, path?)` returns an item without its storage keys and its `purging` flag, and adds `machineMade`. When `path` is
|
|
160
|
+
given, it also adds `path`.
|
|
161
|
+
|
|
162
|
+
The local functions trust their caller. Local data is the trust boundary: anyone who can write the folder
|
|
163
|
+
can act as any principal in it.
|
|
164
|
+
|
|
136
165
|
## UI
|
|
137
166
|
|
|
138
167
|
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.
|
|
@@ -155,6 +184,26 @@ Run `npm run build:ui` before `npm run test:e2e` or `wrangler deploy`, because t
|
|
|
155
184
|
|
|
156
185
|
Set `DRIVE_PUBLIC_URL` to the origin that serves the UI. A download link on another origin is refused.
|
|
157
186
|
|
|
187
|
+
### Mount the UI in your app
|
|
188
|
+
|
|
189
|
+
`agentic-drive/ui` renders the same UI inside your own page. It needs no framework in your app. You give it an element, the address of a drive server, and a function that returns the bearer token. `mount` returns a handle.
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
import "agentic-design/styles.css"; // tokens, fonts, and base styles for body, headings, and links
|
|
193
|
+
import "agentic-drive/ui.css"; // the drive styles
|
|
194
|
+
import { mount } from "agentic-drive/ui";
|
|
195
|
+
|
|
196
|
+
const drive = mount(document.getElementById("drive")!, {
|
|
197
|
+
baseUrl: "https://drive.example.com",
|
|
198
|
+
getToken: () => session.token, // runs before every request, so it can refresh
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
drive.update({ baseUrl, getToken }); // use another server or callback
|
|
202
|
+
drive.unmount(); // remove the UI
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
`getToken` returns 1 to 512 letters, digits, `-` or `_`, or `undefined` to send no `authorization` header. A bad `baseUrl` or a `getToken` that is not a function makes `mount` throw a `TypeError`. The server still checks every request against the token. The embed shows files and trash with upload, rename, move, share, and the "Created by agent" label. It has no sign-in form, because your app owns sign-in. It does not change the page address, and it has no theme switch. `agentic-design/styles.css` sets base styles for the whole page, and it follows the dark mode of the system unless your page sets `data-theme`. Add `agentic-design` to your own dependencies, because your page imports its files. The Worker sends no CORS headers, so the server must be on the origin of your page. Set `baseUrl` to the same address as `DRIVE_PUBLIC_URL`, or the UI refuses its download links. `unmount` and `update` abort the requests that run and stop an upload that has more files to send. The UI keeps the list or grid choice in `localStorage` under `agentic-drive-view`, and it has its own heading for each view. Dialogs, menus, and messages render in `document.body`, outside your element. A click with Ctrl, Cmd, Shift, or the middle button on a folder link does not open the folder. The class names start with `drive-`. `DriveBrowser` is the same UI as a React component. It closes its requests when it unmounts. Wrap it in `TooltipProvider` and `ToastProvider` from `agentic-design/react`. `react` and `react-dom` (version 19 or later) are optional peer dependencies. Install them in your project when you use `agentic-drive/ui`. The web UI that the Worker serves uses the same views.
|
|
206
|
+
|
|
158
207
|
### Serve the UI from the package
|
|
159
208
|
|
|
160
209
|
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`:
|
|
@@ -190,6 +239,30 @@ The [seed script](https://github.com/pooriaarab/agentic-drive/blob/main/e2e/serv
|
|
|
190
239
|
|
|
191
240
|
The UI does not make snapshot links yet. The address of a share link shows once, when you create it.
|
|
192
241
|
|
|
242
|
+
### Inject a store, blobs, and sign-in
|
|
243
|
+
|
|
244
|
+
A host that has its own Worker can serve the drive routes from it. `createApp` from `agentic-drive/worker` takes the parts to use. Each part is optional. A part may be a function of the Worker env, which runs on each request.
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
import { createApp } from "agentic-drive/worker";
|
|
248
|
+
|
|
249
|
+
const app = createApp({
|
|
250
|
+
store: (env) => myStore(env), // a Store of agentic-identity
|
|
251
|
+
blobs: (env) => myBlobs(env), // a Blobs of agentic-identity
|
|
252
|
+
// Return a Ctx for the caller, or null for a 401.
|
|
253
|
+
auth: async (request, store, env) => {
|
|
254
|
+
const principal = await mySignIn(request, env);
|
|
255
|
+
return principal && { owner: principal.chain[0].id, actor: { id: principal.principalId, kind: principal.kind }, principal };
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
export default { fetch: app.fetch };
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
- With `store`, the Worker reads no `DB` binding. It does not create the table either, so the host owns the schema.
|
|
262
|
+
- With `blobs`, the Worker reads no `FILES` binding and needs no `DRIVE_PUBLIC_URL`. The blobs must sign download URLs that point to `/api/v1/drive/blob` on this Worker. That route checks the signature with `DRIVE_URL_SECRET` before it reads the store or the blobs, so set that secret.
|
|
263
|
+
- With `auth`, the Worker does not read bearer tokens from the store. The callback decides who the caller is. Return a whole `Ctx`: a `principal`, and an `owner` and `actor` that match it. Any other value gets 401. Make sure `principal.chain` and `principal.scopes` are real, because the drive checks them. A callback that reads a session cookie must check the request origin itself. Every write route is open to a cross-site form until it does.
|
|
264
|
+
- With no options, `createApp()` works as before: D1, R2, and bearer tokens from the store. `export { default } from "agentic-drive/worker"` is the same.
|
|
265
|
+
|
|
193
266
|
## Features
|
|
194
267
|
|
|
195
268
|
- Folders, files, and typed references to documents in other apps (`docs:<id>`).
|
|
@@ -217,7 +290,7 @@ The UI does not make snapshot links yet. The address of a share link shows once,
|
|
|
217
290
|
- `registerDocument` makes one item per document in a drive. Two calls at the same moment can make two.
|
|
218
291
|
|
|
219
292
|
- Drive reads the `store_docs` table in D1 and `store.jsonl` in the CLI folder. It does not read older layouts. There is no import and no claim backfill.
|
|
220
|
-
- Uploads buffer the stream in memory up to the cap. The default cap is 25 MiB.
|
|
293
|
+
- Uploads buffer the stream in memory up to the cap. The default cap is 25 MiB (`DEFAULT_MAX_BYTES`).
|
|
221
294
|
- Local file blobs return a `file:` URL that nothing serves or checks.
|
|
222
295
|
- Drive writes each new blob only when its key is free (`ifNoneMatch`). A write that finds the key taken fails with `BLOB_EXISTS`.
|
|
223
296
|
- Only `upload()` enforces the size cap. A caller of `putFile()` must check size first.
|
|
@@ -0,0 +1,241 @@
|
|
|
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: true, agent_of_human: true, agent_of_agent: true, agent: true, user_token: true };
|
|
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(Object.keys(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) && typeof v.machineMade === "boolean";
|
|
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
|
+
|
|
238
|
+
export {
|
|
239
|
+
DriveApiError,
|
|
240
|
+
createClient
|
|
241
|
+
};
|