station-kit 1.0.9 → 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 (183) 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 +57 -36
  3. package/.next/standalone/packages/station-kit/.next/app-path-routes-manifest.json +13 -10
  4. package/.next/standalone/packages/station-kit/.next/build-manifest.json +2 -2
  5. package/.next/standalone/packages/station-kit/.next/prerender-manifest.json +72 -24
  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 +13 -10
  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 +6 -4
  79. package/dist/config/schema.d.ts +31 -3
  80. package/dist/config/schema.d.ts.map +1 -1
  81. package/dist/config/schema.js +4 -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 +58 -6
  88. package/dist/server/auth/keys.d.ts.map +1 -1
  89. package/dist/server/auth/keys.js +179 -14
  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 +5 -2
  95. package/dist/server/index.d.ts.map +1 -1
  96. package/dist/server/index.js +169 -24
  97. package/dist/server/index.js.map +1 -1
  98. package/dist/server/log-store.d.ts +102 -6
  99. package/dist/server/log-store.d.ts.map +1 -1
  100. package/dist/server/log-store.js +140 -32
  101. package/dist/server/log-store.js.map +1 -1
  102. package/dist/server/metadata.d.ts +2 -0
  103. package/dist/server/metadata.d.ts.map +1 -1
  104. package/dist/server/metadata.js.map +1 -1
  105. package/dist/server/middleware/rate-limit.d.ts.map +1 -1
  106. package/dist/server/middleware/rate-limit.js +22 -1
  107. package/dist/server/middleware/rate-limit.js.map +1 -1
  108. package/dist/server/routes/beacons.d.ts +10 -0
  109. package/dist/server/routes/beacons.d.ts.map +1 -0
  110. package/dist/server/routes/beacons.js +98 -0
  111. package/dist/server/routes/beacons.js.map +1 -0
  112. package/dist/server/routes/broadcasts.d.ts.map +1 -1
  113. package/dist/server/routes/broadcasts.js +9 -3
  114. package/dist/server/routes/broadcasts.js.map +1 -1
  115. package/dist/server/routes/runs.d.ts.map +1 -1
  116. package/dist/server/routes/runs.js +20 -60
  117. package/dist/server/routes/runs.js.map +1 -1
  118. package/dist/server/routes/signals.d.ts.map +1 -1
  119. package/dist/server/routes/signals.js +17 -13
  120. package/dist/server/routes/signals.js.map +1 -1
  121. package/dist/server/routes/v1/env.d.ts +10 -0
  122. package/dist/server/routes/v1/env.d.ts.map +1 -0
  123. package/dist/server/routes/v1/env.js +132 -0
  124. package/dist/server/routes/v1/env.js.map +1 -0
  125. package/dist/server/routes/v1/events.js +2 -2
  126. package/dist/server/routes/v1/events.js.map +1 -1
  127. package/dist/server/routes/v1/runs.d.ts.map +1 -1
  128. package/dist/server/routes/v1/runs.js +11 -32
  129. package/dist/server/routes/v1/runs.js.map +1 -1
  130. package/dist/server/sse.d.ts +2 -1
  131. package/dist/server/sse.d.ts.map +1 -1
  132. package/dist/server/sse.js +5 -1
  133. package/dist/server/sse.js.map +1 -1
  134. package/dist/server/subscriber.d.ts +56 -0
  135. package/dist/server/subscriber.d.ts.map +1 -1
  136. package/dist/server/subscriber.js +94 -0
  137. package/dist/server/subscriber.js.map +1 -1
  138. package/dist/server/ws.d.ts +8 -2
  139. package/dist/server/ws.d.ts.map +1 -1
  140. package/dist/server/ws.js +28 -3
  141. package/dist/server/ws.js.map +1 -1
  142. package/package.json +13 -11
  143. package/src/app/beacons/[name]/beacon-detail.tsx +253 -0
  144. package/src/app/beacons/[name]/page.tsx +6 -0
  145. package/src/app/beacons/page.tsx +98 -0
  146. package/src/app/components/shell.tsx +24 -0
  147. package/src/app/components/status-badge.tsx +13 -1
  148. package/src/app/environment/page.tsx +415 -0
  149. package/src/app/globals.css +48 -0
  150. package/src/app/hooks/use-api.ts +80 -0
  151. package/src/config/schema.ts +35 -3
  152. package/src/index.ts +12 -0
  153. package/src/server/auth/keys.ts +215 -15
  154. package/src/server/auth/session.ts +38 -11
  155. package/src/server/index.ts +192 -23
  156. package/src/server/log-store.ts +196 -45
  157. package/src/server/metadata.ts +2 -0
  158. package/src/server/middleware/rate-limit.ts +20 -1
  159. package/src/server/routes/beacons.ts +112 -0
  160. package/src/server/routes/broadcasts.ts +9 -3
  161. package/src/server/routes/runs.ts +21 -68
  162. package/src/server/routes/signals.ts +17 -13
  163. package/src/server/routes/v1/env.ts +144 -0
  164. package/src/server/routes/v1/events.ts +2 -2
  165. package/src/server/routes/v1/runs.ts +12 -36
  166. package/src/server/sse.ts +7 -2
  167. package/src/server/subscriber.ts +111 -0
  168. package/src/server/ws.ts +38 -4
  169. package/.next/standalone/packages/station-kit/.next/static/chunks/285-ff198f0a909c4fdd.js +0 -1
  170. package/.next/standalone/packages/station-kit/.next/static/chunks/561-33d912169940283e.js +0 -1
  171. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/dyn/[name]/v/[n]/page-5eac0507f49a00ec.js +0 -1
  172. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/page-dee500ccc01f0821.js +0 -1
  173. package/.next/standalone/packages/station-kit/.next/static/chunks/app/layout-e14e14f3e5b0b8a9.js +0 -1
  174. package/.next/standalone/packages/station-kit/.next/static/chunks/app/page-aac41ef7a470daab.js +0 -1
  175. package/.next/standalone/packages/station-kit/.next/static/chunks/app/playground/expression/page-dc9d91f3f50f4716.js +0 -1
  176. package/.next/standalone/packages/station-kit/.next/static/chunks/app/runs/[id]/page-9e4c4f751a4bea72.js +0 -1
  177. package/.next/standalone/packages/station-kit/.next/static/chunks/app/schedules/page-738d98dc0b63166e.js +0 -1
  178. package/.next/standalone/packages/station-kit/.next/static/chunks/app/settings/page-fc5654b31f57ac21.js +0 -1
  179. package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/[name]/page-4b1c09a539a1ebcd.js +0 -1
  180. package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/page-d2f2403dfede87cc.js +0 -1
  181. package/.next/standalone/packages/station-kit/.next/static/css/0be0e65a5f561f37.css +0 -1
  182. package/.next/standalone/packages/station-kit/.next/static/demLiQWDy62JuUkBw-ILG/_buildManifest.js +0 -1
  183. /package/.next/standalone/packages/station-kit/.next/static/{demLiQWDy62JuUkBw-ILG → vTGEzSqxhR_E57YAFn9DC}/_ssgManifest.js +0 -0
