Skip to content
10 changes: 5 additions & 5 deletions docs/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,11 @@ Do these in order. Each task is a thin end-to-end slice: test first, implement,
- [x] Tests: auth cases; deleting twice → second call 404

## 7. Expiry
- [ ] `X-Bin-TTL` handling and the default/max rules exactly as in api.md → Expiry rules
- [ ] An expired Bin returns 404 on GET/PUT/DELETE, even before the sweep runs
- [ ] `scheduled` handler: loops `deleteExpired(now, 500)` until nothing is left or about 20 s have passed
- [ ] `X-Bin-Expires-At` is sent when the Bin has an Expiry
- [ ] Tests: each TTL rule, invalid TTLs, a lazy 404 with a faked clock, the sweep deleting only expired rows
- [x] `X-Bin-TTL` handling and the default/max rules exactly as in api.md → Expiry rules
- [x] An expired Bin returns 404 on GET/PUT/DELETE, even before the sweep runs
- [x] `scheduled` handler: loops `deleteExpired(now, 500)` until nothing is left or about 20 s have passed
- [x] `X-Bin-Expires-At` is sent when the Bin has an Expiry
- [x] Tests: each TTL rule, invalid TTLs, a lazy 404 with a faked clock, the sweep deleting only expired rows

## 8. Rate limiting
- [ ] Middleware applies `CREATE_LIMITER` to POST and `WRITE_LIMITER` to PUT/DELETE, keyed on `CF-Connecting-IP`
Expand Down
4 changes: 2 additions & 2 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ Browsers can read all of these via `Access-Control-Expose-Headers`, along with `

## Validation and errors

Check in this order: route/id → auth (non-create routes) → content type → size → JSON parse → top-level type.
Check in this order: route/id → auth (non-create routes) → `X-Bin-TTL` (create only) → content type → size → JSON parse → top-level type. `X-Bin-TTL` is a header, so a bad one is rejected before any of the body is read.

| Status | `code` | When |
|---|---|---|
Expand All @@ -104,7 +104,7 @@ Check in this order: route/id → auth (non-create routes) → content type →

Instance variables: `DEFAULT_TTL_SECONDS` and `MAX_TTL_SECONDS`. `0` or unset means no default or no maximum.

1. If `X-Bin-TTL` is sent and is `≤ MAX_TTL_SECONDS` (or there is no maximum), Expiry = `now + TTL`. If it's above the maximum → `400 invalid_ttl`.
1. If `X-Bin-TTL` is sent and is `≤ MAX_TTL_SECONDS` (or there is no maximum), Expiry = `now + TTL`. If it's above the maximum → `400 invalid_ttl`. The value must be ASCII digits only (surrounding whitespace ignored): no sign, decimal point or exponent, not `0`, and not empty. A TTL too large to be an exact integer, or one that puts the Expiry past the latest representable timestamp, is also `400 invalid_ttl`.
2. With no header: if a default is set, use it; otherwise, if a maximum is set, use the maximum; otherwise the Bin never expires.
3. Requests check whether the Bin has expired (`expires_at <= now` → `404`), so a Bin is never served after its Expiry even before it is deleted.
4. An hourly Cron Trigger (`0 * * * *`) deletes expired rows in batches (`LIMIT 500` per statement, looping until none are left or the time budget runs out).
Expand Down
63 changes: 63 additions & 0 deletions src/bins/expiry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { Config } from "../config";
import { ApiError } from "../errors";
import { deleteExpired } from "./repository";

/** The latest instant a JS Date can hold (unix ms); an Expiry past it couldn't be sent as an ISO timestamp. */
const MAX_DATE_MS = 8_640_000_000_000_000;

/**
* The Expiry (unix ms) of a Bin created at `now`, or null when it never expires, following docs/api.md → Expiry
* rules. `header` is the `X-Bin-TTL` request header, or null/undefined when it wasn't sent.
*
* - Header sent: it must be a positive integer of seconds (ASCII digits only, surrounding whitespace ignored) and
* not above the Instance's maximum, else `400 invalid_ttl`. An empty value was still sent, so it is invalid too.
* A TTL so large that it isn't a safe integer, or that puts the Expiry past the largest Date, is also invalid.
* - No header: the Instance's default TTL if set, otherwise its maximum if set, otherwise no Expiry.
*/
export function resolveExpiresAt(header: string | null | undefined, config: Config, now: number): number | null {
if (header === null || header === undefined) {
const ttlSeconds = config.defaultTtlSeconds || config.maxTtlSeconds;
return ttlSeconds > 0 ? now + ttlSeconds * 1000 : null;
}

const value = header.trim();
const ttlSeconds = /^[0-9]+$/.test(value) ? Number(value) : Number.NaN;
if (!Number.isSafeInteger(ttlSeconds) || ttlSeconds <= 0) {
throw new ApiError(400, "invalid_ttl", "X-Bin-TTL must be a positive integer of seconds");
}
if (config.maxTtlSeconds > 0 && ttlSeconds > config.maxTtlSeconds) {
throw new ApiError(400, "invalid_ttl", `X-Bin-TTL must be at most ${config.maxTtlSeconds} seconds`);
}

const expiresAt = now + ttlSeconds * 1000;
if (expiresAt > MAX_DATE_MS) throw new ApiError(400, "invalid_ttl", "X-Bin-TTL is too large");
return expiresAt;
}

export type SweepOptions = {
/** Bins deleted per statement. */
batchSize?: number;
/** Once this many ms have passed since the sweep started, no further batch is started. */
budgetMs?: number;
/** The clock the budget is measured with (unix ms). */
clock?: () => number;
};

/**
* Deletes Bins whose Expiry is at or before `now`, `batchSize` at a time, until a batch comes back short (nothing
* expired is left) or the time budget has run out; what is left goes to the next run. At least one batch always
* runs. Returns how many Bins were deleted.
*/
export async function sweepExpired(
db: D1Database,
now: number,
{ batchSize = 500, budgetMs = 20_000, clock = Date.now }: SweepOptions = {},
): Promise<number> {
const startedAt = clock();
let total = 0;
for (;;) {
const deleted = await deleteExpired(db, now, batchSize);
total += deleted;
if (deleted < batchSize || clock() - startedAt >= budgetMs) return total;
}
}
6 changes: 3 additions & 3 deletions src/bins/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,9 @@ export async function replaceContent(db: D1Database, id: string, content: string
return result.meta.changes > 0;
}

/** Deletes a Bin. Returns false if no Bin has this id. */
export async function remove(db: D1Database, id: string): Promise<boolean> {
const result = await db.prepare("DELETE FROM bins WHERE id = ?").bind(id).run();
/** Deletes a live Bin. Returns false if no live Bin has this id (an expired one is left for the sweep). */
export async function remove(db: D1Database, id: string, now: number): Promise<boolean> {
const result = await db.prepare(`DELETE FROM bins WHERE id = ? AND ${LIVE}`).bind(id, now).run();
return result.meta.changes > 0;
}

Expand Down
13 changes: 8 additions & 5 deletions src/bins/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { type AppEnv, withConfig } from "../context";
import { ApiError } from "../errors";
import { requireKey } from "./auth";
import { parseBinBody } from "./body";
import { resolveExpiresAt } from "./expiry";
import { generateBinId, generateReadKey, generateWriteKey, hashKey, isValidBinId } from "./keys";
import { binMetadataHeaders, toIso } from "./metadata";
import { type Bin, findLive, insert, remove, replaceContent } from "./repository";
Expand All @@ -12,9 +13,12 @@ export const binRoutes = new OpenAPIHono<AppEnv>();
binRoutes.use(withConfig);

binRoutes.post("/", async (c) => {
const now = Date.now();
// X-Bin-TTL is checked before the body (docs/api.md → Validation and errors): it is a header, so a bad one is
// rejected without reading the body, and parseBinBody stays the same for PUT, which ignores X-Bin-TTL.
const expiresAt = resolveExpiresAt(c.req.header("X-Bin-TTL"), c.var.config, now);
const content = await parseBinBody(c.req.raw, c.var.config);

const now = Date.now();
const writeKey = generateWriteKey();
const isPrivate = isPrivateHeader(c.req.header("X-Bin-Private"));
const readKey = isPrivate ? generateReadKey() : null;
Expand All @@ -24,10 +28,9 @@ binRoutes.post("/", async (c) => {
isPrivate,
writeKeyHash: await hashKey(writeKey),
readKeyHash: readKey === null ? null : await hashKey(readKey),
// Expiry (TODO §7) is not implemented yet.
createdAt: now,
updatedAt: now,
expiresAt: null,
expiresAt,
};
await insert(c.env.DB, bin);

Expand Down Expand Up @@ -85,8 +88,8 @@ binRoutes.delete("/:id", async (c) => {
const bin = await findLiveBin(c.env.DB, c.req.param("id"), Date.now());
await requireKey(c, bin, "write");

// Another request can delete the Bin between the lookup and this call; that is a 404 like any other.
if (!(await remove(c.env.DB, bin.id))) throw new ApiError(404, "not_found", "Bin not found");
// The Bin can be deleted by another request or expire between the lookup and this call; that is a 404 like any other.
if (!(await remove(c.env.DB, bin.id, Date.now()))) throw new ApiError(404, "not_found", "Bin not found");

return c.body(null, 204);
});
Expand Down
8 changes: 6 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { sweepExpired } from "./bins/expiry";
import { binRoutes } from "./bins/routes";
import type { AppEnv } from "./context";
import { ApiError, errorResponse } from "./errors";
Expand Down Expand Up @@ -26,6 +27,9 @@ app.onError((err, c) => {

export default {
fetch: app.fetch,
// The Expiry sweep is added in TODO §7.
async scheduled(_controller, _env, _ctx) {},
// The hourly Cron Trigger: deletes expired Bins (docs/api.md → Expiry rules). Requests already treat them as gone.
async scheduled(_controller, env, _ctx) {
const deleted = await sweepExpired(env.DB, Date.now());
console.log(`Expiry sweep deleted ${deleted} Bins`);
},
} satisfies ExportedHandler<Env>;
99 changes: 99 additions & 0 deletions test/expiry-rules.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import { resolveExpiresAt } from "../src/bins/expiry";
import { type Config, parseConfig } from "../src/config";
import { ApiError } from "../src/errors";

const NOW = 1_791_547_200_000; // 2026-10-09T12:00:00.000Z
const DAY = 86_400;

function config(ttl: { defaultTtlSeconds?: number; maxTtlSeconds?: number } = {}): Config {
return { ...parseConfig({}), defaultTtlSeconds: 0, maxTtlSeconds: 0, ...ttl };
}

function expectInvalidTtl(run: () => unknown) {
try {
run();
} catch (err) {
expect(err).toBeInstanceOf(ApiError);
expect((err as ApiError).status).toBe(400);
expect((err as ApiError).code).toBe("invalid_ttl");
return;
}
expect.unreachable("expected invalid_ttl");
}

describe("resolveExpiresAt", () => {
describe("with X-Bin-TTL", () => {
it("sets the Expiry to now + TTL when there is no maximum", () => {
expect(resolveExpiresAt("60", config(), NOW)).toBe(NOW + 60_000);
expect(resolveExpiresAt("1", config(), NOW)).toBe(NOW + 1000);
expect(resolveExpiresAt(String(365 * DAY), config(), NOW)).toBe(NOW + 365 * DAY * 1000);
});

it("sets the Expiry to now + TTL when TTL is below the maximum", () => {
expect(resolveExpiresAt("3600", config({ maxTtlSeconds: DAY }), NOW)).toBe(NOW + 3_600_000);
});

it("accepts a TTL equal to the maximum", () => {
expect(resolveExpiresAt(String(DAY), config({ maxTtlSeconds: DAY }), NOW)).toBe(NOW + DAY * 1000);
});

it("rejects a TTL above the maximum", () => {
expectInvalidTtl(() => resolveExpiresAt(String(DAY + 1), config({ maxTtlSeconds: DAY }), NOW));
});

it("ignores the default when the header is sent", () => {
expect(resolveExpiresAt("60", config({ defaultTtlSeconds: DAY, maxTtlSeconds: 30 * DAY }), NOW)).toBe(
NOW + 60_000,
);
});

it("ignores surrounding whitespace and leading zeros", () => {
expect(resolveExpiresAt(" 60\t", config(), NOW)).toBe(NOW + 60_000);
expect(resolveExpiresAt("007", config(), NOW)).toBe(NOW + 7000);
});

it.each([
["zero", "0"],
["zeros", "000"],
["negative", "-1"],
["a plus sign", "+5"],
["a decimal", "1.5"],
["a trailing dot", "5."],
["an exponent", "1e3"],
["hex", "0x10"],
["not a number", "abc"],
["empty", ""],
["only whitespace", " "],
["two numbers", "1 2"],
["a unit", "60s"],
["non-ASCII digits", "٦٠"],
["beyond a safe integer", "9007199254740993"],
["huge", "9".repeat(400)],
["so large the Expiry isn't a valid date", String(Number.MAX_SAFE_INTEGER)],
])("rejects %s (%j)", (_name, header) => {
expectInvalidTtl(() => resolveExpiresAt(header, config(), NOW));
});
});

describe("without X-Bin-TTL", () => {
it("uses the default when one is set", () => {
expect(resolveExpiresAt(undefined, config({ defaultTtlSeconds: DAY }), NOW)).toBe(NOW + DAY * 1000);
});

it("uses the default, not the maximum, when both are set", () => {
expect(resolveExpiresAt(undefined, config({ defaultTtlSeconds: DAY, maxTtlSeconds: 30 * DAY }), NOW)).toBe(
NOW + DAY * 1000,
);
});

it("uses the maximum when only a maximum is set", () => {
expect(resolveExpiresAt(undefined, config({ maxTtlSeconds: 30 * DAY }), NOW)).toBe(NOW + 30 * DAY * 1000);
});

it("never expires when there is neither a default nor a maximum", () => {
expect(resolveExpiresAt(undefined, config(), NOW)).toBeNull();
expect(resolveExpiresAt(null, config(), NOW)).toBeNull();
});
});
});
Loading
Loading