station-kit 1.0.8 → 1.1.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 (187) hide show
  1. package/.next/standalone/package.json +3 -1
  2. package/.next/standalone/packages/station-kit/.next/BUILD_ID +1 -1
  3. package/.next/standalone/packages/station-kit/.next/app-build-manifest.json +75 -16
  4. package/.next/standalone/packages/station-kit/.next/app-path-routes-manifest.json +10 -3
  5. package/.next/standalone/packages/station-kit/.next/build-manifest.json +3 -3
  6. package/.next/standalone/packages/station-kit/.next/prerender-manifest.json +108 -12
  7. package/.next/standalone/packages/station-kit/.next/routes-manifest.json +49 -0
  8. package/.next/standalone/packages/station-kit/.next/server/app/_not-found/page.js +2 -2
  9. package/.next/standalone/packages/station-kit/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  10. package/.next/standalone/packages/station-kit/.next/server/app/_not-found.html +1 -1
  11. package/.next/standalone/packages/station-kit/.next/server/app/_not-found.rsc +7 -7
  12. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/[id]/page.js +2 -2
  13. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/[id]/page_client-reference-manifest.js +1 -1
  14. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/page.js +2 -0
  15. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/page.js.nft.json +1 -0
  16. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/page_client-reference-manifest.js +1 -0
  17. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/v/[n]/page.js +2 -0
  18. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/v/[n]/page.js.nft.json +1 -0
  19. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/v/[n]/page_client-reference-manifest.js +1 -0
  20. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new/page.js +2 -0
  21. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new/page.js.nft.json +1 -0
  22. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new/page_client-reference-manifest.js +1 -0
  23. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.html +1 -0
  24. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.meta +7 -0
  25. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.rsc +25 -0
  26. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/page.js +2 -2
  27. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/page_client-reference-manifest.js +1 -1
  28. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts.html +1 -1
  29. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts.rsc +8 -8
  30. package/.next/standalone/packages/station-kit/.next/server/app/index.html +1 -1
  31. package/.next/standalone/packages/station-kit/.next/server/app/index.rsc +8 -8
  32. package/.next/standalone/packages/station-kit/.next/server/app/page.js +2 -2
  33. package/.next/standalone/packages/station-kit/.next/server/app/page_client-reference-manifest.js +1 -1
  34. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression/page.js +2 -0
  35. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression/page.js.nft.json +1 -0
  36. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression/page_client-reference-manifest.js +1 -0
  37. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.html +1 -0
  38. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.meta +7 -0
  39. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.rsc +25 -0
  40. package/.next/standalone/packages/station-kit/.next/server/app/runs/[id]/page.js +2 -2
  41. package/.next/standalone/packages/station-kit/.next/server/app/runs/[id]/page_client-reference-manifest.js +1 -1
  42. package/.next/standalone/packages/station-kit/.next/server/app/schedules/[id]/page.js +2 -0
  43. package/.next/standalone/packages/station-kit/.next/server/app/schedules/[id]/page.js.nft.json +1 -0
  44. package/.next/standalone/packages/station-kit/.next/server/app/schedules/[id]/page_client-reference-manifest.js +1 -0
  45. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new/page.js +2 -0
  46. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new/page.js.nft.json +1 -0
  47. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new/page_client-reference-manifest.js +1 -0
  48. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.html +1 -0
  49. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.meta +7 -0
  50. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.rsc +25 -0
  51. package/.next/standalone/packages/station-kit/.next/server/app/schedules/page.js +2 -0
  52. package/.next/standalone/packages/station-kit/.next/server/app/schedules/page.js.nft.json +1 -0
  53. package/.next/standalone/packages/station-kit/.next/server/app/schedules/page_client-reference-manifest.js +1 -0
  54. package/.next/standalone/packages/station-kit/.next/server/app/schedules.html +1 -0
  55. package/.next/standalone/packages/station-kit/.next/server/app/schedules.meta +7 -0
  56. package/.next/standalone/packages/station-kit/.next/server/app/schedules.rsc +25 -0
  57. package/.next/standalone/packages/station-kit/.next/server/app/settings/page.js +2 -2
  58. package/.next/standalone/packages/station-kit/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  59. package/.next/standalone/packages/station-kit/.next/server/app/settings.html +1 -1
  60. package/.next/standalone/packages/station-kit/.next/server/app/settings.rsc +8 -8
  61. package/.next/standalone/packages/station-kit/.next/server/app/signals/[name]/page.js +2 -2
  62. package/.next/standalone/packages/station-kit/.next/server/app/signals/[name]/page_client-reference-manifest.js +1 -1
  63. package/.next/standalone/packages/station-kit/.next/server/app/signals/page.js +2 -2
  64. package/.next/standalone/packages/station-kit/.next/server/app/signals/page_client-reference-manifest.js +1 -1
  65. package/.next/standalone/packages/station-kit/.next/server/app/signals.html +1 -1
  66. package/.next/standalone/packages/station-kit/.next/server/app/signals.rsc +8 -8
  67. package/.next/standalone/packages/station-kit/.next/server/app-paths-manifest.json +10 -3
  68. package/.next/standalone/packages/station-kit/.next/server/chunks/102.js +1 -1
  69. package/.next/standalone/packages/station-kit/.next/server/chunks/535.js +2 -0
  70. package/.next/standalone/packages/station-kit/.next/server/chunks/606.js +14 -14
  71. package/.next/standalone/packages/station-kit/.next/server/chunks/783.js +3 -3
  72. package/.next/standalone/packages/station-kit/.next/server/middleware-build-manifest.js +1 -1
  73. package/.next/standalone/packages/station-kit/.next/server/pages/404.html +1 -1
  74. package/.next/standalone/packages/station-kit/.next/server/pages/500.html +1 -1
  75. package/.next/standalone/packages/station-kit/.next/server/pages/_app.js +1 -1
  76. package/.next/standalone/packages/station-kit/.next/server/pages/_document.js +1 -1
  77. package/.next/standalone/packages/station-kit/.next/server/pages/_error.js +9 -9
  78. package/.next/standalone/packages/station-kit/.next/server/pages-manifest.json +1 -1
  79. package/.next/standalone/packages/station-kit/.next/server/server-reference-manifest.json +1 -1
  80. package/.next/standalone/packages/station-kit/.next/static/THKSkCipW_pj0F6DRXYEG/_buildManifest.js +1 -0
  81. package/.next/standalone/packages/station-kit/.next/static/chunks/145-9e370afd2e5aba39.js +1 -0
  82. package/.next/standalone/packages/station-kit/.next/static/chunks/285-ff198f0a909c4fdd.js +1 -0
  83. package/.next/standalone/packages/station-kit/.next/static/chunks/561-33d912169940283e.js +1 -0
  84. package/.next/standalone/packages/station-kit/.next/static/chunks/935-dff12960528de017.js +1 -0
  85. package/.next/standalone/packages/station-kit/.next/static/chunks/app/_not-found/{page-ce21b4ba9038a5a7.js → page-67ef312aee40cfeb.js} +1 -1
  86. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/[id]/page-fe2f5467a0c68fef.js +1 -0
  87. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/dyn/[name]/page-0d2505242014f51e.js +1 -0
  88. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/dyn/[name]/v/[n]/page-5eac0507f49a00ec.js +1 -0
  89. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/new/page-3d02707043d24dc7.js +1 -0
  90. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/page-dee500ccc01f0821.js +1 -0
  91. package/.next/standalone/packages/station-kit/.next/static/chunks/app/layout-e14e14f3e5b0b8a9.js +1 -0
  92. package/.next/standalone/packages/station-kit/.next/static/chunks/app/page-aac41ef7a470daab.js +1 -0
  93. package/.next/standalone/packages/station-kit/.next/static/chunks/app/playground/expression/page-dc9d91f3f50f4716.js +1 -0
  94. package/.next/standalone/packages/station-kit/.next/static/chunks/app/runs/[id]/page-9e4c4f751a4bea72.js +1 -0
  95. package/.next/standalone/packages/station-kit/.next/static/chunks/app/schedules/[id]/page-435f67be180b8e4f.js +1 -0
  96. package/.next/standalone/packages/station-kit/.next/static/chunks/app/schedules/new/page-f697c289c813496a.js +1 -0
  97. package/.next/standalone/packages/station-kit/.next/static/chunks/app/schedules/page-738d98dc0b63166e.js +1 -0
  98. package/.next/standalone/packages/station-kit/.next/static/chunks/app/settings/page-fc5654b31f57ac21.js +1 -0
  99. package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/[name]/page-4b1c09a539a1ebcd.js +1 -0
  100. package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/page-d2f2403dfede87cc.js +1 -0
  101. package/.next/standalone/packages/station-kit/.next/static/chunks/pages/_app-a3774a320f58a018.js +1 -0
  102. package/.next/standalone/packages/station-kit/package.json +7 -4
  103. package/dist/config/schema.d.ts +23 -0
  104. package/dist/config/schema.d.ts.map +1 -1
  105. package/dist/config/schema.js +2 -0
  106. package/dist/config/schema.js.map +1 -1
  107. package/dist/server/auth/keys.d.ts +91 -8
  108. package/dist/server/auth/keys.d.ts.map +1 -1
  109. package/dist/server/auth/keys.js +289 -54
  110. package/dist/server/auth/keys.js.map +1 -1
  111. package/dist/server/index.d.ts +5 -2
  112. package/dist/server/index.d.ts.map +1 -1
  113. package/dist/server/index.js +84 -9
  114. package/dist/server/index.js.map +1 -1
  115. package/dist/server/log-store.d.ts +102 -6
  116. package/dist/server/log-store.d.ts.map +1 -1
  117. package/dist/server/log-store.js +140 -32
  118. package/dist/server/log-store.js.map +1 -1
  119. package/dist/server/middleware/auth.js +1 -1
  120. package/dist/server/middleware/auth.js.map +1 -1
  121. package/dist/server/routes/broadcasts.d.ts.map +1 -1
  122. package/dist/server/routes/broadcasts.js +3 -1
  123. package/dist/server/routes/broadcasts.js.map +1 -1
  124. package/dist/server/routes/runs.js +1 -1
  125. package/dist/server/routes/runs.js.map +1 -1
  126. package/dist/server/routes/v1/definitions.d.ts +21 -0
  127. package/dist/server/routes/v1/definitions.d.ts.map +1 -0
  128. package/dist/server/routes/v1/definitions.js +139 -0
  129. package/dist/server/routes/v1/definitions.js.map +1 -0
  130. package/dist/server/routes/v1/expressions.d.ts +3 -0
  131. package/dist/server/routes/v1/expressions.d.ts.map +1 -0
  132. package/dist/server/routes/v1/expressions.js +56 -0
  133. package/dist/server/routes/v1/expressions.js.map +1 -0
  134. package/dist/server/routes/v1/keys.js +3 -3
  135. package/dist/server/routes/v1/keys.js.map +1 -1
  136. package/dist/server/routes/v1/runs.js +1 -1
  137. package/dist/server/routes/v1/runs.js.map +1 -1
  138. package/dist/server/routes/v1/schedules.d.ts +10 -0
  139. package/dist/server/routes/v1/schedules.d.ts.map +1 -0
  140. package/dist/server/routes/v1/schedules.js +169 -0
  141. package/dist/server/routes/v1/schedules.js.map +1 -0
  142. package/dist/server/routes/v1/trigger.d.ts.map +1 -1
  143. package/dist/server/routes/v1/trigger.js +21 -0
  144. package/dist/server/routes/v1/trigger.js.map +1 -1
  145. package/package.json +12 -9
  146. package/src/app/broadcasts/components/broadcast-builder.tsx +535 -0
  147. package/src/app/broadcasts/components/dag-editor.tsx +510 -0
  148. package/src/app/broadcasts/dyn/[name]/dynamic-detail.tsx +243 -0
  149. package/src/app/broadcasts/dyn/[name]/page.tsx +10 -0
  150. package/src/app/broadcasts/dyn/[name]/v/[n]/page.tsx +10 -0
  151. package/src/app/broadcasts/dyn/[name]/v/[n]/version-view.tsx +285 -0
  152. package/src/app/broadcasts/new/page.tsx +102 -0
  153. package/src/app/broadcasts/page.tsx +176 -91
  154. package/src/app/components/api-panel.tsx +151 -0
  155. package/src/app/components/shell.tsx +23 -0
  156. package/src/app/hooks/use-api.ts +117 -0
  157. package/src/app/playground/expression/page.tsx +245 -0
  158. package/src/app/schedules/[id]/page.tsx +10 -0
  159. package/src/app/schedules/[id]/schedule-editor.tsx +195 -0
  160. package/src/app/schedules/components/schedule-form.tsx +140 -0
  161. package/src/app/schedules/new/page.tsx +166 -0
  162. package/src/app/schedules/page.tsx +126 -0
  163. package/src/config/schema.ts +25 -0
  164. package/src/server/auth/keys.ts +348 -58
  165. package/src/server/index.ts +118 -11
  166. package/src/server/log-store.ts +196 -45
  167. package/src/server/middleware/auth.ts +1 -1
  168. package/src/server/routes/broadcasts.ts +3 -1
  169. package/src/server/routes/runs.ts +1 -1
  170. package/src/server/routes/v1/definitions.ts +164 -0
  171. package/src/server/routes/v1/expressions.ts +76 -0
  172. package/src/server/routes/v1/keys.ts +3 -3
  173. package/src/server/routes/v1/runs.ts +1 -1
  174. package/src/server/routes/v1/schedules.ts +176 -0
  175. package/src/server/routes/v1/trigger.ts +27 -0
  176. package/.next/standalone/packages/station-kit/.next/static/chunks/580-f007f4d4c050db4e.js +0 -1
  177. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/[id]/page-a0a20cccda13a0e9.js +0 -1
  178. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/page-937eb876f9087bc9.js +0 -1
  179. package/.next/standalone/packages/station-kit/.next/static/chunks/app/layout-68cd71116ba65cd8.js +0 -1
  180. package/.next/standalone/packages/station-kit/.next/static/chunks/app/page-70b0c0958c03459a.js +0 -1
  181. package/.next/standalone/packages/station-kit/.next/static/chunks/app/runs/[id]/page-01f8040619fe56c5.js +0 -1
  182. package/.next/standalone/packages/station-kit/.next/static/chunks/app/settings/page-beac11049f90da31.js +0 -1
  183. package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/[name]/page-931e6a38a4a53d25.js +0 -1
  184. package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/page-6a123a355d93fec5.js +0 -1
  185. package/.next/standalone/packages/station-kit/.next/static/chunks/pages/_app-0a7b2e66ecbe3f0a.js +0 -1
  186. package/.next/standalone/packages/station-kit/.next/static/xYd6dn0Ox68DaamIrH_pB/_buildManifest.js +0 -1
  187. /package/.next/standalone/packages/station-kit/.next/static/{xYd6dn0Ox68DaamIrH_pB → THKSkCipW_pj0F6DRXYEG}/_ssgManifest.js +0 -0
