agentic-drive 0.0.0-stage → 0.2.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 +112 -0
- package/LICENSE +21 -0
- package/README.md +134 -2
- package/SKILL.md +25 -0
- package/dist/chunk-3LISWG7H.js +65 -0
- package/dist/chunk-6CWBHSJ7.js +1351 -0
- package/dist/chunk-RWMBGIAS.js +7 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +235 -0
- package/dist/drive-DKOs1FyM.d.ts +325 -0
- package/dist/index.d.ts +108 -0
- package/dist/index.js +44 -0
- package/dist/mcp.d.ts +12 -0
- package/dist/mcp.js +116 -0
- package/dist/migrate-B79gRPMu.d.ts +29 -0
- package/dist/worker.d.ts +36 -0
- package/dist/worker.js +307 -0
- package/llms.txt +14 -0
- package/package.json +75 -4
|
@@ -0,0 +1,1351 @@
|
|
|
1
|
+
// src/drive.ts
|
|
2
|
+
import { MAX_FOLDER_DEPTH } from "agentic-identity";
|
|
3
|
+
var ROOT_ID = "root";
|
|
4
|
+
var NODES = "drive_nodes";
|
|
5
|
+
var NAMES = "drive_names";
|
|
6
|
+
var ACTOR_KINDS = ["human", "agent_of_human", "agent_of_agent", "agent", "user_token"];
|
|
7
|
+
var DriveError = class extends Error {
|
|
8
|
+
constructor(code, message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.name = "DriveError";
|
|
12
|
+
}
|
|
13
|
+
code;
|
|
14
|
+
};
|
|
15
|
+
var MAX_PATH_SEGMENTS = 64;
|
|
16
|
+
var DEFAULT_MAX_DEPTH = 256;
|
|
17
|
+
var CHUNK = 25;
|
|
18
|
+
var SHARED_CHUNK = 10;
|
|
19
|
+
var HEIGHT_BUDGET = 200;
|
|
20
|
+
var DEFAULT_URL_SECONDS = 900;
|
|
21
|
+
var MAX_URL_SECONDS = 3600;
|
|
22
|
+
var OPAQUE = "application/octet-stream";
|
|
23
|
+
var MIME_SHAPE = /^[a-z0-9][a-z0-9!#$&^_.+-]{0,63}\/[a-z0-9][a-z0-9!#$&^_.+-]{0,63}$/;
|
|
24
|
+
var ACTIVE_TYPES = /* @__PURE__ */ new Set([
|
|
25
|
+
"text/html",
|
|
26
|
+
"application/xhtml+xml",
|
|
27
|
+
"image/svg+xml",
|
|
28
|
+
"text/xml",
|
|
29
|
+
"application/xml",
|
|
30
|
+
"text/javascript",
|
|
31
|
+
"application/javascript",
|
|
32
|
+
"application/x-javascript",
|
|
33
|
+
"text/ecmascript",
|
|
34
|
+
"application/ecmascript",
|
|
35
|
+
"text/css"
|
|
36
|
+
]);
|
|
37
|
+
function safeMime(claimed) {
|
|
38
|
+
const type = (claimed ?? "").trim().toLowerCase();
|
|
39
|
+
return MIME_SHAPE.test(type) && !ACTIVE_TYPES.has(type) ? type : OPAQUE;
|
|
40
|
+
}
|
|
41
|
+
async function sha256Hex(data) {
|
|
42
|
+
const digest = await globalThis.crypto.subtle.digest("SHA-256", data);
|
|
43
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
44
|
+
}
|
|
45
|
+
var REF = /^[a-z][a-z0-9_-]*:[^\s/]+$/;
|
|
46
|
+
function parseName(raw) {
|
|
47
|
+
const name = raw.normalize("NFC");
|
|
48
|
+
if (name.trim() === "" || /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/.test(name) || name === "." || name === ".." || /[/\u0000-\u001f\u007f]/.test(name) || new TextEncoder().encode(name).length > 255) {
|
|
49
|
+
throw new DriveError("INVALID_NAME", `invalid name ${JSON.stringify(raw)}`);
|
|
50
|
+
}
|
|
51
|
+
return { name, key: name.toLowerCase() };
|
|
52
|
+
}
|
|
53
|
+
function splitPath(path) {
|
|
54
|
+
if (!path.startsWith("/")) throw new DriveError("INVALID_PATH", `path must start with "/": ${path}`);
|
|
55
|
+
const rest = path.replace(/\/$/, "").slice(1);
|
|
56
|
+
if (rest === "") return [];
|
|
57
|
+
const parts = rest.split("/");
|
|
58
|
+
if (parts.includes("")) throw new DriveError("INVALID_PATH", `empty segment in path: ${path}`);
|
|
59
|
+
if (parts.length > MAX_PATH_SEGMENTS) throw new DriveError("TOO_DEEP", `a path has at most ${MAX_PATH_SEGMENTS} segments`);
|
|
60
|
+
return parts.map(parseName);
|
|
61
|
+
}
|
|
62
|
+
function batch() {
|
|
63
|
+
const writes = [];
|
|
64
|
+
const fresh = /* @__PURE__ */ new Set();
|
|
65
|
+
const depths = /* @__PURE__ */ new Map();
|
|
66
|
+
return {
|
|
67
|
+
fresh,
|
|
68
|
+
depths,
|
|
69
|
+
put: (collection, id, doc) => void writes.push({ collection, id, doc }),
|
|
70
|
+
remove: (collection, id) => void writes.push({ collection, id, doc: null }),
|
|
71
|
+
async apply(ops) {
|
|
72
|
+
for (const w of writes) await (w.doc ? ops.put(w.collection, w.id, w.doc) : ops.delete(w.collection, w.id));
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
var claimId = (owner, parentId, key) => sha256Hex(new TextEncoder().encode(`${owner}\0${parentId}\0${key}`));
|
|
77
|
+
var rootResource = async (owner) => `drive-root-${(await sha256Hex(new TextEncoder().encode(owner))).slice(0, 32)}`;
|
|
78
|
+
var ACTION_TO_IDENTITY = { read: "read", write: "write", delete: "admin" };
|
|
79
|
+
var MAX_ITEM_DEPTH = MAX_FOLDER_DEPTH - 1;
|
|
80
|
+
var VISITOR = { id: "", kind: "human" };
|
|
81
|
+
var appOf = (node) => node.kind === "ref" && node.ref ? node.ref.split(":")[0] : null;
|
|
82
|
+
var ERASED = "erased";
|
|
83
|
+
var SHARE_ERROR = /^(invalid|give an|expiresAt|expiresInMs|resource exists|the last owner)/;
|
|
84
|
+
function createDrive(deps) {
|
|
85
|
+
const { store, blobs } = deps;
|
|
86
|
+
const now = deps.now ?? Date.now;
|
|
87
|
+
const newId = deps.newId ?? (() => globalThis.crypto.randomUUID());
|
|
88
|
+
const maxDepth = deps.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
89
|
+
const onOrphan = deps.onOrphan ?? ((key, error) => console.error(`orphan blob ${key}: ${error instanceof Error ? error.name : "delete failed"}`));
|
|
90
|
+
const sharing = deps.sharing;
|
|
91
|
+
const resourceOf = async (node) => node.id === ROOT_ID ? rootResource(node.owner) : node.id;
|
|
92
|
+
const can = deps.can ?? (sharing ? async (ctx, action, node) => ctx.principal !== void 0 && (await sharing.can(ctx.principal, ACTION_TO_IDENTITY[action], await resourceOf(node))).allowed : (ctx, _action, node) => node.owner === ctx.owner);
|
|
93
|
+
const rootNode = (owner, by) => ({
|
|
94
|
+
id: ROOT_ID,
|
|
95
|
+
owner,
|
|
96
|
+
parentId: "",
|
|
97
|
+
kind: "folder",
|
|
98
|
+
name: "",
|
|
99
|
+
nameKey: "",
|
|
100
|
+
size: 0,
|
|
101
|
+
mime: null,
|
|
102
|
+
hash: null,
|
|
103
|
+
ref: null,
|
|
104
|
+
blobKey: null,
|
|
105
|
+
createdAt: 0,
|
|
106
|
+
updatedAt: 0,
|
|
107
|
+
createdBy: by,
|
|
108
|
+
updatedBy: by,
|
|
109
|
+
createdById: by.id,
|
|
110
|
+
updatedById: by.id,
|
|
111
|
+
trashed: false,
|
|
112
|
+
trashedAt: null,
|
|
113
|
+
purging: false
|
|
114
|
+
});
|
|
115
|
+
const who = (ctx) => ({ id: ctx.actor.id, kind: ctx.actor.kind });
|
|
116
|
+
const stamp = (ctx, node) => ({ createdById: node.createdById ?? node.createdBy.id, updatedBy: who(ctx), updatedById: ctx.actor.id });
|
|
117
|
+
const resourceDoc = async (node, parent) => ({ id: node.id, type: "drive", parentId: await resourceOf(parent) });
|
|
118
|
+
async function rawNode(ops, id, owner, by, withMarked = false) {
|
|
119
|
+
const node = id === ROOT_ID ? rootNode(owner, by) : await ops.get(NODES, id);
|
|
120
|
+
return node && (withMarked || node.purging !== true) ? node : null;
|
|
121
|
+
}
|
|
122
|
+
async function fetch(ops, ctx, id, purgeAgain = false) {
|
|
123
|
+
const node = await rawNode(ops, id, ctx.owner, ctx.actor, purgeAgain);
|
|
124
|
+
return node && await can(ctx, "read", node) ? node : null;
|
|
125
|
+
}
|
|
126
|
+
async function need(ctx, action, node) {
|
|
127
|
+
if (!await can(ctx, action, node)) throw new DriveError("FORBIDDEN", `not allowed to ${action} ${node.id}`);
|
|
128
|
+
}
|
|
129
|
+
async function children(ops, owner, parentId, extra = {}) {
|
|
130
|
+
const out = [];
|
|
131
|
+
for (let cursor; ; ) {
|
|
132
|
+
const page = await ops.query(NODES, { where: { owner, parentId, ...extra }, limit: 100, cursor });
|
|
133
|
+
out.push(...page.items.map((item) => item.data));
|
|
134
|
+
if (!page.nextCursor) return out;
|
|
135
|
+
cursor = page.nextCursor;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
async function claimed(ops, owner, parentId, key) {
|
|
139
|
+
const claim = await ops.get(NAMES, await claimId(owner, parentId, key));
|
|
140
|
+
const found = claim ? await ops.get(NODES, claim.nodeId) : null;
|
|
141
|
+
const node = found && !found.trashed && found.purging !== true && found.parentId === parentId && found.nameKey === key ? found : null;
|
|
142
|
+
return { taken: claim !== null, node };
|
|
143
|
+
}
|
|
144
|
+
const liveChild = async (ops, folder, key) => (await claimed(ops, folder.owner, folder.id, key)).node;
|
|
145
|
+
async function isLive(ops, ctx, node) {
|
|
146
|
+
for (let n = node, depth = 0; n && depth < maxDepth; depth++) {
|
|
147
|
+
if (n.trashed) return false;
|
|
148
|
+
if (n.id === ROOT_ID) return true;
|
|
149
|
+
n = await rawNode(ops, n.parentId, n.owner, ctx.actor);
|
|
150
|
+
}
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
async function depthOf(ops, w, node) {
|
|
154
|
+
let depth = 0;
|
|
155
|
+
for (let n = node; n && n.id !== ROOT_ID; ) {
|
|
156
|
+
const known = w.depths.get(n.id);
|
|
157
|
+
if (known !== void 0) return depth + known;
|
|
158
|
+
if (++depth > MAX_ITEM_DEPTH) return depth;
|
|
159
|
+
n = await rawNode(ops, n.parentId, n.owner, node.createdBy);
|
|
160
|
+
if (n === null) return MAX_ITEM_DEPTH + 1;
|
|
161
|
+
}
|
|
162
|
+
return depth;
|
|
163
|
+
}
|
|
164
|
+
async function heightOf(ops, folder) {
|
|
165
|
+
let level = [folder];
|
|
166
|
+
let queries = 0;
|
|
167
|
+
for (let height = 1; height <= MAX_ITEM_DEPTH; height++) {
|
|
168
|
+
const next = [];
|
|
169
|
+
for (const f of level) {
|
|
170
|
+
for (let cursor; ; ) {
|
|
171
|
+
if (++queries > HEIGHT_BUDGET) throw new DriveError("TOO_MANY_ITEMS", `${folder.name} has too many folders to count. Move its parts one by one.`);
|
|
172
|
+
const page = await ops.query(NODES, { where: { owner: f.owner, parentId: f.id, kind: "folder" }, limit: 100, cursor });
|
|
173
|
+
next.push(...page.items.map((item) => item.data));
|
|
174
|
+
if (!page.nextCursor) break;
|
|
175
|
+
cursor = page.nextCursor;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (next.length === 0) return height;
|
|
179
|
+
level = next;
|
|
180
|
+
}
|
|
181
|
+
return MAX_ITEM_DEPTH + 1;
|
|
182
|
+
}
|
|
183
|
+
async function heightFor(ctx, node, dest) {
|
|
184
|
+
if (!sharing || node.kind !== "folder") return 0;
|
|
185
|
+
const target = typeof dest === "string" ? await resolve(store, ctx, dest) : dest;
|
|
186
|
+
if (target.id === node.parentId) return 0;
|
|
187
|
+
const w = batch();
|
|
188
|
+
if (await depthOf(store, w, target) + 1 <= await depthOf(store, w, node)) return 0;
|
|
189
|
+
return heightOf(store, node);
|
|
190
|
+
}
|
|
191
|
+
async function noClimb(ctx, node, dest) {
|
|
192
|
+
const person = ctx.principal ? { ...ctx, principal: { ...ctx.principal, scopes: ["drive:admin"] } } : ctx;
|
|
193
|
+
if (await can(person, "delete", dest) && !await can(person, "delete", node)) {
|
|
194
|
+
throw new DriveError("FORBIDDEN", "moving into a folder you own needs owner on the item");
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
async function fitsUnder(ops, w, dest, height) {
|
|
198
|
+
if (sharing && await depthOf(ops, w, dest) + 1 + height > MAX_ITEM_DEPTH) {
|
|
199
|
+
throw new DriveError("TOO_DEEP", `an item can sit at most ${MAX_ITEM_DEPTH} folders deep`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
async function startOf(ops, ctx, loc) {
|
|
203
|
+
if (loc.startsWith("/")) {
|
|
204
|
+
const start = rootNode(ctx.owner, ctx.actor);
|
|
205
|
+
if (!await can(ctx, "read", start)) throw new DriveError("NOT_FOUND", `no item ${loc}`);
|
|
206
|
+
return { start, segs: splitPath(loc) };
|
|
207
|
+
}
|
|
208
|
+
const slash = loc.indexOf("/");
|
|
209
|
+
const base = await resolve(ops, ctx, loc.slice(0, slash));
|
|
210
|
+
if (base.kind !== "folder") throw new DriveError("NOT_A_FOLDER", `${base.name} is not a folder`);
|
|
211
|
+
return { start: base, segs: splitPath(`/${loc.slice(slash + 1)}`) };
|
|
212
|
+
}
|
|
213
|
+
async function resolve(ops, ctx, loc) {
|
|
214
|
+
if (!loc.includes("/")) {
|
|
215
|
+
const node2 = await fetch(ops, ctx, loc);
|
|
216
|
+
if (!node2) throw new DriveError("NOT_FOUND", `no item ${loc}`);
|
|
217
|
+
if (!await isLive(ops, ctx, node2)) throw new DriveError("TRASHED", `${loc} is in the trash`);
|
|
218
|
+
return node2;
|
|
219
|
+
}
|
|
220
|
+
const { start, segs } = await startOf(ops, ctx, loc);
|
|
221
|
+
let node = start;
|
|
222
|
+
for (const seg of segs) {
|
|
223
|
+
if (node.kind !== "folder") throw new DriveError("NOT_A_FOLDER", `${node.name} is not a folder`);
|
|
224
|
+
const next = await liveChild(ops, node, seg.key);
|
|
225
|
+
if (!next) throw new DriveError("NOT_FOUND", `no item ${loc}`);
|
|
226
|
+
node = next;
|
|
227
|
+
}
|
|
228
|
+
if (!await can(ctx, "read", node)) throw new DriveError("NOT_FOUND", `no item ${loc}`);
|
|
229
|
+
return node;
|
|
230
|
+
}
|
|
231
|
+
async function freeName(ops, owner, parentId, name) {
|
|
232
|
+
const dot = name.lastIndexOf(".");
|
|
233
|
+
const [base, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, ""];
|
|
234
|
+
for (let n = 2; ; n++) {
|
|
235
|
+
const candidate = `${base} (${n})${ext}`;
|
|
236
|
+
if (!(await claimed(ops, owner, parentId, parseName(candidate).key)).taken) return candidate;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
async function folderAt(ops, w, ctx, start, segs, create) {
|
|
240
|
+
let node = start;
|
|
241
|
+
for (const seg of segs) {
|
|
242
|
+
if (node.kind !== "folder") throw new DriveError("NOT_A_FOLDER", `${node.name} is not a folder`);
|
|
243
|
+
let next = w.fresh.has(node.id) ? null : await liveChild(ops, node, seg.key);
|
|
244
|
+
if (!next) {
|
|
245
|
+
if (!create) throw new DriveError("NOT_FOUND", `no folder ${seg.name}`);
|
|
246
|
+
next = (await insert(ops, w, ctx, node, seg.name, { kind: "folder" }, "fail")).node;
|
|
247
|
+
w.fresh.add(next.id);
|
|
248
|
+
}
|
|
249
|
+
node = next;
|
|
250
|
+
}
|
|
251
|
+
if (node.kind !== "folder") throw new DriveError("NOT_A_FOLDER", `${node.name} is not a folder`);
|
|
252
|
+
return node;
|
|
253
|
+
}
|
|
254
|
+
async function insert(ops, w, ctx, parent, rawName, fields, onConflict) {
|
|
255
|
+
let { name, key } = parseName(rawName);
|
|
256
|
+
const empty = w.fresh.has(parent.id);
|
|
257
|
+
if (!empty) await need(ctx, "write", parent);
|
|
258
|
+
const slot = empty ? null : await claimed(ops, parent.owner, parent.id, key);
|
|
259
|
+
if (slot?.taken) {
|
|
260
|
+
if (onConflict === "replace" && slot.node?.kind === "file" && fields.kind === "file") {
|
|
261
|
+
const node2 = { ...slot.node, ...fields, updatedAt: now(), ...stamp(ctx, slot.node) };
|
|
262
|
+
w.put(NODES, node2.id, node2);
|
|
263
|
+
return { node: node2, replacedBlob: slot.node.blobKey };
|
|
264
|
+
}
|
|
265
|
+
if (onConflict !== "rename") throw new DriveError("NAME_TAKEN", `"${name}" already exists`);
|
|
266
|
+
name = await freeName(ops, parent.owner, parent.id, name);
|
|
267
|
+
key = parseName(name).key;
|
|
268
|
+
}
|
|
269
|
+
const id = newId();
|
|
270
|
+
if (sharing) {
|
|
271
|
+
const depth = await depthOf(ops, w, parent) + 1;
|
|
272
|
+
if (depth > MAX_ITEM_DEPTH) throw new DriveError("TOO_DEEP", `an item can sit at most ${MAX_ITEM_DEPTH} folders deep`);
|
|
273
|
+
w.depths.set(id, depth);
|
|
274
|
+
}
|
|
275
|
+
const t = now();
|
|
276
|
+
const node = {
|
|
277
|
+
id,
|
|
278
|
+
owner: parent.owner,
|
|
279
|
+
parentId: parent.id,
|
|
280
|
+
kind: "file",
|
|
281
|
+
name,
|
|
282
|
+
nameKey: key,
|
|
283
|
+
size: 0,
|
|
284
|
+
mime: null,
|
|
285
|
+
hash: null,
|
|
286
|
+
ref: null,
|
|
287
|
+
blobKey: null,
|
|
288
|
+
createdAt: t,
|
|
289
|
+
updatedAt: t,
|
|
290
|
+
createdBy: who(ctx),
|
|
291
|
+
updatedBy: who(ctx),
|
|
292
|
+
createdById: ctx.actor.id,
|
|
293
|
+
updatedById: ctx.actor.id,
|
|
294
|
+
trashed: false,
|
|
295
|
+
trashedAt: null,
|
|
296
|
+
purging: false,
|
|
297
|
+
...fields
|
|
298
|
+
};
|
|
299
|
+
w.put(NODES, node.id, node);
|
|
300
|
+
w.put(NAMES, await claimId(parent.owner, parent.id, key), { nodeId: node.id, owner: parent.owner, parentId: parent.id });
|
|
301
|
+
if (sharing) w.put("resources", node.id, await resourceDoc(node, parent));
|
|
302
|
+
return { node, replacedBlob: null };
|
|
303
|
+
}
|
|
304
|
+
async function parentAndName(ops, w, ctx, path, parents) {
|
|
305
|
+
const { start, segs } = await startOf(ops, ctx, path);
|
|
306
|
+
const last = segs.pop();
|
|
307
|
+
if (!last) throw start.id === ROOT_ID ? new DriveError("IS_ROOT", "the root already exists") : new DriveError("INVALID_PATH", "name an item inside the folder");
|
|
308
|
+
return { parent: await folderAt(ops, w, ctx, start, segs, parents), name: last.name };
|
|
309
|
+
}
|
|
310
|
+
async function relocate(ops, w, ctx, node, destLoc, opts) {
|
|
311
|
+
if (node.id === ROOT_ID) throw new DriveError("IS_ROOT", "cannot change the root");
|
|
312
|
+
const dest = typeof destLoc === "string" ? await resolve(ops, ctx, destLoc) : destLoc;
|
|
313
|
+
if (dest.kind !== "folder") throw new DriveError("NOT_A_FOLDER", `${dest.name} is not a folder`);
|
|
314
|
+
if (dest.owner !== node.owner) throw new DriveError("CROSS_DRIVE", "an item cannot move into another person's drive");
|
|
315
|
+
for (let n = dest, depth = 0; n.id !== ROOT_ID; depth++) {
|
|
316
|
+
if (n.id === node.id) throw new DriveError("MOVE_INTO_SELF", `cannot move ${node.name} into itself`);
|
|
317
|
+
if (depth >= maxDepth) throw new DriveError("TOO_DEEP", `the destination is deeper than ${maxDepth} folders`);
|
|
318
|
+
const parent = await rawNode(ops, n.parentId, n.owner, ctx.actor);
|
|
319
|
+
if (!parent) throw new DriveError("NOT_FOUND", `a parent folder of ${dest.name} is missing`);
|
|
320
|
+
n = parent;
|
|
321
|
+
}
|
|
322
|
+
await need(ctx, "write", node);
|
|
323
|
+
if (dest.id !== node.parentId) {
|
|
324
|
+
await need(ctx, "write", dest);
|
|
325
|
+
await noClimb(ctx, node, dest);
|
|
326
|
+
await fitsUnder(ops, w, dest, opts.height ?? 0);
|
|
327
|
+
}
|
|
328
|
+
let { name, key } = parseName(opts.name ?? node.name);
|
|
329
|
+
const slot = await claimed(ops, dest.owner, dest.id, key);
|
|
330
|
+
if (slot.taken && slot.node?.id !== node.id) {
|
|
331
|
+
if (opts.onConflict !== "rename") throw new DriveError("NAME_TAKEN", `"${name}" already exists`);
|
|
332
|
+
name = await freeName(ops, dest.owner, dest.id, name);
|
|
333
|
+
key = parseName(name).key;
|
|
334
|
+
}
|
|
335
|
+
const moved = { ...node, parentId: dest.id, name, nameKey: key, updatedAt: now(), ...stamp(ctx, node) };
|
|
336
|
+
w.put(NODES, moved.id, moved);
|
|
337
|
+
const [from, to] = [await claimId(node.owner, node.parentId, node.nameKey), await claimId(dest.owner, dest.id, key)];
|
|
338
|
+
if (from !== to) {
|
|
339
|
+
if ((await ops.get(NAMES, from))?.nodeId === node.id) w.remove(NAMES, from);
|
|
340
|
+
w.put(NAMES, to, { nodeId: moved.id, owner: dest.owner, parentId: dest.id });
|
|
341
|
+
}
|
|
342
|
+
if (sharing && dest.id !== node.parentId) w.put("resources", moved.id, await resourceDoc(moved, dest));
|
|
343
|
+
return moved;
|
|
344
|
+
}
|
|
345
|
+
async function isUnreferenced(blobKey) {
|
|
346
|
+
try {
|
|
347
|
+
return (await store.query(NODES, { where: { blobKey }, limit: 1 })).items.length === 0;
|
|
348
|
+
} catch (error) {
|
|
349
|
+
onOrphan(blobKey, error);
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
async function dropBlob(key) {
|
|
354
|
+
try {
|
|
355
|
+
await blobs.delete(key);
|
|
356
|
+
} catch (error) {
|
|
357
|
+
onOrphan(key, error);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
async function rawOwnerNodes(owner, withMarked) {
|
|
361
|
+
const out = [];
|
|
362
|
+
for (let cursor; ; ) {
|
|
363
|
+
const page = await store.query(NODES, { where: { owner }, limit: 100, cursor });
|
|
364
|
+
out.push(...page.items.map((item) => item.data).filter((node) => withMarked || node.purging !== true));
|
|
365
|
+
if (!page.nextCursor) return out;
|
|
366
|
+
cursor = page.nextCursor;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
async function ownerNodes(ctx, withMarked = false) {
|
|
370
|
+
if (!await can(ctx, "read", rootNode(ctx.owner, ctx.actor))) throw new DriveError("FORBIDDEN", "not allowed to read this drive");
|
|
371
|
+
return rawOwnerNodes(ctx.owner, withMarked);
|
|
372
|
+
}
|
|
373
|
+
function pathIn(byId, node, liveOnly) {
|
|
374
|
+
const names = [];
|
|
375
|
+
for (let n = node, depth = 0; depth < 1e3; depth++) {
|
|
376
|
+
if (!n) return liveOnly ? null : `/(orphan)/${names.reverse().join("/")}`;
|
|
377
|
+
if (liveOnly && n.trashed) return null;
|
|
378
|
+
names.push(n.name);
|
|
379
|
+
if (n.parentId === ROOT_ID) return `/${names.reverse().join("/")}`;
|
|
380
|
+
n = byId.get(n.parentId);
|
|
381
|
+
}
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
async function transact(fn) {
|
|
385
|
+
try {
|
|
386
|
+
return await store.transaction(fn);
|
|
387
|
+
} catch (error) {
|
|
388
|
+
if (error instanceof Error && /transaction kept conflicting/.test(error.message)) throw new DriveError("BUSY", "the drive changed at the same time. Try again.");
|
|
389
|
+
throw error;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
function assertInPerson(ctx, what) {
|
|
393
|
+
if (!isInPerson(ctx) || ctx.actor.kind !== "human") throw new DriveError("FORBIDDEN", `only the owner in person can ${what}`);
|
|
394
|
+
}
|
|
395
|
+
const subjectOf = (ctx) => ({ id: ctx.owner, kind: ctx.principal?.chain[0]?.kind ?? (isInPerson(ctx) ? ctx.actor.kind : null) });
|
|
396
|
+
async function allWhere(ops, collection, where, orderBy) {
|
|
397
|
+
const out = [];
|
|
398
|
+
for (let cursor; ; ) {
|
|
399
|
+
const page = await ops.query(collection, { where, ...orderBy ? { orderBy } : {}, limit: 100, cursor });
|
|
400
|
+
out.push(...page.items);
|
|
401
|
+
if (!page.nextCursor) return out;
|
|
402
|
+
cursor = page.nextCursor;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
const isInPerson = (ctx) => ctx.principal === void 0 ? ctx.actor.kind === "human" && ctx.actor.id === ctx.owner : ctx.principal.kind === "human" && ctx.principal.chain.length === 1 && ctx.actor.id === ctx.owner;
|
|
406
|
+
const MAX_ACTORS = 1e3;
|
|
407
|
+
async function actorsOf(ops, id) {
|
|
408
|
+
const ownedAgents = (await allWhere(ops, "principals", { ownerId: id })).map((p) => p.id);
|
|
409
|
+
const actors = [id, ...ownedAgents];
|
|
410
|
+
for (let depth = 0, from = 0; from < actors.length && depth < 8; depth++) {
|
|
411
|
+
const next = actors.length;
|
|
412
|
+
for (; from < next; from++) for (const child of await allWhere(ops, "principals", { parentId: actors[from] })) if (!actors.includes(child.id)) actors.push(child.id);
|
|
413
|
+
if (actors.length > MAX_ACTORS) throw new DriveError("TOO_MANY_ITEMS", `a person can have at most ${MAX_ACTORS} agents here`);
|
|
414
|
+
}
|
|
415
|
+
return { actors, ownedAgents };
|
|
416
|
+
}
|
|
417
|
+
async function madeBy(ops, actors) {
|
|
418
|
+
const made = /* @__PURE__ */ new Map();
|
|
419
|
+
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
|
+
return [...made.values()];
|
|
421
|
+
}
|
|
422
|
+
async function eraseDrive(owner) {
|
|
423
|
+
let removed = 0;
|
|
424
|
+
for (let pass = 0; pass < 5; pass++) {
|
|
425
|
+
const all = await rawOwnerNodes(owner, true);
|
|
426
|
+
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
|
+
});
|
|
433
|
+
return removed;
|
|
434
|
+
}
|
|
435
|
+
removed += await removeNodes(all);
|
|
436
|
+
}
|
|
437
|
+
throw new DriveError("BUSY", "the drive kept changing while it was deleted. Run it again.");
|
|
438
|
+
}
|
|
439
|
+
async function sharesOf(ops, resourceId) {
|
|
440
|
+
const out = [];
|
|
441
|
+
for (const collection of ["grants", "links", "invites"]) {
|
|
442
|
+
for (let cursor; ; ) {
|
|
443
|
+
const page = await ops.query(collection, { where: { resourceId }, limit: 100, cursor });
|
|
444
|
+
out.push(...page.items.map((item) => ({ collection, id: item.id })));
|
|
445
|
+
if (!page.nextCursor) break;
|
|
446
|
+
cursor = page.nextCursor;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return out;
|
|
450
|
+
}
|
|
451
|
+
async function removeNodes(nodes, top) {
|
|
452
|
+
const unchanged = (now2, seen) => now2 !== null && now2.trashed === seen.trashed && now2.parentId === seen.parentId && now2.updatedAt === seen.updatedAt;
|
|
453
|
+
const size = sharing ? SHARED_CHUNK : CHUNK;
|
|
454
|
+
const marked = [];
|
|
455
|
+
const markedIds = /* @__PURE__ */ new Set();
|
|
456
|
+
for (let i = 0; i < nodes.length; i += size) {
|
|
457
|
+
const slice = nodes.slice(i, i + size);
|
|
458
|
+
for (const node of slice) {
|
|
459
|
+
if (node.purging === true) {
|
|
460
|
+
marked.push(node);
|
|
461
|
+
markedIds.add(node.id);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
const found = await transact(async (tx) => {
|
|
465
|
+
const out = [];
|
|
466
|
+
const ids = new Set(markedIds);
|
|
467
|
+
for (const node of slice) {
|
|
468
|
+
if (node.purging === true) continue;
|
|
469
|
+
if (top && node.id !== top.id && !ids.has(node.parentId)) continue;
|
|
470
|
+
const current = await tx.get(NODES, node.id);
|
|
471
|
+
if (unchanged(current, node)) {
|
|
472
|
+
out.push(current);
|
|
473
|
+
ids.add(node.id);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
for (const node of out) await tx.put(NODES, node.id, { ...node, purging: true });
|
|
477
|
+
return out;
|
|
478
|
+
});
|
|
479
|
+
for (const node of found) {
|
|
480
|
+
marked.push(node);
|
|
481
|
+
markedIds.add(node.id);
|
|
482
|
+
}
|
|
483
|
+
if (top && i === 0 && !markedIds.has(top.id)) throw new DriveError("BUSY", `${top.name} changed during the purge. Nothing was deleted.`);
|
|
484
|
+
}
|
|
485
|
+
const rows = marked.reverse();
|
|
486
|
+
for (let i = 0; i < rows.length; i += size) {
|
|
487
|
+
const chunk = rows.slice(i, i + size);
|
|
488
|
+
for (const node of chunk) if (node.blobKey) await blobs.delete(node.blobKey);
|
|
489
|
+
await transact(async (tx) => {
|
|
490
|
+
const held = [];
|
|
491
|
+
for (const node of chunk) {
|
|
492
|
+
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 });
|
|
495
|
+
}
|
|
496
|
+
for (const { node, claim, owns, shares } of held) {
|
|
497
|
+
await tx.delete(NODES, node.id);
|
|
498
|
+
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
|
+
}
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
return marked.length;
|
|
505
|
+
}
|
|
506
|
+
async function openShared(principal, id, linkToken) {
|
|
507
|
+
if (!sharing) throw new DriveError("INVALID_SHARE", "sharing is not set up");
|
|
508
|
+
const node = await rawNode(store, id, "", VISITOR);
|
|
509
|
+
if (!node || node.id === ROOT_ID) throw new DriveError("NOT_FOUND", `no item ${id}`);
|
|
510
|
+
const decision = await sharing.can(principal, "read", id, linkToken === void 0 ? {} : { linkToken });
|
|
511
|
+
if (!decision.allowed) throw new DriveError("NOT_FOUND", `no item ${id}`);
|
|
512
|
+
if (!await isLive(store, { owner: node.owner, actor: node.createdBy }, node)) throw new DriveError("TRASHED", `${id} is in the trash`);
|
|
513
|
+
return node;
|
|
514
|
+
}
|
|
515
|
+
const api = {
|
|
516
|
+
async stat(ctx, loc) {
|
|
517
|
+
return resolve(store, ctx, loc);
|
|
518
|
+
},
|
|
519
|
+
/** Live children, sorted by name. */
|
|
520
|
+
async list(ctx, loc = "/") {
|
|
521
|
+
const folder = await resolve(store, ctx, loc);
|
|
522
|
+
if (folder.kind !== "folder") throw new DriveError("NOT_A_FOLDER", `${folder.name} is not a folder`);
|
|
523
|
+
return (await children(store, folder.owner, folder.id)).filter((n) => !n.trashed && n.purging !== true).sort((a, b) => a.nameKey < b.nameKey ? -1 : a.nameKey > b.nameKey ? 1 : a.name < b.name ? -1 : 1);
|
|
524
|
+
},
|
|
525
|
+
async mkdir(ctx, path, opts = {}) {
|
|
526
|
+
return transact(async (tx) => {
|
|
527
|
+
const w = batch();
|
|
528
|
+
const { parent, name } = await parentAndName(tx, w, ctx, path, opts.parents ?? false);
|
|
529
|
+
const existing = w.fresh.has(parent.id) ? null : await liveChild(tx, parent, parseName(name).key);
|
|
530
|
+
if (opts.parents && existing?.kind === "folder") {
|
|
531
|
+
if (!await can(ctx, "read", existing)) throw new DriveError("NOT_FOUND", `no item ${path}`);
|
|
532
|
+
return existing;
|
|
533
|
+
}
|
|
534
|
+
const { node } = await insert(tx, w, ctx, parent, name, { kind: "folder" }, "fail");
|
|
535
|
+
await w.apply(tx);
|
|
536
|
+
return node;
|
|
537
|
+
});
|
|
538
|
+
},
|
|
539
|
+
/**
|
|
540
|
+
* Stores one blob and one node. The blob is written first. The new node commits before
|
|
541
|
+
* any old blob goes. A failed old-blob delete leaves an orphan and never undoes the commit.
|
|
542
|
+
*/
|
|
543
|
+
async putFile(ctx, path, data, opts = {}) {
|
|
544
|
+
const mime = opts.mime === void 0 ? null : safeMime(opts.mime);
|
|
545
|
+
const hash = await sha256Hex(data);
|
|
546
|
+
const blobKey = `blobs/${newId()}`;
|
|
547
|
+
const stored = await blobs.put(blobKey, data, { ifNoneMatch: "*", ...mime ? { contentType: mime } : {} });
|
|
548
|
+
if (!stored) throw new DriveError("BLOB_EXISTS", "a blob with this key already exists");
|
|
549
|
+
let committed;
|
|
550
|
+
try {
|
|
551
|
+
committed = await transact(async (tx) => {
|
|
552
|
+
const w = batch();
|
|
553
|
+
const { parent, name } = await parentAndName(tx, w, ctx, path, opts.parents ?? false);
|
|
554
|
+
const result = await insert(tx, w, ctx, parent, name, { kind: "file", size: data.byteLength, mime, hash, blobKey }, opts.onConflict ?? "fail");
|
|
555
|
+
await w.apply(tx);
|
|
556
|
+
return result;
|
|
557
|
+
});
|
|
558
|
+
} catch (error) {
|
|
559
|
+
if (await isUnreferenced(blobKey)) await dropBlob(blobKey);
|
|
560
|
+
throw error;
|
|
561
|
+
}
|
|
562
|
+
if (committed.replacedBlob) await dropBlob(committed.replacedBlob);
|
|
563
|
+
return committed.node;
|
|
564
|
+
},
|
|
565
|
+
/** A reference points at a document in another app, such as `docs:<id>`. */
|
|
566
|
+
async putRef(ctx, path, ref, opts = {}) {
|
|
567
|
+
if (!REF.test(ref)) throw new DriveError("INVALID_REF", `a reference looks like "docs:<id>", got ${JSON.stringify(ref)}`);
|
|
568
|
+
return transact(async (tx) => {
|
|
569
|
+
const w = batch();
|
|
570
|
+
const { parent, name } = await parentAndName(tx, w, ctx, path, opts.parents ?? false);
|
|
571
|
+
const { node } = await insert(tx, w, ctx, parent, name, { kind: "ref", ref }, opts.onConflict === "replace" ? "fail" : opts.onConflict ?? "fail");
|
|
572
|
+
await w.apply(tx);
|
|
573
|
+
return node;
|
|
574
|
+
});
|
|
575
|
+
},
|
|
576
|
+
async read(ctx, loc) {
|
|
577
|
+
const node = await resolve(store, ctx, loc);
|
|
578
|
+
if (node.kind !== "file" || !node.blobKey) throw new DriveError("NOT_A_FILE", `${node.name} is not a file`);
|
|
579
|
+
const blob = await blobs.get(node.blobKey);
|
|
580
|
+
if (!blob) throw new DriveError("BLOB_MISSING", `the bytes of ${node.name} are missing`);
|
|
581
|
+
return { node, ...blob };
|
|
582
|
+
},
|
|
583
|
+
/**
|
|
584
|
+
* A time-limited URL to fetch the bytes. The URL signs an attachment header and the stored
|
|
585
|
+
* type, so the link holder cannot change how the bytes are served. Trashing an item does
|
|
586
|
+
* not revoke a link already issued.
|
|
587
|
+
*/
|
|
588
|
+
async downloadUrl(ctx, loc, expiresInSeconds = DEFAULT_URL_SECONDS) {
|
|
589
|
+
if (!Number.isInteger(expiresInSeconds) || expiresInSeconds < 1 || expiresInSeconds > MAX_URL_SECONDS) {
|
|
590
|
+
throw new DriveError("INVALID_EXPIRY", `a link lasts 1 to ${MAX_URL_SECONDS} seconds`);
|
|
591
|
+
}
|
|
592
|
+
const node = await resolve(store, ctx, loc);
|
|
593
|
+
if (node.kind !== "file" || !node.blobKey) throw new DriveError("NOT_A_FILE", `${node.name} is not a file`);
|
|
594
|
+
const disposition = `attachment; filename*=UTF-8''${encodeURIComponent(node.name).replace(/['()*]/g, (ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`)}`;
|
|
595
|
+
return blobs.signedUrl(node.blobKey, {
|
|
596
|
+
method: "GET",
|
|
597
|
+
expiresInSeconds,
|
|
598
|
+
responseContentDisposition: disposition.length <= 1024 ? disposition : "attachment",
|
|
599
|
+
responseContentType: node.mime ?? "application/octet-stream"
|
|
600
|
+
});
|
|
601
|
+
},
|
|
602
|
+
async move(ctx, src, destLoc, opts = {}) {
|
|
603
|
+
const probe = sharing ? await resolve(store, ctx, src) : null;
|
|
604
|
+
const height = probe ? await heightFor(ctx, probe, destLoc) : 0;
|
|
605
|
+
return transact(async (tx) => {
|
|
606
|
+
const w = batch();
|
|
607
|
+
const moved = await relocate(tx, w, ctx, await resolve(tx, ctx, src), destLoc, { ...opts, height });
|
|
608
|
+
await w.apply(tx);
|
|
609
|
+
return moved;
|
|
610
|
+
});
|
|
611
|
+
},
|
|
612
|
+
async rename(ctx, loc, name) {
|
|
613
|
+
return transact(async (tx) => {
|
|
614
|
+
const w = batch();
|
|
615
|
+
const node = await resolve(tx, ctx, loc);
|
|
616
|
+
if (node.id === ROOT_ID) throw new DriveError("IS_ROOT", "cannot change the root");
|
|
617
|
+
const parent = await rawNode(tx, node.parentId, node.owner, ctx.actor);
|
|
618
|
+
if (!parent) throw new DriveError("NOT_FOUND", `the folder of ${node.name} is missing`);
|
|
619
|
+
const renamed = await relocate(tx, w, ctx, node, parent, { name });
|
|
620
|
+
await w.apply(tx);
|
|
621
|
+
return renamed;
|
|
622
|
+
});
|
|
623
|
+
},
|
|
624
|
+
/** Hides an item and, for a folder, everything under it. Nothing else changes. */
|
|
625
|
+
async trash(ctx, loc) {
|
|
626
|
+
return transact(async (tx) => {
|
|
627
|
+
const node = await resolve(tx, ctx, loc);
|
|
628
|
+
if (node.id === ROOT_ID) throw new DriveError("IS_ROOT", "cannot trash the root");
|
|
629
|
+
await need(ctx, "write", node);
|
|
630
|
+
const claim = await claimId(node.owner, node.parentId, node.nameKey);
|
|
631
|
+
const held = await tx.get(NAMES, claim);
|
|
632
|
+
const t = now();
|
|
633
|
+
const trashed = { ...node, trashed: true, trashedAt: t, updatedAt: t, ...stamp(ctx, node) };
|
|
634
|
+
await tx.put(NODES, node.id, trashed);
|
|
635
|
+
if (held?.nodeId === node.id) await tx.delete(NAMES, claim);
|
|
636
|
+
return trashed;
|
|
637
|
+
});
|
|
638
|
+
},
|
|
639
|
+
/** Items trashed on their own, newest first. */
|
|
640
|
+
async listTrash(ctx) {
|
|
641
|
+
await need(ctx, "read", rootNode(ctx.owner, ctx.actor));
|
|
642
|
+
const page = await store.query(NODES, { where: { owner: ctx.owner, trashed: true }, limit: 1e3 });
|
|
643
|
+
const out = [];
|
|
644
|
+
for (const { data } of page.items) if (data.purging !== true) out.push(data);
|
|
645
|
+
return out.sort((a, b) => (b.trashedAt ?? 0) - (a.trashedAt ?? 0));
|
|
646
|
+
},
|
|
647
|
+
/** Puts a trashed item back. It needs a live parent folder. */
|
|
648
|
+
async restore(ctx, id, opts = {}) {
|
|
649
|
+
let height = 0;
|
|
650
|
+
if (sharing && opts.parentId !== void 0) {
|
|
651
|
+
const probe = await fetch(store, ctx, id);
|
|
652
|
+
const target = await rawNode(store, opts.parentId, ctx.owner, ctx.actor);
|
|
653
|
+
if (probe?.trashed && target && await can(ctx, "read", target)) height = await heightFor(ctx, probe, target);
|
|
654
|
+
}
|
|
655
|
+
return transact(async (tx) => {
|
|
656
|
+
const node = await fetch(tx, ctx, id);
|
|
657
|
+
if (!node || node.id === ROOT_ID) throw new DriveError("NOT_FOUND", `no item ${id}`);
|
|
658
|
+
if (!node.trashed) throw new DriveError("NOT_TRASHED", `${node.name} is not in the trash`);
|
|
659
|
+
await need(ctx, "write", node);
|
|
660
|
+
const parent = opts.parentId === void 0 ? await rawNode(tx, node.parentId, node.owner, ctx.actor) : await rawNode(tx, opts.parentId, ctx.owner, ctx.actor);
|
|
661
|
+
const visible = parent !== null && (opts.parentId === void 0 || await can(ctx, "read", parent));
|
|
662
|
+
if (!parent || !visible || parent.kind !== "folder" || !await isLive(tx, ctx, parent)) {
|
|
663
|
+
throw new DriveError("PARENT_GONE", `the folder of ${node.name} is gone or in the trash`);
|
|
664
|
+
}
|
|
665
|
+
if (parent.owner !== node.owner) throw new DriveError("CROSS_DRIVE", "an item cannot move into another person's drive");
|
|
666
|
+
if (parent.id !== node.parentId) {
|
|
667
|
+
await need(ctx, "write", parent);
|
|
668
|
+
await noClimb(ctx, node, parent);
|
|
669
|
+
await fitsUnder(tx, batch(), parent, height);
|
|
670
|
+
}
|
|
671
|
+
let { name, key } = parseName(node.name);
|
|
672
|
+
if ((await claimed(tx, parent.owner, parent.id, key)).taken) {
|
|
673
|
+
if (opts.onConflict !== "rename") throw new DriveError("NAME_TAKEN", `"${name}" already exists`);
|
|
674
|
+
name = await freeName(tx, parent.owner, parent.id, name);
|
|
675
|
+
key = parseName(name).key;
|
|
676
|
+
}
|
|
677
|
+
const restored = { ...node, parentId: parent.id, name, nameKey: key, trashed: false, trashedAt: null, updatedAt: now(), ...stamp(ctx, node) };
|
|
678
|
+
await tx.put(NODES, restored.id, restored);
|
|
679
|
+
await tx.put(NAMES, await claimId(parent.owner, parent.id, key), { nodeId: restored.id, owner: parent.owner, parentId: parent.id });
|
|
680
|
+
if (sharing && parent.id !== node.parentId) await tx.put("resources", restored.id, await resourceDoc(restored, parent));
|
|
681
|
+
return restored;
|
|
682
|
+
});
|
|
683
|
+
},
|
|
684
|
+
/** Live items that match every field given, sorted by path. */
|
|
685
|
+
async search(ctx, q = {}) {
|
|
686
|
+
const limit = q.limit ?? 100;
|
|
687
|
+
if (!Number.isInteger(limit) || limit < 1) throw new RangeError("limit must be an integer of 1 or more");
|
|
688
|
+
const all = await ownerNodes(ctx);
|
|
689
|
+
const byId = new Map(all.map((n) => [n.id, n]));
|
|
690
|
+
const needle = q.name?.normalize("NFC").toLowerCase();
|
|
691
|
+
const hits = [];
|
|
692
|
+
for (const node of all) {
|
|
693
|
+
const path = pathIn(byId, node, true);
|
|
694
|
+
if (path === null || node.purging) continue;
|
|
695
|
+
if (needle !== void 0 && !node.nameKey.includes(needle)) continue;
|
|
696
|
+
if (q.kind && node.kind !== q.kind) continue;
|
|
697
|
+
if (q.mime && !(q.mime.endsWith("/*") ? node.mime?.startsWith(q.mime.slice(0, -1)) : node.mime === q.mime)) continue;
|
|
698
|
+
if (q.minSize !== void 0 && node.size < q.minSize) continue;
|
|
699
|
+
if (q.maxSize !== void 0 && node.size > q.maxSize) continue;
|
|
700
|
+
if (q.createdByKind && node.createdBy.kind !== q.createdByKind) continue;
|
|
701
|
+
if (q.app !== void 0 && appOf(node) !== q.app) continue;
|
|
702
|
+
hits.push({ node, path });
|
|
703
|
+
}
|
|
704
|
+
return hits.sort((a, b) => a.path < b.path ? -1 : 1).slice(0, limit);
|
|
705
|
+
},
|
|
706
|
+
/** Every item of the owner, trashed or not, with its original path and bytes. For export. */
|
|
707
|
+
async exportData(ctx) {
|
|
708
|
+
const all = await ownerNodes(ctx);
|
|
709
|
+
const byId = new Map(all.map((n) => [n.id, n]));
|
|
710
|
+
const out = [];
|
|
711
|
+
for (const node of all.sort((a, b) => a.id < b.id ? -1 : 1)) {
|
|
712
|
+
const blob = node.blobKey ? await blobs.get(node.blobKey) : null;
|
|
713
|
+
out.push({ node, path: pathIn(byId, node, false), data: blob?.data ?? null, missing: node.blobKey !== null && !blob });
|
|
714
|
+
}
|
|
715
|
+
return out;
|
|
716
|
+
},
|
|
717
|
+
/** Deletes every item and blob of the owner for good. Only the owner may do this, never an agent for them. A failed call can run again. */
|
|
718
|
+
async deleteAll(ctx) {
|
|
719
|
+
assertInPerson(ctx, "delete the whole drive");
|
|
720
|
+
await need(ctx, "delete", rootNode(ctx.owner, ctx.actor));
|
|
721
|
+
return { removed: await eraseDrive(ctx.owner) };
|
|
722
|
+
},
|
|
723
|
+
/** Deletes a trashed item for good, with everything under it and all their bytes. */
|
|
724
|
+
async purge(ctx, id) {
|
|
725
|
+
const top = await fetch(store, ctx, id, true);
|
|
726
|
+
if (!top || top.id === ROOT_ID) throw new DriveError("NOT_FOUND", `no item ${id}`);
|
|
727
|
+
if (!top.trashed) throw new DriveError("NOT_TRASHED", `${top.name} is not in the trash`);
|
|
728
|
+
await need(ctx, "delete", top);
|
|
729
|
+
const nodes = [];
|
|
730
|
+
for (const queue = [top]; queue.length; ) {
|
|
731
|
+
const node = queue.pop();
|
|
732
|
+
nodes.push(node);
|
|
733
|
+
if (node.kind === "folder") queue.push(...await children(store, node.owner, node.id));
|
|
734
|
+
}
|
|
735
|
+
return { removed: await removeNodes(nodes, top) };
|
|
736
|
+
},
|
|
737
|
+
/**
|
|
738
|
+
* What the home view shows: every file and every reference to an app's document, newest change first.
|
|
739
|
+
* `apps` counts references per app, and `files` counts plain files, before `app` and `limit` apply.
|
|
740
|
+
*/
|
|
741
|
+
async home(ctx, q = {}) {
|
|
742
|
+
const limit = q.limit ?? 100;
|
|
743
|
+
if (!Number.isInteger(limit) || limit < 1) throw new RangeError("limit must be an integer of 1 or more");
|
|
744
|
+
const all = await ownerNodes(ctx);
|
|
745
|
+
const byId = new Map(all.map((n) => [n.id, n]));
|
|
746
|
+
const apps = {};
|
|
747
|
+
let files = 0;
|
|
748
|
+
const items = [];
|
|
749
|
+
for (const node of all) {
|
|
750
|
+
const path = pathIn(byId, node, true);
|
|
751
|
+
if (path === null || node.kind === "folder") continue;
|
|
752
|
+
const app = appOf(node);
|
|
753
|
+
if (app === null) files++;
|
|
754
|
+
else apps[app] = (apps[app] ?? 0) + 1;
|
|
755
|
+
if (q.app === void 0 || app === q.app) items.push({ node, path, app });
|
|
756
|
+
}
|
|
757
|
+
items.sort((x, y) => y.node.updatedAt - x.node.updatedAt || (x.path < y.path ? -1 : 1));
|
|
758
|
+
return { items: items.slice(0, limit), apps, files };
|
|
759
|
+
},
|
|
760
|
+
/**
|
|
761
|
+
* An app puts one of its documents in this drive, as a reference such as `docs:<id>`. A document that is
|
|
762
|
+
* already in the drive comes back as it is, so an app can register again. The reference sits in `folder`
|
|
763
|
+
* (a path or a folder id, "/" by default) under `name`, and gets a free name when that one is taken.
|
|
764
|
+
*/
|
|
765
|
+
async registerDocument(ctx, input) {
|
|
766
|
+
if (!REF.test(input.ref)) throw new DriveError("INVALID_REF", `a reference looks like "docs:<id>", got ${JSON.stringify(input.ref)}`);
|
|
767
|
+
parseName(input.name);
|
|
768
|
+
await need(ctx, "read", rootNode(ctx.owner, ctx.actor));
|
|
769
|
+
const dir = input.folder ?? "/";
|
|
770
|
+
const owner = dir.startsWith("/") ? ctx.owner : (await resolve(store, ctx, dir.split("/")[0])).owner;
|
|
771
|
+
for (const { data } of await allWhere(store, NODES, { owner, ref: input.ref })) {
|
|
772
|
+
const node = data;
|
|
773
|
+
if (node.kind === "ref" && !node.trashed && node.purging !== true && await isLive(store, ctx, node) && await can(ctx, "read", node)) return node;
|
|
774
|
+
}
|
|
775
|
+
return api.putRef(ctx, dir.endsWith("/") ? `${dir}${input.name}` : `${dir}/${input.name}`, input.ref, { parents: true, onConflict: "rename" });
|
|
776
|
+
},
|
|
777
|
+
/** An app deleted a document. Its references in this drive go to the trash. Returns how many. */
|
|
778
|
+
async forgetDocument(ctx, ref) {
|
|
779
|
+
if (!REF.test(ref)) throw new DriveError("INVALID_REF", `a reference looks like "docs:<id>", got ${JSON.stringify(ref)}`);
|
|
780
|
+
await need(ctx, "read", rootNode(ctx.owner, ctx.actor));
|
|
781
|
+
let trashed = 0;
|
|
782
|
+
for (const { data } of await allWhere(store, NODES, { owner: ctx.owner, ref })) {
|
|
783
|
+
if (data.kind !== "ref" || data.trashed || data.purging === true) continue;
|
|
784
|
+
try {
|
|
785
|
+
await api.trash(ctx, data.id);
|
|
786
|
+
trashed++;
|
|
787
|
+
} catch (error) {
|
|
788
|
+
if (!(error instanceof DriveError && error.code === "TRASHED")) throw error;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
return { trashed };
|
|
792
|
+
},
|
|
793
|
+
/**
|
|
794
|
+
* What drive holds about the person behind a call, besides their own drive (`exportData` has that). It is
|
|
795
|
+
* only for the person in person: an agent gets `partial: true` and nothing else. It holds the ids of the
|
|
796
|
+
* items they or their agents made or changed in other drives (no names, and nothing of the people who own
|
|
797
|
+
* them), the shares they hold, the invites they sent, and the audit entries of what they and their agents
|
|
798
|
+
* did. Invitee emails and link tokens are not in it.
|
|
799
|
+
*/
|
|
800
|
+
async exportPerson(ctx) {
|
|
801
|
+
await need(ctx, "read", rootNode(ctx.owner, ctx.actor));
|
|
802
|
+
const subject = subjectOf(ctx);
|
|
803
|
+
if (!isInPerson(ctx)) return { subject, partial: true, authored: [], agents: [], shares: { held: [], invitesCreated: [] }, audit: [] };
|
|
804
|
+
const { actors, ownedAgents } = await actorsOf(store, subject.id);
|
|
805
|
+
const authored = (await madeBy(store, actors)).filter((n) => n.owner !== subject.id && n.purging !== true).map((n) => ({
|
|
806
|
+
id: n.id,
|
|
807
|
+
kind: n.kind,
|
|
808
|
+
created: actors.includes(n.createdById),
|
|
809
|
+
updated: actors.includes(n.updatedById),
|
|
810
|
+
createdAt: actors.includes(n.createdById) ? n.createdAt : null,
|
|
811
|
+
updatedAt: actors.includes(n.updatedById) ? n.updatedAt : null,
|
|
812
|
+
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
|
+
})).sort((x, y) => x.id < y.id ? -1 : 1);
|
|
814
|
+
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 });
|
|
816
|
+
const agents = [];
|
|
817
|
+
for (const agent of ownedAgents) {
|
|
818
|
+
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 }));
|
|
819
|
+
agents.push({ id: agent, items });
|
|
820
|
+
}
|
|
821
|
+
const invitesCreated = [];
|
|
822
|
+
const audit = [];
|
|
823
|
+
for (const actor of actors) {
|
|
824
|
+
for (const i of await allWhere(store, "invites", { createdBy: actor })) {
|
|
825
|
+
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 });
|
|
826
|
+
}
|
|
827
|
+
for (const e of await allWhere(store, "audit", { principalId: actor }, { field: "at" })) {
|
|
828
|
+
audit.push({ at: e.data.at, action: e.data.action, resourceId: e.data.resourceId ?? null, outcome: e.data.outcome, principalId: e.data.principalId, principalKind: e.data.principalKind, machineMade: e.data.machineMade });
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
audit.sort((x, y) => x.at - y.at);
|
|
832
|
+
return { subject, partial: false, authored, agents, shares: { held, invitesCreated }, audit };
|
|
833
|
+
},
|
|
834
|
+
/**
|
|
835
|
+
* Erases a person for good. Only the person in person may. Their drive and its shares go. So do the shares
|
|
836
|
+
* they hold and the invites they or their agents sent, on drive items only: the grants of other apps stay for
|
|
837
|
+
* those apps and for identity. What they or their agents made in other drives stays, because it belongs to
|
|
838
|
+
* 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.
|
|
840
|
+
*/
|
|
841
|
+
async erasePerson(ctx) {
|
|
842
|
+
assertInPerson(ctx, "erase a person");
|
|
843
|
+
const { id } = subjectOf(ctx);
|
|
844
|
+
const { actors, ownedAgents } = await actorsOf(store, id);
|
|
845
|
+
let { removed } = await api.deleteAll(ctx);
|
|
846
|
+
for (const agent of ownedAgents) removed += await eraseDrive(agent);
|
|
847
|
+
const ofDrive = async (docs) => {
|
|
848
|
+
const out = [];
|
|
849
|
+
for (const doc of docs) if ((await store.get("resources", doc.data.resourceId))?.type === "drive") out.push({ id: doc.id });
|
|
850
|
+
return out;
|
|
851
|
+
};
|
|
852
|
+
const sweep = async (docs, collection) => {
|
|
853
|
+
for (let i = 0; i < docs.length; i += 100) {
|
|
854
|
+
await transact(async (tx) => {
|
|
855
|
+
for (const doc of docs.slice(i, i + 100)) await tx.delete(collection, doc.id);
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
return docs.length;
|
|
859
|
+
};
|
|
860
|
+
let grants = 0;
|
|
861
|
+
let invites = 0;
|
|
862
|
+
for (const actor of actors) grants += await sweep(await ofDrive(await allWhere(store, "grants", { subjectId: actor })), "grants");
|
|
863
|
+
for (const actor of actors) invites += await sweep(await ofDrive(await allWhere(store, "invites", { createdBy: actor })), "invites");
|
|
864
|
+
const todo = await madeBy(store, actors);
|
|
865
|
+
for (let i = 0; i < todo.length; i += CHUNK) {
|
|
866
|
+
await transact(async (tx) => {
|
|
867
|
+
const current = [];
|
|
868
|
+
for (const node of todo.slice(i, i + CHUNK)) {
|
|
869
|
+
const found = await tx.get(NODES, node.id);
|
|
870
|
+
if (found) current.push(found);
|
|
871
|
+
}
|
|
872
|
+
for (const node of current) {
|
|
873
|
+
const next = { ...node };
|
|
874
|
+
if (actors.includes(node.createdById ?? node.createdBy.id)) Object.assign(next, { createdBy: { id: ERASED, kind: node.createdBy.kind }, createdById: ERASED });
|
|
875
|
+
if (actors.includes(node.updatedById ?? node.updatedBy.id)) Object.assign(next, { updatedBy: { id: ERASED, kind: node.updatedBy.kind }, updatedById: ERASED });
|
|
876
|
+
await tx.put(NODES, node.id, next);
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
return { removed, grants, invites, anonymized: todo.length };
|
|
881
|
+
},
|
|
882
|
+
/** 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) {
|
|
884
|
+
const resource = await resourceOf(await resolve(store, ctx, loc));
|
|
885
|
+
await viaSharing(ctx, (s, p) => s.grant(p, resource, subjectId, role));
|
|
886
|
+
},
|
|
887
|
+
async unshare(ctx, loc, subjectId) {
|
|
888
|
+
const resource = await resourceOf(await resolve(store, ctx, loc));
|
|
889
|
+
await viaSharing(ctx, (s, p) => s.revokeGrant(p, resource, subjectId));
|
|
890
|
+
},
|
|
891
|
+
/** Invite by `email` (a person) or `handle` (an agent). The invite lasts `expiresInMs`, if given. It sends no message. */
|
|
892
|
+
async invite(ctx, loc, input) {
|
|
893
|
+
const resource = await resourceOf(await resolve(store, ctx, loc));
|
|
894
|
+
return viaSharing(ctx, (s, p) => s.createInvite(p, resource, input));
|
|
895
|
+
},
|
|
896
|
+
/** `identity` is the verified email of a person, or the handle of an agent. */
|
|
897
|
+
async acceptInvite(ctx, inviteId, identity) {
|
|
898
|
+
await viaSharing(ctx, (s, p) => s.acceptInvite(p, inviteId, identity));
|
|
899
|
+
},
|
|
900
|
+
/** The token comes back once. Only its hash is stored. */
|
|
901
|
+
async createLink(ctx, loc, input) {
|
|
902
|
+
const resource = await resourceOf(await resolve(store, ctx, loc));
|
|
903
|
+
return viaSharing(ctx, (s, p) => s.createLink(p, resource, input));
|
|
904
|
+
},
|
|
905
|
+
async revokeLink(ctx, linkId) {
|
|
906
|
+
await viaSharing(ctx, (s, p) => s.revokeLink(p, linkId));
|
|
907
|
+
},
|
|
908
|
+
/** Who can reach an item, and by which links and invites. Only an owner may look. Link tokens are never shown. */
|
|
909
|
+
async shares(ctx, loc) {
|
|
910
|
+
const node = await resolve(store, ctx, loc);
|
|
911
|
+
await need(ctx, "delete", node);
|
|
912
|
+
const where = { resourceId: await resourceOf(node) };
|
|
913
|
+
const rows = async (collection) => (await store.query(collection, { where, limit: 100 })).items.map((item) => item.data);
|
|
914
|
+
return {
|
|
915
|
+
grants: (await rows("grants")).map((g) => ({ subjectId: g.subjectId, role: g.role })),
|
|
916
|
+
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
|
+
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
|
+
};
|
|
919
|
+
},
|
|
920
|
+
openShared,
|
|
921
|
+
/** Live children of a folder that a link visitor or a signed-in person may read, sorted by name. */
|
|
922
|
+
async listShared(principal, id, linkToken) {
|
|
923
|
+
const folder = await openShared(principal, id, linkToken);
|
|
924
|
+
if (folder.kind !== "folder") throw new DriveError("NOT_A_FOLDER", `${folder.name} is not a folder`);
|
|
925
|
+
return (await children(store, folder.owner, folder.id)).filter((n) => !n.trashed && n.purging !== true).sort((x, y) => x.nameKey < y.nameKey ? -1 : x.nameKey > y.nameKey ? 1 : x.name < y.name ? -1 : 1);
|
|
926
|
+
},
|
|
927
|
+
async readShared(principal, id, linkToken) {
|
|
928
|
+
const node = await openShared(principal, id, linkToken);
|
|
929
|
+
if (node.kind !== "file" || !node.blobKey) throw new DriveError("NOT_A_FILE", `${node.name} is not a file`);
|
|
930
|
+
const blob = await blobs.get(node.blobKey);
|
|
931
|
+
if (!blob) throw new DriveError("BLOB_MISSING", `the bytes of ${node.name} are missing`);
|
|
932
|
+
return { node, ...blob };
|
|
933
|
+
}
|
|
934
|
+
};
|
|
935
|
+
async function viaSharing(ctx, fn) {
|
|
936
|
+
if (!sharing) throw new DriveError("INVALID_SHARE", "sharing is not set up");
|
|
937
|
+
if (!ctx.principal) throw new DriveError("FORBIDDEN", "sign in to share");
|
|
938
|
+
try {
|
|
939
|
+
return await fn(sharing, ctx.principal);
|
|
940
|
+
} catch (error) {
|
|
941
|
+
const message = error instanceof Error ? error.message : "";
|
|
942
|
+
if (message === "forbidden") throw new DriveError("FORBIDDEN", "not allowed to share this item");
|
|
943
|
+
if (SHARE_ERROR.test(message)) throw new DriveError("INVALID_SHARE", message);
|
|
944
|
+
throw error;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
async function prepare(ctx) {
|
|
948
|
+
if (!sharing || ctx.principal === void 0) return;
|
|
949
|
+
if (ctx.principal.chain[0]?.id !== ctx.owner) throw new DriveError("FORBIDDEN", "the caller does not own this drive");
|
|
950
|
+
const id = await rootResource(ctx.owner);
|
|
951
|
+
if ((await store.get("resources", id))?.type === "drive") return;
|
|
952
|
+
try {
|
|
953
|
+
await sharing.createResource(ctx.principal, { id, type: "drive" });
|
|
954
|
+
} catch {
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
const guarded = (call) => async (ctx, ...args) => {
|
|
958
|
+
await prepare(ctx);
|
|
959
|
+
return call(ctx, ...args);
|
|
960
|
+
};
|
|
961
|
+
const open = /* @__PURE__ */ new Set(["openShared", "listShared", "readShared"]);
|
|
962
|
+
return Object.fromEntries(Object.entries(api).map(([name, call]) => [name, open.has(name) ? call : guarded(call)]));
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// src/zip.ts
|
|
966
|
+
var CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
|
|
967
|
+
let c = n;
|
|
968
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
|
|
969
|
+
return c >>> 0;
|
|
970
|
+
});
|
|
971
|
+
function crc32(data) {
|
|
972
|
+
let crc = 4294967295;
|
|
973
|
+
for (const byte of data) crc = CRC_TABLE[(crc ^ byte) & 255] ^ crc >>> 8;
|
|
974
|
+
return (crc ^ 4294967295) >>> 0;
|
|
975
|
+
}
|
|
976
|
+
function safeEntryName(name) {
|
|
977
|
+
if (name === "" || /[\\\u0000]/.test(name) || name.startsWith("/") || /^[a-zA-Z]:/.test(name)) return false;
|
|
978
|
+
return name.split("/").every((part) => part !== "" && part !== "." && part !== "..");
|
|
979
|
+
}
|
|
980
|
+
function writeZip(entries, at) {
|
|
981
|
+
for (const { name } of entries) if (!safeEntryName(name)) throw new Error(`zip: unsafe entry name ${JSON.stringify(name)}`);
|
|
982
|
+
const d = new Date(at);
|
|
983
|
+
const time = d.getUTCHours() << 11 | d.getUTCMinutes() << 5 | d.getUTCSeconds() >> 1;
|
|
984
|
+
const date = Math.max(d.getUTCFullYear(), 1980) - 1980 << 9 | d.getUTCMonth() + 1 << 5 | d.getUTCDate();
|
|
985
|
+
const parts = [];
|
|
986
|
+
const central = [];
|
|
987
|
+
let offset = 0;
|
|
988
|
+
const u16 = (v, o, n) => v.setUint16(o, n, true);
|
|
989
|
+
const u32 = (v, o, n) => v.setUint32(o, n, true);
|
|
990
|
+
for (const entry of entries) {
|
|
991
|
+
const name = new TextEncoder().encode(entry.name);
|
|
992
|
+
const crc = crc32(entry.data);
|
|
993
|
+
if (entry.data.byteLength >= 4294967295 || offset >= 4294967295 || entries.length >= 65535) throw new Error("zip: export is too large for a plain zip");
|
|
994
|
+
const local = new DataView(new ArrayBuffer(30));
|
|
995
|
+
u32(local, 0, 67324752);
|
|
996
|
+
u16(local, 4, 20);
|
|
997
|
+
u16(local, 6, 2048);
|
|
998
|
+
u16(local, 8, 0);
|
|
999
|
+
u16(local, 10, time);
|
|
1000
|
+
u16(local, 12, date);
|
|
1001
|
+
u32(local, 14, crc);
|
|
1002
|
+
u32(local, 18, entry.data.byteLength);
|
|
1003
|
+
u32(local, 22, entry.data.byteLength);
|
|
1004
|
+
u16(local, 26, name.length);
|
|
1005
|
+
u16(local, 28, 0);
|
|
1006
|
+
const head = new DataView(new ArrayBuffer(46));
|
|
1007
|
+
u32(head, 0, 33639248);
|
|
1008
|
+
u16(head, 4, 20);
|
|
1009
|
+
u16(head, 6, 20);
|
|
1010
|
+
u16(head, 8, 2048);
|
|
1011
|
+
u16(head, 10, 0);
|
|
1012
|
+
u16(head, 12, time);
|
|
1013
|
+
u16(head, 14, date);
|
|
1014
|
+
u32(head, 16, crc);
|
|
1015
|
+
u32(head, 20, entry.data.byteLength);
|
|
1016
|
+
u32(head, 24, entry.data.byteLength);
|
|
1017
|
+
u16(head, 28, name.length);
|
|
1018
|
+
u32(head, 42, offset);
|
|
1019
|
+
parts.push(new Uint8Array(local.buffer), name, entry.data);
|
|
1020
|
+
central.push(new Uint8Array(head.buffer), name);
|
|
1021
|
+
offset += 30 + name.length + entry.data.byteLength;
|
|
1022
|
+
}
|
|
1023
|
+
const size = central.reduce((n, p) => n + p.byteLength, 0);
|
|
1024
|
+
const end = new DataView(new ArrayBuffer(22));
|
|
1025
|
+
u32(end, 0, 101010256);
|
|
1026
|
+
u16(end, 8, entries.length);
|
|
1027
|
+
u16(end, 10, entries.length);
|
|
1028
|
+
u32(end, 12, size);
|
|
1029
|
+
u32(end, 16, offset);
|
|
1030
|
+
const all = [...parts, ...central, new Uint8Array(end.buffer)];
|
|
1031
|
+
const out = new Uint8Array(all.reduce((n, p) => n + p.byteLength, 0));
|
|
1032
|
+
let at2 = 0;
|
|
1033
|
+
for (const p of all) {
|
|
1034
|
+
out.set(p, at2);
|
|
1035
|
+
at2 += p.byteLength;
|
|
1036
|
+
}
|
|
1037
|
+
return out;
|
|
1038
|
+
}
|
|
1039
|
+
function readZip(bytes) {
|
|
1040
|
+
const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
1041
|
+
let end = bytes.byteLength - 22;
|
|
1042
|
+
while (end >= 0 && v.getUint32(end, true) !== 101010256) end--;
|
|
1043
|
+
if (end < 0) throw new Error("zip: no end signature");
|
|
1044
|
+
const count = v.getUint16(end + 10, true);
|
|
1045
|
+
const out = /* @__PURE__ */ new Map();
|
|
1046
|
+
for (let i = 0, at = v.getUint32(end + 16, true); i < count; i++) {
|
|
1047
|
+
if (v.getUint32(at, true) !== 33639248) throw new Error("zip: bad central signature");
|
|
1048
|
+
if (v.getUint16(at + 10, true) !== 0) throw new Error("zip: compressed entries are not supported");
|
|
1049
|
+
const crc = v.getUint32(at + 16, true);
|
|
1050
|
+
const size = v.getUint32(at + 24, true);
|
|
1051
|
+
const nameLen = v.getUint16(at + 28, true);
|
|
1052
|
+
const local = v.getUint32(at + 42, true);
|
|
1053
|
+
const name = new TextDecoder().decode(bytes.subarray(at + 46, at + 46 + nameLen));
|
|
1054
|
+
if (v.getUint32(local, true) !== 67324752) throw new Error("zip: bad local signature");
|
|
1055
|
+
const start = local + 30 + v.getUint16(local + 26, true) + v.getUint16(local + 28, true);
|
|
1056
|
+
const data = bytes.slice(start, start + size);
|
|
1057
|
+
if (crc32(data) !== crc) throw new Error(`zip: crc mismatch in ${name}`);
|
|
1058
|
+
out.set(name, data);
|
|
1059
|
+
at += 46 + nameLen + v.getUint16(at + 30, true) + v.getUint16(at + 32, true);
|
|
1060
|
+
}
|
|
1061
|
+
return out;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// src/export.ts
|
|
1065
|
+
async function buildExport(drive, ctx, now = Date.now) {
|
|
1066
|
+
const entries = [];
|
|
1067
|
+
const items = (await drive.exportData(ctx)).map(({ node, path, data, missing }) => {
|
|
1068
|
+
const file = data ? `files/${node.id}` : null;
|
|
1069
|
+
if (data && file) entries.push({ name: file, data });
|
|
1070
|
+
return {
|
|
1071
|
+
id: node.id,
|
|
1072
|
+
owner: node.owner,
|
|
1073
|
+
path,
|
|
1074
|
+
parentId: node.parentId,
|
|
1075
|
+
kind: node.kind,
|
|
1076
|
+
name: node.name,
|
|
1077
|
+
size: node.size,
|
|
1078
|
+
mime: node.mime,
|
|
1079
|
+
hash: node.hash,
|
|
1080
|
+
ref: node.ref,
|
|
1081
|
+
createdAt: node.createdAt,
|
|
1082
|
+
updatedAt: node.updatedAt,
|
|
1083
|
+
createdBy: node.createdBy,
|
|
1084
|
+
updatedBy: node.updatedBy,
|
|
1085
|
+
trashed: node.trashed,
|
|
1086
|
+
trashedAt: node.trashedAt,
|
|
1087
|
+
file,
|
|
1088
|
+
...missing ? { missing: true } : {}
|
|
1089
|
+
};
|
|
1090
|
+
});
|
|
1091
|
+
const person = await drive.exportPerson(ctx);
|
|
1092
|
+
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 };
|
|
1093
|
+
return writeZip([{ name: "manifest.json", data: new TextEncoder().encode(JSON.stringify(manifest, null, 2)) }, ...entries], now());
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// src/adapters/fs.ts
|
|
1097
|
+
import { createHash } from "crypto";
|
|
1098
|
+
import { appendFileSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, truncateSync, writeFileSync, writeSync } from "fs";
|
|
1099
|
+
import { join } from "path";
|
|
1100
|
+
import { assertBlobPutOptions, assertResponseHeaders, createMemoryStore, parseRegion } from "agentic-identity";
|
|
1101
|
+
function createFileStore(dir, options = {}) {
|
|
1102
|
+
mkdirSync(dir, { recursive: true });
|
|
1103
|
+
const file = join(dir, "store.jsonl");
|
|
1104
|
+
const inner = createMemoryStore(options);
|
|
1105
|
+
const ready = (async () => {
|
|
1106
|
+
if (!existsSync(file)) return;
|
|
1107
|
+
const text = readFileSync(file, "utf8");
|
|
1108
|
+
const lines = text.split("\n");
|
|
1109
|
+
const tail = lines.pop();
|
|
1110
|
+
const apply = async (changes2) => {
|
|
1111
|
+
for (const [collection, id, doc] of changes2) await (doc ? inner.put(collection, id, doc) : inner.delete(collection, id));
|
|
1112
|
+
};
|
|
1113
|
+
for (const [i, line] of lines.entries()) {
|
|
1114
|
+
if (line === "") continue;
|
|
1115
|
+
try {
|
|
1116
|
+
await apply(JSON.parse(line));
|
|
1117
|
+
} catch {
|
|
1118
|
+
throw new Error(`the journal ${file} has a bad line ${i + 1}`);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
if (tail === "") return;
|
|
1122
|
+
let changes;
|
|
1123
|
+
try {
|
|
1124
|
+
changes = JSON.parse(tail);
|
|
1125
|
+
} catch {
|
|
1126
|
+
truncateSync(file, Buffer.byteLength(text) - Buffer.byteLength(tail));
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
try {
|
|
1130
|
+
await apply(changes);
|
|
1131
|
+
} catch {
|
|
1132
|
+
throw new Error(`the journal ${file} has a bad last line`);
|
|
1133
|
+
}
|
|
1134
|
+
appendFileSync(file, "\n");
|
|
1135
|
+
})();
|
|
1136
|
+
let failed;
|
|
1137
|
+
const keep = (changes) => {
|
|
1138
|
+
if (changes.length === 0) return;
|
|
1139
|
+
try {
|
|
1140
|
+
const fd = openSync(file, "a");
|
|
1141
|
+
try {
|
|
1142
|
+
const line = Buffer.from(`${JSON.stringify(changes)}
|
|
1143
|
+
`);
|
|
1144
|
+
for (let done = 0; done < line.length; ) {
|
|
1145
|
+
const wrote = writeSync(fd, line, done);
|
|
1146
|
+
if (wrote <= 0) throw new Error("the journal write stopped short");
|
|
1147
|
+
done += wrote;
|
|
1148
|
+
}
|
|
1149
|
+
fsyncSync(fd);
|
|
1150
|
+
} finally {
|
|
1151
|
+
closeSync(fd);
|
|
1152
|
+
}
|
|
1153
|
+
} catch (error) {
|
|
1154
|
+
failed = error;
|
|
1155
|
+
throw error;
|
|
1156
|
+
}
|
|
1157
|
+
};
|
|
1158
|
+
const open = async () => {
|
|
1159
|
+
await ready;
|
|
1160
|
+
if (failed !== void 0) throw new Error("the journal could not be written, so the store is closed. Open it again.");
|
|
1161
|
+
};
|
|
1162
|
+
const recording = (ops, changes) => ({
|
|
1163
|
+
...ops,
|
|
1164
|
+
put: async (collection, id, doc) => {
|
|
1165
|
+
await ops.put(collection, id, doc);
|
|
1166
|
+
changes.push([collection, id, doc]);
|
|
1167
|
+
},
|
|
1168
|
+
delete: async (collection, id) => {
|
|
1169
|
+
await ops.delete(collection, id);
|
|
1170
|
+
changes.push([collection, id, null]);
|
|
1171
|
+
}
|
|
1172
|
+
});
|
|
1173
|
+
return {
|
|
1174
|
+
region: inner.region,
|
|
1175
|
+
get: async (c, id) => (await open(), inner.get(c, id)),
|
|
1176
|
+
query: async (c, q) => (await open(), inner.query(c, q)),
|
|
1177
|
+
async put(collection, id, doc) {
|
|
1178
|
+
await open();
|
|
1179
|
+
await inner.put(collection, id, doc);
|
|
1180
|
+
keep([[collection, id, doc]]);
|
|
1181
|
+
},
|
|
1182
|
+
async delete(collection, id) {
|
|
1183
|
+
await open();
|
|
1184
|
+
await inner.delete(collection, id);
|
|
1185
|
+
keep([[collection, id, null]]);
|
|
1186
|
+
},
|
|
1187
|
+
async transaction(fn) {
|
|
1188
|
+
await open();
|
|
1189
|
+
let changes = [];
|
|
1190
|
+
const result = await inner.transaction((tx) => {
|
|
1191
|
+
changes = [];
|
|
1192
|
+
return fn(recording(tx, changes));
|
|
1193
|
+
});
|
|
1194
|
+
keep(changes);
|
|
1195
|
+
return result;
|
|
1196
|
+
}
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
var versionOf = (data) => createHash("sha256").update(data).digest("hex");
|
|
1200
|
+
function createFileBlobs(dir, options = {}) {
|
|
1201
|
+
mkdirSync(dir, { recursive: true });
|
|
1202
|
+
const path = (key) => join(dir, createHash("sha256").update(key).digest("hex"));
|
|
1203
|
+
const need = (key) => {
|
|
1204
|
+
if (key === "") throw new Error("blob key must not be empty");
|
|
1205
|
+
};
|
|
1206
|
+
const now = options.now ?? Date.now;
|
|
1207
|
+
return {
|
|
1208
|
+
region: options.region === void 0 ? "unknown" : parseRegion(options.region),
|
|
1209
|
+
async put(key, data, opts) {
|
|
1210
|
+
need(key);
|
|
1211
|
+
assertBlobPutOptions(opts);
|
|
1212
|
+
const current = existsSync(path(key)) ? versionOf(readFileSync(path(key))) : void 0;
|
|
1213
|
+
if (opts?.ifNoneMatch === "*" && current !== void 0 || opts?.ifMatch !== void 0 && current !== opts.ifMatch) return null;
|
|
1214
|
+
const tmp = `${path(key)}.tmp`;
|
|
1215
|
+
writeFileSync(tmp, data);
|
|
1216
|
+
if (opts?.contentType) writeFileSync(`${path(key)}.type`, opts.contentType);
|
|
1217
|
+
else rmSync(`${path(key)}.type`, { force: true });
|
|
1218
|
+
renameSync(tmp, path(key));
|
|
1219
|
+
return { version: versionOf(data) };
|
|
1220
|
+
},
|
|
1221
|
+
async get(key) {
|
|
1222
|
+
need(key);
|
|
1223
|
+
if (!existsSync(path(key))) return null;
|
|
1224
|
+
const type = existsSync(`${path(key)}.type`) ? readFileSync(`${path(key)}.type`, "utf8") : void 0;
|
|
1225
|
+
const data = new Uint8Array(readFileSync(path(key)));
|
|
1226
|
+
return { data, version: versionOf(data), ...type ? { contentType: type } : {} };
|
|
1227
|
+
},
|
|
1228
|
+
async head(key) {
|
|
1229
|
+
need(key);
|
|
1230
|
+
if (!existsSync(path(key))) return null;
|
|
1231
|
+
const type = existsSync(`${path(key)}.type`) ? readFileSync(`${path(key)}.type`, "utf8") : void 0;
|
|
1232
|
+
const data = readFileSync(path(key));
|
|
1233
|
+
return { size: data.length, version: versionOf(data), ...type ? { contentType: type } : {} };
|
|
1234
|
+
},
|
|
1235
|
+
async delete(key) {
|
|
1236
|
+
need(key);
|
|
1237
|
+
rmSync(path(key), { force: true });
|
|
1238
|
+
rmSync(`${path(key)}.type`, { force: true });
|
|
1239
|
+
},
|
|
1240
|
+
/** A local path with its expiry. Nothing serves or checks it: it exists so the port is whole. */
|
|
1241
|
+
async signedUrl(key, opts) {
|
|
1242
|
+
need(key);
|
|
1243
|
+
const { method, expiresInSeconds } = opts;
|
|
1244
|
+
assertResponseHeaders(opts);
|
|
1245
|
+
if (!Number.isInteger(expiresInSeconds) || expiresInSeconds < 1 || expiresInSeconds > 604800) {
|
|
1246
|
+
throw new RangeError("expiresInSeconds must be an integer from 1 to 604800");
|
|
1247
|
+
}
|
|
1248
|
+
const extra = [
|
|
1249
|
+
opts.responseContentDisposition === void 0 ? "" : `&disposition=${encodeURIComponent(opts.responseContentDisposition)}`,
|
|
1250
|
+
opts.responseContentType === void 0 ? "" : `&type=${encodeURIComponent(opts.responseContentType)}`
|
|
1251
|
+
].join("");
|
|
1252
|
+
return `file://${path(key)}?method=${method}&expires=${now() + expiresInSeconds * 1e3}${extra}`;
|
|
1253
|
+
}
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// src/local.ts
|
|
1258
|
+
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
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
function publicNode(node, path) {
|
|
1268
|
+
const { nameKey: _nameKey, blobKey: _blobKey, purging: _purging, ...rest } = node;
|
|
1269
|
+
return path === void 0 ? rest : { path, ...rest };
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
// src/upload.ts
|
|
1273
|
+
var DEFAULT_MAX_BYTES = 25 * 1024 * 1024;
|
|
1274
|
+
var SIGNATURES = [
|
|
1275
|
+
[[137, 80, 78, 71, 13, 10, 26, 10], "image/png"],
|
|
1276
|
+
[[255, 216, 255], "image/jpeg"],
|
|
1277
|
+
[[71, 73, 70, 56], "image/gif"],
|
|
1278
|
+
[[37, 80, 68, 70, 45], "application/pdf"],
|
|
1279
|
+
[[80, 75, 3, 4], "application/zip"],
|
|
1280
|
+
[[31, 139], "application/gzip"]
|
|
1281
|
+
];
|
|
1282
|
+
function sniffMime(head) {
|
|
1283
|
+
const hit = SIGNATURES.find(([sig]) => sig.every((byte, i) => head[i] === byte));
|
|
1284
|
+
if (hit) return hit[1];
|
|
1285
|
+
const isWebp = [..."RIFF"].every((c, i) => head[i] === c.charCodeAt(0)) && [..."WEBP"].every((c, i) => head[8 + i] === c.charCodeAt(0));
|
|
1286
|
+
return isWebp ? "image/webp" : null;
|
|
1287
|
+
}
|
|
1288
|
+
function looksLikeText(head) {
|
|
1289
|
+
if (head.includes(0)) return false;
|
|
1290
|
+
for (let cut = 0; cut < 4 && cut <= head.length; cut++) {
|
|
1291
|
+
try {
|
|
1292
|
+
new TextDecoder("utf-8", { fatal: true }).decode(head.subarray(0, head.length - cut));
|
|
1293
|
+
return true;
|
|
1294
|
+
} catch {
|
|
1295
|
+
continue;
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
return false;
|
|
1299
|
+
}
|
|
1300
|
+
async function* iterate(source) {
|
|
1301
|
+
if (Symbol.asyncIterator in source) {
|
|
1302
|
+
yield* source;
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
const reader = source.getReader();
|
|
1306
|
+
try {
|
|
1307
|
+
for (; ; ) {
|
|
1308
|
+
const { done, value } = await reader.read();
|
|
1309
|
+
if (done) return;
|
|
1310
|
+
yield value;
|
|
1311
|
+
}
|
|
1312
|
+
} finally {
|
|
1313
|
+
reader.releaseLock();
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
async function upload(drive, ctx, path, source, opts = {}) {
|
|
1317
|
+
const max = opts.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
1318
|
+
const parts = [];
|
|
1319
|
+
let size = 0;
|
|
1320
|
+
for await (const chunk of iterate(source)) {
|
|
1321
|
+
size += chunk.byteLength;
|
|
1322
|
+
if (size > max) throw new DriveError("TOO_LARGE", `upload is over the limit of ${max} bytes`);
|
|
1323
|
+
parts.push(chunk);
|
|
1324
|
+
}
|
|
1325
|
+
const data = new Uint8Array(size);
|
|
1326
|
+
for (let at = 0, i = 0; i < parts.length; at += parts[i].byteLength, i++) data.set(parts[i], at);
|
|
1327
|
+
const head = data.subarray(0, 512);
|
|
1328
|
+
const mime = sniffMime(head) ?? (opts.mime !== void 0 ? safeMime(opts.mime) : looksLikeText(head) ? "text/plain" : "application/octet-stream");
|
|
1329
|
+
return drive.putFile(ctx, path, data, { mime, parents: opts.parents, onConflict: opts.onConflict });
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
export {
|
|
1333
|
+
ROOT_ID,
|
|
1334
|
+
ACTOR_KINDS,
|
|
1335
|
+
DriveError,
|
|
1336
|
+
DEFAULT_URL_SECONDS,
|
|
1337
|
+
safeMime,
|
|
1338
|
+
parseName,
|
|
1339
|
+
rootResource,
|
|
1340
|
+
createDrive,
|
|
1341
|
+
writeZip,
|
|
1342
|
+
readZip,
|
|
1343
|
+
buildExport,
|
|
1344
|
+
createFileStore,
|
|
1345
|
+
createFileBlobs,
|
|
1346
|
+
openLocal,
|
|
1347
|
+
publicNode,
|
|
1348
|
+
DEFAULT_MAX_BYTES,
|
|
1349
|
+
sniffMime,
|
|
1350
|
+
upload
|
|
1351
|
+
};
|