@flagpool/sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,258 @@
1
+ # Flagpool SDK - TypeScript / JavaScript
2
+
3
+ Official TypeScript/JavaScript SDK for Flagpool feature flags with local evaluation, deterministic rollouts, and encrypted target lists.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@flagpool/sdk.svg)](https://www.npmjs.com/package/@flagpool/sdk)
6
+ [![npm downloads](https://img.shields.io/npm/dm/@flagpool/sdk.svg)](https://www.npmjs.com/package/@flagpool/sdk)
7
+
8
+ ## Installation
9
+
10
+ ```bash
11
+ npm install @flagpool/sdk
12
+ ```
13
+
14
+ ## Quick Start
15
+
16
+ ```typescript
17
+ import { FlagpoolClient } from '@flagpool/sdk';
18
+
19
+ const client = new FlagpoolClient({
20
+ apiKey: 'fp_production_xxx',
21
+ environment: 'production',
22
+ context: {
23
+ userId: 'user-123',
24
+ email: 'alice@example.com',
25
+ plan: 'pro',
26
+ country: 'US'
27
+ }
28
+ });
29
+
30
+ await client.init();
31
+
32
+ // Boolean flag
33
+ if (client.isEnabled('new-dashboard')) {
34
+ showNewDashboard();
35
+ }
36
+
37
+ // String flag (A/B test)
38
+ const buttonColor = client.getValue('cta-button-color'); // 'blue' | 'green' | 'orange'
39
+
40
+ // Number flag
41
+ const maxUpload = client.getValue('max-upload-size-mb'); // 10 | 100 | 1000
42
+
43
+ // JSON flag
44
+ const config = client.getValue('checkout-config');
45
+ // { showCoupons: true, maxItems: 50, paymentMethods: [...] }
46
+
47
+ // Clean up when done
48
+ client.close();
49
+ ```
50
+
51
+ ## Features
52
+
53
+ - ✅ **Local evaluation** - No server roundtrip per flag check
54
+ - ✅ **Deterministic rollouts** - Same user always gets same variation
55
+ - ✅ **Multiple flag types** - Boolean, string, number, JSON
56
+ - ✅ **8 targeting operators** - eq, neq, in, nin, contains, startsWith, inTargetList, notInTargetList
57
+ - ✅ **Priority-based rules** - First matching rule wins
58
+ - ✅ **Encrypted target lists** - Optional client-side decryption
59
+ - ✅ **Real-time updates** - Polling for flag changes
60
+ - ✅ **Zero dependencies** - Core SDK has no external dependencies
61
+
62
+ ## API Reference
63
+
64
+ ### `FlagpoolClient`
65
+
66
+ #### Constructor Options
67
+
68
+ ```typescript
69
+ interface FlagpoolClientOptions {
70
+ apiKey: string; // Environment-specific API key
71
+ environment: string; // Environment name (required)
72
+ context?: Record<string, any>; // User context for targeting
73
+ pollingInterval?: number; // Polling interval in ms (default: 30000)
74
+ streaming?: boolean; // Enable real-time updates (default: false)
75
+ urlOverride?: string; // Override API endpoint
76
+ decryptionKey?: string; // Key for client-side target list decryption
77
+ clientSideTargetLists?: boolean; // Enable client-side target list evaluation
78
+ }
79
+ ```
80
+
81
+ #### Methods
82
+
83
+ | Method | Description |
84
+ |--------|-------------|
85
+ | `init()` | Initialize client and fetch flags |
86
+ | `isEnabled(key)` | Check if boolean flag is enabled |
87
+ | `getValue(key)` | Get flag value (any type) |
88
+ | `getVariation(key)` | Alias for getValue |
89
+ | `getAllFlags()` | Get all evaluated flag values |
90
+ | `updateContext(context)` | Update user context and re-evaluate |
91
+ | `onChange(callback)` | Subscribe to flag changes |
92
+ | `close()` | Clean up timers and connections |
93
+
94
+ ## Targeting Operators
95
+
96
+ | Operator | Description | Example |
97
+ |----------|-------------|---------|
98
+ | `eq` | Equals | `plan == "enterprise"` |
99
+ | `neq` | Not equals | `plan != "free"` |
100
+ | `in` | In list | `country in ["US", "CA"]` |
101
+ | `nin` | Not in list | `country not in ["CN", "RU"]` |
102
+ | `contains` | String contains | `email contains "@company.com"` |
103
+ | `startsWith` | String starts with | `userId startsWith "admin-"` |
104
+ | `inTargetList` | In target list | `userId in beta-testers` |
105
+ | `notInTargetList` | Not in target list | `userId not in blocked-users` |
106
+
107
+ ## Dynamic Context Updates
108
+
109
+ ```typescript
110
+ // Initial context
111
+ const client = new FlagpoolClient({
112
+ apiKey: 'fp_prod_xxx',
113
+ environment: 'production',
114
+ context: { userId: 'user-1', plan: 'free' }
115
+ });
116
+
117
+ await client.init();
118
+
119
+ console.log(client.getValue('max-upload-size-mb')); // 10
120
+
121
+ // User upgrades to pro
122
+ client.updateContext({ plan: 'pro' });
123
+
124
+ console.log(client.getValue('max-upload-size-mb')); // 100
125
+ ```
126
+
127
+ ## Encrypted Target Lists
128
+
129
+ For offline/edge scenarios, you can decrypt and evaluate target lists client-side.
130
+
131
+ > **Note:** The SDK has NO crypto dependencies. You provide the adapter.
132
+
133
+ ### Step 1: Create a Crypto Adapter
134
+
135
+ ```typescript
136
+ // crypto-adapter.ts (YOUR CODE)
137
+ import type { CryptoAdapter } from 'flagpool-sdk';
138
+
139
+ export async function createNodeCryptoAdapter(): Promise<CryptoAdapter> {
140
+ const crypto = await import('crypto');
141
+
142
+ return {
143
+ async decrypt(ciphertext, key, iv, tag) {
144
+ // Your AES-256-GCM decryption implementation
145
+ // See examples/src/crypto-adapter.ts for full implementation
146
+ }
147
+ };
148
+ }
149
+ ```
150
+
151
+ ### Step 2: Register and Use
152
+
153
+ ```typescript
154
+ import { FlagpoolClient, setCryptoAdapter } from 'flagpool-sdk';
155
+ import { createNodeCryptoAdapter } from './crypto-adapter';
156
+
157
+ // 1. Create and register your adapter
158
+ const adapter = await createNodeCryptoAdapter();
159
+ setCryptoAdapter(adapter);
160
+
161
+ // 2. Create client with decryption enabled
162
+ const client = new FlagpoolClient({
163
+ apiKey: 'fp_staging_xxx',
164
+ environment: 'staging',
165
+ context: { userId: 'beta-user-1' },
166
+ decryptionKey: 'your-secret-key',
167
+ clientSideTargetLists: true,
168
+ });
169
+
170
+ await client.init();
171
+
172
+ // Target list rules are now evaluated locally
173
+ client.isEnabled('beta-feature'); // true if userId is in beta-testers
174
+ ```
175
+
176
+ ## Real-time Updates
177
+
178
+ ```typescript
179
+ const client = new FlagpoolClient({
180
+ apiKey: 'fp_prod_xxx',
181
+ environment: 'production',
182
+ context: { userId: 'user-1' },
183
+ streaming: true,
184
+ pollingInterval: 30000 // Poll every 30 seconds
185
+ });
186
+
187
+ await client.init();
188
+
189
+ // Listen to flag changes
190
+ client.onChange((flagKey, newValue) => {
191
+ console.log(`Flag ${flagKey} changed to:`, newValue);
192
+ });
193
+ ```
194
+
195
+ ## Examples
196
+
197
+ See the `examples/` directory for working examples:
198
+
199
+ ```bash
200
+ cd examples
201
+ npm install
202
+
203
+ # Start mock server (uses shared server from test-harness)
204
+ npm run server
205
+
206
+ # Run tests
207
+ npm run test:dev # Development environment
208
+ npm run test:staging # Staging environment
209
+ npm run test:evaluator # Comprehensive evaluator tests
210
+ npm run test:client-side # Client-side target list evaluation
211
+ ```
212
+
213
+ ## Flag Types
214
+
215
+ ### Boolean Flags
216
+
217
+ ```typescript
218
+ if (client.isEnabled('feature-flag')) {
219
+ // Feature is enabled
220
+ }
221
+ ```
222
+
223
+ ### String Flags (A/B Tests)
224
+
225
+ ```typescript
226
+ const variant = client.getValue('button-color');
227
+ // 'blue' | 'green' | 'orange'
228
+ ```
229
+
230
+ ### Number Flags
231
+
232
+ ```typescript
233
+ const limit = client.getValue('rate-limit');
234
+ // 100 | 1000 | 10000
235
+ ```
236
+
237
+ ### JSON Flags
238
+
239
+ ```typescript
240
+ const config = client.getValue('checkout-config');
241
+ // { showCoupons: true, maxItems: 50, ... }
242
+ ```
243
+
244
+ ## Conformance
245
+
246
+ This SDK passes all 60 conformance tests defined in `/spec/test-vectors.json`.
247
+
248
+ Run conformance tests:
249
+
250
+ ```bash
251
+ cd ../../test-harness
252
+ npm run test:typescript
253
+ ```
254
+
255
+ ## License
256
+
257
+ MIT
258
+
@@ -0,0 +1,6 @@
1
+ export declare class Cache {
2
+ private key;
3
+ constructor(key: string);
4
+ load(): any | null;
5
+ save(data: any): void;
6
+ }
package/dist/cache.js ADDED
@@ -0,0 +1,24 @@
1
+ export class Cache {
2
+ constructor(key) {
3
+ this.key = key;
4
+ }
5
+ load() {
6
+ try {
7
+ if (typeof localStorage === "undefined")
8
+ return null;
9
+ const raw = localStorage.getItem(this.key);
10
+ return raw ? JSON.parse(raw) : null;
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ }
16
+ save(data) {
17
+ try {
18
+ if (typeof localStorage === "undefined")
19
+ return;
20
+ localStorage.setItem(this.key, JSON.stringify(data));
21
+ }
22
+ catch { }
23
+ }
24
+ }
@@ -0,0 +1,27 @@
1
+ import { FlagpoolClientOptions, FlagChangedCallback } from "./types";
2
+ export declare class FlagpoolClient {
3
+ private options;
4
+ private flags;
5
+ private evaluator;
6
+ private cache;
7
+ private pollingTimer;
8
+ private realtimeConnection?;
9
+ private changeListeners;
10
+ private targetLists;
11
+ constructor(options: FlagpoolClientOptions);
12
+ init(): Promise<void>;
13
+ private refresh;
14
+ /**
15
+ * Process and decrypt target lists if needed
16
+ */
17
+ private processTargetLists;
18
+ private setupRealtime;
19
+ isEnabled(key: string, defaultValue?: boolean): boolean;
20
+ getValue(key: string): any;
21
+ getVariation(key: string): any;
22
+ getAllFlags(): Record<string, any>;
23
+ updateContext(newContext: Record<string, any>): void;
24
+ onChange(callback: FlagChangedCallback): () => boolean;
25
+ private notifyChanges;
26
+ close(): void;
27
+ }
package/dist/client.js ADDED
@@ -0,0 +1,171 @@
1
+ import { Evaluator } from "./evaluator";
2
+ import { fetchFlags } from "./fetcher";
3
+ import { Cache } from "./cache";
4
+ import { RealtimeConnection } from "./realtime";
5
+ import { decryptTargetLists, isEncryptedTargetLists } from "./crypto";
6
+ export class FlagpoolClient {
7
+ constructor(options) {
8
+ this.flags = new Map();
9
+ this.evaluator = new Evaluator();
10
+ this.cache = new Cache("flagpool_flags");
11
+ this.changeListeners = new Set();
12
+ this.targetLists = null;
13
+ if (!options.apiKey)
14
+ throw new Error("apiKey is required");
15
+ if (!options.environment)
16
+ throw new Error("environment is required");
17
+ // Validate client-side target list requirements
18
+ if (options.clientSideTargetLists && !options.decryptionKey) {
19
+ throw new Error("decryptionKey is required when clientSideTargetLists is enabled");
20
+ }
21
+ this.options = options;
22
+ }
23
+ async init() {
24
+ // Try loading from cache
25
+ const cached = this.cache.load();
26
+ if (cached === null || cached === void 0 ? void 0 : cached.flags) {
27
+ Object.entries(cached.flags).forEach(([key, flag]) => {
28
+ this.flags.set(key, flag);
29
+ });
30
+ }
31
+ // Fetch fresh flags
32
+ try {
33
+ await this.refresh();
34
+ }
35
+ catch (err) {
36
+ // If fetch failed but we have cache, continue; otherwise throw
37
+ if (this.flags.size === 0)
38
+ throw err;
39
+ }
40
+ // Setup polling if configured
41
+ if (this.options.pollingInterval && !this.options.streaming) {
42
+ this.pollingTimer = setInterval(() => this.refresh().catch(() => { }), this.options.pollingInterval);
43
+ }
44
+ // Setup realtime streaming if enabled (uses polling internally)
45
+ if (this.options.streaming) {
46
+ this.setupRealtime();
47
+ }
48
+ }
49
+ async refresh() {
50
+ const response = await fetchFlags({
51
+ apiKey: this.options.apiKey,
52
+ environment: this.options.environment,
53
+ context: this.options.context,
54
+ urlOverride: this.options.urlOverride
55
+ });
56
+ // Update flags map
57
+ this.flags.clear();
58
+ response.flags.forEach(flag => {
59
+ this.flags.set(flag.key, flag);
60
+ });
61
+ // Handle target lists if client-side evaluation is enabled
62
+ if (this.options.clientSideTargetLists && this.options.decryptionKey && response.targetLists) {
63
+ await this.processTargetLists(response.targetLists);
64
+ }
65
+ // Cache the response
66
+ this.cache.save({
67
+ flags: Object.fromEntries(this.flags),
68
+ timestamp: response.timestamp
69
+ });
70
+ // Notify listeners of changes
71
+ this.notifyChanges();
72
+ }
73
+ /**
74
+ * Process and decrypt target lists if needed
75
+ */
76
+ async processTargetLists(targetLists) {
77
+ if (isEncryptedTargetLists(targetLists)) {
78
+ // Decrypt target lists
79
+ try {
80
+ this.targetLists = await decryptTargetLists(targetLists, this.options.decryptionKey);
81
+ this.evaluator.setTargetLists(this.targetLists, true);
82
+ }
83
+ catch (err) {
84
+ console.error("Failed to decrypt target lists:", err);
85
+ this.targetLists = null;
86
+ this.evaluator.setTargetLists(null, false);
87
+ }
88
+ }
89
+ else {
90
+ // Already decrypted (for testing/development)
91
+ this.targetLists = targetLists;
92
+ this.evaluator.setTargetLists(this.targetLists, true);
93
+ }
94
+ }
95
+ setupRealtime() {
96
+ var _a;
97
+ const endpoint = (_a = this.options.urlOverride) !== null && _a !== void 0 ? _a : `${process.env.SUPABASE_URL || 'http://localhost:54321'}/functions/v1/get-flags`;
98
+ this.realtimeConnection = new RealtimeConnection({
99
+ endpoint,
100
+ apiKey: this.options.apiKey,
101
+ environment: this.options.environment,
102
+ context: this.options.context,
103
+ onUpdate: () => {
104
+ // Flags changed, refresh
105
+ this.refresh().catch(() => { });
106
+ },
107
+ pollInterval: this.options.pollingInterval || 30000
108
+ });
109
+ this.realtimeConnection.start();
110
+ }
111
+ isEnabled(key, defaultValue = false) {
112
+ const value = this.getValue(key);
113
+ if (value === null || value === undefined)
114
+ return defaultValue;
115
+ return Boolean(value);
116
+ }
117
+ getValue(key) {
118
+ const flag = this.flags.get(key);
119
+ if (!flag)
120
+ return null;
121
+ const context = {
122
+ environment: this.options.environment,
123
+ ...this.options.context
124
+ };
125
+ return this.evaluator.evaluate(flag, context);
126
+ }
127
+ getVariation(key) {
128
+ return this.getValue(key);
129
+ }
130
+ getAllFlags() {
131
+ const result = {};
132
+ const context = {
133
+ environment: this.options.environment,
134
+ ...this.options.context
135
+ };
136
+ this.flags.forEach((flag, key) => {
137
+ result[key] = this.evaluator.evaluate(flag, context);
138
+ });
139
+ return result;
140
+ }
141
+ updateContext(newContext) {
142
+ var _a;
143
+ this.options.context = {
144
+ ...((_a = this.options.context) !== null && _a !== void 0 ? _a : {}),
145
+ ...newContext
146
+ };
147
+ // Notify listeners of potential changes
148
+ this.notifyChanges();
149
+ }
150
+ onChange(callback) {
151
+ this.changeListeners.add(callback);
152
+ return () => this.changeListeners.delete(callback);
153
+ }
154
+ notifyChanges() {
155
+ const allFlags = this.getAllFlags();
156
+ this.changeListeners.forEach(listener => {
157
+ Object.entries(allFlags).forEach(([key, value]) => {
158
+ listener(key, value);
159
+ });
160
+ });
161
+ }
162
+ close() {
163
+ if (this.pollingTimer) {
164
+ clearInterval(this.pollingTimer);
165
+ }
166
+ if (this.realtimeConnection) {
167
+ this.realtimeConnection.stop();
168
+ }
169
+ this.changeListeners.clear();
170
+ }
171
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * AES-256-GCM Encryption/Decryption for Target Lists
3
+ *
4
+ * This module provides secure encryption for target lists, allowing
5
+ * flag configurations to be shared publicly while keeping sensitive
6
+ * user data (like user IDs, emails) encrypted.
7
+ *
8
+ * ARCHITECTURE NOTE:
9
+ * ------------------
10
+ * This crypto module is OPTIONAL and only needed for client-side target list
11
+ * evaluation in offline/edge scenarios. The recommended approach is:
12
+ *
13
+ * 1. SERVER-SIDE EVALUATION (Default): Server evaluates inTargetList rules
14
+ * before sending flags. SDK receives pre-filtered flags, no crypto needed.
15
+ *
16
+ * 2. CLIENT-SIDE EVALUATION (Optional): For offline/edge cases, inject a
17
+ * crypto adapter. This keeps the core SDK dependency-free.
18
+ *
19
+ * To use client-side decryption, provide a CryptoAdapter implementation
20
+ * for your platform (Node.js, Browser, etc.)
21
+ */
22
+ export interface EncryptedData {
23
+ _encrypted: true;
24
+ _algorithm: 'AES-256-GCM';
25
+ _iv: string;
26
+ _data: string;
27
+ _tag: string;
28
+ }
29
+ export interface TargetList {
30
+ attributeKey: string;
31
+ values: string[];
32
+ }
33
+ export interface DecryptedTargetLists {
34
+ [key: string]: TargetList;
35
+ }
36
+ /**
37
+ * Crypto Adapter Interface
38
+ *
39
+ * Implement this interface for your platform to enable client-side
40
+ * target list decryption. This keeps the core SDK dependency-free.
41
+ *
42
+ * Example implementations:
43
+ * - Node.js: Use built-in 'crypto' module
44
+ * - Browser: Use Web Crypto API
45
+ * - React Native: Use expo-crypto or react-native-crypto
46
+ */
47
+ export interface CryptoAdapter {
48
+ /**
49
+ * Decrypt AES-256-GCM encrypted data
50
+ * @param ciphertext - Base64 encoded ciphertext
51
+ * @param key - Decryption key string
52
+ * @param iv - Base64 encoded initialization vector
53
+ * @param tag - Base64 encoded authentication tag
54
+ * @returns Decrypted plaintext string
55
+ */
56
+ decrypt(ciphertext: string, key: string, iv: string, tag: string): Promise<string>;
57
+ /**
58
+ * Encrypt plaintext with AES-256-GCM
59
+ * @param plaintext - String to encrypt
60
+ * @param key - Encryption key string
61
+ * @returns Encrypted data with IV and auth tag
62
+ */
63
+ encrypt?(plaintext: string, key: string): Promise<{
64
+ ciphertext: string;
65
+ iv: string;
66
+ tag: string;
67
+ }>;
68
+ }
69
+ /**
70
+ * Set the crypto adapter for client-side decryption
71
+ *
72
+ * Call this once at app startup if you need client-side target list evaluation.
73
+ * If not set, the SDK will rely on server-side evaluation (recommended).
74
+ *
75
+ * @example
76
+ * // Node.js
77
+ * import { setCryptoAdapter, createNodeCryptoAdapter } from 'flagpool-sdk';
78
+ * setCryptoAdapter(createNodeCryptoAdapter());
79
+ *
80
+ * @example
81
+ * // Browser
82
+ * import { setCryptoAdapter, createWebCryptoAdapter } from 'flagpool-sdk';
83
+ * setCryptoAdapter(createWebCryptoAdapter());
84
+ */
85
+ export declare function setCryptoAdapter(adapter: CryptoAdapter): void;
86
+ /**
87
+ * Get the current crypto adapter
88
+ */
89
+ export declare function getCryptoAdapter(): CryptoAdapter | null;
90
+ /**
91
+ * Check if a crypto adapter is available
92
+ */
93
+ export declare function hasCryptoAdapter(): boolean;
94
+ /**
95
+ * Decrypt target lists with AES-256-GCM
96
+ * Requires a crypto adapter to be set via setCryptoAdapter()
97
+ */
98
+ export declare function decryptTargetLists(encryptedData: EncryptedData, decryptionKey: string): Promise<DecryptedTargetLists>;
99
+ /**
100
+ * Encrypt target lists with AES-256-GCM
101
+ * Requires a crypto adapter with encrypt() support
102
+ */
103
+ export declare function encryptTargetLists(targetLists: DecryptedTargetLists, encryptionKey: string): Promise<EncryptedData>;
104
+ /**
105
+ * Check if an object is encrypted target lists
106
+ */
107
+ export declare function isEncryptedTargetLists(obj: any): obj is EncryptedData;
package/dist/crypto.js ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * AES-256-GCM Encryption/Decryption for Target Lists
3
+ *
4
+ * This module provides secure encryption for target lists, allowing
5
+ * flag configurations to be shared publicly while keeping sensitive
6
+ * user data (like user IDs, emails) encrypted.
7
+ *
8
+ * ARCHITECTURE NOTE:
9
+ * ------------------
10
+ * This crypto module is OPTIONAL and only needed for client-side target list
11
+ * evaluation in offline/edge scenarios. The recommended approach is:
12
+ *
13
+ * 1. SERVER-SIDE EVALUATION (Default): Server evaluates inTargetList rules
14
+ * before sending flags. SDK receives pre-filtered flags, no crypto needed.
15
+ *
16
+ * 2. CLIENT-SIDE EVALUATION (Optional): For offline/edge cases, inject a
17
+ * crypto adapter. This keeps the core SDK dependency-free.
18
+ *
19
+ * To use client-side decryption, provide a CryptoAdapter implementation
20
+ * for your platform (Node.js, Browser, etc.)
21
+ */
22
+ // Global crypto adapter - set via setCryptoAdapter()
23
+ let cryptoAdapter = null;
24
+ /**
25
+ * Set the crypto adapter for client-side decryption
26
+ *
27
+ * Call this once at app startup if you need client-side target list evaluation.
28
+ * If not set, the SDK will rely on server-side evaluation (recommended).
29
+ *
30
+ * @example
31
+ * // Node.js
32
+ * import { setCryptoAdapter, createNodeCryptoAdapter } from 'flagpool-sdk';
33
+ * setCryptoAdapter(createNodeCryptoAdapter());
34
+ *
35
+ * @example
36
+ * // Browser
37
+ * import { setCryptoAdapter, createWebCryptoAdapter } from 'flagpool-sdk';
38
+ * setCryptoAdapter(createWebCryptoAdapter());
39
+ */
40
+ export function setCryptoAdapter(adapter) {
41
+ cryptoAdapter = adapter;
42
+ }
43
+ /**
44
+ * Get the current crypto adapter
45
+ */
46
+ export function getCryptoAdapter() {
47
+ return cryptoAdapter;
48
+ }
49
+ /**
50
+ * Check if a crypto adapter is available
51
+ */
52
+ export function hasCryptoAdapter() {
53
+ return cryptoAdapter !== null;
54
+ }
55
+ /**
56
+ * Decrypt target lists with AES-256-GCM
57
+ * Requires a crypto adapter to be set via setCryptoAdapter()
58
+ */
59
+ export async function decryptTargetLists(encryptedData, decryptionKey) {
60
+ if (!cryptoAdapter) {
61
+ throw new Error('No crypto adapter configured. Either:\n' +
62
+ '1. Use server-side target list evaluation (recommended)\n' +
63
+ '2. Call setCryptoAdapter() with a platform-specific adapter\n' +
64
+ '\n' +
65
+ 'See documentation for available adapters:\n' +
66
+ '- createNodeCryptoAdapter() for Node.js\n' +
67
+ '- createWebCryptoAdapter() for browsers');
68
+ }
69
+ if (!encryptedData._encrypted || encryptedData._algorithm !== 'AES-256-GCM') {
70
+ throw new Error('Invalid encrypted data format');
71
+ }
72
+ const plaintext = await cryptoAdapter.decrypt(encryptedData._data, decryptionKey, encryptedData._iv, encryptedData._tag);
73
+ return JSON.parse(plaintext);
74
+ }
75
+ /**
76
+ * Encrypt target lists with AES-256-GCM
77
+ * Requires a crypto adapter with encrypt() support
78
+ */
79
+ export async function encryptTargetLists(targetLists, encryptionKey) {
80
+ if (!(cryptoAdapter === null || cryptoAdapter === void 0 ? void 0 : cryptoAdapter.encrypt)) {
81
+ throw new Error('No crypto adapter with encryption support configured.\n' +
82
+ 'Call setCryptoAdapter() with an adapter that implements encrypt().');
83
+ }
84
+ const plaintext = JSON.stringify(targetLists);
85
+ const { ciphertext, iv, tag } = await cryptoAdapter.encrypt(plaintext, encryptionKey);
86
+ return {
87
+ _encrypted: true,
88
+ _algorithm: 'AES-256-GCM',
89
+ _iv: iv,
90
+ _data: ciphertext,
91
+ _tag: tag
92
+ };
93
+ }
94
+ /**
95
+ * Check if an object is encrypted target lists
96
+ */
97
+ export function isEncryptedTargetLists(obj) {
98
+ return obj && obj._encrypted === true && obj._algorithm === 'AES-256-GCM';
99
+ }
@@ -0,0 +1,26 @@
1
+ import { FlagDefinition, DecryptedTargetLists } from "./types";
2
+ export declare class Evaluator {
3
+ private targetLists;
4
+ private clientSideTargetLists;
5
+ /**
6
+ * Set target lists for client-side evaluation
7
+ */
8
+ setTargetLists(targetLists: DecryptedTargetLists | null, clientSideEnabled?: boolean): void;
9
+ /**
10
+ * Check if client-side target list evaluation is enabled
11
+ */
12
+ hasTargetLists(): boolean;
13
+ /**
14
+ * Evaluate a flag with priority-based rules.
15
+ * If clientSideTargetLists is enabled, inTargetList/notInTargetList rules
16
+ * are evaluated locally using decrypted target lists.
17
+ */
18
+ evaluate(flag: FlagDefinition, context?: Record<string, any>): any;
19
+ private matchesRule;
20
+ /**
21
+ * Evaluate inTargetList/notInTargetList rules
22
+ */
23
+ private evaluateTargetListRule;
24
+ private compare;
25
+ private inRollout;
26
+ }
@@ -0,0 +1,107 @@
1
+ import { hashString } from "./utils";
2
+ export class Evaluator {
3
+ constructor() {
4
+ this.targetLists = null;
5
+ this.clientSideTargetLists = false;
6
+ }
7
+ /**
8
+ * Set target lists for client-side evaluation
9
+ */
10
+ setTargetLists(targetLists, clientSideEnabled = false) {
11
+ this.targetLists = targetLists;
12
+ this.clientSideTargetLists = clientSideEnabled;
13
+ }
14
+ /**
15
+ * Check if client-side target list evaluation is enabled
16
+ */
17
+ hasTargetLists() {
18
+ return this.clientSideTargetLists && this.targetLists !== null;
19
+ }
20
+ /**
21
+ * Evaluate a flag with priority-based rules.
22
+ * If clientSideTargetLists is enabled, inTargetList/notInTargetList rules
23
+ * are evaluated locally using decrypted target lists.
24
+ */
25
+ evaluate(flag, context = {}) {
26
+ var _a, _b, _c, _d, _e, _f;
27
+ // 1. Sort rules by priority (lower = higher priority)
28
+ const sortedRules = (flag.rules || []).sort((a, b) => a.priority - b.priority);
29
+ // 2. Evaluate rules in priority order
30
+ for (const rule of sortedRules) {
31
+ if (this.matchesRule(rule, context)) {
32
+ return (_b = (_a = flag.variations[rule.variation]) === null || _a === void 0 ? void 0 : _a.value) !== null && _b !== void 0 ? _b : (_c = flag.variations[flag.defaultVariation]) === null || _c === void 0 ? void 0 : _c.value;
33
+ }
34
+ }
35
+ // 3. Check rollout percentage (if less than 100%)
36
+ if (flag.rolloutPercentage < 100) {
37
+ if (!this.inRollout(flag, context)) {
38
+ // Not in rollout - return "off" variation (assume index 1)
39
+ return (_e = (_d = flag.variations[1]) === null || _d === void 0 ? void 0 : _d.value) !== null && _e !== void 0 ? _e : false;
40
+ }
41
+ }
42
+ // 4. Fall back to default variation
43
+ return (_f = flag.variations[flag.defaultVariation]) === null || _f === void 0 ? void 0 : _f.value;
44
+ }
45
+ matchesRule(rule, context) {
46
+ const contextValue = context[rule.attribute];
47
+ // Handle target list operators
48
+ if (rule.operator === 'inTargetList' || rule.operator === 'notInTargetList') {
49
+ return this.evaluateTargetListRule(rule, context);
50
+ }
51
+ // Standard operators
52
+ return this.compare(contextValue, rule.operator, rule.value);
53
+ }
54
+ /**
55
+ * Evaluate inTargetList/notInTargetList rules
56
+ */
57
+ evaluateTargetListRule(rule, context) {
58
+ const targetListKey = rule.targetListKey;
59
+ if (!targetListKey) {
60
+ return false;
61
+ }
62
+ // If client-side target lists are enabled, evaluate locally
63
+ if (this.clientSideTargetLists && this.targetLists) {
64
+ const targetList = this.targetLists[targetListKey];
65
+ if (!targetList) {
66
+ // Target list not found - rule doesn't match
67
+ return false;
68
+ }
69
+ // Get the context value for the target list's attribute
70
+ const contextValue = context[targetList.attributeKey];
71
+ if (contextValue === undefined || contextValue === null) {
72
+ return rule.operator === 'notInTargetList';
73
+ }
74
+ const isInList = targetList.values.includes(String(contextValue));
75
+ return rule.operator === 'inTargetList' ? isInList : !isInList;
76
+ }
77
+ // Server-side evaluation fallback:
78
+ // If the rule exists here, assume server already filtered it
79
+ const contextValue = context[rule.attribute];
80
+ return contextValue !== undefined;
81
+ }
82
+ compare(contextValue, operator, ruleValue) {
83
+ switch (operator) {
84
+ case 'eq':
85
+ return contextValue === ruleValue;
86
+ case 'neq':
87
+ return contextValue !== ruleValue;
88
+ case 'in':
89
+ return Array.isArray(ruleValue) && ruleValue.includes(contextValue);
90
+ case 'nin':
91
+ return Array.isArray(ruleValue) && !ruleValue.includes(contextValue);
92
+ case 'contains':
93
+ return String(contextValue).includes(String(ruleValue));
94
+ case 'startsWith':
95
+ return String(contextValue).startsWith(String(ruleValue));
96
+ default:
97
+ return false;
98
+ }
99
+ }
100
+ inRollout(flag, context) {
101
+ const userId = context.userId || context.id;
102
+ if (!userId)
103
+ return true; // If no user ID, include in rollout
104
+ const hash = hashString(String(userId) + flag.key) % 100;
105
+ return hash < flag.rolloutPercentage;
106
+ }
107
+ }
@@ -0,0 +1,8 @@
1
+ import { GetFlagsResponse } from "./types";
2
+ export interface FetchFlagsOptions {
3
+ apiKey: string;
4
+ environment: string;
5
+ context?: Record<string, any>;
6
+ urlOverride?: string;
7
+ }
8
+ export declare function fetchFlags(options: FetchFlagsOptions): Promise<GetFlagsResponse>;
@@ -0,0 +1,23 @@
1
+ export async function fetchFlags(options) {
2
+ var _a;
3
+ // Default to Supabase edge function endpoint
4
+ const url = (_a = options.urlOverride) !== null && _a !== void 0 ? _a : `${process.env.SUPABASE_URL || 'http://localhost:54321'}/functions/v1/get-flags`;
5
+ const res = await fetch(url, {
6
+ method: 'POST',
7
+ headers: {
8
+ 'Content-Type': 'application/json',
9
+ },
10
+ body: JSON.stringify({
11
+ apiKey: options.apiKey,
12
+ context: {
13
+ environment: options.environment,
14
+ ...options.context
15
+ }
16
+ })
17
+ });
18
+ if (!res.ok) {
19
+ const error = await res.json().catch(() => ({ error: 'Failed to fetch flags' }));
20
+ throw new Error(error.error || `Failed to fetch flags: ${res.status}`);
21
+ }
22
+ return res.json();
23
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./client";
2
+ export * from "./types";
3
+ export { encryptTargetLists, decryptTargetLists, isEncryptedTargetLists, setCryptoAdapter, getCryptoAdapter, hasCryptoAdapter } from "./crypto";
4
+ export type { EncryptedData, TargetList, DecryptedTargetLists, CryptoAdapter } from "./crypto";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./client";
2
+ export * from "./types";
3
+ export { encryptTargetLists, decryptTargetLists, isEncryptedTargetLists, setCryptoAdapter, getCryptoAdapter, hasCryptoAdapter } from "./crypto";
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Minimal real-time connection for flag updates
3
+ * Uses polling as a simple, dependency-free solution
4
+ */
5
+ export interface RealtimeOptions {
6
+ endpoint: string;
7
+ apiKey: string;
8
+ environment: string;
9
+ context?: Record<string, any>;
10
+ onUpdate: () => void;
11
+ pollInterval?: number;
12
+ }
13
+ export declare class RealtimeConnection {
14
+ private options;
15
+ private pollTimer;
16
+ private lastTimestamp;
17
+ private isRunning;
18
+ constructor(options: RealtimeOptions);
19
+ start(): Promise<void>;
20
+ private poll;
21
+ stop(): void;
22
+ updateContext(context: Record<string, any>): void;
23
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Minimal real-time connection for flag updates
3
+ * Uses polling as a simple, dependency-free solution
4
+ */
5
+ export class RealtimeConnection {
6
+ constructor(options) {
7
+ this.lastTimestamp = null;
8
+ this.isRunning = false;
9
+ this.options = {
10
+ pollInterval: 30000, // Default: 30 seconds
11
+ ...options
12
+ };
13
+ }
14
+ async start() {
15
+ if (this.isRunning)
16
+ return;
17
+ this.isRunning = true;
18
+ // Poll immediately
19
+ await this.poll();
20
+ // Setup polling interval
21
+ this.pollTimer = setInterval(() => this.poll().catch(() => { }), this.options.pollInterval);
22
+ }
23
+ async poll() {
24
+ try {
25
+ const response = await fetch(this.options.endpoint, {
26
+ method: 'POST',
27
+ headers: {
28
+ 'Content-Type': 'application/json',
29
+ },
30
+ body: JSON.stringify({
31
+ apiKey: this.options.apiKey,
32
+ context: {
33
+ environment: this.options.environment,
34
+ ...this.options.context
35
+ },
36
+ since: this.lastTimestamp // Server can optimize based on this
37
+ })
38
+ });
39
+ if (!response.ok)
40
+ return;
41
+ const data = await response.json();
42
+ // Check if data has changed
43
+ if (data.timestamp && data.timestamp !== this.lastTimestamp) {
44
+ this.lastTimestamp = data.timestamp;
45
+ this.options.onUpdate();
46
+ }
47
+ }
48
+ catch (err) {
49
+ // Silently fail - will retry on next interval
50
+ }
51
+ }
52
+ stop() {
53
+ this.isRunning = false;
54
+ if (this.pollTimer) {
55
+ clearInterval(this.pollTimer);
56
+ this.pollTimer = null;
57
+ }
58
+ }
59
+ updateContext(context) {
60
+ this.options.context = context;
61
+ }
62
+ }
@@ -0,0 +1,3 @@
1
+ export declare function startStream(): {
2
+ close: () => void;
3
+ };
package/dist/stream.js ADDED
@@ -0,0 +1,6 @@
1
+ // Legacy streaming support - replaced by Supabase Realtime
2
+ // This file is kept for backward compatibility but is no longer used
3
+ export function startStream() {
4
+ console.warn('startStream is deprecated - use Supabase Realtime streaming instead');
5
+ return { close: () => { } };
6
+ }
@@ -0,0 +1,55 @@
1
+ export type FlagValue = boolean | string | number | object | null;
2
+ export interface FlagpoolClientOptions {
3
+ apiKey: string;
4
+ environment: string;
5
+ context?: Record<string, any>;
6
+ pollingInterval?: number;
7
+ streaming?: boolean;
8
+ urlOverride?: string;
9
+ decryptionKey?: string;
10
+ clientSideTargetLists?: boolean;
11
+ }
12
+ export interface FlagVariation {
13
+ value: any;
14
+ name: string;
15
+ }
16
+ export interface FlagRule {
17
+ attribute: string;
18
+ operator: 'eq' | 'neq' | 'in' | 'nin' | 'contains' | 'startsWith' | 'inTargetList' | 'notInTargetList';
19
+ value: any;
20
+ targetListKey?: string;
21
+ variation: number;
22
+ priority: number;
23
+ }
24
+ export interface FlagDefinition {
25
+ key: string;
26
+ variations: FlagVariation[];
27
+ defaultVariation: number;
28
+ rolloutPercentage: number;
29
+ tags?: string[];
30
+ rules?: FlagRule[];
31
+ }
32
+ export interface GetFlagsResponse {
33
+ flags: FlagDefinition[];
34
+ environment: {
35
+ key: string;
36
+ name: string;
37
+ };
38
+ timestamp: string;
39
+ targetLists?: EncryptedTargetLists | DecryptedTargetLists;
40
+ }
41
+ export interface EncryptedTargetLists {
42
+ _encrypted: true;
43
+ _algorithm: 'AES-256-GCM';
44
+ _iv: string;
45
+ _data: string;
46
+ _tag: string;
47
+ }
48
+ export interface TargetList {
49
+ attributeKey: string;
50
+ values: string[];
51
+ }
52
+ export interface DecryptedTargetLists {
53
+ [key: string]: TargetList;
54
+ }
55
+ export type FlagChangedCallback = (key: string, newValue: any) => void;
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export declare function hashString(str: string): number;
package/dist/utils.js ADDED
@@ -0,0 +1,8 @@
1
+ export function hashString(str) {
2
+ let hash = 0;
3
+ for (let i = 0; i < str.length; i++) {
4
+ hash = (hash << 5) - hash + str.charCodeAt(i);
5
+ hash |= 0;
6
+ }
7
+ return Math.abs(hash);
8
+ }
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@flagpool/sdk",
3
+ "version": "0.1.0",
4
+ "description": "Official Flagpool SDK for TypeScript/JavaScript - feature flags with local evaluation, deterministic rollouts, and encrypted target lists",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "module": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/index.js",
12
+ "types": "./dist/index.d.ts"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/flagpool/flagpool-sdk.git",
26
+ "directory": "sdks/typescript"
27
+ },
28
+ "homepage": "https://github.com/flagpool/flagpool-sdk/tree/main/sdks/typescript#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/flagpool/flagpool-sdk/issues"
31
+ },
32
+ "author": "Flagpool",
33
+ "scripts": {
34
+ "build": "tsc",
35
+ "test": "vitest run",
36
+ "test:watch": "vitest",
37
+ "test:ui": "vitest --ui",
38
+ "test:coverage": "vitest run --coverage",
39
+ "prepublishOnly": "npm run build && npm test"
40
+ },
41
+ "keywords": [
42
+ "feature-flags",
43
+ "feature-toggles",
44
+ "sdk",
45
+ "flagpool",
46
+ "ab-testing",
47
+ "rollouts",
48
+ "targeting"
49
+ ],
50
+ "license": "MIT",
51
+ "engines": {
52
+ "node": ">=18.0.0"
53
+ },
54
+ "devDependencies": {
55
+ "@types/node": "^25.0.3",
56
+ "@vitest/coverage-v8": "^4.0.10",
57
+ "@vitest/ui": "^4.0.10",
58
+ "eventsource": "^4.1.0",
59
+ "typescript": "^5.5.2",
60
+ "vitest": "^4.0.10"
61
+ }
62
+ }