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
@@ -70,6 +70,26 @@ export interface SignalMeta {
70
70
  maxConcurrency: number | null;
71
71
  hasSteps: boolean;
72
72
  stepNames: string[];
73
+ requiredEnv?: string[];
74
+ }
75
+
76
+ export type EnvTargetKind = "signal" | "beacon";
77
+
78
+ export interface EnvTarget {
79
+ kind: EnvTargetKind;
80
+ name: string;
81
+ }
82
+
83
+ export interface EnvVar {
84
+ id: string;
85
+ key: string;
86
+ /** Null when the variable is secret (write-only). */
87
+ value: string | null;
88
+ secret: boolean;
89
+ targets: EnvTarget[];
90
+ createdAt: string;
91
+ updatedAt: string;
92
+ createdBy?: string;
73
93
  }
74
94
 
75
95
  export interface BroadcastMeta {
@@ -81,6 +101,47 @@ export interface BroadcastMeta {
81
101
  interval: string | null;
82
102
  }
83
103
 
104
+ export type BeaconStatus =
105
+ | "stopped" | "starting" | "running" | "stopping" | "backoff" | "errored";
106
+
107
+ export interface BeaconInstance {
108
+ beaconName: string;
109
+ status: BeaconStatus;
110
+ desiredState: "running" | "stopped";
111
+ incarnation: number;
112
+ restartCount: number;
113
+ pid?: number;
114
+ config?: string;
115
+ startedAt?: string;
116
+ readyAt?: string;
117
+ lastHeartbeatAt?: string;
118
+ lastExitAt?: string;
119
+ lastExitReason?: "clean" | "failure" | "stopped" | "stalled";
120
+ lastError?: string;
121
+ nextRestartAt?: string;
122
+ createdAt: string;
123
+ updatedAt: string;
124
+ }
125
+
126
+ export interface BeaconListItem {
127
+ name: string;
128
+ filePath: string;
129
+ mode: "run" | "poll";
130
+ restartPolicy: string;
131
+ autoStart: boolean;
132
+ requiredEnv?: string[];
133
+ instance: BeaconInstance | null;
134
+ }
135
+
136
+ export interface BeaconEvent {
137
+ id: string;
138
+ beaconName: string;
139
+ incarnation: number;
140
+ type: string;
141
+ message?: string;
142
+ at: string;
143
+ }
144
+
84
145
  // ─── Dynamic broadcasts / schedules / expressions ───────────────────
85
146
 
86
147
  export type ScheduleKind = "signal" | "broadcast-static" | "broadcast-dynamic";
@@ -174,6 +235,16 @@ export function useApi() {
174
235
  cancelBroadcastRun: (id: string) => fetchApi<{ cancelled: boolean }>(`/broadcast-runs/${id}/cancel`, { method: "POST" }),
175
236
  rerunBroadcastRun: (id: string) => fetchApi<{ id: string; broadcastName: string; status: string }>(`/broadcast-runs/${id}/rerun`, { method: "POST" }),
176
237
 
238
+ // Beacons
239
+ getBeacons: () => fetchApi<BeaconListItem[]>("/beacons"),
240
+ getBeacon: (name: string) => fetchApi<BeaconListItem>(`/beacons/${encodeURIComponent(name)}`),
241
+ getBeaconEvents: (name: string) => fetchApi<BeaconEvent[]>(`/beacons/${encodeURIComponent(name)}/events`),
242
+ getBeaconLogs: (name: string) =>
243
+ fetchApi<Array<{ runId: string; signalName: string; level: string; message: string; timestamp: string }>>(`/beacons/${encodeURIComponent(name)}/logs`),
244
+ startBeacon: (name: string) => fetchApi<{ started: boolean }>(`/beacons/${encodeURIComponent(name)}/start`, { method: "POST" }),
245
+ stopBeacon: (name: string) => fetchApi<{ stopped: boolean }>(`/beacons/${encodeURIComponent(name)}/stop`, { method: "POST" }),
246
+ restartBeacon: (name: string) => fetchApi<{ restarted: boolean }>(`/beacons/${encodeURIComponent(name)}/restart`, { method: "POST" }),
247
+
177
248
  // Dynamic broadcast definitions (v1)
178
249
  getBroadcastDefinitions: () =>
179
250
  fetchApi<DynamicBroadcastSpec[]>("/v1/broadcast-definitions"),
@@ -246,6 +317,15 @@ export function useApi() {
246
317
  body: JSON.stringify({ source }),
247
318
  }),
248
319
 
320
+ // Environment variables (v1 — GET is read-scoped, mutations admin-scoped)
321
+ getEnvVars: () => fetchApi<EnvVar[]>("/v1/env"),
322
+ createEnvVar: (input: { key: string; value: string; secret?: boolean; targets?: EnvTarget[] }) =>
323
+ fetchApi<EnvVar>("/v1/env", { method: "POST", body: JSON.stringify(input) }),
324
+ updateEnvVar: (id: string, patch: Partial<{ value: string; secret: boolean; targets: EnvTarget[] }>) =>
325
+ fetchApi<EnvVar>(`/v1/env/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
326
+ deleteEnvVar: (id: string) =>
327
+ fetchApi<{ deleted: boolean }>(`/v1/env/${id}`, { method: "DELETE" }),
328
+
249
329
  // API Keys (v1 admin routes — session cookie provides admin scope)
250
330
  getApiKeys: () => fetchApi<Array<{ id: string; name: string; keyPrefix: string; scopes: string[]; createdAt: string; lastUsed: string | null; expiresAt: string | null; revoked: boolean }>>("/v1/keys"),
251
331
  createApiKey: (name: string, scopes: string[]) =>
@@ -1,6 +1,8 @@
1
1
  import type { SignalQueueAdapter } from "station-signal";
2
2
  import type { BroadcastQueueAdapter } from "station-broadcast";
3
+ import type { BeaconStateAdapter } from "station-beacon";
3
4
  import type { ScheduleAdapter } from "station-schedules";
5
+ import type { EnvStorageAdapter } from "station-env";
4
6
  import type { ApiKeyStorageAdapter } from "../server/auth/keys.js";
5
7
  import type { LogStorageAdapter } from "../server/log-store.js";
6
8
 
@@ -37,11 +39,26 @@ export interface StationConfig {
37
39
  host: string;
38
40
  adapter?: SignalQueueAdapter;
39
41
  broadcastAdapter?: BroadcastQueueAdapter;
42
+ /**
43
+ * Optional beacon supervision-state adapter. When provided (or when
44
+ * `beaconsDir` is set), the dashboard supervises long-running beacons and
45
+ * exposes them under `/api/beacons`.
46
+ */
47
+ beaconAdapter?: BeaconStateAdapter;
40
48
  /**
41
49
  * Optional schedule storage adapter. When provided, runtime-editable
42
50
  * schedules are persisted here and reconciled by both runners.
43
51
  */
44
52
  scheduleAdapter?: ScheduleAdapter;
53
+ /**
54
+ * Pluggable storage backend for runtime environment variables. Defaults to a
55
+ * JSON file at `<dataDir>/station-env.json` (no native dependencies). When
56
+ * set — or by default — signals/beacons can require env vars via `.env()`,
57
+ * and variables defined here (globally or scoped to specific targets) are
58
+ * injected into runs. For multi-process deployments pass a durable adapter
59
+ * from a `station-adapter-*` package's `/env` subpath.
60
+ */
61
+ envStorage?: EnvStorageAdapter;
45
62
  /**
46
63
  * Pluggable storage backend for run logs. Defaults to a `FileLogStorage`
47
64
  * (append-only JSONL file at `<dataDir>/station-logs.jsonl`, no native
@@ -52,6 +69,7 @@ export interface StationConfig {
52
69
  logStorage?: LogStorageAdapter;
53
70
  signalsDir?: string;
54
71
  broadcastsDir?: string;
72
+ beaconsDir?: string;
55
73
  stationDir: string;
56
74
  runner: RunnerConfig;
57
75
  broadcastRunner: BroadcastRunnerConfig;
@@ -108,10 +126,13 @@ export function resolveConfig(input: StationUserConfig): StationConfig {
108
126
  host: input.host ?? envHost ?? DEFAULTS.host,
109
127
  adapter: input.adapter,
110
128
  broadcastAdapter: input.broadcastAdapter,
129
+ beaconAdapter: input.beaconAdapter,
111
130
  scheduleAdapter: input.scheduleAdapter,
131
+ envStorage: input.envStorage,
112
132
  logStorage: input.logStorage,
113
133
  signalsDir: input.signalsDir,
114
134
  broadcastsDir: input.broadcastsDir,
135
+ beaconsDir: input.beaconsDir,
115
136
  stationDir: input.stationDir ?? DEFAULTS.stationDir,
116
137
  runner: {
117
138
  pollIntervalMs: input.runner?.pollIntervalMs ?? DEFAULTS.runner.pollIntervalMs,
package/src/index.ts CHANGED
@@ -7,3 +7,15 @@ export function defineConfig(config: StationUserConfig): StationUserConfig {
7
7
  export type { StationConfig, StationUserConfig, AuthConfig, DeployConfig } from "./config/schema.js";
8
8
  export { resolveConfig } from "./config/schema.js";
9
9
  export { loadConfig } from "./config/loader.js";
10
+
11
+ // Re-export the runtime env store surface so consumers can construct custom
12
+ // storage or the store itself without a separate `station-env` import.
13
+ export {
14
+ EnvStore,
15
+ MemoryEnvStorage,
16
+ FileEnvStorage,
17
+ type EnvStorageAdapter,
18
+ type EnvVar,
19
+ type EnvVarPublic,
20
+ type EnvTarget,
21
+ } from "station-env";
@@ -137,8 +137,15 @@ export class FileKeyStorage implements ApiKeyStorageAdapter {
137
137
  touch(id: string, lastUsedIso: string): void {
138
138
  const r = this.records.get(id);
139
139
  if (!r) return;
140
+ const prev = r.lastUsed ? Date.parse(r.lastUsed) : 0;
140
141
  r.lastUsed = lastUsedIso;
141
- this.flush();
142
+ // lastUsed is advisory. flush() rewrites and fsyncs the whole file
143
+ // synchronously — doing that on every authenticated request stalls the
144
+ // event loop, so only persist when the value moves by at least a minute.
145
+ // Reads stay fresh because list() serves from memory.
146
+ if (Date.parse(lastUsedIso) - prev >= 60_000) {
147
+ this.flush();
148
+ }
142
149
  }
143
150
 
144
151
  revoke(id: string): boolean {
@@ -264,7 +271,15 @@ export class SqliteKeyStorage implements ApiKeyStorageAdapter {
264
271
  }));
265
272
  }
266
273
 
274
+ /** Last persisted lastUsed per key id, to skip per-request UPDATEs. */
275
+ private lastTouched = new Map<string, number>();
276
+
267
277
  touch(id: string, lastUsedIso: string): void {
278
+ // lastUsed is advisory — throttle writes to once a minute per key.
279
+ const prev = this.lastTouched.get(id) ?? 0;
280
+ const next = Date.parse(lastUsedIso);
281
+ if (next - prev < 60_000) return;
282
+ this.lastTouched.set(id, next);
268
283
  this.db.prepare(`UPDATE ${this.table} SET last_used = ? WHERE id = ?`).run(lastUsedIso, id);
269
284
  }
270
285
 
@@ -332,8 +347,20 @@ export class MemoryKeyStorage implements ApiKeyStorageAdapter {
332
347
 
333
348
  // ─── KeyStore — owns crypto, delegates persistence ──────────────────
334
349
 
350
+ export interface KeyStoreOptions {
351
+ /**
352
+ * Server-side secret ("pepper") mixed into stored key hashes via HMAC.
353
+ * Defense-in-depth: a leaked key file alone can't be brute-forced or
354
+ * precomputed against without also stealing this secret. Optional — when
355
+ * omitted, keys are hashed with plain SHA-256 (legacy behavior). Existing
356
+ * plain-SHA-256 keys keep verifying after a pepper is added (see verify()).
357
+ */
358
+ pepper?: string;
359
+ }
360
+
335
361
  export class KeyStore {
336
362
  private storage: ApiKeyStorageAdapter;
363
+ private pepper?: Buffer;
337
364
 
338
365
  /**
339
366
  * Pass an `ApiKeyStorageAdapter` for any backend. The string overload is
@@ -341,7 +368,7 @@ export class KeyStore {
341
368
  * at the given path. (Previously this returned a SqliteKeyStorage; SQLite
342
369
  * is now opt-in to avoid native build dependencies.)
343
370
  */
344
- constructor(storageOrPath: ApiKeyStorageAdapter | string) {
371
+ constructor(storageOrPath: ApiKeyStorageAdapter | string, options?: KeyStoreOptions) {
345
372
  if (typeof storageOrPath === "string") {
346
373
  const filePath = storageOrPath.endsWith(".db")
347
374
  ? storageOrPath.replace(/\.db$/, ".json")
@@ -350,13 +377,27 @@ export class KeyStore {
350
377
  } else {
351
378
  this.storage = storageOrPath;
352
379
  }
380
+ if (options?.pepper) this.pepper = Buffer.from(options.pepper, "utf8");
381
+ }
382
+
383
+ /** Peppered hash for new keys — HMAC-SHA256 when a pepper is set, else SHA-256. */
384
+ private hashKey(rawKey: string): string {
385
+ if (this.pepper) {
386
+ return crypto.createHmac("sha256", this.pepper).update(rawKey).digest("hex");
387
+ }
388
+ return crypto.createHash("sha256").update(rawKey).digest("hex");
389
+ }
390
+
391
+ /** Unpeppered SHA-256 — used to verify keys stored before a pepper was configured. */
392
+ private legacyHashKey(rawKey: string): string {
393
+ return crypto.createHash("sha256").update(rawKey).digest("hex");
353
394
  }
354
395
 
355
396
  /** Generate a new API key. Returns the full key (only shown once) and the stored record. */
356
397
  async create(name: string, scopes: string[] = ["trigger", "read"]): Promise<{ key: string; record: ApiKey }> {
357
398
  const id = crypto.randomUUID();
358
399
  const rawKey = `sk_live_${crypto.randomBytes(16).toString("hex")}`;
359
- const keyHash = crypto.createHash("sha256").update(rawKey).digest("hex");
400
+ const keyHash = this.hashKey(rawKey);
360
401
  const keyPrefix = rawKey.slice(0, 12);
361
402
  const createdAt = new Date().toISOString();
362
403
 
@@ -370,8 +411,12 @@ export class KeyStore {
370
411
 
371
412
  /** Verify an API key. Returns the key record if valid, null otherwise. */
372
413
  async verify(rawKey: string): Promise<ApiKey | null> {
373
- const keyHash = crypto.createHash("sha256").update(rawKey).digest("hex");
374
- const record = await this.storage.findByHash(keyHash);
414
+ let record = await this.storage.findByHash(this.hashKey(rawKey));
415
+ // Fall back to the unpeppered hash for keys created before the pepper was
416
+ // configured, so enabling a pepper doesn't invalidate existing keys.
417
+ if (!record && this.pepper) {
418
+ record = await this.storage.findByHash(this.legacyHashKey(rawKey));
419
+ }
375
420
  if (!record) return null;
376
421
  if (record.revoked) return null;
377
422
  if (record.expiresAt && new Date(record.expiresAt) < new Date()) return null;
@@ -8,17 +8,45 @@ export interface SessionConfig {
8
8
  sessionTtlMs?: number;
9
9
  }
10
10
 
11
- /** HMAC-sign a token. The secret is derived from the password. */
12
- function sign(payload: string, secret: string): string {
13
- const hmac = crypto.createHmac("sha256", secret);
11
+ /**
12
+ * Derive the HMAC signing key from the password via HKDF instead of using
13
+ * the cleartext password as the key directly — issued cookies should not be
14
+ * HMACs keyed with the literal admin credential. Cached because HKDF per
15
+ * request would be wasted CPU.
16
+ */
17
+ const secretCache = new Map<string, Buffer>();
18
+ function sessionSecret(config: SessionConfig): Buffer {
19
+ let secret = secretCache.get(config.password);
20
+ if (!secret) {
21
+ secret = Buffer.from(
22
+ crypto.hkdfSync("sha256", config.password, "station-kit", "session-token-v1", 32),
23
+ );
24
+ secretCache.set(config.password, secret);
25
+ }
26
+ return secret;
27
+ }
28
+
29
+ function sign(payload: string, config: SessionConfig): string {
30
+ const hmac = crypto.createHmac("sha256", sessionSecret(config));
14
31
  hmac.update(payload);
15
32
  return hmac.digest("hex");
16
33
  }
17
34
 
35
+ /**
36
+ * Timing-safe string comparison that doesn't leak length: both inputs are
37
+ * hashed to a fixed size before `timingSafeEqual` (which requires equal
38
+ * lengths and would otherwise short-circuit on a length check).
39
+ */
40
+ function safeEqual(a: string, b: string): boolean {
41
+ const ha = crypto.createHash("sha256").update(a).digest();
42
+ const hb = crypto.createHash("sha256").update(b).digest();
43
+ return crypto.timingSafeEqual(ha, hb);
44
+ }
45
+
18
46
  export function createSessionToken(config: SessionConfig): string {
19
47
  const exp = Date.now() + (config.sessionTtlMs ?? SESSION_TTL_MS);
20
48
  const payload = `${config.username}:${exp}`;
21
- const signature = sign(payload, config.password);
49
+ const signature = sign(payload, config);
22
50
  return Buffer.from(`${payload}:${signature}`).toString("base64url");
23
51
  }
24
52
 
@@ -31,18 +59,17 @@ export function verifySessionToken(token: string, config: SessionConfig): boolea
31
59
  const exp = parseInt(expStr, 10);
32
60
  if (isNaN(exp) || Date.now() > exp) return false;
33
61
  if (username !== config.username) return false;
34
- const expected = sign(`${username}:${expStr}`, config.password);
35
- return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
62
+ const expected = sign(`${username}:${expStr}`, config);
63
+ return safeEqual(sig, expected);
36
64
  } catch {
37
65
  return false;
38
66
  }
39
67
  }
40
68
 
41
69
  export function verifyCredentials(username: string, password: string, config: SessionConfig): boolean {
42
- // Use timing-safe comparison to prevent timing attacks
43
- const userMatch = username.length === config.username.length &&
44
- crypto.timingSafeEqual(Buffer.from(username), Buffer.from(config.username));
45
- const passMatch = password.length === config.password.length &&
46
- crypto.timingSafeEqual(Buffer.from(password), Buffer.from(config.password));
70
+ // Evaluate both comparisons unconditionally so a valid username isn't
71
+ // distinguishable from an invalid one by response time.
72
+ const userMatch = safeEqual(username, config.username);
73
+ const passMatch = safeEqual(password, config.password);
47
74
  return userMatch && passMatch;
48
75
  }