@tensorix/metahub 0.1.2 → 0.1.3

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.js CHANGED
@@ -704,6 +704,21 @@ var HTML = `<!doctype html>
704
704
  .theme-card.sel .tc-check { opacity:1; transform:scale(1); }
705
705
  @media (max-width:560px) { .theme-grid { grid-template-columns:1fr; } }
706
706
 
707
+ /* sync devices */
708
+ .peer-actions { display:flex; gap:10px; margin-bottom:16px; flex-wrap:wrap; }
709
+ .peer-actions .btn { display:inline-flex; align-items:center; gap:6px; }
710
+ .peer-list { display:flex; flex-direction:column; gap:8px; }
711
+ .peer-row { display:flex; align-items:center; gap:12px; padding:12px 14px; border:1px solid var(--line);
712
+ border-radius:10px; background:var(--surface); }
713
+ .peer-dot { width:9px; height:9px; border-radius:50%; flex:none; background:var(--muted); }
714
+ .peer-dot.on { background:#3fb950; } .peer-dot.err { background:#f85149; } .peer-dot.off { background:var(--muted); }
715
+ .peer-main { flex:1; min-width:0; }
716
+ .peer-url { font-weight:600; font-size:14px; color:var(--fg); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
717
+ .peer-sub { font-size:12px; color:var(--muted); margin-top:2px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
718
+ .peer-menu { flex:none; padding:6px; }
719
+ .pair-code { font-family:var(--mono); font-size:26px; font-weight:700; letter-spacing:.12em;
720
+ text-align:center; padding:16px; border:1px dashed var(--line); border-radius:10px; background:var(--surface); color:var(--fg); }
721
+
707
722
  /* main */
708
723
  .main { flex:1; display:flex; flex-direction:column; overflow:hidden; min-width:0; }
709
724
  .topbar { display:flex; align-items:center; gap:6px; padding:10px 18px; border-bottom:1px solid var(--line); min-height:49px; }
@@ -1140,6 +1155,49 @@ var HTML = `<!doctype html>
1140
1155
  .db { padding:16px 14px 90px; }
1141
1156
  .peek { width:100%; }
1142
1157
  }
