Skip to content

Commit 89cf9b8

Browse files
feat(web): edit redirect destination in place + GC certs on removal (#11)
Edit in place: - PUT /api/domains/:id updates the destination (owner-only, validated, re-exports the GCS config). Hostname stays immutable. - Dashboard: per-domain "Edit destination" with Save/Cancel — no more delete-and-recreate (works on mobile). Cert garbage collection: - On DELETE, after removing the DB row + re-exporting, also delete the host's TLS cert material from GCS cert storage (best-effort; never fails the request). Pure path-matching helper (certObjectsForHost) matches the host as a whole path segment so cleaning the apex never touches www.<host>. Tests: vitest unit for certObjectsForHost (incl. no-over-deletion); integration for PUT (owner update, 400 invalid, 404 cross-account); e2e edit-in-place. Verified live: PUT updates dest; DELETE reports certsRemoved and the object is gone from GCS. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent aca570a commit 89cf9b8

7 files changed

Lines changed: 285 additions & 49 deletions

File tree

web/e2e/admin.spec.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,18 @@ test("sign up, register a domain, and see the DNS records", async ({ page }) =>
2121
await page.locator("#destination").fill("https://example.com/e2e");
2222
await page.getByRole("button", { name: "Register domain" }).click();
2323

24-
// The domain card appears; open the DNS records bottom-sheet.
24+
// The domain card appears.
2525
await expect(page.getByText(host).first()).toBeVisible();
26-
await page.getByRole("button", { name: "View DNS records" }).click();
2726

28-
// The sheet shows the required apex A record to the static IP.
27+
// Edit the destination in place (no delete+recreate).
28+
await page.getByRole("button", { name: "Edit destination" }).click();
29+
const dest = page.locator('input[id^="dest-"]');
30+
await dest.fill("https://example.com/edited");
31+
await page.getByRole("button", { name: "Save", exact: true }).click();
32+
await expect(page.getByText("https://example.com/edited")).toBeVisible();
33+
34+
// Open the DNS records bottom-sheet: required apex A record to the static IP.
35+
await page.getByRole("button", { name: "View DNS records" }).click();
2936
await expect(page.getByText("34.172.36.60")).toBeVisible();
3037
await expect(page.getByText("required").first()).toBeVisible();
3138
});

web/src/app/api/domains/[id]/route.ts

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,70 @@ import { and, eq } from "drizzle-orm";
44
import { auth } from "@/auth";
55
import { db } from "@/db";
66
import { domain } from "@/db/schema";
7-
import { exportConfigToGCS } from "@/lib/gcs-export";
7+
import { dnsInstructions, updateSchema } from "@/lib/domains";
8+
import { deleteCertsForHost, exportConfigToGCS } from "@/lib/gcs-export";
89

9-
// DELETE /api/domains/:id — remove one of the signed-in user's domains.
10-
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
10+
async function requireUserId(): Promise<string | null> {
1111
const sess = await auth.api.getSession({ headers: await headers() });
12-
const userId = sess?.user?.id;
12+
return sess?.user?.id ?? null;
13+
}
14+
15+
// PUT /api/domains/:id — update the redirect destination (owner only).
16+
export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
17+
const userId = await requireUserId();
18+
if (!userId) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
19+
20+
const parsed = updateSchema.safeParse(await req.json().catch(() => ({})));
21+
if (!parsed.success) {
22+
return NextResponse.json(
23+
{ error: parsed.error.issues[0]?.message ?? "invalid input" },
24+
{ status: 400 },
25+
);
26+
}
27+
28+
const { id } = await params;
29+
const [updated] = await db
30+
.update(domain)
31+
.set({ destinationUrl: parsed.data.destination })
32+
.where(and(eq(domain.id, id), eq(domain.userId, userId)))
33+
.returning();
34+
35+
if (!updated) return NextResponse.json({ error: "not found" }, { status: 404 });
36+
37+
await exportConfigToGCS();
38+
39+
return NextResponse.json({
40+
domain: {
41+
id: updated.id,
42+
hostname: updated.hostname,
43+
destination: updated.destinationUrl,
44+
dns: dnsInstructions(updated.hostname),
45+
},
46+
});
47+
}
48+
49+
// DELETE /api/domains/:id — remove one of the signed-in user's domains, then
50+
// garbage-collect its TLS cert material from GCS.
51+
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
52+
const userId = await requireUserId();
1353
if (!userId) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
1454