@@ -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;
@@ -13,14 +25,188 @@ export interface ApiKey {
13
25
  revoked: boolean;
14
26
  }
15
27
 
16
- export class KeyStore {
17
- private db: Database.Database;
28
+ export type ApiKeyPublic = Omit<ApiKey, "keyHash">;
29
+
30
+ /**
31
+ * Pluggable storage backend for API keys. Implementations only persist and
32
+ * query records — hashing, key generation, and verification logic live in
33
+ * the KeyStore. May be sync or async; the KeyStore awaits all results.
34
+ */
35
+ export interface ApiKeyStorageAdapter {
36
+ insert(record: ApiKey): Promise<void> | void;
37
+ findByHash(keyHash: string): Promise<ApiKey | null> | ApiKey | null;
38
+ list(): Promise<ApiKeyPublic[]> | ApiKeyPublic[];
39
+ touch(id: string, lastUsedIso: string): Promise<void> | void;
40
+ revoke(id: string): Promise<boolean> | boolean;
41
+ close?(): Promise<void> | void;
42
+ }
43
+
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
+ r.lastUsed = lastUsedIso;
141
+ this.flush();
142
+ }
143
+
144
+ revoke(id: string): boolean {
145
+ const r = this.records.get(id);
146
+ if (!r) return false;
147
+ r.revoked = true;
148
+ this.flush();
149
+ return true;
150
+ }
151
+ }
152
+
153
+ // ─── SQLite (optional) ──────────────────────────────────────────────
18
154
 
