@tensorix/metahub 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +50 -5
  2. package/dist/cli.js +3160 -196
  3. package/dist/cli.js.map +37 -21
  4. package/dist/core/config.d.ts +14 -0
  5. package/dist/core/config.d.ts.map +1 -0
  6. package/dist/core/databases.d.ts.map +1 -1
  7. package/dist/core/db.d.ts +6 -0
  8. package/dist/core/db.d.ts.map +1 -1
  9. package/dist/core/documents.d.ts +1 -0
  10. package/dist/core/documents.d.ts.map +1 -1
  11. package/dist/core/integrity.d.ts +32 -0
  12. package/dist/core/integrity.d.ts.map +1 -0
  13. package/dist/core/properties.d.ts.map +1 -1
  14. package/dist/core/schema.d.ts +1 -1
  15. package/dist/core/schema.d.ts.map +1 -1
  16. package/dist/core/search.d.ts +2 -0
  17. package/dist/core/search.d.ts.map +1 -1
  18. package/dist/core/snapshot.d.ts +2 -0
  19. package/dist/core/snapshot.d.ts.map +1 -1
  20. package/dist/core/sync/auth.d.ts +6 -0
  21. package/dist/core/sync/auth.d.ts.map +1 -1
  22. package/dist/core/sync/client.d.ts +7 -2
  23. package/dist/core/sync/client.d.ts.map +1 -1
  24. package/dist/core/sync/pairing.d.ts +50 -0
  25. package/dist/core/sync/pairing.d.ts.map +1 -0
  26. package/dist/core/sync/peers-routes.d.ts +3 -0
  27. package/dist/core/sync/peers-routes.d.ts.map +1 -0
  28. package/dist/core/sync/peers.d.ts +45 -0
  29. package/dist/core/sync/peers.d.ts.map +1 -0
  30. package/dist/core/sync/protocol.d.ts +13 -0
  31. package/dist/core/sync/protocol.d.ts.map +1 -1
  32. package/dist/core/sync/routes.d.ts.map +1 -1
  33. package/dist/core/sync/server.d.ts +20 -0
  34. package/dist/core/sync/server.d.ts.map +1 -1
  35. package/dist/core/sync/webui-routes.d.ts.map +1 -1
  36. package/dist/core/sync/webui.d.ts +11 -0
  37. package/dist/core/sync/webui.d.ts.map +1 -1
  38. package/dist/index.js +806 -143
  39. package/dist/index.js.map +21 -17
  40. package/dist/webui.js +33 -32
  41. package/package.json +4 -2
