@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 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
- return {
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
- return {
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flaghoist/core",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Flag schema, evaluation engine, and storage/auth interfaces for Flaghoist. Zero dependencies.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",