@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.
Files changed (80) hide show
  1. package/package.json +8 -10
  2. package/src/__tests__/boot-seed-contract.integration.test.ts +18 -14
  3. package/src/auth-email-password/__tests__/login-gates.test.ts +12 -6
  4. package/src/auth-email-password/seeding.ts +4 -2
  5. package/src/auth-mfa/__tests__/token-secrets.test.ts +19 -0
  6. package/src/auth-mfa/handlers/reencrypt.job.ts +11 -20
  7. package/src/auth-mfa/token-secrets.ts +16 -14
  8. package/src/billing-foundation/subscription-tier-sync.ts +8 -1
  9. package/src/cap-counter/__tests__/stock-cap-guard.integration.test.ts +98 -0
  10. package/src/cap-counter/stock-cap-guard.ts +1 -1
  11. package/src/config/handlers/reencrypt.job.ts +7 -18
  12. package/src/delivery/delivery-service.ts +1 -1
  13. package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +47 -11
  14. package/src/document-ingest-foundation/__tests__/feature.test.ts +6 -2
  15. package/src/document-ingest-foundation/events.ts +15 -0
  16. package/src/document-ingest-foundation/feature.ts +27 -2
  17. package/src/inbound-mail-foundation/watch-supervisor.ts +11 -2
  18. package/src/jobs/handlers/catalog.query.ts +1 -4
  19. package/src/jobs/handlers/trigger.write.ts +2 -1
  20. package/src/jobs/is-manual-trigger.ts +5 -0
  21. package/src/legal-pages/README.md +26 -25
  22. package/src/legal-pages/__tests__/legal-pages.integration.test.ts +93 -61
  23. package/src/legal-pages/feature.ts +40 -31
  24. package/src/legal-pages/web/__tests__/client-plugin.test.ts +2 -2
  25. package/src/legal-pages/web/client-plugin.ts +3 -3
  26. package/src/managed-pages/screens/branding-screen.ts +1 -1
  27. package/src/presets/dsgvo-self-service.ts +10 -10
  28. package/src/seo/__tests__/robots-txt.test.ts +21 -0
  29. package/src/seo/__tests__/seo.integration.test.ts +13 -13
  30. package/src/seo/robots-txt.ts +21 -1
  31. package/src/sessions/handlers/revoke-all-for-user.write.ts +10 -1
  32. package/src/shared/classify-stored-envelope.ts +21 -0
  33. package/src/shared/index.ts +4 -0
  34. package/src/template-resolver/README.md +30 -1
  35. package/src/{text-content → template-resolver}/__tests__/seed-legal-content.integration.test.ts +32 -21
  36. package/src/template-resolver/__tests__/text-blocks.integration.test.ts +563 -0
  37. package/src/template-resolver/api.ts +11 -7
  38. package/src/template-resolver/constants.ts +22 -0
  39. package/src/template-resolver/feature.ts +16 -1
  40. package/src/template-resolver/handlers/by-slug.query.ts +54 -0
  41. package/src/template-resolver/handlers/by-tenant.query.ts +57 -0
  42. package/src/template-resolver/handlers/set.write.ts +110 -0
  43. package/src/template-resolver/handlers/shared.ts +13 -2
  44. package/src/template-resolver/handlers/upsert-system.write.ts +2 -0
  45. package/src/template-resolver/handlers/upsert-tenant.write.ts +2 -0
  46. package/src/template-resolver/index.ts +7 -0
  47. package/src/template-resolver/qualified-names.ts +3 -0
  48. package/src/template-resolver/seeding.ts +126 -2
  49. package/src/template-resolver/table.ts +11 -2
  50. package/src/{text-content → template-resolver}/web/__tests__/client-plugin.test.tsx +26 -19
  51. package/src/{text-content → template-resolver}/web/__tests__/editor-read-only.test.tsx +10 -10
  52. package/src/{text-content → template-resolver}/web/__tests__/group-blocks.test.ts +34 -34
  53. package/src/template-resolver/web/client-plugin.tsx +352 -0
  54. package/src/template-resolver/web/index.ts +8 -0
  55. package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +9 -0
  56. package/src/tenant-settings/tenant-defaults.ts +3 -0
  57. package/src/tier-engine/compose-app.ts +2 -13
  58. package/src/user/__tests__/seed-testing.integration.test.ts +25 -0
  59. package/src/user/seeding.ts +25 -1
  60. package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +1 -1
  61. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +2 -2
  62. package/src/user-data-rights/__tests__/request-deletion-url.test.ts +11 -8
  63. package/src/user-data-rights/feature.ts +3 -2
  64. package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +8 -6
  65. package/src/user-data-rights/web/__tests__/deletion-screens.test.tsx +5 -5
  66. package/src/user-data-rights/web/confirm-deletion-screen.tsx +3 -2
  67. package/src/text-content/README.md +0 -197
  68. package/src/text-content/__tests__/text-content.integration.test.ts +0 -503
  69. package/src/text-content/api.ts +0 -86
  70. package/src/text-content/changes.json +0 -14
  71. package/src/text-content/constants.ts +0 -21
  72. package/src/text-content/feature.ts +0 -53
  73. package/src/text-content/handlers/by-slug.query.ts +0 -55
  74. package/src/text-content/handlers/by-tenant.query.ts +0 -60
  75. package/src/text-content/handlers/set.write.ts +0 -141
  76. package/src/text-content/index.ts +0 -14
  77. package/src/text-content/seeding.ts +0 -139
  78. package/src/text-content/table.ts +0 -53
  79. package/src/text-content/web/client-plugin.tsx +0 -391
  80. 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.174.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.174.0",
