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>
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 |
|
true |
|
|
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> |
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/tokenwithgrant_type=client_credentials,resource={apiResource},scope=all. Authorization header:Basic base64(appId:appSecret). Cache the token in a private field. Parse JWT expiry from responseexpires_infield. Refresh whenDate.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/verifywith{ password }. Returns true if 204, false if 422.updatePassword(logtoSub, newPassword): PATCH/api/users/{logtoSub}/passwordwith{ 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
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:
-
POST /password— Change password (per D-05)- Validate with
changePasswordSchema - Get
logtoSubfrom 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
- Validate with
-
POST /email— Change email (per D-05)- Validate with
changeEmailSchema - Get
logtoSubfrom user record - Call
logtoClient.updateEmail(logtoSub, newEmail)— return 200{ ok: true }
- Validate with
-
GET /has-password— Check if user has password set- Get
logtoSubfrom user record - Call
logtoClient.hasPassword(logtoSub)— return 200{ hasPassword: boolean }
- Get
-
POST /delete— Delete account (per D-05, D-06)- Validate with
deleteAccountSchema(confirmation must be "DELETE", per T-28-04) - Get
logtoSubanduserIdfrom 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 withdisplayName = '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
- Sentinel user: query for user with
- Call
logtoClient.deleteUser(logtoSub)— outside transaction (Logto is external) - Return 200
{ ok: true, redirectTo: "/logout" }
- Validate with
Helper function getLogtoSub(db, userId): query users table for the logtoSub field by user ID.
Register in src/server/index.ts:
- Import
accountRoutesfrom./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
<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>