@@ -1,30 +1,36 @@
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
- import { LogStore } from "./log-store.js";
22
- import { StationSignalSubscriber, StationBroadcastSubscriber } from "./subscriber.js";
26
+ import { LogStore, FileLogStorage } from "./log-store.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";
27
- import { KeyStore, SqliteKeyStorage } from "./auth/keys.js";
32
+ import { beaconRoutes } from "./routes/beacons.js";
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";
30
36
  import { requireScope } from "./middleware/scope-guard.js";
@@ -39,10 +45,12 @@ 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 {
45
52
  KeyStore,
53
+ FileKeyStorage,
46
54
  SqliteKeyStorage,
47
55
  MemoryKeyStorage,
48
56
  } from "./auth/keys.js";
@@ -50,8 +58,19 @@ export type {
50
58
  ApiKey,
51
59
  ApiKeyPublic,
52
60
  ApiKeyStorageAdapter,
61
+ FileKeyStorageOptions,
53
62
  SqliteKeyStorageOptions,
54
63
  } from "./auth/keys.js";
64
+ export {
65
+ LogStore,
66
+ FileLogStorage,
67
+ MemoryLogStorage,
68
+ } from "./log-store.js";
69
+ export type {
70
+ LogStorageAdapter,
71
+ FileLogStorageOptions,
72
+ } from "./log-store.js";
73
+ export type { LogEntry } from "./log-buffer.js";
55
74
 
