@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.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1295 -352
- package/dist/index.js.map +1 -1
- package/dist/routes/health.d.ts +6 -0
- package/dist/routes/health.d.ts.map +1 -0
- package/dist/routes/memory.d.ts.map +1 -1
- package/dist/routes/pack.d.ts +6 -0
- package/dist/routes/pack.d.ts.map +1 -0
- package/dist/routes/raw-events.d.ts.map +1 -1
- package/dist/routes/sync.d.ts +16 -2
- package/dist/routes/sync.d.ts.map +1 -1
- package/dist/routes/target-validation.d.ts +19 -0
- package/dist/routes/target-validation.d.ts.map +1 -0
- package/dist/routes/update-status.d.ts +7 -0
- package/dist/routes/update-status.d.ts.map +1 -0
- package/package.json +2 -2
- package/static/app.js +44 -44
- package/static/app.js.br +0 -0
- package/static/app.js.gz +0 -0
- package/static/controls.css +196 -0
- package/static/controls.css.br +0 -0
- package/static/controls.css.gz +0 -0
- package/static/index.html +140 -35
- package/static/themes.css +4 -0
- package/static/themes.css.br +0 -0
- package/static/themes.css.gz +0 -0
- package/static/tokens.css +2 -0
- package/static/tokens.css.br +0 -0
- package/static/tokens.css.gz +0 -0
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,
|
|
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(
|
|
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
|
|
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(...
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
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
|
-
|
|
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,
|
|
1120
|
-
try {
|
|
1121
|
-
|
|
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
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
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
|
-
|
|
1795
|
+
inserted: ingestResult.inserted,
|
|
1796
|
+
skipped: ingestResult.skipped,
|
|
1797
|
+
received: ingestResult.received
|
|
1302
1798
|
});
|
|
1303
1799
|
} catch (err) {
|
|
1304
|
-
|
|
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
|
|
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
|
|
1320
|
-
const
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
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
|
-
|
|
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
|
|
1341
|
-
skipped:
|
|
1825
|
+
inserted: ingestResult.inserted,
|
|
1826
|
+
skipped: ingestResult.skipped
|
|
1342
1827
|
});
|
|
1343
1828
|
} catch (err) {
|
|
1344
|
-
|
|
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
|
|
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
|
|
1360
|
-
const
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
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
|
-
|
|
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
|
|
1381
|
-
skipped:
|
|
1854
|
+
inserted: ingestResult.inserted,
|
|
1855
|
+
skipped: ingestResult.skipped
|
|
1382
1856
|
});
|
|
1383
1857
|
} catch (err) {
|
|
1384
|
-
|
|
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
|
-
...
|
|
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, {
|
|
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)
|
|
2840
|
-
const [
|
|
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
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
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, {
|
|
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(
|
|
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(
|
|
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
|
|
3932
|
+
let verification;
|
|
3322
3933
|
try {
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
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
|
-
|
|
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 (!
|
|
3948
|
+
if (!verification.ok) return {
|
|
3341
3949
|
ok: false,
|
|
3342
|
-
reason:
|
|
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
|
-
|
|
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
|
-
|
|
3467
|
-
|
|
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
|
-
|
|
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 (!
|
|
4094
|
+
if (!signatureVerification.ok) return {
|
|
3484
4095
|
ok: false,
|
|
3485
|
-
reason:
|
|
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
|
-
|
|
4406
|
-
return (preauthChecked ? null : rateLimitedResponse(c, c.req.path, false)) ?? c.json(unauthorizedPayload(
|
|
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, {
|
|
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)
|
|
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
|
-
|
|
4519
|
-
return (preauthChecked ? null : rateLimitedResponse(c, c.req.path, false)) ?? c.json(unauthorizedPayload(
|
|
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)
|
|
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
|
|
5739
|
+
const rawName = String(body.name ?? "");
|
|
4962
5740
|
if (!peerDeviceId) return c.json({ error: "peer_device_id required" }, 400);
|
|
4963
|
-
if (!
|
|
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
|
|
5679
|
-
if (!
|
|
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
|
-
|
|
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
|
|
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({
|
|
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
|
-
|
|
6829
|
-
|
|
6830
|
-
|
|
6831
|
-
}
|
|
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");
|