@camstack/server 1.1.54 → 1.1.56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/addon-upload.js +14 -124
- package/dist/api/core/auth.router.js +38 -8
- package/dist/api/server-upload.js +188 -0
- package/dist/api/static/spa-static.js +23 -0
- package/dist/api/tarball-manifest.js +86 -0
- package/dist/api/trpc/generated-cap-routers.js +9 -0
- package/dist/api/trpc/share-view-access.js +30 -0
- package/dist/api/trpc/trpc.middleware.js +10 -0
- package/dist/api/upload-auth.js +69 -0
- package/dist/auth/auth-rate-limit.js +76 -0
- package/dist/main.js +28 -22
- package/dist/server-root/index.js +264 -61
- package/package.json +7 -7
|
@@ -104,6 +104,16 @@ exports.protectedProcedure = t.procedure.use(async ({ ctx, next, path, getRawInp
|
|
|
104
104
|
if (!result.ok) {
|
|
105
105
|
throw new server_1.TRPCError({ code: 'FORBIDDEN', message: result.reason });
|
|
106
106
|
}
|
|
107
|
+
// `deviceManager.listAll` is allowlisted for the embed's tile names,
|
|
108
|
+
// but its raw rows describe EVERY device (config incl. credentials).
|
|
109
|
+
// Project the output down to the in-scope whitelisted rows.
|
|
110
|
+
if (path === 'deviceManager.listAll') {
|
|
111
|
+
const out = await next({ ctx: { ...ctx, user: ctx.user } });
|
|
112
|
+
if (out.ok) {
|
|
113
|
+
return { ...out, data: (0, share_view_access_js_1.projectListAllForShareScope)(out.data, ctx.user.shareView.scope) };
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
107
117
|
return next({ ctx: { ...ctx, user: ctx.user } });
|
|
108
118
|
}
|
|
109
119
|
// Spread+reassign of `user` narrows downstream ctx from `User | null`
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.UPLOAD_TRPC_PATH = void 0;
|
|
4
|
+
exports.authorizeUploadRequest = authorizeUploadRequest;
|
|
5
|
+
const scope_access_js_1 = require("./trpc/scope-access.js");
|
|
6
|
+
exports.UPLOAD_TRPC_PATH = 'addons.installPackage';
|
|
7
|
+
/**
|
|
8
|
+
* Validate a `cst_*` scoped token via the `user-management` cap singleton.
|
|
9
|
+
* Returns null when the singleton isn't mounted yet (boot race) or the
|
|
10
|
+
* token doesn't validate. Caller treats both as auth failure.
|
|
11
|
+
*/
|
|
12
|
+
async function validateScopedTokenViaCap(addonRegistry, token) {
|
|
13
|
+
const capRegistry = addonRegistry.getCapabilityRegistry();
|
|
14
|
+
// Documented type boundary: the registry returns the provider untyped; the
|
|
15
|
+
// `user-management` cap's `validateScopedToken` surface is asserted here
|
|
16
|
+
// (same shape the tRPC middleware relies on).
|
|
17
|
+
const userMgmt = capRegistry.getSingleton('user-management');
|
|
18
|
+
if (!userMgmt)
|
|
19
|
+
return null;
|
|
20
|
+
return userMgmt.validateScopedToken({ token });
|
|
21
|
+
}
|
|
22
|
+
function isUploadAllowed(scoped) {
|
|
23
|
+
return (0, scope_access_js_1.checkScopeAccess)(scoped.scopes, exports.UPLOAD_TRPC_PATH).ok;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Run the full upload auth chain. Returns `{ok: true}` on success, or the
|
|
27
|
+
* HTTP status + error message the route should reply with:
|
|
28
|
+
* - 401 `Unauthorized` when no Authorization header is present,
|
|
29
|
+
* - 403 `Forbidden: <reason>` for any recognised-but-insufficient token.
|
|
30
|
+
*/
|
|
31
|
+
async function authorizeUploadRequest(args) {
|
|
32
|
+
const { authHeader, authService, addonRegistry } = args;
|
|
33
|
+
if (!authHeader) {
|
|
34
|
+
return { ok: false, status: 401, error: 'Unauthorized' };
|
|
35
|
+
}
|
|
36
|
+
const token = authHeader.replace('Bearer ', '');
|
|
37
|
+
let authReason;
|
|
38
|
+
// Try JWT first — fastest path + carries the isAdmin flag directly.
|
|
39
|
+
try {
|
|
40
|
+
const payload = authService.verifyToken(token);
|
|
41
|
+
if (payload.isAdmin) {
|
|
42
|
+
return { ok: true };
|
|
43
|
+
}
|
|
44
|
+
authReason = 'JWT is not admin';
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Not a JWT (or invalid signature) — fall through to scoped-token path.
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const record = await validateScopedTokenViaCap(addonRegistry, token);
|
|
51
|
+
if (!record) {
|
|
52
|
+
authReason = authReason ?? 'token not recognised';
|
|
53
|
+
}
|
|
54
|
+
else if (isUploadAllowed(record)) {
|
|
55
|
+
return { ok: true };
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
authReason = `scoped token lacks create access on '${exports.UPLOAD_TRPC_PATH}'`;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
authReason = `scoped token validation failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
status: 403,
|
|
67
|
+
error: `Forbidden: ${authReason ?? 'admin or upload-scoped token required'}`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Public-auth rate limiting — fixed window per (bucket, client ip).
|
|
4
|
+
*
|
|
5
|
+
* The public auth surface (login, TOTP verify, passkey ceremonies,
|
|
6
|
+
* handoff redeem, session exchange/upgrade) previously had NO
|
|
7
|
+
* throttling: unlimited online credential guessing. This module is the
|
|
8
|
+
* first line of defence — deliberately simple (in-memory fixed window),
|
|
9
|
+
* because the deployment is a single hub process and the goal is to
|
|
10
|
+
* blunt brute force, not to be a distributed quota system.
|
|
11
|
+
*
|
|
12
|
+
* Pure + clock-injectable so specs drive it directly. The key map is
|
|
13
|
+
* BOUNDED (`maxKeys`, oldest-window evicted first) so an attacker
|
|
14
|
+
* rotating spoofed source addresses cannot grow it without limit.
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.createRateLimiter = createRateLimiter;
|
|
18
|
+
exports.clientKeyFromRequest = clientKeyFromRequest;
|
|
19
|
+
function createRateLimiter(options) {
|
|
20
|
+
const maxKeys = options.maxKeys ?? 10_000;
|
|
21
|
+
const now = options.now ?? Date.now;
|
|
22
|
+
const entries = new Map();
|
|
23
|
+
const evictOldest = () => {
|
|
24
|
+
let oldestKey = null;
|
|
25
|
+
let oldestStart = Number.POSITIVE_INFINITY;
|
|
26
|
+
for (const [key, entry] of entries) {
|
|
27
|
+
if (entry.windowStart < oldestStart) {
|
|
28
|
+
oldestStart = entry.windowStart;
|
|
29
|
+
oldestKey = key;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (oldestKey !== null)
|
|
33
|
+
entries.delete(oldestKey);
|
|
34
|
+
};
|
|
35
|
+
return {
|
|
36
|
+
check(key) {
|
|
37
|
+
const at = now();
|
|
38
|
+
const existing = entries.get(key);
|
|
39
|
+
if (!existing || at - existing.windowStart >= options.windowMs) {
|
|
40
|
+
if (!entries.has(key) && entries.size >= maxKeys)
|
|
41
|
+
evictOldest();
|
|
42
|
+
entries.set(key, { windowStart: at, count: 1 });
|
|
43
|
+
return { allowed: true, retryAfterSeconds: 0 };
|
|
44
|
+
}
|
|
45
|
+
if (existing.count < options.max) {
|
|
46
|
+
existing.count += 1;
|
|
47
|
+
return { allowed: true, retryAfterSeconds: 0 };
|
|
48
|
+
}
|
|
49
|
+
const retryAfterSeconds = Math.max(1, Math.ceil((existing.windowStart + options.windowMs - at) / 1000));
|
|
50
|
+
return { allowed: false, retryAfterSeconds };
|
|
51
|
+
},
|
|
52
|
+
size() {
|
|
53
|
+
return entries.size;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Client key for a tRPC/Fastify request principal: `FastifyRequest.ip`
|
|
59
|
+
* (proxy-aware when Fastify's `trustProxy` is on) or the raw socket
|
|
60
|
+
* address for WS-upgrade `IncomingMessage`s. `null` = no originating
|
|
61
|
+
* request (mesh-internal `createCaller` invocation) — never limited.
|
|
62
|
+
*/
|
|
63
|
+
function clientKeyFromRequest(req) {
|
|
64
|
+
if (req === null || typeof req !== 'object' || req === undefined)
|
|
65
|
+
return null;
|
|
66
|
+
const ip = Reflect.get(req, 'ip');
|
|
67
|
+
if (typeof ip === 'string' && ip.length > 0)
|
|
68
|
+
return ip;
|
|
69
|
+
const socket = Reflect.get(req, 'socket');
|
|
70
|
+
if (socket !== null && typeof socket === 'object') {
|
|
71
|
+
const remote = Reflect.get(socket, 'remoteAddress');
|
|
72
|
+
if (typeof remote === 'string' && remote.length > 0)
|
|
73
|
+
return remote;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -80,6 +80,7 @@ const trpc_router_1 = require("./api/trpc/trpc.router");
|
|
|
80
80
|
const core_cap_bridge_1 = require("./api/trpc/core-cap-bridge");
|
|
81
81
|
const trpc_context_1 = require("./api/trpc/trpc.context");
|
|
82
82
|
const addon_upload_1 = require("./api/addon-upload");
|
|
83
|
+
const server_upload_1 = require("./api/server-upload");
|
|
83
84
|
const auth_whoami_1 = require("./api/auth-whoami");
|
|
84
85
|
const session_cookie_js_1 = require("./auth/session-cookie.js");
|
|
85
86
|
const health_routes_1 = require("./api/health/health.routes");
|
|
@@ -231,6 +232,15 @@ async function bootstrap() {
|
|
|
231
232
|
const uploadLogger = app.get(logging_service_1.LoggingService).createLogger('addon-upload');
|
|
232
233
|
await (0, addon_upload_1.registerAddonUploadRoute)(fastify, uploadAddonBridge, uploadAuthService, uploadMoleculer, uploadAddonRegistry, uploadAddonPackage, uploadLogger);
|
|
233
234
|
console.log('[bootstrap] Addon upload route registered at POST /api/addons/upload');
|
|
235
|
+
// Dev server-deploy channel gateway (`camstack deploy-server`). MUST be
|
|
236
|
+
// registered AFTER registerAddonUploadRoute — it reuses the multipart
|
|
237
|
+
// plugin that route installs on this instance.
|
|
238
|
+
(0, server_upload_1.registerServerUploadRoute)(fastify, {
|
|
239
|
+
authService: uploadAuthService,
|
|
240
|
+
addonRegistry: uploadAddonRegistry,
|
|
241
|
+
logger: app.get(logging_service_1.LoggingService).createLogger('server-upload'),
|
|
242
|
+
});
|
|
243
|
+
console.log('[bootstrap] Server upload route registered at POST /api/server-upload');
|
|
234
244
|
// Companion endpoint: /api/auth/whoami — validates JWT or cst_*
|
|
235
245
|
// scoped tokens, returns the resolved identity + scope summary.
|
|
236
246
|
// Mirrors the addon-upload auth chain so the CLI can ping for
|
|
@@ -435,7 +445,7 @@ async function bootstrap() {
|
|
|
435
445
|
: 'application/octet-stream';
|
|
436
446
|
const stream = fs.createReadStream(resolved);
|
|
437
447
|
return reply
|
|
438
|
-
.header('cache-control', addonBundleCacheControl(resolved))
|
|
448
|
+
.header('cache-control', (0, spa_static_1.addonBundleCacheControl)(resolved))
|
|
439
449
|
.type(contentType)
|
|
440
450
|
.send(stream);
|
|
441
451
|
});
|
|
@@ -467,7 +477,7 @@ async function bootstrap() {
|
|
|
467
477
|
: 'application/octet-stream';
|
|
468
478
|
const stream = fs.createReadStream(resolved);
|
|
469
479
|
return reply
|
|
470
|
-
.header('cache-control', addonBundleCacheControl(resolved))
|
|
480
|
+
.header('cache-control', (0, spa_static_1.addonBundleCacheControl)(resolved))
|
|
471
481
|
.type(contentType)
|
|
472
482
|
.send(stream);
|
|
473
483
|
});
|
|
@@ -941,7 +951,16 @@ async function bootstrap() {
|
|
|
941
951
|
try {
|
|
942
952
|
const bootAddonRegistry = app.get(addon_registry_service_1.AddonRegistryService);
|
|
943
953
|
const capRegistry = bootAddonRegistry.getCapabilityRegistry();
|
|
944
|
-
|
|
954
|
+
// HUB-NODE-SCOPED resolution — never the cluster-global singleton.
|
|
955
|
+
// Agent nodes register the same `admin-ui` cap through their own
|
|
956
|
+
// agent-ui addon (placement: agent-only), and the cluster-elected
|
|
957
|
+
// active provider can land on the AGENT's registration depending on
|
|
958
|
+
// node boot order. Its `getStaticDir()` then answers with a path on
|
|
959
|
+
// the agent's filesystem — nonexistent here — and the SPA catch-all
|
|
960
|
+
// silently never registers (every `GET /` 404s until the next
|
|
961
|
+
// restart re-rolls the election). `getSingletonForNode(cap, 'hub')`
|
|
962
|
+
// only ever resolves providers hosted on THIS node.
|
|
963
|
+
let adminUI = capRegistry?.getSingletonForNode('admin-ui', 'hub');
|
|
945
964
|
// CAMSTACK_SKIP_ADMIN_UI_WAIT — bypass the 60s poll. Used by the
|
|
946
965
|
// e2e harness, which doesn't need the SPA served and spawns hubs
|
|
947
966
|
// with strict boot timeouts. Production keeps the poll so cold
|
|
@@ -959,7 +978,7 @@ async function bootstrap() {
|
|
|
959
978
|
const deadline = Date.now() + ADMIN_UI_WAIT_MS;
|
|
960
979
|
while (!adminUI && Date.now() < deadline) {
|
|
961
980
|
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
962
|
-
adminUI = capRegistry.
|
|
981
|
+
adminUI = capRegistry.getSingletonForNode('admin-ui', 'hub');
|
|
963
982
|
}
|
|
964
983
|
}
|
|
965
984
|
if (adminUI) {
|
|
@@ -1055,7 +1074,10 @@ async function bootstrap() {
|
|
|
1055
1074
|
try {
|
|
1056
1075
|
const bootAddonRegistry = app.get(addon_registry_service_1.AddonRegistryService);
|
|
1057
1076
|
const capRegistry = bootAddonRegistry.getCapabilityRegistry();
|
|
1058
|
-
|
|
1077
|
+
// Hub-node-scoped for the same reason as admin-ui above: a remote
|
|
1078
|
+
// node's registration must never win the election for the provider
|
|
1079
|
+
// whose staticDir this process reads from local disk.
|
|
1080
|
+
let viewerUI = capRegistry?.getSingletonForNode('viewer-ui', 'hub');
|
|
1059
1081
|
const skipViewerUIWait = process.env['CAMSTACK_SKIP_VIEWER_UI_WAIT'] === '1';
|
|
1060
1082
|
if (!viewerUI && capRegistry && !skipViewerUIWait) {
|
|
1061
1083
|
// The viewer-ui addon runs in its own forked runner and registers a few
|
|
@@ -1066,7 +1088,7 @@ async function bootstrap() {
|
|
|
1066
1088
|
const deadline = Date.now() + VIEWER_UI_WAIT_MS;
|
|
1067
1089
|
while (!viewerUI && Date.now() < deadline) {
|
|
1068
1090
|
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
1069
|
-
viewerUI = capRegistry.
|
|
1091
|
+
viewerUI = capRegistry.getSingletonForNode('viewer-ui', 'hub');
|
|
1070
1092
|
}
|
|
1071
1093
|
}
|
|
1072
1094
|
if (viewerUI) {
|
|
@@ -1211,22 +1233,6 @@ function readHubVersion() {
|
|
|
1211
1233
|
/**
|
|
1212
1234
|
* Build an AddonHttpReply wrapper around a Fastify reply.
|
|
1213
1235
|
*/
|
|
1214
|
-
/**
|
|
1215
|
-
* Cache-Control for an addon MF bundle file. The entry point
|
|
1216
|
-
* (`remoteEntry.js` / `widgets.mjs`) has a STABLE filename — only its
|
|
1217
|
-
* `?v=` query changes across builds — so it MUST revalidate, otherwise a
|
|
1218
|
-
* caching proxy (Cloudflare edge, whose default browser-cache TTL was
|
|
1219
|
-
* serving these 4h) keeps handing out a stale entry whose export map
|
|
1220
|
-
* lacks the current widgets ("bundle does not export this stableId",
|
|
1221
|
-
* tunnel-only). The sibling chunks are CONTENT-HASHED in their filenames
|
|
1222
|
-
* (`dist-<hash>.mjs`, `_virtual_mf-<hash>.mjs`), so they are safely
|
|
1223
|
-
* immutable. The revalidation cost is a cheap 304 on a ~4KB entry.
|
|
1224
|
-
*/
|
|
1225
|
-
function addonBundleCacheControl(filePath) {
|
|
1226
|
-
const base = path.basename(filePath);
|
|
1227
|
-
const isMfEntry = base === 'remoteEntry.js' || base === 'widgets.mjs';
|
|
1228
|
-
return isMfEntry ? 'no-cache, must-revalidate' : 'public, max-age=31536000, immutable';
|
|
1229
|
-
}
|
|
1230
1236
|
function buildAddonReply(reply) {
|
|
1231
1237
|
const wrapper = {
|
|
1232
1238
|
status(code) {
|