feat : notify new update income

This commit is contained in:
2026-09-26 23:34:42 +07:00 Unverified
parent e99a5b76b1
commit 4ef1968b04
14 changed files with 499 additions and 33 deletions
+19 -7
View File
@@ -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);
}
+61
View File
@@ -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");
});
});
+52
View File
@@ -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);
}
}