@arcote.tech/arc-cli 0.7.32 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.arc/platform/_input.css +61 -0
- package/.arc/platform/access.json +1 -0
- package/.arc/platform/browser/_entries/initial.ts +2 -0
- package/.arc/platform/server/_externals.json +1 -0
- package/.arc/platform/server/_server.js +9 -0
- package/.arc/platform/server/_server.js.map +9 -0
- package/dist/index.js +925 -365
- package/package.json +10 -10
- package/src/builder/access-extractor.ts +34 -7
- package/src/builder/framework-peers.ts +7 -1
- package/src/builder/module-builder.ts +49 -11
- package/src/builder/peer-shell.ts +146 -0
- package/src/commands/platform-deploy.ts +42 -1
- package/src/commands/platform-dev.ts +7 -2
- package/src/deploy/assets/terraform/main.tf +35 -1
- package/src/deploy/assets/terraform/variables.tf +6 -0
- package/src/deploy/assets.ts +41 -1
- package/src/deploy/bootstrap.ts +9 -1
- package/src/deploy/config.ts +61 -1
- package/src/deploy/survey.ts +15 -1
- package/src/deploy/terraform.ts +3 -0
- package/src/index.ts +10 -2
- package/src/platform/server.ts +448 -45
- package/src/platform/shared.ts +119 -8
- package/src/platform/startup.ts +208 -4
package/src/platform/server.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type ArcRequestContext,
|
|
8
8
|
type ArcServer,
|
|
9
9
|
} from "@arcote.tech/arc-host";
|
|
10
|
+
import { timingSafeEqual } from "crypto";
|
|
10
11
|
import { existsSync, mkdirSync } from "fs";
|
|
11
12
|
import { join } from "path";
|
|
12
13
|
import { readTranslationsConfig } from "../i18n";
|
|
@@ -29,6 +30,12 @@ export interface PlatformServerOptions {
|
|
|
29
30
|
dbPath?: string;
|
|
30
31
|
/** If true, enables SSE reload stream + mutable manifest (dev mode) */
|
|
31
32
|
devMode?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Headless (spec/platform-headless.md): host API + chunki + manifesty BEZ
|
|
35
|
+
* shella SPA — `/` zwraca JSON info. Dla aplikacji rozwijanych jako moduły
|
|
36
|
+
* federowane konsumowane przez hosta (BMF).
|
|
37
|
+
*/
|
|
38
|
+
headless?: boolean;
|
|
32
39
|
/**
|
|
33
40
|
* Cross-origin isolation policy. Default `"unsafe-none"`. Apps z SQLite
|
|
34
41
|
* WASM/OPFS lub SharedArrayBuffer ustawiają `"require-corp"` w
|
|
@@ -36,6 +43,22 @@ export interface PlatformServerOptions {
|
|
|
36
43
|
* widgets) musi mieć `Cross-Origin-Resource-Policy`. Patrz `ArcServerConfig.coep`.
|
|
37
44
|
*/
|
|
38
45
|
coep?: "unsafe-none" | "credentialless" | "require-corp";
|
|
46
|
+
/**
|
|
47
|
+
* Federacja: dane do `POST /api/federation/register` — kanał, którym host
|
|
48
|
+
* (BMF) przedstawia się aplikacji i POBIERA jej sekret. Obecne tylko gdy
|
|
49
|
+
* aplikacja wystawia moduły (`manifest.federation`).
|
|
50
|
+
*/
|
|
51
|
+
federation?: {
|
|
52
|
+
/** Token parowania — ten sam, który deweloper wkleja hostowi. */
|
|
53
|
+
registrationToken: string;
|
|
54
|
+
/** Sekret aplikacji (`ARC_APP_SECRET`) — host podpisuje nim asercje. */
|
|
55
|
+
appSecret: string;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Adres mapy — ogłaszany w `/api/discovery`, żeby host wiedział, gdzie żyje
|
|
59
|
+
* sync elementów i use-case'y. Tylko dev: w prod mapa nie startuje.
|
|
60
|
+
*/
|
|
61
|
+
mapUrl?: string;
|
|
39
62
|
}
|
|
40
63
|
|
|
41
64
|
export interface PlatformServer {
|
|
@@ -44,6 +67,11 @@ export interface PlatformServer {
|
|
|
44
67
|
connectionManager: ConnectionManager | null;
|
|
45
68
|
/** Update manifest at runtime (dev mode rebuild) */
|
|
46
69
|
setManifest: (m: BuildManifest) => void;
|
|
70
|
+
/**
|
|
71
|
+
* Ogłoś adres mapy w `/api/discovery`. Mapa startuje PO platformie (na jej
|
|
72
|
+
* porcie +1), więc adres znamy dopiero później. Dev-only.
|
|
73
|
+
*/
|
|
74
|
+
setMapUrl: (url: string) => void;
|
|
47
75
|
/** Notify SSE clients of rebuild (dev mode only) */
|
|
48
76
|
notifyReload: (m: BuildManifest) => void;
|
|
49
77
|
/** Cleanup streams and stop server */
|
|
@@ -102,6 +130,10 @@ export function generateShellHtml(
|
|
|
102
130
|
manifest?: { title: string; favicon?: string },
|
|
103
131
|
initial?: { file: string; hash: string },
|
|
104
132
|
stylesHash?: string,
|
|
133
|
+
shell?: {
|
|
134
|
+
imports: Readonly<Record<string, string>>;
|
|
135
|
+
peers: Readonly<Record<string, string>>;
|
|
136
|
+
},
|
|
105
137
|
): string {
|
|
106
138
|
// OpenTelemetry config — injected as a global so the browser SDK chunk
|
|
107
139
|
// (lazy-loaded by start-app.ts) can pick it up without a fetch. Endpoint
|
|
@@ -119,12 +151,19 @@ export function generateShellHtml(
|
|
|
119
151
|
? `\n <script>window.__ARC_OTEL_CONFIG=${JSON.stringify(otelConfig)};</script>`
|
|
120
152
|
: "";
|
|
121
153
|
// Initial bundle carries framework, public modules, and PlatformApp re-export.
|
|
122
|
-
//
|
|
123
|
-
//
|
|
154
|
+
// Bundled build: no importmap — splitting:true dedups everything into
|
|
155
|
+
// chunk-<hash>.js. Federated build (arc.federation): framework peers są
|
|
156
|
+
// external i rozwiązuje je import mapa peer-shella; obowiązuje ona też
|
|
157
|
+
// moduły importowane cross-origin (chunki aplikacji partnerskich) —
|
|
158
|
+
// spec/module-federation.md.
|
|
124
159
|
const initialUrl = initial ? `/browser/${initial.file}` : null;
|
|
125
160
|
if (!initialUrl) {
|
|
126
161
|
throw new Error("generateShellHtml: initial bundle missing from manifest");
|
|
127
162
|
}
|
|
163
|
+
const importMapTag = shell
|
|
164
|
+
? `\n <script type="importmap">${JSON.stringify({ imports: shell.imports })}</script>` +
|
|
165
|
+
`\n <script>window.__ARC_PEERS__=${JSON.stringify(shell.peers)};</script>`
|
|
166
|
+
: "";
|
|
128
167
|
// Append the styles content hash as a query string. Filenames are stable
|
|
129
168
|
// (/styles.css, /theme.css) but their content changes between builds; the
|
|
130
169
|
// hash invalidates the browser cache exactly when the content changes,
|
|
@@ -137,7 +176,7 @@ export function generateShellHtml(
|
|
|
137
176
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
|
138
177
|
<title>${manifest?.title ?? appName}</title>${manifest?.favicon ? `\n <link rel="icon" href="${manifest.favicon}">` : ""}${manifest ? `\n <link rel="manifest" href="/manifest.json${stylesQs}">` : ""}
|
|
139
178
|
<link rel="stylesheet" href="/styles.css${stylesQs}" />
|
|
140
|
-
<link rel="stylesheet" href="/theme.css${stylesQs}"
|
|
179
|
+
<link rel="stylesheet" href="/theme.css${stylesQs}" />${importMapTag}
|
|
141
180
|
<link rel="modulepreload" href="${initialUrl}" />${otelTag}
|
|
142
181
|
</head>
|
|
143
182
|
<body>
|
|
@@ -225,11 +264,51 @@ function ensureModuleSigSecret(ws: WorkspaceInfo, devMode: boolean): void {
|
|
|
225
264
|
* - Threat model: a stolen sig only buys access to the JS code itself —
|
|
226
265
|
* all runtime API calls re-validate the JWT independently.
|
|
227
266
|
*/
|
|
228
|
-
function signGroupUrl(file: string): string {
|
|
267
|
+
function signGroupUrl(file: string, prefix = "/browser"): string {
|
|
229
268
|
const hasher = new Bun.CryptoHasher("sha256");
|
|
230
269
|
hasher.update(`${file}:${MODULE_SIG_SECRET}`);
|
|
231
270
|
const sig = hasher.digest("hex").slice(0, 16);
|
|
232
|
-
return
|
|
271
|
+
return `${prefix}/${file}?sig=${sig}`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Filtruje grupy manifestu do tych, do których tokeny wołającego dają dostęp
|
|
276
|
+
* (per-moduł wg access map), i dokleja podpisany URL z podanym prefiksem.
|
|
277
|
+
* Współdzielone przez `/api/modules` (/browser) i federację (/browser-fed).
|
|
278
|
+
*/
|
|
279
|
+
async function filterGroupsForTokens(
|
|
280
|
+
groups: Readonly<Record<string, BuildManifestGroup>>,
|
|
281
|
+
moduleAccessMap: Map<string, ModuleAccess>,
|
|
282
|
+
tokenPayloads: any[],
|
|
283
|
+
urlPrefix: string,
|
|
284
|
+
): Promise<Record<string, BuildManifestGroup>> {
|
|
285
|
+
const tokensByName = new Map<string, any>();
|
|
286
|
+
for (const t of tokenPayloads) {
|
|
287
|
+
if (t?.tokenType) tokensByName.set(t.tokenType, t);
|
|
288
|
+
}
|
|
289
|
+
const filtered: Record<string, BuildManifestGroup> = {};
|
|
290
|
+
for (const [name, group] of Object.entries(groups)) {
|
|
291
|
+
let allGranted = true;
|
|
292
|
+
for (const moduleName of group.modules) {
|
|
293
|
+
const access = moduleAccessMap.get(moduleName);
|
|
294
|
+
if (!access || access.rules.length === 0) continue;
|
|
295
|
+
let granted = false;
|
|
296
|
+
for (const rule of access.rules) {
|
|
297
|
+
const matching = tokensByName.get(rule.token.name);
|
|
298
|
+
if (!matching) continue;
|
|
299
|
+
granted = rule.check ? await rule.check(matching) : true;
|
|
300
|
+
if (granted) break;
|
|
301
|
+
}
|
|
302
|
+
if (!granted) {
|
|
303
|
+
allGranted = false;
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (allGranted) {
|
|
308
|
+
filtered[name] = { ...group, url: signGroupUrl(group.file, urlPrefix) };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return filtered;
|
|
233
312
|
}
|
|
234
313
|
|
|
235
314
|
function verifyGroupSignature(file: string, sig: string | null): boolean {
|
|
@@ -291,39 +370,12 @@ async function filterManifestForTokens(
|
|
|
291
370
|
// Chunk group names are no longer 1:1 with token names (a checked module
|
|
292
371
|
// gets its own chunk `<token>__<module>`), so grant by the per-module
|
|
293
372
|
// access rules, not by the group name. Look tokens up by their name.
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
for (const [name, group] of Object.entries(manifest.groups)) {
|
|
302
|
-
// Every module in the group must be granted by a held token (and pass
|
|
303
|
-
// its check, if any). Chunks are homogeneous post-split — a checked
|
|
304
|
-
// module is alone in its chunk, so its check can't block other modules.
|
|
305
|
-
let allGranted = true;
|
|
306
|
-
for (const moduleName of group.modules) {
|
|
307
|
-
const access = moduleAccessMap.get(moduleName);
|
|
308
|
-
if (!access || access.rules.length === 0) continue;
|
|
309
|
-
let granted = false;
|
|
310
|
-
for (const rule of access.rules) {
|
|
311
|
-
const matching = tokensByName.get(rule.token.name);
|
|
312
|
-
if (!matching) continue;
|
|
313
|
-
granted = rule.check ? await rule.check(matching) : true;
|
|
314
|
-
if (granted) break;
|
|
315
|
-
}
|
|
316
|
-
if (!granted) {
|
|
317
|
-
allGranted = false;
|
|
318
|
-
break;
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
if (allGranted) {
|
|
323
|
-
filteredGroups[name] = { ...group, url: signGroupUrl(group.file) };
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
373
|
+
const filteredGroups = await filterGroupsForTokens(
|
|
374
|
+
manifest.groups,
|
|
375
|
+
moduleAccessMap,
|
|
376
|
+
tokenPayloads,
|
|
377
|
+
"/browser",
|
|
378
|
+
);
|
|
327
379
|
return {
|
|
328
380
|
initial: manifest.initial,
|
|
329
381
|
groups: filteredGroups,
|
|
@@ -374,6 +426,44 @@ function staticFilesHandler(
|
|
|
374
426
|
"Cache-Control": devMode ? "no-cache" : "max-age=31536000,immutable",
|
|
375
427
|
});
|
|
376
428
|
}
|
|
429
|
+
// Peer-shell (federacja): singletony framework peers rozwiązywane przez
|
|
430
|
+
// import mapę shella. Content-addressed, publiczne, cross-origin (chunki
|
|
431
|
+
// hosta federacji importują je z tego originu przez import mapę).
|
|
432
|
+
if (path.startsWith("/shell/")) {
|
|
433
|
+
const file = path.slice(7);
|
|
434
|
+
if (!BROWSER_FILE_RE.test(file)) {
|
|
435
|
+
return new Response("Not Found", { status: 404, headers: ctx.corsHeaders });
|
|
436
|
+
}
|
|
437
|
+
return serveFile(join(ws.arcDir, "shell", file), {
|
|
438
|
+
...ctx.corsHeaders,
|
|
439
|
+
"Cross-Origin-Resource-Policy": "cross-origin",
|
|
440
|
+
"Cache-Control": devMode ? "no-cache" : "max-age=31536000,immutable",
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
// Chunki modułów FEDEROWANYCH (build browser-fed/) — ładowane przez host
|
|
444
|
+
// federacji z innego originu. Group-entry podpisane (jak /browser/),
|
|
445
|
+
// shared chunki content-addressed. Cross-origin (CORS + CORP).
|
|
446
|
+
if (path.startsWith("/browser-fed/")) {
|
|
447
|
+
const file = path.slice(13);
|
|
448
|
+
if (!BROWSER_FILE_RE.test(file)) {
|
|
449
|
+
return new Response("Not Found", { status: 404, headers: ctx.corsHeaders });
|
|
450
|
+
}
|
|
451
|
+
const fed = getManifest().federation;
|
|
452
|
+
const isGroupEntry = fed
|
|
453
|
+
? Object.values(fed.build.groups).some((g) => g.file === file)
|
|
454
|
+
: false;
|
|
455
|
+
if (isGroupEntry) {
|
|
456
|
+
const sig = url.searchParams.get("sig");
|
|
457
|
+
if (!verifyGroupSignature(file, sig)) {
|
|
458
|
+
return new Response("Forbidden", { status: 403, headers: ctx.corsHeaders });
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return serveFile(join(ws.arcDir, "browser-fed", file), {
|
|
462
|
+
...ctx.corsHeaders,
|
|
463
|
+
"Cross-Origin-Resource-Policy": "cross-origin",
|
|
464
|
+
"Cache-Control": devMode ? "no-cache" : "max-age=31536000,immutable",
|
|
465
|
+
});
|
|
466
|
+
}
|
|
377
467
|
// Locales (compiled .po → .json) — short TTL with SWR; catalogs change
|
|
378
468
|
// build-to-build but rarely within a session, and they're tiny.
|
|
379
469
|
if (path.startsWith("/locales/"))
|
|
@@ -425,14 +515,211 @@ function staticFilesHandler(
|
|
|
425
515
|
};
|
|
426
516
|
}
|
|
427
517
|
|
|
518
|
+
/**
|
|
519
|
+
* `ARC_CORS_ORIGINS` → allowlista originów CORS. `undefined` (brak env) zostaje
|
|
520
|
+
* `undefined`, nie pustą tablicą: pusta lista i brak listy znaczą w arc-host co
|
|
521
|
+
* innego (pusta = nikt nie przejdzie, brak = odbijaj Origin bez credentials).
|
|
522
|
+
*/
|
|
523
|
+
function parseCorsOrigins(raw: string | undefined): string[] | undefined {
|
|
524
|
+
if (!raw) return undefined;
|
|
525
|
+
const list = raw
|
|
526
|
+
.split(",")
|
|
527
|
+
.map((s) => s.trim())
|
|
528
|
+
.filter(Boolean);
|
|
529
|
+
return list.length > 0 ? list : undefined;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Wersja kontraktu discovery — MUSI odpowiadać `MAP_SCHEMA_VERSION`
|
|
534
|
+
* z `@arcote.tech/arc-map/src/types.ts`.
|
|
535
|
+
*
|
|
536
|
+
* Świadomie ZDUPLIKOWANA, nie zaimportowana: `arc-map` to narzędzie DEV, którego
|
|
537
|
+
* w obrazie produkcyjnym NIE MA (`bun install --production` pomija devDeps, a
|
|
538
|
+
* CLI trzyma go jako `--external`). Import — nawet z bezzależnościowego subpatha
|
|
539
|
+
* — zostawał w `host.js` i wywalał kontener w pętli restartów:
|
|
540
|
+
* `Cannot find module '@arcote.tech/arc-map/types'`. Bezzależnościowość PLIKU
|
|
541
|
+
* nie pomaga, gdy brakuje całego PAKIETU.
|
|
542
|
+
*/
|
|
543
|
+
const DISCOVERY_SCHEMA_VERSION = 1;
|
|
544
|
+
|
|
545
|
+
/** Porównanie sekretów w czasie stałym (bez wycieku długości prefiksu). */
|
|
546
|
+
function safeEqual(a: string, b: string): boolean {
|
|
547
|
+
const ab = Buffer.from(a);
|
|
548
|
+
const bb = Buffer.from(b);
|
|
549
|
+
if (ab.length !== bb.length) return false;
|
|
550
|
+
return timingSafeEqual(ab, bb);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* `GET /api/discovery` — kto tu mieszka, za tokenem parowania.
|
|
555
|
+
*
|
|
556
|
+
* Ten sam kształt odpowiedzi co discovery mapy (arc-map), ale serwowany przez
|
|
557
|
+
* PLATFORMĘ, więc żyje też na produkcji. Mapa jest narzędziem dev i w prod nie
|
|
558
|
+
* startuje (startup.ts) — a instalacja u hosta discovery WYMAGA, więc bez tego
|
|
559
|
+
* federacji nie dało się w ogóle zainstalować poza devem.
|
|
560
|
+
*
|
|
561
|
+
* Platforma to adres stabilny; mapa jest dev-owym dodatkiem OGŁASZANYM tutaj
|
|
562
|
+
* (`mapUrl`), żeby host wiedział, gdzie szukać sync-u elementów i use-case'ów.
|
|
563
|
+
* W prod `mapUrl` jest `null` i te funkcje po prostu nie są dostępne.
|
|
564
|
+
*/
|
|
565
|
+
function handleDiscovery(
|
|
566
|
+
req: Request,
|
|
567
|
+
ctx: ArcRequestContext,
|
|
568
|
+
ws: WorkspaceInfo,
|
|
569
|
+
manifest: BuildManifest,
|
|
570
|
+
federation?: PlatformServerOptions["federation"],
|
|
571
|
+
mapUrl?: string,
|
|
572
|
+
): Response {
|
|
573
|
+
if (!federation) {
|
|
574
|
+
return new Response("Not Found", { status: 404, headers: ctx.corsHeaders });
|
|
575
|
+
}
|
|
576
|
+
const presented = req.headers.get("X-Arc-Pairing-Token");
|
|
577
|
+
if (!presented || !safeEqual(presented, federation.registrationToken)) {
|
|
578
|
+
return new Response("Unauthorized", {
|
|
579
|
+
status: 401,
|
|
580
|
+
headers: ctx.corsHeaders,
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const fed = manifest.federation;
|
|
585
|
+
// Adres własny: za proxy `req.url` jest wewnętrzny, więc pozwalamy nadpisać
|
|
586
|
+
// przez env. Konsument i tak zna nas z adresu, którego użył — to pole jest
|
|
587
|
+
// potwierdzeniem, nie źródłem prawdy.
|
|
588
|
+
const platformUrl =
|
|
589
|
+
process.env.ARC_PUBLIC_URL || new URL(req.url).origin;
|
|
590
|
+
|
|
591
|
+
return Response.json(
|
|
592
|
+
{
|
|
593
|
+
meta: {
|
|
594
|
+
schemaVersion: DISCOVERY_SCHEMA_VERSION,
|
|
595
|
+
name: ws.appName,
|
|
596
|
+
version: (ws.rootPkg?.version as string | undefined) ?? undefined,
|
|
597
|
+
},
|
|
598
|
+
platformUrl,
|
|
599
|
+
federation: fed
|
|
600
|
+
? {
|
|
601
|
+
slug: fed.config.slug,
|
|
602
|
+
auth: fed.config.auth,
|
|
603
|
+
modules: fed.modules,
|
|
604
|
+
permissions: fed.permissions,
|
|
605
|
+
slots: fed.slots,
|
|
606
|
+
}
|
|
607
|
+
: null,
|
|
608
|
+
peers: manifest.shell?.peers ?? null,
|
|
609
|
+
federationManifestUrl: fed
|
|
610
|
+
? `${platformUrl}/api/federation/manifest`
|
|
611
|
+
: null,
|
|
612
|
+
/** Adres mapy — tylko dev; w prod `null` (mapa nie startuje). */
|
|
613
|
+
mapUrl: mapUrl ?? null,
|
|
614
|
+
},
|
|
615
|
+
{ headers: { ...ctx.corsHeaders, "Cache-Control": "no-cache, private" } },
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* `POST /api/federation/register` — host federacji przedstawia się aplikacji
|
|
621
|
+
* i POBIERA jej sekret (spec/app-identity.md).
|
|
622
|
+
*
|
|
623
|
+
* Kierunek jest celowy: sekret NALEŻY do aplikacji (stały, `ARC_APP_SECRET`),
|
|
624
|
+
* więc ma go już przy starcie. Host go pobiera i podpisuje nim asercje
|
|
625
|
+
* tożsamości. Dzięki temu instalacja u hosta nie wymaga wklejania czegokolwiek
|
|
626
|
+
* do env partnera ani restartu — a to była przyczyna błędnego koła
|
|
627
|
+
* (instalacja → restart → nowy token parowania → parowanie martwe).
|
|
628
|
+
*
|
|
629
|
+
* Bramą jest token parowania — ten sam, który deweloper świadomie wkleja
|
|
630
|
+
* hostowi. Wywołanie jest SERVER→SERVER (sekret nigdy nie trafia do
|
|
631
|
+
* przeglądarki), więc CORS tu nie obowiązuje.
|
|
632
|
+
*/
|
|
633
|
+
async function handleFederationRegister(
|
|
634
|
+
req: Request,
|
|
635
|
+
ctx: ArcRequestContext,
|
|
636
|
+
manifest: BuildManifest,
|
|
637
|
+
federation?: PlatformServerOptions["federation"],
|
|
638
|
+
): Promise<Response> {
|
|
639
|
+
const fed = manifest.federation;
|
|
640
|
+
if (!fed || !federation) {
|
|
641
|
+
return new Response("Not Found", { status: 404, headers: ctx.corsHeaders });
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const presented = req.headers.get("X-Arc-Pairing-Token");
|
|
645
|
+
if (!presented || !safeEqual(presented, federation.registrationToken)) {
|
|
646
|
+
return new Response("Unauthorized", {
|
|
647
|
+
status: 401,
|
|
648
|
+
headers: ctx.corsHeaders,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
let body: Record<string, unknown>;
|
|
653
|
+
try {
|
|
654
|
+
body = (await req.json()) as Record<string, unknown>;
|
|
655
|
+
} catch {
|
|
656
|
+
return new Response("Bad Request", {
|
|
657
|
+
status: 400,
|
|
658
|
+
headers: ctx.corsHeaders,
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const hostId = typeof body.hostId === "string" ? body.hostId : "";
|
|
663
|
+
const hostUrl = typeof body.hostUrl === "string" ? body.hostUrl : "";
|
|
664
|
+
const hostName = typeof body.hostName === "string" ? body.hostName : hostId;
|
|
665
|
+
const organizationId =
|
|
666
|
+
typeof body.organizationId === "string" ? body.organizationId : "";
|
|
667
|
+
if (!hostId || !hostUrl) {
|
|
668
|
+
return new Response("Bad Request: hostId + hostUrl required", {
|
|
669
|
+
status: 400,
|
|
670
|
+
headers: ctx.corsHeaders,
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Widoczne dla dewelopera aplikacji — kto się podpiął i dla jakiej organizacji.
|
|
675
|
+
ok(
|
|
676
|
+
`Federation: host "${hostName}" (${hostUrl}) registered for org ${organizationId || "—"}`,
|
|
677
|
+
);
|
|
678
|
+
|
|
679
|
+
return Response.json(
|
|
680
|
+
{
|
|
681
|
+
secret: federation.appSecret,
|
|
682
|
+
slug: fed.config.slug,
|
|
683
|
+
auth: fed.config.auth,
|
|
684
|
+
modules: fed.modules,
|
|
685
|
+
permissions: fed.permissions,
|
|
686
|
+
slots: fed.slots,
|
|
687
|
+
},
|
|
688
|
+
{ headers: { ...ctx.corsHeaders, "Cache-Control": "no-store" } },
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
|
|
428
692
|
function apiEndpointsHandler(
|
|
429
693
|
ws: WorkspaceInfo,
|
|
430
694
|
getManifest: () => BuildManifest,
|
|
431
695
|
cm: ConnectionManager | null,
|
|
432
696
|
moduleAccessMap: Map<string, ModuleAccess>,
|
|
433
697
|
telemetry?: import("@arcote.tech/arc-otel").ArcTelemetry,
|
|
698
|
+
federation?: PlatformServerOptions["federation"],
|
|
699
|
+
getMapUrl?: () => string | undefined,
|
|
434
700
|
): ArcHttpHandler {
|
|
435
701
|
return (req, url, ctx) => {
|
|
702
|
+
if (url.pathname === "/api/discovery") {
|
|
703
|
+
return handleDiscovery(
|
|
704
|
+
req,
|
|
705
|
+
ctx,
|
|
706
|
+
ws,
|
|
707
|
+
getManifest(),
|
|
708
|
+
federation,
|
|
709
|
+
getMapUrl?.(),
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
if (url.pathname === "/api/federation/register") {
|
|
713
|
+
// Łapiemy ścieżkę dla KAŻDEJ metody — inaczej GET wpadłby w fallback SPA
|
|
714
|
+
// i oddał shell HTML ze statusem 200, co myli konsumenta.
|
|
715
|
+
if (req.method !== "POST") {
|
|
716
|
+
return new Response("Method Not Allowed", {
|
|
717
|
+
status: 405,
|
|
718
|
+
headers: { ...ctx.corsHeaders, Allow: "POST, OPTIONS" },
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
return handleFederationRegister(req, ctx, getManifest(), federation);
|
|
722
|
+
}
|
|
436
723
|
if (url.pathname === "/api/modules") {
|
|
437
724
|
// Parse all tokens from X-Arc-Tokens header + Authorization fallback
|
|
438
725
|
const arcTokensHeader = req.headers.get("X-Arc-Tokens");
|
|
@@ -456,6 +743,51 @@ function apiEndpointsHandler(
|
|
|
456
743
|
);
|
|
457
744
|
}
|
|
458
745
|
|
|
746
|
+
if (url.pathname === "/api/federation/manifest") {
|
|
747
|
+
// Manifest federacji dla HOSTA (spec/module-federation.md): artefakty z
|
|
748
|
+
// buildu FEDEROWANEGO (browser-fed/), grupy filtrowane per tokeny
|
|
749
|
+
// aplikacji (X-Arc-Tokens), wersje peers do polityki kompatybilności,
|
|
750
|
+
// meta modułów wystawionych. Dostępny, gdy aplikacja eksportuje moduły.
|
|
751
|
+
const m = getManifest();
|
|
752
|
+
if (!m.federation || !m.shell) {
|
|
753
|
+
return new Response("Not Found", { status: 404, headers: ctx.corsHeaders });
|
|
754
|
+
}
|
|
755
|
+
const arcTokensHeader = req.headers.get("X-Arc-Tokens");
|
|
756
|
+
let payloads = parseArcTokensHeader(arcTokensHeader);
|
|
757
|
+
if (payloads.length === 0 && ctx.tokenPayload) {
|
|
758
|
+
payloads = [ctx.tokenPayload];
|
|
759
|
+
}
|
|
760
|
+
const fed = m.federation;
|
|
761
|
+
return filterGroupsForTokens(
|
|
762
|
+
fed.build.groups,
|
|
763
|
+
moduleAccessMap,
|
|
764
|
+
payloads,
|
|
765
|
+
"/browser-fed",
|
|
766
|
+
).then((groups) =>
|
|
767
|
+
Response.json(
|
|
768
|
+
{
|
|
769
|
+
initial: fed.build.initial,
|
|
770
|
+
groups,
|
|
771
|
+
sharedChunks: fed.build.sharedChunks,
|
|
772
|
+
stylesHash: m.stylesHash,
|
|
773
|
+
peers: m.shell!.peers,
|
|
774
|
+
app: {
|
|
775
|
+
slug: fed.config.slug,
|
|
776
|
+
name: ws.appName,
|
|
777
|
+
version: (ws.rootPkg.version as string | undefined) ?? undefined,
|
|
778
|
+
modules: fed.modules,
|
|
779
|
+
permissions: fed.permissions,
|
|
780
|
+
slots: fed.slots,
|
|
781
|
+
auth: fed.config.auth,
|
|
782
|
+
},
|
|
783
|
+
},
|
|
784
|
+
{
|
|
785
|
+
headers: { ...ctx.corsHeaders, "Cache-Control": "no-cache, private" },
|
|
786
|
+
},
|
|
787
|
+
),
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
|
|
459
791
|
if (url.pathname === "/api/translations") {
|
|
460
792
|
const config = readTranslationsConfig(ws.rootDir);
|
|
461
793
|
return Response.json(config ?? { locales: [], sourceLocale: "" }, {
|
|
@@ -515,6 +847,29 @@ function devReloadHandler(
|
|
|
515
847
|
};
|
|
516
848
|
}
|
|
517
849
|
|
|
850
|
+
/**
|
|
851
|
+
* Fallback trybu headless: zamiast shella SPA `/` (i każda niestatyczna
|
|
852
|
+
* ścieżka) zwraca JSON z informacją o aplikacji — konsumenci (host federacji,
|
|
853
|
+
* ludzie z przeglądarką) widzą, że to serwer API modułów federowanych.
|
|
854
|
+
*/
|
|
855
|
+
function headlessFallbackHandler(
|
|
856
|
+
ws: WorkspaceInfo,
|
|
857
|
+
getManifest: () => BuildManifest,
|
|
858
|
+
): ArcHttpHandler {
|
|
859
|
+
return (_req, _url, ctx) => {
|
|
860
|
+
const m = getManifest();
|
|
861
|
+
return Response.json(
|
|
862
|
+
{
|
|
863
|
+
arc: "headless",
|
|
864
|
+
app: ws.appName,
|
|
865
|
+
federation: m.federation ?? null,
|
|
866
|
+
endpoints: ["/api/federation/manifest", "/api/modules", "/health"],
|
|
867
|
+
},
|
|
868
|
+
{ headers: ctx.corsHeaders },
|
|
869
|
+
);
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
|
|
518
873
|
function spaFallbackHandler(getShellHtml: () => string): ArcHttpHandler {
|
|
519
874
|
return (_req, _url, ctx) => {
|
|
520
875
|
return new Response(getShellHtml(), {
|
|
@@ -583,13 +938,29 @@ export async function startPlatformServer(
|
|
|
583
938
|
const setManifest = (m: BuildManifest) => {
|
|
584
939
|
manifest = m;
|
|
585
940
|
};
|
|
941
|
+
let mapUrl = opts.mapUrl;
|
|
942
|
+
const setMapUrl = (u: string) => {
|
|
943
|
+
mapUrl = u;
|
|
944
|
+
};
|
|
945
|
+
const getMapUrl = () => mapUrl;
|
|
586
946
|
|
|
587
947
|
// Recompute on every request — manifest.initial.hash changes when public
|
|
588
948
|
// modules are rebuilt in dev, and we want the new URL in the HTML.
|
|
589
949
|
const getShellHtml = (): string =>
|
|
590
|
-
generateShellHtml(
|
|
950
|
+
generateShellHtml(
|
|
951
|
+
ws.appName,
|
|
952
|
+
ws.manifest,
|
|
953
|
+
manifest?.initial,
|
|
954
|
+
manifest?.stylesHash,
|
|
955
|
+
manifest?.shell,
|
|
956
|
+
);
|
|
591
957
|
const sseClients = new Set<ReadableStreamDefaultController>();
|
|
592
958
|
|
|
959
|
+
// Headless: bez shella SPA — fallback JSON (spec/platform-headless.md).
|
|
960
|
+
const fallbackHandler = opts.headless
|
|
961
|
+
? headlessFallbackHandler(ws, getManifest)
|
|
962
|
+
: spaFallbackHandler(getShellHtml);
|
|
963
|
+
|
|
593
964
|
const notifyReload = (m: BuildManifest) => {
|
|
594
965
|
const data = JSON.stringify(m);
|
|
595
966
|
for (const c of sseClients) {
|
|
@@ -619,8 +990,15 @@ export async function startPlatformServer(
|
|
|
619
990
|
port,
|
|
620
991
|
async fetch(req) {
|
|
621
992
|
const url = new URL(req.url);
|
|
622
|
-
if (req.method === "OPTIONS")
|
|
623
|
-
|
|
993
|
+
if (req.method === "OPTIONS") {
|
|
994
|
+
const headers: Record<string, string> = { ...cors };
|
|
995
|
+
// Private Network Access (Chrome): hostowany host federacji (https)
|
|
996
|
+
// → localhost dewelopera wymaga jawnej zgody na preflight.
|
|
997
|
+
if (req.headers.get("Access-Control-Request-Private-Network")) {
|
|
998
|
+
headers["Access-Control-Allow-Private-Network"] = "true";
|
|
999
|
+
}
|
|
1000
|
+
return new Response(null, { headers });
|
|
1001
|
+
}
|
|
624
1002
|
|
|
625
1003
|
const ctx: ArcRequestContext = {
|
|
626
1004
|
rawToken: null,
|
|
@@ -630,10 +1008,18 @@ export async function startPlatformServer(
|
|
|
630
1008
|
|
|
631
1009
|
// Platform handlers only
|
|
632
1010
|
const handlers: ArcHttpHandler[] = [
|
|
633
|
-
apiEndpointsHandler(
|
|
1011
|
+
apiEndpointsHandler(
|
|
1012
|
+
ws,
|
|
1013
|
+
getManifest,
|
|
1014
|
+
null,
|
|
1015
|
+
moduleAccessMap,
|
|
1016
|
+
telemetry,
|
|
1017
|
+
opts.federation,
|
|
1018
|
+
getMapUrl,
|
|
1019
|
+
),
|
|
634
1020
|
devReloadHandler(sseClients),
|
|
635
1021
|
staticFilesHandler(ws, !!devMode, getManifest),
|
|
636
|
-
|
|
1022
|
+
fallbackHandler,
|
|
637
1023
|
];
|
|
638
1024
|
|
|
639
1025
|
for (const handler of handlers) {
|
|
@@ -650,6 +1036,7 @@ export async function startPlatformServer(
|
|
|
650
1036
|
contextHandler: null,
|
|
651
1037
|
connectionManager: null,
|
|
652
1038
|
setManifest,
|
|
1039
|
+
setMapUrl,
|
|
653
1040
|
notifyReload,
|
|
654
1041
|
stop: () => server.stop(),
|
|
655
1042
|
};
|
|
@@ -683,12 +1070,27 @@ export async function startPlatformServer(
|
|
|
683
1070
|
dbAdapterFactory,
|
|
684
1071
|
port,
|
|
685
1072
|
coep,
|
|
1073
|
+
// Bez allowlisty serwer odbija DOWOLNY Origin. Pole istniało w konfiguracji
|
|
1074
|
+
// od zawsze, ale nikt go nie ustawiał — `ARC_CORS_ORIGINS` daje operatorowi
|
|
1075
|
+
// ścieżkę, żeby ograniczyć się do originu hosta federacji.
|
|
1076
|
+
corsOrigins: parseCorsOrigins(process.env.ARC_CORS_ORIGINS),
|
|
1077
|
+
// PNA ma sens tylko w dev (hostowany host → localhost dewelopera). Na
|
|
1078
|
+
// produkcji aplikacja nie jest zasobem sieci prywatnej.
|
|
1079
|
+
allowPrivateNetwork: !!devMode,
|
|
686
1080
|
httpHandlers: [
|
|
687
1081
|
// Platform-specific handlers (checked AFTER arc handlers)
|
|
688
|
-
apiEndpointsHandler(
|
|
1082
|
+
apiEndpointsHandler(
|
|
1083
|
+
ws,
|
|
1084
|
+
getManifest,
|
|
1085
|
+
null,
|
|
1086
|
+
moduleAccessMap,
|
|
1087
|
+
telemetry,
|
|
1088
|
+
opts.federation,
|
|
1089
|
+
getMapUrl,
|
|
1090
|
+
),
|
|
689
1091
|
devReloadHandler(sseClients),
|
|
690
1092
|
staticFilesHandler(ws, !!devMode, getManifest),
|
|
691
|
-
|
|
1093
|
+
fallbackHandler,
|
|
692
1094
|
],
|
|
693
1095
|
onWsClose: (clientId) => cleanupClientSubs(clientId),
|
|
694
1096
|
telemetry,
|
|
@@ -699,6 +1101,7 @@ export async function startPlatformServer(
|
|
|
699
1101
|
contextHandler: arcServer.contextHandler,
|
|
700
1102
|
connectionManager: arcServer.connectionManager,
|
|
701
1103
|
setManifest,
|
|
1104
|
+
setMapUrl,
|
|
702
1105
|
notifyReload,
|
|
703
1106
|
stop: () => {
|
|
704
1107
|
arcServer.stop();
|