@codemem/server 0.46.3 → 0.47.0-beta.3
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/account/context.d.ts +41 -0
- package/dist/account/context.d.ts.map +1 -0
- package/dist/account/guard.d.ts +13 -0
- package/dist/account/guard.d.ts.map +1 -0
- package/dist/account/link-registry.d.ts +51 -0
- package/dist/account/link-registry.d.ts.map +1 -0
- package/dist/account/status.d.ts +65 -0
- package/dist/account/status.d.ts.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1579 -396
- package/dist/index.js.map +1 -1
- package/dist/routes/account-operations.d.ts +11 -0
- package/dist/routes/account-operations.d.ts.map +1 -0
- package/dist/routes/account-status.d.ts +7 -0
- package/dist/routes/account-status.d.ts.map +1 -0
- package/dist/routes/account.d.ts +9 -0
- package/dist/routes/account.d.ts.map +1 -0
- package/dist/routes/file-context.d.ts +4 -0
- package/dist/routes/file-context.d.ts.map +1 -0
- package/dist/routes/memory-tools.d.ts.map +1 -1
- package/dist/routes/memory.d.ts.map +1 -1
- package/dist/routes/stats.d.ts.map +1 -1
- package/dist/routes/sync.d.ts +1 -29
- package/dist/routes/sync.d.ts.map +1 -1
- package/dist/routes/target-validation.d.ts.map +1 -1
- package/dist/routes/team-setup.d.ts.map +1 -1
- package/package.json +2 -2
- package/static/app.js +52 -49
- package/static/app.js.br +0 -0
- package/static/app.js.gz +0 -0
- package/static/controls.css +175 -3
- package/static/controls.css.br +0 -0
- package/static/controls.css.gz +0 -0
- package/static/index.html +109 -305
package/dist/index.js
CHANGED
|
@@ -1,16 +1,120 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { dirname, isAbsolute, join, posix, resolve, win32 } from "node:path";
|
|
3
|
-
import { ACCESS_CLEANUP_OP_TYPE, CodememConfigMutationError, CoordinatorReciprocalApprovalRequestChangedError, DEFAULT_TIME_WINDOW_S, LEGACY_SHARED_REVIEW_SCOPE_ID, LOCAL_DEFAULT_SCOPE_ID, LOCAL_SYNC_CAPABILITY, LOCAL_SYNC_FEATURES, LegacyTeamSetupAdditiveConvergenceError, LegacyTeamSetupRosterCapacityError, MEMORY_FILTER_FIELD_TYPES, MEMORY_FILTER_NAMES, MEMORY_KIND_DESCRIPTIONS, MemoryStore, ObserverClient, PROMPT_TRANSPORT_PROTOCOL_RANGE, REMEMBER_MEMORY_KINDS, RICH_TIER_ANTHROPIC_DEFAULTS, RICH_TIER_DEFAULTS, RawEventIngestValidationError, RecipientPolicyEdgeRequestError, RecipientPolicyTeamRenameError, RemoteCoordinatorRequestError, SCOPE_MEMBERSHIP_REVOCATION_LIMITATION, SIMPLE_TIER_ANTHROPIC_DEFAULTS, SIMPLE_TIER_DEFAULTS, 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, analyzeProjectScopeMappingChangesGuardrails, analyzeProjectScopeMappingDeletionGuardrails, applyLegacyTeamSetupCompletionManifest, applyLegacyTeamSetupCompletionManifestAndReturnActivation, applyReplicationOps, areLegacyTeamSetupCompletionPolicyFactsAdditivelyCompatible, automaticRecallHealth, buildAuthHeaders, buildBaseUrl, buildDirectPeerAuthHeaders, buildDistillReport, buildFilterClausesWithContext, buildPiFlushSignalFromEvent, buildRawEventEnvelopeFromCodexHook, buildRawEventEnvelopeFromHook, buildRawEventEnvelopeFromPiEvent, buildViewerIdentityTarget, canonicalCoordinatorLegacyTeamCompletionManifestJson, canonicalMemoryKind, canonicalRepositoryProjectIdentity, canonicalWorkspaceIdentity, claimRecipientPolicyActorMutations, claimRecipientPolicyPublicationMutation, classifyRecordedSyncFailure, cleanupNonces, clearLegacyTeamSetupDeviceDecision, clonePromptPackAttempt, coerceObserverCommand, commitDeviceIdentityBindings, commitRecipientPolicyEdges, containLegacyTeamSetupCompletionConflict, coordinatorArchiveGroupAction, coordinatorCreateAddDeviceInviteAction, coordinatorCreateGroupAction, coordinatorCreateInviteAction, coordinatorCreateLegacyTeamCompletionAction, coordinatorCreateScopeAction, coordinatorDisableDeviceAction, coordinatorEnableDeviceAction, coordinatorGetLegacyTeamCompletionAction, coordinatorGrantScopeMembershipAction, coordinatorImportInviteAction, coordinatorListBootstrapGrantsAction, coordinatorListConsumedTeamInvitesAction, coordinatorListDevicesAction, coordinatorListGroupsAction, coordinatorListJoinRequestsAction, coordinatorListLegacyTeamCompletionsAction, coordinatorListReviewedRecipientInviteEvidenceAction, coordinatorListScopeMembershipsAction, coordinatorListScopesAction, coordinatorRemoveDeviceAction, coordinatorRenameDeviceAction, coordinatorRenameGroupAction, coordinatorReviewJoinRequestAction, coordinatorRevokeBootstrapGrantAction, coordinatorRevokeScopeMembershipAction, coordinatorStatusSnapshot, coordinatorUnarchiveGroupAction, coordinatorUpdateScopeAction, countShareableProjectMemories, createCoordinatorReciprocalApproval, decodeInvitePayload, dedupeOrderedIds, defaultSpaceScopeIdForGroup, deleteProjectScopeSettingsMapping, deriveLegacyTeamSetupCompletionManifest, detectInstallKind, deterministicPolicyTeamId, diagnoseStalePeerReceivedRows, discoverLegacyTeamCandidates, ensureDeviceIdentity, executeShareProvisioning, extractInvitePayload, extractReplicationOps, filterReplicationOpsForSyncWithStatus, fingerprintPublicKey, formatHostPort, friendlyDeviceName, fromJson, generateWithOpenCodeV2, getCodememConfigPath, getCodememEnvOverrideValues, getCodememEnvOverrides, getCoordinatorEnrollmentReconciliationIssueSummary, getCoordinatorGroupPreference, getLegacyTeamSetupDraft, getRecipientPolicyAuthorityState, getSemanticIndexDiagnostics, getSyncResetState, getUpdateStatus, ingestRawEvents, inspectFreshLegacyTeamSetupActivation, inspectLegacyTeamSetupActivation, inviteTokenDigest, isAutomaticRecallMeasurement, isLegacyTeamCandidateSelectable, isLegacyTeamSetupProjectMappingIdentity, isPeerTrustBindingCompatible, isProjectSyncEnablementError, isScopedSyncCapability, judgeDistillReport, legacyTeamCandidateId, legacyTeamCandidateProjectInventory, legacyTeamCanonicalProjectRef, legacyTeamDeviceRef, legacyTeamResolvedProjectRef, legacyTeamSetupApiErrorCode, listAuthorizedScopesForPeer, listCoordinatorJoinRequests, listDeviceIdentityInventory, listInboundScopeRejections, listLegacyRecipientPolicyProjections, listMaintenanceJobs, listObserverProviderOptions, listOpenCodeV2Models, listPerPeerScopeSyncState, listProjectScopeCandidates, listProjectScopeInventory, listProjectScopeSettingsMappings, listRecipientPolicyIntent, listRecipientPolicyReview, listSharingDomainSettingsScopes, loadMemorySnapshotPageForPeer, loadReplicationOpsForPeer, lookupCoordinatorPeers, memoryFilterValueMatchesType, mergeAddresses, migrateRecipientPolicyIntent, mutateCodememConfigFile, negotiateSyncCapability, normalizeAddress, normalizeHumanPresentationName, normalizeSyncCapability, normalizeSyncFeatures, normalizeTeammateName, parseAcceptedProjectIntent, parsePositiveMemoryId, parseReassignScopePayload, parseRecipientPolicyEdgeCommitRequest, parseStrictInteger, parseSyncScopeRequest, persistShareOperation, personalScopeGrantStatusForPeer, planShareOperation, previewDeviceIdentityBindings, previewLegacyTeamSetupActivation, previewRecipientPolicyEdges, previewRecipientPolicyOnboarding, previewRecipientPolicyOnboardingFromReviewedIntent, probeAvailableCredentials, projectMatchesFilter, projectShareLifecycle, promptPackArtifactFingerprint, readCodememConfigFile, readCoordinatorSyncConfig, reassignProjectScopeInventoryProject, recipientInviteAuthoritativeIdentityId, recipientPolicyDigest, recipientReviewedIntentDigest, reconcileCoordinatorEnrollmentSnapshot, reconcileRecipientPolicyProject, reconcileShareOperationAcceptance, reconstructLegacyTeamSetupCompletionManifest, recordAutomaticRecall, recordHighestObservedDirectSignatureVersion, recordNonce, recordPromptPackArtifacts, recordPromptPackTerminal, refreshAuthorizedCoordinatorPeerTrust, refreshConfiguredScopeMembershipCache, refreshLegacyTeamCandidate, rejectInboundScopeFailures, renameRecipientPolicyTeam, replayLegacyTeamSetupActivation, repositoryIdentitiesByWorkspace, repositoryIdentityForWorkspace, requestJson, requireLegacyTeamSetupAccessDeltaWithinLimit, resolveDbPath, resolveObserverDefaultModel, resolveObserverRuntime, resolveProject, resolveProjectRoot, resolveRecipientPolicyReview, resolveRecipientPolicyReviewBulk, runSyncPass, schema, serializeRecipientPolicyCoordinatorGroupMutation, serializeRecipientPolicyPublicationMutation, serializeRecipientPolicyTeamMutation, setLegacyTeamSetupDeviceAssignment, setLegacyTeamSetupDeviceDecision, setLegacyTeamSetupProjectMapping,
|
|
3
|
+
import { ACCESS_CLEANUP_OP_TYPE, CodememConfigMutationError, CoordinatorAccountLinkError, CoordinatorReciprocalApprovalRequestChangedError, DEFAULT_TIME_WINDOW_S, FILE_CONTEXT_TRANSPORT_MAX_BYTES, LEGACY_SHARED_REVIEW_SCOPE_ID, LOCAL_DEFAULT_SCOPE_ID, LOCAL_SYNC_CAPABILITY, LOCAL_SYNC_FEATURES, LegacyTeamSetupAdditiveConvergenceError, LegacyTeamSetupRosterCapacityError, MEMORY_FILTER_FIELD_TYPES, MEMORY_FILTER_NAMES, MEMORY_KIND_DESCRIPTIONS, MemoryStore, ObserverClient, PROMPT_TRANSPORT_PROTOCOL_RANGE, REMEMBER_MEMORY_KINDS, RICH_TIER_ANTHROPIC_DEFAULTS, RICH_TIER_DEFAULTS, RawEventIngestValidationError, RecipientPolicyEdgeRequestError, RecipientPolicyTeamRenameError, RemoteCoordinatorRequestError, SCOPE_MEMBERSHIP_REVOCATION_LIMITATION, SIMPLE_TIER_ANTHROPIC_DEFAULTS, SIMPLE_TIER_DEFAULTS, 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, analyzeProjectScopeMappingChangesGuardrails, analyzeProjectScopeMappingDeletionGuardrails, applyLegacyTeamSetupCompletionManifest, applyLegacyTeamSetupCompletionManifestAndReturnActivation, applyReplicationOps, areLegacyTeamSetupCompletionPolicyFactsAdditivelyCompatible, automaticRecallHealth, buildAuthHeaders, buildBaseUrl, buildDirectPeerAuthHeaders, buildDistillReport, buildFilterBranchesWithContext, buildFilterClausesWithContext, buildPiFlushSignalFromEvent, buildRawEventEnvelopeFromCodexHook, buildRawEventEnvelopeFromHook, buildRawEventEnvelopeFromPiEvent, buildViewerIdentityTarget, canonicalCoordinatorLegacyTeamCompletionManifestJson, canonicalMemoryKind, canonicalRepositoryProjectIdentity, canonicalWorkspaceIdentity, changeCoordinatorAccount, claimRecipientPolicyActorMutations, claimRecipientPolicyPublicationMutation, classifyRecordedSyncFailure, cleanupNonces, clearLegacyTeamSetupDeviceDecision, clonePromptPackAttempt, coerceObserverCommand, commitDeviceIdentityBindings, commitRecipientPolicyEdges, containLegacyTeamSetupCompletionConflict, coordinatorArchiveGroupAction, coordinatorCreateAddDeviceInviteAction, coordinatorCreateGroupAction, coordinatorCreateInviteAction, coordinatorCreateLegacyTeamCompletionAction, coordinatorCreateScopeAction, coordinatorDisableDeviceAction, coordinatorEnableDeviceAction, coordinatorGetLegacyTeamCompletionAction, coordinatorGrantScopeMembershipAction, coordinatorImportInviteAction, coordinatorListBootstrapGrantsAction, coordinatorListConsumedTeamInvitesAction, coordinatorListDevicesAction, coordinatorListGroupsAction, coordinatorListJoinRequestsAction, coordinatorListLegacyTeamCompletionsAction, coordinatorListReviewedRecipientInviteEvidenceAction, coordinatorListScopeMembershipsAction, coordinatorListScopesAction, coordinatorRemoveDeviceAction, coordinatorRenameDeviceAction, coordinatorRenameGroupAction, coordinatorReviewJoinRequestAction, coordinatorRevokeBootstrapGrantAction, coordinatorRevokeScopeMembershipAction, coordinatorStatusSnapshot, coordinatorUnarchiveGroupAction, coordinatorUpdateScopeAction, countShareableProjectMemories, createCoordinatorReciprocalApproval, decodeInvitePayload, dedupeOrderedIds, defaultSpaceScopeIdForGroup, deleteProjectScopeSettingsMapping, deriveLegacyTeamSetupCompletionManifest, detectInstallKind, deterministicPolicyTeamId, diagnoseStalePeerReceivedRows, discoverLegacyTeamCandidates, dispatchFileContextTransport, ensureDeviceIdentity, executeShareProvisioning, extractInvitePayload, extractReplicationOps, filterReplicationOpsForSyncWithStatus, fingerprintPublicKey, formatHostPort, friendlyDeviceName, fromJson, generateWithOpenCodeV2, getCodememConfigPath, getCodememEnvOverrideValues, getCodememEnvOverrides, getCoordinatorEnrollmentReconciliationIssueSummary, getCoordinatorGroupPreference, getEffectiveCachedScopeAuthorization, getLegacyTeamSetupDraft, getRecipientPolicyAuthorityState, getSemanticIndexDiagnostics, getSyncResetState, getUpdateStatus, ingestRawEvents, inspectFreshLegacyTeamSetupActivation, inspectLegacyTeamSetupActivation, inviteTokenDigest, isAutomaticRecallMeasurement, isLegacyTeamCandidateSelectable, isLegacyTeamSetupProjectMappingIdentity, isPeerTrustBindingCompatible, isProjectSyncEnablementError, isScopedSyncCapability, judgeDistillReport, legacyTeamCandidateId, legacyTeamCandidateProjectInventory, legacyTeamCanonicalProjectRef, legacyTeamDeviceRef, legacyTeamResolvedProjectRef, legacyTeamSetupApiErrorCode, linkCoordinatorAccount, listAuthorizedScopesForPeer, listCoordinatorJoinRequests, listDeviceIdentityInventory, listInboundScopeRejections, listLegacyRecipientPolicyProjections, listMaintenanceJobs, listObserverProviderOptions, listOpenCodeV2Models, listPerPeerScopeSyncState, listProjectScopeCandidates, listProjectScopeInventory, listProjectScopeSettingsMappings, listRecipientPolicyIntent, listRecipientPolicyReview, listSharingDomainSettingsScopes, loadMemorySnapshotPageForPeer, loadPublicKey, loadReplicationOpsForPeer, loadRuntimeSigningPublicKey, lookupCoordinatorPeers, memoryFilterValueMatchesType, mergeAddresses, migrateRecipientPolicyIntent, mutateCodememConfigFile, negotiateSyncCapability, normalizeAddress, normalizeCoordinatorAccountLinkOrigin, normalizeHumanPresentationName, normalizeSyncCapability, normalizeSyncFeatures, normalizeTeammateName, parseAcceptedProjectIntent, parseBootstrapExpiry, parseFileContextTransportRequest, parsePositiveMemoryId, parseReassignScopePayload, parseRecipientPolicyEdgeCommitRequest, parseStrictInteger, parseSyncScopeRequest, persistShareOperation, personalScopeGrantStatusForPeer, planShareOperation, previewDeviceIdentityBindings, previewLegacyTeamSetupActivation, previewRecipientPolicyEdges, previewRecipientPolicyOnboarding, previewRecipientPolicyOnboardingFromReviewedIntent, probeAvailableCredentials, projectMatchesFilter, projectShareLifecycle, promptPackArtifactFingerprint, readAccountLinkDevice, readCodememConfigFile, readCoordinatorAccountChangeCapabilities, readCoordinatorAccountLinkStatus, readCoordinatorSyncConfig, reassignProjectScopeInventoryProject, recipientInviteAuthoritativeIdentityId, recipientPolicyDigest, recipientReviewedIntentDigest, reconcileCoordinatorEnrollmentSnapshot, reconcileRecipientPolicyProject, reconcileShareOperationAcceptance, reconstructLegacyTeamSetupCompletionManifest, recordAutomaticRecall, recordHighestObservedDirectSignatureVersion, recordNonce, recordPromptPackArtifacts, recordPromptPackTerminal, refreshAuthorizedCoordinatorPeerTrust, refreshConfiguredScopeMembershipCache, refreshLegacyTeamCandidate, rejectInboundScopeFailures, renameRecipientPolicyTeam, replayLegacyTeamSetupActivation, repositoryIdentitiesByWorkspace, repositoryIdentityForWorkspace, requestJson, requireLegacyTeamSetupAccessDeltaWithinLimit, resolveCodememConfigPath, resolveDbPath, resolveObserverDefaultModel, resolveObserverRuntime, resolveProject, resolveProjectRoot, resolveRecipientPolicyReview, resolveRecipientPolicyReviewBulk, resolveVisibleScopeIds, runSyncPass, schema, scopeAuthorizationFailureReason, serializeRecipientPolicyCoordinatorGroupMutation, serializeRecipientPolicyPublicationMutation, serializeRecipientPolicyTeamMutation, setLegacyTeamSetupDeviceAssignment, setLegacyTeamSetupDeviceDecision, setLegacyTeamSetupProjectMapping, summarizeInboundScopeRejections, summaryLikeSqlPredicate, supportsSyncFeature, syncScopeResetRequiredPayload, toJson, tryUpdateRetrievalDelivery, updatePeerAddresses, upsertCoordinatorGroupPreference, upsertProjectScopeSettingsMappings, validateLegacyTeamSetupCompletionManifest, validateLegacyTeamSetupCompletionManifestBinding, validateRawEvents, verifyDirectPeerSignature, verifyRecipientReviewedIntent, verifySignature, wakeRecipientPoliciesForIdentities, wakeRecipientPoliciesForProjectIdentities } from "@codemem/core";
|
|
4
4
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
5
5
|
import { Hono } from "hono";
|
|
6
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
6
7
|
import { createMiddleware } from "hono/factory";
|
|
7
8
|
import { RateLimiter } from "limiter";
|
|
8
|
-
import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID } from "node:crypto";
|
|
9
9
|
import { and, count, desc, eq, inArray, max, ne } from "drizzle-orm";
|
|
10
10
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
11
11
|
import { homedir, hostname, networkInterfaces } from "node:os";
|
|
12
12
|
import * as z from "zod";
|
|
13
13
|
import { chmod, link, mkdir, open, readFile, readdir, rm, stat } from "node:fs/promises";
|
|
14
|
+
//#region src/routes/account.ts
|
|
15
|
+
/** Mounted only alongside the account request middleware. */
|
|
16
|
+
function accountRoutes(getSession) {
|
|
17
|
+
const app = new Hono();
|
|
18
|
+
app.get("/api/account/session", (c) => c.json(getSession()));
|
|
19
|
+
return app;
|
|
20
|
+
}
|
|
21
|
+
//#endregion
|
|
22
|
+
//#region src/account/guard.ts
|
|
23
|
+
var ACCOUNT_PREFIX = "/api/account";
|
|
24
|
+
var SESSION_PATH = `${ACCOUNT_PREFIX}/session`;
|
|
25
|
+
var LISTENER_HOSTS = {
|
|
26
|
+
"127.0.0.1": ["127.0.0.1", "localhost"],
|
|
27
|
+
"::1": ["[::1]", "localhost"],
|
|
28
|
+
localhost: [
|
|
29
|
+
"127.0.0.1",
|
|
30
|
+
"[::1]",
|
|
31
|
+
"localhost"
|
|
32
|
+
]
|
|
33
|
+
};
|
|
34
|
+
function allowedAuthorities(options) {
|
|
35
|
+
try {
|
|
36
|
+
const address = options.getListenerAddress();
|
|
37
|
+
if (!address || !Number.isInteger(address.port) || address.port < 1 || address.port > 65535) return null;
|
|
38
|
+
const { host, port } = address;
|
|
39
|
+
if (!Object.hasOwn(LISTENER_HOSTS, host)) return null;
|
|
40
|
+
const aliases = LISTENER_HOSTS[host];
|
|
41
|
+
if (!aliases) return null;
|
|
42
|
+
return aliases.flatMap((alias) => {
|
|
43
|
+
const explicit = `${alias}:${port}`;
|
|
44
|
+
return [.../* @__PURE__ */ new Set([explicit, new URL(`http://${explicit}`).host])];
|
|
45
|
+
});
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function validRequestContext(c, authorities) {
|
|
51
|
+
const host = c.req.header("Host");
|
|
52
|
+
if (!host || !authorities.includes(host)) return false;
|
|
53
|
+
const expectedOrigin = new URL(`http://${host}`).origin;
|
|
54
|
+
try {
|
|
55
|
+
if (new URL(c.req.url).origin !== expectedOrigin) return false;
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
const origin = c.req.header("Origin");
|
|
60
|
+
if (origin !== void 0 && origin !== expectedOrigin) return false;
|
|
61
|
+
if (c.req.method === "POST" && origin === void 0) return false;
|
|
62
|
+
const fetchSite = c.req.header("Sec-Fetch-Site");
|
|
63
|
+
return fetchSite === void 0 || fetchSite === "same-origin";
|
|
64
|
+
}
|
|
65
|
+
function validSessionRequest(c) {
|
|
66
|
+
const contentLength = c.req.header("Content-Length");
|
|
67
|
+
return c.req.method === "GET" && !c.req.url.includes("?") && c.req.raw.body === null && (contentLength === void 0 || contentLength === "0") && c.req.header("Transfer-Encoding") === void 0;
|
|
68
|
+
}
|
|
69
|
+
function validCsrfToken(c, token) {
|
|
70
|
+
const supplied = c.req.header("X-Codemem-Account-Csrf");
|
|
71
|
+
if (!supplied || supplied.length !== token.length) return false;
|
|
72
|
+
const candidate = Buffer.from(supplied, "utf8");
|
|
73
|
+
return candidate.length === token.length && timingSafeEqual(candidate, token);
|
|
74
|
+
}
|
|
75
|
+
function requestError(c, options, token) {
|
|
76
|
+
const authorities = allowedAuthorities(options);
|
|
77
|
+
if (!authorities) return "account_local_viewer_required";
|
|
78
|
+
if (c.req.method === "OPTIONS" || !validRequestContext(c, authorities)) return "account_request_rejected";
|
|
79
|
+
if (c.req.path === SESSION_PATH) return validSessionRequest(c) ? null : "account_request_rejected";
|
|
80
|
+
return validCsrfToken(c, token) ? null : "account_csrf_invalid";
|
|
81
|
+
}
|
|
82
|
+
function secureAccountResponse(c) {
|
|
83
|
+
for (const name of [...c.res.headers.keys()]) if (name.toLowerCase().startsWith("access-control-")) c.header(name, void 0);
|
|
84
|
+
c.header("Cache-Control", "no-store");
|
|
85
|
+
c.header("X-Content-Type-Options", "nosniff");
|
|
86
|
+
c.header("Referrer-Policy", "no-referrer");
|
|
87
|
+
}
|
|
88
|
+
/** One token per viewer app; only the exact GET session route can return it. */
|
|
89
|
+
function createAccountGuard(options) {
|
|
90
|
+
const csrfToken = randomBytes(32).toString("base64url");
|
|
91
|
+
const token = Buffer.from(csrfToken, "utf8");
|
|
92
|
+
return {
|
|
93
|
+
middleware: createMiddleware(async (c, next) => {
|
|
94
|
+
if (c.req.path !== ACCOUNT_PREFIX && !c.req.path.startsWith(`${ACCOUNT_PREFIX}/`)) {
|
|
95
|
+
await next();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const error = requestError(c, options, token);
|
|
100
|
+
if (error) {
|
|
101
|
+
c.res = c.json({ error }, 403);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
await next();
|
|
105
|
+
} catch {
|
|
106
|
+
c.res = c.json({ error: "account_request_rejected" }, 500);
|
|
107
|
+
} finally {
|
|
108
|
+
secureAccountResponse(c);
|
|
109
|
+
}
|
|
110
|
+
}),
|
|
111
|
+
routes: accountRoutes(() => ({
|
|
112
|
+
version: 1,
|
|
113
|
+
csrfToken
|
|
114
|
+
}))
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
//#endregion
|
|
14
118
|
//#region src/middleware.ts
|
|
15
119
|
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
|
|
16
120
|
"127.0.0.1",
|
|
@@ -161,6 +265,625 @@ function createInMemoryRequestRateLimiter(windowMs = 6e4) {
|
|
|
161
265
|
} };
|
|
162
266
|
}
|
|
163
267
|
//#endregion
|
|
268
|
+
//#region src/account/link-registry.ts
|
|
269
|
+
var DEADLINE_MS = 6e5;
|
|
270
|
+
var RETENTION_MS = 9e5;
|
|
271
|
+
var BUSY_SLOT = Symbol.for("codemem.account-link.busy-owner");
|
|
272
|
+
var runtime = globalThis;
|
|
273
|
+
runtime[BUSY_SLOT] ??= { owner: null };
|
|
274
|
+
var busySlot = runtime[BUSY_SLOT];
|
|
275
|
+
busySlot.lifecycle ??= Symbol();
|
|
276
|
+
function releaseBusyOwner(owner) {
|
|
277
|
+
if (busySlot.owner !== owner) return;
|
|
278
|
+
busySlot.owner = null;
|
|
279
|
+
busySlot.lifecycle = Symbol();
|
|
280
|
+
}
|
|
281
|
+
var AUTHORITY_FIELDS = [
|
|
282
|
+
"contextId",
|
|
283
|
+
"dbPath",
|
|
284
|
+
"keysDir",
|
|
285
|
+
"configPath",
|
|
286
|
+
"coordinatorUrl",
|
|
287
|
+
"groupId",
|
|
288
|
+
"deviceId",
|
|
289
|
+
"fingerprint",
|
|
290
|
+
"identityId"
|
|
291
|
+
];
|
|
292
|
+
function sameAuthority(captured, fresh) {
|
|
293
|
+
return AUTHORITY_FIELDS.every((key) => captured[key] === fresh[key]) && captured.fileIdentity.dev === fresh.fileIdentity.dev && captured.fileIdentity.ino === fresh.fileIdentity.ino;
|
|
294
|
+
}
|
|
295
|
+
function terminal(job) {
|
|
296
|
+
return job.operation.finishedAt !== null;
|
|
297
|
+
}
|
|
298
|
+
function safeFailure(error) {
|
|
299
|
+
if (!(error instanceof CoordinatorAccountLinkError)) return "request_failed";
|
|
300
|
+
if (error.code === "invalid_options") return "configuration_invalid";
|
|
301
|
+
switch (error.code) {
|
|
302
|
+
case "device_unavailable":
|
|
303
|
+
case "request_failed":
|
|
304
|
+
case "schema_upgrade_required":
|
|
305
|
+
case "review_required":
|
|
306
|
+
case "link_conflict":
|
|
307
|
+
case "invalid_response":
|
|
308
|
+
case "context_changed":
|
|
309
|
+
case "identity_mismatch":
|
|
310
|
+
case "link_stopped":
|
|
311
|
+
case "cancellation_unconfirmed":
|
|
312
|
+
case "receiver_close_failed": return error.code;
|
|
313
|
+
default: return "request_failed";
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
function validBrowserUrl(value, origin, intent) {
|
|
317
|
+
try {
|
|
318
|
+
const url = new URL(value);
|
|
319
|
+
return url.origin === origin && url.pathname === (intent === "refresh" || intent === "disconnect" ? "/auth/account/change/start" : "/auth/link/start") && !url.username && !url.password && !url.hash;
|
|
320
|
+
} catch {
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/** Private URLs and captured profiles stay inside this process, never in public snapshots. */
|
|
325
|
+
var InProcessAccountLinkRegistry = class {
|
|
326
|
+
#options;
|
|
327
|
+
#now;
|
|
328
|
+
#jobs = /* @__PURE__ */ new Map();
|
|
329
|
+
#shuttingDown = false;
|
|
330
|
+
#closing = null;
|
|
331
|
+
#uncertainOwner = null;
|
|
332
|
+
constructor(options) {
|
|
333
|
+
this.#options = options;
|
|
334
|
+
this.#now = options.now ?? Date.now;
|
|
335
|
+
}
|
|
336
|
+
#current(context) {
|
|
337
|
+
try {
|
|
338
|
+
const fresh = this.#options.readContext(context.groupId);
|
|
339
|
+
return !("error" in fresh) && fresh.setup === "ready" && fresh.context !== null && sameAuthority(context, fresh.context);
|
|
340
|
+
} catch {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
#cleanup() {
|
|
345
|
+
const finished = [...this.#jobs.values()].filter(terminal);
|
|
346
|
+
for (const [index, job] of finished.entries()) {
|
|
347
|
+
if (job.operation.state === "outcome_unknown") continue;
|
|
348
|
+
if (this.#now() - Date.parse(job.operation.finishedAt ?? "") >= RETENTION_MS || index < finished.length - 8) this.#jobs.delete(job.operation.operationId);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
#active() {
|
|
352
|
+
this.#cleanup();
|
|
353
|
+
return [...this.#jobs.values()].find((job) => !terminal(job) || !this.#shuttingDown && job.operation.state === "outcome_unknown");
|
|
354
|
+
}
|
|
355
|
+
#inProgress() {
|
|
356
|
+
const pending = this.#active();
|
|
357
|
+
if (pending) return {
|
|
358
|
+
error: "account_link_in_progress",
|
|
359
|
+
operationId: pending.operation.operationId
|
|
360
|
+
};
|
|
361
|
+
return busySlot.owner ? { error: "account_link_in_progress" } : null;
|
|
362
|
+
}
|
|
363
|
+
#snapshot(job) {
|
|
364
|
+
return { ...job.operation };
|
|
365
|
+
}
|
|
366
|
+
#clearUrl(job) {
|
|
367
|
+
job.url = null;
|
|
368
|
+
job.operation.browserUrlAvailable = false;
|
|
369
|
+
}
|
|
370
|
+
#finish(job, state, failure) {
|
|
371
|
+
if (terminal(job)) return;
|
|
372
|
+
this.#clearUrl(job);
|
|
373
|
+
Object.assign(job.operation, {
|
|
374
|
+
state,
|
|
375
|
+
failure,
|
|
376
|
+
finishedAt: new Date(this.#now()).toISOString()
|
|
377
|
+
});
|
|
378
|
+
this.#cleanup();
|
|
379
|
+
}
|
|
380
|
+
#browserStart(job, url) {
|
|
381
|
+
if (this.#shuttingDown || terminal(job) || job.controller.signal.aborted) return;
|
|
382
|
+
if (!this.#current(job.context)) job.abortFailure = "context_changed";
|
|
383
|
+
else if (!validBrowserUrl(url, job.context.coordinatorUrl, job.operation.intent)) job.abortFailure = "invalid_response";
|
|
384
|
+
if (job.abortFailure) {
|
|
385
|
+
this.#clearUrl(job);
|
|
386
|
+
job.controller.abort();
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
job.url = url;
|
|
390
|
+
job.operation.state = "awaiting_browser";
|
|
391
|
+
job.operation.browserUrlAvailable = job.deliveries < 5;
|
|
392
|
+
}
|
|
393
|
+
async #run(job, expectedCoordinatorId, expectedAuthority) {
|
|
394
|
+
try {
|
|
395
|
+
if (terminal(job) || job.controller.signal.aborted) {
|
|
396
|
+
this.#finish(job, "cancelled", "link_stopped");
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const { dbPath, keysDir, groupId, coordinatorUrl, identityId } = job.context;
|
|
400
|
+
const options = {
|
|
401
|
+
dbPath,
|
|
402
|
+
keysDir,
|
|
403
|
+
groupId,
|
|
404
|
+
coordinatorUrl,
|
|
405
|
+
expectedIdentityId: identityId,
|
|
406
|
+
expectedCoordinatorId,
|
|
407
|
+
assertCurrentContext: () => this.#current(job.context),
|
|
408
|
+
signal: job.controller.signal,
|
|
409
|
+
onBrowserStart: (url) => this.#browserStart(job, url)
|
|
410
|
+
};
|
|
411
|
+
const intent = job.operation.intent ?? "connect";
|
|
412
|
+
if (intent === "connect") await (this.#options.link ?? linkCoordinatorAccount)({
|
|
413
|
+
...options,
|
|
414
|
+
expectedAuthority
|
|
415
|
+
});
|
|
416
|
+
else {
|
|
417
|
+
const result = await (this.#options.change ?? changeCoordinatorAccount)({
|
|
418
|
+
...options,
|
|
419
|
+
intent,
|
|
420
|
+
expectedLinkId: job.expectedLinkId
|
|
421
|
+
});
|
|
422
|
+
if (result.state !== "completed" || result.intent !== intent || result.coordinatorId !== expectedCoordinatorId || result.identityId !== identityId) throw new CoordinatorAccountLinkError("invalid_response");
|
|
423
|
+
}
|
|
424
|
+
this.#finish(job, "succeeded", null);
|
|
425
|
+
} catch (error) {
|
|
426
|
+
this.#fail(job, error);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
#fail(job, error) {
|
|
430
|
+
let failure = safeFailure(error);
|
|
431
|
+
if (job.abortFailure && (failure === "link_stopped" || failure === "request_failed")) failure = job.abortFailure;
|
|
432
|
+
let state = "failed";
|
|
433
|
+
if (failure === "cancellation_unconfirmed" || failure === "receiver_close_failed") state = "outcome_unknown";
|
|
434
|
+
else if (job.cancelled && (failure === "link_stopped" || failure === "request_failed")) state = "cancelled";
|
|
435
|
+
this.#finish(job, state, failure);
|
|
436
|
+
}
|
|
437
|
+
start({ intent = "connect", context, expectedCoordinatorId, expectedAuthority, expectedLifecycleToken, expectedLinkId }) {
|
|
438
|
+
if (this.#shuttingDown) return { error: "account_shutting_down" };
|
|
439
|
+
const pending = this.#inProgress();
|
|
440
|
+
if (pending) return pending;
|
|
441
|
+
if (expectedLifecycleToken && expectedLifecycleToken !== this.lifecycleToken()) return { error: "account_context_changed" };
|
|
442
|
+
if (!this.#current(context)) return { error: "account_context_changed" };
|
|
443
|
+
const started = this.#now();
|
|
444
|
+
const job = {
|
|
445
|
+
context: {
|
|
446
|
+
...context,
|
|
447
|
+
fileIdentity: { ...context.fileIdentity }
|
|
448
|
+
},
|
|
449
|
+
expectedLinkId,
|
|
450
|
+
controller: new AbortController(),
|
|
451
|
+
url: null,
|
|
452
|
+
deliveries: 0,
|
|
453
|
+
cancelled: false,
|
|
454
|
+
abortFailure: null,
|
|
455
|
+
done: Promise.resolve(),
|
|
456
|
+
operation: {
|
|
457
|
+
intent,
|
|
458
|
+
operationId: randomUUID(),
|
|
459
|
+
groupId: context.groupId,
|
|
460
|
+
contextId: context.contextId,
|
|
461
|
+
state: "starting",
|
|
462
|
+
failure: null,
|
|
463
|
+
browserUrlAvailable: false,
|
|
464
|
+
startedAt: new Date(started).toISOString(),
|
|
465
|
+
deadlineAt: new Date(started + DEADLINE_MS).toISOString(),
|
|
466
|
+
finishedAt: null
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
if (this.#shuttingDown) return { error: "account_shutting_down" };
|
|
470
|
+
const reentrantPending = this.#inProgress();
|
|
471
|
+
if (reentrantPending) return reentrantPending;
|
|
472
|
+
if (expectedLifecycleToken && expectedLifecycleToken !== this.lifecycleToken()) return { error: "account_context_changed" };
|
|
473
|
+
const owner = Symbol();
|
|
474
|
+
busySlot.owner = owner;
|
|
475
|
+
busySlot.lifecycle = Symbol();
|
|
476
|
+
this.#jobs.set(job.operation.operationId, job);
|
|
477
|
+
job.done = Promise.resolve().then(() => this.#run(job, expectedCoordinatorId, expectedAuthority)).finally(() => {
|
|
478
|
+
if (job.operation.state === "outcome_unknown" && !this.#shuttingDown) this.#uncertainOwner = owner;
|
|
479
|
+
else releaseBusyOwner(owner);
|
|
480
|
+
});
|
|
481
|
+
return { operation: this.#snapshot(job) };
|
|
482
|
+
}
|
|
483
|
+
lifecycleToken() {
|
|
484
|
+
busySlot.lifecycle ??= Symbol();
|
|
485
|
+
return busySlot.lifecycle;
|
|
486
|
+
}
|
|
487
|
+
get(operationId) {
|
|
488
|
+
this.#cleanup();
|
|
489
|
+
const job = this.#jobs.get(operationId);
|
|
490
|
+
return job ? this.#snapshot(job) : null;
|
|
491
|
+
}
|
|
492
|
+
browserUrl(operationId) {
|
|
493
|
+
this.#cleanup();
|
|
494
|
+
const job = this.#jobs.get(operationId);
|
|
495
|
+
if (!job || this.#shuttingDown || terminal(job) || job.controller.signal.aborted || job.operation.state !== "awaiting_browser" || job.deliveries >= 5) return null;
|
|
496
|
+
if (!this.#current(job.context)) {
|
|
497
|
+
job.abortFailure = "context_changed";
|
|
498
|
+
this.#clearUrl(job);
|
|
499
|
+
job.controller.abort();
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
const url = job.url;
|
|
503
|
+
if (url) job.deliveries += 1;
|
|
504
|
+
if (job.deliveries >= 5) this.#clearUrl(job);
|
|
505
|
+
return url;
|
|
506
|
+
}
|
|
507
|
+
cancel(operationId) {
|
|
508
|
+
this.#cleanup();
|
|
509
|
+
const job = this.#jobs.get(operationId);
|
|
510
|
+
if (!job) return null;
|
|
511
|
+
if (!terminal(job)) {
|
|
512
|
+
job.cancelled = true;
|
|
513
|
+
this.#clearUrl(job);
|
|
514
|
+
job.controller.abort();
|
|
515
|
+
}
|
|
516
|
+
return this.#snapshot(job);
|
|
517
|
+
}
|
|
518
|
+
async #drain() {
|
|
519
|
+
let timer;
|
|
520
|
+
try {
|
|
521
|
+
await Promise.race([Promise.all([...this.#jobs.values()].map((job) => job.done)), new Promise((resolve) => {
|
|
522
|
+
timer = setTimeout(resolve, 5e3);
|
|
523
|
+
})]);
|
|
524
|
+
} finally {
|
|
525
|
+
clearTimeout(timer);
|
|
526
|
+
for (const job of this.#jobs.values()) this.#finish(job, "outcome_unknown", "cancellation_unconfirmed");
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
close() {
|
|
530
|
+
if (this.#closing) return this.#closing;
|
|
531
|
+
this.#shuttingDown = true;
|
|
532
|
+
for (const job of this.#jobs.values()) {
|
|
533
|
+
this.#clearUrl(job);
|
|
534
|
+
if (!terminal(job)) {
|
|
535
|
+
job.cancelled = true;
|
|
536
|
+
job.controller.abort();
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
this.#closing = this.#drain().finally(() => {
|
|
540
|
+
if (this.#uncertainOwner) releaseBusyOwner(this.#uncertainOwner);
|
|
541
|
+
});
|
|
542
|
+
return this.#closing;
|
|
543
|
+
}
|
|
544
|
+
hasActive() {
|
|
545
|
+
return Boolean(this.#active() || busySlot.owner);
|
|
546
|
+
}
|
|
547
|
+
forContext(contextId) {
|
|
548
|
+
this.#cleanup();
|
|
549
|
+
const job = [...this.#jobs.values()].reverse().find((item) => item.operation.contextId === contextId);
|
|
550
|
+
return job ? this.#snapshot(job) : null;
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
function createAccountLinkRegistry(options) {
|
|
554
|
+
return new InProcessAccountLinkRegistry(options);
|
|
555
|
+
}
|
|
556
|
+
//#endregion
|
|
557
|
+
//#region src/account/status.ts
|
|
558
|
+
function attachOperation(view, registry) {
|
|
559
|
+
const operation = view.contextId ? registry?.forContext(view.contextId) ?? null : null;
|
|
560
|
+
if (registry?.hasActive()) return {
|
|
561
|
+
...view,
|
|
562
|
+
operation,
|
|
563
|
+
canLink: false,
|
|
564
|
+
blockedReason: "operation_active"
|
|
565
|
+
};
|
|
566
|
+
return {
|
|
567
|
+
...view,
|
|
568
|
+
operation
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
function accountLocalView(result) {
|
|
572
|
+
const context = result.context;
|
|
573
|
+
return {
|
|
574
|
+
version: 1,
|
|
575
|
+
contextId: context?.contextId ?? null,
|
|
576
|
+
setup: result.setup,
|
|
577
|
+
groups: result.groups,
|
|
578
|
+
selectedGroupId: result.selectedGroupId,
|
|
579
|
+
coordinator: result.coordinator,
|
|
580
|
+
local: context ? {
|
|
581
|
+
deviceId: context.deviceId,
|
|
582
|
+
fingerprint: context.fingerprint,
|
|
583
|
+
identityId: context.identityId,
|
|
584
|
+
identityLabel: context.identityLabel,
|
|
585
|
+
deviceLabel: context.deviceLabel
|
|
586
|
+
} : null,
|
|
587
|
+
remote: { kind: "not_checked" },
|
|
588
|
+
canLink: false,
|
|
589
|
+
blockedReason: "setup_incomplete",
|
|
590
|
+
operation: null,
|
|
591
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
function applyRemote(view, remote) {
|
|
595
|
+
if (remote.kind !== "status") return {
|
|
596
|
+
...view,
|
|
597
|
+
remote: remote.kind === "unsupported" ? { kind: "unsupported" } : {
|
|
598
|
+
kind: "unavailable",
|
|
599
|
+
code: remote.code
|
|
600
|
+
},
|
|
601
|
+
blockedReason: remote.kind === "unsupported" ? "unsupported" : "status_unavailable"
|
|
602
|
+
};
|
|
603
|
+
const status = remote.status;
|
|
604
|
+
const identityMatches = status.identityId !== null && status.identityId === view.local?.identityId;
|
|
605
|
+
let blockedReason = null;
|
|
606
|
+
if (status.state === "review_required" || status.state === "conflict") blockedReason = status.state;
|
|
607
|
+
else if (!identityMatches) blockedReason = "identity_mismatch";
|
|
608
|
+
else if (status.state === "linked") blockedReason = "already_linked";
|
|
609
|
+
else if (status.state === "revoked") blockedReason = "revoked";
|
|
610
|
+
return {
|
|
611
|
+
...view,
|
|
612
|
+
remote: {
|
|
613
|
+
kind: "status",
|
|
614
|
+
coordinatorId: status.coordinatorId,
|
|
615
|
+
authority: status.authority,
|
|
616
|
+
identityId: status.identityId,
|
|
617
|
+
state: status.state,
|
|
618
|
+
identityMatches,
|
|
619
|
+
account: identityMatches && status.state === "linked" ? status.account : null
|
|
620
|
+
},
|
|
621
|
+
canLink: status.state === "unlinked" && identityMatches,
|
|
622
|
+
blockedReason
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
async function readAccountStatus(options, groupId, signal, readOptions = {}) {
|
|
626
|
+
const initial = options.readContext(groupId);
|
|
627
|
+
if ("error" in initial) return initial;
|
|
628
|
+
const view = accountLocalView(initial);
|
|
629
|
+
if (initial.setup !== "ready" || !initial.context) return attachOperation(view, options.registry);
|
|
630
|
+
const snapshot = JSON.stringify(initial);
|
|
631
|
+
let remote;
|
|
632
|
+
try {
|
|
633
|
+
const { dbPath, keysDir, groupId, coordinatorUrl, identityId } = initial.context;
|
|
634
|
+
remote = await (options.readRemoteStatus ?? readCoordinatorAccountLinkStatus)({
|
|
635
|
+
dbPath,
|
|
636
|
+
keysDir,
|
|
637
|
+
groupId,
|
|
638
|
+
coordinatorUrl,
|
|
639
|
+
identityId,
|
|
640
|
+
signal
|
|
641
|
+
});
|
|
642
|
+
} catch {
|
|
643
|
+
remote = {
|
|
644
|
+
kind: "unavailable",
|
|
645
|
+
code: "coordinator_unreachable"
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
let fresh;
|
|
649
|
+
try {
|
|
650
|
+
fresh = options.readContext(groupId);
|
|
651
|
+
} catch {
|
|
652
|
+
fresh = { error: "account_context_unavailable" };
|
|
653
|
+
}
|
|
654
|
+
const subject = "error" in fresh ? null : fresh.context;
|
|
655
|
+
const tupleChanged = remote.kind === "status" && (remote.status.groupId !== subject?.groupId || remote.status.deviceId !== subject?.deviceId || remote.status.fingerprint !== subject?.fingerprint);
|
|
656
|
+
const expectedChanged = readOptions.expectedContext !== void 0 && (!initial.context || !sameAuthority(readOptions.expectedContext, initial.context));
|
|
657
|
+
if ("error" in fresh || snapshot !== JSON.stringify(fresh) || tupleChanged || expectedChanged) return attachOperation({
|
|
658
|
+
..."error" in fresh ? accountLocalView({
|
|
659
|
+
setup: "device_unavailable",
|
|
660
|
+
groups: [],
|
|
661
|
+
selectedGroupId: null,
|
|
662
|
+
coordinator: null,
|
|
663
|
+
context: null
|
|
664
|
+
}) : accountLocalView(fresh),
|
|
665
|
+
remote: {
|
|
666
|
+
kind: "unavailable",
|
|
667
|
+
code: "context_changed"
|
|
668
|
+
},
|
|
669
|
+
blockedReason: "status_unavailable"
|
|
670
|
+
}, options.registry);
|
|
671
|
+
const currentView = applyRemote(accountLocalView(fresh), remote);
|
|
672
|
+
await attachManagementActions(options, initial.context, currentView, signal);
|
|
673
|
+
return attachOperation(currentView, options.registry);
|
|
674
|
+
}
|
|
675
|
+
async function attachManagementActions(options, context, view, signal) {
|
|
676
|
+
if (view.remote.kind !== "status" || view.remote.state !== "linked" || !view.remote.identityMatches) return;
|
|
677
|
+
const read = options.readRemoteCapabilities ?? (options.readRemoteStatus ? null : readCoordinatorAccountChangeCapabilities);
|
|
678
|
+
if (!read) return;
|
|
679
|
+
let capabilities = null;
|
|
680
|
+
try {
|
|
681
|
+
const { dbPath, keysDir, groupId, coordinatorUrl, identityId } = context;
|
|
682
|
+
capabilities = await read({
|
|
683
|
+
dbPath,
|
|
684
|
+
keysDir,
|
|
685
|
+
groupId,
|
|
686
|
+
coordinatorUrl,
|
|
687
|
+
identityId,
|
|
688
|
+
signal
|
|
689
|
+
});
|
|
690
|
+
} catch {}
|
|
691
|
+
let fresh;
|
|
692
|
+
try {
|
|
693
|
+
fresh = options.readContext(context.groupId);
|
|
694
|
+
} catch {
|
|
695
|
+
fresh = { error: "account_context_unavailable" };
|
|
696
|
+
}
|
|
697
|
+
if (signal.aborted || "error" in fresh || fresh.setup !== "ready" || !fresh.context || !sameAuthority(context, fresh.context)) {
|
|
698
|
+
view.remote = {
|
|
699
|
+
kind: "unavailable",
|
|
700
|
+
code: "context_changed"
|
|
701
|
+
};
|
|
702
|
+
view.blockedReason = "status_unavailable";
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
if (capabilities?.kind !== "capabilities" || capabilities.coordinatorId !== view.remote.coordinatorId) return;
|
|
706
|
+
view.managementActions = {
|
|
707
|
+
...capabilities.actions,
|
|
708
|
+
disconnect: capabilities.actions.disconnect && !!capabilities.unlinkTarget
|
|
709
|
+
};
|
|
710
|
+
if (view.managementActions.disconnect) view.unlinkTarget = capabilities.unlinkTarget;
|
|
711
|
+
}
|
|
712
|
+
//#endregion
|
|
713
|
+
//#region src/routes/account-operations.ts
|
|
714
|
+
var ROOT = "/api/account/link-operations";
|
|
715
|
+
function validString(value) {
|
|
716
|
+
return typeof value === "string" && value.length > 0 && value.length <= 256 && value.trim() === value && !/[\p{Cc}\p{Cf}\p{Cs}]/u.test(value);
|
|
717
|
+
}
|
|
718
|
+
function noBody(request) {
|
|
719
|
+
const length = request.headers.get("Content-Length");
|
|
720
|
+
return request.body === null && (length === null || length === "0") && !request.headers.has("Transfer-Encoding");
|
|
721
|
+
}
|
|
722
|
+
/** Cap both bytes and elapsed time; cancellation must not wait on an uncooperative stream. */
|
|
723
|
+
async function readBody(request, keys, optional = []) {
|
|
724
|
+
if (!/^application\/json(?:\s*;\s*charset\s*=\s*(?:utf-8|"utf-8"))?\s*$/i.test(request.headers.get("Content-Type") ?? "")) return null;
|
|
725
|
+
const reader = request.body?.getReader();
|
|
726
|
+
if (!reader) return null;
|
|
727
|
+
let timer;
|
|
728
|
+
let stop = () => {};
|
|
729
|
+
try {
|
|
730
|
+
const expired = new Promise((_, reject) => {
|
|
731
|
+
stop = () => reject(/* @__PURE__ */ new Error("invalid body"));
|
|
732
|
+
timer = setTimeout(stop, 1e4);
|
|
733
|
+
request.signal.addEventListener("abort", stop, { once: true });
|
|
734
|
+
if (request.signal.aborted) stop();
|
|
735
|
+
});
|
|
736
|
+
const bytes = /* @__PURE__ */ new Uint8Array(1024);
|
|
737
|
+
let length = 0;
|
|
738
|
+
while (true) {
|
|
739
|
+
const chunk = await Promise.race([reader.read(), expired]);
|
|
740
|
+
if (chunk.done) break;
|
|
741
|
+
if (length + chunk.value.byteLength > bytes.length) return null;
|
|
742
|
+
bytes.set(chunk.value, length);
|
|
743
|
+
length += chunk.value.byteLength;
|
|
744
|
+
}
|
|
745
|
+
const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, length)));
|
|
746
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
747
|
+
const actual = Object.keys(value);
|
|
748
|
+
if (keys.some((key) => !actual.includes(key)) || actual.some((key) => !keys.includes(key) && !optional.includes(key))) return null;
|
|
749
|
+
return value;
|
|
750
|
+
} catch {
|
|
751
|
+
return null;
|
|
752
|
+
} finally {
|
|
753
|
+
clearTimeout(timer);
|
|
754
|
+
request.signal.removeEventListener("abort", stop);
|
|
755
|
+
reader.cancel().catch(() => {});
|
|
756
|
+
reader.releaseLock();
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
function isCurrent(options, operation) {
|
|
760
|
+
const fresh = options.readContext(operation.groupId);
|
|
761
|
+
return !("error" in fresh) && fresh.setup === "ready" && fresh.context?.groupId === operation.groupId && fresh.context.contextId === operation.contextId;
|
|
762
|
+
}
|
|
763
|
+
function validStartBody(body) {
|
|
764
|
+
return body !== null && validString(body.groupId) && validString(body.contextId) && validString(body.coordinatorId) && (body.intent !== "disconnect" || validString(body.expectedLinkId)) && (body.intent === void 0 || body.intent === "connect" || body.intent === "refresh" || body.intent === "disconnect") && (body.authority === "controller_attestation" || body.authority === "device_claim");
|
|
765
|
+
}
|
|
766
|
+
function unlinkTargetChanged(body, view) {
|
|
767
|
+
return body.intent === "disconnect" && body.expectedLinkId !== view.unlinkTarget;
|
|
768
|
+
}
|
|
769
|
+
async function startOperation(c, options) {
|
|
770
|
+
const body = await readBody(c.req.raw, [
|
|
771
|
+
"groupId",
|
|
772
|
+
"contextId",
|
|
773
|
+
"authority",
|
|
774
|
+
"coordinatorId"
|
|
775
|
+
], ["intent", "expectedLinkId"]);
|
|
776
|
+
if (!validStartBody(body)) return c.json({ error: "account_invalid_request" }, 400);
|
|
777
|
+
const initial = options.readContext(body.groupId);
|
|
778
|
+
if ("error" in initial && initial.error === "account_group_not_configured") return c.json({ error: initial.error }, 400);
|
|
779
|
+
if ("error" in initial || initial.setup !== "ready" || !initial.context || initial.context?.contextId !== body.contextId) return c.json({ error: "account_context_changed" }, 409);
|
|
780
|
+
const checkedContext = structuredClone(initial.context);
|
|
781
|
+
const expectedLifecycleToken = options.registry.lifecycleToken();
|
|
782
|
+
const view = await readAccountStatus(options, body.groupId, c.req.raw.signal, { expectedContext: checkedContext });
|
|
783
|
+
if ("error" in view || view.setup !== "ready" || view.contextId !== body.contextId || view.remote.kind === "unavailable" && view.remote.code === "context_changed") return c.json({ error: "account_context_changed" }, 409);
|
|
784
|
+
if (view.operation && view.operation.finishedAt === null) return c.json({
|
|
785
|
+
error: "account_link_in_progress",
|
|
786
|
+
operationId: view.operation.operationId
|
|
787
|
+
}, 409);
|
|
788
|
+
if (view.blockedReason === "operation_active") return c.json({
|
|
789
|
+
error: "account_link_not_allowed",
|
|
790
|
+
blockedReason: "operation_active"
|
|
791
|
+
}, 409);
|
|
792
|
+
if (view.remote.kind === "unavailable") return c.json({ error: "account_status_unavailable" }, 503);
|
|
793
|
+
if (!actionAllowed(view, body.intent) || view.remote.kind !== "status") return c.json({
|
|
794
|
+
error: "account_link_not_allowed",
|
|
795
|
+
blockedReason: view.blockedReason
|
|
796
|
+
}, 409);
|
|
797
|
+
if (unlinkTargetChanged(body, view)) return c.json({ error: "account_context_changed" }, 409);
|
|
798
|
+
return beginCurrentOperation(c, options, body, view, view.remote.coordinatorId, checkedContext, expectedLifecycleToken);
|
|
799
|
+
}
|
|
800
|
+
function actionAllowed(view, intent = "connect") {
|
|
801
|
+
if (intent === "connect") return view.canLink;
|
|
802
|
+
if (intent === "refresh" || intent === "disconnect") return view.managementActions?.[intent] === true;
|
|
803
|
+
return false;
|
|
804
|
+
}
|
|
805
|
+
function beginCurrentOperation(c, options, body, view, expectedCoordinatorId, checkedContext, expectedLifecycleToken) {
|
|
806
|
+
if (c.req.raw.signal.aborted) return c.json({ error: "account_invalid_request" }, 400);
|
|
807
|
+
if (view.remote.kind !== "status" || (body.intent ?? "connect") === "connect" && view.remote.authority !== body.authority || view.remote.coordinatorId !== body.coordinatorId) return c.json({ error: "account_context_changed" }, 409);
|
|
808
|
+
const fresh = options.readContext(body.groupId);
|
|
809
|
+
if ("error" in fresh || fresh.setup !== "ready" || !fresh.context || fresh.context.contextId !== body.contextId || fresh.context.contextId !== view.contextId || fresh.context.groupId !== body.groupId || !sameAuthority(checkedContext, fresh.context)) return c.json({ error: "account_context_changed" }, 409);
|
|
810
|
+
const result = options.registry.start({
|
|
811
|
+
intent: body.intent ?? "connect",
|
|
812
|
+
context: checkedContext,
|
|
813
|
+
expectedCoordinatorId,
|
|
814
|
+
expectedAuthority: body.authority,
|
|
815
|
+
expectedLifecycleToken,
|
|
816
|
+
expectedLinkId: body.expectedLinkId
|
|
817
|
+
});
|
|
818
|
+
if ("error" in result) return c.json(result, result.error === "account_shutting_down" ? 503 : 409);
|
|
819
|
+
return c.json({
|
|
820
|
+
version: 1,
|
|
821
|
+
operation: result.operation
|
|
822
|
+
}, 202);
|
|
823
|
+
}
|
|
824
|
+
async function performOperation(c, options) {
|
|
825
|
+
const { id, action } = c.req.param();
|
|
826
|
+
if (!validString(id) || action !== "browser-url" && action !== "cancel" || !await readBody(c.req.raw, [])) return c.json({ error: "account_invalid_request" }, 400);
|
|
827
|
+
const operation = options.registry.get(id);
|
|
828
|
+
if (!operation) return c.json({ error: "account_operation_not_found" }, 404);
|
|
829
|
+
if (action === "cancel") {
|
|
830
|
+
const cancelled = options.registry.cancel(id);
|
|
831
|
+
if (!cancelled) return c.json({ error: "account_operation_not_found" }, 404);
|
|
832
|
+
return c.json({
|
|
833
|
+
version: 1,
|
|
834
|
+
operation: cancelled
|
|
835
|
+
}, cancelled.finishedAt === null ? 202 : 200);
|
|
836
|
+
}
|
|
837
|
+
if (operation.state !== "awaiting_browser") return c.json({ error: "account_browser_url_unavailable" }, 409);
|
|
838
|
+
const url = options.registry.browserUrl(id);
|
|
839
|
+
if (!url) return c.json({ error: "account_browser_url_unavailable" }, 409);
|
|
840
|
+
return c.json({
|
|
841
|
+
version: 1,
|
|
842
|
+
url
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
function accountOperationRoutes(options) {
|
|
846
|
+
const app = new Hono();
|
|
847
|
+
app.onError((_error, c) => c.json({ error: "account_internal_error" }, 500));
|
|
848
|
+
const rejectQuery = async (c, next) => {
|
|
849
|
+
if ([...new URL(c.req.url).searchParams].length) return c.json({ error: "account_invalid_request" }, 400);
|
|
850
|
+
await next();
|
|
851
|
+
};
|
|
852
|
+
app.use(ROOT, rejectQuery);
|
|
853
|
+
app.use(`${ROOT}/*`, rejectQuery);
|
|
854
|
+
app.post(ROOT, (c) => startOperation(c, options));
|
|
855
|
+
app.get(`${ROOT}/:id`, (c) => {
|
|
856
|
+
if (!noBody(c.req.raw) || !validString(c.req.param("id"))) return c.json({ error: "account_invalid_request" }, 400);
|
|
857
|
+
const operation = options.registry.get(c.req.param("id"));
|
|
858
|
+
if (!operation) return c.json({ error: "account_operation_not_found" }, 404);
|
|
859
|
+
return c.json({
|
|
860
|
+
version: 1,
|
|
861
|
+
operation,
|
|
862
|
+
current: isCurrent(options, operation)
|
|
863
|
+
});
|
|
864
|
+
});
|
|
865
|
+
app.post(`${ROOT}/:id/:action`, (c) => performOperation(c, options));
|
|
866
|
+
return app;
|
|
867
|
+
}
|
|
868
|
+
//#endregion
|
|
869
|
+
//#region src/routes/account-status.ts
|
|
870
|
+
function accountStatusRoutes(options) {
|
|
871
|
+
const app = new Hono();
|
|
872
|
+
app.get("/api/account/link-status", async (c) => {
|
|
873
|
+
try {
|
|
874
|
+
const query = new URL(c.req.url).searchParams;
|
|
875
|
+
const length = c.req.header("Content-Length");
|
|
876
|
+
if ([...query.keys()].some((key) => key !== "group_id") || query.getAll("group_id").length > 1 || c.req.raw.body !== null || length !== void 0 && length !== "0" || c.req.header("Transfer-Encoding") !== void 0) return c.json({ error: "account_invalid_request" }, 400);
|
|
877
|
+
const result = await readAccountStatus(options, query.get("group_id") ?? void 0, c.req.raw.signal);
|
|
878
|
+
if ("error" in result) return c.json(result, result.error === "account_group_not_configured" ? 400 : 503);
|
|
879
|
+
return c.json(result);
|
|
880
|
+
} catch {
|
|
881
|
+
return c.json({ error: "account_internal_error" }, 500);
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
return app;
|
|
885
|
+
}
|
|
886
|
+
//#endregion
|
|
164
887
|
//#region src/routes/config.ts
|
|
165
888
|
/**
|
|
166
889
|
* Config routes — GET /api/config, POST /api/config.
|
|
@@ -1028,38 +1751,6 @@ function diagnosticsRoutes(getStore) {
|
|
|
1028
1751
|
return app;
|
|
1029
1752
|
}
|
|
1030
1753
|
//#endregion
|
|
1031
|
-
//#region src/routes/health.ts
|
|
1032
|
-
function databaseReachable(getStore) {
|
|
1033
|
-
try {
|
|
1034
|
-
getStore().db.pragma("schema_version", { simple: true });
|
|
1035
|
-
return true;
|
|
1036
|
-
} catch {
|
|
1037
|
-
return false;
|
|
1038
|
-
}
|
|
1039
|
-
}
|
|
1040
|
-
function healthRoutes(getStore) {
|
|
1041
|
-
const app = new Hono();
|
|
1042
|
-
app.get("/api/viewer-status", (c) => {
|
|
1043
|
-
const store = getStore();
|
|
1044
|
-
store.db.pragma("schema_version", { simple: true });
|
|
1045
|
-
c.header("Cache-Control", "no-store");
|
|
1046
|
-
return c.json({ identity: { actor_id: store.actorId } });
|
|
1047
|
-
});
|
|
1048
|
-
app.get("/api/health", (c) => {
|
|
1049
|
-
const reachable = databaseReachable(getStore);
|
|
1050
|
-
c.header("Cache-Control", "no-store");
|
|
1051
|
-
return c.json({
|
|
1052
|
-
service: VIEWER_SERVICE_DISCRIMINATOR,
|
|
1053
|
-
version: VERSION$1,
|
|
1054
|
-
pid: process.pid,
|
|
1055
|
-
uptime_ms: Math.max(0, Math.floor(process.uptime() * 1e3)),
|
|
1056
|
-
ready: reachable,
|
|
1057
|
-
database: { reachable }
|
|
1058
|
-
});
|
|
1059
|
-
});
|
|
1060
|
-
return app;
|
|
1061
|
-
}
|
|
1062
|
-
//#endregion
|
|
1063
1754
|
//#region src/helpers.ts
|
|
1064
1755
|
/**
|
|
1065
1756
|
* Shared helpers for viewer-server routes.
|
|
@@ -1153,6 +1844,181 @@ async function parseJsonObjectBody(c, maxBytes) {
|
|
|
1153
1844
|
return parsed;
|
|
1154
1845
|
}
|
|
1155
1846
|
//#endregion
|
|
1847
|
+
//#region src/routes/target-validation.ts
|
|
1848
|
+
var IDENTITY_TARGET_KEYS = new Set(VIEWER_IDENTITY_TARGET_KEYS);
|
|
1849
|
+
var BOOLEAN_IDENTITY_TARGET_KEYS = /* @__PURE__ */ new Set([
|
|
1850
|
+
"actor_id_present",
|
|
1851
|
+
"embedding_disabled",
|
|
1852
|
+
"embedding_offline"
|
|
1853
|
+
]);
|
|
1854
|
+
function invalidRequest$1(message) {
|
|
1855
|
+
return {
|
|
1856
|
+
ok: false,
|
|
1857
|
+
status: 400,
|
|
1858
|
+
body: { error: {
|
|
1859
|
+
code: "invalid_request",
|
|
1860
|
+
message
|
|
1861
|
+
} }
|
|
1862
|
+
};
|
|
1863
|
+
}
|
|
1864
|
+
function conflict(code, message) {
|
|
1865
|
+
return {
|
|
1866
|
+
ok: false,
|
|
1867
|
+
status: 409,
|
|
1868
|
+
body: { error: {
|
|
1869
|
+
code,
|
|
1870
|
+
message
|
|
1871
|
+
} }
|
|
1872
|
+
};
|
|
1873
|
+
}
|
|
1874
|
+
function isRecord$1(value) {
|
|
1875
|
+
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
1876
|
+
}
|
|
1877
|
+
function currentIdentityTarget() {
|
|
1878
|
+
return buildViewerIdentityTarget();
|
|
1879
|
+
}
|
|
1880
|
+
function requestedDbMatches(store, value) {
|
|
1881
|
+
if (value == null) return true;
|
|
1882
|
+
if (typeof value !== "string" || !value.trim()) return null;
|
|
1883
|
+
return resolve(resolveDbPath(value.trim())) === resolve(store.dbPath);
|
|
1884
|
+
}
|
|
1885
|
+
function implicitConfigPath() {
|
|
1886
|
+
const { resolved, fallbackChain } = resolveCodememConfigPath();
|
|
1887
|
+
if (resolved.source !== "env-codemem-config") return resolve(resolved.path);
|
|
1888
|
+
const candidates = fallbackChain.filter((candidate) => candidate.valid);
|
|
1889
|
+
const implicit = candidates.find((candidate) => candidate.exists) ?? candidates[0];
|
|
1890
|
+
return implicit ? resolve(implicit.path) : null;
|
|
1891
|
+
}
|
|
1892
|
+
function identityFieldMatches(key, requested, expected) {
|
|
1893
|
+
if (requested === expected) return true;
|
|
1894
|
+
if (key !== "config_path" || requested !== null && expected !== null) return false;
|
|
1895
|
+
return (requested ?? expected) === implicitConfigPath();
|
|
1896
|
+
}
|
|
1897
|
+
function requestedIdentityMatches(value) {
|
|
1898
|
+
if (value == null) return true;
|
|
1899
|
+
if (!isRecord$1(value)) return null;
|
|
1900
|
+
if (Object.keys(value).length !== IDENTITY_TARGET_KEYS.size || Object.keys(value).some((key) => !IDENTITY_TARGET_KEYS.has(key))) return "unsupported";
|
|
1901
|
+
const expected = currentIdentityTarget();
|
|
1902
|
+
for (const key of VIEWER_IDENTITY_TARGET_KEYS) {
|
|
1903
|
+
const requested = value[key];
|
|
1904
|
+
if (BOOLEAN_IDENTITY_TARGET_KEYS.has(key)) {
|
|
1905
|
+
if (typeof requested !== "boolean") return null;
|
|
1906
|
+
} else if (requested !== null && typeof requested !== "string") return null;
|
|
1907
|
+
if (!identityFieldMatches(key, requested, expected[key])) return false;
|
|
1908
|
+
}
|
|
1909
|
+
return true;
|
|
1910
|
+
}
|
|
1911
|
+
function validateViewerTarget(store, payload, options = {}) {
|
|
1912
|
+
if (options.requirePairedTargets) {
|
|
1913
|
+
const hasDbPath = Object.hasOwn(payload, "db_path");
|
|
1914
|
+
if (hasDbPath !== Object.hasOwn(payload, "identity_target")) return invalidRequest$1("db_path and identity_target must be provided together");
|
|
1915
|
+
if (hasDbPath && (payload.db_path == null || payload.identity_target == null)) return invalidRequest$1("db_path and identity_target must be valid when provided");
|
|
1916
|
+
}
|
|
1917
|
+
const dbMatches = requestedDbMatches(store, payload.db_path);
|
|
1918
|
+
if (dbMatches == null) return invalidRequest$1("db_path must be a non-empty string");
|
|
1919
|
+
if (!dbMatches) return conflict("viewer_db_mismatch", "viewer database does not match request");
|
|
1920
|
+
const identityMatches = requestedIdentityMatches(payload.identity_target);
|
|
1921
|
+
if (identityMatches === "unsupported") return conflict("viewer_contract_unsupported", "viewer request contract is incompatible");
|
|
1922
|
+
if (identityMatches == null) return invalidRequest$1("identity_target is invalid");
|
|
1923
|
+
if (!identityMatches) return conflict("viewer_identity_mismatch", "viewer identity does not match request");
|
|
1924
|
+
if ((options.requireCurrentIdentity || payload.identity_target != null) && !store.hasCurrentIdentity()) return conflict("viewer_identity_mismatch", "viewer identity does not match request");
|
|
1925
|
+
return { ok: true };
|
|
1926
|
+
}
|
|
1927
|
+
/**
|
|
1928
|
+
* Middleware read of db_path / identity_target from the query string.
|
|
1929
|
+
* Native tools (pi extension) send targets as query params so a pre-handler
|
|
1930
|
+
* guard can reject a foreign viewer without consuming POST bodies. Lenient:
|
|
1931
|
+
* clients that omit targets (UI, MCP, curl) are unaffected.
|
|
1932
|
+
*/
|
|
1933
|
+
function viewerTargetQueryGuard(getStore) {
|
|
1934
|
+
return async (c, next) => {
|
|
1935
|
+
const dbPath = c.req.query("db_path");
|
|
1936
|
+
const rawIdentity = c.req.query("identity_target");
|
|
1937
|
+
if (dbPath == null && rawIdentity == null) return next();
|
|
1938
|
+
let identityTarget;
|
|
1939
|
+
if (rawIdentity != null) try {
|
|
1940
|
+
identityTarget = JSON.parse(rawIdentity);
|
|
1941
|
+
} catch {
|
|
1942
|
+
return c.json({ error: {
|
|
1943
|
+
code: "invalid_request",
|
|
1944
|
+
message: "identity_target must be JSON"
|
|
1945
|
+
} }, 400);
|
|
1946
|
+
}
|
|
1947
|
+
const target = validateViewerTarget(getStore(), {
|
|
1948
|
+
db_path: dbPath ?? void 0,
|
|
1949
|
+
identity_target: identityTarget
|
|
1950
|
+
});
|
|
1951
|
+
if (!target.ok) return c.json(target.body, target.status);
|
|
1952
|
+
return next();
|
|
1953
|
+
};
|
|
1954
|
+
}
|
|
1955
|
+
//#endregion
|
|
1956
|
+
//#region src/routes/file-context.ts
|
|
1957
|
+
function fileContextRoutes(getStore) {
|
|
1958
|
+
const app = new Hono();
|
|
1959
|
+
app.post("/api/file-context", async (c) => {
|
|
1960
|
+
const body = await parseJsonObjectBody(c, FILE_CONTEXT_TRANSPORT_MAX_BYTES);
|
|
1961
|
+
if (body instanceof Response) return body;
|
|
1962
|
+
const parsed = parseFileContextTransportRequest(body);
|
|
1963
|
+
if (!parsed.success) return c.json({ error: {
|
|
1964
|
+
code: "invalid_request",
|
|
1965
|
+
message: "invalid file-context request"
|
|
1966
|
+
} }, 400);
|
|
1967
|
+
try {
|
|
1968
|
+
const store = getStore();
|
|
1969
|
+
const target = validateViewerTarget(store, body, {
|
|
1970
|
+
requireCurrentIdentity: true,
|
|
1971
|
+
requirePairedTargets: true
|
|
1972
|
+
});
|
|
1973
|
+
if (!target.ok) return c.json(target.body, target.status);
|
|
1974
|
+
const response = dispatchFileContextTransport(store, parsed.data);
|
|
1975
|
+
if (!response.ok) return c.json({ error: {
|
|
1976
|
+
code: "invalid_request",
|
|
1977
|
+
message: "delivery does not match attempt"
|
|
1978
|
+
} }, 400);
|
|
1979
|
+
return c.json(response);
|
|
1980
|
+
} catch {
|
|
1981
|
+
return c.json({ error: {
|
|
1982
|
+
code: "file_context_failed",
|
|
1983
|
+
message: "file context unavailable"
|
|
1984
|
+
} }, 503);
|
|
1985
|
+
}
|
|
1986
|
+
});
|
|
1987
|
+
return app;
|
|
1988
|
+
}
|
|
1989
|
+
//#endregion
|
|
1990
|
+
//#region src/routes/health.ts
|
|
1991
|
+
function databaseReachable(getStore) {
|
|
1992
|
+
try {
|
|
1993
|
+
getStore().db.pragma("schema_version", { simple: true });
|
|
1994
|
+
return true;
|
|
1995
|
+
} catch {
|
|
1996
|
+
return false;
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
function healthRoutes(getStore) {
|
|
2000
|
+
const app = new Hono();
|
|
2001
|
+
app.get("/api/viewer-status", (c) => {
|
|
2002
|
+
const store = getStore();
|
|
2003
|
+
store.db.pragma("schema_version", { simple: true });
|
|
2004
|
+
c.header("Cache-Control", "no-store");
|
|
2005
|
+
return c.json({ identity: { actor_id: store.actorId } });
|
|
2006
|
+
});
|
|
2007
|
+
app.get("/api/health", (c) => {
|
|
2008
|
+
const reachable = databaseReachable(getStore);
|
|
2009
|
+
c.header("Cache-Control", "no-store");
|
|
2010
|
+
return c.json({
|
|
2011
|
+
service: VIEWER_SERVICE_DISCRIMINATOR,
|
|
2012
|
+
version: VERSION$1,
|
|
2013
|
+
pid: process.pid,
|
|
2014
|
+
uptime_ms: Math.max(0, Math.floor(process.uptime() * 1e3)),
|
|
2015
|
+
ready: reachable,
|
|
2016
|
+
database: { reachable }
|
|
2017
|
+
});
|
|
2018
|
+
});
|
|
2019
|
+
return app;
|
|
2020
|
+
}
|
|
2021
|
+
//#endregion
|
|
1156
2022
|
//#region src/routes/memory.ts
|
|
1157
2023
|
var MAX_FEED_QUERY_CODE_UNITS = 256;
|
|
1158
2024
|
var searchFunctionsRegisteredStores = /* @__PURE__ */ new WeakSet();
|
|
@@ -1359,16 +2225,19 @@ function memorySearchSql(query) {
|
|
|
1359
2225
|
params: [query]
|
|
1360
2226
|
};
|
|
1361
2227
|
}
|
|
1362
|
-
function
|
|
1363
|
-
const
|
|
1364
|
-
const
|
|
1365
|
-
"memory_items.
|
|
1366
|
-
`
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
const
|
|
1370
|
-
|
|
1371
|
-
return
|
|
2228
|
+
function countVisibleMemoryClassifications(store, filters) {
|
|
2229
|
+
const branches = buildFilterBranchesWithContext(filters, store.ownershipFilterContext());
|
|
2230
|
+
const source = branches.map((branch) => {
|
|
2231
|
+
const from = branch.joinSessions ? "memory_items JOIN sessions ON sessions.id = memory_items.session_id" : "memory_items";
|
|
2232
|
+
return `SELECT ${summaryLikeSqlPredicate()} AS is_summary FROM ${from}
|
|
2233
|
+
WHERE memory_items.active = 1 AND ${branch.clauses.join(" AND ") || "1 = 1"}`;
|
|
2234
|
+
}).join(" UNION ALL ");
|
|
2235
|
+
const row = store.db.prepare(`SELECT COUNT(*) AS memories,
|
|
2236
|
+
COUNT(CASE WHEN NOT is_summary THEN 1 END) AS observations FROM (${source})`).get(...branches.flatMap((branch) => branch.params));
|
|
2237
|
+
return {
|
|
2238
|
+
memories: Number(row?.memories ?? 0),
|
|
2239
|
+
observations: Number(row?.observations ?? 0)
|
|
2240
|
+
};
|
|
1372
2241
|
}
|
|
1373
2242
|
function sessionAllowsArtifactAccess(store, sessionId) {
|
|
1374
2243
|
const visibleCount = countVisibleMemoryRows(store, { session_id: sessionId });
|
|
@@ -1544,18 +2413,13 @@ function memoryRoutes(getStore) {
|
|
|
1544
2413
|
const project = c.req.query("project") || null;
|
|
1545
2414
|
let prompts;
|
|
1546
2415
|
let artifacts;
|
|
1547
|
-
|
|
1548
|
-
let observations;
|
|
2416
|
+
const { memories, observations } = countVisibleMemoryClassifications(store, project ? { project } : void 0);
|
|
1549
2417
|
if (project) {
|
|
1550
2418
|
prompts = countVisiblePromptRows(store, project);
|
|
1551
2419
|
artifacts = countVisibleArtifactRows(store, project);
|
|
1552
|
-
memories = countVisibleMemoryRows(store, { project });
|
|
1553
|
-
observations = countVisibleObservationRows(store, { project });
|
|
1554
2420
|
} else {
|
|
1555
2421
|
prompts = countVisiblePromptRows(store);
|
|
1556
2422
|
artifacts = countVisibleArtifactRows(store);
|
|
1557
|
-
memories = countVisibleMemoryRows(store);
|
|
1558
|
-
observations = countVisibleObservationRows(store);
|
|
1559
2423
|
}
|
|
1560
2424
|
const total = prompts + artifacts + memories;
|
|
1561
2425
|
return c.json({
|
|
@@ -1647,11 +2511,11 @@ function memoryRoutes(getStore) {
|
|
|
1647
2511
|
}
|
|
1648
2512
|
const memoryId = parseStrictInteger(typeof body.memory_id === "string" ? body.memory_id : String(body.memory_id ?? ""));
|
|
1649
2513
|
if (memoryId == null || memoryId <= 0) return c.json({ error: "memory_id must be int" }, 400);
|
|
1650
|
-
if (!store.
|
|
2514
|
+
if (!store.getForMutation(memoryId)) return c.json({ error: "memory not found" }, 404);
|
|
1651
2515
|
const visibility = String(body.visibility ?? "").trim();
|
|
1652
2516
|
if (visibility !== "private" && visibility !== "shared") return c.json({ error: "visibility must be private or shared" }, 400);
|
|
1653
2517
|
try {
|
|
1654
|
-
const item = store.
|
|
2518
|
+
const item = store.updateMemoryVisibilityForUser(memoryId, visibility);
|
|
1655
2519
|
return c.json({ item });
|
|
1656
2520
|
} catch (err) {
|
|
1657
2521
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -1672,9 +2536,9 @@ function memoryRoutes(getStore) {
|
|
|
1672
2536
|
if (memoryId == null || memoryId <= 0) return c.json({ error: "memory_id must be int" }, 400);
|
|
1673
2537
|
const project = String(body.project ?? "").trim();
|
|
1674
2538
|
if (!project) return c.json({ error: "project must be a non-empty string" }, 400);
|
|
1675
|
-
if (!store.
|
|
2539
|
+
if (!store.getForMutation(memoryId)) return c.json({ error: "memory not found" }, 404);
|
|
1676
2540
|
try {
|
|
1677
|
-
const result = store.
|
|
2541
|
+
const result = store.moveMemoryProjectForUser(memoryId, project);
|
|
1678
2542
|
return c.json(result);
|
|
1679
2543
|
} catch (err) {
|
|
1680
2544
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -1699,11 +2563,11 @@ function memoryRoutes(getStore) {
|
|
|
1699
2563
|
if (!store.memoryOwnedBySelf(row)) return c.json({ error: "memory not owned by this device" }, 403);
|
|
1700
2564
|
if (Number(row.active ?? 1) === 0 || row.deleted_at != null) return c.json({ status: "ok" });
|
|
1701
2565
|
try {
|
|
1702
|
-
store
|
|
2566
|
+
forgetMemoryForViewer(store, memoryId);
|
|
1703
2567
|
return c.json({ status: "ok" });
|
|
1704
2568
|
} catch (err) {
|
|
1705
2569
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1706
|
-
if (msg.includes("not found")) return c.json(
|
|
2570
|
+
if (msg.includes("not found")) return c.json(...forgetNotFound(forgetSafetyFilters(body)));
|
|
1707
2571
|
if (msg.includes("not owned")) return c.json({ error: msg }, 403);
|
|
1708
2572
|
if (msg.includes("sync_rebootstrap_in_progress")) return c.json({ error: msg }, 409);
|
|
1709
2573
|
return c.json({ error: msg }, 400);
|
|
@@ -1721,9 +2585,13 @@ function forgetSafetyFilters(body) {
|
|
|
1721
2585
|
return filters;
|
|
1722
2586
|
}
|
|
1723
2587
|
function forgetTargetExists(store, memoryId, filters) {
|
|
2588
|
+
if (!store.canMutateMemory(memoryId)) return false;
|
|
1724
2589
|
if (Object.keys(filters).length > 0) return store.timeline(null, memoryId, 0, 0, filters).some((row) => row.id === memoryId);
|
|
1725
2590
|
return store.get(memoryId) != null;
|
|
1726
2591
|
}
|
|
2592
|
+
function forgetMemoryForViewer(store, memoryId) {
|
|
2593
|
+
if (!store.forgetForUser(memoryId)) throw new Error("memory not found");
|
|
2594
|
+
}
|
|
1727
2595
|
//#endregion
|
|
1728
2596
|
//#region src/routes/memory-tools.ts
|
|
1729
2597
|
/**
|
|
@@ -1867,7 +2735,8 @@ function parseJsonBody(body) {
|
|
|
1867
2735
|
};
|
|
1868
2736
|
}
|
|
1869
2737
|
function rememberMemory(store, input) {
|
|
1870
|
-
|
|
2738
|
+
const createdMemories = [];
|
|
2739
|
+
const result = store.db.transaction(() => {
|
|
1871
2740
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1872
2741
|
const user = process.env.USER ?? "unknown";
|
|
1873
2742
|
const cwd = process.cwd();
|
|
@@ -1875,10 +2744,10 @@ function rememberMemory(store, input) {
|
|
|
1875
2744
|
project: input.project,
|
|
1876
2745
|
envProject: process.env.CODEMEM_PROJECT
|
|
1877
2746
|
});
|
|
1878
|
-
const sessionInfo = store.db.prepare(`INSERT INTO sessions(started_at, ended_at, cwd, project, user, tool_version, metadata_json)
|
|
1879
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(now, now, cwd, project, user, "viewer-api", toJson({ viewer: true }));
|
|
2747
|
+
const sessionInfo = store.db.prepare(`INSERT INTO sessions(started_at, ended_at, cwd, project, user, tool_version, metadata_json, import_key)
|
|
2748
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(now, now, cwd, project, user, "viewer-api", toJson({ viewer: true }), randomUUID());
|
|
1880
2749
|
const sessionId = Number(sessionInfo.lastInsertRowid);
|
|
1881
|
-
const memId = store.
|
|
2750
|
+
const memId = store.rememberForUser(sessionId, input.kind, input.title, input.body, input.confidence, void 0, void 0, { createdMemories });
|
|
1882
2751
|
if (!getMemoryForAccess(store, memId)) throw new Error("unauthorized_scope");
|
|
1883
2752
|
store.db.prepare("UPDATE sessions SET ended_at = ?, metadata_json = ? WHERE id = ?").run((/* @__PURE__ */ new Date()).toISOString(), toJson({ viewer: true }), sessionId);
|
|
1884
2753
|
return {
|
|
@@ -1887,6 +2756,8 @@ function rememberMemory(store, input) {
|
|
|
1887
2756
|
body: input.body
|
|
1888
2757
|
};
|
|
1889
2758
|
})();
|
|
2759
|
+
store.enqueueCommittedVectorWrites(createdMemories);
|
|
2760
|
+
return result;
|
|
1890
2761
|
}
|
|
1891
2762
|
function readContextFile(path, displayPath, scope) {
|
|
1892
2763
|
if (!existsSync(path)) return null;
|
|
@@ -2052,7 +2923,7 @@ function memoryToolRoutes(getStore) {
|
|
|
2052
2923
|
project
|
|
2053
2924
|
});
|
|
2054
2925
|
try {
|
|
2055
|
-
await
|
|
2926
|
+
await store.flushPendingVectorWrites();
|
|
2056
2927
|
} catch {}
|
|
2057
2928
|
return c.json({ id: result.memId });
|
|
2058
2929
|
} catch (err) {
|
|
@@ -2268,128 +3139,31 @@ function observerStatusRoutes(deps) {
|
|
|
2268
3139
|
auth_backoff_remaining_s: 0
|
|
2269
3140
|
}
|
|
2270
3141
|
});
|
|
2271
|
-
const queueTotals = store.rawEventBacklogTotals();
|
|
2272
|
-
const authBackoff = sweeper?.authBackoffStatus() ?? {
|
|
2273
|
-
active: false,
|
|
2274
|
-
remainingS: 0
|
|
2275
|
-
};
|
|
2276
|
-
const latestFailure = store.latestRawEventFlushFailure();
|
|
2277
|
-
const active = normalizeActiveObserver(observer?.getStatus() ?? null);
|
|
2278
|
-
const availableCredentials = probeAvailableCredentials();
|
|
2279
|
-
const failureWithImpact = latestFailure != null && (authBackoff.active || queueTotals.pending > 0) && latestFailure ? {
|
|
2280
|
-
...latestFailure,
|
|
2281
|
-
impact: buildFailureImpact(latestFailure, queueTotals, authBackoff)
|
|
2282
|
-
} : null;
|
|
2283
|
-
return c.json({
|
|
2284
|
-
active,
|
|
2285
|
-
available_credentials: availableCredentials,
|
|
2286
|
-
capture_enabled: readCaptureEnabled(),
|
|
2287
|
-
latest_failure: failureWithImpact,
|
|
2288
|
-
queue: {
|
|
2289
|
-
...queueTotals,
|
|
2290
|
-
auth_backoff_active: authBackoff.active,
|
|
2291
|
-
auth_backoff_remaining_s: authBackoff.remainingS
|
|
2292
|
-
}
|
|
2293
|
-
});
|
|
2294
|
-
});
|
|
2295
|
-
return app;
|
|
2296
|
-
}
|
|
2297
|
-
//#endregion
|
|
2298
|
-
//#region src/routes/target-validation.ts
|
|
2299
|
-
var IDENTITY_TARGET_KEYS = new Set(VIEWER_IDENTITY_TARGET_KEYS);
|
|
2300
|
-
var BOOLEAN_IDENTITY_TARGET_KEYS = /* @__PURE__ */ new Set([
|
|
2301
|
-
"actor_id_present",
|
|
2302
|
-
"embedding_disabled",
|
|
2303
|
-
"embedding_offline"
|
|
2304
|
-
]);
|
|
2305
|
-
function invalidRequest$1(message) {
|
|
2306
|
-
return {
|
|
2307
|
-
ok: false,
|
|
2308
|
-
status: 400,
|
|
2309
|
-
body: { error: {
|
|
2310
|
-
code: "invalid_request",
|
|
2311
|
-
message
|
|
2312
|
-
} }
|
|
2313
|
-
};
|
|
2314
|
-
}
|
|
2315
|
-
function conflict(code, message) {
|
|
2316
|
-
return {
|
|
2317
|
-
ok: false,
|
|
2318
|
-
status: 409,
|
|
2319
|
-
body: { error: {
|
|
2320
|
-
code,
|
|
2321
|
-
message
|
|
2322
|
-
} }
|
|
2323
|
-
};
|
|
2324
|
-
}
|
|
2325
|
-
function isRecord$1(value) {
|
|
2326
|
-
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
2327
|
-
}
|
|
2328
|
-
function currentIdentityTarget() {
|
|
2329
|
-
return buildViewerIdentityTarget();
|
|
2330
|
-
}
|
|
2331
|
-
function requestedDbMatches(store, value) {
|
|
2332
|
-
if (value == null) return true;
|
|
2333
|
-
if (typeof value !== "string" || !value.trim()) return null;
|
|
2334
|
-
return resolve(resolveDbPath(value.trim())) === resolve(store.dbPath);
|
|
2335
|
-
}
|
|
2336
|
-
function requestedIdentityMatches(value) {
|
|
2337
|
-
if (value == null) return true;
|
|
2338
|
-
if (!isRecord$1(value)) return null;
|
|
2339
|
-
if (Object.keys(value).length !== IDENTITY_TARGET_KEYS.size || Object.keys(value).some((key) => !IDENTITY_TARGET_KEYS.has(key))) return "unsupported";
|
|
2340
|
-
const expected = currentIdentityTarget();
|
|
2341
|
-
for (const key of VIEWER_IDENTITY_TARGET_KEYS) {
|
|
2342
|
-
const requested = value[key];
|
|
2343
|
-
if (BOOLEAN_IDENTITY_TARGET_KEYS.has(key)) {
|
|
2344
|
-
if (typeof requested !== "boolean") return null;
|
|
2345
|
-
} else if (requested !== null && typeof requested !== "string") return null;
|
|
2346
|
-
if (requested !== expected[key]) return false;
|
|
2347
|
-
}
|
|
2348
|
-
return true;
|
|
2349
|
-
}
|
|
2350
|
-
function validateViewerTarget(store, payload, options = {}) {
|
|
2351
|
-
if (options.requirePairedTargets) {
|
|
2352
|
-
const hasDbPath = Object.hasOwn(payload, "db_path");
|
|
2353
|
-
if (hasDbPath !== Object.hasOwn(payload, "identity_target")) return invalidRequest$1("db_path and identity_target must be provided together");
|
|
2354
|
-
if (hasDbPath && (payload.db_path == null || payload.identity_target == null)) return invalidRequest$1("db_path and identity_target must be valid when provided");
|
|
2355
|
-
}
|
|
2356
|
-
const dbMatches = requestedDbMatches(store, payload.db_path);
|
|
2357
|
-
if (dbMatches == null) return invalidRequest$1("db_path must be a non-empty string");
|
|
2358
|
-
if (!dbMatches) return conflict("viewer_db_mismatch", "viewer database does not match request");
|
|
2359
|
-
const identityMatches = requestedIdentityMatches(payload.identity_target);
|
|
2360
|
-
if (identityMatches === "unsupported") return conflict("viewer_contract_unsupported", "viewer request contract is incompatible");
|
|
2361
|
-
if (identityMatches == null) return invalidRequest$1("identity_target is invalid");
|
|
2362
|
-
if (!identityMatches) return conflict("viewer_identity_mismatch", "viewer identity does not match request");
|
|
2363
|
-
if ((options.requireCurrentIdentity || payload.identity_target != null) && !store.hasCurrentIdentity()) return conflict("viewer_identity_mismatch", "viewer identity does not match request");
|
|
2364
|
-
return { ok: true };
|
|
2365
|
-
}
|
|
2366
|
-
/**
|
|
2367
|
-
* Middleware read of db_path / identity_target from the query string.
|
|
2368
|
-
* Native tools (pi extension) send targets as query params so a pre-handler
|
|
2369
|
-
* guard can reject a foreign viewer without consuming POST bodies. Lenient:
|
|
2370
|
-
* clients that omit targets (UI, MCP, curl) are unaffected.
|
|
2371
|
-
*/
|
|
2372
|
-
function viewerTargetQueryGuard(getStore) {
|
|
2373
|
-
return async (c, next) => {
|
|
2374
|
-
const dbPath = c.req.query("db_path");
|
|
2375
|
-
const rawIdentity = c.req.query("identity_target");
|
|
2376
|
-
if (dbPath == null && rawIdentity == null) return next();
|
|
2377
|
-
let identityTarget;
|
|
2378
|
-
if (rawIdentity != null) try {
|
|
2379
|
-
identityTarget = JSON.parse(rawIdentity);
|
|
2380
|
-
} catch {
|
|
2381
|
-
return c.json({ error: {
|
|
2382
|
-
code: "invalid_request",
|
|
2383
|
-
message: "identity_target must be JSON"
|
|
2384
|
-
} }, 400);
|
|
2385
|
-
}
|
|
2386
|
-
const target = validateViewerTarget(getStore(), {
|
|
2387
|
-
db_path: dbPath ?? void 0,
|
|
2388
|
-
identity_target: identityTarget
|
|
3142
|
+
const queueTotals = store.rawEventBacklogTotals();
|
|
3143
|
+
const authBackoff = sweeper?.authBackoffStatus() ?? {
|
|
3144
|
+
active: false,
|
|
3145
|
+
remainingS: 0
|
|
3146
|
+
};
|
|
3147
|
+
const latestFailure = store.latestRawEventFlushFailure();
|
|
3148
|
+
const active = normalizeActiveObserver(observer?.getStatus() ?? null);
|
|
3149
|
+
const availableCredentials = probeAvailableCredentials();
|
|
3150
|
+
const failureWithImpact = latestFailure != null && (authBackoff.active || queueTotals.pending > 0) && latestFailure ? {
|
|
3151
|
+
...latestFailure,
|
|
3152
|
+
impact: buildFailureImpact(latestFailure, queueTotals, authBackoff)
|
|
3153
|
+
} : null;
|
|
3154
|
+
return c.json({
|
|
3155
|
+
active,
|
|
3156
|
+
available_credentials: availableCredentials,
|
|
3157
|
+
capture_enabled: readCaptureEnabled(),
|
|
3158
|
+
latest_failure: failureWithImpact,
|
|
3159
|
+
queue: {
|
|
3160
|
+
...queueTotals,
|
|
3161
|
+
auth_backoff_active: authBackoff.active,
|
|
3162
|
+
auth_backoff_remaining_s: authBackoff.remainingS
|
|
3163
|
+
}
|
|
2389
3164
|
});
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
};
|
|
3165
|
+
});
|
|
3166
|
+
return app;
|
|
2393
3167
|
}
|
|
2394
3168
|
//#endregion
|
|
2395
3169
|
//#region src/routes/pack.ts
|
|
@@ -3619,11 +4393,7 @@ function chunkNumbers(values, chunkSize = 500) {
|
|
|
3619
4393
|
}
|
|
3620
4394
|
function visibleMemoryIdSet(store, memoryIds) {
|
|
3621
4395
|
if (memoryIds.size === 0) return /* @__PURE__ */ new Set();
|
|
3622
|
-
const filterResult = buildFilterClausesWithContext(null,
|
|
3623
|
-
actorId: store.actorId,
|
|
3624
|
-
deviceId: store.deviceId,
|
|
3625
|
-
enforceScopeVisibility: true
|
|
3626
|
-
});
|
|
4396
|
+
const filterResult = buildFilterClausesWithContext(null, store.ownershipFilterContext());
|
|
3627
4397
|
const visibleIds = /* @__PURE__ */ new Set();
|
|
3628
4398
|
for (const chunk of chunkNumbers([...memoryIds])) {
|
|
3629
4399
|
const clauses = [
|
|
@@ -3638,11 +4408,7 @@ function visibleMemoryIdSet(store, memoryIds) {
|
|
|
3638
4408
|
}
|
|
3639
4409
|
function visibleSessionIdSet(store, sessionIds) {
|
|
3640
4410
|
if (sessionIds.size === 0) return /* @__PURE__ */ new Set();
|
|
3641
|
-
const filterResult = buildFilterClausesWithContext(null,
|
|
3642
|
-
actorId: store.actorId,
|
|
3643
|
-
deviceId: store.deviceId,
|
|
3644
|
-
enforceScopeVisibility: true
|
|
3645
|
-
});
|
|
4411
|
+
const filterResult = buildFilterClausesWithContext(null, store.ownershipFilterContext());
|
|
3646
4412
|
const visibleIds = /* @__PURE__ */ new Set();
|
|
3647
4413
|
for (const chunk of chunkNumbers([...sessionIds])) {
|
|
3648
4414
|
const clauses = [
|
|
@@ -3680,8 +4446,20 @@ function usageRowVisible(row, visibility) {
|
|
|
3680
4446
|
}
|
|
3681
4447
|
function sanitizePackUsageMetadata(metadata, visibility) {
|
|
3682
4448
|
if (!metadata) return null;
|
|
3683
|
-
const sanitized =
|
|
3684
|
-
|
|
4449
|
+
const sanitized = MEMORY_ID_METADATA_KEYS.some((key) => {
|
|
4450
|
+
if (!Object.hasOwn(metadata, key)) return false;
|
|
4451
|
+
const ids = metadata[key];
|
|
4452
|
+
if (!Array.isArray(ids)) return true;
|
|
4453
|
+
return ids.some((value) => {
|
|
4454
|
+
if (typeof value !== "number" && typeof value !== "string") return true;
|
|
4455
|
+
const id = Number(value);
|
|
4456
|
+
return !Number.isInteger(id) || id <= 0 || !visibility.visibleMemoryIds.has(id);
|
|
4457
|
+
});
|
|
4458
|
+
}) ? {} : { ...metadata };
|
|
4459
|
+
for (const key of MEMORY_ID_METADATA_KEYS) {
|
|
4460
|
+
const ids = metadata[key];
|
|
4461
|
+
if (Array.isArray(ids)) sanitized[key] = metadataMemoryIds(ids.filter((value) => typeof value === "number" || typeof value === "string")).filter((memoryId) => visibility.visibleMemoryIds.has(memoryId));
|
|
4462
|
+
}
|
|
3685
4463
|
return sanitized;
|
|
3686
4464
|
}
|
|
3687
4465
|
/**
|
|
@@ -3740,15 +4518,15 @@ function usageAggregatePayload(store, project) {
|
|
|
3740
4518
|
};
|
|
3741
4519
|
}
|
|
3742
4520
|
/**
|
|
3743
|
-
* Short-lived cache for the
|
|
4521
|
+
* Short-lived cache for the unfiltered /api/usage aggregates only.
|
|
3744
4522
|
*
|
|
3745
4523
|
* The health dot polls /api/usage on every 5s refresh regardless of the
|
|
3746
4524
|
* active tab. The token/event totals come from indexed SQL aggregates and the
|
|
3747
|
-
* recent-pack window
|
|
3748
|
-
*
|
|
3749
|
-
*
|
|
3750
|
-
*
|
|
3751
|
-
*
|
|
4525
|
+
* recent-pack window must resolve current visibility on every request, even
|
|
4526
|
+
* on cache hits. Only the cumulative dashboard totals tolerate staleness.
|
|
4527
|
+
* Keyed by db path + scope identity + project filter so distinct
|
|
4528
|
+
* stores/projects never share an entry. Never cache recent packs or their
|
|
4529
|
+
* authorization: key, proof, policy, and ownership changes must apply now.
|
|
3752
4530
|
*/
|
|
3753
4531
|
var usagePayloadCache = /* @__PURE__ */ new Map();
|
|
3754
4532
|
var USAGE_PAYLOAD_CACHE_MS = 1e4;
|
|
@@ -3787,12 +4565,11 @@ function sweepUsagePayloadCache(cache, nowMs) {
|
|
|
3787
4565
|
/**
|
|
3788
4566
|
* Cheap fingerprint of the state that drives scope visibility
|
|
3789
4567
|
* (`scope_memberships` / `replication_scopes`). Folding this into the usage
|
|
3790
|
-
* cache key
|
|
3791
|
-
*
|
|
3792
|
-
*
|
|
3793
|
-
* small, so the aggregate is negligible next to the usage_events scan the
|
|
3794
|
-
* cache exists to avoid.
|
|
3795
|
-
* request bypasses the cache and recomputes visibility from scratch.
|
|
4568
|
+
* cache key preserves aggregate invalidation on membership/scope changes.
|
|
4569
|
+
* This fingerprint is not authorization: recent packs always use the current
|
|
4570
|
+
* ownership/scope filter independently of the aggregate cache. These tables
|
|
4571
|
+
* are small, so the aggregate is negligible next to the usage_events scan the
|
|
4572
|
+
* cache exists to avoid. Any error forces a unique value to bypass the cache.
|
|
3796
4573
|
*/
|
|
3797
4574
|
function scopeVisibilityGeneration(store) {
|
|
3798
4575
|
try {
|
|
@@ -3873,8 +4650,16 @@ function statsRoutes(getStore) {
|
|
|
3873
4650
|
const cacheKey = usageCacheKey(store, projectFilter);
|
|
3874
4651
|
const nowMs = Date.now();
|
|
3875
4652
|
const cached = usagePayloadCache.get(cacheKey);
|
|
3876
|
-
|
|
3877
|
-
const aggregates = usageAggregatePayload(store, projectFilter);
|
|
4653
|
+
const cacheHit = cached && nowMs < cached.expiresAtMs;
|
|
4654
|
+
const aggregates = cacheHit ? cached.payload : usageAggregatePayload(store, projectFilter);
|
|
4655
|
+
if (!cacheHit) {
|
|
4656
|
+
const setAtMs = Date.now();
|
|
4657
|
+
sweepUsagePayloadCache(usagePayloadCache, setAtMs);
|
|
4658
|
+
usagePayloadCache.set(cacheKey, {
|
|
4659
|
+
payload: aggregates,
|
|
4660
|
+
expiresAtMs: setAtMs + USAGE_PAYLOAD_CACHE_MS
|
|
4661
|
+
});
|
|
4662
|
+
}
|
|
3878
4663
|
const RECENT_PACK_SCAN_LIMIT = 200;
|
|
3879
4664
|
const loadRecentPackWindow = (project) => {
|
|
3880
4665
|
return (project ? store.db.prepare(`SELECT usage_events.id, usage_events.session_id, usage_events.event,
|
|
@@ -3907,12 +4692,6 @@ function statsRoutes(getStore) {
|
|
|
3907
4692
|
totals_filtered: aggregates.totalsFiltered,
|
|
3908
4693
|
recent_packs: recentPacks
|
|
3909
4694
|
};
|
|
3910
|
-
const setAtMs = Date.now();
|
|
3911
|
-
sweepUsagePayloadCache(usagePayloadCache, setAtMs);
|
|
3912
|
-
usagePayloadCache.set(cacheKey, {
|
|
3913
|
-
payload,
|
|
3914
|
-
expiresAtMs: setAtMs + USAGE_PAYLOAD_CACHE_MS
|
|
3915
|
-
});
|
|
3916
4695
|
return c.json(payload);
|
|
3917
4696
|
}
|
|
3918
4697
|
});
|
|
@@ -3921,10 +4700,7 @@ function statsRoutes(getStore) {
|
|
|
3921
4700
|
function healthStats(store) {
|
|
3922
4701
|
return {
|
|
3923
4702
|
...store.stats(),
|
|
3924
|
-
automatic_recall: automaticRecallHealth(store.db,
|
|
3925
|
-
actorId: store.actorId,
|
|
3926
|
-
deviceId: store.deviceId
|
|
3927
|
-
})
|
|
4703
|
+
automatic_recall: automaticRecallHealth(store.db, store.ownershipFilterContext())
|
|
3928
4704
|
};
|
|
3929
4705
|
}
|
|
3930
4706
|
//#endregion
|
|
@@ -4542,7 +5318,7 @@ async function buildSyncStatusResponse(input) {
|
|
|
4542
5318
|
return response;
|
|
4543
5319
|
}
|
|
4544
5320
|
var PEERS_QUERY$1 = `
|
|
4545
|
-
SELECT p.peer_device_id, p.name, p.pinned_fingerprint, p.addresses_json,
|
|
5321
|
+
SELECT p.peer_device_id, p.name, p.pinned_fingerprint, p.public_key, p.addresses_json,
|
|
4546
5322
|
p.last_seen_at, p.last_sync_at, p.last_error,
|
|
4547
5323
|
p.runtime_version, p.runtime_version_observed_at,
|
|
4548
5324
|
p.projects_include_json, p.projects_exclude_json, p.claimed_local_actor,
|
|
@@ -4879,6 +5655,52 @@ var defaultSnapshotLoaderDependencies = {
|
|
|
4879
5655
|
function safeCoordinatorError() {
|
|
4880
5656
|
return /* @__PURE__ */ new Error("team_setup_roster_unavailable");
|
|
4881
5657
|
}
|
|
5658
|
+
var CoordinatorAdminConfigurationMissingError = class extends Error {
|
|
5659
|
+
constructor() {
|
|
5660
|
+
super("team_setup_roster_unavailable");
|
|
5661
|
+
this.name = "CoordinatorAdminConfigurationMissingError";
|
|
5662
|
+
}
|
|
5663
|
+
};
|
|
5664
|
+
function isPassthroughSnapshotLoadError(error) {
|
|
5665
|
+
return error instanceof CoordinatorAdminConfigurationMissingError || error instanceof Error && error.message === "legacy_team_setup_roster_too_large";
|
|
5666
|
+
}
|
|
5667
|
+
/**
|
|
5668
|
+
* Without coordinator admin access this Viewer cannot discover groups to
|
|
5669
|
+
* migrate, so the summary suppresses discovery. Members still see their Teams
|
|
5670
|
+
* from local records, and Devices reports incomplete coordinator setup. Saved,
|
|
5671
|
+
* unfinished migrations stay visible so they can be resumed once access
|
|
5672
|
+
* returns. Only the summary uses this; Team detail and finish stay fail-closed.
|
|
5673
|
+
*/
|
|
5674
|
+
function summaryWithoutAdminAccess(error, getStore, readConfig) {
|
|
5675
|
+
if (!(error instanceof CoordinatorAdminConfigurationMissingError)) throw error;
|
|
5676
|
+
const store = getStore();
|
|
5677
|
+
const config = readConfig();
|
|
5678
|
+
const eligibleCandidateRefs = new Set(legacyTeamCandidateGroupDescriptors(store, config.syncCoordinatorUrl, config.syncCoordinatorGroups).map(({ coordinatorId, groupId }) => legacyTeamCandidateId(coordinatorId, groupId)));
|
|
5679
|
+
if (eligibleCandidateRefs.size === 0) return [];
|
|
5680
|
+
const eligibleRefs = [...eligibleCandidateRefs];
|
|
5681
|
+
const placeholders = eligibleRefs.map(() => "?").join(", ");
|
|
5682
|
+
return store.db.prepare(`SELECT DISTINCT coordinator_id, group_id FROM legacy_team_setup_drafts
|
|
5683
|
+
WHERE candidate_id IN (${placeholders})
|
|
5684
|
+
ORDER BY coordinator_id, group_id`).all(...eligibleRefs).flatMap((row) => {
|
|
5685
|
+
const candidateRef = legacyTeamCandidateId(row.coordinator_id, row.group_id);
|
|
5686
|
+
if (!eligibleCandidateRefs.has(candidateRef)) return [];
|
|
5687
|
+
const draft = getLegacyTeamSetupDraft(store.db, candidateRef);
|
|
5688
|
+
if (!draft || draft.state === "completed" || hasCompletedLegacyTeamSetup(store, candidateRef)) return [];
|
|
5689
|
+
return [{
|
|
5690
|
+
coordinatorId: row.coordinator_id,
|
|
5691
|
+
groupId: row.group_id,
|
|
5692
|
+
displayName: draft.displayName,
|
|
5693
|
+
devices: []
|
|
5694
|
+
}];
|
|
5695
|
+
}).slice(0, MAX_CONFIGURED_GROUPS);
|
|
5696
|
+
}
|
|
5697
|
+
function loadSummarySnapshots(load, getStore, readConfig) {
|
|
5698
|
+
return () => load().catch((error) => summaryWithoutAdminAccess(error, getStore, readConfig));
|
|
5699
|
+
}
|
|
5700
|
+
function createLoadedSummarySnapshotCache(load, options) {
|
|
5701
|
+
const readConfig = options.readCoordinatorConfig ?? options.snapshotLoaderDependencies?.readConfig ?? readCoordinatorSyncConfig;
|
|
5702
|
+
return createCachedSnapshotLoader(loadSummarySnapshots(load, options.getStore, readConfig), SUMMARY_SNAPSHOT_CACHE_TTL_MS);
|
|
5703
|
+
}
|
|
4882
5704
|
function isCoordinatorRosterTooLargeError(error) {
|
|
4883
5705
|
return error instanceof Error && error.message === "coordinator_response_too_large";
|
|
4884
5706
|
}
|
|
@@ -5028,7 +5850,8 @@ async function loadConfiguredLegacyTeamGroupSnapshotsWith(dependencies, options
|
|
|
5028
5850
|
const hasUrl = Boolean(config.syncCoordinatorUrl);
|
|
5029
5851
|
const hasSecret = Boolean(config.syncCoordinatorAdminSecret);
|
|
5030
5852
|
if (!hasUrl && !hasSecret && config.syncCoordinatorGroups.length === 0) return [];
|
|
5031
|
-
if (
|
|
5853
|
+
if (hasUrl && !hasSecret) throw new CoordinatorAdminConfigurationMissingError();
|
|
5854
|
+
if (!hasUrl) throw safeCoordinatorError();
|
|
5032
5855
|
try {
|
|
5033
5856
|
const configuredCoordinatorId = buildBaseUrl(config.syncCoordinatorUrl);
|
|
5034
5857
|
if (!configuredCoordinatorId) throw safeCoordinatorError();
|
|
@@ -5100,7 +5923,7 @@ async function loadConfiguredLegacyTeamGroupSnapshotsWith(dependencies, options
|
|
|
5100
5923
|
if (validSnapshots.length === 0 && outcomes.some((outcome) => outcome.kind === "unavailable")) throw safeCoordinatorError();
|
|
5101
5924
|
return validSnapshots;
|
|
5102
5925
|
} catch (error) {
|
|
5103
|
-
if (error
|
|
5926
|
+
if (isPassthroughSnapshotLoadError(error)) throw error;
|
|
5104
5927
|
throw safeCoordinatorError();
|
|
5105
5928
|
}
|
|
5106
5929
|
}
|
|
@@ -5762,11 +6585,11 @@ function teamSetupRoutes(options) {
|
|
|
5762
6585
|
deadlineMs
|
|
5763
6586
|
} : void 0);
|
|
5764
6587
|
} catch (error) {
|
|
5765
|
-
if (error
|
|
6588
|
+
if (isPassthroughSnapshotLoadError(error)) throw error;
|
|
5766
6589
|
throw safeCoordinatorError();
|
|
5767
6590
|
}
|
|
5768
6591
|
}
|
|
5769
|
-
const loadedSummarySnapshots =
|
|
6592
|
+
const loadedSummarySnapshots = createLoadedSummarySnapshotCache(loadedSnapshots, options);
|
|
5770
6593
|
options.registerSummaryInvalidator?.(() => loadedSummarySnapshots.invalidate());
|
|
5771
6594
|
const completionDependencies = options.completionDependencies === void 0 ? defaultCompletionDependencies : options.completionDependencies;
|
|
5772
6595
|
/**
|
|
@@ -6760,7 +7583,7 @@ async function maybeGrantDefaultSpaceOnJoin(opts) {
|
|
|
6760
7583
|
adminSecret: opts.config.syncCoordinatorAdminSecret || null
|
|
6761
7584
|
});
|
|
6762
7585
|
}
|
|
6763
|
-
function configuredGroups(config) {
|
|
7586
|
+
function configuredGroups$1(config) {
|
|
6764
7587
|
const rawGroups = config.sync_coordinator_groups;
|
|
6765
7588
|
if (Array.isArray(rawGroups)) return rawGroups.map((group) => String(group).trim()).filter(Boolean);
|
|
6766
7589
|
if (typeof rawGroups === "string") return rawGroups.split(",").map((group) => group.trim()).filter(Boolean);
|
|
@@ -6772,7 +7595,7 @@ function removeConfiguredCoordinatorGroup(groupId) {
|
|
|
6772
7595
|
if (!targetGroup) return [];
|
|
6773
7596
|
let nextGroups = [];
|
|
6774
7597
|
mutateCodememConfigFile((config) => {
|
|
6775
|
-
const groups = configuredGroups(config);
|
|
7598
|
+
const groups = configuredGroups$1(config);
|
|
6776
7599
|
nextGroups = groups.filter((group) => group !== targetGroup);
|
|
6777
7600
|
const configuredGroup = String(config.sync_coordinator_group ?? "").trim();
|
|
6778
7601
|
const removesPluralGroup = nextGroups.length !== groups.length;
|
|
@@ -7295,7 +8118,7 @@ async function reconcileProjectInviteAcceptance(store, operationId) {
|
|
|
7295
8118
|
const owner = store.db.prepare("SELECT inviter_actor_id FROM share_operations WHERE operation_id = ?").pluck().get(operationId);
|
|
7296
8119
|
if (!owner) throw new Error("operation_not_found");
|
|
7297
8120
|
if (owner !== store.actorId) throw new Error("operation_scope_mismatch");
|
|
7298
|
-
|
|
8121
|
+
requireLocalActorRecord(store);
|
|
7299
8122
|
const { groupId, payload } = await coordinatorProjectInvitePayload(operationId);
|
|
7300
8123
|
if (payload.state === "waiting_for_acceptance") return {
|
|
7301
8124
|
accepted: false,
|
|
@@ -7329,7 +8152,7 @@ async function reconcileProjectInviteAcceptance(store, operationId) {
|
|
|
7329
8152
|
throw error;
|
|
7330
8153
|
}
|
|
7331
8154
|
const projects = parseAcceptedProjectIntent(payload.projects);
|
|
7332
|
-
|
|
8155
|
+
commitProjectInviteAcceptance(store, {
|
|
7333
8156
|
operationId,
|
|
7334
8157
|
localInviterActorId: store.actorId,
|
|
7335
8158
|
coordinatorGroupId: groupId,
|
|
@@ -7350,6 +8173,12 @@ async function reconcileProjectInviteAcceptance(store, operationId) {
|
|
|
7350
8173
|
payload
|
|
7351
8174
|
};
|
|
7352
8175
|
}
|
|
8176
|
+
function commitProjectInviteAcceptance(store, input) {
|
|
8177
|
+
store.db.transaction(() => {
|
|
8178
|
+
store.assertPendingLocalCaptureAdoptionActors();
|
|
8179
|
+
reconcileShareOperationAcceptance(store.db, input);
|
|
8180
|
+
}).immediate();
|
|
8181
|
+
}
|
|
7353
8182
|
async function shareOperationReadModels(store, operationId, includeInviteLinks = true) {
|
|
7354
8183
|
const rows = store.db.prepare(`SELECT o.operation_id, o.state, o.person_id, o.teammate_name,
|
|
7355
8184
|
o.recipient_actor_id, o.recipient_display_name, o.recipient_device_id,
|
|
@@ -7529,6 +8358,10 @@ function errorStatus(error) {
|
|
|
7529
8358
|
function projectInviteOwnerErrorResponse(error, fallback) {
|
|
7530
8359
|
const message = error instanceof Error ? error.message : "";
|
|
7531
8360
|
const status = errorStatus(error);
|
|
8361
|
+
if (isPendingCaptureActorConflict(error) && (status == null || status === 409)) return {
|
|
8362
|
+
code: message,
|
|
8363
|
+
status: 409
|
|
8364
|
+
};
|
|
7532
8365
|
if (message === "operation_not_found" && (status == null || status === 404)) return {
|
|
7533
8366
|
code: message,
|
|
7534
8367
|
status: 404
|
|
@@ -8003,6 +8836,14 @@ function unauthorizedPayload() {
|
|
|
8003
8836
|
return { error: "unauthorized" };
|
|
8004
8837
|
}
|
|
8005
8838
|
var SYNC_AUTH_STORE_BUSY_REASON = "sync_auth_store_busy";
|
|
8839
|
+
function authenticatedSyncPeer(deviceId, publicKey) {
|
|
8840
|
+
return {
|
|
8841
|
+
ok: true,
|
|
8842
|
+
reason: "ok",
|
|
8843
|
+
deviceId,
|
|
8844
|
+
authenticatedPeerSigningKey: publicKey
|
|
8845
|
+
};
|
|
8846
|
+
}
|
|
8006
8847
|
var DIRECT_PEER_SIGNATURE_VERSION_NUMBER = 3;
|
|
8007
8848
|
function logDirectPeerAuthDiagnostic(...reasons) {
|
|
8008
8849
|
const reason = reasons.find((candidate) => candidate === "recipient_mismatch" || candidate === "signature_downgrade");
|
|
@@ -8173,11 +9014,39 @@ function authorizeSyncRequest(store, request, body) {
|
|
|
8173
9014
|
const versionResult = recordDirectPeerSignatureVersion(store, deviceId, verification.version);
|
|
8174
9015
|
if (versionResult) return versionResult;
|
|
8175
9016
|
}
|
|
8176
|
-
return
|
|
8177
|
-
|
|
8178
|
-
|
|
8179
|
-
|
|
8180
|
-
|
|
9017
|
+
return authenticatedSyncPeer(deviceId, publicKey);
|
|
9018
|
+
}
|
|
9019
|
+
function bootstrapPayloadRecord(value) {
|
|
9020
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
9021
|
+
}
|
|
9022
|
+
function bootstrapPayloadText(value) {
|
|
9023
|
+
return typeof value === "string" && value.length > 0 && !value.includes("\0");
|
|
9024
|
+
}
|
|
9025
|
+
function bootstrapNullableText(value) {
|
|
9026
|
+
return value === null || typeof value === "string";
|
|
9027
|
+
}
|
|
9028
|
+
function validBootstrapEnrollment(value) {
|
|
9029
|
+
if (!bootstrapPayloadRecord(value)) return false;
|
|
9030
|
+
return bootstrapPayloadText(value.group_id) && bootstrapPayloadText(value.device_id) && bootstrapPayloadText(value.public_key) && bootstrapPayloadText(value.fingerprint) && bootstrapNullableText(value.identity_id) && bootstrapNullableText(value.display_name) && value.enabled === 1 && bootstrapPayloadText(value.created_at) && fingerprintPublicKey(value.public_key) === value.fingerprint;
|
|
9031
|
+
}
|
|
9032
|
+
function validBootstrapVerification(value, grantId) {
|
|
9033
|
+
if (!bootstrapPayloadRecord(value) || value.authorization_version !== 1) return false;
|
|
9034
|
+
const grant = value.grant;
|
|
9035
|
+
if (!bootstrapPayloadRecord(grant)) return false;
|
|
9036
|
+
if (grant.grant_id !== grantId || !bootstrapPayloadText(grant.group_id) || !bootstrapPayloadText(grant.seed_device_id) || !bootstrapPayloadText(grant.worker_device_id) || !bootstrapPayloadText(grant.created_at) || !bootstrapNullableText(grant.created_by) || grant.revoked_at !== null || !Number.isFinite(parseBootstrapExpiry(grant.expires_at))) return false;
|
|
9037
|
+
const seed = value.seed_enrollment;
|
|
9038
|
+
const worker = value.worker_enrollment;
|
|
9039
|
+
if (!validBootstrapEnrollment(seed) || !validBootstrapEnrollment(worker)) return false;
|
|
9040
|
+
return seed.device_id === grant.seed_device_id && worker.device_id === grant.worker_device_id && seed.group_id === grant.group_id && worker.group_id === grant.group_id;
|
|
9041
|
+
}
|
|
9042
|
+
function bootstrapSeedMatchesLocalIdentity(store, seed) {
|
|
9043
|
+
try {
|
|
9044
|
+
const [deviceId, fingerprint] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
|
|
9045
|
+
const publicKey = loadPublicKey(syncKeysDir());
|
|
9046
|
+
return deviceId === seed.device_id && publicKey === seed.public_key && fingerprint === seed.fingerprint && publicKey !== null && fingerprintPublicKey(publicKey) === seed.fingerprint;
|
|
9047
|
+
} catch {
|
|
9048
|
+
return false;
|
|
9049
|
+
}
|
|
8181
9050
|
}
|
|
8182
9051
|
async function authorizeBootstrapGrantRequest(store, request, body) {
|
|
8183
9052
|
const grantId = (request.header("X-Codemem-Bootstrap-Grant") ?? "").trim();
|
|
@@ -8196,9 +9065,10 @@ async function authorizeBootstrapGrantRequest(store, request, body) {
|
|
|
8196
9065
|
reason: "bootstrap_grant_coordinator_not_configured",
|
|
8197
9066
|
deviceId
|
|
8198
9067
|
};
|
|
8199
|
-
|
|
9068
|
+
let localDeviceId;
|
|
8200
9069
|
let verification;
|
|
8201
9070
|
try {
|
|
9071
|
+
[localDeviceId] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
|
|
8202
9072
|
let payload = null;
|
|
8203
9073
|
if (config.syncCoordinatorAdminSecret) {
|
|
8204
9074
|
const [status, adminPayload] = await requestJson("GET", `${buildBaseUrl(config.syncCoordinatorUrl)}/v1/admin/bootstrap-grants/${encodeURIComponent(grantId)}`, {
|
|
@@ -8229,6 +9099,11 @@ async function authorizeBootstrapGrantRequest(store, request, body) {
|
|
|
8229
9099
|
reason: "bootstrap_grant_lookup_failed",
|
|
8230
9100
|
deviceId
|
|
8231
9101
|
};
|
|
9102
|
+
if (!validBootstrapVerification(payload, grantId)) return {
|
|
9103
|
+
ok: false,
|
|
9104
|
+
reason: "bootstrap_grant_invalid_payload",
|
|
9105
|
+
deviceId
|
|
9106
|
+
};
|
|
8232
9107
|
verification = payload;
|
|
8233
9108
|
} catch {
|
|
8234
9109
|
return {
|
|
@@ -8238,44 +9113,15 @@ async function authorizeBootstrapGrantRequest(store, request, body) {
|
|
|
8238
9113
|
};
|
|
8239
9114
|
}
|
|
8240
9115
|
const grant = verification.grant;
|
|
9116
|
+
const seedEnrollment = verification.seed_enrollment;
|
|
8241
9117
|
const workerEnrollment = verification.worker_enrollment;
|
|
8242
|
-
if (
|
|
8243
|
-
ok: false,
|
|
8244
|
-
reason: "bootstrap_grant_invalid_payload",
|
|
8245
|
-
deviceId
|
|
8246
|
-
};
|
|
8247
|
-
if (config.syncCoordinatorGroups.length > 0 && !config.syncCoordinatorGroups.includes(String(grant.group_id))) return {
|
|
9118
|
+
if (config.syncCoordinatorGroups.length > 0 && !config.syncCoordinatorGroups.includes(grant.group_id)) return {
|
|
8248
9119
|
ok: false,
|
|
8249
9120
|
reason: "bootstrap_grant_group_mismatch",
|
|
8250
9121
|
deviceId
|
|
8251
9122
|
};
|
|
8252
|
-
|
|
8253
|
-
|
|
8254
|
-
reason: "bootstrap_grant_revoked",
|
|
8255
|
-
deviceId
|
|
8256
|
-
};
|
|
8257
|
-
if (String(workerEnrollment.device_id) !== String(grant.worker_device_id)) return {
|
|
8258
|
-
ok: false,
|
|
8259
|
-
reason: "bootstrap_grant_worker_enrollment_mismatch",
|
|
8260
|
-
deviceId
|
|
8261
|
-
};
|
|
8262
|
-
if (String(workerEnrollment.group_id) !== String(grant.group_id)) return {
|
|
8263
|
-
ok: false,
|
|
8264
|
-
reason: "bootstrap_grant_group_mismatch",
|
|
8265
|
-
deviceId
|
|
8266
|
-
};
|
|
8267
|
-
if (Number(workerEnrollment.enabled) !== 1) return {
|
|
8268
|
-
ok: false,
|
|
8269
|
-
reason: "bootstrap_grant_worker_disabled",
|
|
8270
|
-
deviceId
|
|
8271
|
-
};
|
|
8272
|
-
const workerPublicKey = String(workerEnrollment.public_key ?? "").trim();
|
|
8273
|
-
const workerFingerprint = String(workerEnrollment.fingerprint ?? "").trim();
|
|
8274
|
-
if (!workerPublicKey || fingerprintPublicKey(workerPublicKey) !== workerFingerprint) return {
|
|
8275
|
-
ok: false,
|
|
8276
|
-
reason: "bootstrap_grant_worker_identity_invalid",
|
|
8277
|
-
deviceId
|
|
8278
|
-
};
|
|
9123
|
+
const workerPublicKey = workerEnrollment.public_key;
|
|
9124
|
+
const workerFingerprint = workerEnrollment.fingerprint;
|
|
8279
9125
|
if (grant.worker_device_id !== deviceId) return {
|
|
8280
9126
|
ok: false,
|
|
8281
9127
|
reason: "bootstrap_grant_worker_mismatch",
|
|
@@ -8286,11 +9132,16 @@ async function authorizeBootstrapGrantRequest(store, request, body) {
|
|
|
8286
9132
|
reason: "bootstrap_grant_seed_mismatch",
|
|
8287
9133
|
deviceId
|
|
8288
9134
|
};
|
|
8289
|
-
if (
|
|
9135
|
+
if (parseBootstrapExpiry(grant.expires_at) <= Date.now()) return {
|
|
8290
9136
|
ok: false,
|
|
8291
9137
|
reason: "bootstrap_grant_expired",
|
|
8292
9138
|
deviceId
|
|
8293
9139
|
};
|
|
9140
|
+
if (!bootstrapSeedMatchesLocalIdentity(store, seedEnrollment)) return {
|
|
9141
|
+
ok: false,
|
|
9142
|
+
reason: "bootstrap_grant_seed_identity_invalid",
|
|
9143
|
+
deviceId
|
|
9144
|
+
};
|
|
8294
9145
|
const existingPeer = store.db.prepare(`SELECT highest_observed_direct_signature_version
|
|
8295
9146
|
FROM sync_peer_signature_state WHERE peer_device_id = ? LIMIT 1`).get(deviceId);
|
|
8296
9147
|
let signatureVerification;
|
|
@@ -8321,7 +9172,7 @@ async function authorizeBootstrapGrantRequest(store, request, body) {
|
|
|
8321
9172
|
const nonceResult = recordSyncAuthNonce(store, deviceId, nonce, (/* @__PURE__ */ new Date()).toISOString());
|
|
8322
9173
|
if (nonceResult) return nonceResult;
|
|
8323
9174
|
updatePeerAddresses(store.db, deviceId, [], {
|
|
8324
|
-
name:
|
|
9175
|
+
name: workerEnrollment.display_name?.trim() || void 0,
|
|
8325
9176
|
pinnedFingerprint: workerFingerprint,
|
|
8326
9177
|
publicKey: workerPublicKey,
|
|
8327
9178
|
replaceTrust: true
|
|
@@ -8330,11 +9181,7 @@ async function authorizeBootstrapGrantRequest(store, request, body) {
|
|
|
8330
9181
|
const versionResult = recordDirectPeerSignatureVersion(store, deviceId, signatureVerification.version);
|
|
8331
9182
|
if (versionResult) return versionResult;
|
|
8332
9183
|
}
|
|
8333
|
-
return
|
|
8334
|
-
ok: true,
|
|
8335
|
-
reason: "ok",
|
|
8336
|
-
deviceId
|
|
8337
|
-
};
|
|
9184
|
+
return authenticatedSyncPeer(deviceId, workerPublicKey);
|
|
8338
9185
|
}
|
|
8339
9186
|
function parseJsonList(value) {
|
|
8340
9187
|
if (value == null) return [];
|
|
@@ -8409,7 +9256,23 @@ function peerAuthorizedScopes(store, peerDeviceId) {
|
|
|
8409
9256
|
updated_at: cleanPeerScopeText(row.updated_at)
|
|
8410
9257
|
}));
|
|
8411
9258
|
}
|
|
9259
|
+
function isPendingCaptureActorConflict(error) {
|
|
9260
|
+
return error instanceof Error && error.constructor === Error && error.message === "device_adoption_actor_conflict";
|
|
9261
|
+
}
|
|
9262
|
+
/** False leaves the conflict untouched; callers must check before claiming or merging identity. */
|
|
8412
9263
|
function ensureLocalActorRecord(store) {
|
|
9264
|
+
return store.db.transaction(() => {
|
|
9265
|
+
try {
|
|
9266
|
+
store.assertPendingLocalCaptureAdoptionActors();
|
|
9267
|
+
} catch (error) {
|
|
9268
|
+
if (isPendingCaptureActorConflict(error)) return false;
|
|
9269
|
+
throw error;
|
|
9270
|
+
}
|
|
9271
|
+
persistLocalActorRecord(store);
|
|
9272
|
+
return true;
|
|
9273
|
+
}).immediate();
|
|
9274
|
+
}
|
|
9275
|
+
function persistLocalActorRecord(store) {
|
|
8413
9276
|
const d = drizzle(store.db, { schema });
|
|
8414
9277
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
8415
9278
|
d.update(schema.actors).set({
|
|
@@ -8437,7 +9300,16 @@ function ensureLocalActorRecord(store) {
|
|
|
8437
9300
|
updated_at: now
|
|
8438
9301
|
}).run();
|
|
8439
9302
|
}
|
|
9303
|
+
function requireLocalActorRecord(store) {
|
|
9304
|
+
if (!ensureLocalActorRecord(store)) throw new Error("device_adoption_actor_conflict");
|
|
9305
|
+
}
|
|
8440
9306
|
function ensureStableStoreIdentity(store) {
|
|
9307
|
+
const deviceId = ensureStableStoreDeviceIdentity(store);
|
|
9308
|
+
requireLocalActorRecord(store);
|
|
9309
|
+
return deviceId;
|
|
9310
|
+
}
|
|
9311
|
+
/** Preserve normal actor setup for reads, without repairing a pending conflict. */
|
|
9312
|
+
function ensureStableStoreIdentityForRead(store) {
|
|
8441
9313
|
const deviceId = ensureStableStoreDeviceIdentity(store);
|
|
8442
9314
|
ensureLocalActorRecord(store);
|
|
8443
9315
|
return deviceId;
|
|
@@ -8749,7 +9621,7 @@ function missingArchivedCoordinatorGroupResponse(groupId, status, groups) {
|
|
|
8749
9621
|
* When showDiag is false, sensitive fields (fingerprint, last_error, addresses)
|
|
8750
9622
|
* are redacted.
|
|
8751
9623
|
*/
|
|
8752
|
-
function mapPeerRow(store, row, showDiag, recentOpsByPeer, scopeRejectionsByPeer, localDeviceId) {
|
|
9624
|
+
function mapPeerRow(store, row, showDiag, recentOpsByPeer, scopeRejectionsByPeer, localDeviceId, loadDiagnosticKey) {
|
|
8753
9625
|
const peerId = String(row.peer_device_id ?? "");
|
|
8754
9626
|
const recentOps = recentOpsByPeer?.get(peerId) ?? {
|
|
8755
9627
|
in: 0,
|
|
@@ -8759,7 +9631,8 @@ function mapPeerRow(store, row, showDiag, recentOpsByPeer, scopeRejectionsByPeer
|
|
|
8759
9631
|
const addresses = safeJsonList(row.addresses_json);
|
|
8760
9632
|
const perScopeSync = listPerPeerScopeSyncState(store.db, {
|
|
8761
9633
|
localDeviceId: localDeviceId ?? null,
|
|
8762
|
-
peerDeviceId: peerId
|
|
9634
|
+
peerDeviceId: peerId,
|
|
9635
|
+
...peerScopeDiagnosticKeys(store, row, localDeviceId, loadDiagnosticKey)
|
|
8763
9636
|
});
|
|
8764
9637
|
return {
|
|
8765
9638
|
peer_device_id: row.peer_device_id,
|
|
@@ -8821,6 +9694,16 @@ function recentScopeRejectionsByPeer(store) {
|
|
|
8821
9694
|
}
|
|
8822
9695
|
return map;
|
|
8823
9696
|
}
|
|
9697
|
+
function staleRowsForCleanupDiagnostics(store, localDeviceId) {
|
|
9698
|
+
return diagnoseStalePeerReceivedRows(store.db, {
|
|
9699
|
+
localDeviceId,
|
|
9700
|
+
keysDir: syncKeysDir(),
|
|
9701
|
+
maxRows: CLEANUP_DIAGNOSTICS_MAX_ROWS
|
|
9702
|
+
});
|
|
9703
|
+
}
|
|
9704
|
+
function applyInboundSyncOps(store, ops, localDeviceId) {
|
|
9705
|
+
return applyReplicationOps(store.db, ops, localDeviceId, store.scanner, { keysDir: syncKeysDir() });
|
|
9706
|
+
}
|
|
8824
9707
|
function cleanupDiagnostics(store, localDeviceId, showDiag) {
|
|
8825
9708
|
const opRows = store.db.prepare(`SELECT
|
|
8826
9709
|
SUM(CASE WHEN device_id = ? THEN 1 ELSE 0 END) AS source_authored,
|
|
@@ -8828,10 +9711,7 @@ function cleanupDiagnostics(store, localDeviceId, showDiag) {
|
|
|
8828
9711
|
MAX(created_at) AS latest_at
|
|
8829
9712
|
FROM replication_ops
|
|
8830
9713
|
WHERE op_type = ?`).get(localDeviceId ?? "", localDeviceId ?? "", ACCESS_CLEANUP_OP_TYPE);
|
|
8831
|
-
const stale = localDeviceId && showDiag ?
|
|
8832
|
-
localDeviceId,
|
|
8833
|
-
maxRows: CLEANUP_DIAGNOSTICS_MAX_ROWS
|
|
8834
|
-
}) : {
|
|
9714
|
+
const stale = localDeviceId && showDiag ? staleRowsForCleanupDiagnostics(store, localDeviceId) : {
|
|
8835
9715
|
ambiguous: [],
|
|
8836
9716
|
checked: 0,
|
|
8837
9717
|
retained: 0,
|
|
@@ -9114,8 +9994,8 @@ function forgetProjectInventoryLocalMemories(store, input) {
|
|
|
9114
9994
|
};
|
|
9115
9995
|
if (input.confirmationToken !== confirmationToken) throw new Error("project memories changed before cleanup; refresh and try again");
|
|
9116
9996
|
store.db.transaction(() => {
|
|
9117
|
-
for (const row of localRows) store.
|
|
9118
|
-
})();
|
|
9997
|
+
for (const row of localRows) if (!store.forgetForUser(row.id)) throw new Error("unauthorized_scope");
|
|
9998
|
+
}).immediate();
|
|
9119
9999
|
return {
|
|
9120
10000
|
confirmed: true,
|
|
9121
10001
|
forgotten_memory_count: localRows.length,
|
|
@@ -9132,7 +10012,7 @@ function assertLocalDeviceScopeMembership(store, scopeId) {
|
|
|
9132
10012
|
AND sm.status = 'active'
|
|
9133
10013
|
AND rs.status = 'active'
|
|
9134
10014
|
AND sm.membership_epoch >= rs.membership_epoch
|
|
9135
|
-
LIMIT 1`).get(scopeId, store.deviceId)) throw new Error(`local device is not a member of Sharing domain ${scopeId}`);
|
|
10015
|
+
LIMIT 1`).get(scopeId, store.deviceId) || !store.isScopeWritable(scopeId)) throw new Error(`local device is not a member of Sharing domain ${scopeId}`);
|
|
9136
10016
|
}
|
|
9137
10017
|
function legacySharedReviewRowsForWorkspace(store, workspaceIdentity) {
|
|
9138
10018
|
const repositoryIdentities = repositoryIdentitiesByWorkspace(store.db);
|
|
@@ -9195,18 +10075,19 @@ function legacySharedReviewReassignmentPreview(store, input) {
|
|
|
9195
10075
|
};
|
|
9196
10076
|
}
|
|
9197
10077
|
function reassignLegacySharedReviewGroup(store, input) {
|
|
9198
|
-
|
|
9199
|
-
|
|
9200
|
-
|
|
9201
|
-
|
|
10078
|
+
return store.db.transaction(() => {
|
|
10079
|
+
const preview = legacySharedReviewReassignmentPreview(store, input);
|
|
10080
|
+
if (input.confirmationToken !== preview.confirmation_token) throw new Error("legacy shared review group changed before reassignment; refresh and try again");
|
|
10081
|
+
const rows = reassignableLegacySharedReviewRows(store, legacySharedReviewRowsForWorkspace(store, input.workspaceIdentity));
|
|
10082
|
+
assertLocalDeviceScopeMembership(store, input.scopeId);
|
|
9202
10083
|
for (const row of rows) store.reassignMemoryScope(row.id, input.scopeId);
|
|
9203
|
-
|
|
9204
|
-
|
|
9205
|
-
|
|
9206
|
-
|
|
9207
|
-
|
|
9208
|
-
|
|
9209
|
-
};
|
|
10084
|
+
return {
|
|
10085
|
+
ok: true,
|
|
10086
|
+
...preview,
|
|
10087
|
+
reassigned_memory_count: rows.length,
|
|
10088
|
+
legacy_shared_review: legacySharedReviewSummary(store)
|
|
10089
|
+
};
|
|
10090
|
+
}).immediate();
|
|
9210
10091
|
}
|
|
9211
10092
|
var SYNC_DIRECTION_WINDOW_SECONDS = 86400;
|
|
9212
10093
|
function recentPeerOps(store) {
|
|
@@ -9281,7 +10162,7 @@ function readViewerBinding(dbPath) {
|
|
|
9281
10162
|
return null;
|
|
9282
10163
|
}
|
|
9283
10164
|
var PEERS_QUERY = `
|
|
9284
|
-
SELECT p.peer_device_id, p.name, p.pinned_fingerprint, p.addresses_json,
|
|
10165
|
+
SELECT p.peer_device_id, p.name, p.pinned_fingerprint, p.public_key, p.addresses_json,
|
|
9285
10166
|
p.last_seen_at, p.last_sync_at, p.last_error,
|
|
9286
10167
|
p.runtime_version, p.runtime_version_observed_at,
|
|
9287
10168
|
p.projects_include_json, p.projects_exclude_json, p.claimed_local_actor,
|
|
@@ -9318,15 +10199,116 @@ function negotiatedSyncCapability(c) {
|
|
|
9318
10199
|
* network-accessible. All requests are auth-gated via signature
|
|
9319
10200
|
* verification so unauthenticated callers are rejected.
|
|
9320
10201
|
*/
|
|
9321
|
-
function
|
|
10202
|
+
function syncScopeAuthorizationContext(store, auth, localDeviceId) {
|
|
10203
|
+
return {
|
|
10204
|
+
db: store.db,
|
|
10205
|
+
localDeviceId,
|
|
10206
|
+
peerDeviceId: auth.deviceId,
|
|
10207
|
+
localSigningPublicKey: loadPublicKey(syncKeysDir()),
|
|
10208
|
+
authenticatedPeerSigningKey: auth.authenticatedPeerSigningKey
|
|
10209
|
+
};
|
|
10210
|
+
}
|
|
10211
|
+
function peerScopeDiagnosticKeys(store, row, localDeviceId, loadDiagnosticKey) {
|
|
10212
|
+
return {
|
|
10213
|
+
localSigningPublicKey: loadDiagnosticKey?.(store, localDeviceId) ?? null,
|
|
10214
|
+
authenticatedPeerSigningKey: typeof row.public_key === "string" ? row.public_key : null
|
|
10215
|
+
};
|
|
10216
|
+
}
|
|
10217
|
+
function createPeerScopeDiagnosticKeyLoader() {
|
|
10218
|
+
let publicKey;
|
|
10219
|
+
return (store, localDeviceId) => {
|
|
10220
|
+
if (publicKey !== void 0) return publicKey;
|
|
10221
|
+
publicKey = null;
|
|
10222
|
+
if (!localDeviceId) return null;
|
|
10223
|
+
if (!store.db.prepare(`SELECT 1 FROM scope_memberships m
|
|
10224
|
+
JOIN replication_scopes s ON s.scope_id = m.scope_id
|
|
10225
|
+
WHERE m.device_id = ? AND m.status = 'active'
|
|
10226
|
+
AND s.authority_type = 'coordinator' LIMIT 1`).get(localDeviceId)) return null;
|
|
10227
|
+
publicKey = loadRuntimeSigningPublicKey(store.db, {
|
|
10228
|
+
deviceId: localDeviceId,
|
|
10229
|
+
keysDir: syncKeysDir()
|
|
10230
|
+
});
|
|
10231
|
+
return publicKey;
|
|
10232
|
+
};
|
|
10233
|
+
}
|
|
10234
|
+
function readPeerScopeDiagnostics(store, showDiag) {
|
|
10235
|
+
const rows = store.db.prepare(PEERS_QUERY).all();
|
|
10236
|
+
const recentOpsByPeer = recentPeerOps(store);
|
|
10237
|
+
const scopeRejectionsByPeer = recentScopeRejectionsByPeer(store);
|
|
10238
|
+
const localDeviceId = drizzle(store.db, { schema }).select({ device_id: schema.syncDevice.device_id }).from(schema.syncDevice).limit(1).get()?.device_id ?? null;
|
|
10239
|
+
const loadDiagnosticKey = createPeerScopeDiagnosticKeyLoader();
|
|
10240
|
+
return rows.map((row) => mapPeerRow(store, row, showDiag, recentOpsByPeer, scopeRejectionsByPeer, localDeviceId, loadDiagnosticKey));
|
|
10241
|
+
}
|
|
10242
|
+
function validateManagedScopes(scopeIds, context) {
|
|
10243
|
+
for (const scopeId of scopeIds) {
|
|
10244
|
+
if (!scopeId || scopeId === LOCAL_DEFAULT_SCOPE_ID) continue;
|
|
10245
|
+
const localAuthorization = getEffectiveCachedScopeAuthorization(context.db, {
|
|
10246
|
+
deviceId: context.localDeviceId,
|
|
10247
|
+
scopeId,
|
|
10248
|
+
expectedPublicKey: context.localSigningPublicKey?.trim() ?? ""
|
|
10249
|
+
});
|
|
10250
|
+
const authority = localAuthorization.scope?.authority_type;
|
|
10251
|
+
if (authority === "manual" || authority === "invite" || authority === "local") continue;
|
|
10252
|
+
if (authority !== "coordinator") return {
|
|
10253
|
+
scopeId,
|
|
10254
|
+
reason: "missing_scope"
|
|
10255
|
+
};
|
|
10256
|
+
const localReason = scopeAuthorizationFailureReason(localAuthorization);
|
|
10257
|
+
if (localReason) return {
|
|
10258
|
+
scopeId,
|
|
10259
|
+
reason: localReason
|
|
10260
|
+
};
|
|
10261
|
+
const peerAuthorization = getEffectiveCachedScopeAuthorization(context.db, {
|
|
10262
|
+
deviceId: context.peerDeviceId,
|
|
10263
|
+
scopeId,
|
|
10264
|
+
expectedPublicKey: context.authenticatedPeerSigningKey.trim()
|
|
10265
|
+
});
|
|
10266
|
+
const peerReason = scopeAuthorizationFailureReason(peerAuthorization);
|
|
10267
|
+
if (peerReason) return {
|
|
10268
|
+
scopeId,
|
|
10269
|
+
reason: peerReason
|
|
10270
|
+
};
|
|
10271
|
+
}
|
|
10272
|
+
return null;
|
|
10273
|
+
}
|
|
10274
|
+
function addExistingMutationScopes(db, ops, scopeIds) {
|
|
10275
|
+
const existingScopes = db.prepare("SELECT DISTINCT scope_id FROM memory_items WHERE import_key = ?");
|
|
10276
|
+
for (const op of ops) {
|
|
10277
|
+
if (![
|
|
10278
|
+
"upsert",
|
|
10279
|
+
"delete",
|
|
10280
|
+
"reassign_scope"
|
|
10281
|
+
].includes(op.op_type)) continue;
|
|
10282
|
+
const rows = existingScopes.all(op.entity_id);
|
|
10283
|
+
for (const { scope_id: scope } of rows) if (typeof scope === "string" && scope.trim()) scopeIds.add(scope.trim());
|
|
10284
|
+
}
|
|
10285
|
+
}
|
|
10286
|
+
function rejectUnauthorizedInboundBatch(c, ops, context, options) {
|
|
10287
|
+
const rejected = rejectInboundScopeFailures(context.db, ops, context.localDeviceId, {
|
|
10288
|
+
enabled: options.enabled,
|
|
10289
|
+
peerDeviceId: context.peerDeviceId
|
|
10290
|
+
});
|
|
10291
|
+
if (rejected) return c.json({
|
|
10292
|
+
error: "scope_rejected",
|
|
10293
|
+
reason: rejected.rejections[0]?.reason ?? "scope_mismatch",
|
|
10294
|
+
rejections: rejected.rejections,
|
|
10295
|
+
sync_capability: LOCAL_SYNC_CAPABILITY,
|
|
10296
|
+
scope_id: options.scopeId
|
|
10297
|
+
}, 403);
|
|
10298
|
+
const scopeIds = new Set(options.managedScopeIds);
|
|
10299
|
+
addExistingMutationScopes(context.db, ops, scopeIds);
|
|
10300
|
+
const failure = validateManagedScopes(scopeIds, context);
|
|
10301
|
+
if (!failure) return null;
|
|
10302
|
+
return c.json(syncScopeResetRequiredPayload(getSyncResetState(context.db), failure.reason, LOCAL_SYNC_CAPABILITY, failure.scopeId), 409);
|
|
10303
|
+
}
|
|
10304
|
+
function serveAuthorizedSnapshot(c, store, auth) {
|
|
10305
|
+
const peerDeviceId = auth.deviceId;
|
|
9322
10306
|
try {
|
|
9323
10307
|
const rawScopeId = c.req.query(SYNC_SCOPE_QUERY_PARAM);
|
|
9324
10308
|
const [localDeviceId] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
|
|
9325
10309
|
const scopeRequest = parseSyncScopeRequest(rawScopeId, rawScopeId !== void 0, {
|
|
9326
|
-
|
|
9327
|
-
|
|
9328
|
-
negotiatedCapability: negotiatedSyncCapability(c),
|
|
9329
|
-
peerDeviceId
|
|
10310
|
+
...syncScopeAuthorizationContext(store, auth, localDeviceId),
|
|
10311
|
+
negotiatedCapability: negotiatedSyncCapability(c)
|
|
9330
10312
|
});
|
|
9331
10313
|
if (!scopeRequest.ok) return c.json(syncScopeResetRequiredPayload(getSyncResetState(store.db), scopeRequest.reason, LOCAL_SYNC_CAPABILITY, null), 409);
|
|
9332
10314
|
const pageRequest = snapshotPageRequest((name) => c.req.query(name), localDeviceId);
|
|
@@ -9407,10 +10389,7 @@ function syncProtocolRoutes(getStore, opts = {}) {
|
|
|
9407
10389
|
const [deviceId, fingerprint] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
|
|
9408
10390
|
const syncReset = getSyncResetState(store.db);
|
|
9409
10391
|
const negotiated = negotiatedSyncCapability(c);
|
|
9410
|
-
const authorizedScopes = isScopedSyncCapability(negotiated) ? listAuthorizedScopesForPeer(store.db, {
|
|
9411
|
-
localDeviceId: deviceId,
|
|
9412
|
-
peerDeviceId: auth.deviceId
|
|
9413
|
-
}) : null;
|
|
10392
|
+
const authorizedScopes = isScopedSyncCapability(negotiated) ? listAuthorizedScopesForPeer(store.db, { ...syncScopeAuthorizationContext(store, auth, deviceId) }) : null;
|
|
9414
10393
|
const response = {
|
|
9415
10394
|
device_id: deviceId,
|
|
9416
10395
|
protocol_version: SYNC_PROTOCOL_VERSION,
|
|
@@ -9443,10 +10422,8 @@ function syncProtocolRoutes(getStore, opts = {}) {
|
|
|
9443
10422
|
const negotiated = negotiatedSyncCapability(c);
|
|
9444
10423
|
const [localDeviceId] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
|
|
9445
10424
|
const scopeRequest = parseSyncScopeRequest(rawScopeId, rawScopeId !== void 0, {
|
|
9446
|
-
|
|
9447
|
-
|
|
9448
|
-
negotiatedCapability: negotiated,
|
|
9449
|
-
peerDeviceId
|
|
10425
|
+
...syncScopeAuthorizationContext(store, auth, localDeviceId),
|
|
10426
|
+
negotiatedCapability: negotiated
|
|
9450
10427
|
});
|
|
9451
10428
|
if (!scopeRequest.ok) return c.json(syncScopeResetRequiredPayload(getSyncResetState(store.db), scopeRequest.reason, LOCAL_SYNC_CAPABILITY, null), 409);
|
|
9452
10429
|
const since = c.req.query("since") ?? null;
|
|
@@ -9520,7 +10497,7 @@ function syncProtocolRoutes(getStore, opts = {}) {
|
|
|
9520
10497
|
}
|
|
9521
10498
|
const limited = rateLimitedResponse(c, auth.deviceId, true);
|
|
9522
10499
|
if (limited) return limited;
|
|
9523
|
-
return serveAuthorizedSnapshot(c, store, auth
|
|
10500
|
+
return serveAuthorizedSnapshot(c, store, auth);
|
|
9524
10501
|
})();
|
|
9525
10502
|
});
|
|
9526
10503
|
app.post("/v1/ops", async (c) => {
|
|
@@ -9546,21 +10523,25 @@ function syncProtocolRoutes(getStore, opts = {}) {
|
|
|
9546
10523
|
}
|
|
9547
10524
|
const negotiated = negotiateSyncCapability(LOCAL_SYNC_CAPABILITY, normalizeSyncCapability(body.sync_capability));
|
|
9548
10525
|
const [localDeviceId] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
|
|
10526
|
+
const scopeContext = syncScopeAuthorizationContext(store, auth, localDeviceId);
|
|
9549
10527
|
const scopeRequest = parseSyncScopeRequest(body.scope_id, Object.hasOwn(body, "scope_id"), {
|
|
9550
|
-
|
|
9551
|
-
|
|
9552
|
-
negotiatedCapability: negotiated,
|
|
9553
|
-
peerDeviceId
|
|
10528
|
+
...scopeContext,
|
|
10529
|
+
negotiatedCapability: negotiated
|
|
9554
10530
|
});
|
|
9555
10531
|
if (!scopeRequest.ok) return c.json(syncScopeResetRequiredPayload(getSyncResetState(store.db), scopeRequest.reason, LOCAL_SYNC_CAPABILITY, null), 409);
|
|
9556
10532
|
if (!Array.isArray(body.ops)) return c.json({ error: "invalid_ops" }, 400);
|
|
9557
10533
|
if (body.ops.length > MAX_SYNC_OPS) return c.json({ error: "too_many_ops" }, 413);
|
|
9558
10534
|
const normalizedOps = extractReplicationOps(body);
|
|
10535
|
+
const scopeIds = new Set(normalizedOps.map((op) => typeof op.scope_id === "string" ? op.scope_id.trim() : ""));
|
|
9559
10536
|
const peerFeatures = normalizeSyncFeatures(body.sync_features);
|
|
9560
10537
|
const reassignOps = normalizedOps.filter((op) => op.op_type === "reassign_scope");
|
|
9561
10538
|
if (reassignOps.length > 0 && !peerFeatures.includes("reassign_scope")) return c.json({ error: "reassign_capability_required" }, 409);
|
|
9562
10539
|
try {
|
|
9563
|
-
for (const op of reassignOps)
|
|
10540
|
+
for (const op of reassignOps) {
|
|
10541
|
+
const reassignment = parseReassignScopePayload(op);
|
|
10542
|
+
scopeIds.add(reassignment.old_scope_id);
|
|
10543
|
+
scopeIds.add(reassignment.new_scope_id);
|
|
10544
|
+
}
|
|
9564
10545
|
} catch {
|
|
9565
10546
|
return c.json({ error: "reassign_payload_invalid" }, 400);
|
|
9566
10547
|
}
|
|
@@ -9573,18 +10554,13 @@ function syncProtocolRoutes(getStore, opts = {}) {
|
|
|
9573
10554
|
applyScopeFilter: false,
|
|
9574
10555
|
supportsReassignScope: peerFeatures.includes("reassign_scope")
|
|
9575
10556
|
});
|
|
9576
|
-
const rejected =
|
|
10557
|
+
const rejected = rejectUnauthorizedInboundBatch(c, normalizedOps, scopeContext, {
|
|
9577
10558
|
enabled: negotiated !== "unsupported",
|
|
9578
|
-
|
|
10559
|
+
scopeId: scopeRequest.scope_id,
|
|
10560
|
+
managedScopeIds: scopeIds
|
|
9579
10561
|
});
|
|
9580
|
-
if (rejected) return
|
|
9581
|
-
|
|
9582
|
-
reason: rejected.rejections[0]?.reason ?? "scope_mismatch",
|
|
9583
|
-
rejections: rejected.rejections,
|
|
9584
|
-
sync_capability: LOCAL_SYNC_CAPABILITY,
|
|
9585
|
-
scope_id: scopeRequest.scope_id
|
|
9586
|
-
}, 403);
|
|
9587
|
-
const result = applyReplicationOps(store.db, filtered.allowed, localDeviceId, store.scanner);
|
|
10562
|
+
if (rejected) return rejected;
|
|
10563
|
+
const result = applyInboundSyncOps(store, filtered.allowed, localDeviceId);
|
|
9588
10564
|
const skipped = result.skipped + filtered.skipped;
|
|
9589
10565
|
return c.json({
|
|
9590
10566
|
...result,
|
|
@@ -9785,10 +10761,15 @@ function wakeRecipientPolicyProjects(store, projectIdentities, now) {
|
|
|
9785
10761
|
wakeRecipientPoliciesForProjectIdentities(store.db, [...new Set(projectIdentities)], now);
|
|
9786
10762
|
}
|
|
9787
10763
|
function saveProjectMappingsAndWakePolicies(store, deviceId, mappingInputs) {
|
|
10764
|
+
store.adoptEnsuredDeviceIdentity(deviceId);
|
|
9788
10765
|
return store.db.transaction(() => {
|
|
9789
10766
|
const before = policyWakeMappings(store);
|
|
9790
10767
|
const previousMappings = existingRequestedMappings(before, mappingInputs);
|
|
9791
|
-
const
|
|
10768
|
+
const writableScopeIds = new Set(resolveVisibleScopeIds(store.db, store.deviceId, store.scopeResolutionDeviceContext()));
|
|
10769
|
+
const mappings = upsertProjectScopeSettingsMappings(store.db, mappingInputs, {
|
|
10770
|
+
deviceId,
|
|
10771
|
+
canWriteScope: (scopeId) => writableScopeIds.has(scopeId ?? "")
|
|
10772
|
+
});
|
|
9792
10773
|
const after = policyWakeMappings(store);
|
|
9793
10774
|
const changedScopeIds = /* @__PURE__ */ new Set([...previousMappings.map((mapping) => mapping.scope_id), ...mappings.map((mapping) => mapping.scope_id)]);
|
|
9794
10775
|
wakeRecipientPolicyProjects(store, [...mappedProjectsInScopes(before, changedScopeIds), ...mappedProjectsInScopes(after, changedScopeIds)], (/* @__PURE__ */ new Date()).toISOString());
|
|
@@ -9796,12 +10777,14 @@ function saveProjectMappingsAndWakePolicies(store, deviceId, mappingInputs) {
|
|
|
9796
10777
|
}).immediate();
|
|
9797
10778
|
}
|
|
9798
10779
|
function deleteProjectMappingAndWakePolicies(store, mappingId, deviceId, confirmedGuardrailTokens) {
|
|
10780
|
+
store.adoptEnsuredDeviceIdentity(deviceId);
|
|
9799
10781
|
return store.db.transaction(() => {
|
|
9800
10782
|
const before = policyWakeMappings(store);
|
|
9801
10783
|
const removed = before.find((mapping) => mapping.id === mappingId);
|
|
9802
10784
|
const deleted = deleteProjectScopeSettingsMapping(store.db, mappingId, {
|
|
9803
10785
|
deviceId,
|
|
9804
|
-
confirmedGuardrailTokens
|
|
10786
|
+
confirmedGuardrailTokens,
|
|
10787
|
+
canWriteScope: (scopeId) => store.isScopeWritable(scopeId)
|
|
9805
10788
|
});
|
|
9806
10789
|
if (deleted && removed) {
|
|
9807
10790
|
const changedScopeIds = /* @__PURE__ */ new Set([removed.scope_id]);
|
|
@@ -9817,8 +10800,125 @@ function deleteProjectMappingAndWakePolicies(store, mappingId, deviceId, confirm
|
|
|
9817
10800
|
* provide sync status, peer management, and coordinator UI for the
|
|
9818
10801
|
* local viewer.
|
|
9819
10802
|
*/
|
|
10803
|
+
function reassignProjectInventoryForUser(store, input) {
|
|
10804
|
+
store.adoptEnsuredDeviceIdentity(input.deviceId);
|
|
10805
|
+
return reassignProjectScopeInventoryProject(store.db, {
|
|
10806
|
+
...input,
|
|
10807
|
+
canWriteScope: (scopeId) => store.isScopeWritable(scopeId)
|
|
10808
|
+
});
|
|
10809
|
+
}
|
|
10810
|
+
function pendingCaptureActorMutationFailure(store) {
|
|
10811
|
+
try {
|
|
10812
|
+
store.assertPendingLocalCaptureAdoptionActors();
|
|
10813
|
+
return null;
|
|
10814
|
+
} catch (error) {
|
|
10815
|
+
if (isPendingCaptureActorConflict(error)) return {
|
|
10816
|
+
error: error.message,
|
|
10817
|
+
status: 409
|
|
10818
|
+
};
|
|
10819
|
+
throw error;
|
|
10820
|
+
}
|
|
10821
|
+
}
|
|
10822
|
+
function runViewerActorMutation(store, mutate) {
|
|
10823
|
+
return store.db.transaction(() => pendingCaptureActorMutationFailure(store) ?? mutate()).immediate();
|
|
10824
|
+
}
|
|
10825
|
+
function actorMergeDigest(primaryActorId, secondaryActorId) {
|
|
10826
|
+
return createHash("sha256").update(JSON.stringify({
|
|
10827
|
+
kind: "identity-merge-v1",
|
|
10828
|
+
primaryActorId,
|
|
10829
|
+
secondaryActorId,
|
|
10830
|
+
nonce: randomUUID()
|
|
10831
|
+
})).digest("hex");
|
|
10832
|
+
}
|
|
10833
|
+
function actorMergePrincipalFailure(store, primary, secondary) {
|
|
10834
|
+
if (primary.status !== "active") return {
|
|
10835
|
+
error: "primary actor not active",
|
|
10836
|
+
status: 409
|
|
10837
|
+
};
|
|
10838
|
+
if (secondary.status !== "active") return {
|
|
10839
|
+
error: "secondary actor not active",
|
|
10840
|
+
status: 409
|
|
10841
|
+
};
|
|
10842
|
+
if (secondary.is_local && secondary.actor_id === store.actorId) return {
|
|
10843
|
+
error: "cannot merge this device's own local actor",
|
|
10844
|
+
status: 409
|
|
10845
|
+
};
|
|
10846
|
+
return null;
|
|
10847
|
+
}
|
|
10848
|
+
function resolvePeerIdentityAssignment(store, opts) {
|
|
10849
|
+
const requestedActorId = opts.body.actor_id == null ? void 0 : String(opts.body.actor_id ?? "").trim() || null;
|
|
10850
|
+
const claimedLocalActor = typeof opts.body.claimed_local_actor === "boolean" ? opts.body.claimed_local_actor : void 0;
|
|
10851
|
+
let nextActorId = opts.actorId;
|
|
10852
|
+
let nextClaimedLocalActor = opts.claimedLocalActor;
|
|
10853
|
+
if (requestedActorId !== void 0) {
|
|
10854
|
+
nextActorId = requestedActorId;
|
|
10855
|
+
if (requestedActorId) {
|
|
10856
|
+
const actor = drizzle(store.db, { schema }).select({
|
|
10857
|
+
actor_id: schema.actors.actor_id,
|
|
10858
|
+
is_local: schema.actors.is_local,
|
|
10859
|
+
status: schema.actors.status
|
|
10860
|
+
}).from(schema.actors).where(eq(schema.actors.actor_id, requestedActorId)).get();
|
|
10861
|
+
if (!actor) return {
|
|
10862
|
+
error: "actor not found",
|
|
10863
|
+
status: 404
|
|
10864
|
+
};
|
|
10865
|
+
if (actor.status !== "active") return {
|
|
10866
|
+
error: "actor not active",
|
|
10867
|
+
status: 409
|
|
10868
|
+
};
|
|
10869
|
+
nextClaimedLocalActor = claimedLocalActor ?? Boolean(actor.is_local);
|
|
10870
|
+
} else nextClaimedLocalActor = claimedLocalActor ?? false;
|
|
10871
|
+
} else if (claimedLocalActor !== void 0) {
|
|
10872
|
+
nextClaimedLocalActor = claimedLocalActor;
|
|
10873
|
+
if (claimedLocalActor) nextActorId = store.actorId;
|
|
10874
|
+
else if (nextActorId === store.actorId) nextActorId = null;
|
|
10875
|
+
}
|
|
10876
|
+
return {
|
|
10877
|
+
actor_id: nextActorId,
|
|
10878
|
+
claimed_local_actor: nextClaimedLocalActor
|
|
10879
|
+
};
|
|
10880
|
+
}
|
|
10881
|
+
function persistPeerIdentityMutation(store, body) {
|
|
10882
|
+
const peerDeviceId = String(body.peer_device_id ?? "").trim();
|
|
10883
|
+
if (!peerDeviceId) return {
|
|
10884
|
+
error: "peer_device_id required",
|
|
10885
|
+
status: 400
|
|
10886
|
+
};
|
|
10887
|
+
const d = drizzle(store.db, { schema });
|
|
10888
|
+
const peer = d.select({
|
|
10889
|
+
peer_device_id: schema.syncPeers.peer_device_id,
|
|
10890
|
+
actor_id: schema.syncPeers.actor_id,
|
|
10891
|
+
claimed_local_actor: schema.syncPeers.claimed_local_actor
|
|
10892
|
+
}).from(schema.syncPeers).where(eq(schema.syncPeers.peer_device_id, peerDeviceId)).get();
|
|
10893
|
+
if (!peer) return {
|
|
10894
|
+
error: "peer not found",
|
|
10895
|
+
status: 404
|
|
10896
|
+
};
|
|
10897
|
+
const assignment = resolvePeerIdentityAssignment(store, {
|
|
10898
|
+
body,
|
|
10899
|
+
actorId: peer.actor_id ?? null,
|
|
10900
|
+
claimedLocalActor: Boolean(peer.claimed_local_actor)
|
|
10901
|
+
});
|
|
10902
|
+
if ("error" in assignment) return assignment;
|
|
10903
|
+
d.update(schema.syncPeers).set({
|
|
10904
|
+
actor_id: assignment.actor_id,
|
|
10905
|
+
claimed_local_actor: assignment.claimed_local_actor ? 1 : 0
|
|
10906
|
+
}).where(eq(schema.syncPeers.peer_device_id, peerDeviceId)).run();
|
|
10907
|
+
return assignment;
|
|
10908
|
+
}
|
|
10909
|
+
function createActorMutationPreflightRoutes(getStore) {
|
|
10910
|
+
const app = new Hono();
|
|
10911
|
+
const preflight = async (c, next) => {
|
|
10912
|
+
if (!ensureLocalActorRecord(getStore())) return c.json({ error: "device_adoption_actor_conflict" }, 409);
|
|
10913
|
+
await next();
|
|
10914
|
+
};
|
|
10915
|
+
app.post("/api/sync/peers/identity", preflight);
|
|
10916
|
+
app.post("/api/sync/actors/merge", preflight);
|
|
10917
|
+
return app;
|
|
10918
|
+
}
|
|
9820
10919
|
function syncRoutes(getStore, getSyncRuntimeStatus, options = {}) {
|
|
9821
|
-
const app =
|
|
10920
|
+
const app = createActorMutationPreflightRoutes(getStore);
|
|
10921
|
+
app.post("/api/sync/invites/inspect", inspectPairingInvite);
|
|
9822
10922
|
const readCoordinatorConfig = options.readCoordinatorConfig ?? readCoordinatorSyncConfig;
|
|
9823
10923
|
const renameCoordinatorGroup = options.renameCoordinatorGroup ?? coordinatorRenameGroupAction;
|
|
9824
10924
|
const loadCoordinatorEvidence = options.loadDeviceIdentityCoordinatorEvidence ?? loadConfiguredDeviceIdentityCoordinatorEvidence;
|
|
@@ -9852,7 +10952,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus, options = {}) {
|
|
|
9852
10952
|
};
|
|
9853
10953
|
app.get("/api/sync/recipient-policy/v1/device-inventory", async (c) => {
|
|
9854
10954
|
const store = getStore();
|
|
9855
|
-
|
|
10955
|
+
ensureStableStoreIdentityForRead(store);
|
|
9856
10956
|
return c.json(listDeviceIdentityInventory(store.db, await localInventoryInput(store)));
|
|
9857
10957
|
});
|
|
9858
10958
|
app.patch("/api/sync/recipient-policy/v1/teams/:teamId", async (c) => {
|
|
@@ -9944,7 +11044,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus, options = {}) {
|
|
|
9944
11044
|
if (!body) return c.json({ error: "binding_request_invalid" }, 400);
|
|
9945
11045
|
const store = getStore();
|
|
9946
11046
|
try {
|
|
9947
|
-
|
|
11047
|
+
ensureStableStoreIdentityForRead(store);
|
|
9948
11048
|
const result = previewDeviceIdentityBindings(store.db, await localInventoryInput(store), body);
|
|
9949
11049
|
if (result.status === "invalid") return c.json(result, 400);
|
|
9950
11050
|
if (result.status === "not_found") return c.json(result, 404);
|
|
@@ -9985,6 +11085,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus, options = {}) {
|
|
|
9985
11085
|
}
|
|
9986
11086
|
});
|
|
9987
11087
|
app.get("/api/sync/status", async (c) => {
|
|
11088
|
+
const loadDiagnosticKey = createPeerScopeDiagnosticKeyLoader();
|
|
9988
11089
|
const response = await buildSyncStatusResponse({
|
|
9989
11090
|
store: getStore(),
|
|
9990
11091
|
showDiagnostics: queryBool(c.req.query("includeDiagnostics")),
|
|
@@ -9996,7 +11097,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus, options = {}) {
|
|
|
9996
11097
|
isRecentIso,
|
|
9997
11098
|
legacySharedReviewSummary,
|
|
9998
11099
|
listRecipientPolicyReconciliationStatus,
|
|
9999
|
-
mapPeerRow,
|
|
11100
|
+
mapPeerRow: (...args) => mapPeerRow(...args, loadDiagnosticKey),
|
|
10000
11101
|
mapSyncAttemptRow,
|
|
10001
11102
|
peerStatus,
|
|
10002
11103
|
readViewerBinding,
|
|
@@ -10013,11 +11114,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus, options = {}) {
|
|
|
10013
11114
|
const store = getStore();
|
|
10014
11115
|
{
|
|
10015
11116
|
const showDiag = queryBool(c.req.query("includeDiagnostics"));
|
|
10016
|
-
const
|
|
10017
|
-
const recentOpsByPeer = recentPeerOps(store);
|
|
10018
|
-
const scopeRejectionsByPeer = recentScopeRejectionsByPeer(store);
|
|
10019
|
-
const localDeviceId = drizzle(store.db, { schema }).select({ device_id: schema.syncDevice.device_id }).from(schema.syncDevice).limit(1).get()?.device_id ?? null;
|
|
10020
|
-
const peers = rows.map((row) => mapPeerRow(store, row, showDiag, recentOpsByPeer, scopeRejectionsByPeer, localDeviceId));
|
|
11117
|
+
const peers = readPeerScopeDiagnostics(store, showDiag);
|
|
10021
11118
|
return c.json({
|
|
10022
11119
|
items: peers,
|
|
10023
11120
|
redacted: !showDiag
|
|
@@ -10407,7 +11504,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus, options = {}) {
|
|
|
10407
11504
|
const body = await parseViewerJsonBody(c);
|
|
10408
11505
|
const request = parseReviewRequest(body);
|
|
10409
11506
|
if (!request) return c.json({ error: "request_invalid" }, 400);
|
|
10410
|
-
|
|
11507
|
+
requireLocalActorRecord(store);
|
|
10411
11508
|
const result = resolveRecipientPolicyReview(store.db, {
|
|
10412
11509
|
localActorId: store.actorId,
|
|
10413
11510
|
localDeviceId: store.deviceId
|
|
@@ -10423,7 +11520,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus, options = {}) {
|
|
|
10423
11520
|
if (!body || !Array.isArray(body.requests) || body.requests.length === 0 || body.requests.length > 100) return c.json({ error: "request_invalid" }, 400);
|
|
10424
11521
|
const requests = body.requests.map(parseReviewRequest);
|
|
10425
11522
|
if (requests.some((request) => request == null)) return c.json({ error: "request_invalid" }, 400);
|
|
10426
|
-
|
|
11523
|
+
requireLocalActorRecord(store);
|
|
10427
11524
|
const result = resolveRecipientPolicyReviewBulk(store.db, {
|
|
10428
11525
|
localActorId: store.actorId,
|
|
10429
11526
|
localDeviceId: store.deviceId
|
|
@@ -10453,7 +11550,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus, options = {}) {
|
|
|
10453
11550
|
const workspaceIdentity = optionalViewerStrictString(body, "workspace_identity") ?? "";
|
|
10454
11551
|
const project = optionalViewerStrictString(body, "project") ?? "";
|
|
10455
11552
|
const [deviceId] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
|
|
10456
|
-
const result =
|
|
11553
|
+
const result = reassignProjectInventoryForUser(store, {
|
|
10457
11554
|
deviceId,
|
|
10458
11555
|
project,
|
|
10459
11556
|
workspaceIdentity
|
|
@@ -10758,51 +11855,17 @@ function syncRoutes(getStore, getSyncRuntimeStatus, options = {}) {
|
|
|
10758
11855
|
app.delete("/api/sync/sharing-domains/project-mappings/:id", (c) => deleteProjectScopeMappingRoute(c, getStore()));
|
|
10759
11856
|
app.post("/api/sync/peers/identity", async (c) => {
|
|
10760
11857
|
const store = getStore();
|
|
10761
|
-
ensureLocalActorRecord(store);
|
|
10762
11858
|
let body;
|
|
10763
11859
|
try {
|
|
10764
11860
|
body = await c.req.json();
|
|
10765
11861
|
} catch {
|
|
10766
11862
|
return c.json({ error: "invalid json" }, 400);
|
|
10767
11863
|
}
|
|
10768
|
-
const
|
|
10769
|
-
if (
|
|
10770
|
-
const requestedActorId = body.actor_id == null ? void 0 : String(body.actor_id ?? "").trim() || null;
|
|
10771
|
-
const claimedLocalActor = typeof body.claimed_local_actor === "boolean" ? body.claimed_local_actor : void 0;
|
|
10772
|
-
const d = drizzle(store.db, { schema });
|
|
10773
|
-
const peer = d.select({
|
|
10774
|
-
peer_device_id: schema.syncPeers.peer_device_id,
|
|
10775
|
-
actor_id: schema.syncPeers.actor_id,
|
|
10776
|
-
claimed_local_actor: schema.syncPeers.claimed_local_actor
|
|
10777
|
-
}).from(schema.syncPeers).where(eq(schema.syncPeers.peer_device_id, peerDeviceId)).get();
|
|
10778
|
-
if (!peer) return c.json({ error: "peer not found" }, 404);
|
|
10779
|
-
let nextActorId = peer.actor_id ?? null;
|
|
10780
|
-
let nextClaimedLocalActor = Boolean(peer.claimed_local_actor);
|
|
10781
|
-
if (requestedActorId !== void 0) {
|
|
10782
|
-
nextActorId = requestedActorId;
|
|
10783
|
-
if (requestedActorId) {
|
|
10784
|
-
const actor = d.select({
|
|
10785
|
-
actor_id: schema.actors.actor_id,
|
|
10786
|
-
is_local: schema.actors.is_local,
|
|
10787
|
-
status: schema.actors.status
|
|
10788
|
-
}).from(schema.actors).where(eq(schema.actors.actor_id, requestedActorId)).get();
|
|
10789
|
-
if (!actor) return c.json({ error: "actor not found" }, 404);
|
|
10790
|
-
if (actor.status !== "active") return c.json({ error: "actor not active" }, 409);
|
|
10791
|
-
nextClaimedLocalActor = claimedLocalActor ?? Boolean(actor.is_local);
|
|
10792
|
-
} else nextClaimedLocalActor = claimedLocalActor ?? false;
|
|
10793
|
-
} else if (claimedLocalActor !== void 0) {
|
|
10794
|
-
nextClaimedLocalActor = claimedLocalActor;
|
|
10795
|
-
if (claimedLocalActor) nextActorId = store.actorId;
|
|
10796
|
-
else if (nextActorId === store.actorId) nextActorId = null;
|
|
10797
|
-
}
|
|
10798
|
-
d.update(schema.syncPeers).set({
|
|
10799
|
-
actor_id: nextActorId,
|
|
10800
|
-
claimed_local_actor: nextClaimedLocalActor ? 1 : 0
|
|
10801
|
-
}).where(eq(schema.syncPeers.peer_device_id, peerDeviceId)).run();
|
|
11864
|
+
const result = runViewerActorMutation(store, () => persistPeerIdentityMutation(store, body));
|
|
11865
|
+
if ("error" in result) return c.json({ error: result.error }, result.status);
|
|
10802
11866
|
return c.json({
|
|
10803
11867
|
ok: true,
|
|
10804
|
-
|
|
10805
|
-
claimed_local_actor: nextClaimedLocalActor
|
|
11868
|
+
...result
|
|
10806
11869
|
});
|
|
10807
11870
|
});
|
|
10808
11871
|
app.post("/api/sync/actors", async (c) => {
|
|
@@ -10869,7 +11932,6 @@ function syncRoutes(getStore, getSyncRuntimeStatus, options = {}) {
|
|
|
10869
11932
|
});
|
|
10870
11933
|
app.post("/api/sync/actors/merge", async (c) => {
|
|
10871
11934
|
const store = getStore();
|
|
10872
|
-
ensureLocalActorRecord(store);
|
|
10873
11935
|
let body;
|
|
10874
11936
|
try {
|
|
10875
11937
|
body = await c.req.json();
|
|
@@ -10886,24 +11948,16 @@ function syncRoutes(getStore, getSyncRuntimeStatus, options = {}) {
|
|
|
10886
11948
|
queueMicrotask(releaseActorMutations);
|
|
10887
11949
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
10888
11950
|
const result = store.db.transaction(() => {
|
|
11951
|
+
const conflict = pendingCaptureActorMutationFailure(store);
|
|
11952
|
+
if (conflict) return conflict;
|
|
10889
11953
|
const primary = d.select().from(schema.actors).where(eq(schema.actors.actor_id, primaryActorId)).get();
|
|
10890
11954
|
const secondary = d.select().from(schema.actors).where(eq(schema.actors.actor_id, secondaryActorId)).get();
|
|
10891
11955
|
if (!primary || !secondary) return {
|
|
10892
11956
|
error: "actor not found",
|
|
10893
11957
|
status: 404
|
|
10894
11958
|
};
|
|
10895
|
-
|
|
10896
|
-
|
|
10897
|
-
status: 409
|
|
10898
|
-
};
|
|
10899
|
-
if (secondary.status !== "active") return {
|
|
10900
|
-
error: "secondary actor not active",
|
|
10901
|
-
status: 409
|
|
10902
|
-
};
|
|
10903
|
-
if (secondary.is_local && secondary.actor_id === store.actorId) return {
|
|
10904
|
-
error: "cannot merge this device's own local actor",
|
|
10905
|
-
status: 409
|
|
10906
|
-
};
|
|
11959
|
+
const principalFailure = actorMergePrincipalFailure(store, primary, secondary);
|
|
11960
|
+
if (principalFailure) return principalFailure;
|
|
10907
11961
|
const memberships = d.select().from(schema.policyTeamMemberships).where(eq(schema.policyTeamMemberships.identity_id, secondaryActorId)).all();
|
|
10908
11962
|
const primaryMemberships = /* @__PURE__ */ new Map();
|
|
10909
11963
|
for (const membership of memberships) {
|
|
@@ -10924,12 +11978,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus, options = {}) {
|
|
|
10924
11978
|
};
|
|
10925
11979
|
primaryRecipients.set(recipient.canonical_project_identity, primaryRecipient);
|
|
10926
11980
|
}
|
|
10927
|
-
const mergeDigest =
|
|
10928
|
-
kind: "identity-merge-v1",
|
|
10929
|
-
primaryActorId,
|
|
10930
|
-
secondaryActorId,
|
|
10931
|
-
nonce: randomUUID()
|
|
10932
|
-
})).digest("hex");
|
|
11981
|
+
const mergeDigest = actorMergeDigest(primaryActorId, secondaryActorId);
|
|
10933
11982
|
const bindings = d.select({
|
|
10934
11983
|
deviceId: schema.identityDevices.device_id,
|
|
10935
11984
|
assignmentVersion: schema.identityDevices.assignment_version
|
|
@@ -11146,7 +12195,8 @@ function syncRoutes(getStore, getSyncRuntimeStatus, options = {}) {
|
|
|
11146
12195
|
if (!payload.operation_id && !recipientInvite) return c.json({ kind: "legacy_team_invite" });
|
|
11147
12196
|
const [status, inspected] = await requestJson("POST", `${buildBaseUrl(payload.coordinator_url)}/v1/invites/inspect`, {
|
|
11148
12197
|
body: { token: payload.token },
|
|
11149
|
-
timeoutS: 10
|
|
12198
|
+
timeoutS: 10,
|
|
12199
|
+
redirect: "error"
|
|
11150
12200
|
});
|
|
11151
12201
|
if (status < 200 || status >= 300) return c.json({ error: recipientInvite ? safeRecipientInviteError(inspected?.error) : String(inspected?.error ?? "invite_invalid") }, status === 410 ? 410 : 400);
|
|
11152
12202
|
const config = readCodememConfigFile();
|
|
@@ -12541,6 +13591,122 @@ function updateStatusRoutes(options = {}) {
|
|
|
12541
13591
|
return app;
|
|
12542
13592
|
}
|
|
12543
13593
|
//#endregion
|
|
13594
|
+
//#region src/account/context.ts
|
|
13595
|
+
function validId(value) {
|
|
13596
|
+
return typeof value === "string" && value.length > 0 && value.length <= 256 && value.trim() === value && !/[\p{Cc}\p{Cf}\p{Cs}]/u.test(value);
|
|
13597
|
+
}
|
|
13598
|
+
function label(value) {
|
|
13599
|
+
return validId(value) ? value : void 0;
|
|
13600
|
+
}
|
|
13601
|
+
function clean(value) {
|
|
13602
|
+
return typeof value === "string" ? value.trim() : "";
|
|
13603
|
+
}
|
|
13604
|
+
function configuredGroups(raw) {
|
|
13605
|
+
const value = process.env.CODEMEM_SYNC_COORDINATOR_GROUPS ?? raw.sync_coordinator_groups;
|
|
13606
|
+
if (value === void 0 || value === null || value === "") return [];
|
|
13607
|
+
const values = typeof value === "string" ? value.split(",") : value;
|
|
13608
|
+
if (!Array.isArray(values) || values.some((item) => !validId(clean(item)))) return null;
|
|
13609
|
+
return values.map(clean);
|
|
13610
|
+
}
|
|
13611
|
+
function readyContext(options, groupId, coordinatorUrl, raw, groups) {
|
|
13612
|
+
const profile = options.getProfile();
|
|
13613
|
+
if (!profile) return "device_unavailable";
|
|
13614
|
+
const before = statSync(profile.dbPath, { bigint: true });
|
|
13615
|
+
const device = readAccountLinkDevice(profile.dbPath);
|
|
13616
|
+
const after = statSync(profile.dbPath, { bigint: true });
|
|
13617
|
+
const override = process.env.CODEMEM_DEVICE_ID?.trim() || void 0;
|
|
13618
|
+
if (!before.isFile() || before.dev !== after.dev || before.ino !== after.ino || device.deviceId !== profile.deviceId || override !== void 0 && override !== device.deviceId) return "device_unavailable";
|
|
13619
|
+
let actor = clean(raw.actor_id);
|
|
13620
|
+
if (Object.hasOwn(process.env, "CODEMEM_ACTOR_ID")) actor = clean(process.env.CODEMEM_ACTOR_ID);
|
|
13621
|
+
actor ||= `local:${device.deviceId}`;
|
|
13622
|
+
if (!validId(actor) || actor !== profile.identityId) return "identity_unavailable";
|
|
13623
|
+
const context = {
|
|
13624
|
+
dbPath: profile.dbPath,
|
|
13625
|
+
keysDir: options.keysDir,
|
|
13626
|
+
configPath: options.configPath,
|
|
13627
|
+
coordinatorUrl,
|
|
13628
|
+
groupId,
|
|
13629
|
+
deviceId: device.deviceId,
|
|
13630
|
+
fingerprint: device.fingerprint,
|
|
13631
|
+
identityId: actor,
|
|
13632
|
+
identityLabel: label(profile.identityLabel),
|
|
13633
|
+
deviceLabel: label(profile.deviceLabel),
|
|
13634
|
+
fileIdentity: {
|
|
13635
|
+
dev: after.dev.toString(),
|
|
13636
|
+
ino: after.ino.toString()
|
|
13637
|
+
}
|
|
13638
|
+
};
|
|
13639
|
+
const contextId = createHash("sha256").update(JSON.stringify({
|
|
13640
|
+
context,
|
|
13641
|
+
groups,
|
|
13642
|
+
actorConfig: clean(raw.actor_id),
|
|
13643
|
+
actorEnv: process.env.CODEMEM_ACTOR_ID,
|
|
13644
|
+
deviceEnv: override,
|
|
13645
|
+
keyStore: process.env.CODEMEM_SYNC_KEY_STORE,
|
|
13646
|
+
home: process.env.HOME
|
|
13647
|
+
})).digest("hex").slice(0, 32);
|
|
13648
|
+
return {
|
|
13649
|
+
...context,
|
|
13650
|
+
contextId
|
|
13651
|
+
};
|
|
13652
|
+
}
|
|
13653
|
+
function readContext(options, groupId) {
|
|
13654
|
+
const raw = options.readConfig();
|
|
13655
|
+
const config = readCoordinatorSyncConfig(raw);
|
|
13656
|
+
if (!configuredGroups(raw)) return { error: "account_context_unavailable" };
|
|
13657
|
+
const groups = [...new Set(config.syncCoordinatorGroups)];
|
|
13658
|
+
if (groups.length > 25 || groups.some((id) => !validId(id))) return { error: "account_context_unavailable" };
|
|
13659
|
+
if (groupId !== void 0 && !groups.includes(groupId)) return { error: "account_group_not_configured" };
|
|
13660
|
+
const selectedGroupId = groupId ?? (groups.length === 1 ? groups[0] : null) ?? null;
|
|
13661
|
+
const origin = normalizeCoordinatorAccountLinkOrigin(config.syncCoordinatorUrl);
|
|
13662
|
+
const base = {
|
|
13663
|
+
groups,
|
|
13664
|
+
selectedGroupId,
|
|
13665
|
+
coordinator: origin ? { origin } : null,
|
|
13666
|
+
context: null
|
|
13667
|
+
};
|
|
13668
|
+
if (!config.syncCoordinatorUrl || groups.length === 0) return {
|
|
13669
|
+
...base,
|
|
13670
|
+
setup: "coordinator_not_configured"
|
|
13671
|
+
};
|
|
13672
|
+
if (!origin) return {
|
|
13673
|
+
...base,
|
|
13674
|
+
setup: "coordinator_url_invalid"
|
|
13675
|
+
};
|
|
13676
|
+
if (!selectedGroupId) return {
|
|
13677
|
+
...base,
|
|
13678
|
+
setup: "group_selection_required"
|
|
13679
|
+
};
|
|
13680
|
+
let context;
|
|
13681
|
+
try {
|
|
13682
|
+
context = readyContext(options, selectedGroupId, origin, raw, groups);
|
|
13683
|
+
} catch {
|
|
13684
|
+
return {
|
|
13685
|
+
...base,
|
|
13686
|
+
setup: "device_unavailable"
|
|
13687
|
+
};
|
|
13688
|
+
}
|
|
13689
|
+
if (typeof context === "string") return {
|
|
13690
|
+
...base,
|
|
13691
|
+
setup: context
|
|
13692
|
+
};
|
|
13693
|
+
return {
|
|
13694
|
+
...base,
|
|
13695
|
+
setup: "ready",
|
|
13696
|
+
context
|
|
13697
|
+
};
|
|
13698
|
+
}
|
|
13699
|
+
/** Trusted callbacks describe the running profile, never a browser-selected path. */
|
|
13700
|
+
function createAccountContextReader(options) {
|
|
13701
|
+
return (groupId) => {
|
|
13702
|
+
try {
|
|
13703
|
+
return readContext(options, groupId);
|
|
13704
|
+
} catch {
|
|
13705
|
+
return { error: "account_context_unavailable" };
|
|
13706
|
+
}
|
|
13707
|
+
};
|
|
13708
|
+
}
|
|
13709
|
+
//#endregion
|
|
12544
13710
|
//#region src/index.ts
|
|
12545
13711
|
/**
|
|
12546
13712
|
* @codemem/server — HTTP server (viewer, sync, API).
|
|
@@ -12590,6 +13756,21 @@ function registerConfigRoutes(app, opts, sweeper) {
|
|
|
12590
13756
|
getObserverApplyStatus: opts?.getObserverApplyStatus
|
|
12591
13757
|
}));
|
|
12592
13758
|
}
|
|
13759
|
+
function registerAccountRoutes(app, options) {
|
|
13760
|
+
if (!options) return;
|
|
13761
|
+
const account = createAccountGuard(options);
|
|
13762
|
+
app.use("*", account.middleware);
|
|
13763
|
+
app.route("/", account.routes);
|
|
13764
|
+
if (options.readContext) app.route("/", accountStatusRoutes({
|
|
13765
|
+
...options,
|
|
13766
|
+
readContext: options.readContext
|
|
13767
|
+
}));
|
|
13768
|
+
if (options.readContext && options.registry) app.route("/", accountOperationRoutes({
|
|
13769
|
+
...options,
|
|
13770
|
+
readContext: options.readContext,
|
|
13771
|
+
registry: options.registry
|
|
13772
|
+
}));
|
|
13773
|
+
}
|
|
12593
13774
|
function createApp(opts) {
|
|
12594
13775
|
const storeFactory = opts?.storeFactory ?? getStore;
|
|
12595
13776
|
const sweeper = opts?.sweeper ?? null;
|
|
@@ -12597,6 +13778,7 @@ function createApp(opts) {
|
|
|
12597
13778
|
const getSyncRuntimeStatus = opts?.getSyncRuntimeStatus ?? (() => null);
|
|
12598
13779
|
let invalidateTeamSetupSummary = () => {};
|
|
12599
13780
|
const app = new Hono();
|
|
13781
|
+
registerAccountRoutes(app, opts?.accountLink);
|
|
12600
13782
|
app.use("*", preflightHandler());
|
|
12601
13783
|
app.use("*", originGuard({ unsafeGetPathPrefixes: [TEAM_SETUP_ROUTE_PREFIX] }));
|
|
12602
13784
|
const viewerTargetGuard = viewerTargetQueryGuard(storeFactory);
|
|
@@ -12608,6 +13790,7 @@ function createApp(opts) {
|
|
|
12608
13790
|
app.route("/", memoryRoutes(storeFactory));
|
|
12609
13791
|
app.route("/", memoryToolRoutes(storeFactory));
|
|
12610
13792
|
app.route("/", packTransportRoutes(storeFactory));
|
|
13793
|
+
app.route("/", fileContextRoutes(storeFactory));
|
|
12611
13794
|
app.route("/", observerStatusRoutes({
|
|
12612
13795
|
getStore: storeFactory,
|
|
12613
13796
|
getSweeper: () => sweeper,
|
|
@@ -12662,6 +13845,6 @@ function createSyncApp(opts) {
|
|
|
12662
13845
|
return app;
|
|
12663
13846
|
}
|
|
12664
13847
|
//#endregion
|
|
12665
|
-
export { FileRawEventInbox, VERSION, advancePendingProjectShares, advancePendingProjectSharesOperation, advanceProjectShareOperation, closeStore, createApp, createRecipientPolicyReconcilerEffects, createSyncApp, createViewerRawEventInbox, getStore, listRecipientPolicyReconciliationStatus, recipientPolicyCapabilityFromStatus, reconcileConfiguredCoordinatorEnrollment, reconcileRecipientPolicyProjects, reconcileRecipientPolicyProjectsOperation, resolveRawEventInboxDirectory };
|
|
13848
|
+
export { FileRawEventInbox, VERSION, advancePendingProjectShares, advancePendingProjectSharesOperation, advanceProjectShareOperation, closeStore, createAccountContextReader, createAccountLinkRegistry, createApp, createRecipientPolicyReconcilerEffects, createSyncApp, createViewerRawEventInbox, getStore, listRecipientPolicyReconciliationStatus, recipientPolicyCapabilityFromStatus, reconcileConfiguredCoordinatorEnrollment, reconcileRecipientPolicyProjects, reconcileRecipientPolicyProjectsOperation, resolveRawEventInboxDirectory };
|
|
12666
13849
|
|
|
12667
13850
|
//# sourceMappingURL=index.js.map
|