@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.
- package/LICENSE +1 -1
- package/package.json +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/audit/changes.json +12 -0
- package/packages/bundled-features/src/audit/feature.ts +1 -1
- package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +63 -11
- package/packages/bundled-features/src/auth-email-password/bootstrap.ts +1 -3
- package/packages/bundled-features/src/auth-email-password/changes.json +18 -0
- package/packages/bundled-features/src/auth-email-password/feature.ts +4 -7
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -3
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +6 -4
- package/packages/bundled-features/src/auth-email-password/handlers/switch-tenant-mfa-gate.write.ts +2 -2
- package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +7 -3
- package/packages/bundled-features/src/auth-email-password/mail-i18n.ts +10 -0
- package/packages/bundled-features/src/billing-foundation/changes.json +12 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +8 -1
- package/packages/bundled-features/src/billing-foundation/i18n.ts +6 -0
- package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +10 -2
- package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +20 -8
- package/packages/bundled-features/src/cap-counter/cap-reservation.ts +7 -2
- package/packages/bundled-features/src/cap-counter/changes.json +13 -0
- package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +2 -0
- package/packages/bundled-features/src/cap-overview/changes.json +6 -0
- package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
- package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +2 -1
- package/packages/bundled-features/src/channel-discord/discord-channel.ts +4 -2
- package/packages/bundled-features/src/channel-email/changes.json +18 -0
- package/packages/bundled-features/src/channel-email/email-channel.ts +30 -20
- package/packages/bundled-features/src/channel-email/smtp-transport.ts +7 -1
- package/packages/bundled-features/src/channel-slack/slack-channel.ts +4 -2
- package/packages/bundled-features/src/channel-teams/teams-channel.ts +4 -2
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +7 -1
- package/packages/bundled-features/src/custom-fields/db/queries/retention.ts +2 -2
- package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +4 -5
- package/packages/bundled-features/src/delivery/attachment-ref.ts +18 -1
- package/packages/bundled-features/src/delivery/changes.json +12 -0
- package/packages/bundled-features/src/delivery/delivery-service.ts +93 -92
- package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
- package/packages/bundled-features/src/feature-toggles/changes.json +7 -0
- package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +26 -12
- package/packages/bundled-features/src/files-tenant-data/index.ts +3 -1
- package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +12 -3
- package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
- package/packages/bundled-features/src/jobs/changes.json +12 -0
- package/packages/bundled-features/src/jobs/job-run-logger.ts +5 -2
- package/packages/bundled-features/src/metrics/changes.json +6 -0
- package/packages/bundled-features/src/metrics/filters.ts +28 -12
- package/packages/bundled-features/src/personal-access-tokens/changes.json +6 -0
- package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +7 -8
- package/packages/bundled-features/src/secrets/changes.json +6 -0
- package/packages/bundled-features/src/secrets/handlers/delete.write.ts +3 -1
- package/packages/bundled-features/src/secrets/write-gate.ts +8 -2
- package/packages/bundled-features/src/shared/index.ts +8 -0
- package/packages/bundled-features/src/shared/resource-image-formats.ts +31 -0
- package/packages/bundled-features/src/shared/sharp-input-limits.ts +7 -8
- package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +2 -2
- package/packages/bundled-features/src/template-resolver/changes.json +81 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +78 -12
- package/packages/bundled-features/src/template-resolver/compile-steps-block.ts +68 -0
- package/packages/bundled-features/src/template-resolver/constants.ts +11 -1
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -0
- package/packages/bundled-features/src/template-resolver/document-schema.ts +7 -1
- package/packages/bundled-features/src/template-resolver/document-steps.ts +114 -0
- package/packages/bundled-features/src/template-resolver/feature.ts +12 -0
- package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +81 -39
- package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +4 -3
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +111 -61
- package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +1 -0
- package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +25 -15
- package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +12 -1
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +12 -2
- package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +48 -9
- package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +4 -1
- package/packages/bundled-features/src/template-resolver/index.ts +6 -1
- package/packages/bundled-features/src/template-resolver/layout-appearance.ts +25 -9
- package/packages/bundled-features/src/template-resolver/mail-constants.ts +4 -0
- package/packages/bundled-features/src/template-resolver/resource-image.ts +9 -16
- package/packages/bundled-features/src/template-resolver/seeding.ts +2 -0
- package/packages/bundled-features/src/template-resolver/store-template-resource.ts +390 -124
- package/packages/bundled-features/src/template-resolver/template-bundle.ts +4 -0
- package/packages/bundled-features/src/template-resolver/template-draft.ts +1 -1
- package/packages/bundled-features/src/template-resolver/template-meta.ts +8 -0
- package/packages/bundled-features/src/template-resolver/template-preview.ts +1 -1
- package/packages/bundled-features/src/template-resolver/template-resources.ts +2 -43
- package/packages/bundled-features/src/template-resolver/template-validation.ts +15 -9
- package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +34 -0
- package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +3 -2
- package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +56 -11
- package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +6 -3
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +7 -0
- package/packages/bundled-features/src/template-resolver/web/index.ts +1 -0
- package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +14 -5
- package/packages/bundled-features/src/tenant/changes.json +6 -0
- package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +3 -8
- package/packages/bundled-features/src/tenant/seeding.ts +33 -27
- package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +14 -3
- package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +9 -1
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +3 -2
- package/packages/bundled-features/src/tier-engine/changes.json +12 -0
- package/packages/bundled-features/src/tier-engine/constants.ts +9 -0
- package/packages/bundled-features/src/tier-engine/entity.ts +9 -4
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +8 -5
- package/packages/bundled-features/src/tier-engine/index.ts +2 -0
- package/packages/bundled-features/src/user/constants.ts +1 -0
- package/packages/bundled-features/src/user/invited-global-roles.ts +2 -4
- package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
- package/packages/bundled-features/src/user-data-rights/email-templates.ts +0 -1
- package/packages/bundled-features/src/user-data-rights/i18n.ts +5 -0
- package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +13 -2
- package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +7 -1
- package/packages/bundled-features/src/waitlist/changes.json +18 -0
- package/packages/bundled-features/src/waitlist/feature.ts +1 -1
- package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +35 -3
- package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +2 -17
- package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +33 -9
- package/packages/bundled-features/src/waitlist/i18n.ts +9 -0
- package/packages/bundled-features/src/waitlist/lib.ts +20 -0
- package/packages/bundled-features/src/workflow-runner/feature.ts +1 -1
- package/samples/apps/ui-walkthrough/src/run-config.ts +14 -1
- package/samples/apps/use-all-bundled/README.md +3 -13
- package/samples/apps/use-all-bundled/feature-manifest.json +3 -3
- package/samples/apps/use-all-bundled/scripts/check-coverage.ts +34 -118
- package/samples/apps/use-all-bundled/src/app/notes-feature.ts +1 -1
- package/samples/apps/use-all-bundled/src/run-config.ts +0 -3
- package/samples/recipes/auth-mfa/README.md +2 -1
- package/samples/recipes/auth-mfa/src/feature.ts +1 -0
- package/samples/recipes/custom-handlers/README.md +4 -3
- package/samples/recipes/custom-handlers/src/feature.ts +21 -12
- package/samples/recipes/dashboard-stats/README.md +38 -0
- package/samples/recipes/dashboard-stats/package.json +8 -0
- package/samples/recipes/dashboard-stats/src/feature.ts +104 -0
- package/samples/recipes/legal-pages/README.md +2 -2
- package/samples/recipes/managed-pages/README.md +2 -2
- package/samples/recipes/pdf-audit-export/README.md +2 -1
- package/samples/recipes/pdf-audit-export/package.json +1 -1
- package/samples/recipes/pdf-audit-export/src/feature.ts +68 -48
- package/samples/recipes/workflow-engine/src/db/queries/suspended-runs.ts +2 -2
- package/samples/apps/use-all-bundled/schema/generate.ts +0 -617
- package/samples/apps/use-all-bundled/schema/migrations/meta/0000_snapshot.json +0 -3055
- package/samples/apps/use-all-bundled/schema/migrations/meta/_journal.json +0 -13
- package/samples/apps/use-all-bundled/schema/schema.generated.ts +0 -94
- 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
|
-
|
|
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
|
|
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(
|
|
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
|
-
[
|
|
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
|
-
[
|
|
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
|
|
92
|
-
//
|
|
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 {
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
97
|
+
source,
|
|
95
98
|
tenantId,
|
|
96
99
|
},
|
|
97
100
|
event.user,
|
|
@@ -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
|
|
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 {
|
|
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
|
-
{
|
|
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(
|
|
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
|
|
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 {
|
|
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
|
-
{
|
|
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 {
|
|
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.
|