feat(10-03): implement credentialSync helper, adminRouter, and self-service /api/me/credential
credentialSync.ts:
- validateEncryptAndStoreCredential(userId, email, appPassword, providerType) — single
shared validate→encrypt→store→initial-sync path used by BOTH admin and self-service
- createFastmailClient + fetchCalendars wrapped in ONE try/catch: any failure throws
CredentialValidationError (routes map to { error: 'Invalid request' } 400)
- appPassword never logged or echoed (T-10-10)
- encryptPassword (AES-256-GCM) applied before DB write (T-10-11)
- fire-and-forget initial sync via loadClientForUser + syncCalendar (Pitfall 5)
admin.ts:
- adminRouter.use('*', requireAdmin) FIRST (Pitfall 9 / T-10-08)
- GET /members: users LEFT JOIN member_credentials → hasCredential boolean
- POST /credentials: noEchoHook + validateEncryptAndStoreCredential (T-10-09)
- GET /calendars: calendar list (UI-SPEC Surface 5)
- PUT /calendars/:id/shared: exclusive is_shared update (ADMIN-02, D-06)
index.ts:
- app.route('/api/admin', adminRouter) mounted in route block
me.ts:
- POST /credential: member self-service, always currentUserId (Pitfall 6 / T-10-12)
- meCredentialSchema (no userId field), meNoEchoHook, calls shared helper
- All 17 new admin tests pass; 270 total pass; tsc --noEmit clean
This commit is contained in:
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Shared validate→encrypt→store→initial-sync helper for credential management.
|
||||||
|
*
|
||||||
|
* Used by BOTH:
|
||||||
|
* - POST /api/admin/credentials (admin rotating any member's credential)
|
||||||
|
* - POST /api/me/credential (member self-service, D-07)
|
||||||
|
*
|
||||||
|
* Security contract (T-10-09, T-10-10, T-10-11):
|
||||||
|
* - createFastmailClient + client.fetchCalendars() are wrapped in a single try/catch.
|
||||||
|
* ANY throw (bad email, malformed input, network error, PROPFIND/auth failure) is
|
||||||
|
* treated identically as a credential-validation failure → CredentialValidationError.
|
||||||
|
* - The caller maps CredentialValidationError to { error: 'Invalid request' } 400.
|
||||||
|
* - The submitted appPassword is NEVER logged or echoed back to the caller.
|
||||||
|
* - On success: encryptPassword (AES-256-GCM) before the DB write (T-10-11).
|
||||||
|
* - Initial sync is fire-and-forget after the DB upsert (Pitfall 5: encrypt+upsert first).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db } from '../db/client.js';
|
||||||
|
import { memberCredentials } from '../db/schema.js';
|
||||||
|
import { encryptPassword } from './crypto.js';
|
||||||
|
import { createFastmailClient } from './client.js';
|
||||||
|
import { loadClientForUser } from './outboxWorker.js';
|
||||||
|
import { syncCalendar } from './sync.js';
|
||||||
|
import type { FastmailClient } from './client.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signals a credential validation failure (createFastmailClient throw, network error,
|
||||||
|
* PROPFIND/auth failure — all treated identically).
|
||||||
|
* The caller maps this to { error: 'Invalid request' } 400.
|
||||||
|
*/
|
||||||
|
export class CredentialValidationError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Credential validation failed');
|
||||||
|
this.name = 'CredentialValidationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a Fastmail app password via CalDAV PROPFIND, encrypts it, upserts the
|
||||||
|
* member_credentials row, and fires an asynchronous full-member initial sync.
|
||||||
|
*
|
||||||
|
* @param userId - The target member's user id (admin path: from route; self-service: session user)
|
||||||
|
* @param fastmailEmail - The Fastmail account email
|
||||||
|
* @param appPassword - The plaintext app password (NEVER logged or echoed)
|
||||||
|
* @param providerType - Credential provider type (e.g. 'caldav')
|
||||||
|
*
|
||||||
|
* @throws CredentialValidationError when validation (PROPFIND) fails for ANY reason
|
||||||
|
*/
|
||||||
|
export async function validateEncryptAndStoreCredential(
|
||||||
|
userId: number,
|
||||||
|
fastmailEmail: string,
|
||||||
|
appPassword: string,
|
||||||
|
providerType: string,
|
||||||
|
): Promise<void> {
|
||||||
|
// Step 1: Validate credential via CalDAV PROPFIND (createFastmailClient + fetchCalendars).
|
||||||
|
// Both calls are wrapped in ONE try/catch. ANY throw from either — bad email, malformed
|
||||||
|
// input, network error, PROPFIND 401/403 from Fastmail — maps to CredentialValidationError.
|
||||||
|
// The password is NEVER logged here or in the catch block (T-10-10).
|
||||||
|
let davCalendars: Awaited<ReturnType<FastmailClient['fetchCalendars']>> = [];
|
||||||
|
try {
|
||||||
|
const client = await createFastmailClient(fastmailEmail, appPassword);
|
||||||
|
davCalendars = await client.fetchCalendars();
|
||||||
|
} catch {
|
||||||
|
// T-10-10: do NOT log appPassword, fastmailEmail, or the error details here.
|
||||||
|
// Only a typed signal is thrown — routes map it to the generic 400 response.
|
||||||
|
throw new CredentialValidationError();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Encrypt the password (AES-256-GCM) BEFORE any DB write (T-10-11).
|
||||||
|
// NEVER log encryptedPassword or appPassword.
|
||||||
|
const encrypted = encryptPassword(appPassword);
|
||||||
|
|
||||||
|
// Step 3: Upsert member_credentials using the UNIQUE(user_id) constraint (D-05).
|
||||||
|
await db
|
||||||
|
.insert(memberCredentials)
|
||||||
|
.values({
|
||||||
|
userId,
|
||||||
|
encryptedPassword: encrypted,
|
||||||
|
fastmailEmail,
|
||||||
|
providerType,
|
||||||
|
})
|
||||||
|
.onDuplicateKeyUpdate({
|
||||||
|
set: {
|
||||||
|
encryptedPassword: encrypted,
|
||||||
|
fastmailEmail,
|
||||||
|
providerType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 4: Fire-and-forget initial full-member sync.
|
||||||
|
// After a FRESH credential save there may be no known calendarUrl — run the full per-member
|
||||||
|
// poll (loadClientForUser → fetchCalendars → syncCalendar per davCal) mirroring poller.ts.
|
||||||
|
// This is non-blocking: the caller returns 200 immediately; sync runs in the background.
|
||||||
|
// Pitfall 5: encrypt+upsert BEFORE triggering sync (credential must exist in DB first).
|
||||||
|
const calsToSync = davCalendars;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const syncClient = await loadClientForUser(userId);
|
||||||
|
const cals = calsToSync.length > 0 ? calsToSync : await syncClient.fetchCalendars();
|
||||||
|
for (const davCal of cals) {
|
||||||
|
await syncCalendar(syncClient, davCal, userId);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Initial sync failure is non-fatal — the poller will catch up on next tick.
|
||||||
|
// T-10-10: do NOT log password or credential details here.
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { eventsRouter } from './routes/events.js';
|
|||||||
import { sseRouter } from './routes/sse.js';
|
import { sseRouter } from './routes/sse.js';
|
||||||
import { listsRouter, listItemsRouter } from './routes/lists.js';
|
import { listsRouter, listItemsRouter } from './routes/lists.js';
|
||||||
import { pushRouter } from './routes/push.js';
|
import { pushRouter } from './routes/push.js';
|
||||||
|
import { adminRouter } from './routes/admin.js';
|
||||||
import { oidcAuthMiddleware, processOAuthCallback } from './auth/middleware.js';
|
import { oidcAuthMiddleware, processOAuthCallback } from './auth/middleware.js';
|
||||||
import { devAuthBypass } from './auth/devBypass.js';
|
import { devAuthBypass } from './auth/devBypass.js';
|
||||||
import { persistSessionCookie } from './auth/persistSessionCookie.js';
|
import { persistSessionCookie } from './auth/persistSessionCookie.js';
|
||||||
@@ -70,6 +71,7 @@ app.route('/api/sse', sseRouter);
|
|||||||
app.route('/api/lists', listsRouter);
|
app.route('/api/lists', listsRouter);
|
||||||
app.route('/api/list-items', listItemsRouter);
|
app.route('/api/list-items', listItemsRouter);
|
||||||
app.route('/api/push', pushRouter);
|
app.route('/api/push', pushRouter);
|
||||||
|
app.route('/api/admin', adminRouter);
|
||||||
|
|
||||||
// WR-04: background worker startup (cron schedules) moved into the isMainModule()
|
// WR-04: background worker startup (cron schedules) moved into the isMainModule()
|
||||||
// guard below. Calling them at top level registered real node-cron schedules whenever
|
// guard below. Calling them at top level registered real node-cron schedules whenever
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* Admin router — role-gated admin API surface (ADMIN-01/02/03).
|
||||||
|
*
|
||||||
|
* Security contract:
|
||||||
|
* - adminRouter.use('*', requireAdmin) is the FIRST statement (Pitfall 9 / T-10-08).
|
||||||
|
* This guard runs before ANY route handler, so no admin route is reachable by non-admins.
|
||||||
|
* - POST /credentials uses noEchoHook: NEVER returns Zod's result.error (which contains
|
||||||
|
* .received = the submitted password). Returns { error: 'Invalid request' } only (T-10-09).
|
||||||
|
* - validateEncryptAndStoreCredential from credentialSync.ts is the ONLY place
|
||||||
|
* createFastmailClient + fetchCalendars + encryptPassword + upsert live (D-07).
|
||||||
|
* - No console.log of request bodies or passwords in any handler (T-10-10).
|
||||||
|
*
|
||||||
|
* Routes:
|
||||||
|
* GET /api/admin/members → list members + credential status (UI-SPEC Surface 2)
|
||||||
|
* POST /api/admin/credentials → validate+encrypt+store for any member (ADMIN-01)
|
||||||
|
* GET /api/admin/calendars → list synced calendars (UI-SPEC Surface 5)
|
||||||
|
* PUT /api/admin/calendars/:id/shared → exclusive is_shared designation (ADMIN-02)
|
||||||
|
*
|
||||||
|
* Mounted in index.ts: app.route('/api/admin', adminRouter)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import type { Context } from 'hono';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '../db/client.js';
|
||||||
|
import { users, memberCredentials, calendars } from '../db/schema.js';
|
||||||
|
import { requireAdmin } from '../lib/requireAdmin.js';
|
||||||
|
import {
|
||||||
|
validateEncryptAndStoreCredential,
|
||||||
|
CredentialValidationError,
|
||||||
|
} from '../broker/credentialSync.js';
|
||||||
|
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
|
||||||
|
import '../auth/devBypass.js';
|
||||||
|
|
||||||
|
export const adminRouter = new Hono();
|
||||||
|
|
||||||
|
// Pitfall 9: requireAdmin MUST be the first statement on the router.
|
||||||
|
// All sub-routes are protected — no path can be reached without passing this guard.
|
||||||
|
adminRouter.use('*', requireAdmin);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Zod schema + no-echo hook for credential routes (T-10-09 / Pitfall 7)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const credentialSchema = z.object({
|
||||||
|
userId: z.number().int().positive(),
|
||||||
|
providerType: z.literal('caldav'),
|
||||||
|
fastmailEmail: z.string().email().max(256),
|
||||||
|
appPassword: z.string().min(1).max(500),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* noEchoHook: NEVER return result.error from zValidator for credential routes.
|
||||||
|
* Zod's error object contains issues[].received which echoes the submitted value
|
||||||
|
* (the app password) — returning it would violate T-10-09 (Pitfall 7).
|
||||||
|
* Always return { error: 'Invalid request' } 400, no other fields.
|
||||||
|
*/
|
||||||
|
const noEchoHook = (result: { success: boolean }, c: Context) => {
|
||||||
|
if (!result.success) {
|
||||||
|
return c.json({ error: 'Invalid request' }, 400);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/admin/members
|
||||||
|
//
|
||||||
|
// Returns all household members with their credential status.
|
||||||
|
// Feeds UI-SPEC Surface 2 (member list with rotate-credential affordance).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
adminRouter.get('/members', async (c) => {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: users.id,
|
||||||
|
displayName: users.displayName,
|
||||||
|
color: users.color,
|
||||||
|
credentialId: memberCredentials.id,
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.leftJoin(memberCredentials, eq(memberCredentials.userId, users.id));
|
||||||
|
|
||||||
|
const members = rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
displayName: row.displayName,
|
||||||
|
color: row.color,
|
||||||
|
hasCredential: row.credentialId !== null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return c.json({ members });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /api/admin/credentials
|
||||||
|
//
|
||||||
|
// Admin rotates (or sets for the first time) a member's Fastmail app password.
|
||||||
|
// Validates against CalDAV (PROPFIND) before storing.
|
||||||
|
// Uses the shared validateEncryptAndStoreCredential helper — no duplicated logic here.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
adminRouter.post('/credentials', zValidator('json', credentialSchema, noEchoHook), async (c) => {
|
||||||
|
const { userId, fastmailEmail, appPassword, providerType } = c.req.valid('json');
|
||||||
|
// T-10-10: NEVER log appPassword or c.req.valid('json') here
|
||||||
|
|
||||||
|
try {
|
||||||
|
await validateEncryptAndStoreCredential(userId, fastmailEmail, appPassword, providerType);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof CredentialValidationError) {
|
||||||
|
// T-10-09: map validation failure to generic 400 — no echo of password or Zod details
|
||||||
|
return c.json({ error: 'Invalid request' }, 400);
|
||||||
|
}
|
||||||
|
// Unexpected errors (DB failure, etc.) — log message only, no credential data
|
||||||
|
console.error(
|
||||||
|
'[admin/POST /credentials] Unexpected error:',
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
|
return c.json({ error: 'Service unavailable' }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ ok: true }, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/admin/calendars
|
||||||
|
//
|
||||||
|
// Lists all synced calendars. Feeds UI-SPEC Surface 5 (shared-calendar picker).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
adminRouter.get('/calendars', async (c) => {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: calendars.id,
|
||||||
|
displayName: calendars.displayName,
|
||||||
|
isShared: calendars.isShared,
|
||||||
|
})
|
||||||
|
.from(calendars);
|
||||||
|
|
||||||
|
return c.json({ calendars: rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PUT /api/admin/calendars/:id/shared
|
||||||
|
//
|
||||||
|
// Exclusively marks one calendar as is_shared=true (ADMIN-02).
|
||||||
|
// Clears is_shared on any prior shared calendar first (D-06 single-select).
|
||||||
|
// Pattern 7: two sequential UPDATE statements.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
adminRouter.put('/calendars/:id/shared', async (c) => {
|
||||||
|
const targetId = parseInt(c.req.param('id'), 10);
|
||||||
|
if (isNaN(targetId)) {
|
||||||
|
return c.json({ error: 'Invalid calendar id' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Clear is_shared on any currently-shared calendar
|
||||||
|
await db.update(calendars).set({ isShared: false }).where(eq(calendars.isShared, true));
|
||||||
|
|
||||||
|
// Step 2: Set is_shared on the target calendar
|
||||||
|
await db.update(calendars).set({ isShared: true }).where(eq(calendars.id, targetId));
|
||||||
|
|
||||||
|
return c.json({ ok: true }, 200);
|
||||||
|
});
|
||||||
@@ -26,11 +26,18 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
|
import type { Context } from 'hono';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { z } from 'zod';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { getAuth } from '../auth/middleware.js';
|
import { getAuth } from '../auth/middleware.js';
|
||||||
import { upsertUser, deriveDisplayName } from '../auth/user.js';
|
import { upsertUser, deriveDisplayName } from '../auth/user.js';
|
||||||
import { db } from '../db/client.js';
|
import { db } from '../db/client.js';
|
||||||
import { users, memberCredentials } from '../db/schema.js';
|
import { users, memberCredentials } from '../db/schema.js';
|
||||||
|
import {
|
||||||
|
validateEncryptAndStoreCredential,
|
||||||
|
CredentialValidationError,
|
||||||
|
} from '../broker/credentialSync.js';
|
||||||
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
|
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
|
||||||
import '../auth/devBypass.js';
|
import '../auth/devBypass.js';
|
||||||
|
|
||||||
@@ -59,6 +66,25 @@ async function resolveAdminAndSetupStatus(userId: number) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auth helper — resolves the current user id from dev-bypass or OIDC session.
|
||||||
|
// Per project convention: duplicated per router (not extracted to shared module).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function resolveUserId(c: Context): Promise<number | null> {
|
||||||
|
const devUser = c.get('user') as { id: number } | undefined;
|
||||||
|
if (devUser) return devUser.id;
|
||||||
|
|
||||||
|
const auth = await getAuth(c);
|
||||||
|
if (!auth) return null;
|
||||||
|
|
||||||
|
const iss = (auth.iss as string | undefined) ?? '';
|
||||||
|
const sub = auth.sub ?? '';
|
||||||
|
const displayName = deriveDisplayName(auth);
|
||||||
|
const user = await upsertUser(iss, sub, displayName);
|
||||||
|
return user?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
meRouter.get('/', async (c) => {
|
meRouter.get('/', async (c) => {
|
||||||
// Dev-auth bypass path: devAuthBypass() sets c.get('user') to DEV_USER when active.
|
// Dev-auth bypass path: devAuthBypass() sets c.get('user') to DEV_USER when active.
|
||||||
// Use the injected dev identity's id for DB lookups — no OIDC session needed,
|
// Use the injected dev identity's id for DB lookups — no OIDC session needed,
|
||||||
@@ -112,3 +138,64 @@ meRouter.get('/', async (c) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /api/me/credential — member self-service credential endpoint (D-07)
|
||||||
|
//
|
||||||
|
// Security contract (T-10-12 Pitfall 6):
|
||||||
|
// - ALWAYS writes to currentUserId from the session — NEVER a body userId.
|
||||||
|
// - Any body.userId field is IGNORED — this endpoint cannot cross-write.
|
||||||
|
// - Uses the SAME shared validateEncryptAndStoreCredential helper as admin path.
|
||||||
|
// - Does NOT require requireAdmin — any authenticated member can set their own credential.
|
||||||
|
// - Failure (any Zod or validation failure) returns { error: 'Invalid request' } 400
|
||||||
|
// with NO echoed password (noEchoHook + CredentialValidationError → 400).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const meCredentialSchema = z.object({
|
||||||
|
// D-07: no userId field — body userId is not accepted (Pitfall 6)
|
||||||
|
providerType: z.literal('caldav'),
|
||||||
|
fastmailEmail: z.string().email().max(256),
|
||||||
|
appPassword: z.string().min(1).max(500),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* noEchoHook for /api/me/credential: NEVER echo Zod error details (T-10-09 / Pitfall 7).
|
||||||
|
*/
|
||||||
|
const meNoEchoHook = (result: { success: boolean }, c: Context) => {
|
||||||
|
if (!result.success) {
|
||||||
|
return c.json({ error: 'Invalid request' }, 400);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
meRouter.post(
|
||||||
|
'/credential',
|
||||||
|
zValidator('json', meCredentialSchema, meNoEchoHook),
|
||||||
|
async (c) => {
|
||||||
|
// Pitfall 6: ALWAYS resolve currentUserId from the session — never from the body.
|
||||||
|
const currentUserId = await resolveUserId(c);
|
||||||
|
if (!currentUserId) {
|
||||||
|
return c.json({ error: 'Unauthorized' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { fastmailEmail, appPassword, providerType } = c.req.valid('json');
|
||||||
|
// T-10-10: NEVER log appPassword or c.req.valid('json') here
|
||||||
|
|
||||||
|
try {
|
||||||
|
// D-07: identical validate→encrypt→store→sync path as admin, but always with
|
||||||
|
// currentUserId (not a body userId). Admin passes the target member's userId;
|
||||||
|
// self-service passes the authenticated session userId. Same helper, same argument order.
|
||||||
|
await validateEncryptAndStoreCredential(currentUserId, fastmailEmail, appPassword, providerType);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof CredentialValidationError) {
|
||||||
|
return c.json({ error: 'Invalid request' }, 400);
|
||||||
|
}
|
||||||
|
console.error(
|
||||||
|
'[me/POST /credential] Unexpected error:',
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
|
return c.json({ error: 'Service unavailable' }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ ok: true }, 200);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user