primitive-admin 1.0.61 → 1.0.63

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 (320) hide show
  1. package/README.md +125 -69
  2. package/assets/skill/skills/primitive-platform/SKILL.md +336 -37
  3. package/dist/bin/primitive.js +25 -14
  4. package/dist/bin/primitive.js.map +1 -1
  5. package/dist/src/commands/analytics.js +36 -16
  6. package/dist/src/commands/analytics.js.map +1 -1
  7. package/dist/src/commands/apps.js +52 -188
  8. package/dist/src/commands/apps.js.map +1 -1
  9. package/dist/src/commands/blob-buckets.js +13 -153
  10. package/dist/src/commands/blob-buckets.js.map +1 -1
  11. package/dist/src/commands/collection-type-configs.js +14 -100
  12. package/dist/src/commands/collection-type-configs.js.map +1 -1
  13. package/dist/src/commands/collections.js +47 -8
  14. package/dist/src/commands/collections.js.map +1 -1
  15. package/dist/src/commands/config.d.ts +46 -0
  16. package/dist/src/commands/config.js +479 -0
  17. package/dist/src/commands/config.js.map +1 -0
  18. package/dist/src/commands/connections.js +12 -7
  19. package/dist/src/commands/connections.js.map +1 -1
  20. package/dist/src/commands/cron-triggers.js +84 -176
  21. package/dist/src/commands/cron-triggers.js.map +1 -1
  22. package/dist/src/commands/database-type-configs.d.ts +2 -0
  23. package/dist/src/commands/database-type-configs.js +171 -0
  24. package/dist/src/commands/database-type-configs.js.map +1 -0
  25. package/dist/src/commands/database-types.js +4 -4
  26. package/dist/src/commands/database-types.js.map +1 -1
  27. package/dist/src/commands/databases.d.ts +63 -0
  28. package/dist/src/commands/databases.js +649 -87
  29. package/dist/src/commands/databases.js.map +1 -1
  30. package/dist/src/commands/documents.js +869 -12
  31. package/dist/src/commands/documents.js.map +1 -1
  32. package/dist/src/commands/email-templates.js +21 -128
  33. package/dist/src/commands/email-templates.js.map +1 -1
  34. package/dist/src/commands/env.d.ts +12 -1
  35. package/dist/src/commands/env.js +115 -26
  36. package/dist/src/commands/env.js.map +1 -1
  37. package/dist/src/commands/feature-flags.d.ts +14 -0
  38. package/dist/src/commands/feature-flags.js +116 -0
  39. package/dist/src/commands/feature-flags.js.map +1 -0
  40. package/dist/src/commands/group-type-configs.js +14 -109
  41. package/dist/src/commands/group-type-configs.js.map +1 -1
  42. package/dist/src/commands/init.d.ts +24 -1
  43. package/dist/src/commands/init.js +1194 -279
  44. package/dist/src/commands/init.js.map +1 -1
  45. package/dist/src/commands/integrations.js +166 -435
  46. package/dist/src/commands/integrations.js.map +1 -1
  47. package/dist/src/commands/metadata-category-configs.d.ts +12 -0
  48. package/dist/src/commands/metadata-category-configs.js +112 -0
  49. package/dist/src/commands/metadata-category-configs.js.map +1 -0
  50. package/dist/src/commands/metadata.js +33 -141
  51. package/dist/src/commands/metadata.js.map +1 -1
  52. package/dist/src/commands/prompts.js +206 -563
  53. package/dist/src/commands/prompts.js.map +1 -1
  54. package/dist/src/commands/rule-sets.d.ts +1 -1
  55. package/dist/src/commands/rule-sets.js +16 -132
  56. package/dist/src/commands/rule-sets.js.map +1 -1
  57. package/dist/src/commands/scripts.d.ts +10 -0
  58. package/dist/src/commands/scripts.js +203 -241
  59. package/dist/src/commands/scripts.js.map +1 -1
  60. package/dist/src/commands/sessions.js +17 -5
  61. package/dist/src/commands/sessions.js.map +1 -1
  62. package/dist/src/commands/sync-app-settings.d.ts +113 -70
  63. package/dist/src/commands/sync-app-settings.js +193 -242
  64. package/dist/src/commands/sync-app-settings.js.map +1 -1
  65. package/dist/src/commands/sync.d.ts +1904 -51
  66. package/dist/src/commands/sync.js +9468 -1790
  67. package/dist/src/commands/sync.js.map +1 -1
  68. package/dist/src/commands/tokens.js +8 -2
  69. package/dist/src/commands/tokens.js.map +1 -1
  70. package/dist/src/commands/users.js +92 -1
  71. package/dist/src/commands/users.js.map +1 -1
  72. package/dist/src/commands/vars.js +44 -58
  73. package/dist/src/commands/vars.js.map +1 -1
  74. package/dist/src/commands/webhooks.js +318 -156
  75. package/dist/src/commands/webhooks.js.map +1 -1
  76. package/dist/src/commands/workflows.d.ts +49 -12
  77. package/dist/src/commands/workflows.js +652 -1086
  78. package/dist/src/commands/workflows.js.map +1 -1
  79. package/dist/src/lib/access-rule-display.d.ts +21 -0
  80. package/dist/src/lib/access-rule-display.js +34 -0
  81. package/dist/src/lib/access-rule-display.js.map +1 -0
  82. package/dist/src/lib/api-client.d.ts +541 -61
  83. package/dist/src/lib/api-client.js +559 -102
  84. package/dist/src/lib/api-client.js.map +1 -1
  85. package/dist/src/lib/app-settings-descriptor.d.ts +180 -25
  86. package/dist/src/lib/app-settings-descriptor.js +403 -82
  87. package/dist/src/lib/app-settings-descriptor.js.map +1 -1
  88. package/dist/src/lib/canonical-json.d.ts +12 -0
  89. package/dist/src/lib/canonical-json.js +35 -0
  90. package/dist/src/lib/canonical-json.js.map +1 -0
  91. package/dist/src/lib/cli-manifest.d.ts +8 -0
  92. package/dist/src/lib/cli-manifest.js +1 -0
  93. package/dist/src/lib/cli-manifest.js.map +1 -1
  94. package/dist/src/lib/codegen-shared/generatedFiles.d.ts +6 -2
  95. package/dist/src/lib/codegen-shared/generatedFiles.js +9 -2
  96. package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -1
  97. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +6 -6
  98. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +8 -8
  99. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js.map +1 -1
  100. package/dist/src/lib/config-object-descriptor.d.ts +127 -0
  101. package/dist/src/lib/config-object-descriptor.js +658 -0
  102. package/dist/src/lib/config-object-descriptor.js.map +1 -0
  103. package/dist/src/lib/config-payload.d.ts +85 -0
  104. package/dist/src/lib/config-payload.js +116 -0
  105. package/dist/src/lib/config-payload.js.map +1 -0
  106. package/dist/src/lib/config-surface.d.ts +130 -0
  107. package/dist/src/lib/config-surface.js +300 -0
  108. package/dist/src/lib/config-surface.js.map +1 -0
  109. package/dist/src/lib/credentials-store.d.ts +10 -0
  110. package/dist/src/lib/credentials-store.js +23 -0
  111. package/dist/src/lib/credentials-store.js.map +1 -1
  112. package/dist/src/lib/data-input.d.ts +23 -0
  113. package/dist/src/lib/data-input.js +50 -0
  114. package/dist/src/lib/data-input.js.map +1 -0
  115. package/dist/src/lib/db-codegen/dbTemplates.js +1 -1
  116. package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -1
  117. package/dist/src/lib/db-codegen/dbTypeIR.d.ts +1 -1
  118. package/dist/src/lib/env-resolver-core.d.ts +187 -0
  119. package/dist/src/lib/env-resolver-core.js +325 -0
  120. package/dist/src/lib/env-resolver-core.js.map +1 -0
  121. package/dist/src/lib/env-resolver.d.ts +28 -6
  122. package/dist/src/lib/env-resolver.js +44 -32
  123. package/dist/src/lib/env-resolver.js.map +1 -1
  124. package/dist/src/lib/generated-allowlist.js +12 -0
  125. package/dist/src/lib/generated-allowlist.js.map +1 -1
  126. package/dist/src/lib/generated-config-surfaces.d.ts +682 -0
  127. package/dist/src/lib/generated-config-surfaces.js +4058 -0
  128. package/dist/src/lib/generated-config-surfaces.js.map +1 -0
  129. package/dist/src/lib/generated-template-lint.d.ts +212 -0
  130. package/dist/src/lib/generated-template-lint.js +624 -0
  131. package/dist/src/lib/generated-template-lint.js.map +1 -0
  132. package/dist/src/lib/init-adopt.d.ts +16 -0
  133. package/dist/src/lib/init-adopt.js +34 -0
  134. package/dist/src/lib/init-adopt.js.map +1 -0
  135. package/dist/src/lib/init-assets.d.ts +39 -0
  136. package/dist/src/lib/init-assets.js +97 -0
  137. package/dist/src/lib/init-assets.js.map +1 -0
  138. package/dist/src/lib/init-client-platforms.d.ts +14 -0
  139. package/dist/src/lib/init-client-platforms.js +71 -0
  140. package/dist/src/lib/init-client-platforms.js.map +1 -0
  141. package/dist/src/lib/init-config.d.ts +44 -5
  142. package/dist/src/lib/init-config.js +80 -7
  143. package/dist/src/lib/init-config.js.map +1 -1
  144. package/dist/src/lib/init-email-redirect-uris.d.ts +37 -0
  145. package/dist/src/lib/init-email-redirect-uris.js +46 -0
  146. package/dist/src/lib/init-email-redirect-uris.js.map +1 -0
  147. package/dist/src/lib/init-ios-links.d.ts +90 -0
  148. package/dist/src/lib/init-ios-links.js +218 -0
  149. package/dist/src/lib/init-ios-links.js.map +1 -0
  150. package/dist/src/lib/init-plan.d.ts +80 -0
  151. package/dist/src/lib/init-plan.js +95 -0
  152. package/dist/src/lib/init-plan.js.map +1 -0
  153. package/dist/src/lib/init-production-env.d.ts +48 -0
  154. package/dist/src/lib/init-production-env.js +59 -0
  155. package/dist/src/lib/init-production-env.js.map +1 -0
  156. package/dist/src/lib/init-schema.d.ts +74 -0
  157. package/dist/src/lib/init-schema.js +358 -0
  158. package/dist/src/lib/init-schema.js.map +1 -0
  159. package/dist/src/lib/init-xcode.d.ts +33 -0
  160. package/dist/src/lib/init-xcode.js +114 -0
  161. package/dist/src/lib/init-xcode.js.map +1 -0
  162. package/dist/src/lib/integration-request-config.d.ts +30 -0
  163. package/dist/src/lib/integration-request-config.js +145 -0
  164. package/dist/src/lib/integration-request-config.js.map +1 -0
  165. package/dist/src/lib/integration-selector.d.ts +42 -0
  166. package/dist/src/lib/integration-selector.js +46 -0
  167. package/dist/src/lib/integration-selector.js.map +1 -0
  168. package/dist/src/lib/local-state.d.ts +55 -0
  169. package/dist/src/lib/local-state.js +167 -0
  170. package/dist/src/lib/local-state.js.map +1 -0
  171. package/dist/src/lib/local-test-cases.d.ts +63 -0
  172. package/dist/src/lib/local-test-cases.js +135 -0
  173. package/dist/src/lib/local-test-cases.js.map +1 -0
  174. package/dist/src/lib/log-inspection.d.ts +568 -0
  175. package/dist/src/lib/log-inspection.js +639 -0
  176. package/dist/src/lib/log-inspection.js.map +1 -0
  177. package/dist/src/lib/migration-nag.d.ts +2 -2
  178. package/dist/src/lib/migration-nag.js +3 -3
  179. package/dist/src/lib/object-status-filter.d.ts +22 -0
  180. package/dist/src/lib/object-status-filter.js +45 -0
  181. package/dist/src/lib/object-status-filter.js.map +1 -0
  182. package/dist/src/lib/output.d.ts +24 -1
  183. package/dist/src/lib/output.js +42 -1
  184. package/dist/src/lib/output.js.map +1 -1
  185. package/dist/src/lib/package-manager.d.ts +140 -0
  186. package/dist/src/lib/package-manager.js +305 -0
  187. package/dist/src/lib/package-manager.js.map +1 -0
  188. package/dist/src/lib/paginate.d.ts +50 -0
  189. package/dist/src/lib/paginate.js +53 -0
  190. package/dist/src/lib/paginate.js.map +1 -1
  191. package/dist/src/lib/platform-owned.d.ts +29 -3
  192. package/dist/src/lib/platform-owned.js +42 -3
  193. package/dist/src/lib/platform-owned.js.map +1 -1
  194. package/dist/src/lib/project-config.d.ts +8 -0
  195. package/dist/src/lib/project-config.js +30 -5
  196. package/dist/src/lib/project-config.js.map +1 -1
  197. package/dist/src/lib/query-operators.d.ts +1 -1
  198. package/dist/src/lib/query-operators.js +1 -1
  199. package/dist/src/lib/record-filter.d.ts +18 -0
  200. package/dist/src/lib/record-filter.js +55 -0
  201. package/dist/src/lib/record-filter.js.map +1 -0
  202. package/dist/src/lib/resolve-init-dev-port.d.ts +56 -0
  203. package/dist/src/lib/resolve-init-dev-port.js +55 -0
  204. package/dist/src/lib/resolve-init-dev-port.js.map +1 -0
  205. package/dist/src/lib/resolve-init-server.d.ts +64 -0
  206. package/dist/src/lib/resolve-init-server.js +77 -0
  207. package/dist/src/lib/resolve-init-server.js.map +1 -0
  208. package/dist/src/lib/resolve-platform.d.ts +43 -14
  209. package/dist/src/lib/resolve-platform.js +74 -12
  210. package/dist/src/lib/resolve-platform.js.map +1 -1
  211. package/dist/src/lib/run-status.d.ts +19 -0
  212. package/dist/src/lib/run-status.generated.d.ts +39 -0
  213. package/dist/src/lib/run-status.generated.js +66 -0
  214. package/dist/src/lib/run-status.generated.js.map +1 -0
  215. package/dist/src/lib/run-status.js +19 -0
  216. package/dist/src/lib/run-status.js.map +1 -0
  217. package/dist/src/lib/server-text-normalization.d.ts +51 -0
  218. package/dist/src/lib/server-text-normalization.js +90 -0
  219. package/dist/src/lib/server-text-normalization.js.map +1 -0
  220. package/dist/src/lib/server-url.d.ts +22 -0
  221. package/dist/src/lib/server-url.js +33 -0
  222. package/dist/src/lib/server-url.js.map +1 -0
  223. package/dist/src/lib/signing-secret-status.d.ts +81 -0
  224. package/dist/src/lib/signing-secret-status.js +116 -0
  225. package/dist/src/lib/signing-secret-status.js.map +1 -0
  226. package/dist/src/lib/skill-installer.js +6 -3
  227. package/dist/src/lib/skill-installer.js.map +1 -1
  228. package/dist/src/lib/snapshots.d.ts +2 -2
  229. package/dist/src/lib/snapshots.js +2 -2
  230. package/dist/src/lib/swift-codegen/dbGenerator.d.ts +15 -2
  231. package/dist/src/lib/swift-codegen/dbGenerator.js +250 -18
  232. package/dist/src/lib/swift-codegen/dbGenerator.js.map +1 -1
  233. package/dist/src/lib/swift-codegen/generator.d.ts +11 -2
  234. package/dist/src/lib/swift-codegen/generator.js +181 -7
  235. package/dist/src/lib/swift-codegen/generator.js.map +1 -1
  236. package/dist/src/lib/swift-codegen/siblingSymbols.d.ts +7 -3
  237. package/dist/src/lib/swift-codegen/siblingSymbols.js +4 -1
  238. package/dist/src/lib/swift-codegen/siblingSymbols.js.map +1 -1
  239. package/dist/src/lib/sync-dir-selector.d.ts +21 -0
  240. package/dist/src/lib/sync-dir-selector.js +30 -0
  241. package/dist/src/lib/sync-dir-selector.js.map +1 -0
  242. package/dist/src/lib/sync-paths.d.ts +39 -0
  243. package/dist/src/lib/sync-paths.js +70 -2
  244. package/dist/src/lib/sync-paths.js.map +1 -1
  245. package/dist/src/lib/sync-resource-types.d.ts +138 -31
  246. package/dist/src/lib/sync-resource-types.js +241 -47
  247. package/dist/src/lib/sync-resource-types.js.map +1 -1
  248. package/dist/src/lib/sync-selectors.d.ts +95 -0
  249. package/dist/src/lib/sync-selectors.js +228 -0
  250. package/dist/src/lib/sync-selectors.js.map +1 -0
  251. package/dist/src/lib/template.d.ts +62 -4
  252. package/dist/src/lib/template.js +147 -39
  253. package/dist/src/lib/template.js.map +1 -1
  254. package/dist/src/lib/test-case-file-names.d.ts +40 -0
  255. package/dist/src/lib/test-case-file-names.js +91 -0
  256. package/dist/src/lib/test-case-file-names.js.map +1 -0
  257. package/dist/src/lib/test-case-keys.d.ts +29 -0
  258. package/dist/src/lib/test-case-keys.js +55 -0
  259. package/dist/src/lib/test-case-keys.js.map +1 -0
  260. package/dist/src/lib/test-case-variables.d.ts +15 -0
  261. package/dist/src/lib/test-case-variables.js +29 -0
  262. package/dist/src/lib/test-case-variables.js.map +1 -0
  263. package/dist/src/lib/toml-database-config.d.ts +4 -4
  264. package/dist/src/lib/toml-database-config.js +27 -34
  265. package/dist/src/lib/toml-database-config.js.map +1 -1
  266. package/dist/src/lib/toml-metadata-config.d.ts +9 -5
  267. package/dist/src/lib/toml-metadata-config.js +40 -45
  268. package/dist/src/lib/toml-metadata-config.js.map +1 -1
  269. package/dist/src/lib/toml-native-form.d.ts +1 -1
  270. package/dist/src/lib/toml-native-form.js +1 -1
  271. package/dist/src/lib/toml-params-validator.d.ts +1 -1
  272. package/dist/src/lib/toml-params-validator.js +1 -1
  273. package/dist/src/lib/toml-scalar-edit.d.ts +43 -0
  274. package/dist/src/lib/toml-scalar-edit.js +283 -0
  275. package/dist/src/lib/toml-scalar-edit.js.map +1 -0
  276. package/dist/src/lib/user-selector.d.ts +24 -0
  277. package/dist/src/lib/user-selector.js +33 -0
  278. package/dist/src/lib/user-selector.js.map +1 -0
  279. package/dist/src/lib/version-check.d.ts +25 -0
  280. package/dist/src/lib/version-check.js +77 -8
  281. package/dist/src/lib/version-check.js.map +1 -1
  282. package/dist/src/lib/watch.d.ts +121 -0
  283. package/dist/src/lib/watch.js +169 -0
  284. package/dist/src/lib/watch.js.map +1 -0
  285. package/dist/src/lib/web-url.d.ts +40 -0
  286. package/dist/src/lib/web-url.js +76 -0
  287. package/dist/src/lib/web-url.js.map +1 -0
  288. package/dist/src/lib/workflow-apply.d.ts +69 -45
  289. package/dist/src/lib/workflow-apply.js +99 -72
  290. package/dist/src/lib/workflow-apply.js.map +1 -1
  291. package/dist/src/lib/workflow-codegen/generator.js +4 -4
  292. package/dist/src/lib/workflow-codegen/generator.js.map +1 -1
  293. package/dist/src/lib/workflow-codegen/invokerIR.d.ts +11 -0
  294. package/dist/src/lib/workflow-codegen/invokerIR.js +5 -0
  295. package/dist/src/lib/workflow-codegen/invokerIR.js.map +1 -1
  296. package/dist/src/lib/workflow-config-apply.d.ts +70 -0
  297. package/dist/src/lib/workflow-config-apply.js +137 -0
  298. package/dist/src/lib/workflow-config-apply.js.map +1 -0
  299. package/dist/src/lib/workflow-config-sidecar.d.ts +63 -0
  300. package/dist/src/lib/workflow-config-sidecar.js +96 -0
  301. package/dist/src/lib/workflow-config-sidecar.js.map +1 -0
  302. package/dist/src/lib/workflow-defaults.d.ts +29 -0
  303. package/dist/src/lib/workflow-defaults.js +41 -0
  304. package/dist/src/lib/workflow-defaults.js.map +1 -0
  305. package/dist/src/lib/workflow-include-preserve.d.ts +76 -0
  306. package/dist/src/lib/workflow-include-preserve.js +286 -0
  307. package/dist/src/lib/workflow-include-preserve.js.map +1 -0
  308. package/dist/src/lib/workflow-payload.d.ts +31 -20
  309. package/dist/src/lib/workflow-payload.js +100 -110
  310. package/dist/src/lib/workflow-payload.js.map +1 -1
  311. package/dist/src/lib/workflow-toml-validator.d.ts +37 -16
  312. package/dist/src/lib/workflow-toml-validator.js +141 -16
  313. package/dist/src/lib/workflow-toml-validator.js.map +1 -1
  314. package/dist/src/types/index.d.ts +78 -22
  315. package/dist/src/validators.d.ts +7 -7
  316. package/dist/src/validators.js +6 -6
  317. package/package.json +11 -6
  318. package/dist/src/commands/settings.d.ts +0 -15
  319. package/dist/src/commands/settings.js +0 -102
  320. package/dist/src/commands/settings.js.map +0 -1
