@cosmicdrift/kumiko-samples 0.364.0 → 0.365.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 (143) hide show
  1. package/LICENSE +1 -1
  2. package/package.json +1 -1
  3. package/packages/bundled-features/package.json +1 -1
  4. package/packages/bundled-features/src/audit/changes.json +12 -0
  5. package/packages/bundled-features/src/audit/feature.ts +1 -1
  6. package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +63 -11
  7. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +1 -3
  8. package/packages/bundled-features/src/auth-email-password/changes.json +18 -0
  9. package/packages/bundled-features/src/auth-email-password/feature.ts +4 -7
  10. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -3
  11. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +6 -4
  12. package/packages/bundled-features/src/auth-email-password/handlers/switch-tenant-mfa-gate.write.ts +2 -2
  13. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +7 -3
  14. package/packages/bundled-features/src/auth-email-password/mail-i18n.ts +10 -0
  15. package/packages/bundled-features/src/billing-foundation/changes.json +12 -0
  16. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +8 -1
  17. package/packages/bundled-features/src/billing-foundation/i18n.ts +6 -0
  18. package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +10 -2
  19. package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +20 -8
  20. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +7 -2
  21. package/packages/bundled-features/src/cap-counter/changes.json +13 -0
  22. package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +2 -0
  23. package/packages/bundled-features/src/cap-overview/changes.json +6 -0
  24. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
  25. package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +2 -1
  26. package/packages/bundled-features/src/channel-discord/discord-channel.ts +4 -2
  27. package/packages/bundled-features/src/channel-email/changes.json +18 -0
  28. package/packages/bundled-features/src/channel-email/email-channel.ts +30 -20
  29. package/packages/bundled-features/src/channel-email/smtp-transport.ts +7 -1
  30. package/packages/bundled-features/src/channel-slack/slack-channel.ts +4 -2
  31. package/packages/bundled-features/src/channel-teams/teams-channel.ts +4 -2
  32. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +7 -1
  33. package/packages/bundled-features/src/custom-fields/db/queries/retention.ts +2 -2
  34. package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +4 -5
  35. package/packages/bundled-features/src/delivery/attachment-ref.ts +18 -1
  36. package/packages/bundled-features/src/delivery/changes.json +12 -0
  37. package/packages/bundled-features/src/delivery/delivery-service.ts +93 -92
  38. package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
  39. package/packages/bundled-features/src/feature-toggles/changes.json +7 -0
  40. package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +26 -12
  41. package/packages/bundled-features/src/files-tenant-data/index.ts +3 -1
  42. package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +12 -3
  43. package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
  44. package/packages/bundled-features/src/jobs/changes.json +12 -0
  45. package/packages/bundled-features/src/jobs/job-run-logger.ts +5 -2
  46. package/packages/bundled-features/src/metrics/changes.json +6 -0
  47. package/packages/bundled-features/src/metrics/filters.ts +28 -12
  48. package/packages/bundled-features/src/personal-access-tokens/changes.json +6 -0
  49. package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +7 -8
  50. package/packages/bundled-features/src/secrets/changes.json +6 -0
  51. package/packages/bundled-features/src/secrets/handlers/delete.write.ts +3 -1
  52. package/packages/bundled-features/src/secrets/write-gate.ts +8 -2
  53. package/packages/bundled-features/src/shared/index.ts +8 -0
  54. package/packages/bundled-features/src/shared/resource-image-formats.ts +31 -0
  55. package/packages/bundled-features/src/shared/sharp-input-limits.ts +7 -8
  56. package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +2 -2
  57. package/packages/bundled-features/src/template-resolver/changes.json +81 -0
  58. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +78 -12
  59. package/packages/bundled-features/src/template-resolver/compile-steps-block.ts +68 -0
  60. package/packages/bundled-features/src/template-resolver/constants.ts +11 -1
  61. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -0
  62. package/packages/bundled-features/src/template-resolver/document-schema.ts +7 -1
  63. package/packages/bundled-features/src/template-resolver/document-steps.ts +114 -0
  64. package/packages/bundled-features/src/template-resolver/feature.ts +12 -0
  65. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +81 -39
  66. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +4 -3
  67. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +111 -61
  68. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +1 -0
  69. package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +25 -15
  70. package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +12 -1
  71. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +12 -2
  72. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +48 -9
  73. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +4 -1
  74. package/packages/bundled-features/src/template-resolver/index.ts +6 -1
  75. package/packages/bundled-features/src/template-resolver/layout-appearance.ts +25 -9
  76. package/packages/bundled-features/src/template-resolver/mail-constants.ts +4 -0
  77. package/packages/bundled-features/src/template-resolver/resource-image.ts +9 -16
  78. package/packages/bundled-features/src/template-resolver/seeding.ts +2 -0
  79. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +390 -124
  80. package/packages/bundled-features/src/template-resolver/template-bundle.ts +4 -0
  81. package/packages/bundled-features/src/template-resolver/template-draft.ts +1 -1
  82. package/packages/bundled-features/src/template-resolver/template-meta.ts +8 -0
  83. package/packages/bundled-features/src/template-resolver/template-preview.ts +1 -1
  84. package/packages/bundled-features/src/template-resolver/template-resources.ts +2 -43
  85. package/packages/bundled-features/src/template-resolver/template-validation.ts +15 -9
  86. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +34 -0
  87. package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +3 -2
  88. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +56 -11
  89. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +6 -3
  90. package/packages/bundled-features/src/template-resolver/web/i18n.ts +7 -0
  91. package/packages/bundled-features/src/template-resolver/web/index.ts +1 -0
  92. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +14 -5
  93. package/packages/bundled-features/src/tenant/changes.json +6 -0
  94. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +3 -8
  95. package/packages/bundled-features/src/tenant/seeding.ts +33 -27
  96. package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +14 -3
  97. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  98. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +9 -1
  99. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +3 -2
  100. package/packages/bundled-features/src/tier-engine/changes.json +12 -0
  101. package/packages/bundled-features/src/tier-engine/constants.ts +9 -0
  102. package/packages/bundled-features/src/tier-engine/entity.ts +9 -4
  103. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +8 -5
  104. package/packages/bundled-features/src/tier-engine/index.ts +2 -0
  105. package/packages/bundled-features/src/user/constants.ts +1 -0
  106. package/packages/bundled-features/src/user/invited-global-roles.ts +2 -4
  107. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  108. package/packages/bundled-features/src/user-data-rights/email-templates.ts +0 -1
  109. package/packages/bundled-features/src/user-data-rights/i18n.ts +5 -0
  110. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +13 -2
  111. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +7 -1
  112. package/packages/bundled-features/src/waitlist/changes.json +18 -0
  113. package/packages/bundled-features/src/waitlist/feature.ts +1 -1
  114. package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +35 -3
  115. package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +2 -17
  116. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +33 -9
  117. package/packages/bundled-features/src/waitlist/i18n.ts +9 -0
  118. package/packages/bundled-features/src/waitlist/lib.ts +20 -0
  119. package/packages/bundled-features/src/workflow-runner/feature.ts +1 -1
  120. package/samples/apps/ui-walkthrough/src/run-config.ts +14 -1
  121. package/samples/apps/use-all-bundled/README.md +3 -13
  122. package/samples/apps/use-all-bundled/feature-manifest.json +3 -3
  123. package/samples/apps/use-all-bundled/scripts/check-coverage.ts +34 -118
  124. package/samples/apps/use-all-bundled/src/app/notes-feature.ts +1 -1
  125. package/samples/apps/use-all-bundled/src/run-config.ts +0 -3
  126. package/samples/recipes/auth-mfa/README.md +2 -1
  127. package/samples/recipes/auth-mfa/src/feature.ts +1 -0
  128. package/samples/recipes/custom-handlers/README.md +4 -3
  129. package/samples/recipes/custom-handlers/src/feature.ts +21 -12
  130. package/samples/recipes/dashboard-stats/README.md +38 -0
  131. package/samples/recipes/dashboard-stats/package.json +8 -0
  132. package/samples/recipes/dashboard-stats/src/feature.ts +104 -0
  133. package/samples/recipes/legal-pages/README.md +2 -2
  134. package/samples/recipes/managed-pages/README.md +2 -2
  135. package/samples/recipes/pdf-audit-export/README.md +2 -1
  136. package/samples/recipes/pdf-audit-export/package.json +1 -1
  137. package/samples/recipes/pdf-audit-export/src/feature.ts +68 -48
  138. package/samples/recipes/workflow-engine/src/db/queries/suspended-runs.ts +2 -2
  139. package/samples/apps/use-all-bundled/schema/generate.ts +0 -617
  140. package/samples/apps/use-all-bundled/schema/migrations/meta/0000_snapshot.json +0 -3055
  141. package/samples/apps/use-all-bundled/schema/migrations/meta/_journal.json +0 -13
  142. package/samples/apps/use-all-bundled/schema/schema.generated.ts +0 -94
  143. package/samples/apps/use-all-bundled/schema/schema.ts +0 -9
