@camstack/server 1.1.42 → 1.1.44
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/dist/api/addon-upload.js +35 -30
- package/dist/api/core/auth.router.js +285 -38
- package/dist/api/core/cap-providers.js +25 -3
- package/dist/api/core/server-management.provider.js +14 -0
- package/dist/api/health/health.routes.js +5 -0
- package/dist/api/static/spa-static.js +69 -0
- package/dist/api/trpc/generated-cap-mounts.js +20 -1
- package/dist/api/trpc/generated-cap-routers.js +791 -748
- package/dist/api/trpc/trpc.router.js +5 -2
- package/dist/core/addon/addon-package.service.js +16 -19
- package/dist/core/addon/package-dir-utils.js +140 -0
- package/dist/core/moleculer/cap-call-fn.js +1 -0
- package/dist/core/moleculer/moleculer.service.js +39 -25
- package/dist/core/server-update/server-update.service.js +81 -0
- package/dist/core/topology/topology-emitter.service.js +6 -2
- package/dist/main.js +126 -13
- package/dist/manual-boot.js +11 -1
- package/dist/server-root/index.js +1124 -0
- package/dist/starter.js +81 -0
- package/package.json +9 -4
- package/dist/core/addon/framework-live-sync.js +0 -344
package/dist/api/addon-upload.js
CHANGED
|
@@ -43,7 +43,7 @@ const os = __importStar(require("node:os"));
|
|
|
43
43
|
const node_child_process_1 = require("node:child_process");
|
|
44
44
|
const scope_access_js_1 = require("./trpc/scope-access.js");
|
|
45
45
|
const addon_package_service_js_1 = require("../core/addon/addon-package.service.js");
|
|
46
|
-
const
|
|
46
|
+
const index_js_1 = require("../server-root/index.js");
|
|
47
47
|
const deploy_stage_registry_js_1 = require("./deploy-stage-registry.js");
|
|
48
48
|
const deployStageRegistry = new deploy_stage_registry_js_1.DeployStageRegistry();
|
|
49
49
|
function getDeployStageRegistry() {
|
|
@@ -406,38 +406,43 @@ async function installToHub(reply, addonBridge, addonRegistry, addonPackageServi
|
|
|
406
406
|
// and re-runs boot initialization. The 10s restart grace lets this response
|
|
407
407
|
// flush before the process exits, so the CLI sees a clean confirmation.
|
|
408
408
|
if ((0, addon_package_service_js_1.isFrameworkPackage)(result.name)) {
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
409
|
+
// Framework live-sync is GONE (superseded by the runtime-updatable root
|
|
410
|
+
// package — the hub main + its framework deps now update as ONE
|
|
411
|
+
// `@camstack/server` closure via the `server-management` cap).
|
|
412
|
+
//
|
|
413
|
+
// Dev loop compatibility: in a workspace checkout the hub resolves the
|
|
414
|
+
// framework straight from `packages/<pkg>/dist`, which the CLI rebuilt
|
|
415
|
+
// BEFORE uploading — a plain restart loads the new code, no mirror
|
|
416
|
+
// needed. `camstack deploy packages/system` therefore keeps working in
|
|
417
|
+
// dev unchanged.
|
|
418
|
+
const workspaceRoot = (0, index_js_1.detectWorkspaceRoot)(__dirname);
|
|
419
|
+
if (workspaceRoot !== null) {
|
|
420
|
+
logger.info('framework package deployed (workspace) — scheduling server restart', {
|
|
421
|
+
meta: { packageName: result.name, packageVersion: result.version, workspaceRoot },
|
|
422
|
+
});
|
|
423
|
+
addonPackageService.restartServer(`addon-upload: ${result.name}@${result.version}`);
|
|
424
|
+
return reply.send({
|
|
425
|
+
success: true,
|
|
426
|
+
name: result.name,
|
|
427
|
+
version: result.version,
|
|
428
|
+
requiresRestart: true,
|
|
429
|
+
restarting: true,
|
|
430
|
+
message: 'Framework package installed — server is restarting to load it from the workspace',
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
// Production (baked/data-root): the upload landed in the addons dir — a
|
|
434
|
+
// location the hub main never loads the framework from. Direct the
|
|
435
|
+
// operator to the supported path instead of pretending it went live.
|
|
436
|
+
logger.warn('framework package upload REJECTED for live update (non-workspace hub)', {
|
|
437
|
+
meta: { packageName: result.name, packageVersion: result.version },
|
|
429
438
|
});
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
success: true,
|
|
439
|
+
return reply.status(409).send({
|
|
440
|
+
success: false,
|
|
433
441
|
name: result.name,
|
|
434
442
|
version: result.version,
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
message: syncFailed.length > 0
|
|
439
|
-
? 'Framework package installed but one or more live locations could not be updated — restarting anyway'
|
|
440
|
-
: 'Framework package installed — server is restarting to load it',
|
|
443
|
+
error: 'Framework packages no longer live-update via upload on a deployed hub. ' +
|
|
444
|
+
'Update the @camstack/server root package instead (Server management → Update, ' +
|
|
445
|
+
'or the server-management.applyServerUpdate cap method).',
|
|
441
446
|
});
|
|
442
447
|
}
|
|
443
448
|
// `addonRegistry.loadNewAddons()` already runs its own fresh filesystem
|
|
@@ -10,20 +10,37 @@ exports.createAuthRouter = createAuthRouter;
|
|
|
10
10
|
* JWT signing stays in the server — it's a transport-level concern
|
|
11
11
|
* (the server owns the secret and the HTTP session).
|
|
12
12
|
*
|
|
13
|
-
*
|
|
13
|
+
* Pre-auth login surfaces (OIDC / magic-link redirect buttons + the
|
|
14
|
+
* passkey second-factor widget) are aggregated for the login page by the
|
|
15
|
+
* public `listLoginMethods` procedure from the `login-method` cap
|
|
16
|
+
* collection — the single, generic mechanism every auth addon contributes
|
|
17
|
+
* to (superseding the removed `auth.listProviders`).
|
|
14
18
|
*/
|
|
15
19
|
const zod_1 = require("zod");
|
|
20
|
+
const types_1 = require("@camstack/types");
|
|
16
21
|
const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
|
|
17
22
|
/**
|
|
18
|
-
*
|
|
23
|
+
* The available second-factor kinds a user may satisfy after the
|
|
24
|
+
* password leg. Capability-driven — `totp` maps to the
|
|
25
|
+
* `user-management` cap's TOTP surface, `passkey` to ANY provider of
|
|
26
|
+
* the `user-passkeys` cap. The client renders a chooser from this list
|
|
27
|
+
* so a future alternate 2FA cap works with zero login-flow changes.
|
|
28
|
+
*/
|
|
29
|
+
const SecondFactorSchema = zod_1.z.enum(['totp', 'passkey']);
|
|
30
|
+
/**
|
|
31
|
+
* Login response.
|
|
32
|
+
*
|
|
33
|
+
* • `secondFactors` empty / absent: the credentials were sufficient,
|
|
34
|
+
* `token` is the real session JWT.
|
|
35
|
+
* • `secondFactors` non-empty: the user has one or more second
|
|
36
|
+
* factors enrolled. `token` is a short-lived (5 min) challenge
|
|
37
|
+
* token, NOT a session. The client presents the factor chooser and
|
|
38
|
+
* completes ONE of: `loginVerifyTotp` (`totp`) or
|
|
39
|
+
* `loginBeginPasskey` + `loginVerifyPasskey` (`passkey`). Only on
|
|
40
|
+
* success does the server mint the real session.
|
|
19
41
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* • `requiresTotp: true`: the user has TOTP enrolled. `token` is a
|
|
23
|
-
* short-lived (5 min) challenge token, NOT a session. The client
|
|
24
|
-
* prompts for a 6-digit code and submits to `loginVerifyTotp`
|
|
25
|
-
* with `{challengeToken, code}`. Only on success does the server
|
|
26
|
-
* mint the real session.
|
|
42
|
+
* `requiresTotp` is retained (`= secondFactors.includes('totp')`) for
|
|
43
|
+
* backward compatibility with older SDK builds that branch on it.
|
|
27
44
|
*/
|
|
28
45
|
const LoginResultSchema = zod_1.z.object({
|
|
29
46
|
token: zod_1.z.string(),
|
|
@@ -33,13 +50,79 @@ const LoginResultSchema = zod_1.z.object({
|
|
|
33
50
|
isAdmin: zod_1.z.boolean(),
|
|
34
51
|
}),
|
|
35
52
|
requiresTotp: zod_1.z.boolean().optional(),
|
|
53
|
+
secondFactors: zod_1.z.array(SecondFactorSchema).optional(),
|
|
36
54
|
});
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
55
|
+
// ── Passkey (user-passkeys cap) resolution ───────────────────────────
|
|
56
|
+
//
|
|
57
|
+
// Cap-agnostic: the passkey provider is resolved through the
|
|
58
|
+
// `user-passkeys` CAPABILITY, never by addon id. Any addon registering
|
|
59
|
+
// the cap works. Resolution mirrors `hwaccel.router`:
|
|
60
|
+
// 1. hub-in-process collection provider (a future hub-resident impl),
|
|
61
|
+
// 2. else the hub-local forked child over UDS via the Moleculer cap
|
|
62
|
+
// proxy (`createCapabilityProxy(cap, 'hub')`) — the same routing the
|
|
63
|
+
// generated cap routers use.
|
|
64
|
+
// The proxy is loosely typed (`Record<method, (params)=>Promise<unknown>>`);
|
|
65
|
+
// its outputs are narrowed through the cap's own Zod schemas rather than
|
|
66
|
+
// cast to the provider type (repo rule: no `as` on cross-boundary values).
|
|
67
|
+
const OptionsJsonSchema = zod_1.z.object({ optionsJSON: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()) });
|
|
68
|
+
const FinishRegistrationSchema = zod_1.z.object({ success: zod_1.z.literal(true), credentialId: zod_1.z.string() });
|
|
69
|
+
const FinishAuthenticationSchema = zod_1.z.object({ verified: zod_1.z.boolean() });
|
|
70
|
+
const RemovePasskeySchema = zod_1.z.object({ success: zod_1.z.literal(true) });
|
|
71
|
+
function proxyPasskeyProvider(proxy) {
|
|
72
|
+
const call = async (method, params) => {
|
|
73
|
+
const fn = proxy[method];
|
|
74
|
+
if (typeof fn !== 'function')
|
|
75
|
+
throw new Error(`user-passkeys proxy missing method '${method}'`);
|
|
76
|
+
return fn(params);
|
|
77
|
+
};
|
|
78
|
+
return {
|
|
79
|
+
beginRegistration: async (input) => OptionsJsonSchema.parse(await call('beginRegistration', input)),
|
|
80
|
+
finishRegistration: async (input) => FinishRegistrationSchema.parse(await call('finishRegistration', input)),
|
|
81
|
+
beginAuthentication: async (input) => OptionsJsonSchema.parse(await call('beginAuthentication', input)),
|
|
82
|
+
finishAuthentication: async (input) => FinishAuthenticationSchema.parse(await call('finishAuthentication', input)),
|
|
83
|
+
listPasskeys: async (input) => zod_1.z.array(types_1.PasskeySummarySchema).parse(await call('listPasskeys', input)),
|
|
84
|
+
removePasskey: async (input) => RemovePasskeySchema.parse(await call('removePasskey', input)),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function resolvePasskeyProvider(registry, moleculer) {
|
|
88
|
+
const local = registry?.getCollection('user-passkeys')?.[0] ?? null;
|
|
89
|
+
if (local)
|
|
90
|
+
return local;
|
|
91
|
+
const proxy = moleculer?.createCapabilityProxy('user-passkeys', 'hub') ?? null;
|
|
92
|
+
return proxy ? proxyPasskeyProvider(proxy) : null;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Whether the user has at least one enrolled passkey. Never throws — a
|
|
96
|
+
* missing provider or a transport hiccup degrades to `false` so the
|
|
97
|
+
* login flow is never blocked by passkey introspection.
|
|
98
|
+
*/
|
|
99
|
+
async function userHasPasskey(userId, provider) {
|
|
100
|
+
if (!provider)
|
|
101
|
+
return false;
|
|
102
|
+
try {
|
|
103
|
+
const list = await provider.listPasskeys({ userId });
|
|
104
|
+
return list.length > 0;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// ── Login-method aggregation (login-method cap) ──────────────────────
|
|
111
|
+
//
|
|
112
|
+
// The PUBLIC `listLoginMethods` procedure walks the `login-method`
|
|
113
|
+
// collection and returns the union that the login page renders directly:
|
|
114
|
+
// `redirect` buttons (OIDC / magic-link) + pre-auth `widget`s (passkey).
|
|
115
|
+
// The widget arm is enriched server-side with a public `bundleUrl` from
|
|
116
|
+
// `addonId` + `bundle`, so the addon never encodes the static-route
|
|
117
|
+
// scheme. `?v=<now>` guards against a stale cached bundle after an addon
|
|
118
|
+
// update (the login page loads rarely, so cache-busting is free here).
|
|
119
|
+
const PublicWidgetLoginMethodSchema = types_1.WidgetLoginMethodSchema.extend({
|
|
120
|
+
bundleUrl: zod_1.z.string(),
|
|
42
121
|
});
|
|
122
|
+
const PublicLoginMethodSchema = zod_1.z.discriminatedUnion('kind', [
|
|
123
|
+
types_1.RedirectLoginMethodSchema,
|
|
124
|
+
PublicWidgetLoginMethodSchema,
|
|
125
|
+
]);
|
|
43
126
|
/** Wire shape of the authenticated user returned by `auth.me`. */
|
|
44
127
|
const MeSchema = zod_1.z
|
|
45
128
|
.object({
|
|
@@ -55,7 +138,7 @@ const MeSchema = zod_1.z
|
|
|
55
138
|
agentId: zod_1.z.string().optional(),
|
|
56
139
|
})
|
|
57
140
|
.nullable();
|
|
58
|
-
function createAuthRouter(auth, registry) {
|
|
141
|
+
function createAuthRouter(auth, registry, moleculer = null) {
|
|
59
142
|
return (0, trpc_middleware_js_1.trpcRouter)({
|
|
60
143
|
login: trpc_middleware_js_1.publicProcedure
|
|
61
144
|
.input(zod_1.z.object({ username: zod_1.z.string(), password: zod_1.z.string() }))
|
|
@@ -78,19 +161,25 @@ function createAuthRouter(auth, registry) {
|
|
|
78
161
|
});
|
|
79
162
|
if (!user)
|
|
80
163
|
throw new Error('Invalid credentials');
|
|
81
|
-
// ──
|
|
82
|
-
// After credentials validate, check
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
164
|
+
// ── Second-factor gate ───────────────────────────────────────
|
|
165
|
+
// After credentials validate, check every enrolled second
|
|
166
|
+
// factor. If ANY is present, mint a SHORT-LIVED challenge token
|
|
167
|
+
// instead of the real session — the client must complete one
|
|
168
|
+
// factor before we hand out the actual JWT. The challenge token
|
|
169
|
+
// carries `kind: 'totp-challenge'` so it can't be replayed
|
|
170
|
+
// against protected endpoints (the auth middleware rejects
|
|
171
|
+
// anything without the standard session shape); the same token
|
|
172
|
+
// binds both the TOTP and the passkey second legs (same userId).
|
|
90
173
|
const totpStatus = typeof userMgmt.getTotpStatus === 'function'
|
|
91
174
|
? await userMgmt.getTotpStatus({ userId: user.id })
|
|
92
175
|
: { enabled: false };
|
|
93
|
-
|
|
176
|
+
const passkeyEnrolled = await userHasPasskey(user.id, resolvePasskeyProvider(registry, moleculer));
|
|
177
|
+
const secondFactors = [];
|
|
178
|
+
if (totpStatus.enabled)
|
|
179
|
+
secondFactors.push('totp');
|
|
180
|
+
if (passkeyEnrolled)
|
|
181
|
+
secondFactors.push('passkey');
|
|
182
|
+
if (secondFactors.length > 0) {
|
|
94
183
|
const challengeToken = auth.signTotpChallengeToken({
|
|
95
184
|
userId: user.id,
|
|
96
185
|
username: user.username,
|
|
@@ -99,7 +188,8 @@ function createAuthRouter(auth, registry) {
|
|
|
99
188
|
return {
|
|
100
189
|
token: challengeToken,
|
|
101
190
|
user: { id: user.id, username: user.username, isAdmin: user.isAdmin },
|
|
102
|
-
requiresTotp:
|
|
191
|
+
requiresTotp: totpStatus.enabled,
|
|
192
|
+
secondFactors,
|
|
103
193
|
};
|
|
104
194
|
}
|
|
105
195
|
// Snapshot `scopes` into the JWT payload. Admins ignore the
|
|
@@ -177,6 +267,75 @@ function createAuthRouter(auth, registry) {
|
|
|
177
267
|
requiresTotp: false,
|
|
178
268
|
};
|
|
179
269
|
}),
|
|
270
|
+
/**
|
|
271
|
+
* Passkey second leg — step 1 of 2. Accepts the challenge token
|
|
272
|
+
* minted by `login` and returns the WebAuthn assertion options
|
|
273
|
+
* (`PublicKeyCredentialRequestOptionsJSON`) scoped to the user's
|
|
274
|
+
* enrolled credentials (strict-user `allowCredentials`). PUBLIC —
|
|
275
|
+
* the client is still pre-session at this point.
|
|
276
|
+
*/
|
|
277
|
+
loginBeginPasskey: trpc_middleware_js_1.publicProcedure
|
|
278
|
+
.input(zod_1.z.object({ challengeToken: zod_1.z.string() }))
|
|
279
|
+
.output(OptionsJsonSchema)
|
|
280
|
+
.mutation(async ({ input }) => {
|
|
281
|
+
const claims = auth.verifyTotpChallengeToken(input.challengeToken);
|
|
282
|
+
if (!claims) {
|
|
283
|
+
throw new Error('Invalid or expired login challenge — please re-enter your password');
|
|
284
|
+
}
|
|
285
|
+
const provider = resolvePasskeyProvider(registry, moleculer);
|
|
286
|
+
if (!provider)
|
|
287
|
+
throw new Error('Passkey authentication is not available');
|
|
288
|
+
return provider.beginAuthentication({ userId: claims.userId });
|
|
289
|
+
}),
|
|
290
|
+
/**
|
|
291
|
+
* Passkey second leg — step 2 of 2. Accepts the challenge token +
|
|
292
|
+
* the browser assertion (`AuthenticationResponseJSON`). Verifies the
|
|
293
|
+
* assertion via the `user-passkeys` cap and, on success, mints the
|
|
294
|
+
* real session JWT through the SAME `auth.signToken` path
|
|
295
|
+
* `loginVerifyTotp` uses (fresh user re-fetch for up-to-date scopes).
|
|
296
|
+
*/
|
|
297
|
+
loginVerifyPasskey: trpc_middleware_js_1.publicProcedure
|
|
298
|
+
.input(zod_1.z.object({ challengeToken: zod_1.z.string(), response: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()) }))
|
|
299
|
+
.output(LoginResultSchema)
|
|
300
|
+
.mutation(async ({ input }) => {
|
|
301
|
+
const claims = auth.verifyTotpChallengeToken(input.challengeToken);
|
|
302
|
+
if (!claims) {
|
|
303
|
+
throw new Error('Invalid or expired login challenge — please re-enter your password');
|
|
304
|
+
}
|
|
305
|
+
const provider = resolvePasskeyProvider(registry, moleculer);
|
|
306
|
+
if (!provider)
|
|
307
|
+
throw new Error('Passkey authentication is not available');
|
|
308
|
+
const result = await provider.finishAuthentication({
|
|
309
|
+
userId: claims.userId,
|
|
310
|
+
response: input.response,
|
|
311
|
+
});
|
|
312
|
+
if (!result.verified) {
|
|
313
|
+
throw new Error('Passkey verification failed');
|
|
314
|
+
}
|
|
315
|
+
const userMgmt = registry?.getSingleton('user-management');
|
|
316
|
+
if (!userMgmt) {
|
|
317
|
+
throw new Error('Login unavailable — `user-management` capability not registered');
|
|
318
|
+
}
|
|
319
|
+
const fresh = typeof userMgmt.listUsers === 'function'
|
|
320
|
+
? (await userMgmt.listUsers()).find((u) => u.id === claims.userId)
|
|
321
|
+
: null;
|
|
322
|
+
if (!fresh) {
|
|
323
|
+
throw new Error('User no longer exists');
|
|
324
|
+
}
|
|
325
|
+
const sessionToken = auth.signToken({
|
|
326
|
+
userId: fresh.id,
|
|
327
|
+
username: fresh.username,
|
|
328
|
+
isAdmin: fresh.isAdmin,
|
|
329
|
+
allowedProviders: fresh.allowedProviders ?? '*',
|
|
330
|
+
allowedDevices: fresh.allowedDevices ?? {},
|
|
331
|
+
scopes: fresh.scopes ?? [],
|
|
332
|
+
});
|
|
333
|
+
return {
|
|
334
|
+
token: sessionToken,
|
|
335
|
+
user: { id: fresh.id, username: fresh.username, isAdmin: fresh.isAdmin },
|
|
336
|
+
requiresTotp: false,
|
|
337
|
+
};
|
|
338
|
+
}),
|
|
180
339
|
me: trpc_middleware_js_1.protectedProcedure
|
|
181
340
|
.input(zod_1.z.void())
|
|
182
341
|
.output(MeSchema)
|
|
@@ -259,26 +418,114 @@ function createAuthRouter(auth, registry) {
|
|
|
259
418
|
return { enabled: false, confirmedAt: null };
|
|
260
419
|
return userMgmt.getTotpStatus({ userId: ctx.user.id });
|
|
261
420
|
}),
|
|
421
|
+
// ── Self-service passkey enrollment (the signed-in user) ──────────
|
|
422
|
+
//
|
|
423
|
+
// The `user-passkeys` cap gates registration/management as `admin`
|
|
424
|
+
// at the trpc layer. These bind `userId` to `ctx.user.id` and call
|
|
425
|
+
// the provider server-side, so a non-admin user can enroll / revoke
|
|
426
|
+
// THEIR OWN passkeys — the router is the trust boundary, exactly like
|
|
427
|
+
// the TOTP self-service block above. Cap-agnostic: resolved through
|
|
428
|
+
// the `user-passkeys` cap, never an addon id.
|
|
429
|
+
beginOwnPasskeyRegistration: trpc_middleware_js_1.protectedProcedure
|
|
430
|
+
.input(zod_1.z.void())
|
|
431
|
+
.output(OptionsJsonSchema)
|
|
432
|
+
.mutation(async ({ ctx }) => {
|
|
433
|
+
if (!ctx.user)
|
|
434
|
+
throw new Error('Not authenticated');
|
|
435
|
+
const provider = resolvePasskeyProvider(registry, moleculer);
|
|
436
|
+
if (!provider)
|
|
437
|
+
throw new Error('Passkey enrollment is not available');
|
|
438
|
+
return provider.beginRegistration({ userId: ctx.user.id, username: ctx.user.username });
|
|
439
|
+
}),
|
|
440
|
+
finishOwnPasskeyRegistration: trpc_middleware_js_1.protectedProcedure
|
|
441
|
+
.input(zod_1.z.object({ response: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()), label: zod_1.z.string() }))
|
|
442
|
+
.output(FinishRegistrationSchema)
|
|
443
|
+
.mutation(async ({ input, ctx }) => {
|
|
444
|
+
if (!ctx.user)
|
|
445
|
+
throw new Error('Not authenticated');
|
|
446
|
+
const provider = resolvePasskeyProvider(registry, moleculer);
|
|
447
|
+
if (!provider)
|
|
448
|
+
throw new Error('Passkey enrollment is not available');
|
|
449
|
+
return provider.finishRegistration({
|
|
450
|
+
userId: ctx.user.id,
|
|
451
|
+
response: input.response,
|
|
452
|
+
label: input.label,
|
|
453
|
+
});
|
|
454
|
+
}),
|
|
455
|
+
listOwnPasskeys: trpc_middleware_js_1.protectedProcedure
|
|
456
|
+
.input(zod_1.z.void())
|
|
457
|
+
.output(zod_1.z.array(types_1.PasskeySummarySchema))
|
|
458
|
+
.query(async ({ ctx }) => {
|
|
459
|
+
if (!ctx.user)
|
|
460
|
+
return [];
|
|
461
|
+
const provider = resolvePasskeyProvider(registry, moleculer);
|
|
462
|
+
if (!provider)
|
|
463
|
+
return [];
|
|
464
|
+
try {
|
|
465
|
+
return await provider.listPasskeys({ userId: ctx.user.id });
|
|
466
|
+
}
|
|
467
|
+
catch {
|
|
468
|
+
return [];
|
|
469
|
+
}
|
|
470
|
+
}),
|
|
471
|
+
removeOwnPasskey: trpc_middleware_js_1.protectedProcedure
|
|
472
|
+
.input(zod_1.z.object({ credentialId: zod_1.z.string() }))
|
|
473
|
+
.output(zod_1.z.object({ success: zod_1.z.literal(true) }))
|
|
474
|
+
.mutation(async ({ input, ctx }) => {
|
|
475
|
+
if (!ctx.user)
|
|
476
|
+
throw new Error('Not authenticated');
|
|
477
|
+
const provider = resolvePasskeyProvider(registry, moleculer);
|
|
478
|
+
if (!provider)
|
|
479
|
+
throw new Error('Passkey management is not available');
|
|
480
|
+
return provider.removePasskey({ userId: ctx.user.id, credentialId: input.credentialId });
|
|
481
|
+
}),
|
|
262
482
|
logout: trpc_middleware_js_1.protectedProcedure
|
|
263
483
|
.input(zod_1.z.void())
|
|
264
484
|
.output(zod_1.z.object({ success: zod_1.z.literal(true) }))
|
|
265
485
|
.mutation(() => ({ success: true })),
|
|
266
|
-
|
|
486
|
+
/**
|
|
487
|
+
* PUBLIC — the login page's single source of pre-auth login surfaces.
|
|
488
|
+
* Aggregates the `login-method` collection: every auth addon (OIDC,
|
|
489
|
+
* magic-link, WebAuthn) contributes `redirect` buttons and/or pre-auth
|
|
490
|
+
* `widget`s, each tagged `stage: 'primary' | 'second-factor'`. The
|
|
491
|
+
* login page renders `redirect` methods as generic buttons and mounts
|
|
492
|
+
* `widget` methods via `loadRemoteBundle` — a future SSO addon plugs
|
|
493
|
+
* in with ZERO shell change.
|
|
494
|
+
*
|
|
495
|
+
* A failing / slow provider is skipped independently (mirrors
|
|
496
|
+
* `listProviders`' old resilience) so one bad addon can't lock every
|
|
497
|
+
* login method out of the UI.
|
|
498
|
+
*/
|
|
499
|
+
listLoginMethods: trpc_middleware_js_1.publicProcedure
|
|
267
500
|
.input(zod_1.z.void())
|
|
268
|
-
.output(zod_1.z.array(
|
|
269
|
-
.query(() => {
|
|
501
|
+
.output(zod_1.z.array(PublicLoginMethodSchema).readonly())
|
|
502
|
+
.query(async () => {
|
|
270
503
|
if (!registry)
|
|
271
504
|
return [];
|
|
272
|
-
// Validate each auth-provider entry independently. A single
|
|
273
|
-
// malformed entry (e.g. an auth addon that registers without an
|
|
274
|
-
// `icon`) must NOT sink the whole array through the tRPC output
|
|
275
|
-
// validator — that would 500 the query and lock every login
|
|
276
|
-
// method out of the UI. Drop the bad entry, keep the rest.
|
|
277
505
|
const out = [];
|
|
278
|
-
for (const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
506
|
+
for (const provider of registry.getCollection('login-method')) {
|
|
507
|
+
let contributions;
|
|
508
|
+
try {
|
|
509
|
+
contributions = await provider.getLoginMethods();
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
for (const raw of contributions) {
|
|
515
|
+
if (raw.kind === 'redirect') {
|
|
516
|
+
const parsed = types_1.RedirectLoginMethodSchema.safeParse(raw);
|
|
517
|
+
if (parsed.success)
|
|
518
|
+
out.push(parsed.data);
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
// Widget arm — stamp a public bundleUrl from addonId + bundle.
|
|
522
|
+
const parsed = PublicWidgetLoginMethodSchema.safeParse({
|
|
523
|
+
...raw,
|
|
524
|
+
bundleUrl: `/api/addon-widgets/${raw.addonId}/${raw.bundle}?v=${Date.now()}`,
|
|
525
|
+
});
|
|
526
|
+
if (parsed.success)
|
|
527
|
+
out.push(parsed.data);
|
|
528
|
+
}
|
|
282
529
|
}
|
|
283
530
|
return out;
|
|
284
531
|
}),
|
|
@@ -38,6 +38,7 @@ exports.buildSystemProvider = buildSystemProvider;
|
|
|
38
38
|
exports.buildNetworkQualityProvider = buildNetworkQualityProvider;
|
|
39
39
|
exports.buildToastProvider = buildToastProvider;
|
|
40
40
|
exports.computeTopology = computeTopology;
|
|
41
|
+
exports.createNodeRootPackageLookup = createNodeRootPackageLookup;
|
|
41
42
|
exports.buildNodesProvider = buildNodesProvider;
|
|
42
43
|
exports.buildIntegrationsProvider = buildIntegrationsProvider;
|
|
43
44
|
exports.buildAddonsProvider = buildAddonsProvider;
|
|
@@ -165,7 +166,7 @@ function getLocalIps() {
|
|
|
165
166
|
* `nodes.topology` cap procedure. Extracted so the topology emitter
|
|
166
167
|
* service can produce identical snapshots without going through tRPC.
|
|
167
168
|
*/
|
|
168
|
-
async function computeTopology(agentRegistry, addonRegistry) {
|
|
169
|
+
async function computeTopology(agentRegistry, addonRegistry, getNodeRootPackage) {
|
|
169
170
|
const nodes = await agentRegistry.listNodes();
|
|
170
171
|
// A2: durable offline-node history. `listNodes()` (above) has just
|
|
171
172
|
// snapshotted every ONLINE node into this store, so `lastActive` is fresh
|
|
@@ -300,6 +301,10 @@ async function computeTopology(agentRegistry, addonRegistry) {
|
|
|
300
301
|
addons: allNodeAddons,
|
|
301
302
|
processes: [mainProcess, ...childProcesses],
|
|
302
303
|
categories: categoriesProjection,
|
|
304
|
+
// Runtime-updatable node packages: the node's root-package identity
|
|
305
|
+
// (hub: the running @camstack/server; agents: their registerNode
|
|
306
|
+
// manifest via HubNodeRegistry). Null when unknown.
|
|
307
|
+
rootPackage: getNodeRootPackage?.(node.info.id, node.isHub) ?? null,
|
|
303
308
|
};
|
|
304
309
|
});
|
|
305
310
|
// A2: union in OFFLINE rows for every persisted node no longer present in the
|
|
@@ -332,13 +337,30 @@ async function computeTopology(agentRegistry, addonRegistry) {
|
|
|
332
337
|
})),
|
|
333
338
|
processes: [],
|
|
334
339
|
categories: [],
|
|
340
|
+
rootPackage: null,
|
|
335
341
|
}));
|
|
336
342
|
return [...liveNodes, ...offlineNodes];
|
|
337
343
|
}
|
|
338
|
-
|
|
344
|
+
/**
|
|
345
|
+
* Build the standard {@link NodeRootPackageLookup}: hub rows come from the
|
|
346
|
+
* running `@camstack/server` package (ServerUpdateService), agent rows from
|
|
347
|
+
* the `registerNode`-fed HubNodeRegistry (via MoleculerService).
|
|
348
|
+
*/
|
|
349
|
+
function createNodeRootPackageLookup(moleculer, serverUpdate) {
|
|
350
|
+
return (nodeId, isHub) => {
|
|
351
|
+
if (isHub) {
|
|
352
|
+
const running = serverUpdate?.getRunningRootPackage();
|
|
353
|
+
return running === undefined || running.version === null
|
|
354
|
+
? null
|
|
355
|
+
: { name: running.name, version: running.version };
|
|
356
|
+
}
|
|
357
|
+
return moleculer.getNodeRootPackage(nodeId);
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
function buildNodesProvider(agentRegistry, moleculer, addonRegistry, getNodeRootPackage) {
|
|
339
361
|
const broker = moleculer.broker;
|
|
340
362
|
return {
|
|
341
|
-
topology: async () => computeTopology(agentRegistry, addonRegistry),
|
|
363
|
+
topology: async () => computeTopology(agentRegistry, addonRegistry, getNodeRootPackage),
|
|
342
364
|
deployAddon: async () => {
|
|
343
365
|
// Placeholder — actual deployment orchestration TBD
|
|
344
366
|
return { success: true };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildServerManagementProvider = buildServerManagementProvider;
|
|
4
|
+
function buildServerManagementProvider(service) {
|
|
5
|
+
if (service === null)
|
|
6
|
+
return null;
|
|
7
|
+
return {
|
|
8
|
+
getServerPackageStatus: () => service.getServerPackageStatus(),
|
|
9
|
+
checkServerUpdate: () => service.checkServerUpdate(),
|
|
10
|
+
applyServerUpdate: (input) => service.applyServerUpdate(input),
|
|
11
|
+
rollbackServerUpdate: () => service.rollbackServerUpdate(),
|
|
12
|
+
restartServer: () => Promise.resolve(service.restartNode()),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -75,6 +75,11 @@ function registerHealthRoutes(fastify, deps) {
|
|
|
75
75
|
reply.status(503);
|
|
76
76
|
return health;
|
|
77
77
|
});
|
|
78
|
+
// Zero-config LAN discovery — unauthenticated, always 200. See DiscoveryInfo.
|
|
79
|
+
fastify.get('/discovery', async () => {
|
|
80
|
+
const name = process.env['CAMSTACK_HUB_NAME'] ?? 'CamStack Hub';
|
|
81
|
+
return { service: 'camstack-hub', nodeId: 'hub', version: deps.hubVersion, name };
|
|
82
|
+
});
|
|
78
83
|
fastify.get('/health/agents', async () => {
|
|
79
84
|
const nodes = deps.agentRegistry.listNodeLiveness();
|
|
80
85
|
return { agents: nodes.filter((n) => !n.isHub && n.isOnline).map((n) => n.id) };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* spa-static — shared static-serving helpers for the SPAs the hub mounts
|
|
4
|
+
* (admin-ui at `/`, viewer-ui at `/viewer/camstack`). Extracted so both
|
|
5
|
+
* mount points apply IDENTICAL cache policy: content-hashed build assets are
|
|
6
|
+
* immutable; the service worker / registration / web manifest + the SPA shell
|
|
7
|
+
* (`index.html`) always revalidate so a redeploy actually reaches clients.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.spaAssetCacheControl = spaAssetCacheControl;
|
|
11
|
+
exports.contentTypeForPath = contentTypeForPath;
|
|
12
|
+
/**
|
|
13
|
+
* Cache-Control value for a static SPA asset addressed by its dist-relative
|
|
14
|
+
* path. Policy (shared by admin-ui + viewer-ui):
|
|
15
|
+
* - `sw.js` / `registerSW.js` / `workbox-*.js` / `manifest.webmanifest`
|
|
16
|
+
* → `no-cache, must-revalidate` (PWA update propagation).
|
|
17
|
+
* - Content-hashed build assets under `assets/` (Vite) or `_expo/` (Expo
|
|
18
|
+
* web export) → `public, max-age=31536000, immutable`.
|
|
19
|
+
* - Everything else (index.html, favicon, non-hashed files) → `no-cache`.
|
|
20
|
+
*/
|
|
21
|
+
function spaAssetCacheControl(rel) {
|
|
22
|
+
const base = rel.split('/').pop() ?? rel;
|
|
23
|
+
if (/^(sw\.js|registerSW\.js|workbox-.*\.js|manifest\.webmanifest)$/.test(base)) {
|
|
24
|
+
return 'no-cache, must-revalidate';
|
|
25
|
+
}
|
|
26
|
+
const inAssetDir = rel.startsWith('assets/') || rel.startsWith('_expo/');
|
|
27
|
+
if (inAssetDir && /-[A-Za-z0-9_-]{8,}\./.test(base)) {
|
|
28
|
+
return 'public, max-age=31536000, immutable';
|
|
29
|
+
}
|
|
30
|
+
return 'no-cache';
|
|
31
|
+
}
|
|
32
|
+
const CONTENT_TYPES = {
|
|
33
|
+
html: 'text/html; charset=utf-8',
|
|
34
|
+
js: 'text/javascript; charset=utf-8',
|
|
35
|
+
mjs: 'text/javascript; charset=utf-8',
|
|
36
|
+
css: 'text/css; charset=utf-8',
|
|
37
|
+
json: 'application/json; charset=utf-8',
|
|
38
|
+
map: 'application/json; charset=utf-8',
|
|
39
|
+
webmanifest: 'application/manifest+json; charset=utf-8',
|
|
40
|
+
wasm: 'application/wasm',
|
|
41
|
+
txt: 'text/plain; charset=utf-8',
|
|
42
|
+
xml: 'application/xml; charset=utf-8',
|
|
43
|
+
svg: 'image/svg+xml',
|
|
44
|
+
png: 'image/png',
|
|
45
|
+
jpg: 'image/jpeg',
|
|
46
|
+
jpeg: 'image/jpeg',
|
|
47
|
+
gif: 'image/gif',
|
|
48
|
+
webp: 'image/webp',
|
|
49
|
+
avif: 'image/avif',
|
|
50
|
+
ico: 'image/x-icon',
|
|
51
|
+
woff: 'font/woff',
|
|
52
|
+
woff2: 'font/woff2',
|
|
53
|
+
ttf: 'font/ttf',
|
|
54
|
+
otf: 'font/otf',
|
|
55
|
+
eot: 'application/vnd.ms-fontobject',
|
|
56
|
+
mp4: 'video/mp4',
|
|
57
|
+
webm: 'video/webm',
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Best-effort MIME type for a static file path. Used when streaming viewer
|
|
61
|
+
* assets directly (the admin-ui path goes through @fastify/static). Defaults
|
|
62
|
+
* to `application/octet-stream` for unknown extensions.
|
|
63
|
+
*/
|
|
64
|
+
function contentTypeForPath(pathname) {
|
|
65
|
+
const base = pathname.split('/').pop() ?? pathname;
|
|
66
|
+
const dot = base.lastIndexOf('.');
|
|
67
|
+
const ext = dot >= 0 ? base.slice(dot + 1).toLowerCase() : '';
|
|
68
|
+
return CONTENT_TYPES[ext] ?? 'application/octet-stream';
|
|
69
|
+
}
|