@codemem/server 0.41.0 → 0.43.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 +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1778 -423
- package/dist/index.js.map +1 -1
- package/dist/middleware.d.ts +7 -3
- package/dist/middleware.d.ts.map +1 -1
- package/dist/routes/memory.d.ts.map +1 -1
- package/dist/routes/pack.d.ts.map +1 -1
- package/dist/routes/raw-events.d.ts.map +1 -1
- package/dist/routes/sync.d.ts +9 -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/team-setup.d.ts +171 -0
- package/dist/routes/team-setup.d.ts.map +1 -0
- package/package.json +2 -2
- package/static/app.js +56 -56
- 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 +160 -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,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
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, 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_SERVICE_DISCRIMINATOR, addSyncScopeToBoundary, analyzeProjectScopeMappingChangeGuardrails, applyReplicationOps, buildAuthHeaders, buildBaseUrl, buildFilterClausesWithContext, buildRawEventEnvelopeFromCodexHook, buildRawEventEnvelopeFromHook, canonicalMemoryKind, canonicalWorkspaceIdentity, cleanupNonces, clonePromptPackAttempt, 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, detectInstallKind, diagnoseStalePeerReceivedRows, ensureDeviceIdentity, executeShareProvisioning, extractInvitePayload, extractReplicationOps, filterReplicationOpsForSyncWithStatus, fingerprintPublicKey, formatHostPort, friendlyDeviceName, fromJson, getCodememConfigPath, getCodememEnvOverrides, getCoordinatorEnrollmentReconciliationIssueSummary, getCoordinatorGroupPreference, getRecipientPolicyAuthorityState, getSemanticIndexDiagnostics, getSyncResetState, getUpdateStatus, inviteTokenDigest, isPeerTrustBindingCompatible, isProjectSyncEnablementError, isScopedSyncCapability,
|
|
3
|
+
import { ACCESS_CLEANUP_OP_TYPE, CODEMEM_CONFIG_ENV_OVERRIDES, CoordinatorReciprocalApprovalRequestChangedError, 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, clearLegacyTeamSetupDeviceDecision, 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, discoverLegacyTeamCandidates, ensureDeviceIdentity, executeShareProvisioning, extractInvitePayload, extractReplicationOps, filterReplicationOpsForSyncWithStatus, fingerprintPublicKey, finishLegacyTeamSetupActivation, formatHostPort, friendlyDeviceName, fromJson, getCodememConfigPath, getCodememEnvOverrides, getCoordinatorEnrollmentReconciliationIssueSummary, getCoordinatorGroupPreference, getLegacyTeamSetupDraft, getRecipientPolicyAuthorityState, getSemanticIndexDiagnostics, getSyncResetState, getUpdateStatus, ingestRawEvents, inviteTokenDigest, isLegacyTeamCandidateSelectable, isLegacyTeamSetupProjectMappingIdentity, isPeerTrustBindingCompatible, isProjectSyncEnablementError, isScopedSyncCapability, legacyTeamCandidateId, legacyTeamCandidateProjectInventory, legacyTeamCanonicalProjectRef, legacyTeamDeviceRef, legacyTeamResolvedProjectRef, legacyTeamSetupApiErrorCode, 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, previewLegacyTeamSetupActivation, previewRecipientPolicyEdges, previewRecipientPolicyOnboarding, previewRecipientPolicyOnboardingFromReviewedIntent, probeAvailableCredentials, projectShareLifecycle, promptPackArtifactFingerprint, readCodememConfigFile, readCoordinatorSyncConfig, reassignProjectScopeInventoryProject, recipientInviteAuthoritativeIdentityId, recipientPolicyDigest, recipientReviewedIntentDigest, reconcileCoordinatorEnrollmentSnapshot, reconcileRecipientPolicyProject, reconcileShareOperationAcceptance, recordHighestObservedDirectSignatureVersion, recordNonce, recordPromptPackArtifacts, recordPromptPackTerminal, refreshAuthorizedCoordinatorPeerTrust, refreshConfiguredScopeMembershipCache, refreshLegacyTeamCandidate, rejectInboundScopeFailures, requestJson, resolveDbPath, resolveProject, resolveRecipientPolicyReview, resolveRecipientPolicyReviewBulk, runSyncPass, schema, setLegacyTeamSetupDeviceAssignment, setLegacyTeamSetupDeviceDecision, setLegacyTeamSetupProjectMapping, 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";
|
|
@@ -28,7 +28,8 @@ function isLoopbackOrigin(origin) {
|
|
|
28
28
|
}
|
|
29
29
|
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
30
30
|
if (url.username || url.password) return false;
|
|
31
|
-
|
|
31
|
+
const hostname = url.hostname.startsWith("[") ? url.hostname.slice(1, -1) : url.hostname;
|
|
32
|
+
return LOOPBACK_HOSTS.has(hostname);
|
|
32
33
|
}
|
|
33
34
|
/** HTTP methods that mutate state and require origin validation. */
|
|
34
35
|
var UNSAFE_METHODS = new Set([
|
|
@@ -61,7 +62,8 @@ function isUnsafeMissingOrigin(c) {
|
|
|
61
62
|
*
|
|
62
63
|
* Ports Python's `reject_cross_origin(missing_origin_policy="reject_if_unsafe")`:
|
|
63
64
|
*
|
|
64
|
-
* - GET/HEAD/OPTIONS: allowed from any origin
|
|
65
|
+
* - GET/HEAD/OPTIONS: allowed from any origin unless the caller marks a
|
|
66
|
+
* side-effecting GET path prefix as unsafe.
|
|
65
67
|
* - POST/DELETE/PATCH/PUT:
|
|
66
68
|
* - Origin present + loopback → allowed (browser on localhost)
|
|
67
69
|
* - Origin present + non-loopback → rejected 403
|
|
@@ -72,20 +74,21 @@ function isUnsafeMissingOrigin(c) {
|
|
|
72
74
|
* Access-Control-Allow-Origin is set — the browser doesn't need it.
|
|
73
75
|
* For valid loopback origins, ACAO is echoed back.
|
|
74
76
|
*/
|
|
75
|
-
function originGuard() {
|
|
77
|
+
function originGuard(options) {
|
|
76
78
|
return createMiddleware(async (c, next) => {
|
|
77
79
|
const origin = c.req.header("Origin");
|
|
78
80
|
const method = c.req.method;
|
|
79
|
-
|
|
81
|
+
const unsafeGet = (method === "GET" || method === "HEAD") && options?.unsafeGetPathPrefixes?.some((prefix) => c.req.path.startsWith(prefix));
|
|
82
|
+
if (UNSAFE_METHODS.has(method) || unsafeGet) {
|
|
80
83
|
if (origin) {
|
|
81
84
|
if (!isLoopbackOrigin(origin)) return c.json({ error: "forbidden" }, 403);
|
|
82
85
|
c.header("Access-Control-Allow-Origin", origin);
|
|
83
|
-
c.header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
86
|
+
c.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
|
84
87
|
c.header("Access-Control-Allow-Headers", "Content-Type");
|
|
85
88
|
} else if (isUnsafeMissingOrigin(c)) return c.json({ error: "forbidden" }, 403);
|
|
86
89
|
} else if (origin && isLoopbackOrigin(origin)) {
|
|
87
90
|
c.header("Access-Control-Allow-Origin", origin);
|
|
88
|
-
c.header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
91
|
+
c.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
|
89
92
|
c.header("Access-Control-Allow-Headers", "Content-Type");
|
|
90
93
|
}
|
|
91
94
|
await next();
|
|
@@ -104,7 +107,7 @@ function preflightHandler() {
|
|
|
104
107
|
const origin = c.req.header("Origin");
|
|
105
108
|
if (origin && isLoopbackOrigin(origin)) {
|
|
106
109
|
c.header("Access-Control-Allow-Origin", origin);
|
|
107
|
-
c.header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
110
|
+
c.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
|
108
111
|
c.header("Access-Control-Allow-Headers", "Content-Type");
|
|
109
112
|
c.header("Access-Control-Max-Age", "86400");
|
|
110
113
|
return c.body(null, 204);
|
|
@@ -573,6 +576,102 @@ function queryBool(value) {
|
|
|
573
576
|
}
|
|
574
577
|
//#endregion
|
|
575
578
|
//#region src/routes/memory.ts
|
|
579
|
+
var MAX_FEED_QUERY_CODE_UNITS = 256;
|
|
580
|
+
var searchFunctionsRegisteredStores = /* @__PURE__ */ new WeakSet();
|
|
581
|
+
function casefold(value) {
|
|
582
|
+
return String(value ?? "").toLowerCase();
|
|
583
|
+
}
|
|
584
|
+
function normalizeMemorySearchQuery(value) {
|
|
585
|
+
return casefold(value?.trim()).slice(0, MAX_FEED_QUERY_CODE_UNITS) || void 0;
|
|
586
|
+
}
|
|
587
|
+
function ensureMemorySearchFunctions(store) {
|
|
588
|
+
if (searchFunctionsRegisteredStores.has(store)) return;
|
|
589
|
+
store.db.function("codemem_casefold", { deterministic: true }, casefold);
|
|
590
|
+
store.db.function("codemem_project_basename", { deterministic: true }, (value) => projectBasename(String(value ?? "").trim()));
|
|
591
|
+
searchFunctionsRegisteredStores.add(store);
|
|
592
|
+
}
|
|
593
|
+
var IDENTITY_LOOKUP_BATCH_SIZE = 500;
|
|
594
|
+
function identityValue(item, key) {
|
|
595
|
+
const direct = String(item[key] ?? "").trim();
|
|
596
|
+
if (direct) return direct;
|
|
597
|
+
const rawMetadata = item.metadata ?? item.metadata_json;
|
|
598
|
+
const metadata = typeof rawMetadata === "string" ? fromJson(rawMetadata) : rawMetadata;
|
|
599
|
+
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return "";
|
|
600
|
+
return String(metadata[key] ?? "").trim();
|
|
601
|
+
}
|
|
602
|
+
function uniqueStrings(items, key) {
|
|
603
|
+
return [...new Set(items.map((item) => identityValue(item, key)).filter(Boolean))];
|
|
604
|
+
}
|
|
605
|
+
function batchedLookup(store, values, query) {
|
|
606
|
+
const rows = [];
|
|
607
|
+
for (let offset = 0; offset < values.length; offset += IDENTITY_LOOKUP_BATCH_SIZE) {
|
|
608
|
+
const batch = values.slice(offset, offset + IDENTITY_LOOKUP_BATCH_SIZE);
|
|
609
|
+
const placeholders = batch.map(() => "?").join(", ");
|
|
610
|
+
rows.push(...store.db.prepare(query(placeholders)).all(...batch));
|
|
611
|
+
}
|
|
612
|
+
return rows;
|
|
613
|
+
}
|
|
614
|
+
function humanName(value) {
|
|
615
|
+
if (typeof value !== "string") return void 0;
|
|
616
|
+
try {
|
|
617
|
+
return normalizeHumanPresentationName(value, "display_name");
|
|
618
|
+
} catch {
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Add presentation-only identity labels without changing persisted provenance.
|
|
624
|
+
* Resolved names are display hints, not authority or ownership evidence; legacy
|
|
625
|
+
* metadata receives the same top-level-first fallback as existing read paths.
|
|
626
|
+
*/
|
|
627
|
+
function attachResolvedIdentityFieldsUnsafe(store, items) {
|
|
628
|
+
const actorIds = uniqueStrings(items, "actor_id");
|
|
629
|
+
const deviceIds = uniqueStrings(items, "origin_device_id");
|
|
630
|
+
const peers = batchedLookup(store, deviceIds, (placeholders) => `SELECT peer_device_id, name, actor_id FROM sync_peers
|
|
631
|
+
WHERE peer_device_id IN (${placeholders})
|
|
632
|
+
AND TRIM(COALESCE(pinned_fingerprint, '')) <> ''`);
|
|
633
|
+
const peerByDevice = new Map(peers.map((peer) => [peer.peer_device_id, peer]));
|
|
634
|
+
const peerActorIds = peers.map((peer) => String(peer.actor_id ?? "").trim()).filter((actorId) => actorId.length > 0);
|
|
635
|
+
const actorRows = batchedLookup(store, [...new Set([...actorIds, ...peerActorIds])], (placeholders) => `SELECT actor_id, display_name, status, merged_into_actor_id FROM actors
|
|
636
|
+
WHERE actor_id IN (${placeholders})`);
|
|
637
|
+
const devices = batchedLookup(store, deviceIds, (placeholders) => `SELECT device_id, display_name FROM identity_devices
|
|
638
|
+
WHERE device_id IN (${placeholders}) AND status = 'active'`);
|
|
639
|
+
const actorById = new Map(actorRows.map((actor) => [actor.actor_id, actor]));
|
|
640
|
+
let pendingMergeTargets = [...new Set(actorRows.map((actor) => String(actor.merged_into_actor_id ?? "").trim()).filter((actorId) => actorId && !actorById.has(actorId)))];
|
|
641
|
+
while (pendingMergeTargets.length > 0) {
|
|
642
|
+
const targetRows = batchedLookup(store, pendingMergeTargets, (placeholders) => `SELECT actor_id, display_name, status, merged_into_actor_id FROM actors
|
|
643
|
+
WHERE actor_id IN (${placeholders})`);
|
|
644
|
+
for (const actor of targetRows) actorById.set(actor.actor_id, actor);
|
|
645
|
+
pendingMergeTargets = [...new Set(targetRows.map((actor) => String(actor.merged_into_actor_id ?? "").trim()).filter((actorId) => actorId && !actorById.has(actorId)))];
|
|
646
|
+
}
|
|
647
|
+
const resolvedActorName = (actorId) => {
|
|
648
|
+
const seen = /* @__PURE__ */ new Set();
|
|
649
|
+
let currentActorId = actorId;
|
|
650
|
+
while (currentActorId && !seen.has(currentActorId)) {
|
|
651
|
+
seen.add(currentActorId);
|
|
652
|
+
const actor = actorById.get(currentActorId);
|
|
653
|
+
if (!actor) return void 0;
|
|
654
|
+
if (actor.status === "active" && actor.merged_into_actor_id === null) return humanName(actor.display_name);
|
|
655
|
+
currentActorId = String(actor.merged_into_actor_id ?? "").trim();
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
const deviceNameById = new Map(devices.map((device) => [device.device_id, humanName(device.display_name)]).filter((entry) => entry[1] !== void 0));
|
|
659
|
+
for (const item of items) {
|
|
660
|
+
const actorId = identityValue(item, "actor_id");
|
|
661
|
+
const deviceId = identityValue(item, "origin_device_id");
|
|
662
|
+
const peer = peerByDevice.get(deviceId);
|
|
663
|
+
const peerActorId = String(peer?.actor_id ?? "").trim();
|
|
664
|
+
const actorName = actorById.has(actorId) ? resolvedActorName(actorId) ?? humanName(item.actor_display_name) : resolvedActorName(peerActorId) ?? humanName(item.actor_display_name);
|
|
665
|
+
const deviceName = deviceNameById.get(deviceId) ?? humanName(peer?.name);
|
|
666
|
+
if (actorName) item.resolved_actor_display_name = actorName;
|
|
667
|
+
if (deviceName) item.resolved_device_display_name = deviceName;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
function attachResolvedIdentityFields(store, items) {
|
|
671
|
+
try {
|
|
672
|
+
attachResolvedIdentityFieldsUnsafe(store, items);
|
|
673
|
+
} catch {}
|
|
674
|
+
}
|
|
576
675
|
function serializeMemoryRow(ownedBySelf, row) {
|
|
577
676
|
const metadata = fromJson(row.metadata_json ?? null);
|
|
578
677
|
const owned_by_self = ownedBySelf(row);
|
|
@@ -651,12 +750,49 @@ function summaryLikeSqlPredicate() {
|
|
|
651
750
|
OR (
|
|
652
751
|
json_valid(COALESCE(memory_items.metadata_json, ''))
|
|
653
752
|
AND (
|
|
654
|
-
COALESCE(
|
|
753
|
+
COALESCE(json_type(memory_items.metadata_json, '$.is_summary') = 'true', 0)
|
|
655
754
|
OR LOWER(TRIM(COALESCE(json_extract(memory_items.metadata_json, '$.source'), ''))) = 'observer_summary'
|
|
656
755
|
)
|
|
657
756
|
)
|
|
658
757
|
)`;
|
|
659
758
|
}
|
|
759
|
+
function memorySearchSql(query) {
|
|
760
|
+
const textClause = `INSTR(codemem_casefold(
|
|
761
|
+
COALESCE(memory_items.title, '') || CHAR(10) ||
|
|
762
|
+
COALESCE(memory_items.body_text, '') || CHAR(10) ||
|
|
763
|
+
CASE WHEN ${summaryLikeSqlPredicate()} THEN 'session_summary' ELSE
|
|
764
|
+
COALESCE(NULLIF(LOWER(TRIM(COALESCE(memory_items.kind, ''))), ''), 'change')
|
|
765
|
+
END || CHAR(10) ||
|
|
766
|
+
COALESCE(
|
|
767
|
+
memory_items.project,
|
|
768
|
+
codemem_project_basename(
|
|
769
|
+
(SELECT sessions.project FROM sessions WHERE sessions.id = memory_items.session_id)
|
|
770
|
+
),
|
|
771
|
+
''
|
|
772
|
+
) || CHAR(10) ||
|
|
773
|
+
COALESCE(memory_items.tags_text, '') || CHAR(10) ||
|
|
774
|
+
COALESCE(memory_items.facts, '') || CHAR(10) ||
|
|
775
|
+
COALESCE(memory_items.subtitle, '') || CHAR(10) ||
|
|
776
|
+
COALESCE(memory_items.narrative, '') || CHAR(10) ||
|
|
777
|
+
CASE WHEN json_valid(COALESCE(memory_items.metadata_json, '')) THEN
|
|
778
|
+
COALESCE(json_extract(memory_items.metadata_json, '$.request'), '') || CHAR(10) ||
|
|
779
|
+
COALESCE(json_extract(memory_items.metadata_json, '$.subtitle'), '') || CHAR(10) ||
|
|
780
|
+
COALESCE(json_extract(memory_items.metadata_json, '$.narrative'), '') || CHAR(10) ||
|
|
781
|
+
COALESCE(json_extract(memory_items.metadata_json, '$.facts'), '') || CHAR(10) ||
|
|
782
|
+
COALESCE(json_extract(memory_items.metadata_json, '$.summary'), '')
|
|
783
|
+
ELSE '' END
|
|
784
|
+
), codemem_casefold(?)) > 0`;
|
|
785
|
+
const parsedMemoryId = parsePositiveMemoryId(query);
|
|
786
|
+
const memoryId = parsedMemoryId != null && String(parsedMemoryId) === query ? parsedMemoryId : null;
|
|
787
|
+
if (memoryId != null) return {
|
|
788
|
+
clause: `(memory_items.id = ? OR ${textClause})`,
|
|
789
|
+
params: [memoryId, query]
|
|
790
|
+
};
|
|
791
|
+
return {
|
|
792
|
+
clause: textClause,
|
|
793
|
+
params: [query]
|
|
794
|
+
};
|
|
795
|
+
}
|
|
660
796
|
function countVisibleObservationRows(store, filters) {
|
|
661
797
|
const filterResult = buildViewerMemoryFilters(store, filters);
|
|
662
798
|
const clauses = [
|
|
@@ -709,43 +845,32 @@ function countVisibleArtifactRows(store, project) {
|
|
|
709
845
|
return Number(row?.total ?? 0);
|
|
710
846
|
}
|
|
711
847
|
function queryMemoryPage(store, options) {
|
|
848
|
+
ensureMemorySearchFunctions(store);
|
|
712
849
|
const filters = {};
|
|
713
850
|
if (options.project) filters.project = options.project;
|
|
714
851
|
if (options.scope) filters.ownership_scope = options.scope;
|
|
715
852
|
const filterResult = buildViewerMemoryFilters(store, filters);
|
|
716
|
-
const
|
|
853
|
+
const summaryPredicate = summaryLikeSqlPredicate();
|
|
854
|
+
const clauses = [
|
|
855
|
+
"memory_items.active = 1",
|
|
856
|
+
options.classification === "summary" ? summaryPredicate : `NOT ${summaryPredicate}`,
|
|
857
|
+
...filterResult.clauses
|
|
858
|
+
];
|
|
859
|
+
const params = [...filterResult.params];
|
|
860
|
+
if (options.q) {
|
|
861
|
+
const search = memorySearchSql(options.q);
|
|
862
|
+
clauses.push(search.clause);
|
|
863
|
+
params.push(...search.params);
|
|
864
|
+
}
|
|
865
|
+
const where = clauses.join(" AND ");
|
|
717
866
|
const from = filterResult.joinSessions ? "memory_items JOIN sessions ON sessions.id = memory_items.session_id" : "memory_items";
|
|
718
867
|
const rows = store.db.prepare(`SELECT memory_items.* FROM ${from}
|
|
719
868
|
WHERE ${where}
|
|
720
|
-
ORDER BY memory_items.created_at DESC
|
|
721
|
-
LIMIT ? OFFSET ?`).all(...
|
|
869
|
+
ORDER BY memory_items.created_at DESC, memory_items.id DESC
|
|
870
|
+
LIMIT ? OFFSET ?`).all(...params, options.limit + 1, options.offset);
|
|
722
871
|
const ownedBySelf = store.buildOwnershipPredicate();
|
|
723
872
|
return rows.map((row) => serializeMemoryRow(ownedBySelf, row));
|
|
724
873
|
}
|
|
725
|
-
function isSummaryLikeMemory$1(item) {
|
|
726
|
-
return isSummaryLikeMemory({
|
|
727
|
-
kind: item.kind,
|
|
728
|
-
metadata: item.metadata_json
|
|
729
|
-
});
|
|
730
|
-
}
|
|
731
|
-
function selectMemoryPage(store, options) {
|
|
732
|
-
const pageSize = Math.max(options.limit + options.offset + 10, 50);
|
|
733
|
-
let rawOffset = 0;
|
|
734
|
-
const matched = [];
|
|
735
|
-
while (matched.length < options.offset + options.limit + 1) {
|
|
736
|
-
const page = queryMemoryPage(store, {
|
|
737
|
-
limit: pageSize,
|
|
738
|
-
offset: rawOffset,
|
|
739
|
-
project: options.project,
|
|
740
|
-
scope: options.scope
|
|
741
|
-
});
|
|
742
|
-
if (page.length === 0) break;
|
|
743
|
-
matched.push(...page.filter(options.matcher));
|
|
744
|
-
if (page.length < pageSize) break;
|
|
745
|
-
rawOffset += page.length;
|
|
746
|
-
}
|
|
747
|
-
return matched.slice(options.offset, options.offset + options.limit + 1);
|
|
748
|
-
}
|
|
749
874
|
function memoryRoutes(getStore) {
|
|
750
875
|
const app = new Hono();
|
|
751
876
|
app.get("/api/sessions", (c) => {
|
|
@@ -794,17 +919,19 @@ function memoryRoutes(getStore) {
|
|
|
794
919
|
{
|
|
795
920
|
const limit = Math.max(1, queryInt(c.req.query("limit"), 20));
|
|
796
921
|
const offset = Math.max(0, queryInt(c.req.query("offset"), 0));
|
|
797
|
-
const items =
|
|
922
|
+
const items = queryMemoryPage(store, {
|
|
798
923
|
limit,
|
|
799
924
|
offset,
|
|
800
925
|
project: c.req.query("project") || void 0,
|
|
801
926
|
scope: normalizeScope(c.req.query("scope")),
|
|
802
|
-
|
|
927
|
+
q: normalizeMemorySearchQuery(c.req.query("q")),
|
|
928
|
+
classification: "observation"
|
|
803
929
|
});
|
|
804
930
|
const hasMore = items.length > limit;
|
|
805
931
|
const result = hasMore ? items.slice(0, limit) : items;
|
|
806
932
|
const asRecords = result;
|
|
807
933
|
attachSessionFields(store, asRecords);
|
|
934
|
+
attachResolvedIdentityFields(store, asRecords);
|
|
808
935
|
return c.json({
|
|
809
936
|
items: asRecords,
|
|
810
937
|
pagination: {
|
|
@@ -821,17 +948,19 @@ function memoryRoutes(getStore) {
|
|
|
821
948
|
{
|
|
822
949
|
const limit = Math.max(1, queryInt(c.req.query("limit"), 50));
|
|
823
950
|
const offset = Math.max(0, queryInt(c.req.query("offset"), 0));
|
|
824
|
-
const items =
|
|
951
|
+
const items = queryMemoryPage(store, {
|
|
825
952
|
limit,
|
|
826
953
|
offset,
|
|
827
954
|
project: c.req.query("project") || void 0,
|
|
828
955
|
scope: normalizeScope(c.req.query("scope")),
|
|
829
|
-
|
|
956
|
+
q: normalizeMemorySearchQuery(c.req.query("q")),
|
|
957
|
+
classification: "summary"
|
|
830
958
|
});
|
|
831
959
|
const hasMore = items.length > limit;
|
|
832
960
|
const result = hasMore ? items.slice(0, limit) : items;
|
|
833
961
|
const asRecords = result;
|
|
834
962
|
attachSessionFields(store, asRecords);
|
|
963
|
+
attachResolvedIdentityFields(store, asRecords);
|
|
835
964
|
return c.json({
|
|
836
965
|
items: asRecords,
|
|
837
966
|
pagination: {
|
|
@@ -926,6 +1055,7 @@ function memoryRoutes(getStore) {
|
|
|
926
1055
|
if (project) filters.project = project;
|
|
927
1056
|
const asRecords = store.recent(limit, filters);
|
|
928
1057
|
attachSessionFields(store, asRecords);
|
|
1058
|
+
attachResolvedIdentityFields(store, asRecords);
|
|
929
1059
|
return c.json({ items: asRecords });
|
|
930
1060
|
}
|
|
931
1061
|
});
|
|
@@ -1077,6 +1207,71 @@ function observerStatusRoutes(deps) {
|
|
|
1077
1207
|
return app;
|
|
1078
1208
|
}
|
|
1079
1209
|
//#endregion
|
|
1210
|
+
//#region src/routes/target-validation.ts
|
|
1211
|
+
var IDENTITY_TARGET_KEYS = new Set(VIEWER_IDENTITY_TARGET_KEYS);
|
|
1212
|
+
var BOOLEAN_IDENTITY_TARGET_KEYS = new Set(["actor_id_present", "embedding_disabled"]);
|
|
1213
|
+
function invalidRequest$1(message) {
|
|
1214
|
+
return {
|
|
1215
|
+
ok: false,
|
|
1216
|
+
status: 400,
|
|
1217
|
+
body: { error: {
|
|
1218
|
+
code: "invalid_request",
|
|
1219
|
+
message
|
|
1220
|
+
} }
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
function conflict(code, message) {
|
|
1224
|
+
return {
|
|
1225
|
+
ok: false,
|
|
1226
|
+
status: 409,
|
|
1227
|
+
body: { error: {
|
|
1228
|
+
code,
|
|
1229
|
+
message
|
|
1230
|
+
} }
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
function isRecord$1(value) {
|
|
1234
|
+
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
1235
|
+
}
|
|
1236
|
+
function currentIdentityTarget() {
|
|
1237
|
+
return buildViewerIdentityTarget();
|
|
1238
|
+
}
|
|
1239
|
+
function requestedDbMatches(store, value) {
|
|
1240
|
+
if (value == null) return true;
|
|
1241
|
+
if (typeof value !== "string" || !value.trim()) return null;
|
|
1242
|
+
return resolve(resolveDbPath(value.trim())) === resolve(store.dbPath);
|
|
1243
|
+
}
|
|
1244
|
+
function requestedIdentityMatches(value) {
|
|
1245
|
+
if (value == null) return true;
|
|
1246
|
+
if (!isRecord$1(value)) return null;
|
|
1247
|
+
if (Object.keys(value).length !== IDENTITY_TARGET_KEYS.size || Object.keys(value).some((key) => !IDENTITY_TARGET_KEYS.has(key))) return "unsupported";
|
|
1248
|
+
const expected = currentIdentityTarget();
|
|
1249
|
+
for (const key of VIEWER_IDENTITY_TARGET_KEYS) {
|
|
1250
|
+
const requested = value[key];
|
|
1251
|
+
if (BOOLEAN_IDENTITY_TARGET_KEYS.has(key)) {
|
|
1252
|
+
if (typeof requested !== "boolean") return null;
|
|
1253
|
+
} else if (requested !== null && typeof requested !== "string") return null;
|
|
1254
|
+
if (requested !== expected[key]) return false;
|
|
1255
|
+
}
|
|
1256
|
+
return true;
|
|
1257
|
+
}
|
|
1258
|
+
function validateViewerTarget(store, payload, options = {}) {
|
|
1259
|
+
if (options.requirePairedTargets) {
|
|
1260
|
+
const hasDbPath = Object.hasOwn(payload, "db_path");
|
|
1261
|
+
if (hasDbPath !== Object.hasOwn(payload, "identity_target")) return invalidRequest$1("db_path and identity_target must be provided together");
|
|
1262
|
+
if (hasDbPath && (payload.db_path == null || payload.identity_target == null)) return invalidRequest$1("db_path and identity_target must be valid when provided");
|
|
1263
|
+
}
|
|
1264
|
+
const dbMatches = requestedDbMatches(store, payload.db_path);
|
|
1265
|
+
if (dbMatches == null) return invalidRequest$1("db_path must be a non-empty string");
|
|
1266
|
+
if (!dbMatches) return conflict("viewer_db_mismatch", "viewer database does not match request");
|
|
1267
|
+
const identityMatches = requestedIdentityMatches(payload.identity_target);
|
|
1268
|
+
if (identityMatches === "unsupported") return conflict("viewer_contract_unsupported", "viewer request contract is incompatible");
|
|
1269
|
+
if (identityMatches == null) return invalidRequest$1("identity_target is invalid");
|
|
1270
|
+
if (!identityMatches) return conflict("viewer_identity_mismatch", "viewer identity does not match request");
|
|
1271
|
+
if ((options.requireCurrentIdentity || payload.identity_target != null) && !store.hasCurrentIdentity()) return conflict("viewer_identity_mismatch", "viewer identity does not match request");
|
|
1272
|
+
return { ok: true };
|
|
1273
|
+
}
|
|
1274
|
+
//#endregion
|
|
1080
1275
|
//#region src/routes/pack.ts
|
|
1081
1276
|
var MAX_LEDGER_PAYLOAD_BYTES = 16 * 1024;
|
|
1082
1277
|
var MAX_METADATA_FIELD_CHARS = 512;
|
|
@@ -1114,19 +1309,6 @@ var LEDGER_KEYS = new Set([
|
|
|
1114
1309
|
"db_path",
|
|
1115
1310
|
"identity_target"
|
|
1116
1311
|
]);
|
|
1117
|
-
var IDENTITY_TARGET_KEYS = new Set([
|
|
1118
|
-
"device_id",
|
|
1119
|
-
"actor_id_present",
|
|
1120
|
-
"actor_id",
|
|
1121
|
-
"config_path",
|
|
1122
|
-
"runtime_root",
|
|
1123
|
-
"workspace_id",
|
|
1124
|
-
"home_dir",
|
|
1125
|
-
"pack_compression",
|
|
1126
|
-
"embedding_disabled",
|
|
1127
|
-
"embedding_model"
|
|
1128
|
-
]);
|
|
1129
|
-
var BOOLEAN_IDENTITY_TARGET_KEYS = new Set(["actor_id_present", "embedding_disabled"]);
|
|
1130
1312
|
var ATTEMPT_KEYS = new Set([
|
|
1131
1313
|
"attempt_id",
|
|
1132
1314
|
"started_at",
|
|
@@ -1154,12 +1336,6 @@ function invalidRequest(message) {
|
|
|
1154
1336
|
message
|
|
1155
1337
|
} };
|
|
1156
1338
|
}
|
|
1157
|
-
function viewerDbMismatch() {
|
|
1158
|
-
return { error: {
|
|
1159
|
-
code: "viewer_db_mismatch",
|
|
1160
|
-
message: "viewer database does not match request"
|
|
1161
|
-
} };
|
|
1162
|
-
}
|
|
1163
1339
|
function viewerIdentityMismatch() {
|
|
1164
1340
|
return { error: {
|
|
1165
1341
|
code: "viewer_identity_mismatch",
|
|
@@ -1185,47 +1361,6 @@ function normalizeWorkingSetPath(value) {
|
|
|
1185
1361
|
if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../")) return null;
|
|
1186
1362
|
return normalized;
|
|
1187
1363
|
}
|
|
1188
|
-
function requestedDbMatches(store, value) {
|
|
1189
|
-
if (value == null) return true;
|
|
1190
|
-
if (typeof value !== "string" || !value.trim()) return null;
|
|
1191
|
-
return resolve(resolveDbPath(value)) === resolve(store.dbPath);
|
|
1192
|
-
}
|
|
1193
|
-
function normalizeIdentityPath(value) {
|
|
1194
|
-
if (!value?.trim()) return null;
|
|
1195
|
-
return resolve(resolveDbPath(value.trim()));
|
|
1196
|
-
}
|
|
1197
|
-
function currentIdentityTarget() {
|
|
1198
|
-
return {
|
|
1199
|
-
device_id: process.env.CODEMEM_DEVICE_ID?.trim() || null,
|
|
1200
|
-
actor_id_present: Object.hasOwn(process.env, "CODEMEM_ACTOR_ID"),
|
|
1201
|
-
actor_id: process.env.CODEMEM_ACTOR_ID?.trim() || null,
|
|
1202
|
-
config_path: normalizeIdentityPath(process.env.CODEMEM_CONFIG),
|
|
1203
|
-
runtime_root: normalizeIdentityPath(process.env.CODEMEM_RUNTIME_ROOT),
|
|
1204
|
-
workspace_id: process.env.CODEMEM_WORKSPACE_ID?.trim() || null,
|
|
1205
|
-
home_dir: normalizeIdentityPath(process.env.HOME || homedir()),
|
|
1206
|
-
pack_compression: process.env.CODEMEM_PACK_COMPRESSION?.trim() || null,
|
|
1207
|
-
embedding_disabled: [
|
|
1208
|
-
"1",
|
|
1209
|
-
"true",
|
|
1210
|
-
"yes"
|
|
1211
|
-
].includes(String(process.env.CODEMEM_EMBEDDING_DISABLED || "").toLowerCase()),
|
|
1212
|
-
embedding_model: process.env.CODEMEM_EMBEDDING_MODEL || "Xenova/bge-small-en-v1.5"
|
|
1213
|
-
};
|
|
1214
|
-
}
|
|
1215
|
-
function requestedIdentityMatches(value) {
|
|
1216
|
-
if (value == null) return true;
|
|
1217
|
-
if (!isRecord(value)) return null;
|
|
1218
|
-
if (Object.keys(value).length !== IDENTITY_TARGET_KEYS.size || Object.keys(value).some((key) => !IDENTITY_TARGET_KEYS.has(key))) return "unsupported";
|
|
1219
|
-
const expected = currentIdentityTarget();
|
|
1220
|
-
for (const key of IDENTITY_TARGET_KEYS) {
|
|
1221
|
-
const requested = value[key];
|
|
1222
|
-
if (BOOLEAN_IDENTITY_TARGET_KEYS.has(key)) {
|
|
1223
|
-
if (typeof requested !== "boolean") return null;
|
|
1224
|
-
} else if (requested !== null && typeof requested !== "string") return null;
|
|
1225
|
-
if (requested !== expected[key]) return false;
|
|
1226
|
-
}
|
|
1227
|
-
return true;
|
|
1228
|
-
}
|
|
1229
1364
|
function validateMetadataFields(payload, allowedKeys) {
|
|
1230
1365
|
if (Buffer.byteLength(JSON.stringify(payload), "utf8") > MAX_LEDGER_PAYLOAD_BYTES) return "ledger metadata exceeds 16384 bytes";
|
|
1231
1366
|
for (const key of Object.keys(payload)) {
|
|
@@ -1347,7 +1482,8 @@ function packTransportRoutes(getStore) {
|
|
|
1347
1482
|
if (!store.hasCurrentIdentity()) return c.json(viewerIdentityMismatch(), 409);
|
|
1348
1483
|
return c.json({
|
|
1349
1484
|
service: "codemem-viewer",
|
|
1350
|
-
protocol_version:
|
|
1485
|
+
protocol_version: PROMPT_TRANSPORT_PROTOCOL_RANGE.protocolVersion,
|
|
1486
|
+
min_supported_protocol_version: PROMPT_TRANSPORT_PROTOCOL_RANGE.minSupportedProtocolVersion,
|
|
1351
1487
|
db_path: resolve(store.dbPath),
|
|
1352
1488
|
identity_target: currentIdentityTarget()
|
|
1353
1489
|
});
|
|
@@ -1368,14 +1504,8 @@ function packTransportRoutes(getStore) {
|
|
|
1368
1504
|
}
|
|
1369
1505
|
try {
|
|
1370
1506
|
const store = getStore();
|
|
1371
|
-
const
|
|
1372
|
-
if (
|
|
1373
|
-
if (!dbMatches) return c.json(viewerDbMismatch(), 409);
|
|
1374
|
-
const identityMatches = requestedIdentityMatches(parsed.identity_target);
|
|
1375
|
-
if (identityMatches === "unsupported") return c.json(viewerContractUnsupported(), 409);
|
|
1376
|
-
if (identityMatches == null) return c.json(invalidRequest("identity_target is invalid"), 400);
|
|
1377
|
-
if (!identityMatches) return c.json(viewerIdentityMismatch(), 409);
|
|
1378
|
-
if (!store.hasCurrentIdentity()) return c.json(viewerIdentityMismatch(), 409);
|
|
1507
|
+
const target = validateViewerTarget(store, parsed, { requireCurrentIdentity: true });
|
|
1508
|
+
if (!target.ok) return c.json(target.body, target.status);
|
|
1379
1509
|
if (!request.attempt) {
|
|
1380
1510
|
const pack = await store.buildMemoryPackAsync(request.context, request.limit, request.tokenBudget, request.filters, request.renderOptions);
|
|
1381
1511
|
return c.json(pack);
|
|
@@ -1412,14 +1542,8 @@ function packTransportRoutes(getStore) {
|
|
|
1412
1542
|
}
|
|
1413
1543
|
try {
|
|
1414
1544
|
const store = getStore();
|
|
1415
|
-
const
|
|
1416
|
-
if (
|
|
1417
|
-
if (!dbMatches) return c.json(viewerDbMismatch(), 409);
|
|
1418
|
-
const identityMatches = requestedIdentityMatches(parsed.identity_target);
|
|
1419
|
-
if (identityMatches === "unsupported") return c.json(viewerContractUnsupported(), 409);
|
|
1420
|
-
if (identityMatches == null) return c.json(invalidRequest("identity_target is invalid"), 400);
|
|
1421
|
-
if (!identityMatches) return c.json(viewerIdentityMismatch(), 409);
|
|
1422
|
-
if (!store.hasCurrentIdentity()) return c.json(viewerIdentityMismatch(), 409);
|
|
1545
|
+
const target = validateViewerTarget(store, parsed, { requireCurrentIdentity: true });
|
|
1546
|
+
if (!target.ok) return c.json(target.body, target.status);
|
|
1423
1547
|
const outcome = dispatchLedger(store, parsed);
|
|
1424
1548
|
if (typeof outcome === "string") return c.json(invalidRequest(outcome), 400);
|
|
1425
1549
|
if (outcome.ok) return c.json(outcome);
|
|
@@ -1439,34 +1563,60 @@ function packTransportRoutes(getStore) {
|
|
|
1439
1563
|
* Raw events routes — GET & POST /api/raw-events, GET /api/raw-events/status,
|
|
1440
1564
|
* POST /api/claude-hooks, POST /api/codex-hooks.
|
|
1441
1565
|
*/
|
|
1442
|
-
var
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
}
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1566
|
+
var DEFAULT_MAX_RAW_EVENTS_BODY_BYTES = 1048576;
|
|
1567
|
+
function configuredMaxRawEventsBodyBytes() {
|
|
1568
|
+
const parsed = Number(process.env.CODEMEM_RAW_EVENTS_MAX_BODY_BYTES?.trim() ?? "");
|
|
1569
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_RAW_EVENTS_BODY_BYTES;
|
|
1570
|
+
}
|
|
1571
|
+
var MAX_RAW_EVENTS_BODY_BYTES = configuredMaxRawEventsBodyBytes();
|
|
1572
|
+
function emptyTranscriptOutcomeCounts() {
|
|
1573
|
+
return {
|
|
1574
|
+
ok: 0,
|
|
1575
|
+
not_provided: 0,
|
|
1576
|
+
path_rejected: 0,
|
|
1577
|
+
unreadable: 0,
|
|
1578
|
+
no_complete_record: 0,
|
|
1579
|
+
no_assistant_record: 0
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
function createTranscriptDiagnostics() {
|
|
1583
|
+
const counts = {
|
|
1584
|
+
claude: emptyTranscriptOutcomeCounts(),
|
|
1585
|
+
codex: emptyTranscriptOutcomeCounts()
|
|
1586
|
+
};
|
|
1587
|
+
return {
|
|
1588
|
+
record(source, outcome) {
|
|
1589
|
+
counts[source][outcome] += 1;
|
|
1590
|
+
},
|
|
1591
|
+
snapshot() {
|
|
1592
|
+
return {
|
|
1593
|
+
scope: "legacy_compatibility_routes",
|
|
1594
|
+
counts: {
|
|
1595
|
+
claude: { ...counts.claude },
|
|
1596
|
+
codex: { ...counts.codex }
|
|
1597
|
+
}
|
|
1598
|
+
};
|
|
1599
|
+
}
|
|
1600
|
+
};
|
|
1601
|
+
}
|
|
1602
|
+
function transcriptSkipResponse(outcome) {
|
|
1603
|
+
if (outcome !== null && outcome !== "ok") return {
|
|
1604
|
+
inserted: 0,
|
|
1605
|
+
skipped: 1,
|
|
1606
|
+
skip_reason: "transcript_unavailable",
|
|
1607
|
+
skip_detail: outcome
|
|
1608
|
+
};
|
|
1609
|
+
return {
|
|
1610
|
+
inserted: 0,
|
|
1611
|
+
skipped: 1,
|
|
1612
|
+
skip_reason: "unsupported_hook"
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
function claudeTranscriptRoot() {
|
|
1616
|
+
return join(process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude"), "projects");
|
|
1617
|
+
}
|
|
1618
|
+
function codexTranscriptRoot() {
|
|
1619
|
+
return join(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"), "sessions");
|
|
1470
1620
|
}
|
|
1471
1621
|
/**
|
|
1472
1622
|
* Parse and validate a JSON object body, enforcing size limits.
|
|
@@ -1479,16 +1629,43 @@ async function parseJsonObjectBody(c, maxBytes) {
|
|
|
1479
1629
|
error: "payload too large",
|
|
1480
1630
|
max_bytes: maxBytes
|
|
1481
1631
|
}, 413);
|
|
1482
|
-
let raw;
|
|
1632
|
+
let raw = "";
|
|
1483
1633
|
try {
|
|
1484
|
-
|
|
1634
|
+
const body = c.req.raw.body;
|
|
1635
|
+
if (body !== null) {
|
|
1636
|
+
const reader = body.getReader();
|
|
1637
|
+
const chunks = [];
|
|
1638
|
+
let totalBytes = 0;
|
|
1639
|
+
try {
|
|
1640
|
+
while (true) {
|
|
1641
|
+
const { done, value } = await reader.read();
|
|
1642
|
+
if (done) break;
|
|
1643
|
+
totalBytes += value.byteLength;
|
|
1644
|
+
if (totalBytes > maxBytes) {
|
|
1645
|
+
try {
|
|
1646
|
+
await reader.cancel();
|
|
1647
|
+
} catch {}
|
|
1648
|
+
return c.json({
|
|
1649
|
+
error: "payload too large",
|
|
1650
|
+
max_bytes: maxBytes
|
|
1651
|
+
}, 413);
|
|
1652
|
+
}
|
|
1653
|
+
chunks.push(value);
|
|
1654
|
+
}
|
|
1655
|
+
} finally {
|
|
1656
|
+
reader.releaseLock();
|
|
1657
|
+
}
|
|
1658
|
+
const bytes = new Uint8Array(totalBytes);
|
|
1659
|
+
let offset = 0;
|
|
1660
|
+
for (const chunk of chunks) {
|
|
1661
|
+
bytes.set(chunk, offset);
|
|
1662
|
+
offset += chunk.byteLength;
|
|
1663
|
+
}
|
|
1664
|
+
raw = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
1665
|
+
}
|
|
1485
1666
|
} catch {
|
|
1486
1667
|
return c.json({ error: "invalid json" }, 400);
|
|
1487
1668
|
}
|
|
1488
|
-
if (Buffer.byteLength(raw, "utf-8") > maxBytes) return c.json({
|
|
1489
|
-
error: "payload too large",
|
|
1490
|
-
max_bytes: maxBytes
|
|
1491
|
-
}, 413);
|
|
1492
1669
|
let parsed;
|
|
1493
1670
|
try {
|
|
1494
1671
|
parsed = raw ? JSON.parse(raw) : {};
|
|
@@ -1499,13 +1676,78 @@ async function parseJsonObjectBody(c, maxBytes) {
|
|
|
1499
1676
|
return parsed;
|
|
1500
1677
|
}
|
|
1501
1678
|
/** Nudge the sweeper safely — never crashes the caller. */
|
|
1502
|
-
function nudgeSweeper(sweeper,
|
|
1503
|
-
try {
|
|
1504
|
-
|
|
1679
|
+
function nudgeSweeper(sweeper, sessions) {
|
|
1680
|
+
for (const session of sessions) try {
|
|
1681
|
+
sweeper?.nudge(session.streamId, session.source);
|
|
1682
|
+
} catch {}
|
|
1683
|
+
}
|
|
1684
|
+
async function flushBoundarySessions(sweeper, sessions) {
|
|
1685
|
+
for (const session of sessions) try {
|
|
1686
|
+
await sweeper?.flushBoundary(session.streamId, session.source);
|
|
1505
1687
|
} catch {}
|
|
1506
1688
|
}
|
|
1689
|
+
function isClaudeBoundaryEnvelope(envelope) {
|
|
1690
|
+
const record = envelope;
|
|
1691
|
+
if (String(record.source ?? "").trim().toLowerCase() !== "claude") return false;
|
|
1692
|
+
const payload = record.payload;
|
|
1693
|
+
if (payload == null || typeof payload !== "object" || Array.isArray(payload)) return false;
|
|
1694
|
+
const adapter = payload._adapter;
|
|
1695
|
+
if (adapter == null || typeof adapter !== "object" || Array.isArray(adapter)) return false;
|
|
1696
|
+
const meta = adapter.meta;
|
|
1697
|
+
if (meta == null || typeof meta !== "object" || Array.isArray(meta)) return false;
|
|
1698
|
+
const hookEventName = meta.hook_event_name;
|
|
1699
|
+
return hookEventName === "SessionEnd" || hookEventName === "Stop";
|
|
1700
|
+
}
|
|
1701
|
+
var SAFE_INGEST_VALIDATION_ERRORS = new Set([
|
|
1702
|
+
"source must be string",
|
|
1703
|
+
"source is required",
|
|
1704
|
+
"source must use 1-64 letters, digits, dots, underscores, or hyphens",
|
|
1705
|
+
"session_stream_id must be string",
|
|
1706
|
+
"session_id must be string",
|
|
1707
|
+
"stream_id must be string",
|
|
1708
|
+
"opencode_session_id must be string",
|
|
1709
|
+
"conflicting session id fields",
|
|
1710
|
+
"invalid session id",
|
|
1711
|
+
"event source conflicts with request source",
|
|
1712
|
+
"session id required",
|
|
1713
|
+
"event_type must be string",
|
|
1714
|
+
"event_type required",
|
|
1715
|
+
"event_type has invalid syntax",
|
|
1716
|
+
"event_id must be string",
|
|
1717
|
+
"event_id required",
|
|
1718
|
+
"event_id has invalid syntax",
|
|
1719
|
+
"event_seq must be int",
|
|
1720
|
+
"ts_wall_ms must be number",
|
|
1721
|
+
"ts_mono_ms must be number",
|
|
1722
|
+
"payload must be an object",
|
|
1723
|
+
"cwd must be string",
|
|
1724
|
+
"project must be string",
|
|
1725
|
+
"started_at must be string",
|
|
1726
|
+
"events must be a list",
|
|
1727
|
+
"event must be an object"
|
|
1728
|
+
]);
|
|
1729
|
+
function boundedIngestValidationMessage(error) {
|
|
1730
|
+
return SAFE_INGEST_VALIDATION_ERRORS.has(error.message) ? error.message : "invalid raw event request";
|
|
1731
|
+
}
|
|
1732
|
+
function boundedIngestErrorResponse(c, error) {
|
|
1733
|
+
if (error instanceof RawEventIngestValidationError) return c.json({ error: boundedIngestValidationMessage(error) }, 400);
|
|
1734
|
+
const response = { error: "internal server error" };
|
|
1735
|
+
if (process.env.CODEMEM_VIEWER_DEBUG === "1") response.detail = error instanceof Error ? error.message : String(error);
|
|
1736
|
+
return c.json(response, 500);
|
|
1737
|
+
}
|
|
1738
|
+
function untargetedPayload(payload) {
|
|
1739
|
+
const { db_path: _dbPath, identity_target: _identityTarget, ...body } = payload;
|
|
1740
|
+
return body;
|
|
1741
|
+
}
|
|
1742
|
+
async function ingestNormalizedEnvelope(store, sweeper, envelope, flushBoundary = false) {
|
|
1743
|
+
const result = ingestRawEvents(store, envelope);
|
|
1744
|
+
nudgeSweeper(sweeper, result.sessions);
|
|
1745
|
+
if (flushBoundary && isClaudeBoundaryEnvelope(envelope)) await flushBoundarySessions(sweeper, result.sessions);
|
|
1746
|
+
return result;
|
|
1747
|
+
}
|
|
1507
1748
|
function rawEventsRoutes(getStore, sweeper) {
|
|
1508
1749
|
const app = new Hono();
|
|
1750
|
+
const transcriptDiagnostics = createTranscriptDiagnostics();
|
|
1509
1751
|
app.get("/api/raw-events", (c) => {
|
|
1510
1752
|
const totals = getStore().rawEventBacklogTotals();
|
|
1511
1753
|
return c.json(totals);
|
|
@@ -1540,233 +1782,83 @@ function rawEventsRoutes(getStore, sweeper) {
|
|
|
1540
1782
|
available: true,
|
|
1541
1783
|
mode: "stream_queue",
|
|
1542
1784
|
max_body_bytes: MAX_RAW_EVENTS_BODY_BYTES
|
|
1543
|
-
}
|
|
1785
|
+
},
|
|
1786
|
+
transcript_diagnostics: transcriptDiagnostics.snapshot()
|
|
1544
1787
|
});
|
|
1545
1788
|
});
|
|
1546
1789
|
app.post("/api/raw-events", async (c) => {
|
|
1547
1790
|
const result = await parseJsonObjectBody(c, MAX_RAW_EVENTS_BODY_BYTES);
|
|
1548
1791
|
if (result instanceof Response) return result;
|
|
1549
|
-
const payload = result;
|
|
1550
|
-
const store = getStore();
|
|
1551
1792
|
try {
|
|
1552
|
-
const
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
const startedAt = payload.started_at;
|
|
1557
|
-
if (startedAt != null && typeof startedAt !== "string") return c.json({ error: "started_at must be string" }, 400);
|
|
1558
|
-
let items = payload.events;
|
|
1559
|
-
if (items == null) items = [payload];
|
|
1560
|
-
if (!Array.isArray(items)) return c.json({ error: "events must be a list" }, 400);
|
|
1561
|
-
let defaultSessionId;
|
|
1562
|
-
try {
|
|
1563
|
-
defaultSessionId = resolveSessionStreamId(payload) ?? "";
|
|
1564
|
-
} catch (err) {
|
|
1565
|
-
return c.json({ error: err.message }, 400);
|
|
1566
|
-
}
|
|
1567
|
-
if (defaultSessionId.startsWith("msg_")) return c.json({ error: "invalid session id" }, 400);
|
|
1568
|
-
let inserted = 0;
|
|
1569
|
-
const lastSeenBySession = /* @__PURE__ */ new Map();
|
|
1570
|
-
const metaBySession = /* @__PURE__ */ new Map();
|
|
1571
|
-
const sessionIds = /* @__PURE__ */ new Set();
|
|
1572
|
-
const batchBySession = /* @__PURE__ */ new Map();
|
|
1573
|
-
for (const item of items) {
|
|
1574
|
-
if (item == null || typeof item !== "object" || Array.isArray(item)) return c.json({ error: "event must be an object" }, 400);
|
|
1575
|
-
const itemObj = item;
|
|
1576
|
-
let itemSessionId;
|
|
1577
|
-
try {
|
|
1578
|
-
itemSessionId = resolveSessionStreamId(itemObj);
|
|
1579
|
-
} catch (err) {
|
|
1580
|
-
return c.json({ error: err.message }, 400);
|
|
1581
|
-
}
|
|
1582
|
-
const opencodeSessionId = String(itemSessionId ?? defaultSessionId ?? "");
|
|
1583
|
-
if (!opencodeSessionId) return c.json({ error: "session id required" }, 400);
|
|
1584
|
-
if (opencodeSessionId.startsWith("msg_")) return c.json({ error: "invalid session id" }, 400);
|
|
1585
|
-
let eventId = String(itemObj.event_id ?? "");
|
|
1586
|
-
const eventType = String(itemObj.event_type ?? "");
|
|
1587
|
-
if (!eventType) return c.json({ error: "event_type required" }, 400);
|
|
1588
|
-
const eventSeqValue = itemObj.event_seq;
|
|
1589
|
-
if (eventSeqValue != null) {
|
|
1590
|
-
const parsed = Number(eventSeqValue);
|
|
1591
|
-
if (!Number.isFinite(parsed) || parsed !== Math.floor(parsed)) return c.json({ error: "event_seq must be int" }, 400);
|
|
1592
|
-
}
|
|
1593
|
-
let tsWallMs = itemObj.ts_wall_ms;
|
|
1594
|
-
if (tsWallMs != null) {
|
|
1595
|
-
const parsed = Number(tsWallMs);
|
|
1596
|
-
if (!Number.isFinite(parsed)) return c.json({ error: "ts_wall_ms must be int" }, 400);
|
|
1597
|
-
tsWallMs = Math.floor(parsed);
|
|
1598
|
-
const prev = lastSeenBySession.get(opencodeSessionId) ?? tsWallMs;
|
|
1599
|
-
lastSeenBySession.set(opencodeSessionId, Math.max(prev, tsWallMs));
|
|
1600
|
-
}
|
|
1601
|
-
let tsMonoMs = itemObj.ts_mono_ms;
|
|
1602
|
-
if (tsMonoMs != null) {
|
|
1603
|
-
const parsed = Number(tsMonoMs);
|
|
1604
|
-
if (!Number.isFinite(parsed)) return c.json({ error: "ts_mono_ms must be number" }, 400);
|
|
1605
|
-
tsMonoMs = parsed;
|
|
1606
|
-
}
|
|
1607
|
-
let eventPayload = itemObj.payload;
|
|
1608
|
-
if (eventPayload == null) eventPayload = {};
|
|
1609
|
-
if (typeof eventPayload !== "object" || Array.isArray(eventPayload)) return c.json({ error: "payload must be an object" }, 400);
|
|
1610
|
-
const itemCwd = itemObj.cwd;
|
|
1611
|
-
if (itemCwd != null && typeof itemCwd !== "string") return c.json({ error: "cwd must be string" }, 400);
|
|
1612
|
-
const itemProject = itemObj.project;
|
|
1613
|
-
if (itemProject != null && typeof itemProject !== "string") return c.json({ error: "project must be string" }, 400);
|
|
1614
|
-
const itemStartedAt = itemObj.started_at;
|
|
1615
|
-
if (itemStartedAt != null && typeof itemStartedAt !== "string") return c.json({ error: "started_at must be string" }, 400);
|
|
1616
|
-
eventPayload = stripPrivateObj(eventPayload);
|
|
1617
|
-
if (!eventId) {
|
|
1618
|
-
const sortedStringify = (obj) => JSON.stringify(obj, (_key, value) => {
|
|
1619
|
-
if (value != null && typeof value === "object" && !Array.isArray(value)) {
|
|
1620
|
-
const sorted = {};
|
|
1621
|
-
for (const k of Object.keys(value).sort()) sorted[k] = value[k];
|
|
1622
|
-
return sorted;
|
|
1623
|
-
}
|
|
1624
|
-
return value;
|
|
1625
|
-
});
|
|
1626
|
-
if (eventSeqValue != null) {
|
|
1627
|
-
const rawId = sortedStringify({
|
|
1628
|
-
s: eventSeqValue,
|
|
1629
|
-
t: eventType,
|
|
1630
|
-
p: eventPayload
|
|
1631
|
-
});
|
|
1632
|
-
eventId = `legacy-seq-${eventSeqValue}-${createHash("sha256").update(rawId, "utf-8").digest("hex").slice(0, 16)}`;
|
|
1633
|
-
} else {
|
|
1634
|
-
const rawId = sortedStringify({
|
|
1635
|
-
m: tsMonoMs ?? null,
|
|
1636
|
-
p: eventPayload,
|
|
1637
|
-
t: eventType,
|
|
1638
|
-
w: tsWallMs ?? null
|
|
1639
|
-
});
|
|
1640
|
-
eventId = `legacy-${createHash("sha256").update(rawId, "utf-8").digest("hex").slice(0, 16)}`;
|
|
1641
|
-
}
|
|
1642
|
-
}
|
|
1643
|
-
const eventEntry = {
|
|
1644
|
-
event_id: eventId,
|
|
1645
|
-
event_type: eventType,
|
|
1646
|
-
payload: eventPayload,
|
|
1647
|
-
ts_wall_ms: tsWallMs ?? null,
|
|
1648
|
-
ts_mono_ms: tsMonoMs ?? null
|
|
1649
|
-
};
|
|
1650
|
-
sessionIds.add(opencodeSessionId);
|
|
1651
|
-
const list = batchBySession.get(opencodeSessionId) ?? [];
|
|
1652
|
-
list.push({ ...eventEntry });
|
|
1653
|
-
batchBySession.set(opencodeSessionId, list);
|
|
1654
|
-
if (itemCwd || itemProject || itemStartedAt) {
|
|
1655
|
-
const perSession = metaBySession.get(opencodeSessionId) ?? {};
|
|
1656
|
-
if (itemCwd) perSession.cwd = itemCwd;
|
|
1657
|
-
if (itemProject) perSession.project = itemProject;
|
|
1658
|
-
if (itemStartedAt) perSession.started_at = itemStartedAt;
|
|
1659
|
-
metaBySession.set(opencodeSessionId, perSession);
|
|
1660
|
-
}
|
|
1661
|
-
}
|
|
1662
|
-
if (sessionIds.size === 1) {
|
|
1663
|
-
const singleSessionId = sessionIds.values().next().value;
|
|
1664
|
-
const batch = batchBySession.get(singleSessionId) ?? [];
|
|
1665
|
-
inserted = store.recordRawEventsBatch(singleSessionId, batch).inserted;
|
|
1666
|
-
} else for (const [sid, sidEvents] of batchBySession) {
|
|
1667
|
-
const result = store.recordRawEventsBatch(sid, sidEvents);
|
|
1668
|
-
inserted += result.inserted;
|
|
1669
|
-
}
|
|
1670
|
-
for (const metaSessionId of sessionIds) {
|
|
1671
|
-
const sessionMeta = metaBySession.get(metaSessionId) ?? {};
|
|
1672
|
-
const applyRequestMeta = sessionIds.size === 1 || metaSessionId === defaultSessionId;
|
|
1673
|
-
store.updateRawEventSessionMeta({
|
|
1674
|
-
opencodeSessionId: metaSessionId,
|
|
1675
|
-
cwd: sessionMeta.cwd ?? (applyRequestMeta ? cwd : void 0) ?? null,
|
|
1676
|
-
project: sessionMeta.project ?? (applyRequestMeta ? project : void 0) ?? null,
|
|
1677
|
-
startedAt: sessionMeta.started_at ?? (applyRequestMeta ? startedAt : void 0) ?? null,
|
|
1678
|
-
lastSeenTsWallMs: lastSeenBySession.get(metaSessionId) ?? null
|
|
1679
|
-
});
|
|
1680
|
-
}
|
|
1681
|
-
nudgeSweeper(sweeper, sessionIds);
|
|
1793
|
+
const store = getStore();
|
|
1794
|
+
const target = validateViewerTarget(store, result, { requirePairedTargets: true });
|
|
1795
|
+
if (!target.ok) return c.json(target.body, target.status);
|
|
1796
|
+
const ingestResult = await ingestNormalizedEnvelope(store, sweeper, untargetedPayload(result), c.req.header("x-codemem-boundary-flush") === "1");
|
|
1682
1797
|
return c.json({
|
|
1683
|
-
inserted,
|
|
1684
|
-
|
|
1798
|
+
inserted: ingestResult.inserted,
|
|
1799
|
+
skipped: ingestResult.skipped,
|
|
1800
|
+
received: ingestResult.received
|
|
1685
1801
|
});
|
|
1686
1802
|
} catch (err) {
|
|
1687
|
-
|
|
1688
|
-
if (process.env.CODEMEM_VIEWER_DEBUG === "1") response.detail = err.message;
|
|
1689
|
-
return c.json(response, 500);
|
|
1803
|
+
return boundedIngestErrorResponse(c, err);
|
|
1690
1804
|
}
|
|
1691
1805
|
});
|
|
1692
1806
|
app.post("/api/claude-hooks", async (c) => {
|
|
1693
1807
|
const result = await parseJsonObjectBody(c, MAX_RAW_EVENTS_BODY_BYTES);
|
|
1694
1808
|
if (result instanceof Response) return result;
|
|
1695
|
-
const
|
|
1696
|
-
if (envelope === null) return c.json({
|
|
1697
|
-
inserted: 0,
|
|
1698
|
-
skipped: 1
|
|
1699
|
-
});
|
|
1700
|
-
const store = getStore();
|
|
1809
|
+
const payload = result;
|
|
1701
1810
|
try {
|
|
1702
|
-
const
|
|
1703
|
-
const
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
source,
|
|
1716
|
-
cwd: envelope.cwd,
|
|
1717
|
-
project: envelope.project,
|
|
1718
|
-
startedAt: envelope.started_at,
|
|
1719
|
-
lastSeenTsWallMs: envelope.ts_wall_ms
|
|
1811
|
+
const store = getStore();
|
|
1812
|
+
const target = validateViewerTarget(store, payload, { requirePairedTargets: true });
|
|
1813
|
+
if (!target.ok) return c.json(target.body, target.status);
|
|
1814
|
+
let transcriptOutcome = null;
|
|
1815
|
+
const envelope = buildRawEventEnvelopeFromHook(untargetedPayload(payload), {
|
|
1816
|
+
transcriptPolicy: {
|
|
1817
|
+
trust: "restricted",
|
|
1818
|
+
approvedRoots: [claudeTranscriptRoot()]
|
|
1819
|
+
},
|
|
1820
|
+
onTranscriptOutcome: (outcome) => {
|
|
1821
|
+
transcriptOutcome = outcome;
|
|
1822
|
+
transcriptDiagnostics.record("claude", outcome);
|
|
1823
|
+
}
|
|
1720
1824
|
});
|
|
1721
|
-
|
|
1825
|
+
if (envelope === null) return c.json(transcriptSkipResponse(transcriptOutcome));
|
|
1826
|
+
const ingestResult = await ingestNormalizedEnvelope(store, sweeper, envelope, c.req.header("x-codemem-boundary-flush") === "1");
|
|
1722
1827
|
return c.json({
|
|
1723
|
-
inserted: inserted
|
|
1724
|
-
skipped:
|
|
1828
|
+
inserted: ingestResult.inserted,
|
|
1829
|
+
skipped: ingestResult.skipped
|
|
1725
1830
|
});
|
|
1726
1831
|
} catch (err) {
|
|
1727
|
-
|
|
1728
|
-
if (process.env.CODEMEM_VIEWER_DEBUG === "1") response.detail = err.message;
|
|
1729
|
-
return c.json(response, 500);
|
|
1832
|
+
return boundedIngestErrorResponse(c, err);
|
|
1730
1833
|
}
|
|
1731
1834
|
});
|
|
1732
1835
|
app.post("/api/codex-hooks", async (c) => {
|
|
1733
1836
|
const result = await parseJsonObjectBody(c, MAX_RAW_EVENTS_BODY_BYTES);
|
|
1734
1837
|
if (result instanceof Response) return result;
|
|
1735
|
-
const
|
|
1736
|
-
if (envelope === null) return c.json({
|
|
1737
|
-
inserted: 0,
|
|
1738
|
-
skipped: 1
|
|
1739
|
-
});
|
|
1740
|
-
const store = getStore();
|
|
1838
|
+
const payload = result;
|
|
1741
1839
|
try {
|
|
1742
|
-
const
|
|
1743
|
-
const
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
source,
|
|
1756
|
-
cwd: envelope.cwd,
|
|
1757
|
-
project: envelope.project,
|
|
1758
|
-
startedAt: envelope.started_at,
|
|
1759
|
-
lastSeenTsWallMs: envelope.ts_wall_ms
|
|
1840
|
+
const store = getStore();
|
|
1841
|
+
const target = validateViewerTarget(store, payload, { requirePairedTargets: true });
|
|
1842
|
+
if (!target.ok) return c.json(target.body, target.status);
|
|
1843
|
+
let transcriptOutcome = null;
|
|
1844
|
+
const envelope = buildRawEventEnvelopeFromCodexHook(untargetedPayload(payload), {
|
|
1845
|
+
transcriptPolicy: {
|
|
1846
|
+
trust: "restricted",
|
|
1847
|
+
approvedRoots: [codexTranscriptRoot()]
|
|
1848
|
+
},
|
|
1849
|
+
onTranscriptOutcome: (outcome) => {
|
|
1850
|
+
transcriptOutcome = outcome;
|
|
1851
|
+
transcriptDiagnostics.record("codex", outcome);
|
|
1852
|
+
}
|
|
1760
1853
|
});
|
|
1761
|
-
|
|
1854
|
+
if (envelope === null) return c.json(transcriptSkipResponse(transcriptOutcome));
|
|
1855
|
+
const ingestResult = await ingestNormalizedEnvelope(store, sweeper, envelope);
|
|
1762
1856
|
return c.json({
|
|
1763
|
-
inserted: inserted
|
|
1764
|
-
skipped:
|
|
1857
|
+
inserted: ingestResult.inserted,
|
|
1858
|
+
skipped: ingestResult.skipped
|
|
1765
1859
|
});
|
|
1766
1860
|
} catch (err) {
|
|
1767
|
-
|
|
1768
|
-
if (process.env.CODEMEM_VIEWER_DEBUG === "1") response.detail = err.message;
|
|
1769
|
-
return c.json(response, 500);
|
|
1861
|
+
return boundedIngestErrorResponse(c, err);
|
|
1770
1862
|
}
|
|
1771
1863
|
});
|
|
1772
1864
|
return app;
|
|
@@ -2426,7 +2518,13 @@ function projectInviteAcceptanceFailure(error) {
|
|
|
2426
2518
|
inviter_identity_invalid: "The owner's device identity could not be verified. Ask the owner to review the share and create a new invitation.",
|
|
2427
2519
|
project_invite_self_acceptance_forbidden: "The owner cannot accept this recipient invitation on the owner's device.",
|
|
2428
2520
|
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.",
|
|
2429
|
-
project_invite_trust_state_invalid: "The owner's trust setup could not be verified. Ask the owner to review the share."
|
|
2521
|
+
project_invite_trust_state_invalid: "The owner's trust setup could not be verified. Ask the owner to review the share.",
|
|
2522
|
+
recipient_display_name_invalid: "Enter a human-readable Identity name instead of an internal identifier.",
|
|
2523
|
+
recipient_display_name_required: "Enter an Identity display name.",
|
|
2524
|
+
recipient_display_name_too_long: "Use an Identity display name with 120 characters or fewer.",
|
|
2525
|
+
device_display_name_invalid: "Enter a human-readable device name instead of an internal identifier.",
|
|
2526
|
+
device_display_name_required: "Enter a device display name.",
|
|
2527
|
+
device_display_name_too_long: "Use a device display name with 120 characters or fewer."
|
|
2430
2528
|
};
|
|
2431
2529
|
return {
|
|
2432
2530
|
error: Object.hasOwn(detailByCode, code) ? code : "project_invite_acceptance_failed",
|
|
@@ -2434,11 +2532,19 @@ function projectInviteAcceptanceFailure(error) {
|
|
|
2434
2532
|
};
|
|
2435
2533
|
}
|
|
2436
2534
|
var PROJECT_INVITE_TTL_HOURS = 168;
|
|
2535
|
+
var PROJECT_INVITE_OWNER_FALLBACK = "Project owner";
|
|
2437
2536
|
var PROJECT_INVITE_BODY_KEYS = new Set([
|
|
2438
2537
|
"teammate_name",
|
|
2439
2538
|
"project_ids",
|
|
2440
2539
|
"reviewed_project_set_digest"
|
|
2441
2540
|
]);
|
|
2541
|
+
function projectInviteInviterDisplayName(value) {
|
|
2542
|
+
try {
|
|
2543
|
+
return normalizeHumanPresentationName(value, "inviter_display_name");
|
|
2544
|
+
} catch {
|
|
2545
|
+
return PROJECT_INVITE_OWNER_FALLBACK;
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2442
2548
|
function projectInviteStringList(body, key) {
|
|
2443
2549
|
const value = body[key];
|
|
2444
2550
|
if (!Array.isArray(value)) throw new Error(`${key}_must_be_string_array`);
|
|
@@ -2565,10 +2671,16 @@ function recipientInviteOnboardingPreviewFromReviewedIntent(store, body, invitat
|
|
|
2565
2671
|
});
|
|
2566
2672
|
}
|
|
2567
2673
|
var SAFE_RECIPIENT_INVITE_ERRORS = new Set([
|
|
2674
|
+
"device_display_name_invalid",
|
|
2675
|
+
"device_display_name_required",
|
|
2676
|
+
"device_display_name_too_long",
|
|
2568
2677
|
"invite_already_bound",
|
|
2569
2678
|
"invite_expired",
|
|
2570
2679
|
"invite_identity_conflict",
|
|
2571
2680
|
"invite_invalid",
|
|
2681
|
+
"recipient_display_name_invalid",
|
|
2682
|
+
"recipient_display_name_required",
|
|
2683
|
+
"recipient_display_name_too_long",
|
|
2572
2684
|
"recipient_invite_intent_mismatch",
|
|
2573
2685
|
"recipient_invite_review_unavailable",
|
|
2574
2686
|
"reviewed_onboarding_stale"
|
|
@@ -2594,8 +2706,9 @@ async function peerSupportsSyncRequirements(store, deviceId, requirements) {
|
|
|
2594
2706
|
const url = `${buildBaseUrl(address)}/v1/status`;
|
|
2595
2707
|
const [status, payload] = await requestJson("GET", url, {
|
|
2596
2708
|
headers: {
|
|
2597
|
-
...
|
|
2709
|
+
...buildDirectPeerAuthHeaders({
|
|
2598
2710
|
deviceId: localDeviceId,
|
|
2711
|
+
recipientId: deviceId,
|
|
2599
2712
|
dbPath: store.dbPath,
|
|
2600
2713
|
method: "GET",
|
|
2601
2714
|
url,
|
|
@@ -3014,13 +3127,13 @@ var TERMINAL_RECONCILIATION_ERRORS = new Set([
|
|
|
3014
3127
|
"operation_not_found",
|
|
3015
3128
|
...PROJECT_INVITE_OWNER_CONFLICT_ERRORS
|
|
3016
3129
|
]);
|
|
3017
|
-
function errorStatus(error) {
|
|
3130
|
+
function errorStatus$1(error) {
|
|
3018
3131
|
const value = error && typeof error === "object" ? error.status : null;
|
|
3019
3132
|
return typeof value === "number" && Number.isInteger(value) && value >= 400 && value <= 599 ? value : null;
|
|
3020
3133
|
}
|
|
3021
3134
|
function projectInviteOwnerErrorResponse(error, fallback) {
|
|
3022
3135
|
const message = error instanceof Error ? error.message : "";
|
|
3023
|
-
const status = errorStatus(error);
|
|
3136
|
+
const status = errorStatus$1(error);
|
|
3024
3137
|
if (message === "operation_not_found" && (status == null || status === 404)) return {
|
|
3025
3138
|
code: message,
|
|
3026
3139
|
status: 404
|
|
@@ -3042,7 +3155,7 @@ function projectInviteOwnerErrorResponse(error, fallback) {
|
|
|
3042
3155
|
function isRetryableReconciliationError(error) {
|
|
3043
3156
|
const message = error instanceof Error ? error.message : String(error);
|
|
3044
3157
|
if (TERMINAL_RECONCILIATION_ERRORS.has(message)) return false;
|
|
3045
|
-
const status = errorStatus(error);
|
|
3158
|
+
const status = errorStatus$1(error);
|
|
3046
3159
|
return status == null || status === 408 || status === 429 || status >= 500;
|
|
3047
3160
|
}
|
|
3048
3161
|
function safeReconciliationErrorCode(error) {
|
|
@@ -3672,14 +3785,15 @@ function pathWithQuery(url) {
|
|
|
3672
3785
|
const parsed = new URL(url);
|
|
3673
3786
|
return parsed.search ? `${parsed.pathname}${parsed.search}` : parsed.pathname;
|
|
3674
3787
|
}
|
|
3675
|
-
function unauthorizedPayload(
|
|
3676
|
-
if (exposeReason && process.env.CODEMEM_SYNC_AUTH_DIAGNOSTICS === "1") return {
|
|
3677
|
-
error: "unauthorized",
|
|
3678
|
-
reason
|
|
3679
|
-
};
|
|
3788
|
+
function unauthorizedPayload() {
|
|
3680
3789
|
return { error: "unauthorized" };
|
|
3681
3790
|
}
|
|
3682
3791
|
var SYNC_AUTH_STORE_BUSY_REASON = "sync_auth_store_busy";
|
|
3792
|
+
var DIRECT_PEER_SIGNATURE_VERSION_NUMBER = 3;
|
|
3793
|
+
function logDirectPeerAuthDiagnostic(...reasons) {
|
|
3794
|
+
const reason = reasons.find((candidate) => candidate === "recipient_mismatch" || candidate === "signature_downgrade");
|
|
3795
|
+
if (reason) console.warn(`[sync] peer auth failed: reason=${reason}`);
|
|
3796
|
+
}
|
|
3683
3797
|
function isSqliteBusy(err) {
|
|
3684
3798
|
if (!err || typeof err !== "object") return false;
|
|
3685
3799
|
const code = "code" in err ? String(err.code ?? "") : "";
|
|
@@ -3716,6 +3830,76 @@ function syncAuthStoreBusyResponse(c) {
|
|
|
3716
3830
|
c.header("Retry-After", "1");
|
|
3717
3831
|
return c.json({ error: SYNC_AUTH_STORE_BUSY_REASON }, 503);
|
|
3718
3832
|
}
|
|
3833
|
+
function verifyDirectPeerRequestSignature(request, body, options) {
|
|
3834
|
+
const timestamp = request.header("X-Opencode-Timestamp") ?? "";
|
|
3835
|
+
const nonce = request.header("X-Opencode-Nonce") ?? "";
|
|
3836
|
+
const legacySignature = request.header("X-Opencode-Signature") ?? "";
|
|
3837
|
+
const recipientHeader = request.header("X-Codemem-Recipient");
|
|
3838
|
+
const directSignatureHeader = request.header("X-Codemem-Signature");
|
|
3839
|
+
const hasDirectMaterial = recipientHeader !== void 0 || directSignatureHeader !== void 0;
|
|
3840
|
+
const direct = verifyDirectPeerSignature({
|
|
3841
|
+
method: request.method,
|
|
3842
|
+
pathWithQuery: pathWithQuery(request.url),
|
|
3843
|
+
bodyBytes: body,
|
|
3844
|
+
timestamp,
|
|
3845
|
+
nonce,
|
|
3846
|
+
recipientId: recipientHeader,
|
|
3847
|
+
expectedRecipientId: options.expectedRecipientId,
|
|
3848
|
+
signature: directSignatureHeader,
|
|
3849
|
+
publicKey: options.publicKey
|
|
3850
|
+
});
|
|
3851
|
+
if (hasDirectMaterial && direct.status === "absent") return {
|
|
3852
|
+
ok: false,
|
|
3853
|
+
reason: "invalid_direct_signature"
|
|
3854
|
+
};
|
|
3855
|
+
if (direct.status === "invalid") return {
|
|
3856
|
+
ok: false,
|
|
3857
|
+
reason: direct.reason === "recipient_mismatch" ? direct.reason : "invalid_direct_signature"
|
|
3858
|
+
};
|
|
3859
|
+
if (!verifySignature({
|
|
3860
|
+
method: request.method,
|
|
3861
|
+
pathWithQuery: pathWithQuery(request.url),
|
|
3862
|
+
bodyBytes: body,
|
|
3863
|
+
timestamp,
|
|
3864
|
+
nonce,
|
|
3865
|
+
signature: legacySignature,
|
|
3866
|
+
publicKey: options.publicKey,
|
|
3867
|
+
deviceId: options.deviceId
|
|
3868
|
+
})) return {
|
|
3869
|
+
ok: false,
|
|
3870
|
+
reason: "invalid_signature"
|
|
3871
|
+
};
|
|
3872
|
+
if (direct.status === "absent" && (options.highestObservedVersion ?? 0) >= DIRECT_PEER_SIGNATURE_VERSION_NUMBER) return {
|
|
3873
|
+
ok: false,
|
|
3874
|
+
reason: "signature_downgrade"
|
|
3875
|
+
};
|
|
3876
|
+
return {
|
|
3877
|
+
ok: true,
|
|
3878
|
+
version: direct.status === "valid" ? 3 : 2
|
|
3879
|
+
};
|
|
3880
|
+
}
|
|
3881
|
+
function recordDirectPeerSignatureVersion(store, deviceId, version) {
|
|
3882
|
+
try {
|
|
3883
|
+
if (!recordHighestObservedDirectSignatureVersion(store.db, deviceId, version)) {
|
|
3884
|
+
if (store.db.prepare(`SELECT sync_peer_signature_state.highest_observed_direct_signature_version
|
|
3885
|
+
FROM sync_peers
|
|
3886
|
+
LEFT JOIN sync_peer_signature_state USING (peer_device_id)
|
|
3887
|
+
WHERE sync_peers.peer_device_id = ? LIMIT 1`).pluck().get(deviceId) === void 0) return {
|
|
3888
|
+
ok: false,
|
|
3889
|
+
reason: "unknown_peer",
|
|
3890
|
+
deviceId
|
|
3891
|
+
};
|
|
3892
|
+
}
|
|
3893
|
+
return null;
|
|
3894
|
+
} catch (err) {
|
|
3895
|
+
if (isSqliteBusy(err)) return {
|
|
3896
|
+
ok: false,
|
|
3897
|
+
reason: SYNC_AUTH_STORE_BUSY_REASON,
|
|
3898
|
+
deviceId
|
|
3899
|
+
};
|
|
3900
|
+
throw err;
|
|
3901
|
+
}
|
|
3902
|
+
}
|
|
3719
3903
|
function authorizeSyncRequest(store, request, body) {
|
|
3720
3904
|
const deviceId = (request.header("X-Opencode-Device") ?? "").trim();
|
|
3721
3905
|
const signature = request.header("X-Opencode-Signature") ?? "";
|
|
@@ -3726,7 +3910,11 @@ function authorizeSyncRequest(store, request, body) {
|
|
|
3726
3910
|
reason: "missing_headers",
|
|
3727
3911
|
deviceId
|
|
3728
3912
|
};
|
|
3729
|
-
const peerRow = store.db.prepare(
|
|
3913
|
+
const peerRow = store.db.prepare(`SELECT sync_peers.pinned_fingerprint, sync_peers.public_key,
|
|
3914
|
+
sync_peer_signature_state.highest_observed_direct_signature_version
|
|
3915
|
+
FROM sync_peers
|
|
3916
|
+
LEFT JOIN sync_peer_signature_state USING (peer_device_id)
|
|
3917
|
+
WHERE sync_peers.peer_device_id = ? LIMIT 1`).get(deviceId);
|
|
3730
3918
|
if (!peerRow) return {
|
|
3731
3919
|
ok: false,
|
|
3732
3920
|
reason: "unknown_peer",
|
|
@@ -3744,17 +3932,14 @@ function authorizeSyncRequest(store, request, body) {
|
|
|
3744
3932
|
reason: "fingerprint_mismatch",
|
|
3745
3933
|
deviceId
|
|
3746
3934
|
};
|
|
3747
|
-
let
|
|
3935
|
+
let verification;
|
|
3748
3936
|
try {
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
bodyBytes: body,
|
|
3753
|
-
timestamp,
|
|
3754
|
-
nonce,
|
|
3755
|
-
signature,
|
|
3937
|
+
const [localDeviceId] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
|
|
3938
|
+
verification = verifyDirectPeerRequestSignature(request, body, {
|
|
3939
|
+
deviceId,
|
|
3756
3940
|
publicKey,
|
|
3757
|
-
|
|
3941
|
+
expectedRecipientId: localDeviceId,
|
|
3942
|
+
highestObservedVersion: peerRow.highest_observed_direct_signature_version
|
|
3758
3943
|
});
|
|
3759
3944
|
} catch {
|
|
3760
3945
|
return {
|
|
@@ -3763,13 +3948,17 @@ function authorizeSyncRequest(store, request, body) {
|
|
|
3763
3948
|
deviceId
|
|
3764
3949
|
};
|
|
3765
3950
|
}
|
|
3766
|
-
if (!
|
|
3951
|
+
if (!verification.ok) return {
|
|
3767
3952
|
ok: false,
|
|
3768
|
-
reason:
|
|
3953
|
+
reason: verification.reason,
|
|
3769
3954
|
deviceId
|
|
3770
3955
|
};
|
|
3771
3956
|
const nonceResult = recordSyncAuthNonce(store, deviceId, nonce, (/* @__PURE__ */ new Date()).toISOString());
|
|
3772
3957
|
if (nonceResult) return nonceResult;
|
|
3958
|
+
if (verification.version === DIRECT_PEER_SIGNATURE_VERSION_NUMBER) {
|
|
3959
|
+
const versionResult = recordDirectPeerSignatureVersion(store, deviceId, verification.version);
|
|
3960
|
+
if (versionResult) return versionResult;
|
|
3961
|
+
}
|
|
3773
3962
|
return {
|
|
3774
3963
|
ok: true,
|
|
3775
3964
|
reason: "ok",
|
|
@@ -3888,17 +4077,15 @@ async function authorizeBootstrapGrantRequest(store, request, body) {
|
|
|
3888
4077
|
reason: "bootstrap_grant_expired",
|
|
3889
4078
|
deviceId
|
|
3890
4079
|
};
|
|
3891
|
-
|
|
4080
|
+
const existingPeer = store.db.prepare(`SELECT highest_observed_direct_signature_version
|
|
4081
|
+
FROM sync_peer_signature_state WHERE peer_device_id = ? LIMIT 1`).get(deviceId);
|
|
4082
|
+
let signatureVerification;
|
|
3892
4083
|
try {
|
|
3893
|
-
|
|
3894
|
-
|
|
3895
|
-
pathWithQuery: pathWithQuery(request.url),
|
|
3896
|
-
bodyBytes: body,
|
|
3897
|
-
timestamp,
|
|
3898
|
-
nonce,
|
|
3899
|
-
signature,
|
|
4084
|
+
signatureVerification = verifyDirectPeerRequestSignature(request, body, {
|
|
4085
|
+
deviceId,
|
|
3900
4086
|
publicKey: workerPublicKey,
|
|
3901
|
-
|
|
4087
|
+
expectedRecipientId: localDeviceId,
|
|
4088
|
+
highestObservedVersion: existingPeer?.highest_observed_direct_signature_version ?? null
|
|
3902
4089
|
});
|
|
3903
4090
|
} catch {
|
|
3904
4091
|
return {
|
|
@@ -3907,9 +4094,9 @@ async function authorizeBootstrapGrantRequest(store, request, body) {
|
|
|
3907
4094
|
deviceId
|
|
3908
4095
|
};
|
|
3909
4096
|
}
|
|
3910
|
-
if (!
|
|
4097
|
+
if (!signatureVerification.ok) return {
|
|
3911
4098
|
ok: false,
|
|
3912
|
-
reason:
|
|
4099
|
+
reason: signatureVerification.reason,
|
|
3913
4100
|
deviceId
|
|
3914
4101
|
};
|
|
3915
4102
|
if (!isPeerTrustBindingCompatible(store.db, deviceId, workerPublicKey, workerFingerprint)) return {
|
|
@@ -3925,6 +4112,10 @@ async function authorizeBootstrapGrantRequest(store, request, body) {
|
|
|
3925
4112
|
publicKey: workerPublicKey,
|
|
3926
4113
|
replaceTrust: true
|
|
3927
4114
|
});
|
|
4115
|
+
if (signatureVerification.version === DIRECT_PEER_SIGNATURE_VERSION_NUMBER) {
|
|
4116
|
+
const versionResult = recordDirectPeerSignatureVersion(store, deviceId, signatureVerification.version);
|
|
4117
|
+
if (versionResult) return versionResult;
|
|
4118
|
+
}
|
|
3928
4119
|
return {
|
|
3929
4120
|
ok: true,
|
|
3930
4121
|
reason: "ok",
|
|
@@ -4221,7 +4412,8 @@ async function acceptDiscoveredPeer(store, input) {
|
|
|
4221
4412
|
const addressesJson = JSON.stringify(mergeAddresses(nextAddresses, existingAddresses));
|
|
4222
4413
|
await createCoordinatorReciprocalApproval(store, config, {
|
|
4223
4414
|
groupId,
|
|
4224
|
-
requestedDeviceId: input.peerDeviceId
|
|
4415
|
+
requestedDeviceId: input.peerDeviceId,
|
|
4416
|
+
...input.expectedIncomingRequestId ? { expectedIncomingRequestId: input.expectedIncomingRequestId } : {}
|
|
4225
4417
|
});
|
|
4226
4418
|
const coordinatorUrl = config.syncCoordinatorUrl || null;
|
|
4227
4419
|
let scopeInclude;
|
|
@@ -4832,8 +5024,8 @@ function syncProtocolRoutes(getStore, opts = {}) {
|
|
|
4832
5024
|
if (!auth.ok) {
|
|
4833
5025
|
const grantPresent = Boolean((c.req.header("X-Codemem-Bootstrap-Grant") ?? "").trim());
|
|
4834
5026
|
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"))}`);
|
|
4835
|
-
|
|
4836
|
-
return (preauthChecked ? null : rateLimitedResponse(c, c.req.path, false)) ?? c.json(unauthorizedPayload(
|
|
5027
|
+
else logDirectPeerAuthDiagnostic(deviceAuthReason, auth.reason);
|
|
5028
|
+
return (preauthChecked ? null : rateLimitedResponse(c, c.req.path, false)) ?? c.json(unauthorizedPayload(), 401);
|
|
4837
5029
|
}
|
|
4838
5030
|
const limited = rateLimitedResponse(c, auth.deviceId, true);
|
|
4839
5031
|
if (limited) return limited;
|
|
@@ -4868,7 +5060,10 @@ function syncProtocolRoutes(getStore, opts = {}) {
|
|
|
4868
5060
|
const store = getStore();
|
|
4869
5061
|
const auth = authorizeSyncRequest(store, c.req, Buffer.alloc(0));
|
|
4870
5062
|
if (isSyncAuthStoreBusy(auth)) return syncAuthStoreBusyResponse(c);
|
|
4871
|
-
if (!auth.ok)
|
|
5063
|
+
if (!auth.ok) {
|
|
5064
|
+
logDirectPeerAuthDiagnostic(auth.reason);
|
|
5065
|
+
return rateLimitedResponse(c, c.req.path, false) ?? c.json(unauthorizedPayload(), 401);
|
|
5066
|
+
}
|
|
4872
5067
|
const limited = rateLimitedResponse(c, auth.deviceId, true);
|
|
4873
5068
|
if (limited) return limited;
|
|
4874
5069
|
const peerDeviceId = auth.deviceId;
|
|
@@ -4949,8 +5144,8 @@ function syncProtocolRoutes(getStore, opts = {}) {
|
|
|
4949
5144
|
if (!auth.ok) {
|
|
4950
5145
|
const grantPresent = Boolean((c.req.header("X-Codemem-Bootstrap-Grant") ?? "").trim());
|
|
4951
5146
|
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"))}`);
|
|
4952
|
-
|
|
4953
|
-
return (preauthChecked ? null : rateLimitedResponse(c, c.req.path, false)) ?? c.json(unauthorizedPayload(
|
|
5147
|
+
else logDirectPeerAuthDiagnostic(deviceAuthReason, auth.reason);
|
|
5148
|
+
return (preauthChecked ? null : rateLimitedResponse(c, c.req.path, false)) ?? c.json(unauthorizedPayload(), 401);
|
|
4954
5149
|
}
|
|
4955
5150
|
const limited = rateLimitedResponse(c, auth.deviceId, true);
|
|
4956
5151
|
if (limited) return limited;
|
|
@@ -5007,7 +5202,10 @@ function syncProtocolRoutes(getStore, opts = {}) {
|
|
|
5007
5202
|
if (raw == null) return c.json({ error: "payload_too_large" }, 413);
|
|
5008
5203
|
const auth = authorizeSyncRequest(store, c.req, raw);
|
|
5009
5204
|
if (isSyncAuthStoreBusy(auth)) return syncAuthStoreBusyResponse(c);
|
|
5010
|
-
if (!auth.ok)
|
|
5205
|
+
if (!auth.ok) {
|
|
5206
|
+
logDirectPeerAuthDiagnostic(auth.reason);
|
|
5207
|
+
return rateLimitedResponse(c, c.req.path, false) ?? c.json(unauthorizedPayload(), 401);
|
|
5208
|
+
}
|
|
5011
5209
|
const limited = rateLimitedResponse(c, auth.deviceId, true);
|
|
5012
5210
|
if (limited) return limited;
|
|
5013
5211
|
const peerDeviceId = auth.deviceId;
|
|
@@ -5071,6 +5269,52 @@ function syncProtocolRoutes(getStore, opts = {}) {
|
|
|
5071
5269
|
});
|
|
5072
5270
|
return app;
|
|
5073
5271
|
}
|
|
5272
|
+
async function loadConfiguredDeviceIdentityCoordinatorEvidence() {
|
|
5273
|
+
const config = readCoordinatorSyncConfig();
|
|
5274
|
+
const hasCoordinatorConfiguration = config.syncCoordinatorUrl.length > 0 || config.syncCoordinatorGroups.length > 0;
|
|
5275
|
+
const remoteUrl = config.syncCoordinatorUrl.trim();
|
|
5276
|
+
const adminSecret = config.syncCoordinatorAdminSecret.trim();
|
|
5277
|
+
const groupIds = [...new Set(config.syncCoordinatorGroups.map((groupId) => groupId.trim()))].filter(Boolean);
|
|
5278
|
+
if (!hasCoordinatorConfiguration) return {
|
|
5279
|
+
availability: "unavailable",
|
|
5280
|
+
safeErrorCode: "coordinator_not_configured",
|
|
5281
|
+
enrollments: []
|
|
5282
|
+
};
|
|
5283
|
+
if (!remoteUrl || !adminSecret || groupIds.length === 0) return {
|
|
5284
|
+
availability: "unavailable",
|
|
5285
|
+
safeErrorCode: "coordinator_unavailable",
|
|
5286
|
+
enrollments: []
|
|
5287
|
+
};
|
|
5288
|
+
if (groupIds.length > 25) return {
|
|
5289
|
+
availability: "unavailable",
|
|
5290
|
+
safeErrorCode: "coordinator_evidence_too_large",
|
|
5291
|
+
enrollments: []
|
|
5292
|
+
};
|
|
5293
|
+
try {
|
|
5294
|
+
const enrollments = (await Promise.all(groupIds.map((groupId) => coordinatorListDevicesAction({
|
|
5295
|
+
groupId,
|
|
5296
|
+
includeDisabled: true,
|
|
5297
|
+
remoteUrl,
|
|
5298
|
+
adminSecret
|
|
5299
|
+
})))).flat();
|
|
5300
|
+
if (enrollments.length > 500) return {
|
|
5301
|
+
availability: "unavailable",
|
|
5302
|
+
safeErrorCode: "coordinator_evidence_too_large",
|
|
5303
|
+
enrollments: []
|
|
5304
|
+
};
|
|
5305
|
+
return {
|
|
5306
|
+
availability: "available",
|
|
5307
|
+
safeErrorCode: null,
|
|
5308
|
+
enrollments
|
|
5309
|
+
};
|
|
5310
|
+
} catch {
|
|
5311
|
+
return {
|
|
5312
|
+
availability: "unavailable",
|
|
5313
|
+
safeErrorCode: "coordinator_unavailable",
|
|
5314
|
+
enrollments: []
|
|
5315
|
+
};
|
|
5316
|
+
}
|
|
5317
|
+
}
|
|
5074
5318
|
/**
|
|
5075
5319
|
* Viewer-facing sync management routes (/api/sync/*).
|
|
5076
5320
|
*
|
|
@@ -5078,8 +5322,112 @@ function syncProtocolRoutes(getStore, opts = {}) {
|
|
|
5078
5322
|
* provide sync status, peer management, and coordinator UI for the
|
|
5079
5323
|
* local viewer.
|
|
5080
5324
|
*/
|
|
5081
|
-
function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
5325
|
+
function syncRoutes(getStore, getSyncRuntimeStatus, options = {}) {
|
|
5082
5326
|
const app = new Hono();
|
|
5327
|
+
const loadCoordinatorEvidence = options.loadDeviceIdentityCoordinatorEvidence ?? loadConfiguredDeviceIdentityCoordinatorEvidence;
|
|
5328
|
+
const currentCoordinatorEvidence = async () => {
|
|
5329
|
+
try {
|
|
5330
|
+
return await loadCoordinatorEvidence();
|
|
5331
|
+
} catch {
|
|
5332
|
+
return {
|
|
5333
|
+
availability: "unavailable",
|
|
5334
|
+
safeErrorCode: "coordinator_unavailable",
|
|
5335
|
+
enrollments: []
|
|
5336
|
+
};
|
|
5337
|
+
}
|
|
5338
|
+
};
|
|
5339
|
+
const localInventoryInput = async (store) => {
|
|
5340
|
+
let explicitDeviceName = "";
|
|
5341
|
+
try {
|
|
5342
|
+
explicitDeviceName = String(readCodememConfigFile().sync_device_name ?? "");
|
|
5343
|
+
} catch {
|
|
5344
|
+
explicitDeviceName = "";
|
|
5345
|
+
}
|
|
5346
|
+
return {
|
|
5347
|
+
localDeviceId: store.deviceId,
|
|
5348
|
+
localDisplayName: friendlyDeviceName({
|
|
5349
|
+
explicitName: explicitDeviceName,
|
|
5350
|
+
osName: hostname(),
|
|
5351
|
+
fallbackSeed: store.deviceId
|
|
5352
|
+
}),
|
|
5353
|
+
coordinator: await currentCoordinatorEvidence()
|
|
5354
|
+
};
|
|
5355
|
+
};
|
|
5356
|
+
app.get("/api/sync/recipient-policy/v1/device-inventory", async (c) => {
|
|
5357
|
+
const store = getStore();
|
|
5358
|
+
ensureStableStoreIdentity(store);
|
|
5359
|
+
return c.json(listDeviceIdentityInventory(store.db, await localInventoryInput(store)));
|
|
5360
|
+
});
|
|
5361
|
+
const parseDeviceBindingRequest = (value, commit) => {
|
|
5362
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
5363
|
+
const body = value;
|
|
5364
|
+
const allowed = new Set(commit ? ["bindings", "reviewedInventoryDigest"] : ["bindings"]);
|
|
5365
|
+
if (Object.keys(body).some((key) => !allowed.has(key)) || !Array.isArray(body.bindings)) return null;
|
|
5366
|
+
const bindings = body.bindings.map((value) => {
|
|
5367
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
5368
|
+
const binding = value;
|
|
5369
|
+
const bindingKeys = new Set([
|
|
5370
|
+
"deviceId",
|
|
5371
|
+
"targetIdentityId",
|
|
5372
|
+
"confirmed",
|
|
5373
|
+
"allowRebind"
|
|
5374
|
+
]);
|
|
5375
|
+
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;
|
|
5376
|
+
return {
|
|
5377
|
+
deviceId: binding.deviceId,
|
|
5378
|
+
targetIdentityId: binding.targetIdentityId,
|
|
5379
|
+
confirmed: binding.confirmed,
|
|
5380
|
+
...typeof binding.allowRebind === "boolean" ? { allowRebind: binding.allowRebind } : {}
|
|
5381
|
+
};
|
|
5382
|
+
});
|
|
5383
|
+
if (bindings.some((binding) => binding == null)) return null;
|
|
5384
|
+
if (commit && typeof body.reviewedInventoryDigest !== "string") return null;
|
|
5385
|
+
return {
|
|
5386
|
+
bindings,
|
|
5387
|
+
...commit ? { reviewedInventoryDigest: body.reviewedInventoryDigest } : {}
|
|
5388
|
+
};
|
|
5389
|
+
};
|
|
5390
|
+
app.post("/api/sync/recipient-policy/v1/device-bindings/preview", async (c) => {
|
|
5391
|
+
const body = parseDeviceBindingRequest(await parseViewerJsonBody(c), false);
|
|
5392
|
+
if (!body) return c.json({ error: "binding_request_invalid" }, 400);
|
|
5393
|
+
const store = getStore();
|
|
5394
|
+
try {
|
|
5395
|
+
ensureStableStoreIdentity(store);
|
|
5396
|
+
const result = previewDeviceIdentityBindings(store.db, await localInventoryInput(store), body);
|
|
5397
|
+
if (result.status === "invalid") return c.json(result, 400);
|
|
5398
|
+
if (result.status === "not_found") return c.json(result, 404);
|
|
5399
|
+
if (result.status === "conflict") return c.json(result, 409);
|
|
5400
|
+
return c.json(result);
|
|
5401
|
+
} catch (error) {
|
|
5402
|
+
if (isSqliteBusy(error)) {
|
|
5403
|
+
c.header("Retry-After", "1");
|
|
5404
|
+
return c.json({ error: "binding_preview_busy" }, 503);
|
|
5405
|
+
}
|
|
5406
|
+
return c.json({ error: "binding_preview_failed" }, 500);
|
|
5407
|
+
}
|
|
5408
|
+
});
|
|
5409
|
+
app.post("/api/sync/recipient-policy/v1/device-bindings/commit", async (c) => {
|
|
5410
|
+
const body = parseDeviceBindingRequest(await parseViewerJsonBody(c), true);
|
|
5411
|
+
if (!body || !("reviewedInventoryDigest" in body)) return c.json({ error: "binding_request_invalid" }, 400);
|
|
5412
|
+
const store = getStore();
|
|
5413
|
+
try {
|
|
5414
|
+
ensureStableStoreIdentity(store);
|
|
5415
|
+
const result = commitDeviceIdentityBindings(store.db, {
|
|
5416
|
+
localActorId: store.actorId,
|
|
5417
|
+
localDeviceId: store.deviceId
|
|
5418
|
+
}, await localInventoryInput(store), body);
|
|
5419
|
+
if (result.status === "invalid") return c.json(result, 400);
|
|
5420
|
+
if (result.status === "not_found") return c.json(result, 404);
|
|
5421
|
+
if (result.status === "stale" || result.status === "conflict") return c.json(result, 409);
|
|
5422
|
+
return c.json(result);
|
|
5423
|
+
} catch (error) {
|
|
5424
|
+
if (isSqliteBusy(error)) {
|
|
5425
|
+
c.header("Retry-After", "1");
|
|
5426
|
+
return c.json({ error: "binding_commit_busy" }, 503);
|
|
5427
|
+
}
|
|
5428
|
+
return c.json({ error: "binding_commit_failed" }, 500);
|
|
5429
|
+
}
|
|
5430
|
+
});
|
|
5083
5431
|
app.get("/api/sync/status", async (c) => {
|
|
5084
5432
|
const store = getStore();
|
|
5085
5433
|
{
|
|
@@ -5392,9 +5740,15 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
5392
5740
|
return c.json({ error: "invalid json" }, 400);
|
|
5393
5741
|
}
|
|
5394
5742
|
const peerDeviceId = String(body.peer_device_id ?? "").trim();
|
|
5395
|
-
const
|
|
5743
|
+
const rawName = String(body.name ?? "");
|
|
5396
5744
|
if (!peerDeviceId) return c.json({ error: "peer_device_id required" }, 400);
|
|
5397
|
-
if (!
|
|
5745
|
+
if (!rawName.trim()) return c.json({ error: "name required" }, 400);
|
|
5746
|
+
let name;
|
|
5747
|
+
try {
|
|
5748
|
+
name = normalizeHumanPresentationName(rawName, "name");
|
|
5749
|
+
} catch (error) {
|
|
5750
|
+
return c.json({ error: error instanceof Error ? error.message : "name_invalid" }, 400);
|
|
5751
|
+
}
|
|
5398
5752
|
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);
|
|
5399
5753
|
d.update(schema.syncPeers).set({ name }).where(eq(schema.syncPeers.peer_device_id, peerDeviceId)).run();
|
|
5400
5754
|
return c.json({ ok: true });
|
|
@@ -5623,10 +5977,18 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
5623
5977
|
if (!value || typeof value !== "object" || Array.isArray(value)) return c.json({ error: "request_invalid" }, 400);
|
|
5624
5978
|
const body = value;
|
|
5625
5979
|
if (Object.keys(body).some((key) => key !== "dryRun") || Object.hasOwn(body, "dryRun") && typeof body.dryRun !== "boolean") return c.json({ error: "request_invalid" }, 400);
|
|
5626
|
-
|
|
5627
|
-
|
|
5628
|
-
|
|
5629
|
-
|
|
5980
|
+
try {
|
|
5981
|
+
return c.json(migrateRecipientPolicyIntent(store.db, {
|
|
5982
|
+
localActorId: store.actorId,
|
|
5983
|
+
localDeviceId: store.deviceId
|
|
5984
|
+
}, { dryRun: body.dryRun === true }));
|
|
5985
|
+
} catch (error) {
|
|
5986
|
+
if (isSqliteBusy(error)) {
|
|
5987
|
+
c.header("Retry-After", "1");
|
|
5988
|
+
return c.json({ error: "migration_busy" }, 503);
|
|
5989
|
+
}
|
|
5990
|
+
return c.json({ error: "migration_failed" }, 500);
|
|
5991
|
+
}
|
|
5630
5992
|
});
|
|
5631
5993
|
const parseReviewRequest = (value) => {
|
|
5632
5994
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
@@ -5802,7 +6164,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
5802
6164
|
operationId: plan.operationId,
|
|
5803
6165
|
reviewedProjectSetDigest: plan.reviewedProjectSetDigest,
|
|
5804
6166
|
inviterActorId: plan.inviterActorId,
|
|
5805
|
-
inviterDisplayName: store.actorDisplayName,
|
|
6167
|
+
inviterDisplayName: projectInviteInviterDisplayName(store.actorDisplayName),
|
|
5806
6168
|
inviterDeviceId: plan.inviterDeviceIds[0],
|
|
5807
6169
|
pendingPersonId: plan.personId,
|
|
5808
6170
|
projectSummaries: plan.projects.map((project) => ({
|
|
@@ -6110,8 +6472,14 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
6110
6472
|
} catch {
|
|
6111
6473
|
return c.json({ error: "invalid json" }, 400);
|
|
6112
6474
|
}
|
|
6113
|
-
const
|
|
6114
|
-
if (!
|
|
6475
|
+
const rawDisplayName = String(body.display_name ?? "");
|
|
6476
|
+
if (!rawDisplayName.trim()) return c.json({ error: "display_name required" }, 400);
|
|
6477
|
+
let displayName;
|
|
6478
|
+
try {
|
|
6479
|
+
displayName = normalizeHumanPresentationName(rawDisplayName, "display_name");
|
|
6480
|
+
} catch (error) {
|
|
6481
|
+
return c.json({ error: error instanceof Error ? error.message : "display_name_invalid" }, 400);
|
|
6482
|
+
}
|
|
6115
6483
|
const d = drizzle(store.db, { schema });
|
|
6116
6484
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
6117
6485
|
const actor = {
|
|
@@ -6136,9 +6504,13 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
6136
6504
|
return c.json({ error: "invalid json" }, 400);
|
|
6137
6505
|
}
|
|
6138
6506
|
const actorId = String(body.actor_id ?? "").trim();
|
|
6139
|
-
const displayName = String(body.display_name ?? "").trim();
|
|
6140
6507
|
if (!actorId) return c.json({ error: "actor_id required" }, 400);
|
|
6141
|
-
|
|
6508
|
+
let displayName;
|
|
6509
|
+
try {
|
|
6510
|
+
displayName = normalizeHumanPresentationName(String(body.display_name ?? ""), "display_name");
|
|
6511
|
+
} catch (error) {
|
|
6512
|
+
return c.json({ error: error instanceof Error ? error.message : "display_name_invalid" }, 400);
|
|
6513
|
+
}
|
|
6142
6514
|
const d = drizzle(store.db, { schema });
|
|
6143
6515
|
const actor = d.select().from(schema.actors).where(eq(schema.actors.actor_id, actorId)).get();
|
|
6144
6516
|
if (!actor) return c.json({ error: "actor not found" }, 404);
|
|
@@ -6168,14 +6540,133 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
6168
6540
|
if (!secondaryActorId) return c.json({ error: "secondary_actor_id required" }, 400);
|
|
6169
6541
|
if (primaryActorId === secondaryActorId) return c.json({ error: "actor ids must differ" }, 400);
|
|
6170
6542
|
const d = drizzle(store.db, { schema });
|
|
6171
|
-
const primary = d.select().from(schema.actors).where(eq(schema.actors.actor_id, primaryActorId)).get();
|
|
6172
|
-
const secondary = d.select().from(schema.actors).where(eq(schema.actors.actor_id, secondaryActorId)).get();
|
|
6173
|
-
if (!primary || !secondary) return c.json({ error: "actor not found" }, 404);
|
|
6174
|
-
if (primary.status !== "active") return c.json({ error: "primary actor not active" }, 409);
|
|
6175
|
-
if (secondary.status !== "active") return c.json({ error: "secondary actor not active" }, 409);
|
|
6176
|
-
if (secondary.is_local && secondary.actor_id === store.actorId) return c.json({ error: "cannot merge this device's own local actor" }, 409);
|
|
6177
6543
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
6178
|
-
const
|
|
6544
|
+
const result = store.db.transaction(() => {
|
|
6545
|
+
const primary = d.select().from(schema.actors).where(eq(schema.actors.actor_id, primaryActorId)).get();
|
|
6546
|
+
const secondary = d.select().from(schema.actors).where(eq(schema.actors.actor_id, secondaryActorId)).get();
|
|
6547
|
+
if (!primary || !secondary) return {
|
|
6548
|
+
error: "actor not found",
|
|
6549
|
+
status: 404
|
|
6550
|
+
};
|
|
6551
|
+
if (primary.status !== "active") return {
|
|
6552
|
+
error: "primary actor not active",
|
|
6553
|
+
status: 409
|
|
6554
|
+
};
|
|
6555
|
+
if (secondary.status !== "active") return {
|
|
6556
|
+
error: "secondary actor not active",
|
|
6557
|
+
status: 409
|
|
6558
|
+
};
|
|
6559
|
+
if (secondary.is_local && secondary.actor_id === store.actorId) return {
|
|
6560
|
+
error: "cannot merge this device's own local actor",
|
|
6561
|
+
status: 409
|
|
6562
|
+
};
|
|
6563
|
+
const memberships = d.select().from(schema.policyTeamMemberships).where(eq(schema.policyTeamMemberships.identity_id, secondaryActorId)).all();
|
|
6564
|
+
const primaryMemberships = /* @__PURE__ */ new Map();
|
|
6565
|
+
for (const membership of memberships) {
|
|
6566
|
+
const primaryMembership = d.select().from(schema.policyTeamMemberships).where(and(eq(schema.policyTeamMemberships.team_id, membership.team_id), eq(schema.policyTeamMemberships.identity_id, primaryActorId))).get();
|
|
6567
|
+
if (primaryMembership && (primaryMembership.role !== membership.role || primaryMembership.status !== membership.status)) return {
|
|
6568
|
+
error: "actor team memberships conflict",
|
|
6569
|
+
status: 409
|
|
6570
|
+
};
|
|
6571
|
+
primaryMemberships.set(membership.team_id, primaryMembership);
|
|
6572
|
+
}
|
|
6573
|
+
const directRecipients = d.select().from(schema.projectRecipients).where(and(eq(schema.projectRecipients.recipient_kind, "identity"), eq(schema.projectRecipients.recipient_id, secondaryActorId))).all();
|
|
6574
|
+
const primaryRecipients = /* @__PURE__ */ new Map();
|
|
6575
|
+
for (const recipient of directRecipients) {
|
|
6576
|
+
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();
|
|
6577
|
+
if (primaryRecipient && primaryRecipient.status !== recipient.status) return {
|
|
6578
|
+
error: "actor project recipients conflict",
|
|
6579
|
+
status: 409
|
|
6580
|
+
};
|
|
6581
|
+
primaryRecipients.set(recipient.canonical_project_identity, primaryRecipient);
|
|
6582
|
+
}
|
|
6583
|
+
const mergeDigest = createHash("sha256").update(JSON.stringify({
|
|
6584
|
+
kind: "identity-merge-v1",
|
|
6585
|
+
primaryActorId,
|
|
6586
|
+
secondaryActorId,
|
|
6587
|
+
nonce: randomUUID()
|
|
6588
|
+
})).digest("hex");
|
|
6589
|
+
const bindings = d.select({
|
|
6590
|
+
deviceId: schema.identityDevices.device_id,
|
|
6591
|
+
assignmentVersion: schema.identityDevices.assignment_version
|
|
6592
|
+
}).from(schema.identityDevices).where(eq(schema.identityDevices.identity_id, secondaryActorId)).all();
|
|
6593
|
+
if (bindings.length > 0) {
|
|
6594
|
+
const commitDigest = mergeDigest;
|
|
6595
|
+
const outcomes = bindings.map((binding) => ({
|
|
6596
|
+
deviceId: binding.deviceId,
|
|
6597
|
+
previousIdentityId: secondaryActorId,
|
|
6598
|
+
targetIdentityId: primaryActorId,
|
|
6599
|
+
action: "rebind"
|
|
6600
|
+
}));
|
|
6601
|
+
store.db.prepare(`INSERT INTO device_identity_binding_commits(
|
|
6602
|
+
commit_digest, reviewed_inventory_digest, request_json, outcomes_json, write_count,
|
|
6603
|
+
decided_by_identity_id, decided_by_device_id, created_at
|
|
6604
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(commitDigest, `identity-merge:${secondaryActorId}:${primaryActorId}`, JSON.stringify({
|
|
6605
|
+
primaryActorId,
|
|
6606
|
+
secondaryActorId
|
|
6607
|
+
}), JSON.stringify(outcomes), bindings.length, store.actorId, store.deviceId, now);
|
|
6608
|
+
for (const binding of bindings) {
|
|
6609
|
+
const revision = createHash("sha256").update(`identity-merge-revision-v1\0${commitDigest}\0${binding.deviceId}`).digest("hex");
|
|
6610
|
+
const idempotencyKey = createHash("sha256").update(`identity-merge-row-v1\0${commitDigest}\0${binding.deviceId}`).digest("hex");
|
|
6611
|
+
if (store.db.prepare(`UPDATE identity_devices SET
|
|
6612
|
+
identity_id = ?, provenance = 'review_resolution', revision = ?,
|
|
6613
|
+
migration_state = 'user_managed', source_fingerprint = ?,
|
|
6614
|
+
idempotency_key = ?, updated_at = ?
|
|
6615
|
+
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");
|
|
6616
|
+
const rebound = store.db.prepare("SELECT assignment_version FROM identity_devices WHERE device_id = ?").get(binding.deviceId);
|
|
6617
|
+
if (!rebound || rebound.assignment_version !== binding.assignmentVersion + 1) throw new Error("identity_merge_assignment_version_unexpected");
|
|
6618
|
+
store.db.prepare("DELETE FROM policy_team_device_decisions WHERE device_id = ?").run(binding.deviceId);
|
|
6619
|
+
store.db.prepare(`INSERT INTO device_identity_binding_audit(
|
|
6620
|
+
event_id, commit_digest, device_id, previous_identity_id, target_identity_id,
|
|
6621
|
+
action, previous_assignment_version, resulting_assignment_version,
|
|
6622
|
+
decided_by_identity_id, decided_by_device_id, created_at
|
|
6623
|
+
) 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);
|
|
6624
|
+
}
|
|
6625
|
+
}
|
|
6626
|
+
for (const membership of memberships) {
|
|
6627
|
+
if (primaryMemberships.get(membership.team_id)) {
|
|
6628
|
+
d.delete(schema.policyTeamMemberships).where(and(eq(schema.policyTeamMemberships.team_id, membership.team_id), eq(schema.policyTeamMemberships.identity_id, secondaryActorId))).run();
|
|
6629
|
+
continue;
|
|
6630
|
+
}
|
|
6631
|
+
const revision = createHash("sha256").update(`identity-merge-membership-revision-v1\0${mergeDigest}\0${membership.team_id}`).digest("hex");
|
|
6632
|
+
const idempotencyKey = createHash("sha256").update(`identity-merge-membership-row-v1\0${mergeDigest}\0${membership.team_id}`).digest("hex");
|
|
6633
|
+
d.update(schema.policyTeamMemberships).set({
|
|
6634
|
+
identity_id: primaryActorId,
|
|
6635
|
+
provenance: "review_resolution",
|
|
6636
|
+
revision,
|
|
6637
|
+
migration_state: "user_managed",
|
|
6638
|
+
source_fingerprint: mergeDigest,
|
|
6639
|
+
idempotency_key: idempotencyKey,
|
|
6640
|
+
updated_at: now
|
|
6641
|
+
}).where(and(eq(schema.policyTeamMemberships.team_id, membership.team_id), eq(schema.policyTeamMemberships.identity_id, secondaryActorId))).run();
|
|
6642
|
+
}
|
|
6643
|
+
for (const recipient of directRecipients) {
|
|
6644
|
+
if (primaryRecipients.get(recipient.canonical_project_identity)) {
|
|
6645
|
+
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();
|
|
6646
|
+
continue;
|
|
6647
|
+
}
|
|
6648
|
+
const policyRevision = createHash("sha256").update(`identity-merge-recipient-revision-v1\0${mergeDigest}\0${recipient.canonical_project_identity}`).digest("hex");
|
|
6649
|
+
const idempotencyKey = createHash("sha256").update(`identity-merge-recipient-row-v1\0${mergeDigest}\0${recipient.canonical_project_identity}`).digest("hex");
|
|
6650
|
+
d.update(schema.projectRecipients).set({
|
|
6651
|
+
recipient_id: primaryActorId,
|
|
6652
|
+
provenance: "review_resolution",
|
|
6653
|
+
policy_revision: policyRevision,
|
|
6654
|
+
migration_state: "user_managed",
|
|
6655
|
+
source_fingerprint: mergeDigest,
|
|
6656
|
+
idempotency_key: idempotencyKey,
|
|
6657
|
+
updated_at: now
|
|
6658
|
+
}).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();
|
|
6659
|
+
}
|
|
6660
|
+
d.update(schema.recipientManagedProjectProjections).set({
|
|
6661
|
+
recipient_identity_id: primaryActorId,
|
|
6662
|
+
updated_at: now
|
|
6663
|
+
}).where(eq(schema.recipientManagedProjectProjections.recipient_identity_id, secondaryActorId)).run();
|
|
6664
|
+
store.db.prepare(`UPDATE share_operations SET
|
|
6665
|
+
inviter_actor_id = CASE WHEN inviter_actor_id = ? THEN ? ELSE inviter_actor_id END,
|
|
6666
|
+
recipient_actor_id = CASE WHEN recipient_actor_id = ? THEN ? ELSE recipient_actor_id END,
|
|
6667
|
+
person_id = CASE WHEN person_id = ? THEN ? ELSE person_id END,
|
|
6668
|
+
updated_at = ?
|
|
6669
|
+
WHERE inviter_actor_id = ? OR recipient_actor_id = ? OR person_id = ?`).run(secondaryActorId, primaryActorId, secondaryActorId, primaryActorId, secondaryActorId, primaryActorId, now, secondaryActorId, secondaryActorId, secondaryActorId);
|
|
6179
6670
|
const peerUpdate = d.update(schema.syncPeers).set({ actor_id: primaryActorId }).where(eq(schema.syncPeers.actor_id, secondaryActorId)).run();
|
|
6180
6671
|
if (primary.is_local) d.update(schema.syncPeers).set({ claimed_local_actor: 1 }).where(eq(schema.syncPeers.actor_id, primaryActorId)).run();
|
|
6181
6672
|
d.update(schema.actors).set({
|
|
@@ -6183,9 +6674,10 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
6183
6674
|
merged_into_actor_id: primaryActorId,
|
|
6184
6675
|
updated_at: now
|
|
6185
6676
|
}).where(eq(schema.actors.actor_id, secondaryActorId)).run();
|
|
6186
|
-
return Number(peerUpdate.changes ?? 0);
|
|
6187
|
-
})();
|
|
6188
|
-
return c.json({
|
|
6677
|
+
return { mergedCount: Number(peerUpdate.changes ?? 0) };
|
|
6678
|
+
}).immediate();
|
|
6679
|
+
if ("error" in result) return c.json({ error: result.error }, result.status);
|
|
6680
|
+
return c.json({ merged_count: result.mergedCount });
|
|
6189
6681
|
});
|
|
6190
6682
|
app.post("/api/sync/actors/deactivate", async (c) => {
|
|
6191
6683
|
const store = getStore();
|
|
@@ -6238,11 +6730,18 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
6238
6730
|
}
|
|
6239
6731
|
const peerDeviceId = String(body.peer_device_id ?? "").trim();
|
|
6240
6732
|
const fingerprint = String(body.fingerprint ?? "").trim();
|
|
6733
|
+
const expectedGroupId = String(body.expected_group_id ?? "").trim();
|
|
6734
|
+
const hasExpectedIncomingRequestId = Object.hasOwn(body, "expected_incoming_request_id");
|
|
6735
|
+
const expectedIncomingRequestId = String(body.expected_incoming_request_id ?? "").trim();
|
|
6241
6736
|
if (!peerDeviceId) return c.json({ error: "peer_device_id required" }, 400);
|
|
6737
|
+
if (hasExpectedIncomingRequestId && !expectedIncomingRequestId) return c.json({ error: "expected_incoming_request_id must not be empty" }, 400);
|
|
6738
|
+
if (expectedIncomingRequestId && !expectedGroupId) return c.json({ error: "expected_group_id required" }, 400);
|
|
6242
6739
|
try {
|
|
6243
6740
|
const result = await acceptDiscoveredPeer(store, {
|
|
6244
6741
|
peerDeviceId,
|
|
6245
|
-
fingerprint: fingerprint || void 0
|
|
6742
|
+
fingerprint: fingerprint || void 0,
|
|
6743
|
+
expectedGroupId: expectedGroupId || void 0,
|
|
6744
|
+
expectedIncomingRequestId: expectedIncomingRequestId || void 0
|
|
6246
6745
|
});
|
|
6247
6746
|
if (!result.ok) return c.json({
|
|
6248
6747
|
error: result.error,
|
|
@@ -6258,6 +6757,10 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
6258
6757
|
needs_scope_review: true
|
|
6259
6758
|
});
|
|
6260
6759
|
} catch (error) {
|
|
6760
|
+
if (error instanceof CoordinatorReciprocalApprovalRequestChangedError) return c.json({
|
|
6761
|
+
error: error.code,
|
|
6762
|
+
detail: "This approval request changed before it was submitted. Refresh sync status and review the current request."
|
|
6763
|
+
}, { status: 409 });
|
|
6261
6764
|
return c.json({
|
|
6262
6765
|
error: "coordinator_lookup_failed",
|
|
6263
6766
|
detail: error instanceof Error ? error.message : String(error)
|
|
@@ -6408,7 +6911,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
6408
6911
|
inviteValue: rawValue,
|
|
6409
6912
|
dbPath: store.dbPath,
|
|
6410
6913
|
recipientActorId: recipientInvite ? null : store.actorId,
|
|
6411
|
-
recipientDisplayName: typeof body.recipient_name === "string" ? body.recipient_name : store.actorDisplayName,
|
|
6914
|
+
recipientDisplayName: decoded.kind === "add_device" ? null : typeof body.recipient_name === "string" ? body.recipient_name : store.actorDisplayName,
|
|
6412
6915
|
deviceDisplayName: typeof body.device_name === "string" ? body.device_name : null,
|
|
6413
6916
|
reviewedOnboardingDigest: reviewedOnboardingDigest || null
|
|
6414
6917
|
});
|
|
@@ -7255,15 +7758,19 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
7255
7758
|
}, 400);
|
|
7256
7759
|
}
|
|
7257
7760
|
const groupId = resolveCoordinatorAdminGroup(String(body.group_id ?? "").trim(), status);
|
|
7258
|
-
const displayName = String(body.display_name ?? "").trim();
|
|
7259
7761
|
if (!groupId) return c.json({
|
|
7260
7762
|
error: "group_id required",
|
|
7261
7763
|
status
|
|
7262
7764
|
}, 400);
|
|
7263
|
-
|
|
7264
|
-
|
|
7265
|
-
|
|
7266
|
-
}
|
|
7765
|
+
let displayName;
|
|
7766
|
+
try {
|
|
7767
|
+
displayName = normalizeHumanPresentationName(String(body.display_name ?? ""), "display_name");
|
|
7768
|
+
} catch (error) {
|
|
7769
|
+
return c.json({
|
|
7770
|
+
error: error instanceof Error ? error.message : "display_name_invalid",
|
|
7771
|
+
status
|
|
7772
|
+
}, 400);
|
|
7773
|
+
}
|
|
7267
7774
|
try {
|
|
7268
7775
|
const device = await coordinatorRenameDeviceAction({
|
|
7269
7776
|
groupId,
|
|
@@ -7682,6 +8189,850 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
7682
8189
|
return app;
|
|
7683
8190
|
}
|
|
7684
8191
|
//#endregion
|
|
8192
|
+
//#region src/routes/team-setup.ts
|
|
8193
|
+
var TEAM_SETUP_VERSION = 1;
|
|
8194
|
+
var MAX_CONFIGURED_GROUPS = 25;
|
|
8195
|
+
var MAX_DEVICES = 500;
|
|
8196
|
+
var MAX_PROJECTS = 500;
|
|
8197
|
+
var MAX_ACCESS_DELTA_ENTRIES = 1e4;
|
|
8198
|
+
var MAX_IDENTITY_CHOICES = 500;
|
|
8199
|
+
var MAX_COMPLETED_IDENTITY_CHOICES = MAX_DEVICES * 4;
|
|
8200
|
+
var MAX_PROJECT_MAPPING_CHOICES = 500;
|
|
8201
|
+
var MAX_TOTAL_PROJECT_MAPPING_CHOICES = 1e4;
|
|
8202
|
+
var MAX_PROJECT_MAPPING_SCAN_ROWS = 1e4;
|
|
8203
|
+
var MAX_PROJECT_MAPPING_METADATA_ROWS = 1e4;
|
|
8204
|
+
var MAX_MUTATION_BODY_BYTES = 8192;
|
|
8205
|
+
var SUMMARY_SNAPSHOT_CACHE_TTL_MS = 3e4;
|
|
8206
|
+
var TEAM_SETUP_ROUTE_PREFIX = "/api/sync/team-setup/v1";
|
|
8207
|
+
var CANDIDATE_REF_PATTERN = /^legacy-team-candidate:[0-9a-f]{32}$/u;
|
|
8208
|
+
var DEVICE_REF_PATTERN = /^legacy-team-device-ref-v1:[0-9a-f]{64}$/u;
|
|
8209
|
+
var PROJECT_REF_PATTERN = /^legacy-team-project-ref-v1:[0-9a-f]{64}$/u;
|
|
8210
|
+
var IDENTITY_REF_PATTERN = /^legacy-team-viewer-identity-ref-v1:[0-9a-f]{64}$/u;
|
|
8211
|
+
var RESOLVED_PROJECT_REF_PATTERN = /^legacy-team-resolved-project-ref-v1:[0-9a-f]{64}$/u;
|
|
8212
|
+
var ATTEMPT_ID_PATTERN = /^legacy-team-attempt:[0-9a-f-]{36}$/u;
|
|
8213
|
+
var FINISH_DIGEST_PATTERN = /^legacy-team-activation-finish-v1:[0-9a-f]{64}$/u;
|
|
8214
|
+
var ACCESS_DELTA_DIGEST_PATTERN = /^legacy-team-access-delta:[0-9a-f]{64}$/u;
|
|
8215
|
+
var VIEWER_ACCESS_DELTA_DIGEST_PATTERN = /^legacy-team-viewer-access-delta-v1:[0-9a-f]{64}$/u;
|
|
8216
|
+
var defaultSnapshotLoaderDependencies = {
|
|
8217
|
+
readConfig: readCoordinatorSyncConfig,
|
|
8218
|
+
listGroups: coordinatorListGroupsAction,
|
|
8219
|
+
listDevices: coordinatorListDevicesAction
|
|
8220
|
+
};
|
|
8221
|
+
function safeCoordinatorError() {
|
|
8222
|
+
return /* @__PURE__ */ new Error("team_setup_roster_unavailable");
|
|
8223
|
+
}
|
|
8224
|
+
function isCoordinatorRosterTooLargeError(error) {
|
|
8225
|
+
return error instanceof Error && error.message === "coordinator_response_too_large";
|
|
8226
|
+
}
|
|
8227
|
+
function configuredGroupIds(groups) {
|
|
8228
|
+
const unique = [...new Set(groups.map((group) => group.trim()).filter(Boolean))];
|
|
8229
|
+
if (unique.length > MAX_CONFIGURED_GROUPS) throw safeCoordinatorError();
|
|
8230
|
+
return unique;
|
|
8231
|
+
}
|
|
8232
|
+
async function loadConfiguredLegacyTeamGroupSnapshotsWith(dependencies, options = {}) {
|
|
8233
|
+
let config;
|
|
8234
|
+
try {
|
|
8235
|
+
config = dependencies.readConfig();
|
|
8236
|
+
} catch {
|
|
8237
|
+
throw safeCoordinatorError();
|
|
8238
|
+
}
|
|
8239
|
+
const groupIds = configuredGroupIds(config.syncCoordinatorGroups);
|
|
8240
|
+
const hasUrl = Boolean(config.syncCoordinatorUrl);
|
|
8241
|
+
const hasSecret = Boolean(config.syncCoordinatorAdminSecret);
|
|
8242
|
+
if (!hasUrl && !hasSecret && groupIds.length === 0) return [];
|
|
8243
|
+
if (!hasUrl || !hasSecret || groupIds.length === 0) throw safeCoordinatorError();
|
|
8244
|
+
try {
|
|
8245
|
+
const remoteUrl = buildBaseUrl(config.syncCoordinatorUrl);
|
|
8246
|
+
const coordinatorId = remoteUrl;
|
|
8247
|
+
const timeoutS = Math.max(1, config.syncCoordinatorTimeoutS);
|
|
8248
|
+
const requestedGroupIds = options.candidateRef ? groupIds.filter((groupId) => legacyTeamCandidateId(coordinatorId, groupId) === options.candidateRef) : groupIds;
|
|
8249
|
+
const groups = await dependencies.listGroups({
|
|
8250
|
+
remoteUrl,
|
|
8251
|
+
adminSecret: config.syncCoordinatorAdminSecret,
|
|
8252
|
+
includeArchived: false,
|
|
8253
|
+
timeoutS
|
|
8254
|
+
});
|
|
8255
|
+
const groupById = new Map(groups.map((group) => [group.group_id, group]));
|
|
8256
|
+
return (await Promise.all(requestedGroupIds.map(async (groupId) => {
|
|
8257
|
+
const group = groupById.get(groupId);
|
|
8258
|
+
if (!group || group.group_id !== groupId || group.display_name !== null && typeof group.display_name !== "string" || group.archived_at != null) throw safeCoordinatorError();
|
|
8259
|
+
let devices;
|
|
8260
|
+
try {
|
|
8261
|
+
devices = await dependencies.listDevices({
|
|
8262
|
+
groupId,
|
|
8263
|
+
includeDisabled: true,
|
|
8264
|
+
remoteUrl,
|
|
8265
|
+
adminSecret: config.syncCoordinatorAdminSecret,
|
|
8266
|
+
timeoutS
|
|
8267
|
+
});
|
|
8268
|
+
} catch (error) {
|
|
8269
|
+
if (isCoordinatorRosterTooLargeError(error)) {
|
|
8270
|
+
if (options.candidateRef) throw new Error("legacy_team_setup_roster_too_large");
|
|
8271
|
+
return null;
|
|
8272
|
+
}
|
|
8273
|
+
throw error;
|
|
8274
|
+
}
|
|
8275
|
+
if (devices.length > MAX_DEVICES) {
|
|
8276
|
+
if (options.candidateRef) throw new Error("legacy_team_setup_roster_too_large");
|
|
8277
|
+
return null;
|
|
8278
|
+
}
|
|
8279
|
+
if (devices.some((device) => fingerprintPublicKey(device.public_key) !== device.fingerprint || device.enabled !== 0 && device.enabled !== 1)) throw safeCoordinatorError();
|
|
8280
|
+
return {
|
|
8281
|
+
coordinatorId,
|
|
8282
|
+
groupId,
|
|
8283
|
+
displayName: group.display_name ?? "Legacy Team",
|
|
8284
|
+
devices: devices.map((device) => ({
|
|
8285
|
+
deviceId: device.device_id,
|
|
8286
|
+
fingerprint: device.fingerprint,
|
|
8287
|
+
displayName: device.display_name ?? "Device",
|
|
8288
|
+
enabled: device.enabled === 1,
|
|
8289
|
+
labelRedactionIds: [device.identity_id ?? "", device.public_key].filter(Boolean)
|
|
8290
|
+
}))
|
|
8291
|
+
};
|
|
8292
|
+
}))).filter((snapshot) => snapshot !== null);
|
|
8293
|
+
} catch (error) {
|
|
8294
|
+
if (error instanceof Error && error.message === "legacy_team_setup_roster_too_large") throw error;
|
|
8295
|
+
throw safeCoordinatorError();
|
|
8296
|
+
}
|
|
8297
|
+
}
|
|
8298
|
+
async function loadConfiguredLegacyTeamGroupSnapshots(options = {}) {
|
|
8299
|
+
return loadConfiguredLegacyTeamGroupSnapshotsWith(defaultSnapshotLoaderDependencies, options);
|
|
8300
|
+
}
|
|
8301
|
+
function requireBoundedSnapshots(groups) {
|
|
8302
|
+
if (groups.length > MAX_CONFIGURED_GROUPS) throw new Error("legacy_team_setup_roster_too_large");
|
|
8303
|
+
}
|
|
8304
|
+
function projectionOptions(store) {
|
|
8305
|
+
return {
|
|
8306
|
+
localActorId: store.actorId,
|
|
8307
|
+
localDeviceId: store.deviceId
|
|
8308
|
+
};
|
|
8309
|
+
}
|
|
8310
|
+
function discoverCandidates(store, groups) {
|
|
8311
|
+
requireBoundedSnapshots(groups);
|
|
8312
|
+
return discoverLegacyTeamCandidates(store.db, {
|
|
8313
|
+
projection: projectionOptions(store),
|
|
8314
|
+
groups
|
|
8315
|
+
});
|
|
8316
|
+
}
|
|
8317
|
+
function candidateSummary(candidate) {
|
|
8318
|
+
return {
|
|
8319
|
+
candidateRef: candidate.candidateRef,
|
|
8320
|
+
displayName: candidate.displayName,
|
|
8321
|
+
status: candidate.status,
|
|
8322
|
+
deviceCount: candidate.deviceCount,
|
|
8323
|
+
projectCount: candidate.projectCount,
|
|
8324
|
+
unresolvedDeviceCount: candidate.unresolvedDeviceCount,
|
|
8325
|
+
unresolvedProjectCount: candidate.unresolvedProjectCount
|
|
8326
|
+
};
|
|
8327
|
+
}
|
|
8328
|
+
function candidateSummaryForDraft(candidate, draft) {
|
|
8329
|
+
return {
|
|
8330
|
+
...candidateSummary(candidate),
|
|
8331
|
+
status: draft.state === "completed" ? "ready" : draft.state,
|
|
8332
|
+
unresolvedDeviceCount: draft.unresolvedDeviceCount,
|
|
8333
|
+
unresolvedProjectCount: draft.unresolvedProjectCount
|
|
8334
|
+
};
|
|
8335
|
+
}
|
|
8336
|
+
function completedCandidateFromDraft(draft) {
|
|
8337
|
+
return {
|
|
8338
|
+
candidateRef: draft.candidateRef,
|
|
8339
|
+
displayName: draft.displayName,
|
|
8340
|
+
status: "ready",
|
|
8341
|
+
deviceCount: draft.devices.length,
|
|
8342
|
+
projectCount: draft.projects.length,
|
|
8343
|
+
unresolvedDeviceCount: draft.unresolvedDeviceCount,
|
|
8344
|
+
unresolvedProjectCount: draft.unresolvedProjectCount
|
|
8345
|
+
};
|
|
8346
|
+
}
|
|
8347
|
+
function identityRef(candidateRef, identityId) {
|
|
8348
|
+
return identityId ? recipientPolicyDigest("legacy-team-viewer-identity-ref-v1", [candidateRef, identityId]) : null;
|
|
8349
|
+
}
|
|
8350
|
+
function requiredIdentityRef(candidateRef, identityId) {
|
|
8351
|
+
return recipientPolicyDigest("legacy-team-viewer-identity-ref-v1", [candidateRef, identityId]);
|
|
8352
|
+
}
|
|
8353
|
+
function teamRef(candidateRef, teamId) {
|
|
8354
|
+
return recipientPolicyDigest("legacy-team-viewer-team-ref-v1", [candidateRef, teamId]);
|
|
8355
|
+
}
|
|
8356
|
+
function resolvedProjectRef(projectRef, projectIdentity) {
|
|
8357
|
+
return projectIdentity ? legacyTeamResolvedProjectRef(projectRef, projectIdentity) : null;
|
|
8358
|
+
}
|
|
8359
|
+
function viewerSafeAccessDelta(candidateRef, delta, labels) {
|
|
8360
|
+
const fallbackLabels = /* @__PURE__ */ new Map();
|
|
8361
|
+
const fallbackLabelCounts = /* @__PURE__ */ new Map();
|
|
8362
|
+
const reservedLabels = new Set([
|
|
8363
|
+
labels?.teamDisplayName,
|
|
8364
|
+
...labels?.devices.map((device) => device.displayName) ?? [],
|
|
8365
|
+
...labels?.projects.flatMap((project) => [project.displayName, ...project.mappingChoices.map((choice) => choice.displayName)]) ?? [],
|
|
8366
|
+
...labels?.identityChoices.map((identity) => identity.displayName) ?? []
|
|
8367
|
+
].filter((label) => Boolean(label)).map((label) => normalizeChoiceLabelText(label).toLowerCase()));
|
|
8368
|
+
const fallbackLabel = (kind, ref) => {
|
|
8369
|
+
const key = `${kind}:${ref}`;
|
|
8370
|
+
const existing = fallbackLabels.get(key);
|
|
8371
|
+
if (existing) return existing;
|
|
8372
|
+
let index = (fallbackLabelCounts.get(kind) ?? 0) + 1;
|
|
8373
|
+
let label = `${kind} outside this setup (${index})`;
|
|
8374
|
+
while (reservedLabels.has(normalizeChoiceLabelText(label).toLowerCase())) {
|
|
8375
|
+
index += 1;
|
|
8376
|
+
label = `${kind} outside this setup (${index})`;
|
|
8377
|
+
}
|
|
8378
|
+
fallbackLabelCounts.set(kind, index);
|
|
8379
|
+
reservedLabels.add(normalizeChoiceLabelText(label).toLowerCase());
|
|
8380
|
+
fallbackLabels.set(key, label);
|
|
8381
|
+
return label;
|
|
8382
|
+
};
|
|
8383
|
+
const projectsByRef = new Map(labels?.projects.map((project) => [project.projectRef, project]));
|
|
8384
|
+
const projectsByCanonicalRef = new Map(labels?.projects.flatMap((project) => project.canonicalProjectRef ? [[project.canonicalProjectRef, project]] : []));
|
|
8385
|
+
const identitiesByRef = new Map(labels?.identityChoices.map((identity) => [identity.identityRef, identity]));
|
|
8386
|
+
const devicesByRef = new Map(labels?.devices.map((device) => [device.deviceRef, device]));
|
|
8387
|
+
const resolvedDisplayName = (projectRef, ref) => {
|
|
8388
|
+
if (!ref) return null;
|
|
8389
|
+
const project = projectsByRef.get(projectRef);
|
|
8390
|
+
const choice = project?.mappingChoices.find((item) => item.resolvedProjectRef === ref);
|
|
8391
|
+
if (choice) return choice.displayName;
|
|
8392
|
+
if (project?.resolvedProjectRef === ref && project.resolution === "deterministic") return `${project.displayName} (automatic match)`;
|
|
8393
|
+
return fallbackLabel("Project", ref);
|
|
8394
|
+
};
|
|
8395
|
+
return {
|
|
8396
|
+
teamChanges: delta.teamChanges.map((change) => ({
|
|
8397
|
+
teamRef: teamRef(candidateRef, change.teamId),
|
|
8398
|
+
teamDisplayName: labels?.teamDisplayName ?? fallbackLabel("Team", teamRef(candidateRef, change.teamId)),
|
|
8399
|
+
change: change.change,
|
|
8400
|
+
fromDeviceEligibilityMode: change.fromDeviceEligibilityMode,
|
|
8401
|
+
toDeviceEligibilityMode: change.toDeviceEligibilityMode
|
|
8402
|
+
})),
|
|
8403
|
+
membershipChanges: delta.membershipChanges.map((change) => {
|
|
8404
|
+
const identityRef = requiredIdentityRef(candidateRef, change.identityId);
|
|
8405
|
+
const membershipTeamRef = teamRef(candidateRef, change.teamId);
|
|
8406
|
+
return {
|
|
8407
|
+
teamRef: membershipTeamRef,
|
|
8408
|
+
teamDisplayName: labels?.teamDisplayName ?? fallbackLabel("Team", membershipTeamRef),
|
|
8409
|
+
identityRef,
|
|
8410
|
+
identityDisplayName: identitiesByRef.get(identityRef)?.displayName ?? fallbackLabel("Person", identityRef),
|
|
8411
|
+
change: change.change
|
|
8412
|
+
};
|
|
8413
|
+
}),
|
|
8414
|
+
projectChanges: delta.projectChanges.map((change) => {
|
|
8415
|
+
const fromRef = resolvedProjectRef(change.projectRef, change.fromProjectIdentity);
|
|
8416
|
+
const toRef = resolvedProjectRef(change.projectRef, change.toProjectIdentity);
|
|
8417
|
+
return {
|
|
8418
|
+
projectRef: change.projectRef,
|
|
8419
|
+
projectDisplayName: projectsByRef.get(change.projectRef)?.displayName ?? fallbackLabel("Project", change.projectRef),
|
|
8420
|
+
fromResolvedProjectRef: fromRef,
|
|
8421
|
+
fromResolvedProjectDisplayName: resolvedDisplayName(change.projectRef, fromRef),
|
|
8422
|
+
toResolvedProjectRef: toRef,
|
|
8423
|
+
toResolvedProjectDisplayName: resolvedDisplayName(change.projectRef, toRef),
|
|
8424
|
+
change: change.change
|
|
8425
|
+
};
|
|
8426
|
+
}),
|
|
8427
|
+
recipientChanges: delta.recipientChanges.map((change) => {
|
|
8428
|
+
const canonicalProjectRef = legacyTeamCanonicalProjectRef(candidateRef, change.canonicalProjectIdentity);
|
|
8429
|
+
const recipientRef = teamRef(candidateRef, change.recipientId);
|
|
8430
|
+
return {
|
|
8431
|
+
canonicalProjectRef,
|
|
8432
|
+
canonicalProjectDisplayName: projectsByCanonicalRef.get(canonicalProjectRef)?.displayName ?? fallbackLabel("Project", canonicalProjectRef),
|
|
8433
|
+
recipientKind: change.recipientKind,
|
|
8434
|
+
recipientRef,
|
|
8435
|
+
recipientDisplayName: labels?.teamDisplayName ?? fallbackLabel("Team", recipientRef),
|
|
8436
|
+
change: change.change
|
|
8437
|
+
};
|
|
8438
|
+
}),
|
|
8439
|
+
deviceAccessChanges: delta.deviceAccessChanges.map((change) => {
|
|
8440
|
+
const canonicalProjectRef = legacyTeamCanonicalProjectRef(candidateRef, change.canonicalProjectIdentity);
|
|
8441
|
+
const deviceRef = legacyTeamDeviceRef(candidateRef, change.deviceId);
|
|
8442
|
+
return {
|
|
8443
|
+
canonicalProjectRef,
|
|
8444
|
+
canonicalProjectDisplayName: projectsByCanonicalRef.get(canonicalProjectRef)?.displayName ?? fallbackLabel("Project", canonicalProjectRef),
|
|
8445
|
+
deviceRef,
|
|
8446
|
+
deviceDisplayName: devicesByRef.get(deviceRef)?.displayName ?? fallbackLabel("Device", deviceRef),
|
|
8447
|
+
change: change.change
|
|
8448
|
+
};
|
|
8449
|
+
})
|
|
8450
|
+
};
|
|
8451
|
+
}
|
|
8452
|
+
function viewerAccessDeltaDigest(delta) {
|
|
8453
|
+
return recipientPolicyDigest("legacy-team-viewer-access-delta-v1", delta);
|
|
8454
|
+
}
|
|
8455
|
+
function requireBoundedAccessDelta(delta) {
|
|
8456
|
+
const total = delta.teamChanges.length + delta.membershipChanges.length + delta.projectChanges.length + delta.recipientChanges.length + delta.deviceAccessChanges.length;
|
|
8457
|
+
if (delta.teamChanges.length > MAX_ACCESS_DELTA_ENTRIES || delta.membershipChanges.length > MAX_ACCESS_DELTA_ENTRIES || delta.projectChanges.length > MAX_ACCESS_DELTA_ENTRIES || delta.recipientChanges.length > MAX_ACCESS_DELTA_ENTRIES || delta.deviceAccessChanges.length > MAX_ACCESS_DELTA_ENTRIES || total > MAX_ACCESS_DELTA_ENTRIES) throw new Error("legacy_team_setup_roster_too_large");
|
|
8458
|
+
}
|
|
8459
|
+
var SAFE_CHOICE_LABEL_PATTERN = /^[\p{L}\p{N} '&,.()_-]*$/u;
|
|
8460
|
+
function normalizeChoiceLabelText(value) {
|
|
8461
|
+
return value.normalize("NFKC").replace(/\p{Cf}/gu, "").replace(/\p{Cc}/gu, " ").replace(/\s+/gu, " ").trim();
|
|
8462
|
+
}
|
|
8463
|
+
function safeChoiceLabel(value, fallback, forbiddenIds, opaqueRef) {
|
|
8464
|
+
const normalized = normalizeChoiceLabelText(value.slice(0, 512));
|
|
8465
|
+
const label = normalized.slice(0, 120).trim();
|
|
8466
|
+
const forbiddenComparisons = forbiddenIds.map((forbiddenId) => normalizeChoiceLabelText(forbiddenId).toLowerCase()).filter(Boolean);
|
|
8467
|
+
const comparable = normalized.toLowerCase();
|
|
8468
|
+
if (!label || !SAFE_CHOICE_LABEL_PATTERN.test(label) || /[\p{L}\p{N}]\.[\p{L}\p{N}]/u.test(label) || /-----|\b(?:ssh|ecdsa|sk)-[\p{L}\p{N}-]+ /iu.test(label) || forbiddenComparisons.some((forbiddenComparison) => comparable.includes(forbiddenComparison))) {
|
|
8469
|
+
const suffix = opaqueRef?.slice(-6);
|
|
8470
|
+
return suffix ? `${fallback} ${suffix}` : fallback;
|
|
8471
|
+
}
|
|
8472
|
+
return label;
|
|
8473
|
+
}
|
|
8474
|
+
function disambiguateChoiceLabels(choices, choiceRef) {
|
|
8475
|
+
const counts = /* @__PURE__ */ new Map();
|
|
8476
|
+
const originalLabels = /* @__PURE__ */ new Set();
|
|
8477
|
+
for (const choice of choices) {
|
|
8478
|
+
const comparable = normalizeChoiceLabelText(choice.displayName).toLowerCase();
|
|
8479
|
+
counts.set(comparable, (counts.get(comparable) ?? 0) + 1);
|
|
8480
|
+
originalLabels.add(comparable);
|
|
8481
|
+
}
|
|
8482
|
+
const usedLabels = /* @__PURE__ */ new Set();
|
|
8483
|
+
return choices.map((choice, index) => {
|
|
8484
|
+
const comparable = normalizeChoiceLabelText(choice.displayName).toLowerCase();
|
|
8485
|
+
if ((counts.get(comparable) ?? 0) < 2 && !usedLabels.has(comparable)) {
|
|
8486
|
+
usedLabels.add(comparable);
|
|
8487
|
+
return choice;
|
|
8488
|
+
}
|
|
8489
|
+
const ref = choiceRef(choice);
|
|
8490
|
+
let suffixLength = Math.min(6, ref.length);
|
|
8491
|
+
let suffix = ref.slice(-suffixLength);
|
|
8492
|
+
const maxAttempts = ref.length + originalLabels.size + usedLabels.size + 2;
|
|
8493
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
8494
|
+
const baseLength = Math.max(0, 119 - suffix.length);
|
|
8495
|
+
const base = choice.displayName.slice(0, baseLength).trimEnd();
|
|
8496
|
+
const displayName = base ? `${base} ${suffix}` : suffix.slice(-120);
|
|
8497
|
+
const finalComparable = normalizeChoiceLabelText(displayName).toLowerCase();
|
|
8498
|
+
if (!usedLabels.has(finalComparable) && !originalLabels.has(finalComparable)) {
|
|
8499
|
+
usedLabels.add(finalComparable);
|
|
8500
|
+
return {
|
|
8501
|
+
...choice,
|
|
8502
|
+
displayName
|
|
8503
|
+
};
|
|
8504
|
+
}
|
|
8505
|
+
if (suffixLength < ref.length) {
|
|
8506
|
+
suffixLength = Math.min(ref.length, suffixLength + 2);
|
|
8507
|
+
suffix = ref.slice(-suffixLength);
|
|
8508
|
+
continue;
|
|
8509
|
+
}
|
|
8510
|
+
suffix = `${ref.slice(-Math.min(96, ref.length))}-${index + 1}-${attempt + 1}`;
|
|
8511
|
+
}
|
|
8512
|
+
throw new Error("legacy_team_setup_roster_too_large");
|
|
8513
|
+
});
|
|
8514
|
+
}
|
|
8515
|
+
function requireCompleteMappingChoices(projectCount, choiceCount) {
|
|
8516
|
+
if (projectCount * choiceCount > MAX_TOTAL_PROJECT_MAPPING_CHOICES) throw new Error("legacy_team_setup_roster_too_large");
|
|
8517
|
+
}
|
|
8518
|
+
function createCachedSnapshotLoader(load, ttlMs, now = Date.now) {
|
|
8519
|
+
let cached = null;
|
|
8520
|
+
let inFlight = null;
|
|
8521
|
+
let generation = 0;
|
|
8522
|
+
const cachedLoad = () => {
|
|
8523
|
+
if (cached && now() < cached.expiresAt) return Promise.resolve(cached.snapshots);
|
|
8524
|
+
if (inFlight) return inFlight;
|
|
8525
|
+
const operationGeneration = generation;
|
|
8526
|
+
let operation;
|
|
8527
|
+
operation = load().then((snapshots) => {
|
|
8528
|
+
if (generation !== operationGeneration) return cachedLoad();
|
|
8529
|
+
cached = {
|
|
8530
|
+
expiresAt: now() + ttlMs,
|
|
8531
|
+
snapshots
|
|
8532
|
+
};
|
|
8533
|
+
return snapshots;
|
|
8534
|
+
}).finally(() => {
|
|
8535
|
+
if (inFlight === operation) inFlight = null;
|
|
8536
|
+
});
|
|
8537
|
+
inFlight = operation;
|
|
8538
|
+
return operation;
|
|
8539
|
+
};
|
|
8540
|
+
cachedLoad.invalidate = () => {
|
|
8541
|
+
generation += 1;
|
|
8542
|
+
cached = null;
|
|
8543
|
+
inFlight = null;
|
|
8544
|
+
};
|
|
8545
|
+
return cachedLoad;
|
|
8546
|
+
}
|
|
8547
|
+
function identityChoicesFromRows(rows, candidateRef, maxChoices = MAX_IDENTITY_CHOICES) {
|
|
8548
|
+
if (rows.length > maxChoices) throw new Error("legacy_team_setup_roster_too_large");
|
|
8549
|
+
const actorIds = rows.map((row) => row.actor_id);
|
|
8550
|
+
return disambiguateChoiceLabels(rows.map((row) => {
|
|
8551
|
+
const opaqueIdentityRef = requiredIdentityRef(candidateRef, row.actor_id);
|
|
8552
|
+
return {
|
|
8553
|
+
identityId: row.actor_id,
|
|
8554
|
+
identityRef: opaqueIdentityRef,
|
|
8555
|
+
displayName: safeChoiceLabel(row.display_name, "Person", actorIds, opaqueIdentityRef)
|
|
8556
|
+
};
|
|
8557
|
+
}), (choice) => choice.identityRef);
|
|
8558
|
+
}
|
|
8559
|
+
function identityChoices(store, candidateRef) {
|
|
8560
|
+
return identityChoicesFromRows(store.db.prepare(`SELECT actor_id, display_name FROM actors
|
|
8561
|
+
WHERE status = 'active' AND merged_into_actor_id IS NULL
|
|
8562
|
+
ORDER BY display_name, actor_id LIMIT ?`).all(501), candidateRef);
|
|
8563
|
+
}
|
|
8564
|
+
function completedDraftIdentityChoices(store, draft) {
|
|
8565
|
+
const actorIds = [...new Set(draft.devices.flatMap((device) => [
|
|
8566
|
+
device.existingIdentityId,
|
|
8567
|
+
device.suggestedIdentityId,
|
|
8568
|
+
device.targetIdentityId,
|
|
8569
|
+
device.expectation.kind === "existing" ? device.expectation.identityId : null
|
|
8570
|
+
]))].filter((actorId) => actorId !== null);
|
|
8571
|
+
if (actorIds.length === 0) return [];
|
|
8572
|
+
const placeholders = actorIds.map(() => "?").join(", ");
|
|
8573
|
+
return identityChoicesFromRows(store.db.prepare(`SELECT actor_id, display_name FROM actors
|
|
8574
|
+
WHERE status = 'active' AND merged_into_actor_id IS NULL
|
|
8575
|
+
AND actor_id IN (${placeholders})
|
|
8576
|
+
ORDER BY display_name, actor_id`).all(...actorIds), draft.candidateRef, MAX_COMPLETED_IDENTITY_CHOICES);
|
|
8577
|
+
}
|
|
8578
|
+
function projectMappingChoices(store) {
|
|
8579
|
+
let candidates;
|
|
8580
|
+
try {
|
|
8581
|
+
candidates = listProjectScopeCandidates(store.db, {
|
|
8582
|
+
limit: null,
|
|
8583
|
+
maxScannedRows: MAX_PROJECT_MAPPING_SCAN_ROWS,
|
|
8584
|
+
maxMetadataRows: MAX_PROJECT_MAPPING_METADATA_ROWS,
|
|
8585
|
+
excludePeerReceived: true
|
|
8586
|
+
}).filter((candidate) => !candidate.read_only && isLegacyTeamSetupProjectMappingIdentity(candidate.workspace_identity));
|
|
8587
|
+
} catch (error) {
|
|
8588
|
+
if (error instanceof Error && (error.message === "project_scope_candidate_scan_too_large" || error.message === "project_scope_candidate_metadata_too_large")) throw new Error("legacy_team_setup_roster_too_large");
|
|
8589
|
+
throw error;
|
|
8590
|
+
}
|
|
8591
|
+
if (candidates.length > MAX_PROJECT_MAPPING_CHOICES) throw new Error("legacy_team_setup_roster_too_large");
|
|
8592
|
+
return candidates.map((candidate) => ({
|
|
8593
|
+
projectIdentity: candidate.workspace_identity,
|
|
8594
|
+
sourceDisplayName: candidate.display_project
|
|
8595
|
+
}));
|
|
8596
|
+
}
|
|
8597
|
+
function viewerSafeDraft(store, draft) {
|
|
8598
|
+
if (draft.devices.length > MAX_DEVICES || draft.projects.length > MAX_PROJECTS) throw new Error("legacy_team_setup_roster_too_large");
|
|
8599
|
+
const completed = draft.state === "completed";
|
|
8600
|
+
const identities = completed ? completedDraftIdentityChoices(store, draft) : identityChoices(store, draft.candidateRef);
|
|
8601
|
+
const mappableProjectCount = completed ? 0 : draft.projects.filter((project) => project.resolution !== "deterministic").length;
|
|
8602
|
+
const mappingChoices = mappableProjectCount === 0 ? [] : projectMappingChoices(store);
|
|
8603
|
+
requireCompleteMappingChoices(mappableProjectCount, mappingChoices.length);
|
|
8604
|
+
const mappingChoiceIdentities = mappingChoices.map((choice) => choice.projectIdentity);
|
|
8605
|
+
return {
|
|
8606
|
+
devices: draft.devices.map((device) => ({
|
|
8607
|
+
deviceRef: device.deviceRef,
|
|
8608
|
+
displayName: device.displayName,
|
|
8609
|
+
enabled: device.enabled,
|
|
8610
|
+
existingIdentityRef: identityRef(draft.candidateRef, device.existingIdentityId),
|
|
8611
|
+
suggestedIdentityRef: identityRef(draft.candidateRef, device.suggestedIdentityId),
|
|
8612
|
+
verifiedEvidenceKind: device.verifiedEvidenceKind,
|
|
8613
|
+
decision: device.decision,
|
|
8614
|
+
targetIdentityRef: identityRef(draft.candidateRef, device.targetIdentityId),
|
|
8615
|
+
expectation: device.expectation.kind === "existing" ? {
|
|
8616
|
+
kind: "existing",
|
|
8617
|
+
assignmentVersion: device.expectation.assignmentVersion,
|
|
8618
|
+
identityRef: requiredIdentityRef(draft.candidateRef, device.expectation.identityId)
|
|
8619
|
+
} : { kind: "absent" }
|
|
8620
|
+
})),
|
|
8621
|
+
projects: draft.projects.map((project) => ({
|
|
8622
|
+
projectRef: project.projectRef,
|
|
8623
|
+
displayName: project.displayName,
|
|
8624
|
+
resolution: project.resolution,
|
|
8625
|
+
canonicalProjectRef: project.canonicalProjectRef,
|
|
8626
|
+
resolvedProjectRef: project.resolvedProjectRef,
|
|
8627
|
+
mappingChoices: project.resolution === "deterministic" ? [] : disambiguateChoiceLabels(mappingChoices.map((choice) => {
|
|
8628
|
+
const opaqueResolvedProjectRef = legacyTeamResolvedProjectRef(project.projectRef, choice.projectIdentity);
|
|
8629
|
+
return {
|
|
8630
|
+
resolvedProjectRef: opaqueResolvedProjectRef,
|
|
8631
|
+
displayName: safeChoiceLabel(choice.sourceDisplayName, "Project", mappingChoiceIdentities, opaqueResolvedProjectRef)
|
|
8632
|
+
};
|
|
8633
|
+
}), (choice) => choice.resolvedProjectRef)
|
|
8634
|
+
})),
|
|
8635
|
+
identityChoices: identities.map(({ identityRef, displayName }) => ({
|
|
8636
|
+
identityRef,
|
|
8637
|
+
displayName
|
|
8638
|
+
}))
|
|
8639
|
+
};
|
|
8640
|
+
}
|
|
8641
|
+
function errorStatus(code) {
|
|
8642
|
+
if (code === "team_setup_roster_unavailable") return 503;
|
|
8643
|
+
if (code === "team_setup_failed") return 503;
|
|
8644
|
+
if (code === "team_setup_incomplete") return 400;
|
|
8645
|
+
return 409;
|
|
8646
|
+
}
|
|
8647
|
+
function apiErrorCode(error) {
|
|
8648
|
+
const code = legacyTeamSetupApiErrorCode(error);
|
|
8649
|
+
return code === "team_setup_projection_changed" ? "team_setup_conflict" : code;
|
|
8650
|
+
}
|
|
8651
|
+
async function parseBoundedJsonObject(c) {
|
|
8652
|
+
if (c.req.header("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") return { ok: false };
|
|
8653
|
+
const lengthHeader = c.req.header("content-length");
|
|
8654
|
+
if (lengthHeader != null && !/^\d+$/u.test(lengthHeader.trim())) return { ok: false };
|
|
8655
|
+
const contentLength = lengthHeader == null ? null : Number(lengthHeader);
|
|
8656
|
+
if (contentLength != null && contentLength > MAX_MUTATION_BODY_BYTES) return { ok: false };
|
|
8657
|
+
const body = c.req.raw.body;
|
|
8658
|
+
if (!body) return { ok: false };
|
|
8659
|
+
const reader = body.getReader();
|
|
8660
|
+
const chunks = [];
|
|
8661
|
+
let total = 0;
|
|
8662
|
+
try {
|
|
8663
|
+
while (true) {
|
|
8664
|
+
const { done, value } = await reader.read();
|
|
8665
|
+
if (done) break;
|
|
8666
|
+
if (!value) continue;
|
|
8667
|
+
total += value.byteLength;
|
|
8668
|
+
if (total > MAX_MUTATION_BODY_BYTES) {
|
|
8669
|
+
await reader.cancel().catch(() => void 0);
|
|
8670
|
+
return { ok: false };
|
|
8671
|
+
}
|
|
8672
|
+
chunks.push(value);
|
|
8673
|
+
}
|
|
8674
|
+
} catch {
|
|
8675
|
+
return { ok: false };
|
|
8676
|
+
} finally {
|
|
8677
|
+
reader.releaseLock();
|
|
8678
|
+
}
|
|
8679
|
+
try {
|
|
8680
|
+
const bytes = new Uint8Array(total);
|
|
8681
|
+
let offset = 0;
|
|
8682
|
+
for (const chunk of chunks) {
|
|
8683
|
+
bytes.set(chunk, offset);
|
|
8684
|
+
offset += chunk.byteLength;
|
|
8685
|
+
}
|
|
8686
|
+
const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
8687
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? {
|
|
8688
|
+
ok: true,
|
|
8689
|
+
value: parsed
|
|
8690
|
+
} : { ok: false };
|
|
8691
|
+
} catch {
|
|
8692
|
+
return { ok: false };
|
|
8693
|
+
}
|
|
8694
|
+
}
|
|
8695
|
+
function hasExactKeys(value, keys) {
|
|
8696
|
+
const actual = Object.keys(value).toSorted();
|
|
8697
|
+
const expected = keys.toSorted();
|
|
8698
|
+
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
|
|
8699
|
+
}
|
|
8700
|
+
function mutationResponse(draft) {
|
|
8701
|
+
return {
|
|
8702
|
+
version: TEAM_SETUP_VERSION,
|
|
8703
|
+
candidateRef: draft.candidateRef,
|
|
8704
|
+
attemptId: draft.attemptId,
|
|
8705
|
+
draftState: draft.state,
|
|
8706
|
+
canFinish: draft.canFinish,
|
|
8707
|
+
unresolvedDeviceCount: draft.unresolvedDeviceCount,
|
|
8708
|
+
unresolvedProjectCount: draft.unresolvedProjectCount
|
|
8709
|
+
};
|
|
8710
|
+
}
|
|
8711
|
+
function finishResponse(candidateRef, result) {
|
|
8712
|
+
return {
|
|
8713
|
+
version: TEAM_SETUP_VERSION,
|
|
8714
|
+
status: result.status,
|
|
8715
|
+
teamRef: teamRef(candidateRef, result.teamId),
|
|
8716
|
+
attemptId: result.attemptId,
|
|
8717
|
+
accessDeltaDigest: result.accessDeltaDigest,
|
|
8718
|
+
completedAt: result.completedAt
|
|
8719
|
+
};
|
|
8720
|
+
}
|
|
8721
|
+
function teamSetupRoutes(options) {
|
|
8722
|
+
const app = new Hono();
|
|
8723
|
+
const loadSnapshots = options.loadLegacyTeamConfiguredGroupSnapshots ?? loadConfiguredLegacyTeamGroupSnapshots;
|
|
8724
|
+
async function loadedSnapshots(candidateRef) {
|
|
8725
|
+
try {
|
|
8726
|
+
return await loadSnapshots(candidateRef ? { candidateRef } : void 0);
|
|
8727
|
+
} catch (error) {
|
|
8728
|
+
if (error instanceof Error && error.message === "legacy_team_setup_roster_too_large") throw error;
|
|
8729
|
+
throw safeCoordinatorError();
|
|
8730
|
+
}
|
|
8731
|
+
}
|
|
8732
|
+
const loadedSummarySnapshots = createCachedSnapshotLoader(() => loadedSnapshots(), SUMMARY_SNAPSHOT_CACHE_TTL_MS);
|
|
8733
|
+
async function loadedCandidateSnapshots(candidateRef) {
|
|
8734
|
+
loadedSummarySnapshots.invalidate();
|
|
8735
|
+
try {
|
|
8736
|
+
return await loadedSnapshots(candidateRef);
|
|
8737
|
+
} finally {
|
|
8738
|
+
loadedSummarySnapshots.invalidate();
|
|
8739
|
+
}
|
|
8740
|
+
}
|
|
8741
|
+
app.get("/api/sync/team-setup/v1", async (c) => {
|
|
8742
|
+
let groups;
|
|
8743
|
+
try {
|
|
8744
|
+
groups = await loadedSummarySnapshots();
|
|
8745
|
+
} catch (error) {
|
|
8746
|
+
const code = apiErrorCode(error);
|
|
8747
|
+
return c.json({ error: code }, errorStatus(code));
|
|
8748
|
+
}
|
|
8749
|
+
try {
|
|
8750
|
+
const response = {
|
|
8751
|
+
version: TEAM_SETUP_VERSION,
|
|
8752
|
+
candidates: discoverCandidates(options.getStore(), groups).map(candidateSummary)
|
|
8753
|
+
};
|
|
8754
|
+
return c.json(response);
|
|
8755
|
+
} catch (error) {
|
|
8756
|
+
const code = apiErrorCode(error);
|
|
8757
|
+
return c.json({ error: code }, errorStatus(code));
|
|
8758
|
+
}
|
|
8759
|
+
});
|
|
8760
|
+
app.get("/api/sync/team-setup/v1/:candidateRef", async (c) => {
|
|
8761
|
+
const candidateRef = c.req.param("candidateRef");
|
|
8762
|
+
if (!CANDIDATE_REF_PATTERN.test(candidateRef)) return c.json({ error: "team_setup_incomplete" }, 400);
|
|
8763
|
+
try {
|
|
8764
|
+
const store = options.getStore();
|
|
8765
|
+
let draft = getLegacyTeamSetupDraft(store.db, candidateRef);
|
|
8766
|
+
let candidate;
|
|
8767
|
+
if (draft?.state === "completed" && isLegacyTeamCandidateSelectable(store.db, candidateRef, { projects: legacyTeamCandidateProjectInventory(store.db, projectionOptions(store), candidateRef) })) candidate = completedCandidateFromDraft(draft);
|
|
8768
|
+
else {
|
|
8769
|
+
const discoveredCandidate = discoverCandidates(store, (await loadedCandidateSnapshots(candidateRef)).filter((group) => legacyTeamCandidateId(group.coordinatorId, group.groupId) === candidateRef)).find((item) => item.candidateRef === candidateRef);
|
|
8770
|
+
if (!discoveredCandidate) return c.json({ error: "team_setup_confirmation_stale" }, 404);
|
|
8771
|
+
candidate = discoveredCandidate;
|
|
8772
|
+
draft = getLegacyTeamSetupDraft(store.db, candidateRef);
|
|
8773
|
+
}
|
|
8774
|
+
if (!draft) return c.json({ error: "team_setup_confirmation_stale" }, 404);
|
|
8775
|
+
let conflictState = null;
|
|
8776
|
+
let preview = null;
|
|
8777
|
+
if (draft.state !== "completed") try {
|
|
8778
|
+
preview = previewLegacyTeamSetupActivation(store.db, {
|
|
8779
|
+
candidateRef,
|
|
8780
|
+
attemptId: draft.attemptId
|
|
8781
|
+
});
|
|
8782
|
+
} catch (error) {
|
|
8783
|
+
const code = apiErrorCode(error);
|
|
8784
|
+
if (code === "team_setup_failed") return c.json({ error: code }, 503);
|
|
8785
|
+
preview = null;
|
|
8786
|
+
conflictState = code;
|
|
8787
|
+
}
|
|
8788
|
+
if (preview) requireBoundedAccessDelta(preview.accessDelta);
|
|
8789
|
+
const viewDraft = conflictState ? getLegacyTeamSetupDraft(store.db, candidateRef) ?? draft : draft;
|
|
8790
|
+
const safeDraft = viewerSafeDraft(store, viewDraft);
|
|
8791
|
+
const responseBase = {
|
|
8792
|
+
version: TEAM_SETUP_VERSION,
|
|
8793
|
+
candidate: candidateSummaryForDraft(candidate, viewDraft),
|
|
8794
|
+
attemptId: viewDraft.attemptId,
|
|
8795
|
+
draftState: viewDraft.state,
|
|
8796
|
+
unresolvedDeviceCount: viewDraft.unresolvedDeviceCount,
|
|
8797
|
+
unresolvedProjectCount: viewDraft.unresolvedProjectCount,
|
|
8798
|
+
...safeDraft
|
|
8799
|
+
};
|
|
8800
|
+
if (!preview) {
|
|
8801
|
+
const response = {
|
|
8802
|
+
...responseBase,
|
|
8803
|
+
canFinish: false,
|
|
8804
|
+
conflictState
|
|
8805
|
+
};
|
|
8806
|
+
return c.json(response);
|
|
8807
|
+
}
|
|
8808
|
+
const accessDelta = viewerSafeAccessDelta(candidateRef, preview.accessDelta, {
|
|
8809
|
+
teamDisplayName: viewDraft.displayName,
|
|
8810
|
+
...safeDraft
|
|
8811
|
+
});
|
|
8812
|
+
const response = {
|
|
8813
|
+
...responseBase,
|
|
8814
|
+
canFinish: true,
|
|
8815
|
+
conflictState: null,
|
|
8816
|
+
finishDigest: preview.finishDigest,
|
|
8817
|
+
accessDeltaDigest: preview.accessDeltaDigest,
|
|
8818
|
+
viewerAccessDeltaDigest: viewerAccessDeltaDigest(accessDelta),
|
|
8819
|
+
accessDelta
|
|
8820
|
+
};
|
|
8821
|
+
return c.json(response);
|
|
8822
|
+
} catch (error) {
|
|
8823
|
+
const code = apiErrorCode(error);
|
|
8824
|
+
return c.json({ error: code }, errorStatus(code));
|
|
8825
|
+
}
|
|
8826
|
+
});
|
|
8827
|
+
app.put("/api/sync/team-setup/v1/:candidateRef/devices/:deviceRef/assignment", async (c) => {
|
|
8828
|
+
const candidateRef = c.req.param("candidateRef");
|
|
8829
|
+
const deviceRef = c.req.param("deviceRef");
|
|
8830
|
+
if (!CANDIDATE_REF_PATTERN.test(candidateRef) || !DEVICE_REF_PATTERN.test(deviceRef)) return c.json({ error: "team_setup_incomplete" }, 400);
|
|
8831
|
+
const parsed = await parseBoundedJsonObject(c);
|
|
8832
|
+
if (!parsed.ok || !hasExactKeys(parsed.value, [
|
|
8833
|
+
"attemptId",
|
|
8834
|
+
"expectation",
|
|
8835
|
+
"targetIdentityRef"
|
|
8836
|
+
])) return c.json({ error: "team_setup_incomplete" }, 400);
|
|
8837
|
+
const { attemptId, expectation, targetIdentityRef } = parsed.value;
|
|
8838
|
+
if (typeof attemptId !== "string" || !ATTEMPT_ID_PATTERN.test(attemptId) || typeof targetIdentityRef !== "string" || !IDENTITY_REF_PATTERN.test(targetIdentityRef) || !expectation || typeof expectation !== "object" || Array.isArray(expectation)) return c.json({ error: "team_setup_incomplete" }, 400);
|
|
8839
|
+
try {
|
|
8840
|
+
const store = options.getStore();
|
|
8841
|
+
const draft = getLegacyTeamSetupDraft(store.db, candidateRef);
|
|
8842
|
+
if (!draft) return c.json({ error: "team_setup_confirmation_stale" }, 404);
|
|
8843
|
+
if (draft.attemptId !== attemptId) return c.json({ error: "team_setup_confirmation_stale" }, 409);
|
|
8844
|
+
const device = draft.devices.find((item) => item.deviceRef === deviceRef);
|
|
8845
|
+
if (!device) return c.json({ error: "team_setup_confirmation_stale" }, 404);
|
|
8846
|
+
const submittedExpectation = expectation;
|
|
8847
|
+
if (!(device.expectation.kind === "absent" ? hasExactKeys(submittedExpectation, ["kind"]) && submittedExpectation.kind === "absent" : hasExactKeys(submittedExpectation, [
|
|
8848
|
+
"assignmentVersion",
|
|
8849
|
+
"identityRef",
|
|
8850
|
+
"kind"
|
|
8851
|
+
]) && submittedExpectation.kind === "existing" && submittedExpectation.assignmentVersion === device.expectation.assignmentVersion && submittedExpectation.identityRef === requiredIdentityRef(candidateRef, device.expectation.identityId))) return c.json({ error: "team_setup_assignment_changed" }, 409);
|
|
8852
|
+
const target = identityChoices(store, candidateRef).find((choice) => choice.identityRef === targetIdentityRef);
|
|
8853
|
+
if (!target) return c.json({ error: "team_setup_confirmation_stale" }, 404);
|
|
8854
|
+
return c.json(mutationResponse(setLegacyTeamSetupDeviceAssignment(store.db, {
|
|
8855
|
+
attemptId,
|
|
8856
|
+
deviceRef,
|
|
8857
|
+
targetIdentityId: target.identityId,
|
|
8858
|
+
expectation: device.expectation
|
|
8859
|
+
})));
|
|
8860
|
+
} catch (error) {
|
|
8861
|
+
const code = apiErrorCode(error);
|
|
8862
|
+
return c.json({ error: code }, errorStatus(code));
|
|
8863
|
+
}
|
|
8864
|
+
});
|
|
8865
|
+
app.put("/api/sync/team-setup/v1/:candidateRef/devices/:deviceRef/decision", async (c) => {
|
|
8866
|
+
const candidateRef = c.req.param("candidateRef");
|
|
8867
|
+
const deviceRef = c.req.param("deviceRef");
|
|
8868
|
+
if (!CANDIDATE_REF_PATTERN.test(candidateRef) || !DEVICE_REF_PATTERN.test(deviceRef)) return c.json({ error: "team_setup_incomplete" }, 400);
|
|
8869
|
+
const parsed = await parseBoundedJsonObject(c);
|
|
8870
|
+
if (!parsed.ok) return c.json({ error: "team_setup_incomplete" }, 400);
|
|
8871
|
+
const { attemptId, decision, expectedTargetIdentityRef } = parsed.value;
|
|
8872
|
+
if (typeof attemptId !== "string" || !ATTEMPT_ID_PATTERN.test(attemptId) || ![
|
|
8873
|
+
"included",
|
|
8874
|
+
"excluded",
|
|
8875
|
+
"removed"
|
|
8876
|
+
].includes(decision) || (decision === "included" ? !hasExactKeys(parsed.value, [
|
|
8877
|
+
"attemptId",
|
|
8878
|
+
"decision",
|
|
8879
|
+
"expectedTargetIdentityRef"
|
|
8880
|
+
]) || typeof expectedTargetIdentityRef !== "string" || !IDENTITY_REF_PATTERN.test(expectedTargetIdentityRef) : !hasExactKeys(parsed.value, ["attemptId", "decision"]))) return c.json({ error: "team_setup_incomplete" }, 400);
|
|
8881
|
+
try {
|
|
8882
|
+
const store = options.getStore();
|
|
8883
|
+
const draft = getLegacyTeamSetupDraft(store.db, candidateRef);
|
|
8884
|
+
if (!draft) return c.json({ error: "team_setup_confirmation_stale" }, 404);
|
|
8885
|
+
if (draft.attemptId !== attemptId) return c.json({ error: "team_setup_confirmation_stale" }, 409);
|
|
8886
|
+
const device = draft.devices.find((item) => item.deviceRef === deviceRef);
|
|
8887
|
+
if (!device) return c.json({ error: "team_setup_confirmation_stale" }, 404);
|
|
8888
|
+
if (decision === "included" && (device.targetIdentityId === null || requiredIdentityRef(candidateRef, device.targetIdentityId) !== expectedTargetIdentityRef)) return c.json({ error: "team_setup_assignment_changed" }, 409);
|
|
8889
|
+
return c.json(mutationResponse(setLegacyTeamSetupDeviceDecision(store.db, {
|
|
8890
|
+
attemptId,
|
|
8891
|
+
deviceRef,
|
|
8892
|
+
decision
|
|
8893
|
+
})));
|
|
8894
|
+
} catch (error) {
|
|
8895
|
+
const code = apiErrorCode(error);
|
|
8896
|
+
return c.json({ error: code }, errorStatus(code));
|
|
8897
|
+
}
|
|
8898
|
+
});
|
|
8899
|
+
app.delete("/api/sync/team-setup/v1/:candidateRef/devices/:deviceRef/decision", async (c) => {
|
|
8900
|
+
const candidateRef = c.req.param("candidateRef");
|
|
8901
|
+
const deviceRef = c.req.param("deviceRef");
|
|
8902
|
+
if (!CANDIDATE_REF_PATTERN.test(candidateRef) || !DEVICE_REF_PATTERN.test(deviceRef)) return c.json({ error: "team_setup_incomplete" }, 400);
|
|
8903
|
+
const parsed = await parseBoundedJsonObject(c);
|
|
8904
|
+
if (!parsed.ok || !hasExactKeys(parsed.value, ["attemptId"])) return c.json({ error: "team_setup_incomplete" }, 400);
|
|
8905
|
+
const { attemptId } = parsed.value;
|
|
8906
|
+
if (typeof attemptId !== "string" || !ATTEMPT_ID_PATTERN.test(attemptId)) return c.json({ error: "team_setup_incomplete" }, 400);
|
|
8907
|
+
try {
|
|
8908
|
+
const store = options.getStore();
|
|
8909
|
+
const draft = getLegacyTeamSetupDraft(store.db, candidateRef);
|
|
8910
|
+
if (!draft) return c.json({ error: "team_setup_confirmation_stale" }, 404);
|
|
8911
|
+
if (draft.attemptId !== attemptId) return c.json({ error: "team_setup_confirmation_stale" }, 409);
|
|
8912
|
+
if (!draft.devices.some((item) => item.deviceRef === deviceRef)) return c.json({ error: "team_setup_confirmation_stale" }, 404);
|
|
8913
|
+
return c.json(mutationResponse(clearLegacyTeamSetupDeviceDecision(store.db, {
|
|
8914
|
+
attemptId,
|
|
8915
|
+
deviceRef
|
|
8916
|
+
})));
|
|
8917
|
+
} catch (error) {
|
|
8918
|
+
const code = apiErrorCode(error);
|
|
8919
|
+
return c.json({ error: code }, errorStatus(code));
|
|
8920
|
+
}
|
|
8921
|
+
});
|
|
8922
|
+
app.put("/api/sync/team-setup/v1/:candidateRef/projects/:projectRef/mapping", async (c) => {
|
|
8923
|
+
const candidateRef = c.req.param("candidateRef");
|
|
8924
|
+
const projectRef = c.req.param("projectRef");
|
|
8925
|
+
if (!CANDIDATE_REF_PATTERN.test(candidateRef) || !PROJECT_REF_PATTERN.test(projectRef)) return c.json({ error: "team_setup_incomplete" }, 400);
|
|
8926
|
+
const parsed = await parseBoundedJsonObject(c);
|
|
8927
|
+
if (!parsed.ok || !hasExactKeys(parsed.value, ["attemptId", "resolvedProjectRef"])) return c.json({ error: "team_setup_incomplete" }, 400);
|
|
8928
|
+
const { attemptId, resolvedProjectRef } = parsed.value;
|
|
8929
|
+
if (typeof attemptId !== "string" || !ATTEMPT_ID_PATTERN.test(attemptId) || typeof resolvedProjectRef !== "string" || !RESOLVED_PROJECT_REF_PATTERN.test(resolvedProjectRef)) return c.json({ error: "team_setup_incomplete" }, 400);
|
|
8930
|
+
try {
|
|
8931
|
+
const store = options.getStore();
|
|
8932
|
+
const draft = getLegacyTeamSetupDraft(store.db, candidateRef);
|
|
8933
|
+
if (!draft) return c.json({ error: "team_setup_confirmation_stale" }, 404);
|
|
8934
|
+
if (draft.attemptId !== attemptId) return c.json({ error: "team_setup_confirmation_stale" }, 409);
|
|
8935
|
+
const project = draft.projects.find((item) => item.projectRef === projectRef);
|
|
8936
|
+
if (!project) return c.json({ error: "team_setup_confirmation_stale" }, 404);
|
|
8937
|
+
if (project.resolution === "deterministic") return c.json({ error: "team_setup_incomplete" }, 400);
|
|
8938
|
+
const target = projectMappingChoices(store).find((choice) => legacyTeamResolvedProjectRef(projectRef, choice.projectIdentity) === resolvedProjectRef);
|
|
8939
|
+
if (!target) return c.json({ error: "team_setup_confirmation_stale" }, 404);
|
|
8940
|
+
return c.json(mutationResponse(setLegacyTeamSetupProjectMapping(store.db, {
|
|
8941
|
+
attemptId,
|
|
8942
|
+
projectRef,
|
|
8943
|
+
resolvedProjectIdentity: target.projectIdentity
|
|
8944
|
+
})));
|
|
8945
|
+
} catch (error) {
|
|
8946
|
+
const code = apiErrorCode(error);
|
|
8947
|
+
return c.json({ error: code }, errorStatus(code));
|
|
8948
|
+
}
|
|
8949
|
+
});
|
|
8950
|
+
app.post("/api/sync/team-setup/v1/:candidateRef/refresh", async (c) => {
|
|
8951
|
+
const candidateRef = c.req.param("candidateRef");
|
|
8952
|
+
if (!CANDIDATE_REF_PATTERN.test(candidateRef)) return c.json({ error: "team_setup_incomplete" }, 400);
|
|
8953
|
+
const parsed = await parseBoundedJsonObject(c);
|
|
8954
|
+
if (!parsed.ok || !hasExactKeys(parsed.value, [])) return c.json({ error: "team_setup_incomplete" }, 400);
|
|
8955
|
+
let groups;
|
|
8956
|
+
try {
|
|
8957
|
+
groups = await loadedCandidateSnapshots(candidateRef);
|
|
8958
|
+
} catch (error) {
|
|
8959
|
+
const code = apiErrorCode(error);
|
|
8960
|
+
return c.json({ error: code }, errorStatus(code));
|
|
8961
|
+
}
|
|
8962
|
+
if (groups.filter((group) => legacyTeamCandidateId(group.coordinatorId, group.groupId) === candidateRef).length === 0) return c.json({ error: "team_setup_confirmation_stale" }, 404);
|
|
8963
|
+
try {
|
|
8964
|
+
const store = options.getStore();
|
|
8965
|
+
return c.json(mutationResponse(refreshLegacyTeamCandidate(store.db, {
|
|
8966
|
+
projection: projectionOptions(store),
|
|
8967
|
+
groups
|
|
8968
|
+
}, candidateRef)));
|
|
8969
|
+
} catch (error) {
|
|
8970
|
+
const code = apiErrorCode(error);
|
|
8971
|
+
return c.json({ error: code }, errorStatus(code));
|
|
8972
|
+
}
|
|
8973
|
+
});
|
|
8974
|
+
app.post("/api/sync/team-setup/v1/:candidateRef/finish", async (c) => {
|
|
8975
|
+
const candidateRef = c.req.param("candidateRef");
|
|
8976
|
+
if (!CANDIDATE_REF_PATTERN.test(candidateRef)) return c.json({ error: "team_setup_incomplete" }, 400);
|
|
8977
|
+
const parsed = await parseBoundedJsonObject(c);
|
|
8978
|
+
if (!parsed.ok) return c.json({ error: "team_setup_incomplete" }, 400);
|
|
8979
|
+
const finishKeys = [
|
|
8980
|
+
"attemptId",
|
|
8981
|
+
"confirmedAccessDeltaDigest",
|
|
8982
|
+
"confirmedViewerAccessDeltaDigest",
|
|
8983
|
+
"finishDigest"
|
|
8984
|
+
];
|
|
8985
|
+
if (!hasExactKeys(parsed.value, finishKeys)) return Object.keys(parsed.value).every((key) => finishKeys.includes(key)) ? c.json({ error: "team_setup_confirmation_stale" }, 409) : c.json({ error: "team_setup_incomplete" }, 400);
|
|
8986
|
+
const { attemptId, confirmedAccessDeltaDigest, confirmedViewerAccessDeltaDigest, finishDigest } = parsed.value;
|
|
8987
|
+
if (typeof attemptId !== "string" || !ATTEMPT_ID_PATTERN.test(attemptId) || typeof finishDigest !== "string" || !FINISH_DIGEST_PATTERN.test(finishDigest) || typeof confirmedAccessDeltaDigest !== "string" || !ACCESS_DELTA_DIGEST_PATTERN.test(confirmedAccessDeltaDigest) || typeof confirmedViewerAccessDeltaDigest !== "string" || !VIEWER_ACCESS_DELTA_DIGEST_PATTERN.test(confirmedViewerAccessDeltaDigest)) return c.json({ error: "team_setup_incomplete" }, 400);
|
|
8988
|
+
try {
|
|
8989
|
+
const store = options.getStore();
|
|
8990
|
+
const draft = getLegacyTeamSetupDraft(store.db, candidateRef);
|
|
8991
|
+
if (!draft) return c.json({ error: "team_setup_confirmation_stale" }, 404);
|
|
8992
|
+
if (draft.attemptId !== attemptId) return c.json({ error: "team_setup_confirmation_stale" }, 409);
|
|
8993
|
+
if (draft.state !== "completed") {
|
|
8994
|
+
const preview = previewLegacyTeamSetupActivation(store.db, {
|
|
8995
|
+
candidateRef,
|
|
8996
|
+
attemptId: draft.attemptId
|
|
8997
|
+
});
|
|
8998
|
+
requireBoundedAccessDelta(preview.accessDelta);
|
|
8999
|
+
const safeDraft = viewerSafeDraft(store, draft);
|
|
9000
|
+
if (viewerAccessDeltaDigest(viewerSafeAccessDelta(candidateRef, preview.accessDelta, {
|
|
9001
|
+
teamDisplayName: draft.displayName,
|
|
9002
|
+
...safeDraft
|
|
9003
|
+
})) !== confirmedViewerAccessDeltaDigest) return c.json({ error: "team_setup_confirmation_stale" }, 409);
|
|
9004
|
+
}
|
|
9005
|
+
const result = await finishLegacyTeamSetupActivation(store.db, {
|
|
9006
|
+
candidateRef,
|
|
9007
|
+
attemptId,
|
|
9008
|
+
finishDigest,
|
|
9009
|
+
confirmedAccessDeltaDigest,
|
|
9010
|
+
loadFreshRoster: async () => {
|
|
9011
|
+
const matching = (await loadedCandidateSnapshots(candidateRef)).filter((group) => legacyTeamCandidateId(group.coordinatorId, group.groupId) === candidateRef);
|
|
9012
|
+
if (matching.length !== 1) throw safeCoordinatorError();
|
|
9013
|
+
return matching[0]?.devices ?? [];
|
|
9014
|
+
},
|
|
9015
|
+
loadProjectInventory: () => legacyTeamCandidateProjectInventory(store.db, projectionOptions(store), candidateRef),
|
|
9016
|
+
validateLockedPreview: (lockedPreview) => {
|
|
9017
|
+
requireBoundedAccessDelta(lockedPreview.accessDelta);
|
|
9018
|
+
const lockedDraft = getLegacyTeamSetupDraft(store.db, candidateRef);
|
|
9019
|
+
if (!lockedDraft || lockedDraft.attemptId !== attemptId) return false;
|
|
9020
|
+
const safeDraft = viewerSafeDraft(store, lockedDraft);
|
|
9021
|
+
return viewerAccessDeltaDigest(viewerSafeAccessDelta(candidateRef, lockedPreview.accessDelta, {
|
|
9022
|
+
teamDisplayName: lockedDraft.displayName,
|
|
9023
|
+
...safeDraft
|
|
9024
|
+
})) === confirmedViewerAccessDeltaDigest;
|
|
9025
|
+
}
|
|
9026
|
+
});
|
|
9027
|
+
return c.json(finishResponse(candidateRef, result));
|
|
9028
|
+
} catch (error) {
|
|
9029
|
+
const code = apiErrorCode(error);
|
|
9030
|
+
return c.json({ error: code }, errorStatus(code));
|
|
9031
|
+
}
|
|
9032
|
+
});
|
|
9033
|
+
return app;
|
|
9034
|
+
}
|
|
9035
|
+
//#endregion
|
|
7685
9036
|
//#region src/routes/update-status.ts
|
|
7686
9037
|
function updateStatusRoutes(options = {}) {
|
|
7687
9038
|
const getUpdateStatus$1 = options.getUpdateStatus ?? getUpdateStatus;
|
|
@@ -7732,7 +9083,7 @@ function createApp(opts) {
|
|
|
7732
9083
|
const getSyncRuntimeStatus = opts?.getSyncRuntimeStatus ?? (() => null);
|
|
7733
9084
|
const app = new Hono();
|
|
7734
9085
|
app.use("*", preflightHandler());
|
|
7735
|
-
app.use("*", originGuard());
|
|
9086
|
+
app.use("*", originGuard({ unsafeGetPathPrefixes: [TEAM_SETUP_ROUTE_PREFIX] }));
|
|
7736
9087
|
app.route("/", healthRoutes(storeFactory));
|
|
7737
9088
|
app.route("/", statsRoutes(storeFactory));
|
|
7738
9089
|
app.route("/", memoryRoutes(storeFactory));
|
|
@@ -7744,7 +9095,11 @@ function createApp(opts) {
|
|
|
7744
9095
|
}));
|
|
7745
9096
|
app.route("/", configRoutes({ getSweeper: () => sweeper }));
|
|
7746
9097
|
app.route("/", rawEventsRoutes(storeFactory, sweeper));
|
|
7747
|
-
app.route("/", syncRoutes(storeFactory, getSyncRuntimeStatus));
|
|
9098
|
+
app.route("/", syncRoutes(storeFactory, getSyncRuntimeStatus, { loadDeviceIdentityCoordinatorEvidence: opts?.loadDeviceIdentityCoordinatorEvidence }));
|
|
9099
|
+
app.route("/", teamSetupRoutes({
|
|
9100
|
+
getStore: storeFactory,
|
|
9101
|
+
loadLegacyTeamConfiguredGroupSnapshots: opts?.loadLegacyTeamConfiguredGroupSnapshots
|
|
9102
|
+
}));
|
|
7748
9103
|
app.route("/", updateStatusRoutes({ getUpdateStatus: opts?.getUpdateStatus }));
|
|
7749
9104
|
const staticRoot = process.env.CODEMEM_VIEWER_STATIC_DIR ?? join(import.meta.dirname ?? ".", "../static");
|
|
7750
9105
|
app.use("/assets/*", async (c, next) => {
|