@lamalibre/create-lamaste 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (270) hide show
  1. package/LICENSE.md +118 -0
  2. package/README.md +114 -0
  3. package/bin/create-lamaste.js +15 -0
  4. package/package.json +54 -0
  5. package/scripts/bundle-vendor.js +110 -0
  6. package/scripts/lamaste-cert-info +62 -0
  7. package/scripts/lamaste-certbot +96 -0
  8. package/scripts/lamaste-priv +693 -0
  9. package/src/index.js +646 -0
  10. package/src/lib/accounts.js +86 -0
  11. package/src/lib/binaries.js +137 -0
  12. package/src/lib/cert-help-page.js +160 -0
  13. package/src/lib/env.js +132 -0
  14. package/src/lib/http-redirect.js +38 -0
  15. package/src/lib/ownership.js +231 -0
  16. package/src/lib/relay-services.js +103 -0
  17. package/src/lib/secrets.js +19 -0
  18. package/src/lib/service-config.js +311 -0
  19. package/src/lib/summary.js +131 -0
  20. package/src/lib/wrappers.js +63 -0
  21. package/src/tasks/gatekeeper.js +233 -0
  22. package/src/tasks/harden.js +293 -0
  23. package/src/tasks/mtls.js +193 -0
  24. package/src/tasks/nginx.js +265 -0
  25. package/src/tasks/node.js +110 -0
  26. package/src/tasks/panel.js +408 -0
  27. package/src/tasks/redeploy.js +438 -0
  28. package/vendor/docs/00-introduction/how-it-works.md +447 -0
  29. package/vendor/docs/00-introduction/quickstart.md +472 -0
  30. package/vendor/docs/00-introduction/what-is-lamaste.md +112 -0
  31. package/vendor/docs/01-concepts/authentication.md +464 -0
  32. package/vendor/docs/01-concepts/authorization.md +242 -0
  33. package/vendor/docs/01-concepts/certificates.md +452 -0
  34. package/vendor/docs/01-concepts/dns-and-domains.md +343 -0
  35. package/vendor/docs/01-concepts/mtls.md +490 -0
  36. package/vendor/docs/01-concepts/nginx-reverse-proxy.md +540 -0
  37. package/vendor/docs/01-concepts/security-model.md +600 -0
  38. package/vendor/docs/01-concepts/tickets.md +414 -0
  39. package/vendor/docs/01-concepts/tunneling.md +367 -0
  40. package/vendor/docs/02-guides/agent-setup.md +194 -0
  41. package/vendor/docs/02-guides/certificate-management.md +341 -0
  42. package/vendor/docs/02-guides/cloud-provisioning.md +406 -0
  43. package/vendor/docs/02-guides/desktop-app-setup.md +226 -0
  44. package/vendor/docs/02-guides/disaster-recovery.md +464 -0
  45. package/vendor/docs/02-guides/first-tunnel.md +230 -0
  46. package/vendor/docs/02-guides/installation.md +393 -0
  47. package/vendor/docs/02-guides/managing-users.md +255 -0
  48. package/vendor/docs/02-guides/onboarding.md +244 -0
  49. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +375 -0
  50. package/vendor/docs/02-guides/static-sites.md +364 -0
  51. package/vendor/docs/03-architecture/e2e-three-vm-sequences.md +457 -0
  52. package/vendor/docs/03-architecture/installer-flow.md +151 -0
  53. package/vendor/docs/03-architecture/installer.md +445 -0
  54. package/vendor/docs/03-architecture/management-flow.md +288 -0
  55. package/vendor/docs/03-architecture/nginx-configuration.md +830 -0
  56. package/vendor/docs/03-architecture/onboarding-flow.md +164 -0
  57. package/vendor/docs/03-architecture/overview.md +541 -0
  58. package/vendor/docs/03-architecture/panel-client.md +416 -0
  59. package/vendor/docs/03-architecture/panel-server.md +695 -0
  60. package/vendor/docs/03-architecture/state-management.md +529 -0
  61. package/vendor/docs/03-architecture/system-overview.md +167 -0
  62. package/vendor/docs/04-api-reference/certificates.md +1141 -0
  63. package/vendor/docs/04-api-reference/gatekeeper.md +592 -0
  64. package/vendor/docs/04-api-reference/onboarding.md +473 -0
  65. package/vendor/docs/04-api-reference/overview.md +404 -0
  66. package/vendor/docs/04-api-reference/services.md +267 -0
  67. package/vendor/docs/04-api-reference/settings.md +343 -0
  68. package/vendor/docs/04-api-reference/sites.md +716 -0
  69. package/vendor/docs/04-api-reference/system.md +268 -0
  70. package/vendor/docs/04-api-reference/tickets.md +618 -0
  71. package/vendor/docs/04-api-reference/tunnels.md +417 -0
  72. package/vendor/docs/04-api-reference/users.md +333 -0
  73. package/vendor/docs/05-operations/backup-and-restore.md +342 -0
  74. package/vendor/docs/05-operations/monitoring.md +285 -0
  75. package/vendor/docs/05-operations/uninstalling.md +344 -0
  76. package/vendor/docs/05-operations/upgrades.md +270 -0
  77. package/vendor/docs/06-reference/config-files.md +1080 -0
  78. package/vendor/docs/06-reference/glossary.md +528 -0
  79. package/vendor/docs/06-reference/installer-flags.md +260 -0
  80. package/vendor/docs/06-reference/ports-and-services.md +244 -0
  81. package/vendor/docs/06-reference/troubleshooting.md +665 -0
  82. package/vendor/docs/_index.json +233 -0
  83. package/vendor/docs/gatekeeper-design.md +953 -0
  84. package/vendor/docs/package.json +15 -0
  85. package/vendor/docs/todo/lib-cli-desktop-refactor.md +641 -0
  86. package/vendor/gatekeeper/dist/boot.d.ts +1 -0
  87. package/vendor/gatekeeper/dist/boot.d.ts.map +1 -0
  88. package/vendor/gatekeeper/dist/boot.js +17 -0
  89. package/vendor/gatekeeper/dist/boot.js.map +1 -0
  90. package/vendor/gatekeeper/dist/cli/commands/access.d.ts +2 -0
  91. package/vendor/gatekeeper/dist/cli/commands/access.d.ts.map +1 -0
  92. package/vendor/gatekeeper/dist/cli/commands/access.js +33 -0
  93. package/vendor/gatekeeper/dist/cli/commands/access.js.map +1 -0
  94. package/vendor/gatekeeper/dist/cli/commands/grants.d.ts +2 -0
  95. package/vendor/gatekeeper/dist/cli/commands/grants.d.ts.map +1 -0
  96. package/vendor/gatekeeper/dist/cli/commands/grants.js +110 -0
  97. package/vendor/gatekeeper/dist/cli/commands/grants.js.map +1 -0
  98. package/vendor/gatekeeper/dist/cli/commands/groups.d.ts +2 -0
  99. package/vendor/gatekeeper/dist/cli/commands/groups.d.ts.map +1 -0
  100. package/vendor/gatekeeper/dist/cli/commands/groups.js +98 -0
  101. package/vendor/gatekeeper/dist/cli/commands/groups.js.map +1 -0
  102. package/vendor/gatekeeper/dist/cli/index.d.ts +3 -0
  103. package/vendor/gatekeeper/dist/cli/index.d.ts.map +1 -0
  104. package/vendor/gatekeeper/dist/cli/index.js +45 -0
  105. package/vendor/gatekeeper/dist/cli/index.js.map +1 -0
  106. package/vendor/gatekeeper/dist/index.d.ts +9 -0
  107. package/vendor/gatekeeper/dist/index.d.ts.map +1 -0
  108. package/vendor/gatekeeper/dist/index.js +13 -0
  109. package/vendor/gatekeeper/dist/index.js.map +1 -0
  110. package/vendor/gatekeeper/dist/lib/authz.d.ts +16 -0
  111. package/vendor/gatekeeper/dist/lib/authz.d.ts.map +1 -0
  112. package/vendor/gatekeeper/dist/lib/authz.js +42 -0
  113. package/vendor/gatekeeper/dist/lib/authz.js.map +1 -0
  114. package/vendor/gatekeeper/dist/lib/cache-bust.d.ts +15 -0
  115. package/vendor/gatekeeper/dist/lib/cache-bust.d.ts.map +1 -0
  116. package/vendor/gatekeeper/dist/lib/cache-bust.js +19 -0
  117. package/vendor/gatekeeper/dist/lib/cache-bust.js.map +1 -0
  118. package/vendor/gatekeeper/dist/lib/constants.d.ts +82 -0
  119. package/vendor/gatekeeper/dist/lib/constants.d.ts.map +1 -0
  120. package/vendor/gatekeeper/dist/lib/constants.js +79 -0
  121. package/vendor/gatekeeper/dist/lib/constants.js.map +1 -0
  122. package/vendor/gatekeeper/dist/lib/grants.d.ts +32 -0
  123. package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -0
  124. package/vendor/gatekeeper/dist/lib/grants.js +314 -0
  125. package/vendor/gatekeeper/dist/lib/grants.js.map +1 -0
  126. package/vendor/gatekeeper/dist/lib/groups.d.ts +10 -0
  127. package/vendor/gatekeeper/dist/lib/groups.d.ts.map +1 -0
  128. package/vendor/gatekeeper/dist/lib/groups.js +247 -0
  129. package/vendor/gatekeeper/dist/lib/groups.js.map +1 -0
  130. package/vendor/gatekeeper/dist/lib/migration.d.ts +17 -0
  131. package/vendor/gatekeeper/dist/lib/migration.d.ts.map +1 -0
  132. package/vendor/gatekeeper/dist/lib/migration.js +79 -0
  133. package/vendor/gatekeeper/dist/lib/migration.js.map +1 -0
  134. package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0001_groups.sql +7 -0
  135. package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0002_access_grants.sql +14 -0
  136. package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0003_access_request_log.sql +9 -0
  137. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0001_groups.sql +7 -0
  138. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0002_access_grants.sql +14 -0
  139. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0003_access_request_log.sql +9 -0
  140. package/vendor/gatekeeper/dist/lib/state-db.d.ts +16 -0
  141. package/vendor/gatekeeper/dist/lib/state-db.d.ts.map +1 -0
  142. package/vendor/gatekeeper/dist/lib/state-db.js +154 -0
  143. package/vendor/gatekeeper/dist/lib/state-db.js.map +1 -0
  144. package/vendor/gatekeeper/dist/lib/templates.d.ts +21 -0
  145. package/vendor/gatekeeper/dist/lib/templates.d.ts.map +1 -0
  146. package/vendor/gatekeeper/dist/lib/templates.js +151 -0
  147. package/vendor/gatekeeper/dist/lib/templates.js.map +1 -0
  148. package/vendor/gatekeeper/dist/lib/types.d.ts +178 -0
  149. package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -0
  150. package/vendor/gatekeeper/dist/lib/types.js +2 -0
  151. package/vendor/gatekeeper/dist/lib/types.js.map +1 -0
  152. package/vendor/gatekeeper/dist/server/index.d.ts +15 -0
  153. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -0
  154. package/vendor/gatekeeper/dist/server/index.js +251 -0
  155. package/vendor/gatekeeper/dist/server/index.js.map +1 -0
  156. package/vendor/gatekeeper/dist/server/routes/access-request.d.ts +7 -0
  157. package/vendor/gatekeeper/dist/server/routes/access-request.d.ts.map +1 -0
  158. package/vendor/gatekeeper/dist/server/routes/access-request.js +17 -0
  159. package/vendor/gatekeeper/dist/server/routes/access-request.js.map +1 -0
  160. package/vendor/gatekeeper/dist/server/routes/authz.d.ts +3 -0
  161. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -0
  162. package/vendor/gatekeeper/dist/server/routes/authz.js +279 -0
  163. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -0
  164. package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts +13 -0
  165. package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -0
  166. package/vendor/gatekeeper/dist/server/routes/diagnostic.js +249 -0
  167. package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -0
  168. package/vendor/gatekeeper/dist/server/routes/grants.d.ts +3 -0
  169. package/vendor/gatekeeper/dist/server/routes/grants.d.ts.map +1 -0
  170. package/vendor/gatekeeper/dist/server/routes/grants.js +112 -0
  171. package/vendor/gatekeeper/dist/server/routes/grants.js.map +1 -0
  172. package/vendor/gatekeeper/dist/server/routes/groups.d.ts +3 -0
  173. package/vendor/gatekeeper/dist/server/routes/groups.d.ts.map +1 -0
  174. package/vendor/gatekeeper/dist/server/routes/groups.js +139 -0
  175. package/vendor/gatekeeper/dist/server/routes/groups.js.map +1 -0
  176. package/vendor/gatekeeper/package.json +49 -0
  177. package/vendor/server/bin/lamaste-server.js +204 -0
  178. package/vendor/server/package.json +31 -0
  179. package/vendor/server/src/commands/certs.js +224 -0
  180. package/vendor/server/src/commands/chisel.js +132 -0
  181. package/vendor/server/src/commands/logs.js +49 -0
  182. package/vendor/server/src/commands/plugins.js +321 -0
  183. package/vendor/server/src/commands/reset-admin.js +478 -0
  184. package/vendor/server/src/commands/restart.js +94 -0
  185. package/vendor/server/src/commands/sites.js +257 -0
  186. package/vendor/server/src/commands/status.js +158 -0
  187. package/vendor/server/src/commands/tunnels.js +365 -0
  188. package/vendor/server/src/commands/uninstall.js +326 -0
  189. package/vendor/server/src/config.js +38 -0
  190. package/vendor/server/src/exec.js +26 -0
  191. package/vendor/server/src/logger.js +39 -0
  192. package/vendor/server/src/ndjson.js +56 -0
  193. package/vendor/server/src/panel-api.js +85 -0
  194. package/vendor/server/src/state.js +45 -0
  195. package/vendor/server-ui/dist/assets/index-B0nWuUyL.js +257 -0
  196. package/vendor/server-ui/dist/assets/index-BqWWFlTU.css +1 -0
  197. package/vendor/server-ui/dist/index.html +13 -0
  198. package/vendor/serverd/package.json +44 -0
  199. package/vendor/serverd/src/boot.js +13 -0
  200. package/vendor/serverd/src/index.js +191 -0
  201. package/vendor/serverd/src/lib/app-error.js +14 -0
  202. package/vendor/serverd/src/lib/authelia.js +85 -0
  203. package/vendor/serverd/src/lib/certbot.js +74 -0
  204. package/vendor/serverd/src/lib/chisel-args.js +6 -0
  205. package/vendor/serverd/src/lib/chisel-reconcile.js +232 -0
  206. package/vendor/serverd/src/lib/chisel-runtime.js +46 -0
  207. package/vendor/serverd/src/lib/chisel-users.js +109 -0
  208. package/vendor/serverd/src/lib/chisel.js +81 -0
  209. package/vendor/serverd/src/lib/config.js +190 -0
  210. package/vendor/serverd/src/lib/constants.js +8 -0
  211. package/vendor/serverd/src/lib/csr-signing.js +331 -0
  212. package/vendor/serverd/src/lib/enrollment.js +302 -0
  213. package/vendor/serverd/src/lib/files.js +19 -0
  214. package/vendor/serverd/src/lib/gatekeeper-client.js +135 -0
  215. package/vendor/serverd/src/lib/invite-page.js +281 -0
  216. package/vendor/serverd/src/lib/ip-utils.js +79 -0
  217. package/vendor/serverd/src/lib/migrations/state/0001_enrollment_tokens.sql +17 -0
  218. package/vendor/serverd/src/lib/migrations/state/0002_tickets.sql +70 -0
  219. package/vendor/serverd/src/lib/migrations/state/0003_plugins.sql +15 -0
  220. package/vendor/serverd/src/lib/migrations/state/0004_agents.sql +19 -0
  221. package/vendor/serverd/src/lib/mtls.js +795 -0
  222. package/vendor/serverd/src/lib/nginx.js +1090 -0
  223. package/vendor/serverd/src/lib/pki-paths.js +41 -0
  224. package/vendor/serverd/src/lib/pki-sign.js +103 -0
  225. package/vendor/serverd/src/lib/plugins.js +538 -0
  226. package/vendor/serverd/src/lib/push-install.js +390 -0
  227. package/vendor/serverd/src/lib/revocation.js +112 -0
  228. package/vendor/serverd/src/lib/services.js +131 -0
  229. package/vendor/serverd/src/lib/session.js +151 -0
  230. package/vendor/serverd/src/lib/state-db.js +130 -0
  231. package/vendor/serverd/src/lib/state.js +140 -0
  232. package/vendor/serverd/src/lib/storage.js +63 -0
  233. package/vendor/serverd/src/lib/system-stats.js +58 -0
  234. package/vendor/serverd/src/lib/tickets.js +1556 -0
  235. package/vendor/serverd/src/lib/totp.js +76 -0
  236. package/vendor/serverd/src/lib/tunnel-deps.js +41 -0
  237. package/vendor/serverd/src/lib/user-access-session.js +142 -0
  238. package/vendor/serverd/src/lib/user-access.js +52 -0
  239. package/vendor/serverd/src/middleware/errors.js +63 -0
  240. package/vendor/serverd/src/middleware/mtls.js +120 -0
  241. package/vendor/serverd/src/middleware/onboarding-guard.js +30 -0
  242. package/vendor/serverd/src/middleware/role-guard.js +58 -0
  243. package/vendor/serverd/src/middleware/twofa-session.js +65 -0
  244. package/vendor/serverd/src/middleware/user-access-session.js +52 -0
  245. package/vendor/serverd/src/routes/enrollment.js +120 -0
  246. package/vendor/serverd/src/routes/health.js +22 -0
  247. package/vendor/serverd/src/routes/invite.js +163 -0
  248. package/vendor/serverd/src/routes/management/agents.js +182 -0
  249. package/vendor/serverd/src/routes/management/certs.js +911 -0
  250. package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +160 -0
  251. package/vendor/serverd/src/routes/management/identity.js +107 -0
  252. package/vendor/serverd/src/routes/management/invitations.js +166 -0
  253. package/vendor/serverd/src/routes/management/logs.js +218 -0
  254. package/vendor/serverd/src/routes/management/plugins.js +560 -0
  255. package/vendor/serverd/src/routes/management/services.js +63 -0
  256. package/vendor/serverd/src/routes/management/settings.js +325 -0
  257. package/vendor/serverd/src/routes/management/sites.js +522 -0
  258. package/vendor/serverd/src/routes/management/storage.js +192 -0
  259. package/vendor/serverd/src/routes/management/system.js +201 -0
  260. package/vendor/serverd/src/routes/management/tickets.js +457 -0
  261. package/vendor/serverd/src/routes/management/tunnels.js +709 -0
  262. package/vendor/serverd/src/routes/management/users.js +377 -0
  263. package/vendor/serverd/src/routes/management.js +36 -0
  264. package/vendor/serverd/src/routes/onboarding/dns.js +101 -0
  265. package/vendor/serverd/src/routes/onboarding/domain.js +42 -0
  266. package/vendor/serverd/src/routes/onboarding/index.js +18 -0
  267. package/vendor/serverd/src/routes/onboarding/provision.js +327 -0
  268. package/vendor/serverd/src/routes/onboarding/status.js +12 -0
  269. package/vendor/serverd/src/routes/plugin-router.js +79 -0
  270. package/vendor/serverd/src/routes/user-access.js +389 -0
