station-kit 1.1.0 → 2.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 (177) hide show
  1. package/.next/standalone/packages/station-kit/.next/BUILD_ID +1 -1
  2. package/.next/standalone/packages/station-kit/.next/app-build-manifest.json +55 -34
  3. package/.next/standalone/packages/station-kit/.next/app-path-routes-manifest.json +11 -8
  4. package/.next/standalone/packages/station-kit/.next/build-manifest.json +2 -2
  5. package/.next/standalone/packages/station-kit/.next/prerender-manifest.json +73 -25
  6. package/.next/standalone/packages/station-kit/.next/routes-manifest.json +20 -0
  7. package/.next/standalone/packages/station-kit/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  8. package/.next/standalone/packages/station-kit/.next/server/app/_not-found.html +1 -1
  9. package/.next/standalone/packages/station-kit/.next/server/app/_not-found.rsc +8 -8
  10. package/.next/standalone/packages/station-kit/.next/server/app/beacons/[name]/page.js +2 -0
  11. package/.next/standalone/packages/station-kit/.next/server/app/beacons/[name]/page.js.nft.json +1 -0
  12. package/.next/standalone/packages/station-kit/.next/server/app/beacons/[name]/page_client-reference-manifest.js +1 -0
  13. package/.next/standalone/packages/station-kit/.next/server/app/beacons/page.js +2 -0
  14. package/.next/standalone/packages/station-kit/.next/server/app/beacons/page.js.nft.json +1 -0
  15. package/.next/standalone/packages/station-kit/.next/server/app/beacons/page_client-reference-manifest.js +1 -0
  16. package/.next/standalone/packages/station-kit/.next/server/app/beacons.html +1 -0
  17. package/.next/standalone/packages/station-kit/.next/server/app/beacons.meta +7 -0
  18. package/.next/standalone/packages/station-kit/.next/server/app/beacons.rsc +25 -0
  19. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/[id]/page_client-reference-manifest.js +1 -1
  20. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/page_client-reference-manifest.js +1 -1
  21. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/v/[n]/page_client-reference-manifest.js +1 -1
  22. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new/page_client-reference-manifest.js +1 -1
  23. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.html +1 -1
  24. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.rsc +9 -9
  25. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/page_client-reference-manifest.js +1 -1
  26. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts.html +1 -1
  27. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts.rsc +9 -9
  28. package/.next/standalone/packages/station-kit/.next/server/app/environment/page.js +2 -0
  29. package/.next/standalone/packages/station-kit/.next/server/app/environment/page.js.nft.json +1 -0
  30. package/.next/standalone/packages/station-kit/.next/server/app/environment/page_client-reference-manifest.js +1 -0
  31. package/.next/standalone/packages/station-kit/.next/server/app/environment.html +1 -0
  32. package/.next/standalone/packages/station-kit/.next/server/app/environment.meta +7 -0
  33. package/.next/standalone/packages/station-kit/.next/server/app/environment.rsc +25 -0
  34. package/.next/standalone/packages/station-kit/.next/server/app/index.html +1 -1
  35. package/.next/standalone/packages/station-kit/.next/server/app/index.rsc +9 -9
  36. package/.next/standalone/packages/station-kit/.next/server/app/page_client-reference-manifest.js +1 -1
  37. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression/page_client-reference-manifest.js +1 -1
  38. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.html +1 -1
  39. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.rsc +9 -9
  40. package/.next/standalone/packages/station-kit/.next/server/app/runs/[id]/page_client-reference-manifest.js +1 -1
  41. package/.next/standalone/packages/station-kit/.next/server/app/schedules/[id]/page_client-reference-manifest.js +1 -1
  42. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new/page_client-reference-manifest.js +1 -1
  43. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.html +1 -1
  44. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.rsc +9 -9
  45. package/.next/standalone/packages/station-kit/.next/server/app/schedules/page_client-reference-manifest.js +1 -1
  46. package/.next/standalone/packages/station-kit/.next/server/app/schedules.html +1 -1
  47. package/.next/standalone/packages/station-kit/.next/server/app/schedules.rsc +9 -9
  48. package/.next/standalone/packages/station-kit/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  49. package/.next/standalone/packages/station-kit/.next/server/app/settings.html +1 -1
  50. package/.next/standalone/packages/station-kit/.next/server/app/settings.rsc +9 -9
  51. package/.next/standalone/packages/station-kit/.next/server/app/signals/[name]/page_client-reference-manifest.js +1 -1
  52. package/.next/standalone/packages/station-kit/.next/server/app/signals/page_client-reference-manifest.js +1 -1
  53. package/.next/standalone/packages/station-kit/.next/server/app/signals.html +1 -1
  54. package/.next/standalone/packages/station-kit/.next/server/app/signals.rsc +9 -9
  55. package/.next/standalone/packages/station-kit/.next/server/app-paths-manifest.json +11 -8
  56. package/.next/standalone/packages/station-kit/.next/server/chunks/102.js +1 -1
  57. package/.next/standalone/packages/station-kit/.next/server/pages/404.html +1 -1
  58. package/.next/standalone/packages/station-kit/.next/server/pages/500.html +1 -1
  59. package/.next/standalone/packages/station-kit/.next/server/pages-manifest.json +1 -1
  60. package/.next/standalone/packages/station-kit/.next/server/server-reference-manifest.json +1 -1
  61. package/.next/standalone/packages/station-kit/.next/static/chunks/285-583f23ee7827983f.js +1 -0
  62. package/.next/standalone/packages/station-kit/.next/static/chunks/561-05a7ef9669e3921a.js +1 -0
  63. package/.next/standalone/packages/station-kit/.next/static/chunks/app/beacons/[name]/page-eb1f82847734e145.js +1 -0
  64. package/.next/standalone/packages/station-kit/.next/static/chunks/app/beacons/page-64e296346197e44b.js +1 -0
  65. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/dyn/[name]/v/[n]/page-15edf07ab26241c9.js +1 -0
  66. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/page-b23a03097c8f75c4.js +1 -0
  67. package/.next/standalone/packages/station-kit/.next/static/chunks/app/environment/page-111242bec067fdee.js +1 -0
  68. package/.next/standalone/packages/station-kit/.next/static/chunks/app/layout-e9ac56fcaf7a5ea7.js +1 -0
  69. package/.next/standalone/packages/station-kit/.next/static/chunks/app/page-ef78692958c2fc01.js +1 -0
  70. package/.next/standalone/packages/station-kit/.next/static/chunks/app/playground/expression/page-29294a705dd8f471.js +1 -0
  71. package/.next/standalone/packages/station-kit/.next/static/chunks/app/runs/[id]/page-287bbb8f6dec2e23.js +1 -0
  72. package/.next/standalone/packages/station-kit/.next/static/chunks/app/schedules/page-c869218da203e3b4.js +1 -0
  73. package/.next/standalone/packages/station-kit/.next/static/chunks/app/settings/page-05cf43b6ae6b3187.js +1 -0
  74. package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/[name]/page-4027803be00c695a.js +1 -0
  75. package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/page-8e076fc5d0188d72.js +1 -0
  76. package/.next/standalone/packages/station-kit/.next/static/css/ddb1646dc25ed100.css +1 -0
  77. package/.next/standalone/packages/station-kit/.next/static/vTGEzSqxhR_E57YAFn9DC/_buildManifest.js +1 -0
  78. package/.next/standalone/packages/station-kit/package.json +4 -1
  79. package/dist/config/schema.d.ts +18 -0
  80. package/dist/config/schema.d.ts.map +1 -1
  81. package/dist/config/schema.js +3 -0
  82. package/dist/config/schema.js.map +1 -1
  83. package/dist/index.d.ts +1 -0
  84. package/dist/index.d.ts.map +1 -1
  85. package/dist/index.js +3 -0
  86. package/dist/index.js.map +1 -1
  87. package/dist/server/auth/keys.d.ts +18 -1
  88. package/dist/server/auth/keys.d.ts.map +1 -1
  89. package/dist/server/auth/keys.js +38 -6
  90. package/dist/server/auth/keys.js.map +1 -1
  91. package/dist/server/auth/session.d.ts.map +1 -1
  92. package/dist/server/auth/session.js +34 -11
  93. package/dist/server/auth/session.js.map +1 -1
  94. package/dist/server/index.d.ts.map +1 -1
  95. package/dist/server/index.js +135 -18
  96. package/dist/server/index.js.map +1 -1
  97. package/dist/server/metadata.d.ts +2 -0
  98. package/dist/server/metadata.d.ts.map +1 -1
  99. package/dist/server/metadata.js.map +1 -1
  100. package/dist/server/middleware/rate-limit.d.ts.map +1 -1
  101. package/dist/server/middleware/rate-limit.js +22 -1
  102. package/dist/server/middleware/rate-limit.js.map +1 -1
  103. package/dist/server/routes/beacons.d.ts +10 -0
  104. package/dist/server/routes/beacons.d.ts.map +1 -0
  105. package/dist/server/routes/beacons.js +98 -0
  106. package/dist/server/routes/beacons.js.map +1 -0
  107. package/dist/server/routes/broadcasts.d.ts.map +1 -1
  108. package/dist/server/routes/broadcasts.js +6 -2
  109. package/dist/server/routes/broadcasts.js.map +1 -1
  110. package/dist/server/routes/runs.d.ts.map +1 -1
  111. package/dist/server/routes/runs.js +19 -59
  112. package/dist/server/routes/runs.js.map +1 -1
  113. package/dist/server/routes/signals.d.ts.map +1 -1
  114. package/dist/server/routes/signals.js +17 -13
  115. package/dist/server/routes/signals.js.map +1 -1
  116. package/dist/server/routes/v1/env.d.ts +10 -0
  117. package/dist/server/routes/v1/env.d.ts.map +1 -0
  118. package/dist/server/routes/v1/env.js +132 -0
  119. package/dist/server/routes/v1/env.js.map +1 -0
  120. package/dist/server/routes/v1/events.js +2 -2
  121. package/dist/server/routes/v1/events.js.map +1 -1
  122. package/dist/server/routes/v1/runs.d.ts.map +1 -1
  123. package/dist/server/routes/v1/runs.js +10 -31
  124. package/dist/server/routes/v1/runs.js.map +1 -1
  125. package/dist/server/sse.d.ts +2 -1
  126. package/dist/server/sse.d.ts.map +1 -1
  127. package/dist/server/sse.js +5 -1
  128. package/dist/server/sse.js.map +1 -1
  129. package/dist/server/subscriber.d.ts +56 -0
  130. package/dist/server/subscriber.d.ts.map +1 -1
  131. package/dist/server/subscriber.js +94 -0
  132. package/dist/server/subscriber.js.map +1 -1
  133. package/dist/server/ws.d.ts +8 -2
  134. package/dist/server/ws.d.ts.map +1 -1
  135. package/dist/server/ws.js +28 -3
  136. package/dist/server/ws.js.map +1 -1
  137. package/package.json +11 -8
  138. package/src/app/beacons/[name]/beacon-detail.tsx +253 -0
  139. package/src/app/beacons/[name]/page.tsx +6 -0
  140. package/src/app/beacons/page.tsx +98 -0
  141. package/src/app/components/shell.tsx +24 -0
  142. package/src/app/components/status-badge.tsx +13 -1
  143. package/src/app/environment/page.tsx +415 -0
  144. package/src/app/globals.css +48 -0
  145. package/src/app/hooks/use-api.ts +80 -0
  146. package/src/config/schema.ts +21 -0
  147. package/src/index.ts +12 -0
  148. package/src/server/auth/keys.ts +50 -5
  149. package/src/server/auth/session.ts +38 -11
  150. package/src/server/index.ts +144 -18
  151. package/src/server/metadata.ts +2 -0
  152. package/src/server/middleware/rate-limit.ts +20 -1
  153. package/src/server/routes/beacons.ts +112 -0
  154. package/src/server/routes/broadcasts.ts +6 -2
  155. package/src/server/routes/runs.ts +20 -67
  156. package/src/server/routes/signals.ts +17 -13
  157. package/src/server/routes/v1/env.ts +144 -0
  158. package/src/server/routes/v1/events.ts +2 -2
  159. package/src/server/routes/v1/runs.ts +11 -35
  160. package/src/server/sse.ts +7 -2
  161. package/src/server/subscriber.ts +111 -0
  162. package/src/server/ws.ts +38 -4
  163. package/.next/standalone/packages/station-kit/.next/static/THKSkCipW_pj0F6DRXYEG/_buildManifest.js +0 -1
  164. package/.next/standalone/packages/station-kit/.next/static/chunks/285-ff198f0a909c4fdd.js +0 -1
  165. package/.next/standalone/packages/station-kit/.next/static/chunks/561-33d912169940283e.js +0 -1
  166. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/dyn/[name]/v/[n]/page-5eac0507f49a00ec.js +0 -1
  167. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/page-dee500ccc01f0821.js +0 -1
  168. package/.next/standalone/packages/station-kit/.next/static/chunks/app/layout-e14e14f3e5b0b8a9.js +0 -1
  169. package/.next/standalone/packages/station-kit/.next/static/chunks/app/page-aac41ef7a470daab.js +0 -1
  170. package/.next/standalone/packages/station-kit/.next/static/chunks/app/playground/expression/page-dc9d91f3f50f4716.js +0 -1
  171. package/.next/standalone/packages/station-kit/.next/static/chunks/app/runs/[id]/page-9e4c4f751a4bea72.js +0 -1
  172. package/.next/standalone/packages/station-kit/.next/static/chunks/app/schedules/page-738d98dc0b63166e.js +0 -1
  173. package/.next/standalone/packages/station-kit/.next/static/chunks/app/settings/page-fc5654b31f57ac21.js +0 -1
  174. package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/[name]/page-4b1c09a539a1ebcd.js +0 -1
  175. package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/page-d2f2403dfede87cc.js +0 -1
  176. package/.next/standalone/packages/station-kit/.next/static/css/0be0e65a5f561f37.css +0 -1
  177. /package/.next/standalone/packages/station-kit/.next/static/{THKSkCipW_pj0F6DRXYEG → vTGEzSqxhR_E57YAFn9DC}/_ssgManifest.js +0 -0
