@adonis-agora/flags 0.0.0-stage → 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.
Files changed (322) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +15 -2
  4. package/dist/assets/spa/assets/index-DdmFo7xr.js +12 -0
  5. package/dist/assets/spa/assets/index-DdmFo7xr.js.map +1 -0
  6. package/dist/assets/spa/assets/index-DhkcP3Cw.css +2 -0
  7. package/dist/assets/spa/index.html +26 -0
  8. package/dist/commands/flags_list.d.ts +9 -0
  9. package/dist/commands/flags_list.d.ts.map +1 -0
  10. package/dist/commands/flags_list.js +51 -0
  11. package/dist/commands/flags_list.js.map +1 -0
  12. package/dist/commands/flags_ops.d.ts +60 -0
  13. package/dist/commands/flags_ops.d.ts.map +1 -0
  14. package/dist/commands/flags_ops.js +253 -0
  15. package/dist/commands/flags_ops.js.map +1 -0
  16. package/dist/commands/flags_refs.d.ts +15 -0
  17. package/dist/commands/flags_refs.d.ts.map +1 -0
  18. package/dist/commands/flags_refs.js +90 -0
  19. package/dist/commands/flags_refs.js.map +1 -0
  20. package/dist/commands/flags_set.d.ts +19 -0
  21. package/dist/commands/flags_set.d.ts.map +1 -0
  22. package/dist/commands/flags_set.js +97 -0
  23. package/dist/commands/flags_set.js.map +1 -0
  24. package/dist/commands/flags_types.d.ts +18 -0
  25. package/dist/commands/flags_types.d.ts.map +1 -0
  26. package/dist/commands/flags_types.js +94 -0
  27. package/dist/commands/flags_types.js.map +1 -0
  28. package/dist/commands/flags_work.d.ts +16 -0
  29. package/dist/commands/flags_work.d.ts.map +1 -0
  30. package/dist/commands/flags_work.js +71 -0
  31. package/dist/commands/flags_work.js.map +1 -0
  32. package/dist/commands/main.d.ts +15 -0
  33. package/dist/commands/main.d.ts.map +1 -0
  34. package/dist/commands/main.js +30 -0
  35. package/dist/commands/main.js.map +1 -0
  36. package/dist/commands/shared.d.ts +21 -0
  37. package/dist/commands/shared.d.ts.map +1 -0
  38. package/dist/commands/shared.js +70 -0
  39. package/dist/commands/shared.js.map +1 -0
  40. package/dist/configure.d.ts +11 -0
  41. package/dist/configure.d.ts.map +1 -0
  42. package/dist/configure.js +32 -0
  43. package/dist/configure.js.map +1 -0
  44. package/dist/providers/dashboard_provider.d.ts +65 -0
  45. package/dist/providers/dashboard_provider.d.ts.map +1 -0
  46. package/dist/providers/dashboard_provider.js +340 -0
  47. package/dist/providers/dashboard_provider.js.map +1 -0
  48. package/dist/providers/flags_provider.d.ts +30 -0
  49. package/dist/providers/flags_provider.d.ts.map +1 -0
  50. package/dist/providers/flags_provider.js +184 -0
  51. package/dist/providers/flags_provider.js.map +1 -0
  52. package/dist/services/main.d.ts +17 -0
  53. package/dist/services/main.d.ts.map +1 -0
  54. package/dist/services/main.js +20 -0
  55. package/dist/services/main.js.map +1 -0
  56. package/dist/src/admin.d.ts +189 -0
  57. package/dist/src/admin.d.ts.map +1 -0
  58. package/dist/src/admin.js +539 -0
  59. package/dist/src/admin.js.map +1 -0
  60. package/dist/src/analytics/backend.d.ts +18 -0
  61. package/dist/src/analytics/backend.d.ts.map +1 -0
  62. package/dist/src/analytics/backend.js +25 -0
  63. package/dist/src/analytics/backend.js.map +1 -0
  64. package/dist/src/analytics/clickhouse_backend.d.ts +16 -0
  65. package/dist/src/analytics/clickhouse_backend.d.ts.map +1 -0
  66. package/dist/src/analytics/clickhouse_backend.js +48 -0
  67. package/dist/src/analytics/clickhouse_backend.js.map +1 -0
  68. package/dist/src/analytics/duckdb.d.ts +39 -0
  69. package/dist/src/analytics/duckdb.d.ts.map +1 -0
  70. package/dist/src/analytics/duckdb.js +117 -0
  71. package/dist/src/analytics/duckdb.js.map +1 -0
  72. package/dist/src/analytics/lucid_backend.d.ts +16 -0
  73. package/dist/src/analytics/lucid_backend.d.ts.map +1 -0
  74. package/dist/src/analytics/lucid_backend.js +45 -0
  75. package/dist/src/analytics/lucid_backend.js.map +1 -0
  76. package/dist/src/analytics/metrics.d.ts +50 -0
  77. package/dist/src/analytics/metrics.d.ts.map +1 -0
  78. package/dist/src/analytics/metrics.js +42 -0
  79. package/dist/src/analytics/metrics.js.map +1 -0
  80. package/dist/src/analytics/results.d.ts +80 -0
  81. package/dist/src/analytics/results.d.ts.map +1 -0
  82. package/dist/src/analytics/results.js +143 -0
  83. package/dist/src/analytics/results.js.map +1 -0
  84. package/dist/src/dashboard/access_denied_page.d.ts +100 -0
  85. package/dist/src/dashboard/access_denied_page.d.ts.map +1 -0
  86. package/dist/src/dashboard/access_denied_page.js +262 -0
  87. package/dist/src/dashboard/access_denied_page.js.map +1 -0
  88. package/dist/src/dashboard/actor.d.ts +12 -0
  89. package/dist/src/dashboard/actor.d.ts.map +1 -0
  90. package/dist/src/dashboard/actor.js +33 -0
  91. package/dist/src/dashboard/actor.js.map +1 -0
  92. package/dist/src/dashboard/authorize_decision.d.ts +48 -0
  93. package/dist/src/dashboard/authorize_decision.d.ts.map +1 -0
  94. package/dist/src/dashboard/authorize_decision.js +44 -0
  95. package/dist/src/dashboard/authorize_decision.js.map +1 -0
  96. package/dist/src/dashboard/console_authorize.d.ts +77 -0
  97. package/dist/src/dashboard/console_authorize.d.ts.map +1 -0
  98. package/dist/src/dashboard/console_authorize.js +104 -0
  99. package/dist/src/dashboard/console_authorize.js.map +1 -0
  100. package/dist/src/dashboard/define_config.d.ts +71 -0
  101. package/dist/src/dashboard/define_config.d.ts.map +1 -0
  102. package/dist/src/dashboard/define_config.js +18 -0
  103. package/dist/src/dashboard/define_config.js.map +1 -0
  104. package/dist/src/dashboard/handlers.d.ts +172 -0
  105. package/dist/src/dashboard/handlers.d.ts.map +1 -0
  106. package/dist/src/dashboard/handlers.js +949 -0
  107. package/dist/src/dashboard/handlers.js.map +1 -0
  108. package/dist/src/dashboard/index.d.ts +15 -0
  109. package/dist/src/dashboard/index.d.ts.map +1 -0
  110. package/dist/src/dashboard/index.js +10 -0
  111. package/dist/src/dashboard/index.js.map +1 -0
  112. package/dist/src/dashboard/shape.d.ts +12 -0
  113. package/dist/src/dashboard/shape.d.ts.map +1 -0
  114. package/dist/src/dashboard/shape.js +232 -0
  115. package/dist/src/dashboard/shape.js.map +1 -0
  116. package/dist/src/dashboard/spa.d.ts +34 -0
  117. package/dist/src/dashboard/spa.d.ts.map +1 -0
  118. package/dist/src/dashboard/spa.js +50 -0
  119. package/dist/src/dashboard/spa.js.map +1 -0
  120. package/dist/src/declare.d.ts +85 -0
  121. package/dist/src/declare.d.ts.map +1 -0
  122. package/dist/src/declare.js +62 -0
  123. package/dist/src/declare.js.map +1 -0
  124. package/dist/src/define_config.d.ts +256 -0
  125. package/dist/src/define_config.d.ts.map +1 -0
  126. package/dist/src/define_config.js +90 -0
  127. package/dist/src/define_config.js.map +1 -0
  128. package/dist/src/durable.d.ts +29 -0
  129. package/dist/src/durable.d.ts.map +1 -0
  130. package/dist/src/durable.js +55 -0
  131. package/dist/src/durable.js.map +1 -0
  132. package/dist/src/enrichers.d.ts +51 -0
  133. package/dist/src/enrichers.d.ts.map +1 -0
  134. package/dist/src/enrichers.js +105 -0
  135. package/dist/src/enrichers.js.map +1 -0
  136. package/dist/src/events/buffer.d.ts +38 -0
  137. package/dist/src/events/buffer.d.ts.map +1 -0
  138. package/dist/src/events/buffer.js +86 -0
  139. package/dist/src/events/buffer.js.map +1 -0
  140. package/dist/src/events/event.d.ts +42 -0
  141. package/dist/src/events/event.d.ts.map +1 -0
  142. package/dist/src/events/event.js +82 -0
  143. package/dist/src/events/event.js.map +1 -0
  144. package/dist/src/http/browser.d.ts +33 -0
  145. package/dist/src/http/browser.d.ts.map +1 -0
  146. package/dist/src/http/browser.js +81 -0
  147. package/dist/src/http/browser.js.map +1 -0
  148. package/dist/src/http/browser_api.d.ts +48 -0
  149. package/dist/src/http/browser_api.d.ts.map +1 -0
  150. package/dist/src/http/browser_api.js +273 -0
  151. package/dist/src/http/browser_api.js.map +1 -0
  152. package/dist/src/http/browser_tokens.d.ts +27 -0
  153. package/dist/src/http/browser_tokens.d.ts.map +1 -0
  154. package/dist/src/http/browser_tokens.js +48 -0
  155. package/dist/src/http/browser_tokens.js.map +1 -0
  156. package/dist/src/http/control_plane.d.ts +58 -0
  157. package/dist/src/http/control_plane.d.ts.map +1 -0
  158. package/dist/src/http/control_plane.js +318 -0
  159. package/dist/src/http/control_plane.js.map +1 -0
  160. package/dist/src/http/csrf.d.ts +22 -0
  161. package/dist/src/http/csrf.d.ts.map +1 -0
  162. package/dist/src/http/csrf.js +24 -0
  163. package/dist/src/http/csrf.js.map +1 -0
  164. package/dist/src/http/edge_tag.d.ts +55 -0
  165. package/dist/src/http/edge_tag.d.ts.map +1 -0
  166. package/dist/src/http/edge_tag.js +45 -0
  167. package/dist/src/http/edge_tag.js.map +1 -0
  168. package/dist/src/http/flag_middleware.d.ts +27 -0
  169. package/dist/src/http/flag_middleware.d.ts.map +1 -0
  170. package/dist/src/http/flag_middleware.js +30 -0
  171. package/dist/src/http/flag_middleware.js.map +1 -0
  172. package/dist/src/http/flags_middleware.d.ts +20 -0
  173. package/dist/src/http/flags_middleware.d.ts.map +1 -0
  174. package/dist/src/http/flags_middleware.js +20 -0
  175. package/dist/src/http/flags_middleware.js.map +1 -0
  176. package/dist/src/http/subject_resolver.d.ts +45 -0
  177. package/dist/src/http/subject_resolver.d.ts.map +1 -0
  178. package/dist/src/http/subject_resolver.js +93 -0
  179. package/dist/src/http/subject_resolver.js.map +1 -0
  180. package/dist/src/identity.d.ts +82 -0
  181. package/dist/src/identity.d.ts.map +1 -0
  182. package/dist/src/identity.js +100 -0
  183. package/dist/src/identity.js.map +1 -0
  184. package/dist/src/index.d.ts +41 -0
  185. package/dist/src/index.d.ts.map +1 -0
  186. package/dist/src/index.js +34 -0
  187. package/dist/src/index.js.map +1 -0
  188. package/dist/src/manager.d.ts +154 -0
  189. package/dist/src/manager.d.ts.map +1 -0
  190. package/dist/src/manager.js +333 -0
  191. package/dist/src/manager.js.map +1 -0
  192. package/dist/src/openfeature/provider.d.ts +36 -0
  193. package/dist/src/openfeature/provider.d.ts.map +1 -0
  194. package/dist/src/openfeature/provider.js +82 -0
  195. package/dist/src/openfeature/provider.js.map +1 -0
  196. package/dist/src/relay/relay.d.ts +36 -0
  197. package/dist/src/relay/relay.d.ts.map +1 -0
  198. package/dist/src/relay/relay.js +73 -0
  199. package/dist/src/relay/relay.js.map +1 -0
  200. package/dist/src/relay/results_cache.d.ts +20 -0
  201. package/dist/src/relay/results_cache.d.ts.map +1 -0
  202. package/dist/src/relay/results_cache.js +31 -0
  203. package/dist/src/relay/results_cache.js.map +1 -0
  204. package/dist/src/relay/worker.d.ts +66 -0
  205. package/dist/src/relay/worker.d.ts.map +1 -0
  206. package/dist/src/relay/worker.js +177 -0
  207. package/dist/src/relay/worker.js.map +1 -0
  208. package/dist/src/ruleset_source.d.ts +73 -0
  209. package/dist/src/ruleset_source.d.ts.map +1 -0
  210. package/dist/src/ruleset_source.js +128 -0
  211. package/dist/src/ruleset_source.js.map +1 -0
  212. package/dist/src/runtime.d.ts +70 -0
  213. package/dist/src/runtime.d.ts.map +1 -0
  214. package/dist/src/runtime.js +301 -0
  215. package/dist/src/runtime.js.map +1 -0
  216. package/dist/src/schema.d.ts +39 -0
  217. package/dist/src/schema.d.ts.map +1 -0
  218. package/dist/src/schema.js +311 -0
  219. package/dist/src/schema.js.map +1 -0
  220. package/dist/src/services/accessor.d.ts +5 -0
  221. package/dist/src/services/accessor.d.ts.map +1 -0
  222. package/dist/src/services/accessor.js +20 -0
  223. package/dist/src/services/accessor.js.map +1 -0
  224. package/dist/src/sinks/clickhouse.d.ts +41 -0
  225. package/dist/src/sinks/clickhouse.d.ts.map +1 -0
  226. package/dist/src/sinks/clickhouse.js +117 -0
  227. package/dist/src/sinks/clickhouse.js.map +1 -0
  228. package/dist/src/sinks/lucid_sink.d.ts +25 -0
  229. package/dist/src/sinks/lucid_sink.d.ts.map +1 -0
  230. package/dist/src/sinks/lucid_sink.js +87 -0
  231. package/dist/src/sinks/lucid_sink.js.map +1 -0
  232. package/dist/src/sinks/parquet_sink.d.ts +41 -0
  233. package/dist/src/sinks/parquet_sink.d.ts.map +1 -0
  234. package/dist/src/sinks/parquet_sink.js +140 -0
  235. package/dist/src/sinks/parquet_sink.js.map +1 -0
  236. package/dist/src/sinks/posthog_sink.d.ts +29 -0
  237. package/dist/src/sinks/posthog_sink.d.ts.map +1 -0
  238. package/dist/src/sinks/posthog_sink.js +65 -0
  239. package/dist/src/sinks/posthog_sink.js.map +1 -0
  240. package/dist/src/sinks/sink.d.ts +36 -0
  241. package/dist/src/sinks/sink.d.ts.map +1 -0
  242. package/dist/src/sinks/sink.js +36 -0
  243. package/dist/src/sinks/sink.js.map +1 -0
  244. package/dist/src/sql.d.ts +33 -0
  245. package/dist/src/sql.d.ts.map +1 -0
  246. package/dist/src/sql.js +66 -0
  247. package/dist/src/sql.js.map +1 -0
  248. package/dist/src/stores/lucid_store.d.ts +55 -0
  249. package/dist/src/stores/lucid_store.d.ts.map +1 -0
  250. package/dist/src/stores/lucid_store.js +412 -0
  251. package/dist/src/stores/lucid_store.js.map +1 -0
  252. package/dist/src/stores/memory_store.d.ts +38 -0
  253. package/dist/src/stores/memory_store.d.ts.map +1 -0
  254. package/dist/src/stores/memory_store.js +166 -0
  255. package/dist/src/stores/memory_store.js.map +1 -0
  256. package/dist/src/stores/store.d.ts +166 -0
  257. package/dist/src/stores/store.d.ts.map +1 -0
  258. package/dist/src/stores/store.js +67 -0
  259. package/dist/src/stores/store.js.map +1 -0
  260. package/dist/src/subject.d.ts +25 -0
  261. package/dist/src/subject.d.ts.map +1 -0
  262. package/dist/src/subject.js +30 -0
  263. package/dist/src/subject.js.map +1 -0
  264. package/dist/src/testing/index.d.ts +31 -0
  265. package/dist/src/testing/index.d.ts.map +1 -0
  266. package/dist/src/testing/index.js +60 -0
  267. package/dist/src/testing/index.js.map +1 -0
  268. package/dist/src/topology/browser_token.d.ts +48 -0
  269. package/dist/src/topology/browser_token.d.ts.map +1 -0
  270. package/dist/src/topology/browser_token.js +128 -0
  271. package/dist/src/topology/browser_token.js.map +1 -0
  272. package/dist/src/topology/remote_source.d.ts +39 -0
  273. package/dist/src/topology/remote_source.d.ts.map +1 -0
  274. package/dist/src/topology/remote_source.js +184 -0
  275. package/dist/src/topology/remote_source.js.map +1 -0
  276. package/dist/src/topology/token.d.ts +36 -0
  277. package/dist/src/topology/token.d.ts.map +1 -0
  278. package/dist/src/topology/token.js +68 -0
  279. package/dist/src/topology/token.js.map +1 -0
  280. package/dist/src/topology/verify.d.ts +88 -0
  281. package/dist/src/topology/verify.d.ts.map +1 -0
  282. package/dist/src/topology/verify.js +128 -0
  283. package/dist/src/topology/verify.js.map +1 -0
  284. package/dist/src/transports/db_transport.d.ts +55 -0
  285. package/dist/src/transports/db_transport.d.ts.map +1 -0
  286. package/dist/src/transports/db_transport.js +202 -0
  287. package/dist/src/transports/db_transport.js.map +1 -0
  288. package/dist/src/transports/http_transport.d.ts +30 -0
  289. package/dist/src/transports/http_transport.d.ts.map +1 -0
  290. package/dist/src/transports/http_transport.js +51 -0
  291. package/dist/src/transports/http_transport.js.map +1 -0
  292. package/dist/src/transports/memory_transport.d.ts +29 -0
  293. package/dist/src/transports/memory_transport.d.ts.map +1 -0
  294. package/dist/src/transports/memory_transport.js +91 -0
  295. package/dist/src/transports/memory_transport.js.map +1 -0
  296. package/dist/src/transports/redis_transport.d.ts +50 -0
  297. package/dist/src/transports/redis_transport.d.ts.map +1 -0
  298. package/dist/src/transports/redis_transport.js +107 -0
  299. package/dist/src/transports/redis_transport.js.map +1 -0
  300. package/dist/src/transports/transport.d.ts +62 -0
  301. package/dist/src/transports/transport.d.ts.map +1 -0
  302. package/dist/src/transports/transport.js +2 -0
  303. package/dist/src/transports/transport.js.map +1 -0
  304. package/dist/src/types.d.ts +30 -0
  305. package/dist/src/types.d.ts.map +1 -0
  306. package/dist/src/types.js +2 -0
  307. package/dist/src/types.js.map +1 -0
  308. package/dist/src/usage.d.ts +18 -0
  309. package/dist/src/usage.d.ts.map +1 -0
  310. package/dist/src/usage.js +52 -0
  311. package/dist/src/usage.js.map +1 -0
  312. package/dist/stubs/config/flags.stub +50 -0
  313. package/dist/stubs/config/flags_dashboard.stub +35 -0
  314. package/dist/stubs/database/migrations/create_flags_tables.stub +27 -0
  315. package/dist/stubs/main.d.ts +3 -0
  316. package/dist/stubs/main.d.ts.map +1 -0
  317. package/dist/stubs/main.js +9 -0
  318. package/dist/stubs/main.js.map +1 -0
  319. package/package.json +178 -3
  320. package/skills/flags-basics/SKILL.md +142 -0
  321. package/skills/flags-deployment/SKILL.md +125 -0
  322. package/skills/flags-experiments/SKILL.md +99 -0
