station-kit 1.0.9 → 2.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/.next/standalone/packages/station-kit/.next/BUILD_ID +1 -1
- package/.next/standalone/packages/station-kit/.next/app-build-manifest.json +57 -36
- package/.next/standalone/packages/station-kit/.next/app-path-routes-manifest.json +13 -10
- package/.next/standalone/packages/station-kit/.next/build-manifest.json +2 -2
- package/.next/standalone/packages/station-kit/.next/prerender-manifest.json +72 -24
- package/.next/standalone/packages/station-kit/.next/routes-manifest.json +20 -0
- package/.next/standalone/packages/station-kit/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/_not-found.rsc +8 -8
- package/.next/standalone/packages/station-kit/.next/server/app/beacons/[name]/page.js +2 -0
- package/.next/standalone/packages/station-kit/.next/server/app/beacons/[name]/page.js.nft.json +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/beacons/[name]/page_client-reference-manifest.js +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/beacons/page.js +2 -0
- package/.next/standalone/packages/station-kit/.next/server/app/beacons/page.js.nft.json +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/beacons/page_client-reference-manifest.js +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/beacons.html +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/beacons.meta +7 -0
- package/.next/standalone/packages/station-kit/.next/server/app/beacons.rsc +25 -0
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/[id]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/v/[n]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.rsc +9 -9
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts.rsc +9 -9
- package/.next/standalone/packages/station-kit/.next/server/app/environment/page.js +2 -0
- package/.next/standalone/packages/station-kit/.next/server/app/environment/page.js.nft.json +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/environment/page_client-reference-manifest.js +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/environment.html +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/environment.meta +7 -0
- package/.next/standalone/packages/station-kit/.next/server/app/environment.rsc +25 -0
- package/.next/standalone/packages/station-kit/.next/server/app/index.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/index.rsc +9 -9
- package/.next/standalone/packages/station-kit/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/playground/expression/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.rsc +9 -9
- package/.next/standalone/packages/station-kit/.next/server/app/runs/[id]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/[id]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/new/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.rsc +9 -9
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/schedules.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/schedules.rsc +9 -9
- package/.next/standalone/packages/station-kit/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/settings.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/settings.rsc +9 -9
- package/.next/standalone/packages/station-kit/.next/server/app/signals/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/signals/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/signals.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/signals.rsc +9 -9
- package/.next/standalone/packages/station-kit/.next/server/app-paths-manifest.json +13 -10
- package/.next/standalone/packages/station-kit/.next/server/chunks/102.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/pages/404.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/pages/500.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/pages-manifest.json +1 -1
- package/.next/standalone/packages/station-kit/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/285-583f23ee7827983f.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/561-05a7ef9669e3921a.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/beacons/[name]/page-eb1f82847734e145.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/beacons/page-64e296346197e44b.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/dyn/[name]/v/[n]/page-15edf07ab26241c9.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/page-b23a03097c8f75c4.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/environment/page-111242bec067fdee.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/layout-e9ac56fcaf7a5ea7.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/page-ef78692958c2fc01.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/playground/expression/page-29294a705dd8f471.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/runs/[id]/page-287bbb8f6dec2e23.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/schedules/page-c869218da203e3b4.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/settings/page-05cf43b6ae6b3187.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/[name]/page-4027803be00c695a.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/page-8e076fc5d0188d72.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/css/ddb1646dc25ed100.css +1 -0
- package/.next/standalone/packages/station-kit/.next/static/vTGEzSqxhR_E57YAFn9DC/_buildManifest.js +1 -0
- package/.next/standalone/packages/station-kit/package.json +6 -4
- package/dist/config/schema.d.ts +31 -3
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +4 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/server/auth/keys.d.ts +58 -6
- package/dist/server/auth/keys.d.ts.map +1 -1
- package/dist/server/auth/keys.js +179 -14
- package/dist/server/auth/keys.js.map +1 -1
- package/dist/server/auth/session.d.ts.map +1 -1
- package/dist/server/auth/session.js +34 -11
- package/dist/server/auth/session.js.map +1 -1
- package/dist/server/index.d.ts +5 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +169 -24
- package/dist/server/index.js.map +1 -1
- package/dist/server/log-store.d.ts +102 -6
- package/dist/server/log-store.d.ts.map +1 -1
- package/dist/server/log-store.js +140 -32
- package/dist/server/log-store.js.map +1 -1
- package/dist/server/metadata.d.ts +2 -0
- package/dist/server/metadata.d.ts.map +1 -1
- package/dist/server/metadata.js.map +1 -1
- package/dist/server/middleware/rate-limit.d.ts.map +1 -1
- package/dist/server/middleware/rate-limit.js +22 -1
- package/dist/server/middleware/rate-limit.js.map +1 -1
- package/dist/server/routes/beacons.d.ts +10 -0
- package/dist/server/routes/beacons.d.ts.map +1 -0
- package/dist/server/routes/beacons.js +98 -0
- package/dist/server/routes/beacons.js.map +1 -0
- package/dist/server/routes/broadcasts.d.ts.map +1 -1
- package/dist/server/routes/broadcasts.js +9 -3
- package/dist/server/routes/broadcasts.js.map +1 -1
- package/dist/server/routes/runs.d.ts.map +1 -1
- package/dist/server/routes/runs.js +20 -60
- package/dist/server/routes/runs.js.map +1 -1
- package/dist/server/routes/signals.d.ts.map +1 -1
- package/dist/server/routes/signals.js +17 -13
- package/dist/server/routes/signals.js.map +1 -1
- package/dist/server/routes/v1/env.d.ts +10 -0
- package/dist/server/routes/v1/env.d.ts.map +1 -0
- package/dist/server/routes/v1/env.js +132 -0
- package/dist/server/routes/v1/env.js.map +1 -0
- package/dist/server/routes/v1/events.js +2 -2
- package/dist/server/routes/v1/events.js.map +1 -1
- package/dist/server/routes/v1/runs.d.ts.map +1 -1
- package/dist/server/routes/v1/runs.js +11 -32
- package/dist/server/routes/v1/runs.js.map +1 -1
- package/dist/server/sse.d.ts +2 -1
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +5 -1
- package/dist/server/sse.js.map +1 -1
- package/dist/server/subscriber.d.ts +56 -0
- package/dist/server/subscriber.d.ts.map +1 -1
- package/dist/server/subscriber.js +94 -0
- package/dist/server/subscriber.js.map +1 -1
- package/dist/server/ws.d.ts +8 -2
- package/dist/server/ws.d.ts.map +1 -1
- package/dist/server/ws.js +28 -3
- package/dist/server/ws.js.map +1 -1
- package/package.json +13 -11
- package/src/app/beacons/[name]/beacon-detail.tsx +253 -0
- package/src/app/beacons/[name]/page.tsx +6 -0
- package/src/app/beacons/page.tsx +98 -0
- package/src/app/components/shell.tsx +24 -0
- package/src/app/components/status-badge.tsx +13 -1
- package/src/app/environment/page.tsx +415 -0
- package/src/app/globals.css +48 -0
- package/src/app/hooks/use-api.ts +80 -0
- package/src/config/schema.ts +35 -3
- package/src/index.ts +12 -0
- package/src/server/auth/keys.ts +215 -15
- package/src/server/auth/session.ts +38 -11
- package/src/server/index.ts +192 -23
- package/src/server/log-store.ts +196 -45
- package/src/server/metadata.ts +2 -0
- package/src/server/middleware/rate-limit.ts +20 -1
- package/src/server/routes/beacons.ts +112 -0
- package/src/server/routes/broadcasts.ts +9 -3
- package/src/server/routes/runs.ts +21 -68
- package/src/server/routes/signals.ts +17 -13
- package/src/server/routes/v1/env.ts +144 -0
- package/src/server/routes/v1/events.ts +2 -2
- package/src/server/routes/v1/runs.ts +12 -36
- package/src/server/sse.ts +7 -2
- package/src/server/subscriber.ts +111 -0
- package/src/server/ws.ts +38 -4
- package/.next/standalone/packages/station-kit/.next/static/chunks/285-ff198f0a909c4fdd.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/561-33d912169940283e.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/dyn/[name]/v/[n]/page-5eac0507f49a00ec.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/page-dee500ccc01f0821.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/layout-e14e14f3e5b0b8a9.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/page-aac41ef7a470daab.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/playground/expression/page-dc9d91f3f50f4716.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/runs/[id]/page-9e4c4f751a4bea72.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/schedules/page-738d98dc0b63166e.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/settings/page-fc5654b31f57ac21.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/[name]/page-4b1c09a539a1ebcd.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/page-d2f2403dfede87cc.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/css/0be0e65a5f561f37.css +0 -1
- package/.next/standalone/packages/station-kit/.next/static/demLiQWDy62JuUkBw-ILG/_buildManifest.js +0 -1
- /package/.next/standalone/packages/station-kit/.next/static/{demLiQWDy62JuUkBw-ILG → vTGEzSqxhR_E57YAFn9DC}/_ssgManifest.js +0 -0
package/src/config/schema.ts
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import type { SignalQueueAdapter } from "station-signal";
|
|
2
2
|
import type { BroadcastQueueAdapter } from "station-broadcast";
|
|
3
|
+
import type { BeaconStateAdapter } from "station-beacon";
|
|
3
4
|
import type { ScheduleAdapter } from "station-schedules";
|
|
5
|
+
import type { EnvStorageAdapter } from "station-env";
|
|
4
6
|
import type { ApiKeyStorageAdapter } from "../server/auth/keys.js";
|
|
7
|
+
import type { LogStorageAdapter } from "../server/log-store.js";
|
|
5
8
|
|
|
6
9
|
export interface AuthConfig {
|
|
7
10
|
username: string;
|
|
8
11
|
password: string;
|
|
9
12
|
sessionTtlMs?: number;
|
|
10
13
|
/**
|
|
11
|
-
* Pluggable storage backend for API keys. Defaults to a
|
|
12
|
-
* `<dataDir>/station-keys.
|
|
13
|
-
* Postgres, MySQL,
|
|
14
|
+
* Pluggable storage backend for API keys. Defaults to a JSON file at
|
|
15
|
+
* `<dataDir>/station-keys.json` (no native dependencies required).
|
|
16
|
+
* Provide a custom adapter to host keys in SQLite, Postgres, MySQL,
|
|
17
|
+
* Redis, etc.
|
|
14
18
|
*/
|
|
15
19
|
keyStorage?: ApiKeyStorageAdapter;
|
|
16
20
|
}
|
|
@@ -35,13 +39,37 @@ export interface StationConfig {
|
|
|
35
39
|
host: string;
|
|
36
40
|
adapter?: SignalQueueAdapter;
|
|
37
41
|
broadcastAdapter?: BroadcastQueueAdapter;
|
|
42
|
+
/**
|
|
43
|
+
* Optional beacon supervision-state adapter. When provided (or when
|
|
44
|
+
* `beaconsDir` is set), the dashboard supervises long-running beacons and
|
|
45
|
+
* exposes them under `/api/beacons`.
|
|
46
|
+
*/
|
|
47
|
+
beaconAdapter?: BeaconStateAdapter;
|
|
38
48
|
/**
|
|
39
49
|
* Optional schedule storage adapter. When provided, runtime-editable
|
|
40
50
|
* schedules are persisted here and reconciled by both runners.
|
|
41
51
|
*/
|
|
42
52
|
scheduleAdapter?: ScheduleAdapter;
|
|
53
|
+
/**
|
|
54
|
+
* Pluggable storage backend for runtime environment variables. Defaults to a
|
|
55
|
+
* JSON file at `<dataDir>/station-env.json` (no native dependencies). When
|
|
56
|
+
* set — or by default — signals/beacons can require env vars via `.env()`,
|
|
57
|
+
* and variables defined here (globally or scoped to specific targets) are
|
|
58
|
+
* injected into runs. For multi-process deployments pass a durable adapter
|
|
59
|
+
* from a `station-adapter-*` package's `/env` subpath.
|
|
60
|
+
*/
|
|
61
|
+
envStorage?: EnvStorageAdapter;
|
|
62
|
+
/**
|
|
63
|
+
* Pluggable storage backend for run logs. Defaults to a `FileLogStorage`
|
|
64
|
+
* (append-only JSONL file at `<dataDir>/station-logs.jsonl`, no native
|
|
65
|
+
* dependencies). The default is single-process only — for multi-process
|
|
66
|
+
* deployments or guaranteed durability, implement `LogStorageAdapter`
|
|
67
|
+
* against Postgres, MySQL, Redis, S3, etc., and pass it here.
|
|
68
|
+
*/
|
|
69
|
+
logStorage?: LogStorageAdapter;
|
|
43
70
|
signalsDir?: string;
|
|
44
71
|
broadcastsDir?: string;
|
|
72
|
+
beaconsDir?: string;
|
|
45
73
|
stationDir: string;
|
|
46
74
|
runner: RunnerConfig;
|
|
47
75
|
broadcastRunner: BroadcastRunnerConfig;
|
|
@@ -98,9 +126,13 @@ export function resolveConfig(input: StationUserConfig): StationConfig {
|
|
|
98
126
|
host: input.host ?? envHost ?? DEFAULTS.host,
|
|
99
127
|
adapter: input.adapter,
|
|
100
128
|
broadcastAdapter: input.broadcastAdapter,
|
|
129
|
+
beaconAdapter: input.beaconAdapter,
|
|
101
130
|
scheduleAdapter: input.scheduleAdapter,
|
|
131
|
+
envStorage: input.envStorage,
|
|
132
|
+
logStorage: input.logStorage,
|
|
102
133
|
signalsDir: input.signalsDir,
|
|
103
134
|
broadcastsDir: input.broadcastsDir,
|
|
135
|
+
beaconsDir: input.beaconsDir,
|
|
104
136
|
stationDir: input.stationDir ?? DEFAULTS.stationDir,
|
|
105
137
|
runner: {
|
|
106
138
|
pollIntervalMs: input.runner?.pollIntervalMs ?? DEFAULTS.runner.pollIntervalMs,
|
package/src/index.ts
CHANGED
|
@@ -7,3 +7,15 @@ export function defineConfig(config: StationUserConfig): StationUserConfig {
|
|
|
7
7
|
export type { StationConfig, StationUserConfig, AuthConfig, DeployConfig } from "./config/schema.js";
|
|
8
8
|
export { resolveConfig } from "./config/schema.js";
|
|
9
9
|
export { loadConfig } from "./config/loader.js";
|
|
10
|
+
|
|
11
|
+
// Re-export the runtime env store surface so consumers can construct custom
|
|
12
|
+
// storage or the store itself without a separate `station-env` import.
|
|
13
|
+
export {
|
|
14
|
+
EnvStore,
|
|
15
|
+
MemoryEnvStorage,
|
|
16
|
+
FileEnvStorage,
|
|
17
|
+
type EnvStorageAdapter,
|
|
18
|
+
type EnvVar,
|
|
19
|
+
type EnvVarPublic,
|
|
20
|
+
type EnvTarget,
|
|
21
|
+
} from "station-env";
|
package/src/server/auth/keys.ts
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
-
import
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
fsyncSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
openSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
renameSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
writeSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import { createRequire } from "node:module";
|
|
14
|
+
import { dirname } from "node:path";
|
|
3
15
|
|
|
4
16
|
export interface ApiKey {
|
|
5
17
|
id: string;
|
|
@@ -29,7 +41,123 @@ export interface ApiKeyStorageAdapter {
|
|
|
29
41
|
close?(): Promise<void> | void;
|
|
30
42
|
}
|
|
31
43
|
|
|
32
|
-
// ───
|
|
44
|
+
// ─── JSON file default ──────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export interface FileKeyStorageOptions {
|
|
47
|
+
filePath: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Default ApiKeyStorageAdapter backed by a JSON file. Used by the Station
|
|
52
|
+
* server when no `keyStorage` is configured. Has no native dependencies —
|
|
53
|
+
* works on any Node 18+ install without compiling bindings.
|
|
54
|
+
*
|
|
55
|
+
* Crash-safety: writes go through a fsync'd tmp-file + rename, with a
|
|
56
|
+
* second fsync on the parent directory so the rename itself survives
|
|
57
|
+
* power loss. The keys file is created with `0o600` and the parent dir
|
|
58
|
+
* with `0o700` so a default umask doesn't expose key metadata.
|
|
59
|
+
*
|
|
60
|
+
* Single-process only: do not point two `createStation` instances at
|
|
61
|
+
* the same file or last-rename-wins will silently clobber writes. For
|
|
62
|
+
* multi-process or high-volume deployments, implement
|
|
63
|
+
* `ApiKeyStorageAdapter` against Postgres / MySQL / Redis.
|
|
64
|
+
*/
|
|
65
|
+
export class FileKeyStorage implements ApiKeyStorageAdapter {
|
|
66
|
+
private filePath: string;
|
|
67
|
+
private records = new Map<string, ApiKey>();
|
|
68
|
+
|
|
69
|
+
constructor(options: FileKeyStorageOptions) {
|
|
70
|
+
this.filePath = options.filePath;
|
|
71
|
+
mkdirSync(dirname(this.filePath), { recursive: true, mode: 0o700 });
|
|
72
|
+
this.load();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private load(): void {
|
|
76
|
+
if (!existsSync(this.filePath)) return;
|
|
77
|
+
try {
|
|
78
|
+
const raw = readFileSync(this.filePath, "utf8");
|
|
79
|
+
const data = JSON.parse(raw) as ApiKey[];
|
|
80
|
+
if (Array.isArray(data)) {
|
|
81
|
+
for (const r of data) this.records.set(r.id, r);
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
// Corrupt or unreadable file — start fresh rather than throwing.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private flush(): void {
|
|
89
|
+
const tmp = `${this.filePath}.tmp`;
|
|
90
|
+
const body = JSON.stringify(Array.from(this.records.values()), null, 2);
|
|
91
|
+
// Write tmp file with fsync so its bytes are durable before rename.
|
|
92
|
+
const fd = openSync(tmp, "w", 0o600);
|
|
93
|
+
try {
|
|
94
|
+
writeSync(fd, body);
|
|
95
|
+
fsyncSync(fd);
|
|
96
|
+
} finally {
|
|
97
|
+
closeSync(fd);
|
|
98
|
+
}
|
|
99
|
+
renameSync(tmp, this.filePath);
|
|
100
|
+
// fsync the parent directory so the rename's directory entry survives
|
|
101
|
+
// a crash. Best-effort: opening a directory for fsync isn't supported
|
|
102
|
+
// on every platform (notably Windows), so swallow errors.
|
|
103
|
+
try {
|
|
104
|
+
const dirFd = openSync(dirname(this.filePath), "r");
|
|
105
|
+
try {
|
|
106
|
+
fsyncSync(dirFd);
|
|
107
|
+
} finally {
|
|
108
|
+
closeSync(dirFd);
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
// Platform doesn't support directory fsync; rename + tmp fsync
|
|
112
|
+
// already give us most of the durability we can offer.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
insert(record: ApiKey): void {
|
|
117
|
+
this.records.set(record.id, { ...record });
|
|
118
|
+
this.flush();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
findByHash(keyHash: string): ApiKey | null {
|
|
122
|
+
for (const r of this.records.values()) {
|
|
123
|
+
if (r.keyHash === keyHash) return { ...r };
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
list(): ApiKeyPublic[] {
|
|
129
|
+
return Array.from(this.records.values())
|
|
130
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
|
131
|
+
.map((r) => {
|
|
132
|
+
const { keyHash: _h, ...rest } = r;
|
|
133
|
+
return rest;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
touch(id: string, lastUsedIso: string): void {
|
|
138
|
+
const r = this.records.get(id);
|
|
139
|
+
if (!r) return;
|
|
140
|
+
const prev = r.lastUsed ? Date.parse(r.lastUsed) : 0;
|
|
141
|
+
r.lastUsed = lastUsedIso;
|
|
142
|
+
// lastUsed is advisory. flush() rewrites and fsyncs the whole file
|
|
143
|
+
// synchronously — doing that on every authenticated request stalls the
|
|
144
|
+
// event loop, so only persist when the value moves by at least a minute.
|
|
145
|
+
// Reads stay fresh because list() serves from memory.
|
|
146
|
+
if (Date.parse(lastUsedIso) - prev >= 60_000) {
|
|
147
|
+
this.flush();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
revoke(id: string): boolean {
|
|
152
|
+
const r = this.records.get(id);
|
|
153
|
+
if (!r) return false;
|
|
154
|
+
r.revoked = true;
|
|
155
|
+
this.flush();
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ─── SQLite (optional) ──────────────────────────────────────────────
|
|
33
161
|
|
|
34
162
|
export interface SqliteKeyStorageOptions {
|
|
35
163
|
dbPath: string;
|
|
@@ -37,13 +165,42 @@ export interface SqliteKeyStorageOptions {
|
|
|
37
165
|
tableName?: string;
|
|
38
166
|
}
|
|
39
167
|
|
|
168
|
+
// Loaded lazily from `better-sqlite3` so the package isn't required at
|
|
169
|
+
// install time. Users who don't construct SqliteKeyStorage never pay for it.
|
|
170
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
171
|
+
type BetterSqlite3Module = any;
|
|
172
|
+
let cachedBetterSqlite3: BetterSqlite3Module | null = null;
|
|
173
|
+
|
|
174
|
+
function loadBetterSqlite3(): BetterSqlite3Module {
|
|
175
|
+
if (cachedBetterSqlite3) return cachedBetterSqlite3;
|
|
176
|
+
try {
|
|
177
|
+
const requireFn = createRequire(import.meta.url);
|
|
178
|
+
cachedBetterSqlite3 = requireFn("better-sqlite3");
|
|
179
|
+
return cachedBetterSqlite3;
|
|
180
|
+
} catch (err) {
|
|
181
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
182
|
+
throw new Error(
|
|
183
|
+
`SqliteKeyStorage requires the optional 'better-sqlite3' package, ` +
|
|
184
|
+
`which isn't installed. Install it with:\n` +
|
|
185
|
+
` npm install better-sqlite3\n` +
|
|
186
|
+
`Or use FileKeyStorage (default) / MemoryKeyStorage instead.\n` +
|
|
187
|
+
`Underlying error: ${reason}`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
40
192
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
193
|
+
* Optional ApiKeyStorageAdapter backed by better-sqlite3. Requires the
|
|
194
|
+
* `better-sqlite3` package to be installed separately — Station Kit no
|
|
195
|
+
* longer ships it as a hard dependency.
|
|
196
|
+
*
|
|
197
|
+
* Prefer `FileKeyStorage` (the default) unless you specifically need
|
|
198
|
+
* sqlite features (concurrent reads from multiple processes, large
|
|
199
|
+
* key catalogs, etc.).
|
|
44
200
|
*/
|
|
45
201
|
export class SqliteKeyStorage implements ApiKeyStorageAdapter {
|
|
46
|
-
|
|
202
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
203
|
+
private db: any;
|
|
47
204
|
private table: string;
|
|
48
205
|
|
|
49
206
|
constructor(options: SqliteKeyStorageOptions) {
|
|
@@ -52,6 +209,7 @@ export class SqliteKeyStorage implements ApiKeyStorageAdapter {
|
|
|
52
209
|
throw new Error(`Invalid table name "${tableName}"`);
|
|
53
210
|
}
|
|
54
211
|
this.table = tableName;
|
|
212
|
+
const Database = loadBetterSqlite3();
|
|
55
213
|
this.db = new Database(options.dbPath);
|
|
56
214
|
this.db.pragma("journal_mode = WAL");
|
|
57
215
|
this.db.exec(`
|
|
@@ -113,7 +271,15 @@ export class SqliteKeyStorage implements ApiKeyStorageAdapter {
|
|
|
113
271
|
}));
|
|
114
272
|
}
|
|
115
273
|
|
|
274
|
+
/** Last persisted lastUsed per key id, to skip per-request UPDATEs. */
|
|
275
|
+
private lastTouched = new Map<string, number>();
|
|
276
|
+
|
|
116
277
|
touch(id: string, lastUsedIso: string): void {
|
|
278
|
+
// lastUsed is advisory — throttle writes to once a minute per key.
|
|
279
|
+
const prev = this.lastTouched.get(id) ?? 0;
|
|
280
|
+
const next = Date.parse(lastUsedIso);
|
|
281
|
+
if (next - prev < 60_000) return;
|
|
282
|
+
this.lastTouched.set(id, next);
|
|
117
283
|
this.db.prepare(`UPDATE ${this.table} SET last_used = ? WHERE id = ?`).run(lastUsedIso, id);
|
|
118
284
|
}
|
|
119
285
|
|
|
@@ -181,27 +347,57 @@ export class MemoryKeyStorage implements ApiKeyStorageAdapter {
|
|
|
181
347
|
|
|
182
348
|
// ─── KeyStore — owns crypto, delegates persistence ──────────────────
|
|
183
349
|
|
|
350
|
+
export interface KeyStoreOptions {
|
|
351
|
+
/**
|
|
352
|
+
* Server-side secret ("pepper") mixed into stored key hashes via HMAC.
|
|
353
|
+
* Defense-in-depth: a leaked key file alone can't be brute-forced or
|
|
354
|
+
* precomputed against without also stealing this secret. Optional — when
|
|
355
|
+
* omitted, keys are hashed with plain SHA-256 (legacy behavior). Existing
|
|
356
|
+
* plain-SHA-256 keys keep verifying after a pepper is added (see verify()).
|
|
357
|
+
*/
|
|
358
|
+
pepper?: string;
|
|
359
|
+
}
|
|
360
|
+
|
|
184
361
|
export class KeyStore {
|
|
185
362
|
private storage: ApiKeyStorageAdapter;
|
|
363
|
+
private pepper?: Buffer;
|
|
186
364
|
|
|
187
365
|
/**
|
|
188
366
|
* Pass an `ApiKeyStorageAdapter` for any backend. The string overload is
|
|
189
|
-
* retained for backwards compatibility — it constructs a
|
|
190
|
-
* at the given path.
|
|
367
|
+
* retained for backwards compatibility — it constructs a FileKeyStorage
|
|
368
|
+
* at the given path. (Previously this returned a SqliteKeyStorage; SQLite
|
|
369
|
+
* is now opt-in to avoid native build dependencies.)
|
|
191
370
|
*/
|
|
192
|
-
constructor(
|
|
193
|
-
if (typeof
|
|
194
|
-
|
|
371
|
+
constructor(storageOrPath: ApiKeyStorageAdapter | string, options?: KeyStoreOptions) {
|
|
372
|
+
if (typeof storageOrPath === "string") {
|
|
373
|
+
const filePath = storageOrPath.endsWith(".db")
|
|
374
|
+
? storageOrPath.replace(/\.db$/, ".json")
|
|
375
|
+
: storageOrPath;
|
|
376
|
+
this.storage = new FileKeyStorage({ filePath });
|
|
195
377
|
} else {
|
|
196
|
-
this.storage =
|
|
378
|
+
this.storage = storageOrPath;
|
|
379
|
+
}
|
|
380
|
+
if (options?.pepper) this.pepper = Buffer.from(options.pepper, "utf8");
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Peppered hash for new keys — HMAC-SHA256 when a pepper is set, else SHA-256. */
|
|
384
|
+
private hashKey(rawKey: string): string {
|
|
385
|
+
if (this.pepper) {
|
|
386
|
+
return crypto.createHmac("sha256", this.pepper).update(rawKey).digest("hex");
|
|
197
387
|
}
|
|
388
|
+
return crypto.createHash("sha256").update(rawKey).digest("hex");
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Unpeppered SHA-256 — used to verify keys stored before a pepper was configured. */
|
|
392
|
+
private legacyHashKey(rawKey: string): string {
|
|
393
|
+
return crypto.createHash("sha256").update(rawKey).digest("hex");
|
|
198
394
|
}
|
|
199
395
|
|
|
200
396
|
/** Generate a new API key. Returns the full key (only shown once) and the stored record. */
|
|
201
397
|
async create(name: string, scopes: string[] = ["trigger", "read"]): Promise<{ key: string; record: ApiKey }> {
|
|
202
398
|
const id = crypto.randomUUID();
|
|
203
399
|
const rawKey = `sk_live_${crypto.randomBytes(16).toString("hex")}`;
|
|
204
|
-
const keyHash =
|
|
400
|
+
const keyHash = this.hashKey(rawKey);
|
|
205
401
|
const keyPrefix = rawKey.slice(0, 12);
|
|
206
402
|
const createdAt = new Date().toISOString();
|
|
207
403
|
|
|
@@ -215,8 +411,12 @@ export class KeyStore {
|
|
|
215
411
|
|
|
216
412
|
/** Verify an API key. Returns the key record if valid, null otherwise. */
|
|
217
413
|
async verify(rawKey: string): Promise<ApiKey | null> {
|
|
218
|
-
|
|
219
|
-
|
|
414
|
+
let record = await this.storage.findByHash(this.hashKey(rawKey));
|
|
415
|
+
// Fall back to the unpeppered hash for keys created before the pepper was
|
|
416
|
+
// configured, so enabling a pepper doesn't invalidate existing keys.
|
|
417
|
+
if (!record && this.pepper) {
|
|
418
|
+
record = await this.storage.findByHash(this.legacyHashKey(rawKey));
|
|
419
|
+
}
|
|
220
420
|
if (!record) return null;
|
|
221
421
|
if (record.revoked) return null;
|
|
222
422
|
if (record.expiresAt && new Date(record.expiresAt) < new Date()) return null;
|
|
@@ -8,17 +8,45 @@ export interface SessionConfig {
|
|
|
8
8
|
sessionTtlMs?: number;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Derive the HMAC signing key from the password via HKDF instead of using
|
|
13
|
+
* the cleartext password as the key directly — issued cookies should not be
|
|
14
|
+
* HMACs keyed with the literal admin credential. Cached because HKDF per
|
|
15
|
+
* request would be wasted CPU.
|
|
16
|
+
*/
|
|
17
|
+
const secretCache = new Map<string, Buffer>();
|
|
18
|
+
function sessionSecret(config: SessionConfig): Buffer {
|
|
19
|
+
let secret = secretCache.get(config.password);
|
|
20
|
+
if (!secret) {
|
|
21
|
+
secret = Buffer.from(
|
|
22
|
+
crypto.hkdfSync("sha256", config.password, "station-kit", "session-token-v1", 32),
|
|
23
|
+
);
|
|
24
|
+
secretCache.set(config.password, secret);
|
|
25
|
+
}
|
|
26
|
+
return secret;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sign(payload: string, config: SessionConfig): string {
|
|
30
|
+
const hmac = crypto.createHmac("sha256", sessionSecret(config));
|
|
14
31
|
hmac.update(payload);
|
|
15
32
|
return hmac.digest("hex");
|
|
16
33
|
}
|
|
17
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Timing-safe string comparison that doesn't leak length: both inputs are
|
|
37
|
+
* hashed to a fixed size before `timingSafeEqual` (which requires equal
|
|
38
|
+
* lengths and would otherwise short-circuit on a length check).
|
|
39
|
+
*/
|
|
40
|
+
function safeEqual(a: string, b: string): boolean {
|
|
41
|
+
const ha = crypto.createHash("sha256").update(a).digest();
|
|
42
|
+
const hb = crypto.createHash("sha256").update(b).digest();
|
|
43
|
+
return crypto.timingSafeEqual(ha, hb);
|
|
44
|
+
}
|
|
45
|
+
|
|
18
46
|
export function createSessionToken(config: SessionConfig): string {
|
|
19
47
|
const exp = Date.now() + (config.sessionTtlMs ?? SESSION_TTL_MS);
|
|
20
48
|
const payload = `${config.username}:${exp}`;
|
|
21
|
-
const signature = sign(payload, config
|
|
49
|
+
const signature = sign(payload, config);
|
|
22
50
|
return Buffer.from(`${payload}:${signature}`).toString("base64url");
|
|
23
51
|
}
|
|
24
52
|
|
|
@@ -31,18 +59,17 @@ export function verifySessionToken(token: string, config: SessionConfig): boolea
|
|
|
31
59
|
const exp = parseInt(expStr, 10);
|
|
32
60
|
if (isNaN(exp) || Date.now() > exp) return false;
|
|
33
61
|
if (username !== config.username) return false;
|
|
34
|
-
const expected = sign(`${username}:${expStr}`, config
|
|
35
|
-
return
|
|
62
|
+
const expected = sign(`${username}:${expStr}`, config);
|
|
63
|
+
return safeEqual(sig, expected);
|
|
36
64
|
} catch {
|
|
37
65
|
return false;
|
|
38
66
|
}
|
|
39
67
|
}
|
|
40
68
|
|
|
41
69
|
export function verifyCredentials(username: string, password: string, config: SessionConfig): boolean {
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const passMatch = password
|
|
46
|
-
crypto.timingSafeEqual(Buffer.from(password), Buffer.from(config.password));
|
|
70
|
+
// Evaluate both comparisons unconditionally so a valid username isn't
|
|
71
|
+
// distinguishable from an invalid one by response time.
|
|
72
|
+
const userMatch = safeEqual(username, config.username);
|
|
73
|
+
const passMatch = safeEqual(password, config.password);
|
|
47
74
|
return userMatch && passMatch;
|
|
48
75
|
}
|