@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.
@@ -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
- // No importmap — single Bun.build with splitting:true inlines + dedups everything
123
- // across initial and per-token group bundles via auto-emitted chunk-<hash>.js.
154
+ // Bundled build: no importmap — splitting:true dedups everything into
155
+ // chunk-<hash>.js. Federated build (arc.federation): framework peers
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 `/browser/${file}?sig=${sig}`;
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 tokensByName = new Map<string, any>();
295
- for (const t of tokenPayloads) {
296
- if (t?.tokenType) tokensByName.set(t.tokenType, t);
297
- }
298
-
299
- const filteredGroups: Record<string, BuildManifestGroup> = {};
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(ws.appName, ws.manifest, manifest?.initial, manifest?.stylesHash);
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
- return new Response(null, { headers: cors });
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(ws, getManifest, null, moduleAccessMap, telemetry),
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
- spaFallbackHandler(getShellHtml),
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(ws, getManifest, null, moduleAccessMap, telemetry),
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
- spaFallbackHandler(getShellHtml),
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();