56
75
  export interface StationInstance {
57
76
  start(): Promise<void>;
@@ -66,13 +85,29 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
66
85
  const signalAdapter: SignalQueueAdapter = config.adapter ?? new MemoryAdapter();
67
86
  const broadcastAdapter: BroadcastQueueAdapter | undefined =
68
87
  config.broadcastAdapter ?? (config.broadcastsDir ? new BroadcastMemoryAdapter() : undefined);
88
+ const beaconAdapter: BeaconStateAdapter | undefined =
89
+ config.beaconAdapter ?? (config.beaconsDir ? new BeaconMemoryAdapter() : undefined);
69
90
 
70
91
  const { dataDir } = ensureStationDir(cwd, config.stationDir);
71
92
 
93
+ warnIfLegacySqliteFiles(dataDir);
94
+
72
95
  const wsHub = new WebSocketHub();
73
96
  const sseHub = new SSEHub();
74
97
  const logBuffer = new LogBuffer();
75
- const logStore = new LogStore(resolve(dataDir, "station-logs.db"));
98
+ const logStore = new LogStore(
99
+ config.logStorage ?? new FileLogStorage({
100
+ filePath: resolve(dataDir, "station-logs.jsonl"),
101
+ onError: (err) => console.error("[station] log write failed:", err),
102
+ }),
103
+ );
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
+ );
76
111
 
77
112
  // Auth: create KeyStore and SessionConfig if auth is configured
78
113
  let keyStore: KeyStore | undefined;
@@ -80,8 +115,8 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
80
115
 
81
116
  if (config.auth) {
82
117
  const storage = config.auth.keyStorage
83
- ?? new SqliteKeyStorage({ dbPath: resolve(dataDir, "station-keys.db") });
84
- keyStore = new KeyStore(storage);
118
+ ?? new FileKeyStorage({ filePath: resolve(dataDir, "station-keys.json") });
119
+ keyStore = new KeyStore(storage, { pepper: resolveKeyPepper(dataDir) });
85
120
  sessionConfig = {
86
121
  username: config.auth.username,
87
122
  password: config.auth.password,
@@ -102,17 +137,26 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
102
137
  ? resolve(cwd, "broadcasts")
103
138
  : undefined;
104
139
 
140
+ const beaconsDir = config.beaconsDir
141
+ ? resolve(cwd, config.beaconsDir)
142
+ : existsSync(resolve(cwd, "beacons"))
143
+ ? resolve(cwd, "beacons")
144
+ : undefined;
145
+
105
146
  // Create subscribers (always — they collect metadata)
106
147
  const stationSignalSub = new StationSignalSubscriber(wsHub, logBuffer, logStore);
107
148
  const stationBroadcastSub = new StationBroadcastSubscriber(wsHub);
149
+ const stationBeaconSub = new StationBeaconSubscriber(wsHub, logBuffer, logStore);
108
150
 
109
151
  // Wire SSE hub into subscribers so events reach both WS and SSE clients
110
152
  stationSignalSub.setSSEHub(sseHub);
111
153
  stationBroadcastSub.setSSEHub(sseHub);
154
+ stationBeaconSub.setSSEHub(sseHub);
112
155
 
113
156
  // Create runners if enabled
114
157
  let signalRunner: SignalRunner | undefined;
115
158
  let broadcastRunner: BroadcastRunner | undefined;
159
+ let beaconRunner: BeaconRunner | undefined;
116
160
  const scheduleAdapter: ScheduleAdapter | undefined = config.scheduleAdapter;
117
161
 
118
162
  if (config.runRunners) {
@@ -139,6 +183,7 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
139
183
  retryBackoffMs: config.runner.retryBackoffMs,
140
184
  subscribers: [stationSignalSub],
141
185
  scheduleReconciler: signalScheduleReconciler,
186
+ envProvider: envStore,
142
187
  });
143
188
 
144
189
  if (broadcastsDir || broadcastAdapter) {
@@ -163,11 +208,30 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
163
208
  scheduleReconciler: broadcastScheduleReconciler,
164
209
  });
