pilotswarm 0.0.1 → 0.4.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.
Files changed (149) hide show
  1. package/README.md +37 -1
  2. package/mcp/README.md +484 -0
  3. package/mcp/dist/bin/pilotswarm-mcp.d.ts +3 -0
  4. package/mcp/dist/bin/pilotswarm-mcp.d.ts.map +1 -0
  5. package/mcp/dist/bin/pilotswarm-mcp.js +367 -0
  6. package/mcp/dist/bin/pilotswarm-mcp.js.map +1 -0
  7. package/mcp/dist/src/auth.d.ts +7 -0
  8. package/mcp/dist/src/auth.d.ts.map +1 -0
  9. package/mcp/dist/src/auth.js +99 -0
  10. package/mcp/dist/src/auth.js.map +1 -0
  11. package/mcp/dist/src/context.d.ts +48 -0
  12. package/mcp/dist/src/context.d.ts.map +1 -0
  13. package/mcp/dist/src/context.js +83 -0
  14. package/mcp/dist/src/context.js.map +1 -0
  15. package/mcp/dist/src/index.d.ts +4 -0
  16. package/mcp/dist/src/index.d.ts.map +1 -0
  17. package/mcp/dist/src/index.js +3 -0
  18. package/mcp/dist/src/index.js.map +1 -0
  19. package/mcp/dist/src/prompts/skills.d.ts +4 -0
  20. package/mcp/dist/src/prompts/skills.d.ts.map +1 -0
  21. package/mcp/dist/src/prompts/skills.js +11 -0
  22. package/mcp/dist/src/prompts/skills.js.map +1 -0
  23. package/mcp/dist/src/resources/agents.d.ts +4 -0
  24. package/mcp/dist/src/resources/agents.d.ts.map +1 -0
  25. package/mcp/dist/src/resources/agents.js +64 -0
  26. package/mcp/dist/src/resources/agents.js.map +1 -0
  27. package/mcp/dist/src/resources/facts.d.ts +4 -0
  28. package/mcp/dist/src/resources/facts.d.ts.map +1 -0
  29. package/mcp/dist/src/resources/facts.js +125 -0
  30. package/mcp/dist/src/resources/facts.js.map +1 -0
  31. package/mcp/dist/src/resources/models.d.ts +4 -0
  32. package/mcp/dist/src/resources/models.d.ts.map +1 -0
  33. package/mcp/dist/src/resources/models.js +43 -0
  34. package/mcp/dist/src/resources/models.js.map +1 -0
  35. package/mcp/dist/src/resources/sessions.d.ts +4 -0
  36. package/mcp/dist/src/resources/sessions.d.ts.map +1 -0
  37. package/mcp/dist/src/resources/sessions.js +190 -0
  38. package/mcp/dist/src/resources/sessions.js.map +1 -0
  39. package/mcp/dist/src/resources/subscriptions.d.ts +9 -0
  40. package/mcp/dist/src/resources/subscriptions.d.ts.map +1 -0
  41. package/mcp/dist/src/resources/subscriptions.js +157 -0
  42. package/mcp/dist/src/resources/subscriptions.js.map +1 -0
  43. package/mcp/dist/src/server.d.ts +4 -0
  44. package/mcp/dist/src/server.d.ts.map +1 -0
  45. package/mcp/dist/src/server.js +59 -0
  46. package/mcp/dist/src/server.js.map +1 -0
  47. package/mcp/dist/src/tools/agents.d.ts +4 -0
  48. package/mcp/dist/src/tools/agents.d.ts.map +1 -0
  49. package/mcp/dist/src/tools/agents.js +317 -0
  50. package/mcp/dist/src/tools/agents.js.map +1 -0
  51. package/mcp/dist/src/tools/facts.d.ts +4 -0
  52. package/mcp/dist/src/tools/facts.d.ts.map +1 -0
  53. package/mcp/dist/src/tools/facts.js +151 -0
  54. package/mcp/dist/src/tools/facts.js.map +1 -0
  55. package/mcp/dist/src/tools/models.d.ts +4 -0
  56. package/mcp/dist/src/tools/models.d.ts.map +1 -0
  57. package/mcp/dist/src/tools/models.js +256 -0
  58. package/mcp/dist/src/tools/models.js.map +1 -0
  59. package/mcp/dist/src/tools/sessions.d.ts +4 -0
  60. package/mcp/dist/src/tools/sessions.d.ts.map +1 -0
  61. package/mcp/dist/src/tools/sessions.js +606 -0
  62. package/mcp/dist/src/tools/sessions.js.map +1 -0
  63. package/mcp/dist/src/util/command.d.ts +52 -0
  64. package/mcp/dist/src/util/command.d.ts.map +1 -0
  65. package/mcp/dist/src/util/command.js +78 -0
  66. package/mcp/dist/src/util/command.js.map +1 -0
  67. package/package.json +81 -6
  68. package/tui/README.md +35 -0
  69. package/tui/bin/tui.js +30 -0
  70. package/tui/plugins/.mcp.json +7 -0
  71. package/tui/plugins/plugin.json +13 -0
  72. package/tui/plugins/session-policy.json +8 -0
  73. package/tui/src/app.js +850 -0
  74. package/tui/src/auth/cli.js +111 -0
  75. package/tui/src/auth/entra-auth.js +218 -0
  76. package/tui/src/bootstrap-env.js +176 -0
  77. package/tui/src/embedded-workers.js +79 -0
  78. package/tui/src/http-transport-host.js +106 -0
  79. package/tui/src/index.js +340 -0
  80. package/tui/src/node-sdk-transport.js +1793 -0
  81. package/tui/src/platform.js +984 -0
  82. package/tui/src/plugin-config.js +239 -0
  83. package/tui/src/portal.js +7 -0
  84. package/tui/src/version.js +7 -0
  85. package/tui/tui-splash.txt +11 -0
  86. package/ui/core/README.md +6 -0
  87. package/ui/core/src/commands.js +93 -0
  88. package/ui/core/src/context-usage.js +212 -0
  89. package/ui/core/src/controller.js +6104 -0
  90. package/ui/core/src/formatting.js +1036 -0
  91. package/ui/core/src/history.js +932 -0
  92. package/ui/core/src/index.js +13 -0
  93. package/ui/core/src/layout.js +332 -0
  94. package/ui/core/src/reducer.js +1935 -0
  95. package/ui/core/src/selectors.js +5409 -0
  96. package/ui/core/src/session-errors.js +14 -0
  97. package/ui/core/src/session-tree.js +151 -0
  98. package/ui/core/src/state.js +248 -0
  99. package/ui/core/src/store.js +23 -0
  100. package/ui/core/src/system-titles.js +24 -0
  101. package/ui/core/src/themes/catppuccin-mocha.js +56 -0
  102. package/ui/core/src/themes/cobalt2.js +56 -0
  103. package/ui/core/src/themes/dark-high-contrast.js +56 -0
  104. package/ui/core/src/themes/daylight.js +62 -0
  105. package/ui/core/src/themes/dracula.js +56 -0
  106. package/ui/core/src/themes/github-dark.js +56 -0
  107. package/ui/core/src/themes/github-light.js +59 -0
  108. package/ui/core/src/themes/gruvbox-dark.js +56 -0
  109. package/ui/core/src/themes/hacker-x-matrix.js +56 -0
  110. package/ui/core/src/themes/hacker-x-orion-prime.js +56 -0
  111. package/ui/core/src/themes/helpers.js +79 -0
  112. package/ui/core/src/themes/high-contrast-mono.js +59 -0
  113. package/ui/core/src/themes/index.js +52 -0
  114. package/ui/core/src/themes/light-high-contrast.js +62 -0
  115. package/ui/core/src/themes/noctis-obscuro.js +56 -0
  116. package/ui/core/src/themes/noctis.js +56 -0
  117. package/ui/core/src/themes/paper-ink.js +62 -0
  118. package/ui/core/src/themes/solarized-ops.js +59 -0
  119. package/ui/core/src/themes/terminal-green.js +59 -0
  120. package/ui/core/src/themes/tokyo-night.js +56 -0
  121. package/ui/react/README.md +5 -0
  122. package/ui/react/src/chat-status.js +39 -0
  123. package/ui/react/src/components.js +1989 -0
  124. package/ui/react/src/index.js +4 -0
  125. package/ui/react/src/platform.js +15 -0
  126. package/ui/react/src/use-controller-state.js +38 -0
  127. package/ui/react/src/web-app.js +4390 -0
  128. package/web/README.md +198 -0
  129. package/web/api/router.js +196 -0
  130. package/web/api/ws.js +152 -0
  131. package/web/auth/authz/engine.js +204 -0
  132. package/web/auth/config.js +115 -0
  133. package/web/auth/index.js +175 -0
  134. package/web/auth/normalize/entra.js +22 -0
  135. package/web/auth/providers/entra.js +76 -0
  136. package/web/auth/providers/none.js +24 -0
  137. package/web/auth.js +10 -0
  138. package/web/bin/serve.js +53 -0
  139. package/web/config.js +20 -0
  140. package/web/dist/app.js +469 -0
  141. package/web/dist/assets/index-CBgQQk-j.css +1 -0
  142. package/web/dist/assets/index-DMefB7Wb.js +24 -0
  143. package/web/dist/assets/msal-CytV9RFv.js +7 -0
  144. package/web/dist/assets/pilotswarm-DyBlqVTY.js +87 -0
  145. package/web/dist/assets/react-BZwsW8pe.js +1 -0
  146. package/web/dist/index.html +16 -0
  147. package/web/runtime.js +454 -0
  148. package/web/server.js +276 -0
  149. package/index.js +0 -1
