spine-framework 0.4.4 → 0.5.0-rc.5

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 (305) hide show
  1. package/.env.example +13 -3
  2. package/.framework/cli/commands/bootstrap.ts +392 -0
  3. package/.framework/cli/commands/dev.ts +91 -218
  4. package/.framework/cli/commands/doctor.ts +132 -26
  5. package/.framework/cli/commands/generate.ts +38 -298
  6. package/.framework/cli/commands/init.ts +20 -1
  7. package/.framework/cli/commands/install-app.ts +183 -13
  8. package/.framework/cli/commands/migrate.ts +36 -30
  9. package/.framework/cli/commands/migrations.ts +16 -56
  10. package/.framework/cli/commands/upgrade.ts +35 -0
  11. package/.framework/cli/commands/validate-app.ts +127 -1
  12. package/.framework/cli/commands/validate-schema.ts +147 -0
  13. package/.framework/cli/context.ts +223 -110
  14. package/.framework/cli/index.ts +6 -0
  15. package/.framework/cli/lib/project-files.ts +241 -1
  16. package/.framework/functions/_shared/app-manifest.ts +2 -0
  17. package/.framework/functions/_shared/hash-api-key.ts +183 -79
  18. package/.framework/functions/_shared/logging.ts +562 -224
  19. package/.framework/functions/_shared/middleware.ts +47 -44
  20. package/.framework/functions/_shared/permissions.ts +4 -0
  21. package/.framework/functions/admin-data.ts +51 -28
  22. package/.framework/functions/ai-agents.ts +10 -6
  23. package/.framework/functions/api-keys.ts +301 -122
  24. package/.framework/functions/apps.ts +63 -29
  25. package/.framework/functions/cil-ingest.ts +48 -15
  26. package/.framework/functions/debug-auth.ts +14 -8
  27. package/.framework/functions/deliveries.ts +39 -12
  28. package/.framework/functions/embeddings.ts +149 -78
  29. package/.framework/functions/integration-outbound.ts +67 -27
  30. package/.framework/functions/integrations.ts +254 -127
  31. package/.framework/functions/logs.ts +273 -133
  32. package/.framework/functions/observability.ts +278 -148
  33. package/.framework/functions/pipeline-executions.ts +1 -1
  34. package/.framework/functions/pipelines.ts +14 -9
  35. package/.framework/functions/prompt-configs.ts +7 -4
  36. package/.framework/functions/roles.ts +8 -5
  37. package/.framework/functions/security.ts +30 -17
  38. package/.framework/functions/system-cron.ts +240 -127
  39. package/.framework/functions/tests.ts +7 -6
  40. package/.framework/functions/timers.ts +310 -107
  41. package/.framework/functions/triggers.ts +28 -13
  42. package/.framework/functions/types.ts +13 -10
  43. package/.framework/migrations/069_log_stats_window.sql +54 -0
  44. package/.framework/migrations/070_app_type_schema_rpcs.sql +166 -0
  45. package/.framework/migrations/071_timer_interval_keys.sql +96 -0
  46. package/.framework/migrations/072_slug_autofill.sql +77 -0
  47. package/.framework/migrations/073_seed_member_roles.sql +26 -0
  48. package/.framework/migrations/074_app_route_prefix_domains.sql +168 -0
  49. package/.framework/migrations/075_security_definer_grants.sql +157 -0
  50. package/.framework/src/apps/admin/index.tsx +1141 -775
  51. package/.framework/src/components/ErrorBoundary.tsx +136 -0
  52. package/.framework/src/components/admin/AdminListPage.tsx +81 -73
  53. package/.framework/src/components/admin/AdminSidebar.tsx +88 -79
  54. package/.framework/src/components/admin/EntitySelect.tsx +181 -0
  55. package/.framework/src/components/admin/PageInstructions.tsx +86 -127
  56. package/.framework/src/components/app-shell/GenericDetailPage.tsx +37 -13
  57. package/.framework/src/components/app-shell/GenericListPage.tsx +28 -32
  58. package/.framework/src/components/layout/Sidebar.tsx +300 -177
  59. package/.framework/src/components/runtime/DataDetailPage.tsx +67 -35
  60. package/.framework/src/components/runtime/DataHeader.tsx +96 -48
  61. package/.framework/src/components/runtime/DataListPage.tsx +3 -26
  62. package/.framework/src/components/runtime/DataStats.tsx +10 -10
  63. package/.framework/src/components/runtime/DataTable.tsx +12 -10
  64. package/.framework/src/components/ui/sheet.tsx +24 -26
  65. package/.framework/src/components/ui/textarea.tsx +74 -48
  66. package/.framework/src/contexts/AppContext.tsx +5 -12
  67. package/.framework/src/contexts/AuthContext.tsx +17 -9
  68. package/.framework/src/hooks/useApps.ts +18 -22
  69. package/.framework/src/hooks/useEntityList.ts +12 -9
  70. package/.framework/src/hooks/useListSchema.ts +108 -58
  71. package/.framework/src/instructions/README.md +74 -0
  72. package/.framework/src/instructions/accounts.md +97 -0
  73. package/.framework/src/instructions/ai-agents.md +69 -0
  74. package/.framework/src/instructions/alerts.md +80 -0
  75. package/.framework/src/instructions/api-keys.md +87 -0
  76. package/.framework/src/instructions/apps.md +77 -0
  77. package/.framework/src/instructions/concepts/ai-and-prompts.md +150 -0
  78. package/.framework/src/instructions/concepts/apps-and-renderers.md +228 -0
  79. package/.framework/src/instructions/concepts/auth-and-api-keys.md +133 -0
  80. package/.framework/src/instructions/concepts/automation.md +186 -0
  81. package/.framework/src/instructions/concepts/concurrency-and-idempotency.md +125 -0
  82. package/.framework/src/instructions/concepts/core-vs-custom.md +124 -0
  83. package/.framework/src/instructions/concepts/design-schema.md +152 -0
  84. package/.framework/src/instructions/concepts/integrations-and-webhooks.md +127 -0
  85. package/.framework/src/instructions/concepts/observability.md +116 -0
  86. package/.framework/src/instructions/concepts/permissions.md +150 -0
  87. package/.framework/src/instructions/concepts/runtime-records.md +111 -0
  88. package/.framework/src/instructions/concepts/scope-and-accounts.md +112 -0
  89. package/.framework/src/instructions/concepts/spine-way.md +172 -0
  90. package/.framework/src/instructions/deliveries.md +59 -0
  91. package/.framework/src/instructions/guide/00-orientation.md +106 -0
  92. package/.framework/src/instructions/guide/01-install-and-first-run.md +253 -0
  93. package/.framework/src/instructions/guide/02-project-map.md +147 -0
  94. package/.framework/src/instructions/guide/03-domain-model.md +223 -0
  95. package/.framework/src/instructions/guide/04-first-app.md +227 -0
  96. package/.framework/src/instructions/guide/05-glossary.md +183 -0
  97. package/.framework/src/instructions/integrations.md +96 -0
  98. package/.framework/src/instructions/internal/cil-markers.md +83 -0
  99. package/.framework/src/instructions/internal/docs-contract.md +158 -0
  100. package/.framework/src/instructions/invites.md +60 -0
  101. package/.framework/src/instructions/kb.md +80 -0
  102. package/.framework/src/instructions/logs.md +77 -0
  103. package/.framework/src/instructions/notifications.md +77 -0
  104. package/.framework/src/instructions/observability.md +61 -0
  105. package/.framework/src/instructions/operations/deployment.md +115 -0
  106. package/.framework/src/instructions/operations/migrations.md +125 -0
  107. package/.framework/src/instructions/operations/security-checklist.md +170 -0
  108. package/.framework/src/instructions/operations/troubleshooting.md +170 -0
  109. package/.framework/src/instructions/operations/upgrade.md +116 -0
  110. package/.framework/src/instructions/people.md +91 -0
  111. package/.framework/src/instructions/pipeline-executions.md +62 -0
  112. package/.framework/src/instructions/pipelines.md +78 -0
  113. package/.framework/src/instructions/prompts.md +95 -0
  114. package/.framework/src/instructions/reference/api/admin-data.md +162 -0
  115. package/.framework/src/instructions/reference/api/auth.md +113 -0
  116. package/.framework/src/instructions/reference/api/errors-and-envelopes.md +113 -0
  117. package/.framework/src/instructions/reference/app-manifest.md +184 -0
  118. package/.framework/src/instructions/reference/cli.md +124 -0
  119. package/.framework/src/instructions/reference/database.md +132 -0
  120. package/.framework/src/instructions/reference/design-schema.md +280 -0
  121. package/.framework/src/instructions/reference/env-vars.md +133 -0
  122. package/.framework/src/instructions/reference/frontend-sdk.md +207 -0
  123. package/.framework/src/instructions/reference/functions/account-nodes.md +75 -0
  124. package/.framework/src/instructions/reference/functions/admin-data.md +197 -0
  125. package/.framework/src/instructions/reference/functions/admin-funnel.md +90 -0
  126. package/.framework/src/instructions/reference/functions/admin-install-funnel.md +107 -0
  127. package/.framework/src/instructions/reference/functions/admin-signals.md +102 -0
  128. package/.framework/src/instructions/reference/functions/ai-agents.md +148 -0
  129. package/.framework/src/instructions/reference/functions/ai-interactions.md +156 -0
  130. package/.framework/src/instructions/reference/functions/api-keys.md +130 -0
  131. package/.framework/src/instructions/reference/functions/apps.md +140 -0
  132. package/.framework/src/instructions/reference/functions/auth.md +99 -0
  133. package/.framework/src/instructions/reference/functions/debug-auth.md +91 -0
  134. package/.framework/src/instructions/reference/functions/deliveries.md +110 -0
  135. package/.framework/src/instructions/reference/functions/embeddings.md +166 -0
  136. package/.framework/src/instructions/reference/functions/eval-harness.md +149 -0
  137. package/.framework/src/instructions/reference/functions/index.md +103 -0
  138. package/.framework/src/instructions/reference/functions/integration-outbound.md +200 -0
  139. package/.framework/src/instructions/reference/functions/integration-routes.md +151 -0
  140. package/.framework/src/instructions/reference/functions/integrations.md +130 -0
  141. package/.framework/src/instructions/reference/functions/invites.md +182 -0
  142. package/.framework/src/instructions/reference/functions/item-progress.md +107 -0
  143. package/.framework/src/instructions/reference/functions/kb.md +166 -0
  144. package/.framework/src/instructions/reference/functions/logs.md +141 -0
  145. package/.framework/src/instructions/reference/functions/observability.md +105 -0
  146. package/.framework/src/instructions/reference/functions/pipeline-executions.md +132 -0
  147. package/.framework/src/instructions/reference/functions/pipeline-runner-background.md +121 -0
  148. package/.framework/src/instructions/reference/functions/pipelines.md +136 -0
  149. package/.framework/src/instructions/reference/functions/prompt-configs.md +121 -0
  150. package/.framework/src/instructions/reference/functions/roles.md +121 -0
  151. package/.framework/src/instructions/reference/functions/security.md +140 -0
  152. package/.framework/src/instructions/reference/functions/system-cron.md +136 -0
  153. package/.framework/src/instructions/reference/functions/system.md +106 -0
  154. package/.framework/src/instructions/reference/functions/tests.md +97 -0
  155. package/.framework/src/instructions/reference/functions/timers.md +117 -0
  156. package/.framework/src/instructions/reference/functions/trigger-scheduler.md +102 -0
  157. package/.framework/src/instructions/reference/functions/triggers.md +140 -0
  158. package/.framework/src/instructions/reference/functions/types.md +146 -0
  159. package/.framework/src/instructions/reference/seeds.md +126 -0
  160. package/.framework/src/instructions/reference/shared-modules.md +127 -0
  161. package/.framework/src/instructions/reference/spine-config.md +114 -0
  162. package/.framework/src/instructions/roles.md +80 -0
  163. package/.framework/src/instructions/runtime-collaboration.md +106 -0
  164. package/.framework/src/instructions/runtime-detail.md +70 -0
  165. package/.framework/src/instructions/runtime-list.md +77 -0
  166. package/.framework/src/instructions/security.md +67 -0
  167. package/.framework/src/instructions/tasks/add-a-pipeline.md +110 -0
  168. package/.framework/src/instructions/tasks/add-a-role.md +96 -0
  169. package/.framework/src/instructions/tasks/add-a-timer.md +114 -0
  170. package/.framework/src/instructions/tasks/add-a-trigger.md +106 -0
  171. package/.framework/src/instructions/tasks/add-a-type.md +151 -0
  172. package/.framework/src/instructions/tasks/add-a-webhook.md +108 -0
  173. package/.framework/src/instructions/tasks/add-an-ai-agent.md +108 -0
  174. package/.framework/src/instructions/tasks/add-an-integration.md +124 -0
  175. package/.framework/src/instructions/tasks/build-a-custom-page.md +118 -0
  176. package/.framework/src/instructions/tasks/document-your-app.md +117 -0
  177. package/.framework/src/instructions/tasks/package-and-install-an-app.md +136 -0
  178. package/.framework/src/instructions/tasks/register-an-app.md +137 -0
  179. package/.framework/src/instructions/tasks/test-your-code.md +108 -0
  180. package/.framework/src/instructions/tasks/write-a-function.md +132 -0
  181. package/.framework/src/instructions/tasks/write-a-migration.md +110 -0
  182. package/.framework/src/instructions/testing.md +59 -0
  183. package/.framework/src/instructions/timers.md +84 -0
  184. package/.framework/src/instructions/triggers.md +69 -0
  185. package/.framework/src/instructions/types.md +93 -0
  186. package/.framework/src/lib/app-mount.ts +88 -0
  187. package/.framework/src/pages/NotFoundPage.tsx +62 -31
  188. package/.framework/src/pages/admin/AIAgentDetailPage.tsx +117 -106
  189. package/.framework/src/pages/admin/AIAgentsPage.tsx +86 -48
  190. package/.framework/src/pages/admin/APIKeyDetailPage.tsx +153 -152
  191. package/.framework/src/pages/admin/APIKeysPage.tsx +91 -90
  192. package/.framework/src/pages/admin/AlertsConfigPage.tsx +29 -18
  193. package/.framework/src/pages/admin/AppDetailPage.tsx +6 -60
  194. package/.framework/src/pages/admin/AppsPage.tsx +104 -66
  195. package/.framework/src/pages/admin/DeliveriesPage.tsx +5 -5
  196. package/.framework/src/pages/admin/EmbeddingDetailPage.tsx +5 -23
  197. package/.framework/src/pages/admin/EmbeddingsPage.tsx +5 -26
  198. package/.framework/src/pages/admin/IntegrationDetailPage.tsx +7 -7
  199. package/.framework/src/pages/admin/IntegrationsPage.tsx +85 -43
  200. package/.framework/src/pages/admin/LogsPage.tsx +144 -76
  201. package/.framework/src/pages/admin/ObservabilityDashboard.tsx +223 -140
  202. package/.framework/src/pages/admin/PipelineDetailPage.tsx +134 -113
  203. package/.framework/src/pages/admin/PipelineExecutionsPage.tsx +108 -45
  204. package/.framework/src/pages/admin/PipelinesPage.tsx +160 -115
  205. package/.framework/src/pages/admin/PromptConfigDetailPage.tsx +96 -98
  206. package/.framework/src/pages/admin/PromptConfigsPage.tsx +101 -59
  207. package/.framework/src/pages/admin/RoleDetailPage.tsx +90 -79
  208. package/.framework/src/pages/admin/RolesPage.tsx +86 -43
  209. package/.framework/src/pages/admin/SecurityPage.tsx +1 -1
  210. package/.framework/src/pages/admin/TestingDashboard.tsx +16 -28
  211. package/.framework/src/pages/admin/TimerDetailPage.tsx +117 -100
  212. package/.framework/src/pages/admin/TimersPage.tsx +90 -62
  213. package/.framework/src/pages/admin/TriggerDetailPage.tsx +90 -83
  214. package/.framework/src/pages/admin/TriggersPage.tsx +124 -99
  215. package/.framework/src/pages/admin/TypeDetailPage.tsx +152 -138
  216. package/.framework/src/pages/admin/TypesPage.tsx +90 -48
  217. package/.framework/src/pages/admin/cil/CILAgentPage.tsx +17 -25
  218. package/.framework/src/pages/admin/cil/CILChunkDetailPage.tsx +17 -27
  219. package/.framework/src/pages/admin/cil/CILDashboardPage.tsx +72 -40
  220. package/.framework/src/pages/admin/cil/CILFileListPage.tsx +321 -129
  221. package/.framework/src/pages/admin/cil/CILFileSelectPage.tsx +18 -29
  222. package/.framework/src/pages/admin/cil/CILGraphPage.tsx +26 -38
  223. package/.framework/src/pages/admin/cil/CILIngestionPage.tsx +34 -46
  224. package/.framework/src/pages/admin/cil/CILProjectsPage.tsx +189 -103
  225. package/.framework/src/pages/admin/cil/CILSearchPage.tsx +246 -136
  226. package/.framework/src/pages/admin/cil/CILSuggestionsPage.tsx +11 -22
  227. package/.framework/src/pages/admin/cil/CILVersionsPage.tsx +9 -20
  228. package/.framework/src/pages/admin/cil/FlexiblePipelinePage.tsx +26 -38
  229. package/.framework/src/pages/admin/kb/KBAdminPage.tsx +7 -26
  230. package/.framework/src/pages/auth/LoginPage.tsx +3 -2
  231. package/.framework/src/pages/auth/RegisterPage.tsx +16 -8
  232. package/.framework/templates/agent/AGENTS.md +167 -0
  233. package/.framework/templates/agent/skills/spine-add-ai-agent/SKILL.md +58 -0
  234. package/.framework/templates/agent/skills/spine-add-automation/SKILL.md +62 -0
  235. package/.framework/templates/agent/skills/spine-add-integration/SKILL.md +63 -0
  236. package/.framework/templates/agent/skills/spine-architect/SKILL.md +86 -0
  237. package/.framework/templates/agent/skills/spine-install-app/SKILL.md +79 -0
  238. package/.framework/templates/agent/skills/spine-migration/SKILL.md +75 -0
  239. package/.framework/templates/agent/skills/spine-new-app/SKILL.md +79 -0
  240. package/.framework/templates/agent/skills/spine-new-function/SKILL.md +71 -0
  241. package/.framework/templates/agent/skills/spine-new-type/SKILL.md +72 -0
  242. package/.framework/templates/agent/skills/spine-schema/SKILL.md +77 -0
  243. package/.framework/templates/agent/skills/spine-test/SKILL.md +67 -0
  244. package/.framework/templates/agent/skills/spine-troubleshoot/SKILL.md +74 -0
  245. package/.framework/templates/agent/skills/spine-upgrade/SKILL.md +69 -0
  246. package/.framework/templates/project.json +4 -1
  247. package/.framework/tests/unit/account-scoping-gates.test.ts +394 -0
  248. package/.framework/tests/unit/agent-files.test.ts +355 -0
  249. package/.framework/tests/unit/agent-runner.test.ts +479 -44
  250. package/.framework/tests/unit/api-keys-create-scope-gate.test.ts +160 -0
  251. package/.framework/tests/unit/api-keys-omit-secret-fields.test.ts +38 -0
  252. package/.framework/tests/unit/app-mount.test.ts +80 -0
  253. package/.framework/tests/unit/bootstrap.test.ts +242 -0
  254. package/.framework/tests/unit/cli-context-api-key.test.ts +114 -0
  255. package/.framework/tests/unit/debug-auth.test.ts +38 -0
  256. package/.framework/tests/unit/deliveries-account-scoping.test.ts +185 -0
  257. package/.framework/tests/unit/integration-outbound-credentials.test.ts +167 -0
  258. package/.framework/tests/unit/integrations-audit-redaction.test.ts +130 -0
  259. package/.framework/tests/unit/logging-app-id.test.ts +75 -0
  260. package/.framework/tests/unit/security-definer-grants.test.ts +154 -0
  261. package/.framework/tests/unit/system-context-denial.test.ts +145 -0
  262. package/.framework/tests/unit/system-cron.test.ts +240 -0
  263. package/.framework/tests/unit/timers.test.ts +159 -0
  264. package/README.md +125 -166
  265. package/config/tsconfig.cli.json +2 -2
  266. package/package.json +15 -4
  267. package/templates/project/package-lock.json +2 -2
  268. package/.framework/docs/admin-routes.md +0 -76
  269. package/.framework/docs/api-docs-progress.md +0 -38
  270. package/.framework/docs/api-governance.md +0 -146
  271. package/.framework/docs/api-testing-results.md +0 -212
  272. package/.framework/docs/assembly-launch-guide.md +0 -138
  273. package/.framework/docs/audit-results.md +0 -590
  274. package/.framework/docs/db-api-inventory.md +0 -95
  275. package/.framework/docs/gap-remediation-backlog.md +0 -103
  276. package/.framework/docs/ui-adoption-verification.md +0 -111
  277. package/.framework/docs/ui-api-coverage.md +0 -84
  278. package/.framework/docs/v2-compatibility-audit.md +0 -228
  279. package/.framework/mcp/index.ts +0 -133
  280. package/.framework/mcp/load-env.ts +0 -60
  281. package/.framework/mcp/shared/app-manifest.ts +0 -275
  282. package/.framework/mcp/shared/db.ts +0 -477
  283. package/.framework/mcp/shared/kb.ts +0 -118
  284. package/.framework/mcp/tools/architect.ts +0 -431
  285. package/.framework/mcp/tools/cil/context.ts +0 -345
  286. package/.framework/mcp/tools/cil/graph.ts +0 -242
  287. package/.framework/mcp/tools/cil/ingest.ts +0 -1217
  288. package/.framework/mcp/tools/cil/pipeline-orchestrator.ts +0 -693
  289. package/.framework/mcp/tools/cil/pipeline.ts +0 -767
  290. package/.framework/mcp/tools/cil/project.ts +0 -791
  291. package/.framework/mcp/tools/cil/retrieval.ts +0 -336
  292. package/.framework/mcp/tools/cil/search-core.ts +0 -454
  293. package/.framework/mcp/tools/cil/search.ts +0 -97
  294. package/.framework/mcp/tools/cil/suggest.ts +0 -186
  295. package/.framework/mcp/tools/cil/validate.ts +0 -1369
  296. package/.framework/mcp/tools/kb/delete.ts +0 -139
  297. package/.framework/mcp/tools/kb/index.ts +0 -52
  298. package/.framework/mcp/tools/kb/ingest.ts +0 -84
  299. package/.framework/mcp/tools/kb/list-types.ts +0 -102
  300. package/.framework/mcp/tools/kb/publish.ts +0 -100
  301. package/.framework/mcp/tools/kb/reindex.ts +0 -86
  302. package/.framework/mcp/tools/kb/revoke.ts +0 -85
  303. package/.framework/mcp/tools/kb/search.ts +0 -101
  304. package/.framework/mcp/tools/kb/upsert.ts +0 -125
  305. package/.framework/mcp/tools/observability.ts +0 -510
