station-kit 1.0.7 → 1.0.9

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 (177) hide show
  1. package/.next/standalone/package.json +3 -1
  2. package/.next/standalone/packages/station-kit/.next/BUILD_ID +1 -1
  3. package/.next/standalone/packages/station-kit/.next/app-build-manifest.json +76 -17
  4. package/.next/standalone/packages/station-kit/.next/app-path-routes-manifest.json +10 -3
  5. package/.next/standalone/packages/station-kit/.next/build-manifest.json +3 -3
  6. package/.next/standalone/packages/station-kit/.next/prerender-manifest.json +112 -16
  7. package/.next/standalone/packages/station-kit/.next/routes-manifest.json +49 -0
  8. package/.next/standalone/packages/station-kit/.next/server/app/_not-found/page.js +2 -2
  9. package/.next/standalone/packages/station-kit/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  10. package/.next/standalone/packages/station-kit/.next/server/app/_not-found.html +1 -1
  11. package/.next/standalone/packages/station-kit/.next/server/app/_not-found.rsc +7 -7
  12. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/[id]/page.js +2 -2
  13. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/[id]/page_client-reference-manifest.js +1 -1
  14. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/page.js +2 -0
  15. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/page.js.nft.json +1 -0
  16. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/page_client-reference-manifest.js +1 -0
  17. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/v/[n]/page.js +2 -0
  18. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/v/[n]/page.js.nft.json +1 -0
  19. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/v/[n]/page_client-reference-manifest.js +1 -0
  20. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new/page.js +2 -0
  21. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new/page.js.nft.json +1 -0
  22. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new/page_client-reference-manifest.js +1 -0
  23. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.html +1 -0
  24. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.meta +7 -0
  25. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.rsc +25 -0
  26. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/page.js +2 -2
  27. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/page_client-reference-manifest.js +1 -1
  28. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts.html +1 -1
  29. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts.rsc +8 -8
  30. package/.next/standalone/packages/station-kit/.next/server/app/index.html +1 -1
  31. package/.next/standalone/packages/station-kit/.next/server/app/index.rsc +8 -8
  32. package/.next/standalone/packages/station-kit/.next/server/app/page.js +2 -2
  33. package/.next/standalone/packages/station-kit/.next/server/app/page_client-reference-manifest.js +1 -1
  34. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression/page.js +2 -0
  35. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression/page.js.nft.json +1 -0
  36. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression/page_client-reference-manifest.js +1 -0
  37. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.html +1 -0
  38. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.meta +7 -0
  39. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.rsc +25 -0
  40. package/.next/standalone/packages/station-kit/.next/server/app/runs/[id]/page.js +2 -2
  41. package/.next/standalone/packages/station-kit/.next/server/app/runs/[id]/page_client-reference-manifest.js +1 -1
  42. package/.next/standalone/packages/station-kit/.next/server/app/schedules/[id]/page.js +2 -0
  43. package/.next/standalone/packages/station-kit/.next/server/app/schedules/[id]/page.js.nft.json +1 -0
  44. package/.next/standalone/packages/station-kit/.next/server/app/schedules/[id]/page_client-reference-manifest.js +1 -0
  45. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new/page.js +2 -0
  46. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new/page.js.nft.json +1 -0
  47. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new/page_client-reference-manifest.js +1 -0
  48. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.html +1 -0
  49. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.meta +7 -0
  50. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.rsc +25 -0
  51. package/.next/standalone/packages/station-kit/.next/server/app/schedules/page.js +2 -0
  52. package/.next/standalone/packages/station-kit/.next/server/app/schedules/page.js.nft.json +1 -0
  53. package/.next/standalone/packages/station-kit/.next/server/app/schedules/page_client-reference-manifest.js +1 -0
  54. package/.next/standalone/packages/station-kit/.next/server/app/schedules.html +1 -0
  55. package/.next/standalone/packages/station-kit/.next/server/app/schedules.meta +7 -0
  56. package/.next/standalone/packages/station-kit/.next/server/app/schedules.rsc +25 -0
  57. package/.next/standalone/packages/station-kit/.next/server/app/settings/page.js +2 -2
  58. package/.next/standalone/packages/station-kit/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  59. package/.next/standalone/packages/station-kit/.next/server/app/settings.html +1 -1
  60. package/.next/standalone/packages/station-kit/.next/server/app/settings.rsc +8 -8
  61. package/.next/standalone/packages/station-kit/.next/server/app/signals/[name]/page.js +2 -2
  62. package/.next/standalone/packages/station-kit/.next/server/app/signals/[name]/page_client-reference-manifest.js +1 -1
  63. package/.next/standalone/packages/station-kit/.next/server/app/signals/page.js +2 -2
  64. package/.next/standalone/packages/station-kit/.next/server/app/signals/page_client-reference-manifest.js +1 -1
  65. package/.next/standalone/packages/station-kit/.next/server/app/signals.html +1 -1
  66. package/.next/standalone/packages/station-kit/.next/server/app/signals.rsc +8 -8
  67. package/.next/standalone/packages/station-kit/.next/server/app-paths-manifest.json +10 -3
  68. package/.next/standalone/packages/station-kit/.next/server/chunks/102.js +1 -1
  69. package/.next/standalone/packages/station-kit/.next/server/chunks/535.js +2 -0
  70. package/.next/standalone/packages/station-kit/.next/server/chunks/606.js +14 -14
  71. package/.next/standalone/packages/station-kit/.next/server/chunks/783.js +3 -3
  72. package/.next/standalone/packages/station-kit/.next/server/middleware-build-manifest.js +1 -1
  73. package/.next/standalone/packages/station-kit/.next/server/pages/404.html +1 -1
  74. package/.next/standalone/packages/station-kit/.next/server/pages/500.html +1 -1
  75. package/.next/standalone/packages/station-kit/.next/server/pages/_app.js +1 -1
  76. package/.next/standalone/packages/station-kit/.next/server/pages/_document.js +1 -1
  77. package/.next/standalone/packages/station-kit/.next/server/pages/_error.js +9 -9
  78. package/.next/standalone/packages/station-kit/.next/server/pages-manifest.json +1 -1
  79. package/.next/standalone/packages/station-kit/.next/server/server-reference-manifest.json +1 -1
  80. package/.next/standalone/packages/station-kit/.next/static/chunks/145-9e370afd2e5aba39.js +1 -0
  81. package/.next/standalone/packages/station-kit/.next/static/chunks/285-ff198f0a909c4fdd.js +1 -0
  82. package/.next/standalone/packages/station-kit/.next/static/chunks/561-33d912169940283e.js +1 -0
  83. package/.next/standalone/packages/station-kit/.next/static/chunks/935-dff12960528de017.js +1 -0
  84. package/.next/standalone/packages/station-kit/.next/static/chunks/app/_not-found/{page-ce21b4ba9038a5a7.js → page-67ef312aee40cfeb.js} +1 -1
  85. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/[id]/page-fe2f5467a0c68fef.js +1 -0
  86. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/dyn/[name]/page-0d2505242014f51e.js +1 -0
  87. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/dyn/[name]/v/[n]/page-5eac0507f49a00ec.js +1 -0
  88. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/new/page-3d02707043d24dc7.js +1 -0
  89. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/page-dee500ccc01f0821.js +1 -0
  90. package/.next/standalone/packages/station-kit/.next/static/chunks/app/layout-e14e14f3e5b0b8a9.js +1 -0
  91. package/.next/standalone/packages/station-kit/.next/static/chunks/app/page-aac41ef7a470daab.js +1 -0
  92. package/.next/standalone/packages/station-kit/.next/static/chunks/app/playground/expression/page-dc9d91f3f50f4716.js +1 -0
  93. package/.next/standalone/packages/station-kit/.next/static/chunks/app/runs/[id]/page-9e4c4f751a4bea72.js +1 -0
  94. package/.next/standalone/packages/station-kit/.next/static/chunks/app/schedules/[id]/page-435f67be180b8e4f.js +1 -0
  95. package/.next/standalone/packages/station-kit/.next/static/chunks/app/schedules/new/page-f697c289c813496a.js +1 -0
  96. package/.next/standalone/packages/station-kit/.next/static/chunks/app/schedules/page-738d98dc0b63166e.js +1 -0
  97. package/.next/standalone/packages/station-kit/.next/static/chunks/app/settings/page-fc5654b31f57ac21.js +1 -0
  98. package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/[name]/page-4b1c09a539a1ebcd.js +1 -0
  99. package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/page-d2f2403dfede87cc.js +1 -0
  100. package/.next/standalone/packages/station-kit/.next/static/chunks/pages/_app-a3774a320f58a018.js +1 -0
  101. package/.next/standalone/packages/station-kit/.next/static/demLiQWDy62JuUkBw-ILG/_buildManifest.js +1 -0
  102. package/.next/standalone/packages/station-kit/package.json +10 -2
  103. package/dist/config/schema.d.ts +13 -0
  104. package/dist/config/schema.d.ts.map +1 -1
  105. package/dist/config/schema.js +1 -0
  106. package/dist/config/schema.js.map +1 -1
  107. package/dist/index.d.ts +2 -0
  108. package/dist/index.d.ts.map +1 -1
  109. package/dist/index.js +2 -0
  110. package/dist/index.js.map +1 -1
  111. package/dist/server/auth/keys.d.ts +56 -8
  112. package/dist/server/auth/keys.d.ts.map +1 -1
  113. package/dist/server/auth/keys.js +155 -53
  114. package/dist/server/auth/keys.js.map +1 -1
  115. package/dist/server/index.d.ts +7 -0
  116. package/dist/server/index.d.ts.map +1 -1
  117. package/dist/server/index.js +55 -5
  118. package/dist/server/index.js.map +1 -1
  119. package/dist/server/middleware/auth.js +1 -1
  120. package/dist/server/middleware/auth.js.map +1 -1
  121. package/dist/server/routes/v1/definitions.d.ts +21 -0
  122. package/dist/server/routes/v1/definitions.d.ts.map +1 -0
  123. package/dist/server/routes/v1/definitions.js +139 -0
  124. package/dist/server/routes/v1/definitions.js.map +1 -0
  125. package/dist/server/routes/v1/expressions.d.ts +3 -0
  126. package/dist/server/routes/v1/expressions.d.ts.map +1 -0
  127. package/dist/server/routes/v1/expressions.js +56 -0
  128. package/dist/server/routes/v1/expressions.js.map +1 -0
  129. package/dist/server/routes/v1/keys.js +3 -3
  130. package/dist/server/routes/v1/keys.js.map +1 -1
  131. package/dist/server/routes/v1/schedules.d.ts +10 -0
  132. package/dist/server/routes/v1/schedules.d.ts.map +1 -0
  133. package/dist/server/routes/v1/schedules.js +169 -0
  134. package/dist/server/routes/v1/schedules.js.map +1 -0
  135. package/dist/server/routes/v1/trigger.d.ts.map +1 -1
  136. package/dist/server/routes/v1/trigger.js +21 -0
  137. package/dist/server/routes/v1/trigger.js.map +1 -1
  138. package/package.json +15 -7
  139. package/src/app/broadcasts/components/broadcast-builder.tsx +535 -0
  140. package/src/app/broadcasts/components/dag-editor.tsx +510 -0
  141. package/src/app/broadcasts/dyn/[name]/dynamic-detail.tsx +243 -0
  142. package/src/app/broadcasts/dyn/[name]/page.tsx +10 -0
  143. package/src/app/broadcasts/dyn/[name]/v/[n]/page.tsx +10 -0
  144. package/src/app/broadcasts/dyn/[name]/v/[n]/version-view.tsx +285 -0
  145. package/src/app/broadcasts/new/page.tsx +102 -0
  146. package/src/app/broadcasts/page.tsx +176 -91
  147. package/src/app/components/api-panel.tsx +151 -0
  148. package/src/app/components/shell.tsx +23 -0
  149. package/src/app/hooks/use-api.ts +117 -0
  150. package/src/app/playground/expression/page.tsx +245 -0
  151. package/src/app/schedules/[id]/page.tsx +10 -0
  152. package/src/app/schedules/[id]/schedule-editor.tsx +195 -0
  153. package/src/app/schedules/components/schedule-form.tsx +140 -0
  154. package/src/app/schedules/new/page.tsx +166 -0
  155. package/src/app/schedules/page.tsx +126 -0
  156. package/src/config/schema.ts +14 -0
  157. package/src/index.ts +2 -0
  158. package/src/server/auth/keys.ts +191 -56
  159. package/src/server/index.ts +78 -5
  160. package/src/server/middleware/auth.ts +1 -1
  161. package/src/server/routes/v1/definitions.ts +164 -0
  162. package/src/server/routes/v1/expressions.ts +76 -0
  163. package/src/server/routes/v1/keys.ts +3 -3
  164. package/src/server/routes/v1/schedules.ts +176 -0
  165. package/src/server/routes/v1/trigger.ts +27 -0
  166. package/.next/standalone/packages/station-kit/.next/static/chunks/580-f007f4d4c050db4e.js +0 -1
  167. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/[id]/page-a0a20cccda13a0e9.js +0 -1
  168. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/page-937eb876f9087bc9.js +0 -1
  169. package/.next/standalone/packages/station-kit/.next/static/chunks/app/layout-68cd71116ba65cd8.js +0 -1
  170. package/.next/standalone/packages/station-kit/.next/static/chunks/app/page-70b0c0958c03459a.js +0 -1
  171. package/.next/standalone/packages/station-kit/.next/static/chunks/app/runs/[id]/page-01f8040619fe56c5.js +0 -1
  172. package/.next/standalone/packages/station-kit/.next/static/chunks/app/settings/page-beac11049f90da31.js +0 -1
  173. package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/[name]/page-931e6a38a4a53d25.js +0 -1
  174. package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/page-6a123a355d93fec5.js +0 -1
  175. package/.next/standalone/packages/station-kit/.next/static/chunks/pages/_app-0a7b2e66ecbe3f0a.js +0 -1
  176. package/.next/standalone/packages/station-kit/.next/static/pHHaxeGaet0VW1dhcIcuY/_buildManifest.js +0 -1
  177. /package/.next/standalone/packages/station-kit/.next/static/{pHHaxeGaet0VW1dhcIcuY → demLiQWDy62JuUkBw-ILG}/_ssgManifest.js +0 -0
