@codemem/server 0.47.0-beta.4 → 0.47.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.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
  }
@@ -1100,7 +1307,7 @@ function readWeakLabel(value) {
1100
1307
  effect: row.effect
1101
1308
  };
1102
1309
  }
1103
- function historyRows(value, selection, labels) {
1310
+ function historyRows$1(value, selection, labels) {
1104
1311
  const row = recoveryRecord(value);
1105
1312
  requireRecovery(recoveryId(row.group_id) && recoveryId(row.candidate_ref) && timestamp(row.created_at));
1106
1313
  const manifest = normalizeCoordinatorLegacyTeamCompletionManifest(row.manifest);
@@ -1118,7 +1325,7 @@ function historyRows(value, selection, labels) {
1118
1325
  identityId: decision.identity_id ?? "",
1119
1326
  assignment: decision.decision,
1120
1327
  relation,
1121
- reason: "Retained canonical Team decision; display metadata only."
1328
+ reason: legacyDecisionCountsAsOwner(decision) ? "Recorded in Team history." : "Guessed by another device in Team history."
1122
1329
  };
1123
1330
  });
1124
1331
  }
@@ -1156,7 +1363,7 @@ function validateRecoveryReview(value, selection, groupId) {
1156
1363
  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
1364
  const labels = row.reviewed_weak_labels.map(readWeakLabel);
1158
1365
  requireRecovery(new Set(labels.map((label) => JSON.stringify(label))).size === labels.length);
1159
- const rows = row.legacy_history.flatMap((history) => historyRows(history, selection, labels));
1366
+ const rows = row.legacy_history.flatMap((history) => historyRows$1(history, selection, labels));
1160
1367
  requireRecovery(row.reasons.length <= 8 && row.reasons.every((reason) => typeof reason === "string" && RECOVERY_REASON.test(reason)));
1161
1368
  const reasons = row.reasons;
1162
1369
  if (row.state === "needs_review") {
@@ -6804,6 +7011,24 @@ function projectMappingChoices(store) {
6804
7011
  sourceDisplayName: candidate.display_project
6805
7012
  }));
6806
7013
  }