@@ -101,26 +101,6 @@ export type SeedTenantHooks = {
101
101
  readonly context: AppContext;
102
102
  };
103
103
 
104
- // Shared by seedTenant/seedTenantMembership/auth-email-password's
105
- // seedUserWithPassword: fires the same entity-scoped postSave hooks the
106
- // real dispatcher would (registry.getEntityPostSaveHooks by entityName),
107
- // just without the full HTTP/JWT roundtrip a fixture doesn't need. Fresh
108
- // lifecycle-hooks instance per call, no systemHooks — only feature-
109
- // registered entity/handler hooks fire (search-index/SSE system hooks are
110
- // wired at server-boot and out of reach here).
111
- //
112
- // Tenant re-scope (#1566): when `targetTenantId` is set we rebuild the
113
- // tenant-bound pieces of the context (`_tenantId`, `db`, `notify`,
114
- // `hasFeature`, `user.tenantId`) — a shallow `{ ...ctx, _tenantId }` left
115
- // closures over the caller's tenant (self-signup is anonymous/system).
116
- // `query`/`write` bridges still share the caller's identity; hooks that
117
- // need a full identity switch use `writeAs`/`queryAs`.
118
- //
119
- // afterCommit (#1566): when `hooks.context.scheduleAfterCommit` is present
120
- // (HandlerContext from a live write), afterCommit hooks are queued onto the
121
- // dispatcher's sink and flush post-commit. Without a sink (fixture / plain
122
- // Connection) they still fire immediately — there is no outer TX to wait for.
123
- //
124
104
  function buildSeedHookNotify(
125
105
  context: AppContext,
126
106
  scopedUser: SessionUser,
@@ -129,7 +109,11 @@ function buildSeedHookNotify(
129
109
  ): AppContext["notify"] {
130
110
  const notifyFactory = context._notifyFactory;
131
111
  if (!notifyFactory) return context.notify;
132
- if (notifyTiming === "inline") return notifyFactory(scopedUser, targetTenantId);
112
+ const notifyAfterCommit = (context._notifyAfterCommitFactory ?? notifyFactory)(
113
+ scopedUser,
114
+ targetTenantId,
115
+ );
116
+ if (notifyTiming === "inline") return notifyAfterCommit;
133
117
  // Still inside the caller's write tx: delivery DB work must not wait for an app-pool slot.
134
118
  const notifyInsideTransaction = (context._notifyInTransactionFactory ?? notifyFactory)(
135
119
  scopedUser,
@@ -140,12 +124,31 @@ function buildSeedHookNotify(
140
124
  return createDeferringNotify({
141
125
  registry: context.registry,
142
126
  tenantId: targetTenantId,
143
- notifyAfterCommit: notifyFactory(scopedUser, targetTenantId),
127
+ notifyAfterCommit,
144
128
  notifyNow: notifyInsideTransaction,
145
129
  scheduleAfterCommit,
146
130
  });
147
131
  }
148
132
 
133
+ // Shared by seedTenant/seedTenantMembership/auth-email-password's
134
+ // seedUserWithPassword: fires the same entity-scoped postSave hooks the
135
+ // real dispatcher would (registry.getEntityPostSaveHooks by entityName),
136
+ // just without the full HTTP/JWT roundtrip a fixture doesn't need. Fresh
137
+ // lifecycle-hooks instance per call, no systemHooks — only feature-
138
+ // registered entity/handler hooks fire (search-index/SSE system hooks are
139
+ // wired at server-boot and out of reach here).
140
+ //
141
+ // Tenant re-scope (#1566): when `targetTenantId` is set we rebuild the
142
+ // tenant-bound pieces of the context (`_tenantId`, `db`, `notify`,
143
+ // `hasFeature`, `user.tenantId`) — a shallow `{ ...ctx, _tenantId }` left
144
+ // closures over the caller's tenant (self-signup is anonymous/system).
145
+ // `query`/`write` bridges still share the caller's identity; hooks that
146
+ // need a full identity switch use `writeAs`/`queryAs`.
147
+ //
148
+ // afterCommit (#1566): when `hooks.context.scheduleAfterCommit` is present
149
+ // (HandlerContext from a live write), afterCommit hooks are queued onto the
150
+ // dispatcher's sink and flush post-commit. Without a sink (fixture / plain
151
+ // Connection) they still fire immediately — there is no outer TX to wait for.
149
152
  // `runner` comes from the caller, which already holds a declared DbRunner —
150
153
  // a TenantDb exposes no runner back out to feature code.
151
154
  function scopeSeedHookContext(
@@ -219,13 +222,16 @@ export async function fireEntityPostSave(
219
222
  target === undefined
220
223
  ? hooks.context
221
224
  : scopeSeedHookContext(hooks.context, target.tenantId, target.db, "deferUntilCommit");
222
- const afterCommitContext =
223
- target === undefined
224
- ? hooks.context
225
- : scopeSeedHookContext(hooks.context, target.tenantId, target.db, "inline");
226
225
  await lifecycle.runPostSave(pseudoType, entityData, scopedContext, HookPhases.inTransaction);
227
226
  const runAfterCommit = () =>
228
- lifecycle.runPostSave(pseudoType, entityData, afterCommitContext, HookPhases.afterCommit);
227
+ lifecycle.runPostSave(
228
+ pseudoType,
229
+ entityData,
230
+ target === undefined
231
+ ? hooks.context
232
+ : scopeSeedHookContext(hooks.context, target.tenantId, target.db, "inline"),
233
+ HookPhases.afterCommit,
234
+ );
229
235
  if (scopedContext.scheduleAfterCommit) {
230
236
  scopedContext.scheduleAfterCommit(runAfterCommit);
231
237
  } else {
@@ -28,6 +28,7 @@ import {
28
28
  executeRawQuery,
29
29
  extractTableName,
30
30
  physicalColumnName,
31
+ toPostgresArrayLiteral,
31
32
  } from "@cosmicdrift/kumiko-framework/db";
32
33
  import { MAX_TRANSFER_DEPTH, type Registry } from "@cosmicdrift/kumiko-framework/engine";
33
34
  import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
@@ -79,7 +80,12 @@ async function moveFileRefs(args: {
79
80
  args.db,
80
81
  `UPDATE ${FILE_REFS_TABLE} SET tenant_id = $1 ` +
81
82
  `WHERE tenant_id = $2 AND entity_type = $3 AND entity_id = ANY($4) RETURNING id`,
82
- [args.destinationTenantId, args.sourceTenantId, args.entityType, args.entityIds],
83
+ [
84
+ args.destinationTenantId,
85
+ args.sourceTenantId,
86
+ args.entityType,
87
+ toPostgresArrayLiteral(args.entityIds),
88
+ ],
83
89
  );
84
90
  const movedIds = rows.map((row) => row.id);
85
91
  if (movedIds.length === 0) return 0;
@@ -126,7 +132,12 @@ async function moveChildRows(args: {
126
132
  `UPDATE "${args.tableName}" SET "${args.tenantCol}" = $1 ` +
127
133
  `WHERE "${args.typeCol}" = $2 AND "${args.idCol}" = ANY($3) AND "${args.tenantCol}" = $4 ` +
128
134
  `RETURNING "${args.pkCol}" AS id`,
129
- [args.destinationTenantId, args.parentEntityType, args.parentRowIds, args.sourceTenantId],
135
+ [
136
+ args.destinationTenantId,
137
+ args.parentEntityType,
138
+ toPostgresArrayLiteral(args.parentRowIds),
139
+ args.sourceTenantId,
140
+ ],
130
141
  );
131
142
  return rows.map((row) => row.id);
132
143
  }
@@ -149,7 +160,7 @@ async function moveReferencedChildRows(args: {
149
160
  `UPDATE "${args.tableName}" SET "${args.tenantCol}" = $1 ` +
150
161
  `WHERE "${args.refCol}" = ANY($2) AND "${args.tenantCol}" = $3 ` +
151
162
  `RETURNING "${args.pkCol}" AS id`,
152
- [args.destinationTenantId, args.parentRowIds, args.sourceTenantId],
163
+ [args.destinationTenantId, toPostgresArrayLiteral(args.parentRowIds), args.sourceTenantId],
153
164
  );
154
165
  return rows.map((row) => row.id);
155
166
  }
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "fix",
5
+ "title": "One sweep tick shares a single stage budget between all destroying tenants",
6
+ "detail": "The destruction sweep used to give every destroying tenant its own full stage budget, so a few large tenants stretched one tick far beyond the minute cron window and the following ticks were skipped. The tick budget is now divided evenly over the destroying tenants. Each tenant's slice starts when that tenant's turn begins, so an overrun by one tenant never leaves the next ones with a zero slice. runNextDestructionStage accepts an optional deadlineAt."
7
+ },
2
8
  {
3
9
  "version": "0.354.0",
4
10
  "type": "improvement",
@@ -290,6 +290,7 @@ export async function runNextDestructionStage(args: {
290
290
  readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
291
291
  readonly escapeHatchAuditLog?: Logger;
292
292
  readonly actor?: string;
293
+ readonly deadlineAt?: number;
293
294
  }): Promise<StageTickResult> {
294
295
  const events = await loadAggregate(args.db, args.tenantId, args.tenantId);
295
296
  const { completed, abandoned, attemptsByStage } = replayStageState(events);
@@ -316,7 +317,7 @@ export async function runNextDestructionStage(args: {
316
317
  escapeHatchAuditSink: args.escapeHatchAuditSink,
317
318
  escapeHatchAuditLog: args.escapeHatchAuditLog,
318
319
  actor: args.actor,
319
- deadlineAt: Date.now() + DESTRUCTION_STAGE_TICK_BUDGET_MS,
320
+ deadlineAt: args.deadlineAt ?? Date.now() + DESTRUCTION_STAGE_TICK_BUDGET_MS,
320
321
  };
321
322
 
322
323
  let version = lastEventVersion(events);
@@ -456,9 +457,16 @@ export async function runTenantDestructionSweep(args: {
456
457
 
457
458
  let advanced = 0;
458
459
  const destroying = await selectMany<{ id: string }>(args.db, tenantTable, destroyingWhere);
460
+ // One budget per sweep tick, split evenly so a large tenant cannot eat the
461
+ // window and starve the ones behind it. Each slice starts when its tenant
462
+ // starts: anchoring to a shared tick deadline would hand tenants after an
463
+ // overrun a zero slice, and a no-progress stage counts as a failed attempt.
464
+ const sliceMs = DESTRUCTION_STAGE_TICK_BUDGET_MS / Math.max(1, destroying.length);
459
465
  for (const row of destroying) {
466
+ const tenantDeadlineAt = Date.now() + sliceMs;
460
467
  try {
461
468
  const result = await runNextDestructionStage({
469
+ deadlineAt: tenantDeadlineAt,
462
470
  db: args.db,
463
471
  registry: args.registry,
464
472
  tenantId: row.id as TenantId,
@@ -88,8 +88,9 @@ const tenantMembershipCrud = createEventStoreExecutor(
88
88
  },
89
89
  );
90
90
 
91
- // Every hook still runs each tick (they are idempotent), so a later hook is not
92
- // starved by an earlier one that keeps reporting done:false.
91
+ // Every hook runs each tick (they are idempotent) but all share one ctx.deadlineAt: an
92
+ // earlier hook that keeps reporting done:false can use up the budget, so later chunking
93
+ // hooks time out with nothing processed until it finishes.
93
94
  function newOutcomeAccumulator(): {
94
95
  add: (result: TenantDestroyHookResult | void) => void;
95
96
  outcome: () => StageRunOutcome;
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "improvement",
5
+ "title": "set-tenant-tier accepts an optional source so a manual grant can return to billing control",
6
+ "detail": "The handler payload takes source (manual, billing or default, default manual). Writing the row with source billing makes the subscription tier sync treat it as billing-driven again, so the next webhook overwrites the tier. The handler stays SystemAdmin only."
7
+ },
8
+ {
9
+ "version": "0.365.0",
10
+ "type": "fix",
11
+ "title": "tier-assignment source is a select field of TierAssignmentSources",
12
+ "detail": "The source column keeps its text storage, but the entity now validates it against manual, billing and default, so a typo can no longer bypass the manual-grant protection of the billing sync. TierAssignmentSource and isTierAssignmentSource are exported; the billing sync narrows the stored value with the guard."
13
+ },
2
14
  {
3
15
  "version": "0.364.0",
4
16
  "type": "fix",
@@ -22,6 +22,15 @@ export const TierAssignmentSources = {
22
22
  default: "default",
23
23
  } as const;
24
24
 
25
+ export type TierAssignmentSource =
26
+ (typeof TierAssignmentSources)[keyof typeof TierAssignmentSources];
27
+
28
+ const TIER_ASSIGNMENT_SOURCE_VALUES: readonly string[] = Object.values(TierAssignmentSources);
29
+
30
+ export function isTierAssignmentSource(value: unknown): value is TierAssignmentSource {
31
+ return typeof value === "string" && TIER_ASSIGNMENT_SOURCE_VALUES.includes(value);
32
+ }
33
+
25
34
  // Qualified query handler names.
26
35
  export const TierEngineQueries = {
27
36
  list: "tier-engine:query:tier-assignment:list",
@@ -1,4 +1,9 @@
1
- import { createEntity, createTextField } from "@cosmicdrift/kumiko-framework/engine";
1
+ import {
2
+ createEntity,
3
+ createSelectField,
4
+ createTextField,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { type TierAssignmentSource, TierAssignmentSources } from "./constants.js";
2
7
 
3
8
  // tier-assignment — pro Plattform-Tenant genau ein Aggregat. Aggregate-ID
4
9
  // wird deterministisch aus tenantId abgeleitet (uuidv5, siehe aggregate-id.ts).
@@ -35,9 +40,9 @@ export const tierAssignmentEntity = createEntity({
35
40
  }),
36
41
  // Origin of the assignment, see TierAssignmentSources. Optional for back-compat with
37
42
  // rows without source. Protects manual grants from being overwritten by the billing sync.
38
- source: createTextField({
43
+ source: createSelectField({
44
+ options: Object.values(TierAssignmentSources),
39
45
  required: false,
40
- maxLength: 20,
41
46
  personal: false,
42
47
  reason: "technical_reference",
43
48
  }),
@@ -48,6 +53,6 @@ export type TierAssignmentRow = {
48
53
  readonly id: string;
49
54
  readonly version: number;
50
55
  readonly tier: string;
51
- readonly source: string | null;
56
+ readonly source: TierAssignmentSource | null;
52
57
  readonly tenantId: string;
53
58
  };
@@ -4,7 +4,7 @@ import { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework
4
4
  import * as z from "zod";
5
5
  import { requireForTenant } from "../../shared/index.js";
6
6
  import { tierAssignmentAggregateId } from "../aggregate-id.js";
7
- import { TierAssignmentSources } from "../constants.js";
7
+ import { type TierAssignmentSource, TierAssignmentSources } from "../constants.js";
8
8
  import { type TierAssignmentRow, tierAssignmentEntity } from "../entity.js";
9
9
 
10
10
  // SystemAdmin setzt das Tier eines BELIEBIGEN Tenants — manueller Grant ohne
@@ -14,7 +14,8 @@ import { type TierAssignmentRow, tierAssignmentEntity } from "../entity.js";
14
14
  // Cross-tenant mechanics: ctx.forTenant(tenantId) yields a tenant-mode db bound to the target plus
15
15
  // the streamTenantId, so the event lands in the target's stream with the operator as actor.
16
16
  //
17
- // `source: TierAssignmentSources.manual` marks the grant so the billing sync skips it.
17
+ // `source` defaults to manual and marks the grant so the billing sync skips it; passing
18
+ // `billing` hands the row back to the sync, which overwrites it on the next webhook.
18
19
  // Upsert: one aggregate per tenant (deterministic aggregate id).
19
20
  //
20
21
  // Effective-set invalidation: the executor write does not fire the
@@ -47,7 +48,7 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
47
48
  return defineWriteHandler({
48
49
  name: "set-tenant-tier",
49
50
  description:
50
- "Assigns a tier to any tenant as a manual grant without a billing purchase, marking it so a later billing sync will not overwrite it, and applies the new feature set immediately.",
51
+ "Assigns a tier to any tenant as a manual grant without a billing purchase, marking it so a later billing sync will not overwrite it (or, with source billing, handing the row back to the sync), and applies the new feature set immediately.",
51
52
  schema: z.object({
52
53
  tenantId: z.string().min(1),
53
54
  tier: z
@@ -55,6 +56,7 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
55
56
  .min(1)
56
57
  .max(50)
57
58
  .refine((t) => !opts.validTiers || opts.validTiers.has(t), { message: "unknown tier" }),
59
+ source: z.enum(TierAssignmentSources).default(TierAssignmentSources.manual),
58
60
  }),
59
61
  access: { roles: ["SystemAdmin"] },
60
62
  agent: { risk: "high" },
@@ -68,6 +70,7 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
68
70
  const tenantId = event.payload.tenantId as TenantId; // @cast-boundary engine-bridge
69
71
  const { db: tdb, streamTenantId } = requireForTenant(ctx, tenantId);
70
72
  const tier = event.payload.tier;
73
+ const source: TierAssignmentSource = event.payload.source;
71
74
 
72
75
  const existing = await fetchOne<TierAssignmentRow>(tdb, tierAssignmentTable, { tenantId });
73
76
 
@@ -76,7 +79,7 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
76
79
  {
77
80
  id: existing.id,
78
81
  version: existing.version,
79
- changes: { tier, source: TierAssignmentSources.manual },
82
+ changes: { tier, source },
80
83
  },
81
84
  event.user,
82
85
  tdb,
@@ -91,7 +94,7 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
91
94
  {
92
95
  id: tierAssignmentAggregateId(tenantId),
93
96
  tier,
94
- source: TierAssignmentSources.manual,
97
+ source,
95
98
  tenantId,
96
99
  },
97
100
  event.user,
@@ -18,7 +18,9 @@ export {
18
18
  type TierMap,
19
19
  } from "./compose-app.js";
20
20
  export {
21
+ isTierAssignmentSource,
21
22
  TIER_ENGINE_FEATURE,
23
+ type TierAssignmentSource,
22
24
  TierAssignmentSources,
23
25
  TierEngineHandlers,
24
26
  TierEngineQueries,
@@ -6,6 +6,7 @@ export const USER_FEATURE = "user" as const;
6
6
  * platform operator. Membership roles (TenantAdmin/Admin/User/…) live on
7
7
  * tenant-membership, not here. */
8
8
  export const GLOBAL_ROLE_OPTIONS = ["SystemAdmin"] as const;
9
+ export type GlobalRole = (typeof GLOBAL_ROLE_OPTIONS)[number];
9
10
 
10
11
  // Qualified write handler names. Handlers carry the "user:" entity prefix so
11
12
  // field-level access rules (passwordHash system-only etc.) are wired up.
@@ -7,7 +7,7 @@ import {
7
7
  import { createSystemUser, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
8
8
  import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
9
9
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
10
- import { GLOBAL_ROLE_OPTIONS } from "./constants.js";
10
+ import { GLOBAL_ROLE_OPTIONS, type GlobalRole } from "./constants.js";
11
11
  import { userEntity, userTable } from "./schema/user.js";
12
12
 
13
13
  const userExecutor = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
@@ -18,8 +18,6 @@ type UserRolesRow = {
18
18
  readonly roles: readonly string[] | string | null;
19
19
  };
20
20
 
21
- type GlobalRole = (typeof GLOBAL_ROLE_OPTIONS)[number];
22
-
23
21
  function isGlobalRole(role: string): role is GlobalRole {
24
22
  return GLOBAL_ROLE_OPTIONS.some((option) => option === role);
25
23
  }
@@ -34,7 +32,7 @@ export function parseInvitedGlobalRoles(raw: unknown): readonly GlobalRole[] {
34
32
  // already holds. Users live on SYSTEM_TENANT_ID (tenancy "global").
35
33
  export async function grantInvitedGlobalRoles(
36
34
  db: DbRunner,
37
- options: { readonly userId: string; readonly globalRoles: readonly string[] },
35
+ options: { readonly userId: string; readonly globalRoles: readonly GlobalRole[] },
38
36
  ) {
39
37
  const existing = await fetchOne<UserRolesRow>(db, userTable, { id: options.userId });
40
38
  if (!existing) return writeFailure(new NotFoundError("user", options.userId));
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "improvement",
5
+ "title": "GDPR mails carry designer labels",
6
+ "detail": "Feature and notify labels for export-ready, export-failed, deletion-requested and deletion-executed name the GDPR mails in the document designer."
7
+ },
2
8
  {
3
9
  "version": "0.364.0",
4
10
  "type": "fix",
@@ -36,7 +36,6 @@ export const GDPR_MAIL_EN: Readonly<Record<string, string>> = {
36
36
  "gdpr.mail.deletionExecuted.subject": "{app} — Your account has been deleted",
37
37
  "gdpr.mail.deletionExecuted.intro":
38
38
  "your {app} account and the associated personal data were deleted on {when}. This action is permanent.",
39
- "gdpr.mail.fallbackUrl": "If the button doesn't work, copy this link into your browser:",
40
39
  };
41
40
 
42
41
  registerMailTranslations("en", GDPR_MAIL_EN);
@@ -4,6 +4,11 @@
4
4
  type LocalizedString = { readonly en: string };
5
5
 
6
6
  export const USER_DATA_RIGHTS_I18N: Readonly<Record<string, LocalizedString>> = {
7
+ "user-data-rights:feature.label": { en: "Privacy" },
8
+ "user-data-rights:notify.export-ready.label": { en: "Data export ready" },
9
+ "user-data-rights:notify.export-failed.label": { en: "Data export failed" },
10
+ "user-data-rights:notify.deletion-requested.label": { en: "Deletion requested" },
11
+ "user-data-rights:notify.deletion-executed.label": { en: "Deletion completed" },
7
12
  "user-data-rights:nav.exportJobs": { en: "GDPR exports" },
8
13
  "screen:export-job-list.title": { en: "GDPR exports" },
9
14
  "screen:export-job-detail.title": { en: "Export job" },
@@ -1,6 +1,10 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
- import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
3
+ import {
4
+ createEventStoreExecutor,
5
+ createTenantDb,
6
+ type WritePurpose,
7
+ } from "@cosmicdrift/kumiko-framework/db";
4
8
  import { createSystemUser, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
5
9
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
6
10
  import { USER_STATUS, userEntity, userTable } from "../../user/index.js";
@@ -27,6 +31,9 @@ export type UpdateUserLifecycleOptions = {
27
31
  // executor's `expect:`. Omitted: unconditional overwrite (skipOptimisticLock).
28
32
  // Scalars only, see the executor's `expect:` doc.
29
33
  readonly expect?: Readonly<Record<string, string | number | boolean | null>>;
34
+ // Erasure flows run after the subject key is erased; the write then stores the
35
+ // erased sentinel instead of failing on re-encrypt.
36
+ readonly writePurpose?: WritePurpose;
30
37
  };
31
38
 
32
39
  // `conn` is ctx.db.unsafeRaw() (regular handlers) or the open tx
@@ -59,7 +66,11 @@ export async function updateUserLifecycle(
59
66
  { id: userId, changes },
60
67
  createSystemUser(SYSTEM_TENANT_ID),
61
68
  tenantDb,
62
- { skipOptimisticLock: true, ...(options?.expect && { expect: options.expect }) },
69
+ {
70
+ skipOptimisticLock: true,
71
+ ...(options?.expect && { expect: options.expect }),
72
+ ...(options?.writePurpose && { writePurpose: options.writePurpose }),
73
+ },
63
74
  );
64
75
 
65
76
  if (result.isSuccess) return { applied: true };
@@ -47,6 +47,7 @@ import {
47
47
  entityEventName,
48
48
  executeRawQuery,
49
49
  nullBlindIndexesForSubject,
50
+ WritePurposes,
50
51
  } from "@cosmicdrift/kumiko-framework/db";
51
52
  import {
52
53
  type EscapeHatchAuditSink,
@@ -531,7 +532,12 @@ async function processUser(args: {
531
532
  // geworfen hat, kommen wir hier nicht an — die Tx rollback'd
532
533
  // alles, der User bleibt im DeletionRequested-Status, naechster
533
534
  // Run retried.
534
- await updateUserLifecycle(tx, userId, { status: USER_STATUS.Deleted });
535
+ await updateUserLifecycle(
536
+ tx,
537
+ userId,
538
+ { status: USER_STATUS.Deleted },
539
+ { writePurpose: WritePurposes.erasure },
540
+ );
535
541
  txSucceeded = true;
536
542
  });
537
543
  } catch (e) {
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "improvement",
5
+ "title": "Waitlist mails carry designer labels",
6
+ "detail": "Feature and notify labels for confirmation and admin-notice name the waitlist mails in the document designer."
7
+ },
8
+ {
9
+ "version": "0.365.0",
10
+ "type": "fix",
11
+ "title": "Waitlist invite no longer double-invites, uses the entry locale and refuses members",
12
+ "detail": "Two parallel invite calls for one entry now queue on a per-entry advisory lock, so exactly one tenant and one invitation are created. The invitation mail is rendered in the entry's locale instead of the caller's. Inviting an email that already is a member of the target tenant fails with 422 waitlist_not_invitable, as reject already did."
13
+ },
14
+ {
15
+ "version": "0.365.0",
16
+ "type": "fix",
17
+ "title": "Waitlist submit honors emailRateLimits: [], resolves notifyRecipient before the advisory lock and checks only open entries",
18
+ "detail": "createWaitlistFeature({ emailRateLimits: [] }) no longer fails boot validation; the per-email bucket is dropped as documented. notifyRecipient runs before the per-email advisory lock and the entry insert, so a failing resolver can no longer abort the submit transaction or hold the lock; a throwing resolver only skips the admin notice. The duplicate check queries status pending or invited with limit 1 instead of scanning the first 50 rows of the address. The feature description now states that the response body is identical for known and unknown addresses while response timing can differ."
19
+ },
2
20
  {
3
21
  "version": "0.363.0",
4
22
  "type": "breaking",
@@ -26,7 +26,7 @@ import { waitlistEntryDeleteHook, waitlistEntryExportHook } from "./user-data-ho
26
26
  export function createWaitlistFeature(opts: WaitlistOptions = {}): FeatureDefinition {
27
27
  return defineFeature(WAITLIST_FEATURE, (r) => {
28
28
  r.describe(
29
- "Public waitlist: an anonymous, rate-limited `submit` handler collects name, email and optional company, portfolio and message (a honeypot field and per-email dedupe keep bots and repeat submits out, and the response never reveals whether an address was already listed), and mails the submitter a confirmation. SystemAdmins review entries in a list screen and invite or reject them; an invite creates the entrant's own tenant (or joins one shared tenant) and mails an accept link with the configured membership role, never a global role. Entries are platform-wide, their personal data is crypto-shredding-ready, and the EXT_USER_DATA hooks export and erase entries matching a user's email.",
29
+ "Public waitlist: an anonymous, rate-limited `submit` handler collects name, email and optional company, portfolio and message (a honeypot field and per-email dedupe keep bots and repeat submits out, and the response body is identical whether or not an address was already listed), and mails the submitter a confirmation. SystemAdmins review entries in a list screen and invite or reject them; an invite creates the entrant's own tenant (or joins one shared tenant) and mails an accept link with the configured membership role, never a global role. Entries are platform-wide, their personal data is crypto-shredding-ready, and the EXT_USER_DATA hooks export and erase entries matching a user's email.",
30
30
  );
31
31
  r.uiHints({ displayLabel: "Waitlist", category: "data", recommended: false });
32
32
  r.systemScope();
@@ -1,4 +1,5 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { acquireNamespacedAdvisoryLock } from "@cosmicdrift/kumiko-framework/db";
2
3
  import {
3
4
  createSystemUser,
4
5
  defineWriteHandler,
@@ -18,7 +19,13 @@ import { AuthErrors, AuthHandlers } from "../../auth-email-password/index.js";
18
19
  import { TenantHandlers, tenantTable } from "../../tenant/index.js";
19
20
  import { DEFAULT_OWN_TENANT_INVITE_ROLE, WAITLIST_STATUS, WaitlistErrors } from "../constants.js";
20
21
  import { isOpenWaitlistStatus } from "../entity.js";
21
- import { adminAccess, platformActor, waitlistDb, waitlistExecutor } from "../lib.js";
22
+ import {
23
+ adminAccess,
24
+ isTenantMember,
25
+ platformActor,
26
+ waitlistDb,
27
+ waitlistExecutor,
28
+ } from "../lib.js";
22
29
  import type { WaitlistInviteOptions } from "../options.js";
23
30
  import { WaitlistIdSchema } from "../payloads.js";
24
31
 
@@ -30,6 +37,11 @@ export type WaitlistInviteData = {
30
37
 
31
38
  type InviteTarget = { readonly tenantId: TenantId; readonly role: string };
32
39
 
40
+ // pg_advisory_xact_lock namespace (int4): 'wlin' as ASCII, disjoint from the submit lock.
41
+ const INVITE_LOCK_NAMESPACE = 0x776c696e;
42
+ const INVITE_LOCK_REASON =
43
+ "takes the per-entry advisory lock so parallel invites cannot both create a tenant and invitation; TenantDb has no lock API";
44
+
33
45
  const DEFAULT_INVITE: WaitlistInviteOptions = {
34
46
  mode: "own-tenant",
35
47
  role: DEFAULT_OWN_TENANT_INVITE_ROLE,
@@ -48,10 +60,11 @@ type EntryRow = {
48
60
  readonly name: string;
49
61
  readonly company: string | null;
50
62
  readonly linkedTenantId: string | null;
63
+ readonly locale: string | undefined;
51
64
  };
52
65
 
53
66
  function readEntryRow(row: Record<string, unknown>): EntryRow {
54
- const { id, version, email, name, company, linkedTenantId } = row;
67
+ const { id, version, email, name, company, linkedTenantId, locale } = row;
55
68
  if (
56
69
  typeof id !== "string" ||
57
70
  typeof version !== "number" ||
@@ -68,6 +81,7 @@ function readEntryRow(row: Record<string, unknown>): EntryRow {
68
81
  name,
69
82
  company: typeof company === "string" && company !== "" ? company : null,
70
83
  linkedTenantId: typeof linkedTenantId === "string" ? linkedTenantId : null,
84
+ locale: typeof locale === "string" && locale !== "" ? locale : undefined,
71
85
  };
72
86
  }
73
87
 
@@ -122,8 +136,17 @@ export function createInviteHandler(invite: WaitlistInviteOptions = DEFAULT_INVI
122
136
  "Invites a pending or already-invited waitlist entry: creates its tenant (or uses the configured shared tenant), mails the entrant a fresh accept link with the configured role and marks the entry invited; rejected entries cannot be invited. The invitee never receives a global role.",
123
137
  // Sends an invite link by email, which cannot be recalled.
124
138
  agent: { risk: "high" },
139
+ escapeHatch: { grants: ["unsafeRaw"], reason: INVITE_LOCK_REASON },
125
140
  handler: async (event, ctx) => {
126
141
  const db = waitlistDb(ctx);
142
+ if (!ctx.systemDb) {
143
+ throw new InternalError({ message: "waitlist:invite requires ctx.systemDb" });
144
+ }
145
+ await acquireNamespacedAdvisoryLock(
146
+ ctx.systemDb.unsafeRaw(INVITE_LOCK_REASON),
147
+ INVITE_LOCK_NAMESPACE,
148
+ event.payload.id,
149
+ );
127
150
  const row = await waitlistExecutor.detail({ id: event.payload.id }, platformActor(), db);
128
151
  if (!row) return writeFailure(new NotFoundError("waitlistEntry", event.payload.id));
129
152
  const entry = readEntryRow(row);
@@ -134,13 +157,22 @@ export function createInviteHandler(invite: WaitlistInviteOptions = DEFAULT_INVI
134
157
  const target = await resolveInviteTarget(invite, entry, ctx);
135
158
  if ("isSuccess" in target) return target;
136
159
 
160
+ if (await isTenantMember(db, entry.email, target.tenantId)) {
161
+ return writeFailure(new UnprocessableError(WaitlistErrors.notInvitable));
162
+ }
163
+
137
164
  // inviteCreate is admin-only (no system role); the system variant with an
138
165
  // empty globalRoles list is the only in-process path, and a waitlist
139
166
  // invite must never grant a global role.
140
167
  const invited = await ctx.writeAs(
141
168
  createSystemUser(target.tenantId),
142
169
  AuthHandlers.systemInviteCreate,
143
- { email: entry.email.toLowerCase(), role: target.role, globalRoles: [] },
170
+ {
171
+ email: entry.email.toLowerCase(),
172
+ role: target.role,
173
+ globalRoles: [],
174
+ ...(entry.locale !== undefined && { locale: entry.locale }),
175
+ },
144
176
  );
145
177
  if (!invited.isSuccess) {
146
178
  // Re-inviting a person who already accepted would reset their live invitation.
@@ -1,11 +1,9 @@
1
- import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
3
2
  import {
4
3
  createSystemUser,
5
4
  defineWriteHandler,
6
5
  type HandlerContext,
7
6
  parseTenantId,
8
- type TenantId,
9
7
  } from "@cosmicdrift/kumiko-framework/engine";
10
8
  import {
11
9
  InternalError,
@@ -18,13 +16,12 @@ import {
18
16
  cancelPendingInvitation,
19
17
  INVITATION_STATUS,
20
18
  tenantInvitationsTable,
21
- tenantMembershipsTable,
22
19
  } from "../../tenant/index.js";
23
- import { userTable } from "../../user/index.js";
24
20
  import { WAITLIST_STATUS, WaitlistErrors } from "../constants.js";
25
21
  import { isOpenWaitlistStatus } from "../entity.js";
26
22
  import {
27
23
  adminAccess,
24
+ isTenantMember,
28
25
  normalizeEmail,
29
26
  platformActor,
30
27
  waitlistDb,
@@ -34,18 +31,6 @@ import { WaitlistIdSchema } from "../payloads.js";
34
31
 
35
32
  export type WaitlistRejectData = { readonly kind: "rejected"; readonly id: string };
36
33
 
37
- // An admin re-invite can flip an accepted invitation back to pending, so the
38
- // invitation status alone cannot prove the person holds no access.
39
- async function isTenantMember(db: TenantDb, email: string, tenantId: TenantId): Promise<boolean> {
40
- const user = await fetchOne<{ id: string }>(db, userTable, {
41
- email: normalizeEmail(email),
42
- isDeleted: false,
43
- });
44
- if (!user) return false;
45
- const membership = await fetchOne(db, tenantMembershipsTable, { userId: user.id, tenantId });
46
- return membership !== undefined && membership !== null;
47
- }
48
-
49
34
  // An invited entry already has a live invitation link; rejecting must kill it,
50
35
  // otherwise the rejected person can still sign up. Once accepted they hold
51
36
  // access, which only tenant member removal may revoke.