@@ -1,29 +1,35 @@
1
1
  import { Hono } from "hono";
2
2
  import { createMiddleware } from "hono/factory";
3
+ import { bodyLimit } from "hono/body-limit";
3
4
  import { serve } from "@hono/node-server";
4
5
  import { resolve } from "node:path";
5
- import { existsSync } from "node:fs";
6
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { randomBytes } from "node:crypto";
6
8
  import type { Server } from "node:http";
7
9
  import { SignalRunner, MemoryAdapter, parseInterval } from "station-signal";
8
10
  import { BroadcastRunner, BroadcastMemoryAdapter } from "station-broadcast";
11
+ import { BeaconRunner, BeaconMemoryAdapter } from "station-beacon";
9
12
  import type { SignalQueueAdapter } from "station-signal";
10
13
  import type { BroadcastQueueAdapter } from "station-broadcast";
14
+ import type { BeaconStateAdapter } from "station-beacon";
11
15
  import {
12
16
  ScheduleReconciler,
13
17
  type Schedule,
14
18
  type ScheduleAdapter,
15
19
  } from "station-schedules";
20
+ import { EnvStore, FileEnvStorage } from "station-env";
16
21
  import type { StationConfig } from "../config/schema.js";
17
22
  import { ensureStationDir } from "../station-dir.js";