1158
+
1159
+ /* Quick Notes window (desktop, loaded at #quick). A transparent body lets the
1160
+ macOS vibrancy blur show through; a translucent panel keeps text readable.
1161
+ The top bar is the window drag region; its buttons opt back out. */
1162
+ body.quicknote { background:transparent; }
1163
+ /* The main app lays #app out as a flex row (sidebar + main); the quick-note
1164
+ route has a single child, so override to a full-bleed block or the panel
1165
+ shrinks to its content width and the window's vibrancy shows through. */
1166
+ body.quicknote #app { display:block; width:100vw; height:100vh; }
1167
+ .qn { display:flex; flex-direction:column; width:100%; height:100vh; overflow:hidden;
1168
+ background:color-mix(in srgb, var(--bg) 20%, transparent); }
1169
+ .qn-bar { display:flex; align-items:center; gap:6px; padding:4px 10px 4px 76px;
1170
+ user-select:none; -webkit-app-region:drag;
1171
+ background:color-mix(in srgb, var(--surface) 30%, transparent); }
1172
+ .qn-brand { flex:1; min-width:0; font-size:12px; font-weight:600; color:var(--fg-soft);
1173
+ white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
1174
+ .qn-actions { display:flex; gap:2px; -webkit-app-region:no-drag; }
1175
+ .qn-actions .iconbtn { width:26px; height:26px; }
1176
+ .qn-actions .iconbtn.active { color:var(--accent); background:var(--hover-2); }
1177
+ .qn-body { flex:1; overflow:auto; -webkit-app-region:no-drag; }
1178
+ .qn-body .doc { max-width:none; margin:0; padding:14px 22px 30vh; }
1179
+ .qn-body .doc-title { font-size:24px; margin-bottom:2px; }
1180
+ .qn-body .doc-meta { display:none; }
1181
+ /* Compact menus inside the quick-note window only (dropdown + slash command
1182
+ share .pop/.item/.lico with the main app; scope under body.quicknote so the
1183
+ main app keeps its roomier density). */
1184
+ body.quicknote .pop { padding:5px; border-radius:9px; }
1185
+ body.quicknote .pop .lbl { padding:4px 8px 2px; font-size:10px; }
1186
+ body.quicknote .pop .item { padding:5px 9px; gap:8px; font-size:12.5px; }
1187
+ body.quicknote .pop .item .t { font-size:12.5px; }
1188
+ body.quicknote .pop .item .d { font-size:10.5px; }
1189
+ body.quicknote .pop .item .lico { width:22px; height:22px; }
1190
+ body.quicknote .pop .item .lico.plain { width:20px; height:20px; }
1191
+ body.quicknote .pop .sep { margin:4px 4px; }
1192
+
1193
+ /* Quick Notes settings rows (desktop only). */
1194
+ .qn-set-row { display:flex; align-items:center; gap:14px; padding:12px 0; border-top:1px solid var(--line); }
1195
+ .qn-set-row:first-of-type { border-top:0; }
1196
+ .qn-set-main { flex:1; min-width:0; }
1197
+ .qn-set-name { font-weight:600; }
1198
+ .qn-set-desc { color:var(--muted); font-size:13px; margin-top:2px; }
1199
+ .qn-shortcut { min-width:140px; justify-content:center; font-family:var(--mono); }
1200
+ .qn-shortcut.capturing { outline:2px solid var(--accent); color:var(--accent); }
1143
1201
  </style>
1144
1202
  </head>
1145
1203
  <body>
@@ -1207,11 +1265,41 @@ CREATE TABLE IF NOT EXISTS crdt_changes (
1207
1265
  CREATE INDEX IF NOT EXISTS idx_changes_hlc ON crdt_changes(hlc);
1208
1266
 
1209
1267
  -- Per-peer replication cursors (SQLite rowids: monotonic in insertion order,
1210
- -- so no change is ever skipped regardless of HLC/clock skew).
1268
+ -- so no change is ever skipped regardless of HLC/clock skew). Outbound side of
1269
+ -- a pairing: "token" is the credential the *remote* issued to us (sent as a
1270
+ -- Bearer header when we sync to them). See migratePeers in db.ts for the columns
1271
+ -- added to legacy databases.
1211
1272
  CREATE TABLE IF NOT EXISTS peers (
1212
- url TEXT PRIMARY KEY,
1213
- pull_cursor INTEGER NOT NULL DEFAULT 0,
1214
- push_cursor INTEGER NOT NULL DEFAULT 0
1273
+ url TEXT PRIMARY KEY,
1274
+ pull_cursor INTEGER NOT NULL DEFAULT 0,
1275
+ push_cursor INTEGER NOT NULL DEFAULT 0,
1276
+ token TEXT,
1277
+ label TEXT,
1278
+ node_id TEXT,
1279
+ enabled INTEGER NOT NULL DEFAULT 1,
1280
+ last_sync_at INTEGER,
1281
+ last_status TEXT,
1282
+ last_error TEXT
1283
+ );
1284
+
1285
+ -- Inbound side of a pairing: credentials *we* issued to remote devices. A peer
1286
+ -- presents one of these on /sync; acceptsSyncToken() checks it alongside the
1287
+ -- managed master token. Revoke by deleting the row.
1288
+ CREATE TABLE IF NOT EXISTS peer_grants (
1289
+ token TEXT PRIMARY KEY,
1290
+ peer_url TEXT,
1291
+ node_id TEXT,
1292
+ created_at INTEGER
1293
+ );
1294
+
1295
+ -- One-time pairing codes minted by "mh config peer code" / POST /api/pair/new.
1296
+ -- Short-lived and single-use: redeemed during the pairing handshake, then the
1297
+ -- two devices exchange durable peer_grants. used/exp gate redemption.
1298
+ CREATE TABLE IF NOT EXISTS pairing_codes (
1299
+ code TEXT PRIMARY KEY,
1300
+ exp INTEGER NOT NULL,
1301
+ used INTEGER NOT NULL DEFAULT 0,
1302
+ created_at INTEGER
1215
1303
  );
1216
1304
 
1217
1305
  CREATE TABLE IF NOT EXISTS databases (
@@ -1924,12 +2012,28 @@ function migrateRecords(db) {
1924
2012
  tx();
1925
2013
  backfillRecordOrderKeys(db);
1926
2014
  }
2015
+ function migratePeers(db) {
2016
+ const add = [
2017
+ ["token", "TEXT"],
2018
+ ["label", "TEXT"],
2019
+ ["node_id", "TEXT"],
2020
+ ["enabled", "INTEGER NOT NULL DEFAULT 1"],
2021
+ ["last_sync_at", "INTEGER"],
2022
+ ["last_status", "TEXT"],
2023
+ ["last_error", "TEXT"]
2024
+ ];
2025
+ for (const [col, decl] of add) {
2026
+ if (!hasColumn(db, "peers", col))
2027
+ db.exec(`ALTER TABLE peers ADD COLUMN ${col} ${decl}`);
2028
+ }
2029
+ }
1927
2030
  function openMetahub() {
1928
2031
  ensureDirs();
1929
2032
  const db = new Database(dbPath(), { create: true });
1930
2033
  db.exec("PRAGMA journal_mode = WAL;");
1931
2034
  runSchema(db);
1932
2035
  migrateRecords(db);
2036
+ migratePeers(db);
1933
2037
  return db;
1934
2038
  }
1935
2039
 
@@ -2032,9 +2136,12 @@ function getDocument(db, id) {
2032
2136
  return db.query("SELECT id, title, body, database_id, parent_id, created_hlc FROM documents WHERE id = ? AND __deleted = 0").get(id);
2033
2137
  }
2034
2138
  function listDocuments(db, opts = {}) {
2139
+ const cols = "SELECT id, title, database_id, parent_id, created_hlc FROM documents WHERE __deleted = 0";
2140
+ if (opts.parent_id !== undefined)
2141
+ return db.query(`${cols} AND parent_id = ? ORDER BY created_hlc`).all(opts.parent_id);
2035
2142
  if (opts.database_id)
2036
- return db.query("SELECT id, title, database_id, parent_id, created_hlc FROM documents WHERE database_id = ? AND __deleted = 0 ORDER BY created_hlc").all(opts.database_id);
2037
- return db.query("SELECT id, title, database_id, parent_id, created_hlc FROM documents WHERE __deleted = 0 ORDER BY created_hlc").all();
2143
+ return db.query(`${cols} AND database_id = ? ORDER BY created_hlc`).all(opts.database_id);
2144
+ return db.query(`${cols} ORDER BY created_hlc`).all();
2038
2145
  }
2039
2146
  function updateDocument(db, id, fields) {
2040
2147
  if (!getDocument(db, id))
@@ -16517,26 +16624,41 @@ var HealthResponseSchema = exports_external.object({
16517
16624
  ok: exports_external.boolean(),
16518
16625
  node: exports_external.string().describe("Server's node id")
16519
16626
  });
16627
+ var PairRequestSchema = exports_external.object({
16628
+ code: exports_external.string().describe("One-time pairing code minted by this server"),
16629
+ node_id: exports_external.string().describe("Calling device's node id"),
16630
+ grant: exports_external.string().describe("Durable credential the caller issues to us"),
16631
+ self_url: exports_external.string().optional().describe("Caller's reachable server URL (for mutual sync)")
16632
+ });
16633
+ var PairResponseSchema = exports_external.object({
16634
+ node_id: exports_external.string().describe("This server's node id"),
16635
+ grant: exports_external.string().describe("Durable credential we issue to the caller")
16636
+ });
16520
16637
  var SYNC_PATH = "/sync";
16521
16638
  var HEALTH_PATH = "/health";
16522
16639
  var RENEW_PATH = "/auth/token";
16640
+ var PAIR_PATH = "/api/pair";
16523
16641
  // src/core/sync/client.ts
16524
16642
  init_node();
16525
16643
  init_crdt();
16526
16644
  function getPeer(db, url2) {
16527
- const row = db.query("SELECT pull_cursor, push_cursor FROM peers WHERE url = ?").get(url2);
16528
- return row ?? { pull_cursor: 0, push_cursor: 0 };
16645
+ const row = db.query("SELECT pull_cursor, push_cursor, token FROM peers WHERE url = ?").get(url2);
16646
+ return row ?? { pull_cursor: 0, push_cursor: 0, token: null };
16529
16647
  }
16530
16648
  function setPeer(db, url2, c) {
16531
16649
  db.query("INSERT INTO peers (url, pull_cursor, push_cursor) VALUES (?, ?, ?) ON CONFLICT(url) DO UPDATE SET pull_cursor = excluded.pull_cursor, push_cursor = excluded.push_cursor").run(url2, c.pull_cursor, c.push_cursor);
16532
16650
  }
16533
- async function syncWithPeer(db, url2) {
16651
+ async function syncWithPeer(db, url2, token) {
16534
16652
  const node = getNodeId(db);
16535
16653
  const peer = getPeer(db, url2);
16654
+ const bearer = token ?? peer.token ?? undefined;
16536
16655
  const toPush = changesAfterSeq(db, peer.push_cursor);
16656
+ const headers = { "content-type": "application/json" };
16657
+ if (bearer)
16658
+ headers.authorization = `Bearer ${bearer}`;
16537
16659
  const res = await fetch(new URL(SYNC_PATH, url2), {
16538
16660
  method: "POST",
16539
- headers: { "content-type": "application/json" },
16661
+ headers,
16540
16662
  body: JSON.stringify({ node_id: node, since: peer.pull_cursor, changes: toPush.changes })
16541
16663
  });
16542
16664
  if (!res.ok)
@@ -16549,10 +16671,108 @@ async function syncWithPeer(db, url2) {
16549
16671
  }
16550
16672
  // src/core/sync/server.ts
16551
16673
  init_node();
16674
+
16675
+ // src/core/sync/token.ts
16676
+ init_ids();
16677
+ var K_TOKEN = "auth_token";
16678
+ var K_EXP = "auth_token_exp";
16679
+ var K_PREV = "auth_token_prev";
16680
+ var K_PREV_EXP = "auth_token_prev_exp";
16681
+ function parseDuration(s, fallbackMs) {
16682
+ if (!s)
16683
+ return fallbackMs;
16684
+ const m = /^(\d+)\s*(ms|s|m|h|d)?$/.exec(s.trim());
16685
+ if (!m)
16686
+ return fallbackMs;
16687
+ const n = Number(m[1]);
16688
+ switch (m[2]) {
16689
+ case "ms":
16690
+ return n;
16691
+ case "m":
16692
+ return n * 60000;
16693
+ case "h":
16694
+ return n * 3600000;
16695
+ case "d":
16696
+ return n * 86400000;
16697
+ default:
16698
+ return n * 1000;
16699
+ }
16700
+ }
16701
+ var DAY = 86400000;
16702
+ var DEFAULT_TTL_MS = parseDuration(process.env.METAHUB_TOKEN_TTL, 30 * DAY);
16703
+ var DEFAULT_GRACE_MS = parseDuration(process.env.METAHUB_TOKEN_GRACE, 7 * DAY);
16704
+ function getMeta(db, key) {
16705
+ const row = db.query("SELECT value FROM meta WHERE key = ?").get(key);
16706
+ return row ? row.value : null;
16707
+ }
16708
+ function setMeta(db, key, value) {
16709
+ db.query("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value);
16710
+ }
16711
+ function readState(db) {
16712
+ const token = getMeta(db, K_TOKEN);
16713
+ if (!token)
16714
+ return null;
16715
+ const prev = getMeta(db, K_PREV);
16716
+ return {
16717
+ token,
16718
+ exp: Number(getMeta(db, K_EXP) ?? 0),
16719
+ prev: prev && prev.length > 0 ? prev : null,
16720
+ prevExp: Number(getMeta(db, K_PREV_EXP) ?? 0)
16721
+ };
16722
+ }
16723
+ function rotate(db, ttlMs, graceMs) {
16724
+ const now = Date.now();
16725
+ const old = getMeta(db, K_TOKEN);
16726
+ const next = {
16727
+ token: randomSuffix(24),
16728
+ exp: now + ttlMs,
16729
+ prev: old,
16730
+ prevExp: old ? now + graceMs : 0
16731
+ };
16732
+ setMeta(db, K_TOKEN, next.token);
16733
+ setMeta(db, K_EXP, String(next.exp));
16734
+ setMeta(db, K_PREV, next.prev ?? "");
16735
+ setMeta(db, K_PREV_EXP, String(next.prevExp));
16736
+ return next;
16737
+ }
16738
+ function loadOrRotate(db, ttlMs, graceMs) {
16739
+ const s = readState(db);
16740
+ if (!s || Date.now() > s.exp)
16741
+ return rotate(db, ttlMs, graceMs);
16742
+ return s;
16743
+ }
16744
+
16745
+ // src/core/config.ts
16746
+ var K_HOST = "cfg_host";
16747
+ var K_PORT = "cfg_port";
16748
+ var K_SYNC_INTERVAL = "cfg_sync_interval";
16749
+ var K_AUTO_SYNC = "cfg_auto_sync";
16750
+ var DEFAULT_CONFIG = {
16751
+ host: "127.0.0.1",
16752
+ port: 7777,
16753
+ syncIntervalMs: parseDuration(process.env.METAHUB_SYNC_INTERVAL, 30000),
16754
+ autoSync: true
16755
+ };
16756
+ function getMeta2(db, key) {
16757
+ const row = db.query("SELECT value FROM meta WHERE key = ?").get(key);
16758
+ return row ? row.value : null;
16759
+ }
16760
+ function getServerConfig(db) {
16761
+ const host = getMeta2(db, K_HOST);
16762
+ const port = getMeta2(db, K_PORT);
16763
+ const interval = getMeta2(db, K_SYNC_INTERVAL);
16764
+ const auto = getMeta2(db, K_AUTO_SYNC);
16765
+ return {
16766
+ host: host ?? DEFAULT_CONFIG.host,
16767
+ port: port != null ? Number(port) : DEFAULT_CONFIG.port,
16768
+ syncIntervalMs: interval != null ? Number(interval) : DEFAULT_CONFIG.syncIntervalMs,
16769
+ autoSync: auto != null ? auto === "1" : DEFAULT_CONFIG.autoSync
16770
+ };
16771
+ }
16552
16772
  // package.json
16553
16773
  var package_default = {
16554
16774
  name: "@tensorix/metahub",
16555
- version: "0.1.2",
16775
+ version: "0.1.3",
16556
16776
  description: "Metahub core library and CLI",
16557
16777
  type: "module",
16558
16778
  main: "./dist/index.js",
@@ -16581,6 +16801,7 @@ var package_default = {
16581
16801
  test: "bun test"
16582
16802
  },
16583
16803
  dependencies: {
16804
+ "@clack/prompts": "^1.5.0",
16584
16805
  citty: "^0.1.6",
16585
16806
  "highlight.js": "^11.11.1",
16586
16807
  preact: "^10.29.2",
@@ -16657,7 +16878,8 @@ var RecordValuesReq = exports_external.record(exports_external.string(), exports
16657
16878
  var CreateDocumentReq = exports_external.object({
16658
16879
  title: exports_external.string(),
16659
16880
  body: exports_external.string().optional(),
16660
- database_id: exports_external.string().optional()
16881
+ database_id: exports_external.string().optional(),
16882
+ parent_id: exports_external.string().optional()
16661
16883
  });
16662
16884
  var UpdateDocumentReq = exports_external.object({
16663
16885
  title: exports_external.string().optional(),
@@ -16835,9 +17057,9 @@ var webuiRoutes = [
16835
17057
  {
16836
17058
  method: "GET",
16837
17059
  path: "/api/documents",
16838
- summary: "List documents. Optional query: ?db=<id>",
17060
+ summary: "List documents. Optional query: ?db=<id> or ?parent=<id>",
16839
17061
  response: exports_external.array(DocumentSummarySchema),
16840
- handler: handle((req, { db }) => listDocuments(db, { database_id: opt(req, "db") }))
17062
+ handler: handle((req, { db }) => listDocuments(db, { database_id: opt(req, "db"), parent_id: opt(req, "parent") }))
16841
17063
  },
16842
17064
  {
16843
17065
  method: "POST",
@@ -16937,6 +17159,250 @@ var sitesRoutes = [
16937
17159
  }
16938
17160
  ];
16939
17161
 
17162
+ // src/core/sync/pairing.ts
17163
+ init_ids();
17164
+
17165
+ // src/core/sync/peers.ts
17166
+ function listPeers(db) {
17167
+ return db.query("SELECT url, pull_cursor, push_cursor, token, label, node_id, enabled, last_sync_at, last_status, last_error FROM peers ORDER BY url").all();
17168
+ }
17169
+ function addPeer(db, input) {
17170
+ db.query(`INSERT INTO peers (url, token, label, node_id, enabled, pull_cursor, push_cursor)
17171
+ VALUES (?, ?, ?, ?, 1, 0, 0)
17172
+ ON CONFLICT(url) DO UPDATE SET
17173
+ token = coalesce(excluded.token, peers.token),
17174
+ label = coalesce(excluded.label, peers.label),
17175
+ node_id = coalesce(excluded.node_id, peers.node_id),
17176
+ enabled = 1`).run(input.url, input.token ?? null, input.label ?? null, input.node_id ?? null);
17177
+ }
17178
+ function removePeer(db, url2) {
17179
+ const tx = db.transaction(() => {
17180
+ const changed = db.query("DELETE FROM peers WHERE url = ?").run(url2).changes > 0;
17181
+ db.query("DELETE FROM peer_grants WHERE peer_url = ?").run(url2);
17182
+ return changed;
17183
+ });
17184
+ return tx();
17185
+ }
17186
+ function setPeerEnabled(db, url2, enabled) {
17187
+ return db.query("UPDATE peers SET enabled = ? WHERE url = ?").run(enabled ? 1 : 0, url2).changes > 0;
17188
+ }
17189
+ function setPeerLabel(db, url2, label) {
17190
+ return db.query("UPDATE peers SET label = ? WHERE url = ?").run(label, url2).changes > 0;
17191
+ }
17192
+ function updatePeerStatus(db, url2, status, error51) {
17193
+ db.query("UPDATE peers SET last_sync_at = ?, last_status = ?, last_error = ? WHERE url = ?").run(Date.now(), status, error51 ?? null, url2);
17194
+ }
17195
+ async function syncPeer(db, url2) {
17196
+ try {
17197
+ const result = await syncWithPeer(db, url2);
17198
+ updatePeerStatus(db, url2, "ok", null);
17199
+ return { url: url2, ok: true, pushed: result.pushed, pulled: result.pulled };
17200
+ } catch (e) {
17201
+ const error51 = e.message;
17202
+ updatePeerStatus(db, url2, "error", error51);
17203
+ return { url: url2, ok: false, error: error51 };
17204
+ }
17205
+ }
17206
+ async function syncAllPeers(db) {
17207
+ const peers = listPeers(db).filter((p) => p.enabled);
17208
+ const out = [];
17209
+ for (const p of peers)
17210
+ out.push(await syncPeer(db, p.url));
17211
+ return out;
17212
+ }
17213
+
17214
+ // src/core/sync/pairing.ts
17215
+ var DEFAULT_PAIR_TTL_MS = parseDuration(process.env.METAHUB_PAIR_TTL, 10 * 60000);
17216
+ function generatePairingCode(db, ttlMs = DEFAULT_PAIR_TTL_MS) {
17217
+ const now = Date.now();
17218
+ db.query("DELETE FROM pairing_codes WHERE used = 1 OR exp < ?").run(now);
17219
+ const code = randomSuffix(12);
17220
+ const exp = now + ttlMs;
17221
+ db.query("INSERT INTO pairing_codes (code, exp, used, created_at) VALUES (?, ?, 0, ?)").run(code, exp, now);
17222
+ return { code, exp };
17223
+ }
17224
+ function redeemPairingCode(db, code) {
17225
+ return db.query("UPDATE pairing_codes SET used = 1 WHERE code = ? AND used = 0 AND exp >= ?").run(code, Date.now()).changes > 0;
17226
+ }
17227
+ function mintGrant(db, peerUrl, nodeId) {
17228
+ const token = randomSuffix(32);
17229
+ db.query("INSERT INTO peer_grants (token, peer_url, node_id, created_at) VALUES (?, ?, ?, ?)").run(token, peerUrl, nodeId, Date.now());
17230
+ return token;
17231
+ }
17232
+ function isAcceptedGrant(db, token) {
17233
+ return db.query("SELECT 1 FROM peer_grants WHERE token = ?").get(token) != null;
17234
+ }
17235
+ function listGrants(db) {
17236
+ return db.query("SELECT token, peer_url, node_id, created_at FROM peer_grants ORDER BY created_at DESC").all();
17237
+ }
17238
+ function revokeGrant(db, tokenOrPrefix) {
17239
+ return db.query("DELETE FROM peer_grants WHERE token = ? OR token LIKE ? || '%'").run(tokenOrPrefix, tokenOrPrefix).changes;
17240
+ }
17241
+ function handlePairRequest(db, node, body) {
17242
+ if (!redeemPairingCode(db, body.code)) {
17243
+ throw new Error("invalid or expired pairing code");
17244
+ }
17245
+ if (body.self_url) {
17246
+ addPeer(db, { url: body.self_url, token: body.grant, node_id: body.node_id });
17247
+ }
17248
+ const grant = mintGrant(db, body.self_url ?? null, body.node_id);
17249
+ return { node_id: node, grant };
17250
+ }
17251
+ async function performPairing(db, node, remoteUrl, code, selfUrl) {
17252
+ const ourGrant = mintGrant(db, remoteUrl, null);
17253
+ const reqBody = { code, node_id: node, grant: ourGrant, self_url: selfUrl };
17254
+ const res = await fetch(new URL(PAIR_PATH, remoteUrl), {
17255
+ method: "POST",
17256
+ headers: { "content-type": "application/json" },
17257
+ body: JSON.stringify(reqBody)
17258
+ });
17259
+ if (!res.ok)
17260
+ throw new Error(`pairing failed: ${res.status} ${await res.text()}`);
17261
+ const data = await res.json();
17262
+ addPeer(db, { url: remoteUrl, token: data.grant, node_id: data.node_id });
17263
+ return { node_id: data.node_id, url: remoteUrl };
17264
+ }
17265
+
17266
+ // src/core/sync/peers-routes.ts
17267
+ var PeerSchema = exports_external.object({
17268
+ url: exports_external.string(),
17269
+ pull_cursor: exports_external.number(),
17270
+ push_cursor: exports_external.number(),
17271
+ token: exports_external.string().nullable(),
17272
+ label: exports_external.string().nullable(),
17273
+ node_id: exports_external.string().nullable(),
17274
+ enabled: exports_external.number(),
17275
+ last_sync_at: exports_external.number().nullable(),
17276
+ last_status: exports_external.string().nullable(),
17277
+ last_error: exports_external.string().nullable()
17278
+ });
17279
+ var PairingCodeSchema = exports_external.object({ code: exports_external.string(), exp: exports_external.number() });
17280
+ var AddPeerReq = exports_external.object({
17281
+ url: exports_external.string(),
17282
+ code: exports_external.string(),
17283
+ self_url: exports_external.string().optional()
17284
+ });
17285
+ var AddPeerRes = exports_external.object({ node_id: exports_external.string(), url: exports_external.string() });
17286
+ var UpdatePeerReq = exports_external.object({
17287
+ enabled: exports_external.boolean().optional(),
17288
+ label: exports_external.string().optional()
17289
+ });
17290
+ var PeerSyncRes = exports_external.object({
17291
+ url: exports_external.string(),
17292
+ ok: exports_external.boolean(),
17293
+ pushed: exports_external.number().optional(),
17294
+ pulled: exports_external.number().optional(),
17295
+ error: exports_external.string().optional()
17296
+ });
17297
+ var GrantSchema = exports_external.object({
17298
+ token: exports_external.string(),
17299
+ peer_url: exports_external.string().nullable(),
17300
+ node_id: exports_external.string().nullable(),
17301
+ created_at: exports_external.number().nullable()
17302
+ });
17303
+ var RevokeRes = exports_external.object({ revoked: exports_external.number() });
17304
+ var OkSchema2 = exports_external.object({ ok: exports_external.boolean() });
17305
+ function need3(req, key) {
17306
+ const v = new URL(req.url).searchParams.get(key);
17307
+ if (!v)
17308
+ throw new Error(`missing query param: ${key}`);
17309
+ return v;
17310
+ }
17311
+ function handle3(fn) {
17312
+ return async (req, ctx) => {
17313
+ try {
17314
+ const out = await fn(req, ctx);
17315
+ if (out instanceof Response)
17316
+ return out;
17317
+ return Response.json(out ?? null);
17318
+ } catch (e) {
17319
+ return Response.json({ error: e.message }, { status: 400 });
17320
+ }
17321
+ };
17322
+ }
17323
+ var peersRoutes = [
17324
+ {
17325
+ method: "POST",
17326
+ path: PAIR_PATH,
17327
+ summary: "Pairing handshake: redeem a one-time code and exchange credentials",
17328
+ request: PairRequestSchema,
17329
+ response: PairResponseSchema,
17330
+ handler: handle3(async (req, { db, node }) => {
17331
+ const body = await req.json();
17332
+ return handlePairRequest(db, node, body);
17333
+ })
17334
+ },
17335
+ {
17336
+ method: "POST",
17337
+ path: "/api/pair/new",
17338
+ summary: "Mint a one-time pairing code for another device to redeem",
17339
+ response: PairingCodeSchema,
17340
+ handler: handle3((_req, { db }) => generatePairingCode(db))
17341
+ },
17342
+ {
17343
+ method: "POST",
17344
+ path: "/api/peers/pair",
17345
+ summary: "Pair this server with a remote one using its one-time code",
17346
+ request: AddPeerReq,
17347
+ response: AddPeerRes,
17348
+ handler: handle3(async (req, { db, node }) => {
17349
+ const body = await req.json();
17350
+ return performPairing(db, node, body.url, body.code, body.self_url);
17351
+ })
17352
+ },
17353
+ {
17354
+ method: "GET",
17355
+ path: "/api/peers",
17356
+ summary: "List configured sync peers with status",
17357
+ response: exports_external.array(PeerSchema),
17358
+ handler: handle3((_req, { db }) => listPeers(db))
17359
+ },
17360
+ {
17361
+ method: "PATCH",
17362
+ path: "/api/peer",
17363
+ summary: "Update a peer (enable/disable or label). Query: ?url=<url>",
17364
+ request: UpdatePeerReq,
17365
+ response: OkSchema2,
17366
+ handler: handle3(async (req, { db }) => {
17367
+ const url2 = need3(req, "url");
17368
+ const body = await req.json();
17369
+ if (body.enabled != null)
17370
+ setPeerEnabled(db, url2, body.enabled);
17371
+ if (body.label != null)
17372
+ setPeerLabel(db, url2, body.label);
17373
+ return { ok: true };
17374
+ })
17375
+ },
17376
+ {
17377
+ method: "DELETE",
17378
+ path: "/api/peer",
17379
+ summary: "Remove a sync peer. Query: ?url=<url>",
17380
+ response: OkSchema2,
17381
+ handler: handle3((req, { db }) => ({ ok: removePeer(db, need3(req, "url")) }))
17382
+ },
17383
+ {
17384
+ method: "POST",
17385
+ path: "/api/peer/sync",
17386
+ summary: "Manually sync once with a peer. Query: ?url=<url>",
17387
+ response: PeerSyncRes,
17388
+ handler: handle3((req, { db }) => syncPeer(db, need3(req, "url")))
17389
+ },
17390
+ {
17391
+ method: "GET",
17392
+ path: "/api/grants",
17393
+ summary: "List credentials this server issued and accepts on /sync (inbound)",
17394
+ response: exports_external.array(GrantSchema),
17395
+ handler: handle3((_req, { db }) => listGrants(db))
17396
+ },
17397
+ {
17398
+ method: "DELETE",
17399
+ path: "/api/grant",
17400
+ summary: "Revoke an issued credential by token or prefix. Query: ?token=<token>",
17401
+ response: RevokeRes,
17402
+ handler: handle3((req, { db }) => ({ revoked: revokeGrant(db, need3(req, "token")) }))
17403
+ }
17404
+ ];
17405
+
16940
17406
  // src/core/sync/routes.ts
16941
17407
  var syncRoutes = [
16942
17408
  {
@@ -16962,77 +17428,7 @@ var syncRoutes = [
16962
17428
  }
16963
17429
  }
16964
17430
  ];
16965
- var routes = [...syncRoutes, ...webuiRoutes, ...sitesRoutes];
16966
-
16967
- // src/core/sync/token.ts
16968
- init_ids();
16969
- var K_TOKEN = "auth_token";
16970
- var K_EXP = "auth_token_exp";
16971
- var K_PREV = "auth_token_prev";
16972
- var K_PREV_EXP = "auth_token_prev_exp";
16973
- function parseDuration(s, fallbackMs) {
16974
- if (!s)
16975
- return fallbackMs;
16976
- const m = /^(\d+)\s*(ms|s|m|h|d)?$/.exec(s.trim());
16977
- if (!m)
16978
- return fallbackMs;
16979
- const n = Number(m[1]);
16980
- switch (m[2]) {
16981
- case "ms":
16982
- return n;
16983
- case "m":
16984
- return n * 60000;
16985
- case "h":
16986
- return n * 3600000;
16987
- case "d":
16988
- return n * 86400000;
16989
- default:
16990
- return n * 1000;
16991
- }
16992
- }
16993
- var DAY = 86400000;
16994
- var DEFAULT_TTL_MS = parseDuration(process.env.METAHUB_TOKEN_TTL, 30 * DAY);
16995
- var DEFAULT_GRACE_MS = parseDuration(process.env.METAHUB_TOKEN_GRACE, 7 * DAY);
16996
- function getMeta(db, key) {
16997
- const row = db.query("SELECT value FROM meta WHERE key = ?").get(key);
16998
- return row ? row.value : null;
16999
- }
17000
- function setMeta(db, key, value) {
17001
- db.query("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value);
17002
- }
17003
- function readState(db) {
17004
- const token = getMeta(db, K_TOKEN);
17005
- if (!token)
17006
- return null;
17007
- const prev = getMeta(db, K_PREV);
17008
- return {
17009
- token,
17010
- exp: Number(getMeta(db, K_EXP) ?? 0),
17011
- prev: prev && prev.length > 0 ? prev : null,
17012
- prevExp: Number(getMeta(db, K_PREV_EXP) ?? 0)
17013
- };
17014
- }
17015
- function rotate(db, ttlMs, graceMs) {
17016
- const now = Date.now();
17017
- const old = getMeta(db, K_TOKEN);
17018
- const next = {
17019
- token: randomSuffix(24),
17020
- exp: now + ttlMs,
17021
- prev: old,
17022
- prevExp: old ? now + graceMs : 0
17023
- };
17024
- setMeta(db, K_TOKEN, next.token);
17025
- setMeta(db, K_EXP, String(next.exp));
17026
- setMeta(db, K_PREV, next.prev ?? "");
17027
- setMeta(db, K_PREV_EXP, String(next.prevExp));
17028
- return next;
17029
- }
17030
- function loadOrRotate(db, ttlMs, graceMs) {
17031
- const s = readState(db);
17032
- if (!s || Date.now() > s.exp)
17033
- return rotate(db, ttlMs, graceMs);
17034
- return s;
17035
- }
17431
+ var routes = [...syncRoutes, ...webuiRoutes, ...sitesRoutes, ...peersRoutes];
17036
17432
 
17037
17433
  // src/core/sync/openapi.ts
17038
17434
  function buildOpenApi(version2) {
@@ -17101,6 +17497,17 @@ function hasValidToken(req, url2, cfg) {
17101
17497
  return true;
17102
17498
  return extractToken(req, url2) === cur.token;
17103
17499
  }
17500
+ function acceptsSyncToken(req, url2, cfg, db) {
17501
+ if (!authActive(cfg))
17502
+ return true;
17503
+ const presented = extractToken(req, url2);
17504
+ if (!presented)
17505
+ return false;
17506
+ const cur = activeToken(cfg);
17507
+ if (cur && presented === cur.token)
17508
+ return true;
17509
+ return isAcceptedGrant(db, presented);
17510
+ }
17104
17511
  function renewToken(req, url2, cfg) {
17105
17512
  if (cfg.debug || cfg.staticToken != null || !cfg.db)
17106
17513
  return null;
@@ -17240,6 +17647,11 @@ function startServer(opts = {}) {
17240
17647
  const db = openMetahub();
17241
17648
  const node = getNodeId(db);
17242
17649
  const ctx = { db, node };
17650
+ const cfg = getServerConfig(db);
17651
+ const port = opts.port ?? cfg.port;
17652
+ const host = opts.host ?? cfg.host;
17653
+ const syncIntervalMs = opts.syncIntervalMs ?? cfg.syncIntervalMs;
17654
+ const autoSync = opts.autoSync ?? cfg.autoSync;
17243
17655
  const auth = opts.debug ? { debug: true, staticToken: null, db: null, ttlMs: 0, graceMs: 0 } : opts.token ? { debug: false, staticToken: opts.token, db: null, ttlMs: 0, graceMs: 0 } : {
17244
17656
  debug: false,
17245
17657
  staticToken: null,
@@ -17248,13 +17660,18 @@ function startServer(opts = {}) {
17248
17660
  graceMs: DEFAULT_GRACE_MS
17249
17661
  };
17250
17662
  const server = Bun.serve({
17251
- port: opts.port ?? 7777,
17252
- hostname: opts.host ?? "127.0.0.1",
17663
+ port,
17664
+ hostname: host,
17253
17665
  async fetch(req) {
17254
17666
  const url2 = new URL(req.url);
17255
- const exempt = url2.pathname === SYNC_PATH || url2.pathname === HEALTH_PATH || url2.pathname === RENEW_PATH;
17256
- if (!exempt && !hasValidToken(req, url2, auth)) {
17257
- return wantsHtml(req) ? new Response(unlockPage(), { headers: HTML_HEADERS }) : unauthorized();
17667
+ if (url2.pathname === SYNC_PATH) {
17668
+ if (!acceptsSyncToken(req, url2, auth, db))
17669
+ return unauthorized();
17670
+ } else {
17671
+ const exempt = url2.pathname === HEALTH_PATH || url2.pathname === RENEW_PATH || url2.pathname === PAIR_PATH;
17672
+ if (!exempt && !hasValidToken(req, url2, auth)) {
17673
+ return wantsHtml(req) ? new Response(unlockPage(), { headers: HTML_HEADERS }) : unauthorized();
17674
+ }
17258
17675
  }
17259
17676
  if (url2.pathname === RENEW_PATH) {
17260
17677
  const r = renewToken(req, url2, auth);
@@ -17284,13 +17701,25 @@ function startServer(opts = {}) {
17284
17701
  return new Response("not found", { status: 404 });
17285
17702
  }
17286
17703
  });
17704
+ let timer = null;
17705
+ if (autoSync && syncIntervalMs > 0) {
17706
+ timer = setInterval(() => {
17707
+ syncAllPeers(db).catch(() => {});
17708
+ }, syncIntervalMs);
17709
+ timer.unref?.();
17710
+ }
17287
17711
  const active = activeToken(auth);
17288
17712
  return {
17289
17713
  server,
17290
17714
  node,
17291
- port: server.port ?? opts.port ?? 7777,
17715
+ port: server.port ?? port,
17292
17716
  token: active?.token ?? null,
17293
- exp: active?.exp ?? null
17717
+ exp: active?.exp ?? null,
17718
+ stop() {
17719
+ if (timer)
17720
+ clearInterval(timer);
17721
+ server.stop();
17722
+ }
17294
17723
  };
17295
17724
  }
17296
17725
  export {
@@ -17324,6 +17753,7 @@ export {
17324
17753
  newId,
17325
17754
  moveRecord,
17326
17755
  migrateRecords,
17756
+ migratePeers,
17327
17757
  metahubHome,
17328
17758
  maybeAutoIndex,
17329
17759
  makeId,
@@ -17380,10 +17810,13 @@ export {
17380
17810
  SyncRequestSchema,
17381
17811
  SYNC_PATH,
17382
17812
  RENEW_PATH,
17813
+ PairResponseSchema,
17814
+ PairRequestSchema,
17383
17815
  PROP_TYPES,
17816
+ PAIR_PATH,
17384
17817
  HealthResponseSchema,
17385
17818
  HEALTH_PATH,
17386
17819
  ChangeSchema
17387
17820
  };
17388
17821
 
17389
- //# debugId=884E1F97976309E464756E2164756E21
17822
+ //# debugId=52E20C0E56A04E8A64756E2164756E21