@roamcode.ai/server 1.0.0 → 1.0.1
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/dist/index.d.ts +2406 -82
- package/dist/index.js +8314 -44
- package/dist/managed-update-helper.js +621 -26
- package/dist/mcp-send.js +76 -90
- package/dist/start.d.ts +591 -9
- package/dist/start.js +7629 -353
- package/package.json +3 -3
- package/dist/auth.d.ts +0 -63
- package/dist/auth.js +0 -133
- package/dist/claude-auth-service.d.ts +0 -76
- package/dist/claude-auth-service.js +0 -217
- package/dist/claude-latest-service.d.ts +0 -34
- package/dist/claude-latest-service.js +0 -61
- package/dist/config.d.ts +0 -78
- package/dist/config.js +0 -59
- package/dist/data-dir.d.ts +0 -42
- package/dist/data-dir.js +0 -70
- package/dist/diag.d.ts +0 -43
- package/dist/diag.js +0 -83
- package/dist/fs-service.d.ts +0 -90
- package/dist/fs-service.js +0 -290
- package/dist/managed-runtime.d.ts +0 -51
- package/dist/managed-runtime.js +0 -411
- package/dist/managed-update-helper.d.ts +0 -2
- package/dist/mcp-send.d.ts +0 -33
- package/dist/origin-check.d.ts +0 -37
- package/dist/origin-check.js +0 -101
- package/dist/pane-status.d.ts +0 -61
- package/dist/pane-status.js +0 -145
- package/dist/providers/claude-metadata-service.d.ts +0 -58
- package/dist/providers/claude-metadata-service.js +0 -352
- package/dist/providers/claude-provider.d.ts +0 -11
- package/dist/providers/claude-provider.js +0 -166
- package/dist/providers/codex-activity.d.ts +0 -21
- package/dist/providers/codex-activity.js +0 -122
- package/dist/providers/codex-app-server-client.d.ts +0 -90
- package/dist/providers/codex-app-server-client.js +0 -485
- package/dist/providers/codex-latest-service.d.ts +0 -50
- package/dist/providers/codex-latest-service.js +0 -174
- package/dist/providers/codex-metadata-service.d.ts +0 -161
- package/dist/providers/codex-metadata-service.js +0 -686
- package/dist/providers/codex-profile-client.d.ts +0 -16
- package/dist/providers/codex-profile-client.js +0 -52
- package/dist/providers/codex-profile-security.d.ts +0 -23
- package/dist/providers/codex-profile-security.js +0 -161
- package/dist/providers/codex-provider.d.ts +0 -15
- package/dist/providers/codex-provider.js +0 -174
- package/dist/providers/codex-thread-coordinator.d.ts +0 -18
- package/dist/providers/codex-thread-coordinator.js +0 -93
- package/dist/providers/codex-thread-persistence.d.ts +0 -9
- package/dist/providers/codex-thread-persistence.js +0 -45
- package/dist/providers/codex-thread-resolver.d.ts +0 -59
- package/dist/providers/codex-thread-resolver.js +0 -322
- package/dist/providers/options.d.ts +0 -7
- package/dist/providers/options.js +0 -155
- package/dist/providers/provider-artifacts.d.ts +0 -3
- package/dist/providers/provider-artifacts.js +0 -30
- package/dist/providers/registry.d.ts +0 -7
- package/dist/providers/registry.js +0 -23
- package/dist/providers/types.d.ts +0 -95
- package/dist/providers/types.js +0 -8
- package/dist/push-dispatch.d.ts +0 -81
- package/dist/push-dispatch.js +0 -100
- package/dist/push-store.d.ts +0 -25
- package/dist/push-store.js +0 -79
- package/dist/rate-limit.d.ts +0 -52
- package/dist/rate-limit.js +0 -72
- package/dist/server-config.d.ts +0 -60
- package/dist/server-config.js +0 -77
- package/dist/service-install.d.ts +0 -60
- package/dist/service-install.js +0 -221
- package/dist/session-defaults.d.ts +0 -26
- package/dist/session-defaults.js +0 -60
- package/dist/session-store.d.ts +0 -81
- package/dist/session-store.js +0 -654
- package/dist/static-routes.d.ts +0 -101
- package/dist/static-routes.js +0 -188
- package/dist/terminal-capability.d.ts +0 -5
- package/dist/terminal-capability.js +0 -27
- package/dist/terminal-manager.d.ts +0 -224
- package/dist/terminal-manager.js +0 -917
- package/dist/terminal-process.d.ts +0 -85
- package/dist/terminal-process.js +0 -238
- package/dist/terminal-shared.d.ts +0 -36
- package/dist/terminal-shared.js +0 -43
- package/dist/tmux-list.d.ts +0 -11
- package/dist/tmux-list.js +0 -39
- package/dist/transport.d.ts +0 -123
- package/dist/transport.js +0 -1559
- package/dist/updater.d.ts +0 -161
- package/dist/updater.js +0 -451
- package/dist/usage-service.d.ts +0 -118
- package/dist/usage-service.js +0 -173
- package/dist/vapid.d.ts +0 -17
- package/dist/vapid.js +0 -31
- package/dist/web-push-send.d.ts +0 -20
- package/dist/web-push-send.js +0 -21
- package/dist/ws-ticket.d.ts +0 -47
- package/dist/ws-ticket.js +0 -62
package/dist/push-dispatch.d.ts
DELETED
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import type { PushStore } from "./push-store.js";
|
|
2
|
-
import type { PushSendFn } from "./web-push-send.js";
|
|
3
|
-
import type { ProviderId } from "./providers/types.js";
|
|
4
|
-
/**
|
|
5
|
-
* The "away-from-desk" events that warrant a phone push. This is the whole point of roamcode: get
|
|
6
|
-
* pinged when claude needs you (awaiting), when it's done (finished), or when it hands you a file.
|
|
7
|
-
* "test" is the odd one out — a user-triggered "are notifications working?" ping (POST /push/test), which
|
|
8
|
-
* carries no session and never touches the home-screen badge.
|
|
9
|
-
*/
|
|
10
|
-
export type PushEventKind = "awaiting" | "finished" | "file" | "test";
|
|
11
|
-
export interface PushEvent {
|
|
12
|
-
kind: PushEventKind;
|
|
13
|
-
/** The session the event is about — becomes the deep-link (`/?session=<id>`) AND the notification tag.
|
|
14
|
-
* Absent for a "test" ping (which isn't about any session). */
|
|
15
|
-
sessionId?: string;
|
|
16
|
-
/** Optional enrichment: the file name (kind:"file") for the body. */
|
|
17
|
-
detail?: string;
|
|
18
|
-
/** Provider/session identity is bounded display metadata, never an auth or protocol payload. */
|
|
19
|
-
provider?: ProviderId;
|
|
20
|
-
label?: string;
|
|
21
|
-
/**
|
|
22
|
-
* Home-screen app-badge value = the count of sessions currently awaiting you. Stamped by the transport on
|
|
23
|
-
* every real dispatch (awaiting/finished/file) from {@link TerminalManager.awaitingCount}, so the SW
|
|
24
|
-
* can keep the badge in sync with "how many sessions need you". Omitted for a "test" ping (it must never
|
|
25
|
-
* clobber the badge). A missing value leaves the badge untouched on the client.
|
|
26
|
-
*/
|
|
27
|
-
badgeCount?: number;
|
|
28
|
-
}
|
|
29
|
-
/**
|
|
30
|
-
* The Web Push payload the browser service worker consumes (packages/web `push` handler). The `tag` +
|
|
31
|
-
* `renotify` pair COLLAPSES repeated pushes for the same session into one re-alerting notification (so a
|
|
32
|
-
* flapping awaiting detector can't spam a phone), and `requireInteraction` keeps a "needs-you" alert
|
|
33
|
-
* sticky while a "done"/"file" alert can auto-dismiss.
|
|
34
|
-
*/
|
|
35
|
-
export interface PushPayload {
|
|
36
|
-
title: string;
|
|
37
|
-
body: string;
|
|
38
|
-
/** Deep-link the notification opens — always `/?session=<sessionId>`. */
|
|
39
|
-
url: string;
|
|
40
|
-
/** Notification tag = sessionId, so the OS collapses per-session alerts (with renotify) rather than stack. */
|
|
41
|
-
tag: string;
|
|
42
|
-
renotify: boolean;
|
|
43
|
-
/** True for a waiting-for-user alert (awaiting); false for a done/file alert. */
|
|
44
|
-
requireInteraction: boolean;
|
|
45
|
-
/**
|
|
46
|
-
* The home-screen app-badge value the SW should apply (applyBadgeFromPush): the count of sessions currently
|
|
47
|
-
* awaiting you. Present on every real away-from-desk payload; ABSENT on a "test" ping (so it never clobbers
|
|
48
|
-
* the badge). The SW treats a missing value as "leave the badge alone" and 0 as "clear it". Android/desktop
|
|
49
|
-
* honor the app badge; iOS can't badge from a push.
|
|
50
|
-
*/
|
|
51
|
-
badgeCount?: number;
|
|
52
|
-
}
|
|
53
|
-
export interface PushDispatcher {
|
|
54
|
-
/**
|
|
55
|
-
* Build the payload for an away-from-desk event and fan it out over every matching subscription. NEVER
|
|
56
|
-
* throws (a push failure must never break the terminal / the calling route); dead subscriptions (404/410)
|
|
57
|
-
* are pruned from the store as a side effect.
|
|
58
|
-
*/
|
|
59
|
-
dispatch(event: PushEvent): Promise<void>;
|
|
60
|
-
}
|
|
61
|
-
/**
|
|
62
|
-
* Map a semantic away-from-desk event to the Web Push payload the SW renders. Pure + exported so the exact
|
|
63
|
-
* contract is unit-testable (and the web agents can match it): every payload deep-links to the session and
|
|
64
|
-
* tags on the session id; only "needs-you" alerts (awaiting) set requireInteraction.
|
|
65
|
-
*/
|
|
66
|
-
export declare function buildPushPayload(event: PushEvent): PushPayload;
|
|
67
|
-
export interface CreatePushDispatcherDeps {
|
|
68
|
-
pushStore: PushStore;
|
|
69
|
-
/** The bound Web Push sender (createWebPushSend). Injected so tests drive a fake without real crypto/HTTP. */
|
|
70
|
-
send: PushSendFn;
|
|
71
|
-
/** Optional log sink for a non-HTTP send failure (defaults to a no-op). */
|
|
72
|
-
log?: (msg: string) => void;
|
|
73
|
-
}
|
|
74
|
-
/**
|
|
75
|
-
* Build the push dispatcher. Fans an event out to the GLOBAL subscriptions UNION the ones scoped to the
|
|
76
|
-
* event's session (that's exactly what `pushStore.list({ sessionId })` returns), delivering to all in
|
|
77
|
-
* parallel. A send that reports 404 (Not Found) / 410 (Gone) means the browser subscription is dead — it's
|
|
78
|
-
* pruned from the store so it isn't retried forever. A non-HTTP failure (encryption, network throw) is
|
|
79
|
-
* logged and the sub is KEPT (it isn't known-dead). Never throws.
|
|
80
|
-
*/
|
|
81
|
-
export declare function createPushDispatcher(deps: CreatePushDispatcherDeps): PushDispatcher;
|
package/dist/push-dispatch.js
DELETED
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Map a semantic away-from-desk event to the Web Push payload the SW renders. Pure + exported so the exact
|
|
3
|
-
* contract is unit-testable (and the web agents can match it): every payload deep-links to the session and
|
|
4
|
-
* tags on the session id; only "needs-you" alerts (awaiting) set requireInteraction.
|
|
5
|
-
*/
|
|
6
|
-
export function buildPushPayload(event) {
|
|
7
|
-
// A user-triggered "are notifications working?" ping (POST /push/test): fixed copy, opens the app root (no
|
|
8
|
-
// session), never sticky, and DELIBERATELY carries no badgeCount so it can't clobber the home-screen badge.
|
|
9
|
-
if (event.kind === "test") {
|
|
10
|
-
return {
|
|
11
|
-
title: "roamcode",
|
|
12
|
-
body: "Notifications are working ✓",
|
|
13
|
-
url: "/",
|
|
14
|
-
tag: "roamcode-test",
|
|
15
|
-
renotify: true,
|
|
16
|
-
requireInteraction: false,
|
|
17
|
-
};
|
|
18
|
-
}
|
|
19
|
-
// Every real away-from-desk payload deep-links to the session, tags on the session id, and — when the
|
|
20
|
-
// transport stamped it — carries the awaiting-session count as `badgeCount` (0 included, so the SW can
|
|
21
|
-
// CLEAR the badge when nothing is left waiting).
|
|
22
|
-
const base = {
|
|
23
|
-
url: `/?session=${event.sessionId}`,
|
|
24
|
-
tag: event.sessionId ?? "",
|
|
25
|
-
renotify: true,
|
|
26
|
-
...(typeof event.badgeCount === "number" ? { badgeCount: event.badgeCount } : {}),
|
|
27
|
-
};
|
|
28
|
-
const provider = event.provider === "codex" ? "Codex" : "Claude";
|
|
29
|
-
const safeLabel = (() => {
|
|
30
|
-
const clean = event.label
|
|
31
|
-
?.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, "")
|
|
32
|
-
.trim()
|
|
33
|
-
.slice(0, 80);
|
|
34
|
-
return clean || "Session";
|
|
35
|
-
})();
|
|
36
|
-
switch (event.kind) {
|
|
37
|
-
case "awaiting":
|
|
38
|
-
return {
|
|
39
|
-
...base,
|
|
40
|
-
title: `${provider} is waiting`,
|
|
41
|
-
body: `${safeLabel} needs your input in ${provider}.`,
|
|
42
|
-
requireInteraction: true,
|
|
43
|
-
};
|
|
44
|
-
case "file":
|
|
45
|
-
return {
|
|
46
|
-
...base,
|
|
47
|
-
title: `${provider} sent a file`,
|
|
48
|
-
body: `${safeLabel} has a file ready in the Files panel.`,
|
|
49
|
-
requireInteraction: false,
|
|
50
|
-
};
|
|
51
|
-
case "finished":
|
|
52
|
-
return {
|
|
53
|
-
...base,
|
|
54
|
-
title: `${provider} session ended`,
|
|
55
|
-
body: `${safeLabel} has ended in ${provider}.`,
|
|
56
|
-
requireInteraction: false,
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
/**
|
|
61
|
-
* Build the push dispatcher. Fans an event out to the GLOBAL subscriptions UNION the ones scoped to the
|
|
62
|
-
* event's session (that's exactly what `pushStore.list({ sessionId })` returns), delivering to all in
|
|
63
|
-
* parallel. A send that reports 404 (Not Found) / 410 (Gone) means the browser subscription is dead — it's
|
|
64
|
-
* pruned from the store so it isn't retried forever. A non-HTTP failure (encryption, network throw) is
|
|
65
|
-
* logged and the sub is KEPT (it isn't known-dead). Never throws.
|
|
66
|
-
*/
|
|
67
|
-
export function createPushDispatcher(deps) {
|
|
68
|
-
const { pushStore, send } = deps;
|
|
69
|
-
async function deliverOne(sub, payload) {
|
|
70
|
-
let result;
|
|
71
|
-
try {
|
|
72
|
-
result = await send(sub, payload);
|
|
73
|
-
}
|
|
74
|
-
catch (err) {
|
|
75
|
-
// A non-HTTP failure (e.g. encryption) — NOT proof the subscription is dead, so keep it; just log.
|
|
76
|
-
deps.log?.(`push send failed for ${sub.endpoint}: ${err.message}`);
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
// 404/410 → the push service says this subscription no longer exists; prune it so we stop retrying.
|
|
80
|
-
if (result.statusCode === 404 || result.statusCode === 410)
|
|
81
|
-
pushStore.remove(sub.endpoint);
|
|
82
|
-
}
|
|
83
|
-
return {
|
|
84
|
-
async dispatch(event) {
|
|
85
|
-
const payload = JSON.stringify(buildPushPayload(event));
|
|
86
|
-
// Global subs (no sessionId) + subs scoped to THIS session. A "test" ping has no sessionId, so it fans
|
|
87
|
-
// out to EVERY subscription (list() with no filter) — the caller just enabled push and wants any of
|
|
88
|
-
// their devices to confirm delivery. A store read failure must not throw here.
|
|
89
|
-
let subs;
|
|
90
|
-
try {
|
|
91
|
-
subs = pushStore.list({ sessionId: event.sessionId });
|
|
92
|
-
}
|
|
93
|
-
catch (err) {
|
|
94
|
-
deps.log?.(`push fan-out skipped (store list failed): ${err.message}`);
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
await Promise.all(subs.map((sub) => deliverOne(sub, payload)));
|
|
98
|
-
},
|
|
99
|
-
};
|
|
100
|
-
}
|
package/dist/push-store.d.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
export interface PushSubscriptionRecord {
|
|
2
|
-
/** Browser push endpoint URL (PRIMARY KEY — one per subscription). */
|
|
3
|
-
endpoint: string;
|
|
4
|
-
/** PushSubscription.keys.p256dh (client public key). */
|
|
5
|
-
p256dh: string;
|
|
6
|
-
/** PushSubscription.keys.auth (client auth secret). */
|
|
7
|
-
auth: string;
|
|
8
|
-
/** Optional session scope; undefined = subscribed to ALL sessions. */
|
|
9
|
-
sessionId?: string;
|
|
10
|
-
createdAt: number;
|
|
11
|
-
}
|
|
12
|
-
export interface PushStore {
|
|
13
|
-
upsert(sub: PushSubscriptionRecord): void;
|
|
14
|
-
/** All subscriptions, or — with { sessionId } — the global (NULL) ones UNION the session-scoped ones. */
|
|
15
|
-
list(opts?: {
|
|
16
|
-
sessionId?: string;
|
|
17
|
-
}): PushSubscriptionRecord[];
|
|
18
|
-
remove(endpoint: string): void;
|
|
19
|
-
close(): void;
|
|
20
|
-
}
|
|
21
|
-
export interface OpenPushStoreOptions {
|
|
22
|
-
/** SQLite file path. ":memory:" uses an in-process DB. */
|
|
23
|
-
dbPath: string;
|
|
24
|
-
}
|
|
25
|
-
export declare function openPushStore(opts: OpenPushStoreOptions): PushStore;
|
package/dist/push-store.js
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
import { createRequire } from "node:module";
|
|
2
|
-
const require = createRequire(import.meta.url);
|
|
3
|
-
function rowToSub(r) {
|
|
4
|
-
const s = {
|
|
5
|
-
endpoint: r.endpoint,
|
|
6
|
-
p256dh: r.p256dh,
|
|
7
|
-
auth: r.auth,
|
|
8
|
-
createdAt: r.created_at,
|
|
9
|
-
};
|
|
10
|
-
if (r.session_id !== null)
|
|
11
|
-
s.sessionId = r.session_id;
|
|
12
|
-
return s;
|
|
13
|
-
}
|
|
14
|
-
/**
|
|
15
|
-
* In-memory fallback used when the native better-sqlite3 module cannot load
|
|
16
|
-
* (no toolchain / unsupported platform) so the server still boots. NOT durable
|
|
17
|
-
* across process restarts — mirrors the SessionStore/idempotency-store pattern.
|
|
18
|
-
*/
|
|
19
|
-
function inMemoryStore() {
|
|
20
|
-
const map = new Map();
|
|
21
|
-
return {
|
|
22
|
-
upsert: (s) => void map.set(s.endpoint, { ...s }),
|
|
23
|
-
list: (opts) => {
|
|
24
|
-
const all = [...map.values()].map((v) => ({ ...v }));
|
|
25
|
-
if (!opts?.sessionId)
|
|
26
|
-
return all;
|
|
27
|
-
return all.filter((s) => s.sessionId === undefined || s.sessionId === opts.sessionId);
|
|
28
|
-
},
|
|
29
|
-
remove: (endpoint) => void map.delete(endpoint),
|
|
30
|
-
close: () => map.clear(),
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
export function openPushStore(opts) {
|
|
34
|
-
let Database;
|
|
35
|
-
try {
|
|
36
|
-
// Dynamic require keeps the native dep out of the module graph until needed
|
|
37
|
-
// and lets us fall back gracefully if the build is missing.
|
|
38
|
-
const mod = require("better-sqlite3");
|
|
39
|
-
Database = (mod.default ?? mod);
|
|
40
|
-
}
|
|
41
|
-
catch {
|
|
42
|
-
return inMemoryStore();
|
|
43
|
-
}
|
|
44
|
-
const db = new Database(opts.dbPath);
|
|
45
|
-
db.pragma("journal_mode = WAL"); // parity with the session store (concurrent reader/writer safety)
|
|
46
|
-
db.exec(`
|
|
47
|
-
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
|
48
|
-
endpoint TEXT PRIMARY KEY,
|
|
49
|
-
p256dh TEXT NOT NULL,
|
|
50
|
-
auth TEXT NOT NULL,
|
|
51
|
-
session_id TEXT,
|
|
52
|
-
created_at INTEGER NOT NULL
|
|
53
|
-
)
|
|
54
|
-
`);
|
|
55
|
-
const upsertStmt = db.prepare(`
|
|
56
|
-
INSERT INTO push_subscriptions (endpoint, p256dh, auth, session_id, created_at)
|
|
57
|
-
VALUES (@endpoint, @p256dh, @auth, @session_id, @created_at)
|
|
58
|
-
ON CONFLICT(endpoint) DO UPDATE SET
|
|
59
|
-
p256dh=excluded.p256dh, auth=excluded.auth, session_id=excluded.session_id, created_at=excluded.created_at
|
|
60
|
-
`);
|
|
61
|
-
const listAllStmt = db.prepare("SELECT * FROM push_subscriptions ORDER BY created_at ASC");
|
|
62
|
-
const listScopedStmt = db.prepare("SELECT * FROM push_subscriptions WHERE session_id IS NULL OR session_id = ? ORDER BY created_at ASC");
|
|
63
|
-
const removeStmt = db.prepare("DELETE FROM push_subscriptions WHERE endpoint = ?");
|
|
64
|
-
return {
|
|
65
|
-
upsert: (s) => void upsertStmt.run({
|
|
66
|
-
endpoint: s.endpoint,
|
|
67
|
-
p256dh: s.p256dh,
|
|
68
|
-
auth: s.auth,
|
|
69
|
-
session_id: s.sessionId ?? null,
|
|
70
|
-
created_at: s.createdAt,
|
|
71
|
-
}),
|
|
72
|
-
list: (opts) => {
|
|
73
|
-
const rows = (opts?.sessionId ? listScopedStmt.all(opts.sessionId) : listAllStmt.all());
|
|
74
|
-
return rows.map(rowToSub);
|
|
75
|
-
},
|
|
76
|
-
remove: (endpoint) => void removeStmt.run(endpoint),
|
|
77
|
-
close: () => db.close(),
|
|
78
|
-
};
|
|
79
|
-
}
|
package/dist/rate-limit.d.ts
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Lightweight, in-memory, per-client rate limiter for the global preHandler. The per-IP auth LOCKOUT
|
|
3
|
-
* (auth.ts) only counts FAILED auth attempts and collapses behind a proxy; this adds a generous request
|
|
4
|
-
* cap so a flood (auth-guessing, scraping, accidental client loop) can't hammer the host — while staying
|
|
5
|
-
* well clear of any legitimate use (the PWA's periodic /sessions + /version poll, a burst of opens).
|
|
6
|
-
*
|
|
7
|
-
* Algorithm: a TOKEN BUCKET per client key (request.ip, honoring trustProxy — same key as the lockout).
|
|
8
|
-
* `capacity` tokens, refilled continuously at `capacity / windowMs` tokens/ms; each request spends one.
|
|
9
|
-
* A token bucket allows a short BURST (up to `capacity`) yet bounds the sustained rate — friendlier to
|
|
10
|
-
* bursty real traffic than a hard fixed window. PURE/I-O-free + an injectable clock so it is unit-testable.
|
|
11
|
-
*
|
|
12
|
-
* SAFE DEFAULTS (see server-config.ts): 600 requests / 60s sustained, burst 120 — orders of magnitude
|
|
13
|
-
* above the app's real cadence. Env-overridable and fully DISABLE-able (enabled:false → allow everything).
|
|
14
|
-
*/
|
|
15
|
-
export interface RateLimiterOptions {
|
|
16
|
-
/** Sustained budget: `capacity` requests refill over `windowMs` (→ capacity/windowMs tokens per ms). */
|
|
17
|
-
capacity: number;
|
|
18
|
-
/** Refill window in ms for the full capacity. */
|
|
19
|
-
windowMs: number;
|
|
20
|
-
/** Max burst (bucket size). Defaults to `capacity`. A larger value tolerates a bigger instantaneous spike. */
|
|
21
|
-
burst?: number;
|
|
22
|
-
/** Master switch. false → {@link RateLimiter.take} always allows (the limiter is off). Default true. */
|
|
23
|
-
enabled?: boolean;
|
|
24
|
-
/** Injectable clock for tests. Default Date.now. */
|
|
25
|
-
now?: () => number;
|
|
26
|
-
}
|
|
27
|
-
export interface RateLimitDecision {
|
|
28
|
-
/** True → allow the request (a token was spent or the limiter is off). */
|
|
29
|
-
allowed: boolean;
|
|
30
|
-
/** When NOT allowed, seconds the client should wait before retrying (for the Retry-After header). */
|
|
31
|
-
retryAfterSeconds: number;
|
|
32
|
-
}
|
|
33
|
-
export declare class RateLimiter {
|
|
34
|
-
private readonly capacity;
|
|
35
|
-
private readonly windowMs;
|
|
36
|
-
private readonly burst;
|
|
37
|
-
private readonly enabled;
|
|
38
|
-
private readonly now;
|
|
39
|
-
private readonly ratePerMs;
|
|
40
|
-
private readonly clients;
|
|
41
|
-
constructor(opts: RateLimiterOptions);
|
|
42
|
-
/**
|
|
43
|
-
* Account for ONE request from `clientKey`. Refills the bucket by elapsed time, then spends a token if
|
|
44
|
-
* one is available. Returns {allowed:true} when a token was spent (or the limiter is disabled), else
|
|
45
|
-
* {allowed:false, retryAfterSeconds} — the whole-seconds wait until the next token is available.
|
|
46
|
-
*/
|
|
47
|
-
take(clientKey: string): RateLimitDecision;
|
|
48
|
-
/** Drop fully-refilled buckets so the map stays bounded (a one-shot client doesn't linger forever). */
|
|
49
|
-
private sweepFull;
|
|
50
|
-
/** TEST ONLY: number of tracked client buckets. */
|
|
51
|
-
trackedClientCount(): number;
|
|
52
|
-
}
|
package/dist/rate-limit.js
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Lightweight, in-memory, per-client rate limiter for the global preHandler. The per-IP auth LOCKOUT
|
|
3
|
-
* (auth.ts) only counts FAILED auth attempts and collapses behind a proxy; this adds a generous request
|
|
4
|
-
* cap so a flood (auth-guessing, scraping, accidental client loop) can't hammer the host — while staying
|
|
5
|
-
* well clear of any legitimate use (the PWA's periodic /sessions + /version poll, a burst of opens).
|
|
6
|
-
*
|
|
7
|
-
* Algorithm: a TOKEN BUCKET per client key (request.ip, honoring trustProxy — same key as the lockout).
|
|
8
|
-
* `capacity` tokens, refilled continuously at `capacity / windowMs` tokens/ms; each request spends one.
|
|
9
|
-
* A token bucket allows a short BURST (up to `capacity`) yet bounds the sustained rate — friendlier to
|
|
10
|
-
* bursty real traffic than a hard fixed window. PURE/I-O-free + an injectable clock so it is unit-testable.
|
|
11
|
-
*
|
|
12
|
-
* SAFE DEFAULTS (see server-config.ts): 600 requests / 60s sustained, burst 120 — orders of magnitude
|
|
13
|
-
* above the app's real cadence. Env-overridable and fully DISABLE-able (enabled:false → allow everything).
|
|
14
|
-
*/
|
|
15
|
-
export class RateLimiter {
|
|
16
|
-
capacity;
|
|
17
|
-
windowMs;
|
|
18
|
-
burst;
|
|
19
|
-
enabled;
|
|
20
|
-
now;
|
|
21
|
-
ratePerMs;
|
|
22
|
-
clients = new Map();
|
|
23
|
-
constructor(opts) {
|
|
24
|
-
this.capacity = opts.capacity;
|
|
25
|
-
this.windowMs = opts.windowMs;
|
|
26
|
-
this.burst = opts.burst ?? opts.capacity;
|
|
27
|
-
this.enabled = opts.enabled ?? true;
|
|
28
|
-
this.now = opts.now ?? Date.now;
|
|
29
|
-
// Guard against a degenerate config (windowMs<=0): treat as instant refill (effectively no sustained cap).
|
|
30
|
-
this.ratePerMs = this.windowMs > 0 ? this.capacity / this.windowMs : Number.POSITIVE_INFINITY;
|
|
31
|
-
}
|
|
32
|
-
/**
|
|
33
|
-
* Account for ONE request from `clientKey`. Refills the bucket by elapsed time, then spends a token if
|
|
34
|
-
* one is available. Returns {allowed:true} when a token was spent (or the limiter is disabled), else
|
|
35
|
-
* {allowed:false, retryAfterSeconds} — the whole-seconds wait until the next token is available.
|
|
36
|
-
*/
|
|
37
|
-
take(clientKey) {
|
|
38
|
-
if (!this.enabled)
|
|
39
|
-
return { allowed: true, retryAfterSeconds: 0 };
|
|
40
|
-
this.sweepFull();
|
|
41
|
-
const t = this.now();
|
|
42
|
-
const state = this.clients.get(clientKey) ?? { tokens: this.burst, updatedAt: t };
|
|
43
|
-
// Refill: add the tokens accrued since the last touch, capped at the burst size.
|
|
44
|
-
const elapsed = Math.max(0, t - state.updatedAt);
|
|
45
|
-
state.tokens = Math.min(this.burst, state.tokens + elapsed * this.ratePerMs);
|
|
46
|
-
state.updatedAt = t;
|
|
47
|
-
if (state.tokens >= 1) {
|
|
48
|
-
state.tokens -= 1;
|
|
49
|
-
this.clients.set(clientKey, state);
|
|
50
|
-
return { allowed: true, retryAfterSeconds: 0 };
|
|
51
|
-
}
|
|
52
|
-
// Out of tokens: time until one more accrues.
|
|
53
|
-
const msToNext = this.ratePerMs > 0 ? (1 - state.tokens) / this.ratePerMs : Number.POSITIVE_INFINITY;
|
|
54
|
-
const retryAfterSeconds = Number.isFinite(msToNext) ? Math.max(1, Math.ceil(msToNext / 1000)) : 1;
|
|
55
|
-
this.clients.set(clientKey, state);
|
|
56
|
-
return { allowed: false, retryAfterSeconds };
|
|
57
|
-
}
|
|
58
|
-
/** Drop fully-refilled buckets so the map stays bounded (a one-shot client doesn't linger forever). */
|
|
59
|
-
sweepFull() {
|
|
60
|
-
const t = this.now();
|
|
61
|
-
for (const [key, state] of this.clients) {
|
|
62
|
-
const elapsed = Math.max(0, t - state.updatedAt);
|
|
63
|
-
const tokens = Math.min(this.burst, state.tokens + elapsed * this.ratePerMs);
|
|
64
|
-
if (tokens >= this.burst)
|
|
65
|
-
this.clients.delete(key);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
/** TEST ONLY: number of tracked client buckets. */
|
|
69
|
-
trackedClientCount() {
|
|
70
|
-
return this.clients.size;
|
|
71
|
-
}
|
|
72
|
-
}
|
package/dist/server-config.d.ts
DELETED
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import type { ServerConfig } from "./config.js";
|
|
2
|
-
/** Safe defaults for the new security/limit controls (generous; never bother a real self-hoster). */
|
|
3
|
-
export declare const DEFAULT_RATE_LIMIT_RPM = 600;
|
|
4
|
-
export declare const DEFAULT_RATE_LIMIT_BURST = 120;
|
|
5
|
-
export declare const DEFAULT_MAX_SESSIONS = 25;
|
|
6
|
-
export interface ServerRuntimeConfig {
|
|
7
|
-
/** TCP port to listen on. Default 4280. */
|
|
8
|
-
port: number;
|
|
9
|
-
/** Address to bind. Default "127.0.0.1" (loopback). */
|
|
10
|
-
bindAddress: string;
|
|
11
|
-
/** Mandatory access token. Optional only for loopback binds (spec §9). */
|
|
12
|
-
accessToken?: string;
|
|
13
|
-
/** Root directory the file picker / fs-service is confined to. Default $HOME or cwd. */
|
|
14
|
-
fsRoot: string;
|
|
15
|
-
/** Max bytes accepted for an upload. Default 25 MiB. */
|
|
16
|
-
maxUploadBytes: number;
|
|
17
|
-
/** Host data dir for the SQLite DB + access token file. */
|
|
18
|
-
dataDir: string;
|
|
19
|
-
/**
|
|
20
|
-
* Trust X-Forwarded-* (passed to Fastify as `trustProxy`). Default false. PREFER a specific proxy IP/CIDR
|
|
21
|
-
* (e.g. "127.0.0.1" for a same-host cloudflared/Caddy) over `true`: `true` trusts EVERY hop and takes the
|
|
22
|
-
* left-most XFF entry, which a client can prepend to spoof `request.ip` and poison the rate limiter. A
|
|
23
|
-
* string here is Fastify's trustProxy spec (IP, CIDR, or comma-list); boolean true = trust all hops.
|
|
24
|
-
*/
|
|
25
|
-
trustProxy?: boolean | string;
|
|
26
|
-
/**
|
|
27
|
-
* The public-facing origin (ROAMCODE_PUBLIC_URL). Used by the Origin/CSWSH guard as an allow-listed
|
|
28
|
-
* origin (the PWA is installed under this when behind a tunnel) AND by start.ts for push deep-links.
|
|
29
|
-
*/
|
|
30
|
-
publicUrl?: string;
|
|
31
|
-
/**
|
|
32
|
-
* Extra Origins the CSWSH guard allows, beyond same-origin / loopback / publicUrl
|
|
33
|
-
* (ROAMCODE_ALLOWED_ORIGINS, comma-separated). Empty by default — the safe default already lets the
|
|
34
|
-
* real app through; this is only to permit an additional known front-end origin.
|
|
35
|
-
*/
|
|
36
|
-
allowedOrigins: string[];
|
|
37
|
-
/**
|
|
38
|
-
* Global per-client request rate limit (token bucket). `rateLimitRpm` requests/minute sustained,
|
|
39
|
-
* `rateLimitBurst` instantaneous. Set rateLimitRpm to 0 to DISABLE the limiter entirely.
|
|
40
|
-
* (ROAMCODE_RATE_LIMIT_RPM / ROAMCODE_RATE_LIMIT_BURST.)
|
|
41
|
-
*/
|
|
42
|
-
rateLimitRpm: number;
|
|
43
|
-
rateLimitBurst: number;
|
|
44
|
-
/** Max concurrent LIVE sessions/processes; POST /sessions is refused (429) at the cap. 0 disables the
|
|
45
|
-
* cap. (ROAMCODE_MAX_SESSIONS.) */
|
|
46
|
-
maxSessions: number;
|
|
47
|
-
/** Kill running terminal sessions with NO attached client that have been idle longer than this many ms.
|
|
48
|
-
* 0 (default) DISABLES it — sessions survive a disconnect indefinitely for later reattach. Opt-in
|
|
49
|
-
* (SESSION_IDLE_TTL_MS) for hosts that want to bound detached claude+tmux accumulation. Optional in the
|
|
50
|
-
* type (older config literals / tests omit it); consumers treat an absent value as 0 (disabled). */
|
|
51
|
-
sessionIdleTtlMs?: number;
|
|
52
|
-
/** The claude-spawn config (claudeBin). */
|
|
53
|
-
claude: ServerConfig;
|
|
54
|
-
/** Codex CLI executable. Defaults to `codex`; independently overridable with CODEX_BIN. */
|
|
55
|
-
codexBin: string;
|
|
56
|
-
}
|
|
57
|
-
export declare function loadServerConfig(env: NodeJS.ProcessEnv): ServerRuntimeConfig;
|
|
58
|
-
export declare function isLoopbackAddress(address: string): boolean;
|
|
59
|
-
/** Spec §9: refuse to serve a non-loopback bind without a token. */
|
|
60
|
-
export declare function assertConfigAllowsStart(cfg: ServerRuntimeConfig): void;
|
package/dist/server-config.js
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
import { loadConfig } from "./config.js";
|
|
2
|
-
import { resolveDataDir } from "./data-dir.js";
|
|
3
|
-
import { parseAllowedOrigins } from "./origin-check.js";
|
|
4
|
-
/** Safe defaults for the new security/limit controls (generous; never bother a real self-hoster). */
|
|
5
|
-
export const DEFAULT_RATE_LIMIT_RPM = 600; // sustained requests/minute per client (way above the app's poll)
|
|
6
|
-
export const DEFAULT_RATE_LIMIT_BURST = 120; // instantaneous burst allowance (a flurry of opens/polls)
|
|
7
|
-
export const DEFAULT_MAX_SESSIONS = 25; // max concurrent LIVE claude sessions/processes
|
|
8
|
-
/**
|
|
9
|
-
* Parse an integer env option. An ABSENT or UNPARSEABLE value falls back to the default (lenient);
|
|
10
|
-
* a present-but-out-of-range value is a configuration ERROR (fail fast at boot).
|
|
11
|
-
*/
|
|
12
|
-
function parseIntOption(raw, fallback, name, range) {
|
|
13
|
-
if (raw === undefined)
|
|
14
|
-
return fallback;
|
|
15
|
-
const n = Number.parseInt(raw, 10);
|
|
16
|
-
if (Number.isNaN(n))
|
|
17
|
-
return fallback;
|
|
18
|
-
if ((range.min !== undefined && n < range.min) || (range.max !== undefined && n > range.max)) {
|
|
19
|
-
throw new Error(`invalid ${name}: ${raw} (must be ${range.min ?? "-∞"}..${range.max ?? "∞"})`);
|
|
20
|
-
}
|
|
21
|
-
return n;
|
|
22
|
-
}
|
|
23
|
-
export function loadServerConfig(env) {
|
|
24
|
-
// PORT 0 is a legitimate value: it tells the OS to pick a free ephemeral port (used in tests
|
|
25
|
-
// and for "let the OS choose"). Only a value ABOVE 65535 (or below 0) is a configuration error.
|
|
26
|
-
const port = parseIntOption(env.PORT, 4280, "PORT", { min: 0, max: 65535 });
|
|
27
|
-
const maxUploadBytes = parseIntOption(env.MAX_UPLOAD_BYTES, 26214400, "MAX_UPLOAD_BYTES", { min: 1 });
|
|
28
|
-
// ROAMCODE_* vars fall back to their legacy REMOTE_CODER_* names (pre-rename installs keep working
|
|
29
|
-
// across an OTA update without touching their service env).
|
|
30
|
-
const rc = (suffix) => env[`ROAMCODE_${suffix}`] ?? env[`REMOTE_CODER_${suffix}`];
|
|
31
|
-
// Rate limit: 0 rpm DISABLES the limiter (min:0); burst min 1 (a 0-size bucket would block everything).
|
|
32
|
-
const rateLimitRpm = parseIntOption(rc("RATE_LIMIT_RPM"), DEFAULT_RATE_LIMIT_RPM, "ROAMCODE_RATE_LIMIT_RPM", { min: 0 }); // prettier-ignore
|
|
33
|
-
const rateLimitBurst = parseIntOption(rc("RATE_LIMIT_BURST"), DEFAULT_RATE_LIMIT_BURST, "ROAMCODE_RATE_LIMIT_BURST", { min: 1 }); // prettier-ignore
|
|
34
|
-
// Concurrency cap: 0 DISABLES the cap (unbounded — the prior behavior, opt-out).
|
|
35
|
-
const maxSessions = parseIntOption(rc("MAX_SESSIONS"), DEFAULT_MAX_SESSIONS, "ROAMCODE_MAX_SESSIONS", { min: 0 }); // prettier-ignore
|
|
36
|
-
// 0 (default) DISABLES idle reaping — detached sessions survive for later reattach (the core feature).
|
|
37
|
-
const sessionIdleTtlMs = parseIntOption(env.SESSION_IDLE_TTL_MS, 0, "SESSION_IDLE_TTL_MS", { min: 0 });
|
|
38
|
-
const cfg = {
|
|
39
|
-
port,
|
|
40
|
-
bindAddress: env.BIND_ADDRESS ?? "127.0.0.1",
|
|
41
|
-
fsRoot: env.FS_ROOT ?? env.HOME ?? process.cwd(),
|
|
42
|
-
maxUploadBytes,
|
|
43
|
-
dataDir: resolveDataDir(env),
|
|
44
|
-
allowedOrigins: parseAllowedOrigins(rc("ALLOWED_ORIGINS")),
|
|
45
|
-
rateLimitRpm,
|
|
46
|
-
rateLimitBurst,
|
|
47
|
-
maxSessions,
|
|
48
|
-
sessionIdleTtlMs,
|
|
49
|
-
claude: loadConfig(env),
|
|
50
|
-
codexBin: env.CODEX_BIN ?? "codex",
|
|
51
|
-
};
|
|
52
|
-
if (env.ACCESS_TOKEN)
|
|
53
|
-
cfg.accessToken = env.ACCESS_TOKEN;
|
|
54
|
-
// "1"/"true" → trust ALL hops (convenient but spoofable). An IP/CIDR-looking value (has a "." or ":" and
|
|
55
|
-
// only address chars — e.g. "127.0.0.1", "10.0.0.0/8", "::1") → pass through as Fastify's trustProxy spec
|
|
56
|
-
// (the recommended form: trust ONLY that proxy hop). Anything else ("0", "false", "no", unset) → off.
|
|
57
|
-
const tp = (env.TRUST_PROXY ?? "").trim();
|
|
58
|
-
if (tp === "1" || tp.toLowerCase() === "true")
|
|
59
|
-
cfg.trustProxy = true;
|
|
60
|
-
else if (/[.:]/.test(tp) && /^[0-9a-fA-F.:,/\s]+$/.test(tp))
|
|
61
|
-
cfg.trustProxy = tp;
|
|
62
|
-
const publicUrl = (rc("PUBLIC_URL") ?? "").trim();
|
|
63
|
-
if (publicUrl)
|
|
64
|
-
cfg.publicUrl = publicUrl;
|
|
65
|
-
return cfg;
|
|
66
|
-
}
|
|
67
|
-
export function isLoopbackAddress(address) {
|
|
68
|
-
if (address === "::1" || address === "localhost")
|
|
69
|
-
return true;
|
|
70
|
-
return /^127(?:\.\d{1,3}){3}$/.test(address);
|
|
71
|
-
}
|
|
72
|
-
/** Spec §9: refuse to serve a non-loopback bind without a token. */
|
|
73
|
-
export function assertConfigAllowsStart(cfg) {
|
|
74
|
-
if (!isLoopbackAddress(cfg.bindAddress) && !cfg.accessToken) {
|
|
75
|
-
throw new Error(`refusing to start: bind address ${cfg.bindAddress} is not loopback and no ACCESS_TOKEN is set (set ACCESS_TOKEN or bind to 127.0.0.1)`);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
export declare function buildServicePath(nodePath: string, home: string): string;
|
|
2
|
-
export interface RenderLaunchdOptions {
|
|
3
|
-
label: string;
|
|
4
|
-
nodePath?: string;
|
|
5
|
-
cliPath?: string;
|
|
6
|
-
executablePath?: string;
|
|
7
|
-
dataDir: string;
|
|
8
|
-
installRoot?: string;
|
|
9
|
-
servicePath?: string;
|
|
10
|
-
}
|
|
11
|
-
export declare function renderLaunchdPlist(opts: RenderLaunchdOptions): string;
|
|
12
|
-
export interface RenderSystemdOptions {
|
|
13
|
-
nodePath?: string;
|
|
14
|
-
cliPath?: string;
|
|
15
|
-
executablePath?: string;
|
|
16
|
-
dataDir: string;
|
|
17
|
-
installRoot?: string;
|
|
18
|
-
servicePath?: string;
|
|
19
|
-
}
|
|
20
|
-
export declare function renderSystemdUnit(opts: RenderSystemdOptions): string;
|
|
21
|
-
export interface ServiceRecord {
|
|
22
|
-
manager: "launchd" | "systemd";
|
|
23
|
-
label: string;
|
|
24
|
-
path: string;
|
|
25
|
-
launcherPath?: string;
|
|
26
|
-
installRoot?: string;
|
|
27
|
-
}
|
|
28
|
-
export interface InstallServiceContext {
|
|
29
|
-
nodePath: string;
|
|
30
|
-
cliPath?: string;
|
|
31
|
-
executablePath?: string;
|
|
32
|
-
dataDir: string;
|
|
33
|
-
installRoot?: string;
|
|
34
|
-
home?: string;
|
|
35
|
-
os?: NodeJS.Platform;
|
|
36
|
-
label?: string;
|
|
37
|
-
}
|
|
38
|
-
export interface InstallServiceResult {
|
|
39
|
-
path: string;
|
|
40
|
-
instructions: string;
|
|
41
|
-
record: ServiceRecord;
|
|
42
|
-
}
|
|
43
|
-
export declare function installService(ctx: InstallServiceContext): InstallServiceResult;
|
|
44
|
-
export declare function readServiceRecord(dataDir: string): ServiceRecord | undefined;
|
|
45
|
-
export declare function migrateServiceToLauncher(opts: {
|
|
46
|
-
dataDir: string;
|
|
47
|
-
installRoot: string;
|
|
48
|
-
launcherPath: string;
|
|
49
|
-
nodePath: string;
|
|
50
|
-
}): ServiceRecord | undefined;
|
|
51
|
-
export declare function restartService(record: ServiceRecord): {
|
|
52
|
-
ok: boolean;
|
|
53
|
-
error?: string;
|
|
54
|
-
};
|
|
55
|
-
/** Enable the per-user service and start it immediately. This is only called by the explicit
|
|
56
|
-
* `roamcode install` command; OTA updates use restartService and never change enablement. */
|
|
57
|
-
export declare function enableService(record: ServiceRecord): {
|
|
58
|
-
ok: boolean;
|
|
59
|
-
error?: string;
|
|
60
|
-
};
|