Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ export interface BootstrapRepository {
setAvatarSkin(patch: AvatarSkinPatch): AppSettingsDto;
getOnboardingState(): OnboardingStateDto;
updateOnboarding(patch: PatchOnboardingInput): OnboardingStateDto;
/** Copies only the completed-guide invariants from the active account into the local BYOK scope. */
preserveCompletedOnboardingForLocalByok(): boolean;
getPrivacySettings(): PrivacySettingsDto;
updatePrivacy(patch: PatchPrivacyInput): PrivacySettingsDto;
getScanPreferences(): ScanPreferences;
Expand Down Expand Up @@ -252,6 +254,65 @@ export function createBootstrapRepository(db: DatabaseSync): BootstrapRepository
return this.getOnboardingState();
},

preserveCompletedOnboardingForLocalByok() {
const sourceUuid = getActiveUuidWithDefaults(db);
if (!sourceUuid || sourceUuid === LOCAL_BYOK_ACCOUNT_UUID) {
return false;
}

const source = getRequiredRow<Pick<
OnboardingStateRow,
"has_finished_guide" | "has_accepted_terms" | "accepted_terms_version" | "completed_at"
>>(
db,
`SELECT
has_finished_guide,
has_accepted_terms,
accepted_terms_version,
completed_at
FROM account_onboarding_state
WHERE uuid = ?`,
[sourceUuid]
);
if (!toBoolean(source.has_finished_guide)) {
return false;
}

ensureLocalOnboardingDefaults(db);
const now = new Date().toISOString();
// Scan permission belongs to the installation scope, while the account's
// improvement-program choice must never become BYOK consent.
const result = db.prepare(
`UPDATE account_onboarding_state
SET has_finished_guide = 1,
current_step = 'completed',
has_accepted_terms = CASE WHEN has_accepted_terms = 1 THEN 1 ELSE ? END,
accepted_terms_version = CASE
WHEN has_accepted_terms = 1 THEN COALESCE(accepted_terms_version, ?)
WHEN ? = 1 THEN ?
ELSE accepted_terms_version
END,
improvement_program = CASE
WHEN improvement_program = 'unset' THEN 'not_applicable'
ELSE improvement_program
END,
completed_at = COALESCE(completed_at, ?, ?),
updated_at = ?
WHERE uuid = ?
AND has_finished_guide = 0`
).run(
source.has_accepted_terms,
source.accepted_terms_version,
source.has_accepted_terms,
source.accepted_terms_version,
source.completed_at,
now,
now,
LOCAL_BYOK_ACCOUNT_UUID
);
return result.changes > 0;
},

