@cosmicdrift/kumiko-bundled-features 0.165.0 → 2.0.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 (152) hide show
  1. package/package.json +10 -6
  2. package/src/__tests__/access-invalidation-consumer.integration.test.ts +279 -0
  3. package/src/__tests__/access-invalidation-stream.integration.test.ts +217 -0
  4. package/src/audit/__tests__/audit.integration.test.ts +50 -21
  5. package/src/audit/handlers/list.query.ts +1 -0
  6. package/src/auth-email-password/__tests__/auth.integration.test.ts +6 -0
  7. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +34 -0
  8. package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +9 -14
  9. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +16 -5
  10. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +54 -0
  11. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -1
  12. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -1
  13. package/src/auth-email-password/handlers/login.write.ts +8 -0
  14. package/src/auth-email-password/handlers/self-registration-status.query.ts +6 -3
  15. package/src/auth-email-password/handlers/signup-request.write.ts +12 -1
  16. package/src/auth-email-password/i18n.ts +2 -0
  17. package/src/auth-email-password/seeding.ts +41 -23
  18. package/src/auth-email-password/web/__tests__/auth-client.test.ts +13 -0
  19. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +55 -8
  20. package/src/auth-email-password/web/__tests__/client-plugin.test.tsx +34 -0
  21. package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +8 -1
  22. package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +5 -1
  23. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +3 -0
  24. package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +8 -2
  25. package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +5 -1
  26. package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +5 -1
  27. package/src/auth-email-password/web/auth-client.ts +32 -10
  28. package/src/auth-email-password/web/auth-gate.tsx +17 -21
  29. package/src/auth-email-password/web/client-plugin.ts +15 -2
  30. package/src/auth-email-password/web/index.ts +5 -1
  31. package/src/auth-email-password/web/login-screen.tsx +7 -3
  32. package/src/auth-email-password/web/request-account-unlock-screen.tsx +1 -1
  33. package/src/auth-foundation/__tests__/session-store.test.ts +2 -4
  34. package/src/auth-foundation/__tests__/tenant-providers.test.ts +24 -0
  35. package/src/auth-foundation/__tests__/token-verifier.test.ts +36 -3
  36. package/src/auth-foundation/boot-checks.ts +74 -69
  37. package/src/auth-foundation/resolve-anonymous-access.ts +13 -5
  38. package/src/auth-foundation/types.ts +3 -3
  39. package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +61 -1
  40. package/src/auth-mfa/__tests__/no-redis.integration.test.ts +209 -0
  41. package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +79 -1
  42. package/src/auth-mfa/handlers/disable.write.ts +16 -1
  43. package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +72 -42
  44. package/src/auth-mfa/handlers/enable-confirm.write.ts +23 -12
  45. package/src/auth-mfa/handlers/reencrypt.job.ts +26 -17
  46. package/src/auth-mfa/handlers/regenerate-recovery.write.ts +15 -1
  47. package/src/auth-mfa/handlers/verify.write.ts +28 -24
  48. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +5 -1
  49. package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +10 -1
  50. package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +104 -1
  51. package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +5 -1
  52. package/src/auth-mfa/web/i18n.ts +4 -0
  53. package/src/auth-mfa/web/mfa-client.ts +13 -2
  54. package/src/auth-mfa/web/mfa-enable-screen.tsx +1 -1
  55. package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +25 -2
  56. package/src/billing-foundation/__tests__/subscription-provider-contract.ts +36 -12
  57. package/src/config/__tests__/backing-secrets.integration.test.ts +4 -0
  58. package/src/config/__tests__/cascade.integration.test.ts +13 -2
  59. package/src/config/__tests__/pii-encrypted.integration.test.ts +6 -6
  60. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +78 -5
  61. package/src/config/db/queries/resolver.ts +3 -3
  62. package/src/config/handlers/reencrypt.job.ts +36 -24
  63. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +14 -0
  64. package/src/document-ingest-foundation/__tests__/feature.test.ts +38 -0
  65. package/src/document-ingest-foundation/__tests__/pages.test.ts +37 -0
  66. package/src/document-ingest-foundation/entity.ts +10 -3
  67. package/src/document-ingest-foundation/feature.ts +26 -8
  68. package/src/document-ingest-foundation/index.ts +1 -0
  69. package/src/document-ingest-foundation/pages.ts +34 -0
  70. package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +114 -11
  71. package/src/feature-toggles/compose-tier-resolver.ts +77 -8
  72. package/src/feature-toggles/global-feature-state-table.ts +15 -12
  73. package/src/files-provider-s3/s3-provider.ts +12 -1
  74. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +78 -1
  75. package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +58 -40
  76. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +9 -1
  77. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +22 -12
  78. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +1 -1
  79. package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +35 -8
  80. package/src/inbound-provider-inmemory/__tests__/feature.test.ts +37 -33
  81. package/src/jobs/__tests__/job-instant.test.ts +15 -0
  82. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +19 -9
  83. package/src/jobs/feature.ts +6 -5
  84. package/src/jobs/job-instant.ts +9 -0
  85. package/src/mail-foundation/__tests__/mail-transport-contract.ts +27 -11
  86. package/src/mail-transport-inmemory/__tests__/feature.test.ts +10 -6
  87. package/src/mail-transport-smtp/__tests__/feature.test.ts +10 -5
  88. package/src/page-render/__tests__/markdown.test.ts +29 -0
  89. package/src/page-render/markdown.ts +1 -9
  90. package/src/personal-access-tokens/feature.ts +1 -1
  91. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +3 -1
  92. package/src/sessions/__tests__/sessions-screens.boot.test.ts +5 -10
  93. package/src/sessions/__tests__/sessions.integration.test.ts +72 -1
  94. package/src/sessions/feature.ts +12 -0
  95. package/src/sessions/handlers/revoke-all-for-user.write.ts +51 -12
  96. package/src/sessions/handlers/revoke-all-others.write.ts +19 -1
  97. package/src/sessions/handlers/revoke.write.ts +13 -0
  98. package/src/sessions/index.ts +7 -0
  99. package/src/sessions/session-callbacks.ts +58 -5
  100. package/src/sessions/session-revoked-event.ts +29 -0
  101. package/src/shared/__tests__/chunked-entity-migration.test.ts +4 -4
  102. package/src/shared/chunked-entity-migration.ts +2 -2
  103. package/src/shared/grace-period.test.ts +26 -0
  104. package/src/shared/grace-period.ts +20 -0
  105. package/src/shared/index.ts +2 -0
  106. package/src/shared/map-with-concurrency.test.ts +104 -0
  107. package/src/shared/map-with-concurrency.ts +38 -0
  108. package/src/subscription-mollie/__tests__/feature.test.ts +16 -6
  109. package/src/subscription-mollie/__tests__/verify-webhook.test.ts +21 -0
  110. package/src/subscription-mollie/verify-webhook.ts +1 -0
  111. package/src/subscription-stripe/__tests__/feature.test.ts +14 -7
  112. package/src/subscription-stripe/__tests__/verify-webhook.test.ts +20 -0
  113. package/src/subscription-stripe/verify-webhook.ts +3 -2
  114. package/src/tenant/__tests__/members-query-kms.integration.test.ts +2 -2
  115. package/src/tenant/__tests__/multi-tenant.integration.test.ts +1 -1
  116. package/src/tenant/handlers/active-tenant-ids.query.ts +8 -2
  117. package/src/tenant/handlers/invitations.query.ts +13 -15
  118. package/src/tenant/handlers/members.query.ts +12 -12
  119. package/src/tenant/seeding.ts +110 -16
  120. package/src/tenant-lifecycle/boot-checks.ts +11 -4
  121. package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +4 -6
  122. package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +115 -0
  123. package/src/tenant-settings/config.ts +44 -0
  124. package/src/tenant-settings/constants.ts +16 -0
  125. package/src/tenant-settings/feature.ts +49 -0
  126. package/src/tenant-settings/index.ts +8 -0
  127. package/src/tenant-settings/tenant-defaults.ts +67 -0
  128. package/src/user/handlers/find-for-auth.query.ts +1 -1
  129. package/src/user/seeding.ts +8 -0
  130. package/src/user-data-rights/COMPLIANCE.md +1 -1
  131. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +65 -0
  132. package/src/user-data-rights/__tests__/boot-checks.test.ts +15 -17
  133. package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +9 -5
  134. package/src/user-data-rights/__tests__/download.integration.test.ts +12 -41
  135. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +7 -10
  136. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +15 -1
  137. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +120 -0
  138. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +9 -4
  139. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +74 -8
  140. package/src/user-data-rights/db/queries/__tests__/forget-cleanup.integration.test.ts +69 -0
  141. package/src/user-data-rights/db/queries/forget-cleanup.ts +9 -2
  142. package/src/user-data-rights/feature.ts +45 -45
  143. package/src/user-data-rights/handlers/cancel-deletion.write.ts +8 -11
  144. package/src/user-data-rights/handlers/download-by-job.query.ts +6 -6
  145. package/src/user-data-rights/handlers/download-by-token.query.ts +6 -6
  146. package/src/user-data-rights/handlers/lift-restriction.write.ts +33 -2
  147. package/src/user-data-rights/handlers/list-download-attempts.query.ts +1 -0
  148. package/src/user-data-rights/handlers/my-audit-log.query.ts +1 -0
  149. package/src/user-data-rights/handlers/restrict-account.write.ts +33 -4
  150. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +2 -0
  151. package/src/user-data-rights/lib/is-admin-actor.ts +19 -0
  152. package/src/user-data-rights/run-forget-cleanup.ts +27 -7
