@camstack/server 1.2.455 → 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.
- package/THIRD_PARTY_MODELS.md +49 -43
- package/dist/agent/agent-config.js +1 -1
- package/dist/agent/main.js +1 -2
- package/dist/api/core/auth.router.js +15 -60
- package/dist/api/cors/cors-policy.js +193 -0
- package/dist/api/cors/register-cors-policy.js +109 -0
- package/dist/api/oauth2/private-host.js +1 -1
- package/dist/api/trpc/client-ip.js +3 -3
- package/dist/api/trpc/device-config-secret-redaction.js +1 -1
- package/dist/auth/session-mint.js +64 -0
- package/dist/auth/sso-finish.js +66 -0
- package/dist/boot/boot-config.js +0 -3
- package/dist/boot/public-url.js +67 -0
- package/dist/main.js +58 -70
- package/dist/manual-boot.js +0 -10
- package/package.json +12 -12
package/THIRD_PARTY_MODELS.md
CHANGED
|
@@ -30,13 +30,13 @@ under [Non-commercial models](#non-commercial-models).
|
|
|
30
30
|
|
|
31
31
|
## Required attributions
|
|
32
32
|
|
|
33
|
-
- © Ultralytics, AGPL-3.0. Corresponding source: the upstream Ultralytics checkpoint and
|
|
33
|
+
- © Ultralytics, AGPL-3.0. Corresponding source: the upstream Ultralytics checkpoint and build-camstack-models.py (with convert_lib.py) published at https://huggingface.co/camstack/camstack-models/tree/main/source. https://github.com/ultralytics/ultralytics
|
|
34
34
|
- Coral Edge TPU models and AIY Vision Birds V1 © Google LLC, Apache-2.0. https://github.com/google-coral/test_data
|
|
35
|
-
- © Ultralytics, AGPL-3.0 (Ultralytics YOLOv9 weights). YOLOv9 architecture: Chien-Yao Wang, I-Hau Yeh, Hong-Yuan Mark Liao, GPL-3.0 (https://github.com/WongKinYiu/yolov9). Corresponding source: the upstream Ultralytics checkpoint and
|
|
35
|
+
- © Ultralytics, AGPL-3.0 (Ultralytics YOLOv9 weights). YOLOv9 architecture: Chien-Yao Wang, I-Hau Yeh, Hong-Yuan Mark Liao, GPL-3.0 (https://github.com/WongKinYiu/yolov9). Corresponding source: the upstream Ultralytics checkpoint and build-camstack-models.py (with convert_lib.py) published at https://huggingface.co/camstack/camstack-models/tree/main/source. https://github.com/ultralytics/ultralytics
|
|
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
|
-
-
|
|
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
|
|
@@ -45,11 +45,10 @@ under [Non-commercial models](#non-commercial-models).
|
|
|
45
45
|
- YAMNet © Google LLC, Apache-2.0. AudioSet ontology and labels © Google LLC, CC BY 4.0. https://github.com/tensorflow/models/tree/master/research/audioset/yamnet
|
|
46
46
|
- Qwen models © Alibaba Cloud, Apache-2.0. https://huggingface.co/Qwen · https://huggingface.co/unsloth · https://huggingface.co/unsloth/Qwen3.6-35B-A3B-GGUF
|
|
47
47
|
- Built with Llama. Llama 3.2 is licensed under the Llama 3.2 Community License, Copyright © Meta Platforms, Inc. All Rights Reserved. https://huggingface.co/bartowski/Llama-3.2-3B-Instruct-GGUF
|
|
48
|
-
- Scrypted YOLOv9 ReLU detectors, Koushik Dutta (scrypted/plugin-models, repository tagged MIT); trained with the YOLOv9 code of Chien-Yao Wang, I-Hau Yeh, Hong-Yuan Mark Liao, GPL-3.0 (https://github.com/WongKinYiu/yolov9). https://huggingface.co/scrypted/plugin-models
|
|
49
48
|
|
|
50
49
|
Training data under attribution terms:
|
|
51
50
|
|
|
52
|
-
- **COCO 2017** (training data of `yolo26n` and
|
|
51
|
+
- **COCO 2017** (training data of `yolo26n` and 50 more): annotations CC-BY-4.0; images under their Flickr owners' terms. https://cocodataset.org/#termsofuse
|
|
53
52
|
- **WIDER FACE** (training data of `yunet-2023mar`): research-oriented terms; images CC BY-NC-ND. http://shuoyang1213.me/WIDERFACE/
|
|
54
53
|
- **Open Images V7** (training data of `animal-classifier`): annotations CC-BY-4.0 (Google LLC); images listed as CC-BY-2.0, no warranty. https://storage.googleapis.com/openimages/web/factsfigures_v7.html
|
|
55
54
|
- **DrBimmer/vehicle-classification** (training data of `vehicle-type-v1`): CC-BY-NC-4.0 — treated as binding the trained weights (conservative reading). https://huggingface.co/datasets/DrBimmer/vehicle-classification
|
|
@@ -63,32 +62,42 @@ Training data under attribution terms:
|
|
|
63
62
|
cards link here instead of repeating it.
|
|
64
63
|
-->
|
|
65
64
|
|
|
66
|
-
The folders `objectDetection/yolo26`, `objectDetection/yolov9
|
|
65
|
+
The folders `objectDetection/yolo26`, `objectDetection/yolov9` and `segmentation/yolo26-seg` of the model mirror hold Ultralytics weights,
|
|
67
66
|
modified by CamStack and distributed under AGPL-3.0. Their Corresponding Source
|
|
68
|
-
is the
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
`yolov9{t,s,m,c}.pt
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
67
|
+
is published in the mirror itself, in its **`source/`** folder,
|
|
68
|
+
**https://huggingface.co/camstack/camstack-models/tree/main/source**, together
|
|
69
|
+
with the upstream checkpoints it starts from:
|
|
70
|
+
|
|
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.
|
|
92
101
|
|
|
93
102
|
## Non-commercial models
|
|
94
103
|
|
|
@@ -137,7 +146,7 @@ entry kept resolvable for a saved selection but no longer offered.
|
|
|
137
146
|
|
|
138
147
|
| Catalog ids | Upstream | Code / weights | Training data | CamStack changes | Downloaded from |
|
|
139
148
|
| --- | --- | --- | --- | --- | --- |
|
|
140
|
-
| `
|
|
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` |
|
|
141
150
|
|
|
142
151
|
### Licence plate reading
|
|
143
152
|
|
|
@@ -155,7 +164,7 @@ entry kept resolvable for a saved selection but no longer offered.
|
|
|
155
164
|
|
|
156
165
|
| Catalog ids | Upstream | Code / weights | Training data | CamStack changes | Downloaded from |
|
|
157
166
|
| --- | --- | --- | --- | --- | --- |
|
|
158
|
-
| `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` |
|
|
159
168
|
|
|
160
169
|
### Vehicle classifier
|
|
161
170
|
|
|
@@ -205,16 +214,13 @@ entry kept resolvable for a saved selection but no longer offered.
|
|
|
205
214
|
| `llm-qwen3-vl-8b-instruct-q4` | [Qwen3-VL (GGUF by unsloth)](https://huggingface.co/unsloth) | — / Apache-2.0 (`Apache-2.0.txt`) | — | Q4_K_M GGUF quantisation by unsloth (not re-hosted by CamStack) | https://huggingface.co/unsloth/Qwen3-VL-8B-Instruct-GGUF |
|
|
206
215
|
| `llm-qwen3.6-35b-a3b-ud-q4` | [Qwen3.6-35B-A3B (GGUF by unsloth)](https://huggingface.co/unsloth/Qwen3.6-35B-A3B-GGUF) | — / Apache-2.0 (`Apache-2.0.txt`) | — | UD-Q4_K_M GGUF quantisation by unsloth (not re-hosted by CamStack) | https://huggingface.co/unsloth/Qwen3.6-35B-A3B-GGUF |
|
|
207
216
|
|
|
208
|
-
### Model Studio catalogs (object detection, imported on request)
|
|
209
|
-
|
|
210
|
-
| Catalog ids | Upstream | Code / weights | Training data | CamStack changes | Downloaded from |
|
|
211
|
-
| --- | --- | --- | --- | --- | --- |
|
|
212
|
-
| `scrypted-yolov9t-relu-320`, `scrypted-yolov9t-relu_test-320`, `scrypted-yolov9s-relu-320`, `scrypted-yolov9s-relu_test-320`, `scrypted-yolov9m-relu-320`, `scrypted-yolov9m-relu_test-320`, `scrypted-yolov9c-relu-320`, `scrypted-yolov9c-relu_test-320`, `scrypted-yolov9t-relu-int8-320`, `scrypted-yolov9t-relu_test-int8-320`, `scrypted-yolov9s-relu-int8-320`, `scrypted-yolov9s-relu_test-int8-320`, `scrypted-yolov9m-relu-int8-320`, `scrypted-yolov9m-relu_test-int8-320`, `scrypted-yolov9c-relu-int8-320`, `scrypted-yolov9c-relu_test-int8-320` | [Scrypted plugin-models (repository tagged MIT, no per-model terms)](https://huggingface.co/scrypted/plugin-models) | GPL-3.0 (`GPL-3.0.txt`) / **Unknown** | not stated by Scrypted: UNKNOWN | none: downloaded as published, at a pinned revision | https://huggingface.co/scrypted/plugin-models |
|
|
213
|
-
| `frigate-mobiledet-edgetpu` | [Google Coral test_data](https://github.com/google-coral/test_data) | Apache-2.0 / Apache-2.0 (`Apache-2.0.txt`) | COCO 2017: annotations CC-BY-4.0; images under their Flickr owners' terms | none | https://github.com/google-coral/test_data |
|
|
214
|
-
|
|
215
217
|
### Your own models
|
|
216
218
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
219
|
+
Model Studio can import third-party models (for example community
|
|
220
|
+
object-detection catalogs) on request. They are downloaded from their
|
|
221
|
+
publishers, are not hosted or redistributed by CamStack, and keep their
|
|
222
|
+
publishers' licences; Model Studio shows the licence where the source declares
|
|
223
|
+
one. A model you upload or convert
|
|
224
|
+
yourself, or pull from your own Frigate+ account, likewise keeps whatever
|
|
225
|
+
licence its author published. CamStack does not change it and does not record
|
|
226
|
+
it.
|
|
@@ -81,7 +81,7 @@ function ensurePersistedNodeId(configPath, dataDir) {
|
|
|
81
81
|
return id;
|
|
82
82
|
}
|
|
83
83
|
function loadAgentConfig(configPath, dataDirOverride) {
|
|
84
|
-
const filePath = configPath ??
|
|
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).
|
package/dist/agent/main.js
CHANGED
|
@@ -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).
|
|
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
|
-
*
|
|
335
|
-
* every successful login leg
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
const
|
|
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
|
|
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
|
-
//
|
|
473
|
-
|
|
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
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* cors-policy — which browser origins may READ this hub's responses (D707).
|
|
4
|
+
*
|
|
5
|
+
* The viewer PWA is hosted at {@link VIEWER_PWA_ORIGIN} and talks to each
|
|
6
|
+
* operator's own hub straight from the browser — no proxy, no cloud. Before
|
|
7
|
+
* D707 the hub answered every request with `Access-Control-Allow-Origin: *`,
|
|
8
|
+
* which let ANY web page a user visited read any hub response its script could
|
|
9
|
+
* reach (an unauthenticated probe of a LAN hub, for one). The policy is now an
|
|
10
|
+
* allowlist:
|
|
11
|
+
*
|
|
12
|
+
* - the hub's OWN origin — the `Origin` whose host is the request's `Host`
|
|
13
|
+
* (or `X-Forwarded-Host`, which a relay such as the Home Assistant panel
|
|
14
|
+
* proxy sets to ITS origin). Same-origin calls never needed CORS; this
|
|
15
|
+
* entry exists for the WebSocket check, where a browser always sends
|
|
16
|
+
* `Origin` and the native clients send their target's;
|
|
17
|
+
* - {@link VIEWER_PWA_ORIGIN}, the hosted viewer;
|
|
18
|
+
* - {@link OPAQUE_ORIGIN} (`null`): the native app's OTA-bundled embed is a
|
|
19
|
+
* `file://` page inside a WKWebView, whose origin is opaque. Removing it
|
|
20
|
+
* breaks live view on iOS;
|
|
21
|
+
* - `CAMSTACK_HUB_PUBLIC_URL`, when set, and the operator's extras
|
|
22
|
+
* (`server.corsOrigins` in config.yaml / `CAMSTACK_CORS_ORIGINS`).
|
|
23
|
+
*
|
|
24
|
+
* The answer to an allowed origin REFLECTS it exactly — never `*` — and never
|
|
25
|
+
* carries `Access-Control-Allow-Credentials`. The cross-origin viewer
|
|
26
|
+
* authenticates with a bearer token in `Authorization` (or tRPC
|
|
27
|
+
* `connectionParams` on the socket), which needs no credentials mode; the
|
|
28
|
+
* httpOnly session cookie therefore never rides a cross-origin request, so the
|
|
29
|
+
* cookie-gated surfaces (`auth.exchangeSession`, `/api/embed-auth`, the
|
|
30
|
+
* admin-ui) stay same-origin by construction.
|
|
31
|
+
*
|
|
32
|
+
* Pure: no Fastify, no I/O — `register-cors-policy.ts` is the glue.
|
|
33
|
+
*/
|
|
34
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
35
|
+
exports.CORS_PREFLIGHT_MAX_AGE_SEC = exports.CORS_EXPOSED_HEADERS = exports.CORS_ALLOWED_HEADERS = exports.CORS_ALLOWED_METHODS = exports.OPAQUE_ORIGIN = exports.VIEWER_PWA_ORIGIN = void 0;
|
|
36
|
+
exports.normalizeOrigin = normalizeOrigin;
|
|
37
|
+
exports.parseConfiguredOrigins = parseConfiguredOrigins;
|
|
38
|
+
exports.builtInOrigins = builtInOrigins;
|
|
39
|
+
exports.judgeCorsOrigin = judgeCorsOrigin;
|
|
40
|
+
exports.corsResponseHeaders = corsResponseHeaders;
|
|
41
|
+
exports.appendVary = appendVary;
|
|
42
|
+
/** The hosted viewer PWA (operator decision 2026-10-06). */
|
|
43
|
+
exports.VIEWER_PWA_ORIGIN = 'https://camstack.zentik.app';
|
|
44
|
+
/**
|
|
45
|
+
* The serialisation of an opaque origin. The native app's OTA embed
|
|
46
|
+
* (`assets/embed/camstack-embed.html`, loaded from `file://`) sends it.
|
|
47
|
+
*/
|
|
48
|
+
exports.OPAQUE_ORIGIN = 'null';
|
|
49
|
+
/** Every method a hub route answers. */
|
|
50
|
+
exports.CORS_ALLOWED_METHODS = 'GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS';
|
|
51
|
+
/**
|
|
52
|
+
* Request headers a cross-origin caller may send. `authorization` is the
|
|
53
|
+
* whole point; `range` is recorded-media seeking; the `trpc-*` / `x-trpc-*`
|
|
54
|
+
* names are what `@trpc/client` links may add. NOT `x-camstack-exchange`:
|
|
55
|
+
* the cookie→bearer exchange is a same-origin mechanism and a cross-origin
|
|
56
|
+
* page has no business asking for it.
|
|
57
|
+
*/
|
|
58
|
+
exports.CORS_ALLOWED_HEADERS = 'authorization, content-type, range, if-range, if-none-match, cache-control, trpc-accept, x-trpc-source, x-trpc-batch-mode';
|
|
59
|
+
/**
|
|
60
|
+
* Response headers a cross-origin script may read. The byte-range trio is
|
|
61
|
+
* recorded playback (a seek reads `content-range`); `content-disposition` is
|
|
62
|
+
* export downloads; `retry-after` is the rate limiter; the `x-camstack-*`
|
|
63
|
+
* trio is the clip-still contract (a 204 that says WHY there is no image).
|
|
64
|
+
*/
|
|
65
|
+
exports.CORS_EXPOSED_HEADERS = 'content-range, accept-ranges, content-length, content-disposition, etag, retry-after, x-camstack-reason, x-camstack-reason-code, x-camstack-disposition';
|
|
66
|
+
/** Preflight cache. Chromium caps at 7200 s; Firefox honours up to 86400. */
|
|
67
|
+
exports.CORS_PREFLIGHT_MAX_AGE_SEC = 7200;
|
|
68
|
+
/**
|
|
69
|
+
* The canonical serialisation of an origin (`scheme://host[:port]`, lower
|
|
70
|
+
* case, default port dropped), or `null` when `raw` is not an http(s) origin.
|
|
71
|
+
* `null` (the opaque origin) is accepted verbatim.
|
|
72
|
+
*/
|
|
73
|
+
function normalizeOrigin(raw) {
|
|
74
|
+
const trimmed = raw.trim();
|
|
75
|
+
if (trimmed === exports.OPAQUE_ORIGIN)
|
|
76
|
+
return exports.OPAQUE_ORIGIN;
|
|
77
|
+
let url;
|
|
78
|
+
try {
|
|
79
|
+
url = new URL(trimmed);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:')
|
|
85
|
+
return null;
|
|
86
|
+
// An origin has no path, query, fragment or credentials. Accept a bare
|
|
87
|
+
// trailing slash (a common paste), refuse anything that names more.
|
|
88
|
+
if (url.username !== '' || url.password !== '')
|
|
89
|
+
return null;
|
|
90
|
+
if ((url.pathname !== '/' && url.pathname !== '') || url.search !== '' || url.hash !== '') {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
return url.origin;
|
|
94
|
+
}
|
|
95
|
+
/** Read the configured value (array from YAML, already split from env). */
|
|
96
|
+
function parseConfiguredOrigins(value) {
|
|
97
|
+
if (!Array.isArray(value))
|
|
98
|
+
return { origins: [], rejected: [] };
|
|
99
|
+
const origins = [];
|
|
100
|
+
const rejected = [];
|
|
101
|
+
for (const entry of value) {
|
|
102
|
+
if (typeof entry !== 'string') {
|
|
103
|
+
rejected.push(String(entry));
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const normalized = normalizeOrigin(entry);
|
|
107
|
+
if (normalized === null)
|
|
108
|
+
rejected.push(entry);
|
|
109
|
+
else
|
|
110
|
+
origins.push(normalized);
|
|
111
|
+
}
|
|
112
|
+
return { origins, rejected };
|
|
113
|
+
}
|
|
114
|
+
/** The built-in entries plus the optional public origin. */
|
|
115
|
+
function builtInOrigins(publicOrigin) {
|
|
116
|
+
const entries = [exports.VIEWER_PWA_ORIGIN, exports.OPAQUE_ORIGIN];
|
|
117
|
+
const pub = publicOrigin !== undefined ? normalizeOrigin(publicOrigin) : null;
|
|
118
|
+
if (pub !== null && pub !== exports.OPAQUE_ORIGIN)
|
|
119
|
+
entries.push(pub);
|
|
120
|
+
return entries;
|
|
121
|
+
}
|
|
122
|
+
/** The first value of a possibly comma-joined forwarding header, lower case. */
|
|
123
|
+
function firstHost(value) {
|
|
124
|
+
if (value === undefined)
|
|
125
|
+
return null;
|
|
126
|
+
const first = value.split(',')[0]?.trim().toLowerCase() ?? '';
|
|
127
|
+
return first.length > 0 ? first : null;
|
|
128
|
+
}
|
|
129
|
+
/** `host[:port]` of an http(s) origin, or null. */
|
|
130
|
+
function hostOfOrigin(origin) {
|
|
131
|
+
if (origin === exports.OPAQUE_ORIGIN)
|
|
132
|
+
return null;
|
|
133
|
+
try {
|
|
134
|
+
return new URL(origin).host.toLowerCase();
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Judge one request's `Origin`. `listed` is the full allowlist (built-ins and
|
|
142
|
+
* the operator's extras), already normalised.
|
|
143
|
+
*/
|
|
144
|
+
function judgeCorsOrigin(facts, listed) {
|
|
145
|
+
const raw = facts.origin;
|
|
146
|
+
if (raw === undefined || raw.length === 0)
|
|
147
|
+
return { kind: 'no-origin' };
|
|
148
|
+
const normalized = normalizeOrigin(raw);
|
|
149
|
+
if (normalized === null)
|
|
150
|
+
return { kind: 'denied', origin: raw };
|
|
151
|
+
if (listed.has(normalized))
|
|
152
|
+
return { kind: 'allowed', origin: raw, reason: 'listed' };
|
|
153
|
+
const originHost = hostOfOrigin(normalized);
|
|
154
|
+
if (originHost !== null) {
|
|
155
|
+
const ownHosts = [firstHost(facts.host), firstHost(facts.forwardedHost)];
|
|
156
|
+
if (ownHosts.includes(originHost))
|
|
157
|
+
return { kind: 'allowed', origin: raw, reason: 'own-host' };
|
|
158
|
+
}
|
|
159
|
+
return { kind: 'denied', origin: raw };
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The headers an ALLOWED origin gets. `preflight` adds the method/header
|
|
163
|
+
* grants and the cache lifetime. The origin is the exact one the browser
|
|
164
|
+
* sent — reflecting it is what lets the browser hand the response to the
|
|
165
|
+
* page, and `Vary: Origin` (set by the glue on every response) keeps a cache
|
|
166
|
+
* from serving one origin's answer to another.
|
|
167
|
+
*/
|
|
168
|
+
function corsResponseHeaders(origin, preflight) {
|
|
169
|
+
const base = {
|
|
170
|
+
'access-control-allow-origin': origin,
|
|
171
|
+
'access-control-expose-headers': exports.CORS_EXPOSED_HEADERS,
|
|
172
|
+
};
|
|
173
|
+
if (preflight === null)
|
|
174
|
+
return base;
|
|
175
|
+
return {
|
|
176
|
+
...base,
|
|
177
|
+
'access-control-allow-methods': exports.CORS_ALLOWED_METHODS,
|
|
178
|
+
'access-control-allow-headers': exports.CORS_ALLOWED_HEADERS,
|
|
179
|
+
'access-control-max-age': String(exports.CORS_PREFLIGHT_MAX_AGE_SEC),
|
|
180
|
+
// A public origin (the PWA) calling a LAN hub is a "private network"
|
|
181
|
+
// request to Chromium; its preflight must be answered explicitly.
|
|
182
|
+
...(preflight.requestPrivateNetwork ? { 'access-control-allow-private-network': 'true' } : {}),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/** Append `token` to a `Vary` value unless it is already there. */
|
|
186
|
+
function appendVary(existing, token) {
|
|
187
|
+
if (existing === undefined || existing.trim().length === 0)
|
|
188
|
+
return token;
|
|
189
|
+
const parts = existing.split(',').map((p) => p.trim().toLowerCase());
|
|
190
|
+
if (parts.includes('*') || parts.includes(token.toLowerCase()))
|
|
191
|
+
return existing;
|
|
192
|
+
return `${existing}, ${token}`;
|
|
193
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createCorsPolicy = createCorsPolicy;
|
|
4
|
+
exports.registerCorsPolicy = registerCorsPolicy;
|
|
5
|
+
exports.refuseForeignWsUpgrade = refuseForeignWsUpgrade;
|
|
6
|
+
const cors_policy_js_1 = require("./cors-policy.js");
|
|
7
|
+
/** Distinct refused origins remembered for log de-duplication. */
|
|
8
|
+
const MAX_LOGGED_DENIALS = 256;
|
|
9
|
+
function headerValue(value) {
|
|
10
|
+
if (Array.isArray(value))
|
|
11
|
+
return value[0];
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
function varyText(value) {
|
|
15
|
+
if (typeof value === 'string')
|
|
16
|
+
return value;
|
|
17
|
+
if (Array.isArray(value))
|
|
18
|
+
return value.filter((v) => typeof v === 'string').join(', ');
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
function createCorsPolicy(deps) {
|
|
22
|
+
const builtIns = (0, cors_policy_js_1.builtInOrigins)(deps.publicOrigin);
|
|
23
|
+
let lastConfigured = Symbol('unset');
|
|
24
|
+
let listed = new Set(builtIns);
|
|
25
|
+
const loggedDenials = new Set();
|
|
26
|
+
const currentList = () => {
|
|
27
|
+
const configured = deps.configuredOrigins();
|
|
28
|
+
if (configured === lastConfigured)
|
|
29
|
+
return listed;
|
|
30
|
+
lastConfigured = configured;
|
|
31
|
+
const parsed = (0, cors_policy_js_1.parseConfiguredOrigins)(configured);
|
|
32
|
+
if (parsed.rejected.length > 0) {
|
|
33
|
+
deps.logger.warn('cors: ignored configured origins that are not http(s) origins', {
|
|
34
|
+
meta: { rejected: parsed.rejected, setting: 'server.corsOrigins' },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
listed = new Set([...builtIns, ...parsed.origins]);
|
|
38
|
+
return listed;
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
judge(headers) {
|
|
42
|
+
const verdict = (0, cors_policy_js_1.judgeCorsOrigin)({
|
|
43
|
+
origin: headerValue(headers.origin),
|
|
44
|
+
host: headerValue(headers.host),
|
|
45
|
+
forwardedHost: headerValue(headers['x-forwarded-host']),
|
|
46
|
+
}, currentList());
|
|
47
|
+
if (verdict.kind === 'denied' && !loggedDenials.has(verdict.origin)) {
|
|
48
|
+
if (loggedDenials.size < MAX_LOGGED_DENIALS)
|
|
49
|
+
loggedDenials.add(verdict.origin);
|
|
50
|
+
deps.logger.warn('cors: refused a cross-origin caller not on the allowlist', {
|
|
51
|
+
meta: {
|
|
52
|
+
origin: verdict.origin,
|
|
53
|
+
host: headerValue(headers.host) ?? null,
|
|
54
|
+
hint: 'add it to server.corsOrigins (config.yaml) or CAMSTACK_CORS_ORIGINS',
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return verdict;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Install the policy on the hub's Fastify instance. Every `OPTIONS` is
|
|
64
|
+
* answered here with 204 (as before D707) — with grants only for an allowed
|
|
65
|
+
* origin, so a refused preflight fails in the browser.
|
|
66
|
+
*/
|
|
67
|
+
function registerCorsPolicy(fastify, policy) {
|
|
68
|
+
fastify.addHook('onRequest', async (request, reply) => {
|
|
69
|
+
// The answer depends on Origin for every route, so every response says so
|
|
70
|
+
// — a cached same-origin answer must never be replayed to another origin.
|
|
71
|
+
const vary = (0, cors_policy_js_1.appendVary)(varyText(reply.getHeader('vary')), 'Origin');
|
|
72
|
+
reply.header('vary', vary);
|
|
73
|
+
reply.raw.setHeader('vary', vary);
|
|
74
|
+
const verdict = policy.judge(request.headers);
|
|
75
|
+
const isPreflight = request.method === 'OPTIONS' && request.headers['access-control-request-method'] !== undefined;
|
|
76
|
+
if (verdict.kind === 'allowed') {
|
|
77
|
+
const headers = (0, cors_policy_js_1.corsResponseHeaders)(verdict.origin, isPreflight
|
|
78
|
+
? {
|
|
79
|
+
requestPrivateNetwork: headerValue(request.headers['access-control-request-private-network']) === 'true',
|
|
80
|
+
}
|
|
81
|
+
: null);
|
|
82
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
83
|
+
reply.header(name, value);
|
|
84
|
+
// Hijacked replies (the data-plane proxy) skip Fastify's header
|
|
85
|
+
// write; Node merges these into their `writeHead`.
|
|
86
|
+
reply.raw.setHeader(name, value);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (request.method === 'OPTIONS') {
|
|
90
|
+
return reply.code(204).send();
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Refuse a WebSocket upgrade whose `Origin` is not on the allowlist. Returns
|
|
97
|
+
* `true` when it refused (the socket is answered 403 and destroyed); the
|
|
98
|
+
* caller must then do nothing else with it. A request with no `Origin` (a
|
|
99
|
+
* Node client, an agent) passes — Origin is the browser's signal, and auth is
|
|
100
|
+
* the bearer in `connectionParams` either way.
|
|
101
|
+
*/
|
|
102
|
+
function refuseForeignWsUpgrade(policy, headers, socket) {
|
|
103
|
+
const verdict = policy.judge(headers);
|
|
104
|
+
if (verdict.kind !== 'denied')
|
|
105
|
+
return false;
|
|
106
|
+
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
|
|
107
|
+
socket.destroy();
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Used by `/api/oauth2/authorize` to admit a `redirect_uri` whose host the hub
|
|
6
6
|
* cannot know in advance — a self-hosted Home Assistant at
|
|
7
|
-
* `http://192.168.
|
|
7
|
+
* `http://192.168.10.40:8123/auth/external/callback`. Pairing "private host" with
|
|
8
8
|
* "exact path" is what keeps that from being a wildcard prefix: a public host
|
|
9
9
|
* never satisfies it, so the worst an attacker can aim a code at is a machine
|
|
10
10
|
* they already need LAN presence to read.
|
|
@@ -101,7 +101,7 @@ function extractUserAgent(req) {
|
|
|
101
101
|
return null;
|
|
102
102
|
}
|
|
103
103
|
/**
|
|
104
|
-
* Strip an IPv4-mapped IPv6 prefix (`::ffff:192.168.
|
|
104
|
+
* Strip an IPv4-mapped IPv6 prefix (`::ffff:192.168.10.5` → `192.168.10.5`)
|
|
105
105
|
* and any zone id (`fe80::1%en0` → `fe80::1`) so the range checks below
|
|
106
106
|
* see a clean address.
|
|
107
107
|
*/
|
|
@@ -216,13 +216,13 @@ function isPrivateIpv4(ip) {
|
|
|
216
216
|
if (a === undefined || b === undefined)
|
|
217
217
|
return false;
|
|
218
218
|
if (a === 10)
|
|
219
|
-
return true; // 10.
|
|
219
|
+
return true; // 10.0.0.0/8
|
|
220
220
|
if (a === 127)
|
|
221
221
|
return true; // 127.0.0.0/8 loopback
|
|
222
222
|
if (a === 172 && b >= 16 && b <= 31)
|
|
223
223
|
return true; // 172.16.0.0/12
|
|
224
224
|
if (a === 192 && b === 168)
|
|
225
|
-
return true; // 192.168.
|
|
225
|
+
return true; // 192.168.0.0/16
|
|
226
226
|
if (a === 169 && b === 254)
|
|
227
227
|
return true; // 169.254.0.0/16 link-local
|
|
228
228
|
return false;
|
|
@@ -347,7 +347,7 @@ function redactConfigEntries(data) {
|
|
|
347
347
|
*
|
|
348
348
|
* The stream surfaces (`cameraStreams.getRtspEntries`,
|
|
349
349
|
* `streamBroker.getRtspEntry`, `deviceOps.getStreamSources`, …) hand a
|
|
350
|
-
* non-admin caller `rtsp://admin:hunter2@192.168.
|
|
350
|
+
* non-admin caller `rtsp://admin:hunter2@192.168.10.139/…` verbatim: a
|
|
351
351
|
* read-only token can open every camera by copy-pasting the string. These
|
|
352
352
|
* are NOT config blobs, so {@link redactRecord} never sees them — the leak
|
|
353
353
|
* lives in a plain `url` / `mutedUrl` string field.
|
|
@@ -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
|
+
}
|
package/dist/boot/boot-config.js
CHANGED
|
@@ -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,42 +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
|
|
63
|
-
const
|
|
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
|
|
73
|
-
const cap_providers_1 = require("./api/core/cap-providers");
|
|
74
|
-
const logging_settings_1 = require("./api/core/logging-settings");
|
|
75
|
-
const request_census_settings_1 = require("./api/core/request-census-settings");
|
|
75
|
+
const spa_static_1 = require("./api/static/spa-static");
|
|
76
76
|
const core_cap_bridge_1 = require("./api/trpc/core-cap-bridge");
|
|
77
|
-
const http_request_census_hook_1 = require("./api/trpc/http-request-census-hook");
|
|
78
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");
|
|
79
79
|
const trpc_context_1 = require("./api/trpc/trpc.context");
|
|
80
80
|
const trpc_router_1 = require("./api/trpc/trpc.router");
|
|
81
|
-
const trpc_error_principal_1 = require("./api/trpc/trpc-error-principal");
|
|
82
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");
|
|
83
83
|
const ws_request_census_1 = require("./api/trpc/ws-request-census");
|
|
84
84
|
const ws_slow_consumer_guard_js_1 = require("./api/trpc/ws-slow-consumer-guard.js");
|
|
85
85
|
const addon_route_jwt_gate_js_1 = require("./auth/addon-route-jwt-gate.js");
|
|
86
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");
|
|
87
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");
|
|
88
91
|
const boot_config_1 = require("./boot/boot-config");
|
|
92
|
+
const public_url_1 = require("./boot/public-url");
|
|
89
93
|
const integration_id_backfill_1 = require("./boot/integration-id-backfill");
|
|
90
94
|
const post_boot_service_1 = require("./boot/post-boot.service");
|
|
91
95
|
const addon_package_service_1 = require("./core/addon/addon-package.service");
|
|
92
|
-
const well_known_app_links_1 = require("./core/app-links/well-known-app-links");
|
|
93
96
|
const addon_registry_service_1 = require("./core/addon/addon-registry.service");
|
|
94
97
|
const addon_bridge_service_1 = require("./core/addon-bridge/addon-bridge.service");
|
|
95
98
|
const addon_pages_service_1 = require("./core/addon-pages/addon-pages.service");
|
|
96
99
|
const addon_widgets_service_1 = require("./core/addon-widgets/addon-widgets.service");
|
|
97
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");
|
|
98
102
|
const auth_service_1 = require("./core/auth/auth.service");
|
|
99
103
|
const handoff_code_service_1 = require("./core/auth/handoff-code.service");
|
|
100
104
|
const share_token_service_1 = require("./core/auth/share-token.service");
|
|
@@ -237,6 +241,11 @@ async function sendMaybePrecompressed(reply, absolutePath, acceptEncoding) {
|
|
|
237
241
|
}
|
|
238
242
|
// ---- Bootstrap ----
|
|
239
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);
|
|
240
249
|
// hub-main's own memory, on the record. Deliberately started HERE and not in
|
|
241
250
|
// the starter: the starter runs from the image's /opt closure, which only
|
|
242
251
|
// changes with a new image, while this entry is what `applyServerUpdate`
|
|
@@ -279,10 +288,8 @@ async function bootstrap() {
|
|
|
279
288
|
// watch is the slope — 181-330MB/min steady, one burst of >=1.3GB in <=11s —
|
|
280
289
|
// and the traffic behind it is ~22 000 socket reads/s and ~22 500 writes/s at
|
|
281
290
|
// a 0.33KB mean. `LocalChildRegistry` has counted that per child since the
|
|
282
|
-
// fan-out gate was built and NOTHING
|
|
283
|
-
// likewise
|
|
284
|
-
// burn-in with no end condition, because the evidence that would authorise the
|
|
285
|
-
// 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.
|
|
286
293
|
//
|
|
287
294
|
// Every source is read LAZILY: this runs as the first statement of bootstrap()
|
|
288
295
|
// and `MoleculerService` does not exist for hundreds of lines. Until it does,
|
|
@@ -296,7 +303,6 @@ async function bootstrap() {
|
|
|
296
303
|
const broker = moleculerForEventPlane?.broker;
|
|
297
304
|
return broker === undefined ? null : (0, system_1.getMoleculerEventStats)(broker);
|
|
298
305
|
},
|
|
299
|
-
crossNodeMode: () => (0, system_1.readMoleculerFanoutMode)(),
|
|
300
306
|
}),
|
|
301
307
|
// The fan-out counters above answered their own question and sharpened
|
|
302
308
|
// this one: the event plane is 4% of this process's ~44 000 socket
|
|
@@ -347,8 +353,21 @@ async function bootstrap() {
|
|
|
347
353
|
const fastifyOpts = tlsOptions ? { https: tlsOptions } : {};
|
|
348
354
|
const app = await (0, manual_boot_1.bootManual)({ infra, fastifyOpts });
|
|
349
355
|
app.enableShutdownHooks();
|
|
350
|
-
app.enableCors();
|
|
351
356
|
const fastify = app.getHttpAdapter().getInstance();
|
|
357
|
+
// Which browser origins may read hub responses, and open the /trpc socket
|
|
358
|
+
// (D707): the hub's own, the hosted viewer PWA, the native embed's opaque
|
|
359
|
+
// `null`, and the operator's `server.corsOrigins`. First hook on the
|
|
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
|
+
}
|
|
364
|
+
const corsConfig = app.get(config_service_1.ConfigService);
|
|
365
|
+
const corsPolicy = (0, register_cors_policy_1.createCorsPolicy)({
|
|
366
|
+
configuredOrigins: () => corsConfig.get('server.corsOrigins'),
|
|
367
|
+
publicOrigin: process.env[public_url_1.HUB_PUBLIC_URL_ENV],
|
|
368
|
+
logger: app.get(logging_service_1.LoggingService).createLogger('cors'),
|
|
369
|
+
});
|
|
370
|
+
(0, register_cors_policy_1.registerCorsPolicy)(fastify, corsPolicy);
|
|
352
371
|
await fastify.register(cookie_1.default);
|
|
353
372
|
// Every HTML document the hub serves may be framed by its own origin only
|
|
354
373
|
// (D425). Stamped here, on the one instance, so no shell-send site added
|
|
@@ -783,58 +802,25 @@ async function bootstrap() {
|
|
|
783
802
|
return reply.send(fs.readFileSync(filePath));
|
|
784
803
|
});
|
|
785
804
|
// ── SSO finish endpoint ─────────────────────────────────────────
|
|
786
|
-
// OIDC /
|
|
787
|
-
//
|
|
788
|
-
//
|
|
789
|
-
//
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
// The provider prefixes the redirect with `provider=auth-oidc` so
|
|
796
|
-
// we can attribute the session. The mint is currently best-effort
|
|
797
|
-
// — a future hardening should require the provider to also pass a
|
|
798
|
-
// nonce signed with the auth.jwtSecret so we can verify the redirect
|
|
799
|
-
// 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
|
+
});
|
|
800
814
|
fastify.get('/api/auth/sso/finish', async (request, reply) => {
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
}
|
|
811
|
-
const ssoAuth = app.get(auth_service_1.AuthService);
|
|
812
|
-
const claims = ssoAuth.verifySsoBridgeToken(bridge);
|
|
813
|
-
if (!claims) {
|
|
814
|
-
return reply.status(401).send({ error: 'Invalid or expired bridge token' });
|
|
815
|
-
}
|
|
816
|
-
try {
|
|
817
|
-
const token = ssoAuth.signToken({
|
|
818
|
-
userId: claims.userId,
|
|
819
|
-
username: claims.username,
|
|
820
|
-
isAdmin: claims.isAdmin,
|
|
821
|
-
allowedProviders: '*',
|
|
822
|
-
allowedDevices: {},
|
|
823
|
-
});
|
|
824
|
-
// Redirect to the admin UI with the token in the URL fragment.
|
|
825
|
-
// Fragments don't hit the server log + don't get sent on
|
|
826
|
-
// subsequent requests — the SPA's auth-context picks the
|
|
827
|
-
// token up on mount and stores it in localStorage.
|
|
828
|
-
reply.code(302);
|
|
829
|
-
reply.header('Location', `/admin/login#token=${encodeURIComponent(token)}&provider=${encodeURIComponent(claims.provider)}`);
|
|
830
|
-
return reply.send('');
|
|
831
|
-
}
|
|
832
|
-
catch (err) {
|
|
833
|
-
return reply.status(500).send({
|
|
834
|
-
error: 'SSO finish failed',
|
|
835
|
-
message: err instanceof Error ? err.message : String(err),
|
|
836
|
-
});
|
|
837
|
-
}
|
|
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('');
|
|
838
824
|
});
|
|
839
825
|
// ── Backup archive download ─────────────────────────────────────
|
|
840
826
|
// Streams an archive through the storage cap's chunked-download
|
|
@@ -1130,7 +1116,7 @@ async function bootstrap() {
|
|
|
1130
1116
|
(0, oauth2_routes_js_1.registerOauth2Routes)(fastify, {
|
|
1131
1117
|
getRegistry: () => capabilityRegistry,
|
|
1132
1118
|
verifyToken: (t) => authService.verifyToken(t),
|
|
1133
|
-
publicHubUrl: () => process.env.
|
|
1119
|
+
publicHubUrl: () => process.env[public_url_1.HUB_PUBLIC_URL_ENV] ?? `https://localhost:${port}`,
|
|
1134
1120
|
// Every /authorize refusal is a dropped account-linking attempt. Without
|
|
1135
1121
|
// this scope the drop is invisible in Loki and the only signal left is an
|
|
1136
1122
|
// operator noticing that linking stopped working.
|
|
@@ -1239,6 +1225,9 @@ async function bootstrap() {
|
|
|
1239
1225
|
httpServer.on('upgrade', (request, socket, head) => {
|
|
1240
1226
|
const pathname = (request.url ?? '').split('?')[0];
|
|
1241
1227
|
if (pathname === '/trpc') {
|
|
1228
|
+
// CORS does not cover a WebSocket: refuse a foreign page here (D707).
|
|
1229
|
+
if ((0, register_cors_policy_1.refuseForeignWsUpgrade)(corsPolicy, request.headers, socket))
|
|
1230
|
+
return;
|
|
1242
1231
|
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
1243
1232
|
wss.emit('connection', ws, request);
|
|
1244
1233
|
});
|
|
@@ -1411,8 +1400,7 @@ async function bootstrap() {
|
|
|
1411
1400
|
// node's registration must never win the election for the provider
|
|
1412
1401
|
// whose staticDir this process reads from local disk.
|
|
1413
1402
|
let viewerUI = capRegistry?.getSingletonForNode('viewer-ui', 'hub');
|
|
1414
|
-
|
|
1415
|
-
if (!viewerUI && capRegistry && !skipViewerUIWait) {
|
|
1403
|
+
if (!viewerUI && capRegistry) {
|
|
1416
1404
|
// The viewer-ui addon runs in its own forked runner and registers a few
|
|
1417
1405
|
// seconds after boot. Poll briefly (shorter than admin-ui's 60s — the
|
|
1418
1406
|
// viewer is non-critical and admin-ui already gated the slow cold boot).
|
package/dist/manual-boot.js
CHANGED
|
@@ -394,16 +394,6 @@ async function bootManual(opts) {
|
|
|
394
394
|
getHttpAdapter() {
|
|
395
395
|
return { getInstance: () => fastify };
|
|
396
396
|
},
|
|
397
|
-
enableCors() {
|
|
398
|
-
fastify.addHook('onRequest', async (request, reply) => {
|
|
399
|
-
reply.header('access-control-allow-origin', '*');
|
|
400
|
-
reply.header('access-control-allow-methods', 'GET,POST,PUT,DELETE,OPTIONS,PATCH');
|
|
401
|
-
reply.header('access-control-allow-headers', 'content-type,authorization');
|
|
402
|
-
if (request.method === 'OPTIONS') {
|
|
403
|
-
reply.status(204).send();
|
|
404
|
-
}
|
|
405
|
-
});
|
|
406
|
-
},
|
|
407
397
|
enableShutdownHooks() {
|
|
408
398
|
if (shutdownHooksRegistered)
|
|
409
399
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/server",
|
|
3
|
-
"version": "1.2.
|
|
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.
|
|
41
|
-
"@camstack/addon-agent-ui": "1.2.
|
|
42
|
-
"@camstack/addon-auth": "1.2.
|
|
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
|
-
"@camstack/addon-notifiers": "1.2.
|
|
45
|
-
"@camstack/addon-pipeline": "1.2.
|
|
46
|
-
"@camstack/addon-pipeline-orchestrator": "1.2.
|
|
47
|
-
"@camstack/addon-post-analysis": "1.2.
|
|
48
|
-
"@camstack/sdk": "1.2.
|
|
49
|
-
"@camstack/system": "1.2.
|
|
50
|
-
"@camstack/types": "1.2.
|
|
51
|
-
"@camstack/ui-library": "1.2.
|
|
44
|
+
"@camstack/addon-notifiers": "1.2.128",
|
|
45
|
+
"@camstack/addon-pipeline": "1.2.328",
|
|
46
|
+
"@camstack/addon-pipeline-orchestrator": "1.2.282",
|
|
47
|
+
"@camstack/addon-post-analysis": "1.2.334",
|
|
48
|
+
"@camstack/sdk": "1.2.121",
|
|
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",
|