@@ -0,0 +1,949 @@
1
+ import { evaluate, OPERATORS, } from '@adonis-agora/flags-core';
2
+ import { ForbiddenError, ValidationError } from '../admin.js';
3
+ import { knownTenants } from '../http/control_plane.js';
4
+ import { getRuntime } from '../services/accessor.js';
5
+ import { applyChange, } from '../stores/store.js';
6
+ import { flagDefinitionIssues, holdoutIssues, layerIssues, rulesetIssues, segmentIssues, } from './shape.js';
7
+ export const ok = (body) => ({ status: 200, body });
8
+ /** An error that already knows its HTTP answer. */
9
+ export class HttpError extends Error {
10
+ status;
11
+ extra;
12
+ constructor(status, message, extra = {}) {
13
+ super(message);
14
+ this.status = status;
15
+ this.extra = extra;
16
+ }
17
+ }
18
+ const invalid = (issues) => new HttpError(422, 'invalid change', { issues });
19
+ /** Map anything a handler threw to the answer the SPA can show. */
20
+ export function errorResponse(error) {
21
+ if (error instanceof HttpError) {
22
+ return { status: error.status, body: { error: error.message, ...error.extra } };
23
+ }
24
+ if (error instanceof ValidationError) {
25
+ return { status: 422, body: { error: 'invalid change', issues: error.issues } };
26
+ }
27
+ if (error instanceof ForbiddenError)
28
+ return { status: 403, body: { error: error.message } };
29
+ if (error instanceof RangeError)
30
+ return { status: 400, body: { error: error.message } };
31
+ const message = error instanceof Error ? error.message : String(error);
32
+ // `FlagsAdmin` reports these as plain errors; their wording is the contract.
33
+ if (/ not found$|neither in the ruleset nor declared/.test(message)) {
34
+ return { status: 404, body: { error: message } };
35
+ }
36
+ if (/^change request \S+ is \w+$/.test(message))
37
+ return { status: 409, body: { error: message } };
38
+ if (/is not an on\/off flag|needs payload/.test(message)) {
39
+ return { status: 400, body: { error: message } };
40
+ }
41
+ return { status: 500, body: { error: message || 'internal error' } };
42
+ }
43
+ /** The runtime, and its admin + store — a tenant has neither (it edits on its control-plane). */
44
+ function owned(deps) {
45
+ const runtime = deps.runtime ?? getRuntime();
46
+ if (runtime.topology === 'tenant' || !runtime.admin || !runtime.store) {
47
+ throw new HttpError(503, 'This app is a flags tenant: flags are edited on the control-plane console, not here.');
48
+ }
49
+ return { runtime, admin: runtime.admin, store: runtime.store };
50
+ }
51
+ /** Runs a handler body and maps whatever it throws. */
52
+ async function handle(deps, fn) {
53
+ try {
54
+ return await fn(owned(deps));
55
+ }
56
+ catch (error) {
57
+ return errorResponse(error);
58
+ }
59
+ }
60
+ // ── Request parsing ─────────────────────────────────────────────────────────
61
+ function first(value) {
62
+ const v = Array.isArray(value) ? value[0] : value;
63
+ return v === undefined || v === '' ? undefined : v;
64
+ }
65
+ function bodyOf(req) {
66
+ const body = req.body;
67
+ return typeof body === 'object' && body !== null && !Array.isArray(body)
68
+ ? body
69
+ : {};
70
+ }
71
+ const ENVIRONMENT = /^[A-Za-z0-9_.-]{1,64}$/;
72
+ function checkEnvironment(value, field = 'environment') {
73
+ if (typeof value !== 'string' || !ENVIRONMENT.test(value)) {
74
+ throw new HttpError(400, `${field} must be 1–64 letters, digits, "_", "-" or "."`);
75
+ }
76
+ return value;
77
+ }
78
+ /** `environment` from the body (writes) or the query string (reads); the app's own by default. */
79
+ function environmentOf(req, runtime) {
80
+ const value = bodyOf(req).environment ?? first(req.query.environment);
81
+ return value === undefined ? runtime.manager.environment : checkEnvironment(value);
82
+ }
83
+ function keyParam(req, name = 'key') {
84
+ const key = req.params[name];
85
+ if (key === undefined || key === '' || key.length > 200) {
86
+ throw new HttpError(400, `${name} is required`);
87
+ }
88
+ return key;
89
+ }
90
+ function noteOf(req) {
91
+ const note = bodyOf(req).note;
92
+ if (note === undefined || note === null || note === '')
93
+ return undefined;
94
+ if (typeof note !== 'string')
95
+ throw new HttpError(400, 'note must be a string');
96
+ return note.slice(0, 2000);
97
+ }
98
+ function intQuery(req, name, fallback, max) {
99
+ const raw = first(req.query[name]);
100
+ if (raw === undefined)
101
+ return fallback;
102
+ const n = Number(raw);
103
+ if (!Number.isInteger(n) || n < 1)
104
+ throw new HttpError(400, `${name} must be a positive integer`);
105
+ return Math.min(n, max);
106
+ }
107
+ /** Options for `FlagsAdmin.propose` and friends, without `undefined` keys. */
108
+ function writeOptions(environment, note) {
109
+ return note !== undefined ? { environment, note } : { environment };
110
+ }
111
+ /** `200` when applied, `202` when it became a change request waiting for a second person. */
112
+ function proposed(result) {
113
+ return { status: result.status === 'applied' ? 200 : 202, body: result };
114
+ }
115
+ /** This app's declarations plus every app's pushed ones (a control-plane hears from tenants). */
116
+ async function declarationsOf(o, ruleset) {
117
+ const out = new Map();
118
+ const app = o.runtime.manager.app;
119
+ for (const [key, declaration] of Object.entries(o.admin.declarations)) {
120
+ out.set(key, { ...declaration, apps: [app], source: 'code' });
121
+ }
122
+ for (const row of await o.store.declarations()) {
123
+ const existing = out.get(row.key);
124
+ if (existing) {
125
+ if (!existing.apps.includes(row.app))
126
+ existing.apps.push(row.app);
127
+ }
128
+ else {
129
+ out.set(row.key, { ...row.declaration, apps: [row.app], source: 'code' });
130
+ }
131
+ }
132
+ // Flags created in the console: their declaration travels in the ruleset. Code wins.
133
+ for (const [key, definition] of Object.entries(ruleset.flags)) {
134
+ if (definition.declaration && !out.has(key)) {
135
+ out.set(key, { ...definition.declaration, apps: [], source: 'console' });
136
+ }
137
+ }
138
+ return out;
139
+ }
140
+ function round(n, digits = 2) {
141
+ const f = 10 ** digits;
142
+ return Math.round(n * f) / f;
143
+ }
144
+ function serveSummary(serve) {
145
+ if ('variant' in serve)
146
+ return { kind: 'fixed', variant: serve.variant };
147
+ const total = serve.split.reduce((sum, arm) => sum + Math.max(0, arm.weight), 0);
148
+ const arms = serve.split.map((arm) => ({
149
+ variant: arm.variant,
150
+ percent: total > 0 ? round((Math.max(0, arm.weight) / total) * 100) : 0,
151
+ }));
152
+ const names = arms.map((arm) => arm.variant).sort();
153
+ if (serve.bucketBy === undefined &&
154
+ names.length === 2 &&
155
+ names[0] === 'off' &&
156
+ names[1] === 'on') {
157
+ return { kind: 'rollout', percent: arms.find((arm) => arm.variant === 'on')?.percent ?? 0 };
158
+ }
159
+ return { kind: 'split', arms, ...(serve.bucketBy ? { bucketBy: serve.bucketBy } : {}) };
160
+ }
161
+ /** The one-line "what does this serve" every flag row shows. */
162
+ export function servingSummary(definition, declaration) {
163
+ if (!definition)
164
+ return { kind: 'default', value: declaration?.default ?? null };
165
+ if (!definition.enabled)
166
+ return { kind: 'off', variant: definition.offVariant };
167
+ return serveSummary(definition.fallthrough);
168
+ }
169
+ function flagTags(definition, declaration) {
170
+ return [...new Set([...(declaration?.tags ?? []), ...(definition?.tags ?? [])])].sort();
171
+ }
172
+ // ── Meta ────────────────────────────────────────────────────────────────────
173
+ /** `GET /api/meta` — what the shell needs: environments, approvals, who you are. */
174
+ export function meta(deps, _req) {
175
+ return handle(deps, async (o) => {
176
+ const current = o.runtime.manager.environment;
177
+ const environments = [...new Set([current, ...(await o.store.environments())])].sort();
178
+ const experiments = o.runtime.config.topology !== 'tenant' ? Object.keys(o.runtime.config.experiments ?? {}) : [];
179
+ return ok({
180
+ app: o.runtime.manager.app,
181
+ topology: o.runtime.topology,
182
+ environment: current,
183
+ environments,
184
+ approvals: Object.fromEntries(environments.map((e) => [e, o.admin.requiresApproval(e)])),
185
+ actor: deps.actor,
186
+ operators: OPERATORS,
187
+ analytics: o.runtime.analytics !== undefined,
188
+ experiments,
189
+ });
190
+ });
191
+ }
192
+ // ── Flags ───────────────────────────────────────────────────────────────────
193
+ /** `GET /api/flags` — every flag in the ruleset or declared in code, with what it serves. */
194
+ export function listFlags(deps, req) {
195
+ return handle(deps, async (o) => {
196
+ const environment = environmentOf(req, o.runtime);
197
+ const [ruleset, stale] = await Promise.all([
198
+ o.store.readRuleset(environment),
199
+ o.admin.stale({ environment, ...(deps.now ? { now: deps.now() } : {}) }),
200
+ ]);
201
+ const declared = await declarationsOf(o, ruleset);
202
+ const staleByKey = new Map(stale.map((s) => [s.key, s.reasons]));
203
+ const keys = [...new Set([...Object.keys(ruleset.flags), ...declared.keys()])].sort();
204
+ const flags = keys.map((key) => {
205
+ const definition = ruleset.flags[key];
206
+ const declaration = declared.get(key);
207
+ return {
208
+ key,
209
+ declaration: declaration ?? null,
210
+ inRuleset: definition !== undefined,
211
+ enabled: definition ? definition.enabled : null,
212
+ serving: servingSummary(definition, declaration),
213
+ tags: flagTags(definition, declaration),
214
+ owner: declaration?.owner ?? null,
215
+ expiresAt: declaration?.expiresAt ?? null,
216
+ experiment: definition?.experiment !== undefined,
217
+ rules: definition?.rules?.length ?? 0,
218
+ overrides: Object.keys(definition?.overrides ?? {}).length,
219
+ stale: staleByKey.get(key) ?? [],
220
+ };
221
+ });
222
+ return ok({ environment, version: ruleset.version, flags });
223
+ });
224
+ }
225
+ /** `GET /api/flags/:key` — one flag: its definition (stored, or what the declaration implies), its
226
+ * audit trail, schedules and pending requests, plus the names the editor offers. */
227
+ export function flagDetail(deps, req) {
228
+ return handle(deps, async (o) => {
229
+ const environment = environmentOf(req, o.runtime);
230
+ const key = keyParam(req);
231
+ const ruleset = await o.store.readRuleset(environment);
232
+ const declared = await declarationsOf(o, ruleset);
233
+ const stored = ruleset.flags[key];
234
+ const declaration = declared.get(key);
235
+ if (!stored && !declaration) {
236
+ throw new HttpError(404, `flag "${key}" is neither in the ruleset nor declared in code`);
237
+ }
238
+ const [audit, schedules, pending] = await Promise.all([
239
+ o.store.audit({ environment, entity: 'flag', key, limit: 50 }),
240
+ o.store.listSchedules({ environment, flag: key, limit: 100 }),
241
+ o.store.listChangeRequests({ environment, status: 'pending', limit: 200 }),
242
+ ]);
243
+ return ok({
244
+ environment,
245
+ version: ruleset.version,
246
+ key,
247
+ declaration: declaration ?? null,
248
+ stored: stored ?? null,
249
+ definition: stored ?? (await o.admin.definition(key, environment)),
250
+ serving: servingSummary(stored, declaration),
251
+ audit,
252
+ schedules,
253
+ pendingRequests: pending.filter((r) => r.change.entity === 'flag' && r.change.key === key),
254
+ requiresApproval: o.admin.requiresApproval(environment),
255
+ segments: Object.keys(ruleset.segments ?? {}).sort(),
256
+ layers: Object.keys(ruleset.layers ?? {}).sort(),
257
+ flags: Object.fromEntries(Object.entries(ruleset.flags)
258
+ .filter(([other]) => other !== key)
259
+ .map(([other, def]) => [other, Object.keys(def.variants)])),
260
+ });
261
+ });
262
+ }
263
+ /** `PUT /api/flags/:key` `{ definition, note? }` — save the whole definition. */
264
+ export function saveFlag(deps, req) {
265
+ return handle(deps, async (o) => {
266
+ const environment = environmentOf(req, o.runtime);
267
+ const key = keyParam(req);
268
+ const definition = bodyOf(req).definition;
269
+ const issues = flagDefinitionIssues(definition);
270
+ if (issues.length > 0)
271
+ throw invalid(issues);
272
+ return proposed(await o.admin.saveFlag(key, definition, deps.actor, writeOptions(environment, noteOf(req))));
273
+ });
274
+ }
275
+ const DECLARATION_TYPES = new Set(['boolean', 'string', 'number', 'json']);
276
+ /** Shape-checks a declaration from the console; deeper rules are the ruleset validator's. */
277
+ function declarationIssues(value, partial) {
278
+ const issues = [];
279
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
280
+ return [{ path: 'declaration', message: 'must be an object' }];
281
+ }
282
+ const d = value;
283
+ if (!partial || d.type !== undefined) {
284
+ if (!DECLARATION_TYPES.has(String(d.type))) {
285
+ issues.push({ path: 'declaration.type', message: 'must be boolean, string, number or json' });
286
+ }
287
+ }
288
+ if (!partial && !('default' in d))
289
+ issues.push({ path: 'declaration.default', message: 'is required' });
290
+ for (const field of ['description', 'owner', 'expiresAt']) {
291
+ if (d[field] !== undefined && typeof d[field] !== 'string') {
292
+ issues.push({ path: `declaration.${field}`, message: 'must be a string' });
293
+ }
294
+ }
295
+ for (const field of ['variants', 'tags']) {
296
+ if (d[field] !== undefined &&
297
+ (!Array.isArray(d[field]) || !d[field].every((v) => typeof v === 'string'))) {
298
+ issues.push({ path: `declaration.${field}`, message: 'must be a list of strings' });
299
+ }
300
+ }
301
+ if (d.client !== undefined && typeof d.client !== 'boolean') {
302
+ issues.push({ path: 'declaration.client', message: 'must be true or false' });
303
+ }
304
+ return issues;
305
+ }
306
+ /** `POST /api/flags` `{ key, declaration, note? }` — create a flag here, without code. */
307
+ export function createFlag(deps, req) {
308
+ return handle(deps, async (o) => {
309
+ const environment = environmentOf(req, o.runtime);
310
+ const body = bodyOf(req);
311
+ if (typeof body.key !== 'string')
312
+ throw invalid([{ path: 'key', message: 'is required' }]);
313
+ const issues = declarationIssues(body.declaration, false);
314
+ if (issues.length > 0)
315
+ throw invalid(issues);
316
+ const result = await o.admin.createFlag(body.key, body.declaration, deps.actor, writeOptions(environment, noteOf(req)));
317
+ const response = proposed(result);
318
+ return result.status === 'applied' ? { ...response, status: 201 } : response;
319
+ });
320
+ }
321
+ /** `PUT /api/flags/:key/declaration` `{ declaration, note? }` — edit a console flag's declaration. */
322
+ export function updateDeclaration(deps, req) {
323
+ return handle(deps, async (o) => {
324
+ const environment = environmentOf(req, o.runtime);
325
+ const key = keyParam(req);
326
+ const patch = bodyOf(req).declaration;
327
+ const issues = declarationIssues(patch, true);
328
+ if (issues.length > 0)
329
+ throw invalid(issues);
330
+ return proposed(await o.admin.updateDeclaration(key, patch, deps.actor, writeOptions(environment, noteOf(req))));
331
+ });
332
+ }
333
+ /** `DELETE /api/flags/:key` — remove the flag from the ruleset (the code default takes over). */
334
+ export function deleteFlag(deps, req) {
335
+ return handle(deps, async (o) => {
336
+ const environment = environmentOf(req, o.runtime);
337
+ const key = keyParam(req);
338
+ const ruleset = await o.store.readRuleset(environment);
339
+ if (!ruleset.flags[key])
340
+ throw new HttpError(404, `flag "${key}" is not in the ruleset`);
341
+ return proposed(await o.admin.deleteFlag(key, deps.actor, writeOptions(environment, noteOf(req))));
342
+ });
343
+ }
344
+ /** `POST /api/flags/:key/enabled` `{ enabled }` — the kill switch. */
345
+ export function setEnabled(deps, req) {
346
+ return handle(deps, async (o) => {
347
+ const environment = environmentOf(req, o.runtime);
348
+ const key = keyParam(req);
349
+ const enabled = bodyOf(req).enabled;
350
+ if (typeof enabled !== 'boolean')
351
+ throw new HttpError(400, 'enabled must be a boolean');
352
+ return proposed(await o.admin.setEnabled(key, enabled, deps.actor, writeOptions(environment, noteOf(req))));
353
+ });
354
+ }
355
+ /** `POST /api/flags/:key/rollout` `{ percent }` — on/off flags only. */
356
+ export function setRollout(deps, req) {
357
+ return handle(deps, async (o) => {
358
+ const environment = environmentOf(req, o.runtime);
359
+ const key = keyParam(req);
360
+ const percent = bodyOf(req).percent;
361
+ if (typeof percent !== 'number' || !Number.isFinite(percent)) {
362
+ throw new HttpError(400, 'percent must be a number between 0 and 100');
363
+ }
364
+ return proposed(await o.admin.setRollout(key, percent, deps.actor, writeOptions(environment, noteOf(req))));
365
+ });
366
+ }
367
+ /** `POST /api/flags/:key/variant` `{ variant }` — serve one variant to everyone. */
368
+ export function setVariant(deps, req) {
369
+ return handle(deps, async (o) => {
370
+ const environment = environmentOf(req, o.runtime);
371
+ const key = keyParam(req);
372
+ const variant = bodyOf(req).variant;
373
+ if (typeof variant !== 'string' || variant === '') {
374
+ throw new HttpError(400, 'variant must be a non-empty string');
375
+ }
376
+ return proposed(await o.admin.setVariant(key, variant, deps.actor, writeOptions(environment, noteOf(req))));
377
+ });
378
+ }
379
+ /** `POST /api/flags/:key/overrides` `{ subject, variant | null }` — force (or clear) one subject. */
380
+ export function setOverride(deps, req) {
381
+ return handle(deps, async (o) => {
382
+ const environment = environmentOf(req, o.runtime);
383
+ const key = keyParam(req);
384
+ const { subject, variant } = bodyOf(req);
385
+ if (typeof subject !== 'string' || subject === '') {
386
+ throw new HttpError(400, 'subject must be a non-empty string');
387
+ }
388
+ if (variant !== null && (typeof variant !== 'string' || variant === '')) {
389
+ throw new HttpError(400, 'variant must be a variant name, or null to remove the override');
390
+ }
391
+ return proposed(await o.admin.setOverride(key, subject, variant, deps.actor, writeOptions(environment, noteOf(req))));
392
+ });
393
+ }
394
+ // ── Preview and evaluate ────────────────────────────────────────────────────
395
+ const PREVIEW_ENTITIES = new Set(['flag', 'segment', 'layer', 'holdout']);
396
+ /** Parse `{ entity, key, value }` into a {@link RulesetChange}, shape-checked. */
397
+ function changeOf(raw) {
398
+ if (typeof raw !== 'object' || raw === null)
399
+ throw new HttpError(400, 'change is required');
400
+ const { entity, key, value } = raw;
401
+ if (typeof entity !== 'string' || !PREVIEW_ENTITIES.has(entity)) {
402
+ throw new HttpError(400, 'change.entity must be flag, segment, layer or holdout');
403
+ }
404
+ if (entity === 'holdout') {
405
+ const issues = holdoutIssues(value ?? null, 'change.value');
406
+ if (issues.length > 0)
407
+ throw invalid(issues);
408
+ return { entity: 'holdout', value: (value ?? null) };
409
+ }
410
+ if (typeof key !== 'string' || key === '')
411
+ throw new HttpError(400, 'change.key is required');
412
+ if (value === null)
413
+ return { entity, key, value: null };
414
+ const issues = entity === 'flag'
415
+ ? flagDefinitionIssues(value, 'change.value')
416
+ : entity === 'segment'
417
+ ? segmentIssues(value, 'change.value')
418
+ : layerIssues(value, 'change.value');
419
+ if (issues.length > 0)
420
+ throw invalid(issues);
421
+ return { entity, key, value };
422
+ }
423
+ /**
424
+ * `POST /api/preview` `{ environment, change }` — would this edit be accepted? Answers `200` either
425
+ * way (`{ valid, issues }`): the editor calls it on every keystroke, and an invalid draft is the
426
+ * normal state of a form being typed into, not an error.
427
+ */
428
+ export function preview(deps, req) {
429
+ return handle(deps, async (o) => {
430
+ const environment = environmentOf(req, o.runtime);
431
+ let change;
432
+ try {
433
+ change = changeOf(bodyOf(req).change);
434
+ }
435
+ catch (error) {
436
+ if (error instanceof HttpError && error.status === 422) {
437
+ return ok({ valid: false, issues: error.extra.issues });
438
+ }
439
+ throw error;
440
+ }
441
+ try {
442
+ const next = await o.admin.preview(change, environment);
443
+ const serving = change.entity === 'flag' && change.value
444
+ ? servingSummary(next.flags[change.key], o.admin.declarations[change.key])
445
+ : null;
446
+ return ok({ valid: true, issues: [], serving });
447
+ }
448
+ catch (error) {
449
+ if (error instanceof ValidationError)
450
+ return ok({ valid: false, issues: error.issues });
451
+ throw error;
452
+ }
453
+ });
454
+ }
455
+ function contextOf(raw) {
456
+ if (raw === undefined || raw === null)
457
+ return {};
458
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
459
+ throw new HttpError(400, 'context must be an object of attributes');
460
+ }
461
+ const context = raw;
462
+ if (context.targetingKey !== undefined && typeof context.targetingKey !== 'string') {
463
+ throw new HttpError(400, 'context.targetingKey must be a string');
464
+ }
465
+ return context;
466
+ }
467
+ /**
468
+ * `POST /api/evaluate` `{ environment, context, flag?, definition? }` — what a context would get.
469
+ *
470
+ * Evaluates with the engine directly against the environment's STORED ruleset, so it records no
471
+ * exposure and counts no usage: testing a rule must not enrol a fake user in an experiment. With
472
+ * `definition`, the unsaved draft is evaluated in place of the stored flag. Without `flag`, every
473
+ * flag is evaluated.
474
+ */
475
+ export function evaluateContext(deps, req) {
476
+ return handle(deps, async (o) => {
477
+ const environment = environmentOf(req, o.runtime);
478
+ const body = bodyOf(req);
479
+ const context = contextOf(body.context);
480
+ let ruleset = await o.store.readRuleset(environment);
481
+ const declared = await declarationsOf(o, ruleset);
482
+ const flag = body.flag;
483
+ if (flag !== undefined && (typeof flag !== 'string' || flag === '')) {
484
+ throw new HttpError(400, 'flag must be a flag key');
485
+ }
486
+ if (body.definition !== undefined) {
487
+ if (typeof flag !== 'string')
488
+ throw new HttpError(400, 'a draft definition needs a flag');
489
+ const issues = flagDefinitionIssues(body.definition);
490
+ if (issues.length > 0)
491
+ throw invalid(issues);
492
+ ruleset = applyChange(ruleset, {
493
+ entity: 'flag',
494
+ key: flag,
495
+ value: body.definition,
496
+ });
497
+ }
498
+ const one = (key) => evaluate(ruleset, key, (declared.get(key)?.default ?? null), context);
499
+ if (typeof flag === 'string')
500
+ return ok({ environment, result: one(flag) });
501
+ const keys = [...new Set([...Object.keys(ruleset.flags), ...declared.keys()])].sort();
502
+ return ok({ environment, results: keys.map(one) });
503
+ });
504
+ }
505
+ // ── Segments, layers, holdout ───────────────────────────────────────────────
506
+ function referencingFlags(ruleset, segment) {
507
+ const out = [];
508
+ for (const [key, flag] of Object.entries(ruleset.flags)) {
509
+ const uses = (flag.rules ?? []).some((rule) => rule.conditions.some((c) => 'segment' in c && c.segment === segment));
510
+ if (uses)
511
+ out.push(key);
512
+ }
513
+ return out.sort();
514
+ }
515
+ /** `GET /api/segments` — every segment and the flags whose rules reference it. */
516
+ export function listSegments(deps, req) {
517
+ return handle(deps, async (o) => {
518
+ const environment = environmentOf(req, o.runtime);
519
+ const ruleset = await o.store.readRuleset(environment);
520
+ const segments = Object.entries(ruleset.segments ?? {})
521
+ .sort(([a], [b]) => a.localeCompare(b))
522
+ .map(([key, segment]) => ({ key, segment, usedBy: referencingFlags(ruleset, key) }));
523
+ return ok({ environment, version: ruleset.version, segments });
524
+ });
525
+ }
526
+ /** `PUT /api/segments/:key` `{ segment, note? }`. */
527
+ export function saveSegment(deps, req) {
528
+ return handle(deps, async (o) => {
529
+ const environment = environmentOf(req, o.runtime);
530
+ const key = keyParam(req);
531
+ const segment = bodyOf(req).segment;
532
+ const issues = segmentIssues(segment);
533
+ if (issues.length > 0)
534
+ throw invalid(issues);
535
+ return proposed(await o.admin.propose({ entity: 'segment', key, value: segment }, deps.actor, writeOptions(environment, noteOf(req))));
536
+ });
537
+ }
538
+ /** `DELETE /api/segments/:key` — refused (422) while a rule still references it. */
539
+ export function deleteSegment(deps, req) {
540
+ return handle(deps, async (o) => {
541
+ const environment = environmentOf(req, o.runtime);
542
+ const key = keyParam(req);
543
+ return proposed(await o.admin.propose({ entity: 'segment', key, value: null }, deps.actor, writeOptions(environment, noteOf(req))));
544
+ });
545
+ }
546
+ /** `GET /api/layers` — layers with their allocations, the global holdout, and every experiment. */
547
+ export function listLayers(deps, req) {
548
+ return handle(deps, async (o) => {
549
+ const environment = environmentOf(req, o.runtime);
550
+ const ruleset = await o.store.readRuleset(environment);
551
+ const layers = Object.entries(ruleset.layers ?? {})
552
+ .sort(([a], [b]) => a.localeCompare(b))
553
+ .map(([key, layer]) => {
554
+ const used = Object.values(layer.allocations).reduce((sum, [start, end]) => sum + Math.max(0, end - start), 0);
555
+ return { key, layer, allocatedPercent: round(used / 100) };
556
+ });
557
+ const experiments = Object.entries(ruleset.flags)
558
+ .filter(([, flag]) => flag.experiment !== undefined)
559
+ .map(([key, flag]) => ({
560
+ flag: key,
561
+ experiment: flag.experiment?.key ?? key,
562
+ layer: flag.experiment?.layer ?? null,
563
+ holdout: flag.experiment?.holdout !== false,
564
+ }))
565
+ .sort((a, b) => a.flag.localeCompare(b.flag));
566
+ return ok({
567
+ environment,
568
+ version: ruleset.version,
569
+ layers,
570
+ holdout: ruleset.holdout ?? null,
571
+ experiments,
572
+ });
573
+ });
574
+ }
575
+ /** `PUT /api/layers/:key` `{ layer, note? }`. */
576
+ export function saveLayer(deps, req) {
577
+ return handle(deps, async (o) => {
578
+ const environment = environmentOf(req, o.runtime);
579
+ const key = keyParam(req);
580
+ const layer = bodyOf(req).layer;
581
+ const issues = layerIssues(layer);
582
+ if (issues.length > 0)
583
+ throw invalid(issues);
584
+ return proposed(await o.admin.propose({ entity: 'layer', key, value: layer }, deps.actor, writeOptions(environment, noteOf(req))));
585
+ });
586
+ }
587
+ /** `DELETE /api/layers/:key`. */
588
+ export function deleteLayer(deps, req) {
589
+ return handle(deps, async (o) => {
590
+ const environment = environmentOf(req, o.runtime);
591
+ const key = keyParam(req);
592
+ return proposed(await o.admin.propose({ entity: 'layer', key, value: null }, deps.actor, writeOptions(environment, noteOf(req))));
593
+ });
594
+ }
595
+ /** `PUT /api/holdout` `{ holdout | null, note? }` — `null` removes the global holdout. */
596
+ export function saveHoldout(deps, req) {
597
+ return handle(deps, async (o) => {
598
+ const environment = environmentOf(req, o.runtime);
599
+ const holdout = bodyOf(req).holdout ?? null;
600
+ const issues = holdoutIssues(holdout);
601
+ if (issues.length > 0)
602
+ throw invalid(issues);
603
+ return proposed(await o.admin.propose({ entity: 'holdout', value: holdout }, deps.actor, writeOptions(environment, noteOf(req))));
604
+ });
605
+ }
606
+ // ── Experiments ─────────────────────────────────────────────────────────────
607
+ function experimentConfigs(runtime) {
608
+ return runtime.config.topology === 'tenant' ? {} : (runtime.config.experiments ?? {});
609
+ }
610
+ /** `GET /api/experiments` — configured experiments with a summary of their last results. */
611
+ export function listExperiments(deps, req) {
612
+ return handle(deps, async (o) => {
613
+ const environment = environmentOf(req, o.runtime);
614
+ const ruleset = await o.store.readRuleset(environment);
615
+ const configs = experimentConfigs(o.runtime);
616
+ const rows = [];
617
+ const covered = new Set();
618
+ for (const [key, config] of Object.entries(configs)) {
619
+ const flag = config.flag ?? key;
620
+ covered.add(flag);
621
+ const results = await o.runtime.manager.results(key, environment);
622
+ rows.push({
623
+ key,
624
+ flag,
625
+ configured: true,
626
+ metrics: config.metrics,
627
+ guardrails: config.guardrails ?? [],
628
+ running: ruleset.flags[flag]?.experiment !== undefined && ruleset.flags[flag]?.enabled === true,
629
+ computedAt: results?.computedAt ?? null,
630
+ exposures: results ? Object.values(results.exposures).reduce((sum, n) => sum + n, 0) : null,
631
+ srmMismatch: results?.srm?.mismatch ?? false,
632
+ alert: results?.metrics.some((m) => m.alert) ?? false,
633
+ verdicts: results
634
+ ? results.metrics.flatMap((m) => m.comparisons.map((c) => ({
635
+ metric: m.name,
636
+ variant: c.variant,
637
+ verdict: c.verdict,
638
+ })))
639
+ : [],
640
+ });
641
+ }
642
+ // Flags marked as experiments with no analysis configured: assignments are recorded, but
643
+ // nothing will compute their numbers — worth seeing, not worth hiding.
644
+ for (const [flag, definition] of Object.entries(ruleset.flags)) {
645
+ if (!definition.experiment || covered.has(flag))
646
+ continue;
647
+ rows.push({
648
+ key: definition.experiment.key ?? flag,
649
+ flag,
650
+ configured: false,
651
+ metrics: [],
652
+ guardrails: [],
653
+ running: definition.enabled,
654
+ computedAt: null,
655
+ exposures: null,
656
+ srmMismatch: false,
657
+ alert: false,
658
+ verdicts: [],
659
+ });
660
+ }
661
+ rows.sort((a, b) => a.key.localeCompare(b.key));
662
+ return ok({ environment, analytics: o.runtime.analytics !== undefined, experiments: rows });
663
+ });
664
+ }
665
+ /** `GET /api/experiments/:key` — the last computed results (the worker refreshes them). */
666
+ export function experimentDetail(deps, req) {
667
+ return handle(deps, async (o) => {
668
+ const key = keyParam(req);
669
+ const config = experimentConfigs(o.runtime)[key];
670
+ if (!config)
671
+ throw new HttpError(404, `experiment "${key}" is not configured`);
672
+ return ok({
673
+ key,
674
+ flag: config.flag ?? key,
675
+ config,
676
+ analytics: o.runtime.analytics !== undefined,
677
+ environment: environmentOf(req, o.runtime),
678
+ results: await o.runtime.manager.results(key, environmentOf(req, o.runtime)),
679
+ });
680
+ });
681
+ }
682
+ /** `POST /api/experiments/:key/recompute` — compute the results now instead of at the next tick. */
683
+ export function recomputeExperiment(deps, req) {
684
+ return handle(deps, async (o) => {
685
+ const key = keyParam(req);
686
+ if (!experimentConfigs(o.runtime)[key]) {
687
+ throw new HttpError(404, `experiment "${key}" is not configured`);
688
+ }
689
+ if (!o.runtime.worker || !o.runtime.analytics) {
690
+ throw new HttpError(503, 'No analytics backend is configured, so experiment results cannot be computed here.');
691
+ }
692
+ const results = await o.runtime.worker.computeResults(key, environmentOf(req, o.runtime));
693
+ if (!results)
694
+ throw new HttpError(503, `results for "${key}" could not be computed`);
695
+ return ok({ key, results });
696
+ });
697
+ }
698
+ // ── Change requests ─────────────────────────────────────────────────────────
699
+ const REQUEST_STATUSES = new Set([
700
+ 'pending',
701
+ 'approved',
702
+ 'rejected',
703
+ 'applied',
704
+ 'cancelled',
705
+ ]);
706
+ /** `GET /api/change-requests?status=pending|…|all`. */
707
+ export function listChangeRequests(deps, req) {
708
+ return handle(deps, async (o) => {
709
+ const environment = environmentOf(req, o.runtime);
710
+ const status = first(req.query.status) ?? 'pending';
711
+ if (status !== 'all' && !REQUEST_STATUSES.has(status)) {
712
+ throw new HttpError(400, `unknown status "${status}"`);
713
+ }
714
+ const requests = await o.store.listChangeRequests({
715
+ environment,
716
+ ...(status !== 'all' ? { status: status } : {}),
717
+ limit: intQuery(req, 'limit', 100, 500),
718
+ });
719
+ return ok({
720
+ environment,
721
+ requiresApproval: o.admin.requiresApproval(environment),
722
+ actor: deps.actor,
723
+ requests,
724
+ });
725
+ });
726
+ }
727
+ /** `POST /api/change-requests/:id/approve` `{ note? }` — the requester cannot approve (403). */
728
+ export function approveChangeRequest(deps, req) {
729
+ return handle(deps, async (o) => ok(await o.admin.approve(keyParam(req, 'id'), deps.actor, noteOf(req))));
730
+ }
731
+ /** `POST /api/change-requests/:id/reject` `{ note? }`. */
732
+ export function rejectChangeRequest(deps, req) {
733
+ return handle(deps, async (o) => ok({ request: await o.admin.reject(keyParam(req, 'id'), deps.actor, noteOf(req)) }));
734
+ }
735
+ /** `POST /api/change-requests/:id/cancel` — only the requester can withdraw their request. */
736
+ export function cancelChangeRequest(deps, req) {
737
+ return handle(deps, async (o) => ok({ request: await o.admin.cancel(keyParam(req, 'id'), deps.actor) }));
738
+ }
739
+ // ── Audit ───────────────────────────────────────────────────────────────────
740
+ const ENTITIES = new Set(['flag', 'segment', 'layer', 'holdout', 'ruleset']);
741
+ /** `GET /api/audit?entity&key&before&limit` — newest first; page with `before=<at of last row>`. */
742
+ export function audit(deps, req) {
743
+ return handle(deps, async (o) => {
744
+ const environment = environmentOf(req, o.runtime);
745
+ const entity = first(req.query.entity);
746
+ if (entity !== undefined && !ENTITIES.has(entity)) {
747
+ throw new HttpError(400, `unknown entity "${entity}"`);
748
+ }
749
+ const key = first(req.query.key);
750
+ const beforeRaw = first(req.query.before);
751
+ const before = beforeRaw !== undefined ? new Date(beforeRaw) : undefined;
752
+ if (before !== undefined && Number.isNaN(before.getTime())) {
753
+ throw new HttpError(400, 'before must be an ISO date');
754
+ }
755
+ const limit = intQuery(req, 'limit', 50, 200);
756
+ const entries = await o.store.audit({
757
+ environment,
758
+ ...(entity !== undefined ? { entity: entity } : {}),
759
+ ...(key !== undefined ? { key } : {}),
760
+ ...(before !== undefined ? { before } : {}),
761
+ limit,
762
+ });
763
+ return ok({ environment, entries, limit });
764
+ });
765
+ }
766
+ // ── Schedules ───────────────────────────────────────────────────────────────
767
+ const SCHEDULE_STATUSES = new Set(['pending', 'running', 'done', 'failed', 'cancelled']);
768
+ /** `GET /api/schedules?flag&status`. */
769
+ export function listSchedules(deps, req) {
770
+ return handle(deps, async (o) => {
771
+ const environment = environmentOf(req, o.runtime);
772
+ const flag = first(req.query.flag);
773
+ const status = first(req.query.status);
774
+ if (status !== undefined && !SCHEDULE_STATUSES.has(status)) {
775
+ throw new HttpError(400, `unknown status "${status}"`);
776
+ }
777
+ const schedules = await o.store.listSchedules({
778
+ environment,
779
+ ...(flag !== undefined ? { flag } : {}),
780
+ ...(status !== undefined ? { status: status } : {}),
781
+ limit: intQuery(req, 'limit', 100, 500),
782
+ });
783
+ return ok({ environment, schedules });
784
+ });
785
+ }
786
+ function dateOf(value, field) {
787
+ const date = typeof value === 'string' || typeof value === 'number' ? new Date(value) : null;
788
+ if (!date || Number.isNaN(date.getTime()))
789
+ throw new HttpError(400, `${field} must be a date`);
790
+ return date;
791
+ }
792
+ function percentOf(value, field) {
793
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 100) {
794
+ throw new HttpError(400, `${field} must be a number between 0 and 100`);
795
+ }
796
+ return value;
797
+ }
798
+ /**
799
+ * `POST /api/flags/:key/schedules` — either one change at a time
800
+ * (`{ kind: 'enable' | 'disable' | 'rollout', runAt, percent? }`) or a gradual rollout
801
+ * (`{ steps: [{ at, percent }] }`, one schedule per step).
802
+ */
803
+ export function createSchedule(deps, req) {
804
+ return handle(deps, async (o) => {
805
+ const environment = environmentOf(req, o.runtime);
806
+ const key = keyParam(req);
807
+ const body = bodyOf(req);
808
+ // Fail here, not when the step runs at 3am: the flag must exist to be scheduled.
809
+ await o.admin.definition(key, environment);
810
+ if (body.steps !== undefined) {
811
+ if (!Array.isArray(body.steps) || body.steps.length === 0 || body.steps.length > 50) {
812
+ throw new HttpError(400, 'steps must be a list of 1–50 { at, percent }');
813
+ }
814
+ const steps = body.steps.map((step, i) => {
815
+ const s = (typeof step === 'object' && step !== null ? step : {});
816
+ return {
817
+ at: dateOf(s.at, `steps[${i}].at`),
818
+ percent: percentOf(s.percent, `steps[${i}].percent`),
819
+ };
820
+ });
821
+ const schedules = await o.admin.scheduleRollout(key, steps, deps.actor, { environment });
822
+ return { status: 201, body: { schedules } };
823
+ }
824
+ const kind = body.kind;
825
+ if (kind !== 'enable' && kind !== 'disable' && kind !== 'rollout') {
826
+ throw new HttpError(400, 'kind must be enable, disable or rollout (or send steps)');
827
+ }
828
+ const runAt = dateOf(body.runAt, 'runAt');
829
+ const payload = kind === 'rollout' ? { percent: percentOf(body.percent, 'percent') } : {};
830
+ const schedule = await o.admin.schedule({ flag: key, kind, payload, runAt, environment }, deps.actor);
831
+ return { status: 201, body: { schedules: [schedule] } };
832
+ });
833
+ }
834
+ /** `DELETE /api/schedules/:id` — only a pending schedule can be cancelled. */
835
+ export function cancelSchedule(deps, req) {
836
+ return handle(deps, async (o) => {
837
+ const id = keyParam(req, 'id');
838
+ const found = await o.store.getSchedule(id);
839
+ if (!found)
840
+ throw new HttpError(404, `schedule ${id} not found`);
841
+ if (found.status !== 'pending') {
842
+ throw new HttpError(409, `schedule ${id} is ${found.status}`);
843
+ }
844
+ return ok({ schedule: await o.admin.cancelSchedule(id, deps.actor) });
845
+ });
846
+ }
847
+ // ── Environments: export, import, promote ───────────────────────────────────
848
+ /** `GET /api/export` — the environment's ruleset as JSON. */
849
+ export function exportRuleset(deps, req) {
850
+ return handle(deps, async (o) => {
851
+ const environment = environmentOf(req, o.runtime);
852
+ return ok(await o.admin.exportRuleset(environment));
853
+ });
854
+ }
855
+ /** `POST /api/import` `{ ruleset, note? }` — replaces every flag, segment, layer and the holdout. */
856
+ export function importRuleset(deps, req) {
857
+ return handle(deps, async (o) => {
858
+ const environment = environmentOf(req, o.runtime);
859
+ const ruleset = bodyOf(req).ruleset;
860
+ const issues = rulesetIssues(ruleset);
861
+ if (issues.length > 0)
862
+ throw invalid(issues);
863
+ return proposed(await o.admin.importRuleset(ruleset, deps.actor, writeOptions(environment, noteOf(req))));
864
+ });
865
+ }
866
+ /** `POST /api/promote` `{ from, to, note? }` — copy one environment's ruleset over another's. */
867
+ export function promote(deps, req) {
868
+ return handle(deps, async (o) => {
869
+ const body = bodyOf(req);
870
+ const from = checkEnvironment(body.from, 'from');
871
+ const to = checkEnvironment(body.to, 'to');
872
+ if (from === to)
873
+ throw new HttpError(400, 'from and to must be different environments');
874
+ return proposed(await o.admin.promote(from, to, deps.actor, noteOf(req)));
875
+ });
876
+ }
877
+ // ── Hygiene ─────────────────────────────────────────────────────────────────
878
+ /** `GET /api/stale?unusedDays&staticDays` — flags that probably should be removed. */
879
+ export function stale(deps, req) {
880
+ return handle(deps, async (o) => {
881
+ const environment = environmentOf(req, o.runtime);
882
+ const unusedDays = intQuery(req, 'unusedDays', 30, 3650);
883
+ const staticDays = intQuery(req, 'staticDays', 30, 3650);
884
+ const flags = await o.admin.stale({
885
+ environment,
886
+ unusedDays,
887
+ staticDays,
888
+ ...(deps.now ? { now: deps.now() } : {}),
889
+ });
890
+ return ok({ environment, unusedDays, staticDays, flags });
891
+ });
892
+ }
893
+ // ── Ops ─────────────────────────────────────────────────────────────────────
894
+ /** `GET /api/ops` — is the machinery healthy: source, buffer, transport and every sink. */
895
+ /**
896
+ * Tenants that called this control-plane. The in-memory view only covers this replica, so it is
897
+ * merged with what every replica persisted: the declarations tenants publish at boot.
898
+ */
899
+ async function tenantsOf(store) {
900
+ const live = new Map(knownTenants().map((t) => [t.partition, t]));
901
+ const persisted = new Map();
902
+ for (const row of await store.declarations()) {
903
+ persisted.set(row.app, Math.max(persisted.get(row.app) ?? 0, row.lastSeenAt.getTime()));
904
+ }
905
+ const names = new Set([...live.keys(), ...persisted.keys()]);
906
+ return [...names].sort().map((partition) => {
907
+ const seen = live.get(partition);
908
+ const declaredAt = persisted.get(partition);
909
+ return {
910
+ partition,
911
+ environment: seen?.environment ?? null,
912
+ capabilities: seen?.capabilities ?? [],
913
+ lastRulesetVersion: seen?.lastRulesetVersion ?? null,
914
+ lastSeenAt: seen?.lastSeenAt ?? (declaredAt ? new Date(declaredAt).toISOString() : null),
915
+ };
916
+ });
917
+ }
918
+ export function ops(deps, _req) {
919
+ return handle(deps, async (o) => {
920
+ const status = o.runtime.manager.status();
921
+ const names = [...o.runtime.sinks.keys()];
922
+ let sinks = names.map((sink) => ({ sink }));
923
+ let sinkError = null;
924
+ if (o.runtime.transport?.status && names.length > 0) {
925
+ try {
926
+ sinks = await o.runtime.transport.status(names);
927
+ }
928
+ catch (error) {
929
+ sinkError = error instanceof Error ? error.message : String(error);
930
+ }
931
+ }
932
+ return ok({
933
+ app: status.app,
934
+ environment: status.environment,
935
+ topology: o.runtime.topology,
936
+ role: o.runtime.role,
937
+ source: status.source,
938
+ buffer: status.buffer,
939
+ transport: status.transport,
940
+ sinks,
941
+ sinkError,
942
+ relay: o.runtime.relay !== undefined,
943
+ worker: o.runtime.worker !== undefined,
944
+ analytics: o.runtime.analytics !== undefined,
945
+ tenants: o.runtime.topology === 'control-plane' ? await tenantsOf(o.store) : [],
946
+ });
947
+ });
948
+ }
949
+ //# sourceMappingURL=handlers.js.map