@@ -0,0 +1,4058 @@
1
+ /**
2
+ * GENERATED FILE — DO NOT EDIT BY HAND.
3
+ *
4
+ * Vendored from the canonical server modules under `src/config-surface/` — the
5
+ * ONE definition per configuration object (issue #2644). The server's
6
+ * create/update handlers and this copy read the same field surface, so the CLI's
7
+ * push payloads, pull serializers and TOML key validation cannot drift from what
8
+ * the server accepts.
9
+ *
10
+ * Regenerate with:
11
+ * node cli/scripts/gen-config-surfaces.mjs (runs automatically at CLI prebuild)
12
+ *
13
+ * A freshness guard (`gen-config-surfaces.mjs --check`, asserted by
14
+ * `cli/tests/unit/config-surface-drift-guard.test.ts`) fails if this committed
15
+ * copy does not match the source.
16
+ */
17
+ // ── src/config-surface/helpers.ts ────────────────────────────────────────
18
+ /**
19
+ * Shared spellings used by every configuration-object definition (issue #2644).
20
+ *
21
+ * These live in one module for a mechanical reason as much as a stylistic one:
22
+ * `cli/scripts/gen-config-surfaces.mjs` concatenates the whole directory into a
23
+ * single vendored artifact, so a `const handler = …` declared per definition
24
+ * module would collide as a duplicate identifier the moment a second object was
25
+ * migrated.
26
+ *
27
+ * Pure: no models, no tenant context, no `env` (see `types.ts` §Purity).
28
+ */
29
+ /** Both write modes — the common case, spelled once. */
30
+ export const BOTH = ["create", "update"];
31
+ /** Create only: an immutable key or a field the update handler ignores. */
32
+ export const CREATE_ONLY = ["create"];
33
+ /** Update only: a field the create handler assigns itself. */
34
+ export const UPDATE_ONLY = ["update"];
35
+ /**
36
+ * Name the handler that owns a field's validation, normalization and
37
+ * serialization, as `"<repo-relative module>#<export>"`. Classification only —
38
+ * the definition never dispatches through it (#1976 decision of record); the
39
+ * guard asserts the export exists so a renamed handler fails the CLI unit suite
40
+ * instead of leaving a dangling reference (#2644 behavior 2b).
41
+ */
42
+ export function handledBy(modulePath, exportName) {
43
+ return { handler: `${modulePath}#${exportName}` };
44
+ }
45
+ /**
46
+ * The declared-access manifest's top-level TOML keys (#1304, #1364).
47
+ *
48
+ * Five objects carry the same three-key fragment beside their field table —
49
+ * workflows, database types, and the group / collection / metadata-category
50
+ * configs — parsed by the one `parseDeclaredAccessManifestToml`. Spelling it
51
+ * once here keeps `config push`'s document-level rejection from disagreeing with
52
+ * itself object by object.
53
+ */
54
+ export const DECLARED_ACCESS_MANIFEST_KEYS = {
55
+ metadata: {
56
+ kind: "structural",
57
+ note: "#1304 declared-access manifest — `[metadata.self]` / `[metadata.paths.*]`. " +
58
+ "Parsed by parseDeclaredAccessManifestToml and stored as the owning " +
59
+ "object's `metadataManifest`.",
60
+ },
61
+ secrets: {
62
+ kind: "structural",
63
+ note: "The manifest's declared secret names (#1304). Same channel as [metadata].",
64
+ },
65
+ vars: {
66
+ kind: "structural",
67
+ note: "The manifest's declared var names (#1364), the non-secret twin of " +
68
+ "`secrets`. Same channel as [metadata].",
69
+ },
70
+ };
71
+ /** The field names this table accepts on the wire in `mode`, in declaration order. */
72
+ export function writableFieldNames(table, mode) {
73
+ return table.fields
74
+ .filter((f) => f.writableOn.includes(mode))
75
+ .map((f) => f.field);
76
+ }
77
+ /**
78
+ * The field names the server PERSISTS for this table in `mode`: the writable
79
+ * fields, plus the `deprecated` classifications — "still writable, superseded
80
+ * by another field" is what that kind means (`types.ts`), so a superseded key
81
+ * a handler still honors (`accessPolicy`, `passkeyRpId`) belongs in every
82
+ * accepted set, and a field that is NOT writable belongs in none of them.
83
+ *
84
+ * Stated once here because three consumers need the same answer: the generated
85
+ * request schemas, `PUT /settings`'s write allow-list, and the guards.
86
+ */
87
+ export function acceptedWriteFieldNames(table, mode) {
88
+ const deprecated = Object.entries(table.notExposed)
89
+ .filter(([, classification]) => classification.kind === "deprecated")
90
+ .map(([field]) => field);
91
+ return [...writableFieldNames(table, mode), ...deprecated];
92
+ }
93
+ /**
94
+ * Split a request body into the keys this table accepts in `mode` and the keys
95
+ * it does not.
96
+ *
97
+ * `accepted` preserves the caller's values verbatim — including an explicit
98
+ * `null` or `false`, which are meaningful (clear / opt-out) and must not be
99
+ * coalesced away. Only keys the body actually carries appear, so a handler can
100
+ * keep using presence (`hasOwnProperty`) to distinguish "leave unset" from
101
+ * "set to null".
102
+ *
103
+ * `rejected` is every other key the body carried. Today it is informational;
104
+ * #2644 phase 5 turns it into a 400 through the generated request schemas.
105
+ */
106
+ export function pickWritableFields(body, table, mode) {
107
+ const writable = new Set(writableFieldNames(table, mode));
108
+ const accepted = {};
109
+ const rejected = [];
110
+ if (!body || typeof body !== "object")
111
+ return { accepted, rejected };
112
+ for (const key of Object.keys(body)) {
113
+ if (writable.has(key)) {
114
+ accepted[key] = body[key];
115
+ }
116
+ else {
117
+ rejected.push(key);
118
+ }
119
+ }
120
+ return { accepted, rejected };
121
+ }
122
+ /**
123
+ * The PASSTHROUGH half of the accepted body: the keys this table declares
124
+ * `validation: "passthrough"`, writable in `mode`, that the body actually
125
+ * carries — with their values verbatim.
126
+ *
127
+ * This is what makes criterion 1 true rather than aspirational. A handler that
128
+ * only picked its accepted set still had to name each field again when it built
129
+ * the row to persist, so a scalar field added to a definition alone was
130
+ * accepted on the wire and then dropped on the floor. Handlers spread this into
131
+ * the create/update payload FIRST, so a field with real behavior still lands
132
+ * through its named handler (whose assignment comes after and wins), and a
133
+ * plain scalar needs no handler edit at all.
134
+ *
135
+ * Values are passed through untouched — including an explicit `null` or
136
+ * `false`, which are meaningful (clear / opt-out). "Stored as-is" is exactly
137
+ * what the `passthrough` classification promises.
138
+ */
139
+ export function passthroughFields(body, table, mode, options = {}) {
140
+ const out = {};
141
+ if (!body || typeof body !== "object")
142
+ return out;
143
+ const handled = new Set(options.handledHere ?? []);
144
+ for (const field of table.fields) {
145
+ if (field.validation !== "passthrough")
146
+ continue;
147
+ if (!field.writableOn.includes(mode))
148
+ continue;
149
+ if (handled.has(field.field))
150
+ continue;
151
+ if (!Object.prototype.hasOwnProperty.call(body, field.field))
152
+ continue;
153
+ out[field.field] = body[field.field];
154
+ }
155
+ return out;
156
+ }
157
+ /** `true` when the body carries `field` and this table accepts it in `mode`. */
158
+ export function hasWritableField(accepted, field) {
159
+ return Object.prototype.hasOwnProperty.call(accepted, field);
160
+ }
161
+ /**
162
+ * Whether a value counts as unset for an `emit: "whenSet"` field, so `config pull`
163
+ * omits the key instead of writing a noisy empty one (and `config diff` does not
164
+ * report a difference that is not there).
165
+ *
166
+ * Carried over verbatim from #1033's shipped app-settings descriptor
167
+ * (`cli/src/lib/app-settings-descriptor.ts`): an empty array and an empty
168
+ * object are unset, while `false` and `0` are meaningful values and never are.
169
+ */
170
+ export function isEmptyForEmit(value, type) {
171
+ if (value === null || value === undefined)
172
+ return true;
173
+ if (type === "string")
174
+ return value === "";
175
+ if (type === "string[]")
176
+ return !Array.isArray(value) || value.length === 0;
177
+ if (type === "json") {
178
+ return (typeof value !== "object" ||
179
+ Array.isArray(value) ||
180
+ Object.keys(value).length === 0);
181
+ }
182
+ return false; // booleans (false) and numbers (0) are meaningful, never empty
183
+ }
184
+ /**
185
+ * A field name whose shape says it carries a credential. Such a field must be
186
+ * classified explicitly — exposed with a stated reference-only contract, or
187
+ * `notExposed: { kind: "secret" }` — because a mechanical generalization that
188
+ * merely omits it would read as "not decided" (#2254, #2256).
189
+ */
190
+ export const SECRETISH_FIELD = /secret|token|password|credential/i;
191
+ /** Model fields the table neither exposes nor classifies. Non-empty is a failure. */
192
+ export function findUnclassifiedFields(table, modelFields) {
193
+ const exposed = new Set(table.fields.map((f) => f.field));
194
+ return modelFields
195
+ .filter((f) => !exposed.has(f) && !(f in table.notExposed))
196
+ .sort();
197
+ }
198
+ /**
199
+ * Classifications that no longer correspond to a model field — i.e. a field
200
+ * removed from `models.yaml` while the definition still names it. Also a
201
+ * failure: a stale classification must not silently pass, or the coverage claim
202
+ * quietly stops meaning anything.
203
+ */
204
+ export function findStaleClassifications(table, modelFields) {
205
+ const known = new Set(modelFields);
206
+ return [...table.fields.map((f) => f.field), ...Object.keys(table.notExposed)]
207
+ .filter((f) => !known.has(f))
208
+ .sort();
209
+ }
210
+ // ── Surface-level coverage ───────────────────────────────────────────────
211
+ //
212
+ // A table is not always the right unit. `app-settings` projects ONE model
213
+ // (`App`) over FOUR `app.toml` sections, so `googleClients` is exposed in
214
+ // `[auth]` while `[app]` has never heard of it — per-table coverage would
215
+ // report it unclassified. Coverage is therefore a statement about the OBJECT:
216
+ // every field of every model the object's tables name is exposed in one of
217
+ // them, or classified `notExposed` in one of them.
218
+ //
219
+ // Results are reported as `"<Model>.<field>"` so a multi-model surface (prompt
220
+ // carries `AppPrompt` and `AppPromptConfig`) says which model it means.
221
+ /** The models this surface's tables project, in declaration order, deduped. */
222
+ export function surfaceModels(surface) {
223
+ return [...new Set(surface.tables.map((t) => t.model))];
224
+ }
225
+ /** Model fields for `model`, or a loud failure — a silent `[]` is a vacuous guard. */
226
+ function requireModelFields(modelFields, model) {
227
+ const fields = modelFields[model];
228
+ if (!fields) {
229
+ throw new Error(`config-surface: no models.yaml field list for "${model}". ` +
230
+ "Run `node cli/scripts/gen-config-surfaces.mjs`.");
231
+ }
232
+ return fields;
233
+ }
234
+ /** Every field name the surface exposes for `model`, across all its tables. */
235
+ function exposedForModel(surface, model) {
236
+ return new Set(surface.tables
237
+ .filter((t) => t.model === model)
238
+ .flatMap((t) => t.fields.map((f) => f.field)));
239
+ }
240
+ /** Every field name the surface classifies `notExposed` for `model`. */
241
+ function notExposedForModel(surface, model) {
242
+ return new Set(surface.tables
243
+ .filter((t) => t.model === model)
244
+ .flatMap((t) => Object.keys(t.notExposed)));
245
+ }
246
+ /**
247
+ * Model fields the whole surface neither exposes nor classifies. Non-empty is
248
+ * a failure: the CLI would silently ignore them and a pull → push cycle would
249
+ * clear them (#2644 criterion 2).
250
+ */
251
+ export function findUnclassifiedSurfaceFields(surface, modelFields) {
252
+ const out = [];
253
+ for (const model of surfaceModels(surface)) {
254
+ const exposed = exposedForModel(surface, model);
255
+ const classified = notExposedForModel(surface, model);
256
+ for (const field of requireModelFields(modelFields, model)) {
257
+ if (!exposed.has(field) && !classified.has(field)) {
258
+ out.push(`${model}.${field}`);
259
+ }
260
+ }
261
+ }
262
+ return out.sort();
263
+ }
264
+ /**
265
+ * Classifications naming a field the model no longer has — the other direction,
266
+ * and equally a failure: a stale entry quietly stops meaning anything.
267
+ */
268
+ export function findStaleSurfaceClassifications(surface, modelFields) {
269
+ const out = [];
270
+ for (const model of surfaceModels(surface)) {
271
+ const known = new Set(requireModelFields(modelFields, model));
272
+ for (const name of [
273
+ ...exposedForModel(surface, model),
274
+ ...notExposedForModel(surface, model),
275
+ ]) {
276
+ if (!known.has(name))
277
+ out.push(`${model}.${name}`);
278
+ }
279
+ }
280
+ return out.sort();
281
+ }
282
+ /** Fields the surface both exposes and classifies `notExposed`. Ambiguous. */
283
+ export function findDoubleClassifiedSurfaceFields(surface) {
284
+ const out = [];
285
+ for (const model of surfaceModels(surface)) {
286
+ const classified = notExposedForModel(surface, model);
287
+ for (const field of exposedForModel(surface, model)) {
288
+ if (classified.has(field))
289
+ out.push(`${model}.${field}`);
290
+ }
291
+ }
292
+ return out.sort();
293
+ }
294
+ /**
295
+ * Secret-adjacent model fields the surface leaves undecided — neither exposed
296
+ * with a stated contract nor classified `notExposed` (#2254, #2256).
297
+ */
298
+ export function findUnclassifiedSecretishSurfaceFields(surface, modelFields) {
299
+ return findUnclassifiedSurfaceFields(surface, modelFields).filter((name) => SECRETISH_FIELD.test(name.split(".")[1] ?? name));
300
+ }
301
+ /** Fields classified twice — exposed AND `notExposed`. Ambiguous, so a failure. */
302
+ export function findDoubleClassifiedFields(table) {
303
+ return table.fields
304
+ .map((f) => f.field)
305
+ .filter((f) => f in table.notExposed)
306
+ .sort();
307
+ }
308
+ /**
309
+ * `notExposed` / `tomlOnlyKeys` / `responseOnlyKeys` entries whose `note` is
310
+ * missing or blank. A reason is the whole point of the classification.
311
+ */
312
+ export function findReasonlessClassifications(table) {
313
+ const bad = [];
314
+ const maps = [
315
+ ["notExposed", table.notExposed],
316
+ ["tomlOnlyKeys", table.tomlOnlyKeys],
317
+ ["responseOnlyKeys", table.responseOnlyKeys],
318
+ ];
319
+ for (const [label, map] of maps) {
320
+ for (const [field, entry] of Object.entries(map)) {
321
+ if (!entry || typeof entry.note !== "string" || !entry.note.trim()) {
322
+ bad.push(`${label}.${field}`);
323
+ }
324
+ }
325
+ }
326
+ return bad.sort();
327
+ }
328
+ /**
329
+ * Secret-adjacent model fields with no explicit decision — neither an exposed
330
+ * entry nor a `notExposed` classification. A bare omission is the failure mode
331
+ * this catches.
332
+ */
333
+ export function findUnclassifiedSecretishFields(table, modelFields) {
334
+ const exposed = new Set(table.fields.map((f) => f.field));
335
+ return modelFields
336
+ .filter((f) => SECRETISH_FIELD.test(f) && !exposed.has(f) && !(f in table.notExposed))
337
+ .sort();
338
+ }
339
+ /**
340
+ * Every distinct handler reference the table names, as
341
+ * `"<repo-relative module>#<export>"`. The guard resolves each one and fails
342
+ * when the module or the export is missing (#2644 behavior 2b).
343
+ */
344
+ export function handlerReferences(table) {
345
+ const refs = new Set();
346
+ for (const field of table.fields) {
347
+ if (field.validation !== "passthrough")
348
+ refs.add(field.validation.handler);
349
+ }
350
+ return [...refs].sort();
351
+ }
352
+ /** TOML keys this table accepts: the exposed fields plus the declared extras. */
353
+ export function acceptedTomlKeys(table) {
354
+ return new Set([
355
+ ...table.fields.map((f) => f.tomlKey),
356
+ ...Object.keys(table.tomlOnlyKeys),
357
+ ]);
358
+ }
359
+ // ── Document-level acceptance ────────────────────────────────────────────
360
+ //
361
+ // `acceptedTomlKeys` answers "is this key accepted inside this table". These
362
+ // three answer the question one level up — "is this table accepted in this
363
+ // file, and in the right shape" — which is what makes push's rejection total
364
+ // (design gate, 2026-08-12). A typo'd header (`[integraton]`) or a miswritten
365
+ // array (`[configs]` for `[[configs]]`) used to parse to a root key nothing
366
+ // checked, so the file pushed as though the real table were empty: the
367
+ // TOML-owned fields inside it were sent as explicit nulls and CLEARED.
368
+ /** Top-level TOML keys this object's file accepts: its tables plus the extras. */
369
+ export function acceptedTomlDocumentKeys(surface) {
370
+ return new Set([
371
+ ...surface.tables.map((t) => t.tomlPath[0]).filter((k) => !!k),
372
+ ...Object.keys(surface.tomlDocumentKeys),
373
+ ]);
374
+ }
375
+ /**
376
+ * `tomlDocumentKeys` entries with no reason, and any that merely restate a
377
+ * field table. Both are failures: a reason is the whole point of the
378
+ * classification, and a duplicate would let a table's shape check be bypassed
379
+ * by declaring it twice.
380
+ */
381
+ export function findBadDocumentKeyDeclarations(surface) {
382
+ const tableKeys = new Set(surface.tables.map((t) => t.tomlPath[0]));
383
+ const bad = [];
384
+ for (const [key, entry] of Object.entries(surface.tomlDocumentKeys)) {
385
+ if (!entry || typeof entry.note !== "string" || !entry.note.trim()) {
386
+ bad.push(`${key} (no note)`);
387
+ }
388
+ if (tableKeys.has(key))
389
+ bad.push(`${key} (already a field table)`);
390
+ }
391
+ return bad.sort();
392
+ }
393
+ /** Root keys of `tomlData` the surface does not declare. `config push` rejects these. */
394
+ export function findUnknownTomlDocumentKeys(surface, tomlData) {
395
+ if (!tomlData || typeof tomlData !== "object" || Array.isArray(tomlData)) {
396
+ return [];
397
+ }
398
+ const accepted = acceptedTomlDocumentKeys(surface);
399
+ return Object.keys(tomlData)
400
+ .filter((key) => !accepted.has(key))
401
+ .sort();
402
+ }
403
+ /**
404
+ * Field tables present in the file in the wrong shape — a `repeated` table
405
+ * written as `[configs]` instead of `[[configs]]`, or the reverse.
406
+ *
407
+ * Reported separately from an unknown key because the remedy is different: the
408
+ * table is recognized, its ARRAY MARKERS are wrong, and the builder would
409
+ * otherwise read the misshapen value as "no entries".
410
+ *
411
+ * A repeated table has to be an array OF TABLES, not merely an array: TOML
412
+ * accepts `configs = ["default"]` at the document root, and an element that is
413
+ * not a table has no keys for the per-entry unknown-key check to look at, so it
414
+ * used to sail through preflight and then be applied as a defaulted entry.
415
+ */
416
+ /** A parsed TOML table: an object, and neither `null` nor an array. */
417
+ function isTomlTable(value) {
418
+ return !!value && typeof value === "object" && !Array.isArray(value);
419
+ }
420
+ export function findMisshapenTomlTables(surface, tomlData) {
421
+ if (!tomlData || typeof tomlData !== "object" || Array.isArray(tomlData)) {
422
+ return [];
423
+ }
424
+ const doc = tomlData;
425
+ const out = [];
426
+ for (const table of surface.tables) {
427
+ const key = table.tomlPath[0];
428
+ // Only the FIRST path segment is a document-level statement; a nested path
429
+ // is inside a table this loop has already accepted.
430
+ if (!key || table.tomlPath.length !== 1)
431
+ continue;
432
+ const value = doc[key];
433
+ if (value === undefined || value === null)
434
+ continue;
435
+ if (table.repeated) {
436
+ if (!Array.isArray(value) || !value.every(isTomlTable)) {
437
+ out.push({ key, expected: "array of tables" });
438
+ }
439
+ }
440
+ else if (Array.isArray(value) || typeof value !== "object") {
441
+ out.push({ key, expected: "table" });
442
+ }
443
+ }
444
+ return out.sort((a, b) => a.key.localeCompare(b.key));
445
+ }
446
+ /**
447
+ * Server-response keys this table recognizes: every model field (exposed or
448
+ * not) plus the declared response-only keys. `config pull` warns about anything
449
+ * else instead of dropping it silently (#2644 criterion 6).
450
+ */
451
+ export function recognizedResponseKeys(table, modelFields) {
452
+ return new Set([
453
+ ...modelFields,
454
+ ...table.fields.map((f) => f.field),
455
+ ...Object.keys(table.notExposed),
456
+ ...Object.keys(table.responseOnlyKeys),
457
+ ]);
458
+ }
459
+ /**
460
+ * Protocol keys every config UPDATE accepts, whichever object it is.
461
+ *
462
+ * `expectedModifiedAt` is the optimistic-concurrency token `config push` attaches
463
+ * to an update body when it has a baseline from the last pull; a handler that
464
+ * does not implement conflict detection ignores it. It is a property of the
465
+ * sync protocol rather than of any one object, which is why it is stated once
466
+ * here instead of in thirteen definitions.
467
+ */
468
+ export const UPDATE_PROTOCOL_KEYS = ["expectedModifiedAt"];
469
+ /** The body keys this table accepts in `mode`, in a stable order. */
470
+ export function requestSchemaKeys(table, mode) {
471
+ // Writable fields plus the `deprecated` ones, which that kind defines as
472
+ // still writable. `server-owned` and `secret` are excluded by construction —
473
+ // and a legacy slot the handler never reads off the body is `server-owned`,
474
+ // not `deprecated`, so it cannot slip in here (#2644 second opinion,
475
+ // CFG-2644-006).
476
+ const keys = new Set(acceptedWriteFieldNames(table, mode));
477
+ if (mode === "update")
478
+ for (const key of UPDATE_PROTOCOL_KEYS)
479
+ keys.add(key);
480
+ for (const [field, classification] of Object.entries(table.notExposed)) {
481
+ if (classification.kind !== "structural")
482
+ continue;
483
+ // A structural key states which modes carry it in the BODY; omitted means
484
+ // both. An identity that travels in the URL path or comes from the file
485
+ // name declares `[]` and is admitted by no schema — being structural is a
486
+ // statement about the TOML surface, not a licence to accept the key on the
487
+ // wire and drop it (#2644 second opinion, CFG-2644-005).
488
+ if (classification.requestModes &&
489
+ !classification.requestModes.includes(mode)) {
490
+ continue;
491
+ }
492
+ keys.add(field);
493
+ }
494
+ for (const [key, entry] of Object.entries(table.requestOnlyKeys ?? {})) {
495
+ // A request-only key states which modes read it; omitted means both.
496
+ if (!entry.modes || entry.modes.includes(mode))
497
+ keys.add(key);
498
+ }
499
+ return [...keys].sort();
500
+ }
501
+ /**
502
+ * The request schema for one object and mode. Generated from the definition, so
503
+ * a schema permitting a key the definition does not is unrepresentable: there
504
+ * is no place to write one.
505
+ */
506
+ export function configRequestSchema(table, mode) {
507
+ const properties = {};
508
+ for (const key of requestSchemaKeys(table, mode)) {
509
+ properties[key] = {};
510
+ }
511
+ const schema = {
512
+ type: "object",
513
+ properties,
514
+ additionalProperties: false,
515
+ };
516
+ rememberRetiredKeys(schema, RETIRED_CONFIG_KEYS[table.tomlPath[0]]);
517
+ return schema;
518
+ }
519
+ /**
520
+ * Retired-key guidance carried BESIDE a generated schema, not inside it
521
+ * (issue #2803, second opinion).
522
+ *
523
+ * A route that opts into runtime validation is validated before its handler
524
+ * runs, so a handler's targeted "`status` is server-owned, use
525
+ * `<noun> enable|disable`" refusal never fired for the app-API webhook and cron
526
+ * routes: `additionalProperties: false` answered first, with the generic
527
+ * "Request body validation failed". The guidance has to reach the validator.
528
+ *
529
+ * It travels in a side table rather than as a schema keyword because the schema
530
+ * object IS the OpenAPI `meta.request` and is held to a fail-closed keyword
531
+ * allowlist (`RUNTIME_SUPPORTED_SCHEMA_KEYWORDS`) — an invented keyword would
532
+ * be published in the spec and would fail that guard. Keying on the schema's
533
+ * identity also means the guidance cannot drift: it is attached by the same
534
+ * call that builds the key set, so a route cannot opt into one without the
535
+ * other.
536
+ */
537
+ const retiredKeyGuidance = new WeakMap();
538
+ function rememberRetiredKeys(schema, retired) {
539
+ if (!retired)
540
+ return;
541
+ const entries = {};
542
+ for (const [key, entry] of Object.entries(retired)) {
543
+ // A key the schema ACCEPTS is not retired on this surface, whatever the
544
+ // TOML surface says about it. The accepted key set wins, so the guidance
545
+ // can never shadow a live field.
546
+ if (key in schema.properties)
547
+ continue;
548
+ entries[key] = entry.request;
549
+ }
550
+ if (Object.keys(entries).length > 0)
551
+ retiredKeyGuidance.set(schema, entries);
552
+ }
553
+ /**
554
+ * The retired-key guidance for a generated schema, keyed by body key — or
555
+ * `undefined` when the schema's object has none. Consumed by the app API's
556
+ * request validator (`src/app-api/request-validation.ts`).
557
+ */
558
+ export function retiredRequestKeys(schema) {
559
+ return schema ? retiredKeyGuidance.get(schema) : undefined;
560
+ }
561
+ /**
562
+ * One schema for an endpoint whose body spans MORE than one table.
563
+ *
564
+ * `POST …/prompts` is the case: it creates the prompt AND seeds its first
565
+ * config, so the body carries `[prompt]` fields and `[[configs]]` fields
566
+ * together. Both halves still come from their definitions — this only says the
567
+ * endpoint accepts the union, in the one place that is true.
568
+ */
569
+ export function mergeRequestSchemas(...schemas) {
570
+ const properties = {};
571
+ for (const schema of schemas) {
572
+ for (const key of Object.keys(schema.properties))
573
+ properties[key] = {};
574
+ }
575
+ // The union's guidance is the union of its halves'. A key one table retired
576
+ // and another accepts is admitted by the merged key set, so `rememberRetiredKeys`
577
+ // drops it below.
578
+ const retired = {};
579
+ for (const schema of schemas) {
580
+ for (const [key, request] of Object.entries(retiredRequestKeys(schema) ?? {})) {
581
+ retired[key] = { request };
582
+ }
583
+ }
584
+ const merged = {
585
+ type: "object",
586
+ properties,
587
+ additionalProperties: false,
588
+ };
589
+ rememberRetiredKeys(merged, retired);
590
+ return merged;
591
+ }
592
+ /**
593
+ * The body keys `table` does not accept in `mode` — the 400's subject.
594
+ *
595
+ * Used by the admin API, which has no request-schema middleware: its handlers
596
+ * call this directly so both APIs reject the same key set for the same object.
597
+ */
598
+ export function unknownRequestKeys(body, table, mode) {
599
+ if (!body || typeof body !== "object" || Array.isArray(body))
600
+ return [];
601
+ const known = new Set(requestSchemaKeys(table, mode));
602
+ return Object.keys(body)
603
+ .filter((key) => !known.has(key))
604
+ .sort();
605
+ }
606
+ /** The 400 message naming the unknown key(s), shared by both APIs. */
607
+ export function unknownRequestKeysMessage(keys) {
608
+ return (`Unrecognized request body key${keys.length === 1 ? "" : "s"}: ` +
609
+ `${keys.join(", ")}. This endpoint rejects keys it does not accept rather ` +
610
+ "than ignoring them.");
611
+ }
612
+ export const RETIRED_CONFIG_KEYS = {
613
+ // #2891 — the app-settings auth surface was reshaped so that `app.toml` can
614
+ // state what the server holds. All five keys below stay DECLARED in
615
+ // `models.yaml` (their attributes are still in DynamoDB, and the passkey pair
616
+ // still feeds a runtime fallback), but none is writable or echoed any more,
617
+ // so "removed for app developers" is achieved entirely here.
618
+ //
619
+ // Keyed under the `app` prefix because that is the app-settings surface's
620
+ // identifier at both consumers — the merged `PUT /settings` request schema
621
+ // and the `app.toml` parser — even though four of the five were authored
622
+ // under `[auth]`.
623
+ app: {
624
+ googleClientId: {
625
+ toml: "`googleClientId` is no longer authored in TOML (#2891). Google " +
626
+ "registers a client per platform, so the id belongs to one client: " +
627
+ "move it to `[auth.google.clients.<type>].clientId` (`web`, `ios`, " +
628
+ "`android`, `desktop` or `chrome-extension`) and delete the line.",
629
+ request: "`googleClientId` is retired (#2891). Send the client id inside " +
630
+ "`googleClients` as `[auth.google.clients.<type>].clientId`.",
631
+ },
632
+ googleClientSecret: {
633
+ toml: "`googleClientSecret` is no longer authored in TOML (#2891). The " +
634
+ "secret belongs to one Google client — and Google issues none at all " +
635
+ "for `ios`, `android` or `chrome-extension` — so move it to " +
636
+ "`[auth.google.clients.<type>].clientSecret` (still a whole " +
637
+ "`{{secrets.KEY}}` reference) and delete the line.",
638
+ request: "`googleClientSecret` is retired (#2891). Send the reference inside " +
639
+ "`googleClients` as `[auth.google.clients.<type>].clientSecret`; the " +
640
+ "`web` and `desktop` types require one and the native types reject one.",
641
+ },
642
+ redirectUris: {
643
+ toml: "`redirectUris` is no longer authored in TOML (#2891). A redirect URI " +
644
+ "belongs to the thing that redirects, and one app-wide list served " +
645
+ "both flows: put Google callbacks in " +
646
+ "`[auth.google.clients.<type>].redirectUris` and the magic-link " +
647
+ "allow-list in `emailRedirectUris`, then delete the line. A stored " +
648
+ "list may legitimately have mixed the two — split it.",
649
+ request: "`redirectUris` is retired (#2891). Google callbacks go in " +
650
+ "`googleClients` as `[auth.google.clients.<type>].redirectUris`; the " +
651
+ "magic-link allow-list is `emailRedirectUris`. A stored list may " +
652
+ "legitimately have mixed the two — split it.",
653
+ },
654
+ passkeyRpId: {
655
+ toml: "`passkeyRpId` is no longer authored in TOML (#2891). Passkey " +
656
+ "relying-party config is authored one way only: `passkeyRpConfig`, " +
657
+ "written as `[auth.passkeys]` with an entry per RP ID. Delete the " +
658
+ "line and push again.",
659
+ request: "`passkeyRpId` is retired (#2891). Send the relying-party config as " +
660
+ "`passkeyRpConfig` (`[auth.passkeys]`), which carries the per-rpId " +
661
+ "names.",
662
+ },
663
+ passkeyRpName: {
664
+ toml: "`passkeyRpName` is no longer authored in TOML (#2891). It was the " +
665
+ "other half of the legacy single-rpId config, superseded by " +
666
+ "`passkeyRpConfig`: put the name under its RP ID in `[auth.passkeys]` " +
667
+ "and delete the line.",
668
+ request: "`passkeyRpName` is retired (#2891). Send the relying-party config as " +
669
+ "`passkeyRpConfig` (`[auth.passkeys]`), which carries the per-rpId " +
670
+ "names.",
671
+ },
672
+ // #2884 — email sign-in became ONE flow, so it has ONE setting. Both keys
673
+ // stay DECLARED in `models.yaml` and are mirrored on every write of
674
+ // `emailSignInEnabled`, but neither is authorable any more: a file that
675
+ // still carries one is asking for a method choice that no longer exists.
676
+ magicLinkEnabled: {
677
+ toml: "`magicLinkEnabled` is no longer authored in TOML (#2884). Email " +
678
+ "sign-in is one flow — one request sends one email carrying a code " +
679
+ "and, when a link can be issued, a link — so it has one setting: " +
680
+ "replace this line with `emailSignInEnabled` (`false` turns email " +
681
+ "sign-in off entirely).",
682
+ request: "`magicLinkEnabled` is retired (#2884). Email sign-in has one " +
683
+ "setting: send `emailSignInEnabled`.",
684
+ },
685
+ otpEnabled: {
686
+ toml: "`otpEnabled` is no longer authored in TOML (#2884). It was the " +
687
+ "other half of the retired pair: one email now carries both the code " +
688
+ "and the link, so replace both lines with `emailSignInEnabled`.",
689
+ request: "`otpEnabled` is retired (#2884). Email sign-in has one setting: " +
690
+ "send `emailSignInEnabled`.",
691
+ },
692
+ },
693
+ // #2645 — pausing moved off `state` onto its own field, so `state` stopped
694
+ // being author-writable rather than becoming a second way to say "paused".
695
+ // Every pre-#2645 pull wrote the line, so existing repos hit this on their
696
+ // first push after upgrading: the failure is deliberate (sponsor decision at
697
+ // the approval gate, 2026-08-15), because the line has to be removed, not
698
+ // silently ignored, or the file keeps claiming a pause state push does not
699
+ // apply.
700
+ //
701
+ // #2907 — each of these names TWO verbs, not one. An author writing
702
+ // `status = "archived"` was not asking about availability: they were asking
703
+ // to retire the object, which is the delete flow and is spelled
704
+ // `primitive <noun> archive`. Answering with `enable|disable` alone sent
705
+ // exactly that author away with verbs that cannot do what they asked.
706
+ cronTrigger: {
707
+ state: {
708
+ toml: "`state` is no longer authored in TOML (#2645), and #2803 replaced it " +
709
+ "with the server-owned `status`. Availability is " +
710
+ "`primitive cron-triggers enable|disable`; retiring the trigger is " +
711
+ "`primitive cron-triggers archive` (#2907). Delete the line and push " +
712
+ "again.",
713
+ request: "`state` is server-owned and superseded by `status` (#2645, #2803). " +
714
+ "Use `primitive cron-triggers enable|disable` to change availability, " +
715
+ "or `primitive cron-triggers archive` to retire the trigger (#2907).",
716
+ },
717
+ status: {
718
+ toml: "`status` is no longer authored in TOML (#2803). Availability is " +
719
+ "server-owned: use `primitive cron-triggers enable|disable`. " +
720
+ "Retiring the trigger is `primitive cron-triggers archive` (#2907) — " +
721
+ "the archived row keeps its key and its slot under the per-app cap. " +
722
+ "Delete the line and push again.",
723
+ request: "`status` is server-owned (#2803). Use " +
724
+ "`primitive cron-triggers enable|disable` to change availability, or " +
725
+ "`primitive cron-triggers archive` to retire the trigger (#2907).",
726
+ },
727
+ },
728
+ // #2803 — the OBJECT-level availability leaves TOML. The per-version
729
+ // `[[configs]] status` (`active | archived`) is a different question — which
730
+ // named config is retired — and stays authorable.
731
+ prompt: {
732
+ status: {
733
+ toml: "`[prompt].status` is no longer authored in TOML (#2803). " +
734
+ "Availability is server-owned: use `primitive prompts enable|disable`. " +
735
+ "Retiring the prompt is `primitive prompts archive` (#2907). " +
736
+ "Delete the line and push again — the per-config `[[configs]] status` " +
737
+ "is unaffected.",
738
+ request: "`status` is server-owned (#2803). Use " +
739
+ "`primitive prompts enable|disable` to change availability, or " +
740
+ "`primitive prompts archive` to retire the prompt (#2907).",
741
+ },
742
+ },
743
+ integration: {
744
+ status: {
745
+ toml: "`status` is no longer authored in TOML (#2803), and `draft` no longer " +
746
+ "exists. Availability is server-owned: use " +
747
+ "`primitive integrations enable|disable`. Retiring the integration is " +
748
+ "`primitive integrations archive` (#2907). Delete the line and push " +
749
+ "again. If this file describes an ARCHIVED integration, delete the " +
750
+ "file instead — pull no longer exports tombstones, and the next " +
751
+ "confirmed `config push --prune` hard-deletes the row.",
752
+ request: "`status` is server-owned (#2803). Use " +
753
+ "`primitive integrations enable|disable` to change availability, or " +
754
+ "`primitive integrations archive` to retire the integration (#2907).",
755
+ },
756
+ },
757
+ // #2803 — availability is one server-owned `status`. As a TOML key it made
758
+ // `config push` a second writer: pushing a file pulled before an operator ran
759
+ // `webhooks disable` put the endpoint back in service as a side effect. The
760
+ // tenant app-API create/update also stored the value UNVALIDATED, so any app
761
+ // admin could write any string into the field the receiver gates on.
762
+ webhook: {
763
+ status: {
764
+ toml: "`status` is no longer authored in TOML (#2803). Availability is " +
765
+ "server-owned: use `primitive webhooks enable|disable`. Retiring the " +
766
+ "webhook is `primitive webhooks archive` (#2907) — the archived row " +
767
+ "keeps its key and its slot under the per-app cap. Delete the " +
768
+ "line and push again. If this file describes an ARCHIVED webhook, " +
769
+ "delete the file instead — pull no longer exports tombstones, and the " +
770
+ "next confirmed `config push --prune` hard-deletes the row, freeing " +
771
+ "its slot and its key.",
772
+ request: "`status` is server-owned (#2803). Use " +
773
+ "`primitive webhooks enable|disable` to change availability, or " +
774
+ "`primitive webhooks archive` to retire the webhook (#2907).",
775
+ },
776
+ },
777
+ // #2803 — availability is one server-owned `status`. As a TOML key it made
778
+ // `config push` a second writer: pushing a file pulled before an operator ran
779
+ // `workflows disable` put the workflow back in service as a side effect.
780
+ workflow: {
781
+ status: {
782
+ toml: "`status` is no longer authored in TOML (#2803). Availability is " +
783
+ "server-owned: use `primitive workflows enable|disable`. Retiring the " +
784
+ "workflow is `primitive workflows archive` (#2907), which keeps its " +
785
+ "runs and revisions resolving. Delete the line and push again.",
786
+ request: "`status` is server-owned (#2803). Use " +
787
+ "`primitive workflows enable|disable` to change availability, or " +
788
+ "`primitive workflows archive` to retire the workflow (#2907).",
789
+ },
790
+ },
791
+ };
792
+ /** The retired-key entry for `key` under `prefix`, or null when it is simply unknown. */
793
+ export function retiredConfigKey(prefix, key) {
794
+ return RETIRED_CONFIG_KEYS[prefix]?.[key] ?? null;
795
+ }
796
+ // ── src/config-surface/workflow.ts ───────────────────────────────────────
797
+ /**
798
+ * The `workflow` configuration object's definition (issue #2644, phase 1).
799
+ *
800
+ * One place records the `[workflow]` field surface: which `WorkflowDefinition`
801
+ * fields the server accepts on create/update, which TOML key each carries,
802
+ * whether `config pull` always emits it, and — for every field with real
803
+ * behavior — the handler that owns that behavior. `createAppWorkflow` /
804
+ * `updateAppWorkflow` take their accepted set from here (`pickWritableFields`),
805
+ * the CLI's push builder and pull serializer read the vendored copy, and the
806
+ * coverage guard cross-checks the whole thing against `models.yaml`.
807
+ *
808
+ * Supersedes `cli/src/lib/workflow-field-descriptor.ts` (#2326) and
809
+ * `WORKFLOW_PAYLOAD_FIELDS_CREATE` / `_UPDATE`
810
+ * (`cli/src/lib/workflow-payload.ts`, #1177), both deleted with this change.
811
+ */
812
+ const HANDLERS = "src/workflows/config/workflow-field-handlers.ts";
813
+ const handler = (name) => handledBy(HANDLERS, name);
814
+ /**
815
+ * The five per-app / per-user queue limits (#1177). All share one handler: a
816
+ * finite non-negative number, floored.
817
+ */
818
+ const QUEUE_LIMITS = [
819
+ "perUserMaxRunning",
820
+ "perUserMaxQueued",
821
+ "perAppMaxRunning",
822
+ "perAppMaxQueued",
823
+ "queueTtlSeconds",
824
+ ];
825
+ const queueLimitFields = QUEUE_LIMITS.map((field) => ({
826
+ field,
827
+ tomlKey: field,
828
+ type: "number",
829
+ emit: "whenSet",
830
+ writableOn: BOTH,
831
+ validation: handler("normalizeWorkflowQueueLimit"),
832
+ }));
833
+ export const WORKFLOW_SURFACE = {
834
+ label: "workflow",
835
+ tables: [
836
+ {
837
+ tomlPath: ["workflow"],
838
+ repeated: false,
839
+ model: "WorkflowDefinition",
840
+ // Declaration order is the order `config pull` writes the keys, so it
841
+ // deliberately matches the pre-#2644 hand-written serializer — migrating
842
+ // to the definition must not reshuffle every workflow TOML file.
843
+ fields: [
844
+ {
845
+ field: "name",
846
+ tomlKey: "name",
847
+ type: "string",
848
+ emit: "always",
849
+ writableOn: BOTH,
850
+ validation: handler("normalizeWorkflowName"),
851
+ },
852
+ {
853
+ field: "description",
854
+ tomlKey: "description",
855
+ type: "string",
856
+ emit: "always",
857
+ writableOn: BOTH,
858
+ validation: handler("normalizeWorkflowDescription"),
859
+ },
860
+ {
861
+ field: "accessRule",
862
+ tomlKey: "accessRule",
863
+ type: "string",
864
+ emit: "whenSet",
865
+ writableOn: BOTH,
866
+ validation: handler("normalizeWorkflowAccessRule"),
867
+ },
868
+ {
869
+ field: "runAs",
870
+ tomlKey: "runAs",
871
+ type: "string",
872
+ emit: "whenSet",
873
+ writableOn: BOTH,
874
+ validation: handler("normalizeWorkflowRunAs"),
875
+ },
876
+ // #1078 Tier 1b — StringSetField on the model, string array in TOML/JSON.
877
+ {
878
+ field: "capabilities",
879
+ tomlKey: "capabilities",
880
+ type: "string[]",
881
+ emit: "whenSet",
882
+ writableOn: BOTH,
883
+ validation: handler("normalizeWorkflowCapabilities"),
884
+ },
885
+ ...queueLimitFields,
886
+ {
887
+ field: "dequeueOrder",
888
+ tomlKey: "dequeueOrder",
889
+ type: "string",
890
+ emit: "always",
891
+ writableOn: BOTH,
892
+ validation: handler("normalizeWorkflowDequeueOrder"),
893
+ },
894
+ {
895
+ field: "requiresClientApply",
896
+ tomlKey: "requiresClientApply",
897
+ type: "boolean",
898
+ emit: "always",
899
+ writableOn: BOTH,
900
+ validation: handler("normalizeWorkflowOptOutFlag"),
901
+ },
902
+ // Accepted on BOTH modes — #807's deferred second PATCH is an update
903
+ // call. The CLI's create-only send is a sequencing invariant owned by
904
+ // `applyWorkflowBody`, not a statement about what the server accepts.
905
+ {
906
+ field: "syncCallable",
907
+ tomlKey: "syncCallable",
908
+ type: "boolean",
909
+ emit: "always",
910
+ writableOn: BOTH,
911
+ validation: handler("normalizeWorkflowSyncCallable"),
912
+ },
913
+ // JSON Schema objects. TOML may present them as an inline table OR as a
914
+ // JSON string; the handler owns that parse.
915
+ {
916
+ field: "inputSchema",
917
+ tomlKey: "inputSchema",
918
+ type: "json",
919
+ emit: "whenSet",
920
+ writableOn: BOTH,
921
+ validation: handler("normalizeWorkflowSchemaValue"),
922
+ },
923
+ {
924
+ field: "outputSchema",
925
+ tomlKey: "outputSchema",
926
+ type: "json",
927
+ emit: "whenSet",
928
+ writableOn: BOTH,
929
+ validation: handler("normalizeWorkflowSchemaValue"),
930
+ },
931
+ // #1518/#1972 — declarative run-scoped lock. `[workflow.lock]` inline
932
+ // table on the TOML side, JSON on the wire.
933
+ {
934
+ field: "lock",
935
+ tomlKey: "lock",
936
+ type: "json",
937
+ emit: "whenSet",
938
+ writableOn: BOTH,
939
+ validation: handler("normalizeWorkflowLockValue"),
940
+ },
941
+ ],
942
+ notExposed: {
943
+ workflowId: {
944
+ kind: "server-owned",
945
+ note: "ULID assigned on create; never author-authored.",
946
+ },
947
+ // #2803 — `status` is the ONE availability control and it is
948
+ // server-owned. It used to be a `[workflow]` key, which made `config
949
+ // push` a second writer: pushing a file pulled before an operator ran
950
+ // `workflows disable` put the workflow back in service as a side
951
+ // effect. `server-owned` keeps it out of both the TOML surface and the
952
+ // create/update request schemas, so the only writers are
953
+ // `workflows enable|disable` and create (which assigns `active`).
954
+ status: {
955
+ kind: "server-owned",
956
+ note: "Availability (#2803, #2887): active | inactive | archived. " +
957
+ "Written only by `workflows enable|disable`, the delete flow (a " +
958
+ "plain DELETE writes the `archived` tombstone; `?hard=true` " +
959
+ "destroys), and create, which always assigns `active`. Never " +
960
+ "authored in TOML: `config pull` does not emit it and a file that " +
961
+ "still carries the key fails push (RETIRED_TOML_KEYS).",
962
+ },
963
+ // #2645 criterion 9 — the operational toggle. Configuration is
964
+ // authored in TOML and applied with `config push`, so anything this
965
+ // table exposes has the repo as its single writer. Taking a workflow
966
+ // out of service is an operation with its own single writer
967
+ // (`workflows disable|enable`), so this field must never appear in
968
+ // the `[workflow]` table: `server-owned` keeps it out of both request
969
+ // schemas and the TOML surface, and the coverage guard fails if it is
970
+ // ever moved into `fields`.
971
+ operationallyDisabled: {
972
+ kind: "server-owned",
973
+ note: "Operational toggle (#2645). Written only by the disable/enable " +
974
+ "endpoints (`workflows disable|enable`); read by the dispatch " +
975
+ "path. Never authored in TOML and never accepted on the config " +
976
+ "create/update wire.",
977
+ },
978
+ appId: {
979
+ kind: "server-owned",
980
+ note: "Tenant scope, taken from the route; never on the wire.",
981
+ },
982
+ workflowKey: {
983
+ kind: "structural",
984
+ // Create carries it in the body; update does not read it at all —
985
+ // the URL names the workflow and the CLI checks a contradicting
986
+ // TOML key locally (`workflowKeyMismatchError`).
987
+ requestModes: ["create"],
988
+ note: "The workflow's identity. Comes from the TOML file name / --key and " +
989
+ "is attached by the caller, not spread from the [workflow] table. Its " +
990
+ "TOML spelling is declared in tomlOnlyKeys as `key`.",
991
+ },
992
+ activeConfigId: {
993
+ kind: "server-owned",
994
+ note: "Pointer to the active WorkflowConfiguration, computed on publish.",
995
+ },
996
+ metadataManifest: {
997
+ kind: "structural",
998
+ note: "#1304 declared-access manifest. Travels as the top-level [metadata] / " +
999
+ "secrets tables and is validated by validateManifestShape, not as a " +
1000
+ "[workflow] key.",
1001
+ },
1002
+ // The legacy revision/draft slots are `server-owned`, not `deprecated`:
1003
+ // `deprecated` means "still WRITABLE, superseded by another field"
1004
+ // (`types.ts`), and phase 5's generated request schemas admit every
1005
+ // deprecated key for exactly that reason. None of these six is writable
1006
+ // — create and update never read them off the body; the draft save path
1007
+ // and the R2 writer assign them — so classifying them `deprecated` made
1008
+ // the schema accept a key the handler then dropped, which is the 200-on
1009
+ // -a-key-that-did-nothing this issue exists to end. Their models.yaml
1010
+ // DEPRECATED marking is about the field's future, not about who writes it.
1011
+ latestRevision: {
1012
+ kind: "server-owned",
1013
+ note: "Legacy revision pointer, marked DEPRECATED in models.yaml. " +
1014
+ "Written by the revision save path, never accepted on the wire.",
1015
+ },
1016
+ stepsR2Key: {
1017
+ kind: "server-owned",
1018
+ note: "Legacy steps pointer, marked DEPRECATED in models.yaml. Assigned " +
1019
+ "by the R2 write, never accepted on the wire.",
1020
+ },
1021
+ draftStepsR2Key: {
1022
+ kind: "server-owned",
1023
+ note: "Legacy draft steps pointer, marked DEPRECATED in models.yaml. " +
1024
+ "Assigned by the draft save path, never accepted on the wire.",
1025
+ },
1026
+ draftInputSchema: {
1027
+ kind: "server-owned",
1028
+ note: "Legacy draft slot, marked DEPRECATED in models.yaml. Written by " +
1029
+ "the draft save path from `inputSchema`, never accepted on the wire.",
1030
+ },
1031
+ draftOutputSchema: {
1032
+ kind: "server-owned",
1033
+ note: "Legacy draft slot, marked DEPRECATED in models.yaml. Written by " +
1034
+ "the draft save path from `outputSchema`, never accepted on the wire.",
1035
+ },
1036
+ draftModifiedAt: {
1037
+ kind: "server-owned",
1038
+ note: "Legacy draft timestamp, marked DEPRECATED in models.yaml. Stamped " +
1039
+ "by the draft save path, never accepted on the wire.",
1040
+ },
1041
+ createdBy: {
1042
+ kind: "server-owned",
1043
+ note: "Admin who created the workflow; assigned server-side.",
1044
+ },
1045
+ createdAt: {
1046
+ kind: "server-owned",
1047
+ note: "Timestamp, assigned server-side.",
1048
+ },
1049
+ modifiedAt: {
1050
+ kind: "server-owned",
1051
+ note: "Timestamp, assigned server-side.",
1052
+ },
1053
+ },
1054
+ tomlOnlyKeys: {
1055
+ key: {
1056
+ kind: "structural",
1057
+ note: "The workflow key. `config pull` writes it for readability; push takes " +
1058
+ "the identity from the file name and attaches `workflowKey` itself.",
1059
+ },
1060
+ activeConfigName: {
1061
+ kind: "structural",
1062
+ note: "Names which [[configs]] entry is active. Consumed by the push apply " +
1063
+ "path (which activates the matching config by id), not sent as a " +
1064
+ "[workflow] field.",
1065
+ },
1066
+ },
1067
+ requestOnlyKeys: {
1068
+ steps: {
1069
+ // CREATE only: `updateAppWorkflow` never reads `steps` off the body —
1070
+ // an update writes them through the workflow-config / draft
1071
+ // endpoints (`applyWorkflowBody`) — so a PATCH carrying steps used to
1072
+ // return 200 having saved none of them.
1073
+ modes: CREATE_ONLY,
1074
+ note: "The workflow's step list. Not a `WorkflowDefinition` field — it " +
1075
+ "lives on the active WorkflowConfiguration (and in R2) — but the " +
1076
+ "create call carries it in the same body so a new workflow lands " +
1077
+ "with its steps in one request. Validated by the step allowlist " +
1078
+ "(`cli/src/lib/generated-allowlist.ts`, #998) and the server's " +
1079
+ "definition validation.",
1080
+ },
1081
+ expressions: {
1082
+ // CREATE only, for the same reason as `steps`: they travel on the
1083
+ // steps payload, which an update writes through its own endpoint.
1084
+ modes: CREATE_ONLY,
1085
+ note: "#1614 — the named `expr.*` definitions, which travel on the same " +
1086
+ "steps payload rather than as a definition field.",
1087
+ },
1088
+ },
1089
+ responseOnlyKeys: {
1090
+ platformOwned: {
1091
+ kind: "server-owned",
1092
+ note: "#1660 — derived flag marking a reserved `__internal.` workflow. " +
1093
+ "Computed at serialize time; not persisted, never writable.",
1094
+ },
1095
+ },
1096
+ },
1097
+ ],
1098
+ tomlDocumentKeys: {
1099
+ steps: {
1100
+ kind: "structural",
1101
+ note: "The `[[steps]]` array. Validated by the step allowlist " +
1102
+ "(`cli/src/lib/generated-allowlist.ts`, #998) and the server's " +
1103
+ "definition validation; its shape is checked by validateWorkflowToml.",
1104
+ },
1105
+ configs: {
1106
+ kind: "structural",
1107
+ note: "RETIRED as an inline list (#2645): named configurations are authored " +
1108
+ "one per file at `workflows/<key>.configs/<name>.toml` and selected by " +
1109
+ "`[workflow].activeConfigName`. Pull writes the sidecars, push applies " +
1110
+ "them through the workflow-config endpoints, and a file still carrying " +
1111
+ "`[[configs]]` fails validation (validateWorkflowToml) rather than " +
1112
+ "having the list quietly ignored. Declared here so the document-shape " +
1113
+ "check defers to that targeted message.",
1114
+ },
1115
+ ...DECLARED_ACCESS_MANIFEST_KEYS,
1116
+ expr: {
1117
+ kind: "structural",
1118
+ note: "#1614 — the named `[expr.cel]` guard definitions, which travel on the " +
1119
+ "steps payload (parseWorkflowExpressionsToml).",
1120
+ },
1121
+ include: {
1122
+ kind: "structural",
1123
+ note: "Workflow fragments (`include = [\"name\"]` or repeated `[[include]]` " +
1124
+ "tables), expanded into `steps` by expandWorkflowTomlData before push.",
1125
+ },
1126
+ },
1127
+ };
1128
+ // ── src/config-surface/prompt.ts ─────────────────────────────────────────
1129
+ /**
1130
+ * The `prompt` configuration object's definition (issue #2644, phase 2).
1131
+ *
1132
+ * `prompts/<key>.toml` carries TWO field tables: the `[prompt]` table backed by
1133
+ * `AppPrompt`, and the repeated `[[configs]]` table backed by `AppPromptConfig`
1134
+ * — one entry per named LLM configuration. Both are declared here, so a field
1135
+ * added to either model is covered by the same guard.
1136
+ *
1137
+ * The `[[configs]]` entries are the reason prompts have two tables rather than
1138
+ * a `structural` note: they are a real field surface the CLI pushes
1139
+ * (`createPromptConfig` / `updatePromptConfig`) and pulls, not an opaque blob.
1140
+ */
1141
+ const PROMPT_HANDLER = "src/admin-api.ts";
1142
+ export const PROMPT_SURFACE = {
1143
+ label: "prompt",
1144
+ tables: [
1145
+ {
1146
+ tomlPath: ["prompt"],
1147
+ repeated: false,
1148
+ model: "AppPrompt",
1149
+ // Declaration order reproduces the pre-#2644 serializer's key order.
1150
+ fields: [
1151
+ {
1152
+ field: "displayName",
1153
+ tomlKey: "displayName",
1154
+ type: "string",
1155
+ emit: "always",
1156
+ writableOn: BOTH,
1157
+ validation: "passthrough",
1158
+ },
1159
+ {
1160
+ field: "description",
1161
+ tomlKey: "description",
1162
+ type: "string",
1163
+ emit: "whenSet",
1164
+ writableOn: BOTH,
1165
+ validation: "passthrough",
1166
+ },
1167
+ // JSON Schema objects. Stored as JSON strings, returned parsed; TOML
1168
+ // may present them as an inline table or a JSON string.
1169
+ {
1170
+ field: "inputSchema",
1171
+ tomlKey: "inputSchema",
1172
+ type: "json",
1173
+ emit: "whenSet",
1174
+ writableOn: BOTH,
1175
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
1176
+ },
1177
+ // #2644 — accepted by both handlers and returned by the detail, but
1178
+ // never written to TOML before this: a prompt's declared output shape
1179
+ // was invisible to config-as-code.
1180
+ {
1181
+ field: "outputSchema",
1182
+ tomlKey: "outputSchema",
1183
+ type: "json",
1184
+ emit: "whenSet",
1185
+ writableOn: BOTH,
1186
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
1187
+ },
1188
+ // #2652 — the CEL rule gating who may execute the prompt. Required at
1189
+ // create; an explicit null/"" on update clears it (fail-closed). This
1190
+ // declaration is the whole authoring channel: the admin routes' unknown
1191
+ // key guard reads the same table, so it is what makes `accessRule` an
1192
+ // accepted body key as well as a pulled and pushed TOML line.
1193
+ {
1194
+ field: "accessRule",
1195
+ tomlKey: "accessRule",
1196
+ type: "string",
1197
+ emit: "whenSet",
1198
+ writableOn: BOTH,
1199
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
1200
+ },
1201
+ ],
1202
+ notExposed: {
1203
+ promptId: {
1204
+ kind: "server-owned",
1205
+ note: "ULID assigned on create; never author-authored.",
1206
+ },
1207
+ // #2803 — the OBJECT-level availability, server-owned. Distinct from
1208
+ // the per-version `[[configs]] status` below, which stays a TOML key:
1209
+ // that says which named config is retired, this says whether the
1210
+ // prompt is in service at all.
1211
+ status: {
1212
+ kind: "server-owned",
1213
+ note: "Availability (#2803, #2887): active | inactive | archived. " +
1214
+ "Written only by `prompts enable|disable`, the delete flow (a " +
1215
+ "plain DELETE writes the `archived` tombstone; `?hard=true` " +
1216
+ "destroys), and create, which always assigns `active`. Never " +
1217
+ "authored in TOML; the per-version `[[configs]] status` is " +
1218
+ "unaffected.",
1219
+ },
1220
+ appId: {
1221
+ kind: "server-owned",
1222
+ note: "Tenant scope, taken from the route; never on the wire.",
1223
+ },
1224
+ promptKey: {
1225
+ kind: "structural",
1226
+ // Create carries it in the body; update does not read it at all
1227
+ // (`updateAppPrompt` — the URL names the prompt), so accepting it
1228
+ // there would 200 a rename that changed nothing.
1229
+ requestModes: ["create"],
1230
+ note: "The prompt's identity. Comes from the TOML file name and is " +
1231
+ "attached by the caller, not spread from the [prompt] table. Its " +
1232
+ "TOML spelling is declared in tomlOnlyKeys as `key`.",
1233
+ },
1234
+ copiedFromCatalogId: {
1235
+ kind: "server-owned",
1236
+ note: "Provenance stamp written when a prompt is copied from the catalog.",
1237
+ },
1238
+ activeConfigId: {
1239
+ kind: "server-owned",
1240
+ note: "Pointer to the active AppPromptConfig. Activation is its own " +
1241
+ "endpoint (activatePromptConfig), not a [prompt] key.",
1242
+ },
1243
+ defaultEvaluatorPromptId: {
1244
+ kind: "server-owned",
1245
+ note: "Link to the evaluator prompt, written when an evaluator is created " +
1246
+ "against this prompt. Managed through the prompts API, not TOML.",
1247
+ },
1248
+ createdBy: {
1249
+ kind: "server-owned",
1250
+ note: "Admin who created the prompt; assigned server-side.",
1251
+ },
1252
+ createdAt: {
1253
+ kind: "server-owned",
1254
+ note: "Timestamp, assigned server-side.",
1255
+ },
1256
+ modifiedAt: {
1257
+ kind: "server-owned",
1258
+ note: "Timestamp, assigned server-side.",
1259
+ },
1260
+ },
1261
+ requestOnlyKeys: {
1262
+ defaultEvaluatorPromptId: {
1263
+ note: "Writable through the prompts API (the console links an evaluator " +
1264
+ "prompt) but never through TOML, which is why it is classified " +
1265
+ "`server-owned` for the sync surface and named here for the request " +
1266
+ "surface. Sending it is accepted; sync never does.",
1267
+ },
1268
+ },
1269
+ tomlOnlyKeys: {
1270
+ key: {
1271
+ kind: "structural",
1272
+ note: "The prompt key. `config pull` writes it for readability; push takes " +
1273
+ "the identity from the file name and attaches `promptKey` itself.",
1274
+ },
1275
+ },
1276
+ responseOnlyKeys: {
1277
+ configs: {
1278
+ kind: "structural",
1279
+ note: "The prompt's AppPromptConfig rows, returned inline on the detail " +
1280
+ "and written as the repeated [[configs]] table below.",
1281
+ },
1282
+ },
1283
+ },
1284
+ {
1285
+ tomlPath: ["configs"],
1286
+ repeated: true,
1287
+ model: "AppPromptConfig",
1288
+ fields: [
1289
+ {
1290
+ field: "configName",
1291
+ tomlKey: "name",
1292
+ type: "string",
1293
+ emit: "always",
1294
+ writableOn: BOTH,
1295
+ // Renamable on update, uniqueness-checked within the prompt.
1296
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
1297
+ },
1298
+ // #2645 — a config's status is configuration: `prompts configs update
1299
+ // --status archived` used to be the only way to retire one, and pull
1300
+ // emitted nothing, so the file could not say which of its configs the
1301
+ // server still runs. Update-only: create hard-codes "active" and
1302
+ // ignores a supplied status, same statement `writableOn` makes for
1303
+ // every create-assigned field.
1304
+ {
1305
+ field: "status",
1306
+ tomlKey: "status",
1307
+ type: "string",
1308
+ emit: "always",
1309
+ writableOn: UPDATE_ONLY,
1310
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
1311
+ },
1312
+ {
1313
+ field: "description",
1314
+ tomlKey: "description",
1315
+ type: "string",
1316
+ emit: "whenSet",
1317
+ writableOn: BOTH,
1318
+ validation: "passthrough",
1319
+ },
1320
+ {
1321
+ field: "provider",
1322
+ tomlKey: "provider",
1323
+ type: "string",
1324
+ emit: "always",
1325
+ writableOn: BOTH,
1326
+ // openrouter | gemini, lower-cased and cross-checked against the model.
1327
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
1328
+ },
1329
+ {
1330
+ field: "model",
1331
+ tomlKey: "model",
1332
+ type: "string",
1333
+ emit: "always",
1334
+ writableOn: BOTH,
1335
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
1336
+ },
1337
+ {
1338
+ field: "systemPrompt",
1339
+ tomlKey: "systemPrompt",
1340
+ type: "string",
1341
+ emit: "whenSet",
1342
+ writableOn: BOTH,
1343
+ // Offloaded to R2 above the inline size limit and read back
1344
+ // transparently, so the TOML value is the whole prompt either way.
1345
+ validation: handledBy("src/services/large-field-storage.ts", "storeField"),
1346
+ },
1347
+ {
1348
+ field: "userPromptTemplate",
1349
+ tomlKey: "userPromptTemplate",
1350
+ type: "string",
1351
+ emit: "whenSet",
1352
+ writableOn: BOTH,
1353
+ validation: handledBy("src/services/large-field-storage.ts", "storeField"),
1354
+ },
1355
+ {
1356
+ field: "temperature",
1357
+ tomlKey: "temperature",
1358
+ type: "number",
1359
+ emit: "whenSet",
1360
+ writableOn: BOTH,
1361
+ // Stored as a string on the model, returned parsed as a number.
1362
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
1363
+ dualEncoded: {
1364
+ note: "#2869 — a `StringField` on the model: the server stores what it " +
1365
+ "is given and returns `parseFloat` of it, so `temperature = " +
1366
+ '"0.2"` and `temperature = 0.2` are one value. Both spellings ' +
1367
+ "compare equal; neither is a validation error.",
1368
+ },
1369
+ },
1370
+ // #2644 — accepted by both handlers, returned by the detail, and never
1371
+ // written to TOML before this.
1372
+ {
1373
+ field: "topP",
1374
+ tomlKey: "topP",
1375
+ type: "number",
1376
+ emit: "whenSet",
1377
+ writableOn: BOTH,
1378
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
1379
+ dualEncoded: {
1380
+ note: "#2869 — a `StringField` returned parsed, exactly as `temperature`.",
1381
+ },
1382
+ },
1383
+ {
1384
+ field: "maxTokens",
1385
+ tomlKey: "maxTokens",
1386
+ type: "number",
1387
+ emit: "whenSet",
1388
+ writableOn: BOTH,
1389
+ validation: "passthrough",
1390
+ },
1391
+ {
1392
+ field: "outputFormat",
1393
+ tomlKey: "outputFormat",
1394
+ type: "string",
1395
+ emit: "whenSet",
1396
+ writableOn: BOTH,
1397
+ validation: "passthrough",
1398
+ },
1399
+ // #2644 — the per-config structured-output schema and provider options.
1400
+ // Both are accepted by the config endpoints and were invisible to sync.
1401
+ {
1402
+ field: "outputSchema",
1403
+ tomlKey: "outputSchema",
1404
+ type: "json",
1405
+ emit: "whenSet",
1406
+ writableOn: BOTH,
1407
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
1408
+ },
1409
+ {
1410
+ field: "providerConfig",
1411
+ tomlKey: "providerConfig",
1412
+ type: "json",
1413
+ emit: "whenSet",
1414
+ writableOn: BOTH,
1415
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
1416
+ },
1417
+ ],
1418
+ notExposed: {
1419
+ configId: {
1420
+ kind: "server-owned",
1421
+ note: "ULID assigned on create; never author-authored.",
1422
+ },
1423
+ promptId: {
1424
+ kind: "server-owned",
1425
+ note: "Parent pointer, taken from the route; never on the wire.",
1426
+ },
1427
+ appId: {
1428
+ kind: "server-owned",
1429
+ note: "Tenant scope, taken from the route; never on the wire.",
1430
+ },
1431
+ promptIdConfigName: {
1432
+ kind: "server-owned",
1433
+ note: "Composite uniqueness key derived from promptId + configName.",
1434
+ },
1435
+ systemPromptR2Key: {
1436
+ kind: "server-owned",
1437
+ note: "Where an oversized systemPrompt was offloaded. Assigned by " +
1438
+ "storeField; the value itself round-trips through `systemPrompt`.",
1439
+ },
1440
+ userPromptTemplateR2Key: {
1441
+ kind: "server-owned",
1442
+ note: "Where an oversized userPromptTemplate was offloaded. Assigned by " +
1443
+ "storeField; the value round-trips through `userPromptTemplate`.",
1444
+ },
1445
+ createdBy: {
1446
+ kind: "server-owned",
1447
+ note: "Admin who created the config; assigned server-side.",
1448
+ },
1449
+ createdAt: {
1450
+ kind: "server-owned",
1451
+ note: "Timestamp, assigned server-side.",
1452
+ },
1453
+ modifiedAt: {
1454
+ kind: "server-owned",
1455
+ note: "Timestamp, assigned server-side.",
1456
+ },
1457
+ },
1458
+ tomlOnlyKeys: {
1459
+ // #2645 — the live-config marker. Exactly one [[configs]] entry may
1460
+ // carry `active = true`; pull writes it on the active entry and push
1461
+ // honors it on create AND update by calling the activate endpoint
1462
+ // (activatePromptConfig). It is a control, never a key in the config
1463
+ // body.
1464
+ active: {
1465
+ kind: "structural",
1466
+ note: "Which config the prompt runs (#2645). Pull writes it on the " +
1467
+ "active entry; push enacts it through the activate endpoint " +
1468
+ "(activatePromptConfig), never as a config-body key.",
1469
+ },
1470
+ isActive: {
1471
+ kind: "structural",
1472
+ note: "Legacy spelling of `active` — the create leg read it before pull " +
1473
+ "ever wrote a marker. Declared here so the unrecognized-key " +
1474
+ "rejection does not fail a file that has always used it; pull " +
1475
+ "never writes it.",
1476
+ },
1477
+ },
1478
+ responseOnlyKeys: {},
1479
+ },
1480
+ ],
1481
+ // Both of a prompt file's tables ARE field tables (`[prompt]` and the
1482
+ // repeated `[[configs]]`), so there is no third top-level channel. The
1483
+ // sidecar test cases live in `prompts/<key>.tests/` — their own surface.
1484
+ tomlDocumentKeys: {},
1485
+ };
1486
+ // ── src/config-surface/integration.ts ────────────────────────────────────
1487
+ /**
1488
+ * The `integration` configuration object's definition (issue #2644, phase 2).
1489
+ *
1490
+ * `integrations/<key>.toml` carries one `[integration]` field table plus the
1491
+ * top-level `[requestConfig]` table, which is authored structure with its own
1492
+ * validator (`serializeRequestConfig`) and is therefore declared `structural`
1493
+ * rather than re-modelled here.
1494
+ *
1495
+ * ── On the `validation` classifications ──────────────────────────────────
1496
+ * Phase 1 decomposed the workflow handler into one exported normalizer per
1497
+ * field. `createAppIntegration` / `updateAppIntegration` are not decomposed yet,
1498
+ * so a field with real behavior names the module and export that owns it TODAY
1499
+ * (`src/admin-api.ts#AdminAPI`, or the shared helper where one already exists).
1500
+ * The classification is what makes the field visible to every guard; splitting
1501
+ * those handlers up is a later phase's job, not a reason to leave the field
1502
+ * undeclared.
1503
+ */
1504
+ const INTEGRATION_HANDLER = "src/admin-api.ts";
1505
+ export const INTEGRATION_SURFACE = {
1506
+ label: "integration",
1507
+ tables: [
1508
+ {
1509
+ tomlPath: ["integration"],
1510
+ repeated: false,
1511
+ model: "AppIntegration",
1512
+ // Declaration order is the order `config pull` writes the keys — it matches
1513
+ // the pre-#2644 hand-written serializer so migrating does not reshuffle
1514
+ // every existing integrations/*.toml.
1515
+ fields: [
1516
+ {
1517
+ field: "displayName",
1518
+ tomlKey: "displayName",
1519
+ type: "string",
1520
+ emit: "always",
1521
+ writableOn: BOTH,
1522
+ validation: "passthrough",
1523
+ },
1524
+ {
1525
+ field: "description",
1526
+ tomlKey: "description",
1527
+ type: "string",
1528
+ emit: "whenSet",
1529
+ writableOn: BOTH,
1530
+ validation: "passthrough",
1531
+ },
1532
+ {
1533
+ field: "timeoutMs",
1534
+ tomlKey: "timeoutMs",
1535
+ type: "number",
1536
+ emit: "always",
1537
+ writableOn: BOTH,
1538
+ validation: handledBy(INTEGRATION_HANDLER, "AdminAPI"),
1539
+ // `src/admin-api.ts` createAppIntegration: a create with no
1540
+ // `timeoutMs` stores 300000. Recorded so a hand-authored file that
1541
+ // omits the key compares (and pushes) as the value the server holds
1542
+ // rather than diffing `Modified` forever (#2880 DSO-002).
1543
+ serverDefault: 300000,
1544
+ },
1545
+ // #2644 — accepted by both handlers and returned on the detail, but
1546
+ // never written to TOML before this. Declaring it is what makes it
1547
+ // authorable; leaving it out would be the silent gap this epic closes.
1548
+ {
1549
+ field: "maxRequestBodyBytes",
1550
+ tomlKey: "maxRequestBodyBytes",
1551
+ type: "number",
1552
+ emit: "whenSet",
1553
+ writableOn: BOTH,
1554
+ validation: handledBy(INTEGRATION_HANDLER, "AdminAPI"),
1555
+ // Same create default, same reason (#2880 DSO-002): 1 MiB.
1556
+ serverDefault: 1048576,
1557
+ },
1558
+ // #2631 — the CEL rule gating who may invoke the integration. Required
1559
+ // at create; an explicit null/"" on update clears it (fail-closed).
1560
+ {
1561
+ field: "accessRule",
1562
+ tomlKey: "accessRule",
1563
+ type: "string",
1564
+ emit: "whenSet",
1565
+ writableOn: BOTH,
1566
+ validation: handledBy(INTEGRATION_HANDLER, "AdminAPI"),
1567
+ },
1568
+ ],
1569
+ notExposed: {
1570
+ integrationId: {
1571
+ kind: "server-owned",
1572
+ note: "ULID assigned on create; never author-authored.",
1573
+ },
1574
+ // #2803 — availability, server-owned. `draft` is gone from the model;
1575
+ // a stored one reads `inactive` through the canonical normalizer.
1576
+ status: {
1577
+ kind: "server-owned",
1578
+ note: "Availability (#2803): active | inactive | archived. Written only " +
1579
+ "by `integrations enable|disable`, by the delete flow's tombstone, " +
1580
+ "and by create, which always assigns `active`. Never authored in " +
1581
+ "TOML: pull does not emit it and pull skips archived rows.",
1582
+ },
1583
+ appId: {
1584
+ kind: "server-owned",
1585
+ note: "Tenant scope, taken from the route; never on the wire.",
1586
+ },
1587
+ integrationKey: {
1588
+ kind: "structural",
1589
+ // Both modes on purpose. Update ignores the key (`updateAppIntegration`
1590
+ // takes the target from the URL), but every shipped `config push`
1591
+ // attaches it to the update body — criterion 9's breaking change is
1592
+ // for stray keys, not for the normal request of the CLI in the field.
1593
+ requestModes: ["create", "update"],
1594
+ note: "The integration's identity. Comes from the TOML file name and is " +
1595
+ "attached by the caller, not spread from the [integration] table. " +
1596
+ "Its TOML spelling is declared in tomlOnlyKeys as `key`.",
1597
+ },
1598
+ requestConfig: {
1599
+ kind: "structural",
1600
+ note: "The upstream request definition. Authored as the top-level " +
1601
+ "[requestConfig] table and validated by serializeRequestConfig " +
1602
+ "(src/services/integration-utils.ts), not as an [integration] key.",
1603
+ },
1604
+ activeConfigId: {
1605
+ kind: "server-owned",
1606
+ note: "#1183 — pointer to the active versioned config, set on publish.",
1607
+ },
1608
+ copiedFromCatalogId: {
1609
+ kind: "server-owned",
1610
+ note: "Provenance stamp written when an integration is copied from the catalog.",
1611
+ },
1612
+ lastTestedAt: {
1613
+ kind: "server-owned",
1614
+ note: "Timestamp of the last test invocation; written by the test endpoint.",
1615
+ },
1616
+ createdBy: {
1617
+ kind: "server-owned",
1618
+ note: "Admin who created the integration; assigned server-side.",
1619
+ },
1620
+ createdAt: {
1621
+ kind: "server-owned",
1622
+ note: "Timestamp, assigned server-side.",
1623
+ },
1624
+ modifiedAt: {
1625
+ kind: "server-owned",
1626
+ note: "Timestamp, assigned server-side.",
1627
+ },
1628
+ },
1629
+ tomlOnlyKeys: {
1630
+ key: {
1631
+ kind: "structural",
1632
+ note: "The integration key. `config pull` writes it for readability; push " +
1633
+ "takes the identity from the file name and attaches " +
1634
+ "`integrationKey` itself.",
1635
+ },
1636
+ },
1637
+ responseOnlyKeys: {},
1638
+ },
1639
+ ],
1640
+ tomlDocumentKeys: {
1641
+ requestConfig: {
1642
+ kind: "structural",
1643
+ note: "The upstream request definition (method, url, headers, body). " +
1644
+ "Authored as its own top-level table and stored as the integration's " +
1645
+ "`requestConfig`; the server validates its shape.",
1646
+ },
1647
+ },
1648
+ };
1649
+ // ── src/config-surface/webhook.ts ────────────────────────────────────────
1650
+ /**
1651
+ * The `webhook` configuration object's definition (issue #2644, phase 2).
1652
+ *
1653
+ * `webhooks/<key>.toml` carries one `[webhook]` field table plus four authored
1654
+ * structures with their own channels — `[verification.<scheme>]` (the stored
1655
+ * `config` blob), `[allowedIps]`, `[inputMapping]` and `[metadata]`. Each is
1656
+ * declared `structural` here with the code that owns it named, so the reason is
1657
+ * visible rather than implied by absence.
1658
+ *
1659
+ * ── Secret-adjacent fields ───────────────────────────────────────────────
1660
+ * `signingSecret` is EXPOSED with a reference-only contract (#2254): the server
1661
+ * returns a whole `{{secrets.KEY}}` reference or `null` and never the
1662
+ * credential, which is exactly what makes `config pull` → `config push` lossless
1663
+ * (#1235). `previousSigningSecret` is `notExposed: secret` — a rotation slot the
1664
+ * author does not write. The derived `signingSecretStatus` / `…Health` keys are
1665
+ * response-only. The coverage guard fails on a bare omission of any of these
1666
+ * (`/secret|token|password|credential/i`), so none of it can be decided by
1667
+ * silence.
1668
+ *
1669
+ * On the `validation` classifications: `createAppWebhook` / `updateAppWebhook`
1670
+ * are not decomposed into per-field handlers, so a field names the shared
1671
+ * validator that owns it where one exists, and `src/admin-api.ts#AdminAPI`
1672
+ * otherwise.
1673
+ */
1674
+ const WEBHOOK_HANDLER = "src/admin-api.ts";
1675
+ const WEBHOOK_CONFIG_VALIDATION = "src/app-api/services/webhook-verification/config-validation.ts";
1676
+ export const WEBHOOK_SURFACE = {
1677
+ label: "webhook",
1678
+ tables: [
1679
+ {
1680
+ tomlPath: ["webhook"],
1681
+ repeated: false,
1682
+ model: "AppWebhook",
1683
+ // Declaration order reproduces the pre-#2644 serializer's key order.
1684
+ fields: [
1685
+ {
1686
+ field: "displayName",
1687
+ tomlKey: "displayName",
1688
+ type: "string",
1689
+ emit: "always",
1690
+ writableOn: BOTH,
1691
+ validation: "passthrough",
1692
+ },
1693
+ {
1694
+ field: "description",
1695
+ tomlKey: "description",
1696
+ type: "string",
1697
+ emit: "whenSet",
1698
+ writableOn: BOTH,
1699
+ validation: "passthrough",
1700
+ },
1701
+ {
1702
+ field: "workflowKey",
1703
+ tomlKey: "workflowKey",
1704
+ type: "string",
1705
+ emit: "always",
1706
+ writableOn: BOTH,
1707
+ validation: "passthrough",
1708
+ },
1709
+ {
1710
+ field: "verificationScheme",
1711
+ tomlKey: "verificationScheme",
1712
+ type: "string",
1713
+ emit: "always",
1714
+ writableOn: BOTH,
1715
+ validation: handledBy(WEBHOOK_HANDLER, "AdminAPI"),
1716
+ },
1717
+ {
1718
+ field: "toleranceSeconds",
1719
+ tomlKey: "toleranceSeconds",
1720
+ type: "number",
1721
+ emit: "always",
1722
+ writableOn: BOTH,
1723
+ validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "validateReplayProtection"),
1724
+ },
1725
+ {
1726
+ field: "deduplicationEnabled",
1727
+ tomlKey: "deduplicationEnabled",
1728
+ type: "boolean",
1729
+ emit: "always",
1730
+ writableOn: BOTH,
1731
+ validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "validateReplayProtection"),
1732
+ },
1733
+ {
1734
+ field: "deduplicationWindowMs",
1735
+ tomlKey: "deduplicationWindowMs",
1736
+ type: "number",
1737
+ emit: "always",
1738
+ writableOn: BOTH,
1739
+ validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "validateReplayProtection"),
1740
+ },
1741
+ // Per-webhook inbound body cap. Unset means the platform default
1742
+ // (5 MiB), so it is omitted from TOML rather than written as a zero.
1743
+ {
1744
+ field: "maxBodyBytes",
1745
+ tomlKey: "maxBodyBytes",
1746
+ type: "number",
1747
+ emit: "whenSet",
1748
+ writableOn: BOTH,
1749
+ validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "validateMaxBodyBytes"),
1750
+ },
1751
+ {
1752
+ field: "secretGracePeriodMs",
1753
+ tomlKey: "secretGracePeriodMs",
1754
+ type: "number",
1755
+ emit: "always",
1756
+ writableOn: BOTH,
1757
+ validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "normalizeSecretGracePeriodMs"),
1758
+ },
1759
+ // #2254 — reference-only: a whole `{{secrets.KEY}}` reference naming an
1760
+ // existing app secret, and nothing else. The server never returns
1761
+ // credential material here, so the reference round-trips verbatim
1762
+ // (#1235) and a legacy literal is withheld (no line is written).
1763
+ {
1764
+ field: "signingSecret",
1765
+ tomlKey: "signingSecret",
1766
+ type: "string",
1767
+ emit: "whenSet",
1768
+ writableOn: BOTH,
1769
+ validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "validateWholeSecretReference"),
1770
+ },
1771
+ ],
1772
+ notExposed: {
1773
+ webhookId: {
1774
+ kind: "server-owned",
1775
+ note: "ULID assigned on create; never author-authored.",
1776
+ },
1777
+ // #2803 — availability is server-owned. As a `[webhook]` key it made
1778
+ // `config push` a second writer: a file pulled before an operator
1779
+ // paused the webhook put the endpoint back in service on the next
1780
+ // push. `server-owned` keeps it out of the TOML surface AND out of the
1781
+ // create/update request schemas, closing the tenant app-API path that
1782
+ // stored `body.status` unvalidated.
1783
+ status: {
1784
+ kind: "server-owned",
1785
+ note: "Availability (#2803): active | inactive | archived. Written only " +
1786
+ "by `webhooks enable|disable` and by the delete flow's tombstone. " +
1787
+ "Never authored in TOML: pull does not emit it, pull skips " +
1788
+ "archived rows entirely, and a file carrying the key fails push.",
1789
+ },
1790
+ appId: {
1791
+ kind: "server-owned",
1792
+ note: "Tenant scope, taken from the route; never on the wire.",
1793
+ },
1794
+ webhookKey: {
1795
+ kind: "structural",
1796
+ // Both modes: the update handlers ignore it, but the shipped CLI
1797
+ // attaches it to every webhook update body (see integration.ts).
1798
+ requestModes: ["create", "update"],
1799
+ note: "The webhook's identity. Comes from the TOML file name and is " +
1800
+ "attached by the caller, not spread from the [webhook] table. Its " +
1801
+ "TOML spelling is declared in tomlOnlyKeys as `key`.",
1802
+ },
1803
+ workflowId: {
1804
+ kind: "server-owned",
1805
+ note: "Resolved from `workflowKey` server-side; the author writes the key.",
1806
+ },
1807
+ previousSigningSecret: {
1808
+ kind: "secret",
1809
+ note: "#2383 rotation slot. Written by the rotate endpoint, never by an " +
1810
+ "author, and reported through the derived signingSecretStatus " +
1811
+ "fields rather than as a TOML key.",
1812
+ },
1813
+ secretRotatedAt: {
1814
+ kind: "server-owned",
1815
+ note: "Stamped by the rotate endpoint; reported, never authored.",
1816
+ },
1817
+ config: {
1818
+ kind: "structural",
1819
+ note: "Per-scheme verification settings. Authored as the top-level " +
1820
+ "[verification.<scheme>] table and converted by " +
1821
+ "webhookConfigToTomlTable / tomlTableToWebhookConfig, whose stored " +
1822
+ "shape differs per scheme (#2109), not as a [webhook] key.",
1823
+ },
1824
+ allowedIpCidrs: {
1825
+ kind: "structural",
1826
+ note: "Authored as the top-level [allowedIps] table (`cidrs = [...]`) and " +
1827
+ "stored as a JSON string, not as a [webhook] key.",
1828
+ },
1829
+ inputMapping: {
1830
+ kind: "structural",
1831
+ note: "Authored as the top-level [inputMapping] table and stored as a " +
1832
+ "JSON string, not as a [webhook] key.",
1833
+ },
1834
+ metadata: {
1835
+ kind: "structural",
1836
+ note: "Authored as the top-level [metadata] table and stored as a JSON " +
1837
+ "string, not as a [webhook] key.",
1838
+ },
1839
+ handshakeRules: {
1840
+ kind: "structural",
1841
+ note: "Provider handshake/challenge rules, validated by " +
1842
+ "src/app-api/services/webhook-handshake.ts. Accepted by the admin " +
1843
+ "API but not part of the TOML surface — `config push` never sends it, " +
1844
+ "so the stored value is left alone rather than cleared.",
1845
+ },
1846
+ lastTriggeredAt: {
1847
+ kind: "server-owned",
1848
+ note: "Stamped by the receiver on delivery; reported, never authored.",
1849
+ },
1850
+ createdBy: {
1851
+ kind: "server-owned",
1852
+ note: "User who created the webhook; assigned server-side.",
1853
+ },
1854
+ createdAt: {
1855
+ kind: "server-owned",
1856
+ note: "Timestamp, assigned server-side.",
1857
+ },
1858
+ modifiedAt: {
1859
+ kind: "server-owned",
1860
+ note: "Timestamp, assigned server-side.",
1861
+ },
1862
+ },
1863
+ tomlOnlyKeys: {
1864
+ key: {
1865
+ kind: "structural",
1866
+ note: "The webhook key. `config pull` writes it for readability; push takes " +
1867
+ "the identity from the file name and attaches `webhookKey` itself.",
1868
+ },
1869
+ },
1870
+ responseOnlyKeys: {
1871
+ signingSecretStatus: {
1872
+ kind: "server-owned",
1873
+ note: "#2254 — derived disclosure state of the stored secret (reference / " +
1874
+ "withheld literal / unset). Computed at serialize time; read-only.",
1875
+ },
1876
+ previousSigningSecretStatus: {
1877
+ kind: "server-owned",
1878
+ note: "Same derivation for the rotation slot. Read-only.",
1879
+ },
1880
+ signingSecretHealth: {
1881
+ kind: "server-owned",
1882
+ note: "#2383 — derived health report on the stored secret. Read-only.",
1883
+ },
1884
+ previousSigningSecretHealth: {
1885
+ kind: "server-owned",
1886
+ note: "Same report for the rotation slot. Read-only.",
1887
+ },
1888
+ },
1889
+ },
1890
+ ],
1891
+ tomlDocumentKeys: {
1892
+ verification: {
1893
+ kind: "structural",
1894
+ note: "The `[verification.<scheme>]` table carrying the scheme's settings " +
1895
+ "(#2109). Converted to/from the stored `config` blob by " +
1896
+ "webhookConfigToTomlTable / tomlTableToWebhookConfig.",
1897
+ },
1898
+ allowedIps: {
1899
+ kind: "structural",
1900
+ note: "`[allowedIps] cidrs = [...]` — the source allow-list, stored as the " +
1901
+ "JSON string `allowedIpCidrs`.",
1902
+ },
1903
+ inputMapping: {
1904
+ kind: "structural",
1905
+ note: "The authored payload mapping, stored as the JSON string " +
1906
+ "`inputMapping`; a table rather than a [webhook] key so it can nest.",
1907
+ },
1908
+ metadata: {
1909
+ kind: "structural",
1910
+ note: "The webhook's authored metadata table, stored as the JSON string " +
1911
+ "`metadata`. Unrelated to the declared-access manifest of the type " +
1912
+ "configs, which spells `[metadata]` the same way.",
1913
+ },
1914
+ },
1915
+ };
1916
+ // ── src/config-surface/cron-trigger.ts ───────────────────────────────────
1917
+ /**
1918
+ * The `cron-trigger` configuration object's definition (issue #2644, phase 2).
1919
+ *
1920
+ * `cron-triggers/<key>.toml` carries one `[cronTrigger]` field table plus the
1921
+ * top-level `[rootInput]` and `[inputMapping]` tables, which are authored JSON
1922
+ * structures with their own normalizer and are declared `structural`.
1923
+ *
1924
+ * `state` is classified `structural` (update-only), not a field (#2645):
1925
+ * pausing moved onto the `operationallyDisabled` toggle written by
1926
+ * `cron-triggers pause|resume`, so `state` left the TOML surface — pull does
1927
+ * not emit it, and push REJECTS a file that still carries the line (with
1928
+ * guidance naming the pause verbs; see `RETIRED_TOML_KEYS` in the CLI's
1929
+ * `commands/sync.ts`) — while the update handler keeps reading it for the
1930
+ * console.
1931
+ */
1932
+ const CRON_TRIGGER_HANDLER = "src/app-api/controllers/cron-triggers-controller.ts";
1933
+ export const CRON_TRIGGER_SURFACE = {
1934
+ label: "cron-trigger",
1935
+ tables: [
1936
+ {
1937
+ tomlPath: ["cronTrigger"],
1938
+ repeated: false,
1939
+ model: "CronTrigger",
1940
+ // Declaration order reproduces the pre-#2644 serializer's key order.
1941
+ fields: [
1942
+ {
1943
+ field: "displayName",
1944
+ tomlKey: "displayName",
1945
+ type: "string",
1946
+ emit: "always",
1947
+ writableOn: BOTH,
1948
+ validation: "passthrough",
1949
+ },
1950
+ {
1951
+ field: "description",
1952
+ tomlKey: "description",
1953
+ type: "string",
1954
+ emit: "whenSet",
1955
+ writableOn: BOTH,
1956
+ validation: "passthrough",
1957
+ },
1958
+ {
1959
+ field: "cron",
1960
+ tomlKey: "cron",
1961
+ type: "string",
1962
+ emit: "always",
1963
+ writableOn: BOTH,
1964
+ validation: handledBy("src/cron-parser.ts", "parseCron"),
1965
+ },
1966
+ {
1967
+ field: "timezone",
1968
+ tomlKey: "timezone",
1969
+ type: "string",
1970
+ emit: "always",
1971
+ writableOn: BOTH,
1972
+ // Validated against Intl; unset reads as "UTC".
1973
+ validation: handledBy(CRON_TRIGGER_HANDLER, "CronTriggersController"),
1974
+ // `cron-triggers-controller.ts` stores `timezone || "UTC"`, so a file
1975
+ // that omits the key describes a trigger running in UTC — compared
1976
+ // and pushed as that, rather than as a difference no push can clear
1977
+ // (#2880 DSO-002).
1978
+ serverDefault: "UTC",
1979
+ },
1980
+ {
1981
+ field: "workflowKey",
1982
+ tomlKey: "workflowKey",
1983
+ type: "string",
1984
+ emit: "always",
1985
+ writableOn: BOTH,
1986
+ // #1002 — a reserved `__internal.` key is rejected at save time.
1987
+ validation: handledBy(CRON_TRIGGER_HANDLER, "CronTriggersController"),
1988
+ },
1989
+ {
1990
+ field: "overlapPolicy",
1991
+ tomlKey: "overlapPolicy",
1992
+ type: "string",
1993
+ emit: "always",
1994
+ writableOn: BOTH,
1995
+ // skip | allow.
1996
+ validation: handledBy(CRON_TRIGGER_HANDLER, "CronTriggersController"),
1997
+ // Same store-time default as `timezone` (#2880 DSO-002).
1998
+ serverDefault: "skip",
1999
+ },
2000
+ ],
2001
+ notExposed: {
2002
+ // #2803 — the ONE availability control, server-owned. Written only by
2003
+ // `cron-triggers enable|disable`, the delete flow (the `archived`
2004
+ // tombstone) and the alarm's error-pause path. `server-owned` keeps it
2005
+ // out of both the TOML surface and the create/update request schemas,
2006
+ // so no configuration write can change availability as a side effect.
2007
+ status: {
2008
+ kind: "server-owned",
2009
+ note: "Availability (#2803): active | inactive | archived. Written only " +
2010
+ "by `cron-triggers enable|disable`, the delete flow and the " +
2011
+ "error-pause path. Never authored in TOML: pull does not emit it " +
2012
+ "and a file that still carries the key fails push.",
2013
+ },
2014
+ // #2645 criterion 9 moved pausing off `state`; #2803 retired the field
2015
+ // entirely. It mixed lifecycle (`archived`, `error_paused`) with
2016
+ // availability (`paused`), which is what made two writers possible in
2017
+ // the first place. `server-owned` now, not `structural` with an
2018
+ // update-only mode: the console's carve-out is gone with it, so the
2019
+ // generic update rejects a supplied `state` exactly as create always
2020
+ // did.
2021
+ state: {
2022
+ kind: "server-owned",
2023
+ note: "DEPRECATED (#2803), superseded by `status`. Read only by " +
2024
+ "`canonicalCronStatus()`; normalized back to `active` by " +
2025
+ "`cron-triggers enable`. Never authored in TOML and never accepted " +
2026
+ "on the wire.",
2027
+ },
2028
+ operationallyDisabled: {
2029
+ kind: "server-owned",
2030
+ note: "DEPRECATED (#2803), superseded by `status`. The #2645 operational " +
2031
+ "toggle, cleared by `cron-triggers enable|disable` as rows are " +
2032
+ "touched. Never authored in TOML and never accepted on the config " +
2033
+ "create/update wire.",
2034
+ },
2035
+ triggerId: {
2036
+ kind: "server-owned",
2037
+ note: "ULID assigned on create; never author-authored.",
2038
+ },
2039
+ appId: {
2040
+ kind: "server-owned",
2041
+ note: "Tenant scope, taken from the route; never on the wire.",
2042
+ },
2043
+ triggerKey: {
2044
+ kind: "structural",
2045
+ // Both modes: the update handler ignores it, but the shipped CLI
2046
+ // attaches it to every cron-trigger update body (see integration.ts).
2047
+ requestModes: ["create", "update"],
2048
+ note: "The trigger's identity. Comes from the TOML file name and is " +
2049
+ "attached by the caller, not spread from the [cronTrigger] table. " +
2050
+ "Its TOML spelling is declared in tomlOnlyKeys as `key`.",
2051
+ },
2052
+ rootInput: {
2053
+ kind: "structural",
2054
+ note: "Authored as the top-level [rootInput] table and stored as a JSON " +
2055
+ "string (normalizeJsonString), not as a [cronTrigger] key.",
2056
+ },
2057
+ inputMapping: {
2058
+ kind: "structural",
2059
+ note: "Authored as the top-level [inputMapping] table and stored as a " +
2060
+ "JSON string (normalizeJsonString), not as a [cronTrigger] key.",
2061
+ },
2062
+ lastError: {
2063
+ kind: "server-owned",
2064
+ note: "Last fire failure, written by the trigger DO and cleared on resume.",
2065
+ },
2066
+ lastTriggeredAt: {
2067
+ kind: "server-owned",
2068
+ note: "Stamped on fire; reported, never authored.",
2069
+ },
2070
+ lastTriggeredRunId: {
2071
+ kind: "server-owned",
2072
+ note: "Run started by the last fire; reported, never authored.",
2073
+ },
2074
+ skippedCount: {
2075
+ kind: "server-owned",
2076
+ note: "Runtime counter maintained by the trigger DO.",
2077
+ },
2078
+ firedCount: {
2079
+ kind: "server-owned",
2080
+ note: "Runtime counter maintained by the trigger DO.",
2081
+ },
2082
+ consecutiveNotActiveCount: {
2083
+ kind: "server-owned",
2084
+ note: "Runtime counter behind the auto-pause rule; DO-maintained.",
2085
+ },
2086
+ nextFireAt: {
2087
+ kind: "server-owned",
2088
+ note: "Projected next fire, computed from the schedule by the DO.",
2089
+ },
2090
+ createdBy: {
2091
+ kind: "server-owned",
2092
+ note: "User who created the trigger; assigned server-side.",
2093
+ },
2094
+ createdAt: {
2095
+ kind: "server-owned",
2096
+ note: "Timestamp, assigned server-side.",
2097
+ },
2098
+ modifiedAt: {
2099
+ kind: "server-owned",
2100
+ note: "Timestamp, assigned server-side.",
2101
+ },
2102
+ },
2103
+ requestOnlyKeys: {
2104
+ key: {
2105
+ // CREATE only: `update` never reads it — the trigger is addressed by
2106
+ // id in the path, and its key is immutable — so a `key` sent on
2107
+ // update is a 400 rather than a silently ignored rename.
2108
+ modes: CREATE_ONLY,
2109
+ note: "Alias for `triggerKey` on create, accepted by the controller " +
2110
+ "(`triggerKey || key`) since before sync existed. Declared so the " +
2111
+ "generated request schema keeps accepting a client that uses it.",
2112
+ },
2113
+ name: {
2114
+ note: "Alias for `displayName`, accepted on create and update " +
2115
+ "(`displayName ?? name`). Same reason as `key`.",
2116
+ },
2117
+ },
2118
+ tomlOnlyKeys: {
2119
+ key: {
2120
+ kind: "structural",
2121
+ note: "The trigger key. `config pull` writes it for readability; push takes " +
2122
+ "the identity from the file name and attaches `triggerKey` itself.",
2123
+ },
2124
+ },
2125
+ responseOnlyKeys: {
2126
+ runtime: {
2127
+ kind: "server-owned",
2128
+ note: "DO-derived schedule status (`scheduledAlarm` / `scheduledAlarmAt`) " +
2129
+ "attached to the detail response. Computed, never persisted here.",
2130
+ },
2131
+ },
2132
+ },
2133
+ ],
2134
+ tomlDocumentKeys: {
2135
+ rootInput: {
2136
+ kind: "structural",
2137
+ note: "The authored root input for each fire, stored as the JSON string " +
2138
+ "`rootInput` (normalizeJsonString).",
2139
+ },
2140
+ inputMapping: {
2141
+ kind: "structural",
2142
+ note: "The authored input mapping, stored as the JSON string `inputMapping` " +
2143
+ "(normalizeJsonString).",
2144
+ },
2145
+ },
2146
+ };
2147
+ // ── src/config-surface/blob-bucket.ts ────────────────────────────────────
2148
+ /**
2149
+ * The `blob-bucket` configuration object's definition (issue #2644, phase 2).
2150
+ *
2151
+ * `blob-buckets/<key>.toml` carries a single `[bucket]` table.
2152
+ *
2153
+ * `ttlTier` is `writableOn: ["create"]`: the update handler does not read it, so
2154
+ * the retention tier is fixed at creation. `preset` and `ruleSetId` are the two
2155
+ * halves of the access model (#1020) — at most one may change per request, which
2156
+ * is the controller's rule and stays there.
2157
+ */
2158
+ const BLOB_BUCKET_HANDLER = "src/app-api/controllers/blob-buckets-controller.ts";
2159
+ export const BLOB_BUCKET_SURFACE = {
2160
+ label: "blob-bucket",
2161
+ tables: [
2162
+ {
2163
+ tomlPath: ["bucket"],
2164
+ repeated: false,
2165
+ model: "BlobBucket",
2166
+ // Declaration order reproduces the pre-#2644 serializer's key order.
2167
+ fields: [
2168
+ {
2169
+ field: "name",
2170
+ tomlKey: "name",
2171
+ type: "string",
2172
+ emit: "always",
2173
+ writableOn: BOTH,
2174
+ validation: "passthrough",
2175
+ },
2176
+ {
2177
+ field: "description",
2178
+ tomlKey: "description",
2179
+ type: "string",
2180
+ emit: "whenSet",
2181
+ writableOn: BOTH,
2182
+ validation: "passthrough",
2183
+ },
2184
+ {
2185
+ field: "ttlTier",
2186
+ tomlKey: "ttlTier",
2187
+ type: "string",
2188
+ emit: "always",
2189
+ writableOn: CREATE_ONLY,
2190
+ // Immutable after create: `update` never reads it.
2191
+ validation: handledBy(BLOB_BUCKET_HANDLER, "BlobBucketsController"),
2192
+ },
2193
+ {
2194
+ field: "preset",
2195
+ tomlKey: "preset",
2196
+ type: "string",
2197
+ emit: "whenSet",
2198
+ writableOn: BOTH,
2199
+ // #1020 — the honest access field. A rule-set-backed bucket reports
2200
+ // "custom", which is not written back as a preset.
2201
+ validation: handledBy("src/app-api/resource-types/blob-bucket.ts", "resolveBucketPreset"),
2202
+ },
2203
+ {
2204
+ field: "ruleSetId",
2205
+ tomlKey: "ruleSetId",
2206
+ type: "string",
2207
+ emit: "whenSet",
2208
+ writableOn: BOTH,
2209
+ // Existence-checked against AccessRuleSet; mutually exclusive with a
2210
+ // preset change in the same request.
2211
+ validation: handledBy(BLOB_BUCKET_HANDLER, "BlobBucketsController"),
2212
+ },
2213
+ ],
2214
+ notExposed: {
2215
+ bucketId: {
2216
+ kind: "server-owned",
2217
+ note: "ULID assigned on create; never author-authored.",
2218
+ },
2219
+ appId: {
2220
+ kind: "server-owned",
2221
+ note: "Tenant scope, taken from the route; never on the wire.",
2222
+ },
2223
+ bucketKey: {
2224
+ kind: "structural",
2225
+ // Create carries it in the body; update does NOT — the key is
2226
+ // immutable (`blob-buckets-controller.ts` update) and the URL already
2227
+ // names the bucket, so a body carrying it is a 400 rather than a
2228
+ // rename that silently did nothing.
2229
+ requestModes: ["create"],
2230
+ note: "The bucket's identity. Comes from the TOML file name and is " +
2231
+ "attached by the caller, not spread from the [bucket] table. Its " +
2232
+ "TOML spelling is declared in tomlOnlyKeys as `key`.",
2233
+ },
2234
+ accessPolicy: {
2235
+ kind: "deprecated",
2236
+ note: "#1020 — the legacy access field, superseded by `preset`. Still " +
2237
+ "accepted on the wire and returned as a read alias, but authors " +
2238
+ "write `preset`, so pull does not emit it (writing both would " +
2239
+ "author the same decision twice).",
2240
+ },
2241
+ createdBy: {
2242
+ kind: "server-owned",
2243
+ note: "User who created the bucket; assigned server-side.",
2244
+ },
2245
+ createdAt: {
2246
+ kind: "server-owned",
2247
+ note: "Timestamp, assigned server-side.",
2248
+ },
2249
+ modifiedAt: {
2250
+ kind: "server-owned",
2251
+ note: "Timestamp, assigned server-side.",
2252
+ },
2253
+ },
2254
+ tomlOnlyKeys: {
2255
+ key: {
2256
+ kind: "structural",
2257
+ note: "The bucket key. `config pull` writes it for readability; push takes " +
2258
+ "the identity from the file name and attaches `bucketKey` itself.",
2259
+ },
2260
+ accessPolicy: {
2261
+ kind: "deprecated",
2262
+ note: "Accepted in existing files as the pre-#1020 spelling of `preset` " +
2263
+ "and forwarded on push, so an unmigrated file still pushes. Pull " +
2264
+ "writes `preset`.",
2265
+ },
2266
+ },
2267
+ responseOnlyKeys: {},
2268
+ },
2269
+ ],
2270
+ // A bucket file is the `[bucket]` table and nothing else: its access model is
2271
+ // a preset or a rule-set reference, both scalars inside that table.
2272
+ tomlDocumentKeys: {},
2273
+ };
2274
+ // ── src/config-surface/email-template.ts ─────────────────────────────────
2275
+ /**
2276
+ * The `email-template` configuration object's definition (issue #2644, phase 2).
2277
+ *
2278
+ * `email-templates/<emailType>.toml` carries a single `[template]` table. The
2279
+ * object is an OVERRIDE of a built-in template: the detail response is
2280
+ * `{ emailType, hasOverride, override: {...}, default: {...} }`, and only the
2281
+ * `override` half is a field surface — the `default` half is what the platform
2282
+ * ships. Both are declared as response-only keys so `config pull`'s
2283
+ * unrecognized-key warning reports genuinely unknown fields rather than the
2284
+ * envelope.
2285
+ *
2286
+ * There is one write endpoint (`PUT …/email-templates/{emailType}`, an upsert),
2287
+ * so every field is writable on both modes.
2288
+ */
2289
+ /**
2290
+ * Email types RETIRED by #2884, kept named rather than simply deleted.
2291
+ *
2292
+ * Email sign-in sends ONE email from the `email-sign-in` template, so
2293
+ * `magic-link` and `otp` are no longer rendered by any code path — which is
2294
+ * what makes deleting the link block from an `email-sign-in` override an
2295
+ * actual guarantee rather than a hope about which endpoint ran.
2296
+ *
2297
+ * A stored override for a retired type is NOT deleted: it stays listed and
2298
+ * readable (labelled retired, with the guidance below) so an app can find its
2299
+ * customization and migrate it, and deleting it still works. Everything that
2300
+ * would author one — the admin write/preview/test endpoints, `config create`,
2301
+ * a `config push --only` selector — refuses by name instead. Silent
2302
+ * non-rendering is the outcome all of that exists to avoid.
2303
+ *
2304
+ * It lives in the config surface, not beside the default templates, because
2305
+ * the CLI vendors this directory and needs the same sentence.
2306
+ */
2307
+ export const RETIRED_EMAIL_TYPES = ["magic-link", "otp"];
2308
+ /** True when `emailType` is one of the retired sign-in types. */
2309
+ export function isRetiredEmailType(emailType) {
2310
+ return RETIRED_EMAIL_TYPES.includes(emailType);
2311
+ }
2312
+ /** What every surface says about a retired type, in one sentence. */
2313
+ export function retiredEmailTypeGuidance(emailType) {
2314
+ const carryOver = emailType === "otp"
2315
+ ? "the code block (`{{code}}`, `{{expiryMinutes}}`)"
2316
+ : "the link block (`{{{magicLink}}}`), inside `{{#if magicLink}}`";
2317
+ return (`\`${emailType}\` is retired (#2884): email sign-in sends ONE email from ` +
2318
+ "the `email-sign-in` template, and no sign-in path renders this type any " +
2319
+ `more. Re-author ${carryOver} into your \`email-sign-in\` template, then ` +
2320
+ `delete this override.`);
2321
+ }
2322
+ const EMAIL_TEMPLATE_HANDLER = "src/admin-api.ts";
2323
+ export const EMAIL_TEMPLATE_SURFACE = {
2324
+ label: "email-template",
2325
+ tables: [
2326
+ {
2327
+ tomlPath: ["template"],
2328
+ repeated: false,
2329
+ model: "EmailTemplate",
2330
+ // Declaration order reproduces the pre-#2644 serializer's key order.
2331
+ fields: [
2332
+ {
2333
+ field: "subject",
2334
+ tomlKey: "subject",
2335
+ type: "string",
2336
+ emit: "always",
2337
+ writableOn: BOTH,
2338
+ // Size-capped and checked for unescaped `{{{var}}}` interpolation.
2339
+ validation: handledBy(EMAIL_TEMPLATE_HANDLER, "AdminAPI"),
2340
+ },
2341
+ {
2342
+ field: "htmlBody",
2343
+ tomlKey: "htmlBody",
2344
+ type: "string",
2345
+ emit: "whenSet",
2346
+ writableOn: BOTH,
2347
+ validation: handledBy(EMAIL_TEMPLATE_HANDLER, "AdminAPI"),
2348
+ },
2349
+ {
2350
+ field: "textBody",
2351
+ tomlKey: "textBody",
2352
+ type: "string",
2353
+ emit: "whenSet",
2354
+ writableOn: BOTH,
2355
+ validation: handledBy(EMAIL_TEMPLATE_HANDLER, "AdminAPI"),
2356
+ },
2357
+ ],
2358
+ notExposed: {
2359
+ templateId: {
2360
+ kind: "server-owned",
2361
+ note: "ULID assigned on create; never author-authored.",
2362
+ },
2363
+ appId: {
2364
+ kind: "server-owned",
2365
+ note: "Tenant scope, taken from the route; never on the wire.",
2366
+ },
2367
+ emailType: {
2368
+ kind: "structural",
2369
+ // Stated, not implied: the upsert handler reads the type from the
2370
+ // route, so no request schema admits the key.
2371
+ requestModes: [],
2372
+ note: "The override's identity — which built-in email it replaces. Comes " +
2373
+ "from the TOML file name and travels in the URL path, not in the " +
2374
+ "request body. Its TOML spelling is declared in tomlOnlyKeys.",
2375
+ },
2376
+ createdBy: {
2377
+ kind: "server-owned",
2378
+ note: "Admin who created the override; assigned server-side.",
2379
+ },
2380
+ createdAt: {
2381
+ kind: "server-owned",
2382
+ note: "Timestamp, assigned server-side.",
2383
+ },
2384
+ modifiedAt: {
2385
+ kind: "server-owned",
2386
+ note: "Timestamp, assigned server-side.",
2387
+ },
2388
+ },
2389
+ tomlOnlyKeys: {
2390
+ emailType: {
2391
+ kind: "structural",
2392
+ note: "`config pull` writes the email type for readability; push takes it " +
2393
+ "from the file name and puts it in the URL, never the body.",
2394
+ },
2395
+ },
2396
+ responseOnlyKeys: {
2397
+ label: {
2398
+ kind: "server-owned",
2399
+ note: "Human-readable name of the built-in email this override replaces " +
2400
+ "(the emailType itself for a custom type). Presentation only.",
2401
+ },
2402
+ description: {
2403
+ kind: "server-owned",
2404
+ note: "What the built-in email is for, shown in the admin UI. Platform " +
2405
+ "copy, not an override field.",
2406
+ },
2407
+ isCustom: {
2408
+ kind: "server-owned",
2409
+ note: "Derived flag: whether the emailType is a custom one rather than a " +
2410
+ "built-in. Computed from the route, never authored.",
2411
+ },
2412
+ variables: {
2413
+ kind: "server-owned",
2414
+ note: "The template variables this email exposes, with their " +
2415
+ "descriptions — the platform's own list for built-ins, extracted " +
2416
+ "from the override's text for a custom type. Documentation, not a " +
2417
+ "field surface.",
2418
+ },
2419
+ hasOverride: {
2420
+ kind: "server-owned",
2421
+ note: "Derived flag: whether this app has replaced the built-in template. " +
2422
+ "Computed at serialize time; `config pull` writes only overrides.",
2423
+ },
2424
+ override: {
2425
+ kind: "structural",
2426
+ note: "The app's override fields, nested in the detail response. Flattened " +
2427
+ "into the [template] table on pull.",
2428
+ },
2429
+ default: {
2430
+ kind: "server-owned",
2431
+ note: "The platform's built-in copy of the template, returned for " +
2432
+ "comparison. Never authored and never written to TOML.",
2433
+ },
2434
+ },
2435
+ },
2436
+ ],
2437
+ // An override file is the `[template]` table alone — subject and the two
2438
+ // bodies. The platform default it replaces is server-side, never authored.
2439
+ tomlDocumentKeys: {},
2440
+ };
2441
+ // ── src/config-surface/database-type-config.ts ───────────────────────────
2442
+ /**
2443
+ * The `database-type-config` configuration object's definition (issue #2644, phase 3).
2444
+ *
2445
+ * `database-type-configs/<databaseType>.toml` is the most structured file in the sync
2446
+ * slot: a `[type]` scalar table, plus four authored sub-trees that keep the
2447
+ * validators that already own them, per the spec's scope boundary —
2448
+ *
2449
+ * `[models.*]` → the stored `schema` TOML string (`parseSchemaToml`)
2450
+ * `[[operations]]` → `DatabaseTypeOperation` rows (`toml-params-validator.ts`)
2451
+ * `[[subscriptions]]` → `DatabaseTypeSubscription` rows (#803)
2452
+ * `[triggers]` → the trigger-rule tree (`validateTriggerRules`)
2453
+ * `[metadata]`/`secrets` → the declared-access manifest (#1304)
2454
+ *
2455
+ * `operations` and `subscriptions` are separate models with their own
2456
+ * endpoints, so they are not fields of `DatabaseTypeConfig` at all; `schema`,
2457
+ * `triggers` and `metadataManifest` ARE model fields and are declared
2458
+ * `structural` here, naming the channel and the validator, so their absence
2459
+ * from the `[type]` table reads as a decision rather than an omission.
2460
+ *
2461
+ * The exposed `[type]` scalars are exactly the file-OWNED ones (#1567): `sync
2462
+ * push` sends each as value-or-`null` so removing a line from the TOML clears
2463
+ * it server-side rather than leaving a stale value live. `DB_TYPE_OWNED_SCALARS`
2464
+ * used to state that list a second time in the CLI; it now reads this
2465
+ * definition.
2466
+ */
2467
+ const DB_TYPE_HANDLER = "src/app-api/controllers/database-type-config-controller.ts";
2468
+ export const DATABASE_TYPE_SURFACE = {
2469
+ label: "database-type-config",
2470
+ tables: [
2471
+ {
2472
+ tomlPath: ["type"],
2473
+ repeated: false,
2474
+ model: "DatabaseTypeConfig",
2475
+ // Declaration order reproduces the pre-#2644 serializer's key order.
2476
+ fields: [
2477
+ {
2478
+ field: "databaseType",
2479
+ tomlKey: "databaseType",
2480
+ type: "string",
2481
+ emit: "always",
2482
+ writableOn: CREATE_ONLY,
2483
+ // The type's identity: required and `#`-free on create, taken from
2484
+ // the URL on update.
2485
+ validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController"),
2486
+ },
2487
+ {
2488
+ field: "ruleSetId",
2489
+ tomlKey: "ruleSetName",
2490
+ type: "string",
2491
+ emit: "always",
2492
+ writableOn: BOTH,
2493
+ // Authored as a portable name and sent as an id; the server checks
2494
+ // the set exists and has `resourceType: "database"`.
2495
+ validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController"),
2496
+ },
2497
+ {
2498
+ field: "metadataAccess",
2499
+ tomlKey: "celContextAccess",
2500
+ type: "string",
2501
+ emit: "whenSet",
2502
+ writableOn: BOTH,
2503
+ // CEL, parsed before storing (#1336). The legacy `metadataAccess`
2504
+ // spelling stays readable on push — see tomlOnlyKeys.
2505
+ validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController"),
2506
+ },
2507
+ {
2508
+ field: "defaultAccess",
2509
+ tomlKey: "defaultAccess",
2510
+ type: "string",
2511
+ emit: "whenSet",
2512
+ writableOn: BOTH,
2513
+ // CEL, plus the shared `md`-shape allowlist (#1523).
2514
+ validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController"),
2515
+ },
2516
+ {
2517
+ field: "autoPopulatedFields",
2518
+ tomlKey: "autoPopulatedFields",
2519
+ type: "json",
2520
+ emit: "whenSet",
2521
+ writableOn: BOTH,
2522
+ // #750 — shorthand and verbose forms both accepted, normalized by
2523
+ // `validateAndNormalizeAutoPopulatedFields`.
2524
+ validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController"),
2525
+ },
2526
+ {
2527
+ field: "timestamps",
2528
+ tomlKey: "timestamps",
2529
+ type: "json",
2530
+ emit: "whenSet",
2531
+ writableOn: BOTH,
2532
+ // #748 — `{ create?, update?, models? }`, normalized by
2533
+ // `validateAndNormalizeTimestamps`.
2534
+ validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController"),
2535
+ },
2536
+ ],
2537
+ notExposed: {
2538
+ appId: {
2539
+ kind: "server-owned",
2540
+ note: "Tenant scope, taken from the route; never on the wire.",
2541
+ },
2542
+ triggers: {
2543
+ kind: "structural",
2544
+ note: "The trigger-rule tree, authored as the file's top-level " +
2545
+ "`[triggers]` table rather than a key inside [type]. Validated by " +
2546
+ "`validateTriggerRules` server-side and pushed whole; the file owns " +
2547
+ "it, so an absent table clears it (#1567).",
2548
+ },
2549
+ metadataManifest: {
2550
+ kind: "structural",
2551
+ note: "The declared-access manifest (#1304 P-C): authored as the file's " +
2552
+ "top-level `[metadata]` + `secrets` fragments, validated by " +
2553
+ "`validateDeclaredAccessManifest` before push.",
2554
+ },
2555
+ schema: {
2556
+ kind: "structural",
2557
+ note: "The type's model schema (#666), stored as a raw TOML string and " +
2558
+ "authored as the file's `[models.*]` blocks. Parsed by " +
2559
+ "`parseSchemaToml` server-side; it is a sub-tree with its own " +
2560
+ "prior-state discriminator on push, not a scalar.",
2561
+ },
2562
+ createdAt: {
2563
+ kind: "server-owned",
2564
+ note: "Timestamp, assigned server-side.",
2565
+ },
2566
+ modifiedAt: {
2567
+ kind: "server-owned",
2568
+ note: "Timestamp, assigned server-side.",
2569
+ },
2570
+ createdBy: {
2571
+ kind: "server-owned",
2572
+ note: "User who created the config; assigned server-side.",
2573
+ },
2574
+ },
2575
+ tomlOnlyKeys: {
2576
+ ruleSetId: {
2577
+ kind: "deprecated",
2578
+ note: "The legacy id-based rule-set reference. Still accepted on push " +
2579
+ "so an unmigrated file pushes unchanged; pull writes `ruleSetName`.",
2580
+ },
2581
+ metadataAccess: {
2582
+ kind: "deprecated",
2583
+ note: "The legacy spelling of `celContextAccess`. Read on push when the " +
2584
+ "newer key is absent (#1567 keeps `celContextAccess = \"\"` an " +
2585
+ "explicit clear); pull writes `celContextAccess`.",
2586
+ },
2587
+ },
2588
+ requestOnlyKeys: {
2589
+ pendingOpDeletes: {
2590
+ note: "Operation names this push is deleting, sent with the type-config " +
2591
+ "PATCH so the server validates the remaining schema against the " +
2592
+ "operations that will survive rather than the current set.",
2593
+ },
2594
+ pendingOpUpdates: {
2595
+ note: "#1336 — the post-push form of the operations this push rewrites " +
2596
+ "({ name, access, params }), so the child-op re-lint evaluates " +
2597
+ "each against what the push is about to store rather than the " +
2598
+ "stale row. Read by `type-config-access-lint.ts`, the same " +
2599
+ "look-ahead contract as `pendingOpDeletes` / `finalOpNames`.",
2600
+ },
2601
+ pendingOpUpserts: {
2602
+ modes: ["update"],
2603
+ note: "#2732 — the full post-push BODY of every operation this push " +
2604
+ "lands ({ name, type, modelName, access, definition, params }), so " +
2605
+ "the schema-edit gate lints a rewritten operation as rewritten " +
2606
+ "rather than as stored. That is what makes removing a model and " +
2607
+ "rewriting the operations that named it a single push. Read by " +
2608
+ "`pending-op-set.ts`, which honors an entry only when it is a " +
2609
+ "whole operation body whose name `finalOpNames` keeps — anything " +
2610
+ "less is ignored and the stored operation is gated instead. A " +
2611
+ "create has no stored operations to gate.",
2612
+ },
2613
+ finalOpNames: {
2614
+ note: "The operation names the file will end with, for the same " +
2615
+ "look-ahead validation as `pendingOpDeletes`.",
2616
+ },
2617
+ },
2618
+ responseOnlyKeys: {},
2619
+ },
2620
+ ],
2621
+ tomlDocumentKeys: {
2622
+ models: {
2623
+ kind: "structural",
2624
+ note: "#666 — the `[models.<Name>.fields.*]` blocks, re-serialized into the " +
2625
+ "stored `schema` TOML string on push and merged back on pull.",
2626
+ },
2627
+ operations: {
2628
+ kind: "structural",
2629
+ note: "The `[[operations]]` array. Keeps the validator that already owns it " +
2630
+ "(`cli/src/lib/toml-params-validator.ts`) and its own endpoints.",
2631
+ },
2632
+ subscriptions: {
2633
+ kind: "structural",
2634
+ note: "#803 — the `[[subscriptions]]` array, applied through its own endpoints.",
2635
+ },
2636
+ triggers: {
2637
+ kind: "structural",
2638
+ note: "The type's authored trigger table, stored on the type config as " +
2639
+ "`triggers` and validated server-side.",
2640
+ },
2641
+ ...DECLARED_ACCESS_MANIFEST_KEYS,
2642
+ },
2643
+ };
2644
+ // ── src/config-surface/rule-set.ts ───────────────────────────────────────
2645
+ /**
2646
+ * The `rule-set` configuration object's definition (issue #2644, phase 3).
2647
+ *
2648
+ * `rule-sets/<name>.toml` carries a `[ruleSet]` scalar table plus the authored
2649
+ * `[rules]` tree — `{ category: { operation: "<CEL>" } }` — which is declared
2650
+ * `structural`: it is a nested authored sub-tree with its own validator
2651
+ * (`validateRules`, server-side, against the set's `resourceType`), and the
2652
+ * spec's scope boundary keeps nested structure with the validator that already
2653
+ * owns it rather than re-modelling it as fields.
2654
+ *
2655
+ * `resourceType` is `create` only: `update` builds its `updates` map from
2656
+ * `name` / `description` / `rules` alone, so a rule set's resource type is
2657
+ * fixed at creation.
2658
+ */
2659
+ const RULE_SET_HANDLER = "src/app-api/controllers/rule-sets-controller.ts";
2660
+ export const RULE_SET_SURFACE = {
2661
+ label: "rule-set",
2662
+ tables: [
2663
+ {
2664
+ tomlPath: ["ruleSet"],
2665
+ repeated: false,
2666
+ model: "AccessRuleSet",
2667
+ // Declaration order reproduces the pre-#2644 serializer's key order.
2668
+ fields: [
2669
+ {
2670
+ field: "name",
2671
+ tomlKey: "name",
2672
+ type: "string",
2673
+ emit: "always",
2674
+ writableOn: BOTH,
2675
+ // Required, trimmed, and non-empty on both modes.
2676
+ validation: handledBy(RULE_SET_HANDLER, "RuleSetsController"),
2677
+ },
2678
+ {
2679
+ field: "resourceType",
2680
+ tomlKey: "resourceType",
2681
+ type: "string",
2682
+ emit: "always",
2683
+ writableOn: CREATE_ONLY,
2684
+ // Checked against `getValidResourceTypes()`, and singleton types
2685
+ // (#1148) reject a second set. `update` never reads it.
2686
+ validation: handledBy(RULE_SET_HANDLER, "RuleSetsController"),
2687
+ },
2688
+ {
2689
+ field: "description",
2690
+ tomlKey: "description",
2691
+ type: "string",
2692
+ emit: "whenSet",
2693
+ writableOn: BOTH,
2694
+ validation: "passthrough",
2695
+ },
2696
+ ],
2697
+ notExposed: {
2698
+ ruleSetId: {
2699
+ kind: "server-owned",
2700
+ note: "ULID assigned on create; the file is keyed by name instead.",
2701
+ },
2702
+ appId: {
2703
+ kind: "server-owned",
2704
+ note: "Tenant scope, taken from the route; never on the wire.",
2705
+ },
2706
+ rules: {
2707
+ kind: "structural",
2708
+ note: "The authored `[rules]` tree, a sibling table of [ruleSet] rather " +
2709
+ "than a key inside it. Its shape is category → operation → CEL, " +
2710
+ "validated by `validateRules` in rule-sets-controller.ts against " +
2711
+ "the set's resourceType; `config push` sends it whole.",
2712
+ },
2713
+ version: {
2714
+ kind: "server-owned",
2715
+ note: "Bumped by the server whenever `rules` changes; never authored.",
2716
+ },
2717
+ createdAt: {
2718
+ kind: "server-owned",
2719
+ note: "Timestamp, assigned server-side.",
2720
+ },
2721
+ modifiedAt: {
2722
+ kind: "server-owned",
2723
+ note: "Timestamp, assigned server-side.",
2724
+ },
2725
+ createdBy: {
2726
+ kind: "server-owned",
2727
+ note: "User who created the rule set; assigned server-side.",
2728
+ },
2729
+ },
2730
+ tomlOnlyKeys: {},
2731
+ responseOnlyKeys: {},
2732
+ },
2733
+ ],
2734
+ tomlDocumentKeys: {
2735
+ rules: {
2736
+ kind: "structural",
2737
+ note: "The `[rules.<category>]` tree of CEL expressions — the rule set's " +
2738
+ "whole point. Keeps its own validator and travels beside [ruleSet].",
2739
+ },
2740
+ },
2741
+ };
2742
+ // ── src/config-surface/group-type-config.ts ──────────────────────────────
2743
+ /**
2744
+ * The `group-type-config` configuration object's definition (issue #2644,
2745
+ * phase 3).
2746
+ *
2747
+ * `group-type-configs/<groupType>.toml` carries a single `[groupTypeConfig]`
2748
+ * table, plus the declared-access manifest's top-level `[metadata]` / `secrets`
2749
+ * fragments — which are `structural`: authored elsewhere in the file, with
2750
+ * their own validator (`validateDeclaredAccessManifest` locally,
2751
+ * `validateManifestShape` server-side).
2752
+ *
2753
+ * The rule-set reference is stored as `ruleSetId` but AUTHORED as
2754
+ * `ruleSetName`, because a name is portable across apps and an id is not. The
2755
+ * name → id resolution is the CLI's (`config push` resolves against the app's
2756
+ * rule sets); the server validates that the id exists and has
2757
+ * `resourceType: "group"`. The legacy id spelling stays accepted on push and is
2758
+ * declared in `tomlOnlyKeys`.
2759
+ */
2760
+ const GROUP_TYPE_HANDLER = "src/app-api/controllers/group-type-config-controller.ts";
2761
+ export const GROUP_TYPE_CONFIG_SURFACE = {
2762
+ label: "group-type-config",
2763
+ tables: [
2764
+ {
2765
+ tomlPath: ["groupTypeConfig"],
2766
+ repeated: false,
2767
+ model: "GroupTypeConfig",
2768
+ // Declaration order reproduces the pre-#2644 serializer's key order.
2769
+ fields: [
2770
+ {
2771
+ field: "groupType",
2772
+ tomlKey: "groupType",
2773
+ type: "string",
2774
+ emit: "always",
2775
+ writableOn: CREATE_ONLY,
2776
+ // The config's identity: required and `#`-free on create, taken from
2777
+ // the URL on update.
2778
+ validation: handledBy(GROUP_TYPE_HANDLER, "GroupTypeConfigController"),
2779
+ },
2780
+ {
2781
+ field: "ruleSetId",
2782
+ tomlKey: "ruleSetName",
2783
+ type: "string",
2784
+ emit: "always",
2785
+ writableOn: BOTH,
2786
+ // Authored as a name, sent as an id; existence and resourceType are
2787
+ // checked server-side, and an empty value clears the reference.
2788
+ validation: handledBy(GROUP_TYPE_HANDLER, "GroupTypeConfigController"),
2789
+ },
2790
+ {
2791
+ field: "autoAddCreator",
2792
+ tomlKey: "autoAddCreator",
2793
+ type: "boolean",
2794
+ emit: "always",
2795
+ writableOn: BOTH,
2796
+ // Defaults to `true` when absent on create, and is coerced with `!!`
2797
+ // on update — not a stored-as-is passthrough.
2798
+ validation: handledBy(GROUP_TYPE_HANDLER, "GroupTypeConfigController"),
2799
+ },
2800
+ ],
2801
+ notExposed: {
2802
+ appId: {
2803
+ kind: "server-owned",
2804
+ note: "Tenant scope, taken from the route; never on the wire.",
2805
+ },
2806
+ metadataManifest: {
2807
+ kind: "structural",
2808
+ note: "The declared-access manifest (#1350, extending #1304): authored " +
2809
+ "as the file's top-level `[metadata]` + `secrets` fragments, not " +
2810
+ "as a key inside [groupTypeConfig]. Validated by " +
2811
+ "`validateDeclaredAccessManifest` (cli/src/lib/toml-metadata-config.ts) " +
2812
+ "before push and `validateManifestShape` at persist time.",
2813
+ },
2814
+ createdAt: {
2815
+ kind: "server-owned",
2816
+ note: "Timestamp, assigned server-side.",
2817
+ },
2818
+ modifiedAt: {
2819
+ kind: "server-owned",
2820
+ note: "Timestamp, assigned server-side.",
2821
+ },
2822
+ createdBy: {
2823
+ kind: "server-owned",
2824
+ note: "User who created the config; assigned server-side.",
2825
+ },
2826
+ },
2827
+ tomlOnlyKeys: {
2828
+ ruleSetId: {
2829
+ kind: "deprecated",
2830
+ note: "The legacy id-based rule-set reference. Still accepted on push " +
2831
+ "so an unmigrated file pushes unchanged; pull writes `ruleSetName`.",
2832
+ },
2833
+ },
2834
+ responseOnlyKeys: {},
2835
+ },
2836
+ ],
2837
+ tomlDocumentKeys: { ...DECLARED_ACCESS_MANIFEST_KEYS },
2838
+ };
2839
+ // ── src/config-surface/collection-type-config.ts ─────────────────────────
2840
+ /**
2841
+ * The `collection-type-config` configuration object's definition (issue #2644,
2842
+ * phase 3).
2843
+ *
2844
+ * `collection-type-configs/<collectionType>.toml` carries a single
2845
+ * `[collectionTypeConfig]` table plus the declared-access manifest's top-level
2846
+ * `[metadata]` / `secrets` fragments (declared `structural`, as on every other
2847
+ * config that carries one).
2848
+ *
2849
+ * Same shape as `group-type-config` minus `autoAddCreator`: the rule-set
2850
+ * reference is stored as `ruleSetId` and authored as `ruleSetName`, and the
2851
+ * server checks the referenced set has `resourceType: "collection"`.
2852
+ */
2853
+ const COLLECTION_TYPE_HANDLER = "src/app-api/controllers/collection-type-config-controller.ts";
2854
+ export const COLLECTION_TYPE_CONFIG_SURFACE = {
2855
+ label: "collection-type-config",
2856
+ tables: [
2857
+ {
2858
+ tomlPath: ["collectionTypeConfig"],
2859
+ repeated: false,
2860
+ model: "CollectionTypeConfig",
2861
+ // Declaration order reproduces the pre-#2644 serializer's key order.
2862
+ fields: [
2863
+ {
2864
+ field: "collectionType",
2865
+ tomlKey: "collectionType",
2866
+ type: "string",
2867
+ emit: "always",
2868
+ writableOn: CREATE_ONLY,
2869
+ // The config's identity: required on create, taken from the URL on
2870
+ // update.
2871
+ validation: handledBy(COLLECTION_TYPE_HANDLER, "CollectionTypeConfigController"),
2872
+ },
2873
+ {
2874
+ field: "ruleSetId",
2875
+ tomlKey: "ruleSetName",
2876
+ type: "string",
2877
+ emit: "always",
2878
+ writableOn: BOTH,
2879
+ // Authored as a name, sent as an id; existence and resourceType are
2880
+ // checked server-side, and an empty value clears the reference.
2881
+ validation: handledBy(COLLECTION_TYPE_HANDLER, "CollectionTypeConfigController"),
2882
+ },
2883
+ ],
2884
+ notExposed: {
2885
+ appId: {
2886
+ kind: "server-owned",
2887
+ note: "Tenant scope, taken from the route; never on the wire.",
2888
+ },
2889
+ metadataManifest: {
2890
+ kind: "structural",
2891
+ note: "The declared-access manifest (#1304 P-C): authored as the file's " +
2892
+ "top-level `[metadata]` + `secrets` fragments, not as a key inside " +
2893
+ "[collectionTypeConfig]. Validated by " +
2894
+ "`validateDeclaredAccessManifest` (cli/src/lib/toml-metadata-config.ts) " +
2895
+ "before push and `validateManifestShape` at persist time.",
2896
+ },
2897
+ createdAt: {
2898
+ kind: "server-owned",
2899
+ note: "Timestamp, assigned server-side.",
2900
+ },
2901
+ modifiedAt: {
2902
+ kind: "server-owned",
2903
+ note: "Timestamp, assigned server-side.",
2904
+ },
2905
+ createdBy: {
2906
+ kind: "server-owned",
2907
+ note: "User who created the config; assigned server-side.",
2908
+ },
2909
+ },
2910
+ tomlOnlyKeys: {
2911
+ ruleSetId: {
2912
+ kind: "deprecated",
2913
+ note: "The legacy id-based rule-set reference. Still accepted on push " +
2914
+ "so an unmigrated file pushes unchanged; pull writes `ruleSetName`.",
2915
+ },
2916
+ },
2917
+ responseOnlyKeys: {},
2918
+ },
2919
+ ],
2920
+ tomlDocumentKeys: { ...DECLARED_ACCESS_MANIFEST_KEYS },
2921
+ };
2922
+ // ── src/config-surface/metadata-category-config.ts ───────────────────────
2923
+ /**
2924
+ * The `metadata-category-config` configuration object's definition (issue
2925
+ * #2644, phase 3).
2926
+ *
2927
+ * `metadata-category-configs/<resourceType>-<category>.toml` carries a single
2928
+ * `[metadataCategoryConfig]` table. Its identity is the `resourceType` /
2929
+ * `category` PAIR read out of the file's content (not from the file name), and
2930
+ * both halves are part of the table — which is why they are exposed fields and
2931
+ * `categoryKey`, the stored `resourceType#category` composite, is not.
2932
+ *
2933
+ * There is one write path (`PUT …/metadata/categories/{resourceType}/{category}`
2934
+ * and its POST twin, both an upsert through
2935
+ * `MetadataController.upsertCategoryConfig`), so every field is writable on
2936
+ * both modes.
2937
+ *
2938
+ * `schema` is exposed rather than structural: unlike workflow `steps` or a
2939
+ * database type's `[models]` blocks it is a single JSON value the server
2940
+ * returns parsed and stores whole, and `config pull` emits it as nested
2941
+ * `[metadataCategoryConfig.schema.fields.*]` tables. Its CONTENT is
2942
+ * handler-owned — `validateCategorySchema` mirrors the server's field-type,
2943
+ * enum and `unique` rules — but the field itself round-trips as one value.
2944
+ */
2945
+ const METADATA_HANDLER = "src/app-api/controllers/metadata-controller.ts";
2946
+ const CLI_METADATA_TOML = "cli/src/lib/toml-metadata-config.ts";
2947
+ export const METADATA_CATEGORY_CONFIG_SURFACE = {
2948
+ label: "metadata-category-config",
2949
+ tables: [
2950
+ {
2951
+ tomlPath: ["metadataCategoryConfig"],
2952
+ repeated: false,
2953
+ model: "MetadataCategoryConfig",
2954
+ // Declaration order reproduces the pre-#2644 serializer's key order, so
2955
+ // the scalars stay ahead of the nested `[…schema.fields.*]` tables.
2956
+ fields: [
2957
+ {
2958
+ field: "resourceType",
2959
+ tomlKey: "resourceType",
2960
+ type: "string",
2961
+ emit: "always",
2962
+ writableOn: BOTH,
2963
+ // Half of the category's identity; required on every upsert.
2964
+ validation: handledBy(METADATA_HANDLER, "MetadataController"),
2965
+ },
2966
+ {
2967
+ field: "category",
2968
+ tomlKey: "category",
2969
+ type: "string",
2970
+ emit: "always",
2971
+ writableOn: BOTH,
2972
+ // The other half of the identity; required on every upsert.
2973
+ validation: handledBy(METADATA_HANDLER, "MetadataController"),
2974
+ },
2975
+ {
2976
+ field: "readRule",
2977
+ tomlKey: "readRule",
2978
+ type: "string",
2979
+ emit: "whenSet",
2980
+ writableOn: BOTH,
2981
+ // CEL, parsed and stored or nulled.
2982
+ validation: handledBy(METADATA_HANDLER, "MetadataController"),
2983
+ },
2984
+ {
2985
+ field: "writeRule",
2986
+ tomlKey: "writeRule",
2987
+ type: "string",
2988
+ emit: "whenSet",
2989
+ writableOn: BOTH,
2990
+ validation: handledBy(METADATA_HANDLER, "MetadataController"),
2991
+ },
2992
+ {
2993
+ field: "description",
2994
+ tomlKey: "description",
2995
+ type: "string",
2996
+ emit: "whenSet",
2997
+ writableOn: BOTH,
2998
+ validation: "passthrough",
2999
+ },
3000
+ {
3001
+ field: "schema",
3002
+ tomlKey: "schema",
3003
+ type: "json",
3004
+ emit: "always",
3005
+ writableOn: BOTH,
3006
+ // Required on every upsert, and an absent one is written as
3007
+ // `{ fields: {} }` so the file stays pushable. Field types, enums and
3008
+ // the single-`unique` rule are checked locally before push and again
3009
+ // server-side.
3010
+ validation: handledBy(CLI_METADATA_TOML, "validateCategorySchema"),
3011
+ },
3012
+ ],
3013
+ notExposed: {
3014
+ appId: {
3015
+ kind: "server-owned",
3016
+ note: "Tenant scope, taken from the route; never on the wire.",
3017
+ },
3018
+ categoryKey: {
3019
+ kind: "server-owned",
3020
+ note: "The stored `resourceType#category` composite. Derived from the " +
3021
+ "two exposed identity fields; writing it too would author the same " +
3022
+ "decision twice.",
3023
+ },
3024
+ metadataManifest: {
3025
+ kind: "structural",
3026
+ note: "The declared-access manifest (#1420 phase 2): authored as the " +
3027
+ "file's top-level `[metadata]` + `secrets` fragments, not as a key " +
3028
+ "inside [metadataCategoryConfig].",
3029
+ },
3030
+ uniqueField: {
3031
+ kind: "server-owned",
3032
+ note: "Derived from the schema's single `unique: true` field (#1361) and " +
3033
+ "maintained by the server's reverse-resolve index; authors declare " +
3034
+ "`unique` on the field, not this pointer.",
3035
+ },
3036
+ schemaVersion: {
3037
+ kind: "server-owned",
3038
+ note: "Bumped by the server when the schema changes; never authored.",
3039
+ },
3040
+ generation: {
3041
+ kind: "server-owned",
3042
+ note: "Server-managed rewrite counter for the category's rows.",
3043
+ },
3044
+ createdAt: {
3045
+ kind: "server-owned",
3046
+ note: "Timestamp, assigned server-side.",
3047
+ },
3048
+ modifiedAt: {
3049
+ kind: "server-owned",
3050
+ note: "Timestamp, assigned server-side.",
3051
+ },
3052
+ createdBy: {
3053
+ kind: "server-owned",
3054
+ note: "User who created the category; assigned server-side.",
3055
+ },
3056
+ },
3057
+ tomlOnlyKeys: {},
3058
+ responseOnlyKeys: {},
3059
+ },
3060
+ ],
3061
+ tomlDocumentKeys: { ...DECLARED_ACCESS_MANIFEST_KEYS },
3062
+ };
3063
+ // ── src/config-surface/transform.ts ──────────────────────────────────────
3064
+ /**
3065
+ * The `transform` configuration object's definition (issue #2644, phase 3).
3066
+ *
3067
+ * Transforms are the one synced type with NO TOML field table: a transform is
3068
+ * `transforms/<name>.rhai`, a Rhai source file, and its whole authored surface
3069
+ * is the script body. `config pull` writes the active `ScriptConfig`'s body and
3070
+ * `config push` sends it back; there is no key/value table to define, and the
3071
+ * `Script` / `ScriptConfig` scalars around it (name, description, inputSchema,
3072
+ * limits, status) are not authorable through the sync slot today.
3073
+ *
3074
+ * The entry exists so that is a DECISION rather than an absence. Criterion 3's
3075
+ * registry guard reads `SYNC_RESOURCE_TYPES` and requires a surface per label;
3076
+ * without this module `transform` would have to sit in an exemption list, which
3077
+ * is precisely the "absent from a hand-written list" failure mode this epic
3078
+ * exists to end.
3079
+ */
3080
+ export const TRANSFORM_SURFACE = {
3081
+ label: "transform",
3082
+ tables: [],
3083
+ // No TOML file at all, so no top-level TOML keys either — stated rather than
3084
+ // left to `noFieldTable` to imply.
3085
+ tomlDocumentKeys: {},
3086
+ noFieldTable: {
3087
+ note: "A transform is a Rhai source file (`transforms/<name>.rhai`); its " +
3088
+ "authored surface is the script body, which config pull writes from the " +
3089
+ "active ScriptConfig and config push sends back whole. The Script / " +
3090
+ "ScriptConfig scalars (name, description, inputSchema, limits, status) " +
3091
+ "are managed through `primitive transforms`, not through a TOML table, " +
3092
+ "so this object has no field surface to define. Its sidecar test cases " +
3093
+ "(`transforms/<name>.tests/`) are the separate `test-case` surface.",
3094
+ },
3095
+ };
3096
+ // ── src/config-surface/test-case.ts ──────────────────────────────────────
3097
+ /**
3098
+ * The `test-case` configuration object's definition (issue #2644, phase 3).
3099
+ *
3100
+ * Test cases round-trip without being one of the per-entity directories: they
3101
+ * live in a sidecar `<key>.tests/` folder beside the block they test — a
3102
+ * prompt, a workflow, a transform or (since #2769) an integration — one
3103
+ * `[test]` table per file. They are not
3104
+ * a `SYNC_RESOURCE_TYPES` label (that table is per-entity directory metadata),
3105
+ * which is exactly why the spec names them explicitly: nothing that syncs sits
3106
+ * outside the registry.
3107
+ *
3108
+ * Three fields are stored as ids and AUTHORED as keys — `configId` /
3109
+ * `evaluatorPromptId` / `evaluatorConfigId` become `configName` /
3110
+ * `evaluatorPromptKey` / `evaluatorConfigName` — so a test file is portable
3111
+ * across apps. The legacy id spellings stay accepted on push and are declared
3112
+ * in `tomlOnlyKeys`.
3113
+ *
3114
+ * Three more are stored as JSON strings and carried in TOML as JSON strings
3115
+ * (`inputVariables`, `expectedOutputContains`, `expectedJsonSubset`) while the
3116
+ * WIRE shape is the parsed value. That asymmetry is the handler's, not the
3117
+ * definition's: `parseTestCaseToml` parses on push and the admin API
3118
+ * re-stringifies on store.
3119
+ */
3120
+ const TEST_CASE_HANDLER = "src/admin-api.ts";
3121
+ const CLI_SYNC = "cli/src/commands/sync.ts";
3122
+ export const TEST_CASE_SURFACE = {
3123
+ label: "test-case",
3124
+ tables: [
3125
+ {
3126
+ tomlPath: ["test"],
3127
+ repeated: false,
3128
+ model: "BlockTestCase",
3129
+ // Declaration order reproduces the pre-#2644 serializer's key order.
3130
+ fields: [
3131
+ {
3132
+ field: "name",
3133
+ tomlKey: "name",
3134
+ type: "string",
3135
+ emit: "always",
3136
+ writableOn: BOTH,
3137
+ // Required and trimmed on create; an empty name is a 400.
3138
+ validation: handledBy(TEST_CASE_HANDLER, "AdminAPI"),
3139
+ },
3140
+ {
3141
+ field: "description",
3142
+ tomlKey: "description",
3143
+ type: "string",
3144
+ emit: "always",
3145
+ writableOn: BOTH,
3146
+ validation: "passthrough",
3147
+ },
3148
+ {
3149
+ field: "inputVariables",
3150
+ tomlKey: "inputVariables",
3151
+ type: "json",
3152
+ emit: "always",
3153
+ writableOn: BOTH,
3154
+ // Required. TOML carries the JSON text; the wire carries the parsed
3155
+ // object, which the server re-stringifies for storage.
3156
+ validation: handledBy(CLI_SYNC, "parseTestCaseToml"),
3157
+ },
3158
+ {
3159
+ field: "configId",
3160
+ tomlKey: "configName",
3161
+ type: "string",
3162
+ emit: "always",
3163
+ writableOn: BOTH,
3164
+ // Authored as the config's NAME and resolved to an id at push time.
3165
+ validation: handledBy(CLI_SYNC, "parseTestCaseToml"),
3166
+ },
3167
+ {
3168
+ field: "evaluatorPromptId",
3169
+ tomlKey: "evaluatorPromptKey",
3170
+ type: "string",
3171
+ emit: "always",
3172
+ writableOn: BOTH,
3173
+ // Authored as the judge prompt's KEY and resolved to an id at push.
3174
+ validation: handledBy(CLI_SYNC, "parseTestCaseToml"),
3175
+ },
3176
+ {
3177
+ field: "evaluatorConfigId",
3178
+ tomlKey: "evaluatorConfigName",
3179
+ type: "string",
3180
+ emit: "always",
3181
+ writableOn: BOTH,
3182
+ validation: handledBy(CLI_SYNC, "parseTestCaseToml"),
3183
+ },
3184
+ {
3185
+ field: "expectedOutputPattern",
3186
+ tomlKey: "expectedOutputPattern",
3187
+ type: "string",
3188
+ emit: "always",
3189
+ writableOn: BOTH,
3190
+ validation: "passthrough",
3191
+ },
3192
+ {
3193
+ field: "expectedOutputContains",
3194
+ tomlKey: "expectedOutputContains",
3195
+ type: "json",
3196
+ emit: "always",
3197
+ writableOn: BOTH,
3198
+ // JSON array of strings; TOML carries the JSON text.
3199
+ validation: handledBy(CLI_SYNC, "parseTestCaseToml"),
3200
+ },
3201
+ {
3202
+ field: "expectedJsonSubset",
3203
+ tomlKey: "expectedJsonSubset",
3204
+ type: "json",
3205
+ emit: "always",
3206
+ writableOn: BOTH,
3207
+ // JSON object, deep-partial-matched against the parsed output.
3208
+ validation: handledBy(CLI_SYNC, "parseTestCaseToml"),
3209
+ },
3210
+ ],
3211
+ notExposed: {
3212
+ testCaseId: {
3213
+ kind: "server-owned",
3214
+ note: "ULID assigned on create. The file name carries the identity in " +
3215
+ "the committed tree — as `key`, since #2896 — so this id is the " +
3216
+ "server's handle, not what a sidecar is reconciled by.",
3217
+ },
3218
+ key: {
3219
+ kind: "structural",
3220
+ requestModes: ["create", "update"],
3221
+ note: "The sidecar's file name (basename without `.toml`) — the case's " +
3222
+ "identity in the committed tree (#2896). It travels in the BODY " +
3223
+ "so a clean checkout with no `.primitive-sync.json` reconciles " +
3224
+ "instead of creating a duplicate of every case; create stamps it " +
3225
+ "and update backfills it onto a legacy case. Never inside the " +
3226
+ "`[test]` table: sidecar bytes and the content hash are unchanged " +
3227
+ "by it.",
3228
+ },
3229
+ keyToken: {
3230
+ kind: "server-owned",
3231
+ note: "`blockComposite#key.toLowerCase()`, maintained by the write path " +
3232
+ "so `uniqueTestCaseKeyPerBlock` enforces per-block, " +
3233
+ "case-insensitive key uniqueness in one transactional write " +
3234
+ "(#2896). Never on the wire in either direction.",
3235
+ },
3236
+ appId: {
3237
+ kind: "server-owned",
3238
+ note: "Tenant scope, taken from the route; never on the wire.",
3239
+ },
3240
+ blockType: {
3241
+ kind: "structural",
3242
+ requestModes: [],
3243
+ note: "Which kind of block the case tests (prompt | workflow | script | " +
3244
+ "integration). " +
3245
+ "Comes from the directory the `<key>.tests/` folder sits in and " +
3246
+ "travels in the URL path, not in the body.",
3247
+ },
3248
+ blockId: {
3249
+ kind: "structural",
3250
+ requestModes: [],
3251
+ note: "The block the case belongs to, resolved from the owning file's " +
3252
+ "key at push time and sent in the URL path.",
3253
+ },
3254
+ blockComposite: {
3255
+ kind: "server-owned",
3256
+ note: "`blockType#blockId`, assembled server-side for indexing.",
3257
+ },
3258
+ attachments: {
3259
+ kind: "server-owned",
3260
+ note: "Uploaded fixture files, managed through the " +
3261
+ "`/test-cases/{id}/attachments` endpoints and stored in R2. The " +
3262
+ "create handler always writes null; sync never carries them.",
3263
+ },
3264
+ createdBy: {
3265
+ kind: "server-owned",
3266
+ note: "Admin who created the case; assigned server-side.",
3267
+ },
3268
+ createdAt: {
3269
+ kind: "server-owned",
3270
+ note: "Timestamp, assigned server-side.",
3271
+ },
3272
+ modifiedAt: {
3273
+ kind: "server-owned",
3274
+ note: "Timestamp, assigned server-side.",
3275
+ },
3276
+ },
3277
+ tomlOnlyKeys: {
3278
+ configId: {
3279
+ kind: "deprecated",
3280
+ note: "The legacy id-based config reference. Still accepted on push so " +
3281
+ "an unmigrated file pushes unchanged; pull writes `configName`.",
3282
+ },
3283
+ evaluatorPromptId: {
3284
+ kind: "deprecated",
3285
+ note: "The legacy id-based evaluator-prompt reference; pull writes " +
3286
+ "`evaluatorPromptKey`.",
3287
+ },
3288
+ evaluatorConfigId: {
3289
+ kind: "deprecated",
3290
+ note: "The legacy id-based evaluator-config reference; pull writes " +
3291
+ "`evaluatorConfigName`.",
3292
+ },
3293
+ },
3294
+ responseOnlyKeys: {},
3295
+ },
3296
+ ],
3297
+ // A sidecar is the `[test]` table alone: inputs and expectations are keys
3298
+ // inside it (JSON strings), not sibling tables.
3299
+ tomlDocumentKeys: {},
3300
+ };
3301
+ // ── src/config-surface/app-settings.ts ───────────────────────────────────
3302
+ /**
3303
+ * The `app-settings` configuration object's definition (issue #2644, phase 3).
3304
+ *
3305
+ * `app.toml` is the other surface that round-trips without being a per-entity
3306
+ * file, so — like `test-case` — it is named explicitly rather than reached
3307
+ * through `SYNC_RESOURCE_TYPES`. It is also the object the whole epic
3308
+ * generalizes: #1033 already drives its pull serializer and push parser from
3309
+ * one descriptor (`cli/src/lib/app-settings-descriptor.ts`) against the
3310
+ * server's dependency-free write allow-list (`settings-fields.ts`), with a
3311
+ * parity test between them. This definition is that trio's registry entry, so
3312
+ * the shared coverage guard holds `App` to the same standard as every other
3313
+ * model: every field exposed, or classified with a reason.
3314
+ *
3315
+ * Two shapes here are unlike the per-entity objects:
3316
+ *
3317
+ * - ONE model over FOUR tables. `app.toml`'s `[app]`, `[auth]`, `[cors]` and
3318
+ * `[invitations]` sections all project the same `App` row, so coverage is
3319
+ * asserted per SURFACE (`findUnclassifiedSurfaceFields`) rather than per
3320
+ * table, and the `notExposed` classifications live once, on `[app]`.
3321
+ * - UPDATE only. There is one write path — `PUT /settings` — and an app is
3322
+ * created through a different endpoint entirely, so no field is writable on
3323
+ * "create" in this surface's sense.
3324
+ *
3325
+ * `googleClients` carries a per-entry `clientSecret` on purpose (#2256, #2891):
3326
+ * each stored value is a whole `{{secrets.KEY}}` REFERENCE, never the
3327
+ * credential, so the map round-trips like any other setting. That is the
3328
+ * explicit classification the secret-adjacent rule requires — the guard fails
3329
+ * on a bare omission, not on a stated reference-only contract.
3330
+ *
3331
+ * As of #2891 nothing here is writable-but-unauthorable. `notExposed` carries
3332
+ * no `deprecated` entry and no withheld-on-read field: the five superseded keys
3333
+ * (`googleClientId`, `googleClientSecret`, `redirectUris`, `passkeyRpId`,
3334
+ * `passkeyRpName`) stay DECLARED in `models.yaml` so a row's storage can still
3335
+ * be read, but no write path accepts them and no serializer emits them, which
3336
+ * makes them `server-owned` here rather than `deprecated`.
3337
+ */
3338
+ const SETTINGS_HANDLER = "src/app-api/controllers/settings-controller.ts";
3339
+ export const APP_SETTINGS_SURFACE = {
3340
+ label: "app-settings",
3341
+ tables: [
3342
+ {
3343
+ tomlPath: ["app"],
3344
+ repeated: false,
3345
+ model: "App",
3346
+ // Declaration order reproduces `APP_SETTINGS_DESCRIPTOR`'s (#1033), which
3347
+ // is the order `app.toml` is written in.
3348
+ fields: [
3349
+ {
3350
+ field: "name",
3351
+ tomlKey: "name",
3352
+ type: "string",
3353
+ emit: "always",
3354
+ writableOn: UPDATE_ONLY,
3355
+ validation: "passthrough",
3356
+ },
3357
+ {
3358
+ field: "mode",
3359
+ tomlKey: "mode",
3360
+ type: "string",
3361
+ emit: "always",
3362
+ writableOn: UPDATE_ONLY,
3363
+ validation: "passthrough",
3364
+ },
3365
+ {
3366
+ field: "baseUrl",
3367
+ tomlKey: "baseUrl",
3368
+ type: "string",
3369
+ emit: "whenSet",
3370
+ writableOn: UPDATE_ONLY,
3371
+ validation: "passthrough",
3372
+ },
3373
+ {
3374
+ field: "waitlistEnabled",
3375
+ tomlKey: "waitlistEnabled",
3376
+ type: "boolean",
3377
+ emit: "always",
3378
+ writableOn: UPDATE_ONLY,
3379
+ validation: "passthrough",
3380
+ },
3381
+ {
3382
+ field: "waitlistNotifyAdmins",
3383
+ tomlKey: "waitlistNotifyAdmins",
3384
+ type: "boolean",
3385
+ emit: "always",
3386
+ writableOn: UPDATE_ONLY,
3387
+ validation: "passthrough",
3388
+ },
3389
+ {
3390
+ field: "directLlmEnabled",
3391
+ tomlKey: "directLlmEnabled",
3392
+ type: "boolean",
3393
+ // Always emitted: an app that has not opted the deprecated direct
3394
+ // LLM/Gemini proxy in must see `directLlmEnabled = false` in its
3395
+ // pulled `app.toml`, not a key it has to already know about (#2687).
3396
+ emit: "always",
3397
+ writableOn: UPDATE_ONLY,
3398
+ // STRICT, not `!!`: the value arms a credit-spending surface read
3399
+ // with `=== true`, so `"false"` must be a 400 rather than a stored
3400
+ // `true`. The generated request schema stays keys-only (#1976), so
3401
+ // the check is `parseDirectLlmEnabledWrite` in the handler.
3402
+ validation: handledBy(SETTINGS_HANDLER, "SettingsController"),
3403
+ },
3404
+ {
3405
+ field: "allowedDomains",
3406
+ tomlKey: "allowedDomains",
3407
+ type: "string[]",
3408
+ emit: "whenSet",
3409
+ writableOn: UPDATE_ONLY,
3410
+ validation: "passthrough",
3411
+ },
3412
+ {
3413
+ field: "testAccountBaseEmails",
3414
+ tomlKey: "testAccountBaseEmails",
3415
+ type: "string[]",
3416
+ emit: "whenSet",
3417
+ writableOn: UPDATE_ONLY,
3418
+ // `+primitivetest` bypass whitelist (#490/#675), shape-checked by
3419
+ // `validateTestAccountBaseEmails`.
3420
+ validation: handledBy(SETTINGS_HANDLER, "SettingsController"),
3421
+ },
3422
+ ],
3423
+ notExposed: {
3424
+ appId: {
3425
+ kind: "server-owned",
3426
+ note: "The app's id; taken from the route, never authored.",
3427
+ },
3428
+ createdBy: {
3429
+ kind: "server-owned",
3430
+ note: "User who created the app; assigned server-side.",
3431
+ },
3432
+ createdByAdminId: {
3433
+ kind: "server-owned",
3434
+ note: "Admin who created the app; assigned server-side.",
3435
+ },
3436
+ passkeyRpId: {
3437
+ kind: "server-owned",
3438
+ note: "RETIRED from the write surface by #2891, superseded by " +
3439
+ "`passkeyRpConfig` (`[auth.passkeys]`), which carries the " +
3440
+ "per-rpId names. The attribute stays declared so the passkey " +
3441
+ "runtime can keep reading a legacy row as a fallback, but no " +
3442
+ "write path accepts it and no serializer emits it.",
3443
+ },
3444
+ passkeyRpName: {
3445
+ kind: "server-owned",
3446
+ note: "The other half of the legacy single-rpId passkey config, " +
3447
+ "RETIRED by #2891 alongside it; superseded by `passkeyRpConfig`.",
3448
+ },
3449
+ googleClientId: {
3450
+ kind: "server-owned",
3451
+ note: "RETIRED from the write surface by #2891, superseded by " +
3452
+ "`[auth.google.clients.<type>].clientId` — Google registers a " +
3453
+ "client per platform, so one app-wide id could not state what " +
3454
+ "the app actually has. Declared-and-deprecated storage only.",
3455
+ },
3456
+ googleClientSecret: {
3457
+ kind: "server-owned",
3458
+ note: "RETIRED from the write surface by #2891, superseded by " +
3459
+ "`[auth.google.clients.<type>].clientSecret` — the secret belongs " +
3460
+ "to one client, and Google issues none at all for the native " +
3461
+ "types. Declared-and-deprecated storage only; never read, never " +
3462
+ "echoed.",
3463
+ },
3464
+ magicLinkEnabled: {
3465
+ kind: "server-owned",
3466
+ note: "RETIRED from the write surface by #2884, superseded by " +
3467
+ "`emailSignInEnabled`. Email sign-in is ONE flow — one request, " +
3468
+ "one email carrying a code and (when a link can be issued) a " +
3469
+ "link — so there is no method to enable separately. The " +
3470
+ "attribute stays declared and is MIRRORED on every write of " +
3471
+ "`emailSignInEnabled`, so a row read by an un-migrated consumer " +
3472
+ "still matches behavior.",
3473
+ },
3474
+ otpEnabled: {
3475
+ kind: "server-owned",
3476
+ note: "The other half of the retired pair (#2884); superseded by " +
3477
+ "`emailSignInEnabled` and mirrored the same way.",
3478
+ },
3479
+ redirectUris: {
3480
+ kind: "server-owned",
3481
+ note: "RETIRED from the write surface by #2891. A redirect URI belongs " +
3482
+ "to the thing that redirects: Google callbacks moved to " +
3483
+ "`[auth.google.clients.<type>].redirectUris` and the magic-link " +
3484
+ "allow-list to `emailRedirectUris`. Declared-and-deprecated " +
3485
+ "storage only.",
3486
+ },
3487
+ createdAt: {
3488
+ kind: "server-owned",
3489
+ note: "Timestamp, assigned server-side.",
3490
+ },
3491
+ modifiedAt: {
3492
+ kind: "server-owned",
3493
+ note: "Timestamp, assigned server-side.",
3494
+ },
3495
+ },
3496
+ tomlOnlyKeys: {},
3497
+ responseOnlyKeys: {
3498
+ // `hasGoogleAuth` is gone (#2891): the client map states per-platform
3499
+ // truth directly, so a single derived flag can only be wrong in one
3500
+ // direction or the other. `hasAppleAuth` and `hasPasskey` STAY — the
3501
+ // asymmetry is deliberate, not an oversight to "fix" later: Apple and
3502
+ // passkeys have no map to derive that truth from.
3503
+ hasAppleAuth: {
3504
+ kind: "server-owned",
3505
+ note: "Derived presence flag on GET /settings; no setter.",
3506
+ },
3507
+ hasPasskey: {
3508
+ kind: "server-owned",
3509
+ note: "Derived presence flag on GET /settings; no setter.",
3510
+ },
3511
+ },
3512
+ },
3513
+ {
3514
+ tomlPath: ["auth"],
3515
+ repeated: false,
3516
+ model: "App",
3517
+ fields: [
3518
+ {
3519
+ field: "googleOAuthEnabled",
3520
+ tomlKey: "googleOAuthEnabled",
3521
+ type: "boolean",
3522
+ emit: "whenSet",
3523
+ writableOn: UPDATE_ONLY,
3524
+ validation: "passthrough",
3525
+ },
3526
+ {
3527
+ field: "googleClients",
3528
+ tomlKey: "google",
3529
+ type: "json",
3530
+ emit: "whenSet",
3531
+ writableOn: UPDATE_ONLY,
3532
+ // `[auth.google.clients.<type>]` — one entry per Google client type,
3533
+ // each with its own `clientId`, `redirectUris` and (where the type
3534
+ // takes one) a whole `{{secrets.KEY}}` `clientSecret`. Shape, the
3535
+ // per-type secret table, cross-entry URI uniqueness and the
3536
+ // aggregate caps are all `validateGoogleClientsWrite`
3537
+ // (`src/services/google-clients.ts`), applied by the handler.
3538
+ validation: handledBy(SETTINGS_HANDLER, "SettingsController"),
3539
+ },
3540
+ {
3541
+ field: "passkeyEnabled",
3542
+ tomlKey: "passkeyEnabled",
3543
+ type: "boolean",
3544
+ emit: "always",
3545
+ writableOn: UPDATE_ONLY,
3546
+ validation: "passthrough",
3547
+ },
3548
+ {
3549
+ field: "emailSignInEnabled",
3550
+ tomlKey: "emailSignInEnabled",
3551
+ type: "boolean",
3552
+ // Always emitted: an app's pulled `app.toml` should state whether
3553
+ // email sign-in is on, not leave it to be inferred from the absence
3554
+ // of a key (#2884).
3555
+ emit: "always",
3556
+ writableOn: UPDATE_ONLY,
3557
+ // Mirrored into the two retired model fields by the handler so an
3558
+ // un-migrated reader cannot disagree.
3559
+ validation: handledBy(SETTINGS_HANDLER, "SettingsController"),
3560
+ },
3561
+ {
3562
+ field: "appleSignInEnabled",
3563
+ tomlKey: "appleSignInEnabled",
3564
+ type: "boolean",
3565
+ emit: "whenSet",
3566
+ writableOn: UPDATE_ONLY,
3567
+ validation: "passthrough",
3568
+ },
3569
+ {
3570
+ field: "appleAudiences",
3571
+ tomlKey: "appleAudiences",
3572
+ type: "string[]",
3573
+ emit: "whenSet",
3574
+ writableOn: UPDATE_ONLY,
3575
+ validation: "passthrough",
3576
+ },
3577
+ {
3578
+ field: "emailRedirectUris",
3579
+ tomlKey: "emailRedirectUris",
3580
+ type: "string[]",
3581
+ emit: "whenSet",
3582
+ writableOn: UPDATE_ONLY,
3583
+ // The email sign-in link allow-list, flat in `[auth]` beside the
3584
+ // flag it belongs with (sponsor decision, DSO-2891-003 — no
3585
+ // `[auth.email]` table). Each URI is parsed and canonicalized by
3586
+ // `validateRedirectUris`; matching is fail-closed, and a request
3587
+ // whose target this list does not cover gets a code-only email
3588
+ // rather than a link (#2884).
3589
+ validation: handledBy(SETTINGS_HANDLER, "SettingsController"),
3590
+ },
3591
+ {
3592
+ field: "passkeyRpConfig",
3593
+ tomlKey: "passkeys",
3594
+ type: "json",
3595
+ emit: "whenSet",
3596
+ writableOn: UPDATE_ONLY,
3597
+ // `[auth.passkeys]` — rpId → { name }, shape-checked by
3598
+ // `SettingsController.validatePasskeyRpConfig`.
3599
+ validation: handledBy(SETTINGS_HANDLER, "SettingsController"),
3600
+ },
3601
+ ],
3602
+ notExposed: {},
3603
+ tomlOnlyKeys: {},
3604
+ responseOnlyKeys: {},
3605
+ },
3606
+ {
3607
+ tomlPath: ["cors"],
3608
+ repeated: false,
3609
+ model: "App",
3610
+ fields: [
3611
+ {
3612
+ field: "corsMode",
3613
+ tomlKey: "mode",
3614
+ type: "string",
3615
+ emit: "always",
3616
+ writableOn: UPDATE_ONLY,
3617
+ validation: "passthrough",
3618
+ },
3619
+ {
3620
+ field: "corsAllowedOrigins",
3621
+ tomlKey: "allowedOrigins",
3622
+ type: "string[]",
3623
+ emit: "whenSet",
3624
+ writableOn: UPDATE_ONLY,
3625
+ validation: "passthrough",
3626
+ },
3627
+ {
3628
+ field: "corsAllowCredentials",
3629
+ tomlKey: "allowCredentials",
3630
+ type: "boolean",
3631
+ emit: "whenSet",
3632
+ writableOn: UPDATE_ONLY,
3633
+ validation: "passthrough",
3634
+ },
3635
+ {
3636
+ field: "corsAllowedMethods",
3637
+ tomlKey: "allowedMethods",
3638
+ type: "string[]",
3639
+ emit: "whenSet",
3640
+ writableOn: UPDATE_ONLY,
3641
+ validation: "passthrough",
3642
+ },
3643
+ {
3644
+ field: "corsAllowedHeaders",
3645
+ tomlKey: "allowedHeaders",
3646
+ type: "string[]",
3647
+ emit: "whenSet",
3648
+ writableOn: UPDATE_ONLY,
3649
+ validation: "passthrough",
3650
+ },
3651
+ {
3652
+ field: "corsExposedHeaders",
3653
+ tomlKey: "exposedHeaders",
3654
+ type: "string[]",
3655
+ emit: "whenSet",
3656
+ writableOn: UPDATE_ONLY,
3657
+ validation: "passthrough",
3658
+ },
3659
+ {
3660
+ field: "corsMaxAge",
3661
+ tomlKey: "maxAge",
3662
+ type: "number",
3663
+ emit: "whenSet",
3664
+ writableOn: UPDATE_ONLY,
3665
+ validation: "passthrough",
3666
+ },
3667
+ ],
3668
+ notExposed: {},
3669
+ tomlOnlyKeys: {},
3670
+ responseOnlyKeys: {},
3671
+ },
3672
+ {
3673
+ tomlPath: ["invitations"],
3674
+ repeated: false,
3675
+ model: "App",
3676
+ fields: [
3677
+ {
3678
+ field: "memberInvitationsEnabled",
3679
+ tomlKey: "enabled",
3680
+ type: "boolean",
3681
+ emit: "always",
3682
+ writableOn: UPDATE_ONLY,
3683
+ validation: "passthrough",
3684
+ },
3685
+ {
3686
+ field: "memberInvitationLimit",
3687
+ tomlKey: "limit",
3688
+ type: "number",
3689
+ emit: "always",
3690
+ writableOn: UPDATE_ONLY,
3691
+ validation: "passthrough",
3692
+ },
3693
+ ],
3694
+ notExposed: {},
3695
+ tomlOnlyKeys: {},
3696
+ responseOnlyKeys: {},
3697
+ },
3698
+ ],
3699
+ // `app.toml`'s four sections are all field tables; it carries no other
3700
+ // top-level key. (App config VARS live in their own `vars.toml`.)
3701
+ tomlDocumentKeys: {},
3702
+ };
3703
+ // ── src/config-surface/index.ts ──────────────────────────────────────────
3704
+ /**
3705
+ * The configuration-object registry (issue #2644).
3706
+ *
3707
+ * `CONFIG_SURFACES` holds one entry per synced configuration object type. The
3708
+ * registry — not a per-type test — is what makes coverage follow from existing:
3709
+ * `cli/tests/unit/config-surface-drift-guard.test.ts` reads the CLI's
3710
+ * `SYNC_RESOURCE_TYPES` labels and fails when a label has no surface here.
3711
+ *
3712
+ * `CONFIG_SURFACES` is the write authority. `SYNC_RESOURCE_TYPES` keeps its
3713
+ * documented role — directory/state/prune/diff layout metadata, explicitly "not
3714
+ * a write framework" — and gains no write-surface fields; the two are
3715
+ * cross-checked, not merged.
3716
+ */
3717
+ /**
3718
+ * Every configuration object whose field surface is defined here.
3719
+ *
3720
+ * One entry per `SYNC_RESOURCE_TYPES` label, plus the two surfaces that
3721
+ * round-trip without being a per-entity file: `app-settings` (`app.toml`) and
3722
+ * `test-case` (`<key>.tests/`). Nothing that syncs sits outside the registry —
3723
+ * `PENDING_MIGRATION`, the migration's temporary exemption list, is gone as of
3724
+ * phase 3, so a new synced type has nowhere to be parked and fails the registry
3725
+ * guard until it is defined (#2644 criterion 3).
3726
+ */
3727
+ export const CONFIG_SURFACES = [
3728
+ WORKFLOW_SURFACE,
3729
+ PROMPT_SURFACE,
3730
+ INTEGRATION_SURFACE,
3731
+ WEBHOOK_SURFACE,
3732
+ CRON_TRIGGER_SURFACE,
3733
+ BLOB_BUCKET_SURFACE,
3734
+ EMAIL_TEMPLATE_SURFACE,
3735
+ DATABASE_TYPE_SURFACE,
3736
+ RULE_SET_SURFACE,
3737
+ GROUP_TYPE_CONFIG_SURFACE,
3738
+ COLLECTION_TYPE_CONFIG_SURFACE,
3739
+ METADATA_CATEGORY_CONFIG_SURFACE,
3740
+ TRANSFORM_SURFACE,
3741
+ TEST_CASE_SURFACE,
3742
+ APP_SETTINGS_SURFACE,
3743
+ ];
3744
+ /** The surface for a `SyncResourceType.label`, or undefined when unmigrated. */
3745
+ export function getConfigSurface(label) {
3746
+ return CONFIG_SURFACES.find((s) => s.label === label);
3747
+ }
3748
+ /** One table of one surface, addressed by its TOML path. */
3749
+ export function getConfigTable(label, tomlPath) {
3750
+ const surface = getConfigSurface(label);
3751
+ if (!surface)
3752
+ return undefined;
3753
+ return surface.tables.find((t) => t.tomlPath.length === tomlPath.length &&
3754
+ t.tomlPath.every((segment, i) => segment === tomlPath[i]));
3755
+ }
3756
+ /**
3757
+ * Every field of every `models.yaml` model a configuration-object definition
3758
+ * names, in declaration order — the coverage guard's anchor (#2644 criterion 2).
3759
+ */
3760
+ export const GENERATED_CONFIG_MODEL_FIELDS = {
3761
+ "AccessRuleSet": [
3762
+ "ruleSetId",
3763
+ "appId",
3764
+ "name",
3765
+ "description",
3766
+ "resourceType",
3767
+ "rules",
3768
+ "version",
3769
+ "createdAt",
3770
+ "modifiedAt",
3771
+ "createdBy",
3772
+ ],
3773
+ "App": [
3774
+ "appId",
3775
+ "name",
3776
+ "mode",
3777
+ "waitlistEnabled",
3778
+ "waitlistNotifyAdmins",
3779
+ "createdBy",
3780
+ "createdByAdminId",
3781
+ "googleOAuthEnabled",
3782
+ "googleClientId",
3783
+ "googleClientSecret",
3784
+ "googleClients",
3785
+ "appleSignInEnabled",
3786
+ "appleAudiences",
3787
+ "allowedDomains",
3788
+ "redirectUris",
3789
+ "emailRedirectUris",
3790
+ "testAccountBaseEmails",
3791
+ "corsMode",
3792
+ "corsAllowedOrigins",
3793
+ "corsAllowCredentials",
3794
+ "corsAllowedMethods",
3795
+ "corsAllowedHeaders",
3796
+ "corsExposedHeaders",
3797
+ "corsMaxAge",
3798
+ "passkeyEnabled",
3799
+ "passkeyRpId",
3800
+ "passkeyRpName",
3801
+ "passkeyRpConfig",
3802
+ "emailSignInEnabled",
3803
+ "magicLinkEnabled",
3804
+ "otpEnabled",
3805
+ "baseUrl",
3806
+ "directLlmEnabled",
3807
+ "memberInvitationsEnabled",
3808
+ "memberInvitationLimit",
3809
+ "createdAt",
3810
+ "modifiedAt",
3811
+ ],
3812
+ "AppIntegration": [
3813
+ "integrationId",
3814
+ "appId",
3815
+ "integrationKey",
3816
+ "displayName",
3817
+ "description",
3818
+ "status",
3819
+ "requestConfig",
3820
+ "accessRule",
3821
+ "activeConfigId",
3822
+ "timeoutMs",
3823
+ "maxRequestBodyBytes",
3824
+ "copiedFromCatalogId",
3825
+ "lastTestedAt",
3826
+ "createdBy",
3827
+ "createdAt",
3828
+ "modifiedAt",
3829
+ ],
3830
+ "AppPrompt": [
3831
+ "promptId",
3832
+ "appId",
3833
+ "promptKey",
3834
+ "displayName",
3835
+ "description",
3836
+ "copiedFromCatalogId",
3837
+ "inputSchema",
3838
+ "outputSchema",
3839
+ "activeConfigId",
3840
+ "defaultEvaluatorPromptId",
3841
+ "status",
3842
+ "accessRule",
3843
+ "createdBy",
3844
+ "createdAt",
3845
+ "modifiedAt",
3846
+ ],
3847
+ "AppPromptConfig": [
3848
+ "configId",
3849
+ "promptId",
3850
+ "appId",
3851
+ "configName",
3852
+ "promptIdConfigName",
3853
+ "description",
3854
+ "provider",
3855
+ "model",
3856
+ "systemPrompt",
3857
+ "systemPromptR2Key",
3858
+ "userPromptTemplate",
3859
+ "userPromptTemplateR2Key",
3860
+ "temperature",
3861
+ "topP",
3862
+ "maxTokens",
3863
+ "outputFormat",
3864
+ "outputSchema",
3865
+ "providerConfig",
3866
+ "status",
3867
+ "createdBy",
3868
+ "createdAt",
3869
+ "modifiedAt",
3870
+ ],
3871
+ "AppWebhook": [
3872
+ "webhookId",
3873
+ "appId",
3874
+ "webhookKey",
3875
+ "displayName",
3876
+ "description",
3877
+ "status",
3878
+ "workflowKey",
3879
+ "workflowId",
3880
+ "verificationScheme",
3881
+ "signingSecret",
3882
+ "previousSigningSecret",
3883
+ "secretRotatedAt",
3884
+ "secretGracePeriodMs",
3885
+ "toleranceSeconds",
3886
+ "deduplicationEnabled",
3887
+ "deduplicationWindowMs",
3888
+ "maxBodyBytes",
3889
+ "inputMapping",
3890
+ "allowedIpCidrs",
3891
+ "handshakeRules",
3892
+ "config",
3893
+ "metadata",
3894
+ "lastTriggeredAt",
3895
+ "createdBy",
3896
+ "createdAt",
3897
+ "modifiedAt",
3898
+ ],
3899
+ "BlobBucket": [
3900
+ "bucketId",
3901
+ "appId",
3902
+ "bucketKey",
3903
+ "name",
3904
+ "description",
3905
+ "ttlTier",
3906
+ "accessPolicy",
3907
+ "preset",
3908
+ "ruleSetId",
3909
+ "createdBy",
3910
+ "createdAt",
3911
+ "modifiedAt",
3912
+ ],
3913
+ "BlockTestCase": [
3914
+ "testCaseId",
3915
+ "appId",
3916
+ "blockType",
3917
+ "blockId",
3918
+ "blockComposite",
3919
+ "configId",
3920
+ "key",
3921
+ "keyToken",
3922
+ "name",
3923
+ "description",
3924
+ "inputVariables",
3925
+ "expectedOutputPattern",
3926
+ "expectedOutputContains",
3927
+ "expectedJsonSubset",
3928
+ "evaluatorPromptId",
3929
+ "evaluatorConfigId",
3930
+ "attachments",
3931
+ "createdBy",
3932
+ "createdAt",
3933
+ "modifiedAt",
3934
+ ],
3935
+ "CollectionTypeConfig": [
3936
+ "appId",
3937
+ "collectionType",
3938
+ "ruleSetId",
3939
+ "metadataManifest",
3940
+ "createdAt",
3941
+ "modifiedAt",
3942
+ "createdBy",
3943
+ ],
3944
+ "CronTrigger": [
3945
+ "triggerId",
3946
+ "appId",
3947
+ "triggerKey",
3948
+ "displayName",
3949
+ "description",
3950
+ "cron",
3951
+ "timezone",
3952
+ "workflowKey",
3953
+ "overlapPolicy",
3954
+ "rootInput",
3955
+ "inputMapping",
3956
+ "status",
3957
+ "state",
3958
+ "operationallyDisabled",
3959
+ "lastError",
3960
+ "lastTriggeredAt",
3961
+ "lastTriggeredRunId",
3962
+ "skippedCount",
3963
+ "firedCount",
3964
+ "consecutiveNotActiveCount",
3965
+ "nextFireAt",
3966
+ "createdBy",
3967
+ "createdAt",
3968
+ "modifiedAt",
3969
+ ],
3970
+ "DatabaseTypeConfig": [
3971
+ "appId",
3972
+ "databaseType",
3973
+ "ruleSetId",
3974
+ "triggers",
3975
+ "metadataAccess",
3976
+ "defaultAccess",
3977
+ "metadataManifest",
3978
+ "autoPopulatedFields",
3979
+ "timestamps",
3980
+ "schema",
3981
+ "createdAt",
3982
+ "modifiedAt",
3983
+ "createdBy",
3984
+ ],
3985
+ "EmailTemplate": [
3986
+ "templateId",
3987
+ "appId",
3988
+ "emailType",
3989
+ "subject",
3990
+ "htmlBody",
3991
+ "textBody",
3992
+ "createdBy",
3993
+ "createdAt",
3994
+ "modifiedAt",
3995
+ ],
3996
+ "GroupTypeConfig": [
3997
+ "appId",
3998
+ "groupType",
3999
+ "ruleSetId",
4000
+ "metadataManifest",
4001
+ "autoAddCreator",
4002
+ "createdAt",
4003
+ "modifiedAt",
4004
+ "createdBy",
4005
+ ],
4006
+ "MetadataCategoryConfig": [
4007
+ "appId",
4008
+ "categoryKey",
4009
+ "resourceType",
4010
+ "category",
4011
+ "schema",
4012
+ "readRule",
4013
+ "writeRule",
4014
+ "metadataManifest",
4015
+ "uniqueField",
4016
+ "schemaVersion",
4017
+ "generation",
4018
+ "description",
4019
+ "createdAt",
4020
+ "modifiedAt",
4021
+ "createdBy",
4022
+ ],
4023
+ "WorkflowDefinition": [
4024
+ "workflowId",
4025
+ "appId",
4026
+ "workflowKey",
4027
+ "name",
4028
+ "status",
4029
+ "latestRevision",
4030
+ "description",
4031
+ "stepsR2Key",
4032
+ "draftStepsR2Key",
4033
+ "draftInputSchema",
4034
+ "draftOutputSchema",
4035
+ "draftModifiedAt",
4036
+ "activeConfigId",
4037
+ "inputSchema",
4038
+ "outputSchema",
4039
+ "perUserMaxRunning",
4040
+ "perUserMaxQueued",
4041
+ "perAppMaxRunning",
4042
+ "perAppMaxQueued",
4043
+ "queueTtlSeconds",
4044
+ "dequeueOrder",
4045
+ "requiresClientApply",
4046
+ "syncCallable",
4047
+ "accessRule",
4048
+ "metadataManifest",
4049
+ "runAs",
4050
+ "lock",
4051
+ "capabilities",
4052
+ "operationallyDisabled",
4053
+ "createdBy",
4054
+ "createdAt",
4055
+ "modifiedAt",
4056
+ ],
4057
+ };
4058
+ //# sourceMappingURL=generated-config-surfaces.js.map