---
phase: 28-profile-and-logto-integration
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- 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
autonomous: true
requirements: []
user_setup:
- type: env_var
name: LOGTO_M2M_APP_ID
source: Logto Console > Applications > Machine-to-Machine app > App ID
- type: env_var
name: LOGTO_M2M_APP_SECRET
source: Logto Console > Applications > Machine-to-Machine app > App Secret
- type: external_config
name: Logto M2M Application
instructions: Create a Machine-to-Machine application in Logto Console, assign the built-in "Logto Management API" role with "all" scope
must_haves:
truths:
- 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
artifacts:
- src/server/services/logto.service.ts
- src/server/routes/account.ts
- tests/services/logto.service.test.ts
key_links:
- 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
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
@.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
| 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 |
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`:
```typescript
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.
- 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
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`:**
```typescript
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).
- 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"`
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
- 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