getPrivacySettings() {
const uuid = resolvePrivacyUuidWithDefaults(db);

Expand Down
4 changes: 4 additions & 0 deletions App/backend/src/services/account-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
AccountSessionProfileInput,
AccountSessionRepository
} from "../infrastructure/app-state-store/repositories/account-session-repo.js";
import type { BootstrapRepository } from "../infrastructure/app-state-store/repositories/bootstrap-repo.js";
import type { MemmyConfigWriter, RuntimeProjectionResult } from "../infrastructure/memmy-config/index.js";
import type { MemoryClient } from "../adapters/outbound/memory-client/index.js";
import type { OkResponse } from "@memmy/local-api-contracts";
Expand All @@ -41,6 +42,8 @@ export interface CreateAccountServiceOptions {
cloudClient: CloudClient;
/** Account session repository. */
accountSessionRepository: AccountSessionRepository;
/** Bootstrap repository used to preserve machine-level onboarding across logout. */
bootstrapRepository: Pick<BootstrapRepository, "preserveCompletedOnboardingForLocalByok">;
/** Memmy config writer. */
memmyConfigWriter?: MemmyConfigWriter;
/** Memory client. */
Expand Down Expand Up @@ -175,6 +178,7 @@ export function createAccountService(options: CreateAccountServiceOptions): Acco
async logout() {
const uuid = options.accountSessionRepository.getCloudUuid();
const session = options.accountSessionRepository.get();
options.bootstrapRepository.preserveCompletedOnboardingForLocalByok();
if (uuid) {
try {
await options.cloudClient.logout({ uuid });
Expand Down
1 change: 1 addition & 0 deletions App/backend/src/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ export function createBackendServices(options: CreateBackendServicesOptions): Ba
account: createAccountService({
cloudClient: options.cloudClient,
accountSessionRepository: options.appStateStore.repositories.accountSession,
bootstrapRepository: options.appStateStore.repositories.bootstrap,
memmyConfigWriter: options.memmyConfigWriter,
memoryClient: options.memoryClient,
accountChannel: options.accountChannel
Expand Down
198 changes: 197 additions & 1 deletion App/backend/src/services/tests/account-service.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,31 @@
/** Account service tests. */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { createAccountService } from "../account-service.js";
import { LOCAL_BYOK_ACCOUNT_UUID } from "../../infrastructure/app-state-store/account-context.js";
import { createAppStateStore } from "../../infrastructure/app-state-store/index.js";
import { INSTALLATION_SCAN_SCOPE_UUID } from "../../infrastructure/installation-scan-scope.js";
import {
createAccountService as createAccountServiceImplementation,
type CreateAccountServiceOptions
} from "../account-service.js";

type TestAccountServiceOptions = Omit<CreateAccountServiceOptions, "bootstrapRepository"> & {
bootstrapRepository?: CreateAccountServiceOptions["bootstrapRepository"];
};

function createAccountService(options: TestAccountServiceOptions) {
const { bootstrapRepository, ...rest } = options;
return createAccountServiceImplementation({
...rest,
bootstrapRepository: bootstrapRepository ?? {
preserveCompletedOnboardingForLocalByok() {
return false;
}
}
});
}

describe("AccountService", () => {
it("rejects verification channels that are not supported by the desktop package", async () => {
Expand Down Expand Up @@ -579,6 +604,12 @@ describe("AccountService", () => {
return true;
}
},
bootstrapRepository: {
preserveCompletedOnboardingForLocalByok() {
calls.push("preserve-onboarding");
return true;
}
},
memmyConfigWriter: {
async writeAccountModelProjection() {
calls.push("write-account");
Expand Down Expand Up @@ -606,12 +637,170 @@ describe("AccountService", () => {

await expect(service.logout()).resolves.toEqual({ ok: true });
expect(calls).toEqual([
"preserve-onboarding",
"cloud-logout:cloud.login.uuid",
"clear-account-config:true:cloud.login.uuid",
"clear-if:cloud.login.uuid"
]);
});

it("preserves local onboarding before a failed cloud logout and still clears the local session", async () => {
const calls: string[] = [];
const service = createAccountService({
cloudClient: {
...createCloudClientStub(),
async logout() {
calls.push("cloud-logout");
throw new Error("cloud unavailable");
}
},
accountSessionRepository: {
...createAccountSessionRepositoryStub(),
getCloudUuid() {
return "cloud.login.uuid";
},
clearIfCloudUuid(cloudUuid) {
calls.push(`clear-if:${cloudUuid}`);
return true;
}
},
bootstrapRepository: {
preserveCompletedOnboardingForLocalByok() {
calls.push("preserve-onboarding");
return true;
}
}
});

await expect(service.logout()).resolves.toEqual({ ok: true });
expect(calls).toEqual([
"preserve-onboarding",
"cloud-logout",
"clear-if:cloud.login.uuid"
]);
});

it("preserves completed onboarding in the local BYOK scope when logout clears the active account", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "memmy-account-logout-onboarding-"));
const databasePath = join(tempDir, "app.sqlite");
let store: ReturnType<typeof createAppStateStore> | null = createAppStateStore({ databasePath });

try {
store.repositories.accountSession.upsert({
profile: cloudProfile(),
uuid: "cloud-account-user-1",
cloudUuid: "cloud.login.uuid",
isNewUser: false,
authChannel: "email"
});
store.repositories.bootstrap.updateOnboarding({
completed: true,
currentStep: "completed",
hasAcceptedTerms: true,
acceptedTermsVersion: "2026-06-01",
scanPermission: "scan_only",
firstEncounterReportStatus: "shown",
improvementProgram: "accepted",
completedAt: "2026-06-20T12:00:00.000Z"
});
const readInstallationOnboarding = () => store!.db.prepare(
`SELECT scan_permission, first_encounter_report_status, updated_at
FROM account_onboarding_state
WHERE uuid = ?`
).get(INSTALLATION_SCAN_SCOPE_UUID);
const installationBeforeLogout = readInstallationOnboarding();

const service = createAccountService({
cloudClient: createCloudClientStub(),
accountSessionRepository: store.repositories.accountSession,
bootstrapRepository: store.repositories.bootstrap
});

await expect(service.logout()).resolves.toEqual({ ok: true });
expect(readInstallationOnboarding()).toEqual(installationBeforeLogout);
expect(store.repositories.accountSession.get()).toEqual({ authenticated: false });
expect(store.repositories.bootstrap.getOnboardingState()).toMatchObject({
completed: true,
currentStep: "completed",
hasAcceptedTerms: true,
acceptedTermsVersion: "2026-06-01",
scanPermission: "scan_only",
firstEncounterReportStatus: "shown",
improvementProgram: "not_applicable",
completedAt: "2026-06-20T12:00:00.000Z"
});

expect(store.repositories.accountSession.activateByCloudUuid("cloud.login.uuid", "email")).toBe(true);
expect(store.repositories.bootstrap.getOnboardingState()).toMatchObject({
completed: true,
currentStep: "completed",
improvementProgram: "accepted",
completedAt: "2026-06-20T12:00:00.000Z"
});
expect(store.repositories.bootstrap.preserveCompletedOnboardingForLocalByok()).toBe(false);
store.repositories.accountSession.clear();

store.repositories.bootstrap.updateAppSettings({ userMode: "byok" });
store.close();
store = null;
store = createAppStateStore({ databasePath });

expect(store.repositories.bootstrap.getAppSettings().userMode).toBe("byok");
expect(store.repositories.bootstrap.getOnboardingState()).toMatchObject({
completed: true,
currentStep: "completed",
hasAcceptedTerms: true,
acceptedTermsVersion: "2026-06-01",
scanPermission: "scan_only",
firstEncounterReportStatus: "shown",
improvementProgram: "not_applicable",
completedAt: "2026-06-20T12:00:00.000Z"
});
} finally {
store?.close();
rmSync(tempDir, { recursive: true, force: true });
}
});

it("does not promote incomplete account onboarding into the local BYOK scope", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "memmy-account-logout-onboarding-"));
const databasePath = join(tempDir, "app.sqlite");
const store = createAppStateStore({ databasePath });

try {
store.repositories.accountSession.upsert({
profile: cloudProfile(),
uuid: "cloud-account-user-1",
cloudUuid: "cloud.login.uuid",
isNewUser: false,
authChannel: "email"
});
store.repositories.bootstrap.updateOnboarding({
completed: false,
currentStep: "product_tour_required",
improvementProgram: "accepted"
});
const readLocalByok = () => store.db.prepare(
`SELECT has_finished_guide, current_step, has_accepted_terms,
accepted_terms_version, improvement_program, completed_at, updated_at
FROM account_onboarding_state
WHERE uuid = ?`
).get(LOCAL_BYOK_ACCOUNT_UUID);
const before = readLocalByok();
const service = createAccountService({
cloudClient: createCloudClientStub(),
accountSessionRepository: store.repositories.accountSession,
bootstrapRepository: store.repositories.bootstrap
});

await expect(service.logout()).resolves.toEqual({ ok: true });
expect(readLocalByok()).toEqual(before);
} finally {
store.close();
rmSync(tempDir, { recursive: true, force: true });
}
});

it("does not clear a newer account session when an older manual logout finishes late", async () => {
const calls: string[] = [];
let activeCloudUuid: string | null = "cloud.login.uuid";
Expand Down Expand Up @@ -639,6 +828,12 @@ describe("AccountService", () => {
return true;
}
},
bootstrapRepository: {
preserveCompletedOnboardingForLocalByok() {
calls.push("preserve-onboarding");
return true;
}
},
memmyConfigWriter: {
async writeAccountModelProjection() {
return projectionResult();
Expand Down Expand Up @@ -667,6 +862,7 @@ describe("AccountService", () => {

expect(activeCloudUuid).toBe("cloud.new.uuid");
expect(calls).toEqual([
"preserve-onboarding",
"cloud-logout",
"clear-account-config:cloud.login.uuid",
"clear-if:cloud.login.uuid"
Expand Down
3 changes: 2 additions & 1 deletion App/frontend/desktop/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,8 @@ function RuntimeApp() {
bootstrap: effectiveBootstrap,
preferredMode: launchModeOverride ?? persistedPreferredMode,
accountSession,
guidanceCompleted
guidanceCompleted,
modelConfig
});
const initialPath = resolveLaunchInitialView({
defaultPath: defaultInitialPath,
Expand Down
22 changes: 15 additions & 7 deletions App/frontend/desktop/src/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ export interface ResolveInitialViewInput {
preferredMode: PreferredMode | null;
accountSession?: AccountSessionView;
guidanceCompleted?: boolean;
modelConfig?: ByokAgentModelAvailability | null;
}

export interface ByokAgentModelAvailability {
catalog?: {
modelAssignments: {
byok: { agent: { candidates: readonly string[] } };
};
} | null;
}

/** Contract for pet launch guard input. */
Expand Down Expand Up @@ -134,6 +143,11 @@ export function resolveInitialView(input: ResolveInitialViewInput): AppRoutePath
}

if (input.bootstrap.app.userMode === "byok") {
if (input.modelConfig !== undefined &&
!input.modelConfig?.catalog?.modelAssignments.byok.agent.candidates.length) {
return "/api-key";
}

if (input.bootstrap.onboarding.completed) {
return input.preferredMode === "pet" ? "/pet" : "/main";
}
Expand Down Expand Up @@ -226,13 +240,7 @@ export function resolveByokModelCompletion(input: ResolveByokModelCompletionInpu
/** Contract for resolve byok entry input. */
export interface ResolveByokEntryInput {
onboarding: OnboardingStateDto | undefined;
modelConfig?: {
catalog?: {
modelAssignments: {
byok: { agent: { candidates: string[] } };
};
};
} | null;
modelConfig?: ByokAgentModelAvailability | null;
}

/** Contract for resolve byok entry result. */
Expand Down
Loading
Loading