@camstack/server 1.2.604 → 1.2.606
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/auth-whoami.js +27 -43
- package/dist/api/core/auth-audit.procedures.js +44 -0
- package/dist/api/core/auth-device-cert.procedures.js +1 -1
- package/dist/api/core/auth-reauth.procedures.js +1 -1
- package/dist/api/core/auth-revoke-credentials.procedures.js +25 -52
- package/dist/api/core/auth-sessions.procedures.js +1 -1
- package/dist/api/core/auth-tokens.procedures.js +283 -0
- package/dist/api/core/auth.router.js +20 -203
- package/dist/api/core/live-events.router.js +1 -1
- package/dist/api/core/preferences.router.js +98 -0
- package/dist/api/oauth2/oauth2-routes.js +40 -15
- package/dist/api/trpc/device-config-secret-redaction.js +2 -2
- package/dist/api/trpc/generated-cap-mounts.js +0 -1
- package/dist/api/trpc/generated-cap-routers.js +2 -38
- package/dist/api/trpc/principal-authorization.js +82 -0
- package/dist/api/trpc/principal-visibility.js +3 -3
- package/dist/api/trpc/share-view-access.js +1 -1
- package/dist/api/trpc/trpc-error-principal.js +9 -9
- package/dist/api/trpc/trpc.context.js +87 -258
- package/dist/api/trpc/trpc.middleware.js +42 -83
- package/dist/api/trpc/trpc.router.js +7 -1
- package/dist/api/upload-auth.js +19 -54
- package/dist/auth/addon-route-auth.js +44 -0
- package/dist/auth/addon-route-share-gate.js +18 -16
- package/dist/auth/credential-verifier.js +236 -0
- package/dist/auth/embed-auth-route.js +45 -0
- package/dist/auth/live-user.js +19 -0
- package/dist/auth/scope-escalation.js +133 -0
- package/dist/boot/boot-config.js +8 -1
- package/dist/core/addon/addon-registry.service.js +18 -7
- package/dist/core/auth/credential-token.service.js +245 -0
- package/dist/core/auth/handoff-code.service.js +1 -1
- package/dist/core/auth/record-kinds.js +38 -2
- package/dist/main.js +65 -141
- package/package.json +14 -14
- package/dist/api/core/auth-admin-tokens.procedures.js +0 -138
- package/dist/auth/addon-route-jwt-gate.js +0 -59
- package/dist/core/auth/share-token.service.js +0 -459
|
@@ -10,7 +10,7 @@ exports.scopedEventVisibility = scopedEventVisibility;
|
|
|
10
10
|
* Non-admin principal device visibility — the shared answer to "which cameras
|
|
11
11
|
* may this scoped caller see?", reused by two hotfixes:
|
|
12
12
|
*
|
|
13
|
-
* - F0.4 `auth.
|
|
13
|
+
* - F0.4 `auth.mintToken` (a share-view grant) — a scoped user must not mint a share link
|
|
14
14
|
* share token for a device it cannot see. The requested deviceIds are
|
|
15
15
|
* INTERSECTED with what the caller can view.
|
|
16
16
|
* - F0.5 the event firehose (`live.onEvent`, `systemEvents.subscribe`, …) —
|
|
@@ -141,13 +141,13 @@ function projectListAllForPrincipal(data, isAdmin, scopes, fleet) {
|
|
|
141
141
|
* view; an event with NO device identity is DROPPED (fail closed — "probably in
|
|
142
142
|
* scope" is not a security argument).
|
|
143
143
|
*
|
|
144
|
-
* Share-view
|
|
144
|
+
* Share-view principals are NOT handled here — they keep their own,
|
|
145
145
|
* stricter `liveEventInShareScope` filter, applied by the routers first.
|
|
146
146
|
*/
|
|
147
147
|
function scopedEventVisibility(user, lookup) {
|
|
148
148
|
if (user.isAdmin)
|
|
149
149
|
return null;
|
|
150
|
-
const scopes = user.scopes ?? [];
|
|
150
|
+
const scopes = (0, types_1.capabilityScopesOf)(user.scopes ?? []);
|
|
151
151
|
if (isBroadEventViewer(scopes))
|
|
152
152
|
return null;
|
|
153
153
|
return (evt) => {
|
|
@@ -181,7 +181,7 @@ function projectListAllForShareScope(data, scope) {
|
|
|
181
181
|
* The projection ENGINE: drop every `listAll` row whose id is not in
|
|
182
182
|
* `allowed`, and project the survivors onto the whitelisted row.
|
|
183
183
|
*
|
|
184
|
-
* Shared by the
|
|
184
|
+
* Shared by the share-link path ({@link projectListAllForShareScope}) and
|
|
185
185
|
* the scoped-principal path (`projectListAllForPrincipal` in
|
|
186
186
|
* `principal-visibility.ts`) so enumeration is cut ONE way. Two projectors
|
|
187
187
|
* would be two chances to forget `config` — which carries the camera
|
|
@@ -29,12 +29,13 @@ function readCredentialIdentity(claims) {
|
|
|
29
29
|
/**
|
|
30
30
|
* Render a principal for one log line.
|
|
31
31
|
*
|
|
32
|
-
* Shapes, in the order they are distinguished
|
|
32
|
+
* Shapes, in the order they are distinguished (the label comes from the
|
|
33
|
+
* principal's audit tag — attribution only, D892):
|
|
33
34
|
*
|
|
34
35
|
* anonymous
|
|
35
36
|
* apocaliss92 (admin)
|
|
36
|
-
*
|
|
37
|
-
*
|
|
37
|
+
* token:ci (token)
|
|
38
|
+
* token:ops (token admin)
|
|
38
39
|
* apocaliss92 (oauth-access session=910a6179-…)
|
|
39
40
|
* apocaliss92 (session)
|
|
40
41
|
*/
|
|
@@ -44,15 +45,14 @@ function describeTrpcPrincipal(user) {
|
|
|
44
45
|
if (!user)
|
|
45
46
|
return 'anonymous';
|
|
46
47
|
const name = user.username || user.id || 'unknown';
|
|
47
|
-
if (user.
|
|
48
|
-
return `${name} (share-view)`;
|
|
49
|
-
if (user.isAdmin)
|
|
48
|
+
if (user.auditTag === 'session' && user.isAdmin)
|
|
50
49
|
return `${name} (admin)`;
|
|
51
50
|
if (user.credential) {
|
|
52
51
|
const session = user.oauthSessionId ? ` session=${user.oauthSessionId}` : '';
|
|
53
52
|
return `${name} (${user.credential}${session})`;
|
|
54
53
|
}
|
|
55
|
-
if (user.
|
|
56
|
-
return `${name} (
|
|
57
|
-
|
|
54
|
+
if (user.auditTag !== undefined) {
|
|
55
|
+
return `${name} (${user.auditTag}${user.isAdmin && user.auditTag !== 'session' ? ' admin' : ''})`;
|
|
56
|
+
}
|
|
57
|
+
return user.isAdmin ? `${name} (admin)` : `${name} (session)`;
|
|
58
58
|
}
|
|
@@ -1,56 +1,65 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.agentFromPrincipal = agentFromPrincipal;
|
|
4
|
+
exports.bindHubCredentialStores = bindHubCredentialStores;
|
|
5
|
+
exports.hubCredentialVerifier = hubCredentialVerifier;
|
|
6
|
+
exports.requestFacts = requestFacts;
|
|
3
7
|
exports.createMeshTrpcContext = createMeshTrpcContext;
|
|
4
8
|
exports.createTrpcContext = createTrpcContext;
|
|
5
9
|
exports.createWsTrpcContext = createWsTrpcContext;
|
|
6
10
|
const system_1 = require("@camstack/system");
|
|
7
|
-
const types_1 = require("@camstack/types");
|
|
8
11
|
const auth_network_policy_js_1 = require("../../auth/auth-network-policy.js");
|
|
9
|
-
const
|
|
12
|
+
const credential_verifier_js_1 = require("../../auth/credential-verifier.js");
|
|
10
13
|
const request_lang_js_1 = require("../../i18n/request-lang.js");
|
|
11
|
-
|
|
14
|
+
/** The tRPC principal a verified credential becomes. */
|
|
15
|
+
function agentFromPrincipal(principal) {
|
|
16
|
+
return {
|
|
17
|
+
id: principal.subjectId,
|
|
18
|
+
username: principal.username,
|
|
19
|
+
isAdmin: principal.isAdmin,
|
|
20
|
+
permissions: { isAdmin: principal.isAdmin, allowedProviders: '*', allowedDevices: {} },
|
|
21
|
+
isApiKey: !principal.interactive,
|
|
22
|
+
scopes: principal.scopes,
|
|
23
|
+
credentialId: principal.credentialId,
|
|
24
|
+
auditTag: principal.auditTag,
|
|
25
|
+
interactive: principal.interactive,
|
|
26
|
+
userDelegated: principal.userDelegated,
|
|
27
|
+
...(principal.sessionId !== undefined ? { sessionId: principal.sessionId } : {}),
|
|
28
|
+
...(principal.oauthSessionId !== undefined ? { oauthSessionId: principal.oauthSessionId } : {}),
|
|
29
|
+
...(principal.credential !== undefined ? { credential: principal.credential } : {}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
12
32
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* The JWT embeds scopes at login, so an admin's scope edit used to reach a
|
|
16
|
-
* live session only at the next login — a user granted cameras while logged
|
|
17
|
-
* in saw NONE until they signed out and back in (2026-08-15, user report).
|
|
18
|
-
* The store is therefore re-read per user, but memoized: a scope change
|
|
19
|
-
* propagates within {@link SCOPE_REFRESH_TTL_MS} and a busy session costs at
|
|
20
|
-
* most one store read per TTL window, not one per request.
|
|
33
|
+
* The live-scope memo of non-admin sessions, shared by every verifier this
|
|
34
|
+
* process builds (a request builds one; the memo must outlive it).
|
|
21
35
|
*/
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (now - entry.at > SCOPE_REFRESH_TTL_MS * 4)
|
|
50
|
-
liveScopeMemo.delete(key);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
return scopes;
|
|
36
|
+
const hubScopeMemo = new Map();
|
|
37
|
+
/** The user-list memo, shared the same way (every token verify asks "does its subject exist?"). */
|
|
38
|
+
const hubUsersMemo = { entry: null };
|
|
39
|
+
const hubStores = { tokenRows: null };
|
|
40
|
+
function bindHubCredentialStores(stores) {
|
|
41
|
+
if (stores.tokenRows !== undefined)
|
|
42
|
+
hubStores.tokenRows = stores.tokenRows;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The hub's verifier over its own stores — the same one for tRPC and the
|
|
46
|
+
* addon routes. Documented type boundary: the registry hands the
|
|
47
|
+
* `user-management` provider back untyped; the one method the verifier reads
|
|
48
|
+
* (`listUsers`) is asserted here.
|
|
49
|
+
*/
|
|
50
|
+
function hubCredentialVerifier(deps) {
|
|
51
|
+
const provider = () => deps.addonRegistry.getCapabilityRegistry().getSingleton('user-management');
|
|
52
|
+
return (0, credential_verifier_js_1.createCredentialVerifier)({
|
|
53
|
+
verifyJwt: (token) => deps.authService.verifyToken(token),
|
|
54
|
+
verifyCredentialJwt: (token) => deps.authService.verifyCredentialToken(token),
|
|
55
|
+
users: () => provider() ?? null,
|
|
56
|
+
internal: () => (0, system_1.getUserManagementInternal)(provider()),
|
|
57
|
+
tokenRows: () => deps.tokenRows ?? hubStores.tokenRows,
|
|
58
|
+
logger: deps.logger ?? null,
|
|
59
|
+
scopeMemo: hubScopeMemo,
|
|
60
|
+
usersMemo: hubUsersMemo,
|
|
61
|
+
...(deps.deviceScopeLookup !== undefined ? { deviceScopeLookup: deps.deviceScopeLookup } : {}),
|
|
62
|
+
});
|
|
54
63
|
}
|
|
55
64
|
/** Read `req.query` if present (Fastify-only) without losing type safety. */
|
|
56
65
|
function readQuery(req) {
|
|
@@ -62,7 +71,7 @@ function readQuery(req) {
|
|
|
62
71
|
return { ...q };
|
|
63
72
|
}
|
|
64
73
|
/**
|
|
65
|
-
* Extract a
|
|
74
|
+
* Extract a bearer from an HTTP request.
|
|
66
75
|
* Priority: Authorization header → Fastify req.query → URL query string
|
|
67
76
|
*/
|
|
68
77
|
function extractTokenFromRequest(req) {
|
|
@@ -89,6 +98,7 @@ function extractTokenFromRequest(req) {
|
|
|
89
98
|
}
|
|
90
99
|
return null;
|
|
91
100
|
}
|
|
101
|
+
/** What the audit of an admin-capable token's use records about the request. */
|
|
92
102
|
function requestFacts(req) {
|
|
93
103
|
if (req === undefined)
|
|
94
104
|
return {};
|
|
@@ -97,203 +107,9 @@ function requestFacts(req) {
|
|
|
97
107
|
const path = typeof url === 'string' ? url.split('?')[0] : undefined;
|
|
98
108
|
return { ...(path !== undefined ? { path } : {}), ...(ip !== null ? { ip } : {}) };
|
|
99
109
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
* an admin, not revoked, not expired) is local-auth's (`AdminTokenManager`).
|
|
104
|
-
*/
|
|
105
|
-
async function resolveAdminToken(token, addonRegistry, facts) {
|
|
106
|
-
const internal = (0, system_1.getUserManagementInternal)(addonRegistry.getCapabilityRegistry().getSingleton('user-management'));
|
|
107
|
-
if (internal === null)
|
|
108
|
-
return null;
|
|
109
|
-
const record = await internal.adminTokens.validate(token).catch(() => null);
|
|
110
|
-
if (record === null)
|
|
111
|
-
return null;
|
|
112
|
-
void internal.audit.record({
|
|
113
|
-
action: 'admin-token-used',
|
|
114
|
-
userId: record.createdBy,
|
|
115
|
-
...(facts.ip !== undefined ? { ip: facts.ip } : {}),
|
|
116
|
-
detail: {
|
|
117
|
-
tokenId: record.id,
|
|
118
|
-
name: record.name,
|
|
119
|
-
prefix: record.tokenPrefix,
|
|
120
|
-
...(facts.path !== undefined ? { path: facts.path } : {}),
|
|
121
|
-
},
|
|
122
|
-
});
|
|
123
|
-
return {
|
|
124
|
-
id: record.createdBy,
|
|
125
|
-
username: `admin-token:${record.name}`,
|
|
126
|
-
isAdmin: true,
|
|
127
|
-
permissions: { isAdmin: true, allowedProviders: '*', allowedDevices: {} },
|
|
128
|
-
isApiKey: true,
|
|
129
|
-
credential: 'admin-token',
|
|
130
|
-
adminTokenId: record.id,
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
async function resolveUser(token, authService, addonRegistry, shareTokens = null, logger = null, facts = {}) {
|
|
134
|
-
if (!token)
|
|
135
|
-
return null;
|
|
136
|
-
if (token.startsWith(system_1.ADMIN_TOKEN_PREFIX))
|
|
137
|
-
return resolveAdminToken(token, addonRegistry, facts);
|
|
138
|
-
// Share-token path (`csv_*`): resolve through the ShareTokenService.
|
|
139
|
-
// The synthetic principal is maximally restricted — non-admin, no
|
|
140
|
-
// scopes; the `shareView` grant routes it into the fail-closed
|
|
141
|
-
// allowlist in `protectedProcedure` instead of the scope matcher.
|
|
142
|
-
if (token.startsWith(share_token_service_js_1.SHARE_TOKEN_PREFIX)) {
|
|
143
|
-
if (!shareTokens)
|
|
144
|
-
return null;
|
|
145
|
-
try {
|
|
146
|
-
const record = await shareTokens.validate(token);
|
|
147
|
-
if (!record)
|
|
148
|
-
return null;
|
|
149
|
-
return {
|
|
150
|
-
id: record.userId,
|
|
151
|
-
// Display label — `share:<prefix>` keeps audit logs readable
|
|
152
|
-
// without exposing the token hash.
|
|
153
|
-
username: `share:${record.tokenPrefix}`,
|
|
154
|
-
isAdmin: false,
|
|
155
|
-
permissions: {
|
|
156
|
-
isAdmin: false,
|
|
157
|
-
allowedProviders: '*',
|
|
158
|
-
allowedDevices: {},
|
|
159
|
-
},
|
|
160
|
-
isApiKey: true,
|
|
161
|
-
shareView: { tokenId: record.id, scope: record.scope },
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
catch {
|
|
165
|
-
return null;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
// Scoped-token path: hit the user-management cap. Synthetic user
|
|
169
|
-
// with `isAdmin: false` so admin-gated procedures bounce while
|
|
170
|
-
// protectedProcedure can still gate by scope match.
|
|
171
|
-
if (token.startsWith('cst_')) {
|
|
172
|
-
try {
|
|
173
|
-
const userMgmt = addonRegistry.getCapabilityRegistry().getSingleton('user-management');
|
|
174
|
-
if (!userMgmt)
|
|
175
|
-
return null;
|
|
176
|
-
const record = await userMgmt.validateScopedToken({ token });
|
|
177
|
-
if (!record)
|
|
178
|
-
return null;
|
|
179
|
-
return {
|
|
180
|
-
id: record.userId,
|
|
181
|
-
// Display label — `scoped:<prefix>` makes audit logs read
|
|
182
|
-
// naturally without exposing the token hash.
|
|
183
|
-
username: `scoped:${record.tokenPrefix}`,
|
|
184
|
-
isAdmin: false,
|
|
185
|
-
permissions: {
|
|
186
|
-
isAdmin: false,
|
|
187
|
-
allowedProviders: '*',
|
|
188
|
-
allowedDevices: {},
|
|
189
|
-
},
|
|
190
|
-
isApiKey: true,
|
|
191
|
-
isScoped: true,
|
|
192
|
-
// Migrate any v2 `device:targets` grant to a v3 selector at the
|
|
193
|
-
// boundary so the enforcement matcher only ever sees v3.
|
|
194
|
-
scopes: (0, types_1.normalizeTokenScopes)(record.scopes),
|
|
195
|
-
};
|
|
196
|
-
}
|
|
197
|
-
catch {
|
|
198
|
-
return null;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
// JWT path.
|
|
202
|
-
try {
|
|
203
|
-
const payload = authService.verifyToken(token);
|
|
204
|
-
// Reject pre-v2 JWTs at the boundary. Tokens issued before the
|
|
205
|
-
// role → isAdmin migration don't carry the `isAdmin` field, so
|
|
206
|
-
// letting them through would degrade silently into "non-admin
|
|
207
|
-
// with no scopes" → locked out of every cap. Returning null forces
|
|
208
|
-
// the client to land on 401 → re-login, where it picks up a fresh
|
|
209
|
-
// v2 token. No back-compat shim — the role enum is gone.
|
|
210
|
-
if (typeof payload.isAdmin !== 'boolean') {
|
|
211
|
-
return null;
|
|
212
|
-
}
|
|
213
|
-
// What the token IS decides both WHETHER it authenticates at all and
|
|
214
|
-
// WHERE its scopes come from.
|
|
215
|
-
const principal = (0, types_1.classifyBearerPrincipal)(payload);
|
|
216
|
-
// ── The allowlist ────────────────────────────────────────────────
|
|
217
|
-
// Every token this hub mints verifies under the same `auth.jwtSecret`, so
|
|
218
|
-
// `verifyToken` succeeding means "we minted this", never "this is an API
|
|
219
|
-
// credential" (D103). Exactly two kinds are credentials here:
|
|
220
|
-
//
|
|
221
|
-
// • `session` — no `kind` claim: `auth.login`'s JWT and the SSO-minted
|
|
222
|
-
// session from `/api/auth/sso/finish`.
|
|
223
|
-
// • `integration` — `provider: 'oauth-access'`: the account-link
|
|
224
|
-
// credential a component (Home Assistant) calls `/trpc` with.
|
|
225
|
-
//
|
|
226
|
-
// Everything else is minted for ONE hand-off step and is refused: an
|
|
227
|
-
// `oauth-code` is single-use, lives 60s and travels in a browser redirect
|
|
228
|
-
// URL (history, referrers, proxy logs); an `oauth-refresh` belongs to
|
|
229
|
-
// `POST /oauth/token`; an `oidc` login bridge belongs to
|
|
230
|
-
// `?bridge=` on `/api/auth/sso/finish`; a `totp-challenge` is login leg 1.
|
|
231
|
-
// `/addon/:addonId/*` has refused these since D103 — this closes the same
|
|
232
|
-
// hole on the other surface.
|
|
233
|
-
//
|
|
234
|
-
// The TOTP login is unaffected BY CONSTRUCTION: `loginVerifyTotp` takes
|
|
235
|
-
// the challenge as procedure INPUT on a public rate-limited procedure, and
|
|
236
|
-
// neither the SDK (`login()` calls `setToken` only when
|
|
237
|
-
// `!requiresSecondFactor`) nor the admin UI ever puts it in a header.
|
|
238
|
-
if (principal.kind === 'not-a-credential') {
|
|
239
|
-
logger?.warn('trpc: refused a bearer that is not an API credential', {
|
|
240
|
-
meta: { reason: principal.reason, userId: payload.userId ?? 'unknown' },
|
|
241
|
-
});
|
|
242
|
-
return null;
|
|
243
|
-
}
|
|
244
|
-
// What the token says it IS. An `oauth-access` token is an account link,
|
|
245
|
-
// and until this was carried onto the principal a refusal of one was
|
|
246
|
-
// indistinguishable in the logs from a refusal of a browser session.
|
|
247
|
-
const credential = (0, trpc_error_principal_js_1.readCredentialIdentity)(payload);
|
|
248
|
-
return {
|
|
249
|
-
id: payload.userId ?? 'unknown',
|
|
250
|
-
username: payload.username ?? 'unknown',
|
|
251
|
-
isAdmin: payload.isAdmin,
|
|
252
|
-
permissions: {
|
|
253
|
-
isAdmin: payload.isAdmin,
|
|
254
|
-
allowedProviders: payload.allowedProviders,
|
|
255
|
-
allowedDevices: payload.allowedDevices,
|
|
256
|
-
},
|
|
257
|
-
isApiKey: false,
|
|
258
|
-
...(principal.kind === 'session' && typeof payload.sid === 'string'
|
|
259
|
-
? { sessionId: payload.sid }
|
|
260
|
-
: {}),
|
|
261
|
-
// Scopes are baked into the JWT at login; the middleware uses
|
|
262
|
-
// them to gate every call until the user re-logs. Normalised at the
|
|
263
|
-
// boundary — a v2 `device:targets` JWT (issued before the v3 model)
|
|
264
|
-
// migrates to a selector here, so a pre-v3 token keeps working.
|
|
265
|
-
//
|
|
266
|
-
// …but a login-time snapshot is exactly the bug: an admin editing a
|
|
267
|
-
// user's scopes expects the change to APPLY, and the live session
|
|
268
|
-
// kept gating on the old set until re-login (2026-08-15: a user
|
|
269
|
-
// granted the whole fleet kept seeing zero cameras). For non-admin
|
|
270
|
-
// SESSION principals the store's current scopes win when readable
|
|
271
|
-
// (memoized — see above); the JWT's copy is the fallback when the
|
|
272
|
-
// user-management cap is unreachable, so a store hiccup never
|
|
273
|
-
// locks anybody out mid-request.
|
|
274
|
-
//
|
|
275
|
-
// `session` ONLY, and that word is the whole fix. An ACCOUNT-LINK token
|
|
276
|
-
// is governed by the grant the operator consented to, not by the linking
|
|
277
|
-
// user's record: it always carries `isAdmin: false` (`oauth-grants.ts`
|
|
278
|
-
// → `baseClaims`) and its owner is normally an admin, whose record is
|
|
279
|
-
// deliberately unscoped — so the refresh replaced a six-scope Home
|
|
280
|
-
// Assistant grant with `[]` and the hub refused the component's first
|
|
281
|
-
// call with `Have: (none)` (2026-08-19). The user store knows nothing
|
|
282
|
-
// about a link and must not get a vote on one.
|
|
283
|
-
...(payload.scopes !== undefined ? { scopes: (0, types_1.normalizeTokenScopes)(payload.scopes) } : {}),
|
|
284
|
-
...(!payload.isAdmin && principal.kind === 'session'
|
|
285
|
-
? await (async () => {
|
|
286
|
-
const fresh = await refreshScopesFromStore(payload.userId ?? 'unknown', addonRegistry);
|
|
287
|
-
return fresh !== null ? { scopes: fresh } : {};
|
|
288
|
-
})()
|
|
289
|
-
: {}),
|
|
290
|
-
...(credential.credential !== undefined ? { credential: credential.credential } : {}),
|
|
291
|
-
...(credential.sessionId !== undefined ? { oauthSessionId: credential.sessionId } : {}),
|
|
292
|
-
};
|
|
293
|
-
}
|
|
294
|
-
catch {
|
|
295
|
-
return null;
|
|
296
|
-
}
|
|
110
|
+
async function resolveUser(token, verifier, facts) {
|
|
111
|
+
const principal = await verifier.verify(token, facts);
|
|
112
|
+
return principal === null ? null : agentFromPrincipal(principal);
|
|
297
113
|
}
|
|
298
114
|
/**
|
|
299
115
|
* Build the parent-chain walker for the scope-access matcher. Returns every
|
|
@@ -304,8 +120,7 @@ async function resolveUser(token, authService, addonRegistry, shareTokens = null
|
|
|
304
120
|
* the device-manager's PERSISTED parentage (F0.6). It MUST NOT walk the hub
|
|
305
121
|
* `DeviceRegistry` directly: that registry only holds hub-local devices, so it
|
|
306
122
|
* is empty for every forked provider and the inheritance this function exists to
|
|
307
|
-
* provide silently did nothing (0/6 providers inherited).
|
|
308
|
-
* off the event path and read synchronously here.
|
|
123
|
+
* provide silently did nothing (0/6 providers inherited).
|
|
309
124
|
*/
|
|
310
125
|
function makeDeviceScopeLookup(addonRegistry) {
|
|
311
126
|
return {
|
|
@@ -321,15 +136,12 @@ function makeDeviceScopeLookup(addonRegistry) {
|
|
|
321
136
|
* `principal` names WHO is calling (D802): `mesh` for the hub's own runners
|
|
322
137
|
* (they reach the bridge over UDS, inside the hub's trust boundary), and
|
|
323
138
|
* `node:<nodeId>` for another node — whose id the hub's mesh gate has bound
|
|
324
|
-
* to its CERTIFICATE before the call got here (
|
|
325
|
-
* the TLS handshake chained to the mesh CA, the leaf CN is that id, D857).
|
|
139
|
+
* to its CERTIFICATE before the call got here (D857).
|
|
326
140
|
*
|
|
327
|
-
* Both principals
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
* next step once the mesh census has measured what each node calls (D802).
|
|
332
|
-
* There is no HTTP request behind the call.
|
|
141
|
+
* Both principals hold `'*'`: that keeps every `adminProcedure` an agent's
|
|
142
|
+
* addons call working. What is NOT reachable from another node is decided by
|
|
143
|
+
* the bridge itself (`REMOTE_DENIED_NAMESPACES`), and `repl` is not bridged at
|
|
144
|
+
* all. There is no HTTP request behind the call.
|
|
333
145
|
*/
|
|
334
146
|
function createMeshTrpcContext(principal = 'mesh') {
|
|
335
147
|
const user = {
|
|
@@ -338,17 +150,26 @@ function createMeshTrpcContext(principal = 'mesh') {
|
|
|
338
150
|
isAdmin: true,
|
|
339
151
|
permissions: { isAdmin: true, allowedProviders: '*', allowedDevices: {} },
|
|
340
152
|
isApiKey: true,
|
|
153
|
+
scopes: ['*'],
|
|
154
|
+
credentialId: principal,
|
|
341
155
|
};
|
|
342
156
|
return { user };
|
|
343
157
|
}
|
|
344
158
|
/** Context factory for HTTP tRPC requests (Fastify adapter). */
|
|
345
|
-
async function createTrpcContext(req, authService, addonRegistry,
|
|
346
|
-
const
|
|
159
|
+
async function createTrpcContext(req, authService, addonRegistry, logger = null, tokenRows = null) {
|
|
160
|
+
const deviceScopeLookup = makeDeviceScopeLookup(addonRegistry);
|
|
161
|
+
const verifier = hubCredentialVerifier({
|
|
162
|
+
authService,
|
|
163
|
+
addonRegistry,
|
|
164
|
+
tokenRows,
|
|
165
|
+
logger,
|
|
166
|
+
deviceScopeLookup,
|
|
167
|
+
});
|
|
347
168
|
return {
|
|
348
|
-
user: await resolveUser(
|
|
169
|
+
user: await resolveUser(extractTokenFromRequest(req), verifier, requestFacts(req)),
|
|
349
170
|
req,
|
|
350
171
|
lang: (0, request_lang_js_1.resolveRequestLang)((0, request_lang_js_1.requestLangInputFrom)(req.headers, readQuery(req))),
|
|
351
|
-
deviceScopeLookup
|
|
172
|
+
deviceScopeLookup,
|
|
352
173
|
deviceFleet: () => addonRegistry.getPersistedDeviceList(),
|
|
353
174
|
};
|
|
354
175
|
}
|
|
@@ -357,11 +178,19 @@ async function createTrpcContext(req, authService, addonRegistry, shareTokens =
|
|
|
357
178
|
* Token is sent via tRPC connectionParams (a JSON message sent right after
|
|
358
179
|
* the WS handshake), which is more reliable than query params through proxies.
|
|
359
180
|
*/
|
|
360
|
-
async function createWsTrpcContext(opts, authService, addonRegistry,
|
|
181
|
+
async function createWsTrpcContext(opts, authService, addonRegistry, logger = null, tokenRows = null) {
|
|
361
182
|
// 1. connectionParams.token (sent by BackendClient's createWSClient)
|
|
362
183
|
const paramToken = opts.info.connectionParams?.['token'];
|
|
363
184
|
const token = (typeof paramToken === 'string' ? paramToken : null) ?? extractTokenFromRequest(opts.req);
|
|
364
|
-
const
|
|
185
|
+
const deviceScopeLookup = makeDeviceScopeLookup(addonRegistry);
|
|
186
|
+
const verifier = hubCredentialVerifier({
|
|
187
|
+
authService,
|
|
188
|
+
addonRegistry,
|
|
189
|
+
tokenRows,
|
|
190
|
+
logger,
|
|
191
|
+
deviceScopeLookup,
|
|
192
|
+
});
|
|
193
|
+
const user = await resolveUser(token, verifier, requestFacts(opts.req));
|
|
365
194
|
const sessionId = user?.sessionId;
|
|
366
195
|
return {
|
|
367
196
|
user,
|
|
@@ -379,7 +208,7 @@ async function createWsTrpcContext(opts, authService, addonRegistry, shareTokens
|
|
|
379
208
|
}) === null,
|
|
380
209
|
}
|
|
381
210
|
: {}),
|
|
382
|
-
deviceScopeLookup
|
|
211
|
+
deviceScopeLookup,
|
|
383
212
|
deviceFleet: () => addonRegistry.getPersistedDeviceList(),
|
|
384
213
|
};
|
|
385
214
|
}
|