18
23
  import { WebSocketHub } from "./ws.js";
19
24
  import { SSEHub } from "./sse.js";
20
25
  import { LogBuffer } from "./log-buffer.js";
21
26
  import { LogStore, FileLogStorage } from "./log-store.js";
22
- import { StationSignalSubscriber, StationBroadcastSubscriber } from "./subscriber.js";
27
+ import { StationSignalSubscriber, StationBroadcastSubscriber, StationBeaconSubscriber } from "./subscriber.js";
23
28
  import { healthRoutes } from "./routes/health.js";
24
29
  import { signalRoutes } from "./routes/signals.js";
25
30
  import { runRoutes } from "./routes/runs.js";
26
31
  import { broadcastRoutes } from "./routes/broadcasts.js";
32
+ import { beaconRoutes } from "./routes/beacons.js";
27
33
  import { KeyStore, FileKeyStorage } from "./auth/keys.js";
28
34
  import { verifySessionToken, verifyCredentials, createSessionToken, type SessionConfig } from "./auth/session.js";
29
35
  import { authResolver } from "./middleware/auth.js";
@@ -39,6 +45,7 @@ import { v1AuthRoutes } from "./routes/v1/auth.js";
39
45
  import { v1EventRoutes } from "./routes/v1/events.js";
40
46
  import { v1DefinitionRoutes, v1DefinitionReadRoutes } from "./routes/v1/definitions.js";
41
47
  import { v1ScheduleRoutes, v1ScheduleReadRoutes } from "./routes/v1/schedules.js";
48
+ import { v1EnvRoutes, v1EnvReadRoutes } from "./routes/v1/env.js";
42
49
  import { v1ExpressionRoutes } from "./routes/v1/expressions.js";
43
50
 
44
51
  export {
@@ -78,6 +85,8 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
78
85
  const signalAdapter: SignalQueueAdapter = config.adapter ?? new MemoryAdapter();
79
86
  const broadcastAdapter: BroadcastQueueAdapter | undefined =
80
87
  config.broadcastAdapter ?? (config.broadcastsDir ? new BroadcastMemoryAdapter() : undefined);
88
+ const beaconAdapter: BeaconStateAdapter | undefined =
89
+ config.beaconAdapter ?? (config.beaconsDir ? new BeaconMemoryAdapter() : undefined);
81
90
 
82
91
  const { dataDir } = ensureStationDir(cwd, config.stationDir);
83
92
 
@@ -93,6 +102,13 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
93
102
  }),
