fix(v2): UX polish, spec compliance, type safety, race-condition fixes

- Auto-create WebhookToken for new users (first-user and admin-created),
  so setup wizard step 3 immediately has a valid secret to display
- Setup wizard: load webhook secret on step 3 entry (not only on AWS save),
  so skipping AWS setup still shows correct webhook info
- Admin panel: add Edit modal with username and password change for any user
  (spec: 'Edit username or password of any user')
- Webhook handler: only post 'no config' comment on opened/reopened actions,
  not on synchronize or closed — prevents spam on sync events
- deploy.ts: clear stale abort signal at start of runDeploy so a signal meant
  to cancel the previous job cannot accidentally abort the new one
- routes/auth.ts: fix sameSite cookie case to lowercase 'lax' per TypeScript
- Add node-cron type declaration to silence TS7016 for that import
- SPEC.md: fix ec2:ImportKeyPair → ec2:CreateKeyPair (code uses CreateKeyPair)
- example.env: improve comments, add NODE_ENV, add key generation hints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 08:39:36 +00:00
parent 3f49511fb5
commit d1ec1287d2
8 changed files with 62 additions and 19 deletions
+5
View File
@@ -1,4 +1,5 @@
import bcrypt from "bcryptjs";
import { randomBytes } from "crypto";
import { prisma } from "../../lib/db";
import { makeResponse } from "../../lib/response";
import { ERROR_MESSAGES } from "../../lib/errors";
@@ -43,6 +44,10 @@ export async function createUser(ctr: any) {
select: { id: true, username: true, isAdmin: true, isFounder: true },
});
// Auto-create webhook token for new users
const webhookSecret = randomBytes(32).toString("hex");
await prisma.webhookToken.create({ data: { userId: user.id, token: webhookSecret } });
return makeResponse({ ctr, content: { code: 201, data: user } });
}
+6 -2
View File
@@ -42,7 +42,7 @@ export async function loginHandler(ctr: any) {
httpOnly: true,
expires: new Date(Date.now() + COOKIE_MAX_AGE_MS),
path: "/",
sameSite: "Lax",
sameSite: "lax",
}),
);
@@ -116,6 +116,10 @@ export async function firstUserHandler(ctr: any) {
data: { username, passwordHash: hash, isAdmin: true, isFounder: true },
});
// Auto-create a webhook token so it's immediately available in the setup wizard
const webhookSecret = randomBytes(32).toString("hex");
await prisma.webhookToken.create({ data: { userId: user.id, token: webhookSecret } });
const sessionHash = randomBytes(32).toString("hex");
await prisma.session.create({ data: { hash: sessionHash, userId: user.id } });
@@ -125,7 +129,7 @@ export async function firstUserHandler(ctr: any) {
httpOnly: true,
expires: new Date(Date.now() + COOKIE_MAX_AGE_MS),
path: "/",
sameSite: "Lax",
sameSite: "lax",
}),
);
+12 -9
View File
@@ -86,15 +86,18 @@ async function handlePullRequestEvent(user: any, payload: any) {
});
if (!repoConfig) {
const existing = await prisma.noConfigComment.findUnique({
where: { userId_repoOwner_repoName_prNumber: { userId: user.id, repoOwner: owner, repoName, prNumber } },
});
if (!existing) {
const body = `No previews configured for \`${owner}/${repoName}\`. Configure this repo in [PR Previews](${env.PP_BASE_URL}).`;
try {
await postComment(user, owner, repoName, prNumber, body);
await prisma.noConfigComment.create({ data: { userId: user.id, repoOwner: owner, repoName, prNumber } });
} catch {}
// Only post "no config" comment on new PR opens (not on sync/close events)
if (action === "opened" || action === "reopened") {
const existing = await prisma.noConfigComment.findUnique({
where: { userId_repoOwner_repoName_prNumber: { userId: user.id, repoOwner: owner, repoName, prNumber } },
});
if (!existing) {
const body = `No previews configured for \`${owner}/${repoName}\`. Configure this repo in [PR Previews](${env.PP_BASE_URL}).`;
try {
await postComment(user, owner, repoName, prNumber, body);
await prisma.noConfigComment.create({ data: { userId: user.id, repoOwner: owner, repoName, prNumber } });
} catch {}
}
}
return;
}
+4
View File
@@ -77,6 +77,10 @@ export async function runDeploy(jobId: number) {
return;
}
// Clear any stale abort signal left over from aborting the previous job for this preview.
// The new job should not be cancelled by a signal meant for the old one.
if (job.preview.id) abortSignals.delete(job.preview.id);
await prisma.job.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: new Date() } });
const preview = job.preview as any;
+9
View File
@@ -0,0 +1,9 @@
declare module 'node-cron' {
interface ScheduledTask {
start(): void;
stop(): void;
destroy(): void;
}
function schedule(cronExpression: string, func: () => void, options?: { scheduled?: boolean; timezone?: string }): ScheduledTask;
export { schedule, ScheduledTask };
}