@codemem/server 0.22.4 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
- import { CODEMEM_CONFIG_ENV_OVERRIDES, DEFAULT_TIME_WINDOW_S, MemoryStore, VERSION, VERSION as VERSION$1, applyReplicationOps, buildFilterClausesWithContext, buildRawEventEnvelopeFromHook, cleanupNonces, coordinatorCreateInviteAction, coordinatorImportInviteAction, coordinatorReviewJoinRequestAction, coordinatorStatusSnapshot, createCoordinatorReciprocalApproval, ensureDeviceIdentity, extractReplicationOps, fingerprintPublicKey, fromJson, getCodememConfigPath, getCodememEnvOverrides, getSyncResetState, listCoordinatorJoinRequests, listObserverProviderOptions, loadMemorySnapshotPageForPeer, loadReplicationOpsForPeer, lookupCoordinatorPeers, mergeAddresses, parseStrictInteger, probeAvailableCredentials, readCodememConfigFile, readCoordinatorSyncConfig, recordNonce, resolveDbPath, runSyncPass, schema, stripJsonComments, stripPrivateObj, stripTrailingCommas, verifySignature, writeCodememConfigFile } from "@codemem/core";
3
+ import { CODEMEM_CONFIG_ENV_OVERRIDES, DEFAULT_TIME_WINDOW_S, MemoryStore, VERSION, VERSION as VERSION$1, applyReplicationOps, buildBaseUrl, buildFilterClausesWithContext, buildRawEventEnvelopeFromHook, canonicalMemoryKind, cleanupNonces, coordinatorCreateInviteAction, coordinatorImportInviteAction, coordinatorListBootstrapGrantsAction, coordinatorReviewJoinRequestAction, coordinatorRevokeBootstrapGrantAction, coordinatorStatusSnapshot, createCoordinatorReciprocalApproval, ensureDeviceIdentity, extractReplicationOps, fingerprintPublicKey, fromJson, getCodememConfigPath, getCodememEnvOverrides, getSyncResetState, isSummaryLikeMemory, listCoordinatorJoinRequests, listObserverProviderOptions, loadMemorySnapshotPageForPeer, loadReplicationOpsForPeer, lookupCoordinatorPeers, mergeAddresses, parseStrictInteger, probeAvailableCredentials, readCodememConfigFile, readCoordinatorSyncConfig, recordNonce, requestJson, resolveDbPath, runSyncPass, schema, stripJsonComments, stripPrivateObj, stripTrailingCommas, verifySignature, writeCodememConfigFile } from "@codemem/core";
4
4
  import { serveStatic } from "@hono/node-server/serve-static";
5
5
  import { Hono } from "hono";
6
6
  import { createMiddleware } from "hono/factory";
