Skip to content

Commit 2605a4f

Browse files
committed
feat(instance): keys UX, rotate, suspend/resume, delete, error types
1 parent ec5c061 commit 2605a4f

24 files changed

Lines changed: 2136 additions & 438 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@secondlayer/provisioner": minor
3+
"@secondlayer/shared": minor
4+
---
5+
6+
Phase 1 instance-page hardening. Adds per-tenant key rotation with independent service/anon generations, suspend/resume endpoints on the provisioner, hard-delete teardown, typed provisioner errors, and automatic attachment of the platform postgres to `sl-source` with a `postgres` alias at provision time.
7+
8+
- `jwt.ts``mintTenantKeys` now takes `{ serviceGen, anonGen }` and embeds a `gen` claim; adds `mintSingleKey` for role-scoped rotation.
9+
- `lifecycle.ts` — new `rotateTenantKeys(slug, plan, type, newGens)` recreates the tenant API container with new env vars and mints replacement key(s).
10+
- `routes.ts` — adds `POST /tenants/:slug/keys/rotate`; bubbles typed error codes + appropriate HTTP status via new `httpStatusForProvisionError`.
11+
- `types.ts` — adds `ProvisionErrorCode`, `classifyProvisionError`, `httpStatusForProvisionError`.
12+
- `docker.ts` — adds `networkConnectWithAlias` (idempotent); `provision.ts` calls it to attach `secondlayer-postgres-1` to `sl-source` as `postgres` so fresh Hetzner hosts work without manual compose edits.
13+
- `@secondlayer/shared` — migration `0040_tenant_key_generations` adds `service_gen` + `anon_gen` counters to `tenants`; new queries `bumpTenantKeyGen`, `updateTenantKeys`, `deleteTenant`.
14+
- `@secondlayer/api` middleware — `dedicatedAuth` validates the `gen` claim against `SERVICE_GEN`/`ANON_GEN` env; adds `/me/keys/rotate`, `/me/suspend`, `/me/resume`; changes `DELETE /me` from soft-suspend to hard-delete (containers + volume + DB row).
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { ApiError, apiRequest, getSessionFromRequest } from "@/lib/api";
2+
import { NextResponse } from "next/server";
3+
4+
export async function POST(req: Request) {
5+
const sessionToken = getSessionFromRequest(req);
6+
if (!sessionToken) {
7+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
8+
}
9+
const body = await req.json().catch(() => null);
10+
if (!body) {
11+
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
12+
}
13+
try {
14+
const data = await apiRequest("/api/tenants/me/keys/rotate", {
15+
method: "POST",
16+
sessionToken,
17+
body,
18+
});
19+
return NextResponse.json(data);
20+
} catch (e) {
21+
if (e instanceof ApiError) {
22+
return NextResponse.json({ error: e.message }, { status: e.status });
23+
}
24+
return NextResponse.json({ error: "Internal error" }, { status: 500 });
25+
}
26+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { ApiError, apiRequest, getSessionFromRequest } from "@/lib/api";
2+
import { NextResponse } from "next/server";
3+
4+
export async function POST(req: Request) {
5+
const sessionToken = getSessionFromRequest(req);
6+
if (!sessionToken) {
7+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
8+
}
9+
try {
10+
const data = await apiRequest("/api/tenants/me/resume", {
11+
method: "POST",
12+
sessionToken,
13+
});
14+
return NextResponse.json(data);
15+
} catch (e) {
16+
if (e instanceof ApiError) {
17+
return NextResponse.json({ error: e.message }, { status: e.status });
18+
}
19+
return NextResponse.json({ error: "Internal error" }, { status: 500 });
20+
}
21+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { ApiError, apiRequest, getSessionFromRequest } from "@/lib/api";
2+
import { NextResponse } from "next/server";
3+
4+
export async function POST(req: Request) {
5+
const sessionToken = getSessionFromRequest(req);
6+
if (!sessionToken) {
7+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
8+
}
9+
try {
10+
const data = await apiRequest("/api/tenants/me/suspend", {
11+
method: "POST",
12+
sessionToken,
13+
});
14+
return NextResponse.json(data);
15+
} catch (e) {
16+
if (e instanceof ApiError) {
17+
return NextResponse.json({ error: e.message }, { status: e.status });
18+
}
19+
return NextResponse.json({ error: "Internal error" }, { status: 500 });
20+
}
21+
}
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
5+
export function DangerZone({
6+
slug,
7+
status,
8+
sessionToken,
9+
onSuspended,
10+
onDeleted,
11+
onRotateAll,
12+
}: {
13+
slug: string;
14+
status: string;
15+
sessionToken: string;
16+
onSuspended: () => void;
17+
onDeleted: () => void;
18+
onRotateAll: () => void;
19+
}) {
20+
const [busy, setBusy] = useState<"suspend" | "resume" | "delete" | null>(
21+
null,
22+
);
23+
const [confirmDelete, setConfirmDelete] = useState(false);
24+
const [deleteInput, setDeleteInput] = useState("");
25+
const [error, setError] = useState<string | null>(null);
26+
27+
const callAction = async (path: string, method: string) => {
28+
const res = await fetch(path, {
29+
method,
30+
headers: { Authorization: `Bearer ${sessionToken}` },
31+
});
32+
if (!res.ok) {
33+
const body = await res.json().catch(() => ({}));
34+
throw new Error(body.error ?? `Request failed (${res.status})`);
35+
}
36+
return res.json();
37+
};
38+
39+
const handleSuspend = async () => {
40+
if (busy) return;
41+
setBusy("suspend");
42+
setError(null);
43+
try {
44+
await callAction("/api/tenants/me/suspend", "POST");
45+
onSuspended();
46+
} catch (e) {
47+
setError(e instanceof Error ? e.message : "Suspend failed");
48+
} finally {
49+
setBusy(null);
50+
}
51+
};
52+
53+
const handleResume = async () => {
54+
if (busy) return;
55+
setBusy("resume");
56+
setError(null);
57+
try {
58+
await callAction("/api/tenants/me/resume", "POST");
59+
onSuspended();
60+
} catch (e) {
61+
setError(e instanceof Error ? e.message : "Resume failed");
62+
} finally {
63+
setBusy(null);
64+
}
65+
};
66+
67+
const handleDelete = async () => {
68+
if (busy || deleteInput !== slug) return;
69+
setBusy("delete");
70+
setError(null);
71+
try {
72+
await callAction("/api/tenants/me", "DELETE");
73+
onDeleted();
74+
} catch (e) {
75+
setError(e instanceof Error ? e.message : "Delete failed");
76+
setBusy(null);
77+
}
78+
};
79+
80+
const isSuspended = status === "suspended";
81+
82+
return (
83+
<section className="settings-section">
84+
<div className="settings-section-title">Danger zone</div>
85+
86+
{error && (
87+
<div className="instance-banner danger" style={{ marginBottom: 12 }}>
88+
<span className="banner-dot" />
89+
<div className="banner-body">{error}</div>
90+
</div>
91+
)}
92+
93+
<div className="danger-card" style={{ marginBottom: 10 }}>
94+
<div>
95+
<div className="title">Rotate all keys</div>
96+
<div className="desc">
97+
Invalidates service + anon simultaneously. Use when a team member
98+
leaves or you suspect both keys are compromised.
99+
</div>
100+
</div>
101+
<button
102+
type="button"
103+
className="settings-btn ghost small"
104+
onClick={onRotateAll}
105+
disabled={busy !== null}
106+
>
107+
Rotate all
108+
</button>
109+
</div>
110+
111+
<div className="danger-card" style={{ marginBottom: 10 }}>
112+
<div>
113+
<div className="title">
114+
{isSuspended ? "Resume instance" : "Suspend instance"}
115+
</div>
116+
<div className="desc">
117+
{isSuspended
118+
? "Start all containers. Data preserved while suspended."
119+
: "Stop all containers. Data preserved. Resume any time in ~20s."}
120+
</div>
121+
</div>
122+
<button
123+
type="button"
124+
className="settings-btn ghost small"
125+
onClick={isSuspended ? handleResume : handleSuspend}
126+
disabled={busy !== null}
127+
>
128+
{busy === "suspend"
129+
? "Suspending…"
130+
: busy === "resume"
131+
? "Resuming…"
132+
: isSuspended
133+
? "Resume"
134+
: "Suspend"}
135+
</button>
136+
</div>
137+
138+
<div className="danger-card">
139+
<div>
140+
<div className="title">Delete instance</div>
141+
<div className="desc">
142+
Tears down containers and deletes all tenant data. Cannot be undone.
143+
</div>
144+
</div>
145+
{!confirmDelete ? (
146+
<button
147+
type="button"
148+
className="settings-btn danger small"
149+
onClick={() => setConfirmDelete(true)}
150+
disabled={busy !== null}
151+
>
152+
Delete
153+
</button>
154+
) : (
155+
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
156+
<input
157+
type="text"
158+
className="settings-input mono"
159+
value={deleteInput}
160+
onChange={(e) => setDeleteInput(e.target.value)}
161+
placeholder={slug}
162+
style={{ width: 140, height: 32 }}
163+
/>
164+
<button
165+
type="button"
166+
className="settings-btn danger small"
167+
onClick={handleDelete}
168+
disabled={deleteInput !== slug || busy !== null}
169+
>
170+
{busy === "delete" ? "Deleting…" : "Confirm"}
171+
</button>
172+
<button
173+
type="button"
174+
className="settings-btn ghost small"
175+
onClick={() => {
176+
setConfirmDelete(false);
177+
setDeleteInput("");
178+
}}
179+
disabled={busy !== null}
180+
>
181+
Cancel
182+
</button>
183+
</div>
184+
)}
185+
</div>
186+
187+
{confirmDelete && (
188+
<div className="settings-hint">
189+
Type the slug <code>{slug}</code> to confirm.
190+
</div>
191+
)}
192+
</section>
193+
);
194+
}

0 commit comments

Comments
 (0)