@flaghoist/core 0.1.2 → 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;
@@ -74,6 +108,31 @@ function isValidFlagKey(key) {
74
108
  return key.length >= 1 && key.length <= LIMITS.maxKeyLength && FLAG_KEY_PATTERN.test(key);
75
109
  }
76
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
+ }
77
136
 
78
137
  // src/operators.ts
79
138
  function isScalar(v) {
@@ -151,6 +210,7 @@ async function resolveRule(result, targetingKey, flagKey, ruleIndex) {
151
210
  return { value, reason: isSplit(percentage) ? "SPLIT" : "TARGETING_MATCH", ruleIndex };
152
211
  }
153
212
  async function evaluate(flag, context = {}) {
213
+ if (flag.archived) return { value: false, reason: "DISABLED" };
154
214
  if (!flag.enabled) return { value: false, reason: "DISABLED" };
155
215
  const targetingKey = context.targetingKey ?? "";
156
216
  let ruleIndex = 0;
@@ -256,7 +316,7 @@ function parseFlag(input) {
256
316
  const rawRules = Array.isArray(o.rules) ? o.rules : [];
257
317
  if (rawRules.length > LIMITS.maxRules) return null;
258
318
  const rules = rawRules.map(parseRule).filter((r) => r !== null);
259
- return {
319
+ const flag = {
260
320
  key: o.key,
261
321
  enabled: o.enabled,
262
322
  rollout: { percentage },
@@ -264,6 +324,14 @@ function parseFlag(input) {
264
324
  description: typeof o.description === "string" ? o.description : "",
265
325
  metadata: parseMetadata(o.metadata)
266
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;
267
335
  }
268
336
  function createFlag(input) {
269
337
  if (!isValidFlagKey(input.key)) {
@@ -282,16 +350,23 @@ function createFlag(input) {
282
350
  }
283
351
  // Annotate the CommonJS export names for ESM import in node:
284
352
  0 && (module.exports = {
353
+ FLAG_AUDIT_ACTIONS,
285
354
  FLAG_KEY_RULE,
286
355
  FORBIDDEN_ATTRIBUTES,
287
356
  LIMITS,
357
+ MEMBER_WEBHOOK_EVENTS,
358
+ WEBHOOK_EVENTS,
359
+ assertRecordAddress,
360
+ auditCategory,
288
361
  clampPercentage,
289
362
  compareSemver,
290
363
  createFlag,
291
364
  evaluate,
292
365
  evaluateAll,
293
366
  isInRollout,
367
+ isValidCollectionName,
294
368
  isValidFlagKey,
369
+ isValidRecordId,
295
370
  matchCondition,
296
371
  matchesAllConditions,
297
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 {
@@ -134,6 +250,20 @@ declare const FORBIDDEN_ATTRIBUTES: ReadonlySet<string>;
134
250
  declare function isValidFlagKey(key: string): boolean;
135
251
  /** Human-readable description of the flag key rule, for error messages. */
136
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;
137
267
 
138
268
  /**
139
269
  * Compare two semver strings. Returns -1, 0, or 1. A version with no prerelease outranks
@@ -186,4 +316,4 @@ interface CreateFlagInput {
186
316
  */
187
317
  declare function createFlag(input: CreateFlagInput): FeatureFlag;
188
318
 
189
- 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 {
@@ -134,6 +250,20 @@ declare const FORBIDDEN_ATTRIBUTES: ReadonlySet<string>;
134
250
  declare function isValidFlagKey(key: string): boolean;
135
251
  /** Human-readable description of the flag key rule, for error messages. */
136
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;
137
267
 
138
268
  /**
139
269
  * Compare two semver strings. Returns -1, 0, or 1. A version with no prerelease outranks
@@ -186,4 +316,4 @@ interface CreateFlagInput {
186
316
  */
187
317
  declare function createFlag(input: CreateFlagInput): FeatureFlag;
188
318
 
189
- 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;
@@ -35,6 +62,31 @@ function isValidFlagKey(key) {
35
62
  return key.length >= 1 && key.length <= LIMITS.maxKeyLength && FLAG_KEY_PATTERN.test(key);
36
63
  }
37
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
+ }
38
90
 
39
91
  // src/operators.ts
40
92
  function isScalar(v) {
@@ -112,6 +164,7 @@ async function resolveRule(result, targetingKey, flagKey, ruleIndex) {
112
164
  return { value, reason: isSplit(percentage) ? "SPLIT" : "TARGETING_MATCH", ruleIndex };
113
165
  }
114
166
  async function evaluate(flag, context = {}) {
167
+ if (flag.archived) return { value: false, reason: "DISABLED" };
115
168
  if (!flag.enabled) return { value: false, reason: "DISABLED" };
116
169
  const targetingKey = context.targetingKey ?? "";
117
170
  let ruleIndex = 0;
@@ -217,7 +270,7 @@ function parseFlag(input) {
217
270
  const rawRules = Array.isArray(o.rules) ? o.rules : [];
218
271
  if (rawRules.length > LIMITS.maxRules) return null;
219
272
  const rules = rawRules.map(parseRule).filter((r) => r !== null);
220
- return {
273
+ const flag = {
221
274
  key: o.key,
222
275
  enabled: o.enabled,
223
276
  rollout: { percentage },
@@ -225,6 +278,14 @@ function parseFlag(input) {
225
278
  description: typeof o.description === "string" ? o.description : "",
226
279
  metadata: parseMetadata(o.metadata)
227
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;
228
289
  }
229
290
  function createFlag(input) {
230
291
  if (!isValidFlagKey(input.key)) {
@@ -242,16 +303,23 @@ function createFlag(input) {
242
303
  };
243
304
  }
244
305
  export {
306
+ FLAG_AUDIT_ACTIONS,
245
307
  FLAG_KEY_RULE,
246
308
  FORBIDDEN_ATTRIBUTES,
247
309
  LIMITS,
310
+ MEMBER_WEBHOOK_EVENTS,
311
+ WEBHOOK_EVENTS,
312
+ assertRecordAddress,
313
+ auditCategory,
248
314
  clampPercentage,
249
315
  compareSemver,
250
316
  createFlag,
251
317
  evaluate,
252
318
  evaluateAll,
253
319
  isInRollout,
320
+ isValidCollectionName,
254
321
  isValidFlagKey,
322
+ isValidRecordId,
255
323
  matchCondition,
256
324
  matchesAllConditions,
257
325
  parseFlag,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flaghoist/core",
3
- "version": "0.1.2",
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",