@autumnsgrove/groveengine 0.9.94 → 0.9.96
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/components/admin/MarkdownEditor.svelte +15 -11
- package/dist/components/admin/VoiceInput.svelte +4 -3
- package/dist/feature-flags/admin.d.ts +69 -0
- package/dist/feature-flags/admin.js +119 -0
- package/dist/feature-flags/evaluate.js +16 -0
- package/dist/feature-flags/greenhouse.d.ts +91 -0
- package/dist/feature-flags/greenhouse.js +242 -0
- package/dist/feature-flags/index.d.ts +3 -1
- package/dist/feature-flags/index.js +4 -0
- package/dist/feature-flags/percentage.js +1 -2
- package/dist/feature-flags/rules.js +2 -0
- package/dist/feature-flags/test-utils.js +1 -0
- package/dist/feature-flags/types.d.ts +30 -2
- package/dist/grafts/greenhouse/CultivateFlagRow.svelte +116 -0
- package/dist/grafts/greenhouse/CultivateFlagRow.svelte.d.ts +18 -0
- package/dist/grafts/greenhouse/CultivateFlagTable.svelte +105 -0
- package/dist/grafts/greenhouse/CultivateFlagTable.svelte.d.ts +18 -0
- package/dist/grafts/greenhouse/GreenhouseEnrollDialog.svelte +264 -0
- package/dist/grafts/greenhouse/GreenhouseEnrollDialog.svelte.d.ts +20 -0
- package/dist/grafts/greenhouse/GreenhouseEnrollTable.svelte +199 -0
- package/dist/grafts/greenhouse/GreenhouseEnrollTable.svelte.d.ts +20 -0
- package/dist/grafts/greenhouse/GreenhouseStatusCard.svelte +101 -0
- package/dist/grafts/greenhouse/GreenhouseStatusCard.svelte.d.ts +15 -0
- package/dist/grafts/greenhouse/GreenhouseToggle.svelte +123 -0
- package/dist/grafts/greenhouse/GreenhouseToggle.svelte.d.ts +19 -0
- package/dist/grafts/greenhouse/index.d.ts +52 -0
- package/dist/grafts/greenhouse/index.js +53 -0
- package/dist/grafts/greenhouse/types.d.ts +139 -0
- package/dist/grafts/greenhouse/types.js +7 -0
- package/dist/grafts/login/LoginGraft.svelte +121 -25
- package/dist/grafts/login/LoginGraft.svelte.d.ts +0 -32
- package/dist/grafts/login/config.d.ts +26 -11
- package/dist/grafts/login/config.js +26 -11
- package/dist/grafts/login/index.d.ts +5 -15
- package/dist/grafts/login/index.js +4 -14
- package/dist/grafts/login/server/callback.d.ts +14 -10
- package/dist/grafts/login/server/callback.js +34 -147
- package/dist/grafts/login/server/index.d.ts +4 -13
- package/dist/grafts/login/server/index.js +3 -12
- package/dist/grafts/login/types.d.ts +20 -35
- package/dist/ui/components/chrome/AdminHeader.svelte +160 -0
- package/dist/ui/components/chrome/AdminHeader.svelte.d.ts +25 -0
- package/dist/ui/components/chrome/index.d.ts +1 -0
- package/dist/ui/components/chrome/index.js +1 -0
- package/dist/ui/components/chrome/types.d.ts +10 -2
- package/dist/ui/components/chrome/types.js +2 -2
- package/dist/ui/styles/tokens.css +1 -1
- package/package.json +6 -1
- package/dist/grafts/login/LoginCard.svelte +0 -88
- package/dist/grafts/login/LoginCard.svelte.d.ts +0 -24
- package/dist/grafts/login/ProviderButton.svelte +0 -40
- package/dist/grafts/login/ProviderButton.svelte.d.ts +0 -18
- package/dist/grafts/login/server/login.d.ts +0 -32
- package/dist/grafts/login/server/login.js +0 -108
|
@@ -65,8 +65,17 @@
|
|
|
65
65
|
let lineNumbersRef = $state(null);
|
|
66
66
|
|
|
67
67
|
// Editor mode: "write" (source only), "split" (source + preview), "preview" (preview only)
|
|
68
|
+
// Initialize from localStorage synchronously to avoid flash of wrong mode
|
|
68
69
|
/** @type {"write" | "split" | "preview"} */
|
|
69
|
-
let editorMode = $state(
|
|
70
|
+
let editorMode = $state((() => {
|
|
71
|
+
if (browser) {
|
|
72
|
+
const saved = localStorage.getItem("editor-mode");
|
|
73
|
+
if (saved === "write" || saved === "split" || saved === "preview") {
|
|
74
|
+
return saved;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return "write"; // Default to source/raw mode for focused writing
|
|
78
|
+
})());
|
|
70
79
|
|
|
71
80
|
let cursorLine = $state(1);
|
|
72
81
|
let cursorCol = $state(1);
|
|
@@ -403,15 +412,9 @@
|
|
|
403
412
|
setEditorMode(modes[nextIndex]);
|
|
404
413
|
}
|
|
405
414
|
|
|
406
|
-
//
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
const saved = localStorage.getItem("editor-mode");
|
|
410
|
-
if (saved === "write" || saved === "split" || saved === "preview") {
|
|
411
|
-
editorMode = saved;
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
+
// Note: Editor mode is now initialized synchronously at declaration time
|
|
416
|
+
// using an IIFE that reads from localStorage. This prevents the flash of
|
|
417
|
+
// wrong mode that occurred when loadEditorMode() was called in $effect.
|
|
415
418
|
|
|
416
419
|
// Typewriter scrolling
|
|
417
420
|
function applyTypewriterScroll() {
|
|
@@ -649,7 +652,8 @@
|
|
|
649
652
|
updateCursorPosition();
|
|
650
653
|
editorTheme.loadTheme();
|
|
651
654
|
draftManager.init(content);
|
|
652
|
-
|
|
655
|
+
// Note: editorMode is now initialized synchronously at declaration time
|
|
656
|
+
// to avoid flash of wrong mode on initial render
|
|
653
657
|
|
|
654
658
|
return () => {
|
|
655
659
|
draftManager.cleanup();
|
|
@@ -72,6 +72,10 @@
|
|
|
72
72
|
|
|
73
73
|
$effect(() => {
|
|
74
74
|
// Initialize recorder when component mounts
|
|
75
|
+
// Note: We intentionally do NOT call recorder.warm() here!
|
|
76
|
+
// Requesting microphone permission should only happen on user interaction,
|
|
77
|
+
// not on page load. The start() method handles warming on-demand.
|
|
78
|
+
// This fixes #751 (mic prompt on load), #752 (frozen editor), #757 (disabled state)
|
|
75
79
|
recorder = createScribeRecorder({
|
|
76
80
|
onAudioLevel: (level) => {
|
|
77
81
|
audioLevel = level;
|
|
@@ -85,9 +89,6 @@
|
|
|
85
89
|
},
|
|
86
90
|
});
|
|
87
91
|
|
|
88
|
-
// Warm up the recorder
|
|
89
|
-
recorder.warm();
|
|
90
|
-
|
|
91
92
|
// Cleanup on unmount
|
|
92
93
|
return () => {
|
|
93
94
|
recorder?.dispose();
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feature Flags Admin API
|
|
3
|
+
*
|
|
4
|
+
* Functions for managing feature flags in the admin UI.
|
|
5
|
+
* Provides simple global enable/disable (cultivate/prune) controls.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* import { getFeatureFlags, setFlagEnabled } from './admin';
|
|
10
|
+
*
|
|
11
|
+
* // Get all flags for display
|
|
12
|
+
* const flags = await getFeatureFlags(env);
|
|
13
|
+
*
|
|
14
|
+
* // Cultivate (enable globally)
|
|
15
|
+
* await setFlagEnabled('jxl_encoding', true, env);
|
|
16
|
+
*
|
|
17
|
+
* // Prune (disable globally)
|
|
18
|
+
* await setFlagEnabled('jxl_encoding', false, env);
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
import type { FeatureFlagsEnv, FlagType } from "./types.js";
|
|
22
|
+
/**
|
|
23
|
+
* Summary of a feature flag for admin display.
|
|
24
|
+
*/
|
|
25
|
+
export interface FeatureFlagSummary {
|
|
26
|
+
/** Unique flag identifier */
|
|
27
|
+
id: string;
|
|
28
|
+
/** Human-readable flag name */
|
|
29
|
+
name: string;
|
|
30
|
+
/** Optional description */
|
|
31
|
+
description?: string;
|
|
32
|
+
/** Whether the flag is globally enabled (cultivated) */
|
|
33
|
+
enabled: boolean;
|
|
34
|
+
/** Whether the flag is only available to greenhouse tenants */
|
|
35
|
+
greenhouseOnly: boolean;
|
|
36
|
+
/** The type of flag value */
|
|
37
|
+
flagType: FlagType;
|
|
38
|
+
/** Default value when no rules match */
|
|
39
|
+
defaultValue: unknown;
|
|
40
|
+
/** Cache TTL in seconds (0 = no cache) */
|
|
41
|
+
cacheTtl: number;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Get all feature flags for admin display.
|
|
45
|
+
*
|
|
46
|
+
* @param env - Cloudflare environment bindings
|
|
47
|
+
* @returns Array of flag summaries sorted by name
|
|
48
|
+
*/
|
|
49
|
+
export declare function getFeatureFlags(env: FeatureFlagsEnv): Promise<FeatureFlagSummary[]>;
|
|
50
|
+
/**
|
|
51
|
+
* Toggle a flag's enabled status (cultivate/prune).
|
|
52
|
+
*
|
|
53
|
+
* When enabled = true (cultivate): Flag rules are evaluated normally
|
|
54
|
+
* When enabled = false (prune): Flag always returns default_value
|
|
55
|
+
*
|
|
56
|
+
* @param flagId - The flag identifier
|
|
57
|
+
* @param enabled - Whether to enable (cultivate) or disable (prune)
|
|
58
|
+
* @param env - Cloudflare environment bindings
|
|
59
|
+
* @returns True if the update succeeded
|
|
60
|
+
*/
|
|
61
|
+
export declare function setFlagEnabled(flagId: string, enabled: boolean, env: FeatureFlagsEnv): Promise<boolean>;
|
|
62
|
+
/**
|
|
63
|
+
* Get a single flag's summary by ID.
|
|
64
|
+
*
|
|
65
|
+
* @param flagId - The flag identifier
|
|
66
|
+
* @param env - Cloudflare environment bindings
|
|
67
|
+
* @returns The flag summary or null if not found
|
|
68
|
+
*/
|
|
69
|
+
export declare function getFeatureFlag(flagId: string, env: FeatureFlagsEnv): Promise<FeatureFlagSummary | null>;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feature Flags Admin API
|
|
3
|
+
*
|
|
4
|
+
* Functions for managing feature flags in the admin UI.
|
|
5
|
+
* Provides simple global enable/disable (cultivate/prune) controls.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* import { getFeatureFlags, setFlagEnabled } from './admin';
|
|
10
|
+
*
|
|
11
|
+
* // Get all flags for display
|
|
12
|
+
* const flags = await getFeatureFlags(env);
|
|
13
|
+
*
|
|
14
|
+
* // Cultivate (enable globally)
|
|
15
|
+
* await setFlagEnabled('jxl_encoding', true, env);
|
|
16
|
+
*
|
|
17
|
+
* // Prune (disable globally)
|
|
18
|
+
* await setFlagEnabled('jxl_encoding', false, env);
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
import { invalidateFlag } from "./cache.js";
|
|
22
|
+
// =============================================================================
|
|
23
|
+
// ADMIN FUNCTIONS
|
|
24
|
+
// =============================================================================
|
|
25
|
+
/**
|
|
26
|
+
* Get all feature flags for admin display.
|
|
27
|
+
*
|
|
28
|
+
* @param env - Cloudflare environment bindings
|
|
29
|
+
* @returns Array of flag summaries sorted by name
|
|
30
|
+
*/
|
|
31
|
+
export async function getFeatureFlags(env) {
|
|
32
|
+
try {
|
|
33
|
+
const result = await env.DB.prepare(`SELECT id, name, description, flag_type, default_value, enabled, greenhouse_only, cache_ttl
|
|
34
|
+
FROM feature_flags
|
|
35
|
+
ORDER BY name ASC`).all();
|
|
36
|
+
return (result.results ?? []).map(rowToFlagSummary);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
console.error("Failed to load feature flags:", error);
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Toggle a flag's enabled status (cultivate/prune).
|
|
45
|
+
*
|
|
46
|
+
* When enabled = true (cultivate): Flag rules are evaluated normally
|
|
47
|
+
* When enabled = false (prune): Flag always returns default_value
|
|
48
|
+
*
|
|
49
|
+
* @param flagId - The flag identifier
|
|
50
|
+
* @param enabled - Whether to enable (cultivate) or disable (prune)
|
|
51
|
+
* @param env - Cloudflare environment bindings
|
|
52
|
+
* @returns True if the update succeeded
|
|
53
|
+
*/
|
|
54
|
+
export async function setFlagEnabled(flagId, enabled, env) {
|
|
55
|
+
try {
|
|
56
|
+
const result = await env.DB.prepare(`UPDATE feature_flags
|
|
57
|
+
SET enabled = ?, updated_at = datetime('now')
|
|
58
|
+
WHERE id = ?`)
|
|
59
|
+
.bind(enabled ? 1 : 0, flagId)
|
|
60
|
+
.run();
|
|
61
|
+
if (result.meta.changes === 0) {
|
|
62
|
+
// Flag not found
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
// Invalidate the cache so the change takes effect immediately
|
|
66
|
+
await invalidateFlag(flagId, env);
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
console.error(`Failed to update flag ${flagId}:`, error);
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Get a single flag's summary by ID.
|
|
76
|
+
*
|
|
77
|
+
* @param flagId - The flag identifier
|
|
78
|
+
* @param env - Cloudflare environment bindings
|
|
79
|
+
* @returns The flag summary or null if not found
|
|
80
|
+
*/
|
|
81
|
+
export async function getFeatureFlag(flagId, env) {
|
|
82
|
+
try {
|
|
83
|
+
const result = await env.DB.prepare(`SELECT id, name, description, flag_type, default_value, enabled, greenhouse_only, cache_ttl
|
|
84
|
+
FROM feature_flags
|
|
85
|
+
WHERE id = ?`)
|
|
86
|
+
.bind(flagId)
|
|
87
|
+
.first();
|
|
88
|
+
return result ? rowToFlagSummary(result) : null;
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
console.error(`Failed to load flag ${flagId}:`, error);
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// =============================================================================
|
|
96
|
+
// HELPERS
|
|
97
|
+
// =============================================================================
|
|
98
|
+
/**
|
|
99
|
+
* Convert a database row to a FeatureFlagSummary.
|
|
100
|
+
*/
|
|
101
|
+
function rowToFlagSummary(row) {
|
|
102
|
+
let defaultValue;
|
|
103
|
+
try {
|
|
104
|
+
defaultValue = JSON.parse(row.default_value);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
defaultValue = row.default_value;
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
id: row.id,
|
|
111
|
+
name: row.name,
|
|
112
|
+
description: row.description ?? undefined,
|
|
113
|
+
enabled: row.enabled === 1,
|
|
114
|
+
greenhouseOnly: row.greenhouse_only === 1,
|
|
115
|
+
flagType: row.flag_type,
|
|
116
|
+
defaultValue,
|
|
117
|
+
cacheTtl: row.cache_ttl ?? 300,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -99,6 +99,7 @@ function safeRowToFlag(row, rules) {
|
|
|
99
99
|
// Fall back to false if default_value JSON is malformed
|
|
100
100
|
defaultValue: defaultValue ?? false,
|
|
101
101
|
enabled: row.enabled === 1,
|
|
102
|
+
greenhouseOnly: row.greenhouse_only === 1,
|
|
102
103
|
cacheTtl: row.cache_ttl ?? undefined,
|
|
103
104
|
rules,
|
|
104
105
|
createdAt: new Date(row.created_at),
|
|
@@ -159,6 +160,21 @@ export async function evaluateFlag(flagId, context, env) {
|
|
|
159
160
|
await cacheResult(cacheKey, result, env, flag.cacheTtl);
|
|
160
161
|
return result;
|
|
161
162
|
}
|
|
163
|
+
// 3.5. Check greenhouse_only gate
|
|
164
|
+
// If flag is greenhouse_only and tenant is NOT in greenhouse, return false immediately
|
|
165
|
+
if (flag.greenhouseOnly && context.inGreenhouse !== true) {
|
|
166
|
+
const result = {
|
|
167
|
+
value: false,
|
|
168
|
+
flagId,
|
|
169
|
+
matched: false,
|
|
170
|
+
evaluatedAt: new Date(),
|
|
171
|
+
cached: false,
|
|
172
|
+
};
|
|
173
|
+
// Cache the rejection (tenant won't suddenly join greenhouse mid-session)
|
|
174
|
+
const cacheKey = buildCacheKey(flagId, context);
|
|
175
|
+
await cacheResult(cacheKey, result, env, flag.cacheTtl);
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
162
178
|
// 4. Evaluate rules in priority order (already sorted by D1 query)
|
|
163
179
|
for (const rule of flag.rules) {
|
|
164
180
|
const matches = await evaluateRule(rule, context, flagId);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Greenhouse Program Management
|
|
3
|
+
*
|
|
4
|
+
* Functions for managing the greenhouse program - a trusted-tester tier
|
|
5
|
+
* that allows selected tenants early access to experimental features.
|
|
6
|
+
*
|
|
7
|
+
* Features greenhouse_only flag behavior:
|
|
8
|
+
* - Flags with greenhouse_only=1 are only visible to greenhouse tenants
|
|
9
|
+
* - When a flag "graduates" from greenhouse, set greenhouse_only=0
|
|
10
|
+
*
|
|
11
|
+
* @see docs/plans/feature-flags-spec.md
|
|
12
|
+
*/
|
|
13
|
+
import type { FeatureFlagsEnv, GreenhouseTenant } from "./types.js";
|
|
14
|
+
/**
|
|
15
|
+
* Check if a tenant is enrolled in the greenhouse program.
|
|
16
|
+
* Uses KV caching for performance (60s TTL).
|
|
17
|
+
*
|
|
18
|
+
* @param tenantId - The tenant ID to check
|
|
19
|
+
* @param env - Cloudflare environment bindings
|
|
20
|
+
* @returns True if tenant is in greenhouse and enabled
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```typescript
|
|
24
|
+
* const inGreenhouse = await isInGreenhouse(locals.tenant.id, platform.env);
|
|
25
|
+
* if (inGreenhouse) {
|
|
26
|
+
* // Show experimental features
|
|
27
|
+
* }
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export declare function isInGreenhouse(tenantId: string, env: FeatureFlagsEnv): Promise<boolean>;
|
|
31
|
+
/**
|
|
32
|
+
* Invalidate the greenhouse cache for a specific tenant.
|
|
33
|
+
* Call this after enrollment changes.
|
|
34
|
+
*
|
|
35
|
+
* @param tenantId - The tenant ID to invalidate
|
|
36
|
+
* @param env - Cloudflare environment bindings
|
|
37
|
+
*/
|
|
38
|
+
export declare function invalidateGreenhouseCache(tenantId: string, env: FeatureFlagsEnv): Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* Get all tenants enrolled in the greenhouse program.
|
|
41
|
+
* Used by the admin UI to display the enrollment table.
|
|
42
|
+
*
|
|
43
|
+
* @param env - Cloudflare environment bindings
|
|
44
|
+
* @returns Array of greenhouse tenant records
|
|
45
|
+
*/
|
|
46
|
+
export declare function getGreenhouseTenants(env: FeatureFlagsEnv): Promise<GreenhouseTenant[]>;
|
|
47
|
+
/**
|
|
48
|
+
* Get a single greenhouse tenant by ID.
|
|
49
|
+
*
|
|
50
|
+
* @param tenantId - The tenant ID to lookup
|
|
51
|
+
* @param env - Cloudflare environment bindings
|
|
52
|
+
* @returns The greenhouse tenant or null if not enrolled
|
|
53
|
+
*/
|
|
54
|
+
export declare function getGreenhouseTenant(tenantId: string, env: FeatureFlagsEnv): Promise<GreenhouseTenant | null>;
|
|
55
|
+
/**
|
|
56
|
+
* Enroll a tenant in the greenhouse program.
|
|
57
|
+
*
|
|
58
|
+
* @param tenantId - The tenant ID to enroll
|
|
59
|
+
* @param enrolledBy - Email/name of the person enrolling the tenant
|
|
60
|
+
* @param notes - Optional notes about why this tenant was enrolled
|
|
61
|
+
* @param env - Cloudflare environment bindings
|
|
62
|
+
* @returns True if enrollment succeeded
|
|
63
|
+
*/
|
|
64
|
+
export declare function enrollInGreenhouse(tenantId: string, enrolledBy: string | undefined, notes: string | undefined, env: FeatureFlagsEnv): Promise<boolean>;
|
|
65
|
+
/**
|
|
66
|
+
* Remove a tenant from the greenhouse program.
|
|
67
|
+
*
|
|
68
|
+
* @param tenantId - The tenant ID to remove
|
|
69
|
+
* @param env - Cloudflare environment bindings
|
|
70
|
+
* @returns True if removal succeeded
|
|
71
|
+
*/
|
|
72
|
+
export declare function removeFromGreenhouse(tenantId: string, env: FeatureFlagsEnv): Promise<boolean>;
|
|
73
|
+
/**
|
|
74
|
+
* Toggle a tenant's greenhouse status (enabled/disabled).
|
|
75
|
+
* Disabled tenants remain in the table but don't receive greenhouse features.
|
|
76
|
+
*
|
|
77
|
+
* @param tenantId - The tenant ID to toggle
|
|
78
|
+
* @param enabled - Whether to enable or disable
|
|
79
|
+
* @param env - Cloudflare environment bindings
|
|
80
|
+
* @returns True if toggle succeeded
|
|
81
|
+
*/
|
|
82
|
+
export declare function toggleGreenhouseStatus(tenantId: string, enabled: boolean, env: FeatureFlagsEnv): Promise<boolean>;
|
|
83
|
+
/**
|
|
84
|
+
* Update notes for a greenhouse tenant.
|
|
85
|
+
*
|
|
86
|
+
* @param tenantId - The tenant ID to update
|
|
87
|
+
* @param notes - New notes (can be null to clear)
|
|
88
|
+
* @param env - Cloudflare environment bindings
|
|
89
|
+
* @returns True if update succeeded
|
|
90
|
+
*/
|
|
91
|
+
export declare function updateGreenhouseNotes(tenantId: string, notes: string | null, env: FeatureFlagsEnv): Promise<boolean>;
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Greenhouse Program Management
|
|
3
|
+
*
|
|
4
|
+
* Functions for managing the greenhouse program - a trusted-tester tier
|
|
5
|
+
* that allows selected tenants early access to experimental features.
|
|
6
|
+
*
|
|
7
|
+
* Features greenhouse_only flag behavior:
|
|
8
|
+
* - Flags with greenhouse_only=1 are only visible to greenhouse tenants
|
|
9
|
+
* - When a flag "graduates" from greenhouse, set greenhouse_only=0
|
|
10
|
+
*
|
|
11
|
+
* @see docs/plans/feature-flags-spec.md
|
|
12
|
+
*/
|
|
13
|
+
// =============================================================================
|
|
14
|
+
// CONSTANTS
|
|
15
|
+
// =============================================================================
|
|
16
|
+
/** Cache TTL for greenhouse membership checks (60 seconds) */
|
|
17
|
+
const GREENHOUSE_CACHE_TTL = 60;
|
|
18
|
+
/** KV key prefix for greenhouse caching */
|
|
19
|
+
const GREENHOUSE_CACHE_PREFIX = "greenhouse:";
|
|
20
|
+
// =============================================================================
|
|
21
|
+
// GREENHOUSE STATUS CHECKS
|
|
22
|
+
// =============================================================================
|
|
23
|
+
/**
|
|
24
|
+
* Check if a tenant is enrolled in the greenhouse program.
|
|
25
|
+
* Uses KV caching for performance (60s TTL).
|
|
26
|
+
*
|
|
27
|
+
* @param tenantId - The tenant ID to check
|
|
28
|
+
* @param env - Cloudflare environment bindings
|
|
29
|
+
* @returns True if tenant is in greenhouse and enabled
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```typescript
|
|
33
|
+
* const inGreenhouse = await isInGreenhouse(locals.tenant.id, platform.env);
|
|
34
|
+
* if (inGreenhouse) {
|
|
35
|
+
* // Show experimental features
|
|
36
|
+
* }
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export async function isInGreenhouse(tenantId, env) {
|
|
40
|
+
if (!tenantId)
|
|
41
|
+
return false;
|
|
42
|
+
// Check cache first
|
|
43
|
+
const cacheKey = `${GREENHOUSE_CACHE_PREFIX}${tenantId}`;
|
|
44
|
+
try {
|
|
45
|
+
const cached = await env.FLAGS_KV.get(cacheKey);
|
|
46
|
+
if (cached !== null) {
|
|
47
|
+
return cached === "true";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Cache read failed - continue to DB query
|
|
52
|
+
}
|
|
53
|
+
// Query database
|
|
54
|
+
try {
|
|
55
|
+
const result = await env.DB.prepare("SELECT enabled FROM greenhouse_tenants WHERE tenant_id = ? AND enabled = 1")
|
|
56
|
+
.bind(tenantId)
|
|
57
|
+
.first();
|
|
58
|
+
const inGreenhouse = result !== null;
|
|
59
|
+
// Cache the result
|
|
60
|
+
try {
|
|
61
|
+
await env.FLAGS_KV.put(cacheKey, inGreenhouse ? "true" : "false", {
|
|
62
|
+
expirationTtl: GREENHOUSE_CACHE_TTL,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// Cache write failed - not critical
|
|
67
|
+
}
|
|
68
|
+
return inGreenhouse;
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
console.error(`Failed to check greenhouse status for ${tenantId}:`, error);
|
|
72
|
+
return false; // Fail safe - don't grant greenhouse access on error
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Invalidate the greenhouse cache for a specific tenant.
|
|
77
|
+
* Call this after enrollment changes.
|
|
78
|
+
*
|
|
79
|
+
* @param tenantId - The tenant ID to invalidate
|
|
80
|
+
* @param env - Cloudflare environment bindings
|
|
81
|
+
*/
|
|
82
|
+
export async function invalidateGreenhouseCache(tenantId, env) {
|
|
83
|
+
const cacheKey = `${GREENHOUSE_CACHE_PREFIX}${tenantId}`;
|
|
84
|
+
try {
|
|
85
|
+
await env.FLAGS_KV.delete(cacheKey);
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
// Log cache invalidation failures - stale cache could cause confusing behavior
|
|
89
|
+
// where tenant thinks they're enrolled but cache says otherwise for up to 60s
|
|
90
|
+
console.warn(`Failed to invalidate greenhouse cache for ${tenantId}:`, error);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// =============================================================================
|
|
94
|
+
// GREENHOUSE MANAGEMENT
|
|
95
|
+
// =============================================================================
|
|
96
|
+
/**
|
|
97
|
+
* Get all tenants enrolled in the greenhouse program.
|
|
98
|
+
* Used by the admin UI to display the enrollment table.
|
|
99
|
+
*
|
|
100
|
+
* @param env - Cloudflare environment bindings
|
|
101
|
+
* @returns Array of greenhouse tenant records
|
|
102
|
+
*/
|
|
103
|
+
export async function getGreenhouseTenants(env) {
|
|
104
|
+
try {
|
|
105
|
+
const result = await env.DB.prepare(`SELECT tenant_id, enabled, enrolled_at, enrolled_by, notes
|
|
106
|
+
FROM greenhouse_tenants
|
|
107
|
+
ORDER BY enrolled_at DESC`).all();
|
|
108
|
+
return (result.results ?? []).map(rowToGreenhouseTenant);
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
console.error("Failed to load greenhouse tenants:", error);
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Get a single greenhouse tenant by ID.
|
|
117
|
+
*
|
|
118
|
+
* @param tenantId - The tenant ID to lookup
|
|
119
|
+
* @param env - Cloudflare environment bindings
|
|
120
|
+
* @returns The greenhouse tenant or null if not enrolled
|
|
121
|
+
*/
|
|
122
|
+
export async function getGreenhouseTenant(tenantId, env) {
|
|
123
|
+
try {
|
|
124
|
+
const result = await env.DB.prepare(`SELECT tenant_id, enabled, enrolled_at, enrolled_by, notes
|
|
125
|
+
FROM greenhouse_tenants
|
|
126
|
+
WHERE tenant_id = ?`)
|
|
127
|
+
.bind(tenantId)
|
|
128
|
+
.first();
|
|
129
|
+
return result ? rowToGreenhouseTenant(result) : null;
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
console.error(`Failed to load greenhouse tenant ${tenantId}:`, error);
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Enroll a tenant in the greenhouse program.
|
|
138
|
+
*
|
|
139
|
+
* @param tenantId - The tenant ID to enroll
|
|
140
|
+
* @param enrolledBy - Email/name of the person enrolling the tenant
|
|
141
|
+
* @param notes - Optional notes about why this tenant was enrolled
|
|
142
|
+
* @param env - Cloudflare environment bindings
|
|
143
|
+
* @returns True if enrollment succeeded
|
|
144
|
+
*/
|
|
145
|
+
export async function enrollInGreenhouse(tenantId, enrolledBy, notes, env) {
|
|
146
|
+
try {
|
|
147
|
+
await env.DB.prepare(`INSERT OR REPLACE INTO greenhouse_tenants (tenant_id, enabled, enrolled_at, enrolled_by, notes)
|
|
148
|
+
VALUES (?, 1, datetime('now'), ?, ?)`)
|
|
149
|
+
.bind(tenantId, enrolledBy ?? null, notes ?? null)
|
|
150
|
+
.run();
|
|
151
|
+
// Invalidate cache
|
|
152
|
+
await invalidateGreenhouseCache(tenantId, env);
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
console.error(`Failed to enroll tenant ${tenantId} in greenhouse:`, error);
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Remove a tenant from the greenhouse program.
|
|
162
|
+
*
|
|
163
|
+
* @param tenantId - The tenant ID to remove
|
|
164
|
+
* @param env - Cloudflare environment bindings
|
|
165
|
+
* @returns True if removal succeeded
|
|
166
|
+
*/
|
|
167
|
+
export async function removeFromGreenhouse(tenantId, env) {
|
|
168
|
+
try {
|
|
169
|
+
await env.DB.prepare("DELETE FROM greenhouse_tenants WHERE tenant_id = ?")
|
|
170
|
+
.bind(tenantId)
|
|
171
|
+
.run();
|
|
172
|
+
// Invalidate cache
|
|
173
|
+
await invalidateGreenhouseCache(tenantId, env);
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
console.error(`Failed to remove tenant ${tenantId} from greenhouse:`, error);
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Toggle a tenant's greenhouse status (enabled/disabled).
|
|
183
|
+
* Disabled tenants remain in the table but don't receive greenhouse features.
|
|
184
|
+
*
|
|
185
|
+
* @param tenantId - The tenant ID to toggle
|
|
186
|
+
* @param enabled - Whether to enable or disable
|
|
187
|
+
* @param env - Cloudflare environment bindings
|
|
188
|
+
* @returns True if toggle succeeded
|
|
189
|
+
*/
|
|
190
|
+
export async function toggleGreenhouseStatus(tenantId, enabled, env) {
|
|
191
|
+
try {
|
|
192
|
+
const result = await env.DB.prepare("UPDATE greenhouse_tenants SET enabled = ? WHERE tenant_id = ?")
|
|
193
|
+
.bind(enabled ? 1 : 0, tenantId)
|
|
194
|
+
.run();
|
|
195
|
+
if (result.meta.changes === 0) {
|
|
196
|
+
// Tenant not in greenhouse
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
// Invalidate cache
|
|
200
|
+
await invalidateGreenhouseCache(tenantId, env);
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
console.error(`Failed to toggle greenhouse status for ${tenantId}:`, error);
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Update notes for a greenhouse tenant.
|
|
210
|
+
*
|
|
211
|
+
* @param tenantId - The tenant ID to update
|
|
212
|
+
* @param notes - New notes (can be null to clear)
|
|
213
|
+
* @param env - Cloudflare environment bindings
|
|
214
|
+
* @returns True if update succeeded
|
|
215
|
+
*/
|
|
216
|
+
export async function updateGreenhouseNotes(tenantId, notes, env) {
|
|
217
|
+
try {
|
|
218
|
+
const result = await env.DB.prepare("UPDATE greenhouse_tenants SET notes = ? WHERE tenant_id = ?")
|
|
219
|
+
.bind(notes, tenantId)
|
|
220
|
+
.run();
|
|
221
|
+
return result.meta.changes > 0;
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
console.error(`Failed to update greenhouse notes for ${tenantId}:`, error);
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// =============================================================================
|
|
229
|
+
// HELPERS
|
|
230
|
+
// =============================================================================
|
|
231
|
+
/**
|
|
232
|
+
* Convert a database row to a GreenhouseTenant object.
|
|
233
|
+
*/
|
|
234
|
+
function rowToGreenhouseTenant(row) {
|
|
235
|
+
return {
|
|
236
|
+
tenantId: row.tenant_id,
|
|
237
|
+
enabled: row.enabled === 1,
|
|
238
|
+
enrolledAt: new Date(row.enrolled_at),
|
|
239
|
+
enrolledBy: row.enrolled_by ?? undefined,
|
|
240
|
+
notes: row.notes ?? undefined,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
@@ -121,7 +121,9 @@ export declare function getFlag<T = unknown>(flagId: string, context: Evaluation
|
|
|
121
121
|
* ```
|
|
122
122
|
*/
|
|
123
123
|
export declare function getFlags(flagIds: string[], context: EvaluationContext, env: FeatureFlagsEnv): Promise<Map<string, EvaluationResult>>;
|
|
124
|
-
export type { EvaluationContext, EvaluationResult, FeatureFlag, FeatureFlagsEnv, FlagRule, FlagType, RuleType, RuleCondition, TenantRuleCondition, TierRuleCondition, PercentageRuleCondition, UserRuleCondition, TimeRuleCondition, AuditAction, FlagAuditEntry, } from "./types.js";
|
|
124
|
+
export type { EvaluationContext, EvaluationResult, FeatureFlag, FeatureFlagsEnv, FlagRule, FlagType, RuleType, RuleCondition, TenantRuleCondition, TierRuleCondition, PercentageRuleCondition, UserRuleCondition, TimeRuleCondition, GreenhouseRuleCondition, AuditAction, FlagAuditEntry, GreenhouseTenant, } from "./types.js";
|
|
125
125
|
export { invalidateFlag, invalidateAllFlags } from "./cache.js";
|
|
126
126
|
export { isTierAtLeast, getTiersAtLeast } from "./rules.js";
|
|
127
127
|
export { getUserBucket, getUserBucketSync } from "./percentage.js";
|
|
128
|
+
export { isInGreenhouse, getGreenhouseTenants, getGreenhouseTenant, enrollInGreenhouse, removeFromGreenhouse, toggleGreenhouseStatus, updateGreenhouseNotes, invalidateGreenhouseCache, } from "./greenhouse.js";
|
|
129
|
+
export { getFeatureFlags, getFeatureFlag, setFlagEnabled, type FeatureFlagSummary, } from "./admin.js";
|
|
@@ -147,3 +147,7 @@ export { invalidateFlag, invalidateAllFlags } from "./cache.js";
|
|
|
147
147
|
export { isTierAtLeast, getTiersAtLeast } from "./rules.js";
|
|
148
148
|
// Percentage utilities (for debugging)
|
|
149
149
|
export { getUserBucket, getUserBucketSync } from "./percentage.js";
|
|
150
|
+
// Greenhouse management
|
|
151
|
+
export { isInGreenhouse, getGreenhouseTenants, getGreenhouseTenant, enrollInGreenhouse, removeFromGreenhouse, toggleGreenhouseStatus, updateGreenhouseNotes, invalidateGreenhouseCache, } from "./greenhouse.js";
|
|
152
|
+
// Admin functions (Cultivate Mode)
|
|
153
|
+
export { getFeatureFlags, getFeatureFlag, setFlagEnabled, } from "./admin.js";
|
|
@@ -80,8 +80,7 @@ export function getUserBucketSync(flagId, identifier, salt = "") {
|
|
|
80
80
|
let hash = 0x811c9dc5;
|
|
81
81
|
for (let i = 0; i < input.length; i++) {
|
|
82
82
|
hash ^= input.charCodeAt(i);
|
|
83
|
-
hash =
|
|
84
|
-
(hash * 0x01000193) >>> 0; // FNV prime, keep as 32-bit unsigned
|
|
83
|
+
hash = (hash * 0x01000193) >>> 0; // FNV prime, keep as 32-bit unsigned
|
|
85
84
|
}
|
|
86
85
|
return hash % 100;
|
|
87
86
|
}
|
|
@@ -29,6 +29,8 @@ export async function evaluateRule(rule, context, flagId) {
|
|
|
29
29
|
return evaluateUserRule(rule.ruleValue, context);
|
|
30
30
|
case "time":
|
|
31
31
|
return evaluateTimeRule(rule.ruleValue);
|
|
32
|
+
case "greenhouse":
|
|
33
|
+
return context.inGreenhouse === true;
|
|
32
34
|
case "always":
|
|
33
35
|
return true;
|
|
34
36
|
default:
|
|
@@ -87,6 +87,7 @@ export function createFlagRow(id, options = {}) {
|
|
|
87
87
|
flag_type: options.flag_type ?? "boolean",
|
|
88
88
|
default_value: options.default_value ?? "false",
|
|
89
89
|
enabled: options.enabled ?? 1,
|
|
90
|
+
greenhouse_only: options.greenhouse_only ?? 0,
|
|
90
91
|
cache_ttl: options.cache_ttl ?? null,
|
|
91
92
|
created_at: options.created_at ?? new Date().toISOString(),
|
|
92
93
|
updated_at: options.updated_at ?? new Date().toISOString(),
|