@codemem/server 0.39.1 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1856 -40
- package/dist/index.js.map +1 -1
- package/dist/routes/config.d.ts.map +1 -1
- package/dist/routes/sync.d.ts +91 -1
- package/dist/routes/sync.d.ts.map +1 -1
- package/package.json +2 -2
- package/static/app.js +67 -67
- package/static/app.js.br +0 -0
- package/static/app.js.gz +0 -0
- package/static/index.html +286 -21
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
|
-
import { ACCESS_CLEANUP_OP_TYPE, CODEMEM_CONFIG_ENV_OVERRIDES, DEFAULT_TIME_WINDOW_S, LEGACY_SHARED_REVIEW_SCOPE_ID, LOCAL_DEFAULT_SCOPE_ID, LOCAL_SYNC_CAPABILITY, MemoryStore, SYNC_CAPABILITY_HEADER, SYNC_SCOPE_QUERY_PARAM, VERSION, VERSION as VERSION$1, addSyncScopeToBoundary, analyzeProjectScopeMappingChangeGuardrails, applyReplicationOps, buildBaseUrl, buildFilterClausesWithContext, buildRawEventEnvelopeFromCodexHook, buildRawEventEnvelopeFromHook, canonicalMemoryKind, canonicalWorkspaceIdentity, cleanupNonces, coordinatorArchiveGroupAction, coordinatorCreateGroupAction, coordinatorCreateInviteAction, coordinatorCreateScopeAction, coordinatorDisableDeviceAction, coordinatorEnableDeviceAction, coordinatorGrantScopeMembershipAction, coordinatorImportInviteAction, coordinatorListBootstrapGrantsAction, coordinatorListDevicesAction, coordinatorListGroupsAction, coordinatorListJoinRequestsAction, coordinatorListScopeMembershipsAction, coordinatorListScopesAction, coordinatorRemoveDeviceAction, coordinatorRenameDeviceAction, coordinatorRenameGroupAction, coordinatorReviewJoinRequestAction, coordinatorRevokeBootstrapGrantAction, coordinatorRevokeScopeMembershipAction, coordinatorStatusSnapshot, coordinatorUnarchiveGroupAction, coordinatorUpdateScopeAction, createCoordinatorReciprocalApproval, defaultSpaceScopeIdForGroup, deleteProjectScopeSettingsMapping, diagnoseStalePeerReceivedRows, ensureDeviceIdentity, extractReplicationOps, filterReplicationOpsForSyncWithStatus, fingerprintPublicKey, formatHostPort, fromJson, getCodememConfigPath, getCodememEnvOverrides, getCoordinatorGroupPreference, getSemanticIndexDiagnostics, getSyncResetState, isScopedSyncCapability, isSummaryLikeMemory, listAuthorizedScopesForPeer, listCoordinatorJoinRequests, listInboundScopeRejections, listMaintenanceJobs, listObserverProviderOptions, listPerPeerScopeSyncState, listProjectScopeCandidates, listProjectScopeInventory, listProjectScopeSettingsMappings, listSharingDomainSettingsScopes, loadMemorySnapshotPageForPeer, loadReplicationOpsForPeer, lookupCoordinatorPeers, mergeAddresses, negotiateSyncCapability, normalizeAddress, normalizeSyncCapability, parseStrictInteger, parseSyncScopeRequest, personalScopeGrantStatusForPeer, probeAvailableCredentials, readCodememConfigFile, readCoordinatorSyncConfig, reassignProjectScopeInventoryProject, recordNonce, rejectInboundScopeFailures, requestJson, resolveDbPath, runSyncPass, schema, stripPrivateObj, summarizeInboundScopeRejections, syncScopeResetRequiredPayload, updatePeerAddresses, upsertCoordinatorGroupPreference, upsertProjectScopeSettingsMapping, verifySignature, writeCodememConfigFile } from "@codemem/core";
|
|
3
|
+
import { ACCESS_CLEANUP_OP_TYPE, CODEMEM_CONFIG_ENV_OVERRIDES, DEFAULT_TIME_WINDOW_S, LEGACY_SHARED_REVIEW_SCOPE_ID, LOCAL_DEFAULT_SCOPE_ID, LOCAL_SYNC_CAPABILITY, LOCAL_SYNC_FEATURES, MemoryStore, RecipientPolicyEdgeRequestError, SCOPE_MEMBERSHIP_REVOCATION_LIMITATION, SYNC_AUTHORIZATION_REFRESH_HEADER, SYNC_CAPABILITY_HEADER, SYNC_FEATURES_HEADER, SYNC_SCOPE_QUERY_PARAM, VERSION, VERSION as VERSION$1, addSyncScopeToBoundary, analyzeProjectScopeMappingChangeGuardrails, applyReplicationOps, buildAuthHeaders, buildBaseUrl, buildFilterClausesWithContext, buildRawEventEnvelopeFromCodexHook, buildRawEventEnvelopeFromHook, canonicalMemoryKind, canonicalWorkspaceIdentity, cleanupNonces, coerceObserverCommand, commitRecipientPolicyEdges, coordinatorArchiveGroupAction, coordinatorCreateAddDeviceInviteAction, coordinatorCreateGroupAction, coordinatorCreateInviteAction, coordinatorCreateScopeAction, coordinatorDisableDeviceAction, coordinatorEnableDeviceAction, coordinatorGrantScopeMembershipAction, coordinatorImportInviteAction, coordinatorListBootstrapGrantsAction, coordinatorListConsumedTeamInvitesAction, coordinatorListDevicesAction, coordinatorListGroupsAction, coordinatorListJoinRequestsAction, coordinatorListReviewedRecipientInviteEvidenceAction, coordinatorListScopeMembershipsAction, coordinatorListScopesAction, coordinatorRemoveDeviceAction, coordinatorRenameDeviceAction, coordinatorRenameGroupAction, coordinatorReviewJoinRequestAction, coordinatorRevokeBootstrapGrantAction, coordinatorRevokeScopeMembershipAction, coordinatorStatusSnapshot, coordinatorUnarchiveGroupAction, coordinatorUpdateScopeAction, countShareableProjectMemories, createCoordinatorReciprocalApproval, decodeInvitePayload, defaultSpaceScopeIdForGroup, deleteProjectScopeSettingsMapping, diagnoseStalePeerReceivedRows, ensureDeviceIdentity, executeShareProvisioning, extractInvitePayload, extractReplicationOps, filterReplicationOpsForSyncWithStatus, fingerprintPublicKey, formatHostPort, friendlyDeviceName, fromJson, getCodememConfigPath, getCodememEnvOverrides, getCoordinatorEnrollmentReconciliationIssueSummary, getCoordinatorGroupPreference, getRecipientPolicyAuthorityState, getSemanticIndexDiagnostics, getSyncResetState, inviteTokenDigest, isPeerTrustBindingCompatible, isProjectSyncEnablementError, isScopedSyncCapability, isSummaryLikeMemory, listAuthorizedScopesForPeer, listCoordinatorJoinRequests, listInboundScopeRejections, listLegacyRecipientPolicyProjections, listMaintenanceJobs, listObserverProviderOptions, listPerPeerScopeSyncState, listProjectScopeCandidates, listProjectScopeInventory, listProjectScopeSettingsMappings, listRecipientPolicyIntent, listRecipientPolicyReview, listSharingDomainSettingsScopes, loadMemorySnapshotPageForPeer, loadReplicationOpsForPeer, lookupCoordinatorPeers, mergeAddresses, migrateRecipientPolicyIntent, negotiateSyncCapability, normalizeAddress, normalizeSyncCapability, normalizeSyncFeatures, normalizeTeammateName, parseAcceptedProjectIntent, parseReassignScopePayload, parseStrictInteger, parseSyncScopeRequest, persistShareOperation, personalScopeGrantStatusForPeer, planShareOperation, previewRecipientPolicyEdges, previewRecipientPolicyOnboarding, previewRecipientPolicyOnboardingFromReviewedIntent, probeAvailableCredentials, projectShareLifecycle, readCodememConfigFile, readCoordinatorSyncConfig, reassignProjectScopeInventoryProject, recipientInviteAuthoritativeIdentityId, recipientReviewedIntentDigest, reconcileCoordinatorEnrollmentSnapshot, reconcileRecipientPolicyProject, reconcileShareOperationAcceptance, recordNonce, refreshAuthorizedCoordinatorPeerTrust, refreshConfiguredScopeMembershipCache, rejectInboundScopeFailures, requestJson, resolveDbPath, resolveRecipientPolicyReview, resolveRecipientPolicyReviewBulk, runSyncPass, schema, stripPrivateObj, summarizeInboundScopeRejections, supportsSyncFeature, syncScopeResetRequiredPayload, updatePeerAddresses, upsertCoordinatorGroupPreference, upsertProjectScopeSettingsMapping, verifyRecipientReviewedIntent, verifySignature, writeCodememConfigFile } from "@codemem/core";
|
|
4
4
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
5
5
|
import { Hono } from "hono";
|
|
6
6
|
import { createMiddleware } from "hono/factory";
|
|
@@ -8,7 +8,7 @@ import { RateLimiter } from "limiter";
|
|
|
8
8
|
import { and, count, desc, eq, inArray, max, ne } from "drizzle-orm";
|
|
9
9
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
10
10
|
import { createHash, randomUUID } from "node:crypto";
|
|
11
|
-
import { networkInterfaces } from "node:os";
|
|
11
|
+
import { hostname, networkInterfaces } from "node:os";
|
|
12
12
|
//#region src/middleware.ts
|
|
13
13
|
var LOOPBACK_HOSTS = new Set([
|
|
14
14
|
"127.0.0.1",
|
|
@@ -164,7 +164,11 @@ function createInMemoryRequestRateLimiter(windowMs = 6e4) {
|
|
|
164
164
|
* codemem/viewer_routes/config.py, scoped to the TS runtime's current needs.
|
|
165
165
|
*/
|
|
166
166
|
var REDACTED_VALUE = "[redacted]";
|
|
167
|
-
var RUNTIMES = new Set([
|
|
167
|
+
var RUNTIMES = new Set([
|
|
168
|
+
"api_http",
|
|
169
|
+
"claude_sidecar",
|
|
170
|
+
"codex_sidecar"
|
|
171
|
+
]);
|
|
168
172
|
var AUTH_SOURCES = new Set([
|
|
169
173
|
"auto",
|
|
170
174
|
"env",
|
|
@@ -175,6 +179,7 @@ var AUTH_SOURCES = new Set([
|
|
|
175
179
|
var HOT_RELOAD_KEYS = new Set(["raw_events_sweeper_interval_s"]);
|
|
176
180
|
var PROTECTED_WRITE_KEYS = new Set([
|
|
177
181
|
"claude_command",
|
|
182
|
+
"codex_command",
|
|
178
183
|
"observer_base_url",
|
|
179
184
|
"observer_auth_file",
|
|
180
185
|
"observer_auth_command",
|
|
@@ -190,12 +195,15 @@ var SECRET_CONFIG_KEYS = new Set([
|
|
|
190
195
|
var REMOVED_CONFIG_KEYS = new Set(["observer_rich_openai_use_responses"]);
|
|
191
196
|
var ALLOWED_KEYS = [
|
|
192
197
|
"claude_command",
|
|
198
|
+
"codex_command",
|
|
193
199
|
"observer_base_url",
|
|
194
200
|
"observer_provider",
|
|
195
201
|
"observer_model",
|
|
196
202
|
"observer_tier_routing_enabled",
|
|
197
203
|
"observer_simple_model",
|
|
198
204
|
"observer_simple_temperature",
|
|
205
|
+
"observer_reasoning_effort",
|
|
206
|
+
"observer_reasoning_summary",
|
|
199
207
|
"observer_rich_model",
|
|
200
208
|
"observer_rich_temperature",
|
|
201
209
|
"observer_rich_reasoning_effort",
|
|
@@ -227,6 +235,7 @@ var ALLOWED_KEYS = [
|
|
|
227
235
|
];
|
|
228
236
|
var DEFAULTS = {
|
|
229
237
|
claude_command: ["claude"],
|
|
238
|
+
codex_command: ["codex"],
|
|
230
239
|
observer_runtime: "api_http",
|
|
231
240
|
observer_auth_source: "auto",
|
|
232
241
|
observer_tier_routing_enabled: false,
|
|
@@ -262,9 +271,12 @@ function getEffectiveConfig(configData) {
|
|
|
262
271
|
...DEFAULTS,
|
|
263
272
|
...configData
|
|
264
273
|
};
|
|
274
|
+
for (const key of ["claude_command", "codex_command"]) effective[key] = coerceObserverCommand(effective[key]) ?? DEFAULTS[key];
|
|
265
275
|
for (const [key, envVar] of Object.entries(CODEMEM_CONFIG_ENV_OVERRIDES)) {
|
|
266
276
|
const val = process.env[envVar];
|
|
267
|
-
if (val
|
|
277
|
+
if (val == null || val === "") continue;
|
|
278
|
+
if (key === "claude_command" || key === "codex_command") effective[key] = coerceObserverCommand(val) ?? effective[key];
|
|
279
|
+
else effective[key] = val;
|
|
268
280
|
}
|
|
269
281
|
return effective;
|
|
270
282
|
}
|
|
@@ -358,7 +370,7 @@ function validateAndApplyUpdate(configData, key, value, providers) {
|
|
|
358
370
|
if (key === "observer_runtime") {
|
|
359
371
|
if (typeof value !== "string") return "observer_runtime must be string";
|
|
360
372
|
const runtime = value.trim().toLowerCase();
|
|
361
|
-
if (!RUNTIMES.has(runtime)) return "observer_runtime must be one of: api_http, claude_sidecar";
|
|
373
|
+
if (!RUNTIMES.has(runtime)) return "observer_runtime must be one of: api_http, claude_sidecar, codex_sidecar";
|
|
362
374
|
configData[key] = runtime;
|
|
363
375
|
return null;
|
|
364
376
|
}
|
|
@@ -369,7 +381,7 @@ function validateAndApplyUpdate(configData, key, value, providers) {
|
|
|
369
381
|
configData[key] = source;
|
|
370
382
|
return null;
|
|
371
383
|
}
|
|
372
|
-
if (key === "claude_command" || key === "observer_auth_command") {
|
|
384
|
+
if (key === "claude_command" || key === "codex_command" || key === "observer_auth_command") {
|
|
373
385
|
const argv = asExecutableArgv(value);
|
|
374
386
|
if (argv == null) return `${key} must be string array`;
|
|
375
387
|
if (argv.length > 0) configData[key] = argv;
|
|
@@ -393,7 +405,7 @@ function validateAndApplyUpdate(configData, key, value, providers) {
|
|
|
393
405
|
configData[key] = value;
|
|
394
406
|
return null;
|
|
395
407
|
}
|
|
396
|
-
if (key === "observer_base_url" || key === "observer_model" || key === "observer_simple_model" || key === "observer_rich_model" || key === "observer_rich_reasoning_effort" || key === "observer_rich_reasoning_summary" || key === "observer_auth_file" || key === "sync_host" || key === "sync_coordinator_url" || key === "sync_coordinator_group") {
|
|
408
|
+
if (key === "observer_base_url" || key === "observer_model" || key === "observer_simple_model" || key === "observer_reasoning_effort" || key === "observer_reasoning_summary" || key === "observer_rich_model" || key === "observer_rich_reasoning_effort" || key === "observer_rich_reasoning_summary" || key === "observer_auth_file" || key === "sync_host" || key === "sync_coordinator_url" || key === "sync_coordinator_group") {
|
|
397
409
|
if (typeof value !== "string") return `${key} must be string`;
|
|
398
410
|
const trimmed = value.trim();
|
|
399
411
|
if (!trimmed) delete configData[key];
|
|
@@ -471,7 +483,7 @@ function configRoutes(opts = {}) {
|
|
|
471
483
|
}
|
|
472
484
|
const afterEffective = getEffectiveConfig(nextConfig);
|
|
473
485
|
const savedChangedKeys = ALLOWED_KEYS.filter((key) => beforeConfig[key] !== nextConfig[key]);
|
|
474
|
-
const effectiveChangedKeys = ALLOWED_KEYS.filter((key) => beforeEffective[key]
|
|
486
|
+
const effectiveChangedKeys = ALLOWED_KEYS.filter((key) => !configValuesEqual(beforeEffective[key], afterEffective[key]));
|
|
475
487
|
const envOverrides = getCodememEnvOverrides();
|
|
476
488
|
const ignoredByEnvKeys = savedChangedKeys.filter((key) => !effectiveChangedKeys.includes(key) && key in envOverrides);
|
|
477
489
|
const hotReloadedKeys = applyRuntimeEffects([...new Set([
|
|
@@ -1819,6 +1831,7 @@ async function ensureDefaultSpaceForTeam(opts) {
|
|
|
1819
1831
|
return {
|
|
1820
1832
|
scope,
|
|
1821
1833
|
membership: await coordinatorGrantScopeMembershipAction({
|
|
1834
|
+
effectId: `team-default-grant:${opts.groupId}:${scopeId}:${deviceId}:1`,
|
|
1822
1835
|
groupId: opts.groupId,
|
|
1823
1836
|
scopeId,
|
|
1824
1837
|
deviceId,
|
|
@@ -1851,6 +1864,7 @@ async function maybeGrantDefaultSpaceOnJoin(opts) {
|
|
|
1851
1864
|
adminSecret: opts.config.syncCoordinatorAdminSecret || null
|
|
1852
1865
|
})).find((scope) => scope.scope_id === scopeId) ?? null)?.kind !== "team_default") return null;
|
|
1853
1866
|
return await coordinatorGrantScopeMembershipAction({
|
|
1867
|
+
effectId: `team-default-join:${opts.requestId}:${scopeId}:${opts.deviceId}:1`,
|
|
1854
1868
|
groupId: opts.groupId,
|
|
1855
1869
|
scopeId,
|
|
1856
1870
|
deviceId: opts.deviceId,
|
|
@@ -1892,6 +1906,30 @@ function coordinatorAdminUnavailable(status) {
|
|
|
1892
1906
|
};
|
|
1893
1907
|
return null;
|
|
1894
1908
|
}
|
|
1909
|
+
function recipientInviteCreationUnavailable(status, kind) {
|
|
1910
|
+
if (status.readiness === "not_configured") return {
|
|
1911
|
+
body: {
|
|
1912
|
+
error: "coordinator_not_configured",
|
|
1913
|
+
status
|
|
1914
|
+
},
|
|
1915
|
+
httpStatus: 400
|
|
1916
|
+
};
|
|
1917
|
+
if (!status.has_groups) return {
|
|
1918
|
+
body: {
|
|
1919
|
+
error: "coordinator_group_missing",
|
|
1920
|
+
status
|
|
1921
|
+
},
|
|
1922
|
+
httpStatus: 400
|
|
1923
|
+
};
|
|
1924
|
+
if (kind === "team_member" && !status.has_admin_secret) return {
|
|
1925
|
+
body: {
|
|
1926
|
+
error: "coordinator_admin_secret_missing",
|
|
1927
|
+
status
|
|
1928
|
+
},
|
|
1929
|
+
httpStatus: 400
|
|
1930
|
+
};
|
|
1931
|
+
return null;
|
|
1932
|
+
}
|
|
1895
1933
|
async function parseViewerJsonBody(c, options = {}) {
|
|
1896
1934
|
const raw = await c.req.text();
|
|
1897
1935
|
if (!raw.trim()) return options.allowEmpty ? {} : null;
|
|
@@ -1929,6 +1967,21 @@ function optionalViewerNumber(body, key) {
|
|
|
1929
1967
|
const number = typeof value === "number" ? value : Number(value);
|
|
1930
1968
|
return Number.isFinite(number) ? Math.trunc(number) : NaN;
|
|
1931
1969
|
}
|
|
1970
|
+
function coordinatorAdminMembershipEffectId(action, attemptId, input) {
|
|
1971
|
+
return `viewer-admin-membership:${action}:${createHash("sha256").update(JSON.stringify({
|
|
1972
|
+
action,
|
|
1973
|
+
attemptId,
|
|
1974
|
+
groupId: input.groupId,
|
|
1975
|
+
scopeId: input.scopeId,
|
|
1976
|
+
deviceId: input.deviceId,
|
|
1977
|
+
role: input.role ?? null,
|
|
1978
|
+
membershipEpoch: input.membershipEpoch,
|
|
1979
|
+
coordinatorId: input.coordinatorId ?? null,
|
|
1980
|
+
manifestIssuerDeviceId: input.manifestIssuerDeviceId ?? null,
|
|
1981
|
+
manifestHash: input.manifestHash,
|
|
1982
|
+
signedManifestJson: input.signedManifestJson
|
|
1983
|
+
})).digest("hex")}`;
|
|
1984
|
+
}
|
|
1932
1985
|
function optionalViewerInteger(body, key) {
|
|
1933
1986
|
const value = body[key];
|
|
1934
1987
|
if (value == null || value === "") return null;
|
|
@@ -1955,7 +2008,7 @@ function parseViewerProjectMappingInput(body) {
|
|
|
1955
2008
|
}
|
|
1956
2009
|
function coordinatorAdminMutationStatus(message) {
|
|
1957
2010
|
if (message.includes("scope_not_found") || message.includes("membership_not_found") || message.includes("group_not_found") || message.includes("Group not found") || message.includes("Scope not found") || message.includes("device_not_enrolled_for_scope_group") || message.includes("device must be enrolled")) return 404;
|
|
1958
|
-
if (message.includes("not active") || message.includes("scope_not_active") || message.includes("group_archived") || message.includes("Group is archived")) return 409;
|
|
2011
|
+
if (message.includes("not active") || message.includes("scope_not_active") || message.includes("scope_membership_effect_conflict") || message.includes("group_archived") || message.includes("Group is archived")) return 409;
|
|
1959
2012
|
if (message.includes("Remote coordinator request failed (5") || message.includes("fetch failed") || message.includes("network") || message.includes("timed out")) return 502;
|
|
1960
2013
|
return 400;
|
|
1961
2014
|
}
|
|
@@ -1976,6 +2029,1141 @@ function parseInviteTtlHours(value) {
|
|
|
1976
2029
|
if (!Number.isInteger(ttlHours) || ttlHours < 1) return null;
|
|
1977
2030
|
return ttlHours;
|
|
1978
2031
|
}
|
|
2032
|
+
function projectInviteAcceptanceFailure(error) {
|
|
2033
|
+
if (isProjectSyncEnablementError(error)) return {
|
|
2034
|
+
error: error.code,
|
|
2035
|
+
detail: error.detail
|
|
2036
|
+
};
|
|
2037
|
+
const code = error instanceof Error ? error.message : "project_invite_acceptance_failed";
|
|
2038
|
+
const detailByCode = {
|
|
2039
|
+
invite_already_bound: "This invitation was accepted by another device. Ask the owner to create a new invitation.",
|
|
2040
|
+
invite_expired: "This invitation expired. Ask the owner to create a new invitation.",
|
|
2041
|
+
invite_identity_conflict: "This invitation does not match this Identity. Ask the owner to create a new invitation for this recipient.",
|
|
2042
|
+
invite_invalid: "This invitation is no longer valid. Ask the owner to create a new invitation.",
|
|
2043
|
+
inviter_identity_invalid: "The owner's device identity could not be verified. Ask the owner to review the share and create a new invitation.",
|
|
2044
|
+
project_invite_self_acceptance_forbidden: "The owner cannot accept this recipient invitation on the owner's device.",
|
|
2045
|
+
project_invite_bootstrap_incomplete: "The owner's device is not ready to establish trust yet. Retry once, then ask the owner to review the share.",
|
|
2046
|
+
project_invite_trust_state_invalid: "The owner's trust setup could not be verified. Ask the owner to review the share."
|
|
2047
|
+
};
|
|
2048
|
+
return {
|
|
2049
|
+
error: Object.hasOwn(detailByCode, code) ? code : "project_invite_acceptance_failed",
|
|
2050
|
+
detail: detailByCode[code] ?? "The invitation could not be accepted safely. Retry once, then ask the owner to review the share."
|
|
2051
|
+
};
|
|
2052
|
+
}
|
|
2053
|
+
var PROJECT_INVITE_TTL_HOURS = 168;
|
|
2054
|
+
var PROJECT_INVITE_BODY_KEYS = new Set([
|
|
2055
|
+
"teammate_name",
|
|
2056
|
+
"project_ids",
|
|
2057
|
+
"reviewed_project_set_digest"
|
|
2058
|
+
]);
|
|
2059
|
+
function projectInviteStringList(body, key) {
|
|
2060
|
+
const value = body[key];
|
|
2061
|
+
if (!Array.isArray(value)) throw new Error(`${key}_must_be_string_array`);
|
|
2062
|
+
if (value.length > 100) throw new Error(`${key}_too_large`);
|
|
2063
|
+
const items = value.map((item) => {
|
|
2064
|
+
if (typeof item !== "string") throw new Error(`${key}_must_be_string_array`);
|
|
2065
|
+
return item.trim();
|
|
2066
|
+
});
|
|
2067
|
+
if (items.some((item) => !item)) throw new Error(`${key}_contains_empty_value`);
|
|
2068
|
+
if (new Set(items).size !== items.length) throw new Error(`${key}_contains_duplicates`);
|
|
2069
|
+
return items;
|
|
2070
|
+
}
|
|
2071
|
+
function assertProjectInviteBody(body, creating) {
|
|
2072
|
+
const allowed = creating ? PROJECT_INVITE_BODY_KEYS : new Set(["teammate_name", "project_ids"]);
|
|
2073
|
+
if (Object.keys(body).some((key) => !allowed.has(key))) throw new Error("unexpected_project_invite_fields");
|
|
2074
|
+
if (typeof body.teammate_name !== "string") throw new Error("teammate_name_must_be_string");
|
|
2075
|
+
if (creating && typeof body.reviewed_project_set_digest !== "string") throw new Error("reviewed_project_set_digest_required");
|
|
2076
|
+
if (creating && !/^[a-f0-9]{64}$/u.test(String(body.reviewed_project_set_digest).trim())) throw new Error("reviewed_project_set_digest_invalid");
|
|
2077
|
+
}
|
|
2078
|
+
function projectInviteStatus(config = readCoordinatorSyncConfig()) {
|
|
2079
|
+
const status = coordinatorAdminStatusPayload(config);
|
|
2080
|
+
if (status.readiness !== "ready") throw new Error("team_sharing_not_configured");
|
|
2081
|
+
if (status.groups.length !== 1) throw new Error("team_selection_ambiguous");
|
|
2082
|
+
if (!status.active_group) throw new Error("team_sharing_not_configured");
|
|
2083
|
+
return {
|
|
2084
|
+
groupId: status.active_group,
|
|
2085
|
+
config
|
|
2086
|
+
};
|
|
2087
|
+
}
|
|
2088
|
+
function recipientInviteKind(value) {
|
|
2089
|
+
if (value === "team_member" || value === "add_device") return value;
|
|
2090
|
+
throw new Error("recipient_invite_kind_invalid");
|
|
2091
|
+
}
|
|
2092
|
+
function localOnboardingBinding(store, deviceName, options = {}) {
|
|
2093
|
+
const deviceId = options.materializeActor === false ? ensureStableStoreDeviceIdentity(store) : ensureStableStoreIdentity(store);
|
|
2094
|
+
const devicePublicKey = String(store.db.prepare("SELECT public_key FROM sync_device WHERE device_id = ?").pluck().get(deviceId) ?? "").trim();
|
|
2095
|
+
if (!devicePublicKey) throw new Error("device_public_key_missing");
|
|
2096
|
+
const config = readCodememConfigFile();
|
|
2097
|
+
return {
|
|
2098
|
+
deviceId,
|
|
2099
|
+
devicePublicKey,
|
|
2100
|
+
deviceDisplayName: friendlyDeviceName({
|
|
2101
|
+
explicitName: deviceName ?? String(config.sync_device_name ?? ""),
|
|
2102
|
+
osName: hostname(),
|
|
2103
|
+
fallbackSeed: deviceId
|
|
2104
|
+
})
|
|
2105
|
+
};
|
|
2106
|
+
}
|
|
2107
|
+
function recipientInviteOnboardingPreview(store, body, invitationId) {
|
|
2108
|
+
const kind = recipientInviteKind(body.kind ?? body.invite_kind);
|
|
2109
|
+
const binding = localOnboardingBinding(store, optionalViewerString(body, "device_name"));
|
|
2110
|
+
const teamId = optionalViewerStrictString(body, "policy_team_id");
|
|
2111
|
+
const targetIdentityId = optionalViewerStrictString(body, "target_identity_id");
|
|
2112
|
+
if (kind === "team_member") {
|
|
2113
|
+
if (!teamId || targetIdentityId) throw new Error("recipient_invite_metadata_invalid");
|
|
2114
|
+
return previewRecipientPolicyOnboarding(store.db, {
|
|
2115
|
+
version: 1,
|
|
2116
|
+
journey: "team",
|
|
2117
|
+
invitationId,
|
|
2118
|
+
identityId: store.actorId,
|
|
2119
|
+
...binding,
|
|
2120
|
+
teamId
|
|
2121
|
+
});
|
|
2122
|
+
}
|
|
2123
|
+
if (!targetIdentityId || teamId) throw new Error("recipient_invite_metadata_invalid");
|
|
2124
|
+
return previewRecipientPolicyOnboarding(store.db, {
|
|
2125
|
+
version: 1,
|
|
2126
|
+
journey: "add_device",
|
|
2127
|
+
invitationId,
|
|
2128
|
+
identityId: targetIdentityId,
|
|
2129
|
+
...binding
|
|
2130
|
+
});
|
|
2131
|
+
}
|
|
2132
|
+
function recipientInviteReviewedIntent(store, kind, targetId, preview) {
|
|
2133
|
+
if (kind === "team_member") {
|
|
2134
|
+
if (preview.journey !== "team" || !preview.team || preview.team.teamId !== targetId) throw new Error("recipient_invite_intent_mismatch");
|
|
2135
|
+
return {
|
|
2136
|
+
version: 1,
|
|
2137
|
+
journey: "team",
|
|
2138
|
+
team: preview.team,
|
|
2139
|
+
projects: preview.projects,
|
|
2140
|
+
excludedProjects: []
|
|
2141
|
+
};
|
|
2142
|
+
}
|
|
2143
|
+
if (preview.journey !== "add_device" || preview.binding.identityId !== targetId) throw new Error("recipient_invite_intent_mismatch");
|
|
2144
|
+
const targetIdentity = store.db.prepare(`SELECT display_name FROM actors
|
|
2145
|
+
WHERE actor_id = ? AND status = 'active' AND merged_into_actor_id IS NULL`).get(targetId);
|
|
2146
|
+
if (!targetIdentity?.display_name) throw new Error("identity_not_found");
|
|
2147
|
+
return {
|
|
2148
|
+
version: 1,
|
|
2149
|
+
journey: "add_device",
|
|
2150
|
+
targetIdentity: {
|
|
2151
|
+
identityId: targetId,
|
|
2152
|
+
displayName: targetIdentity.display_name
|
|
2153
|
+
},
|
|
2154
|
+
projects: preview.projects,
|
|
2155
|
+
excludedProjects: preview.excludedProjects
|
|
2156
|
+
};
|
|
2157
|
+
}
|
|
2158
|
+
function recipientInviteOnboardingPreviewFromReviewedIntent(store, body, invitationId, reviewedIntent) {
|
|
2159
|
+
const kind = recipientInviteKind(body.kind ?? body.invite_kind);
|
|
2160
|
+
const binding = localOnboardingBinding(store, optionalViewerString(body, "device_name"), { materializeActor: false });
|
|
2161
|
+
const teamId = optionalViewerStrictString(body, "policy_team_id");
|
|
2162
|
+
const targetIdentityId = optionalViewerStrictString(body, "target_identity_id");
|
|
2163
|
+
const assignedIdentityId = optionalViewerStrictString(body, "assigned_identity_id");
|
|
2164
|
+
if (kind === "team_member") {
|
|
2165
|
+
if (!teamId || !assignedIdentityId || targetIdentityId) throw new Error("recipient_invite_metadata_invalid");
|
|
2166
|
+
return previewRecipientPolicyOnboardingFromReviewedIntent(reviewedIntent, {
|
|
2167
|
+
version: 1,
|
|
2168
|
+
journey: "team",
|
|
2169
|
+
invitationId,
|
|
2170
|
+
identityId: assignedIdentityId,
|
|
2171
|
+
...binding,
|
|
2172
|
+
teamId
|
|
2173
|
+
});
|
|
2174
|
+
}
|
|
2175
|
+
if (!targetIdentityId || teamId || assignedIdentityId) throw new Error("recipient_invite_metadata_invalid");
|
|
2176
|
+
return previewRecipientPolicyOnboardingFromReviewedIntent(reviewedIntent, {
|
|
2177
|
+
version: 1,
|
|
2178
|
+
journey: "add_device",
|
|
2179
|
+
invitationId,
|
|
2180
|
+
identityId: targetIdentityId,
|
|
2181
|
+
...binding
|
|
2182
|
+
});
|
|
2183
|
+
}
|
|
2184
|
+
var SAFE_RECIPIENT_INVITE_ERRORS = new Set([
|
|
2185
|
+
"invite_already_bound",
|
|
2186
|
+
"invite_expired",
|
|
2187
|
+
"invite_identity_conflict",
|
|
2188
|
+
"invite_invalid",
|
|
2189
|
+
"recipient_invite_intent_mismatch",
|
|
2190
|
+
"recipient_invite_review_unavailable",
|
|
2191
|
+
"reviewed_onboarding_stale"
|
|
2192
|
+
]);
|
|
2193
|
+
function safeRecipientInviteError(value, fallback = "invite_invalid") {
|
|
2194
|
+
const code = value instanceof Error ? value.message : String(value ?? "").trim();
|
|
2195
|
+
return SAFE_RECIPIENT_INVITE_ERRORS.has(code) ? code : fallback;
|
|
2196
|
+
}
|
|
2197
|
+
function recipientInvitePreviewId(kind, targetId) {
|
|
2198
|
+
return `recipient-invite-preview:${kind}:${targetId}`;
|
|
2199
|
+
}
|
|
2200
|
+
function recipientPolicyCapabilityFromStatus(payload) {
|
|
2201
|
+
if (!isScopedSyncCapability(normalizeSyncCapability(payload.sync_capability))) return "unsupported";
|
|
2202
|
+
return supportsSyncFeature(payload.sync_features, "reassign_scope") ? "supported" : "unsupported";
|
|
2203
|
+
}
|
|
2204
|
+
async function peerSupportsSyncRequirements(store, deviceId, requirements) {
|
|
2205
|
+
const [localDeviceId] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
|
|
2206
|
+
if (deviceId === localDeviceId) return "supported";
|
|
2207
|
+
const row = store.db.prepare("SELECT addresses_json, pinned_fingerprint, public_key FROM sync_peers WHERE peer_device_id = ?").get(deviceId);
|
|
2208
|
+
const expectedFingerprint = String(row?.pinned_fingerprint ?? "").trim() || (row?.public_key ? fingerprintPublicKey(row.public_key) : "");
|
|
2209
|
+
const addresses = safeJsonList(row?.addresses_json ?? null);
|
|
2210
|
+
for (const address of addresses) try {
|
|
2211
|
+
const url = `${buildBaseUrl(address)}/v1/status`;
|
|
2212
|
+
const [status, payload] = await requestJson("GET", url, {
|
|
2213
|
+
headers: {
|
|
2214
|
+
...buildAuthHeaders({
|
|
2215
|
+
deviceId: localDeviceId,
|
|
2216
|
+
method: "GET",
|
|
2217
|
+
url,
|
|
2218
|
+
bodyBytes: Buffer.alloc(0),
|
|
2219
|
+
keysDir: syncKeysDir()
|
|
2220
|
+
}),
|
|
2221
|
+
[SYNC_CAPABILITY_HEADER]: LOCAL_SYNC_CAPABILITY,
|
|
2222
|
+
[SYNC_FEATURES_HEADER]: LOCAL_SYNC_FEATURES.join(",")
|
|
2223
|
+
},
|
|
2224
|
+
timeoutS: 5
|
|
2225
|
+
});
|
|
2226
|
+
if (status < 200 || status >= 300) continue;
|
|
2227
|
+
if (String(payload?.device_id ?? "").trim() !== deviceId) continue;
|
|
2228
|
+
if (expectedFingerprint && String(payload?.fingerprint ?? "").trim() !== expectedFingerprint) continue;
|
|
2229
|
+
if (requirements.scoped && requirements.reassignScope && recipientPolicyCapabilityFromStatus(payload ?? {}) !== "supported") return "unsupported";
|
|
2230
|
+
if (requirements.scoped && !isScopedSyncCapability(normalizeSyncCapability(payload?.sync_capability))) return "unsupported";
|
|
2231
|
+
if (requirements.reassignScope && !supportsSyncFeature(payload?.sync_features, "reassign_scope")) return "unsupported";
|
|
2232
|
+
return "supported";
|
|
2233
|
+
} catch {}
|
|
2234
|
+
return "undetermined";
|
|
2235
|
+
}
|
|
2236
|
+
async function peerSupportsReassignScope(store, deviceId) {
|
|
2237
|
+
return peerSupportsSyncRequirements(store, deviceId, {
|
|
2238
|
+
scoped: false,
|
|
2239
|
+
reassignScope: true
|
|
2240
|
+
});
|
|
2241
|
+
}
|
|
2242
|
+
function resolveProjectInviteSelection(store, requestedIds, initiatingDeviceId) {
|
|
2243
|
+
if (requestedIds.length === 0) throw new Error("project_selection_empty");
|
|
2244
|
+
const requested = new Set(requestedIds);
|
|
2245
|
+
const byIdentity = /* @__PURE__ */ new Map();
|
|
2246
|
+
let offset = 0;
|
|
2247
|
+
while (byIdentity.size < requested.size) {
|
|
2248
|
+
const page = listProjectScopeInventory(store.db, {
|
|
2249
|
+
limit: 250,
|
|
2250
|
+
offset
|
|
2251
|
+
});
|
|
2252
|
+
for (const project of page.projects) if (requested.has(project.workspace_identity)) byIdentity.set(project.workspace_identity, project);
|
|
2253
|
+
if (!page.has_more) break;
|
|
2254
|
+
offset += page.limit;
|
|
2255
|
+
}
|
|
2256
|
+
return requestedIds.map((projectId) => {
|
|
2257
|
+
const project = byIdentity.get(projectId);
|
|
2258
|
+
if (!project) throw new Error("project_selection_unknown");
|
|
2259
|
+
if (project.guardrail_warnings.some((warning) => warning.code === "basename_collision_review" && warning.requires_confirmation)) throw new Error("project_selection_ambiguous");
|
|
2260
|
+
if (project.read_only || project.identity_source === "unmapped" || project.memory_count == null || project.guardrail_warnings.some((warning) => warning.requires_confirmation)) throw new Error("project_selection_unsupported");
|
|
2261
|
+
return {
|
|
2262
|
+
canonicalIdentity: project.workspace_identity,
|
|
2263
|
+
displayName: project.display_project,
|
|
2264
|
+
identitySource: project.identity_source,
|
|
2265
|
+
existingMemoryCount: countShareableProjectMemories(store.db, {
|
|
2266
|
+
canonicalIdentity: project.workspace_identity,
|
|
2267
|
+
initiatingDeviceId
|
|
2268
|
+
})
|
|
2269
|
+
};
|
|
2270
|
+
});
|
|
2271
|
+
}
|
|
2272
|
+
function resolveSharePerson(store, teammateName) {
|
|
2273
|
+
const matches = store.db.prepare(`SELECT actor_id, display_name, status
|
|
2274
|
+
FROM actors
|
|
2275
|
+
WHERE status IN ('active', 'pending') AND lower(trim(display_name)) = lower(?)
|
|
2276
|
+
ORDER BY actor_id`).all(teammateName);
|
|
2277
|
+
if (matches.length > 1) throw new Error("teammate_match_ambiguous");
|
|
2278
|
+
const match = matches[0];
|
|
2279
|
+
if (match) return match.status === "pending" ? {
|
|
2280
|
+
kind: "pending",
|
|
2281
|
+
personId: match.actor_id,
|
|
2282
|
+
displayName: match.display_name
|
|
2283
|
+
} : {
|
|
2284
|
+
kind: "existing",
|
|
2285
|
+
personId: match.actor_id,
|
|
2286
|
+
displayName: match.display_name
|
|
2287
|
+
};
|
|
2288
|
+
return {
|
|
2289
|
+
kind: "pending",
|
|
2290
|
+
displayName: teammateName
|
|
2291
|
+
};
|
|
2292
|
+
}
|
|
2293
|
+
function projectInvitePlan(store, body, createdAt, inviteExpiresAt) {
|
|
2294
|
+
const teammateName = normalizeTeammateName(String(body.teammate_name));
|
|
2295
|
+
const [localDeviceId] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
|
|
2296
|
+
store.adoptEnsuredDeviceIdentity(localDeviceId);
|
|
2297
|
+
const projects = resolveProjectInviteSelection(store, projectInviteStringList(body, "project_ids"), localDeviceId);
|
|
2298
|
+
const { groupId, config } = projectInviteStatus();
|
|
2299
|
+
return {
|
|
2300
|
+
plan: planShareOperation({
|
|
2301
|
+
inviterActorId: store.actorId,
|
|
2302
|
+
inviterDeviceIds: [localDeviceId],
|
|
2303
|
+
person: resolveSharePerson(store, teammateName),
|
|
2304
|
+
projects,
|
|
2305
|
+
coordinatorGroupId: groupId,
|
|
2306
|
+
createdAt,
|
|
2307
|
+
inviteExpiresAt
|
|
2308
|
+
}),
|
|
2309
|
+
config
|
|
2310
|
+
};
|
|
2311
|
+
}
|
|
2312
|
+
function projectInvitePreview(plan) {
|
|
2313
|
+
return {
|
|
2314
|
+
operation_id: plan.operationId,
|
|
2315
|
+
teammate: {
|
|
2316
|
+
display_name: plan.teammateName,
|
|
2317
|
+
match: plan.personKind,
|
|
2318
|
+
...plan.personKind === "existing" ? { person_id: plan.personId } : {}
|
|
2319
|
+
},
|
|
2320
|
+
projects: plan.projects.map((project) => ({
|
|
2321
|
+
project_id: project.canonicalIdentity,
|
|
2322
|
+
display_name: project.displayName,
|
|
2323
|
+
existing_memory_count: project.existingMemoryCount
|
|
2324
|
+
})),
|
|
2325
|
+
existing_memory_count: plan.projects.reduce((total, project) => total + project.existingMemoryCount, 0),
|
|
2326
|
+
future_memories_shared: true,
|
|
2327
|
+
history_policy: plan.historyPolicy,
|
|
2328
|
+
reviewed_project_set_digest: plan.reviewedProjectSetDigest
|
|
2329
|
+
};
|
|
2330
|
+
}
|
|
2331
|
+
async function coordinatorProjectInvitePayload(operationId) {
|
|
2332
|
+
const { groupId, config } = projectInviteStatus();
|
|
2333
|
+
const [status, payload] = await requestJson("GET", `${buildBaseUrl(config.syncCoordinatorUrl)}/v1/admin/project-invites/${encodeURIComponent(operationId)}?group_id=${encodeURIComponent(groupId)}`, {
|
|
2334
|
+
headers: { "X-Codemem-Coordinator-Admin": config.syncCoordinatorAdminSecret },
|
|
2335
|
+
timeoutS: 10
|
|
2336
|
+
}).catch((cause) => {
|
|
2337
|
+
const error = new Error("coordinator_unavailable", { cause });
|
|
2338
|
+
Object.assign(error, { status: 503 });
|
|
2339
|
+
throw error;
|
|
2340
|
+
});
|
|
2341
|
+
if (status < 200 || status >= 300) {
|
|
2342
|
+
const error = new Error(String(payload?.error ?? "operation_read_failed"));
|
|
2343
|
+
Object.assign(error, { status });
|
|
2344
|
+
throw error;
|
|
2345
|
+
}
|
|
2346
|
+
if (!payload || payload.operation_id !== operationId || payload.group_id !== groupId) {
|
|
2347
|
+
const error = /* @__PURE__ */ new Error("operation_scope_mismatch");
|
|
2348
|
+
Object.assign(error, { status: 409 });
|
|
2349
|
+
throw error;
|
|
2350
|
+
}
|
|
2351
|
+
return {
|
|
2352
|
+
groupId,
|
|
2353
|
+
config,
|
|
2354
|
+
payload
|
|
2355
|
+
};
|
|
2356
|
+
}
|
|
2357
|
+
async function reconcileProjectInviteAcceptance(store, operationId) {
|
|
2358
|
+
const owner = store.db.prepare("SELECT inviter_actor_id FROM share_operations WHERE operation_id = ?").pluck().get(operationId);
|
|
2359
|
+
if (!owner) throw new Error("operation_not_found");
|
|
2360
|
+
if (owner !== store.actorId) throw new Error("operation_scope_mismatch");
|
|
2361
|
+
ensureLocalActorRecord(store);
|
|
2362
|
+
const { groupId, payload } = await coordinatorProjectInvitePayload(operationId);
|
|
2363
|
+
if (payload.state === "waiting_for_acceptance") return {
|
|
2364
|
+
accepted: false,
|
|
2365
|
+
payload
|
|
2366
|
+
};
|
|
2367
|
+
if (payload.state !== "accepted") {
|
|
2368
|
+
const error = /* @__PURE__ */ new Error("operation_state_invalid");
|
|
2369
|
+
Object.assign(error, { status: 409 });
|
|
2370
|
+
throw error;
|
|
2371
|
+
}
|
|
2372
|
+
if ([
|
|
2373
|
+
"reviewed_project_set_digest",
|
|
2374
|
+
"recipient_actor_id",
|
|
2375
|
+
"recipient_display_name",
|
|
2376
|
+
"recipient_device_id",
|
|
2377
|
+
"recipient_device_display_name",
|
|
2378
|
+
"recipient_public_key",
|
|
2379
|
+
"recipient_fingerprint",
|
|
2380
|
+
"consumed_at",
|
|
2381
|
+
"trust_state"
|
|
2382
|
+
].some((key) => typeof payload[key] !== "string" || !String(payload[key]).trim())) {
|
|
2383
|
+
const error = /* @__PURE__ */ new Error("operation_acceptance_invalid");
|
|
2384
|
+
Object.assign(error, { status: 409 });
|
|
2385
|
+
throw error;
|
|
2386
|
+
}
|
|
2387
|
+
const trustState = String(payload.trust_state);
|
|
2388
|
+
const bootstrapGrantId = typeof payload.bootstrap_grant_id === "string" && payload.bootstrap_grant_id.trim() ? payload.bootstrap_grant_id : null;
|
|
2389
|
+
if (trustState !== "pending_inviter_device" && trustState !== "bootstrap_grant_created" || trustState === "bootstrap_grant_created" !== Boolean(bootstrapGrantId)) {
|
|
2390
|
+
const error = /* @__PURE__ */ new Error("operation_trust_state_invalid");
|
|
2391
|
+
Object.assign(error, { status: 409 });
|
|
2392
|
+
throw error;
|
|
2393
|
+
}
|
|
2394
|
+
const projects = parseAcceptedProjectIntent(payload.projects);
|
|
2395
|
+
reconcileShareOperationAcceptance(store.db, {
|
|
2396
|
+
operationId,
|
|
2397
|
+
localInviterActorId: store.actorId,
|
|
2398
|
+
coordinatorGroupId: groupId,
|
|
2399
|
+
reviewedProjectSetDigest: String(payload.reviewed_project_set_digest),
|
|
2400
|
+
recipientActorId: String(payload.recipient_actor_id),
|
|
2401
|
+
recipientDisplayName: String(payload.recipient_display_name),
|
|
2402
|
+
recipientDeviceId: String(payload.recipient_device_id),
|
|
2403
|
+
recipientDeviceDisplayName: String(payload.recipient_device_display_name),
|
|
2404
|
+
recipientPublicKey: String(payload.recipient_public_key),
|
|
2405
|
+
recipientFingerprint: String(payload.recipient_fingerprint),
|
|
2406
|
+
consumedAt: String(payload.consumed_at),
|
|
2407
|
+
trustState,
|
|
2408
|
+
bootstrapGrantId,
|
|
2409
|
+
projects
|
|
2410
|
+
});
|
|
2411
|
+
return {
|
|
2412
|
+
accepted: true,
|
|
2413
|
+
payload
|
|
2414
|
+
};
|
|
2415
|
+
}
|
|
2416
|
+
async function shareOperationReadModels(store, operationId, includeInviteLinks = true) {
|
|
2417
|
+
const rows = store.db.prepare(`SELECT o.operation_id, o.state, o.person_id, o.teammate_name,
|
|
2418
|
+
o.recipient_actor_id, o.recipient_display_name, o.recipient_device_id,
|
|
2419
|
+
o.recipient_device_display_name, o.invite_expires_at, o.acceptance_consumed_at,
|
|
2420
|
+
o.created_at, o.updated_at, a.display_name AS actor_display_name,
|
|
2421
|
+
p.name AS peer_display_name, p.last_seen_at AS device_last_seen_at,
|
|
2422
|
+
p.last_sync_at AS device_last_reached_at
|
|
2423
|
+
FROM share_operations o
|
|
2424
|
+
LEFT JOIN actors a ON a.actor_id = o.person_id
|
|
2425
|
+
LEFT JOIN sync_peers p ON p.peer_device_id = o.recipient_device_id
|
|
2426
|
+
WHERE o.inviter_actor_id = ? AND (? IS NULL OR o.operation_id = ?)
|
|
2427
|
+
ORDER BY o.created_at DESC, o.operation_id`).all(store.actorId, operationId ?? null, operationId ?? null);
|
|
2428
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2429
|
+
return Promise.all(rows.map(async (row) => {
|
|
2430
|
+
const projects = store.db.prepare(`SELECT canonical_project_identity AS project_id, display_name, existing_memory_count
|
|
2431
|
+
FROM share_operation_projects WHERE operation_id = ? ORDER BY ordinal`).all(row.operation_id);
|
|
2432
|
+
const steps = store.db.prepare(`SELECT step_key, status, attempt_count, started_at, last_attempt_at,
|
|
2433
|
+
safe_error_code, updated_at FROM share_operation_steps WHERE operation_id = ?`).all(row.operation_id).map((step) => ({
|
|
2434
|
+
attemptCount: step.attempt_count,
|
|
2435
|
+
lastAttemptAt: step.last_attempt_at,
|
|
2436
|
+
safeErrorCode: step.safe_error_code,
|
|
2437
|
+
startedAt: step.started_at,
|
|
2438
|
+
status: step.status,
|
|
2439
|
+
stepKey: step.step_key,
|
|
2440
|
+
updatedAt: step.updated_at
|
|
2441
|
+
}));
|
|
2442
|
+
let inviteLink = null;
|
|
2443
|
+
let projectedState = row.state;
|
|
2444
|
+
if (includeInviteLinks && row.state === "waiting_for_acceptance") try {
|
|
2445
|
+
const remote = await coordinatorProjectInvitePayload(row.operation_id);
|
|
2446
|
+
if (remote.payload.state === "accepted") projectedState = "accepted";
|
|
2447
|
+
inviteLink = typeof remote.payload.invite_link === "string" && remote.payload.invite_link.trim() ? remote.payload.invite_link : null;
|
|
2448
|
+
} catch {}
|
|
2449
|
+
const personId = row.recipient_actor_id || row.person_id;
|
|
2450
|
+
const personName = row.recipient_display_name || row.actor_display_name || row.teammate_name;
|
|
2451
|
+
const deviceName = row.recipient_device_display_name || row.peer_display_name;
|
|
2452
|
+
const lifecycle = projectShareLifecycle({
|
|
2453
|
+
deviceLastSeenAt: row.device_last_seen_at,
|
|
2454
|
+
deviceLastReachedAt: row.device_last_reached_at,
|
|
2455
|
+
deviceName,
|
|
2456
|
+
inviteLink,
|
|
2457
|
+
now,
|
|
2458
|
+
personName,
|
|
2459
|
+
state: projectedState,
|
|
2460
|
+
steps
|
|
2461
|
+
});
|
|
2462
|
+
return {
|
|
2463
|
+
operation_id: row.operation_id,
|
|
2464
|
+
person: {
|
|
2465
|
+
actor_id: personId,
|
|
2466
|
+
display_name: personName
|
|
2467
|
+
},
|
|
2468
|
+
devices: row.recipient_device_id && deviceName ? [{
|
|
2469
|
+
device_id: row.recipient_device_id,
|
|
2470
|
+
display_name: deviceName,
|
|
2471
|
+
last_seen_at: row.device_last_seen_at
|
|
2472
|
+
}] : [],
|
|
2473
|
+
projects,
|
|
2474
|
+
project_count: projects.length,
|
|
2475
|
+
lifecycle: {
|
|
2476
|
+
state: lifecycle.lifecycle,
|
|
2477
|
+
label: lifecycle.label,
|
|
2478
|
+
explanation: lifecycle.explanation,
|
|
2479
|
+
primary_action: lifecycle.primaryAction?.kind === "copy_invite" ? {
|
|
2480
|
+
kind: "copy_invite",
|
|
2481
|
+
label: lifecycle.primaryAction.label,
|
|
2482
|
+
invite_link: lifecycle.primaryAction.inviteLink
|
|
2483
|
+
} : !includeInviteLinks && lifecycle.lifecycle === "waiting_for_acceptance" ? {
|
|
2484
|
+
kind: "copy_invite",
|
|
2485
|
+
label: "Copy invite"
|
|
2486
|
+
} : lifecycle.primaryAction
|
|
2487
|
+
},
|
|
2488
|
+
timestamps: {
|
|
2489
|
+
created_at: row.created_at,
|
|
2490
|
+
updated_at: row.updated_at,
|
|
2491
|
+
accepted_at: row.acceptance_consumed_at,
|
|
2492
|
+
invite_expires_at: row.invite_expires_at
|
|
2493
|
+
}
|
|
2494
|
+
};
|
|
2495
|
+
}));
|
|
2496
|
+
}
|
|
2497
|
+
async function executeProjectShareProvisioning(store, operationId) {
|
|
2498
|
+
const { groupId, config } = projectInviteStatus();
|
|
2499
|
+
const coordinatorId = config.syncCoordinatorUrl || null;
|
|
2500
|
+
if (!coordinatorId) throw new Error("coordinator_not_configured");
|
|
2501
|
+
const [localDeviceId] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
|
|
2502
|
+
return executeShareProvisioning(store.db, {
|
|
2503
|
+
operationId,
|
|
2504
|
+
initiatingDeviceId: localDeviceId
|
|
2505
|
+
}, {
|
|
2506
|
+
createOrGetBoundary: async (project, expectedGroupId) => {
|
|
2507
|
+
if (expectedGroupId !== groupId) throw new Error("operation_scope_mismatch");
|
|
2508
|
+
const readExisting = async () => (await coordinatorListScopesAction({
|
|
2509
|
+
groupId,
|
|
2510
|
+
includeInactive: true,
|
|
2511
|
+
remoteUrl: config.syncCoordinatorUrl || null,
|
|
2512
|
+
adminSecret: config.syncCoordinatorAdminSecret || null
|
|
2513
|
+
})).find((scope) => scope.scope_id === project.boundaryId) ?? null;
|
|
2514
|
+
const existing = await readExisting();
|
|
2515
|
+
if (existing) return existing;
|
|
2516
|
+
try {
|
|
2517
|
+
return await coordinatorCreateScopeAction({
|
|
2518
|
+
groupId,
|
|
2519
|
+
scopeId: project.boundaryId,
|
|
2520
|
+
label: project.displayName,
|
|
2521
|
+
kind: "managed_project",
|
|
2522
|
+
authorityType: "coordinator",
|
|
2523
|
+
coordinatorId,
|
|
2524
|
+
membershipEpoch: 1,
|
|
2525
|
+
status: "active",
|
|
2526
|
+
remoteUrl: config.syncCoordinatorUrl || null,
|
|
2527
|
+
adminSecret: config.syncCoordinatorAdminSecret || null
|
|
2528
|
+
});
|
|
2529
|
+
} catch (error) {
|
|
2530
|
+
const reread = await readExisting();
|
|
2531
|
+
if (reread) return reread;
|
|
2532
|
+
throw error;
|
|
2533
|
+
}
|
|
2534
|
+
},
|
|
2535
|
+
grantMembership: ({ effectId, groupId: expectedGroupId, scopeId, deviceId, role }) => {
|
|
2536
|
+
if (expectedGroupId !== groupId) throw new Error("operation_scope_mismatch");
|
|
2537
|
+
return coordinatorGrantScopeMembershipAction({
|
|
2538
|
+
effectId,
|
|
2539
|
+
groupId,
|
|
2540
|
+
scopeId,
|
|
2541
|
+
deviceId,
|
|
2542
|
+
role,
|
|
2543
|
+
membershipEpoch: 1,
|
|
2544
|
+
coordinatorId,
|
|
2545
|
+
remoteUrl: config.syncCoordinatorUrl || null,
|
|
2546
|
+
adminSecret: config.syncCoordinatorAdminSecret || null
|
|
2547
|
+
});
|
|
2548
|
+
},
|
|
2549
|
+
supportsReassignScope: (deviceId) => peerSupportsReassignScope(store, deviceId),
|
|
2550
|
+
refreshAuthorization: async (expectedGroupId) => {
|
|
2551
|
+
if (expectedGroupId !== groupId) throw new Error("operation_scope_mismatch");
|
|
2552
|
+
if ((await refreshConfiguredScopeMembershipCache(store.db, config, { keysDir: syncKeysDir() })).groups.find((item) => item.groupId === groupId)?.status !== "refreshed") throw new Error("authorization_refresh_failed");
|
|
2553
|
+
},
|
|
2554
|
+
runInitialSync: (recipientDeviceId) => runSyncPass(store.db, recipientDeviceId, {
|
|
2555
|
+
keysDir: syncKeysDir(),
|
|
2556
|
+
scanner: store.scanner,
|
|
2557
|
+
refreshAuthorization: true
|
|
2558
|
+
})
|
|
2559
|
+
});
|
|
2560
|
+
}
|
|
2561
|
+
async function advanceProjectShareOperation(store, operationId) {
|
|
2562
|
+
const operation = store.db.prepare("SELECT state, inviter_actor_id, recipient_device_id FROM share_operations WHERE operation_id = ?").get(operationId);
|
|
2563
|
+
if (!operation) throw new Error("operation_not_found");
|
|
2564
|
+
if (operation.inviter_actor_id !== store.actorId) throw new Error("operation_scope_mismatch");
|
|
2565
|
+
if (operation.state === "cancelled") return {
|
|
2566
|
+
advanced: false,
|
|
2567
|
+
state: "cancelled"
|
|
2568
|
+
};
|
|
2569
|
+
if (!operation.recipient_device_id) {
|
|
2570
|
+
if (!(await reconcileProjectInviteAcceptance(store, operationId)).accepted) return {
|
|
2571
|
+
advanced: false,
|
|
2572
|
+
state: "waiting_for_acceptance"
|
|
2573
|
+
};
|
|
2574
|
+
}
|
|
2575
|
+
if ((await executeProjectShareProvisioning(store, operationId)).superseded) return {
|
|
2576
|
+
advanced: false,
|
|
2577
|
+
state: "cancelled"
|
|
2578
|
+
};
|
|
2579
|
+
return {
|
|
2580
|
+
advanced: true,
|
|
2581
|
+
state: "active"
|
|
2582
|
+
};
|
|
2583
|
+
}
|
|
2584
|
+
var AUTOMATIC_SHARE_OPERATION_STATES = [
|
|
2585
|
+
"waiting_for_acceptance",
|
|
2586
|
+
"accepted",
|
|
2587
|
+
"provisioning",
|
|
2588
|
+
"initial_sync",
|
|
2589
|
+
"waiting_for_device"
|
|
2590
|
+
];
|
|
2591
|
+
var AUTOMATIC_WAITING_ACCEPTANCE_RETRY_COOLDOWN_MS = 30 * 1e3;
|
|
2592
|
+
var AUTOMATIC_WAITING_DEVICE_RETRY_COOLDOWN_MS = 300 * 1e3;
|
|
2593
|
+
var TERMINAL_SHARE_MAINTENANCE_ERRORS = new Set([
|
|
2594
|
+
"coordinator_not_configured",
|
|
2595
|
+
"team_sharing_not_configured",
|
|
2596
|
+
"team_selection_ambiguous",
|
|
2597
|
+
"initiating_device_not_reviewed",
|
|
2598
|
+
"inviter_project_access_ambiguous",
|
|
2599
|
+
"managed_boundary_plan_missing",
|
|
2600
|
+
"operation_device_binding_missing",
|
|
2601
|
+
"operation_intent_invalid",
|
|
2602
|
+
"provisioning_membership_plan_invalid",
|
|
2603
|
+
"device_binding_conflict",
|
|
2604
|
+
"intent_conflict",
|
|
2605
|
+
"inviter_identity_conflict"
|
|
2606
|
+
]);
|
|
2607
|
+
var PROJECT_INVITE_OWNER_CONFLICT_ERRORS = new Set([
|
|
2608
|
+
"operation_scope_mismatch",
|
|
2609
|
+
"operation_state_invalid",
|
|
2610
|
+
"operation_acceptance_invalid",
|
|
2611
|
+
"operation_trust_state_invalid",
|
|
2612
|
+
"operation_intent_invalid",
|
|
2613
|
+
"operation_intent_mismatch",
|
|
2614
|
+
"recipient_fingerprint_mismatch",
|
|
2615
|
+
"recipient_device_identity_conflict",
|
|
2616
|
+
"recipient_actor_conflict",
|
|
2617
|
+
"pending_person_identity_conflict",
|
|
2618
|
+
"device_binding_conflict",
|
|
2619
|
+
"intent_conflict",
|
|
2620
|
+
"inviter_identity_conflict"
|
|
2621
|
+
]);
|
|
2622
|
+
var TERMINAL_RECONCILIATION_ERRORS = new Set([
|
|
2623
|
+
"coordinator_not_configured",
|
|
2624
|
+
"team_sharing_not_configured",
|
|
2625
|
+
"team_selection_ambiguous",
|
|
2626
|
+
"operation_not_found",
|
|
2627
|
+
...PROJECT_INVITE_OWNER_CONFLICT_ERRORS
|
|
2628
|
+
]);
|
|
2629
|
+
function errorStatus(error) {
|
|
2630
|
+
const value = error && typeof error === "object" ? error.status : null;
|
|
2631
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 400 && value <= 599 ? value : null;
|
|
2632
|
+
}
|
|
2633
|
+
function projectInviteOwnerErrorResponse(error, fallback) {
|
|
2634
|
+
const message = error instanceof Error ? error.message : "";
|
|
2635
|
+
const status = errorStatus(error);
|
|
2636
|
+
if (message === "operation_not_found" && (status == null || status === 404)) return {
|
|
2637
|
+
code: message,
|
|
2638
|
+
status: 404
|
|
2639
|
+
};
|
|
2640
|
+
if (PROJECT_INVITE_OWNER_CONFLICT_ERRORS.has(message) && (status == null || status === 409)) return {
|
|
2641
|
+
code: message,
|
|
2642
|
+
status: 409
|
|
2643
|
+
};
|
|
2644
|
+
if (status != null && status >= 500 && message !== "coordinator_unavailable") return {
|
|
2645
|
+
code: "coordinator_upstream_failed",
|
|
2646
|
+
status: 502
|
|
2647
|
+
};
|
|
2648
|
+
if (message === "coordinator_unavailable" || status === 408 || status === 429 || status === 503) return {
|
|
2649
|
+
code: "coordinator_unavailable",
|
|
2650
|
+
status: 503
|
|
2651
|
+
};
|
|
2652
|
+
return fallback;
|
|
2653
|
+
}
|
|
2654
|
+
function isRetryableReconciliationError(error) {
|
|
2655
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2656
|
+
if (TERMINAL_RECONCILIATION_ERRORS.has(message)) return false;
|
|
2657
|
+
const status = errorStatus(error);
|
|
2658
|
+
return status == null || status === 408 || status === 429 || status >= 500;
|
|
2659
|
+
}
|
|
2660
|
+
function safeReconciliationErrorCode(error) {
|
|
2661
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2662
|
+
return TERMINAL_RECONCILIATION_ERRORS.has(message) ? message : "operation_read_failed";
|
|
2663
|
+
}
|
|
2664
|
+
function recordInviteReconciliationFailure(store, operationId, error, now) {
|
|
2665
|
+
const safeErrorCode = safeReconciliationErrorCode(error);
|
|
2666
|
+
const retryable = isRetryableReconciliationError(error);
|
|
2667
|
+
store.db.prepare(`UPDATE share_operation_steps SET
|
|
2668
|
+
status = CASE WHEN ? = 1 THEN 'pending' ELSE 'failed' END,
|
|
2669
|
+
attempt_count = attempt_count + 1, last_attempt_at = ?, safe_error_code = ?, updated_at = ?
|
|
2670
|
+
WHERE operation_id = ? AND step_key = 'invite_consumption'`).run(retryable ? 1 : 0, now, safeErrorCode, now, operationId);
|
|
2671
|
+
const outcome = retryable ? "retry_scheduled" : "needs_attention";
|
|
2672
|
+
store.db.prepare("UPDATE share_operations SET state = ?, updated_at = ? WHERE operation_id = ?").run(outcome === "needs_attention" ? "needs_attention" : "waiting_for_acceptance", now, operationId);
|
|
2673
|
+
return outcome;
|
|
2674
|
+
}
|
|
2675
|
+
async function advancePendingProjectShares(store, options = {}) {
|
|
2676
|
+
const limit = Math.max(1, Math.min(Math.trunc(options.limit ?? 3), 10));
|
|
2677
|
+
const placeholders = AUTOMATIC_SHARE_OPERATION_STATES.map(() => "?").join(", ");
|
|
2678
|
+
const maintenanceNow = options.now ?? /* @__PURE__ */ new Date();
|
|
2679
|
+
const waitingAcceptanceRetryBefore = (/* @__PURE__ */ new Date(maintenanceNow.getTime() - AUTOMATIC_WAITING_ACCEPTANCE_RETRY_COOLDOWN_MS)).toISOString();
|
|
2680
|
+
const waitingRetryBefore = (/* @__PURE__ */ new Date(maintenanceNow.getTime() - AUTOMATIC_WAITING_DEVICE_RETRY_COOLDOWN_MS)).toISOString();
|
|
2681
|
+
const rows = store.db.prepare(`SELECT operation.operation_id FROM share_operations AS operation
|
|
2682
|
+
WHERE operation.inviter_actor_id = ?
|
|
2683
|
+
AND operation.state IN (${placeholders})
|
|
2684
|
+
AND (operation.state <> 'waiting_for_acceptance' OR operation.updated_at <= ?)
|
|
2685
|
+
AND (
|
|
2686
|
+
operation.state <> 'waiting_for_device'
|
|
2687
|
+
OR operation.updated_at <= ?
|
|
2688
|
+
OR (
|
|
2689
|
+
NOT EXISTS (
|
|
2690
|
+
SELECT 1 FROM share_operation_steps AS step
|
|
2691
|
+
WHERE step.operation_id = operation.operation_id
|
|
2692
|
+
AND step.step_key = 'capability_preflight'
|
|
2693
|
+
AND step.status <> 'completed'
|
|
2694
|
+
AND (
|
|
2695
|
+
step.status = 'running'
|
|
2696
|
+
OR step.safe_error_code IN (
|
|
2697
|
+
'waiting_for_device', 'device_offline', 'recipient_offline'
|
|
2698
|
+
)
|
|
2699
|
+
)
|
|
2700
|
+
)
|
|
2701
|
+
AND EXISTS (
|
|
2702
|
+
SELECT 1 FROM sync_attempts AS attempt
|
|
2703
|
+
WHERE attempt.id = (
|
|
2704
|
+
SELECT latest.id FROM sync_attempts AS latest
|
|
2705
|
+
WHERE latest.peer_device_id = operation.recipient_device_id
|
|
2706
|
+
ORDER BY latest.started_at DESC, latest.id DESC
|
|
2707
|
+
LIMIT 1
|
|
2708
|
+
)
|
|
2709
|
+
AND attempt.ok = 1
|
|
2710
|
+
AND julianday(COALESCE(attempt.finished_at, attempt.started_at)) >
|
|
2711
|
+
julianday(operation.updated_at)
|
|
2712
|
+
)
|
|
2713
|
+
)
|
|
2714
|
+
)
|
|
2715
|
+
ORDER BY CASE WHEN operation.state IN ('accepted', 'provisioning', 'initial_sync') THEN 0 ELSE 1 END,
|
|
2716
|
+
CASE WHEN operation.state IN ('waiting_for_acceptance', 'waiting_for_device')
|
|
2717
|
+
THEN operation.updated_at ELSE operation.created_at END ASC,
|
|
2718
|
+
operation.created_at ASC, operation.operation_id ASC
|
|
2719
|
+
LIMIT ?`).all(store.actorId, ...AUTOMATIC_SHARE_OPERATION_STATES, waitingAcceptanceRetryBefore, waitingRetryBefore, limit);
|
|
2720
|
+
const advanceOperation = options.advanceOperation ?? advanceProjectShareOperation;
|
|
2721
|
+
const result = {
|
|
2722
|
+
processed: 0,
|
|
2723
|
+
advanced: 0,
|
|
2724
|
+
waiting: 0,
|
|
2725
|
+
attention: 0,
|
|
2726
|
+
failed: 0,
|
|
2727
|
+
items: []
|
|
2728
|
+
};
|
|
2729
|
+
for (const row of rows) {
|
|
2730
|
+
result.processed += 1;
|
|
2731
|
+
try {
|
|
2732
|
+
const advanced = await advanceOperation(store, row.operation_id);
|
|
2733
|
+
if (!advanced.advanced) {
|
|
2734
|
+
if (advanced.state === "cancelled") {
|
|
2735
|
+
result.items.push({
|
|
2736
|
+
operationId: row.operation_id,
|
|
2737
|
+
outcome: "superseded"
|
|
2738
|
+
});
|
|
2739
|
+
continue;
|
|
2740
|
+
}
|
|
2741
|
+
store.db.prepare("UPDATE share_operations SET updated_at = ? WHERE operation_id = ?").run(maintenanceNow.toISOString(), row.operation_id);
|
|
2742
|
+
result.waiting += 1;
|
|
2743
|
+
result.items.push({
|
|
2744
|
+
operationId: row.operation_id,
|
|
2745
|
+
outcome: "waiting_for_acceptance"
|
|
2746
|
+
});
|
|
2747
|
+
continue;
|
|
2748
|
+
}
|
|
2749
|
+
result.advanced += 1;
|
|
2750
|
+
result.items.push({
|
|
2751
|
+
operationId: row.operation_id,
|
|
2752
|
+
outcome: "advanced"
|
|
2753
|
+
});
|
|
2754
|
+
} catch (error) {
|
|
2755
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2756
|
+
const state = store.db.prepare("SELECT state FROM share_operations WHERE operation_id = ?").pluck().get(row.operation_id);
|
|
2757
|
+
if (state === "cancelled") {
|
|
2758
|
+
result.items.push({
|
|
2759
|
+
operationId: row.operation_id,
|
|
2760
|
+
outcome: "superseded"
|
|
2761
|
+
});
|
|
2762
|
+
continue;
|
|
2763
|
+
}
|
|
2764
|
+
if (message === "waiting_for_device" || state === "waiting_for_device") {
|
|
2765
|
+
result.waiting += 1;
|
|
2766
|
+
result.items.push({
|
|
2767
|
+
operationId: row.operation_id,
|
|
2768
|
+
outcome: "waiting_for_device"
|
|
2769
|
+
});
|
|
2770
|
+
continue;
|
|
2771
|
+
}
|
|
2772
|
+
if (state === "needs_attention") {
|
|
2773
|
+
result.attention += 1;
|
|
2774
|
+
result.items.push({
|
|
2775
|
+
operationId: row.operation_id,
|
|
2776
|
+
outcome: "needs_attention",
|
|
2777
|
+
error: message
|
|
2778
|
+
});
|
|
2779
|
+
continue;
|
|
2780
|
+
}
|
|
2781
|
+
if (state === "waiting_for_acceptance") {
|
|
2782
|
+
const outcome = recordInviteReconciliationFailure(store, row.operation_id, error, maintenanceNow.toISOString());
|
|
2783
|
+
if (outcome === "needs_attention") result.attention += 1;
|
|
2784
|
+
else result.waiting += 1;
|
|
2785
|
+
result.items.push({
|
|
2786
|
+
operationId: row.operation_id,
|
|
2787
|
+
outcome,
|
|
2788
|
+
error: message
|
|
2789
|
+
});
|
|
2790
|
+
continue;
|
|
2791
|
+
}
|
|
2792
|
+
if (TERMINAL_SHARE_MAINTENANCE_ERRORS.has(message)) {
|
|
2793
|
+
store.db.prepare("UPDATE share_operations SET state = 'needs_attention', updated_at = ? WHERE operation_id = ?").run(maintenanceNow.toISOString(), row.operation_id);
|
|
2794
|
+
result.attention += 1;
|
|
2795
|
+
result.items.push({
|
|
2796
|
+
operationId: row.operation_id,
|
|
2797
|
+
outcome: "needs_attention",
|
|
2798
|
+
error: message
|
|
2799
|
+
});
|
|
2800
|
+
continue;
|
|
2801
|
+
}
|
|
2802
|
+
result.failed += 1;
|
|
2803
|
+
result.items.push({
|
|
2804
|
+
operationId: row.operation_id,
|
|
2805
|
+
outcome: "failed",
|
|
2806
|
+
error: message
|
|
2807
|
+
});
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
return result;
|
|
2811
|
+
}
|
|
2812
|
+
async function reconcileConfiguredCoordinatorEnrollment(store, options = {}) {
|
|
2813
|
+
const config = options.config ?? readCoordinatorSyncConfig();
|
|
2814
|
+
const remoteUrl = config.syncCoordinatorUrl.trim();
|
|
2815
|
+
const adminSecret = config.syncCoordinatorAdminSecret.trim();
|
|
2816
|
+
if (!remoteUrl || !adminSecret || config.syncCoordinatorGroups.length === 0) return {
|
|
2817
|
+
skipped: true,
|
|
2818
|
+
groupsProcessed: 0,
|
|
2819
|
+
failedGroups: 0,
|
|
2820
|
+
devicesAdded: 0,
|
|
2821
|
+
membershipsAdded: 0,
|
|
2822
|
+
identitiesAdded: 0,
|
|
2823
|
+
unchanged: 0,
|
|
2824
|
+
issues: 0
|
|
2825
|
+
};
|
|
2826
|
+
const listDevices = options.listDevices ?? coordinatorListDevicesAction;
|
|
2827
|
+
const listConsumedTeamInvites = options.listConsumedTeamInvites ?? coordinatorListConsumedTeamInvitesAction;
|
|
2828
|
+
const reconcileSnapshot = options.reconcileSnapshot ?? ((input) => reconcileCoordinatorEnrollmentSnapshot(store.db, input));
|
|
2829
|
+
const total = {
|
|
2830
|
+
skipped: false,
|
|
2831
|
+
groupsProcessed: 0,
|
|
2832
|
+
failedGroups: 0,
|
|
2833
|
+
devicesAdded: 0,
|
|
2834
|
+
membershipsAdded: 0,
|
|
2835
|
+
identitiesAdded: 0,
|
|
2836
|
+
unchanged: 0,
|
|
2837
|
+
issues: 0
|
|
2838
|
+
};
|
|
2839
|
+
for (const groupId of config.syncCoordinatorGroups) try {
|
|
2840
|
+
const [enrollments, consumedTeamInvites] = await Promise.all([listDevices({
|
|
2841
|
+
groupId,
|
|
2842
|
+
remoteUrl,
|
|
2843
|
+
adminSecret
|
|
2844
|
+
}), listConsumedTeamInvites({
|
|
2845
|
+
groupId,
|
|
2846
|
+
remoteUrl,
|
|
2847
|
+
adminSecret
|
|
2848
|
+
})]);
|
|
2849
|
+
const result = reconcileSnapshot({
|
|
2850
|
+
coordinatorId: buildBaseUrl(remoteUrl),
|
|
2851
|
+
groupId,
|
|
2852
|
+
enrollments,
|
|
2853
|
+
consumedTeamInvites,
|
|
2854
|
+
localDeviceId: store.deviceId
|
|
2855
|
+
});
|
|
2856
|
+
total.groupsProcessed += 1;
|
|
2857
|
+
total.devicesAdded += result.devicesAdded;
|
|
2858
|
+
total.membershipsAdded += result.membershipsAdded;
|
|
2859
|
+
total.identitiesAdded += result.identitiesAdded;
|
|
2860
|
+
total.unchanged += result.unchanged;
|
|
2861
|
+
total.issues += result.issues.length;
|
|
2862
|
+
} catch {
|
|
2863
|
+
total.failedGroups += 1;
|
|
2864
|
+
}
|
|
2865
|
+
return total;
|
|
2866
|
+
}
|
|
2867
|
+
var RECIPIENT_POLICY_MAINTENANCE_MAX_LIMIT = 10;
|
|
2868
|
+
var RECIPIENT_POLICY_MAINTENANCE_DEFAULT_LIMIT = 3;
|
|
2869
|
+
var RECIPIENT_POLICY_MAINTENANCE_BACKOFF_MS = 6e4;
|
|
2870
|
+
var RECIPIENT_POLICY_WAITING_ERRORS = new Set([
|
|
2871
|
+
"recipient_policy_capability_undetermined",
|
|
2872
|
+
"recipient_policy_parity_incomplete",
|
|
2873
|
+
"recipient_policy_snapshot_not_fresh"
|
|
2874
|
+
]);
|
|
2875
|
+
function recipientPolicyCoordinatorBoundary(store, scopeId) {
|
|
2876
|
+
const row = store.db.prepare(`SELECT coordinator_id, group_id, membership_epoch FROM replication_scopes
|
|
2877
|
+
WHERE scope_id = ? AND kind = 'managed_project' AND authority_type = 'coordinator'
|
|
2878
|
+
AND status = 'active'`).get(scopeId);
|
|
2879
|
+
const coordinatorId = String(row?.coordinator_id ?? "").trim();
|
|
2880
|
+
const groupId = String(row?.group_id ?? "").trim();
|
|
2881
|
+
const membershipEpoch = Number(row?.membership_epoch);
|
|
2882
|
+
if (!coordinatorId || !groupId || !Number.isSafeInteger(membershipEpoch) || membershipEpoch < 0) throw new Error("recipient_policy_active_managed_scope_required");
|
|
2883
|
+
return {
|
|
2884
|
+
coordinatorId,
|
|
2885
|
+
groupId,
|
|
2886
|
+
membershipEpoch
|
|
2887
|
+
};
|
|
2888
|
+
}
|
|
2889
|
+
function recipientPolicySnapshotFingerprint(scopeId, scopeMembershipEpoch, memberships) {
|
|
2890
|
+
const canonical = memberships.toSorted((left, right) => left.deviceId.localeCompare(right.deviceId) || left.status.localeCompare(right.status) || left.membershipEpoch - right.membershipEpoch).map((membership) => `${membership.deviceId}\u0000${membership.status}\u0000${membership.membershipEpoch}`).join("");
|
|
2891
|
+
return `recipient-policy-coordinator-snapshot-v2:${createHash("sha256").update(`${scopeId}\u0000${scopeMembershipEpoch}\u0000${canonical}`).digest("hex")}`;
|
|
2892
|
+
}
|
|
2893
|
+
function recipientPolicyPresenceCapabilityExpiresAt(enrollment, observedAt) {
|
|
2894
|
+
const expiresAt = enrollment.presence_expires_at;
|
|
2895
|
+
const capabilities = enrollment.presence_capabilities;
|
|
2896
|
+
if (typeof expiresAt !== "string" || !capabilities) return null;
|
|
2897
|
+
const expiresAtMs = Date.parse(expiresAt);
|
|
2898
|
+
const observedAtMs = Date.parse(observedAt);
|
|
2899
|
+
if (!Number.isFinite(expiresAtMs) || !Number.isFinite(observedAtMs) || new Date(expiresAtMs).toISOString() !== expiresAt || expiresAtMs <= observedAtMs || capabilities.sync_capability !== "scoped" || !Array.isArray(capabilities.sync_features) || !capabilities.sync_features.every((feature) => typeof feature === "string") || !capabilities.sync_features.includes("reassign_scope")) return null;
|
|
2900
|
+
return expiresAtMs;
|
|
2901
|
+
}
|
|
2902
|
+
function recipientPolicyEnrollmentValueIsValid(value) {
|
|
2903
|
+
return typeof value === "string" && value.length > 0 && value === value.trim();
|
|
2904
|
+
}
|
|
2905
|
+
function createRecipientPolicyReconcilerEffects(store, options = {}) {
|
|
2906
|
+
const config = options.config ?? readCoordinatorSyncConfig();
|
|
2907
|
+
const now = options.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
|
|
2908
|
+
const listDevices = options.listDevices ?? coordinatorListDevicesAction;
|
|
2909
|
+
const listReviewedRecipientInviteEvidence = options.listReviewedRecipientInviteEvidence ?? coordinatorListReviewedRecipientInviteEvidenceAction;
|
|
2910
|
+
const reviewedRecipientInviteEvidence = /* @__PURE__ */ new Map();
|
|
2911
|
+
const loadReviewedRecipientInviteEvidence = (groupId) => {
|
|
2912
|
+
let evidence = reviewedRecipientInviteEvidence.get(groupId);
|
|
2913
|
+
if (!evidence) {
|
|
2914
|
+
evidence = listReviewedRecipientInviteEvidence({
|
|
2915
|
+
groupId,
|
|
2916
|
+
remoteUrl: config.syncCoordinatorUrl || null,
|
|
2917
|
+
adminSecret: config.syncCoordinatorAdminSecret || null
|
|
2918
|
+
});
|
|
2919
|
+
reviewedRecipientInviteEvidence.set(groupId, evidence);
|
|
2920
|
+
}
|
|
2921
|
+
return evidence;
|
|
2922
|
+
};
|
|
2923
|
+
const boundaryEnrollments = /* @__PURE__ */ new Map();
|
|
2924
|
+
const target = (scopeId) => recipientPolicyCoordinatorBoundary(store, scopeId);
|
|
2925
|
+
const coordinatorOptions = (scopeId) => {
|
|
2926
|
+
const boundary = target(scopeId);
|
|
2927
|
+
const remoteUrl = config.syncCoordinatorUrl?.trim();
|
|
2928
|
+
const adminSecret = config.syncCoordinatorAdminSecret?.trim();
|
|
2929
|
+
if (!remoteUrl || !adminSecret) throw new Error("recipient_policy_effect_failed");
|
|
2930
|
+
if (!config.syncCoordinatorGroups.includes(boundary.groupId)) throw new Error("recipient_policy_effect_failed");
|
|
2931
|
+
try {
|
|
2932
|
+
if (buildBaseUrl(boundary.coordinatorId) !== buildBaseUrl(remoteUrl)) throw new Error("recipient_policy_effect_failed");
|
|
2933
|
+
} catch {
|
|
2934
|
+
throw new Error("recipient_policy_effect_failed");
|
|
2935
|
+
}
|
|
2936
|
+
return {
|
|
2937
|
+
...boundary,
|
|
2938
|
+
remoteUrl,
|
|
2939
|
+
adminSecret
|
|
2940
|
+
};
|
|
2941
|
+
};
|
|
2942
|
+
return {
|
|
2943
|
+
now,
|
|
2944
|
+
snapshot: async ({ scopeId }) => {
|
|
2945
|
+
const targetOptions = coordinatorOptions(scopeId);
|
|
2946
|
+
const snapshotMemberships = (await coordinatorListScopeMembershipsAction({
|
|
2947
|
+
groupId: targetOptions.groupId,
|
|
2948
|
+
scopeId,
|
|
2949
|
+
includeRevoked: true,
|
|
2950
|
+
remoteUrl: targetOptions.remoteUrl,
|
|
2951
|
+
adminSecret: targetOptions.adminSecret
|
|
2952
|
+
}).catch(() => {
|
|
2953
|
+
throw new Error("recipient_policy_snapshot_not_fresh");
|
|
2954
|
+
})).map((membership) => {
|
|
2955
|
+
if (typeof membership.device_id !== "string" || membership.status !== "active" && membership.status !== "revoked") throw new Error("recipient_policy_snapshot_invalid");
|
|
2956
|
+
const status = membership.status;
|
|
2957
|
+
return {
|
|
2958
|
+
deviceId: membership.device_id,
|
|
2959
|
+
status,
|
|
2960
|
+
membershipEpoch: membership.membership_epoch
|
|
2961
|
+
};
|
|
2962
|
+
});
|
|
2963
|
+
return {
|
|
2964
|
+
authoritative: true,
|
|
2965
|
+
scopeId,
|
|
2966
|
+
scopeMembershipEpoch: targetOptions.membershipEpoch,
|
|
2967
|
+
fingerprint: recipientPolicySnapshotFingerprint(scopeId, targetOptions.membershipEpoch, snapshotMemberships),
|
|
2968
|
+
observedAt: now(),
|
|
2969
|
+
memberships: snapshotMemberships
|
|
2970
|
+
};
|
|
2971
|
+
},
|
|
2972
|
+
listBoundaryEnrollments: async ({ scopeId }) => {
|
|
2973
|
+
const targetOptions = coordinatorOptions(scopeId);
|
|
2974
|
+
const observedAt = now();
|
|
2975
|
+
const enrollments = await listDevices({
|
|
2976
|
+
groupId: targetOptions.groupId,
|
|
2977
|
+
includeDisabled: true,
|
|
2978
|
+
remoteUrl: targetOptions.remoteUrl,
|
|
2979
|
+
adminSecret: targetOptions.adminSecret
|
|
2980
|
+
}).catch(() => {
|
|
2981
|
+
throw new Error("recipient_policy_snapshot_not_fresh");
|
|
2982
|
+
});
|
|
2983
|
+
const presenceCapabilityExpiries = new Map(enrollments.map((enrollment) => [enrollment.device_id, recipientPolicyPresenceCapabilityExpiresAt(enrollment, observedAt)]));
|
|
2984
|
+
const mapped = enrollments.map((enrollment) => {
|
|
2985
|
+
if (enrollment.group_id !== targetOptions.groupId || !recipientPolicyEnrollmentValueIsValid(enrollment.device_id) || !recipientPolicyEnrollmentValueIsValid(enrollment.public_key) || !recipientPolicyEnrollmentValueIsValid(enrollment.fingerprint) || fingerprintPublicKey(enrollment.public_key) !== enrollment.fingerprint || enrollment.enabled !== 0 && enrollment.enabled !== 1 || enrollment.identity_id !== null && !recipientPolicyEnrollmentValueIsValid(enrollment.identity_id)) throw new Error("recipient_policy_snapshot_invalid");
|
|
2986
|
+
return {
|
|
2987
|
+
deviceId: enrollment.device_id,
|
|
2988
|
+
identityId: enrollment.identity_id,
|
|
2989
|
+
publicKey: enrollment.public_key,
|
|
2990
|
+
fingerprint: enrollment.fingerprint,
|
|
2991
|
+
enabled: enrollment.enabled === 1
|
|
2992
|
+
};
|
|
2993
|
+
});
|
|
2994
|
+
boundaryEnrollments.set(scopeId, new Map(mapped.flatMap((enrollment) => enrollment.enabled && enrollment.identityId !== null ? [[enrollment.deviceId, {
|
|
2995
|
+
identityId: enrollment.identityId,
|
|
2996
|
+
publicKey: enrollment.publicKey,
|
|
2997
|
+
fingerprint: enrollment.fingerprint,
|
|
2998
|
+
presenceCapabilityExpiresAtMs: presenceCapabilityExpiries.get(enrollment.deviceId) ?? null
|
|
2999
|
+
}]] : [])));
|
|
3000
|
+
return mapped;
|
|
3001
|
+
},
|
|
3002
|
+
probeCapability: async ({ deviceId, scopeId }) => {
|
|
3003
|
+
if (deviceId === store.deviceId) return "supported";
|
|
3004
|
+
const targetOptions = coordinatorOptions(scopeId);
|
|
3005
|
+
const enrollment = boundaryEnrollments.get(scopeId)?.get(deviceId);
|
|
3006
|
+
if (!enrollment) return "undetermined";
|
|
3007
|
+
const peer = store.db.prepare("SELECT pinned_fingerprint, public_key FROM sync_peers WHERE peer_device_id = ?").get(deviceId);
|
|
3008
|
+
if (peer) {
|
|
3009
|
+
const peerPublicKey = String(peer.public_key ?? "").trim();
|
|
3010
|
+
const peerFingerprint = String(peer.pinned_fingerprint ?? "").trim() || (peerPublicKey ? fingerprintPublicKey(peerPublicKey) : "");
|
|
3011
|
+
if ((peerFingerprint || peerPublicKey) && (peerFingerprint !== enrollment.fingerprint || peerPublicKey && peerPublicKey !== enrollment.publicKey)) return "undetermined";
|
|
3012
|
+
}
|
|
3013
|
+
const observed = await peerSupportsSyncRequirements(store, deviceId, {
|
|
3014
|
+
scoped: true,
|
|
3015
|
+
reassignScope: true
|
|
3016
|
+
});
|
|
3017
|
+
if (observed !== "undetermined") return observed;
|
|
3018
|
+
const observedAtMs = Date.parse(now());
|
|
3019
|
+
if (enrollment.presenceCapabilityExpiresAtMs == null || !Number.isFinite(observedAtMs) || enrollment.presenceCapabilityExpiresAtMs <= observedAtMs) return "undetermined";
|
|
3020
|
+
return (await loadReviewedRecipientInviteEvidence(targetOptions.groupId).catch(() => [])).some((invite) => invite.group_id === targetOptions.groupId && invite.bound_device_id === deviceId && invite.recipient_actor_id === enrollment.identityId && invite.bound_public_key === enrollment.publicKey && invite.bound_fingerprint === enrollment.fingerprint) ? "supported" : "undetermined";
|
|
3021
|
+
},
|
|
3022
|
+
revoke: async (input) => {
|
|
3023
|
+
const targetOptions = coordinatorOptions(input.scopeId);
|
|
3024
|
+
await coordinatorRevokeScopeMembershipAction({
|
|
3025
|
+
effectId: input.effectId,
|
|
3026
|
+
groupId: targetOptions.groupId,
|
|
3027
|
+
scopeId: input.scopeId,
|
|
3028
|
+
deviceId: input.deviceId,
|
|
3029
|
+
remoteUrl: targetOptions.remoteUrl,
|
|
3030
|
+
adminSecret: targetOptions.adminSecret
|
|
3031
|
+
});
|
|
3032
|
+
return {
|
|
3033
|
+
effectId: input.effectId,
|
|
3034
|
+
scopeId: input.scopeId,
|
|
3035
|
+
deviceId: input.deviceId,
|
|
3036
|
+
status: "revoked"
|
|
3037
|
+
};
|
|
3038
|
+
},
|
|
3039
|
+
grant: async (input) => {
|
|
3040
|
+
const targetOptions = coordinatorOptions(input.scopeId);
|
|
3041
|
+
const membership = await coordinatorGrantScopeMembershipAction({
|
|
3042
|
+
effectId: input.effectId,
|
|
3043
|
+
groupId: targetOptions.groupId,
|
|
3044
|
+
scopeId: input.scopeId,
|
|
3045
|
+
deviceId: input.deviceId,
|
|
3046
|
+
role: input.role,
|
|
3047
|
+
coordinatorId: targetOptions.coordinatorId,
|
|
3048
|
+
remoteUrl: targetOptions.remoteUrl,
|
|
3049
|
+
adminSecret: targetOptions.adminSecret
|
|
3050
|
+
});
|
|
3051
|
+
return {
|
|
3052
|
+
effectId: input.effectId,
|
|
3053
|
+
scopeId: membership.scope_id,
|
|
3054
|
+
deviceId: membership.device_id,
|
|
3055
|
+
status: membership.status === "active" ? "active" : "revoked"
|
|
3056
|
+
};
|
|
3057
|
+
},
|
|
3058
|
+
refresh: async ({ scopeId }) => {
|
|
3059
|
+
const boundary = target(scopeId);
|
|
3060
|
+
if ((await refreshConfiguredScopeMembershipCache(store.db, config, { keysDir: syncKeysDir() })).groups.find((item) => item.groupId === boundary.groupId)?.status !== "refreshed") throw new Error("recipient_policy_effect_failed");
|
|
3061
|
+
await refreshAuthorizedCoordinatorPeerTrust(store, config);
|
|
3062
|
+
}
|
|
3063
|
+
};
|
|
3064
|
+
}
|
|
3065
|
+
async function reconcileRecipientPolicyProjects(store, options = {}) {
|
|
3066
|
+
const limit = Math.max(1, Math.min(Math.trunc(options.limit ?? RECIPIENT_POLICY_MAINTENANCE_DEFAULT_LIMIT), RECIPIENT_POLICY_MAINTENANCE_MAX_LIMIT));
|
|
3067
|
+
const maintenanceNow = options.now ?? /* @__PURE__ */ new Date();
|
|
3068
|
+
const backoffMs = Math.max(0, Math.trunc(options.backoffMs ?? RECIPIENT_POLICY_MAINTENANCE_BACKOFF_MS));
|
|
3069
|
+
const retryBefore = new Date(maintenanceNow.getTime() - backoffMs).toISOString();
|
|
3070
|
+
const rows = store.db.prepare(`WITH projects AS (
|
|
3071
|
+
SELECT DISTINCT canonical_project_identity FROM project_recipients
|
|
3072
|
+
UNION
|
|
3073
|
+
SELECT canonical_project_identity FROM recipient_policy_authority_states
|
|
3074
|
+
)
|
|
3075
|
+
SELECT projects.canonical_project_identity
|
|
3076
|
+
FROM projects
|
|
3077
|
+
LEFT JOIN recipient_policy_authority_states authority
|
|
3078
|
+
ON authority.canonical_project_identity = projects.canonical_project_identity
|
|
3079
|
+
WHERE authority.safe_error_code IS NULL OR authority.last_attempt_at IS NULL
|
|
3080
|
+
OR authority.last_attempt_at <= ?
|
|
3081
|
+
ORDER BY CASE WHEN authority.last_attempt_at IS NULL THEN 0 ELSE 1 END,
|
|
3082
|
+
authority.last_attempt_at, projects.canonical_project_identity
|
|
3083
|
+
LIMIT ?`).all(retryBefore, limit);
|
|
3084
|
+
const effects = options.effects ?? createRecipientPolicyReconcilerEffects(store);
|
|
3085
|
+
const reconcileProject = options.reconcileProject ?? reconcileRecipientPolicyProject;
|
|
3086
|
+
const result = {
|
|
3087
|
+
processed: 0,
|
|
3088
|
+
active: 0,
|
|
3089
|
+
waiting: 0,
|
|
3090
|
+
attention: 0,
|
|
3091
|
+
failed: 0,
|
|
3092
|
+
items: []
|
|
3093
|
+
};
|
|
3094
|
+
for (const row of rows) {
|
|
3095
|
+
result.processed += 1;
|
|
3096
|
+
try {
|
|
3097
|
+
const outcome = await reconcileProject(store.db, {
|
|
3098
|
+
canonicalProjectIdentity: row.canonical_project_identity,
|
|
3099
|
+
leaseOwner: options.leaseOwner ?? `recipient-policy-maintenance:${store.deviceId || process.pid}`
|
|
3100
|
+
}, effects);
|
|
3101
|
+
if (outcome.status === "active") result.active += 1;
|
|
3102
|
+
else if (outcome.status === "needs_attention") result.attention += 1;
|
|
3103
|
+
else result.waiting += 1;
|
|
3104
|
+
result.items.push({
|
|
3105
|
+
canonicalProjectIdentity: row.canonical_project_identity,
|
|
3106
|
+
status: outcome.status,
|
|
3107
|
+
safeErrorCode: outcome.safeErrorCode
|
|
3108
|
+
});
|
|
3109
|
+
} catch {
|
|
3110
|
+
result.failed += 1;
|
|
3111
|
+
result.items.push({
|
|
3112
|
+
canonicalProjectIdentity: row.canonical_project_identity,
|
|
3113
|
+
status: "failed",
|
|
3114
|
+
safeErrorCode: "recipient_policy_reconciliation_failed"
|
|
3115
|
+
});
|
|
3116
|
+
}
|
|
3117
|
+
}
|
|
3118
|
+
return result;
|
|
3119
|
+
}
|
|
3120
|
+
function recipientPolicyReadState(authority) {
|
|
3121
|
+
if (!authority) return "pending";
|
|
3122
|
+
if (authority.safeErrorCode && RECIPIENT_POLICY_WAITING_ERRORS.has(authority.safeErrorCode)) return "waiting";
|
|
3123
|
+
if (authority.safeErrorCode || authority.authorityState === "rolled_back") return "needs_attention";
|
|
3124
|
+
if (authority.authorityState === "active") return "active";
|
|
3125
|
+
if (authority.authorityState === "eligible") return "verifying";
|
|
3126
|
+
return "pending";
|
|
3127
|
+
}
|
|
3128
|
+
function recipientPolicyReadCopy(state) {
|
|
3129
|
+
if (state === "active") return {
|
|
3130
|
+
label: "Recipient policy active",
|
|
3131
|
+
explanation: "Recipient policy now controls future access for this Project."
|
|
3132
|
+
};
|
|
3133
|
+
if (state === "verifying") return {
|
|
3134
|
+
label: "Verifying recipient policy",
|
|
3135
|
+
explanation: "Current access matches recipient policy and needs one more stable check."
|
|
3136
|
+
};
|
|
3137
|
+
if (state === "waiting") return {
|
|
3138
|
+
label: "Waiting to reconcile",
|
|
3139
|
+
explanation: "Waiting for devices or a fresh coordinator snapshot. No partial grant is applied."
|
|
3140
|
+
};
|
|
3141
|
+
if (state === "needs_attention") return {
|
|
3142
|
+
label: "Reconciliation needs attention",
|
|
3143
|
+
explanation: "Legacy scope enforcement remains in control until this Project is safe to retry."
|
|
3144
|
+
};
|
|
3145
|
+
return {
|
|
3146
|
+
label: "Recipient policy pending",
|
|
3147
|
+
explanation: "Legacy scope enforcement remains in control while reconciliation is pending."
|
|
3148
|
+
};
|
|
3149
|
+
}
|
|
3150
|
+
function listRecipientPolicyReconciliationStatus(store) {
|
|
3151
|
+
return {
|
|
3152
|
+
version: 1,
|
|
3153
|
+
items: store.db.prepare(`SELECT DISTINCT canonical_project_identity FROM project_recipients WHERE status = 'active'
|
|
3154
|
+
UNION SELECT canonical_project_identity FROM recipient_policy_authority_states
|
|
3155
|
+
ORDER BY canonical_project_identity`).all().map((row) => row.canonical_project_identity).map((canonicalProjectIdentity) => {
|
|
3156
|
+
const state = recipientPolicyReadState(getRecipientPolicyAuthorityState(store.db, canonicalProjectIdentity));
|
|
3157
|
+
return {
|
|
3158
|
+
canonicalProjectIdentity,
|
|
3159
|
+
state,
|
|
3160
|
+
...recipientPolicyReadCopy(state),
|
|
3161
|
+
deliveredCopiesMayRemain: true,
|
|
3162
|
+
revocationWarning: SCOPE_MEMBERSHIP_REVOCATION_LIMITATION
|
|
3163
|
+
};
|
|
3164
|
+
})
|
|
3165
|
+
};
|
|
3166
|
+
}
|
|
1979
3167
|
function sortActiveMaintenanceJobs(jobs) {
|
|
1980
3168
|
return [...jobs].sort((a, b) => {
|
|
1981
3169
|
const aTime = a.started_at ?? a.updated_at;
|
|
@@ -2017,6 +3205,11 @@ function redactSemanticIndexDiagnostics(diagnostics, showDiagnostics) {
|
|
|
2017
3205
|
function syncKeysDir() {
|
|
2018
3206
|
return process.env.CODEMEM_KEYS_DIR?.trim() || void 0;
|
|
2019
3207
|
}
|
|
3208
|
+
/** Strip control/format characters and cap length so attacker-controlled
|
|
3209
|
+
* headers cannot forge log lines when included in diagnostics. */
|
|
3210
|
+
function loggableHeaderValue(value) {
|
|
3211
|
+
return (value ?? "").replace(/[\p{Cc}\p{Cf}]/gu, "").slice(0, 64).trim() || "unknown";
|
|
3212
|
+
}
|
|
2020
3213
|
function intEnvOr(name, fallback) {
|
|
2021
3214
|
const value = Number.parseInt(process.env[name] ?? "", 10);
|
|
2022
3215
|
return Number.isFinite(value) ? value : fallback;
|
|
@@ -2169,7 +3362,7 @@ async function authorizeBootstrapGrantRequest(store, request, body) {
|
|
|
2169
3362
|
deviceId
|
|
2170
3363
|
};
|
|
2171
3364
|
const config = readCoordinatorSyncConfig();
|
|
2172
|
-
if (!config.syncCoordinatorUrl
|
|
3365
|
+
if (!config.syncCoordinatorUrl) return {
|
|
2173
3366
|
ok: false,
|
|
2174
3367
|
reason: "bootstrap_grant_coordinator_not_configured",
|
|
2175
3368
|
deviceId
|
|
@@ -2177,11 +3370,31 @@ async function authorizeBootstrapGrantRequest(store, request, body) {
|
|
|
2177
3370
|
const [localDeviceId] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
|
|
2178
3371
|
let verification;
|
|
2179
3372
|
try {
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
3373
|
+
let payload = null;
|
|
3374
|
+
if (config.syncCoordinatorAdminSecret) {
|
|
3375
|
+
const [status, adminPayload] = await requestJson("GET", `${buildBaseUrl(config.syncCoordinatorUrl)}/v1/admin/bootstrap-grants/${encodeURIComponent(grantId)}`, {
|
|
3376
|
+
headers: { "X-Codemem-Coordinator-Admin": config.syncCoordinatorAdminSecret },
|
|
3377
|
+
timeoutS: Math.max(1, config.syncCoordinatorTimeoutS)
|
|
3378
|
+
});
|
|
3379
|
+
if (status === 200) payload = adminPayload;
|
|
3380
|
+
} else for (const groupId of config.syncCoordinatorGroups) {
|
|
3381
|
+
const lookupUrl = `${buildBaseUrl(config.syncCoordinatorUrl)}/v1/bootstrap-grants/${encodeURIComponent(grantId)}?group_id=${encodeURIComponent(groupId)}`;
|
|
3382
|
+
const [status, signedPayload] = await requestJson("GET", lookupUrl, {
|
|
3383
|
+
headers: buildAuthHeaders({
|
|
3384
|
+
deviceId: localDeviceId,
|
|
3385
|
+
method: "GET",
|
|
3386
|
+
url: lookupUrl,
|
|
3387
|
+
bodyBytes: Buffer.alloc(0),
|
|
3388
|
+
keysDir: syncKeysDir()
|
|
3389
|
+
}),
|
|
3390
|
+
timeoutS: Math.max(1, config.syncCoordinatorTimeoutS)
|
|
3391
|
+
});
|
|
3392
|
+
if (status === 200) {
|
|
3393
|
+
payload = signedPayload;
|
|
3394
|
+
break;
|
|
3395
|
+
}
|
|
3396
|
+
}
|
|
3397
|
+
if (!payload) return {
|
|
2185
3398
|
ok: false,
|
|
2186
3399
|
reason: "bootstrap_grant_lookup_failed",
|
|
2187
3400
|
deviceId
|
|
@@ -2201,6 +3414,11 @@ async function authorizeBootstrapGrantRequest(store, request, body) {
|
|
|
2201
3414
|
reason: "bootstrap_grant_invalid_payload",
|
|
2202
3415
|
deviceId
|
|
2203
3416
|
};
|
|
3417
|
+
if (config.syncCoordinatorGroups.length > 0 && !config.syncCoordinatorGroups.includes(String(grant.group_id))) return {
|
|
3418
|
+
ok: false,
|
|
3419
|
+
reason: "bootstrap_grant_group_mismatch",
|
|
3420
|
+
deviceId
|
|
3421
|
+
};
|
|
2204
3422
|
if (grant.revoked_at) return {
|
|
2205
3423
|
ok: false,
|
|
2206
3424
|
reason: "bootstrap_grant_revoked",
|
|
@@ -2221,6 +3439,13 @@ async function authorizeBootstrapGrantRequest(store, request, body) {
|
|
|
2221
3439
|
reason: "bootstrap_grant_worker_disabled",
|
|
2222
3440
|
deviceId
|
|
2223
3441
|
};
|
|
3442
|
+
const workerPublicKey = String(workerEnrollment.public_key ?? "").trim();
|
|
3443
|
+
const workerFingerprint = String(workerEnrollment.fingerprint ?? "").trim();
|
|
3444
|
+
if (!workerPublicKey || fingerprintPublicKey(workerPublicKey) !== workerFingerprint) return {
|
|
3445
|
+
ok: false,
|
|
3446
|
+
reason: "bootstrap_grant_worker_identity_invalid",
|
|
3447
|
+
deviceId
|
|
3448
|
+
};
|
|
2224
3449
|
if (grant.worker_device_id !== deviceId) return {
|
|
2225
3450
|
ok: false,
|
|
2226
3451
|
reason: "bootstrap_grant_worker_mismatch",
|
|
@@ -2245,7 +3470,7 @@ async function authorizeBootstrapGrantRequest(store, request, body) {
|
|
|
2245
3470
|
timestamp,
|
|
2246
3471
|
nonce,
|
|
2247
3472
|
signature,
|
|
2248
|
-
publicKey:
|
|
3473
|
+
publicKey: workerPublicKey,
|
|
2249
3474
|
deviceId
|
|
2250
3475
|
});
|
|
2251
3476
|
} catch {
|
|
@@ -2260,8 +3485,19 @@ async function authorizeBootstrapGrantRequest(store, request, body) {
|
|
|
2260
3485
|
reason: "bootstrap_grant_invalid_signature",
|
|
2261
3486
|
deviceId
|
|
2262
3487
|
};
|
|
3488
|
+
if (!isPeerTrustBindingCompatible(store.db, deviceId, workerPublicKey, workerFingerprint)) return {
|
|
3489
|
+
ok: false,
|
|
3490
|
+
reason: "bootstrap_grant_peer_trust_conflict",
|
|
3491
|
+
deviceId
|
|
3492
|
+
};
|
|
2263
3493
|
const nonceResult = recordSyncAuthNonce(store, deviceId, nonce, (/* @__PURE__ */ new Date()).toISOString());
|
|
2264
3494
|
if (nonceResult) return nonceResult;
|
|
3495
|
+
updatePeerAddresses(store.db, deviceId, [], {
|
|
3496
|
+
name: String(workerEnrollment.display_name ?? "").trim() || void 0,
|
|
3497
|
+
pinnedFingerprint: workerFingerprint,
|
|
3498
|
+
publicKey: workerPublicKey,
|
|
3499
|
+
replaceTrust: true
|
|
3500
|
+
});
|
|
2265
3501
|
return {
|
|
2266
3502
|
ok: true,
|
|
2267
3503
|
reason: "ok",
|
|
@@ -2348,8 +3584,12 @@ function ensureLocalActorRecord(store) {
|
|
|
2348
3584
|
is_local: 0,
|
|
2349
3585
|
updated_at: now
|
|
2350
3586
|
}).where(and(eq(schema.actors.is_local, 1), ne(schema.actors.actor_id, store.actorId))).run();
|
|
2351
|
-
|
|
2352
|
-
|
|
3587
|
+
const existing = d.select({
|
|
3588
|
+
actor_id: schema.actors.actor_id,
|
|
3589
|
+
is_local: schema.actors.is_local
|
|
3590
|
+
}).from(schema.actors).where(eq(schema.actors.actor_id, store.actorId)).get();
|
|
3591
|
+
if (existing) {
|
|
3592
|
+
if (existing.is_local !== 1) d.update(schema.actors).set({
|
|
2353
3593
|
is_local: 1,
|
|
2354
3594
|
updated_at: now
|
|
2355
3595
|
}).where(eq(schema.actors.actor_id, store.actorId)).run();
|
|
@@ -2365,6 +3605,16 @@ function ensureLocalActorRecord(store) {
|
|
|
2365
3605
|
updated_at: now
|
|
2366
3606
|
}).run();
|
|
2367
3607
|
}
|
|
3608
|
+
function ensureStableStoreIdentity(store) {
|
|
3609
|
+
const deviceId = ensureStableStoreDeviceIdentity(store);
|
|
3610
|
+
ensureLocalActorRecord(store);
|
|
3611
|
+
return deviceId;
|
|
3612
|
+
}
|
|
3613
|
+
function ensureStableStoreDeviceIdentity(store) {
|
|
3614
|
+
const [deviceId] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
|
|
3615
|
+
store.adoptEnsuredDeviceIdentity(deviceId);
|
|
3616
|
+
return deviceId;
|
|
3617
|
+
}
|
|
2368
3618
|
function findPeerDeviceIdForAddress(store, address) {
|
|
2369
3619
|
const rows = store.db.prepare("SELECT peer_device_id, addresses_json FROM sync_peers").all();
|
|
2370
3620
|
for (const row of rows) if (safeJsonList(row.addresses_json).includes(address)) return String(row.peer_device_id ?? "").trim() || null;
|
|
@@ -2615,7 +3865,8 @@ async function acceptDiscoveredPeer(store, input) {
|
|
|
2615
3865
|
function filterOpsForPeer(store, peerDeviceId, localDeviceId, ops, options = {}) {
|
|
2616
3866
|
const [allowed, , skipped] = filterReplicationOpsForSyncWithStatus(store.db, ops, peerDeviceId, {
|
|
2617
3867
|
localDeviceId,
|
|
2618
|
-
applyScopeFilter: options.applyScopeFilter
|
|
3868
|
+
applyScopeFilter: options.applyScopeFilter,
|
|
3869
|
+
supportsReassignScope: options.supportsReassignScope
|
|
2619
3870
|
});
|
|
2620
3871
|
return {
|
|
2621
3872
|
allowed,
|
|
@@ -3132,6 +4383,7 @@ function syncProtocolRoutes(getStore, opts = {}) {
|
|
|
3132
4383
|
if (isSyncAuthStoreBusy(auth)) return syncAuthStoreBusyResponse(c);
|
|
3133
4384
|
let preauthChecked = false;
|
|
3134
4385
|
let bootstrapAttempted = false;
|
|
4386
|
+
let deviceAuthReason = null;
|
|
3135
4387
|
if (!auth.ok) {
|
|
3136
4388
|
if ((c.req.header("X-Codemem-Bootstrap-Grant") ?? "").trim()) {
|
|
3137
4389
|
preauthChecked = true;
|
|
@@ -3142,19 +4394,21 @@ function syncProtocolRoutes(getStore, opts = {}) {
|
|
|
3142
4394
|
const unauthLimited = rateLimitedResponse(c, c.req.path, false);
|
|
3143
4395
|
if (unauthLimited) return unauthLimited;
|
|
3144
4396
|
}
|
|
4397
|
+
deviceAuthReason = auth.reason;
|
|
3145
4398
|
auth = await authorizeBootstrapGrantRequest(store, c.req, Buffer.alloc(0));
|
|
3146
4399
|
bootstrapAttempted = true;
|
|
3147
4400
|
}
|
|
3148
4401
|
if (isSyncAuthStoreBusy(auth)) return syncAuthStoreBusyResponse(c);
|
|
3149
4402
|
if (!auth.ok) {
|
|
3150
4403
|
const grantPresent = Boolean((c.req.header("X-Codemem-Bootstrap-Grant") ?? "").trim());
|
|
3151
|
-
if (bootstrapAttempted && grantPresent) console.warn(`[sync]
|
|
3152
|
-
const wireReason = bootstrapAttempted ? "bootstrap_grant_invalid" : auth.reason;
|
|
4404
|
+
if (bootstrapAttempted && (grantPresent || deviceAuthReason != null && deviceAuthReason !== "missing_headers")) console.warn(`[sync] peer auth failed: reason=${auth.reason} device_auth_reason=${deviceAuthReason ?? "none"} grant_present=${grantPresent} path=${c.req.path} device=${loggableHeaderValue(c.req.header("X-Opencode-Device"))}`);
|
|
4405
|
+
const wireReason = bootstrapAttempted && grantPresent ? "bootstrap_grant_invalid" : deviceAuthReason ?? auth.reason;
|
|
3153
4406
|
return (preauthChecked ? null : rateLimitedResponse(c, c.req.path, false)) ?? c.json(unauthorizedPayload(wireReason, true), 401);
|
|
3154
4407
|
}
|
|
3155
4408
|
const limited = rateLimitedResponse(c, auth.deviceId, true);
|
|
3156
4409
|
if (limited) return limited;
|
|
3157
4410
|
try {
|
|
4411
|
+
if (c.req.header(SYNC_AUTHORIZATION_REFRESH_HEADER) === "1" && supportsSyncFeature(c.req.header(SYNC_FEATURES_HEADER), "reassign_scope")) await refreshConfiguredScopeMembershipCache(store.db, void 0, { keysDir: syncKeysDir() });
|
|
3158
4412
|
const [deviceId, fingerprint] = ensureDeviceIdentity(store.db, { keysDir: syncKeysDir() });
|
|
3159
4413
|
const syncReset = getSyncResetState(store.db);
|
|
3160
4414
|
const authorizedScopes = isScopedSyncCapability(negotiatedSyncCapability(c)) ? listAuthorizedScopesForPeer(store.db, {
|
|
@@ -3166,7 +4420,8 @@ function syncProtocolRoutes(getStore, opts = {}) {
|
|
|
3166
4420
|
protocol_version: SYNC_PROTOCOL_VERSION,
|
|
3167
4421
|
fingerprint,
|
|
3168
4422
|
sync_reset: addSyncScopeToBoundary(syncReset, null),
|
|
3169
|
-
sync_capability: LOCAL_SYNC_CAPABILITY
|
|
4423
|
+
sync_capability: LOCAL_SYNC_CAPABILITY,
|
|
4424
|
+
sync_features: LOCAL_SYNC_FEATURES
|
|
3170
4425
|
};
|
|
3171
4426
|
if (authorizedScopes !== null) response.authorized_scopes = authorizedScopes;
|
|
3172
4427
|
return c.json(response);
|
|
@@ -3216,7 +4471,7 @@ function syncProtocolRoutes(getStore, opts = {}) {
|
|
|
3216
4471
|
...addSyncScopeToBoundary(result.reset, scopeRequest.scope_id)
|
|
3217
4472
|
}, 409);
|
|
3218
4473
|
const { ops, nextCursor, boundary } = result;
|
|
3219
|
-
const filtered = filterOpsForPeer(store, peerDeviceId, localDeviceId, ops);
|
|
4474
|
+
const filtered = filterOpsForPeer(store, peerDeviceId, localDeviceId, ops, { supportsReassignScope: supportsSyncFeature(c.req.header(SYNC_FEATURES_HEADER), "reassign_scope") });
|
|
3220
4475
|
return c.json({
|
|
3221
4476
|
reset_required: false,
|
|
3222
4477
|
sync_capability: LOCAL_SYNC_CAPABILITY,
|
|
@@ -3241,6 +4496,7 @@ function syncProtocolRoutes(getStore, opts = {}) {
|
|
|
3241
4496
|
if (isSyncAuthStoreBusy(auth)) return syncAuthStoreBusyResponse(c);
|
|
3242
4497
|
let preauthChecked = false;
|
|
3243
4498
|
let bootstrapAttempted = false;
|
|
4499
|
+
let deviceAuthReason = null;
|
|
3244
4500
|
if (!auth.ok) {
|
|
3245
4501
|
if ((c.req.header("X-Codemem-Bootstrap-Grant") ?? "").trim()) {
|
|
3246
4502
|
preauthChecked = true;
|
|
@@ -3251,14 +4507,15 @@ function syncProtocolRoutes(getStore, opts = {}) {
|
|
|
3251
4507
|
const unauthLimited = rateLimitedResponse(c, c.req.path, false);
|
|
3252
4508
|
if (unauthLimited) return unauthLimited;
|
|
3253
4509
|
}
|
|
4510
|
+
deviceAuthReason = auth.reason;
|
|
3254
4511
|
auth = await authorizeBootstrapGrantRequest(store, c.req, Buffer.alloc(0));
|
|
3255
4512
|
bootstrapAttempted = true;
|
|
3256
4513
|
}
|
|
3257
4514
|
if (isSyncAuthStoreBusy(auth)) return syncAuthStoreBusyResponse(c);
|
|
3258
4515
|
if (!auth.ok) {
|
|
3259
4516
|
const grantPresent = Boolean((c.req.header("X-Codemem-Bootstrap-Grant") ?? "").trim());
|
|
3260
|
-
if (bootstrapAttempted && grantPresent) console.warn(`[sync]
|
|
3261
|
-
const wireReason = bootstrapAttempted ? "bootstrap_grant_invalid" : auth.reason;
|
|
4517
|
+
if (bootstrapAttempted && (grantPresent || deviceAuthReason != null && deviceAuthReason !== "missing_headers")) console.warn(`[sync] peer auth failed: reason=${auth.reason} device_auth_reason=${deviceAuthReason ?? "none"} grant_present=${grantPresent} path=${c.req.path} device=${loggableHeaderValue(c.req.header("X-Opencode-Device"))}`);
|
|
4518
|
+
const wireReason = bootstrapAttempted && grantPresent ? "bootstrap_grant_invalid" : deviceAuthReason ?? auth.reason;
|
|
3262
4519
|
return (preauthChecked ? null : rateLimitedResponse(c, c.req.path, false)) ?? c.json(unauthorizedPayload(wireReason, true), 401);
|
|
3263
4520
|
}
|
|
3264
4521
|
const limited = rateLimitedResponse(c, auth.deviceId, true);
|
|
@@ -3340,12 +4597,23 @@ function syncProtocolRoutes(getStore, opts = {}) {
|
|
|
3340
4597
|
if (!Array.isArray(body.ops)) return c.json({ error: "invalid_ops" }, 400);
|
|
3341
4598
|
if (body.ops.length > MAX_SYNC_OPS) return c.json({ error: "too_many_ops" }, 413);
|
|
3342
4599
|
const normalizedOps = extractReplicationOps(body);
|
|
4600
|
+
const peerFeatures = normalizeSyncFeatures(body.sync_features);
|
|
4601
|
+
const reassignOps = normalizedOps.filter((op) => op.op_type === "reassign_scope");
|
|
4602
|
+
if (reassignOps.length > 0 && !peerFeatures.includes("reassign_scope")) return c.json({ error: "reassign_capability_required" }, 409);
|
|
4603
|
+
try {
|
|
4604
|
+
for (const op of reassignOps) parseReassignScopePayload(op);
|
|
4605
|
+
} catch {
|
|
4606
|
+
return c.json({ error: "reassign_payload_invalid" }, 400);
|
|
4607
|
+
}
|
|
3343
4608
|
for (const op of normalizedOps) if (op.device_id !== peerDeviceId || op.clock_device_id !== peerDeviceId) return c.json({
|
|
3344
4609
|
error: "invalid_op_device",
|
|
3345
4610
|
reason: "device_id_mismatch",
|
|
3346
4611
|
op_id: op.op_id
|
|
3347
4612
|
}, 400);
|
|
3348
|
-
const filtered = filterOpsForPeer(store, peerDeviceId, localDeviceId, normalizedOps, {
|
|
4613
|
+
const filtered = filterOpsForPeer(store, peerDeviceId, localDeviceId, normalizedOps, {
|
|
4614
|
+
applyScopeFilter: false,
|
|
4615
|
+
supportsReassignScope: peerFeatures.includes("reassign_scope")
|
|
4616
|
+
});
|
|
3349
4617
|
const rejected = rejectInboundScopeFailures(store.db, normalizedOps, localDeviceId, {
|
|
3350
4618
|
enabled: negotiated !== "unsupported",
|
|
3351
4619
|
peerDeviceId
|
|
@@ -3411,6 +4679,23 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
3411
4679
|
}
|
|
3412
4680
|
const lastSyncRow = traceSync("lastSyncRow", () => d.select({ last_sync_at: max(schema.syncPeers.last_sync_at) }).from(schema.syncPeers).get());
|
|
3413
4681
|
const semanticIndex = traceSync("semanticIndex", () => redactSemanticIndexDiagnostics(getSemanticIndexDiagnostics(store.db), showDiag));
|
|
4682
|
+
const enrollmentIssueSummary = traceSync("coordinatorEnrollmentIssues", () => getCoordinatorEnrollmentReconciliationIssueSummary(store.db));
|
|
4683
|
+
const enrollmentIssueStatus = {
|
|
4684
|
+
counts: enrollmentIssueSummary.counts,
|
|
4685
|
+
...showDiag ? { issues: enrollmentIssueSummary.issues.map((issue) => ({
|
|
4686
|
+
coordinator_id: issue.coordinatorId,
|
|
4687
|
+
group_id: issue.groupId,
|
|
4688
|
+
kind: issue.kind,
|
|
4689
|
+
reference_id: issue.referenceId,
|
|
4690
|
+
code: issue.code,
|
|
4691
|
+
status: issue.status,
|
|
4692
|
+
first_seen_at: issue.firstSeenAt,
|
|
4693
|
+
last_seen_at: issue.lastSeenAt,
|
|
4694
|
+
resolved_at: issue.resolvedAt,
|
|
4695
|
+
occurrence_count: issue.occurrenceCount,
|
|
4696
|
+
updated_at: issue.updatedAt
|
|
4697
|
+
})) } : {}
|
|
4698
|
+
};
|
|
3414
4699
|
const lastError = daemonState?.last_error;
|
|
3415
4700
|
const lastErrorAt = daemonState?.last_error_at;
|
|
3416
4701
|
const lastOkAt = daemonState?.last_ok_at;
|
|
@@ -3438,6 +4723,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
3438
4723
|
last_error_at: retentionState?.last_error_at ?? null
|
|
3439
4724
|
},
|
|
3440
4725
|
semantic_index: semanticIndex,
|
|
4726
|
+
coordinator_enrollment_reconciliation_issues: enrollmentIssueStatus,
|
|
3441
4727
|
peer_count: Number(peerCountRow?.total ?? 0),
|
|
3442
4728
|
last_sync_at: lastSyncRow?.last_sync_at ?? null,
|
|
3443
4729
|
daemon_state: daemonStateValue,
|
|
@@ -3510,6 +4796,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
3510
4796
|
const legacyDevices = traceSync("legacyDevices", () => store.claimableLegacyDeviceIds());
|
|
3511
4797
|
const legacyReview = traceSync("legacySharedReview", () => legacySharedReviewSummary(store));
|
|
3512
4798
|
const sharingReview = traceSync("sharingReview", () => store.sharingReviewSummary(project));
|
|
4799
|
+
const recipientPolicyReconciliation = traceSync("recipientPolicyReconciliation", () => listRecipientPolicyReconciliationStatus(store));
|
|
3513
4800
|
let joinRequests = [];
|
|
3514
4801
|
if (includeJoinRequests && showDiag && config.syncCoordinatorAdminSecret) try {
|
|
3515
4802
|
joinRequests = await listCoordinatorJoinRequests(config);
|
|
@@ -3545,6 +4832,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
3545
4832
|
legacy_devices: legacyDevices,
|
|
3546
4833
|
legacy_shared_review: legacyReview,
|
|
3547
4834
|
sharing_review: sharingReview,
|
|
4835
|
+
recipient_policy_reconciliation: recipientPolicyReconciliation,
|
|
3548
4836
|
coordinator
|
|
3549
4837
|
};
|
|
3550
4838
|
if (includeJoinRequests && showDiag) responsePayload.join_requests = joinRequests;
|
|
@@ -3698,7 +4986,10 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
3698
4986
|
else peerIds = store.db.prepare("SELECT peer_device_id FROM sync_peers").all().map((row) => String(row.peer_device_id ?? "").trim()).filter(Boolean);
|
|
3699
4987
|
const items = [];
|
|
3700
4988
|
for (const peerId of peerIds) {
|
|
3701
|
-
const result = await runSyncPass(store.db, peerId, {
|
|
4989
|
+
const result = await runSyncPass(store.db, peerId, {
|
|
4990
|
+
keysDir: syncKeysDir(),
|
|
4991
|
+
scanner: store.scanner
|
|
4992
|
+
});
|
|
3702
4993
|
items.push({
|
|
3703
4994
|
peer_device_id: peerId,
|
|
3704
4995
|
...result,
|
|
@@ -3747,18 +5038,245 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
3747
5038
|
local_default_scope_id: LOCAL_DEFAULT_SCOPE_ID
|
|
3748
5039
|
});
|
|
3749
5040
|
});
|
|
3750
|
-
app.get("/api/sync/
|
|
5041
|
+
app.get("/api/sync/recipient-policy/v1/projection", (c) => {
|
|
5042
|
+
const store = getStore();
|
|
5043
|
+
return c.json(listLegacyRecipientPolicyProjections(store.db, {
|
|
5044
|
+
localActorId: store.actorId,
|
|
5045
|
+
localDeviceId: store.deviceId
|
|
5046
|
+
}));
|
|
5047
|
+
});
|
|
5048
|
+
app.get("/api/sync/recipient-policy/v1/review", (c) => {
|
|
5049
|
+
const store = getStore();
|
|
5050
|
+
ensureLocalActorRecord(store);
|
|
5051
|
+
return c.json(listRecipientPolicyReview(store.db, {
|
|
5052
|
+
localActorId: store.actorId,
|
|
5053
|
+
localDeviceId: store.deviceId
|
|
5054
|
+
}));
|
|
5055
|
+
});
|
|
5056
|
+
app.get("/api/sync/recipient-policy/v1/intent", (c) => {
|
|
5057
|
+
const store = getStore();
|
|
5058
|
+
return c.json(listRecipientPolicyIntent(store.db));
|
|
5059
|
+
});
|
|
5060
|
+
app.get("/api/sync/recipient-policy/v1/reconciliation-status", (c) => {
|
|
5061
|
+
return c.json(listRecipientPolicyReconciliationStatus(getStore()));
|
|
5062
|
+
});
|
|
5063
|
+
app.post("/api/sync/recipient-policy/v1/edges/preview", async (c) => {
|
|
5064
|
+
const store = getStore();
|
|
5065
|
+
const body = await parseViewerJsonBody(c);
|
|
5066
|
+
try {
|
|
5067
|
+
return c.json(previewRecipientPolicyEdges(store.db, body));
|
|
5068
|
+
} catch (error) {
|
|
5069
|
+
if (error instanceof RecipientPolicyEdgeRequestError) {
|
|
5070
|
+
if (error.status === "not_found") return c.json({ error: error.errorCode }, 404);
|
|
5071
|
+
return c.json({ error: error.errorCode }, 400);
|
|
5072
|
+
}
|
|
5073
|
+
if (isSqliteBusy(error)) {
|
|
5074
|
+
c.header("Retry-After", "1");
|
|
5075
|
+
return c.json({ error: "edge_preview_busy" }, 503);
|
|
5076
|
+
}
|
|
5077
|
+
return c.json({ error: "edge_preview_failed" }, 500);
|
|
5078
|
+
}
|
|
5079
|
+
});
|
|
5080
|
+
app.post("/api/sync/recipient-policy/v1/edges/commit", async (c) => {
|
|
5081
|
+
const store = getStore();
|
|
5082
|
+
const body = await parseViewerJsonBody(c);
|
|
5083
|
+
try {
|
|
5084
|
+
const result = commitRecipientPolicyEdges(store.db, body);
|
|
5085
|
+
if (result.status === "invalid") return c.json(result, 400);
|
|
5086
|
+
if (result.status === "not_found") return c.json(result, 404);
|
|
5087
|
+
if (result.status === "stale" || result.status === "conflict") return c.json(result, 409);
|
|
5088
|
+
return c.json(result);
|
|
5089
|
+
} catch (error) {
|
|
5090
|
+
if (isSqliteBusy(error)) {
|
|
5091
|
+
c.header("Retry-After", "1");
|
|
5092
|
+
return c.json({ error: "edge_commit_busy" }, 503);
|
|
5093
|
+
}
|
|
5094
|
+
return c.json({
|
|
5095
|
+
version: 1,
|
|
5096
|
+
status: "conflict",
|
|
5097
|
+
reviewedPolicyDigest: "",
|
|
5098
|
+
errorCode: "edge_commit_conflict",
|
|
5099
|
+
outcomes: [],
|
|
5100
|
+
writeCount: 0,
|
|
5101
|
+
idempotent: false
|
|
5102
|
+
}, 409);
|
|
5103
|
+
}
|
|
5104
|
+
});
|
|
5105
|
+
app.post("/api/sync/recipient-policy/v1/invites/preview", async (c) => {
|
|
5106
|
+
const store = getStore();
|
|
5107
|
+
const body = await parseViewerJsonBody(c);
|
|
5108
|
+
if (!body) return c.json({ error: "request_invalid" }, 400);
|
|
5109
|
+
try {
|
|
5110
|
+
const kind = recipientInviteKind(body.kind ?? body.invite_kind);
|
|
5111
|
+
const unavailable = recipientInviteCreationUnavailable(coordinatorAdminStatusPayload(readCoordinatorSyncConfig()), kind);
|
|
5112
|
+
if (unavailable) return c.json(unavailable.body, unavailable.httpStatus);
|
|
5113
|
+
const targetId = kind === "team_member" ? optionalViewerStrictString(body, "policy_team_id") : optionalViewerStrictString(body, "target_identity_id");
|
|
5114
|
+
if (!targetId) throw new Error("recipient_invite_metadata_invalid");
|
|
5115
|
+
if (kind === "add_device" && targetId !== store.actorId) throw new Error("invite_identity_conflict");
|
|
5116
|
+
const preview = recipientInviteOnboardingPreview(store, body, recipientInvitePreviewId(kind, targetId));
|
|
5117
|
+
return c.json({
|
|
5118
|
+
kind,
|
|
5119
|
+
preview
|
|
5120
|
+
});
|
|
5121
|
+
} catch (error) {
|
|
5122
|
+
return c.json({ error: error instanceof Error ? error.message : "recipient_invite_invalid" }, 400);
|
|
5123
|
+
}
|
|
5124
|
+
});
|
|
5125
|
+
app.post("/api/sync/recipient-policy/v1/invites", async (c) => {
|
|
5126
|
+
const store = getStore();
|
|
5127
|
+
const body = await parseViewerJsonBody(c);
|
|
5128
|
+
if (!body) return c.json({ error: "request_invalid" }, 400);
|
|
5129
|
+
try {
|
|
5130
|
+
const kind = recipientInviteKind(body.kind ?? body.invite_kind);
|
|
5131
|
+
const config = readCoordinatorSyncConfig();
|
|
5132
|
+
const status = coordinatorAdminStatusPayload(config);
|
|
5133
|
+
const unavailable = recipientInviteCreationUnavailable(status, kind);
|
|
5134
|
+
if (unavailable) return c.json(unavailable.body, unavailable.httpStatus);
|
|
5135
|
+
const targetId = kind === "team_member" ? optionalViewerStrictString(body, "policy_team_id") : optionalViewerStrictString(body, "target_identity_id");
|
|
5136
|
+
if (!targetId) throw new Error("recipient_invite_metadata_invalid");
|
|
5137
|
+
if (kind === "add_device" && targetId !== store.actorId) throw new Error("invite_identity_conflict");
|
|
5138
|
+
const preview = recipientInviteOnboardingPreview(store, body, recipientInvitePreviewId(kind, targetId));
|
|
5139
|
+
const reviewedDigest = optionalViewerStrictString(body, "reviewed_onboarding_digest");
|
|
5140
|
+
if (!reviewedDigest) throw new Error("reviewed_onboarding_digest_required");
|
|
5141
|
+
if (reviewedDigest !== preview.reviewedOnboardingDigest) return c.json({
|
|
5142
|
+
error: "reviewed_onboarding_stale",
|
|
5143
|
+
preview
|
|
5144
|
+
}, 409);
|
|
5145
|
+
const reviewedIntent = recipientInviteReviewedIntent(store, kind, targetId, preview);
|
|
5146
|
+
const reviewedPreviewDigest = await recipientReviewedIntentDigest(reviewedIntent);
|
|
5147
|
+
const groupId = resolveCoordinatorAdminGroup(optionalViewerString(body, "group_id"), status);
|
|
5148
|
+
const ttlHours = parseInviteTtlHours(body.ttl_hours);
|
|
5149
|
+
if (!groupId) throw new Error("group_id_required");
|
|
5150
|
+
if (ttlHours == null) throw new Error("ttl_hours_invalid");
|
|
5151
|
+
const result = kind === "team_member" || status.has_admin_secret ? await coordinatorCreateInviteAction({
|
|
5152
|
+
groupId,
|
|
5153
|
+
coordinatorUrl: config.syncCoordinatorUrl || null,
|
|
5154
|
+
policy: "auto_admit",
|
|
5155
|
+
ttlHours,
|
|
5156
|
+
createdBy: store.actorId,
|
|
5157
|
+
remoteUrl: config.syncCoordinatorUrl || null,
|
|
5158
|
+
adminSecret: config.syncCoordinatorAdminSecret || null,
|
|
5159
|
+
inviteKind: kind,
|
|
5160
|
+
policyTeamId: kind === "team_member" ? targetId : null,
|
|
5161
|
+
targetIdentityId: kind === "add_device" ? targetId : null,
|
|
5162
|
+
inviterDeviceId: kind === "add_device" ? preview.binding.deviceId : null,
|
|
5163
|
+
reviewedPreviewDigest,
|
|
5164
|
+
reviewedIntent
|
|
5165
|
+
}) : await coordinatorCreateAddDeviceInviteAction({
|
|
5166
|
+
groupId,
|
|
5167
|
+
coordinatorUrl: config.syncCoordinatorUrl || null,
|
|
5168
|
+
ttlHours,
|
|
5169
|
+
deviceId: preview.binding.deviceId,
|
|
5170
|
+
keysDir: syncKeysDir(),
|
|
5171
|
+
remoteUrl: config.syncCoordinatorUrl || null,
|
|
5172
|
+
reviewedPreviewDigest,
|
|
5173
|
+
reviewedIntent
|
|
5174
|
+
});
|
|
5175
|
+
return c.json({
|
|
5176
|
+
ok: true,
|
|
5177
|
+
kind,
|
|
5178
|
+
preview,
|
|
5179
|
+
invite: result
|
|
5180
|
+
});
|
|
5181
|
+
} catch (error) {
|
|
5182
|
+
return c.json({ error: error instanceof Error ? error.message : "recipient_invite_failed" }, 400);
|
|
5183
|
+
}
|
|
5184
|
+
});
|
|
5185
|
+
app.post("/api/sync/recipient-policy/v1/migrate", async (c) => {
|
|
5186
|
+
const store = getStore();
|
|
5187
|
+
const value = await parseViewerJsonBody(c);
|
|
5188
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return c.json({ error: "request_invalid" }, 400);
|
|
5189
|
+
const body = value;
|
|
5190
|
+
if (Object.keys(body).some((key) => key !== "dryRun") || Object.hasOwn(body, "dryRun") && typeof body.dryRun !== "boolean") return c.json({ error: "request_invalid" }, 400);
|
|
5191
|
+
return c.json(migrateRecipientPolicyIntent(store.db, {
|
|
5192
|
+
localActorId: store.actorId,
|
|
5193
|
+
localDeviceId: store.deviceId
|
|
5194
|
+
}, { dryRun: body.dryRun === true }));
|
|
5195
|
+
});
|
|
5196
|
+
const parseReviewRequest = (value) => {
|
|
5197
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
5198
|
+
const body = value;
|
|
5199
|
+
const allowedKeys = new Set([
|
|
5200
|
+
"reviewItemId",
|
|
5201
|
+
"sourceFingerprint",
|
|
5202
|
+
"decision",
|
|
5203
|
+
"decisionInput"
|
|
5204
|
+
]);
|
|
5205
|
+
if (Object.keys(body).some((key) => !allowedKeys.has(key)) || typeof body.reviewItemId !== "string" || typeof body.sourceFingerprint !== "string" || typeof body.decision !== "string") return null;
|
|
5206
|
+
return {
|
|
5207
|
+
reviewItemId: body.reviewItemId,
|
|
5208
|
+
sourceFingerprint: body.sourceFingerprint,
|
|
5209
|
+
decision: body.decision,
|
|
5210
|
+
...Object.hasOwn(body, "decisionInput") ? { decisionInput: body.decisionInput } : {}
|
|
5211
|
+
};
|
|
5212
|
+
};
|
|
5213
|
+
app.post("/api/sync/recipient-policy/v1/review/resolve", async (c) => {
|
|
5214
|
+
const store = getStore();
|
|
5215
|
+
const request = parseReviewRequest(await parseViewerJsonBody(c));
|
|
5216
|
+
if (!request) return c.json({ error: "request_invalid" }, 400);
|
|
5217
|
+
ensureLocalActorRecord(store);
|
|
5218
|
+
const result = resolveRecipientPolicyReview(store.db, {
|
|
5219
|
+
localActorId: store.actorId,
|
|
5220
|
+
localDeviceId: store.deviceId
|
|
5221
|
+
}, request);
|
|
5222
|
+
if (result.status === "stale" || result.status === "conflict") return c.json(result, 409);
|
|
5223
|
+
if (result.status === "not_found") return c.json(result, 404);
|
|
5224
|
+
if (result.status === "invalid") return c.json(result, 400);
|
|
5225
|
+
return c.json(result);
|
|
5226
|
+
});
|
|
5227
|
+
app.post("/api/sync/recipient-policy/v1/review/resolve-bulk", async (c) => {
|
|
5228
|
+
const store = getStore();
|
|
5229
|
+
const body = await parseViewerJsonBody(c);
|
|
5230
|
+
if (!body || !Array.isArray(body.requests) || body.requests.length === 0 || body.requests.length > 100) return c.json({ error: "request_invalid" }, 400);
|
|
5231
|
+
const requests = body.requests.map(parseReviewRequest);
|
|
5232
|
+
if (requests.some((request) => request == null)) return c.json({ error: "request_invalid" }, 400);
|
|
5233
|
+
ensureLocalActorRecord(store);
|
|
5234
|
+
const result = resolveRecipientPolicyReviewBulk(store.db, {
|
|
5235
|
+
localActorId: store.actorId,
|
|
5236
|
+
localDeviceId: store.deviceId
|
|
5237
|
+
}, requests);
|
|
5238
|
+
return c.json(result, result.results.some((item) => item.status !== "applied") ? 207 : 200);
|
|
5239
|
+
});
|
|
5240
|
+
app.get("/api/sync/projects", async (c) => {
|
|
3751
5241
|
const store = getStore();
|
|
3752
5242
|
const limit = Math.max(1, queryInt(c.req.query("limit"), 50));
|
|
3753
5243
|
const offset = Math.max(0, queryInt(c.req.query("offset"), 0));
|
|
3754
|
-
|
|
5244
|
+
const inventory = listProjectScopeInventory(store.db, {
|
|
3755
5245
|
identitySource: c.req.query("identity_source"),
|
|
3756
5246
|
limit,
|
|
3757
5247
|
offset,
|
|
3758
5248
|
query: c.req.query("q"),
|
|
3759
5249
|
scopeId: c.req.query("scope_id"),
|
|
3760
5250
|
status: c.req.query("status")
|
|
3761
|
-
})
|
|
5251
|
+
});
|
|
5252
|
+
const operations = await shareOperationReadModels(store, void 0, false);
|
|
5253
|
+
const operationById = new Map(operations.map((operation) => [operation.operation_id, operation]));
|
|
5254
|
+
const sharingByProject = /* @__PURE__ */ new Map();
|
|
5255
|
+
const reviewed = store.db.prepare(`SELECT p.operation_id, p.canonical_project_identity
|
|
5256
|
+
FROM share_operation_projects p
|
|
5257
|
+
JOIN share_operations o ON o.operation_id = p.operation_id
|
|
5258
|
+
WHERE o.inviter_actor_id = ? ORDER BY o.created_at, p.ordinal`).all(store.actorId);
|
|
5259
|
+
for (const item of reviewed) {
|
|
5260
|
+
const operation = operationById.get(item.operation_id);
|
|
5261
|
+
if (!operation) continue;
|
|
5262
|
+
const current = sharingByProject.get(item.canonical_project_identity) ?? [];
|
|
5263
|
+
current.push({
|
|
5264
|
+
person: operation.person,
|
|
5265
|
+
lifecycle: {
|
|
5266
|
+
state: operation.lifecycle.state,
|
|
5267
|
+
label: operation.lifecycle.label,
|
|
5268
|
+
explanation: operation.lifecycle.explanation
|
|
5269
|
+
}
|
|
5270
|
+
});
|
|
5271
|
+
sharingByProject.set(item.canonical_project_identity, current);
|
|
5272
|
+
}
|
|
5273
|
+
return c.json({
|
|
5274
|
+
...inventory,
|
|
5275
|
+
projects: inventory.projects.map((project) => ({
|
|
5276
|
+
...project,
|
|
5277
|
+
sharing: sharingByProject.get(project.workspace_identity) ?? []
|
|
5278
|
+
}))
|
|
5279
|
+
});
|
|
3762
5280
|
});
|
|
3763
5281
|
app.post("/api/sync/projects/reassign-project", async (c) => {
|
|
3764
5282
|
const store = getStore();
|
|
@@ -3808,6 +5326,175 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
3808
5326
|
return c.json({ error: message }, message.includes("not found") ? 404 : 400);
|
|
3809
5327
|
}
|
|
3810
5328
|
});
|
|
5329
|
+
app.post("/api/sync/project-invites/preview", async (c) => {
|
|
5330
|
+
const store = getStore();
|
|
5331
|
+
const body = await parseViewerJsonBody(c);
|
|
5332
|
+
if (!body) return c.json({ error: "invalid_json" }, 400);
|
|
5333
|
+
try {
|
|
5334
|
+
assertProjectInviteBody(body, false);
|
|
5335
|
+
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5336
|
+
const { plan } = projectInvitePlan(store, body, createdAt, new Date(new Date(createdAt).getTime() + PROJECT_INVITE_TTL_HOURS * 3600 * 1e3).toISOString());
|
|
5337
|
+
return c.json(projectInvitePreview(plan));
|
|
5338
|
+
} catch (error) {
|
|
5339
|
+
return c.json({ error: error instanceof Error ? error.message : "project_invite_invalid" }, 400);
|
|
5340
|
+
}
|
|
5341
|
+
});
|
|
5342
|
+
app.post("/api/sync/project-invites", async (c) => {
|
|
5343
|
+
const store = getStore();
|
|
5344
|
+
const body = await parseViewerJsonBody(c);
|
|
5345
|
+
if (!body) return c.json({ error: "invalid_json" }, 400);
|
|
5346
|
+
try {
|
|
5347
|
+
assertProjectInviteBody(body, true);
|
|
5348
|
+
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5349
|
+
const { plan, config } = projectInvitePlan(store, body, createdAt, new Date(new Date(createdAt).getTime() + PROJECT_INVITE_TTL_HOURS * 3600 * 1e3).toISOString());
|
|
5350
|
+
if (String(body.reviewed_project_set_digest).trim() !== plan.reviewedProjectSetDigest) return c.json({ error: "reviewed_project_set_changed" }, 409);
|
|
5351
|
+
const existingState = (() => {
|
|
5352
|
+
try {
|
|
5353
|
+
return store.db.prepare("SELECT state FROM share_operations WHERE operation_id = ?").pluck().get(plan.operationId);
|
|
5354
|
+
} catch {
|
|
5355
|
+
return;
|
|
5356
|
+
}
|
|
5357
|
+
})();
|
|
5358
|
+
if (existingState && existingState !== "waiting_for_acceptance") return c.json({ error: "operation_state_invalid" }, 409);
|
|
5359
|
+
const result = await coordinatorCreateInviteAction({
|
|
5360
|
+
groupId: plan.coordinatorGroupId,
|
|
5361
|
+
coordinatorUrl: config.syncCoordinatorUrl || null,
|
|
5362
|
+
policy: "auto_admit",
|
|
5363
|
+
ttlHours: PROJECT_INVITE_TTL_HOURS,
|
|
5364
|
+
createdBy: store.actorId,
|
|
5365
|
+
remoteUrl: config.syncCoordinatorUrl || null,
|
|
5366
|
+
adminSecret: config.syncCoordinatorAdminSecret || null,
|
|
5367
|
+
operationId: plan.operationId,
|
|
5368
|
+
reviewedProjectSetDigest: plan.reviewedProjectSetDigest,
|
|
5369
|
+
inviterActorId: plan.inviterActorId,
|
|
5370
|
+
inviterDisplayName: store.actorDisplayName,
|
|
5371
|
+
inviterDeviceId: plan.inviterDeviceIds[0],
|
|
5372
|
+
pendingPersonId: plan.personId,
|
|
5373
|
+
projectSummaries: plan.projects.map((project) => ({
|
|
5374
|
+
display_name: project.displayName,
|
|
5375
|
+
existing_memory_count: project.existingMemoryCount
|
|
5376
|
+
})),
|
|
5377
|
+
projectIntent: plan.projects.map((project) => ({
|
|
5378
|
+
canonical_identity: project.canonicalIdentity,
|
|
5379
|
+
display_name: project.displayName,
|
|
5380
|
+
existing_memory_count: project.existingMemoryCount
|
|
5381
|
+
}))
|
|
5382
|
+
});
|
|
5383
|
+
if (String(result.operation_id ?? "").trim() !== plan.operationId || String(result.reviewed_project_set_digest ?? "").trim() !== plan.reviewedProjectSetDigest) throw new Error("coordinator_invite_intent_mismatch");
|
|
5384
|
+
const payload = result.payload;
|
|
5385
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new Error("coordinator_invite_payload_missing");
|
|
5386
|
+
const token = String(payload.token ?? "").trim();
|
|
5387
|
+
const expiresAt = String(payload.expires_at ?? "").trim();
|
|
5388
|
+
const link = String(result.link ?? "").trim();
|
|
5389
|
+
const encoded = String(result.encoded ?? "").trim();
|
|
5390
|
+
if (!token || !expiresAt || !link || !encoded) throw new Error("coordinator_invite_payload_invalid");
|
|
5391
|
+
const persistedPlan = {
|
|
5392
|
+
...plan,
|
|
5393
|
+
inviteExpiresAt: new Date(expiresAt).toISOString()
|
|
5394
|
+
};
|
|
5395
|
+
persistShareOperation(store.db, persistedPlan, {
|
|
5396
|
+
inviteId: String(result.invite_id ?? "").trim() || null,
|
|
5397
|
+
tokenDigest: inviteTokenDigest(token)
|
|
5398
|
+
});
|
|
5399
|
+
return c.json({
|
|
5400
|
+
ok: true,
|
|
5401
|
+
...projectInvitePreview(persistedPlan),
|
|
5402
|
+
invite: {
|
|
5403
|
+
link,
|
|
5404
|
+
encoded,
|
|
5405
|
+
expires_at: persistedPlan.inviteExpiresAt
|
|
5406
|
+
}
|
|
5407
|
+
});
|
|
5408
|
+
} catch (error) {
|
|
5409
|
+
return c.json({ error: error instanceof Error ? error.message : "project_invite_failed" }, 400);
|
|
5410
|
+
}
|
|
5411
|
+
});
|
|
5412
|
+
app.get("/api/sync/project-invites/:operationId", async (c) => {
|
|
5413
|
+
const operationId = String(c.req.param("operationId") ?? "").trim();
|
|
5414
|
+
if (!/^share_[a-f0-9]{40}$/u.test(operationId)) return c.json({ error: "operation_id_invalid" }, 400);
|
|
5415
|
+
try {
|
|
5416
|
+
const { payload } = await coordinatorProjectInvitePayload(operationId);
|
|
5417
|
+
return c.json(payload.state === "accepted" ? {
|
|
5418
|
+
...payload,
|
|
5419
|
+
state: "pending_setup"
|
|
5420
|
+
} : payload);
|
|
5421
|
+
} catch (error) {
|
|
5422
|
+
const mapped = projectInviteOwnerErrorResponse(error, {
|
|
5423
|
+
code: "operation_read_failed",
|
|
5424
|
+
status: 400
|
|
5425
|
+
});
|
|
5426
|
+
return c.json({ error: mapped.code }, mapped.status);
|
|
5427
|
+
}
|
|
5428
|
+
});
|
|
5429
|
+
app.post("/api/sync/project-invites/:operationId/reconcile", async (c) => {
|
|
5430
|
+
const store = getStore();
|
|
5431
|
+
const operationId = String(c.req.param("operationId") ?? "").trim();
|
|
5432
|
+
if (!/^share_[a-f0-9]{40}$/u.test(operationId)) return c.json({ error: "operation_id_invalid" }, 400);
|
|
5433
|
+
try {
|
|
5434
|
+
const result = await reconcileProjectInviteAcceptance(store, operationId);
|
|
5435
|
+
return c.json({
|
|
5436
|
+
ok: true,
|
|
5437
|
+
operation_id: operationId,
|
|
5438
|
+
reconciled: result.accepted,
|
|
5439
|
+
state: result.accepted ? "pending_setup" : "waiting_for_acceptance"
|
|
5440
|
+
});
|
|
5441
|
+
} catch (error) {
|
|
5442
|
+
const mapped = projectInviteOwnerErrorResponse(error, {
|
|
5443
|
+
code: "operation_reconcile_failed",
|
|
5444
|
+
status: 409
|
|
5445
|
+
});
|
|
5446
|
+
return c.json({ error: mapped.code }, mapped.status);
|
|
5447
|
+
}
|
|
5448
|
+
});
|
|
5449
|
+
app.get("/api/sync/share-operations", async (c) => {
|
|
5450
|
+
const items = await shareOperationReadModels(getStore(), void 0, false);
|
|
5451
|
+
return c.json({ items });
|
|
5452
|
+
});
|
|
5453
|
+
app.get("/api/sync/share-operations/:operationId", async (c) => {
|
|
5454
|
+
const store = getStore();
|
|
5455
|
+
const operationId = String(c.req.param("operationId") ?? "").trim();
|
|
5456
|
+
if (!/^share_[a-f0-9]{40}$/u.test(operationId)) return c.json({ error: "operation_id_invalid" }, 400);
|
|
5457
|
+
const [item] = await shareOperationReadModels(store, operationId);
|
|
5458
|
+
return item ? c.json(item) : c.json({ error: "operation_not_found" }, 404);
|
|
5459
|
+
});
|
|
5460
|
+
const advanceProjectShare = async (c) => {
|
|
5461
|
+
const store = getStore();
|
|
5462
|
+
const operationId = String(c.req.param("operationId") ?? "").trim();
|
|
5463
|
+
if (!/^share_[a-f0-9]{40}$/u.test(operationId)) return c.json({ error: "operation_id_invalid" }, 400);
|
|
5464
|
+
try {
|
|
5465
|
+
const advanced = await advanceProjectShareOperation(store, operationId);
|
|
5466
|
+
if (!advanced.advanced) return c.json({ error: advanced.state === "cancelled" ? "operation_superseded" : "invitation_not_accepted" }, 409);
|
|
5467
|
+
const [operation] = await shareOperationReadModels(store, operationId);
|
|
5468
|
+
return c.json({
|
|
5469
|
+
ok: true,
|
|
5470
|
+
operation
|
|
5471
|
+
});
|
|
5472
|
+
} catch (error) {
|
|
5473
|
+
const code = error instanceof Error ? error.message : "provisioning_failed";
|
|
5474
|
+
if (store.db.prepare("SELECT state FROM share_operations WHERE operation_id = ?").pluck().get(operationId) === "cancelled") return c.json({ error: "operation_superseded" }, 409);
|
|
5475
|
+
return c.json({ error: code }, code === "operation_not_found" ? 404 : 409);
|
|
5476
|
+
}
|
|
5477
|
+
};
|
|
5478
|
+
app.post("/api/sync/share-operations/:operationId/advance", advanceProjectShare);
|
|
5479
|
+
app.post("/api/sync/project-invites/:operationId/provision", async (c) => {
|
|
5480
|
+
const store = getStore();
|
|
5481
|
+
const operationId = String(c.req.param("operationId") ?? "").trim();
|
|
5482
|
+
if (!/^share_[a-f0-9]{40}$/u.test(operationId)) return c.json({ error: "operation_id_invalid" }, 400);
|
|
5483
|
+
try {
|
|
5484
|
+
const plan = await executeProjectShareProvisioning(store, operationId);
|
|
5485
|
+
if (plan.superseded) return c.json({ error: "operation_superseded" }, 409);
|
|
5486
|
+
return c.json({
|
|
5487
|
+
ok: true,
|
|
5488
|
+
operation_id: operationId,
|
|
5489
|
+
state: "active",
|
|
5490
|
+
plan
|
|
5491
|
+
});
|
|
5492
|
+
} catch (error) {
|
|
5493
|
+
const code = error instanceof Error ? error.message : "provisioning_failed";
|
|
5494
|
+
if (store.db.prepare("SELECT state FROM share_operations WHERE operation_id = ?").pluck().get(operationId) === "cancelled") return c.json({ error: "operation_superseded" }, 409);
|
|
5495
|
+
return c.json({ error: code }, code === "operation_not_found" ? 404 : 409);
|
|
5496
|
+
}
|
|
5497
|
+
});
|
|
3811
5498
|
app.post("/api/sync/legacy-shared-review/reassign", async (c) => {
|
|
3812
5499
|
const store = getStore();
|
|
3813
5500
|
const body = await parseViewerJsonBody(c);
|
|
@@ -4173,6 +5860,73 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
4173
5860
|
return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
|
|
4174
5861
|
}
|
|
4175
5862
|
});
|
|
5863
|
+
app.post("/api/sync/invites/inspect", async (c) => {
|
|
5864
|
+
const store = getStore();
|
|
5865
|
+
const body = await parseViewerJsonBody(c);
|
|
5866
|
+
if (!body || typeof body.invite !== "string") return c.json({ error: "invite_invalid" }, 400);
|
|
5867
|
+
try {
|
|
5868
|
+
const payload = decodeInvitePayload(extractInvitePayload(body.invite));
|
|
5869
|
+
const recipientInvite = payload.kind === "team_member" || payload.kind === "add_device";
|
|
5870
|
+
if (recipientInvite) ensureStableStoreDeviceIdentity(store);
|
|
5871
|
+
else ensureStableStoreIdentity(store);
|
|
5872
|
+
if (!payload.operation_id && !recipientInvite) return c.json({ kind: "legacy_team_invite" });
|
|
5873
|
+
const [status, inspected] = await requestJson("POST", `${buildBaseUrl(payload.coordinator_url)}/v1/invites/inspect`, {
|
|
5874
|
+
body: { token: payload.token },
|
|
5875
|
+
timeoutS: 10
|
|
5876
|
+
});
|
|
5877
|
+
if (status < 200 || status >= 300) return c.json({ error: recipientInvite ? safeRecipientInviteError(inspected?.error) : String(inspected?.error ?? "invite_invalid") }, status === 410 ? 410 : 400);
|
|
5878
|
+
const config = readCodememConfigFile();
|
|
5879
|
+
const identityId = recipientInvite ? recipientInviteAuthoritativeIdentityId({
|
|
5880
|
+
kind: payload.kind,
|
|
5881
|
+
assigned_identity_id: inspected?.assigned_identity_id,
|
|
5882
|
+
target_identity_id: inspected?.target_identity_id
|
|
5883
|
+
}) : store.actorId;
|
|
5884
|
+
const expectedTarget = payload.kind === "team_member" ? String(inspected?.policy_team_id ?? "").trim() : String(inspected?.target_identity_id ?? "").trim();
|
|
5885
|
+
if (recipientInvite) {
|
|
5886
|
+
if (String(inspected?.kind ?? "").trim() !== payload.kind || !expectedTarget || String(inspected?.reviewed_preview_digest ?? "").trim() !== String(payload.reviewed_preview_digest ?? "").trim() || payload.kind === "team_member" && expectedTarget !== payload.policy_team_id || payload.kind === "team_member" && (identityId !== String(payload.assigned_identity_id ?? "").trim() || !identityId) || payload.kind === "add_device" && expectedTarget !== payload.target_identity_id) return c.json({ error: "recipient_invite_intent_mismatch" }, 409);
|
|
5887
|
+
if (inspected?.reviewed_intent == null) return c.json({ error: "recipient_invite_review_unavailable" }, 409);
|
|
5888
|
+
let reviewedIntent;
|
|
5889
|
+
try {
|
|
5890
|
+
reviewedIntent = await verifyRecipientReviewedIntent(inspected.reviewed_intent, {
|
|
5891
|
+
target: payload.kind === "team_member" ? {
|
|
5892
|
+
kind: "team_member",
|
|
5893
|
+
policyTeamId: expectedTarget
|
|
5894
|
+
} : {
|
|
5895
|
+
kind: "add_device",
|
|
5896
|
+
targetIdentityId: expectedTarget
|
|
5897
|
+
},
|
|
5898
|
+
digest: String(payload.reviewed_preview_digest ?? "").trim()
|
|
5899
|
+
});
|
|
5900
|
+
} catch {
|
|
5901
|
+
return c.json({ error: "recipient_invite_intent_mismatch" }, 409);
|
|
5902
|
+
}
|
|
5903
|
+
const preview = recipientInviteOnboardingPreviewFromReviewedIntent(store, {
|
|
5904
|
+
kind: payload.kind,
|
|
5905
|
+
policy_team_id: payload.kind === "team_member" ? expectedTarget : null,
|
|
5906
|
+
assigned_identity_id: payload.kind === "team_member" ? identityId : null,
|
|
5907
|
+
target_identity_id: payload.kind === "add_device" ? identityId : null,
|
|
5908
|
+
device_name: optionalViewerString(body, "device_name")
|
|
5909
|
+
}, String(payload.token), reviewedIntent);
|
|
5910
|
+
return c.json({
|
|
5911
|
+
...inspected,
|
|
5912
|
+
recipient_name: reviewedIntent.journey === "add_device" ? reviewedIntent.targetIdentity.displayName : store.actorDisplayName,
|
|
5913
|
+
device_name: preview.binding.deviceDisplayName,
|
|
5914
|
+
onboarding: preview
|
|
5915
|
+
});
|
|
5916
|
+
}
|
|
5917
|
+
return c.json({
|
|
5918
|
+
...inspected,
|
|
5919
|
+
recipient_name: store.actorDisplayName,
|
|
5920
|
+
device_name: friendlyDeviceName({
|
|
5921
|
+
explicitName: String(config.sync_device_name ?? ""),
|
|
5922
|
+
osName: hostname(),
|
|
5923
|
+
fallbackSeed: store.deviceId
|
|
5924
|
+
})
|
|
5925
|
+
});
|
|
5926
|
+
} catch (error) {
|
|
5927
|
+
return c.json({ error: safeRecipientInviteError(error) }, 400);
|
|
5928
|
+
}
|
|
5929
|
+
});
|
|
4176
5930
|
app.post("/api/sync/invites/import", async (c) => {
|
|
4177
5931
|
const store = getStore();
|
|
4178
5932
|
let body;
|
|
@@ -4205,16 +5959,49 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
4205
5959
|
} catch (error) {
|
|
4206
5960
|
return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
|
|
4207
5961
|
}
|
|
5962
|
+
let projectInvite = false;
|
|
5963
|
+
let recipientInvite = false;
|
|
4208
5964
|
try {
|
|
5965
|
+
const decoded = decodeInvitePayload(extractInvitePayload(rawValue));
|
|
5966
|
+
projectInvite = Boolean(decoded.operation_id);
|
|
5967
|
+
recipientInvite = decoded.kind === "team_member" || decoded.kind === "add_device";
|
|
5968
|
+
if (recipientInvite) ensureStableStoreDeviceIdentity(store);
|
|
5969
|
+
else ensureStableStoreIdentity(store);
|
|
5970
|
+
const reviewedOnboardingDigest = typeof body.reviewed_onboarding_digest === "string" ? body.reviewed_onboarding_digest.trim() : "";
|
|
5971
|
+
if (recipientInvite && !reviewedOnboardingDigest) return c.json({ error: "reviewed_onboarding_digest_required" }, 400);
|
|
4209
5972
|
const result = await coordinatorImportInviteAction({
|
|
4210
5973
|
inviteValue: rawValue,
|
|
4211
|
-
dbPath: store.dbPath
|
|
5974
|
+
dbPath: store.dbPath,
|
|
5975
|
+
recipientActorId: recipientInvite ? null : store.actorId,
|
|
5976
|
+
recipientDisplayName: typeof body.recipient_name === "string" ? body.recipient_name : store.actorDisplayName,
|
|
5977
|
+
deviceDisplayName: typeof body.device_name === "string" ? body.device_name : null,
|
|
5978
|
+
reviewedOnboardingDigest: reviewedOnboardingDigest || null
|
|
4212
5979
|
});
|
|
5980
|
+
const type = recipientInvite ? "recipient_onboarding" : projectInvite ? "project_share" : "team_join";
|
|
5981
|
+
if (recipientInvite) {
|
|
5982
|
+
const persistedIdentityId = String(result.identity_id ?? "").trim();
|
|
5983
|
+
if (!store.refreshPersistedLocalIdentity(persistedIdentityId)) return c.json({
|
|
5984
|
+
...result,
|
|
5985
|
+
type,
|
|
5986
|
+
setup_state: "restart_required",
|
|
5987
|
+
restart_required: true,
|
|
5988
|
+
detail: "The invitation was accepted and the reviewed Identity was persisted, but this process could not safely adopt it. Restart codemem before continuing."
|
|
5989
|
+
}, 200);
|
|
5990
|
+
}
|
|
5991
|
+
if ((projectInvite || recipientInvite) && getSyncRuntimeStatus?.()?.phase === "disabled") return c.json({
|
|
5992
|
+
...result,
|
|
5993
|
+
type,
|
|
5994
|
+
setup_state: "restart_required",
|
|
5995
|
+
restart_required: true,
|
|
5996
|
+
detail: projectInvite ? "The invitation was accepted and sync was enabled. Restart codemem to start the sync service; Project setup remains pending until the first sync finishes." : "The invitation was accepted and sync was enabled. Restart codemem to start the sync service and receive the reviewed Projects."
|
|
5997
|
+
}, 200);
|
|
4213
5998
|
return c.json({
|
|
4214
5999
|
...result,
|
|
4215
|
-
type
|
|
6000
|
+
type
|
|
4216
6001
|
});
|
|
4217
6002
|
} catch (error) {
|
|
6003
|
+
if (projectInvite) return c.json(projectInviteAcceptanceFailure(error), 400);
|
|
6004
|
+
if (recipientInvite) return c.json({ error: safeRecipientInviteError(error) }, 400);
|
|
4218
6005
|
return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
|
|
4219
6006
|
}
|
|
4220
6007
|
});
|
|
@@ -4659,17 +6446,34 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
4659
6446
|
error: "membership_epoch must be number",
|
|
4660
6447
|
status
|
|
4661
6448
|
}, 400);
|
|
6449
|
+
const role = optionalViewerString(body, "role");
|
|
6450
|
+
const coordinatorId = optionalViewerString(body, "coordinator_id");
|
|
6451
|
+
const manifestIssuerDeviceId = optionalViewerString(body, "manifest_issuer_device_id");
|
|
6452
|
+
const manifestHash = optionalViewerString(body, "manifest_hash");
|
|
6453
|
+
const signedManifestJson = optionalViewerString(body, "signed_manifest_json");
|
|
6454
|
+
const effectId = optionalViewerString(body, "effect_id") ?? coordinatorAdminMembershipEffectId("grant", randomUUID(), {
|
|
6455
|
+
groupId,
|
|
6456
|
+
scopeId,
|
|
6457
|
+
deviceId,
|
|
6458
|
+
role,
|
|
6459
|
+
membershipEpoch,
|
|
6460
|
+
coordinatorId,
|
|
6461
|
+
manifestIssuerDeviceId,
|
|
6462
|
+
manifestHash,
|
|
6463
|
+
signedManifestJson
|
|
6464
|
+
});
|
|
4662
6465
|
try {
|
|
4663
6466
|
const membership = await coordinatorGrantScopeMembershipAction({
|
|
6467
|
+
effectId,
|
|
4664
6468
|
groupId,
|
|
4665
6469
|
scopeId,
|
|
4666
6470
|
deviceId,
|
|
4667
|
-
role
|
|
6471
|
+
role,
|
|
4668
6472
|
membershipEpoch,
|
|
4669
|
-
coordinatorId
|
|
4670
|
-
manifestIssuerDeviceId
|
|
4671
|
-
manifestHash
|
|
4672
|
-
signedManifestJson
|
|
6473
|
+
coordinatorId,
|
|
6474
|
+
manifestIssuerDeviceId,
|
|
6475
|
+
manifestHash,
|
|
6476
|
+
signedManifestJson,
|
|
4673
6477
|
remoteUrl: config.syncCoordinatorUrl || null,
|
|
4674
6478
|
adminSecret: config.syncCoordinatorAdminSecret || null
|
|
4675
6479
|
});
|
|
@@ -4718,14 +6522,25 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
4718
6522
|
error: "membership_epoch must be number",
|
|
4719
6523
|
status
|
|
4720
6524
|
}, 400);
|
|
6525
|
+
const manifestHash = optionalViewerString(body, "manifest_hash");
|
|
6526
|
+
const signedManifestJson = optionalViewerString(body, "signed_manifest_json");
|
|
6527
|
+
const effectId = optionalViewerString(body, "effect_id") ?? coordinatorAdminMembershipEffectId("revoke", randomUUID(), {
|
|
6528
|
+
groupId,
|
|
6529
|
+
scopeId,
|
|
6530
|
+
deviceId,
|
|
6531
|
+
membershipEpoch,
|
|
6532
|
+
manifestHash,
|
|
6533
|
+
signedManifestJson
|
|
6534
|
+
});
|
|
4721
6535
|
try {
|
|
4722
6536
|
if (!await coordinatorRevokeScopeMembershipAction({
|
|
6537
|
+
effectId,
|
|
4723
6538
|
groupId,
|
|
4724
6539
|
scopeId,
|
|
4725
6540
|
deviceId,
|
|
4726
6541
|
membershipEpoch,
|
|
4727
|
-
manifestHash
|
|
4728
|
-
signedManifestJson
|
|
6542
|
+
manifestHash,
|
|
6543
|
+
signedManifestJson,
|
|
4729
6544
|
remoteUrl: config.syncCoordinatorUrl || null,
|
|
4730
6545
|
adminSecret: config.syncCoordinatorAdminSecret || null
|
|
4731
6546
|
})) return c.json({
|
|
@@ -4878,6 +6693,7 @@ function syncRoutes(getStore, getSyncRuntimeStatus) {
|
|
|
4878
6693
|
store: getStore(),
|
|
4879
6694
|
config,
|
|
4880
6695
|
groupId: result.group_id,
|
|
6696
|
+
requestId,
|
|
4881
6697
|
deviceId: result.device_id
|
|
4882
6698
|
});
|
|
4883
6699
|
} catch (grantError) {
|
|
@@ -5519,6 +7335,6 @@ function createSyncApp(opts) {
|
|
|
5519
7335
|
return app;
|
|
5520
7336
|
}
|
|
5521
7337
|
//#endregion
|
|
5522
|
-
export { VERSION, closeStore, createApp, createSyncApp, getStore };
|
|
7338
|
+
export { VERSION, advancePendingProjectShares, advanceProjectShareOperation, closeStore, createApp, createRecipientPolicyReconcilerEffects, createSyncApp, getStore, listRecipientPolicyReconciliationStatus, recipientPolicyCapabilityFromStatus, reconcileConfiguredCoordinatorEnrollment, reconcileRecipientPolicyProjects };
|
|
5523
7339
|
|
|
5524
7340
|
//# sourceMappingURL=index.js.map
|