@oharness/sync 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/feed.d.ts ADDED
@@ -0,0 +1,68 @@
1
+ import type { WireRecord, WireSession } from "./protocol.ts";
2
+ /**
3
+ * One project's history, as an append-only log.
4
+ *
5
+ * The only question a syncing client asks is "what has happened since cursor
6
+ * N" — which is what a log answers natively and what a document store has to
7
+ * be indexed into answering. So the server's storage is the same shape as its
8
+ * protocol: append, and read forward from an offset.
9
+ *
10
+ * A database would buy indexed queries this does not need and cost a dependency
11
+ * or an unstable built-in (`node:sqlite` still needs a flag on the oldest Node
12
+ * this package supports). If multi-tenant scale ever arrives, `Feed` is the
13
+ * seam to reimplement — nothing above it knows how a cursor is stored.
14
+ */
15
+ export type Entry = ({
16
+ cursor: number;
17
+ kind: "record";
18
+ } & WireRecord) | ({
19
+ cursor: number;
20
+ kind: "session";
21
+ } & WireSession);
22
+ /**
23
+ * Who a feed belongs to.
24
+ *
25
+ * A person by default, and a team when a team is paying for the seat. The
26
+ * distinction is the whole of session collaboration: two people cannot hand
27
+ * each other a session by both being logged in, they have to be reading and
28
+ * writing one feed, and a feed keyed by whoever pushed first is not that.
29
+ *
30
+ * The kind is part of the directory name rather than implied by the id, so a
31
+ * team named after a user's id cannot land on that user's feed.
32
+ */
33
+ export type Owner = {
34
+ kind: "user" | "team";
35
+ id: string;
36
+ };
37
+ export declare function ownerDir(owner: Owner): string;
38
+ export declare class Feed {
39
+ private readonly file;
40
+ private state;
41
+ /** Appends run one at a time: two writers would hand out the same cursor. */
42
+ private tail;
43
+ private readonly waiters;
44
+ constructor(root: string, owner: Owner, project: string);
45
+ private load;
46
+ cursor(): Promise<number>;
47
+ /**
48
+ * Add what the caller does not already have.
49
+ *
50
+ * Records are identified by the client, so "already held" is a set lookup
51
+ * rather than a comparison — pushing the same batch twice after a failed
52
+ * response adds nothing the second time.
53
+ */
54
+ append(records: WireRecord[], sessions: WireSession[]): Promise<{
55
+ cursor: number;
56
+ accepted: number;
57
+ }>;
58
+ /** Everything after `since`, oldest first. */
59
+ since(since: number, limit: number): Promise<{
60
+ entries: Entry[];
61
+ more: boolean;
62
+ }>;
63
+ /** Resolve when the feed moves past `since`, or when the wait runs out. */
64
+ wait(since: number, seconds: number): Promise<void>;
65
+ private wake;
66
+ }
67
+ /** Projects anything has been pushed to, under one owner. */
68
+ export declare function listProjects(root: string, owner: Owner): Promise<string[]>;
package/dist/feed.js ADDED
@@ -0,0 +1,228 @@
1
+ import { appendFile, mkdir, open, readdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { parseJson } from "@oharness/engine/util/json.ts";
4
+ /** How often a byte offset is remembered, so a pull seeks instead of scans. */
5
+ const CHECKPOINT_EVERY = 128;
6
+ export function ownerDir(owner) {
7
+ return `${owner.kind}-${safe(owner.id)}`;
8
+ }
9
+ export class Feed {
10
+ file;
11
+ state;
12
+ /** Appends run one at a time: two writers would hand out the same cursor. */
13
+ tail = Promise.resolve();
14
+ waiters = new Set();
15
+ constructor(root, owner, project) {
16
+ this.file = path.join(root, "feed", ownerDir(owner), `${safe(project)}.jsonl`);
17
+ }
18
+ load() {
19
+ this.state ??= scan(this.file);
20
+ return this.state;
21
+ }
22
+ async cursor() {
23
+ return (await this.load()).lastCursor;
24
+ }
25
+ /**
26
+ * Add what the caller does not already have.
27
+ *
28
+ * Records are identified by the client, so "already held" is a set lookup
29
+ * rather than a comparison — pushing the same batch twice after a failed
30
+ * response adds nothing the second time.
31
+ */
32
+ append(records, sessions) {
33
+ const run = async () => {
34
+ const state = await this.load();
35
+ const lines = [];
36
+ let cursor = state.lastCursor;
37
+ for (const record of records) {
38
+ const key = `${record.session}/${record.id}`;
39
+ if (state.seen.has(key))
40
+ continue;
41
+ state.seen.add(key);
42
+ lines.push({ ...record, cursor: ++cursor, kind: "record" });
43
+ }
44
+ for (const session of sessions) {
45
+ const held = state.sessions.get(session.id);
46
+ // Generation first, then time: a rewrite beats an older device's
47
+ // appends however recently its clock says they happened.
48
+ if (held && (held.gen > session.gen ||
49
+ (held.gen === session.gen && held.updatedAt >= session.updatedAt)))
50
+ continue;
51
+ state.sessions.set(session.id, { gen: session.gen, updatedAt: session.updatedAt });
52
+ lines.push({ ...session, cursor: ++cursor, kind: "session" });
53
+ }
54
+ if (lines.length) {
55
+ await mkdir(path.dirname(this.file), { recursive: true });
56
+ const body = lines.map((line) => JSON.stringify(line)).join("\n") + "\n";
57
+ await appendFile(this.file, body, "utf8");
58
+ for (const line of lines) {
59
+ state.size += Buffer.byteLength(JSON.stringify(line)) + 1;
60
+ const last = state.checkpoints[state.checkpoints.length - 1];
61
+ if (!last || line.cursor - last.cursor >= CHECKPOINT_EVERY) {
62
+ state.checkpoints.push({ cursor: line.cursor, offset: state.size });
63
+ }
64
+ }
65
+ state.lastCursor = cursor;
66
+ this.wake();
67
+ }
68
+ return { cursor: state.lastCursor, accepted: lines.length };
69
+ };
70
+ const next = this.tail.then(run, run);
71
+ // The chain must survive a failed append, or every later push inherits the
72
+ // rejection and the feed wedges.
73
+ this.tail = next.catch(() => { });
74
+ return next;
75
+ }
76
+ /** Everything after `since`, oldest first. */
77
+ async since(since, limit) {
78
+ const state = await this.load();
79
+ if (since >= state.lastCursor)
80
+ return { entries: [], more: false };
81
+ // Start from the last checkpoint at or before the caller's cursor rather
82
+ // than the top of the file: catching up should not cost a full history read.
83
+ let offset = 0;
84
+ for (const point of state.checkpoints) {
85
+ if (point.cursor > since)
86
+ break;
87
+ offset = point.offset;
88
+ }
89
+ const entries = [];
90
+ let more = false;
91
+ for (const line of await readFrom(this.file, offset)) {
92
+ const parsed = parseJson(line);
93
+ if (!parsed.ok || parsed.value.cursor <= since)
94
+ continue;
95
+ if (entries.length >= limit) {
96
+ more = true;
97
+ break;
98
+ }
99
+ entries.push(parsed.value);
100
+ }
101
+ return { entries, more };
102
+ }
103
+ /** Resolve when the feed moves past `since`, or when the wait runs out. */
104
+ wait(since, seconds) {
105
+ return new Promise((resolve) => {
106
+ let done = false;
107
+ const finish = () => {
108
+ if (done)
109
+ return;
110
+ done = true;
111
+ this.waiters.delete(finish);
112
+ clearTimeout(timer);
113
+ resolve();
114
+ };
115
+ const timer = setTimeout(finish, seconds * 1000);
116
+ // Unref: a pending long-poll must not hold the process open on shutdown.
117
+ timer.unref?.();
118
+ this.waiters.add(finish);
119
+ void this.cursor().then((now) => {
120
+ if (now > since)
121
+ finish();
122
+ });
123
+ });
124
+ }
125
+ wake() {
126
+ for (const waiter of [...this.waiters])
127
+ waiter();
128
+ }
129
+ }
130
+ /** Projects anything has been pushed to, under one owner. */
131
+ export async function listProjects(root, owner) {
132
+ const dir = path.join(root, "feed", ownerDir(owner));
133
+ const entries = await readdir(dir).catch(() => []);
134
+ return entries.filter((name) => name.endsWith(".jsonl")).map((name) => name.slice(0, -6)).sort();
135
+ }
136
+ /**
137
+ * Rebuild what is only kept in memory: the cursor, the checkpoints, and what
138
+ * has already been seen. One pass, on first touch of a feed.
139
+ */
140
+ async function scan(file) {
141
+ const state = {
142
+ lastCursor: 0,
143
+ checkpoints: [],
144
+ seen: new Set(),
145
+ sessions: new Map(),
146
+ size: 0,
147
+ };
148
+ let handle;
149
+ try {
150
+ handle = await open(file, "r");
151
+ }
152
+ catch {
153
+ return state;
154
+ }
155
+ try {
156
+ let carry = "";
157
+ for await (const chunk of handle.createReadStream({ encoding: "utf8" })) {
158
+ const text = carry + chunk;
159
+ const lines = text.split("\n");
160
+ carry = lines.pop() ?? "";
161
+ for (const line of lines) {
162
+ state.size += Buffer.byteLength(line) + 1;
163
+ if (line.trim() === "")
164
+ continue;
165
+ const parsed = parseJson(line);
166
+ // A torn line from a killed append costs that line, not the feed.
167
+ if (!parsed.ok || typeof parsed.value.cursor !== "number")
168
+ continue;
169
+ remember(state, parsed.value);
170
+ }
171
+ }
172
+ if (carry.trim() !== "") {
173
+ const parsed = parseJson(carry);
174
+ if (parsed.ok && typeof parsed.value.cursor === "number") {
175
+ state.size += Buffer.byteLength(carry) + 1;
176
+ remember(state, parsed.value);
177
+ }
178
+ }
179
+ }
180
+ finally {
181
+ await handle.close();
182
+ }
183
+ return state;
184
+ }
185
+ function remember(state, entry) {
186
+ state.lastCursor = Math.max(state.lastCursor, entry.cursor);
187
+ if (entry.kind === "record")
188
+ state.seen.add(`${entry.session}/${entry.id}`);
189
+ if (entry.kind === "session") {
190
+ state.sessions.set(entry.id, { gen: entry.gen, updatedAt: entry.updatedAt });
191
+ }
192
+ const last = state.checkpoints[state.checkpoints.length - 1];
193
+ if (!last || entry.cursor - last.cursor >= CHECKPOINT_EVERY) {
194
+ state.checkpoints.push({ cursor: entry.cursor, offset: state.size });
195
+ }
196
+ }
197
+ async function readFrom(file, offset) {
198
+ const handle = await open(file, "r").catch(() => undefined);
199
+ if (!handle)
200
+ return [];
201
+ try {
202
+ const { size } = await handle.stat();
203
+ const start = Math.min(offset, size);
204
+ const buffer = Buffer.alloc(size - start);
205
+ if (buffer.length === 0)
206
+ return [];
207
+ await handle.read(buffer, 0, buffer.length, start);
208
+ return buffer.toString("utf8").split("\n");
209
+ }
210
+ finally {
211
+ await handle.close();
212
+ }
213
+ }
214
+ /**
215
+ * Ids reach the filesystem, so they are constrained rather than trusted: a
216
+ * project called `../../etc` must name a file inside the feed directory.
217
+ */
218
+ function safe(id) {
219
+ const cleaned = id
220
+ .replace(/[^A-Za-z0-9._-]/g, "_")
221
+ // Runs of dots go too. Separators are already gone by this point, so `..`
222
+ // cannot traverse anything — but a name that still reads as a traversal is
223
+ // one someone will eventually pass to a path join that does.
224
+ .replace(/\.{2,}/g, "_")
225
+ .replace(/^\./, "_");
226
+ return cleaned.slice(0, 128) || "_";
227
+ }
228
+ //# sourceMappingURL=feed.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"feed.js","sourceRoot":"","sources":["../src/feed.ts"],"names":[],"mappings":"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"}
package/dist/main.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/main.js ADDED
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ import { userPath } from "@oharness/engine/paths.ts";
3
+ import { errorMessage } from "@oharness/engine/core/errors.ts";
4
+ import { issueToken, readUsers, startSyncServer } from "./server.js";
5
+ /**
6
+ * The sync server's entry point — a separate binary because it is a separate
7
+ * decision. Running the harness never starts one.
8
+ */
9
+ const HELP = `oharness-sync — an optional server for syncing sessions between devices
10
+
11
+ Usage:
12
+ oharness-sync [options] Serve
13
+ oharness-sync token <user> Issue a token for a user, printed once
14
+
15
+ Options:
16
+ --port <n> Port to listen on (default 4483)
17
+ --host <addr> Interface to bind (default 127.0.0.1)
18
+ --root <dir> Where feeds are stored (default ~/.oharness/sync)
19
+ --team <id> With 'token': a team the holder may sync with. Repeatable.
20
+ -h, --help Show this help
21
+
22
+ A team is a shared feed. Everyone issued a token carrying the same team id
23
+ reads and writes one history for a project, which is what lets two people
24
+ continue each other's sessions; without one, a token reaches only its own.
25
+
26
+ This speaks plain HTTP and authenticates with a bearer token. Put it behind a
27
+ reverse proxy that terminates TLS before exposing it to a network you do not
28
+ control — a token sent in the clear is a token everyone on the path now holds.
29
+
30
+ It stores what it is given and hands it back in order. It never runs a model,
31
+ and it does not need to read anything it stores.
32
+
33
+ Environment, for a deployment fronting an account system. Set either the
34
+ secret or the JWKS URL and this also accepts tokens that system signed, so
35
+ subscribers are not issued one by hand:
36
+
37
+ OHARNESS_SYNC_JWT_SECRET HS256 shared secret (Supabase: the JWT secret)
38
+ OHARNESS_SYNC_JWKS_URL public keys, for asymmetric signing
39
+ OHARNESS_SYNC_JWT_ISSUER required iss, if set
40
+ OHARNESS_SYNC_JWT_AUDIENCE required aud (Supabase: authenticated)
41
+ OHARNESS_SYNC_TEAMS_CLAIM claim carrying team ids (default: teams)
42
+
43
+ Locally issued tokens keep working either way.`;
44
+ async function main() {
45
+ const argv = process.argv.slice(2);
46
+ if (argv.includes("-h") || argv.includes("--help")) {
47
+ console.log(HELP);
48
+ return;
49
+ }
50
+ const flag = (name) => {
51
+ const at = argv.indexOf(`--${name}`);
52
+ return at === -1 ? undefined : argv[at + 1];
53
+ };
54
+ const root = flag("root") ?? userPath("sync");
55
+ if (argv[0] === "token") {
56
+ const user = argv[1];
57
+ if (!user) {
58
+ console.error("usage: oharness-sync token <user> [--team <id>]...");
59
+ process.exitCode = 1;
60
+ return;
61
+ }
62
+ const teams = argv.flatMap((arg, at) => (arg === "--team" && argv[at + 1] ? [argv[at + 1]] : []));
63
+ const token = await issueToken(root, user, teams);
64
+ console.log(`\nToken for ${user}:\n\n ${token}\n`);
65
+ if (teams.length)
66
+ console.log(`Teams: ${teams.join(", ")}\n`);
67
+ console.log("Stored as a hash — this is the only time it is shown.\n");
68
+ return;
69
+ }
70
+ const port = Number.parseInt(flag("port") ?? "4483", 10);
71
+ if (Number.isNaN(port)) {
72
+ console.error("--port needs a number");
73
+ process.exitCode = 1;
74
+ return;
75
+ }
76
+ const host = flag("host");
77
+ const env = process.env;
78
+ const jwt = {
79
+ ...(env["OHARNESS_SYNC_JWT_SECRET"] ? { secret: env["OHARNESS_SYNC_JWT_SECRET"] } : {}),
80
+ ...(env["OHARNESS_SYNC_JWKS_URL"] ? { jwksUrl: env["OHARNESS_SYNC_JWKS_URL"] } : {}),
81
+ ...(env["OHARNESS_SYNC_JWT_ISSUER"] ? { issuer: env["OHARNESS_SYNC_JWT_ISSUER"] } : {}),
82
+ ...(env["OHARNESS_SYNC_JWT_AUDIENCE"] ? { audience: env["OHARNESS_SYNC_JWT_AUDIENCE"] } : {}),
83
+ ...(env["OHARNESS_SYNC_TEAMS_CLAIM"] ? { teamsClaim: env["OHARNESS_SYNC_TEAMS_CLAIM"] } : {}),
84
+ };
85
+ const signedIn = Boolean(jwt.secret || jwt.jwksUrl);
86
+ const server = await startSyncServer({
87
+ root,
88
+ port,
89
+ ...(host ? { host } : {}),
90
+ ...(signedIn ? { jwt } : {}),
91
+ });
92
+ const users = await readUsers(root);
93
+ console.log(`\noharness sync · ${server.url}`);
94
+ console.log(`storage ${root}`);
95
+ console.log(signedIn ? "accounts signed tokens, plus users.json" : "accounts users.json only");
96
+ if (!users.length && !signedIn) {
97
+ console.log("\n! No users yet — nothing can authenticate.");
98
+ console.log("! Run: oharness-sync token <your-name>\n");
99
+ }
100
+ else if (users.length) {
101
+ console.log(`local tokens ${users.map((user) => user.id).join(", ")}\n`);
102
+ }
103
+ else {
104
+ console.log("");
105
+ }
106
+ const stop = () => void server.close().then(() => process.exit(0));
107
+ process.on("SIGINT", stop);
108
+ process.on("SIGTERM", stop);
109
+ }
110
+ main().catch((err) => {
111
+ console.error(errorMessage(err));
112
+ process.exitCode = 1;
113
+ });
114
+ //# sourceMappingURL=main.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"main.js","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The wire, re-exported.
3
+ *
4
+ * The definitions live in the engine because both halves need them and only
5
+ * one direction of dependency is allowed: this package depends on the engine,
6
+ * so the engine cannot import from here. Keeping one copy is the point —
7
+ * a client and a server that each own their idea of the wire drift, and the
8
+ * drift shows up as records that silently fail to merge.
9
+ */
10
+ export type { PullResponse, PushRequest, PushResponse, WireRecord, WireSession } from "@oharness/engine/sync/protocol.ts";
11
+ export { DEFAULT_PULL_LIMIT, MAX_BODY_BYTES, MAX_PUSH_RECORDS, MAX_WAIT_SECONDS, } from "@oharness/engine/sync/protocol.ts";
@@ -0,0 +1,2 @@
1
+ export { DEFAULT_PULL_LIMIT, MAX_BODY_BYTES, MAX_PUSH_RECORDS, MAX_WAIT_SECONDS, } from "@oharness/engine/sync/protocol.ts";
2
+ //# sourceMappingURL=protocol.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"protocol.js","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AAUA,OAAO,EACL,kBAAkB,EAClB,cAAc,EACd,gBAAgB,EAChB,gBAAgB,GACjB,MAAM,mCAAmC,CAAC"}
@@ -0,0 +1,66 @@
1
+ import type { JwtOptions } from "@oharness/engine/util/jwt.ts";
2
+ /**
3
+ * The optional half of sync.
4
+ *
5
+ * Nothing here runs unless someone starts it. A harness with no `sync` block in
6
+ * its config never opens a socket, and the local store is complete on its own —
7
+ * this exists because two machines cannot hand each other a transcript without
8
+ * somewhere to leave it, not because the harness needs a backend.
9
+ *
10
+ * It is deliberately dumb: it orders opaque blobs and hands them back. It does
11
+ * not parse messages, call models, or know what a tool is. That is what keeps
12
+ * it safe to leave running, and what keeps the message format free to change
13
+ * without a deploy.
14
+ *
15
+ * **TLS is not handled here.** Put it behind a reverse proxy that terminates
16
+ * HTTPS. A bearer token over plaintext on an untrusted network is a token
17
+ * anyone on that network now has.
18
+ */
19
+ export interface SyncUser {
20
+ id: string;
21
+ /** sha256 of the bearer token. The token itself is never stored. */
22
+ tokenHash: string;
23
+ /**
24
+ * Teams this token may act for.
25
+ *
26
+ * Membership is recorded against the token rather than asked for per
27
+ * request, because the alternative is a sync server that has to call an
28
+ * account service to answer a push — putting someone else's availability in
29
+ * front of every write, on the one endpoint that exists to make transcripts
30
+ * durable.
31
+ */
32
+ teams?: string[];
33
+ }
34
+ export interface SyncServerOptions {
35
+ /** Where feeds are written. Defaults to `sync/` under the user directory. */
36
+ root: string;
37
+ port?: number;
38
+ /** Defaults to loopback. Bind wider only behind a proxy you control. */
39
+ host?: string;
40
+ /**
41
+ * Accept tokens the account system signed, alongside locally issued ones.
42
+ *
43
+ * This is what makes the hosted deployment possible: without it every new
44
+ * subscriber needs a shell on this container to be given a token, which is
45
+ * not a sign-up flow. Self-hosting stays exactly as it was — nothing here is
46
+ * required, and users.json keeps working whether or not it is set.
47
+ */
48
+ jwt?: JwtOptions;
49
+ }
50
+ export interface SyncServer {
51
+ url: string;
52
+ port: number;
53
+ close(): Promise<void>;
54
+ }
55
+ export declare function startSyncServer(opts: SyncServerOptions): Promise<SyncServer>;
56
+ /**
57
+ * Accounts, in a file.
58
+ *
59
+ * One entry is the single-user case and costs nothing; more entries isolate
60
+ * users by feed directory. There is no sign-up flow on purpose — issuing a
61
+ * token is an administrative act, and a sync server that can create accounts
62
+ * is a sync server with a registration endpoint to defend.
63
+ */
64
+ export declare function readUsers(root: string): Promise<SyncUser[]>;
65
+ /** Issue a token for a user, returning it once. Only the hash is stored. */
66
+ export declare function issueToken(root: string, id: string, teams?: string[]): Promise<string>;
package/dist/server.js ADDED
@@ -0,0 +1,241 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { errorMessage } from "@oharness/engine/core/errors.ts";
6
+ import { parseJson } from "@oharness/engine/util/json.ts";
7
+ import { Feed, listProjects } from "./feed.js";
8
+ import { JwtVerifier } from "@oharness/engine/util/jwt.ts";
9
+ import { DEFAULT_PULL_LIMIT, MAX_BODY_BYTES, MAX_PUSH_RECORDS, MAX_WAIT_SECONDS, } from "./protocol.js";
10
+ export async function startSyncServer(opts) {
11
+ const host = opts.host ?? "127.0.0.1";
12
+ const verifier = opts.jwt ? new JwtVerifier(opts.jwt) : undefined;
13
+ const feeds = new Map();
14
+ const feed = (owner, project) => {
15
+ const key = `${owner.kind}\u0000${owner.id}\u0000${project}`;
16
+ let held = feeds.get(key);
17
+ if (!held) {
18
+ held = new Feed(opts.root, owner, project);
19
+ feeds.set(key, held);
20
+ }
21
+ return held;
22
+ };
23
+ const server = createServer((req, res) => {
24
+ void handle(req, res).catch((err) => send(res, 500, { error: errorMessage(err) }));
25
+ });
26
+ async function handle(req, res) {
27
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
28
+ const route = `${req.method} ${url.pathname}`;
29
+ // Unauthenticated on purpose: a health check that needs a credential is a
30
+ // health check nobody wires up.
31
+ if (route === "GET /v1/health")
32
+ return send(res, 200, { ok: true });
33
+ const user = await authenticate(opts.root, req, verifier);
34
+ if (!user) {
35
+ res.setHeader("www-authenticate", "Bearer");
36
+ return send(res, 401, { error: "missing or bad token" });
37
+ }
38
+ /**
39
+ * Whose feed this request is for.
40
+ *
41
+ * Absent `team`, a person's own. Naming a team the token does not hold is
42
+ * refused rather than quietly answered with the caller's own feed — that
43
+ * substitution reads to the user as "the team has nothing in it", which is
44
+ * the one wrong answer they would act on.
45
+ */
46
+ const owned = (named) => {
47
+ const team = (named ?? "").trim();
48
+ if (!team)
49
+ return { kind: "user", id: user.id };
50
+ return (user.teams ?? []).includes(team) ? { kind: "team", id: team } : undefined;
51
+ };
52
+ switch (route) {
53
+ case "GET /v1/projects": {
54
+ const owner = owned(url.searchParams.get("team"));
55
+ if (!owner)
56
+ return send(res, 403, { error: "not a member of that team" });
57
+ return send(res, 200, { projects: await listProjects(opts.root, owner) });
58
+ }
59
+ case "POST /v1/push": {
60
+ const body = (await readJson(req));
61
+ const project = typeof body.project === "string" ? body.project : "";
62
+ if (!project)
63
+ return send(res, 400, { error: "project is required" });
64
+ const records = (Array.isArray(body.records) ? body.records : []).filter(isRecord);
65
+ const sessions = (Array.isArray(body.sessions) ? body.sessions : []).filter(isSession);
66
+ // Refused rather than truncated: accepting half a batch and reporting
67
+ // success would lose turns the client then considers delivered.
68
+ if (records.length > MAX_PUSH_RECORDS || sessions.length > MAX_PUSH_RECORDS) {
69
+ return send(res, 413, { error: `at most ${MAX_PUSH_RECORDS} entries per push` });
70
+ }
71
+ const owner = owned(typeof body.team === "string" ? body.team : undefined);
72
+ if (!owner)
73
+ return send(res, 403, { error: "not a member of that team" });
74
+ const result = await feed(owner, project).append(records, sessions);
75
+ return send(res, 200, result);
76
+ }
77
+ case "GET /v1/pull": {
78
+ const project = url.searchParams.get("project") ?? "";
79
+ if (!project)
80
+ return send(res, 400, { error: "project is required" });
81
+ const since = Number.parseInt(url.searchParams.get("since") ?? "0", 10) || 0;
82
+ const limit = clamp(Number.parseInt(url.searchParams.get("limit") ?? "", 10) || DEFAULT_PULL_LIMIT, 1, DEFAULT_PULL_LIMIT);
83
+ const wait = clamp(Number.parseInt(url.searchParams.get("wait") ?? "0", 10) || 0, 0, MAX_WAIT_SECONDS);
84
+ const owner = owned(url.searchParams.get("team"));
85
+ if (!owner)
86
+ return send(res, 403, { error: "not a member of that team" });
87
+ const target = feed(owner, project);
88
+ // A long poll is how the second device sees a turn while it is being
89
+ // typed rather than on the next timer.
90
+ if (wait > 0 && since >= (await target.cursor()))
91
+ await target.wait(since, wait);
92
+ const { entries, more } = await target.since(since, limit);
93
+ const answer = {
94
+ cursor: entries.length ? entries[entries.length - 1].cursor : since,
95
+ records: entries.filter((entry) => entry.kind === "record").map(stripCursor),
96
+ sessions: entries.filter((entry) => entry.kind === "session").map(stripCursor),
97
+ more,
98
+ };
99
+ return send(res, 200, answer);
100
+ }
101
+ default:
102
+ return send(res, 404, { error: "no such endpoint" });
103
+ }
104
+ }
105
+ await new Promise((resolve, reject) => {
106
+ server.once("error", reject);
107
+ server.listen(opts.port ?? 0, host, () => {
108
+ server.off("error", reject);
109
+ resolve();
110
+ });
111
+ });
112
+ const address = server.address();
113
+ const port = typeof address === "object" && address ? address.port : (opts.port ?? 0);
114
+ return {
115
+ url: `http://${host}:${port}`,
116
+ port,
117
+ close: () => closeServer(server),
118
+ };
119
+ }
120
+ // --- users -----------------------------------------------------------------
121
+ /**
122
+ * Accounts, in a file.
123
+ *
124
+ * One entry is the single-user case and costs nothing; more entries isolate
125
+ * users by feed directory. There is no sign-up flow on purpose — issuing a
126
+ * token is an administrative act, and a sync server that can create accounts
127
+ * is a sync server with a registration endpoint to defend.
128
+ */
129
+ export async function readUsers(root) {
130
+ const raw = await readFile(path.join(root, "users.json"), "utf8").catch(() => undefined);
131
+ if (!raw)
132
+ return [];
133
+ const parsed = parseJson(raw);
134
+ if (!parsed.ok || !Array.isArray(parsed.value.users))
135
+ return [];
136
+ return parsed.value.users
137
+ .filter((user) => typeof user?.id === "string" && typeof user?.tokenHash === "string")
138
+ .map((user) => ({
139
+ ...user,
140
+ // Normalised on read so a hand-edited users.json cannot turn a string
141
+ // into a membership that `includes` would match one character of.
142
+ teams: Array.isArray(user.teams) ? user.teams.filter((t) => typeof t === "string") : [],
143
+ }));
144
+ }
145
+ /** Issue a token for a user, returning it once. Only the hash is stored. */
146
+ export async function issueToken(root, id, teams = []) {
147
+ const token = randomBytes(32).toString("base64url");
148
+ const users = (await readUsers(root)).filter((user) => user.id !== id);
149
+ users.push({ id, tokenHash: hash(token), ...(teams.length ? { teams } : {}) });
150
+ const file = path.join(root, "users.json");
151
+ await mkdir(root, { recursive: true });
152
+ const temp = `${file}.${process.pid}.tmp`;
153
+ await writeFile(temp, `${JSON.stringify({ users }, null, 2)}\n`, "utf8");
154
+ await rename(temp, file);
155
+ return token;
156
+ }
157
+ function hash(token) {
158
+ return createHash("sha256").update(token).digest("hex");
159
+ }
160
+ async function authenticate(root, req, verifier) {
161
+ const supplied = (req.headers.authorization ?? "").replace(/^Bearer\s+/i, "").trim();
162
+ if (!supplied)
163
+ return undefined;
164
+ // A locally issued token is 32 random bytes in base64url and can never hold
165
+ // a dot, so the two forms are told apart by shape rather than by trying one
166
+ // and falling through — which would make every bad JWT cost a file read.
167
+ if (verifier?.enabled && supplied.includes(".")) {
168
+ const claims = await verifier.verify(supplied);
169
+ if (!claims)
170
+ return undefined;
171
+ // No tokenHash: nothing about this account is stored here. The signature
172
+ // is the whole of what this server knows, which is the point — it holds no
173
+ // account records to keep in step with the account system.
174
+ return { id: claims.sub, tokenHash: "", teams: claims.teams };
175
+ }
176
+ const digest = Buffer.from(hash(supplied));
177
+ for (const user of await readUsers(root)) {
178
+ const known = Buffer.from(user.tokenHash);
179
+ // Constant time: comparing hashes rather than tokens keeps the stored form
180
+ // useless to whoever reads the file, and the timing safe either way.
181
+ if (known.length === digest.length && timingSafeEqual(known, digest))
182
+ return user;
183
+ }
184
+ return undefined;
185
+ }
186
+ // --- plumbing --------------------------------------------------------------
187
+ function isRecord(value) {
188
+ const record = value;
189
+ return Boolean(record &&
190
+ typeof record.session === "string" &&
191
+ typeof record.id === "string" &&
192
+ typeof record.seq === "number" &&
193
+ typeof record.device === "string");
194
+ }
195
+ function isSession(value) {
196
+ const session = value;
197
+ return Boolean(session &&
198
+ typeof session.id === "string" &&
199
+ typeof session.gen === "number" &&
200
+ typeof session.updatedAt === "number");
201
+ }
202
+ function stripCursor(entry) {
203
+ const { cursor: _cursor, kind: _kind, ...rest } = entry;
204
+ return rest;
205
+ }
206
+ function clamp(value, low, high) {
207
+ return Math.min(high, Math.max(low, value));
208
+ }
209
+ function send(res, status, body) {
210
+ const payload = JSON.stringify(body);
211
+ res.writeHead(status, {
212
+ "content-type": "application/json; charset=utf-8",
213
+ "content-length": Buffer.byteLength(payload),
214
+ });
215
+ res.end(payload);
216
+ }
217
+ async function readJson(req) {
218
+ const chunks = [];
219
+ let size = 0;
220
+ for await (const chunk of req) {
221
+ size += chunk.length;
222
+ // Checked while reading: buffering the whole body first is exactly what the
223
+ // limit exists to prevent.
224
+ if (size > MAX_BODY_BYTES)
225
+ throw new Error("request body too large");
226
+ chunks.push(chunk);
227
+ }
228
+ if (!chunks.length)
229
+ return {};
230
+ const parsed = parseJson(Buffer.concat(chunks).toString("utf8"));
231
+ if (!parsed.ok)
232
+ throw new Error("invalid JSON body");
233
+ return parsed.value;
234
+ }
235
+ function closeServer(server) {
236
+ return new Promise((resolve) => {
237
+ server.closeAllConnections?.();
238
+ server.close(() => resolve());
239
+ });
240
+ }
241
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@oharness/sync",
3
+ "version": "0.1.0",
4
+ "description": "The optional sync server: orders opaque transcript records so two machines can hand each other a session.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": ">=22.6.0"
9
+ },
10
+ "main": "./dist/server.js",
11
+ "types": "./dist/server.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "oharness-source": "./src/server.ts",
15
+ "types": "./dist/server.d.ts",
16
+ "default": "./dist/server.js"
17
+ },
18
+ "./*.ts": {
19
+ "oharness-source": "./src/*.ts",
20
+ "types": "./dist/*.d.ts",
21
+ "default": "./dist/*.js"
22
+ }
23
+ },
24
+ "bin": {
25
+ "oharness-sync": "./dist/main.js"
26
+ },
27
+ "files": [
28
+ "dist"
29
+ ],
30
+ "scripts": {
31
+ "build": "rm -rf dist && tsc -p tsconfig.build.json"
32
+ },
33
+ "dependencies": {
34
+ "@oharness/engine": "0.1.0"
35
+ }
36
+ }