@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.
|
|
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.
|
|
184
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.67.1",
|
|
185
185
|
"bun-types": "^1.3.13",
|
|
186
186
|
"pino-pretty": "^13.1.3"
|
|
187
187
|
},
|
package/src/engine/index.ts
CHANGED
|
@@ -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).
|