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.
- package/.next/standalone/package.json +3 -1
- package/.next/standalone/packages/station-kit/.next/BUILD_ID +1 -1
- package/.next/standalone/packages/station-kit/.next/app-build-manifest.json +75 -16
- package/.next/standalone/packages/station-kit/.next/app-path-routes-manifest.json +10 -3
- package/.next/standalone/packages/station-kit/.next/build-manifest.json +3 -3
- package/.next/standalone/packages/station-kit/.next/prerender-manifest.json +108 -12
- package/.next/standalone/packages/station-kit/.next/routes-manifest.json +49 -0
- package/.next/standalone/packages/station-kit/.next/server/app/_not-found/page.js +2 -2
- 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 +7 -7
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/[id]/page.js +2 -2
- 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.js +2 -0
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/page.js.nft.json +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/page_client-reference-manifest.js +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/v/[n]/page.js +2 -0
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/v/[n]/page.js.nft.json +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/v/[n]/page_client-reference-manifest.js +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new/page.js +2 -0
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new/page.js.nft.json +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new/page_client-reference-manifest.js +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.html +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.meta +7 -0
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.rsc +25 -0
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/page.js +2 -2
- 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 +8 -8
- package/.next/standalone/packages/station-kit/.next/server/app/index.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/index.rsc +8 -8
- package/.next/standalone/packages/station-kit/.next/server/app/page.js +2 -2
- 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.js +2 -0
- package/.next/standalone/packages/station-kit/.next/server/app/playground/expression/page.js.nft.json +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/playground/expression/page_client-reference-manifest.js +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.html +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.meta +7 -0
- package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.rsc +25 -0
- package/.next/standalone/packages/station-kit/.next/server/app/runs/[id]/page.js +2 -2
- 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.js +2 -0
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/[id]/page.js.nft.json +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/[id]/page_client-reference-manifest.js +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/new/page.js +2 -0
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/new/page.js.nft.json +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/new/page_client-reference-manifest.js +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.html +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.meta +7 -0
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.rsc +25 -0
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/page.js +2 -0
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/page.js.nft.json +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/page_client-reference-manifest.js +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/schedules.html +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/schedules.meta +7 -0
- package/.next/standalone/packages/station-kit/.next/server/app/schedules.rsc +25 -0
- package/.next/standalone/packages/station-kit/.next/server/app/settings/page.js +2 -2
- 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 +8 -8
- package/.next/standalone/packages/station-kit/.next/server/app/signals/[name]/page.js +2 -2
- 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.js +2 -2
- 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 +8 -8
- package/.next/standalone/packages/station-kit/.next/server/app-paths-manifest.json +10 -3
- package/.next/standalone/packages/station-kit/.next/server/chunks/102.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/chunks/535.js +2 -0
- package/.next/standalone/packages/station-kit/.next/server/chunks/606.js +14 -14
- package/.next/standalone/packages/station-kit/.next/server/chunks/783.js +3 -3
- package/.next/standalone/packages/station-kit/.next/server/middleware-build-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/pages/404.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/pages/500.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/pages/_app.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/pages/_document.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/pages/_error.js +9 -9
- package/.next/standalone/packages/station-kit/.next/server/pages-manifest.json +1 -1
- package/.next/standalone/packages/station-kit/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/packages/station-kit/.next/static/THKSkCipW_pj0F6DRXYEG/_buildManifest.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/145-9e370afd2e5aba39.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/285-ff198f0a909c4fdd.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/561-33d912169940283e.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/935-dff12960528de017.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/_not-found/{page-ce21b4ba9038a5a7.js → page-67ef312aee40cfeb.js} +1 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/[id]/page-fe2f5467a0c68fef.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/dyn/[name]/page-0d2505242014f51e.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/dyn/[name]/v/[n]/page-5eac0507f49a00ec.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/new/page-3d02707043d24dc7.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/page-dee500ccc01f0821.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/layout-e14e14f3e5b0b8a9.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/page-aac41ef7a470daab.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/playground/expression/page-dc9d91f3f50f4716.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/runs/[id]/page-9e4c4f751a4bea72.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/schedules/[id]/page-435f67be180b8e4f.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/schedules/new/page-f697c289c813496a.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/schedules/page-738d98dc0b63166e.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/settings/page-fc5654b31f57ac21.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/[name]/page-4b1c09a539a1ebcd.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/page-d2f2403dfede87cc.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/pages/_app-a3774a320f58a018.js +1 -0
- package/.next/standalone/packages/station-kit/package.json +7 -4
- package/dist/config/schema.d.ts +23 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +2 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/server/auth/keys.d.ts +91 -8
- package/dist/server/auth/keys.d.ts.map +1 -1
- package/dist/server/auth/keys.js +289 -54
- 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 +84 -9
- 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/middleware/auth.js +1 -1
- package/dist/server/middleware/auth.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/definitions.d.ts +21 -0
- package/dist/server/routes/v1/definitions.d.ts.map +1 -0
- package/dist/server/routes/v1/definitions.js +139 -0
- package/dist/server/routes/v1/definitions.js.map +1 -0
- package/dist/server/routes/v1/expressions.d.ts +3 -0
- package/dist/server/routes/v1/expressions.d.ts.map +1 -0
- package/dist/server/routes/v1/expressions.js +56 -0
- package/dist/server/routes/v1/expressions.js.map +1 -0
- package/dist/server/routes/v1/keys.js +3 -3
- package/dist/server/routes/v1/keys.js.map +1 -1
- package/dist/server/routes/v1/runs.js +1 -1
- package/dist/server/routes/v1/runs.js.map +1 -1
- package/dist/server/routes/v1/schedules.d.ts +10 -0
- package/dist/server/routes/v1/schedules.d.ts.map +1 -0
- package/dist/server/routes/v1/schedules.js +169 -0
- package/dist/server/routes/v1/schedules.js.map +1 -0
- package/dist/server/routes/v1/trigger.d.ts.map +1 -1
- package/dist/server/routes/v1/trigger.js +21 -0
- package/dist/server/routes/v1/trigger.js.map +1 -1
- package/package.json +12 -9
- package/src/app/broadcasts/components/broadcast-builder.tsx +535 -0
- package/src/app/broadcasts/components/dag-editor.tsx +510 -0
- package/src/app/broadcasts/dyn/[name]/dynamic-detail.tsx +243 -0
- package/src/app/broadcasts/dyn/[name]/page.tsx +10 -0
- package/src/app/broadcasts/dyn/[name]/v/[n]/page.tsx +10 -0
- package/src/app/broadcasts/dyn/[name]/v/[n]/version-view.tsx +285 -0
- package/src/app/broadcasts/new/page.tsx +102 -0
- package/src/app/broadcasts/page.tsx +176 -91
- package/src/app/components/api-panel.tsx +151 -0
- package/src/app/components/shell.tsx +23 -0
- package/src/app/hooks/use-api.ts +117 -0
- package/src/app/playground/expression/page.tsx +245 -0
- package/src/app/schedules/[id]/page.tsx +10 -0
- package/src/app/schedules/[id]/schedule-editor.tsx +195 -0
- package/src/app/schedules/components/schedule-form.tsx +140 -0
- package/src/app/schedules/new/page.tsx +166 -0
- package/src/app/schedules/page.tsx +126 -0
- package/src/config/schema.ts +25 -0
- package/src/server/auth/keys.ts +348 -58
- package/src/server/index.ts +118 -11
- package/src/server/log-store.ts +196 -45
- package/src/server/middleware/auth.ts +1 -1
- package/src/server/routes/broadcasts.ts +3 -1
- package/src/server/routes/runs.ts +1 -1
- package/src/server/routes/v1/definitions.ts +164 -0
- package/src/server/routes/v1/expressions.ts +76 -0
- package/src/server/routes/v1/keys.ts +3 -3
- package/src/server/routes/v1/runs.ts +1 -1
- package/src/server/routes/v1/schedules.ts +176 -0
- package/src/server/routes/v1/trigger.ts +27 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/580-f007f4d4c050db4e.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/[id]/page-a0a20cccda13a0e9.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/page-937eb876f9087bc9.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/layout-68cd71116ba65cd8.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/page-70b0c0958c03459a.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/runs/[id]/page-01f8040619fe56c5.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/settings/page-beac11049f90da31.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/[name]/page-931e6a38a4a53d25.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/page-6a123a355d93fec5.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/pages/_app-0a7b2e66ecbe3f0a.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/xYd6dn0Ox68DaamIrH_pB/_buildManifest.js +0 -1
- /package/.next/standalone/packages/station-kit/.next/static/{xYd6dn0Ox68DaamIrH_pB → THKSkCipW_pj0F6DRXYEG}/_ssgManifest.js +0 -0
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;
|
|
@@ -13,14 +25,188 @@ export interface ApiKey {
|
|
|
13
25
|
revoked: boolean;
|
|
14
26
|
}
|
|
15
27
|
|
|
16
|
-
export
|
|
17
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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():
|
|
86
|
-
|
|
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
|
-
|
|
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.
|
|
399
|
+
async close(): Promise<void> {
|
|
400
|
+
if (this.storage.close) await this.storage.close();
|
|
111
401
|
}
|
|
112
402
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|