@@ -591,14 +591,15 @@ function queryMemoryPage(store, options) {
591
591
  ORDER BY memory_items.created_at DESC
592
592
  LIMIT ? OFFSET ?`).all(...filterResult.params, options.limit + 1, options.offset).map((row) => ({
593
593
  ...row,
594
+ kind: canonicalMemoryKind(row.kind ?? null, row.metadata_json),
594
595
  metadata_json: fromJson(row.metadata_json ?? null)
595
596
  }));
596
597
  }
597
- function isSummaryLikeMemory(item) {
598
- if (String(item.kind ?? "").toLowerCase() === "session_summary") return true;
599
- const metadata = item.metadata_json ?? {};
600
- if (metadata.is_summary === true) return true;
601
- return String(metadata.source ?? "").trim().toLowerCase() === "observer_summary";
598
+ function isSummaryLikeMemory$1(item) {
599
+ return isSummaryLikeMemory({
600
+ kind: item.kind,
601
+ metadata: item.metadata_json
602
+ });
602
603
  }
603
604
  function selectMemoryPage(store, options) {
604
605
  const pageSize = Math.max(options.limit + options.offset + 10, 50);
@@ -653,7 +654,7 @@ function memoryRoutes(getStore) {
653
654
  offset,
654
655
  project: c.req.query("project") || void 0,
655
656
  scope: normalizeScope(c.req.query("scope")),
656
- matcher: (item) => !isSummaryLikeMemory(item)
657
+ matcher: (item) => !isSummaryLikeMemory$1(item)
657
658
  });
658
659
  const hasMore = items.length > limit;
659
660
  const result = hasMore ? items.slice(0, limit) : items;
@@ -680,7 +681,7 @@ function memoryRoutes(getStore) {
680
681
  offset,
681
682
  project: c.req.query("project") || void 0,
682
683
  scope: normalizeScope(c.req.query("scope")),
683
- matcher: (item) => isSummaryLikeMemory(item)
684
+ matcher: (item) => isSummaryLikeMemory$1(item)
684
685
  });
685
686
  const hasMore = items.length > limit;
686
687
  const result = hasMore ? items.slice(0, limit) : items;
@@ -719,7 +720,7 @@ function memoryRoutes(getStore) {
719
720
  project: scopeProject
720
721
  });
721
722
  if (page.length === 0) break;
722
- total += page.filter((item) => !isSummaryLikeMemory(item)).length;
723
+ total += page.filter((item) => !isSummaryLikeMemory$1(item)).length;
723
724
  if (page.length < 200) break;
724
725
  offset += page.length;
725
726
  }
@@ -1288,6 +1289,9 @@ var SYNC_STALE_AFTER_SECONDS = 600;
1288
1289
  var SYNC_PROTOCOL_VERSION = "2";
1289
1290
  var LEGACY_SYNC_ACTOR_DISPLAY_NAME = "Legacy synced peer";
1290
1291
  var LEGACY_SHARED_WORKSPACE_ID = "shared:legacy";
1292
+ function syncKeysDir() {
1293
+ return process.env.CODEMEM_KEYS_DIR?.trim() || void 0;
1294
+ }
1291
1295
  function intEnvOr(name, fallback) {
1292
1296
  const value = Number.parseInt(process.env[name] ?? "", 10);
1293
1297
  return Number.isFinite(value) ? value : fallback;
@@ -1324,8 +1328,8 @@ function pathWithQuery(url) {
1324
1328
  const parsed = new URL(url);
1325
1329
  return parsed.search ? `${parsed.pathname}${parsed.search}` : parsed.pathname;
1326
1330
  }
1327
- function unauthorizedPayload(reason) {
1328
- if (process.env.CODEMEM_SYNC_AUTH_DIAGNOSTICS === "1") return {
1331
+ function unauthorizedPayload(reason, exposeReason = false) {
1332
+ if (exposeReason && process.env.CODEMEM_SYNC_AUTH_DIAGNOSTICS === "1") return {
1329
1333
  error: "unauthorized",
1330
1334
  reason
1331
1335
  };
@@ -1397,6 +1401,123 @@ function authorizeSyncRequest(store, request, body) {
1397
1401
  deviceId
1398
1402
  };
1399
1403
  }
1404
+ async function authorizeBootstrapGrantRequest(store, request, body) {
1405
+ const grantId = (request.header("X-Codemem-Bootstrap-Grant") ?? "").trim();
1406
+ const deviceId = (request.header("X-Opencode-Device") ?? "").trim();
1407
+ const signature = request.header("X-Opencode-Signature") ?? "";
1408
+ const timestamp = request.header("X-Opencode-Timestamp") ?? "";
1409
+ const nonce = request.header("X-Opencode-Nonce") ?? "";
1410
+ if (!grantId || !deviceId || !signature || !timestamp || !nonce) return {
1411
+ ok: false,
1412
+ reason: "missing_bootstrap_grant_headers",
1413
+ deviceId
1414
+ };
1415
+ const config = readCoordinatorSyncConfig();
1416
+ if (!config.syncCoordinatorUrl || !config.syncCoordinatorAdminSecret) return {
1417
+ ok: false,
1418
+ reason: "bootstrap_grant_coordinator_not_configured",
1419
+ deviceId
1420
+ };
1421
+ const [localDeviceId] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
1422
+ let verification;
1423
+ try {
1424
+ const [status, payload] = await requestJson("GET", `${buildBaseUrl(config.syncCoordinatorUrl)}/v1/admin/bootstrap-grants/${encodeURIComponent(grantId)}`, {
1425
+ headers: { "X-Codemem-Coordinator-Admin": config.syncCoordinatorAdminSecret },
1426
+ timeoutS: Math.max(1, config.syncCoordinatorTimeoutS)
1427
+ });
1428
+ if (status !== 200 || !payload) return {
1429
+ ok: false,
1430
+ reason: "bootstrap_grant_lookup_failed",
1431
+ deviceId
1432
+ };
1433
+ verification = payload;
1434
+ } catch {
1435
+ return {
1436
+ ok: false,
1437
+ reason: "bootstrap_grant_lookup_failed",
1438
+ deviceId
1439
+ };
1440
+ }
1441
+ const grant = verification.grant;
1442
+ const workerEnrollment = verification.worker_enrollment;
1443
+ if (!grant || !workerEnrollment) return {
1444
+ ok: false,
1445
+ reason: "bootstrap_grant_invalid_payload",
1446
+ deviceId
1447
+ };
1448
+ if (grant.revoked_at) return {
1449
+ ok: false,
1450
+ reason: "bootstrap_grant_revoked",
1451
+ deviceId
1452
+ };
1453
+ if (String(workerEnrollment.device_id) !== String(grant.worker_device_id)) return {
1454
+ ok: false,
1455
+ reason: "bootstrap_grant_worker_enrollment_mismatch",
1456
+ deviceId
1457
+ };
1458
+ if (String(workerEnrollment.group_id) !== String(grant.group_id)) return {
1459
+ ok: false,
1460
+ reason: "bootstrap_grant_group_mismatch",
1461
+ deviceId
1462
+ };
1463
+ if (Number(workerEnrollment.enabled) !== 1) return {
1464
+ ok: false,
1465
+ reason: "bootstrap_grant_worker_disabled",
1466
+ deviceId
1467
+ };
1468
+ if (grant.worker_device_id !== deviceId) return {
1469
+ ok: false,
1470
+ reason: "bootstrap_grant_worker_mismatch",
1471
+ deviceId
1472
+ };
1473
+ if (grant.seed_device_id !== localDeviceId) return {
1474
+ ok: false,
1475
+ reason: "bootstrap_grant_seed_mismatch",
1476
+ deviceId
1477
+ };
1478
+ if (new Date(grant.expires_at) <= /* @__PURE__ */ new Date()) return {
1479
+ ok: false,
1480
+ reason: "bootstrap_grant_expired",
1481
+ deviceId
1482
+ };
1483
+ let valid = false;
1484
+ try {
1485
+ valid = verifySignature({
1486
+ method: request.method,
1487
+ pathWithQuery: pathWithQuery(request.url),
1488
+ bodyBytes: body,
1489
+ timestamp,
1490
+ nonce,
1491
+ signature,
1492
+ publicKey: String(workerEnrollment.public_key),
1493
+ deviceId
1494
+ });
1495
+ } catch {
1496
+ return {
1497
+ ok: false,
1498
+ reason: "bootstrap_grant_signature_verification_error",
1499
+ deviceId
1500
+ };
1501
+ }
1502
+ if (!valid) return {
1503
+ ok: false,
1504
+ reason: "bootstrap_grant_invalid_signature",
1505
+ deviceId
1506
+ };
1507
+ const createdAt = (/* @__PURE__ */ new Date()).toISOString();
1508
+ if (!recordNonce(store.db, deviceId, nonce, createdAt)) return {
1509
+ ok: false,
1510
+ reason: "nonce_replay",
1511
+ deviceId
1512
+ };
1513
+ const cutoff = (/* @__PURE__ */ new Date(Date.now() - DEFAULT_TIME_WINDOW_S * 2 * 1e3)).toISOString();
1514
+ cleanupNonces(store.db, cutoff);
1515
+ return {
1516
+ ok: true,
1517
+ reason: "ok",
1518
+ deviceId
1519
+ };
1520
+ }
1400
1521
  function projectBasename(value) {
1401
1522
  const project = String(value ?? "").trim().replaceAll("\\", "/");
1402
1523
  if (!project) return "";
@@ -1795,29 +1916,43 @@ function syncProtocolRoutes(getStore, opts = {}) {
1795
1916
  }
1796
1917
  app.get("/v1/status", (c) => {
1797
1918
  const store = getStore();
1798
- const auth = authorizeSyncRequest(store, c.req, Buffer.alloc(0));
1799
- if (!auth.ok) return rateLimitedResponse(c, c.req.path, false) ?? c.json(unauthorizedPayload(auth.reason), 401);
1800
- const limited = rateLimitedResponse(c, auth.deviceId, true);
1801
- if (limited) return limited;
1802
- try {
1803
- let device = store.db.prepare("SELECT device_id, fingerprint FROM sync_device LIMIT 1").get();
1804
- if (!device) {
1805
- const [deviceId, fingerprint] = ensureDeviceIdentity(store.db);
1806
- device = {
1919
+ return (async () => {
1920
+ let auth = authorizeSyncRequest(store, c.req, Buffer.alloc(0));
1921
+ let preauthChecked = false;
1922
+ let bootstrapAttempted = false;
1923
+ if (!auth.ok) {
1924
+ if ((c.req.header("X-Codemem-Bootstrap-Grant") ?? "").trim()) {
1925
+ preauthChecked = true;
1926
+ const bootstrapLimited = rateLimitedResponse(c, c.req.path, false);
1927
+ if (bootstrapLimited) return bootstrapLimited;
1928
+ } else {
1929
+ preauthChecked = true;
1930
+ const unauthLimited = rateLimitedResponse(c, c.req.path, false);
1931
+ if (unauthLimited) return unauthLimited;
1932
+ }
1933
+ auth = await authorizeBootstrapGrantRequest(store, c.req, Buffer.alloc(0));
1934
+ bootstrapAttempted = true;
1935
+ }
1936
+ if (!auth.ok) {
1937
+ if (bootstrapAttempted) console.warn(`[sync] bootstrap grant auth failed: reason=${auth.reason} grant=${(c.req.header("X-Codemem-Bootstrap-Grant") ?? "").trim()} path=${c.req.path}`);
1938
+ const wireReason = bootstrapAttempted ? "bootstrap_grant_invalid" : auth.reason;
1939
+ return (preauthChecked ? null : rateLimitedResponse(c, c.req.path, false)) ?? c.json(unauthorizedPayload(wireReason), 401);
1940
+ }
1941
+ const limited = rateLimitedResponse(c, auth.deviceId, true);
1942
+ if (limited) return limited;
1943
+ try {
1944
+ const [deviceId, fingerprint] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
1945
+ const syncReset = getSyncResetState(store.db);
1946
+ return c.json({
1807
1947
  device_id: deviceId,
1808
- fingerprint
1809
- };
1948
+ protocol_version: SYNC_PROTOCOL_VERSION,
1949
+ fingerprint,
1950
+ sync_reset: syncReset
1951
+ });
1952
+ } catch {
1953
+ return c.json({ error: "internal_error" }, 500);
1810
1954
  }
1811
- const syncReset = getSyncResetState(store.db);
1812
- return c.json({
1813
- device_id: device.device_id,
1814
- protocol_version: SYNC_PROTOCOL_VERSION,
1815
- fingerprint: device.fingerprint,
1816
- sync_reset: syncReset
1817
- });
1818
- } catch {
1819
- return c.json({ error: "internal_error" }, 500);
1820
- }
1955
+ })();
1821
1956
  });
1822
1957
  app.get("/v1/ops", (c) => {
1823
1958
  const store = getStore();
@@ -1834,15 +1969,11 @@ function syncProtocolRoutes(getStore, opts = {}) {
1834
1969
  const snapshotId = c.req.query("snapshot_id") ?? null;
1835
1970
  const baselineCursor = c.req.query("baseline_cursor") ?? null;
1836
1971
  const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(rawLimit, 1e3)) : 200;
1837
- let localDeviceId = store.db.prepare("SELECT device_id FROM sync_device LIMIT 1").get();
1838
- if (!localDeviceId) {
1839
- const [deviceId] = ensureDeviceIdentity(store.db);
1840
- localDeviceId = { device_id: deviceId };
1841
- }
1972
+ const [localDeviceId] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
1842
1973
  const result = loadReplicationOpsForPeer(store.db, {
1843
1974
  since,
1844
1975
  limit,
1845
- deviceId: localDeviceId.device_id,
1976
+ deviceId: localDeviceId,
1846
1977
  generation: Number.isFinite(generation) ? generation : null,
1847
1978
  snapshotId,
1848
1979
  baselineCursor
@@ -1869,42 +2000,63 @@ function syncProtocolRoutes(getStore, opts = {}) {
1869
2000
  });
1870
2001
  app.get("/v1/snapshot", (c) => {
1871
2002
  const store = getStore();
1872
- const auth = authorizeSyncRequest(store, c.req, Buffer.alloc(0));
1873
- if (!auth.ok) return rateLimitedResponse(c, c.req.path, false) ?? c.json(unauthorizedPayload(auth.reason), 401);
1874
- const limited = rateLimitedResponse(c, auth.deviceId, true);
1875
- if (limited) return limited;
1876
- try {
1877
- const rawGeneration = c.req.query("generation");
1878
- const generation = rawGeneration != null && rawGeneration.trim().length > 0 ? Number.parseInt(rawGeneration, 10) : null;
1879
- const snapshotId = c.req.query("snapshot_id") ?? null;
1880
- const baselineCursor = c.req.query("baseline_cursor") ?? null;
1881
- const pageToken = c.req.query("page_token") ?? null;
1882
- const rawLimit = Number.parseInt(c.req.query("limit") ?? "200", 10);
1883
- const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(rawLimit, 5e3)) : 200;
1884
- if (generation == null || !Number.isFinite(generation)) return c.json({ error: "missing_generation" }, 400);
1885
- if (!snapshotId) return c.json({ error: "missing_snapshot_id" }, 400);
1886
- const result = loadMemorySnapshotPageForPeer(store.db, {
1887
- generation,
1888
- snapshotId,
1889
- baselineCursor,
1890
- pageToken,
1891
- limit,
1892
- peerDeviceId: auth.deviceId
1893
- });
1894
- return c.json({
1895
- generation: result.boundary.generation,
1896
- snapshot_id: result.boundary.snapshot_id,
1897
- baseline_cursor: result.boundary.baseline_cursor,
1898
- retained_floor_cursor: result.boundary.retained_floor_cursor,
1899
- items: result.items,
1900
- next_page_token: result.nextPageToken,
1901
- has_more: result.hasMore
1902
- });
1903
- } catch (err) {
1904
- const message = err instanceof Error ? err.message : "";
1905
- if (message === "generation_mismatch" || message === "boundary_mismatch") return c.json({ error: message }, 409);
1906
- return c.json({ error: "internal_error" }, 500);
1907
- }
2003
+ return (async () => {
2004
+ let auth = authorizeSyncRequest(store, c.req, Buffer.alloc(0));
2005
+ let preauthChecked = false;
2006
+ let bootstrapAttempted = false;
2007
+ if (!auth.ok) {
2008
+ if ((c.req.header("X-Codemem-Bootstrap-Grant") ?? "").trim()) {
2009
+ preauthChecked = true;
2010
+ const bootstrapLimited = rateLimitedResponse(c, c.req.path, false);
2011
+ if (bootstrapLimited) return bootstrapLimited;
2012
+ } else {
2013
+ preauthChecked = true;
2014
+ const unauthLimited = rateLimitedResponse(c, c.req.path, false);
2015
+ if (unauthLimited) return unauthLimited;
2016
+ }
2017
+ auth = await authorizeBootstrapGrantRequest(store, c.req, Buffer.alloc(0));
2018
+ bootstrapAttempted = true;
2019
+ }
2020
+ if (!auth.ok) {
2021
+ if (bootstrapAttempted) console.warn(`[sync] bootstrap grant auth failed: reason=${auth.reason} grant=${(c.req.header("X-Codemem-Bootstrap-Grant") ?? "").trim()} path=${c.req.path}`);
2022
+ const wireReason = bootstrapAttempted ? "bootstrap_grant_invalid" : auth.reason;
2023
+ return (preauthChecked ? null : rateLimitedResponse(c, c.req.path, false)) ?? c.json(unauthorizedPayload(wireReason), 401);
2024
+ }
2025
+ const limited = rateLimitedResponse(c, auth.deviceId, true);
2026
+ if (limited) return limited;
2027
+ try {
2028
+ const rawGeneration = c.req.query("generation");
2029
+ const generation = rawGeneration != null && rawGeneration.trim().length > 0 ? Number.parseInt(rawGeneration, 10) : null;
2030
+ const snapshotId = c.req.query("snapshot_id") ?? null;
2031
+ const baselineCursor = c.req.query("baseline_cursor") ?? null;
2032
+ const pageToken = c.req.query("page_token") ?? null;
2033
+ const rawLimit = Number.parseInt(c.req.query("limit") ?? "200", 10);
2034
+ const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(rawLimit, 5e3)) : 200;
2035
+ if (generation == null || !Number.isFinite(generation)) return c.json({ error: "missing_generation" }, 400);
2036
+ if (!snapshotId) return c.json({ error: "missing_snapshot_id" }, 400);
2037
+ const result = loadMemorySnapshotPageForPeer(store.db, {
2038
+ generation,
2039
+ snapshotId,
2040
+ baselineCursor,
2041
+ pageToken,
2042
+ limit,
2043
+ peerDeviceId: auth.deviceId
2044
+ });
2045
+ return c.json({
2046
+ generation: result.boundary.generation,
2047
+ snapshot_id: result.boundary.snapshot_id,
2048
+ baseline_cursor: result.boundary.baseline_cursor,
2049
+ retained_floor_cursor: result.boundary.retained_floor_cursor,
2050
+ items: result.items,
2051
+ next_page_token: result.nextPageToken,
2052
+ has_more: result.hasMore
2053
+ });
2054
+ } catch (err) {
2055
+ const message = err instanceof Error ? err.message : "";
2056
+ if (message === "generation_mismatch" || message === "boundary_mismatch") return c.json({ error: message }, 409);
2057
+ return c.json({ error: "internal_error" }, 500);
2058
+ }
2059
+ })();
1908
2060
  });
1909
2061
  app.post("/v1/ops", async (c) => {
1910
2062
  const store = getStore();
@@ -1931,13 +2083,9 @@ function syncProtocolRoutes(getStore, opts = {}) {
1931
2083
  reason: "device_id_mismatch",
1932
2084
  op_id: op.op_id
1933
2085
  }, 400);
1934
- let localDeviceId = store.db.prepare("SELECT device_id FROM sync_device LIMIT 1").get();
1935
- if (!localDeviceId) {
1936
- const [deviceId] = ensureDeviceIdentity(store.db);
1937
- localDeviceId = { device_id: deviceId };
1938
- }
2086
+ const [localDeviceId] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
1939
2087
  const filteredInbound = filterOpsForPeer(store, peerDeviceId, normalizedOps);
1940
- const result = applyReplicationOps(store.db, filteredInbound.allowed, localDeviceId.device_id);
2088
+ const result = applyReplicationOps(store.db, filteredInbound.allowed, localDeviceId);
1941
2089
  return c.json({
1942
2090
  ...result,
1943
2091
  skipped: result.skipped + filteredInbound.skipped
@@ -2171,7 +2319,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
2171
2319
  publicKey = String(deviceRow.public_key);
2172
2320
  fingerprint = String(deviceRow.fingerprint);
2173
2321
  } else try {
2174
- const [id, fp] = ensureDeviceIdentity(store.db);
2322
+ const [id, fp] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
2175
2323
  deviceId = id;
2176
2324
  fingerprint = fp;
2177
2325
  publicKey = d.select({ public_key: schema.syncDevice.public_key }).from(schema.syncDevice).where(eq(schema.syncDevice.device_id, id)).get()?.public_key ?? "";
@@ -2552,6 +2700,47 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
2552
2700
  return c.json({ error: message }, message.includes("request_not_found") || message.includes("not found") ? 404 : 400);
2553
2701
  }
2554
2702
  });
2703
+ app.get("/api/sync/bootstrap-grants", async (c) => {
2704
+ const groupId = String(c.req.query("group_id") ?? "").trim();
2705
+ if (!groupId) return c.json({ error: "group_id_required" }, 400);
2706
+ const config = readCoordinatorSyncConfig();
2707
+ try {
2708
+ const items = await coordinatorListBootstrapGrantsAction({
2709
+ groupId,
2710
+ remoteUrl: config.syncCoordinatorUrl || null,
2711
+ adminSecret: config.syncCoordinatorAdminSecret || null
2712
+ });
2713
+ return c.json({ items });
2714
+ } catch (error) {
2715
+ const message = error instanceof Error ? error.message : "unknown";
2716
+ return c.json({ error: message }, 500);
2717
+ }
2718
+ });
2719
+ app.post("/api/sync/bootstrap-grants/revoke", async (c) => {
2720
+ let body;
2721
+ try {
2722
+ body = await c.req.json();
2723
+ } catch {
2724
+ return c.json({ error: "invalid json" }, 400);
2725
+ }
2726
+ const grantId = String(body.grant_id ?? "").trim();
2727
+ if (!grantId) return c.json({ error: "grant_id_required" }, 400);
2728
+ const config = readCoordinatorSyncConfig();
2729
+ try {
2730
+ if (!await coordinatorRevokeBootstrapGrantAction({
2731
+ grantId,
2732
+ remoteUrl: config.syncCoordinatorUrl || null,
2733
+ adminSecret: config.syncCoordinatorAdminSecret || null
2734
+ })) return c.json({ error: "grant_not_found" }, 404);
2735
+ return c.json({
2736
+ ok: true,
2737
+ grant_id: grantId
2738
+ });
2739
+ } catch (error) {
2740
+ const message = error instanceof Error ? error.message : "unknown";
2741
+ return c.json({ error: message }, 500);
2742
+ }
2743
+ });
2555
2744
  app.delete("/api/sync/peers/:peer_device_id", (c) => {
2556
2745
  const store = getStore();
2557
2746
  {