@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.
@@ -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 framework_live_sync_js_1 = require("../core/addon/framework-live-sync.js");
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
- // `installFromTgz` wrote the built tree to `${addonsDir}/<pkg>` a path
410
- // NO runtime consumer loads: the hub main resolves the framework from its
411
- // OWN node_modules closure (walk-up beats NODE_PATH) and forked addon
412
- // runners resolve it from CAMSTACK_FRAMEWORK_DIR via the ESM hook. Mirror
413
- // the freshly-built `dist/` into BOTH so the restart below actually loads
414
- // the new code (otherwise the bounce reloads the identical old code).
415
- const sourceDistDir = path.join(addonsDir, result.name, 'dist');
416
- const frameworkSync = await (0, framework_live_sync_js_1.syncFrameworkDist)({
417
- packageName: result.name,
418
- sourceDistDir,
419
- logger,
420
- });
421
- const syncFailed = frameworkSync.results.filter((r) => !r.ok);
422
- logger.info('framework package deployed — scheduling server restart', {
423
- meta: {
424
- packageName: result.name,
425
- packageVersion: result.version,
426
- syncedTargets: frameworkSync.results.filter((r) => r.ok).map((r) => r.role),
427
- failedTargets: syncFailed.map((r) => r.role),
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
- addonPackageService.restartServer(`addon-upload: ${result.name}@${result.version}`);
431
- return reply.send({
432
- success: true,
439
+ return reply.status(409).send({
440
+ success: false,
433
441
  name: result.name,
434
442
  version: result.version,
435
- requiresRestart: true,
436
- restarting: true,
437
- frameworkSync: frameworkSync.results,
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
- * External auth providers are listed via the `auth-provider` cap collection.
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
- * Login response discriminated on `requiresTotp`.
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
- * `requiresTotp: false` (or absent in the legacy path): the
21
- * credentials were sufficient, `token` is the real session JWT.
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
- const AuthProviderSummarySchema = zod_1.z.object({
38
- id: zod_1.z.string(),
39
- name: zod_1.z.string(),
40
- icon: zod_1.z.string(),
41
- flowType: zod_1.z.string(),
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
- // ── TOTP gate ────────────────────────────────────────────────
82
- // After credentials validate, check whether the user has
83
- // active 2FA enrollment. If yes, mint a SHORT-LIVED challenge
84
- // token instead of the real session — the client must follow
85
- // up via `loginVerifyTotp` with a valid 6-digit code before
86
- // we hand out the actual JWT. The challenge token carries
87
- // `kind: 'totp-challenge'` so it can't be replayed against
88
- // protected endpoints (the auth middleware rejects anything
89
- // without the standard session shape).
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
- if (totpStatus.enabled) {
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: true,
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
- listProviders: trpc_middleware_js_1.publicProcedure
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(AuthProviderSummarySchema).readonly())
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 entry of registry.getCollection('auth-provider')) {
279
- const parsed = AuthProviderSummarySchema.safeParse(entry);
280
- if (parsed.success)
281
- out.push(parsed.data);
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
- function buildNodesProvider(agentRegistry, moleculer, addonRegistry) {
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
+ }