package/web/README.md ADDED
@@ -0,0 +1,198 @@
1
+ # pilotswarm-web
2
+
3
+ Web portal for PilotSwarm — browser-based durable agent orchestration UI.
4
+
5
+ Full feature parity with the TUI: session management, real-time chat, agent
6
+ splash screens (ASCII art), sequence diagrams, node maps, worker logs,
7
+ binary-safe artifact downloads, metadata-aware browser previews, and keyboard shortcuts.
8
+
9
+ The portal server also hosts the versioned [PilotSwarm Web API](../../docs/api/reference.md)
10
+ (`/api/v1` HTTP + `/api/v1/ws` WebSocket) — see [Web API](#web-api) below.
11
+
12
+ ## Quick Start
13
+
14
+ ```bash
15
+ # Install
16
+ npm install pilotswarm-web
17
+
18
+ # Run (starts server + serves React app)
19
+ npx pilotswarm-web --env .env.remote
20
+ npx pilotswarm-web --env .env.remote --plugin ./plugin
21
+
22
+ # Development (Vite HMR)
23
+ cd packages/app/web
24
+ npm run dev # React app at http://localhost:5173
25
+ node server.js # API server at http://localhost:3001
26
+ ```
27
+
28
+ ## Portal Customization
29
+
30
+ The web portal reads app-facing customization from `plugin.json` in your app
31
+ plugin directory. Pass the plugin path with `--plugin` or set `PLUGIN_DIRS`
32
+ so the portal process can see the same metadata the TUI and worker use.
33
+
34
+ Supported keys:
35
+
36
+ ```json
37
+ {
38
+ "tui": {
39
+ "title": "DevOps Command Center",
40
+ "splashFile": "./tui-splash.txt"
41
+ },
42
+ "portal": {
43
+ "branding": {
44
+ "title": "DevOps Command Center",
45
+ "pageTitle": "DevOps Command Center Portal",
46
+ "splashFile": "./tui-splash.txt",
47
+ "logoFile": "./assets/logo.svg",
48
+ "faviconFile": "./assets/favicon.png"
49
+ },
50
+ "ui": {
51
+ "loadingMessage": "Preparing the DevOps workspace",
52
+ "loadingCopy": "Connecting dashboards, session feeds, and orchestration state..."
53
+ },
54
+ "auth": {
55
+ "provider": "entra",
56
+ "signInTitle": "Sign in to DevOps Command Center",
57
+ "signInMessage": "Use your organization's identity provider to open the shared operations workspace.",
58
+ "signInLabel": "Sign In"
59
+ }
60
+ }
61
+ }
62
+ ```
63
+
64
+ Notes:
65
+
66
+ - Preferred schema is nested: `portal.branding`, `portal.ui`, and `portal.auth`.
67
+ - Flat legacy keys such as `portal.title` and `portal.loadingMessage` are still accepted for backwards compatibility.
68
+ - `portal.auth.provider` selects the active auth provider when the deployment does not override it with `PORTAL_AUTH_PROVIDER`.
69
+ - `branding.logoFile` is used on the loading splash, sign-in card, and signed-in header.
70
+ - If `branding.faviconFile` is omitted, the browser tab icon reuses `branding.logoFile`.
71
+ - Keep logo assets inside the plugin directory so the portal image can package and serve them alongside `plugin.json`.
72
+
73
+ Fallback order:
74
+
75
+ - `portal.branding.*` / `portal.ui.*` / `portal.auth.*`
76
+ - flat `portal.*`
77
+ - `tui.title` / `tui.splash` / `tui.splashFile`
78
+ - built-in `PilotSwarm` defaults
79
+
80
+ Named-agent creation in the portal comes from the same plugin metadata surface.
81
+ If the portal process cannot see your plugin directory, the web UI falls back
82
+ to generic sessions even when the worker supports named agents.
83
+
84
+ Artifact behavior:
85
+
86
+ - text artifacts still preview inline in the browser workspace
87
+ - binary artifacts render as a download-only card in the browser workspace
88
+ - downloads preserve the stored content type and raw bytes
89
+
90
+ ## Auth Add-Ons
91
+
92
+ Portal authentication is provider-based.
93
+
94
+ - Default: `none`
95
+ - Built-in optional provider: `entra`
96
+ - AuthZ is common across providers and currently supports Phase 1 group-based admission control
97
+
98
+ Enable Entra ID with env vars:
99
+
100
+ ```bash
101
+ PORTAL_AUTH_PROVIDER=entra
102
+ PORTAL_AUTH_ENTRA_TENANT_ID=<tenant-id>
103
+ PORTAL_AUTH_ENTRA_CLIENT_ID=<client-id>
104
+ PORTAL_AUTHZ_ADMIN_GROUPS=admin1@contoso.com,admin2@contoso.com
105
+ PORTAL_AUTHZ_USER_GROUPS=user1@contoso.com,user2@contoso.com
106
+ ```
107
+
108
+ Notes:
109
+
110
+ - `PORTAL_AUTHZ_ADMIN_GROUPS` and `PORTAL_AUTHZ_USER_GROUPS` are currently comma-delimited email allowlists despite the historical variable names.
111
+ - `PORTAL_AUTHZ_DEFAULT_ROLE` defaults to `none` (deny-by-default since v0.1.33). If no admin/user groups are configured and the JWT carries no role claim, a signed-in user is **denied** at the portal layer. Set `PORTAL_AUTHZ_DEFAULT_ROLE=user` to restore the legacy "any tenant user gets `user`" open posture (sandbox stamps only).
112
+ - `admin` and `user` have the same portal permissions today; the allowlists act as an admission gate and role assignment surface.
113
+
114
+ ### App Roles (Recommended For IT-Managed Tenants)
115
+
116
+ When the Entra app registration defines `admin` and `user` app roles
117
+ and assigns them, the portal decides admission from the JWT `roles`
118
+ claim — the role assignment in Entra **is** the allowlist for this
119
+ posture. The portal matches the claim by case-insensitive equality
120
+ against the canonical values `admin` and `user`; there is no override
121
+ env var. With deny-by-default (the default since v0.1.33), unassigned
122
+ signed-in users are denied at the portal layer.
123
+
124
+ The email-allowlist path (`PORTAL_AUTHZ_ADMIN_GROUPS` /
125
+ `PORTAL_AUTHZ_USER_GROUPS`) is bypassed entirely when the JWT carries
126
+ a `roles[]` claim — use it only for stamps that do not use
127
+ `-CreateAppRoles`. Flipping `appRoleAssignmentRequired=true` on the
128
+ Enterprise Application adds an Entra-side gate as well; it is optional
129
+ and carries a restricted-tenant caveat (AADSTS90094 admin-consent
130
+ prompts). See
131
+ [`../../docs/portal-entra-app-roles.md`](../../docs/developer/deploy/entra-app-roles.md)
132
+ for the full operator runbook.
133
+
134
+ The portal core no longer assumes Entra specifically. New providers can plug
135
+ into the same browser/server provider interfaces, while sharing the same common
136
+ authz layer.
137
+
138
+ ## Web API
139
+
140
+ The portal server also hosts the versioned PilotSwarm Web API — same process,
141
+ same port (3001):
142
+
143
+ - **HTTP `/api/v1`** — routes generated from the `pilotswarm-sdk/api`
144
+ operations table (`packages/sdk/api/src/protocol.js`, the contract source
145
+ of truth) and dispatched through the existing runtime dispatcher — the same
146
+ one the legacy `/api/rpc` uses, so the two surfaces cannot drift.
147
+ - **WebSocket `/api/v1/ws`** — session event and log streaming.
148
+ - **Auth** — reuses the portal's provider-based auth; the same `PORTAL_AUTH_*`
149
+ and `PORTAL_AUTHZ_*` env vars apply, no separate configuration.
150
+ - Errors use the `{ ok: false, error: { code, message } }` envelope.
151
+
152
+ See [`docs/api/reference.md`](../../docs/api/reference.md) for the full API
153
+ reference.
154
+
155
+ The legacy `/api/rpc` and `/portal-ws` endpoints remain mounted for a
156
+ deprecation window; new integrations must use `/api/v1`.
157
+
158
+ ## Architecture
159
+
160
+ ```
161
+ Browser (React + Vite)
162
+
163
+ └── BrowserPortalTransport (extends HttpApiTransport)
164
+
165
+ ├── HTTP ──────► /api/v1 ─┐
166
+ │ ├─ Portal Server (Express + ws)
167
+ └── WebSocket ─► /api/v1/ws ─┘
168
+
169
+ ├── PilotSwarmClient
170
+ ├── PilotSwarmManagementClient
171
+ └── PilotSwarmWorker (embedded or remote)
172
+ ```
173
+
174
+ The browser UI talks `/api/v1` itself via `BrowserPortalTransport`, which
175
+ extends `HttpApiTransport` from `pilotswarm-sdk/api` — the same client
176
+ surface external integrations use.
177
+
178
+ Same public API boundary as the TUI — only `PilotSwarmClient`,
179
+ `PilotSwarmManagementClient`, and `PilotSwarmWorker` APIs. No internal
180
+ module imports.
181
+
182
+ ## Package Relationship
183
+
184
+ ```
185
+ pilotswarm (the app package; this dir is its web/ tree)
186
+ ├── pilotswarm/host (shared node/runtime host glue, tui/src/portal.js)
187
+ │ └── pilotswarm-sdk
188
+ ├── pilotswarm/ui-core, pilotswarm/ui-react (internal UI layers)
189
+ ├── express
190
+ ├── ws
191
+ ├── react, react-dom
192
+ └── vite (devDependency)
193
+ ```
194
+
195
+ `pilotswarm-web` now consumes a small supported portal-facing surface from
196
+ `pilotswarm-cli` rather than importing monorepo-relative source files. That
197
+ keeps the publishable package graph explicit and lets the portal reuse the same
198
+ Node transport and plugin-config behavior as the TUI.
@@ -0,0 +1,196 @@
1
+ import express from "express";
2
+ import path from "node:path";
3
+ import { API_VERSION, OPERATIONS, coerceQueryValue } from "pilotswarm-sdk/api";
4
+ import { getAuthConfig } from "../auth.js";
5
+ import { getPublicAuthContext } from "../auth/authz/engine.js";
6
+
7
+ /**
8
+ * The versioned Web API router (`/api/v1`).
9
+ *
10
+ * Every JSON operation is generated from the pilotswarm-sdk/api
11
+ * operations table and delegates to `runtime.call(name, params, req.auth)` —
12
+ * the same dispatcher the legacy `/api/rpc` uses, so the two surfaces cannot
13
+ * drift. Only health/auth/bootstrap and the binary artifact download are
14
+ * hand-written routes.
15
+ *
16
+ * Error envelope: `{ ok: false, error: { code, message } }`.
17
+ */
18
+
19
+ const ERROR_STATUS_BY_CODE = {
20
+ INVALID_REQUEST: 400,
21
+ PORTAL_AUTH_REQUIRED: 401,
22
+ UNAUTHORIZED: 401,
23
+ FORBIDDEN: 403,
24
+ NOT_FOUND: 404,
25
+ // Capability mismatches: the deployment's store doesn't support the op.
26
+ FACTS_ENHANCED_UNSUPPORTED: 409,
27
+ GRAPH_UNSUPPORTED: 409,
28
+ PAYLOAD_TOO_LARGE: 413,
29
+ };
30
+
31
+ // Domain/lifecycle errors the runtime throws with actionable, non-sensitive
32
+ // messages. These are client errors (their message is the whole value), so
33
+ // they keep their text; everything else that reaches 500 is treated as an
34
+ // unexpected fault and gets a generic message.
35
+ const CLIENT_ERROR_MESSAGE = /must be|is required|cannot be|Unsupported|is not started|terminal orchestration|cannot accept new messages|not found|already|Unknown model|does not support reasoning effort/i;
36
+
37
+ function statusForError(error) {
38
+ const byCode = ERROR_STATUS_BY_CODE[error?.code];
39
+ if (byCode) return byCode;
40
+ if (Number.isInteger(error?.status) && error.status >= 400 && error.status <= 599) return error.status;
41
+ const message = String(error?.message || "");
42
+ if (/is not started|terminal orchestration|cannot accept new messages|already/i.test(message)) return 409;
43
+ if (CLIENT_ERROR_MESSAGE.test(message)) return 400;
44
+ return 500;
45
+ }
46
+
47
+ function sendError(res, error, fallbackStatus) {
48
+ const status = fallbackStatus || statusForError(error);
49
+ const code = error?.code || (status === 404 ? "NOT_FOUND" : status === 400 ? "INVALID_REQUEST" : status === 413 ? "PAYLOAD_TOO_LARGE" : "INTERNAL_ERROR");
50
+ // 500s are unexpected server faults; their raw messages can leak
51
+ // connection strings, file paths, or stack detail. Send a generic
52
+ // message and keep the code for correlation. Client-facing errors
53
+ // (4xx: validation, not-found, auth, lifecycle conflicts) keep their
54
+ // message because it is actionable and non-sensitive.
55
+ const message = status >= 500 ? "Internal server error" : (error?.message || String(error));
56
+ res.status(status).json({ ok: false, error: { code, message } });
57
+ }
58
+
59
+ // Session/child id path params must look like ids — never a path fragment.
60
+ // This is the trust boundary for the filesystem artifact store, which builds
61
+ // paths from these values; anything with a separator or ".." is rejected here
62
+ // before it can reach a sink. (Group ids are validated the same way.)
63
+ const ID_PARAM_KEYS = new Set(["sessionId", "parentSessionId", "childSessionId", "agentIdOrSessionId", "groupId"]);
64
+ const SAFE_ID = /^[\w:.-]{1,200}$/;
65
+
66
+ function assertSafeIdParams(op, params) {
67
+ for (const key of Object.keys(op.params || {})) {
68
+ if (!ID_PARAM_KEYS.has(key)) continue;
69
+ const value = params[key];
70
+ if (value == null) continue;
71
+ if (!SAFE_ID.test(String(value)) || String(value).includes("..")) {
72
+ throw Object.assign(new Error(`Invalid ${key}`), { code: "INVALID_REQUEST" });
73
+ }
74
+ }
75
+ }
76
+
77
+ // A caller is privileged (admin) when the validated token carries the admin
78
+ // role, OR the deployment is no-auth (role "anonymous" — no-auth means a
79
+ // trusted/private deployment with full access, consistent with the rest of
80
+ // the API's binary admission model).
81
+ function isAdminAuth(auth) {
82
+ const role = auth?.authorization?.role;
83
+ return role === "admin" || role === "anonymous";
84
+ }
85
+
86
+ function collectParams(op, req) {
87
+ const params = {};
88
+ for (const [key, spec] of Object.entries(op.params || {})) {
89
+ if (spec.in === "path") {
90
+ params[key] = req.params[spec.name || key];
91
+ } else if (spec.in === "query") {
92
+ const value = coerceQueryValue(req.query[key], spec.type);
93
+ if (value !== undefined) params[key] = value;
94
+ } else if (req.body && Object.prototype.hasOwnProperty.call(req.body, key)) {
95
+ params[key] = req.body[key];
96
+ }
97
+ }
98
+ return params;
99
+ }
100
+
101
+ export function createApiRouter({ runtime, requireAuth }) {
102
+ const router = express.Router();
103
+
104
+ // ── Public routes ───────────────────────────────────────────────────
105
+ router.get("/health", (_req, res) => {
106
+ res.json({ ok: true, started: runtime.started, mode: runtime.mode, apiVersion: API_VERSION });
107
+ });
108
+
109
+ router.get("/auth/config", async (req, res) => {
110
+ try {
111
+ res.json(await getAuthConfig(req));
112
+ } catch (error) {
113
+ sendError(res, error);
114
+ }
115
+ });
116
+
117
+ // ── Authenticated routes ────────────────────────────────────────────
118
+ router.use(requireAuth);
119
+
120
+ router.get("/auth/me", (req, res) => {
121
+ res.json({ ok: true, ...getPublicAuthContext(req.auth) });
122
+ });
123
+
124
+ router.get("/bootstrap", async (req, res) => {
125
+ try {
126
+ const bootstrap = await runtime.getBootstrap();
127
+ res.json({ ok: true, ...bootstrap, apiVersion: API_VERSION, auth: getPublicAuthContext(req.auth) });
128
+ } catch (error) {
129
+ sendError(res, error);
130
+ }
131
+ });
132
+
133
+ // Binary artifact download (streams; not part of the JSON envelope).
134
+ router.get("/sessions/:sessionId/artifacts/:filename/download", async (req, res) => {
135
+ if (!SAFE_ID.test(String(req.params.sessionId)) || String(req.params.sessionId).includes("..")) {
136
+ sendError(res, Object.assign(new Error("Invalid sessionId"), { code: "INVALID_REQUEST" }), 400);
137
+ return;
138
+ }
139
+ try {
140
+ const artifact = await runtime.downloadArtifactBinary(req.params.sessionId, req.params.filename);
141
+ res.setHeader("content-type", String(artifact?.contentType || "application/octet-stream"));
142
+ res.setHeader("content-disposition", `attachment; filename="${path.basename(req.params.filename)}"`);
143
+ res.send(artifact.body);
144
+ } catch (error) {
145
+ sendError(res, error, 404);
146
+ }
147
+ });
148
+
149
+ // ── Generated operation routes ──────────────────────────────────────
150
+ for (const op of OPERATIONS) {
151
+ const expressPath = op.path.replace(/:([\w]+)/g, ":$1");
152
+ router[op.method.toLowerCase()](expressPath, async (req, res) => {
153
+ try {
154
+ // Tier-2 operational ops require the admin role. This is the
155
+ // API's only per-route role check; every other op shares the
156
+ // binary admission gate (requireAuth above).
157
+ if (op.admin && !isAdminAuth(req.auth)) {
158
+ sendError(res, Object.assign(new Error("This operation requires the admin role."), { code: "FORBIDDEN" }), 403);
159
+ return;
160
+ }
161
+ const params = collectParams(op, req);
162
+ assertSafeIdParams(op, params);
163
+ const result = await runtime.call(op.name, params, req.auth);
164
+ res.json({ ok: true, result: result === undefined ? null : result });
165
+ } catch (error) {
166
+ sendError(res, error);
167
+ }
168
+ });
169
+ }
170
+
171
+ // ── Envelope 404 for unknown API paths ──────────────────────────────
172
+ router.use((req, res) => {
173
+ res.status(404).json({
174
+ ok: false,
175
+ error: { code: "NOT_FOUND", message: `Unknown API route: ${req.method} ${req.baseUrl}${req.path}` },
176
+ });
177
+ });
178
+
179
+ // ── Error middleware: body-parser failures (oversized/malformed JSON)
180
+ // reach here as thrown errors rather than route rejections; map them to
181
+ // the same envelope instead of Express's default HTML error page.
182
+ router.use((error, _req, res, _next) => {
183
+ if (res.headersSent) return;
184
+ if (error?.type === "entity.too.large") {
185
+ sendError(res, Object.assign(new Error("Request body too large"), { code: "PAYLOAD_TOO_LARGE" }), 413);
186
+ return;
187
+ }
188
+ if (error?.type === "entity.parse.failed" || error instanceof SyntaxError) {
189
+ sendError(res, Object.assign(new Error("Malformed JSON body"), { code: "INVALID_REQUEST" }), 400);
190
+ return;
191
+ }
192
+ sendError(res, error);
193
+ });
194
+
195
+ return router;
196
+ }
package/web/api/ws.js ADDED
@@ -0,0 +1,152 @@
1
+ import { WebSocketServer } from "ws";
2
+ import { authenticateToken, extractToken } from "../auth.js";
3
+
4
+ /**
5
+ * The Web API streaming endpoint: session events and the live log tail.
6
+ *
7
+ * One connection handler serves both `/api/v1/ws` (the product API) and the
8
+ * legacy `/portal-ws` (which additionally answers the portal-only `theme`
9
+ * message). Vocabulary:
10
+ * client -> server: subscribeSession | unsubscribeSession | subscribeLogs | unsubscribeLogs
11
+ * server -> client: ready | subscribedSession | sessionEvent | subscribedLogs | logEntry | error
12
+ *
13
+ * Delivery here is an acceleration path — correctness comes from event
14
+ * replay via GET /api/v1/management/sessions/:id/events?afterSeq=… after a
15
+ * reconnect.
16
+ */
17
+
18
+ function isSafeThemeId(value) {
19
+ return /^[\w-]+$/u.test(String(value || ""));
20
+ }
21
+
22
+ export function createConnectionHandler(runtime, { allowThemeMessages = false } = {}) {
23
+ return async function handleConnection(ws, req) {
24
+ const auth = await authenticateToken(extractToken(req), req);
25
+ if (!auth.ok) {
26
+ ws.close(auth.status === 403 ? 4403 : 4401, auth.error || (auth.status === 403 ? "Forbidden" : "Unauthorized"));
27
+ return;
28
+ }
29
+
30
+ const sessionSubscriptions = new Map();
31
+ let logUnsubscribe = null;
32
+
33
+ const send = (message) => {
34
+ if (ws.readyState === ws.OPEN) {
35
+ ws.send(JSON.stringify(message));
36
+ }
37
+ };
38
+
39
+ send({ type: "ready" });
40
+
41
+ ws.on("message", async (raw) => {
42
+ let message;
43
+ try {
44
+ message = JSON.parse(String(raw));
45
+ } catch {
46
+ return;
47
+ }
48
+
49
+ const type = String(message?.type || "");
50
+ if (type === "subscribeSession") {
51
+ const sessionId = String(message?.sessionId || "").trim();
52
+ if (!sessionId || sessionSubscriptions.has(sessionId)) return;
53
+ try {
54
+ await runtime.start();
55
+ const unsubscribe = runtime.subscribeSession(sessionId, (event) => {
56
+ send({ type: "sessionEvent", sessionId, event });
57
+ });
58
+ sessionSubscriptions.set(sessionId, unsubscribe);
59
+ send({ type: "subscribedSession", sessionId });
60
+ } catch (error) {
61
+ send({ type: "error", scope: "session", sessionId, error: error?.message || String(error) });
62
+ }
63
+ return;
64
+ }
65
+
66
+ if (type === "unsubscribeSession") {
67
+ const sessionId = String(message?.sessionId || "").trim();
68
+ const unsubscribe = sessionSubscriptions.get(sessionId);
69
+ if (unsubscribe) {
70
+ unsubscribe();
71
+ sessionSubscriptions.delete(sessionId);
72
+ }
73
+ return;
74
+ }
75
+
76
+ if (type === "subscribeLogs") {
77
+ if (logUnsubscribe) return;
78
+ try {
79
+ await runtime.start();
80
+ logUnsubscribe = runtime.startLogTail((entry) => {
81
+ send({ type: "logEntry", entry });
82
+ });
83
+ send({ type: "subscribedLogs" });
84
+ } catch (error) {
85
+ send({ type: "error", scope: "logs", error: error?.message || String(error) });
86
+ }
87
+ return;
88
+ }
89
+
90
+ if (type === "unsubscribeLogs") {
91
+ if (logUnsubscribe) {
92
+ logUnsubscribe();
93
+ logUnsubscribe = null;
94
+ }
95
+ return;
96
+ }
97
+
98
+ if (allowThemeMessages && type === "theme" && isSafeThemeId(message?.themeId)) {
99
+ send({ type: "themeAck", themeId: message.themeId });
100
+ }
101
+ });
102
+
103
+ ws.on("close", () => {
104
+ for (const unsubscribe of sessionSubscriptions.values()) {
105
+ try {
106
+ unsubscribe();
107
+ } catch {}
108
+ }
109
+ sessionSubscriptions.clear();
110
+ if (logUnsubscribe) {
111
+ try {
112
+ logUnsubscribe();
113
+ } catch {}
114
+ logUnsubscribe = null;
115
+ }
116
+ });
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Mount WebSocket endpoints on the shared HTTP server.
122
+ *
123
+ * Multiple path-bound WebSocketServers cannot share one HTTP server (each
124
+ * competes for the 'upgrade' event), so this routes upgrades manually with
125
+ * noServer-mode servers — one per endpoint.
126
+ *
127
+ * @param {import("node:http").Server} server
128
+ * @param {object} runtime
129
+ * @param {Array<{ path: string, allowThemeMessages?: boolean }>} endpoints
130
+ * @returns {WebSocketServer[]}
131
+ */
132
+ export function attachWebSockets(server, runtime, endpoints) {
133
+ const byPath = new Map(endpoints.map(({ path, allowThemeMessages = false }) => {
134
+ const wss = new WebSocketServer({ noServer: true });
135
+ wss.on("connection", createConnectionHandler(runtime, { allowThemeMessages }));
136
+ return [path, wss];
137
+ }));
138
+
139
+ server.on("upgrade", (req, socket, head) => {
140
+ const { pathname } = new URL(req.url || "/", "http://localhost");
141
+ const wss = byPath.get(pathname);
142
+ if (!wss) {
143
+ socket.destroy();
144
+ return;
145
+ }
146
+ wss.handleUpgrade(req, socket, head, (ws) => {
147
+ wss.emit("connection", ws, req);
148
+ });
149
+ });
150
+
151
+ return [...byPath.values()];
152
+ }