@codemem/server 0.47.0-beta.4 → 0.47.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { closeSync, existsSync, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { basename, dirname, isAbsolute, join, posix, resolve, win32 } from "node:path";
3
- import { ACCESS_CLEANUP_OP_TYPE, CodememConfigMutationError, CoordinatorAccountLinkError, CoordinatorReciprocalApprovalRequestChangedError, DEFAULT_TIME_WINDOW_S, FILE_CONTEXT_TRANSPORT_MAX_BYTES, LEGACY_SHARED_REVIEW_SCOPE_ID, LOCAL_DEFAULT_SCOPE_ID, LOCAL_SYNC_CAPABILITY, LOCAL_SYNC_FEATURES, LegacyTeamSetupAdditiveConvergenceError, LegacyTeamSetupRosterCapacityError, MEMORY_FILTER_FIELD_TYPES, MEMORY_FILTER_NAMES, MEMORY_KIND_DESCRIPTIONS, MemoryStore, ObserverClient, PROMPT_TRANSPORT_PROTOCOL_RANGE, REMEMBER_MEMORY_KINDS, RICH_TIER_ANTHROPIC_DEFAULTS, RICH_TIER_DEFAULTS, RawEventIngestValidationError, RecipientPolicyEdgeRequestError, RecipientPolicyTeamRenameError, RemoteCoordinatorRequestError, SCOPE_MEMBERSHIP_REVOCATION_LIMITATION, SIMPLE_TIER_ANTHROPIC_DEFAULTS, SIMPLE_TIER_DEFAULTS, SYNC_AUTHORIZATION_REFRESH_HEADER, SYNC_CAPABILITY_HEADER, SYNC_FEATURES_HEADER, SYNC_SCOPE_QUERY_PARAM, VERSION, VERSION as VERSION$1, VIEWER_IDENTITY_TARGET_KEYS, VIEWER_SERVICE_DISCRIMINATOR, addSyncScopeToBoundary, analyzeProjectScopeMappingChangeGuardrails, analyzeProjectScopeMappingChangesGuardrails, analyzeProjectScopeMappingDeletionGuardrails, applyLegacyTeamSetupCompletionManifest, applyLegacyTeamSetupCompletionManifestAndReturnActivation, applyReplicationOps, areLegacyTeamSetupCompletionPolicyFactsAdditivelyCompatible, automaticRecallHealth, buildAuthHeaders, buildBaseUrl, buildDirectPeerAuthHeaders, buildDistillReport, buildFilterBranchesWithContext, buildFilterClausesWithContext, buildPiFlushSignalFromEvent, buildRawEventEnvelopeFromCodexHook, buildRawEventEnvelopeFromHook, buildRawEventEnvelopeFromPiEvent, buildViewerIdentityTarget, canonicalCoordinatorLegacyTeamCompletionManifestJson, canonicalMemoryKind, canonicalRepositoryProjectIdentity, canonicalWorkspaceIdentity, captureAccountOwnerProfilePins, changeCoordinatorAccount, claimRecipientPolicyActorMutations, claimRecipientPolicyPublicationMutation, classifyRecordedSyncFailure, cleanupNonces, clearLegacyTeamSetupDeviceDecision, clonePromptPackAttempt, coerceObserverCommand, commitDeviceIdentityBindings, commitRecipientPolicyEdges, containLegacyTeamSetupCompletionConflict, coordinatorArchiveGroupAction, coordinatorAuthControllerReviewAction, coordinatorCreateAddDeviceInviteAction, coordinatorCreateGroupAction, coordinatorCreateInviteAction, coordinatorCreateLegacyTeamCompletionAction, coordinatorCreateScopeAction, coordinatorDisableDeviceAction, coordinatorEnableDeviceAction, coordinatorGetLegacyTeamCompletionAction, coordinatorGrantScopeMembershipAction, coordinatorImportInviteAction, coordinatorListBootstrapGrantsAction, coordinatorListConsumedTeamInvitesAction, coordinatorListDevicesAction, coordinatorListGroupsAction, coordinatorListJoinRequestsAction, coordinatorListLegacyTeamCompletionsAction, coordinatorListReviewedRecipientInviteEvidenceAction, coordinatorListScopeMembershipsAction, coordinatorListScopesAction, coordinatorRemoveDeviceAction, coordinatorRenameDeviceAction, coordinatorRenameGroupAction, coordinatorReviewJoinRequestAction, coordinatorRevokeBootstrapGrantAction, coordinatorRevokeScopeMembershipAction, coordinatorStatusSnapshot, coordinatorUnarchiveGroupAction, coordinatorUpdateScopeAction, countShareableProjectMemories, createCoordinatorReciprocalApproval, decodeInvitePayload, dedupeOrderedIds, defaultSpaceScopeIdForGroup, deleteProjectScopeSettingsMapping, deriveLegacyTeamSetupCompletionManifest, detectInstallKind, deterministicPolicyTeamId, diagnoseStalePeerReceivedRows, discoverLegacyTeamCandidates, dispatchFileContextTransport, ensureDeviceIdentity, executeShareProvisioning, extractInvitePayload, extractReplicationOps, filterReplicationOpsForSyncWithStatus, fingerprintPublicKey, formatHostPort, friendlyDeviceName, fromJson, generateWithOpenCodeV2, getCodememConfigPath, getCodememEnvOverrideValues, getCodememEnvOverrides, getCoordinatorEnrollmentReconciliationIssueSummary, getCoordinatorGroupPreference, getEffectiveCachedScopeAuthorization, getLegacyTeamSetupDraft, getRecipientPolicyAuthorityState, getSemanticIndexDiagnostics, getSyncResetState, getUpdateStatus, ingestRawEvents, inspectFreshLegacyTeamSetupActivation, inspectLegacyTeamSetupActivation, inviteTokenDigest, isAutomaticRecallMeasurement, isLegacyTeamCandidateSelectable, isLegacyTeamSetupProjectMappingIdentity, isPeerTrustBindingCompatible, isProjectSyncEnablementError, isScopedSyncCapability, judgeDistillReport, legacyTeamCandidateId, legacyTeamCandidateProjectInventory, legacyTeamCanonicalProjectRef, legacyTeamDeviceRef, legacyTeamResolvedProjectRef, legacyTeamSetupApiErrorCode, linkCoordinatorAccount, listAuthorizedScopesForPeer, listCoordinatorJoinRequests, listDeviceIdentityInventory, listInboundScopeRejections, listLegacyRecipientPolicyProjections, listMaintenanceJobs, listObserverProviderOptions, listOpenCodeV2Models, listPerPeerScopeSyncState, listProjectScopeCandidates, listProjectScopeInventory, listProjectScopeSettingsMappings, listRecipientPolicyIntent, listRecipientPolicyReview, listSharingDomainSettingsScopes, loadMemorySnapshotPageForPeer, loadPublicKey, loadReplicationOpsForPeer, loadRuntimeSigningPublicKey, lookupCoordinatorPeers, memoryFilterValueMatchesType, mergeAddresses, migrateRecipientPolicyIntent, mutateCodememConfigFile, negotiateSyncCapability, normalizeAddress, normalizeCoordinatorAccountLinkOrigin, normalizeCoordinatorLegacyTeamCompletionManifest, normalizeHumanPresentationName, normalizeSyncCapability, normalizeSyncFeatures, normalizeTeammateName, parseAcceptedProjectIntent, parseBootstrapExpiry, parseFileContextTransportRequest, parsePositiveMemoryId, parseReassignScopePayload, parseRecipientPolicyEdgeCommitRequest, parseStrictInteger, parseSyncScopeRequest, persistShareOperation, personalScopeGrantStatusForPeer, planShareOperation, previewDeviceIdentityBindings, previewLegacyTeamSetupActivation, previewRecipientPolicyEdges, previewRecipientPolicyOnboarding, previewRecipientPolicyOnboardingFromReviewedIntent, probeAvailableCredentials, projectMatchesFilter, projectShareLifecycle, promptPackArtifactFingerprint, readAccountLinkDevice, readAccountOwnerAssociation, readCodememConfigFile, readCoordinatorAccountChangeCapabilities, readCoordinatorAccountLinkStatus, readCoordinatorResolvedAccountOwnerReceiptStatus, readCoordinatorResolvedAccountOwnerStatus, readCoordinatorSyncConfig, reassignProjectScopeInventoryProject, recipientInviteAuthoritativeIdentityId, recipientPolicyDigest, recipientReviewedIntentDigest, reconcileCoordinatorEnrollmentSnapshot, reconcileRecipientPolicyProject, reconcileShareOperationAcceptance, reconstructLegacyTeamSetupCompletionManifest, recordAutomaticRecall, recordHighestObservedDirectSignatureVersion, recordNonce, recordPromptPackArtifacts, recordPromptPackTerminal, refreshAuthorizedCoordinatorPeerTrust, refreshConfiguredScopeMembershipCache, refreshLegacyTeamCandidate, rejectInboundScopeFailures, renameRecipientPolicyTeam, replayLegacyTeamSetupActivation, repositoryIdentitiesByWorkspace, repositoryIdentityForWorkspace, requestJson, requireLegacyTeamSetupAccessDeltaWithinLimit, resolveCodememConfigPath, resolveDbPath, resolveObserverDefaultModel, resolveObserverRuntime, resolveProject, resolveProjectRoot, resolveRecipientPolicyReview, resolveRecipientPolicyReviewBulk, resolveVisibleScopeIds, runSyncPass, schema, scopeAuthorizationFailureReason, searchPiSessions, serializeRecipientPolicyCoordinatorGroupMutation, serializeRecipientPolicyPublicationMutation, serializeRecipientPolicyTeamMutation, setLegacyTeamSetupDeviceAssignment, setLegacyTeamSetupDeviceDecision, setLegacyTeamSetupProjectMapping, summarizeInboundScopeRejections, summaryLikeSqlPredicate, supportsSyncFeature, syncScopeResetRequiredPayload, toJson, tryUpdateRetrievalDelivery, updatePeerAddresses, upsertCoordinatorGroupPreference, upsertProjectScopeSettingsMappings, validateLegacyTeamSetupCompletionManifest, validateLegacyTeamSetupCompletionManifestBinding, validateRawEvents, verifyDirectPeerSignature, verifyRecipientReviewedIntent, verifySignature, wakeRecipientPoliciesForIdentities, wakeRecipientPoliciesForProjectIdentities } from "@codemem/core";
3
+ import { ACCESS_CLEANUP_OP_TYPE, CodememConfigMutationError, CoordinatorAccountLinkError, CoordinatorReciprocalApprovalRequestChangedError, DEFAULT_TIME_WINDOW_S, FILE_CONTEXT_TRANSPORT_MAX_BYTES, LEGACY_SHARED_REVIEW_SCOPE_ID, LOCAL_DEFAULT_SCOPE_ID, LOCAL_SYNC_CAPABILITY, LOCAL_SYNC_FEATURES, LegacyTeamSetupAdditiveConvergenceError, LegacyTeamSetupRosterCapacityError, MEMORY_FILTER_FIELD_TYPES, MEMORY_FILTER_NAMES, MEMORY_KIND_DESCRIPTIONS, MemoryStore, ObserverClient, PROMPT_TRANSPORT_PROTOCOL_RANGE, REMEMBER_MEMORY_KINDS, RICH_TIER_ANTHROPIC_DEFAULTS, RICH_TIER_DEFAULTS, RawEventIngestValidationError, RecipientPolicyEdgeRequestError, RecipientPolicyTeamRenameError, RemoteCoordinatorRequestError, SCOPE_MEMBERSHIP_REVOCATION_LIMITATION, SIMPLE_TIER_ANTHROPIC_DEFAULTS, SIMPLE_TIER_DEFAULTS, SYNC_AUTHORIZATION_REFRESH_HEADER, SYNC_CAPABILITY_HEADER, SYNC_FEATURES_HEADER, SYNC_SCOPE_QUERY_PARAM, VERSION, VERSION as VERSION$1, VIEWER_IDENTITY_TARGET_KEYS, VIEWER_SERVICE_DISCRIMINATOR, addSyncScopeToBoundary, analyzeProjectScopeMappingChangeGuardrails, analyzeProjectScopeMappingChangesGuardrails, analyzeProjectScopeMappingDeletionGuardrails, applyLegacyTeamSetupCompletionManifest, applyLegacyTeamSetupCompletionManifestAndReturnActivation, applyLocalIdentitySwitch, applyReplicationOps, areLegacyTeamSetupCompletionPolicyFactsAdditivelyCompatible, assertNoLocalIdentityOverride, automaticRecallHealth, buildAuthHeaders, buildBaseUrl, buildDirectPeerAuthHeaders, buildDistillReport, buildFilterBranchesWithContext, buildFilterClausesWithContext, buildPiFlushSignalFromEvent, buildRawEventEnvelopeFromCodexHook, buildRawEventEnvelopeFromHook, buildRawEventEnvelopeFromPiEvent, buildViewerIdentityTarget, canonicalCoordinatorLegacyTeamCompletionManifestJson, canonicalMemoryKind, canonicalRepositoryProjectIdentity, canonicalWorkspaceIdentity, captureAccountOwnerProfilePins, changeCoordinatorAccount, claimRecipientPolicyActorMutations, claimRecipientPolicyPublicationMutation, classifyRecordedSyncFailure, cleanupNonces, clearLegacyTeamSetupDeviceDecision, clonePromptPackAttempt, coerceObserverCommand, commitDeviceIdentityBindings, commitRecipientPolicyEdges, completeLocalIdentitySwitch, containLegacyTeamSetupCompletionConflict, coordinatorArchiveGroupAction, coordinatorAuthControllerReviewAction, coordinatorCreateAddDeviceInviteAction, coordinatorCreateGroupAction, coordinatorCreateInviteAction, coordinatorCreateLegacyTeamCompletionAction, coordinatorCreateScopeAction, coordinatorDisableDeviceAction, coordinatorEnableDeviceAction, coordinatorGetLegacyTeamCompletionAction, coordinatorGrantScopeMembershipAction, coordinatorImportInviteAction, coordinatorListBootstrapGrantsAction, coordinatorListConsumedTeamInvitesAction, coordinatorListDevicesAction, coordinatorListGroupsAction, coordinatorListJoinRequestsAction, coordinatorListLegacyTeamCompletionsAction, coordinatorListReviewedRecipientInviteEvidenceAction, coordinatorListScopeMembershipsAction, coordinatorListScopesAction, coordinatorRemoveDeviceAction, coordinatorRenameDeviceAction, coordinatorRenameGroupAction, coordinatorReviewJoinRequestAction, coordinatorRevokeBootstrapGrantAction, coordinatorRevokeScopeMembershipAction, coordinatorStatusSnapshot, coordinatorUnarchiveGroupAction, coordinatorUpdateScopeAction, countShareableProjectMemories, createCoordinatorReciprocalApproval, decodeInvitePayload, dedupeOrderedIds, defaultSpaceScopeIdForGroup, deleteProjectScopeSettingsMapping, deriveLegacyTeamSetupCompletionManifest, detectInstallKind, deterministicPolicyTeamId, diagnoseStalePeerReceivedRows, discoverLegacyTeamCandidates, dispatchFileContextTransport, ensureDeviceIdentity, executeShareProvisioning, extractInvitePayload, extractReplicationOps, filterReplicationOpsForSyncWithStatus, fingerprintPublicKey, formatHostPort, friendlyDeviceName, fromJson, generateWithOpenCodeV2, getCodememConfigPath, getCodememEnvOverrideValues, getCodememEnvOverrides, getCoordinatorEnrollmentReconciliationIssueSummary, getCoordinatorGroupPreference, getEffectiveCachedScopeAuthorization, getLegacyTeamSetupDraft, getRecipientPolicyAuthorityState, getSemanticIndexDiagnostics, getSyncResetState, getUpdateStatus, ingestRawEvents, inspectFreshLegacyTeamSetupActivation, inspectLegacyTeamSetupActivation, inviteTokenDigest, isAutomaticRecallMeasurement, isLegacyTeamCandidateSelectable, isLegacyTeamSetupProjectMappingIdentity, isPeerTrustBindingCompatible, isProjectSyncEnablementError, isScopedSyncCapability, judgeDistillReport, legacyDecisionCountsAsOwner, legacyTeamCandidateId, legacyTeamCandidateProjectInventory, legacyTeamCanonicalProjectRef, legacyTeamDeviceRef, legacyTeamResolvedProjectRef, legacyTeamSetupApiErrorCode, linkCoordinatorAccount, listAuthorizedScopesForPeer, listCoordinatorJoinRequests, listDeviceIdentityInventory, listInboundScopeRejections, listLegacyRecipientPolicyProjections, listMaintenanceJobs, listObserverProviderOptions, listOpenCodeV2Models, listPerPeerScopeSyncState, listProjectScopeCandidates, listProjectScopeInventory, listProjectScopeSettingsMappings, listRecipientPolicyIntent, listRecipientPolicyReview, listSharingDomainSettingsScopes, loadMemorySnapshotPageForPeer, loadPublicKey, loadReplicationOpsForPeer, loadRuntimeSigningPublicKey, lookupCoordinatorPeers, markLocalIdentitySwitchBound, markLocalIdentitySwitchNotCommitted, memoryFilterValueMatchesType, mergeAddresses, migrateRecipientPolicyIntent, mutateCodememConfigFile, negotiateSyncCapability, normalizeAddress, normalizeCoordinatorAccountLinkOrigin, normalizeCoordinatorLegacyTeamCompletionManifest, normalizeHumanPresentationName, normalizeSyncCapability, normalizeSyncFeatures, normalizeTeammateName, parseAcceptedProjectIntent, parseBootstrapExpiry, parseFileContextTransportRequest, parsePositiveMemoryId, parseReassignScopePayload, parseRecipientPolicyEdgeCommitRequest, parseStrictInteger, parseSyncScopeRequest, persistShareOperation, personalScopeGrantStatusForPeer, planShareOperation, previewDeviceIdentityBindings, previewLegacyTeamSetupActivation, previewRecipientPolicyEdges, previewRecipientPolicyOnboarding, previewRecipientPolicyOnboardingFromReviewedIntent, probeAvailableCredentials, projectMatchesFilter, projectShareLifecycle, promptPackArtifactFingerprint, readAccountLinkDevice, readAccountOwnerAssociation, readAccountOwnerAssociationRetainedAtMs, readCodememConfigFile, readCompletedLocalIdentitySwitches, readCoordinatorAccountChangeCapabilities, readCoordinatorAccountLinkStatus, readCoordinatorResolvedAccountOwnerReceiptStatus, readCoordinatorResolvedAccountOwnerStatus, readCoordinatorSyncConfig, readOpenLocalIdentitySwitch, reassignProjectScopeInventoryProject, recipientInviteAuthoritativeIdentityId, recipientPolicyDigest, recipientReviewedIntentDigest, reconcileCoordinatorEnrollmentSnapshot, reconcileRecipientPolicyProject, reconcileShareOperationAcceptance, reconstructLegacyTeamSetupCompletionManifest, recordAutomaticRecall, recordHighestObservedDirectSignatureVersion, recordLocalIdentitySwitch, recordNonce, recordPromptPackArtifacts, recordPromptPackTerminal, refreshAuthorizedCoordinatorPeerTrust, refreshConfiguredScopeMembershipCache, refreshLegacyTeamCandidate, rejectInboundScopeFailures, renameRecipientPolicyTeam, replayLegacyTeamSetupActivation, repositoryIdentitiesByWorkspace, repositoryIdentityForWorkspace, requestJson, requireLegacyTeamSetupAccessDeltaWithinLimit, resolveCodememConfigPath, resolveDbPath, resolveObserverDefaultModel, resolveObserverRuntime, resolveProject, resolveProjectRoot, resolveRecipientPolicyReview, resolveRecipientPolicyReviewBulk, resolveVisibleScopeIds, resumeDeviceRebind, runDeviceRebind, runSyncPass, schema, scopeAuthorizationFailureReason, searchPiSessions, serializeRecipientPolicyCoordinatorGroupMutation, serializeRecipientPolicyPublicationMutation, serializeRecipientPolicyTeamMutation, setLegacyTeamSetupDeviceAssignment, setLegacyTeamSetupDeviceDecision, setLegacyTeamSetupProjectMapping, summarizeInboundScopeRejections, summaryLikeSqlPredicate, supportsSyncFeature, syncScopeResetRequiredPayload, toJson, tryUpdateRetrievalDelivery, updatePeerAddresses, upsertCoordinatorGroupPreference, upsertProjectScopeSettingsMappings, validateLegacyTeamSetupCompletionManifest, validateLegacyTeamSetupCompletionManifestBinding, validateRawEvents, verifyDirectPeerSignature, verifyRecipientReviewedIntent, verifySignature, wakeRecipientPoliciesForIdentities, wakeRecipientPoliciesForProjectIdentities } from "@codemem/core";
4
4
  import { serveStatic } from "@hono/node-server/serve-static";
5
5
  import { Hono } from "hono";
6
6
  import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
@@ -316,10 +316,31 @@ function safeFailure(error) {
316
316
  default: return "request_failed";
317
317
  }
318
318
  }
