Skip to content
Draft
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ coverage
tmp
temp

# Local Cloudflare / wrangler state (never commit)
.wrangler/

# Vercel
.vercel

Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
13 changes: 12 additions & 1 deletion apps/web/src/routes/$slug.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,23 @@ import { SiteViewer } from "@/components/site-viewer";
import { Mark } from "@/components/wordmark";

export const Route = createFileRoute("/$slug")({
validateSearch: (search: Record<string, unknown>) => ({
t: typeof search.t === "string" ? search.t : "",
}),
component: ViewerPage,
});

function ViewerPage() {
const { slug } = Route.useParams();
const site = useQuery(api.sites.getBySlug, { slug });
const { t: token } = Route.useSearch();
// Edit-token holders may open a private page in the browser, matching what
// the skill promises. Strangers without a token still get a not-found page.
const siteByToken = useQuery(
api.sites.getBySlugForManager,
token ? { slug, editToken: token } : "skip",
);
const sitePublic = useQuery(api.sites.getBySlug, token ? "skip" : { slug });
const site = token ? siteByToken : sitePublic;

// A published page belongs to whoever published it, so there is no app chrome
// here at all: no nav, no spinner flash, just the document and a small badge.
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/routes/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ function MySites() {
<Link
to="/$slug"
params={{ slug: s.slug }}
search={{ t: "" }}
className={buttonVariants({ variant: "outline", size: "sm" })}
>
Open
Expand Down
9 changes: 8 additions & 1 deletion apps/web/src/routes/manage.$slug.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,14 @@ function ManagePage() {
const { t: token } = Route.useSearch();
const navigate = useNavigate();
const { isAuthenticated } = useConvexAuth();
const site = useQuery(api.sites.getBySlug, { slug });
// Prefer the edit-token query when `?t=` is present so private pages stay
// manageable without a session — that is what manageUrl from deploy returns.
const siteByToken = useQuery(
api.sites.getBySlugForManager,
token ? { slug, editToken: token } : "skip",
);
const siteBySession = useQuery(api.sites.getBySlug, token ? "skip" : { slug });
const site = token ? siteByToken : siteBySession;
const claim = useMutation(api.sites.claim);
const setVisibility = useMutation(api.sites.setVisibility);
const [busy, setBusy] = useState(false);
Expand Down
Empty file removed nonexistent
Empty file.
4 changes: 3 additions & 1 deletion packages/backend/convex/crons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { internal } from "./_generated/api";
const crons = cronJobs();

// Daily sweep that deletes expired images (7d) and expired sites (30d anon /
// 90d claimed), including their R2 objects and timeline history.
// 90d claimed), including their R2 objects and timeline history. The mutation
// self-schedules while a batch is saturated so a backlog does not wait another
// day; failed R2 deletes are retried without dropping ledger keys.
crons.daily(
"cleanup expired sites and images",
{ hourUTC: 8, minuteUTC: 17 },
Expand Down
1 change: 1 addition & 0 deletions packages/backend/convex/healthCheck.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { query } from "./_generated/server";

export const get = query({
args: {},
handler: async () => {
return "OK";
},
Expand Down
89 changes: 69 additions & 20 deletions packages/backend/convex/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,20 @@ function versionKey(slug: string, kind: "markdown" | "html"): string {
return `sites/${slug}/${crypto.randomUUID()}.${kind === "html" ? "html" : "md"}`;
}

/** Best-effort rollback when metadata write fails after an R2 store. */
async function discardR2Key(ctx: ActionCtx, key: string): Promise<void> {
try {
await r2.deleteObject(ctx, key);
} catch {
// Leave the orphan rather than fail the caller twice; cleanup cannot see
// keys that were never ledgered, so this is the only chance we get.
}
}

function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}

async function uniqueSlug(ctx: ActionCtx): Promise<string> {
// Every site gets an unguessable random UUID. Collisions are astronomically
// unlikely, but the existence check is cheap insurance.
Expand Down Expand Up @@ -280,8 +294,12 @@ async function opDeploy(ctx: ActionCtx, rateKey: string, body: unknown, caller:
now,
expiresAt,
});
} catch {
return { error: "Slug just became unavailable — please retry.", status: 409 };
} catch (err) {
await discardR2Key(ctx, key);
if (errorMessage(err).includes("slug-taken")) {
return { error: "Slug just became unavailable — please retry.", status: 409 };
}
return { error: "Failed to record the new site. Please retry.", status: 500 };
}

const app = appBase();
Expand Down Expand Up @@ -313,15 +331,21 @@ async function opUpdate(ctx: ActionCtx, slug: string, token: string | null, body

const key = versionKey(slug, parsed.kind);
await r2.store(ctx, parsed.bytes, { key, type: parsed.contentType, cacheControl: VERSION_CACHE });
await ctx.runMutation(internal.sites.recordUpdate, {
slug,
kind: parsed.kind,
title: parsed.title,
key,
contentType: parsed.contentType,
byteSize: parsed.bytes.byteLength,
visibility: parsed.visibility,
});
try {
await ctx.runMutation(internal.sites.recordUpdate, {
slug,
kind: parsed.kind,
title: parsed.title,
key,
contentType: parsed.contentType,
byteSize: parsed.bytes.byteLength,
visibility: parsed.visibility,
});
} catch (err) {
await discardR2Key(ctx, key);
if (errorMessage(err).includes("not-found")) return GONE;
return { error: "Failed to record the update. Please retry.", status: 500 };
}
return { ok: true, ...(await publicStatus(ctx, slug)) };
}

Expand Down Expand Up @@ -526,15 +550,22 @@ const sitesPost = httpAction(async (ctx, request) => {
cacheControl: "public, max-age=604800, immutable",
});
const now = Date.now();
const { imageId } = await ctx.runMutation(internal.sites.recordImage, {
siteId: gate.auth.siteId,
slug,
key,
contentType,
byteSize: bytes.byteLength,
now,
expiresAt: now + RETENTION.imageMs,
});
let imageId: Id<"siteImages">;
try {
const recorded = await ctx.runMutation(internal.sites.recordImage, {
siteId: gate.auth.siteId,
slug,
key,
contentType,
byteSize: bytes.byteLength,
now,
expiresAt: now + RETENTION.imageMs,
});
imageId = recorded.imageId;
} catch {
await discardR2Key(ctx, key);
return fail(500, "Failed to record the image. Please retry.");
}
return json(
{
ok: true,
Expand Down Expand Up @@ -640,6 +671,15 @@ const authorizationServerMetadata = httpAction(async () =>

/** RFC 7591 dynamic client registration. Public clients, PKCE required. */
const oauthRegister = httpAction(async (ctx, request) => {
const limit = await rateLimiter.limit(ctx, "oauthRegister", { key: clientIp(request) });
if (!limit.ok) {
return json(
{ error: "temporarily_unavailable", error_description: "Rate limit exceeded." },
429,
retryHeader(limit.retryAfter),
);
}

let body: Record<string, unknown>;
try {
body = (await request.json()) as Record<string, unknown>;
Expand Down Expand Up @@ -686,6 +726,15 @@ function formValue(form: URLSearchParams, key: string): string {
}

const oauthToken = httpAction(async (ctx, request) => {
const limit = await rateLimiter.limit(ctx, "oauthToken", { key: clientIp(request) });
if (!limit.ok) {
return json(
{ error: "temporarily_unavailable", error_description: "Rate limit exceeded." },
429,
retryHeader(limit.retryAfter),
);
}

const form = new URLSearchParams(await request.text());
const grantType = formValue(form, "grant_type");
const clientId = formValue(form, "client_id");
Expand Down
31 changes: 20 additions & 11 deletions packages/backend/convex/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import { v } from "convex/values";

import { internal } from "./_generated/api";
import { internalMutation, internalQuery, mutation, query } from "./_generated/server";
import { authComponent } from "./auth";
import { generateEditToken, sha256Hex } from "./lib";
Expand Down Expand Up @@ -221,27 +222,35 @@ export const consumeRefreshToken = internalMutation({
},
});

/** Drop expired codes and tokens. Called by the daily cron. */
/** Drop expired codes and tokens. Called by the daily cron; self-schedules
* while a batch is full so a busy day cannot leave a permanent backlog. */
export const cleanupExpiredGrants = internalMutation({
args: {},
handler: async (ctx) => {
const now = Date.now();
const TOKEN_BATCH = 200;
const CODE_BATCH = 200;
let saturated = false;

const tokens = await ctx.db
.query("oauthTokens")
.withIndex("by_expiresAt", (q) => q.lt("expiresAt", now))
.take(200);
.take(TOKEN_BATCH);
for (const row of tokens) await ctx.db.delete(row._id);
if (tokens.length === TOKEN_BATCH) saturated = true;

// Codes live five minutes, so a small sweep is always enough.
const codes = await ctx.db.query("oauthCodes").take(100);
let removed = 0;
for (const row of codes) {
if (row.expiresAt <= now) {
await ctx.db.delete(row._id);
removed++;
}
const codes = await ctx.db
.query("oauthCodes")
.withIndex("by_expiresAt", (q) => q.lt("expiresAt", now))
.take(CODE_BATCH);
for (const row of codes) await ctx.db.delete(row._id);
if (codes.length === CODE_BATCH) saturated = true;

if (saturated) {
await ctx.scheduler.runAfter(0, internal.oauth.cleanupExpiredGrants, {});
}
return { tokens: tokens.length, codes: removed };

return { tokens: tokens.length, codes: codes.length, saturated };
},
});

Expand Down
6 changes: 5 additions & 1 deletion packages/backend/convex/rateLimiter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DAY, HOUR, RateLimiter } from "@convex-dev/rate-limiter";
import { DAY, HOUR, MINUTE, RateLimiter } from "@convex-dev/rate-limiter";

import { components } from "./_generated/api";

Expand All @@ -15,4 +15,8 @@ export const rateLimiter = new RateLimiter(components.rateLimiter, {
createSiteAuthed: { kind: "token bucket", rate: 1000, period: DAY, capacity: 100 },
updateSite: { kind: "token bucket", rate: 120, period: HOUR, capacity: 20 },
uploadImage: { kind: "token bucket", rate: 40, period: DAY, capacity: 10 },
// Dynamic client registration is unauthenticated; keep the oauthClients table
// from growing without bound under a spam loop.
oauthRegister: { kind: "token bucket", rate: 20, period: HOUR, capacity: 5 },
oauthToken: { kind: "token bucket", rate: 60, period: MINUTE, capacity: 20 },
});
7 changes: 5 additions & 2 deletions packages/backend/convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ export default defineSchema({
})
.index("by_slug", ["slug"])
.index("by_expiresAt", ["expiresAt"])
.index("by_owner", ["ownerSubject"]),
.index("by_owner", ["ownerSubject"])
.index("by_owner_updated", ["ownerSubject", "updatedAt"]),

// Long-lived keys that let an agent act as a signed-in account over the HTTP
// API. Only the SHA-256 hash is stored; the raw key is shown once at creation.
Expand Down Expand Up @@ -88,7 +89,9 @@ export default defineSchema({
anonymous: v.boolean(),
resource: v.optional(v.string()),
expiresAt: v.number(),
}).index("by_hash", ["codeHash"]),
})
.index("by_hash", ["codeHash"])
.index("by_expiresAt", ["expiresAt"]),

// Issued tokens, stored only as hashes. `audience` is checked on every call so
// a token minted for something else cannot be replayed here.
Expand Down
Loading