Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions apps/web/src/components/delivery-row.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 <span className="text-xs text-muted-foreground">soon</span>;
const s = Math.ceil(delta / 1000);
if (s < 60) return <span className="text-xs text-amber-600 dark:text-amber-400">in {s}s</span>;
Expand Down
9 changes: 7 additions & 2 deletions apps/web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -62,7 +62,12 @@ export async function requestWake(): Promise<void> {
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 */
}
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/lib/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
16 changes: 15 additions & 1 deletion apps/web/src/pages/auth-callback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);
}
Expand All @@ -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() {
Expand Down Expand Up @@ -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();
Expand Down
3 changes: 3 additions & 0 deletions infra/cdk/.gitignore
Original file line number Diff line number Diff line change
@@ -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
66 changes: 0 additions & 66 deletions infra/cdk/cdk.context.json

This file was deleted.

48 changes: 41 additions & 7 deletions infra/cdk/lambda/wake/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) };
}
17 changes: 17 additions & 0 deletions infra/cdk/lib/compute-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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: {
Expand Down
Loading