@@ -0,0 +1,166 @@
1
+ "use client";
2
+
3
+ import { useMemo, useState } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { useApi } from "../../hooks/use-api";
6
+ import { useBreadcrumb } from "../../hooks/use-breadcrumb";
7
+ import { ScheduleForm, type ScheduleFormValue } from "../components/schedule-form";
8
+ import { ApiPanel } from "../../components/api-panel";
9
+
10
+ const INITIAL: ScheduleFormValue = {
11
+ kind: "signal",
12
+ target: "",
13
+ interval: "5m",
14
+ input: "{}",
15
+ enabled: true,
16
+ };
17
+
18
+ const INTERVAL_REGEX = /^(\d+)(ms|s|m|h|d|w)$/i;
19
+ const UNIT_MS: Record<string, number> = {
20
+ ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000,
21
+ };
22
+
23
+ function parseIntervalLocal(s: string): number | null {
24
+ const m = INTERVAL_REGEX.exec(s.trim());
25
+ if (!m) return null;
26
+ return Number(m[1]) * UNIT_MS[m[2].toLowerCase()];
27
+ }
28
+
29
+ export default function NewSchedulePage() {
30
+ const api = useApi();
31
+ const router = useRouter();
32
+ const [value, setValue] = useState<ScheduleFormValue>(INITIAL);
33
+ const [busy, setBusy] = useState(false);
34
+ const [error, setError] = useState<string | null>(null);
35
+
36
+ useBreadcrumb(
37
+ [
38
+ { label: "Schedules", href: "/schedules" },
39
+ { label: "New" },
40
+ ],
41
+ "schedules",
42
+ );
43
+
44
+ // Compute the next 5 fire times locally so users can see the cadence
45
+ // before saving.
46
+ const previewFires = useMemo(() => {
47
+ const ms = parseIntervalLocal(value.interval);
48
+ if (ms === null || ms <= 0) return [];
49
+ const now = Date.now();
50
+ const out: string[] = [];
51
+ for (let i = 1; i <= 5; i++) {
52
+ out.push(new Date(now + ms * i).toISOString());
53
+ }
54
+ return out;
55
+ }, [value.interval]);
56
+
57
+ async function handleSave() {
58
+ setError(null);
59
+ if (!value.target) {
60
+ setError("Target is required.");
61
+ return;
62
+ }
63
+ let inputParsed: unknown = undefined;
64
+ if (value.input.trim()) {
65
+ try {
66
+ inputParsed = JSON.parse(value.input);
67
+ } catch (err) {
68
+ setError(`Input JSON parse error: ${err instanceof Error ? err.message : String(err)}`);
69
+ return;
70
+ }
71
+ }
72
+ setBusy(true);
73
+ try {
74
+ const res = await api.createSchedule({
75
+ kind: value.kind,
76
+ target: value.target,
77
+ interval: value.interval,
78
+ input: inputParsed,
79
+ enabled: value.enabled,
80
+ });
81
+ router.push(`/schedules/${res.data.id}`);
82
+ } catch (err) {
83
+ setError(err instanceof Error ? err.message : String(err));
84
+ } finally {
85
+ setBusy(false);
86
+ }
87
+ }
88
+
89
+ return (
90
+ <div>
91
+ <h1 className="page-title">New schedule</h1>
92
+
93
+ <div style={{ display: "grid", gridTemplateColumns: "minmax(0, 2fr) minmax(0, 1fr)", gap: "2rem" }}>
94
+ <div>
95
+ <ScheduleForm value={value} onChange={setValue} />
96
+
97
+ {error && (
98
+ <div style={{
99
+ marginTop: "1rem",
100
+ padding: "0.625rem 0.75rem",
101
+ background: "var(--error-bg, #fee)",
102
+ color: "var(--error, #b00)",
103
+ border: "1px solid var(--error, #b00)",
104
+ borderRadius: "4px",
105
+ fontSize: "0.8125rem",
106
+ maxWidth: "640px",
107
+ }}>{error}</div>
108
+ )}
109
+
110
+ <div style={{ marginTop: "1.5rem", display: "flex", gap: "0.5rem" }}>
111
+ <button className="btn btn--primary" onClick={handleSave} disabled={busy}>
112
+ {busy ? "Creating..." : "Create schedule"}
113
+ </button>
114
+ <button className="btn" onClick={() => router.push("/schedules")}>Cancel</button>
115
+ </div>
116
+ </div>
117
+
118
+ <aside>
119
+ <h3 className="mono" style={{ fontSize: "0.75rem", color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: "0.5rem" }}>
120
+ Next fire times (preview)
121
+ </h3>
122
+ {previewFires.length === 0 ? (
123
+ <div className="mono" style={{ fontSize: "0.75rem", color: "var(--error, #b00)" }}>
124
+ Invalid interval. Use formats like &quot;30s&quot;, &quot;5m&quot;, &quot;1h&quot;, &quot;1d&quot;, &quot;1w&quot;.
125
+ </div>
126
+ ) : (
127
+ <ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
128
+ {previewFires.map((iso, i) => (
129
+ <li key={i} className="mono" style={{
130
+ fontSize: "0.8125rem",
131
+ padding: "0.375rem 0",
132
+ borderBottom: "1px dashed var(--border)",
133
+ color: i === 0 ? "var(--text)" : "var(--muted)",
134
+ }}>
135
+ {new Date(iso).toLocaleString()}
136
+ </li>
137
+ ))}
138
+ </ul>
139
+ )}
140
+ </aside>
141
+ </div>
142
+
143
+ <ApiPanel
144
+ title="Create this schedule"
145
+ snippets={[
146
+ {
147
+ label: "POST /api/v1/schedules",
148
+ method: "POST",
149
+ path: "/api/v1/schedules",
150
+ body: {
151
+ kind: value.kind,
152
+ target: value.target,
153
+ interval: value.interval,
154
+ enabled: value.enabled,
155
+ input: tryParse(value.input),
156
+ },
157
+ },
158
+ ]}
159
+ />
160
+ </div>
161
+ );
162
+ }
163
+
164
+ function tryParse(s: string): unknown {
165
+ try { return JSON.parse(s); } catch { return s; }
166
+ }
@@ -0,0 +1,126 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import Link from "next/link";
5
+ import { useRouter } from "next/navigation";
6
+ import { useApi, type Schedule } from "../hooks/use-api";
7
+ import { useBreadcrumb } from "../hooks/use-breadcrumb";
8
+ import { ApiPanel } from "../components/api-panel";
9
+
10
+ const KIND_LABEL: Record<Schedule["kind"], string> = {
11
+ signal: "Signal",
12
+ "broadcast-static": "Broadcast (file)",
13
+ "broadcast-dynamic": "Broadcast (dynamic)",
14
+ };
15
+
16
+ export default function SchedulesPage() {
17
+ const api = useApi();
18
+ const router = useRouter();
19
+ const [schedules, setSchedules] = useState<Schedule[]>([]);
20
+ const [loading, setLoading] = useState(true);
21
+ const [error, setError] = useState<string | null>(null);
22
+
23
+ useBreadcrumb([{ label: "Schedules" }], "schedules");
24
+
25
+ useEffect(() => {
26
+ api.getSchedules()
27
+ .then((res) => setSchedules(res.data))
28
+ .catch((err) => setError(err instanceof Error ? err.message : String(err)))
29
+ .finally(() => setLoading(false));
30
+ }, []);
31
+
32
+ if (loading) {
33
+ return (
34
+ <div>
35
+ <h1 className="page-title">Schedules</h1>
36
+ <div className="loading-bar"><div className="loading-bar-fill" /></div>
37
+ </div>
38
+ );
39
+ }
40
+
41
+ return (
42
+ <div>
43
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "1rem" }}>
44
+ <h1 className="page-title" style={{ margin: 0 }}>Schedules</h1>
45
+ <Link href="/schedules/new" className="btn btn--primary">+ New schedule</Link>
46
+ </div>
47
+
48
+ {error && (
49
+ <div style={{
50
+ marginBottom: "1rem",
51
+ padding: "0.625rem 0.75rem",
52
+ background: "var(--error-bg, #fee)",
53
+ color: "var(--error, #b00)",
54
+ borderRadius: "4px",
55
+ fontSize: "0.8125rem",
56
+ }}>{error}</div>
57
+ )}
58
+
59
+ {schedules.length === 0 ? (
60
+ <div className="empty-state">
61
+ <p className="empty-state-text">
62
+ No schedules yet. <Link href="/schedules/new">Create one</Link> to fire signals or broadcasts on intervals.
63
+ </p>
64
+ </div>
65
+ ) : (
66
+ <table className="station-table">
67
+ <thead>
68
+ <tr>
69
+ <th>Target</th>
70
+ <th>Kind</th>
71
+ <th>Interval</th>
72
+ <th>Next run</th>
73
+ <th>Last run</th>
74
+ <th>Status</th>
75
+ <th></th>
76
+ </tr>
77
+ </thead>
78
+ <tbody>
79
+ {schedules.map((s) => (
80
+ <tr
81
+ key={s.id}
82
+ className="clickable-row"
83
+ onClick={() => router.push(`/schedules/${s.id}`)}
84
+ >
85
+ <td className="mono">{s.target}</td>
86
+ <td className="mono" style={{ fontSize: "0.75rem", color: "var(--muted)" }}>{KIND_LABEL[s.kind]}</td>
87
+ <td className="mono">{s.interval}</td>
88
+ <td className="mono" style={{ fontSize: "0.75rem", color: "var(--muted)" }}>
89
+ {new Date(s.nextRunAt).toLocaleString()}
90
+ </td>
91
+ <td className="mono" style={{ fontSize: "0.75rem", color: "var(--muted)" }}>
92
+ {s.lastRunAt ? new Date(s.lastRunAt).toLocaleString() : "—"}
93
+ </td>
94
+ <td className="mono" style={{ fontSize: "0.75rem" }}>
95
+ <span style={{
96
+ padding: "0.125rem 0.375rem",
97
+ borderRadius: "3px",
98
+ background: s.enabled ? "var(--success-bg, #efe)" : "var(--surface)",
99
+ color: s.enabled ? "var(--success, #060)" : "var(--muted)",
100
+ }}>
101
+ {s.enabled ? "enabled" : "disabled"}
102
+ </span>
103
+ </td>
104
+ <td></td>
105
+ </tr>
106
+ ))}
107
+ </tbody>
108
+ </table>
109
+ )}
110
+
111
+ <ApiPanel
112
+ title="List & manage schedules"
113
+ snippets={[
114
+ { label: "List all", method: "GET", path: "/api/v1/schedules" },
115
+ { label: "List by kind", method: "GET", path: "/api/v1/schedules", query: { kind: "signal" } },
116
+ {
117
+ label: "Create",
118
+ method: "POST",
119
+ path: "/api/v1/schedules",
120
+ body: { kind: "signal", target: "<signalName>", interval: "5m", enabled: true, input: {} },
121
+ },
122
+ ]}
123
+ />
124
+ </div>
125
+ );
126
+ }
@@ -1,10 +1,18 @@
1
1
  import type { SignalQueueAdapter } from "station-signal";
