@zenxdigitalholdings/zenflags 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # @zenxdigitalholdings/zenflags
2
+
3
+ Feature flags for Zen products — evaluated **in-process** from the Zen flags
4
+ server ([atlasrails.online](https://atlasrails.online)). The flag set loads
5
+ once at boot and refreshes in the background; a flag check never makes a
6
+ network call.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ npm install @zenxdigitalholdings/zenflags
12
+ ```
13
+
14
+ ## Use (any Node ≥18 service)
15
+
16
+ ```js
17
+ import { createFlags } from '@zenxdigitalholdings/zenflags'
18
+
19
+ const flags = await createFlags() // API key from ZEN_FLAGS_KEY
20
+
21
+ const d = await flags.evaluate(
22
+ { vertical: 'zenotc', microservice: 'otc-rfq', component: 'block-trade' },
23
+ { entityId: userId },
24
+ )
25
+ if (!d.allowsWrite()) return featureUnavailable()
26
+ ```
27
+
28
+ Set one environment variable: `ZEN_FLAGS_KEY` — the API key issued for your
29
+ product + environment in the Zen Flags console. The server endpoint is
30
+ platform-managed (baked in); `ZEN_FLAGS_URL` exists only as an ops override.
31
+
32
+ ## NestJS
33
+
34
+ ```ts
35
+ import { FlagsModule, FlagsService, FlagGuard, RequireFlag } from '@zenxdigitalholdings/zenflags/nestjs'
36
+
37
+ @Module({ imports: [FlagsModule.forRoot({})] })
38
+ export class AppModule {}
39
+
40
+ // declarative route gating:
41
+ @UseGuards(FlagGuard)
42
+ @RequireFlag({ vertical: 'zenotc', microservice: 'otc-rfq', component: 'block-trade', op: 'write' })
43
+ ```
44
+
45
+ ## Guarantees
46
+
47
+ - **No network per check** — snapshot at boot (~35 ms), in-memory evaluation,
48
+ jittered conditional refresh (304s when nothing changed).
49
+ - **Never takes your service down** — on outage the last snapshot serves until
50
+ `maxStaleMs`; then protected flags fail **closed**, others use their fallback.
51
+ - Unknown flags resolve to `DISABLED_HARD`. Deterministic DRAIN ramp bucketing,
52
+ identical to the Go SDK.
53
+
54
+ Internal to Zen Atlas Group. Console & docs: https://atlasrails.online
@@ -0,0 +1,16 @@
1
+ import { Evaluator } from './evaluator';
2
+ import { type CachedZenOptions } from './zen';
3
+ export interface CreateFlagsOptions extends CachedZenOptions {
4
+ /** How long to block for the boot snapshot (ms). Default 5000. */
5
+ readyTimeoutMs?: number;
6
+ }
7
+ /**
8
+ * The one-liner for plain Node/TS services:
9
+ *
10
+ * const flags = await createFlags() // key from ZEN_FLAGS_KEY
11
+ *
12
+ * Loads the flag set once at boot, evaluates from memory, refreshes in the
13
+ * background. The server endpoint is platform-managed — consumers never
14
+ * configure a URL.
15
+ */
16
+ export declare function createFlags(opts?: CreateFlagsOptions): Promise<Evaluator>;
package/dist/client.js ADDED
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createFlags = createFlags;
4
+ const evaluator_1 = require("./evaluator");
5
+ const zen_1 = require("./zen");
6
+ /**
7
+ * The one-liner for plain Node/TS services:
8
+ *
9
+ * const flags = await createFlags() // key from ZEN_FLAGS_KEY
10
+ *
11
+ * Loads the flag set once at boot, evaluates from memory, refreshes in the
12
+ * background. The server endpoint is platform-managed — consumers never
13
+ * configure a URL.
14
+ */
15
+ async function createFlags(opts = {}) {
16
+ const provider = new zen_1.CachedZenProvider(opts);
17
+ const ok = await provider.ready(opts.readyTimeoutMs ?? 5000);
18
+ if (!ok) {
19
+ // eslint-disable-next-line no-console
20
+ console.warn('[zen-flags] snapshot not loaded before timeout — flags fail closed until it arrives');
21
+ }
22
+ return new evaluator_1.Evaluator(provider);
23
+ }
@@ -0,0 +1,12 @@
1
+ export type Op = 'read' | 'write';
2
+ /**
3
+ * Per-request signals the provider targets on, and the stable identity the
4
+ * DRAIN ramp buckets by.
5
+ */
6
+ export interface RequestContext {
7
+ environment: string;
8
+ entityId?: string;
9
+ region?: string;
10
+ kycTier?: string;
11
+ attributes?: Record<string, string>;
12
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,19 @@
1
+ import type { State } from './state';
2
+ import type { Op } from './context';
3
+ /**
4
+ * The result of evaluating a Spec against a RequestContext. Always safe to act
5
+ * on: every degraded path yields a conservative state.
6
+ */
7
+ export declare class Decision {
8
+ readonly key: string;
9
+ readonly state: State;
10
+ readonly isProtected: boolean;
11
+ readonly variant: string;
12
+ readonly inRamp: boolean;
13
+ readonly reason: string;
14
+ readonly err?: unknown | undefined;
15
+ constructor(key: string, state: State, isProtected: boolean, variant: string, inRamp: boolean, reason: string, err?: unknown | undefined);
16
+ allowsRead(): boolean;
17
+ allowsWrite(): boolean;
18
+ allows(op: Op): boolean;
19
+ }
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Decision = void 0;
4
+ /**
5
+ * The result of evaluating a Spec against a RequestContext. Always safe to act
6
+ * on: every degraded path yields a conservative state.
7
+ */
8
+ class Decision {
9
+ constructor(key, state, isProtected, variant, inRamp, reason, err) {
10
+ this.key = key;
11
+ this.state = state;
12
+ this.isProtected = isProtected;
13
+ this.variant = variant;
14
+ this.inRamp = inRamp;
15
+ this.reason = reason;
16
+ this.err = err;
17
+ }
18
+ allowsRead() {
19
+ switch (this.state) {
20
+ case 'ENABLED':
21
+ case 'READ_ONLY':
22
+ return true;
23
+ case 'DRAIN':
24
+ return this.inRamp;
25
+ default:
26
+ return false;
27
+ }
28
+ }
29
+ allowsWrite() {
30
+ switch (this.state) {
31
+ case 'ENABLED':
32
+ return true;
33
+ case 'DRAIN':
34
+ return this.inRamp;
35
+ default:
36
+ return false;
37
+ }
38
+ }
39
+ allows(op) {
40
+ return op === 'write' ? this.allowsWrite() : this.allowsRead();
41
+ }
42
+ }
43
+ exports.Decision = Decision;
@@ -0,0 +1,19 @@
1
+ import type { Provider } from './provider';
2
+ import type { Spec } from './spec';
3
+ import type { RequestContext, Op } from './context';
4
+ import { Decision } from './decision';
5
+ /**
6
+ * Applies the Zen policy on top of a Provider: taxonomy, ramp bucketing, and —
7
+ * the crux — fail-closed handling for the PROTECTED class. evaluate never
8
+ * rejects: failures fold into a conservative Decision with `err` set.
9
+ */
10
+ export declare class Evaluator {
11
+ private readonly provider;
12
+ constructor(provider: Provider);
13
+ evaluate(spec: Spec, rc: RequestContext): Promise<Decision>;
14
+ allow(spec: Spec, rc: RequestContext, op: Op): Promise<{
15
+ allowed: boolean;
16
+ decision: Decision;
17
+ }>;
18
+ close(): Promise<void>;
19
+ }
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Evaluator = void 0;
4
+ const spec_1 = require("./spec");
5
+ const decision_1 = require("./decision");
6
+ const state_1 = require("./state");
7
+ const ramp_1 = require("./ramp");
8
+ /**
9
+ * Applies the Zen policy on top of a Provider: taxonomy, ramp bucketing, and —
10
+ * the crux — fail-closed handling for the PROTECTED class. evaluate never
11
+ * rejects: failures fold into a conservative Decision with `err` set.
12
+ */
13
+ class Evaluator {
14
+ constructor(provider) {
15
+ this.provider = provider;
16
+ }
17
+ async evaluate(spec, rc) {
18
+ const key = (0, spec_1.specKey)(spec);
19
+ try {
20
+ const res = await this.provider.evaluate(spec, rc);
21
+ if (!(0, state_1.isValidState)(res.state)) {
22
+ return new decision_1.Decision(key, 'DISABLED_HARD', !!spec.protected, '', false, `invalid state "${res.state}" -> DISABLED_HARD`, new Error('invalid state'));
23
+ }
24
+ if (res.state === 'DRAIN') {
25
+ const ramped = (0, ramp_1.inRamp)(key, rc.entityId ?? '', res.rampPercent ?? 0);
26
+ return new decision_1.Decision(key, 'DRAIN', !!spec.protected, res.variant ?? '', ramped, `DRAIN ${res.rampPercent ?? 0}%: inRamp=${ramped}`);
27
+ }
28
+ return new decision_1.Decision(key, res.state, !!spec.protected, res.variant ?? '', false, res.reason ?? '');
29
+ }
30
+ catch (err) {
31
+ if (spec.protected) {
32
+ return new decision_1.Decision(key, 'DISABLED_HARD', true, '', false, 'protected flag: provider error -> fail-closed DENY', err);
33
+ }
34
+ const fb = spec.fallbackState ?? 'DISABLED_HARD';
35
+ return new decision_1.Decision(key, fb, false, '', false, `provider error -> fallback ${fb}`, err);
36
+ }
37
+ }
38
+ async allow(spec, rc, op) {
39
+ const decision = await this.evaluate(spec, rc);
40
+ return { allowed: decision.allows(op), decision };
41
+ }
42
+ close() {
43
+ return this.provider.close();
44
+ }
45
+ }
46
+ exports.Evaluator = Evaluator;
@@ -0,0 +1,10 @@
1
+ export * from './state';
2
+ export * from './spec';
3
+ export * from './context';
4
+ export * from './decision';
5
+ export * from './provider';
6
+ export * from './memory';
7
+ export * from './zen';
8
+ export * from './evaluator';
9
+ export { inRamp } from './ramp';
10
+ export * from './client';
package/dist/index.js ADDED
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.inRamp = void 0;
18
+ __exportStar(require("./state"), exports);
19
+ __exportStar(require("./spec"), exports);
20
+ __exportStar(require("./context"), exports);
21
+ __exportStar(require("./decision"), exports);
22
+ __exportStar(require("./provider"), exports);
23
+ __exportStar(require("./memory"), exports);
24
+ __exportStar(require("./zen"), exports);
25
+ __exportStar(require("./evaluator"), exports);
26
+ var ramp_1 = require("./ramp");
27
+ Object.defineProperty(exports, "inRamp", { enumerable: true, get: function () { return ramp_1.inRamp; } });
28
+ __exportStar(require("./client"), exports);
@@ -0,0 +1,16 @@
1
+ import type { Provider, EvalResult } from './provider';
2
+ import type { Spec } from './spec';
3
+ import type { RequestContext } from './context';
4
+ /**
5
+ * In-process Provider for tests and offline runs. Unknown flags resolve to
6
+ * DISABLED_HARD (baseline off), never an error. setError simulates an outage
7
+ * to exercise the fail-closed path.
8
+ */
9
+ export declare class MemoryProvider implements Provider {
10
+ private readonly results;
11
+ private failErr?;
12
+ set(env: string, spec: Spec, res: EvalResult): void;
13
+ setError(err?: Error): void;
14
+ evaluate(spec: Spec, rc: RequestContext): Promise<EvalResult>;
15
+ close(): Promise<void>;
16
+ }
package/dist/memory.js ADDED
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MemoryProvider = void 0;
4
+ const spec_1 = require("./spec");
5
+ /**
6
+ * In-process Provider for tests and offline runs. Unknown flags resolve to
7
+ * DISABLED_HARD (baseline off), never an error. setError simulates an outage
8
+ * to exercise the fail-closed path.
9
+ */
10
+ class MemoryProvider {
11
+ constructor() {
12
+ this.results = new Map();
13
+ }
14
+ set(env, spec, res) {
15
+ this.results.set(`${env}|${(0, spec_1.specKey)(spec)}`, res);
16
+ }
17
+ setError(err) {
18
+ this.failErr = err;
19
+ }
20
+ async evaluate(spec, rc) {
21
+ if (this.failErr)
22
+ throw this.failErr;
23
+ return (this.results.get(`${rc.environment}|${(0, spec_1.specKey)(spec)}`) ?? {
24
+ state: 'DISABLED_HARD',
25
+ reason: 'unknown flag -> baseline DISABLED_HARD',
26
+ });
27
+ }
28
+ async close() { }
29
+ }
30
+ exports.MemoryProvider = MemoryProvider;
@@ -0,0 +1,14 @@
1
+ import { CanActivate, ExecutionContext } from '@nestjs/common';
2
+ import { Reflector } from '@nestjs/core';
3
+ import { FlagsService } from './flags.service';
4
+ /**
5
+ * Enforces @RequireFlag on a route. Pulls the ramp/targeting context from the
6
+ * request (authenticated user id, then x-entity-id / x-region / x-kyc-tier
7
+ * headers) and throws ForbiddenException when the flag denies the operation.
8
+ */
9
+ export declare class FlagGuard implements CanActivate {
10
+ private readonly reflector;
11
+ private readonly flags;
12
+ constructor(reflector: Reflector, flags: FlagsService);
13
+ canActivate(context: ExecutionContext): Promise<boolean>;
14
+ }
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var __metadata = (this && this.__metadata) || function (k, v) {
9
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.FlagGuard = void 0;
13
+ const common_1 = require("@nestjs/common");
14
+ const core_1 = require("@nestjs/core");
15
+ const flags_service_1 = require("./flags.service");
16
+ const require_flag_decorator_1 = require("./require-flag.decorator");
17
+ /**
18
+ * Enforces @RequireFlag on a route. Pulls the ramp/targeting context from the
19
+ * request (authenticated user id, then x-entity-id / x-region / x-kyc-tier
20
+ * headers) and throws ForbiddenException when the flag denies the operation.
21
+ */
22
+ let FlagGuard = class FlagGuard {
23
+ constructor(reflector, flags) {
24
+ this.reflector = reflector;
25
+ this.flags = flags;
26
+ }
27
+ async canActivate(context) {
28
+ const meta = this.reflector.getAllAndOverride(require_flag_decorator_1.FLAG_META, [
29
+ context.getHandler(),
30
+ context.getClass(),
31
+ ]);
32
+ if (!meta)
33
+ return true;
34
+ const req = context.switchToHttp().getRequest();
35
+ const { allowed, decision } = await this.flags.allow({
36
+ vertical: meta.vertical,
37
+ microservice: meta.microservice,
38
+ component: meta.component,
39
+ protected: meta.protected,
40
+ }, meta.op ?? 'read', {
41
+ entityId: req.user?.id ?? req.headers['x-entity-id'],
42
+ region: req.headers['x-region'],
43
+ kycTier: req.headers['x-kyc-tier'],
44
+ });
45
+ if (!allowed) {
46
+ throw new common_1.ForbiddenException({
47
+ error: 'feature unavailable',
48
+ key: decision.key,
49
+ state: decision.state,
50
+ reason: decision.reason,
51
+ });
52
+ }
53
+ return true;
54
+ }
55
+ };
56
+ exports.FlagGuard = FlagGuard;
57
+ exports.FlagGuard = FlagGuard = __decorate([
58
+ (0, common_1.Injectable)(),
59
+ __metadata("design:paramtypes", [core_1.Reflector,
60
+ flags_service_1.FlagsService])
61
+ ], FlagGuard);
@@ -0,0 +1,32 @@
1
+ import { DynamicModule } from '@nestjs/common';
2
+ import type { Provider } from '../provider';
3
+ export declare const FLAGS_OPTIONS = "ZEN_FLAGS_OPTIONS";
4
+ export interface FlagsModuleOptions {
5
+ /** Per-service API key (zfk_…). Falls back to ZEN_FLAGS_KEY. */
6
+ apiKey?: string;
7
+ /** Ops override only — the endpoint is platform-managed (baked default + ZEN_FLAGS_URL). */
8
+ serverUrl?: string;
9
+ /**
10
+ * 'cached' (default) loads the flag set once at boot and keeps it fresh in
11
+ * the background — no network call per flag check; 'direct' calls
12
+ * /v1/evaluate per check (debugging only); 'memory' for tests; or pass a
13
+ * custom Provider instance.
14
+ */
15
+ mode?: 'cached' | 'direct' | 'memory' | Provider;
16
+ /** Snapshot refresh interval (ms) in cached mode. Default 5000. */
17
+ refreshMs?: number;
18
+ /**
19
+ * In cached mode, how long module init waits for the first snapshot
20
+ * (load-once-at-boot). Default 5000; on timeout the app still starts and
21
+ * flags fail closed / fall back until the snapshot loads.
22
+ */
23
+ readyTimeoutMs?: number;
24
+ }
25
+ /**
26
+ * FlagsModule.forRoot({...}) wires an Evaluator + FlagsService for the app.
27
+ * Register once (global); inject FlagsService anywhere, or gate routes with
28
+ * @RequireFlag + FlagGuard.
29
+ */
30
+ export declare class FlagsModule {
31
+ static forRoot(options: FlagsModuleOptions): DynamicModule;
32
+ }
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var FlagsModule_1;
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.FlagsModule = exports.FLAGS_OPTIONS = void 0;
11
+ const common_1 = require("@nestjs/common");
12
+ const evaluator_1 = require("../evaluator");
13
+ const zen_1 = require("../zen");
14
+ const memory_1 = require("../memory");
15
+ const flags_service_1 = require("./flags.service");
16
+ exports.FLAGS_OPTIONS = 'ZEN_FLAGS_OPTIONS';
17
+ /**
18
+ * FlagsModule.forRoot({...}) wires an Evaluator + FlagsService for the app.
19
+ * Register once (global); inject FlagsService anywhere, or gate routes with
20
+ * @RequireFlag + FlagGuard.
21
+ */
22
+ let FlagsModule = FlagsModule_1 = class FlagsModule {
23
+ static forRoot(options) {
24
+ const evaluatorProvider = {
25
+ provide: evaluator_1.Evaluator,
26
+ useFactory: async () => {
27
+ let provider;
28
+ if (options.mode && typeof options.mode !== 'string') {
29
+ provider = options.mode;
30
+ }
31
+ else if (options.mode === 'memory') {
32
+ provider = new memory_1.MemoryProvider();
33
+ }
34
+ else if (options.mode === 'direct') {
35
+ provider = new zen_1.ZenProvider(options);
36
+ }
37
+ else {
38
+ const cached = new zen_1.CachedZenProvider(options);
39
+ // Load-once-at-boot: block init until the first snapshot (bounded).
40
+ const ok = await cached.ready(options.readyTimeoutMs ?? 5000);
41
+ if (!ok) {
42
+ // eslint-disable-next-line no-console
43
+ console.warn('[zen-flags] snapshot not loaded before timeout — flags fail closed until it arrives');
44
+ }
45
+ provider = cached;
46
+ }
47
+ return new evaluator_1.Evaluator(provider);
48
+ },
49
+ };
50
+ return {
51
+ module: FlagsModule_1,
52
+ global: true,
53
+ providers: [{ provide: exports.FLAGS_OPTIONS, useValue: options }, evaluatorProvider, flags_service_1.FlagsService],
54
+ exports: [flags_service_1.FlagsService],
55
+ };
56
+ }
57
+ };
58
+ exports.FlagsModule = FlagsModule;
59
+ exports.FlagsModule = FlagsModule = FlagsModule_1 = __decorate([
60
+ (0, common_1.Module)({})
61
+ ], FlagsModule);
@@ -0,0 +1,18 @@
1
+ import { Evaluator } from '../evaluator';
2
+ import type { Spec } from '../spec';
3
+ import type { RequestContext, Op } from '../context';
4
+ import type { Decision } from '../decision';
5
+ /**
6
+ * Injectable wrapper over the Evaluator. The environment is pinned by the API
7
+ * key server-side; callers pass only per-request context (entityId for DRAIN
8
+ * bucketing, region/kycTier for future targeting).
9
+ */
10
+ export declare class FlagsService {
11
+ private readonly evaluator;
12
+ constructor(evaluator: Evaluator);
13
+ evaluate(spec: Spec, ctx?: Partial<RequestContext>): Promise<Decision>;
14
+ allow(spec: Spec, op: Op, ctx?: Partial<RequestContext>): Promise<{
15
+ allowed: boolean;
16
+ decision: Decision;
17
+ }>;
18
+ }
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var __metadata = (this && this.__metadata) || function (k, v) {
9
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.FlagsService = void 0;
13
+ const common_1 = require("@nestjs/common");
14
+ const evaluator_1 = require("../evaluator");
15
+ /**
16
+ * Injectable wrapper over the Evaluator. The environment is pinned by the API
17
+ * key server-side; callers pass only per-request context (entityId for DRAIN
18
+ * bucketing, region/kycTier for future targeting).
19
+ */
20
+ let FlagsService = class FlagsService {
21
+ constructor(evaluator) {
22
+ this.evaluator = evaluator;
23
+ }
24
+ evaluate(spec, ctx = {}) {
25
+ return this.evaluator.evaluate(spec, { environment: '', ...ctx });
26
+ }
27
+ async allow(spec, op, ctx = {}) {
28
+ const decision = await this.evaluate(spec, ctx);
29
+ return { allowed: decision.allows(op), decision };
30
+ }
31
+ };
32
+ exports.FlagsService = FlagsService;
33
+ exports.FlagsService = FlagsService = __decorate([
34
+ (0, common_1.Injectable)(),
35
+ __metadata("design:paramtypes", [evaluator_1.Evaluator])
36
+ ], FlagsService);
@@ -0,0 +1,4 @@
1
+ export * from './flags.module';
2
+ export * from './flags.service';
3
+ export * from './require-flag.decorator';
4
+ export * from './flag.guard';
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./flags.module"), exports);
18
+ __exportStar(require("./flags.service"), exports);
19
+ __exportStar(require("./require-flag.decorator"), exports);
20
+ __exportStar(require("./flag.guard"), exports);
@@ -0,0 +1,18 @@
1
+ import type { Op } from '../context';
2
+ export declare const FLAG_META = "zen:require-flag";
3
+ export interface RequireFlagMeta {
4
+ vertical: string;
5
+ microservice: string;
6
+ component?: string;
7
+ /** Operation being gated (default 'read'). */
8
+ op?: Op;
9
+ /** Mark the compliance class — fails closed on backend error. */
10
+ protected?: boolean;
11
+ }
12
+ /**
13
+ * Route-level flag gate. Pair with FlagGuard:
14
+ *
15
+ * @RequireFlag({ vertical: 'zenotc', microservice: 'otc-rfq', component: 'block-trade', op: 'write' })
16
+ * @UseGuards(FlagGuard)
17
+ */
18
+ export declare const RequireFlag: (meta: RequireFlagMeta) => import("@nestjs/common").CustomDecorator<string>;
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RequireFlag = exports.FLAG_META = void 0;
4
+ const common_1 = require("@nestjs/common");
5
+ exports.FLAG_META = 'zen:require-flag';
6
+ /**
7
+ * Route-level flag gate. Pair with FlagGuard:
8
+ *
9
+ * @RequireFlag({ vertical: 'zenotc', microservice: 'otc-rfq', component: 'block-trade', op: 'write' })
10
+ * @UseGuards(FlagGuard)
11
+ */
12
+ const RequireFlag = (meta) => (0, common_1.SetMetadata)(exports.FLAG_META, meta);
13
+ exports.RequireFlag = RequireFlag;
@@ -0,0 +1,15 @@
1
+ import type { State } from './state';
2
+ import type { Spec } from './spec';
3
+ import type { RequestContext } from './context';
4
+ /** Raw evaluation a Provider returns, before ramp/fail-closed policy is applied. */
5
+ export interface EvalResult {
6
+ state: State;
7
+ rampPercent?: number;
8
+ variant?: string;
9
+ reason?: string;
10
+ }
11
+ /** Pluggable flag backend. FliptProvider is production; MemoryProvider backs tests. */
12
+ export interface Provider {
13
+ evaluate(spec: Spec, rc: RequestContext): Promise<EvalResult>;
14
+ close(): Promise<void>;
15
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
package/dist/ramp.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Deterministic FNV-1a (32-bit) bucketing over UTF-8 bytes. Matches the Go
3
+ * flag-sdk exactly, so a given entity lands in the same DRAIN cohort whether it
4
+ * is evaluated by a Go service or a TypeScript one.
5
+ */
6
+ export declare function inRamp(key: string, entityId: string, percent: number): boolean;
package/dist/ramp.js ADDED
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.inRamp = inRamp;
4
+ /**
5
+ * Deterministic FNV-1a (32-bit) bucketing over UTF-8 bytes. Matches the Go
6
+ * flag-sdk exactly, so a given entity lands in the same DRAIN cohort whether it
7
+ * is evaluated by a Go service or a TypeScript one.
8
+ */
9
+ function inRamp(key, entityId, percent) {
10
+ if (percent <= 0)
11
+ return false;
12
+ if (percent >= 100)
13
+ return true;
14
+ const bytes = new TextEncoder().encode(`${key}:${entityId}`);
15
+ let h = 0x811c9dc5;
16
+ for (const b of bytes) {
17
+ h ^= b;
18
+ h = Math.imul(h, 0x01000193) >>> 0;
19
+ }
20
+ return h % 100 < percent;
21
+ }
package/dist/spec.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ import type { State } from './state';
2
+ /**
3
+ * Spec identifies a flag on the three kill-switch axes and declares its class.
4
+ * Key format: vertical.microservice[.component] (vertical maps to a Flipt namespace).
5
+ */
6
+ export interface Spec {
7
+ vertical: string;
8
+ microservice: string;
9
+ component?: string;
10
+ /** Compliance class (AML/KYC/sanctions/MPC): fails CLOSED on any error. */
11
+ protected?: boolean;
12
+ /** Fallback for NON-protected flags on provider error (default DISABLED_HARD). */
13
+ fallbackState?: State;
14
+ }
15
+ export declare function specKey(s: Spec): string;
16
+ export declare function specNamespace(s: Spec): string;
17
+ export declare function specFlagKey(s: Spec): string;
package/dist/spec.js ADDED
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.specKey = specKey;
4
+ exports.specNamespace = specNamespace;
5
+ exports.specFlagKey = specFlagKey;
6
+ function specKey(s) {
7
+ return s.component
8
+ ? `${s.vertical}.${s.microservice}.${s.component}`
9
+ : `${s.vertical}.${s.microservice}`;
10
+ }
11
+ function specNamespace(s) {
12
+ return s.vertical;
13
+ }
14
+ function specFlagKey(s) {
15
+ return s.component ? `${s.microservice}.${s.component}` : s.microservice;
16
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The five operational states used across the Zen ecosystem. Orthogonal to the
3
+ * PROTECTED class (a property of the Spec): PROTECTED describes error behaviour,
4
+ * State describes what an evaluated flag permits.
5
+ */
6
+ export type State = 'ENABLED' | 'READ_ONLY' | 'DRAIN' | 'MAINTENANCE' | 'DISABLED_HARD';
7
+ export declare function isValidState(s: string): s is State;
package/dist/state.js ADDED
@@ -0,0 +1,7 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isValidState = isValidState;
4
+ const ALL = ['ENABLED', 'READ_ONLY', 'DRAIN', 'MAINTENANCE', 'DISABLED_HARD'];
5
+ function isValidState(s) {
6
+ return ALL.includes(s);
7
+ }
package/dist/zen.d.ts ADDED
@@ -0,0 +1,72 @@
1
+ import type { Provider, EvalResult } from './provider';
2
+ import type { Spec } from './spec';
3
+ import type { RequestContext } from './context';
4
+ /**
5
+ * Evaluates flags against the Zen flags server's read plane. Authentication is
6
+ * a per-service API key; the key pins the environment (and optionally the
7
+ * vertical) server-side — the SDK never sends an environment. Flipt is an
8
+ * internal detail of the server; consumers never see it.
9
+ *
10
+ * Prefer CachedZenProvider in services: it loads the flag set once at boot and
11
+ * keeps it fresh in the background — no network call per flag check.
12
+ */
13
+ /**
14
+ * The canonical Zen flags server. Consumers never configure a URL — the
15
+ * platform team owns this endpoint. Resolution order (highest wins):
16
+ * explicit option → ZEN_FLAGS_URL env (local dev / ops override) → default.
17
+ */
18
+ export declare const DEFAULT_SERVER_URL = "https://atlasrails.online";
19
+ export interface ZenOptions {
20
+ /** Service API key (zfk_…). Falls back to ZEN_FLAGS_KEY. */
21
+ apiKey?: string;
22
+ /** Ops override only — consumers should not set this. Falls back to ZEN_FLAGS_URL. */
23
+ serverUrl?: string;
24
+ timeoutMs?: number;
25
+ }
26
+ export interface CachedZenOptions extends ZenOptions {
27
+ /** Background refresh interval (ms). Default 5000. */
28
+ refreshMs?: number;
29
+ /** How long a stale snapshot keeps serving in an outage (ms). Default 5min. */
30
+ maxStaleMs?: number;
31
+ }
32
+ export declare class ZenProvider implements Provider {
33
+ readonly baseURL: string;
34
+ readonly apiKey: string;
35
+ private readonly timeoutMs;
36
+ constructor(opts?: ZenOptions);
37
+ evaluate(spec: Spec, _rc: RequestContext): Promise<EvalResult>;
38
+ close(): Promise<void>;
39
+ }
40
+ /**
41
+ * Load-once + background-refresh provider. The full flag set for the key's
42
+ * scope is fetched at construction (await ready() during service boot), then
43
+ * kept fresh with jittered conditional polls — unchanged snapshots cost a
44
+ * 0-byte 304. Every evaluation is served from memory; no network on the hot
45
+ * path. A server outage keeps serving the last snapshot until maxStaleMs,
46
+ * after which evaluations throw and the Evaluator's fail-closed/fallback
47
+ * policy takes over.
48
+ */
49
+ export declare class CachedZenProvider implements Provider {
50
+ private snapshot;
51
+ private fetchedAt;
52
+ private etag;
53
+ private timer;
54
+ private stopped;
55
+ private readonly zen;
56
+ private readonly intervalMs;
57
+ private readonly maxStaleMs;
58
+ private readonly readyPromise;
59
+ private markReady;
60
+ constructor(opts?: CachedZenOptions);
61
+ /**
62
+ * Resolves once the first snapshot has loaded — await this at service boot
63
+ * (the load-once pattern). Pass a timeout to bound startup: on timeout the
64
+ * provider keeps retrying in the background and evaluations fail closed /
65
+ * fall back until it recovers.
66
+ */
67
+ ready(timeoutMs?: number): Promise<boolean>;
68
+ private schedule;
69
+ private refresh;
70
+ evaluate(spec: Spec, _rc: RequestContext): Promise<EvalResult>;
71
+ close(): Promise<void>;
72
+ }
package/dist/zen.js ADDED
@@ -0,0 +1,146 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CachedZenProvider = exports.ZenProvider = exports.DEFAULT_SERVER_URL = void 0;
4
+ const spec_1 = require("./spec");
5
+ /**
6
+ * Evaluates flags against the Zen flags server's read plane. Authentication is
7
+ * a per-service API key; the key pins the environment (and optionally the
8
+ * vertical) server-side — the SDK never sends an environment. Flipt is an
9
+ * internal detail of the server; consumers never see it.
10
+ *
11
+ * Prefer CachedZenProvider in services: it loads the flag set once at boot and
12
+ * keeps it fresh in the background — no network call per flag check.
13
+ */
14
+ /**
15
+ * The canonical Zen flags server. Consumers never configure a URL — the
16
+ * platform team owns this endpoint. Resolution order (highest wins):
17
+ * explicit option → ZEN_FLAGS_URL env (local dev / ops override) → default.
18
+ */
19
+ exports.DEFAULT_SERVER_URL = 'https://atlasrails.online';
20
+ const env = (k) => typeof process !== 'undefined' ? process.env?.[k] : undefined;
21
+ class ZenProvider {
22
+ constructor(opts = {}) {
23
+ this.baseURL = (opts.serverUrl ?? env('ZEN_FLAGS_URL') ?? exports.DEFAULT_SERVER_URL).replace(/\/+$/, '');
24
+ this.apiKey = opts.apiKey ?? env('ZEN_FLAGS_KEY') ?? '';
25
+ this.timeoutMs = opts.timeoutMs ?? 2000;
26
+ }
27
+ async evaluate(spec, _rc) {
28
+ const controller = new AbortController();
29
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
30
+ try {
31
+ const res = await fetch(`${this.baseURL}/v1/evaluate`, {
32
+ method: 'POST',
33
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${this.apiKey}` },
34
+ body: JSON.stringify({
35
+ vertical: spec.vertical,
36
+ microservice: spec.microservice,
37
+ component: spec.component ?? '',
38
+ }),
39
+ signal: controller.signal,
40
+ });
41
+ if (!res.ok)
42
+ throw new Error(`zen flags: status ${res.status}`);
43
+ const out = (await res.json());
44
+ return { state: out.state, rampPercent: out.rampPercent, reason: out.reason };
45
+ }
46
+ finally {
47
+ clearTimeout(timer);
48
+ }
49
+ }
50
+ async close() { }
51
+ }
52
+ exports.ZenProvider = ZenProvider;
53
+ /**
54
+ * Load-once + background-refresh provider. The full flag set for the key's
55
+ * scope is fetched at construction (await ready() during service boot), then
56
+ * kept fresh with jittered conditional polls — unchanged snapshots cost a
57
+ * 0-byte 304. Every evaluation is served from memory; no network on the hot
58
+ * path. A server outage keeps serving the last snapshot until maxStaleMs,
59
+ * after which evaluations throw and the Evaluator's fail-closed/fallback
60
+ * policy takes over.
61
+ */
62
+ class CachedZenProvider {
63
+ constructor(opts = {}) {
64
+ this.snapshot = null;
65
+ this.fetchedAt = 0;
66
+ this.etag = '';
67
+ this.timer = null;
68
+ this.stopped = false;
69
+ this.intervalMs = opts.refreshMs ?? 5000;
70
+ this.maxStaleMs = opts.maxStaleMs ?? 5 * 60_000;
71
+ this.zen = new ZenProvider(opts);
72
+ this.readyPromise = new Promise((resolve) => {
73
+ this.markReady = resolve;
74
+ });
75
+ void this.refresh();
76
+ this.schedule();
77
+ }
78
+ /**
79
+ * Resolves once the first snapshot has loaded — await this at service boot
80
+ * (the load-once pattern). Pass a timeout to bound startup: on timeout the
81
+ * provider keeps retrying in the background and evaluations fail closed /
82
+ * fall back until it recovers.
83
+ */
84
+ ready(timeoutMs) {
85
+ if (!timeoutMs)
86
+ return this.readyPromise.then(() => true);
87
+ return Promise.race([
88
+ this.readyPromise.then(() => true),
89
+ new Promise((r) => setTimeout(() => r(false), timeoutMs)),
90
+ ]);
91
+ }
92
+ schedule() {
93
+ if (this.stopped)
94
+ return;
95
+ // ±20% jitter so a fleet of instances doesn't poll in lockstep.
96
+ const jitter = (Math.random() - 0.5) * 0.4 * this.intervalMs;
97
+ this.timer = setTimeout(() => {
98
+ void this.refresh().finally(() => this.schedule());
99
+ }, this.intervalMs + jitter);
100
+ if (typeof this.timer === 'object' && 'unref' in this.timer)
101
+ this.timer.unref();
102
+ }
103
+ async refresh() {
104
+ try {
105
+ const headers = { authorization: `Bearer ${this.zen.apiKey}` };
106
+ if (this.etag)
107
+ headers['if-none-match'] = this.etag;
108
+ const res = await fetch(`${this.zen.baseURL}/v1/snapshot`, { headers });
109
+ if (res.status === 304) {
110
+ this.fetchedAt = Date.now(); // unchanged — refresh the staleness clock
111
+ this.markReady();
112
+ return;
113
+ }
114
+ if (!res.ok)
115
+ return;
116
+ const out = (await res.json());
117
+ const next = new Map();
118
+ for (const f of out.flags ?? []) {
119
+ next.set(f.key, { state: f.state, rampPercent: f.rampPercent, reason: 'snapshot' });
120
+ }
121
+ this.snapshot = next;
122
+ this.fetchedAt = Date.now();
123
+ this.etag = res.headers.get('etag') ?? '';
124
+ this.markReady();
125
+ }
126
+ catch {
127
+ /* keep last snapshot; staleness guard below handles prolonged outages */
128
+ }
129
+ }
130
+ async evaluate(spec, _rc) {
131
+ if (!this.snapshot || Date.now() - this.fetchedAt > this.maxStaleMs) {
132
+ throw new Error('zen flags: snapshot unavailable or stale');
133
+ }
134
+ return (this.snapshot.get((0, spec_1.specKey)(spec)) ?? {
135
+ state: 'DISABLED_HARD',
136
+ reason: 'unknown flag -> baseline',
137
+ });
138
+ }
139
+ async close() {
140
+ this.stopped = true;
141
+ if (this.timer)
142
+ clearTimeout(this.timer);
143
+ this.timer = null;
144
+ }
145
+ }
146
+ exports.CachedZenProvider = CachedZenProvider;
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "@zenxdigitalholdings/zenflags",
3
+ "version": "0.2.0",
4
+ "description": "Zen Flags SDK \u2014 feature flags for Zen products, evaluated in-process from the Zen flags server.",
5
+ "license": "UNLICENSED",
6
+ "type": "commonjs",
7
+ "main": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./nestjs": {
15
+ "types": "./dist/nestjs/index.d.ts",
16
+ "default": "./dist/nestjs/index.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "scripts": {
23
+ "build": "tsc -p tsconfig.build.json",
24
+ "typecheck": "tsc --noEmit",
25
+ "test": "node --import tsx --test src/evaluator.test.ts",
26
+ "prepublishOnly": "npm run typecheck && npm test && npm run build"
27
+ },
28
+ "peerDependencies": {
29
+ "@nestjs/common": ">=10",
30
+ "@nestjs/core": ">=10",
31
+ "reflect-metadata": ">=0.1",
32
+ "rxjs": ">=7"
33
+ },
34
+ "peerDependenciesMeta": {
35
+ "@nestjs/common": {
36
+ "optional": true
37
+ },
38
+ "@nestjs/core": {
39
+ "optional": true
40
+ },
41
+ "reflect-metadata": {
42
+ "optional": true
43
+ },
44
+ "rxjs": {
45
+ "optional": true
46
+ }
47
+ },
48
+ "devDependencies": {
49
+ "@nestjs/common": "^10.4.0",
50
+ "@nestjs/core": "^10.4.0",
51
+ "@types/node": "^22.9.0",
52
+ "reflect-metadata": "^0.2.2",
53
+ "rxjs": "^7.8.1",
54
+ "tsx": "^4.19.2",
55
+ "typescript": "^5.6.3"
56
+ },
57
+ "repository": {
58
+ "type": "git",
59
+ "url": "https://gitlab.com/zenrails/featureflagging.git",
60
+ "directory": "sdks/ts"
61
+ },
62
+ "homepage": "https://atlasrails.online",
63
+ "keywords": [
64
+ "feature-flags",
65
+ "zen",
66
+ "zenrails"
67
+ ],
68
+ "engines": {
69
+ "node": ">=18"
70
+ },
71
+ "publishConfig": {
72
+ "access": "public"
73
+ }
74
+ }