station-kit 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/cli-main.d.ts +2 -0
- package/dist/cli-main.d.ts.map +1 -0
- package/dist/cli-main.js +58 -0
- package/dist/cli-main.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +25 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/loader.d.ts +3 -0
- package/dist/config/loader.d.ts.map +1 -0
- package/dist/config/loader.js +29 -0
- package/dist/config/loader.js.map +1 -0
- package/dist/config/schema.d.ts +36 -0
- package/dist/config/schema.d.ts.map +1 -0
- package/dist/config/schema.js +40 -0
- package/dist/config/schema.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/server/auth/keys.d.ts +28 -0
- package/dist/server/auth/keys.d.ts.map +1 -0
- package/dist/server/auth/keys.js +91 -0
- package/dist/server/auth/keys.js.map +1 -0
- package/dist/server/auth/session.d.ts +9 -0
- package/dist/server/auth/session.d.ts.map +1 -0
- package/dist/server/auth/session.js +42 -0
- package/dist/server/auth/session.js.map +1 -0
- package/dist/server/index.d.ts +7 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +253 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/log-buffer.d.ts +20 -0
- package/dist/server/log-buffer.d.ts.map +1 -0
- package/dist/server/log-buffer.js +33 -0
- package/dist/server/log-buffer.js.map +1 -0
- package/dist/server/log-store.d.ts +11 -0
- package/dist/server/log-store.d.ts.map +1 -0
- package/dist/server/log-store.js +40 -0
- package/dist/server/log-store.js.map +1 -0
- package/dist/server/metadata.d.ts +38 -0
- package/dist/server/metadata.d.ts.map +1 -0
- package/dist/server/metadata.js +130 -0
- package/dist/server/metadata.js.map +1 -0
- package/dist/server/middleware/auth.d.ts +12 -0
- package/dist/server/middleware/auth.d.ts.map +1 -0
- package/dist/server/middleware/auth.js +42 -0
- package/dist/server/middleware/auth.js.map +1 -0
- package/dist/server/middleware/rate-limit.d.ts +15 -0
- package/dist/server/middleware/rate-limit.d.ts.map +1 -0
- package/dist/server/middleware/rate-limit.js +36 -0
- package/dist/server/middleware/rate-limit.js.map +1 -0
- package/dist/server/middleware/scope-guard.d.ts +9 -0
- package/dist/server/middleware/scope-guard.d.ts.map +1 -0
- package/dist/server/middleware/scope-guard.js +17 -0
- package/dist/server/middleware/scope-guard.js.map +1 -0
- package/dist/server/routes/broadcasts.d.ts +12 -0
- package/dist/server/routes/broadcasts.d.ts.map +1 -0
- package/dist/server/routes/broadcasts.js +135 -0
- package/dist/server/routes/broadcasts.js.map +1 -0
- package/dist/server/routes/health.d.ts +9 -0
- package/dist/server/routes/health.d.ts.map +1 -0
- package/dist/server/routes/health.js +27 -0
- package/dist/server/routes/health.js.map +1 -0
- package/dist/server/routes/runs.d.ts +12 -0
- package/dist/server/routes/runs.d.ts.map +1 -0
- package/dist/server/routes/runs.js +122 -0
- package/dist/server/routes/runs.js.map +1 -0
- package/dist/server/routes/signals.d.ts +10 -0
- package/dist/server/routes/signals.d.ts.map +1 -0
- package/dist/server/routes/signals.js +120 -0
- package/dist/server/routes/signals.js.map +1 -0
- package/dist/server/routes/v1/auth.d.ts +7 -0
- package/dist/server/routes/v1/auth.d.ts.map +1 -0
- package/dist/server/routes/v1/auth.js +28 -0
- package/dist/server/routes/v1/auth.js.map +1 -0
- package/dist/server/routes/v1/broadcasts.d.ts +10 -0
- package/dist/server/routes/v1/broadcasts.d.ts.map +1 -0
- package/dist/server/routes/v1/broadcasts.js +68 -0
- package/dist/server/routes/v1/broadcasts.js.map +1 -0
- package/dist/server/routes/v1/events.d.ts +7 -0
- package/dist/server/routes/v1/events.d.ts.map +1 -0
- package/dist/server/routes/v1/events.js +57 -0
- package/dist/server/routes/v1/events.js.map +1 -0
- package/dist/server/routes/v1/health.d.ts +9 -0
- package/dist/server/routes/v1/health.d.ts.map +1 -0
- package/dist/server/routes/v1/health.js +31 -0
- package/dist/server/routes/v1/health.js.map +1 -0
- package/dist/server/routes/v1/keys.d.ts +7 -0
- package/dist/server/routes/v1/keys.d.ts.map +1 -0
- package/dist/server/routes/v1/keys.js +43 -0
- package/dist/server/routes/v1/keys.js.map +1 -0
- package/dist/server/routes/v1/runs.d.ts +12 -0
- package/dist/server/routes/v1/runs.d.ts.map +1 -0
- package/dist/server/routes/v1/runs.js +76 -0
- package/dist/server/routes/v1/runs.js.map +1 -0
- package/dist/server/routes/v1/signals.d.ts +9 -0
- package/dist/server/routes/v1/signals.d.ts.map +1 -0
- package/dist/server/routes/v1/signals.js +33 -0
- package/dist/server/routes/v1/signals.js.map +1 -0
- package/dist/server/routes/v1/trigger.d.ts +12 -0
- package/dist/server/routes/v1/trigger.d.ts.map +1 -0
- package/dist/server/routes/v1/trigger.js +73 -0
- package/dist/server/routes/v1/trigger.js.map +1 -0
- package/dist/server/sse.d.ts +19 -0
- package/dist/server/sse.d.ts.map +1 -0
- package/dist/server/sse.js +51 -0
- package/dist/server/sse.js.map +1 -0
- package/dist/server/subscriber.d.ts +128 -0
- package/dist/server/subscriber.d.ts.map +1 -0
- package/dist/server/subscriber.js +246 -0
- package/dist/server/subscriber.js.map +1 -0
- package/dist/server/ws.d.ts +15 -0
- package/dist/server/ws.d.ts.map +1 -0
- package/dist/server/ws.js +32 -0
- package/dist/server/ws.js.map +1 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +10 -0
- package/package.json +49 -0
- package/src/app/broadcasts/[id]/page.tsx +511 -0
- package/src/app/broadcasts/page.tsx +158 -0
- package/src/app/components/auth-provider.tsx +75 -0
- package/src/app/components/breadcrumb-provider.tsx +18 -0
- package/src/app/components/dag-view.tsx +380 -0
- package/src/app/components/empty-state.tsx +7 -0
- package/src/app/components/json-viewer.tsx +153 -0
- package/src/app/components/login-page.tsx +78 -0
- package/src/app/components/node-detail.tsx +158 -0
- package/src/app/components/pulse-dot.tsx +8 -0
- package/src/app/components/relative-time.tsx +34 -0
- package/src/app/components/run-table.tsx +96 -0
- package/src/app/components/schema-form.tsx +121 -0
- package/src/app/components/shell.tsx +203 -0
- package/src/app/components/status-badge.tsx +10 -0
- package/src/app/components/step-timeline.tsx +134 -0
- package/src/app/components/theme-provider.tsx +45 -0
- package/src/app/components/workflow-node-sidebar.tsx +68 -0
- package/src/app/globals.css +1523 -0
- package/src/app/hooks/use-api.ts +129 -0
- package/src/app/hooks/use-breadcrumb.ts +37 -0
- package/src/app/hooks/use-realtime.ts +68 -0
- package/src/app/hooks/use-station.tsx +34 -0
- package/src/app/layout.tsx +42 -0
- package/src/app/page.tsx +275 -0
- package/src/app/runs/[id]/page.tsx +277 -0
- package/src/app/signals/[name]/page.tsx +250 -0
- package/src/app/signals/page.tsx +99 -0
- package/src/cli-main.ts +70 -0
- package/src/cli.ts +27 -0
- package/src/config/loader.ts +33 -0
- package/src/config/schema.ts +80 -0
- package/src/index.ts +7 -0
- package/src/server/auth/keys.ts +112 -0
- package/src/server/auth/session.ts +48 -0
- package/src/server/index.ts +296 -0
- package/src/server/log-buffer.ts +43 -0
- package/src/server/log-store.ts +56 -0
- package/src/server/metadata.ts +180 -0
- package/src/server/middleware/auth.ts +50 -0
- package/src/server/middleware/rate-limit.ts +61 -0
- package/src/server/middleware/scope-guard.ts +20 -0
- package/src/server/routes/broadcasts.ts +160 -0
- package/src/server/routes/health.ts +37 -0
- package/src/server/routes/runs.ts +149 -0
- package/src/server/routes/signals.ts +153 -0
- package/src/server/routes/v1/auth.ts +47 -0
- package/src/server/routes/v1/broadcasts.ts +84 -0
- package/src/server/routes/v1/events.ts +71 -0
- package/src/server/routes/v1/health.ts +41 -0
- package/src/server/routes/v1/keys.ts +57 -0
- package/src/server/routes/v1/runs.ts +97 -0
- package/src/server/routes/v1/signals.ts +44 -0
- package/src/server/routes/v1/trigger.ts +111 -0
- package/src/server/sse.ts +70 -0
- package/src/server/subscriber.ts +288 -0
- package/src/server/ws.ts +44 -0
- package/station.config.example.ts +16 -0
- package/tsconfig.json +12 -0
- package/tsconfig.next.json +15 -0
- package/tsconfig.tsbuildinfo +1 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Station CLI launcher — re-executes itself with tsx loader so user .ts files
|
|
4
|
+
// (signals, broadcasts, configs) can be imported with full resolution.
|
|
5
|
+
|
|
6
|
+
import { execPath } from "node:process";
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
const MARKER = "__STATION_TSX_LOADED";
|
|
11
|
+
|
|
12
|
+
if (!process.env[MARKER]) {
|
|
13
|
+
// Re-exec with --import tsx
|
|
14
|
+
const main = fileURLToPath(new URL("./cli-main.js", import.meta.url));
|
|
15
|
+
const child = spawn(execPath, ["--import", "tsx", main], {
|
|
16
|
+
stdio: "inherit",
|
|
17
|
+
env: { ...process.env, [MARKER]: "1" },
|
|
18
|
+
});
|
|
19
|
+
child.on("exit", (code) => process.exit(code ?? 0));
|
|
20
|
+
child.on("error", (err) => {
|
|
21
|
+
console.error("[station] Failed to start:", err.message);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
});
|
|
24
|
+
} else {
|
|
25
|
+
// Already loaded with tsx — import main
|
|
26
|
+
await import("./cli-main.js");
|
|
27
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { resolveConfig, type StationConfig } from "./schema.js";
|
|
5
|
+
|
|
6
|
+
const CONFIG_NAMES = [
|
|
7
|
+
"station.config.ts",
|
|
8
|
+
"station.config.js",
|
|
9
|
+
"station.config.mjs",
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
export async function loadConfig(cwd: string): Promise<StationConfig> {
|
|
13
|
+
const configPath = findConfigFile(cwd);
|
|
14
|
+
|
|
15
|
+
if (!configPath) {
|
|
16
|
+
console.log("[station] No config file found. Using defaults.");
|
|
17
|
+
return resolveConfig({});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
console.log(`[station] Loading ${configPath}`);
|
|
21
|
+
|
|
22
|
+
const mod = await import(pathToFileURL(resolve(configPath)).href);
|
|
23
|
+
const raw = mod.default ?? mod;
|
|
24
|
+
return resolveConfig(raw);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function findConfigFile(cwd: string): string | null {
|
|
28
|
+
for (const name of CONFIG_NAMES) {
|
|
29
|
+
const candidate = join(cwd, name);
|
|
30
|
+
if (existsSync(candidate)) return candidate;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { SignalQueueAdapter } from "station-signal";
|
|
2
|
+
import type { BroadcastQueueAdapter } from "station-broadcast";
|
|
3
|
+
|
|
4
|
+
export interface AuthConfig {
|
|
5
|
+
username: string;
|
|
6
|
+
password: string;
|
|
7
|
+
sessionTtlMs?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface RunnerConfig {
|
|
11
|
+
pollIntervalMs: number;
|
|
12
|
+
maxConcurrent: number;
|
|
13
|
+
maxAttempts: number;
|
|
14
|
+
retryBackoffMs: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface BroadcastRunnerConfig {
|
|
18
|
+
pollIntervalMs: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface StationConfig {
|
|
22
|
+
port: number;
|
|
23
|
+
host: string;
|
|
24
|
+
adapter?: SignalQueueAdapter;
|
|
25
|
+
broadcastAdapter?: BroadcastQueueAdapter;
|
|
26
|
+
signalsDir?: string;
|
|
27
|
+
broadcastsDir?: string;
|
|
28
|
+
runner: RunnerConfig;
|
|
29
|
+
broadcastRunner: BroadcastRunnerConfig;
|
|
30
|
+
runRunners: boolean;
|
|
31
|
+
open: boolean;
|
|
32
|
+
logLevel: "debug" | "info" | "warn" | "error";
|
|
33
|
+
auth?: AuthConfig;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type StationUserConfig = Partial<Omit<StationConfig, "runner" | "broadcastRunner">> & {
|
|
37
|
+
runner?: Partial<RunnerConfig>;
|
|
38
|
+
broadcastRunner?: Partial<BroadcastRunnerConfig>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const DEFAULTS: StationConfig = {
|
|
42
|
+
port: 4400,
|
|
43
|
+
host: "localhost",
|
|
44
|
+
runner: {
|
|
45
|
+
pollIntervalMs: 1000,
|
|
46
|
+
maxConcurrent: 5,
|
|
47
|
+
maxAttempts: 1,
|
|
48
|
+
retryBackoffMs: 1000,
|
|
49
|
+
},
|
|
50
|
+
broadcastRunner: {
|
|
51
|
+
pollIntervalMs: 1000,
|
|
52
|
+
},
|
|
53
|
+
runRunners: true,
|
|
54
|
+
open: true,
|
|
55
|
+
logLevel: "info",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export function resolveConfig(input: StationUserConfig): StationConfig {
|
|
59
|
+
return {
|
|
60
|
+
port: input.port ?? DEFAULTS.port,
|
|
61
|
+
host: input.host ?? DEFAULTS.host,
|
|
62
|
+
adapter: input.adapter,
|
|
63
|
+
broadcastAdapter: input.broadcastAdapter,
|
|
64
|
+
signalsDir: input.signalsDir,
|
|
65
|
+
broadcastsDir: input.broadcastsDir,
|
|
66
|
+
runner: {
|
|
67
|
+
pollIntervalMs: input.runner?.pollIntervalMs ?? DEFAULTS.runner.pollIntervalMs,
|
|
68
|
+
maxConcurrent: input.runner?.maxConcurrent ?? DEFAULTS.runner.maxConcurrent,
|
|
69
|
+
maxAttempts: input.runner?.maxAttempts ?? DEFAULTS.runner.maxAttempts,
|
|
70
|
+
retryBackoffMs: input.runner?.retryBackoffMs ?? DEFAULTS.runner.retryBackoffMs,
|
|
71
|
+
},
|
|
72
|
+
broadcastRunner: {
|
|
73
|
+
pollIntervalMs: input.broadcastRunner?.pollIntervalMs ?? DEFAULTS.broadcastRunner.pollIntervalMs,
|
|
74
|
+
},
|
|
75
|
+
runRunners: input.runRunners ?? DEFAULTS.runRunners,
|
|
76
|
+
open: input.open ?? DEFAULTS.open,
|
|
77
|
+
logLevel: input.logLevel ?? DEFAULTS.logLevel,
|
|
78
|
+
auth: input.auth,
|
|
79
|
+
};
|
|
80
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
|
|
4
|
+
export interface ApiKey {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
keyHash: string;
|
|
8
|
+
keyPrefix: string;
|
|
9
|
+
scopes: string[];
|
|
10
|
+
createdAt: string;
|
|
11
|
+
lastUsed: string | null;
|
|
12
|
+
expiresAt: string | null;
|
|
13
|
+
revoked: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class KeyStore {
|
|
17
|
+
private db: Database.Database;
|
|
18
|
+
|
|
19
|
+
constructor(dbPath: string) {
|
|
20
|
+
this.db = new Database(dbPath);
|
|
21
|
+
this.db.pragma("journal_mode = WAL");
|
|
22
|
+
this.db.exec(`
|
|
23
|
+
CREATE TABLE IF NOT EXISTS api_keys (
|
|
24
|
+
id TEXT PRIMARY KEY,
|
|
25
|
+
name TEXT NOT NULL,
|
|
26
|
+
key_hash TEXT NOT NULL UNIQUE,
|
|
27
|
+
key_prefix TEXT NOT NULL,
|
|
28
|
+
scopes TEXT NOT NULL DEFAULT '[]',
|
|
29
|
+
created_at TEXT NOT NULL,
|
|
30
|
+
last_used TEXT,
|
|
31
|
+
expires_at TEXT,
|
|
32
|
+
revoked INTEGER NOT NULL DEFAULT 0
|
|
33
|
+
)
|
|
34
|
+
`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 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 } {
|
|
39
|
+
const id = crypto.randomUUID();
|
|
40
|
+
const rawKey = `sk_live_${crypto.randomBytes(16).toString("hex")}`;
|
|
41
|
+
const keyHash = crypto.createHash("sha256").update(rawKey).digest("hex");
|
|
42
|
+
const keyPrefix = rawKey.slice(0, 12);
|
|
43
|
+
const createdAt = new Date().toISOString();
|
|
44
|
+
|
|
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 },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Verify an API key. Returns the key record if valid, null otherwise. */
|
|
57
|
+
verify(rawKey: string): ApiKey | null {
|
|
58
|
+
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;
|
|
63
|
+
|
|
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;
|
|
67
|
+
|
|
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
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 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
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 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;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
close(): void {
|
|
110
|
+
this.db.close();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const SESSION_TTL_MS = 86_400_000; // 24 hours
|
|
4
|
+
|
|
5
|
+
export interface SessionConfig {
|
|
6
|
+
username: string;
|
|
7
|
+
password: string;
|
|
8
|
+
sessionTtlMs?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** HMAC-sign a token. The secret is derived from the password. */
|
|
12
|
+
function sign(payload: string, secret: string): string {
|
|
13
|
+
const hmac = crypto.createHmac("sha256", secret);
|
|
14
|
+
hmac.update(payload);
|
|
15
|
+
return hmac.digest("hex");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createSessionToken(config: SessionConfig): string {
|
|
19
|
+
const exp = Date.now() + (config.sessionTtlMs ?? SESSION_TTL_MS);
|
|
20
|
+
const payload = `${config.username}:${exp}`;
|
|
21
|
+
const signature = sign(payload, config.password);
|
|
22
|
+
return Buffer.from(`${payload}:${signature}`).toString("base64url");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function verifySessionToken(token: string, config: SessionConfig): boolean {
|
|
26
|
+
try {
|
|
27
|
+
const decoded = Buffer.from(token, "base64url").toString();
|
|
28
|
+
const parts = decoded.split(":");
|
|
29
|
+
if (parts.length !== 3) return false;
|
|
30
|
+
const [username, expStr, sig] = parts;
|
|
31
|
+
const exp = parseInt(expStr, 10);
|
|
32
|
+
if (isNaN(exp) || Date.now() > exp) return false;
|
|
33
|
+
if (username !== config.username) return false;
|
|
34
|
+
const expected = sign(`${username}:${expStr}`, config.password);
|
|
35
|
+
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function verifyCredentials(username: string, password: string, config: SessionConfig): boolean {
|
|
42
|
+
// Use timing-safe comparison to prevent timing attacks
|
|
43
|
+
const userMatch = username.length === config.username.length &&
|
|
44
|
+
crypto.timingSafeEqual(Buffer.from(username), Buffer.from(config.username));
|
|
45
|
+
const passMatch = password.length === config.password.length &&
|
|
46
|
+
crypto.timingSafeEqual(Buffer.from(password), Buffer.from(config.password));
|
|
47
|
+
return userMatch && passMatch;
|
|
48
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { cors } from "hono/cors";
|
|
3
|
+
import { createMiddleware } from "hono/factory";
|
|
4
|
+
import { serve } from "@hono/node-server";
|
|
5
|
+
import { resolve } from "node:path";
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import type { Server } from "node:http";
|
|
8
|
+
import { SignalRunner, MemoryAdapter } from "station-signal";
|
|
9
|
+
import { BroadcastRunner, BroadcastMemoryAdapter } from "station-broadcast";
|
|
10
|
+
import type { SignalQueueAdapter } from "station-signal";
|
|
11
|
+
import type { BroadcastQueueAdapter } from "station-broadcast";
|
|
12
|
+
import type { StationConfig } from "../config/schema.js";
|
|
13
|
+
import { WebSocketHub } from "./ws.js";
|
|
14
|
+
import { SSEHub } from "./sse.js";
|
|
15
|
+
import { LogBuffer } from "./log-buffer.js";
|
|
16
|
+
import { LogStore } from "./log-store.js";
|
|
17
|
+
import { StationSignalSubscriber, StationBroadcastSubscriber } from "./subscriber.js";
|
|
18
|
+
import { healthRoutes } from "./routes/health.js";
|
|
19
|
+
import { signalRoutes } from "./routes/signals.js";
|
|
20
|
+
import { runRoutes } from "./routes/runs.js";
|
|
21
|
+
import { broadcastRoutes } from "./routes/broadcasts.js";
|
|
22
|
+
import { KeyStore } from "./auth/keys.js";
|
|
23
|
+
import { verifySessionToken, verifyCredentials, createSessionToken, type SessionConfig } from "./auth/session.js";
|
|
24
|
+
import { authResolver } from "./middleware/auth.js";
|
|
25
|
+
import { requireScope } from "./middleware/scope-guard.js";
|
|
26
|
+
import { rateLimiter } from "./middleware/rate-limit.js";
|
|
27
|
+
import { v1HealthRoutes } from "./routes/v1/health.js";
|
|
28
|
+
import { v1SignalRoutes } from "./routes/v1/signals.js";
|
|
29
|
+
import { v1RunRoutes } from "./routes/v1/runs.js";
|
|
30
|
+
import { v1BroadcastRoutes } from "./routes/v1/broadcasts.js";
|
|
31
|
+
import { v1TriggerRoutes } from "./routes/v1/trigger.js";
|
|
32
|
+
import { v1KeyRoutes } from "./routes/v1/keys.js";
|
|
33
|
+
import { v1AuthRoutes } from "./routes/v1/auth.js";
|
|
34
|
+
import { v1EventRoutes } from "./routes/v1/events.js";
|
|
35
|
+
|
|
36
|
+
export interface StationInstance {
|
|
37
|
+
start(): Promise<void>;
|
|
38
|
+
stop(): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function createStation(config: StationConfig, cwd: string): Promise<StationInstance> {
|
|
42
|
+
const signalAdapter: SignalQueueAdapter = config.adapter ?? new MemoryAdapter();
|
|
43
|
+
const broadcastAdapter: BroadcastQueueAdapter | undefined =
|
|
44
|
+
config.broadcastAdapter ?? (config.broadcastsDir ? new BroadcastMemoryAdapter() : undefined);
|
|
45
|
+
|
|
46
|
+
const wsHub = new WebSocketHub();
|
|
47
|
+
const sseHub = new SSEHub();
|
|
48
|
+
const logBuffer = new LogBuffer();
|
|
49
|
+
const logStore = new LogStore(resolve(cwd, "station-logs.db"));
|
|
50
|
+
|
|
51
|
+
// Auth: create KeyStore and SessionConfig if auth is configured
|
|
52
|
+
let keyStore: KeyStore | undefined;
|
|
53
|
+
let sessionConfig: SessionConfig | undefined;
|
|
54
|
+
|
|
55
|
+
if (config.auth) {
|
|
56
|
+
keyStore = new KeyStore(resolve(cwd, "station-keys.db"));
|
|
57
|
+
sessionConfig = {
|
|
58
|
+
username: config.auth.username,
|
|
59
|
+
password: config.auth.password,
|
|
60
|
+
sessionTtlMs: config.auth.sessionTtlMs,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Resolve directories
|
|
65
|
+
const signalsDir = config.signalsDir
|
|
66
|
+
? resolve(cwd, config.signalsDir)
|
|
67
|
+
: existsSync(resolve(cwd, "signals"))
|
|
68
|
+
? resolve(cwd, "signals")
|
|
69
|
+
: undefined;
|
|
70
|
+
|
|
71
|
+
const broadcastsDir = config.broadcastsDir
|
|
72
|
+
? resolve(cwd, config.broadcastsDir)
|
|
73
|
+
: existsSync(resolve(cwd, "broadcasts"))
|
|
74
|
+
? resolve(cwd, "broadcasts")
|
|
75
|
+
: undefined;
|
|
76
|
+
|
|
77
|
+
// Create subscribers (always — they collect metadata)
|
|
78
|
+
const stationSignalSub = new StationSignalSubscriber(wsHub, logBuffer, logStore);
|
|
79
|
+
const stationBroadcastSub = new StationBroadcastSubscriber(wsHub);
|
|
80
|
+
|
|
81
|
+
// Wire SSE hub into subscribers so events reach both WS and SSE clients
|
|
82
|
+
stationSignalSub.setSSEHub(sseHub);
|
|
83
|
+
stationBroadcastSub.setSSEHub(sseHub);
|
|
84
|
+
|
|
85
|
+
// Create runners if enabled
|
|
86
|
+
let signalRunner: SignalRunner | undefined;
|
|
87
|
+
let broadcastRunner: BroadcastRunner | undefined;
|
|
88
|
+
|
|
89
|
+
if (config.runRunners) {
|
|
90
|
+
signalRunner = new SignalRunner({
|
|
91
|
+
signalsDir,
|
|
92
|
+
adapter: signalAdapter,
|
|
93
|
+
pollIntervalMs: config.runner.pollIntervalMs,
|
|
94
|
+
maxConcurrent: config.runner.maxConcurrent,
|
|
95
|
+
maxAttempts: config.runner.maxAttempts,
|
|
96
|
+
retryBackoffMs: config.runner.retryBackoffMs,
|
|
97
|
+
subscribers: [stationSignalSub],
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
if (broadcastsDir || broadcastAdapter) {
|
|
101
|
+
broadcastRunner = new BroadcastRunner({
|
|
102
|
+
signalRunner,
|
|
103
|
+
broadcastsDir,
|
|
104
|
+
adapter: broadcastAdapter ?? new BroadcastMemoryAdapter(),
|
|
105
|
+
pollIntervalMs: config.broadcastRunner.pollIntervalMs,
|
|
106
|
+
subscribers: [stationBroadcastSub],
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Build Hono app
|
|
112
|
+
const app = new Hono();
|
|
113
|
+
|
|
114
|
+
// CORS for Next.js dev server and API clients
|
|
115
|
+
app.use("/*", cors({
|
|
116
|
+
origin: [`http://${config.host}:${config.port + 1}`, `http://localhost:${config.port + 1}`],
|
|
117
|
+
allowMethods: ["GET", "POST", "DELETE", "OPTIONS"],
|
|
118
|
+
allowHeaders: ["Content-Type", "Authorization"],
|
|
119
|
+
credentials: true,
|
|
120
|
+
}));
|
|
121
|
+
|
|
122
|
+
// ── Dashboard auth routes (always accessible) ──────────────────────
|
|
123
|
+
app.get("/api/auth/check", async (c) => {
|
|
124
|
+
if (!sessionConfig) {
|
|
125
|
+
return c.json({ data: { authenticated: true, authRequired: false } });
|
|
126
|
+
}
|
|
127
|
+
const cookie = c.req.header("cookie");
|
|
128
|
+
if (cookie) {
|
|
129
|
+
const match = cookie.match(/station_session=([^;]+)/);
|
|
130
|
+
if (match && verifySessionToken(match[1], sessionConfig)) {
|
|
131
|
+
return c.json({ data: { authenticated: true, authRequired: true } });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return c.json({ data: { authenticated: false, authRequired: true } });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
app.post("/api/auth/login", async (c) => {
|
|
138
|
+
if (!sessionConfig) {
|
|
139
|
+
return c.json({ data: { ok: true } });
|
|
140
|
+
}
|
|
141
|
+
const body = await c.req.json().catch(() => ({}));
|
|
142
|
+
const { username, password } = body;
|
|
143
|
+
if (!username || !password) {
|
|
144
|
+
return c.json({ error: "bad_request", message: "Missing username or password." }, 400);
|
|
145
|
+
}
|
|
146
|
+
if (!verifyCredentials(username, password, sessionConfig)) {
|
|
147
|
+
return c.json({ error: "unauthorized", message: "Invalid credentials." }, 401);
|
|
148
|
+
}
|
|
149
|
+
const token = createSessionToken(sessionConfig);
|
|
150
|
+
const ttlSeconds = Math.floor((sessionConfig.sessionTtlMs ?? 86_400_000) / 1000);
|
|
151
|
+
c.header("Set-Cookie", `station_session=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${ttlSeconds}`);
|
|
152
|
+
return c.json({ data: { ok: true } });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
app.post("/api/auth/logout", async (c) => {
|
|
156
|
+
c.header("Set-Cookie", "station_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0");
|
|
157
|
+
return c.json({ data: { ok: true } });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ── Dashboard API routes (session required when auth configured) ───
|
|
161
|
+
if (sessionConfig) {
|
|
162
|
+
app.use("/api/*", createMiddleware(async (c, next) => {
|
|
163
|
+
// Skip auth check for /api/auth/* (already handled above)
|
|
164
|
+
if (c.req.path.startsWith("/api/auth/")) return next();
|
|
165
|
+
// Skip auth check for /api/v1/* (has its own auth)
|
|
166
|
+
if (c.req.path.startsWith("/api/v1/")) return next();
|
|
167
|
+
|
|
168
|
+
const cookie = c.req.header("cookie");
|
|
169
|
+
if (cookie) {
|
|
170
|
+
const match = cookie.match(/station_session=([^;]+)/);
|
|
171
|
+
if (match && verifySessionToken(match[1], sessionConfig)) {
|
|
172
|
+
return next();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return c.json({ error: "unauthorized", message: "Session required." }, 401);
|
|
176
|
+
}));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
app.route("/api", healthRoutes({ signalAdapter, broadcastAdapter }));
|
|
180
|
+
app.route("/api", signalRoutes({ signalRunner, signalAdapter, signalSubscriber: stationSignalSub }));
|
|
181
|
+
app.route("/api", runRoutes({ signalRunner, signalAdapter, logBuffer, logStore }));
|
|
182
|
+
app.route("/api", broadcastRoutes({ broadcastRunner, broadcastAdapter, broadcastSubscriber: stationBroadcastSub, logBuffer, logStore }));
|
|
183
|
+
|
|
184
|
+
// ── v1 API routes (authenticated) ──────────────────────────────────
|
|
185
|
+
|
|
186
|
+
// Public v1 routes (no auth required)
|
|
187
|
+
app.route("/api/v1", v1HealthRoutes({ signalAdapter, broadcastAdapter }));
|
|
188
|
+
|
|
189
|
+
// Auth routes: public but rate-limited to prevent brute force
|
|
190
|
+
const authApp = new Hono();
|
|
191
|
+
authApp.use("/*", rateLimiter({ windowMs: 60_000, max: 10 }));
|
|
192
|
+
authApp.route("/", v1AuthRoutes({ sessionConfig }));
|
|
193
|
+
app.route("/api/v1", authApp);
|
|
194
|
+
|
|
195
|
+
// Authenticated v1 routes — apply auth resolver middleware
|
|
196
|
+
const v1 = new Hono();
|
|
197
|
+
v1.use("/*", authResolver({ keyStore, sessionConfig }));
|
|
198
|
+
|
|
199
|
+
// Read-scope routes
|
|
200
|
+
const readRoutes = new Hono();
|
|
201
|
+
readRoutes.use("/*", requireScope("read"));
|
|
202
|
+
readRoutes.route("/", v1SignalRoutes({ signalRunner, signalSubscriber: stationSignalSub }));
|
|
203
|
+
readRoutes.route("/", v1RunRoutes({ signalRunner, signalAdapter, logBuffer, logStore }));
|
|
204
|
+
readRoutes.route("/", v1BroadcastRoutes({ broadcastRunner, broadcastAdapter, broadcastSubscriber: stationBroadcastSub }));
|
|
205
|
+
readRoutes.route("/", v1EventRoutes({ sseHub }));
|
|
206
|
+
v1.route("/", readRoutes);
|
|
207
|
+
|
|
208
|
+
// Trigger-scope routes
|
|
209
|
+
const triggerRoutes = new Hono();
|
|
210
|
+
triggerRoutes.use("/*", requireScope("trigger"));
|
|
211
|
+
triggerRoutes.route("/", v1TriggerRoutes({ signalRunner, signalAdapter, broadcastRunner, signalSubscriber: stationSignalSub }));
|
|
212
|
+
v1.route("/", triggerRoutes);
|
|
213
|
+
|
|
214
|
+
// Cancel-scope routes — only the cancel endpoints
|
|
215
|
+
const cancelRoutes = new Hono();
|
|
216
|
+
cancelRoutes.use("/*", requireScope("cancel"));
|
|
217
|
+
cancelRoutes.post("/runs/:id/cancel", async (c) => {
|
|
218
|
+
const id = c.req.param("id");
|
|
219
|
+
if (!signalRunner) {
|
|
220
|
+
return c.json({ error: "unavailable", message: "Station is in read-only mode." }, 503);
|
|
221
|
+
}
|
|
222
|
+
const success = await signalRunner.cancel(id);
|
|
223
|
+
if (!success) {
|
|
224
|
+
return c.json({ error: "cannot_cancel", message: "Run cannot be cancelled." }, 400);
|
|
225
|
+
}
|
|
226
|
+
return c.json({ data: { cancelled: true } });
|
|
227
|
+
});
|
|
228
|
+
cancelRoutes.post("/broadcast-runs/:id/cancel", async (c) => {
|
|
229
|
+
const id = c.req.param("id");
|
|
230
|
+
if (!broadcastRunner) {
|
|
231
|
+
return c.json({ error: "unavailable", message: "Station is in read-only mode." }, 503);
|
|
232
|
+
}
|
|
233
|
+
const success = await broadcastRunner.cancel(id);
|
|
234
|
+
if (!success) {
|
|
235
|
+
return c.json({ error: "cannot_cancel", message: "Broadcast run cannot be cancelled." }, 400);
|
|
236
|
+
}
|
|
237
|
+
return c.json({ data: { cancelled: true } });
|
|
238
|
+
});
|
|
239
|
+
v1.route("/", cancelRoutes);
|
|
240
|
+
|
|
241
|
+
// Admin-scope routes
|
|
242
|
+
const adminRoutes = new Hono();
|
|
243
|
+
adminRoutes.use("/*", requireScope("admin"));
|
|
244
|
+
adminRoutes.route("/", v1KeyRoutes({ keyStore }));
|
|
245
|
+
v1.route("/", adminRoutes);
|
|
246
|
+
|
|
247
|
+
app.route("/api/v1", v1);
|
|
248
|
+
|
|
249
|
+
let httpServer: Server | null = null;
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
async start() {
|
|
253
|
+
// Start runners (non-blocking — they have internal poll loops)
|
|
254
|
+
if (config.runRunners) {
|
|
255
|
+
if (signalRunner) {
|
|
256
|
+
signalRunner.start().catch((err: unknown) => {
|
|
257
|
+
console.error("[station] Signal runner error:", err);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
if (broadcastRunner) {
|
|
261
|
+
broadcastRunner.start().catch((err: unknown) => {
|
|
262
|
+
console.error("[station] Broadcast runner error:", err);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Start Hono server
|
|
268
|
+
httpServer = serve(
|
|
269
|
+
{ fetch: app.fetch, port: config.port, hostname: config.host },
|
|
270
|
+
(info) => {
|
|
271
|
+
console.log(`[station] API server on http://${config.host}:${info.port}`);
|
|
272
|
+
},
|
|
273
|
+
) as unknown as Server;
|
|
274
|
+
|
|
275
|
+
// Attach WebSocket to the HTTP server
|
|
276
|
+
wsHub.attach(httpServer);
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
async stop() {
|
|
280
|
+
// Stop broadcast runner first — it queries the DB during graceful shutdown
|
|
281
|
+
if (broadcastRunner) {
|
|
282
|
+
await broadcastRunner.stop({ graceful: true, timeoutMs: 5000 });
|
|
283
|
+
}
|
|
284
|
+
if (signalRunner) {
|
|
285
|
+
await signalRunner.stop({ graceful: true, timeoutMs: 5000 });
|
|
286
|
+
}
|
|
287
|
+
wsHub.close();
|
|
288
|
+
sseHub.close();
|
|
289
|
+
logStore.close();
|
|
290
|
+
keyStore?.close();
|
|
291
|
+
if (httpServer) {
|
|
292
|
+
httpServer.close();
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export interface LogEntry {
|
|
2
|
+
runId: string;
|
|
3
|
+
signalName: string;
|
|
4
|
+
level: "stdout" | "stderr";
|
|
5
|
+
message: string;
|
|
6
|
+
timestamp: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class LogBuffer {
|
|
10
|
+
private logs = new Map<string, LogEntry[]>();
|
|
11
|
+
private maxPerRun: number;
|
|
12
|
+
private maxRuns: number;
|
|
13
|
+
|
|
14
|
+
constructor(opts?: { maxPerRun?: number; maxRuns?: number }) {
|
|
15
|
+
this.maxPerRun = opts?.maxPerRun ?? 2000;
|
|
16
|
+
this.maxRuns = opts?.maxRuns ?? 500;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
add(entry: LogEntry): void {
|
|
20
|
+
let entries = this.logs.get(entry.runId);
|
|
21
|
+
if (!entries) {
|
|
22
|
+
entries = [];
|
|
23
|
+
this.logs.set(entry.runId, entries);
|
|
24
|
+
// Prune oldest runs if over capacity
|
|
25
|
+
if (this.logs.size > this.maxRuns) {
|
|
26
|
+
const firstKey = this.logs.keys().next().value;
|
|
27
|
+
if (firstKey) this.logs.delete(firstKey);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
entries.push(entry);
|
|
31
|
+
if (entries.length > this.maxPerRun) {
|
|
32
|
+
entries.shift();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get(runId: string): LogEntry[] {
|
|
37
|
+
return this.logs.get(runId) ?? [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
clear(): void {
|
|
41
|
+
this.logs.clear();
|
|
42
|
+
}
|
|
43
|
+
}
|