@codemem/server 0.40.2 → 0.42.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,14 +1,14 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
3
- import { ACCESS_CLEANUP_OP_TYPE, CODEMEM_CONFIG_ENV_OVERRIDES, DEFAULT_TIME_WINDOW_S, LEGACY_SHARED_REVIEW_SCOPE_ID, LOCAL_DEFAULT_SCOPE_ID, LOCAL_SYNC_CAPABILITY, LOCAL_SYNC_FEATURES, MemoryStore, RecipientPolicyEdgeRequestError, SCOPE_MEMBERSHIP_REVOCATION_LIMITATION, SYNC_AUTHORIZATION_REFRESH_HEADER, SYNC_CAPABILITY_HEADER, SYNC_FEATURES_HEADER, SYNC_SCOPE_QUERY_PARAM, VERSION, VERSION as VERSION$1, addSyncScopeToBoundary, analyzeProjectScopeMappingChangeGuardrails, applyReplicationOps, buildAuthHeaders, buildBaseUrl, buildFilterClausesWithContext, buildRawEventEnvelopeFromCodexHook, buildRawEventEnvelopeFromHook, canonicalMemoryKind, canonicalWorkspaceIdentity, cleanupNonces, coerceObserverCommand, commitRecipientPolicyEdges, coordinatorArchiveGroupAction, coordinatorCreateAddDeviceInviteAction, coordinatorCreateGroupAction, coordinatorCreateInviteAction, coordinatorCreateScopeAction, coordinatorDisableDeviceAction, coordinatorEnableDeviceAction, coordinatorGrantScopeMembershipAction, coordinatorImportInviteAction, coordinatorListBootstrapGrantsAction, coordinatorListConsumedTeamInvitesAction, coordinatorListDevicesAction, coordinatorListGroupsAction, coordinatorListJoinRequestsAction, coordinatorListReviewedRecipientInviteEvidenceAction, coordinatorListScopeMembershipsAction, coordinatorListScopesAction, coordinatorRemoveDeviceAction, coordinatorRenameDeviceAction, coordinatorRenameGroupAction, coordinatorReviewJoinRequestAction, coordinatorRevokeBootstrapGrantAction, coordinatorRevokeScopeMembershipAction, coordinatorStatusSnapshot, coordinatorUnarchiveGroupAction, coordinatorUpdateScopeAction, countShareableProjectMemories, createCoordinatorReciprocalApproval, decodeInvitePayload, defaultSpaceScopeIdForGroup, deleteProjectScopeSettingsMapping, diagnoseStalePeerReceivedRows, ensureDeviceIdentity, executeShareProvisioning, extractInvitePayload, extractReplicationOps, filterReplicationOpsForSyncWithStatus, fingerprintPublicKey, formatHostPort, friendlyDeviceName, fromJson, getCodememConfigPath, getCodememEnvOverrides, getCoordinatorEnrollmentReconciliationIssueSummary, getCoordinatorGroupPreference, getRecipientPolicyAuthorityState, getSemanticIndexDiagnostics, getSyncResetState, inviteTokenDigest, isPeerTrustBindingCompatible, isProjectSyncEnablementError, isScopedSyncCapability, isSummaryLikeMemory, listAuthorizedScopesForPeer, listCoordinatorJoinRequests, listInboundScopeRejections, listLegacyRecipientPolicyProjections, listMaintenanceJobs, listObserverProviderOptions, listPerPeerScopeSyncState, listProjectScopeCandidates, listProjectScopeInventory, listProjectScopeSettingsMappings, listRecipientPolicyIntent, listRecipientPolicyReview, listSharingDomainSettingsScopes, loadMemorySnapshotPageForPeer, loadReplicationOpsForPeer, lookupCoordinatorPeers, mergeAddresses, migrateRecipientPolicyIntent, negotiateSyncCapability, normalizeAddress, normalizeSyncCapability, normalizeSyncFeatures, normalizeTeammateName, parseAcceptedProjectIntent, parseReassignScopePayload, parseStrictInteger, parseSyncScopeRequest, persistShareOperation, personalScopeGrantStatusForPeer, planShareOperation, previewRecipientPolicyEdges, previewRecipientPolicyOnboarding, previewRecipientPolicyOnboardingFromReviewedIntent, probeAvailableCredentials, projectShareLifecycle, readCodememConfigFile, readCoordinatorSyncConfig, reassignProjectScopeInventoryProject, recipientInviteAuthoritativeIdentityId, recipientReviewedIntentDigest, reconcileCoordinatorEnrollmentSnapshot, reconcileRecipientPolicyProject, reconcileShareOperationAcceptance, recordNonce, refreshAuthorizedCoordinatorPeerTrust, refreshConfiguredScopeMembershipCache, rejectInboundScopeFailures, requestJson, resolveDbPath, resolveRecipientPolicyReview, resolveRecipientPolicyReviewBulk, runSyncPass, schema, stripPrivateObj, summarizeInboundScopeRejections, supportsSyncFeature, syncScopeResetRequiredPayload, updatePeerAddresses, upsertCoordinatorGroupPreference, upsertProjectScopeSettingsMapping, verifyRecipientReviewedIntent, verifySignature, writeCodememConfigFile } from "@codemem/core";
2
+ import { dirname, isAbsolute, join, posix, resolve, win32 } from "node:path";
3
+ import { ACCESS_CLEANUP_OP_TYPE, CODEMEM_CONFIG_ENV_OVERRIDES, DEFAULT_TIME_WINDOW_S, LEGACY_SHARED_REVIEW_SCOPE_ID, LOCAL_DEFAULT_SCOPE_ID, LOCAL_SYNC_CAPABILITY, LOCAL_SYNC_FEATURES, MemoryStore, PROMPT_TRANSPORT_PROTOCOL_RANGE, RawEventIngestValidationError, RecipientPolicyEdgeRequestError, SCOPE_MEMBERSHIP_REVOCATION_LIMITATION, SYNC_AUTHORIZATION_REFRESH_HEADER, SYNC_CAPABILITY_HEADER, SYNC_FEATURES_HEADER, SYNC_SCOPE_QUERY_PARAM, VERSION, VERSION as VERSION$1, VIEWER_IDENTITY_TARGET_KEYS, VIEWER_SERVICE_DISCRIMINATOR, addSyncScopeToBoundary, analyzeProjectScopeMappingChangeGuardrails, applyReplicationOps, buildAuthHeaders, buildBaseUrl, buildDirectPeerAuthHeaders, buildFilterClausesWithContext, buildRawEventEnvelopeFromCodexHook, buildRawEventEnvelopeFromHook, buildViewerIdentityTarget, canonicalMemoryKind, canonicalWorkspaceIdentity, cleanupNonces, clonePromptPackAttempt, coerceObserverCommand, commitDeviceIdentityBindings, commitRecipientPolicyEdges, coordinatorArchiveGroupAction, coordinatorCreateAddDeviceInviteAction, coordinatorCreateGroupAction, coordinatorCreateInviteAction, coordinatorCreateScopeAction, coordinatorDisableDeviceAction, coordinatorEnableDeviceAction, coordinatorGrantScopeMembershipAction, coordinatorImportInviteAction, coordinatorListBootstrapGrantsAction, coordinatorListConsumedTeamInvitesAction, coordinatorListDevicesAction, coordinatorListGroupsAction, coordinatorListJoinRequestsAction, coordinatorListReviewedRecipientInviteEvidenceAction, coordinatorListScopeMembershipsAction, coordinatorListScopesAction, coordinatorRemoveDeviceAction, coordinatorRenameDeviceAction, coordinatorRenameGroupAction, coordinatorReviewJoinRequestAction, coordinatorRevokeBootstrapGrantAction, coordinatorRevokeScopeMembershipAction, coordinatorStatusSnapshot, coordinatorUnarchiveGroupAction, coordinatorUpdateScopeAction, countShareableProjectMemories, createCoordinatorReciprocalApproval, decodeInvitePayload, defaultSpaceScopeIdForGroup, deleteProjectScopeSettingsMapping, detectInstallKind, diagnoseStalePeerReceivedRows, ensureDeviceIdentity, executeShareProvisioning, extractInvitePayload, extractReplicationOps, filterReplicationOpsForSyncWithStatus, fingerprintPublicKey, formatHostPort, friendlyDeviceName, fromJson, getCodememConfigPath, getCodememEnvOverrides, getCoordinatorEnrollmentReconciliationIssueSummary, getCoordinatorGroupPreference, getRecipientPolicyAuthorityState, getSemanticIndexDiagnostics, getSyncResetState, getUpdateStatus, ingestRawEvents, inviteTokenDigest, isPeerTrustBindingCompatible, isProjectSyncEnablementError, isScopedSyncCapability, listAuthorizedScopesForPeer, listCoordinatorJoinRequests, listDeviceIdentityInventory, listInboundScopeRejections, listLegacyRecipientPolicyProjections, listMaintenanceJobs, listObserverProviderOptions, listPerPeerScopeSyncState, listProjectScopeCandidates, listProjectScopeInventory, listProjectScopeSettingsMappings, listRecipientPolicyIntent, listRecipientPolicyReview, listSharingDomainSettingsScopes, loadMemorySnapshotPageForPeer, loadReplicationOpsForPeer, lookupCoordinatorPeers, mergeAddresses, migrateRecipientPolicyIntent, negotiateSyncCapability, normalizeAddress, normalizeHumanPresentationName, normalizeSyncCapability, normalizeSyncFeatures, normalizeTeammateName, parseAcceptedProjectIntent, parsePositiveMemoryId, parseReassignScopePayload, parseStrictInteger, parseSyncScopeRequest, persistShareOperation, personalScopeGrantStatusForPeer, planShareOperation, previewDeviceIdentityBindings, previewRecipientPolicyEdges, previewRecipientPolicyOnboarding, previewRecipientPolicyOnboardingFromReviewedIntent, probeAvailableCredentials, projectShareLifecycle, promptPackArtifactFingerprint, readCodememConfigFile, readCoordinatorSyncConfig, reassignProjectScopeInventoryProject, recipientInviteAuthoritativeIdentityId, recipientReviewedIntentDigest, reconcileCoordinatorEnrollmentSnapshot, reconcileRecipientPolicyProject, reconcileShareOperationAcceptance, recordHighestObservedDirectSignatureVersion, recordNonce, recordPromptPackArtifacts, recordPromptPackTerminal, refreshAuthorizedCoordinatorPeerTrust, refreshConfiguredScopeMembershipCache, rejectInboundScopeFailures, requestJson, resolveDbPath, resolveProject, resolveRecipientPolicyReview, resolveRecipientPolicyReviewBulk, runSyncPass, schema, summarizeInboundScopeRejections, supportsSyncFeature, syncScopeResetRequiredPayload, tryUpdateRetrievalDelivery, updatePeerAddresses, upsertCoordinatorGroupPreference, upsertProjectScopeSettingsMapping, verifyDirectPeerSignature, verifyRecipientReviewedIntent, 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";
7
7
  import { RateLimiter } from "limiter";
8
8
  import { and, count, desc, eq, inArray, max, ne } from "drizzle-orm";
9
9
  import { drizzle } from "drizzle-orm/better-sqlite3";
10
+ import { homedir, hostname, networkInterfaces } from "node:os";
10
11
  import { createHash, randomUUID } from "node:crypto";
11
- import { hostname, networkInterfaces } from "node:os";
12
12
  //#region src/middleware.ts
13
13
  var LOOPBACK_HOSTS = new Set([
14
14
  "127.0.0.1",
@@ -510,6 +510,32 @@ function configRoutes(opts = {}) {
510
510
  return app;
511
511
  }
512
512
  //#endregion
513
+ //#region src/routes/health.ts
514
+ function databaseReachable(getStore) {
515
+ try {
516
+ getStore().db.pragma("schema_version", { simple: true });
517
+ return true;
518
+ } catch {
519
+ return false;
520
+ }
521
+ }
522
+ function healthRoutes(getStore) {
523
+ const app = new Hono();
524
+ app.get("/api/health", (c) => {
525
+ const reachable = databaseReachable(getStore);
526
+ c.header("Cache-Control", "no-store");
527
+ return c.json({
528
+ service: VIEWER_SERVICE_DISCRIMINATOR,
529
+ version: VERSION$1,
530
+ pid: process.pid,
531
+ uptime_ms: Math.max(0, Math.floor(process.uptime() * 1e3)),
532
+ ready: reachable,
533
+ database: { reachable }
534
+ });
535
+ });
536
+ return app;
537
+ }
538
+ //#endregion
513
539
  //#region src/helpers.ts
514
540
  /**
515
541
  * Shared helpers for viewer-server routes.
@@ -547,6 +573,102 @@ function queryBool(value) {
547
573
  }
548
574
  //#endregion
549
575
  //#region src/routes/memory.ts
576
+ var MAX_FEED_QUERY_CODE_UNITS = 256;
577
+ var searchFunctionsRegisteredStores = /* @__PURE__ */ new WeakSet();
578
+ function casefold(value) {
579
+ return String(value ?? "").toLowerCase();
580
+ }
581
+ function normalizeMemorySearchQuery(value) {
582
+ return casefold(value?.trim()).slice(0, MAX_FEED_QUERY_CODE_UNITS) || void 0;
583
+ }
584
+ function ensureMemorySearchFunctions(store) {
585
+ if (searchFunctionsRegisteredStores.has(store)) return;
586
+ store.db.function("codemem_casefold", { deterministic: true }, casefold);
587
+ store.db.function("codemem_project_basename", { deterministic: true }, (value) => projectBasename(String(value ?? "").trim()));
588
+ searchFunctionsRegisteredStores.add(store);
589
+ }
590
+ var IDENTITY_LOOKUP_BATCH_SIZE = 500;
591
+ function identityValue(item, key) {
592
+ const direct = String(item[key] ?? "").trim();
593
+ if (direct) return direct;
594
+ const rawMetadata = item.metadata ?? item.metadata_json;
595
+ const metadata = typeof rawMetadata === "string" ? fromJson(rawMetadata) : rawMetadata;
596
+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return "";
597
+ return String(metadata[key] ?? "").trim();
598
+ }
599
+ function uniqueStrings(items, key) {
600
+ return [...new Set(items.map((item) => identityValue(item, key)).filter(Boolean))];
601
+ }
602
+ function batchedLookup(store, values, query) {
603
+ const rows = [];
604
+ for (let offset = 0; offset < values.length; offset += IDENTITY_LOOKUP_BATCH_SIZE) {
605
+ const batch = values.slice(offset, offset + IDENTITY_LOOKUP_BATCH_SIZE);
606
+ const placeholders = batch.map(() => "?").join(", ");
607
+ rows.push(...store.db.prepare(query(placeholders)).all(...batch));
608
+ }
609
+ return rows;
610
+ }
611
+ function humanName(value) {
612
+ if (typeof value !== "string") return void 0;
613
+ try {
614
+ return normalizeHumanPresentationName(value, "display_name");
615
+ } catch {
616
+ return;
617
+ }
618
+ }
619
+ /**
620
+ * Add presentation-only identity labels without changing persisted provenance.
621
+ * Resolved names are display hints, not authority or ownership evidence; legacy
622
+ * metadata receives the same top-level-first fallback as existing read paths.
623
+ */
624
+ function attachResolvedIdentityFieldsUnsafe(store, items) {
625
+ const actorIds = uniqueStrings(items, "actor_id");
626
+ const deviceIds = uniqueStrings(items, "origin_device_id");
627
+ const peers = batchedLookup(store, deviceIds, (placeholders) => `SELECT peer_device_id, name, actor_id FROM sync_peers
628
+ WHERE peer_device_id IN (${placeholders})
629
+ AND TRIM(COALESCE(pinned_fingerprint, '')) <> ''`);
630
+ const peerByDevice = new Map(peers.map((peer) => [peer.peer_device_id, peer]));
631
+ const peerActorIds = peers.map((peer) => String(peer.actor_id ?? "").trim()).filter((actorId) => actorId.length > 0);
632
+ const actorRows = batchedLookup(store, [...new Set([...actorIds, ...peerActorIds])], (placeholders) => `SELECT actor_id, display_name, status, merged_into_actor_id FROM actors
633
+ WHERE actor_id IN (${placeholders})`);
634
+ const devices = batchedLookup(store, deviceIds, (placeholders) => `SELECT device_id, display_name FROM identity_devices
635
+ WHERE device_id IN (${placeholders}) AND status = 'active'`);
636
+ const actorById = new Map(actorRows.map((actor) => [actor.actor_id, actor]));
637
+ let pendingMergeTargets = [...new Set(actorRows.map((actor) => String(actor.merged_into_actor_id ?? "").trim()).filter((actorId) => actorId && !actorById.has(actorId)))];
638
+ while (pendingMergeTargets.length > 0) {
639
+ const targetRows = batchedLookup(store, pendingMergeTargets, (placeholders) => `SELECT actor_id, display_name, status, merged_into_actor_id FROM actors
640
+ WHERE actor_id IN (${placeholders})`);
641
+ for (const actor of targetRows) actorById.set(actor.actor_id, actor);
642
+ pendingMergeTargets = [...new Set(targetRows.map((actor) => String(actor.merged_into_actor_id ?? "").trim()).filter((actorId) => actorId && !actorById.has(actorId)))];
643
+ }
644
+ const resolvedActorName = (actorId) => {
645
+ const seen = /* @__PURE__ */ new Set();
646
+ let currentActorId = actorId;
647
+ while (currentActorId && !seen.has(currentActorId)) {
648
+ seen.add(currentActorId);
649
+ const actor = actorById.get(currentActorId);
650
+ if (!actor) return void 0;
651
+ if (actor.status === "active" && actor.merged_into_actor_id === null) return humanName(actor.display_name);
652
+ currentActorId = String(actor.merged_into_actor_id ?? "").trim();
653
+ }
654
+ };
655
+ const deviceNameById = new Map(devices.map((device) => [device.device_id, humanName(device.display_name)]).filter((entry) => entry[1] !== void 0));
656
+ for (const item of items) {
657
+ const actorId = identityValue(item, "actor_id");
658
+ const deviceId = identityValue(item, "origin_device_id");
659
+ const peer = peerByDevice.get(deviceId);
660
+ const peerActorId = String(peer?.actor_id ?? "").trim();
661
+ const actorName = actorById.has(actorId) ? resolvedActorName(actorId) ?? humanName(item.actor_display_name) : resolvedActorName(peerActorId) ?? humanName(item.actor_display_name);
662
+ const deviceName = deviceNameById.get(deviceId) ?? humanName(peer?.name);
663
+ if (actorName) item.resolved_actor_display_name = actorName;
664
+ if (deviceName) item.resolved_device_display_name = deviceName;
665
+ }
666
+ }
667
+ function attachResolvedIdentityFields(store, items) {
668
+ try {
669
+ attachResolvedIdentityFieldsUnsafe(store, items);
670
+ } catch {}
671
+ }
550
672
  function serializeMemoryRow(ownedBySelf, row) {
551
673
  const metadata = fromJson(row.metadata_json ?? null);
552
674
  const owned_by_self = ownedBySelf(row);
@@ -625,12 +747,49 @@ function summaryLikeSqlPredicate() {
625
747
  OR (
626
748
  json_valid(COALESCE(memory_items.metadata_json, ''))
627
749
  AND (
628
- COALESCE(json_extract(memory_items.metadata_json, '$.is_summary') = 1, 0)
750
+ COALESCE(json_type(memory_items.metadata_json, '$.is_summary') = 'true', 0)
629
751
  OR LOWER(TRIM(COALESCE(json_extract(memory_items.metadata_json, '$.source'), ''))) = 'observer_summary'
630
752
  )
631
753
  )
632
754
  )`;
633
755
  }
756
+ function memorySearchSql(query) {
757
+ const textClause = `INSTR(codemem_casefold(
758
+ COALESCE(memory_items.title, '') || CHAR(10) ||
759
+ COALESCE(memory_items.body_text, '') || CHAR(10) ||
760
+ CASE WHEN ${summaryLikeSqlPredicate()} THEN 'session_summary' ELSE
761
+ COALESCE(NULLIF(LOWER(TRIM(COALESCE(memory_items.kind, ''))), ''), 'change')
762
+ END || CHAR(10) ||
763
+ COALESCE(
764
+ memory_items.project,
765
+ codemem_project_basename(
766
+ (SELECT sessions.project FROM sessions WHERE sessions.id = memory_items.session_id)
767
+ ),
768
+ ''
769
+ ) || CHAR(10) ||
770
+ COALESCE(memory_items.tags_text, '') || CHAR(10) ||
771
+ COALESCE(memory_items.facts, '') || CHAR(10) ||
772
+ COALESCE(memory_items.subtitle, '') || CHAR(10) ||
773
+ COALESCE(memory_items.narrative, '') || CHAR(10) ||
774
+ CASE WHEN json_valid(COALESCE(memory_items.metadata_json, '')) THEN
775
+ COALESCE(json_extract(memory_items.metadata_json, '$.request'), '') || CHAR(10) ||
776
+ COALESCE(json_extract(memory_items.metadata_json, '$.subtitle'), '') || CHAR(10) ||
777
+ COALESCE(json_extract(memory_items.metadata_json, '$.narrative'), '') || CHAR(10) ||
778
+ COALESCE(json_extract(memory_items.metadata_json, '$.facts'), '') || CHAR(10) ||
779
+ COALESCE(json_extract(memory_items.metadata_json, '$.summary'), '')
780
+ ELSE '' END
781
+ ), codemem_casefold(?)) > 0`;
782
+ const parsedMemoryId = parsePositiveMemoryId(query);
783
+ const memoryId = parsedMemoryId != null && String(parsedMemoryId) === query ? parsedMemoryId : null;
784
+ if (memoryId != null) return {
785
+ clause: `(memory_items.id = ? OR ${textClause})`,
786
+ params: [memoryId, query]
787
+ };
788
+ return {
789
+ clause: textClause,
790
+ params: [query]
791
+ };
792
+ }
634
793
  function countVisibleObservationRows(store, filters) {
635
794
  const filterResult = buildViewerMemoryFilters(store, filters);
636
795
  const clauses = [
@@ -683,43 +842,32 @@ function countVisibleArtifactRows(store, project) {
683
842
  return Number(row?.total ?? 0);
684
843
  }
685
844
  function queryMemoryPage(store, options) {
845
+ ensureMemorySearchFunctions(store);
686
846
  const filters = {};
687
847
  if (options.project) filters.project = options.project;
688
848
  if (options.scope) filters.ownership_scope = options.scope;
689
849
  const filterResult = buildViewerMemoryFilters(store, filters);
690
- const where = ["memory_items.active = 1", ...filterResult.clauses].join(" AND ");
850
+ const summaryPredicate = summaryLikeSqlPredicate();
851
+ const clauses = [
852
+ "memory_items.active = 1",
853
+ options.classification === "summary" ? summaryPredicate : `NOT ${summaryPredicate}`,
854
+ ...filterResult.clauses
855
+ ];
856
+ const params = [...filterResult.params];
857
+ if (options.q) {
858
+ const search = memorySearchSql(options.q);
859
+ clauses.push(search.clause);
860
+ params.push(...search.params);
861
+ }
862
+ const where = clauses.join(" AND ");
691
863
  const from = filterResult.joinSessions ? "memory_items JOIN sessions ON sessions.id = memory_items.session_id" : "memory_items";
692
864
  const rows = store.db.prepare(`SELECT memory_items.* FROM ${from}
693
865
  WHERE ${where}
694
- ORDER BY memory_items.created_at DESC
695
- LIMIT ? OFFSET ?`).all(...filterResult.params, options.limit + 1, options.offset);
866
+ ORDER BY memory_items.created_at DESC, memory_items.id DESC
867
+ LIMIT ? OFFSET ?`).all(...params, options.limit + 1, options.offset);
696
868
  const ownedBySelf = store.buildOwnershipPredicate();
697
869
  return rows.map((row) => serializeMemoryRow(ownedBySelf, row));
698
870
  }
699
- function isSummaryLikeMemory$1(item) {
700
- return isSummaryLikeMemory({
701
- kind: item.kind,
702
- metadata: item.metadata_json
703
- });
704
- }
705
- function selectMemoryPage(store, options) {
706
- const pageSize = Math.max(options.limit + options.offset + 10, 50);
707
- let rawOffset = 0;
708
- const matched = [];
709
- while (matched.length < options.offset + options.limit + 1) {
710
- const page = queryMemoryPage(store, {
711
- limit: pageSize,
712
- offset: rawOffset,
713
- project: options.project,
714
- scope: options.scope
715
- });
716
- if (page.length === 0) break;
717
- matched.push(...page.filter(options.matcher));
718
- if (page.length < pageSize) break;
719
- rawOffset += page.length;
720
- }
721
- return matched.slice(options.offset, options.offset + options.limit + 1);
722
- }
723
871
  function memoryRoutes(getStore) {
724
872
  const app = new Hono();
725
873
  app.get("/api/sessions", (c) => {
@@ -768,17 +916,19 @@ function memoryRoutes(getStore) {
768
916
  {
769
917
  const limit = Math.max(1, queryInt(c.req.query("limit"), 20));
770
918
  const offset = Math.max(0, queryInt(c.req.query("offset"), 0));
771
- const items = selectMemoryPage(store, {
919
+ const items = queryMemoryPage(store, {
772
920
  limit,
773
921
  offset,
774
922
  project: c.req.query("project") || void 0,
775
923
  scope: normalizeScope(c.req.query("scope")),
776
- matcher: (item) => !isSummaryLikeMemory$1(item)
924
+ q: normalizeMemorySearchQuery(c.req.query("q")),
925
+ classification: "observation"
777
926
  });
778
927
  const hasMore = items.length > limit;
779
928
  const result = hasMore ? items.slice(0, limit) : items;
780
929
  const asRecords = result;
781
930
  attachSessionFields(store, asRecords);
931
+ attachResolvedIdentityFields(store, asRecords);
782
932
  return c.json({
783
933
  items: asRecords,
784
934
  pagination: {
@@ -795,17 +945,19 @@ function memoryRoutes(getStore) {
795
945
  {
796
946
  const limit = Math.max(1, queryInt(c.req.query("limit"), 50));
797
947
  const offset = Math.max(0, queryInt(c.req.query("offset"), 0));
798
- const items = selectMemoryPage(store, {
948
+ const items = queryMemoryPage(store, {
799
949
  limit,
800
950
  offset,
801
951
  project: c.req.query("project") || void 0,
802
952
  scope: normalizeScope(c.req.query("scope")),
803
- matcher: (item) => isSummaryLikeMemory$1(item)
953
+ q: normalizeMemorySearchQuery(c.req.query("q")),
954
+ classification: "summary"
804
955
  });
805
956
  const hasMore = items.length > limit;
806
957
  const result = hasMore ? items.slice(0, limit) : items;
807
958
  const asRecords = result;
808
959
  attachSessionFields(store, asRecords);
960
+ attachResolvedIdentityFields(store, asRecords);
809
961
  return c.json({
810
962
  items: asRecords,
811
963
  pagination: {
@@ -900,6 +1052,7 @@ function memoryRoutes(getStore) {
900
1052
  if (project) filters.project = project;
901
1053
  const asRecords = store.recent(limit, filters);
902
1054
  attachSessionFields(store, asRecords);
1055
+ attachResolvedIdentityFields(store, asRecords);
903
1056
  return c.json({ items: asRecords });
904
1057
  }
905
1058
  });
@@ -1051,39 +1204,416 @@ function observerStatusRoutes(deps) {
1051
1204
  return app;
1052
1205
  }
1053
1206
  //#endregion
1207
+ //#region src/routes/target-validation.ts
1208
+ var IDENTITY_TARGET_KEYS = new Set(VIEWER_IDENTITY_TARGET_KEYS);
1209
+ var BOOLEAN_IDENTITY_TARGET_KEYS = new Set(["actor_id_present", "embedding_disabled"]);
1210
+ function invalidRequest$1(message) {
1211
+ return {
1212
+ ok: false,
1213
+ status: 400,
1214
+ body: { error: {
1215
+ code: "invalid_request",
1216
+ message
1217
+ } }
1218
+ };
1219
+ }
1220
+ function conflict(code, message) {
1221
+ return {
1222
+ ok: false,
1223
+ status: 409,
1224
+ body: { error: {
1225
+ code,
1226
+ message
1227
+ } }
1228
+ };
1229
+ }
1230
+ function isRecord$1(value) {
1231
+ return value != null && typeof value === "object" && !Array.isArray(value);
1232
+ }
1233
+ function currentIdentityTarget() {
1234
+ return buildViewerIdentityTarget();
1235
+ }
1236
+ function requestedDbMatches(store, value) {
1237
+ if (value == null) return true;
1238
+ if (typeof value !== "string" || !value.trim()) return null;
1239
+ return resolve(resolveDbPath(value.trim())) === resolve(store.dbPath);
1240
+ }
1241
+ function requestedIdentityMatches(value) {
1242
+ if (value == null) return true;
1243
+ if (!isRecord$1(value)) return null;
1244
+ if (Object.keys(value).length !== IDENTITY_TARGET_KEYS.size || Object.keys(value).some((key) => !IDENTITY_TARGET_KEYS.has(key))) return "unsupported";
1245
+ const expected = currentIdentityTarget();
1246
+ for (const key of VIEWER_IDENTITY_TARGET_KEYS) {
1247
+ const requested = value[key];
1248
+ if (BOOLEAN_IDENTITY_TARGET_KEYS.has(key)) {
1249
+ if (typeof requested !== "boolean") return null;
1250
+ } else if (requested !== null && typeof requested !== "string") return null;
1251
+ if (requested !== expected[key]) return false;
1252
+ }
1253
+ return true;
1254
+ }
1255
+ function validateViewerTarget(store, payload, options = {}) {
1256
+ if (options.requirePairedTargets) {
1257
+ const hasDbPath = Object.hasOwn(payload, "db_path");
1258
+ if (hasDbPath !== Object.hasOwn(payload, "identity_target")) return invalidRequest$1("db_path and identity_target must be provided together");
1259
+ if (hasDbPath && (payload.db_path == null || payload.identity_target == null)) return invalidRequest$1("db_path and identity_target must be valid when provided");
1260
+ }
1261
+ const dbMatches = requestedDbMatches(store, payload.db_path);
1262
+ if (dbMatches == null) return invalidRequest$1("db_path must be a non-empty string");
1263
+ if (!dbMatches) return conflict("viewer_db_mismatch", "viewer database does not match request");
1264
+ const identityMatches = requestedIdentityMatches(payload.identity_target);
1265
+ if (identityMatches === "unsupported") return conflict("viewer_contract_unsupported", "viewer request contract is incompatible");
1266
+ if (identityMatches == null) return invalidRequest$1("identity_target is invalid");
1267
+ if (!identityMatches) return conflict("viewer_identity_mismatch", "viewer identity does not match request");
1268
+ if ((options.requireCurrentIdentity || payload.identity_target != null) && !store.hasCurrentIdentity()) return conflict("viewer_identity_mismatch", "viewer identity does not match request");
1269
+ return { ok: true };
1270
+ }
1271
+ //#endregion
1272
+ //#region src/routes/pack.ts
1273
+ var MAX_LEDGER_PAYLOAD_BYTES = 16 * 1024;
1274
+ var MAX_METADATA_FIELD_CHARS = 512;
1275
+ var MAX_WORKING_SET_FILES = 50;
1276
+ var MAX_WORKING_SET_PATH_CHARS = 512;
1277
+ var INVALID_JSON = Symbol("invalid-json");
1278
+ var PACK_KEYS = new Set([
1279
+ "context",
1280
+ "limit",
1281
+ "token_budget",
1282
+ "project",
1283
+ "cwd",
1284
+ "all_projects",
1285
+ "working_set_files",
1286
+ "compact",
1287
+ "compact_detail_count",
1288
+ "db_path",
1289
+ "identity_target",
1290
+ "attempt"
1291
+ ]);
1292
+ var LEDGER_KEYS = new Set([
1293
+ "action",
1294
+ "attempt_id",
1295
+ "started_at",
1296
+ "source",
1297
+ "stream_id",
1298
+ "source_session_id",
1299
+ "prompt_number",
1300
+ "request_id",
1301
+ "retrieval_status",
1302
+ "delivery_status",
1303
+ "failure_code",
1304
+ "failure_stage",
1305
+ "original_attempt_id",
1306
+ "db_path",
1307
+ "identity_target"
1308
+ ]);
1309
+ var ATTEMPT_KEYS = new Set([
1310
+ "attempt_id",
1311
+ "started_at",
1312
+ "source",
1313
+ "stream_id",
1314
+ "source_session_id",
1315
+ "prompt_number",
1316
+ "request_id"
1317
+ ]);
1318
+ var FORBIDDEN_LEDGER_KEYS = new Set([
1319
+ "body",
1320
+ "context",
1321
+ "pack",
1322
+ "pack_text",
1323
+ "path",
1324
+ "preview",
1325
+ "prompt",
1326
+ "query",
1327
+ "raw_prompt",
1328
+ "title"
1329
+ ]);
1330
+ function invalidRequest(message) {
1331
+ return { error: {
1332
+ code: "invalid_request",
1333
+ message
1334
+ } };
1335
+ }
1336
+ function viewerIdentityMismatch() {
1337
+ return { error: {
1338
+ code: "viewer_identity_mismatch",
1339
+ message: "viewer identity does not match request"
1340
+ } };
1341
+ }
1342
+ function viewerContractUnsupported() {
1343
+ return { error: {
1344
+ code: "viewer_contract_unsupported",
1345
+ message: "viewer request contract is incompatible"
1346
+ } };
1347
+ }
1348
+ function isRecord(value) {
1349
+ return value != null && typeof value === "object" && !Array.isArray(value);
1350
+ }
1351
+ function isAbsolutePath(value) {
1352
+ return posix.isAbsolute(value) || win32.isAbsolute(value);
1353
+ }
1354
+ function normalizeWorkingSetPath(value) {
1355
+ const trimmed = value.trim();
1356
+ if (!trimmed || trimmed.length > MAX_WORKING_SET_PATH_CHARS || isAbsolutePath(trimmed)) return null;
1357
+ const normalized = posix.normalize(trimmed.replaceAll("\\", "/")).replace(/^\.\//, "");
1358
+ if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../")) return null;
1359
+ return normalized;
1360
+ }
1361
+ function validateMetadataFields(payload, allowedKeys) {
1362
+ if (Buffer.byteLength(JSON.stringify(payload), "utf8") > MAX_LEDGER_PAYLOAD_BYTES) return "ledger metadata exceeds 16384 bytes";
1363
+ for (const key of Object.keys(payload)) {
1364
+ if (FORBIDDEN_LEDGER_KEYS.has(key)) return `ledger metadata rejects sensitive field: ${key}`;
1365
+ if (!allowedKeys.has(key)) return `ledger metadata contains unsupported field: ${key}`;
1366
+ }
1367
+ if (typeof payload.attempt_id !== "string" || payload.attempt_id.length === 0) return "ledger metadata requires attempt_id";
1368
+ if (payload.prompt_number != null && (typeof payload.prompt_number !== "number" || !Number.isInteger(payload.prompt_number) || payload.prompt_number < 0)) return "ledger metadata prompt_number must be a non-negative integer";
1369
+ for (const key of [
1370
+ "attempt_id",
1371
+ "started_at",
1372
+ "source",
1373
+ "stream_id",
1374
+ "source_session_id",
1375
+ "request_id",
1376
+ "failure_code",
1377
+ "failure_stage",
1378
+ "original_attempt_id"
1379
+ ]) {
1380
+ const field = payload[key];
1381
+ if (field != null && (typeof field !== "string" || field.length > MAX_METADATA_FIELD_CHARS)) return `ledger metadata field ${key} is invalid`;
1382
+ if (typeof field === "string" && isAbsolutePath(field)) return `ledger metadata rejects absolute paths in field: ${key}`;
1383
+ }
1384
+ return null;
1385
+ }
1386
+ function attemptMetadata(payload) {
1387
+ return {
1388
+ attemptId: payload.attempt_id,
1389
+ startedAt: payload.started_at ?? (/* @__PURE__ */ new Date()).toISOString(),
1390
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
1391
+ source: payload.source ?? "opencode",
1392
+ streamId: payload.stream_id ?? null,
1393
+ sourceSessionId: payload.source_session_id ?? null,
1394
+ promptNumber: payload.prompt_number ?? null,
1395
+ requestId: payload.request_id ?? null
1396
+ };
1397
+ }
1398
+ function validatePackRequest(value) {
1399
+ if (!isRecord(value)) return "request body must be an object";
1400
+ for (const key of Object.keys(value)) if (!PACK_KEYS.has(key)) return `request body contains unsupported field: ${key}`;
1401
+ const context = typeof value.context === "string" ? value.context.trim() : "";
1402
+ if (!context) return "context required";
1403
+ const limit = value.limit ?? 10;
1404
+ if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1) return "limit must be a positive int";
1405
+ const tokenBudget = value.token_budget ?? null;
1406
+ if (tokenBudget !== null && (typeof tokenBudget !== "number" || !Number.isInteger(tokenBudget) || tokenBudget < 0)) return "token_budget must be a non-negative int";
1407
+ if (value.all_projects != null && typeof value.all_projects !== "boolean") return "all_projects must be a boolean";
1408
+ if (value.project != null && (typeof value.project !== "string" || !value.project.trim())) return "project must be a non-empty string";
1409
+ if (value.cwd != null && (typeof value.cwd !== "string" || !value.cwd.trim() || !isAbsolute(value.cwd))) return "cwd must be an absolute path";
1410
+ if (value.working_set_files != null && !Array.isArray(value.working_set_files)) return "working_set_files must be an array of repository-relative strings";
1411
+ const workingSetValues = value.working_set_files ?? [];
1412
+ if (workingSetValues.length > MAX_WORKING_SET_FILES) return `working_set_files must contain at most ${MAX_WORKING_SET_FILES} entries`;
1413
+ const workingSetFiles = [];
1414
+ for (const entry of workingSetValues) {
1415
+ if (typeof entry !== "string") return "working_set_files must be an array of repository-relative strings";
1416
+ const normalized = normalizeWorkingSetPath(entry);
1417
+ if (!normalized) return "working_set_files contains an invalid repository-relative path";
1418
+ if (!workingSetFiles.includes(normalized)) workingSetFiles.push(normalized);
1419
+ }
1420
+ if (value.compact != null && typeof value.compact !== "boolean") return "compact must be a boolean";
1421
+ if (value.compact_detail_count != null && (typeof value.compact_detail_count !== "number" || !Number.isInteger(value.compact_detail_count) || value.compact_detail_count < 0)) return "compact_detail_count must be a non-negative int";
1422
+ let attempt;
1423
+ if (value.attempt != null) {
1424
+ if (!isRecord(value.attempt)) return "attempt must be an object";
1425
+ const attemptError = validateMetadataFields(value.attempt, ATTEMPT_KEYS);
1426
+ if (attemptError) return attemptError;
1427
+ attempt = value.attempt;
1428
+ }
1429
+ const filters = {};
1430
+ if (value.all_projects !== true) {
1431
+ const envProject = process.env.CODEMEM_PROJECT?.trim() || null;
1432
+ const requestCwd = value.cwd;
1433
+ const requestProject = value.project;
1434
+ const project = resolveProject(requestCwd ?? process.cwd(), requestProject ?? (requestCwd == null ? envProject : void 0));
1435
+ if (project) filters.project = project;
1436
+ }
1437
+ if (workingSetFiles.length > 0) filters.working_set_paths = workingSetFiles;
1438
+ let renderOptions;
1439
+ if (value.compact != null || value.compact_detail_count != null) renderOptions = {
1440
+ compact: value.compact === true || value.compact_detail_count != null,
1441
+ ...value.compact_detail_count != null ? { compactDetailCount: value.compact_detail_count } : {}
1442
+ };
1443
+ return {
1444
+ context,
1445
+ limit,
1446
+ tokenBudget,
1447
+ filters,
1448
+ renderOptions,
1449
+ attempt
1450
+ };
1451
+ }
1452
+ function ledgerFailureStatus(reason) {
1453
+ if (reason === "idempotency_conflict") return 409;
1454
+ if (reason === "attempt_not_found") return 422;
1455
+ if (reason === "storage_unavailable") return 503;
1456
+ return 400;
1457
+ }
1458
+ function dispatchLedger(store, payload) {
1459
+ const action = payload.action;
1460
+ if (action !== "record" && action !== "delivery" && action !== "cache_reuse") return "ledger action is invalid";
1461
+ const metadata = attemptMetadata(payload);
1462
+ if (action === "delivery") {
1463
+ const status = payload.delivery_status;
1464
+ if (status !== "handed_off" && status !== "failed" && status !== "unknown") return "delivery action requires a valid delivery_status";
1465
+ return tryUpdateRetrievalDelivery(store.db, metadata.attemptId, status);
1466
+ }
1467
+ if (action === "cache_reuse") {
1468
+ if (typeof payload.original_attempt_id !== "string" || !payload.original_attempt_id) return "cache_reuse action requires original_attempt_id";
1469
+ return clonePromptPackAttempt(store.db, payload.original_attempt_id, metadata);
1470
+ }
1471
+ if (payload.retrieval_status !== "skipped" && payload.retrieval_status !== "failed" || typeof payload.failure_code !== "string" || !payload.failure_code || typeof payload.failure_stage !== "string" || !payload.failure_stage) return "record action requires retrieval_status, failure_code, and failure_stage";
1472
+ return recordPromptPackTerminal(store.db, metadata, payload.retrieval_status, payload.failure_code, payload.failure_stage);
1473
+ }
1474
+ function packTransportRoutes(getStore) {
1475
+ const app = new Hono();
1476
+ app.get("/api/prompt-pack-profile", (c) => {
1477
+ try {
1478
+ const store = getStore();
1479
+ if (!store.hasCurrentIdentity()) return c.json(viewerIdentityMismatch(), 409);
1480
+ return c.json({
1481
+ service: "codemem-viewer",
1482
+ protocol_version: PROMPT_TRANSPORT_PROTOCOL_RANGE.protocolVersion,
1483
+ min_supported_protocol_version: PROMPT_TRANSPORT_PROTOCOL_RANGE.minSupportedProtocolVersion,
1484
+ db_path: resolve(store.dbPath),
1485
+ identity_target: currentIdentityTarget()
1486
+ });
1487
+ } catch {
1488
+ return c.json({ error: {
1489
+ code: "profile_failed",
1490
+ message: "viewer profile could not be read"
1491
+ } }, 500);
1492
+ }
1493
+ });
1494
+ app.post("/api/pack", async (c) => {
1495
+ const parsed = await c.req.json().catch(() => INVALID_JSON);
1496
+ if (parsed === INVALID_JSON) return c.json(invalidRequest("invalid json body"), 400);
1497
+ const request = validatePackRequest(parsed);
1498
+ if (typeof request === "string") {
1499
+ if (request.startsWith("request body contains unsupported field:")) return c.json(viewerContractUnsupported(), 409);
1500
+ return c.json(invalidRequest(request), 400);
1501
+ }
1502
+ try {
1503
+ const store = getStore();
1504
+ const target = validateViewerTarget(store, parsed, { requireCurrentIdentity: true });
1505
+ if (!target.ok) return c.json(target.body, target.status);
1506
+ if (!request.attempt) {
1507
+ const pack = await store.buildMemoryPackAsync(request.context, request.limit, request.tokenBudget, request.filters, request.renderOptions);
1508
+ return c.json(pack);
1509
+ }
1510
+ const artifacts = await store.buildMemoryPackWithTraceAsync(request.context, request.limit, request.tokenBudget, request.filters, request.renderOptions);
1511
+ let ledgerArtifactFingerprint;
1512
+ try {
1513
+ ledgerArtifactFingerprint = promptPackArtifactFingerprint(store.db, request.context, request.filters, artifacts);
1514
+ } catch {}
1515
+ let ledgerOutcome;
1516
+ try {
1517
+ ledgerOutcome = recordPromptPackArtifacts(store.db, attemptMetadata(request.attempt), request.context, request.filters, artifacts);
1518
+ } catch {}
1519
+ return c.json({
1520
+ ...artifacts.response,
1521
+ ...ledgerArtifactFingerprint ? { ledger_artifact_fingerprint: ledgerArtifactFingerprint } : {},
1522
+ ...ledgerOutcome?.ok === false && ledgerOutcome.reason === "idempotency_conflict" ? { ledger_outcome: ledgerOutcome } : {}
1523
+ });
1524
+ } catch {
1525
+ return c.json({ error: {
1526
+ code: "pack_failed",
1527
+ message: "memory pack could not be built"
1528
+ } }, 500);
1529
+ }
1530
+ });
1531
+ app.post("/api/prompt-pack-ledger", async (c) => {
1532
+ const parsed = await c.req.json().catch(() => INVALID_JSON);
1533
+ if (parsed === INVALID_JSON) return c.json(invalidRequest("invalid json body"), 400);
1534
+ if (!isRecord(parsed)) return c.json(invalidRequest("request body must be an object"), 400);
1535
+ const validationError = validateMetadataFields(parsed, LEDGER_KEYS);
1536
+ if (validationError) {
1537
+ if (validationError.startsWith("ledger metadata contains unsupported field:")) return c.json(viewerContractUnsupported(), 409);
1538
+ return c.json(invalidRequest(validationError), 400);
1539
+ }
1540
+ try {
1541
+ const store = getStore();
1542
+ const target = validateViewerTarget(store, parsed, { requireCurrentIdentity: true });
1543
+ if (!target.ok) return c.json(target.body, target.status);
1544
+ const outcome = dispatchLedger(store, parsed);
1545
+ if (typeof outcome === "string") return c.json(invalidRequest(outcome), 400);
1546
+ if (outcome.ok) return c.json(outcome);
1547
+ return c.json(outcome, ledgerFailureStatus(outcome.reason));
1548
+ } catch {
1549
+ return c.json({ error: {
1550
+ code: "ledger_failed",
1551
+ message: "prompt-pack ledger operation failed"
1552
+ } }, 500);
1553
+ }
1554
+ });
1555
+ return app;
1556
+ }
1557
+ //#endregion
1054
1558
  //#region src/routes/raw-events.ts
1055
1559
  /**
1056
1560
  * Raw events routes — GET & POST /api/raw-events, GET /api/raw-events/status,
1057
1561
  * POST /api/claude-hooks, POST /api/codex-hooks.
1058
1562
  */
1059
- var MAX_RAW_EVENTS_BODY_BYTES = Number.parseInt(process.env.CODEMEM_RAW_EVENTS_MAX_BODY_BYTES ?? "", 10) || 1048576;
1060
- /** Keys to check (in priority order) when resolving a session stream id. */
1061
- var SESSION_ID_KEYS = [
1062
- "session_stream_id",
1063
- "session_id",
1064
- "stream_id",
1065
- "opencode_session_id"
1066
- ];
1067
- /**
1068
- * Resolve a session stream id from a payload object.
1069
- * Checks multiple field aliases. Throws on conflicting values.
1070
- */
1071
- function resolveSessionStreamId(payload) {
1072
- const values = /* @__PURE__ */ new Map();
1073
- for (const key of SESSION_ID_KEYS) {
1074
- const value = payload[key];
1075
- if (value == null) continue;
1076
- if (typeof value !== "string") throw new Error(`${key} must be string`);
1077
- const text = value.trim();
1078
- if (text) values.set(key, text);
1079
- }
1080
- if (values.size === 0) return null;
1081
- if (new Set(values.values()).size > 1) throw new Error("conflicting session id fields");
1082
- for (const key of SESSION_ID_KEYS) {
1083
- const v = values.get(key);
1084
- if (v) return v;
1085
- }
1086
- return null;
1563
+ var DEFAULT_MAX_RAW_EVENTS_BODY_BYTES = 1048576;
1564
+ function configuredMaxRawEventsBodyBytes() {
1565
+ const parsed = Number(process.env.CODEMEM_RAW_EVENTS_MAX_BODY_BYTES?.trim() ?? "");
1566
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_RAW_EVENTS_BODY_BYTES;
1567
+ }
1568
+ var MAX_RAW_EVENTS_BODY_BYTES = configuredMaxRawEventsBodyBytes();
1569
+ function emptyTranscriptOutcomeCounts() {
1570
+ return {
1571
+ ok: 0,
1572
+ not_provided: 0,
1573
+ path_rejected: 0,
1574
+ unreadable: 0,
1575
+ no_complete_record: 0,
1576
+ no_assistant_record: 0
1577
+ };
1578
+ }
1579
+ function createTranscriptDiagnostics() {
1580
+ const counts = {
1581
+ claude: emptyTranscriptOutcomeCounts(),
1582
+ codex: emptyTranscriptOutcomeCounts()
1583
+ };
1584
+ return {
1585
+ record(source, outcome) {
1586
+ counts[source][outcome] += 1;
1587
+ },
1588
+ snapshot() {
1589
+ return {
1590
+ scope: "legacy_compatibility_routes",
1591
+ counts: {
1592
+ claude: { ...counts.claude },
1593
+ codex: { ...counts.codex }
1594
+ }
1595
+ };
1596
+ }
1597
+ };
1598
+ }
1599
+ function transcriptSkipResponse(outcome) {
1600
+ if (outcome !== null && outcome !== "ok") return {
1601
+ inserted: 0,
1602
+ skipped: 1,
1603
+ skip_reason: "transcript_unavailable",
1604
+ skip_detail: outcome
1605
+ };
1606
+ return {
1607
+ inserted: 0,
1608
+ skipped: 1,
1609
+ skip_reason: "unsupported_hook"
1610
+ };
1611
+ }
1612
+ function claudeTranscriptRoot() {
1613
+ return join(process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude"), "projects");
1614
+ }
1615
+ function codexTranscriptRoot() {
1616
+ return join(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"), "sessions");
1087
1617
  }
1088
1618
  /**
1089
1619
  * Parse and validate a JSON object body, enforcing size limits.
@@ -1096,16 +1626,43 @@ async function parseJsonObjectBody(c, maxBytes) {
1096
1626
  error: "payload too large",
1097
1627
  max_bytes: maxBytes
1098
1628
  }, 413);
1099
- let raw;
1629
+ let raw = "";
1100
1630
  try {
1101
- raw = await c.req.text();
1631
+ const body = c.req.raw.body;
1632
+ if (body !== null) {
1633
+ const reader = body.getReader();
1634
+ const chunks = [];
1635
+ let totalBytes = 0;
1636
+ try {
1637
+ while (true) {
1638
+ const { done, value } = await reader.read();
1639
+ if (done) break;
1640
+ totalBytes += value.byteLength;
1641
+ if (totalBytes > maxBytes) {
1642
+ try {
1643
+ await reader.cancel();
1644
+ } catch {}
1645
+ return c.json({
1646
+ error: "payload too large",
1647
+ max_bytes: maxBytes
1648
+ }, 413);
1649
+ }
1650
+ chunks.push(value);
1651
+ }
1652
+ } finally {
1653
+ reader.releaseLock();
1654
+ }
1655
+ const bytes = new Uint8Array(totalBytes);
1656
+ let offset = 0;
1657
+ for (const chunk of chunks) {
1658
+ bytes.set(chunk, offset);
1659
+ offset += chunk.byteLength;
1660
+ }
1661
+ raw = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
1662
+ }
1102
1663
  } catch {
1103
1664
  return c.json({ error: "invalid json" }, 400);
1104
1665
  }
1105
- if (Buffer.byteLength(raw, "utf-8") > maxBytes) return c.json({
1106
- error: "payload too large",
1107
- max_bytes: maxBytes
1108
- }, 413);
1109
1666
  let parsed;
1110
1667
  try {
1111
1668
  parsed = raw ? JSON.parse(raw) : {};
@@ -1116,13 +1673,78 @@ async function parseJsonObjectBody(c, maxBytes) {
1116
1673
  return parsed;
1117
1674
  }
1118
1675
  /** Nudge the sweeper safely — never crashes the caller. */
1119
- function nudgeSweeper(sweeper, sessionIds, source = "opencode") {
1120
- try {
1121
- for (const sessionId of sessionIds) sweeper?.nudge(sessionId, source);
1676
+ function nudgeSweeper(sweeper, sessions) {
1677
+ for (const session of sessions) try {
1678
+ sweeper?.nudge(session.streamId, session.source);
1122
1679
  } catch {}
1123
1680
  }
1681
+ async function flushBoundarySessions(sweeper, sessions) {
1682
+ for (const session of sessions) try {
1683
+ await sweeper?.flushBoundary(session.streamId, session.source);
1684
+ } catch {}
1685
+ }
1686
+ function isClaudeBoundaryEnvelope(envelope) {
1687
+ const record = envelope;
1688
+ if (String(record.source ?? "").trim().toLowerCase() !== "claude") return false;
1689
+ const payload = record.payload;
1690
+ if (payload == null || typeof payload !== "object" || Array.isArray(payload)) return false;
1691
+ const adapter = payload._adapter;
1692
+ if (adapter == null || typeof adapter !== "object" || Array.isArray(adapter)) return false;
1693
+ const meta = adapter.meta;
1694
+ if (meta == null || typeof meta !== "object" || Array.isArray(meta)) return false;
1695
+ const hookEventName = meta.hook_event_name;
1696
+ return hookEventName === "SessionEnd" || hookEventName === "Stop";
1697
+ }
1698
+ var SAFE_INGEST_VALIDATION_ERRORS = new Set([
1699
+ "source must be string",
1700
+ "source is required",
1701
+ "source must use 1-64 letters, digits, dots, underscores, or hyphens",
1702
+ "session_stream_id must be string",
1703
+ "session_id must be string",
1704
+ "stream_id must be string",
1705
+ "opencode_session_id must be string",
1706
+ "conflicting session id fields",
1707
+ "invalid session id",
1708
+ "event source conflicts with request source",
1709
+ "session id required",
1710
+ "event_type must be string",
1711
+ "event_type required",
1712
+ "event_type has invalid syntax",
1713
+ "event_id must be string",
1714
+ "event_id required",
1715
+ "event_id has invalid syntax",
1716
+ "event_seq must be int",
1717
+ "ts_wall_ms must be number",
1718
+ "ts_mono_ms must be number",
1719
+ "payload must be an object",
1720
+ "cwd must be string",
1721
+ "project must be string",
1722
+ "started_at must be string",
1723
+ "events must be a list",
1724
+ "event must be an object"
1725
+ ]);
1726
+ function boundedIngestValidationMessage(error) {
1727
+ return SAFE_INGEST_VALIDATION_ERRORS.has(error.message) ? error.message : "invalid raw event request";
1728
+ }
1729
+ function boundedIngestErrorResponse(c, error) {
1730
+ if (error instanceof RawEventIngestValidationError) return c.json({ error: boundedIngestValidationMessage(error) }, 400);
1731
+ const response = { error: "internal server error" };
1732
+ if (process.env.CODEMEM_VIEWER_DEBUG === "1") response.detail = error instanceof Error ? error.message : String(error);
1733
+ return c.json(response, 500);
1734
+ }
1735
+ function untargetedPayload(payload) {
1736
+ const { db_path: _dbPath, identity_target: _identityTarget, ...body } = payload;
1737
+ return body;
1738
+ }
1739
+ async function ingestNormalizedEnvelope(store, sweeper, envelope, flushBoundary = false) {
1740
+ const result = ingestRawEvents(store, envelope);
1741
+ nudgeSweeper(sweeper, result.sessions);
1742
+ if (flushBoundary && isClaudeBoundaryEnvelope(envelope)) await flushBoundarySessions(sweeper, result.sessions);
1743
+ return result;
1744
+ }
1124
1745
  function rawEventsRoutes(getStore, sweeper) {
1125
1746
  const app = new Hono();
1747
+ const transcriptDiagnostics = createTranscriptDiagnostics();
1126
1748
  app.get("/api/raw-events", (c) => {
1127
1749
  const totals = getStore().rawEventBacklogTotals();
1128
1750
  return c.json(totals);
@@ -1157,233 +1779,83 @@ function rawEventsRoutes(getStore, sweeper) {
1157
1779
  available: true,
1158
1780
  mode: "stream_queue",
1159
1781
  max_body_bytes: MAX_RAW_EVENTS_BODY_BYTES
1160
- }
1782
+ },
1783
+ transcript_diagnostics: transcriptDiagnostics.snapshot()
1161
1784
  });
1162
1785
  });
1163
1786
  app.post("/api/raw-events", async (c) => {
1164
1787
  const result = await parseJsonObjectBody(c, MAX_RAW_EVENTS_BODY_BYTES);
1165
1788
  if (result instanceof Response) return result;
1166
- const payload = result;
1167
- const store = getStore();
1168
1789
  try {
1169
- const cwd = payload.cwd;
1170
- if (cwd != null && typeof cwd !== "string") return c.json({ error: "cwd must be string" }, 400);
1171
- const project = payload.project;
1172
- if (project != null && typeof project !== "string") return c.json({ error: "project must be string" }, 400);
1173
- const startedAt = payload.started_at;
1174
- if (startedAt != null && typeof startedAt !== "string") return c.json({ error: "started_at must be string" }, 400);
1175
- let items = payload.events;
1176
- if (items == null) items = [payload];
1177
- if (!Array.isArray(items)) return c.json({ error: "events must be a list" }, 400);
1178
- let defaultSessionId;
1179
- try {
1180
- defaultSessionId = resolveSessionStreamId(payload) ?? "";
1181
- } catch (err) {
1182
- return c.json({ error: err.message }, 400);
1183
- }
1184
- if (defaultSessionId.startsWith("msg_")) return c.json({ error: "invalid session id" }, 400);
1185
- let inserted = 0;
1186
- const lastSeenBySession = /* @__PURE__ */ new Map();
1187
- const metaBySession = /* @__PURE__ */ new Map();
1188
- const sessionIds = /* @__PURE__ */ new Set();
1189
- const batchBySession = /* @__PURE__ */ new Map();
1190
- for (const item of items) {
1191
- if (item == null || typeof item !== "object" || Array.isArray(item)) return c.json({ error: "event must be an object" }, 400);
1192
- const itemObj = item;
1193
- let itemSessionId;
1194
- try {
1195
- itemSessionId = resolveSessionStreamId(itemObj);
1196
- } catch (err) {
1197
- return c.json({ error: err.message }, 400);
1198
- }
1199
- const opencodeSessionId = String(itemSessionId ?? defaultSessionId ?? "");
1200
- if (!opencodeSessionId) return c.json({ error: "session id required" }, 400);
1201
- if (opencodeSessionId.startsWith("msg_")) return c.json({ error: "invalid session id" }, 400);
1202
- let eventId = String(itemObj.event_id ?? "");
1203
- const eventType = String(itemObj.event_type ?? "");
1204
- if (!eventType) return c.json({ error: "event_type required" }, 400);
1205
- const eventSeqValue = itemObj.event_seq;
1206
- if (eventSeqValue != null) {
1207
- const parsed = Number(eventSeqValue);
1208
- if (!Number.isFinite(parsed) || parsed !== Math.floor(parsed)) return c.json({ error: "event_seq must be int" }, 400);
1209
- }
1210
- let tsWallMs = itemObj.ts_wall_ms;
1211
- if (tsWallMs != null) {
1212
- const parsed = Number(tsWallMs);
1213
- if (!Number.isFinite(parsed)) return c.json({ error: "ts_wall_ms must be int" }, 400);
1214
- tsWallMs = Math.floor(parsed);
1215
- const prev = lastSeenBySession.get(opencodeSessionId) ?? tsWallMs;
1216
- lastSeenBySession.set(opencodeSessionId, Math.max(prev, tsWallMs));
1217
- }
1218
- let tsMonoMs = itemObj.ts_mono_ms;
1219
- if (tsMonoMs != null) {
1220
- const parsed = Number(tsMonoMs);
1221
- if (!Number.isFinite(parsed)) return c.json({ error: "ts_mono_ms must be number" }, 400);
1222
- tsMonoMs = parsed;
1223
- }
1224
- let eventPayload = itemObj.payload;
1225
- if (eventPayload == null) eventPayload = {};
1226
- if (typeof eventPayload !== "object" || Array.isArray(eventPayload)) return c.json({ error: "payload must be an object" }, 400);
1227
- const itemCwd = itemObj.cwd;
1228
- if (itemCwd != null && typeof itemCwd !== "string") return c.json({ error: "cwd must be string" }, 400);
1229
- const itemProject = itemObj.project;
1230
- if (itemProject != null && typeof itemProject !== "string") return c.json({ error: "project must be string" }, 400);
1231
- const itemStartedAt = itemObj.started_at;
1232
- if (itemStartedAt != null && typeof itemStartedAt !== "string") return c.json({ error: "started_at must be string" }, 400);
1233
- eventPayload = stripPrivateObj(eventPayload);
1234
- if (!eventId) {
1235
- const sortedStringify = (obj) => JSON.stringify(obj, (_key, value) => {
1236
- if (value != null && typeof value === "object" && !Array.isArray(value)) {
1237
- const sorted = {};
1238
- for (const k of Object.keys(value).sort()) sorted[k] = value[k];
1239
- return sorted;
1240
- }
1241
- return value;
1242
- });
1243
- if (eventSeqValue != null) {
1244
- const rawId = sortedStringify({
1245
- s: eventSeqValue,
1246
- t: eventType,
1247
- p: eventPayload
1248
- });
1249
- eventId = `legacy-seq-${eventSeqValue}-${createHash("sha256").update(rawId, "utf-8").digest("hex").slice(0, 16)}`;
1250
- } else {
1251
- const rawId = sortedStringify({
1252
- m: tsMonoMs ?? null,
1253
- p: eventPayload,
1254
- t: eventType,
1255
- w: tsWallMs ?? null
1256
- });
1257
- eventId = `legacy-${createHash("sha256").update(rawId, "utf-8").digest("hex").slice(0, 16)}`;
1258
- }
1259
- }
1260
- const eventEntry = {
1261
- event_id: eventId,
1262
- event_type: eventType,
1263
- payload: eventPayload,
1264
- ts_wall_ms: tsWallMs ?? null,
1265
- ts_mono_ms: tsMonoMs ?? null
1266
- };
1267
- sessionIds.add(opencodeSessionId);
1268
- const list = batchBySession.get(opencodeSessionId) ?? [];
1269
- list.push({ ...eventEntry });
1270
- batchBySession.set(opencodeSessionId, list);
1271
- if (itemCwd || itemProject || itemStartedAt) {
1272
- const perSession = metaBySession.get(opencodeSessionId) ?? {};
1273
- if (itemCwd) perSession.cwd = itemCwd;
1274
- if (itemProject) perSession.project = itemProject;
1275
- if (itemStartedAt) perSession.started_at = itemStartedAt;
1276
- metaBySession.set(opencodeSessionId, perSession);
1277
- }
1278
- }
1279
- if (sessionIds.size === 1) {
1280
- const singleSessionId = sessionIds.values().next().value;
1281
- const batch = batchBySession.get(singleSessionId) ?? [];
1282
- inserted = store.recordRawEventsBatch(singleSessionId, batch).inserted;
1283
- } else for (const [sid, sidEvents] of batchBySession) {
1284
- const result = store.recordRawEventsBatch(sid, sidEvents);
1285
- inserted += result.inserted;
1286
- }
1287
- for (const metaSessionId of sessionIds) {
1288
- const sessionMeta = metaBySession.get(metaSessionId) ?? {};
1289
- const applyRequestMeta = sessionIds.size === 1 || metaSessionId === defaultSessionId;
1290
- store.updateRawEventSessionMeta({
1291
- opencodeSessionId: metaSessionId,
1292
- cwd: sessionMeta.cwd ?? (applyRequestMeta ? cwd : void 0) ?? null,
1293
- project: sessionMeta.project ?? (applyRequestMeta ? project : void 0) ?? null,
1294
- startedAt: sessionMeta.started_at ?? (applyRequestMeta ? startedAt : void 0) ?? null,
1295
- lastSeenTsWallMs: lastSeenBySession.get(metaSessionId) ?? null
1296
- });
1297
- }
1298
- nudgeSweeper(sweeper, sessionIds);
1790
+ const store = getStore();
1791
+ const target = validateViewerTarget(store, result, { requirePairedTargets: true });
1792
+ if (!target.ok) return c.json(target.body, target.status);
1793
+ const ingestResult = await ingestNormalizedEnvelope(store, sweeper, untargetedPayload(result), c.req.header("x-codemem-boundary-flush") === "1");
1299
1794
  return c.json({
1300
- inserted,
1301
- received: items.length
1795
+ inserted: ingestResult.inserted,
1796
+ skipped: ingestResult.skipped,
1797
+ received: ingestResult.received
1302
1798
  });
1303
1799
  } catch (err) {
1304
- const response = { error: "internal server error" };
1305
- if (process.env.CODEMEM_VIEWER_DEBUG === "1") response.detail = err.message;
1306
- return c.json(response, 500);
1800
+ return boundedIngestErrorResponse(c, err);
1307
1801
  }
1308
1802
  });
1309
1803
  app.post("/api/claude-hooks", async (c) => {
1310
1804
  const result = await parseJsonObjectBody(c, MAX_RAW_EVENTS_BODY_BYTES);
1311
1805
  if (result instanceof Response) return result;
1312
- const envelope = buildRawEventEnvelopeFromHook(result);
1313
- if (envelope === null) return c.json({
1314
- inserted: 0,
1315
- skipped: 1
1316
- });
1317
- const store = getStore();
1806
+ const payload = result;
1318
1807
  try {
1319
- const opencodeSessionId = envelope.opencode_session_id;
1320
- const source = envelope.source;
1321
- const strippedPayload = stripPrivateObj(envelope.payload);
1322
- const inserted = store.recordRawEvent({
1323
- opencodeSessionId,
1324
- source,
1325
- eventId: envelope.event_id,
1326
- eventType: "claude.hook",
1327
- payload: strippedPayload,
1328
- tsWallMs: envelope.ts_wall_ms
1329
- });
1330
- store.updateRawEventSessionMeta({
1331
- opencodeSessionId,
1332
- source,
1333
- cwd: envelope.cwd,
1334
- project: envelope.project,
1335
- startedAt: envelope.started_at,
1336
- lastSeenTsWallMs: envelope.ts_wall_ms
1808
+ const store = getStore();
1809
+ const target = validateViewerTarget(store, payload, { requirePairedTargets: true });
1810
+ if (!target.ok) return c.json(target.body, target.status);
1811
+ let transcriptOutcome = null;
1812
+ const envelope = buildRawEventEnvelopeFromHook(untargetedPayload(payload), {
1813
+ transcriptPolicy: {
1814
+ trust: "restricted",
1815
+ approvedRoots: [claudeTranscriptRoot()]
1816
+ },
1817
+ onTranscriptOutcome: (outcome) => {
1818
+ transcriptOutcome = outcome;
1819
+ transcriptDiagnostics.record("claude", outcome);
1820
+ }
1337
1821
  });
1338
- nudgeSweeper(sweeper, [opencodeSessionId], source);
1822
+ if (envelope === null) return c.json(transcriptSkipResponse(transcriptOutcome));
1823
+ const ingestResult = await ingestNormalizedEnvelope(store, sweeper, envelope, c.req.header("x-codemem-boundary-flush") === "1");
1339
1824
  return c.json({
1340
- inserted: inserted ? 1 : 0,
1341
- skipped: 0
1825
+ inserted: ingestResult.inserted,
1826
+ skipped: ingestResult.skipped
1342
1827
  });
1343
1828
  } catch (err) {
1344
- const response = { error: "internal server error" };
1345
- if (process.env.CODEMEM_VIEWER_DEBUG === "1") response.detail = err.message;
1346
- return c.json(response, 500);
1829
+ return boundedIngestErrorResponse(c, err);
1347
1830
  }
1348
1831
  });
1349
1832
  app.post("/api/codex-hooks", async (c) => {
1350
1833
  const result = await parseJsonObjectBody(c, MAX_RAW_EVENTS_BODY_BYTES);
1351
1834
  if (result instanceof Response) return result;
1352
- const envelope = buildRawEventEnvelopeFromCodexHook(result);
1353
- if (envelope === null) return c.json({
1354
- inserted: 0,
1355
- skipped: 1
1356
- });
1357
- const store = getStore();
1835
+ const payload = result;
1358
1836
  try {
1359
- const opencodeSessionId = envelope.opencode_session_id;
1360
- const source = envelope.source;
1361
- const strippedPayload = stripPrivateObj(envelope.payload);
1362
- const inserted = store.recordRawEvent({
1363
- opencodeSessionId,
1364
- source,
1365
- eventId: envelope.event_id,
1366
- eventType: envelope.event_type,
1367
- payload: strippedPayload,
1368
- tsWallMs: envelope.ts_wall_ms
1369
- });
1370
- store.updateRawEventSessionMeta({
1371
- opencodeSessionId,
1372
- source,
1373
- cwd: envelope.cwd,
1374
- project: envelope.project,
1375
- startedAt: envelope.started_at,
1376
- lastSeenTsWallMs: envelope.ts_wall_ms
1837
+ const store = getStore();
1838
+ const target = validateViewerTarget(store, payload, { requirePairedTargets: true });
1839
+ if (!target.ok) return c.json(target.body, target.status);
1840
+ let transcriptOutcome = null;
1841
+ const envelope = buildRawEventEnvelopeFromCodexHook(untargetedPayload(payload), {
1842
+ transcriptPolicy: {
1843
+ trust: "restricted",
1844
+ approvedRoots: [codexTranscriptRoot()]
1845
+ },
1846
+ onTranscriptOutcome: (outcome) => {
1847
+ transcriptOutcome = outcome;
1848
+ transcriptDiagnostics.record("codex", outcome);
1849
+ }
1377
1850
  });
1378
- nudgeSweeper(sweeper, [opencodeSessionId], source);
1851
+ if (envelope === null) return c.json(transcriptSkipResponse(transcriptOutcome));
1852
+ const ingestResult = await ingestNormalizedEnvelope(store, sweeper, envelope);
1379
1853
  return c.json({
1380
- inserted: inserted ? 1 : 0,
1381
- skipped: 0
1854
+ inserted: ingestResult.inserted,
1855
+ skipped: ingestResult.skipped
1382
1856
  });
1383
1857
  } catch (err) {
1384
- const response = { error: "internal server error" };
1385
- if (process.env.CODEMEM_VIEWER_DEBUG === "1") response.detail = err.message;
1386
- return c.json(response, 500);
1858
+ return boundedIngestErrorResponse(c, err);
1387
1859
  }
1388
1860
  });
1389
1861
  return app;
@@ -2043,7 +2515,13 @@ function projectInviteAcceptanceFailure(error) {
2043
2515
  inviter_identity_invalid: "The owner's device identity could not be verified. Ask the owner to review the share and create a new invitation.",
2044
2516
  project_invite_self_acceptance_forbidden: "The owner cannot accept this recipient invitation on the owner's device.",
2045
2517
  project_invite_bootstrap_incomplete: "The owner's device is not ready to establish trust yet. Retry once, then ask the owner to review the share.",
2046
- project_invite_trust_state_invalid: "The owner's trust setup could not be verified. Ask the owner to review the share."
2518
+ project_invite_trust_state_invalid: "The owner's trust setup could not be verified. Ask the owner to review the share.",
2519
+ recipient_display_name_invalid: "Enter a human-readable Identity name instead of an internal identifier.",
2520
+ recipient_display_name_required: "Enter an Identity display name.",
2521
+ recipient_display_name_too_long: "Use an Identity display name with 120 characters or fewer.",
2522
+ device_display_name_invalid: "Enter a human-readable device name instead of an internal identifier.",
2523
+ device_display_name_required: "Enter a device display name.",
2524
+ device_display_name_too_long: "Use a device display name with 120 characters or fewer."
2047
2525
  };
2048
2526
  return {
2049
2527
  error: Object.hasOwn(detailByCode, code) ? code : "project_invite_acceptance_failed",
@@ -2051,11 +2529,19 @@ function projectInviteAcceptanceFailure(error) {
2051
2529
  };
2052
2530
  }
2053
2531
  var PROJECT_INVITE_TTL_HOURS = 168;
2532
+ var PROJECT_INVITE_OWNER_FALLBACK = "Project owner";
2054
2533
  var PROJECT_INVITE_BODY_KEYS = new Set([
2055
2534
  "teammate_name",
2056
2535
  "project_ids",
2057
2536
  "reviewed_project_set_digest"
2058
2537
  ]);
2538
+ function projectInviteInviterDisplayName(value) {
2539
+ try {
2540
+ return normalizeHumanPresentationName(value, "inviter_display_name");
2541
+ } catch {
2542
+ return PROJECT_INVITE_OWNER_FALLBACK;
2543
+ }
2544
+ }
2059
2545
  function projectInviteStringList(body, key) {
2060
2546
  const value = body[key];
2061
2547
  if (!Array.isArray(value)) throw new Error(`${key}_must_be_string_array`);
@@ -2127,7 +2613,7 @@ function recipientInviteOnboardingPreview(store, body, invitationId) {
2127
2613
  invitationId,
2128
2614
  identityId: targetIdentityId,
2129
2615
  ...binding
2130
- });
2616
+ }, { addDeviceTeamEligibility: "prospective_device" });
2131
2617
  }
2132
2618
  function recipientInviteReviewedIntent(store, kind, targetId, preview) {
2133
2619
  if (kind === "team_member") {
@@ -2182,10 +2668,16 @@ function recipientInviteOnboardingPreviewFromReviewedIntent(store, body, invitat
2182
2668
  });
2183
2669
  }
2184
2670
  var SAFE_RECIPIENT_INVITE_ERRORS = new Set([
2671
+ "device_display_name_invalid",
2672
+ "device_display_name_required",
2673
+ "device_display_name_too_long",
2185
2674
  "invite_already_bound",
2186
2675
  "invite_expired",
2187
2676
  "invite_identity_conflict",
2188
2677
  "invite_invalid",
2678
+ "recipient_display_name_invalid",
2679
+ "recipient_display_name_required",
2680
+ "recipient_display_name_too_long",
2189
2681
  "recipient_invite_intent_mismatch",
2190
2682
  "recipient_invite_review_unavailable",
2191
2683
  "reviewed_onboarding_stale"
@@ -2211,8 +2703,10 @@ async function peerSupportsSyncRequirements(store, deviceId, requirements) {
2211
2703
  const url = `${buildBaseUrl(address)}/v1/status`;
2212
2704
  const [status, payload] = await requestJson("GET", url, {
2213
2705
  headers: {
2214
- ...buildAuthHeaders({
2706
+ ...buildDirectPeerAuthHeaders({
2215
2707
  deviceId: localDeviceId,
2708
+ recipientId: deviceId,
2709
+ dbPath: store.dbPath,
2216
2710
  method: "GET",
2217
2711
  url,
2218
2712
  bodyBytes: Buffer.alloc(0),
@@ -2549,10 +3043,14 @@ async function executeProjectShareProvisioning(store, operationId) {
2549
3043
  supportsReassignScope: (deviceId) => peerSupportsReassignScope(store, deviceId),
2550
3044
  refreshAuthorization: async (expectedGroupId) => {
2551
3045
  if (expectedGroupId !== groupId) throw new Error("operation_scope_mismatch");
2552
- if ((await refreshConfiguredScopeMembershipCache(store.db, config, { keysDir: syncKeysDir() })).groups.find((item) => item.groupId === groupId)?.status !== "refreshed") throw new Error("authorization_refresh_failed");
3046
+ if ((await refreshConfiguredScopeMembershipCache(store.db, config, {
3047
+ keysDir: syncKeysDir(),
3048
+ dbPath: store.dbPath
3049
+ })).groups.find((item) => item.groupId === groupId)?.status !== "refreshed") throw new Error("authorization_refresh_failed");
2553
3050
  },
2554
3051
  runInitialSync: (recipientDeviceId) => runSyncPass(store.db, recipientDeviceId, {
2555
3052
  keysDir: syncKeysDir(),
3053
+ dbPath: store.dbPath,
2556
3054
  scanner: store.scanner,
2557
3055
  refreshAuthorization: true
2558
3056
  })
@@ -2809,6 +3307,30 @@ async function advancePendingProjectShares(store, options = {}) {
2809
3307
  }
2810
3308
  return result;
2811
3309
  }
3310
+ function coordinatorEnrollmentFailureCode(error) {
3311
+ const message = (error instanceof Error ? error.message : String(error)).trim();
3312
+ const httpStatus = message.match(/\((\d{3})(?::|\))/u)?.[1];
3313
+ if (httpStatus) return `http_${httpStatus}`;
3314
+ if (new Set([
3315
+ "coordinator_consumed_team_invite_invalid",
3316
+ "coordinator_device_list_malformed",
3317
+ "coordinator_group_id_invalid",
3318
+ "coordinator_id_invalid",
3319
+ "coordinator_invite_group_mismatch",
3320
+ "coordinator_invite_list_malformed",
3321
+ "reconciliation_time_invalid"
3322
+ ]).has(message)) return message;
3323
+ if (/timeout|timed out|abort/iu.test(message)) return "request_timeout";
3324
+ if (/fetch failed|network|econn|enotfound/iu.test(message)) return "network_error";
3325
+ return "unexpected_error";
3326
+ }
3327
+ function coordinatorEnrollmentFailure(groupId, stage, error) {
3328
+ return {
3329
+ groupId: /^[A-Za-z0-9._-]{1,64}$/u.test(groupId) ? groupId : `group_${createHash("sha256").update(groupId).digest("hex").slice(0, 12)}`,
3330
+ stage,
3331
+ code: coordinatorEnrollmentFailureCode(error)
3332
+ };
3333
+ }
2812
3334
  async function reconcileConfiguredCoordinatorEnrollment(store, options = {}) {
2813
3335
  const config = options.config ?? readCoordinatorSyncConfig();
2814
3336
  const remoteUrl = config.syncCoordinatorUrl.trim();
@@ -2817,6 +3339,7 @@ async function reconcileConfiguredCoordinatorEnrollment(store, options = {}) {
2817
3339
  skipped: true,
2818
3340
  groupsProcessed: 0,
2819
3341
  failedGroups: 0,
3342
+ failures: [],
2820
3343
  devicesAdded: 0,
2821
3344
  membershipsAdded: 0,
2822
3345
  identitiesAdded: 0,
@@ -2830,14 +3353,15 @@ async function reconcileConfiguredCoordinatorEnrollment(store, options = {}) {
2830
3353
  skipped: false,
2831
3354
  groupsProcessed: 0,
2832
3355
  failedGroups: 0,
3356
+ failures: [],
2833
3357
  devicesAdded: 0,
2834
3358
  membershipsAdded: 0,
2835
3359
  identitiesAdded: 0,
2836
3360
  unchanged: 0,
2837
3361
  issues: 0
2838
3362
  };
2839
- for (const groupId of config.syncCoordinatorGroups) try {
2840
- const [enrollments, consumedTeamInvites] = await Promise.all([listDevices({
3363
+ for (const groupId of config.syncCoordinatorGroups) {
3364
+ const [enrollmentsResult, consumedTeamInvitesResult] = await Promise.allSettled([listDevices({
2841
3365
  groupId,
2842
3366
  remoteUrl,
2843
3367
  adminSecret
@@ -2846,21 +3370,30 @@ async function reconcileConfiguredCoordinatorEnrollment(store, options = {}) {
2846
3370
  remoteUrl,
2847
3371
  adminSecret
2848
3372
  })]);
2849
- const result = reconcileSnapshot({
2850
- coordinatorId: buildBaseUrl(remoteUrl),
2851
- groupId,
2852
- enrollments,
2853
- consumedTeamInvites,
2854
- localDeviceId: store.deviceId
2855
- });
2856
- total.groupsProcessed += 1;
2857
- total.devicesAdded += result.devicesAdded;
2858
- total.membershipsAdded += result.membershipsAdded;
2859
- total.identitiesAdded += result.identitiesAdded;
2860
- total.unchanged += result.unchanged;
2861
- total.issues += result.issues.length;
2862
- } catch {
2863
- total.failedGroups += 1;
3373
+ if (enrollmentsResult.status === "rejected") total.failures.push(coordinatorEnrollmentFailure(groupId, "list_devices", enrollmentsResult.reason));
3374
+ if (consumedTeamInvitesResult.status === "rejected") total.failures.push(coordinatorEnrollmentFailure(groupId, "list_consumed_team_invites", consumedTeamInvitesResult.reason));
3375
+ if (enrollmentsResult.status === "rejected" || consumedTeamInvitesResult.status === "rejected") {
3376
+ total.failedGroups += 1;
3377
+ continue;
3378
+ }
3379
+ try {
3380
+ const result = reconcileSnapshot({
3381
+ coordinatorId: buildBaseUrl(remoteUrl),
3382
+ groupId,
3383
+ enrollments: enrollmentsResult.value,
3384
+ consumedTeamInvites: consumedTeamInvitesResult.value,
3385
+ localDeviceId: store.deviceId
3386
+ });
3387
+ total.groupsProcessed += 1;
3388
+ total.devicesAdded += result.devicesAdded;
3389
+ total.membershipsAdded += result.membershipsAdded;
3390
+ total.identitiesAdded += result.identitiesAdded;
3391
+ total.unchanged += result.unchanged;
3392
+ total.issues += result.issues.length;
3393
+ } catch (error) {
3394
+ total.failedGroups += 1;
3395
+ total.failures.push(coordinatorEnrollmentFailure(groupId, "reconcile_snapshot", error));
3396
+ }
2864
3397
  }
2865
3398
  return total;
2866
3399
  }
@@ -3057,7 +3590,10 @@ function createRecipientPolicyReconcilerEffects(store, options = {}) {
3057
3590
  },
3058
3591
  refresh: async ({ scopeId }) => {
3059
3592
  const boundary = target(scopeId);
3060
- if ((await refreshConfiguredScopeMembershipCache(store.db, config, { keysDir: syncKeysDir() })).groups.find((item) => item.groupId === boundary.groupId)?.status !== "refreshed") throw new Error("recipient_policy_effect_failed");
3593
+ if ((await refreshConfiguredScopeMembershipCache(store.db, config, {
3594
+ keysDir: syncKeysDir(),
3595
+ dbPath: store.dbPath
3596
+ })).groups.find((item) => item.groupId === boundary.groupId)?.status !== "refreshed") throw new Error("recipient_policy_effect_failed");
3061
3597
  await refreshAuthorizedCoordinatorPeerTrust(store, config);
3062
3598
  }
3063
3599
  };
@@ -3246,14 +3782,15 @@ function pathWithQuery(url) {
3246
3782
  const parsed = new URL(url);
3247
3783
  return parsed.search ? `${parsed.pathname}${parsed.search}` : parsed.pathname;
3248
3784
  }
3249
- function unauthorizedPayload(reason, exposeReason = false) {
3250
- if (exposeReason && process.env.CODEMEM_SYNC_AUTH_DIAGNOSTICS === "1") return {
3251
- error: "unauthorized",
3252
- reason
3253
- };
3785
+ function unauthorizedPayload() {
3254
3786
  return { error: "unauthorized" };
3255
3787
  }
3256
3788
  var SYNC_AUTH_STORE_BUSY_REASON = "sync_auth_store_busy";
3789
+ var DIRECT_PEER_SIGNATURE_VERSION_NUMBER = 3;
3790
+ function logDirectPeerAuthDiagnostic(...reasons) {
3791
+ const reason = reasons.find((candidate) => candidate === "recipient_mismatch" || candidate === "signature_downgrade");
3792
+ if (reason) console.warn(`[sync] peer auth failed: reason=${reason}`);
3793
+ }
3257
3794
  function isSqliteBusy(err) {
3258
3795
  if (!err || typeof err !== "object") return false;
3259
3796
  const code = "code" in err ? String(err.code ?? "") : "";
@@ -3290,6 +3827,76 @@ function syncAuthStoreBusyResponse(c) {
3290
3827
  c.header("Retry-After", "1");
3291
3828
  return c.json({ error: SYNC_AUTH_STORE_BUSY_REASON }, 503);
3292
3829
  }
3830
+ function verifyDirectPeerRequestSignature(request, body, options) {
3831
+ const timestamp = request.header("X-Opencode-Timestamp") ?? "";
3832
+ const nonce = request.header("X-Opencode-Nonce") ?? "";
3833
+ const legacySignature = request.header("X-Opencode-Signature") ?? "";
3834
+ const recipientHeader = request.header("X-Codemem-Recipient");
3835
+ const directSignatureHeader = request.header("X-Codemem-Signature");
3836
+ const hasDirectMaterial = recipientHeader !== void 0 || directSignatureHeader !== void 0;
3837
+ const direct = verifyDirectPeerSignature({
3838
+ method: request.method,
3839
+ pathWithQuery: pathWithQuery(request.url),
3840
+ bodyBytes: body,
3841
+ timestamp,
3842
+ nonce,
3843
+ recipientId: recipientHeader,
3844
+ expectedRecipientId: options.expectedRecipientId,
3845
+ signature: directSignatureHeader,
3846
+ publicKey: options.publicKey
3847
+ });
3848
+ if (hasDirectMaterial && direct.status === "absent") return {
3849
+ ok: false,
3850
+ reason: "invalid_direct_signature"
3851
+ };
3852
+ if (direct.status === "invalid") return {
3853
+ ok: false,
3854
+ reason: direct.reason === "recipient_mismatch" ? direct.reason : "invalid_direct_signature"
3855
+ };
3856
+ if (!verifySignature({
3857
+ method: request.method,
3858
+ pathWithQuery: pathWithQuery(request.url),
3859
+ bodyBytes: body,
3860
+ timestamp,
3861
+ nonce,
3862
+ signature: legacySignature,
3863
+ publicKey: options.publicKey,
3864
+ deviceId: options.deviceId
3865
+ })) return {
3866
+ ok: false,
3867
+ reason: "invalid_signature"
3868
+ };
3869
+ if (direct.status === "absent" && (options.highestObservedVersion ?? 0) >= DIRECT_PEER_SIGNATURE_VERSION_NUMBER) return {
3870
+ ok: false,
3871
+ reason: "signature_downgrade"
3872
+ };
3873
+ return {
3874
+ ok: true,
3875
+ version: direct.status === "valid" ? 3 : 2
3876
+ };
3877
+ }
3878
+ function recordDirectPeerSignatureVersion(store, deviceId, version) {
3879
+ try {
3880
+ if (!recordHighestObservedDirectSignatureVersion(store.db, deviceId, version)) {
3881
+ if (store.db.prepare(`SELECT sync_peer_signature_state.highest_observed_direct_signature_version
3882
+ FROM sync_peers
3883
+ LEFT JOIN sync_peer_signature_state USING (peer_device_id)
3884
+ WHERE sync_peers.peer_device_id = ? LIMIT 1`).pluck().get(deviceId) === void 0) return {
3885
+ ok: false,
3886
+ reason: "unknown_peer",
3887
+ deviceId
3888
+ };
3889
+ }
3890
+ return null;
3891
+ } catch (err) {
3892
+ if (isSqliteBusy(err)) return {
3893
+ ok: false,
3894
+ reason: SYNC_AUTH_STORE_BUSY_REASON,
3895
+ deviceId
3896
+ };
3897
+ throw err;
3898
+ }
3899
+ }
3293
3900
  function authorizeSyncRequest(store, request, body) {
3294
3901
  const deviceId = (request.header("X-Opencode-Device") ?? "").trim();
3295
3902
  const signature = request.header("X-Opencode-Signature") ?? "";
@@ -3300,7 +3907,11 @@ function authorizeSyncRequest(store, request, body) {
3300
3907
  reason: "missing_headers",
3301
3908
  deviceId
3302
3909
  };
3303
- const peerRow = store.db.prepare("SELECT pinned_fingerprint, public_key FROM sync_peers WHERE peer_device_id = ? LIMIT 1").get(deviceId);
3910
+ const peerRow = store.db.prepare(`SELECT sync_peers.pinned_fingerprint, sync_peers.public_key,
3911
+ sync_peer_signature_state.highest_observed_direct_signature_version
3912
+ FROM sync_peers
3913
+ LEFT JOIN sync_peer_signature_state USING (peer_device_id)
3914
+ WHERE sync_peers.peer_device_id = ? LIMIT 1`).get(deviceId);
3304
3915
  if (!peerRow) return {
3305
3916
  ok: false,
3306
3917
  reason: "unknown_peer",
@@ -3318,17 +3929,14 @@ function authorizeSyncRequest(store, request, body) {
3318
3929
  reason: "fingerprint_mismatch",
3319
3930
  deviceId
3320
3931
  };
3321
- let valid = false;
3932
+ let verification;
3322
3933
  try {
3323
- valid = verifySignature({
3324
- method: request.method,
3325
- pathWithQuery: pathWithQuery(request.url),
3326
- bodyBytes: body,
3327
- timestamp,
3328
- nonce,
3329
- signature,
3934
+ const [localDeviceId] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
3935
+ verification = verifyDirectPeerRequestSignature(request, body, {
3936
+ deviceId,
3330
3937
  publicKey,
3331
- deviceId
3938
+ expectedRecipientId: localDeviceId,
3939
+ highestObservedVersion: peerRow.highest_observed_direct_signature_version
3332
3940
  });
3333
3941
  } catch {
3334
3942
  return {
@@ -3337,13 +3945,17 @@ function authorizeSyncRequest(store, request, body) {
3337
3945
  deviceId
3338
3946
  };
3339
3947
  }
3340
- if (!valid) return {
3948
+ if (!verification.ok) return {
3341
3949
  ok: false,
3342
- reason: "invalid_signature",
3950
+ reason: verification.reason,
3343
3951
  deviceId
3344
3952
  };
3345
3953
  const nonceResult = recordSyncAuthNonce(store, deviceId, nonce, (/* @__PURE__ */ new Date()).toISOString());
3346
3954
  if (nonceResult) return nonceResult;
3955
+ if (verification.version === DIRECT_PEER_SIGNATURE_VERSION_NUMBER) {
3956
+ const versionResult = recordDirectPeerSignatureVersion(store, deviceId, verification.version);
3957
+ if (versionResult) return versionResult;
3958
+ }
3347
3959
  return {
3348
3960
  ok: true,
3349
3961
  reason: "ok",
@@ -3382,6 +3994,7 @@ async function authorizeBootstrapGrantRequest(store, request, body) {
3382
3994
  const [status, signedPayload] = await requestJson("GET", lookupUrl, {
3383
3995
  headers: buildAuthHeaders({
3384
3996
  deviceId: localDeviceId,
3997
+ dbPath: store.dbPath,
3385
3998
  method: "GET",
3386
3999
  url: lookupUrl,
3387
4000
  bodyBytes: Buffer.alloc(0),
@@ -3461,17 +4074,15 @@ async function authorizeBootstrapGrantRequest(store, request, body) {
3461
4074
  reason: "bootstrap_grant_expired",
3462
4075
  deviceId
3463
4076
  };
3464
- let valid = false;
4077
+ const existingPeer = store.db.prepare(`SELECT highest_observed_direct_signature_version
4078
+ FROM sync_peer_signature_state WHERE peer_device_id = ? LIMIT 1`).get(deviceId);
4079
+ let signatureVerification;
3465
4080
  try {
3466
- valid = verifySignature({
3467
- method: request.method,
3468
- pathWithQuery: pathWithQuery(request.url),
3469
- bodyBytes: body,
3470
- timestamp,
3471
- nonce,
3472
- signature,
4081
+ signatureVerification = verifyDirectPeerRequestSignature(request, body, {
4082
+ deviceId,
3473
4083
  publicKey: workerPublicKey,
3474
- deviceId
4084
+ expectedRecipientId: localDeviceId,
4085
+ highestObservedVersion: existingPeer?.highest_observed_direct_signature_version ?? null
3475
4086
  });
3476
4087
  } catch {
3477
4088
  return {
@@ -3480,9 +4091,9 @@ async function authorizeBootstrapGrantRequest(store, request, body) {
3480
4091
  deviceId
3481
4092
  };
3482
4093
  }
3483
- if (!valid) return {
4094
+ if (!signatureVerification.ok) return {
3484
4095
  ok: false,
3485
- reason: "bootstrap_grant_invalid_signature",
4096
+ reason: signatureVerification.reason,
3486
4097
  deviceId
3487
4098
  };
3488
4099
  if (!isPeerTrustBindingCompatible(store.db, deviceId, workerPublicKey, workerFingerprint)) return {
@@ -3498,6 +4109,10 @@ async function authorizeBootstrapGrantRequest(store, request, body) {
3498
4109
  publicKey: workerPublicKey,
3499
4110
  replaceTrust: true
3500
4111
  });
4112
+ if (signatureVerification.version === DIRECT_PEER_SIGNATURE_VERSION_NUMBER) {
4113
+ const versionResult = recordDirectPeerSignatureVersion(store, deviceId, signatureVerification.version);
4114
+ if (versionResult) return versionResult;
4115
+ }
3501
4116
  return {
3502
4117
  ok: true,
3503
4118
  reason: "ok",
@@ -3916,6 +4531,8 @@ function mapPeerRow(store, row, showDiag, recentOpsByPeer, scopeRejectionsByPeer
3916
4531
  last_seen_at: row.last_seen_at,
3917
4532
  last_sync_at: row.last_sync_at,
3918
4533
  last_error: showDiag ? row.last_error : null,
4534
+ runtime_version: row.runtime_version ?? null,
4535
+ runtime_version_observed_at: row.runtime_version_observed_at ?? null,
3919
4536
  has_error: Boolean(row.last_error),
3920
4537
  claimed_local_actor: Boolean(row.claimed_local_actor),
3921
4538
  claimed_local_actor_scope: claimedLocalActorScopeStatus(store, row),
@@ -4329,6 +4946,7 @@ function readViewerBinding(dbPath) {
4329
4946
  var PEERS_QUERY = `
4330
4947
  SELECT p.peer_device_id, p.name, p.pinned_fingerprint, p.addresses_json,
4331
4948
  p.last_seen_at, p.last_sync_at, p.last_error,
4949
+ p.runtime_version, p.runtime_version_observed_at,
4332
4950
  p.projects_include_json, p.projects_exclude_json, p.claimed_local_actor,
4333
4951
  p.actor_id, p.discovered_via_coordinator_id, p.discovered_via_group_id,
4334
4952
  a.display_name AS actor_display_name
@@ -4402,13 +5020,16 @@ function syncProtocolRoutes(getStore, opts = {}) {
4402
5020
  if (!auth.ok) {
4403
5021
  const grantPresent = Boolean((c.req.header("X-Codemem-Bootstrap-Grant") ?? "").trim());
4404
5022
  if (bootstrapAttempted && (grantPresent || deviceAuthReason != null && deviceAuthReason !== "missing_headers")) console.warn(`[sync] peer auth failed: reason=${auth.reason} device_auth_reason=${deviceAuthReason ?? "none"} grant_present=${grantPresent} path=${c.req.path} device=${loggableHeaderValue(c.req.header("X-Opencode-Device"))}`);
4405
- const wireReason = bootstrapAttempted && grantPresent ? "bootstrap_grant_invalid" : deviceAuthReason ?? auth.reason;
4406
- return (preauthChecked ? null : rateLimitedResponse(c, c.req.path, false)) ?? c.json(unauthorizedPayload(wireReason, true), 401);
5023
+ else logDirectPeerAuthDiagnostic(deviceAuthReason, auth.reason);
5024
+ return (preauthChecked ? null : rateLimitedResponse(c, c.req.path, false)) ?? c.json(unauthorizedPayload(), 401);
4407
5025
  }
4408
5026
  const limited = rateLimitedResponse(c, auth.deviceId, true);
4409
5027
  if (limited) return limited;
4410
5028
  try {
4411
- if (c.req.header(SYNC_AUTHORIZATION_REFRESH_HEADER) === "1" && supportsSyncFeature(c.req.header(SYNC_FEATURES_HEADER), "reassign_scope")) await refreshConfiguredScopeMembershipCache(store.db, void 0, { keysDir: syncKeysDir() });
5029
+ if (c.req.header(SYNC_AUTHORIZATION_REFRESH_HEADER) === "1" && supportsSyncFeature(c.req.header(SYNC_FEATURES_HEADER), "reassign_scope")) await refreshConfiguredScopeMembershipCache(store.db, void 0, {
5030
+ keysDir: syncKeysDir(),
5031
+ dbPath: store.dbPath
5032
+ });
4412
5033
  const [deviceId, fingerprint] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
4413
5034
  const syncReset = getSyncResetState(store.db);
4414
5035
  const authorizedScopes = isScopedSyncCapability(negotiatedSyncCapability(c)) ? listAuthorizedScopesForPeer(store.db, {
@@ -4419,6 +5040,7 @@ function syncProtocolRoutes(getStore, opts = {}) {
4419
5040
  device_id: deviceId,
4420
5041
  protocol_version: SYNC_PROTOCOL_VERSION,
4421
5042
  fingerprint,
5043
+ runtime_version: VERSION$1,
4422
5044
  sync_reset: addSyncScopeToBoundary(syncReset, null),
4423
5045
  sync_capability: LOCAL_SYNC_CAPABILITY,
4424
5046
  sync_features: LOCAL_SYNC_FEATURES
@@ -4434,7 +5056,10 @@ function syncProtocolRoutes(getStore, opts = {}) {
4434
5056
  const store = getStore();
4435
5057
  const auth = authorizeSyncRequest(store, c.req, Buffer.alloc(0));
4436
5058
  if (isSyncAuthStoreBusy(auth)) return syncAuthStoreBusyResponse(c);
4437
- if (!auth.ok) return rateLimitedResponse(c, c.req.path, false) ?? c.json(unauthorizedPayload(auth.reason, true), 401);
5059
+ if (!auth.ok) {
5060
+ logDirectPeerAuthDiagnostic(auth.reason);
5061
+ return rateLimitedResponse(c, c.req.path, false) ?? c.json(unauthorizedPayload(), 401);
5062
+ }
4438
5063
  const limited = rateLimitedResponse(c, auth.deviceId, true);
4439
5064
  if (limited) return limited;
4440
5065
  const peerDeviceId = auth.deviceId;
@@ -4515,8 +5140,8 @@ function syncProtocolRoutes(getStore, opts = {}) {
4515
5140
  if (!auth.ok) {
4516
5141
  const grantPresent = Boolean((c.req.header("X-Codemem-Bootstrap-Grant") ?? "").trim());
4517
5142
  if (bootstrapAttempted && (grantPresent || deviceAuthReason != null && deviceAuthReason !== "missing_headers")) console.warn(`[sync] peer auth failed: reason=${auth.reason} device_auth_reason=${deviceAuthReason ?? "none"} grant_present=${grantPresent} path=${c.req.path} device=${loggableHeaderValue(c.req.header("X-Opencode-Device"))}`);
4518
- const wireReason = bootstrapAttempted && grantPresent ? "bootstrap_grant_invalid" : deviceAuthReason ?? auth.reason;
4519
- return (preauthChecked ? null : rateLimitedResponse(c, c.req.path, false)) ?? c.json(unauthorizedPayload(wireReason, true), 401);
5143
+ else logDirectPeerAuthDiagnostic(deviceAuthReason, auth.reason);
5144
+ return (preauthChecked ? null : rateLimitedResponse(c, c.req.path, false)) ?? c.json(unauthorizedPayload(), 401);
4520
5145
  }
4521
5146
  const limited = rateLimitedResponse(c, auth.deviceId, true);
4522
5147
  if (limited) return limited;
@@ -4573,7 +5198,10 @@ function syncProtocolRoutes(getStore, opts = {}) {
4573
5198
  if (raw == null) return c.json({ error: "payload_too_large" }, 413);
4574
5199
  const auth = authorizeSyncRequest(store, c.req, raw);
4575
5200
  if (isSyncAuthStoreBusy(auth)) return syncAuthStoreBusyResponse(c);
4576
- if (!auth.ok) return rateLimitedResponse(c, c.req.path, false) ?? c.json(unauthorizedPayload(auth.reason, true), 401);
5201
+ if (!auth.ok) {
5202
+ logDirectPeerAuthDiagnostic(auth.reason);
5203
+ return rateLimitedResponse(c, c.req.path, false) ?? c.json(unauthorizedPayload(), 401);
5204
+ }
4577
5205
  const limited = rateLimitedResponse(c, auth.deviceId, true);
4578
5206
  if (limited) return limited;
4579
5207
  const peerDeviceId = auth.deviceId;
@@ -4637,6 +5265,52 @@ function syncProtocolRoutes(getStore, opts = {}) {
4637
5265
  });
4638
5266
  return app;
4639
5267
  }
5268
+ async function loadConfiguredDeviceIdentityCoordinatorEvidence() {
5269
+ const config = readCoordinatorSyncConfig();
5270
+ const hasCoordinatorConfiguration = config.syncCoordinatorUrl.length > 0 || config.syncCoordinatorGroups.length > 0;
5271
+ const remoteUrl = config.syncCoordinatorUrl.trim();
5272
+ const adminSecret = config.syncCoordinatorAdminSecret.trim();
5273
+ const groupIds = [...new Set(config.syncCoordinatorGroups.map((groupId) => groupId.trim()))].filter(Boolean);
5274
+ if (!hasCoordinatorConfiguration) return {
5275
+ availability: "unavailable",
5276
+ safeErrorCode: "coordinator_not_configured",
5277
+ enrollments: []
5278
+ };
5279
+ if (!remoteUrl || !adminSecret || groupIds.length === 0) return {
5280
+ availability: "unavailable",
5281
+ safeErrorCode: "coordinator_unavailable",
5282
+ enrollments: []
5283
+ };
5284
+ if (groupIds.length > 25) return {
5285
+ availability: "unavailable",
5286
+ safeErrorCode: "coordinator_evidence_too_large",
5287
+ enrollments: []
5288
+ };
5289
+ try {
5290
+ const enrollments = (await Promise.all(groupIds.map((groupId) => coordinatorListDevicesAction({
5291
+ groupId,
5292
+ includeDisabled: true,
5293
+ remoteUrl,
5294
+ adminSecret
5295
+ })))).flat();
5296
+ if (enrollments.length > 500) return {
5297
+ availability: "unavailable",
5298
+ safeErrorCode: "coordinator_evidence_too_large",
5299
+ enrollments: []
5300
+ };
5301
+ return {
5302
+ availability: "available",
5303
+ safeErrorCode: null,
5304
+ enrollments
5305
+ };
5306
+ } catch {
5307
+ return {
5308
+ availability: "unavailable",
5309
+ safeErrorCode: "coordinator_unavailable",
5310
+ enrollments: []
5311
+ };
5312
+ }
5313
+ }
4640
5314
  /**
4641
5315
  * Viewer-facing sync management routes (/api/sync/*).
4642
5316
  *
@@ -4644,8 +5318,112 @@ function syncProtocolRoutes(getStore, opts = {}) {
4644
5318
  * provide sync status, peer management, and coordinator UI for the
4645
5319
  * local viewer.
4646
5320
  */
4647
- function syncRoutes(getStore, getSyncRuntimeStatus) {
5321
+ function syncRoutes(getStore, getSyncRuntimeStatus, options = {}) {
4648
5322
  const app = new Hono();
5323
+ const loadCoordinatorEvidence = options.loadDeviceIdentityCoordinatorEvidence ?? loadConfiguredDeviceIdentityCoordinatorEvidence;
5324
+ const currentCoordinatorEvidence = async () => {
5325
+ try {
5326
+ return await loadCoordinatorEvidence();
5327
+ } catch {
5328
+ return {
5329
+ availability: "unavailable",
5330
+ safeErrorCode: "coordinator_unavailable",
5331
+ enrollments: []
5332
+ };
5333
+ }
5334
+ };
5335
+ const localInventoryInput = async (store) => {
5336
+ let explicitDeviceName = "";
5337
+ try {
5338
+ explicitDeviceName = String(readCodememConfigFile().sync_device_name ?? "");
5339
+ } catch {
5340
+ explicitDeviceName = "";
5341
+ }
5342
+ return {
5343
+ localDeviceId: store.deviceId,
5344
+ localDisplayName: friendlyDeviceName({
5345
+ explicitName: explicitDeviceName,
5346
+ osName: hostname(),
5347
+ fallbackSeed: store.deviceId
5348
+ }),
5349
+ coordinator: await currentCoordinatorEvidence()
5350
+ };
5351
+ };
5352
+ app.get("/api/sync/recipient-policy/v1/device-inventory", async (c) => {
5353
+ const store = getStore();
5354
+ ensureStableStoreIdentity(store);
5355
+ return c.json(listDeviceIdentityInventory(store.db, await localInventoryInput(store)));
5356
+ });
5357
+ const parseDeviceBindingRequest = (value, commit) => {
5358
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
5359
+ const body = value;
5360
+ const allowed = new Set(commit ? ["bindings", "reviewedInventoryDigest"] : ["bindings"]);
5361
+ if (Object.keys(body).some((key) => !allowed.has(key)) || !Array.isArray(body.bindings)) return null;
5362
+ const bindings = body.bindings.map((value) => {
5363
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
5364
+ const binding = value;
5365
+ const bindingKeys = new Set([
5366
+ "deviceId",
5367
+ "targetIdentityId",
5368
+ "confirmed",
5369
+ "allowRebind"
5370
+ ]);
5371
+ if (Object.keys(binding).some((key) => !bindingKeys.has(key)) || typeof binding.deviceId !== "string" || typeof binding.targetIdentityId !== "string" || typeof binding.confirmed !== "boolean" || Object.hasOwn(binding, "allowRebind") && typeof binding.allowRebind !== "boolean") return null;
5372
+ return {
5373
+ deviceId: binding.deviceId,
5374
+ targetIdentityId: binding.targetIdentityId,
5375
+ confirmed: binding.confirmed,
5376
+ ...typeof binding.allowRebind === "boolean" ? { allowRebind: binding.allowRebind } : {}
5377
+ };
5378
+ });
5379
+ if (bindings.some((binding) => binding == null)) return null;
5380
+ if (commit && typeof body.reviewedInventoryDigest !== "string") return null;
5381
+ return {
5382
+ bindings,
5383
+ ...commit ? { reviewedInventoryDigest: body.reviewedInventoryDigest } : {}
5384
+ };
5385
+ };
5386
+ app.post("/api/sync/recipient-policy/v1/device-bindings/preview", async (c) => {
5387
+ const body = parseDeviceBindingRequest(await parseViewerJsonBody(c), false);
5388
+ if (!body) return c.json({ error: "binding_request_invalid" }, 400);
5389
+ const store = getStore();
5390
+ try {
5391
+ ensureStableStoreIdentity(store);
5392
+ const result = previewDeviceIdentityBindings(store.db, await localInventoryInput(store), body);
5393
+ if (result.status === "invalid") return c.json(result, 400);
5394
+ if (result.status === "not_found") return c.json(result, 404);
5395
+ if (result.status === "conflict") return c.json(result, 409);
5396
+ return c.json(result);
5397
+ } catch (error) {
5398
+ if (isSqliteBusy(error)) {
5399
+ c.header("Retry-After", "1");
5400
+ return c.json({ error: "binding_preview_busy" }, 503);
5401
+ }
5402
+ return c.json({ error: "binding_preview_failed" }, 500);
5403
+ }
5404
+ });
5405
+ app.post("/api/sync/recipient-policy/v1/device-bindings/commit", async (c) => {
5406
+ const body = parseDeviceBindingRequest(await parseViewerJsonBody(c), true);
5407
+ if (!body || !("reviewedInventoryDigest" in body)) return c.json({ error: "binding_request_invalid" }, 400);
5408
+ const store = getStore();
5409
+ try {
5410
+ ensureStableStoreIdentity(store);
5411
+ const result = commitDeviceIdentityBindings(store.db, {
5412
+ localActorId: store.actorId,
5413
+ localDeviceId: store.deviceId
5414
+ }, await localInventoryInput(store), body);
5415
+ if (result.status === "invalid") return c.json(result, 400);
5416
+ if (result.status === "not_found") return c.json(result, 404);
5417
+ if (result.status === "stale" || result.status === "conflict") return c.json(result, 409);
5418
+ return c.json(result);
5419
+ } catch (error) {
5420
+ if (isSqliteBusy(error)) {
5421
+ c.header("Retry-After", "1");
5422
+ return c.json({ error: "binding_commit_busy" }, 503);
5423
+ }
5424
+ return c.json({ error: "binding_commit_failed" }, 500);
5425
+ }
5426
+ });
4649
5427
  app.get("/api/sync/status", async (c) => {
4650
5428
  const store = getStore();
4651
5429
  {
@@ -4958,9 +5736,15 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
4958
5736
  return c.json({ error: "invalid json" }, 400);
4959
5737
  }
4960
5738
  const peerDeviceId = String(body.peer_device_id ?? "").trim();
4961
- const name = String(body.name ?? "").trim();
5739
+ const rawName = String(body.name ?? "");
4962
5740
  if (!peerDeviceId) return c.json({ error: "peer_device_id required" }, 400);
4963
- if (!name) return c.json({ error: "name required" }, 400);
5741
+ if (!rawName.trim()) return c.json({ error: "name required" }, 400);
5742
+ let name;
5743
+ try {
5744
+ name = normalizeHumanPresentationName(rawName, "name");
5745
+ } catch (error) {
5746
+ return c.json({ error: error instanceof Error ? error.message : "name_invalid" }, 400);
5747
+ }
4964
5748
  if (!d.select({ peer_device_id: schema.syncPeers.peer_device_id }).from(schema.syncPeers).where(eq(schema.syncPeers.peer_device_id, peerDeviceId)).get()) return c.json({ error: "peer not found" }, 404);
4965
5749
  d.update(schema.syncPeers).set({ name }).where(eq(schema.syncPeers.peer_device_id, peerDeviceId)).run();
4966
5750
  return c.json({ ok: true });
@@ -4988,6 +5772,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
4988
5772
  for (const peerId of peerIds) {
4989
5773
  const result = await runSyncPass(store.db, peerId, {
4990
5774
  keysDir: syncKeysDir(),
5775
+ dbPath: store.dbPath,
4991
5776
  scanner: store.scanner
4992
5777
  });
4993
5778
  items.push({
@@ -5367,7 +6152,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
5367
6152
  operationId: plan.operationId,
5368
6153
  reviewedProjectSetDigest: plan.reviewedProjectSetDigest,
5369
6154
  inviterActorId: plan.inviterActorId,
5370
- inviterDisplayName: store.actorDisplayName,
6155
+ inviterDisplayName: projectInviteInviterDisplayName(store.actorDisplayName),
5371
6156
  inviterDeviceId: plan.inviterDeviceIds[0],
5372
6157
  pendingPersonId: plan.personId,
5373
6158
  projectSummaries: plan.projects.map((project) => ({
@@ -5675,8 +6460,14 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
5675
6460
  } catch {
5676
6461
  return c.json({ error: "invalid json" }, 400);
5677
6462
  }
5678
- const displayName = String(body.display_name ?? "").trim();
5679
- if (!displayName) return c.json({ error: "display_name required" }, 400);
6463
+ const rawDisplayName = String(body.display_name ?? "");
6464
+ if (!rawDisplayName.trim()) return c.json({ error: "display_name required" }, 400);
6465
+ let displayName;
6466
+ try {
6467
+ displayName = normalizeHumanPresentationName(rawDisplayName, "display_name");
6468
+ } catch (error) {
6469
+ return c.json({ error: error instanceof Error ? error.message : "display_name_invalid" }, 400);
6470
+ }
5680
6471
  const d = drizzle(store.db, { schema });
5681
6472
  const now = (/* @__PURE__ */ new Date()).toISOString();
5682
6473
  const actor = {
@@ -5701,9 +6492,13 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
5701
6492
  return c.json({ error: "invalid json" }, 400);
5702
6493
  }
5703
6494
  const actorId = String(body.actor_id ?? "").trim();
5704
- const displayName = String(body.display_name ?? "").trim();
5705
6495
  if (!actorId) return c.json({ error: "actor_id required" }, 400);
5706
- if (!displayName) return c.json({ error: "display_name required" }, 400);
6496
+ let displayName;
6497
+ try {
6498
+ displayName = normalizeHumanPresentationName(String(body.display_name ?? ""), "display_name");
6499
+ } catch (error) {
6500
+ return c.json({ error: error instanceof Error ? error.message : "display_name_invalid" }, 400);
6501
+ }
5707
6502
  const d = drizzle(store.db, { schema });
5708
6503
  const actor = d.select().from(schema.actors).where(eq(schema.actors.actor_id, actorId)).get();
5709
6504
  if (!actor) return c.json({ error: "actor not found" }, 404);
@@ -5733,14 +6528,133 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
5733
6528
  if (!secondaryActorId) return c.json({ error: "secondary_actor_id required" }, 400);
5734
6529
  if (primaryActorId === secondaryActorId) return c.json({ error: "actor ids must differ" }, 400);
5735
6530
  const d = drizzle(store.db, { schema });
5736
- const primary = d.select().from(schema.actors).where(eq(schema.actors.actor_id, primaryActorId)).get();
5737
- const secondary = d.select().from(schema.actors).where(eq(schema.actors.actor_id, secondaryActorId)).get();
5738
- if (!primary || !secondary) return c.json({ error: "actor not found" }, 404);
5739
- if (primary.status !== "active") return c.json({ error: "primary actor not active" }, 409);
5740
- if (secondary.status !== "active") return c.json({ error: "secondary actor not active" }, 409);
5741
- if (secondary.is_local && secondary.actor_id === store.actorId) return c.json({ error: "cannot merge this device's own local actor" }, 409);
5742
6531
  const now = (/* @__PURE__ */ new Date()).toISOString();
5743
- const mergedCount = store.db.transaction(() => {
6532
+ const result = store.db.transaction(() => {
6533
+ const primary = d.select().from(schema.actors).where(eq(schema.actors.actor_id, primaryActorId)).get();
6534
+ const secondary = d.select().from(schema.actors).where(eq(schema.actors.actor_id, secondaryActorId)).get();
6535
+ if (!primary || !secondary) return {
6536
+ error: "actor not found",
6537
+ status: 404
6538
+ };
6539
+ if (primary.status !== "active") return {
6540
+ error: "primary actor not active",
6541
+ status: 409
6542
+ };
6543
+ if (secondary.status !== "active") return {
6544
+ error: "secondary actor not active",
6545
+ status: 409
6546
+ };
6547
+ if (secondary.is_local && secondary.actor_id === store.actorId) return {
6548
+ error: "cannot merge this device's own local actor",
6549
+ status: 409
6550
+ };
6551
+ const memberships = d.select().from(schema.policyTeamMemberships).where(eq(schema.policyTeamMemberships.identity_id, secondaryActorId)).all();
6552
+ const primaryMemberships = /* @__PURE__ */ new Map();
6553
+ for (const membership of memberships) {
6554
+ const primaryMembership = d.select().from(schema.policyTeamMemberships).where(and(eq(schema.policyTeamMemberships.team_id, membership.team_id), eq(schema.policyTeamMemberships.identity_id, primaryActorId))).get();
6555
+ if (primaryMembership && (primaryMembership.role !== membership.role || primaryMembership.status !== membership.status)) return {
6556
+ error: "actor team memberships conflict",
6557
+ status: 409
6558
+ };
6559
+ primaryMemberships.set(membership.team_id, primaryMembership);
6560
+ }
6561
+ const directRecipients = d.select().from(schema.projectRecipients).where(and(eq(schema.projectRecipients.recipient_kind, "identity"), eq(schema.projectRecipients.recipient_id, secondaryActorId))).all();
6562
+ const primaryRecipients = /* @__PURE__ */ new Map();
6563
+ for (const recipient of directRecipients) {
6564
+ const primaryRecipient = d.select().from(schema.projectRecipients).where(and(eq(schema.projectRecipients.canonical_project_identity, recipient.canonical_project_identity), eq(schema.projectRecipients.recipient_kind, "identity"), eq(schema.projectRecipients.recipient_id, primaryActorId))).get();
6565
+ if (primaryRecipient && primaryRecipient.status !== recipient.status) return {
6566
+ error: "actor project recipients conflict",
6567
+ status: 409
6568
+ };
6569
+ primaryRecipients.set(recipient.canonical_project_identity, primaryRecipient);
6570
+ }
6571
+ const mergeDigest = createHash("sha256").update(JSON.stringify({
6572
+ kind: "identity-merge-v1",
6573
+ primaryActorId,
6574
+ secondaryActorId,
6575
+ nonce: randomUUID()
6576
+ })).digest("hex");
6577
+ const bindings = d.select({
6578
+ deviceId: schema.identityDevices.device_id,
6579
+ assignmentVersion: schema.identityDevices.assignment_version
6580
+ }).from(schema.identityDevices).where(eq(schema.identityDevices.identity_id, secondaryActorId)).all();
6581
+ if (bindings.length > 0) {
6582
+ const commitDigest = mergeDigest;
6583
+ const outcomes = bindings.map((binding) => ({
6584
+ deviceId: binding.deviceId,
6585
+ previousIdentityId: secondaryActorId,
6586
+ targetIdentityId: primaryActorId,
6587
+ action: "rebind"
6588
+ }));
6589
+ store.db.prepare(`INSERT INTO device_identity_binding_commits(
6590
+ commit_digest, reviewed_inventory_digest, request_json, outcomes_json, write_count,
6591
+ decided_by_identity_id, decided_by_device_id, created_at
6592
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(commitDigest, `identity-merge:${secondaryActorId}:${primaryActorId}`, JSON.stringify({
6593
+ primaryActorId,
6594
+ secondaryActorId
6595
+ }), JSON.stringify(outcomes), bindings.length, store.actorId, store.deviceId, now);
6596
+ for (const binding of bindings) {
6597
+ const revision = createHash("sha256").update(`identity-merge-revision-v1\0${commitDigest}\0${binding.deviceId}`).digest("hex");
6598
+ const idempotencyKey = createHash("sha256").update(`identity-merge-row-v1\0${commitDigest}\0${binding.deviceId}`).digest("hex");
6599
+ if (store.db.prepare(`UPDATE identity_devices SET
6600
+ identity_id = ?, provenance = 'review_resolution', revision = ?,
6601
+ migration_state = 'user_managed', source_fingerprint = ?,
6602
+ idempotency_key = ?, updated_at = ?
6603
+ WHERE device_id = ? AND identity_id = ?`).run(primaryActorId, revision, commitDigest, idempotencyKey, now, binding.deviceId, secondaryActorId).changes !== 1) throw new Error("identity_merge_binding_update_failed");
6604
+ const rebound = store.db.prepare("SELECT assignment_version FROM identity_devices WHERE device_id = ?").get(binding.deviceId);
6605
+ if (!rebound || rebound.assignment_version !== binding.assignmentVersion + 1) throw new Error("identity_merge_assignment_version_unexpected");
6606
+ store.db.prepare("DELETE FROM policy_team_device_decisions WHERE device_id = ?").run(binding.deviceId);
6607
+ store.db.prepare(`INSERT INTO device_identity_binding_audit(
6608
+ event_id, commit_digest, device_id, previous_identity_id, target_identity_id,
6609
+ action, previous_assignment_version, resulting_assignment_version,
6610
+ decided_by_identity_id, decided_by_device_id, created_at
6611
+ ) VALUES (?, ?, ?, ?, ?, 'rebind', ?, ?, ?, ?, ?)`).run(createHash("sha256").update(`identity-merge-event-v1\0${commitDigest}\0${binding.deviceId}`).digest("hex"), commitDigest, binding.deviceId, secondaryActorId, primaryActorId, binding.assignmentVersion, rebound.assignment_version, store.actorId, store.deviceId, now);
6612
+ }
6613
+ }
6614
+ for (const membership of memberships) {
6615
+ if (primaryMemberships.get(membership.team_id)) {
6616
+ d.delete(schema.policyTeamMemberships).where(and(eq(schema.policyTeamMemberships.team_id, membership.team_id), eq(schema.policyTeamMemberships.identity_id, secondaryActorId))).run();
6617
+ continue;
6618
+ }
6619
+ const revision = createHash("sha256").update(`identity-merge-membership-revision-v1\0${mergeDigest}\0${membership.team_id}`).digest("hex");
6620
+ const idempotencyKey = createHash("sha256").update(`identity-merge-membership-row-v1\0${mergeDigest}\0${membership.team_id}`).digest("hex");
6621
+ d.update(schema.policyTeamMemberships).set({
6622
+ identity_id: primaryActorId,
6623
+ provenance: "review_resolution",
6624
+ revision,
6625
+ migration_state: "user_managed",
6626
+ source_fingerprint: mergeDigest,
6627
+ idempotency_key: idempotencyKey,
6628
+ updated_at: now
6629
+ }).where(and(eq(schema.policyTeamMemberships.team_id, membership.team_id), eq(schema.policyTeamMemberships.identity_id, secondaryActorId))).run();
6630
+ }
6631
+ for (const recipient of directRecipients) {
6632
+ if (primaryRecipients.get(recipient.canonical_project_identity)) {
6633
+ d.delete(schema.projectRecipients).where(and(eq(schema.projectRecipients.canonical_project_identity, recipient.canonical_project_identity), eq(schema.projectRecipients.recipient_kind, "identity"), eq(schema.projectRecipients.recipient_id, secondaryActorId))).run();
6634
+ continue;
6635
+ }
6636
+ const policyRevision = createHash("sha256").update(`identity-merge-recipient-revision-v1\0${mergeDigest}\0${recipient.canonical_project_identity}`).digest("hex");
6637
+ const idempotencyKey = createHash("sha256").update(`identity-merge-recipient-row-v1\0${mergeDigest}\0${recipient.canonical_project_identity}`).digest("hex");
6638
+ d.update(schema.projectRecipients).set({
6639
+ recipient_id: primaryActorId,
6640
+ provenance: "review_resolution",
6641
+ policy_revision: policyRevision,
6642
+ migration_state: "user_managed",
6643
+ source_fingerprint: mergeDigest,
6644
+ idempotency_key: idempotencyKey,
6645
+ updated_at: now
6646
+ }).where(and(eq(schema.projectRecipients.canonical_project_identity, recipient.canonical_project_identity), eq(schema.projectRecipients.recipient_kind, "identity"), eq(schema.projectRecipients.recipient_id, secondaryActorId))).run();
6647
+ }
6648
+ d.update(schema.recipientManagedProjectProjections).set({
6649
+ recipient_identity_id: primaryActorId,
6650
+ updated_at: now
6651
+ }).where(eq(schema.recipientManagedProjectProjections.recipient_identity_id, secondaryActorId)).run();
6652
+ store.db.prepare(`UPDATE share_operations SET
6653
+ inviter_actor_id = CASE WHEN inviter_actor_id = ? THEN ? ELSE inviter_actor_id END,
6654
+ recipient_actor_id = CASE WHEN recipient_actor_id = ? THEN ? ELSE recipient_actor_id END,
6655
+ person_id = CASE WHEN person_id = ? THEN ? ELSE person_id END,
6656
+ updated_at = ?
6657
+ WHERE inviter_actor_id = ? OR recipient_actor_id = ? OR person_id = ?`).run(secondaryActorId, primaryActorId, secondaryActorId, primaryActorId, secondaryActorId, primaryActorId, now, secondaryActorId, secondaryActorId, secondaryActorId);
5744
6658
  const peerUpdate = d.update(schema.syncPeers).set({ actor_id: primaryActorId }).where(eq(schema.syncPeers.actor_id, secondaryActorId)).run();
5745
6659
  if (primary.is_local) d.update(schema.syncPeers).set({ claimed_local_actor: 1 }).where(eq(schema.syncPeers.actor_id, primaryActorId)).run();
5746
6660
  d.update(schema.actors).set({
@@ -5748,9 +6662,10 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
5748
6662
  merged_into_actor_id: primaryActorId,
5749
6663
  updated_at: now
5750
6664
  }).where(eq(schema.actors.actor_id, secondaryActorId)).run();
5751
- return Number(peerUpdate.changes ?? 0);
5752
- })();
5753
- return c.json({ merged_count: mergedCount });
6665
+ return { mergedCount: Number(peerUpdate.changes ?? 0) };
6666
+ }).immediate();
6667
+ if ("error" in result) return c.json({ error: result.error }, result.status);
6668
+ return c.json({ merged_count: result.mergedCount });
5754
6669
  });
5755
6670
  app.post("/api/sync/actors/deactivate", async (c) => {
5756
6671
  const store = getStore();
@@ -5973,7 +6888,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
5973
6888
  inviteValue: rawValue,
5974
6889
  dbPath: store.dbPath,
5975
6890
  recipientActorId: recipientInvite ? null : store.actorId,
5976
- recipientDisplayName: typeof body.recipient_name === "string" ? body.recipient_name : store.actorDisplayName,
6891
+ recipientDisplayName: decoded.kind === "add_device" ? null : typeof body.recipient_name === "string" ? body.recipient_name : store.actorDisplayName,
5977
6892
  deviceDisplayName: typeof body.device_name === "string" ? body.device_name : null,
5978
6893
  reviewedOnboardingDigest: reviewedOnboardingDigest || null
5979
6894
  });
@@ -6820,15 +7735,19 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
6820
7735
  }, 400);
6821
7736
  }
6822
7737
  const groupId = resolveCoordinatorAdminGroup(String(body.group_id ?? "").trim(), status);
6823
- const displayName = String(body.display_name ?? "").trim();
6824
7738
  if (!groupId) return c.json({
6825
7739
  error: "group_id required",
6826
7740
  status
6827
7741
  }, 400);
6828
- if (!displayName) return c.json({
6829
- error: "display_name required",
6830
- status
6831
- }, 400);
7742
+ let displayName;
7743
+ try {
7744
+ displayName = normalizeHumanPresentationName(String(body.display_name ?? ""), "display_name");
7745
+ } catch (error) {
7746
+ return c.json({
7747
+ error: error instanceof Error ? error.message : "display_name_invalid",
7748
+ status
7749
+ }, 400);
7750
+ }
6832
7751
  try {
6833
7752
  const device = await coordinatorRenameDeviceAction({
6834
7753
  groupId,
@@ -7247,6 +8166,27 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
7247
8166
  return app;
7248
8167
  }
7249
8168
  //#endregion
8169
+ //#region src/routes/update-status.ts
8170
+ function updateStatusRoutes(options = {}) {
8171
+ const getUpdateStatus$1 = options.getUpdateStatus ?? getUpdateStatus;
8172
+ const app = new Hono();
8173
+ app.get("/api/update-status", async (c) => {
8174
+ try {
8175
+ const status = await getUpdateStatus$1({
8176
+ currentVersion: VERSION$1,
8177
+ installKind: detectInstallKind({
8178
+ entryPath: process.argv[1] ?? "",
8179
+ env: process.env
8180
+ })
8181
+ });
8182
+ return c.json(status);
8183
+ } catch {
8184
+ return c.json({ error: "Update status unavailable." }, 503);
8185
+ }
8186
+ });
8187
+ return app;
8188
+ }
8189
+ //#endregion
7250
8190
  //#region src/index.ts
7251
8191
  /**
7252
8192
  * @codemem/server — HTTP server (viewer, sync, API).
@@ -7277,8 +8217,10 @@ function createApp(opts) {
7277
8217
  const app = new Hono();
7278
8218
  app.use("*", preflightHandler());
7279
8219
  app.use("*", originGuard());
8220
+ app.route("/", healthRoutes(storeFactory));
7280
8221
  app.route("/", statsRoutes(storeFactory));
7281
8222
  app.route("/", memoryRoutes(storeFactory));
8223
+ app.route("/", packTransportRoutes(storeFactory));
7282
8224
  app.route("/", observerStatusRoutes({
7283
8225
  getStore: storeFactory,
7284
8226
  getSweeper: () => sweeper,
@@ -7286,7 +8228,8 @@ function createApp(opts) {
7286
8228
  }));
7287
8229
  app.route("/", configRoutes({ getSweeper: () => sweeper }));
7288
8230
  app.route("/", rawEventsRoutes(storeFactory, sweeper));
7289
- app.route("/", syncRoutes(storeFactory, getSyncRuntimeStatus));
8231
+ app.route("/", syncRoutes(storeFactory, getSyncRuntimeStatus, { loadDeviceIdentityCoordinatorEvidence: opts?.loadDeviceIdentityCoordinatorEvidence }));
8232
+ app.route("/", updateStatusRoutes({ getUpdateStatus: opts?.getUpdateStatus }));
7290
8233
  const staticRoot = process.env.CODEMEM_VIEWER_STATIC_DIR ?? join(import.meta.dirname ?? ".", "../static");
7291
8234
  app.use("/assets/*", async (c, next) => {
7292
8235
  c.header("Cache-Control", "no-cache");