@cosmicdrift/kumiko-framework 0.66.0 → 0.67.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.66.0",
3
+ "version": "0.67.1",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -181,7 +181,7 @@
181
181
  "zod": "^4.4.3"
182
182
  },
183
183
  "devDependencies": {
184
- "@cosmicdrift/kumiko-dispatcher-live": "0.65.0",
184
+ "@cosmicdrift/kumiko-dispatcher-live": "0.67.1",
185
185
  "bun-types": "^1.3.13",
186
186
  "pino-pretty": "^13.1.3"
187
187
  },
@@ -204,6 +204,7 @@ export {
204
204
  findTierResolverUsage,
205
205
  TENANT_TIER_RESOLVER_EXT,
206
206
  type TierResolverPlugin,
207
+ type TrialGate,
207
208
  } from "./tier-resolver-extension";
208
209
  // Types
209
210
  export type {
@@ -41,12 +41,29 @@ export const TENANT_TIER_RESOLVER_EXT = "tenantTierResolver";
41
41
  * **System-context convention:** call mit SYSTEM_TENANT_ID erwartet die union
42
42
  * aller tier-features (siehe DispatcherOptions.effectiveFeatures doc-block).
43
43
  */
44
- export type EffectiveFeaturesResolver = (tenantId: TenantId) => ReadonlySet<string>;
44
+ export type EffectiveFeaturesResolver = ((tenantId: TenantId) => ReadonlySet<string>) & {
45
+ /**
46
+ * Optionaler Live-Trial-Gate, vom dispatcher-feature-gate NUR konsultiert
47
+ * wenn der synchrone Resolver ein Feature als disabled meldet. Liest das
48
+ * Signup-Datum des Tenants live (tenant.inserted_at) und returnt true wenn
49
+ * der Tenant im Trial-Fenster ist UND das Feature zum Trial-Tier gehört.
50
+ * Async + nicht im Boot-Cache, weil der Trial zeit-abgeleitet ist (ändert
51
+ * sich zwischen Requests). Nur die 2 Gate-Aufrufstellen awaiten ihn; der
52
+ * synchrone Hot-Path (ctx.hasFeature, Feature-Set) bleibt unberührt.
53
+ */
54
+ readonly trialGate?: TrialGate;
55
+ };
56
+
57
+ /**
58
+ * Live-Trial-Gate-Shape. Siehe EffectiveFeaturesResolver.trialGate.
59
+ */
60
+ export type TrialGate = (tenantId: TenantId, featureName: string) => Promise<boolean>;
45
61
 
46
62
  /**
47
63
  * Plugin-shape für tier-resolver-extension. Plugins implementieren `build`
48
64
  * als boot-time factory: kriegen `db` + `registry` (post-stack-setup),
49
- * laden initial cache aus DB, returnen den synchronen resolver-callback.
65
+ * laden initial cache aus DB, returnen den synchronen resolver-callback
66
+ * (optional mit angehängtem `trialGate`).
50
67
  */
51
68
  export type TierResolverPlugin = {
52
69
  readonly build: (deps: {
@@ -376,6 +376,37 @@ describe("dispatcher feature-gate", () => {
376
376
  });
377
377
  });
378
378
 
379
+ test("trialGate: disabled feature is allowed on the gate's cold path when trialGate returns true", async () => {
380
+ // Beweist die Live-Trial-Mechanik: der sync resolver hat das Feature NICHT
381
+ // im Set (alles disabled), aber checkFeatureEnabled konsultiert den async
382
+ // trialGate auf dem disabled-Pfad. Deckt beide Aufrufstellen ab (query =
383
+ // ensureFeatureEnabled, write = checkFeatureEnabled).
384
+ const registry = createRegistry([toggled()]);
385
+ let trialOpen = false;
386
+ const effectiveFeatures = Object.assign(() => new Set<string>(), {
387
+ trialGate: async (_tenantId: TenantId, feature: string) => trialOpen && feature === "toggled",
388
+ });
389
+ const dispatcher = createDispatcher(registry, {}, { effectiveFeatures });
390
+
391
+ // Trial zu → 403 (resolver-Set leer, Gate verweigert).
392
+ await expect(dispatcher.query("toggled:query:widget:list", {}, user)).rejects.toThrow(
393
+ /feature toggled is disabled/,
394
+ );
395
+ const writeClosed = await dispatcher.write("toggled:write:widget:create", { name: "x" }, user);
396
+ expect(writeClosed.isSuccess).toBe(false);
397
+ if (!writeClosed.isSuccess) expect(writeClosed.error.code).toBe("feature_disabled");
398
+
399
+ // Trial offen → Gate lässt durch, obwohl der Resolver das Feature nicht
400
+ // führt. Query passiert; Write passiert das Gate (scheitert ggf. später an
401
+ // fehlender DB, aber NICHT mehr an feature_disabled).
402
+ trialOpen = true;
403
+ await expect(dispatcher.query("toggled:query:widget:list", {}, user)).resolves.toEqual({
404
+ items: [],
405
+ });
406
+ const writeOpen = await dispatcher.write("toggled:write:widget:create", { name: "x" }, user);
407
+ if (!writeOpen.isSuccess) expect(writeOpen.error.code).not.toBe("feature_disabled");
408
+ });
409
+
379
410
  test("Sprint 8a: per-tenant gating — Tenant A passes, Tenant B gets feature_disabled", async () => {
380
411
  // Beweist die Phase-1-Architektur: dispatcher ruft effectiveFeatures
381
412
  // mit user.tenantId, resolver kann pro Tenant unterschiedliche Sets
@@ -659,10 +659,10 @@ export function createDispatcher(
659
659
  // When `effectiveFeatures` is not wired (tests, apps without feature-toggles
660
660
  // loaded), every handler is treated as enabled — the gate is a pure
661
661
  // pass-through in that common case.
662
- function checkFeatureEnabled(
662
+ async function checkFeatureEnabled(
663
663
  qualifiedHandler: string,
664
664
  tenantId: TenantId,
665
- ): import("../errors").FeatureDisabledError | undefined {
665
+ ): Promise<import("../errors").FeatureDisabledError | undefined> {
666
666
  if (!effectiveFeatures) return undefined;
667
667
  const owner = registry.getHandlerFeature(qualifiedHandler);
668
668
  // skip: handler without an owning feature cannot be toggled — shouldn't
@@ -671,11 +671,18 @@ export function createDispatcher(
671
671
  if (!owner) return undefined;
672
672
  const set = effectiveFeatures(tenantId);
673
673
  if (set.has(owner)) return undefined;
674
+ // Feature is off for the stored tier — give the live trial-gate a last
675
+ // chance. Time-derived (tenant.inserted_at + window), so it can't live in
676
+ // the boot-cached sync resolver; consulted only on this already-disabled
677
+ // cold path, never on the hot enabled path.
678
+ if (effectiveFeatures.trialGate && (await effectiveFeatures.trialGate(tenantId, owner))) {
679
+ return undefined;
680
+ }
674
681
  return new FeatureDisabledError(owner, qualifiedHandler);
675
682
  }
676
683
 
677
- function ensureFeatureEnabled(qualifiedHandler: string, tenantId: TenantId): void {
678
- const err = checkFeatureEnabled(qualifiedHandler, tenantId);
684
+ async function ensureFeatureEnabled(qualifiedHandler: string, tenantId: TenantId): Promise<void> {
685
+ const err = await checkFeatureEnabled(qualifiedHandler, tenantId);
679
686
  if (err) throw err;
680
687
  }
681
688
 
@@ -737,7 +744,7 @@ export function createDispatcher(
737
744
  // disabled feature must not consume the rate-limit quota — the call
738
745
  // never happened from the feature's perspective. Order is: lookup →
739
746
  // feature-gate → rate-limit → access → validation → handler.
740
- ensureFeatureEnabled(type, user.tenantId);
747
+ await ensureFeatureEnabled(type, user.tenantId);
741
748
 
742
749
  // Rate-limit gate runs BEFORE access-check on purpose: anonymous /
743
750
  // unauthorized callers must hit the cap too (otherwise the limit
@@ -1028,7 +1035,7 @@ export function createDispatcher(
1028
1035
 
1029
1036
  // Feature-toggle gate: disabled handlers must short-circuit before any
1030
1037
  // rate-limit/access/validation work — see executeQueryInner comment.
1031
- const disabledErr = checkFeatureEnabled(type, user.tenantId);
1038
+ const disabledErr = await checkFeatureEnabled(type, user.tenantId);
1032
1039
  if (disabledErr) return writeFailure(disabledErr);
1033
1040
 
1034
1041
  // Rate-limit gate before access (same reasoning as in executeQueryInner).