@gnolith/taproot 0.2.0 → 0.3.0

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/COMPATIBILITY.md +5 -3
  3. package/README.md +106 -33
  4. package/SECURITY.md +6 -3
  5. package/dist/authorization-maintenance.d.ts +55 -0
  6. package/dist/authorization-maintenance.d.ts.map +1 -0
  7. package/dist/authorization-maintenance.js +686 -0
  8. package/dist/authorization-maintenance.js.map +1 -0
  9. package/dist/authorization.d.ts +98 -0
  10. package/dist/authorization.d.ts.map +1 -0
  11. package/dist/authorization.js +1137 -0
  12. package/dist/authorization.js.map +1 -0
  13. package/dist/canonical.d.ts +2 -1
  14. package/dist/canonical.d.ts.map +1 -1
  15. package/dist/canonical.js +9 -1
  16. package/dist/canonical.js.map +1 -1
  17. package/dist/errors.d.ts +4 -0
  18. package/dist/errors.d.ts.map +1 -1
  19. package/dist/errors.js +4 -0
  20. package/dist/errors.js.map +1 -1
  21. package/dist/index.d.ts +125 -49
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +351 -44
  24. package/dist/index.js.map +1 -1
  25. package/dist/migrations.d.ts +9 -3
  26. package/dist/migrations.d.ts.map +1 -1
  27. package/dist/migrations.js +77 -5
  28. package/dist/migrations.js.map +1 -1
  29. package/dist/repository.d.ts +20 -9
  30. package/dist/repository.d.ts.map +1 -1
  31. package/dist/repository.js +593 -48
  32. package/dist/repository.js.map +1 -1
  33. package/dist/schema.d.ts +14 -5
  34. package/dist/schema.d.ts.map +1 -1
  35. package/dist/schema.js +461 -18
  36. package/dist/schema.js.map +1 -1
  37. package/dist/types.d.ts +59 -0
  38. package/dist/types.d.ts.map +1 -1
  39. package/docs/api.md +56 -18
  40. package/docs/architecture.md +19 -7
  41. package/docs/authorization.md +82 -0
  42. package/docs/completion-audit.md +15 -15
  43. package/docs/operations.md +28 -5
  44. package/docs/product-scope.md +5 -3
  45. package/docs/testing.md +18 -1
  46. package/docs/threat-model.md +30 -3
  47. package/examples/d1-diamond-interop/README.md +2 -1
  48. package/examples/d1-diamond-interop/demo.ts +150 -21
  49. package/migrations/0003_canonical_statement_text.sql +31 -0
  50. package/migrations/0004_canonical_authorization_policy.sql +213 -0
  51. package/package.json +4 -1