@@ -40,8 +40,16 @@ const testRegistry = createRegistry([
40
40
  }),
41
41
  ]);
42
42
 
43
+ // Mirrors tier-engine's real resolver contract (tier-engine/feature.ts's
44
+ // mergeAlwaysOn calls): SYSTEM_TENANT_ID always includes at least the
45
+ // always-on features merged in, even when zero tiers grant anything —
46
+ // tests exercising "no tier grants this feature" must still see a non-empty
47
+ // SYSTEM_TENANT_ID union, exactly like a real app always has.
43
48
  function tierResolverGranting(...features: readonly string[]): EffectiveFeaturesResolver {
44
- return ((_tenantId: TenantId) => new Set(features)) as EffectiveFeaturesResolver;
49
+ return (tenantId: TenantId) =>
50
+ tenantId === SYSTEM_TENANT_ID
51
+ ? new Set([...features, "always-on-stand-in"])
52
+ : new Set(features);
45
53
  }
46
54
 
47
55
  describe("composeTierResolverWithGlobalToggles", () => {
@@ -77,10 +85,10 @@ describe("composeTierResolverWithGlobalToggles", () => {
77
85
  // nothing (e.g. Free). An operator flipping the global row to true
78
86
  // must not leak it in — that decision belongs to the tier, not the
79
87
  // toggle.
80
- const tierResolver = ((tenantId: TenantId) =>
88
+ const tierResolver: EffectiveFeaturesResolver = (tenantId: TenantId) =>
81
89
  tenantId === SYSTEM_TENANT_ID
82
90
  ? new Set(["personal-access-tokens"]) // union of all tiers' features
83
- : new Set<string>()) as EffectiveFeaturesResolver; // this tenant's (Free) tier grants nothing
91
+ : new Set<string>(); // this tenant's (Free) tier grants nothing
84
92
  const runtime = new GlobalFeatureToggleRuntime(fakeDb, testRegistry);
85
93
  runtime.apply("personal-access-tokens", true);
86
94
  const composed = composeTierResolverWithGlobalToggles(tierResolver, runtime, testRegistry);
@@ -141,11 +149,46 @@ describe("composeTierResolverWithGlobalToggles", () => {
141
149
  expect([...composed("t1" as TenantId)]).toEqual(["auth-self-registration"]);
142
150
  });
143
151
 
152
+ test("kumiko-framework#1479/2: an empty SYSTEM_TENANT_ID union throws instead of silently disabling all tier-gating", () => {
153
+ // A tierResolver that doesn't implement the SYSTEM_TENANT_ID convention
154
+ // at all (e.g. always returns per-tenant grants, never the union) —
155
+ // the old code would treat this as "zero tier-managed features", so
156
+ // EVERY toggleable feature falls through to the tier-unaware cascade
157
+ // and tier-gating is silently defeated.
158
+ const brokenTierResolver: EffectiveFeaturesResolver = (_tenantId: TenantId) =>
159
+ new Set<string>();
160
+ const runtime = new GlobalFeatureToggleRuntime(fakeDb, testRegistry);
161
+ const composed = composeTierResolverWithGlobalToggles(
162
+ brokenTierResolver,
163
+ runtime,
164
+ testRegistry,
165
+ );
166
+ expect(() => composed("t1" as TenantId)).toThrow(/SYSTEM_TENANT_ID/);
167
+ });
168
+
169
+ test("kumiko-framework#1479/2: tierManaged is computed lazily — a resolver not ready at compose-time is fine as long as it's ready by first call", () => {
170
+ let ready = false;
171
+ const lateBoundResolver: EffectiveFeaturesResolver = (tenantId: TenantId) => {
172
+ if (!ready) throw new Error("tierResolver not wired yet");
173
+ return tenantId === SYSTEM_TENANT_ID
174
+ ? new Set(["personal-access-tokens"])
175
+ : new Set<string>();
176
+ };
177
+ const runtime = new GlobalFeatureToggleRuntime(fakeDb, testRegistry);
178
+ // Composing must NOT eagerly call tierResolver(SYSTEM_TENANT_ID) — the
179
+ // late-bound holder pattern (run-dev-app.ts) builds the real resolver
180
+ // only after composeTierResolverWithGlobalToggles has already run.
181
+ const composed = composeTierResolverWithGlobalToggles(lateBoundResolver, runtime, testRegistry);
182
+ ready = true;
183
+ expect(() => composed("t1" as TenantId)).not.toThrow();
184
+ });
185
+
144
186
  test("preserves the tier resolver's trialGate", async () => {
145
187
  const runtime = new GlobalFeatureToggleRuntime(fakeDb, testRegistry);
146
- const tierResolver = tierResolverGranting();
147
188
  const trialGate = async (_tenantId: TenantId, _featureName: string) => true;
148
- Object.assign(tierResolver, { trialGate });
189
+ const tierResolver: EffectiveFeaturesResolver = Object.assign(tierResolverGranting(), {
190
+ trialGate,
191
+ });
149
192
 
150
193
  const composed = composeTierResolverWithGlobalToggles(tierResolver, runtime, testRegistry);
151
194
  expect(composed.trialGate).toBe(trialGate);
@@ -177,10 +220,10 @@ describe("composeTierResolverWithGlobalToggles — non-toggleable + requires() i
177
220
  // otherwise composeTierResolverWithGlobalToggles' `!tierManaged.has()`
178
221
  // filter would incorrectly drop it (tier-managed but absent from this
179
222
  // tenant's own tier subset).
180
- const tierResolver = ((tenantId: TenantId) =>
223
+ const tierResolver: EffectiveFeaturesResolver = (tenantId: TenantId) =>
181
224
  tenantId === SYSTEM_TENANT_ID
182
225
  ? new Set(["tenant", "personal-access-tokens"])
183
- : new Set(["tenant"])) as EffectiveFeaturesResolver;
226
+ : new Set(["tenant"]);
184
227
  const runtime = new GlobalFeatureToggleRuntime(fakeDb, registry);
185
228
  const composed = composeTierResolverWithGlobalToggles(tierResolver, runtime, registry);
186
229
  expect([...composed("t1" as TenantId)]).toContain("tenant");
@@ -190,12 +233,72 @@ describe("composeTierResolverWithGlobalToggles — non-toggleable + requires() i
190
233
  // "pat-companion" is tier-unaware (no tier ever lists it) but requires
191
234
  // "personal-access-tokens", which IS tier-managed and this tenant's own
192
235
  // tier grants nothing — the dependency must not silently resolve true.
193
- const tierResolver = ((tenantId: TenantId) =>
194
- tenantId === SYSTEM_TENANT_ID
195
- ? new Set(["personal-access-tokens"])
196
- : new Set<string>()) as EffectiveFeaturesResolver;
236
+ const tierResolver: EffectiveFeaturesResolver = (tenantId: TenantId) =>
237
+ tenantId === SYSTEM_TENANT_ID ? new Set(["personal-access-tokens"]) : new Set<string>();
197
238
  const runtime = new GlobalFeatureToggleRuntime(fakeDb, registry);
198
239
  const composed = composeTierResolverWithGlobalToggles(tierResolver, runtime, registry);
199
240
  expect([...composed("t1" as TenantId)]).not.toContain("pat-companion");
200
241
  });
242
+
243
+ test("kumiko-framework#1471/1: a tier-granted feature whose requires() dependency is globally disabled is excluded too", () => {
244
+ // "pat-companion" IS in this tenant's tier grant this time (unlike the
245
+ // test above) — the tenant's tier says yes, but its requires()
246
+ // dependency "personal-access-tokens" is killed globally via override.
247
+ // The old code only checked `readOverride(name) !== false` on
248
+ // "pat-companion" itself (which has no override, so it stayed) without
249
+ // ever re-running the requires() cascade over the tier-granted set —
250
+ // exactly the class of bug #1471/1 describes (channel-email staying up
251
+ // after its `delivery` dependency is killed).
252
+ const tierResolver: EffectiveFeaturesResolver = (tenantId: TenantId) =>
253
+ tenantId === SYSTEM_TENANT_ID
254
+ ? new Set(["personal-access-tokens", "pat-companion"])
255
+ : new Set(["personal-access-tokens", "pat-companion"]);
256
+ const runtime = new GlobalFeatureToggleRuntime(fakeDb, registry);
257
+ runtime.apply("personal-access-tokens", false);
258
+ const composed = composeTierResolverWithGlobalToggles(tierResolver, runtime, registry);
259
+ const result = [...composed("t1" as TenantId)];
260
+ expect(result).not.toContain("personal-access-tokens");
261
+ expect(result).not.toContain("pat-companion");
262
+ });
263
+
264
+ test("kumiko-framework#1471/2: a global override cannot disable a non-toggleable (always-on) feature", () => {
265
+ // "tenant" has no r.toggleable() call at all — computeEffectiveFeatures'
266
+ // Rule 1 says non-toggleable features ignore overrides entirely. The old
267
+ // code applied `readOverride(name) !== false` to every name in tierSet
268
+ // with no toggleability check, so a stray override row (seed script,
269
+ // ops SQL, or a feature that used to be toggleable and had `r.toggleable()`
270
+ // removed) could disable an always-on feature outright.
271
+ const tierResolver: EffectiveFeaturesResolver = (tenantId: TenantId) =>
272
+ tenantId === SYSTEM_TENANT_ID ? new Set(["tenant"]) : new Set(["tenant"]);
273
+ const runtime = new GlobalFeatureToggleRuntime(fakeDb, registry);
274
+ runtime.apply("tenant", false);
275
+ const composed = composeTierResolverWithGlobalToggles(tierResolver, runtime, registry);
276
+ expect([...composed("t1" as TenantId)]).toContain("tenant");
277
+ });
278
+
279
+ test("framework#1528: a resolver satisfying the emptiness guard but not merging always-on per-tenant still surfaces the always-on feature", () => {
280
+ // Custom composeApp resolver: implements the SYSTEM_TENANT_ID convention
281
+ // (union includes "tenant") but, unlike tier-engine's own mergeAlwaysOn,
282
+ // returns only the tier-only set for real tenants. Before the Rule-1
283
+ // structural fix, "tenant" landed in the raw (unfiltered) tierManaged set
284
+ // and got skipped by the globalCascade loop too, since it's absent from
285
+ // this tenant's own tierSet — a silent 403 on every always-on handler.
286
+ const tierResolver: EffectiveFeaturesResolver = (tenantId: TenantId) =>
287
+ tenantId === SYSTEM_TENANT_ID
288
+ ? new Set(["tenant", "personal-access-tokens"])
289
+ : new Set<string>();
290
+ const runtime = new GlobalFeatureToggleRuntime(fakeDb, registry);
291
+ const composed = composeTierResolverWithGlobalToggles(tierResolver, runtime, registry);
292
+ expect([...composed("t1" as TenantId)]).toContain("tenant");
293
+ });
294
+
295
+ test("a tier-granted name with no registry entry is dropped by the requires() cascade fixpoint", () => {
296
+ const tierResolver: EffectiveFeaturesResolver = (tenantId: TenantId) =>
297
+ tenantId === SYSTEM_TENANT_ID
298
+ ? new Set(["tenant", "ghost-feature"])
299
+ : new Set(["tenant", "ghost-feature"]);
300
+ const runtime = new GlobalFeatureToggleRuntime(fakeDb, registry);
301
+ const composed = composeTierResolverWithGlobalToggles(tierResolver, runtime, registry);
302
+ expect([...composed("t1" as TenantId)]).not.toContain("ghost-feature");
303
+ });
201
304
  });
@@ -1,6 +1,6 @@
1
1
  import {
2
- computeEffectiveFeatures,
3
2
  type EffectiveFeaturesResolver,
3
+ isToggleableFeature,
4
4
  type Registry,
5
5
  SYSTEM_TENANT_ID,
6
6
  } from "@cosmicdrift/kumiko-framework/engine";
@@ -39,24 +39,93 @@ import type { GlobalFeatureToggleRuntime } from "./toggle-runtime";
39
39
  export function composeTierResolverWithGlobalToggles(
40
40
  tierResolver: EffectiveFeaturesResolver,
41
41
  toggleRuntime: GlobalFeatureToggleRuntime,
42
+ // registry needed for toggleability lookups; toggleRuntime keeps its own registry reference private.
42
43
  registry: Registry,
43
44
  ): EffectiveFeaturesResolver {
44
- const tierManaged = tierResolver(SYSTEM_TENANT_ID);
45
+ // Lazy + memoized: computed on first composed(tenantId) call, not at
46
+ // compose-time. Late-bound holder wiring (e.g. run-dev-app.ts's tier
47
+ // resolver plugin) may not have its real tierResolver built yet when
48
+ // composeTierResolverWithGlobalToggles itself runs.
49
+ let tierManaged: ReadonlySet<string> | undefined;
50
+
51
+ const composed: EffectiveFeaturesResolver = (tenantId) => {
52
+ if (tierManaged === undefined) {
53
+ const managed = tierResolver(SYSTEM_TENANT_ID);
54
+ // fail-loud: an empty union means the resolver doesn't implement the SYSTEM_TENANT_ID convention at all.
55
+ if (managed.size === 0) {
56
+ throw new Error(
57
+ "composeTierResolverWithGlobalToggles: tierResolver(SYSTEM_TENANT_ID) " +
58
+ "returned an empty set. tier-engine's SYSTEM_TENANT_ID convention " +
59
+ "(union of every tier's features, plus always-on features) must " +
60
+ "never be empty — an empty result means the tierResolver passed in " +
61
+ "doesn't implement that convention, which would silently disable " +
62
+ "all tier-gating (every feature would be treated as tier-unaware).",
63
+ );
64
+ }
65
+ // Structural Rule 1: only the TOGGLEABLE subset of the union is tier-managed.
66
+ // Non-toggleable (always-on) names must keep reaching `result` through the
67
+ // globalCascade loop below even if this resolver's SYSTEM_TENANT_ID answer
68
+ // satisfies the emptiness guard above without also merging always-on into
69
+ // every per-tenant tierSet (framework#1528).
70
+ tierManaged = new Set(
71
+ [...managed].filter((name) => {
72
+ const feature = registry.getFeature(name);
73
+ return feature !== undefined && isToggleableFeature(feature);
74
+ }),
75
+ );
76
+ }
45
77
 
46
- const composed = ((tenantId) => {
47
78
  const tierSet = tierResolver(tenantId);
48
79
  const result = new Set<string>();
49
80
  for (const name of tierSet) {
50
- if (toggleRuntime.readOverride(name) !== false) result.add(name);
81
+ // Only a toggleable feature's override can narrow the tier's grant —
82
+ // computeEffectiveFeatures' Rule 1 ignores overrides for features
83
+ // without a toggleableDefault, and an override row can otherwise
84
+ // reach a non-toggleable feature via seed-scripts/ops-SQL/an
85
+ // r.toggleable() removal after the fact (readOverride/refresh() have
86
+ // no toggleability gate of their own).
87
+ const feature = registry.getFeature(name);
88
+ const isToggleable = feature !== undefined && isToggleableFeature(feature);
89
+ if (!isToggleable || toggleRuntime.readOverride(name) !== false) {
90
+ result.add(name);
91
+ }
51
92
  }
52
- const globalCascade = computeEffectiveFeatures(registry, (name) =>
53
- toggleRuntime.readOverride(name),
54
- );
93
+ // toggleRuntime.effectiveFeatures() already applies computeEffectiveFeatures'
94
+ // full rule set (toggleableDefault ?? override, THEN the requires() cascade)
95
+ // — reusing it here instead of calling computeEffectiveFeatures directly
96
+ // keeps this in lockstep with the runtime's own dispatcher-facing callback.
97
+ const globalCascade = toggleRuntime.effectiveFeatures();
55
98
  for (const name of globalCascade) {
56
99
  if (!tierManaged.has(name)) result.add(name);
57
100
  }
101
+
102
+ // requires() cascade over the combined result: a feature narrowed off by
103
+ // either loop above (tier-managed override, or absent from both sets)
104
+ // must also drop anything that itself requires it — computeEffectiveFeatures
105
+ // enforces this for its own single reader, but tierSet's membership isn't
106
+ // reader-driven, so the cascade has to be re-applied over the union.
107
+ let changed = true;
108
+ while (changed) {
109
+ changed = false;
110
+ for (const name of [...result]) {
111
+ const feature = registry.getFeature(name);
112
+ if (!feature) {
113
+ result.delete(name);
114
+ changed = true;
115
+ continue;
116
+ }
117
+ for (const dep of feature.requires) {
118
+ if (!result.has(dep)) {
119
+ result.delete(name);
120
+ changed = true;
121
+ break;
122
+ }
123
+ }
124
+ }
125
+ }
126
+
58
127
  return result;
59
- }) as EffectiveFeaturesResolver;
128
+ };
60
129
 
61
130
  if (tierResolver.trialGate) {
62
131
  return Object.assign(composed, { trialGate: tierResolver.trialGate });
@@ -1,6 +1,6 @@
1
+ import { asEntityTableMeta } from "@cosmicdrift/kumiko-framework/bun-db";
1
2
  import {
2
3
  boolean,
3
- defineUnmanagedTable,
4
4
  type EntityTableMeta,
5
5
  instant,
6
6
  integer,
@@ -31,14 +31,17 @@ export const globalFeatureStateTable = pgTable("store_global_feature_state", {
31
31
 
32
32
  // r.storeTable meta — without this, collectTableMetas(FEATURES) never
33
33
  // sees the table, so `kumiko schema generate` reports no changes and no
34
- // app ever gets a migration for it (framework gap, not app-local).
35
- export const globalFeatureStateTableMeta: EntityTableMeta = defineUnmanagedTable({
36
- tableName: "store_global_feature_state",
37
- columns: [
38
- { name: "feature_name", pgType: "text", notNull: true, primaryKey: true },
39
- { name: "enabled", pgType: "boolean", notNull: true },
40
- { name: "version", pgType: "integer", notNull: true, defaultSql: "1" },
41
- { name: "updated_at", pgType: "timestamptz", notNull: true, defaultSql: "now()" },
42
- { name: "updated_by", pgType: "text", notNull: false },
43
- ],
44
- });
34
+ // app ever gets a migration for it (framework gap, not app-local). Derived
35
+ // from the pgTable above instead of hand-written a second time — the two
36
+ // used to drift independently (framework#1529): a column added only to
37
+ // the pgTable made queries type-check while the migration generator stayed
38
+ // blind to it, failing in prod with "column ... does not exist" while every
39
+ // mocked-stack test stayed green.
40
+ const derivedMeta = asEntityTableMeta(globalFeatureStateTable);
41
+ if (!derivedMeta) {
42
+ throw new Error(
43
+ "global-feature-state-table: asEntityTableMeta(globalFeatureStateTable) returned undefined " +
44
+ "— the pgTable definition no longer round-trips through the unmanaged-table meta builder.",
45
+ );
46
+ }
47
+ export const globalFeatureStateTableMeta: EntityTableMeta = derivedMeta;
@@ -1,4 +1,8 @@
1
- import type { FileStorageProvider, SignedUrlOptions } from "@cosmicdrift/kumiko-framework/files";
1
+ import {
2
+ assertSafeStorageKey,
3
+ type FileStorageProvider,
4
+ type SignedUrlOptions,
5
+ } from "@cosmicdrift/kumiko-framework/files";
2
6
 
3
7
  // =============================================================================
4
8
  // Operator-Pflicht-Setup (Multipart-Upload-Cleanup)
@@ -72,10 +76,12 @@ export function createS3Provider(config: S3ProviderConfig): FileStorageProvider
72
76
 
73
77
  return {
74
78
  async write(key, data, mimeType): Promise<void> {
79
+ assertSafeStorageKey(key);
75
80
  await client.write(key, data, mimeType !== undefined ? { type: mimeType } : undefined);
76
81
  },
77
82
 
78
83
  async writeStream(key, source, options): Promise<void> {
84
+ assertSafeStorageKey(key);
79
85
  // Echtes multipart-streaming via Bun's S3-Writer — partSize steuert die
80
86
  // Part-Boundary intern (AWS/R2 verlangen non-final Parts >= 5 MiB,
81
87
  // sonst EntityTooSmall beim CompleteMultipartUpload). Manuelles flush()
@@ -96,10 +102,12 @@ export function createS3Provider(config: S3ProviderConfig): FileStorageProvider
96
102
  },
97
103
 
98
104
  async read(key): Promise<Uint8Array> {
105
+ assertSafeStorageKey(key);
99
106
  return new Uint8Array(await client.file(key).arrayBuffer());
100
107
  },
101
108
 
102
109
  readStream(key): AsyncIterable<Uint8Array> {
110
+ assertSafeStorageKey(key);
103
111
  // Lazy: erst beim ersten chunk-pull wird der GET-Request abgesetzt.
104
112
  // Existiert der Key nicht, faellt der Error genau dort (nicht beim
105
113
  // readStream-Aufruf) — gleiches Lazy-Verhalten wie inmemory + local.
@@ -113,10 +121,12 @@ export function createS3Provider(config: S3ProviderConfig): FileStorageProvider
113
121
  },
114
122
 
115
123
  async delete(key): Promise<void> {
124
+ assertSafeStorageKey(key);
116
125
  await client.delete(key);
117
126
  },
118
127
 
119
128
  async exists(key): Promise<boolean> {
129
+ assertSafeStorageKey(key);
120
130
  return client.exists(key);
121
131
  },
122
132
 
@@ -125,6 +135,7 @@ export function createS3Provider(config: S3ProviderConfig): FileStorageProvider
125
135
  expiresInSeconds: number,
126
136
  options?: SignedUrlOptions,
127
137
  ): Promise<string> {
138
+ assertSafeStorageKey(key);
128
139
  // contentDisposition wird von Bun als response-content-disposition
129
140
  // Query-Param signiert (Response-Override fuer den GET-Download) —
130
141
  // der Browser sieht den Original-Dateinamen statt des UUID-Keys.
@@ -22,12 +22,14 @@ import {
22
22
  PII_ERASED_SENTINEL,
23
23
  resetPiiSubjectKmsForTests,
24
24
  } from "@cosmicdrift/kumiko-framework/crypto";
25
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
25
+ import { createTenantDb, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
26
+ import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
26
27
  import {
27
28
  append,
28
29
  createEventsTable,
29
30
  getStreamVersion,
30
31
  loadAggregate,
32
+ VersionConflictError,
31
33
  } from "@cosmicdrift/kumiko-framework/event-store";
32
34
  import { rebuildProjection } from "@cosmicdrift/kumiko-framework/pipeline";
33
35
  import {
@@ -37,6 +39,7 @@ import {
37
39
  testTenantId,
38
40
  unsafeCreateEntityTable,
39
41
  } from "@cosmicdrift/kumiko-framework/stack";
42
+ import { bridgeStub } from "@cosmicdrift/kumiko-framework/testing";
40
43
  import {
41
44
  createComplianceProfilesFeature,
42
45
  tenantComplianceProfileEntity,
@@ -54,6 +57,7 @@ import {
54
57
  type MailThreadEventPayload,
55
58
  } from "../events";
56
59
  import { inboundMailFoundationFeature } from "../feature";
60
+ import { ingestMessageHandler } from "../handlers/ingest-message.write";
57
61
  import {
58
62
  inboundMessagesProjectionTable,
59
63
  mailAccountsProjectionTable,
@@ -370,6 +374,79 @@ describe("scenario 2c: ingest-message — concurrent thread-rollup race (issue #
370
374
  });
371
375
  });
372
376
 
377
+ describe("scenario 2d: ingest-message — thread-rollup retry exhaustion (#1229)", () => {
378
+ // The version-conflict retry loop (THREAD_ROLLUP_MAX_ATTEMPTS = 5) is
379
+ // exercised by scenario 2c above only on its happy path (some attempt
380
+ // within the bound eventually wins). New behavior added alongside it —
381
+ // exhausting all 5 attempts — is untested: does it actually surface as an
382
+ // InternalError instead of silently persisting a stale messageCount?
383
+ // Forces exhaustion deterministically by calling the handler directly
384
+ // with a ctx whose tryAppendEvent always loses the version-conflict race
385
+ // for the thread aggregate specifically (the message-append itself still
386
+ // goes through a real, conflict-free append — there's no concurrent
387
+ // writer for a brand-new message aggregateId).
388
+ //
389
+ // Honest scope: this drives the handler directly (no dispatcher, no
390
+ // enclosing TX), same as reindex-entity-job's direct-call tests — it
391
+ // does NOT exercise the dispatcher's own TX-rollback-on-throw behavior,
392
+ // only that the handler itself throws instead of returning success after
393
+ // exhausting the attempts.
394
+ test("5 lost version-conflict races → InternalError, not a silent success", async () => {
395
+ const admin = adminFor(4023);
396
+ const accountId = await connectSharedAccount(admin);
397
+ const messageIdHeader = "exhaustion-root@example.com";
398
+
399
+ const tenantDb = createTenantDb(db, admin.tenantId, "system");
400
+ let threadAppendAttempts = 0;
401
+ const ctx = {
402
+ db: tenantDb,
403
+ registry: stack.registry,
404
+ ...bridgeStub({ user: admin }),
405
+ loadAggregate: (aggregateId: string) => loadAggregate(db, aggregateId, admin.tenantId),
406
+ tryAppendEvent: async (args: {
407
+ aggregateId: string;
408
+ aggregateType: string;
409
+ type: string;
410
+ payload: unknown;
411
+ headers?: unknown;
412
+ }) => {
413
+ if (args.aggregateType === MAIL_THREAD_AGGREGATE_TYPE) {
414
+ threadAppendAttempts++;
415
+ return { ok: false as const, conflict: new VersionConflictError(args.aggregateId, 0) };
416
+ }
417
+ // Step 4 (message append): fresh aggregateId, no concurrent writer
418
+ // in this test — a real, conflict-free append via expectedVersion 0.
419
+ const event = await append(db, {
420
+ aggregateId: args.aggregateId,
421
+ aggregateType: args.aggregateType,
422
+ tenantId: admin.tenantId,
423
+ expectedVersion: 0,
424
+ type: args.type,
425
+ payload: args.payload as Record<string, unknown>,
426
+ metadata: { userId: String(admin.id) },
427
+ });
428
+ return { ok: true as const, event };
429
+ },
430
+ // @cast-boundary test/framework — hand-built ctx narrower than HandlerContext
431
+ } as unknown as HandlerContext;
432
+
433
+ await expect(
434
+ ingestMessageHandler.handler(
435
+ {
436
+ type: InboundMailFoundationHandlers.ingestMessage,
437
+ user: admin,
438
+ payload: ingestPayload(accountId, {
439
+ providerMessageId: "exhaustion-1",
440
+ messageIdHeader,
441
+ }),
442
+ },
443
+ ctx,
444
+ ),
445
+ ).rejects.toThrow(/lost the version-conflict race 5 times in a row/);
446
+ expect(threadAppendAttempts).toBe(5);
447
+ });
448
+ });
449
+
373
450
  describe("scenario 3: Scope-Sichtbarkeit (persönliches Postfach)", () => {
374
451
  test("Messages eines user-scoped Accounts sieht nur der Owner (+ TenantAdmin)", async () => {
375
452
  const tenantNumber = 4003;
@@ -20,21 +20,34 @@ export type InboundMailProviderContractFixture = {
20
20
  export function describeInboundMailProviderContract(
21
21
  name: string,
22
22
  factory: () => InboundMailProviderContractFixture | Promise<InboundMailProviderContractFixture>,
23
- opts?: { readonly skip?: boolean },
23
+ opts?: {
24
+ readonly skip?: boolean;
25
+ // Whether the provider implements `watch` — read from the plugin's
26
+ // module-level export (e.g. `Boolean(imapInboundMailPlugin.watch)`),
27
+ // not by calling `factory()`. Calling the factory eagerly to probe
28
+ // this (#1337) ran the imap caller's fixture construction even when
29
+ // `skip` was true (the exact case the skip exists to avoid), and left
30
+ // an eager Promise unawaited/uncaught on an async factory. Defaults to
31
+ // true (run the watch test) when omitted.
32
+ readonly hasWatch?: boolean;
33
+ // bunfig's global test timeout (15s) is too tight for a live-IMAP
34
+ // round-trip under CI load — imap-live.integration.test.ts's own
35
+ // tests already use 20s for the same greenmail server.
36
+ readonly timeout?: number;
37
+ },
24
38
  ): void {
25
- // Eager sync probe so a provider without `watch` produces a visible
26
- // test.skip instead of a silent 0-assertion pass (#1337). Both current
27
- // factories (imap, inmemory) are sync object literals; an async factory
28
- // falls back to the old runtime `if (!plugin.watch) return` check below.
29
- const probe = factory();
30
- const watchProbe = probe instanceof Promise ? undefined : Boolean(probe.plugin.watch);
39
+ const timeout = opts?.timeout;
31
40
 
32
41
  describe(`${name} — InboundMailProviderPlugin contract`, () => {
33
42
  const t = opts?.skip ? test.skip : test;
34
- t("verify resolves for a valid account", async () => {
35
- const { plugin, ctx, account } = await factory();
36
- await expect(plugin.verify(ctx, account)).resolves.toBeUndefined();
37
- });
43
+ t(
44
+ "verify resolves for a valid account",
45
+ async () => {
46
+ const { plugin, ctx, account } = await factory();
47
+ await expect(plugin.verify(ctx, account)).resolves.toBeUndefined();
48
+ },
49
+ timeout,
50
+ );
38
51
 
39
52
  t(
40
53
  "fetch: backfill picks up a seeded message, incremental cursor finds nothing new",
@@ -55,39 +68,44 @@ export function describeInboundMailProviderContract(
55
68
  });
56
69
  expect(second.messages.some((m) => m.subject === subject)).toBe(false);
57
70
  },
71
+ timeout,
58
72
  );
59
73
 
60
- const watchTest = watchProbe === false ? test.skip : t;
61
- watchTest("watch: pushes a seeded message via onMessages", async () => {
62
- const { plugin, ctx, account, seed } = await factory();
63
- if (!plugin.watch) return; // fallback for an async factory (watchProbe undefined)
64
- const subject = `contract-watch-${crypto.randomUUID()}`;
74
+ const watchTest = opts?.hasWatch === false ? test.skip : t;
75
+ watchTest(
76
+ "watch: pushes a seeded message via onMessages",
77
+ async () => {
78
+ const { plugin, ctx, account, seed } = await factory();
79
+ if (!plugin.watch) return; // safety net if opts.hasWatch drifted from the plugin
80
+ const subject = `contract-watch-${crypto.randomUUID()}`;
65
81
 
66
- const pushed = new Promise<void>((resolve, reject) => {
67
- const timer = setTimeout(
68
- () => reject(new Error("watch push not received within 5s")),
69
- 5_000,
70
- );
71
- void plugin
72
- .watch?.(ctx, account, {
73
- onMessages: async (msgs) => {
74
- if (msgs.some((m) => m.subject === subject)) {
82
+ const pushed = new Promise<void>((resolve, reject) => {
83
+ const timer = setTimeout(
84
+ () => reject(new Error("watch push not received within 5s")),
85
+ 5_000,
86
+ );
87
+ void plugin
88
+ .watch?.(ctx, account, {
89
+ onMessages: async (msgs) => {
90
+ if (msgs.some((m) => m.subject === subject)) {
91
+ clearTimeout(timer);
92
+ resolve();
93
+ }
94
+ },
95
+ onError: (err) => {
75
96
  clearTimeout(timer);
76
- resolve();
77
- }
78
- },
79
- onError: (err) => {
80
- clearTimeout(timer);
81
- reject(err instanceof Error ? err : new Error(String(err)));
82
- },
83
- })
84
- .then((stop) => {
85
- void seed(subject).catch(reject);
86
- void pushed.finally(() => void stop().catch(() => {}));
87
- }, reject);
88
- });
97
+ reject(err instanceof Error ? err : new Error(String(err)));
98
+ },
99
+ })
100
+ .then((stop) => {
101
+ void seed(subject).catch(reject);
102
+ void pushed.finally(() => void stop().catch(() => {}));
103
+ }, reject);
104
+ });
89
105
 
90
- await pushed;
91
- });
106
+ await pushed;
107
+ },
108
+ timeout,
109
+ );
92
110
  });
93
111
  }
@@ -295,6 +295,14 @@ describe("watch-supervisor — watch restart + ingest resilience", () => {
295
295
  });
296
296
 
297
297
  emitWatchError(accountId, new Error("socket dropped"));
298
+ // No assertion here: isWatching(accountId) flips to false as a side
299
+ // effect of emitWatchError itself (inbound-provider-inmemory's
300
+ // feature.ts sets state.watch = null before handlers.onError runs),
301
+ // and the restart re-arms fast enough that the persisted `backoff:`
302
+ // projection state isn't reliably observable between the two either
303
+ // — asserting either one here pins test-helper bookkeeping, not
304
+ // production behavior. The real assertions are below: the watch
305
+ // recovers, and the account stays active with a valid watchState.
298
306
 
299
307
  await waitFor(() => {
300
308
  expect(isWatching(accountId)).toBe(true);
@@ -302,7 +310,7 @@ describe("watch-supervisor — watch restart + ingest resilience", () => {
302
310
 
303
311
  const accounts = await selectMany(db, mailAccountsProjectionTable, { id: accountId });
304
312
  expect(accounts[0]?.["status"]).toBe(InboundMailAccountStatuses.active);
305
- expect(String(accounts[0]?.["watchState"])).toMatch(/^watching|backoff:/);
313
+ expect(String(accounts[0]?.["watchState"])).toMatch(/^(watching|backoff:)/);
306
314
  } finally {
307
315
  await supervisor.stop();
308
316
  }