@arcote.tech/platform 0.7.26 → 0.7.28
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/package.json +5 -5
- package/src/module-loader.ts +134 -42
- package/tests/manifest-401-recovery.test.ts +120 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcote.tech/platform",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.7.
|
|
4
|
+
"version": "0.7.28",
|
|
5
5
|
"private": false,
|
|
6
6
|
"author": "Przemysław Krasiński [arcote.tech]",
|
|
7
7
|
"description": "Arc Platform — module system, router, layout, theme, i18n, platform app shell",
|
|
@@ -14,12 +14,12 @@
|
|
|
14
14
|
"type-check": "tsc --noEmit"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@arcote.tech/arc-ds": "^0.7.
|
|
18
|
-
"@arcote.tech/arc-react": "^0.7.
|
|
17
|
+
"@arcote.tech/arc-ds": "^0.7.28",
|
|
18
|
+
"@arcote.tech/arc-react": "^0.7.28"
|
|
19
19
|
},
|
|
20
20
|
"peerDependencies": {
|
|
21
|
-
"@arcote.tech/arc": "^0.7.
|
|
22
|
-
"@arcote.tech/arc-otel": "^0.7.
|
|
21
|
+
"@arcote.tech/arc": "^0.7.28",
|
|
22
|
+
"@arcote.tech/arc-otel": "^0.7.28",
|
|
23
23
|
"@lingui/core": "^5.0.0",
|
|
24
24
|
"@lingui/react": "^5.0.0",
|
|
25
25
|
"framer-motion": "^12.0.0",
|
package/src/module-loader.ts
CHANGED
|
@@ -110,27 +110,69 @@ function buildTokensHeader(tokens: Record<string, string>): string {
|
|
|
110
110
|
.join(",");
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
function buildHeaders(tokens
|
|
114
|
-
const resolved = tokens ?? getAllPersistedTokens();
|
|
113
|
+
function buildHeaders(tokens: Record<string, string>): HeadersInit {
|
|
115
114
|
const headers: HeadersInit = {};
|
|
116
|
-
const tokensHeader = buildTokensHeader(
|
|
115
|
+
const tokensHeader = buildTokensHeader(tokens);
|
|
117
116
|
if (tokensHeader) headers["X-Arc-Tokens"] = tokensHeader;
|
|
118
117
|
// Also send first token as Authorization for backwards compatibility
|
|
119
|
-
const firstToken = Object.values(
|
|
118
|
+
const firstToken = Object.values(tokens)[0];
|
|
120
119
|
if (firstToken) headers["Authorization"] = `Bearer ${firstToken}`;
|
|
121
120
|
return headers;
|
|
122
121
|
}
|
|
123
122
|
|
|
124
|
-
/**
|
|
123
|
+
/** Remove the named token scopes from localStorage (invalid/rejected tokens). */
|
|
124
|
+
function clearPersistedTokens(scopes: string[]): void {
|
|
125
|
+
if (typeof localStorage === "undefined") return;
|
|
126
|
+
for (const scope of scopes) localStorage.removeItem(`arc:token:${scope}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Single manifest request with an explicit token map. `bust` forces a
|
|
130
|
+
* cache-busted request (query param + `cache: "reload"`). */
|
|
131
|
+
function fetchManifestOnce(
|
|
132
|
+
baseUrl: string,
|
|
133
|
+
tokens: Record<string, string>,
|
|
134
|
+
bust?: string,
|
|
135
|
+
): Promise<Response> {
|
|
136
|
+
const url = bust ? `${baseUrl}/api/modules?t=${bust}` : `${baseUrl}/api/modules`;
|
|
137
|
+
return fetch(url, {
|
|
138
|
+
headers: buildHeaders(tokens),
|
|
139
|
+
...(bust ? { cache: "reload" as RequestCache } : {}),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Fetch the module manifest. Self-heals a rejected token: if the request
|
|
145
|
+
* carried token(s) and the server answers 401/403 (e.g. the token was minted
|
|
146
|
+
* with a rotated secret, or came from another environment), the offending
|
|
147
|
+
* tokens are dropped from localStorage and the manifest is refetched
|
|
148
|
+
* anonymously. Without this a single stale token bricks the whole boot with
|
|
149
|
+
* "Failed to load application" until the user manually clears storage.
|
|
150
|
+
*
|
|
151
|
+
* `bust` forces a cache-busted request — used by the group-import recovery
|
|
152
|
+
* path when a stale cached manifest pointed at chunks that no longer exist.
|
|
153
|
+
*/
|
|
125
154
|
async function fetchManifest(
|
|
126
155
|
baseUrl: string,
|
|
127
156
|
tokens?: Record<string, string>,
|
|
157
|
+
bust?: string,
|
|
128
158
|
): Promise<BuildManifest> {
|
|
129
|
-
const
|
|
130
|
-
const res = await
|
|
131
|
-
if (
|
|
132
|
-
|
|
133
|
-
|
|
159
|
+
const resolved = tokens ?? getAllPersistedTokens();
|
|
160
|
+
const res = await fetchManifestOnce(baseUrl, resolved, bust);
|
|
161
|
+
if (res.ok) return res.json();
|
|
162
|
+
|
|
163
|
+
const sentScopes = Object.keys(resolved);
|
|
164
|
+
if ((res.status === 401 || res.status === 403) && sentScopes.length > 0) {
|
|
165
|
+
console.warn(
|
|
166
|
+
`[arc] Manifest fetch ${res.status} with token(s) [${sentScopes.join(", ")}]; ` +
|
|
167
|
+
`clearing them and retrying anonymously.`,
|
|
168
|
+
);
|
|
169
|
+
clearPersistedTokens(sentScopes);
|
|
170
|
+
const retry = await fetchManifestOnce(baseUrl, {}, bust);
|
|
171
|
+
if (retry.ok) return retry.json();
|
|
172
|
+
throw new Error(`Failed to fetch module manifest: ${retry.status}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
throw new Error(`Failed to fetch module manifest: ${res.status}`);
|
|
134
176
|
}
|
|
135
177
|
|
|
136
178
|
/** Track hashes of token-group bundles already imported so hot-swaps re-import on change. */
|
|
@@ -143,29 +185,81 @@ const importedGroupHashes = new Map<string, string>();
|
|
|
143
185
|
const knownGroupModuleNames = new Set<string>();
|
|
144
186
|
|
|
145
187
|
/**
|
|
146
|
-
* Attempt a dynamic import
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
188
|
+
* Attempt a dynamic import of a token-group bundle. Returns `true` on success,
|
|
189
|
+
* `false` on failure (network error, evaluation error, stale signed URL).
|
|
190
|
+
* Deliberately does NOT mutate tokens: recovery is orchestrated by the caller
|
|
191
|
+
* so a stale cached manifest can be refetched before we give up on the token.
|
|
192
|
+
* Never rejects, so a single failing group can't brick the whole boot path.
|
|
151
193
|
*/
|
|
152
|
-
async function
|
|
194
|
+
async function tryImportGroup(
|
|
153
195
|
baseUrl: string,
|
|
154
|
-
groupName: string,
|
|
155
196
|
group: BuildManifestGroup,
|
|
156
197
|
bust?: string,
|
|
157
|
-
): Promise<
|
|
198
|
+
): Promise<boolean> {
|
|
158
199
|
try {
|
|
159
200
|
await import(/* @vite-ignore */ groupUrl(baseUrl, group, bust));
|
|
201
|
+
return true;
|
|
160
202
|
} catch (err) {
|
|
203
|
+
console.error(`[arc] Failed to load group "${group.file}".`, err);
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Import the named groups from `manifest`. On any failure — typically a 404/403
|
|
210
|
+
* from a stale cached manifest after a deploy — refetch a cache-busted manifest
|
|
211
|
+
* ONCE and retry only the still-failing groups with the fresh hashes/signed
|
|
212
|
+
* URLs. Only if a group still fails against the fresh manifest (genuinely broken
|
|
213
|
+
* bundle) or has vanished from it (access revoked) do we clear its token. This
|
|
214
|
+
* self-heals the "manifest error / token disappeared" symptom even when an
|
|
215
|
+
* intermediary cache/CDN ignores `no-cache`.
|
|
216
|
+
*
|
|
217
|
+
* Returns the manifest that actually satisfied the imports (the fresh one if a
|
|
218
|
+
* refetch happened), so callers compute their active-module set from it.
|
|
219
|
+
*/
|
|
220
|
+
async function importGroupsWithRecovery(
|
|
221
|
+
baseUrl: string,
|
|
222
|
+
manifest: BuildManifest,
|
|
223
|
+
names: string[],
|
|
224
|
+
tokens?: Record<string, string>,
|
|
225
|
+
bust?: string,
|
|
226
|
+
): Promise<BuildManifest> {
|
|
227
|
+
const first = await Promise.all(
|
|
228
|
+
names.map(async (name) => ({
|
|
229
|
+
name,
|
|
230
|
+
ok: await tryImportGroup(baseUrl, manifest.groups[name], bust),
|
|
231
|
+
})),
|
|
232
|
+
);
|
|
233
|
+
const failed = first.filter((r) => !r.ok).map((r) => r.name);
|
|
234
|
+
if (failed.length === 0) return manifest;
|
|
235
|
+
|
|
236
|
+
console.warn(
|
|
237
|
+
`[arc] ${failed.length} group(s) failed to load; refetching manifest to recover.`,
|
|
238
|
+
);
|
|
239
|
+
const fresh = await fetchManifest(baseUrl, tokens, String(Date.now()));
|
|
240
|
+
const freshBust = String(Date.now());
|
|
241
|
+
const retry = await Promise.all(
|
|
242
|
+
failed
|
|
243
|
+
.filter((name) => fresh.groups[name])
|
|
244
|
+
.map(async (name) => {
|
|
245
|
+
const group = fresh.groups[name];
|
|
246
|
+
importedGroupHashes.set(name, group.hash);
|
|
247
|
+
return { name, ok: await tryImportGroup(baseUrl, group, freshBust) };
|
|
248
|
+
}),
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
const unrecoverable = new Set(retry.filter((r) => !r.ok).map((r) => r.name));
|
|
252
|
+
// Groups absent from the fresh manifest = access revoked → unrecoverable too.
|
|
253
|
+
for (const name of failed) if (!fresh.groups[name]) unrecoverable.add(name);
|
|
254
|
+
for (const name of unrecoverable) {
|
|
161
255
|
console.error(
|
|
162
|
-
`[arc]
|
|
163
|
-
err,
|
|
256
|
+
`[arc] Group "${name}" still failing after manifest refresh; clearing token.`,
|
|
164
257
|
);
|
|
165
258
|
if (typeof localStorage !== "undefined") {
|
|
166
|
-
localStorage.removeItem(`arc:token:${
|
|
259
|
+
localStorage.removeItem(`arc:token:${name}`);
|
|
167
260
|
}
|
|
168
261
|
}
|
|
262
|
+
return fresh;
|
|
169
263
|
}
|
|
170
264
|
|
|
171
265
|
/**
|
|
@@ -177,14 +271,10 @@ export async function loadModules(
|
|
|
177
271
|
): Promise<BuildManifest> {
|
|
178
272
|
const manifest = await fetchManifest(baseUrl, tokens);
|
|
179
273
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
importedGroupHashes.set(name, group.hash);
|
|
183
|
-
return safeImportGroup(baseUrl, name, group);
|
|
184
|
-
}),
|
|
185
|
-
);
|
|
274
|
+
const names = Object.keys(manifest.groups);
|
|
275
|
+
for (const name of names) importedGroupHashes.set(name, manifest.groups[name].hash);
|
|
186
276
|
|
|
187
|
-
return manifest;
|
|
277
|
+
return importGroupsWithRecovery(baseUrl, manifest, names, tokens);
|
|
188
278
|
}
|
|
189
279
|
|
|
190
280
|
/**
|
|
@@ -199,24 +289,23 @@ export async function syncModules(
|
|
|
199
289
|
): Promise<BuildManifest> {
|
|
200
290
|
const manifest = await fetchManifest(baseUrl, tokens);
|
|
201
291
|
|
|
202
|
-
const toImport = Object.entries(manifest.groups)
|
|
203
|
-
([name, g]) => importedGroupHashes.get(name) !== g.hash
|
|
204
|
-
|
|
292
|
+
const toImport = Object.entries(manifest.groups)
|
|
293
|
+
.filter(([name, g]) => importedGroupHashes.get(name) !== g.hash)
|
|
294
|
+
.map(([name]) => name);
|
|
205
295
|
|
|
296
|
+
// Recovery may refetch a fresh manifest; the active set must be computed from
|
|
297
|
+
// whatever manifest actually satisfied the imports.
|
|
298
|
+
let effective = manifest;
|
|
206
299
|
if (toImport.length > 0) {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
importedGroupHashes.set(name, group.hash);
|
|
210
|
-
return safeImportGroup(baseUrl, name, group);
|
|
211
|
-
}),
|
|
212
|
-
);
|
|
300
|
+
for (const name of toImport) importedGroupHashes.set(name, manifest.groups[name].hash);
|
|
301
|
+
effective = await importGroupsWithRecovery(baseUrl, manifest, toImport, tokens);
|
|
213
302
|
}
|
|
214
303
|
|
|
215
304
|
// Compute active set: every registered module EXCEPT protected-group members
|
|
216
305
|
// not present in the current manifest (e.g. after logout). Public modules,
|
|
217
306
|
// never observed in a group, stay visible automatically.
|
|
218
307
|
const currentlyAllowed = new Set<string>();
|
|
219
|
-
for (const group of Object.values(
|
|
308
|
+
for (const group of Object.values(effective.groups)) {
|
|
220
309
|
for (const name of group.modules) {
|
|
221
310
|
currentlyAllowed.add(name);
|
|
222
311
|
knownGroupModuleNames.add(name);
|
|
@@ -229,7 +318,7 @@ export async function syncModules(
|
|
|
229
318
|
}
|
|
230
319
|
setActiveModules(active);
|
|
231
320
|
|
|
232
|
-
return
|
|
321
|
+
return effective;
|
|
233
322
|
}
|
|
234
323
|
|
|
235
324
|
/**
|
|
@@ -246,10 +335,13 @@ export async function reloadModules(
|
|
|
246
335
|
// Clear all modules — fresh code will re-register them
|
|
247
336
|
clearModules();
|
|
248
337
|
|
|
338
|
+
// Dev full-reload: the cache-bust already guarantees fresh code on disk, so a
|
|
339
|
+
// failure here is a genuine build/eval error — log it, don't nuke tokens.
|
|
249
340
|
await Promise.all(
|
|
250
|
-
Object.entries(manifest.groups).map(([name, group]) =>
|
|
251
|
-
|
|
252
|
-
|
|
341
|
+
Object.entries(manifest.groups).map(([name, group]) => {
|
|
342
|
+
importedGroupHashes.set(name, group.hash);
|
|
343
|
+
return tryImportGroup(baseUrl, group, bust);
|
|
344
|
+
}),
|
|
253
345
|
);
|
|
254
346
|
|
|
255
347
|
return manifest;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { loadModules } from "../src/module-loader";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Regression for "Failed to fetch module manifest: 401".
|
|
6
|
+
*
|
|
7
|
+
* The platform server rejects ANY request carrying an invalid token with 401
|
|
8
|
+
* (global auth gate) — including /api/modules. A stale token in localStorage
|
|
9
|
+
* (e.g. minted with a rotated secret) therefore bricks the whole boot. The
|
|
10
|
+
* loader must self-heal: drop the rejected token(s) and refetch the manifest
|
|
11
|
+
* anonymously instead of throwing.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// --- Minimal in-memory localStorage --------------------------------------
|
|
15
|
+
class MemoryStorage {
|
|
16
|
+
private map = new Map<string, string>();
|
|
17
|
+
get length() {
|
|
18
|
+
return this.map.size;
|
|
19
|
+
}
|
|
20
|
+
key(i: number) {
|
|
21
|
+
return [...this.map.keys()][i] ?? null;
|
|
22
|
+
}
|
|
23
|
+
getItem(k: string) {
|
|
24
|
+
return this.map.get(k) ?? null;
|
|
25
|
+
}
|
|
26
|
+
setItem(k: string, v: string) {
|
|
27
|
+
this.map.set(k, v);
|
|
28
|
+
}
|
|
29
|
+
removeItem(k: string) {
|
|
30
|
+
this.map.delete(k);
|
|
31
|
+
}
|
|
32
|
+
clear() {
|
|
33
|
+
this.map.clear();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A JWT-shaped string with no `exp` claim so getAllPersistedTokens keeps it.
|
|
38
|
+
const dummyJwt = "aaa.eyJ0b2tlbk5hbWUiOiJ1c2VyIn0.bbb";
|
|
39
|
+
const emptyManifest = {
|
|
40
|
+
initial: { file: "initial.abc.js", hash: "abc" },
|
|
41
|
+
groups: {},
|
|
42
|
+
sharedChunks: [],
|
|
43
|
+
stylesHash: "s",
|
|
44
|
+
buildTime: "t",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
let originalFetch: typeof globalThis.fetch;
|
|
48
|
+
let originalLocalStorage: any;
|
|
49
|
+
let calls: { url: string; hasAuth: boolean }[];
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
originalFetch = globalThis.fetch;
|
|
53
|
+
originalLocalStorage = (globalThis as any).localStorage;
|
|
54
|
+
(globalThis as any).localStorage = new MemoryStorage();
|
|
55
|
+
calls = [];
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
globalThis.fetch = originalFetch;
|
|
60
|
+
(globalThis as any).localStorage = originalLocalStorage;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("manifest fetch — 401 self-heal", () => {
|
|
64
|
+
it("drops the rejected token and retries anonymously", async () => {
|
|
65
|
+
localStorage.setItem("arc:token:user", dummyJwt);
|
|
66
|
+
|
|
67
|
+
// First (tokened) request → 401; anonymous retry → 200 manifest.
|
|
68
|
+
globalThis.fetch = (async (input: any, init: any) => {
|
|
69
|
+
const url = String(input);
|
|
70
|
+
const hasAuth = Boolean(new Headers(init?.headers).get("Authorization"));
|
|
71
|
+
calls.push({ url, hasAuth });
|
|
72
|
+
if (hasAuth) {
|
|
73
|
+
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
|
|
74
|
+
}
|
|
75
|
+
return new Response(JSON.stringify(emptyManifest), { status: 200 });
|
|
76
|
+
}) as any;
|
|
77
|
+
|
|
78
|
+
const manifest = await loadModules("http://x");
|
|
79
|
+
|
|
80
|
+
// Recovered: resolved with the manifest, did not throw.
|
|
81
|
+
expect(manifest.groups).toEqual({});
|
|
82
|
+
// Two requests: one with the token, then the anonymous retry.
|
|
83
|
+
expect(calls).toHaveLength(2);
|
|
84
|
+
expect(calls[0].hasAuth).toBe(true);
|
|
85
|
+
expect(calls[1].hasAuth).toBe(false);
|
|
86
|
+
// The rejected token was cleared from storage.
|
|
87
|
+
expect(localStorage.getItem("arc:token:user")).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("does not clear tokens or retry on a successful fetch", async () => {
|
|
91
|
+
localStorage.setItem("arc:token:user", dummyJwt);
|
|
92
|
+
|
|
93
|
+
globalThis.fetch = (async (input: any, init: any) => {
|
|
94
|
+
const hasAuth = Boolean(new Headers(init?.headers).get("Authorization"));
|
|
95
|
+
calls.push({ url: String(input), hasAuth });
|
|
96
|
+
return new Response(JSON.stringify(emptyManifest), { status: 200 });
|
|
97
|
+
}) as any;
|
|
98
|
+
|
|
99
|
+
await loadModules("http://x");
|
|
100
|
+
|
|
101
|
+
expect(calls).toHaveLength(1);
|
|
102
|
+
expect(calls[0].hasAuth).toBe(true);
|
|
103
|
+
expect(localStorage.getItem("arc:token:user")).toBe(dummyJwt);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("propagates a non-auth error (e.g. 500) without clearing tokens", async () => {
|
|
107
|
+
localStorage.setItem("arc:token:user", dummyJwt);
|
|
108
|
+
|
|
109
|
+
globalThis.fetch = (async (input: any, init: any) => {
|
|
110
|
+
const hasAuth = Boolean(new Headers(init?.headers).get("Authorization"));
|
|
111
|
+
calls.push({ url: String(input), hasAuth });
|
|
112
|
+
return new Response("boom", { status: 500 });
|
|
113
|
+
}) as any;
|
|
114
|
+
|
|
115
|
+
await expect(loadModules("http://x")).rejects.toThrow(/500/);
|
|
116
|
+
expect(calls).toHaveLength(1);
|
|
117
|
+
// A server error is not a token problem — keep the token.
|
|
118
|
+
expect(localStorage.getItem("arc:token:user")).toBe(dummyJwt);
|
|
119
|
+
});
|
|
120
|
+
});
|