@roamcode.ai/server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/LICENSE +21 -0
  2. package/dist/auth.d.ts +63 -0
  3. package/dist/auth.js +133 -0
  4. package/dist/claude-auth-service.d.ts +76 -0
  5. package/dist/claude-auth-service.js +217 -0
  6. package/dist/claude-latest-service.d.ts +34 -0
  7. package/dist/claude-latest-service.js +61 -0
  8. package/dist/config.d.ts +78 -0
  9. package/dist/config.js +59 -0
  10. package/dist/data-dir.d.ts +42 -0
  11. package/dist/data-dir.js +70 -0
  12. package/dist/diag.d.ts +43 -0
  13. package/dist/diag.js +83 -0
  14. package/dist/fs-service.d.ts +90 -0
  15. package/dist/fs-service.js +290 -0
  16. package/dist/index.d.ts +82 -0
  17. package/dist/index.js +44 -0
  18. package/dist/managed-runtime.d.ts +51 -0
  19. package/dist/managed-runtime.js +411 -0
  20. package/dist/managed-update-helper.d.ts +2 -0
  21. package/dist/managed-update-helper.js +34 -0
  22. package/dist/mcp-send.d.ts +33 -0
  23. package/dist/mcp-send.js +107 -0
  24. package/dist/origin-check.d.ts +37 -0
  25. package/dist/origin-check.js +101 -0
  26. package/dist/pane-status.d.ts +61 -0
  27. package/dist/pane-status.js +145 -0
  28. package/dist/providers/claude-metadata-service.d.ts +58 -0
  29. package/dist/providers/claude-metadata-service.js +352 -0
  30. package/dist/providers/claude-provider.d.ts +11 -0
  31. package/dist/providers/claude-provider.js +166 -0
  32. package/dist/providers/codex-activity.d.ts +21 -0
  33. package/dist/providers/codex-activity.js +122 -0
  34. package/dist/providers/codex-app-server-client.d.ts +90 -0
  35. package/dist/providers/codex-app-server-client.js +485 -0
  36. package/dist/providers/codex-latest-service.d.ts +50 -0
  37. package/dist/providers/codex-latest-service.js +174 -0
  38. package/dist/providers/codex-metadata-service.d.ts +161 -0
  39. package/dist/providers/codex-metadata-service.js +686 -0
  40. package/dist/providers/codex-profile-client.d.ts +16 -0
  41. package/dist/providers/codex-profile-client.js +52 -0
  42. package/dist/providers/codex-profile-security.d.ts +23 -0
  43. package/dist/providers/codex-profile-security.js +161 -0
  44. package/dist/providers/codex-provider.d.ts +15 -0
  45. package/dist/providers/codex-provider.js +174 -0
  46. package/dist/providers/codex-thread-coordinator.d.ts +18 -0
  47. package/dist/providers/codex-thread-coordinator.js +93 -0
  48. package/dist/providers/codex-thread-persistence.d.ts +9 -0
  49. package/dist/providers/codex-thread-persistence.js +45 -0
  50. package/dist/providers/codex-thread-resolver.d.ts +59 -0
  51. package/dist/providers/codex-thread-resolver.js +322 -0
  52. package/dist/providers/options.d.ts +7 -0
  53. package/dist/providers/options.js +155 -0
  54. package/dist/providers/provider-artifacts.d.ts +3 -0
  55. package/dist/providers/provider-artifacts.js +30 -0
  56. package/dist/providers/registry.d.ts +7 -0
  57. package/dist/providers/registry.js +23 -0
  58. package/dist/providers/types.d.ts +95 -0
  59. package/dist/providers/types.js +8 -0
  60. package/dist/push-dispatch.d.ts +81 -0
  61. package/dist/push-dispatch.js +100 -0
  62. package/dist/push-store.d.ts +25 -0
  63. package/dist/push-store.js +79 -0
  64. package/dist/rate-limit.d.ts +52 -0
  65. package/dist/rate-limit.js +72 -0
  66. package/dist/server-config.d.ts +60 -0
  67. package/dist/server-config.js +77 -0
  68. package/dist/service-install.d.ts +60 -0
  69. package/dist/service-install.js +221 -0
  70. package/dist/session-defaults.d.ts +26 -0
  71. package/dist/session-defaults.js +60 -0
  72. package/dist/session-store.d.ts +81 -0
  73. package/dist/session-store.js +654 -0
  74. package/dist/start.d.ts +31 -0
  75. package/dist/start.js +372 -0
  76. package/dist/static-routes.d.ts +101 -0
  77. package/dist/static-routes.js +188 -0
  78. package/dist/terminal-capability.d.ts +5 -0
  79. package/dist/terminal-capability.js +27 -0
  80. package/dist/terminal-manager.d.ts +224 -0
  81. package/dist/terminal-manager.js +917 -0
  82. package/dist/terminal-process.d.ts +85 -0
  83. package/dist/terminal-process.js +238 -0
  84. package/dist/terminal-shared.d.ts +36 -0
  85. package/dist/terminal-shared.js +43 -0
  86. package/dist/tmux-list.d.ts +11 -0
  87. package/dist/tmux-list.js +39 -0
  88. package/dist/transport.d.ts +123 -0
  89. package/dist/transport.js +1559 -0
  90. package/dist/updater.d.ts +161 -0
  91. package/dist/updater.js +451 -0
  92. package/dist/usage-service.d.ts +118 -0
  93. package/dist/usage-service.js +173 -0
  94. package/dist/vapid.d.ts +17 -0
  95. package/dist/vapid.js +31 -0
  96. package/dist/web-push-send.d.ts +20 -0
  97. package/dist/web-push-send.js +21 -0
  98. package/dist/ws-ticket.d.ts +47 -0
  99. package/dist/ws-ticket.js +62 -0
  100. package/package.json +55 -0
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Claude usage limits (the `/usage` slash command), surfaced in the session rail and settings:
3
+ * the 5-hour SESSION limit, the all-model WEEKLY limit, and any provider-named weekly buckets.
4
+ *
5
+ * DATA SOURCE: the real `claude` CLI's `/usage` command returns the exact numbers. Invoked headlessly
6
+ * as `claudeBin -p "/usage" --output-format json --dangerously-skip-permissions` (clean exit, ONE JSON
7
+ * line). Its `.result` string carries the human-readable usage block we parse here, e.g.:
8
+ *
9
+ * Current session: 12% used · resets Jun 25 at 11:30pm (Europe/Istanbul)
10
+ * Current week (all models): 72% used · resets Jun 25 at 10pm (Europe/Istanbul)
11
+ * Current week (Fable): 2% used · resets Jun 25 at 9:59pm (Europe/Istanbul)
12
+ *
13
+ * The spawn is the cost; a short TTL cache keeps it to ~once/3min regardless of how fast the rail
14
+ * polls. A failed/empty fetch (claude missing / not logged in / parse fail) degrades GRACEFULLY to the
15
+ * last good cached value, or null when there's none — the feature simply hides in the UI, never 500s.
16
+ *
17
+ * Deps are INJECTABLE (`runUsage` + `now`, mirroring the updater's style) so the parse + cache logic is
18
+ * unit-testable against fixture text with no real spawn.
19
+ */
20
+ /** A single usage bar: percent used (0–100) plus a reset string when the provider supplies one. */
21
+ export interface Bar {
22
+ percent: number;
23
+ /** Claude omits this while a window is still at 0%, so reset time is optional. */
24
+ resets?: string;
25
+ }
26
+ export interface ModelWeekBar extends Bar {
27
+ model: string;
28
+ }
29
+ /**
30
+ * Parsed usage snapshot. `session` is the 5-hour limit; `week` is the all-models weekly limit;
31
+ * `weekModels` contains provider-named weekly buckets. `weekSonnet` remains for older clients.
32
+ */
33
+ export interface UsageInfo {
34
+ session?: Bar;
35
+ week?: Bar;
36
+ /** Provider-named weekly buckets such as Fable or the legacy Sonnet-only limit. */
37
+ weekModels?: ModelWeekBar[];
38
+ /** Legacy compatibility for older clients; new code should prefer weekModels. */
39
+ weekSonnet?: Bar;
40
+ fetchedAt: number;
41
+ }
42
+ /** Resolve the raw `.result` string from `claude /usage`. NEVER rejects — resolves "" on any failure. */
43
+ export type RunUsage = () => Promise<string>;
44
+ export interface UsageServiceDeps {
45
+ runUsage: RunUsage;
46
+ /** Clock seam so the cache TTL is testable. */
47
+ now: () => number;
48
+ /** Cache TTL in ms (default USAGE_CACHE_MS). A getUsage within this window reuses the cache. */
49
+ ttlMs?: number;
50
+ }
51
+ interface UsagePty {
52
+ onData(cb: (data: string) => void): void;
53
+ onExit(cb: (event: {
54
+ exitCode: number;
55
+ }) => void): void;
56
+ kill(signal?: string): void;
57
+ }
58
+ type UsagePtySpawn = (file: string, args: string[], options: {
59
+ name: string;
60
+ cols: number;
61
+ rows: number;
62
+ cwd: string;
63
+ env: NodeJS.ProcessEnv;
64
+ }) => UsagePty;
65
+ /** Cache TTL for a parsed usage snapshot. The rail polls ~every 60s; this throttles the real spawn to
66
+ * roughly once every few minutes regardless of poll rate. */
67
+ export declare const USAGE_CACHE_MS: number;
68
+ /** Hard timeout for the `claude /usage` spawn so a hung CLI never blocks the /usage response. */
69
+ export declare const USAGE_TIMEOUT_MS = 15000;
70
+ /**
71
+ * Parse the `/usage` result text into a UsageInfo. PURE: no spawn, no clock (the `now` arg stamps
72
+ * `fetchedAt`). Returns null when neither the session nor the all-models week line parses (the feature
73
+ * is then unavailable → the UI hides). Model-specific weeks and reset strings are optional. Reset
74
+ * strings are kept as-is (trimmed); the UI may shorten them further.
75
+ */
76
+ export declare function parseUsage(text: string, now?: number): UsageInfo | null;
77
+ /**
78
+ * The real `runUsage` adapter. Claude 2.1.207+ omits account-limit lines when stdin is not a TTY, so a
79
+ * normal child-process pipe returns only the diagnostic "What's contributing" section and parses as no
80
+ * limits. Run the short-lived `/usage` command in its own isolated PTY instead. This is NOT a RoamCode tmux
81
+ * session; it exits with the command and never attaches to a user's chat. ANTHROPIC_API_KEY is stripped so
82
+ * subscription auth is used. NEVER rejects — resolves "" on spawn error, timeout, malformed JSON, or a
83
+ * missing `.result`.
84
+ */
85
+ export declare function createUsageRunner(opts: {
86
+ claudeBin: string;
87
+ env?: NodeJS.ProcessEnv;
88
+ timeoutMs?: number;
89
+ /** Test seam; production lazily loads node-pty, already required by terminal sessions. */
90
+ ptySpawn?: UsagePtySpawn;
91
+ }): RunUsage;
92
+ /**
93
+ * Resolves + caches the parsed Claude usage. The cache stores the LAST GOOD snapshot; the TTL governs
94
+ * when `runUsage` is re-run. A failed/empty fetch returns the last good value (or null), never throws.
95
+ */
96
+ export declare class UsageService {
97
+ private readonly runUsage;
98
+ private readonly now;
99
+ private readonly ttlMs;
100
+ private cache?;
101
+ constructor(deps: UsageServiceDeps);
102
+ /**
103
+ * Get the current usage. Within the TTL (and not forced) returns the cached snapshot WITHOUT
104
+ * re-spawning. Otherwise runs `runUsage`, parses, and (on success) caches it as the new last-good.
105
+ * A failed/empty refresh keeps the last good value (or null) and re-stamps the cache time so an
106
+ * outage doesn't re-spawn on every poll.
107
+ */
108
+ getUsage(force?: boolean): Promise<UsageInfo | null>;
109
+ }
110
+ /** Construct a UsageService with the real spawn adapter (the production wiring). */
111
+ export declare function createUsageService(opts: {
112
+ claudeBin: string;
113
+ env?: NodeJS.ProcessEnv;
114
+ now?: () => number;
115
+ timeoutMs?: number;
116
+ ttlMs?: number;
117
+ }): UsageService;
118
+ export {};
@@ -0,0 +1,173 @@
1
+ import { createRequire } from "node:module";
2
+ const require = createRequire(import.meta.url);
3
+ /** Cache TTL for a parsed usage snapshot. The rail polls ~every 60s; this throttles the real spawn to
4
+ * roughly once every few minutes regardless of poll rate. */
5
+ export const USAGE_CACHE_MS = 3 * 60 * 1000;
6
+ /** Hard timeout for the `claude /usage` spawn so a hung CLI never blocks the /usage response. */
7
+ export const USAGE_TIMEOUT_MS = 15_000;
8
+ /** Match one usage line (`<label>: NN% used [· resets <when>]`) into a Bar. Tolerant of spacing / the
9
+ * middle `·` / `reset` vs `resets`, case-insensitive. Returns undefined when the line isn't present. */
10
+ function matchBar(text, re) {
11
+ const m = re.exec(text);
12
+ if (!m)
13
+ return undefined;
14
+ const parsed = Number.parseInt(m[1], 10);
15
+ if (Number.isNaN(parsed))
16
+ return undefined;
17
+ // Clamp to [0,100] so a malformed/over-100 value can't render an overflowing progress bar.
18
+ const percent = Math.max(0, Math.min(100, parsed));
19
+ const resets = (m[2] ?? "").trim();
20
+ return { percent, ...(resets ? { resets } : {}) };
21
+ }
22
+ const OPTIONAL_RESET = String.raw `(?:\s*·?\s*resets?\s*(.+))?\s*$`;
23
+ const SESSION_RE = new RegExp(String.raw `^Current session:\s*(\d+)%\s*used${OPTIONAL_RESET}`, "im");
24
+ const WEEK_RE = new RegExp(String.raw `^Current week \(all models\):\s*(\d+)%\s*used${OPTIONAL_RESET}`, "im");
25
+ const MODEL_WEEK_RE = new RegExp(String.raw `^Current week \((?!all models\))(.+?)\):\s*(\d+)%\s*used${OPTIONAL_RESET}`, "gim");
26
+ function matchModelWeeks(text) {
27
+ return Array.from(text.matchAll(MODEL_WEEK_RE), (match) => {
28
+ const model = match[1].trim().replace(/\s+only$/i, "");
29
+ const percent = Math.max(0, Math.min(100, Number.parseInt(match[2], 10)));
30
+ const resets = (match[3] ?? "").trim();
31
+ return { model, percent, ...(resets ? { resets } : {}) };
32
+ }).filter((bar) => bar.model.length > 0 && !Number.isNaN(bar.percent));
33
+ }
34
+ /**
35
+ * Parse the `/usage` result text into a UsageInfo. PURE: no spawn, no clock (the `now` arg stamps
36
+ * `fetchedAt`). Returns null when neither the session nor the all-models week line parses (the feature
37
+ * is then unavailable → the UI hides). Model-specific weeks and reset strings are optional. Reset
38
+ * strings are kept as-is (trimmed); the UI may shorten them further.
39
+ */
40
+ export function parseUsage(text, now = Date.now()) {
41
+ if (typeof text !== "string" || !text.trim())
42
+ return null;
43
+ const session = matchBar(text, SESSION_RE);
44
+ const week = matchBar(text, WEEK_RE);
45
+ const weekModels = matchModelWeeks(text);
46
+ const weekSonnetModel = weekModels.find((bar) => bar.model.toLowerCase() === "sonnet");
47
+ const weekSonnet = weekSonnetModel
48
+ ? {
49
+ percent: weekSonnetModel.percent,
50
+ ...(weekSonnetModel.resets ? { resets: weekSonnetModel.resets } : {}),
51
+ }
52
+ : undefined;
53
+ if (!session && !week)
54
+ return null;
55
+ const info = { fetchedAt: now };
56
+ if (session)
57
+ info.session = session;
58
+ if (week)
59
+ info.week = week;
60
+ if (weekModels.length > 0)
61
+ info.weekModels = weekModels;
62
+ if (weekSonnet)
63
+ info.weekSonnet = weekSonnet;
64
+ return info;
65
+ }
66
+ /**
67
+ * Pull the JSON object out of a PTY stream. Claude writes one JSON line, then may restore terminal modes
68
+ * with ANSI sequences; using the outermost braces excludes that terminal trailer without trying to strip
69
+ * arbitrary content from the JSON string itself.
70
+ */
71
+ function resultFromPtyOutput(output) {
72
+ const start = output.indexOf("{");
73
+ const end = output.lastIndexOf("}");
74
+ if (start < 0 || end < start)
75
+ return "";
76
+ try {
77
+ const parsed = JSON.parse(output.slice(start, end + 1));
78
+ return typeof parsed.result === "string" ? parsed.result : "";
79
+ }
80
+ catch {
81
+ return "";
82
+ }
83
+ }
84
+ /**
85
+ * The real `runUsage` adapter. Claude 2.1.207+ omits account-limit lines when stdin is not a TTY, so a
86
+ * normal child-process pipe returns only the diagnostic "What's contributing" section and parses as no
87
+ * limits. Run the short-lived `/usage` command in its own isolated PTY instead. This is NOT a RoamCode tmux
88
+ * session; it exits with the command and never attaches to a user's chat. ANTHROPIC_API_KEY is stripped so
89
+ * subscription auth is used. NEVER rejects — resolves "" on spawn error, timeout, malformed JSON, or a
90
+ * missing `.result`.
91
+ */
92
+ export function createUsageRunner(opts) {
93
+ return () => new Promise((resolve) => {
94
+ let settled = false;
95
+ const finish = (val) => {
96
+ if (settled)
97
+ return;
98
+ settled = true;
99
+ resolve(val);
100
+ };
101
+ // Subscription auth only (mirrors claude-process): never pass an API key to the child.
102
+ const env = { ...(opts.env ?? process.env) };
103
+ delete env.ANTHROPIC_API_KEY;
104
+ let child;
105
+ try {
106
+ const ptySpawn = opts.ptySpawn ??
107
+ ((file, args, options) => {
108
+ const pty = require("node-pty");
109
+ return pty.spawn(file, args, options);
110
+ });
111
+ child = ptySpawn(opts.claudeBin, ["-p", "/usage", "--output-format", "json", "--dangerously-skip-permissions"], { name: "xterm-256color", cols: 200, rows: 24, cwd: process.cwd(), env });
112
+ }
113
+ catch {
114
+ finish("");
115
+ return;
116
+ }
117
+ let stdout = "";
118
+ const timer = setTimeout(() => {
119
+ try {
120
+ child.kill("SIGKILL");
121
+ }
122
+ catch {
123
+ // already gone
124
+ }
125
+ finish("");
126
+ }, opts.timeoutMs ?? USAGE_TIMEOUT_MS);
127
+ child.onData((data) => (stdout += data));
128
+ child.onExit(() => {
129
+ clearTimeout(timer);
130
+ finish(resultFromPtyOutput(stdout));
131
+ });
132
+ });
133
+ }
134
+ /**
135
+ * Resolves + caches the parsed Claude usage. The cache stores the LAST GOOD snapshot; the TTL governs
136
+ * when `runUsage` is re-run. A failed/empty fetch returns the last good value (or null), never throws.
137
+ */
138
+ export class UsageService {
139
+ runUsage;
140
+ now;
141
+ ttlMs;
142
+ cache;
143
+ constructor(deps) {
144
+ this.runUsage = deps.runUsage;
145
+ this.now = deps.now;
146
+ this.ttlMs = deps.ttlMs ?? USAGE_CACHE_MS;
147
+ }
148
+ /**
149
+ * Get the current usage. Within the TTL (and not forced) returns the cached snapshot WITHOUT
150
+ * re-spawning. Otherwise runs `runUsage`, parses, and (on success) caches it as the new last-good.
151
+ * A failed/empty refresh keeps the last good value (or null) and re-stamps the cache time so an
152
+ * outage doesn't re-spawn on every poll.
153
+ */
154
+ async getUsage(force = false) {
155
+ const now = this.now();
156
+ if (!force && this.cache && now - this.cache.at < this.ttlMs)
157
+ return this.cache.info;
158
+ const raw = await this.runUsage();
159
+ const parsed = parseUsage(raw, now);
160
+ // On a failed/empty refresh keep the last good snapshot if we have one, else null.
161
+ const info = parsed ?? this.cache?.info ?? null;
162
+ this.cache = { at: now, info };
163
+ return info;
164
+ }
165
+ }
166
+ /** Construct a UsageService with the real spawn adapter (the production wiring). */
167
+ export function createUsageService(opts) {
168
+ return new UsageService({
169
+ runUsage: createUsageRunner({ claudeBin: opts.claudeBin, env: opts.env, timeoutMs: opts.timeoutMs }),
170
+ now: opts.now ?? (() => Date.now()),
171
+ ttlMs: opts.ttlMs,
172
+ });
173
+ }
@@ -0,0 +1,17 @@
1
+ export interface VapidKeys {
2
+ /** URL-safe base64 public key — safe to send to the browser for the push subscription. */
3
+ publicKey: string;
4
+ /** URL-safe base64 private key — NEVER leaves the server (signs pushes). */
5
+ privateKey: string;
6
+ }
7
+ export interface ResolveVapidKeysOptions {
8
+ dataDir: string;
9
+ /** Injectable generator for tests. Defaults to web-push's NIST P-256 generator. */
10
+ generate?: () => VapidKeys;
11
+ }
12
+ /**
13
+ * Generate the VAPID keypair on first run and persist it to `<dataDir>/vapid.json` (mode 0600 — the
14
+ * private key is a server secret). On subsequent runs the persisted pair is reused so existing
15
+ * browser subscriptions stay valid (rotating the key would invalidate every subscription).
16
+ */
17
+ export declare function resolveVapidKeys(opts: ResolveVapidKeysOptions): VapidKeys;
package/dist/vapid.js ADDED
@@ -0,0 +1,31 @@
1
+ import { chmodSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import webpush from "web-push";
4
+ import { ensureDataDir } from "./data-dir.js";
5
+ function defaultGenerate() {
6
+ const { publicKey, privateKey } = webpush.generateVAPIDKeys();
7
+ return { publicKey, privateKey };
8
+ }
9
+ /**
10
+ * Generate the VAPID keypair on first run and persist it to `<dataDir>/vapid.json` (mode 0600 — the
11
+ * private key is a server secret). On subsequent runs the persisted pair is reused so existing
12
+ * browser subscriptions stay valid (rotating the key would invalidate every subscription).
13
+ */
14
+ export function resolveVapidKeys(opts) {
15
+ const path = join(opts.dataDir, "vapid.json");
16
+ try {
17
+ const existing = JSON.parse(readFileSync(path, "utf8"));
18
+ if (typeof existing.publicKey === "string" && typeof existing.privateKey === "string") {
19
+ return { publicKey: existing.publicKey, privateKey: existing.privateKey };
20
+ }
21
+ }
22
+ catch {
23
+ // no vapid.json yet (or corrupt) — generate + persist below
24
+ }
25
+ const keys = (opts.generate ?? defaultGenerate)();
26
+ ensureDataDir(opts.dataDir);
27
+ // mode is honored only on CREATE; chmodSync unconditionally enforces 0600 even if the path existed.
28
+ writeFileSync(path, JSON.stringify(keys), { mode: 0o600 });
29
+ chmodSync(path, 0o600);
30
+ return keys;
31
+ }
@@ -0,0 +1,20 @@
1
+ import type { VapidKeys } from "./vapid.js";
2
+ export interface PushRecipient {
3
+ endpoint: string;
4
+ p256dh: string;
5
+ auth: string;
6
+ }
7
+ export type PushSendFn = (sub: PushRecipient, payload: string) => Promise<{
8
+ statusCode?: number;
9
+ }>;
10
+ export interface CreateWebPushSendOptions {
11
+ vapid: VapidKeys;
12
+ /** VAPID subject: a mailto: or https: URL the push service can contact (web-push requires it). */
13
+ subject: string;
14
+ }
15
+ /**
16
+ * Bind a real Web Push sender. Maps our flat PushSubscriptionRecord to the {endpoint, keys} shape
17
+ * web-push.sendNotification expects, and normalizes the outcome to { statusCode } so the dispatcher
18
+ * can prune on 404/410 without depending on web-push's error type.
19
+ */
20
+ export declare function createWebPushSend(opts: CreateWebPushSendOptions): PushSendFn;
@@ -0,0 +1,21 @@
1
+ import webpush from "web-push";
2
+ /**
3
+ * Bind a real Web Push sender. Maps our flat PushSubscriptionRecord to the {endpoint, keys} shape
4
+ * web-push.sendNotification expects, and normalizes the outcome to { statusCode } so the dispatcher
5
+ * can prune on 404/410 without depending on web-push's error type.
6
+ */
7
+ export function createWebPushSend(opts) {
8
+ webpush.setVapidDetails(opts.subject, opts.vapid.publicKey, opts.vapid.privateKey);
9
+ return async (sub, payload) => {
10
+ try {
11
+ const res = await webpush.sendNotification({ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }, payload);
12
+ return { statusCode: res.statusCode };
13
+ }
14
+ catch (err) {
15
+ const status = err.statusCode;
16
+ if (typeof status === "number")
17
+ return { statusCode: status };
18
+ throw err; // a non-HTTP failure (e.g. encryption) — let the dispatcher swallow it
19
+ }
20
+ };
21
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Single-use, short-lived tickets for the terminal WebSocket handshake.
3
+ *
4
+ * WHY: a browser WebSocket cannot send an Authorization header, so the client historically put the
5
+ * LONG-LIVED access token in the WS URL (`?token=`). Query strings are routinely written into proxy /
6
+ * tunnel / access logs, so that leaked a full-access credential. The flow now is: POST /ws-ticket (over
7
+ * the normal header-authed API) → get a 32-byte ticket → open the WS with `?ticket=`. A logged ticket is
8
+ * worthless: it is consumed by the very connection that carried it (single-use) and dies within TTL_MS
9
+ * anyway. The legacy `?token=` path is kept for old bundles (see transport.ts) but is deprecated.
10
+ */
11
+ /** Ticket lifetime. Long enough for the client to turn the POST response into a WS connect (even on a
12
+ * slow mobile link), short enough that a logged/leaked ticket is stale almost immediately. */
13
+ export declare const WS_TICKET_TTL_MS = 30000;
14
+ export interface WsTicketStoreOptions {
15
+ /** Override the TTL (tests). Default {@link WS_TICKET_TTL_MS}. */
16
+ ttlMs?: number;
17
+ /** Injectable clock for tests. Default Date.now. */
18
+ now?: () => number;
19
+ /** Injectable ticket generator for tests. Default: 32 CSPRNG bytes, base64url. */
20
+ generate?: () => string;
21
+ }
22
+ /**
23
+ * In-memory single-use ticket store. Deliberately NOT persisted: a ticket outliving a server restart
24
+ * would defeat its 30s point, and the client just re-POSTs on reconnect. Expired entries are swept
25
+ * LAZILY on each issue/consume — issuance is token-gated + rate-limited, so the map stays tiny without
26
+ * a timer.
27
+ */
28
+ export declare class WsTicketStore {
29
+ /** ticket → expiry (epoch ms). */
30
+ private readonly tickets;
31
+ private readonly ttlMs;
32
+ private readonly now;
33
+ private readonly generate;
34
+ constructor(opts?: WsTicketStoreOptions);
35
+ /** Mint a fresh single-use ticket. The response shape is exactly what POST /ws-ticket returns. */
36
+ issue(): {
37
+ ticket: string;
38
+ expiresInMs: number;
39
+ };
40
+ /** Consume a ticket: true exactly ONCE for a live ticket; false for unknown, expired, or already-used
41
+ * (the entry is deleted on first consume, so a replayed WS URL is rejected). */
42
+ consume(ticket: string): boolean;
43
+ /** Drop expired entries so an unconsumed flood can't grow the map (lazy — no timer to leak). */
44
+ private sweep;
45
+ /** TEST ONLY: how many un-consumed tickets are currently held (post-sweep count). */
46
+ size(): number;
47
+ }
@@ -0,0 +1,62 @@
1
+ import { randomBytes } from "node:crypto";
2
+ /**
3
+ * Single-use, short-lived tickets for the terminal WebSocket handshake.
4
+ *
5
+ * WHY: a browser WebSocket cannot send an Authorization header, so the client historically put the
6
+ * LONG-LIVED access token in the WS URL (`?token=`). Query strings are routinely written into proxy /
7
+ * tunnel / access logs, so that leaked a full-access credential. The flow now is: POST /ws-ticket (over
8
+ * the normal header-authed API) → get a 32-byte ticket → open the WS with `?ticket=`. A logged ticket is
9
+ * worthless: it is consumed by the very connection that carried it (single-use) and dies within TTL_MS
10
+ * anyway. The legacy `?token=` path is kept for old bundles (see transport.ts) but is deprecated.
11
+ */
12
+ /** Ticket lifetime. Long enough for the client to turn the POST response into a WS connect (even on a
13
+ * slow mobile link), short enough that a logged/leaked ticket is stale almost immediately. */
14
+ export const WS_TICKET_TTL_MS = 30_000;
15
+ /**
16
+ * In-memory single-use ticket store. Deliberately NOT persisted: a ticket outliving a server restart
17
+ * would defeat its 30s point, and the client just re-POSTs on reconnect. Expired entries are swept
18
+ * LAZILY on each issue/consume — issuance is token-gated + rate-limited, so the map stays tiny without
19
+ * a timer.
20
+ */
21
+ export class WsTicketStore {
22
+ /** ticket → expiry (epoch ms). */
23
+ tickets = new Map();
24
+ ttlMs;
25
+ now;
26
+ generate;
27
+ constructor(opts = {}) {
28
+ this.ttlMs = opts.ttlMs ?? WS_TICKET_TTL_MS;
29
+ this.now = opts.now ?? Date.now;
30
+ this.generate = opts.generate ?? (() => randomBytes(32).toString("base64url"));
31
+ }
32
+ /** Mint a fresh single-use ticket. The response shape is exactly what POST /ws-ticket returns. */
33
+ issue() {
34
+ this.sweep();
35
+ const ticket = this.generate();
36
+ this.tickets.set(ticket, this.now() + this.ttlMs);
37
+ return { ticket, expiresInMs: this.ttlMs };
38
+ }
39
+ /** Consume a ticket: true exactly ONCE for a live ticket; false for unknown, expired, or already-used
40
+ * (the entry is deleted on first consume, so a replayed WS URL is rejected). */
41
+ consume(ticket) {
42
+ this.sweep();
43
+ const expiresAt = this.tickets.get(ticket);
44
+ if (expiresAt === undefined)
45
+ return false;
46
+ this.tickets.delete(ticket); // single-use: gone whether it validates or not
47
+ return this.now() <= expiresAt;
48
+ }
49
+ /** Drop expired entries so an unconsumed flood can't grow the map (lazy — no timer to leak). */
50
+ sweep() {
51
+ const t = this.now();
52
+ for (const [ticket, expiresAt] of this.tickets) {
53
+ if (expiresAt < t)
54
+ this.tickets.delete(ticket);
55
+ }
56
+ }
57
+ /** TEST ONLY: how many un-consumed tickets are currently held (post-sweep count). */
58
+ size() {
59
+ this.sweep();
60
+ return this.tickets.size;
61
+ }
62
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@roamcode.ai/server",
3
+ "version": "1.0.0",
4
+ "description": "Host-native server for RoamCode",
5
+ "homepage": "https://roamcode.ai",
6
+ "bugs": "https://github.com/burakgon/roamcode/issues",
7
+ "type": "module",
8
+ "main": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "license": "MIT",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/burakgon/roamcode.git"
23
+ },
24
+ "engines": {
25
+ "node": ">=24"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "bin": {
31
+ "roamcode-server": "./dist/start.js"
32
+ },
33
+ "dependencies": {
34
+ "@fastify/multipart": "^10.1.0",
35
+ "@fastify/static": "^9.3.0",
36
+ "@fastify/websocket": "^11.3.0",
37
+ "@modelcontextprotocol/sdk": "^1.29.0",
38
+ "better-sqlite3": "^12.11.1",
39
+ "fastify": "^5.10.0",
40
+ "node-pty": "1.1.0",
41
+ "web-push": "^3.6.7",
42
+ "zod": "^4.4.3",
43
+ "@roamcode.ai/web": "1.0.0"
44
+ },
45
+ "devDependencies": {
46
+ "@types/better-sqlite3": "^7.6.13",
47
+ "@types/web-push": "^3.6.4",
48
+ "@types/ws": "^8.18.1",
49
+ "ws": "^8.21.0"
50
+ },
51
+ "scripts": {
52
+ "build": "tsup",
53
+ "start": "node dist/start.js"
54
+ }
55
+ }