agentic-drive 0.9.1 → 0.10.1
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 +15 -0
- package/README.md +8 -2
- package/dist/account.d.ts +28 -0
- package/dist/account.js +7 -0
- package/dist/{chunk-622NAJ6K.js → chunk-DB4F5EWD.js} +25 -207
- package/dist/chunk-ECVUDAHL.js +49 -0
- package/dist/chunk-HJXRMOUY.js +204 -0
- package/dist/{chunk-D6KW6NVM.js → chunk-XXFLENYI.js} +1 -1
- package/dist/cli.js +7 -5
- package/dist/client.d.ts +2 -2
- package/dist/{drive-BRg1Uj_3.d.ts → drive-BhMTeQ-s.d.ts} +3 -3
- package/dist/index.d.ts +3 -2
- package/dist/index.js +15 -9
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +7 -5
- package/dist/worker.d.ts +2 -2
- package/dist/worker.js +6 -4
- package/package.json +7 -3
- package/ui-dist/assets/index-BpKbn_as.css +1 -0
- package/ui-dist/assets/index-DkdU0nYK.js +49 -0
- package/ui-dist/index.html +2 -2
- package/ui-dist/assets/index-BiYPMlrf.js +0 -49
- package/ui-dist/assets/index-oybSrFGW.css +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.1
|
|
4
|
+
|
|
5
|
+
- Allow `agentic-design` versions 0.8 and 0.9 and `agentic-identity` versions 0.9 and 0.10.
|
|
6
|
+
|
|
7
|
+
## 0.10.0
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Account plug-in.** `driveAccount` (from `agentic-drive/account`, also exported from the root) has `exportAccount` and `eraseAccount`, so a host runs a GDPR export and erase without reading the drive storage layout. The export has `items`, `authored`, `agents`, `grants`, `invites`, `audit`, and `activity`. The erase returns counts, and a second erase counts zero.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- Person exports include held grants and sent invites for drive resources only. They no longer include shares for other apps. This also affects the export manifest in the SDK, CLI, MCP, and HTTP API.
|
|
16
|
+
- Reading or exporting an empty drive no longer creates its root. When no root or items exist, a delegated caller needs the `drive:read` scope.
|
|
17
|
+
|
|
3
18
|
## 0.9.1
|
|
4
19
|
|
|
5
20
|
No change to the SDK, CLI, MCP or HTTP shape. Two breaking changes for hosts that pin `agentic-design` or `agentic-identity`, see Breaking.
|
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.10.1, early. The library, CLI, MCP server, and HTTP API work, with sharing in the SDK, the HTTP API, the CLI, and the MCP server.
|
|
11
11
|
> A small web UI works. See [UI](#ui).
|
|
12
12
|
|
|
13
13
|
## Live demo
|
|
@@ -163,6 +163,12 @@ The root export, `agentic-drive`, also exports these names. The package typings
|
|
|
163
163
|
The local functions trust their caller. Local data is the trust boundary: anyone who can write the folder
|
|
164
164
|
can act as any principal in it.
|
|
165
165
|
|
|
166
|
+
## Account export and erase
|
|
167
|
+
|
|
168
|
+
A host that runs account export and erase across many packages calls `driveAccount` (from `agentic-drive/account`). It has `exportAccount(subject, { drive, ctx })` and `eraseAccount(subject, { drive, ctx })`, so the host does not read the storage layout. `subject` is an id and a kind (`human`, or `agent` with its own identity), and `ctx` is the drive context of that subject. A context for someone else throws `FORBIDDEN`. A bad subject, or a missing `drive` or `ctx`, throws a `TypeError`.
|
|
169
|
+
|
|
170
|
+
The checks of an export write access-log rows to `audit`, as every read does. That log is the record of access to personal data. The export writes no other data of the person. The export is plain JSON with `items` (your drive, trashed items flagged, file bytes as base64 in `data`), `authored` (items you made in other drives), `agents`, `grants` (shares you hold on drive items), `invites` (invites you sent on drive items), `audit`, and `activity` (the entries of your changes to items, with your kind). Only an agent with its own identity can be a subject: it gets its own `items` and empty collections. An agent that acts for a person is refused with `FORBIDDEN`. The erase removes your drive with its bytes, the shares you hold on drive items, and the invites you sent on drive items. It returns counts of `items`, `grants`, `invites`, and `anonymized`. `invites` counts only the invites sent on other drives: the invites on your own items go with your drive and are not counted. What you made in other drives stays, with your id set to `erased` and the kind kept. Only the person, not an agent, may erase: an agent subject gets `FORBIDDEN`. A second erase counts zero. The shape is provisional until `agentic-identity` adds its `AccountPackage` contract.
|
|
171
|
+
|
|
166
172
|
## UI
|
|
167
173
|
|
|
168
174
|
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.
|
|
@@ -203,7 +209,7 @@ drive.update({ baseUrl, getToken }); // use another server or callback
|
|
|
203
209
|
drive.unmount(); // remove the UI
|
|
204
210
|
```
|
|
205
211
|
|
|
206
|
-
`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
|
|
212
|
+
`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@">=0.8.0 <0.10.0"` 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-`. The design-system classes start with `agd-`. `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.
|
|
207
213
|
|
|
208
214
|
### Serve the UI from the package
|
|
209
215
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { b as Actor, D as Drive, C as Ctx } from './drive-BhMTeQ-s.js';
|
|
2
|
+
import 'agentic-identity';
|
|
3
|
+
|
|
4
|
+
interface DriveAccountDeps {
|
|
5
|
+
drive: Drive;
|
|
6
|
+
/** The subject's own context. The package checks that it names the subject, in person. */
|
|
7
|
+
ctx: Ctx;
|
|
8
|
+
}
|
|
9
|
+
type JsonRecord = Record<string, unknown>;
|
|
10
|
+
interface DriveAccount {
|
|
11
|
+
id: "drive";
|
|
12
|
+
/**
|
|
13
|
+
* Collections: `items` (the subject's own drive, trashed ones flagged, a file with its bytes as base64 in `data`),
|
|
14
|
+
* `authored` (ids of items the subject made or changed in other drives), `agents` (the drives of agents
|
|
15
|
+
* that belong to the subject, with no bytes), `grants` (shares the subject holds on drive items), `invites` (invites the subject sent on drive items),
|
|
16
|
+
* `audit` (what the subject did), and `activity` (the entries of the subject's changes to items). Only an agent with its own identity can be a subject: it gets its own `items` and empty collections, and the export does not say that these are partial. An agent that acts for a person has a context for that person, and is refused.
|
|
17
|
+
*/
|
|
18
|
+
exportAccount(subject: Actor, deps: DriveAccountDeps): Promise<Record<"items" | "authored" | "agents" | "grants" | "invites" | "audit" | "activity", JsonRecord[]>>;
|
|
19
|
+
/**
|
|
20
|
+
* Erases the subject's drive with all its bytes, the shares they hold, and the invites they sent, on drive items only.
|
|
21
|
+
* What the subject made in other drives stays, with the id set to "erased" and the kind kept. Counts by `items`, `grants`,
|
|
22
|
+
* `invites`, and `anonymized`. `invites` counts only the invites on items of other drives: the ones on the subject's own items go with the drive, and are not counted. A repeat counts what is left, so it is zero after a full run. Only the person, not an agent, may erase: an agent subject is refused.
|
|
23
|
+
*/
|
|
24
|
+
eraseAccount(subject: Actor, deps: DriveAccountDeps): Promise<Record<"items" | "grants" | "invites" | "anonymized", number>>;
|
|
25
|
+
}
|
|
26
|
+
declare const driveAccount: DriveAccount;
|
|
27
|
+
|
|
28
|
+
export { type DriveAccount, type DriveAccountDeps, driveAccount };
|
package/dist/account.js
ADDED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/drive.ts
|
|
2
|
-
import { EraseRefusedError, isMachineMade, StoreConflictError, MAX_FOLDER_DEPTH, PRINCIPAL_KINDS, ROLES, SUBJECT_KINDS } from "agentic-identity";
|
|
2
|
+
import { EraseRefusedError, isMachineMade, StoreConflictError, MAX_FOLDER_DEPTH, PRINCIPAL_KINDS, ROLES, SUBJECT_KINDS, hasScope } from "agentic-identity";
|
|
3
3
|
var ROOT_ID = "root";
|
|
4
4
|
var NODES = "drive_nodes";
|
|
5
5
|
var NAMES = "drive_names";
|
|
@@ -477,6 +477,16 @@ function createDrive(deps) {
|
|
|
477
477
|
if (!await can(ctx, "read", rootNode(ctx.owner, ctx.actor))) throw new DriveError("FORBIDDEN", "not allowed to read this drive");
|
|
478
478
|
return rawOwnerNodes(ctx.owner, withMarked);
|
|
479
479
|
}
|
|
480
|
+
async function readOwnerRoot(ctx) {
|
|
481
|
+
if (sharing && ctx.principal !== void 0 && await store.get("resources", await rootResource(ctx.owner)) === null) {
|
|
482
|
+
if ((await rawOwnerNodes(ctx.owner, true)).length === 0) {
|
|
483
|
+
if (hasScope(ctx.principal.scopes, "drive:read")) return;
|
|
484
|
+
throw new DriveError("FORBIDDEN", "not allowed to read this drive");
|
|
485
|
+
}
|
|
486
|
+
await prepare(ctx);
|
|
487
|
+
}
|
|
488
|
+
if (!await can(ctx, "read", rootNode(ctx.owner, ctx.actor))) throw new DriveError("FORBIDDEN", "not allowed to read this drive");
|
|
489
|
+
}
|
|
480
490
|
function pathIn(byId, node, liveOnly) {
|
|
481
491
|
const names = [];
|
|
482
492
|
for (let n = node, depth = 0; depth < 1e3; depth++) {
|
|
@@ -886,7 +896,8 @@ function createDrive(deps) {
|
|
|
886
896
|
},
|
|
887
897
|
/** Every item of the owner, trashed or not, with its original path and bytes. For export. */
|
|
888
898
|
async exportData(ctx) {
|
|
889
|
-
|
|
899
|
+
await readOwnerRoot(ctx);
|
|
900
|
+
const all = await rawOwnerNodes(ctx.owner, false);
|
|
890
901
|
const byId = new Map(all.map((n) => [n.id, n]));
|
|
891
902
|
const out = [];
|
|
892
903
|
for (const node of all.sort((a, b) => a.id < b.id ? -1 : 1)) {
|
|
@@ -1030,11 +1041,11 @@ function createDrive(deps) {
|
|
|
1030
1041
|
* What drive holds about the person behind a call, besides their own drive (`exportData` has that). It is
|
|
1031
1042
|
* only for the person in person: an agent gets `partial: true` and nothing else. It holds the ids of the
|
|
1032
1043
|
* items they or their agents made or changed in other drives (no names, and nothing of the people who own
|
|
1033
|
-
* them), the shares they hold
|
|
1034
|
-
* did. Invitee emails and link tokens are not in it.
|
|
1044
|
+
* them), the shares they hold and the invites they sent on drive items, and the audit entries of what they and
|
|
1045
|
+
* their agents did. Invitee emails and link tokens are not in it.
|
|
1035
1046
|
*/
|
|
1036
1047
|
async exportPerson(ctx) {
|
|
1037
|
-
await
|
|
1048
|
+
await readOwnerRoot(ctx);
|
|
1038
1049
|
const subject = subjectOf(ctx);
|
|
1039
1050
|
if (!isInPerson(ctx)) return { subject, partial: true, authored: [], agents: [], shares: { held: [], invitesCreated: [] }, audit: [], activity: [] };
|
|
1040
1051
|
const { actors, ownedAgents } = await actorsOf(store, subject.id);
|
|
@@ -1047,8 +1058,9 @@ function createDrive(deps) {
|
|
|
1047
1058
|
updatedAt: actors.includes(n.updatedById) ? n.updatedAt : null,
|
|
1048
1059
|
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))
|
|
1049
1060
|
})).sort((x, y) => x.id < y.id ? -1 : 1);
|
|
1061
|
+
const onDrive = async (resourceId) => typeof resourceId === "string" && (await store.get("resources", resourceId))?.type === "drive";
|
|
1050
1062
|
const held = [];
|
|
1051
|
-
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 });
|
|
1063
|
+
for (const [actor, kind] of heldBy(subject, ownedAgents)) for (const g of await allWhere(store, "grants", { subjectId: actor, subjectKind: kind })) if (await onDrive(g.data.resourceId)) held.push({ subjectKind: g.data.subjectKind, subjectId: g.data.subjectId, resourceId: g.data.resourceId, role: g.data.role });
|
|
1052
1064
|
const agents = [];
|
|
1053
1065
|
for (const agent of ownedAgents) {
|
|
1054
1066
|
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 }));
|
|
@@ -1058,6 +1070,7 @@ function createDrive(deps) {
|
|
|
1058
1070
|
const audit = [];
|
|
1059
1071
|
for (const actor of actors) {
|
|
1060
1072
|
for (const i of await allWhere(store, "invites", { createdBy: actor })) {
|
|
1073
|
+
if (!await onDrive(i.data.resourceId)) continue;
|
|
1061
1074
|
invitesCreated.push({ id: i.data.id, resourceId: i.data.resourceId, role: i.data.role, expiresAt: i.data.expiresAt ?? null, accepted: i.data.acceptedAt !== void 0 });
|
|
1062
1075
|
}
|
|
1063
1076
|
for (const e of await allWhere(store, "audit", { principalId: actor }, { field: "at" })) {
|
|
@@ -1223,9 +1236,10 @@ function createDrive(deps) {
|
|
|
1223
1236
|
throw error;
|
|
1224
1237
|
}
|
|
1225
1238
|
}
|
|
1226
|
-
async function prepare(ctx) {
|
|
1239
|
+
async function prepare(ctx, create = true) {
|
|
1227
1240
|
if (!sharing || ctx.principal === void 0) return;
|
|
1228
1241
|
if (ctx.principal.chain[0]?.id !== ctx.owner) throw new DriveError("FORBIDDEN", "the caller does not own this drive");
|
|
1242
|
+
if (!create) return;
|
|
1229
1243
|
const id = await rootResource(ctx.owner);
|
|
1230
1244
|
if ((await store.get("resources", id))?.type === "drive") return;
|
|
1231
1245
|
try {
|
|
@@ -1233,203 +1247,13 @@ function createDrive(deps) {
|
|
|
1233
1247
|
} catch {
|
|
1234
1248
|
}
|
|
1235
1249
|
}
|
|
1236
|
-
const guarded = (call) => async (ctx, ...args) => {
|
|
1237
|
-
await prepare(ctx);
|
|
1250
|
+
const guarded = (call, create = true) => async (ctx, ...args) => {
|
|
1251
|
+
await prepare(ctx, create);
|
|
1238
1252
|
return call(ctx, ...args);
|
|
1239
1253
|
};
|
|
1240
1254
|
const open = /* @__PURE__ */ new Set(["openShared", "listShared", "readShared"]);
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
// src/upload.ts
|
|
1245
|
-
var DEFAULT_MAX_BYTES = 25 * 1024 * 1024;
|
|
1246
|
-
var SIGNATURES = [
|
|
1247
|
-
[[137, 80, 78, 71, 13, 10, 26, 10], "image/png"],
|
|
1248
|
-
[[255, 216, 255], "image/jpeg"],
|
|
1249
|
-
[[71, 73, 70, 56], "image/gif"],
|
|
1250
|
-
[[37, 80, 68, 70, 45], "application/pdf"],
|
|
1251
|
-
[[80, 75, 3, 4], "application/zip"],
|
|
1252
|
-
[[31, 139], "application/gzip"]
|
|
1253
|
-
];
|
|
1254
|
-
function sniffMime(head) {
|
|
1255
|
-
const hit = SIGNATURES.find(([sig]) => sig.every((byte, i) => head[i] === byte));
|
|
1256
|
-
if (hit) return hit[1];
|
|
1257
|
-
const isWebp = [..."RIFF"].every((c, i) => head[i] === c.charCodeAt(0)) && [..."WEBP"].every((c, i) => head[8 + i] === c.charCodeAt(0));
|
|
1258
|
-
return isWebp ? "image/webp" : null;
|
|
1259
|
-
}
|
|
1260
|
-
function looksLikeText(head) {
|
|
1261
|
-
if (head.includes(0)) return false;
|
|
1262
|
-
for (let cut = 0; cut < 4 && cut <= head.length; cut++) {
|
|
1263
|
-
try {
|
|
1264
|
-
new TextDecoder("utf-8", { fatal: true }).decode(head.subarray(0, head.length - cut));
|
|
1265
|
-
return true;
|
|
1266
|
-
} catch {
|
|
1267
|
-
continue;
|
|
1268
|
-
}
|
|
1269
|
-
}
|
|
1270
|
-
return false;
|
|
1271
|
-
}
|
|
1272
|
-
async function* iterate(source) {
|
|
1273
|
-
if (Symbol.asyncIterator in source) {
|
|
1274
|
-
yield* source;
|
|
1275
|
-
return;
|
|
1276
|
-
}
|
|
1277
|
-
const reader = source.getReader();
|
|
1278
|
-
try {
|
|
1279
|
-
for (; ; ) {
|
|
1280
|
-
const { done, value } = await reader.read();
|
|
1281
|
-
if (done) return;
|
|
1282
|
-
yield value;
|
|
1283
|
-
}
|
|
1284
|
-
} finally {
|
|
1285
|
-
reader.releaseLock();
|
|
1286
|
-
}
|
|
1287
|
-
}
|
|
1288
|
-
async function upload(drive, ctx, path, source, opts = {}) {
|
|
1289
|
-
const max = opts.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
1290
|
-
const parts = [];
|
|
1291
|
-
let size = 0;
|
|
1292
|
-
for await (const chunk of iterate(source)) {
|
|
1293
|
-
size += chunk.byteLength;
|
|
1294
|
-
if (size > max) throw new DriveError("TOO_LARGE", `upload is over the limit of ${max} bytes`);
|
|
1295
|
-
parts.push(chunk);
|
|
1296
|
-
}
|
|
1297
|
-
const data = new Uint8Array(size);
|
|
1298
|
-
for (let at = 0, i = 0; i < parts.length; at += parts[i].byteLength, i++) data.set(parts[i], at);
|
|
1299
|
-
const head = data.subarray(0, 512);
|
|
1300
|
-
const mime = sniffMime(head) ?? (opts.mime !== void 0 ? safeMime(opts.mime) : looksLikeText(head) ? "text/plain" : "application/octet-stream");
|
|
1301
|
-
return drive.putFile(ctx, path, data, { mime, parents: opts.parents, onConflict: opts.onConflict });
|
|
1302
|
-
}
|
|
1303
|
-
|
|
1304
|
-
// src/zip.ts
|
|
1305
|
-
var CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
|
|
1306
|
-
let c = n;
|
|
1307
|
-
for (let k = 0; k < 8; k++) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
|
|
1308
|
-
return c >>> 0;
|
|
1309
|
-
});
|
|
1310
|
-
function crc32(data) {
|
|
1311
|
-
let crc = 4294967295;
|
|
1312
|
-
for (const byte of data) crc = CRC_TABLE[(crc ^ byte) & 255] ^ crc >>> 8;
|
|
1313
|
-
return (crc ^ 4294967295) >>> 0;
|
|
1314
|
-
}
|
|
1315
|
-
function safeEntryName(name) {
|
|
1316
|
-
if (name === "" || /[\\\u0000]/.test(name) || name.startsWith("/") || /^[a-zA-Z]:/.test(name)) return false;
|
|
1317
|
-
return name.split("/").every((part) => part !== "" && part !== "." && part !== "..");
|
|
1318
|
-
}
|
|
1319
|
-
function writeZip(entries, at) {
|
|
1320
|
-
for (const { name } of entries) if (!safeEntryName(name)) throw new Error(`zip: unsafe entry name ${JSON.stringify(name)}`);
|
|
1321
|
-
const d = new Date(at);
|
|
1322
|
-
const time = d.getUTCHours() << 11 | d.getUTCMinutes() << 5 | d.getUTCSeconds() >> 1;
|
|
1323
|
-
const date = Math.max(d.getUTCFullYear(), 1980) - 1980 << 9 | d.getUTCMonth() + 1 << 5 | d.getUTCDate();
|
|
1324
|
-
const parts = [];
|
|
1325
|
-
const central = [];
|
|
1326
|
-
let offset = 0;
|
|
1327
|
-
const u16 = (v, o, n) => v.setUint16(o, n, true);
|
|
1328
|
-
const u32 = (v, o, n) => v.setUint32(o, n, true);
|
|
1329
|
-
for (const entry of entries) {
|
|
1330
|
-
const name = new TextEncoder().encode(entry.name);
|
|
1331
|
-
const crc = crc32(entry.data);
|
|
1332
|
-
if (entry.data.byteLength >= 4294967295 || offset >= 4294967295 || entries.length >= 65535) throw new Error("zip: export is too large for a plain zip");
|
|
1333
|
-
const local = new DataView(new ArrayBuffer(30));
|
|
1334
|
-
u32(local, 0, 67324752);
|
|
1335
|
-
u16(local, 4, 20);
|
|
1336
|
-
u16(local, 6, 2048);
|
|
1337
|
-
u16(local, 8, 0);
|
|
1338
|
-
u16(local, 10, time);
|
|
1339
|
-
u16(local, 12, date);
|
|
1340
|
-
u32(local, 14, crc);
|
|
1341
|
-
u32(local, 18, entry.data.byteLength);
|
|
1342
|
-
u32(local, 22, entry.data.byteLength);
|
|
1343
|
-
u16(local, 26, name.length);
|
|
1344
|
-
u16(local, 28, 0);
|
|
1345
|
-
const head = new DataView(new ArrayBuffer(46));
|
|
1346
|
-
u32(head, 0, 33639248);
|
|
1347
|
-
u16(head, 4, 20);
|
|
1348
|
-
u16(head, 6, 20);
|
|
1349
|
-
u16(head, 8, 2048);
|
|
1350
|
-
u16(head, 10, 0);
|
|
1351
|
-
u16(head, 12, time);
|
|
1352
|
-
u16(head, 14, date);
|
|
1353
|
-
u32(head, 16, crc);
|
|
1354
|
-
u32(head, 20, entry.data.byteLength);
|
|
1355
|
-
u32(head, 24, entry.data.byteLength);
|
|
1356
|
-
u16(head, 28, name.length);
|
|
1357
|
-
u32(head, 42, offset);
|
|
1358
|
-
parts.push(new Uint8Array(local.buffer), name, entry.data);
|
|
1359
|
-
central.push(new Uint8Array(head.buffer), name);
|
|
1360
|
-
offset += 30 + name.length + entry.data.byteLength;
|
|
1361
|
-
}
|
|
1362
|
-
const size = central.reduce((n, p) => n + p.byteLength, 0);
|
|
1363
|
-
const end = new DataView(new ArrayBuffer(22));
|
|
1364
|
-
u32(end, 0, 101010256);
|
|
1365
|
-
u16(end, 8, entries.length);
|
|
1366
|
-
u16(end, 10, entries.length);
|
|
1367
|
-
u32(end, 12, size);
|
|
1368
|
-
u32(end, 16, offset);
|
|
1369
|
-
const all = [...parts, ...central, new Uint8Array(end.buffer)];
|
|
1370
|
-
const out = new Uint8Array(all.reduce((n, p) => n + p.byteLength, 0));
|
|
1371
|
-
let at2 = 0;
|
|
1372
|
-
for (const p of all) {
|
|
1373
|
-
out.set(p, at2);
|
|
1374
|
-
at2 += p.byteLength;
|
|
1375
|
-
}
|
|
1376
|
-
return out;
|
|
1377
|
-
}
|
|
1378
|
-
function readZip(bytes) {
|
|
1379
|
-
const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
1380
|
-
let end = bytes.byteLength - 22;
|
|
1381
|
-
while (end >= 0 && v.getUint32(end, true) !== 101010256) end--;
|
|
1382
|
-
if (end < 0) throw new Error("zip: no end signature");
|
|
1383
|
-
const count = v.getUint16(end + 10, true);
|
|
1384
|
-
const out = /* @__PURE__ */ new Map();
|
|
1385
|
-
for (let i = 0, at = v.getUint32(end + 16, true); i < count; i++) {
|
|
1386
|
-
if (v.getUint32(at, true) !== 33639248) throw new Error("zip: bad central signature");
|
|
1387
|
-
if (v.getUint16(at + 10, true) !== 0) throw new Error("zip: compressed entries are not supported");
|
|
1388
|
-
const crc = v.getUint32(at + 16, true);
|
|
1389
|
-
const size = v.getUint32(at + 24, true);
|
|
1390
|
-
const nameLen = v.getUint16(at + 28, true);
|
|
1391
|
-
const local = v.getUint32(at + 42, true);
|
|
1392
|
-
const name = new TextDecoder().decode(bytes.subarray(at + 46, at + 46 + nameLen));
|
|
1393
|
-
if (v.getUint32(local, true) !== 67324752) throw new Error("zip: bad local signature");
|
|
1394
|
-
const start = local + 30 + v.getUint16(local + 26, true) + v.getUint16(local + 28, true);
|
|
1395
|
-
const data = bytes.slice(start, start + size);
|
|
1396
|
-
if (crc32(data) !== crc) throw new Error(`zip: crc mismatch in ${name}`);
|
|
1397
|
-
out.set(name, data);
|
|
1398
|
-
at += 46 + nameLen + v.getUint16(at + 30, true) + v.getUint16(at + 32, true);
|
|
1399
|
-
}
|
|
1400
|
-
return out;
|
|
1401
|
-
}
|
|
1402
|
-
|
|
1403
|
-
// src/export.ts
|
|
1404
|
-
async function buildExport(drive, ctx, now = Date.now) {
|
|
1405
|
-
const entries = [];
|
|
1406
|
-
const items = (await drive.exportData(ctx)).map(({ node, path, data, missing }) => {
|
|
1407
|
-
const file = data ? `files/${node.id}` : null;
|
|
1408
|
-
if (data && file) entries.push({ name: file, data });
|
|
1409
|
-
return {
|
|
1410
|
-
id: node.id,
|
|
1411
|
-
owner: node.owner,
|
|
1412
|
-
path,
|
|
1413
|
-
parentId: node.parentId,
|
|
1414
|
-
kind: node.kind,
|
|
1415
|
-
name: node.name,
|
|
1416
|
-
size: node.size,
|
|
1417
|
-
mime: node.mime,
|
|
1418
|
-
hash: node.hash,
|
|
1419
|
-
ref: node.ref,
|
|
1420
|
-
createdAt: node.createdAt,
|
|
1421
|
-
updatedAt: node.updatedAt,
|
|
1422
|
-
createdBy: node.createdBy,
|
|
1423
|
-
updatedBy: node.updatedBy,
|
|
1424
|
-
trashed: node.trashed,
|
|
1425
|
-
trashedAt: node.trashedAt,
|
|
1426
|
-
file,
|
|
1427
|
-
...missing ? { missing: true } : {}
|
|
1428
|
-
};
|
|
1429
|
-
});
|
|
1430
|
-
const person = await drive.exportPerson(ctx);
|
|
1431
|
-
const manifest = { format: "agentic-drive-export", version: 2, exportedAt: now(), owner: ctx.owner, subject: person.subject, partial: person.partial, items, authored: person.authored, agents: person.agents, shares: person.shares, audit: person.audit, activity: person.activity };
|
|
1432
|
-
return writeZip([{ name: "manifest.json", data: new TextEncoder().encode(JSON.stringify(manifest, null, 2)) }, ...entries], now());
|
|
1255
|
+
const reads = /* @__PURE__ */ new Set(["exportData", "exportPerson"]);
|
|
1256
|
+
return Object.fromEntries(Object.entries(api).map(([name, call]) => [name, open.has(name) ? call : guarded(call, !reads.has(name))]));
|
|
1433
1257
|
}
|
|
1434
1258
|
|
|
1435
1259
|
// src/auth.ts
|
|
@@ -1589,12 +1413,6 @@ export {
|
|
|
1589
1413
|
subjectKindOf,
|
|
1590
1414
|
parseSubject,
|
|
1591
1415
|
createDrive,
|
|
1592
|
-
DEFAULT_MAX_BYTES,
|
|
1593
|
-
sniffMime,
|
|
1594
|
-
upload,
|
|
1595
|
-
writeZip,
|
|
1596
|
-
readZip,
|
|
1597
|
-
buildExport,
|
|
1598
1416
|
authenticate,
|
|
1599
1417
|
inviteIdentity,
|
|
1600
1418
|
backfillAuthorIds,
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DriveError,
|
|
3
|
+
publicNode
|
|
4
|
+
} from "./chunk-DB4F5EWD.js";
|
|
5
|
+
|
|
6
|
+
// src/account.ts
|
|
7
|
+
var plain = (value) => JSON.parse(JSON.stringify(value));
|
|
8
|
+
function check(subject, deps) {
|
|
9
|
+
const { drive, ctx } = deps ?? {};
|
|
10
|
+
if (typeof drive?.exportData !== "function" || typeof drive.exportPerson !== "function" || typeof drive.erasePerson !== "function" || typeof ctx !== "object" || ctx === null) throw new TypeError("driveAccount needs deps.drive and deps.ctx");
|
|
11
|
+
const known = subject?.kind === "human" || subject?.kind === "agent";
|
|
12
|
+
if (typeof subject?.id !== "string" || subject.id.trim() === "" || !known) throw new TypeError("subject needs an id and a kind of human or agent");
|
|
13
|
+
if (ctx.owner !== subject.id || ctx.actor?.id !== subject.id || ctx.actor.kind !== subject.kind) throw new DriveError("FORBIDDEN", "context does not match the subject");
|
|
14
|
+
const p = ctx.principal;
|
|
15
|
+
if (p !== void 0 && (p?.principalId !== subject.id || p.kind !== subject.kind || p.chain?.length !== 1 || p.chain[0]?.id !== subject.id || p.chain[0]?.kind !== subject.kind)) throw new DriveError("FORBIDDEN", "context does not match the subject");
|
|
16
|
+
}
|
|
17
|
+
var b64 = (data) => {
|
|
18
|
+
let bin = "";
|
|
19
|
+
for (let i = 0; i < data.length; i += 32768) bin += String.fromCharCode(...data.subarray(i, i + 32768));
|
|
20
|
+
return btoa(bin);
|
|
21
|
+
};
|
|
22
|
+
var driveAccount = {
|
|
23
|
+
id: "drive",
|
|
24
|
+
async exportAccount(subject, deps) {
|
|
25
|
+
check(subject, deps);
|
|
26
|
+
const { drive, ctx } = deps;
|
|
27
|
+
const person = await drive.exportPerson(ctx);
|
|
28
|
+
if (person.partial && subject.kind === "human") throw new DriveError("FORBIDDEN", "the person's export is partial");
|
|
29
|
+
const items = (await drive.exportData(ctx)).map(({ node, path, data, missing }) => ({ ...publicNode(node, path), data: data ? b64(data) : null, ...missing ? { missing: true } : {} }));
|
|
30
|
+
return {
|
|
31
|
+
items: plain(items),
|
|
32
|
+
authored: plain(person.authored),
|
|
33
|
+
agents: plain(person.agents),
|
|
34
|
+
grants: plain(person.shares.held),
|
|
35
|
+
invites: plain(person.shares.invitesCreated),
|
|
36
|
+
audit: plain(person.audit),
|
|
37
|
+
activity: plain(person.activity)
|
|
38
|
+
};
|
|
39
|
+
},
|
|
40
|
+
async eraseAccount(subject, deps) {
|
|
41
|
+
check(subject, deps);
|
|
42
|
+
const { removed, grants, invites, anonymized } = await deps.drive.erasePerson(deps.ctx);
|
|
43
|
+
return { items: removed, grants, invites, anonymized };
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export {
|
|
48
|
+
driveAccount
|
|
49
|
+
};
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DriveError,
|
|
3
|
+
safeMime
|
|
4
|
+
} from "./chunk-DB4F5EWD.js";
|
|
5
|
+
|
|
6
|
+
// src/upload.ts
|
|
7
|
+
var DEFAULT_MAX_BYTES = 25 * 1024 * 1024;
|
|
8
|
+
var SIGNATURES = [
|
|
9
|
+
[[137, 80, 78, 71, 13, 10, 26, 10], "image/png"],
|
|
10
|
+
[[255, 216, 255], "image/jpeg"],
|
|
11
|
+
[[71, 73, 70, 56], "image/gif"],
|
|
12
|
+
[[37, 80, 68, 70, 45], "application/pdf"],
|
|
13
|
+
[[80, 75, 3, 4], "application/zip"],
|
|
14
|
+
[[31, 139], "application/gzip"]
|
|
15
|
+
];
|
|
16
|
+
function sniffMime(head) {
|
|
17
|
+
const hit = SIGNATURES.find(([sig]) => sig.every((byte, i) => head[i] === byte));
|
|
18
|
+
if (hit) return hit[1];
|
|
19
|
+
const isWebp = [..."RIFF"].every((c, i) => head[i] === c.charCodeAt(0)) && [..."WEBP"].every((c, i) => head[8 + i] === c.charCodeAt(0));
|
|
20
|
+
return isWebp ? "image/webp" : null;
|
|
21
|
+
}
|
|
22
|
+
function looksLikeText(head) {
|
|
23
|
+
if (head.includes(0)) return false;
|
|
24
|
+
for (let cut = 0; cut < 4 && cut <= head.length; cut++) {
|
|
25
|
+
try {
|
|
26
|
+
new TextDecoder("utf-8", { fatal: true }).decode(head.subarray(0, head.length - cut));
|
|
27
|
+
return true;
|
|
28
|
+
} catch {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
async function* iterate(source) {
|
|
35
|
+
if (Symbol.asyncIterator in source) {
|
|
36
|
+
yield* source;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const reader = source.getReader();
|
|
40
|
+
try {
|
|
41
|
+
for (; ; ) {
|
|
42
|
+
const { done, value } = await reader.read();
|
|
43
|
+
if (done) return;
|
|
44
|
+
yield value;
|
|
45
|
+
}
|
|
46
|
+
} finally {
|
|
47
|
+
reader.releaseLock();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async function upload(drive, ctx, path, source, opts = {}) {
|
|
51
|
+
const max = opts.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
52
|
+
const parts = [];
|
|
53
|
+
let size = 0;
|
|
54
|
+
for await (const chunk of iterate(source)) {
|
|
55
|
+
size += chunk.byteLength;
|
|
56
|
+
if (size > max) throw new DriveError("TOO_LARGE", `upload is over the limit of ${max} bytes`);
|
|
57
|
+
parts.push(chunk);
|
|
58
|
+
}
|
|
59
|
+
const data = new Uint8Array(size);
|
|
60
|
+
for (let at = 0, i = 0; i < parts.length; at += parts[i].byteLength, i++) data.set(parts[i], at);
|
|
61
|
+
const head = data.subarray(0, 512);
|
|
62
|
+
const mime = sniffMime(head) ?? (opts.mime !== void 0 ? safeMime(opts.mime) : looksLikeText(head) ? "text/plain" : "application/octet-stream");
|
|
63
|
+
return drive.putFile(ctx, path, data, { mime, parents: opts.parents, onConflict: opts.onConflict });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// src/zip.ts
|
|
67
|
+
var CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
|
|
68
|
+
let c = n;
|
|
69
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
|
|
70
|
+
return c >>> 0;
|
|
71
|
+
});
|
|
72
|
+
function crc32(data) {
|
|
73
|
+
let crc = 4294967295;
|
|
74
|
+
for (const byte of data) crc = CRC_TABLE[(crc ^ byte) & 255] ^ crc >>> 8;
|
|
75
|
+
return (crc ^ 4294967295) >>> 0;
|
|
76
|
+
}
|
|
77
|
+
function safeEntryName(name) {
|
|
78
|
+
if (name === "" || /[\\\u0000]/.test(name) || name.startsWith("/") || /^[a-zA-Z]:/.test(name)) return false;
|
|
79
|
+
return name.split("/").every((part) => part !== "" && part !== "." && part !== "..");
|
|
80
|
+
}
|
|
81
|
+
function writeZip(entries, at) {
|
|
82
|
+
for (const { name } of entries) if (!safeEntryName(name)) throw new Error(`zip: unsafe entry name ${JSON.stringify(name)}`);
|
|
83
|
+
const d = new Date(at);
|
|
84
|
+
const time = d.getUTCHours() << 11 | d.getUTCMinutes() << 5 | d.getUTCSeconds() >> 1;
|
|
85
|
+
const date = Math.max(d.getUTCFullYear(), 1980) - 1980 << 9 | d.getUTCMonth() + 1 << 5 | d.getUTCDate();
|
|
86
|
+
const parts = [];
|
|
87
|
+
const central = [];
|
|
88
|
+
let offset = 0;
|
|
89
|
+
const u16 = (v, o, n) => v.setUint16(o, n, true);
|
|
90
|
+
const u32 = (v, o, n) => v.setUint32(o, n, true);
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
const name = new TextEncoder().encode(entry.name);
|
|
93
|
+
const crc = crc32(entry.data);
|
|
94
|
+
if (entry.data.byteLength >= 4294967295 || offset >= 4294967295 || entries.length >= 65535) throw new Error("zip: export is too large for a plain zip");
|
|
95
|
+
const local = new DataView(new ArrayBuffer(30));
|
|
96
|
+
u32(local, 0, 67324752);
|
|
97
|
+
u16(local, 4, 20);
|
|
98
|
+
u16(local, 6, 2048);
|
|
99
|
+
u16(local, 8, 0);
|
|
100
|
+
u16(local, 10, time);
|
|
101
|
+
u16(local, 12, date);
|
|
102
|
+
u32(local, 14, crc);
|
|
103
|
+
u32(local, 18, entry.data.byteLength);
|
|
104
|
+
u32(local, 22, entry.data.byteLength);
|
|
105
|
+
u16(local, 26, name.length);
|
|
106
|
+
u16(local, 28, 0);
|
|
107
|
+
const head = new DataView(new ArrayBuffer(46));
|
|
108
|
+
u32(head, 0, 33639248);
|
|
109
|
+
u16(head, 4, 20);
|
|
110
|
+
u16(head, 6, 20);
|
|
111
|
+
u16(head, 8, 2048);
|
|
112
|
+
u16(head, 10, 0);
|
|
113
|
+
u16(head, 12, time);
|
|
114
|
+
u16(head, 14, date);
|
|
115
|
+
u32(head, 16, crc);
|
|
116
|
+
u32(head, 20, entry.data.byteLength);
|
|
117
|
+
u32(head, 24, entry.data.byteLength);
|
|
118
|
+
u16(head, 28, name.length);
|
|
119
|
+
u32(head, 42, offset);
|
|
120
|
+
parts.push(new Uint8Array(local.buffer), name, entry.data);
|
|
121
|
+
central.push(new Uint8Array(head.buffer), name);
|
|
122
|
+
offset += 30 + name.length + entry.data.byteLength;
|
|
123
|
+
}
|
|
124
|
+
const size = central.reduce((n, p) => n + p.byteLength, 0);
|
|
125
|
+
const end = new DataView(new ArrayBuffer(22));
|
|
126
|
+
u32(end, 0, 101010256);
|
|
127
|
+
u16(end, 8, entries.length);
|
|
128
|
+
u16(end, 10, entries.length);
|
|
129
|
+
u32(end, 12, size);
|
|
130
|
+
u32(end, 16, offset);
|
|
131
|
+
const all = [...parts, ...central, new Uint8Array(end.buffer)];
|
|
132
|
+
const out = new Uint8Array(all.reduce((n, p) => n + p.byteLength, 0));
|
|
133
|
+
let at2 = 0;
|
|
134
|
+
for (const p of all) {
|
|
135
|
+
out.set(p, at2);
|
|
136
|
+
at2 += p.byteLength;
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
function readZip(bytes) {
|
|
141
|
+
const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
142
|
+
let end = bytes.byteLength - 22;
|
|
143
|
+
while (end >= 0 && v.getUint32(end, true) !== 101010256) end--;
|
|
144
|
+
if (end < 0) throw new Error("zip: no end signature");
|
|
145
|
+
const count = v.getUint16(end + 10, true);
|
|
146
|
+
const out = /* @__PURE__ */ new Map();
|
|
147
|
+
for (let i = 0, at = v.getUint32(end + 16, true); i < count; i++) {
|
|
148
|
+
if (v.getUint32(at, true) !== 33639248) throw new Error("zip: bad central signature");
|
|
149
|
+
if (v.getUint16(at + 10, true) !== 0) throw new Error("zip: compressed entries are not supported");
|
|
150
|
+
const crc = v.getUint32(at + 16, true);
|
|
151
|
+
const size = v.getUint32(at + 24, true);
|
|
152
|
+
const nameLen = v.getUint16(at + 28, true);
|
|
153
|
+
const local = v.getUint32(at + 42, true);
|
|
154
|
+
const name = new TextDecoder().decode(bytes.subarray(at + 46, at + 46 + nameLen));
|
|
155
|
+
if (v.getUint32(local, true) !== 67324752) throw new Error("zip: bad local signature");
|
|
156
|
+
const start = local + 30 + v.getUint16(local + 26, true) + v.getUint16(local + 28, true);
|
|
157
|
+
const data = bytes.slice(start, start + size);
|
|
158
|
+
if (crc32(data) !== crc) throw new Error(`zip: crc mismatch in ${name}`);
|
|
159
|
+
out.set(name, data);
|
|
160
|
+
at += 46 + nameLen + v.getUint16(at + 30, true) + v.getUint16(at + 32, true);
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// src/export.ts
|
|
166
|
+
async function buildExport(drive, ctx, now = Date.now) {
|
|
167
|
+
const entries = [];
|
|
168
|
+
const items = (await drive.exportData(ctx)).map(({ node, path, data, missing }) => {
|
|
169
|
+
const file = data ? `files/${node.id}` : null;
|
|
170
|
+
if (data && file) entries.push({ name: file, data });
|
|
171
|
+
return {
|
|
172
|
+
id: node.id,
|
|
173
|
+
owner: node.owner,
|
|
174
|
+
path,
|
|
175
|
+
parentId: node.parentId,
|
|
176
|
+
kind: node.kind,
|
|
177
|
+
name: node.name,
|
|
178
|
+
size: node.size,
|
|
179
|
+
mime: node.mime,
|
|
180
|
+
hash: node.hash,
|
|
181
|
+
ref: node.ref,
|
|
182
|
+
createdAt: node.createdAt,
|
|
183
|
+
updatedAt: node.updatedAt,
|
|
184
|
+
createdBy: node.createdBy,
|
|
185
|
+
updatedBy: node.updatedBy,
|
|
186
|
+
trashed: node.trashed,
|
|
187
|
+
trashedAt: node.trashedAt,
|
|
188
|
+
file,
|
|
189
|
+
...missing ? { missing: true } : {}
|
|
190
|
+
};
|
|
191
|
+
});
|
|
192
|
+
const person = await drive.exportPerson(ctx);
|
|
193
|
+
const manifest = { format: "agentic-drive-export", version: 2, exportedAt: now(), owner: ctx.owner, subject: person.subject, partial: person.partial, items, authored: person.authored, agents: person.agents, shares: person.shares, audit: person.audit, activity: person.activity };
|
|
194
|
+
return writeZip([{ name: "manifest.json", data: new TextEncoder().encode(JSON.stringify(manifest, null, 2)) }, ...entries], now());
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export {
|
|
198
|
+
DEFAULT_MAX_BYTES,
|
|
199
|
+
sniffMime,
|
|
200
|
+
upload,
|
|
201
|
+
writeZip,
|
|
202
|
+
readZip,
|
|
203
|
+
buildExport
|
|
204
|
+
};
|