@camstack/server 1.2.456 → 1.2.457

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.
@@ -36,7 +36,7 @@ under [Non-commercial models](#non-commercial-models).
36
36
  - RF-DETR © Roboflow, Apache-2.0. https://github.com/roboflow/rf-detr
37
37
  - YuNet weights Copyright (c) 2020 Shiqi Yu, MIT. Training code: ShiqiYu/libfacedetection.train, BSD-3-Clause. https://github.com/opencv/opencv_zoo/tree/main/models/face_detection_yunet
38
38
  - AuraFace v1 by fal.ai, Apache-2.0. https://huggingface.co/fal/AuraFace-v1
39
- - © Ultralytics, AGPL-3.0 (YOLOv8n base, fine-tuned for plates by an unidentified third party). CamStack holds no training checkpoint; the ONNX file is the only form it has. https://github.com/ultralytics/ultralytics
39
+ - open-lpr models, Copyright the open-lpr contributors, Apache-2.0 (https://huggingface.co/faisalthaheem/open-lpr-models, tag detector-2026.10.1). YOLOX architecture and COCO pretraining by Megvii (https://github.com/Megvii-BaseDetection/YOLOX), Apache-2.0. The upstream NOTICE is reproduced verbatim (`NOTICE-open-lpr.txt`). https://huggingface.co/faisalthaheem/open-lpr-models
40
40
  - fast-plate-ocr, MIT License, copyright (c) 2024 ankandrew (full notice: licenses/models/MIT-fast-plate-ocr.txt). https://github.com/ankandrew/fast-plate-ocr
41
41
  - EfficientNet-Lite0 via timm (Ross Wightman), Apache-2.0. Open Images V7 annotations © Google LLC, CC BY 4.0; images listed by Google as CC BY 2.0, without warranty. https://github.com/huggingface/pytorch-image-models
42
42
  - torchvision MobileNetV3, BSD-3-Clause. Trained on the Vehicle classification dataset by DrBimmer (https://huggingface.co/datasets/DrBimmer/vehicle-classification), CC BY-NC 4.0. https://github.com/pytorch/vision
@@ -62,34 +62,42 @@ Training data under attribution terms:
62
62
  cards link here instead of repeating it.
63
63
  -->
64
64
 
65
- The folders `objectDetection/yolo26`, `objectDetection/yolov9`, `plateDetection/yolov8-plate` and `segmentation/yolo26-seg` of the model mirror hold Ultralytics weights,
65
+ The folders `objectDetection/yolo26`, `objectDetection/yolov9` and `segmentation/yolo26-seg` of the model mirror hold Ultralytics weights,
66
66
  modified by CamStack and distributed under AGPL-3.0. Their Corresponding Source
67
67
  is published in the mirror itself, in its **`source/`** folder,
68
68
  **https://huggingface.co/camstack/camstack-models/tree/main/source**, together
69
69
  with the upstream checkpoints it starts from:
70
70
 
71
- 1. **The upstream checkpoints**, published by Ultralytics at
72
- https://github.com/ultralytics/assets/releases: `yolo26{n,s,m,l,x}.pt`,
73
- `yolov9{t,s,m,c}.pt`, `yolo26{n,s,m}-seg.pt`.
74
- 2. **The export scripts** in `source/`: `build-camstack-models.py` (ONNX
75
- with a dynamic batch axis, CoreML FP16, OpenVINO IR, OpenVINO NNCF INT8),
76
- `convert_lib.py` (the ONNX to OpenVINO / INT8 / CoreML converter it imports,
77
- the same one CamStack's Model Studio runs), `requirements.txt` and
78
- `export-models.py`. The build instructions (Python environment,
79
- invocation, calibration images for INT8) are in `source/README.md` and in
80
- each script's header. These scripts are CamStack's own code and are offered
81
- under the MIT licence; the weights they produce keep their upstream licence.
82
-
83
- What the published scripts do **not** cover yet:
84
-
85
- - **Some hosted sizes and input resolutions.** The export scripts do not list
86
- every variant in the mirror (for example the `-320`/`-256` YOLO26 exports,
87
- `yolov9m`/`yolov9c`, and the `yolo26*-seg` models). Each one is the same
88
- Ultralytics `export()` with a different model id or `imgsz`, but no
89
- published script reproduces it as-is.
90
- - **`yolov8n-plate` has no training checkpoint.** It is a third-party model
91
- (trained 2023-12-05 according to its own metadata, origin not identified).
92
- The ONNX file is the only form CamStack has.
71
+ 1. **The upstream checkpoints.** Ultralytics publishes
72
+ `yolo26{n,s,m,l,x}.pt`, `yolov9{t,s,m,c}.pt` and `yolo26{n,s,m}-seg.pt` at
73
+ https://github.com/ultralytics/assets/releases.
74
+ 2. **The build script** in `source/`: `build-camstack-models.py`, with
75
+ `convert_lib.py` (the ONNX to OpenVINO / INT8 converter it calls, the same
76
+ file CamStack's Model Studio runs) and `requirements.txt`. It holds one
77
+ recipe per hosted file: the upstream checkpoint, the input size, the
78
+ Ultralytics `export()` arguments, the OpenVINO precision and the INT8
79
+ calibration count that file records about itself. Every hosted
80
+ Ultralytics-derived file is rebuilt by one invocation:
81
+ `build-camstack-models.py --variant <file stem>` (for example
82
+ `camstack-yolo26n-320` or `camstack-yolov9m-640`), `--family <folder>`
83
+ or `--all`; `--list` prints the recipes and `--check-hosted` confirms
84
+ against the mirror that every hosted file is a recipe output. The INT8
85
+ builds are NNCF post-training quantisation calibrated on COCO images: 128
86
+ for YOLO26, 300 for YOLOv9, as each IR records; the documented set is the
87
+ first N of COCO val2017 in file-name order. The Python
88
+ environment, the recorded tool versions and the calibration set are in
89
+ `source/README.md` and the script's header. These scripts are CamStack's
90
+ own code and are offered under the MIT licence; the weights they produce
91
+ keep their upstream licence.
92
+
93
+ Limits of a rebuild:
94
+
95
+ - **Tool versions.** With the Ultralytics, PyTorch, coremltools and OpenVINO
96
+ versions each recipe names, a rebuild reproduces the hosted weights. Other
97
+ versions give the same weights in a possibly different graph.
98
+ - **INT8 calibration.** An IR records how many images calibrated it, not
99
+ which ones. Calibrating on the same COCO images gives the documented build;
100
+ a different set gives an equivalent, not bit-identical, INT8 model.
93
101
 
94
102
  ## Non-commercial models
95
103
 
@@ -138,7 +146,7 @@ entry kept resolvable for a saved selection but no longer offered.
138
146
 
139
147
  | Catalog ids | Upstream | Code / weights | Training data | CamStack changes | Downloaded from |
140
148
  | --- | --- | --- | --- | --- | --- |
141
- | `yolov8n-plate` (default) | [Ultralytics](https://github.com/ultralytics/ultralytics) | AGPL-3.0 / AGPL-3.0 (`AGPL-3.0.txt`) | unidentified plate dataset (third-party model, trained 2023-12-05 per its ONNX metadata): UNKNOWN | converted to OpenVINO IR and CoreML | mirror `plateDetection/yolov8-plate` |
149
+ | `yolox-tiny-plate` (default) | [open-lpr models (faisalthaheem/open-lpr-models), YOLOX-tiny](https://huggingface.co/faisalthaheem/open-lpr-models) | Apache-2.0 / Apache-2.0 (`Apache-2.0.txt`) | Simanno annotated plate corpus plus community-contributed photographs, pseudo-labelled by a teacher model (not published; the upstream NOTICE says the photographs were uploaded without consent obtained): UNKNOWN | input contract baked into the graph (static 1x3x640x640 RGB /255 becomes the upstream BGR 0..255: Mul(255) plus a channel Gather) and the output reshaped to the YOLOv8 layout (cx, cy, w, h, obj*cls); ONNX fp32, OpenVINO IR fp16, CoreML fp16 (scripts/build-yolox-plate-model.py) | mirror `plateDetection/yolox-tiny-plate` |
142
150
 
143
151
  ### Licence plate reading
144
152
 
@@ -156,7 +164,7 @@ entry kept resolvable for a saved selection but no longer offered.
156
164
 
157
165
  | Catalog ids | Upstream | Code / weights | Training data | CamStack changes | Downloaded from |
158
166
  | --- | --- | --- | --- | --- | --- |
159
- | `bird-classifier` (default) | [Google AIY Vision birds_V1](https://github.com/google-coral/test_data) | — / Apache-2.0 (`Apache-2.0.txt`) | — | converted from TensorFlow with tf2onnx; OpenVINO IR and CoreML derived from it | mirror `animalClassification/bird-classifier` |
167
+ | `bird-classifier` (default) | [Google AIY Vision birds_V1](https://github.com/google-coral/test_data) | — / Apache-2.0 (`Apache-2.0.txt`) | — | converted from TensorFlow with tf2onnx; OpenVINO IR and CoreML derived from it; the Edge TPU build is the Google full-integer quantised TFLite of the same model, compiled with edgetpu_compiler 14 (scripts/build-bird-classifier-edgetpu.py) | mirror `animalClassification/bird-classifier` |
160
168
 
161
169
  ### Vehicle classifier
162
170
 
@@ -81,7 +81,7 @@ function ensurePersistedNodeId(configPath, dataDir) {
81
81
  return id;
82
82
  }
83
83
  function loadAgentConfig(configPath, dataDirOverride) {
84
- const filePath = configPath ?? process.env.CAMSTACK_AGENT_CONFIG ?? 'agent.json';
84
+ const filePath = configPath ?? 'agent.json';
85
85
  const dataDir = dataDirOverride ?? process.env.CAMSTACK_DATA ?? './camstack-data';
86
86
  const resolvedDataDir = path.resolve(dataDir);
87
87
  // Name: config file takes priority over env (allows UI rename to stick).
@@ -721,8 +721,7 @@ async function startAgent(configPath) {
721
721
  // agent drops hub-origin categories no local subscriber cares about instead
722
722
  // of fanning every category into its bus. Read per-event, so a child
723
723
  // (un)subscribing takes effect immediately. Fails OPEN when a child is
724
- // undeclared (aggregateEventInterest → null). Gated by
725
- // CAMSTACK_MOLECULER_EVENT_FANOUT (filter|shadow|broadcast).
724
+ // undeclared (aggregateEventInterest → null).
726
725
  const interestRegistry = agentUdsRegistry;
727
726
  (0, system_1.setNodeEventInterest)(broker, () => interestRegistry.aggregateEventInterest());
728
727
  // D1: answer readiness-snapshot requests from UDS children over the
@@ -22,6 +22,7 @@ const server_1 = require("@trpc/server");
22
22
  const zod_1 = require("zod");
23
23
  const auth_rate_limit_js_1 = require("../../auth/auth-rate-limit.js");
24
24
  const session_cookie_js_1 = require("../../auth/session-cookie.js");
25
+ const session_mint_js_1 = require("../../auth/session-mint.js");
25
26
  const handoff_code_service_js_1 = require("../../core/auth/handoff-code.service.js");
26
27
  const share_token_service_js_1 = require("../../core/auth/share-token.service.js");
27
28
  const principal_visibility_js_1 = require("../trpc/principal-visibility.js");
@@ -331,46 +332,18 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
331
332
  return shareTokens;
332
333
  };
333
334
  /**
334
- * Mint the REAL session JWT for `userId` — the single shared tail of
335
- * every successful login leg (TOTP second factor, passkey second
336
- * factor, usernameless passkey-first). Re-fetches the user record so
337
- * scopes / allowedDevices reflect any admin change made mid-flow —
338
- * signing from stale claims could mint a stale-scope session.
335
+ * THE session mint (`auth/session-mint.ts`, D712) — the single shared tail
336
+ * of every successful login leg, including `/api/auth/sso/finish`. It
337
+ * re-reads the user record so scopes / allowedDevices / isAdmin reflect any
338
+ * admin change made mid-flow; signing from stale claims could mint a
339
+ * stale-scope session.
339
340
  */
340
- /**
341
- * Fresh user record for `userId` via `user-management` — shared by the
342
- * session-mint tail and the second-factor challenge legs (both need
343
- * live identity claims, never stale ones).
344
- */
345
- const findUserById = async (userId) => {
346
- const userMgmt = registry?.getSingleton('user-management');
347
- if (!userMgmt) {
348
- throw new Error('Login unavailable — `user-management` capability not registered');
349
- }
350
- const fresh = typeof userMgmt.listUsers === 'function'
351
- ? (await userMgmt.listUsers()).find((u) => u.id === userId)
352
- : null;
353
- if (!fresh) {
354
- throw new Error('User no longer exists');
355
- }
356
- return fresh;
357
- };
358
- const mintSessionForUserId = async (userId) => {
359
- const fresh = await findUserById(userId);
360
- const sessionToken = auth.signToken({
361
- userId: fresh.id,
362
- username: fresh.username,
363
- isAdmin: fresh.isAdmin,
364
- allowedProviders: fresh.allowedProviders ?? '*',
365
- allowedDevices: fresh.allowedDevices ?? {},
366
- scopes: fresh.scopes ?? [],
367
- });
368
- return {
369
- token: sessionToken,
370
- user: { id: fresh.id, username: fresh.username, isAdmin: fresh.isAdmin },
371
- requiresTotp: false,
372
- };
373
- };
341
+ const minter = (0, session_mint_js_1.createSessionMinter)({
342
+ signer: auth,
343
+ directory: () => registry?.getSingleton('user-management'),
344
+ });
345
+ const findUserById = minter.findUser;
346
+ const mintSessionForUserId = minter.mintForUserId;
374
347
  /**
375
348
  * `mintSessionForUserId` with the "user vanished" failure mapped to a
376
349
  * clean 401 — for the token-exchange legs (`exchangeSession`,
@@ -383,7 +356,7 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
383
356
  return await mintSessionForUserId(userId);
384
357
  }
385
358
  catch (error) {
386
- if (error instanceof Error && error.message === 'User no longer exists') {
359
+ if (error instanceof session_mint_js_1.SessionUserGoneError) {
387
360
  throw new server_1.TRPCError({ code: 'UNAUTHORIZED', message: 'User no longer exists' });
388
361
  }
389
362
  throw error;
@@ -469,26 +442,8 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
469
442
  secondFactors,
470
443
  };
471
444
  }
472
- // Snapshot `scopes` into the JWT payload. Admins ignore the
473
- // field (middleware bypass); for non-admins this drives the
474
- // entire scope-access check until the user logs out + back in.
475
- // setUserScopes mutations take effect on next login — old JWTs
476
- // carry the snapshot from issue time. This is the standard JWT
477
- // staleness tradeoff; if you need immediate revocation, force a
478
- // logout from the admin UI.
479
- const token = auth.signToken({
480
- userId: user.id,
481
- username: user.username,
482
- isAdmin: user.isAdmin,
483
- allowedProviders: user.allowedProviders ?? '*',
484
- allowedDevices: user.allowedDevices ?? {},
485
- scopes: user.scopes ?? [],
486
- });
487
- return {
488
- token,
489
- user: { id: user.id, username: user.username, isAdmin: user.isAdmin },
490
- requiresTotp: false,
491
- };
445
+ // `user` is the record `validateCredentials` just read — fresh.
446
+ return minter.mintForRecord(user);
492
447
  }),
493
448
  /**
494
449
  * Second leg of the 2FA login. Accepts the challenge token minted
@@ -18,7 +18,7 @@
18
18
  * - {@link OPAQUE_ORIGIN} (`null`): the native app's OTA-bundled embed is a
19
19
  * `file://` page inside a WKWebView, whose origin is opaque. Removing it
20
20
  * breaks live view on iOS;
21
- * - `CAMSTACK_PUBLIC_ORIGIN`, when set, and the operator's extras
21
+ * - `CAMSTACK_HUB_PUBLIC_URL`, when set, and the operator's extras
22
22
  * (`server.corsOrigins` in config.yaml / `CAMSTACK_CORS_ORIGINS`).
23
23
  *
24
24
  * The answer to an allowed origin REFLECTS it exactly — never `*` — and never
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SessionDirectoryUnavailableError = exports.SessionUserGoneError = void 0;
4
+ exports.createSessionMinter = createSessionMinter;
5
+ /** The user a credential names is not in `user-management` (deleted, or never existed). */
6
+ class SessionUserGoneError extends Error {
7
+ userId;
8
+ constructor(userId) {
9
+ super('User no longer exists');
10
+ this.userId = userId;
11
+ this.name = 'SessionUserGoneError';
12
+ }
13
+ }
14
+ exports.SessionUserGoneError = SessionUserGoneError;
15
+ /** `user-management` is not registered: an infrastructure failure, never a 401. */
16
+ class SessionDirectoryUnavailableError extends Error {
17
+ constructor() {
18
+ super('Login unavailable — `user-management` capability not registered');
19
+ this.name = 'SessionDirectoryUnavailableError';
20
+ }
21
+ }
22
+ exports.SessionDirectoryUnavailableError = SessionDirectoryUnavailableError;
23
+ function createSessionMinter(deps) {
24
+ const findUserOrNull = async (userId) => {
25
+ const directory = deps.directory();
26
+ if (!directory)
27
+ throw new SessionDirectoryUnavailableError();
28
+ if (typeof directory.listUsers !== 'function')
29
+ return null;
30
+ return (await directory.listUsers()).find((u) => u.id === userId) ?? null;
31
+ };
32
+ const findUser = async (userId) => {
33
+ const record = await findUserOrNull(userId);
34
+ if (record === null)
35
+ throw new SessionUserGoneError(userId);
36
+ return record;
37
+ };
38
+ const mintForRecord = (record) => ({
39
+ token: deps.signer.signToken({
40
+ userId: record.id,
41
+ username: record.username,
42
+ isAdmin: record.isAdmin,
43
+ allowedProviders: record.allowedProviders ?? '*',
44
+ allowedDevices: record.allowedDevices ?? {},
45
+ scopes: record.scopes ?? [],
46
+ }),
47
+ user: { id: record.id, username: record.username, isAdmin: record.isAdmin },
48
+ requiresTotp: false,
49
+ });
50
+ return {
51
+ findUser,
52
+ findUserOrNull,
53
+ mintForRecord,
54
+ mintForUserId: async (userId) => mintForRecord(await findUser(userId)),
55
+ mintForFederatedIdentity: (identity) => mintForRecord({
56
+ id: identity.userId,
57
+ username: identity.username,
58
+ isAdmin: identity.isAdmin,
59
+ allowedProviders: '*',
60
+ allowedDevices: {},
61
+ scopes: [],
62
+ }),
63
+ };
64
+ }
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OIDC_FEDERATED_USER_PREFIX = exports.OIDC_BRIDGE_PROVIDER_PREFIX = exports.MAGIC_LINK_BRIDGE_PROVIDER = void 0;
4
+ exports.isLoginBridge = isLoginBridge;
5
+ exports.finishSsoLogin = finishSsoLogin;
6
+ const session_mint_js_1 = require("./session-mint.js");
7
+ /** The magic-link addon's bridge `provider`. */
8
+ exports.MAGIC_LINK_BRIDGE_PROVIDER = 'auth-magic-link';
9
+ /** `auth-oidc` signs `auth-oidc/<instance>`. */
10
+ exports.OIDC_BRIDGE_PROVIDER_PREFIX = 'auth-oidc/';
11
+ /** `auth-oidc` names a federated identity `oidc:<instance>:<sub>`. */
12
+ exports.OIDC_FEDERATED_USER_PREFIX = 'oidc:';
13
+ function isOidcBridge(claims) {
14
+ return (claims.provider.startsWith(exports.OIDC_BRIDGE_PROVIDER_PREFIX) &&
15
+ claims.userId.startsWith(exports.OIDC_FEDERATED_USER_PREFIX));
16
+ }
17
+ /** Whether `claims` is a LOGIN bridge — the only kind this endpoint swaps. */
18
+ function isLoginBridge(claims) {
19
+ return claims.provider === exports.MAGIC_LINK_BRIDGE_PROVIDER || isOidcBridge(claims);
20
+ }
21
+ /** A record wins; only an OIDC identity may stand without one. */
22
+ async function mintForBridge(claims, minter) {
23
+ const record = await minter.findUserOrNull(claims.userId);
24
+ if (record !== null)
25
+ return minter.mintForRecord(record);
26
+ if (!isOidcBridge(claims))
27
+ throw new session_mint_js_1.SessionUserGoneError(claims.userId);
28
+ return minter.mintForFederatedIdentity({
29
+ userId: claims.userId,
30
+ username: claims.username,
31
+ isAdmin: claims.isAdmin,
32
+ });
33
+ }
34
+ function unauthorized(error) {
35
+ return { kind: 'error', status: 401, body: { error } };
36
+ }
37
+ async function finishSsoLogin(bridge, deps) {
38
+ if (!bridge)
39
+ return { kind: 'error', status: 400, body: { error: 'Missing bridge token' } };
40
+ const claims = deps.verifyBridge(bridge);
41
+ if (!claims)
42
+ return unauthorized('Invalid or expired bridge token');
43
+ if (!isLoginBridge(claims))
44
+ return unauthorized('Not a login bridge token');
45
+ try {
46
+ const session = await mintForBridge(claims, deps.minter);
47
+ // The token rides in the URL FRAGMENT: it never reaches a server log and
48
+ // is not resent on later requests; the SPA's auth-context reads it on mount.
49
+ return {
50
+ kind: 'redirect',
51
+ location: `/admin/login#token=${encodeURIComponent(session.token)}&provider=${encodeURIComponent(claims.provider)}`,
52
+ };
53
+ }
54
+ catch (error) {
55
+ if (error instanceof session_mint_js_1.SessionUserGoneError)
56
+ return unauthorized('User no longer exists');
57
+ return {
58
+ kind: 'error',
59
+ status: 500,
60
+ body: {
61
+ error: 'SSO finish failed',
62
+ message: error instanceof Error ? error.message : String(error),
63
+ },
64
+ };
65
+ }
66
+ }
@@ -66,9 +66,6 @@ const ENV_VAR_MAP = {
66
66
  CAMSTACK_JWT_SECRET: 'auth.jwtSecret',
67
67
  CAMSTACK_ADMIN_USER: 'auth.adminUsername',
68
68
  CAMSTACK_ADMIN_PASS: 'auth.adminPassword',
69
- CAMSTACK_HUB_URL: 'hub.url',
70
- CAMSTACK_HUB_TOKEN: 'hub.token',
71
- CAMSTACK_AGENT_NAME: 'agent.name',
72
69
  CAMSTACK_TLS_ENABLED: 'tls.enabled',
73
70
  CAMSTACK_TLS_CERT: 'tls.certPath',
74
71
  CAMSTACK_TLS_KEY: 'tls.keyPath',
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ /**
3
+ * The hub's public URL — ONE environment variable, resolved once at boot.
4
+ *
5
+ * Two names used to answer the same question ("at which address do clients
6
+ * reach this hub?"): `CAMSTACK_HUB_PUBLIC_URL` (addon bundle and model
7
+ * distribution, notification links, the Dreame RFC host) and
8
+ * `CAMSTACK_PUBLIC_ORIGIN` (magic-link, OIDC callback, WebAuthn relying party,
9
+ * the OAuth2 issuer and the CORS allowlist, D707). An operator who set one got
10
+ * half a configured hub and no line saying so.
11
+ *
12
+ * `CAMSTACK_HUB_PUBLIC_URL` is the survivor. `CAMSTACK_PUBLIC_ORIGIN` is still
13
+ * accepted, for hubs that already set it, and costs a WARN on every boot until
14
+ * it is renamed. The resolution writes the survivor back into `process.env`
15
+ * and deletes the retired name, so every forked addon runner — which inherits
16
+ * this process's environment — reads exactly one variable.
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.RETIRED_PUBLIC_ORIGIN_ENV = exports.HUB_PUBLIC_URL_ENV = void 0;
20
+ exports.resolveHubPublicUrl = resolveHubPublicUrl;
21
+ exports.applyHubPublicUrl = applyHubPublicUrl;
22
+ /** The single public-URL variable every reader uses. */
23
+ exports.HUB_PUBLIC_URL_ENV = 'CAMSTACK_HUB_PUBLIC_URL';
24
+ /** Retired alias, accepted with a deprecation warning. */
25
+ exports.RETIRED_PUBLIC_ORIGIN_ENV = 'CAMSTACK_PUBLIC_ORIGIN';
26
+ function readTrimmed(env, name) {
27
+ const raw = env[name]?.trim();
28
+ if (raw === undefined || raw === '')
29
+ return undefined;
30
+ return raw.replace(/\/+$/, '');
31
+ }
32
+ /** Pure: which URL wins, and what to warn about. */
33
+ function resolveHubPublicUrl(env) {
34
+ const current = readTrimmed(env, exports.HUB_PUBLIC_URL_ENV);
35
+ const retired = readTrimmed(env, exports.RETIRED_PUBLIC_ORIGIN_ENV);
36
+ if (retired === undefined)
37
+ return { url: current, warning: undefined };
38
+ if (current === undefined) {
39
+ return {
40
+ url: retired,
41
+ warning: `${exports.RETIRED_PUBLIC_ORIGIN_ENV} is deprecated and was read as ${exports.HUB_PUBLIC_URL_ENV}=${retired}. ` +
42
+ `Rename it to ${exports.HUB_PUBLIC_URL_ENV}; the old name will stop being read in a future release.`,
43
+ };
44
+ }
45
+ const disagree = current !== retired;
46
+ return {
47
+ url: current,
48
+ warning: `${exports.RETIRED_PUBLIC_ORIGIN_ENV} is deprecated and IGNORED because ${exports.HUB_PUBLIC_URL_ENV} is set` +
49
+ (disagree ? ` (${current} wins over ${retired})` : '') +
50
+ `. Remove ${exports.RETIRED_PUBLIC_ORIGIN_ENV} from this container.`,
51
+ };
52
+ }
53
+ /**
54
+ * Apply the resolution to a process environment: the survivor is set (or left
55
+ * unset), the retired name is removed. Returns the warning to log once a
56
+ * logger exists. Called once, first thing in the hub's bootstrap, before any
57
+ * addon runner is forked.
58
+ */
59
+ function applyHubPublicUrl(env) {
60
+ const { url, warning } = resolveHubPublicUrl(env);
61
+ if (url === undefined)
62
+ delete env[exports.HUB_PUBLIC_URL_ENV];
63
+ else
64
+ env[exports.HUB_PUBLIC_URL_ENV] = url;
65
+ delete env[exports.RETIRED_PUBLIC_ORIGIN_ENV];
66
+ return warning;
67
+ }
package/dist/main.js CHANGED
@@ -59,43 +59,46 @@ const ws_2 = require("ws");
59
59
  const addon_upload_1 = require("./api/addon-upload");
60
60
  const auth_whoami_1 = require("./api/auth-whoami");
61
61
  const backup_download_js_1 = require("./api/backup-download.js");
62
- const health_routes_1 = require("./api/health/health.routes");
63
- const auth_rate_limit_1 = require("./auth/auth-rate-limit");
62
+ const cap_providers_1 = require("./api/core/cap-providers");
63
+ const logging_settings_1 = require("./api/core/logging-settings");
64
+ const request_census_settings_1 = require("./api/core/request-census-settings");
65
+ const register_cors_policy_1 = require("./api/cors/register-cors-policy");
64
66
  const adaptive_probe_routes_1 = require("./api/health/adaptive-probe.routes");
67
+ const health_routes_1 = require("./api/health/health.routes");
65
68
  const model_distributor_js_1 = require("./api/model-distributor.js");
66
69
  const oauth2_routes_js_1 = require("./api/oauth2/oauth2-routes.js");
67
70
  const server_upload_1 = require("./api/server-upload");
68
71
  const sse_no_compression_js_1 = require("./api/sse-no-compression.js");
72
+ const frame_policy_1 = require("./api/static/frame-policy");
69
73
  const precompressed_asset_js_1 = require("./api/static/precompressed-asset.js");
70
- const spa_static_1 = require("./api/static/spa-static");
71
74
  const spa_mount_1 = require("./api/static/spa-mount");
72
- const frame_policy_1 = require("./api/static/frame-policy");
73
- const register_cors_policy_1 = require("./api/cors/register-cors-policy");
74
- const cap_providers_1 = require("./api/core/cap-providers");
75
- const logging_settings_1 = require("./api/core/logging-settings");
76
- const request_census_settings_1 = require("./api/core/request-census-settings");
75
+ const spa_static_1 = require("./api/static/spa-static");
77
76
  const core_cap_bridge_1 = require("./api/trpc/core-cap-bridge");
78
- const http_request_census_hook_1 = require("./api/trpc/http-request-census-hook");
79
77
  const http_request_census_singleton_1 = require("./api/trpc/http-request-census.singleton");
78
+ const http_request_census_hook_1 = require("./api/trpc/http-request-census-hook");
80
79
  const trpc_context_1 = require("./api/trpc/trpc.context");
81
80
  const trpc_router_1 = require("./api/trpc/trpc.router");
82
- const trpc_error_principal_1 = require("./api/trpc/trpc-error-principal");
83
81
  const trpc_error_device_tags_1 = require("./api/trpc/trpc-error-device-tags");
82
+ const trpc_error_principal_1 = require("./api/trpc/trpc-error-principal");
84
83
  const ws_request_census_1 = require("./api/trpc/ws-request-census");
85
84
  const ws_slow_consumer_guard_js_1 = require("./api/trpc/ws-slow-consumer-guard.js");
86
85
  const addon_route_jwt_gate_js_1 = require("./auth/addon-route-jwt-gate.js");
87
86
  const addon_route_share_gate_js_1 = require("./auth/addon-route-share-gate.js");
87
+ const auth_rate_limit_1 = require("./auth/auth-rate-limit");
88
88
  const session_cookie_js_1 = require("./auth/session-cookie.js");
89
+ const session_mint_js_1 = require("./auth/session-mint.js");
90
+ const sso_finish_js_1 = require("./auth/sso-finish.js");
89
91
  const boot_config_1 = require("./boot/boot-config");
92
+ const public_url_1 = require("./boot/public-url");
90
93
  const integration_id_backfill_1 = require("./boot/integration-id-backfill");
91
94
  const post_boot_service_1 = require("./boot/post-boot.service");
92
95
  const addon_package_service_1 = require("./core/addon/addon-package.service");
93
- const well_known_app_links_1 = require("./core/app-links/well-known-app-links");
94
96
  const addon_registry_service_1 = require("./core/addon/addon-registry.service");
95
97
  const addon_bridge_service_1 = require("./core/addon-bridge/addon-bridge.service");
96
98
  const addon_pages_service_1 = require("./core/addon-pages/addon-pages.service");
97
99
  const addon_widgets_service_1 = require("./core/addon-widgets/addon-widgets.service");
98
100
  const agent_registry_service_1 = require("./core/agent/agent-registry.service");
101
+ const well_known_app_links_1 = require("./core/app-links/well-known-app-links");
99
102
  const auth_service_1 = require("./core/auth/auth.service");
100
103
  const handoff_code_service_1 = require("./core/auth/handoff-code.service");
101
104
  const share_token_service_1 = require("./core/auth/share-token.service");
@@ -238,6 +241,11 @@ async function sendMaybePrecompressed(reply, absolutePath, acceptEncoding) {
238
241
  }
239
242
  // ---- Bootstrap ----
240
243
  async function bootstrap() {
244
+ // ONE public-URL variable (CAMSTACK_HUB_PUBLIC_URL). The retired
245
+ // CAMSTACK_PUBLIC_ORIGIN is folded into it here — before any addon runner is
246
+ // forked, so every child inherits one name — and warned about once a logger
247
+ // exists (below). See `boot/public-url.ts`.
248
+ const publicUrlWarning = (0, public_url_1.applyHubPublicUrl)(process.env);
241
249
  // hub-main's own memory, on the record. Deliberately started HERE and not in
242
250
  // the starter: the starter runs from the image's /opt closure, which only
243
251
  // changes with a new image, while this entry is what `applyServerUpdate`
@@ -280,10 +288,8 @@ async function bootstrap() {
280
288
  // watch is the slope — 181-330MB/min steady, one burst of >=1.3GB in <=11s —
281
289
  // and the traffic behind it is ~22 000 socket reads/s and ~22 500 writes/s at
282
290
  // a 0.33KB mean. `LocalChildRegistry` has counted that per child since the
283
- // fan-out gate was built and NOTHING has ever read it; `getMoleculerEventStats`
284
- // likewise, while the hub runs `CAMSTACK_MOLECULER_EVENT_FANOUT=shadow` — a
285
- // burn-in with no end condition, because the evidence that would authorise the
286
- // flip to `filter` was never surfaced. This surfaces it and changes no default.
291
+ // fan-out gate was built and NOTHING had ever read it; `getMoleculerEventStats`
292
+ // likewise. This surfaces both.
287
293
  //
288
294
  // Every source is read LAZILY: this runs as the first statement of bootstrap()
289
295
  // and `MoleculerService` does not exist for hundreds of lines. Until it does,
@@ -297,7 +303,6 @@ async function bootstrap() {
297
303
  const broker = moleculerForEventPlane?.broker;
298
304
  return broker === undefined ? null : (0, system_1.getMoleculerEventStats)(broker);
299
305
  },
300
- crossNodeMode: () => (0, system_1.readMoleculerFanoutMode)(),
301
306
  }),
302
307
  // The fan-out counters above answered their own question and sharpened
303
308
  // this one: the event plane is 4% of this process's ~44 000 socket
@@ -353,10 +358,13 @@ async function bootstrap() {
353
358
  // (D707): the hub's own, the hosted viewer PWA, the native embed's opaque
354
359
  // `null`, and the operator's `server.corsOrigins`. First hook on the
355
360
  // instance so every route — and every OPTIONS — is judged.
361
+ if (publicUrlWarning !== undefined) {
362
+ app.get(logging_service_1.LoggingService).createLogger('public-url').warn(publicUrlWarning);
363
+ }
356
364
  const corsConfig = app.get(config_service_1.ConfigService);
357
365
  const corsPolicy = (0, register_cors_policy_1.createCorsPolicy)({
358
366
  configuredOrigins: () => corsConfig.get('server.corsOrigins'),
359
- publicOrigin: process.env.CAMSTACK_PUBLIC_ORIGIN,
367
+ publicOrigin: process.env[public_url_1.HUB_PUBLIC_URL_ENV],
360
368
  logger: app.get(logging_service_1.LoggingService).createLogger('cors'),
361
369
  });
362
370
  (0, register_cors_policy_1.registerCorsPolicy)(fastify, corsPolicy);
@@ -794,58 +802,25 @@ async function bootstrap() {
794
802
  return reply.send(fs.readFileSync(filePath));
795
803
  });
796
804
  // ── SSO finish endpoint ─────────────────────────────────────────
797
- // OIDC / SAML / external auth providers redirect here after their
798
- // own callback handler validated the IdP response. We trust the
799
- // claims because:
800
- // 1. The path is server-internal — providers run in-process and
801
- // issue the redirect with their own validated state.
802
- // 2. The query string is consumed once and immediately swapped
803
- // for a JWT in the URL fragment (#token=...) which never hits
804
- // the server logs and isn't replayable.
805
- //
806
- // The provider prefixes the redirect with `provider=auth-oidc` so
807
- // we can attribute the session. The mint is currently best-effort
808
- // — a future hardening should require the provider to also pass a
809
- // nonce signed with the auth.jwtSecret so we can verify the redirect
810
- // came from the addon's own callback handler.
805
+ // OIDC / magic-link redirect here with an HMAC-signed LOGIN bridge
806
+ // (`sso-bridge` cap). The bridge says who signed in; the session is
807
+ // minted by THE session mint from the user record as it is now (D712) —
808
+ // `auth/sso-finish.ts` holds the rules, this route only maps the reply.
809
+ const ssoAuth = app.get(auth_service_1.AuthService);
810
+ const ssoMinter = (0, session_mint_js_1.createSessionMinter)({
811
+ signer: ssoAuth,
812
+ directory: () => capabilityRegistry.getSingleton('user-management'),
813
+ });
811
814
  fastify.get('/api/auth/sso/finish', async (request, reply) => {
812
- // The auth-provider addon (OIDC, SAML, …) mints an HMAC-signed
813
- // bridge token via the `sso-bridge` cap and 302s to here with
814
- // `?bridge=<jwt>`. We verify the signature with the same JWT
815
- // secret used elsewhere — only then do we trust the claims
816
- // (including the `isAdmin` flag). This closes the prior gap
817
- // where any client could call `?isAdmin=1` and become admin.
818
- const bridge = request.query.bridge;
819
- if (!bridge) {
820
- return reply.status(400).send({ error: 'Missing bridge token' });
821
- }
822
- const ssoAuth = app.get(auth_service_1.AuthService);
823
- const claims = ssoAuth.verifySsoBridgeToken(bridge);
824
- if (!claims) {
825
- return reply.status(401).send({ error: 'Invalid or expired bridge token' });
826
- }
827
- try {
828
- const token = ssoAuth.signToken({
829
- userId: claims.userId,
830
- username: claims.username,
831
- isAdmin: claims.isAdmin,
832
- allowedProviders: '*',
833
- allowedDevices: {},
834
- });
835
- // Redirect to the admin UI with the token in the URL fragment.
836
- // Fragments don't hit the server log + don't get sent on
837
- // subsequent requests — the SPA's auth-context picks the
838
- // token up on mount and stores it in localStorage.
839
- reply.code(302);
840
- reply.header('Location', `/admin/login#token=${encodeURIComponent(token)}&provider=${encodeURIComponent(claims.provider)}`);
841
- return reply.send('');
842
- }
843
- catch (err) {
844
- return reply.status(500).send({
845
- error: 'SSO finish failed',
846
- message: err instanceof Error ? err.message : String(err),
847
- });
848
- }
815
+ const outcome = await (0, sso_finish_js_1.finishSsoLogin)(request.query.bridge, {
816
+ verifyBridge: (token) => ssoAuth.verifySsoBridgeToken(token),
817
+ minter: ssoMinter,
818
+ });
819
+ if (outcome.kind === 'error')
820
+ return reply.status(outcome.status).send(outcome.body);
821
+ reply.code(302);
822
+ reply.header('Location', outcome.location);
823
+ return reply.send('');
849
824
  });
850
825
  // ── Backup archive download ─────────────────────────────────────
851
826
  // Streams an archive through the storage cap's chunked-download
@@ -1141,7 +1116,7 @@ async function bootstrap() {
1141
1116
  (0, oauth2_routes_js_1.registerOauth2Routes)(fastify, {
1142
1117
  getRegistry: () => capabilityRegistry,
1143
1118
  verifyToken: (t) => authService.verifyToken(t),
1144
- publicHubUrl: () => process.env.CAMSTACK_PUBLIC_ORIGIN ?? `https://localhost:${port}`,
1119
+ publicHubUrl: () => process.env[public_url_1.HUB_PUBLIC_URL_ENV] ?? `https://localhost:${port}`,
1145
1120
  // Every /authorize refusal is a dropped account-linking attempt. Without
1146
1121
  // this scope the drop is invisible in Loki and the only signal left is an
1147
1122
  // operator noticing that linking stopped working.
@@ -1425,8 +1400,7 @@ async function bootstrap() {
1425
1400
  // node's registration must never win the election for the provider
1426
1401
  // whose staticDir this process reads from local disk.
1427
1402
  let viewerUI = capRegistry?.getSingletonForNode('viewer-ui', 'hub');
1428
- const skipViewerUIWait = process.env['CAMSTACK_SKIP_VIEWER_UI_WAIT'] === '1';
1429
- if (!viewerUI && capRegistry && !skipViewerUIWait) {
1403
+ if (!viewerUI && capRegistry) {
1430
1404
  // The viewer-ui addon runs in its own forked runner and registers a few
1431
1405
  // seconds after boot. Poll briefly (shorter than admin-ui's 60s — the
1432
1406
  // viewer is non-critical and admin-ui already gated the slow cold boot).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.456",
3
+ "version": "1.2.457",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "files": [
@@ -37,18 +37,18 @@
37
37
  ]
38
38
  },
39
39
  "dependencies": {
40
- "@camstack/addon-admin-ui": "1.2.330",
41
- "@camstack/addon-agent-ui": "1.2.174",
42
- "@camstack/addon-auth": "1.2.126",
40
+ "@camstack/addon-admin-ui": "1.2.331",
41
+ "@camstack/addon-agent-ui": "1.2.175",
42
+ "@camstack/addon-auth": "1.2.127",
43
43
  "@camstack/addon-decoder-nodeav": "1.2.119",
44
44
  "@camstack/addon-notifiers": "1.2.128",
45
- "@camstack/addon-pipeline": "1.2.327",
46
- "@camstack/addon-pipeline-orchestrator": "1.2.281",
47
- "@camstack/addon-post-analysis": "1.2.333",
45
+ "@camstack/addon-pipeline": "1.2.328",
46
+ "@camstack/addon-pipeline-orchestrator": "1.2.282",
47
+ "@camstack/addon-post-analysis": "1.2.334",
48
48
  "@camstack/sdk": "1.2.121",
49
- "@camstack/system": "1.2.354",
50
- "@camstack/types": "1.2.305",
51
- "@camstack/ui-library": "1.2.232",
49
+ "@camstack/system": "1.2.355",
50
+ "@camstack/types": "1.2.306",
51
+ "@camstack/ui-library": "1.2.233",
52
52
  "@fastify/compress": "^9.0.0",
53
53
  "@fastify/cookie": "^11.0.2",
54
54
  "@fastify/cors": "^11.2.0",