@@ -0,0 +1,1556 @@
1
+ import crypto from 'node:crypto';
2
+ import { z } from 'zod';
3
+ import {
4
+ setTicketScopeCapabilitiesOnMtls,
5
+ loadAgentRegistry,
6
+ getValidCapabilities,
7
+ } from './mtls.js';
8
+ import { getPluginCapabilities } from './plugins.js';
9
+ import { RESERVED_API_PREFIXES } from './constants.js';
10
+ import { PLUGIN_CAPABILITY_REGEX } from '@lamalibre/lamaste';
11
+ import { getStateDb } from './state-db.js';
12
+
13
+ // Module-scoped logger used as the default for `validateTicket` when no
14
+ // per-request logger is passed. Routes still hand in their own request-scoped
15
+ // logger for normal operations.
16
+ let moduleLogger = {
17
+ warn: (..._args) => {},
18
+ info: (..._args) => {},
19
+ };
20
+
21
+ export function setTicketModuleLogger(logger) {
22
+ if (logger && typeof logger.warn === 'function') {
23
+ moduleLogger = logger;
24
+ }
25
+ }
26
+
27
+ // --- Failed-validation surveillance (per-process, resets on restart) ---
28
+ //
29
+ // Brute-force of ticket tokens is mathematically infeasible (256 bits), but
30
+ // we still want operators to see active enumeration attempts. Two tiers:
31
+ //
32
+ // 1. Cumulative counter, logged every FAILURE_LOG_INTERVAL failures.
33
+ // 2. 5-minute rolling rate window — sustained > THRESHOLD/min for
34
+ // RATE_BURST_MINUTES consecutive minutes triggers a one-time warn until
35
+ // the rate drops back below threshold.
36
+ //
37
+ // Defense-in-depth only: the panel still returns a generic 401 to the caller.
38
+ const FAILURE_LOG_INTERVAL = 50;
39
+ const RATE_THRESHOLD_PER_MIN = 10;
40
+ const RATE_BURST_MINUTES = 5;
41
+ const RATE_WINDOW_MS = RATE_BURST_MINUTES * 60 * 1000;
42
+ let ticketValidationFailureCount = 0;
43
+ const ticketValidationFailureTimestamps = [];
44
+ let ticketValidationBurstActive = false;
45
+
46
+ function recordTicketValidationFailure(logger, reason) {
47
+ ticketValidationFailureCount += 1;
48
+
49
+ if (ticketValidationFailureCount % FAILURE_LOG_INTERVAL === 0) {
50
+ logger.warn(
51
+ { count: ticketValidationFailureCount, reason },
52
+ 'ticket validation repeated failures — possible enumeration attack',
53
+ );
54
+ }
55
+
56
+ const now = Date.now();
57
+ const cutoff = now - RATE_WINDOW_MS;
58
+ ticketValidationFailureTimestamps.push(now);
59
+ while (
60
+ ticketValidationFailureTimestamps.length > 0 &&
61
+ ticketValidationFailureTimestamps[0] < cutoff
62
+ ) {
63
+ ticketValidationFailureTimestamps.shift();
64
+ }
65
+
66
+ // Sustained-burst escalation. Only fires once per "above threshold" episode
67
+ // — clears when the window drops back below threshold so a renewed burst
68
+ // can re-escalate without log spam in between.
69
+ const ratePerMinute = ticketValidationFailureTimestamps.length / RATE_BURST_MINUTES;
70
+ if (ratePerMinute >= RATE_THRESHOLD_PER_MIN) {
71
+ if (!ticketValidationBurstActive) {
72
+ ticketValidationBurstActive = true;
73
+ logger.warn(
74
+ {
75
+ rate: Number(ratePerMinute.toFixed(2)),
76
+ threshold: RATE_THRESHOLD_PER_MIN,
77
+ windowMinutes: RATE_BURST_MINUTES,
78
+ note: 'possible ticket enumeration attack',
79
+ },
80
+ 'ticket validation failure rate sustained above threshold',
81
+ );
82
+ }
83
+ } else if (ticketValidationBurstActive) {
84
+ ticketValidationBurstActive = false;
85
+ }
86
+ }
87
+
88
+ // --- Ticket expiry constants ---
89
+ const TICKET_EXPIRY_MS = 30 * 1000; // 30 seconds
90
+ const TICKET_CLEANUP_MS = 60 * 60 * 1000; // 1 hour
91
+ const INSTANCE_STALE_MS = 5 * 60 * 1000; // 5 minutes → stale
92
+ const INSTANCE_DEAD_MS = 60 * 60 * 1000; // 1 hour → dead
93
+ const SESSION_STALE_MS = 10 * 60 * 1000; // 10 minutes without activity → dead
94
+ const SESSION_CLEANUP_MS = 24 * 60 * 60 * 1000; // 24 hours
95
+
96
+ // --- Hard caps (DoS protection on 512MB droplets) ---
97
+ const MAX_INSTANCES = 200;
98
+ const MAX_TICKETS = 1000;
99
+ const MAX_SESSIONS = 500;
100
+
101
+ // --- Rate limiting ---
102
+ const TICKET_RATE_LIMIT = 10; // per agent per minute
103
+ const TICKET_RATE_WINDOW_MS = 60 * 1000;
104
+ const MAX_RATE_ENTRIES = 1000;
105
+ const ticketRateCounts = new Map();
106
+
107
+ // --- Promise-chain mutex ---
108
+ //
109
+ // SQLite's BEGIN IMMEDIATE serialises a single write transaction, but many
110
+ // exported operations are read-modify-write sequences spanning multiple
111
+ // statements. This lock keeps each such sequence atomic. Tracking as a
112
+ // follow-up: collapse each sequence into a single transaction, then drop
113
+ // the mutex.
114
+ let ticketLock = Promise.resolve();
115
+ function withTicketLock(fn) {
116
+ const prev = ticketLock;
117
+ let resolve;
118
+ ticketLock = new Promise((r) => {
119
+ resolve = r;
120
+ });
121
+ return prev.then(fn).finally(resolve);
122
+ }
123
+
124
+ // --- Zod schemas ---
125
+
126
+ const RESERVED_NAMES = RESERVED_API_PREFIXES;
127
+
128
+ export const TicketScopeNameSchema = z
129
+ .string()
130
+ .min(1)
131
+ .max(50)
132
+ .regex(/^[a-z0-9-]+$/, 'Must be lowercase alphanumeric with hyphens')
133
+ .refine((v) => !RESERVED_NAMES.includes(v), 'Name is reserved');
134
+
135
+ // Ticket scope sub-scope names share the plugin capability namespace
136
+ // (`plugin:<short-name>:<action>`) — this is the only top-level namespace
137
+ // any non-core code can declare. Forces sub-scopes to coexist cleanly with
138
+ // plugin-declared capabilities and makes "plugin owns its own scopes"
139
+ // the only mental model needed at call sites.
140
+ const CapabilityStringSchema = z
141
+ .string()
142
+ .min(1)
143
+ .max(100)
144
+ .regex(
145
+ PLUGIN_CAPABILITY_REGEX,
146
+ 'Must use the "plugin:<short-name>:<action>" namespace (lowercase alphanumeric with optional internal hyphens)',
147
+ );
148
+
149
+ const ScopeDeclarationSchema = z.object({
150
+ name: CapabilityStringSchema,
151
+ description: z.string().min(1).max(500),
152
+ instanceScoped: z.boolean(),
153
+ });
154
+
155
+ const TransportStrategySchema = z.enum(['tunnel', 'relay', 'direct']);
156
+
157
+ const TransportSchema = z
158
+ .object({
159
+ strategies: z.array(TransportStrategySchema).min(1),
160
+ preferred: TransportStrategySchema,
161
+ port: z
162
+ .number()
163
+ .int()
164
+ .refine((v) => v === 0 || (v >= 1024 && v <= 65535), 'Port must be 0 or 1024-65535'),
165
+ protocol: z.enum(['wss', 'tcp']),
166
+ })
167
+ .refine(
168
+ (t) => t.strategies.includes(t.preferred),
169
+ 'Preferred strategy must be in strategies array',
170
+ );
171
+
172
+ export const RegisterScopeSchema = z.object({
173
+ name: TicketScopeNameSchema,
174
+ version: z.string().min(1).max(50),
175
+ description: z.string().min(1).max(500),
176
+ scopes: z.array(ScopeDeclarationSchema).min(1).max(50),
177
+ transport: TransportSchema,
178
+ });
179
+
180
+ // Hostname/IP validation: reject private, loopback, link-local, and metadata IPs
181
+ const HostnameSchema = z
182
+ .string()
183
+ .min(1)
184
+ .max(255)
185
+ .refine(
186
+ (host) => {
187
+ // Block metadata endpoint (AWS/GCP/Azure)
188
+ if (host === '169.254.169.254' || host === 'metadata.google.internal') return false;
189
+ // Block loopback
190
+ if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return false;
191
+ // Block IPv4 private ranges and link-local
192
+ const ipv4Match = host.match(/^(\d{1,3})\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/);
193
+ if (ipv4Match) {
194
+ const [, a, b] = ipv4Match.map(Number);
195
+ if (a === 10) return false; // 10.0.0.0/8
196
+ if (a === 172 && b >= 16 && b <= 31) return false; // 172.16.0.0/12
197
+ if (a === 192 && b === 168) return false; // 192.168.0.0/16
198
+ if (a === 169 && b === 254) return false; // 169.254.0.0/16 link-local
199
+ if (a === 0) return false; // 0.0.0.0/8
200
+ }
201
+ return true;
202
+ },
203
+ { message: 'Host must be a public hostname or IP address' },
204
+ );
205
+
206
+ export const RegisterInstanceSchema = z.object({
207
+ scope: CapabilityStringSchema,
208
+ transport: z.object({
209
+ strategies: z.array(TransportStrategySchema).min(1),
210
+ preferred: TransportStrategySchema.optional(),
211
+ direct: z
212
+ .object({
213
+ host: HostnameSchema,
214
+ port: z.number().int().min(1024).max(65535),
215
+ })
216
+ .optional(),
217
+ }),
218
+ });
219
+
220
+ export const RequestTicketSchema = z.object({
221
+ scope: CapabilityStringSchema,
222
+ instanceId: z
223
+ .string()
224
+ .min(1)
225
+ .max(64)
226
+ .regex(/^[a-f0-9]+$/),
227
+ // max 150 to accommodate plugin-agent:<delegating>:<plugin> labels
228
+ target: z.string().min(1).max(150),
229
+ });
230
+
231
+ export const ValidateTicketSchema = z.object({
232
+ ticketId: z
233
+ .string()
234
+ .min(1)
235
+ .max(128)
236
+ .regex(/^[a-f0-9]+$/),
237
+ });
238
+
239
+ export const CreateSessionSchema = z.object({
240
+ ticketId: z
241
+ .string()
242
+ .min(1)
243
+ .max(128)
244
+ .regex(/^[a-f0-9]+$/),
245
+ });
246
+
247
+ export const UpdateSessionSchema = z.object({
248
+ status: z.enum(['active', 'grace']),
249
+ });
250
+
251
+ export const AssignmentSchema = z.object({
252
+ agentLabel: z.string().min(1).max(100),
253
+ instanceScope: z
254
+ .string()
255
+ .min(1)
256
+ .max(200)
257
+ .regex(/^plugin:[a-z0-9-]+:[a-z0-9-]+:[a-f0-9]+$/),
258
+ });
259
+
260
+ // --- SQLite prepared-statement bundle (lazy init) ---
261
+
262
+ let stmts = null;
263
+
264
+ async function getStmts() {
265
+ if (stmts) return stmts;
266
+
267
+ const db = await getStateDb();
268
+
269
+ stmts = {
270
+ db,
271
+
272
+ // Scope statements
273
+ selectAllScopes: db.prepare('SELECT * FROM ticket_scopes'),
274
+ selectScopeByName: db.prepare('SELECT * FROM ticket_scopes WHERE name = ?'),
275
+ insertScope: db.prepare(`
276
+ INSERT INTO ticket_scopes
277
+ (name, version, description, scopes, transport, hooks, installed_at)
278
+ VALUES (?, ?, ?, ?, ?, ?, ?)
279
+ `),
280
+ deleteScope: db.prepare('DELETE FROM ticket_scopes WHERE name = ?'),
281
+
282
+ // Instance statements
283
+ selectAllInstances: db.prepare('SELECT * FROM ticket_instances'),
284
+ selectInstanceById: db.prepare('SELECT * FROM ticket_instances WHERE instance_id = ?'),
285
+ selectInstanceByScopeAgent: db.prepare(
286
+ 'SELECT * FROM ticket_instances WHERE scope = ? AND agent_label = ?',
287
+ ),
288
+ countInstances: db.prepare('SELECT COUNT(*) AS n FROM ticket_instances'),
289
+ insertInstance: db.prepare(`
290
+ INSERT INTO ticket_instances
291
+ (instance_id, scope, agent_label, registered_at, last_heartbeat, status, transport)
292
+ VALUES (?, ?, ?, ?, ?, ?, ?)
293
+ `),
294
+ updateInstanceTransportHeartbeat: db.prepare(`
295
+ UPDATE ticket_instances
296
+ SET transport = ?, last_heartbeat = ?, status = 'active'
297
+ WHERE instance_id = ?
298
+ `),
299
+ updateInstanceHeartbeat: db.prepare(`
300
+ UPDATE ticket_instances
301
+ SET last_heartbeat = ?, status = 'active'
302
+ WHERE instance_id = ?
303
+ `),
304
+ updateInstanceStatus: db.prepare(`
305
+ UPDATE ticket_instances SET status = ? WHERE instance_id = ?
306
+ `),
307
+ deleteInstance: db.prepare('DELETE FROM ticket_instances WHERE instance_id = ?'),
308
+ deleteInstanceByStatus: db.prepare('DELETE FROM ticket_instances WHERE status = ?'),
309
+
310
+ // Assignment statements
311
+ selectAllAssignments: db.prepare('SELECT * FROM ticket_assignments'),
312
+ selectAssignment: db.prepare(
313
+ 'SELECT * FROM ticket_assignments WHERE agent_label = ? AND instance_scope = ?',
314
+ ),
315
+ insertAssignment: db.prepare(`
316
+ INSERT INTO ticket_assignments
317
+ (agent_label, instance_scope, assigned_at, assigned_by)
318
+ VALUES (?, ?, ?, ?)
319
+ `),
320
+ deleteAssignment: db.prepare(
321
+ 'DELETE FROM ticket_assignments WHERE agent_label = ? AND instance_scope = ?',
322
+ ),
323
+ deleteAssignmentsByInstanceScope: db.prepare(
324
+ 'DELETE FROM ticket_assignments WHERE instance_scope = ?',
325
+ ),
326
+ deleteAssignmentsByInstanceScopePrefix: db.prepare(
327
+ 'DELETE FROM ticket_assignments WHERE instance_scope LIKE ?',
328
+ ),
329
+
330
+ // Ticket statements
331
+ selectAllTickets: db.prepare('SELECT * FROM tickets'),
332
+ selectTicketByIndex: db.prepare('SELECT * FROM tickets WHERE index_hash = ?'),
333
+ selectTicketsByInstance: db.prepare('SELECT * FROM tickets WHERE instance_id = ?'),
334
+ selectTicketsByScope: db.prepare('SELECT * FROM tickets WHERE scope = ?'),
335
+ countTickets: db.prepare('SELECT COUNT(*) AS n FROM tickets'),
336
+ insertTicket: db.prepare(`
337
+ INSERT INTO tickets
338
+ (index_hash, id, scope, instance_id, source, target, created_at, expires_at, used, used_at, session_id, transport)
339
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
340
+ `),
341
+ markTicketUsed: db.prepare('UPDATE tickets SET used = 1, used_at = ? WHERE index_hash = ?'),
342
+ setTicketSession: db.prepare('UPDATE tickets SET session_id = ? WHERE index_hash = ?'),
343
+ deleteTicketsCreatedBefore: db.prepare('DELETE FROM tickets WHERE created_at <= ?'),
344
+
345
+ // Session statements
346
+ selectAllSessions: db.prepare('SELECT * FROM ticket_sessions'),
347
+ selectSessionById: db.prepare('SELECT * FROM ticket_sessions WHERE session_id = ?'),
348
+ selectSessionsByInstance: db.prepare('SELECT * FROM ticket_sessions WHERE instance_id = ?'),
349
+ countLiveSessions: db.prepare(
350
+ "SELECT COUNT(*) AS n FROM ticket_sessions WHERE status != 'dead'",
351
+ ),
352
+ insertSession: db.prepare(`
353
+ INSERT INTO ticket_sessions
354
+ (session_id, ticket_id, scope, instance_id, source, target, created_at, last_activity_at, status, reconnect_grace_seconds, terminated_by, terminated_at)
355
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
356
+ `),
357
+ updateSessionActivity: db.prepare(
358
+ 'UPDATE ticket_sessions SET last_activity_at = ? WHERE session_id = ?',
359
+ ),
360
+ updateSessionStatusActivity: db.prepare(
361
+ 'UPDATE ticket_sessions SET status = ?, last_activity_at = ? WHERE session_id = ?',
362
+ ),
363
+ killSessionStmt: db.prepare(`
364
+ UPDATE ticket_sessions
365
+ SET status = 'dead', terminated_by = ?, terminated_at = ?
366
+ WHERE session_id = ?
367
+ `),
368
+ deleteDeadOldSessions: db.prepare(
369
+ "DELETE FROM ticket_sessions WHERE status = 'dead' AND created_at <= ?",
370
+ ),
371
+
372
+ // Transaction control
373
+ begin: db.prepare('BEGIN IMMEDIATE'),
374
+ commit: db.prepare('COMMIT'),
375
+ rollback: db.prepare('ROLLBACK'),
376
+ };
377
+
378
+ return stmts;
379
+ }
380
+
381
+ // --- Row → JS object helpers (one per table) ---
382
+
383
+ function rowToScope(row) {
384
+ return {
385
+ name: row.name,
386
+ version: row.version,
387
+ description: row.description,
388
+ scopes: JSON.parse(row.scopes),
389
+ transport: JSON.parse(row.transport),
390
+ hooks: JSON.parse(row.hooks),
391
+ installedAt: row.installed_at,
392
+ };
393
+ }
394
+
395
+ function rowToInstance(row) {
396
+ return {
397
+ instanceId: row.instance_id,
398
+ scope: row.scope,
399
+ agentLabel: row.agent_label,
400
+ registeredAt: row.registered_at,
401
+ lastHeartbeat: row.last_heartbeat,
402
+ status: row.status,
403
+ transport: JSON.parse(row.transport),
404
+ };
405
+ }
406
+
407
+ function rowToAssignment(row) {
408
+ return {
409
+ agentLabel: row.agent_label,
410
+ instanceScope: row.instance_scope,
411
+ assignedAt: row.assigned_at,
412
+ assignedBy: row.assigned_by,
413
+ };
414
+ }
415
+
416
+ function rowToTicket(row) {
417
+ return {
418
+ id: row.id,
419
+ scope: row.scope,
420
+ instanceId: row.instance_id,
421
+ source: row.source,
422
+ target: row.target,
423
+ createdAt: row.created_at,
424
+ expiresAt: row.expires_at,
425
+ used: row.used === 1,
426
+ usedAt: row.used_at ?? null,
427
+ sessionId: row.session_id ?? null,
428
+ transport: JSON.parse(row.transport),
429
+ };
430
+ }
431
+
432
+ function rowToSession(row) {
433
+ const session = {
434
+ sessionId: row.session_id,
435
+ ticketId: row.ticket_id,
436
+ scope: row.scope,
437
+ instanceId: row.instance_id,
438
+ source: row.source,
439
+ target: row.target,
440
+ createdAt: row.created_at,
441
+ lastActivityAt: row.last_activity_at,
442
+ status: row.status,
443
+ reconnectGraceSeconds: row.reconnect_grace_seconds,
444
+ };
445
+ if (row.terminated_by) session.terminatedBy = row.terminated_by;
446
+ if (row.terminated_at) session.terminatedAt = row.terminated_at;
447
+ return session;
448
+ }
449
+
450
+ // --- Ticket index (hash of full token) ---
451
+ //
452
+ // Tickets are stored keyed by `sha256(ticketId)` (base64url) so validate /
453
+ // revoke / createSession lookups are O(1) and constant-cost regardless of
454
+ // how many tickets are live. The hash is purely an index — it is recomputed
455
+ // from the inbound token, so the persisted form leaks no usable secret.
456
+ function ticketIndex(ticketId) {
457
+ return crypto
458
+ .createHash('sha256')
459
+ .update(ticketId, 'utf-8')
460
+ .digest('base64')
461
+ .replace(/\+/g, '-')
462
+ .replace(/\//g, '_')
463
+ .replace(/=+$/, '');
464
+ }
465
+
466
+ function constantTimeStringEqual(a, b) {
467
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
468
+ if (a.length !== b.length) return false;
469
+ return crypto.timingSafeEqual(Buffer.from(a, 'utf-8'), Buffer.from(b, 'utf-8'));
470
+ }
471
+
472
+ // --- Cleanup helpers ---
473
+ //
474
+ // Delete expired tickets, mark stale sessions as dead. Writes happen
475
+ // in-place; the helper returns void.
476
+ function cleanExpiredTickets() {
477
+ const cutoff = new Date(Date.now() - TICKET_CLEANUP_MS).toISOString();
478
+ stmts.deleteTicketsCreatedBefore.run(cutoff);
479
+
480
+ // Mark stale (no heartbeat for SESSION_STALE_MS) sessions as dead.
481
+ const now = Date.now();
482
+ const staleCutoff = new Date(now - SESSION_STALE_MS).toISOString();
483
+ const allSessions = stmts.selectAllSessions.all();
484
+ for (const row of allSessions) {
485
+ if (row.status !== 'dead' && row.last_activity_at < staleCutoff) {
486
+ stmts.killSessionStmt.run('system', new Date().toISOString(), row.session_id);
487
+ }
488
+ }
489
+
490
+ const sessionCutoff = new Date(now - SESSION_CLEANUP_MS).toISOString();
491
+ stmts.deleteDeadOldSessions.run(sessionCutoff);
492
+ }
493
+
494
+ // --- Rate limiting ---
495
+
496
+ function checkRateLimit(agentLabel) {
497
+ const now = Date.now();
498
+ const key = agentLabel;
499
+ let entry = ticketRateCounts.get(key);
500
+
501
+ if (!entry || now - entry.windowStart > TICKET_RATE_WINDOW_MS) {
502
+ // Evict oldest entries if map is too large
503
+ if (ticketRateCounts.size >= MAX_RATE_ENTRIES && !ticketRateCounts.has(key)) {
504
+ const firstKey = ticketRateCounts.keys().next().value;
505
+ ticketRateCounts.delete(firstKey);
506
+ }
507
+ entry = { windowStart: now, count: 0 };
508
+ ticketRateCounts.set(key, entry);
509
+ }
510
+
511
+ entry.count++;
512
+ if (entry.count > TICKET_RATE_LIMIT) {
513
+ throw Object.assign(new Error('Rate limit exceeded'), { statusCode: 429 });
514
+ }
515
+ }
516
+
517
+ // Periodic cleanup of stale rate limit entries
518
+ const rateLimitInterval = setInterval(() => {
519
+ const cutoff = Date.now() - TICKET_RATE_WINDOW_MS * 2;
520
+ for (const [key, entry] of ticketRateCounts) {
521
+ if (entry.windowStart < cutoff) ticketRateCounts.delete(key);
522
+ }
523
+ }, TICKET_RATE_WINDOW_MS * 2);
524
+ rateLimitInterval.unref();
525
+
526
+ export function clearRateLimitInterval() {
527
+ clearInterval(rateLimitInterval);
528
+ }
529
+
530
+ // --- Ticket scope capabilities ---
531
+
532
+ async function refreshTicketScopeCapabilities() {
533
+ const scopeRows = stmts.selectAllScopes.all();
534
+ const caps = [];
535
+ for (const row of scopeRows) {
536
+ const scope = rowToScope(row);
537
+ for (const s of scope.scopes) {
538
+ caps.push(s.name);
539
+ }
540
+ }
541
+ setTicketScopeCapabilitiesOnMtls([...new Set(caps)]);
542
+ }
543
+
544
+ export async function loadTicketScopeCapabilities() {
545
+ await getStmts();
546
+ await refreshTicketScopeCapabilities();
547
+ }
548
+
549
+ /**
550
+ * Check whether a given capability name is a registered ticket scope capability.
551
+ *
552
+ * Iterates the scope registry and checks if any registered scope has a
553
+ * sub-scope whose name matches the provided capability.
554
+ *
555
+ * @param {string} capability - Capability string to check (e.g., "plugin:sync:connect")
556
+ * @returns {Promise<boolean>}
557
+ */
558
+ export function isRegisteredTicketScope(capability) {
559
+ return withTicketLock(async () => {
560
+ await getStmts();
561
+ const scopeRows = stmts.selectAllScopes.all();
562
+ for (const row of scopeRows) {
563
+ const scope = rowToScope(row);
564
+ for (const s of scope.scopes) {
565
+ if (s.name === capability) {
566
+ return true;
567
+ }
568
+ }
569
+ }
570
+ return false;
571
+ });
572
+ }
573
+
574
+ /**
575
+ * List every sub-scope name currently held by the ticket scope registry.
576
+ *
577
+ * Used by the plugin install path to detect a collision between an incoming
578
+ * plugin's declared capability and an already-registered ticket scope. The
579
+ * namespacing rule guarantees both sides live under `plugin:*`, so a flat
580
+ * set intersection is sufficient.
581
+ *
582
+ * @returns {Promise<string[]>}
583
+ */
584
+ export function listRegisteredSubScopeNames() {
585
+ return withTicketLock(async () => {
586
+ await getStmts();
587
+ const scopeRows = stmts.selectAllScopes.all();
588
+ const names = [];
589
+ for (const row of scopeRows) {
590
+ const scope = rowToScope(row);
591
+ for (const s of scope.scopes) {
592
+ names.push(s.name);
593
+ }
594
+ }
595
+ return names;
596
+ });
597
+ }
598
+
599
+ // --- Instance ownership check ---
600
+
601
+ /**
602
+ * Check whether an agent owns at least one active instance for the given scope.
603
+ *
604
+ * Used by delegated enrollment to verify the delegating agent has a live
605
+ * ticket instance for the scope it is delegating.
606
+ *
607
+ * @param {string} agentLabel - Agent label to check
608
+ * @param {string} scope - Ticket scope (e.g., "sync:connect")
609
+ * @returns {Promise<boolean>}
610
+ */
611
+ export function agentOwnsInstanceForScope(agentLabel, scope) {
612
+ return withTicketLock(async () => {
613
+ await getStmts();
614
+ const rows = stmts.selectInstanceByScopeAgent.all(scope, agentLabel);
615
+ return rows.some((r) => r.status !== 'dead');
616
+ });
617
+ }
618
+
619
+ // --- Scope management ---
620
+
621
+ export function registerScope(body, logger) {
622
+ return withTicketLock(async () => {
623
+ await getStmts();
624
+
625
+ const existing = stmts.selectScopeByName.get(body.name);
626
+ if (existing) {
627
+ throw Object.assign(new Error(`Ticket scope "${body.name}" is already registered`), {
628
+ statusCode: 409,
629
+ });
630
+ }
631
+
632
+ // Sub-scope name uniqueness across the registry: even with namespacing,
633
+ // two unrelated scope manifests could declare the same `plugin:foo:bar`
634
+ // sub-scope and the union in `getValidCapabilities()` would prevent
635
+ // either owner from cleanly revoking it. Reject the second registration
636
+ // deterministically.
637
+ const subScopeNames = body.scopes.map((s) => s.name);
638
+ const duplicateInBody = subScopeNames.find((n, i) => subScopeNames.indexOf(n) !== i);
639
+ if (duplicateInBody) {
640
+ throw Object.assign(
641
+ new Error(`Ticket scope manifest declares sub-scope "${duplicateInBody}" twice`),
642
+ { statusCode: 400 },
643
+ );
644
+ }
645
+
646
+ const allScopeRows = stmts.selectAllScopes.all();
647
+ let collisionWithExistingScope = null;
648
+ let offender = null;
649
+ for (const row of allScopeRows) {
650
+ const scope = rowToScope(row);
651
+ const collide = scope.scopes.find((sub) => subScopeNames.includes(sub.name));
652
+ if (collide) {
653
+ collisionWithExistingScope = scope;
654
+ offender = collide;
655
+ break;
656
+ }
657
+ }
658
+ if (collisionWithExistingScope) {
659
+ throw Object.assign(
660
+ new Error(
661
+ `Sub-scope "${offender?.name}" is already registered by ticket scope ` +
662
+ `"${collisionWithExistingScope.name}"`,
663
+ ),
664
+ { statusCode: 409 },
665
+ );
666
+ }
667
+
668
+ // Cross-namespace collision: a sub-scope name MUST NOT match any
669
+ // capability already contributed by an installed (enabled) plugin. The
670
+ // namespacing rule enforces both sides live in `plugin:*` so the check
671
+ // is a flat set intersection.
672
+ const pluginCaps = new Set(await getPluginCapabilities());
673
+ const collidingPluginCap = subScopeNames.find((n) => pluginCaps.has(n));
674
+ if (collidingPluginCap) {
675
+ throw Object.assign(
676
+ new Error(
677
+ `Sub-scope "${collidingPluginCap}" is already declared by an enabled plugin — ` +
678
+ 'pick a different action name or revoke the plugin first',
679
+ ),
680
+ { statusCode: 409 },
681
+ );
682
+ }
683
+
684
+ const installedAt = new Date().toISOString();
685
+
686
+ stmts.begin.run();
687
+ try {
688
+ stmts.insertScope.run(
689
+ body.name,
690
+ body.version,
691
+ body.description,
692
+ JSON.stringify(body.scopes),
693
+ JSON.stringify(body.transport),
694
+ JSON.stringify({}),
695
+ installedAt,
696
+ );
697
+ stmts.commit.run();
698
+ } catch (err) {
699
+ stmts.rollback.run();
700
+ throw err;
701
+ }
702
+
703
+ await refreshTicketScopeCapabilities();
704
+
705
+ logger.info({ name: body.name, registered: subScopeNames }, 'Ticket scope registered');
706
+ return { ok: true, registered: subScopeNames };
707
+ });
708
+ }
709
+
710
+ export function listScopes() {
711
+ return withTicketLock(async () => {
712
+ await getStmts();
713
+ const scopeRows = stmts.selectAllScopes.all();
714
+ const instanceRows = stmts.selectAllInstances.all();
715
+ const assignmentRows = stmts.selectAllAssignments.all();
716
+ return {
717
+ scopes: scopeRows.map(rowToScope),
718
+ instances: instanceRows.map(rowToInstance),
719
+ assignments: assignmentRows.map(rowToAssignment),
720
+ };
721
+ });
722
+ }
723
+
724
+ export function unregisterScope(name, logger) {
725
+ return withTicketLock(async () => {
726
+ await getStmts();
727
+ const scopeRow = stmts.selectScopeByName.get(name);
728
+ if (!scopeRow) {
729
+ throw Object.assign(new Error(`Ticket scope "${name}" not found`), { statusCode: 404 });
730
+ }
731
+
732
+ const scope = rowToScope(scopeRow);
733
+ const scopeNames = scope.scopes.map((s) => s.name);
734
+
735
+ stmts.begin.run();
736
+ try {
737
+ // Remove instances for any sub-scope of this manifest (cascade).
738
+ const allInstances = stmts.selectAllInstances.all();
739
+ for (const inst of allInstances) {
740
+ if (scopeNames.includes(inst.scope)) {
741
+ stmts.deleteInstance.run(inst.instance_id);
742
+ }
743
+ }
744
+
745
+ // Remove assignments whose instanceScope starts with `${subScope}:`.
746
+ for (const sn of scopeNames) {
747
+ stmts.deleteAssignmentsByInstanceScopePrefix.run(`${sn}:%`);
748
+ }
749
+
750
+ // Delete the scope row itself.
751
+ stmts.deleteScope.run(name);
752
+
753
+ // Invalidate active tickets for removed scopes (mark used).
754
+ const usedAt = new Date().toISOString();
755
+ for (const sn of scopeNames) {
756
+ const ticketRows = stmts.selectTicketsByScope.all(sn);
757
+ for (const tr of ticketRows) {
758
+ if (tr.used === 0) {
759
+ stmts.markTicketUsed.run(usedAt, tr.index_hash);
760
+ }
761
+ }
762
+ }
763
+
764
+ stmts.commit.run();
765
+ } catch (err) {
766
+ stmts.rollback.run();
767
+ throw err;
768
+ }
769
+
770
+ await refreshTicketScopeCapabilities();
771
+
772
+ logger.info({ name }, 'Ticket scope unregistered');
773
+ return { ok: true, name };
774
+ });
775
+ }
776
+
777
+ // --- Instance management ---
778
+
779
+ export function registerInstance(scope, transport, agentLabel, logger) {
780
+ return withTicketLock(async () => {
781
+ await getStmts();
782
+
783
+ // Verify scope exists (search across all scope manifests)
784
+ const scopeRows = stmts.selectAllScopes.all();
785
+ let scopeEntry = null;
786
+ for (const row of scopeRows) {
787
+ const s = rowToScope(row);
788
+ for (const decl of s.scopes) {
789
+ if (decl.name === scope) {
790
+ scopeEntry = decl;
791
+ break;
792
+ }
793
+ }
794
+ if (scopeEntry) break;
795
+ }
796
+
797
+ if (!scopeEntry) {
798
+ throw Object.assign(new Error('Scope not registered'), { statusCode: 404 });
799
+ }
800
+
801
+ // Check for existing instance from this agent for this scope (idempotent re-registration)
802
+ const existingRows = stmts.selectInstanceByScopeAgent.all(scope, agentLabel);
803
+ if (existingRows.length > 0) {
804
+ const existing = existingRows[0];
805
+ stmts.begin.run();
806
+ try {
807
+ stmts.updateInstanceTransportHeartbeat.run(
808
+ JSON.stringify(transport),
809
+ new Date().toISOString(),
810
+ existing.instance_id,
811
+ );
812
+ stmts.commit.run();
813
+ } catch (err) {
814
+ stmts.rollback.run();
815
+ throw err;
816
+ }
817
+
818
+ logger.info(
819
+ { scope, agentLabel, instanceId: existing.instance_id },
820
+ 'Instance re-registered',
821
+ );
822
+ return {
823
+ ok: true,
824
+ instanceId: existing.instance_id,
825
+ instanceScope: `${scope}:${existing.instance_id}`,
826
+ isReregistration: true,
827
+ };
828
+ }
829
+
830
+ // New registration — enforce hard cap
831
+ const { n } = stmts.countInstances.get();
832
+ if (n >= MAX_INSTANCES) {
833
+ throw Object.assign(new Error('Instance limit reached'), { statusCode: 503 });
834
+ }
835
+
836
+ const instanceId = crypto.randomBytes(16).toString('hex');
837
+ const now = new Date().toISOString();
838
+
839
+ stmts.begin.run();
840
+ try {
841
+ stmts.insertInstance.run(
842
+ instanceId,
843
+ scope,
844
+ agentLabel,
845
+ now,
846
+ now,
847
+ 'active',
848
+ JSON.stringify(transport),
849
+ );
850
+ stmts.commit.run();
851
+ } catch (err) {
852
+ stmts.rollback.run();
853
+ throw err;
854
+ }
855
+
856
+ logger.info({ scope, agentLabel, instanceId }, 'Instance registered');
857
+ return {
858
+ ok: true,
859
+ instanceId,
860
+ instanceScope: `${scope}:${instanceId}`,
861
+ isReregistration: false,
862
+ };
863
+ });
864
+ }
865
+
866
+ export function deregisterInstance(instanceId, callerLabel, callerRole, logger) {
867
+ return withTicketLock(async () => {
868
+ await getStmts();
869
+
870
+ const instanceRow = stmts.selectInstanceById.get(instanceId);
871
+ if (!instanceRow) {
872
+ throw Object.assign(new Error('Instance not found'), { statusCode: 404 });
873
+ }
874
+
875
+ // Only the owning agent or admin can deregister (return 404 to avoid leaking existence)
876
+ if (callerRole !== 'admin' && callerLabel !== instanceRow.agent_label) {
877
+ throw Object.assign(new Error('Instance not found'), { statusCode: 404 });
878
+ }
879
+
880
+ const instanceScope = `${instanceRow.scope}:${instanceId}`;
881
+
882
+ stmts.begin.run();
883
+ try {
884
+ // Remove assignments for this instance.
885
+ stmts.deleteAssignmentsByInstanceScope.run(instanceScope);
886
+
887
+ // Delete the instance row.
888
+ stmts.deleteInstance.run(instanceId);
889
+
890
+ // Invalidate pending tickets and kill active sessions for this instance.
891
+ const usedAt = new Date().toISOString();
892
+ const ticketRows = stmts.selectTicketsByInstance.all(instanceId);
893
+ for (const tr of ticketRows) {
894
+ if (tr.used === 0) {
895
+ stmts.markTicketUsed.run(usedAt, tr.index_hash);
896
+ }
897
+ }
898
+ const sessionRows = stmts.selectSessionsByInstance.all(instanceId);
899
+ for (const sr of sessionRows) {
900
+ if (sr.status !== 'dead') {
901
+ stmts.killSessionStmt.run('system', new Date().toISOString(), sr.session_id);
902
+ }
903
+ }
904
+ stmts.commit.run();
905
+ } catch (err) {
906
+ stmts.rollback.run();
907
+ throw err;
908
+ }
909
+
910
+ logger.info({ instanceId, scope: instanceRow.scope }, 'Instance deregistered');
911
+ return { ok: true, instanceId };
912
+ });
913
+ }
914
+
915
+ export function instanceHeartbeat(instanceId, agentLabel) {
916
+ return withTicketLock(async () => {
917
+ await getStmts();
918
+ const instanceRow = stmts.selectInstanceById.get(instanceId);
919
+
920
+ if (!instanceRow || instanceRow.agent_label !== agentLabel) {
921
+ throw Object.assign(new Error('Instance not found'), { statusCode: 404 });
922
+ }
923
+
924
+ // Verify agent still has the scope capability. Filter the stored list
925
+ // through the live valid set so that capabilities contributed by an
926
+ // uninstalled plugin or deregistered scope are not honored.
927
+ const agentRegistry = await loadAgentRegistry();
928
+ const agent = agentRegistry.agents.find((a) => a.label === agentLabel && !a.revoked);
929
+ if (!agent) {
930
+ throw Object.assign(new Error('Instance not found'), { statusCode: 404 });
931
+ }
932
+ if (!liveCapsInclude(agent, instanceRow.scope)) {
933
+ throw Object.assign(new Error('Instance not found'), { statusCode: 404 });
934
+ }
935
+
936
+ stmts.begin.run();
937
+ try {
938
+ stmts.updateInstanceHeartbeat.run(new Date().toISOString(), instanceId);
939
+ stmts.commit.run();
940
+ } catch (err) {
941
+ stmts.rollback.run();
942
+ throw err;
943
+ }
944
+
945
+ return { ok: true };
946
+ });
947
+ }
948
+
949
+ /**
950
+ * Check whether `capability` is currently honored for `agent`.
951
+ *
952
+ * Single source of truth for "does this agent currently hold this cap?".
953
+ * Reads `agent.capabilities` and intersects with the live valid set
954
+ * (`getValidCapabilities()`), so an entry that became invalid after a
955
+ * plugin uninstall or ticket scope deregistration is treated as absent.
956
+ *
957
+ * Defaults a missing capability list to ['tunnels:read'] to preserve the
958
+ * legacy behaviour for agents enrolled before capabilities were stored.
959
+ */
960
+ function liveCapsInclude(agent, capability) {
961
+ const stored = Array.isArray(agent?.capabilities) ? agent.capabilities : ['tunnels:read'];
962
+ if (!stored.includes(capability)) return false;
963
+ const valid = new Set(getValidCapabilities());
964
+ return valid.has(capability);
965
+ }
966
+
967
+ // --- Instance assignment ---
968
+
969
+ export function createAssignment(agentLabel, instanceScope, logger) {
970
+ return withTicketLock(async () => {
971
+ await getStmts();
972
+
973
+ // Parse instanceScope: "plugin:<route>:<action>:<hex-id>"
974
+ const parts = instanceScope.match(/^(plugin:[a-z0-9-]+:[a-z0-9-]+):([a-f0-9]+)$/);
975
+ if (!parts) {
976
+ throw Object.assign(new Error('Invalid instance scope format'), { statusCode: 400 });
977
+ }
978
+ const [, baseScope, instanceId] = parts;
979
+
980
+ // Verify agent exists and is not revoked
981
+ const agentRegistry = await loadAgentRegistry();
982
+ const agent = agentRegistry.agents.find((a) => a.label === agentLabel && !a.revoked);
983
+ if (!agent) {
984
+ throw Object.assign(new Error('Agent not found or revoked'), { statusCode: 404 });
985
+ }
986
+
987
+ // Verify agent has the base capability — check against the live valid
988
+ // set so caps from uninstalled plugins / deregistered scopes are not
989
+ // honored (see liveCapsInclude).
990
+ if (!liveCapsInclude(agent, baseScope)) {
991
+ throw Object.assign(new Error(`Agent "${agentLabel}" lacks capability "${baseScope}"`), {
992
+ statusCode: 400,
993
+ });
994
+ }
995
+
996
+ // Verify instance exists and is active
997
+ const instanceRow = stmts.selectInstanceById.get(instanceId);
998
+ if (!instanceRow || instanceRow.scope !== baseScope || instanceRow.status === 'dead') {
999
+ throw Object.assign(new Error('Instance not found or not active'), { statusCode: 404 });
1000
+ }
1001
+
1002
+ // Check for duplicate assignment
1003
+ const existingRow = stmts.selectAssignment.get(agentLabel, instanceScope);
1004
+ if (existingRow) {
1005
+ return { ok: true, assignment: rowToAssignment(existingRow), isExisting: true };
1006
+ }
1007
+
1008
+ const assignedAt = new Date().toISOString();
1009
+ const assignedBy = 'admin';
1010
+
1011
+ stmts.begin.run();
1012
+ try {
1013
+ stmts.insertAssignment.run(agentLabel, instanceScope, assignedAt, assignedBy);
1014
+ stmts.commit.run();
1015
+ } catch (err) {
1016
+ stmts.rollback.run();
1017
+ throw err;
1018
+ }
1019
+
1020
+ const assignment = { agentLabel, instanceScope, assignedAt, assignedBy };
1021
+ logger.info({ agentLabel, instanceScope }, 'Assignment created');
1022
+ return { ok: true, assignment };
1023
+ });
1024
+ }
1025
+
1026
+ export function removeAssignment(agentLabel, instanceScope, logger) {
1027
+ return withTicketLock(async () => {
1028
+ await getStmts();
1029
+ const existingRow = stmts.selectAssignment.get(agentLabel, instanceScope);
1030
+ if (!existingRow) {
1031
+ throw Object.assign(new Error('Assignment not found'), { statusCode: 404 });
1032
+ }
1033
+
1034
+ stmts.begin.run();
1035
+ try {
1036
+ stmts.deleteAssignment.run(agentLabel, instanceScope);
1037
+ stmts.commit.run();
1038
+ } catch (err) {
1039
+ stmts.rollback.run();
1040
+ throw err;
1041
+ }
1042
+
1043
+ logger.info({ agentLabel, instanceScope }, 'Assignment removed');
1044
+ return { ok: true };
1045
+ });
1046
+ }
1047
+
1048
+ export function listAssignments(filters) {
1049
+ return withTicketLock(async () => {
1050
+ await getStmts();
1051
+ let assignments = stmts.selectAllAssignments.all().map(rowToAssignment);
1052
+
1053
+ if (filters?.agentLabel) {
1054
+ assignments = assignments.filter((a) => a.agentLabel === filters.agentLabel);
1055
+ }
1056
+ if (filters?.instanceScope) {
1057
+ assignments = assignments.filter((a) => a.instanceScope === filters.instanceScope);
1058
+ }
1059
+
1060
+ return { assignments };
1061
+ });
1062
+ }
1063
+
1064
+ // --- Ticket operations ---
1065
+
1066
+ export function requestTicket(scope, instanceId, target, sourceLabel, logger) {
1067
+ return withTicketLock(async () => {
1068
+ await getStmts();
1069
+
1070
+ // Rate limit
1071
+ checkRateLimit(sourceLabel);
1072
+
1073
+ cleanExpiredTickets();
1074
+
1075
+ // Stage 1: Verify source/target agents have the base capability —
1076
+ // checked through liveCapsInclude so caps from uninstalled plugins or
1077
+ // deregistered scopes do not satisfy the check.
1078
+ const agentRegistry = await loadAgentRegistry();
1079
+ const sourceAgent = agentRegistry.agents.find((a) => a.label === sourceLabel && !a.revoked);
1080
+ if (!sourceAgent || !liveCapsInclude(sourceAgent, scope)) {
1081
+ throw Object.assign(new Error('Not found'), { statusCode: 404 });
1082
+ }
1083
+
1084
+ const targetAgent = agentRegistry.agents.find((a) => a.label === target && !a.revoked);
1085
+ if (!targetAgent || !liveCapsInclude(targetAgent, scope)) {
1086
+ throw Object.assign(new Error('Not found'), { statusCode: 404 });
1087
+ }
1088
+
1089
+ // Stage 2: Verify source owns the instance
1090
+ const instanceRow = stmts.selectInstanceById.get(instanceId);
1091
+ if (!instanceRow || instanceRow.scope !== scope || instanceRow.agent_label !== sourceLabel) {
1092
+ throw Object.assign(new Error('Not found'), { statusCode: 404 });
1093
+ }
1094
+
1095
+ // Check instance status
1096
+ if (instanceRow.status === 'stale') {
1097
+ throw Object.assign(new Error('Instance is stale'), { statusCode: 503 });
1098
+ }
1099
+ if (instanceRow.status === 'dead') {
1100
+ throw Object.assign(new Error('Not found'), { statusCode: 404 });
1101
+ }
1102
+
1103
+ // Reject self-tickets (source cannot also be the target)
1104
+ if (sourceLabel === target) {
1105
+ throw Object.assign(new Error('Not found'), { statusCode: 404 });
1106
+ }
1107
+
1108
+ // Stage 3: Verify target is assigned to this instance
1109
+ const instanceScope = `${scope}:${instanceId}`;
1110
+ const assignmentRow = stmts.selectAssignment.get(target, instanceScope);
1111
+ if (!assignmentRow) {
1112
+ throw Object.assign(new Error('Not found'), { statusCode: 404 });
1113
+ }
1114
+
1115
+ // Enforce ticket cap
1116
+ const { n } = stmts.countTickets.get();
1117
+ if (n >= MAX_TICKETS) {
1118
+ throw Object.assign(new Error('Ticket limit reached'), { statusCode: 503 });
1119
+ }
1120
+
1121
+ // Create ticket
1122
+ const ticketId = crypto.randomBytes(32).toString('hex');
1123
+ const now = new Date();
1124
+ const expiresAt = new Date(now.getTime() + TICKET_EXPIRY_MS);
1125
+ const transportObj = JSON.parse(instanceRow.transport);
1126
+
1127
+ stmts.begin.run();
1128
+ try {
1129
+ stmts.insertTicket.run(
1130
+ ticketIndex(ticketId),
1131
+ ticketId,
1132
+ scope,
1133
+ instanceId,
1134
+ sourceLabel,
1135
+ target,
1136
+ now.toISOString(),
1137
+ expiresAt.toISOString(),
1138
+ 0,
1139
+ null,
1140
+ null,
1141
+ instanceRow.transport,
1142
+ );
1143
+ stmts.commit.run();
1144
+ } catch (err) {
1145
+ stmts.rollback.run();
1146
+ throw err;
1147
+ }
1148
+
1149
+ logger.info(
1150
+ { ticketId: ticketId.slice(0, 8), scope, source: sourceLabel, target },
1151
+ 'Ticket issued',
1152
+ );
1153
+ return {
1154
+ ok: true,
1155
+ ticket: {
1156
+ id: ticketId,
1157
+ scope,
1158
+ instanceId,
1159
+ source: sourceLabel,
1160
+ target,
1161
+ expiresAt: expiresAt.toISOString(),
1162
+ transport: transportObj,
1163
+ },
1164
+ };
1165
+ });
1166
+ }
1167
+
1168
+ export function getTicketInbox(agentLabel) {
1169
+ return withTicketLock(async () => {
1170
+ await getStmts();
1171
+ const now = Date.now();
1172
+ const allTickets = stmts.selectAllTickets.all().map(rowToTicket);
1173
+ const tickets = allTickets.filter(
1174
+ (t) => t.target === agentLabel && !t.used && new Date(t.expiresAt).getTime() > now,
1175
+ );
1176
+
1177
+ return {
1178
+ tickets: tickets.map((t) => ({
1179
+ id: t.id,
1180
+ scope: t.scope,
1181
+ instanceId: t.instanceId,
1182
+ source: t.source,
1183
+ expiresAt: t.expiresAt,
1184
+ transport: t.transport,
1185
+ })),
1186
+ };
1187
+ });
1188
+ }
1189
+
1190
+ /**
1191
+ * Validate a ticket and atomically mark it used.
1192
+ *
1193
+ * `logger` is the request-scoped Fastify logger; structured `reason` for the
1194
+ * specific failure mode is written to the panel log on every denial. The HTTP
1195
+ * response always uses the generic "Invalid ticket" message — no information
1196
+ * leakage to the calling agent.
1197
+ */
1198
+ export function validateTicket(ticketId, callerLabel, logger = moduleLogger) {
1199
+ return withTicketLock(async () => {
1200
+ await getStmts();
1201
+ cleanExpiredTickets();
1202
+
1203
+ // Discriminated reason for the panel log only — never returned to caller.
1204
+ const deny = (reason) => {
1205
+ logger.info(
1206
+ { ticketId: ticketId.slice(0, 8), reason, caller: callerLabel },
1207
+ 'ticket validation denied',
1208
+ );
1209
+ recordTicketValidationFailure(logger, reason);
1210
+ throw Object.assign(new Error('Invalid ticket'), { statusCode: 401, reason });
1211
+ };
1212
+
1213
+ const key = ticketIndex(ticketId);
1214
+ const row = stmts.selectTicketByIndex.get(key);
1215
+
1216
+ if (!row) deny('not_found');
1217
+ // Defense against a hash collision: confirm the stored full token matches
1218
+ // the caller-supplied one with a constant-time compare. ticketIndex is a
1219
+ // SHA-256 — a collision is computationally infeasible — but the equality
1220
+ // is cheap and removes any reliance on collision resistance for auth.
1221
+ if (!constantTimeStringEqual(row.id, ticketId)) deny('not_found');
1222
+
1223
+ if (row.used === 1) deny('already_used');
1224
+ if (new Date(row.expires_at).getTime() < Date.now()) deny('expired');
1225
+ if (row.target !== callerLabel) deny('target_mismatch');
1226
+
1227
+ // Mark as used atomically
1228
+ stmts.begin.run();
1229
+ try {
1230
+ stmts.markTicketUsed.run(new Date().toISOString(), key);
1231
+ stmts.commit.run();
1232
+ } catch (err) {
1233
+ stmts.rollback.run();
1234
+ throw err;
1235
+ }
1236
+
1237
+ return {
1238
+ valid: true,
1239
+ scope: row.scope,
1240
+ instanceId: row.instance_id,
1241
+ source: row.source,
1242
+ target: row.target,
1243
+ transport: JSON.parse(row.transport),
1244
+ };
1245
+ });
1246
+ }
1247
+
1248
+ export function listTickets() {
1249
+ return withTicketLock(async () => {
1250
+ await getStmts();
1251
+ cleanExpiredTickets();
1252
+ // Admin is fully trusted (mTLS admin cert) — return full IDs so revoke works.
1253
+ // Tickets are stored hash-indexed in SQLite for O(1) validate; the admin UI
1254
+ // consumes a plain array, so flatten on the way out.
1255
+ return { tickets: stmts.selectAllTickets.all().map(rowToTicket) };
1256
+ });
1257
+ }
1258
+
1259
+ export function revokeTicket(ticketId, logger) {
1260
+ return withTicketLock(async () => {
1261
+ await getStmts();
1262
+ const key = ticketIndex(ticketId);
1263
+ const row = stmts.selectTicketByIndex.get(key);
1264
+
1265
+ // Defense against hash collision (see validateTicket): require the stored
1266
+ // full token to constant-time-equal the inbound one.
1267
+ if (!row || !constantTimeStringEqual(row.id, ticketId)) {
1268
+ throw Object.assign(new Error('Ticket not found'), { statusCode: 404 });
1269
+ }
1270
+
1271
+ stmts.begin.run();
1272
+ try {
1273
+ if (row.used === 0) {
1274
+ stmts.markTicketUsed.run(new Date().toISOString(), key);
1275
+ }
1276
+
1277
+ // If the ticket had a session, mark it for termination
1278
+ if (row.session_id) {
1279
+ const sessionRow = stmts.selectSessionById.get(row.session_id);
1280
+ if (sessionRow && sessionRow.status !== 'dead') {
1281
+ stmts.killSessionStmt.run('admin', new Date().toISOString(), row.session_id);
1282
+ }
1283
+ }
1284
+ stmts.commit.run();
1285
+ } catch (err) {
1286
+ stmts.rollback.run();
1287
+ throw err;
1288
+ }
1289
+
1290
+ logger.info({ ticketId: ticketId.slice(0, 8) }, 'Ticket revoked');
1291
+ return { ok: true };
1292
+ });
1293
+ }
1294
+
1295
+ // --- Session management ---
1296
+
1297
+ export function createSession(ticketId, callerLabel, logger) {
1298
+ return withTicketLock(async () => {
1299
+ await getStmts();
1300
+
1301
+ const key = ticketIndex(ticketId);
1302
+ const row = stmts.selectTicketByIndex.get(key);
1303
+ if (
1304
+ !row ||
1305
+ !constantTimeStringEqual(row.id, ticketId) ||
1306
+ row.used !== 1 ||
1307
+ row.target !== callerLabel
1308
+ ) {
1309
+ throw Object.assign(new Error('Invalid ticket'), { statusCode: 400 });
1310
+ }
1311
+
1312
+ // Prevent duplicate sessions for the same ticket
1313
+ if (row.session_id) {
1314
+ throw Object.assign(new Error('Session already exists for this ticket'), { statusCode: 409 });
1315
+ }
1316
+
1317
+ // Enforce session cap
1318
+ const { n } = stmts.countLiveSessions.get();
1319
+ if (n >= MAX_SESSIONS) {
1320
+ throw Object.assign(new Error('Session limit reached'), { statusCode: 503 });
1321
+ }
1322
+
1323
+ // Generate session ID server-side for uniqueness guarantee
1324
+ const sessionId = crypto.randomBytes(16).toString('hex');
1325
+ const now = new Date().toISOString();
1326
+ const session = {
1327
+ sessionId,
1328
+ ticketId,
1329
+ scope: row.scope,
1330
+ instanceId: row.instance_id,
1331
+ source: row.source,
1332
+ target: row.target,
1333
+ createdAt: now,
1334
+ lastActivityAt: now,
1335
+ status: 'active',
1336
+ reconnectGraceSeconds: 60,
1337
+ };
1338
+
1339
+ stmts.begin.run();
1340
+ try {
1341
+ stmts.insertSession.run(
1342
+ sessionId,
1343
+ ticketId,
1344
+ row.scope,
1345
+ row.instance_id,
1346
+ row.source,
1347
+ row.target,
1348
+ now,
1349
+ now,
1350
+ 'active',
1351
+ 60,
1352
+ null,
1353
+ null,
1354
+ );
1355
+ stmts.setTicketSession.run(sessionId, key);
1356
+ stmts.commit.run();
1357
+ } catch (err) {
1358
+ stmts.rollback.run();
1359
+ throw err;
1360
+ }
1361
+
1362
+ logger.info({ sessionId, ticketId: ticketId.slice(0, 8), scope: row.scope }, 'Session created');
1363
+ return { ok: true, session };
1364
+ });
1365
+ }
1366
+
1367
+ export function sessionHeartbeat(sessionId, callerLabel) {
1368
+ return withTicketLock(async () => {
1369
+ await getStmts();
1370
+ const sessionRow = stmts.selectSessionById.get(sessionId);
1371
+
1372
+ if (!sessionRow || sessionRow.target !== callerLabel) {
1373
+ throw Object.assign(new Error('Session not found'), { statusCode: 404 });
1374
+ }
1375
+
1376
+ if (sessionRow.status === 'dead') {
1377
+ return { authorized: false, reason: 'admin_killed' };
1378
+ }
1379
+
1380
+ const session = rowToSession(sessionRow);
1381
+
1382
+ // Re-validate authorization
1383
+ const agentRegistry = await loadAgentRegistry();
1384
+
1385
+ // Check source cert is not revoked
1386
+ const sourceAgent = agentRegistry.agents.find((a) => a.label === session.source);
1387
+ if (!sourceAgent || sourceAgent.revoked) {
1388
+ stmts.killSessionStmt.run('system', new Date().toISOString(), sessionId);
1389
+ return { authorized: false, reason: 'source_revoked' };
1390
+ }
1391
+
1392
+ // Check source still has capability — through the live valid set so a
1393
+ // capability whose backing plugin/scope was removed counts as gone.
1394
+ if (!liveCapsInclude(sourceAgent, session.scope)) {
1395
+ stmts.killSessionStmt.run('system', new Date().toISOString(), sessionId);
1396
+ return { authorized: false, reason: 'capability_removed' };
1397
+ }
1398
+
1399
+ // Check target agent still has capability (defense-in-depth)
1400
+ const targetAgent = agentRegistry.agents.find((a) => a.label === session.target);
1401
+ if (!targetAgent || targetAgent.revoked) {
1402
+ stmts.killSessionStmt.run('system', new Date().toISOString(), sessionId);
1403
+ return { authorized: false, reason: 'target_revoked' };
1404
+ }
1405
+ if (!liveCapsInclude(targetAgent, session.scope)) {
1406
+ stmts.killSessionStmt.run('system', new Date().toISOString(), sessionId);
1407
+ return { authorized: false, reason: 'capability_removed' };
1408
+ }
1409
+
1410
+ // Check assignment still valid
1411
+ const instanceScope = `${session.scope}:${session.instanceId}`;
1412
+ const assignmentRow = stmts.selectAssignment.get(callerLabel, instanceScope);
1413
+ if (!assignmentRow) {
1414
+ stmts.killSessionStmt.run('system', new Date().toISOString(), sessionId);
1415
+ return { authorized: false, reason: 'assignment_removed' };
1416
+ }
1417
+
1418
+ // All checks passed
1419
+ stmts.updateSessionActivity.run(new Date().toISOString(), sessionId);
1420
+ return { authorized: true };
1421
+ });
1422
+ }
1423
+
1424
+ export function updateSession(sessionId, status, callerLabel) {
1425
+ return withTicketLock(async () => {
1426
+ await getStmts();
1427
+ const sessionRow = stmts.selectSessionById.get(sessionId);
1428
+ if (!sessionRow || (sessionRow.target !== callerLabel && sessionRow.source !== callerLabel)) {
1429
+ throw Object.assign(new Error('Session not found'), { statusCode: 404 });
1430
+ }
1431
+
1432
+ // Prevent reactivation of admin-killed or system-terminated sessions
1433
+ if (sessionRow.status === 'dead') {
1434
+ throw Object.assign(new Error('Session is terminated'), { statusCode: 409 });
1435
+ }
1436
+
1437
+ const session = rowToSession(sessionRow);
1438
+
1439
+ // Re-validate authorization on every status transition
1440
+ const agentRegistry = await loadAgentRegistry();
1441
+
1442
+ // Check source cert is not revoked and still has capability
1443
+ const sourceAgent = agentRegistry.agents.find((a) => a.label === session.source);
1444
+ if (!sourceAgent || sourceAgent.revoked) {
1445
+ stmts.killSessionStmt.run('system', new Date().toISOString(), sessionId);
1446
+ throw Object.assign(new Error('Session is terminated'), { statusCode: 409 });
1447
+ }
1448
+ if (!liveCapsInclude(sourceAgent, session.scope)) {
1449
+ stmts.killSessionStmt.run('system', new Date().toISOString(), sessionId);
1450
+ throw Object.assign(new Error('Session is terminated'), { statusCode: 409 });
1451
+ }
1452
+
1453
+ // Check target assignment is still valid (always check against target, not caller)
1454
+ const instanceScope = `${session.scope}:${session.instanceId}`;
1455
+ const assignmentRow = stmts.selectAssignment.get(session.target, instanceScope);
1456
+ if (!assignmentRow) {
1457
+ stmts.killSessionStmt.run('system', new Date().toISOString(), sessionId);
1458
+ throw Object.assign(new Error('Session is terminated'), { statusCode: 409 });
1459
+ }
1460
+
1461
+ // Always set server-side timestamp to prevent clients from extending session lifetime
1462
+ stmts.updateSessionStatusActivity.run(status, new Date().toISOString(), sessionId);
1463
+
1464
+ return { ok: true };
1465
+ });
1466
+ }
1467
+
1468
+ export function killSession(sessionId, logger) {
1469
+ return withTicketLock(async () => {
1470
+ await getStmts();
1471
+ const sessionRow = stmts.selectSessionById.get(sessionId);
1472
+ if (!sessionRow) {
1473
+ throw Object.assign(new Error('Session not found'), { statusCode: 404 });
1474
+ }
1475
+
1476
+ stmts.killSessionStmt.run('admin', new Date().toISOString(), sessionId);
1477
+
1478
+ logger.info({ sessionId }, 'Session killed by admin');
1479
+ return { ok: true };
1480
+ });
1481
+ }
1482
+
1483
+ export function listSessions() {
1484
+ return withTicketLock(async () => {
1485
+ await getStmts();
1486
+ cleanExpiredTickets();
1487
+ return { sessions: stmts.selectAllSessions.all().map(rowToSession) };
1488
+ });
1489
+ }
1490
+
1491
+ // --- Instance liveness check (call periodically) ---
1492
+
1493
+ export async function checkInstanceLiveness(logger) {
1494
+ return withTicketLock(async () => {
1495
+ await getStmts();
1496
+ const now = Date.now();
1497
+
1498
+ const allInstances = stmts.selectAllInstances.all();
1499
+
1500
+ for (const inst of allInstances) {
1501
+ const lastBeat = new Date(inst.last_heartbeat).getTime();
1502
+ const elapsed = now - lastBeat;
1503
+
1504
+ if (inst.status === 'active' && elapsed > INSTANCE_STALE_MS) {
1505
+ stmts.updateInstanceStatus.run('stale', inst.instance_id);
1506
+ inst.status = 'stale';
1507
+ logger.warn({ instanceId: inst.instance_id, scope: inst.scope }, 'Instance marked stale');
1508
+ }
1509
+
1510
+ if (inst.status !== 'dead' && elapsed > INSTANCE_DEAD_MS) {
1511
+ stmts.updateInstanceStatus.run('dead', inst.instance_id);
1512
+ inst.status = 'dead';
1513
+ logger.warn({ instanceId: inst.instance_id, scope: inst.scope }, 'Instance marked dead');
1514
+ }
1515
+ }
1516
+
1517
+ // Remove dead instances and their assignments to free up capacity
1518
+ const deadIds = allInstances
1519
+ .filter((inst) => inst.status === 'dead')
1520
+ .map((inst) => ({ instanceId: inst.instance_id, scope: inst.scope }));
1521
+
1522
+ if (deadIds.length > 0) {
1523
+ const usedAt = new Date().toISOString();
1524
+
1525
+ stmts.begin.run();
1526
+ try {
1527
+ for (const { instanceId, scope } of deadIds) {
1528
+ const instanceScope = `${scope}:${instanceId}`;
1529
+ stmts.deleteAssignmentsByInstanceScope.run(instanceScope);
1530
+ stmts.deleteInstance.run(instanceId);
1531
+ logger.info({ instanceId, scope }, 'Dead instance removed');
1532
+
1533
+ // Invalidate pending tickets for this dead instance.
1534
+ const ticketRows = stmts.selectTicketsByInstance.all(instanceId);
1535
+ for (const tr of ticketRows) {
1536
+ if (tr.used === 0) {
1537
+ stmts.markTicketUsed.run(usedAt, tr.index_hash);
1538
+ }
1539
+ }
1540
+
1541
+ // Kill active sessions for this dead instance.
1542
+ const sessionRows = stmts.selectSessionsByInstance.all(instanceId);
1543
+ for (const sr of sessionRows) {
1544
+ if (sr.status !== 'dead') {
1545
+ stmts.killSessionStmt.run('system', new Date().toISOString(), sr.session_id);
1546
+ }
1547
+ }
1548
+ }
1549
+ stmts.commit.run();
1550
+ } catch (err) {
1551
+ stmts.rollback.run();
1552
+ throw err;
1553
+ }
1554
+ }
1555
+ });
1556
+ }