@@ -0,0 +1,1137 @@
1
+ import { AuthorizationDeniedError, InvalidAuthorizationError, InvalidCursorError, } from './errors.js';
2
+ import { exportEntityJson } from './canonical.js';
3
+ import { TaprootRepository } from './repository.js';
4
+ export const SEARCH_ADMIN_CAPABILITY = 'search:admin';
5
+ export const KNOWLEDGE_WRITE_CAPABILITY = 'knowledge:write';
6
+ export const KNOWLEDGE_POLICY_CAPABILITY = 'knowledge:policy';
7
+ const cursorCodecs = new WeakMap();
8
+ export function createAuthorizationCursorCodec(key) {
9
+ if (key.type !== 'secret' ||
10
+ key.extractable ||
11
+ key.algorithm.name !== 'AES-GCM' ||
12
+ !key.usages.includes('encrypt') ||
13
+ !key.usages.includes('decrypt'))
14
+ invalid('cursor key must be a non-extractable AES-GCM key with encrypt/decrypt usages');
15
+ const codec = Object.freeze({ kind: 'taproot-aes-gcm-v1' });
16
+ cursorCodecs.set(codec, key);
17
+ return codec;
18
+ }
19
+ const MAX_IDENTIFIER_LENGTH = 256;
20
+ const MAX_CLAUSES = 64;
21
+ const MAX_ATOMS_PER_CLAUSE = 64;
22
+ const CURSOR_AAD = new TextEncoder().encode('taproot-authorized-cursor-v1').buffer;
23
+ const CURSOR_PLAINTEXT_BYTES = 1024;
24
+ export function normalizeAuthorizationContext(value) {
25
+ if (!isRecord(value))
26
+ invalid('authorization context must be an object');
27
+ exactKeys(value, [
28
+ 'installationId',
29
+ 'principalId',
30
+ 'activeWorkspaceId',
31
+ 'workspaceIds',
32
+ 'capabilities',
33
+ 'authorizationRevision',
34
+ ]);
35
+ const installationId = identifier(value.installationId, 'installationId');
36
+ const principalId = identifier(value.principalId, 'principalId');
37
+ const activeWorkspaceId = value.activeWorkspaceId === null
38
+ ? null
39
+ : identifier(value.activeWorkspaceId, 'activeWorkspaceId');
40
+ const workspaceIds = stringSet(value.workspaceIds, 'workspaceIds');
41
+ const capabilities = stringSet(value.capabilities, 'capabilities');
42
+ const authorizationRevision = revision(value.authorizationRevision, 'authorizationRevision');
43
+ if (activeWorkspaceId !== null && !workspaceIds.includes(activeWorkspaceId))
44
+ invalid('activeWorkspaceId must be present in workspaceIds');
45
+ return {
46
+ installationId,
47
+ principalId,
48
+ activeWorkspaceId,
49
+ workspaceIds,
50
+ capabilities,
51
+ authorizationRevision,
52
+ };
53
+ }
54
+ export function normalizeVisibilityScope(value) {
55
+ if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.clauses))
56
+ invalid('visibility scope must be a version 1 CNF object');
57
+ exactKeys(value, ['version', 'clauses']);
58
+ if (value.clauses.length > MAX_CLAUSES)
59
+ invalid(`visibility scope exceeds ${MAX_CLAUSES} clauses`);
60
+ const clauses = new Map();
61
+ for (const rawClause of value.clauses) {
62
+ if (!Array.isArray(rawClause) || rawClause.length === 0)
63
+ invalid('visibility clauses must contain at least one atom');
64
+ if (rawClause.length > MAX_ATOMS_PER_CLAUSE)
65
+ invalid(`visibility clause exceeds ${MAX_ATOMS_PER_CLAUSE} atoms`);
66
+ const atoms = new Map();
67
+ let publicClause = false;
68
+ for (const rawAtom of rawClause) {
69
+ const atom = normalizeAtom(rawAtom);
70
+ if (atom.kind === 'public') {
71
+ publicClause = true;
72
+ break;
73
+ }
74
+ atoms.set(serializeAtom(atom), atom);
75
+ }
76
+ // A public OR-clause is true and therefore has no effect in an AND scope.
77
+ if (publicClause)
78
+ continue;
79
+ const sorted = [...atoms.entries()]
80
+ .sort(([left], [right]) => compareCodeUnits(left, right))
81
+ .map(([, atom]) => atom);
82
+ if (sorted.length === 0)
83
+ invalid('visibility clause is empty after normalization');
84
+ clauses.set(JSON.stringify(sorted), sorted);
85
+ }
86
+ return {
87
+ version: 1,
88
+ clauses: [...clauses.entries()]
89
+ .sort(([left], [right]) => compareCodeUnits(left, right))
90
+ .map(([, clause]) => clause),
91
+ };
92
+ }
93
+ export function serializeVisibilityScope(scope) {
94
+ return JSON.stringify(normalizeVisibilityScope(scope));
95
+ }
96
+ export async function visibilityScopeFingerprint(scope) {
97
+ const bytes = new TextEncoder().encode(serializeVisibilityScope(scope));
98
+ const digest = await crypto.subtle.digest('SHA-256', bytes);
99
+ return [...new Uint8Array(digest)]
100
+ .map((byte) => byte.toString(16).padStart(2, '0'))
101
+ .join('');
102
+ }
103
+ /** CNF intersection is lossless clause concatenation followed by normalization. */
104
+ export function intersectVisibilityScopes(...scopes) {
105
+ return normalizeVisibilityScope({
106
+ version: 1,
107
+ clauses: scopes.flatMap((scope) => normalizeVisibilityScope(scope).clauses),
108
+ });
109
+ }
110
+ export function normalizeCanonicalAuthorizationPolicy(value) {
111
+ if (!isRecord(value))
112
+ invalid('authorization policy must be an object');
113
+ exactKeys(value, [
114
+ 'installationId',
115
+ 'workspaceId',
116
+ 'ownerPrincipalId',
117
+ 'visibility',
118
+ 'statementRestrictions',
119
+ 'expectedAuthorizationRevision',
120
+ ]);
121
+ if (!isRecord(value.statementRestrictions))
122
+ invalid('statementRestrictions must be an object');
123
+ const statementRestrictions = {};
124
+ for (const [statementId, scopes] of Object.entries(value.statementRestrictions).sort(([left], [right]) => compareCodeUnits(left, right))) {
125
+ identifier(statementId, 'statementId');
126
+ if (!Array.isArray(scopes))
127
+ invalid(`statementRestrictions.${statementId} must be an array`);
128
+ const normalizedScopes = [];
129
+ for (let index = 0; index < scopes.length; index += 1) {
130
+ if (!Object.hasOwn(scopes, index))
131
+ invalid(`statementRestrictions.${statementId} must be dense`);
132
+ normalizedScopes.push(normalizeVisibilityScope(scopes[index]));
133
+ }
134
+ statementRestrictions[statementId] = normalizedScopes;
135
+ }
136
+ return {
137
+ installationId: identifier(value.installationId, 'installationId'),
138
+ workspaceId: value.workspaceId === null
139
+ ? null
140
+ : identifier(value.workspaceId, 'workspaceId'),
141
+ ownerPrincipalId: identifier(value.ownerPrincipalId, 'ownerPrincipalId'),
142
+ visibility: normalizeVisibilityScope(value.visibility),
143
+ statementRestrictions,
144
+ expectedAuthorizationRevision: revision(value.expectedAuthorizationRevision, 'expectedAuthorizationRevision'),
145
+ };
146
+ }
147
+ export function isVisibleTo(rawScope, rawContext) {
148
+ const scope = normalizeVisibilityScope(rawScope);
149
+ const context = normalizeAuthorizationContext(rawContext);
150
+ const workspaces = new Set(context.workspaceIds);
151
+ const capabilities = new Set(context.capabilities);
152
+ return scope.clauses.every((clause) => clause.some((atom) => {
153
+ switch (atom.kind) {
154
+ case 'public':
155
+ return true;
156
+ case 'principal':
157
+ return atom.principalId === context.principalId;
158
+ case 'workspace':
159
+ return workspaces.has(atom.workspaceId);
160
+ case 'capability':
161
+ return capabilities.has(atom.capability);
162
+ }
163
+ }));
164
+ }
165
+ export function hasSearchAdministration(context) {
166
+ return normalizeAuthorizationContext(context).capabilities.includes(SEARCH_ADMIN_CAPABILITY);
167
+ }
168
+ export function requireSearchAdministration(context) {
169
+ if (!hasSearchAdministration(context))
170
+ throw new AuthorizationDeniedError('Authorization denied');
171
+ }
172
+ /**
173
+ * Authorization-enforcing canonical read boundary. The context and policy
174
+ * source are mandatory, and policy is checked both before and after hydration.
175
+ */
176
+ export class AuthorizedTaprootReader {
177
+ #db;
178
+ #repository;
179
+ #context;
180
+ #authorization;
181
+ #cursorCodec;
182
+ constructor(db, options, context, authorizedOptions) {
183
+ this.#db = db;
184
+ this.#repository = new TaprootRepository(db, options);
185
+ this.#context = normalizeAuthorizationContext(context);
186
+ this.#authorization = new PersistedEntityAuthorizationSource(db);
187
+ if (!isRecord(authorizedOptions))
188
+ invalid('authorized options are required');
189
+ exactKeys(authorizedOptions, ['cursorCodec']);
190
+ if (!cursorCodecs.has(authorizedOptions.cursorCodec))
191
+ invalid('host-issued cursor codec is required');
192
+ this.#cursorCodec = authorizedOptions.cursorCodec;
193
+ }
194
+ async getEntity(id) {
195
+ return this.#authorizedHydration(id, undefined, () => this.#repository.getEntity(id));
196
+ }
197
+ async getEntityRevision(id, revisionNumber) {
198
+ return this.#authorizedHydration(id, revisionNumber, () => this.#repository.getEntityRevision(id, revisionNumber));
199
+ }
200
+ async resolveEntity(id, maxDepth = 100) {
201
+ if (!Number.isSafeInteger(maxDepth) || maxDepth < 0 || maxDepth > 1000)
202
+ throw new RangeError('maxDepth must be an integer from 0 through 1000');
203
+ const redirects = [];
204
+ const seen = new Set();
205
+ let current = id;
206
+ for (;;) {
207
+ if (seen.has(current))
208
+ throw new AuthorizationDeniedError('Authorization denied');
209
+ seen.add(current);
210
+ const stored = await this.getEntity(current);
211
+ if (!stored.redirectTo)
212
+ return { ...stored, requestedId: id, resolvedId: current, redirects };
213
+ if (redirects.length >= maxDepth)
214
+ throw new AuthorizationDeniedError('Authorization denied');
215
+ redirects.push(stored.redirectTo);
216
+ current = stored.redirectTo;
217
+ }
218
+ }
219
+ async listEntityRevisions(id, options = {}) {
220
+ const limit = authorizedLimit(options.limit, 50);
221
+ const binding = await this.#binding('entity-revisions', { id });
222
+ const generation = await this.#generation();
223
+ let before = Number.MAX_SAFE_INTEGER;
224
+ if (options.cursor) {
225
+ const cursor = await this.#decodeCursor(options.cursor, binding, generation);
226
+ before = cursorNumber(cursor.position, 'revision');
227
+ }
228
+ await this.#assertContextCurrent();
229
+ const items = [];
230
+ let exhausted = false;
231
+ while (items.length < limit && !exhausted) {
232
+ const batch = await this.#db
233
+ .prepare(`SELECT revision FROM taproot_entity_revisions
234
+ WHERE entity_id = ? AND revision < ?
235
+ ORDER BY revision DESC LIMIT ?`)
236
+ .bind(id, before, scanLimit(limit - items.length))
237
+ .all();
238
+ exhausted = batch.results.length < scanLimit(limit - items.length);
239
+ for (let index = 0; index < batch.results.length; index += 1) {
240
+ const candidate = batch.results[index];
241
+ before = candidate.revision;
242
+ try {
243
+ items.push(await this.#authorizedHydration(id, candidate.revision, () => this.#repository.getEntityRevision(id, candidate.revision)));
244
+ }
245
+ catch (error) {
246
+ if (!(error instanceof AuthorizationDeniedError))
247
+ throw error;
248
+ }
249
+ if (items.length === limit) {
250
+ if (index < batch.results.length - 1)
251
+ exhausted = false;
252
+ break;
253
+ }
254
+ }
255
+ if (!batch.results.length)
256
+ exhausted = true;
257
+ }
258
+ await this.#reauthorize(items.map(() => id));
259
+ await this.#assertGeneration(generation);
260
+ return {
261
+ items,
262
+ cursor: !exhausted && items.length
263
+ ? await this.#encodeCursor(binding, generation, { revision: before })
264
+ : null,
265
+ };
266
+ }
267
+ async listEntities(options = {}) {
268
+ const limit = authorizedLimit(options.limit, 50);
269
+ const filter = {
270
+ type: options.type ?? null,
271
+ includeDeleted: options.includeDeleted ?? false,
272
+ };
273
+ const binding = await this.#binding('entities', filter);
274
+ const generation = await this.#generation();
275
+ let position = null;
276
+ if (options.cursor) {
277
+ const cursor = await this.#decodeCursor(options.cursor, binding, generation);
278
+ position = entityPosition(cursor.position);
279
+ }
280
+ await this.#assertContextCurrent();
281
+ const items = [];
282
+ let exhausted = false;
283
+ while (items.length < limit && !exhausted) {
284
+ const amount = scanLimit(limit - items.length);
285
+ const result = await this.#db
286
+ .prepare(`SELECT entity_id, entity_type FROM taproot_entities
287
+ WHERE (? IS NULL OR entity_type > ? OR
288
+ (entity_type = ? AND CAST(substr(entity_id, 2) AS INTEGER) > ?))
289
+ AND (? IS NULL OR entity_type = ?)
290
+ AND (? = 1 OR deleted_at IS NULL)
291
+ ORDER BY entity_type, CAST(substr(entity_id, 2) AS INTEGER) LIMIT ?`)
292
+ .bind(position?.entityType ?? null, position?.entityType ?? null, position?.entityType ?? null, position?.numericId ?? 0, filter.type, filter.type, filter.includeDeleted ? 1 : 0, amount)
293
+ .all();
294
+ exhausted = result.results.length < amount;
295
+ for (let index = 0; index < result.results.length; index += 1) {
296
+ const candidate = result.results[index];
297
+ position = {
298
+ entityType: candidate.entity_type,
299
+ numericId: Number(candidate.entity_id.slice(1)),
300
+ };
301
+ try {
302
+ const stored = await this.#authorizedHydration(candidate.entity_id, undefined, () => this.#repository.getEntity(candidate.entity_id));
303
+ items.push({ entityId: candidate.entity_id, ...stored });
304
+ }
305
+ catch (error) {
306
+ if (!(error instanceof AuthorizationDeniedError))
307
+ throw error;
308
+ }
309
+ if (items.length === limit) {
310
+ if (index < result.results.length - 1)
311
+ exhausted = false;
312
+ break;
313
+ }
314
+ }
315
+ if (!result.results.length)
316
+ exhausted = true;
317
+ }
318
+ await this.#reauthorize(items.map((item) => item.entityId));
319
+ await this.#assertGeneration(generation);
320
+ return {
321
+ items,
322
+ cursor: !exhausted && position
323
+ ? await this.#encodeCursor(binding, generation, position)
324
+ : null,
325
+ };
326
+ }
327
+ async searchEntities(query, options = {}) {
328
+ const limit = authorizedLimit(options.limit, 20);
329
+ const filter = {
330
+ query,
331
+ language: options.language ?? null,
332
+ includeDeleted: options.includeDeleted ?? false,
333
+ };
334
+ const binding = await this.#binding('term-search', filter);
335
+ const generation = await this.#generation();
336
+ let offset = 0;
337
+ if (options.cursor) {
338
+ const cursor = await this.#decodeCursor(options.cursor, binding, generation);
339
+ offset = cursorNumber(cursor.position, 'offset');
340
+ }
341
+ await this.#assertContextCurrent();
342
+ const escaped = query.replace(/[\\%_]/gu, '\\$&');
343
+ const items = [];
344
+ let exhausted = false;
345
+ while (items.length < limit && !exhausted) {
346
+ const amount = scanLimit(limit - items.length);
347
+ const candidates = await this.#db
348
+ .prepare(`SELECT t.entity_id
349
+ FROM taproot_terms t JOIN taproot_entities e ON e.entity_id = t.entity_id
350
+ WHERE t.value LIKE ? ESCAPE '\\' COLLATE NOCASE
351
+ AND (? IS NULL OR t.language = ?)
352
+ AND (? = 1 OR e.deleted_at IS NULL)
353
+ ORDER BY CASE WHEN t.value = ? COLLATE NOCASE THEN 0 ELSE 1 END,
354
+ t.value COLLATE NOCASE, t.entity_id, t.language, t.term_type, t.ordinal
355
+ LIMIT ? OFFSET ?`)
356
+ .bind(`%${escaped}%`, filter.language, filter.language, filter.includeDeleted ? 1 : 0, query, amount, offset)
357
+ .all();
358
+ exhausted = candidates.results.length < amount;
359
+ const batchOffset = offset;
360
+ for (let index = 0; index < candidates.results.length; index += 1) {
361
+ const candidate = candidates.results[index];
362
+ const candidateOffset = batchOffset + index;
363
+ try {
364
+ const before = await this.#readAndAuthorize(candidate.entity_id);
365
+ const hydrated = await this.#db
366
+ .prepare(`SELECT t.entity_id, e.entity_type, t.language, t.term_type, t.value
367
+ FROM taproot_terms t JOIN taproot_entities e ON e.entity_id = t.entity_id
368
+ WHERE t.value LIKE ? ESCAPE '\\' COLLATE NOCASE
369
+ AND (? IS NULL OR t.language = ?)
370
+ AND (? = 1 OR e.deleted_at IS NULL)
371
+ ORDER BY CASE WHEN t.value = ? COLLATE NOCASE THEN 0 ELSE 1 END,
372
+ t.value COLLATE NOCASE, t.entity_id, t.language, t.term_type, t.ordinal
373
+ LIMIT 1 OFFSET ?`)
374
+ .bind(`%${escaped}%`, filter.language, filter.language, filter.includeDeleted ? 1 : 0, query, candidateOffset)
375
+ .all();
376
+ const row = hydrated.results[0];
377
+ if (!row || row.entity_id !== candidate.entity_id)
378
+ throw new AuthorizationDeniedError('Authorization denied');
379
+ const after = await this.#readAndAuthorize(candidate.entity_id);
380
+ if (before.state.authorizationRevision !==
381
+ after.state.authorizationRevision ||
382
+ before.record.authorizationRevision !==
383
+ after.record.authorizationRevision ||
384
+ serializeVisibilityScope(before.record.visibility) !==
385
+ serializeVisibilityScope(after.record.visibility))
386
+ throw new AuthorizationDeniedError('Authorization denied');
387
+ items.push({
388
+ entityId: row.entity_id,
389
+ entityType: row.entity_type,
390
+ language: row.language,
391
+ termType: row.term_type,
392
+ value: row.value,
393
+ });
394
+ }
395
+ catch (error) {
396
+ if (!(error instanceof AuthorizationDeniedError))
397
+ throw error;
398
+ }
399
+ offset = candidateOffset + 1;
400
+ if (items.length === limit) {
401
+ if (index < candidates.results.length - 1)
402
+ exhausted = false;
403
+ break;
404
+ }
405
+ }
406
+ if (!candidates.results.length)
407
+ exhausted = true;
408
+ }
409
+ await this.#reauthorize(items.map((item) => item.entityId));
410
+ await this.#assertGeneration(generation);
411
+ return {
412
+ items,
413
+ cursor: !exhausted && items.length
414
+ ? await this.#encodeCursor(binding, generation, { offset })
415
+ : null,
416
+ };
417
+ }
418
+ async getAuditEvent(eventId) {
419
+ const candidate = await this.#db
420
+ .prepare(`SELECT entity_id, revision FROM taproot_audit_events WHERE event_id = ?`)
421
+ .bind(eventId)
422
+ .all();
423
+ const row = candidate.results[0];
424
+ if (!row)
425
+ throw new AuthorizationDeniedError('Authorization denied');
426
+ return this.#authorizedHydration(row.entity_id, row.revision, () => this.#repository.getAuditEvent(eventId));
427
+ }
428
+ async listAuditEvents(options = {}) {
429
+ const limit = authorizedLimit(options.limit, 50);
430
+ const filter = {
431
+ entityId: options.entityId ?? null,
432
+ requestId: options.requestId ?? null,
433
+ type: options.type ?? null,
434
+ attributionId: options.attributionId ?? null,
435
+ tag: options.tag ?? null,
436
+ };
437
+ const binding = await this.#binding('audit-events', filter);
438
+ const generation = await this.#generation();
439
+ let before = Number.MAX_SAFE_INTEGER;
440
+ if (options.cursor) {
441
+ const cursor = await this.#decodeCursor(options.cursor, binding, generation);
442
+ before = cursorNumber(cursor.position, 'sequence');
443
+ }
444
+ await this.#assertContextCurrent();
445
+ const items = [];
446
+ let exhausted = false;
447
+ while (items.length < limit && !exhausted) {
448
+ const amount = scanLimit(limit - items.length);
449
+ const candidates = await this.#db
450
+ .prepare(`SELECT rowid AS sequence, event_id, entity_id, revision FROM taproot_audit_events
451
+ WHERE (? IS NULL OR entity_id = ?) AND (? IS NULL OR request_id = ?)
452
+ AND (? IS NULL OR event_type = ?)
453
+ AND (? IS NULL OR json_extract(attribution_json, '$.id') = ?)
454
+ AND (? IS NULL OR EXISTS (SELECT 1 FROM json_each(tags_json) WHERE value = ?))
455
+ AND rowid < ? ORDER BY rowid DESC LIMIT ?`)
456
+ .bind(filter.entityId, filter.entityId, filter.requestId, filter.requestId, filter.type, filter.type, filter.attributionId, filter.attributionId, filter.tag, filter.tag, before, amount)
457
+ .all();
458
+ exhausted = candidates.results.length < amount;
459
+ for (let index = 0; index < candidates.results.length; index += 1) {
460
+ const candidate = candidates.results[index];
461
+ before = candidate.sequence;
462
+ try {
463
+ items.push(await this.#authorizedHydration(candidate.entity_id, candidate.revision, () => this.#repository.getAuditEvent(candidate.event_id)));
464
+ }
465
+ catch (error) {
466
+ if (!(error instanceof AuthorizationDeniedError))
467
+ throw error;
468
+ }
469
+ if (items.length === limit) {
470
+ if (index < candidates.results.length - 1)
471
+ exhausted = false;
472
+ break;
473
+ }
474
+ }
475
+ if (!candidates.results.length)
476
+ exhausted = true;
477
+ }
478
+ await this.#reauthorize(items.map((item) => item.entityId));
479
+ await this.#assertGeneration(generation);
480
+ return {
481
+ items,
482
+ cursor: !exhausted && items.length
483
+ ? await this.#encodeCursor(binding, generation, { sequence: before })
484
+ : null,
485
+ };
486
+ }
487
+ async exportEntities(options = {}) {
488
+ const lines = [];
489
+ let cursor;
490
+ do {
491
+ const page = await this.listEntities({
492
+ ...options,
493
+ limit: 100,
494
+ ...(cursor ? { cursor } : {}),
495
+ });
496
+ for (const item of page.items)
497
+ lines.push(exportEntityJson(item.entity));
498
+ cursor = page.cursor ?? undefined;
499
+ } while (cursor);
500
+ return lines.length ? `${lines.join('\n')}\n` : '';
501
+ }
502
+ async inspectEntityIntegrity(id) {
503
+ requireSearchAdministration(this.#context);
504
+ return this.#authorizedHydration(id, undefined, () => this.#repository.inspectEntityIntegrity(id));
505
+ }
506
+ async inspectTaprootIntegrity(options = {}) {
507
+ requireSearchAdministration(this.#context);
508
+ const entities = await this.listEntities({
509
+ ...options,
510
+ includeDeleted: true,
511
+ });
512
+ const items = [];
513
+ for (const entity of entities.items)
514
+ items.push(await this.inspectEntityIntegrity(entity.entityId));
515
+ return { items, cursor: entities.cursor };
516
+ }
517
+ async verifyAuditChain(id) {
518
+ requireSearchAdministration(this.#context);
519
+ return this.#authorizedHydration(id, undefined, async () => {
520
+ const generation = await this.#generation();
521
+ const revisions = await this.#db
522
+ .prepare(`SELECT revision FROM taproot_entity_revisions
523
+ WHERE entity_id = ? ORDER BY revision`)
524
+ .bind(id)
525
+ .all();
526
+ const before = [];
527
+ for (const { revision } of revisions.results)
528
+ before.push(await this.#readAndAuthorize(id, revision));
529
+ const report = await this.#repository.verifyAuditChain(id);
530
+ for (let index = 0; index < revisions.results.length; index += 1) {
531
+ const after = await this.#readAndAuthorize(id, revisions.results[index].revision);
532
+ const prior = before[index];
533
+ if (prior.state.authorizationRevision !==
534
+ after.state.authorizationRevision ||
535
+ prior.record.authorizationRevision !==
536
+ after.record.authorizationRevision ||
537
+ serializeVisibilityScope(prior.record.visibility) !==
538
+ serializeVisibilityScope(after.record.visibility))
539
+ throw new AuthorizationDeniedError('Authorization denied');
540
+ }
541
+ await this.#assertGeneration(generation);
542
+ return report;
543
+ });
544
+ }
545
+ async repairEntityProjection(id, metadata = {}) {
546
+ requireSearchAdministration(this.#context);
547
+ return this.#authorizedHydration(id, undefined, () => this.#repository.repairEntityProjection(id, metadata));
548
+ }
549
+ async #binding(operation, filter) {
550
+ return fingerprintValue({
551
+ operation,
552
+ filter,
553
+ installationId: this.#context.installationId,
554
+ principalId: this.#context.principalId,
555
+ activeWorkspaceId: this.#context.activeWorkspaceId,
556
+ workspaceIds: this.#context.workspaceIds,
557
+ capabilities: this.#context.capabilities,
558
+ authorizationRevision: this.#context.authorizationRevision,
559
+ });
560
+ }
561
+ async #generation() {
562
+ const result = await this.#db
563
+ .prepare(`SELECT
564
+ COALESCE((SELECT MAX(rowid) FROM taproot_entity_revisions), 0) AS revision_generation,
565
+ COALESCE((SELECT MAX(rowid) FROM taproot_audit_events), 0) AS audit_generation,
566
+ COALESCE((SELECT search_generation FROM taproot_installation_authorization
567
+ WHERE singleton = 1), 0) AS search_generation`)
568
+ .all();
569
+ const revisionGeneration = Number(result.results[0]?.revision_generation ?? 0);
570
+ const auditGeneration = Number(result.results[0]?.audit_generation ?? 0);
571
+ const searchGeneration = Number(result.results[0]?.search_generation ?? 0);
572
+ if (!Number.isSafeInteger(revisionGeneration) ||
573
+ revisionGeneration < 0 ||
574
+ !Number.isSafeInteger(auditGeneration) ||
575
+ auditGeneration < 0 ||
576
+ !Number.isSafeInteger(searchGeneration) ||
577
+ searchGeneration < 1)
578
+ throw new AuthorizationDeniedError('Authorization denied');
579
+ return `${revisionGeneration}:${auditGeneration}:${searchGeneration}`;
580
+ }
581
+ async #assertGeneration(expected) {
582
+ if ((await this.#generation()) !== expected)
583
+ throw new AuthorizationDeniedError('Authorization denied');
584
+ }
585
+ async #assertContextCurrent() {
586
+ try {
587
+ const state = normalizeInstallationState(await this.#authorization.getInstallationAuthorizationState());
588
+ if (state.installationId !== this.#context.installationId ||
589
+ state.authorizationRevision !== this.#context.authorizationRevision)
590
+ throw new AuthorizationDeniedError('Authorization denied');
591
+ }
592
+ catch {
593
+ throw new AuthorizationDeniedError('Authorization denied');
594
+ }
595
+ }
596
+ async #reauthorize(entityIds) {
597
+ await this.#assertContextCurrent();
598
+ for (const entityId of new Set(entityIds))
599
+ await this.#readAndAuthorize(entityId);
600
+ }
601
+ async #encodeCursor(binding, generation, position) {
602
+ const key = cursorCodecs.get(this.#cursorCodec);
603
+ const iv = crypto.getRandomValues(new Uint8Array(12));
604
+ const serialized = new TextEncoder().encode(JSON.stringify({ version: 1, binding, generation, position }));
605
+ if (serialized.length > CURSOR_PLAINTEXT_BYTES)
606
+ throw new AuthorizationDeniedError('Authorization denied');
607
+ const plaintext = new Uint8Array(CURSOR_PLAINTEXT_BYTES);
608
+ plaintext.fill(0x20);
609
+ plaintext.set(serialized);
610
+ const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: CURSOR_AAD }, key, plaintext);
611
+ return `${base64Url(iv)}.${base64Url(new Uint8Array(encrypted))}`;
612
+ }
613
+ async #decodeCursor(encoded, binding, generation) {
614
+ try {
615
+ if (encoded.length > 4096)
616
+ throw new Error('invalid cursor');
617
+ const parts = encoded.split('.');
618
+ if (parts.length !== 2)
619
+ throw new Error('invalid cursor');
620
+ const key = cursorCodecs.get(this.#cursorCodec);
621
+ const decrypted = await crypto.subtle.decrypt({
622
+ name: 'AES-GCM',
623
+ iv: fromBase64Url(parts[0]).buffer,
624
+ additionalData: CURSOR_AAD,
625
+ }, key, fromBase64Url(parts[1]).buffer);
626
+ const payload = JSON.parse(new TextDecoder().decode(decrypted));
627
+ if (!isRecord(payload) ||
628
+ payload.version !== 1 ||
629
+ payload.binding !== binding ||
630
+ payload.generation !== generation ||
631
+ !Object.hasOwn(payload, 'position') ||
632
+ Object.keys(payload).length !== 4)
633
+ throw new Error('invalid cursor');
634
+ return payload;
635
+ }
636
+ catch {
637
+ throw new InvalidCursorError('Cursor is invalid');
638
+ }
639
+ }
640
+ async #authorizedHydration(entityId, revisionNumber, hydrate) {
641
+ const before = await this.#readAndAuthorize(entityId, revisionNumber);
642
+ const hydrated = await hydrate();
643
+ const after = await this.#readAndAuthorize(entityId, revisionNumber);
644
+ if (before.state.authorizationRevision !==
645
+ after.state.authorizationRevision ||
646
+ before.record.authorizationRevision !==
647
+ after.record.authorizationRevision ||
648
+ serializeVisibilityScope(before.record.visibility) !==
649
+ serializeVisibilityScope(after.record.visibility))
650
+ throw new AuthorizationDeniedError('Authorization denied');
651
+ return hydrated;
652
+ }
653
+ async #readAndAuthorize(entityId, revisionNumber) {
654
+ let state;
655
+ let record;
656
+ try {
657
+ [state, record] = await Promise.all([
658
+ this.#authorization.getInstallationAuthorizationState(),
659
+ this.#authorization.getEntityAuthorization(entityId),
660
+ ]);
661
+ const normalizedState = normalizeInstallationState(state);
662
+ const current = normalizeAuthorizationRecord(record);
663
+ if (normalizedState.installationId !== this.#context.installationId ||
664
+ current.installationId !== this.#context.installationId ||
665
+ normalizedState.authorizationRevision !==
666
+ this.#context.authorizationRevision ||
667
+ current.authorizationRevision > normalizedState.authorizationRevision ||
668
+ !isVisibleTo(current.visibility, this.#context))
669
+ throw new AuthorizationDeniedError('Authorization denied');
670
+ let effective = current;
671
+ if (revisionNumber !== undefined) {
672
+ const historical = normalizeAuthorizationRecord(await this.#authorization.getEntityRevisionAuthorization(entityId, revisionNumber));
673
+ if (historical.installationId !== current.installationId)
674
+ throw new AuthorizationDeniedError('Authorization denied');
675
+ effective = {
676
+ ...current,
677
+ authorizationRevision: Math.max(current.authorizationRevision, historical.authorizationRevision),
678
+ visibility: intersectVisibilityScopes(current.visibility, historical.visibility),
679
+ };
680
+ }
681
+ if (effective.authorizationRevision >
682
+ normalizedState.authorizationRevision ||
683
+ !isVisibleTo(effective.visibility, this.#context))
684
+ throw new AuthorizationDeniedError('Authorization denied');
685
+ return { state: normalizedState, record: effective };
686
+ }
687
+ catch {
688
+ throw new AuthorizationDeniedError('Authorization denied');
689
+ }
690
+ }
691
+ }
692
+ /** Taproot-owned, fail-closed canonical policy source. */
693
+ export class PersistedEntityAuthorizationSource {
694
+ #db;
695
+ constructor(db) {
696
+ this.#db = db;
697
+ }
698
+ async getInstallationAuthorizationState() {
699
+ const result = await this.#db
700
+ .prepare(`SELECT installation_id, authorization_revision, search_generation
701
+ FROM taproot_installation_authorization WHERE singleton = 1`)
702
+ .all();
703
+ const row = result.results[0];
704
+ if (!row)
705
+ throw new AuthorizationDeniedError('Authorization denied');
706
+ return {
707
+ installationId: row.installation_id,
708
+ authorizationRevision: Number(row.authorization_revision),
709
+ searchGeneration: Number(row.search_generation),
710
+ };
711
+ }
712
+ async getEntityAuthorization(entityId) {
713
+ const result = await this.#db
714
+ .prepare(`SELECT p.installation_id, p.authorization_revision,
715
+ p.effective_visibility_json AS visibility_json,
716
+ p.visibility_json AS declared_visibility_json,
717
+ p.workspace_id, p.owner_principal_id, p.source_revision
718
+ FROM taproot_entity_authorization p
719
+ JOIN taproot_entity_authorization_revisions history
720
+ ON history.entity_id = p.entity_id
721
+ AND history.source_revision = p.source_revision
722
+ AND history.installation_id IS p.installation_id
723
+ AND history.workspace_id IS p.workspace_id
724
+ AND history.owner_principal_id IS p.owner_principal_id
725
+ AND history.visibility_json IS p.visibility_json
726
+ AND history.effective_visibility_json IS p.effective_visibility_json
727
+ AND history.authorization_revision IS p.authorization_revision
728
+ AND history.deleted_at IS p.deleted_at
729
+ AND history.event_id IS p.event_id
730
+ AND history.created_at IS p.updated_at
731
+ JOIN taproot_entities e ON e.entity_id = p.entity_id
732
+ AND e.revision = p.source_revision
733
+ JOIN taproot_installation_authorization state ON state.singleton = 1
734
+ AND state.installation_id = p.installation_id
735
+ AND p.authorization_revision BETWEEN 1 AND state.authorization_revision
736
+ WHERE p.entity_id = ? AND p.deleted_at IS NULL
737
+ AND e.deleted_at IS NULL`)
738
+ .bind(entityId)
739
+ .all();
740
+ const row = result.results[0];
741
+ if (!row)
742
+ return null;
743
+ const declared = parseCanonicalVisibilityScope(row.declared_visibility_json);
744
+ const effective = parseCanonicalVisibilityScope(row.visibility_json);
745
+ if (!declared || !effective)
746
+ return null;
747
+ const statements = await this.#db
748
+ .prepare(`SELECT taproot_statement_authorization.restrictions_json,
749
+ taproot_statement_authorization.effective_visibility_json,
750
+ taproot_statement_authorization.authorization_revision
751
+ FROM taproot_statement_authorization
752
+ JOIN taproot_statement_authorization_revisions history
753
+ ON history.entity_id = taproot_statement_authorization.entity_id
754
+ AND history.source_revision = taproot_statement_authorization.source_revision
755
+ AND history.statement_id = taproot_statement_authorization.statement_id
756
+ AND history.restrictions_json IS taproot_statement_authorization.restrictions_json
757
+ AND history.effective_visibility_json IS taproot_statement_authorization.effective_visibility_json
758
+ AND history.authorization_revision IS taproot_statement_authorization.authorization_revision
759
+ WHERE taproot_statement_authorization.entity_id = ?
760
+ AND taproot_statement_authorization.source_revision = ?
761
+ ORDER BY taproot_statement_authorization.statement_id`)
762
+ .bind(entityId, row.source_revision)
763
+ .all();
764
+ let expectedEffective = declared;
765
+ for (const statement of statements.results) {
766
+ const restrictions = parseCanonicalVisibilityRestrictions(statement.restrictions_json);
767
+ const statementEffective = parseCanonicalVisibilityScope(statement.effective_visibility_json);
768
+ if (!restrictions ||
769
+ !statementEffective ||
770
+ Number(statement.authorization_revision) !==
771
+ Number(row.authorization_revision))
772
+ return null;
773
+ const expectedStatement = intersectVisibilityScopes(declared, ...restrictions);
774
+ if (serializeVisibilityScope(statementEffective) !==
775
+ serializeVisibilityScope(expectedStatement))
776
+ return null;
777
+ expectedEffective = intersectVisibilityScopes(expectedEffective, expectedStatement);
778
+ }
779
+ if (serializeVisibilityScope(effective) !==
780
+ serializeVisibilityScope(expectedEffective))
781
+ return null;
782
+ return authorizationRecordFromRow(row);
783
+ }
784
+ async getEntityRevisionAuthorization(entityId, revisionNumber) {
785
+ const current = await this.getEntityAuthorization(entityId);
786
+ if (!current)
787
+ return null;
788
+ const result = await this.#db
789
+ .prepare(`SELECT history.installation_id, history.authorization_revision,
790
+ history.effective_visibility_json AS historical_visibility_json,
791
+ history.visibility_json AS historical_declared_visibility_json,
792
+ history.workspace_id, history.owner_principal_id,
793
+ history.source_revision
794
+ FROM taproot_entity_authorization_revisions history
795
+ JOIN taproot_installation_authorization state ON state.singleton = 1
796
+ AND state.installation_id = history.installation_id
797
+ AND history.authorization_revision BETWEEN 1 AND state.authorization_revision
798
+ WHERE history.entity_id = ? AND history.source_revision = ?
799
+ AND history.deleted_at IS NULL`)
800
+ .bind(entityId, revisionNumber)
801
+ .all();
802
+ const row = result.results[0];
803
+ if (!row || row.installation_id !== current.installationId)
804
+ return null;
805
+ const declared = parseCanonicalVisibilityScope(row.historical_declared_visibility_json);
806
+ const effective = parseCanonicalVisibilityScope(row.historical_visibility_json);
807
+ if (!declared || !effective)
808
+ return null;
809
+ const coverage = await this.#db
810
+ .prepare(`WITH expected(statement_id) AS (
811
+ SELECT json_extract(statement.value, '$.id')
812
+ FROM taproot_entity_revisions revision,
813
+ json_each(revision.entity_json, '$.claims') claim,
814
+ json_each(claim.value) statement
815
+ WHERE revision.entity_id = ? AND revision.revision = ?
816
+ )
817
+ SELECT
818
+ (SELECT COUNT(*) FROM expected) AS expected_count,
819
+ (SELECT COUNT(*) FROM taproot_statement_authorization_revisions policy
820
+ WHERE policy.entity_id = ? AND policy.source_revision = ?) AS actual_count,
821
+ (SELECT COUNT(*) FROM expected WHERE NOT EXISTS (
822
+ SELECT 1 FROM taproot_statement_authorization_revisions policy
823
+ WHERE policy.entity_id = ? AND policy.source_revision = ?
824
+ AND policy.statement_id = expected.statement_id
825
+ )) AS missing_count`)
826
+ .bind(entityId, revisionNumber, entityId, revisionNumber, entityId, revisionNumber)
827
+ .all();
828
+ const coverageRow = coverage.results[0];
829
+ if (!coverageRow ||
830
+ Number(coverageRow.expected_count) !== Number(coverageRow.actual_count) ||
831
+ Number(coverageRow.missing_count) !== 0)
832
+ return null;
833
+ const statements = await this.#db
834
+ .prepare(`SELECT restrictions_json, effective_visibility_json,
835
+ authorization_revision
836
+ FROM taproot_statement_authorization_revisions
837
+ WHERE entity_id = ? AND source_revision = ?
838
+ ORDER BY statement_id`)
839
+ .bind(entityId, revisionNumber)
840
+ .all();
841
+ let expectedEffective = declared;
842
+ for (const statement of statements.results) {
843
+ const restrictions = parseCanonicalVisibilityRestrictions(statement.restrictions_json);
844
+ const statementEffective = parseCanonicalVisibilityScope(statement.effective_visibility_json);
845
+ if (!restrictions ||
846
+ !statementEffective ||
847
+ Number(statement.authorization_revision) !==
848
+ Number(row.authorization_revision))
849
+ return null;
850
+ const expectedStatement = intersectVisibilityScopes(declared, ...restrictions);
851
+ if (serializeVisibilityScope(statementEffective) !==
852
+ serializeVisibilityScope(expectedStatement))
853
+ return null;
854
+ expectedEffective = intersectVisibilityScopes(expectedEffective, expectedStatement);
855
+ }
856
+ if (serializeVisibilityScope(effective) !==
857
+ serializeVisibilityScope(expectedEffective))
858
+ return null;
859
+ return {
860
+ installationId: row.installation_id,
861
+ authorizationRevision: Math.max(current.authorizationRevision, Number(row.authorization_revision)),
862
+ visibility: intersectVisibilityScopes(current.visibility, effective),
863
+ workspaceId: row.workspace_id,
864
+ ownerPrincipalId: row.owner_principal_id,
865
+ sourceRevision: Number(row.source_revision),
866
+ };
867
+ }
868
+ async getStatementAuthorization(entityId, statementId) {
869
+ const result = await this.#db
870
+ .prepare(`SELECT p.installation_id, s.authorization_revision,
871
+ s.effective_visibility_json AS visibility_json,
872
+ s.restrictions_json, p.visibility_json AS parent_visibility_json,
873
+ p.effective_visibility_json AS parent_effective_visibility_json,
874
+ p.authorization_revision AS parent_authorization_revision,
875
+ p.workspace_id, p.owner_principal_id, s.source_revision
876
+ FROM taproot_statement_authorization s
877
+ JOIN taproot_statement_authorization_revisions history
878
+ ON history.entity_id = s.entity_id
879
+ AND history.source_revision = s.source_revision
880
+ AND history.statement_id = s.statement_id
881
+ AND history.restrictions_json IS s.restrictions_json
882
+ AND history.effective_visibility_json IS s.effective_visibility_json
883
+ AND history.authorization_revision IS s.authorization_revision
884
+ JOIN taproot_entity_authorization p ON p.entity_id = s.entity_id
885
+ AND p.source_revision = s.source_revision
886
+ JOIN taproot_entity_authorization_revisions parent_history
887
+ ON parent_history.entity_id = p.entity_id
888
+ AND parent_history.source_revision = p.source_revision
889
+ AND parent_history.installation_id IS p.installation_id
890
+ AND parent_history.workspace_id IS p.workspace_id
891
+ AND parent_history.owner_principal_id IS p.owner_principal_id
892
+ AND parent_history.visibility_json IS p.visibility_json
893
+ AND parent_history.effective_visibility_json IS p.effective_visibility_json
894
+ AND parent_history.authorization_revision IS p.authorization_revision
895
+ AND parent_history.deleted_at IS p.deleted_at
896
+ AND parent_history.event_id IS p.event_id
897
+ AND parent_history.created_at IS p.updated_at
898
+ JOIN taproot_entities e ON e.entity_id = s.entity_id
899
+ AND e.revision = s.source_revision
900
+ JOIN taproot_installation_authorization state ON state.singleton = 1
901
+ AND state.installation_id = p.installation_id
902
+ AND p.authorization_revision BETWEEN 1 AND state.authorization_revision
903
+ AND s.authorization_revision BETWEEN 1 AND state.authorization_revision
904
+ WHERE s.entity_id = ? AND s.statement_id = ?
905
+ AND p.deleted_at IS NULL AND e.deleted_at IS NULL`)
906
+ .bind(entityId, statementId)
907
+ .all();
908
+ const row = result.results[0];
909
+ if (!row ||
910
+ Number(row.authorization_revision) !==
911
+ Number(row.parent_authorization_revision))
912
+ return null;
913
+ const declaredParent = parseCanonicalVisibilityScope(row.parent_visibility_json);
914
+ const restrictions = parseCanonicalVisibilityRestrictions(row.restrictions_json);
915
+ const effective = parseCanonicalVisibilityScope(row.visibility_json);
916
+ if (!declaredParent ||
917
+ !restrictions ||
918
+ !effective ||
919
+ serializeVisibilityScope(effective) !==
920
+ serializeVisibilityScope(intersectVisibilityScopes(declaredParent, ...restrictions)))
921
+ return null;
922
+ return authorizationRecordFromRow(row);
923
+ }
924
+ }
925
+ function parseCanonicalVisibilityRestrictions(raw) {
926
+ try {
927
+ const value = JSON.parse(raw);
928
+ if (!Array.isArray(value))
929
+ return null;
930
+ const restrictions = value.map((scope) => normalizeVisibilityScope(scope));
931
+ return JSON.stringify(restrictions) === raw ? restrictions : null;
932
+ }
933
+ catch {
934
+ return null;
935
+ }
936
+ }
937
+ function parseCanonicalVisibilityScope(raw) {
938
+ try {
939
+ const scope = normalizeVisibilityScope(JSON.parse(raw));
940
+ return serializeVisibilityScope(scope) === raw ? scope : null;
941
+ }
942
+ catch {
943
+ return null;
944
+ }
945
+ }
946
+ function authorizationRecordFromRow(row) {
947
+ if (!row)
948
+ return null;
949
+ return {
950
+ installationId: row.installation_id,
951
+ authorizationRevision: Number(row.authorization_revision),
952
+ visibility: JSON.parse(row.visibility_json),
953
+ workspaceId: row.workspace_id,
954
+ ownerPrincipalId: row.owner_principal_id,
955
+ sourceRevision: Number(row.source_revision),
956
+ };
957
+ }
958
+ export function createAuthorizedTaproot(db, options, context, authorizedOptions) {
959
+ return new AuthorizedTaprootReader(db, options, context, authorizedOptions);
960
+ }
961
+ function normalizeInstallationState(value) {
962
+ if (!isRecord(value))
963
+ invalid('authorization state must be an object');
964
+ exactKeys(value, [
965
+ 'installationId',
966
+ 'authorizationRevision',
967
+ 'searchGeneration',
968
+ ]);
969
+ return {
970
+ installationId: identifier(value.installationId, 'installationId'),
971
+ authorizationRevision: revision(value.authorizationRevision, 'authorizationRevision'),
972
+ searchGeneration: revision(value.searchGeneration, 'searchGeneration'),
973
+ };
974
+ }
975
+ function normalizeAuthorizationRecord(value) {
976
+ if (!isRecord(value))
977
+ invalid('authorization record must be an object');
978
+ exactKeys(value, [
979
+ 'installationId',
980
+ 'authorizationRevision',
981
+ 'visibility',
982
+ 'workspaceId',
983
+ 'ownerPrincipalId',
984
+ 'sourceRevision',
985
+ ]);
986
+ return {
987
+ installationId: identifier(value.installationId, 'installationId'),
988
+ authorizationRevision: revision(value.authorizationRevision, 'authorizationRevision'),
989
+ visibility: normalizeVisibilityScope(value.visibility),
990
+ workspaceId: value.workspaceId === null
991
+ ? null
992
+ : identifier(value.workspaceId, 'workspaceId'),
993
+ ownerPrincipalId: identifier(value.ownerPrincipalId, 'ownerPrincipalId'),
994
+ sourceRevision: revision(value.sourceRevision, 'sourceRevision'),
995
+ };
996
+ }
997
+ function normalizeAtom(value) {
998
+ if (!isRecord(value) || typeof value.kind !== 'string')
999
+ invalid('visibility atom must be an object');
1000
+ switch (value.kind) {
1001
+ case 'public':
1002
+ if (Object.keys(value).length !== 1)
1003
+ invalid('public atom has unknown fields');
1004
+ return { kind: 'public' };
1005
+ case 'principal':
1006
+ exactKeys(value, ['kind', 'principalId']);
1007
+ return {
1008
+ kind: 'principal',
1009
+ principalId: identifier(value.principalId, 'principalId'),
1010
+ };
1011
+ case 'workspace':
1012
+ exactKeys(value, ['kind', 'workspaceId']);
1013
+ return {
1014
+ kind: 'workspace',
1015
+ workspaceId: identifier(value.workspaceId, 'workspaceId'),
1016
+ };
1017
+ case 'capability':
1018
+ exactKeys(value, ['kind', 'capability']);
1019
+ return {
1020
+ kind: 'capability',
1021
+ capability: identifier(value.capability, 'capability'),
1022
+ };
1023
+ default:
1024
+ invalid('visibility atom kind is not supported');
1025
+ }
1026
+ }
1027
+ function serializeAtom(atom) {
1028
+ return JSON.stringify(atom);
1029
+ }
1030
+ function stringSet(value, field) {
1031
+ if (!Array.isArray(value) || value.length > 256)
1032
+ invalid(`${field} must be an array with at most 256 entries`);
1033
+ const normalized = [];
1034
+ for (let index = 0; index < value.length; index += 1) {
1035
+ if (!Object.hasOwn(value, index))
1036
+ invalid(`${field} must be dense`);
1037
+ normalized.push(identifier(value[index], field));
1038
+ }
1039
+ return [...new Set(normalized)].sort(compareCodeUnits);
1040
+ }
1041
+ function identifier(value, field) {
1042
+ if (typeof value !== 'string')
1043
+ invalid(`${field} must be a string`);
1044
+ const normalized = value.normalize('NFC');
1045
+ if (normalized.length === 0 ||
1046
+ normalized.length > MAX_IDENTIFIER_LENGTH ||
1047
+ normalized.trim() !== normalized ||
1048
+ hasControlCharacter(normalized))
1049
+ invalid(`${field} is invalid`);
1050
+ return normalized;
1051
+ }
1052
+ function revision(value, field) {
1053
+ if (!Number.isSafeInteger(value) || value < 0)
1054
+ invalid(`${field} must be a non-negative safe integer`);
1055
+ return value;
1056
+ }
1057
+ function exactKeys(value, expected) {
1058
+ const actual = Object.keys(value).sort();
1059
+ const wanted = [...expected].sort();
1060
+ if (actual.length !== wanted.length ||
1061
+ actual.some((key, index) => key !== wanted[index]))
1062
+ invalid('visibility atom has unknown or missing fields');
1063
+ }
1064
+ function isRecord(value) {
1065
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
1066
+ }
1067
+ function compareCodeUnits(left, right) {
1068
+ return left < right ? -1 : left > right ? 1 : 0;
1069
+ }
1070
+ function hasControlCharacter(value) {
1071
+ for (const character of value) {
1072
+ const codePoint = character.codePointAt(0);
1073
+ if (codePoint <= 0x1f || codePoint === 0x7f)
1074
+ return true;
1075
+ }
1076
+ return false;
1077
+ }
1078
+ function authorizedLimit(value, fallback) {
1079
+ const limit = value ?? fallback;
1080
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500)
1081
+ throw new RangeError('limit must be an integer from 1 through 500');
1082
+ return limit;
1083
+ }
1084
+ function scanLimit(remaining) {
1085
+ return Math.min(500, Math.max(32, remaining * 4));
1086
+ }
1087
+ function cursorNumber(position, key) {
1088
+ if (!isRecord(position) ||
1089
+ Object.keys(position).length !== 1 ||
1090
+ !Object.hasOwn(position, key))
1091
+ throw new InvalidCursorError('Cursor is invalid');
1092
+ const value = position[key];
1093
+ if (!Number.isSafeInteger(value) || value < 0)
1094
+ throw new InvalidCursorError('Cursor is invalid');
1095
+ return value;
1096
+ }
1097
+ function entityPosition(position) {
1098
+ if (!isRecord(position))
1099
+ throw new InvalidCursorError('Cursor is invalid');
1100
+ exactKeys(position, ['entityType', 'numericId']);
1101
+ if ((position.entityType !== 'item' && position.entityType !== 'property') ||
1102
+ !Number.isSafeInteger(position.numericId) ||
1103
+ position.numericId < 0)
1104
+ throw new InvalidCursorError('Cursor is invalid');
1105
+ return {
1106
+ entityType: position.entityType,
1107
+ numericId: position.numericId,
1108
+ };
1109
+ }
1110
+ async function fingerprintValue(value) {
1111
+ const bytes = new TextEncoder().encode(JSON.stringify(value));
1112
+ const digest = await crypto.subtle.digest('SHA-256', bytes);
1113
+ return base64Url(new Uint8Array(digest));
1114
+ }
1115
+ function base64Url(bytes) {
1116
+ let binary = '';
1117
+ for (const byte of bytes)
1118
+ binary += String.fromCharCode(byte);
1119
+ return btoa(binary)
1120
+ .replaceAll('+', '-')
1121
+ .replaceAll('/', '_')
1122
+ .replace(/=+$/u, '');
1123
+ }
1124
+ function fromBase64Url(value) {
1125
+ if (!/^[A-Za-z0-9_-]+$/u.test(value))
1126
+ throw new Error('invalid base64url');
1127
+ const padding = '='.repeat((4 - (value.length % 4)) % 4);
1128
+ const binary = atob(value.replaceAll('-', '+').replaceAll('_', '/') + padding);
1129
+ const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
1130
+ if (base64Url(bytes) !== value)
1131
+ throw new Error('invalid base64url');
1132
+ return bytes;
1133
+ }
1134
+ function invalid(message) {
1135
+ throw new InvalidAuthorizationError(message);
1136
+ }
1137
+ //# sourceMappingURL=authorization.js.map