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,16 +1,20 @@
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";
7
+ import type { LogStorageAdapter } from "../server/log-store.js";
5
8
 
6
9
  export interface AuthConfig {
7
10
  username: string;
8
11
  password: string;
9
12
  sessionTtlMs?: number;
10
13
  /**
11
- * Pluggable storage backend for API keys. Defaults to a SQLite store at
12
- * `<dataDir>/station-keys.db`. Provide a custom adapter to host keys in
13
- * Postgres, MySQL, Redis, etc.
14
+ * Pluggable storage backend for API keys. Defaults to a JSON file at
15
+ * `<dataDir>/station-keys.json` (no native dependencies required).
16
+ * Provide a custom adapter to host keys in SQLite, Postgres, MySQL,
17
+ * Redis, etc.
14
18
  */
15
19
  keyStorage?: ApiKeyStorageAdapter;
16
20
  }
@@ -35,13 +39,37 @@ export interface StationConfig {
35
39
  host: string;
36
40
  adapter?: SignalQueueAdapter;
37
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;
38
48
  /**
39
49
  * Optional schedule storage adapter. When provided, runtime-editable
40
50
  * schedules are persisted here and reconciled by both runners.
41
51
  */
42
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;
62
+ /**
63
+ * Pluggable storage backend for run logs. Defaults to a `FileLogStorage`
64
+ * (append-only JSONL file at `<dataDir>/station-logs.jsonl`, no native
65
+ * dependencies). The default is single-process only — for multi-process
66
+ * deployments or guaranteed durability, implement `LogStorageAdapter`
67
+ * against Postgres, MySQL, Redis, S3, etc., and pass it here.
68
+ */
69
+ logStorage?: LogStorageAdapter;
43
70
  signalsDir?: string;
44
71
  broadcastsDir?: string;
72
+ beaconsDir?: string;
45
73
  stationDir: string;
46
74
  runner: RunnerConfig;
47
75
  broadcastRunner: BroadcastRunnerConfig;
@@ -98,9 +126,13 @@ export function resolveConfig(input: StationUserConfig): StationConfig {
98
126
  host: input.host ?? envHost ?? DEFAULTS.host,
99
127
  adapter: input.adapter,
100
128
  broadcastAdapter: input.broadcastAdapter,
129
+ beaconAdapter: input.beaconAdapter,
101
130
  scheduleAdapter: input.scheduleAdapter,
131
+ envStorage: input.envStorage,
132
+ logStorage: input.logStorage,
102
133
  signalsDir: input.signalsDir,
103
134
  broadcastsDir: input.broadcastsDir,
135
+ beaconsDir: input.beaconsDir,
104
136
  stationDir: input.stationDir ?? DEFAULTS.stationDir,
105
137
  runner: {
106
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";
@@ -1,5 +1,17 @@
1
1
  import crypto from "node:crypto";
2
- import Database from "better-sqlite3";
2
+ import {
3
+ closeSync,
4
+ existsSync,
5
+ fsyncSync,
6
+ mkdirSync,
7
+ openSync,
8
+ readFileSync,
9
+ renameSync,
10
+ writeFileSync,
11
+ writeSync,
12
+ } from "node:fs";
13
+ import { createRequire } from "node:module";
14
+ import { dirname } from "node:path";
3
15
 
4
16
  export interface ApiKey {
5
17
  id: string;
@@ -29,7 +41,123 @@ export interface ApiKeyStorageAdapter {
29
41
  close?(): Promise<void> | void;
30
42
  }
31
43
 
32
- // ─── SQLite default ─────────────────────────────────────────────────
44
+ // ─── JSON file default ──────────────────────────────────────────────
45
+
46
+ export interface FileKeyStorageOptions {
47
+ filePath: string;
48
+ }
49
+
50
+ /**
51
+ * Default ApiKeyStorageAdapter backed by a JSON file. Used by the Station
52
+ * server when no `keyStorage` is configured. Has no native dependencies —
53
+ * works on any Node 18+ install without compiling bindings.
54
+ *
55
+ * Crash-safety: writes go through a fsync'd tmp-file + rename, with a
56
+ * second fsync on the parent directory so the rename itself survives
57
+ * power loss. The keys file is created with `0o600` and the parent dir
58
+ * with `0o700` so a default umask doesn't expose key metadata.
59
+ *
60
+ * Single-process only: do not point two `createStation` instances at
61
+ * the same file or last-rename-wins will silently clobber writes. For
62
+ * multi-process or high-volume deployments, implement
63
+ * `ApiKeyStorageAdapter` against Postgres / MySQL / Redis.
64
+ */
65
+ export class FileKeyStorage implements ApiKeyStorageAdapter {
66
+ private filePath: string;
67
+ private records = new Map<string, ApiKey>();
68
+
69
+ constructor(options: FileKeyStorageOptions) {
70
+ this.filePath = options.filePath;
71
+ mkdirSync(dirname(this.filePath), { recursive: true, mode: 0o700 });
72
+ this.load();
73
+ }
74
+
75
+ private load(): void {
76
+ if (!existsSync(this.filePath)) return;
77
+ try {
78
+ const raw = readFileSync(this.filePath, "utf8");
79
+ const data = JSON.parse(raw) as ApiKey[];
80
+ if (Array.isArray(data)) {
81
+ for (const r of data) this.records.set(r.id, r);
82
+ }
83
+ } catch {
84
+ // Corrupt or unreadable file — start fresh rather than throwing.
85
+ }
86
+ }
87
+
88
+ private flush(): void {
89
+ const tmp = `${this.filePath}.tmp`;
90
+ const body = JSON.stringify(Array.from(this.records.values()), null, 2);
91
+ // Write tmp file with fsync so its bytes are durable before rename.
92
+ const fd = openSync(tmp, "w", 0o600);
93
+ try {
94
+ writeSync(fd, body);
95
+ fsyncSync(fd);
96
+ } finally {
97
+ closeSync(fd);
98
+ }
99
+ renameSync(tmp, this.filePath);
100
+ // fsync the parent directory so the rename's directory entry survives
101
+ // a crash. Best-effort: opening a directory for fsync isn't supported
102
+ // on every platform (notably Windows), so swallow errors.
103
+ try {
104
+ const dirFd = openSync(dirname(this.filePath), "r");
105
+ try {
106
+ fsyncSync(dirFd);
107
+ } finally {
108
+ closeSync(dirFd);
109
+ }
110
+ } catch {
111
+ // Platform doesn't support directory fsync; rename + tmp fsync
112
+ // already give us most of the durability we can offer.
113
+ }
114
+ }
115
+
116
+ insert(record: ApiKey): void {
117
+ this.records.set(record.id, { ...record });
118
+ this.flush();
119
+ }
120
+
121
+ findByHash(keyHash: string): ApiKey | null {
122
+ for (const r of this.records.values()) {
123
+ if (r.keyHash === keyHash) return { ...r };
124
+ }
125
+ return null;
126
+ }
127
+
128
+ list(): ApiKeyPublic[] {
129
+ return Array.from(this.records.values())
130
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
131
+ .map((r) => {
132
+ const { keyHash: _h, ...rest } = r;
133
+ return rest;
134
+ });
135
+ }
136
+
137
+ touch(id: string, lastUsedIso: string): void {
138
+ const r = this.records.get(id);
139
+ if (!r) return;
140
+ const prev = r.lastUsed ? Date.parse(r.lastUsed) : 0;
141
+ r.lastUsed = lastUsedIso;
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
+ }
149
+ }
150
+
151
+ revoke(id: string): boolean {
152
+ const r = this.records.get(id);
153
+ if (!r) return false;
154
+ r.revoked = true;
155
+ this.flush();
156
+ return true;
157
+ }
158
+ }
159
+
160
+ // ─── SQLite (optional) ──────────────────────────────────────────────
33
161
 
34
162
  export interface SqliteKeyStorageOptions {
35
163
  dbPath: string;
@@ -37,13 +165,42 @@ export interface SqliteKeyStorageOptions {
37
165
  tableName?: string;
38
166
  }
39
167
 
168
+ // Loaded lazily from `better-sqlite3` so the package isn't required at
169
+ // install time. Users who don't construct SqliteKeyStorage never pay for it.
170
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
171
+ type BetterSqlite3Module = any;
172
+ let cachedBetterSqlite3: BetterSqlite3Module | null = null;
173
+
174
+ function loadBetterSqlite3(): BetterSqlite3Module {
175
+ if (cachedBetterSqlite3) return cachedBetterSqlite3;
176
+ try {
177
+ const requireFn = createRequire(import.meta.url);
178
+ cachedBetterSqlite3 = requireFn("better-sqlite3");
179
+ return cachedBetterSqlite3;
180
+ } catch (err) {
181
+ const reason = err instanceof Error ? err.message : String(err);
182
+ throw new Error(
183
+ `SqliteKeyStorage requires the optional 'better-sqlite3' package, ` +
184
+ `which isn't installed. Install it with:\n` +
185
+ ` npm install better-sqlite3\n` +
186
+ `Or use FileKeyStorage (default) / MemoryKeyStorage instead.\n` +
187
+ `Underlying error: ${reason}`,
188
+ );
189
+ }
190
+ }
191
+
40
192
  /**
41
- * Default ApiKeyStorageAdapter backed by better-sqlite3. Used by the Station
42
- * server when no `keyStorage` is configured. For Postgres / MySQL / Redis,
43
- * implement `ApiKeyStorageAdapter` and pass it to `KeyStore` directly.
193
+ * Optional ApiKeyStorageAdapter backed by better-sqlite3. Requires the
194
+ * `better-sqlite3` package to be installed separately Station Kit no
195
+ * longer ships it as a hard dependency.
196
+ *
197
+ * Prefer `FileKeyStorage` (the default) unless you specifically need
198
+ * sqlite features (concurrent reads from multiple processes, large
199
+ * key catalogs, etc.).
44
200
  */
45
201
  export class SqliteKeyStorage implements ApiKeyStorageAdapter {
46
- private db: Database.Database;
202
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
203
+ private db: any;
47
204
  private table: string;
48
205
 
49
206
  constructor(options: SqliteKeyStorageOptions) {
@@ -52,6 +209,7 @@ export class SqliteKeyStorage implements ApiKeyStorageAdapter {
52
209
  throw new Error(`Invalid table name "${tableName}"`);
53
210
  }
54
211
  this.table = tableName;
212
+ const Database = loadBetterSqlite3();
55
213
  this.db = new Database(options.dbPath);
56
214
  this.db.pragma("journal_mode = WAL");
57
215
  this.db.exec(`
@@ -113,7 +271,15 @@ export class SqliteKeyStorage implements ApiKeyStorageAdapter {
113
271
  }));
114
272
  }
115
273
 
274
+ /** Last persisted lastUsed per key id, to skip per-request UPDATEs. */
275
+ private lastTouched = new Map<string, number>();
276
+
116
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);
117
283
  this.db.prepare(`UPDATE ${this.table} SET last_used = ? WHERE id = ?`).run(lastUsedIso, id);
118
284
  }
119
285
 
@@ -181,27 +347,57 @@ export class MemoryKeyStorage implements ApiKeyStorageAdapter {
181
347
 
182
348
  // ─── KeyStore — owns crypto, delegates persistence ──────────────────
183
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
+
184
361
  export class KeyStore {
185
362
  private storage: ApiKeyStorageAdapter;
363
+ private pepper?: Buffer;
186
364
 
187
365
  /**
188
366
  * Pass an `ApiKeyStorageAdapter` for any backend. The string overload is
189
- * retained for backwards compatibility — it constructs a SqliteKeyStorage
190
- * at the given path.
367
+ * retained for backwards compatibility — it constructs a FileKeyStorage
368
+ * at the given path. (Previously this returned a SqliteKeyStorage; SQLite
369
+ * is now opt-in to avoid native build dependencies.)
191
370
  */
192
- constructor(storageOrDbPath: ApiKeyStorageAdapter | string) {
193
- if (typeof storageOrDbPath === "string") {
194
- this.storage = new SqliteKeyStorage({ dbPath: storageOrDbPath });
371
+ constructor(storageOrPath: ApiKeyStorageAdapter | string, options?: KeyStoreOptions) {
372
+ if (typeof storageOrPath === "string") {
373
+ const filePath = storageOrPath.endsWith(".db")
374
+ ? storageOrPath.replace(/\.db$/, ".json")
375
+ : storageOrPath;
376
+ this.storage = new FileKeyStorage({ filePath });
195
377
  } else {
196
- this.storage = storageOrDbPath;
378
+ this.storage = storageOrPath;
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");
197
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");
198
394
  }
199
395
 
200
396
  /** Generate a new API key. Returns the full key (only shown once) and the stored record. */
201
397
  async create(name: string, scopes: string[] = ["trigger", "read"]): Promise<{ key: string; record: ApiKey }> {
202
398
  const id = crypto.randomUUID();
203
399
  const rawKey = `sk_live_${crypto.randomBytes(16).toString("hex")}`;
204
- const keyHash = crypto.createHash("sha256").update(rawKey).digest("hex");
400
+ const keyHash = this.hashKey(rawKey);
205
401
  const keyPrefix = rawKey.slice(0, 12);
206
402
  const createdAt = new Date().toISOString();
207
403
 
@@ -215,8 +411,12 @@ export class KeyStore {
215
411
 
216
412
  /** Verify an API key. Returns the key record if valid, null otherwise. */
217
413
  async verify(rawKey: string): Promise<ApiKey | null> {
218
- const keyHash = crypto.createHash("sha256").update(rawKey).digest("hex");
219
- 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
+ }
220
420
  if (!record) return null;
221
421
  if (record.revoked) return null;
222
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
  }