@camstack/server 1.2.603 → 1.2.605

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.
@@ -0,0 +1,250 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.CredentialTokenService = void 0;
37
+ /**
38
+ * The unified token store (D892).
39
+ *
40
+ * A token is a signed JWT `{ kind: 'credential', sub, jti, scopes }` and one
41
+ * row here: the SHA-256 of its `jti`, its label, its scopes, its ONE expiry,
42
+ * when it was last used and whether it was revoked. The verifier reads the
43
+ * row by `jti` on every request — that is what makes revocation immediate and
44
+ * the expiry slidable (a share link's `extend`). The raw token leaves this
45
+ * service exactly once, in `mint`'s answer; neither it nor the `jti` is ever
46
+ * stored or logged.
47
+ *
48
+ * Rows live in the `core-auth` records (`credential-tokens`), declared by the
49
+ * hub beside the share links. Until that binding happens the store refuses to
50
+ * mint and authenticates nothing — it never falls back to another table.
51
+ */
52
+ const crypto = __importStar(require("node:crypto"));
53
+ const types_1 = require("@camstack/types");
54
+ const record_kinds_js_1 = require("./record-kinds.js");
55
+ const TOUCH_EVERY_MS = 60_000;
56
+ const DEAD_RETENTION_MS = 30 * 86_400_000;
57
+ const LABEL_MAX = 80;
58
+ const PAGE = 1000;
59
+ const MAX_PAGES = 100;
60
+ function hashJti(jti) {
61
+ return crypto.createHash('sha256').update(jti).digest('hex');
62
+ }
63
+ /** Constant-time equality of two hex digests. */
64
+ function sameDigest(a, b) {
65
+ const left = Buffer.from(a, 'hex');
66
+ const right = Buffer.from(b, 'hex');
67
+ return left.length === right.length && crypto.timingSafeEqual(left, right);
68
+ }
69
+ function toRecord(id, body) {
70
+ return {
71
+ id,
72
+ subjectId: body.subjectId,
73
+ createdBy: body.createdBy,
74
+ label: body.label,
75
+ scopes: body.scopes,
76
+ createdAt: body.createdAt,
77
+ expiresAt: body.expiresAt,
78
+ lastUsedAt: body.lastUsedAt ?? null,
79
+ revokedAt: body.revokedAt ?? null,
80
+ };
81
+ }
82
+ class CredentialTokenService {
83
+ signer;
84
+ logger;
85
+ now;
86
+ resolveKind = () => null;
87
+ constructor(signer, logger = null, now = Date.now) {
88
+ this.signer = signer;
89
+ this.logger = logger;
90
+ this.now = now;
91
+ }
92
+ /** Hub wiring after `records.forOwner('core-auth').declare(...)`. */
93
+ bindKind(resolver) {
94
+ this.resolveKind = resolver;
95
+ }
96
+ kind() {
97
+ const kind = this.resolveKind();
98
+ if (kind === null) {
99
+ throw (0, types_1.userError)('auth.tokens-unavailable', undefined, 'Tokens are not available yet');
100
+ }
101
+ return kind;
102
+ }
103
+ /** Issue a token. The raw token is in this answer only. */
104
+ async mint(input) {
105
+ const label = input.label.trim().slice(0, LABEL_MAX);
106
+ if (label.length === 0) {
107
+ throw (0, types_1.userError)('auth.token-label-required', undefined, 'A token needs a name');
108
+ }
109
+ const at = this.now();
110
+ if (input.expiresAt !== null && input.expiresAt <= at) {
111
+ throw (0, types_1.userError)('auth.token-expiry-past', undefined, 'A token must expire in the future');
112
+ }
113
+ const jti = crypto.randomBytes(32).toString('base64url');
114
+ const id = crypto.randomUUID();
115
+ const body = {
116
+ subjectId: input.subjectId,
117
+ createdBy: input.createdBy,
118
+ label,
119
+ jtiHash: hashJti(jti),
120
+ scopes: [...input.scopes],
121
+ createdAt: at,
122
+ expiresAt: input.expiresAt,
123
+ lastUsedAt: null,
124
+ revokedAt: null,
125
+ };
126
+ const parsed = record_kinds_js_1.coreAuthRecordKinds['credential-tokens'].schema.safeParse(body);
127
+ if (!parsed.success)
128
+ throw new Error(parsed.error.message);
129
+ const kind = this.kind();
130
+ const token = this.signer.signCredentialToken({
131
+ kind: types_1.CREDENTIAL_TOKEN_KIND,
132
+ sub: input.subjectId,
133
+ jti,
134
+ scopes: [...input.scopes],
135
+ });
136
+ await kind.put({
137
+ id,
138
+ ...(body.expiresAt !== null ? { expiresAt: body.expiresAt } : {}),
139
+ data: parsed.data,
140
+ });
141
+ this.logger?.info('Token minted', {
142
+ meta: { tokenId: id, subjectId: input.subjectId, label, expiresAt: input.expiresAt },
143
+ });
144
+ return { token, record: toRecord(id, parsed.data) };
145
+ }
146
+ /** The live row behind `jti`: unknown, revoked and expired answer `null`. */
147
+ async lookupLive(jti) {
148
+ const kind = this.resolveKind();
149
+ if (kind === null)
150
+ return null;
151
+ const digest = hashJti(jti);
152
+ const page = await kind.query({ where: { jtiHash: digest }, limit: 1 });
153
+ const row = page.records[0];
154
+ if (row === undefined || !sameDigest(row.data.jtiHash, digest))
155
+ return null;
156
+ const at = this.now();
157
+ if (row.data.revokedAt !== null && row.data.revokedAt !== undefined)
158
+ return null;
159
+ if (row.data.expiresAt !== null && row.data.expiresAt <= at)
160
+ return null;
161
+ const lastUsedAt = row.data.lastUsedAt ?? null;
162
+ if (lastUsedAt === null || at - lastUsedAt > TOUCH_EVERY_MS) {
163
+ void this.write(row.id, { ...row.data, lastUsedAt: at }).catch(() => { });
164
+ }
165
+ return {
166
+ id: row.id,
167
+ subjectId: row.data.subjectId,
168
+ label: row.data.label,
169
+ scopes: row.data.scopes,
170
+ };
171
+ }
172
+ async get(id) {
173
+ const row = await this.kind().get(id);
174
+ return row === null ? null : toRecord(row.id, row.data);
175
+ }
176
+ /** Every row not yet reaped, newest first; `subjectId` narrows to one user's. */
177
+ async list(subjectId) {
178
+ const kind = this.resolveKind();
179
+ if (kind === null)
180
+ return [];
181
+ const out = [];
182
+ let cursor;
183
+ for (let page = 0; page < MAX_PAGES; page += 1) {
184
+ const answer = await kind.query({
185
+ ...(subjectId === undefined ? {} : { where: { subjectId } }),
186
+ limit: PAGE,
187
+ ...(cursor === undefined ? {} : { cursor }),
188
+ });
189
+ out.push(...answer.records.map((row) => toRecord(row.id, row.data)));
190
+ if (answer.nextCursor === null)
191
+ return out.toSorted((a, b) => b.createdAt - a.createdAt);
192
+ cursor = answer.nextCursor;
193
+ }
194
+ throw new Error('credential-tokens: row count exceeds pagination bound');
195
+ }
196
+ /** Revoke one token now. `false` when unknown or already revoked. */
197
+ async revoke(id) {
198
+ const row = await this.kind().get(id);
199
+ if (row === null || (row.data.revokedAt !== null && row.data.revokedAt !== undefined)) {
200
+ return false;
201
+ }
202
+ await this.write(id, { ...row.data, revokedAt: this.now() });
203
+ this.logger?.info('Token revoked', { meta: { tokenId: id, subjectId: row.data.subjectId } });
204
+ return true;
205
+ }
206
+ /** Move a live token's expiry (a share link's slide). `null` when not live. */
207
+ async setExpiry(id, expiresAt) {
208
+ const row = await this.kind().get(id);
209
+ if (row === null)
210
+ return null;
211
+ const at = this.now();
212
+ const dead = (row.data.revokedAt !== null && row.data.revokedAt !== undefined) ||
213
+ (row.data.expiresAt !== null && row.data.expiresAt <= at);
214
+ if (dead || expiresAt <= at)
215
+ return null;
216
+ const next = { ...row.data, expiresAt };
217
+ await this.write(id, next);
218
+ return toRecord(id, next);
219
+ }
220
+ /** Revoke every live token of `subjectId` (the user was deleted). */
221
+ async revokeAllFor(subjectId) {
222
+ let revoked = 0;
223
+ for (const token of await this.list(subjectId)) {
224
+ if (token.revokedAt === null && (await this.revoke(token.id)))
225
+ revoked += 1;
226
+ }
227
+ return revoked;
228
+ }
229
+ /** Delete rows expired or revoked more than 30 days ago. */
230
+ async reap() {
231
+ const horizon = this.now() - DEAD_RETENTION_MS;
232
+ let deleted = 0;
233
+ for (const token of await this.list()) {
234
+ const deadAt = token.revokedAt ?? token.expiresAt;
235
+ if (deadAt !== null && deadAt < horizon) {
236
+ await this.kind().delete(token.id);
237
+ deleted += 1;
238
+ }
239
+ }
240
+ return deleted;
241
+ }
242
+ async write(id, body) {
243
+ await this.kind().put({
244
+ id,
245
+ ...(body.expiresAt !== null ? { expiresAt: body.expiresAt } : {}),
246
+ data: body,
247
+ });
248
+ }
249
+ }
250
+ exports.CredentialTokenService = CredentialTokenService;
@@ -21,6 +21,22 @@ const ShareViewTokenBodySchema = zod_1.z.object({
21
21
  expiresAt: zod_1.z.number().nullable(),
22
22
  lastUsedAt: zod_1.z.number().nullish(),
23
23
  });
