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
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import type { LogEntry } from "./log-buffer.js";
|
|
3
|
+
|
|
4
|
+
export class LogStore {
|
|
5
|
+
private db: Database.Database;
|
|
6
|
+
private insertStmt: Database.Statement;
|
|
7
|
+
private selectStmt: Database.Statement;
|
|
8
|
+
|
|
9
|
+
constructor(dbPath: string) {
|
|
10
|
+
this.db = new Database(dbPath);
|
|
11
|
+
this.db.pragma("journal_mode = WAL");
|
|
12
|
+
this.db.exec(`
|
|
13
|
+
CREATE TABLE IF NOT EXISTS logs (
|
|
14
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
15
|
+
run_id TEXT NOT NULL,
|
|
16
|
+
signal_name TEXT NOT NULL,
|
|
17
|
+
level TEXT NOT NULL,
|
|
18
|
+
message TEXT NOT NULL,
|
|
19
|
+
timestamp TEXT NOT NULL
|
|
20
|
+
)
|
|
21
|
+
`);
|
|
22
|
+
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_logs_run_id ON logs(run_id)`);
|
|
23
|
+
|
|
24
|
+
this.insertStmt = this.db.prepare(
|
|
25
|
+
`INSERT INTO logs (run_id, signal_name, level, message, timestamp) VALUES (?, ?, ?, ?, ?)`,
|
|
26
|
+
);
|
|
27
|
+
this.selectStmt = this.db.prepare(
|
|
28
|
+
`SELECT run_id, signal_name, level, message, timestamp FROM logs WHERE run_id = ? ORDER BY id`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
add(entry: LogEntry): void {
|
|
33
|
+
this.insertStmt.run(entry.runId, entry.signalName, entry.level, entry.message, entry.timestamp);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get(runId: string): LogEntry[] {
|
|
37
|
+
const rows = this.selectStmt.all(runId) as Array<{
|
|
38
|
+
run_id: string;
|
|
39
|
+
signal_name: string;
|
|
40
|
+
level: string;
|
|
41
|
+
message: string;
|
|
42
|
+
timestamp: string;
|
|
43
|
+
}>;
|
|
44
|
+
return rows.map((row) => ({
|
|
45
|
+
runId: row.run_id,
|
|
46
|
+
signalName: row.signal_name,
|
|
47
|
+
level: row.level as "stdout" | "stderr",
|
|
48
|
+
message: row.message,
|
|
49
|
+
timestamp: row.timestamp,
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
close(): void {
|
|
54
|
+
this.db.close();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zod schema serializer and metadata types for station API.
|
|
3
|
+
* Introspects Zod schema internals to produce JSON-serializable descriptions.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ── Types ──
|
|
7
|
+
|
|
8
|
+
export interface SchemaField {
|
|
9
|
+
type: string;
|
|
10
|
+
required: boolean;
|
|
11
|
+
properties?: Record<string, SchemaField>;
|
|
12
|
+
items?: SchemaField;
|
|
13
|
+
values?: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SignalMeta {
|
|
17
|
+
name: string;
|
|
18
|
+
filePath: string;
|
|
19
|
+
inputSchema: SchemaField | null;
|
|
20
|
+
outputSchema: SchemaField | null;
|
|
21
|
+
interval: string | null;
|
|
22
|
+
timeout: number;
|
|
23
|
+
maxAttempts: number;
|
|
24
|
+
maxConcurrency: number | null;
|
|
25
|
+
hasSteps: boolean;
|
|
26
|
+
stepNames: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface BroadcastMeta {
|
|
30
|
+
name: string;
|
|
31
|
+
filePath: string;
|
|
32
|
+
nodes: Array<{ name: string; signalName: string; dependsOn: string[] }>;
|
|
33
|
+
failurePolicy: string;
|
|
34
|
+
timeout: number | null;
|
|
35
|
+
interval: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Zod Schema Serializer ──
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolves the type identifier and def from a Zod schema.
|
|
42
|
+
* Supports both Zod v3 (_def.typeName) and Zod v4 (_zod.def.type).
|
|
43
|
+
*/
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
45
|
+
function zodDef(schema: any): { type: string; def: any } | null {
|
|
46
|
+
if (schema?._zod?.def?.type) {
|
|
47
|
+
return { type: schema._zod.def.type, def: schema._zod.def };
|
|
48
|
+
}
|
|
49
|
+
if (schema?._def?.typeName) {
|
|
50
|
+
return { type: schema._def.typeName, def: schema._def };
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
56
|
+
export function serializeZodSchema(schema: any): SchemaField {
|
|
57
|
+
const z = zodDef(schema);
|
|
58
|
+
if (!z) return { type: "unknown", required: true };
|
|
59
|
+
|
|
60
|
+
const { type, def } = z;
|
|
61
|
+
|
|
62
|
+
switch (type) {
|
|
63
|
+
case "object":
|
|
64
|
+
case "ZodObject": {
|
|
65
|
+
const shape = typeof def.shape === "function" ? def.shape() : def.shape;
|
|
66
|
+
const properties: Record<string, SchemaField> = {};
|
|
67
|
+
if (shape && typeof shape === "object") {
|
|
68
|
+
for (const [key, value] of Object.entries(shape)) {
|
|
69
|
+
properties[key] = serializeZodSchema(value);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { type: "object", required: true, properties };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
case "string":
|
|
76
|
+
case "ZodString":
|
|
77
|
+
return { type: "string", required: true };
|
|
78
|
+
|
|
79
|
+
case "number":
|
|
80
|
+
case "ZodNumber":
|
|
81
|
+
return { type: "number", required: true };
|
|
82
|
+
|
|
83
|
+
case "boolean":
|
|
84
|
+
case "ZodBoolean":
|
|
85
|
+
return { type: "boolean", required: true };
|
|
86
|
+
|
|
87
|
+
case "array":
|
|
88
|
+
case "ZodArray":
|
|
89
|
+
return {
|
|
90
|
+
type: "array",
|
|
91
|
+
required: true,
|
|
92
|
+
items: (def.element ?? def.type) ? serializeZodSchema(def.element ?? def.type) : undefined,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
case "optional":
|
|
96
|
+
case "ZodOptional":
|
|
97
|
+
return { ...serializeZodSchema(def.innerType), required: false };
|
|
98
|
+
|
|
99
|
+
case "nullable":
|
|
100
|
+
case "ZodNullable": {
|
|
101
|
+
const inner = serializeZodSchema(def.innerType);
|
|
102
|
+
return { ...inner, type: inner.type + " | null" };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
case "default":
|
|
106
|
+
case "ZodDefault":
|
|
107
|
+
return { ...serializeZodSchema(def.innerType), required: false };
|
|
108
|
+
|
|
109
|
+
case "enum":
|
|
110
|
+
case "ZodEnum": {
|
|
111
|
+
// v4: def.entries is {a:'a', b:'b'}; v3: def.values is string[]
|
|
112
|
+
let vals: string[] | undefined;
|
|
113
|
+
if (def.entries && typeof def.entries === "object") {
|
|
114
|
+
vals = Object.values(def.entries) as string[];
|
|
115
|
+
} else if (Array.isArray(def.values)) {
|
|
116
|
+
vals = def.values as string[];
|
|
117
|
+
}
|
|
118
|
+
return { type: "enum", required: true, values: vals };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
case "literal":
|
|
122
|
+
case "ZodLiteral": {
|
|
123
|
+
// v4: def.values is [value]; v3: def.value
|
|
124
|
+
const val = Array.isArray(def.values) ? def.values[0] : def.value;
|
|
125
|
+
return { type: `"${String(val)}"`, required: true };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
case "union":
|
|
129
|
+
case "ZodUnion": {
|
|
130
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
131
|
+
const opts = (def.options as any[])?.map((o: any) => serializeZodSchema(o).type);
|
|
132
|
+
return { type: opts?.join(" | ") ?? "unknown", required: true };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
case "record":
|
|
136
|
+
case "ZodRecord":
|
|
137
|
+
return { type: "record", required: true };
|
|
138
|
+
|
|
139
|
+
case "any":
|
|
140
|
+
case "ZodAny":
|
|
141
|
+
return { type: "any", required: true };
|
|
142
|
+
|
|
143
|
+
case "void":
|
|
144
|
+
case "undefined":
|
|
145
|
+
case "ZodVoid":
|
|
146
|
+
case "ZodUndefined":
|
|
147
|
+
return { type: "void", required: true };
|
|
148
|
+
|
|
149
|
+
default:
|
|
150
|
+
return { type: "unknown", required: true };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Template Generator ──
|
|
155
|
+
|
|
156
|
+
export function generateTemplate(field: SchemaField): unknown {
|
|
157
|
+
switch (field.type) {
|
|
158
|
+
case "object":
|
|
159
|
+
if (field.properties) {
|
|
160
|
+
const obj: Record<string, unknown> = {};
|
|
161
|
+
for (const [key, f] of Object.entries(field.properties)) {
|
|
162
|
+
obj[key] = generateTemplate(f);
|
|
163
|
+
}
|
|
164
|
+
return obj;
|
|
165
|
+
}
|
|
166
|
+
return {};
|
|
167
|
+
case "string":
|
|
168
|
+
return "";
|
|
169
|
+
case "number":
|
|
170
|
+
return 0;
|
|
171
|
+
case "boolean":
|
|
172
|
+
return false;
|
|
173
|
+
case "array":
|
|
174
|
+
return [];
|
|
175
|
+
case "enum":
|
|
176
|
+
return field.values?.[0] ?? "";
|
|
177
|
+
default:
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createMiddleware } from "hono/factory";
|
|
2
|
+
import type { KeyStore } from "../auth/keys.js";
|
|
3
|
+
import { verifySessionToken, type SessionConfig } from "../auth/session.js";
|
|
4
|
+
|
|
5
|
+
export interface AuthDeps {
|
|
6
|
+
keyStore?: KeyStore;
|
|
7
|
+
sessionConfig?: SessionConfig;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Sets c.get("authType"), c.get("apiKeyId"), c.get("scopes") */
|
|
11
|
+
export function authResolver(deps: AuthDeps) {
|
|
12
|
+
return createMiddleware(async (c, next) => {
|
|
13
|
+
// Check for API key in Authorization header
|
|
14
|
+
const authHeader = c.req.header("authorization");
|
|
15
|
+
if (authHeader?.startsWith("Bearer ") && deps.keyStore) {
|
|
16
|
+
const token = authHeader.slice(7);
|
|
17
|
+
if (token.startsWith("sk_")) {
|
|
18
|
+
const key = deps.keyStore.verify(token);
|
|
19
|
+
if (key) {
|
|
20
|
+
c.set("authType", "api-key");
|
|
21
|
+
c.set("apiKeyId", key.id);
|
|
22
|
+
c.set("scopes", key.scopes);
|
|
23
|
+
return next();
|
|
24
|
+
}
|
|
25
|
+
return c.json({ error: "unauthorized", message: "Invalid or revoked API key." }, 401);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Check for session cookie
|
|
30
|
+
const cookie = c.req.header("cookie");
|
|
31
|
+
if (cookie && deps.sessionConfig) {
|
|
32
|
+
const match = cookie.match(/station_session=([^;]+)/);
|
|
33
|
+
if (match) {
|
|
34
|
+
const valid = verifySessionToken(match[1], deps.sessionConfig);
|
|
35
|
+
if (valid) {
|
|
36
|
+
c.set("authType", "session");
|
|
37
|
+
c.set("apiKeyId", undefined);
|
|
38
|
+
c.set("scopes", ["trigger", "read", "cancel", "admin"]);
|
|
39
|
+
return next();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// No auth
|
|
45
|
+
c.set("authType", "none");
|
|
46
|
+
c.set("apiKeyId", undefined);
|
|
47
|
+
c.set("scopes", []);
|
|
48
|
+
return next();
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createMiddleware } from "hono/factory";
|
|
2
|
+
import type { Context } from "hono";
|
|
3
|
+
|
|
4
|
+
interface RateLimitOptions {
|
|
5
|
+
/** Time window in ms. Default: 60_000 (1 minute). */
|
|
6
|
+
windowMs?: number;
|
|
7
|
+
/** Max requests per window. Default: 100. */
|
|
8
|
+
max?: number;
|
|
9
|
+
/** Function to derive the rate limit key from request. Default: API key ID or IP. */
|
|
10
|
+
keyFn?: (c: Context) => string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface BucketEntry {
|
|
14
|
+
count: number;
|
|
15
|
+
resetAt: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function rateLimiter(options: RateLimitOptions = {}) {
|
|
19
|
+
const windowMs = options.windowMs ?? 60_000;
|
|
20
|
+
const max = options.max ?? 100;
|
|
21
|
+
const keyFn = options.keyFn ?? ((c: Context) => {
|
|
22
|
+
return (c.get("apiKeyId") as string) ?? c.req.header("x-forwarded-for") ?? "anonymous";
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const buckets = new Map<string, BucketEntry>();
|
|
26
|
+
|
|
27
|
+
// Cleanup old entries periodically
|
|
28
|
+
const cleanup = setInterval(() => {
|
|
29
|
+
const now = Date.now();
|
|
30
|
+
for (const [key, entry] of buckets) {
|
|
31
|
+
if (now > entry.resetAt) buckets.delete(key);
|
|
32
|
+
}
|
|
33
|
+
}, windowMs * 2);
|
|
34
|
+
cleanup.unref();
|
|
35
|
+
|
|
36
|
+
return createMiddleware(async (c, next) => {
|
|
37
|
+
const key = keyFn(c);
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
|
|
40
|
+
let entry = buckets.get(key);
|
|
41
|
+
if (!entry || now > entry.resetAt) {
|
|
42
|
+
entry = { count: 0, resetAt: now + windowMs };
|
|
43
|
+
buckets.set(key, entry);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
entry.count++;
|
|
47
|
+
|
|
48
|
+
c.header("X-RateLimit-Limit", String(max));
|
|
49
|
+
c.header("X-RateLimit-Remaining", String(Math.max(0, max - entry.count)));
|
|
50
|
+
c.header("X-RateLimit-Reset", String(Math.ceil(entry.resetAt / 1000)));
|
|
51
|
+
|
|
52
|
+
if (entry.count > max) {
|
|
53
|
+
return c.json(
|
|
54
|
+
{ error: "rate_limited", message: "Too many requests. Try again later." },
|
|
55
|
+
429,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return next();
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createMiddleware } from "hono/factory";
|
|
2
|
+
|
|
3
|
+
/** Require at least one of the specified scopes. */
|
|
4
|
+
export function requireScope(...requiredScopes: string[]) {
|
|
5
|
+
return createMiddleware(async (c, next) => {
|
|
6
|
+
const authType = c.get("authType") as string | undefined;
|
|
7
|
+
if (!authType || authType === "none") {
|
|
8
|
+
return c.json({ error: "unauthorized", message: "Authentication required." }, 401);
|
|
9
|
+
}
|
|
10
|
+
const scopes = (c.get("scopes") as string[]) ?? [];
|
|
11
|
+
const hasScope = requiredScopes.some((s) => scopes.includes(s));
|
|
12
|
+
if (!hasScope) {
|
|
13
|
+
return c.json(
|
|
14
|
+
{ error: "forbidden", message: `Required scope: ${requiredScopes.join(" or ")}.` },
|
|
15
|
+
403,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
return next();
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { BroadcastRunner, BroadcastQueueAdapter } from "station-broadcast";
|
|
3
|
+
import type { StationBroadcastSubscriber } from "../subscriber.js";
|
|
4
|
+
|
|
5
|
+
export interface BroadcastDeps {
|
|
6
|
+
broadcastRunner?: BroadcastRunner;
|
|
7
|
+
broadcastAdapter?: BroadcastQueueAdapter;
|
|
8
|
+
broadcastSubscriber?: StationBroadcastSubscriber;
|
|
9
|
+
logBuffer?: import("../log-buffer.js").LogBuffer;
|
|
10
|
+
logStore?: import("../log-store.js").LogStore;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function broadcastRoutes(deps: BroadcastDeps) {
|
|
14
|
+
const app = new Hono();
|
|
15
|
+
|
|
16
|
+
// GET /broadcasts — list all broadcasts with metadata
|
|
17
|
+
app.get("/broadcasts", async (c) => {
|
|
18
|
+
if (deps.broadcastSubscriber) {
|
|
19
|
+
const meta = deps.broadcastSubscriber.getAllBroadcastMeta();
|
|
20
|
+
if (meta.length > 0) {
|
|
21
|
+
return c.json({ data: meta });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Fallback to registry
|
|
26
|
+
if (!deps.broadcastRunner) {
|
|
27
|
+
return c.json({ data: [] });
|
|
28
|
+
}
|
|
29
|
+
const result = deps.broadcastRunner.listRegistered();
|
|
30
|
+
return c.json({ data: result });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// GET /broadcasts/:name — single broadcast metadata
|
|
34
|
+
app.get("/broadcasts/:name", async (c) => {
|
|
35
|
+
const name = c.req.param("name");
|
|
36
|
+
|
|
37
|
+
if (deps.broadcastSubscriber) {
|
|
38
|
+
const meta = deps.broadcastSubscriber.getBroadcastMeta(name);
|
|
39
|
+
if (meta) {
|
|
40
|
+
return c.json({ data: meta });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Fallback: check registry
|
|
45
|
+
if (deps.broadcastRunner) {
|
|
46
|
+
const entry = deps.broadcastRunner.listRegistered().find((b) => b.name === name);
|
|
47
|
+
if (entry) {
|
|
48
|
+
return c.json({ data: entry });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return c.json({ error: "not_found", message: `Broadcast "${name}" not found.` }, 404);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// POST /broadcasts/:name/trigger
|
|
56
|
+
app.post("/broadcasts/:name/trigger", async (c) => {
|
|
57
|
+
const name = c.req.param("name");
|
|
58
|
+
if (!deps.broadcastRunner) {
|
|
59
|
+
return c.json({ error: "read_only", message: "Station is in read-only mode." }, 403);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const body = await c.req.json().catch(() => ({}));
|
|
63
|
+
const input = body.input ?? {};
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const id = await deps.broadcastRunner.trigger(name, input);
|
|
67
|
+
return c.json({ data: { id } });
|
|
68
|
+
} catch (err: unknown) {
|
|
69
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
70
|
+
return c.json({ error: "trigger_failed", message }, 400);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// GET /broadcasts/:name/runs
|
|
75
|
+
app.get("/broadcasts/:name/runs", async (c) => {
|
|
76
|
+
const name = c.req.param("name");
|
|
77
|
+
if (!deps.broadcastAdapter) {
|
|
78
|
+
return c.json({ data: [], meta: { total: 0 } });
|
|
79
|
+
}
|
|
80
|
+
const runs = await deps.broadcastAdapter.listBroadcastRuns(name);
|
|
81
|
+
return c.json({
|
|
82
|
+
data: runs.map(serializeBroadcastRun),
|
|
83
|
+
meta: { total: runs.length },
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// GET /broadcast-runs/:id
|
|
88
|
+
app.get("/broadcast-runs/:id", async (c) => {
|
|
89
|
+
const id = c.req.param("id");
|
|
90
|
+
if (!deps.broadcastAdapter) {
|
|
91
|
+
return c.json({ error: "not_found", message: "No broadcast adapter configured." }, 404);
|
|
92
|
+
}
|
|
93
|
+
const run = await deps.broadcastAdapter.getBroadcastRun(id);
|
|
94
|
+
if (!run) {
|
|
95
|
+
return c.json({ error: "not_found", message: "Broadcast run not found." }, 404);
|
|
96
|
+
}
|
|
97
|
+
return c.json({ data: serializeBroadcastRun(run) });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// GET /broadcast-runs/:id/nodes
|
|
101
|
+
app.get("/broadcast-runs/:id/nodes", async (c) => {
|
|
102
|
+
const id = c.req.param("id");
|
|
103
|
+
if (!deps.broadcastAdapter) {
|
|
104
|
+
return c.json({ data: [] });
|
|
105
|
+
}
|
|
106
|
+
const nodes = await deps.broadcastAdapter.getNodeRuns(id);
|
|
107
|
+
return c.json({
|
|
108
|
+
data: nodes.map((nr) => ({
|
|
109
|
+
...nr,
|
|
110
|
+
startedAt: nr.startedAt?.toISOString?.() ?? nr.startedAt,
|
|
111
|
+
completedAt: nr.completedAt?.toISOString?.() ?? nr.completedAt,
|
|
112
|
+
})),
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// GET /broadcast-runs/:id/logs — aggregate logs from all node signal runs
|
|
117
|
+
app.get("/broadcast-runs/:id/logs", async (c) => {
|
|
118
|
+
const id = c.req.param("id");
|
|
119
|
+
if (!deps.broadcastAdapter || (!deps.logStore && !deps.logBuffer)) {
|
|
120
|
+
return c.json({ data: [] });
|
|
121
|
+
}
|
|
122
|
+
const nodes = await deps.broadcastAdapter.getNodeRuns(id);
|
|
123
|
+
const allLogs: Array<{ runId: string; signalName: string; level: string; message: string; timestamp: string; nodeName: string }> = [];
|
|
124
|
+
for (const nr of nodes) {
|
|
125
|
+
if (nr.signalRunId) {
|
|
126
|
+
const logs = deps.logStore?.get(nr.signalRunId) ?? deps.logBuffer?.get(nr.signalRunId) ?? [];
|
|
127
|
+
for (const log of logs) {
|
|
128
|
+
allLogs.push({ ...log, nodeName: nr.nodeName });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
allLogs.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
133
|
+
return c.json({ data: allLogs });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// POST /broadcast-runs/:id/cancel
|
|
137
|
+
app.post("/broadcast-runs/:id/cancel", async (c) => {
|
|
138
|
+
const id = c.req.param("id");
|
|
139
|
+
if (!deps.broadcastRunner) {
|
|
140
|
+
return c.json({ error: "read_only", message: "Station is in read-only mode." }, 403);
|
|
141
|
+
}
|
|
142
|
+
const success = await deps.broadcastRunner.cancel(id);
|
|
143
|
+
if (!success) {
|
|
144
|
+
return c.json({ error: "cannot_cancel", message: "Broadcast run cannot be cancelled." }, 400);
|
|
145
|
+
}
|
|
146
|
+
return c.json({ data: { cancelled: true } });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
return app;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function serializeBroadcastRun(run: any): Record<string, unknown> {
|
|
153
|
+
return {
|
|
154
|
+
...run,
|
|
155
|
+
nextRunAt: run.nextRunAt?.toISOString?.() ?? run.nextRunAt,
|
|
156
|
+
startedAt: run.startedAt?.toISOString?.() ?? run.startedAt,
|
|
157
|
+
completedAt: run.completedAt?.toISOString?.() ?? run.completedAt,
|
|
158
|
+
createdAt: run.createdAt?.toISOString?.() ?? run.createdAt,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { SignalQueueAdapter } from "station-signal";
|
|
3
|
+
import type { BroadcastQueueAdapter } from "station-broadcast";
|
|
4
|
+
|
|
5
|
+
export interface HealthDeps {
|
|
6
|
+
signalAdapter: SignalQueueAdapter;
|
|
7
|
+
broadcastAdapter?: BroadcastQueueAdapter;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function healthRoutes(deps: HealthDeps) {
|
|
11
|
+
const app = new Hono();
|
|
12
|
+
|
|
13
|
+
app.get("/health", async (c) => {
|
|
14
|
+
let signalOk = false;
|
|
15
|
+
let broadcastOk = false;
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
signalOk = await deps.signalAdapter.ping();
|
|
19
|
+
} catch {}
|
|
20
|
+
|
|
21
|
+
if (deps.broadcastAdapter) {
|
|
22
|
+
try {
|
|
23
|
+
broadcastOk = await deps.broadcastAdapter.ping();
|
|
24
|
+
} catch {}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return c.json({
|
|
28
|
+
data: {
|
|
29
|
+
ok: signalOk && (!deps.broadcastAdapter || broadcastOk),
|
|
30
|
+
signal: signalOk,
|
|
31
|
+
broadcast: deps.broadcastAdapter ? broadcastOk : null,
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return app;
|
|
37
|
+
}
|