@cosmicdrift/kumiko-bundled-features 0.288.0 → 0.290.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/package.json +10 -9
- package/src/auth-email-password/__tests__/query-as-member.integration.test.ts +125 -5
- package/src/auth-email-password/handlers/self-registration-status.query.ts +1 -0
- package/src/auth-mfa/__tests__/verify.integration.test.ts +6 -2
- package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +2 -0
- package/src/billing-foundation/__tests__/subscription-tier-sync.integration.test.ts +2 -0
- package/src/compliance-profiles/handlers/sub-processors.query.ts +1 -0
- package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +8 -1
- package/src/files-tenant-data/__tests__/hooks.integration.test.ts +88 -1
- package/src/files-tenant-data/hooks.ts +101 -3
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +2 -0
- package/src/inbound-mail-foundation/__tests__/retention.integration.test.ts +2 -0
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +2 -0
- package/src/managed-pages/handlers/branding.query.ts +1 -0
- package/src/managed-pages/handlers/by-slug.query.ts +1 -0
- package/src/managed-pages/handlers/by-tenant-published.query.ts +1 -0
- package/src/seo/handlers/seo-config.query.ts +1 -0
- package/src/subscription-mollie/__tests__/mollie-foundation.integration.test.ts +3 -0
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +4 -0
- package/src/template-resolver/handlers/by-slug.query.ts +1 -0
- package/src/template-resolver/handlers/by-tenant.query.ts +1 -0
- package/src/tenant-handover/__tests__/claim.integration.test.ts +337 -0
- package/src/tenant-handover/changes.json +8 -0
- package/src/tenant-handover/events.ts +26 -0
- package/src/tenant-handover/feature.ts +35 -0
- package/src/tenant-handover/grant.ts +43 -0
- package/src/tenant-handover/handlers/claim.write.ts +143 -0
- package/src/tenant-handover/index.ts +9 -0
- package/src/tenant-handover/move-entity-graph.ts +228 -0
- package/src/tenant-handover/transfer-graph.ts +44 -0
- package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +108 -0
- package/src/tenant-lifecycle/stages.ts +2 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +39 -4
- package/src/user-data-rights/__tests__/deletion-token-compat.test.ts +1 -0
- package/src/user-data-rights/changes.json +6 -0
- package/src/user-data-rights/deletion-token.ts +9 -11
- package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +45 -35
- package/src/user-data-rights/handlers/deletion-grace-period.ts +31 -9
- package/src/user-data-rights/lib/update-user-lifecycle.ts +44 -6
|
@@ -40,17 +40,23 @@ async function readPendingDeletionRequestId(
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
43
|
+
// Anonymous apex flow step 2: verify-link target. Verifies the HMAC token,
|
|
44
|
+
// extracts the userId, and flips the grace period through the shared logic —
|
|
45
|
+
// in ONE atomic step with the anchor spend (#3024): commitDeletion carries
|
|
46
|
+
// the actual grace-period transition, so a crash after the spend can no
|
|
47
|
+
// longer lose the write step.
|
|
46
48
|
//
|
|
47
|
-
// Replay
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
49
|
+
// Replay protection (#354/1): the row's requestId is part of the verify key.
|
|
50
|
+
// We read it via the (unverified, lookup-only) userId from the token, reject
|
|
51
|
+
// a missing entry, and verify the token against the CURRENT requestId. After
|
|
52
|
+
// a cancel-deletion (status → Active, pendingDeletionRequestId → null), a
|
|
53
|
+
// replayed token fails against the nulled/renewed requestId — no re-arm.
|
|
54
|
+
//
|
|
55
|
+
// Concurrency (#3024): two simultaneous confirms of the same token both read
|
|
56
|
+
// the same requestId and both verify the HMAC — commitDeletion spends the
|
|
57
|
+
// anchor AND writes the transition atomically (expect: status===Active &&
|
|
58
|
+
// pendingDeletionRequestId===requestId), so exactly one wins. The loser gets
|
|
59
|
+
// the same generic 422 as an invalid token — no status leak (#354/2).
|
|
54
60
|
export function createConfirmDeletionByTokenHandler(opts: ConfirmDeletionByTokenOptions = {}) {
|
|
55
61
|
return defineWriteHandler({
|
|
56
62
|
name: "confirm-deletion-by-token",
|
|
@@ -66,16 +72,6 @@ export function createConfirmDeletionByTokenHandler(opts: ConfirmDeletionByToken
|
|
|
66
72
|
agent: { expose: false },
|
|
67
73
|
rateLimit: { per: "ip", limit: 10, windowSeconds: 60 },
|
|
68
74
|
handler: async (event, ctx) => {
|
|
69
|
-
// The row's requestId is part of the verify key, so a token from a
|
|
70
|
-
// cancelled or superseded cycle fails. Every error path ends in the same
|
|
71
|
-
// generic 422.
|
|
72
|
-
const verified = await redeemDeletionToken({
|
|
73
|
-
token: event.payload.token,
|
|
74
|
-
secret: opts.deletionTokenSecret,
|
|
75
|
-
loadPendingRequestId: (userId) => readPendingDeletionRequestId(ctx, userId),
|
|
76
|
-
});
|
|
77
|
-
if (!verified.ok) return writeFailure(invalidToken());
|
|
78
|
-
|
|
79
75
|
// @cast-boundary engine-payload — queryAs returns unknown, narrowed to
|
|
80
76
|
// the compliance-profile shape.
|
|
81
77
|
const profile = (await ctx.queryAs(
|
|
@@ -83,26 +79,40 @@ export function createConfirmDeletionByTokenHandler(opts: ConfirmDeletionByToken
|
|
|
83
79
|
"compliance-profiles:query:for-tenant",
|
|
84
80
|
{},
|
|
85
81
|
)) as { profile: { userRights: { gracePeriod: DurationSpec } } };
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
82
|
+
|
|
83
|
+
let gracePeriodEndIso: string | undefined;
|
|
84
|
+
|
|
85
|
+
// The row's requestId is part of the verify key, so a token from a
|
|
86
|
+
// cancelled or superseded cycle fails. Every error path ends in the same
|
|
87
|
+
// generic 422 — commitDeletion folding the grace-period write into the
|
|
88
|
+
// anchor-spend means there's no separate res.ok branch left to leak a
|
|
89
|
+
// concrete status through.
|
|
90
|
+
const verified = await redeemDeletionToken({
|
|
91
|
+
token: event.payload.token,
|
|
92
|
+
secret: opts.deletionTokenSecret,
|
|
93
|
+
loadPendingRequestId: (userId) => readPendingDeletionRequestId(ctx, userId),
|
|
94
|
+
commitDeletion: async (userId, requestId) => {
|
|
95
|
+
const res = await startDeletionGracePeriod(
|
|
96
|
+
ctx,
|
|
97
|
+
userId,
|
|
98
|
+
profile.profile.userRights.gracePeriod,
|
|
99
|
+
ctx.db.unsafeRaw(
|
|
100
|
+
"appends the user lifecycle event on the SYSTEM_TENANT_ID user stream",
|
|
101
|
+
),
|
|
102
|
+
{ pendingDeletionRequestId: requestId },
|
|
103
|
+
);
|
|
104
|
+
if (!res.ok) return false;
|
|
105
|
+
gracePeriodEndIso = res.gracePeriodEnd.toString();
|
|
106
|
+
return true;
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
if (!verified.ok || gracePeriodEndIso === undefined) return writeFailure(invalidToken());
|
|
100
110
|
|
|
101
111
|
return {
|
|
102
112
|
isSuccess: true as const,
|
|
103
113
|
data: {
|
|
104
114
|
status: USER_STATUS.DeletionRequested,
|
|
105
|
-
gracePeriodEnd:
|
|
115
|
+
gracePeriodEnd: gracePeriodEndIso,
|
|
106
116
|
},
|
|
107
117
|
};
|
|
108
118
|
},
|
|
@@ -25,6 +25,25 @@ export type StartGracePeriodResult =
|
|
|
25
25
|
//
|
|
26
26
|
// The user row is tenant-agnostic (account-wide deletion), so it is read via
|
|
27
27
|
// ctx.db.global(userTable); only the grace period duration is tenant-configured.
|
|
28
|
+
// That read only supplies email/locale for the caller's notification — it is
|
|
29
|
+
// NOT the transition's guard. The guard is `expect: { status: Active }` on
|
|
30
|
+
// the lifecycle write itself (#3024): the executor re-checks it against a
|
|
31
|
+
// fresh row right before writing, so a concurrent caller that already moved
|
|
32
|
+
// the user off Active is rejected even though this read saw Active.
|
|
33
|
+
//
|
|
34
|
+
// `additionalExpect` lets a caller fold its own precondition into the SAME
|
|
35
|
+
// atomic write — the confirm-by-token path uses it to spend a row-bound
|
|
36
|
+
// grant's anchor (pendingDeletionRequestId) in the same statement that flips
|
|
37
|
+
// status, per shared/row-bound-grant.ts's "write in the same statement that
|
|
38
|
+
// spends the anchor" contract. The write also always clears
|
|
39
|
+
// pendingDeletionRequestId: a request-by-email token is meant for one
|
|
40
|
+
// confirm only, and leaving the id in place would let it re-arm later if
|
|
41
|
+
// something ever moves the user back to Active without going through
|
|
42
|
+
// cancel-deletion's explicit null (restrict/lift-restriction can't today,
|
|
43
|
+
// since status is a single field and Restricted/DeletionRequested are
|
|
44
|
+
// mutually exclusive — but nulling it here doesn't depend on that staying
|
|
45
|
+
// true). No-op for the authenticated request-deletion path, which never set
|
|
46
|
+
// a pendingDeletionRequestId to begin with.
|
|
28
47
|
//
|
|
29
48
|
// `gracePeriod` and `lifecycleRunner` are resolved by the caller: both
|
|
30
49
|
// require an escalation (reading the tenant compliance profile, appending to
|
|
@@ -35,6 +54,7 @@ export async function startDeletionGracePeriod(
|
|
|
35
54
|
userId: string,
|
|
36
55
|
gracePeriod: DurationSpec,
|
|
37
56
|
lifecycleRunner: DbRunner,
|
|
57
|
+
additionalExpect?: Readonly<Record<string, string | number | boolean | null>>,
|
|
38
58
|
): Promise<StartGracePeriodResult> {
|
|
39
59
|
const userRow = await ctx.db
|
|
40
60
|
.global(userTable)
|
|
@@ -47,7 +67,17 @@ export async function startDeletionGracePeriod(
|
|
|
47
67
|
}),
|
|
48
68
|
};
|
|
49
69
|
}
|
|
50
|
-
|
|
70
|
+
|
|
71
|
+
const T = getTemporal();
|
|
72
|
+
const gracePeriodEnd = addDurationSpec(T.Now.instant(), gracePeriod);
|
|
73
|
+
|
|
74
|
+
const { applied } = await updateUserLifecycle(
|
|
75
|
+
lifecycleRunner,
|
|
76
|
+
userId,
|
|
77
|
+
{ status: USER_STATUS.DeletionRequested, gracePeriodEnd, pendingDeletionRequestId: null },
|
|
78
|
+
{ expect: { status: USER_STATUS.Active, ...additionalExpect } },
|
|
79
|
+
);
|
|
80
|
+
if (!applied) {
|
|
51
81
|
return {
|
|
52
82
|
ok: false,
|
|
53
83
|
error: new UnprocessableError("user_not_in_active_state", {
|
|
@@ -56,14 +86,6 @@ export async function startDeletionGracePeriod(
|
|
|
56
86
|
};
|
|
57
87
|
}
|
|
58
88
|
|
|
59
|
-
const T = getTemporal();
|
|
60
|
-
const gracePeriodEnd = addDurationSpec(T.Now.instant(), gracePeriod);
|
|
61
|
-
|
|
62
|
-
await updateUserLifecycle(lifecycleRunner, userId, {
|
|
63
|
-
status: USER_STATUS.DeletionRequested,
|
|
64
|
-
gracePeriodEnd,
|
|
65
|
-
});
|
|
66
|
-
|
|
67
89
|
return {
|
|
68
90
|
ok: true,
|
|
69
91
|
gracePeriodEnd,
|
|
@@ -21,30 +21,68 @@ import { USER_STATUS, userEntity, userTable } from "../../user";
|
|
|
21
21
|
// kann abweichen).
|
|
22
22
|
const userExecutor = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
|
|
23
23
|
|
|
24
|
+
export type UpdateUserLifecycleOptions = {
|
|
25
|
+
// Declarative "genau einmal"-precondition (#3024): the update only applies
|
|
26
|
+
// if the row still holds these field values at write time. Forwarded
|
|
27
|
+
// as-is to the executor's `expect:` — see event-store-executor-write.ts
|
|
28
|
+
// for the atomicity story. Omitted: behaves exactly as before (unconditional
|
|
29
|
+
// overwrite, skipOptimisticLock). Scalars only — see the executor's
|
|
30
|
+
// `expect:` doc for why (`!==` comparison, no object/Date/Instant values).
|
|
31
|
+
readonly expect?: Readonly<Record<string, string | number | boolean | null>>;
|
|
32
|
+
};
|
|
33
|
+
|
|
24
34
|
// `conn` is ctx.db.unsafeRaw(reason) (regular handlers) or the open tx
|
|
25
35
|
// (forget-cleanup sub-tx) — keeps the event append atomic with the write.
|
|
36
|
+
//
|
|
37
|
+
// Returns `{ applied: false }` instead of throwing only when `options.expect`
|
|
38
|
+
// was set AND the executor rejected the write because the precondition no
|
|
39
|
+
// longer held (precondition_failed) or a concurrent writer won the race on
|
|
40
|
+
// the same stream version (version_conflict) — both mean "someone else's
|
|
41
|
+
// transition already landed", the expected outcome for a caller that opted
|
|
42
|
+
// into `expect`. Any other failure, or a failure with no `expect` set (every
|
|
43
|
+
// pre-#3024 caller), still throws — those callers never checked a return
|
|
44
|
+
// value, so silently dropping their write would be silent data loss.
|
|
26
45
|
export async function updateUserLifecycle(
|
|
27
46
|
conn: DbRunner,
|
|
28
47
|
userId: string,
|
|
29
48
|
changes: Record<string, unknown>,
|
|
30
|
-
|
|
49
|
+
options?: UpdateUserLifecycleOptions,
|
|
50
|
+
): Promise<{ readonly applied: boolean }> {
|
|
31
51
|
// user ist systemStream (#497): der Executor-Choke-Point addressiert den
|
|
32
52
|
// Stream immer auf SYSTEM_TENANT_ID — ein Rescope auf die row-tenant_id
|
|
33
53
|
// waere wirkungslos. "system"-Mode, damit loadById auch Legacy-Rows findet,
|
|
34
54
|
// deren tenant_id noch vor dem #762-Backfill-Rebuild steht.
|
|
55
|
+
// skipOptimisticLock stays true (kept correct, not just carried over): it
|
|
56
|
+
// was set from this function's very first version (#494) because every
|
|
57
|
+
// caller here is a server-side business transition (restrict/lift-
|
|
58
|
+
// restriction/grace-period/cancel/forget), never a client edit-conflict UI
|
|
59
|
+
// round-trip — none of them read-then-hold a row version to pass as
|
|
60
|
+
// `payload.version`, so the executor's version gate would reject every
|
|
61
|
+
// call outright without it (`update()` requires `payload.version` unless
|
|
62
|
+
// skipOptimisticLock is set). #3024 replaces the safety that decision gave
|
|
63
|
+
// up with the purpose-built `expect:` precondition above, checked fresh at
|
|
64
|
+
// write time — the mechanism now genuinely matches the caller's shape
|
|
65
|
+
// (a business-field guard) instead of a repurposed version check.
|
|
35
66
|
const tenantDb = createTenantDb(conn, SYSTEM_TENANT_ID, "system");
|
|
36
67
|
const result = await userExecutor.update(
|
|
37
68
|
{ id: userId, changes },
|
|
38
69
|
createSystemUser(SYSTEM_TENANT_ID),
|
|
39
70
|
tenantDb,
|
|
40
|
-
{ skipOptimisticLock: true },
|
|
71
|
+
{ skipOptimisticLock: true, ...(options?.expect && { expect: options.expect }) },
|
|
41
72
|
);
|
|
42
73
|
|
|
43
|
-
if (
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
74
|
+
if (result.isSuccess) return { applied: true };
|
|
75
|
+
|
|
76
|
+
if (
|
|
77
|
+
options?.expect &&
|
|
78
|
+
(result.error.code === "precondition_failed" || result.error.code === "version_conflict")
|
|
79
|
+
) {
|
|
80
|
+
return { applied: false };
|
|
47
81
|
}
|
|
82
|
+
|
|
83
|
+
throw new InternalError({
|
|
84
|
+
message: `user lifecycle update failed for ${userId}: ${result.error.code}`,
|
|
85
|
+
});
|
|
48
86
|
}
|
|
49
87
|
|
|
50
88
|
// #494 Bestandsdaten-Reconcile: Rows, deren Lifecycle-State der alte
|