@cosmicdrift/kumiko-bundled-features 0.174.0 → 0.176.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 +8 -10
- package/src/__tests__/boot-seed-contract.integration.test.ts +18 -14
- 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/legal-pages/README.md +26 -25
- package/src/legal-pages/__tests__/legal-pages.integration.test.ts +93 -61
- package/src/legal-pages/feature.ts +40 -31
- package/src/legal-pages/web/__tests__/client-plugin.test.ts +2 -2
- package/src/legal-pages/web/client-plugin.ts +3 -3
- package/src/managed-pages/screens/branding-screen.ts +1 -1
- package/src/presets/dsgvo-self-service.ts +10 -10
- package/src/seo/__tests__/robots-txt.test.ts +21 -0
- package/src/seo/__tests__/seo.integration.test.ts +13 -13
- package/src/seo/robots-txt.ts +21 -1
- 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/template-resolver/README.md +30 -1
- package/src/{text-content → template-resolver}/__tests__/seed-legal-content.integration.test.ts +32 -21
- package/src/template-resolver/__tests__/text-blocks.integration.test.ts +563 -0
- package/src/template-resolver/api.ts +11 -7
- package/src/template-resolver/constants.ts +22 -0
- package/src/template-resolver/feature.ts +16 -1
- package/src/template-resolver/handlers/by-slug.query.ts +54 -0
- package/src/template-resolver/handlers/by-tenant.query.ts +57 -0
- package/src/template-resolver/handlers/set.write.ts +110 -0
- package/src/template-resolver/handlers/shared.ts +13 -2
- package/src/template-resolver/handlers/upsert-system.write.ts +2 -0
- package/src/template-resolver/handlers/upsert-tenant.write.ts +2 -0
- package/src/template-resolver/index.ts +7 -0
- package/src/template-resolver/qualified-names.ts +3 -0
- package/src/template-resolver/seeding.ts +126 -2
- package/src/template-resolver/table.ts +11 -2
- package/src/{text-content → template-resolver}/web/__tests__/client-plugin.test.tsx +26 -19
- package/src/{text-content → template-resolver}/web/__tests__/editor-read-only.test.tsx +10 -10
- package/src/{text-content → template-resolver}/web/__tests__/group-blocks.test.ts +34 -34
- package/src/template-resolver/web/client-plugin.tsx +352 -0
- package/src/template-resolver/web/index.ts +8 -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/src/text-content/README.md +0 -197
- package/src/text-content/__tests__/text-content.integration.test.ts +0 -503
- package/src/text-content/api.ts +0 -86
- package/src/text-content/changes.json +0 -14
- package/src/text-content/constants.ts +0 -21
- package/src/text-content/feature.ts +0 -53
- package/src/text-content/handlers/by-slug.query.ts +0 -55
- package/src/text-content/handlers/by-tenant.query.ts +0 -60
- package/src/text-content/handlers/set.write.ts +0 -141
- package/src/text-content/index.ts +0 -14
- package/src/text-content/seeding.ts +0 -139
- package/src/text-content/table.ts +0 -53
- package/src/text-content/web/client-plugin.tsx +0 -391
- package/src/text-content/web/index.ts +0 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.176.0",
|
|
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>",
|
|
@@ -104,12 +104,10 @@
|
|
|
104
104
|
"./personal-access-tokens/web": "./src/personal-access-tokens/web/index.ts",
|
|
105
105
|
"./feature-toggles": "./src/feature-toggles/index.ts",
|
|
106
106
|
"./feature-toggles/web": "./src/feature-toggles/web/index.ts",
|
|
107
|
-
"./text-content": "./src/text-content/index.ts",
|
|
108
|
-
"./text-content/seeding": "./src/text-content/seeding.ts",
|
|
109
|
-
"./text-content/web": "./src/text-content/web/index.ts",
|
|
110
107
|
"./template-resolver": "./src/template-resolver/index.ts",
|
|
111
108
|
"./template-resolver/seeding": "./src/template-resolver/seeding.ts",
|
|
112
109
|
"./template-resolver/testing": "./src/template-resolver/testing.ts",
|
|
110
|
+
"./template-resolver/web": "./src/template-resolver/web/index.ts",
|
|
113
111
|
"./renderer-foundation": "./src/renderer-foundation/index.ts",
|
|
114
112
|
"./legal-pages": "./src/legal-pages/index.ts",
|
|
115
113
|
"./legal-pages/web": "./src/legal-pages/web/index.ts",
|
|
@@ -122,12 +120,12 @@
|
|
|
122
120
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
123
121
|
},
|
|
124
122
|
"dependencies": {
|
|
125
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
126
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
127
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
128
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
129
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
130
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
123
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.176.0",
|
|
124
|
+
"@cosmicdrift/kumiko-framework": "0.176.0",
|
|
125
|
+
"@cosmicdrift/kumiko-headless": "0.176.0",
|
|
126
|
+
"@cosmicdrift/kumiko-renderer": "0.176.0",
|
|
127
|
+
"@cosmicdrift/kumiko-renderer-web": "0.176.0",
|
|
128
|
+
"@cosmicdrift/kumiko-types": "0.176.0",
|
|
131
129
|
"@mollie/api-client": "^4.5.0",
|
|
132
130
|
"imapflow": "^1.3.3",
|
|
133
131
|
"mailparser": "^3.9.8",
|
|
@@ -17,17 +17,21 @@ import {
|
|
|
17
17
|
tenantComplianceProfileTable,
|
|
18
18
|
} from "../compliance-profiles/schema/profile-selection";
|
|
19
19
|
import { seedComplianceProfile } from "../compliance-profiles/seeding";
|
|
20
|
-
import {
|
|
21
|
-
import { seedTextBlock } from "../
|
|
22
|
-
import {
|
|
20
|
+
import { createTemplateResolverFeature } from "../template-resolver/feature";
|
|
21
|
+
import { seedTextBlock } from "../template-resolver/seeding";
|
|
22
|
+
import {
|
|
23
|
+
type TemplateResourceRow,
|
|
24
|
+
templateResourceEntity,
|
|
25
|
+
templateResourcesTable,
|
|
26
|
+
} from "../template-resolver/table";
|
|
23
27
|
|
|
24
28
|
let stack: TestStack;
|
|
25
29
|
|
|
26
30
|
beforeAll(async () => {
|
|
27
31
|
stack = await setupTestStack({
|
|
28
|
-
features: [
|
|
32
|
+
features: [createTemplateResolverFeature(), createComplianceProfilesFeature()],
|
|
29
33
|
});
|
|
30
|
-
await unsafeCreateEntityTable(stack.db,
|
|
34
|
+
await unsafeCreateEntityTable(stack.db, templateResourceEntity);
|
|
31
35
|
await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
|
|
32
36
|
await createEventsTable(stack.db);
|
|
33
37
|
});
|
|
@@ -43,32 +47,32 @@ describe("boot-seed contract", () => {
|
|
|
43
47
|
await seedTextBlock(stack.db, {
|
|
44
48
|
tenantId,
|
|
45
49
|
slug: "imprint",
|
|
46
|
-
|
|
50
|
+
locale: "de",
|
|
47
51
|
title: "Impressum",
|
|
48
|
-
|
|
52
|
+
content: "seed body",
|
|
49
53
|
});
|
|
50
54
|
await seedTextBlock(stack.db, {
|
|
51
55
|
tenantId,
|
|
52
56
|
slug: "imprint",
|
|
53
|
-
|
|
57
|
+
locale: "de",
|
|
54
58
|
title: "Impressum (edited)",
|
|
55
|
-
|
|
59
|
+
content: "admin body",
|
|
56
60
|
ifExists: "update",
|
|
57
61
|
});
|
|
58
62
|
await seedTextBlock(stack.db, {
|
|
59
63
|
tenantId,
|
|
60
64
|
slug: "imprint",
|
|
61
|
-
|
|
65
|
+
locale: "de",
|
|
62
66
|
title: "Impressum",
|
|
63
|
-
|
|
67
|
+
content: "seed body",
|
|
64
68
|
});
|
|
65
69
|
|
|
66
|
-
const row = await fetchOne<
|
|
70
|
+
const row = await fetchOne<TemplateResourceRow>(stack.db, templateResourcesTable, {
|
|
67
71
|
tenantId,
|
|
68
72
|
slug: "imprint",
|
|
69
|
-
|
|
73
|
+
locale: "de",
|
|
70
74
|
});
|
|
71
|
-
expect(row).toMatchObject({ title: "Impressum (edited)",
|
|
75
|
+
expect(row).toMatchObject({ title: "Impressum (edited)", content: "admin body", version: 2 });
|
|
72
76
|
|
|
73
77
|
const events = await selectMany(stack.db, eventsTable, { aggregateId: String(row!.id) });
|
|
74
78
|
expect(events).toHaveLength(2);
|
|
@@ -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>;
|