Skip to content

Commit 0c1f40c

Browse files
committed
fix+feat: v0.11.1 — auth hooks, admin records flow, collection stats
Auth signup paths now fire the same hook lifecycle as the records flow. Pre-fix: /register, /anonymous, and OAuth new-user paths inserted via users-table directly, never reaching runBeforeHook / runAfterHook in core/records.ts. After-create hooks (auto-create profile, send welcome email, etc.) silently never fired. Now wired: POST /auth/<col>/register -> beforeCreate + afterCreate POST /auth/<col>/anonymous -> afterCreate OAuth callback (new user) -> afterCreate beforeCreate runs synchronously: throws abort the signup with 422 + validation details. afterCreate is fire-and-forget (errors logged, do not block the response). Hook ctx receives the canonical projected record (password_hash already stripped) plus the standard helpers library. Hook helpers gained `helpers.uuid()` and its alias `helpers.id()`. Crypto.randomUUID() was always available globally, but the LLM-friendly name was missing — hooks calling `ctx.helpers.id()` got undefined and cascaded into "null"-string ids in user data. Admin Records.tsx fix: every operation on auth collections now goes through the records flow (`/api/v1/<col>/<id>`) instead of the removed `/api/v1/admin/users/<col>/<id>` endpoint that v0.11 dropped. DELETE / bulk delete / PATCH save / disable-MFA all hit the right URL. New admin recovery endpoint: `POST /admin/users/<col>/<id>/disable-mfa` clears totp_enabled + totp_secret + recovery codes. The records-flow PATCH explicitly write-protects auth-system columns, so this dedicated endpoint covers the "user lost authenticator" case. Collections admin page: per-collection stats now real. New endpoint `GET /admin/collections/stats` returns: - recordCount (capped at 50000 — saturates as "50,000+" in UI) - lastUpdated (max(updated_at), unix-seconds) - recentWrites (count of rows updated in last 24h) View collections return null counts. Tests +5 collections-stats (auth required, base counts, view skip, auth counts, 24h window correctness) +3 auth-hooks (register fires before+after, anonymous fires after, global hooks fire on auth signup) 859 pass / 2 pre-existing GIF flakes.
1 parent e064e90 commit 0c1f40c

11 files changed

Lines changed: 517 additions & 47 deletions

File tree

admin/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "vaultbase-admin",
33
"private": true,
4-
"version": "0.11.0",
4+
"version": "0.11.1",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

admin/src/pages/Collections.tsx

Lines changed: 66 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useEffect, useMemo, useState } from "react";
22
import { useNavigate } from "react-router-dom";
3-
import { api, parseFields } from "../api.ts";
3+
import { api, parseFields, type ApiResponse } from "../api.ts";
44
import { confirm } from "../components/Confirm.tsx";
55
import Icon from "../components/Icon.tsx";
66
import { toast } from "../stores/toast.ts";
@@ -20,6 +20,16 @@ import NewCollectionModal from "./NewCollectionModal.tsx";
2020

2121
type Tab = "all" | "auth" | "base" | "view";
2222