19
- constructor(dbPath: string) {
20
- this.db = new Database(dbPath);
155
+ export interface SqliteKeyStorageOptions {
156
+ dbPath: string;
157
+ /** Override the table name (default: "api_keys"). */
158
+ tableName?: string;
159
+ }
160
+
161
+ // Loaded lazily from `better-sqlite3` so the package isn't required at
162
+ // install time. Users who don't construct SqliteKeyStorage never pay for it.
163
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
164
+ type BetterSqlite3Module = any;
165
+ let cachedBetterSqlite3: BetterSqlite3Module | null = null;
166
+
167
+ function loadBetterSqlite3(): BetterSqlite3Module {
168
+ if (cachedBetterSqlite3) return cachedBetterSqlite3;
169
+ try {
170
+ const requireFn = createRequire(import.meta.url);
171
+ cachedBetterSqlite3 = requireFn("better-sqlite3");
172
+ return cachedBetterSqlite3;
173
+ } catch (err) {
174
+ const reason = err instanceof Error ? err.message : String(err);
175
+ throw new Error(
176
+ `SqliteKeyStorage requires the optional 'better-sqlite3' package, ` +
177
+ `which isn't installed. Install it with:\n` +
178
+ ` npm install better-sqlite3\n` +
179
+ `Or use FileKeyStorage (default) / MemoryKeyStorage instead.\n` +
180
+ `Underlying error: ${reason}`,
181
+ );
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Optional ApiKeyStorageAdapter backed by better-sqlite3. Requires the
187
+ * `better-sqlite3` package to be installed separately — Station Kit no
188
+ * longer ships it as a hard dependency.
189
+ *
190
+ * Prefer `FileKeyStorage` (the default) unless you specifically need
191
+ * sqlite features (concurrent reads from multiple processes, large
192
+ * key catalogs, etc.).
193
+ */
194
+ export class SqliteKeyStorage implements ApiKeyStorageAdapter {
195
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
196
+ private db: any;
197
+ private table: string;
198
+
199
+ constructor(options: SqliteKeyStorageOptions) {
200
+ const tableName = options.tableName ?? "api_keys";
201
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
202
+ throw new Error(`Invalid table name "${tableName}"`);
203
+ }
204
+ this.table = tableName;
205
+ const Database = loadBetterSqlite3();
206
+ this.db = new Database(options.dbPath);
21
207
  this.db.pragma("journal_mode = WAL");
22
208
  this.db.exec(`
23
- CREATE TABLE IF NOT EXISTS api_keys (
209
+ CREATE TABLE IF NOT EXISTS ${this.table} (
24
210
  id TEXT PRIMARY KEY,
25
211
  name TEXT NOT NULL,
26
212
  key_hash TEXT NOT NULL UNIQUE,
@@ -34,79 +220,183 @@ export class KeyStore {
34
220
  `);
35
221
  }
36
222
 
223
+ insert(record: ApiKey): void {
224
+ this.db.prepare(`
225
+ INSERT INTO ${this.table}
226
+ (id, name, key_hash, key_prefix, scopes, created_at, last_used, expires_at, revoked)
227
+ VALUES
228
+ (@id, @name, @key_hash, @key_prefix, @scopes, @created_at, @last_used, @expires_at, @revoked)
229
+ `).run({
230
+ id: record.id,
231
+ name: record.name,
232
+ key_hash: record.keyHash,
233
+ key_prefix: record.keyPrefix,
234
+ scopes: JSON.stringify(record.scopes),
235
+ created_at: record.createdAt,
236
+ last_used: record.lastUsed,
237
+ expires_at: record.expiresAt,
238
+ revoked: record.revoked ? 1 : 0,
239
+ });
240
+ }
241
+
242
+ findByHash(keyHash: string): ApiKey | null {
243
+ const row = this.db
244
+ .prepare(`SELECT id, name, key_hash, key_prefix, scopes, created_at, last_used, expires_at, revoked
245
+ FROM ${this.table} WHERE key_hash = ?`)
246
+ .get(keyHash) as Record<string, unknown> | undefined;
247
+ return row ? rowToApiKey(row) : null;
248
+ }
249
+
250
+ list(): ApiKeyPublic[] {
251
+ const rows = this.db
252
+ .prepare(`SELECT id, name, key_prefix, scopes, created_at, last_used, expires_at, revoked
253
+ FROM ${this.table} ORDER BY created_at DESC`)
254
+ .all() as Record<string, unknown>[];
255
+ return rows.map((row) => ({
256
+ id: row.id as string,
257
+ name: row.name as string,
258
+ keyPrefix: row.key_prefix as string,
259
+ scopes: JSON.parse(row.scopes as string),
260
+ createdAt: row.created_at as string,
261
+ lastUsed: (row.last_used as string | null) ?? null,
262
+ expiresAt: (row.expires_at as string | null) ?? null,
263
+ revoked: Boolean(row.revoked),
264
+ }));
265
+ }
266
+
267
+ touch(id: string, lastUsedIso: string): void {
268
+ this.db.prepare(`UPDATE ${this.table} SET last_used = ? WHERE id = ?`).run(lastUsedIso, id);
269
+ }
270
+
271
+ revoke(id: string): boolean {
272
+ const result = this.db.prepare(`UPDATE ${this.table} SET revoked = 1 WHERE id = ?`).run(id);
273
+ return result.changes > 0;
274
+ }
275
+
276
+ close(): void {
277
+ this.db.close();
278
+ }
279
+ }
280
+
281
+ function rowToApiKey(row: Record<string, unknown>): ApiKey {
282
+ return {
283
+ id: row.id as string,
284
+ name: row.name as string,
285
+ keyHash: row.key_hash as string,
286
+ keyPrefix: row.key_prefix as string,
287
+ scopes: JSON.parse(row.scopes as string),
288
+ createdAt: row.created_at as string,
289
+ lastUsed: (row.last_used as string | null) ?? null,
290
+ expiresAt: (row.expires_at as string | null) ?? null,
291
+ revoked: Boolean(row.revoked),
292
+ };
293
+ }
294
+
295
+ // ─── In-memory storage for tests / ephemeral deployments ────────────
296
+
297
+ export class MemoryKeyStorage implements ApiKeyStorageAdapter {
298
+ private records = new Map<string, ApiKey>();
299
+
300
+ insert(record: ApiKey): void {
301
+ this.records.set(record.id, { ...record });
302
+ }
303
+
304
+ findByHash(keyHash: string): ApiKey | null {
305
+ for (const r of this.records.values()) {
306
+ if (r.keyHash === keyHash) return { ...r };
307
+ }
308
+ return null;
309
+ }
310
+
311
+ list(): ApiKeyPublic[] {
312
+ return Array.from(this.records.values())
313
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
314
+ .map((r) => {
315
+ const { keyHash: _h, ...rest } = r;
316
+ return rest;
317
+ });
318
+ }
319
+
320
+ touch(id: string, lastUsedIso: string): void {
321
+ const r = this.records.get(id);
322
+ if (r) r.lastUsed = lastUsedIso;
323
+ }
324
+
325
+ revoke(id: string): boolean {
326
+ const r = this.records.get(id);
327
+ if (!r) return false;
328
+ r.revoked = true;
329
+ return true;
330
+ }
331
+ }
332
+
333
+ // ─── KeyStore — owns crypto, delegates persistence ──────────────────
334
+
335
+ export class KeyStore {
336
+ private storage: ApiKeyStorageAdapter;
337
+
338
+ /**
339
+ * Pass an `ApiKeyStorageAdapter` for any backend. The string overload is
340
+ * retained for backwards compatibility — it constructs a FileKeyStorage
341
+ * at the given path. (Previously this returned a SqliteKeyStorage; SQLite
342
+ * is now opt-in to avoid native build dependencies.)
343
+ */
344
+ constructor(storageOrPath: ApiKeyStorageAdapter | string) {
345
+ if (typeof storageOrPath === "string") {
346
+ const filePath = storageOrPath.endsWith(".db")
347
+ ? storageOrPath.replace(/\.db$/, ".json")
348
+ : storageOrPath;
349
+ this.storage = new FileKeyStorage({ filePath });
350
+ } else {
351
+ this.storage = storageOrPath;
352
+ }
353
+ }
354
+
37
355
  /** Generate a new API key. Returns the full key (only shown once) and the stored record. */
38
- create(name: string, scopes: string[] = ["trigger", "read"]): { key: string; record: ApiKey } {
356
+ async create(name: string, scopes: string[] = ["trigger", "read"]): Promise<{ key: string; record: ApiKey }> {
39
357
  const id = crypto.randomUUID();
40
358
  const rawKey = `sk_live_${crypto.randomBytes(16).toString("hex")}`;
41
359
  const keyHash = crypto.createHash("sha256").update(rawKey).digest("hex");
42
360
  const keyPrefix = rawKey.slice(0, 12);
43
361
  const createdAt = new Date().toISOString();
44
362
 
45
- this.db.prepare(`
46
- INSERT INTO api_keys (id, name, key_hash, key_prefix, scopes, created_at)
47
- VALUES (?, ?, ?, ?, ?, ?)
48
- `).run(id, name, keyHash, keyPrefix, JSON.stringify(scopes), createdAt);
49
-
50
- return {
51
- key: rawKey,
52
- record: { id, name, keyHash, keyPrefix, scopes, createdAt, lastUsed: null, expiresAt: null, revoked: false },
363
+ const record: ApiKey = {
364
+ id, name, keyHash, keyPrefix, scopes, createdAt,
365
+ lastUsed: null, expiresAt: null, revoked: false,
53
366
  };
367
+ await this.storage.insert(record);
368
+ return { key: rawKey, record };
54
369
  }
55
370
 
56
371
  /** Verify an API key. Returns the key record if valid, null otherwise. */
57
- verify(rawKey: string): ApiKey | null {
372
+ async verify(rawKey: string): Promise<ApiKey | null> {
58
373
  const keyHash = crypto.createHash("sha256").update(rawKey).digest("hex");
59
- const row = this.db.prepare(`
60
- SELECT id, name, key_hash, key_prefix, scopes, created_at, last_used, expires_at, revoked
61
- FROM api_keys WHERE key_hash = ?
62
- `).get(keyHash) as Record<string, unknown> | undefined;
374
+ const record = await this.storage.findByHash(keyHash);
375
+ if (!record) return null;
376
+ if (record.revoked) return null;
377
+ if (record.expiresAt && new Date(record.expiresAt) < new Date()) return null;
63
378
 
64
- if (!row) return null;
65
- if (row.revoked) return null;
66
- if (row.expires_at && new Date(row.expires_at as string) < new Date()) return null;
379
+ // Touch is best-effort — don't block verification on the write. Wrap in
380
+ // an explicit deferred so a synchronous throw from a sync `touch()` is
381
+ // also swallowed, matching the async case.
382
+ Promise.resolve()
383
+ .then(() => this.storage.touch(record.id, new Date().toISOString()))
384
+ .catch(() => {});
67
385
 
68
- // Update last_used
69
- this.db.prepare("UPDATE api_keys SET last_used = ? WHERE id = ?").run(new Date().toISOString(), row.id);
70
-
71
- return {
72
- id: row.id as string,
73
- name: row.name as string,
74
- keyHash: row.key_hash as string,
75
- keyPrefix: row.key_prefix as string,
76
- scopes: JSON.parse(row.scopes as string),
77
- createdAt: row.created_at as string,
78
- lastUsed: row.last_used as string | null,
79
- expiresAt: row.expires_at as string | null,
80
- revoked: Boolean(row.revoked),
81
- };
386
+ return record;
82
387
  }
83
388
 
84
389
  /** List all keys (without hashes). */
85
- list(): Omit<ApiKey, "keyHash">[] {
86
- const rows = this.db.prepare(`
87
- SELECT id, name, key_prefix, scopes, created_at, last_used, expires_at, revoked
88
- FROM api_keys ORDER BY created_at DESC
89
- `).all() as Record<string, unknown>[];
90
-
91
- return rows.map((row) => ({
92
- id: row.id as string,
93
- name: row.name as string,
94
- keyPrefix: row.key_prefix as string,
95
- scopes: JSON.parse(row.scopes as string),
96
- createdAt: row.created_at as string,
97
- lastUsed: row.last_used as string | null,
98
- expiresAt: row.expires_at as string | null,
99
- revoked: Boolean(row.revoked),
100
- }));
390
+ async list(): Promise<ApiKeyPublic[]> {
391
+ return this.storage.list();
101
392
  }
102
393
 
103
394
  /** Revoke a key by ID. */
104
- revoke(id: string): boolean {
105
- const result = this.db.prepare("UPDATE api_keys SET revoked = 1 WHERE id = ?").run(id);
106
- return result.changes > 0;
395
+ async revoke(id: string): Promise<boolean> {
396
+ return this.storage.revoke(id);
107
397
  }
108
398
 
109
- close(): void {
110
- this.db.close();
399
+ async close(): Promise<void> {
400
+ if (this.storage.close) await this.storage.close();
111
401
  }
112
402
  }
@@ -4,22 +4,27 @@ import { serve } from "@hono/node-server";
4
4
  import { resolve } from "node:path";
5
5
  import { existsSync } from "node:fs";
6
6
  import type { Server } from "node:http";
7
- import { SignalRunner, MemoryAdapter } from "station-signal";
7
+ import { SignalRunner, MemoryAdapter, parseInterval } from "station-signal";
8
8
  import { BroadcastRunner, BroadcastMemoryAdapter } from "station-broadcast";
9
9
  import type { SignalQueueAdapter } from "station-signal";
10
10
  import type { BroadcastQueueAdapter } from "station-broadcast";
11
+ import {
12
+ ScheduleReconciler,
13
+ type Schedule,
14
+ type ScheduleAdapter,
15
+ } from "station-schedules";
11
16
  import type { StationConfig } from "../config/schema.js";
12
17
  import { ensureStationDir } from "../station-dir.js";
13
18
  import { WebSocketHub } from "./ws.js";
14
19
  import { SSEHub } from "./sse.js";
15
20
  import { LogBuffer } from "./log-buffer.js";
16
- import { LogStore } from "./log-store.js";
21
+ import { LogStore, FileLogStorage } from "./log-store.js";
17
22
  import { StationSignalSubscriber, StationBroadcastSubscriber } from "./subscriber.js";
18
23
  import { healthRoutes } from "./routes/health.js";
19
24
  import { signalRoutes } from "./routes/signals.js";
20
25
  import { runRoutes } from "./routes/runs.js";
21
26
  import { broadcastRoutes } from "./routes/broadcasts.js";
22
- import { KeyStore } from "./auth/keys.js";
27
+ import { KeyStore, FileKeyStorage } from "./auth/keys.js";
23
28
  import { verifySessionToken, verifyCredentials, createSessionToken, type SessionConfig } from "./auth/session.js";
24
29
  import { authResolver } from "./middleware/auth.js";
25
30
  import { requireScope } from "./middleware/scope-guard.js";
@@ -32,9 +37,33 @@ import { v1TriggerRoutes } from "./routes/v1/trigger.js";
32
37
  import { v1KeyRoutes } from "./routes/v1/keys.js";
33
38
  import { v1AuthRoutes } from "./routes/v1/auth.js";
34
39
  import { v1EventRoutes } from "./routes/v1/events.js";
35
-
36
- export { KeyStore } from "./auth/keys.js";
37
- export type { ApiKey } from "./auth/keys.js";
40
+ import { v1DefinitionRoutes, v1DefinitionReadRoutes } from "./routes/v1/definitions.js";
41
+ import { v1ScheduleRoutes, v1ScheduleReadRoutes } from "./routes/v1/schedules.js";
42
+ import { v1ExpressionRoutes } from "./routes/v1/expressions.js";
43
+
44
+ export {
45
+ KeyStore,
46
+ FileKeyStorage,
47
+ SqliteKeyStorage,
48
+ MemoryKeyStorage,
49
+ } from "./auth/keys.js";
50
+ export type {
51
+ ApiKey,
52
+ ApiKeyPublic,
53
+ ApiKeyStorageAdapter,
54
+ FileKeyStorageOptions,
55
+ SqliteKeyStorageOptions,
56
+ } from "./auth/keys.js";
57
+ export {
58
+ LogStore,
59
+ FileLogStorage,
60
+ MemoryLogStorage,
61
+ } from "./log-store.js";
62
+ export type {
63
+ LogStorageAdapter,
64
+ FileLogStorageOptions,
65
+ } from "./log-store.js";
66
+ export type { LogEntry } from "./log-buffer.js";
38
67
 
39
68
  export interface StationInstance {
40
69
  start(): Promise<void>;
@@ -52,17 +81,26 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
52
81
 
53
82
  const { dataDir } = ensureStationDir(cwd, config.stationDir);
54
83
 
84
+ warnIfLegacySqliteFiles(dataDir);
85
+
55
86
  const wsHub = new WebSocketHub();
56
87
  const sseHub = new SSEHub();
57
88
  const logBuffer = new LogBuffer();
58
- const logStore = new LogStore(resolve(dataDir, "station-logs.db"));
89
+ const logStore = new LogStore(
90
+ config.logStorage ?? new FileLogStorage({
91
+ filePath: resolve(dataDir, "station-logs.jsonl"),
92
+ onError: (err) => console.error("[station] log write failed:", err),
93
+ }),
94
+ );
59
95
 
60
96
  // Auth: create KeyStore and SessionConfig if auth is configured
61
97
  let keyStore: KeyStore | undefined;
62
98
  let sessionConfig: SessionConfig | undefined;
63
99
 
64
100
  if (config.auth) {
65
- keyStore = new KeyStore(resolve(dataDir, "station-keys.db"));
101
+ const storage = config.auth.keyStorage
102
+ ?? new FileKeyStorage({ filePath: resolve(dataDir, "station-keys.json") });
103
+ keyStore = new KeyStore(storage);
66
104
  sessionConfig = {
67
105
  username: config.auth.username,
68
106
  password: config.auth.password,
@@ -94,8 +132,23 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
94
132
  // Create runners if enabled
95
133
  let signalRunner: SignalRunner | undefined;
96
134
  let broadcastRunner: BroadcastRunner | undefined;
135
+ const scheduleAdapter: ScheduleAdapter | undefined = config.scheduleAdapter;
97
136
 
98
137
  if (config.runRunners) {
138
+ // Build schedule reconcilers up front. Each reconciler handles only the
139
+ // kinds it's responsible for; the runner ticks it once per loop.
140
+ const signalScheduleReconciler = scheduleAdapter
141
+ ? new ScheduleReconciler({
142
+ adapter: scheduleAdapter,
143
+ kinds: ["signal"],
144
+ parseInterval,
145
+ triggerFn: (s: Schedule) => signalRunner!.triggerSignal(s.target, s.input ?? {}),
146
+ hasPendingOrRunning: (s: Schedule) =>
147
+ signalRunner!.hasPendingOrRunningForSignal(s.target),
148
+ onError: (err) => console.error("[station] Signal schedule reconciler:", err),
149
+ })
150
+ : undefined;
151
+
99
152
  signalRunner = new SignalRunner({
100
153
  signalsDir,
101
154
  adapter: signalAdapter,
@@ -104,15 +157,29 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
104
157
  maxAttempts: config.runner.maxAttempts,
105
158
  retryBackoffMs: config.runner.retryBackoffMs,
106
159
  subscribers: [stationSignalSub],
160
+ scheduleReconciler: signalScheduleReconciler,
107
161
  });
108
162
 
109
163
  if (broadcastsDir || broadcastAdapter) {
164
+ const broadcastScheduleReconciler = scheduleAdapter
165
+ ? new ScheduleReconciler({
166
+ adapter: scheduleAdapter,
167
+ kinds: ["broadcast-static", "broadcast-dynamic"],
168
+ parseInterval,
169
+ triggerFn: (s: Schedule) => broadcastRunner!.trigger(s.target, s.input ?? {}),
170
+ hasPendingOrRunning: (s: Schedule) =>
171
+ broadcastRunner!.hasPendingOrRunningForBroadcast(s.target),
172
+ onError: (err) => console.error("[station] Broadcast schedule reconciler:", err),
173
+ })
174
+ : undefined;
175
+
110
176
  broadcastRunner = new BroadcastRunner({
111
177
  signalRunner,
112
178
  broadcastsDir,
113
179
  adapter: broadcastAdapter ?? new BroadcastMemoryAdapter(),
114
180
  pollIntervalMs: config.broadcastRunner.pollIntervalMs,
115
181
  subscribers: [stationBroadcastSub],
182
+ scheduleReconciler: broadcastScheduleReconciler,
116
183
  });
117
184
  }
118
185
  }
@@ -204,6 +271,15 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
204
271
  readRoutes.route("/", v1RunRoutes({ signalRunner, signalAdapter, logBuffer, logStore }));
205
272
  readRoutes.route("/", v1BroadcastRoutes({ broadcastRunner, broadcastAdapter, broadcastSubscriber: stationBroadcastSub }));
206
273
  readRoutes.route("/", v1EventRoutes({ sseHub }));
274
+ readRoutes.route("/", v1ExpressionRoutes());
275
+ // Schedule GET + preview are read-scoped; mutating routes are mounted under admin below.
276
+ readRoutes.route("/", v1ScheduleReadRoutes({ scheduleAdapter }));
277
+ readRoutes.route("/", v1DefinitionReadRoutes({
278
+ broadcastRunner,
279
+ broadcastAdapter,
280
+ signalRunner,
281
+ signalSubscriber: stationSignalSub,
282
+ }));
207
283
  v1.route("/", readRoutes);
208
284
 
209
285
  // Trigger-scope routes
@@ -239,10 +315,17 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
239
315
  });
240
316
  v1.route("/", cancelRoutes);
241
317
 
242
- // Admin-scope routes
318
+ // Admin-scope routes — destructive / mutating endpoints
243
319
  const adminRoutes = new Hono();
244
320
  adminRoutes.use("/*", requireScope("admin"));
245
321
  adminRoutes.route("/", v1KeyRoutes({ keyStore }));
322
+ adminRoutes.route("/", v1DefinitionRoutes({
323
+ broadcastRunner,
324
+ broadcastAdapter,
325
+ signalRunner,
326
+ signalSubscriber: stationSignalSub,
327
+ }));
328
+ adminRoutes.route("/", v1ScheduleRoutes({ scheduleAdapter }));
246
329
  v1.route("/", adminRoutes);
247
330
 
248
331
  app.route("/api/v1", v1);
@@ -337,11 +420,35 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
337
420
  }
338
421
  wsHub.close();
339
422
  sseHub.close();
340
- logStore.close();
341
- keyStore?.close();
423
+ await logStore.close();
424
+ await keyStore?.close();
342
425
  if (httpServer) {
343
426
  httpServer.close();
344
427
  }
345
428
  },
346
429
  };
347
430
  }
431
+
432
+ // Existing deployments that ran older Station versions persisted keys
433
+ // to `station-keys.db` (SQLite) and run logs to `station-logs.db`. The
434
+ // new defaults are `station-keys.json` and `station-logs.jsonl`; the
435
+ // legacy files are NOT auto-migrated. Emit a one-time warning so an
436
+ // upgrade doesn't silently appear to wipe a user's API keys.
437
+ function warnIfLegacySqliteFiles(dataDir: string): void {
438
+ const legacy: { file: string; replacement: string }[] = [
439
+ { file: "station-keys.db", replacement: "station-keys.json" },
440
+ { file: "station-logs.db", replacement: "station-logs.jsonl" },
441
+ ];
442
+ for (const { file, replacement } of legacy) {
443
+ const legacyPath = resolve(dataDir, file);
444
+ if (!existsSync(legacyPath)) continue;
445
+ const replacementPath = resolve(dataDir, replacement);
446
+ if (existsSync(replacementPath)) continue;
447
+ console.warn(
448
+ `[station] Legacy ${file} detected at ${legacyPath} but no ${replacement} found. ` +
449
+ `Station no longer reads SQLite-backed defaults; data in ${file} will not be loaded. ` +
450
+ `If you need the contents, export them with the better-sqlite3 CLI before upgrading. ` +
451
+ `To suppress this warning, delete or rename ${file}.`,
452
+ );
453
+ }
454
+ }