Files
GearBox/.planning/milestones/v2.2-phases/28-profile-and-logto-integration/28-01-PLAN.md
Jean-Luc Makiola 2853477a75
All checks were successful
CI / ci (push) Successful in 1m15s
CI / e2e (push) Has been skipped
CI / deploy (push) Has been skipped
chore: archive v2.2 User Experience Polish milestone
Phases 28-31 archived to milestones/v2.2-phases/
Requirements and roadmap snapshots archived to milestones/

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:00:35 +02:00

12 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, user_setup, must_haves
phase plan type wave depends_on files_modified autonomous requirements user_setup must_haves
28-profile-and-logto-integration 01 execute 1
src/server/services/logto.service.ts
src/server/routes/account.ts
src/server/index.ts
src/shared/schemas.ts
src/shared/types.ts
tests/services/logto.service.test.ts
true
type name source
env_var LOGTO_M2M_APP_ID Logto Console > Applications > Machine-to-Machine app > App ID
type name source
env_var LOGTO_M2M_APP_SECRET Logto Console > Applications > Machine-to-Machine app > App Secret
type name instructions
external_config Logto M2M Application Create a Machine-to-Machine application in Logto Console, assign the built-in "Logto Management API" role with "all" scope
truths artifacts key_links
Logto Management API client acquires and caches M2M access tokens
Password change endpoint verifies current password before setting new one
Email change endpoint updates primary email on Logto user record
Account deletion endpoint removes user from both GearBox DB and Logto
All account management endpoints require authentication
src/server/services/logto.service.ts
src/server/routes/account.ts
tests/services/logto.service.test.ts
logto.service.ts provides LogtoManagementClient used by account.ts routes
account.ts routes are registered in index.ts under /api/account
Zod schemas in shared/schemas.ts validate all request bodies
Create Logto Management API client service and account management API routes (password change, email change, account deletion) per D-04 and D-05.

Purpose: Backend foundation for all in-app account management — users never interact with Logto directly (D-04). Provides three account actions: change password, change email, delete account (D-05). Output: logto.service.ts (M2M client), account.ts (routes), Zod schemas, unit tests

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/28-profile-and-logto-integration/28-CONTEXT.md @.planning/phases/28-profile-and-logto-integration/28-RESEARCH.md

@src/server/services/auth.service.ts @src/server/routes/auth.ts @src/server/middleware/auth.ts @src/server/index.ts @src/db/schema.ts @src/shared/schemas.ts

<threat_model>

Threat Model

ID Threat Severity Mitigation
T-28-01 M2M app secret leaked in logs/errors HIGH Never log secrets; store in env vars only; redact in error messages
T-28-02 M2M token cached indefinitely, used after revocation MEDIUM Cache with TTL (token expiry minus 60s buffer); refresh on 401
T-28-03 Password change without verifying current password HIGH Always call Logto verifyPassword before updatePassword; reject on failure
T-28-04 Account deletion without confirmation HIGH Require typed "DELETE" confirmation string in request body
T-28-05 Unauthenticated access to account management HIGH All routes use requireAuth middleware
T-28-06 TOCTOU in deletion (user data changes between anonymize and delete) LOW Run deletion in a single transaction
</threat_model>
Task 1: Create Logto Management API client service src/server/services/logto.service.ts, tests/services/logto.service.test.ts - src/server/services/auth.service.ts (existing service pattern — DI with db parameter) - src/server/index.ts (env var patterns — OIDC_ISSUER) - .planning/phases/28-profile-and-logto-integration/28-RESEARCH.md (M2M token flow) - Test 1: getAccessToken() fetches token via client_credentials grant and caches it - Test 2: getAccessToken() returns cached token when not expired - Test 3: getAccessToken() refreshes token when expired (tokenExpiry < Date.now()) - Test 4: verifyPassword(logtoSub, password) calls POST /api/users/{logtoSub}/password/verify - Test 5: updatePassword(logtoSub, newPassword) calls PATCH /api/users/{logtoSub}/password - Test 6: hasPassword(logtoSub) calls GET /api/users/{logtoSub}/has-password and returns boolean - Test 7: updateEmail(logtoSub, email) calls PATCH /api/users/{logtoSub} with primaryEmail field - Test 8: deleteUser(logtoSub) calls DELETE /api/users/{logtoSub} - Test 9: getUser(logtoSub) calls GET /api/users/{logtoSub} and returns user object Create `src/server/services/logto.service.ts`:
interface LogtoManagementConfig {
  issuer: string;        // from OIDC_ISSUER env var
  m2mAppId: string;      // from LOGTO_M2M_APP_ID env var  
  m2mAppSecret: string;  // from LOGTO_M2M_APP_SECRET env var
  apiResource: string;   // https://default.logto.app/api (or from LOGTO_API_RESOURCE)
}

Implement LogtoManagementClient class:

  • Constructor reads config from env vars. If LOGTO_M2M_APP_ID or LOGTO_M2M_APP_SECRET are not set, all methods throw a clear error "Logto M2M not configured".
  • getAccessToken(): POST to {issuer}/oidc/token with grant_type=client_credentials, resource={apiResource}, scope=all. Authorization header: Basic base64(appId:appSecret). Cache the token in a private field. Parse JWT expiry from response expires_in field. Refresh when Date.now() >= tokenExpiry - 60000 (60s buffer). Per T-28-01: never log the token or secret.
  • getUser(logtoSub): GET /api/users/{logtoSub} with Bearer token. Returns { id, primaryEmail, name, avatar, createdAt }.
  • verifyPassword(logtoSub, password): POST /api/users/{logtoSub}/password/verify with { password }. Returns true if 204, false if 422.
  • updatePassword(logtoSub, newPassword): PATCH /api/users/{logtoSub}/password with { password: newPassword }.
  • hasPassword(logtoSub): GET /api/users/{logtoSub}/has-password. Returns boolean from response.
  • updateEmail(logtoSub, email): PATCH /api/users/{logtoSub} with { primaryEmail: email }.
  • deleteUser(logtoSub): DELETE /api/users/{logtoSub}.

