pmx-canvas 0.1.28 → 0.1.30

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.
Files changed (67) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/Readme.md +20 -10
  3. package/dist/canvas/global.css +13 -0
  4. package/dist/canvas/index.js +80 -163
  5. package/dist/canvas/surface-theme.css +142 -0
  6. package/dist/json-render/index.js +103 -103
  7. package/dist/types/client/nodes/HtmlNode.d.ts +0 -7
  8. package/dist/types/client/nodes/ax-node-actions.d.ts +18 -0
  9. package/dist/types/client/nodes/surface-url.d.ts +22 -0
  10. package/dist/types/client/state/attention-bridge.d.ts +3 -0
  11. package/dist/types/client/state/intent-bridge.d.ts +17 -0
  12. package/dist/types/json-render/renderer/index.d.ts +2 -0
  13. package/dist/types/json-render/schema.d.ts +2 -0
  14. package/dist/types/json-render/server.d.ts +2 -0
  15. package/dist/types/mcp/canvas-access.d.ts +47 -0
  16. package/dist/types/server/ax-interaction.d.ts +210 -0
  17. package/dist/types/server/ax-state.d.ts +67 -1
  18. package/dist/types/server/canvas-db.d.ts +4 -0
  19. package/dist/types/server/canvas-serialization.d.ts +2 -0
  20. package/dist/types/server/canvas-state.d.ts +47 -2
  21. package/dist/types/server/html-surface.d.ts +40 -0
  22. package/dist/types/server/index.d.ts +56 -2
  23. package/dist/types/server/mutation-history.d.ts +1 -1
  24. package/dist/types/server/placement.d.ts +1 -1
  25. package/dist/types/shared/surface.d.ts +19 -0
  26. package/docs/cli.md +30 -0
  27. package/docs/http-api.md +55 -0
  28. package/docs/mcp.md +40 -2
  29. package/docs/node-types.md +26 -0
  30. package/docs/plans/plan-004-pmx-ax-primitives.md +623 -394
  31. package/docs/sdk.md +20 -0
  32. package/package.json +2 -2
  33. package/skills/pmx-canvas/SKILL.md +107 -9
  34. package/src/cli/agent.ts +190 -0
  35. package/src/client/canvas/CanvasNode.tsx +8 -4
  36. package/src/client/canvas/ExpandedNodeOverlay.tsx +12 -0
  37. package/src/client/nodes/ContextNode.tsx +17 -0
  38. package/src/client/nodes/ExtAppFrame.tsx +40 -3
  39. package/src/client/nodes/FileNode.tsx +26 -0
  40. package/src/client/nodes/HtmlNode.tsx +60 -188
  41. package/src/client/nodes/LedgerNode.tsx +39 -5
  42. package/src/client/nodes/McpAppNode.tsx +47 -2
  43. package/src/client/nodes/StatusNode.tsx +20 -0
  44. package/src/client/nodes/ax-node-actions.ts +39 -0
  45. package/src/client/nodes/surface-url.ts +48 -0
  46. package/src/client/state/attention-bridge.ts +5 -0
  47. package/src/client/state/intent-bridge.ts +33 -0
  48. package/src/client/theme/global.css +13 -0
  49. package/src/client/theme/surface-theme.css +142 -0
  50. package/src/json-render/renderer/index.tsx +31 -0
  51. package/src/json-render/schema.ts +4 -0
  52. package/src/json-render/server.ts +31 -1
  53. package/src/mcp/canvas-access.ts +212 -1
  54. package/src/mcp/server.ts +238 -5
  55. package/src/server/ax-context.ts +3 -0
  56. package/src/server/ax-interaction.ts +549 -0
  57. package/src/server/ax-state.ts +188 -2
  58. package/src/server/canvas-db.ts +20 -0
  59. package/src/server/canvas-operations.ts +11 -0
  60. package/src/server/canvas-serialization.ts +9 -0
  61. package/src/server/canvas-state.ts +177 -16
  62. package/src/server/html-surface.ts +170 -0
  63. package/src/server/index.ts +105 -1
  64. package/src/server/mutation-history.ts +5 -0
  65. package/src/server/placement.ts +5 -1
  66. package/src/server/server.ts +305 -0
  67. package/src/shared/surface.ts +38 -0