165
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
+ }
166
221
  }
167
222
 
168
223
  // Build Hono app
169
224
  const app = new Hono();
170
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
+
171
235
  // ── Dashboard auth routes (always accessible) ──────────────────────
172
236
  app.get("/api/auth/check", async (c) => {
173
237
  if (!sessionConfig) {
@@ -229,15 +293,18 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
229
293
  app.route("/api", signalRoutes({ signalRunner, signalAdapter, signalSubscriber: stationSignalSub }));
230
294
  app.route("/api", runRoutes({ signalRunner, signalAdapter, logBuffer, logStore, signalSubscriber: stationSignalSub }));
231
295
  app.route("/api", broadcastRoutes({ broadcastRunner, broadcastAdapter, broadcastSubscriber: stationBroadcastSub, logBuffer, logStore }));
296
+ app.route("/api", beaconRoutes({ beaconRunner, beaconAdapter, logBuffer, logStore }));
232
297
 
233
298
  // ── v1 API routes (authenticated) ──────────────────────────────────
234
299
 
235
300
  // Public v1 routes (no auth required)
236
301
  app.route("/api/v1", v1HealthRoutes({ signalAdapter, broadcastAdapter }));
237
302
 
238
- // 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.
239
306
  const authApp = new Hono();
240
- authApp.use("/*", rateLimiter({ windowMs: 60_000, max: 10 }));
307
+ authApp.use("/auth/*", rateLimiter({ windowMs: 60_000, max: 10 }));
241
308
  authApp.route("/", v1AuthRoutes({ sessionConfig }));
242
309
  app.route("/api/v1", authApp);
243
310
 
@@ -245,9 +312,22 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
245
312
  const v1 = new Hono();
246
313
  v1.use("/*", authResolver({ keyStore, sessionConfig }));
247
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
+
248
329
  // Read-scope routes
249
330
  const readRoutes = new Hono();
250
- readRoutes.use("/*", requireScope("read"));
251
331
  readRoutes.route("/", v1SignalRoutes({ signalRunner, signalSubscriber: stationSignalSub }));
252
332
  readRoutes.route("/", v1RunRoutes({ signalRunner, signalAdapter, logBuffer, logStore }));
253
333
  readRoutes.route("/", v1BroadcastRoutes({ broadcastRunner, broadcastAdapter, broadcastSubscriber: stationBroadcastSub }));
@@ -255,23 +335,21 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
255
335
  readRoutes.route("/", v1ExpressionRoutes());
256
336
  // Schedule GET + preview are read-scoped; mutating routes are mounted under admin below.
257
337
  readRoutes.route("/", v1ScheduleReadRoutes({ scheduleAdapter }));
338
+ // Env GET is read-scoped (secret values redacted); mutations are admin-only below.
339
+ readRoutes.route("/", v1EnvReadRoutes({ envStore }));
258
340
  readRoutes.route("/", v1DefinitionReadRoutes({
259
341
  broadcastRunner,
260
342
  broadcastAdapter,
261
343
  signalRunner,
262
344
  signalSubscriber: stationSignalSub,
263
345
  }));
264
- v1.route("/", readRoutes);
346
+ v1.route("/", guarded("read", readRoutes));
265
347
 
266
348
  // Trigger-scope routes
267
- const triggerRoutes = new Hono();
268
- triggerRoutes.use("/*", requireScope("trigger"));
269
- triggerRoutes.route("/", v1TriggerRoutes({ signalRunner, signalAdapter, broadcastRunner, broadcastAdapter, signalSubscriber: stationSignalSub }));
270
- v1.route("/", triggerRoutes);
349
+ v1.route("/", guarded("trigger", v1TriggerRoutes({ signalRunner, signalAdapter, broadcastRunner, broadcastAdapter, signalSubscriber: stationSignalSub })));
271
350
 
272
351
  // Cancel-scope routes — only the cancel endpoints
273
352
  const cancelRoutes = new Hono();
274
- cancelRoutes.use("/*", requireScope("cancel"));
275
353
  cancelRoutes.post("/runs/:id/cancel", async (c) => {
276
354
  const id = c.req.param("id");
277
355
  if (!signalRunner) {
@@ -294,11 +372,10 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
294
372
  }
295
373
  return c.json({ data: { cancelled: true } });
296
374
  });
297
- v1.route("/", cancelRoutes);
375
+ v1.route("/", guarded("cancel", cancelRoutes));
298
376
 
299
377
  // Admin-scope routes — destructive / mutating endpoints
300
378
  const adminRoutes = new Hono();
301
- adminRoutes.use("/*", requireScope("admin"));
302
379
  adminRoutes.route("/", v1KeyRoutes({ keyStore }));
303
380
  adminRoutes.route("/", v1DefinitionRoutes({
304
381
  broadcastRunner,
@@ -307,7 +384,8 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
307
384
  signalSubscriber: stationSignalSub,
308
385
  }));
309
386
  adminRoutes.route("/", v1ScheduleRoutes({ scheduleAdapter }));
310
- v1.route("/", adminRoutes);
387
+ adminRoutes.route("/", v1EnvRoutes({ envStore }));
388
+ v1.route("/", guarded("admin", adminRoutes));
311
389
 
312
390
  app.route("/api/v1", v1);
313
391
 
@@ -365,6 +443,14 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
365
443
  keyStore,
366
444
  dataDir,
367
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
+
368
454
  // Start runners (non-blocking — they have internal poll loops)
369
455
  if (config.runRunners) {
370
456
  if (signalRunner) {
@@ -377,6 +463,11 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
377
463
  console.error("[station] Broadcast runner error:", err);
378
464
  });
379
465
  }
466
+ if (beaconRunner) {
467
+ beaconRunner.start().catch((err: unknown) => {
468
+ console.error("[station] Beacon runner error:", err);
469
+ });
470
+ }
380
471
  }
381
472
 
382
473
  // Start Hono server
@@ -387,12 +478,31 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
387
478
  },