All API calls use the Management API base URL derived from issuer (strip /oidc suffix if present, append /api).

Export a singleton: export const logtoClient = new LogtoManagementClient().

For tests: mock global fetch to intercept Logto API calls. Test token caching by verifying fetch is called once for two getAccessToken() calls within expiry window. Test each API method verifies the correct URL and method are called. <acceptance_criteria> - src/server/services/logto.service.ts contains class LogtoManagementClient - src/server/services/logto.service.ts contains export const logtoClient - src/server/services/logto.service.ts contains getAccessToken method - src/server/services/logto.service.ts contains verifyPassword method - src/server/services/logto.service.ts contains updatePassword method - src/server/services/logto.service.ts contains hasPassword method - src/server/services/logto.service.ts contains updateEmail method - src/server/services/logto.service.ts contains deleteUser method - tests/services/logto.service.test.ts exists and contains at least 6 test cases - bun test tests/services/logto.service.test.ts exits 0 </acceptance_criteria> bun test tests/services/logto.service.test.ts LogtoManagementClient passes all unit tests with mocked fetch, token caching works, all CRUD methods call correct Logto API endpoints

Task 2: Create account management API routes and register them src/server/routes/account.ts, src/server/index.ts, src/shared/schemas.ts, src/shared/types.ts - src/server/routes/auth.ts (existing route pattern — Hono app, requireAuth, zValidator) - src/server/index.ts (route registration pattern) - src/shared/schemas.ts (existing Zod schema patterns) - src/db/schema.ts (users table, setups table for deletion) - src/server/services/logto.service.ts (the service just created in Task 1) **Add Zod schemas to `src/shared/schemas.ts`:**
export const changePasswordSchema = z.object({
  currentPassword: z.string().min(1),
  newPassword: z.string().min(8),
});

export const changeEmailSchema = z.object({
  newEmail: z.string().email(),
});

export const deleteAccountSchema = z.object({
  confirmation: z.literal("DELETE"),
});

Create src/server/routes/account.ts:

Route group using Hono with requireAuth middleware on all routes:

  1. POST /password — Change password (per D-05)

    • Validate with changePasswordSchema
    • Get logtoSub from user record in DB (query users table by userId from auth context)
    • Call logtoClient.verifyPassword(logtoSub, currentPassword) — return 400 "Current password is incorrect" if false
    • Call logtoClient.updatePassword(logtoSub, newPassword) — return 200 { ok: true }
    • Per T-28-03: ALWAYS verify current password first
  2. POST /email — Change email (per D-05)

    • Validate with changeEmailSchema
    • Get logtoSub from user record
    • Call logtoClient.updateEmail(logtoSub, newEmail) — return 200 { ok: true }
  3. GET /has-password — Check if user has password set

    • Get logtoSub from user record
    • Call logtoClient.hasPassword(logtoSub) — return 200 { hasPassword: boolean }
  4. POST /delete — Delete account (per D-05, D-06)

    • Validate with deleteAccountSchema (confirmation must be "DELETE", per T-28-04)
    • Get logtoSub and userId from auth context
    • Run deletion in transaction (per T-28-06): a. Update public setups: UPDATE setups SET user_id = (sentinel user id) WHERE user_id = ? AND is_public = true
      • Sentinel user: query for user with logtoSub = 'deleted-user'. If not found, create one with displayName = 'Deleted User'. b. Delete private setups and their setup_items (setup_items first due to FK) c. Delete items (via categories FK chain) d. Delete categories e. Delete threads and threadCandidates f. Delete API keys g. Delete settings h. Delete sessions i. Delete user record
    • Call logtoClient.deleteUser(logtoSub) — outside transaction (Logto is external)
    • Return 200 { ok: true, redirectTo: "/logout" }

Helper function getLogtoSub(db, userId): query users table for the logtoSub field by user ID.

Register in src/server/index.ts:

  • Import accountRoutes from ./routes/account.ts
  • Add app.route("/api/account", accountRoutes) alongside existing route registrations

Add types to src/shared/types.ts if needed for the schemas (infer from Zod). <acceptance_criteria> - src/shared/schemas.ts contains changePasswordSchema - src/shared/schemas.ts contains changeEmailSchema - src/shared/schemas.ts contains deleteAccountSchema - src/server/routes/account.ts contains POST /password handler - src/server/routes/account.ts contains POST /email handler - src/server/routes/account.ts contains POST /delete handler - src/server/routes/account.ts contains GET /has-password handler - src/server/routes/account.ts imports requireAuth - src/server/index.ts contains accountRoutes - src/server/index.ts contains "/api/account" </acceptance_criteria> bun run lint && grep -q "accountRoutes" src/server/index.ts && grep -q "changePasswordSchema" src/shared/schemas.ts Account management routes registered, all endpoints use requireAuth, password change verifies current password first, account deletion handles data anonymization

1. `bun test tests/services/logto.service.test.ts` — all logto service tests pass 2. `bun run lint` — no lint errors 3. `grep -q "accountRoutes" src/server/index.ts` — routes registered 4. `grep -q "requireAuth" src/server/routes/account.ts` — auth required on all endpoints

<success_criteria>

  • Logto Management API client service exists with token caching and all user management methods
  • Account routes handle password change (with current password verification), email change, and account deletion
  • Account deletion anonymizes public setups to sentinel user before deleting private data
  • All routes require authentication
  • Unit tests pass for the Logto service </success_criteria>