fix(19): satisfy CI fast-checks + secret scan
CI / changes (pull_request) Successful in 9s
CI / api (pull_request) Successful in 3m2s
CI / fast-checks (pull_request) Successful in 4m20s
CI / security (pull_request) Successful in 1m14s
CI / harness (pull_request) Successful in 6m56s
CI / gate (pull_request) Successful in 2s
CI / changes (pull_request) Successful in 9s
CI / api (pull_request) Successful in 3m2s
CI / fast-checks (pull_request) Successful in 4m20s
CI / security (pull_request) Successful in 1m14s
CI / harness (pull_request) Successful in 6m56s
CI / gate (pull_request) Successful in 2s
Lint (eslint --max-warnings 0): - index.ts: disable no-unsafe-argument on the type-only Context mismatch when delegating to the OIDC handler inside the local-session skip wrapper - localAuth.ts: handleLogout is sync (no await) — drop async (require-await) - devBypass.ts: disable detect-possible-timing-attacks on the public well-known dev-placeholder string compare (not a secret comparison) - remove dead code / unused bindings flagged by no-unused-vars: makeTestApp (localSession.test), makeUnauthContext + BrowserContext import (login.spec), unused memberId (admin.test), unused txSelectCount counter (me.test) - localAuthMiddleware.test / me.test: fix unused + reflow-detached eslint-disable directives Format: prettier --write across the 20 Phase-19 files that were never formatted. Secret scan (gitleaks): allowlist two false positives — the synthetic >=32-char TEST_SECRET in localSession.test.ts, and .planning/ design prose (a generic-api-key regex hit on "credential atomically, 409-equivalent"). Neither is a real secret. Verified locally: format:check, lint, typecheck, md:lint, gitleaks (no leaks), PWA 266/266, API 452/452. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
91ab9d1f78
commit
b6490feff4
@@ -140,77 +140,73 @@ const createMemberSchema = z.object({
|
||||
initialPassword: z.string().min(8),
|
||||
});
|
||||
|
||||
adminRouter.post(
|
||||
'/members',
|
||||
zValidator('json', createMemberSchema, noEchoHook),
|
||||
async (c) => {
|
||||
const { displayName, username, initialPassword } = c.req.valid('json');
|
||||
// T-19-06: NEVER log request body, displayName, username, or initialPassword here
|
||||
adminRouter.post('/members', zValidator('json', createMemberSchema, noEchoHook), async (c) => {
|
||||
const { displayName, username, initialPassword } = c.req.valid('json');
|
||||
// T-19-06: NEVER log request body, displayName, username, or initialPassword here
|
||||
|
||||
// Assign the first palette color not already in use (mirrors upsertUser color logic)
|
||||
const usedRows = await db.select({ color: users.color }).from(users);
|
||||
const usedColors = new Set(usedRows.map((r) => r.color));
|
||||
const color =
|
||||
COLOR_PALETTE.find((c) => !usedColors.has(c)) ??
|
||||
COLOR_PALETTE[usedColors.size % COLOR_PALETTE.length];
|
||||
// Assign the first palette color not already in use (mirrors upsertUser color logic)
|
||||
const usedRows = await db.select({ color: users.color }).from(users);
|
||||
const usedColors = new Set(usedRows.map((r) => r.color));
|
||||
const color =
|
||||
COLOR_PALETTE.find((c) => !usedColors.has(c)) ??
|
||||
COLOR_PALETTE[usedColors.size % COLOR_PALETTE.length];
|
||||
|
||||
// WR-03: hash the initial password BEFORE opening the transaction so the (now async,
|
||||
// threadpool) scrypt work does not hold the DB transaction open for its duration.
|
||||
const initialPasswordHash = await hashPassword(initialPassword);
|
||||
// WR-03: hash the initial password BEFORE opening the transaction so the (now async,
|
||||
// threadpool) scrypt work does not hold the DB transaction open for its duration.
|
||||
const initialPasswordHash = await hashPassword(initialPassword);
|
||||
|
||||
try {
|
||||
let newUserId: number;
|
||||
try {
|
||||
let newUserId: number;
|
||||
|
||||
// T-19-10: atomic transaction — both inserts succeed or both roll back
|
||||
await db.transaction(async (tx) => {
|
||||
// Insert the new users row (no oidcIss/oidcSub — local-only member)
|
||||
const [inserted] = await tx
|
||||
.insert(users)
|
||||
.values({
|
||||
displayName,
|
||||
color,
|
||||
isAdmin: false,
|
||||
claimed: false, // no OIDC identity bound yet
|
||||
})
|
||||
.$returningId();
|
||||
newUserId = inserted.id;
|
||||
// T-19-10: atomic transaction — both inserts succeed or both roll back
|
||||
await db.transaction(async (tx) => {
|
||||
// Insert the new users row (no oidcIss/oidcSub — local-only member)
|
||||
const [inserted] = await tx
|
||||
.insert(users)
|
||||
.values({
|
||||
displayName,
|
||||
color,
|
||||
isAdmin: false,
|
||||
claimed: false, // no OIDC identity bound yet
|
||||
})
|
||||
.$returningId();
|
||||
newUserId = inserted.id;
|
||||
|
||||
// Insert local_credentials row with hashed initial password
|
||||
// If username is already in use, the UNIQUE constraint fires here and rolls back
|
||||
await tx.insert(localCredentials).values({
|
||||
userId: newUserId,
|
||||
username,
|
||||
passwordHash: initialPasswordHash,
|
||||
});
|
||||
// Insert local_credentials row with hashed initial password
|
||||
// If username is already in use, the UNIQUE constraint fires here and rolls back
|
||||
await tx.insert(localCredentials).values({
|
||||
userId: newUserId,
|
||||
username,
|
||||
passwordHash: initialPasswordHash,
|
||||
});
|
||||
});
|
||||
|
||||
// Return the new user's id (the PWA uses it to navigate to the member)
|
||||
return c.json({ id: newUserId! }, 201);
|
||||
} catch (err) {
|
||||
// Username uniqueness violation — UNIQUE constraint on local_credentials.username.
|
||||
// Drizzle wraps the mysql2 error; check the cause chain or the message for ER_DUP_ENTRY.
|
||||
const isDup =
|
||||
(err instanceof Error && err.message.includes('ER_DUP_ENTRY')) ||
|
||||
(err != null &&
|
||||
typeof err === 'object' &&
|
||||
'code' in err &&
|
||||
(err as { code?: string }).code === 'ER_DUP_ENTRY') ||
|
||||
(err != null &&
|
||||
typeof err === 'object' &&
|
||||
'cause' in err &&
|
||||
(err as { cause?: { code?: string } }).cause?.code === 'ER_DUP_ENTRY');
|
||||
if (isDup) {
|
||||
return c.json({ error: 'Username already in use' }, 409);
|
||||
}
|
||||
// Unexpected errors — log message only, never the body or password
|
||||
console.error(
|
||||
'[admin/POST /members] Unexpected error:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
// Return the new user's id (the PWA uses it to navigate to the member)
|
||||
return c.json({ id: newUserId! }, 201);
|
||||
} catch (err) {
|
||||
// Username uniqueness violation — UNIQUE constraint on local_credentials.username.
|
||||
// Drizzle wraps the mysql2 error; check the cause chain or the message for ER_DUP_ENTRY.
|
||||
const isDup =
|
||||
(err instanceof Error && err.message.includes('ER_DUP_ENTRY')) ||
|
||||
(err != null &&
|
||||
typeof err === 'object' &&
|
||||
'code' in err &&
|
||||
(err as { code?: string }).code === 'ER_DUP_ENTRY') ||
|
||||
(err != null &&
|
||||
typeof err === 'object' &&
|
||||
'cause' in err &&
|
||||
(err as { cause?: { code?: string } }).cause?.code === 'ER_DUP_ENTRY');
|
||||
if (isDup) {
|
||||
return c.json({ error: 'Username already in use' }, 409);
|
||||
}
|
||||
},
|
||||
);
|
||||
// Unexpected errors — log message only, never the body or password
|
||||
console.error(
|
||||
'[admin/POST /members] Unexpected error:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/admin/members/:id/password
|
||||
|
||||
Reference in New Issue
Block a user