319
+ function browserPath(intent) {
320
+ if (intent === "refresh" || intent === "disconnect") return "/auth/account/change/start";
321
+ if (intent === "sign_in") return "/auth/rebind/start";
322
+ return "/auth/link/start";
323
+ }
324
+ var REBIND_REFUSALS = {
325
+ account_not_linked: "account_not_linked",
326
+ already_this_identity: "already_this_identity",
327
+ current_identity_linked: "current_identity_linked",
328
+ device_already_bound: "device_already_bound",
329
+ daily_limit: "daily_limit"
330
+ };
331
+ /** Maps a rebind result to the operation's terminal state and failure. */
332
+ function rebindOutcome(result) {
333
+ if (result.kind === "switched" || result.kind === "bound") return ["succeeded", null];
334
+ if (result.kind === "bind_unknown") return ["outcome_unknown", "bind_unconfirmed"];
335
+ if (result.kind === "pending") return ["outcome_unknown", "switch_pending"];
336
+ if (result.kind === "refused" && result.reason === "unavailable" && result.beforeBrowser) return ["failed", "sign_in_unavailable"];
337
+ if (result.kind === "refused") return ["failed", REBIND_REFUSALS[result.reason] ?? "request_failed"];
338
+ return ["failed", "request_failed"];
339
+ }
319
340
  function validBrowserUrl(value, origin, intent) {
320
341
  try {
321
342
  const url = new URL(value);
322
- return url.origin === origin && url.pathname === (intent === "refresh" || intent === "disconnect" ? "/auth/account/change/start" : "/auth/link/start") && !url.username && !url.password && !url.hash;
343
+ return url.origin === origin && url.pathname === browserPath(intent) && !url.username && !url.password && !url.hash;
323
344
  } catch {
324
345
  return false;
325
346
  }
@@ -413,6 +434,7 @@ var InProcessAccountLinkRegistry = class {
413
434
  onBrowserStart: (url) => this.#browserStart(job, url)
414
435
  };
415
436
  const intent = job.operation.intent ?? "connect";
437
+ if (intent === "sign_in") return await this.#runSignIn(job, options.onBrowserStart);
416
438
  if (intent === "connect") await (this.#options.link ?? linkCoordinatorAccount)({
417
439
  ...options,
418
440
  expectedAuthority
@@ -430,6 +452,25 @@ var InProcessAccountLinkRegistry = class {
430
452
  this.#fail(job, error);
431
453
  }
432
454
  }
455
+ /** Sign-in may switch this device's Identity; its result maps to one terminal state. */
456
+ async #runSignIn(job, onBrowserStart) {
457
+ const rebind = this.#options.rebind;
458
+ if (!rebind) throw new CoordinatorAccountLinkError("invalid_options");
459
+ const [state, failure] = rebindOutcome(await rebind({
460
+ context: job.context,
461
+ signal: job.controller.signal,
462
+ onBrowserStart,
463
+ isCurrent: () => {
464
+ const current = this.#current(job.context);
465
+ if (!current) job.abortFailure = "context_changed";
466
+ return current;
467
+ }
468
+ }));
469
+ if (state !== "failed") this.#finish(job, state, failure);
470
+ else if (job.abortFailure) this.#finish(job, "failed", job.abortFailure);
471
+ else if (job.cancelled) this.#finish(job, "cancelled", "link_stopped");
472
+ else this.#finish(job, "failed", failure);
473
+ }
433
474
  #fail(job, error) {
