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
@@ -0,0 +1,204 @@
1
+ function normalizeRole(role) {
2
+ const normalized = String(role || "").trim().toLowerCase();
3
+ if (normalized === "admin") return "admin";
4
+ if (normalized === "user") return "user";
5
+ if (normalized === "anonymous") return "anonymous";
6
+ if (normalized === "none") return "none";
7
+ return null;
8
+ }
9
+
10
+ // Match `principalRoles` to an engine role using case-insensitive equality
11
+ // against the two canonical role values `admin` and `user`. See Spec.md
12
+ // FR-001..FR-005.
13
+ //
14
+ // Convention: the PilotSwarm app reg defines exactly two app roles with
15
+ // `value: "admin"` and `value: "user"` (see deploy/scripts/auth/Setup-PortalAuth.ps1
16
+ // `Build-AppRolesJson`). These are the canonical, prescriptive values —
17
+ // the engine matches only these. If you need additional gate-keeping beyond
18
+ // admin/user (e.g. an auditor role), define a new app role and check it
19
+ // explicitly in code against the JWT `roles` claim — do not alias arbitrary
20
+ // role values onto the built-in admin/user buckets here.
21
+ //
22
+ // Order: admin-before-user precedence is preserved — if a principal carries
23
+ // both an admin role and a user role, the engine resolves to `admin`
24
+ // (CodeResearch §8a).
25
+ //
26
+ // Empty / whitespace-only role tokens are filtered out before comparison
27
+ // (mirrors `toStringArray` semantics in `normalize/entra.js`).
28
+ //
29
+ // Returns "admin", "user", or null.
30
+ //
31
+ // Note: `createNoAuthUnknownPrincipal()` produces `roles: ["anonymous"]` but is
32
+ // never reachable here — the no-auth path passes `principal=null` to the engine
33
+ // (CodeResearch §6). This matcher correctly returns null for "anonymous"
34
+ // since it equals neither "admin" nor "user".
35
+ function matchEngineRole(principalRoles) {
36
+ const rawTokens = Array.isArray(principalRoles) ? principalRoles : [];
37
+ const tokens = rawTokens
38
+ .map((t) => (typeof t === "string" ? t.trim().toLowerCase() : ""))
39
+ .filter(Boolean);
40
+ if (tokens.length === 0) return null;
41
+
42
+ // Admin pass first to preserve admin-before-user precedence.
43
+ if (tokens.includes("admin")) return "admin";
44
+ if (tokens.includes("user")) return "user";
45
+ return null;
46
+ }
47
+
48
+ function normalizeIdentifier(value) {
49
+ return String(value || "").trim().toLowerCase();
50
+ }
51
+
52
+ function intersectIdentifier(value, allowed = []) {
53
+ const normalizedValue = normalizeIdentifier(value);
54
+ if (!normalizedValue) return [];
55
+
56
+ const allowedSet = new Set((allowed || []).map(normalizeIdentifier).filter(Boolean));
57
+ return allowedSet.has(normalizedValue) ? [normalizedValue] : [];
58
+ }
59
+
60
+ export function authorizePrincipal(principal, policy = {}) {
61
+ // Secure-by-default: when no PORTAL_AUTHZ_DEFAULT_ROLE is configured,
62
+ // unmatched authenticated principals are denied rather than silently
63
+ // granted `user`. Set `PORTAL_AUTHZ_DEFAULT_ROLE=user` to restore the
64
+ // pre-v0.1.33 "any tenant user gets `user`" behavior.
65
+ const defaultRole = normalizeRole(policy.defaultRole) || "none";
66
+ const adminGroups = Array.isArray(policy.adminGroups) ? policy.adminGroups : [];
67
+ const userGroups = Array.isArray(policy.userGroups) ? policy.userGroups : [];
68
+ const allowUnauthenticated = policy.allowUnauthenticated === true;
69
+
70
+ if (!principal) {
71
+ if (allowUnauthenticated) {
72
+ return {
73
+ allowed: true,
74
+ role: "anonymous",
75
+ reason: "Authentication disabled",
76
+ matchedGroups: [],
77
+ };
78
+ }
79
+ return {
80
+ allowed: false,
81
+ role: null,
82
+ reason: "Authentication required",
83
+ matchedGroups: [],
84
+ };
85
+ }
86
+
87
+ const principalEmail = String(principal.email || "").trim();
88
+ const principalRoles = Array.isArray(principal.roles) ? principal.roles : [];
89
+ const matchedAdminGroups = intersectIdentifier(principalEmail, adminGroups);
90
+ const matchedUserGroups = intersectIdentifier(principalEmail, userGroups);
91
+
92
+ // Role-authoritative branch (Spec.md FR-001..FR-009): when the JWT carries a
93
+ // non-empty `roles[]` claim, decide solely from roles and bypass the email
94
+ // allowlist. Admin-before-user precedence is preserved by `matchEngineRole`.
95
+ const hasRoleTokens = principalRoles.some(
96
+ (t) => typeof t === "string" && t.trim().length > 0,
97
+ );
98
+ if (hasRoleTokens) {
99
+ const matched = matchEngineRole(principalRoles);
100
+ if (matched) {
101
+ return {
102
+ allowed: true,
103
+ role: matched,
104
+ reason: `Matched ${matched} role`,
105
+ matchedGroups: [],
106
+ };
107
+ }
108
+ return {
109
+ allowed: false,
110
+ role: null,
111
+ reason: "Roles present but no admin/user role matched",
112
+ matchedGroups: [],
113
+ };
114
+ }
115
+
116
+ if (adminGroups.length === 0 && userGroups.length === 0) {
117
+ // No email allowlists configured and the principal carries no role tokens
118
+ // (the role-authoritative branch above already handled non-empty roles).
119
+ if (defaultRole === "none") {
120
+ return {
121
+ allowed: false,
122
+ role: null,
123
+ reason: "No email allowlists configured and PORTAL_AUTHZ_DEFAULT_ROLE is not set (deny by default)",
124
+ matchedGroups: [],
125
+ };
126
+ }
127
+ return {
128
+ allowed: true,
129
+ role: defaultRole,
130
+ reason: "No email allowlists configured",
131
+ matchedGroups: [],
132
+ };
133
+ }
134
+
135
+ if (matchedAdminGroups.length > 0) {
136
+ return {
137
+ allowed: true,
138
+ role: "admin",
139
+ reason: "Matched admin email allowlist",
140
+ matchedGroups: matchedAdminGroups,
141
+ };
142
+ }
143
+
144
+ if (matchedUserGroups.length > 0) {
145
+ return {
146
+ allowed: true,
147
+ role: "user",
148
+ reason: "Matched user email allowlist",
149
+ matchedGroups: matchedUserGroups,
150
+ };
151
+ }
152
+
153
+ if (!principalEmail) {
154
+ return {
155
+ allowed: false,
156
+ role: null,
157
+ reason: "Authenticated token did not include a usable email claim",
158
+ matchedGroups: [],
159
+ };
160
+ }
161
+
162
+ return {
163
+ allowed: false,
164
+ role: null,
165
+ reason: "Authenticated principal email is not in an allowed admin/user list",
166
+ matchedGroups: [],
167
+ };
168
+ }
169
+
170
+ export function getPublicAuthContext(authContext) {
171
+ if (!authContext) {
172
+ return {
173
+ principal: null,
174
+ authorization: {
175
+ allowed: false,
176
+ role: null,
177
+ reason: "Unauthenticated",
178
+ matchedGroups: [],
179
+ },
180
+ };
181
+ }
182
+
183
+ const principal = authContext.principal
184
+ ? {
185
+ provider: authContext.principal.provider,
186
+ subject: authContext.principal.subject,
187
+ email: authContext.principal.email ?? null,
188
+ displayName: authContext.principal.displayName ?? null,
189
+ tenantId: authContext.principal.tenantId ?? null,
190
+ groups: [...(authContext.principal.groups || [])],
191
+ roles: [...(authContext.principal.roles || [])],
192
+ }
193
+ : null;
194
+
195
+ return {
196
+ principal,
197
+ authorization: {
198
+ allowed: authContext.authorization?.allowed === true,
199
+ role: authContext.authorization?.role ?? null,
200
+ reason: authContext.authorization?.reason ?? null,
201
+ matchedGroups: [...(authContext.authorization?.matchedGroups || [])],
202
+ },
203
+ };
204
+ }
@@ -0,0 +1,115 @@
1
+ import path from "node:path";
2
+ import { getPluginDirsFromEnv, readPluginMetadata } from "pilotswarm/host";
3
+
4
+ function getObject(value) {
5
+ return value && typeof value === "object" && !Array.isArray(value)
6
+ ? value
7
+ : {};
8
+ }
9
+
10
+ function firstNonEmptyString(...values) {
11
+ for (const value of values) {
12
+ if (typeof value === "string" && value.trim()) {
13
+ return value.trim();
14
+ }
15
+ }
16
+ return null;
17
+ }
18
+
19
+ function parseCsv(value) {
20
+ return String(value || "")
21
+ .split(",")
22
+ .map((entry) => entry.trim())
23
+ .filter(Boolean);
24
+ }
25
+
26
+ function parseBoolean(value, defaultValue = false) {
27
+ if (value == null || value === "") return defaultValue;
28
+ const normalized = String(value).trim().toLowerCase();
29
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
30
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
31
+ return defaultValue;
32
+ }
33
+
34
+ function normalizeRole(value, defaultRole = "none") {
35
+ const normalized = String(value || "").trim().toLowerCase();
36
+ if (normalized === "admin") return "admin";
37
+ if (normalized === "user") return "user";
38
+ if (normalized === "none") return "none";
39
+ return defaultRole;
40
+ }
41
+
42
+ export function resolvePluginAuthConfigFromPluginDirs(pluginDirs = getPluginDirsFromEnv()) {
43
+ for (const pluginDir of pluginDirs) {
44
+ const pluginMeta = readPluginMetadata(path.resolve(pluginDir));
45
+ if (!pluginMeta) continue;
46
+ const portal = getObject(pluginMeta.portal);
47
+ const portalAuth = getObject(portal.auth);
48
+ if (Object.keys(portalAuth).length === 0 && typeof portal.provider !== "string") {
49
+ continue;
50
+ }
51
+ return portalAuth;
52
+ }
53
+ return {};
54
+ }
55
+
56
+ export function inferAuthProviderId(env = process.env) {
57
+ if (
58
+ env.PORTAL_AUTH_ENTRA_TENANT_ID
59
+ || env.PORTAL_AUTH_ENTRA_CLIENT_ID
60
+ ) {
61
+ return "entra";
62
+ }
63
+ return "none";
64
+ }
65
+
66
+ export function resolveAuthProviderId({
67
+ env = process.env,
68
+ pluginAuthConfig = resolvePluginAuthConfigFromPluginDirs(),
69
+ } = {}) {
70
+ const explicitProvider = firstNonEmptyString(env.PORTAL_AUTH_PROVIDER);
71
+ if (explicitProvider) return explicitProvider.toLowerCase();
72
+
73
+ const pluginProvider = firstNonEmptyString(pluginAuthConfig?.provider);
74
+ if (pluginProvider) return pluginProvider.toLowerCase();
75
+
76
+ return inferAuthProviderId(env);
77
+ }
78
+
79
+ function getProviderScopedGroupEnv({ env, providerId, groupKind }) {
80
+ const normalizedProvider = String(providerId || "").trim().toUpperCase();
81
+ const normalizedKind = String(groupKind || "").trim().toUpperCase();
82
+ return firstNonEmptyString(env[`PORTAL_AUTH_${normalizedProvider}_${normalizedKind}_GROUPS`]);
83
+ }
84
+
85
+ export function loadAuthorizationPolicy({
86
+ env = process.env,
87
+ providerId = resolveAuthProviderId({ env }),
88
+ } = {}) {
89
+ // Secure-by-default: deny when PORTAL_AUTHZ_DEFAULT_ROLE is not set.
90
+ // Accepted values: "admin" | "user" | "none". Default: "none" (deny).
91
+ const defaultRole = normalizeRole(env.PORTAL_AUTHZ_DEFAULT_ROLE, "none");
92
+ const adminGroups = parseCsv(
93
+ firstNonEmptyString(
94
+ env.PORTAL_AUTHZ_ADMIN_GROUPS,
95
+ getProviderScopedGroupEnv({ env, providerId, groupKind: "ADMIN" }),
96
+ ),
97
+ );
98
+ const userGroups = parseCsv(
99
+ firstNonEmptyString(
100
+ env.PORTAL_AUTHZ_USER_GROUPS,
101
+ getProviderScopedGroupEnv({ env, providerId, groupKind: "USER" }),
102
+ ),
103
+ );
104
+ const allowUnauthenticated = parseBoolean(
105
+ env.PORTAL_AUTH_ALLOW_UNAUTHENTICATED,
106
+ providerId === "none",
107
+ );
108
+
109
+ return {
110
+ defaultRole,
111
+ adminGroups,
112
+ userGroups,
113
+ allowUnauthenticated,
114
+ };
115
+ }
@@ -0,0 +1,175 @@
1
+ import { createNoAuthProvider } from "./providers/none.js";
2
+ import { createEntraAuthProvider } from "./providers/entra.js";
3
+ import { authorizePrincipal } from "./authz/engine.js";
4
+ import { loadAuthorizationPolicy, resolveAuthProviderId, resolvePluginAuthConfigFromPluginDirs } from "./config.js";
5
+
6
+ const PROVIDERS = {
7
+ none: createNoAuthProvider,
8
+ entra: createEntraAuthProvider,
9
+ };
10
+
11
+ const NO_AUTH_UNKNOWN_PRINCIPAL = Object.freeze({
12
+ provider: "none",
13
+ subject: "unknown",
14
+ email: null,
15
+ displayName: "Unknown User",
16
+ groups: Object.freeze([]),
17
+ roles: Object.freeze(["anonymous"]),
18
+ rawClaims: Object.freeze({}),
19
+ });
20
+
21
+ let cachedBundle = null;
22
+
23
+ export function createNoAuthUnknownPrincipal() {
24
+ return {
25
+ provider: NO_AUTH_UNKNOWN_PRINCIPAL.provider,
26
+ subject: NO_AUTH_UNKNOWN_PRINCIPAL.subject,
27
+ email: NO_AUTH_UNKNOWN_PRINCIPAL.email,
28
+ displayName: NO_AUTH_UNKNOWN_PRINCIPAL.displayName,
29
+ groups: [...NO_AUTH_UNKNOWN_PRINCIPAL.groups],
30
+ roles: [...NO_AUTH_UNKNOWN_PRINCIPAL.roles],
31
+ rawClaims: { ...NO_AUTH_UNKNOWN_PRINCIPAL.rawClaims },
32
+ };
33
+ }
34
+
35
+ function getProviderBundle() {
36
+ if (cachedBundle) return cachedBundle;
37
+
38
+ const pluginAuthConfig = resolvePluginAuthConfigFromPluginDirs();
39
+ const providerId = resolveAuthProviderId({ pluginAuthConfig });
40
+ const factory = PROVIDERS[providerId];
41
+ if (!factory) {
42
+ throw new Error(`Unsupported portal auth provider: ${providerId}`);
43
+ }
44
+
45
+ cachedBundle = {
46
+ providerId,
47
+ pluginAuthConfig,
48
+ provider: factory({ pluginAuthConfig }),
49
+ policy: loadAuthorizationPolicy({ providerId }),
50
+ };
51
+ return cachedBundle;
52
+ }
53
+
54
+ function buildDeniedResult({ status, error, principal = null, authorization = null }) {
55
+ return {
56
+ ok: false,
57
+ status,
58
+ error,
59
+ principal,
60
+ authorization,
61
+ };
62
+ }
63
+
64
+ export function getAuthProvider() {
65
+ return getProviderBundle().provider;
66
+ }
67
+
68
+ export function getAuthorizationPolicy() {
69
+ return getProviderBundle().policy;
70
+ }
71
+
72
+ export function getResolvedAuthProviderId() {
73
+ return getProviderBundle().providerId;
74
+ }
75
+
76
+ export async function getAuthConfig(req) {
77
+ return getAuthProvider().getPublicConfig(req);
78
+ }
79
+
80
+ export async function validateToken(token, req) {
81
+ return getAuthProvider().authenticateRequest(token, req);
82
+ }
83
+
84
+ export async function authenticateToken(token, req) {
85
+ const provider = getAuthProvider();
86
+ const policy = getAuthorizationPolicy();
87
+
88
+ if (provider.enabled) {
89
+ if (!token) {
90
+ return buildDeniedResult({
91
+ status: 401,
92
+ error: "Unauthorized",
93
+ authorization: {
94
+ allowed: false,
95
+ role: null,
96
+ reason: "Authentication required",
97
+ matchedGroups: [],
98
+ },
99
+ });
100
+ }
101
+
102
+ const principal = await validateToken(token, req);
103
+ if (!principal) {
104
+ return buildDeniedResult({
105
+ status: 401,
106
+ error: "Unauthorized",
107
+ authorization: {
108
+ allowed: false,
109
+ role: null,
110
+ reason: "Token validation failed",
111
+ matchedGroups: [],
112
+ },
113
+ });
114
+ }
115
+
116
+ const authorization = authorizePrincipal(principal, policy);
117
+ if (!authorization.allowed) {
118
+ return buildDeniedResult({
119
+ status: 403,
120
+ error: authorization.reason || "Forbidden",
121
+ principal,
122
+ authorization,
123
+ });
124
+ }
125
+
126
+ return {
127
+ ok: true,
128
+ status: 200,
129
+ principal,
130
+ authorization,
131
+ };
132
+ }
133
+
134
+ const authorization = authorizePrincipal(null, policy);
135
+ if (!authorization.allowed) {
136
+ return buildDeniedResult({
137
+ status: 401,
138
+ error: authorization.reason || "Unauthorized",
139
+ authorization,
140
+ });
141
+ }
142
+
143
+ return {
144
+ ok: true,
145
+ status: 200,
146
+ principal: createNoAuthUnknownPrincipal(),
147
+ authorization,
148
+ };
149
+ }
150
+
151
+ export async function authenticateRequest(req) {
152
+ return authenticateToken(extractToken(req), req);
153
+ }
154
+
155
+ /**
156
+ * Extract Bearer token from various sources.
157
+ * Checks: Authorization header, then sec-websocket-protocol header.
158
+ */
159
+ export function extractToken(req) {
160
+ const authHeader = req.headers["authorization"];
161
+ if (authHeader?.startsWith("Bearer ")) {
162
+ return authHeader.slice(7);
163
+ }
164
+
165
+ const protocols = req.headers["sec-websocket-protocol"];
166
+ if (protocols) {
167
+ const parts = protocols.split(",").map((segment) => segment.trim());
168
+ const tokenIndex = parts.indexOf("access_token");
169
+ if (tokenIndex >= 0 && parts[tokenIndex + 1]) {
170
+ return parts[tokenIndex + 1];
171
+ }
172
+ }
173
+
174
+ return null;
175
+ }
@@ -0,0 +1,22 @@
1
+ function toStringArray(value) {
2
+ return Array.isArray(value)
3
+ ? value.map((entry) => String(entry || "").trim()).filter(Boolean)
4
+ : [];
5
+ }
6
+
7
+ export function normalizeEntraPrincipal(payload = {}) {
8
+ const subject = String(payload.oid || payload.sub || "").trim();
9
+ if (!subject) return null;
10
+
11
+ return {
12
+ provider: "entra",
13
+ subject,
14
+ email: String(payload.preferred_username || payload.email || payload.upn || "").trim() || null,
15
+ displayName: String(payload.name || "").trim() || null,
16
+ groups: toStringArray(payload.groups),
17
+ roles: toStringArray(payload.roles),
18
+ tenantId: String(payload.tid || "").trim() || null,
19
+ rawClaims: payload,
20
+ };
21
+ }
22
+
@@ -0,0 +1,76 @@
1
+ import * as jose from "jose";
2
+ import { normalizeEntraPrincipal } from "../normalize/entra.js";
3
+
4
+ const JWKS_CACHE = new Map();
5
+
6
+ function getEntraConfig(pluginAuthConfig = {}) {
7
+ const tenantId = process.env.PORTAL_AUTH_ENTRA_TENANT_ID;
8
+ const clientId = process.env.PORTAL_AUTH_ENTRA_CLIENT_ID;
9
+ const displayName = String(
10
+ pluginAuthConfig?.providers?.entra?.displayName
11
+ || pluginAuthConfig?.displayName
12
+ || "Entra ID",
13
+ ).trim() || "Entra ID";
14
+ if (!tenantId || !clientId) return null;
15
+ return { tenantId, clientId, displayName };
16
+ }
17
+
18
+ async function ensureJwks(tenantId) {
19
+ const cached = JWKS_CACHE.get(tenantId);
20
+ if (cached) return cached;
21
+
22
+ const issuer = `https://login.microsoftonline.com/${tenantId}/v2.0`;
23
+ const jwks = jose.createRemoteJWKSet(new URL(`https://login.microsoftonline.com/${tenantId}/discovery/v2.0/keys`));
24
+ const bundle = { issuer, jwks };
25
+ JWKS_CACHE.set(tenantId, bundle);
26
+ return bundle;
27
+ }
28
+
29
+ async function validateToken(token, config) {
30
+ const { issuer, jwks } = await ensureJwks(config.tenantId);
31
+ const { payload } = await jose.jwtVerify(token, jwks, {
32
+ issuer,
33
+ audience: config.clientId,
34
+ });
35
+ return normalizeEntraPrincipal(payload);
36
+ }
37
+
38
+ export function createEntraAuthProvider({ pluginAuthConfig } = {}) {
39
+ const config = getEntraConfig(pluginAuthConfig);
40
+
41
+ return {
42
+ id: "entra",
43
+ enabled: Boolean(config),
44
+ displayName: config?.displayName || "Entra ID",
45
+ async authenticateRequest(token) {
46
+ if (!config || !token) return null;
47
+ try {
48
+ return await validateToken(token, config);
49
+ } catch (error) {
50
+ console.error("[portal-auth:entra] Token validation failed:", error?.message || String(error));
51
+ return null;
52
+ }
53
+ },
54
+ async getPublicConfig(req) {
55
+ if (!config) {
56
+ return {
57
+ enabled: false,
58
+ provider: "entra",
59
+ displayName: config?.displayName || "Entra ID",
60
+ client: null,
61
+ };
62
+ }
63
+ const host = req?.get?.("x-forwarded-host") || req?.get?.("host") || "";
64
+ return {
65
+ enabled: true,
66
+ provider: "entra",
67
+ displayName: config.displayName,
68
+ client: {
69
+ clientId: config.clientId,
70
+ authority: `https://login.microsoftonline.com/${config.tenantId}`,
71
+ redirectUri: `${req?.protocol || "https"}://${host}`,
72
+ },
73
+ };
74
+ },
75
+ };
76
+ }
@@ -0,0 +1,24 @@
1
+ export function createNoAuthProvider({ pluginAuthConfig } = {}) {
2
+ const displayName = String(
3
+ pluginAuthConfig?.providers?.none?.displayName
4
+ || pluginAuthConfig?.displayName
5
+ || "No auth",
6
+ ).trim() || "No auth";
7
+
8
+ return {
9
+ id: "none",
10
+ enabled: false,
11
+ displayName,
12
+ async authenticateRequest() {
13
+ return null;
14
+ },
15
+ async getPublicConfig() {
16
+ return {
17
+ enabled: false,
18
+ provider: "none",
19
+ displayName,
20
+ client: null,
21
+ };
22
+ },
23
+ };
24
+ }
package/web/auth.js ADDED
@@ -0,0 +1,10 @@
1
+ export {
2
+ authenticateRequest,
3
+ authenticateToken,
4
+ extractToken,
5
+ getAuthConfig,
6
+ getAuthProvider,
7
+ getAuthorizationPolicy,
8
+ getResolvedAuthProviderId,
9
+ validateToken,
10
+ } from "./auth/index.js";
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * pilotswarm-web — Starts the Express + WebSocket server and serves the
5
+ * built React portal. In development, use `npm run dev` (Vite) with the
6
+ * server running separately via `node server.js`.
7
+ *
8
+ * Usage:
9
+ * npx pilotswarm-web --env .env.remote
10
+ * npx pilotswarm-web --port 3001
11
+ * npx pilotswarm-web --plugin ./plugin
12
+ * npx pilotswarm-web --workers 4 # embedded workers
13
+ * npx pilotswarm-web --workers 0 # remote workers (AKS)
14
+ */
15
+
16
+ import { fileURLToPath } from "node:url";
17
+ import path from "node:path";
18
+
19
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
+
21
+ // Load env file if --env flag provided
22
+ const envIdx = process.argv.indexOf("--env");
23
+ if (envIdx !== -1 && process.argv[envIdx + 1]) {
24
+ const envPath = path.resolve(process.argv[envIdx + 1]);
25
+ // Node 24+ supports --env-file natively; for programmatic loading we
26
+ // read the file and set process.env entries manually.
27
+ const { readFileSync } = await import("node:fs");
28
+ for (const line of readFileSync(envPath, "utf-8").split("\n")) {
29
+ const trimmed = line.trim();
30
+ if (!trimmed || trimmed.startsWith("#")) continue;
31
+ const eq = trimmed.indexOf("=");
32
+ if (eq === -1) continue;
33
+ const key = trimmed.slice(0, eq).trim();
34
+ const val = trimmed.slice(eq + 1).trim().replace(/^["']|["']$/g, "");
35
+ if (!process.env[key]) process.env[key] = val;
36
+ }
37
+ }
38
+
39
+ // Dynamically import the server (after env is loaded)
40
+ const { startServer } = await import("../server.js");
41
+
42
+ const portFlag = process.argv.indexOf("--port");
43
+ const port = portFlag !== -1 ? parseInt(process.argv[portFlag + 1], 10) : 3001;
44
+
45
+ const pluginFlag = process.argv.indexOf("--plugin");
46
+ if (pluginFlag !== -1 && process.argv[pluginFlag + 1]) {
47
+ process.env.PLUGIN_DIRS = path.resolve(process.argv[pluginFlag + 1]);
48
+ }
49
+
50
+ const workersFlag = process.argv.indexOf("--workers");
51
+ const workers = workersFlag !== -1 ? parseInt(process.argv[workersFlag + 1], 10) : 4;
52
+
53
+ startServer({ port, workers });