@camstack/server 1.2.605 → 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.
@@ -1,459 +0,0 @@
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.ShareTokenService = exports.ShareTokenRecordSchema = exports.ShareTokenScopeSchema = exports.SHARE_TOKEN_MAX_DEVICES = exports.SHARE_TOKEN_TTL_DEFAULT_SEC = exports.SHARE_TOKEN_TTL_MAX_SEC = exports.SHARE_TOKEN_TTL_MIN_SEC = exports.SHARE_TOKEN_PREFIX = void 0;
37
- /**
38
- * ShareTokenService — scoped, TTL'd VIEW tokens for standalone share links
39
- * (grid rework R6: the embed page opened by a third party authenticates with
40
- * a `csv_*` token carried in the share URL's `#t=` fragment).
41
- *
42
- * Design mirrors the existing `cst_*` scoped-token mechanism
43
- * (`@camstack/system` ScopedTokenManager): a random opaque token, stored
44
- * server-side as a SHA-256 hash so a DB leak never exposes live tokens,
45
- * validated per request, revocable, with the TTL enforced at verification.
46
- *
47
- * Differences from `cst_*` tokens — a share token is NOT a user session:
48
- * • it resolves to a RESTRICTED `share-view` principal (see
49
- * `trpc.context.ts`) that can only call the allowlisted embed surface
50
- * (`share-view-access.ts`), never the general cap surface;
51
- * • the scope is a share grant (`kind: 'grid-view'` + deviceIds), not a
52
- * capability/addon grant;
53
- * • expiry is explicit at mint time: a bounded TTL (60s..30d, default
54
- * 7d) or the deliberate `ttlSec: 'never'` choice (`expiresAt: null`)
55
- * for permanently-shared grids — revocation stays the kill switch.
56
- *
57
- * Storage: the hub's settings backend (same SQLite store the scoped tokens
58
- * live in, reached directly instead of over the settings-store cap since
59
- * this service is hub-resident). Collection: `share_view_tokens`.
60
- */
61
- const crypto = __importStar(require("node:crypto"));
62
- const zod_1 = require("zod");
63
- const types_1 = require("@camstack/types");
64
- const record_kinds_js_1 = require("./record-kinds.js");
65
- /**
66
- * @durable class=config owner=core-auth
67
- * write="a user mints a share link; and a best-effort lastUsedAt touch on every successful validation"
68
- * retention="none — no sweep and no TTL reaper. A row goes only when the owner or an admin revokes it. expiresAt is enforced at VALIDATION, so an expired token keeps its row and keeps being scanned; the table is bounded only by how many links people mint."
69
- */
70
- const SHARE_TOKENS_COLLECTION = 'share_view_tokens';
71
- /**
72
- * The two predicates this service actually issues, indexed.
73
- *
74
- * The collection is KV-shaped — `(id TEXT PK, data TEXT)` — so `tokenHash` and
75
- * `userId` live inside the `data` blob and the backend rewrites both predicates
76
- * to `json_extract`. Declaring an index on a blob field makes the backend emit
77
- * a matching EXPRESSION index (`sqlite-settings-backend.ts`, `ensureTable`),
78
- * which is the only kind SQLite can use for these queries.
79
- *
80
- * Why it matters: `validate()` runs on EVERY request carrying a `csv_*` token,
81
- * and without this it was a full-table scan that parsed the JSON of every row
82
- * to compare one hash. Six rows today, so it never showed up — but it is the
83
- * authentication path for share links, so it is the one that gets hit hardest
84
- * the day a link is shared widely.
85
- *
86
- * Adding indexes is NOT a shape change: the column list is untouched, so
87
- * `ensureTable` takes neither the rebuild nor the `ALTER TABLE` branch, and no
88
- * row is migrated or lost.
89
- */
90
- const SHARE_TOKENS_INDEXES = [
91
- { name: 'idx_share_view_tokens_hash', columns: ['tokenHash'] },
92
- { name: 'idx_share_view_tokens_user', columns: ['userId'] },
93
- ];
94
- /** Wire prefix — `csv_` = CamStack Share View (cf. `cst_` scoped tokens). */
95
- exports.SHARE_TOKEN_PREFIX = 'csv_';
96
- // ── TTL / scope bounds (enforced at mint, re-checked by the router schema) ──
97
- exports.SHARE_TOKEN_TTL_MIN_SEC = 60;
98
- exports.SHARE_TOKEN_TTL_MAX_SEC = 30 * 24 * 60 * 60; // 30 days
99
- exports.SHARE_TOKEN_TTL_DEFAULT_SEC = 7 * 24 * 60 * 60; // 7 days
100
- exports.SHARE_TOKEN_MAX_DEVICES = 64;
101
- /**
102
- * What a share token is allowed to see. Keyed on `kind` so future share
103
- * surfaces (single-camera view, recording clip, …) extend the set without
104
- * touching verification plumbing.
105
- */
106
- /**
107
- * `grid-view` — the live camera wall (WebRTC + snapshots).
108
- * `events-view` — the events embed (track feed, kind taxonomy, CLIP search).
109
- *
110
- * They are SEPARATE kinds, not one kind with a bigger surface. Adding the
111
- * track methods to `grid-view` would retroactively widen every share link an
112
- * operator has already handed out: a link minted to show a camera wall would
113
- * silently start serving that camera's event history. A token keeps the
114
- * perimeter it was minted with, and a new perimeter needs a new mint.
115
- *
116
- * Neither kind is a superset of the other — an events link cannot open a
117
- * WebRTC session, and a grid link cannot read a track.
118
- */
119
- exports.ShareTokenScopeSchema = zod_1.z.object({
120
- kind: zod_1.z.enum(types_1.SHARE_VIEW_KINDS),
121
- deviceIds: zod_1.z.array(zod_1.z.number().int().nonnegative()).min(1).max(exports.SHARE_TOKEN_MAX_DEVICES),
122
- /**
123
- * TALK-BACK, opt-in at mint time. Absent means OFF, and that is the whole
124
- * point: a share link is handed to third parties, so the difference between
125
- * "can watch the kitchen" and "can speak into the kitchen" must be a choice
126
- * somebody made for THAT link, on the day they made it.
127
- *
128
- * It rides on the scope rather than widening the grid enumeration because
129
- * the enumeration is retroactive: a method added there is granted to every
130
- * token ever minted, including the one handed to a neighbour last week.
131
- * `share-view-access.ts` says so about event history; the same rule with
132
- * actuation into the home on the other end of it is not a smaller matter.
133
- *
134
- * It widens WHAT may be called, never WHICH cameras — `deviceIds` still
135
- * gates every talk call.
136
- */
137
- talk: zod_1.z.boolean().optional(),
138
- });
139
- /** Persisted record — never leaves the server with `tokenHash` attached. */
140
- exports.ShareTokenRecordSchema = zod_1.z.object({
141
- id: zod_1.z.string(),
142
- /** The user that minted the share link (owner — may revoke it). */
143
- userId: zod_1.z.string(),
144
- tokenHash: zod_1.z.string(),
145
- /** First 12 chars of the raw token — display/audit label only. */
146
- tokenPrefix: zod_1.z.string(),
147
- scope: exports.ShareTokenScopeSchema,
148
- createdAt: zod_1.z.number(),
149
- /** Epoch ms — or `null` for a never-expiring token (explicit choice at mint). */
150
- expiresAt: zod_1.z.number().nullable(),
151
- lastUsedAt: zod_1.z.number().optional(),
152
- });
153
- function parseShareToken(data) {
154
- const parsed = exports.ShareTokenRecordSchema.safeParse(data);
155
- return parsed.success ? parsed.data : null;
156
- }
157
- function recordToBody(record) {
158
- const { id: _id, ...body } = record;
159
- return body;
160
- }
161
- function rowToRecord(id, body) {
162
- return exports.ShareTokenRecordSchema.safeParse({ id, ...body }).data ?? null;
163
- }
164
- async function readAllShareTokenRows(kind, where) {
165
- const out = [];
166
- let cursor;
167
- for (let page = 0; page < 100; page += 1) {
168
- const answer = await kind.query({
169
- ...(where === undefined ? {} : { where }),
170
- limit: 1000,
171
- ...(cursor === undefined ? {} : { cursor }),
172
- });
173
- out.push(...answer.records);
174
- if (answer.nextCursor === null)
175
- return out;
176
- cursor = answer.nextCursor;
177
- }
178
- throw new Error('share-view-tokens: row count exceeds pagination bound');
179
- }
180
- class ShareTokenService {
181
- getStore;
182
- logger;
183
- /**
184
- * Declaration is one-time per process; reset only if the backend itself
185
- * is swapped (it never is at runtime — the getter is lazy solely because
186
- * the sqlite builtin registers after this service is constructed).
187
- */
188
- collectionDeclared = false;
189
- resolveKind;
190
- constructor(getStore, logger = null, resolveKind = () => null) {
191
- this.getStore = getStore;
192
- this.logger = logger;
193
- this.resolveKind = resolveKind;
194
- }
195
- /** Hub wiring after `records.forOwner('core-auth').declare(...)`. */
196
- bindShareTokensKind(resolver) {
197
- this.resolveKind = resolver;
198
- this.collectionDeclared = true;
199
- }
200
- kind() {
201
- return this.resolveKind();
202
- }
203
- async findById(id) {
204
- const kind = this.kind();
205
- if (kind !== null) {
206
- const row = await kind.get(id);
207
- return row === null ? null : rowToRecord(row.id, row.data);
208
- }
209
- const results = await (await this.store()).query({
210
- collection: SHARE_TOKENS_COLLECTION,
211
- filter: { where: { id } },
212
- });
213
- const first = results[0];
214
- return first === undefined ? null : parseShareToken(first.data);
215
- }
216
- async persistRecord(record) {
217
- const kind = this.kind();
218
- if (kind !== null) {
219
- const parsed = record_kinds_js_1.coreAuthRecordKinds['share-view-tokens'].schema.safeParse(recordToBody(record));
220
- if (!parsed.success)
221
- throw new Error(parsed.error.message);
222
- await kind.put({
223
- id: record.id,
224
- expiresAt: record.expiresAt ?? undefined,
225
- data: parsed.data,
226
- });
227
- return;
228
- }
229
- await (await this.store()).update({
230
- collection: SHARE_TOKENS_COLLECTION,
231
- id: record.id,
232
- data: { ...record },
233
- });
234
- }
235
- async deleteById(id) {
236
- const kind = this.kind();
237
- if (kind !== null) {
238
- await kind.delete(id);
239
- return;
240
- }
241
- await (await this.store()).delete({ collection: SHARE_TOKENS_COLLECTION, key: id });
242
- }
243
- /**
244
- * Resolve the backend AND ensure `share_view_tokens` is declared before
245
- * the first operation. The structured settings backend fail-closes on
246
- * undeclared collections; this KV shape (`id` PK + `data` TEXT) is
247
- * byte-identical to what the legacy on-demand path created, so existing
248
- * rows keep working with no migration.
249
- */
250
- async store() {
251
- if (this.kind() !== null) {
252
- const store = this.getStore();
253
- if (!store) {
254
- throw new Error('Share tokens unavailable — settings backend not ready');
255
- }
256
- return store;
257
- }
258
- const store = this.getStore();
259
- if (!store) {
260
- throw new Error('Share tokens unavailable — settings backend not ready');
261
- }
262
- if (!this.collectionDeclared) {
263
- await store.declareCollection({
264
- collection: SHARE_TOKENS_COLLECTION,
265
- columns: [
266
- { name: 'id', type: 'TEXT', primaryKey: true, notNull: true },
267
- { name: 'data', type: 'TEXT', notNull: true },
268
- ],
269
- indexes: SHARE_TOKENS_INDEXES,
270
- });
271
- this.collectionDeclared = true;
272
- }
273
- return store;
274
- }
275
- /**
276
- * Mint a new share token. Throws on invalid scope or out-of-bounds TTL
277
- * (fail fast — the router's Zod input schema should have caught both).
278
- * `ttlSec: 'never'` is the explicit opt-in to a never-expiring token
279
- * (`expiresAt: null`); omitted falls back to the 7-day default.
280
- * Returns the RAW token exactly once; only its hash is persisted.
281
- */
282
- async create(input) {
283
- const scope = exports.ShareTokenScopeSchema.parse(input.scope);
284
- const ttlSec = input.ttlSec ?? exports.SHARE_TOKEN_TTL_DEFAULT_SEC;
285
- if (ttlSec !== 'never' &&
286
- (!Number.isInteger(ttlSec) ||
287
- ttlSec < exports.SHARE_TOKEN_TTL_MIN_SEC ||
288
- ttlSec > exports.SHARE_TOKEN_TTL_MAX_SEC)) {
289
- throw new Error(`Share token TTL out of bounds — got ${ttlSec}s, allowed ` +
290
- `[${exports.SHARE_TOKEN_TTL_MIN_SEC}s, ${exports.SHARE_TOKEN_TTL_MAX_SEC}s] or 'never'`);
291
- }
292
- const rawToken = `${exports.SHARE_TOKEN_PREFIX}${crypto.randomBytes(32).toString('hex')}`;
293
- const now = Date.now();
294
- const record = {
295
- id: crypto.randomUUID(),
296
- userId: input.userId,
297
- tokenHash: crypto.createHash('sha256').update(rawToken).digest('hex'),
298
- tokenPrefix: rawToken.slice(0, 12),
299
- scope,
300
- createdAt: now,
301
- expiresAt: ttlSec === 'never' ? null : now + ttlSec * 1000,
302
- };
303
- const kind = this.kind();
304
- if (kind !== null) {
305
- const parsed = record_kinds_js_1.coreAuthRecordKinds['share-view-tokens'].schema.safeParse(recordToBody(record));
306
- if (!parsed.success)
307
- throw new Error(parsed.error.message);
308
- await kind.put({
309
- id: record.id,
310
- expiresAt: record.expiresAt ?? undefined,
311
- data: parsed.data,
312
- });
313
- }
314
- else {
315
- await (await this.store()).insert({
316
- collection: SHARE_TOKENS_COLLECTION,
317
- record: { id: record.id, data: { ...record } },
318
- });
319
- }
320
- this.logger?.info('Share token minted', {
321
- meta: {
322
- id: record.id,
323
- userId: record.userId,
324
- kind: scope.kind,
325
- deviceIds: scope.deviceIds,
326
- expiresAt: record.expiresAt,
327
- },
328
- });
329
- return { token: rawToken, record };
330
- }
331
- /**
332
- * Resolve a raw `csv_*` token to its record. Returns `null` for:
333
- * wrong prefix, unknown token, corrupt record, or expired token.
334
- * Never throws on bad input — the auth boundary treats `null` as 401.
335
- */
336
- async validate(rawToken) {
337
- if (!rawToken.startsWith(exports.SHARE_TOKEN_PREFIX))
338
- return null;
339
- const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
340
- const kind = this.kind();
341
- let record = null;
342
- if (kind !== null) {
343
- const page = await kind.query({ where: { tokenHash }, limit: 1 });
344
- const row = page.records[0];
345
- record = row === undefined ? null : rowToRecord(row.id, row.data);
346
- }
347
- else {
348
- const results = await (await this.store()).query({
349
- collection: SHARE_TOKENS_COLLECTION,
350
- filter: { where: { tokenHash } },
351
- });
352
- const first = results[0];
353
- record = first === undefined ? null : parseShareToken(first.data);
354
- }
355
- if (record === null)
356
- return null;
357
- // `expiresAt: null` = never expires (explicit mint-time choice);
358
- // revocation remains the kill switch for those.
359
- if (record.expiresAt !== null && Date.now() > record.expiresAt)
360
- return null;
361
- this.touchLastUsed(record).catch(() => {
362
- /* best-effort audit timestamp — never block validation */
363
- });
364
- return record;
365
- }
366
- /**
367
- * Slide `expiresAt` forward from NOW for a still-valid token. Same raw
368
- * credential, same id — Home Assistant remints without flooding
369
- * `share_view_tokens`. Throws when missing, expired, never-expiring,
370
- * or the caller is not the owner/admin; refuses `'never'` as ttlSec.
371
- */
372
- async extend(input) {
373
- if (!Number.isInteger(input.ttlSec) ||
374
- input.ttlSec < exports.SHARE_TOKEN_TTL_MIN_SEC ||
375
- input.ttlSec > exports.SHARE_TOKEN_TTL_MAX_SEC) {
376
- throw new Error(`Share token TTL out of bounds — got ${input.ttlSec}s, allowed ` +
377
- `[${exports.SHARE_TOKEN_TTL_MIN_SEC}s, ${exports.SHARE_TOKEN_TTL_MAX_SEC}s]`);
378
- }
379
- const record = await this.findById(input.id);
380
- if (record === null) {
381
- throw (0, types_1.userError)('auth.share-token-not-found', { id: input.id }, `Share token not found: ${input.id}`);
382
- }
383
- if (!input.callerIsAdmin && record.userId !== input.callerUserId) {
384
- throw (0, types_1.userError)('auth.share-owner-only-extend', undefined, 'Only the token owner or an admin can extend a share token');
385
- }
386
- if (record.expiresAt === null) {
387
- throw (0, types_1.userError)('auth.share-never-expiring-extend', undefined, 'A never-expiring share token cannot be extended');
388
- }
389
- const now = Date.now();
390
- if (now > record.expiresAt) {
391
- throw (0, types_1.userError)('server.share-token-expired', { id: String(input.id) }, `Share token expired: ${input.id}`);
392
- }
393
- const updated = {
394
- ...record,
395
- expiresAt: now + input.ttlSec * 1000,
396
- };
397
- await this.persistRecord(updated);
398
- this.logger?.info('Share token extended', {
399
- meta: {
400
- id: updated.id,
401
- byUserId: input.callerUserId,
402
- expiresAt: updated.expiresAt,
403
- },
404
- });
405
- return updated;
406
- }
407
- /**
408
- * Revoke (delete) a share token. Only the minting user or an admin may
409
- * revoke. Returns `false` when the token doesn't exist; throws on a
410
- * permission mismatch so the router surfaces a FORBIDDEN.
411
- */
412
- async revoke(input) {
413
- const record = await this.findById(input.id);
414
- if (record === null)
415
- return false;
416
- if (!input.callerIsAdmin && record.userId !== input.callerUserId) {
417
- throw (0, types_1.userError)('auth.share-owner-only-revoke', undefined, 'Only the token owner or an admin can revoke a share token');
418
- }
419
- await this.deleteById(input.id);
420
- this.logger?.info('Share token revoked', {
421
- meta: { id: record.id, byUserId: input.callerUserId },
422
- });
423
- return true;
424
- }
425
- /** Every share token minted by `userId`, expired ones included. */
426
- async listForUser(userId) {
427
- const kind = this.kind();
428
- if (kind !== null) {
429
- const rows = await readAllShareTokenRows(kind, { userId });
430
- return rows
431
- .map((row) => rowToRecord(row.id, row.data))
432
- .filter((r) => r !== null);
433
- }
434
- const results = await (await this.store()).query({
435
- collection: SHARE_TOKENS_COLLECTION,
436
- filter: { where: { userId } },
437
- });
438
- return results.map((r) => parseShareToken(r.data)).filter((r) => r !== null);
439
- }
440
- /** All share tokens (admin listing). */
441
- async listAll() {
442
- const kind = this.kind();
443
- if (kind !== null) {
444
- const rows = await readAllShareTokenRows(kind);
445
- return rows
446
- .map((row) => rowToRecord(row.id, row.data))
447
- .filter((r) => r !== null);
448
- }
449
- const results = await (await this.store()).query({
450
- collection: SHARE_TOKENS_COLLECTION,
451
- filter: {},
452
- });
453
- return results.map((r) => parseShareToken(r.data)).filter((r) => r !== null);
454
- }
455
- async touchLastUsed(record) {
456
- await this.persistRecord({ ...record, lastUsedAt: Date.now() });
457
- }
458
- }
459
- exports.ShareTokenService = ShareTokenService;