94
103
  );
95
104
 
105
+ // Runtime env store: defaults to a JSON file so `.env()` requirements and
106
+ // dashboard-defined variables work out of the box. Pass a durable adapter
107
+ // via `envStorage` for multi-process deployments.
108
+ const envStore = new EnvStore(
109
+ config.envStorage ?? new FileEnvStorage({ filePath: resolve(dataDir, "station-env.json") }),
110
+ );
111
+
96
112
  // Auth: create KeyStore and SessionConfig if auth is configured
97
113
  let keyStore: KeyStore | undefined;
98
114
  let sessionConfig: SessionConfig | undefined;
@@ -100,7 +116,7 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
100
116
  if (config.auth) {
101
117
  const storage = config.auth.keyStorage
102
118
  ?? new FileKeyStorage({ filePath: resolve(dataDir, "station-keys.json") });
103
- keyStore = new KeyStore(storage);
119
+ keyStore = new KeyStore(storage, { pepper: resolveKeyPepper(dataDir) });
104
120
  sessionConfig = {
105
121
  username: config.auth.username,
106
122
  password: config.auth.password,
@@ -121,17 +137,26 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
121
137
  ? resolve(cwd, "broadcasts")
122
138
  : undefined;
123
139
 
140
+ const beaconsDir = config.beaconsDir
141
+ ? resolve(cwd, config.beaconsDir)
142
+ : existsSync(resolve(cwd, "beacons"))
143
+ ? resolve(cwd, "beacons")
144
+ : undefined;
145
+
124
146
  // Create subscribers (always — they collect metadata)
125
147
  const stationSignalSub = new StationSignalSubscriber(wsHub, logBuffer, logStore);
126
148
  const stationBroadcastSub = new StationBroadcastSubscriber(wsHub);
149
+ const stationBeaconSub = new StationBeaconSubscriber(wsHub, logBuffer, logStore);
127
150
 
128
151
  // Wire SSE hub into subscribers so events reach both WS and SSE clients
129
152
  stationSignalSub.setSSEHub(sseHub);
130
153
  stationBroadcastSub.setSSEHub(sseHub);
154
+ stationBeaconSub.setSSEHub(sseHub);
131
155
 
132
156
  // Create runners if enabled
133
157
  let signalRunner: SignalRunner | undefined;
134
158
  let broadcastRunner: BroadcastRunner | undefined;
159
+ let beaconRunner: BeaconRunner | undefined;
135
160
  const scheduleAdapter: ScheduleAdapter | undefined = config.scheduleAdapter;
136
161
 
137
162
  if (config.runRunners) {
@@ -158,6 +183,7 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
158
183
  retryBackoffMs: config.runner.retryBackoffMs,
159
184
  subscribers: [stationSignalSub],
160
185
  scheduleReconciler: signalScheduleReconciler,
186
+ envProvider: envStore,
161
187
  });
162
188
 
163
189
  if (broadcastsDir || broadcastAdapter) {
@@ -182,11 +208,30 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
182
208
  scheduleReconciler: broadcastScheduleReconciler,
183
209
  });
184
210
  }
211
+
212
+ if (beaconsDir || beaconAdapter) {
213
+ beaconRunner = new BeaconRunner({
214
+ beaconsDir,
215
+ adapter: beaconAdapter ?? new BeaconMemoryAdapter(),
216
+ signalRunner, // beacons can trigger signals into the shared queue
217
+ subscribers: [stationBeaconSub],
218
+ envProvider: envStore,
219
+ });
220
+ }
185
221
  }
186
222
 
187
223
  // Build Hono app
188
224
  const app = new Hono();
189
225
 
226
+ // Bound request bodies so oversized payloads can't be parsed/stored.
227
+ app.use("/api/*", bodyLimit({
228
+ maxSize: 5 * 1024 * 1024,
229
+ onError: (c) => c.json({ error: "payload_too_large", message: "Request body too large." }, 413),
230
+ }));
231
+
232
+ // Brute-force protection for the dashboard login (the v1 login is limited below).
233
+ app.use("/api/auth/login", rateLimiter({ windowMs: 60_000, max: 10 }));
234
+
190
235
  // ── Dashboard auth routes (always accessible) ──────────────────────