388
479
  ) as unknown as Server;
389
480
 
390
- // Attach WebSocket to the HTTP server
391
- 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
+ });
392
498
  },
393
499
 
394
500
  async stop() {
395
- // 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
396
506
  if (broadcastRunner) {
397
507
  await broadcastRunner.stop({ graceful: true, timeoutMs: 5000 });
398
508
  }
@@ -401,11 +511,70 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
401
511
  }
402
512
  wsHub.close();
403
513
  sseHub.close();
404
- logStore.close();
514
+ await logStore.close();
405
515
  await keyStore?.close();
516
+ await envStore.close();
406
517
  if (httpServer) {
407
518
  httpServer.close();
408
519
  }
409
520
  },
410
521
  };
411
522
  }
523
+
524
+ // Existing deployments that ran older Station versions persisted keys
525
+ // to `station-keys.db` (SQLite) and run logs to `station-logs.db`. The
526
+ // new defaults are `station-keys.json` and `station-logs.jsonl`; the
527
+ // legacy files are NOT auto-migrated. Emit a one-time warning so an
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
+
563
+ function warnIfLegacySqliteFiles(dataDir: string): void {
564
+ const legacy: { file: string; replacement: string }[] = [
565
+ { file: "station-keys.db", replacement: "station-keys.json" },
566
+ { file: "station-logs.db", replacement: "station-logs.jsonl" },
567
+ ];
568
+ for (const { file, replacement } of legacy) {
569
+ const legacyPath = resolve(dataDir, file);
570
+ if (!existsSync(legacyPath)) continue;
571
+ const replacementPath = resolve(dataDir, replacement);
572
+ if (existsSync(replacementPath)) continue;
573
+ console.warn(
574
+ `[station] Legacy ${file} detected at ${legacyPath} but no ${replacement} found. ` +
575
+ `Station no longer reads SQLite-backed defaults; data in ${file} will not be loaded. ` +
576
+ `If you need the contents, export them with the better-sqlite3 CLI before upgrading. ` +
577
+ `To suppress this warning, delete or rename ${file}.`,
578
+ );
579
+ }
580
+ }
@@ -1,56 +1,207 @@
1
- import Database from "better-sqlite3";
1
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { appendFile } from "node:fs/promises";
3
+ import { dirname } from "node:path";
2
4
  import type { LogEntry } from "./log-buffer.js";