23+
/** Compact "5m ago" / "3h ago" / "2d ago" formatter for `lastUpdated`. */
24+
function formatRel(unix: number): string {
25+
const diff = Math.floor(Date.now() / 1000) - unix;
26+
if (diff < 0) return "just now";
27+
if (diff < 60) return `${diff}s ago`;
28+
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
29+
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
30+
return `${Math.floor(diff / 86400)}d ago`;
31+
}
32+
2333
export default function Collections() {
2434
const navigate = useNavigate();
2535
const collections = useCollections((s) => s.list);
@@ -33,8 +43,32 @@ export default function Collections() {
3343
const [search, setSearch] = useState("");
3444
const [showNew, setShowNew] = useState(false);
3545

36-
function load() { invalidate(); void loadCollections(true); }
37-
useEffect(() => { void loadCollections(); }, [loadCollections]);
46+
// Per-collection counts + activity. Fetched separately so the page
47+
// renders the collection list immediately and the stats fill in.
48+
interface CollectionStats {
49+
name: string;
50+
type: "base" | "auth" | "view";
51+
recordCount: number | null;
52+
recordCountCapped: boolean;
53+
lastUpdated: number | null;
54+
recentWrites: number;
55+
}
56+
const [stats, setStats] = useState<Map<string, CollectionStats>>(new Map());
57+
const [statsWindowSec, setStatsWindowSec] = useState<number>(86400);
58+
59+
function load() { invalidate(); void loadCollections(true); void loadStats(); }
60+
useEffect(() => { void loadCollections(); void loadStats(); }, [loadCollections]);
61+
62+
async function loadStats(): Promise<void> {
63+
const res = await api.get<ApiResponse<CollectionStats[]> & { windowSec?: number }>(
64+
"/api/v1/admin/collections/stats",
65+
);
66+
if (!res.data) return;
67+
const m = new Map<string, CollectionStats>();
68+
for (const s of res.data) m.set(s.name, s);
69+
setStats(m);
70+
if (typeof res.windowSec === "number") setStatsWindowSec(res.windowSec);
71+
}
3872

3973
async function handleDelete(e: React.MouseEvent, id: string, name: string) {
4074
e.stopPropagation();
@@ -49,18 +83,30 @@ export default function Collections() {
4983
load();
5084
}
5185

52-
// Per-collection metadata (records, recent-activity rate, last write).
53-
// The stats endpoint isn't exposed yet — these slots stay so the layout
54-
// matches the design and the visual rhythm is right; values will land
55-
// when the per-collection-stats endpoint does.
56-
const enriched = useMemo(() => collections.map((c) => ({
57-
...c,
58-
type: (c.type ?? "base") as "base" | "auth" | "view",
59-
fieldCount: parseFields(c.fields).length,
60-
records: null as number | null,
61-
writeRate: 0,
62-
lastWrite: null as string | null,
63-
})), [collections]);
86+
// Per-collection metadata. `stats` is loaded async via /admin/collections/stats —
87+
// until then `recordCount` is null and the cell renders "—".
88+
const enriched = useMemo(() => collections.map((c) => {
89+
const s = stats.get(c.name);
90+
const recordCount = s?.recordCount ?? null;
91+
const capped = s?.recordCountCapped ?? false;
92+
// ActivityBar rate is 0..1. Map recent writes against a soft ceiling so
93+
// a busy collection saturates the bar without surprises. Saturate at
94+
// ~3% of the window's seconds (≈2.5k writes / 24h fills).
95+
const ceiling = Math.max(1, Math.floor(statsWindowSec * 0.03));
96+
const writeRate = s?.recentWrites
97+
? Math.min(1, s.recentWrites / ceiling)
98+
: 0;
99+
const lastWrite = s?.lastUpdated ? formatRel(s.lastUpdated) : null;
100+
return {
101+
...c,
102+
type: (c.type ?? "base") as "base" | "auth" | "view",
103+
fieldCount: parseFields(c.fields).length,
104+
records: recordCount,
105+
recordsCapped: capped,
106+
writeRate,
107+
lastWrite,
108+
};
109+
}), [collections, stats, statsWindowSec]);
64110

65111
const counts = {
66112
all: enriched.length,
@@ -226,7 +272,11 @@ export default function Collections() {
226272
fontFamily: "var(--font-mono)",
227273
fontVariantNumeric: "tabular-nums",
228274
}}>
229-
{c.records == null ? "—" : c.records.toLocaleString()}
275+
{c.records == null
276+
? "—"
277+
: c.recordsCapped
278+
? `${c.records.toLocaleString()}+`
279+
: c.records.toLocaleString()}
230280
</span>
231281
<ActivityBar rate={c.writeRate} lastWrite={c.lastWrite} />
232282
<span style={{

admin/src/pages/Records.tsx

Lines changed: 18 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -866,18 +866,17 @@ export default function Records() {
866866
setSaving(true);
867867

868868
if (collection.type === "auth") {
869-
// Auth-user updates go through the admin users endpoint. Email + verified
870-
// are top-level columns; everything else is shoved into the `data` blob.
869+
// v0.11: auth users update via the records flow — same endpoint as
870+
// any other collection. Auth-system columns (password_hash etc.)
871+
// are stripped server-side from incoming patches; credential
872+
// changes go through dedicated /auth/<col>/* flows.
871873
const payload: Record<string, unknown> = {};
872-
const dataObj: Record<string, unknown> = {};
873874
for (const [k, v] of Object.entries(editData)) {
874-
if (k === "email") payload["email"] = v;
875-
else if (k === "verified") payload["verified"] = !!v;
876-
else dataObj[k] = v;
875+
if (k === "verified") payload["email_verified"] = v ? 1 : 0;
876+
else payload[k] = v;
877877
}
878-
if (Object.keys(dataObj).length > 0) payload["data"] = dataObj;
879878
const res = await api.patch<ApiResponse<RecordRow>>(
880-
`/api/v1/admin/users/${collection.name}/${String(openRec.id)}`,
879+
`/api/v1/${collection.name}/${encodeURIComponent(String(openRec.id))}`,
881880
payload
882881
);
883882
setSaving(false);
@@ -920,9 +919,8 @@ export default function Records() {
920919
danger: true,
921920
});
922921
if (!ok) return;
923-
const url = isAuth
924-
? `/api/v1/admin/users/${collection.name}/${id}`
925-
: `/api/v1/${collection.name}/${id}`;
922+
// v0.11: auth + base + view all use the records flow.
923+
const url = `/api/v1/${collection.name}/${encodeURIComponent(id)}`;
926924
await api.delete(url);
927925
toast(isAuth ? "User deleted" : "Record deleted", "trash");
928926
setOpenRec(null);
@@ -943,13 +941,8 @@ export default function Records() {
943941
const ids = selected.map((r) => String(r.id));
944942
let failed = 0;
945943

946-
if (isAuth) {
947-
// No batch API for auth users — sequential per-id deletes.
948-
for (const id of ids) {
949-
const res = await api.delete<ApiResponse<null>>(`/api/v1/admin/users/${collection.name}/${id}`);
950-
if (res.error) failed++;
951-
}
952-
} else {
944+
{
945+
// v0.11: every collection (incl. auth) uses the records flow.
953946
// Use the atomic batch API in chunks of 100 (server cap).
954947
const CHUNK = 100;
955948
for (let i = 0; i < ids.length; i += CHUNK) {
@@ -1065,9 +1058,13 @@ export default function Records() {
10651058
confirmLabel: "Disable MFA",
10661059
});
10671060
if (!ok) return;
1068-
const res = await api.patch<ApiResponse<RecordRow>>(
1069-
`/api/v1/admin/users/${collection.name}/${id}`,
1070-
{ mfa_enabled: false }
1061+
// v0.11: records flow. updateRecord on auth strips most auth-system
1062+
// columns from the patch; `totp_enabled` + `totp_secret` are
1063+
// explicitly write-protected. Use the dedicated /totp/disable
1064+
// server-side helper instead — exposed here as an internal call.
1065+
const res = await api.post<ApiResponse<{ disabled: boolean }>>(
1066+
`/api/v1/admin/users/${encodeURIComponent(collection.name)}/${encodeURIComponent(id)}/disable-mfa`,
1067+
{},
10711068
);
10721069
if (res.error) { toast(res.error, "info"); return; }
10731070
toast("MFA disabled");

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "vaultbase",
3-
"version": "0.11.0",
3+
"version": "0.11.1",
44
"type": "module",
55
"scripts": {
66
"dev": "bun --watch src/index.ts",

src/__tests__/auth-hooks.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* Auth-collection signup paths fire the same hook lifecycle as the
3+
* records flow:
4+
* - /register → beforeCreate + afterCreate
5+
* - /anonymous → afterCreate (no body to validate)
6+
* - oauth signup → afterCreate
7+
*
8+
* Pre-v0.11.1 the auth path bypassed core/records.ts and never hit
9+
* runBeforeHook/runAfterHook. This regression test pins the new
10+
* behaviour.
11+
*/
12+
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
13+
import { mkdtempSync, rmSync } from "fs";
14+
import { tmpdir } from "os";
15+
import { join } from "path";
16+
17+
import { initDb, closeDb, getDb } from "../db/client.ts";
18+
import { runMigrations } from "../db/migrate.ts";
19+
import { setLogsDir } from "../core/file-logger.ts";
20+
import { hooks as hooksTable } from "../db/schema.ts";
21+
import { invalidateHookCache } from "../core/hooks.ts";
22+
import { createCollection } from "../core/collections.ts";
23+
import { makeAuthPlugin } from "../api/auth.ts";
24+
25+
const SECRET = "test-secret-auth-hooks";
26+
let tmpDir: string;
27+
28+
beforeEach(async () => {
29+
tmpDir = mkdtempSync(join(tmpdir(), "vaultbase-auth-hooks-"));
30+
setLogsDir(tmpDir);
31+
initDb(":memory:");
32+
await runMigrations();
33+
});
34+
35+
afterEach(() => {
36+
invalidateHookCache();
37+
closeDb();
38+
try { rmSync(tmpDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); } catch { /* swallow */ }
39+
});
40+
41+
async function installHook(collection: string, event: string, code: string): Promise<void> {
42+
const now = Math.floor(Date.now() / 1000);
43+
await getDb().insert(hooksTable).values({
44+
id: crypto.randomUUID(),
45+
name: `${collection}-${event}`,
46+
collection_name: collection,
47+
event,
48+
code,
49+
enabled: 1,
50+
created_at: now,
51+
updated_at: now,
52+
});
53+
invalidateHookCache();
54+
}
55+
56+
/** Wait for the after-hook fire-and-forget microtask to settle. */
57+
async function flushAfterHook(): Promise<void> {
58+
await new Promise((r) => setTimeout(r, 50));
59+
}
60+
61+
describe("auth signup hook lifecycle", () => {
62+
it("/register fires beforeCreate + afterCreate", async () => {
63+
await createCollection({
64+
name: "users", type: "auth",
65+
fields: JSON.stringify([{ name: "marker", type: "text" }]),
66+
view_rule: null,
67+
});
68+
// Mark hooks via globalThis side channel — cheap way to assert
69+
// they ran without DOM/spies.
70+
(globalThis as Record<string, unknown>)["_beforeHits"] = 0;
71+
(globalThis as Record<string, unknown>)["_afterHits"] = 0;
72+
await installHook("users", "beforeCreate", `globalThis._beforeHits = (globalThis._beforeHits || 0) + 1;`);
73+
await installHook("users", "afterCreate", `globalThis._afterHits = (globalThis._afterHits || 0) + 1;`);
74+
75+
const app = makeAuthPlugin(SECRET);
76+
const res = await app.handle(new Request("http://localhost/auth/users/register", {
77+
method: "POST",
78+
headers: { "content-type": "application/json" },
79+
body: JSON.stringify({ email: "alice@x.com", password: "hunter2!!hunter2!!" }),
80+
}));
81+
expect(res.status).toBe(200);
82+
await flushAfterHook();
83+
expect((globalThis as Record<string, unknown>)["_beforeHits"]).toBe(1);
84+
expect((globalThis as Record<string, unknown>)["_afterHits"]).toBe(1);
85+
});
86+
87+
it("/anonymous fires afterCreate", async () => {
88+
await createCollection({
89+
name: "users", type: "auth",
90+
fields: JSON.stringify([]), view_rule: null,
91+
});
92+
// Anonymous auth is opt-in by default — enable for this test.
93+
const { settings } = await import("../db/schema.ts");
94+
await getDb().insert(settings).values({ key: "auth.anonymous.enabled", value: "1", updated_at: Math.floor(Date.now() / 1000) });
95+
96+
(globalThis as Record<string, unknown>)["_anonHits"] = 0;
97+
await installHook("users", "afterCreate", `globalThis._anonHits = (globalThis._anonHits || 0) + 1;`);
98+
99+
const app = makeAuthPlugin(SECRET);
100+
const res = await app.handle(new Request("http://localhost/auth/users/anonymous", { method: "POST" }));
101+
expect(res.status).toBe(200);
102+
await flushAfterHook();
103+
expect((globalThis as Record<string, unknown>)["_anonHits"]).toBe(1);
104+
});
105+
106+
it("global hooks (collection_name='') fire on auth signup too", async () => {
107+
await createCollection({
108+
name: "users", type: "auth",
109+
fields: JSON.stringify([]), view_rule: null,
110+
});
111+
(globalThis as Record<string, unknown>)["_globalHits"] = 0;
112+
await installHook("", "afterCreate", `globalThis._globalHits = (globalThis._globalHits || 0) + 1;`);
113+
114+
const app = makeAuthPlugin(SECRET);
115+
await app.handle(new Request("http://localhost/auth/users/register", {
116+
method: "POST",
117+
headers: { "content-type": "application/json" },
118+
body: JSON.stringify({ email: "bob@x.com", password: "hunter2!!hunter2!!" }),
119+
}));
120+
await flushAfterHook();
121+
expect((globalThis as Record<string, unknown>)["_globalHits"]).toBe(1);
122+
});
123+
});

0 commit comments

Comments
 (0)