feat : notify new update income
This commit is contained in:
+19
-7
@@ -3,7 +3,11 @@ import { limitRequest, trustedClientAddress } from "@/lib/security/rate-limit";
|
||||
import { connection } from "next/server";
|
||||
|
||||
import { normalizeDeploymentId } from "@/lib/deployment/version";
|
||||
import { createEventStream, eventStreamHeaders } from "@/lib/events/sse";
|
||||
import {
|
||||
DEPLOYMENT_EVENT_TOPIC,
|
||||
getLatestDeploymentStatus,
|
||||
} from "@/lib/deployment/repository";
|
||||
import { createRedisNamedEventResponse } from "@/lib/events/redis-stream";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
await connection();
|
||||
@@ -11,12 +15,20 @@ export async function GET(request: Request) {
|
||||
await limitRequest("stream-open", trustedClientAddress(request.headers), 60);
|
||||
const deploymentId =
|
||||
normalizeDeploymentId(process.env.NEXT_DEPLOYMENT_ID) ?? "development";
|
||||
const channel = createEventStream({ signal: request.signal });
|
||||
channel.sendNamed("deployment", deploymentId);
|
||||
|
||||
return new Response(channel.stream, {
|
||||
headers: eventStreamHeaders(),
|
||||
});
|
||||
return await createRedisNamedEventResponse(
|
||||
DEPLOYMENT_EVENT_TOPIC,
|
||||
"deployment-status",
|
||||
request.signal,
|
||||
async () => {
|
||||
const latestStatus = await getLatestDeploymentStatus();
|
||||
return [
|
||||
{ eventName: "deployment", data: deploymentId },
|
||||
...(latestStatus
|
||||
? [{ eventName: "deployment-status", data: JSON.stringify(latestStatus) }]
|
||||
: []),
|
||||
];
|
||||
},
|
||||
);
|
||||
} catch (cause) {
|
||||
return errorResponse(cause);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const publishDeploymentStatus = vi.fn();
|
||||
|
||||
vi.mock("@/lib/deployment/repository", () => ({ publishDeploymentStatus }));
|
||||
|
||||
const { POST } = await import("./route");
|
||||
const deploymentId = "a".repeat(40);
|
||||
|
||||
function request(body: unknown, authorization?: string) {
|
||||
return new Request("https://guide.sudloh.com/api/deployments/status", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(authorization ? { Authorization: authorization } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("deployment status webhook", () => {
|
||||
beforeEach(() => {
|
||||
process.env.DEPLOYMENT_WEBHOOK_SECRET = "test-deployment-secret";
|
||||
publishDeploymentStatus.mockReset();
|
||||
publishDeploymentStatus.mockResolvedValue({
|
||||
accepted: true,
|
||||
event: {
|
||||
deploymentId,
|
||||
status: "deploying",
|
||||
updatedAt: "2026-09-26T12:00:00.000Z",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects missing or incorrect credentials", async () => {
|
||||
expect((await POST(request({ deploymentId, status: "deploying" }))).status).toBe(401);
|
||||
expect((await POST(request(
|
||||
{ deploymentId, status: "deploying" },
|
||||
"Bearer incorrect",
|
||||
))).status).toBe(401);
|
||||
expect(publishDeploymentStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects invalid lifecycle payloads", async () => {
|
||||
const response = await POST(request(
|
||||
{ deploymentId: "short", status: "unknown" },
|
||||
"Bearer test-deployment-secret",
|
||||
));
|
||||
expect(response.status).toBe(400);
|
||||
expect(publishDeploymentStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("publishes an authenticated lifecycle update", async () => {
|
||||
const response = await POST(request(
|
||||
{ deploymentId, status: "deploying" },
|
||||
"Bearer test-deployment-secret",
|
||||
));
|
||||
expect(response.status).toBe(202);
|
||||
expect(publishDeploymentStatus).toHaveBeenCalledWith(deploymentId, "deploying");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
|
||||
import { publishDeploymentStatus } from "@/lib/deployment/repository";
|
||||
import {
|
||||
isDeploymentId,
|
||||
isDeploymentStatus,
|
||||
} from "@/lib/deployment/status";
|
||||
import {
|
||||
errorResponse,
|
||||
HttpError,
|
||||
readJson,
|
||||
} from "@/lib/security/http";
|
||||
|
||||
function authorized(request: Request): boolean {
|
||||
const secret = process.env.DEPLOYMENT_WEBHOOK_SECRET;
|
||||
const authorization = request.headers.get("authorization");
|
||||
if (!secret) throw new HttpError(503, "deployment-webhook-not-configured");
|
||||
if (!authorization?.startsWith("Bearer ")) return false;
|
||||
const supplied = authorization.slice("Bearer ".length);
|
||||
const expectedDigest = createHash("sha256").update(secret).digest();
|
||||
const suppliedDigest = createHash("sha256").update(supplied).digest();
|
||||
return timingSafeEqual(expectedDigest, suppliedDigest);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
if (!authorized(request)) throw new HttpError(401, "unauthorized");
|
||||
const body = await readJson(request, 1_024);
|
||||
if (
|
||||
typeof body !== "object" ||
|
||||
body === null ||
|
||||
!isDeploymentId((body as Record<string, unknown>).deploymentId) ||
|
||||
!isDeploymentStatus((body as Record<string, unknown>).status)
|
||||
) {
|
||||
throw new HttpError(400, "invalid-deployment-status");
|
||||
}
|
||||
const deployment = body as {
|
||||
deploymentId: string;
|
||||
status: "deploying" | "ready" | "failed";
|
||||
};
|
||||
const result = await publishDeploymentStatus(
|
||||
deployment.deploymentId,
|
||||
deployment.status,
|
||||
);
|
||||
return Response.json(result, {
|
||||
status: 202,
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
} catch (cause) {
|
||||
return errorResponse(cause);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user