2
2
  import type { BroadcastQueueAdapter } from "station-broadcast";
3
+ import type { ScheduleAdapter } from "station-schedules";
4
+ import type { ApiKeyStorageAdapter } from "../server/auth/keys.js";
3
5
 
4
6
  export interface AuthConfig {
5
7
  username: string;
6
8
  password: string;
7
9
  sessionTtlMs?: number;
10
+ /**
11
+ * Pluggable storage backend for API keys. Defaults to a SQLite store at
12
+ * `<dataDir>/station-keys.db`. Provide a custom adapter to host keys in
13
+ * Postgres, MySQL, Redis, etc.
14
+ */
15
+ keyStorage?: ApiKeyStorageAdapter;
8
16
  }
9
17
 
10
18
  export interface RunnerConfig {
@@ -27,6 +35,11 @@ export interface StationConfig {
27
35
  host: string;
28
36
  adapter?: SignalQueueAdapter;
29
37
  broadcastAdapter?: BroadcastQueueAdapter;
38
+ /**
39
+ * Optional schedule storage adapter. When provided, runtime-editable
40
+ * schedules are persisted here and reconciled by both runners.
41
+ */
42
+ scheduleAdapter?: ScheduleAdapter;
30
43
  signalsDir?: string;
31
44
  broadcastsDir?: string;
32
45
  stationDir: string;
@@ -85,6 +98,7 @@ export function resolveConfig(input: StationUserConfig): StationConfig {
85
98
  host: input.host ?? envHost ?? DEFAULTS.host,
86
99
  adapter: input.adapter,
87
100
  broadcastAdapter: input.broadcastAdapter,
101
+ scheduleAdapter: input.scheduleAdapter,
88
102
  signalsDir: input.signalsDir,
89
103
  broadcastsDir: input.broadcastsDir,
90
104
  stationDir: input.stationDir ?? DEFAULTS.stationDir,
package/src/index.ts CHANGED
@@ -5,3 +5,5 @@ export function defineConfig(config: StationUserConfig): StationUserConfig {
5
5
  }
6
6
 
7
7
  export type { StationConfig, StationUserConfig, AuthConfig, DeployConfig } from "./config/schema.js";
8
+ export { resolveConfig } from "./config/schema.js";
9
+ export { loadConfig } from "./config/loader.js";
@@ -13,14 +13,49 @@ export interface ApiKey {
13
13
  revoked: boolean;
14
14
  }
15
15
 
16
- export class KeyStore {
16
+ export type ApiKeyPublic = Omit<ApiKey, "keyHash">;
17
+
18
+ /**
19
+ * Pluggable storage backend for API keys. Implementations only persist and
20
+ * query records — hashing, key generation, and verification logic live in
21
+ * the KeyStore. May be sync or async; the KeyStore awaits all results.
22
+ */
23
+ export interface ApiKeyStorageAdapter {
24
+ insert(record: ApiKey): Promise<void> | void;
25
+ findByHash(keyHash: string): Promise<ApiKey | null> | ApiKey | null;
26
+ list(): Promise<ApiKeyPublic[]> | ApiKeyPublic[];
27
+ touch(id: string, lastUsedIso: string): Promise<void> | void;
28
+ revoke(id: string): Promise<boolean> | boolean;
29
+ close?(): Promise<void> | void;
30
+ }
31
+
32
+ // ─── SQLite default ─────────────────────────────────────────────────
33
+
34
+ export interface SqliteKeyStorageOptions {
35
+ dbPath: string;
36
+ /** Override the table name (default: "api_keys"). */
37
+ tableName?: string;
38
+ }
39
+
40
+ /**
41
+ * Default ApiKeyStorageAdapter backed by better-sqlite3. Used by the Station
42
+ * server when no `keyStorage` is configured. For Postgres / MySQL / Redis,
43
+ * implement `ApiKeyStorageAdapter` and pass it to `KeyStore` directly.
44
+ */
45
+ export class SqliteKeyStorage implements ApiKeyStorageAdapter {
17
46
  private db: Database.Database;
47
+ private table: string;
18
48
 
19
- constructor(dbPath: string) {
20
- this.db = new Database(dbPath);
49
+ constructor(options: SqliteKeyStorageOptions) {
50
+ const tableName = options.tableName ?? "api_keys";
51
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
52
+ throw new Error(`Invalid table name "${tableName}"`);
53
+ }
54
+ this.table = tableName;
55
+ this.db = new Database(options.dbPath);
21
56
  this.db.pragma("journal_mode = WAL");
22
57
  this.db.exec(`
23
- CREATE TABLE IF NOT EXISTS api_keys (
58
+ CREATE TABLE IF NOT EXISTS ${this.table} (
24
59
  id TEXT PRIMARY KEY,
25
60
  name TEXT NOT NULL,
26
61
  key_hash TEXT NOT NULL UNIQUE,
@@ -34,79 +69,179 @@ export class KeyStore {
34
69
  `);
35
70
  }
36
71
 
72
+ insert(record: ApiKey): void {
73
+ this.db.prepare(`
74
+ INSERT INTO ${this.table}
75
+ (id, name, key_hash, key_prefix, scopes, created_at, last_used, expires_at, revoked)
76
+ VALUES
77
+ (@id, @name, @key_hash, @key_prefix, @scopes, @created_at, @last_used, @expires_at, @revoked)
78
+ `).run({
79
+ id: record.id,
80
+ name: record.name,
81
+ key_hash: record.keyHash,
82
+ key_prefix: record.keyPrefix,
83
+ scopes: JSON.stringify(record.scopes),
84
+ created_at: record.createdAt,
85
+ last_used: record.lastUsed,
86
+ expires_at: record.expiresAt,
87
+ revoked: record.revoked ? 1 : 0,
88
+ });
89
+ }
90
+
91
+ findByHash(keyHash: string): ApiKey | null {
92
+ const row = this.db
93
+ .prepare(`SELECT id, name, key_hash, key_prefix, scopes, created_at, last_used, expires_at, revoked
94
+ FROM ${this.table} WHERE key_hash = ?`)
95
+ .get(keyHash) as Record<string, unknown> | undefined;
96
+ return row ? rowToApiKey(row) : null;
97
+ }
98
+
99
+ list(): ApiKeyPublic[] {
100
+ const rows = this.db
101
+ .prepare(`SELECT id, name, key_prefix, scopes, created_at, last_used, expires_at, revoked
102
+ FROM ${this.table} ORDER BY created_at DESC`)
103
+ .all() as Record<string, unknown>[];
104
+ return rows.map((row) => ({
105
+ id: row.id as string,
106
+ name: row.name as string,
107
+ keyPrefix: row.key_prefix as string,
108
+ scopes: JSON.parse(row.scopes as string),
109
+ createdAt: row.created_at as string,
110
+ lastUsed: (row.last_used as string | null) ?? null,
111
+ expiresAt: (row.expires_at as string | null) ?? null,
112
+ revoked: Boolean(row.revoked),
113
+ }));
114
+ }
115
+
116
+ touch(id: string, lastUsedIso: string): void {
117
+ this.db.prepare(`UPDATE ${this.table} SET last_used = ? WHERE id = ?`).run(lastUsedIso, id);
118
+ }
119
+
120
+ revoke(id: string): boolean {
121
+ const result = this.db.prepare(`UPDATE ${this.table} SET revoked = 1 WHERE id = ?`).run(id);
122
+ return result.changes > 0;
123
+ }
124
+
125
+ close(): void {
126
+ this.db.close();
127
+ }
128
+ }
129
+
130
+ function rowToApiKey(row: Record<string, unknown>): ApiKey {
131
+ return {
132
+ id: row.id as string,
133
+ name: row.name as string,
134
+ keyHash: row.key_hash as string,
135
+ keyPrefix: row.key_prefix as string,
136
+ scopes: JSON.parse(row.scopes as string),
137
+ createdAt: row.created_at as string,
138
+ lastUsed: (row.last_used as string | null) ?? null,
139
+ expiresAt: (row.expires_at as string | null) ?? null,
140
+ revoked: Boolean(row.revoked),
141
+ };
142
+ }
143
+
144
+ // ─── In-memory storage for tests / ephemeral deployments ────────────
145
+
146
+ export class MemoryKeyStorage implements ApiKeyStorageAdapter {
147
+ private records = new Map<string, ApiKey>();
148
+
149
+ insert(record: ApiKey): void {
150
+ this.records.set(record.id, { ...record });
151
+ }
152
+
153
+ findByHash(keyHash: string): ApiKey | null {
154
+ for (const r of this.records.values()) {
155
+ if (r.keyHash === keyHash) return { ...r };
156
+ }
157
+ return null;
158
+ }
159
+
160
+ list(): ApiKeyPublic[] {
161
+ return Array.from(this.records.values())
162
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
163
+ .map((r) => {
164
+ const { keyHash: _h, ...rest } = r;
165
+ return rest;
166
+ });
167
+ }
168
+
169
+ touch(id: string, lastUsedIso: string): void {
170
+ const r = this.records.get(id);
171
+ if (r) r.lastUsed = lastUsedIso;
172
+ }
173
+
174
+ revoke(id: string): boolean {
175
+ const r = this.records.get(id);
176
+ if (!r) return false;
177
+ r.revoked = true;
178
+ return true;
179
+ }
180
+ }
181
+
182
+ // ─── KeyStore — owns crypto, delegates persistence ──────────────────
183
+
184
+ export class KeyStore {
185
+ private storage: ApiKeyStorageAdapter;
186
+
187
+ /**
188
+ * Pass an `ApiKeyStorageAdapter` for any backend. The string overload is
189
+ * retained for backwards compatibility — it constructs a SqliteKeyStorage
190
+ * at the given path.
191
+ */
192
+ constructor(storageOrDbPath: ApiKeyStorageAdapter | string) {
193
+ if (typeof storageOrDbPath === "string") {
194
+ this.storage = new SqliteKeyStorage({ dbPath: storageOrDbPath });
195
+ } else {
196
+ this.storage = storageOrDbPath;
197
+ }
198
+ }
199
+
37
200
  /** 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 } {
201
+ async create(name: string, scopes: string[] = ["trigger", "read"]): Promise<{ key: string; record: ApiKey }> {
39
202
  const id = crypto.randomUUID();
40
203
  const rawKey = `sk_live_${crypto.randomBytes(16).toString("hex")}`;
41
204
  const keyHash = crypto.createHash("sha256").update(rawKey).digest("hex");
42
205
  const keyPrefix = rawKey.slice(0, 12);
43
206
  const createdAt = new Date().toISOString();
44
207
 
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 },
208
+ const record: ApiKey = {
209
+ id, name, keyHash, keyPrefix, scopes, createdAt,
210
+ lastUsed: null, expiresAt: null, revoked: false,
53
211
  };
212
+ await this.storage.insert(record);
213
+ return { key: rawKey, record };
54
214
  }
55
215
 
56
216
  /** Verify an API key. Returns the key record if valid, null otherwise. */
57
- verify(rawKey: string): ApiKey | null {
217
+ async verify(rawKey: string): Promise<ApiKey | null> {
58
218
  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;
219
+ const record = await this.storage.findByHash(keyHash);
220
+ if (!record) return null;
221
+ if (record.revoked) return null;
222
+ if (record.expiresAt && new Date(record.expiresAt) < new Date()) return null;
63
223
 
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;
224
+ // Touch is best-effort — don't block verification on the write. Wrap in
225
+ // an explicit deferred so a synchronous throw from a sync `touch()` is
226
+ // also swallowed, matching the async case.
227
+ Promise.resolve()
228
+ .then(() => this.storage.touch(record.id, new Date().toISOString()))
229
+ .catch(() => {});
67
230
 
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
- };
231
+ return record;
82
232
  }
83
233
 
84
234
  /** 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
- }));
235
+ async list(): Promise<ApiKeyPublic[]> {
236
+ return this.storage.list();
101
237
  }
102
238
 
103
239
  /** 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;
240
+ async revoke(id: string): Promise<boolean> {
241
+ return this.storage.revoke(id);
107
242
  }
108
243
 
109
- close(): void {
110
- this.db.close();
244
+ async close(): Promise<void> {
245
+ if (this.storage.close) await this.storage.close();
111
246
  }
112
247
  }