@usehercules/convex 0.0.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 (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +478 -0
  3. package/dist/_generated/component.d.ts +184 -0
  4. package/dist/_generated/component.d.ts.map +1 -0
  5. package/dist/_generated/component.js +11 -0
  6. package/dist/_generated/component.js.map +1 -0
  7. package/dist/checker/cli.d.ts +3 -0
  8. package/dist/checker/cli.d.ts.map +1 -0
  9. package/dist/checker/cli.js +71 -0
  10. package/dist/checker/cli.js.map +1 -0
  11. package/dist/checker/index.d.ts +28 -0
  12. package/dist/checker/index.d.ts.map +1 -0
  13. package/dist/checker/index.js +1928 -0
  14. package/dist/checker/index.js.map +1 -0
  15. package/dist/client/access-admin.d.ts +818 -0
  16. package/dist/client/access-admin.d.ts.map +1 -0
  17. package/dist/client/access-admin.js +1830 -0
  18. package/dist/client/access-admin.js.map +1 -0
  19. package/dist/client/http.d.ts +19 -0
  20. package/dist/client/http.d.ts.map +1 -0
  21. package/dist/client/http.js +76 -0
  22. package/dist/client/http.js.map +1 -0
  23. package/dist/client/index.d.ts +440 -0
  24. package/dist/client/index.d.ts.map +1 -0
  25. package/dist/client/index.js +654 -0
  26. package/dist/client/index.js.map +1 -0
  27. package/dist/component/authz.d.ts +114 -0
  28. package/dist/component/authz.d.ts.map +1 -0
  29. package/dist/component/authz.js +168 -0
  30. package/dist/component/authz.js.map +1 -0
  31. package/dist/component/checks.d.ts +86 -0
  32. package/dist/component/checks.d.ts.map +1 -0
  33. package/dist/component/checks.js +184 -0
  34. package/dist/component/checks.js.map +1 -0
  35. package/dist/component/convex.config.d.ts +3 -0
  36. package/dist/component/convex.config.d.ts.map +1 -0
  37. package/dist/component/convex.config.js +3 -0
  38. package/dist/component/convex.config.js.map +1 -0
  39. package/dist/component/effective.d.ts +82 -0
  40. package/dist/component/effective.d.ts.map +1 -0
  41. package/dist/component/effective.js +757 -0
  42. package/dist/component/effective.js.map +1 -0
  43. package/dist/component/queries.d.ts +170 -0
  44. package/dist/component/queries.d.ts.map +1 -0
  45. package/dist/component/queries.js +633 -0
  46. package/dist/component/queries.js.map +1 -0
  47. package/dist/component/schema.d.ts +258 -0
  48. package/dist/component/schema.d.ts.map +1 -0
  49. package/dist/component/schema.js +222 -0
  50. package/dist/component/schema.js.map +1 -0
  51. package/dist/component/sync.d.ts +85 -0
  52. package/dist/component/sync.d.ts.map +1 -0
  53. package/dist/component/sync.js +851 -0
  54. package/dist/component/sync.js.map +1 -0
  55. package/dist/shared/projection-protocol.d.ts +1624 -0
  56. package/dist/shared/projection-protocol.d.ts.map +1 -0
  57. package/dist/shared/projection-protocol.js +561 -0
  58. package/dist/shared/projection-protocol.js.map +1 -0
  59. package/dist/shared/sync.d.ts +24 -0
  60. package/dist/shared/sync.d.ts.map +1 -0
  61. package/dist/shared/sync.js +18 -0
  62. package/dist/shared/sync.js.map +1 -0
  63. package/dist/shared/token.d.ts +5 -0
  64. package/dist/shared/token.d.ts.map +1 -0
  65. package/dist/shared/token.js +19 -0
  66. package/dist/shared/token.js.map +1 -0
  67. package/package.json +89 -0
@@ -0,0 +1,851 @@
1
+ import { internalMutationGeneric, makeFunctionReference, mutationGeneric, } from "convex/server";
2
+ import { v } from "convex/values";
3
+ import { accessProjectionSyncPayloadSchema, } from "../shared/sync";
4
+ import schema from "./schema";
5
+ const internalMutation = internalMutationGeneric;
6
+ // applySync is the parent-facing entry point (the app's HTTP sync route calls
7
+ // it via runMutation); it must be public-in-component to be exported to the
8
+ // parent. The expiry mutations below stay internal: they are scheduled and run
9
+ // entirely inside the component.
10
+ const mutation = mutationGeneric;
11
+ // Exact-identity expiry mutations: scheduled at expiresAt so the reactive query
12
+ // is invalidated when a time-bound binding lapses. The runtime readers also
13
+ // fail closed on the timestamp, so a delayed schedule never over-grants.
14
+ const expireRoleBindingReference = makeFunctionReference("sync:expireRoleBinding");
15
+ const expirePermissionBindingReference = makeFunctionReference("sync:expirePermissionBinding");
16
+ // Convex transactions have document-count limits. Reject an oversized aggregate
17
+ // with a clear payload failure rather than letting the mutation abort opaquely.
18
+ const MAX_SNAPSHOT_DOCUMENTS = 16_000;
19
+ function bindingAppliesTo(binding) {
20
+ if ("appliesTo" in binding &&
21
+ (binding.appliesTo === "self" || binding.appliesTo === "self_and_descendants")) {
22
+ return binding.appliesTo;
23
+ }
24
+ return "self";
25
+ }
26
+ // The args validator is intentionally loose (the producer ships either payload
27
+ // kind); real validation is the zod parse below. Accept the v3 top-level shape.
28
+ const syncPayloadArgs = {
29
+ type: v.union(v.literal("access.projection.snapshot"), v.literal("access.projection.event")),
30
+ schemaVersion: v.number(),
31
+ eventId: v.string(),
32
+ sourceVersion: v.number(),
33
+ mode: v.optional(v.union(v.literal("initialize"), v.literal("reset"))),
34
+ expectedIssuer: v.optional(v.string()),
35
+ catalog: v.optional(v.any()),
36
+ users: v.optional(v.any()),
37
+ scopes: v.optional(v.array(v.any())),
38
+ };
39
+ export const applySync = mutation({
40
+ args: syncPayloadArgs,
41
+ handler: async (ctx, rawArgs) => {
42
+ if (rawArgs.schemaVersion !== 3) {
43
+ return { ok: false, status: "unsupported_schema" };
44
+ }
45
+ // A bootstrap/reset aggregate must carry the default scope (the zod also
46
+ // checks default-first/exactly-one, but this returns a precise status the
47
+ // reconciler keys on before paying for a full parse).
48
+ if (rawArgs.type === "access.projection.snapshot" &&
49
+ !(rawArgs.scopes ?? []).some((entry) => entry !== null &&
50
+ typeof entry === "object" &&
51
+ "scope" in entry &&
52
+ entry.scope !== null &&
53
+ typeof entry.scope === "object" &&
54
+ "kind" in entry.scope &&
55
+ entry.scope.kind === "default")) {
56
+ return { ok: false, status: "default_scope_required" };
57
+ }
58
+ const parsed = accessProjectionSyncPayloadSchema.safeParse(rawArgs);
59
+ if (!parsed.success) {
60
+ return { ok: false, status: "invalid_payload" };
61
+ }
62
+ const payload = parsed.data;
63
+ const state = await ctx.db.query("sync_state").unique();
64
+ // Idempotency: a re-delivered event/snapshot with the same eventId is a
65
+ // no-op ack (the version must match what we recorded for that eventId).
66
+ if (state?.lastEventId === payload.eventId) {
67
+ if (state.sourceVersion !== payload.sourceVersion) {
68
+ return { ok: false, status: "invalid_payload" };
69
+ }
70
+ return {
71
+ ok: true,
72
+ status: "duplicate",
73
+ acknowledgedVersion: state.sourceVersion,
74
+ };
75
+ }
76
+ if (payload.type === "access.projection.event") {
77
+ if (!state) {
78
+ return { ok: false, status: "not_ready", currentVersion: 0 };
79
+ }
80
+ // Contract point 1: events apply strictly at currentVersion + 1.
81
+ const expectedVersion = state.sourceVersion + 1;
82
+ if (payload.sourceVersion !== expectedVersion) {
83
+ return {
84
+ ok: false,
85
+ status: "version_gap",
86
+ currentVersion: state.sourceVersion,
87
+ expectedVersion,
88
+ receivedVersion: payload.sourceVersion,
89
+ };
90
+ }
91
+ }
92
+ else {
93
+ // initialize requires a clean mirror; reset requires an existing one.
94
+ if (payload.mode === "initialize" && state) {
95
+ return {
96
+ ok: false,
97
+ status: "reset_required",
98
+ currentVersion: state.sourceVersion,
99
+ };
100
+ }
101
+ if (payload.mode === "reset" && !state) {
102
+ return { ok: false, status: "not_ready", currentVersion: 0 };
103
+ }
104
+ if (state && state.expectedIssuer !== payload.expectedIssuer) {
105
+ return { ok: false, status: "issuer_mismatch" };
106
+ }
107
+ if (payload.mode === "reset" && state && payload.sourceVersion < state.sourceVersion) {
108
+ return {
109
+ ok: false,
110
+ status: "version_gap",
111
+ currentVersion: state.sourceVersion,
112
+ expectedVersion: state.sourceVersion + 1,
113
+ receivedVersion: payload.sourceVersion,
114
+ };
115
+ }
116
+ }
117
+ const now = Date.now();
118
+ if (payload.type === "access.projection.snapshot") {
119
+ if (snapshotDocumentCount(payload) > MAX_SNAPSHOT_DOCUMENTS) {
120
+ return { ok: false, status: "invalid_payload" };
121
+ }
122
+ // Whole-aggregate atomic install. A Convex mutation is a single
123
+ // transaction, so no scope becomes visible until the entire snapshot
124
+ // commits (contract point 2).
125
+ await replaceProjection(payload, now);
126
+ }
127
+ else {
128
+ await applyEvent(payload, now);
129
+ }
130
+ const nextState = {
131
+ sourceVersion: payload.sourceVersion,
132
+ expectedIssuer: payload.type === "access.projection.snapshot"
133
+ ? payload.expectedIssuer
134
+ : state.expectedIssuer,
135
+ lastEventId: payload.eventId,
136
+ lastSyncedAt: Date.now(),
137
+ };
138
+ if (state) {
139
+ await ctx.db.replace(state._id, nextState);
140
+ }
141
+ else {
142
+ await ctx.db.insert("sync_state", nextState);
143
+ }
144
+ return {
145
+ ok: true,
146
+ status: "applied",
147
+ acknowledgedVersion: payload.sourceVersion,
148
+ };
149
+ // ── snapshot install ──────────────────────────────────────────────────
150
+ async function replaceProjection(snapshot, now) {
151
+ // Clear children-before-parents is unnecessary (no DB FKs), so order only
152
+ // for clarity. Every table is wiped before re-install.
153
+ await clearTable("permission_bindings");
154
+ await clearTable("role_bindings");
155
+ await clearTable("role_permission_overrides");
156
+ await clearTable("role_permissions");
157
+ await clearTable("permissions");
158
+ await clearTable("roles");
159
+ await clearTable("principal_memberships");
160
+ await clearTable("principals");
161
+ await clearTable("organizations");
162
+ await clearTable("scopes");
163
+ await clearTable("users");
164
+ // Deployment-wide catalog (NEVER per-scope). Catalog roles carry no
165
+ // accessScopeId; permissions are pinned to the default scope id for
166
+ // lookup symmetry.
167
+ const defaultScopeId = snapshot.scopes[0].scope.accessScopeId;
168
+ for (const role of snapshot.catalog.roles) {
169
+ await ctx.db.insert("roles", {
170
+ roleId: role.roleId,
171
+ key: role.key,
172
+ source: role.source,
173
+ name: role.name,
174
+ baseWildcard: role.baseWildcard,
175
+ updatedAt: role.updatedAt,
176
+ });
177
+ }
178
+ for (const permission of snapshot.catalog.permissions) {
179
+ await ctx.db.insert("permissions", {
180
+ accessScopeId: defaultScopeId,
181
+ permissionId: permission.permissionId,
182
+ key: permission.key,
183
+ resourceType: permission.resourceType,
184
+ action: permission.action,
185
+ classification: permission.classification,
186
+ tenantAssignable: permission.tenantAssignable,
187
+ updatedAt: permission.updatedAt,
188
+ });
189
+ }
190
+ for (const rolePermission of snapshot.catalog.rolePermissions) {
191
+ await ctx.db.insert("role_permissions", {
192
+ roleId: rolePermission.roleId,
193
+ permissionId: rolePermission.permissionId,
194
+ effect: rolePermission.effect,
195
+ updatedAt: rolePermission.updatedAt,
196
+ });
197
+ }
198
+ // Deployment-wide users.
199
+ for (const user of snapshot.users) {
200
+ await ctx.db.insert("users", { ...user });
201
+ }
202
+ // Per-scope runtime state.
203
+ for (const scopeEntry of snapshot.scopes) {
204
+ await insertScope(scopeEntry.scope);
205
+ for (const principal of scopeEntry.principals) {
206
+ await ctx.db.insert("principals", {
207
+ accessScopeId: scopeEntry.scope.accessScopeId,
208
+ ...principal,
209
+ });
210
+ }
211
+ for (const membership of scopeEntry.principalMemberships) {
212
+ await ctx.db.insert("principal_memberships", {
213
+ accessScopeId: scopeEntry.scope.accessScopeId,
214
+ ...membership,
215
+ });
216
+ }
217
+ // E4 (cross-scope escalation fence): write every embedded row pinned to
218
+ // the ENCLOSING scope, never the nested accessScopeId the row carries.
219
+ // The zod parse already rejects a mismatched nested id, but pinning here
220
+ // is the second, in-depth layer so a scope-A block can never land a row
221
+ // in scope B even if the parse fence is bypassed.
222
+ const enclosingScopeId = scopeEntry.scope.accessScopeId;
223
+ for (const role of scopeEntry.roles) {
224
+ await ctx.db.insert("roles", {
225
+ roleId: role.roleId,
226
+ key: role.key,
227
+ source: role.source,
228
+ name: role.name,
229
+ baseWildcard: role.baseWildcard,
230
+ accessScopeId: enclosingScopeId,
231
+ updatedAt: role.updatedAt,
232
+ });
233
+ }
234
+ for (const override of scopeEntry.rolePermissionOverrides) {
235
+ await ctx.db.insert("role_permission_overrides", {
236
+ ...override,
237
+ accessScopeId: enclosingScopeId,
238
+ });
239
+ }
240
+ for (const binding of scopeEntry.roleBindings) {
241
+ if (binding.expiresAt !== undefined && binding.expiresAt <= now) {
242
+ continue;
243
+ }
244
+ await ctx.db.insert("role_bindings", {
245
+ ...binding,
246
+ accessScopeId: enclosingScopeId,
247
+ appliesTo: bindingAppliesTo(binding),
248
+ });
249
+ await scheduleRoleBindingExpiration(binding);
250
+ }
251
+ for (const binding of scopeEntry.permissionBindings) {
252
+ if (binding.expiresAt !== undefined && binding.expiresAt <= now) {
253
+ continue;
254
+ }
255
+ await ctx.db.insert("permission_bindings", {
256
+ ...binding,
257
+ accessScopeId: enclosingScopeId,
258
+ appliesTo: bindingAppliesTo(binding),
259
+ });
260
+ await schedulePermissionBindingExpiration(binding);
261
+ }
262
+ }
263
+ }
264
+ // ── event application ─────────────────────────────────────────────────
265
+ async function applyEvent(event, now) {
266
+ if (event.catalog)
267
+ await applyCatalogDelta(event.catalog);
268
+ if (event.users)
269
+ await applyUserDelta(event.users);
270
+ for (const scopeDelta of event.scopes ?? []) {
271
+ await applyScopeDelta(scopeDelta, now);
272
+ }
273
+ }
274
+ async function applyCatalogDelta(catalog) {
275
+ const defaultScopeId = await getDefaultScopeId();
276
+ for (const change of catalog.changes) {
277
+ if (change.operation === "delete") {
278
+ switch (change.entityType) {
279
+ case "role":
280
+ await deleteCatalogRole(change.roleId);
281
+ break;
282
+ case "permission":
283
+ await deletePermission(change.permissionId);
284
+ break;
285
+ case "role_permission":
286
+ await deleteRolePermission(change.roleId, change.permissionId);
287
+ break;
288
+ }
289
+ continue;
290
+ }
291
+ switch (change.entityType) {
292
+ case "role": {
293
+ const role = catalog.roles.find((r) => r.roleId === change.roleId);
294
+ await upsertCatalogRole({
295
+ roleId: role.roleId,
296
+ key: role.key,
297
+ source: role.source,
298
+ name: role.name,
299
+ baseWildcard: role.baseWildcard,
300
+ updatedAt: role.updatedAt,
301
+ });
302
+ break;
303
+ }
304
+ case "permission": {
305
+ const permission = catalog.permissions.find((p) => p.permissionId === change.permissionId);
306
+ await upsertPermission({
307
+ accessScopeId: defaultScopeId,
308
+ permissionId: permission.permissionId,
309
+ key: permission.key,
310
+ resourceType: permission.resourceType,
311
+ action: permission.action,
312
+ classification: permission.classification,
313
+ tenantAssignable: permission.tenantAssignable,
314
+ updatedAt: permission.updatedAt,
315
+ });
316
+ break;
317
+ }
318
+ case "role_permission": {
319
+ const rolePermission = catalog.rolePermissions.find((rp) => rp.roleId === change.roleId && rp.permissionId === change.permissionId);
320
+ await upsertRolePermission({
321
+ roleId: rolePermission.roleId,
322
+ permissionId: rolePermission.permissionId,
323
+ effect: rolePermission.effect,
324
+ updatedAt: rolePermission.updatedAt,
325
+ });
326
+ break;
327
+ }
328
+ }
329
+ }
330
+ }
331
+ async function applyUserDelta(delta) {
332
+ for (const change of delta.changes) {
333
+ if (change.operation === "delete") {
334
+ await deleteUser(change.herculesAuthUserId);
335
+ continue;
336
+ }
337
+ const user = delta.users.find((u) => u.herculesAuthUserId === change.herculesAuthUserId);
338
+ await upsertUser({ ...user });
339
+ }
340
+ }
341
+ async function applyScopeDelta(scope, now) {
342
+ const accessScopeId = scope.accessScopeId;
343
+ if (scope.scope)
344
+ await upsertScope(scope.scope);
345
+ for (const change of scope.changes) {
346
+ if (change.operation === "delete") {
347
+ switch (change.entityType) {
348
+ case "scope":
349
+ await deleteScope(change.accessScopeId);
350
+ break;
351
+ case "principal":
352
+ await deletePrincipal(accessScopeId, change.principalId);
353
+ break;
354
+ case "principal_membership":
355
+ await deleteMembership(accessScopeId, change.groupPrincipalId, change.memberPrincipalId);
356
+ break;
357
+ case "role":
358
+ await deleteTenantRole(change.roleId);
359
+ break;
360
+ case "role_permission_override":
361
+ // E4: delete keyed on the ENCLOSING scope, never the change's own
362
+ // accessScopeId (which could name a foreign scope).
363
+ await deleteOverride(accessScopeId, change.roleId, change.permissionId);
364
+ break;
365
+ case "role_binding":
366
+ await deleteRoleBinding(change.bindingId);
367
+ break;
368
+ case "permission_binding":
369
+ await deletePermissionBinding(change.bindingId);
370
+ break;
371
+ }
372
+ continue;
373
+ }
374
+ switch (change.entityType) {
375
+ // A `scope` upsert change is satisfied by `scope.scope` above; the
376
+ // integrity rule guarantees the metadata row is present.
377
+ case "scope":
378
+ break;
379
+ case "principal": {
380
+ const principal = scope.principals.find((p) => p.principalId === change.principalId);
381
+ await upsertPrincipal(accessScopeId, principal);
382
+ break;
383
+ }
384
+ case "principal_membership": {
385
+ const membership = scope.principalMemberships.find((m) => m.groupPrincipalId === change.groupPrincipalId &&
386
+ m.memberPrincipalId === change.memberPrincipalId);
387
+ await upsertMembership(accessScopeId, membership);
388
+ break;
389
+ }
390
+ case "role": {
391
+ const role = scope.roles.find((r) => r.roleId === change.roleId);
392
+ await upsertTenantRole({
393
+ roleId: role.roleId,
394
+ key: role.key,
395
+ source: role.source,
396
+ name: role.name,
397
+ baseWildcard: role.baseWildcard,
398
+ // E4: pin to the ENCLOSING scope, never the row's own
399
+ // accessScopeId.
400
+ accessScopeId,
401
+ updatedAt: role.updatedAt,
402
+ });
403
+ break;
404
+ }
405
+ case "role_permission_override": {
406
+ const override = scope.rolePermissionOverrides.find((o) => o.accessScopeId === change.accessScopeId &&
407
+ o.roleId === change.roleId &&
408
+ o.permissionId === change.permissionId);
409
+ // E4: write the override pinned to the ENCLOSING scope.
410
+ await upsertOverride({ ...override, accessScopeId });
411
+ break;
412
+ }
413
+ case "role_binding": {
414
+ const binding = scope.roleBindings.find((b) => b.bindingId === change.bindingId);
415
+ // E4: write the binding pinned to the ENCLOSING scope.
416
+ await upsertRoleBinding({ ...binding, accessScopeId }, now);
417
+ break;
418
+ }
419
+ case "permission_binding": {
420
+ const binding = scope.permissionBindings.find((b) => b.bindingId === change.bindingId);
421
+ // E4: write the binding pinned to the ENCLOSING scope.
422
+ await upsertPermissionBinding({ ...binding, accessScopeId }, now);
423
+ break;
424
+ }
425
+ }
426
+ }
427
+ }
428
+ // ── table helpers ─────────────────────────────────────────────────────
429
+ async function clearTable(table) {
430
+ for (const row of await ctx.db.query(table).collect()) {
431
+ await ctx.db.delete(row._id);
432
+ }
433
+ }
434
+ async function getDefaultScopeId() {
435
+ const defaultScope = await ctx.db
436
+ .query("scopes")
437
+ .withIndex("by_kind", (q) => q.eq("kind", "default"))
438
+ .unique();
439
+ // The mirror always holds exactly one default scope (snapshot installs it
440
+ // before any event applies). Fall back to empty string defensively.
441
+ return defaultScope?.accessScopeId ?? "";
442
+ }
443
+ async function insertScope(scope) {
444
+ await ctx.db.insert("scopes", { ...scope });
445
+ if (scope.kind === "org" || scope.kind === "suite") {
446
+ await ctx.db.insert("organizations", organizationFromScope(scope));
447
+ }
448
+ }
449
+ async function upsertScope(scope) {
450
+ const existing = await ctx.db
451
+ .query("scopes")
452
+ .withIndex("by_scope_id", (q) => q.eq("accessScopeId", scope.accessScopeId))
453
+ .unique();
454
+ if (existing)
455
+ await ctx.db.replace(existing._id, { ...scope });
456
+ else
457
+ await ctx.db.insert("scopes", { ...scope });
458
+ const organization = await ctx.db
459
+ .query("organizations")
460
+ .withIndex("by_scope_id", (q) => q.eq("accessScopeId", scope.accessScopeId))
461
+ .unique();
462
+ if (scope.kind !== "org" && scope.kind !== "suite") {
463
+ if (organization)
464
+ await ctx.db.delete(organization._id);
465
+ return;
466
+ }
467
+ const row = organizationFromScope(scope);
468
+ if (organization)
469
+ await ctx.db.replace(organization._id, row);
470
+ else
471
+ await ctx.db.insert("organizations", row);
472
+ }
473
+ async function deleteScope(accessScopeId) {
474
+ const scope = await ctx.db
475
+ .query("scopes")
476
+ .withIndex("by_scope_id", (q) => q.eq("accessScopeId", accessScopeId))
477
+ .unique();
478
+ const organization = await ctx.db
479
+ .query("organizations")
480
+ .withIndex("by_scope_id", (q) => q.eq("accessScopeId", accessScopeId))
481
+ .unique();
482
+ if (organization)
483
+ await ctx.db.delete(organization._id);
484
+ if (scope)
485
+ await ctx.db.delete(scope._id);
486
+ }
487
+ async function upsertUser(user) {
488
+ const existing = await ctx.db
489
+ .query("users")
490
+ .withIndex("by_auth_user_id", (q) => q.eq("herculesAuthUserId", user.herculesAuthUserId))
491
+ .unique();
492
+ if (existing && existing.updatedAt >= user.updatedAt)
493
+ return;
494
+ if (existing)
495
+ await ctx.db.replace(existing._id, user);
496
+ else
497
+ await ctx.db.insert("users", user);
498
+ }
499
+ async function deleteUser(herculesAuthUserId) {
500
+ const user = await ctx.db
501
+ .query("users")
502
+ .withIndex("by_auth_user_id", (q) => q.eq("herculesAuthUserId", herculesAuthUserId))
503
+ .unique();
504
+ if (user)
505
+ await ctx.db.delete(user._id);
506
+ }
507
+ async function upsertPrincipal(accessScopeId, principal) {
508
+ const existing = await ctx.db
509
+ .query("principals")
510
+ .withIndex("by_principal_id", (q) => q.eq("principalId", principal.principalId))
511
+ .unique();
512
+ const row = { accessScopeId, ...principal };
513
+ if (existing)
514
+ await ctx.db.replace(existing._id, row);
515
+ else
516
+ await ctx.db.insert("principals", row);
517
+ }
518
+ async function deletePrincipal(accessScopeId, principalId) {
519
+ const principal = await ctx.db
520
+ .query("principals")
521
+ .withIndex("by_principal_id", (q) => q.eq("principalId", principalId))
522
+ .unique();
523
+ if (!principal)
524
+ return;
525
+ // Cascade: the principal's bindings (as subject) and memberships.
526
+ for (const binding of await ctx.db
527
+ .query("role_bindings")
528
+ .withIndex("by_subject_principal", (q) => q.eq("subjectPrincipalId", principalId))
529
+ .collect()) {
530
+ await ctx.db.delete(binding._id);
531
+ }
532
+ for (const binding of await ctx.db
533
+ .query("permission_bindings")
534
+ .withIndex("by_subject_principal", (q) => q.eq("subjectPrincipalId", principalId))
535
+ .collect()) {
536
+ await ctx.db.delete(binding._id);
537
+ }
538
+ for (const membership of await ctx.db
539
+ .query("principal_memberships")
540
+ .withIndex("by_group", (q) => q.eq("accessScopeId", accessScopeId).eq("groupPrincipalId", principalId))
541
+ .collect()) {
542
+ await ctx.db.delete(membership._id);
543
+ }
544
+ for (const membership of await ctx.db
545
+ .query("principal_memberships")
546
+ .withIndex("by_member", (q) => q.eq("accessScopeId", accessScopeId).eq("memberPrincipalId", principalId))
547
+ .collect()) {
548
+ await ctx.db.delete(membership._id);
549
+ }
550
+ await ctx.db.delete(principal._id);
551
+ }
552
+ async function upsertMembership(accessScopeId, membership) {
553
+ const existing = await ctx.db
554
+ .query("principal_memberships")
555
+ .withIndex("by_group_member", (q) => q
556
+ .eq("accessScopeId", accessScopeId)
557
+ .eq("groupPrincipalId", membership.groupPrincipalId)
558
+ .eq("memberPrincipalId", membership.memberPrincipalId))
559
+ .unique();
560
+ const row = { accessScopeId, ...membership };
561
+ if (existing)
562
+ await ctx.db.replace(existing._id, row);
563
+ else
564
+ await ctx.db.insert("principal_memberships", row);
565
+ }
566
+ async function deleteMembership(accessScopeId, groupPrincipalId, memberPrincipalId) {
567
+ const membership = await ctx.db
568
+ .query("principal_memberships")
569
+ .withIndex("by_group_member", (q) => q
570
+ .eq("accessScopeId", accessScopeId)
571
+ .eq("groupPrincipalId", groupPrincipalId)
572
+ .eq("memberPrincipalId", memberPrincipalId))
573
+ .unique();
574
+ if (membership)
575
+ await ctx.db.delete(membership._id);
576
+ }
577
+ async function upsertCatalogRole(role) {
578
+ const existing = await ctx.db
579
+ .query("roles")
580
+ .withIndex("by_role_id", (q) => q.eq("roleId", role.roleId))
581
+ .unique();
582
+ // Catalog roles carry NO accessScopeId.
583
+ if (existing)
584
+ await ctx.db.replace(existing._id, role);
585
+ else
586
+ await ctx.db.insert("roles", role);
587
+ }
588
+ async function upsertTenantRole(role) {
589
+ const existing = await ctx.db
590
+ .query("roles")
591
+ .withIndex("by_role_id", (q) => q.eq("roleId", role.roleId))
592
+ .unique();
593
+ if (existing)
594
+ await ctx.db.replace(existing._id, role);
595
+ else
596
+ await ctx.db.insert("roles", role);
597
+ }
598
+ async function deleteRoleEverywhere(roleId) {
599
+ const role = await ctx.db
600
+ .query("roles")
601
+ .withIndex("by_role_id", (q) => q.eq("roleId", roleId))
602
+ .unique();
603
+ if (!role)
604
+ return;
605
+ // Cascade base mappings + per-scope overrides keyed on this role.
606
+ for (const row of await ctx.db
607
+ .query("role_permissions")
608
+ .withIndex("by_role", (q) => q.eq("roleId", roleId))
609
+ .collect()) {
610
+ await ctx.db.delete(row._id);
611
+ }
612
+ for (const row of await ctx.db
613
+ .query("role_permission_overrides")
614
+ .withIndex("by_role", (q) => q.eq("roleId", roleId))
615
+ .collect()) {
616
+ await ctx.db.delete(row._id);
617
+ }
618
+ // Cascade bindings: role bindings of this role, and permission bindings
619
+ // with this role as subject.
620
+ for (const binding of await ctx.db
621
+ .query("role_bindings")
622
+ .withIndex("by_role", (q) => q.eq("roleId", roleId))
623
+ .collect()) {
624
+ await ctx.db.delete(binding._id);
625
+ }
626
+ for (const binding of await ctx.db
627
+ .query("permission_bindings")
628
+ .withIndex("by_subject_role", (q) => q.eq("subjectRoleId", roleId))
629
+ .collect()) {
630
+ await ctx.db.delete(binding._id);
631
+ }
632
+ await ctx.db.delete(role._id);
633
+ }
634
+ async function deleteCatalogRole(roleId) {
635
+ await deleteRoleEverywhere(roleId);
636
+ }
637
+ async function deleteTenantRole(roleId) {
638
+ await deleteRoleEverywhere(roleId);
639
+ }
640
+ async function upsertPermission(permission) {
641
+ const existing = await ctx.db
642
+ .query("permissions")
643
+ .withIndex("by_permission_id", (q) => q.eq("permissionId", permission.permissionId))
644
+ .unique();
645
+ if (existing)
646
+ await ctx.db.replace(existing._id, permission);
647
+ else
648
+ await ctx.db.insert("permissions", permission);
649
+ }
650
+ async function deletePermission(permissionId) {
651
+ const permission = await ctx.db
652
+ .query("permissions")
653
+ .withIndex("by_permission_id", (q) => q.eq("permissionId", permissionId))
654
+ .unique();
655
+ if (!permission)
656
+ return;
657
+ // Cascade base mappings, overrides, and permission bindings on this perm.
658
+ for (const row of await ctx.db
659
+ .query("role_permissions")
660
+ .withIndex("by_permission", (q) => q.eq("permissionId", permissionId))
661
+ .collect()) {
662
+ await ctx.db.delete(row._id);
663
+ }
664
+ for (const row of await ctx.db
665
+ .query("role_permission_overrides")
666
+ .withIndex("by_permission", (q) => q.eq("permissionId", permissionId))
667
+ .collect()) {
668
+ await ctx.db.delete(row._id);
669
+ }
670
+ for (const binding of await ctx.db
671
+ .query("permission_bindings")
672
+ .withIndex("by_permission", (q) => q.eq("permissionId", permissionId))
673
+ .collect()) {
674
+ await ctx.db.delete(binding._id);
675
+ }
676
+ await ctx.db.delete(permission._id);
677
+ }
678
+ async function upsertRolePermission(rolePermission) {
679
+ const existing = await ctx.db
680
+ .query("role_permissions")
681
+ .withIndex("by_role_permission", (q) => q.eq("roleId", rolePermission.roleId).eq("permissionId", rolePermission.permissionId))
682
+ .unique();
683
+ if (existing)
684
+ await ctx.db.replace(existing._id, rolePermission);
685
+ else
686
+ await ctx.db.insert("role_permissions", rolePermission);
687
+ }
688
+ async function deleteRolePermission(roleId, permissionId) {
689
+ const row = await ctx.db
690
+ .query("role_permissions")
691
+ .withIndex("by_role_permission", (q) => q.eq("roleId", roleId).eq("permissionId", permissionId))
692
+ .unique();
693
+ if (row)
694
+ await ctx.db.delete(row._id);
695
+ }
696
+ async function upsertOverride(override) {
697
+ const existing = await ctx.db
698
+ .query("role_permission_overrides")
699
+ .withIndex("by_scope_role_permission", (q) => q
700
+ .eq("accessScopeId", override.accessScopeId)
701
+ .eq("roleId", override.roleId)
702
+ .eq("permissionId", override.permissionId))
703
+ .unique();
704
+ if (existing)
705
+ await ctx.db.replace(existing._id, override);
706
+ else
707
+ await ctx.db.insert("role_permission_overrides", override);
708
+ }
709
+ async function deleteOverride(accessScopeId, roleId, permissionId) {
710
+ const row = await ctx.db
711
+ .query("role_permission_overrides")
712
+ .withIndex("by_scope_role_permission", (q) => q
713
+ .eq("accessScopeId", accessScopeId)
714
+ .eq("roleId", roleId)
715
+ .eq("permissionId", permissionId))
716
+ .unique();
717
+ if (row)
718
+ await ctx.db.delete(row._id);
719
+ }
720
+ async function upsertRoleBinding(binding, now) {
721
+ const existing = await ctx.db
722
+ .query("role_bindings")
723
+ .withIndex("by_binding_id", (q) => q.eq("bindingId", binding.bindingId))
724
+ .unique();
725
+ if (binding.expiresAt !== undefined && binding.expiresAt <= now) {
726
+ if (existing)
727
+ await ctx.db.delete(existing._id);
728
+ return;
729
+ }
730
+ const normalized = { ...binding, appliesTo: bindingAppliesTo(binding) };
731
+ if (existing)
732
+ await ctx.db.replace(existing._id, normalized);
733
+ else
734
+ await ctx.db.insert("role_bindings", normalized);
735
+ await scheduleRoleBindingExpiration(binding);
736
+ }
737
+ async function deleteRoleBinding(bindingId) {
738
+ const binding = await ctx.db
739
+ .query("role_bindings")
740
+ .withIndex("by_binding_id", (q) => q.eq("bindingId", bindingId))
741
+ .unique();
742
+ if (binding)
743
+ await ctx.db.delete(binding._id);
744
+ }
745
+ async function upsertPermissionBinding(binding, now) {
746
+ const existing = await ctx.db
747
+ .query("permission_bindings")
748
+ .withIndex("by_binding_id", (q) => q.eq("bindingId", binding.bindingId))
749
+ .unique();
750
+ if (binding.expiresAt !== undefined && binding.expiresAt <= now) {
751
+ if (existing)
752
+ await ctx.db.delete(existing._id);
753
+ return;
754
+ }
755
+ const normalized = { ...binding, appliesTo: bindingAppliesTo(binding) };
756
+ if (existing)
757
+ await ctx.db.replace(existing._id, normalized);
758
+ else
759
+ await ctx.db.insert("permission_bindings", normalized);
760
+ await schedulePermissionBindingExpiration(binding);
761
+ }
762
+ async function deletePermissionBinding(bindingId) {
763
+ const binding = await ctx.db
764
+ .query("permission_bindings")
765
+ .withIndex("by_binding_id", (q) => q.eq("bindingId", bindingId))
766
+ .unique();
767
+ if (binding)
768
+ await ctx.db.delete(binding._id);
769
+ }
770
+ async function scheduleRoleBindingExpiration(binding) {
771
+ if (binding.expiresAt === undefined)
772
+ return;
773
+ await ctx.scheduler.runAt(binding.expiresAt, expireRoleBindingReference, {
774
+ bindingId: binding.bindingId,
775
+ expiresAt: binding.expiresAt,
776
+ updatedAt: binding.updatedAt,
777
+ });
778
+ }
779
+ async function schedulePermissionBindingExpiration(binding) {
780
+ if (binding.expiresAt === undefined)
781
+ return;
782
+ await ctx.scheduler.runAt(binding.expiresAt, expirePermissionBindingReference, {
783
+ bindingId: binding.bindingId,
784
+ expiresAt: binding.expiresAt,
785
+ updatedAt: binding.updatedAt,
786
+ });
787
+ }
788
+ },
789
+ });
790
+ export const expireRoleBinding = internalMutation({
791
+ args: { bindingId: v.string(), expiresAt: v.number(), updatedAt: v.number() },
792
+ handler: async (ctx, args) => {
793
+ const binding = await ctx.db
794
+ .query("role_bindings")
795
+ .withIndex("by_binding_id", (q) => q.eq("bindingId", args.bindingId))
796
+ .unique();
797
+ if (!binding || binding.expiresAt !== args.expiresAt || binding.updatedAt !== args.updatedAt) {
798
+ return;
799
+ }
800
+ if (args.expiresAt > Date.now()) {
801
+ await ctx.scheduler.runAt(args.expiresAt, expireRoleBindingReference, args);
802
+ return;
803
+ }
804
+ await ctx.db.delete(binding._id);
805
+ },
806
+ });
807
+ export const expirePermissionBinding = internalMutation({
808
+ args: { bindingId: v.string(), expiresAt: v.number(), updatedAt: v.number() },
809
+ handler: async (ctx, args) => {
810
+ const binding = await ctx.db
811
+ .query("permission_bindings")
812
+ .withIndex("by_binding_id", (q) => q.eq("bindingId", args.bindingId))
813
+ .unique();
814
+ if (!binding || binding.expiresAt !== args.expiresAt || binding.updatedAt !== args.updatedAt) {
815
+ return;
816
+ }
817
+ if (args.expiresAt > Date.now()) {
818
+ await ctx.scheduler.runAt(args.expiresAt, expirePermissionBindingReference, args);
819
+ return;
820
+ }
821
+ await ctx.db.delete(binding._id);
822
+ },
823
+ });
824
+ function organizationFromScope(scope) {
825
+ return {
826
+ accessScopeId: scope.accessScopeId,
827
+ name: scope.name,
828
+ status: scope.status,
829
+ accountEntryMode: scope.accountEntryMode,
830
+ updatedAt: scope.updatedAt,
831
+ };
832
+ }
833
+ function snapshotDocumentCount(snapshot) {
834
+ let count = snapshot.catalog.roles.length +
835
+ snapshot.catalog.permissions.length +
836
+ snapshot.catalog.rolePermissions.length +
837
+ snapshot.users.length;
838
+ for (const scope of snapshot.scopes) {
839
+ count +=
840
+ 1 + // scope (+ possibly an organization row, counted generously below)
841
+ 1 +
842
+ scope.principals.length +
843
+ scope.principalMemberships.length +
844
+ scope.roles.length +
845
+ scope.rolePermissionOverrides.length +
846
+ scope.roleBindings.length +
847
+ scope.permissionBindings.length;
848
+ }
849
+ return count;
850
+ }
851
+ //# sourceMappingURL=sync.js.map