@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.
Files changed (54) hide show
  1. package/dist/components/admin/MarkdownEditor.svelte +15 -11
  2. package/dist/components/admin/VoiceInput.svelte +4 -3
  3. package/dist/feature-flags/admin.d.ts +69 -0
  4. package/dist/feature-flags/admin.js +119 -0
  5. package/dist/feature-flags/evaluate.js +16 -0
  6. package/dist/feature-flags/greenhouse.d.ts +91 -0
  7. package/dist/feature-flags/greenhouse.js +242 -0
  8. package/dist/feature-flags/index.d.ts +3 -1
  9. package/dist/feature-flags/index.js +4 -0
  10. package/dist/feature-flags/percentage.js +1 -2
  11. package/dist/feature-flags/rules.js +2 -0
  12. package/dist/feature-flags/test-utils.js +1 -0
  13. package/dist/feature-flags/types.d.ts +30 -2
  14. package/dist/grafts/greenhouse/CultivateFlagRow.svelte +116 -0
  15. package/dist/grafts/greenhouse/CultivateFlagRow.svelte.d.ts +18 -0
  16. package/dist/grafts/greenhouse/CultivateFlagTable.svelte +105 -0
  17. package/dist/grafts/greenhouse/CultivateFlagTable.svelte.d.ts +18 -0
  18. package/dist/grafts/greenhouse/GreenhouseEnrollDialog.svelte +264 -0
  19. package/dist/grafts/greenhouse/GreenhouseEnrollDialog.svelte.d.ts +20 -0
  20. package/dist/grafts/greenhouse/GreenhouseEnrollTable.svelte +199 -0
  21. package/dist/grafts/greenhouse/GreenhouseEnrollTable.svelte.d.ts +20 -0
  22. package/dist/grafts/greenhouse/GreenhouseStatusCard.svelte +101 -0
  23. package/dist/grafts/greenhouse/GreenhouseStatusCard.svelte.d.ts +15 -0
  24. package/dist/grafts/greenhouse/GreenhouseToggle.svelte +123 -0
  25. package/dist/grafts/greenhouse/GreenhouseToggle.svelte.d.ts +19 -0
  26. package/dist/grafts/greenhouse/index.d.ts +52 -0
  27. package/dist/grafts/greenhouse/index.js +53 -0
  28. package/dist/grafts/greenhouse/types.d.ts +139 -0
  29. package/dist/grafts/greenhouse/types.js +7 -0
  30. package/dist/grafts/login/LoginGraft.svelte +121 -25
  31. package/dist/grafts/login/LoginGraft.svelte.d.ts +0 -32
  32. package/dist/grafts/login/config.d.ts +26 -11
  33. package/dist/grafts/login/config.js +26 -11
  34. package/dist/grafts/login/index.d.ts +5 -15
  35. package/dist/grafts/login/index.js +4 -14
  36. package/dist/grafts/login/server/callback.d.ts +14 -10
  37. package/dist/grafts/login/server/callback.js +34 -147
  38. package/dist/grafts/login/server/index.d.ts +4 -13
  39. package/dist/grafts/login/server/index.js +3 -12
  40. package/dist/grafts/login/types.d.ts +20 -35
  41. package/dist/ui/components/chrome/AdminHeader.svelte +160 -0
  42. package/dist/ui/components/chrome/AdminHeader.svelte.d.ts +25 -0
  43. package/dist/ui/components/chrome/index.d.ts +1 -0
  44. package/dist/ui/components/chrome/index.js +1 -0
  45. package/dist/ui/components/chrome/types.d.ts +10 -2
  46. package/dist/ui/components/chrome/types.js +2 -2
  47. package/dist/ui/styles/tokens.css +1 -1
  48. package/package.json +6 -1
  49. package/dist/grafts/login/LoginCard.svelte +0 -88
  50. package/dist/grafts/login/LoginCard.svelte.d.ts +0 -24
  51. package/dist/grafts/login/ProviderButton.svelte +0 -40
  52. package/dist/grafts/login/ProviderButton.svelte.d.ts +0 -18
  53. package/dist/grafts/login/server/login.d.ts +0 -32
  54. 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("write"); // Default to source/raw mode for focused writing
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
- // Load editor mode from localStorage
407
- function loadEditorMode() {
408
- if (browser) {
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
- loadEditorMode();
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(),