@arcote.tech/arc-map 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/package.json +6 -5
- package/src/app/api.ts +28 -0
- package/src/app/index.tsx +4 -3
- package/src/app/usecase-view.tsx +3 -2
- package/src/mapper.ts +4 -0
- package/src/server.ts +89 -7
- package/src/types.ts +17 -0
- package/tests/fixtures/app-entry.ts +2 -0
- package/tests/server-auth.test.ts +110 -0
- package/use-cases/parowanie-tokenem.mdx +48 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcote.tech/arc-map",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.8.0",
|
|
5
5
|
"private": false,
|
|
6
6
|
"author": "Przemysław Krasiński [arcote.tech]",
|
|
7
7
|
"description": "Arc Context Map — interactive map of context spaces, elements, event-flow and React useScope usages (dev tool)",
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"types": "./src/index.ts",
|
|
10
10
|
"exports": {
|
|
11
11
|
".": "./src/index.ts",
|
|
12
|
-
"./app": "./src/app/index.tsx"
|
|
12
|
+
"./app": "./src/app/index.tsx",
|
|
13
|
+
"./types": "./src/types.ts"
|
|
13
14
|
},
|
|
14
15
|
"scripts": {
|
|
15
16
|
"type-check": "tsc --noEmit",
|
|
@@ -19,11 +20,11 @@
|
|
|
19
20
|
"ts-morph": "^24.0.0",
|
|
20
21
|
"@xyflow/react": "^12.0.0",
|
|
21
22
|
"elkjs": "^0.9.3",
|
|
22
|
-
"@arcote.tech/arc-process": "^0.
|
|
23
|
+
"@arcote.tech/arc-process": "^0.8.0"
|
|
23
24
|
},
|
|
24
25
|
"peerDependencies": {
|
|
25
|
-
"@arcote.tech/arc": "^0.
|
|
26
|
-
"@arcote.tech/platform": "^0.
|
|
26
|
+
"@arcote.tech/arc": "^0.8.0",
|
|
27
|
+
"@arcote.tech/platform": "^0.8.0",
|
|
27
28
|
"react": "^18.0.0 || ^19.0.0",
|
|
28
29
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
29
30
|
"typescript": "^5.0.0"
|
package/src/app/api.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetch do API serwera mapy z tokenem parowania. Serwer (gdy uruchomiony
|
|
3
|
+
* z `authToken`) wymaga `X-Arc-Map-Token` na `/api/*`; własny front dostaje
|
|
4
|
+
* token wstrzyknięty do shella jako `window.__ARC_MAP_TOKEN__`. SSE nie umie
|
|
5
|
+
* nagłówków — tam token idzie w query (`sseUrl`).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
declare global {
|
|
9
|
+
interface Window {
|
|
10
|
+
__ARC_MAP_TOKEN__?: string;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const token = (): string | undefined =>
|
|
15
|
+
typeof window === "undefined" ? undefined : window.__ARC_MAP_TOKEN__;
|
|
16
|
+
|
|
17
|
+
export function apiFetch(path: string, init?: RequestInit): Promise<Response> {
|
|
18
|
+
const t = token();
|
|
19
|
+
const headers = new Headers(init?.headers);
|
|
20
|
+
if (t) headers.set("X-Arc-Map-Token", t);
|
|
21
|
+
return fetch(path, { ...init, headers });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** URL strumienia SSE z tokenem w query (EventSource nie wysyła nagłówków). */
|
|
25
|
+
export function sseUrl(path: string): string {
|
|
26
|
+
const t = token();
|
|
27
|
+
return t ? `${path}${path.includes("?") ? "&" : "?"}token=${encodeURIComponent(t)}` : path;
|
|
28
|
+
}
|
package/src/app/index.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import "@xyflow/react/dist/style.css";
|
|
|
4
4
|
import { StrictMode, useEffect, useState } from "react";
|
|
5
5
|
import { createRoot } from "react-dom/client";
|
|
6
6
|
import type { MapJson, UseCaseEntry } from "../types";
|
|
7
|
+
import { apiFetch, sseUrl } from "./api";
|
|
7
8
|
import { MapView } from "./map-view";
|
|
8
9
|
import { UseCaseView } from "./usecase-view";
|
|
9
10
|
import { GLASS } from "./theme";
|
|
@@ -54,11 +55,11 @@ function App() {
|
|
|
54
55
|
const [mode, setMode] = useState<Mode>("use-cases");
|
|
55
56
|
|
|
56
57
|
const load = () => {
|
|
57
|
-
|
|
58
|
+
apiFetch("/api/map")
|
|
58
59
|
.then((r) => r.json())
|
|
59
60
|
.then((data) => (data?.error ? setError(data.error) : (setMap(data), setError(null))))
|
|
60
61
|
.catch((e) => setError(String(e)));
|
|
61
|
-
|
|
62
|
+
apiFetch("/api/use-cases")
|
|
62
63
|
.then((r) => r.json())
|
|
63
64
|
.then((d) => setUseCases(d.useCases ?? []))
|
|
64
65
|
.catch(() => setUseCases([]));
|
|
@@ -66,7 +67,7 @@ function App() {
|
|
|
66
67
|
|
|
67
68
|
useEffect(() => {
|
|
68
69
|
load();
|
|
69
|
-
const es = new EventSource("/api/reload-stream");
|
|
70
|
+
const es = new EventSource(sseUrl("/api/reload-stream"));
|
|
70
71
|
es.onmessage = (ev) => {
|
|
71
72
|
if (ev.data && ev.data !== "connected") load();
|
|
72
73
|
};
|
package/src/app/usecase-view.tsx
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from "@arcote.tech/arc-process";
|
|
17
17
|
import { ProcessDiagram, type SourceProvider } from "@arcote.tech/arc-process/react";
|
|
18
18
|
import type { MapJson, UseCaseEntry } from "../types";
|
|
19
|
+
import { apiFetch } from "./api";
|
|
19
20
|
import { UseCaseTree } from "./usecase-tree";
|
|
20
21
|
import { GLASS } from "./theme";
|
|
21
22
|
|
|
@@ -24,7 +25,7 @@ const sourceProvider: SourceProvider = async (node: ProcessNode) => {
|
|
|
24
25
|
const source = (node.meta as { source?: { file: string; line?: number } })
|
|
25
26
|
?.source;
|
|
26
27
|
if (!source?.file) return null;
|
|
27
|
-
const res = await
|
|
28
|
+
const res = await apiFetch(`/api/source?file=${encodeURIComponent(source.file)}`);
|
|
28
29
|
if (!res.ok) return null;
|
|
29
30
|
return { file: source.file, line: source.line, code: await res.text() };
|
|
30
31
|
};
|
|
@@ -42,7 +43,7 @@ export function UseCaseView({ map, useCases }: { map: MapJson; useCases: UseCase
|
|
|
42
43
|
useEffect(() => {
|
|
43
44
|
if (!selectedId) return;
|
|
44
45
|
setMdx("");
|
|
45
|
-
|
|
46
|
+
apiFetch(`/api/use-cases/raw?id=${encodeURIComponent(selectedId)}`)
|
|
46
47
|
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
|
47
48
|
.then(setMdx)
|
|
48
49
|
.catch((e) => setMdx(`# Błąd\n\nNie udało się wczytać: ${String(e)}`));
|
package/src/mapper.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
type MapElement,
|
|
30
30
|
type MapElementMethod,
|
|
31
31
|
type MapJson,
|
|
32
|
+
type MapMeta,
|
|
32
33
|
type MapSpace,
|
|
33
34
|
} from "./types";
|
|
34
35
|
|
|
@@ -52,6 +53,8 @@ export interface BuildMapInput {
|
|
|
52
53
|
catalog?: CatalogEntry[];
|
|
53
54
|
/** ISO timestamp; injected so the function stays deterministic for tests. */
|
|
54
55
|
generatedAt?: string;
|
|
56
|
+
/** Tożsamość aplikacji + wersja kontraktu — przechodzi 1:1 do `MapJson.meta`. */
|
|
57
|
+
meta?: MapMeta;
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
// ---------------------------------------------------------------------------
|
|
@@ -400,6 +403,7 @@ export function buildMapJson(input: BuildMapInput): MapJson {
|
|
|
400
403
|
const resolvedComponents = components.filter((c) => usedComponentIds.has(c.id));
|
|
401
404
|
|
|
402
405
|
return {
|
|
406
|
+
meta: input.meta,
|
|
403
407
|
spaces: [...spaceIndex.values()],
|
|
404
408
|
elements,
|
|
405
409
|
edges,
|
package/src/server.ts
CHANGED
|
@@ -21,7 +21,7 @@ import { buildMapJson, type ModuleInfo } from "./mapper";
|
|
|
21
21
|
import { loadCatalog } from "./catalog";
|
|
22
22
|
import { scanScopeUsages, type ScanInput } from "./scan";
|
|
23
23
|
import { discoverUseCases, type UseCasePackage } from "./use-cases/discover";
|
|
24
|
-
import type { MapJson, UseCaseEntry } from "./types";
|
|
24
|
+
import type { MapJson, MapMeta, UseCaseEntry } from "./types";
|
|
25
25
|
|
|
26
26
|
export interface MapServerOptions {
|
|
27
27
|
/** Returns the current live context (recomputed by the host on rebuild). */
|
|
@@ -39,6 +39,28 @@ export interface MapServerOptions {
|
|
|
39
39
|
appEntry?: string;
|
|
40
40
|
/** Packages to scan for `use-cases/**\/*.mdx`. Enables the use-case mode. */
|
|
41
41
|
useCasePackages?: UseCasePackage[];
|
|
42
|
+
/**
|
|
43
|
+
* Pairing token. When set, every `/api/*` request (except OPTIONS) must
|
|
44
|
+
* carry `X-Arc-Map-Token: <token>` — otherwise 401. `/api/reload-stream`
|
|
45
|
+
* additionally accepts `?token=` (EventSource cannot send headers). The
|
|
46
|
+
* map's own front gets the token injected into the shell HTML (served
|
|
47
|
+
* locally only), so its UX is unchanged. This closes the standing hole
|
|
48
|
+
* where ANY web page open in the developer's browser could read the whole
|
|
49
|
+
* map and source code via CORS `*`.
|
|
50
|
+
*/
|
|
51
|
+
authToken?: string;
|
|
52
|
+
/** Tożsamość aplikacji + wersja kontraktu — trafia do `MapJson.meta`. */
|
|
53
|
+
meta?: MapMeta;
|
|
54
|
+
/**
|
|
55
|
+
* Discovery (spec/platform-headless.md): informacje o serwerze PLATFORMY
|
|
56
|
+
* tej aplikacji (komendy/query/WS + chunki federowane) — konsument
|
|
57
|
+
* (host federacji, np. BMF) dostaje je z `GET /api/discovery`.
|
|
58
|
+
*/
|
|
59
|
+
platform?: {
|
|
60
|
+
platformUrl: string;
|
|
61
|
+
federation?: unknown;
|
|
62
|
+
peers?: Readonly<Record<string, string>>;
|
|
63
|
+
};
|
|
42
64
|
}
|
|
43
65
|
|
|
44
66
|
export interface MapServer {
|
|
@@ -53,13 +75,19 @@ export interface MapServer {
|
|
|
53
75
|
const DEFAULT_PORT = 5006;
|
|
54
76
|
const MAX_PORT_TRIES = 20;
|
|
55
77
|
|
|
56
|
-
function shellHtml(): string {
|
|
78
|
+
function shellHtml(authToken?: string): string {
|
|
79
|
+
// Token dla własnego frontu mapy — shell serwowany jest tylko lokalnie
|
|
80
|
+
// (localhost), więc wstrzyknięcie nie poszerza powierzchni: kto może
|
|
81
|
+
// odczytać ten HTML, ten i tak siedzi na maszynie z serwerem.
|
|
82
|
+
const tokenScript = authToken
|
|
83
|
+
? `\n <script>window.__ARC_MAP_TOKEN__ = ${JSON.stringify(authToken)};</script>`
|
|
84
|
+
: "";
|
|
57
85
|
return `<!DOCTYPE html>
|
|
58
86
|
<html lang="en">
|
|
59
87
|
<head>
|
|
60
88
|
<meta charset="UTF-8" />
|
|
61
89
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
62
|
-
<title>Arc Context Map</title
|
|
90
|
+
<title>Arc Context Map</title>${tokenScript}
|
|
63
91
|
<link rel="stylesheet" href="/app.css" />
|
|
64
92
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
65
93
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" />
|
|
@@ -110,7 +138,17 @@ async function buildApp(appEntry: string): Promise<{ js: string; css: string }>
|
|
|
110
138
|
const CORS = {
|
|
111
139
|
"Access-Control-Allow-Origin": "*",
|
|
112
140
|
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
|
113
|
-
"Access-Control-Allow-Headers": "Content-Type",
|
|
141
|
+
"Access-Control-Allow-Headers": "Content-Type, X-Arc-Map-Token",
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Preflight z publicznej strony (https) do localhost wymaga w Chrome zgody
|
|
146
|
+
* Private Network Access — bez tego nagłówka fetch z hostowanego BMF do
|
|
147
|
+
* lokalnego serwera mapy jest blokowany zanim w ogóle dojdzie do requestu.
|
|
148
|
+
*/
|
|
149
|
+
const PREFLIGHT = {
|
|
150
|
+
...CORS,
|
|
151
|
+
"Access-Control-Allow-Private-Network": "true",
|
|
114
152
|
};
|
|
115
153
|
|
|
116
154
|
export async function startMapServer(opts: MapServerOptions): Promise<MapServer> {
|
|
@@ -140,7 +178,14 @@ export async function startMapServer(opts: MapServerOptions): Promise<MapServer>
|
|
|
140
178
|
const buildPayload = (): MapJson => {
|
|
141
179
|
const context = opts.getContext();
|
|
142
180
|
if (!context) {
|
|
143
|
-
return {
|
|
181
|
+
return {
|
|
182
|
+
meta: opts.meta,
|
|
183
|
+
spaces: [],
|
|
184
|
+
elements: [],
|
|
185
|
+
edges: [],
|
|
186
|
+
components: [],
|
|
187
|
+
componentEdges: [],
|
|
188
|
+
};
|
|
144
189
|
}
|
|
145
190
|
const scan = getScan();
|
|
146
191
|
const moduleByElement = (opts.getModuleIndex ?? buildModuleIndex)();
|
|
@@ -152,6 +197,7 @@ export async function startMapServer(opts: MapServerOptions): Promise<MapServer>
|
|
|
152
197
|
pages: buildPageNodes(),
|
|
153
198
|
catalog: getCatalog(),
|
|
154
199
|
generatedAt: new Date().toISOString(),
|
|
200
|
+
meta: opts.meta,
|
|
155
201
|
});
|
|
156
202
|
};
|
|
157
203
|
|
|
@@ -178,7 +224,24 @@ export async function startMapServer(opts: MapServerOptions): Promise<MapServer>
|
|
|
178
224
|
|
|
179
225
|
const handler = (req: Request): Response => {
|
|
180
226
|
const url = new URL(req.url);
|
|
181
|
-
if (req.method === "OPTIONS") return new Response(null, { headers:
|
|
227
|
+
if (req.method === "OPTIONS") return new Response(null, { headers: PREFLIGHT });
|
|
228
|
+
|
|
229
|
+
// Guard tokenu parowania na całym /api/* (patrz MapServerOptions.authToken).
|
|
230
|
+
// 401 wychodzi Z nagłówkami CORS, żeby zewnętrzny konsument (BMF) umiał
|
|
231
|
+
// odróżnić „zły token" od „serwer nie żyje" (network error).
|
|
232
|
+
if (opts.authToken && url.pathname.startsWith("/api/")) {
|
|
233
|
+
const header = req.headers.get("X-Arc-Map-Token");
|
|
234
|
+
const query =
|
|
235
|
+
url.pathname === "/api/reload-stream"
|
|
236
|
+
? url.searchParams.get("token")
|
|
237
|
+
: null;
|
|
238
|
+
if (header !== opts.authToken && query !== opts.authToken) {
|
|
239
|
+
return new Response(JSON.stringify({ error: "invalid pairing token" }), {
|
|
240
|
+
status: 401,
|
|
241
|
+
headers: { ...CORS, "Content-Type": "application/json" },
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
182
245
|
|
|
183
246
|
switch (url.pathname) {
|
|
184
247
|
case "/app.js":
|
|
@@ -203,6 +266,25 @@ export async function startMapServer(opts: MapServerOptions): Promise<MapServer>
|
|
|
203
266
|
headers: { ...CORS, "Content-Type": "application/json", "Cache-Control": "no-cache" },
|
|
204
267
|
});
|
|
205
268
|
}
|
|
269
|
+
case "/api/discovery": {
|
|
270
|
+
// Discovery dla zewnętrznego konsumenta (za tokenem parowania):
|
|
271
|
+
// gdzie żyje serwer platformy, czy federacja jest dostępna i z
|
|
272
|
+
// jakimi peers (polityka kompatybilności hosta) — bez konieczności
|
|
273
|
+
// zgadywania portów.
|
|
274
|
+
const platform = opts.platform;
|
|
275
|
+
const body = {
|
|
276
|
+
meta: opts.meta ?? null,
|
|
277
|
+
platformUrl: platform?.platformUrl ?? null,
|
|
278
|
+
federation: (platform?.federation as object | undefined) ?? null,
|
|
279
|
+
peers: platform?.peers ?? null,
|
|
280
|
+
federationManifestUrl: platform
|
|
281
|
+
? `${platform.platformUrl}/api/federation/manifest`
|
|
282
|
+
: null,
|
|
283
|
+
};
|
|
284
|
+
return new Response(JSON.stringify(body), {
|
|
285
|
+
headers: { ...CORS, "Content-Type": "application/json", "Cache-Control": "no-cache" },
|
|
286
|
+
});
|
|
287
|
+
}
|
|
206
288
|
case "/api/use-cases": {
|
|
207
289
|
const list: UseCaseEntry[] = getUseCases().map(
|
|
208
290
|
({ absPath, ...e }) => e,
|
|
@@ -278,7 +360,7 @@ export async function startMapServer(opts: MapServerOptions): Promise<MapServer>
|
|
|
278
360
|
});
|
|
279
361
|
}
|
|
280
362
|
default:
|
|
281
|
-
return new Response(shellHtml(), {
|
|
363
|
+
return new Response(shellHtml(opts.authToken), {
|
|
282
364
|
headers: { "Content-Type": "text/html" },
|
|
283
365
|
});
|
|
284
366
|
}
|
package/src/types.ts
CHANGED
|
@@ -150,8 +150,25 @@ export interface MapComponentEdge {
|
|
|
150
150
|
line?: number;
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Meta zbudowanej mapy — tożsamość aplikacji dla zewnętrznych konsumentów
|
|
155
|
+
* (np. import kontekstu do BMF). `schemaVersion` wersjonuje kontrakt MapJson;
|
|
156
|
+
* konsument odrzuca nieznaną wersję major z czytelnym komunikatem.
|
|
157
|
+
*/
|
|
158
|
+
export interface MapMeta {
|
|
159
|
+
schemaVersion: number;
|
|
160
|
+
/** Nazwa aplikacji (root package.json workspace'u). */
|
|
161
|
+
name: string;
|
|
162
|
+
version?: string;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Bieżąca wersja kontraktu MapJson serwowanego przez `/api/map`. */
|
|
166
|
+
export const MAP_SCHEMA_VERSION = 1;
|
|
167
|
+
|
|
153
168
|
/** Full payload returned by `GET /api/map`. */
|
|
154
169
|
export interface MapJson {
|
|
170
|
+
/** Tożsamość aplikacji + wersja kontraktu (dla zewnętrznych konsumentów). */
|
|
171
|
+
meta?: MapMeta;
|
|
155
172
|
spaces: MapSpace[];
|
|
156
173
|
elements: MapElement[];
|
|
157
174
|
edges: MapEdge[];
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { startMapServer, type MapServer } from "../src/server";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Testy parowania tokenem serwera arc-map (konsumpcja przez zewnętrzną
|
|
7
|
+
* aplikację, np. BMF): guard `X-Arc-Map-Token` na `/api/*`, nagłówki CORS +
|
|
8
|
+
* Private Network Access na preflight, fallback `?token=` na SSE oraz meta
|
|
9
|
+
* aplikacji w `/api/map`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const APP_ENTRY = join(import.meta.dir, "fixtures/app-entry.ts");
|
|
13
|
+
const TOKEN = "sekret-parowania-123";
|
|
14
|
+
|
|
15
|
+
const servers: MapServer[] = [];
|
|
16
|
+
afterAll(() => {
|
|
17
|
+
for (const s of servers) s.stop();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
async function boot(opts: { authToken?: string } = {}): Promise<MapServer> {
|
|
21
|
+
const server = await startMapServer({
|
|
22
|
+
getContext: () => null,
|
|
23
|
+
scan: { globs: [], workspaceRoot: import.meta.dir, packageOf: () => undefined },
|
|
24
|
+
port: 45810 + servers.length * 25,
|
|
25
|
+
appEntry: APP_ENTRY,
|
|
26
|
+
meta: { schemaVersion: 1, name: "@x/test-app", version: "1.2.3" },
|
|
27
|
+
...opts,
|
|
28
|
+
});
|
|
29
|
+
servers.push(server);
|
|
30
|
+
return server;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("startMapServer bez authToken (backward compat)", () => {
|
|
34
|
+
test("/api/map odpowiada bez tokenu", async () => {
|
|
35
|
+
const s = await boot();
|
|
36
|
+
const res = await fetch(`${s.url}/api/map`);
|
|
37
|
+
expect(res.status).toBe(200);
|
|
38
|
+
const body = (await res.json()) as { meta?: { name: string } };
|
|
39
|
+
expect(body.meta?.name).toBe("@x/test-app");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("startMapServer z authToken", () => {
|
|
44
|
+
let s: MapServer;
|
|
45
|
+
test("boot", async () => {
|
|
46
|
+
s = await boot({ authToken: TOKEN });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("/api/map bez tokenu → 401 z nagłówkami CORS", async () => {
|
|
50
|
+
const res = await fetch(`${s.url}/api/map`);
|
|
51
|
+
expect(res.status).toBe(401);
|
|
52
|
+
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("/api/map ze złym tokenem → 401", async () => {
|
|
56
|
+
const res = await fetch(`${s.url}/api/map`, {
|
|
57
|
+
headers: { "X-Arc-Map-Token": "zly" },
|
|
58
|
+
});
|
|
59
|
+
expect(res.status).toBe(401);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("/api/map z tokenem → 200 + meta", async () => {
|
|
63
|
+
const res = await fetch(`${s.url}/api/map`, {
|
|
64
|
+
headers: { "X-Arc-Map-Token": TOKEN },
|
|
65
|
+
});
|
|
66
|
+
expect(res.status).toBe(200);
|
|
67
|
+
const body = (await res.json()) as {
|
|
68
|
+
meta?: { schemaVersion: number; name: string; version?: string };
|
|
69
|
+
};
|
|
70
|
+
expect(body.meta).toEqual({
|
|
71
|
+
schemaVersion: 1,
|
|
72
|
+
name: "@x/test-app",
|
|
73
|
+
version: "1.2.3",
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("OPTIONS przechodzi bez tokenu: PNA + Allow-Headers z X-Arc-Map-Token", async () => {
|
|
78
|
+
const res = await fetch(`${s.url}/api/map`, { method: "OPTIONS" });
|
|
79
|
+
expect(res.status).toBeLessThan(300);
|
|
80
|
+
expect(res.headers.get("Access-Control-Allow-Private-Network")).toBe("true");
|
|
81
|
+
expect(res.headers.get("Access-Control-Allow-Headers")).toContain(
|
|
82
|
+
"X-Arc-Map-Token",
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("/api/reload-stream przyjmuje ?token= (EventSource bez nagłówków)", async () => {
|
|
87
|
+
const res = await fetch(
|
|
88
|
+
`${s.url}/api/reload-stream?token=${encodeURIComponent(TOKEN)}`,
|
|
89
|
+
);
|
|
90
|
+
expect(res.status).toBe(200);
|
|
91
|
+
expect(res.headers.get("Content-Type")).toContain("text/event-stream");
|
|
92
|
+
const reader = res.body!.getReader();
|
|
93
|
+
const first = await reader.read();
|
|
94
|
+
expect(new TextDecoder().decode(first.value)).toContain("connected");
|
|
95
|
+
await reader.cancel();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("/api/reload-stream bez tokenu → 401", async () => {
|
|
99
|
+
const res = await fetch(`${s.url}/api/reload-stream`);
|
|
100
|
+
expect(res.status).toBe(401);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("shell HTML (/) działa bez tokenu i wstrzykuje token dla własnego frontu", async () => {
|
|
104
|
+
const res = await fetch(`${s.url}/`);
|
|
105
|
+
expect(res.status).toBe(200);
|
|
106
|
+
const html = await res.text();
|
|
107
|
+
expect(html).toContain("__ARC_MAP_TOKEN__");
|
|
108
|
+
expect(html).toContain(TOKEN);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Parowanie zewnętrznego konsumenta tokenem
|
|
3
|
+
description: Serwer mapy chroni /api/* tokenem parowania; zewnętrzna aplikacja (np. BMF) wysyła go w nagłówku X-Arc-Map-Token
|
|
4
|
+
order: 1
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Cel
|
|
8
|
+
|
|
9
|
+
Zamknąć API serwera mapy (kontekst + kod źródłowy przez `/api/source`) przed
|
|
10
|
+
obcymi stronami w przeglądarce dewelopera, a jednocześnie dać zewnętrznym
|
|
11
|
+
konsumentom (BMF) prosty sposób podłączenia się: token wypisany w konsoli
|
|
12
|
+
(wzór Jupyter).
|
|
13
|
+
|
|
14
|
+
## Aktorzy
|
|
15
|
+
|
|
16
|
+
- **Deweloper** — uruchamia `arc platform dev --map`, wkleja token w BMF.
|
|
17
|
+
- **Zewnętrzny konsument (BMF)** — fetchuje `/api/map`, `/api/source`,
|
|
18
|
+
`/api/use-cases`, SSE `/api/reload-stream` z tokenem.
|
|
19
|
+
- **Obca strona w przeglądarce** — bez tokenu dostaje 401.
|
|
20
|
+
|
|
21
|
+
## Przepływ kroków
|
|
22
|
+
|
|
23
|
+
<Step id="start" title="Start serwera z tokenem">
|
|
24
|
+
Deweloper uruchamia `arc platform dev --map`. CLI generuje token parowania
|
|
25
|
+
(`ARC_MAP_TOKEN` w env → token stały) i wypisuje go w konsoli obok adresu mapy.
|
|
26
|
+
Serwer dostaje `authToken` oraz `meta` (nazwa + wersja aplikacji z root
|
|
27
|
+
`package.json`, `schemaVersion` kontraktu MapJson).
|
|
28
|
+
</Step>
|
|
29
|
+
|
|
30
|
+
<Step id="wlasny-front" title="Własny front mapy działa bez zmian">
|
|
31
|
+
Shell HTML (serwowany tylko lokalnie) niesie wstrzyknięty
|
|
32
|
+
`window.__ARC_MAP_TOKEN__`; helper `apiFetch` dokłada nagłówek
|
|
33
|
+
`X-Arc-Map-Token`, a `EventSource` dostaje token w query (`sseUrl`).
|
|
34
|
+
</Step>
|
|
35
|
+
|
|
36
|
+
<Step id="konsument" title="Parowanie zewnętrznego konsumenta">
|
|
37
|
+
Deweloper wkleja adres i token w BMF. Konsument wysyła
|
|
38
|
+
`X-Arc-Map-Token` na każde `/api/*`; preflight OPTIONS przechodzi bez tokenu
|
|
39
|
+
i odpowiada nagłówkami CORS + `Access-Control-Allow-Private-Network: true`
|
|
40
|
+
(Chrome PNA dla https→localhost). `GET /api/map` zwraca `meta` — konsument
|
|
41
|
+
waliduje `schemaVersion` i pokazuje nazwę aplikacji.
|
|
42
|
+
</Step>
|
|
43
|
+
|
|
44
|
+
<Step id="odmowa" title="Odmowa bez tokenu">
|
|
45
|
+
Request na `/api/*` bez tokenu lub ze złym tokenem dostaje **401 z nagłówkami
|
|
46
|
+
CORS** — konsument odróżnia „zły token" od „serwer nie żyje" (network error).
|
|
47
|
+
Strony spoza parowania nie odczytają mapy ani kodu źródłowego.
|
|
48
|
+
</Step>
|