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
7 changes: 7 additions & 0 deletions .changeset/admin-api-round-trips.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@cloudhal/api": patch
"@cloudhal/cloudflare": patch
"@cloudhal/cli": patch
---

Fewer D1 round trips per admin API request. The session is cached in a signed cookie for 60 seconds, so a session revoked elsewhere can stay usable that long (ADR-0010 addendum); membership and roles still apply at once. A member and their site roles load in one query. `get-session` is no longer rate limited, since the limiter's D1 read and write landed on every session check. Due comment purges are swept at most every 10 seconds per isolate instead of on every request.
10 changes: 10 additions & 0 deletions docs/adr/0010-auth-better-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,13 @@ Adopt **Better Auth 1.7.x** (MIT; Vercel-owned since 2026-07 with an open-source
- Positive: one library covers Studio login, tenants, API keys and the MCP authorization server on both runtimes.
- Negative: 1.5→1.7 churn (adapter packages split, OIDC plugin removed, tables renamed) and a wide advisory history → pin to 1.7.x, subscribe to GHSAs, keep telemetry off.
- Risks: scrypt cost on Workers free plan (override hashing or document paid plan); Vercel governance (mitigated by MIT + data in our DB).

## Addendum (2026-10-22): session cookie cache
Every signed-in API request reads the session, and on D1 that was a round trip before the handler could start. Studio fires 10–13 calls per screen, and D1 serves one query at a time per database, so these lookups queued behind each other (internal performance review, 2026-10).

- **Session cookie cache.** Better Auth's `session.cookieCache` is on with `maxAge: 60`: the session is signed into a cookie and read from it for 60 seconds. The trade-off, accepted by the maintainer:
- a session revoked from another device, or by an administrator, stays usable for up to 60 seconds;
- a person's own sign-out clears the cookie at once;
- Better Auth's sensitive endpoints (password change, revoking sessions) do not use the cache;
- membership and roles are still read from D1 on every request, so removing a member or changing a role takes effect immediately.
- **Rate limiter.** It stays on for every auth endpoint except `/get-session`, which is not a brute-force target and which Studio asks for often. Its storage is D1, so each limited call costs a read and a write.
12 changes: 11 additions & 1 deletion packages/api/src/auth/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ export function createInstance(options: AuthOptions, staff: StaffStore): Auth {
autoSignIn: true,
},
telemetry: { enabled: false },
// Every signed-in API request reads the session; signed into a cookie for 60 s, it costs no
// D1 round trip. A revoked session or a sign-out on another device takes effect within 60 s
// (the person's own sign-out clears the cookie at once).
session: { cookieCache: { enabled: true, maxAge: 60 } },
hooks: {
// Checked before the endpoint runs (outside Better Auth's write transaction, which would
// deadlock a second query on single-connection SQLite).
Expand Down Expand Up @@ -109,7 +113,13 @@ export function createInstance(options: AuthOptions, staff: StaffStore): Auth {
},
...(options.workers
? {
rateLimit: { enabled: true, storage: "database" as const },
// Stored in D1, so each limited call costs a read and a write. Reading the session is
// not a brute-force target and Studio asks for it often: it is not limited.
rateLimit: {
enabled: true,
storage: "database" as const,
customRules: { "/get-session": false as const },
},
advanced: {
ipAddress: { ipAddressHeaders: ["cf-connecting-ip"] },
...(options.workers.waitUntil
Expand Down
12 changes: 2 additions & 10 deletions packages/api/src/site-members.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,6 @@ export function parseSiteRoles(value: unknown): SiteRoles | undefined {
}
}

/** One member's assignments (undefined: every site). */
export async function siteRolesOf(sql: SqlDriver, userId: string): Promise<SiteRoles | undefined> {
const rows = await sql.all<{ site_id: string; role: string }>(
"SELECT site_id, role FROM site_members WHERE user_id = ? ORDER BY site_id",
[userId],
);
return toRoles(rows);
}

/** Every member's assignments, by user id. */
export async function allSiteRoles(sql: SqlDriver): Promise<Map<string, SiteRoles>> {
const rows = await sql.all<{ user_id: string; site_id: string; role: string }>(
Expand All @@ -52,7 +43,8 @@ export async function allSiteRoles(sql: SqlDriver): Promise<Map<string, SiteRole
return out;
}

function toRoles(rows: readonly { site_id: string; role: string }[]): SiteRoles | undefined {
/** Assignment rows → the map (undefined for none). */
export function toRoles(rows: readonly { site_id: string; role: string }[]): SiteRoles | undefined {
const out: Record<string, Role> = {};
for (const r of rows) if (isRole(r.role) && isSiteRole(r.role)) out[r.site_id] = r.role;
return Object.keys(out).length > 0 ? out : undefined;
Expand Down
18 changes: 13 additions & 5 deletions packages/api/src/staff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
parseSiteRoles,
replaceSiteRoles,
type SiteRoles,
siteRolesOf,
toRoles,
} from "./site-members.ts";

/** The one organization of a deployment (Cloud: one per tenant). */
Expand Down Expand Up @@ -99,14 +99,22 @@ export class StaffStore {

// ───────────────────────────── members ─────────────────────────────

/**
* One query with the member's site assignments (it runs on every signed-in API request, and a
* second round trip to D1 there slowed every screen).
*/
async member(userId: string): Promise<MemberRow | undefined> {
const row = await this.#sql.get<Row>(
`SELECT m."userId", m."role", m."createdAt", u."name", u."email" FROM "member" m JOIN "user" u ON u."id" = m."userId" WHERE m."organizationId" = ? AND m."userId" = ?`,
const rows = await this.#sql.all<Row>(
`SELECT m."userId", m."role", m."createdAt", u."name", u."email", s.site_id AS "siteId", s.role AS "siteRole" FROM "member" m JOIN "user" u ON u."id" = m."userId" LEFT JOIN site_members s ON s.user_id = m."userId" WHERE m."organizationId" = ? AND m."userId" = ? ORDER BY s.site_id`,
[DEPLOYMENT_ORG, userId],
);
const member = row ? toMember(row) : undefined;
const member = rows[0] ? toMember(rows[0]) : undefined;
if (!member) return undefined;
const sites = await siteRolesOf(this.#sql, userId);
const sites = toRoles(
rows
.filter((r) => typeof r.siteId === "string")
.map((r) => ({ site_id: r.siteId as string, role: String(r.siteRole) })),
);
return sites ? { ...member, sites } : member;
}

Expand Down
20 changes: 18 additions & 2 deletions packages/cloudflare/src/admin-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,22 @@ function required(env: AdminEnv, key: keyof AdminEnv): string {
return value;
}

/** How often one isolate sweeps due comment purges on an admin request. */
const COMMENTS_SWEEP_MS = 10_000;
/** When this isolate last swept them; a plain number (ADR-0012: no objects between requests). */
let commentsSweptAt = 0;

/**
* Whether this request sweeps due comment purges. Each sweep reads D1, which serves one query at
* a time, so sweeping on every request slowed the screen's own calls; moderation still wakes its
* own flush.
*/
function commentsSweepDue(now = Date.now()): boolean {
if (now - commentsSweptAt <= COMMENTS_SWEEP_MS) return false;
commentsSweptAt = now;
return true;
}

export function createAdminWorker(options: AdminWorkerOptions = {}) {
const skills: LoadedSkill[] = (options.skills ?? []).map((s) => skillFromSource(s));
// Checked once per isolate; `cloudhal deploy` refused invalid types before uploading.
Expand Down Expand Up @@ -357,8 +373,8 @@ export function createAdminWorker(options: AdminWorkerOptions = {}) {
}
: {}),
});
// Every admin request also flushes what is due (Studio polls every 15–60 s).
if (comments) ctx.waitUntil(flush());
// Every admin request also flushes what is due (Studio polls every 15–60 s), throttled.
if (comments && commentsSweepDue()) ctx.waitUntil(flush());
const store: SiteGraphStore = new SiteGraphStore({
sql,
blobs,
Expand Down
Loading