191
236
  app.get("/api/auth/check", async (c) => {
192
237
  if (!sessionConfig) {
@@ -248,15 +293,18 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
248
293
  app.route("/api", signalRoutes({ signalRunner, signalAdapter, signalSubscriber: stationSignalSub }));
249
294
  app.route("/api", runRoutes({ signalRunner, signalAdapter, logBuffer, logStore, signalSubscriber: stationSignalSub }));
250
295
  app.route("/api", broadcastRoutes({ broadcastRunner, broadcastAdapter, broadcastSubscriber: stationBroadcastSub, logBuffer, logStore }));
296
+ app.route("/api", beaconRoutes({ beaconRunner, beaconAdapter, logBuffer, logStore }));
251
297
 
252
298
  // ── v1 API routes (authenticated) ──────────────────────────────────
253
299
 
254
300
  // Public v1 routes (no auth required)
255
301
  app.route("/api/v1", v1HealthRoutes({ signalAdapter, broadcastAdapter }));
256
302
 
257
- // Auth routes: public but rate-limited to prevent brute force
303
+ // Auth routes: public but rate-limited to prevent brute force. The limiter
304
+ // is scoped to /auth/* — a "/*" limiter here would run for every /api/v1
305
+ // route mounted after this app and throttle the whole API to 10 req/min.
258
306
  const authApp = new Hono();
259
- authApp.use("/*", rateLimiter({ windowMs: 60_000, max: 10 }));
307
+ authApp.use("/auth/*", rateLimiter({ windowMs: 60_000, max: 10 }));
260
308
  authApp.route("/", v1AuthRoutes({ sessionConfig }));
261
309
  app.route("/api/v1", authApp);
262
310
 
@@ -264,9 +312,22 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
264
312
  const v1 = new Hono();
265
313
  v1.use("/*", authResolver({ keyStore, sessionConfig }));
266
314
 
315
+ // Hono runs a sub-app's `use("/*")` middleware for routes that sibling
316
+ // sub-apps mount LATER under the same prefix, so per-group scope guards
317
+ // stack instead of isolating (a trigger-only key would be rejected by the
318
+ // read group's guard before reaching /trigger). Attach the guard to each
319
+ // route individually instead.
320
+ const guarded = (scope: string, group: Hono): Hono => {
321
+ const out = new Hono();
322
+ const guard = requireScope(scope);
323
+ for (const r of group.routes) {
324
+ out.on(r.method, r.path, guard, r.handler);
325
+ }
326
+ return out;
327
+ };
328
+
267
329
  // Read-scope routes
268
330
  const readRoutes = new Hono();
269
- readRoutes.use("/*", requireScope("read"));
270
331
  readRoutes.route("/", v1SignalRoutes({ signalRunner, signalSubscriber: stationSignalSub }));
271
332
  readRoutes.route("/", v1RunRoutes({ signalRunner, signalAdapter, logBuffer, logStore }));
272
333
  readRoutes.route("/", v1BroadcastRoutes({ broadcastRunner, broadcastAdapter, broadcastSubscriber: stationBroadcastSub }));
@@ -274,23 +335,21 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
274
335
  readRoutes.route("/", v1ExpressionRoutes());
275
336
  // Schedule GET + preview are read-scoped; mutating routes are mounted under admin below.
276
337
  readRoutes.route("/", v1ScheduleReadRoutes({ scheduleAdapter }));
338
+ // Env GET is read-scoped (secret values redacted); mutations are admin-only below.
339
+ readRoutes.route("/", v1EnvReadRoutes({ envStore }));
277
340
  readRoutes.route("/", v1DefinitionReadRoutes({
278
341
  broadcastRunner,
279
342
  broadcastAdapter,
280
343
  signalRunner,
281
344
  signalSubscriber: stationSignalSub,
282
345
  }));
283
- v1.route("/", readRoutes);
346
+ v1.route("/", guarded("read", readRoutes));
284
347
 
285
348
  // Trigger-scope routes
286
- const triggerRoutes = new Hono();
287
- triggerRoutes.use("/*", requireScope("trigger"));
288
- triggerRoutes.route("/", v1TriggerRoutes({ signalRunner, signalAdapter, broadcastRunner, broadcastAdapter, signalSubscriber: stationSignalSub }));
289
- v1.route("/", triggerRoutes);
349
+ v1.route("/", guarded("trigger", v1TriggerRoutes({ signalRunner, signalAdapter, broadcastRunner, broadcastAdapter, signalSubscriber: stationSignalSub })));
290
350
 
291
351
  // Cancel-scope routes — only the cancel endpoints
292
352
  const cancelRoutes = new Hono();
293
- cancelRoutes.use("/*", requireScope("cancel"));
294
353
  cancelRoutes.post("/runs/:id/cancel", async (c) => {
295
354
  const id = c.req.param("id");
296
355
  if (!signalRunner) {
@@ -313,11 +372,10 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
313
372
  }
314
373
  return c.json({ data: { cancelled: true } });
315
374
  });
316
- v1.route("/", cancelRoutes);
375
+ v1.route("/", guarded("cancel", cancelRoutes));
317
376
 
318
377
  // Admin-scope routes — destructive / mutating endpoints
319
378
  const adminRoutes = new Hono();
320
- adminRoutes.use("/*", requireScope("admin"));
321
379
  adminRoutes.route("/", v1KeyRoutes({ keyStore }));
322
380
  adminRoutes.route("/", v1DefinitionRoutes({
323
381
  broadcastRunner,
@@ -326,7 +384,8 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
326
384
  signalSubscriber: stationSignalSub,
327
385
  }));
328
386
  adminRoutes.route("/", v1ScheduleRoutes({ scheduleAdapter }));
329
- v1.route("/", adminRoutes);
387
+ adminRoutes.route("/", v1EnvRoutes({ envStore }));
388
+ v1.route("/", guarded("admin", adminRoutes));
330
389
 
331
390
  app.route("/api/v1", v1);
332
391
 
@@ -384,6 +443,14 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
384
443
  keyStore,
385
444
  dataDir,
386
445
  async start() {
446
+ if (!config.auth && !isLoopbackHost(config.host)) {
447
+ console.warn(
448
+ `[station] WARNING: no auth configured while binding to ${config.host} — ` +
449
+ "anyone who can reach this port can view logs and trigger, cancel, or rerun jobs. " +
450
+ "Set auth (STATION_AUTH_USERNAME/STATION_AUTH_PASSWORD) or bind to 127.0.0.1.",
451
+ );
452
+ }
453
+
387
454
  // Start runners (non-blocking — they have internal poll loops)
388
455
  if (config.runRunners) {
389
456
  if (signalRunner) {
@@ -396,6 +463,11 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
396
463
  console.error("[station] Broadcast runner error:", err);
397
464
  });
398
465
  }
466
+ if (beaconRunner) {
467
+ beaconRunner.start().catch((err: unknown) => {
468
+ console.error("[station] Beacon runner error:", err);
469
+ });
470
+ }
399
471
  }
400
472
 
401
473
  // Start Hono server
@@ -406,12 +478,31 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
406
478
  },
407
479
  ) as unknown as Server;
408
480
 
