@cosmicdrift/kumiko-bundled-features 0.156.0 → 0.156.1
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 +6 -6
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +171 -0
- package/src/jobs/__tests__/jobs-events.integration.test.ts +37 -1
- package/src/sessions/__tests__/sessions.integration.test.ts +35 -0
- package/src/sessions/session-checker-fail-open.test.ts +77 -0
- package/src/user-profile/__tests__/change-email.integration.test.ts +4 -0
- package/src/user-profile/feature.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.156.
|
|
3
|
+
"version": "0.156.1",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -116,11 +116,11 @@
|
|
|
116
116
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
117
117
|
},
|
|
118
118
|
"dependencies": {
|
|
119
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.156.
|
|
120
|
-
"@cosmicdrift/kumiko-framework": "0.156.
|
|
121
|
-
"@cosmicdrift/kumiko-headless": "0.156.
|
|
122
|
-
"@cosmicdrift/kumiko-renderer": "0.156.
|
|
123
|
-
"@cosmicdrift/kumiko-renderer-web": "0.156.
|
|
119
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.156.1",
|
|
120
|
+
"@cosmicdrift/kumiko-framework": "0.156.1",
|
|
121
|
+
"@cosmicdrift/kumiko-headless": "0.156.1",
|
|
122
|
+
"@cosmicdrift/kumiko-renderer": "0.156.1",
|
|
123
|
+
"@cosmicdrift/kumiko-renderer-web": "0.156.1",
|
|
124
124
|
"@mollie/api-client": "^4.5.0",
|
|
125
125
|
"imapflow": "^1.3.3",
|
|
126
126
|
"mailparser": "^3.9.8",
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Regression guard for kumiko-framework#1187: proves the config
|
|
2
|
+
// KEK-rotation job handles a real version-N → version-N+1 rotation, not
|
|
3
|
+
// just legacy-format → envelope (see encrypted-legacy-migration.integration
|
|
4
|
+
// .test.ts for that story). Modeled directly on auth-mfa's
|
|
5
|
+
// reencrypt-job.integration.test.ts, which is the reference regression
|
|
6
|
+
// guard for this exact bug class (kumiko-framework#266 Step 8): rotation
|
|
7
|
+
// must go through executor.update() (a real event), not a raw UPDATE on
|
|
8
|
+
// the projection table — otherwise a full projection rebuild replays the
|
|
9
|
+
// OLD event and resurrects the pre-rotation kekVersion.
|
|
10
|
+
|
|
11
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
12
|
+
import { randomBytes } from "node:crypto";
|
|
13
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
14
|
+
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
15
|
+
import {
|
|
16
|
+
access,
|
|
17
|
+
createSystemConfig,
|
|
18
|
+
createSystemUser,
|
|
19
|
+
defineFeature,
|
|
20
|
+
SYSTEM_TENANT_ID,
|
|
21
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
22
|
+
import { rebuildProjection } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
23
|
+
import {
|
|
24
|
+
createEnvelopeCipher,
|
|
25
|
+
createEnvMasterKeyProvider,
|
|
26
|
+
} from "@cosmicdrift/kumiko-framework/secrets";
|
|
27
|
+
import {
|
|
28
|
+
setupTestStack,
|
|
29
|
+
type TestStack,
|
|
30
|
+
unsafePushTables,
|
|
31
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
32
|
+
import { createMutableMasterKeyProvider } from "@cosmicdrift/kumiko-framework/testing";
|
|
33
|
+
import { createConfigFeature } from "../feature";
|
|
34
|
+
import { reencryptJob } from "../handlers/reencrypt.job";
|
|
35
|
+
import { createConfigResolver } from "../resolver";
|
|
36
|
+
import { configValueEntity, configValuesTable } from "../table";
|
|
37
|
+
|
|
38
|
+
const KEY = "kek-rot:config:secret-pass";
|
|
39
|
+
const PLAINTEXT = "rotate-me-please";
|
|
40
|
+
const PROJECTION_NAME = "config:projection:config-value-entity";
|
|
41
|
+
|
|
42
|
+
const v1Key = randomBytes(32).toString("base64");
|
|
43
|
+
const v2Key = randomBytes(32).toString("base64");
|
|
44
|
+
const mutableProvider = createMutableMasterKeyProvider(
|
|
45
|
+
createEnvMasterKeyProvider({
|
|
46
|
+
env: {
|
|
47
|
+
KUMIKO_SECRETS_MASTER_KEY_V1: v1Key,
|
|
48
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
|
|
49
|
+
},
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
const cipher = createEnvelopeCipher(mutableProvider, {});
|
|
53
|
+
const resolver = createConfigResolver({ cipher });
|
|
54
|
+
|
|
55
|
+
const keyDef = createSystemConfig("text", {
|
|
56
|
+
encrypted: true,
|
|
57
|
+
read: access.systemAdmin,
|
|
58
|
+
write: access.systemAdmin,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const rotFeature = defineFeature("kek-rot", (r) => {
|
|
62
|
+
r.requires("config");
|
|
63
|
+
r.config({ keys: { "secret-pass": keyDef } });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const executor = createEventStoreExecutor(configValuesTable, configValueEntity, {
|
|
67
|
+
entityName: "config-value",
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
let stack: TestStack;
|
|
71
|
+
|
|
72
|
+
async function seedV1Row(): Promise<void> {
|
|
73
|
+
const envelope = await cipher.encrypt(JSON.stringify(PLAINTEXT), { tenantId: SYSTEM_TENANT_ID });
|
|
74
|
+
const systemUser = createSystemUser(SYSTEM_TENANT_ID);
|
|
75
|
+
const tdb = createTenantDb(stack.db, SYSTEM_TENANT_ID, "system");
|
|
76
|
+
const result = await executor.create(
|
|
77
|
+
{ key: KEY, value: envelope, tenantId: SYSTEM_TENANT_ID, userId: null },
|
|
78
|
+
systemUser,
|
|
79
|
+
tdb,
|
|
80
|
+
);
|
|
81
|
+
if (!result.isSuccess) throw new Error(`seed failed: ${result.error.code}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type RawConfigRow = { value: string };
|
|
85
|
+
|
|
86
|
+
async function readRawValue(): Promise<RawConfigRow> {
|
|
87
|
+
const rows = await selectMany<RawConfigRow>(stack.db, configValuesTable, { key: KEY });
|
|
88
|
+
const row = rows[0];
|
|
89
|
+
if (!row) throw new Error("no config row");
|
|
90
|
+
return row;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const noopLog = {
|
|
94
|
+
info: () => {},
|
|
95
|
+
warn: () => {},
|
|
96
|
+
error: () => {},
|
|
97
|
+
debug: () => {},
|
|
98
|
+
child: () => noopLog,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
function jobCtx(): Parameters<typeof reencryptJob>[1] {
|
|
102
|
+
return {
|
|
103
|
+
db: stack.db,
|
|
104
|
+
registry: stack.registry,
|
|
105
|
+
masterKeyProvider: mutableProvider,
|
|
106
|
+
configEncryption: cipher,
|
|
107
|
+
log: noopLog,
|
|
108
|
+
} as unknown as Parameters<typeof reencryptJob>[1]; // @cast-boundary test-seam — job only reads db/registry/masterKeyProvider/configEncryption/log
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
beforeAll(async () => {
|
|
112
|
+
stack = await setupTestStack({
|
|
113
|
+
features: [createConfigFeature(), rotFeature],
|
|
114
|
+
masterKeyProvider: mutableProvider,
|
|
115
|
+
extraContext: { configResolver: resolver, configEncryption: cipher },
|
|
116
|
+
});
|
|
117
|
+
await unsafePushTables(stack.db, { configValuesTable });
|
|
118
|
+
await seedV1Row();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
afterAll(async () => {
|
|
122
|
+
await stack.cleanup();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("config KEK-rotation job — version-N to version-N+1 (kumiko-framework#1187)", () => {
|
|
126
|
+
test("rotation rewraps the value onto the current KEK, and a full projection rebuild still lands on it", async () => {
|
|
127
|
+
const beforeRow = await readRawValue();
|
|
128
|
+
expect(JSON.parse(beforeRow.value).kekVersion).toBe(1);
|
|
129
|
+
|
|
130
|
+
// "ops added a new master key version and flipped CURRENT=2" —
|
|
131
|
+
// same simulate-rotation-without-restart shape auth-mfa's test uses.
|
|
132
|
+
mutableProvider.replace(
|
|
133
|
+
createEnvMasterKeyProvider({
|
|
134
|
+
env: {
|
|
135
|
+
KUMIKO_SECRETS_MASTER_KEY_V1: v1Key,
|
|
136
|
+
KUMIKO_SECRETS_MASTER_KEY_V2: v2Key,
|
|
137
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "2",
|
|
138
|
+
},
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
await reencryptJob({}, jobCtx());
|
|
143
|
+
|
|
144
|
+
const afterJobRow = await readRawValue();
|
|
145
|
+
expect(JSON.parse(afterJobRow.value).kekVersion).toBe(2);
|
|
146
|
+
|
|
147
|
+
// Vacuous-rebuild guard: confirm the projection name actually exists
|
|
148
|
+
// before trusting a silent no-op rebuild as a pass.
|
|
149
|
+
expect(stack.registry.getAllProjections().has(PROJECTION_NAME)).toBe(true);
|
|
150
|
+
|
|
151
|
+
const rebuildResult = await rebuildProjection(PROJECTION_NAME, {
|
|
152
|
+
db: stack.db,
|
|
153
|
+
registry: stack.registry,
|
|
154
|
+
});
|
|
155
|
+
expect(rebuildResult.eventsProcessed).toBeGreaterThan(0);
|
|
156
|
+
|
|
157
|
+
// The regression guard itself: a from-scratch rebuild replays every
|
|
158
|
+
// event for this row and must land on the V2 envelope from the
|
|
159
|
+
// rotation job's own .updated event — NOT a resurrected V1 wrap from
|
|
160
|
+
// the original seed event. Kept alive with the V1 key still present
|
|
161
|
+
// in the env on purpose: a resurrected V1 row would still decrypt
|
|
162
|
+
// cleanly, so only this version-tag check (not the decrypt below)
|
|
163
|
+
// catches the regression this issue describes.
|
|
164
|
+
const afterRebuildRow = await readRawValue();
|
|
165
|
+
expect(JSON.parse(afterRebuildRow.value).kekVersion).toBe(2);
|
|
166
|
+
|
|
167
|
+
// Decrypt-level proof, not just the version tag: the plaintext
|
|
168
|
+
// survived rotation + rebuild byte-identically.
|
|
169
|
+
expect(await resolver.get(KEY, keyDef, SYSTEM_TENANT_ID, "u1", stack.db)).toBe(PLAINTEXT);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -32,12 +32,13 @@ import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
|
|
|
32
32
|
|
|
33
33
|
let testDb: TestDb;
|
|
34
34
|
let testRedis: TestRedis;
|
|
35
|
+
let registry: ReturnType<typeof createRegistry>;
|
|
35
36
|
let logger: ReturnType<typeof createJobRunLogger>;
|
|
36
37
|
|
|
37
38
|
beforeAll(async () => {
|
|
38
39
|
testDb = await createTestDb();
|
|
39
40
|
testRedis = await createTestRedis();
|
|
40
|
-
|
|
41
|
+
registry = createRegistry([createJobsFeature()]);
|
|
41
42
|
await unsafePushTables(testDb.db, { jobRunsTable, jobRunLogsTable });
|
|
42
43
|
await createEventsTable(testDb.db);
|
|
43
44
|
logger = createJobRunLogger({ db: testDb.db, registry });
|
|
@@ -127,4 +128,39 @@ describe("jobRun event shapes", () => {
|
|
|
127
128
|
const ids = new Set(events.map((e) => e.aggregateId));
|
|
128
129
|
expect(ids.size).toBe(1);
|
|
129
130
|
});
|
|
131
|
+
|
|
132
|
+
test("complete/fail without a prior start skips — does not forge a jobRun stream", async () => {
|
|
133
|
+
// State-loss path: worker restart with empty cache AND no projection row for
|
|
134
|
+
// this bullJobId. Dropping the terminal event is intentional — forging an
|
|
135
|
+
// aggregate from scratch would invent a run that never started.
|
|
136
|
+
await logger.onJobComplete?.("example:job:orphan", "bull-orphan-complete", 50, []);
|
|
137
|
+
await logger.onJobFailed?.("example:job:orphan", "bull-orphan-failed", "boom", []);
|
|
138
|
+
|
|
139
|
+
const completed = await selectMany(testDb.db, eventsTable, { type: JOB_RUN_COMPLETED_EVENT });
|
|
140
|
+
const failed = await selectMany(testDb.db, eventsTable, { type: JOB_RUN_FAILED_EVENT });
|
|
141
|
+
const runs = await selectMany(testDb.db, jobRunsTable);
|
|
142
|
+
|
|
143
|
+
expect(completed).toHaveLength(0);
|
|
144
|
+
expect(failed).toHaveLength(0);
|
|
145
|
+
expect(runs).toHaveLength(0);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("complete after cache loss recovers runId from the projection (same stream)", async () => {
|
|
149
|
+
// Simulates worker process restart: in-memory bullJobId→runId cache is gone,
|
|
150
|
+
// but the run-started projection row still has bull_job_id. A fresh logger
|
|
151
|
+
// must DB-lookup and append onto the original aggregate — not mint a second.
|
|
152
|
+
await logger.onJobStart?.("example:job:restart", "bull-restart-1", {});
|
|
153
|
+
const started = await selectMany(testDb.db, eventsTable, { type: JOB_RUN_STARTED_EVENT });
|
|
154
|
+
expect(started).toHaveLength(1);
|
|
155
|
+
const originalAggregateId = started[0]?.aggregateId;
|
|
156
|
+
expect(originalAggregateId).toBeTruthy();
|
|
157
|
+
|
|
158
|
+
const coldLogger = createJobRunLogger({ db: testDb.db, registry });
|
|
159
|
+
await coldLogger.onJobComplete?.("example:job:restart", "bull-restart-1", 42, []);
|
|
160
|
+
|
|
161
|
+
const all = await selectMany(testDb.db, eventsTable, { aggregateType: "jobRun" });
|
|
162
|
+
expect(all).toHaveLength(2);
|
|
163
|
+
expect(new Set(all.map((e) => e.aggregateId))).toEqual(new Set([originalAggregateId]));
|
|
164
|
+
expect(all.some((e) => e.type === JOB_RUN_COMPLETED_EVENT)).toBe(true);
|
|
165
|
+
});
|
|
130
166
|
});
|
|
@@ -391,6 +391,41 @@ describe("sessions feature — login → check → revoke → rejected", () => {
|
|
|
391
391
|
expect(body.error?.details?.reason).toBe("missing");
|
|
392
392
|
});
|
|
393
393
|
|
|
394
|
+
// Cross-user check: a valid JWT for Alice paired with Bob's live sid must
|
|
395
|
+
// look identical to a forged/missing sid — otherwise the checker would be
|
|
396
|
+
// an existence oracle on other users' sessions.
|
|
397
|
+
test("sid belonging to another user → 401 reason=missing (no existence oracle)", async () => {
|
|
398
|
+
const alice = await h.seedUser("alice-xuser@example.com", "pw-long-enough");
|
|
399
|
+
const bob = await h.seedUser("bob-xuser@example.com", "pw-long-enough");
|
|
400
|
+
const bobLogin = await h.login("bob-xuser@example.com", "pw-long-enough");
|
|
401
|
+
|
|
402
|
+
// Direct invariant: Bob's sid + Alice's userId → missing (not live/revoked).
|
|
403
|
+
expect(await callbacks.get().sessionChecker(bobLogin.sid, alice.userId)).toBe("missing");
|
|
404
|
+
expect(await callbacks.get().sessionChecker(bobLogin.sid, bob.userId)).toBe("live");
|
|
405
|
+
|
|
406
|
+
// HTTP path: mint Alice's identity onto Bob's sid (stolen-sid scenario).
|
|
407
|
+
const forged = await stack.jwt.sign({
|
|
408
|
+
id: alice.userId,
|
|
409
|
+
tenantId: TENANT,
|
|
410
|
+
roles: ["User"],
|
|
411
|
+
sid: bobLogin.sid,
|
|
412
|
+
});
|
|
413
|
+
const res = await h.authedPost("/api/query", forged, {
|
|
414
|
+
type: "user:query:user:me",
|
|
415
|
+
payload: {},
|
|
416
|
+
});
|
|
417
|
+
expect(res.status).toBe(401);
|
|
418
|
+
const body = (await res.json()) as { error?: { details?: { reason?: string } } };
|
|
419
|
+
expect(body.error?.details?.reason).toBe("missing");
|
|
420
|
+
|
|
421
|
+
// Control: Bob's own JWT still authenticates — the sid itself is fine.
|
|
422
|
+
const bobOk = await h.authedPost("/api/query", bobLogin.token, {
|
|
423
|
+
type: "user:query:user:me",
|
|
424
|
+
payload: {},
|
|
425
|
+
});
|
|
426
|
+
expect(bobOk.status).toBe(200);
|
|
427
|
+
});
|
|
428
|
+
|
|
394
429
|
test("expired session row → 401 with reason=expired", async () => {
|
|
395
430
|
await h.seedUser("stale@example.com", "pw-long-enough");
|
|
396
431
|
const { token, sid } = await h.login("stale@example.com", "pw-long-enough");
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import * as bunDb from "@cosmicdrift/kumiko-framework/bun-db";
|
|
3
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
4
|
+
import { Temporal } from "temporal-polyfill";
|
|
5
|
+
import { USER_STATUS, userTable } from "../user/schema/user";
|
|
6
|
+
import { userSessionTable } from "./schema/user-session";
|
|
7
|
+
import { createSessionCallbacks } from "./session-callbacks";
|
|
8
|
+
|
|
9
|
+
// Unit-only: the fail-open-on-throw branch cannot be provoked through real
|
|
10
|
+
// Postgres without faking infrastructure failure. Integration tests forbid
|
|
11
|
+
// mocks, so this lives next to the source as a plain *.test.ts.
|
|
12
|
+
|
|
13
|
+
type FetchOne = typeof bunDb.fetchOne;
|
|
14
|
+
|
|
15
|
+
describe("sessionChecker fail-open on user-lookup throw", () => {
|
|
16
|
+
test("read_users THROW → live (not 500 / not blocked)", async () => {
|
|
17
|
+
const db = {} as DbConnection;
|
|
18
|
+
const cbs = createSessionCallbacks({ db });
|
|
19
|
+
const sid = "00000000-0000-4000-8000-00000000sid1";
|
|
20
|
+
const userId = "00000000-0000-4000-8000-00000000user";
|
|
21
|
+
const farFutureMs = Temporal.Now.instant().add({ hours: 1 }).epochMilliseconds;
|
|
22
|
+
|
|
23
|
+
const originalFetchOne = bunDb.fetchOne;
|
|
24
|
+
const spy = spyOn(bunDb, "fetchOne").mockImplementation((async (_db, table) => {
|
|
25
|
+
if (table === userSessionTable) {
|
|
26
|
+
return {
|
|
27
|
+
userId,
|
|
28
|
+
revokedAt: null,
|
|
29
|
+
expiresAt: { epochMilliseconds: farFutureMs },
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
if (table === userTable) {
|
|
33
|
+
throw new Error("simulated pool exhaustion");
|
|
34
|
+
}
|
|
35
|
+
throw new Error("unexpected table in sessionChecker spy");
|
|
36
|
+
}) as FetchOne);
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
expect(await cbs.sessionChecker(sid, userId)).toBe("live");
|
|
40
|
+
} finally {
|
|
41
|
+
spy.mockRestore();
|
|
42
|
+
// Sanity: restore must put the real export back (guards against spy leak).
|
|
43
|
+
expect(bunDb.fetchOne).toBe(originalFetchOne);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("control: Restricted user without throw → blocked (spy must hit userTable)", async () => {
|
|
48
|
+
// If the spy somehow skipped the userTable branch, Restricted would still
|
|
49
|
+
// be "live" via fail-open-on-null — this control pins that the throw path
|
|
50
|
+
// is what we exercise above, not an accidental miss.
|
|
51
|
+
const db = {} as DbConnection;
|
|
52
|
+
const cbs = createSessionCallbacks({ db });
|
|
53
|
+
const sid = "00000000-0000-4000-8000-00000000sid2";
|
|
54
|
+
const userId = "00000000-0000-4000-8000-00000000usr2";
|
|
55
|
+
const farFutureMs = Temporal.Now.instant().add({ hours: 1 }).epochMilliseconds;
|
|
56
|
+
|
|
57
|
+
const spy = spyOn(bunDb, "fetchOne").mockImplementation((async (_db, table) => {
|
|
58
|
+
if (table === userSessionTable) {
|
|
59
|
+
return {
|
|
60
|
+
userId,
|
|
61
|
+
revokedAt: null,
|
|
62
|
+
expiresAt: { epochMilliseconds: farFutureMs },
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (table === userTable) {
|
|
66
|
+
return { status: USER_STATUS.Restricted };
|
|
67
|
+
}
|
|
68
|
+
throw new Error("unexpected table in sessionChecker spy");
|
|
69
|
+
}) as FetchOne);
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
expect(await cbs.sessionChecker(sid, userId)).toBe("blocked");
|
|
73
|
+
} finally {
|
|
74
|
+
spy.mockRestore();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
import { createConfigFeature } from "../../config";
|
|
27
27
|
import { configValuesTable } from "../../config/table";
|
|
28
28
|
import { createDataRetentionFeature } from "../../data-retention";
|
|
29
|
+
import { createFilesFeature } from "../../files";
|
|
29
30
|
import { createSessionsFeature } from "../../sessions";
|
|
30
31
|
import { hashPassword } from "../../shared";
|
|
31
32
|
import { createTenantFeature } from "../../tenant";
|
|
@@ -36,6 +37,7 @@ import { UserErrors, UserHandlers, UserQueries } from "../../user";
|
|
|
36
37
|
import { createUserFeature } from "../../user/feature";
|
|
37
38
|
import { userEntity, userTable } from "../../user/schema/user";
|
|
38
39
|
import { createUserDataRightsFeature } from "../../user-data-rights";
|
|
40
|
+
import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
|
|
39
41
|
import {
|
|
40
42
|
UserDataRightsHandlers,
|
|
41
43
|
UserProfileErrors,
|
|
@@ -59,7 +61,9 @@ beforeAll(async () => {
|
|
|
59
61
|
createDataRetentionFeature(),
|
|
60
62
|
createComplianceProfilesFeature(),
|
|
61
63
|
createSessionsFeature(),
|
|
64
|
+
createFilesFeature(),
|
|
62
65
|
createUserDataRightsFeature(),
|
|
66
|
+
createUserDataRightsDefaultsFeature(),
|
|
63
67
|
createUserProfileFeature(),
|
|
64
68
|
],
|
|
65
69
|
authConfig: {
|
|
@@ -11,7 +11,9 @@ export function createUserProfileFeature(): FeatureDefinition {
|
|
|
11
11
|
"change-email and account deletion (user-data-rights request/cancel with " +
|
|
12
12
|
"grace period) into one screen. Apps register the screen as " +
|
|
13
13
|
'`type: "custom"` with `__component: "UserProfileScreen"`. Requires `user`, ' +
|
|
14
|
-
"`auth-email-password`, and `user-data-rights
|
|
14
|
+
"`auth-email-password`, `user-data-rights`, and `user-data-rights-defaults` " +
|
|
15
|
+
"(so the GDPR boot-validator finds export/delete hooks for `user`'s PII " +
|
|
16
|
+
"fields once user-data-rights is mounted).",
|
|
15
17
|
);
|
|
16
18
|
r.uiHints({
|
|
17
19
|
displayLabel: "User Profile · Self-Service",
|
|
@@ -21,6 +23,7 @@ export function createUserProfileFeature(): FeatureDefinition {
|
|
|
21
23
|
r.requires("user");
|
|
22
24
|
r.requires("auth-email-password");
|
|
23
25
|
r.requires("user-data-rights");
|
|
26
|
+
r.requires("user-data-rights-defaults");
|
|
24
27
|
|
|
25
28
|
const handlers = {
|
|
26
29
|
changeEmail: r.writeHandler(changeEmailWrite),
|