station-kit 1.0.9 → 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.
- package/.next/standalone/packages/station-kit/.next/BUILD_ID +1 -1
- package/.next/standalone/packages/station-kit/.next/app-build-manifest.json +29 -29
- package/.next/standalone/packages/station-kit/.next/app-path-routes-manifest.json +7 -7
- package/.next/standalone/packages/station-kit/.next/build-manifest.json +2 -2
- package/.next/standalone/packages/station-kit/.next/prerender-manifest.json +12 -12
- package/.next/standalone/packages/station-kit/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/_not-found.rsc +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/[id]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/v/[n]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.rsc +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts.rsc +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/index.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/index.rsc +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/playground/expression/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.rsc +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/runs/[id]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/[id]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/new/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.rsc +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/schedules.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/schedules.rsc +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/settings.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/settings.rsc +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/signals/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/signals/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/signals.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/signals.rsc +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app-paths-manifest.json +7 -7
- package/.next/standalone/packages/station-kit/.next/server/pages/404.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/pages/500.html +1 -1
- package/.next/standalone/packages/station-kit/package.json +3 -4
- package/dist/config/schema.d.ts +13 -3
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +1 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/server/auth/keys.d.ts +41 -6
- package/dist/server/auth/keys.d.ts.map +1 -1
- package/dist/server/auth/keys.js +143 -10
- package/dist/server/auth/keys.js.map +1 -1
- package/dist/server/index.d.ts +5 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +34 -6
- package/dist/server/index.js.map +1 -1
- package/dist/server/log-store.d.ts +102 -6
- package/dist/server/log-store.d.ts.map +1 -1
- package/dist/server/log-store.js +140 -32
- package/dist/server/log-store.js.map +1 -1
- package/dist/server/routes/broadcasts.d.ts.map +1 -1
- package/dist/server/routes/broadcasts.js +3 -1
- package/dist/server/routes/broadcasts.js.map +1 -1
- package/dist/server/routes/runs.js +1 -1
- package/dist/server/routes/runs.js.map +1 -1
- package/dist/server/routes/v1/runs.js +1 -1
- package/dist/server/routes/v1/runs.js.map +1 -1
- package/package.json +4 -5
- package/src/config/schema.ts +14 -3
- package/src/server/auth/keys.ts +167 -12
- package/src/server/index.ts +48 -5
- package/src/server/log-store.ts +196 -45
- package/src/server/routes/broadcasts.ts +3 -1
- package/src/server/routes/runs.ts +1 -1
- package/src/server/routes/v1/runs.ts +1 -1
- /package/.next/standalone/packages/station-kit/.next/static/{demLiQWDy62JuUkBw-ILG → THKSkCipW_pj0F6DRXYEG}/_buildManifest.js +0 -0
- /package/.next/standalone/packages/station-kit/.next/static/{demLiQWDy62JuUkBw-ILG → THKSkCipW_pj0F6DRXYEG}/_ssgManifest.js +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "station-kit",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Dashboard for station-signal — inspect and control signals and broadcasts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -35,7 +35,6 @@
|
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@hono/node-server": "^1",
|
|
38
|
-
"better-sqlite3": "^11.9.1",
|
|
39
38
|
"esbuild": "^0.25.12",
|
|
40
39
|
"hono": "^4",
|
|
41
40
|
"next": "^15",
|
|
@@ -47,20 +46,20 @@
|
|
|
47
46
|
"station-schedules": "1.0.4"
|
|
48
47
|
},
|
|
49
48
|
"devDependencies": {
|
|
50
|
-
"@types/better-sqlite3": "^7.6.13",
|
|
51
49
|
"@types/node": "^25.3.0",
|
|
52
50
|
"@types/react": "^19",
|
|
53
51
|
"@types/react-dom": "^19",
|
|
54
52
|
"@types/ws": "^8",
|
|
53
|
+
"better-sqlite3": "^11.9.1",
|
|
55
54
|
"typescript": "^5.9.3",
|
|
56
|
-
"station-broadcast": "1.0.4",
|
|
57
55
|
"station-signal": "1.0.4",
|
|
56
|
+
"station-broadcast": "1.0.4",
|
|
58
57
|
"station-adapter-sqlite": "1.0.4"
|
|
59
58
|
},
|
|
60
59
|
"scripts": {
|
|
61
60
|
"build": "tsc && next build && cp -r .next/static .next/standalone/packages/station-kit/.next/static",
|
|
62
61
|
"dev": "tsx src/cli.ts",
|
|
63
62
|
"typecheck": "tsc --noEmit",
|
|
64
|
-
"test": "node --import tsx --test test/**/*.test.ts"
|
|
63
|
+
"test": "node --import tsx --test \"test/**/*.test.ts\""
|
|
65
64
|
}
|
|
66
65
|
}
|
package/src/config/schema.ts
CHANGED
|
@@ -2,15 +2,17 @@ import type { SignalQueueAdapter } from "station-signal";
|
|
|
2
2
|
import type { BroadcastQueueAdapter } from "station-broadcast";
|
|
3
3
|
import type { ScheduleAdapter } from "station-schedules";
|
|
4
4
|
import type { ApiKeyStorageAdapter } from "../server/auth/keys.js";
|
|
5
|
+
import type { LogStorageAdapter } from "../server/log-store.js";
|
|
5
6
|
|
|
6
7
|
export interface AuthConfig {
|
|
7
8
|
username: string;
|
|
8
9
|
password: string;
|
|
9
10
|
sessionTtlMs?: number;
|
|
10
11
|
/**
|
|
11
|
-
* Pluggable storage backend for API keys. Defaults to a
|
|
12
|
-
* `<dataDir>/station-keys.
|
|
13
|
-
* Postgres, MySQL,
|
|
12
|
+
* Pluggable storage backend for API keys. Defaults to a JSON file at
|
|
13
|
+
* `<dataDir>/station-keys.json` (no native dependencies required).
|
|
14
|
+
* Provide a custom adapter to host keys in SQLite, Postgres, MySQL,
|
|
15
|
+
* Redis, etc.
|
|
14
16
|
*/
|
|
15
17
|
keyStorage?: ApiKeyStorageAdapter;
|
|
16
18
|
}
|
|
@@ -40,6 +42,14 @@ export interface StationConfig {
|
|
|
40
42
|
* schedules are persisted here and reconciled by both runners.
|
|
41
43
|
*/
|
|
42
44
|
scheduleAdapter?: ScheduleAdapter;
|
|
45
|
+
/**
|
|
46
|
+
* Pluggable storage backend for run logs. Defaults to a `FileLogStorage`
|
|
47
|
+
* (append-only JSONL file at `<dataDir>/station-logs.jsonl`, no native
|
|
48
|
+
* dependencies). The default is single-process only — for multi-process
|
|
49
|
+
* deployments or guaranteed durability, implement `LogStorageAdapter`
|
|
50
|
+
* against Postgres, MySQL, Redis, S3, etc., and pass it here.
|
|
51
|
+
*/
|
|
52
|
+
logStorage?: LogStorageAdapter;
|
|
43
53
|
signalsDir?: string;
|
|
44
54
|
broadcastsDir?: string;
|
|
45
55
|
stationDir: string;
|
|
@@ -99,6 +109,7 @@ export function resolveConfig(input: StationUserConfig): StationConfig {
|
|
|
99
109
|
adapter: input.adapter,
|
|
100
110
|
broadcastAdapter: input.broadcastAdapter,
|
|
101
111
|
scheduleAdapter: input.scheduleAdapter,
|
|
112
|
+
logStorage: input.logStorage,
|
|
102
113
|
signalsDir: input.signalsDir,
|
|
103
114
|
broadcastsDir: input.broadcastsDir,
|
|
104
115
|
stationDir: input.stationDir ?? DEFAULTS.stationDir,
|
package/src/server/auth/keys.ts
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
-
import
|
|
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,116 @@ export interface ApiKeyStorageAdapter {
|
|
|
29
41
|
close?(): Promise<void> | void;
|
|
30
42
|
}
|
|
31
43
|
|
|
32
|
-
// ───
|
|
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) ──────────────────────────────────────────────
|
|
33
154
|
|
|
34
155
|
export interface SqliteKeyStorageOptions {
|
|
35
156
|
dbPath: string;
|
|
@@ -37,13 +158,42 @@ export interface SqliteKeyStorageOptions {
|
|
|
37
158
|
tableName?: string;
|
|
38
159
|
}
|
|
39
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
|
+
|
|
40
185
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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.).
|
|
44
193
|
*/
|
|
45
194
|
export class SqliteKeyStorage implements ApiKeyStorageAdapter {
|
|
46
|
-
|
|
195
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
196
|
+
private db: any;
|
|
47
197
|
private table: string;
|
|
48
198
|
|
|
49
199
|
constructor(options: SqliteKeyStorageOptions) {
|
|
@@ -52,6 +202,7 @@ export class SqliteKeyStorage implements ApiKeyStorageAdapter {
|
|
|
52
202
|
throw new Error(`Invalid table name "${tableName}"`);
|
|
53
203
|
}
|
|
54
204
|
this.table = tableName;
|
|
205
|
+
const Database = loadBetterSqlite3();
|
|
55
206
|
this.db = new Database(options.dbPath);
|
|
56
207
|
this.db.pragma("journal_mode = WAL");
|
|
57
208
|
this.db.exec(`
|
|
@@ -186,14 +337,18 @@ export class KeyStore {
|
|
|
186
337
|
|
|
187
338
|
/**
|
|
188
339
|
* Pass an `ApiKeyStorageAdapter` for any backend. The string overload is
|
|
189
|
-
* retained for backwards compatibility — it constructs a
|
|
190
|
-
* at the given path.
|
|
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.)
|
|
191
343
|
*/
|
|
192
|
-
constructor(
|
|
193
|
-
if (typeof
|
|
194
|
-
|
|
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 });
|
|
195
350
|
} else {
|
|
196
|
-
this.storage =
|
|
351
|
+
this.storage = storageOrPath;
|
|
197
352
|
}
|
|
198
353
|
}
|
|
199
354
|
|
package/src/server/index.ts
CHANGED
|
@@ -18,13 +18,13 @@ import { ensureStationDir } from "../station-dir.js";
|
|
|
18
18
|
import { WebSocketHub } from "./ws.js";
|
|
19
19
|
import { SSEHub } from "./sse.js";
|
|
20
20
|
import { LogBuffer } from "./log-buffer.js";
|
|
21
|
-
import { LogStore } from "./log-store.js";
|
|
21
|
+
import { LogStore, FileLogStorage } from "./log-store.js";
|
|
22
22
|
import { StationSignalSubscriber, StationBroadcastSubscriber } from "./subscriber.js";
|
|
23
23
|
import { healthRoutes } from "./routes/health.js";
|
|
24
24
|
import { signalRoutes } from "./routes/signals.js";
|
|
25
25
|
import { runRoutes } from "./routes/runs.js";
|
|
26
26
|
import { broadcastRoutes } from "./routes/broadcasts.js";
|
|
27
|
-
import { KeyStore,
|
|
27
|
+
import { KeyStore, FileKeyStorage } from "./auth/keys.js";
|
|
28
28
|
import { verifySessionToken, verifyCredentials, createSessionToken, type SessionConfig } from "./auth/session.js";
|
|
29
29
|
import { authResolver } from "./middleware/auth.js";
|
|
30
30
|
import { requireScope } from "./middleware/scope-guard.js";
|
|
@@ -43,6 +43,7 @@ import { v1ExpressionRoutes } from "./routes/v1/expressions.js";
|
|
|
43
43
|
|
|
44
44
|
export {
|
|
45
45
|
KeyStore,
|
|
46
|
+
FileKeyStorage,
|
|
46
47
|
SqliteKeyStorage,
|
|
47
48
|
MemoryKeyStorage,
|
|
48
49
|
} from "./auth/keys.js";
|
|
@@ -50,8 +51,19 @@ export type {
|
|
|
50
51
|
ApiKey,
|
|
51
52
|
ApiKeyPublic,
|
|
52
53
|
ApiKeyStorageAdapter,
|
|
54
|
+
FileKeyStorageOptions,
|
|
53
55
|
SqliteKeyStorageOptions,
|
|
54
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";
|
|
55
67
|
|
|
56
68
|
export interface StationInstance {
|
|
57
69
|
start(): Promise<void>;
|
|
@@ -69,10 +81,17 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
|
|
|
69
81
|
|
|
70
82
|
const { dataDir } = ensureStationDir(cwd, config.stationDir);
|
|
71
83
|
|
|
84
|
+
warnIfLegacySqliteFiles(dataDir);
|
|
85
|
+
|
|
72
86
|
const wsHub = new WebSocketHub();
|
|
73
87
|
const sseHub = new SSEHub();
|
|
74
88
|
const logBuffer = new LogBuffer();
|
|
75
|
-
const logStore = new LogStore(
|
|
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
|
+
);
|
|
76
95
|
|
|
77
96
|
// Auth: create KeyStore and SessionConfig if auth is configured
|
|
78
97
|
let keyStore: KeyStore | undefined;
|
|
@@ -80,7 +99,7 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
|
|
|
80
99
|
|
|
81
100
|
if (config.auth) {
|
|
82
101
|
const storage = config.auth.keyStorage
|
|
83
|
-
?? new
|
|
102
|
+
?? new FileKeyStorage({ filePath: resolve(dataDir, "station-keys.json") });
|
|
84
103
|
keyStore = new KeyStore(storage);
|
|
85
104
|
sessionConfig = {
|
|
86
105
|
username: config.auth.username,
|
|
@@ -401,7 +420,7 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
|
|
|
401
420
|
}
|
|
402
421
|
wsHub.close();
|
|
403
422
|
sseHub.close();
|
|
404
|
-
logStore.close();
|
|
423
|
+
await logStore.close();
|
|
405
424
|
await keyStore?.close();
|
|
406
425
|
if (httpServer) {
|
|
407
426
|
httpServer.close();
|
|
@@ -409,3 +428,27 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
|
|
|
409
428
|
},
|
|
410
429
|
};
|
|
411
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
|
+
}
|
package/src/server/log-store.ts
CHANGED
|
@@ -1,56 +1,207 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { appendFile } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
2
4
|
import type { LogEntry } from "./log-buffer.js";
|
|
3
5
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Pluggable storage backend for run logs. Implementations only persist
|
|
8
|
+
* and query records — bounded in-memory buffering for live UI streams
|
|
9
|
+
* lives in `LogBuffer`. May be sync or async; the LogStore wrapper
|
|
10
|
+
* normalizes both.
|
|
11
|
+
*
|
|
12
|
+
* Contract for implementers:
|
|
13
|
+
*
|
|
14
|
+
* - **`add(entry)`** is treated as fire-and-forget at the LogStore
|
|
15
|
+
* boundary. Signal runners never block on log writes. Adapters that
|
|
16
|
+
* need durability guarantees (queues, retries, batching) should
|
|
17
|
+
* implement that internally; thrown errors and rejected promises are
|
|
18
|
+
* caught and surfaced via the LogStore's `onError` hook (if set) but
|
|
19
|
+
* never rethrown to the caller.
|
|
20
|
+
* - **`get(runId)`** must return entries for that run in append order
|
|
21
|
+
* (oldest first). Routes that aggregate across runs may re-sort by
|
|
22
|
+
* timestamp, but per-run ordering is the adapter's responsibility.
|
|
23
|
+
* - **`close?()`** is called once on graceful shutdown. Use it to flush
|
|
24
|
+
* any in-flight buffers. It is NOT called on `SIGKILL` / OOM kill —
|
|
25
|
+
* adapters that must guarantee durability per write should not rely
|
|
26
|
+
* on it.
|
|
27
|
+
*
|
|
28
|
+
* Single-process semantics: the built-in `FileLogStorage` is safe for a
|
|
29
|
+
* single Node process. Running multiple processes against the same file
|
|
30
|
+
* path WILL produce interleaved bytes and lost entries — use a real
|
|
31
|
+
* database adapter (Postgres, MySQL, Redis, etc.) for multi-process or
|
|
32
|
+
* distributed deployments.
|
|
33
|
+
*/
|
|
34
|
+
export interface LogStorageAdapter {
|
|
35
|
+
add(entry: LogEntry): Promise<void> | void;
|
|
36
|
+
get(runId: string): Promise<LogEntry[]> | LogEntry[];
|
|
37
|
+
close?(): Promise<void> | void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── File-backed default ────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
export interface FileLogStorageOptions {
|
|
43
|
+
filePath: string;
|
|
44
|
+
/**
|
|
45
|
+
* Called when a background write to the underlying file fails. Use
|
|
46
|
+
* this to surface persistence problems (disk full, permission denied,
|
|
47
|
+
* etc.) to your monitoring system. If unset, write failures are
|
|
48
|
+
* silently dropped — acceptable for local dev, NOT for production.
|
|
49
|
+
*/
|
|
50
|
+
onError?: (err: unknown) => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* File-backed log storage using append-only JSONL framing. Each line is
|
|
55
|
+
* a JSON-serialized `LogEntry`; existing entries are loaded into memory
|
|
56
|
+
* on construction; appends are serialized through an async write queue
|
|
57
|
+
* so concurrent writers can't interleave bytes within one process.
|
|
58
|
+
*
|
|
59
|
+
* No native dependencies — works on any Node 18+ install.
|
|
60
|
+
*
|
|
61
|
+
* **Production caveats** (in order of severity):
|
|
62
|
+
*
|
|
63
|
+
* 1. **Single-process only.** Two Node processes appending to the same
|
|
64
|
+
* file WILL interleave bytes once individual JSON lines exceed the
|
|
65
|
+
* OS pipe buffer (4 KB on Linux), corrupting the file.
|
|
66
|
+
* 2. **Best-effort durability.** Writes are queued and flushed via
|
|
67
|
+
* `fs.appendFile`; on `SIGKILL` / OOM kill, in-flight writes are lost.
|
|
68
|
+
* Set `onError` to surface fs failures.
|
|
69
|
+
* 3. **Unbounded memory on replay.** The whole file is loaded into a
|
|
70
|
+
* Map on startup. For high-volume deployments (gigabytes of logs)
|
|
71
|
+
* use a database-backed adapter instead.
|
|
72
|
+
*
|
|
73
|
+
* For multi-process, distributed, or high-durability deployments,
|
|
74
|
+
* implement `LogStorageAdapter` against Postgres / MySQL / Redis / S3.
|
|
75
|
+
*/
|
|
76
|
+
export class FileLogStorage implements LogStorageAdapter {
|
|
77
|
+
private path: string;
|
|
78
|
+
private byRunId = new Map<string, LogEntry[]>();
|
|
79
|
+
private writeQueue: Promise<void> = Promise.resolve();
|
|
80
|
+
private onError: (err: unknown) => void;
|
|
81
|
+
|
|
82
|
+
constructor(options: FileLogStorageOptions) {
|
|
83
|
+
// Backwards compat: callers used to pass `.db` paths for the sqlite store.
|
|
84
|
+
// Transparently swap to `.jsonl` so existing config files keep working.
|
|
85
|
+
this.path = options.filePath.endsWith(".db")
|
|
86
|
+
? options.filePath.replace(/\.db$/, ".jsonl")
|
|
87
|
+
: options.filePath;
|
|
88
|
+
this.onError = options.onError ?? (() => {});
|
|
89
|
+
mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
90
|
+
this.replay();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private replay(): void {
|
|
94
|
+
if (!existsSync(this.path)) return;
|
|
95
|
+
let content: string;
|
|
96
|
+
try {
|
|
97
|
+
content = readFileSync(this.path, "utf8");
|
|
98
|
+
} catch (err) {
|
|
99
|
+
this.onError(err);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const lines = content.split("\n");
|
|
103
|
+
for (const line of lines) {
|
|
104
|
+
if (!line) continue;
|
|
105
|
+
try {
|
|
106
|
+
const entry = JSON.parse(line) as LogEntry;
|
|
107
|
+
this.indexEntry(entry);
|
|
108
|
+
} catch {
|
|
109
|
+
// Skip malformed line; a partial write may have left a truncated tail.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private indexEntry(entry: LogEntry): void {
|
|
115
|
+
let entries = this.byRunId.get(entry.runId);
|
|
116
|
+
if (!entries) {
|
|
117
|
+
entries = [];
|
|
118
|
+
this.byRunId.set(entry.runId, entries);
|
|
119
|
+
}
|
|
120
|
+
entries.push(entry);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
add(entry: LogEntry): void {
|
|
124
|
+
this.indexEntry(entry);
|
|
125
|
+
const line = JSON.stringify(entry) + "\n";
|
|
126
|
+
this.writeQueue = this.writeQueue.then(
|
|
127
|
+
() => appendFile(this.path, line, { mode: 0o600 }).catch((err) => {
|
|
128
|
+
this.onError(err);
|
|
129
|
+
}),
|
|
29
130
|
);
|
|
30
131
|
}
|
|
31
132
|
|
|
133
|
+
get(runId: string): LogEntry[] {
|
|
134
|
+
return this.byRunId.get(runId) ?? [];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async close(): Promise<void> {
|
|
138
|
+
await this.writeQueue;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ─── In-memory storage for tests / ephemeral deployments ────────────
|
|
143
|
+
|
|
144
|
+
export class MemoryLogStorage implements LogStorageAdapter {
|
|
145
|
+
private byRunId = new Map<string, LogEntry[]>();
|
|
146
|
+
|
|
32
147
|
add(entry: LogEntry): void {
|
|
33
|
-
this.
|
|
148
|
+
let entries = this.byRunId.get(entry.runId);
|
|
149
|
+
if (!entries) {
|
|
150
|
+
entries = [];
|
|
151
|
+
this.byRunId.set(entry.runId, entries);
|
|
152
|
+
}
|
|
153
|
+
entries.push(entry);
|
|
34
154
|
}
|
|
35
155
|
|
|
36
156
|
get(runId: string): LogEntry[] {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
157
|
+
return this.byRunId.get(runId) ?? [];
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ─── LogStore — thin wrapper that delegates to an adapter ───────────
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* LogStore is the consumer-facing handle that wraps a `LogStorageAdapter`.
|
|
165
|
+
* It exists so signal runners and route handlers can interact with a
|
|
166
|
+
* single concrete type, while the underlying persistence is swappable.
|
|
167
|
+
*
|
|
168
|
+
* `add` is fire-and-forget — adapter promises are caught at this boundary
|
|
169
|
+
* so a slow or failing log backend can never block (or crash) a signal
|
|
170
|
+
* runner. `get` always returns a Promise so callers can transparently
|
|
171
|
+
* support async backends (Postgres, Redis, etc.).
|
|
172
|
+
*/
|
|
173
|
+
export class LogStore {
|
|
174
|
+
private storage: LogStorageAdapter;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Pass a `LogStorageAdapter` for any backend. The string overload is
|
|
178
|
+
* a shortcut for `new FileLogStorage({ filePath })` — useful for
|
|
179
|
+
* local dev and the default Station data directory.
|
|
180
|
+
*/
|
|
181
|
+
constructor(storageOrPath: LogStorageAdapter | string) {
|
|
182
|
+
if (typeof storageOrPath === "string") {
|
|
183
|
+
this.storage = new FileLogStorage({ filePath: storageOrPath });
|
|
184
|
+
} else {
|
|
185
|
+
this.storage = storageOrPath;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
add(entry: LogEntry): void {
|
|
190
|
+
try {
|
|
191
|
+
const result = this.storage.add(entry);
|
|
192
|
+
if (result && typeof (result as Promise<void>).catch === "function") {
|
|
193
|
+
(result as Promise<void>).catch(() => {});
|
|
194
|
+
}
|
|
195
|
+
} catch {
|
|
196
|
+
// Swallow sync throws; a broken log adapter must not crash signal runs.
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async get(runId: string): Promise<LogEntry[]> {
|
|
201
|
+
return await this.storage.get(runId);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async close(): Promise<void> {
|
|
205
|
+
if (this.storage.close) await this.storage.close();
|
|
55
206
|
}
|
|
56
207
|
}
|
|
@@ -123,7 +123,9 @@ export function broadcastRoutes(deps: BroadcastDeps) {
|
|
|
123
123
|
const allLogs: Array<{ runId: string; signalName: string; level: string; message: string; timestamp: string; nodeName: string }> = [];
|
|
124
124
|
for (const nr of nodes) {
|
|
125
125
|
if (nr.signalRunId) {
|
|
126
|
-
const logs = deps.logStore
|
|
126
|
+
const logs = deps.logStore
|
|
127
|
+
? await deps.logStore.get(nr.signalRunId)
|
|
128
|
+
: deps.logBuffer?.get(nr.signalRunId) ?? [];
|
|
127
129
|
for (const log of logs) {
|
|
128
130
|
allLogs.push({ ...log, nodeName: nr.nodeName });
|
|
129
131
|
}
|
|
@@ -120,7 +120,7 @@ export function runRoutes(deps: RunDeps) {
|
|
|
120
120
|
|
|
121
121
|
app.get("/runs/:id/logs", async (c) => {
|
|
122
122
|
const id = c.req.param("id");
|
|
123
|
-
const logs = deps.logStore
|
|
123
|
+
const logs = deps.logStore ? await deps.logStore.get(id) : deps.logBuffer.get(id);
|
|
124
124
|
return c.json({ data: logs });
|
|
125
125
|
});
|
|
126
126
|
|