@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.
- package/dist/api/auth-whoami.js +0 -1
- package/dist/api/core/auth-audit.procedures.js +44 -0
- package/dist/api/core/auth-revoke-credentials.procedures.js +4 -78
- package/dist/api/core/auth-tokens.procedures.js +105 -72
- package/dist/api/core/auth.router.js +14 -280
- 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-visibility.js +2 -2
- package/dist/api/trpc/share-view-access.js +1 -1
- package/dist/api/trpc/trpc-error-principal.js +1 -2
- package/dist/api/trpc/trpc.context.js +16 -13
- package/dist/api/trpc/trpc.router.js +6 -1
- package/dist/api/upload-auth.js +13 -25
- package/dist/auth/addon-route-share-gate.js +2 -2
- package/dist/auth/credential-verifier.js +63 -104
- package/dist/auth/embed-auth-route.js +45 -0
- package/dist/auth/live-user.js +19 -0
- package/dist/auth/scope-escalation.js +77 -2
- package/dist/boot/boot-config.js +8 -1
- package/dist/core/addon/addon-registry.service.js +14 -20
- package/dist/core/auth/credential-token.service.js +9 -14
- package/dist/core/auth/handoff-code.service.js +1 -1
- package/dist/core/auth/record-kinds.js +7 -2
- package/dist/main.js +18 -48
- package/package.json +14 -14
- package/dist/api/core/auth-admin-tokens.procedures.js +0 -156
- package/dist/core/auth/share-token.service.js +0 -459
|
@@ -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;
|