3
5
 
4
- export class LogStore {
5
- private db: Database.Database;
6
- private insertStmt: Database.Statement;
7
- private selectStmt: Database.Statement;
8
-
9
- constructor(dbPath: string) {
10
- this.db = new Database(dbPath);
11
- this.db.pragma("journal_mode = WAL");
12
- this.db.exec(`
13
- CREATE TABLE IF NOT EXISTS logs (
14
- id INTEGER PRIMARY KEY AUTOINCREMENT,
15
- run_id TEXT NOT NULL,
16
- signal_name TEXT NOT NULL,
17
- level TEXT NOT NULL,
18
- message TEXT NOT NULL,
19
- timestamp TEXT NOT NULL
20
- )
21
- `);
22
- this.db.exec(`CREATE INDEX IF NOT EXISTS idx_logs_run_id ON logs(run_id)`);
23
-
24
- this.insertStmt = this.db.prepare(
25
- `INSERT INTO logs (run_id, signal_name, level, message, timestamp) VALUES (?, ?, ?, ?, ?)`,
26
- );
27
- this.selectStmt = this.db.prepare(
28
- `SELECT run_id, signal_name, level, message, timestamp FROM logs WHERE run_id = ? ORDER BY id`,
6
+ /**
7
+ * Pluggable storage backend for run logs. Implementations only persist
8
+ * and query records — bounded in-memory buffering for live UI streams
9
+ * lives in `LogBuffer`. May be sync or async; the LogStore wrapper
10
+ * normalizes both.
11
+ *
12
+ * Contract for implementers:
13
+ *
14
+ * - **`add(entry)`** is treated as fire-and-forget at the LogStore
15
+ * boundary. Signal runners never block on log writes. Adapters that
16
+ * need durability guarantees (queues, retries, batching) should
17
+ * implement that internally; thrown errors and rejected promises are
18
+ * caught and surfaced via the LogStore's `onError` hook (if set) but
19
+ * never rethrown to the caller.
20
+ * - **`get(runId)`** must return entries for that run in append order
21
+ * (oldest first). Routes that aggregate across runs may re-sort by
22
+ * timestamp, but per-run ordering is the adapter's responsibility.
23
+ * - **`close?()`** is called once on graceful shutdown. Use it to flush
24
+ * any in-flight buffers. It is NOT called on `SIGKILL` / OOM kill —
25
+ * adapters that must guarantee durability per write should not rely
26
+ * on it.
27
+ *
28
+ * Single-process semantics: the built-in `FileLogStorage` is safe for a
29
+ * single Node process. Running multiple processes against the same file
30
+ * path WILL produce interleaved bytes and lost entries use a real
31
+ * database adapter (Postgres, MySQL, Redis, etc.) for multi-process or
32
+ * distributed deployments.
33
+ */
34
+ export interface LogStorageAdapter {
35
+ add(entry: LogEntry): Promise<void> | void;
36
+ get(runId: string): Promise<LogEntry[]> | LogEntry[];
37
+ close?(): Promise<void> | void;
38
+ }
39
+
40
+ // ─── File-backed default ────────────────────────────────────────────
41
+
42
+ export interface FileLogStorageOptions {
43
+ filePath: string;
44
+ /**
45
+ * Called when a background write to the underlying file fails. Use
46
+ * this to surface persistence problems (disk full, permission denied,
47
+ * etc.) to your monitoring system. If unset, write failures are
48
+ * silently dropped — acceptable for local dev, NOT for production.
49
+ */
50
+ onError?: (err: unknown) => void;
51
+ }
52
+
53
+ /**
54
+ * File-backed log storage using append-only JSONL framing. Each line is
55
+ * a JSON-serialized `LogEntry`; existing entries are loaded into memory
56
+ * on construction; appends are serialized through an async write queue
57
+ * so concurrent writers can't interleave bytes within one process.
58
+ *
59
+ * No native dependencies — works on any Node 18+ install.
60
+ *
61
+ * **Production caveats** (in order of severity):
62
+ *
63
+ * 1. **Single-process only.** Two Node processes appending to the same
64
+ * file WILL interleave bytes once individual JSON lines exceed the
65
+ * OS pipe buffer (4 KB on Linux), corrupting the file.
66
+ * 2. **Best-effort durability.** Writes are queued and flushed via
67
+ * `fs.appendFile`; on `SIGKILL` / OOM kill, in-flight writes are lost.
68
+ * Set `onError` to surface fs failures.
69
+ * 3. **Unbounded memory on replay.** The whole file is loaded into a
70
+ * Map on startup. For high-volume deployments (gigabytes of logs)
71
+ * use a database-backed adapter instead.
72
+ *
73
+ * For multi-process, distributed, or high-durability deployments,
74
+ * implement `LogStorageAdapter` against Postgres / MySQL / Redis / S3.
75
+ */
76
+ export class FileLogStorage implements LogStorageAdapter {
77
+ private path: string;
78
+ private byRunId = new Map<string, LogEntry[]>();
79
+ private writeQueue: Promise<void> = Promise.resolve();
80
+ private onError: (err: unknown) => void;
81
+
82
+ constructor(options: FileLogStorageOptions) {
83
+ // Backwards compat: callers used to pass `.db` paths for the sqlite store.
84
+ // Transparently swap to `.jsonl` so existing config files keep working.
85
+ this.path = options.filePath.endsWith(".db")
86
+ ? options.filePath.replace(/\.db$/, ".jsonl")
87
+ : options.filePath;
88
+ this.onError = options.onError ?? (() => {});
89
+ mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
90
+ this.replay();
91
+ }
92
+
93
+ private replay(): void {
94
+ if (!existsSync(this.path)) return;
95
+ let content: string;
96
+ try {
97
+ content = readFileSync(this.path, "utf8");
98
+ } catch (err) {
99
+ this.onError(err);
100
+ return;
101
+ }
102
+ const lines = content.split("\n");
103
+ for (const line of lines) {
104
+ if (!line) continue;
105
+ try {
106
+ const entry = JSON.parse(line) as LogEntry;
107
+ this.indexEntry(entry);
108
+ } catch {
109
+ // Skip malformed line; a partial write may have left a truncated tail.
110
+ }
111
+ }
112
+ }
113
+
114
+ private indexEntry(entry: LogEntry): void {
115
+ let entries = this.byRunId.get(entry.runId);
116
+ if (!entries) {
117
+ entries = [];
118
+ this.byRunId.set(entry.runId, entries);
119
+ }
120
+ entries.push(entry);
121
+ }
122
+
123
+ add(entry: LogEntry): void {
124
+ this.indexEntry(entry);
125
+ const line = JSON.stringify(entry) + "\n";
126
+ this.writeQueue = this.writeQueue.then(
127
+ () => appendFile(this.path, line, { mode: 0o600 }).catch((err) => {
128
+ this.onError(err);
129
+ }),
29
130
  );
30
131
  }
31
132
 
133
+ get(runId: string): LogEntry[] {
134
+ return this.byRunId.get(runId) ?? [];
135
+ }
136
+
137
+ async close(): Promise<void> {
138
+ await this.writeQueue;
139
+ }
140
+ }
141
+
142
+ // ─── In-memory storage for tests / ephemeral deployments ────────────
143
+
144
+ export class MemoryLogStorage implements LogStorageAdapter {
145
+ private byRunId = new Map<string, LogEntry[]>();
146
+
32
147
  add(entry: LogEntry): void {
33
- this.insertStmt.run(entry.runId, entry.signalName, entry.level, entry.message, entry.timestamp);
148
+ let entries = this.byRunId.get(entry.runId);
149
+ if (!entries) {
150
+ entries = [];
151
+ this.byRunId.set(entry.runId, entries);
152
+ }
153
+ entries.push(entry);
34
154
  }
35
155
 
36
156
  get(runId: string): LogEntry[] {
37
- const rows = this.selectStmt.all(runId) as Array<{
38
- run_id: string;
39
- signal_name: string;
40
- level: string;
41
- message: string;
42
- timestamp: string;
43
- }>;
44
- return rows.map((row) => ({
45
- runId: row.run_id,
46
- signalName: row.signal_name,
47
- level: row.level as "stdout" | "stderr",
48
- message: row.message,
49
- timestamp: row.timestamp,
50
- }));
51
- }
52
-
53
- close(): void {
54
- this.db.close();
157
+ return this.byRunId.get(runId) ?? [];
158
+ }
159
+ }
160
+
161
+ // ─── LogStore — thin wrapper that delegates to an adapter ───────────
162
+
163
+ /**
164
+ * LogStore is the consumer-facing handle that wraps a `LogStorageAdapter`.
165
+ * It exists so signal runners and route handlers can interact with a
166
+ * single concrete type, while the underlying persistence is swappable.
167
+ *
168
+ * `add` is fire-and-forget — adapter promises are caught at this boundary
169
+ * so a slow or failing log backend can never block (or crash) a signal
170
+ * runner. `get` always returns a Promise so callers can transparently
171
+ * support async backends (Postgres, Redis, etc.).
172
+ */
173
+ export class LogStore {
174
+ private storage: LogStorageAdapter;
175
+
176
+ /**
177
+ * Pass a `LogStorageAdapter` for any backend. The string overload is
178
+ * a shortcut for `new FileLogStorage({ filePath })` — useful for
179
+ * local dev and the default Station data directory.
180
+ */
181
+ constructor(storageOrPath: LogStorageAdapter | string) {
182
+ if (typeof storageOrPath === "string") {
183
+ this.storage = new FileLogStorage({ filePath: storageOrPath });
184
+ } else {
185
+ this.storage = storageOrPath;
186
+ }
187
+ }
188
+
189
+ add(entry: LogEntry): void {
190
+ try {
191
+ const result = this.storage.add(entry);
192
+ if (result && typeof (result as Promise<void>).catch === "function") {
193
+ (result as Promise<void>).catch(() => {});
194
+ }
195
+ } catch {
196
+ // Swallow sync throws; a broken log adapter must not crash signal runs.
197
+ }
198
+ }
199
+
200
+ async get(runId: string): Promise<LogEntry[]> {
201
+ return await this.storage.get(runId);
202
+ }
203
+
204
+ async close(): Promise<void> {
205
+ if (this.storage.close) await this.storage.close();
55
206
  }
56
207
  }
@@ -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 {