24
+ /**
25
+ * The unified token (D892): one row per token `auth.mintToken` issued — the
26
+ * hashed `jti` the verifier looks up for revocation, the label, the scopes,
27
+ * the ONE expiry (`null` only for a never-expiring share link), last use.
28
+ */
29
+ const CredentialTokenBodySchema = zod_1.z.object({
30
+ subjectId: zod_1.z.string(),
31
+ createdBy: zod_1.z.string(),
32
+ label: zod_1.z.string(),
33
+ jtiHash: zod_1.z.string(),
34
+ scopes: zod_1.z.array(types_1.CredentialScopeSchema).min(1),
35
+ createdAt: zod_1.z.number(),
36
+ expiresAt: zod_1.z.number().nullable(),
37
+ lastUsedAt: zod_1.z.number().nullish(),
38
+ revokedAt: zod_1.z.number().nullish(),
39
+ });
24
40
  exports.coreAuthRecordKinds = (0, types_1.defineRecordKinds)({
25
41
  'share-view-tokens': {
26
42
  schema: ShareViewTokenBodySchema,
@@ -35,4 +51,19 @@ exports.coreAuthRecordKinds = (0, types_1.defineRecordKinds)({
35
51
  retention: { none: 'revoke only; expiry enforced at validate' },
36
52
  adoptsFrom: exports.SHARE_VIEW_TOKENS_LEGACY,
37
53
  },
54
+ 'credential-tokens': {
55
+ schema: CredentialTokenBodySchema,
56
+ class: 'config',
57
+ writeTrigger: 'a user mints a token (auth.mintToken or a wrapper); validate touches lastUsedAt at most once a minute; revoke stamps revokedAt',
58
+ device: 'none',
59
+ at: 'none',
60
+ indexes: [
61
+ { name: 'jti', fields: ['jtiHash'], unique: true },
62
+ { name: 'subject', fields: ['subjectId'] },
63
+ ],
64
+ retention: {
65
+ owner: 'core-auth',
66
+ reason: 'the daily reap deletes rows expired or revoked more than 30 days ago',
67
+ },
68
+ },
38
69
  });
package/dist/main.js CHANGED
@@ -88,7 +88,7 @@ const trpc_error_device_tags_1 = require("./api/trpc/trpc-error-device-tags");
88
88
  const trpc_error_principal_1 = require("./api/trpc/trpc-error-principal");
89
89
  const ws_request_census_1 = require("./api/trpc/ws-request-census");
90
90
  const ws_slow_consumer_guard_js_1 = require("./api/trpc/ws-slow-consumer-guard.js");
91
- const addon_route_jwt_gate_js_1 = require("./auth/addon-route-jwt-gate.js");
91
+ const addon_route_auth_js_1 = require("./auth/addon-route-auth.js");
92
92
  const addon_route_share_gate_js_1 = require("./auth/addon-route-share-gate.js");
93
93
  const auth_network_policy_js_1 = require("./auth/auth-network-policy.js");
94
94
  const hub_sessions_js_1 = require("./auth/hub-sessions.js");
@@ -121,6 +121,7 @@ const agent_registry_service_1 = require("./core/agent/agent-registry.service");
121
121
  const well_known_app_links_1 = require("./core/app-links/well-known-app-links");
122
122
  const auth_service_1 = require("./core/auth/auth.service");
123
123
  const handoff_code_service_1 = require("./core/auth/handoff-code.service");
124
+ const credential_token_service_js_1 = require("./core/auth/credential-token.service.js");
124
125
  const share_token_service_1 = require("./core/auth/share-token.service");
125
126
  const config_service_1 = require("./core/config/config.service");
126
127
  const event_bus_service_1 = require("./core/events/event-bus.service");
@@ -600,6 +601,12 @@ async function bootstrap() {
600
601
  // builtin registers; the service resolves it per call).
601
602
  const shareTokenService = new share_token_service_1.ShareTokenService(() => addonRegistry.getSettingsBackend(), loggingService.createLogger('share-tokens'));
602
603
  addonRegistry.setShareTokenService(shareTokenService);
604
+ // The unified token store (D892): rows in the `core-auth` records, bound
605
+ // with the share links; the JWT is signed with the session signing key.
606
+ const credentialTokenService = new credential_token_service_js_1.CredentialTokenService(app.get(auth_service_1.AuthService), loggingService.createLogger('tokens'));
607
+ addonRegistry.setCredentialTokenService(credentialTokenService);
608
+ // Every surface that verifies a bearer reads the same stores (D892).
609
+ (0, trpc_context_1.bindHubCredentialStores)({ shareTokens: shareTokenService, tokenRows: credentialTokenService });
603
610
  // One-time native-app login handoff codes (in-memory, 60s TTL, single-use) —
604
611
  // minted by `auth.createHandoffCode`, redeemed by the viewer app via the
605
612
  // public `auth.redeemHandoffCode`.
@@ -632,6 +639,7 @@ async function bootstrap() {
632
639
  const buildRouter = () => (0, trpc_router_1.buildAppRouter)({
633
640
  authService: app.get(auth_service_1.AuthService),
634
641
  shareTokenService,
642
+ credentialTokenService,
635
643
  handoffCodeService,
636
644
  externalLinkIntents,
637
645
  nativeLink: {
@@ -803,7 +811,7 @@ async function bootstrap() {
803
811
  const trpcCtx = await (0, trpc_context_1.createTrpcContext)(req, authService, addonRegistry, shareTokenService,
804
812
  // A bearer refused for NOT being an API credential never reaches a
805
813
  // procedure, so `onError` below never sees it — the refusal would
806
- // be silent without this. Same reason `gateAddonJwt` logs (D103).
814
+ // be silent without this. Same reason the addon gate logs (D103).
807
815
  app.get(logging_service_1.LoggingService).createLogger('tRPC'));
808
816
  // WHO called, counted here because this is the first point at which
809
817
  // a principal exists AND the only one tRPC runs exactly once per
@@ -1075,6 +1083,12 @@ async function bootstrap() {
1075
1083
  // about ("why did Home Assistant stop actuating?"). Its own scope so the
1076
1084
  // reason is greppable rather than buried in the bootstrap logger.
1077
1085
  const addonGateLogger = app.get(logging_service_1.LoggingService).createLogger('addon-route-auth');
1086
+ const addonVerifier = (0, trpc_context_1.hubCredentialVerifier)({
1087
+ authService,
1088
+ addonRegistry,
1089
+ shareTokens: shareTokenService,
1090
+ logger: addonGateLogger,
1091
+ });
1078
1092
  // Addon HTTP API route catch-all: /addon/:addonId/*
1079
1093
  // Only handles non-GET or routes that actually exist in the addon route registry.
1080
1094
  // GET requests that don't match an addon route are SPA pages — handled by the /* fallback.
@@ -1113,40 +1127,20 @@ async function bootstrap() {
1113
1127
  return reply.status(401).send({ error: 'Unauthorized' });
1114
1128
  }
1115
1129
  const token = authHeader ? authHeader.replace('Bearer ', '') : cookieToken;
1116
- if (token.startsWith('csv_')) {
1117
- // A share link (`csv_*`): admitted only by a plane that opted in
1118
- // to its scope kind, and the scope's devices travel in headers the
1119
- // HUB sets — whatever the client sent under those names is dropped
1120
- // (see `addon-route-share-gate.ts`).
1121
- const record = await shareTokenService.validate(token);
1122
- const gate = (0, addon_route_share_gate_js_1.gateAddonShareToken)({ record, endpoint: dpMatch.endpoint });
1123
- if (!gate.ok)
1124
- return reply.status(gate.status).send({ error: gate.error });
1125
- shareHeaders = gate.headers;
1126
- }
1127
- else if (token.startsWith('cst_')) {
1128
- const userMgmt = capabilityRegistry?.getSingleton('user-management');
1129
- if (!userMgmt)
1130
- return reply.status(503).send({ error: 'User management not available' });
1131
- const scopedToken = await userMgmt.validateScopedToken({ token });
1132
- const scopeOk = scopedToken?.scopes.some((scope) => scope.type === 'addon' && scope.target === addonId);
1133
- if (!scopedToken || !scopeOk) {
1134
- return reply.status(403).send({ error: 'Token scope mismatch' });
1135
- }
1136
- }
1137
- else {
1138
- let payload;
1139
- try {
1140
- payload = authService.verifyToken(token);
1141
- }
1142
- catch {
1143
- return reply.status(401).send({ error: 'Invalid token' });
1144
- }
1145
- const gate = (0, addon_route_jwt_gate_js_1.gateAddonJwt)({ payload, addonId, method, access }, addonGateLogger);
1146
- if (!gate.ok) {
1147
- return reply.status(gate.status).send({ error: gate.error });
1148
- }
1149
- }
1130
+ // The ONE verifier and the ONE scope matcher, as on tRPC (D892). A
1131
+ // share principal is admitted only by a plane that opted in to its
1132
+ // kind, and its devices travel in headers the HUB sets — whatever
1133
+ // the client sent under those names is dropped below.
1134
+ const auth = await (0, addon_route_auth_js_1.authorizeAddonBearer)({
1135
+ bearer: token,
1136
+ verifier: addonVerifier,
1137
+ target: { addonId, method, access, share: dpMatch.endpoint.share ?? [] },
1138
+ facts: (0, trpc_context_1.requestFacts)(request),
1139
+ logger: addonGateLogger,
1140
+ });
1141
+ if (!auth.ok)
1142
+ return reply.status(auth.status).send({ error: auth.error });
1143
+ shareHeaders = auth.shareHeaders;
1150
1144
  }
1151
1145
  const qIdx = request.url.indexOf('?');
1152
1146
  const queryString = qIdx >= 0 ? request.url.slice(qIdx) : '';
@@ -1221,67 +1215,27 @@ async function bootstrap() {
1221
1215
  return reply.status(401).send({ error: 'Unauthorized' });
1222
1216
  }
1223
1217
  const token = authHeader ? authHeader.replace('Bearer ', '') : cookieToken;
1224
- if (token.startsWith('cst_')) {
1225
- const userMgmt = capabilityRegistry?.getSingleton('user-management');
1226
- if (!userMgmt)
1227
- return reply.status(503).send({ error: 'User management not available' });
1228
- const scopedToken = await userMgmt.validateScopedToken({ token });
1229
- if (!scopedToken) {
1230
- return reply.status(401).send({ error: 'Invalid token' });
1231
- }
1232
- // v2 model: scoped tokens grant by cap-category/cap-name/addon.
1233
- // For addon-route REST endpoints we match the `addon` scope
1234
- // type only — generic route-prefix scopes were dropped with
1235
- // the caps-only refactor.
1236
- const scopeMatch = scopedToken.scopes.some((scope) => {
1237
- return scope.type === 'addon' && scope.target === addonId;
1238
- });
1239
- if (!scopeMatch) {
1240
- return reply.status(403).send({ error: 'Token scope mismatch' });
1241
- }
1242
- // Build addon request with scoped token context
1243
- const addonRequest = {
1244
- params: match.params,
1245
- query,
1246
- body: request.body,
1247
- headers,
1248
- scopedToken: {
1249
- id: scopedToken.id,
1250
- userId: scopedToken.userId,
1251
- scopes: scopedToken.scopes,
1252
- },
1253
- };
1254
- const addonReply = buildAddonReply(reply);
1255
- return match.route.handler(addonRequest, addonReply);
1256
- }
1257
- else {
1258
- let payload;
1259
- try {
1260
- payload = authService.verifyToken(token);
1261
- }
1262
- catch {
1263
- return reply.status(401).send({ error: 'Invalid token' });
1264
- }
1265
- // NOTE: the handler call used to sit INSIDE this try, so a throw from
1266
- // an addon's own route handler was reported as `401 Invalid token`.
1267
- const gate = (0, addon_route_jwt_gate_js_1.gateAddonJwt)({ payload, addonId, method, access: match.route.access }, addonGateLogger);
1268
- if (!gate.ok) {
1269
- return reply.status(gate.status).send({ error: gate.error });
1270
- }
1271
- const addonRequest = {
1272
- params: match.params,
1273
- query,
1274
- body: request.body,
1275
- headers,
1276
- user: {
1277
- id: payload.userId ?? 'unknown',
1278
- username: payload.username ?? 'unknown',
1279
- isAdmin: payload.isAdmin,
1280
- },
1281
- };
1282
- const addonReply = buildAddonReply(reply);
1283
- return match.route.handler(addonRequest, addonReply);
1284
- }
1218
+ // The ONE verifier and the ONE scope matcher, as on tRPC (D892).
1219
+ const auth = await (0, addon_route_auth_js_1.authorizeAddonBearer)({
1220
+ bearer: token,
1221
+ verifier: addonVerifier,
1222
+ target: { addonId, method, access: match.route.access },
1223
+ facts: (0, trpc_context_1.requestFacts)(request),
1224
+ logger: addonGateLogger,
1225
+ });
1226
+ if (!auth.ok)
1227
+ return reply.status(auth.status).send({ error: auth.error });
1228
+ // NOTE: the handler call used to sit INSIDE the JWT try, so a throw
1229
+ // from an addon's own route handler was reported as `401 Invalid token`.
1230
+ const addonRequest = {
1231
+ params: match.params,
1232
+ query,
1233
+ body: request.body,
1234
+ headers,
1235
+ ...(0, addon_route_auth_js_1.addonRouteCaller)(auth.principal),
1236
+ };
1237
+ const addonReply = buildAddonReply(reply);
1238
+ return match.route.handler(addonRequest, addonReply);
1285
1239
  }
1286
1240
  // Public route — no auth required
1287
1241
  const addonRequest = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.603",
3
+ "version": "1.2.605",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "files": [
@@ -38,19 +38,19 @@
38
38
  ]
39
39
  },
40
40
  "dependencies": {
41
- "@camstack/addon-admin-ui": "1.2.440",
42
- "@camstack/addon-agent-ui": "1.2.246",
43
- "@camstack/addon-auth": "1.2.151",
44
- "@camstack/addon-decoder-nodeav": "1.2.125",
45
- "@camstack/addon-notifiers": "1.2.603",
46
- "@camstack/addon-pipeline": "1.2.603",
47
- "@camstack/addon-pipeline-orchestrator": "1.2.604",
48
- "@camstack/addon-post-analysis": "1.2.608",
49
- "@camstack/develop": "0.1.15",
50
- "@camstack/sdk": "1.2.135",
51
- "@camstack/system": "1.2.454",
52
- "@camstack/types": "1.2.385",
53
- "@camstack/ui-library": "1.2.302",
41
+ "@camstack/addon-admin-ui": "1.2.441",
42
+ "@camstack/addon-agent-ui": "1.2.247",
43
+ "@camstack/addon-auth": "1.2.152",
44
+ "@camstack/addon-decoder-nodeav": "1.2.126",
45
+ "@camstack/addon-notifiers": "1.2.604",
46
+ "@camstack/addon-pipeline": "1.2.604",
47
+ "@camstack/addon-pipeline-orchestrator": "1.2.605",
48
+ "@camstack/addon-post-analysis": "1.2.610",
49
+ "@camstack/develop": "0.1.16",
50
+ "@camstack/sdk": "1.2.136",
51
+ "@camstack/system": "1.2.455",
52
+ "@camstack/types": "1.2.386",
53
+ "@camstack/ui-library": "1.2.303",
54
54
  "@fastify/compress": "^9.0.0",
55
55
  "@fastify/cookie": "^11.0.2",
56
56
  "@fastify/cors": "^11.2.0",
@@ -1,59 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.gateAddonJwt = gateAddonJwt;
4
- /**
5
- * The JWT half of the `/addon/:addonId/*` gate — shared by the HTTP data-plane
6
- * branch and the addon-route branch so there is exactly ONE answer to "may this
7
- * bearer reach this addon".
8
- *
9
- * Before this existed the two branches disagreed with themselves and with the
10
- * `cst_` branch: a scoped token needed an `addon:<id>` grant, while ANY valid
11
- * hub JWT passed on nothing but a signature check. Measured on the live hub
12
- * 2026-08-09, that let an `oauth-access` token minted for the `export-alexa`
13
- * account link drive `POST /addon/homeassistant-export/command` — PTZ, reboot,
14
- * the per-camera switches and notification snooze. A 60-second authorization
15
- * CODE and a 30-day refresh token passed the same way. See D103.
16
- */
17
- const types_1 = require("@camstack/types");
18
- function readScopes(raw) {
19
- return Array.isArray(raw) ? raw : [];
20
- }
21
- /**
22
- * Decide, and REPORT. A refusal always returns its reason to the log — a branch
23
- * that drops a request silently reads as "never happened", and this one drops
24
- * requests an operator will be trying to explain.
25
- *
26
- * Order is deliberate. `Admin required` is answered before the scope check so
27
- * an admin-only route keeps the response it already had; nothing that is not a
28
- * session or an `oauth-access` token gets that far at all.
29
- */
30
- function gateAddonJwt(input, logger) {
31
- const principal = (0, types_1.classifyBearerPrincipal)(input.payload);
32
- const isAdmin = input.payload.isAdmin === true;
33
- if (principal.kind === 'not-a-credential') {
34
- logger.warn('addon route: refused a bearer that is not an API credential', {
35
- meta: { addonId: input.addonId, method: input.method, reason: principal.reason },
36
- });
37
- return { ok: false, status: 401, error: 'Invalid token' };
38
- }
39
- if (input.access === 'admin' && !isAdmin) {
40
- logger.warn('addon route: refused a non-admin bearer on an admin route', {
41
- meta: { addonId: input.addonId, method: input.method, principal: principal.kind },
42
- });
43
- return { ok: false, status: 403, error: 'Admin required' };
44
- }
45
- const verdict = (0, types_1.principalMayReachAddon)({
46
- principal,
47
- scopes: readScopes(input.payload.scopes),
48
- isAdmin,
49
- addonId: input.addonId,
50
- method: input.method,
51
- });
52
- if (!verdict.allowed) {
53
- logger.warn('addon route: refused an integration token outside its declared grant', {
54
- meta: { addonId: input.addonId, method: input.method, reason: verdict.reason },
55
- });
56
- return { ok: false, status: 403, error: 'Token scope mismatch' };
57
- }
58
- return { ok: true };
59
- }