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.
- package/.next/standalone/packages/station-kit/.next/BUILD_ID +1 -1
- package/.next/standalone/packages/station-kit/.next/app-build-manifest.json +55 -34
- package/.next/standalone/packages/station-kit/.next/app-path-routes-manifest.json +11 -8
- package/.next/standalone/packages/station-kit/.next/build-manifest.json +2 -2
- package/.next/standalone/packages/station-kit/.next/prerender-manifest.json +73 -25
- package/.next/standalone/packages/station-kit/.next/routes-manifest.json +20 -0
- package/.next/standalone/packages/station-kit/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/_not-found.rsc +8 -8
- package/.next/standalone/packages/station-kit/.next/server/app/beacons/[name]/page.js +2 -0
- package/.next/standalone/packages/station-kit/.next/server/app/beacons/[name]/page.js.nft.json +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/beacons/[name]/page_client-reference-manifest.js +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/beacons/page.js +2 -0
- package/.next/standalone/packages/station-kit/.next/server/app/beacons/page.js.nft.json +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/beacons/page_client-reference-manifest.js +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/beacons.html +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/beacons.meta +7 -0
- package/.next/standalone/packages/station-kit/.next/server/app/beacons.rsc +25 -0
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/[id]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/v/[n]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.rsc +9 -9
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts.rsc +9 -9
- package/.next/standalone/packages/station-kit/.next/server/app/environment/page.js +2 -0
- package/.next/standalone/packages/station-kit/.next/server/app/environment/page.js.nft.json +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/environment/page_client-reference-manifest.js +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/environment.html +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/environment.meta +7 -0
- package/.next/standalone/packages/station-kit/.next/server/app/environment.rsc +25 -0
- package/.next/standalone/packages/station-kit/.next/server/app/index.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/index.rsc +9 -9
- package/.next/standalone/packages/station-kit/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/playground/expression/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.rsc +9 -9
- package/.next/standalone/packages/station-kit/.next/server/app/runs/[id]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/[id]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/new/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.rsc +9 -9
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/schedules.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/schedules.rsc +9 -9
- package/.next/standalone/packages/station-kit/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/settings.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/settings.rsc +9 -9
- package/.next/standalone/packages/station-kit/.next/server/app/signals/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/signals/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/signals.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/signals.rsc +9 -9
- package/.next/standalone/packages/station-kit/.next/server/app-paths-manifest.json +11 -8
- package/.next/standalone/packages/station-kit/.next/server/chunks/102.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/pages/404.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/pages/500.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/pages-manifest.json +1 -1
- package/.next/standalone/packages/station-kit/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/285-583f23ee7827983f.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/561-05a7ef9669e3921a.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/beacons/[name]/page-eb1f82847734e145.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/beacons/page-64e296346197e44b.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/dyn/[name]/v/[n]/page-15edf07ab26241c9.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/page-b23a03097c8f75c4.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/environment/page-111242bec067fdee.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/layout-e9ac56fcaf7a5ea7.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/page-ef78692958c2fc01.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/playground/expression/page-29294a705dd8f471.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/runs/[id]/page-287bbb8f6dec2e23.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/schedules/page-c869218da203e3b4.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/settings/page-05cf43b6ae6b3187.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/[name]/page-4027803be00c695a.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/page-8e076fc5d0188d72.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/css/ddb1646dc25ed100.css +1 -0
- package/.next/standalone/packages/station-kit/.next/static/vTGEzSqxhR_E57YAFn9DC/_buildManifest.js +1 -0
- package/.next/standalone/packages/station-kit/package.json +4 -1
- package/dist/config/schema.d.ts +18 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +3 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/server/auth/keys.d.ts +18 -1
- package/dist/server/auth/keys.d.ts.map +1 -1
- package/dist/server/auth/keys.js +38 -6
- package/dist/server/auth/keys.js.map +1 -1
- package/dist/server/auth/session.d.ts.map +1 -1
- package/dist/server/auth/session.js +34 -11
- package/dist/server/auth/session.js.map +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +135 -18
- package/dist/server/index.js.map +1 -1
- package/dist/server/metadata.d.ts +2 -0
- package/dist/server/metadata.d.ts.map +1 -1
- package/dist/server/metadata.js.map +1 -1
- package/dist/server/middleware/rate-limit.d.ts.map +1 -1
- package/dist/server/middleware/rate-limit.js +22 -1
- package/dist/server/middleware/rate-limit.js.map +1 -1
- package/dist/server/routes/beacons.d.ts +10 -0
- package/dist/server/routes/beacons.d.ts.map +1 -0
- package/dist/server/routes/beacons.js +98 -0
- package/dist/server/routes/beacons.js.map +1 -0
- package/dist/server/routes/broadcasts.d.ts.map +1 -1
- package/dist/server/routes/broadcasts.js +6 -2
- package/dist/server/routes/broadcasts.js.map +1 -1
- package/dist/server/routes/runs.d.ts.map +1 -1
- package/dist/server/routes/runs.js +19 -59
- package/dist/server/routes/runs.js.map +1 -1
- package/dist/server/routes/signals.d.ts.map +1 -1
- package/dist/server/routes/signals.js +17 -13
- package/dist/server/routes/signals.js.map +1 -1
- package/dist/server/routes/v1/env.d.ts +10 -0
- package/dist/server/routes/v1/env.d.ts.map +1 -0
- package/dist/server/routes/v1/env.js +132 -0
- package/dist/server/routes/v1/env.js.map +1 -0
- package/dist/server/routes/v1/events.js +2 -2
- package/dist/server/routes/v1/events.js.map +1 -1
- package/dist/server/routes/v1/runs.d.ts.map +1 -1
- package/dist/server/routes/v1/runs.js +10 -31
- package/dist/server/routes/v1/runs.js.map +1 -1
- package/dist/server/sse.d.ts +2 -1
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +5 -1
- package/dist/server/sse.js.map +1 -1
- package/dist/server/subscriber.d.ts +56 -0
- package/dist/server/subscriber.d.ts.map +1 -1
- package/dist/server/subscriber.js +94 -0
- package/dist/server/subscriber.js.map +1 -1
- package/dist/server/ws.d.ts +8 -2
- package/dist/server/ws.d.ts.map +1 -1
- package/dist/server/ws.js +28 -3
- package/dist/server/ws.js.map +1 -1
- package/package.json +11 -8
- package/src/app/beacons/[name]/beacon-detail.tsx +253 -0
- package/src/app/beacons/[name]/page.tsx +6 -0
- package/src/app/beacons/page.tsx +98 -0
- package/src/app/components/shell.tsx +24 -0
- package/src/app/components/status-badge.tsx +13 -1
- package/src/app/environment/page.tsx +415 -0
- package/src/app/globals.css +48 -0
- package/src/app/hooks/use-api.ts +80 -0
- package/src/config/schema.ts +21 -0
- package/src/index.ts +12 -0
- package/src/server/auth/keys.ts +50 -5
- package/src/server/auth/session.ts +38 -11
- package/src/server/index.ts +144 -18
- package/src/server/metadata.ts +2 -0
- package/src/server/middleware/rate-limit.ts +20 -1
- package/src/server/routes/beacons.ts +112 -0
- package/src/server/routes/broadcasts.ts +6 -2
- package/src/server/routes/runs.ts +20 -67
- package/src/server/routes/signals.ts +17 -13
- package/src/server/routes/v1/env.ts +144 -0
- package/src/server/routes/v1/events.ts +2 -2
- package/src/server/routes/v1/runs.ts +11 -35
- package/src/server/sse.ts +7 -2
- package/src/server/subscriber.ts +111 -0
- package/src/server/ws.ts +38 -4
- package/.next/standalone/packages/station-kit/.next/static/THKSkCipW_pj0F6DRXYEG/_buildManifest.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/285-ff198f0a909c4fdd.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/561-33d912169940283e.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/dyn/[name]/v/[n]/page-5eac0507f49a00ec.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/page-dee500ccc01f0821.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/layout-e14e14f3e5b0b8a9.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/page-aac41ef7a470daab.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/playground/expression/page-dc9d91f3f50f4716.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/runs/[id]/page-9e4c4f751a4bea72.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/schedules/page-738d98dc0b63166e.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/settings/page-fc5654b31f57ac21.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/[name]/page-4b1c09a539a1ebcd.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/page-d2f2403dfede87cc.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/css/0be0e65a5f561f37.css +0 -1
- /package/.next/standalone/packages/station-kit/.next/static/{THKSkCipW_pj0F6DRXYEG → vTGEzSqxhR_E57YAFn9DC}/_ssgManifest.js +0 -0
package/src/server/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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" },
|
package/src/server/metadata.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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: {
|
|
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
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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: {
|
|
33
|
+
meta: { count: runs.length, limit, offset },
|
|
67
34
|
});
|
|
68
35
|
});
|
|
69
36
|
|
|
70
37
|
app.get("/runs/stats", async (c) => {
|
|
71
|
-
const
|
|
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
|
|
92
|
-
|
|
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,
|