126
- "@cosmicdrift/kumiko-framework": "0.174.0",
127
- "@cosmicdrift/kumiko-headless": "0.174.0",
128
- "@cosmicdrift/kumiko-renderer": "0.174.0",
129
- "@cosmicdrift/kumiko-renderer-web": "0.174.0",
130
- "@cosmicdrift/kumiko-types": "0.174.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 { createTextContentFeature } from "../text-content/feature";
21
- import { seedTextBlock } from "../text-content/seeding";
22
- import { type TextBlockRow, textBlockEntity, textBlocksTable } from "../text-content/table";
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: [createTextContentFeature(), createComplianceProfilesFeature()],
32
+ features: [createTemplateResolverFeature(), createComplianceProfilesFeature()],
29
33
  });
30
- await unsafeCreateEntityTable(stack.db, textBlockEntity);
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
- lang: "de",
50
+ locale: "de",
47
51
  title: "Impressum",
48
- body: "seed body",
52
+ content: "seed body",
49
53
  });
50
54
  await seedTextBlock(stack.db, {
51
55
  tenantId,
52
56
  slug: "imprint",
53
- lang: "de",
57
+ locale: "de",
54
58
  title: "Impressum (edited)",
55
- body: "admin body",
59
+ content: "admin body",
56
60
  ifExists: "update",
57
61
  });
58
62
  await seedTextBlock(stack.db, {
59
63
  tenantId,
60
64
  slug: "imprint",
61
- lang: "de",
65
+ locale: "de",
62
66
  title: "Impressum",
63
- body: "seed body",
67
+ content: "seed body",
64
68
  });
65
69
 
66
- const row = await fetchOne<TextBlockRow>(stack.db, textBlocksTable, {
70
+ const row = await fetchOne<TemplateResourceRow>(stack.db, templateResourcesTable, {
67
71
  tenantId,
68
72
  slug: "imprint",
69
- lang: "de",
73
+ locale: "de",
70
74
  });
71
- expect(row).toMatchObject({ title: "Impressum (edited)", body: "admin body", version: 2 });
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.isSuccess).toBe(false);
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
- if (!g.ok) {
40
- expect(g.result.isSuccess).toBe(false);
41
- // anti-enumeration — same family as invalid credentials
42
- expect(g.result).toMatchObject({ isSuccess: false });
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-emailVerified-FlagDefault false (unverified),
194
- * gleicher Mechanismus wie seedUserWithPassword.emailVerified. */
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 { type EnvelopeCipher, isStoredEnvelope } from "@cosmicdrift/kumiko-framework/secrets";
42
- import { type ChunkedMigrationStopReason, runChunkedMigration } from "../../shared";
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
- const totpClass = classifyEnvelope(envelopeValues.totpSecret, targetVersion);
170
- const recoveryClass = classifyEnvelope(envelopeValues.recoveryCodes, targetVersion);
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
- // The two HKDF purposes auth-mfa needs. They live here rather than at each
14
- // call site because an app typically resolves them twice — once in the prod
15
- // entrypoint against a validated JWT_SECRET, once in the dev server against
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
- overrides.setupTokenSecret ?? derivePurposeSecret(masterSecret, SETUP_TOKEN_PURPOSE),
36
- challengeTokenSecret:
37
- overrides.challengeTokenSecret ?? derivePurposeSecret(masterSecret, CHALLENGE_TOKEN_PURPOSE),
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
- return { isSuccess: false, error: syncError };
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, { tenantId, ...spec.where });
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 { type EnvelopeCipher, isStoredEnvelope } from "@cosmicdrift/kumiko-framework/secrets";
29
- import { type ChunkedMigrationStopReason, runChunkedMigration } from "../../shared";
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 = classifyRow(row.value, targetVersion);
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
- const payload = rows[0]?.payload;
101
- expect(payload?.["fileRefId"]).toBe(id);
102
- expect(payload?.["storageKey"]).toBe(storageKey);
103
- expect(payload?.["mimeType"]).toBe("application/pdf");
104
- expect(payload?.["data"]).toBeUndefined();
105
- expect(payload?.["binary"]).toBeUndefined();
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: 26 * 1024 * 1024,
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 event under the exported QN", () => {
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>;