@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.
@@ -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
- // 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.
157
+ // Bundled build: no importmap — splitting:true dedups everything into
158
+ // chunk-<hash>.js. Federated build (arc.federation): framework peers
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 `/browser/${file}?sig=${sig}`;
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 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
-
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(ws.appName, ws.manifest, manifest?.initial, manifest?.stylesHash);
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
- return new Response(null, { headers: cors });
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(ws, getManifest, null, moduleAccessMap, telemetry),
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
- spaFallbackHandler(getShellHtml),
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(ws, getManifest, null, moduleAccessMap, telemetry),
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
- spaFallbackHandler(getShellHtml),
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();