1555
const { id } = await params;
16-
const deleted = await db
56+
const [deleted] = await db
1757
.delete(domain)
1858
.where(and(eq(domain.id, id), eq(domain.userId, userId)))
1959
.returning();
2060

21-
if (deleted.length === 0) {
22-
return NextResponse.json({ error: "not found" }, { status: 404 });
23-
}
61+
if (!deleted) return NextResponse.json({ error: "not found" }, { status: 404 });
2462

2563
await exportConfigToGCS();
26-
return NextResponse.json({ ok: true });
64+
// Best-effort cert cleanup — never fail the request on this.
65+
let certsRemoved = 0;
66+
try {
67+
certsRemoved = await deleteCertsForHost(deleted.hostname);
68+
} catch {
69+
certsRemoved = 0;
70+
}
71+
72+
return NextResponse.json({ ok: true, certsRemoved });
2773
}

web/src/app/api/domains/route.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { eq, inArray } from "drizzle-orm";
1212
import { db } from "@/db";
1313
import { domain, user } from "@/db/schema";
1414
import { GET, POST } from "./route";
15-
import { DELETE } from "./[id]/route";
15+
import { DELETE, PUT } from "./[id]/route";
1616

1717
const UA = "itest-user-a";
1818
const UB = "itest-user-b";
@@ -99,6 +99,48 @@ describe("GET /api/domains", () => {
9999
});
100100
});
101101

