@fireweaveai/server-sdk-cjs 0.0.0-reserve.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/LICENSE +21 -0
- package/README.md +55 -0
- package/dist/application/client.d.ts +77 -0
- package/dist/application/client.js +144 -0
- package/dist/application/mode.d.ts +70 -0
- package/dist/application/mode.js +74 -0
- package/dist/application/ports.d.ts +94 -0
- package/dist/application/ports.js +2 -0
- package/dist/application/runtime.d.ts +101 -0
- package/dist/application/runtime.js +346 -0
- package/dist/domain/context.d.ts +67 -0
- package/dist/domain/context.js +90 -0
- package/dist/domain/errors.d.ts +45 -0
- package/dist/domain/errors.js +99 -0
- package/dist/domain/target.d.ts +2 -0
- package/dist/domain/target.js +2 -0
- package/dist/domain/types.d.ts +107 -0
- package/dist/domain/types.js +2 -0
- package/dist/domain/validation.d.ts +124 -0
- package/dist/domain/validation.js +355 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +52 -0
- package/dist/infrastructure/adapters/inmemory.d.ts +77 -0
- package/dist/infrastructure/adapters/inmemory.js +151 -0
- package/dist/infrastructure/adapters/local.d.ts +108 -0
- package/dist/infrastructure/adapters/local.js +105 -0
- package/dist/infrastructure/adapters/remote.d.ts +74 -0
- package/dist/infrastructure/adapters/remote.js +373 -0
- package/dist/infrastructure/hosts.d.ts +18 -0
- package/dist/infrastructure/hosts.js +72 -0
- package/dist/register-browser.d.ts +1 -0
- package/dist/register-browser.js +7 -0
- package/dist/register.d.ts +1 -0
- package/dist/register.js +13 -0
- package/dist/start/browser.d.ts +12 -0
- package/dist/start/browser.js +36 -0
- package/dist/start/build-info.d.ts +2 -0
- package/dist/start/build-info.js +9 -0
- package/dist/start/control-points.d.ts +20 -0
- package/dist/start/control-points.js +59 -0
- package/dist/start/env.d.ts +11 -0
- package/dist/start/env.js +64 -0
- package/dist/start/fw.d.ts +29 -0
- package/dist/start/fw.js +76 -0
- package/dist/start/index.d.ts +23 -0
- package/dist/start/index.js +28 -0
- package/dist/start/instance.d.ts +17 -0
- package/dist/start/instance.js +56 -0
- package/dist/start/names.d.ts +22 -0
- package/dist/start/names.js +25 -0
- package/dist/start/resolve.d.ts +40 -0
- package/dist/start/resolve.js +139 -0
- package/dist/start/state.d.ts +96 -0
- package/dist/start/state.js +303 -0
- package/package.json +58 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FireweaveRuntime = exports.DEFAULT_SHUTDOWN_TIMEOUT_MS = void 0;
|
|
4
|
+
exports.stableStringify = stableStringify;
|
|
5
|
+
/**
|
|
6
|
+
* FireweaveRuntime: shared engine behind the OpenFeature provider and the
|
|
7
|
+
* FireweaveClient extensions. Owns lifecycle state machine, config validation,
|
|
8
|
+
* context policy, and decision construction. Defaults never throw: evaluation
|
|
9
|
+
* always returns a Decision.
|
|
10
|
+
*/
|
|
11
|
+
const errors_js_1 = require("../domain/errors.js");
|
|
12
|
+
const hosts_js_1 = require("../infrastructure/hosts.js");
|
|
13
|
+
const context_js_1 = require("../domain/context.js");
|
|
14
|
+
const validation_js_1 = require("../domain/validation.js");
|
|
15
|
+
/** Default bound on shutdown/flush (matches capabilities.get()). */
|
|
16
|
+
exports.DEFAULT_SHUTDOWN_TIMEOUT_MS = 10000;
|
|
17
|
+
function validateConfig(config) {
|
|
18
|
+
if (config.host !== undefined) {
|
|
19
|
+
// Allowlist is ON by default: undefined/empty
|
|
20
|
+
// allowedHosts falls back to the canonical Fireweave + loopback list.
|
|
21
|
+
(0, hosts_js_1.assertHostAllowed)(config.host, config.allowedHosts);
|
|
22
|
+
}
|
|
23
|
+
if (config.limits !== undefined) {
|
|
24
|
+
for (const value of Object.values(config.limits)) {
|
|
25
|
+
if (value !== undefined && (!Number.isInteger(value) || value <= 0)) {
|
|
26
|
+
throw new errors_js_1.FireweaveError('Configuration');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** JSON stringify with deterministic (sorted) key order at every level. */
|
|
32
|
+
function stableStringify(value) {
|
|
33
|
+
if (value === null || typeof value !== 'object')
|
|
34
|
+
return JSON.stringify(value);
|
|
35
|
+
if (Array.isArray(value))
|
|
36
|
+
return `[${value.map((v) => stableStringify(v)).join(',')}]`;
|
|
37
|
+
const keys = Object.keys(value).sort();
|
|
38
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;
|
|
39
|
+
}
|
|
40
|
+
class FireweaveRuntime {
|
|
41
|
+
adapter;
|
|
42
|
+
config;
|
|
43
|
+
contextPolicy;
|
|
44
|
+
state = 'UNINITIALIZED';
|
|
45
|
+
globalContext;
|
|
46
|
+
clientContext;
|
|
47
|
+
initPromise;
|
|
48
|
+
stateListeners = new Set();
|
|
49
|
+
/** Dedup window for evaluate/OF-path exposures (cleared on flush / shutdown). */
|
|
50
|
+
evaluateExposureSeen = new Set();
|
|
51
|
+
constructor(adapter, config = {}) {
|
|
52
|
+
this.adapter = adapter;
|
|
53
|
+
this.config = { ...config };
|
|
54
|
+
const limits = { ...context_js_1.DEFAULT_CONTEXT_LIMITS, ...(config.limits ?? {}) };
|
|
55
|
+
this.contextPolicy = {
|
|
56
|
+
limits,
|
|
57
|
+
reservedAttributeKeys: [
|
|
58
|
+
...context_js_1.DEFAULT_RESERVED_ATTRIBUTE_KEYS,
|
|
59
|
+
...(config.reservedAttributeKeys ?? []),
|
|
60
|
+
],
|
|
61
|
+
requireTargetingKey: config.requireTargetingKey ?? false,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** Clear evaluate-path exposure dedup (M-2 / clear-on-flush lifecycle). */
|
|
65
|
+
clearEvaluateExposureDedup() {
|
|
66
|
+
this.evaluateExposureSeen.clear();
|
|
67
|
+
}
|
|
68
|
+
getState() {
|
|
69
|
+
return this.state;
|
|
70
|
+
}
|
|
71
|
+
onStateChange(listener) {
|
|
72
|
+
this.stateListeners.add(listener);
|
|
73
|
+
}
|
|
74
|
+
setState(next) {
|
|
75
|
+
this.state = next;
|
|
76
|
+
for (const listener of this.stateListeners)
|
|
77
|
+
listener(next);
|
|
78
|
+
}
|
|
79
|
+
/** Mark definitions/cache staleness (adapter-driven). */
|
|
80
|
+
markStale() {
|
|
81
|
+
if (this.state === 'READY')
|
|
82
|
+
this.setState('STALE');
|
|
83
|
+
}
|
|
84
|
+
setGlobalContext(context) {
|
|
85
|
+
this.globalContext = context;
|
|
86
|
+
}
|
|
87
|
+
setClientContext(context) {
|
|
88
|
+
this.clientContext = context;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Initialize the runtime: validate config, then bring the adapter up.
|
|
92
|
+
* Rejects with FireweaveError; Configuration errors put the runtime in FATAL.
|
|
93
|
+
*/
|
|
94
|
+
async initialize(signal) {
|
|
95
|
+
if (this.state === 'SHUTDOWN') {
|
|
96
|
+
throw new errors_js_1.FireweaveError('AlreadyClosed');
|
|
97
|
+
}
|
|
98
|
+
if (this.state === 'READY')
|
|
99
|
+
return;
|
|
100
|
+
if (this.initPromise !== undefined)
|
|
101
|
+
return this.initPromise;
|
|
102
|
+
this.initPromise = (async () => {
|
|
103
|
+
this.setState('INITIALIZING');
|
|
104
|
+
try {
|
|
105
|
+
validateConfig(this.config);
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
this.setState('FATAL');
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
await this.adapter.initialize(signal);
|
|
113
|
+
this.setState('READY');
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
if ((0, errors_js_1.isFireweaveError)(err) && err.kind === 'Configuration') {
|
|
117
|
+
this.setState('FATAL');
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
this.setState('ERROR');
|
|
121
|
+
}
|
|
122
|
+
throw (0, errors_js_1.isFireweaveError)(err) ? err : new errors_js_1.FireweaveError('Internal', { cause: err });
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
this.initPromise = undefined;
|
|
126
|
+
}
|
|
127
|
+
})();
|
|
128
|
+
return this.initPromise;
|
|
129
|
+
}
|
|
130
|
+
async shutdown() {
|
|
131
|
+
if (this.state === 'SHUTDOWN')
|
|
132
|
+
return; // idempotent
|
|
133
|
+
// The configured deadline bounds the whole flush+close sequence (M-1/L-4):
|
|
134
|
+
// a wedged vendor client must not hang process exit.
|
|
135
|
+
const timeoutMs = this.config.shutdownTimeoutMs ?? exports.DEFAULT_SHUTDOWN_TIMEOUT_MS;
|
|
136
|
+
let timer;
|
|
137
|
+
const deadline = new Promise((resolve) => {
|
|
138
|
+
timer = setTimeout(resolve, timeoutMs);
|
|
139
|
+
});
|
|
140
|
+
const close = (async () => {
|
|
141
|
+
try {
|
|
142
|
+
await this.adapter.flush?.();
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// best-effort flush; never throw from shutdown
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
await this.adapter.shutdown();
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// swallow: shutdown must not throw
|
|
152
|
+
}
|
|
153
|
+
})();
|
|
154
|
+
try {
|
|
155
|
+
await Promise.race([close, deadline]);
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
this.evaluateExposureSeen.clear();
|
|
159
|
+
if (timer !== undefined)
|
|
160
|
+
clearTimeout(timer);
|
|
161
|
+
this.setState('SHUTDOWN');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** Canonicalize + validate the merged context (throws FireweaveError). */
|
|
165
|
+
resolveContext(invocationContext) {
|
|
166
|
+
const merged = (0, context_js_1.mergeContexts)(this.globalContext, this.clientContext, invocationContext);
|
|
167
|
+
const result = (0, validation_js_1.validateContext)(merged, this.contextPolicy);
|
|
168
|
+
if (!result.ok)
|
|
169
|
+
throw result.error;
|
|
170
|
+
return result.value;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Evaluate a flag to a canonical Decision. Never throws.
|
|
174
|
+
*
|
|
175
|
+
* Validates in the fixed order spec/control-points.md "Validation, before
|
|
176
|
+
* any I/O" names, stopping at the first failure: (1) key, (2) default vs
|
|
177
|
+
* type, (3) context, (4) lifecycle. Only once all four pass does this
|
|
178
|
+
* reach the adapter (the one I/O call in this method).
|
|
179
|
+
*/
|
|
180
|
+
async evaluate(controlPointKey, expectedType, defaultValue, invocationContext, options = {}) {
|
|
181
|
+
const keyResult = (0, validation_js_1.validateControlPointKey)(controlPointKey);
|
|
182
|
+
if (!keyResult.ok) {
|
|
183
|
+
return this.errorDecision(controlPointKey, defaultValue, keyResult.error);
|
|
184
|
+
}
|
|
185
|
+
const defaultResult = (0, validation_js_1.validateDefaultValue)(expectedType, defaultValue);
|
|
186
|
+
if (!defaultResult.ok) {
|
|
187
|
+
return this.errorDecision(controlPointKey, defaultValue, defaultResult.error);
|
|
188
|
+
}
|
|
189
|
+
const merged = (0, context_js_1.mergeContexts)(this.globalContext, this.clientContext, invocationContext);
|
|
190
|
+
const contextResult = (0, validation_js_1.validateContext)(merged, this.contextPolicy);
|
|
191
|
+
if (!contextResult.ok) {
|
|
192
|
+
return this.errorDecision(controlPointKey, defaultValue, contextResult.error);
|
|
193
|
+
}
|
|
194
|
+
const context = contextResult.value;
|
|
195
|
+
const lifecycleError = this.lifecycleError();
|
|
196
|
+
if (lifecycleError !== undefined) {
|
|
197
|
+
return this.errorDecision(controlPointKey, defaultValue, lifecycleError);
|
|
198
|
+
}
|
|
199
|
+
let resolution;
|
|
200
|
+
try {
|
|
201
|
+
const resolveOpts = options.signal !== undefined ? { signal: options.signal } : {};
|
|
202
|
+
resolution = await this.adapter.resolve(controlPointKey, context, resolveOpts);
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
// H-2: non-Fireweave (vendor/internal) exception text never reaches the
|
|
206
|
+
// outward errorMessage — the fixed taxonomy message is used and the
|
|
207
|
+
// original error is preserved on `cause` only.
|
|
208
|
+
const fw = (0, errors_js_1.isFireweaveError)(err) ? err : new errors_js_1.FireweaveError('Internal', { cause: err });
|
|
209
|
+
return this.errorDecision(controlPointKey, defaultValue, fw);
|
|
210
|
+
}
|
|
211
|
+
if (!resolution.found) {
|
|
212
|
+
// spec/modes.md "Behaviour per mode": local's unknown-key row is
|
|
213
|
+
// `default` / `reason: DEFAULT` — deliberately not an error, unlike
|
|
214
|
+
// remote's `default` / `ERROR` / `ControlPointNotFound`. An adapter signals the
|
|
215
|
+
// former by carrying `reason: 'DEFAULT'` on its miss (FireweaveLocalAdapter);
|
|
216
|
+
// any adapter that leaves `reason` unset (InMemoryAdapter,
|
|
217
|
+
// FireweaveRemoteAdapter) keeps the ControlPointNotFound/ERROR path below.
|
|
218
|
+
if (resolution.reason === 'DEFAULT') {
|
|
219
|
+
return { controlPointKey, value: defaultValue, reason: 'DEFAULT', metadata: {} };
|
|
220
|
+
}
|
|
221
|
+
const meta = {};
|
|
222
|
+
if (resolution.quotaLimited === true)
|
|
223
|
+
meta['fireweave.quotaLimited'] = true;
|
|
224
|
+
return this.errorDecision(controlPointKey, defaultValue, new errors_js_1.FireweaveError('ControlPointNotFound', { metadata: meta }));
|
|
225
|
+
}
|
|
226
|
+
const value = resolution.value ?? null;
|
|
227
|
+
if (!(0, validation_js_1.matchesExpectedType)(value, expectedType)) {
|
|
228
|
+
return this.errorDecision(controlPointKey, defaultValue, new errors_js_1.FireweaveError('TypeMismatch'));
|
|
229
|
+
}
|
|
230
|
+
const metadata = {};
|
|
231
|
+
if (resolution.version !== undefined)
|
|
232
|
+
metadata['fireweave.controlPointVersion'] = resolution.version;
|
|
233
|
+
// Detailed vendor fields travel together: they surface only when the
|
|
234
|
+
// backend reported BOTH a vendor flag id and a matched condition index
|
|
235
|
+
// (fixtures: eval-detailed-fields exposes them; eval-multivariate-string
|
|
236
|
+
// [index, no id] and eval-payload-attached [id, no index] do not).
|
|
237
|
+
if (resolution.vendorControlPointId !== undefined && resolution.conditionIndex !== undefined) {
|
|
238
|
+
metadata['fireweave.vendorControlPointId'] = resolution.vendorControlPointId;
|
|
239
|
+
if (resolution.reasonCode !== undefined)
|
|
240
|
+
metadata['fireweave.reasonCode'] = resolution.reasonCode;
|
|
241
|
+
}
|
|
242
|
+
if (options.includePayload === true && resolution.payload !== undefined) {
|
|
243
|
+
metadata['fireweave.payload'] =
|
|
244
|
+
typeof resolution.payload === 'string' ? resolution.payload : stableStringify(resolution.payload);
|
|
245
|
+
}
|
|
246
|
+
if (resolution.quotaLimited === true)
|
|
247
|
+
metadata['fireweave.quotaLimited'] = true;
|
|
248
|
+
if (resolution.fromCache === true)
|
|
249
|
+
metadata['fireweave.fromCache'] = true;
|
|
250
|
+
let reason;
|
|
251
|
+
if (resolution.enabled === false) {
|
|
252
|
+
reason = 'DISABLED';
|
|
253
|
+
}
|
|
254
|
+
else if (resolution.reason !== undefined) {
|
|
255
|
+
reason = resolution.reason;
|
|
256
|
+
}
|
|
257
|
+
else if (resolution.fromCache === true || this.state === 'STALE') {
|
|
258
|
+
reason = 'STALE';
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
reason = 'TARGETING_MATCH';
|
|
262
|
+
}
|
|
263
|
+
const decision = { controlPointKey, value, reason, metadata };
|
|
264
|
+
if (resolution.variant !== undefined)
|
|
265
|
+
decision.variant = resolution.variant;
|
|
266
|
+
// H-4 / ruling 20: evaluate is side-effect-free by default; opt in via sendExposure: true.
|
|
267
|
+
if (options.sendExposure === true) {
|
|
268
|
+
this.emitEvaluateExposure({
|
|
269
|
+
targetingKey: context.targetingKey ?? '',
|
|
270
|
+
controlPointKey,
|
|
271
|
+
value,
|
|
272
|
+
...(resolution.variant !== undefined ? { variant: resolution.variant } : {}),
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
return decision;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Register a user or device so flag rules can target its DURABLE properties.
|
|
279
|
+
*
|
|
280
|
+
* Call once per login / device provisioning with the facts that outlive a
|
|
281
|
+
* request (plan, beta membership, region, device model), then send the same
|
|
282
|
+
* `targetingKey` on evaluation. Per-request context attributes still override
|
|
283
|
+
* the registered properties for a single evaluation — the two identity paths
|
|
284
|
+
* compose (spec/remote-protocol.md § Two identity paths).
|
|
285
|
+
*
|
|
286
|
+
* Resolves with `ok: false` instead of throwing: this runs in sign-in paths.
|
|
287
|
+
* Adapters without the capability (in-memory, local dev) report
|
|
288
|
+
* `Unsupported` so a dev harness does not silently look registered.
|
|
289
|
+
*/
|
|
290
|
+
async registerTarget(targetingKey, options = {}) {
|
|
291
|
+
const lifecycle = this.lifecycleError();
|
|
292
|
+
if (lifecycle !== undefined)
|
|
293
|
+
return { ok: false, error: lifecycle };
|
|
294
|
+
if (this.adapter.registerTarget === undefined) {
|
|
295
|
+
return { ok: false, error: new errors_js_1.FireweaveError('UnsupportedCapability') };
|
|
296
|
+
}
|
|
297
|
+
return this.adapter.registerTarget(targetingKey, options);
|
|
298
|
+
}
|
|
299
|
+
emitEvaluateExposure(exposure) {
|
|
300
|
+
if (exposure.targetingKey.length === 0)
|
|
301
|
+
return;
|
|
302
|
+
if (this.adapter.recordExposure === undefined)
|
|
303
|
+
return;
|
|
304
|
+
const key = `${exposure.targetingKey}\u0000${exposure.controlPointKey}\u0000${exposure.variant ?? ''}\u0000${stableStringify(exposure.value ?? null)}`;
|
|
305
|
+
if (this.evaluateExposureSeen.has(key))
|
|
306
|
+
return;
|
|
307
|
+
this.evaluateExposureSeen.add(key);
|
|
308
|
+
this.adapter.recordExposure({
|
|
309
|
+
targetingKey: exposure.targetingKey,
|
|
310
|
+
controlPointKey: exposure.controlPointKey,
|
|
311
|
+
value: exposure.value,
|
|
312
|
+
...(exposure.variant !== undefined ? { variant: exposure.variant } : {}),
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
lifecycleError() {
|
|
316
|
+
switch (this.state) {
|
|
317
|
+
case 'UNINITIALIZED':
|
|
318
|
+
case 'INITIALIZING':
|
|
319
|
+
return new errors_js_1.FireweaveError('NotReady');
|
|
320
|
+
case 'ERROR':
|
|
321
|
+
return new errors_js_1.FireweaveError('NotReady');
|
|
322
|
+
case 'FATAL':
|
|
323
|
+
return new errors_js_1.FireweaveError('Configuration');
|
|
324
|
+
case 'SHUTDOWN':
|
|
325
|
+
return new errors_js_1.FireweaveError('AlreadyClosed');
|
|
326
|
+
default:
|
|
327
|
+
return undefined;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
errorDecision(controlPointKey, defaultValue, err) {
|
|
331
|
+
const metadata = {
|
|
332
|
+
'fireweave.errorKind': err.kind,
|
|
333
|
+
...err.metadata,
|
|
334
|
+
};
|
|
335
|
+
return {
|
|
336
|
+
controlPointKey,
|
|
337
|
+
value: defaultValue,
|
|
338
|
+
reason: 'ERROR',
|
|
339
|
+
errorCode: err.openFeatureErrorCode,
|
|
340
|
+
errorKind: err.kind,
|
|
341
|
+
errorMessage: err.message,
|
|
342
|
+
metadata,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
exports.FireweaveRuntime = FireweaveRuntime;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evaluation-context handling: merge order (global → client → invocation)
|
|
3
|
+
* and normalization of raw caller input into the shape validation.ts checks,
|
|
4
|
+
* plus the post-canonicalization reporting view.
|
|
5
|
+
* Bounds per spec/evaluation-context.schema.json (ratified 2026-07-27):
|
|
6
|
+
* 128 attrs / 256 B keys / 4 KiB values / depth 6 / 64 KiB serialized.
|
|
7
|
+
*
|
|
8
|
+
* Bound/reserved-key ENFORCEMENT (the pure, Validated<T>-returning checks)
|
|
9
|
+
* lives in validation.ts (`validateContext`) — spec/control-points.md
|
|
10
|
+
* "Validation, before any I/O" rule 3. This module has no throwing/failing
|
|
11
|
+
* surface of its own.
|
|
12
|
+
*/
|
|
13
|
+
import type { CanonicalContext, JsonValue } from './types.js';
|
|
14
|
+
export interface ContextLimits {
|
|
15
|
+
maxAttributeCount: number;
|
|
16
|
+
maxKeyBytes: number;
|
|
17
|
+
maxValueBytes: number;
|
|
18
|
+
maxNestingDepth: number;
|
|
19
|
+
maxSerializedContextBytes: number;
|
|
20
|
+
}
|
|
21
|
+
export declare const DEFAULT_CONTEXT_LIMITS: Readonly<ContextLimits>;
|
|
22
|
+
/** Attribute keys callers may never set (in addition to the fireweave.* namespace). */
|
|
23
|
+
export declare const DEFAULT_RESERVED_ATTRIBUTE_KEYS: readonly string[];
|
|
24
|
+
/**
|
|
25
|
+
* Sanctioned fireweave.* carriers (orchestrator rulings 12–14): the ONLY
|
|
26
|
+
* `fireweave.*` context keys callers may set. They are the canonical spelling
|
|
27
|
+
* for group memberships / group properties; plain `groups`/`groupProperties`
|
|
28
|
+
* remain accepted as a documented alias.
|
|
29
|
+
*/
|
|
30
|
+
export declare const ALLOWED_FIREWEAVE_CONTEXT_KEYS: readonly string[];
|
|
31
|
+
export interface ContextPolicy {
|
|
32
|
+
limits: ContextLimits;
|
|
33
|
+
reservedAttributeKeys: readonly string[];
|
|
34
|
+
requireTargetingKey: boolean;
|
|
35
|
+
}
|
|
36
|
+
/** Loose input shape accepted from callers (OpenFeature EvaluationContext-compatible). */
|
|
37
|
+
export interface ContextInput {
|
|
38
|
+
targetingKey?: string;
|
|
39
|
+
attributes?: Record<string, unknown>;
|
|
40
|
+
/** OpenFeature flat contexts: extra top-level keys are treated as attributes. */
|
|
41
|
+
[key: string]: unknown;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Extract {targetingKey, attributes} from a caller context. Supports both the
|
|
45
|
+
* canonical shape ({targetingKey, attributes:{...}}) and OpenFeature flat shape
|
|
46
|
+
* ({targetingKey, plan:"pro", ...}).
|
|
47
|
+
*/
|
|
48
|
+
export declare function normalizeContextInput(input: ContextInput | undefined): {
|
|
49
|
+
targetingKey?: string;
|
|
50
|
+
attributes: Record<string, unknown>;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Merge context layers with later layers winning at the attribute-key level.
|
|
54
|
+
* Layers are raw inputs; output is a deep copy (caller objects never retained).
|
|
55
|
+
*/
|
|
56
|
+
export declare function mergeContexts(...layers: Array<ContextInput | undefined>): {
|
|
57
|
+
targetingKey?: string;
|
|
58
|
+
attributes: Record<string, unknown>;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Resolved context for reporting/telemetry: `$`-prefixed system directives are
|
|
62
|
+
* filtered out; empty attributes omitted.
|
|
63
|
+
*/
|
|
64
|
+
export declare function resolvedContextView(ctx: CanonicalContext): {
|
|
65
|
+
targetingKey?: string;
|
|
66
|
+
attributes?: Record<string, JsonValue>;
|
|
67
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ALLOWED_FIREWEAVE_CONTEXT_KEYS = exports.DEFAULT_RESERVED_ATTRIBUTE_KEYS = exports.DEFAULT_CONTEXT_LIMITS = void 0;
|
|
4
|
+
exports.normalizeContextInput = normalizeContextInput;
|
|
5
|
+
exports.mergeContexts = mergeContexts;
|
|
6
|
+
exports.resolvedContextView = resolvedContextView;
|
|
7
|
+
exports.DEFAULT_CONTEXT_LIMITS = Object.freeze({
|
|
8
|
+
maxAttributeCount: 128,
|
|
9
|
+
maxKeyBytes: 256,
|
|
10
|
+
maxValueBytes: 4096,
|
|
11
|
+
maxNestingDepth: 6,
|
|
12
|
+
maxSerializedContextBytes: 65536,
|
|
13
|
+
});
|
|
14
|
+
/** Attribute keys callers may never set (in addition to the fireweave.* namespace). */
|
|
15
|
+
exports.DEFAULT_RESERVED_ATTRIBUTE_KEYS = Object.freeze([
|
|
16
|
+
'targetingKey',
|
|
17
|
+
'kind',
|
|
18
|
+
]);
|
|
19
|
+
/**
|
|
20
|
+
* Sanctioned fireweave.* carriers (orchestrator rulings 12–14): the ONLY
|
|
21
|
+
* `fireweave.*` context keys callers may set. They are the canonical spelling
|
|
22
|
+
* for group memberships / group properties; plain `groups`/`groupProperties`
|
|
23
|
+
* remain accepted as a documented alias.
|
|
24
|
+
*/
|
|
25
|
+
exports.ALLOWED_FIREWEAVE_CONTEXT_KEYS = Object.freeze([
|
|
26
|
+
'fireweave.groups',
|
|
27
|
+
'fireweave.groupProperties',
|
|
28
|
+
]);
|
|
29
|
+
/**
|
|
30
|
+
* Extract {targetingKey, attributes} from a caller context. Supports both the
|
|
31
|
+
* canonical shape ({targetingKey, attributes:{...}}) and OpenFeature flat shape
|
|
32
|
+
* ({targetingKey, plan:"pro", ...}).
|
|
33
|
+
*/
|
|
34
|
+
function normalizeContextInput(input) {
|
|
35
|
+
if (input === undefined || input === null)
|
|
36
|
+
return { attributes: {} };
|
|
37
|
+
const attributes = {};
|
|
38
|
+
let targetingKey;
|
|
39
|
+
if (typeof input.targetingKey === 'string' && input.targetingKey.length > 0) {
|
|
40
|
+
targetingKey = input.targetingKey;
|
|
41
|
+
}
|
|
42
|
+
if (input.attributes !== undefined && typeof input.attributes === 'object' && input.attributes !== null && !Array.isArray(input.attributes)) {
|
|
43
|
+
for (const [k, v] of Object.entries(input.attributes)) {
|
|
44
|
+
if (v !== undefined)
|
|
45
|
+
attributes[k] = v;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
for (const [k, v] of Object.entries(input)) {
|
|
49
|
+
if (k === 'targetingKey' || k === 'attributes')
|
|
50
|
+
continue;
|
|
51
|
+
if (v !== undefined)
|
|
52
|
+
attributes[k] = v;
|
|
53
|
+
}
|
|
54
|
+
return targetingKey !== undefined ? { targetingKey, attributes } : { attributes };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Merge context layers with later layers winning at the attribute-key level.
|
|
58
|
+
* Layers are raw inputs; output is a deep copy (caller objects never retained).
|
|
59
|
+
*/
|
|
60
|
+
function mergeContexts(...layers) {
|
|
61
|
+
let targetingKey;
|
|
62
|
+
const attributes = {};
|
|
63
|
+
for (const layer of layers) {
|
|
64
|
+
const { targetingKey: tk, attributes: attrs } = normalizeContextInput(layer);
|
|
65
|
+
if (tk !== undefined)
|
|
66
|
+
targetingKey = tk;
|
|
67
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
68
|
+
attributes[k] = v;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return targetingKey !== undefined ? { targetingKey, attributes } : { attributes };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Resolved context for reporting/telemetry: `$`-prefixed system directives are
|
|
75
|
+
* filtered out; empty attributes omitted.
|
|
76
|
+
*/
|
|
77
|
+
function resolvedContextView(ctx) {
|
|
78
|
+
const attributes = {};
|
|
79
|
+
for (const [k, v] of Object.entries(ctx.attributes)) {
|
|
80
|
+
if (k.startsWith('$'))
|
|
81
|
+
continue;
|
|
82
|
+
attributes[k] = v;
|
|
83
|
+
}
|
|
84
|
+
const out = {};
|
|
85
|
+
if (ctx.targetingKey !== undefined)
|
|
86
|
+
out.targetingKey = ctx.targetingKey;
|
|
87
|
+
if (Object.keys(attributes).length > 0)
|
|
88
|
+
out.attributes = attributes;
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fireweave canonical error taxonomy (spec/errors.schema.json, contracts/errors.json).
|
|
3
|
+
*
|
|
4
|
+
* 15 PascalCase kinds mapped to OpenFeature error codes. Rules:
|
|
5
|
+
* - defaults never throw: evaluation paths surface errors via Decision, not exceptions;
|
|
6
|
+
* - no secrets in messages: all outward-facing messages are redacted;
|
|
7
|
+
* - cause preserved internally (`FireweaveError.cause`), never serialized outward.
|
|
8
|
+
*/
|
|
9
|
+
export type FireweaveErrorKind = 'NotReady' | 'ControlPointNotFound' | 'TypeMismatch' | 'InvalidContext' | 'Authentication' | 'Authorization' | 'RateLimited' | 'Timeout' | 'Network' | 'BackendUnavailable' | 'MalformedResponse' | 'UnsupportedCapability' | 'Configuration' | 'AlreadyClosed' | 'Internal';
|
|
10
|
+
/** OpenFeature error code strings (mirrors @openfeature/core ErrorCode values). */
|
|
11
|
+
export type OpenFeatureErrorCode = 'PROVIDER_NOT_READY' | 'PROVIDER_FATAL' | 'FLAG_NOT_FOUND' | 'PARSE_ERROR' | 'TYPE_MISMATCH' | 'TARGETING_KEY_MISSING' | 'INVALID_CONTEXT' | 'GENERAL';
|
|
12
|
+
export interface ErrorKindSpec {
|
|
13
|
+
readonly kind: FireweaveErrorKind;
|
|
14
|
+
readonly openFeatureErrorCode: OpenFeatureErrorCode;
|
|
15
|
+
readonly retryable: boolean;
|
|
16
|
+
readonly errorClass: 'transient' | 'permanent';
|
|
17
|
+
readonly defaultMessage: string;
|
|
18
|
+
}
|
|
19
|
+
export declare const ERROR_TAXONOMY: Readonly<Record<FireweaveErrorKind, ErrorKindSpec>>;
|
|
20
|
+
/**
|
|
21
|
+
* Scrub secrets per the redaction contract, then collapse whitespace runs and
|
|
22
|
+
* trim (so a message stays on one log line).
|
|
23
|
+
*/
|
|
24
|
+
export declare function redactSecrets(text: string): string;
|
|
25
|
+
export interface FireweaveErrorOptions {
|
|
26
|
+
/** Custom message; will be redacted. Defaults to the taxonomy default message. */
|
|
27
|
+
message?: string;
|
|
28
|
+
/** Underlying cause, preserved internally only. */
|
|
29
|
+
cause?: unknown;
|
|
30
|
+
/** Override the OF code (e.g. TARGETING_KEY_MISSING for InvalidContext). */
|
|
31
|
+
openFeatureErrorCode?: OpenFeatureErrorCode;
|
|
32
|
+
/** Extra fireweave.* flag metadata to surface with error decisions. */
|
|
33
|
+
metadata?: Record<string, string | number | boolean>;
|
|
34
|
+
}
|
|
35
|
+
export declare class FireweaveError extends Error {
|
|
36
|
+
readonly kind: FireweaveErrorKind;
|
|
37
|
+
readonly openFeatureErrorCode: OpenFeatureErrorCode;
|
|
38
|
+
readonly retryable: boolean;
|
|
39
|
+
readonly errorClass: 'transient' | 'permanent';
|
|
40
|
+
/** Deterministic outward-facing message from the taxonomy. */
|
|
41
|
+
readonly safeMessage: string;
|
|
42
|
+
readonly metadata: Readonly<Record<string, string | number | boolean>>;
|
|
43
|
+
constructor(kind: FireweaveErrorKind, options?: FireweaveErrorOptions);
|
|
44
|
+
}
|
|
45
|
+
export declare function isFireweaveError(err: unknown): err is FireweaveError;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Fireweave canonical error taxonomy (spec/errors.schema.json, contracts/errors.json).
|
|
4
|
+
*
|
|
5
|
+
* 15 PascalCase kinds mapped to OpenFeature error codes. Rules:
|
|
6
|
+
* - defaults never throw: evaluation paths surface errors via Decision, not exceptions;
|
|
7
|
+
* - no secrets in messages: all outward-facing messages are redacted;
|
|
8
|
+
* - cause preserved internally (`FireweaveError.cause`), never serialized outward.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.FireweaveError = exports.ERROR_TAXONOMY = void 0;
|
|
12
|
+
exports.redactSecrets = redactSecrets;
|
|
13
|
+
exports.isFireweaveError = isFireweaveError;
|
|
14
|
+
const spec = (kind, openFeatureErrorCode, retryable, errorClass, defaultMessage) => ({ kind, openFeatureErrorCode, retryable, errorClass, defaultMessage });
|
|
15
|
+
exports.ERROR_TAXONOMY = Object.freeze({
|
|
16
|
+
NotReady: spec('NotReady', 'PROVIDER_NOT_READY', true, 'transient', 'provider not ready'),
|
|
17
|
+
ControlPointNotFound: spec('ControlPointNotFound', 'FLAG_NOT_FOUND', false, 'permanent', 'flag not found'),
|
|
18
|
+
TypeMismatch: spec('TypeMismatch', 'TYPE_MISMATCH', false, 'permanent', 'flag type mismatch'),
|
|
19
|
+
InvalidContext: spec('InvalidContext', 'INVALID_CONTEXT', false, 'permanent', 'invalid evaluation context'),
|
|
20
|
+
Authentication: spec('Authentication', 'GENERAL', false, 'permanent', 'authentication failed'),
|
|
21
|
+
Authorization: spec('Authorization', 'GENERAL', false, 'permanent', 'authorization failed'),
|
|
22
|
+
RateLimited: spec('RateLimited', 'GENERAL', true, 'transient', 'rate limited'),
|
|
23
|
+
Timeout: spec('Timeout', 'GENERAL', true, 'transient', 'request timed out'),
|
|
24
|
+
Network: spec('Network', 'GENERAL', true, 'transient', 'network error'),
|
|
25
|
+
BackendUnavailable: spec('BackendUnavailable', 'GENERAL', true, 'transient', 'backend unavailable'),
|
|
26
|
+
MalformedResponse: spec('MalformedResponse', 'PARSE_ERROR', false, 'permanent', 'malformed backend response'),
|
|
27
|
+
UnsupportedCapability: spec('UnsupportedCapability', 'GENERAL', false, 'permanent', 'unsupported capability'),
|
|
28
|
+
Configuration: spec('Configuration', 'PROVIDER_FATAL', false, 'permanent', 'invalid configuration'),
|
|
29
|
+
AlreadyClosed: spec('AlreadyClosed', 'PROVIDER_NOT_READY', false, 'permanent', 'provider already closed'),
|
|
30
|
+
Internal: spec('Internal', 'GENERAL', false, 'permanent', 'internal error'),
|
|
31
|
+
});
|
|
32
|
+
/**
|
|
33
|
+
* The redaction contract (contracts/errors.json `rules.redaction`, start-profile
|
|
34
|
+
* SP-26). Applied in this order: bearer tokens, URL userinfo, named
|
|
35
|
+
* assignments, then key-shaped values. A variable NAME is never redacted on its
|
|
36
|
+
* own; only its value is. `test/unit/redaction-contract.test.ts` runs every
|
|
37
|
+
* contract vector through `redactSecrets`.
|
|
38
|
+
*/
|
|
39
|
+
const REDACTION_PLACEHOLDER = '[REDACTED]';
|
|
40
|
+
/** Variables whose assigned value is scrubbed: `NAME=value`, `NAME: value`, `NAME = "value"`. */
|
|
41
|
+
const ASSIGNMENT_NAMES = ['FIREWEAVE_KEY', 'FIREWEAVE_BROWSER_KEY', 'FW_PROJECT_API_KEY'];
|
|
42
|
+
/** Key families; a prefix only counts when one or more of [A-Za-z0-9_-] follows it. */
|
|
43
|
+
const VALUE_PREFIXES = [
|
|
44
|
+
'project-api-key_',
|
|
45
|
+
'fw_public_',
|
|
46
|
+
'fw_ingest_pub_',
|
|
47
|
+
'fw_org_',
|
|
48
|
+
'cli_at_',
|
|
49
|
+
'phc_',
|
|
50
|
+
'phx_',
|
|
51
|
+
'phs_',
|
|
52
|
+
];
|
|
53
|
+
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
54
|
+
const BEARER = /\bBearer(\s+)[A-Za-z0-9._~+/=-]+/g;
|
|
55
|
+
// scheme://userinfo@host: userinfo cannot hold whitespace, '/', '?', '#' or '@'.
|
|
56
|
+
const URL_USERINFO = /\b([A-Za-z][A-Za-z0-9+.-]*:\/\/)[^\s/?#@]+@/g;
|
|
57
|
+
// The name stays, as do the separator and any opening quote; the value runs to
|
|
58
|
+
// whitespace, a quote, a comma or a semicolon. The lookbehind keeps a longer
|
|
59
|
+
// name that merely ends in one of these (MY_FIREWEAVE_KEY) out of scope.
|
|
60
|
+
const ASSIGNMENT = new RegExp(`(?<![A-Za-z0-9_])(${ASSIGNMENT_NAMES.map(escapeRegExp).join('|')})(\\s*[=:]\\s*)(["']?)[^\\s"',;]+`, 'g');
|
|
61
|
+
// No leading boundary: a key glued to other text is still a key.
|
|
62
|
+
const KEY_VALUE = new RegExp(`(?:${VALUE_PREFIXES.map(escapeRegExp).join('|')})[A-Za-z0-9_-]+`, 'g');
|
|
63
|
+
/**
|
|
64
|
+
* Scrub secrets per the redaction contract, then collapse whitespace runs and
|
|
65
|
+
* trim (so a message stays on one log line).
|
|
66
|
+
*/
|
|
67
|
+
function redactSecrets(text) {
|
|
68
|
+
const out = text
|
|
69
|
+
.replace(BEARER, `Bearer$1${REDACTION_PLACEHOLDER}`)
|
|
70
|
+
.replace(URL_USERINFO, `$1${REDACTION_PLACEHOLDER}@`)
|
|
71
|
+
.replace(ASSIGNMENT, `$1$2$3${REDACTION_PLACEHOLDER}`)
|
|
72
|
+
.replace(KEY_VALUE, REDACTION_PLACEHOLDER);
|
|
73
|
+
return out.replace(/\s+/g, ' ').trim();
|
|
74
|
+
}
|
|
75
|
+
class FireweaveError extends Error {
|
|
76
|
+
kind;
|
|
77
|
+
openFeatureErrorCode;
|
|
78
|
+
retryable;
|
|
79
|
+
errorClass;
|
|
80
|
+
/** Deterministic outward-facing message from the taxonomy. */
|
|
81
|
+
safeMessage;
|
|
82
|
+
metadata;
|
|
83
|
+
constructor(kind, options = {}) {
|
|
84
|
+
const taxonomy = exports.ERROR_TAXONOMY[kind];
|
|
85
|
+
const message = options.message !== undefined ? redactSecrets(options.message) : taxonomy.defaultMessage;
|
|
86
|
+
super(message, options.cause !== undefined ? { cause: options.cause } : undefined);
|
|
87
|
+
this.name = 'FireweaveError';
|
|
88
|
+
this.kind = kind;
|
|
89
|
+
this.openFeatureErrorCode = options.openFeatureErrorCode ?? taxonomy.openFeatureErrorCode;
|
|
90
|
+
this.retryable = taxonomy.retryable;
|
|
91
|
+
this.errorClass = taxonomy.errorClass;
|
|
92
|
+
this.safeMessage = taxonomy.defaultMessage;
|
|
93
|
+
this.metadata = Object.freeze({ ...(options.metadata ?? {}) });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
exports.FireweaveError = FireweaveError;
|
|
97
|
+
function isFireweaveError(err) {
|
|
98
|
+
return err instanceof FireweaveError;
|
|
99
|
+
}
|