pilotswarm 0.0.1 → 0.4.1
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/README.md +37 -1
- package/mcp/README.md +484 -0
- package/mcp/dist/bin/pilotswarm-mcp.d.ts +3 -0
- package/mcp/dist/bin/pilotswarm-mcp.d.ts.map +1 -0
- package/mcp/dist/bin/pilotswarm-mcp.js +367 -0
- package/mcp/dist/bin/pilotswarm-mcp.js.map +1 -0
- package/mcp/dist/src/auth.d.ts +7 -0
- package/mcp/dist/src/auth.d.ts.map +1 -0
- package/mcp/dist/src/auth.js +99 -0
- package/mcp/dist/src/auth.js.map +1 -0
- package/mcp/dist/src/context.d.ts +48 -0
- package/mcp/dist/src/context.d.ts.map +1 -0
- package/mcp/dist/src/context.js +83 -0
- package/mcp/dist/src/context.js.map +1 -0
- package/mcp/dist/src/index.d.ts +4 -0
- package/mcp/dist/src/index.d.ts.map +1 -0
- package/mcp/dist/src/index.js +3 -0
- package/mcp/dist/src/index.js.map +1 -0
- package/mcp/dist/src/prompts/skills.d.ts +4 -0
- package/mcp/dist/src/prompts/skills.d.ts.map +1 -0
- package/mcp/dist/src/prompts/skills.js +11 -0
- package/mcp/dist/src/prompts/skills.js.map +1 -0
- package/mcp/dist/src/resources/agents.d.ts +4 -0
- package/mcp/dist/src/resources/agents.d.ts.map +1 -0
- package/mcp/dist/src/resources/agents.js +64 -0
- package/mcp/dist/src/resources/agents.js.map +1 -0
- package/mcp/dist/src/resources/facts.d.ts +4 -0
- package/mcp/dist/src/resources/facts.d.ts.map +1 -0
- package/mcp/dist/src/resources/facts.js +125 -0
- package/mcp/dist/src/resources/facts.js.map +1 -0
- package/mcp/dist/src/resources/models.d.ts +4 -0
- package/mcp/dist/src/resources/models.d.ts.map +1 -0
- package/mcp/dist/src/resources/models.js +43 -0
- package/mcp/dist/src/resources/models.js.map +1 -0
- package/mcp/dist/src/resources/sessions.d.ts +4 -0
- package/mcp/dist/src/resources/sessions.d.ts.map +1 -0
- package/mcp/dist/src/resources/sessions.js +190 -0
- package/mcp/dist/src/resources/sessions.js.map +1 -0
- package/mcp/dist/src/resources/subscriptions.d.ts +9 -0
- package/mcp/dist/src/resources/subscriptions.d.ts.map +1 -0
- package/mcp/dist/src/resources/subscriptions.js +157 -0
- package/mcp/dist/src/resources/subscriptions.js.map +1 -0
- package/mcp/dist/src/server.d.ts +4 -0
- package/mcp/dist/src/server.d.ts.map +1 -0
- package/mcp/dist/src/server.js +59 -0
- package/mcp/dist/src/server.js.map +1 -0
- package/mcp/dist/src/tools/agents.d.ts +4 -0
- package/mcp/dist/src/tools/agents.d.ts.map +1 -0
- package/mcp/dist/src/tools/agents.js +317 -0
- package/mcp/dist/src/tools/agents.js.map +1 -0
- package/mcp/dist/src/tools/facts.d.ts +4 -0
- package/mcp/dist/src/tools/facts.d.ts.map +1 -0
- package/mcp/dist/src/tools/facts.js +151 -0
- package/mcp/dist/src/tools/facts.js.map +1 -0
- package/mcp/dist/src/tools/models.d.ts +4 -0
- package/mcp/dist/src/tools/models.d.ts.map +1 -0
- package/mcp/dist/src/tools/models.js +256 -0
- package/mcp/dist/src/tools/models.js.map +1 -0
- package/mcp/dist/src/tools/sessions.d.ts +4 -0
- package/mcp/dist/src/tools/sessions.d.ts.map +1 -0
- package/mcp/dist/src/tools/sessions.js +606 -0
- package/mcp/dist/src/tools/sessions.js.map +1 -0
- package/mcp/dist/src/util/command.d.ts +52 -0
- package/mcp/dist/src/util/command.d.ts.map +1 -0
- package/mcp/dist/src/util/command.js +78 -0
- package/mcp/dist/src/util/command.js.map +1 -0
- package/package.json +82 -6
- package/tui/README.md +35 -0
- package/tui/bin/tui.js +30 -0
- package/tui/plugins/.mcp.json +7 -0
- package/tui/plugins/plugin.json +13 -0
- package/tui/plugins/session-policy.json +8 -0
- package/tui/src/app.js +850 -0
- package/tui/src/auth/cli.js +111 -0
- package/tui/src/auth/entra-auth.js +218 -0
- package/tui/src/bootstrap-env.js +176 -0
- package/tui/src/embedded-workers.js +79 -0
- package/tui/src/http-transport-host.js +106 -0
- package/tui/src/index.js +340 -0
- package/tui/src/node-sdk-transport.js +1794 -0
- package/tui/src/platform.js +984 -0
- package/tui/src/plugin-config.js +273 -0
- package/tui/src/portal.js +7 -0
- package/tui/src/version.js +7 -0
- package/tui/tui-splash-mobile.txt +7 -0
- package/tui/tui-splash.txt +11 -0
- package/ui/core/README.md +6 -0
- package/ui/core/src/commands.js +93 -0
- package/ui/core/src/context-usage.js +212 -0
- package/ui/core/src/controller.js +6201 -0
- package/ui/core/src/formatting.js +1036 -0
- package/ui/core/src/history.js +950 -0
- package/ui/core/src/index.js +13 -0
- package/ui/core/src/layout.js +332 -0
- package/ui/core/src/reducer.js +1952 -0
- package/ui/core/src/selectors.js +5419 -0
- package/ui/core/src/session-errors.js +14 -0
- package/ui/core/src/session-tree.js +151 -0
- package/ui/core/src/state.js +251 -0
- package/ui/core/src/store.js +23 -0
- package/ui/core/src/system-titles.js +24 -0
- package/ui/core/src/themes/catppuccin-mocha.js +56 -0
- package/ui/core/src/themes/cobalt2.js +56 -0
- package/ui/core/src/themes/dark-high-contrast.js +56 -0
- package/ui/core/src/themes/daylight.js +62 -0
- package/ui/core/src/themes/dracula.js +56 -0
- package/ui/core/src/themes/github-dark.js +56 -0
- package/ui/core/src/themes/github-light.js +59 -0
- package/ui/core/src/themes/gruvbox-dark.js +56 -0
- package/ui/core/src/themes/hacker-x-matrix.js +56 -0
- package/ui/core/src/themes/hacker-x-orion-prime.js +56 -0
- package/ui/core/src/themes/helpers.js +79 -0
- package/ui/core/src/themes/high-contrast-mono.js +59 -0
- package/ui/core/src/themes/index.js +52 -0
- package/ui/core/src/themes/light-high-contrast.js +62 -0
- package/ui/core/src/themes/noctis-obscuro.js +56 -0
- package/ui/core/src/themes/noctis.js +56 -0
- package/ui/core/src/themes/paper-ink.js +62 -0
- package/ui/core/src/themes/solarized-ops.js +59 -0
- package/ui/core/src/themes/terminal-green.js +59 -0
- package/ui/core/src/themes/tokyo-night.js +56 -0
- package/ui/react/README.md +5 -0
- package/ui/react/src/chat-status.js +39 -0
- package/ui/react/src/components.js +1989 -0
- package/ui/react/src/index.js +4 -0
- package/ui/react/src/platform.js +15 -0
- package/ui/react/src/use-controller-state.js +38 -0
- package/ui/react/src/web-app.js +4457 -0
- package/web/README.md +198 -0
- package/web/api/router.js +196 -0
- package/web/api/ws.js +152 -0
- package/web/auth/authz/engine.js +204 -0
- package/web/auth/config.js +115 -0
- package/web/auth/index.js +175 -0
- package/web/auth/normalize/entra.js +22 -0
- package/web/auth/providers/entra.js +76 -0
- package/web/auth/providers/none.js +24 -0
- package/web/auth.js +10 -0
- package/web/bin/serve.js +53 -0
- package/web/config.js +20 -0
- package/web/dist/app.js +469 -0
- package/web/dist/assets/index-DmGOcKR-.css +1 -0
- package/web/dist/assets/index-xJ8IzIZY.js +24 -0
- package/web/dist/assets/msal-CytV9RFv.js +7 -0
- package/web/dist/assets/pilotswarm-D9pEmenA.js +90 -0
- package/web/dist/assets/react-CEPDSRB6.js +1 -0
- package/web/dist/index.html +16 -0
- package/web/runtime.js +455 -0
- package/web/server.js +276 -0
- 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
package/web/bin/serve.js
ADDED
|
@@ -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 });
|