spine-framework 0.4.4 → 0.5.0-rc.4

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 (283) 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/migrate.ts +36 -30
  8. package/.framework/cli/commands/migrations.ts +16 -56
  9. package/.framework/cli/commands/upgrade.ts +35 -0
  10. package/.framework/cli/commands/validate-schema.ts +147 -0
  11. package/.framework/cli/context.ts +223 -110
  12. package/.framework/cli/index.ts +6 -0
  13. package/.framework/cli/lib/project-files.ts +241 -1
  14. package/.framework/functions/_shared/hash-api-key.ts +183 -79
  15. package/.framework/functions/_shared/logging.ts +562 -224
  16. package/.framework/functions/_shared/middleware.ts +47 -44
  17. package/.framework/functions/admin-data.ts +39 -28
  18. package/.framework/functions/ai-agents.ts +10 -6
  19. package/.framework/functions/api-keys.ts +301 -122
  20. package/.framework/functions/apps.ts +43 -26
  21. package/.framework/functions/cil-ingest.ts +48 -15
  22. package/.framework/functions/debug-auth.ts +14 -8
  23. package/.framework/functions/deliveries.ts +39 -12
  24. package/.framework/functions/embeddings.ts +145 -76
  25. package/.framework/functions/integration-outbound.ts +67 -27
  26. package/.framework/functions/integrations.ts +254 -127
  27. package/.framework/functions/logs.ts +273 -133
  28. package/.framework/functions/observability.ts +277 -147
  29. package/.framework/functions/pipeline-executions.ts +1 -1
  30. package/.framework/functions/pipelines.ts +14 -9
  31. package/.framework/functions/prompt-configs.ts +7 -4
  32. package/.framework/functions/roles.ts +8 -5
  33. package/.framework/functions/security.ts +30 -17
  34. package/.framework/functions/system-cron.ts +240 -127
  35. package/.framework/functions/tests.ts +7 -6
  36. package/.framework/functions/timers.ts +310 -107
  37. package/.framework/functions/triggers.ts +28 -13
  38. package/.framework/functions/types.ts +13 -10
  39. package/.framework/migrations/069_log_stats_window.sql +54 -0
  40. package/.framework/migrations/070_app_type_schema_rpcs.sql +166 -0
  41. package/.framework/migrations/071_timer_interval_keys.sql +96 -0
  42. package/.framework/migrations/072_slug_autofill.sql +77 -0
  43. package/.framework/migrations/073_seed_member_roles.sql +26 -0
  44. package/.framework/src/apps/admin/index.tsx +1141 -775
  45. package/.framework/src/components/ErrorBoundary.tsx +136 -0
  46. package/.framework/src/components/admin/AdminListPage.tsx +81 -73
  47. package/.framework/src/components/admin/AdminSidebar.tsx +88 -79
  48. package/.framework/src/components/admin/EntitySelect.tsx +181 -0
  49. package/.framework/src/components/admin/PageInstructions.tsx +86 -127
  50. package/.framework/src/components/app-shell/GenericDetailPage.tsx +37 -13
  51. package/.framework/src/components/app-shell/GenericListPage.tsx +28 -32
  52. package/.framework/src/components/layout/Sidebar.tsx +300 -177
  53. package/.framework/src/components/runtime/DataDetailPage.tsx +67 -35
  54. package/.framework/src/components/runtime/DataHeader.tsx +96 -48
  55. package/.framework/src/components/runtime/DataListPage.tsx +3 -26
  56. package/.framework/src/components/runtime/DataStats.tsx +10 -10
  57. package/.framework/src/components/runtime/DataTable.tsx +12 -10
  58. package/.framework/src/components/ui/sheet.tsx +24 -26
  59. package/.framework/src/components/ui/textarea.tsx +74 -48
  60. package/.framework/src/hooks/useEntityList.ts +12 -9
  61. package/.framework/src/hooks/useListSchema.ts +108 -58
  62. package/.framework/src/instructions/README.md +57 -0
  63. package/.framework/src/instructions/ai-agents.md +69 -0
  64. package/.framework/src/instructions/alerts.md +80 -0
  65. package/.framework/src/instructions/api-keys.md +87 -0
  66. package/.framework/src/instructions/apps.md +77 -0
  67. package/.framework/src/instructions/concepts/ai-and-prompts.md +150 -0
  68. package/.framework/src/instructions/concepts/apps-and-renderers.md +162 -0
  69. package/.framework/src/instructions/concepts/auth-and-api-keys.md +133 -0
  70. package/.framework/src/instructions/concepts/automation.md +186 -0
  71. package/.framework/src/instructions/concepts/concurrency-and-idempotency.md +125 -0
  72. package/.framework/src/instructions/concepts/core-vs-custom.md +124 -0
  73. package/.framework/src/instructions/concepts/design-schema.md +152 -0
  74. package/.framework/src/instructions/concepts/integrations-and-webhooks.md +127 -0
  75. package/.framework/src/instructions/concepts/observability.md +116 -0
  76. package/.framework/src/instructions/concepts/permissions.md +150 -0
  77. package/.framework/src/instructions/concepts/runtime-records.md +111 -0
  78. package/.framework/src/instructions/concepts/scope-and-accounts.md +112 -0
  79. package/.framework/src/instructions/concepts/spine-way.md +172 -0
  80. package/.framework/src/instructions/deliveries.md +59 -0
  81. package/.framework/src/instructions/guide/00-orientation.md +106 -0
  82. package/.framework/src/instructions/guide/01-install-and-first-run.md +253 -0
  83. package/.framework/src/instructions/guide/02-project-map.md +147 -0
  84. package/.framework/src/instructions/guide/03-domain-model.md +223 -0
  85. package/.framework/src/instructions/guide/04-first-app.md +227 -0
  86. package/.framework/src/instructions/guide/05-glossary.md +183 -0
  87. package/.framework/src/instructions/integrations.md +96 -0
  88. package/.framework/src/instructions/internal/cil-markers.md +83 -0
  89. package/.framework/src/instructions/invites.md +60 -0
  90. package/.framework/src/instructions/kb.md +80 -0
  91. package/.framework/src/instructions/logs.md +77 -0
  92. package/.framework/src/instructions/observability.md +61 -0
  93. package/.framework/src/instructions/operations/deployment.md +115 -0
  94. package/.framework/src/instructions/operations/migrations.md +125 -0
  95. package/.framework/src/instructions/operations/security-checklist.md +148 -0
  96. package/.framework/src/instructions/operations/troubleshooting.md +170 -0
  97. package/.framework/src/instructions/operations/upgrade.md +116 -0
  98. package/.framework/src/instructions/pipeline-executions.md +62 -0
  99. package/.framework/src/instructions/pipelines.md +78 -0
  100. package/.framework/src/instructions/prompts.md +95 -0
  101. package/.framework/src/instructions/reference/api/admin-data.md +162 -0
  102. package/.framework/src/instructions/reference/api/auth.md +113 -0
  103. package/.framework/src/instructions/reference/api/errors-and-envelopes.md +113 -0
  104. package/.framework/src/instructions/reference/app-manifest.md +150 -0
  105. package/.framework/src/instructions/reference/cli.md +124 -0
  106. package/.framework/src/instructions/reference/database.md +132 -0
  107. package/.framework/src/instructions/reference/design-schema.md +280 -0
  108. package/.framework/src/instructions/reference/env-vars.md +133 -0
  109. package/.framework/src/instructions/reference/functions/account-nodes.md +75 -0
  110. package/.framework/src/instructions/reference/functions/admin-data.md +195 -0
  111. package/.framework/src/instructions/reference/functions/admin-funnel.md +90 -0
  112. package/.framework/src/instructions/reference/functions/admin-install-funnel.md +107 -0
  113. package/.framework/src/instructions/reference/functions/admin-signals.md +102 -0
  114. package/.framework/src/instructions/reference/functions/ai-agents.md +148 -0
  115. package/.framework/src/instructions/reference/functions/ai-interactions.md +156 -0
  116. package/.framework/src/instructions/reference/functions/api-keys.md +130 -0
  117. package/.framework/src/instructions/reference/functions/apps.md +140 -0
  118. package/.framework/src/instructions/reference/functions/auth.md +99 -0
  119. package/.framework/src/instructions/reference/functions/debug-auth.md +91 -0
  120. package/.framework/src/instructions/reference/functions/deliveries.md +110 -0
  121. package/.framework/src/instructions/reference/functions/embeddings.md +166 -0
  122. package/.framework/src/instructions/reference/functions/eval-harness.md +149 -0
  123. package/.framework/src/instructions/reference/functions/index.md +103 -0
  124. package/.framework/src/instructions/reference/functions/integration-outbound.md +200 -0
  125. package/.framework/src/instructions/reference/functions/integration-routes.md +151 -0
  126. package/.framework/src/instructions/reference/functions/integrations.md +130 -0
  127. package/.framework/src/instructions/reference/functions/invites.md +182 -0
  128. package/.framework/src/instructions/reference/functions/item-progress.md +107 -0
  129. package/.framework/src/instructions/reference/functions/kb.md +166 -0
  130. package/.framework/src/instructions/reference/functions/logs.md +141 -0
  131. package/.framework/src/instructions/reference/functions/observability.md +105 -0
  132. package/.framework/src/instructions/reference/functions/pipeline-executions.md +132 -0
  133. package/.framework/src/instructions/reference/functions/pipeline-runner-background.md +121 -0
  134. package/.framework/src/instructions/reference/functions/pipelines.md +136 -0
  135. package/.framework/src/instructions/reference/functions/prompt-configs.md +121 -0
  136. package/.framework/src/instructions/reference/functions/roles.md +121 -0
  137. package/.framework/src/instructions/reference/functions/security.md +140 -0
  138. package/.framework/src/instructions/reference/functions/system-cron.md +136 -0
  139. package/.framework/src/instructions/reference/functions/system.md +106 -0
  140. package/.framework/src/instructions/reference/functions/tests.md +97 -0
  141. package/.framework/src/instructions/reference/functions/timers.md +117 -0
  142. package/.framework/src/instructions/reference/functions/trigger-scheduler.md +102 -0
  143. package/.framework/src/instructions/reference/functions/triggers.md +140 -0
  144. package/.framework/src/instructions/reference/functions/types.md +146 -0
  145. package/.framework/src/instructions/reference/seeds.md +126 -0
  146. package/.framework/src/instructions/reference/shared-modules.md +127 -0
  147. package/.framework/src/instructions/reference/spine-config.md +114 -0
  148. package/.framework/src/instructions/roles.md +80 -0
  149. package/.framework/src/instructions/runtime-detail.md +70 -0
  150. package/.framework/src/instructions/runtime-list.md +77 -0
  151. package/.framework/src/instructions/security.md +67 -0
  152. package/.framework/src/instructions/tasks/add-a-pipeline.md +110 -0
  153. package/.framework/src/instructions/tasks/add-a-role.md +96 -0
  154. package/.framework/src/instructions/tasks/add-a-timer.md +114 -0
  155. package/.framework/src/instructions/tasks/add-a-trigger.md +106 -0
  156. package/.framework/src/instructions/tasks/add-a-type.md +151 -0
  157. package/.framework/src/instructions/tasks/add-a-webhook.md +108 -0
  158. package/.framework/src/instructions/tasks/add-an-ai-agent.md +108 -0
  159. package/.framework/src/instructions/tasks/add-an-integration.md +124 -0
  160. package/.framework/src/instructions/tasks/build-a-custom-page.md +118 -0
  161. package/.framework/src/instructions/tasks/package-and-install-an-app.md +127 -0
  162. package/.framework/src/instructions/tasks/test-your-code.md +108 -0
  163. package/.framework/src/instructions/tasks/write-a-function.md +132 -0
  164. package/.framework/src/instructions/tasks/write-a-migration.md +110 -0
  165. package/.framework/src/instructions/testing.md +59 -0
  166. package/.framework/src/instructions/timers.md +82 -0
  167. package/.framework/src/instructions/triggers.md +69 -0
  168. package/.framework/src/instructions/types.md +93 -0
  169. package/.framework/src/pages/NotFoundPage.tsx +62 -31
  170. package/.framework/src/pages/admin/AIAgentDetailPage.tsx +117 -106
  171. package/.framework/src/pages/admin/AIAgentsPage.tsx +86 -48
  172. package/.framework/src/pages/admin/APIKeyDetailPage.tsx +153 -152
  173. package/.framework/src/pages/admin/APIKeysPage.tsx +91 -90
  174. package/.framework/src/pages/admin/AlertsConfigPage.tsx +29 -18
  175. package/.framework/src/pages/admin/AppDetailPage.tsx +6 -60
  176. package/.framework/src/pages/admin/AppsPage.tsx +104 -66
  177. package/.framework/src/pages/admin/DeliveriesPage.tsx +5 -5
  178. package/.framework/src/pages/admin/EmbeddingDetailPage.tsx +5 -23
  179. package/.framework/src/pages/admin/EmbeddingsPage.tsx +5 -26
  180. package/.framework/src/pages/admin/IntegrationDetailPage.tsx +7 -7
  181. package/.framework/src/pages/admin/IntegrationsPage.tsx +85 -43
  182. package/.framework/src/pages/admin/LogsPage.tsx +144 -76
  183. package/.framework/src/pages/admin/ObservabilityDashboard.tsx +223 -140
  184. package/.framework/src/pages/admin/PipelineDetailPage.tsx +134 -113
  185. package/.framework/src/pages/admin/PipelineExecutionsPage.tsx +108 -45
  186. package/.framework/src/pages/admin/PipelinesPage.tsx +160 -115
  187. package/.framework/src/pages/admin/PromptConfigDetailPage.tsx +96 -98
  188. package/.framework/src/pages/admin/PromptConfigsPage.tsx +101 -59
  189. package/.framework/src/pages/admin/RoleDetailPage.tsx +90 -79
  190. package/.framework/src/pages/admin/RolesPage.tsx +86 -43
  191. package/.framework/src/pages/admin/SecurityPage.tsx +1 -1
  192. package/.framework/src/pages/admin/TestingDashboard.tsx +16 -28
  193. package/.framework/src/pages/admin/TimerDetailPage.tsx +117 -100
  194. package/.framework/src/pages/admin/TimersPage.tsx +90 -62
  195. package/.framework/src/pages/admin/TriggerDetailPage.tsx +90 -83
  196. package/.framework/src/pages/admin/TriggersPage.tsx +124 -99
  197. package/.framework/src/pages/admin/TypeDetailPage.tsx +152 -138
  198. package/.framework/src/pages/admin/TypesPage.tsx +90 -48
  199. package/.framework/src/pages/admin/cil/CILAgentPage.tsx +17 -25
  200. package/.framework/src/pages/admin/cil/CILChunkDetailPage.tsx +17 -27
  201. package/.framework/src/pages/admin/cil/CILDashboardPage.tsx +72 -40
  202. package/.framework/src/pages/admin/cil/CILFileListPage.tsx +321 -129
  203. package/.framework/src/pages/admin/cil/CILFileSelectPage.tsx +18 -29
  204. package/.framework/src/pages/admin/cil/CILGraphPage.tsx +26 -38
  205. package/.framework/src/pages/admin/cil/CILIngestionPage.tsx +34 -46
  206. package/.framework/src/pages/admin/cil/CILProjectsPage.tsx +189 -103
  207. package/.framework/src/pages/admin/cil/CILSearchPage.tsx +246 -136
  208. package/.framework/src/pages/admin/cil/CILSuggestionsPage.tsx +11 -22
  209. package/.framework/src/pages/admin/cil/CILVersionsPage.tsx +9 -20
  210. package/.framework/src/pages/admin/cil/FlexiblePipelinePage.tsx +26 -38
  211. package/.framework/src/pages/admin/kb/KBAdminPage.tsx +7 -26
  212. package/.framework/templates/agent/AGENTS.md +150 -0
  213. package/.framework/templates/agent/skills/spine-add-ai-agent/SKILL.md +58 -0
  214. package/.framework/templates/agent/skills/spine-add-automation/SKILL.md +62 -0
  215. package/.framework/templates/agent/skills/spine-add-integration/SKILL.md +63 -0
  216. package/.framework/templates/agent/skills/spine-architect/SKILL.md +86 -0
  217. package/.framework/templates/agent/skills/spine-install-app/SKILL.md +72 -0
  218. package/.framework/templates/agent/skills/spine-migration/SKILL.md +75 -0
  219. package/.framework/templates/agent/skills/spine-new-app/SKILL.md +67 -0
  220. package/.framework/templates/agent/skills/spine-new-function/SKILL.md +65 -0
  221. package/.framework/templates/agent/skills/spine-new-type/SKILL.md +65 -0
  222. package/.framework/templates/agent/skills/spine-schema/SKILL.md +77 -0
  223. package/.framework/templates/agent/skills/spine-test/SKILL.md +67 -0
  224. package/.framework/templates/agent/skills/spine-troubleshoot/SKILL.md +74 -0
  225. package/.framework/templates/agent/skills/spine-upgrade/SKILL.md +69 -0
  226. package/.framework/templates/project.json +4 -1
  227. package/.framework/tests/unit/account-scoping-gates.test.ts +392 -0
  228. package/.framework/tests/unit/agent-files.test.ts +355 -0
  229. package/.framework/tests/unit/agent-runner.test.ts +479 -44
  230. package/.framework/tests/unit/api-keys-create-scope-gate.test.ts +160 -0
  231. package/.framework/tests/unit/api-keys-omit-secret-fields.test.ts +38 -0
  232. package/.framework/tests/unit/bootstrap.test.ts +242 -0
  233. package/.framework/tests/unit/cli-context-api-key.test.ts +114 -0
  234. package/.framework/tests/unit/debug-auth.test.ts +38 -0
  235. package/.framework/tests/unit/deliveries-account-scoping.test.ts +185 -0
  236. package/.framework/tests/unit/integration-outbound-credentials.test.ts +167 -0
  237. package/.framework/tests/unit/integrations-audit-redaction.test.ts +130 -0
  238. package/.framework/tests/unit/logging-app-id.test.ts +75 -0
  239. package/.framework/tests/unit/system-context-denial.test.ts +145 -0
  240. package/.framework/tests/unit/system-cron.test.ts +240 -0
  241. package/.framework/tests/unit/timers.test.ts +159 -0
  242. package/README.md +125 -166
  243. package/config/tsconfig.cli.json +2 -2
  244. package/package.json +15 -4
  245. package/templates/project/package-lock.json +2 -2
  246. package/.framework/docs/admin-routes.md +0 -76
  247. package/.framework/docs/api-docs-progress.md +0 -38
  248. package/.framework/docs/api-governance.md +0 -146
  249. package/.framework/docs/api-testing-results.md +0 -212
  250. package/.framework/docs/assembly-launch-guide.md +0 -138
  251. package/.framework/docs/audit-results.md +0 -590
  252. package/.framework/docs/db-api-inventory.md +0 -95
  253. package/.framework/docs/gap-remediation-backlog.md +0 -103
  254. package/.framework/docs/ui-adoption-verification.md +0 -111
  255. package/.framework/docs/ui-api-coverage.md +0 -84
  256. package/.framework/docs/v2-compatibility-audit.md +0 -228
  257. package/.framework/mcp/index.ts +0 -133
  258. package/.framework/mcp/load-env.ts +0 -60
  259. package/.framework/mcp/shared/app-manifest.ts +0 -275
  260. package/.framework/mcp/shared/db.ts +0 -477
  261. package/.framework/mcp/shared/kb.ts +0 -118
  262. package/.framework/mcp/tools/architect.ts +0 -431
  263. package/.framework/mcp/tools/cil/context.ts +0 -345
  264. package/.framework/mcp/tools/cil/graph.ts +0 -242
  265. package/.framework/mcp/tools/cil/ingest.ts +0 -1217
  266. package/.framework/mcp/tools/cil/pipeline-orchestrator.ts +0 -693
  267. package/.framework/mcp/tools/cil/pipeline.ts +0 -767
  268. package/.framework/mcp/tools/cil/project.ts +0 -791
  269. package/.framework/mcp/tools/cil/retrieval.ts +0 -336
  270. package/.framework/mcp/tools/cil/search-core.ts +0 -454
  271. package/.framework/mcp/tools/cil/search.ts +0 -97
  272. package/.framework/mcp/tools/cil/suggest.ts +0 -186
  273. package/.framework/mcp/tools/cil/validate.ts +0 -1369
  274. package/.framework/mcp/tools/kb/delete.ts +0 -139
  275. package/.framework/mcp/tools/kb/index.ts +0 -52
  276. package/.framework/mcp/tools/kb/ingest.ts +0 -84
  277. package/.framework/mcp/tools/kb/list-types.ts +0 -102
  278. package/.framework/mcp/tools/kb/publish.ts +0 -100
  279. package/.framework/mcp/tools/kb/reindex.ts +0 -86
  280. package/.framework/mcp/tools/kb/revoke.ts +0 -85
  281. package/.framework/mcp/tools/kb/search.ts +0 -101
  282. package/.framework/mcp/tools/kb/upsert.ts +0 -125
  283. package/.framework/mcp/tools/observability.ts +0 -510
@@ -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
+ })