@cosmicdrift/kumiko-bundled-features 0.285.2 → 0.287.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 +13 -9
- package/src/auth-email-password/changes.json +6 -0
- package/src/auth-email-password/invite-token-store.ts +47 -96
- package/src/auth-email-password/lockout-store.ts +13 -102
- package/src/auth-email-password/signed-token.ts +4 -86
- package/src/auth-email-password/signup-token-store.test.ts +29 -0
- package/src/auth-email-password/signup-token-store.ts +40 -103
- package/src/auth-mfa/changes.json +6 -0
- package/src/auth-mfa/mfa-verify-attempts.ts +10 -68
- package/src/billing-foundation/__tests__/billing-info-query.test.ts +103 -0
- package/src/billing-foundation/billing-info-query.ts +95 -0
- package/src/billing-foundation/changes.json +8 -1
- package/src/billing-foundation/index.ts +5 -0
- package/src/derivatives-sharp/__tests__/render.test.ts +246 -1
- package/src/derivatives-sharp/changes.json +8 -1
- package/src/derivatives-sharp/render.ts +172 -5
- package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +15 -1
- package/src/file-derivatives/changes.json +6 -0
- package/src/file-derivatives/feature.ts +9 -1
- package/src/shared/__tests__/row-bound-grant.integration.test.ts +184 -0
- package/src/shared/changes.json +8 -1
- package/src/shared/index.ts +14 -0
- package/src/shared/lockout-counter.test.ts +91 -0
- package/src/shared/lockout-counter.ts +108 -0
- package/src/shared/row-bound-grant.test.ts +294 -0
- package/src/shared/row-bound-grant.ts +115 -0
- package/src/{auth-email-password/__tests__ → shared}/signed-token.test.ts +1 -1
- package/src/shared/signed-token.ts +101 -0
- package/src/shared/single-use-token-store.test.ts +75 -0
- package/src/shared/single-use-token-store.ts +136 -0
- package/src/tenant/seeding.ts +4 -0
- package/src/user-data-rights/__tests__/deletion-token-compat.test.ts +35 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +90 -1
- package/src/user-data-rights/changes.json +12 -0
- package/src/user-data-rights/deletion-token.ts +41 -47
- package/src/user-data-rights/feature.ts +27 -0
- package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +10 -20
- package/src/user-data-rights/run-export-jobs.ts +69 -1
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +198 -1
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +76 -6
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Generic Redis-backed pre-activation token store: bidirectional
|
|
2
|
+
// token↔subject mapping plus single-use burn/unburn semantics. Extracted
|
|
3
|
+
// from auth-email-password/signup-token-store.ts and
|
|
4
|
+
// auth-email-password/invite-token-store.ts (infra#446) — both were the
|
|
5
|
+
// same Redis layout, differing only in their key prefixes and which field
|
|
6
|
+
// (email vs. invitationId) plays the "subject" role.
|
|
7
|
+
//
|
|
8
|
+
// Public subpath export (./shared/single-use-token-store in package.json):
|
|
9
|
+
// offlot-app (a separate repo, external consumer) carries its own copy of
|
|
10
|
+
// this exact logic under `src/features/waitlist/signup-token-store.ts`,
|
|
11
|
+
// with a comment noting it "must stay byte-compatible with
|
|
12
|
+
// auth-email-password/signup-token-store" because signup-confirm resolves
|
|
13
|
+
// tokens via the same Redis key layout. offlot-app#418 (separate issue,
|
|
14
|
+
// after this ships) will replace that copy with
|
|
15
|
+
// `createSingleUseTokenStore({ tokenPrefix: "signup:by-token:", subjectPrefix:
|
|
16
|
+
// "signup:by-email:", burnPrefix: "signup:burn:" })` — i.e. the exact same
|
|
17
|
+
// prefix strings the framework's own signup store below uses, so both stay
|
|
18
|
+
// byte-compatible by construction instead of by hand-copied logic.
|
|
19
|
+
//
|
|
20
|
+
// Token material: opaque random 256-bit (e.g. crypto.randomBytes,
|
|
21
|
+
// base64url-encoded). Not designed for human typing — the subject clicks a
|
|
22
|
+
// mail link, nobody types the token.
|
|
23
|
+
//
|
|
24
|
+
// Why a server-side lookup at all (not a stateless HMAC-signed token, like
|
|
25
|
+
// password-reset/email-verification)? Some subjects (e.g. a not-yet-created
|
|
26
|
+
// signup) have no stable identity claim yet for an HMAC to bind to. We map
|
|
27
|
+
// token ↔ subject bidirectionally in Redis and delete the pair on confirm.
|
|
28
|
+
// Bidirectional because:
|
|
29
|
+
// - forward (by-token): confirm/accept needs token → subject
|
|
30
|
+
// - reverse (by-subject): the create/request flow needs to know whether a
|
|
31
|
+
// token is still live for this subject, so a resend can invalidate it
|
|
32
|
+
// instead of leaving two valid tokens around
|
|
33
|
+
//
|
|
34
|
+
// Every key is derived from sha256(token), never the raw token — Redis key
|
|
35
|
+
// names, MONITOR output, replica traffic, and memory/backup dumps never
|
|
36
|
+
// carry the bearer secret in the clear (#2174). The by-subject entry stores
|
|
37
|
+
// the *hash* of the live token, not the token itself, so it can only be
|
|
38
|
+
// used to invalidate (delete the matching forward entry) — never to
|
|
39
|
+
// recover or resend the original token. A resend therefore always mints a
|
|
40
|
+
// fresh token and invalidates the previous one, rather than reusing the
|
|
41
|
+
// same link.
|
|
42
|
+
//
|
|
43
|
+
// Single-use burn: `SET burn:<hash> "1" EX 3600 NX` — first caller to
|
|
44
|
+
// confirm/accept wins ("OK"), a concurrent second tab racing the same link
|
|
45
|
+
// gets "already-used". TTL is 1h (short enough that the burn-key doesn't
|
|
46
|
+
// permanently tax Redis, long enough to catch replays inside any realistic
|
|
47
|
+
// race window).
|
|
48
|
+
|
|
49
|
+
import { createHash } from "node:crypto";
|
|
50
|
+
import type Redis from "ioredis";
|
|
51
|
+
|
|
52
|
+
function hashToken(token: string): string {
|
|
53
|
+
return createHash("sha256").update(token).digest("hex");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createSingleUseTokenStore(prefixes: {
|
|
57
|
+
readonly tokenPrefix: string;
|
|
58
|
+
readonly subjectPrefix: string;
|
|
59
|
+
readonly burnPrefix: string;
|
|
60
|
+
}) {
|
|
61
|
+
function tokenKey(token: string): string {
|
|
62
|
+
return `${prefixes.tokenPrefix}${hashToken(token)}`;
|
|
63
|
+
}
|
|
64
|
+
// Builds the forward key from an already-hashed value (e.g. read back
|
|
65
|
+
// from the by-subject entry) — does NOT hash again. Kept separate from
|
|
66
|
+
// tokenKey() (which hashes a raw token) so a double-hash mistake is
|
|
67
|
+
// visible at the call site instead of silently no-op'ing a delete.
|
|
68
|
+
function forwardKeyForHash(tokenHash: string): string {
|
|
69
|
+
return `${prefixes.tokenPrefix}${tokenHash}`;
|
|
70
|
+
}
|
|
71
|
+
function subjectKey(subjectId: string): string {
|
|
72
|
+
return `${prefixes.subjectPrefix}${subjectId}`;
|
|
73
|
+
}
|
|
74
|
+
function burnKey(token: string): string {
|
|
75
|
+
return `${prefixes.burnPrefix}${hashToken(token)}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Stores the pair bidirectionally and sets TTL on both keys. Idempotent —
|
|
79
|
+
// re-writing the same token/subject pair is fine. The by-subject value is
|
|
80
|
+
// the token's hash, not the token — see file header.
|
|
81
|
+
async function store(
|
|
82
|
+
redis: Redis,
|
|
83
|
+
args: { subjectId: string; token: string; ttlSeconds: number },
|
|
84
|
+
): Promise<void> {
|
|
85
|
+
await Promise.all([
|
|
86
|
+
redis.set(tokenKey(args.token), args.subjectId, "EX", args.ttlSeconds),
|
|
87
|
+
redis.set(subjectKey(args.subjectId), hashToken(args.token), "EX", args.ttlSeconds),
|
|
88
|
+
]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Lookup: subject for a token. Null when the token doesn't (or no longer)
|
|
92
|
+
// exist (expired, already consumed, or invalid).
|
|
93
|
+
async function getSubjectForToken(redis: Redis, token: string): Promise<string | null> {
|
|
94
|
+
return redis.get(tokenKey(token));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Deletes a still-live token for this subject, if one exists — both the
|
|
98
|
+
// forward entry (built from the hash already stored in the by-subject
|
|
99
|
+
// entry, never recovers the raw token) and the by-subject entry itself.
|
|
100
|
+
// Returns whether a live token existed. Deleting the by-subject entry
|
|
101
|
+
// here too (not just the forward key) avoids leaving a dangling hash
|
|
102
|
+
// pointing at nothing if the caller crashes before the following store().
|
|
103
|
+
async function invalidateExistingBySubject(redis: Redis, subjectId: string): Promise<boolean> {
|
|
104
|
+
const existingHash = await redis.get(subjectKey(subjectId));
|
|
105
|
+
if (existingHash === null) return false;
|
|
106
|
+
await Promise.all([
|
|
107
|
+
redis.del(forwardKeyForHash(existingHash)),
|
|
108
|
+
redis.del(subjectKey(subjectId)),
|
|
109
|
+
]);
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// SET NX EX — atomic check-and-set. Returns "OK" when the key is new,
|
|
114
|
+
// null when it's already there.
|
|
115
|
+
async function burn(redis: Redis, token: string): Promise<"burned" | "already-used"> {
|
|
116
|
+
const result = await redis.set(burnKey(token), "1", "EX", 3600, "NX");
|
|
117
|
+
return result === "OK" ? "burned" : "already-used";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Cleanup after a successful confirm/accept — both lookup keys. The
|
|
121
|
+
// burn-key stays (prevents a replay for the rest of the burn TTL).
|
|
122
|
+
async function deleteBoth(
|
|
123
|
+
redis: Redis,
|
|
124
|
+
args: { subjectId: string; token: string },
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
await Promise.all([redis.del(tokenKey(args.token)), redis.del(subjectKey(args.subjectId))]);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Burn-release for a failed confirm/accept path (e.g. a DB error) so a
|
|
130
|
+
// legitimate retry isn't blocked by a stale burn marker.
|
|
131
|
+
async function unburn(redis: Redis, token: string): Promise<void> {
|
|
132
|
+
await redis.del(burnKey(token));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { store, getSubjectForToken, invalidateExistingBySubject, burn, deleteBoth, unburn };
|
|
136
|
+
}
|
package/src/tenant/seeding.ts
CHANGED
|
@@ -234,6 +234,10 @@ export async function seedTenant(
|
|
|
234
234
|
tdb,
|
|
235
235
|
);
|
|
236
236
|
if (!result.isSuccess) {
|
|
237
|
+
// Same idempotency case as the stream-version check above, only detected
|
|
238
|
+
// one instruction later: a dispatcher cycle appended to the stream between
|
|
239
|
+
// that read and this create. Both paths return without firing postSave.
|
|
240
|
+
if (result.error.code === "version_conflict") return { id: options.id };
|
|
237
241
|
throw new Error(
|
|
238
242
|
`seedTenant failed: ${result.error.code} — ${JSON.stringify(result.error.details ?? {})}`,
|
|
239
243
|
);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { Temporal } from "temporal-polyfill";
|
|
3
|
+
import { signToken } from "../../shared";
|
|
4
|
+
import { redeemDeletionToken, signDeletionToken } from "../deletion-token";
|
|
5
|
+
|
|
6
|
+
const SECRET = "deletion-token-compat-secret";
|
|
7
|
+
const USER_ID = "user-7";
|
|
8
|
+
const REQUEST_ID = "req-99";
|
|
9
|
+
const NOW = Temporal.Instant.fromEpochMilliseconds(1_700_000_000_000);
|
|
10
|
+
|
|
11
|
+
// Deletion tokens sit in mails that are already out. Moving the minting onto
|
|
12
|
+
// shared/row-bound-grant must not change a single byte of them, so this pins
|
|
13
|
+
// the wire format against the formula the hand-rolled version used:
|
|
14
|
+
// signToken(userId, `deletion-request:${requestId}`, ...).
|
|
15
|
+
describe("deletion token wire format", () => {
|
|
16
|
+
test("a token minted with the pre-refactor formula still redeems", async () => {
|
|
17
|
+
const legacy = signToken(USER_ID, `deletion-request:${REQUEST_ID}`, 30, SECRET).token;
|
|
18
|
+
|
|
19
|
+
const result = await redeemDeletionToken({
|
|
20
|
+
token: legacy,
|
|
21
|
+
secret: SECRET,
|
|
22
|
+
loadPendingRequestId: async () => REQUEST_ID,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
expect(result.ok).toBe(true);
|
|
26
|
+
expect(result.ok && result.subject).toBe(USER_ID);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("minting produces exactly the pre-refactor bytes", () => {
|
|
30
|
+
const minted = signDeletionToken(USER_ID, REQUEST_ID, 30, SECRET, NOW);
|
|
31
|
+
const legacy = signToken(USER_ID, `deletion-request:${REQUEST_ID}`, 30, SECRET, NOW);
|
|
32
|
+
|
|
33
|
+
expect(minted.token).toBe(legacy.token);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
createInMemoryFileProvider,
|
|
25
25
|
type FileStorageProvider,
|
|
26
26
|
} from "@cosmicdrift/kumiko-framework/files";
|
|
27
|
+
import type { MetricsHandle } from "@cosmicdrift/kumiko-framework/observability";
|
|
27
28
|
import {
|
|
28
29
|
createTestUser,
|
|
29
30
|
setupTestStack,
|
|
@@ -43,7 +44,7 @@ import { createSessionsFeature, userSessionEntity } from "../../sessions";
|
|
|
43
44
|
import { tenantMembershipsTable } from "../../tenant";
|
|
44
45
|
import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
|
|
45
46
|
import { createUserDataRightsFeature } from "../feature";
|
|
46
|
-
import { runExportJobs } from "../run-export-jobs";
|
|
47
|
+
import { EXPORT_CLEANUP_BACKLOG_AGE_METRIC, runExportJobs } from "../run-export-jobs";
|
|
47
48
|
import { exportDownloadTokenEntity, exportDownloadTokensTable } from "../schema/download-token";
|
|
48
49
|
import { EXPORT_JOB_STATUS, exportJobEntity, exportJobsTable } from "../schema/export-job";
|
|
49
50
|
import { hashDownloadToken } from "../token-helpers";
|
|
@@ -133,6 +134,20 @@ function buildProvider(tenantId: string): Promise<FileStorageProvider> {
|
|
|
133
134
|
return Promise.resolve(p);
|
|
134
135
|
}
|
|
135
136
|
|
|
137
|
+
// Typed fake standing in for the ctx.meter-backed MetricsHandle feature.ts
|
|
138
|
+
// builds for the real cron — records `set()` calls without a Meter/registry.
|
|
139
|
+
function createRecordingMetricsHandle(): MetricsHandle & { readonly values: Map<string, number> } {
|
|
140
|
+
const values = new Map<string, number>();
|
|
141
|
+
return {
|
|
142
|
+
values,
|
|
143
|
+
inc: () => {},
|
|
144
|
+
observe: () => {},
|
|
145
|
+
set: (name, value) => {
|
|
146
|
+
values.set(name, value);
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
136
151
|
// Seedet einen pending Job via realen request-export-Handler — echter
|
|
137
152
|
// ES-Pfad (crud.create emittiert event "export-job.created"), nicht
|
|
138
153
|
// direct-INSERT. Direct-INSERT wuerde stream-version=0/row-version=1
|
|
@@ -444,6 +459,80 @@ describe("runExportJobs :: storage-cleanup", () => {
|
|
|
444
459
|
});
|
|
445
460
|
});
|
|
446
461
|
|
|
462
|
+
describe("runExportJobs :: export-cleanup backlog metric", () => {
|
|
463
|
+
test("done-Job mit abgelaufener TTL + weiterhin gesetztem downloadStorageKey → Metrik > 0", async () => {
|
|
464
|
+
const jobId = await seedPendingJob();
|
|
465
|
+
const T = getTemporal();
|
|
466
|
+
const longAgo = T.Instant.fromEpochMilliseconds(Date.now() - 365 * 24 * 60 * 60 * 1000);
|
|
467
|
+
const storageKey = `exports/${tenantA}/${jobId}.zip`;
|
|
468
|
+
const provider = await buildProvider(tenantA);
|
|
469
|
+
await provider.write(storageKey, new Uint8Array([7, 8, 9]));
|
|
470
|
+
|
|
471
|
+
await updateRows(
|
|
472
|
+
stack.db,
|
|
473
|
+
exportJobsTable,
|
|
474
|
+
{
|
|
475
|
+
status: EXPORT_JOB_STATUS.Done,
|
|
476
|
+
startedAt: longAgo,
|
|
477
|
+
completedAt: longAgo,
|
|
478
|
+
downloadStorageKey: storageKey,
|
|
479
|
+
expiresAt: longAgo,
|
|
480
|
+
},
|
|
481
|
+
{ id: jobId },
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
const metrics = createRecordingMetricsHandle();
|
|
485
|
+
await runExportJobs({
|
|
486
|
+
db: stack.db,
|
|
487
|
+
registry: stack.registry,
|
|
488
|
+
buildStorageProvider: async () => ({
|
|
489
|
+
...provider,
|
|
490
|
+
delete: async () => {
|
|
491
|
+
throw new Error("synthetic storage delete failure");
|
|
492
|
+
},
|
|
493
|
+
}),
|
|
494
|
+
now: NOW(),
|
|
495
|
+
metrics,
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
expect(metrics.values.get(EXPORT_CLEANUP_BACKLOG_AGE_METRIC)).toBeGreaterThan(0);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("done-Job wird erfolgreich cleaned → Metrik ist 0", async () => {
|
|
502
|
+
const jobId = await seedPendingJob();
|
|
503
|
+
const T = getTemporal();
|
|
504
|
+
const longAgo = T.Instant.fromEpochMilliseconds(Date.now() - 365 * 24 * 60 * 60 * 1000);
|
|
505
|
+
const storageKey = `exports/${tenantA}/${jobId}.zip`;
|
|
506
|
+
const provider = await buildProvider(tenantA);
|
|
507
|
+
await provider.write(storageKey, new Uint8Array([1, 2, 3]));
|
|
508
|
+
|
|
509
|
+
await updateRows(
|
|
510
|
+
stack.db,
|
|
511
|
+
exportJobsTable,
|
|
512
|
+
{
|
|
513
|
+
status: EXPORT_JOB_STATUS.Done,
|
|
514
|
+
startedAt: longAgo,
|
|
515
|
+
completedAt: longAgo,
|
|
516
|
+
downloadStorageKey: storageKey,
|
|
517
|
+
expiresAt: longAgo,
|
|
518
|
+
},
|
|
519
|
+
{ id: jobId },
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
const metrics = createRecordingMetricsHandle();
|
|
523
|
+
const result = await runExportJobs({
|
|
524
|
+
db: stack.db,
|
|
525
|
+
registry: stack.registry,
|
|
526
|
+
buildStorageProvider: buildProvider,
|
|
527
|
+
now: NOW(),
|
|
528
|
+
metrics,
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
expect(result.cleanedJobIds).toContain(jobId);
|
|
532
|
+
expect(metrics.values.get(EXPORT_CLEANUP_BACKLOG_AGE_METRIC)).toBe(0);
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
|
|
447
536
|
describe("runExportJobs :: idempotency", () => {
|
|
448
537
|
test("zweiter Run nach done ist no-op fuer den selben Job", async () => {
|
|
449
538
|
const jobId = await seedPendingJob();
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.287.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "export-cleanup cron now emits an export_cleanup_backlog_age gauge to surface stalled runs",
|
|
6
|
+
"detail": "storageCleanupPass deletes expired export bundles via provider.delete, but the storage provider only exposes list() (no timestamps), so a silent failure left unencrypted PII bundles sitting in the bucket with no signal. runExportJobs now accepts an optional MetricsHandle and, after the cleanup loop, reports the age in seconds of the oldest done-status job whose expiresAt+grace has passed while downloadStorageKey is still set (0 if none) via the new EXPORT_CLEANUP_BACKLOG_AGE_METRIC (\"export_cleanup_backlog_age\", a gauge — no _total/_seconds suffix per validateMetricName, unit: \"seconds\" carried in the r.metric() definition instead). feature.ts's job handler builds the handle itself with createMetricsHandle(ctx.meter, \"user-data-rights\") since JobContext has no bound ctx.metrics (that only exists on HandlerContext, built per write/query dispatch) — falls back to createNoopMetricsHandle() when ctx.meter is unset. Metric emission is wrapped in try/catch so an observability hiccup never fails an otherwise-successful cleanup pass. Scope is deliberately done-status only; failed-status cleanup candidates have no expiresAt/TTL."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.287.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "GDPR forget only hard-deletes files that are PII of the forgotten person",
|
|
12
|
+
"detail": "fileRefDeleteHook picked rows via insertedById (who uploaded the file) and treated every matched row the same on strategy=\"delete\", so forgetting an uploader hard-deleted third-party business files they had merely uploaded (e.g. a dealer's vehicle photos uploaded by an employee). FileFieldDef/ImageFieldDef/FilesFieldDef/ImagesFieldDef now support \"& ResolvedPiiFlags\" like every other field type (createFileField/createImageField/createFilesField/createImagesField thread personal/reason through expandPersonalAnnotations), and fileRefDeleteHook's strategy=\"delete\" branch splits rows per-row via the new isPersonalFileRow/isPersonalPiiField predicates: pii/userOwned/recordOwned fields take the existing hard-delete path (storageProvider.delete() + derivatives + row), every other row (explicit personal:false, tenantOwned, subjectRef, no annotation, or an unresolvable entity/field) goes through severPersonLink instead, same as the existing strategy=\"anonymize\" path. Unattached uploads (no entityType/fieldName) still hard-delete. Derivatives have no own file_refs row, so they keep following their original's decision (the #2461 \"GDPR derivatives survive forget\" test stays green). POST /files now resolves entityType/fieldName against the registry and rejects the upload with 400 when they're set but don't resolve to a real field, closing the gap that let a client attach a file to a field the forget-hook could never look up; both unset (unattached upload) still passes. No new column or migration — the decision uses the entityType/fieldName file_refs already stores."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.285.2",
|
|
4
16
|
"type": "fix",
|
|
@@ -1,62 +1,56 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// Email-verified account deletion, expressed as a row-bound grant: the
|
|
2
|
+
// subject is the user, the anchor is the pending request id stored on the
|
|
3
|
+
// user row. Minting and verification both run through
|
|
4
|
+
// shared/row-bound-grant, so a token from a cancelled cycle (id nulled) or a
|
|
5
|
+
// superseded one (row holds a newer id) fails verification — see #354/1.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// The same id is stored on the user row when the request is minted and nulled
|
|
10
|
-
// on cancel. confirm recomputes the HMAC with the row's CURRENT id, so a token
|
|
11
|
-
// from a cancelled cycle (row id nulled) or a superseded one (row holds a newer
|
|
12
|
-
// id) fails verification — the bounded-TTL replay window is closed without
|
|
13
|
-
// touching the shared signToken/verifyToken primitive.
|
|
7
|
+
// The purpose string and the anchoring are unchanged from the hand-rolled
|
|
8
|
+
// version this replaced, so tokens stay byte-compatible.
|
|
14
9
|
|
|
15
10
|
import type { Temporal } from "temporal-polyfill";
|
|
16
|
-
import {
|
|
11
|
+
import { type RowBoundGrantResult, redeemRowBoundGrant, signRowBoundGrant } from "../shared";
|
|
17
12
|
|
|
18
13
|
const DELETION_REQUEST_PURPOSE = "deletion-request";
|
|
19
14
|
|
|
20
|
-
export type VerifyResult =
|
|
21
|
-
| { readonly ok: true; readonly userId: string; readonly expiresAtMs: number }
|
|
22
|
-
| { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
|
|
23
|
-
|
|
24
|
-
function deletionPurpose(requestId: string): string {
|
|
25
|
-
return `${DELETION_REQUEST_PURPOSE}:${requestId}`;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
15
|
export function signDeletionToken(
|
|
29
16
|
userId: string,
|
|
30
17
|
requestId: string,
|
|
31
18
|
ttlMinutes: number,
|
|
32
19
|
secret: string,
|
|
33
20
|
now?: Temporal.Instant,
|
|
34
|
-
): { token: string; expiresAt: Temporal.Instant } {
|
|
35
|
-
return
|
|
21
|
+
): { readonly token: string; readonly expiresAt: Temporal.Instant } {
|
|
22
|
+
return signRowBoundGrant({
|
|
23
|
+
subject: userId,
|
|
24
|
+
purpose: DELETION_REQUEST_PURPOSE,
|
|
25
|
+
anchor: requestId,
|
|
26
|
+
ttlMinutes,
|
|
27
|
+
secret,
|
|
28
|
+
now,
|
|
29
|
+
});
|
|
36
30
|
}
|
|
37
31
|
|
|
38
|
-
export function
|
|
39
|
-
token: string
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
now?: Temporal.Instant
|
|
43
|
-
):
|
|
44
|
-
return
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
32
|
+
export function redeemDeletionToken(args: {
|
|
33
|
+
readonly token: string;
|
|
34
|
+
readonly secret: string | undefined;
|
|
35
|
+
readonly loadPendingRequestId: (userId: string) => Promise<string | null>;
|
|
36
|
+
readonly now?: Temporal.Instant;
|
|
37
|
+
}): Promise<RowBoundGrantResult> {
|
|
38
|
+
return redeemRowBoundGrant({
|
|
39
|
+
token: args.token,
|
|
40
|
+
purpose: DELETION_REQUEST_PURPOSE,
|
|
41
|
+
secret: args.secret,
|
|
42
|
+
loadAnchor: args.loadPendingRequestId,
|
|
43
|
+
commitAnchor: {
|
|
44
|
+
unsafeSkip: {
|
|
45
|
+
reason:
|
|
46
|
+
"pendingDeletionRequestId may only be changed through updateUserLifecycle — a " +
|
|
47
|
+
"conditional UPDATE would bypass the user.updated event and lose the field on a " +
|
|
48
|
+
"projection rebuild (see update-user-lifecycle.ts). The concurrent-redeem window " +
|
|
49
|
+
"this leaves open predates row-bound grants: startDeletionGracePeriod already " +
|
|
50
|
+
"read-then-writes the Active check. Closing it needs an atomic lifecycle " +
|
|
51
|
+
"transition, which is its own change.",
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
now: args.now,
|
|
55
|
+
});
|
|
62
56
|
}
|
|
@@ -7,6 +7,10 @@ import {
|
|
|
7
7
|
i18nKey,
|
|
8
8
|
SYSTEM_USER_ID,
|
|
9
9
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
10
|
+
import {
|
|
11
|
+
createMetricsHandle,
|
|
12
|
+
createNoopMetricsHandle,
|
|
13
|
+
} from "@cosmicdrift/kumiko-framework/observability";
|
|
10
14
|
import { validateGdprHookCompleteness, validateGdprPiiHookCoverage } from "./boot-checks";
|
|
11
15
|
import {
|
|
12
16
|
EXPORT_SECTION_EXTENSION_NAME,
|
|
@@ -49,6 +53,7 @@ import { makeTenantMailTransportResolver } from "./lib/mail-transport-resolver";
|
|
|
49
53
|
import { resolveAppTenantModel } from "./lib/resolve-tenant-model";
|
|
50
54
|
import { makeTenantStorageProviderResolver } from "./lib/storage-provider-resolver";
|
|
51
55
|
import {
|
|
56
|
+
EXPORT_CLEANUP_BACKLOG_AGE_METRIC,
|
|
52
57
|
runExportJobs,
|
|
53
58
|
type SendExportFailedEmailFn,
|
|
54
59
|
type SendExportReadyEmailFn,
|
|
@@ -528,6 +533,21 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
528
533
|
},
|
|
529
534
|
});
|
|
530
535
|
|
|
536
|
+
// Surfaces a stalled export-cleanup cron: if the daily
|
|
537
|
+
// pass stops running, done-status export bundles keep sitting in
|
|
538
|
+
// storage past their TTL+grace with downloadStorageKey still set
|
|
539
|
+
// (unencrypted PII at rest). Emitted from the same job that cleans up
|
|
540
|
+
// (see handler below) — a dead cron makes the metric go stale too,
|
|
541
|
+
// which an `absent()` alert catches without a second liveness signal.
|
|
542
|
+
// Scope: Done-status bundles only — Failed-status cleanup candidates
|
|
543
|
+
// have no expiresAt/TTL (immediate cleanup, see storageCleanupPass).
|
|
544
|
+
r.metric(EXPORT_CLEANUP_BACKLOG_AGE_METRIC, {
|
|
545
|
+
type: "gauge",
|
|
546
|
+
unit: "seconds",
|
|
547
|
+
description:
|
|
548
|
+
"Age in seconds of the oldest done-status export bundle whose expiresAt+grace has passed but downloadStorageKey is still set. 0 when the storage-cleanup pass has no backlog.",
|
|
549
|
+
});
|
|
550
|
+
|
|
531
551
|
// S2.U3 Atom 3b — Worker fuer Async Export-Pipeline. Cron-getriggert.
|
|
532
552
|
const RUN_EXPORT_JOBS_REASON = "processes pending export jobs across every tenant";
|
|
533
553
|
r.job({
|
|
@@ -548,6 +568,12 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
548
568
|
RUN_EXPORT_JOBS_REASON,
|
|
549
569
|
) as import("@cosmicdrift/kumiko-framework/db").DbConnection; // @cast-boundary db-operator — jobs never run inside a DbTx
|
|
550
570
|
const exportRegistry = ctx.registry;
|
|
571
|
+
// JobContext carries ctx.meter (raw), not ctx.metrics — that bound
|
|
572
|
+
// handle only exists on HandlerContext (built per write/query call
|
|
573
|
+
// in dispatch-shared.ts). Build the same handle explicitly here.
|
|
574
|
+
const exportMetrics = ctx.meter
|
|
575
|
+
? createMetricsHandle(ctx.meter, "user-data-rights")
|
|
576
|
+
: createNoopMetricsHandle();
|
|
551
577
|
|
|
552
578
|
// C6 — ohne eigene send*Email-Opts aber mit gemountetem mail-transport
|
|
553
579
|
// versendet der Cron die Export-Notifications selbst (Default-Templates).
|
|
@@ -612,6 +638,7 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
612
638
|
}),
|
|
613
639
|
escapeHatchAuditSink: ctx._escapeHatchAuditSink,
|
|
614
640
|
actor: ctx.systemUser.id,
|
|
641
|
+
metrics: exportMetrics,
|
|
615
642
|
});
|
|
616
643
|
},
|
|
617
644
|
});
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
import { USER_STATUS, userTable } from "../../user";
|
|
10
|
-
import {
|
|
10
|
+
import { redeemDeletionToken } from "../deletion-token";
|
|
11
11
|
import { startDeletionGracePeriod } from "./deletion-grace-period";
|
|
12
12
|
|
|
13
13
|
export type ConfirmDeletionByTokenOptions = {
|
|
@@ -66,24 +66,14 @@ export function createConfirmDeletionByTokenHandler(opts: ConfirmDeletionByToken
|
|
|
66
66
|
agent: { expose: false },
|
|
67
67
|
rateLimit: { per: "ip", limit: 10, windowSeconds: 60 },
|
|
68
68
|
handler: async (event, ctx) => {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
// peekedUserId ist unverifizierte Angreifer-Eingabe — ein Lookup-Fehler
|
|
78
|
-
// (z.B. typfremde id) wird zu demselben generischen 422, nie zu einem 500.
|
|
79
|
-
const requestId = await readPendingDeletionRequestId(ctx, peekedUserId);
|
|
80
|
-
if (!requestId) return writeFailure(invalidToken());
|
|
81
|
-
|
|
82
|
-
const verified = verifyDeletionToken(
|
|
83
|
-
event.payload.token,
|
|
84
|
-
requestId,
|
|
85
|
-
opts.deletionTokenSecret,
|
|
86
|
-
);
|
|
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
|
+
});
|
|
87
77
|
if (!verified.ok) return writeFailure(invalidToken());
|
|
88
78
|
|
|
89
79
|
// @cast-boundary engine-payload — queryAs returns unknown, narrowed to
|
|
@@ -95,7 +85,7 @@ export function createConfirmDeletionByTokenHandler(opts: ConfirmDeletionByToken
|
|
|
95
85
|
)) as { profile: { userRights: { gracePeriod: DurationSpec } } };
|
|
96
86
|
const res = await startDeletionGracePeriod(
|
|
97
87
|
ctx,
|
|
98
|
-
verified.
|
|
88
|
+
verified.subject,
|
|
99
89
|
profile.profile.userRights.gracePeriod,
|
|
100
90
|
ctx.db.unsafeRaw("appends the user lifecycle event on the SYSTEM_TENANT_ID user stream"),
|
|
101
91
|
);
|