@flaghoist/core 0.1.1 → 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/dist/index.cjs +81 -2
- package/dist/index.d.cts +132 -1
- package/dist/index.d.ts +132 -1
- package/dist/index.js +74 -2
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -20,16 +20,23 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/index.ts
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
|
+
FLAG_AUDIT_ACTIONS: () => FLAG_AUDIT_ACTIONS,
|
|
23
24
|
FLAG_KEY_RULE: () => FLAG_KEY_RULE,
|
|
24
25
|
FORBIDDEN_ATTRIBUTES: () => FORBIDDEN_ATTRIBUTES,
|
|
25
26
|
LIMITS: () => LIMITS,
|
|
27
|
+
MEMBER_WEBHOOK_EVENTS: () => MEMBER_WEBHOOK_EVENTS,
|
|
28
|
+
WEBHOOK_EVENTS: () => WEBHOOK_EVENTS,
|
|
29
|
+
assertRecordAddress: () => assertRecordAddress,
|
|
30
|
+
auditCategory: () => auditCategory,
|
|
26
31
|
clampPercentage: () => clampPercentage,
|
|
27
32
|
compareSemver: () => compareSemver,
|
|
28
33
|
createFlag: () => createFlag,
|
|
29
34
|
evaluate: () => evaluate,
|
|
30
35
|
evaluateAll: () => evaluateAll,
|
|
31
36
|
isInRollout: () => isInRollout,
|
|
37
|
+
isValidCollectionName: () => isValidCollectionName,
|
|
32
38
|
isValidFlagKey: () => isValidFlagKey,
|
|
39
|
+
isValidRecordId: () => isValidRecordId,
|
|
33
40
|
matchCondition: () => matchCondition,
|
|
34
41
|
matchesAllConditions: () => matchesAllConditions,
|
|
35
42
|
parseFlag: () => parseFlag,
|
|
@@ -37,6 +44,33 @@ __export(index_exports, {
|
|
|
37
44
|
});
|
|
38
45
|
module.exports = __toCommonJS(index_exports);
|
|
39
46
|
|
|
47
|
+
// src/types.ts
|
|
48
|
+
var FLAG_AUDIT_ACTIONS = [
|
|
49
|
+
"create",
|
|
50
|
+
"update",
|
|
51
|
+
"delete",
|
|
52
|
+
"archive",
|
|
53
|
+
"restore"
|
|
54
|
+
];
|
|
55
|
+
function auditCategory(action) {
|
|
56
|
+
return FLAG_AUDIT_ACTIONS.includes(action) ? "flags" : "security";
|
|
57
|
+
}
|
|
58
|
+
var WEBHOOK_EVENTS = [
|
|
59
|
+
"flag.created",
|
|
60
|
+
"flag.updated",
|
|
61
|
+
"flag.deleted",
|
|
62
|
+
"flag.archived",
|
|
63
|
+
"flag.restored"
|
|
64
|
+
];
|
|
65
|
+
var MEMBER_WEBHOOK_EVENTS = [
|
|
66
|
+
"member.invited",
|
|
67
|
+
"member.joined",
|
|
68
|
+
"member.role_changed",
|
|
69
|
+
"member.disabled",
|
|
70
|
+
"member.enabled",
|
|
71
|
+
"member.removed"
|
|
72
|
+
];
|
|
73
|
+
|
|
40
74
|
// src/hash.ts
|
|
41
75
|
function clampPercentage(percentage) {
|
|
42
76
|
if (Number.isNaN(percentage)) return 0;
|
|
@@ -61,7 +95,8 @@ var LIMITS = {
|
|
|
61
95
|
maxRules: 100,
|
|
62
96
|
maxConditionsPerRule: 50,
|
|
63
97
|
maxListItems: 1e3,
|
|
64
|
-
maxValueLength: 1024
|
|
98
|
+
maxValueLength: 1024,
|
|
99
|
+
maxDescriptionLength: 2048
|
|
65
100
|
};
|
|
66
101
|
var FLAG_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
67
102
|
var FORBIDDEN_ATTRIBUTES = /* @__PURE__ */ new Set([
|
|
@@ -73,6 +108,31 @@ function isValidFlagKey(key) {
|
|
|
73
108
|
return key.length >= 1 && key.length <= LIMITS.maxKeyLength && FLAG_KEY_PATTERN.test(key);
|
|
74
109
|
}
|
|
75
110
|
var FLAG_KEY_RULE = "must be 1-256 characters of [A-Za-z0-9._-] and start with an alphanumeric";
|
|
111
|
+
var COLLECTION_NAME_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
|
|
112
|
+
function isValidCollectionName(name) {
|
|
113
|
+
return COLLECTION_NAME_PATTERN.test(name);
|
|
114
|
+
}
|
|
115
|
+
var MAX_RECORD_ID_BYTES = 256;
|
|
116
|
+
function isValidRecordId(id) {
|
|
117
|
+
if (id.length < 1 || new TextEncoder().encode(id).length > MAX_RECORD_ID_BYTES) return false;
|
|
118
|
+
for (let i = 0; i < id.length; i++) {
|
|
119
|
+
const code = id.charCodeAt(i);
|
|
120
|
+
if (code < 32 || code === 127) return false;
|
|
121
|
+
}
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
function assertRecordAddress(collection, id) {
|
|
125
|
+
if (!isValidCollectionName(collection)) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`Invalid record collection ${JSON.stringify(collection)}: must match ${COLLECTION_NAME_PATTERN.source}.`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (id !== void 0 && !isValidRecordId(id)) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`Invalid record id: must be 1-${MAX_RECORD_ID_BYTES} bytes with no control characters.`
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
76
136
|
|
|
77
137
|
// src/operators.ts
|
|
78
138
|
function isScalar(v) {
|
|
@@ -150,6 +210,7 @@ async function resolveRule(result, targetingKey, flagKey, ruleIndex) {
|
|
|
150
210
|
return { value, reason: isSplit(percentage) ? "SPLIT" : "TARGETING_MATCH", ruleIndex };
|
|
151
211
|
}
|
|
152
212
|
async function evaluate(flag, context = {}) {
|
|
213
|
+
if (flag.archived) return { value: false, reason: "DISABLED" };
|
|
153
214
|
if (!flag.enabled) return { value: false, reason: "DISABLED" };
|
|
154
215
|
const targetingKey = context.targetingKey ?? "";
|
|
155
216
|
let ruleIndex = 0;
|
|
@@ -249,10 +310,13 @@ function parseFlag(input) {
|
|
|
249
310
|
if (typeof o.enabled !== "boolean") return null;
|
|
250
311
|
const rollout = asRecord(o.rollout);
|
|
251
312
|
const percentage = rollout && typeof rollout.percentage === "number" ? clampPercentage(rollout.percentage) : 0;
|
|
313
|
+
if (typeof o.description === "string" && o.description.length > LIMITS.maxDescriptionLength) {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
252
316
|
const rawRules = Array.isArray(o.rules) ? o.rules : [];
|
|
253
317
|
if (rawRules.length > LIMITS.maxRules) return null;
|
|
254
318
|
const rules = rawRules.map(parseRule).filter((r) => r !== null);
|
|
255
|
-
|
|
319
|
+
const flag = {
|
|
256
320
|
key: o.key,
|
|
257
321
|
enabled: o.enabled,
|
|
258
322
|
rollout: { percentage },
|
|
@@ -260,6 +324,14 @@ function parseFlag(input) {
|
|
|
260
324
|
description: typeof o.description === "string" ? o.description : "",
|
|
261
325
|
metadata: parseMetadata(o.metadata)
|
|
262
326
|
};
|
|
327
|
+
if (o.archived === true) {
|
|
328
|
+
flag.archived = true;
|
|
329
|
+
if (typeof o.archivedAt === "string") flag.archivedAt = o.archivedAt;
|
|
330
|
+
}
|
|
331
|
+
if (typeof o.environment === "string" && o.environment.length > 0) {
|
|
332
|
+
flag.environment = o.environment;
|
|
333
|
+
}
|
|
334
|
+
return flag;
|
|
263
335
|
}
|
|
264
336
|
function createFlag(input) {
|
|
265
337
|
if (!isValidFlagKey(input.key)) {
|
|
@@ -278,16 +350,23 @@ function createFlag(input) {
|
|
|
278
350
|
}
|
|
279
351
|
// Annotate the CommonJS export names for ESM import in node:
|
|
280
352
|
0 && (module.exports = {
|
|
353
|
+
FLAG_AUDIT_ACTIONS,
|
|
281
354
|
FLAG_KEY_RULE,
|
|
282
355
|
FORBIDDEN_ATTRIBUTES,
|
|
283
356
|
LIMITS,
|
|
357
|
+
MEMBER_WEBHOOK_EVENTS,
|
|
358
|
+
WEBHOOK_EVENTS,
|
|
359
|
+
assertRecordAddress,
|
|
360
|
+
auditCategory,
|
|
284
361
|
clampPercentage,
|
|
285
362
|
compareSemver,
|
|
286
363
|
createFlag,
|
|
287
364
|
evaluate,
|
|
288
365
|
evaluateAll,
|
|
289
366
|
isInRollout,
|
|
367
|
+
isValidCollectionName,
|
|
290
368
|
isValidFlagKey,
|
|
369
|
+
isValidRecordId,
|
|
291
370
|
matchCondition,
|
|
292
371
|
matchesAllConditions,
|
|
293
372
|
parseFlag,
|
package/dist/index.d.cts
CHANGED
|
@@ -50,6 +50,14 @@ interface FeatureFlag {
|
|
|
50
50
|
rules?: TargetingRule[];
|
|
51
51
|
description: string;
|
|
52
52
|
metadata: FlagMetadata;
|
|
53
|
+
archived?: boolean;
|
|
54
|
+
archivedAt?: string;
|
|
55
|
+
/**
|
|
56
|
+
* The environment this flag belongs to, when the server has environments configured. Absent
|
|
57
|
+
* means the default environment ("production"), so existing bare-keyed flags need no migration
|
|
58
|
+
* when environments are turned on for the first time.
|
|
59
|
+
*/
|
|
60
|
+
environment?: string;
|
|
53
61
|
}
|
|
54
62
|
/**
|
|
55
63
|
* The OpenFeature-style evaluation context: an optional stable `targetingKey` used for
|
|
@@ -76,6 +84,114 @@ interface StorageAdapter {
|
|
|
76
84
|
put(key: string, flag: FeatureFlag): Promise<void>;
|
|
77
85
|
delete(key: string): Promise<void>;
|
|
78
86
|
list(): Promise<FeatureFlag[]>;
|
|
87
|
+
/** Append an audit entry to persistent storage. Optional; the server falls back to in-memory. */
|
|
88
|
+
appendAudit?(entry: AuditEntry): Promise<void>;
|
|
89
|
+
/** List audit entries with optional filtering and pagination. */
|
|
90
|
+
listAudit?(options?: AuditListOptions): Promise<AuditPage>;
|
|
91
|
+
/** Store a webhook endpoint. Optional; the server falls back to in-memory. */
|
|
92
|
+
putWebhook?(id: string, webhook: WebhookEndpoint): Promise<void>;
|
|
93
|
+
/** Retrieve a webhook by id. */
|
|
94
|
+
getWebhook?(id: string): Promise<WebhookEndpoint | null>;
|
|
95
|
+
/** Remove a webhook. */
|
|
96
|
+
deleteWebhook?(id: string): Promise<void>;
|
|
97
|
+
/** List all webhook endpoints. */
|
|
98
|
+
listWebhooks?(): Promise<WebhookEndpoint[]>;
|
|
99
|
+
/**
|
|
100
|
+
* Generic record store: JSON values grouped into named collections, addressed by id. Optional.
|
|
101
|
+
* Features that need persistent server-side state beyond flags build on this rather than adding
|
|
102
|
+
* a new method group to every adapter. Collection names must pass `isValidCollectionName` and
|
|
103
|
+
* ids `isValidRecordId`; values must be JSON-serializable.
|
|
104
|
+
*/
|
|
105
|
+
getRecord?(collection: string, id: string): Promise<unknown | null>;
|
|
106
|
+
/** Create or replace a record. */
|
|
107
|
+
putRecord?(collection: string, id: string, value: unknown): Promise<void>;
|
|
108
|
+
/** Remove a record. Removing a missing record is a no-op. */
|
|
109
|
+
deleteRecord?(collection: string, id: string): Promise<void>;
|
|
110
|
+
/** List every record in one collection, in no particular order. */
|
|
111
|
+
listRecords?(collection: string): Promise<RecordEntry[]>;
|
|
112
|
+
}
|
|
113
|
+
/** One record returned by `StorageAdapter.listRecords`. */
|
|
114
|
+
interface RecordEntry {
|
|
115
|
+
id: string;
|
|
116
|
+
value: unknown;
|
|
117
|
+
}
|
|
118
|
+
/** A point-in-time snapshot of a flag's evaluable state, recorded in audit entries. */
|
|
119
|
+
interface FlagSnapshot {
|
|
120
|
+
enabled: boolean;
|
|
121
|
+
rollout: {
|
|
122
|
+
percentage: number;
|
|
123
|
+
};
|
|
124
|
+
description: string;
|
|
125
|
+
}
|
|
126
|
+
/** Actions recorded against a flag. */
|
|
127
|
+
type FlagAuditAction = 'create' | 'update' | 'delete' | 'archive' | 'restore';
|
|
128
|
+
/**
|
|
129
|
+
* Actions recorded against accounts, sessions and webhooks. These never carry a `flagKey`; they
|
|
130
|
+
* name what they touched in `target` instead.
|
|
131
|
+
*/
|
|
132
|
+
type SecurityAuditAction = 'login' | 'login.failed' | 'logout' | 'password.changed' | 'session.revoked' | 'password.reset' | 'user.created' | 'user.updated' | 'user.removed' | 'invite.created' | 'invite.accepted' | 'invite.revoked' | 'token.created' | 'token.revoked' | 'token.expired' | 'two_factor.enabled' | 'two_factor.disabled' | 'two_factor.reset' | 'two_factor.recovery_used' | 'webhook.created' | 'webhook.updated' | 'webhook.deleted';
|
|
133
|
+
type AuditAction = FlagAuditAction | SecurityAuditAction;
|
|
134
|
+
declare const FLAG_AUDIT_ACTIONS: readonly FlagAuditAction[];
|
|
135
|
+
/** Which log an entry belongs to: flag changes, or sign-ins and configuration changes. */
|
|
136
|
+
type AuditCategory = 'flags' | 'security';
|
|
137
|
+
declare function auditCategory(action: AuditAction): AuditCategory;
|
|
138
|
+
/** What a security event touched. */
|
|
139
|
+
interface AuditTarget {
|
|
140
|
+
type: 'user' | 'session' | 'invite' | 'token' | 'webhook';
|
|
141
|
+
id: string;
|
|
142
|
+
}
|
|
143
|
+
interface AuditEntry {
|
|
144
|
+
id: string;
|
|
145
|
+
timestamp: string;
|
|
146
|
+
action: AuditAction;
|
|
147
|
+
/** The flag that changed. Always set on flag events; absent on security events. */
|
|
148
|
+
flagKey?: string;
|
|
149
|
+
/** What a security event touched. Absent on flag events. */
|
|
150
|
+
target?: AuditTarget;
|
|
151
|
+
actor: string;
|
|
152
|
+
previous?: FlagSnapshot;
|
|
153
|
+
current?: FlagSnapshot;
|
|
154
|
+
/** A note on the change: the author's description on flag events, context on security events. */
|
|
155
|
+
changeDescription?: string;
|
|
156
|
+
/** The environment the change happened in. Absent means the default environment. */
|
|
157
|
+
environment?: string;
|
|
158
|
+
}
|
|
159
|
+
interface AuditListOptions {
|
|
160
|
+
limit?: number;
|
|
161
|
+
offset?: number;
|
|
162
|
+
/**
|
|
163
|
+
* Which log to read. Adapters that persist audit entries should honour this; `auditCategory()`
|
|
164
|
+
* maps an action onto its category. Absent means both.
|
|
165
|
+
*/
|
|
166
|
+
category?: AuditCategory;
|
|
167
|
+
flagKey?: string;
|
|
168
|
+
action?: AuditAction;
|
|
169
|
+
/** Restrict to entries recorded in this environment. Absent means the default environment. */
|
|
170
|
+
environment?: string;
|
|
171
|
+
}
|
|
172
|
+
interface AuditPage {
|
|
173
|
+
entries: AuditEntry[];
|
|
174
|
+
total: number;
|
|
175
|
+
}
|
|
176
|
+
type FlagWebhookEvent = 'flag.created' | 'flag.updated' | 'flag.deleted' | 'flag.archived' | 'flag.restored';
|
|
177
|
+
/**
|
|
178
|
+
* Team changes, for servers with user accounts. Opt-in: a webhook gets them only when it lists
|
|
179
|
+
* them, never through the default event list, so a receiver built for flag events never sees a
|
|
180
|
+
* payload of another shape.
|
|
181
|
+
*/
|
|
182
|
+
type MemberWebhookEvent = 'member.invited' | 'member.joined' | 'member.role_changed' | 'member.disabled' | 'member.enabled' | 'member.removed';
|
|
183
|
+
type WebhookEvent = FlagWebhookEvent | MemberWebhookEvent;
|
|
184
|
+
/** The flag events: what a webhook subscribes to when it names no events. */
|
|
185
|
+
declare const WEBHOOK_EVENTS: FlagWebhookEvent[];
|
|
186
|
+
declare const MEMBER_WEBHOOK_EVENTS: MemberWebhookEvent[];
|
|
187
|
+
interface WebhookEndpoint {
|
|
188
|
+
id: string;
|
|
189
|
+
url: string;
|
|
190
|
+
secret: string;
|
|
191
|
+
events: WebhookEvent[];
|
|
192
|
+
enabled: boolean;
|
|
193
|
+
createdAt: string;
|
|
194
|
+
updatedAt: string;
|
|
79
195
|
}
|
|
80
196
|
/** The authenticated caller, extracted from a validated admin token. */
|
|
81
197
|
interface AuthContext {
|
|
@@ -117,6 +233,7 @@ declare const LIMITS: {
|
|
|
117
233
|
readonly maxConditionsPerRule: 50;
|
|
118
234
|
readonly maxListItems: 1000;
|
|
119
235
|
readonly maxValueLength: 1024;
|
|
236
|
+
readonly maxDescriptionLength: 2048;
|
|
120
237
|
};
|
|
121
238
|
/**
|
|
122
239
|
* Attribute names that resolve onto the prototype chain rather than to real data. They are
|
|
@@ -133,6 +250,20 @@ declare const FORBIDDEN_ATTRIBUTES: ReadonlySet<string>;
|
|
|
133
250
|
declare function isValidFlagKey(key: string): boolean;
|
|
134
251
|
/** Human-readable description of the flag key rule, for error messages. */
|
|
135
252
|
declare const FLAG_KEY_RULE = "must be 1-256 characters of [A-Za-z0-9._-] and start with an alphanumeric";
|
|
253
|
+
/**
|
|
254
|
+
* A record-store collection name. Collections are chosen by server code, never by callers, but
|
|
255
|
+
* adapters embed them in storage keys and table rows, so the charset stays narrow: lowercase
|
|
256
|
+
* alphanumerics and hyphens, no separators that could collide with an adapter's key layout.
|
|
257
|
+
*/
|
|
258
|
+
declare function isValidCollectionName(name: string): boolean;
|
|
259
|
+
/**
|
|
260
|
+
* A record id. Ids can carry user-supplied text (an email address used as a lookup key, say), so
|
|
261
|
+
* they allow any printable characters but must be non-empty, bounded, and free of control
|
|
262
|
+
* characters.
|
|
263
|
+
*/
|
|
264
|
+
declare function isValidRecordId(id: string): boolean;
|
|
265
|
+
/** Throw if a collection name or record id is not safe to hand to a storage adapter. */
|
|
266
|
+
declare function assertRecordAddress(collection: string, id?: string): void;
|
|
136
267
|
|
|
137
268
|
/**
|
|
138
269
|
* Compare two semver strings. Returns -1, 0, or 1. A version with no prerelease outranks
|
|
@@ -185,4 +316,4 @@ interface CreateFlagInput {
|
|
|
185
316
|
*/
|
|
186
317
|
declare function createFlag(input: CreateFlagInput): FeatureFlag;
|
|
187
318
|
|
|
188
|
-
export { type AttributeValue, type AuthContext, type AuthVerifier, type Condition, type ConditionValue, type CreateFlagInput, type EvaluationContext, type EvaluationReason, type EvaluationResult, FLAG_KEY_RULE, FORBIDDEN_ATTRIBUTES, type FeatureFlag, type FlagMetadata, LIMITS, type Operator, type RuleResult, type StorageAdapter, type TargetingRule, clampPercentage, compareSemver, createFlag, evaluate, evaluateAll, isInRollout, isValidFlagKey, matchCondition, matchesAllConditions, parseFlag, stickyBucket };
|
|
319
|
+
export { type AttributeValue, type AuditAction, type AuditCategory, type AuditEntry, type AuditListOptions, type AuditPage, type AuditTarget, type AuthContext, type AuthVerifier, type Condition, type ConditionValue, type CreateFlagInput, type EvaluationContext, type EvaluationReason, type EvaluationResult, FLAG_AUDIT_ACTIONS, FLAG_KEY_RULE, FORBIDDEN_ATTRIBUTES, type FeatureFlag, type FlagAuditAction, type FlagMetadata, type FlagSnapshot, type FlagWebhookEvent, LIMITS, MEMBER_WEBHOOK_EVENTS, type MemberWebhookEvent, type Operator, type RecordEntry, type RuleResult, type SecurityAuditAction, type StorageAdapter, type TargetingRule, WEBHOOK_EVENTS, type WebhookEndpoint, type WebhookEvent, assertRecordAddress, auditCategory, clampPercentage, compareSemver, createFlag, evaluate, evaluateAll, isInRollout, isValidCollectionName, isValidFlagKey, isValidRecordId, matchCondition, matchesAllConditions, parseFlag, stickyBucket };
|
package/dist/index.d.ts
CHANGED
|
@@ -50,6 +50,14 @@ interface FeatureFlag {
|
|
|
50
50
|
rules?: TargetingRule[];
|
|
51
51
|
description: string;
|
|
52
52
|
metadata: FlagMetadata;
|
|
53
|
+
archived?: boolean;
|
|
54
|
+
archivedAt?: string;
|
|
55
|
+
/**
|
|
56
|
+
* The environment this flag belongs to, when the server has environments configured. Absent
|
|
57
|
+
* means the default environment ("production"), so existing bare-keyed flags need no migration
|
|
58
|
+
* when environments are turned on for the first time.
|
|
59
|
+
*/
|
|
60
|
+
environment?: string;
|
|
53
61
|
}
|
|
54
62
|
/**
|
|
55
63
|
* The OpenFeature-style evaluation context: an optional stable `targetingKey` used for
|
|
@@ -76,6 +84,114 @@ interface StorageAdapter {
|
|
|
76
84
|
put(key: string, flag: FeatureFlag): Promise<void>;
|
|
77
85
|
delete(key: string): Promise<void>;
|
|
78
86
|
list(): Promise<FeatureFlag[]>;
|
|
87
|
+
/** Append an audit entry to persistent storage. Optional; the server falls back to in-memory. */
|
|
88
|
+
appendAudit?(entry: AuditEntry): Promise<void>;
|
|
89
|
+
/** List audit entries with optional filtering and pagination. */
|
|
90
|
+
listAudit?(options?: AuditListOptions): Promise<AuditPage>;
|
|
91
|
+
/** Store a webhook endpoint. Optional; the server falls back to in-memory. */
|
|
92
|
+
putWebhook?(id: string, webhook: WebhookEndpoint): Promise<void>;
|
|
93
|
+
/** Retrieve a webhook by id. */
|
|
94
|
+
getWebhook?(id: string): Promise<WebhookEndpoint | null>;
|
|
95
|
+
/** Remove a webhook. */
|
|
96
|
+
deleteWebhook?(id: string): Promise<void>;
|
|
97
|
+
/** List all webhook endpoints. */
|
|
98
|
+
listWebhooks?(): Promise<WebhookEndpoint[]>;
|
|
99
|
+
/**
|
|
100
|
+
* Generic record store: JSON values grouped into named collections, addressed by id. Optional.
|
|
101
|
+
* Features that need persistent server-side state beyond flags build on this rather than adding
|
|
102
|
+
* a new method group to every adapter. Collection names must pass `isValidCollectionName` and
|
|
103
|
+
* ids `isValidRecordId`; values must be JSON-serializable.
|
|
104
|
+
*/
|
|
105
|
+
getRecord?(collection: string, id: string): Promise<unknown | null>;
|
|
106
|
+
/** Create or replace a record. */
|
|
107
|
+
putRecord?(collection: string, id: string, value: unknown): Promise<void>;
|
|
108
|
+
/** Remove a record. Removing a missing record is a no-op. */
|
|
109
|
+
deleteRecord?(collection: string, id: string): Promise<void>;
|
|
110
|
+
/** List every record in one collection, in no particular order. */
|
|
111
|
+
listRecords?(collection: string): Promise<RecordEntry[]>;
|
|
112
|
+
}
|
|
113
|
+
/** One record returned by `StorageAdapter.listRecords`. */
|
|
114
|
+
interface RecordEntry {
|
|
115
|
+
id: string;
|
|
116
|
+
value: unknown;
|
|
117
|
+
}
|
|
118
|
+
/** A point-in-time snapshot of a flag's evaluable state, recorded in audit entries. */
|
|
119
|
+
interface FlagSnapshot {
|
|
120
|
+
enabled: boolean;
|
|
121
|
+
rollout: {
|
|
122
|
+
percentage: number;
|
|
123
|
+
};
|
|
124
|
+
description: string;
|
|
125
|
+
}
|
|
126
|
+
/** Actions recorded against a flag. */
|
|
127
|
+
type FlagAuditAction = 'create' | 'update' | 'delete' | 'archive' | 'restore';
|
|
128
|
+
/**
|
|
129
|
+
* Actions recorded against accounts, sessions and webhooks. These never carry a `flagKey`; they
|
|
130
|
+
* name what they touched in `target` instead.
|
|
131
|
+
*/
|
|
132
|
+
type SecurityAuditAction = 'login' | 'login.failed' | 'logout' | 'password.changed' | 'session.revoked' | 'password.reset' | 'user.created' | 'user.updated' | 'user.removed' | 'invite.created' | 'invite.accepted' | 'invite.revoked' | 'token.created' | 'token.revoked' | 'token.expired' | 'two_factor.enabled' | 'two_factor.disabled' | 'two_factor.reset' | 'two_factor.recovery_used' | 'webhook.created' | 'webhook.updated' | 'webhook.deleted';
|
|
133
|
+
type AuditAction = FlagAuditAction | SecurityAuditAction;
|
|
134
|
+
declare const FLAG_AUDIT_ACTIONS: readonly FlagAuditAction[];
|
|
135
|
+
/** Which log an entry belongs to: flag changes, or sign-ins and configuration changes. */
|
|
136
|
+
type AuditCategory = 'flags' | 'security';
|
|
137
|
+
declare function auditCategory(action: AuditAction): AuditCategory;
|
|
138
|
+
/** What a security event touched. */
|
|
139
|
+
interface AuditTarget {
|
|
140
|
+
type: 'user' | 'session' | 'invite' | 'token' | 'webhook';
|
|
141
|
+
id: string;
|
|
142
|
+
}
|
|
143
|
+
interface AuditEntry {
|
|
144
|
+
id: string;
|
|
145
|
+
timestamp: string;
|
|
146
|
+
action: AuditAction;
|
|
147
|
+
/** The flag that changed. Always set on flag events; absent on security events. */
|
|
148
|
+
flagKey?: string;
|
|
149
|
+
/** What a security event touched. Absent on flag events. */
|
|
150
|
+
target?: AuditTarget;
|
|
151
|
+
actor: string;
|
|
152
|
+
previous?: FlagSnapshot;
|
|
153
|
+
current?: FlagSnapshot;
|
|
154
|
+
/** A note on the change: the author's description on flag events, context on security events. */
|
|
155
|
+
changeDescription?: string;
|
|
156
|
+
/** The environment the change happened in. Absent means the default environment. */
|
|
157
|
+
environment?: string;
|
|
158
|
+
}
|
|
159
|
+
interface AuditListOptions {
|
|
160
|
+
limit?: number;
|
|
161
|
+
offset?: number;
|
|
162
|
+
/**
|
|
163
|
+
* Which log to read. Adapters that persist audit entries should honour this; `auditCategory()`
|
|
164
|
+
* maps an action onto its category. Absent means both.
|
|
165
|
+
*/
|
|
166
|
+
category?: AuditCategory;
|
|
167
|
+
flagKey?: string;
|
|
168
|
+
action?: AuditAction;
|
|
169
|
+
/** Restrict to entries recorded in this environment. Absent means the default environment. */
|
|
170
|
+
environment?: string;
|
|
171
|
+
}
|
|
172
|
+
interface AuditPage {
|
|
173
|
+
entries: AuditEntry[];
|
|
174
|
+
total: number;
|
|
175
|
+
}
|
|
176
|
+
type FlagWebhookEvent = 'flag.created' | 'flag.updated' | 'flag.deleted' | 'flag.archived' | 'flag.restored';
|
|
177
|
+
/**
|
|
178
|
+
* Team changes, for servers with user accounts. Opt-in: a webhook gets them only when it lists
|
|
179
|
+
* them, never through the default event list, so a receiver built for flag events never sees a
|
|
180
|
+
* payload of another shape.
|
|
181
|
+
*/
|
|
182
|
+
type MemberWebhookEvent = 'member.invited' | 'member.joined' | 'member.role_changed' | 'member.disabled' | 'member.enabled' | 'member.removed';
|
|
183
|
+
type WebhookEvent = FlagWebhookEvent | MemberWebhookEvent;
|
|
184
|
+
/** The flag events: what a webhook subscribes to when it names no events. */
|
|
185
|
+
declare const WEBHOOK_EVENTS: FlagWebhookEvent[];
|
|
186
|
+
declare const MEMBER_WEBHOOK_EVENTS: MemberWebhookEvent[];
|
|
187
|
+
interface WebhookEndpoint {
|
|
188
|
+
id: string;
|
|
189
|
+
url: string;
|
|
190
|
+
secret: string;
|
|
191
|
+
events: WebhookEvent[];
|
|
192
|
+
enabled: boolean;
|
|
193
|
+
createdAt: string;
|
|
194
|
+
updatedAt: string;
|
|
79
195
|
}
|
|
80
196
|
/** The authenticated caller, extracted from a validated admin token. */
|
|
81
197
|
interface AuthContext {
|
|
@@ -117,6 +233,7 @@ declare const LIMITS: {
|
|
|
117
233
|
readonly maxConditionsPerRule: 50;
|
|
118
234
|
readonly maxListItems: 1000;
|
|
119
235
|
readonly maxValueLength: 1024;
|
|
236
|
+
readonly maxDescriptionLength: 2048;
|
|
120
237
|
};
|
|
121
238
|
/**
|
|
122
239
|
* Attribute names that resolve onto the prototype chain rather than to real data. They are
|
|
@@ -133,6 +250,20 @@ declare const FORBIDDEN_ATTRIBUTES: ReadonlySet<string>;
|
|
|
133
250
|
declare function isValidFlagKey(key: string): boolean;
|
|
134
251
|
/** Human-readable description of the flag key rule, for error messages. */
|
|
135
252
|
declare const FLAG_KEY_RULE = "must be 1-256 characters of [A-Za-z0-9._-] and start with an alphanumeric";
|
|
253
|
+
/**
|
|
254
|
+
* A record-store collection name. Collections are chosen by server code, never by callers, but
|
|
255
|
+
* adapters embed them in storage keys and table rows, so the charset stays narrow: lowercase
|
|
256
|
+
* alphanumerics and hyphens, no separators that could collide with an adapter's key layout.
|
|
257
|
+
*/
|
|
258
|
+
declare function isValidCollectionName(name: string): boolean;
|
|
259
|
+
/**
|
|
260
|
+
* A record id. Ids can carry user-supplied text (an email address used as a lookup key, say), so
|
|
261
|
+
* they allow any printable characters but must be non-empty, bounded, and free of control
|
|
262
|
+
* characters.
|
|
263
|
+
*/
|
|
264
|
+
declare function isValidRecordId(id: string): boolean;
|
|
265
|
+
/** Throw if a collection name or record id is not safe to hand to a storage adapter. */
|
|
266
|
+
declare function assertRecordAddress(collection: string, id?: string): void;
|
|
136
267
|
|
|
137
268
|
/**
|
|
138
269
|
* Compare two semver strings. Returns -1, 0, or 1. A version with no prerelease outranks
|
|
@@ -185,4 +316,4 @@ interface CreateFlagInput {
|
|
|
185
316
|
*/
|
|
186
317
|
declare function createFlag(input: CreateFlagInput): FeatureFlag;
|
|
187
318
|
|
|
188
|
-
export { type AttributeValue, type AuthContext, type AuthVerifier, type Condition, type ConditionValue, type CreateFlagInput, type EvaluationContext, type EvaluationReason, type EvaluationResult, FLAG_KEY_RULE, FORBIDDEN_ATTRIBUTES, type FeatureFlag, type FlagMetadata, LIMITS, type Operator, type RuleResult, type StorageAdapter, type TargetingRule, clampPercentage, compareSemver, createFlag, evaluate, evaluateAll, isInRollout, isValidFlagKey, matchCondition, matchesAllConditions, parseFlag, stickyBucket };
|
|
319
|
+
export { type AttributeValue, type AuditAction, type AuditCategory, type AuditEntry, type AuditListOptions, type AuditPage, type AuditTarget, type AuthContext, type AuthVerifier, type Condition, type ConditionValue, type CreateFlagInput, type EvaluationContext, type EvaluationReason, type EvaluationResult, FLAG_AUDIT_ACTIONS, FLAG_KEY_RULE, FORBIDDEN_ATTRIBUTES, type FeatureFlag, type FlagAuditAction, type FlagMetadata, type FlagSnapshot, type FlagWebhookEvent, LIMITS, MEMBER_WEBHOOK_EVENTS, type MemberWebhookEvent, type Operator, type RecordEntry, type RuleResult, type SecurityAuditAction, type StorageAdapter, type TargetingRule, WEBHOOK_EVENTS, type WebhookEndpoint, type WebhookEvent, assertRecordAddress, auditCategory, clampPercentage, compareSemver, createFlag, evaluate, evaluateAll, isInRollout, isValidCollectionName, isValidFlagKey, isValidRecordId, matchCondition, matchesAllConditions, parseFlag, stickyBucket };
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,30 @@
|
|
|
1
|
+
// src/types.ts
|
|
2
|
+
var FLAG_AUDIT_ACTIONS = [
|
|
3
|
+
"create",
|
|
4
|
+
"update",
|
|
5
|
+
"delete",
|
|
6
|
+
"archive",
|
|
7
|
+
"restore"
|
|
8
|
+
];
|
|
9
|
+
function auditCategory(action) {
|
|
10
|
+
return FLAG_AUDIT_ACTIONS.includes(action) ? "flags" : "security";
|
|
11
|
+
}
|
|
12
|
+
var WEBHOOK_EVENTS = [
|
|
13
|
+
"flag.created",
|
|
14
|
+
"flag.updated",
|
|
15
|
+
"flag.deleted",
|
|
16
|
+
"flag.archived",
|
|
17
|
+
"flag.restored"
|
|
18
|
+
];
|
|
19
|
+
var MEMBER_WEBHOOK_EVENTS = [
|
|
20
|
+
"member.invited",
|
|
21
|
+
"member.joined",
|
|
22
|
+
"member.role_changed",
|
|
23
|
+
"member.disabled",
|
|
24
|
+
"member.enabled",
|
|
25
|
+
"member.removed"
|
|
26
|
+
];
|
|
27
|
+
|
|
1
28
|
// src/hash.ts
|
|
2
29
|
function clampPercentage(percentage) {
|
|
3
30
|
if (Number.isNaN(percentage)) return 0;
|
|
@@ -22,7 +49,8 @@ var LIMITS = {
|
|
|
22
49
|
maxRules: 100,
|
|
23
50
|
maxConditionsPerRule: 50,
|
|
24
51
|
maxListItems: 1e3,
|
|
25
|
-
maxValueLength: 1024
|
|
52
|
+
maxValueLength: 1024,
|
|
53
|
+
maxDescriptionLength: 2048
|
|
26
54
|
};
|
|
27
55
|
var FLAG_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
28
56
|
var FORBIDDEN_ATTRIBUTES = /* @__PURE__ */ new Set([
|
|
@@ -34,6 +62,31 @@ function isValidFlagKey(key) {
|
|
|
34
62
|
return key.length >= 1 && key.length <= LIMITS.maxKeyLength && FLAG_KEY_PATTERN.test(key);
|
|
35
63
|
}
|
|
36
64
|
var FLAG_KEY_RULE = "must be 1-256 characters of [A-Za-z0-9._-] and start with an alphanumeric";
|
|
65
|
+
var COLLECTION_NAME_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
|
|
66
|
+
function isValidCollectionName(name) {
|
|
67
|
+
return COLLECTION_NAME_PATTERN.test(name);
|
|
68
|
+
}
|
|
69
|
+
var MAX_RECORD_ID_BYTES = 256;
|
|
70
|
+
function isValidRecordId(id) {
|
|
71
|
+
if (id.length < 1 || new TextEncoder().encode(id).length > MAX_RECORD_ID_BYTES) return false;
|
|
72
|
+
for (let i = 0; i < id.length; i++) {
|
|
73
|
+
const code = id.charCodeAt(i);
|
|
74
|
+
if (code < 32 || code === 127) return false;
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
function assertRecordAddress(collection, id) {
|
|
79
|
+
if (!isValidCollectionName(collection)) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`Invalid record collection ${JSON.stringify(collection)}: must match ${COLLECTION_NAME_PATTERN.source}.`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
if (id !== void 0 && !isValidRecordId(id)) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`Invalid record id: must be 1-${MAX_RECORD_ID_BYTES} bytes with no control characters.`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
37
90
|
|
|
38
91
|
// src/operators.ts
|
|
39
92
|
function isScalar(v) {
|
|
@@ -111,6 +164,7 @@ async function resolveRule(result, targetingKey, flagKey, ruleIndex) {
|
|
|
111
164
|
return { value, reason: isSplit(percentage) ? "SPLIT" : "TARGETING_MATCH", ruleIndex };
|
|
112
165
|
}
|
|
113
166
|
async function evaluate(flag, context = {}) {
|
|
167
|
+
if (flag.archived) return { value: false, reason: "DISABLED" };
|
|
114
168
|
if (!flag.enabled) return { value: false, reason: "DISABLED" };
|
|
115
169
|
const targetingKey = context.targetingKey ?? "";
|
|
116
170
|
let ruleIndex = 0;
|
|
@@ -210,10 +264,13 @@ function parseFlag(input) {
|
|
|
210
264
|
if (typeof o.enabled !== "boolean") return null;
|
|
211
265
|
const rollout = asRecord(o.rollout);
|
|
212
266
|
const percentage = rollout && typeof rollout.percentage === "number" ? clampPercentage(rollout.percentage) : 0;
|
|
267
|
+
if (typeof o.description === "string" && o.description.length > LIMITS.maxDescriptionLength) {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
213
270
|
const rawRules = Array.isArray(o.rules) ? o.rules : [];
|
|
214
271
|
if (rawRules.length > LIMITS.maxRules) return null;
|
|
215
272
|
const rules = rawRules.map(parseRule).filter((r) => r !== null);
|
|
216
|
-
|
|
273
|
+
const flag = {
|
|
217
274
|
key: o.key,
|
|
218
275
|
enabled: o.enabled,
|
|
219
276
|
rollout: { percentage },
|
|
@@ -221,6 +278,14 @@ function parseFlag(input) {
|
|
|
221
278
|
description: typeof o.description === "string" ? o.description : "",
|
|
222
279
|
metadata: parseMetadata(o.metadata)
|
|
223
280
|
};
|
|
281
|
+
if (o.archived === true) {
|
|
282
|
+
flag.archived = true;
|
|
283
|
+
if (typeof o.archivedAt === "string") flag.archivedAt = o.archivedAt;
|
|
284
|
+
}
|
|
285
|
+
if (typeof o.environment === "string" && o.environment.length > 0) {
|
|
286
|
+
flag.environment = o.environment;
|
|
287
|
+
}
|
|
288
|
+
return flag;
|
|
224
289
|
}
|
|
225
290
|
function createFlag(input) {
|
|
226
291
|
if (!isValidFlagKey(input.key)) {
|
|
@@ -238,16 +303,23 @@ function createFlag(input) {
|
|
|
238
303
|
};
|
|
239
304
|
}
|
|
240
305
|
export {
|
|
306
|
+
FLAG_AUDIT_ACTIONS,
|
|
241
307
|
FLAG_KEY_RULE,
|
|
242
308
|
FORBIDDEN_ATTRIBUTES,
|
|
243
309
|
LIMITS,
|
|
310
|
+
MEMBER_WEBHOOK_EVENTS,
|
|
311
|
+
WEBHOOK_EVENTS,
|
|
312
|
+
assertRecordAddress,
|
|
313
|
+
auditCategory,
|
|
244
314
|
clampPercentage,
|
|
245
315
|
compareSemver,
|
|
246
316
|
createFlag,
|
|
247
317
|
evaluate,
|
|
248
318
|
evaluateAll,
|
|
249
319
|
isInRollout,
|
|
320
|
+
isValidCollectionName,
|
|
250
321
|
isValidFlagKey,
|
|
322
|
+
isValidRecordId,
|
|
251
323
|
matchCondition,
|
|
252
324
|
matchesAllConditions,
|
|
253
325
|
parseFlag,
|