409
- // Attach WebSocket to the HTTP server
410
- wsHub.attach(httpServer);
481
+ // Attach WebSocket to the HTTP server. The upgrade path never passes
482
+ // through Hono middleware, so it enforces auth itself: session cookie
483
+ // or a read-scoped API key, mirroring the SSE endpoint.
484
+ wsHub.attach(httpServer, async (req) => {
485
+ if (!sessionConfig && !keyStore) return true;
486
+ const cookie = req.headers.cookie;
487
+ if (cookie && sessionConfig) {
488
+ const match = cookie.match(/station_session=([^;]+)/);
489
+ if (match && verifySessionToken(match[1], sessionConfig)) return true;
490
+ }
491
+ const authHeader = req.headers.authorization;
492
+ if (authHeader?.startsWith("Bearer ") && keyStore) {
493
+ const key = await keyStore.verify(authHeader.slice(7));
494
+ if (key?.scopes.includes("read")) return true;
495
+ }
496
+ return false;
497
+ });
411
498
  },
412
499
 
413
500
  async stop() {
414
- // Stop broadcast runner first — it queries the DB during graceful shutdown
501
+ // Stop beacons first — they are producers that may trigger signals.
502
+ if (beaconRunner) {
503
+ await beaconRunner.stop({ graceful: true, timeoutMs: 5000 });
504
+ }
505
+ // Stop broadcast runner next — it queries the DB during graceful shutdown
415
506
  if (broadcastRunner) {
416
507
  await broadcastRunner.stop({ graceful: true, timeoutMs: 5000 });
417
508
  }
@@ -422,6 +513,7 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
422
513
  sseHub.close();
423
514
  await logStore.close();
424
515
  await keyStore?.close();
516
+ await envStore.close();
425
517
  if (httpServer) {
426
518
  httpServer.close();
427
519
  }
@@ -434,6 +526,40 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
434
526
  // new defaults are `station-keys.json` and `station-logs.jsonl`; the
435
527
  // legacy files are NOT auto-migrated. Emit a one-time warning so an
436
528
  // upgrade doesn't silently appear to wipe a user's API keys.
529
+ function isLoopbackHost(host: string): boolean {
530
+ return host === "127.0.0.1" || host === "localhost" || host === "::1" || host === "[::1]";
531
+ }
532
+
533
+ /**
534
+ * Resolve the API-key pepper (a server-side secret mixed into stored key
535
+ * hashes). Priority: STATION_KEY_PEPPER env var, else a persisted per-install
536
+ * secret at `<dataDir>/station-key-pepper` (generated with 0600 on first run).
537
+ * Persisting it means existing peppered keys keep verifying across restarts.
538
+ */
539
+ function resolveKeyPepper(dataDir: string): string {
540
+ const fromEnv = process.env.STATION_KEY_PEPPER;
541
+ if (fromEnv) return fromEnv;
542
+
543
+ const pepperPath = resolve(dataDir, "station-key-pepper");
544
+ try {
545
+ if (existsSync(pepperPath)) {
546
+ const existing = readFileSync(pepperPath, "utf8").trim();
547
+ if (existing) return existing;
548
+ }
549
+ } catch {
550
+ // Unreadable — fall through and regenerate.
551
+ }
552
+ const pepper = randomBytes(32).toString("hex");
553
+ try {
554
+ writeFileSync(pepperPath, pepper, { mode: 0o600 });
555
+ } catch (err) {
556
+ // If we can't persist it, warn: keys created this run won't verify after
557
+ // a restart (a new pepper would be generated). Better than failing boot.
558
+ console.warn(`[station] Could not persist key pepper to ${pepperPath}:`, err);
559
+ }
560
+ return pepper;
561
+ }
562
+
437
563
  function warnIfLegacySqliteFiles(dataDir: string): void {
438
564
  const legacy: { file: string; replacement: string }[] = [
439
565
  { file: "station-keys.db", replacement: "station-keys.json" },
@@ -24,6 +24,8 @@ export interface SignalMeta {
24
24
  maxConcurrency: number | null;
25
25
  hasSteps: boolean;
26
26
  stepNames: string[];
27
+ /** Env var keys this signal requires (declared via `.env()`). */
28
+ requiredEnv: string[];
27
29
  }
28
30
 
29
31
  export interface BroadcastMeta {
@@ -15,11 +15,20 @@ interface BucketEntry {
15
15
  resetAt: number;
16
16
  }
17
17
 
18
+ /** Upper bound on tracked buckets so unique keys can't grow the map unbounded. */
19
+ const MAX_BUCKETS = 10_000;
20
+
18
21
  export function rateLimiter(options: RateLimitOptions = {}) {
19
22
  const windowMs = options.windowMs ?? 60_000;
20
23
  const max = options.max ?? 100;
21
24
  const keyFn = options.keyFn ?? ((c: Context) => {
22
- return (c.get("apiKeyId") as string) ?? c.req.header("x-forwarded-for") ?? "anonymous";
25
+ const apiKeyId = c.get("apiKeyId") as string | undefined;
26
+ if (apiKeyId) return apiKeyId;
27
+ // Key on the socket's address, NOT x-forwarded-for: that header is
28
+ // client-controlled, so keying on it lets an attacker mint a fresh
29
+ // bucket per request and bypass the limit entirely.
30
+ const incoming = (c.env as { incoming?: { socket?: { remoteAddress?: string } } })?.incoming;
31
+ return incoming?.socket?.remoteAddress ?? "anonymous";
23
32
  });
24
33
 
25
34
  const buckets = new Map<string, BucketEntry>();
@@ -39,6 +48,16 @@ export function rateLimiter(options: RateLimitOptions = {}) {
39
48
 
40
49
  let entry = buckets.get(key);
41
50
  if (!entry || now > entry.resetAt) {
51
+ if (!entry && buckets.size >= MAX_BUCKETS) {
52
+ // Evict expired entries first; if everything is live, drop the oldest.
53
+ for (const [k, e] of buckets) {
54
+ if (now > e.resetAt) buckets.delete(k);
55
+ }
56
+ if (buckets.size >= MAX_BUCKETS) {
57
+ const oldest = buckets.keys().next().value;
58
+ if (oldest !== undefined) buckets.delete(oldest);
59
+ }
60
+ }
42
61
  entry = { count: 0, resetAt: now + windowMs };
43
62
  buckets.set(key, entry);
44
63
  }
@@ -0,0 +1,112 @@
1
+ import { Hono } from "hono";
2
+ import type { BeaconRunner, BeaconStateAdapter, BeaconInstance } from "station-beacon";
3
+ import { beaconLogKey } from "../subscriber.js";
4
+
5
+ export interface BeaconDeps {
6
+ beaconRunner?: BeaconRunner;
7
+ beaconAdapter?: BeaconStateAdapter;
8
+ logBuffer?: import("../log-buffer.js").LogBuffer;
9
+ logStore?: import("../log-store.js").LogStore;
10
+ }
11
+
12
+ function serializeInstance(inst: BeaconInstance): Record<string, unknown> {
13
+ return {
14
+ ...inst,
15
+ startedAt: inst.startedAt?.toISOString?.() ?? inst.startedAt,
16
+ readyAt: inst.readyAt?.toISOString?.() ?? inst.readyAt,
17
+ lastHeartbeatAt: inst.lastHeartbeatAt?.toISOString?.() ?? inst.lastHeartbeatAt,
18
+ lastExitAt: inst.lastExitAt?.toISOString?.() ?? inst.lastExitAt,
19
+ nextRestartAt: inst.nextRestartAt?.toISOString?.() ?? inst.nextRestartAt,
20
+ createdAt: inst.createdAt?.toISOString?.() ?? inst.createdAt,
21
+ updatedAt: inst.updatedAt?.toISOString?.() ?? inst.updatedAt,
22
+ };
23
+ }
24
+
25
+ export function beaconRoutes(deps: BeaconDeps) {
26
+ const app = new Hono();
27
+
28
+ // GET /beacons — list registered beacons merged with their instance state
29
+ app.get("/beacons", async (c) => {
30
+ if (!deps.beaconRunner) return c.json({ data: [] });
31
+ const registered = deps.beaconRunner.listRegistered();
32
+ const instances = await deps.beaconRunner.listInstances();
33
+ const byName = new Map(instances.map((i) => [i.beaconName, i]));
34
+ const data = registered.map((r) => {
35
+ const inst = byName.get(r.name);
36
+ return { ...r, instance: inst ? serializeInstance(inst) : null };
37
+ });
38
+ return c.json({ data });
39
+ });
40
+
41
+ // GET /beacons/:name — registered metadata + current instance
42
+ app.get("/beacons/:name", async (c) => {
43
+ const name = c.req.param("name");
44
+ if (!deps.beaconRunner) {
45
+ return c.json({ error: "not_found", message: "No beacon runner configured." }, 404);
46
+ }
47
+ const meta = deps.beaconRunner.listRegistered().find((b) => b.name === name);
48
+ if (!meta) {
49
+ return c.json({ error: "not_found", message: `Beacon "${name}" not found.` }, 404);
50
+ }
51
+ const inst = await deps.beaconRunner.getInstance(name);
52
+ return c.json({ data: { ...meta, instance: inst ? serializeInstance(inst) : null } });
53
+ });
54
+
55
+ // GET /beacons/:name/events — lifecycle event log (if the adapter records one)
56
+ app.get("/beacons/:name/events", async (c) => {
57
+ const name = c.req.param("name");
58
+ // Clamp so a client can't request an unbounded (or NaN) event scan.
59
+ const raw = Number(c.req.query("limit") ?? "200");
60
+ const limit = Number.isFinite(raw) ? Math.min(Math.max(Math.trunc(raw), 1), 1000) : 200;
61
+ if (!deps.beaconAdapter?.listEvents) return c.json({ data: [] });
62
+ const events = await deps.beaconAdapter.listEvents(name, limit);
63
+ return c.json({
64
+ data: events.map((e) => ({ ...e, at: e.at?.toISOString?.() ?? e.at })),
65
+ });
66
+ });
67
+
68
+ // GET /beacons/:name/logs — captured stdout/stderr/log lines
69
+ app.get("/beacons/:name/logs", async (c) => {
70
+ const name = c.req.param("name");
71
+ const key = beaconLogKey(name);
72
+ const logs = deps.logStore
73
+ ? await deps.logStore.get(key)
74
+ : deps.logBuffer?.get(key) ?? [];
75
+ return c.json({ data: logs });
76
+ });
77
+
78
+ // POST /beacons/:name/{start,stop,restart} — operator controls
79
+ app.post("/beacons/:name/start", async (c) => {
80
+ const name = c.req.param("name");
81
+ if (!deps.beaconRunner) {
82
+ return c.json({ error: "read_only", message: "Station is in read-only mode." }, 403);
83
+ }
84
+ const body = await c.req.json().catch(() => ({}));
85
+ try {
86
+ await deps.beaconRunner.startBeacon(name, "config" in body ? { config: body.config } : undefined);
87
+ return c.json({ data: { started: true } });
88
+ } catch (err: unknown) {
89
+ return c.json({ error: "start_failed", message: err instanceof Error ? err.message : String(err) }, 400);
90
+ }
91
+ });
92
+
93
+ app.post("/beacons/:name/stop", async (c) => {
94
+ const name = c.req.param("name");
95
+ if (!deps.beaconRunner) {
96
+ return c.json({ error: "read_only", message: "Station is in read-only mode." }, 403);
97
+ }
98
+ await deps.beaconRunner.stopBeacon(name);
99
+ return c.json({ data: { stopped: true } });
100
+ });
101
+
102
+ app.post("/beacons/:name/restart", async (c) => {
103
+ const name = c.req.param("name");
104
+ if (!deps.beaconRunner) {
105
+ return c.json({ error: "read_only", message: "Station is in read-only mode." }, 403);
106
+ }
107
+ await deps.beaconRunner.restartBeacon(name);
108
+ return c.json({ data: { restarted: true } });
109
+ });
110
+
111
+ return app;
112
+ }
@@ -77,10 +77,14 @@ export function broadcastRoutes(deps: BroadcastDeps) {
77
77
  if (!deps.broadcastAdapter) {
78
78
  return c.json({ data: [], meta: { total: 0 } });
79
79
  }
80
- const runs = await deps.broadcastAdapter.listBroadcastRuns(name);
80
+ const limitRaw = parseInt(c.req.query("limit") ?? "100", 10);
81
+ const limit = Math.min(Number.isNaN(limitRaw) ? 100 : Math.max(limitRaw, 1), 500);
82
+ const offsetRaw = parseInt(c.req.query("offset") ?? "0", 10);
83
+ const offset = Number.isNaN(offsetRaw) ? 0 : Math.max(offsetRaw, 0);
84
+ const runs = await deps.broadcastAdapter.listBroadcastRuns(name, { limit, offset });
81
85
  return c.json({
82
86
  data: runs.map(serializeBroadcastRun),
83
- meta: { total: runs.length },
87
+ meta: { count: runs.length, limit, offset },
84
88
  });
85
89
  });
86
90
 
@@ -1,5 +1,5 @@
1
1
  import { Hono } from "hono";
2
- import type { SignalRunner, SignalQueueAdapter } from "station-signal";
2
+ import type { SignalRunner, SignalQueueAdapter, Run, RunStatus } from "station-signal";
3
3
  import type { LogBuffer } from "../log-buffer.js";
4
4
  import type { LogStore } from "../log-store.js";
5
5
  import type { StationSignalSubscriber } from "../subscriber.js";
@@ -18,82 +18,28 @@ export function runRoutes(deps: RunDeps) {
18
18
  app.get("/runs", async (c) => {
19
19
  const status = c.req.query("status");
20
20
  const signalName = c.req.query("signalName");
21
+ const limit = clampInt(c.req.query("limit"), 100, 1, 500);
22
+ const offset = clampInt(c.req.query("offset"), 0, 0, Number.MAX_SAFE_INTEGER);
23
+ const statuses = status ? ([status] as RunStatus[]) : undefined;
21
24
 
22
- // Gather runs from adapter
23
- let runs: any[] = [];
24
-
25
- if (signalName) {
26
- runs = await deps.signalAdapter.listRuns(signalName);
27
- } else {
28
- // Get all runs by combining due + running + listing by known signals
29
- const due = await deps.signalAdapter.getRunsDue();
30
- const running = await deps.signalAdapter.getRunsRunning();
31
- const seen = new Set<string>();
32
- for (const r of [...due, ...running]) {
33
- if (!seen.has(r.id)) {
34
- seen.add(r.id);
35
- runs.push(r);
36
- }
37
- }
38
-
39
- // Also get runs from known signals
40
- if (deps.signalRunner) {
41
- for (const { name } of deps.signalRunner.listRegistered()) {
42
- const signalRuns = await deps.signalAdapter.listRuns(name);
43
- for (const r of signalRuns) {
44
- if (!seen.has(r.id)) {
45
- seen.add(r.id);
46
- runs.push(r);
47
- }
48
- }
49
- }
50
- }
51
- }
52
-
53
- if (status) {
54
- runs = runs.filter((r) => r.status === status);
55
- }
56
-
57
- // Sort by createdAt descending
58
- runs.sort((a, b) => {
59
- const aTime = a.createdAt instanceof Date ? a.createdAt.getTime() : new Date(a.createdAt).getTime();
60
- const bTime = b.createdAt instanceof Date ? b.createdAt.getTime() : new Date(b.createdAt).getTime();
61
- return bTime - aTime;
62
- });
25
+ // Push filtering, ordering, and limiting into the adapter instead of
26
+ // loading every signal's full history and sorting in memory.
27
+ const runs: Run[] = signalName
28
+ ? await deps.signalAdapter.listRuns(signalName, { limit, offset, statuses })
29
+ : await deps.signalAdapter.listAllRuns({ limit, offset, statuses });
63
30
 
64
31
  return c.json({
65
32
  data: runs.map(serializeRun),
66
- meta: { total: runs.length },
33
+ meta: { count: runs.length, limit, offset },
67
34
  });
68
35
  });
69
36
 
70
37
  app.get("/runs/stats", async (c) => {
71
- const due = await deps.signalAdapter.getRunsDue();
72
- const running = await deps.signalAdapter.getRunsRunning();
73
-
74
- // Aggregate from known signals
75
- let allRuns: any[] = [...due, ...running];
76
- const seen = new Set(allRuns.map((r) => r.id));
77
-
78
- if (deps.signalRunner) {
79
- for (const { name } of deps.signalRunner.listRegistered()) {
80
- const signalRuns = await deps.signalAdapter.listRuns(name);
81
- for (const r of signalRuns) {
82
- if (!seen.has(r.id)) {
83
- seen.add(r.id);
84
- allRuns.push(r);
85
- }
86
- }
87
- }
88
- }
89
-
38
+ const counts = await deps.signalAdapter.countRunsByStatus();
90
39
  const stats = { pending: 0, running: 0, completed: 0, failed: 0, cancelled: 0 };
91
- for (const r of allRuns) {
92
- if (r.status in stats) {
93
- stats[r.status as keyof typeof stats]++;
94
- }
40
+ for (const key of Object.keys(stats) as RunStatus[]) {
41
+ stats[key as keyof typeof stats] = counts[key] ?? 0;
95
42
  }
96
-
97
43
  return c.json({ data: stats });
98
44
  });
99
45
 
@@ -213,6 +159,13 @@ export function runRoutes(deps: RunDeps) {
213
159
  return app;
214
160
  }
215
161
 
162
+ /** Parse an integer query param, clamping to [min, max] with a fallback. */
163
+ function clampInt(raw: string | undefined, fallback: number, min: number, max: number): number {
164
+ const n = parseInt(raw ?? "", 10);
165
+ if (Number.isNaN(n)) return fallback;
166
+ return Math.min(Math.max(n, min), max);
167
+ }
168
+
216
169
  function serializeRun(run: any): Record<string, unknown> {
217
170
  return {
218
171
  ...run,