package/dist/index.js CHANGED
@@ -531,9 +531,14 @@ var init_sites = __esm(() => {
531
531
  // src/core/sync/webui.ts
532
532
  var exports_webui = {};
533
533
  __export(exports_webui, {
534
+ warmWebui: () => warmWebui,
535
+ setWebuiBundle: () => setWebuiBundle,
534
536
  serveWebui: () => serveWebui
535
537
  });
536
538
  import { fileURLToPath } from "url";
539
+ function setWebuiBundle(js) {
540
+ cachedJs = js;
541
+ }
537
542
  async function getJs() {
538
543
  if (cachedJs != null)
539
544
  return cachedJs;
@@ -549,6 +554,11 @@ async function getJs() {
549
554
  cachedJs = await res.outputs[0].text();
550
555
  return cachedJs;
551
556
  }
557
+ async function warmWebui() {
558
+ try {
559
+ await getJs();
560
+ } catch {}
561
+ }
552
562
  async function serveWebui(req) {
553
563
  if (req.method !== "GET")
554
564
  return null;
@@ -575,7 +585,13 @@ var HTML = `<!doctype html>
575
585
  <title>Metahub</title>
576
586
  <link rel="preconnect" href="https://fonts.googleapis.com">
577
587
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
578
- <link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
588
+ <!-- Load web fonts without blocking first paint: media="print" keeps the
589
+ stylesheet non-render-blocking, then onload flips it to "all". The page
590
+ paints instantly with the system-font fallbacks baked into --ui/--mono and
591
+ swaps to the web fonts when they arrive (or stays on system fonts offline).
592
+ This is what kept the desktop cold-start white for seconds on slow networks. -->
593
+ <link rel="stylesheet" media="print" onload="this.media='all'" href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap">
594
+ <noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"></noscript>
579
595
  <script>try{document.documentElement.dataset.theme=localStorage.getItem('mh-theme')||'system'}catch(e){}</script>
580
596
  <style>
581
597
  :root {
@@ -685,6 +701,9 @@ var HTML = `<!doctype html>
685
701
  .set-section { margin-top:34px; }
686
702
  .set-section-head { font-size:12px; font-weight:600; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:4px; }
687
703
  .set-section-desc { color:var(--fg-soft); margin-bottom:16px; }
704
+ .set-footer { margin-top:40px; padding-top:20px; border-top:1px solid var(--line); display:flex;
705
+ justify-content:center; gap:8px; color:var(--muted); font-size:12px; }
706
+ .set-footer-sep { opacity:.5; }
688
707
  .theme-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; }
689
708
  .theme-card { position:relative; display:flex; flex-direction:column; align-items:flex-start; gap:10px;
690
709
  text-align:left; padding:16px; border:1px solid var(--line-strong); border-radius:var(--radius);
@@ -704,9 +723,25 @@ var HTML = `<!doctype html>
704
723
  .theme-card.sel .tc-check { opacity:1; transform:scale(1); }
705
724
  @media (max-width:560px) { .theme-grid { grid-template-columns:1fr; } }
706
725
 
726
+ /* sync devices */
727
+ .peer-actions { display:flex; gap:10px; margin-bottom:16px; flex-wrap:wrap; }
728
+ .peer-actions .btn { display:inline-flex; align-items:center; gap:6px; }
729
+ .peer-list { display:flex; flex-direction:column; gap:8px; }
730
+ .peer-row { display:flex; align-items:center; gap:12px; padding:12px 14px; border:1px solid var(--line);
731
+ border-radius:10px; background:var(--surface); }
732
+ .peer-dot { width:9px; height:9px; border-radius:50%; flex:none; background:var(--muted); }
733
+ .peer-dot.on { background:#3fb950; } .peer-dot.err { background:#f85149; } .peer-dot.off { background:var(--muted); }
734
+ .peer-main { flex:1; min-width:0; }
735
+ .peer-url { font-weight:600; font-size:14px; color:var(--fg); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
736
+ .peer-sub { font-size:12px; color:var(--muted); margin-top:2px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
737
+ .peer-menu { flex:none; padding:6px; }
738
+ .pair-code { font-family:var(--mono); font-size:26px; font-weight:700; letter-spacing:.12em;
739
+ text-align:center; padding:16px; border:1px dashed var(--line); border-radius:10px; background:var(--surface); color:var(--fg); }
740
+
707
741
  /* main */
708
742
  .main { flex:1; display:flex; flex-direction:column; overflow:hidden; min-width:0; }
709
743
  .topbar { display:flex; align-items:center; gap:6px; padding:10px 18px; border-bottom:1px solid var(--line); min-height:49px; }
744
+ .topbar.bare { border-bottom:0; }
710
745
  .topbar .hamburger { display:none; }
711
746
  .topbar .hamburger.show-collapsed { display:grid; }
712
747
  .sidebar.collapsed { overflow:hidden; }
@@ -1031,7 +1066,71 @@ var HTML = `<!doctype html>
1031
1066
  @keyframes slidein { from { opacity:0; transform:translateX(-12px); } }
1032
1067
 
1033
1068
  /* misc */
1034
- .empty { display:grid; place-items:center; height:100%; color:var(--muted); text-align:center; padding:40px; }
1069
+ .empty { display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%;
1070
+ color:var(--muted); text-align:center; padding:40px; }
1071
+
1072
+ /* ---- empty state: \u77E5\u8BC6\u751F\u957F\u63D2\u753B ---- */
1073
+ .estate-art { width:240px; height:208px; overflow:visible; animation:idleFloat 5s ease-in-out 1.6s infinite; }
1074
+ .estate-art .stroke-a { fill:none; stroke:var(--accent); stroke-width:2.4; stroke-linecap:round; stroke-linejoin:round; }
1075
+ .estate-art .stroke { fill:none; stroke:var(--line-strong); stroke-width:2.4; stroke-linecap:round; stroke-linejoin:round; }
1076
+ .estate-art .fill-a { fill:var(--accent); }
1077
+ .estate-art .fill-soft { fill:var(--accent-soft); }
1078
+ .estate-art .fill-surface { fill:var(--bg); }
1079
+ .estate-art .ground { fill:var(--accent); }
1080
+ .estate-title { margin:28px 0 0; font-size:18px; font-weight:600; letter-spacing:.2px; color:var(--fg);
1081
+ opacity:0; animation:fadeUp .5s ease forwards 1.0s; }
1082
+ .estate-hint { margin:9px 0 0; font-size:13.5px; line-height:1.6; color:var(--muted); max-width:300px;
1083
+ opacity:0; animation:fadeUp .5s ease forwards 1.15s; }
1084
+ .estate-link { margin:18px 0 0; font-size:14px; font-weight:600; color:var(--accent);
1085
+ display:inline-flex; align-items:center; gap:6px; padding:9px 8px; border-radius:var(--radius-sm);
1086
+ opacity:0; animation:fadeUp .5s ease forwards 1.3s; transition:opacity .15s; }
1087
+ .estate-link:hover { opacity:.7; }
1088
+ /* intro */
1089
+ .draw { stroke-dasharray:100; stroke-dashoffset:100; }
1090
+ @keyframes draw { to { stroke-dashoffset:0; } }
1091
+ @keyframes leafGrow { from { opacity:0; transform:scale(.2) rotate(-10deg); } to { opacity:1; transform:scale(1) rotate(0); } }
1092
+ @keyframes cardUp { from { opacity:0; transform:translateY(16px) scale(.96); } to { opacity:1; transform:translateY(0) scale(1); } }
1093
+ @keyframes groundIn { to { opacity:.1; } }
1094
+ @keyframes glowIn { to { opacity:.6; } }
1095
+ @keyframes fadeUp { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:translateY(0); } }
1096
+ /* idle loops \u2014 each 0% equals the settled rest state to avoid a jump */
1097
+ @keyframes idleFloat { 0%,100% { transform:translateY(0); } 50% { transform:translateY(-5px); } }
1098
+ @keyframes plantSway { 0%,100% { transform:rotate(0deg); } 25% { transform:rotate(-1.3deg); } 75% { transform:rotate(1.3deg); } }
1099
+ @keyframes leafSwayA { 0%,100% { transform:rotate(0deg); } 25% { transform:rotate(-2.6deg); } 75% { transform:rotate(2.6deg); } }
1100
+ @keyframes leafSwayB { 0%,100% { transform:rotate(0deg); } 25% { transform:rotate(2.6deg); } 75% { transform:rotate(-2.6deg); } }
1101
+ @keyframes groundBreathe { 0%,100% { transform:scale(1); opacity:.10; } 50% { transform:scale(1.07); opacity:.15; } }
1102
+ @keyframes glowPulse { 0%,100% { transform:scale(1); opacity:.6; } 50% { transform:scale(1.1); opacity:.85; } }
1103
+ @keyframes particle { 0% { opacity:0; transform:translate(0,0) scale(.4); } 35% { opacity:.85; transform:translate(2px,-4px) scale(1); } 70% { opacity:0; transform:translate(4px,-9px) scale(.5); } 100% { opacity:0; transform:translate(0,0) scale(.4); } }
1104
+ /* wiring */
1105
+ .kg-ground { transform-box:fill-box; transform-origin:center; opacity:0;
1106
+ animation:groundIn .5s ease forwards .1s, groundBreathe 6s ease-in-out 1.6s infinite; }
1107
+ .kg-glow { filter:blur(9px); transform-box:fill-box; transform-origin:center; opacity:0;
1108
+ animation:glowIn .6s ease forwards .1s, glowPulse 4.6s ease-in-out 1.6s infinite; }
1109
+ .kg-cardB { transform-box:fill-box; transform-origin:center; opacity:0; animation:cardUp .55s cubic-bezier(.2,.8,.2,1) forwards .2s; }
1110
+ .kg-cardA { transform-box:fill-box; transform-origin:center; opacity:0; animation:cardUp .55s cubic-bezier(.2,.8,.2,1) forwards .32s; }
1111
+ .kg-plant { transform-box:fill-box; transform-origin:50% 100%; animation:plantSway 6s ease-in-out 1.7s infinite; }
1112
+ .kg-stem { animation:draw .9s ease-out forwards .4s; }
1113
+ .kg-leaf { transform-box:fill-box; }
1114
+ .kg-grow { transform-box:fill-box; opacity:0; }
1115
+ .kg-leaf.l1 { transform-origin:90% 100%; animation:leafSwayA 4.6s ease-in-out 1.7s infinite; }
1116
+ .kg-leaf.l2 { transform-origin:0% 80%; animation:leafSwayB 5.2s ease-in-out 1.8s infinite; }
1117
+ .kg-leaf.l3 { transform-origin:0% 100%; animation:leafSwayA 5.0s ease-in-out 1.9s infinite; }
1118
+ .kg-leaf.l1 .kg-grow { transform-origin:90% 100%; animation:leafGrow .52s cubic-bezier(.18,.9,.24,1.22) forwards .85s; }
1119
+ .kg-leaf.l2 .kg-grow { transform-origin:0% 80%; animation:leafGrow .52s cubic-bezier(.18,.9,.24,1.22) forwards 1.0s; }
1120
+ .kg-leaf.l3 .kg-grow { transform-origin:0% 100%; animation:leafGrow .52s cubic-bezier(.18,.9,.24,1.22) forwards 1.15s; }
1121
+ .kg-particle { transform-box:fill-box; transform-origin:center; opacity:0; }
1122
+ .kg-particle.p1 { animation:particle 4.5s ease-in-out 1.6s infinite; }
1123
+ .kg-particle.p2 { animation:particle 5.2s ease-in-out 2.0s infinite; }
1124
+ .kg-particle.p3 { animation:particle 4.8s ease-in-out 2.6s infinite; }
1125
+ .kg-particle.p4 { animation:particle 5.6s ease-in-out 3.1s infinite; }
1126
+ .kg-particle.p5 { animation:particle 4.2s ease-in-out 2.3s infinite; }
1127
+ @media (prefers-reduced-motion: reduce) {
1128
+ .estate-art, .estate-art *, .estate-title, .estate-hint, .estate-link { animation:none !important; opacity:1 !important; transform:none !important; }
1129
+ .draw { stroke-dashoffset:0 !important; }
1130
+ .kg-ground { opacity:.1 !important; }
1131
+ .kg-glow { opacity:.6 !important; }
1132
+ .kg-particle { opacity:0 !important; }
1133
+ }
1035
1134
  .error-bar { margin:12px 24px; padding:10px 14px; background:var(--danger-soft); color:var(--danger); border-radius:var(--radius-sm); font-size:13px; cursor:pointer; }
1036
1135
  .view-placeholder { padding:40px; text-align:center; border:1px dashed var(--line-strong); border-radius:var(--radius); color:var(--muted); margin-top:8px; }
1037
1136
  .ico.flip { transform:scaleX(-1); }
@@ -1140,6 +1239,49 @@ var HTML = `<!doctype html>
1140
1239
  .db { padding:16px 14px 90px; }
1141
1240
  .peek { width:100%; }
1142
1241
  }
1242
+
1243
+ /* Quick Notes window (desktop, loaded at #quick). A transparent body lets the
1244
+ macOS vibrancy blur show through; a translucent panel keeps text readable.
1245
+ The top bar is the window drag region; its buttons opt back out. */
1246
+ body.quicknote { background:transparent; }
1247
+ /* The main app lays #app out as a flex row (sidebar + main); the quick-note
1248
+ route has a single child, so override to a full-bleed block or the panel
1249
+ shrinks to its content width and the window's vibrancy shows through. */
1250
+ body.quicknote #app { display:block; width:100vw; height:100vh; }
1251
+ .qn { display:flex; flex-direction:column; width:100%; height:100vh; overflow:hidden;
1252
+ background:color-mix(in srgb, var(--bg) 20%, transparent); }
1253
+ .qn-bar { display:flex; align-items:center; gap:6px; padding:4px 10px 4px 76px;
1254
+ user-select:none; -webkit-app-region:drag;
1255
+ background:color-mix(in srgb, var(--surface) 30%, transparent); }
1256
+ .qn-brand { flex:1; min-width:0; font-size:12px; font-weight:600; color:var(--fg-soft);
1257
+ white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
1258
+ .qn-actions { display:flex; gap:2px; -webkit-app-region:no-drag; }
1259
+ .qn-actions .iconbtn { width:26px; height:26px; }
1260
+ .qn-actions .iconbtn.active { color:var(--accent); background:var(--hover-2); }
1261
+ .qn-body { flex:1; overflow:auto; -webkit-app-region:no-drag; }
1262
+ .qn-body .doc { max-width:none; margin:0; padding:14px 22px 30vh; }
1263
+ .qn-body .doc-title { font-size:24px; margin-bottom:2px; }
1264
+ .qn-body .doc-meta { display:none; }
1265
+ /* Compact menus inside the quick-note window only (dropdown + slash command
1266
+ share .pop/.item/.lico with the main app; scope under body.quicknote so the
1267
+ main app keeps its roomier density). */
1268
+ body.quicknote .pop { padding:5px; border-radius:9px; }
1269
+ body.quicknote .pop .lbl { padding:4px 8px 2px; font-size:10px; }
1270
+ body.quicknote .pop .item { padding:5px 9px; gap:8px; font-size:12.5px; }
1271
+ body.quicknote .pop .item .t { font-size:12.5px; }
1272
+ body.quicknote .pop .item .d { font-size:10.5px; }
1273
+ body.quicknote .pop .item .lico { width:22px; height:22px; }
1274
+ body.quicknote .pop .item .lico.plain { width:20px; height:20px; }
1275
+ body.quicknote .pop .sep { margin:4px 4px; }
1276
+
1277
+ /* Quick Notes settings rows (desktop only). */
1278
+ .qn-set-row { display:flex; align-items:center; gap:14px; padding:12px 0; border-top:1px solid var(--line); }
1279
+ .qn-set-row:first-of-type { border-top:0; }
1280
+ .qn-set-main { flex:1; min-width:0; }
1281
+ .qn-set-name { font-weight:600; }
1282
+ .qn-set-desc { color:var(--muted); font-size:13px; margin-top:2px; }
1283
+ .qn-shortcut { min-width:140px; justify-content:center; font-family:var(--mono); }
1284
+ .qn-shortcut.capturing { outline:2px solid var(--accent); color:var(--accent); }
1143
1285
  </style>
1144
1286
  </head>
1145
1287
  <body>
@@ -1207,11 +1349,41 @@ CREATE TABLE IF NOT EXISTS crdt_changes (
1207
1349
  CREATE INDEX IF NOT EXISTS idx_changes_hlc ON crdt_changes(hlc);
1208
1350
 
1209
1351
  -- Per-peer replication cursors (SQLite rowids: monotonic in insertion order,
1210
- -- so no change is ever skipped regardless of HLC/clock skew).
1352
+ -- so no change is ever skipped regardless of HLC/clock skew). Outbound side of
1353
+ -- a pairing: "token" is the credential the *remote* issued to us (sent as a
1354
+ -- Bearer header when we sync to them). See migratePeers in db.ts for the columns
1355
+ -- added to legacy databases.
1211
1356
  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
1357
+ url TEXT PRIMARY KEY,
1358
+ pull_cursor INTEGER NOT NULL DEFAULT 0,
1359
+ push_cursor INTEGER NOT NULL DEFAULT 0,
1360
+ token TEXT,
1361
+ label TEXT,
1362
+ node_id TEXT,
1363
+ enabled INTEGER NOT NULL DEFAULT 1,
1364
+ last_sync_at INTEGER,
1365
+ last_status TEXT,
1366
+ last_error TEXT
1367
+ );
1368
+
1369
+ -- Inbound side of a pairing: credentials *we* issued to remote devices. A peer
1370
+ -- presents one of these on /sync; acceptsSyncToken() checks it alongside the
1371
+ -- managed master token. Revoke by deleting the row.
1372
+ CREATE TABLE IF NOT EXISTS peer_grants (
1373
+ token TEXT PRIMARY KEY,
1374
+ peer_url TEXT,
1375
+ node_id TEXT,
1376
+ created_at INTEGER
1377
+ );
1378
+
1379
+ -- One-time pairing codes minted by "mh config peer code" / POST /api/pair/new.
1380
+ -- Short-lived and single-use: redeemed during the pairing handshake, then the
1381
+ -- two devices exchange durable peer_grants. used/exp gate redemption.
1382
+ CREATE TABLE IF NOT EXISTS pairing_codes (
1383
+ code TEXT PRIMARY KEY,
1384
+ exp INTEGER NOT NULL,
1385
+ used INTEGER NOT NULL DEFAULT 0,
1386
+ created_at INTEGER
1215
1387
  );
1216
1388
 
1217
1389
  CREATE TABLE IF NOT EXISTS databases (
@@ -1338,9 +1510,20 @@ function deleteDatabase(db, id) {
1338
1510
  if (!getDatabase(db, id))
1339
1511
  return false;
1340
1512
  emit(db, "databases", id, "__deleted", 1);
1513
+ const tombstone = (table) => {
1514
+ for (const r of liveChildren(db, table, "database_id", id))
1515
+ emit(db, table, r, "__deleted", 1);
1516
+ };
1517
+ tombstone("properties");
1518
+ tombstone("records");
1519
+ for (const d of liveChildren(db, "documents", "database_id", id))
1520
+ emit(db, "documents", d, "database_id", null);
1341
1521
  db.query("DELETE FROM meta WHERE key = 'current_db' AND value = ?").run(id);
1342
1522
  return true;
1343
1523
  }
1524
+ function liveChildren(db, table, col, parentId) {
1525
+ return db.query(`SELECT id FROM ${table} WHERE ${col} = ? AND __deleted = 0`).all(parentId).map((r) => r.id);
1526
+ }
1344
1527
 
1345
1528
  // src/core/properties.ts
1346
1529
  init_ids();
@@ -1469,9 +1652,13 @@ function setPropertyWidth(db, id, width) {
1469
1652
  return updateProperty(db, id, { config: { ...cur.config ?? {}, width: w } });
1470
1653
  }
1471
1654
  function removeProperty(db, id) {
1472
- if (!getProperty(db, id))
1655
+ const prop = getProperty(db, id);
1656
+ if (!prop)
1473
1657
  return false;
1474
1658
  emit(db, "properties", id, "__deleted", 1);
1659
+ const rows = db.query("SELECT id FROM records WHERE database_id = ? AND __deleted = 0 AND data ->> ? IS NOT NULL").all(prop.database_id, id);
1660
+ for (const r of rows)
1661
+ emit(db, "records", r.id, id, undefined);
1475
1662
  return true;
1476
1663
  }
1477
1664
 
@@ -1924,12 +2111,28 @@ function migrateRecords(db) {
1924
2111
  tx();
1925
2112
  backfillRecordOrderKeys(db);
1926
2113
  }
2114
+ function migratePeers(db) {
2115
+ const add = [
2116
+ ["token", "TEXT"],
2117
+ ["label", "TEXT"],
2118
+ ["node_id", "TEXT"],
2119
+ ["enabled", "INTEGER NOT NULL DEFAULT 1"],
2120
+ ["last_sync_at", "INTEGER"],
2121
+ ["last_status", "TEXT"],
2122
+ ["last_error", "TEXT"]
2123
+ ];
2124
+ for (const [col, decl] of add) {
2125
+ if (!hasColumn(db, "peers", col))
2126
+ db.exec(`ALTER TABLE peers ADD COLUMN ${col} ${decl}`);
2127
+ }
2128
+ }
1927
2129
  function openMetahub() {
1928
2130
  ensureDirs();
1929
2131
  const db = new Database(dbPath(), { create: true });
1930
2132
  db.exec("PRAGMA journal_mode = WAL;");
1931
2133
  runSchema(db);
1932
2134
  migrateRecords(db);
2135
+ migratePeers(db);
1933
2136
  return db;
1934
2137
  }
1935
2138
 
@@ -2032,9 +2235,12 @@ function getDocument(db, id) {
2032
2235
  return db.query("SELECT id, title, body, database_id, parent_id, created_hlc FROM documents WHERE id = ? AND __deleted = 0").get(id);
2033
2236
  }
2034
2237
  function listDocuments(db, opts = {}) {
2238
+ const cols = "SELECT id, title, database_id, parent_id, created_hlc FROM documents WHERE __deleted = 0";
2239
+ if (opts.parent_id !== undefined)
2240
+ return db.query(`${cols} AND parent_id = ? ORDER BY created_hlc`).all(opts.parent_id);
2035
2241
  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();
2242
+ return db.query(`${cols} AND database_id = ? ORDER BY created_hlc`).all(opts.database_id);
2243
+ return db.query(`${cols} ORDER BY created_hlc`).all();
2038
2244
  }
2039
2245
  function updateDocument(db, id, fields) {
2040
2246
  if (!getDocument(db, id))
@@ -2118,8 +2324,15 @@ function deleteDocument(db, id) {
2118
2324
  if (!getDocument(db, id))
2119
2325
  return false;
2120
2326
  emit(db, "documents", id, "__deleted", 1);
2327
+ for (const b of liveChildIds(db, "doc_blocks", "doc_id", id))
2328
+ emit(db, "doc_blocks", b, "__deleted", 1);
2329
+ for (const c of liveChildIds(db, "documents", "parent_id", id))
2330
+ emit(db, "documents", c, "parent_id", null);
2121
2331
  return true;
2122
2332
  }
2333
+ function liveChildIds(db, table, col, parentId) {
2334
+ return db.query(`SELECT id FROM ${table} WHERE ${col} = ? AND __deleted = 0`).all(parentId).map((r) => r.id);
2335
+ }
2123
2336
  function countOccurrences(haystack, needle) {
2124
2337
  if (needle === "")
2125
2338
  return 0;
@@ -2132,26 +2345,112 @@ function countOccurrences(haystack, needle) {
2132
2345
  return n;
2133
2346
  }
2134
2347
  // src/core/search.ts
2348
+ init_crdt();
2135
2349
  var TEXT_TYPES = "('text','url','select','multi_select','date')";
2136
- function ensureIndex(db) {
2137
- if (!ftsAvailable(db))
2138
- return false;
2139
- const max = db.query("SELECT MAX(hlc) AS h FROM crdt_changes").get().h ?? "";
2140
- const indexed = db.query("SELECT value FROM meta WHERE key = 'search_hlc'").get()?.value ?? "";
2141
- if (max === indexed)
2142
- return true;
2143
- db.query("DELETE FROM search_fts").run();
2144
- for (const d of db.query("SELECT id, title, body, database_id FROM documents WHERE __deleted = 0").all()) {
2350
+ var SEARCH_INDEX_VERSION = "1";
2351
+ function readMeta(db, key) {
2352
+ return db.query("SELECT value FROM meta WHERE key = ?").get(key)?.value ?? null;
2353
+ }
2354
+ function writeMeta(db, key, value) {
2355
+ db.query("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value);
2356
+ }
2357
+ function reindexDocument(db, id) {
2358
+ db.query("DELETE FROM search_fts WHERE kind = 'document' AND id = ?").run(id);
2359
+ const d = db.query("SELECT id, title, body, database_id FROM documents WHERE id = ? AND __deleted = 0").get(id);
2360
+ if (d)
2145
2361
  db.query("INSERT INTO search_fts (kind, id, database_id, title, body) VALUES ('document', ?, ?, ?, ?)").run(d.id, d.database_id, d.title ?? "", d.body ?? "");
2146
- }
2147
- for (const r of db.query(`SELECT r.id AS id, r.database_id AS database_id, group_concat(je.value, ' ') AS body
2362
+ }
2363
+ function recordBodyRows(db, where, ...args) {
2364
+ return db.query(`SELECT r.id AS id, r.database_id AS database_id, group_concat(je.value, ' ') AS body
2148
2365
  FROM records r, json_each(r.data) je
2149
2366
  JOIN properties p ON p.id = je.key AND p.__deleted = 0
2150
- WHERE r.__deleted = 0 AND p.type IN ${TEXT_TYPES}
2151
- GROUP BY r.id`).all()) {
2152
- db.query("INSERT INTO search_fts (kind, id, database_id, title, body) VALUES ('record', ?, ?, ?, ?)").run(r.id, r.database_id, r.id, r.body ?? "");
2367
+ WHERE ${where} AND r.__deleted = 0 AND p.type IN ${TEXT_TYPES}
2368
+ GROUP BY r.id`).all(...args);
2369
+ }
2370
+ function insertRecordRow(db, r) {
2371
+ db.query("INSERT INTO search_fts (kind, id, database_id, title, body) VALUES ('record', ?, ?, ?, ?)").run(r.id, r.database_id, r.id, r.body ?? "");
2372
+ }
2373
+ function reindexRecord(db, id) {
2374
+ db.query("DELETE FROM search_fts WHERE kind = 'record' AND id = ?").run(id);
2375
+ const r = recordBodyRows(db, "r.id = ?", id)[0];
2376
+ if (r)
2377
+ insertRecordRow(db, r);
2378
+ }
2379
+ function reindexDatabaseRecords(db, databaseId) {
2380
+ db.query("DELETE FROM search_fts WHERE kind = 'record' AND database_id = ?").run(databaseId);
2381
+ for (const r of recordBodyRows(db, "r.database_id = ?", databaseId))
2382
+ insertRecordRow(db, r);
2383
+ }
2384
+ function fullRebuild(db) {
2385
+ db.query("DELETE FROM search_fts").run();
2386
+ for (const d of db.query("SELECT id, title, body, database_id FROM documents WHERE __deleted = 0").all())
2387
+ db.query("INSERT INTO search_fts (kind, id, database_id, title, body) VALUES ('document', ?, ?, ?, ?)").run(d.id, d.database_id, d.title ?? "", d.body ?? "");
2388
+ for (const r of recordBodyRows(db, "1 = 1"))
2389
+ insertRecordRow(db, r);
2390
+ const max = db.query("SELECT MAX(rowid) AS m FROM crdt_changes").get().m ?? 0;
2391
+ writeMeta(db, "search_seq", String(max));
2392
+ writeMeta(db, "search_index_version", SEARCH_INDEX_VERSION);
2393
+ }
2394
+ function incrementalUpdate(db) {
2395
+ const cursor = Number(readMeta(db, "search_seq") ?? "0");
2396
+ const { changes, cursor: next } = changesAfterSeq(db, cursor);
2397
+ if (changes.length === 0)
2398
+ return;
2399
+ const docIds = new Set;
2400
+ const recordIds = new Set;
2401
+ const dbIds = new Set;
2402
+ for (const c of changes) {
2403
+ switch (c.dataset) {
2404
+ case "documents":
2405
+ docIds.add(c.row_id);
2406
+ break;
2407
+ case "doc_blocks": {
2408
+ const blk = db.query("SELECT doc_id FROM doc_blocks WHERE id = ?").get(c.row_id);
2409
+ if (blk?.doc_id)
2410
+ docIds.add(blk.doc_id);
2411
+ break;
2412
+ }
2413
+ case "records":
2414
+ recordIds.add(c.row_id);
2415
+ break;
2416
+ case "properties":
2417
+ if (c.col === "type" || c.col === "__deleted" || c.col === "database_id") {
2418
+ const p = db.query("SELECT database_id FROM properties WHERE id = ?").get(c.row_id);
2419
+ if (p?.database_id)
2420
+ dbIds.add(p.database_id);
2421
+ }
2422
+ break;
2423
+ }
2424
+ }
2425
+ for (const id of docIds)
2426
+ reindexDocument(db, id);
2427
+ for (const dbId of dbIds)
2428
+ reindexDatabaseRecords(db, dbId);
2429
+ for (const id of recordIds) {
2430
+ const r = db.query("SELECT database_id FROM records WHERE id = ?").get(id);
2431
+ if (r?.database_id && dbIds.has(r.database_id))
2432
+ continue;
2433
+ reindexRecord(db, id);
2153
2434
  }
2154
- db.query("INSERT INTO meta (key, value) VALUES ('search_hlc', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(max);
2435
+ writeMeta(db, "search_seq", String(next));
2436
+ }
2437
+ function ensureIndex(db) {
2438
+ if (!ftsAvailable(db))
2439
+ return false;
2440
+ const version = readMeta(db, "search_index_version");
2441
+ const seq = readMeta(db, "search_seq");
2442
+ db.transaction(() => {
2443
+ if (version !== SEARCH_INDEX_VERSION || seq === null)
2444
+ fullRebuild(db);
2445
+ else
2446
+ incrementalUpdate(db);
2447
+ })();
2448
+ return true;
2449
+ }
2450
+ function rebuildSearchIndex(db) {
2451
+ if (!ftsAvailable(db))
2452
+ return false;
2453
+ db.transaction(() => fullRebuild(db))();
2155
2454
  return true;
2156
2455
  }
2157
2456
  function ftsSearch(db, query, limit) {
@@ -16517,26 +16816,41 @@ var HealthResponseSchema = exports_external.object({
16517
16816
  ok: exports_external.boolean(),
16518
16817
  node: exports_external.string().describe("Server's node id")
16519
16818
  });
16819
+ var PairRequestSchema = exports_external.object({
16820
+ code: exports_external.string().describe("One-time pairing code minted by this server"),
16821
+ node_id: exports_external.string().describe("Calling device's node id"),
16822
+ grant: exports_external.string().describe("Durable credential the caller issues to us"),
16823
+ self_url: exports_external.string().optional().describe("Caller's reachable server URL (for mutual sync)")
16824
+ });
16825
+ var PairResponseSchema = exports_external.object({
16826
+ node_id: exports_external.string().describe("This server's node id"),
16827
+ grant: exports_external.string().describe("Durable credential we issue to the caller")
16828
+ });
16520
16829
  var SYNC_PATH = "/sync";
16521
16830
  var HEALTH_PATH = "/health";
16522
16831
  var RENEW_PATH = "/auth/token";
16832
+ var PAIR_PATH = "/api/pair";
16523
16833
  // src/core/sync/client.ts
16524
16834
  init_node();
16525
16835
  init_crdt();
16526
16836
  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 };
16837
+ const row = db.query("SELECT pull_cursor, push_cursor, token FROM peers WHERE url = ?").get(url2);
16838
+ return row ?? { pull_cursor: 0, push_cursor: 0, token: null };
16529
16839
  }
16530
16840
  function setPeer(db, url2, c) {
16531
16841
  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
16842
  }
16533
- async function syncWithPeer(db, url2) {
16843
+ async function syncWithPeer(db, url2, token) {
16534
16844
  const node = getNodeId(db);
16535
16845
  const peer = getPeer(db, url2);
16846
+ const bearer = token ?? peer.token ?? undefined;
16536
16847
  const toPush = changesAfterSeq(db, peer.push_cursor);
16848
+ const headers = { "content-type": "application/json" };
16849
+ if (bearer)
16850
+ headers.authorization = `Bearer ${bearer}`;
16537
16851
  const res = await fetch(new URL(SYNC_PATH, url2), {
16538
16852
  method: "POST",
16539
- headers: { "content-type": "application/json" },
16853
+ headers,
16540
16854
  body: JSON.stringify({ node_id: node, since: peer.pull_cursor, changes: toPush.changes })
16541
16855
  });
16542
16856
  if (!res.ok)
@@ -16549,10 +16863,108 @@ async function syncWithPeer(db, url2) {
16549
16863
  }
16550
16864
  // src/core/sync/server.ts
16551
16865
  init_node();
16866
+
16867
+ // src/core/sync/token.ts
16868
+ init_ids();
16869
+ var K_TOKEN = "auth_token";
16870
+ var K_EXP = "auth_token_exp";
16871
+ var K_PREV = "auth_token_prev";
16872
+ var K_PREV_EXP = "auth_token_prev_exp";
16873
+ function parseDuration(s, fallbackMs) {
16874
+ if (!s)
16875
+ return fallbackMs;
16876
+ const m = /^(\d+)\s*(ms|s|m|h|d)?$/.exec(s.trim());
16877
+ if (!m)
16878
+ return fallbackMs;
16879
+ const n = Number(m[1]);
16880
+ switch (m[2]) {
16881
+ case "ms":
16882
+ return n;
16883
+ case "m":
16884
+ return n * 60000;
16885
+ case "h":
16886
+ return n * 3600000;
16887
+ case "d":
16888
+ return n * 86400000;
16889
+ default:
16890
+ return n * 1000;
16891
+ }
16892
+ }
16893
+ var DAY = 86400000;
16894
+ var DEFAULT_TTL_MS = parseDuration(process.env.METAHUB_TOKEN_TTL, 30 * DAY);
16895
+ var DEFAULT_GRACE_MS = parseDuration(process.env.METAHUB_TOKEN_GRACE, 7 * DAY);
16896
+ function getMeta(db, key) {
16897
+ const row = db.query("SELECT value FROM meta WHERE key = ?").get(key);
16898
+ return row ? row.value : null;
16899
+ }
16900
+ function setMeta(db, key, value) {
16901
+ db.query("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value);
16902
+ }
16903
+ function readState(db) {
16904
+ const token = getMeta(db, K_TOKEN);
16905
+ if (!token)
16906
+ return null;
16907
+ const prev = getMeta(db, K_PREV);
16908
+ return {
16909
+ token,
16910
+ exp: Number(getMeta(db, K_EXP) ?? 0),
16911
+ prev: prev && prev.length > 0 ? prev : null,
16912
+ prevExp: Number(getMeta(db, K_PREV_EXP) ?? 0)
16913
+ };
16914
+ }
16915
+ function rotate(db, ttlMs, graceMs) {
16916
+ const now = Date.now();
16917
+ const old = getMeta(db, K_TOKEN);
16918
+ const next = {
16919
+ token: randomSuffix(24),
16920
+ exp: now + ttlMs,
16921
+ prev: old,
16922
+ prevExp: old ? now + graceMs : 0
16923
+ };
16924
+ setMeta(db, K_TOKEN, next.token);
16925
+ setMeta(db, K_EXP, String(next.exp));
16926
+ setMeta(db, K_PREV, next.prev ?? "");
16927
+ setMeta(db, K_PREV_EXP, String(next.prevExp));
16928
+ return next;
16929
+ }
16930
+ function loadOrRotate(db, ttlMs, graceMs) {
16931
+ const s = readState(db);
16932
+ if (!s || Date.now() > s.exp)
16933
+ return rotate(db, ttlMs, graceMs);
16934
+ return s;
16935
+ }
16936
+
16937
+ // src/core/config.ts
16938
+ var K_HOST = "cfg_host";
16939
+ var K_PORT = "cfg_port";
16940
+ var K_SYNC_INTERVAL = "cfg_sync_interval";
16941
+ var K_AUTO_SYNC = "cfg_auto_sync";
16942
+ var DEFAULT_CONFIG = {
16943
+ host: "127.0.0.1",
16944
+ port: 7777,
16945
+ syncIntervalMs: parseDuration(process.env.METAHUB_SYNC_INTERVAL, 30000),
16946
+ autoSync: true
16947
+ };
16948
+ function getMeta2(db, key) {
16949
+ const row = db.query("SELECT value FROM meta WHERE key = ?").get(key);
16950
+ return row ? row.value : null;
16951
+ }
16952
+ function getServerConfig(db) {
16953
+ const host = getMeta2(db, K_HOST);
16954
+ const port = getMeta2(db, K_PORT);
16955
+ const interval = getMeta2(db, K_SYNC_INTERVAL);
16956
+ const auto = getMeta2(db, K_AUTO_SYNC);
16957
+ return {
16958
+ host: host ?? DEFAULT_CONFIG.host,
16959
+ port: port != null ? Number(port) : DEFAULT_CONFIG.port,
16960
+ syncIntervalMs: interval != null ? Number(interval) : DEFAULT_CONFIG.syncIntervalMs,
16961
+ autoSync: auto != null ? auto === "1" : DEFAULT_CONFIG.autoSync
16962
+ };
16963
+ }
16552
16964
  // package.json
16553
16965
  var package_default = {
16554
16966
  name: "@tensorix/metahub",
16555
- version: "0.1.2",
16967
+ version: "0.1.4",
16556
16968
  description: "Metahub core library and CLI",
16557
16969
  type: "module",
16558
16970
  main: "./dist/index.js",
@@ -16578,9 +16990,11 @@ var package_default = {
16578
16990
  build: "bun run scripts/build.ts",
16579
16991
  "build:binaries": "bun run scripts/compile-binaries.ts",
16580
16992
  prepublishOnly: "bun run build",
16581
- test: "bun test"
16993
+ test: "bun test",
16994
+ release: "bun run scripts/tag-release.ts"
16582
16995
  },
16583
16996
  dependencies: {
16997
+ "@clack/prompts": "^1.5.0",
16584
16998
  citty: "^0.1.6",
16585
16999
  "highlight.js": "^11.11.1",
16586
17000
  preact: "^10.29.2",
@@ -16657,7 +17071,8 @@ var RecordValuesReq = exports_external.record(exports_external.string(), exports
16657
17071
  var CreateDocumentReq = exports_external.object({
16658
17072
  title: exports_external.string(),
16659
17073
  body: exports_external.string().optional(),
16660
- database_id: exports_external.string().optional()
17074
+ database_id: exports_external.string().optional(),
17075
+ parent_id: exports_external.string().optional()
16661
17076
  });
16662
17077
  var UpdateDocumentReq = exports_external.object({
16663
17078
  title: exports_external.string().optional(),
@@ -16685,7 +17100,15 @@ function handle(fn) {
16685
17100
  }
16686
17101
  };
16687
17102
  }
17103
+ var VersionSchema = exports_external.object({ version: exports_external.string() });
16688
17104
  var webuiRoutes = [
17105
+ {
17106
+ method: "GET",
17107
+ path: "/api/version",
17108
+ summary: "Version of the running core (sidecar). Used by the desktop app's settings footer.",
17109
+ response: VersionSchema,
17110
+ handler: () => Response.json({ version: package_default.version })
17111
+ },
16689
17112
  {
16690
17113
  method: "GET",
16691
17114
  path: "/api/databases",
@@ -16835,9 +17258,9 @@ var webuiRoutes = [
16835
17258
  {
16836
17259
  method: "GET",
16837
17260
  path: "/api/documents",
16838
- summary: "List documents. Optional query: ?db=<id>",
17261
+ summary: "List documents. Optional query: ?db=<id> or ?parent=<id>",
16839
17262
  response: exports_external.array(DocumentSummarySchema),
16840
- handler: handle((req, { db }) => listDocuments(db, { database_id: opt(req, "db") }))
17263
+ handler: handle((req, { db }) => listDocuments(db, { database_id: opt(req, "db"), parent_id: opt(req, "parent") }))
16841
17264
  },
16842
17265
  {
16843
17266
  method: "POST",
@@ -16937,6 +17360,250 @@ var sitesRoutes = [
16937
17360
  }
16938
17361
  ];
16939
17362
 
17363
+ // src/core/sync/pairing.ts
17364
+ init_ids();
17365
+
17366
+ // src/core/sync/peers.ts
17367
+ function listPeers(db) {
17368
+ 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();
17369
+ }
17370
+ function addPeer(db, input) {
17371
+ db.query(`INSERT INTO peers (url, token, label, node_id, enabled, pull_cursor, push_cursor)
17372
+ VALUES (?, ?, ?, ?, 1, 0, 0)
17373
+ ON CONFLICT(url) DO UPDATE SET
17374
+ token = coalesce(excluded.token, peers.token),
17375
+ label = coalesce(excluded.label, peers.label),
17376
+ node_id = coalesce(excluded.node_id, peers.node_id),
17377
+ enabled = 1`).run(input.url, input.token ?? null, input.label ?? null, input.node_id ?? null);
17378
+ }
17379
+ function removePeer(db, url2) {
17380
+ const tx = db.transaction(() => {
17381
+ const changed = db.query("DELETE FROM peers WHERE url = ?").run(url2).changes > 0;
17382
+ db.query("DELETE FROM peer_grants WHERE peer_url = ?").run(url2);
17383
+ return changed;
17384
+ });
17385
+ return tx();
17386
+ }
17387
+ function setPeerEnabled(db, url2, enabled) {
17388
+ return db.query("UPDATE peers SET enabled = ? WHERE url = ?").run(enabled ? 1 : 0, url2).changes > 0;
17389
+ }
17390
+ function setPeerLabel(db, url2, label) {
17391
+ return db.query("UPDATE peers SET label = ? WHERE url = ?").run(label, url2).changes > 0;
17392
+ }
17393
+ function updatePeerStatus(db, url2, status, error51) {
17394
+ db.query("UPDATE peers SET last_sync_at = ?, last_status = ?, last_error = ? WHERE url = ?").run(Date.now(), status, error51 ?? null, url2);
17395
+ }
17396
+ async function syncPeer(db, url2) {
17397
+ try {
17398
+ const result = await syncWithPeer(db, url2);
17399
+ updatePeerStatus(db, url2, "ok", null);
17400
+ return { url: url2, ok: true, pushed: result.pushed, pulled: result.pulled };
17401
+ } catch (e) {
17402
+ const error51 = e.message;
17403
+ updatePeerStatus(db, url2, "error", error51);
17404
+ return { url: url2, ok: false, error: error51 };
17405
+ }
17406
+ }
17407
+ async function syncAllPeers(db) {
17408
+ const peers = listPeers(db).filter((p) => p.enabled);
17409
+ const out = [];
17410
+ for (const p of peers)
17411
+ out.push(await syncPeer(db, p.url));
17412
+ return out;
17413
+ }
17414
+
17415
+ // src/core/sync/pairing.ts
17416
+ var DEFAULT_PAIR_TTL_MS = parseDuration(process.env.METAHUB_PAIR_TTL, 10 * 60000);
17417
+ function generatePairingCode(db, ttlMs = DEFAULT_PAIR_TTL_MS) {
17418
+ const now = Date.now();
17419
+ db.query("DELETE FROM pairing_codes WHERE used = 1 OR exp < ?").run(now);
17420
+ const code = randomSuffix(12);
17421
+ const exp = now + ttlMs;
17422
+ db.query("INSERT INTO pairing_codes (code, exp, used, created_at) VALUES (?, ?, 0, ?)").run(code, exp, now);
17423
+ return { code, exp };
17424
+ }
17425
+ function redeemPairingCode(db, code) {
17426
+ return db.query("UPDATE pairing_codes SET used = 1 WHERE code = ? AND used = 0 AND exp >= ?").run(code, Date.now()).changes > 0;
17427
+ }
17428
+ function mintGrant(db, peerUrl, nodeId) {
17429
+ const token = randomSuffix(32);
17430
+ db.query("INSERT INTO peer_grants (token, peer_url, node_id, created_at) VALUES (?, ?, ?, ?)").run(token, peerUrl, nodeId, Date.now());
17431
+ return token;
17432
+ }
17433
+ function isAcceptedGrant(db, token) {
17434
+ return db.query("SELECT 1 FROM peer_grants WHERE token = ?").get(token) != null;
17435
+ }
17436
+ function listGrants(db) {
17437
+ return db.query("SELECT token, peer_url, node_id, created_at FROM peer_grants ORDER BY created_at DESC").all();
17438
+ }
17439
+ function revokeGrant(db, tokenOrPrefix) {
17440
+ return db.query("DELETE FROM peer_grants WHERE token = ? OR token LIKE ? || '%'").run(tokenOrPrefix, tokenOrPrefix).changes;
17441
+ }
17442
+ function handlePairRequest(db, node, body) {
17443
+ if (!redeemPairingCode(db, body.code)) {
17444
+ throw new Error("invalid or expired pairing code");
17445
+ }
17446
+ if (body.self_url) {
17447
+ addPeer(db, { url: body.self_url, token: body.grant, node_id: body.node_id });
17448
+ }
17449
+ const grant = mintGrant(db, body.self_url ?? null, body.node_id);
17450
+ return { node_id: node, grant };
17451
+ }
17452
+ async function performPairing(db, node, remoteUrl, code, selfUrl) {
17453
+ const ourGrant = mintGrant(db, remoteUrl, null);
17454
+ const reqBody = { code, node_id: node, grant: ourGrant, self_url: selfUrl };
17455
+ const res = await fetch(new URL(PAIR_PATH, remoteUrl), {
17456
+ method: "POST",
17457
+ headers: { "content-type": "application/json" },
17458
+ body: JSON.stringify(reqBody)
17459
+ });
17460
+ if (!res.ok)
17461
+ throw new Error(`pairing failed: ${res.status} ${await res.text()}`);
17462
+ const data = await res.json();
17463
+ addPeer(db, { url: remoteUrl, token: data.grant, node_id: data.node_id });
17464
+ return { node_id: data.node_id, url: remoteUrl };
17465
+ }
17466
+
17467
+ // src/core/sync/peers-routes.ts
17468
+ var PeerSchema = exports_external.object({
17469
+ url: exports_external.string(),
17470
+ pull_cursor: exports_external.number(),
17471
+ push_cursor: exports_external.number(),
17472
+ token: exports_external.string().nullable(),
17473
+ label: exports_external.string().nullable(),
17474
+ node_id: exports_external.string().nullable(),
17475
+ enabled: exports_external.number(),
17476
+ last_sync_at: exports_external.number().nullable(),
17477
+ last_status: exports_external.string().nullable(),
17478
+ last_error: exports_external.string().nullable()
17479
+ });
17480
+ var PairingCodeSchema = exports_external.object({ code: exports_external.string(), exp: exports_external.number() });
17481
+ var AddPeerReq = exports_external.object({
17482
+ url: exports_external.string(),
17483
+ code: exports_external.string(),
17484
+ self_url: exports_external.string().optional()
17485
+ });
17486
+ var AddPeerRes = exports_external.object({ node_id: exports_external.string(), url: exports_external.string() });
17487
+ var UpdatePeerReq = exports_external.object({
17488
+ enabled: exports_external.boolean().optional(),
17489
+ label: exports_external.string().optional()
17490
+ });
17491
+ var PeerSyncRes = exports_external.object({
17492
+ url: exports_external.string(),
17493
+ ok: exports_external.boolean(),
17494
+ pushed: exports_external.number().optional(),
17495
+ pulled: exports_external.number().optional(),
17496
+ error: exports_external.string().optional()
17497
+ });
17498
+ var GrantSchema = exports_external.object({
17499
+ token: exports_external.string(),
17500
+ peer_url: exports_external.string().nullable(),
17501
+ node_id: exports_external.string().nullable(),
17502
+ created_at: exports_external.number().nullable()
17503
+ });
17504
+ var RevokeRes = exports_external.object({ revoked: exports_external.number() });
17505
+ var OkSchema2 = exports_external.object({ ok: exports_external.boolean() });
17506
+ function need3(req, key) {
17507
+ const v = new URL(req.url).searchParams.get(key);
17508
+ if (!v)
17509
+ throw new Error(`missing query param: ${key}`);
17510
+ return v;
17511
+ }
17512
+ function handle3(fn) {
17513
+ return async (req, ctx) => {
17514
+ try {
17515
+ const out = await fn(req, ctx);
17516
+ if (out instanceof Response)
17517
+ return out;
17518
+ return Response.json(out ?? null);
17519
+ } catch (e) {
17520
+ return Response.json({ error: e.message }, { status: 400 });
17521
+ }
17522
+ };
17523
+ }
17524
+ var peersRoutes = [
17525
+ {
17526
+ method: "POST",
17527
+ path: PAIR_PATH,
17528
+ summary: "Pairing handshake: redeem a one-time code and exchange credentials",
17529
+ request: PairRequestSchema,
17530
+ response: PairResponseSchema,
17531
+ handler: handle3(async (req, { db, node }) => {
17532
+ const body = await req.json();
17533
+ return handlePairRequest(db, node, body);
17534
+ })
17535
+ },
17536
+ {
17537
+ method: "POST",
17538
+ path: "/api/pair/new",
17539
+ summary: "Mint a one-time pairing code for another device to redeem",
17540
+ response: PairingCodeSchema,
17541
+ handler: handle3((_req, { db }) => generatePairingCode(db))
17542
+ },
17543
+ {
17544
+ method: "POST",
17545
+ path: "/api/peers/pair",
17546
+ summary: "Pair this server with a remote one using its one-time code",
17547
+ request: AddPeerReq,
17548
+ response: AddPeerRes,
17549
+ handler: handle3(async (req, { db, node }) => {
17550
+ const body = await req.json();
17551
+ return performPairing(db, node, body.url, body.code, body.self_url);
17552
+ })
17553
+ },
17554
+ {
17555
+ method: "GET",
17556
+ path: "/api/peers",
17557
+ summary: "List configured sync peers with status",
17558
+ response: exports_external.array(PeerSchema),
17559
+ handler: handle3((_req, { db }) => listPeers(db))
17560
+ },
17561
+ {
17562
+ method: "PATCH",
17563
+ path: "/api/peer",
17564
+ summary: "Update a peer (enable/disable or label). Query: ?url=<url>",
17565
+ request: UpdatePeerReq,
17566
+ response: OkSchema2,
17567
+ handler: handle3(async (req, { db }) => {
17568
+ const url2 = need3(req, "url");
17569
+ const body = await req.json();
17570
+ if (body.enabled != null)
17571
+ setPeerEnabled(db, url2, body.enabled);
17572
+ if (body.label != null)
17573
+ setPeerLabel(db, url2, body.label);
17574
+ return { ok: true };
17575
+ })
17576
+ },
17577
+ {
17578
+ method: "DELETE",
17579
+ path: "/api/peer",
17580
+ summary: "Remove a sync peer. Query: ?url=<url>",
17581
+ response: OkSchema2,
17582
+ handler: handle3((req, { db }) => ({ ok: removePeer(db, need3(req, "url")) }))
17583
+ },
17584
+ {
17585
+ method: "POST",
17586
+ path: "/api/peer/sync",
17587
+ summary: "Manually sync once with a peer. Query: ?url=<url>",
17588
+ response: PeerSyncRes,
17589
+ handler: handle3((req, { db }) => syncPeer(db, need3(req, "url")))
17590
+ },
17591
+ {
17592
+ method: "GET",
17593
+ path: "/api/grants",
17594
+ summary: "List credentials this server issued and accepts on /sync (inbound)",
17595
+ response: exports_external.array(GrantSchema),
17596
+ handler: handle3((_req, { db }) => listGrants(db))
17597
+ },
17598
+ {
17599
+ method: "DELETE",
17600
+ path: "/api/grant",
17601
+ summary: "Revoke an issued credential by token or prefix. Query: ?token=<token>",
17602
+ response: RevokeRes,
17603
+ handler: handle3((req, { db }) => ({ revoked: revokeGrant(db, need3(req, "token")) }))
17604
+ }
17605
+ ];
17606
+
16940
17607
  // src/core/sync/routes.ts
16941
17608
  var syncRoutes = [
16942
17609
  {
@@ -16962,77 +17629,7 @@ var syncRoutes = [
16962
17629
  }
16963
17630
  }
16964
17631
  ];
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
- }
17632
+ var routes = [...syncRoutes, ...webuiRoutes, ...sitesRoutes, ...peersRoutes];
17036
17633
 
17037
17634
  // src/core/sync/openapi.ts
17038
17635
  function buildOpenApi(version2) {
@@ -17101,6 +17698,17 @@ function hasValidToken(req, url2, cfg) {
17101
17698
  return true;
17102
17699
  return extractToken(req, url2) === cur.token;
17103
17700
  }
17701
+ function acceptsSyncToken(req, url2, cfg, db) {
17702
+ if (!authActive(cfg))
17703
+ return true;
17704
+ const presented = extractToken(req, url2);
17705
+ if (!presented)
17706
+ return false;
17707
+ const cur = activeToken(cfg);
17708
+ if (cur && presented === cur.token)
17709
+ return true;
17710
+ return isAcceptedGrant(db, presented);
17711
+ }
17104
17712
  function renewToken(req, url2, cfg) {
17105
17713
  if (cfg.debug || cfg.staticToken != null || !cfg.db)
17106
17714
  return null;
@@ -17228,6 +17836,16 @@ function unauthorized() {
17228
17836
  }
17229
17837
 
17230
17838
  // src/core/sync/server.ts
17839
+ class PortInUseError extends Error {
17840
+ port;
17841
+ host;
17842
+ constructor(port, host) {
17843
+ super(`port ${port} is already in use \u2014 another mh server may be running. ` + `Stop it, or start on a free port with --port <n>.`);
17844
+ this.port = port;
17845
+ this.host = host;
17846
+ this.name = "PortInUseError";
17847
+ }
17848
+ }
17231
17849
  function scalarHtml(specUrl) {
17232
17850
  return `<!doctype html><html><head>
17233
17851
  <meta charset="utf-8"><title>Metahub API</title>
@@ -17240,6 +17858,11 @@ function startServer(opts = {}) {
17240
17858
  const db = openMetahub();
17241
17859
  const node = getNodeId(db);
17242
17860
  const ctx = { db, node };
17861
+ const cfg = getServerConfig(db);
17862
+ const port = opts.port ?? cfg.port;
17863
+ const host = opts.host ?? cfg.host;
17864
+ const syncIntervalMs = opts.syncIntervalMs ?? cfg.syncIntervalMs;
17865
+ const autoSync = opts.autoSync ?? cfg.autoSync;
17243
17866
  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
17867
  debug: false,
17245
17868
  staticToken: null,
@@ -17247,50 +17870,84 @@ function startServer(opts = {}) {
17247
17870
  ttlMs: DEFAULT_TTL_MS,
17248
17871
  graceMs: DEFAULT_GRACE_MS
17249
17872
  };
17250
- const server = Bun.serve({
17251
- port: opts.port ?? 7777,
17252
- hostname: opts.host ?? "127.0.0.1",
17253
- async fetch(req) {
17254
- 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();
17258
- }
17259
- if (url2.pathname === RENEW_PATH) {
17260
- const r = renewToken(req, url2, auth);
17261
- return r ? Response.json(r) : unauthorized();
17262
- }
17263
- if (req.method === "GET" && url2.pathname === "/docs.json") {
17264
- return Response.json(buildOpenApi(package_default.version));
17265
- }
17266
- if (req.method === "GET" && url2.pathname === "/docs") {
17267
- return withShim(new Response(scalarHtml("/docs.json"), { headers: HTML_HEADERS }), auth);
17268
- }
17269
- if (req.method === "GET" && (url2.pathname === "/" || url2.pathname === "/webui.js")) {
17270
- const { serveWebui: serveWebui2 } = await Promise.resolve().then(() => (init_webui(), exports_webui));
17271
- const res = await serveWebui2(req);
17272
- if (res)
17273
- return withShim(res, auth);
17274
- }
17275
- if (req.method === "GET" && url2.pathname.startsWith("/sites/")) {
17276
- const { serveSite: serveSite2 } = await Promise.resolve().then(() => (init_sites_serve(), exports_sites_serve));
17277
- const res = await serveSite2(req, ctx);
17278
- if (res)
17279
- return withShim(res, auth);
17280
- }
17281
- const route = routes.find((r) => r.method === req.method && r.path === url2.pathname);
17282
- if (route)
17283
- return route.handler(req, ctx);
17284
- return new Response("not found", { status: 404 });
17873
+ let server;
17874
+ try {
17875
+ server = Bun.serve({
17876
+ port,
17877
+ hostname: host,
17878
+ async fetch(req) {
17879
+ const url2 = new URL(req.url);
17880
+ if (url2.pathname === SYNC_PATH) {
17881
+ if (!acceptsSyncToken(req, url2, auth, db))
17882
+ return unauthorized();
17883
+ } else {
17884
+ const exempt = url2.pathname === HEALTH_PATH || url2.pathname === RENEW_PATH || url2.pathname === PAIR_PATH;
17885
+ if (!exempt && !hasValidToken(req, url2, auth)) {
17886
+ return wantsHtml(req) ? new Response(unlockPage(), { headers: HTML_HEADERS }) : unauthorized();
17887
+ }
17888
+ }
17889
+ if (url2.pathname === RENEW_PATH) {
17890
+ const r = renewToken(req, url2, auth);
17891
+ return r ? Response.json(r) : unauthorized();
17892
+ }
17893
+ if (req.method === "GET" && url2.pathname === "/docs.json") {
17894
+ return Response.json(buildOpenApi(package_default.version));
17895
+ }
17896
+ if (req.method === "GET" && url2.pathname === "/docs") {
17897
+ return withShim(new Response(scalarHtml("/docs.json"), { headers: HTML_HEADERS }), auth);
17898
+ }
17899
+ if (req.method === "GET" && (url2.pathname === "/" || url2.pathname === "/webui.js")) {
17900
+ const { serveWebui: serveWebui2 } = await Promise.resolve().then(() => (init_webui(), exports_webui));
17901
+ const res = await serveWebui2(req);
17902
+ if (res)
17903
+ return withShim(res, auth);
17904
+ }
17905
+ if (req.method === "GET" && url2.pathname.startsWith("/sites/")) {
17906
+ const { serveSite: serveSite2 } = await Promise.resolve().then(() => (init_sites_serve(), exports_sites_serve));
17907
+ const res = await serveSite2(req, ctx);
17908
+ if (res)
17909
+ return withShim(res, auth);
17910
+ }
17911
+ const route = routes.find((r) => r.method === req.method && r.path === url2.pathname);
17912
+ if (route)
17913
+ return route.handler(req, ctx);
17914
+ return new Response("not found", { status: 404 });
17915
+ }
17916
+ });
17917
+ } catch (e) {
17918
+ const code = e?.code;
17919
+ if (code === "EADDRINUSE") {
17920
+ throw new PortInUseError(port, host);
17285
17921
  }
17286
- });
17922
+ if (code === "EACCES") {
17923
+ throw new Error(`permission denied binding ${host}:${port} \u2014 ports below 1024 need elevated privileges; pick a higher port with --port`);
17924
+ }
17925
+ throw e;
17926
+ }
17927
+ let timer = null;
17928
+ if (autoSync && syncIntervalMs > 0) {
17929
+ timer = setInterval(() => {
17930
+ syncAllPeers(db).catch(() => {});
17931
+ }, syncIntervalMs);
17932
+ timer.unref?.();
17933
+ }
17287
17934
  const active = activeToken(auth);
17935
+ const authMode = opts.debug ? "disabled" : opts.token ? "static" : "managed";
17288
17936
  return {
17289
17937
  server,
17290
17938
  node,
17291
- port: server.port ?? opts.port ?? 7777,
17939
+ port: server.port ?? port,
17940
+ host,
17292
17941
  token: active?.token ?? null,
17293
- exp: active?.exp ?? null
17942
+ exp: active?.exp ?? null,
17943
+ authMode,
17944
+ autoSync: Boolean(autoSync && syncIntervalMs > 0),
17945
+ syncIntervalMs,
17946
+ stop() {
17947
+ if (timer)
17948
+ clearInterval(timer);
17949
+ server.stop();
17950
+ }
17294
17951
  };
17295
17952
  }
17296
17953
  export {
@@ -17312,6 +17969,7 @@ export {
17312
17969
  resolveCandidates,
17313
17970
  removeProperty,
17314
17971
  reconcile,
17972
+ rebuildSearchIndex,
17315
17973
  randomSuffix,
17316
17974
  putFile,
17317
17975
  putBlob,
@@ -17324,6 +17982,7 @@ export {
17324
17982
  newId,
17325
17983
  moveRecord,
17326
17984
  migrateRecords,
17985
+ migratePeers,
17327
17986
  metahubHome,
17328
17987
  maybeAutoIndex,
17329
17988
  makeId,
@@ -17380,10 +18039,14 @@ export {
17380
18039
  SyncRequestSchema,
17381
18040
  SYNC_PATH,
17382
18041
  RENEW_PATH,
18042
+ PortInUseError,
18043
+ PairResponseSchema,
18044
+ PairRequestSchema,
17383
18045
  PROP_TYPES,
18046
+ PAIR_PATH,
17384
18047
  HealthResponseSchema,
17385
18048
  HEALTH_PATH,
17386
18049
  ChangeSchema
17387
18050
  };
17388
18051
 
17389
- //# debugId=884E1F97976309E464756E2164756E21
18052
+ //# debugId=539C6C8168968A4864756E2164756E21