@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.
- package/README.md +41 -9
- package/dist/adapters/db/db-flows.js +210 -16
- package/dist/adapters/db/db-grants.d.ts +61 -9
- package/dist/adapters/db/db-grants.js +118 -16
- package/dist/adapters/db/db.js +6 -37
- package/dist/auth/auth.js +46 -19
- package/dist/build/build.js +39 -0
- package/dist/bundle/bundle.js +104 -15
- package/dist/cli/cli.js +114 -16
- package/dist/flows/flows.js +196 -17
- package/dist/flows/flows.types.d.ts +49 -3
- package/dist/http/http.js +28 -4
- package/dist/intel/intel.js +29 -1
- package/dist/mcp/mcp.js +41 -3
- package/dist/nodes/nodes.js +26 -1
- package/dist/shared/grant-expiry/grant-expiry.d.ts +19 -0
- package/dist/shared/grant-expiry/grant-expiry.js +26 -0
- package/migrations/0021_a_flow_carries_its_own_grant.sql +44 -0
- package/package.json +3 -3
|
@@ -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
|
-
|
|
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
|
|
9
|
-
WHERE grant_row
|
|
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
|
|
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 ${
|
|
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 `
|
|
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 ${
|
|
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.
|
|
109
|
-
* folder it is filed in
|
|
110
|
-
*
|
|
111
|
-
* who created it. `UNION` for the
|
|
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
|
|
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 [
|
|
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 = `(
|
|
140
|
-
|
|
141
|
-
|
|
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
|
}
|
package/dist/adapters/db/db.js
CHANGED
|
@@ -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 =
|
|
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, '
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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));
|
package/dist/build/build.js
CHANGED
|
@@ -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;
|
package/dist/bundle/bundle.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
938
|
-
|
|
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);
|