@anchrd/intel-api 0.23.0 → 0.25.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.
@@ -1,17 +1,82 @@
1
+ export const grantColumns = (subjectColumn) => `id, ${subjectColumn} AS resource_id, principal_type, principal_id, verb, expires_at,
2
+ created_by, created_at`;
3
+ export function mapGrant(row) {
4
+ let principal;
5
+ switch (row.principal_type) {
6
+ case "user":
7
+ principal = { type: "user", id: row.principal_id };
8
+ break;
9
+ case "email":
10
+ principal = { type: "email", email: row.principal_id };
11
+ break;
12
+ case "organization":
13
+ principal = { type: "organization" };
14
+ break;
15
+ default:
16
+ throw new Error("Unsupported grant principal type");
17
+ }
18
+ return {
19
+ id: row.id,
20
+ resourceId: row.resource_id,
21
+ principal,
22
+ verb: row.verb,
23
+ expiresAt: row.expires_at,
24
+ createdBy: row.created_by,
25
+ createdAt: row.created_at,
26
+ };
27
+ }
28
+ // How a principal is written into the subject columns of both grant tables. An organization is the
29
+ // literal `*` every reader above matches on; an email is folded to lower case on the way in so the
30
+ // comparison there has one shape to worry about.
31
+ export function principalColumns(principal) {
32
+ if (principal.type === "user")
33
+ return { type: "user", id: principal.id };
34
+ if (principal.type === "email")
35
+ return { type: "email", id: principal.email.toLowerCase() };
36
+ return { type: "organization", id: "*" };
37
+ }
1
38
  const principalMatch = `(
2
39
  (grant_row.principal_type = 'user' AND grant_row.principal_id = ?)
3
40
  OR (grant_row.principal_type = 'email' AND lower(grant_row.principal_id) = lower(?))
4
41
  OR (grant_row.principal_type = 'organization' AND grant_row.principal_id = '*')
5
42
  )`;
