diff --git a/apps/web/src/components/delivery-row.tsx b/apps/web/src/components/delivery-row.tsx
index 559caff..6054788 100644
--- a/apps/web/src/components/delivery-row.tsx
+++ b/apps/web/src/components/delivery-row.tsx
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { useEffect, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { ChevronDownIcon, ChevronRightIcon, RefreshCwIcon } from "lucide-react";
@@ -54,7 +54,14 @@ export function StatusBadge({ status }: { status: Delivery["status"] }) {
// ── Countdown helper ─────────────────────────────────────────────────────────
function RetryCountdown({ nextRetryAt }: { nextRetryAt: string }) {
- const delta = new Date(nextRetryAt).getTime() - Date.now();
+ // Tick every second so the countdown updates smoothly, independent of the 5s
+ // data refetch (which otherwise makes it jump in ~5s steps).
+ const [now, setNow] = useState(() => Date.now());
+ useEffect(() => {
+ const id = window.setInterval(() => setNow(Date.now()), 1000);
+ return () => window.clearInterval(id);
+ }, []);
+ const delta = new Date(nextRetryAt).getTime() - now;
if (delta <= 0) return soon;
const s = Math.ceil(delta / 1000);
if (s < 60) return in {s}s;
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
index de8405d..9296293 100644
--- a/apps/web/src/lib/api.ts
+++ b/apps/web/src/lib/api.ts
@@ -3,7 +3,7 @@ import type {
Form, FormWithFields, PublicForm, Submission, SubmissionWithForm,
} from "./types";
import { refreshTokens } from "./pkce";
-import { getAccessToken, getRefreshToken, storeTokens } from "@/pages/auth-callback";
+import { getAccessToken, getRefreshToken, storeTokens, getUserEmail } from "@/pages/auth-callback";
const API_URL: string = import.meta.env.VITE_API_URL ?? "http://localhost:3001";
const AUTH_MODE: string = import.meta.env.VITE_AUTH_MODE ?? "dev";
@@ -62,7 +62,12 @@ export async function requestWake(): Promise {
const authHeader = getAuthHeader();
if (!authHeader) return;
try {
- await fetch(WAKE_URL, { method: "POST", headers: { authorization: authHeader } });
+ await fetch(WAKE_URL, {
+ method: "POST",
+ headers: { authorization: authHeader, "content-type": "application/json" },
+ // Email (from the ID token) so the owner is notified who woke the box.
+ body: JSON.stringify({ email: getUserEmail() ?? undefined }),
+ });
} catch {
/* best effort */
}
diff --git a/apps/web/src/lib/jwt.ts b/apps/web/src/lib/jwt.ts
index 8f43b01..56e8393 100644
--- a/apps/web/src/lib/jwt.ts
+++ b/apps/web/src/lib/jwt.ts
@@ -37,3 +37,9 @@ export function displayNameFromIdToken(idToken: string): string | null {
}
return null;
}
+
+/** The user's email claim from an OIDC ID token, or null. */
+export function emailFromIdToken(idToken: string): string | null {
+ const email = decodeJwtPayload(idToken)?.email;
+ return typeof email === "string" && email.trim().length > 0 ? email.trim() : null;
+}
diff --git a/apps/web/src/pages/auth-callback.tsx b/apps/web/src/pages/auth-callback.tsx
index 512c6db..8a1bac5 100644
--- a/apps/web/src/pages/auth-callback.tsx
+++ b/apps/web/src/pages/auth-callback.tsx
@@ -4,7 +4,7 @@ import { exchangeCode } from "@/lib/pkce";
import type { CognitoConfig } from "@/lib/pkce";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth";
-import { displayNameFromIdToken } from "@/lib/jwt";
+import { displayNameFromIdToken, emailFromIdToken } from "@/lib/jwt";
function getCognitoCfg(): CognitoConfig {
return {
@@ -18,11 +18,17 @@ function getCognitoCfg(): CognitoConfig {
const ACCESS_TOKEN_KEY = "eventform.accessToken";
const REFRESH_TOKEN_KEY = "eventform.refreshToken";
+const EMAIL_KEY = "eventform.email";
export function getAccessToken(): string | null {
return localStorage.getItem(ACCESS_TOKEN_KEY);
}
+/** The signed-in user's email (from the ID token), for the wake notification. */
+export function getUserEmail(): string | null {
+ return localStorage.getItem(EMAIL_KEY);
+}
+
export function getRefreshToken(): string | null {
return localStorage.getItem(REFRESH_TOKEN_KEY);
}
@@ -37,6 +43,7 @@ export function storeTokens(accessToken: string, refreshToken?: string): void {
export function clearTokens(): void {
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
+ localStorage.removeItem(EMAIL_KEY);
}
export default function AuthCallbackPage() {
@@ -75,6 +82,13 @@ export default function AuthCallbackPage() {
if (displayName) {
await api.updateMe(displayName).catch(() => undefined);
}
+ // Stash the email (from the ID token) for the scale-to-zero wake
+ // notification — requestWake() sends it so the owner is emailed who
+ // started the box. Best-effort; sign-in must not depend on it.
+ const email = emailFromIdToken(tokens.id_token);
+ if (email) {
+ localStorage.setItem(EMAIL_KEY, email);
+ }
// Re-sync the auth context BEFORE navigating — RequireAuth otherwise
// still holds the at-mount null sub and bounces back to /login.
completeSignIn();
diff --git a/infra/cdk/.gitignore b/infra/cdk/.gitignore
index 850b823..888601e 100644
--- a/infra/cdk/.gitignore
+++ b/infra/cdk/.gitignore
@@ -1,6 +1,9 @@
node_modules/
dist/
cdk.out/
+# Per-environment lookup cache (VPC/AMI/account IDs) — regenerated on each
+# synth/deploy; not committed (avoids churn + keeping account/VPC IDs in a public repo).
+cdk.context.json
*.d.ts
*.js
*.js.map
diff --git a/infra/cdk/cdk.context.json b/infra/cdk/cdk.context.json
deleted file mode 100644
index d0ba91a..0000000
--- a/infra/cdk/cdk.context.json
+++ /dev/null
@@ -1,66 +0,0 @@
-{
- "vpc-provider:account=536972289919:filter.isDefault=true:region=eu-west-2:returnAsymmetricSubnets=true": {
- "vpcId": "vpc-0532c9aff15bab055",
- "vpcCidrBlock": "172.31.0.0/16",
- "ownerAccountId": "536972289919",
- "availabilityZones": [],
- "subnetGroups": [
- {
- "name": "Public",
- "type": "Public",
- "subnets": [
- {
- "subnetId": "subnet-01c0e30a04d01bcc0",
- "cidr": "172.31.16.0/20",
- "availabilityZone": "eu-west-2a",
- "routeTableId": "rtb-0df448a091bfb2c7b"
- },
- {
- "subnetId": "subnet-0f508483bec4f14e5",
- "cidr": "172.31.32.0/20",
- "availabilityZone": "eu-west-2b",
- "routeTableId": "rtb-0df448a091bfb2c7b"
- },
- {
- "subnetId": "subnet-04844df3545c6aecb",
- "cidr": "172.31.0.0/20",
- "availabilityZone": "eu-west-2c",
- "routeTableId": "rtb-0df448a091bfb2c7b"
- }
- ]
- }
- ]
- },
- "vpc-provider:account=536972289919:filter.isDefault=true:region=ap-south-1:returnAsymmetricSubnets=true": {
- "vpcId": "vpc-0c6cf65d84b82a02d",
- "vpcCidrBlock": "172.31.0.0/16",
- "ownerAccountId": "536972289919",
- "availabilityZones": [],
- "subnetGroups": [
- {
- "name": "Public",
- "type": "Public",
- "subnets": [
- {
- "subnetId": "subnet-01e2fb1025d1f6de6",
- "cidr": "172.31.32.0/20",
- "availabilityZone": "ap-south-1a",
- "routeTableId": "rtb-06232339fc3ebdc0c"
- },
- {
- "subnetId": "subnet-0cfb97198de813c29",
- "cidr": "172.31.0.0/20",
- "availabilityZone": "ap-south-1b",
- "routeTableId": "rtb-06232339fc3ebdc0c"
- },
- {
- "subnetId": "subnet-0f11890f8370f432d",
- "cidr": "172.31.16.0/20",
- "availabilityZone": "ap-south-1c",
- "routeTableId": "rtb-06232339fc3ebdc0c"
- }
- ]
- }
- ]
- }
-}
diff --git a/infra/cdk/lambda/wake/index.mjs b/infra/cdk/lambda/wake/index.mjs
index 56763e8..14c9d28 100644
--- a/infra/cdk/lambda/wake/index.mjs
+++ b/infra/cdk/lambda/wake/index.mjs
@@ -8,29 +8,63 @@ import {
DescribeAutoScalingGroupsCommand,
SetDesiredCapacityCommand,
} from "@aws-sdk/client-auto-scaling";
+import { SNSClient, PublishCommand } from "@aws-sdk/client-sns";
import { decideWake } from "./decide.mjs";
const ASG_NAME = process.env.ASG_NAME;
-const client = new AutoScalingClient({});
+const TOPIC_ARN = process.env.TOPIC_ARN;
+const asg = new AutoScalingClient({});
+const sns = new SNSClient({});
-export async function handler() {
+export async function handler(event) {
if (!ASG_NAME) return json(500, { error: "ASG_NAME not configured" });
- const out = await client.send(
+ const out = await asg.send(
new DescribeAutoScalingGroupsCommand({ AutoScalingGroupNames: [ASG_NAME] }),
);
- const asg = out.AutoScalingGroups?.[0];
- if (!asg) return json(404, { error: "auto scaling group not found" });
+ const group = out.AutoScalingGroups?.[0];
+ if (!group) return json(404, { error: "auto scaling group not found" });
- const decision = decideWake(asg);
+ const decision = decideWake(group);
if (decision.action === "start") {
- await client.send(
+ await asg.send(
new SetDesiredCapacityCommand({ AutoScalingGroupName: ASG_NAME, DesiredCapacity: 1 }),
);
+ // Notify only on the real 0→1 transition — one email per cold start, not per
+ // wake call (concurrent callers during boot get a no-op). Never fail the wake
+ // on a notify error.
+ await notifyStarted(event);
}
return json(200, { state: decision.state, desired: decision.desired });
}
+async function notifyStarted(event) {
+ if (!TOPIC_ARN) return;
+ const who = userFrom(event);
+ try {
+ await sns.send(
+ new PublishCommand({
+ TopicArn: TOPIC_ARN,
+ Subject: `EventForm started by ${who}`.slice(0, 99),
+ Message: `EventForm backend was woken from idle.\n\nUser: ${who}\nWhen: ${new Date().toISOString()}`,
+ }),
+ );
+ } catch (e) {
+ console.error("wake notify failed:", e?.message);
+ }
+}
+
+function userFrom(event) {
+ try {
+ const body = event?.body ? JSON.parse(event.body) : {};
+ if (body?.email) return String(body.email);
+ } catch {
+ /* malformed body — fall through to claims */
+ }
+ const claims = event?.requestContext?.authorizer?.jwt?.claims ?? {};
+ return claims.email || claims.username || claims.sub || "an authenticated user";
+}
+
function json(statusCode, body) {
return { statusCode, headers: { "content-type": "application/json" }, body: JSON.stringify(body) };
}
diff --git a/infra/cdk/lib/compute-stack.ts b/infra/cdk/lib/compute-stack.ts
index 3a50bdb..e91e4c1 100644
--- a/infra/cdk/lib/compute-stack.ts
+++ b/infra/cdk/lib/compute-stack.ts
@@ -5,6 +5,8 @@ import * as autoscaling from "aws-cdk-lib/aws-autoscaling";
import * as iam from "aws-cdk-lib/aws-iam";
import * as acm from "aws-cdk-lib/aws-certificatemanager";
import * as lambda from "aws-cdk-lib/aws-lambda";
+import * as sns from "aws-cdk-lib/aws-sns";
+import * as subscriptions from "aws-cdk-lib/aws-sns-subscriptions";
import * as apigwv2 from "aws-cdk-lib/aws-apigatewayv2";
import { HttpJwtAuthorizer } from "aws-cdk-lib/aws-apigatewayv2-authorizers";
import { HttpLambdaIntegration } from "aws-cdk-lib/aws-apigatewayv2-integrations";
@@ -271,6 +273,21 @@ export class ComputeStack extends cdk.Stack {
}),
);
+ // Notify on a real cold start (0→1). SNS email subscription → you get one
+ // email per startup naming who woke it. The subscriber must confirm the
+ // one-time "confirm subscription" email AWS sends. Pass `-c notifyEmail=`
+ // (empty) to disable.
+ const notifyEmail =
+ (this.node.tryGetContext("notifyEmail") as string | undefined) ?? "murugu2001@gmail.com";
+ if (notifyEmail) {
+ const notifyTopic = new sns.Topic(this, "WakeNotifyTopic", {
+ displayName: "EventForm backend started",
+ });
+ notifyTopic.addSubscription(new subscriptions.EmailSubscription(notifyEmail));
+ notifyTopic.grantPublish(wakeFn);
+ wakeFn.addEnvironment("TOPIC_ARN", notifyTopic.topicArn);
+ }
+
const wakeApi = new apigwv2.HttpApi(this, "WakeApi", {
description: "EventForm wake endpoint (Cognito-authorized)",
corsPreflight: {