@@ -48,6 +48,7 @@ import type {
48
48
  ListToolsResult,
49
49
  } from '@modelcontextprotocol/sdk/types.js';
50
50
  import { type CanvasAnnotation, type CanvasEdge, type CanvasLayout, type CanvasNodeState, IMAGE_MIME_MAP, canvasState } from './canvas-state.js';
51
+ import { buildHtmlSurfaceDocument, HTML_SURFACE_SANDBOX, normalizeSurfaceTheme } from './html-surface.js';
51
52
  import { findCanvasExtAppNodeId as findCanvasExtAppNodeIdShared } from './ext-app-lookup.js';
52
53
  import { normalizeExtAppToolResult } from './ext-app-tool-result.js';
53
54
  import { getMcpAppHostSnapshot } from './mcp-app-host.js';
@@ -77,8 +78,10 @@ import {
77
78
  import { buildCodeGraphSummary, formatCodeGraph } from './code-graph.js';
78
79
  import { buildAgentContextPreamble, serializeNodeForAgentContext } from './agent-context.js';
79
80
  import { buildCanvasAxContext } from './ax-context.js';
81
+ import { applyAxInteraction, resolveNodeAxCapabilities } from './ax-interaction.js';
80
82
  import { isAxEventKind, isAxEvidenceKind } from './ax-state.js';
81
83
  import type {
84
+ PmxAxPolicy,
82
85
  PmxAxReviewAnchorType,
83
86
  PmxAxReviewKind,
84
87
  PmxAxReviewRegion,
@@ -1376,6 +1379,108 @@ function handleFrameDocument(pathname: string): Response {
1376
1379
  });
1377
1380
  }
1378
1381
 