6
- function grantExists(nodeColumn) {
43
+ /**
44
+ * Whether a grant row is in force at the moment bound after it. Takes one binding: that moment.
45
+ *
46
+ * ⚠️ The ONE place this repository decides what an expiry means, for both grant tables and for
47
+ * every statement that reads either — the point check, the subtree walk, and the two effective
48
+ * views alike. It is a fragment rather than part of `grantExists` because half the readers do not
49
+ * ask about a principal at all: `listEffectiveAccess` lists every grant along a path, and
50
+ * `callReach` asks only whether an organization-wide `execute` stands. Those had written the
51
+ * condition out by hand, which is exactly the second set of doors the header above warns about —
52
+ * a grace period, a different comparison, and the two spellings drift apart in silence.
53
+ *
54
+ * `git grep "expires_at IS NULL" -- packages/api/src` must find this line and nothing else.
55
+ */
56
+ export const grantInForce = "(grant_row.expires_at IS NULL OR grant_row.expires_at > ?)";
57
+ /**
58
+ * One grant row that is in force, asked of one table.
59
+ *
60
+ * ⚠️ The table is a parameter and the rest is not, which is the whole reason #530 could give a flow
61
+ * its own grants without a second set of doors. `flow_grants` and `node_grants` differ in their
62
+ * subject column and in nothing else — same principal shapes, same verbs, same expiry — so a
63
+ * question asked of one is asked of the other by the same string. A hand-written copy for flows is
64
+ * how one of the two would eventually stop honouring `expires_at`.
65
+ */
66
+ function grantExists(table, subjectColumn, targetColumn) {
7
67
  return `EXISTS (
8
- SELECT 1 FROM node_grants grant_row
9
- WHERE grant_row.node_id = ${nodeColumn}
68
+ SELECT 1 FROM ${table} grant_row
69
+ WHERE grant_row.${subjectColumn} = ${targetColumn}
10
70
  AND ${principalMatch}
11
71
  AND grant_row.verb = ?
12
- AND (grant_row.expires_at IS NULL OR grant_row.expires_at > ?)
72
+ AND ${grantInForce}
13
73
  )`;
14
74
  }
75
+ const nodeGrantExists = (nodeColumn) => grantExists("node_grants", "node_id", nodeColumn);
76
+ // A grant that sits on the flow itself (#530), beside whatever its folder already passes down. It
77
+ // adds to the folder's reach and never replaces it, exactly as a grant on a document adds to its
78
+ // folder's — the two are OR-ed everywhere this appears.
79
+ const flowGrantExists = (flowColumn) => grantExists("flow_grants", "flow_id", flowColumn);
15
80
  // Bindings for one `grantExists`: principal (twice), verb, and the moment expiry is measured
16
81
  // against. Callers concatenate these in SQL order; the helpers below say which order that is.
17
82
  function grantBindings(actor, verb, now) {
@@ -29,7 +94,7 @@ function subtreeWalk(name) {
29
94
  FROM nodes seed
30
95
  WHERE ? = 1
31
96
  OR seed.owner_id = ?
32
- OR ${grantExists("seed.id")}
97
+ OR ${nodeGrantExists("seed.id")}
33
98
  UNION
34
99
  SELECT child.id, child.parent_id
35
100
  FROM nodes child
@@ -70,14 +135,28 @@ export const readableOrRunnableCte = `WITH RECURSIVE ${subtreeWalk("readable")},
70
135
  * A flow the actor may open *or* may run, for a statement carrying `readableOrRunnableCte`. It is
71
136
  * the callable rule of ADR-0004 §2/§3 — `execute` without `read` is the library, a building block
72
137
  * anyone may run and few may open — asked of a whole list of flows at once instead of one flow at a
73
- * time. Its bindings follow the two CTEs' and are `flowInSubtreeBindings(actor)`.
138
+ * time. Its bindings follow the two CTEs' and are `flowCallableBindings(actor, now)`.
139
+ *
140
+ * ⚠️ Both direct grants, and both are needed. A flow handed out on its own for `execute` alone is
141
+ * the library case shrunk to one flow — runnable by people who may not open it — and leaving `read`
142
+ * out here would hide a flow somebody was explicitly given to look at.
74
143
  */
75
144
  export const flowCallable = `(
76
145
  ? = 1
77
146
  OR flow.owner_id = ?
78
147
  OR flow.parent_id IN (SELECT id FROM readable)
79
148
  OR flow.parent_id IN (SELECT id FROM runnable)
149
+ OR ${flowGrantExists("flow.id")}
150
+ OR ${flowGrantExists("flow.id")}
80
151
  )`;
152
+ export function flowCallableBindings(actor, now) {
153
+ return [
154
+ actor.isAdmin ? 1 : 0,
155
+ actor.id,
156
+ ...grantBindings(actor, "read", now),
157
+ ...grantBindings(actor, "execute", now),
158
+ ];
159
+ }
81
160
  /**
82
161
  * The point check for one node: the node itself and every ancestor above it. Cheaper than
83
162
  * the subtree walk and the same answer, because a grant reaches down and never sideways.
@@ -99,16 +178,21 @@ export const nodeVerbQuery = `WITH RECURSIVE ancestors(id, parent_id, owner_id)
99
178
  FROM ancestors
100
179
  WHERE ? = 1
101
180
  OR owner_id = ?
102
- OR ${grantExists("ancestors.id")}
181
+ OR ${nodeGrantExists("ancestors.id")}
103
182
  LIMIT 1`;
104
183
  export function nodeVerbBindings(nodeId, actor, verb, now) {
105
184
  return [nodeId, actor.isAdmin ? 1 : 0, actor.id, ...grantBindings(actor, verb, now)];
106
185
  }
107
186
  /**
108
- * The same question for a flow. A flow carries no grant of its own any more: what reaches it is the
109
- * folder it is filed in and that folder's ancestors. Its owner keeps it, the way a node's
110
- * owner keeps theirs otherwise a flow at the root of the tree would be unreachable by the person
111
- * who created it. `UNION` for the same reason as above.
187
+ * The same question for a flow. Three things reach it, and they are OR-ed rather than ranked: its
188
+ * owner, a grant on the folder it is filed in or any folder above that, and since #530 a grant
189
+ * that sits on the flow itself. Its owner keeps it the way a node's owner keeps theirs, otherwise a
190
+ * flow at the root of the tree would be unreachable by the person who created it. `UNION` for the
191
+ * same reason as above.
192
+ *
193
+ * ⚠️ The direct grant is asked BEFORE the ancestor walk, and only because it is cheaper: a point
194
+ * lookup on an indexed column against a recursive walk of the tree. It decides nothing the walk
195
+ * would have decided differently — both are the same OR.
112
196
  */
113
197
  export const flowVerbQuery = `WITH RECURSIVE ancestors(id, parent_id) AS (
114
198
  SELECT folder.id, folder.parent_id
@@ -126,17 +210,35 @@ export const flowVerbQuery = `WITH RECURSIVE ancestors(id, parent_id) AS (
126
210
  AND (
127
211
  ? = 1
128
212
  OR flow.owner_id = ?
129
- OR EXISTS (SELECT 1 FROM ancestors WHERE ${grantExists("ancestors.id")})
213
+ OR ${flowGrantExists("flow.id")}
214
+ OR EXISTS (SELECT 1 FROM ancestors WHERE ${nodeGrantExists("ancestors.id")})
130
215
  )
131
216
  LIMIT 1`;
132
217
  export function flowVerbBindings(flowId, actor, verb, now) {
133
- return [flowId, flowId, actor.isAdmin ? 1 : 0, actor.id, ...grantBindings(actor, verb, now)];
218
+ return [
219
+ flowId,
220
+ flowId,
221
+ actor.isAdmin ? 1 : 0,
222
+ actor.id,
223
+ ...grantBindings(actor, verb, now),
224
+ ...grantBindings(actor, verb, now),
225
+ ];
134
226
  }
135
227
  /**
136
228
  * The predicate a query over `flows` uses when it already carries `subtreeCte` for the same verb.
137
229
  * Its bindings follow the CTE's.
230
+ *
231
+ * ⚠️ The verb is now a binding of its own, and it has to be the SAME verb the CTE was seeded with.
232
+ * The walk answers "which folders", the direct grant answers "this flow" — asking them for two
233
+ * different verbs would produce a predicate that is neither, and it is the kind of mismatch nothing
234
+ * fails on: the query still runs and quietly hands out the wrong list.
138
235
  */
139
- export const flowInSubtree = `(? = 1 OR flow.owner_id = ? OR flow.parent_id IN (SELECT id FROM allowed))`;
140
- export function flowInSubtreeBindings(actor) {
141
- return [actor.isAdmin ? 1 : 0, actor.id];
236
+ export const flowInSubtree = `(
237
+ ? = 1
238
+ OR flow.owner_id = ?
239
+ OR flow.parent_id IN (SELECT id FROM allowed)
240
+ OR ${flowGrantExists("flow.id")}
241
+ )`;
242
+ export function flowInSubtreeBindings(actor, verb, now) {
243
+ return [actor.isAdmin ? 1 : 0, actor.id, ...grantBindings(actor, verb, now)];
142
244
  }
@@ -1,8 +1,7 @@
1
- import { descendantsCte, nodeVerbBindings, nodeVerbQuery, subtreeBindings, subtreeCte, } from "./db-grants.js";
1
+ import { descendantsCte, grantColumns as grantColumnsFor, grantInForce, mapGrant, nodeVerbBindings, nodeVerbQuery, principalColumns, subtreeBindings, subtreeCte, } from "./db-grants.js";
2
2
  const versionColumns = `id, node_id, sequence, content_key, media_type, content_hash,
3
3
  size, segment, created_by, created_at`;
4
- const grantColumns = `id, node_id, principal_type, principal_id, verb, expires_at,
5
- created_by, created_at`;
4
+ const grantColumns = grantColumnsFor("node_id");
6
5
  const linkColumns = `link.id, link.source_node_id, link.target_node_id, link.relation,
7
6
  link.origin, link.label, link.created_by, link.created_at`;
8
7
  // The first row of each node, in the order they arrive. A chunked kind — one whose index holds
@@ -47,31 +46,6 @@ function mapVersion(row) {
47
46
  createdAt: row.created_at,
48
47
  };
49
48
  }
50
- function mapGrant(row) {
51
- let principal;
52
- switch (row.principal_type) {
53
- case "user":
54
- principal = { type: "user", id: row.principal_id };
55
- break;
56
- case "email":
57
- principal = { type: "email", email: row.principal_id };
58
- break;
59
- case "organization":
60
- principal = { type: "organization" };
61
- break;
62
- default:
63
- throw new Error("Unsupported node grant principal type");
64
- }
65
- return {
66
- id: row.id,
67
- resourceId: row.node_id,
68
- principal,
69
- verb: row.verb,
70
- expiresAt: row.expires_at,
71
- createdBy: row.created_by,
72
- createdAt: row.created_at,
73
- };
74
- }
75
49
  function mapLink(row) {
76
50
  return {
77
51
  id: row.id,
@@ -1029,7 +1003,7 @@ export function createNodeRepository(deps) {
1029
1003
  (SELECT json_group_array(owner_id)
1030
1004
  FROM (SELECT DISTINCT owner_id FROM ancestors ORDER BY owner_id)) AS owner_ids_json,
1031
1005
  (SELECT json_group_array(json_object(
1032
- 'id', id, 'node_id', node_id, 'principal_type', principal_type,
1006
+ 'id', id, 'resource_id', node_id, 'principal_type', principal_type,
1033
1007
  'principal_id', principal_id, 'verb', verb, 'expires_at', expires_at,
1034
1008
  'created_by', created_by, 'created_at', created_at
1035
1009
  ))
@@ -1039,7 +1013,7 @@ export function createNodeRepository(deps) {
1039
1013
  grant_row.created_by, grant_row.created_at
1040
1014
  FROM node_grants grant_row
1041
1015
  JOIN ancestors ON ancestors.id = grant_row.node_id
1042
- WHERE grant_row.expires_at IS NULL OR grant_row.expires_at > ?
1016
+ WHERE ${grantInForce}
1043
1017
  ORDER BY grant_row.principal_type, grant_row.principal_id, grant_row.verb
1044
1018
  )) AS grants_json`)
1045
1019
  .bind(resourceId, deps.now().toISOString())
@@ -1066,7 +1040,7 @@ export function createNodeRepository(deps) {
1066
1040
  WHERE grant_row.id <> ?
1067
1041
  AND grant_row.principal_type = 'organization'
1068
1042
  AND grant_row.verb = 'execute'
1069
- AND (grant_row.expires_at IS NULL OR grant_row.expires_at > ?)
1043
+ AND ${grantInForce}
1070
1044
  LIMIT 1`)
1071
1045
  .bind(nodeId, exceptGrantId, deps.now().toISOString())
1072
1046
  .first();
@@ -1173,12 +1147,7 @@ export function createNodeRepository(deps) {
1173
1147
  },
1174
1148
  async setGrant(input) {
1175
1149
  const grant = input.grant;
1176
- const principalType = grant.principal.type;
1177
- const principalId = grant.principal.type === "user"
1178
- ? grant.principal.id
1179
- : grant.principal.type === "email"
1180
- ? grant.principal.email.toLowerCase()
1181
- : "*";
1150
+ const { type: principalType, id: principalId } = principalColumns(grant.principal);
1182
1151
  try {
1183
1152
  await deps.db.batch([
1184
1153
  deps.db
package/dist/auth/auth.js CHANGED
@@ -218,28 +218,55 @@ export function createBrowserAuth(deps) {
218
218
  ? `/auth/connect?returnTo=${encodeURIComponent(pending.returnTo)}`
219
219
  : withConnectError(pending.returnTo));
220
220
  }
221
- const tokens = await deps.oauth.exchange({
222
- issuer: pending.kind === "connection-pending" ? pending.issuer : gateIssuer,
223
- clientId: pending.clientId,
224
- callbackUrl: requestUrl,
225
- redirectUri,
226
- resource: pending.kind === "connection-pending" ? pending.resource : deps.resource,
227
- codeVerifier: pending.codeVerifier,
228
- state: pending.state,
229
- });
230
- if (pending.kind === "connection-pending") {
231
- await deps.portalTokens.write(pending.userId, {
232
- accessToken: tokens.accessToken,
233
- refreshToken: tokens.refreshToken ?? null,
234
- expiresAt: deps.now().getTime() + Math.max(30, tokens.expiresIn) * 1_000,
235
- issuer: pending.issuer,
221
+ // ⚠️ Everything below runs with the handoff READ, and that is the whole difference between
222
+ // this catch and the one on the route (#444): here a failure knows where the person was
223
+ // going, and for a connection handoff it still holds their parked Intel session. The route
224
+ // can only send somebody to a decided screen, so whatever can be answered here is answered
225
+ // here — the token exchange, the portal token write and the sealing of the new session all
226
+ // used to end as a bare 500 in JSON, in the middle of a sign-in.
227
+ try {
228
+ const tokens = await deps.oauth.exchange({
229
+ issuer: pending.kind === "connection-pending" ? pending.issuer : gateIssuer,
236
230
  clientId: pending.clientId,
237
- resource: pending.resource,
231
+ callbackUrl: requestUrl,
232
+ redirectUri,
233
+ resource: pending.kind === "connection-pending" ? pending.resource : deps.resource,
234
+ codeVerifier: pending.codeVerifier,
235
+ state: pending.state,
238
236
  });
239
- return await restore(pending.user, pending.returnTo, pending.returnTo);
237
+ if (pending.kind === "connection-pending") {
238
+ await deps.portalTokens.write(pending.userId, {
239
+ accessToken: tokens.accessToken,
240
+ refreshToken: tokens.refreshToken ?? null,
241
+ expiresAt: deps.now().getTime() + Math.max(30, tokens.expiresIn) * 1_000,
242
+ issuer: pending.issuer,
243
+ clientId: pending.clientId,
244
+ resource: pending.resource,
245
+ });
246
+ return await restore(pending.user, pending.returnTo, pending.returnTo);
247
+ }
248
+ const session = await userSession(pending.clientId, tokens);
249
+ return redirect(pending.returnTo, sessionCookie(await deps.sessions.seal(session), secure, (session.expiresAt - deps.now().getTime()) / 1_000));
250
+ }
251
+ catch (error) {
252
+ // The exception is the only thing that still holds the reason: what the reader gets is a
253
+ // fixed marker, and a marker names a KIND of failure, never its cause (#93).
254
+ reportUnexpectedError(error);
255
+ // `portal_sign_in_failed` is the marker `/auth/connect` already writes for exactly this
256
+ // situation, so nothing new is invented here and no screen has to learn a word. The Tools
257
+ // screen reads it as "the sign-in is broken", which is true of both handoffs — a Gate login
258
+ // that could not be exchanged is no more the reader's doing than a portal one.
259
+ if (pending.kind === "connection-pending") {
260
+ // Every other way out of the callback puts the parked Intel session back, and so does
261
+ // this one: the portal attempt must never cost somebody their Intel login.
262
+ return await restore(pending.user, pending.returnTo, withConnectError(pending.returnTo, "portal_sign_in_failed"));
263
+ }
264
+ // A Gate login handoff parks nothing, so there is no session to put back — and the cookie
265
+ // is spent either way: its verifier belongs to an authorization code that has now been
266
+ // used. Clearing it is what makes the next attempt start a fresh one instead of replaying
267
+ // this one.
268
+ return redirect(withConnectError(pending.returnTo, "portal_sign_in_failed"), sessionCookie("", secure, 0));
240
269
  }
241
- const session = await userSession(pending.clientId, tokens);
242
- return redirect(pending.returnTo, sessionCookie(await deps.sessions.seal(session), secure, (session.expiresAt - deps.now().getTime()) / 1_000));
243
270
  },
244
271
  logout() {
245
272
  return redirect("/", sessionCookie("", secure, 0));
@@ -13,6 +13,42 @@ const IntelConfig = z.strictObject({
13
13
  const Catalog = z.record(z.string(), z.string());
14
14
  const defaultTheme = `/* Generated by \`intel build\`. Intel defaults are active. */\n`;
15
15
  const defaultLogo = `export const customLogoUrl: string | null = null;\n`;
16
+ // The `_headers` file of Workers Assets, written INTO THE BUILD OUTPUT (#543, sibling of
17
+ // anchrd/gate#329).
18
+ //
19
+ // Without it every asset answers with the platform default `public, max-age=0, must-revalidate`,
20
+ // and `public` lets a browser store an ERROR answer too. A `403` that appears once during setup is
21
+ // then replayed on every navigation WITHOUT a request leaving the browser — invisible in the worker
22
+ // log, in the Cloudflare Access log, in the firewall and in Cloudflare Analytics, while `curl`
23
+ // against the same address keeps answering `200`. The second rule matters more here than in Gate:
24
+ // the UI ships 1.9 MB of fingerprinted assets that are revalidated on every navigation today.
25
+ //
26
+ // ⚠️ `! Cache-Control` is not decoration, and the naive two-rule form from the ticket is WRONG.
27
+ // A request matching several rules inherits ALL their headers, and a header named twice is joined
28
+ // with a comma. Measured against `wrangler dev` 4.112.0 with both spellings side by side:
29
+ // without the detach /assets/app.js → `no-store, public, max-age=31536000, immutable`
30
+ // with the detach /assets/app.js → `public, max-age=31536000, immutable`
31
+ // The joined value carries `no-store`, so those 1.9 MB would never be cached at all — the exact
32
+ // opposite of the second rule's purpose.
33
+ //
34
+ // ⚠️ It has to be written into the TEMPORARY directory, before `replaceDir`. The next run's
35
+ // `--emptyOutDir` applies to that directory, so a file placed into the live outDir afterwards
36
+ // survives exactly one build. And `packages/ui/public/` is no way either: the `files` field of
37
+ // @anchrd/intel-ui does not list `public`, so the file would look right in the repository and never
38
+ // reach a customer.
39
+ //
40
+ // The three `cache-control` writes in this package do not contradict it: `auth/auth.ts` (redirect),
41
+ // `http/http.ts` (zipResponse, attachment bytes) all answer `no-store` under `/auth/*` and `/api/*`,
42
+ // which the assets layer never sees. Nothing serves an SPA route under `/assets/`, so the
43
+ // `immutable` rule cannot swallow one.
44
+ const headersFile = `# Generated by \`intel build\` — do not edit by hand.
45
+ /*
46
+ Cache-Control: no-store
47
+
48
+ /assets/*
49
+ ! Cache-Control
50
+ Cache-Control: public, max-age=31536000, immutable
51
+ `;
16
52
  // Customer paths from intel.json are written into generated CSS and TypeScript comments. A path
17
53
  // containing `*/` or a newline would close the comment early, leaving broken output or — in
18
54
  // custom-logo.ts — customer-controlled text outside the comment and inside the UI bundle.
@@ -130,6 +166,9 @@ export function createBuild(deps) {
130
166
  deps.log(`Error: Intel UI build exited with ${exitCode}.`);
131
167
  return 1;
132
168
  }
169
+ // Into the temporary directory, not into outDir — see `headersFile`. Vite has just emptied
170
+ // and refilled this directory; anything written here travels with the swap.
171
+ await deps.writeTextFile(`${temporaryOutDir}/_headers`, headersFile);
133
172
  await deps.replaceDir(temporaryOutDir, location.outDir);
134
173
  deps.log(`Intel UI built at ${location.outDir}.`);
135
174
  return 0;
@@ -336,6 +336,15 @@ export function createBundle(deps) {
336
336
  }
337
337
  async function tableCsv(nodeId) {
338
338
  const keys = await deps.repository.listVersionContentKeys(nodeId);
339
+ // ⚠️ `some` on an empty list is false, so no segments at all used to walk past the guard below
340
+ // and return `[].join("")` — the empty string, written into the zip as a file of zero bytes
341
+ // (#534). A table that has never been defined no longer reaches this function at all: it has no
342
+ // current version, and `plannedNode` marks it absent instead. An empty list HERE therefore means
343
+ // something else — a current version whose segments are gone — which is the loss the guard below
344
+ // already names, and it deserves the same answer rather than a silently empty file.
345
+ if (keys.length === 0) {
346
+ throw new IntelError(500, "content_missing", "Version content is missing");
347
+ }
339
348
  const segments = await Promise.all(keys.map(async (key) => await deps.content.get(key)));
340
349
  if (segments.some((segment) => segment === null)) {
341
350
  throw new IntelError(500, "content_missing", "Version content is missing");
@@ -351,6 +360,27 @@ export function createBundle(deps) {
351
360
  return body;
352
361
  };
353
362
  }
363
+ /**
364
+ * The bytes of an attachment version, or the same refusal the text path has always given (#560).
365
+ *
366
+ * ⚠️ A `contentKey` in the row and nothing behind it in R2 is data LOSS, and the attachment path
367
+ * was the one path that answered it with silence: `getStream` returned `null`, the zip got a file
368
+ * of zero bytes, and the export reported success. Whoever filed that zip away as a backup learns
369
+ * what it holds when they restore it — the one moment nothing can be done about it any more.
370
+ *
371
+ * This is deliberately NOT the `absence` answer. "Never uploaded" is a state of the node and
372
+ * travels in the manifest; "the object is gone" is a broken installation, and a bundle must not
373
+ * be able to describe it as if it were fine. Same distinction as `textLoader` and `tableCsv`.
374
+ */
375
+ function streamLoader(contentKey) {
376
+ return async () => {
377
+ const stream = await deps.content.getStream(contentKey);
378
+ if (stream === null) {
379
+ throw new IntelError(500, "content_missing", "Version content is missing");
380
+ }
381
+ return stream;
382
+ };
383
+ }
354
384
  function plannedNode(row, directory, used) {
355
385
  const { node, version } = row;
356
386
  const base = sanitizeName(node.title);
@@ -371,6 +401,30 @@ export function createBundle(deps) {
371
401
  }
372
402
  if (node.kind === "table") {
373
403
  const path = `${directory}${uniqueName(used, base, ".csv")}`;
404
+ // ⚠️ A table with no current version has never been given a header, and for a table the
405
+ // canonical content IS the CSV: there is nothing to write. It used to travel as a file of zero
406
+ // bytes, which the holder of the zip cannot tell apart from a table whose content was lost on
407
+ // the way — the very case `tableCsv` refuses as `content_missing`. One file, two meanings, no
408
+ // way to read which (#534).
409
+ //
410
+ // ⚠️ The entry STAYS in the manifest, and that is the half that matters: dropping it would
411
+ // take the node's title, its description and its place in the tree out of the backup too, and
412
+ // the import would recreate a subtree with a node missing from it without anybody noticing.
413
+ // What travels instead is the node without bytes, plus the word for what is not there.
414
+ if (version === null) {
415
+ return {
416
+ manifest: {
417
+ id: node.id,
418
+ kind: "table",
419
+ title: node.title,
420
+ description: node.description,
421
+ mediaType: TableMediaType,
422
+ path,
423
+ absence: "no-content",
424
+ },
425
+ content: { type: "absent" },
426
+ };
427
+ }
374
428
  return {
375
429
  manifest: {
376
430
  id: node.id,
@@ -387,19 +441,38 @@ export function createBundle(deps) {
387
441
  // Original bytes under the original name: an attachment's title IS its file name, so no
388
442
  // extension is imposed on it.
389
443
  const path = `${directory}${uniqueName(used, base, "")}`;
444
+ // ⚠️ An attachment with no current version was created and never uploaded to, and for an
445
+ // attachment the canonical content IS the bytes: there is nothing to write. It used to travel
446
+ // as a file of zero bytes — the same one file with two meanings the table branch above
447
+ // refuses, and here the second meaning is a lost R2 object (#560).
448
+ //
449
+ // ⚠️ The entry STAYS in the manifest for the same reason it does for a table: dropping it
450
+ // would take the node's title, its description and its place in the tree out of the backup,
451
+ // and the import would rebuild a subtree with a node missing from it and say nothing.
452
+ if (version === null) {
453
+ return {
454
+ manifest: {
455
+ id: node.id,
456
+ kind: "attachment",
457
+ title: node.title,
458
+ description: node.description,
459
+ mediaType: "application/octet-stream",
460
+ path,
461
+ absence: "no-content",
462
+ },
463
+ content: { type: "absent" },
464
+ };
465
+ }
390
466
  return {
391
467
  manifest: {
392
468
  id: node.id,
393
469
  kind: "attachment",
394
470
  title: node.title,
395
471
  description: node.description,
396
- mediaType: version?.mediaType ?? "application/octet-stream",
472
+ mediaType: version.mediaType ?? "application/octet-stream",
397
473
  path,
398
474
  },
399
- content: {
400
- type: "stream",
401
- load: async () => version === null ? null : await deps.content.getStream(version.contentKey),
402
- },
475
+ content: { type: "stream", load: streamLoader(version.contentKey) },
403
476
  };
404
477
  }
405
478
  // ⚠️ A row of a kind this build no longer makes — an `agent` or a `board` written before #390
@@ -574,18 +647,25 @@ export function createBundle(deps) {
574
647
  await writer.ready;
575
648
  continue;
576
649
  }
650
+ // Nothing is written for it — not even an empty file, which is the whole point (#534). The
651
+ // manifest carries the node and says why its path is unoccupied.
652
+ if (entry.content.type === "absent")
653
+ continue;
577
654
  if (entry.content.type === "text") {
578
655
  await pushText(entry.manifest.path, await entry.content.load());
579
656
  continue;
580
657
  }
658
+ // ⚠️ No `null` branch here any more, and its removal is the repair (#560): it wrote a file
659
+ // of zero bytes for "the loader has nothing", which was true both for an attachment nobody
660
+ // ever uploaded to and for one whose object had left R2. The first is now `absent` above
661
+ // and never reaches this loop; the second throws out of `load()` and aborts the whole zip,
662
+ // which is what a backup that cannot be written is supposed to do.
663
+ //
664
+ // The loader runs BEFORE the entry is added, so a refusal leaves no half-opened file
665
+ // behind in the zip that nothing will ever terminate.
666
+ const stream = await entry.content.load();
581
667
  const file = new ZipPassThrough(entry.manifest.path);
582
668
  zip.add(file);
583
- const stream = await entry.content.load();
584
- if (stream === null) {
585
- file.push(new Uint8Array(0), true);
586
- await writer.ready;
587
- continue;
588
- }
589
669
  const reader = stream.getReader();
590
670
  for (;;) {
591
671
  const { done, value } = await reader.read();
@@ -648,7 +728,11 @@ export function createBundle(deps) {
648
728
  if (isFolder)
649
729
  folderIdByPath.set(entry.path.endsWith("/") ? entry.path : `${entry.path}/`, newId);
650
730
  let body = null;
651
- if (!isFolder) {
731
+ // ⚠️ An entry that declares an `absence` names a path with nothing at it ON PURPOSE, so the
732
+ // zip is complete rather than broken (#534). Without this the export written by this very
733
+ // build would refuse its own import — and the node it describes arrives the way it left: a
734
+ // table with a title, a place in the tree and no content.
735
+ if (!isFolder && entry.absence === undefined) {
652
736
  const found = files.get(entry.path);
653
737
  if (found === undefined) {
654
738
  throw new IntelError(400, "import_bundle_incomplete", `The manifest names a file the zip does not carry: ${entry.path}`);
@@ -933,9 +1017,14 @@ export function createBundle(deps) {
933
1017
  */
934
1018
  throw new IntelError(400, "import_kind_parked", `Bundle entry “${entry.path}” is a ${entry.kind}, and ${entry.kind}s are not part of this installation any more (anchrd/intel#385). The bundle was exported from a version that still had them; the code is on the branch parked/agents-and-board. Remove the entry from the bundle to import the rest.`);
935
1019
  }
936
- else if (entry.kind === "attachment") {
937
- const bytes = entry.body ?? new Uint8Array(0);
938
- version = versionRowFor(entry, bytes, entry.mediaType ?? "application/octet-stream", await deps.hash(bytes), null);
1020
+ else if (entry.kind === "attachment" && entry.body !== null) {
1021
+ // ⚠️ `entry.body === null` reaches here only for a manifest entry that declared an
1022
+ // absence a naked folder always has the bytes of the file it names. It used to be read
1023
+ // as `new Uint8Array(0)` and given a version of zero bytes, which is a DIFFERENT node
1024
+ // from the one that was exported: the export said "never uploaded to", and the import
1025
+ // would answer with an upload of nothing. No version is what came out of that
1026
+ // installation and no version is what goes back in (#560).
1027
+ version = versionRowFor(entry, entry.body, entry.mediaType ?? "application/octet-stream", await deps.hash(entry.body), null);
939
1028
  }
940
1029
  if (version !== null)
941
1030
  versions.push(version);