@cosmicdrift/kumiko-bundled-features 0.311.0 → 0.313.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 +9 -9
- package/src/__tests__/extension-points-typed.test.ts +8 -0
- package/src/agent-tools/__tests__/agent-manifest.test.ts +30 -0
- package/src/agent-tools/agent-manifest.ts +7 -1
- package/src/agent-tools/changes.json +8 -1
- package/src/audit/changes.json +6 -0
- package/src/audit/feature.ts +1 -2
- package/src/audit/i18n.ts +3 -0
- package/src/auth-email-password/__tests__/signup-handover.integration.test.ts +424 -0
- package/src/auth-email-password/changes.json +6 -0
- package/src/auth-email-password/handlers/signup-confirm.write.ts +75 -5
- package/src/auth-email-password/handlers/signup-request.write.ts +60 -0
- package/src/auth-email-password/signup-token-store.ts +87 -1
- package/src/auth-email-password/web/auth-client.ts +10 -1
- package/src/data-retention/__tests__/retention-cleanup-kms.integration.test.ts +160 -0
- package/src/data-retention/changes.json +6 -0
- package/src/data-retention/run-retention-cleanup.ts +160 -18
- package/src/delivery/__tests__/delivery.integration.test.ts +206 -3
- package/src/delivery/address-opt-out.ts +87 -0
- package/src/delivery/changes.json +7 -0
- package/src/delivery/db/queries/address-opt-outs.ts +23 -0
- package/src/delivery/delivery-service.ts +32 -0
- package/src/delivery/feature.ts +9 -1
- package/src/delivery/index.ts +5 -0
- package/src/delivery/tables.ts +37 -0
- package/src/delivery/unsubscribe.ts +101 -16
- package/src/delivery/upsert-preference.ts +1 -1
- package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +621 -43
- package/src/document-ingest-foundation/__tests__/feature.test.ts +20 -3
- package/src/document-ingest-foundation/__tests__/providers.test.ts +204 -0
- package/src/document-ingest-foundation/boot-checks.ts +58 -0
- package/src/document-ingest-foundation/changes.json +18 -0
- package/src/document-ingest-foundation/events.ts +3 -0
- package/src/document-ingest-foundation/executor.ts +12 -0
- package/src/document-ingest-foundation/feature.ts +125 -72
- package/src/document-ingest-foundation/file-ref-liveness.ts +10 -0
- package/src/document-ingest-foundation/forget-extract-with-file-ref.ts +84 -0
- package/src/document-ingest-foundation/index.ts +17 -0
- package/src/document-ingest-foundation/providers.ts +78 -0
- package/src/document-ingest-foundation/tenant-destroy-hook.ts +3 -7
- package/src/document-ingest-foundation/write-document-extract.ts +48 -0
- package/src/file-derivatives/__tests__/public-variant-cross-tenant.integration.test.ts +40 -0
- package/src/file-derivatives/changes.json +6 -0
- package/src/file-derivatives/feature.ts +5 -0
- package/src/file-derivatives/handlers/public-variant-by-file-ref.query.ts +23 -6
- package/src/shared/index.ts +10 -1
- package/src/shared/run-in-sub-transaction.ts +35 -0
- package/src/shared/signup-handover.ts +67 -0
- package/src/shared/single-use-token-store.ts +25 -5
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +44 -0
- package/src/subscription-stripe/changes.json +9 -1
- package/src/subscription-stripe/feature.ts +13 -1
- package/src/subscription-stripe/plugin-methods.ts +17 -2
- package/src/tenant/__tests__/is-tenant-serving-public-content.test.ts +21 -0
- package/src/tenant/changes.json +6 -0
- package/src/tenant/index.ts +1 -0
- package/src/tenant/is-tenant-serving-public-content.ts +13 -0
- package/src/tenant/web/__tests__/member-roles-cell.test.tsx +59 -0
- package/src/tenant/web/__tests__/member-status-cell.test.tsx +46 -0
- package/src/tenant/web/member-roles-cell.tsx +11 -2
- package/src/tenant/web/member-status-cell.tsx +6 -2
- package/src/tenant/web/translate-or-raw.ts +13 -0
- package/src/tenant-handover/__tests__/claim-storage-usage.integration.test.ts +201 -0
- package/src/tenant-handover/__tests__/claim.integration.test.ts +115 -2
- package/src/tenant-handover/__tests__/transfer-graph.test.ts +15 -0
- package/src/tenant-handover/changes.json +12 -0
- package/src/tenant-handover/events.ts +6 -0
- package/src/tenant-handover/feature.ts +11 -0
- package/src/tenant-handover/handlers/claim.write.ts +8 -25
- package/src/tenant-handover/move-entity-graph.ts +30 -1
- package/src/tenant-handover/root-anchor.ts +50 -0
- package/src/tenant-handover/signup-handover-provider.ts +62 -0
- package/src/tenant-handover/transfer-graph.ts +6 -2
- package/src/user-data-rights/changes.json +6 -0
- package/src/user-data-rights/feature.ts +1 -2
- package/src/user-data-rights/i18n.ts +3 -0
- package/src/user-data-rights/run-forget-cleanup.ts +1 -38
|
@@ -23,13 +23,25 @@ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
|
23
23
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
24
24
|
import {
|
|
25
25
|
defineWriteHandler,
|
|
26
|
+
type HandlerContext,
|
|
26
27
|
type SessionUser,
|
|
27
28
|
type TenantId,
|
|
28
29
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
29
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
ConflictError,
|
|
32
|
+
InternalError,
|
|
33
|
+
reraiseAsKumikoError,
|
|
34
|
+
type WriteErrorInfo,
|
|
35
|
+
writeFailure,
|
|
36
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
30
37
|
import { generateUniqueName } from "@cosmicdrift/kumiko-framework/random";
|
|
31
38
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
32
39
|
import * as z from "zod";
|
|
40
|
+
import {
|
|
41
|
+
findSignupHandoverProvider,
|
|
42
|
+
SIGNUP_HANDOVER_BENIGN_CLAIM_REJECTION_CODE,
|
|
43
|
+
type SignupHandoverBinding,
|
|
44
|
+
} from "../../shared";
|
|
33
45
|
// kumiko-lint-ignore cross-feature-import signup-confirm reads tenants.key for slug-uniqueness check (TOCTOU + DB-unique-index zusammen)
|
|
34
46
|
import { tenantTable } from "../../tenant/schema/tenant";
|
|
35
47
|
import { invalidSignupToken, signupEmailAlreadyRegistered } from "../errors";
|
|
@@ -38,8 +50,10 @@ import { passwordSchema } from "../password-policy";
|
|
|
38
50
|
import { INITIAL_SIGNUP_ROLES, provisionSignupAccount } from "../seeding";
|
|
39
51
|
import {
|
|
40
52
|
burnSignupToken,
|
|
53
|
+
deleteSignupHandover,
|
|
41
54
|
deleteSignupToken,
|
|
42
55
|
getEmailForSignupToken,
|
|
56
|
+
getSignupHandover,
|
|
43
57
|
unburnSignupToken,
|
|
44
58
|
} from "../signup-token-store";
|
|
45
59
|
|
|
@@ -58,11 +72,57 @@ export type SignupConfirmData = {
|
|
|
58
72
|
readonly kind: "auth-session";
|
|
59
73
|
readonly session: SessionUser;
|
|
60
74
|
readonly tenantKey: string;
|
|
75
|
+
// Present only when a bound tenant-handover grant existed AND its claim
|
|
76
|
+
// actually succeeded (see the handler body below).
|
|
77
|
+
readonly handover?: { readonly entityType: string; readonly id: string };
|
|
61
78
|
};
|
|
62
79
|
|
|
63
80
|
const SIGNUP_CONFIRM_PROVISION_REASON =
|
|
64
81
|
"provisions a new tenant, its first user and membership before any tenant context exists";
|
|
65
82
|
|
|
83
|
+
// UnprocessableError's `.code` is always the fixed literal "unprocessable" —
|
|
84
|
+
// the distinguishing reason slug lives in `details.reason` (see
|
|
85
|
+
// KumikoError's own reasonSlug getter in kumiko-error.ts). Comparing `.code`
|
|
86
|
+
// directly against SIGNUP_HANDOVER_BENIGN_CLAIM_REJECTION_CODE would never
|
|
87
|
+
// match; this reads the same field the framework itself uses.
|
|
88
|
+
function isBenignClaimRejection(error: WriteErrorInfo): boolean {
|
|
89
|
+
return (
|
|
90
|
+
typeof error.details === "object" &&
|
|
91
|
+
error.details !== null &&
|
|
92
|
+
(error.details as Record<string, unknown>)["reason"] ===
|
|
93
|
+
SIGNUP_HANDOVER_BENIGN_CLAIM_REJECTION_CODE
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
type ClaimedHandover = { readonly entityType: string; readonly id: string };
|
|
98
|
+
|
|
99
|
+
// Rides the caller's tx. A benign claim rejection (nothing was written) logs
|
|
100
|
+
// and yields undefined; any other failure — including a real DB error, which
|
|
101
|
+
// leaves the shared tx unusable regardless of catching it here — throws, so
|
|
102
|
+
// the whole signup rolls back instead of leaving a partially moved transfer
|
|
103
|
+
// graph next to a committed account.
|
|
104
|
+
async function claimBoundHandover(
|
|
105
|
+
ctx: HandlerContext,
|
|
106
|
+
session: SessionUser,
|
|
107
|
+
binding: SignupHandoverBinding,
|
|
108
|
+
): Promise<ClaimedHandover | undefined> {
|
|
109
|
+
const provider = findSignupHandoverProvider(ctx.registry);
|
|
110
|
+
if (!provider) {
|
|
111
|
+
ctx.log?.warn(
|
|
112
|
+
"signup-confirm: handover binding present but no signupHandover provider mounted",
|
|
113
|
+
);
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
const claim = provider.mintClaim(binding);
|
|
117
|
+
const claimResult = await ctx.writeAs(session, claim.qualifiedName, claim.payload);
|
|
118
|
+
if (claimResult.isSuccess) return { entityType: binding.entityType, id: binding.rowId };
|
|
119
|
+
if (!isBenignClaimRejection(claimResult.error)) throw reraiseAsKumikoError(claimResult.error);
|
|
120
|
+
ctx.log?.warn("signup-confirm: handover grant did not redeem", {
|
|
121
|
+
entityType: binding.entityType,
|
|
122
|
+
});
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
66
126
|
export function createSignupConfirmHandler() {
|
|
67
127
|
return defineWriteHandler<"signup-confirm", typeof SignupConfirmSchema, SignupConfirmData>({
|
|
68
128
|
name: "signup-confirm",
|
|
@@ -141,10 +201,6 @@ export function createSignupConfirmHandler() {
|
|
|
141
201
|
throw err;
|
|
142
202
|
}
|
|
143
203
|
|
|
144
|
-
// Cleanup beider Token-Lookup-Keys. Burn-Key bleibt für die
|
|
145
|
-
// restliche Burn-TTL als Replay-Schutz.
|
|
146
|
-
await deleteSignupToken(ctx.redis, { email, token: event.payload.token });
|
|
147
|
-
|
|
148
204
|
// SessionUser für JWT-Mint. Roles aus INITIAL_SIGNUP_ROLES
|
|
149
205
|
// damit DB-write (provisionSignupAccount) und Session-claim
|
|
150
206
|
// dieselbe Quelle teilen — sonst hätten zwei Stellen "TenantAdmin"
|
|
@@ -156,6 +212,19 @@ export function createSignupConfirmHandler() {
|
|
|
156
212
|
roles: [...INITIAL_SIGNUP_ROLES],
|
|
157
213
|
};
|
|
158
214
|
|
|
215
|
+
// Claim BEFORE spending the signup token or the binding below and
|
|
216
|
+
// before `committed` flips: if the claim throws, the `finally` still
|
|
217
|
+
// unburns and the activation link stays retryable.
|
|
218
|
+
const handoverBinding = await getSignupHandover(ctx.redis, event.payload.token);
|
|
219
|
+
const handover = handoverBinding
|
|
220
|
+
? await claimBoundHandover(ctx, session, handoverBinding)
|
|
221
|
+
: undefined;
|
|
222
|
+
|
|
223
|
+
// Drop both token lookup keys; the burn key stays for its remaining
|
|
224
|
+
// TTL as replay protection.
|
|
225
|
+
await deleteSignupToken(ctx.redis, { email, token: event.payload.token });
|
|
226
|
+
if (handoverBinding) await deleteSignupHandover(ctx.redis, event.payload.token);
|
|
227
|
+
|
|
159
228
|
committed = true;
|
|
160
229
|
return {
|
|
161
230
|
isSuccess: true,
|
|
@@ -163,6 +232,7 @@ export function createSignupConfirmHandler() {
|
|
|
163
232
|
kind: "auth-session",
|
|
164
233
|
session,
|
|
165
234
|
tenantKey,
|
|
235
|
+
...(handover !== undefined && { handover }),
|
|
166
236
|
},
|
|
167
237
|
};
|
|
168
238
|
} finally {
|
|
@@ -26,6 +26,7 @@ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
26
26
|
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
27
27
|
import { Temporal } from "temporal-polyfill";
|
|
28
28
|
import * as z from "zod";
|
|
29
|
+
import { findSignupHandoverProvider, type SignupHandoverBinding } from "../../shared";
|
|
29
30
|
import { AUTH_SIGNUP_DEFAULT_TTL_MINUTES } from "../constants";
|
|
30
31
|
import type { AuthMailLocale } from "../email-templates";
|
|
31
32
|
import { renderActivationEmail } from "../email-templates";
|
|
@@ -34,15 +35,30 @@ import { AUTH_SELF_REGISTRATION_FEATURE } from "../self-registration-toggle";
|
|
|
34
35
|
import {
|
|
35
36
|
invalidateExistingSignupToken,
|
|
36
37
|
normalizeEmail,
|
|
38
|
+
storeSignupHandover,
|
|
37
39
|
storeSignupToken,
|
|
40
|
+
takeOverSignupHandoverForResend,
|
|
38
41
|
} from "../signup-token-store";
|
|
39
42
|
|
|
40
43
|
const SIGNUP_NOTIFICATION_TYPE = "auth-email-password:signup-activation";
|
|
41
44
|
|
|
42
45
|
const SignupRequestSchema = z.object({
|
|
43
46
|
email: z.email(),
|
|
47
|
+
// Cross-device try-first handover (kumiko-framework#3035 follow-up,
|
|
48
|
+
// offlot-app#454) — see shared/signup-handover.ts for why the grant is
|
|
49
|
+
// verified here and rebound to the signup token instead of the client
|
|
50
|
+
// carrying it through the activation link.
|
|
51
|
+
handover: z
|
|
52
|
+
.object({
|
|
53
|
+
entityType: z.string().min(1),
|
|
54
|
+
token: z.string().min(1),
|
|
55
|
+
})
|
|
56
|
+
.optional(),
|
|
44
57
|
});
|
|
45
58
|
|
|
59
|
+
const HANDOVER_GRANT_VERIFY_REASON =
|
|
60
|
+
"verifies a tenant-handover grant against its root row in the source tenant (read-only anchor lookup) before binding it to the signup token";
|
|
61
|
+
|
|
46
62
|
export type SignupRequestData =
|
|
47
63
|
| {
|
|
48
64
|
readonly kind: "signup-requested";
|
|
@@ -79,6 +95,7 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
|
|
|
79
95
|
rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
|
|
80
96
|
description:
|
|
81
97
|
"Starts magic-link self-registration by mailing a fresh activation link to an address and invalidating any link still outstanding for it; the answer looks the same whether or not the address is already registered.",
|
|
98
|
+
escapeHatch: { reason: HANDOVER_GRANT_VERIFY_REASON },
|
|
82
99
|
handler: async (event, ctx) => {
|
|
83
100
|
// Silent no-op when off, matching the route's own always-200
|
|
84
101
|
// anti-enumeration contract (registerTokenRequestRoute swallows every
|
|
@@ -114,6 +131,12 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
|
|
|
114
131
|
// between lookup paths that lowercase differently (or not at all).
|
|
115
132
|
const email = event.payload.email;
|
|
116
133
|
|
|
134
|
+
// Read off the OLD token's binding before it's invalidated below — a
|
|
135
|
+
// resend without a fresh `handover` in the payload (the common case:
|
|
136
|
+
// the visitor just retyped their email) must still carry the earlier
|
|
137
|
+
// verified grant over to the new token instead of silently dropping it.
|
|
138
|
+
const priorBinding = await takeOverSignupHandoverForResend(ctx.redis, email);
|
|
139
|
+
|
|
117
140
|
// At most one live signup token per email: invalidate whatever's
|
|
118
141
|
// there before minting the new one (see signup-token-store.ts).
|
|
119
142
|
await invalidateExistingSignupToken(ctx.redis, email);
|
|
@@ -131,6 +154,43 @@ export function createSignupRequestHandler(opts: SignupRequestOptions) {
|
|
|
131
154
|
|
|
132
155
|
await storeSignupToken(ctx.redis, { email, token, ttlSeconds });
|
|
133
156
|
|
|
157
|
+
// A handover grant in this request re-verifies and replaces the prior
|
|
158
|
+
// binding; anything short of a fresh, verified grant (no provider
|
|
159
|
+
// mounted, or verification failed) falls back to it instead —
|
|
160
|
+
// dropping a still-live binding on a bad request would strand the
|
|
161
|
+
// visitor's run. A throw here must never skip the mail below, or the
|
|
162
|
+
// signup itself silently breaks for a bug in a provider this handler
|
|
163
|
+
// doesn't own.
|
|
164
|
+
let binding: SignupHandoverBinding | null = priorBinding;
|
|
165
|
+
if (event.payload.handover) {
|
|
166
|
+
const { entityType, token: grantToken } = event.payload.handover;
|
|
167
|
+
const provider = findSignupHandoverProvider(ctx.registry);
|
|
168
|
+
if (!provider) {
|
|
169
|
+
ctx.log?.warn(
|
|
170
|
+
"signup-request: handover payload present but no signupHandover provider mounted",
|
|
171
|
+
);
|
|
172
|
+
} else {
|
|
173
|
+
try {
|
|
174
|
+
const verified = await provider.verifyGrant({
|
|
175
|
+
db: ctx.db.unsafeRaw(HANDOVER_GRANT_VERIFY_REASON),
|
|
176
|
+
registry: ctx.registry,
|
|
177
|
+
entityType,
|
|
178
|
+
token: grantToken,
|
|
179
|
+
});
|
|
180
|
+
if (verified) {
|
|
181
|
+
binding = verified;
|
|
182
|
+
} else {
|
|
183
|
+
ctx.log?.warn("signup-request: handover grant did not verify", { entityType });
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
ctx.log?.warn("signup-request: handover grant verification threw", { entityType });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (binding) {
|
|
191
|
+
await storeSignupHandover(ctx.redis, { token, binding, ttlSeconds });
|
|
192
|
+
}
|
|
193
|
+
|
|
134
194
|
// normalizeEmail from the store — one source of truth for
|
|
135
195
|
// normalization; the delivery recipient + lookup path consistently
|
|
136
196
|
// get the same format.
|
|
@@ -11,7 +11,12 @@
|
|
|
11
11
|
// `signup:`-prefix.
|
|
12
12
|
|
|
13
13
|
import type Redis from "ioredis";
|
|
14
|
-
import
|
|
14
|
+
import * as z from "zod";
|
|
15
|
+
import {
|
|
16
|
+
createSingleUseTokenStore,
|
|
17
|
+
hashSingleUseToken,
|
|
18
|
+
type SignupHandoverBinding,
|
|
19
|
+
} from "../shared";
|
|
15
20
|
|
|
16
21
|
/** Email normalization — single source for every lookup layer (used
|
|
17
22
|
* internally by the store AND by callers that need a consistent form
|
|
@@ -67,3 +72,84 @@ export async function deleteSignupToken(
|
|
|
67
72
|
/** Burn release for failed-confirm paths (DB error etc.) so a legitimate
|
|
68
73
|
* retry isn't blocked by a stale burn marker. */
|
|
69
74
|
export const unburnSignupToken = store.unburn;
|
|
75
|
+
|
|
76
|
+
// Cross-device try-first handover (kumiko-framework#3035 follow-up,
|
|
77
|
+
// offlot-app#454): signup-request binds a verified tenant-handover grant to
|
|
78
|
+
// its own signup token, in a SIBLING Redis namespace keyed the same way
|
|
79
|
+
// (sha256(token)) so it stays byte-compatible with the token store above
|
|
80
|
+
// without going through it — the binding has its own lifecycle (survives a
|
|
81
|
+
// resend, is read once by signup-confirm) that doesn't fit the token↔email
|
|
82
|
+
// pairing. Only {entityType, rowId, sourceTenantId} is ever stored — never
|
|
83
|
+
// the grant token or the email (see shared/signup-handover.ts).
|
|
84
|
+
const SIGNUP_HANDOVER_KEY_PREFIX = "signup:handover:";
|
|
85
|
+
|
|
86
|
+
const signupHandoverBindingSchema = z.object({
|
|
87
|
+
entityType: z.string().min(1),
|
|
88
|
+
rowId: z.string().min(1),
|
|
89
|
+
sourceTenantId: z.string().min(1),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
function signupHandoverKeyForHash(tokenHash: string): string {
|
|
93
|
+
return `${SIGNUP_HANDOVER_KEY_PREFIX}${tokenHash}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function signupHandoverKey(token: string): string {
|
|
97
|
+
return signupHandoverKeyForHash(hashSingleUseToken(token));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseSignupHandoverBinding(raw: string): SignupHandoverBinding | null {
|
|
101
|
+
let json: unknown;
|
|
102
|
+
try {
|
|
103
|
+
json = JSON.parse(raw);
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
const parsed = signupHandoverBindingSchema.safeParse(json);
|
|
108
|
+
return parsed.success ? parsed.data : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Binds a verified grant to this signup token, TTL-matched to the token
|
|
112
|
+
* itself so the binding never outlives (or is killed before) it. */
|
|
113
|
+
export async function storeSignupHandover(
|
|
114
|
+
redis: Redis,
|
|
115
|
+
args: { token: string; binding: SignupHandoverBinding; ttlSeconds: number },
|
|
116
|
+
): Promise<void> {
|
|
117
|
+
await redis.set(
|
|
118
|
+
signupHandoverKey(args.token),
|
|
119
|
+
JSON.stringify(args.binding),
|
|
120
|
+
"EX",
|
|
121
|
+
args.ttlSeconds,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Reads the binding for a still-live signup token, if any. */
|
|
126
|
+
export async function getSignupHandover(
|
|
127
|
+
redis: Redis,
|
|
128
|
+
token: string,
|
|
129
|
+
): Promise<SignupHandoverBinding | null> {
|
|
130
|
+
const raw = await redis.get(signupHandoverKey(token));
|
|
131
|
+
return raw === null ? null : parseSignupHandoverBinding(raw);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Carries a still-live binding over to a resend, before the old token is
|
|
135
|
+
* invalidated: reads the old token's hash off the store's by-email entry
|
|
136
|
+
* (never the token itself), fetches the binding under that hash, and
|
|
137
|
+
* deletes it — the fresh token gets its own storeSignupHandover call with
|
|
138
|
+
* the same binding right after. Null when there was no live binding. */
|
|
139
|
+
export async function takeOverSignupHandoverForResend(
|
|
140
|
+
redis: Redis,
|
|
141
|
+
email: string,
|
|
142
|
+
): Promise<SignupHandoverBinding | null> {
|
|
143
|
+
const oldTokenHash = await store.getTokenHashForSubject(redis, normalizeEmail(email));
|
|
144
|
+
if (oldTokenHash === null) return null;
|
|
145
|
+
const raw = await redis.get(signupHandoverKeyForHash(oldTokenHash));
|
|
146
|
+
if (raw === null) return null;
|
|
147
|
+
await redis.del(signupHandoverKeyForHash(oldTokenHash));
|
|
148
|
+
return parseSignupHandoverBinding(raw);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Cleanup after signup-confirm has read (and acted on, or logged a failed
|
|
152
|
+
* claim for) the binding. */
|
|
153
|
+
export async function deleteSignupHandover(redis: Redis, token: string): Promise<void> {
|
|
154
|
+
await redis.del(signupHandoverKey(token));
|
|
155
|
+
}
|
|
@@ -316,12 +316,18 @@ export async function confirmAccountUnlock(
|
|
|
316
316
|
// User auf /signup/complete?token=… wo er sein Password setzt.
|
|
317
317
|
export async function requestSignup(
|
|
318
318
|
email: string,
|
|
319
|
+
// Cross-device try-first handover (kumiko-framework#3035 follow-up,
|
|
320
|
+
// offlot-app#454): forwards a row-bound tenant-handover grant so the
|
|
321
|
+
// server can verify + rebind it to the signup token. The grant never
|
|
322
|
+
// travels any further than this request — never in the URL, the mail,
|
|
323
|
+
// or the confirm response.
|
|
324
|
+
handover?: { readonly entityType: string; readonly token: string },
|
|
319
325
|
): Promise<{ ok: true } | { ok: false; error: AuthTokenFailure }> {
|
|
320
326
|
const res = await fetch("/api/auth/signup-request", {
|
|
321
327
|
method: "POST",
|
|
322
328
|
credentials: "same-origin",
|
|
323
329
|
headers: { "Content-Type": "application/json", ...csrfHeader() },
|
|
324
|
-
body: JSON.stringify({ email }),
|
|
330
|
+
body: JSON.stringify({ email, ...(handover !== undefined && { handover }) }),
|
|
325
331
|
});
|
|
326
332
|
if (res.ok) return { ok: true };
|
|
327
333
|
return { ok: false, error: await parseTokenFailure(res) };
|
|
@@ -334,6 +340,9 @@ export async function requestSignup(
|
|
|
334
340
|
export type SignupConfirmSuccess = {
|
|
335
341
|
readonly user: { readonly id: string; readonly tenantId: string; readonly roles: string[] };
|
|
336
342
|
readonly tenantKey: string;
|
|
343
|
+
// Present only when a bound handover grant existed and its claim
|
|
344
|
+
// succeeded — see signup-confirm.write.ts.
|
|
345
|
+
readonly handover?: { readonly entityType: string; readonly id: string };
|
|
337
346
|
};
|
|
338
347
|
|
|
339
348
|
export async function confirmSignup(
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Refs #2057 (partial) — hardDelete must crypto-shred the purged row's own
|
|
2
|
+
// KMS subject key AFTER the forget, mirroring run-forget-cleanup.ts:477-503's
|
|
3
|
+
// forget → eraseKey → nullBlindIndexesForSubject ordering. Only recordOwned
|
|
4
|
+
// and self-pii subjects belong to the row being deleted; a userOwned field's
|
|
5
|
+
// subject is the REFERENCED user, whose key may still protect that user's
|
|
6
|
+
// other live rows elsewhere — hardDelete of one row must never erase it.
|
|
7
|
+
|
|
8
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
|
+
import {
|
|
11
|
+
configurePiiSubjectKms,
|
|
12
|
+
InMemoryKmsAdapter,
|
|
13
|
+
KeyErasedError,
|
|
14
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
15
|
+
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
16
|
+
import {
|
|
17
|
+
setupTestStack,
|
|
18
|
+
type TestStack,
|
|
19
|
+
testTenantId,
|
|
20
|
+
unsafeCreateEntityTable,
|
|
21
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
22
|
+
import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testing";
|
|
23
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
24
|
+
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../feature";
|
|
25
|
+
import { runRetentionCleanup } from "../run-retention-cleanup";
|
|
26
|
+
|
|
27
|
+
// Entity A: `personal: { of: "id" }` → recordOwned — the row IS its own
|
|
28
|
+
// subject. hardDelete must erase this key.
|
|
29
|
+
const recordOwnedEntity = createEntity({
|
|
30
|
+
table: "read_c9_record_owned",
|
|
31
|
+
fields: {
|
|
32
|
+
note: createTextField({ required: true, personal: { of: "id" }, find: "none" }),
|
|
33
|
+
},
|
|
34
|
+
retention: { keepFor: "30d", strategy: "hardDelete" },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Entity B: `personal: { of: "ownerUserId" }` → userOwned — the subject is
|
|
38
|
+
// the REFERENCED user, not this row. hardDelete of this row must NEVER
|
|
39
|
+
// erase the owner's key (they may own other, still-live rows).
|
|
40
|
+
const userOwnedEntity = createEntity({
|
|
41
|
+
table: "read_c9_user_owned",
|
|
42
|
+
fields: {
|
|
43
|
+
ownerUserId: createTextField({
|
|
44
|
+
required: true,
|
|
45
|
+
personal: false,
|
|
46
|
+
reason: "technical_reference",
|
|
47
|
+
}),
|
|
48
|
+
note: createTextField({ required: true, personal: { of: "ownerUserId" }, find: "none" }),
|
|
49
|
+
},
|
|
50
|
+
retention: { keepFor: "30d", strategy: "hardDelete" },
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const c9Feature = defineFeature("c9-retention-kms-fixtures", (r) => {
|
|
54
|
+
r.entity("c9-record-owned", recordOwnedEntity);
|
|
55
|
+
r.entity("c9-user-owned", userOwnedEntity);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
let stack: TestStack;
|
|
59
|
+
let kms: InMemoryKmsAdapter;
|
|
60
|
+
let now: ReturnType<ReturnType<typeof getTemporal>["Now"]["instant"]>;
|
|
61
|
+
let pastIso: string;
|
|
62
|
+
|
|
63
|
+
beforeAll(async () => {
|
|
64
|
+
stack = await setupTestStack({ features: [createDataRetentionFeature(), c9Feature] });
|
|
65
|
+
for (const e of [tenantRetentionOverrideEntity, recordOwnedEntity, userOwnedEntity]) {
|
|
66
|
+
await unsafeCreateEntityTable(stack.db, e);
|
|
67
|
+
}
|
|
68
|
+
now = getTemporal().Now.instant();
|
|
69
|
+
pastIso = now.subtract({ hours: 60 * 24 }).toString(); // 60 days old, past the cutoff
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterAll(async () => {
|
|
73
|
+
await stack.cleanup();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
beforeEach(async () => {
|
|
77
|
+
for (const t of ["read_c9_record_owned", "read_c9_user_owned"]) {
|
|
78
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM ${t}`);
|
|
79
|
+
}
|
|
80
|
+
kms = new InMemoryKmsAdapter();
|
|
81
|
+
configurePiiSubjectKms(kms);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
resetPiiSubjectKmsForTests();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Ciphertext content is irrelevant to this test — it only proves which
|
|
89
|
+
// subject's key eraseKey was (not) called for, not the field encryption
|
|
90
|
+
// round-trip. The subject's key is pre-created here the same way the real
|
|
91
|
+
// write path creates it on first encrypt (getOrCreateDek), so eraseKey's
|
|
92
|
+
// effect is observable via getKey.
|
|
93
|
+
async function seedRecordOwned(tenantId: string, insertedAtIso: string): Promise<string> {
|
|
94
|
+
const rows = (await asRawClient(stack.db).unsafe(
|
|
95
|
+
`INSERT INTO read_c9_record_owned (tenant_id, note, inserted_at) VALUES ($1, $2, $3::timestamptz) RETURNING id::text AS id`,
|
|
96
|
+
[tenantId, "pii:placeholder", insertedAtIso],
|
|
97
|
+
)) as { id: string }[];
|
|
98
|
+
const rowId = rows[0]?.id;
|
|
99
|
+
if (!rowId) throw new Error("seedRecordOwned: insert returned no id");
|
|
100
|
+
await kms.createKey({ kind: "record", entity: "c9-record-owned", id: rowId });
|
|
101
|
+
return rowId;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function seedUserOwned(
|
|
105
|
+
tenantId: string,
|
|
106
|
+
ownerUserId: string,
|
|
107
|
+
insertedAtIso: string,
|
|
108
|
+
): Promise<string> {
|
|
109
|
+
const rows = (await asRawClient(stack.db).unsafe(
|
|
110
|
+
`INSERT INTO read_c9_user_owned (tenant_id, owner_user_id, note, inserted_at) VALUES ($1, $2, $3, $4::timestamptz) RETURNING id::text AS id`,
|
|
111
|
+
[tenantId, ownerUserId, "pii:placeholder", insertedAtIso],
|
|
112
|
+
)) as { id: string }[];
|
|
113
|
+
const rowId = rows[0]?.id;
|
|
114
|
+
if (!rowId) throw new Error("seedUserOwned: insert returned no id");
|
|
115
|
+
await kms.createKey({ kind: "user", userId: ownerUserId });
|
|
116
|
+
return rowId;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
describe("runRetentionCleanup :: hardDelete crypto-shredding (Refs #2057)", () => {
|
|
120
|
+
test("erases a recordOwned row's own key, leaves a userOwned row's owner key untouched", async () => {
|
|
121
|
+
const tenantId = testTenantId(9);
|
|
122
|
+
const ownerUserId = crypto.randomUUID();
|
|
123
|
+
const recordRowId = await seedRecordOwned(tenantId, pastIso);
|
|
124
|
+
const userRowId = await seedUserOwned(tenantId, ownerUserId, pastIso);
|
|
125
|
+
|
|
126
|
+
// Sanity: both keys exist and are readable before cleanup runs.
|
|
127
|
+
await expect(
|
|
128
|
+
kms.getKey({ kind: "record", entity: "c9-record-owned", id: recordRowId }),
|
|
129
|
+
).resolves.toBeInstanceOf(Buffer);
|
|
130
|
+
await expect(kms.getKey({ kind: "user", userId: ownerUserId })).resolves.toBeInstanceOf(Buffer);
|
|
131
|
+
|
|
132
|
+
const result = await runRetentionCleanup({
|
|
133
|
+
db: stack.db,
|
|
134
|
+
registry: stack.registry,
|
|
135
|
+
tenantId,
|
|
136
|
+
tenantPreset: null,
|
|
137
|
+
now,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
expect(result.hardDeleted).toBe(2);
|
|
141
|
+
|
|
142
|
+
const recordRows = await asRawClient(stack.db).unsafe(
|
|
143
|
+
`SELECT id FROM read_c9_record_owned WHERE tenant_id = $1`,
|
|
144
|
+
[tenantId],
|
|
145
|
+
);
|
|
146
|
+
expect(recordRows).toHaveLength(0);
|
|
147
|
+
await expect(
|
|
148
|
+
kms.getKey({ kind: "record", entity: "c9-record-owned", id: recordRowId }),
|
|
149
|
+
).rejects.toThrow(KeyErasedError);
|
|
150
|
+
|
|
151
|
+
const userRows = await asRawClient(stack.db).unsafe(
|
|
152
|
+
`SELECT id FROM read_c9_user_owned WHERE tenant_id = $1`,
|
|
153
|
+
[tenantId],
|
|
154
|
+
);
|
|
155
|
+
expect(userRows).toHaveLength(0);
|
|
156
|
+
expect(userRowId).toBeTruthy();
|
|
157
|
+
// The owner's key must survive — it is not this row's own subject.
|
|
158
|
+
await expect(kms.getKey({ kind: "user", userId: ownerUserId })).resolves.toBeInstanceOf(Buffer);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.313.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "data-retention hardDelete now erases a purged row's own KMS subject key",
|
|
6
|
+
"detail": "Refs #2057 (partial fix — the hardDelete path only). run-retention-\ncleanup's hardDelete forgot the row's event history via the executor but\nnever crypto-shredded its KMS subject key, leaving the DEK live after the\nrow was gone. It now erases the key in the same per-row sub-transaction as the\nforget (an eraseKey throw rolls the forget back too), mirroring run-\nforget-cleanup.ts's forget -> eraseKey -> nullBlindIndexesForSubject\nordering. Only the row's own recordOwned/self-pii subjects are erased;\na userOwned or tenantOwned field's subject is the REFERENCED user/tenant,\nnot this row, and may still protect other live rows elsewhere — those\nkeys are never touched. No migration needed."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.309.0",
|
|
4
10
|
"type": "breaking",
|