Skip to content

Commit 5331a34

Browse files
committed
Large hardening pass: explicit migrations, schema diagnostics, admin warnings, error UX
1 parent 070e55b commit 5331a34

13 files changed

Lines changed: 305 additions & 57 deletions
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
PRAGMA foreign_keys = ON;
2+
3+
CREATE TABLE IF NOT EXISTS deleted_users (
4+
id TEXT PRIMARY KEY,
5+
deleted_at TEXT NOT NULL,
6+
deleted_by_user_id TEXT
7+
);
8+
9+
ALTER TABLE users ADD COLUMN username TEXT;
10+
ALTER TABLE users ADD COLUMN email TEXT;
11+
ALTER TABLE users ADD COLUMN bio TEXT;
12+
ALTER TABLE users ADD COLUMN access_request_note TEXT;
13+
ALTER TABLE users ADD COLUMN idp_email TEXT;
14+
ALTER TABLE users ADD COLUMN idp_email_verified INTEGER NOT NULL DEFAULT 0;
15+
ALTER TABLE users ADD COLUMN avatar_url TEXT;
16+
ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0;
17+
ALTER TABLE users ADD COLUMN is_approved INTEGER NOT NULL DEFAULT 0;
18+
ALTER TABLE users ADD COLUMN approved_at TEXT;
19+
ALTER TABLE users ADD COLUMN approved_by_user_id TEXT;
20+
ALTER TABLE users ADD COLUMN updated_at TEXT;
21+
22+
ALTER TABLE sites ADD COLUMN created_by_user_id TEXT;
23+
ALTER TABLE sites ADD COLUMN last_edited_by_user_id TEXT;
24+
ALTER TABLE sites ADD COLUMN created_at TEXT;
25+
ALTER TABLE sites ADD COLUMN last_edited_at TEXT;
26+
27+
ALTER TABLE simulations ADD COLUMN created_by_user_id TEXT;
28+
ALTER TABLE simulations ADD COLUMN last_edited_by_user_id TEXT;
29+
ALTER TABLE simulations ADD COLUMN created_at TEXT;
30+
ALTER TABLE simulations ADD COLUMN last_edited_at TEXT;

db/schema.sql

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ CREATE TABLE IF NOT EXISTS users (
1717
updated_at TEXT
1818
);
1919