1382
+ // ── Node surfaces ("Open as site") ─────────────────────────────
1383
+ //
1384
+ // One stable, node-addressable URL — /api/canvas/surface/:nodeId — that serves
1385
+ // (or redirects to) the exact same rendered surface a node shows in the canvas.
1386
+ // The in-canvas html iframe points at this URL too, so there is one render path.
1387
+ //
1388
+ // Served document types (html / ext-app) carry the same opaque-origin posture as
1389
+ // frame documents: `Content-Security-Policy: sandbox <tokens>` (no
1390
+ // allow-same-origin) means author scripts cannot reach the PMX origin even when
1391
+ // the page is opened top-level in a browser tab. Other types redirect to the
1392
+ // route that already renders them standalone.
1393
+
1394
+ function surfaceHtmlResponse(html: string, sandbox: string): Response {
1395
+ return new Response(html, {
1396
+ headers: {
1397
+ 'Content-Type': 'text/html; charset=utf-8',
1398
+ 'Cache-Control': 'no-store',
1399
+ 'Content-Security-Policy': `sandbox ${sandbox}`,
1400
+ 'Referrer-Policy': 'no-referrer',
1401
+ 'X-Content-Type-Options': 'nosniff',
1402
+ },
1403
+ });
1404
+ }
1405
+
1406
+ function surfaceRedirect(target: string): Response {
1407
+ return new Response(null, { status: 302, headers: { Location: target, 'Cache-Control': 'no-store' } });
1408
+ }
1409
+
1410
+ // Permit only absolute http(s) URLs and root-relative same-origin paths. Blocks
1411
+ // `javascript:`/`data:` and protocol-relative `//host` open-redirects.
1412
+ function isSafeSurfaceRedirect(target: string): boolean {
1413
+ if (/^https?:\/\//i.test(target)) return true;
1414
+ if (/^\/(?!\/)/.test(target)) return true;
1415
+ return false;
1416
+ }
1417
+
1418
+ function handleNodeSurface(pathname: string, url: URL): Response {
1419
+ const nodeId = decodeURIComponent(pathname.slice('/api/canvas/surface/'.length));
1420
+ if (!nodeId) return responseText('Missing node id', 400);
1421
+ const node = canvasState.getNode(nodeId);
1422
+ if (!node) return responseText('Node not found', 404);
1423
+
1424
+ const theme = normalizeSurfaceTheme(url.searchParams.get('theme'));
1425
+
1426
+ if (node.type === 'html') {
1427
+ const html = typeof node.data.html === 'string'
1428
+ ? node.data.html
1429
+ : typeof node.data.content === 'string'
1430
+ ? node.data.content
1431
+ : '';
1432
+ if (!html) return responseText('HTML node has no content', 404);
1433
+ const present = url.searchParams.get('present') === '1';
1434
+ const axCaps = resolveNodeAxCapabilities(node);
1435
+ const doc = buildHtmlSurfaceDocument(html, {
1436
+ theme,
1437
+ themeToken: url.searchParams.get('themeToken') ?? undefined,
1438
+ presentation: present,
1439
+ presentationExitToken: url.searchParams.get('presentToken') ?? undefined,
1440
+ axBridge: axCaps.enabled && axCaps.allowed.length > 0,
1441
+ axToken: url.searchParams.get('axToken') ?? undefined,
1442
+ nodeId: node.id,
1443
+ });
1444
+ return surfaceHtmlResponse(doc, HTML_SURFACE_SANDBOX);
1445
+ }
1446
+
1447
+ if (node.type === 'json-render' || node.type === 'graph') {
1448
+ const params = new URLSearchParams({ nodeId, theme });
1449
+ const display = url.searchParams.get('display');
1450
+ if (display === 'expanded') params.set('display', 'expanded');
1451
+ return surfaceRedirect(`/api/canvas/json-render/view?${params.toString()}`);
1452
+ }
1453
+
1454
+ if (node.type === 'mcp-app') {
1455
+ // Bundled web artifact — same standalone page the canvas iframe already loads.
1456
+ if (node.data.viewerType === 'web-artifact' && typeof node.data.path === 'string' && node.data.path) {
1457
+ return surfaceRedirect(`/artifact?path=${encodeURIComponent(node.data.path)}`);
1458
+ }
1459
+ // Hosted ext-app — serve the same prepared HTML the in-canvas frame receives.
1460
+ // The app's host bridge has no peer in a standalone tab, so interactive
1461
+ // tool-calls won't function there; the UI still renders. Served TOP-LEVEL with
1462
+ // a tighter sandbox than the in-canvas iframe (no allow-popups-to-escape-sandbox)
1463
+ // since this is untrusted third-party HTML opened as its own page.
1464
+ if (node.data.mode === 'ext-app' && typeof node.data.html === 'string' && node.data.html) {
1465
+ return surfaceHtmlResponse(node.data.html, HTML_SURFACE_SANDBOX);
1466
+ }
1467
+ // URL-backed viewer — hand off to its own origin.
1468
+ if (typeof node.data.url === 'string' && isSafeSurfaceRedirect(node.data.url)) {
1469
+ return surfaceRedirect(node.data.url);
1470
+ }
1471
+ return responseText('MCP app node has no openable surface', 404);
1472
+ }
1473
+
1474
+ if (node.type === 'webpage') {
1475
+ if (typeof node.data.url === 'string' && isSafeSurfaceRedirect(node.data.url)) {
1476
+ return surfaceRedirect(node.data.url);
1477
+ }
1478
+ return responseText('Webpage node has no url', 404);
1479
+ }
1480
+
1481
+ return responseText('Node type cannot be opened as a site', 404);
1482
+ }
1483
+
1379
1484
  async function handleCanvasUpdate(req: Request): Promise<Response> {
1380
1485
  const body = await readJson(req);
1381
1486
  const updates = Array.isArray(body.updates) ? body.updates : [];
@@ -1542,6 +1647,9 @@ function buildNodeResponse(node: CanvasNodeState): Record<string, unknown> {
1542
1647
  ok: true,
1543
1648
  node: serialized,
1544
1649
  ...serialized,
1650
+ // `nodeId` aliases `id` so HTTP/CLI node-create responses match the MCP
1651
+ // createdNodePayload — agents using either key (or a cached schema) work.
1652
+ nodeId: node.id,
1545
1653
  };
1546
1654
  }
1547
1655
 
@@ -2423,12 +2531,14 @@ async function handleJsonRenderView(url: URL): Promise<Response> {
2423
2531
  const devtoolsEnabled =
2424
2532
  process.env.PMX_CANVAS_JSON_RENDER_DEVTOOLS === '1' &&
2425
2533
  url.searchParams.get('devtools') === '1';
2534
+ const axToken = url.searchParams.get('axToken');
2426
2535
  const html = await buildJsonRenderViewerHtml({
2427
2536
  title,
2428
2537
  spec,
2429
2538
  ...(theme ? { theme } : {}),
2430
2539
  ...(url.searchParams.get('display') === 'expanded' ? { display: 'expanded' as const } : {}),
2431
2540
  ...(devtoolsEnabled ? { devtools: true } : {}),
2541
+ ...(axToken ? { nodeId, axToken } : {}),
2432
2542
  });
2433
2543
  return new Response(html, {
2434
2544
  headers: {
@@ -3713,6 +3823,136 @@ function handleGetAxContext(): Response {
3713
3823
  return responseJson(buildCanvasAxContext());
3714
3824
  }
3715
3825
 
3826
+ async function handleAxInteraction(req: Request): Promise<Response> {
3827
+ const body = await readJson(req);
3828
+ const { result, events } = applyAxInteraction(canvasState, body, normalizeAxSource(body.source, 'api'));
3829
+ for (const e of events) {
3830
+ broadcastWorkbenchEvent(e.event, {
3831
+ ...e.payload,
3832
+ sessionId: primaryWorkbenchSessionId,
3833
+ timestamp: new Date().toISOString(),
3834
+ });
3835
+ }
3836
+ return responseJson(result, result.ok ? 200 : result.status);
3837
+ }
3838
+
3839
+ function handleAxDeliveryPending(url: URL): Response {
3840
+ const consumer = url.searchParams.get('consumer') ?? undefined;
3841
+ const limitRaw = Number(url.searchParams.get('limit') ?? '');
3842
+ const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : undefined;
3843
+ const pending = canvasState.getPendingSteering({
3844
+ ...(consumer ? { consumer } : {}),
3845
+ ...(limit ? { limit } : {}),
3846
+ });
3847
+ return responseJson({ ok: true, pending });
3848
+ }
3849
+
3850
+ function handleAxDeliveryMark(id: string): Response {
3851
+ const delivered = canvasState.markSteeringDelivered(id);
3852
+ if (delivered) {
3853
+ broadcastWorkbenchEvent('ax-event-created', {
3854
+ steeringDelivered: id,
3855
+ sessionId: primaryWorkbenchSessionId,
3856
+ timestamp: new Date().toISOString(),
3857
+ });
3858
+ }
3859
+ return responseJson({ ok: true, delivered });
3860
+ }
3861
+
3862
+ function handleAxElicitationList(): Response {
3863
+ return responseJson({ ok: true, elicitations: canvasState.getElicitations() });
3864
+ }
3865
+
3866
+ async function handleAxElicitationRequest(req: Request): Promise<Response> {
3867
+ const body = await readJson(req);
3868
+ if (typeof body.prompt !== 'string' || !body.prompt.trim()) {
3869
+ return responseJson({ ok: false, error: 'elicitation requires a prompt.' }, 400);
3870
+ }
3871
+ const elicitation = canvasState.requestElicitation(
3872
+ {
3873
+ prompt: body.prompt,
3874
+ ...(Array.isArray(body.fields) ? { fields: body.fields.filter((f: unknown): f is string => typeof f === 'string') } : {}),
3875
+ ...(Array.isArray(body.nodeIds) ? { nodeIds: normalizeAxNodeIds(body.nodeIds) } : {}),
3876
+ },
3877
+ { source: normalizeAxSource(body.source, 'api') },
3878
+ );
3879
+ broadcastWorkbenchEvent('ax-state-changed', { elicitation, sessionId: primaryWorkbenchSessionId, timestamp: new Date().toISOString() });
3880
+ return responseJson({ ok: true, elicitation });
3881
+ }
3882
+
3883
+ async function handleAxElicitationRespond(req: Request, id: string): Promise<Response> {
3884
+ const body = await readJson(req);
3885
+ const response = isRecord(body.response) ? body.response : {};
3886
+ const elicitation = canvasState.respondElicitation(id, response, { source: normalizeAxSource(body.source, 'api') });
3887
+ if (!elicitation) return responseJson({ ok: false, error: 'elicitation not found or already answered.' }, 404);
3888
+ broadcastWorkbenchEvent('ax-state-changed', { elicitation, sessionId: primaryWorkbenchSessionId, timestamp: new Date().toISOString() });
3889
+ return responseJson({ ok: true, elicitation });
3890
+ }
3891
+
3892
+ function handleAxModeList(): Response {
3893
+ return responseJson({ ok: true, modeRequests: canvasState.getModeRequests() });
3894
+ }
3895
+
3896
+ async function handleAxModeRequest(req: Request): Promise<Response> {
3897
+ const body = await readJson(req);
3898
+ if (body.mode !== 'plan' && body.mode !== 'execute' && body.mode !== 'autonomous') {
3899
+ return responseJson({ ok: false, error: 'mode request requires mode plan|execute|autonomous.' }, 400);
3900
+ }
3901
+ const modeRequest = canvasState.requestMode(
3902
+ {
3903
+ mode: body.mode,
3904
+ ...(typeof body.reason === 'string' ? { reason: body.reason } : {}),
3905
+ ...(Array.isArray(body.nodeIds) ? { nodeIds: normalizeAxNodeIds(body.nodeIds) } : {}),
3906
+ },
3907
+ { source: normalizeAxSource(body.source, 'api') },
3908
+ );
3909
+ broadcastWorkbenchEvent('ax-state-changed', { modeRequest, sessionId: primaryWorkbenchSessionId, timestamp: new Date().toISOString() });
3910
+ return responseJson({ ok: true, modeRequest });
3911
+ }
3912
+
3913
+ async function handleAxModeResolve(req: Request, id: string): Promise<Response> {
3914
+ const body = await readJson(req);
3915
+ if (body.decision !== 'approved' && body.decision !== 'rejected') {
3916
+ return responseJson({ ok: false, error: 'resolve requires decision approved or rejected.' }, 400);
3917
+ }
3918
+ const modeRequest = canvasState.resolveModeRequest(id, body.decision, {
3919
+ ...(typeof body.resolution === 'string' ? { resolution: body.resolution } : {}),
3920
+ source: normalizeAxSource(body.source, 'api'),
3921
+ });
3922
+ if (!modeRequest) return responseJson({ ok: false, error: 'mode request not found or already resolved.' }, 404);
3923
+ broadcastWorkbenchEvent('ax-state-changed', { modeRequest, sessionId: primaryWorkbenchSessionId, timestamp: new Date().toISOString() });
3924
+ return responseJson({ ok: true, modeRequest });
3925
+ }
3926
+
3927
+ function handleAxCommandList(): Response {
3928
+ return responseJson({ ok: true, commands: canvasState.getCommandRegistry() });
3929
+ }
3930
+
3931
+ async function handleAxCommandInvoke(req: Request): Promise<Response> {
3932
+ const body = await readJson(req);
3933
+ if (typeof body.name !== 'string') {
3934
+ return responseJson({ ok: false, error: 'command requires a name.' }, 400);
3935
+ }
3936
+ const event = canvasState.invokeCommand(body.name, isRecord(body.args) ? body.args : null, { source: normalizeAxSource(body.source, 'api') });
3937
+ if (!event) return responseJson({ ok: false, error: `Unknown command "${body.name}".` }, 400);
3938
+ broadcastWorkbenchEvent('ax-event-created', { event, sessionId: primaryWorkbenchSessionId, timestamp: new Date().toISOString() });
3939
+ return responseJson({ ok: true, event });
3940
+ }
3941
+
3942
+ function handleAxPolicyGet(): Response {
3943
+ return responseJson({ ok: true, policy: canvasState.getPolicy() });
3944
+ }
3945
+
3946
+ async function handleAxPolicySet(req: Request): Promise<Response> {
3947
+ const body = await readJson(req);
3948
+ const patch: { tools?: Partial<PmxAxPolicy['tools']>; prompt?: Partial<PmxAxPolicy['prompt']> } = {};
3949
+ if (isRecord(body.tools)) patch.tools = body.tools as Partial<PmxAxPolicy['tools']>;
3950
+ if (isRecord(body.prompt)) patch.prompt = body.prompt as Partial<PmxAxPolicy['prompt']>;
3951
+ const policy = canvasState.setPolicy(patch, { source: normalizeAxSource(body.source, 'api') });
3952
+ broadcastWorkbenchEvent('ax-state-changed', { policy, sessionId: primaryWorkbenchSessionId, timestamp: new Date().toISOString() });
3953
+ return responseJson({ ok: true, policy });
3954
+ }
3955
+
3716
3956
  async function handleAxFocusUpdate(req: Request): Promise<Response> {
3717
3957
  const body = await readJson(req);
3718
3958
  const nodeIds = normalizeAxNodeIds(body.nodeIds);
@@ -4760,6 +5000,10 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
4760
5000
  return handleFrameDocument(url.pathname);
4761
5001
  }
4762
5002
 
5003
+ if (url.pathname.startsWith('/api/canvas/surface/') && req.method === 'GET') {
5004
+ return handleNodeSurface(url.pathname, url);
5005
+ }
5006
+
4763
5007
  if (url.pathname === '/' || url.pathname === '/workbench' || url.pathname === '/artifact') {
4764
5008
  return new Response(canvasSpaHtml(), {
4765
5009
  headers: {
@@ -5074,6 +5318,67 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
5074
5318
  return handleAxHostCapabilityReport(req);
5075
5319
  }
5076
5320
 
5321
+ if (url.pathname === '/api/canvas/ax/interaction' && req.method === 'POST') {
5322
+ return handleAxInteraction(req);
5323
+ }
5324
+
5325
+ if (url.pathname === '/api/canvas/ax/delivery/pending' && req.method === 'GET') {
5326
+ return handleAxDeliveryPending(url);
5327
+ }
5328
+
5329
+ if (url.pathname.startsWith('/api/canvas/ax/delivery/') && url.pathname.endsWith('/mark') && req.method === 'POST') {
5330
+ const deliveryId = decodeURIComponent(
5331
+ url.pathname.slice('/api/canvas/ax/delivery/'.length, -'/mark'.length),
5332
+ );
5333
+ return handleAxDeliveryMark(deliveryId);
5334
+ }
5335
+
5336
+ if (url.pathname === '/api/canvas/ax/elicitation' && req.method === 'GET') {
5337
+ return handleAxElicitationList();
5338
+ }
5339
+
5340
+ if (url.pathname === '/api/canvas/ax/elicitation' && req.method === 'POST') {
5341
+ return handleAxElicitationRequest(req);
5342
+ }
5343
+
5344
+ if (url.pathname.startsWith('/api/canvas/ax/elicitation/') && url.pathname.endsWith('/respond') && req.method === 'POST') {
5345
+ const elicitationId = decodeURIComponent(
5346
+ url.pathname.slice('/api/canvas/ax/elicitation/'.length, -'/respond'.length),
5347
+ );
5348
+ return handleAxElicitationRespond(req, elicitationId);
5349
+ }
5350
+
5351
+ if (url.pathname === '/api/canvas/ax/mode' && req.method === 'GET') {
5352
+ return handleAxModeList();
5353
+ }
5354
+
5355
+ if (url.pathname === '/api/canvas/ax/mode' && req.method === 'POST') {
5356
+ return handleAxModeRequest(req);
5357
+ }
5358
+
5359
+ if (url.pathname.startsWith('/api/canvas/ax/mode/') && url.pathname.endsWith('/resolve') && req.method === 'POST') {
5360
+ const modeId = decodeURIComponent(
5361
+ url.pathname.slice('/api/canvas/ax/mode/'.length, -'/resolve'.length),
5362
+ );
5363
+ return handleAxModeResolve(req, modeId);
5364
+ }
5365
+
5366
+ if (url.pathname === '/api/canvas/ax/command' && req.method === 'GET') {
5367
+ return handleAxCommandList();
5368
+ }
5369
+
5370
+ if (url.pathname === '/api/canvas/ax/command' && req.method === 'POST') {
5371
+ return handleAxCommandInvoke(req);
5372
+ }
5373
+
5374
+ if (url.pathname === '/api/canvas/ax/policy' && req.method === 'GET') {
5375
+ return handleAxPolicyGet();
5376
+ }
5377
+
5378
+ if (url.pathname === '/api/canvas/ax/policy' && req.method === 'POST') {
5379
+ return handleAxPolicySet(req);
5380
+ }
5381
+
5077
5382
  // Spatial context API
5078
5383
  if (url.pathname === '/api/canvas/spatial-context' && req.method === 'GET') {
5079
5384
  const layout = canvasState.getLayout();
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Cross-cutting helpers for node "surfaces" — the standalone documents served at
3
+ * /api/canvas/surface/:nodeId. Shared by the server route, the server-side node
4
+ * serialization, and the client so the openability rule lives in exactly one
5
+ * place. The authoritative dispatch (serve vs redirect) lives in the server's
6
+ * handleNodeSurface; this predicate mirrors which node types it can open.
7
+ */
8
+
9
+ /**
10
+ * Whether a node has an "Open as site" surface. Coarse by design (presence of the
11
+ * minimal field) — the route itself decides what to actually serve.
12
+ */
13
+ export function canOpenNodeAsSurface(type: string, data: Record<string, unknown>): boolean {
14
+ switch (type) {
15
+ case 'html':
16
+ return (typeof data.html === 'string' && data.html.length > 0)
17
+ || (typeof data.content === 'string' && data.content.length > 0);
18
+ case 'json-render':
19
+ case 'graph':
20
+ return true;
21
+ case 'mcp-app':
22
+ return (data.viewerType === 'web-artifact' && typeof data.path === 'string' && data.path.length > 0)
23
+ || (data.mode === 'ext-app' && typeof data.html === 'string' && data.html.length > 0)
24
+ || (typeof data.url === 'string' && data.url.length > 0);
25
+ case 'webpage':
26
+ return typeof data.url === 'string' && data.url.length > 0;
27
+ default:
28
+ return false;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * CSP sandbox tokens for a hosted ext-app surface, used both as the in-canvas
34
+ * iframe `sandbox` attribute and as the `Content-Security-Policy: sandbox` value
35
+ * when the surface route serves an ext-app standalone. All tokens are in the
36
+ * server's safe-sandbox allowlist; notably NOT allow-same-origin.
37
+ */
38
+ export const DEFAULT_EXT_APP_SANDBOX = 'allow-scripts allow-popups allow-popups-to-escape-sandbox';