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