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.
Files changed (181) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cli-main.d.ts +2 -0
  3. package/dist/cli-main.d.ts.map +1 -0
  4. package/dist/cli-main.js +58 -0
  5. package/dist/cli-main.js.map +1 -0
  6. package/dist/cli.d.ts +3 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +25 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/config/loader.d.ts +3 -0
  11. package/dist/config/loader.d.ts.map +1 -0
  12. package/dist/config/loader.js +29 -0
  13. package/dist/config/loader.js.map +1 -0
  14. package/dist/config/schema.d.ts +36 -0
  15. package/dist/config/schema.d.ts.map +1 -0
  16. package/dist/config/schema.js +40 -0
  17. package/dist/config/schema.js.map +1 -0
  18. package/dist/index.d.ts +4 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +4 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/server/auth/keys.d.ts +28 -0
  23. package/dist/server/auth/keys.d.ts.map +1 -0
  24. package/dist/server/auth/keys.js +91 -0
  25. package/dist/server/auth/keys.js.map +1 -0
  26. package/dist/server/auth/session.d.ts +9 -0
  27. package/dist/server/auth/session.d.ts.map +1 -0
  28. package/dist/server/auth/session.js +42 -0
  29. package/dist/server/auth/session.js.map +1 -0
  30. package/dist/server/index.d.ts +7 -0
  31. package/dist/server/index.d.ts.map +1 -0
  32. package/dist/server/index.js +253 -0
  33. package/dist/server/index.js.map +1 -0
  34. package/dist/server/log-buffer.d.ts +20 -0
  35. package/dist/server/log-buffer.d.ts.map +1 -0
  36. package/dist/server/log-buffer.js +33 -0
  37. package/dist/server/log-buffer.js.map +1 -0
  38. package/dist/server/log-store.d.ts +11 -0
  39. package/dist/server/log-store.d.ts.map +1 -0
  40. package/dist/server/log-store.js +40 -0
  41. package/dist/server/log-store.js.map +1 -0
  42. package/dist/server/metadata.d.ts +38 -0
  43. package/dist/server/metadata.d.ts.map +1 -0
  44. package/dist/server/metadata.js +130 -0
  45. package/dist/server/metadata.js.map +1 -0
  46. package/dist/server/middleware/auth.d.ts +12 -0
  47. package/dist/server/middleware/auth.d.ts.map +1 -0
  48. package/dist/server/middleware/auth.js +42 -0
  49. package/dist/server/middleware/auth.js.map +1 -0
  50. package/dist/server/middleware/rate-limit.d.ts +15 -0
  51. package/dist/server/middleware/rate-limit.d.ts.map +1 -0
  52. package/dist/server/middleware/rate-limit.js +36 -0
  53. package/dist/server/middleware/rate-limit.js.map +1 -0
  54. package/dist/server/middleware/scope-guard.d.ts +9 -0
  55. package/dist/server/middleware/scope-guard.d.ts.map +1 -0
  56. package/dist/server/middleware/scope-guard.js +17 -0
  57. package/dist/server/middleware/scope-guard.js.map +1 -0
  58. package/dist/server/routes/broadcasts.d.ts +12 -0
  59. package/dist/server/routes/broadcasts.d.ts.map +1 -0
  60. package/dist/server/routes/broadcasts.js +135 -0
  61. package/dist/server/routes/broadcasts.js.map +1 -0
  62. package/dist/server/routes/health.d.ts +9 -0
  63. package/dist/server/routes/health.d.ts.map +1 -0
  64. package/dist/server/routes/health.js +27 -0
  65. package/dist/server/routes/health.js.map +1 -0
  66. package/dist/server/routes/runs.d.ts +12 -0
  67. package/dist/server/routes/runs.d.ts.map +1 -0
  68. package/dist/server/routes/runs.js +122 -0
  69. package/dist/server/routes/runs.js.map +1 -0
  70. package/dist/server/routes/signals.d.ts +10 -0
  71. package/dist/server/routes/signals.d.ts.map +1 -0
  72. package/dist/server/routes/signals.js +120 -0
  73. package/dist/server/routes/signals.js.map +1 -0
  74. package/dist/server/routes/v1/auth.d.ts +7 -0
  75. package/dist/server/routes/v1/auth.d.ts.map +1 -0
  76. package/dist/server/routes/v1/auth.js +28 -0
  77. package/dist/server/routes/v1/auth.js.map +1 -0
  78. package/dist/server/routes/v1/broadcasts.d.ts +10 -0
  79. package/dist/server/routes/v1/broadcasts.d.ts.map +1 -0
  80. package/dist/server/routes/v1/broadcasts.js +68 -0
  81. package/dist/server/routes/v1/broadcasts.js.map +1 -0
  82. package/dist/server/routes/v1/events.d.ts +7 -0
  83. package/dist/server/routes/v1/events.d.ts.map +1 -0
  84. package/dist/server/routes/v1/events.js +57 -0
  85. package/dist/server/routes/v1/events.js.map +1 -0
  86. package/dist/server/routes/v1/health.d.ts +9 -0
  87. package/dist/server/routes/v1/health.d.ts.map +1 -0
  88. package/dist/server/routes/v1/health.js +31 -0
  89. package/dist/server/routes/v1/health.js.map +1 -0
  90. package/dist/server/routes/v1/keys.d.ts +7 -0
  91. package/dist/server/routes/v1/keys.d.ts.map +1 -0
  92. package/dist/server/routes/v1/keys.js +43 -0
  93. package/dist/server/routes/v1/keys.js.map +1 -0
  94. package/dist/server/routes/v1/runs.d.ts +12 -0
  95. package/dist/server/routes/v1/runs.d.ts.map +1 -0
  96. package/dist/server/routes/v1/runs.js +76 -0
  97. package/dist/server/routes/v1/runs.js.map +1 -0
  98. package/dist/server/routes/v1/signals.d.ts +9 -0
  99. package/dist/server/routes/v1/signals.d.ts.map +1 -0
  100. package/dist/server/routes/v1/signals.js +33 -0
  101. package/dist/server/routes/v1/signals.js.map +1 -0
  102. package/dist/server/routes/v1/trigger.d.ts +12 -0
  103. package/dist/server/routes/v1/trigger.d.ts.map +1 -0
  104. package/dist/server/routes/v1/trigger.js +73 -0
  105. package/dist/server/routes/v1/trigger.js.map +1 -0
  106. package/dist/server/sse.d.ts +19 -0
  107. package/dist/server/sse.d.ts.map +1 -0
  108. package/dist/server/sse.js +51 -0
  109. package/dist/server/sse.js.map +1 -0
  110. package/dist/server/subscriber.d.ts +128 -0
  111. package/dist/server/subscriber.d.ts.map +1 -0
  112. package/dist/server/subscriber.js +246 -0
  113. package/dist/server/subscriber.js.map +1 -0
  114. package/dist/server/ws.d.ts +15 -0
  115. package/dist/server/ws.d.ts.map +1 -0
  116. package/dist/server/ws.js +32 -0
  117. package/dist/server/ws.js.map +1 -0
  118. package/next-env.d.ts +6 -0
  119. package/next.config.ts +10 -0
  120. package/package.json +49 -0
  121. package/src/app/broadcasts/[id]/page.tsx +511 -0
  122. package/src/app/broadcasts/page.tsx +158 -0
  123. package/src/app/components/auth-provider.tsx +75 -0
  124. package/src/app/components/breadcrumb-provider.tsx +18 -0
  125. package/src/app/components/dag-view.tsx +380 -0
  126. package/src/app/components/empty-state.tsx +7 -0
  127. package/src/app/components/json-viewer.tsx +153 -0
  128. package/src/app/components/login-page.tsx +78 -0
  129. package/src/app/components/node-detail.tsx +158 -0
  130. package/src/app/components/pulse-dot.tsx +8 -0
  131. package/src/app/components/relative-time.tsx +34 -0
  132. package/src/app/components/run-table.tsx +96 -0
  133. package/src/app/components/schema-form.tsx +121 -0
  134. package/src/app/components/shell.tsx +203 -0
  135. package/src/app/components/status-badge.tsx +10 -0
  136. package/src/app/components/step-timeline.tsx +134 -0
  137. package/src/app/components/theme-provider.tsx +45 -0
  138. package/src/app/components/workflow-node-sidebar.tsx +68 -0
  139. package/src/app/globals.css +1523 -0
  140. package/src/app/hooks/use-api.ts +129 -0
  141. package/src/app/hooks/use-breadcrumb.ts +37 -0
  142. package/src/app/hooks/use-realtime.ts +68 -0
  143. package/src/app/hooks/use-station.tsx +34 -0
  144. package/src/app/layout.tsx +42 -0
  145. package/src/app/page.tsx +275 -0
  146. package/src/app/runs/[id]/page.tsx +277 -0
  147. package/src/app/signals/[name]/page.tsx +250 -0
  148. package/src/app/signals/page.tsx +99 -0
  149. package/src/cli-main.ts +70 -0
  150. package/src/cli.ts +27 -0
  151. package/src/config/loader.ts +33 -0
  152. package/src/config/schema.ts +80 -0
  153. package/src/index.ts +7 -0
  154. package/src/server/auth/keys.ts +112 -0
  155. package/src/server/auth/session.ts +48 -0
  156. package/src/server/index.ts +296 -0
  157. package/src/server/log-buffer.ts +43 -0
  158. package/src/server/log-store.ts +56 -0
  159. package/src/server/metadata.ts +180 -0
  160. package/src/server/middleware/auth.ts +50 -0
  161. package/src/server/middleware/rate-limit.ts +61 -0
  162. package/src/server/middleware/scope-guard.ts +20 -0
  163. package/src/server/routes/broadcasts.ts +160 -0
  164. package/src/server/routes/health.ts +37 -0
  165. package/src/server/routes/runs.ts +149 -0
  166. package/src/server/routes/signals.ts +153 -0
  167. package/src/server/routes/v1/auth.ts +47 -0
  168. package/src/server/routes/v1/broadcasts.ts +84 -0
  169. package/src/server/routes/v1/events.ts +71 -0
  170. package/src/server/routes/v1/health.ts +41 -0
  171. package/src/server/routes/v1/keys.ts +57 -0
  172. package/src/server/routes/v1/runs.ts +97 -0
  173. package/src/server/routes/v1/signals.ts +44 -0
  174. package/src/server/routes/v1/trigger.ts +111 -0
  175. package/src/server/sse.ts +70 -0
  176. package/src/server/subscriber.ts +288 -0
  177. package/src/server/ws.ts +44 -0
  178. package/station.config.example.ts +16 -0
  179. package/tsconfig.json +12 -0
  180. package/tsconfig.next.json +15 -0
  181. 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,7 @@
1
+ import type { StationUserConfig } from "./config/schema.js";
2
+
3
+ export function defineConfig(config: StationUserConfig): StationUserConfig {
4
+ return config;
5
+ }
6
+
7
+ export type { StationConfig, StationUserConfig, AuthConfig } from "./config/schema.js";
@@ -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
+ }