@cosmicdrift/kumiko-bundled-features 0.174.0 → 0.174.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 +7 -7
- package/src/auth-email-password/__tests__/login-gates.test.ts +12 -6
- package/src/auth-email-password/seeding.ts +4 -2
- package/src/auth-mfa/__tests__/token-secrets.test.ts +19 -0
- package/src/auth-mfa/handlers/reencrypt.job.ts +11 -20
- package/src/auth-mfa/token-secrets.ts +16 -14
- package/src/billing-foundation/subscription-tier-sync.ts +8 -1
- package/src/cap-counter/__tests__/stock-cap-guard.integration.test.ts +98 -0
- package/src/cap-counter/stock-cap-guard.ts +1 -1
- package/src/config/handlers/reencrypt.job.ts +7 -18
- package/src/delivery/delivery-service.ts +1 -1
- package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +47 -11
- package/src/document-ingest-foundation/__tests__/feature.test.ts +6 -2
- package/src/document-ingest-foundation/events.ts +15 -0
- package/src/document-ingest-foundation/feature.ts +27 -2
- package/src/inbound-mail-foundation/watch-supervisor.ts +11 -2
- package/src/jobs/handlers/catalog.query.ts +1 -4
- package/src/jobs/handlers/trigger.write.ts +2 -1
- package/src/jobs/is-manual-trigger.ts +5 -0
- package/src/managed-pages/screens/branding-screen.ts +1 -1
- package/src/presets/dsgvo-self-service.ts +10 -10
- package/src/sessions/handlers/revoke-all-for-user.write.ts +10 -1
- package/src/shared/classify-stored-envelope.ts +21 -0
- package/src/shared/index.ts +4 -0
- package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +9 -0
- package/src/tenant-settings/tenant-defaults.ts +3 -0
- package/src/tier-engine/compose-app.ts +2 -13
- package/src/user/__tests__/seed-testing.integration.test.ts +25 -0
- package/src/user/seeding.ts +25 -1
- package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +1 -1
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +2 -2
- package/src/user-data-rights/__tests__/request-deletion-url.test.ts +11 -8
- package/src/user-data-rights/feature.ts +3 -2
- package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +8 -6
- package/src/user-data-rights/web/__tests__/deletion-screens.test.tsx +5 -5
- package/src/user-data-rights/web/confirm-deletion-screen.tsx +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.174.
|
|
3
|
+
"version": "0.174.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>",
|
|
@@ -122,12 +122,12 @@
|
|
|
122
122
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
123
123
|
},
|
|
124
124
|
"dependencies": {
|
|
125
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.174.
|
|
126
|
-
"@cosmicdrift/kumiko-framework": "0.174.
|
|
127
|
-
"@cosmicdrift/kumiko-headless": "0.174.
|
|
128
|
-
"@cosmicdrift/kumiko-renderer": "0.174.
|
|
129
|
-
"@cosmicdrift/kumiko-renderer-web": "0.174.
|
|
130
|
-
"@cosmicdrift/kumiko-types": "0.174.
|
|
125
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.174.1",
|
|
126
|
+
"@cosmicdrift/kumiko-framework": "0.174.1",
|
|
127
|
+
"@cosmicdrift/kumiko-headless": "0.174.1",
|
|
128
|
+
"@cosmicdrift/kumiko-renderer": "0.174.1",
|
|
129
|
+
"@cosmicdrift/kumiko-renderer-web": "0.174.1",
|
|
130
|
+
"@cosmicdrift/kumiko-types": "0.174.1",
|
|
131
131
|
"@mollie/api-client": "^4.5.0",
|
|
132
132
|
"imapflow": "^1.3.3",
|
|
133
133
|
"mailparser": "^3.9.8",
|
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { USER_STATUS } from "../../user";
|
|
4
4
|
import type { AuthUserRow } from "../auth-user-row";
|
|
5
|
+
import { accountRestricted, emailNotVerified, invalidCredentials } from "../errors";
|
|
5
6
|
import {
|
|
6
7
|
gateBuildSession,
|
|
7
8
|
gateEnforceAccountStatus,
|
|
@@ -21,7 +22,7 @@ describe("login.write gates (fw#1284)", () => {
|
|
|
21
22
|
test("gateEnforceEmailVerified rejects when strict and unverified", () => {
|
|
22
23
|
const g = gateEnforceEmailVerified(row({ emailVerified: false }), true);
|
|
23
24
|
expect(g.ok).toBe(false);
|
|
24
|
-
if (!g.ok) expect(g.result
|
|
25
|
+
if (!g.ok) expect(g.result).toEqual(emailNotVerified());
|
|
25
26
|
});
|
|
26
27
|
|
|
27
28
|
test("gateEnforceEmailVerified passes when not strict", () => {
|
|
@@ -31,16 +32,21 @@ describe("login.write gates (fw#1284)", () => {
|
|
|
31
32
|
test("gateEnforceAccountStatus rejects restricted", () => {
|
|
32
33
|
const g = gateEnforceAccountStatus(row({ status: USER_STATUS.Restricted }));
|
|
33
34
|
expect(g.ok).toBe(false);
|
|
35
|
+
if (!g.ok) expect(g.result).toEqual(accountRestricted());
|
|
34
36
|
});
|
|
35
37
|
|
|
36
38
|
test("gateEnforceAccountStatus rejects deletion_requested as invalid_creds shape", () => {
|
|
37
39
|
const g = gateEnforceAccountStatus(row({ status: USER_STATUS.DeletionRequested }));
|
|
38
40
|
expect(g.ok).toBe(false);
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
// anti-enumeration — same payload as invalid credentials, not a
|
|
42
|
+
// distinguishable "account deleted" error.
|
|
43
|
+
if (!g.ok) expect(g.result).toEqual(invalidCredentials());
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("gateEnforceAccountStatus rejects deleted as invalid_creds shape", () => {
|
|
47
|
+
const g = gateEnforceAccountStatus(row({ status: USER_STATUS.Deleted }));
|
|
48
|
+
expect(g.ok).toBe(false);
|
|
49
|
+
if (!g.ok) expect(g.result).toEqual(invalidCredentials());
|
|
44
50
|
});
|
|
45
51
|
|
|
46
52
|
test("gateEnforceAccountStatus passes active", () => {
|
|
@@ -190,8 +190,10 @@ export type SeedAdminOptions = {
|
|
|
190
190
|
* membership-Rollen. Typischer use-case: `["SystemAdmin"]` für
|
|
191
191
|
* einen Plattform-Operator. Default: leer. */
|
|
192
192
|
readonly globalRoles?: readonly string[];
|
|
193
|
-
/** Initial
|
|
194
|
-
*
|
|
193
|
+
/** Initial emailVerified state — defaults to false (unverified). Same
|
|
194
|
+
* mechanism as seedUserWithPassword.emailVerified. Re-running the seed
|
|
195
|
+
* against an already-seeded row reconciles this flag too (true → sets
|
|
196
|
+
* it via a real `.updated` event; never flips true → false). */
|
|
195
197
|
readonly emailVerified?: boolean;
|
|
196
198
|
readonly by?: SessionUser;
|
|
197
199
|
};
|
|
@@ -30,6 +30,25 @@ describe("resolveMfaTokenSecrets", () => {
|
|
|
30
30
|
);
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
+
test("an empty-string override falls back to derivation instead of an empty key", () => {
|
|
34
|
+
const resolved = resolveMfaTokenSecrets(JWT_SECRET, {
|
|
35
|
+
setupTokenSecret: "",
|
|
36
|
+
challengeTokenSecret: "",
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(resolved).toEqual(resolveMfaTokenSecrets(JWT_SECRET));
|
|
40
|
+
expect(resolved.setupTokenSecret).not.toBe("");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("a whitespace-only override falls back to derivation too", () => {
|
|
44
|
+
const resolved = resolveMfaTokenSecrets(JWT_SECRET, {
|
|
45
|
+
setupTokenSecret: " ",
|
|
46
|
+
challengeTokenSecret: "\t",
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(resolved).toEqual(resolveMfaTokenSecrets(JWT_SECRET));
|
|
50
|
+
});
|
|
51
|
+
|
|
33
52
|
test("rotating the master rotates both", () => {
|
|
34
53
|
const before = resolveMfaTokenSecrets(JWT_SECRET);
|
|
35
54
|
const after = resolveMfaTokenSecrets(`${JWT_SECRET}-rotated`);
|
|
@@ -38,8 +38,12 @@ import {
|
|
|
38
38
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
39
39
|
import type { JobHandlerFn, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
40
40
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
41
|
+
import type { EnvelopeCipher } from "@cosmicdrift/kumiko-framework/secrets";
|
|
42
|
+
import {
|
|
43
|
+
type ChunkedMigrationStopReason,
|
|
44
|
+
classifyStoredEnvelope,
|
|
45
|
+
runChunkedMigration,
|
|
46
|
+
} from "../../shared";
|
|
43
47
|
import { userMfaEntity, userMfaTable } from "../schema/user-mfa";
|
|
44
48
|
|
|
45
49
|
const DEFAULT_BATCH_SIZE = 100;
|
|
@@ -66,22 +70,6 @@ export type MfaReencryptJobResult = {
|
|
|
66
70
|
readonly stoppedReason: ChunkedMigrationStopReason;
|
|
67
71
|
};
|
|
68
72
|
|
|
69
|
-
type EnvelopeClassification = "rotate" | "current" | "unrecognized";
|
|
70
|
-
|
|
71
|
-
// After PII peel (when configured), values must be current-cipher envelopes.
|
|
72
|
-
// Non-JSON / non-envelope is not a supported legacy decrypt path — fail the
|
|
73
|
-
// row instead of treating it as "needs rotate" (#1541, mirrors config #1513).
|
|
74
|
-
function classifyEnvelope(value: string, targetVersion: number): EnvelopeClassification {
|
|
75
|
-
let parsed: unknown;
|
|
76
|
-
try {
|
|
77
|
-
parsed = JSON.parse(value);
|
|
78
|
-
} catch {
|
|
79
|
-
return "unrecognized";
|
|
80
|
-
}
|
|
81
|
-
if (!isStoredEnvelope(parsed)) return "unrecognized";
|
|
82
|
-
return parsed.kekVersion === targetVersion ? "current" : "rotate";
|
|
83
|
-
}
|
|
84
|
-
|
|
85
73
|
export const mfaReencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
|
|
86
74
|
const payload = rawPayload as MfaReencryptJobPayload; // @cast-boundary engine-payload
|
|
87
75
|
const maybeCipher = configuredEntityFieldEncryption();
|
|
@@ -166,8 +154,11 @@ export const mfaReencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<vo
|
|
|
166
154
|
envelopeValues = unwrapped as { totpSecret: string; recoveryCodes: string }; // @cast-boundary engine-payload
|
|
167
155
|
}
|
|
168
156
|
|
|
169
|
-
|
|
170
|
-
|
|
157
|
+
// After PII peel (when configured), values must be current-cipher
|
|
158
|
+
// envelopes — non-JSON/non-envelope is not a supported legacy decrypt
|
|
159
|
+
// path (#1541, mirrors config #1513).
|
|
160
|
+
const totpClass = classifyStoredEnvelope(envelopeValues.totpSecret, targetVersion);
|
|
161
|
+
const recoveryClass = classifyStoredEnvelope(envelopeValues.recoveryCodes, targetVersion);
|
|
171
162
|
if (totpClass === "unrecognized" || recoveryClass === "unrecognized") {
|
|
172
163
|
ctx.log?.warn?.(
|
|
173
164
|
`[auth-mfa:reencrypt] row ${row.id} has a field that is not a current-cipher envelope, not re-encryptable`,
|
|
@@ -10,19 +10,19 @@ export type ResolvedMfaTokenSecrets = {
|
|
|
10
10
|
readonly challengeTokenSecret: string;
|
|
11
11
|
};
|
|
12
12
|
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// its fallback — and a purpose string that drifts between those two files
|
|
17
|
-
// invalidates every token issued by the other.
|
|
18
|
-
//
|
|
19
|
-
// Setup and challenge stay separate on purpose: a setup token proves "this
|
|
20
|
-
// user is enrolling a factor", a challenge token proves "this user passed
|
|
21
|
-
// step one of login". Sharing one key would let the first be replayed as the
|
|
22
|
-
// second.
|
|
13
|
+
// Shared here (not per call site) so the purpose string can't drift between
|
|
14
|
+
// prod/dev entrypoints. Setup and challenge stay separate: sharing one key
|
|
15
|
+
// would let a setup token be replayed as a login-challenge token.
|
|
23
16
|
const SETUP_TOKEN_PURPOSE = "mfa-setup-token-v1";
|
|
24
17
|
const CHALLENGE_TOKEN_PURPOSE = "mfa-challenge-token-v1";
|
|
25
18
|
|
|
19
|
+
// A blank or whitespace-only override is treated as absent — an empty env
|
|
20
|
+
// var (MFA_SETUP_TOKEN_SECRET="") must fall back to derivation, not sign
|
|
21
|
+
// tokens with an empty/near-empty HMAC key (fw#1623).
|
|
22
|
+
function resolveSecret(override: string | undefined, fallback: () => string): string {
|
|
23
|
+
return override !== undefined && override.trim() !== "" ? override : fallback();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
26
|
/** Derives both MFA token secrets from the app's master secret. Pass explicit
|
|
27
27
|
* overrides only when a deployment needs its own key for one of them —
|
|
28
28
|
* otherwise deriving keeps a single env var authoritative. */
|
|
@@ -31,9 +31,11 @@ export function resolveMfaTokenSecrets(
|
|
|
31
31
|
overrides: MfaTokenSecretOverrides = {},
|
|
32
32
|
): ResolvedMfaTokenSecrets {
|
|
33
33
|
return {
|
|
34
|
-
setupTokenSecret:
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
setupTokenSecret: resolveSecret(overrides.setupTokenSecret, () =>
|
|
35
|
+
derivePurposeSecret(masterSecret, SETUP_TOKEN_PURPOSE),
|
|
36
|
+
),
|
|
37
|
+
challengeTokenSecret: resolveSecret(overrides.challengeTokenSecret, () =>
|
|
38
|
+
derivePurposeSecret(masterSecret, CHALLENGE_TOKEN_PURPOSE),
|
|
39
|
+
),
|
|
38
40
|
};
|
|
39
41
|
}
|
|
@@ -116,9 +116,16 @@ export function createSubscriptionTierSync<TTier extends string>(
|
|
|
116
116
|
tenantId: targetTenantId,
|
|
117
117
|
});
|
|
118
118
|
if (!result.isSuccess) return result;
|
|
119
|
+
// The primary write already committed — a webhook caller (Stripe/
|
|
120
|
+
// PayPal) that sees isSuccess:false here retries the whole event,
|
|
121
|
+
// re-running an already-succeeded side effect. Log the tier-sync
|
|
122
|
+
// failure instead of masking the primary write's success.
|
|
119
123
|
const syncError = await syncTierFromSubscription(targetTenantId);
|
|
120
124
|
if (syncError) {
|
|
121
|
-
|
|
125
|
+
// biome-ignore lint/suspicious/noConsole: operator visibility for a post-commit sync failure
|
|
126
|
+
console.warn(
|
|
127
|
+
`[subscription-tier-sync] tier sync failed for tenant ${targetTenantId} after successful webhook write: ${syncError.code} ${syncError.message}`,
|
|
128
|
+
);
|
|
122
129
|
}
|
|
123
130
|
return result;
|
|
124
131
|
},
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Regression test for the tenant-scope override bug: checkStockCap used to
|
|
2
|
+
// spread `spec.where` AFTER the injected `tenantId`, so a `where` object
|
|
3
|
+
// carrying its own `tenantId` key silently overrode the caller's real tenant
|
|
4
|
+
// and the count ran against the wrong tenant's rows.
|
|
5
|
+
|
|
6
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
7
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
8
|
+
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
9
|
+
import { createEntityExecutor, type SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
10
|
+
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
11
|
+
import {
|
|
12
|
+
setupTestStack,
|
|
13
|
+
type TestStack,
|
|
14
|
+
testTenantId,
|
|
15
|
+
unsafeCreateEntityTable,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
17
|
+
import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
18
|
+
import { capCounterEntity } from "../entity";
|
|
19
|
+
import { createStockCapGuard } from "../stock-cap-guard";
|
|
20
|
+
|
|
21
|
+
const { executor, table: capCounterTable } = createEntityExecutor("cap-counter", capCounterEntity);
|
|
22
|
+
|
|
23
|
+
let stack: TestStack;
|
|
24
|
+
let db: DbConnection;
|
|
25
|
+
|
|
26
|
+
beforeAll(async () => {
|
|
27
|
+
stack = await setupTestStack({ features: [] });
|
|
28
|
+
db = stack.db;
|
|
29
|
+
await unsafeCreateEntityTable(db, capCounterEntity, "cap-counter");
|
|
30
|
+
await createEventsTable(db);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterAll(async () => {
|
|
34
|
+
await stack.cleanup();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
beforeEach(async () => {
|
|
38
|
+
await resetTestTables(db, [capCounterTable, eventsTable]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
function seedUser(tenantId: ReturnType<typeof testTenantId>): SessionUser {
|
|
42
|
+
return { id: "seed-user", tenantId, roles: ["SystemAdmin"] };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function seedCounterRow(tenantId: ReturnType<typeof testTenantId>): Promise<void> {
|
|
46
|
+
const result = await executor.create(
|
|
47
|
+
{ tenantId, capName: "x", value: 0, periodStart: "2026-07-01T00:00:00Z" },
|
|
48
|
+
seedUser(tenantId),
|
|
49
|
+
createTenantDb(db, tenantId),
|
|
50
|
+
);
|
|
51
|
+
if (!result.isSuccess) throw new Error(`seed failed: ${JSON.stringify(result)}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe("checkStockCap tenant scoping", () => {
|
|
55
|
+
test("spec.where cannot override the caller's real tenantId", async () => {
|
|
56
|
+
const realTenant = testTenantId(1);
|
|
57
|
+
const otherTenant = testTenantId(2);
|
|
58
|
+
|
|
59
|
+
// 5 rows live under otherTenant — realTenant has none.
|
|
60
|
+
for (let i = 0; i < 5; i++) {
|
|
61
|
+
await seedCounterRow(otherTenant);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const guard = createStockCapGuard(async () => ({}));
|
|
65
|
+
const failure = await guard.checkStockCap(db, realTenant, {
|
|
66
|
+
table: capCounterTable,
|
|
67
|
+
limit: () => 1,
|
|
68
|
+
// A where object that (accidentally or maliciously) carries its own
|
|
69
|
+
// tenantId must NOT be able to redirect the count to another tenant.
|
|
70
|
+
where: { tenantId: otherTenant, capName: "x" },
|
|
71
|
+
code: "cap-exceeded",
|
|
72
|
+
i18nKey: "cap.exceeded",
|
|
73
|
+
field: "capName",
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// realTenant has zero matching rows — must not see otherTenant's 5.
|
|
77
|
+
expect(failure).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("still enforces the cap for the caller's own tenant", async () => {
|
|
81
|
+
const tenant = testTenantId(1);
|
|
82
|
+
for (let i = 0; i < 2; i++) {
|
|
83
|
+
await seedCounterRow(tenant);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const guard = createStockCapGuard(async () => ({}));
|
|
87
|
+
const failure = await guard.checkStockCap(db, tenant, {
|
|
88
|
+
table: capCounterTable,
|
|
89
|
+
limit: () => 1,
|
|
90
|
+
where: { capName: "x" },
|
|
91
|
+
code: "cap-exceeded",
|
|
92
|
+
i18nKey: "cap.exceeded",
|
|
93
|
+
field: "capName",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(failure).not.toBeNull();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -39,7 +39,7 @@ export function createStockCapGuard<TCaps>(
|
|
|
39
39
|
spec: StockCapSpec<TCaps>,
|
|
40
40
|
): Promise<WriteFailure | null> {
|
|
41
41
|
const caps = await resolveTierCaps(db, tenantId);
|
|
42
|
-
const current = await countWhere(db, spec.table, {
|
|
42
|
+
const current = await countWhere(db, spec.table, { ...spec.where, tenantId });
|
|
43
43
|
const { state, limit } = enforceStockCap({
|
|
44
44
|
current,
|
|
45
45
|
limit: spec.limit(caps),
|
|
@@ -25,8 +25,12 @@ import {
|
|
|
25
25
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
26
26
|
import type { JobHandlerFn, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
27
27
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
28
|
+
import type { EnvelopeCipher } from "@cosmicdrift/kumiko-framework/secrets";
|
|
29
|
+
import {
|
|
30
|
+
type ChunkedMigrationStopReason,
|
|
31
|
+
classifyStoredEnvelope,
|
|
32
|
+
runChunkedMigration,
|
|
33
|
+
} from "../../shared";
|
|
30
34
|
import { configValueEntity, configValuesTable } from "../table";
|
|
31
35
|
|
|
32
36
|
const DEFAULT_MAX_FAILURES = 10;
|
|
@@ -49,21 +53,6 @@ export type ReencryptJobResult = {
|
|
|
49
53
|
readonly stoppedReason: ChunkedMigrationStopReason;
|
|
50
54
|
};
|
|
51
55
|
|
|
52
|
-
type RowClassification = "rotate" | "current" | "unrecognized";
|
|
53
|
-
|
|
54
|
-
function classifyRow(value: string, targetVersion: number): RowClassification {
|
|
55
|
-
let parsed: unknown;
|
|
56
|
-
try {
|
|
57
|
-
parsed = JSON.parse(value);
|
|
58
|
-
} catch {
|
|
59
|
-
// not JSON at all — no supported format decrypts this; let
|
|
60
|
-
// cipher.decrypt reject it and count the row as failed.
|
|
61
|
-
return "unrecognized";
|
|
62
|
-
}
|
|
63
|
-
if (!isStoredEnvelope(parsed)) return "unrecognized";
|
|
64
|
-
return parsed.kekVersion === targetVersion ? "current" : "rotate";
|
|
65
|
-
}
|
|
66
|
-
|
|
67
56
|
export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
|
|
68
57
|
const payload = rawPayload as ReencryptJobPayload; // @cast-boundary engine-payload
|
|
69
58
|
const maybeCipher = ctx.configEncryption;
|
|
@@ -148,7 +137,7 @@ export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void>
|
|
|
148
137
|
|
|
149
138
|
async function migrateRow(row: ConfigRow): Promise<"migrated" | "skipped" | "failed"> {
|
|
150
139
|
if (row.value === null || row.value === undefined) return "skipped";
|
|
151
|
-
const classification =
|
|
140
|
+
const classification = classifyStoredEnvelope(row.value, targetVersion);
|
|
152
141
|
if (classification === "current") {
|
|
153
142
|
alreadyCurrent++;
|
|
154
143
|
return "skipped";
|
|
@@ -488,7 +488,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
488
488
|
tenantId,
|
|
489
489
|
notificationType,
|
|
490
490
|
channel: "*",
|
|
491
|
-
recipientId: null,
|
|
491
|
+
recipientId: options.recipientId ?? null,
|
|
492
492
|
recipientAddress: null,
|
|
493
493
|
status: "skipped",
|
|
494
494
|
error: "duplicate_idempotency_key",
|
|
@@ -89,6 +89,14 @@ async function loadIngestRequestedEvents(): Promise<{ payload: Record<string, un
|
|
|
89
89
|
return rows as { payload: Record<string, unknown> }[];
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
async function loadIngestSkippedEvents(): Promise<{ payload: Record<string, unknown> }[]> {
|
|
93
|
+
const rows = await asRawClient(stack.db).unsafe(
|
|
94
|
+
`SELECT payload FROM kumiko_events WHERE type = $1`,
|
|
95
|
+
["document-ingest-foundation:event:document-ingest-skipped"],
|
|
96
|
+
);
|
|
97
|
+
return rows as { payload: Record<string, unknown> }[];
|
|
98
|
+
}
|
|
99
|
+
|
|
92
100
|
describe("fileRef.created → documentIngest.requested", () => {
|
|
93
101
|
test("PDF upload requests ingest with the fileRef pointer, no binary", async () => {
|
|
94
102
|
const { id, storageKey } = await uploadFile("invoice.pdf", pdfBytes, "application/pdf");
|
|
@@ -97,12 +105,13 @@ describe("fileRef.created → documentIngest.requested", () => {
|
|
|
97
105
|
|
|
98
106
|
const rows = await loadIngestRequestedEvents();
|
|
99
107
|
expect(rows).toHaveLength(1);
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
108
|
+
expect(rows[0]?.payload).toEqual({
|
|
109
|
+
fileRefId: id,
|
|
110
|
+
storageKey,
|
|
111
|
+
fileName: "invoice.pdf",
|
|
112
|
+
mimeType: "application/pdf",
|
|
113
|
+
size: pdfBytes.length,
|
|
114
|
+
});
|
|
106
115
|
});
|
|
107
116
|
|
|
108
117
|
test("image/png upload also requests ingest (Phase-1 mime allowlist)", async () => {
|
|
@@ -113,19 +122,32 @@ describe("fileRef.created → documentIngest.requested", () => {
|
|
|
113
122
|
expect(await loadIngestRequestedEvents()).toHaveLength(1);
|
|
114
123
|
});
|
|
115
124
|
|
|
116
|
-
test("unsupported mime type is skipped — no ingest requested", async () => {
|
|
117
|
-
await uploadFile("notes.txt", textBytes, "text/plain");
|
|
125
|
+
test("unsupported mime type is skipped — no ingest requested, skip is observable", async () => {
|
|
126
|
+
const { id, storageKey } = await uploadFile("notes.txt", textBytes, "text/plain");
|
|
118
127
|
|
|
119
128
|
await stack.eventDispatcher?.runOnce();
|
|
120
129
|
|
|
121
130
|
expect(await loadIngestRequestedEvents()).toHaveLength(0);
|
|
131
|
+
const skipped = await loadIngestSkippedEvents();
|
|
132
|
+
expect(skipped).toHaveLength(1);
|
|
133
|
+
// mimeType not pinned exactly: the upload route/File API append a
|
|
134
|
+
// charset suffix ("text/plain;charset=utf-8") that isn't this MSP's
|
|
135
|
+
// concern — the allowlist-miss is.
|
|
136
|
+
expect(skipped[0]?.payload).toMatchObject({
|
|
137
|
+
fileRefId: id,
|
|
138
|
+
storageKey,
|
|
139
|
+
fileName: "notes.txt",
|
|
140
|
+
reason: "unsupported-mime-type",
|
|
141
|
+
});
|
|
142
|
+
expect(String(skipped[0]?.payload["mimeType"])).toStartWith("text/plain");
|
|
122
143
|
});
|
|
123
144
|
|
|
124
|
-
test("oversized file is skipped before the mime check — no ingest requested", async () => {
|
|
145
|
+
test("oversized file is skipped before the mime check — no ingest requested, skip is observable", async () => {
|
|
125
146
|
// Real uploads can't exceed file-routes.ts' 10mb unconstrained-upload
|
|
126
147
|
// default, well below this feature's 25mb domain cap — so an oversized
|
|
127
148
|
// fileRef.created is inserted directly (bypassing the upload route) to
|
|
128
149
|
// prove the MSP's own size-check fires independently of it.
|
|
150
|
+
const oversizedSize = 26 * 1024 * 1024;
|
|
129
151
|
await asRawClient(stack.db).unsafe(
|
|
130
152
|
`
|
|
131
153
|
INSERT INTO kumiko_events
|
|
@@ -142,9 +164,13 @@ describe("fileRef.created → documentIngest.requested", () => {
|
|
|
142
164
|
storageKey: "huge.pdf",
|
|
143
165
|
fileName: "huge.pdf",
|
|
144
166
|
mimeType: "application/pdf",
|
|
145
|
-
size:
|
|
167
|
+
size: oversizedSize,
|
|
146
168
|
}),
|
|
147
|
-
|
|
169
|
+
// MSP-apply's ctx.unsafeAppendEvent stamps the new event's actor from
|
|
170
|
+
// the TRIGGERING event's metadata.userId (server.ts) — an empty
|
|
171
|
+
// metadata object here leaves it undefined and the skip-event insert
|
|
172
|
+
// rejects with "UNDEFINED_VALUE".
|
|
173
|
+
JSON.stringify({ userId: admin.id }),
|
|
148
174
|
admin.id,
|
|
149
175
|
],
|
|
150
176
|
);
|
|
@@ -152,5 +178,15 @@ describe("fileRef.created → documentIngest.requested", () => {
|
|
|
152
178
|
await stack.eventDispatcher?.runOnce();
|
|
153
179
|
|
|
154
180
|
expect(await loadIngestRequestedEvents()).toHaveLength(0);
|
|
181
|
+
const skipped = await loadIngestSkippedEvents();
|
|
182
|
+
expect(skipped).toHaveLength(1);
|
|
183
|
+
expect(skipped[0]?.payload).toEqual({
|
|
184
|
+
fileRefId: "00000000-0000-4000-8000-0000000000ff",
|
|
185
|
+
storageKey: "huge.pdf",
|
|
186
|
+
fileName: "huge.pdf",
|
|
187
|
+
mimeType: "application/pdf",
|
|
188
|
+
size: oversizedSize,
|
|
189
|
+
reason: "file-too-large",
|
|
190
|
+
});
|
|
155
191
|
});
|
|
156
192
|
});
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { describe, expect, test } from "bun:test";
|
|
8
8
|
import { EXT_TENANT_DATA } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
9
|
import { documentExtractEntity } from "../entity";
|
|
10
|
-
import { DOCUMENT_INGEST_REQUESTED_EVENT_QN } from "../events";
|
|
10
|
+
import { DOCUMENT_INGEST_REQUESTED_EVENT_QN, DOCUMENT_INGEST_SKIPPED_EVENT_QN } from "../events";
|
|
11
11
|
import { documentIngestFoundationFeature } from "../feature";
|
|
12
12
|
|
|
13
13
|
describe("documentIngestFoundationFeature — shape", () => {
|
|
@@ -64,9 +64,10 @@ describe("documentIngestFoundationFeature — shape", () => {
|
|
|
64
64
|
expect(Object.keys(documentIngestFoundationFeature.queryHandlers)).toHaveLength(0);
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
-
test("registers the documentIngest.requested
|
|
67
|
+
test("registers the documentIngest.requested and documentIngest.skipped events under their exported QNs", () => {
|
|
68
68
|
expect(Object.keys(documentIngestFoundationFeature.events)).toEqual([
|
|
69
69
|
"documentIngest.requested",
|
|
70
|
+
"documentIngest.skipped",
|
|
70
71
|
]);
|
|
71
72
|
// The registry qualifies short → QN via qn(toKebab(feature), "event",
|
|
72
73
|
// toKebab(short)) — pin the hand-written DOCUMENT_INGEST_REQUESTED_EVENT_QN
|
|
@@ -77,6 +78,9 @@ describe("documentIngestFoundationFeature — shape", () => {
|
|
|
77
78
|
expect(documentIngestFoundationFeature.events["documentIngest.requested"]?.name).toBe(
|
|
78
79
|
DOCUMENT_INGEST_REQUESTED_EVENT_QN,
|
|
79
80
|
);
|
|
81
|
+
expect(documentIngestFoundationFeature.events["documentIngest.skipped"]?.name).toBe(
|
|
82
|
+
DOCUMENT_INGEST_SKIPPED_EVENT_QN,
|
|
83
|
+
);
|
|
80
84
|
});
|
|
81
85
|
|
|
82
86
|
test("registers the fileRef.created MSP", () => {
|
|
@@ -23,3 +23,18 @@ export const documentIngestRequestedPayloadSchema = z.object({
|
|
|
23
23
|
size: z.number().int().min(0),
|
|
24
24
|
});
|
|
25
25
|
export type DocumentIngestRequestedPayload = z.infer<typeof documentIngestRequestedPayloadSchema>;
|
|
26
|
+
|
|
27
|
+
export const DOCUMENT_INGEST_SKIPPED_EVENT_SHORT = "documentIngest.skipped" as const;
|
|
28
|
+
|
|
29
|
+
export const DOCUMENT_INGEST_SKIPPED_EVENT_QN =
|
|
30
|
+
"document-ingest-foundation:event:document-ingest-skipped" as const;
|
|
31
|
+
|
|
32
|
+
export const documentIngestSkippedPayloadSchema = z.object({
|
|
33
|
+
fileRefId: z.string().min(1),
|
|
34
|
+
storageKey: z.string().min(1),
|
|
35
|
+
fileName: z.string().min(1),
|
|
36
|
+
mimeType: z.string().min(1),
|
|
37
|
+
size: z.number().int().min(0),
|
|
38
|
+
reason: z.enum(["file-too-large", "unsupported-mime-type"]),
|
|
39
|
+
});
|
|
40
|
+
export type DocumentIngestSkippedPayload = z.infer<typeof documentIngestSkippedPayloadSchema>;
|
|
@@ -24,7 +24,10 @@ import {
|
|
|
24
24
|
DOCUMENT_INGEST_AGGREGATE_TYPE,
|
|
25
25
|
DOCUMENT_INGEST_REQUESTED_EVENT_QN,
|
|
26
26
|
DOCUMENT_INGEST_REQUESTED_EVENT_SHORT,
|
|
27
|
+
DOCUMENT_INGEST_SKIPPED_EVENT_QN,
|
|
28
|
+
DOCUMENT_INGEST_SKIPPED_EVENT_SHORT,
|
|
27
29
|
documentIngestRequestedPayloadSchema,
|
|
30
|
+
documentIngestSkippedPayloadSchema,
|
|
28
31
|
} from "./events";
|
|
29
32
|
import { documentExtractTenantDestroyHook } from "./tenant-destroy-hook";
|
|
30
33
|
|
|
@@ -100,6 +103,7 @@ export const documentIngestFoundationFeature = defineFeature(FEATURE_NAME, (r) =
|
|
|
100
103
|
);
|
|
101
104
|
|
|
102
105
|
r.defineEvent(DOCUMENT_INGEST_REQUESTED_EVENT_SHORT, documentIngestRequestedPayloadSchema);
|
|
106
|
+
r.defineEvent(DOCUMENT_INGEST_SKIPPED_EVENT_SHORT, documentIngestSkippedPayloadSchema);
|
|
103
107
|
|
|
104
108
|
r.multiStreamProjection({
|
|
105
109
|
name: "request-ingest",
|
|
@@ -112,10 +116,31 @@ export const documentIngestFoundationFeature = defineFeature(FEATURE_NAME, (r) =
|
|
|
112
116
|
if (!parsed.success) return;
|
|
113
117
|
const payload = parsed.data;
|
|
114
118
|
|
|
119
|
+
const skip = (reason: "file-too-large" | "unsupported-mime-type") =>
|
|
120
|
+
ctx.unsafeAppendEvent({
|
|
121
|
+
aggregateId: event.aggregateId,
|
|
122
|
+
aggregateType: DOCUMENT_INGEST_AGGREGATE_TYPE,
|
|
123
|
+
type: DOCUMENT_INGEST_SKIPPED_EVENT_QN,
|
|
124
|
+
payload: {
|
|
125
|
+
fileRefId: event.aggregateId,
|
|
126
|
+
storageKey: payload.storageKey,
|
|
127
|
+
fileName: payload.fileName,
|
|
128
|
+
mimeType: payload.mimeType,
|
|
129
|
+
size: payload.size,
|
|
130
|
+
reason,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
115
134
|
// skip: over the fixed cap — no ingest requested, upload itself already succeeded
|
|
116
|
-
if (payload.size > MAX_FILE_BYTES)
|
|
135
|
+
if (payload.size > MAX_FILE_BYTES) {
|
|
136
|
+
await skip("file-too-large");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
117
139
|
// skip: outside the Phase-1 mime allowlist — no ingest requested
|
|
118
|
-
if (!ALLOWED_MIME_TYPES.has(payload.mimeType))
|
|
140
|
+
if (!ALLOWED_MIME_TYPES.has(payload.mimeType)) {
|
|
141
|
+
await skip("unsupported-mime-type");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
119
144
|
|
|
120
145
|
await ctx.unsafeAppendEvent({
|
|
121
146
|
aggregateId: event.aggregateId,
|
|
@@ -408,8 +408,17 @@ export function createInboundMailSupervisor(
|
|
|
408
408
|
state.stop = stop;
|
|
409
409
|
state.backoffMs = backoffInitialMs;
|
|
410
410
|
// Await — fire-and-forget raced with a later auth_error mark under
|
|
411
|
-
// try-first waitFor (isWatching true before projection settled).
|
|
412
|
-
|
|
411
|
+
// try-first waitFor (isWatching true before projection settled). Own
|
|
412
|
+
// try/catch: a projection-write hiccup here is not a sync failure —
|
|
413
|
+
// the watch itself is healthy, so it must not trigger handleSyncError's
|
|
414
|
+
// backoff/restart/auth_error handling below.
|
|
415
|
+
try {
|
|
416
|
+
await markAccount(account, { watchState: "watching" }, "watch_supervisor");
|
|
417
|
+
} catch (err) {
|
|
418
|
+
log(
|
|
419
|
+
`inbound-mail: markAccount(watching) for account ${account.id} failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
413
422
|
} catch (err) {
|
|
414
423
|
const keepRunning = await handleSyncError(account, err);
|
|
415
424
|
if (keepRunning) scheduleRestart(err);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineQueryHandler, type JobDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { isManualTrigger } from "../is-manual-trigger";
|
|
3
4
|
|
|
4
5
|
export type ManualJobCatalogEntry = {
|
|
5
6
|
readonly jobName: string;
|
|
@@ -8,10 +9,6 @@ export type ManualJobCatalogEntry = {
|
|
|
8
9
|
readonly payloadSchema: Record<string, unknown> | null;
|
|
9
10
|
};
|
|
10
11
|
|
|
11
|
-
function isManualTrigger(trigger: JobDefinition["trigger"]): boolean {
|
|
12
|
-
return "manual" in trigger && trigger.manual === true;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
12
|
function payloadSchemaJson(job: JobDefinition): Record<string, unknown> | null {
|
|
16
13
|
if (job.schema === undefined) return null;
|
|
17
14
|
try {
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import type { JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
|
|
10
10
|
import { z } from "zod";
|
|
11
11
|
import { JobErrors } from "../constants";
|
|
12
|
+
import { isManualTrigger } from "../is-manual-trigger";
|
|
12
13
|
|
|
13
14
|
export const triggerWrite = defineWriteHandler({
|
|
14
15
|
name: "trigger",
|
|
@@ -31,7 +32,7 @@ export const triggerWrite = defineWriteHandler({
|
|
|
31
32
|
);
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
if (!(
|
|
35
|
+
if (!isManualTrigger(jobDef.trigger)) {
|
|
35
36
|
return writeFailure(
|
|
36
37
|
new UnprocessableError(JobErrors.notManual, {
|
|
37
38
|
i18nKey: "jobs.errors.notManual",
|
|
@@ -42,7 +42,7 @@ export function createBrandingSettingsScreen(opts: {
|
|
|
42
42
|
description: createTextField({
|
|
43
43
|
maxLength: 500,
|
|
44
44
|
multiline: { rows: 3 },
|
|
45
|
-
allowPlaintext: "
|
|
45
|
+
allowPlaintext: "is-business-data",
|
|
46
46
|
}),
|
|
47
47
|
siteUrl: createTextField({ maxLength: 2000, format: "url" }),
|
|
48
48
|
accentColor: createTextField({ maxLength: 9 }),
|
|
@@ -6,19 +6,19 @@ import { createUserDataRightsFeature, type UserDataRightsOptions } from "../user
|
|
|
6
6
|
import { createUserProfileFeature } from "../user-profile";
|
|
7
7
|
|
|
8
8
|
export type DsgvoSelfServiceOptions = {
|
|
9
|
-
/**
|
|
10
|
-
*
|
|
9
|
+
/** Passed through to createUserDataRightsFeature — export/deletion mail
|
|
10
|
+
* callbacks + Apex deletion HMAC. Default {} (no-op mail side). */
|
|
11
11
|
readonly userDataRights?: UserDataRightsOptions;
|
|
12
12
|
};
|
|
13
13
|
|
|
14
|
-
// DSGVO
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
14
|
+
// DSGVO + account self-service chain every Kumiko SaaS app mounts (privacy
|
|
15
|
+
// center, account deletion Art. 17, export Art. 20, sessions). Order is
|
|
16
|
+
// load-bearing (require-order): user-data-rights needs data-retention +
|
|
17
|
+
// compliance-profiles + sessions, user-profile needs user-data-rights. This
|
|
18
|
+
// exact order used to be hand-copied into every app with an explainer
|
|
19
|
+
// comment. text-content + legal-pages stay out deliberately — legal-pages
|
|
20
|
+
// has an app-specific wrapLayout, text-content is a standalone foundation;
|
|
21
|
+
// both are spread in by the app itself.
|
|
22
22
|
export function dsgvoSelfServiceFeatures(opts: DsgvoSelfServiceOptions = {}): FeatureDefinition[] {
|
|
23
23
|
return [
|
|
24
24
|
createDataRetentionFeature(),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { requestContext } from "@cosmicdrift/kumiko-framework/api";
|
|
2
2
|
import { updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
3
3
|
import { access, defineWriteHandler, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
5
|
import { append } from "@cosmicdrift/kumiko-framework/event-store";
|
|
5
6
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
6
7
|
import { Temporal } from "temporal-polyfill";
|
|
@@ -55,6 +56,14 @@ export const revokeAllForUserWrite = defineWriteHandler({
|
|
|
55
56
|
// parse explicitly before the raw append so a shape drift fails loudly
|
|
56
57
|
// here instead of landing unvalidated on the events table.
|
|
57
58
|
if (updated.length > 0) {
|
|
59
|
+
const eventDef = ctx.registry.getEvent(SESSION_REVOKED_EVENT_QN);
|
|
60
|
+
if (!eventDef) {
|
|
61
|
+
return writeFailure(
|
|
62
|
+
new UnprocessableError("session_revoked_event_not_registered", {
|
|
63
|
+
details: { eventQn: SESSION_REVOKED_EVENT_QN },
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
58
67
|
const payload = sessionRevokedSchema.parse({
|
|
59
68
|
userId: event.payload.userId,
|
|
60
69
|
sessionIds: updated.map((row) => row.id),
|
|
@@ -66,7 +75,7 @@ export const revokeAllForUserWrite = defineWriteHandler({
|
|
|
66
75
|
tenantId: SYSTEM_TENANT_ID,
|
|
67
76
|
expectedVersion: 0,
|
|
68
77
|
type: SESSION_REVOKED_EVENT_QN,
|
|
69
|
-
eventVersion:
|
|
78
|
+
eventVersion: eventDef.version,
|
|
70
79
|
payload,
|
|
71
80
|
metadata: {
|
|
72
81
|
userId: event.user.id,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { isStoredEnvelope } from "@cosmicdrift/kumiko-framework/secrets";
|
|
2
|
+
|
|
3
|
+
export type StoredEnvelopeClassification = "rotate" | "current" | "unrecognized";
|
|
4
|
+
|
|
5
|
+
// Shared by every KEK-rotation job (config, auth-mfa, ...): a row whose value
|
|
6
|
+
// isn't a current-cipher envelope (malformed JSON, or any pre-envelope
|
|
7
|
+
// format) is never a supported re-encrypt input — classify it "unrecognized"
|
|
8
|
+
// so callers fail it loudly instead of handing it to cipher.decrypt.
|
|
9
|
+
export function classifyStoredEnvelope(
|
|
10
|
+
value: string,
|
|
11
|
+
targetVersion: number,
|
|
12
|
+
): StoredEnvelopeClassification {
|
|
13
|
+
let parsed: unknown;
|
|
14
|
+
try {
|
|
15
|
+
parsed = JSON.parse(value);
|
|
16
|
+
} catch {
|
|
17
|
+
return "unrecognized";
|
|
18
|
+
}
|
|
19
|
+
if (!isStoredEnvelope(parsed)) return "unrecognized";
|
|
20
|
+
return parsed.kekVersion === targetVersion ? "current" : "rotate";
|
|
21
|
+
}
|
package/src/shared/index.ts
CHANGED
|
@@ -5,6 +5,10 @@ export {
|
|
|
5
5
|
type MigrationRowOutcome,
|
|
6
6
|
runChunkedMigration,
|
|
7
7
|
} from "./chunked-entity-migration";
|
|
8
|
+
export {
|
|
9
|
+
classifyStoredEnvelope,
|
|
10
|
+
type StoredEnvelopeClassification,
|
|
11
|
+
} from "./classify-stored-envelope";
|
|
8
12
|
export { decryptStoredPii } from "./decrypt-stored-pii";
|
|
9
13
|
export { encryptForDirectWrite } from "./encrypt-for-direct-write";
|
|
10
14
|
export { entitiesOf } from "./entities-of";
|
|
@@ -47,6 +47,15 @@ test("defineCreateWithTenantDefaults throws on an unknown currency field", () =>
|
|
|
47
47
|
).toThrow(/unknown field "notARealField"/);
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
+
test("defineCreateWithTenantDefaults throws on an unknown locale field", () => {
|
|
51
|
+
expect(() =>
|
|
52
|
+
defineCreateWithTenantDefaults("invoice", invoiceEntity, {
|
|
53
|
+
access: ACCESS,
|
|
54
|
+
localeField: "languge",
|
|
55
|
+
}),
|
|
56
|
+
).toThrow(/unknown field "languge"/);
|
|
57
|
+
});
|
|
58
|
+
|
|
50
59
|
describe("without tenant-settings mount", () => {
|
|
51
60
|
const invoiceFeature = defineFeature("invoice", (r) => {
|
|
52
61
|
r.entity("invoice", invoiceEntity);
|
|
@@ -42,6 +42,9 @@ export function defineCreateWithTenantDefaults(
|
|
|
42
42
|
const isRequired = "required" in def && def.required === true;
|
|
43
43
|
relax[field] = isRequired ? OPTIONAL_CURRENCY_MONEY : OPTIONAL_CURRENCY_MONEY.optional();
|
|
44
44
|
}
|
|
45
|
+
if (options.localeField && !entity.fields[options.localeField]) {
|
|
46
|
+
throw new Error(`defineCreateWithTenantDefaults: unknown field "${options.localeField}"`);
|
|
47
|
+
}
|
|
45
48
|
const baseSchema = buildInsertSchema(entity);
|
|
46
49
|
const schema = Object.keys(relax).length > 0 ? baseSchema.extend(relax) : baseSchema;
|
|
47
50
|
|
|
@@ -85,19 +85,8 @@ export type ComposedApp<TCaps extends Readonly<Record<string, unknown>>> = {
|
|
|
85
85
|
* is safer than silently mounting the wrong feature-set and hoping nobody
|
|
86
86
|
* notices when "Pro" turns out to mean "Free".
|
|
87
87
|
*
|
|
88
|
-
* **No production callers, and that is not rot
|
|
89
|
-
*
|
|
90
|
-
* which features a given tenant may see out of everything mounted, is
|
|
91
|
-
* `createTierEngineFeature`'s `resolver(tenantId) => ReadonlySet<string>`
|
|
92
|
-
* (feature.ts). Both read the same tierMap at opposite ends. Apps use the
|
|
93
|
-
* resolver today; nobody composes per-tenant feature sets at boot yet, so an
|
|
94
|
-
* app that only needs caps merges them locally rather than constructing a
|
|
95
|
-
* featureRegistry it has no other use for (kumiko-studio's
|
|
96
|
-
* `resolvePlatformCaps`, studio#154/1 → PR #162 — a deliberate, typed copy of
|
|
97
|
-
* step 4, not a workaround). Whether the boot-time half ever gets a caller is
|
|
98
|
-
* a product question: kumiko-studio's BYO pivot (Sprint 8a Phase 3a)
|
|
99
|
-
* differentiates tiers by CAPS, not by feature set. Delete this only once no
|
|
100
|
-
* app plans to differentiate by feature set — the engine is app-agnostic.
|
|
88
|
+
* **No production callers, and that is not rot** — see
|
|
89
|
+
* docs/reference/tier-composition-boot-vs-runtime.md for why.
|
|
101
90
|
*/
|
|
102
91
|
export function composeApp<TCaps extends Readonly<Record<string, unknown>>>(
|
|
103
92
|
input: ComposeAppInput<TCaps>,
|
|
@@ -104,6 +104,31 @@ describe("seedUser", () => {
|
|
|
104
104
|
expect(row?.["passwordHash"]).toBeNull();
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
+
test("emailVerified:true reconciled auf einem bereits geseedeten User (#1687)", async () => {
|
|
108
|
+
const first = await seedUser(stack.db, {
|
|
109
|
+
email: "frank@example.com",
|
|
110
|
+
displayName: "Frank",
|
|
111
|
+
});
|
|
112
|
+
const [rowBefore] = await selectMany(stack.db, userTable, { id: first.id });
|
|
113
|
+
expect(rowBefore?.["emailVerified"]).not.toBe(true);
|
|
114
|
+
|
|
115
|
+
const second = await seedUser(stack.db, {
|
|
116
|
+
email: "frank@example.com",
|
|
117
|
+
displayName: "Frank",
|
|
118
|
+
emailVerified: true,
|
|
119
|
+
});
|
|
120
|
+
expect(second.id).toBe(first.id);
|
|
121
|
+
|
|
122
|
+
const [rowAfter] = await selectMany(stack.db, userTable, { id: first.id });
|
|
123
|
+
expect(rowAfter?.["emailVerified"]).toBe(true);
|
|
124
|
+
|
|
125
|
+
const updated = await selectMany(stack.db, eventsTable, {
|
|
126
|
+
aggregateType: "user",
|
|
127
|
+
type: "user.updated",
|
|
128
|
+
});
|
|
129
|
+
expect(updated).toHaveLength(1);
|
|
130
|
+
});
|
|
131
|
+
|
|
107
132
|
test("default `by` ist TestUsers.systemAdmin (für audit-trail)", async () => {
|
|
108
133
|
const { id: userId } = await seedUser(stack.db, {
|
|
109
134
|
email: "eve@example.com",
|
package/src/user/seeding.ts
CHANGED
|
@@ -69,7 +69,31 @@ export async function seedUser(
|
|
|
69
69
|
const existing = await fetchOne(db, userTable, { email: options.email });
|
|
70
70
|
// @cast-boundary db-row: users.id ist uuid-Spalte (string), fetchOne
|
|
71
71
|
// liefert die Projection-Row als Record<string, unknown>.
|
|
72
|
-
if (existing)
|
|
72
|
+
if (existing) {
|
|
73
|
+
const id = existing["id"] as string;
|
|
74
|
+
// Reconcile emailVerified on an already-seeded row: a persistent dev DB
|
|
75
|
+
// (or a re-run bootstrap) can carry a User seeded before this flag
|
|
76
|
+
// existed or before it flipped to true — without this, "seed with
|
|
77
|
+
// emailVerified: true" only ever takes effect on the very first insert
|
|
78
|
+
// and the flag silently does nothing on every re-run after (#1687).
|
|
79
|
+
// Goes through the executor (a real `.updated` event), never a direct
|
|
80
|
+
// write.
|
|
81
|
+
if (options.emailVerified === true && existing["emailVerified"] !== true) {
|
|
82
|
+
const result = await userExecutor.update(
|
|
83
|
+
{ id, version: existing["version"] as number, changes: { emailVerified: true } },
|
|
84
|
+
by,
|
|
85
|
+
tdb,
|
|
86
|
+
);
|
|
87
|
+
// version_conflict = a concurrent write already changed the row —
|
|
88
|
+
// fine for a seed helper, don't fail the whole seed run over it.
|
|
89
|
+
if (!result.isSuccess && result.error.code !== "version_conflict") {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`seedUser emailVerified reconcile failed: ${result.error.code} — ${JSON.stringify(result.error.details ?? {})}`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { id };
|
|
96
|
+
}
|
|
73
97
|
|
|
74
98
|
const result = await userExecutor.create(
|
|
75
99
|
{
|
|
@@ -103,7 +103,7 @@ afterEach(() => {
|
|
|
103
103
|
|
|
104
104
|
function tokenFromLastVerifyCall(): string {
|
|
105
105
|
const url = new URL(verifyCalls[0]?.verifyUrl ?? "");
|
|
106
|
-
return url.
|
|
106
|
+
return new URLSearchParams(url.hash.slice(1)).get("token") ?? "";
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
describe("anonymous deletion flow with active KMS", () => {
|
|
@@ -95,7 +95,7 @@ async function seedAlice(status: string = USER_STATUS.Active, email: string = AL
|
|
|
95
95
|
|
|
96
96
|
function tokenFromLastVerifyCall(): string {
|
|
97
97
|
const url = new URL(verifyCalls[0]?.verifyUrl ?? "");
|
|
98
|
-
return url.
|
|
98
|
+
return new URLSearchParams(url.hash.slice(1)).get("token") ?? "";
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
async function statusOf(): Promise<string | undefined> {
|
|
@@ -118,7 +118,7 @@ describe("anonymous deletion flow", () => {
|
|
|
118
118
|
|
|
119
119
|
expect(verifyCalls).toHaveLength(1);
|
|
120
120
|
expect(verifyCalls[0]?.email).toBe(ALICE_EMAIL);
|
|
121
|
-
expect(verifyCalls[0]?.verifyUrl.startsWith(`${VERIFY_URL}
|
|
121
|
+
expect(verifyCalls[0]?.verifyUrl.startsWith(`${VERIFY_URL}#token=`)).toBe(true);
|
|
122
122
|
expect(tokenFromLastVerifyCall().length).toBeGreaterThan(0);
|
|
123
123
|
// Status noch NICHT geflipt — erst confirm startet die Grace-Period.
|
|
124
124
|
expect(await statusOf()).toBe(USER_STATUS.Active);
|
|
@@ -2,20 +2,23 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { buildDeletionVerifyUrl } from "../handlers/request-deletion-by-email.write";
|
|
3
3
|
|
|
4
4
|
describe("buildDeletionVerifyUrl", () => {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
// fw#1554: the token goes in the URL fragment, not a query param —
|
|
6
|
+
// fragments never leave the browser, so they never land in proxy/access
|
|
7
|
+
// logs (unlike ?token=, which does).
|
|
8
|
+
test("puts the token in the URL fragment, not a query param", () => {
|
|
9
|
+
const url = buildDeletionVerifyUrl("https://app.example.com/delete/confirm", "tok-123");
|
|
10
|
+
expect(url).toBe("https://app.example.com/delete/confirm#token=tok-123");
|
|
11
|
+
expect(new URL(url).search).toBe("");
|
|
9
12
|
});
|
|
10
13
|
|
|
11
|
-
test("
|
|
14
|
+
test("preserves an existing query param — only the token is a fragment", () => {
|
|
12
15
|
const url = buildDeletionVerifyUrl("https://app.example.com/confirm?lang=de", "tok-123");
|
|
13
|
-
expect(url).toBe("https://app.example.com/confirm?lang=de
|
|
14
|
-
expect(url.match(/\?/g)).toHaveLength(1);
|
|
16
|
+
expect(url).toBe("https://app.example.com/confirm?lang=de#token=tok-123");
|
|
15
17
|
});
|
|
16
18
|
|
|
17
19
|
test("URL-encodes a token with reserved characters", () => {
|
|
18
20
|
const url = new URL(buildDeletionVerifyUrl("https://app.example.com/c", "a b&c=d"));
|
|
19
|
-
|
|
21
|
+
const params = new URLSearchParams(url.hash.slice(1));
|
|
22
|
+
expect(params.get("token")).toBe("a b&c=d");
|
|
20
23
|
});
|
|
21
24
|
});
|
|
@@ -112,8 +112,9 @@ export type UserDataRightsOptions = {
|
|
|
112
112
|
* by-token weist generisch ab). */
|
|
113
113
|
readonly deletionTokenSecret?: string;
|
|
114
114
|
/** Basis-URL des Apex-Confirm-Screens, z.B.
|
|
115
|
-
* "https://app.example.com/delete-account/confirm". Der Handler hängt
|
|
116
|
-
*
|
|
115
|
+
* "https://app.example.com/delete-account/confirm". Der Handler hängt das
|
|
116
|
+
* Token als URL-Fragment an (`#token=<token>`, fw#1554). Required wenn
|
|
117
|
+
* deletionTokenSecret gesetzt. */
|
|
117
118
|
readonly deletionVerifyUrl?: string;
|
|
118
119
|
/** Versand des Verify-Magic-Links (Schritt 1 des anonymen Flows).
|
|
119
120
|
* Best-effort, app-author-wired. MUSS non-blocking sein (enqueue, z.B.
|
|
@@ -30,18 +30,20 @@ export type RequestDeletionByEmailOptions = {
|
|
|
30
30
|
* deaktiviert (Handler antwortet still mit success, kein Link). */
|
|
31
31
|
readonly deletionTokenSecret?: string;
|
|
32
32
|
/** Basis-URL des Apex-Confirm-Screens, z.B.
|
|
33
|
-
* "https://app.example.com/delete-account/confirm". Der Handler hängt
|
|
34
|
-
*
|
|
33
|
+
* "https://app.example.com/delete-account/confirm". Der Handler hängt das
|
|
34
|
+
* Token als URL-Fragment an (`#token=<token>`, fw#1554). Ohne URL kein Link. */
|
|
35
35
|
readonly deletionVerifyUrl?: string;
|
|
36
36
|
readonly sendDeletionVerificationEmail?: SendDeletionVerificationEmailFn;
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
-
// URL
|
|
40
|
-
// (
|
|
41
|
-
// `?
|
|
39
|
+
// Token goes in the URL fragment, not a query param — fragments never leave
|
|
40
|
+
// the browser (not sent to the server, so they never land in proxy/access
|
|
41
|
+
// logs, unlike `?token=`). Same convention as the export-download link
|
|
42
|
+
// (feature.ts, issue #1271). Preserves any existing query params on `base`
|
|
43
|
+
// (`?lang=de` stays a query param; only the token is a fragment).
|
|
42
44
|
export function buildDeletionVerifyUrl(base: string, token: string): string {
|
|
43
45
|
const url = new URL(base);
|
|
44
|
-
url.
|
|
46
|
+
url.hash = `token=${encodeURIComponent(token)}`;
|
|
45
47
|
return url.toString();
|
|
46
48
|
}
|
|
47
49
|
|
|
@@ -79,15 +79,15 @@ describe("RequestAccountDeletionScreen", () => {
|
|
|
79
79
|
});
|
|
80
80
|
|
|
81
81
|
describe("ConfirmAccountDeletionScreen", () => {
|
|
82
|
-
test("ohne
|
|
82
|
+
test("ohne #token → missingToken, kein Confirm-Button", () => {
|
|
83
83
|
window.history.replaceState({}, "", "/delete-account/confirm");
|
|
84
84
|
const ui = renderWith(<ConfirmAccountDeletionScreen />, makeDispatcher(true, []));
|
|
85
85
|
expect(ui.getByText(/Kein Token/)).toBeTruthy();
|
|
86
86
|
expect(ui.queryByRole("button")).toBeNull();
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
-
test("mit
|
|
90
|
-
window.history.replaceState({}, "", "/delete-account/confirm
|
|
89
|
+
test("mit #token → Confirm dispatcht confirm-deletion-by-token + Success", async () => {
|
|
90
|
+
window.history.replaceState({}, "", "/delete-account/confirm#token=tok-123");
|
|
91
91
|
const calls: WriteCall[] = [];
|
|
92
92
|
const ui = renderWith(<ConfirmAccountDeletionScreen />, makeDispatcher(true, calls));
|
|
93
93
|
fireEvent.click(ui.getByRole("button"));
|
|
@@ -98,7 +98,7 @@ describe("ConfirmAccountDeletionScreen", () => {
|
|
|
98
98
|
});
|
|
99
99
|
|
|
100
100
|
test("write-Failure → invalidToken-Banner, kein Success", async () => {
|
|
101
|
-
window.history.replaceState({}, "", "/delete-account/confirm
|
|
101
|
+
window.history.replaceState({}, "", "/delete-account/confirm#token=bad");
|
|
102
102
|
const ui = renderWith(<ConfirmAccountDeletionScreen />, makeDispatcher(false, []));
|
|
103
103
|
fireEvent.click(ui.getByRole("button"));
|
|
104
104
|
await waitFor(() => expect(ui.getByText(/ungültig oder abgelaufen/)).toBeTruthy());
|
|
@@ -106,7 +106,7 @@ describe("ConfirmAccountDeletionScreen", () => {
|
|
|
106
106
|
});
|
|
107
107
|
|
|
108
108
|
test("write wirft → generischer Error-Banner, NICHT invalidToken", async () => {
|
|
109
|
-
window.history.replaceState({}, "", "/delete-account/confirm
|
|
109
|
+
window.history.replaceState({}, "", "/delete-account/confirm#token=tok-123");
|
|
110
110
|
const ui = renderWith(<ConfirmAccountDeletionScreen />, makeThrowingDispatcher());
|
|
111
111
|
fireEvent.click(ui.getByRole("button"));
|
|
112
112
|
await waitFor(() => expect(ui.getByText(/schief gegangen/)).toBeTruthy());
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @runtime client
|
|
2
2
|
// ConfirmAccountDeletionScreen — anonymer Apex-Screen Schritt 2. Liest das
|
|
3
|
-
//
|
|
3
|
+
// Token aus dem URL-Fragment (`#token=…`, nie an den Server gesendet — landet
|
|
4
|
+
// so nicht in Proxy-/Access-Logs, fw#1554) und dispatcht beim Bestätigen
|
|
4
5
|
// user-data-rights:write:confirm-deletion-by-token → startet die Grace-Period.
|
|
5
6
|
//
|
|
6
7
|
// App mountet den Screen unter der deletionVerifyUrl-Route (z.B.
|
|
@@ -15,7 +16,7 @@ type Phase = "idle" | "submitting" | "success" | "missing" | "invalid" | "error"
|
|
|
15
16
|
|
|
16
17
|
function readToken(): string {
|
|
17
18
|
if (typeof window === "undefined") return "";
|
|
18
|
-
return new URLSearchParams(window.location.
|
|
19
|
+
return new URLSearchParams(window.location.hash.slice(1)).get("token") ?? "";
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
export type ConfirmAccountDeletionScreenProps = {
|