7014
+ function viewerSafeDevice(candidateRef, device) {
7015
+ return {
7016
+ deviceRef: device.deviceRef,
7017
+ displayName: device.displayName,
7018
+ enabled: device.enabled,
7019
+ existingIdentityRef: identityRef(candidateRef, device.existingIdentityId),
7020
+ suggestedIdentityRef: identityRef(candidateRef, device.suggestedIdentityId),
7021
+ verifiedEvidenceKind: device.verifiedEvidenceKind,
7022
+ identityEvidence: device.identityEvidence,
7023
+ decision: device.decision,
7024
+ targetIdentityRef: identityRef(candidateRef, device.targetIdentityId),
7025
+ expectation: device.expectation.kind === "existing" ? {
7026
+ kind: "existing",
7027
+ assignmentVersion: device.expectation.assignmentVersion,
7028
+ identityRef: requiredIdentityRef(candidateRef, device.expectation.identityId)
7029
+ } : { kind: "absent" }
7030
+ };
7031
+ }
6807
7032
  function viewerSafeDraft(store, draft) {
6808
7033
  if (draft.devices.length > MAX_DEVICES || draft.projects.length > MAX_PROJECTS) throw new Error("legacy_team_setup_roster_too_large");
6809
7034
  const completed = draft.state === "completed";
@@ -6813,21 +7038,7 @@ function viewerSafeDraft(store, draft) {
6813
7038
  requireCompleteMappingChoices(mappableProjectCount, mappingChoices.length);
6814
7039
  const mappingChoiceIdentities = mappingChoices.map((choice) => choice.projectIdentity);
6815
7040
  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
- })),
7041
+ devices: draft.devices.map((device) => viewerSafeDevice(draft.candidateRef, device)),
6831
7042
  projects: draft.projects.map((project) => ({
6832
7043
  projectRef: project.projectRef,
6833
7044
  displayName: project.displayName,
@@ -9799,6 +10010,7 @@ function isPendingCaptureActorConflict(error) {
9799
10010
  /** False leaves the conflict untouched; callers must check before claiming or merging identity. */
9800
10011
  function ensureLocalActorRecord(store) {
9801
10012
  return store.db.transaction(() => {
10013
+ store.assertLocalIdentityCurrent();
9802
10014
  try {
9803
10015
  store.assertPendingLocalCaptureAdoptionActors();
9804
10016
  } catch (error) {
@@ -15358,6 +15570,7 @@ sqliteTable("legacy_team_setup_draft_devices", {
15358
15570
  existing_identity_id: text("existing_identity_id"),
15359
15571
  existing_assignment_version: integer("existing_assignment_version"),
15360
15572
  verified_evidence_kind: text("verified_evidence_kind"),
15573
+ identity_evidence: text("identity_evidence"),
15361
15574
  decision: text("decision").notNull().default("unresolved"),
15362
15575
  target_identity_id: text("target_identity_id"),
15363
15576
  expected_assignment_kind: text("expected_assignment_kind"),
@@ -15877,6 +16090,96 @@ function retainGeneration(profile, review, context) {
15877
16090
  return retained.record;
15878
16091
  }
15879
16092
  //#endregion
16093
+ //#region src/account/owner-recovery-candidates.ts
16094
+ function historyRows(response) {
16095
+ const history = response?.legacy_history;
16096
+ return Array.isArray(history) ? history : [];
16097
+ }
16098
+ /** Return [identity, group] pairs for this device, or none when the record is unreadable. */
16099
+ function recordedOwners(row, deviceId) {
16100
+ const record = row;
16101
+ const groupId = record?.group_id;
16102
+ if (!recoveryId(groupId)) return [];
16103
+ let manifest;
16104
+ try {
16105
+ manifest = normalizeCoordinatorLegacyTeamCompletionManifest(record?.manifest);
16106
+ } catch {
16107
+ return [];
16108
+ }
16109
+ return manifest.device_decisions.flatMap((decision) => {
16110
+ const identityId = decision.identity_id;
16111
+ if (decision.device_id !== deviceId || !recoveryId(identityId)) return [];
16112
+ if (!legacyDecisionCountsAsOwner(decision)) return [];
16113
+ return [[identityId, groupId]];
16114
+ });
16115
+ }
16116
+ /** Map each Identity recorded for this device to the groups whose Team history records it. */
16117
+ function recordedOwnerGroups(responses, device) {
16118
+ const owners = /* @__PURE__ */ new Map();
16119
+ const pairs = responses.flatMap(historyRows).flatMap((row) => recordedOwners(row, device.deviceId));
16120
+ for (const [identityId, groupId] of pairs) owners.set(identityId, /* @__PURE__ */ new Set([...owners.get(identityId) ?? [], groupId]));
16121
+ return owners;
16122
+ }
16123
+ var MAX_LABEL_LENGTH = 256;
16124
+ var LISTED_GROUPS = 3;
16125
+ function candidateLabel(prefix, groups) {
16126
+ const sorted = [...groups].sort();
16127
+ const listed = sorted.slice(0, LISTED_GROUPS).join(", ");
16128
+ const more = sorted.length - LISTED_GROUPS;
16129
+ const label = `${prefix} recorded for this device in ${more > 0 ? `${listed} and ${more} more` : listed}`;
16130
+ if (label.length <= MAX_LABEL_LENGTH) return label;
16131
+ return `${prefix} recorded for this device in ${sorted.length === 1 ? "1 group" : `${sorted.length} groups`}`;
16132
+ }
16133
+ var MAX_CANDIDATES = 256;
16134
+ function candidateList(owners, currentIdentityId) {
16135
+ const rank = (id) => id === currentIdentityId ? 0 : 1;
16136
+ return [...owners.entries()].sort(([left], [right]) => rank(left) - rank(right) || left.localeCompare(right)).slice(0, MAX_CANDIDATES).map(([id, groups]) => {
16137
+ return {
16138
+ id,
16139
+ label: candidateLabel(id === currentIdentityId ? "This profile's Identity," : "Identity", [...groups])
16140
+ };
16141
+ });
16142
+ }
16143
+ /**
16144
+ * The coordinator returns this device's history across all groups once it finds an
16145
+ * enrollment. It hides the enrollment on an identity mismatch but still sends the
16146
+ * history, so non-empty history also counts as complete.
16147
+ */
16148
+ function hasCompleteHistory(response) {
16149
+ const row = response;
16150
+ return row?.enrollment !== null && typeof row?.enrollment === "object" || historyRows(response).length > 0;
16151
+ }
16152
+ /**
16153
+ * Suggest owners from read-only coordinator previews of this device's current review.
16154
+ * Suggestions are display hints: the scoped owner preview still checks the chosen ID.
16155
+ *
16156
+ * Previews share the coordinator's admin review rate limit with the owner preview and
16157
+ * confirmation. History is filtered by device, not group, so this asks one group at a
16158
+ * time and stops at the first enrolled group instead of spending one request per group.
16159
+ */
16160
+ async function readRecoveryCandidates(options) {
16161
+ const { device } = options;
16162
+ const responses = [];
16163
+ for (const groupId of options.groups) {
16164
+ let response;
16165
+ try {
16166
+ response = await options.previewDeviceOwner({
16167
+ remoteUrl: options.remoteUrl,
16168
+ adminSecret: options.adminSecret,
16169
+ groupId,
16170
+ deviceId: device.deviceId,
16171
+ fingerprint: device.fingerprint,
16172
+ identityId: device.identityId
16173
+ });
16174
+ } catch {
16175
+ continue;
16176
+ }
16177
+ responses.push(response);
16178
+ if (hasCompleteHistory(response)) break;
16179
+ }
16180
+ return candidateList(recordedOwnerGroups(responses, device), device.identityId);
16181
+ }
16182
+ //#endregion
15880
16183
  //#region src/account/owner-recovery-context.ts
15881
16184
  function recoverySha(value) {
15882
16185
  return createHash("sha256").update(value).digest("hex");
@@ -15956,6 +16259,7 @@ function assertNoCompetingOwner(destinations, ownerIdentityId) {
15956
16259
  }
15957
16260
  //#endregion
15958
16261
  //#region src/account/owner-recovery-service.ts
16262
+ var CANDIDATE_TTL_MS = 6e4;
15959
16263
  function groupAction(state) {
15960
16264
  if (state === "ready") return "new";
15961
16265
  if (state === "restored") return "reuse";
@@ -15971,6 +16275,7 @@ function staleDenial(error) {
15971
16275
  var AccountOwnerRecoveryService = class {
15972
16276
  #options;
15973
16277
  #handles = /* @__PURE__ */ new Map();
16278
+ #candidateCache;
15974
16279
  #reservations = /* @__PURE__ */ new Set();
15975
16280
  #controller = new AbortController();
15976
16281
  #closed = false;
@@ -16010,14 +16315,38 @@ var AccountOwnerRecoveryService = class {
16010
16315
  const capture = this.#capture();
16011
16316
  const coordinatorId = await this.#discover(capture);
16012
16317
  this.#assertCurrent(capture);
16013
- const reviewAccess = this.#options.readAdminSecret() ? "operator" : "request";
16318
+ const adminSecret = this.#options.readAdminSecret();
16319
+ const candidates = adminSecret ? await this.#candidates(capture, adminSecret) : [];
16014
16320
  this.#assertCurrent(capture);
16015
16321
  return {
16016
16322
  version: 1,
16017
16323
  profile: recoveryProfile(capture, coordinatorId),
16018
- reviewAccess,
16019
- candidates: []
16324
+ reviewAccess: adminSecret ? "operator" : "request",
16325
+ candidates
16326
+ };
16327
+ }
16328
+ async #candidates(capture, adminSecret) {
16329
+ const cached = this.#candidateCache;
16330
+ if (cached?.semanticId === capture.semanticId && this.#now() - cached.at < CANDIDATE_TTL_MS) return structuredClone(cached.candidates);
16331
+ const c = capture.context;
16332
+ const candidates = await readRecoveryCandidates({
16333
+ previewDeviceOwner: this.#options.previewDeviceOwner ?? coordinatorAuthControllerReviewAction,
16334
+ remoteUrl: c.coordinatorUrl,
16335
+ adminSecret,
16336
+ groups: capture.groups,
16337
+ device: {
16338
+ deviceId: c.deviceId,
16339
+ fingerprint: c.fingerprint,
16340
+ identityId: c.identityId
16341
+ }
16342
+ });
16343
+ this.#assertCurrent(capture);
16344
+ this.#candidateCache = {
16345
+ semanticId: capture.semanticId,
16346
+ at: this.#now(),
16347
+ candidates
16020
16348
  };
16349
+ return structuredClone(candidates);
16021
16350
  }
16022
16351
  #reviewInput(capture, selection, groupId) {
16023
16352
  const adminSecret = this.#options.readAdminSecret();
@@ -16291,6 +16620,143 @@ function createAccountOwnerRecoveryService(options) {
16291
16620
  return new AccountOwnerRecoveryService(options);
16292
16621
  }
16293
16622
  //#endregion
16623
+ //#region src/account/rebind.ts
16624
+ /**
16625
+ * Viewer side of "Sign in with Google" when the account belongs to another
16626
+ * Identity (ADR 0005 Section 8): run the coordinator rebind, then switch this
16627
+ * device's local Identity. An unclear outcome stays recorded and resumes.
16628
+ */
16629
+ /** Pins the local facts the person reviewed; the coordinator stores and signs it. */
16630
+ function reviewDigest(store, context) {
16631
+ const binding = store.db.prepare("SELECT identity_id, assignment_version FROM identity_devices WHERE device_id = ?").get(context.deviceId);
16632
+ const localActors = store.db.prepare("SELECT actor_id FROM actors WHERE is_local = 1 ORDER BY actor_id").pluck().all();
16633
+ return createHash("sha256").update(JSON.stringify([
16634
+ "codemem-device-rebind-review-v1",
16635
+ context.deviceId,
16636
+ context.fingerprint,
16637
+ context.identityId,
16638
+ binding ?? null,
16639
+ localActors
16640
+ ])).digest("hex");
16641
+ }
16642
+ /**
16643
+ * Finishes the local half from a coordinator receipt. Each step is
16644
+ * idempotent, so a later resume can repeat it.
16645
+ */
16646
+ function finishLocalSwitch(store, paths, attemptId, receipt) {
16647
+ try {
16648
+ if (receipt) markLocalIdentitySwitchBound(store.db, receipt);
16649
+ const context = { configPath: paths.configPath };
16650
+ applyLocalIdentitySwitch(store.db, {
16651
+ attemptId,
16652
+ displayName: store.actorDisplayName,
16653
+ expectedPublicKey: loadPublicKey(paths.keysDir) ?? ""
16654
+ }, context);
16655
+ completeLocalIdentitySwitch(store.db, {
16656
+ attemptId,
16657
+ configPath: paths.configPath
16658
+ });
16659
+ const target = readTarget(store, attemptId);
16660
+ if (!target || !store.refreshPersistedLocalIdentity(target)) return { kind: "pending" };
16661
+ return {
16662
+ kind: "switched",
16663
+ targetIdentityId: target
16664
+ };
16665
+ } catch {
16666
+ return { kind: "pending" };
16667
+ }
16668
+ }
16669
+ function readTarget(store, attemptId) {
16670
+ const row = store.db.prepare("SELECT target_identity_id FROM local_identity_switches WHERE attempt_id = ? AND state = 'completed'").pluck().get(attemptId);
16671
+ return typeof row === "string" ? row : null;
16672
+ }
16673
+ function createAccountRebindRunner(store) {
16674
+ return async ({ context, signal, onBrowserStart, isCurrent = () => true }) => {
16675
+ try {
16676
+ assertNoLocalIdentityOverride();
16677
+ } catch {
16678
+ return { kind: "failed" };
16679
+ }
16680
+ const earlier = await resolveOpenSwitch(store, context);
16681
+ if (earlier) return earlier;
16682
+ const recorded = {};
16683
+ return settleOutcome(store, context, await runRebind(store, context, {
16684
+ signal,
16685
+ onBrowserStart,
16686
+ recorded,
16687
+ isCurrent
16688
+ }), recorded.attemptId);
16689
+ };
16690
+ }
16691
+ /** An earlier switch left open is resolved first; no new Google trip until it is. */
16692
+ async function resolveOpenSwitch(store, context) {
16693
+ if (!readOpenLocalIdentitySwitch(store.db)) return null;
16694
+ const resumed = await resumeAccountRebind(store, context);
16695
+ if (resumed?.kind === "switched") return resumed;
16696
+ return readOpenLocalIdentitySwitch(store.db) ? { kind: "pending" } : null;
16697
+ }
16698
+ /**
16699
+ * A refusal or failure means this run's finalize did not commit, and nothing
16700
+ * else sends it, so the attempt this run recorded can close at once.
16701
+ */
16702
+ function settleOutcome(store, context, outcome, recordedAttemptId) {
16703
+ if ((outcome.kind === "refused" || outcome.kind === "failed") && recordedAttemptId) markLocalIdentitySwitchNotCommitted(store.db, recordedAttemptId);
16704
+ if (outcome.kind === "refused") return {
16705
+ kind: "refused",
16706
+ reason: outcome.reason,
16707
+ ...outcome.beforeBrowser ? { beforeBrowser: true } : {}
16708
+ };
16709
+ if (outcome.kind === "failed") return { kind: "failed" };
16710
+ if (outcome.kind === "unknown") return isSelfBind(outcome.pending) ? { kind: "bind_unknown" } : { kind: "pending" };
16711
+ if (isSelfBind(outcome.receipt)) return { kind: "bound" };
16712
+ return finishLocalSwitch(store, context, outcome.receipt.attemptId, outcome.receipt);
16713
+ }
16714
+ /** A self-bind keeps this device's Identity, so it has no local switch. */
16715
+ function isSelfBind(attempt) {
16716
+ return attempt.targetIdentityId === attempt.previousIdentityId;
16717
+ }
16718
+ function runRebind(store, context, run) {
16719
+ return runDeviceRebind({
16720
+ dbPath: context.dbPath,
16721
+ keysDir: context.keysDir,
16722
+ coordinatorUrl: context.coordinatorUrl,
16723
+ groupId: context.groupId,
16724
+ currentIdentityId: context.identityId,
16725
+ reviewDigest: reviewDigest(store, context),
16726
+ openBrowser: run.onBrowserStart,
16727
+ recordPending: (pending) => {
16728
+ if (!run.isCurrent()) throw new Error("device_rebind_context_changed");
16729
+ if (reviewDigest(store, context) !== pending.reviewDigest) throw new Error("device_rebind_review_changed");
16730
+ if (isSelfBind(pending)) return;
16731
+ recordLocalIdentitySwitch(store.db, pending, { configPath: context.configPath });
16732
+ run.recorded.attemptId = pending.attemptId;
16733
+ },
16734
+ signal: run.signal
16735
+ });
16736
+ }
16737
+ /**
16738
+ * Picks up a switch an earlier run left open. A recorded attempt is resolved
16739
+ * by reading coordinator status, never by sending finalize again.
16740
+ */
16741
+ async function resumeAccountRebind(store, paths) {
16742
+ const open = readOpenLocalIdentitySwitch(store.db);
16743
+ if (!open) return null;
16744
+ if (open.state !== "pending") return finishLocalSwitch(store, paths, open.attemptId);
16745
+ const resumed = await resumeDeviceRebind({
16746
+ dbPath: store.dbPath,
16747
+ keysDir: paths.keysDir,
16748
+ coordinatorUrl: open.coordinatorOrigin,
16749
+ groupId: open.groupId,
16750
+ pending: open
16751
+ });
16752
+ if (resumed.kind === "finalized") return finishLocalSwitch(store, paths, open.attemptId, resumed.receipt);
16753
+ if (resumed.kind === "not_committed") {
16754
+ markLocalIdentitySwitchNotCommitted(store.db, open.attemptId);
16755
+ return { kind: "failed" };
16756
+ }
16757
+ return { kind: "pending" };
16758
+ }
16759
+ //#endregion
16294
16760
  //#region src/index.ts
16295
16761
  /**
16296
16762
  * @codemem/server — HTTP server (viewer, sync, API).
@@ -16443,6 +16909,6 @@ function createSyncApp(opts) {
16443
16909
  return app;
16444
16910
  }
16445
16911
  //#endregion
16446
- export { FileRawEventInbox, VERSION, advancePendingProjectShares, advancePendingProjectSharesOperation, advanceProjectShareOperation, closeStore, closeStoreAsync, createAccountContextReader, createAccountLinkRegistry, createAccountOwnerRecoveryService, createApp, createRecipientPolicyReconcilerEffects, createSyncApp, createViewerRawEventInbox, getStore, listRecipientPolicyReconciliationStatus, recipientPolicyCapabilityFromStatus, reconcileConfiguredCoordinatorEnrollment, reconcileRecipientPolicyProjects, reconcileRecipientPolicyProjectsOperation, resolveRawEventInboxDirectory };
16912
+ 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
16913
 
16448
16914
  //# sourceMappingURL=index.js.map