parallelclaw 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.
- package/CHANGELOG.md +204 -0
- package/HELP.md +600 -0
- package/LICENSE +21 -0
- package/MULTI_MACHINE.md +152 -0
- package/README.md +417 -0
- package/README.ru.md +740 -0
- package/SYNC.md +844 -0
- package/bot/README.md +173 -0
- package/bot/config.js +66 -0
- package/bot/inbox.js +153 -0
- package/bot/index.js +294 -0
- package/bot/nexara.js +61 -0
- package/bot/poll.js +304 -0
- package/bot/search.js +155 -0
- package/bot/telegram.js +96 -0
- package/ingest.js +2712 -0
- package/lib/cli/index.js +1987 -0
- package/lib/config.js +220 -0
- package/lib/db-init.js +158 -0
- package/lib/hook/install.js +268 -0
- package/lib/import-telegram.js +158 -0
- package/lib/ingest-file.js +779 -0
- package/lib/notify-click-action.js +281 -0
- package/lib/openclaw-channel.js +643 -0
- package/lib/parse-cursor.js +172 -0
- package/lib/parse-obsidian.js +256 -0
- package/lib/parse-telegram-html.js +384 -0
- package/lib/parse.js +175 -0
- package/lib/render-markdown.js +0 -0
- package/lib/store-doc/canonicalize.js +116 -0
- package/lib/store-doc/detect.js +209 -0
- package/lib/store-doc/extract-title.js +162 -0
- package/lib/sync/auth.js +80 -0
- package/lib/sync/cert.js +144 -0
- package/lib/sync/cli.js +906 -0
- package/lib/sync/client.js +138 -0
- package/lib/sync/config.js +130 -0
- package/lib/sync/pair.js +145 -0
- package/lib/sync/pull.js +158 -0
- package/lib/sync/push.js +305 -0
- package/lib/sync/replicate.js +335 -0
- package/lib/sync/server.js +224 -0
- package/lib/sync/service.js +726 -0
- package/lib/tasks.js +215 -0
- package/lib/telegram-decisions.js +165 -0
- package/lib/telegram-discovery.js +373 -0
- package/lib/telegram-notify.js +272 -0
- package/lib/telegram-pending.js +200 -0
- package/lib/web/index.js +265 -0
- package/lib/web/routes/conversation.js +193 -0
- package/lib/web/routes/conversations.js +180 -0
- package/lib/web/routes/dashboard.js +175 -0
- package/lib/web/routes/pending.js +277 -0
- package/lib/web/routes/settings.js +226 -0
- package/lib/web/static/style.css +393 -0
- package/lib/web/templates.js +234 -0
- package/package.json +84 -0
- package/server.js +3816 -0
- package/skills/install-memex/README.md +109 -0
- package/skills/install-memex/SKILL.md +342 -0
- package/skills/install-memex/examples.md +294 -0
- package/skills/install-memex-claw/SKILL.md +423 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTPS client for talking to a peer's /sync/{health,push,pull} endpoints.
|
|
3
|
+
*
|
|
4
|
+
* Tracer-bullet shape: supports plain bearer + --insecure (skip TLS validation).
|
|
5
|
+
* Cert-fingerprint pinning lands in Day 5; for now we still send the request
|
|
6
|
+
* over TLS, just don't verify the cert chain.
|
|
7
|
+
*
|
|
8
|
+
* The client is intentionally dumb — it doesn't decide WHAT to push or pull,
|
|
9
|
+
* just shuttles bytes. The replication loop lives in lib/sync/replicate.js.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { request as httpsRequest } from 'node:https';
|
|
13
|
+
import { fingerprintsMatch } from './cert.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Create a client bound to one remote.
|
|
17
|
+
*
|
|
18
|
+
* opts:
|
|
19
|
+
* url — required, e.g. "https://localhost:8765"
|
|
20
|
+
* bearer — required, hex token
|
|
21
|
+
* insecure — bool. If true, skip TLS cert validation entirely (tracer-bullet).
|
|
22
|
+
* cert_fp — string. If set, pin the server cert to this SHA-256 fingerprint
|
|
23
|
+
* (overrides insecure — pinning is enabled even when insecure=true).
|
|
24
|
+
* timeoutMs — request timeout, default 30s
|
|
25
|
+
*/
|
|
26
|
+
export function createSyncClient(opts) {
|
|
27
|
+
if (!opts || !opts.url) throw new Error('createSyncClient: url required');
|
|
28
|
+
if (!opts.bearer) throw new Error('createSyncClient: bearer required');
|
|
29
|
+
|
|
30
|
+
const url = new URL(opts.url);
|
|
31
|
+
const insecure = !!opts.insecure;
|
|
32
|
+
const certFp = opts.cert_fp || null;
|
|
33
|
+
const timeoutMs = opts.timeoutMs ?? 30_000;
|
|
34
|
+
|
|
35
|
+
function makeRequest({ method, path, body }) {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const payload = body == null ? null : Buffer.from(JSON.stringify(body));
|
|
38
|
+
const headers = {
|
|
39
|
+
Authorization: `Bearer ${opts.bearer}`,
|
|
40
|
+
};
|
|
41
|
+
if (payload) {
|
|
42
|
+
headers['Content-Type'] = 'application/json';
|
|
43
|
+
headers['Content-Length'] = payload.length;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const req = httpsRequest({
|
|
47
|
+
host: url.hostname,
|
|
48
|
+
port: url.port || 443,
|
|
49
|
+
path,
|
|
50
|
+
method,
|
|
51
|
+
headers,
|
|
52
|
+
// When cert pinning is on (cert_fp set), we still set rejectUnauthorized
|
|
53
|
+
// to false because self-signed certs would otherwise fail the chain check —
|
|
54
|
+
// we validate the fingerprint manually on 'secureConnect' instead.
|
|
55
|
+
rejectUnauthorized: (!insecure && !certFp),
|
|
56
|
+
timeout: timeoutMs,
|
|
57
|
+
// Disable keep-alive pooling. With pooling, a reused TLS socket
|
|
58
|
+
// doesn't re-fire 'secureConnect', so our per-request pinning
|
|
59
|
+
// listener would pile up (MaxListenersExceededWarning seen during
|
|
60
|
+
// the 326-batch live push). A fresh socket per request keeps the
|
|
61
|
+
// pinning check correct and leak-free; sync is low-frequency enough
|
|
62
|
+
// that the extra handshakes are negligible.
|
|
63
|
+
agent: false,
|
|
64
|
+
}, (res) => {
|
|
65
|
+
let chunks = '';
|
|
66
|
+
res.on('data', (c) => { chunks += c; });
|
|
67
|
+
res.on('end', () => {
|
|
68
|
+
let parsed = null;
|
|
69
|
+
try { parsed = chunks ? JSON.parse(chunks) : null; } catch (_) { parsed = { _raw: chunks }; }
|
|
70
|
+
resolve({ status: res.statusCode, body: parsed });
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Cert-pinning validation hook. With agent:false each request gets a
|
|
75
|
+
// fresh socket, so 'socket' and 'secureConnect' each fire exactly once
|
|
76
|
+
// per request. We use .once() defensively so a listener can never
|
|
77
|
+
// outlive the handshake it was registered for.
|
|
78
|
+
if (certFp) {
|
|
79
|
+
req.once('socket', (socket) => {
|
|
80
|
+
socket.once('secureConnect', () => {
|
|
81
|
+
const peerCert = socket.getPeerCertificate(true);
|
|
82
|
+
// node returns SHA-256 fingerprint as fingerprint256 in "AA:BB:..." form
|
|
83
|
+
const peerFp = peerCert?.fingerprint256;
|
|
84
|
+
if (!peerFp || !fingerprintsMatch(certFp, peerFp)) {
|
|
85
|
+
req.destroy(new Error(
|
|
86
|
+
`TLS fingerprint mismatch — expected ${certFp}, got ${peerFp || 'none'}`
|
|
87
|
+
));
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
req.on('error', reject);
|
|
94
|
+
req.on('timeout', () => req.destroy(new Error(`request timeout (${timeoutMs}ms)`)));
|
|
95
|
+
|
|
96
|
+
if (payload) req.write(payload);
|
|
97
|
+
req.end();
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
async health() {
|
|
103
|
+
const r = await makeRequest({ method: 'GET', path: '/sync/health' });
|
|
104
|
+
if (r.status !== 200) throw new Error(`health failed: ${r.status} ${JSON.stringify(r.body)}`);
|
|
105
|
+
return r.body;
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
async pull({ since = 0, limit = 500 } = {}) {
|
|
109
|
+
const r = await makeRequest({
|
|
110
|
+
method: 'GET',
|
|
111
|
+
path: `/sync/pull?since=${since}&limit=${limit}`,
|
|
112
|
+
});
|
|
113
|
+
if (r.status !== 200) throw new Error(`pull failed: ${r.status} ${JSON.stringify(r.body)}`);
|
|
114
|
+
return r.body;
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
async push({ rows }) {
|
|
118
|
+
if (!Array.isArray(rows)) throw new Error('push: rows[] required');
|
|
119
|
+
const r = await makeRequest({
|
|
120
|
+
method: 'POST',
|
|
121
|
+
path: '/sync/push',
|
|
122
|
+
body: { rows },
|
|
123
|
+
});
|
|
124
|
+
if (r.status !== 200) {
|
|
125
|
+
// Carry the HTTP status on the error so the replication loop can
|
|
126
|
+
// react — e.g. halve the batch on 413 payload_too_large.
|
|
127
|
+
const err = new Error(`push failed: ${r.status} ${JSON.stringify(r.body)}`);
|
|
128
|
+
err.status = r.status;
|
|
129
|
+
err.body = r.body;
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
return r.body;
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
// Direct access for advanced/test callers
|
|
136
|
+
raw: makeRequest,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync section of ~/.memex/config.json — owns its own defaults and helpers,
|
|
3
|
+
* but persists through the same file as the rest of the config (so a single
|
|
4
|
+
* config.json round-trips cleanly).
|
|
5
|
+
*
|
|
6
|
+
* Schema:
|
|
7
|
+
* sync: {
|
|
8
|
+
* server: {
|
|
9
|
+
* enabled: false, // are we running `memex sync server`?
|
|
10
|
+
* port: 8765, // listen port
|
|
11
|
+
* bind: "0.0.0.0", // 0.0.0.0 | 127.0.0.1 | tailscale-ip
|
|
12
|
+
* bearer: "<64-hex>", // 256-bit token (one per server)
|
|
13
|
+
* cert_path: "~/.memex/sync-cert.pem",
|
|
14
|
+
* key_path: "~/.memex/sync-key.pem",
|
|
15
|
+
* cert_fp: "sha256:..." // computed at server start
|
|
16
|
+
* },
|
|
17
|
+
* remotes: {
|
|
18
|
+
* "<alias>": {
|
|
19
|
+
* url: "https://host:8765",
|
|
20
|
+
* bearer: "<64-hex>",
|
|
21
|
+
* cert_fp: "sha256:...", // pinned fingerprint (from pair blob)
|
|
22
|
+
* pulled_to: 0, // last id we pulled FROM this remote
|
|
23
|
+
* pushed_to: 0, // last OUR id we pushed TO this remote
|
|
24
|
+
* last_sync_at: 0, // unix ms; 0 = never
|
|
25
|
+
* last_error: null // string | null
|
|
26
|
+
* }
|
|
27
|
+
* }
|
|
28
|
+
* }
|
|
29
|
+
*
|
|
30
|
+
* The MEMEX_SYNC_EXPERIMENTAL=1 env var gates all sync activity at the CLI
|
|
31
|
+
* layer; this module only knows shape, not whether the feature is on.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { loadConfig, saveConfig } from '../config.js';
|
|
35
|
+
|
|
36
|
+
export const DEFAULT_SYNC = Object.freeze({
|
|
37
|
+
server: {
|
|
38
|
+
enabled: false,
|
|
39
|
+
port: 8765,
|
|
40
|
+
bind: '0.0.0.0',
|
|
41
|
+
bearer: null,
|
|
42
|
+
cert_path: null,
|
|
43
|
+
key_path: null,
|
|
44
|
+
cert_fp: null,
|
|
45
|
+
},
|
|
46
|
+
remotes: {},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export function loadSyncConfig() {
|
|
50
|
+
const cfg = loadConfig();
|
|
51
|
+
const sync = cfg.sync || {};
|
|
52
|
+
return {
|
|
53
|
+
...sync, // preserve extra keys (e.g. `enabled` set by sync-join) across saves
|
|
54
|
+
server: { ...DEFAULT_SYNC.server, ...(sync.server || {}) },
|
|
55
|
+
remotes: { ...DEFAULT_SYNC.remotes, ...(sync.remotes || {}) },
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function saveSyncConfig(syncCfg) {
|
|
60
|
+
const cfg = loadConfig();
|
|
61
|
+
cfg.sync = syncCfg;
|
|
62
|
+
saveConfig(cfg);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Merge `patch` into the sync.server section and persist. Other config sections
|
|
67
|
+
* untouched. Returns the new server config.
|
|
68
|
+
*/
|
|
69
|
+
export function updateSyncServer(patch) {
|
|
70
|
+
const sync = loadSyncConfig();
|
|
71
|
+
sync.server = { ...sync.server, ...patch };
|
|
72
|
+
saveSyncConfig(sync);
|
|
73
|
+
return sync.server;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Upsert a remote by alias. `patch` shallowly merges into the existing
|
|
78
|
+
* remote (or seeds a new one with cursor=0).
|
|
79
|
+
*/
|
|
80
|
+
export function upsertSyncRemote(alias, patch) {
|
|
81
|
+
if (!alias) throw new Error('upsertSyncRemote: alias required');
|
|
82
|
+
const sync = loadSyncConfig();
|
|
83
|
+
const existing = sync.remotes[alias] || {
|
|
84
|
+
url: null, bearer: null, cert_fp: null,
|
|
85
|
+
pulled_to: 0, pushed_to: 0,
|
|
86
|
+
last_sync_at: 0, last_error: null,
|
|
87
|
+
};
|
|
88
|
+
sync.remotes[alias] = { ...existing, ...patch };
|
|
89
|
+
saveSyncConfig(sync);
|
|
90
|
+
return sync.remotes[alias];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function getSyncRemote(alias) {
|
|
94
|
+
const sync = loadSyncConfig();
|
|
95
|
+
return sync.remotes[alias] || null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function listSyncRemotes() {
|
|
99
|
+
return loadSyncConfig().remotes;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function removeSyncRemote(alias) {
|
|
103
|
+
const sync = loadSyncConfig();
|
|
104
|
+
if (!(alias in sync.remotes)) return false;
|
|
105
|
+
delete sync.remotes[alias];
|
|
106
|
+
saveSyncConfig(sync);
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Returns true if sync is enabled — either via the MEMEX_SYNC_EXPERIMENTAL
|
|
112
|
+
* env var (the original opt-in) or via `sync.enabled: true` in config.json,
|
|
113
|
+
* which `sync-join` sets on success. The config path is what makes the
|
|
114
|
+
* lazy-user flow work: after one successful join, every later sync command
|
|
115
|
+
* (sync-run, sync-status, the schedule timer) just works in any shell with
|
|
116
|
+
* no env var to remember.
|
|
117
|
+
*/
|
|
118
|
+
export function syncExperimentEnabled() {
|
|
119
|
+
const v = process.env.MEMEX_SYNC_EXPERIMENTAL;
|
|
120
|
+
if (v && (v === '1' || v.toLowerCase() === 'true' || v.toLowerCase() === 'yes')) return true;
|
|
121
|
+
try { return (loadConfig().sync || {}).enabled === true; }
|
|
122
|
+
catch (_) { return false; }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Persist sync.enabled=true — called by sync-join on success. */
|
|
126
|
+
export function markSyncEnabled() {
|
|
127
|
+
const sync = loadSyncConfig();
|
|
128
|
+
sync.enabled = true;
|
|
129
|
+
saveSyncConfig(sync);
|
|
130
|
+
}
|
package/lib/sync/pair.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pair blob — collapse {host, port, cert_fp, bearer} into one copy-pasteable
|
|
3
|
+
* string so a peer can be added with a single paste instead of three CLI args.
|
|
4
|
+
*
|
|
5
|
+
* memex-pair:<base64url(JSON)>
|
|
6
|
+
*
|
|
7
|
+
* The JSON payload:
|
|
8
|
+
* { v, host, port, cert_fp, token, exp }
|
|
9
|
+
*
|
|
10
|
+
* Design notes:
|
|
11
|
+
* • base64url (no +/=) so it survives chat clients, URLs, and shell args
|
|
12
|
+
* without escaping.
|
|
13
|
+
* • `exp` (unix seconds) — a short TTL (default 10 min). A leaked blob is
|
|
14
|
+
* only useful until it expires; after that the client refuses it and the
|
|
15
|
+
* operator mints a fresh one. The bearer itself doesn't rotate on expiry
|
|
16
|
+
* (it persists server-side); expiry just bounds the pairing window.
|
|
17
|
+
* • `v` version gate — a client that doesn't understand the version refuses
|
|
18
|
+
* rather than mis-parsing.
|
|
19
|
+
* • This is transport-agnostic: `host` is whatever the CLIENT will dial —
|
|
20
|
+
* a public IP, a localhost SSH-tunnel port, or a Tailscale MagicDNS name.
|
|
21
|
+
* The server can't know that, so the invite step chooses/declares it.
|
|
22
|
+
*
|
|
23
|
+
* Security model unchanged from the 3-arg path: cert_fp gives TLS pinning,
|
|
24
|
+
* token is the 256-bit bearer. The blob just bundles them.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const PREFIX = 'memex-pair:';
|
|
28
|
+
// Join token (v0.13 lazy-user flow) — same payload as a pair blob PLUS
|
|
29
|
+
// `ssh_target` ("user@host"). The presence of ssh_target tells the client to
|
|
30
|
+
// reach the server through a forward SSH tunnel (-L) instead of dialing
|
|
31
|
+
// host:port directly — the canonical loopback-hub topology where the server
|
|
32
|
+
// never exposes a public port. A distinct prefix so old clients fail with
|
|
33
|
+
// "not a memex-pair token" instead of silently dialing 127.0.0.1.
|
|
34
|
+
const JOIN_PREFIX = 'memex-join:';
|
|
35
|
+
const PAIR_VERSION = 1;
|
|
36
|
+
export const DEFAULT_PAIR_TTL_SEC = 600; // 10 minutes
|
|
37
|
+
export const DEFAULT_JOIN_TTL_SEC = 1800; // 30 minutes — join is a longer dance
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Encode a pair blob. Returns the "memex-pair:..." string.
|
|
41
|
+
* host — required; what the client will connect to
|
|
42
|
+
* port — default 8766
|
|
43
|
+
* cert_fp — TLS fingerprint to pin (sha256:AA:BB:...); may be null for
|
|
44
|
+
* transport-trusted setups (SSH tunnel / Tailscale)
|
|
45
|
+
* token — required; 256-bit hex bearer
|
|
46
|
+
* ttlSec — seconds until the blob expires (default 10 min)
|
|
47
|
+
* now — injectable clock (ms) for tests
|
|
48
|
+
*/
|
|
49
|
+
export function encodePairBlob({ host, port = 8766, cert_fp = null, token, ttlSec = DEFAULT_PAIR_TTL_SEC, now = Date.now() }) {
|
|
50
|
+
if (!host) throw new Error('encodePairBlob: host required');
|
|
51
|
+
if (!token) throw new Error('encodePairBlob: token required');
|
|
52
|
+
const payload = {
|
|
53
|
+
v: PAIR_VERSION,
|
|
54
|
+
host,
|
|
55
|
+
port: Number(port) || 8766,
|
|
56
|
+
cert_fp: cert_fp || null,
|
|
57
|
+
token,
|
|
58
|
+
exp: Math.floor(now / 1000) + Math.max(1, Math.floor(ttlSec)),
|
|
59
|
+
};
|
|
60
|
+
const b64 = Buffer.from(JSON.stringify(payload), 'utf-8').toString('base64url');
|
|
61
|
+
return PREFIX + b64;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Encode a JOIN token — the lazy-user variant. Same fields as a pair blob,
|
|
66
|
+
* plus `ssh_target`. `host` is pinned to 127.0.0.1: the server binds loopback
|
|
67
|
+
* and the client reaches it through its own `-L` tunnel, so loopback is the
|
|
68
|
+
* only address that's ever dialed.
|
|
69
|
+
*/
|
|
70
|
+
export function encodeJoinBlob({ ssh_target, port = 8766, cert_fp = null, token, ttlSec = DEFAULT_JOIN_TTL_SEC, now = Date.now() }) {
|
|
71
|
+
if (!ssh_target || !/^[^@\s]+@[^@\s]+$/.test(ssh_target)) {
|
|
72
|
+
throw new Error('encodeJoinBlob: ssh_target required (user@host)');
|
|
73
|
+
}
|
|
74
|
+
if (!token) throw new Error('encodeJoinBlob: token required');
|
|
75
|
+
const payload = {
|
|
76
|
+
v: PAIR_VERSION,
|
|
77
|
+
host: '127.0.0.1',
|
|
78
|
+
ssh_target,
|
|
79
|
+
port: Number(port) || 8766,
|
|
80
|
+
cert_fp: cert_fp || null,
|
|
81
|
+
token,
|
|
82
|
+
exp: Math.floor(now / 1000) + Math.max(1, Math.floor(ttlSec)),
|
|
83
|
+
};
|
|
84
|
+
return JOIN_PREFIX + Buffer.from(JSON.stringify(payload), 'utf-8').toString('base64url');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Parse + validate a pair OR join blob. Throws a friendly Error on any problem.
|
|
89
|
+
* Returns { kind, host, port, url, cert_fp, token, exp, ssh_target }.
|
|
90
|
+
* kind — 'pair' | 'join' (by prefix)
|
|
91
|
+
* ssh_target — "user@host" for join tokens, null for plain pair blobs
|
|
92
|
+
*
|
|
93
|
+
* now — injectable clock (ms) for tests.
|
|
94
|
+
*/
|
|
95
|
+
export function parsePairBlob(blob, { now = Date.now() } = {}) {
|
|
96
|
+
if (typeof blob !== 'string' || !blob.trim()) {
|
|
97
|
+
throw new Error('pair blob must be a non-empty string');
|
|
98
|
+
}
|
|
99
|
+
let s = blob.trim();
|
|
100
|
+
let kind;
|
|
101
|
+
if (s.startsWith(JOIN_PREFIX)) {
|
|
102
|
+
kind = 'join';
|
|
103
|
+
s = s.slice(JOIN_PREFIX.length).trim();
|
|
104
|
+
} else if (s.startsWith(PREFIX)) {
|
|
105
|
+
kind = 'pair';
|
|
106
|
+
s = s.slice(PREFIX.length).trim();
|
|
107
|
+
} else {
|
|
108
|
+
throw new Error(`not a memex pair/join token (must start with "${PREFIX}" or "${JOIN_PREFIX}")`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let payload;
|
|
112
|
+
try {
|
|
113
|
+
payload = JSON.parse(Buffer.from(s, 'base64url').toString('utf-8'));
|
|
114
|
+
} catch (_) {
|
|
115
|
+
throw new Error('pair blob is corrupt (base64/JSON decode failed) — re-copy it whole');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (payload.v !== PAIR_VERSION) {
|
|
119
|
+
throw new Error(`unsupported pair blob version ${payload.v} — this memex speaks v${PAIR_VERSION}; upgrade the older side`);
|
|
120
|
+
}
|
|
121
|
+
if (!payload.host || !payload.token) {
|
|
122
|
+
throw new Error('pair blob missing host or token');
|
|
123
|
+
}
|
|
124
|
+
if (kind === 'join' && !/^[^@\s]+@[^@\s]+$/.test(payload.ssh_target || '')) {
|
|
125
|
+
throw new Error('join token missing ssh_target (user@host) — re-emit it with `sync-server invite --join`');
|
|
126
|
+
}
|
|
127
|
+
if (payload.exp && Math.floor(now / 1000) > payload.exp) {
|
|
128
|
+
const agoMin = Math.round((Math.floor(now / 1000) - payload.exp) / 60);
|
|
129
|
+
throw new Error(`pair blob expired ${agoMin}m ago — mint a fresh one with \`memex-sync sync-server invite\``);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const port = Number(payload.port) || 8766;
|
|
133
|
+
return {
|
|
134
|
+
kind,
|
|
135
|
+
host: payload.host,
|
|
136
|
+
port,
|
|
137
|
+
url: `https://${payload.host}:${port}`,
|
|
138
|
+
cert_fp: payload.cert_fp || null,
|
|
139
|
+
token: payload.token,
|
|
140
|
+
exp: payload.exp || null,
|
|
141
|
+
ssh_target: kind === 'join' ? payload.ssh_target : null,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export { PREFIX as PAIR_PREFIX, JOIN_PREFIX, PAIR_VERSION };
|
package/lib/sync/pull.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /sync/pull?since=<int>&limit=<int> — return rows the caller hasn't seen.
|
|
3
|
+
*
|
|
4
|
+
* Wire contract (see SYNC.md §Wire protocol):
|
|
5
|
+
*
|
|
6
|
+
* Query:
|
|
7
|
+
* since — caller's last-seen local id from this server (0 = first pull)
|
|
8
|
+
* limit — max rows; default 500, hard cap 1000
|
|
9
|
+
*
|
|
10
|
+
* Response: {
|
|
11
|
+
* rows: [Row, ...], // ordered by id ASC, id > since
|
|
12
|
+
* next_cursor: <int>, // id of the last row in this batch
|
|
13
|
+
* // (caller passes this as since= next time)
|
|
14
|
+
* has_more: bool, // true → more rows wait; caller should
|
|
15
|
+
* // re-pull immediately with the new cursor
|
|
16
|
+
* server_now: <ms_epoch> // informational
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* Each Row matches the shape POST /sync/push expects, including the embedded
|
|
20
|
+
* conversation metadata (so the receiver can upsert without a separate
|
|
21
|
+
* join-and-fetch round trip).
|
|
22
|
+
*
|
|
23
|
+
* Conversation embedding is denormalised on read: we LEFT JOIN conversations
|
|
24
|
+
* and inline the columns. For a typical 500-row pull this is one query.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const DEFAULT_LIMIT = 500;
|
|
28
|
+
const MAX_LIMIT = 1000;
|
|
29
|
+
|
|
30
|
+
export function makePullHandler({ db }) {
|
|
31
|
+
if (!db) throw new Error('makePullHandler: db is required');
|
|
32
|
+
|
|
33
|
+
// One query, LEFT JOIN — every message also surfaces its conversation row
|
|
34
|
+
// for the receiver's upsert. Order is critical: ASC by id ensures the
|
|
35
|
+
// cursor (max id returned) is monotonic across pulls.
|
|
36
|
+
const fetchSince = db.prepare(`
|
|
37
|
+
SELECT
|
|
38
|
+
m.id AS id,
|
|
39
|
+
m.source AS source,
|
|
40
|
+
m.conversation_id AS conversation_id,
|
|
41
|
+
m.msg_id AS msg_id,
|
|
42
|
+
m.role AS role,
|
|
43
|
+
m.sender AS sender,
|
|
44
|
+
m.text AS text,
|
|
45
|
+
m.ts AS ts,
|
|
46
|
+
m.edited_at AS edited_at,
|
|
47
|
+
m.uuid AS uuid,
|
|
48
|
+
m.channel AS channel,
|
|
49
|
+
m.origin AS origin,
|
|
50
|
+
m.metadata AS metadata,
|
|
51
|
+
c.title AS conv_title,
|
|
52
|
+
c.first_ts AS conv_first_ts,
|
|
53
|
+
c.last_ts AS conv_last_ts,
|
|
54
|
+
c.parent_conversation_id AS conv_parent,
|
|
55
|
+
c.project_path AS conv_project_path
|
|
56
|
+
FROM messages m
|
|
57
|
+
LEFT JOIN conversations c ON c.conversation_id = m.conversation_id
|
|
58
|
+
WHERE m.id > ?
|
|
59
|
+
ORDER BY m.id ASC
|
|
60
|
+
LIMIT ?
|
|
61
|
+
`);
|
|
62
|
+
|
|
63
|
+
return function pullHandler(req, res) {
|
|
64
|
+
let url;
|
|
65
|
+
try {
|
|
66
|
+
url = new URL(req.url, 'https://placeholder.local');
|
|
67
|
+
} catch (_) {
|
|
68
|
+
return respondJson(res, 400, { error: 'bad_request', detail: 'malformed URL' });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const sinceRaw = url.searchParams.get('since') ?? '0';
|
|
72
|
+
const limitRaw = url.searchParams.get('limit') ?? String(DEFAULT_LIMIT);
|
|
73
|
+
|
|
74
|
+
const since = parseNonNegInt(sinceRaw);
|
|
75
|
+
if (since == null) {
|
|
76
|
+
return respondJson(res, 400, { error: 'bad_request', detail: 'since must be a non-negative integer' });
|
|
77
|
+
}
|
|
78
|
+
const limit = clampLimit(limitRaw);
|
|
79
|
+
|
|
80
|
+
let rows;
|
|
81
|
+
try {
|
|
82
|
+
rows = fetchSince.all(since, limit + 1);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
return respondJson(res, 500, { error: 'internal', detail: err.message });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// If we fetched limit+1, the extra row indicates there's more after this batch.
|
|
88
|
+
const has_more = rows.length > limit;
|
|
89
|
+
if (has_more) rows = rows.slice(0, limit);
|
|
90
|
+
|
|
91
|
+
const wireRows = rows.map(rowToWire);
|
|
92
|
+
const next_cursor = wireRows.length
|
|
93
|
+
? wireRows[wireRows.length - 1].id_serverside
|
|
94
|
+
: since;
|
|
95
|
+
|
|
96
|
+
// Strip the bookkeeping field — id_serverside was only for next_cursor.
|
|
97
|
+
for (const r of wireRows) delete r.id_serverside;
|
|
98
|
+
|
|
99
|
+
respondJson(res, 200, {
|
|
100
|
+
rows: wireRows,
|
|
101
|
+
next_cursor,
|
|
102
|
+
has_more,
|
|
103
|
+
server_now: Date.now(),
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Map a SQLite-row into the wire Row shape per SYNC.md. We tuck the
|
|
110
|
+
* server-side id into a temporary `id_serverside` field so the caller of
|
|
111
|
+
* makePullHandler can compute next_cursor without re-iterating. Field is
|
|
112
|
+
* removed before serialization.
|
|
113
|
+
*/
|
|
114
|
+
function rowToWire(r) {
|
|
115
|
+
return {
|
|
116
|
+
source: r.source,
|
|
117
|
+
conversation_id: r.conversation_id,
|
|
118
|
+
msg_id: r.msg_id,
|
|
119
|
+
uuid: r.uuid,
|
|
120
|
+
role: r.role,
|
|
121
|
+
sender: r.sender,
|
|
122
|
+
text: r.text,
|
|
123
|
+
ts: r.ts,
|
|
124
|
+
edited_at: r.edited_at,
|
|
125
|
+
channel: r.channel,
|
|
126
|
+
origin: r.origin,
|
|
127
|
+
metadata: r.metadata,
|
|
128
|
+
conversation: {
|
|
129
|
+
title: r.conv_title,
|
|
130
|
+
first_ts: r.conv_first_ts,
|
|
131
|
+
last_ts: r.conv_last_ts,
|
|
132
|
+
parent_conversation_id: r.conv_parent,
|
|
133
|
+
project_path: r.conv_project_path,
|
|
134
|
+
},
|
|
135
|
+
id_serverside: r.id, // stripped before response
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function parseNonNegInt(s) {
|
|
140
|
+
if (s == null) return null;
|
|
141
|
+
const n = Number(s);
|
|
142
|
+
if (!Number.isFinite(n)) return null;
|
|
143
|
+
if (n < 0) return null;
|
|
144
|
+
return Math.trunc(n);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function clampLimit(s) {
|
|
148
|
+
const n = parseNonNegInt(s);
|
|
149
|
+
if (n == null || n === 0) return DEFAULT_LIMIT;
|
|
150
|
+
return Math.min(n, MAX_LIMIT);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function respondJson(res, status, obj) {
|
|
154
|
+
if (res.headersSent || res.writableEnded) return;
|
|
155
|
+
res.statusCode = status;
|
|
156
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
157
|
+
res.end(JSON.stringify(obj));
|
|
158
|
+
}
|