434
475
  let failure = safeFailure(error);
435
476
  if (job.abortFailure && (failure === "link_stopped" || failure === "request_failed")) failure = job.abortFailure;
@@ -484,6 +525,15 @@ var InProcessAccountLinkRegistry = class {
484
525
  });
485
526
  return { operation: this.#snapshot(job) };
486
527
  }
528
+ supportsSignIn() {
529
+ if (!this.#options.rebind) return false;
530
+ try {
531
+ assertNoLocalIdentityOverride();
532
+ return true;
533
+ } catch {
534
+ return false;
535
+ }
536
+ }
487
537
  lifecycleToken() {
488
538
  busySlot.lifecycle ??= Symbol();
489
539
  return busySlot.lifecycle;
@@ -584,18 +634,69 @@ function readOwnerAssociation(context, coordinatorId) {
584
634
  if (context.publicKeyHash === void 0) return null;
585
635
  const before = statSync(context.dbPath, { bigint: true });
586
636
  const device = readAccountLinkDevice(context.dbPath);
587
- const profile = {
588
- ...context,
589
- publicKey: device.publicKey,
590
- actorId: context.identityId,
591
- coordinatorId
637
+ const profileFor = (actorId) => {
638
+ const profile = {
639
+ ...context,
640
+ publicKey: device.publicKey,
641
+ actorId,
642
+ coordinatorId
643
+ };
644
+ return {
645
+ profile,
646
+ expectedProfilePins: captureAccountOwnerProfilePins(profile)
647
+ };
592
648
  };
593
- const expectedProfilePins = captureAccountOwnerProfilePins(profile);
594
- const association = readAccountOwnerAssociation(profile, { expectedProfilePins }) ?? readAccountOwnerAssociation(profile);
649
+ const association = readCurrentAssociation(context, {
650
+ generation: (actorId) => {
651
+ const { profile, expectedProfilePins } = profileFor(actorId);
652
+ return readAccountOwnerAssociation(profile, { expectedProfilePins });
653
+ },
654
+ generationRetainedAtMs: (actorId) => {
655
+ const { profile, expectedProfilePins } = profileFor(actorId);
656
+ return readAccountOwnerAssociationRetainedAtMs(profile, { expectedProfilePins });
657
+ },
658
+ legacy: (actorId) => readAccountOwnerAssociation(profileFor(actorId).profile),
659
+ legacyRetainedAtMs: (actorId) => readAccountOwnerAssociationRetainedAtMs(profileFor(actorId).profile)
660
+ });
595
661
  const after = statSync(context.dbPath, { bigint: true });
596
662
  if (!before.isFile() || before.dev !== after.dev || before.ino !== after.ino || after.dev.toString() !== context.fileIdentity.dev || after.ino.toString() !== context.fileIdentity.ino || device.deviceId !== context.deviceId || createHash("sha256").update(device.publicKey).digest("hex") !== context.publicKeyHash || device.fingerprint !== context.fingerprint) throw new Error("account_owner_association_invalid");
597
663
  return association;
598
664
  }
665
+ /** A switch away from `actorId` completed after evidence published at `retainedAtMs`. */
666
+ function switchedAwayAfter(switches, actorId, retainedAtMs) {
667
+ return retainedAtMs !== null && switches.some((s) => s.previousIdentityId === actorId && s.completedAtMs > retainedAtMs);
668
+ }
669
+ /** The shared legacy file pins a previous Identity whose switch superseded it. */
670
+ function legacySuperseded(evidence, switches) {
671
+ return [...new Set(switches.map((s) => s.previousIdentityId))].some((actorId) => {
672
+ try {
673
+ return evidence.legacy(actorId) !== null && switchedAwayAfter(switches, actorId, evidence.legacyRetainedAtMs(actorId));
674
+ } catch {
675
+ return false;
676
+ }
677
+ });
678
+ }
679
+ /**
680
+ * A switch away from the pinned Identity, completed after the association was
681
+ * published, supersedes it for good. Read failures are suppressed only for the
682
+ * shared legacy file, and only when it pins such a superseded Identity; a
683
+ * failing exact generation for the current Identity is always reported.
684
+ */
685
+ function readCurrentAssociation(context, evidence) {
686
+ const current = context.identityId;
687
+ const generation = evidence.generation(current);
688
+ const switches = () => readCompletedLocalIdentitySwitches(context.dbPath);
689
+ if (generation) return switchedAwayAfter(switches(), current, evidence.generationRetainedAtMs(current)) ? null : generation;
690
+ let legacy;
691
+ try {
692
+ legacy = evidence.legacy(current);
693
+ } catch (error) {
694
+ if (legacySuperseded(evidence, switches())) return null;
695
+ throw error;
696
+ }
697
+ if (!legacy) return null;
698
+ return switchedAwayAfter(switches(), current, evidence.legacyRetainedAtMs(current)) ? null : legacy;
699
+ }
599
700
  function ownerUnavailable(view, code) {
600
701
  return {
601
702
  ...withoutOwnerCapabilities(view),
@@ -688,7 +789,26 @@ function associatedOwnerView(view, context, association, remote) {
688
789
  }
689
790
  //#endregion
690
791
  //#region src/account/status.ts
691
- function attachOperation(view, registry, operationContextId = view.contextId) {
792
+ /**
793
+ * The owner-only state offers sign-in when the owner's Google account is
794
+ * linked and this device's own Identity has none: signing in switches the
795
+ * device to the owner's Identity.
796
+ */
797
+ function withOwnerSignIn(owner, own, context, remote) {
798
+ if (owner.ownerProjection?.status.profileState !== "linked" || own.canSignIn !== true || own.remote.kind !== "status" || own.remote.state !== "unlinked") return owner;
799
+ return {
800
+ ...owner,
801
+ canSignIn: true,
802
+ signIn: {
803
+ contextId: context.contextId,
804
+ coordinatorId: remote.status.coordinatorId,
805
+ authority: remote.status.authority
806
+ }
807
+ };
808
+ }
809
+ function attachOperation(input, registry, operationContextId = input.contextId) {
810
+ const { canSignIn, signIn, ...rest } = input;
811
+ const view = registry?.supportsSignIn?.() ? input : rest;
692
812
  const operation = operationContextId ? registry?.forContext(operationContextId) ?? null : null;
693
813
  const operationContext = operation && operationContextId && operationContextId !== view.contextId ? { operationContextId } : {};
694
814
  if (registry?.hasActive()) return {
@@ -696,6 +816,7 @@ function attachOperation(view, registry, operationContextId = view.contextId) {
696
816
  ...operationContext,
697
817
  operation,
698
818
  canLink: false,
819
+ ..."canSignIn" in view ? { canSignIn: false } : {},
699
820
  blockedReason: "operation_active"
700
821
  };
701
822
  return {
@@ -722,6 +843,7 @@ function accountLocalView(result) {
722
843
  } : null,
723
844
  remote: { kind: "not_checked" },
724
845
  canLink: false,
846
+ canSignIn: false,
725
847
  blockedReason: "setup_incomplete",
726
848
  operation: null,
727
849
  checkedAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -755,12 +877,84 @@ function applyRemote(view, remote) {
755
877
  account: identityMatches && status.state === "linked" ? status.account : null
756
878
  },
757
879
  canLink: status.state === "unlinked" && identityMatches,
880
+ canSignIn: identityMatches && signInState(status),
758
881
  blockedReason
759
882
  };
760
883
  }
884
+ /** Sign-in starts unlinked, or linked when this device has no proof of its own (a self-bind). */
885
+ function signInState(status) {
886
+ return status.state === "unlinked" || status.state === "linked" && !status.account;
887
+ }
761
888
  async function readAccountStatus(options, groupId, signal, readOptions = {}) {
762
889
  const initial = options.readContext(groupId);
763
890
  if ("error" in initial) return initial;
891
+ if (groupId === void 0 && readOptions.expectedContext === void 0 && initial.setup === "group_selection_required" && initial.groups.length > 0) return withPeopleNames(options, await readFirstUsableGroupStatus(options, initial.groups, signal));
892
+ return withPeopleNames(options, await readStatusFromContext(options, initial, groupId, signal, readOptions));
893
+ }
894
+ /**
895
+ * Names come from the local People list at view time. They stay out of the
896
+ * context, so renaming someone in Sharing cannot abort an account operation.
897
+ */
898
+ function withPeopleNames(options, result) {
899
+ if ("error" in result) return result;
900
+ const name = (identityId) => {
901
+ try {
902
+ return options.identityName?.(identityId) || void 0;
903
+ } catch {
904
+ return;
905
+ }
906
+ };
907
+ let view = result;
908
+ const localName = view.local ? name(view.local.identityId) : void 0;
909
+ if (view.local && localName) view = {
910
+ ...view,
911
+ local: {
912
+ ...view.local,
913
+ identityLabel: localName
914
+ }
915
+ };
916
+ const ownerName = view.accountOwner ? name(view.accountOwner.identityId) : void 0;
917
+ if (view.accountOwner && ownerName) view = {
918
+ ...view,
919
+ accountOwner: {
920
+ ...view.accountOwner,
921
+ displayName: ownerName
922
+ }
923
+ };
924
+ return view;
925
+ }
926
+ /** 2: use this group. 1: review, keep looking. 0: this enrollment was refused. -1: stop. */
927
+ function usableStatusRank(result) {
928
+ if ("error" in result) return -1;
929
+ if (result.ownerProjection) return 2;
930
+ if (result.remote.kind === "status") return result.remote.state === "review_required" ? 1 : 2;
931
+ if (result.remote.kind === "unavailable" && result.remote.code === "coordinator_rejected") return 0;
932
+ return -1;
933
+ }
934
+ /**
935
+ * Account status is per coordinator, and the group only picks which enrollment
936
+ * signs the request. Try configured groups in order and keep the first one
937
+ * whose enrollment the coordinator accepts, preferring a decided status over
938
+ * review. Each attempt is a full signed read with its own context checks.
939
+ */
940
+ async function readFirstUsableGroupStatus(options, groups, signal) {
941
+ let best = null;
942
+ for (const groupId of groups) {
943
+ if (signal.aborted) break;
944
+ const result = await readGroupAccountStatus(options, groupId, signal);
945
+ const rank = usableStatusRank(result);
946
+ if (rank === 2) return result;
947
+ if (rank === -1) return best && usableStatusRank(best) === 1 ? best : result;
948
+ if (!best || rank > usableStatusRank(best)) best = result;
949
+ }
950
+ return best ?? readGroupAccountStatus(options, void 0, signal);
951
+ }
952
+ async function readGroupAccountStatus(options, groupId, signal) {
953
+ const initial = options.readContext(groupId);
954
+ if ("error" in initial) return initial;
955
+ return readStatusFromContext(options, initial, groupId, signal);
956
+ }
957
+ async function readStatusFromContext(options, initial, groupId, signal, readOptions = {}) {
764
958
  const view = accountLocalView(initial);
765
959
  if (initial.setup !== "ready" || !initial.context) return attachOperation(view, options.registry);
766
960
  const snapshot = JSON.stringify(initial);
@@ -804,13 +998,21 @@ async function readAccountStatus(options, groupId, signal, readOptions = {}) {
804
998
  },
805
999
  blockedReason: "status_unavailable"
806
1000
  }, options.registry);
807
- return finishAccountStatus(options, initial.context, fresh, remote, signal);
1001
+ return finishAccountStatus(options, initial.context, fresh, remote, {
1002
+ signal,
1003
+ ownIdentityOnly: readOptions.ownIdentityOnly === true
1004
+ });
808
1005
  }
809
- async function finishAccountStatus(options, context, fresh, remote, signal) {
1006
+ async function finishAccountStatus(options, context, fresh, remote, read) {
1007
+ const { signal } = read;
810
1008
  const currentView = applyRemote(accountLocalView(fresh), remote);
811
1009
  if (remote.kind === "status") {
812
1010
  const associated = await readAssociatedOwner(options, context, currentView, remote, signal);
813
- if (associated) return attachOperation(associated, options.registry, context.contextId);
1011
+ if (associated) {
1012
+ const owner = withOwnerSignIn(associated, currentView, context, remote);
1013
+ if (read.ownIdentityOnly && owner.signIn?.contextId === context.contextId) return attachOperation(currentView, options.registry);
1014
+ return attachOperation(owner, options.registry, context.contextId);
1015
+ }
814
1016
  }
815
1017
  await attachManagementActions(options, context, currentView, signal);
816
1018
  if (remote.kind === "status") try {
@@ -824,7 +1026,8 @@ async function readAssociatedOwner(options, context, view, actorRemote, signal)
824
1026
  const local = {
825
1027
  ...view,
826
1028
  remote: { kind: "not_checked" },
827
- canLink: false
1029
+ canLink: false,
1030
+ canSignIn: false
828
1031
  };
829
1032
  try {
830
1033
  const association = readOwnerAssociation(context, actorRemote.status.coordinatorId);
@@ -944,7 +1147,7 @@ function isCurrent(options, operation) {
944
1147
  return !("error" in fresh) && fresh.setup === "ready" && fresh.context?.groupId === operation.groupId && fresh.context.contextId === operation.contextId;
945
1148
  }
946
1149
  function validStartBody(body) {
947
- return body !== null && validString(body.groupId) && validString(body.contextId) && validString(body.coordinatorId) && (body.intent !== "disconnect" || validString(body.expectedLinkId)) && (body.intent === void 0 || body.intent === "connect" || body.intent === "refresh" || body.intent === "disconnect") && (body.authority === "controller_attestation" || body.authority === "device_claim");
1150
+ return body !== null && validString(body.groupId) && validString(body.contextId) && validString(body.coordinatorId) && (body.intent !== "disconnect" || validString(body.expectedLinkId)) && (body.intent === void 0 || body.intent === "connect" || body.intent === "refresh" || body.intent === "disconnect" || body.intent === "sign_in") && (body.authority === "controller_attestation" || body.authority === "device_claim");
948
1151
  }
949
1152
  function unlinkTargetChanged(body, view) {
950
1153
  return body.intent === "disconnect" && body.expectedLinkId !== view.unlinkTarget;
@@ -962,7 +1165,10 @@ async function startOperation(c, options) {
962
1165
  if ("error" in initial || initial.setup !== "ready" || !initial.context || initial.context?.contextId !== body.contextId) return c.json({ error: "account_context_changed" }, 409);
963
1166
  const checkedContext = structuredClone(initial.context);
964
1167
  const expectedLifecycleToken = options.registry.lifecycleToken();
965
- const view = await readAccountStatus(options, body.groupId, c.req.raw.signal, { expectedContext: checkedContext });
1168
+ const view = await readAccountStatus(options, body.groupId, c.req.raw.signal, {
1169
+ expectedContext: checkedContext,
1170
+ ownIdentityOnly: body.intent === "sign_in"
1171
+ });
966
1172
  if ("error" in view || view.setup !== "ready" || view.contextId !== body.contextId || view.remote.kind === "unavailable" && view.remote.code === "context_changed") return c.json({ error: "account_context_changed" }, 409);
967
1173
  if (view.operation && view.operation.finishedAt === null) return c.json({
968
1174
  error: "account_link_in_progress",
@@ -982,6 +1188,7 @@ async function startOperation(c, options) {
982
1188
  }
983
1189
  function actionAllowed(view, intent = "connect") {
984
1190
  if (intent === "connect") return view.canLink;
1191
+ if (intent === "sign_in") return view.canSignIn === true;
985
1192
  if (intent === "refresh" || intent === "disconnect") return view.managementActions?.[intent] === true;
986
1193
  return false;
987
1194
  }
@@ -1059,6 +1266,14 @@ var AccountOwnerRecoveryError = class extends Error {
1059
1266
  this.code = code;
1060
1267
  }
1061
1268
  };
1269
+ /**
1270
+ * The fix for an unsafe local state directory, or null for any other error. Matches the
1271
+ * core error by code because the bundle can carry a second copy of the core class.
1272
+ */
1273
+ function unsafeStorageMessage(error) {
1274
+ if (!(error instanceof Error) || !("code" in error)) return null;
1275
+ return error.code === "account_owner_association_directory_unsafe" ? error.message : null;
1276
+ }
1062
1277
  function requireRecovery(condition, code = "invalid") {
1063
1278
  if (!condition) throw new AccountOwnerRecoveryError(code);
1064
1279
  }
@@ -1100,7 +1315,7 @@ function readWeakLabel(value) {
1100
1315
  effect: row.effect
1101
1316
  };
1102
1317
  }
1103
- function historyRows(value, selection, labels) {
1318
+ function historyRows$1(value, selection, labels) {
1104
1319
  const row = recoveryRecord(value);
1105
1320
  requireRecovery(recoveryId(row.group_id) && recoveryId(row.candidate_ref) && timestamp(row.created_at));
1106
1321
  const manifest = normalizeCoordinatorLegacyTeamCompletionManifest(row.manifest);
@@ -1118,7 +1333,7 @@ function historyRows(value, selection, labels) {
1118
1333
  identityId: decision.identity_id ?? "",
1119
1334
  assignment: decision.decision,
1120
1335
  relation,
1121
- reason: "Retained canonical Team decision; display metadata only."
1336
+ reason: legacyDecisionCountsAsOwner(decision) ? "Recorded in Team history." : "Guessed by another device in Team history."
1122
1337
  };
1123
1338
  });
1124
1339
  }
@@ -1156,7 +1371,7 @@ function validateRecoveryReview(value, selection, groupId) {
1156
1371
  requireRecovery(Array.isArray(row.legacy_history) && row.legacy_history.length <= 500 && Array.isArray(row.reviewed_weak_labels) && row.reviewed_weak_labels.length <= 256 && Array.isArray(row.reasons) && row.reasons.every((reason) => typeof reason === "string"));
1157
1372
  const labels = row.reviewed_weak_labels.map(readWeakLabel);
1158
1373
  requireRecovery(new Set(labels.map((label) => JSON.stringify(label))).size === labels.length);
1159
- const rows = row.legacy_history.flatMap((history) => historyRows(history, selection, labels));
1374
+ const rows = row.legacy_history.flatMap((history) => historyRows$1(history, selection, labels));
1160
1375
  requireRecovery(row.reasons.length <= 8 && row.reasons.every((reason) => typeof reason === "string" && RECOVERY_REASON.test(reason)));
1161
1376
  const reasons = row.reasons;
1162
1377
  if (row.state === "needs_review") {
@@ -1367,6 +1582,11 @@ async function dispatch(c, service) {
1367
1582
  function accountOwnerRecoveryRoutes(service) {
1368
1583
  const app = new Hono();
1369
1584
  app.onError((error, c) => {
1585
+ const storage = unsafeStorageMessage(error);
1586
+ if (storage) return c.json({
1587
+ error: "account_owner_recovery_storage_unsafe",
1588
+ detail: storage
1589
+ }, 409);
1370
1590
  if (!(error instanceof AccountOwnerRecoveryError)) return c.json({ error: "account_owner_recovery_unavailable" }, 503);
1371
1591
  return c.json({ error: `account_owner_recovery_${error.code}` }, {
1372
1592
  invalid: 400,
@@ -6804,6 +7024,24 @@ function projectMappingChoices(store) {
6804
7024
  sourceDisplayName: candidate.display_project
6805
7025
  }));
6806
7026
  }
7027
+ function viewerSafeDevice(candidateRef, device) {
7028
+ return {
7029
+ deviceRef: device.deviceRef,
7030
+ displayName: device.displayName,
7031
+ enabled: device.enabled,
7032
+ existingIdentityRef: identityRef(candidateRef, device.existingIdentityId),
7033
+ suggestedIdentityRef: identityRef(candidateRef, device.suggestedIdentityId),
7034
+ verifiedEvidenceKind: device.verifiedEvidenceKind,
7035
+ identityEvidence: device.identityEvidence,
7036
+ decision: device.decision,
7037
+ targetIdentityRef: identityRef(candidateRef, device.targetIdentityId),
7038
+ expectation: device.expectation.kind === "existing" ? {
7039
+ kind: "existing",
7040
+ assignmentVersion: device.expectation.assignmentVersion,
7041
+ identityRef: requiredIdentityRef(candidateRef, device.expectation.identityId)
7042
+ } : { kind: "absent" }
7043
+ };
7044
+ }
6807
7045
  function viewerSafeDraft(store, draft) {
6808
7046
  if (draft.devices.length > MAX_DEVICES || draft.projects.length > MAX_PROJECTS) throw new Error("legacy_team_setup_roster_too_large");
6809
7047
  const completed = draft.state === "completed";
@@ -6813,21 +7051,7 @@ function viewerSafeDraft(store, draft) {
6813
7051
  requireCompleteMappingChoices(mappableProjectCount, mappingChoices.length);
6814
7052
  const mappingChoiceIdentities = mappingChoices.map((choice) => choice.projectIdentity);
6815
7053
  return {
6816
- devices: draft.devices.map((device) => ({
6817
- deviceRef: device.deviceRef,
6818
- displayName: device.displayName,
6819
- enabled: device.enabled,
6820
- existingIdentityRef: identityRef(draft.candidateRef, device.existingIdentityId),
6821
- suggestedIdentityRef: identityRef(draft.candidateRef, device.suggestedIdentityId),
6822
- verifiedEvidenceKind: device.verifiedEvidenceKind,
6823
- decision: device.decision,
6824
- targetIdentityRef: identityRef(draft.candidateRef, device.targetIdentityId),
6825
- expectation: device.expectation.kind === "existing" ? {
6826
- kind: "existing",
6827
- assignmentVersion: device.expectation.assignmentVersion,
6828
- identityRef: requiredIdentityRef(draft.candidateRef, device.expectation.identityId)
6829
- } : { kind: "absent" }
6830
- })),
7054
+ devices: draft.devices.map((device) => viewerSafeDevice(draft.candidateRef, device)),
6831
7055
  projects: draft.projects.map((project) => ({
6832
7056
  projectRef: project.projectRef,
6833
7057
  displayName: project.displayName,
@@ -9799,6 +10023,7 @@ function isPendingCaptureActorConflict(error) {
9799
10023
  /** False leaves the conflict untouched; callers must check before claiming or merging identity. */
9800
10024
  function ensureLocalActorRecord(store) {
9801
10025
  return store.db.transaction(() => {
10026
+ store.assertLocalIdentityCurrent();
9802
10027
  try {
9803
10028
  store.assertPendingLocalCaptureAdoptionActors();
9804
10029
  } catch (error) {
@@ -15358,6 +15583,7 @@ sqliteTable("legacy_team_setup_draft_devices", {
15358
15583
  existing_identity_id: text("existing_identity_id"),
15359
15584
  existing_assignment_version: integer("existing_assignment_version"),
15360
15585
  verified_evidence_kind: text("verified_evidence_kind"),
15586
+ identity_evidence: text("identity_evidence"),
15361
15587
  decision: text("decision").notNull().default("unresolved"),
15362
15588
  target_identity_id: text("target_identity_id"),
15363
15589
  expected_assignment_kind: text("expected_assignment_kind"),
@@ -15561,16 +15787,44 @@ function assertPublishedConfigLock(path, staged) {
15561
15787
  function accountOwnerAssociationDirectory() {
15562
15788
  return join(homedir(), ".codemem", "account-owner-associations");
15563
15789
  }
15790
+ /**
15791
+ * A state directory that other local users could change. The message names the path and
15792
+ * the command that fixes it, because callers show it to the person who owns the directory.
15793
+ */
15794
+ var AccountOwnerAssociationDirectoryError = class extends Error {
15795
+ path;
15796
+ problem;
15797
+ code = "account_owner_association_directory_unsafe";
15798
+ constructor(path, problem) {
15799
+ super(directoryProblemMessage(path, problem));
15800
+ this.path = path;
15801
+ this.problem = problem;
15802
+ this.name = "AccountOwnerAssociationDirectoryError";
15803
+ }
15804
+ };
15805
+ function directoryProblemMessage(path, problem) {
15806
+ if (problem === "shared_write") return `${path} is writable by other users. Run \`chmod go-w ${path}\`, then try again.`;
15807
+ if (problem === "not_private") return `${path} must be private to you. Run \`chmod 700 ${path}\`, then try again.`;
15808
+ if (problem === "wrong_owner") return `${path} is owned by another user. Make it yours.`;
15809
+ return `${path} must be a real directory, not a file or symlink.`;
15810
+ }
15811
+ function directoryProblem(stat, options) {
15812
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return "not_directory";
15813
+ if (typeof process.getuid === "function" && stat.uid !== process.getuid()) return "wrong_owner";
15814
+ if (options.private) return (stat.mode & 511) === 448 ? null : "not_private";
15815
+ return (stat.mode & 18) === 0 ? null : "shared_write";
15816
+ }
15564
15817
  function checkDirectory(path, options = {}) {
15818
+ let stat;
15565
15819
  try {
15566
- const stat = lstatSync(path);
15567
- const unsafeMode = options.private ? (stat.mode & 511) !== 448 : (stat.mode & 18) !== 0;
15568
- if (!stat.isDirectory() || stat.isSymbolicLink() || unsafeMode || typeof process.getuid === "function" && stat.uid !== process.getuid()) throw new Error("account_owner_association_invalid");
15569
- return true;
15820
+ stat = lstatSync(path);
15570
15821
  } catch (error) {
15571
15822
  if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return false;
15572
15823
  throw error;
15573
15824
  }
15825
+ const problem = directoryProblem(stat, options);
15826
+ if (problem) throw new AccountOwnerAssociationDirectoryError(path, problem);
15827
+ return true;
15574
15828
  }
15575
15829
  /** Missing directories stay missing during reads. Never follow a state-directory symlink. */
15576
15830
  function readAccountOwnerAssociationDirectory(path) {
@@ -15877,6 +16131,97 @@ function retainGeneration(profile, review, context) {
15877
16131
  return retained.record;
15878
16132
  }
15879
16133
  //#endregion
16134
+ //#region src/account/owner-recovery-candidates.ts
16135
+ function historyRows(response) {
16136
+ const history = response?.legacy_history;
16137
+ return Array.isArray(history) ? history : [];
16138
+ }
16139
+ /** Return [identity, group] pairs for this device, or none when the record is unreadable. */
16140
+ function recordedOwners(row, deviceId) {
16141
+ const record = row;
16142
+ const groupId = record?.group_id;
16143
+ if (!recoveryId(groupId)) return [];
16144
+ let manifest;
16145
+ try {
16146
+ manifest = normalizeCoordinatorLegacyTeamCompletionManifest(record?.manifest);
16147
+ } catch {
16148
+ return [];
16149
+ }
16150
+ return manifest.device_decisions.flatMap((decision) => {
16151
+ const identityId = decision.identity_id;
16152
+ if (decision.device_id !== deviceId || !recoveryId(identityId)) return [];
16153
+ if (!legacyDecisionCountsAsOwner(decision)) return [];
16154
+ return [[identityId, groupId]];
16155
+ });
16156
+ }
16157
+ /** Map each Identity recorded for this device to the groups whose Team history records it. */
16158
+ function recordedOwnerGroups(responses, device) {
16159
+ const owners = /* @__PURE__ */ new Map();
16160
+ const pairs = responses.flatMap(historyRows).flatMap((row) => recordedOwners(row, device.deviceId));
16161
+ for (const [identityId, groupId] of pairs) owners.set(identityId, /* @__PURE__ */ new Set([...owners.get(identityId) ?? [], groupId]));
16162
+ return owners;
16163
+ }
16164
+ var MAX_LABEL_LENGTH = 256;
16165
+ var LISTED_GROUPS = 3;
16166
+ function candidateLabel(prefix, groups) {
16167
+ const sorted = [...groups].sort();
16168
+ const listed = sorted.slice(0, LISTED_GROUPS).join(", ");
16169
+ const more = sorted.length - LISTED_GROUPS;
16170
+ const label = `${prefix} recorded for this device in ${more > 0 ? `${listed} and ${more} more` : listed}`;
16171
+ if (label.length <= MAX_LABEL_LENGTH) return label;
16172
+ return `${prefix} recorded for this device in ${sorted.length === 1 ? "1 group" : `${sorted.length} groups`}`;
16173
+ }
16174
+ var MAX_CANDIDATES = 256;
16175
+ function candidateList(owners, currentIdentityId) {
16176
+ const rank = (id) => id === currentIdentityId ? 0 : 1;
16177
+ return [...owners.entries()].sort(([left], [right]) => rank(left) - rank(right) || left.localeCompare(right)).slice(0, MAX_CANDIDATES).map(([id, groups]) => {
16178
+ return {
16179
+ id,
16180
+ label: candidateLabel(id === currentIdentityId ? "This profile's Identity," : "Identity", [...groups])
16181
+ };
16182
+ });
16183
+ }
16184
+ /**
16185
+ * The coordinator returns this device's history across all groups once it finds an
16186
+ * enrollment. It hides the enrollment on an identity mismatch but still sends the
16187
+ * history, so non-empty history also counts as complete.
16188
+ */
16189
+ function hasCompleteHistory(response) {
16190
+ const row = response;
16191
+ return row?.enrollment !== null && typeof row?.enrollment === "object" || historyRows(response).length > 0;
16192
+ }
16193
+ /**
16194
+ * Suggest owners from read-only coordinator previews of this device's current review.
16195
+ * Suggestions are display hints: the scoped owner preview still checks the chosen ID.
16196
+ *
16197
+ * Previews share the coordinator's admin review rate limit with the owner preview and
16198
+ * confirmation. History is filtered by device, not group, so this asks one group at a
16199
+ * time and stops at the first enrolled group instead of spending one request per group.
16200
+ */
16201
+ async function readRecoveryCandidates(options) {
16202
+ const { device } = options;
16203
+ const responses = [];
16204
+ for (const groupId of options.groups) {
16205
+ let response;
16206
+ try {
16207
+ response = await options.previewDeviceOwner({
16208
+ remoteUrl: options.remoteUrl,
16209
+ adminSecret: options.adminSecret,
16210
+ groupId,
16211
+ deviceId: device.deviceId,
16212
+ fingerprint: device.fingerprint,
16213
+ identityId: device.identityId,
16214
+ timeoutS: options.timeoutS
16215
+ });
16216
+ } catch {
16217
+ continue;
16218
+ }
16219
+ responses.push(response);
16220
+ if (hasCompleteHistory(response)) break;
16221
+ }
16222
+ return candidateList(recordedOwnerGroups(responses, device), device.identityId);
16223
+ }
16224
+ //#endregion
15880
16225
  //#region src/account/owner-recovery-context.ts
15881
16226
  function recoverySha(value) {
15882
16227
  return createHash("sha256").update(value).digest("hex");
@@ -15954,8 +16299,7 @@ function assertNoCompetingOwner(destinations, ownerIdentityId) {
15954
16299
  requireRecovery(!existing || existing.ownerIdentityId === ownerIdentityId);
15955
16300
  }
15956
16301
  }
15957
- //#endregion
15958
- //#region src/account/owner-recovery-service.ts
16302
+ var CANDIDATE_TTL_MS = 6e4;
15959
16303
  function groupAction(state) {
15960
16304
  if (state === "ready") return "new";
15961
16305
  if (state === "restored") return "reuse";
@@ -15971,6 +16315,7 @@ function staleDenial(error) {
15971
16315
  var AccountOwnerRecoveryService = class {
15972
16316
  #options;
15973
16317
  #handles = /* @__PURE__ */ new Map();
16318
+ #candidateCache;
15974
16319
  #reservations = /* @__PURE__ */ new Set();
15975
16320
  #controller = new AbortController();
15976
16321
  #closed = false;
@@ -15980,6 +16325,10 @@ var AccountOwnerRecoveryService = class {
15980
16325
  #now() {
15981
16326
  return (this.#options.now ?? Date.now)();
15982
16327
  }
16328
+ #timeoutS() {
16329
+ const configured = this.#options.readCoordinatorTimeoutS?.() ?? 0;
16330
+ return Math.max(10, Number.isFinite(configured) ? configured : 0);
16331
+ }
15983
16332
  #assertCurrent(capture, reservation) {
15984
16333
  requireRecovery(!this.#closed, "unavailable");
15985
16334
  requireRecovery(!reservation || reservation.isCurrent(), "context_changed");
@@ -16010,14 +16359,39 @@ var AccountOwnerRecoveryService = class {
16010
16359
  const capture = this.#capture();
16011
16360
  const coordinatorId = await this.#discover(capture);
16012
16361
  this.#assertCurrent(capture);
16013
- const reviewAccess = this.#options.readAdminSecret() ? "operator" : "request";
16362
+ const adminSecret = this.#options.readAdminSecret();
16363
+ const candidates = adminSecret ? await this.#candidates(capture, adminSecret) : [];
16014
16364
  this.#assertCurrent(capture);
16015
16365
  return {
16016
16366
  version: 1,
16017
16367
  profile: recoveryProfile(capture, coordinatorId),
16018
- reviewAccess,
16019
- candidates: []
16368
+ reviewAccess: adminSecret ? "operator" : "request",
16369
+ candidates
16370
+ };
16371
+ }
16372
+ async #candidates(capture, adminSecret) {
16373
+ const cached = this.#candidateCache;
16374
+ if (cached?.semanticId === capture.semanticId && this.#now() - cached.at < CANDIDATE_TTL_MS) return structuredClone(cached.candidates);
16375
+ const c = capture.context;
16376
+ const candidates = await readRecoveryCandidates({
16377
+ previewDeviceOwner: this.#options.previewDeviceOwner ?? coordinatorAuthControllerReviewAction,
16378
+ remoteUrl: c.coordinatorUrl,
16379
+ adminSecret,
16380
+ groups: capture.groups,
16381
+ device: {
16382
+ deviceId: c.deviceId,
16383
+ fingerprint: c.fingerprint,
16384
+ identityId: c.identityId
16385
+ },
16386
+ timeoutS: this.#timeoutS()
16387
+ });
16388
+ this.#assertCurrent(capture);
16389
+ this.#candidateCache = {
16390
+ semanticId: capture.semanticId,
16391
+ at: this.#now(),
16392
+ candidates
16020
16393
  };
16394
+ return structuredClone(candidates);
16021
16395
  }
16022
16396
  #reviewInput(capture, selection, groupId) {
16023
16397
  const adminSecret = this.#options.readAdminSecret();
@@ -16031,7 +16405,8 @@ var AccountOwnerRecoveryService = class {
16031
16405
  groupId,
16032
16406
  deviceId: capture.context.deviceId,
16033
16407
  fingerprint: capture.context.fingerprint,
16034
- identityId: selection.existingOwnerId
16408
+ identityId: selection.existingOwnerId,
16409
+ timeoutS: this.#timeoutS()
16035
16410
  };
16036
16411
  }
16037
16412
  async #previewReview(entry, groupId) {
@@ -16197,7 +16572,7 @@ var AccountOwnerRecoveryService = class {
16197
16572
  return { outcome: {
16198
16573
  groupId,
16199
16574
  status: staleDenial(error) ? "failed" : "unknown",
16200
- message: "Check coordinator status or import original receipts before trying again."
16575
+ message: unsafeStorageMessage(error) ?? "Check coordinator status or import original receipts before trying again."
16201
16576
  } };
16202
16577
  }
16203
16578
  }
@@ -16272,11 +16647,11 @@ var AccountOwnerRecoveryService = class {
16272
16647
  async #importGroup(entry, groupId, receipt, reservation) {
16273
16648
  try {
16274
16649
  return await this.#verifyAndRetain(entry, groupId, receipt, reservation);
16275
- } catch {
16650
+ } catch (error) {
16276
16651
  return {
16277
16652
  groupId,
16278
16653
  status: "unknown",
16279
- message: "Context or proof outcome changed; check status before importing again."
16654
+ message: unsafeStorageMessage(error) ?? "Context or proof outcome changed; check status before importing again."
16280
16655
  };
16281
16656
  }
16282
16657
  }
@@ -16291,6 +16666,143 @@ function createAccountOwnerRecoveryService(options) {
16291
16666
  return new AccountOwnerRecoveryService(options);
16292
16667
  }
16293
16668
  //#endregion
16669
+ //#region src/account/rebind.ts
16670
+ /**
16671
+ * Viewer side of "Sign in with Google" when the account belongs to another
16672
+ * Identity (ADR 0005 Section 8): run the coordinator rebind, then switch this
16673
+ * device's local Identity. An unclear outcome stays recorded and resumes.
16674
+ */
16675
+ /** Pins the local facts the person reviewed; the coordinator stores and signs it. */
16676
+ function reviewDigest(store, context) {
16677
+ const binding = store.db.prepare("SELECT identity_id, assignment_version FROM identity_devices WHERE device_id = ?").get(context.deviceId);
16678
+ const localActors = store.db.prepare("SELECT actor_id FROM actors WHERE is_local = 1 ORDER BY actor_id").pluck().all();
16679
+ return createHash("sha256").update(JSON.stringify([
16680
+ "codemem-device-rebind-review-v1",
16681
+ context.deviceId,
16682
+ context.fingerprint,
16683
+ context.identityId,
16684
+ binding ?? null,
16685
+ localActors
16686
+ ])).digest("hex");
16687
+ }
16688
+ /**
16689
+ * Finishes the local half from a coordinator receipt. Each step is
16690
+ * idempotent, so a later resume can repeat it.
16691
+ */
16692
+ function finishLocalSwitch(store, paths, attemptId, receipt) {
16693
+ try {
16694
+ if (receipt) markLocalIdentitySwitchBound(store.db, receipt);
16695
+ const context = { configPath: paths.configPath };
16696
+ applyLocalIdentitySwitch(store.db, {
16697
+ attemptId,
16698
+ displayName: store.actorDisplayName,
16699
+ expectedPublicKey: loadPublicKey(paths.keysDir) ?? ""
16700
+ }, context);
16701
+ completeLocalIdentitySwitch(store.db, {
16702
+ attemptId,
16703
+ configPath: paths.configPath
16704
+ });
16705
+ const target = readTarget(store, attemptId);
16706
+ if (!target || !store.refreshPersistedLocalIdentity(target)) return { kind: "pending" };
16707
+ return {
16708
+ kind: "switched",
16709
+ targetIdentityId: target
16710
+ };
16711
+ } catch {
16712
+ return { kind: "pending" };
16713
+ }
16714
+ }
16715
+ function readTarget(store, attemptId) {
16716
+ const row = store.db.prepare("SELECT target_identity_id FROM local_identity_switches WHERE attempt_id = ? AND state = 'completed'").pluck().get(attemptId);
16717
+ return typeof row === "string" ? row : null;
16718
+ }
16719
+ function createAccountRebindRunner(store) {
16720
+ return async ({ context, signal, onBrowserStart, isCurrent = () => true }) => {
16721
+ try {
16722
+ assertNoLocalIdentityOverride();
16723
+ } catch {
16724
+ return { kind: "failed" };
16725
+ }
16726
+ const earlier = await resolveOpenSwitch(store, context);
16727
+ if (earlier) return earlier;
16728
+ const recorded = {};
16729
+ return settleOutcome(store, context, await runRebind(store, context, {
16730
+ signal,
16731
+ onBrowserStart,
16732
+ recorded,
16733
+ isCurrent
16734
+ }), recorded.attemptId);
16735
+ };
16736
+ }
16737
+ /** An earlier switch left open is resolved first; no new Google trip until it is. */
16738
+ async function resolveOpenSwitch(store, context) {
16739
+ if (!readOpenLocalIdentitySwitch(store.db)) return null;
16740
+ const resumed = await resumeAccountRebind(store, context);
16741
+ if (resumed?.kind === "switched") return resumed;
16742
+ return readOpenLocalIdentitySwitch(store.db) ? { kind: "pending" } : null;
16743
+ }
16744
+ /**
16745
+ * A refusal or failure means this run's finalize did not commit, and nothing
16746
+ * else sends it, so the attempt this run recorded can close at once.
16747
+ */
16748
+ function settleOutcome(store, context, outcome, recordedAttemptId) {
16749
+ if ((outcome.kind === "refused" || outcome.kind === "failed") && recordedAttemptId) markLocalIdentitySwitchNotCommitted(store.db, recordedAttemptId);
16750
+ if (outcome.kind === "refused") return {
16751
+ kind: "refused",
16752
+ reason: outcome.reason,
16753
+ ...outcome.beforeBrowser ? { beforeBrowser: true } : {}
16754
+ };
16755
+ if (outcome.kind === "failed") return { kind: "failed" };
16756
+ if (outcome.kind === "unknown") return isSelfBind(outcome.pending) ? { kind: "bind_unknown" } : { kind: "pending" };
16757
+ if (isSelfBind(outcome.receipt)) return { kind: "bound" };
16758
+ return finishLocalSwitch(store, context, outcome.receipt.attemptId, outcome.receipt);
16759
+ }
16760
+ /** A self-bind keeps this device's Identity, so it has no local switch. */
16761
+ function isSelfBind(attempt) {
16762
+ return attempt.targetIdentityId === attempt.previousIdentityId;
16763
+ }
16764
+ function runRebind(store, context, run) {
16765
+ return runDeviceRebind({
16766
+ dbPath: context.dbPath,
16767
+ keysDir: context.keysDir,
16768
+ coordinatorUrl: context.coordinatorUrl,
16769
+ groupId: context.groupId,
16770
+ currentIdentityId: context.identityId,
16771
+ reviewDigest: reviewDigest(store, context),
16772
+ openBrowser: run.onBrowserStart,
16773
+ recordPending: (pending) => {
16774
+ if (!run.isCurrent()) throw new Error("device_rebind_context_changed");
16775
+ if (reviewDigest(store, context) !== pending.reviewDigest) throw new Error("device_rebind_review_changed");
16776
+ if (isSelfBind(pending)) return;
16777
+ recordLocalIdentitySwitch(store.db, pending, { configPath: context.configPath });
16778
+ run.recorded.attemptId = pending.attemptId;
16779
+ },
16780
+ signal: run.signal
16781
+ });
16782
+ }
16783
+ /**
16784
+ * Picks up a switch an earlier run left open. A recorded attempt is resolved
16785
+ * by reading coordinator status, never by sending finalize again.
16786
+ */
16787
+ async function resumeAccountRebind(store, paths) {
16788
+ const open = readOpenLocalIdentitySwitch(store.db);
16789
+ if (!open) return null;
16790
+ if (open.state !== "pending") return finishLocalSwitch(store, paths, open.attemptId);
16791
+ const resumed = await resumeDeviceRebind({
16792
+ dbPath: store.dbPath,
16793
+ keysDir: paths.keysDir,
16794
+ coordinatorUrl: open.coordinatorOrigin,
16795
+ groupId: open.groupId,
16796
+ pending: open
16797
+ });
16798
+ if (resumed.kind === "finalized") return finishLocalSwitch(store, paths, open.attemptId, resumed.receipt);
16799
+ if (resumed.kind === "not_committed") {
16800
+ markLocalIdentitySwitchNotCommitted(store.db, open.attemptId);
16801
+ return { kind: "failed" };
16802
+ }
16803
+ return { kind: "pending" };
16804
+ }
16805
+ //#endregion
16294
16806
  //#region src/index.ts
16295
16807
  /**
16296
16808
  * @codemem/server — HTTP server (viewer, sync, API).
@@ -16443,6 +16955,6 @@ function createSyncApp(opts) {
16443
16955
  return app;
16444
16956
  }
16445
16957
  //#endregion
16446
- export { FileRawEventInbox, VERSION, advancePendingProjectShares, advancePendingProjectSharesOperation, advanceProjectShareOperation, closeStore, closeStoreAsync, createAccountContextReader, createAccountLinkRegistry, createAccountOwnerRecoveryService, createApp, createRecipientPolicyReconcilerEffects, createSyncApp, createViewerRawEventInbox, getStore, listRecipientPolicyReconciliationStatus, recipientPolicyCapabilityFromStatus, reconcileConfiguredCoordinatorEnrollment, reconcileRecipientPolicyProjects, reconcileRecipientPolicyProjectsOperation, resolveRawEventInboxDirectory };
16958
+ export { FileRawEventInbox, VERSION, advancePendingProjectShares, advancePendingProjectSharesOperation, advanceProjectShareOperation, closeStore, closeStoreAsync, createAccountContextReader, createAccountLinkRegistry, createAccountOwnerRecoveryService, createAccountRebindRunner, createApp, createRecipientPolicyReconcilerEffects, createSyncApp, createViewerRawEventInbox, getStore, listRecipientPolicyReconciliationStatus, recipientPolicyCapabilityFromStatus, reconcileConfiguredCoordinatorEnrollment, reconcileRecipientPolicyProjects, reconcileRecipientPolicyProjectsOperation, resolveRawEventInboxDirectory, resumeAccountRebind };
16447
16959
 
16448
16960
  //# sourceMappingURL=index.js.map