@cosmicdrift/kumiko-bundled-features 0.221.0 → 0.223.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/audit/__tests__/audit-log-screen.test.tsx +5 -5
- package/src/audit/__tests__/audit-screens.boot.test.ts +2 -1
- package/src/audit/__tests__/audit.integration.test.ts +3 -1
- package/src/audit/__tests__/constants.test.ts +6 -1
- package/src/audit/constants.ts +10 -1
- package/src/audit/feature.ts +2 -0
- package/src/audit/i18n.ts +2 -0
- package/src/audit/web/audit-log-detail-screen.tsx +7 -3
- package/src/audit/web/audit-log-screen.tsx +27 -29
- package/src/auth-email-password/handlers/invite-create.write.ts +7 -1
- package/src/auth-email-password/seeding.ts +3 -1
- package/src/auth-mfa/hmac-token-codec.ts +75 -0
- package/src/auth-mfa/mfa-challenge-token.ts +10 -65
- package/src/auth-mfa/mfa-preauth-setup-token.ts +10 -61
- package/src/cap-counter/stock-cap-guard.ts +1 -1
- package/src/channel-email/__tests__/email-channel.test.ts +19 -0
- package/src/channel-email/__tests__/pii-guard.test.ts +11 -0
- package/src/channel-email/email-channel.ts +14 -7
- package/src/channel-email/pii-guard.ts +8 -0
- package/src/compliance-profiles/handlers/set-profile.write.ts +1 -4
- package/src/crypto-shredding/constants.ts +1 -0
- package/src/crypto-shredding/feature.ts +6 -1
- package/src/crypto-shredding/handlers/forget-subject.write.ts +25 -1
- package/src/crypto-shredding/index.ts +2 -0
- package/src/custom-fields/__tests__/audit-integration.integration.test.ts +9 -1
- package/src/file-derivatives/feature.ts +2 -2
- package/src/file-derivatives/handlers/public-variant.query.ts +4 -5
- package/src/files-provider-s3/__tests__/s3-provider.test.ts +58 -1
- package/src/files-provider-s3/s3-provider.ts +45 -0
- package/src/files-tenant-data/__tests__/hooks.integration.test.ts +271 -0
- package/src/files-tenant-data/__tests__/sweep-orphaned-derivatives.job.integration.test.ts +275 -0
- package/src/files-tenant-data/changes.json +1 -0
- package/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +173 -0
- package/src/files-tenant-data/hooks.ts +93 -0
- package/src/files-tenant-data/index.ts +58 -0
- package/src/form-draft/db/queries/cleanup.ts +17 -0
- package/src/form-draft/handlers/cleanup.job.ts +20 -10
- package/src/form-draft/handlers/discard.write.ts +25 -7
- package/src/form-draft/handlers/get.query.ts +4 -2
- package/src/form-draft/handlers/list.query.ts +4 -1
- package/src/form-draft/lookup.ts +12 -5
- package/src/inbound-mail-foundation/watch-supervisor.ts +28 -4
- package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +128 -3
- package/src/jobs/constants.ts +1 -0
- package/src/jobs/handlers/retry.write.ts +26 -6
- package/src/jobs/i18n.ts +3 -0
- package/src/sessions/session-callbacks.ts +1 -0
- package/src/tags/__tests__/tag-screens.boot.test.ts +13 -0
- package/src/tenant/__tests__/tenant-timezone-boot.integration.test.ts +165 -0
- package/src/tenant/changes.json +1 -1
- package/src/tenant/constants.ts +5 -1
- package/src/tenant/handlers/remove-member.write.ts +12 -0
- package/src/tenant/handlers/update-member-roles.write.ts +10 -37
- package/src/tenant/last-tenant-admin.ts +39 -0
- package/src/tenant/screens.ts +2 -0
- package/src/tenant/seeding.ts +3 -1
- package/src/tenant-lifecycle/feature.ts +1 -0
- package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +1 -5
- package/src/tenant-lifecycle/run-tenant-destroy.ts +5 -0
- package/src/tenant-lifecycle/stages.ts +5 -0
- package/src/tier-engine/web/tier-admin-screen.tsx +1 -2
- package/src/user/__tests__/email-unique-blind-index.integration.test.ts +25 -13
- package/src/user/changes.json +12 -5
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +3 -10
- package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +2 -8
- package/src/user-data-rights/handlers/deletion-grace-period.ts +2 -2
- package/src/user-data-rights/handlers/lift-restriction.write.ts +2 -2
- package/src/user-data-rights/handlers/restrict-account.write.ts +3 -3
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +10 -6
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +114 -1
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +63 -12
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
loadAggregate,
|
|
12
12
|
VersionConflictError,
|
|
13
13
|
} from "@cosmicdrift/kumiko-framework/event-store";
|
|
14
|
+
import type { FileProviderResolver } from "@cosmicdrift/kumiko-framework/files";
|
|
14
15
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
15
16
|
import { tenantEntity, tenantTable } from "../tenant";
|
|
16
17
|
import {
|
|
@@ -214,6 +215,7 @@ export async function runNextDestructionStage(args: {
|
|
|
214
215
|
readonly registry: Registry;
|
|
215
216
|
readonly tenantId: TenantId;
|
|
216
217
|
readonly log?: (message: string) => void;
|
|
218
|
+
readonly fileProviderResolver?: FileProviderResolver;
|
|
217
219
|
}): Promise<{ readonly done: boolean; readonly error?: string; readonly halted?: boolean }> {
|
|
218
220
|
const events = await loadAggregate(args.db, args.tenantId, args.tenantId);
|
|
219
221
|
const { completed, abandoned, attemptsByStage } = replayStageState(events);
|
|
@@ -235,6 +237,7 @@ export async function runNextDestructionStage(args: {
|
|
|
235
237
|
registry: args.registry,
|
|
236
238
|
tenantId: args.tenantId,
|
|
237
239
|
log: args.log,
|
|
240
|
+
fileProviderResolver: args.fileProviderResolver,
|
|
238
241
|
};
|
|
239
242
|
|
|
240
243
|
let version = lastEventVersion(events);
|
|
@@ -300,6 +303,7 @@ export async function runTenantDestructionSweep(args: {
|
|
|
300
303
|
readonly registry: Registry;
|
|
301
304
|
readonly now?: Temporal.Instant;
|
|
302
305
|
readonly log?: (message: string) => void;
|
|
306
|
+
readonly fileProviderResolver?: FileProviderResolver;
|
|
303
307
|
}): Promise<{ readonly triggered: number; readonly advanced: number }> {
|
|
304
308
|
const T = getTemporal();
|
|
305
309
|
const now = args.now ?? T.Now.instant();
|
|
@@ -348,6 +352,7 @@ export async function runTenantDestructionSweep(args: {
|
|
|
348
352
|
registry: args.registry,
|
|
349
353
|
tenantId: row.id as TenantId,
|
|
350
354
|
log: args.log,
|
|
355
|
+
fileProviderResolver: args.fileProviderResolver,
|
|
351
356
|
});
|
|
352
357
|
if (!result.error && !result.halted) advanced++;
|
|
353
358
|
} catch (err) {
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type Registry,
|
|
16
16
|
type TenantId,
|
|
17
17
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
18
|
+
import type { FileProviderResolver } from "@cosmicdrift/kumiko-framework/files";
|
|
18
19
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
19
20
|
import {
|
|
20
21
|
tenantEntity,
|
|
@@ -30,6 +31,10 @@ export type DestructionStageCtx = {
|
|
|
30
31
|
readonly registry: Registry;
|
|
31
32
|
readonly tenantId: TenantId;
|
|
32
33
|
readonly log?: (message: string) => void;
|
|
34
|
+
// Only threaded through for the "files" stage (EXT_STORAGE_PROVIDER
|
|
35
|
+
// "destroyTenant" hooks) — undefined when no file-provider is wired,
|
|
36
|
+
// which those hooks must treat as "nothing to clean up", not an error.
|
|
37
|
+
readonly fileProviderResolver?: FileProviderResolver;
|
|
33
38
|
};
|
|
34
39
|
|
|
35
40
|
export type DestructionStage = {
|
|
@@ -60,8 +60,7 @@ export function TierAdminScreen(): ReactNode {
|
|
|
60
60
|
// shown (UI-level mis-grant guard). tenantId is a pure trigger here (the
|
|
61
61
|
// body doesn't read it) — Biome's "extra dep" autofix would empty the deps
|
|
62
62
|
// and limit the reset to mount.
|
|
63
|
-
// kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
|
|
64
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: tenantId is the intended reset trigger, do not remove.
|
|
63
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: tenantId is the intended reset trigger, do not remove. kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
|
|
65
64
|
useEffect(() => {
|
|
66
65
|
setTier("");
|
|
67
66
|
setStatus({ kind: "idle" });
|
|
@@ -106,15 +106,16 @@ describe("fw#2134 — email unique constraint on the blind-index column", () =>
|
|
|
106
106
|
expect(survivors[0]?.n).toBe(1);
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
-
test("a soft-deleted user
|
|
109
|
+
test("a soft-deleted user's email becomes available for reuse (fw#2464)", async () => {
|
|
110
110
|
// Soft-delete only flips isDeleted/deletedAt (apply-entity-event.ts) —
|
|
111
|
-
// it never touches email/email_bidx, so the row
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
// email
|
|
111
|
+
// it never touches email/email_bidx, so the old row keeps carrying the
|
|
112
|
+
// email's bidx hash. Before fw#2464 the partial bidx unique index
|
|
113
|
+
// covered every row regardless of isDeleted, so that hash permanently
|
|
114
|
+
// blocked reuse of the email by a new or restored user. The index's
|
|
115
|
+
// WHERE clause now also excludes soft-deleted rows (isDeleted = false)
|
|
116
|
+
// whenever the entity has softDelete enabled, so a fresh create with
|
|
117
|
+
// the same email succeeds once the original row is soft-deleted — only
|
|
118
|
+
// a live (non-deleted) row still occupying the email blocks the slot.
|
|
118
119
|
const email = "soft-deleted@example.com";
|
|
119
120
|
const tdb = createTenantDb(testDb.db, SYSTEM_TENANT_ID, "system");
|
|
120
121
|
const systemUser = createSystemUser(SYSTEM_TENANT_ID);
|
|
@@ -130,11 +131,22 @@ describe("fw#2134 — email unique constraint on the blind-index column", () =>
|
|
|
130
131
|
if (!deleted.isSuccess) throw new Error("expected soft-delete to succeed");
|
|
131
132
|
|
|
132
133
|
const reCreated = await executor.create({ email, displayName: "Squatter" }, systemUser, tdb);
|
|
133
|
-
if (reCreated.isSuccess) {
|
|
134
|
-
throw new Error(
|
|
134
|
+
if (!reCreated.isSuccess) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
"expected re-create with the same email to succeed once the original row is soft-deleted",
|
|
137
|
+
);
|
|
135
138
|
}
|
|
136
|
-
expect(reCreated.
|
|
137
|
-
|
|
138
|
-
|
|
139
|
+
expect(reCreated.data.id).not.toBe(created.data.id);
|
|
140
|
+
|
|
141
|
+
// DB-proof: both rows survive — the soft-deleted original still holds
|
|
142
|
+
// the bidx hash, and the new live row now holds it too, since the
|
|
143
|
+
// partial index only enforces uniqueness among non-deleted rows.
|
|
144
|
+
const rows = (await asRawClient(testDb.db).unsafe(
|
|
145
|
+
`SELECT "id", "is_deleted" FROM "read_users" WHERE "email_bidx" = $1 ORDER BY "is_deleted"`,
|
|
146
|
+
[computeBlindIndex(TEST_KEY, email)],
|
|
147
|
+
)) as ReadonlyArray<{ id: string; is_deleted: boolean }>;
|
|
148
|
+
expect(rows).toHaveLength(2);
|
|
149
|
+
expect(rows[0]).toEqual({ id: String(reCreated.data.id), is_deleted: false });
|
|
150
|
+
expect(rows[1]).toEqual({ id: String(created.data.id), is_deleted: true });
|
|
139
151
|
});
|
|
140
152
|
});
|
package/src/user/changes.json
CHANGED
|
@@ -1,30 +1,37 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.218.1",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "user.email searchable \u2014 reindex required after upgrade",
|
|
6
|
+
"detail": "email gained searchable for roster lookup. buildSearchDocument only runs on write; existing rows need reindexEntity(\"user\") (or the reindex job) plus Meili configure for the new attribute.",
|
|
7
|
+
"migration": "After deploying, run reindexEntity(\"user\") (or the existing reindex job) so pre-upgrade user rows appear in roster search."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.218.0",
|
|
4
11
|
"type": "fix",
|
|
5
12
|
"title": "user-list: system-scope search; membership roles on tenants; emailVerified sort",
|
|
6
|
-
"detail": "systemScope entityList search uses SYSTEM_TENANT_ID (was session tenant
|
|
13
|
+
"detail": "systemScope entityList search uses SYSTEM_TENANT_ID (was session tenant \u2192 Meili miss/500). Tenants column shows membership roles (Offlot Demo (TenantAdmin)). emailVerified sortable; email also searchable for roster lookup. Global roles stay SystemAdmin-only \u2014 TenantAdmin/Admin live on membership (Team \u2192 Members)."
|
|
7
14
|
},
|
|
8
15
|
{
|
|
9
16
|
"version": "0.217.0",
|
|
10
17
|
"type": "improvement",
|
|
11
18
|
"title": "user-list: roles multiSelect + tenants column; roles editable as SystemAdmin (fw#2384 follow-up)",
|
|
12
|
-
"detail": "user.roles is now createMultiSelectField (jsonb string[], options=[SystemAdmin]) so entityEdit shows a real multi-select instead of a raw JSON text box
|
|
13
|
-
"migration": "Run `kumiko-schema generate` and apply
|
|
19
|
+
"detail": "user.roles is now createMultiSelectField (jsonb string[], options=[SystemAdmin]) so entityEdit shows a real multi-select instead of a raw JSON text box \u2014 SystemAdmins can promote another SystemAdmin from the UI. user-list columns gain roles + tenants; tenants is a derived field filled by the list handler from memberships (platform roster was unusable without tenant context). switch-tenant + auth-client parseRoles accept jsonb arrays (string-only deserialize dropped SystemAdmin on tenant switch). Soft-deleted users stay hidden by default.",
|
|
20
|
+
"migration": "Run `kumiko-schema generate` and apply \u2014 migrates read_users.roles from text to jsonb USING roles::jsonb. Existing JSON array strings (`[]`, `[\"SystemAdmin\"]`) convert cleanly. Consumers that wrote roles as a JSON-encoded string via user:create/update should pass string[] instead (parseRoles still accepts both on read)."
|
|
14
21
|
},
|
|
15
22
|
{
|
|
16
23
|
"version": "0.167.1",
|
|
17
24
|
"type": "breaking",
|
|
18
25
|
"title": "user.locale no longer defaults to \"de\" (fw#1637).",
|
|
19
26
|
"detail": "The entity-level default contradicted tenant-settings' own \"en\" default and silently overrode any app or tenant locale configuration for every new user. locale now stays unset until the client or a resolution chain provides one. Consumers that already fall back with `user.locale ?? \"en\"` are unaffected in shape but now see null instead of \"de\".",
|
|
20
|
-
"migration": "Run `kumiko-schema generate` and apply
|
|
27
|
+
"migration": "Run `kumiko-schema generate` and apply \u2014 the migration emits `ALTER TABLE read_users ALTER COLUMN locale DROP DEFAULT`. Note that DROP DEFAULT only affects future inserts: existing rows keep the \"de\" the old default wrote, which no user ever chose. Your app therefore splits into pre-bump users on \"de\" and post-bump users on null, and each group resolves to a different language. Decide deliberately: either keep the old values (and state that pre-bump users stay German), or run `UPDATE read_users SET locale = NULL WHERE locale = 'de'` once so every user follows the same chain \u2014 the latter only if no UI ever let users edit the field, otherwise it erases real choices. If your app relied on the implicit German default, set an explicit fallback instead (fw#1653)."
|
|
21
28
|
},
|
|
22
29
|
{
|
|
23
30
|
"version": "0.167.1",
|
|
24
31
|
"type": "improvement",
|
|
25
32
|
"title": "user.timezone field, wired into ctx.tz (fw#1636).",
|
|
26
33
|
"detail": "The user entity gains a `timezone` field (createTzField, no default). buildHandlerContext passes it to ctx.tz.user; the fallback chain stays tenant.timezone, then \"UTC\". Before this, ctx.tz.tenant and ctx.tz.user both hardcoded \"UTC\" while the plumbing already accepted per-request values.",
|
|
27
|
-
"migration": "Run `kumiko-schema generate` and apply
|
|
34
|
+
"migration": "Run `kumiko-schema generate` and apply \u2014 the migration adds `read_users.timezone`. No backfill: an unset timezone falls through to the tenant's, exactly as before the field existed."
|
|
28
35
|
},
|
|
29
36
|
{
|
|
30
37
|
"version": "0.165.3",
|
|
@@ -33,7 +33,7 @@ export const cancelDeletionWrite = defineWriteHandler({
|
|
|
33
33
|
if (!row) {
|
|
34
34
|
return writeFailure(
|
|
35
35
|
new UnprocessableError("user_not_found", {
|
|
36
|
-
details: {
|
|
36
|
+
details: { userId: event.user.id },
|
|
37
37
|
}),
|
|
38
38
|
);
|
|
39
39
|
}
|
|
@@ -41,20 +41,13 @@ export const cancelDeletionWrite = defineWriteHandler({
|
|
|
41
41
|
if (row.status !== USER_STATUS.DeletionRequested) {
|
|
42
42
|
return writeFailure(
|
|
43
43
|
new UnprocessableError("no_pending_deletion", {
|
|
44
|
-
details: {
|
|
45
|
-
reason: "no_pending_deletion",
|
|
46
|
-
currentStatus: row.status,
|
|
47
|
-
},
|
|
44
|
+
details: { currentStatus: row.status },
|
|
48
45
|
}),
|
|
49
46
|
);
|
|
50
47
|
}
|
|
51
48
|
|
|
52
49
|
if (!isWithinGracePeriod(row.gracePeriodEnd)) {
|
|
53
|
-
return writeFailure(
|
|
54
|
-
new UnprocessableError("grace_period_expired", {
|
|
55
|
-
details: { reason: "grace_period_expired" },
|
|
56
|
-
}),
|
|
57
|
-
);
|
|
50
|
+
return writeFailure(new UnprocessableError("grace_period_expired"));
|
|
58
51
|
}
|
|
59
52
|
|
|
60
53
|
await updateUserLifecycle(ctx.db.raw, event.user.id, {
|
|
@@ -13,9 +13,7 @@ export type ConfirmDeletionByTokenOptions = {
|
|
|
13
13
|
// Generischer 422 für jeden Token-Fehlerpfad (malformed / bad_signature /
|
|
14
14
|
// expired / kein Secret) — kein Signal ob ein Token zu einem User gehört.
|
|
15
15
|
function invalidToken(): UnprocessableError {
|
|
16
|
-
return new UnprocessableError("invalid_or_expired_token"
|
|
17
|
-
details: { reason: "invalid_or_expired_token" },
|
|
18
|
-
});
|
|
16
|
+
return new UnprocessableError("invalid_or_expired_token");
|
|
19
17
|
}
|
|
20
18
|
|
|
21
19
|
// userId stammt aus dem noch-unverifizierten Token (Angreifer-Eingabe). Ein
|
|
@@ -81,11 +79,7 @@ export function createConfirmDeletionByTokenHandler(opts: ConfirmDeletionByToken
|
|
|
81
79
|
// user_not_in_active_state) und würde einem Token-Inhaber das Proben des
|
|
82
80
|
// Account-Status erlauben (#354/2). Der authentifizierte request-deletion-
|
|
83
81
|
// Pfad zeigt dem User legitim seinen eigenen Status.
|
|
84
|
-
return writeFailure(
|
|
85
|
-
new UnprocessableError("cannot_process_deletion", {
|
|
86
|
-
details: { reason: "cannot_process_deletion" },
|
|
87
|
-
}),
|
|
88
|
-
);
|
|
82
|
+
return writeFailure(new UnprocessableError("cannot_process_deletion"));
|
|
89
83
|
}
|
|
90
84
|
|
|
91
85
|
return {
|
|
@@ -41,7 +41,7 @@ export async function startDeletionGracePeriod(
|
|
|
41
41
|
return {
|
|
42
42
|
ok: false,
|
|
43
43
|
error: new UnprocessableError("user_not_found", {
|
|
44
|
-
details: {
|
|
44
|
+
details: { userId },
|
|
45
45
|
}),
|
|
46
46
|
};
|
|
47
47
|
}
|
|
@@ -49,7 +49,7 @@ export async function startDeletionGracePeriod(
|
|
|
49
49
|
return {
|
|
50
50
|
ok: false,
|
|
51
51
|
error: new UnprocessableError("user_not_in_active_state", {
|
|
52
|
-
details: {
|
|
52
|
+
details: { currentStatus: userRow["status"] },
|
|
53
53
|
}),
|
|
54
54
|
};
|
|
55
55
|
}
|
|
@@ -46,7 +46,7 @@ export const liftRestrictionWrite = defineWriteHandler({
|
|
|
46
46
|
if (!userRow) {
|
|
47
47
|
return writeFailure(
|
|
48
48
|
new UnprocessableError("user_not_found", {
|
|
49
|
-
details: {
|
|
49
|
+
details: { userId: targetUserId },
|
|
50
50
|
}),
|
|
51
51
|
);
|
|
52
52
|
}
|
|
@@ -55,7 +55,7 @@ export const liftRestrictionWrite = defineWriteHandler({
|
|
|
55
55
|
if (currentStatus !== USER_STATUS.Restricted) {
|
|
56
56
|
return writeFailure(
|
|
57
57
|
new UnprocessableError("not_restricted", {
|
|
58
|
-
details: {
|
|
58
|
+
details: { currentStatus },
|
|
59
59
|
}),
|
|
60
60
|
);
|
|
61
61
|
}
|
|
@@ -69,7 +69,7 @@ export const restrictAccountWrite = defineWriteHandler({
|
|
|
69
69
|
if (!userRow) {
|
|
70
70
|
return writeFailure(
|
|
71
71
|
new UnprocessableError("user_not_found", {
|
|
72
|
-
details: {
|
|
72
|
+
details: { userId: targetUserId },
|
|
73
73
|
}),
|
|
74
74
|
);
|
|
75
75
|
}
|
|
@@ -78,14 +78,14 @@ export const restrictAccountWrite = defineWriteHandler({
|
|
|
78
78
|
if (currentStatus === USER_STATUS.Restricted) {
|
|
79
79
|
return writeFailure(
|
|
80
80
|
new UnprocessableError("already_restricted", {
|
|
81
|
-
details: {
|
|
81
|
+
details: { currentStatus },
|
|
82
82
|
}),
|
|
83
83
|
);
|
|
84
84
|
}
|
|
85
85
|
if (currentStatus !== USER_STATUS.Active) {
|
|
86
86
|
return writeFailure(
|
|
87
87
|
new UnprocessableError("user_not_in_active_state", {
|
|
88
|
-
details: {
|
|
88
|
+
details: { currentStatus },
|
|
89
89
|
}),
|
|
90
90
|
);
|
|
91
91
|
}
|
|
@@ -132,15 +132,19 @@ async function waitForMount(view: ReturnType<typeof render>): Promise<void> {
|
|
|
132
132
|
});
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
/** Export status is a second async query — mount alone
|
|
136
|
-
async function
|
|
135
|
+
/** Export status is a second async query — mount alone races status-dependent UI. */
|
|
136
|
+
async function waitForTestId(view: ReturnType<typeof render>, testId: string): Promise<void> {
|
|
137
137
|
await waitFor(() => {
|
|
138
|
-
if (view.queryByTestId(
|
|
139
|
-
throw new Error(
|
|
138
|
+
if (view.queryByTestId(testId) === null) {
|
|
139
|
+
throw new Error(`${testId} not ready`);
|
|
140
140
|
}
|
|
141
141
|
});
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
async function waitForDownloadReady(view: ReturnType<typeof render>): Promise<void> {
|
|
145
|
+
await waitForTestId(view, "privacy-export-download");
|
|
146
|
+
}
|
|
147
|
+
|
|
144
148
|
// CI runs this file in its own `bun test` process (own ci.yml step), NOT in the
|
|
145
149
|
// shared `kumiko check` run — see bunfig.ci.toml pathIgnorePatterns. In the shared
|
|
146
150
|
// single-process happy-dom, the global `afterEach` from `test-setup/dom.preload.ts`
|
|
@@ -180,7 +184,7 @@ describe("PrivacyCenterScreen", () => {
|
|
|
180
184
|
me: activeMe,
|
|
181
185
|
exportStatus: { hasJob: true, job: { id: "job-9", status: EXPORT_JOB_STATUS.Failed } },
|
|
182
186
|
});
|
|
183
|
-
await
|
|
187
|
+
await waitForTestId(view, "privacy-export-failed");
|
|
184
188
|
expect(view.getByTestId("privacy-export-failed")).toBeTruthy();
|
|
185
189
|
expect(view.getByTestId("privacy-export-request")).toBeTruthy();
|
|
186
190
|
});
|
|
@@ -190,7 +194,7 @@ describe("PrivacyCenterScreen", () => {
|
|
|
190
194
|
me: activeMe,
|
|
191
195
|
exportStatus: { hasJob: true, job: { id: "job-1", status: EXPORT_JOB_STATUS.Pending } },
|
|
192
196
|
});
|
|
193
|
-
await
|
|
197
|
+
await waitForTestId(view, "privacy-export-pending");
|
|
194
198
|
expect(view.getByTestId("privacy-export-pending")).toBeTruthy();
|
|
195
199
|
expect(view.queryByTestId("privacy-export-request")).toBeNull();
|
|
196
200
|
});
|
package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts
CHANGED
|
@@ -13,7 +13,12 @@
|
|
|
13
13
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
14
14
|
import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
15
15
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
16
|
-
import {
|
|
16
|
+
import { variantSuffix } from "@cosmicdrift/kumiko-framework/derivatives";
|
|
17
|
+
import {
|
|
18
|
+
createInMemoryFileProvider,
|
|
19
|
+
deriveKey,
|
|
20
|
+
fileRefsTable,
|
|
21
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
17
22
|
import {
|
|
18
23
|
setupTestStack,
|
|
19
24
|
type TestStack,
|
|
@@ -357,3 +362,111 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
|
|
|
357
362
|
expect(afterSecond).toHaveLength(0);
|
|
358
363
|
});
|
|
359
364
|
});
|
|
365
|
+
|
|
366
|
+
describe("S2.H2 :: fileRefDeleteHook — GDPR derivatives survive forget (issue #2461)", () => {
|
|
367
|
+
async function seedFileRefWithKey(
|
|
368
|
+
id: string,
|
|
369
|
+
tenantId: string,
|
|
370
|
+
insertedById: string,
|
|
371
|
+
storageKey: string,
|
|
372
|
+
): Promise<void> {
|
|
373
|
+
await asRawClient(stack.db).unsafe(
|
|
374
|
+
`
|
|
375
|
+
INSERT INTO file_refs (id, tenant_id, storage_key, file_name, mime_type, size, inserted_by_id)
|
|
376
|
+
VALUES ($1, $2, $3, 'photo.jpg', 'image/jpeg', 2048, $4)
|
|
377
|
+
ON CONFLICT (id) DO NOTHING
|
|
378
|
+
`,
|
|
379
|
+
[id, tenantId, storageKey, insertedById],
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
test('strategy="delete" removes the original AND its derivatives, but spares an unrelated same-prefix sibling', async () => {
|
|
384
|
+
const userId = "user-derivatives-files";
|
|
385
|
+
const originalKey = `derivatives-test/${uuid(501)}.jpg`;
|
|
386
|
+
const derivedKey = deriveKey(originalKey, variantSuffix("thumb", { maxEdge: 256 }));
|
|
387
|
+
// Same base as originalKey (only the extension differs) — a plain
|
|
388
|
+
// prefix-delete would sweep this up too; the grammar-anchored filter
|
|
389
|
+
// must not.
|
|
390
|
+
const siblingKey = `derivatives-test/${uuid(501)}.png`;
|
|
391
|
+
|
|
392
|
+
const provider = createInMemoryFileProvider();
|
|
393
|
+
await provider.write(originalKey, new Uint8Array([1]));
|
|
394
|
+
await provider.write(derivedKey, new Uint8Array([2]));
|
|
395
|
+
await provider.write(siblingKey, new Uint8Array([3]));
|
|
396
|
+
|
|
397
|
+
await seedFileRefWithKey(uuid(501), TENANT_A, userId, originalKey);
|
|
398
|
+
|
|
399
|
+
await fileRefDeleteHook(
|
|
400
|
+
{
|
|
401
|
+
db: stack.db,
|
|
402
|
+
registry: stack.registry,
|
|
403
|
+
tenantId: TENANT_A,
|
|
404
|
+
userId,
|
|
405
|
+
buildStorageProvider: async () => provider,
|
|
406
|
+
},
|
|
407
|
+
"delete",
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
expect(await provider.exists(originalKey)).toBe(false);
|
|
411
|
+
expect(await provider.exists(derivedKey)).toBe(false);
|
|
412
|
+
expect(await provider.exists(siblingKey)).toBe(true);
|
|
413
|
+
|
|
414
|
+
const remaining = await fetchFileRefs(TENANT_A, userId);
|
|
415
|
+
expect(remaining).toHaveLength(0);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test('strategy="anonymize" leaves original AND derivative binaries untouched', async () => {
|
|
419
|
+
const userId = "user-anon-derivatives-files";
|
|
420
|
+
const originalKey = `derivatives-test/${uuid(502)}.jpg`;
|
|
421
|
+
const derivedKey = deriveKey(originalKey, variantSuffix("card", { maxEdge: 1024 }));
|
|
422
|
+
|
|
423
|
+
const provider = createInMemoryFileProvider();
|
|
424
|
+
await provider.write(originalKey, new Uint8Array([1]));
|
|
425
|
+
await provider.write(derivedKey, new Uint8Array([2]));
|
|
426
|
+
|
|
427
|
+
await seedFileRefWithKey(uuid(502), TENANT_A, userId, originalKey);
|
|
428
|
+
|
|
429
|
+
await fileRefDeleteHook(
|
|
430
|
+
{
|
|
431
|
+
db: stack.db,
|
|
432
|
+
registry: stack.registry,
|
|
433
|
+
tenantId: TENANT_A,
|
|
434
|
+
userId,
|
|
435
|
+
buildStorageProvider: async () => provider,
|
|
436
|
+
},
|
|
437
|
+
"anonymize",
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
expect(await provider.exists(originalKey)).toBe(true);
|
|
441
|
+
expect(await provider.exists(derivedKey)).toBe(true);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("a provider.list() failure (e.g. missing s3:ListBucket) throws a wrapped, actionable error instead of a raw provider error", async () => {
|
|
445
|
+
const userId = "user-list-fails-derivatives";
|
|
446
|
+
const originalKey = `derivatives-test/${uuid(503)}.jpg`;
|
|
447
|
+
|
|
448
|
+
const base = createInMemoryFileProvider();
|
|
449
|
+
await base.write(originalKey, new Uint8Array([1]));
|
|
450
|
+
const provider = {
|
|
451
|
+
...base,
|
|
452
|
+
list: async () => {
|
|
453
|
+
throw new Error("AccessDenied");
|
|
454
|
+
},
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
await seedFileRefWithKey(uuid(503), TENANT_A, userId, originalKey);
|
|
458
|
+
|
|
459
|
+
await expect(
|
|
460
|
+
fileRefDeleteHook(
|
|
461
|
+
{
|
|
462
|
+
db: stack.db,
|
|
463
|
+
registry: stack.registry,
|
|
464
|
+
tenantId: TENANT_A,
|
|
465
|
+
userId,
|
|
466
|
+
buildStorageProvider: async () => provider,
|
|
467
|
+
},
|
|
468
|
+
"delete",
|
|
469
|
+
),
|
|
470
|
+
).rejects.toThrow(/list permission/);
|
|
471
|
+
});
|
|
472
|
+
});
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
createTenantDb,
|
|
5
5
|
type TenantDb,
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
7
|
+
import { derivativeListPrefix, isDerivativeKeyOf } from "@cosmicdrift/kumiko-framework/derivatives";
|
|
7
8
|
import {
|
|
8
9
|
createSystemUser,
|
|
9
10
|
type SessionUser,
|
|
@@ -12,7 +13,11 @@ import {
|
|
|
12
13
|
type UserDataHookCtx,
|
|
13
14
|
type UserDataStorageProvider,
|
|
14
15
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
15
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
assertSafeStorageKey,
|
|
18
|
+
fileRefEntity,
|
|
19
|
+
fileRefsTable,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
16
21
|
|
|
17
22
|
// Forget writes go through the executor (events), not deleteMany/updateMany:
|
|
18
23
|
// a projection rebuild replays the events, so the erasure survives. Eventless
|
|
@@ -123,9 +128,52 @@ async function resolveProvider(ctx: UserDataHookCtx): Promise<UserDataStoragePro
|
|
|
123
128
|
}
|
|
124
129
|
}
|
|
125
130
|
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
131
|
+
// Derivatives (thumbnails/resized variants — see derivatives-context.ts) are
|
|
132
|
+
// never tracked anywhere but the storage layer: they're rendered under a
|
|
133
|
+
// deterministic key (deriveKey + variantSuffix) computed from the original's
|
|
134
|
+
// key + a render spec, on demand, and nothing writes that key back onto the
|
|
135
|
+
// FileRef row. List the original's key-prefix and keep only candidates whose
|
|
136
|
+
// suffix matches the derivative grammar, anchored to the original's own
|
|
137
|
+
// basename AND extension — a same-directory sibling original (a different
|
|
138
|
+
// file, possibly another user's) shares the same list prefix but fails the
|
|
139
|
+
// extension/suffix check and is never touched.
|
|
140
|
+
async function listDerivativeKeys(
|
|
141
|
+
originalKey: string,
|
|
142
|
+
provider: UserDataStorageProvider,
|
|
143
|
+
): Promise<readonly string[]> {
|
|
144
|
+
let candidates: readonly string[];
|
|
145
|
+
try {
|
|
146
|
+
candidates = await provider.list(derivativeListPrefix(originalKey));
|
|
147
|
+
} catch (err) {
|
|
148
|
+
// Wrap rather than let a raw provider error (e.g. S3 AccessDenied) surface
|
|
149
|
+
// unexplained — list() is a NEW requirement this hook added on top of
|
|
150
|
+
// delete(); a bucket policy granting only s3:DeleteObject now fails forget
|
|
151
|
+
// every run instead of just leaving derivatives behind. Name the likely
|
|
152
|
+
// cause so an operator doesn't have to guess.
|
|
153
|
+
throw new Error(
|
|
154
|
+
`[user-data-rights-defaults:fileRef] provider.list() failed while looking up derivatives of key=${originalKey} — forget/erasure requires list permission on the storage bucket (e.g. s3:ListBucket), not just delete. Original error: ${err instanceof Error ? err.message : String(err)}`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
const derivativeKeys: string[] = [];
|
|
158
|
+
for (const candidate of candidates) {
|
|
159
|
+
if (!isDerivativeKeyOf(originalKey, candidate)) continue;
|
|
160
|
+
try {
|
|
161
|
+
assertSafeStorageKey(candidate);
|
|
162
|
+
} catch {
|
|
163
|
+
// ponytail: a listing hit that fails the storage-key grammar can't be
|
|
164
|
+
// a real derivative (every derivative is written via a validated
|
|
165
|
+
// write()) — skip rather than pass a malformed key to delete().
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
derivativeKeys.push(candidate);
|
|
169
|
+
}
|
|
170
|
+
return derivativeKeys;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Delete every row's binary (original + any derivatives) via the provider.
|
|
174
|
+
// Returns the keys whose delete threw — the caller fails closed on a
|
|
175
|
+
// non-empty list so the sub-tx rolls back and the next forget run retries
|
|
176
|
+
// (delete is idempotent → converges).
|
|
129
177
|
async function deleteBinaries(
|
|
130
178
|
rows: readonly Record<string, unknown>[],
|
|
131
179
|
provider: UserDataStorageProvider,
|
|
@@ -134,14 +182,17 @@ async function deleteBinaries(
|
|
|
134
182
|
for (const row of rows) {
|
|
135
183
|
const key = row["storageKey"]; // @cast-boundary db-row
|
|
136
184
|
if (typeof key !== "string" || key.length === 0) continue;
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
185
|
+
const keysToDelete = [key, ...(await listDerivativeKeys(key, provider))];
|
|
186
|
+
for (const k of keysToDelete) {
|
|
187
|
+
try {
|
|
188
|
+
await provider.delete(k);
|
|
189
|
+
} catch (err) {
|
|
190
|
+
// biome-ignore lint/suspicious/noConsole: operator-visibility for binary-cleanup-failure
|
|
191
|
+
console.warn(
|
|
192
|
+
`[user-data-rights-defaults:fileRef] storage delete failed key=${k} err=${err instanceof Error ? err.message : String(err)}`,
|
|
193
|
+
);
|
|
194
|
+
failedKeys.push(k);
|
|
195
|
+
}
|
|
145
196
|
}
|
|
146
197
|
}
|
|
147
198
|
return failedKeys;
|