102+
function put(id: string, body: unknown) {
103+
return [
104+
new Request("http://t", {
105+
method: "PUT",
106+
headers: { "content-type": "application/json" },
107+
body: JSON.stringify(body),
108+
}),
109+
{ params: Promise.resolve({ id }) },
110+
] as const;
111+
}
112+
113+
describe("PUT /api/domains/:id", () => {
114+
it("updates the destination for the owner", async () => {
115+
session.current = { user: { id: UA, email: "a" } };
116+
const created = await (await POST(post({ hostname: HOSTS[0], destination: "https://a.com/old" }))).json();
117+
const id = created.domain.id;
118+
119+
const res = await PUT(...put(id, { destination: "https://a.com/new" }));
120+
expect(res.status).toBe(200);
121+
const body = await res.json();
122+
expect(body.domain.destination).toBe("https://a.com/new");
123+
124+
const rows = await db.select().from(domain).where(eq(domain.id, id));
125+
expect(rows[0].destinationUrl).toBe("https://a.com/new");
126+
});
127+
128+
it("400 on an invalid destination", async () => {
129+
session.current = { user: { id: UA, email: "a" } };
130+
const created = await (await POST(post({ hostname: HOSTS[0], destination: "https://a.com" }))).json();
131+
const res = await PUT(...put(created.domain.id, { destination: "not-a-url" }));
132+
expect(res.status).toBe(400);
133+
});
134+
135+
it("404 when another account tries to edit it", async () => {
136+
session.current = { user: { id: UA, email: "a" } };
137+
const created = await (await POST(post({ hostname: HOSTS[0], destination: "https://a.com" }))).json();
138+
session.current = { user: { id: UB, email: "b" } };
139+
const res = await PUT(...put(created.domain.id, { destination: "https://hijack.com" }));
140+
expect(res.status).toBe(404);
141+
});
142+
});
143+
102144
describe("DELETE /api/domains/:id", () => {
103145
it("removes only the caller's own domain", async () => {
104146
session.current = { user: { id: UA, email: "a" } };

web/src/components/Dashboard.tsx

Lines changed: 102 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,102 @@ function DnsSheet({ domain }: { domain: DomainItem }) {
8787
);
8888
}
8989

90+
function DomainCard({
91+
domain,
92+
onChange,
93+
onRemove,
94+
}: {
95+
domain: DomainItem;
96+
onChange: (d: DomainItem) => void;
97+
onRemove: (id: string) => void;
98+
}) {
99+
const [editing, setEditing] = useState(false);
100+
const [draft, setDraft] = useState(domain.destination);
101+
const [busy, setBusy] = useState(false);
102+
const [error, setError] = useState<string | null>(null);
103+
104+
async function save() {
105+
setBusy(true);
106+
setError(null);
107+
try {
108+
const res = await fetch(`/api/domains/${domain.id}`, {
109+
method: "PUT",
110+
headers: { "content-type": "application/json" },
111+
body: JSON.stringify({ destination: draft }),
112+
});
113+
const data = await res.json();
114+
if (!res.ok) {
115+
setError(data.error ?? "update failed");
116+
return;
117+
}
118+
onChange(data.domain);
119+
setEditing(false);
120+
} finally {
121+
setBusy(false);
122+
}
123+
}
124+
125+
async function remove() {
126+
setBusy(true);
127+
try {
128+
const res = await fetch(`/api/domains/${domain.id}`, { method: "DELETE" });
129+
if (res.ok) onRemove(domain.id);
130+
} finally {
131+
setBusy(false);
132+
}
133+
}
134+
135+
return (
136+
<div className="card">
137+
<div className="row">
138+
<div className="domain-head">
139+
<strong className="mono">{domain.hostname}</strong>
140+
{!editing && <div className="muted mono dest">{domain.destination}</div>}
141+
</div>
142+
{!editing && (
143+
<button className="danger" disabled={busy} onClick={remove}>
144+
Remove
145+
</button>
146+
)}
147+
</div>
148+
149+
{editing ? (
150+
<div style={{ marginTop: "0.6rem" }}>
151+
<label htmlFor={`dest-${domain.id}`}>Redirect destination</label>
152+
<input
153+
id={`dest-${domain.id}`}
154+
value={draft}
155+
onChange={(e) => setDraft(e.target.value)}
156+
autoCapitalize="off"
157+
autoCorrect="off"
158+
/>
159+
<div style={{ marginTop: "0.6rem", display: "flex", gap: "0.5rem" }}>
160+
<button className="primary" disabled={busy} onClick={save}>
161+
{busy ? "Saving…" : "Save"}
162+
</button>
163+
<button
164+
disabled={busy}
165+
onClick={() => {
166+
setEditing(false);
167+
setDraft(domain.destination);
168+
setError(null);
169+
}}
170+
>
171+
Cancel
172+
</button>
173+
</div>
174+
{error && <p className="error">{error}</p>}
175+
</div>
176+
) : (
177+
<div style={{ marginTop: "0.6rem", display: "flex", gap: "0.5rem" }}>
178+
<button onClick={() => setEditing(true)}>Edit destination</button>
179+
<DnsSheet domain={domain} />
180+
</div>
181+
)}
182+
</div>
183+
);
184+
}
185+
90186
export function Dashboard({
91187
initial,
92188
userEmail,
@@ -124,16 +220,6 @@ export function Dashboard({
124220
}
125221
}
126222

127-
async function remove(id: string) {
128-
setBusy(true);
129-
try {
130-
const res = await fetch(`/api/domains/${id}`, { method: "DELETE" });
131-
if (res.ok) setDomains((d) => d.filter((x) => x.id !== id));
132-
} finally {
133-
setBusy(false);
134-
}
135-
}
136-
137223
return (
138224
<div>
139225
<div className="row">
@@ -190,20 +276,12 @@ export function Dashboard({
190276
{domains.length === 0 && <p className="muted">No domains yet — register one above.</p>}
191277

192278
{domains.map((d) => (
193-
<div className="card" key={d.id}>
194-
<div className="row">
195-
<div className="domain-head">
196-
<strong className="mono">{d.hostname}</strong>
197-
<div className="muted mono dest">{d.destination}</div>
198-
</div>
199-
<button className="danger" disabled={busy} onClick={() => remove(d.id)}>
200-
Remove
201-
</button>
202-
</div>
203-
<div style={{ marginTop: "0.6rem" }}>
204-
<DnsSheet domain={d} />
205-
</div>
206-
</div>
279+
<DomainCard
280+
key={d.id}
281+
domain={d}
282+
onChange={(u) => setDomains((ds) => ds.map((x) => (x.id === u.id ? u : x)))}
283+
onRemove={(id) => setDomains((ds) => ds.filter((x) => x.id !== id))}
284+
/>
207285
))}
208286
</div>
209287
);

web/src/lib/domains.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,27 +32,33 @@ export function apexOf(host: string): string {
3232

3333
const HOSTNAME_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/;
3434

35+
const destinationField = z
36+
.string()
37+
.trim()
38+
.min(1, "destination URL is required")
39+
.refine((u) => {
40+
try {
41+
const parsed = new URL(u);
42+
return parsed.protocol === "http:" || parsed.protocol === "https:";
43+
} catch {
44+
return false;
45+
}
46+
}, "must be an absolute http(s) URL");
47+
3548
export const registerSchema = z.object({
3649
hostname: z
3750
.string()
3851
.trim()
3952
.min(1, "hostname is required")
4053
.transform(normalizeHost)
4154
.refine((h) => HOSTNAME_RE.test(h), "must be a valid fully-qualified domain (e.g. example.com)"),
42-
destination: z
43-
.string()
44-
.trim()
45-
.min(1, "destination URL is required")
46-
.refine((u) => {
47-
try {
48-
const parsed = new URL(u);
49-
return parsed.protocol === "http:" || parsed.protocol === "https:";
50-
} catch {
51-
return false;
52-
}
53-
}, "must be an absolute http(s) URL"),
55+
destination: destinationField,
5456
});
5557

58+
// Editing a redirect only changes the destination; the hostname is immutable
59+
// (changing it is a different registration).
60+
export const updateSchema = z.object({ destination: destinationField });
61+
5662
export type DnsRecord = {
5763
type: "A" | "CNAME";
5864
name: string;

web/src/lib/gcs-export.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { describe, expect, it } from "vitest";
2+
import { certObjectsForHost } from "./gcs-export";
3+
4+
describe("certObjectsForHost", () => {
5+
const names = [
6+
"certs/certificates/acme-v02.api.letsencrypt.org-directory/f3muletown.com/f3muletown.com.crt",
7+
"certs/certificates/acme-v02.api.letsencrypt.org-directory/f3muletown.com/f3muletown.com.json",
8+
"certs/certificates/acme-v02.api.letsencrypt.org-directory/www.f3muletown.com/www.f3muletown.com.crt",
9+
"certs/certificates/acme-v02.api.letsencrypt.org-directory/stats.f3muletown.com/stats.f3muletown.com.crt",
10+
"certs/acme/acme-v02.api.letsencrypt.org-directory/users/patrick@pstaylor.net/patrick.json",
11+
];
12+
13+
it("matches a host's cert objects by whole path segment", () => {
14+
const got = certObjectsForHost(names, "f3muletown.com");
15+
expect(got).toHaveLength(2);
16+
expect(got.every((n) => n.includes("/f3muletown.com/"))).toBe(true);
17+
});
18+
19+
it("does NOT match www.<host> when cleaning the apex (no over-deletion)", () => {
20+
const got = certObjectsForHost(names, "f3muletown.com");
21+
expect(got.some((n) => n.includes("www.f3muletown.com"))).toBe(false);
22+
});
23+
24+
it("matches a subdomain's own certs", () => {
25+
expect(certObjectsForHost(names, "stats.f3muletown.com")).toHaveLength(1);
26+
});
27+
28+
it("returns nothing for an unknown host", () => {
29+
expect(certObjectsForHost(names, "nope.example.com")).toHaveLength(0);
30+
});
31+
});

0 commit comments

Comments
 (0)