20+
CREATE TABLE IF NOT EXISTS deleted_users (
21+
id TEXT PRIMARY KEY,
22+
deleted_at TEXT NOT NULL,
23+
deleted_by_user_id TEXT
24+
);
25+
2026
CREATE TABLE IF NOT EXISTS sites (
2127
id TEXT PRIMARY KEY,
2228
owner_user_id TEXT NOT NULL,

docs/BACKLOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,9 @@ State: stabilization pass (no net-new product features unless explicitly approve
4141

4242
### Data and storage safety
4343
- [ ] Replace avatar data URLs in D1 with object storage flow (R2) + thumbnails
44-
- [ ] Remove runtime schema migration from request path
44+
- [x] Remove runtime schema migration from request path
4545
- [ ] Add migration/version status visibility in admin tools
46+
- Progress: admin schema diagnostics endpoint + warnings added in User Settings.
4647
- [ ] Add import/export/backup health indicators and stronger restore UX
4748

4849
### Admin tooling
@@ -67,7 +68,7 @@ State: stabilization pass (no net-new product features unless explicitly approve
6768

6869
### Security and access hardening
6970
- [ ] Productize Access policy templates in-app docs and setup checklist
70-
- [ ] Add admin warning surfaces for unsafe auth/access configuration
71+
- [x] Add admin warning surfaces for unsafe auth/access configuration
7172

7273
## Hardening execution paths (agreed, no further discussion required now)
7374
- [ ] Runtime migrations

docs/cloudflare-auth-setup.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ Copy the returned `database_id` into `wrangler.toml`.
2222
npx wrangler d1 execute linksim --file ./db/schema.sql
2323
```
2424

25+
For upgrades from older deployments, apply migrations explicitly (runtime auto-migrations are disabled):
26+
27+
```bash
28+
npx wrangler d1 execute linksim --file ./db/migrations/2026-03-12_schema_alignment.sql
29+
```
30+
2531
## 3) Configure Cloudflare Access (GitHub + OTP)
2632

2733
In Cloudflare Zero Trust:
@@ -77,6 +83,7 @@ Deploy from this repo. Pages Functions under `functions/api/*` deploy automatica
7783
- Access protects app URL (unauth users blocked/challenged)
7884
- Sign in via GitHub (or OTP fallback)
7985
- Open User Settings and confirm user status
86+
- For admins: check `/api/schema-diagnostics` and `/api/auth-diagnostics`
8087
- Trigger `Sync From Cloud`
8188
- Create/edit site/simulation and confirm cloud sync status updates
8289

functions/_lib/auth.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ describe("auth inspection", () => {
2424
});
2525

2626
describe("verifyAuth", () => {
27+
it("returns null without auth signals when dev fallback is disabled", async () => {
28+
const request = new Request("https://example.test/api/me");
29+
const auth = await verifyAuth(request, makeEnv({ ALLOW_INSECURE_DEV_AUTH: "false" }));
30+
expect(auth).toBeNull();
31+
});
32+
2733
it("accepts header-based auth when user headers are present", async () => {
2834
const request = new Request("https://example.test/api/me", {
2935
headers: {

functions/_lib/db.ts

Lines changed: 67 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,67 @@ const registrationMode = (env: Env): "open" | "approval_required" => {
123123
return value === "open" ? "open" : "approval_required";
124124
};
125125

126+
const REQUIRED_COLUMNS: Record<string, string[]> = {
127+
users: [
128+
"id",
129+
"username",
130+
"email",
131+
"bio",
132+
"access_request_note",
133+
"idp_email",
134+
"idp_email_verified",
135+
"avatar_url",
136+
"is_admin",
137+
"is_approved",
138+
"approved_at",
139+
"approved_by_user_id",
140+
"created_at",
141+
"updated_at",
142+
],
143+
sites: [
144+
"id",
145+
"owner_user_id",
146+
"created_by_user_id",
147+
"last_edited_by_user_id",
148+
"created_at",
149+
"last_edited_at",
150+
"name",
151+
"visibility",
152+
"payload_json",
153+
"updated_at",
154+
],
155+
simulations: [
156+
"id",
157+
"owner_user_id",
158+
"created_by_user_id",
159+
"last_edited_by_user_id",
160+
"created_at",
161+
"last_edited_at",
162+
"name",
163+
"visibility",
164+
"payload_json",
165+
"updated_at",
166+
],
167+
deleted_users: ["id", "deleted_at", "deleted_by_user_id"],
168+
site_roles: ["site_id", "user_id", "role", "created_at"],
169+
simulation_roles: ["simulation_id", "user_id", "role", "created_at"],
170+
resource_changes: ["id", "resource_kind", "resource_id", "action", "actor_user_id", "changed_at", "note"],
171+
};
172+
173+
export const getSchemaDiagnostics = async (env: Env): Promise<{
174+
ok: boolean;
175+
missing: Array<{ table: string; columns: string[] }>;
176+
}> => {
177+
const missing: Array<{ table: string; columns: string[] }> = [];
178+
for (const [table, required] of Object.entries(REQUIRED_COLUMNS)) {
179+
const pragma = await env.DB.prepare(`PRAGMA table_info(${table})`).all<{ name: string }>();
180+
const existing = new Set(pragma.results.map((col) => col.name));
181+
const missingColumns = required.filter((col) => !existing.has(col));
182+
if (missingColumns.length) missing.push({ table, columns: missingColumns });
183+
}
184+
return { ok: missing.length === 0, missing };
185+
};
186+
126187
const ensureSchema = async (env: Env): Promise<void> => {
127188
if (!schemaReady) {
128189
schemaReady = (async () => {
@@ -224,47 +285,12 @@ const ensureSchema = async (env: Env): Promise<void> => {
224285
env.DB.prepare("CREATE INDEX IF NOT EXISTS idx_resource_changes_lookup ON resource_changes(resource_kind, resource_id, changed_at DESC)"),
225286
]);
226287

227-
const userColumns = await env.DB.prepare("PRAGMA table_info(users)").all<{ name: string }>();
228-
const userNames = new Set(userColumns.results.map((col) => col.name));
229-
for (const query of [
230-
!userNames.has("username") ? "ALTER TABLE users ADD COLUMN username TEXT" : "",
231-
!userNames.has("email") ? "ALTER TABLE users ADD COLUMN email TEXT" : "",
232-
!userNames.has("bio") ? "ALTER TABLE users ADD COLUMN bio TEXT" : "",
233-
!userNames.has("access_request_note") ? "ALTER TABLE users ADD COLUMN access_request_note TEXT" : "",
234-
!userNames.has("idp_email") ? "ALTER TABLE users ADD COLUMN idp_email TEXT" : "",
235-
!userNames.has("idp_email_verified")
236-
? "ALTER TABLE users ADD COLUMN idp_email_verified INTEGER NOT NULL DEFAULT 0"
237-
: "",
238-
!userNames.has("avatar_url") ? "ALTER TABLE users ADD COLUMN avatar_url TEXT" : "",
239-
!userNames.has("is_admin") ? "ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0" : "",
240-
!userNames.has("is_approved") ? "ALTER TABLE users ADD COLUMN is_approved INTEGER NOT NULL DEFAULT 0" : "",
241-
!userNames.has("approved_at") ? "ALTER TABLE users ADD COLUMN approved_at TEXT" : "",
242-
!userNames.has("approved_by_user_id") ? "ALTER TABLE users ADD COLUMN approved_by_user_id TEXT" : "",
243-
!userNames.has("updated_at") ? "ALTER TABLE users ADD COLUMN updated_at TEXT" : "",
244-
]) {
245-
if (query) await env.DB.prepare(query).run();
246-
}
247-
248-
const siteColumns = await env.DB.prepare("PRAGMA table_info(sites)").all<{ name: string }>();
249-
const siteNames = new Set(siteColumns.results.map((col) => col.name));
250-
for (const query of [
251-
!siteNames.has("created_by_user_id") ? "ALTER TABLE sites ADD COLUMN created_by_user_id TEXT" : "",
252-
!siteNames.has("last_edited_by_user_id") ? "ALTER TABLE sites ADD COLUMN last_edited_by_user_id TEXT" : "",
253-
!siteNames.has("created_at") ? "ALTER TABLE sites ADD COLUMN created_at TEXT" : "",
254-
!siteNames.has("last_edited_at") ? "ALTER TABLE sites ADD COLUMN last_edited_at TEXT" : "",
255-
]) {
256-
if (query) await env.DB.prepare(query).run();
257-
}
258-
259-
const simColumns = await env.DB.prepare("PRAGMA table_info(simulations)").all<{ name: string }>();
260-
const simNames = new Set(simColumns.results.map((col) => col.name));
261-
for (const query of [
262-
!simNames.has("created_by_user_id") ? "ALTER TABLE simulations ADD COLUMN created_by_user_id TEXT" : "",
263-
!simNames.has("last_edited_by_user_id") ? "ALTER TABLE simulations ADD COLUMN last_edited_by_user_id TEXT" : "",
264-
!simNames.has("created_at") ? "ALTER TABLE simulations ADD COLUMN created_at TEXT" : "",
265-
!simNames.has("last_edited_at") ? "ALTER TABLE simulations ADD COLUMN last_edited_at TEXT" : "",
266-
]) {
267-
if (query) await env.DB.prepare(query).run();
288+
const diagnostics = await getSchemaDiagnostics(env);
289+
if (!diagnostics.ok) {
290+
const summary = diagnostics.missing
291+
.map((entry) => `${entry.table}: ${entry.columns.join(",")}`)
292+
.join(" | ");
293+
throw new Error(`Schema out of date. Run D1 migrations. Missing: ${summary}`);
268294
}
269295
})();
270296
}

functions/_lib/http.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { normalizeApiErrorMessage, statusFromErrorMessage } from "./http";
33

44
describe("http error normalization", () => {
55
it("maps known auth/access errors to stable statuses", () => {
6+
expect(statusFromErrorMessage("Schema out of date")).toBe(503);
67
expect(statusFromErrorMessage("Session revoked by admin")).toBe(401);
78
expect(statusFromErrorMessage("Unauthorized")).toBe(401);
89
expect(statusFromErrorMessage("Account pending approval")).toBe(403);

functions/_lib/http.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export const normalizeApiErrorMessage = (message: string): string => {
5252

5353
export const statusFromErrorMessage = (message: string, fallback = 500): number => {
5454
const lower = message.toLowerCase();
55+
if (lower.includes("schema out of date")) return 503;
5556
if (lower.includes("session revoked by admin")) return 401;
5657
if (lower.includes("unauthorized")) return 401;
5758
if (lower.includes("pending approval")) return 403;

functions/api/auth-diagnostics.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,30 @@
11
import { verifyAuth, inspectAuthRequest } from "../_lib/auth";
2-
import { assertUserAccess, ensureUser, fetchUserProfile } from "../_lib/db";
2+
import { ensureUser, fetchUserProfile } from "../_lib/db";
33
import { errorResponse, handleOptions, json, withCors } from "../_lib/http";
44
import type { Env } from "../_lib/types";
55

66
export const onRequestOptions: PagesFunction<Env> = async ({ request }) => handleOptions(request);
77

8+
const isBootstrapAdmin = (env: Env, userId: string): boolean =>
9+
(env.ADMIN_USER_IDS ?? "")
10+
.split(",")
11+
.map((value) => value.trim().toLowerCase())
12+
.filter(Boolean)
13+
.includes(userId.toLowerCase());
14+
815
export const onRequestGet: PagesFunction<Env> = async ({ request, env }) => {
916
try {
1017
const auth = await verifyAuth(request, env);
1118
if (!auth) return withCors(request, json({ error: "Unauthorized" }, { status: 401 }));
12-
13-
await ensureUser(env, auth.userId, auth.tokenPayload);
14-
await assertUserAccess(env, auth.userId);
15-
const me = await fetchUserProfile(env, auth.userId);
16-
if (!me) return withCors(request, json({ error: "Unauthorized" }, { status: 401 }));
17-
if (!me.isAdmin) return withCors(request, json({ error: "Forbidden" }, { status: 403 }));
19+
let allowed = isBootstrapAdmin(env, auth.userId);
20+
try {
21+
await ensureUser(env, auth.userId, auth.tokenPayload);
22+
const me = await fetchUserProfile(env, auth.userId);
23+
if (me?.isAdmin) allowed = true;
24+
} catch {
25+
// If schema is out of date, allow bootstrap admins to inspect diagnostics.
26+
}
27+
if (!allowed) return withCors(request, json({ error: "Forbidden" }, { status: 403 }));
1828

1929
const claims = auth.tokenPayload;
2030
return withCors(
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { verifyAuth } from "../_lib/auth";
2+
import { ensureUser, fetchUserProfile, getSchemaDiagnostics } from "../_lib/db";
3+
import { errorResponse, handleOptions, json, withCors } from "../_lib/http";
4+
import type { Env } from "../_lib/types";
5+
6+
export const onRequestOptions: PagesFunction<Env> = async ({ request }) => handleOptions(request);
7+
8+
const isBootstrapAdmin = (env: Env, userId: string): boolean =>
9+
(env.ADMIN_USER_IDS ?? "")
10+
.split(",")
11+
.map((value) => value.trim().toLowerCase())
12+
.filter(Boolean)
13+
.includes(userId.toLowerCase());
14+
15+
export const onRequestGet: PagesFunction<Env> = async ({ request, env }) => {
16+
try {
17+
const auth = await verifyAuth(request, env);
18+
if (!auth) return withCors(request, json({ error: "Unauthorized" }, { status: 401 }));
19+
20+
let allowed = isBootstrapAdmin(env, auth.userId);
21+
try {
22+
await ensureUser(env, auth.userId, auth.tokenPayload);
23+
const me = await fetchUserProfile(env, auth.userId);
24+
if (me?.isAdmin) allowed = true;
25+
} catch {
26+
// If schema is out of date, allow bootstrap admins to inspect diagnostics.
27+
}
28+
if (!allowed) return withCors(request, json({ error: "Forbidden" }, { status: 403 }));
29+
30+
const diagnostics = await getSchemaDiagnostics(env);
31+
return withCors(request, json({ schema: diagnostics }));
32+
} catch (error) {
33+
return errorResponse(request, error, 500);
34+
}
35+
};

0 commit comments

Comments
 (0)