@@ -0,0 +1,154 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readFileSync, readdirSync } from 'fs'
3
+ import { join } from 'path'
4
+
5
+ // Regression guard for the SECURITY DEFINER RPC exposure fixed in migration
6
+ // 075. Postgres grants EXECUTE on new functions to PUBLIC by default, and
7
+ // Supabase's anon/authenticated roles inherit it — so every definer function
8
+ // ships callable at /rest/v1/rpc/* with the anon key unless the migration
9
+ // explicitly revokes it.
10
+ //
11
+ // This lint statically scans non-archived migrations and asserts that every
12
+ // SECURITY DEFINER function either:
13
+ // a) is an allowlisted RLS helper (policies evaluate it as the querying
14
+ // role, so anon/authenticated MUST keep EXECUTE), or
15
+ // b) is revoked from PUBLIC/anon/authenticated in some migration
16
+ // (literal REVOKE statement or an entry in a revoke-list DO block), or
17
+ // c) was dropped.
18
+ // It also asserts every definer function pins search_path — in its own body
19
+ // or via a later ALTER FUNCTION — to close schema-shadowing on privileged code.
20
+
21
+ const MIGRATIONS_DIR = join(__dirname, '../../migrations')
22
+
23
+ // anon/authenticated must keep EXECUTE on these: every tenant RLS policy
24
+ // evaluates them as the querying role. Revoking them would break isolation
25
+ // schema-wide — get_accessible_accounts is pinned internally instead (075).
26
+ const EXEC_ALLOWLIST = new Set([
27
+ 'current_actor_id',
28
+ 'get_accessible_accounts',
29
+ ])
30
+
31
+ function migrationFiles(): string[] {
32
+ return readdirSync(MIGRATIONS_DIR)
33
+ .filter((f) => /^\d+_.*\.sql$/.test(f))
34
+ .sort()
35
+ }
36
+
37
+ interface DefinerDef {
38
+ name: string
39
+ file: string
40
+ hasSearchPath: boolean
41
+ }
42
+
43
+ // Find CREATE [OR REPLACE] FUNCTION public.name(...) blocks and keep those
44
+ // declared SECURITY DEFINER.
45
+ function findDefinerFunctions(file: string, content: string): DefinerDef[] {
46
+ const out: DefinerDef[] = []
47
+ const re = /CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+public\.(\w+)\s*\(/gi
48
+ let m: RegExpExecArray | null
49
+ while ((m = re.exec(content))) {
50
+ // A function body never spans another top-level CREATE; bound the slice.
51
+ const rest = content.slice(m.index)
52
+ const endMatch = rest.slice(1).search(/CREATE\s+(?:OR\s+REPLACE\s+)?(?:FUNCTION|TRIGGER|POLICY|UNIQUE|INDEX)|CREATE\s+(?:TABLE|POLICY|TRIGGER|INDEX)/i)
53
+ const body = endMatch === -1 ? rest : rest.slice(0, endMatch + 1)
54
+ if (/SECURITY\s+DEFINER/i.test(body)) {
55
+ out.push({
56
+ name: m[1],
57
+ file,
58
+ hasSearchPath: /search_path\s*(?:=|TO)\s*'?public'?/i.test(body),
59
+ })
60
+ }
61
+ }
62
+ return out
63
+ }
64
+
65
+ // Names that appear in any literal REVOKE ... ON FUNCTION public.name(...),
66
+ // any DROP FUNCTION public.name, any ALTER FUNCTION public.name SET
67
+ // search_path, or any DO-block that performs those via format()/EXECUTE with
68
+ // signature entries like 'name(arg,arg)'.
69
+ function collectCovered(
70
+ files: string[],
71
+ contents: Map<string, string>,
72
+ ): { revoked: Set<string>; dropped: Set<string>; pathed: Set<string> } {
73
+ const revoked = new Set<string>()
74
+ const dropped = new Set<string>()
75
+ const pathed = new Set<string>()
76
+
77
+ for (const file of files) {
78
+ const content = contents.get(file)!
79
+
80
+ for (const m of content.matchAll(/REVOKE\s+\w+\s+ON\s+FUNCTION\s+public\.(\w+)\s*\(/gi)) {
81
+ revoked.add(m[1])
82
+ }
83
+ for (const m of content.matchAll(/DROP\s+FUNCTION\s+(?:IF\s+EXISTS\s+)?public\.(\w+)\s*\(/gi)) {
84
+ dropped.add(m[1])
85
+ }
86
+ for (const m of content.matchAll(/ALTER\s+FUNCTION\s+public\.(\w+)\s*\([^)]*\)\s*SET\s+search_path/gi)) {
87
+ pathed.add(m[1])
88
+ }
89
+
90
+ // DO $$ ... $$ blocks that EXECUTE grant-management statements against a
91
+ // signature list: the signature literals inside the block name the covered
92
+ // functions (075 pattern).
93
+ for (const block of content.matchAll(/DO\s+\$\$[\s\S]*?END\s+\$\$;/gi)) {
94
+ const b = block[0]
95
+ const doesRevoke = /REVOKE/i.test(b)
96
+ const doesPath = /ALTER\s+FUNCTION[\s\S]*search_path|search_path[\s\S]*ALTER\s+FUNCTION/i.test(b)
97
+ if (!doesRevoke && !doesPath) continue
98
+ for (const m of b.matchAll(/'([a-z_]+)\(([^']*)\)'/g)) {
99
+ if (doesRevoke) revoked.add(m[1])
100
+ if (doesPath) pathed.add(m[1])
101
+ }
102
+ }
103
+ }
104
+
105
+ return { revoked, dropped, pathed }
106
+ }
107
+
108
+ describe('SECURITY DEFINER grant hygiene', () => {
109
+ const files = migrationFiles()
110
+ const contents = new Map(files.map((f) => [f, readFileSync(join(MIGRATIONS_DIR, f), 'utf8')]))
111
+ const { revoked, dropped, pathed } = collectCovered(files, contents)
112
+
113
+ const definers: DefinerDef[] = files.flatMap((f) => findDefinerFunctions(f, contents.get(f)!))
114
+ const uniqueNames = [...new Set(definers.map((d) => d.name))]
115
+
116
+ it('finds the definer functions we know about', () => {
117
+ expect(uniqueNames).toEqual(
118
+ expect.arrayContaining([
119
+ 'match_embeddings',
120
+ 'get_account_apps',
121
+ 'get_accessible_accounts',
122
+ 'current_actor_id',
123
+ 'rebuild_kb_cache',
124
+ 'revoke_embeddings_for_item',
125
+ 'cleanup_old_logs',
126
+ ]),
127
+ )
128
+ })
129
+
130
+ it('every definer function is revoked, dropped, or an allowlisted RLS helper', () => {
131
+ const exposed = uniqueNames.filter(
132
+ (n) => !EXEC_ALLOWLIST.has(n) && !revoked.has(n) && !dropped.has(n),
133
+ )
134
+ expect(
135
+ exposed,
136
+ `SECURITY DEFINER functions still executable by PUBLIC/anon/authenticated — ` +
137
+ `add "REVOKE ALL ON FUNCTION public.<sig> FROM PUBLIC, anon, authenticated" ` +
138
+ `to a migration, or extend the EXEC_ALLOWLIST if it is an RLS helper:\n ${exposed.join('\n ')}`,
139
+ ).toEqual([])
140
+ })
141
+
142
+ it('every definer function pins search_path', () => {
143
+ const missing = uniqueNames.filter((n) => {
144
+ const defs = definers.filter((d) => d.name === n)
145
+ const anyDefHasPath = defs.some((d) => d.hasSearchPath)
146
+ return !anyDefHasPath && !pathed.has(n)
147
+ })
148
+ expect(
149
+ missing,
150
+ `SECURITY DEFINER functions without a pinned search_path — ` +
151
+ `add SET search_path = public to the definition or ALTER FUNCTION:\n ${missing.join('\n ')}`,
152
+ ).toEqual([])
153
+ })
154
+ })
@@ -0,0 +1,145 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+
3
+ // F-051: requireSystemContextWithAudit's ctx overload used to return a
4
+ // Netlify response object on denial; handlers returned it as `result`, so
5
+ // createHandler wrapped it as {data:{statusCode:403}} under HTTP 200. It now
6
+ // throws a statusCode-carrying error which the catch path maps to a real
7
+ // HTTP status. These tests exercise the real createHandler end-to-end for
8
+ // security.ts and tests.ts.
9
+
10
+ const principalRef = vi.hoisted(() => ({ principal: null as any }))
11
+
12
+ vi.mock('../../functions/_shared/principal.ts', () => ({
13
+ resolvePrincipal: vi.fn(async () => principalRef.principal),
14
+ isSystemAdmin: (p: any) =>
15
+ !!p?.roles?.some((r: any) => r === 'system_admin' || r?.slug === 'system_admin'),
16
+ getPrincipalDb: () => ({}),
17
+ }))
18
+
19
+ vi.mock('../../functions/_shared/db.ts', () => ({
20
+ adminDb: {
21
+ from: () => {
22
+ const builder: any = {}
23
+ for (const m of [
24
+ 'select', 'eq', 'in', 'is', 'ilike', 'filter', 'not', 'order', 'limit',
25
+ 'update', 'insert', 'upsert', 'delete', 'neq', 'or', 'contains', 'gte',
26
+ 'lte', 'lt', 'range', 'textSearch',
27
+ ]) {
28
+ builder[m] = () => builder
29
+ }
30
+ builder.single = () => Promise.resolve({ data: null, error: null })
31
+ builder.maybeSingle = () => Promise.resolve({ data: null, error: null })
32
+ builder.then = (res: any, rej: any) =>
33
+ Promise.resolve({ data: [], error: null }).then(res, rej)
34
+ return builder
35
+ },
36
+ rpc: vi.fn(async () => ({ data: null, error: null })),
37
+ auth: { admin: {} },
38
+ },
39
+ getUserDb: vi.fn(() => ({})),
40
+ }))
41
+
42
+ vi.mock('../../functions/_shared/audit.ts', () => ({
43
+ emitAudit: vi.fn(async () => {}),
44
+ emitSpan: vi.fn(async () => {}),
45
+ emitLog: vi.fn(async () => {}),
46
+ emitActivity: vi.fn(async () => {}),
47
+ }))
48
+
49
+ vi.mock('../../functions/_shared/security.ts', () => ({
50
+ isBlockedRequest: vi.fn(async () => null),
51
+ isBlockedIp: vi.fn(async () => false),
52
+ touchPersonGeo: vi.fn(async () => {}),
53
+ }))
54
+
55
+ vi.mock('../../functions/_shared/geo.ts', () => ({
56
+ getClientGeo: vi.fn(() => ({ ip: '127.0.0.1' })),
57
+ }))
58
+
59
+ import { requireSystemContextWithAudit } from '../../functions/_shared/middleware'
60
+ import { handler as securityHandler } from '../../functions/security'
61
+ import { handler as testsHandler } from '../../functions/tests'
62
+
63
+ const memberPrincipal = {
64
+ id: 'p-member',
65
+ type: 'human',
66
+ accountId: 'acct-1',
67
+ roles: ['member'],
68
+ provenance: { sourceType: 'jwt', createdBy: null, invokedAt: new Date().toISOString() },
69
+ }
70
+
71
+ const adminPrincipal = {
72
+ ...memberPrincipal,
73
+ id: 'p-admin',
74
+ roles: ['system_admin'],
75
+ }
76
+
77
+ const event = (queryStringParameters: Record<string, string> = {}) => ({
78
+ headers: {},
79
+ httpMethod: 'GET',
80
+ path: '/api/x',
81
+ queryStringParameters,
82
+ body: null,
83
+ })
84
+
85
+ describe('requireSystemContextWithAudit ctx overload (F-051)', () => {
86
+ it('throws a statusCode 403 error for a non-admin principal', () => {
87
+ const ctx = { principal: memberPrincipal, accountId: 'acct-1' }
88
+ try {
89
+ requireSystemContextWithAudit(ctx as any)
90
+ expect.unreachable('should have thrown')
91
+ } catch (err: any) {
92
+ expect(err).toBeInstanceOf(Error)
93
+ expect(err.statusCode).toBe(403)
94
+ expect(err.message).toBe('System context required')
95
+ }
96
+ })
97
+
98
+ it('throws a statusCode 401 error for an anonymous principal', () => {
99
+ const ctx = { principal: { id: 'anonymous', type: 'machine' }, accountId: null }
100
+ try {
101
+ requireSystemContextWithAudit(ctx as any)
102
+ expect.unreachable('should have thrown')
103
+ } catch (err: any) {
104
+ expect(err.statusCode).toBe(401)
105
+ expect(err.message).toBe('Authentication required')
106
+ }
107
+ })
108
+
109
+ it('returns null and stamps triggeredBy for a system_admin principal', () => {
110
+ const ctx: any = { principal: { ...adminPrincipal }, accountId: 'acct-1' }
111
+ expect(requireSystemContextWithAudit(ctx, 'suite')).toBeNull()
112
+ expect(ctx.triggeredBy).toBe('suite')
113
+ })
114
+ })
115
+
116
+ describe('denied responses are real HTTP errors (F-051)', () => {
117
+ it('tests.ts returns HTTP 403 — not a 200-wrapped response object', async () => {
118
+ principalRef.principal = memberPrincipal
119
+ const res = await testsHandler(event({ action: 'list' }), {})
120
+ expect(res.statusCode).toBe(403)
121
+ const body = JSON.parse(res.body)
122
+ expect(body.error).toBe('System context required')
123
+ expect(body.data).toBeNull()
124
+ // Regression shape: previously {statusCode:200, body:{data:{statusCode:403}}}
125
+ expect(body.data?.statusCode).toBeUndefined()
126
+ })
127
+
128
+ it('security.ts returns HTTP 403 — not a 200-wrapped response object', async () => {
129
+ principalRef.principal = memberPrincipal
130
+ const res = await securityHandler(event({ action: 'blocklist' }), {})
131
+ expect(res.statusCode).toBe(403)
132
+ const body = JSON.parse(res.body)
133
+ expect(body.error).toBe('System context required')
134
+ expect(body.data).toBeNull()
135
+ })
136
+
137
+ it('tests.ts still serves a system_admin principal with HTTP 200', async () => {
138
+ principalRef.principal = adminPrincipal
139
+ const res = await testsHandler(event({ action: 'list' }), {})
140
+ expect(res.statusCode).toBe(200)
141
+ const body = JSON.parse(res.body)
142
+ expect(body.error).toBeNull()
143
+ expect(body.data.data).toEqual([])
144
+ })
145
+ })
@@ -0,0 +1,240 @@
1
+ // Tests for system-cron's per-tick maintenance blocks (F-052).
2
+ // Regression: when get_due_schedules returns zero rows the handler used to
3
+ // return early, skipping due timers, threshold alerts, delivery retries,
4
+ // and log cleanup. These tests exercise the handler with all shared deps
5
+ // mocked; createHandler is stubbed to identity so a fabricated
6
+ // RequestContext flows straight into the handler body.
7
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
8
+
9
+ const {
10
+ rpcMock,
11
+ fromMock,
12
+ emitAuditMock,
13
+ runPipelineMock,
14
+ sweepMock,
15
+ retryMock,
16
+ } = vi.hoisted(() => ({
17
+ rpcMock: vi.fn(),
18
+ fromMock: vi.fn(),
19
+ emitAuditMock: vi.fn(),
20
+ runPipelineMock: vi.fn(),
21
+ sweepMock: vi.fn(),
22
+ retryMock: vi.fn(),
23
+ }))
24
+
25
+ vi.mock('../../functions/_shared/middleware.ts', () => ({
26
+ createHandler: (fn: any) => fn,
27
+ }))
28
+
29
+ vi.mock('../../functions/_shared/db.ts', () => ({
30
+ adminDb: { rpc: rpcMock, from: fromMock },
31
+ }))
32
+
33
+ vi.mock('../../functions/_shared/audit.ts', () => ({
34
+ emitAudit: emitAuditMock,
35
+ }))
36
+
37
+ vi.mock('../../functions/_shared/pipeline-runner.ts', () => ({
38
+ runPipeline: runPipelineMock,
39
+ sweepStaleExecutions: sweepMock,
40
+ }))
41
+
42
+ vi.mock('../../functions/_shared/retry-deliveries.ts', () => ({
43
+ retryPendingDeliveries: retryMock,
44
+ }))
45
+
46
+ import { handler } from '../../functions/system-cron.ts'
47
+
48
+ function makeCtx() {
49
+ return {
50
+ requestId: 'test-req',
51
+ principal: {
52
+ id: 'system',
53
+ type: 'machine',
54
+ machineType: 'internal',
55
+ accountId: null,
56
+ scopes: ['*:*'],
57
+ isInternal: true,
58
+ },
59
+ db: {},
60
+ accountId: null,
61
+ appId: null,
62
+ query: {},
63
+ requestPath: '/.netlify/functions/system-cron',
64
+ headers: {},
65
+ }
66
+ }
67
+
68
+ // Per-RPC-name responses; anything not listed resolves to an empty list.
69
+ function rpcImpl(overrides: Record<string, any> = {}) {
70
+ return (name: string) => {
71
+ if (name in overrides) {
72
+ const v = overrides[name]
73
+ return v instanceof Error ? Promise.reject(v) : Promise.resolve(v)
74
+ }
75
+ return Promise.resolve({ data: [], error: null })
76
+ }
77
+ }
78
+
79
+ // Chainable thenable query builder; terminal result is keyed by table name.
80
+ function fromImpl(tableData: Record<string, any> = {}) {
81
+ return (table: string) => {
82
+ const result = table in tableData ? tableData[table] : { data: [], error: null }
83
+ const chain: any = {}
84
+ for (const m of ['select', 'eq', 'neq', 'lt', 'gt', 'or', 'in', 'order', 'limit', 'insert', 'update', 'single', 'maybeSingle']) {
85
+ chain[m] = vi.fn(() => chain)
86
+ }
87
+ chain.then = (onF: any, onR: any) => Promise.resolve(result).then(onF, onR)
88
+ return chain
89
+ }
90
+ }
91
+
92
+ const RETRY_SUMMARY = {
93
+ outbound_retried: 0,
94
+ outbound_succeeded: 0,
95
+ outbound_failed: 0,
96
+ inbound_retried: 0,
97
+ inbound_succeeded: 0,
98
+ inbound_failed: 0,
99
+ errors: [],
100
+ }
101
+
102
+ describe('system-cron', () => {
103
+ beforeEach(() => {
104
+ vi.clearAllMocks()
105
+ sweepMock.mockResolvedValue(undefined)
106
+ retryMock.mockResolvedValue(RETRY_SUMMARY)
107
+ })
108
+
109
+ it('runs per-tick maintenance when no schedules are due', async () => {
110
+ rpcMock.mockImplementation(rpcImpl({
111
+ get_due_schedules: { data: [], error: null },
112
+ get_due_timers: { data: [], error: null },
113
+ }))
114
+ fromMock.mockImplementation(fromImpl({
115
+ threshold_alerts: { data: [], error: null },
116
+ }))
117
+
118
+ const res = await handler(makeCtx())
119
+ expect(res.statusCode).toBe(200)
120
+
121
+ // Every per-tick block still ran despite the empty schedule list.
122
+ expect(sweepMock).toHaveBeenCalledOnce()
123
+ expect(rpcMock).toHaveBeenCalledWith('get_due_timers', expect.anything())
124
+ expect(fromMock).toHaveBeenCalledWith('threshold_alerts')
125
+ expect(retryMock).toHaveBeenCalledOnce()
126
+
127
+ const body = JSON.parse(res.body)
128
+ expect(body.schedules_due).toBe(0)
129
+ expect(body.executed).toBe(0)
130
+ expect(body.results).toEqual([])
131
+ expect(body.thresholds_evaluated).toBe(0)
132
+ expect(body.deliveries_retried).toEqual(RETRY_SUMMARY)
133
+ })
134
+
135
+ it('executes due timers on a tick with no due schedules', async () => {
136
+ const timer = {
137
+ timer_id: 't1',
138
+ pipeline_id: 'p1',
139
+ account_id: 'a1',
140
+ name: 'nightly',
141
+ execution_count: 3,
142
+ created_by: 'u1',
143
+ app_id: null,
144
+ }
145
+ rpcMock.mockImplementation(rpcImpl({
146
+ get_due_schedules: { data: [], error: null },
147
+ get_due_timers: { data: [timer], error: null },
148
+ update_timer_after_run: { data: null, error: null },
149
+ }))
150
+ fromMock.mockImplementation(fromImpl({
151
+ threshold_alerts: { data: [], error: null },
152
+ }))
153
+ runPipelineMock.mockResolvedValue({ status: 'completed' })
154
+
155
+ const res = await handler(makeCtx())
156
+ expect(res.statusCode).toBe(200)
157
+
158
+ expect(runPipelineMock).toHaveBeenCalledWith(
159
+ 'p1',
160
+ expect.objectContaining({ timer_id: 't1' }),
161
+ expect.objectContaining({ accountId: 'a1' }),
162
+ )
163
+ expect(rpcMock).toHaveBeenCalledWith('update_timer_after_run',
164
+ expect.objectContaining({ p_timer_id: 't1', p_success: true }))
165
+
166
+ const body = JSON.parse(res.body)
167
+ expect(body.schedules_due).toBe(0)
168
+ expect(body.executed).toBe(1)
169
+ expect(body.success).toBe(1)
170
+ expect(body.results[0].scheduleId).toBe('t1')
171
+ expect(retryMock).toHaveBeenCalledOnce()
172
+ })
173
+
174
+ it('still reaches delivery retries when the timer block throws', async () => {
175
+ rpcMock.mockImplementation(rpcImpl({
176
+ get_due_schedules: { data: [], error: null },
177
+ get_due_timers: new Error('rpc down'),
178
+ }))
179
+ fromMock.mockImplementation(fromImpl({
180
+ threshold_alerts: { data: [], error: null },
181
+ }))
182
+
183
+ const res = await handler(makeCtx())
184
+ expect(res.statusCode).toBe(200)
185
+ expect(retryMock).toHaveBeenCalledOnce()
186
+ })
187
+
188
+ it('still processes due schedules and then runs maintenance', async () => {
189
+ const schedule = {
190
+ schedule_id: 's1',
191
+ action_id: 'act1',
192
+ account_id: 'a1',
193
+ machine_principal_id: 'm1',
194
+ delegated_scopes: ['*:*'],
195
+ config: {},
196
+ }
197
+ const action = {
198
+ id: 'act1',
199
+ handler: 'send_email',
200
+ handler_module: 'functions',
201
+ config: {},
202
+ required_scopes: ['*:*'],
203
+ }
204
+ const machine = {
205
+ id: 'm1',
206
+ account_id: 'a1',
207
+ scopes: ['*:*'],
208
+ machine_type: 'internal',
209
+ is_internal: true,
210
+ created_by: 'u1',
211
+ }
212
+
213
+ rpcMock.mockImplementation(rpcImpl({
214
+ get_due_schedules: { data: [schedule], error: null },
215
+ get_due_timers: { data: [], error: null },
216
+ validate_schedule_creator: { data: { is_valid: true }, error: null },
217
+ update_schedule_after_run: { data: null, error: null },
218
+ }))
219
+ fromMock.mockImplementation(fromImpl({
220
+ actions: { data: action, error: null },
221
+ api_keys: { data: machine, error: null },
222
+ threshold_alerts: { data: [], error: null },
223
+ schedule_executions: { data: null, error: null },
224
+ }))
225
+
226
+ const res = await handler(makeCtx())
227
+ expect(res.statusCode).toBe(200)
228
+
229
+ const body = JSON.parse(res.body)
230
+ expect(body.schedules_due).toBe(1)
231
+ expect(body.executed).toBe(1)
232
+ expect(body.success).toBe(1)
233
+ expect(body.results[0].scheduleId).toBe('s1')
234
+
235
+ // Maintenance blocks still ran after schedule processing.
236
+ expect(rpcMock).toHaveBeenCalledWith('get_due_timers', expect.anything())
237
+ expect(fromMock).toHaveBeenCalledWith('threshold_alerts')
238
+ expect(retryMock).toHaveBeenCalledOnce()
239
+ })
240
+ })
@@ -0,0 +1,159 @@
1
+ // Tests for F-056: timers created/activated via the API must be armed
2
+ // (next_execution set) or they can never fire — get_due_timers requires
3
+ // next_execution IS NOT NULL and system-cron is the only consumer.
4
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
5
+
6
+ const { fromMock } = vi.hoisted(() => ({ fromMock: vi.fn() }))
7
+
8
+ vi.mock('../../functions/_shared/db.ts', () => ({
9
+ adminDb: { from: fromMock, rpc: vi.fn() },
10
+ joins: {}
11
+ }))
12
+
13
+ vi.mock('../../functions/_shared/audit.ts', () => ({
14
+ emitLog: vi.fn().mockResolvedValue(undefined),
15
+ emitAudit: vi.fn().mockResolvedValue(undefined),
16
+ emitSpan: vi.fn().mockResolvedValue(undefined)
17
+ }))
18
+
19
+ vi.mock('../../functions/_shared/permissions.ts', () => ({
20
+ sanitizeRecordData: vi.fn((_ctx: any, record: any) => Promise.resolve(record))
21
+ }))
22
+
23
+ import { create, update, toggle } from '../../functions/timers.ts'
24
+
25
+ // Chainable stand-in for the supabase-js query builder. Every call on a
26
+ // table gets its own builder via a results queue on `db.from`, and all
27
+ // insert/update payloads are recorded in `captured`.
28
+ function makeBuilder(result: any, captured: Array<{ method: string; args: any[] }>) {
29
+ const b: any = {}
30
+ for (const m of ['select', 'insert', 'update', 'delete', 'eq', 'order']) {
31
+ b[m] = vi.fn((...args: any[]) => { captured.push({ method: m, args }); return b })
32
+ }
33
+ b.single = vi.fn(() => Promise.resolve(result))
34
+ b.maybeSingle = vi.fn(() => Promise.resolve(result))
35
+ b.then = (onFulfilled: any, onRejected?: any) => Promise.resolve(result).then(onFulfilled, onRejected)
36
+ return b
37
+ }
38
+
39
+ let captured: Array<{ method: string; args: any[] }>
40
+ let fromResults: any[]
41
+
42
+ function queueResult(result: any) {
43
+ fromResults.push(result)
44
+ }
45
+
46
+ const ctx: any = {
47
+ requestId: 'req-timer-test',
48
+ principal: { id: 'person-1', type: 'person', accountId: 'acct-1', roles: [{ slug: 'system_admin' }] },
49
+ accountId: 'acct-1',
50
+ appId: null,
51
+ db: {},
52
+ query: {}
53
+ }
54
+
55
+ beforeEach(() => {
56
+ captured = []
57
+ fromResults = []
58
+ fromMock.mockReset()
59
+ fromMock.mockImplementation(() =>
60
+ makeBuilder(fromResults.shift() ?? { data: { id: 'timer-1' }, error: null }, captured))
61
+ })
62
+
63
+ function insertPayload() {
64
+ return captured.find(c => c.method === 'insert')?.args[0]
65
+ }
66
+ function updatePayload() {
67
+ return captured.find(c => c.method === 'update')?.args[0]
68
+ }
69
+ const MIN = 60_000
70
+
71
+ describe('timers.create — F-056 arming', () => {
72
+ it('arms next_execution from config.interval_minutes', async () => {
73
+ const before = Date.now()
74
+ await create(ctx, { name: 'n', timer_type: 'interval', config: { interval_minutes: 15 } })
75
+ const ts = Date.parse(insertPayload().next_execution)
76
+ expect(ts).toBeGreaterThanOrEqual(before + 15 * MIN)
77
+ expect(ts).toBeLessThanOrEqual(Date.now() + 15 * MIN + 1000)
78
+ })
79
+
80
+ it('arms next_execution from legacy config.interval_seconds', async () => {
81
+ const before = Date.now()
82
+ await create(ctx, { name: 'n', timer_type: 'interval', config: { interval_seconds: 3600 } })
83
+ const ts = Date.parse(insertPayload().next_execution)
84
+ expect(ts).toBeGreaterThanOrEqual(before + 60 * MIN)
85
+ expect(ts).toBeLessThanOrEqual(Date.now() + 60 * MIN + 1000)
86
+ })
87
+
88
+ it('lets an explicit next_execution win over the computed value', async () => {
89
+ const explicit = '2030-01-01T00:00:00.000Z'
90
+ await create(ctx, { name: 'n', timer_type: 'interval', config: { interval_minutes: 5 }, next_execution: explicit })
91
+ expect(insertPayload().next_execution).toBe(explicit)
92
+ })
93
+
94
+ it('arms a once timer from config.run_at', async () => {
95
+ const runAt = '2030-06-15T12:00:00.000Z'
96
+ await create(ctx, { name: 'n', timer_type: 'once', config: { run_at: runAt } })
97
+ expect(insertPayload().next_execution).toBe(runAt)
98
+ })
99
+
100
+ it('leaves next_execution null when the timer is created inactive', async () => {
101
+ await create(ctx, { name: 'n', timer_type: 'interval', is_active: false, config: { interval_minutes: 15 } })
102
+ expect(insertPayload().next_execution).toBeNull()
103
+ expect(insertPayload().is_active).toBe(false)
104
+ })
105
+
106
+ it('leaves next_execution null when no schedule is computable', async () => {
107
+ await create(ctx, { name: 'n', timer_type: 'cron', config: { schedule: '*/5 * * * *' } })
108
+ expect(insertPayload().next_execution).toBeNull()
109
+ })
110
+ })
111
+
112
+ describe('timers.update — F-056 arming', () => {
113
+ it('re-arms next_execution when config changes', async () => {
114
+ queueResult({ data: { config: { interval_minutes: 60 }, is_active: true }, error: null })
115
+ const before = Date.now()
116
+ await update(ctx, { id: 't1', config: { interval_minutes: 10 } })
117
+ const ts = Date.parse(updatePayload().next_execution)
118
+ expect(ts).toBeGreaterThanOrEqual(before + 10 * MIN)
119
+ expect(ts).toBeLessThanOrEqual(Date.now() + 10 * MIN + 1000)
120
+ })
121
+
122
+ it('arms next_execution when an inactive timer is activated via update', async () => {
123
+ queueResult({ data: { config: { interval_seconds: 600 }, is_active: false }, error: null })
124
+ const before = Date.now()
125
+ await update(ctx, { id: 't1', is_active: true })
126
+ const ts = Date.parse(updatePayload().next_execution)
127
+ expect(ts).toBeGreaterThanOrEqual(before + 10 * MIN)
128
+ expect(ts).toBeLessThanOrEqual(Date.now() + 10 * MIN + 1000)
129
+ })
130
+
131
+ it('passes through an explicit next_execution', async () => {
132
+ const explicit = '2031-03-03T03:03:03.000Z'
133
+ await update(ctx, { id: 't1', next_execution: explicit })
134
+ expect(updatePayload().next_execution).toBe(explicit)
135
+ })
136
+
137
+ it('does not touch next_execution for unrelated field updates', async () => {
138
+ await update(ctx, { id: 't1', name: 'renamed' })
139
+ expect(updatePayload().next_execution).toBeUndefined()
140
+ })
141
+ })
142
+
143
+ describe('timers.toggle — F-056 arming', () => {
144
+ it('arms next_execution when activating a never-scheduled timer', async () => {
145
+ queueResult({ data: { is_active: false, name: 'n', config: { interval_minutes: 30 }, next_execution: null }, error: null })
146
+ const before = Date.now()
147
+ await toggle(ctx, { id: 't1' })
148
+ const ts = Date.parse(updatePayload().next_execution)
149
+ expect(ts).toBeGreaterThanOrEqual(before + 30 * MIN)
150
+ expect(ts).toBeLessThanOrEqual(Date.now() + 30 * MIN + 1000)
151
+ })
152
+
153
+ it('does not clear next_execution when deactivating', async () => {
154
+ queueResult({ data: { is_active: true, name: 'n', config: { interval_minutes: 30 }, next_execution: '2030-01-01T00:00:00.000Z' }, error: null })
155
+ await toggle(ctx, { id: 't1' })
156
+ expect(updatePayload().is_active).toBe(false)
157
+ expect(updatePayload().next_execution).toBeUndefined()
158
+ })
159
+ })