From 2d82563924e9788ac1508cde2a1b3724403af625 Mon Sep 17 00:00:00 2001 From: Uzair Aftab Date: Mon, 27 Apr 2026 13:55:39 +0200 Subject: [PATCH 1/2] feat(cache): allow non-expiring api tokens Tokens can now be created with no expiry by passing expiresInDays: null to the admin create endpoint, or --expires never on the CLI. The JWT exp claim becomes optional and the api_tokens.expires_at column is nullable. Previous behavior required a finite lifetime between 1 and 365 days (default 90). Long-running automation had to rotate tokens periodically. The new flow lets operators opt in to forever-tokens explicitly while keeping the safe default: omitting expiresInDays still yields the 90-day token, only an explicit null disables expiry. verifyJwt skips the exp comparison when the claim is absent and rejects malformed exp values otherwise. deleteExpiredApiTokens leaves rows with expires_at IS NULL untouched because SQL NULL comparisons are unknown. Added admin-tokens.spec.ts covering the admin endpoint, JWT round-trip, expiry cleanup, and a 67-year time-warp via vi.setSystemTime to confirm the never-expiring path. --- apps/cli/src/api.ts | 7 +- apps/cli/src/main.ts | 23 +- workers/cache/src/admin.ts | 17 +- workers/cache/src/db/repository.ts | 3 +- workers/cache/src/db/schema.ts | 3 +- workers/cache/src/jwt.ts | 14 +- workers/cache/test/admin-tokens.spec.ts | 265 ++++++++++++++++++++++++ 7 files changed, 304 insertions(+), 28 deletions(-) create mode 100644 workers/cache/test/admin-tokens.spec.ts diff --git a/apps/cli/src/api.ts b/apps/cli/src/api.ts index c88c3f0..6c9ca0b 100644 --- a/apps/cli/src/api.ts +++ b/apps/cli/src/api.ts @@ -211,7 +211,7 @@ export interface TokenInfo { readonly caches: string[]; readonly perms: string[]; readonly createdAt: string; - readonly expiresAt: string; + readonly expiresAt: string | null; readonly createdBy: string; readonly revokedAt: string | null; readonly revokedBy: string | null; @@ -224,7 +224,7 @@ export interface CreateTokenResponse { readonly subject: string; readonly caches: string[]; readonly perms: string[]; - readonly expiresAt: string; + readonly expiresAt: string | null; } export async function createToken( @@ -233,7 +233,8 @@ export async function createToken( subject: string; caches: string[]; perms: string[]; - expiresInDays?: number; + // `null` requests a non-expiring token. + expiresInDays?: number | null; }, ): Promise { const resp = await request(client, "POST", "/_api/v1/admin/tokens", params); diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index f596de4..96f07c9 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -23,13 +23,14 @@ Options: Token create options: --caches Comma-separated cache names or "*" (default: *) --perms Comma-separated permissions (default: push) - --expires Token lifetime in days (default: 90) + --expires Token lifetime in days, or "never" for no expiry (default: 90) Examples: helios login prod https://cache.example.com my-admin-secret helios push main /nix/store/abc...-hello helios push main --closure /run/current-system helios token create ci-runner --caches main --perms push --expires 90 + helios token create bot --caches main --perms push --expires never helios token list helios token revoke a1b2c3d4-... "employee offboarded" `; @@ -155,13 +156,19 @@ async function main(): Promise { permsStr = args[permsIdx + 1]; } - let expiresInDays: number | undefined; + // undefined: server default. null: never expires. number: lifetime in days. + let expiresInDays: number | null | undefined; const expiresIdx = args.indexOf("--expires"); if (expiresIdx !== -1 && expiresIdx + 1 < args.length) { - expiresInDays = parseInt(args[expiresIdx + 1], 10); - if (!Number.isInteger(expiresInDays) || expiresInDays < 1) { - console.error("--expires must be a positive integer"); - process.exit(1); + const value = args[expiresIdx + 1]; + if (value === "never") { + expiresInDays = null; + } else { + expiresInDays = parseInt(value, 10); + if (!Number.isInteger(expiresInDays) || expiresInDays < 1) { + console.error("--expires must be a positive integer or \"never\""); + process.exit(1); + } } } @@ -173,7 +180,7 @@ async function main(): Promise { console.log(` JTI: ${result.jti}`); console.log(` Caches: ${result.caches.join(", ")}`); console.log(` Perms: ${result.perms.join(", ")}`); - console.log(` Expires: ${result.expiresAt}`); + console.log(` Expires: ${result.expiresAt ?? "never"}`); console.log(""); console.log(result.token); return; @@ -187,7 +194,7 @@ async function main(): Promise { } for (const t of tokens) { const status = t.revokedAt ? `revoked (${t.revokedAt})` : "active"; - console.log(`${t.jti} ${t.subject} [${t.perms.join(",")}] caches=[${t.caches.join(",")}] ${status} expires=${t.expiresAt}`); + console.log(`${t.jti} ${t.subject} [${t.perms.join(",")}] caches=[${t.caches.join(",")}] ${status} expires=${t.expiresAt ?? "never"}`); } return; } diff --git a/workers/cache/src/admin.ts b/workers/cache/src/admin.ts index 60c6609..dc0a077 100644 --- a/workers/cache/src/admin.ts +++ b/workers/cache/src/admin.ts @@ -28,7 +28,8 @@ export async function handleCreateToken( subject: string; caches: string[]; perms: string[]; - expiresInDays?: number; + // `null` requests a non-expiring token; omitted falls back to the default lifetime. + expiresInDays?: number | null; }>(request); if (body instanceof Response) return body; @@ -66,9 +67,11 @@ export async function handleCreateToken( validatedPerms.push(p); } - const expiresInDays = body.expiresInDays ?? DEFAULT_TOKEN_LIFETIME_DAYS; - if (!Number.isFinite(expiresInDays) || !Number.isInteger(expiresInDays) || expiresInDays < 1 || expiresInDays > MAX_TOKEN_LIFETIME_DAYS) { - return errorResponse(`expiresInDays must be between 1 and ${MAX_TOKEN_LIFETIME_DAYS}`, 400); + // `null` opts in to a non-expiring token; an omitted field still defaults to a + // finite lifetime to avoid accidental forever-tokens. + const expiresInDays = body.expiresInDays === undefined ? DEFAULT_TOKEN_LIFETIME_DAYS : body.expiresInDays; + if (expiresInDays !== null && (!Number.isFinite(expiresInDays) || !Number.isInteger(expiresInDays) || expiresInDays < 1 || expiresInDays > MAX_TOKEN_LIFETIME_DAYS)) { + return errorResponse(`expiresInDays must be between 1 and ${MAX_TOKEN_LIFETIME_DAYS}, or null for no expiry`, 400); } const dedupedCaches = [...new Set(body.caches)]; @@ -76,7 +79,7 @@ export async function handleCreateToken( const now = Date.now() / 1000; const jti = crypto.randomUUID(); - const exp = now + expiresInDays * 86400; + const exp = expiresInDays === null ? undefined : Math.floor(now + expiresInDays * 86400); const claims: TokenClaims = { jti, @@ -86,12 +89,12 @@ export async function handleCreateToken( caches: dedupedCaches, perms: dedupedPerms, iat: Math.floor(now), - exp: Math.floor(exp), + exp, }; const token = await signJwt(claims, config.jwtSecret); - const expiresAt = new Date(exp * 1000).toISOString(); + const expiresAt = exp === undefined ? null : new Date(exp * 1000).toISOString(); await createApiToken(config.db, { jti, diff --git a/workers/cache/src/db/repository.ts b/workers/cache/src/db/repository.ts index bef6c54..d80a6fc 100644 --- a/workers/cache/src/db/repository.ts +++ b/workers/cache/src/db/repository.ts @@ -377,7 +377,8 @@ export async function createApiToken( readonly subject: string; readonly cachesJson: string; readonly permsJson: string; - readonly expiresAt: string; + // `null` means the token never expires. + readonly expiresAt: string | null; readonly createdBy: string; }, ): Promise { diff --git a/workers/cache/src/db/schema.ts b/workers/cache/src/db/schema.ts index 6f32dbd..43123aa 100644 --- a/workers/cache/src/db/schema.ts +++ b/workers/cache/src/db/schema.ts @@ -88,7 +88,8 @@ export const apiTokens = sqliteTable("api_tokens", { cachesJson: text("caches_json").notNull(), permsJson: text("perms_json").notNull(), createdAt: timestamp("created_at"), - expiresAt: text("expires_at").notNull(), + // Nullable: when null, the token never expires. + expiresAt: text("expires_at"), createdBy: text("created_by").notNull(), revokedAt: text("revoked_at"), revokedBy: text("revoked_by"), diff --git a/workers/cache/src/jwt.ts b/workers/cache/src/jwt.ts index 3569ba5..137785f 100644 --- a/workers/cache/src/jwt.ts +++ b/workers/cache/src/jwt.ts @@ -7,7 +7,8 @@ export type TokenClaims = { readonly aud: string; readonly caches: readonly string[]; readonly perms: readonly TokenPermission[]; - readonly exp: number; + // Absent when the token is configured to never expire. + readonly exp?: number; readonly iat: number; }; @@ -87,13 +88,14 @@ function validateClaims(obj: Record): JwtVerifyResult { return { kind: "error", reason: "invalid token" }; } + // `exp` is optional: tokens minted with no expiry omit the claim entirely. const exp = obj["exp"]; - if (typeof exp !== "number") { + if (exp !== undefined && (typeof exp !== "number" || !Number.isSafeInteger(exp))) { return { kind: "error", reason: "invalid token" }; } const iat = obj["iat"]; - if (typeof iat !== "number") { + if (typeof iat !== "number" || !Number.isSafeInteger(iat)) { return { kind: "error", reason: "invalid token" }; } @@ -107,17 +109,13 @@ function validateClaims(obj: Record): JwtVerifyResult { return { kind: "error", reason: "invalid token" }; } - if (!Number.isSafeInteger(exp) || !Number.isSafeInteger(iat)) { - return { kind: "error", reason: "invalid token" }; - } - // Reject tokens issued in the future, allowing a small clock skew between nodes. const now = Date.now() / 1000; if (iat > now + MAX_CLOCK_SKEW_SECONDS) { return { kind: "error", reason: "invalid token" }; } - if (exp <= iat) { + if (exp !== undefined && exp <= iat) { return { kind: "error", reason: "invalid token" }; } diff --git a/workers/cache/test/admin-tokens.spec.ts b/workers/cache/test/admin-tokens.spec.ts new file mode 100644 index 0000000..5ec7725 --- /dev/null +++ b/workers/cache/test/admin-tokens.spec.ts @@ -0,0 +1,265 @@ +import { env, createExecutionContext, waitOnExecutionContext } from "cloudflare:test"; +import { describe, it, expect, beforeAll, vi } from "vitest"; +import { drizzle } from "drizzle-orm/d1"; +import { createCache, deleteExpiredApiTokens, findApiToken } from "../src/db/repository.js"; +import { verifyJwt, signJwt, TOKEN_ISSUER, TOKEN_AUDIENCE } from "../src/jwt.js"; +import type { TokenClaims } from "../src/jwt.js"; +import worker from "../src"; + +const ADMIN_SECRET = "test-admin-secret"; +const JWT_SECRET = "test-jwt-secret"; +const CACHE_NAME = "expiry-test"; + +function envWithSecrets(): typeof env { + return { ...env, ADMIN_SECRET, JWT_SECRET }; +} + +async function adminFetch(method: string, path: string, body?: unknown): Promise { + const init: RequestInit = { + method, + headers: { + authorization: `Bearer ${ADMIN_SECRET}`, + ...(body !== undefined ? { "content-type": "application/json" } : {}), + }, + }; + if (body !== undefined) init.body = JSON.stringify(body); + const ctx = createExecutionContext(); + const response = worker.fetch(new Request(`http://example.com${path}`, init), envWithSecrets(), ctx); + await waitOnExecutionContext(ctx); + return response; +} + +function decodeJwtPayload(token: string): Record { + const parts = token.split("."); + expect(parts.length).toBe(3); + const b64 = parts[1].replaceAll("-", "+").replaceAll("_", "/"); + const padded = b64 + "====".slice(0, (4 - (b64.length % 4)) % 4); + const json = atob(padded); + const parsed: unknown = JSON.parse(json); + expect(typeof parsed).toBe("object"); + expect(parsed).not.toBeNull(); + return parsed as Record; +} + +beforeAll(async () => { + const db = drizzle(env.CACHE_DB); + await createCache(db, CACHE_NAME); +}); + +describe("admin: non-expiring tokens", () => { + it("creates a token with expiresInDays: null and persists null in the DB", async () => { + const res = await adminFetch("POST", "/_api/v1/admin/tokens", { + subject: "forever-bot", + caches: [CACHE_NAME], + perms: ["push"], + expiresInDays: null, + }); + + expect(res.status).toBe(201); + const body = await res.json<{ token: string; jti: string; expiresAt: string | null }>(); + expect(body.expiresAt).toBeNull(); + expect(typeof body.token).toBe("string"); + + // JWT payload must omit `exp` so verifiers treat it as non-expiring. + const payload = decodeJwtPayload(body.token); + expect(payload["exp"]).toBeUndefined(); + expect(payload["jti"]).toBe(body.jti); + + // DB row stores null in expires_at. + const db = drizzle(env.CACHE_DB); + const row = await findApiToken(db, body.jti); + expect(row?.expiresAt).toBeNull(); + }); + + it("lists the never-expiring token with expiresAt: null", async () => { + // Per-test isolated storage: create the token in this same test. + const create = await adminFetch("POST", "/_api/v1/admin/tokens", { + subject: "list-bot", + caches: [CACHE_NAME], + perms: ["push"], + expiresInDays: null, + }); + expect(create.status).toBe(201); + + const list = await adminFetch("GET", "/_api/v1/admin/tokens"); + expect(list.status).toBe(200); + const body = await list.json<{ tokens: { subject: string; expiresAt: string | null }[] }>(); + const found = body.tokens.find((t) => t.subject === "list-bot"); + expect(found).toBeDefined(); + expect(found?.expiresAt).toBeNull(); + }); + + it("still defaults to a finite lifetime when expiresInDays is omitted", async () => { + const res = await adminFetch("POST", "/_api/v1/admin/tokens", { + subject: "default-bot", + caches: [CACHE_NAME], + perms: ["push"], + }); + + expect(res.status).toBe(201); + const body = await res.json<{ expiresAt: string | null; token: string }>(); + expect(body.expiresAt).not.toBeNull(); + const payload = decodeJwtPayload(body.token); + expect(typeof payload["exp"]).toBe("number"); + }); + + it("rejects out-of-range expiresInDays", async () => { + const res = await adminFetch("POST", "/_api/v1/admin/tokens", { + subject: "bad-bot", + caches: [CACHE_NAME], + perms: ["push"], + expiresInDays: 0, + }); + expect(res.status).toBe(400); + }); + + it("authenticates a write request with a never-expiring JWT", async () => { + const create = await adminFetch("POST", "/_api/v1/admin/tokens", { + subject: "writer-bot", + caches: [CACHE_NAME], + perms: ["push"], + expiresInDays: null, + }); + expect(create.status).toBe(201); + const { token } = await create.json<{ token: string }>(); + + // Use only Nix base-32 chars (no 'e','o','u','t') for storePathHash. + const writeReq = new Request( + `http://example.com/_api/v1/caches/${CACHE_NAME}/upload-sessions`, + { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + storePath: "/nix/store/forever", + storePathHash: "a".repeat(32), + narHash: "sha256:" + "1".repeat(52), + narSize: 100, + fileHash: "f".repeat(64), + fileSize: 100, + compression: "zstd", + }), + }, + ); + const ctx = createExecutionContext(); + const res = worker.fetch(writeReq, envWithSecrets(), ctx); + await waitOnExecutionContext(ctx); + expect((await res).status).toBe(201); + }); + + it("does not delete a never-expiring token during expiry cleanup", async () => { + const create = await adminFetch("POST", "/_api/v1/admin/tokens", { + subject: "gc-bot", + caches: [CACHE_NAME], + perms: ["push"], + expiresInDays: null, + }); + const { jti } = await create.json<{ jti: string }>(); + + const db = drizzle(env.CACHE_DB); + // Pretend "now" is 1000 days in the future. Tokens with expires_at = NULL must be untouched. + const farFuture = new Date(Date.now() + 1000 * 86400 * 1000).toISOString(); + await deleteExpiredApiTokens(db, farFuture); + + const row = await findApiToken(db, jti); + expect(row).toBeDefined(); + expect(row?.expiresAt).toBeNull(); + }); +}); + +describe("jwt: missing exp claim", () => { + it("verifyJwt accepts a token signed with no exp", async () => { + const claims: TokenClaims = { + jti: crypto.randomUUID(), + sub: "no-exp", + iss: TOKEN_ISSUER, + aud: TOKEN_AUDIENCE, + caches: ["*"], + perms: ["pull"], + iat: Math.floor(Date.now() / 1000), + }; + const token = await signJwt(claims, JWT_SECRET); + expect(decodeJwtPayload(token)["exp"]).toBeUndefined(); + + const result = await verifyJwt(token, JWT_SECRET); + expect(result.kind).toBe("ok"); + if (result.kind === "ok") { + expect(result.claims.exp).toBeUndefined(); + expect(result.claims.sub).toBe("no-exp"); + } + }); + + it("a never-expiring token is still valid 67 years later", async () => { + const iat = Math.floor(Date.now() / 1000); + const claims: TokenClaims = { + jti: crypto.randomUUID(), + sub: "time-traveler", + iss: TOKEN_ISSUER, + aud: TOKEN_AUDIENCE, + caches: ["*"], + perms: ["pull"], + iat, + }; + const token = await signJwt(claims, JWT_SECRET); + + // Jump the clock 420 years forward. + const SECONDS_PER_YEAR = 365.25 * 86400; + const future = new Date((iat + 420 * SECONDS_PER_YEAR) * 1000); + vi.useFakeTimers(); + try { + vi.setSystemTime(future); + + // Sanity: clock really moved. + expect(Date.now()).toBe(future.getTime()); + + const result = await verifyJwt(token, JWT_SECRET); + expect(result.kind).toBe("ok"); + if (result.kind === "ok") { + expect(result.claims.exp).toBeUndefined(); + } + + // Contrast: a finite-expiry token signed at the same iat IS rejected at this future time. + const finite: TokenClaims = { ...claims, jti: crypto.randomUUID(), exp: iat + 90 * 86400 }; + const finiteToken = await signJwt(finite, JWT_SECRET); + const finiteResult = await verifyJwt(finiteToken, JWT_SECRET); + expect(finiteResult.kind).toBe("error"); + } finally { + vi.useRealTimers(); + } + }); + + it("verifyJwt still rejects a token with a malformed exp", async () => { + // Hand-craft a payload with a string `exp` so we exercise the type check. + const header = { alg: "HS256", typ: "JWT" }; + const payload = { + jti: crypto.randomUUID(), + sub: "bad-exp", + iss: TOKEN_ISSUER, + aud: TOKEN_AUDIENCE, + caches: ["*"], + perms: ["pull"], + iat: Math.floor(Date.now() / 1000), + exp: "not-a-number", + }; + const enc = new TextEncoder(); + const b64 = (s: string) => + btoa(s).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); + const signingInput = `${b64(JSON.stringify(header))}.${b64(JSON.stringify(payload))}`; + const key = await crypto.subtle.importKey( + "raw", + enc.encode(JWT_SECRET), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = new Uint8Array(await crypto.subtle.sign("HMAC", key, enc.encode(signingInput))); + let bin = ""; + for (let i = 0; i < sig.length; i++) bin += String.fromCharCode(sig[i]); + const token = `${signingInput}.${b64(bin)}`; + + const result = await verifyJwt(token, JWT_SECRET); + expect(result.kind).toBe("error"); + }); +}); From 69687e4c4b6dd1e3b660946dc11c1b94e6439776 Mon Sep 17 00:00:00 2001 From: Uzair Aftab Date: Mon, 27 Apr 2026 13:55:39 +0200 Subject: [PATCH 2/2] chore(cache): add migration for nullable api token expiry Generated by drizzle-kit after relaxing the NOT NULL constraint on api_tokens.expires_at in schema.ts. SQLite cannot drop NOT NULL via ALTER TABLE, so the migration recreates the table via the standard rename-and-copy pattern. --- .../cache/migrations/0003_happy_eternity.sql | 18 + .../cache/migrations/meta/0003_snapshot.json | 724 ++++++++++++++++++ workers/cache/migrations/meta/_journal.json | 7 + 3 files changed, 749 insertions(+) create mode 100644 workers/cache/migrations/0003_happy_eternity.sql create mode 100644 workers/cache/migrations/meta/0003_snapshot.json diff --git a/workers/cache/migrations/0003_happy_eternity.sql b/workers/cache/migrations/0003_happy_eternity.sql new file mode 100644 index 0000000..eca1ad5 --- /dev/null +++ b/workers/cache/migrations/0003_happy_eternity.sql @@ -0,0 +1,18 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_api_tokens` ( + `jti` text PRIMARY KEY NOT NULL, + `subject` text NOT NULL, + `caches_json` text NOT NULL, + `perms_json` text NOT NULL, + `created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + `expires_at` text, + `created_by` text NOT NULL, + `revoked_at` text, + `revoked_by` text, + `revocation_reason` text +); +--> statement-breakpoint +INSERT INTO `__new_api_tokens`("jti", "subject", "caches_json", "perms_json", "created_at", "expires_at", "created_by", "revoked_at", "revoked_by", "revocation_reason") SELECT "jti", "subject", "caches_json", "perms_json", "created_at", "expires_at", "created_by", "revoked_at", "revoked_by", "revocation_reason" FROM `api_tokens`;--> statement-breakpoint +DROP TABLE `api_tokens`;--> statement-breakpoint +ALTER TABLE `__new_api_tokens` RENAME TO `api_tokens`;--> statement-breakpoint +PRAGMA foreign_keys=ON; \ No newline at end of file diff --git a/workers/cache/migrations/meta/0003_snapshot.json b/workers/cache/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..080ecbc --- /dev/null +++ b/workers/cache/migrations/meta/0003_snapshot.json @@ -0,0 +1,724 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "de27c945-8f3c-4010-a29e-8ed7ad61f5da", + "prevId": "f6ad1415-55bd-4c46-8984-34fa48eaea35", + "tables": { + "api_tokens": { + "name": "api_tokens", + "columns": { + "jti": { + "name": "jti", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "caches_json": { + "name": "caches_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "perms_json": { + "name": "perms_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revocation_reason": { + "name": "revocation_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_logs": { + "name": "audit_logs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "actor": { + "name": "actor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cache_name": { + "name": "cache_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_audit_logs_actor": { + "name": "idx_audit_logs_actor", + "columns": [ + "actor" + ], + "isUnique": false + }, + "idx_audit_logs_cache": { + "name": "idx_audit_logs_cache", + "columns": [ + "cache_name" + ], + "isUnique": false + }, + "idx_audit_logs_time": { + "name": "idx_audit_logs_time", + "columns": [ + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "blob_objects": { + "name": "blob_objects", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "file_hash": { + "name": "file_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "compression": { + "name": "compression", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "blob_objects_r2_key_unique": { + "name": "blob_objects_r2_key_unique", + "columns": [ + "r2_key" + ], + "isUnique": true + }, + "blob_objects_file_hash_compression_unique": { + "name": "blob_objects_file_hash_compression_unique", + "columns": [ + "file_hash", + "compression" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "caches": { + "name": "caches", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_public": { + "name": "is_public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "caches_name_unique": { + "name": "caches_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "gc_marks": { + "name": "gc_marks", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "marked_at": { + "name": "marked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "gc_marks_target_unique": { + "name": "gc_marks_target_unique", + "columns": [ + "target_type", + "target_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "published_paths": { + "name": "published_paths", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "cache_id": { + "name": "cache_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "store_path_hash": { + "name": "store_path_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "store_path": { + "name": "store_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nar_hash": { + "name": "nar_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nar_size": { + "name": "nar_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "blob_object_id": { + "name": "blob_object_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "references_json": { + "name": "references_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "deriver": { + "name": "deriver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "system": { + "name": "system", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signatures_json": { + "name": "signatures_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "published_paths_cache_store_unique": { + "name": "published_paths_cache_store_unique", + "columns": [ + "cache_id", + "store_path_hash" + ], + "isUnique": true + }, + "idx_published_paths_blob_object": { + "name": "idx_published_paths_blob_object", + "columns": [ + "blob_object_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "published_paths_cache_id_caches_id_fk": { + "name": "published_paths_cache_id_caches_id_fk", + "tableFrom": "published_paths", + "tableTo": "caches", + "columnsFrom": [ + "cache_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "published_paths_blob_object_id_blob_objects_id_fk": { + "name": "published_paths_blob_object_id_blob_objects_id_fk", + "tableFrom": "published_paths", + "tableTo": "blob_objects", + "columnsFrom": [ + "blob_object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "upload_parts": { + "name": "upload_parts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part_number": { + "name": "part_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "upload_parts_session_part_unique": { + "name": "upload_parts_session_part_unique", + "columns": [ + "session_id", + "part_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "upload_parts_session_id_upload_sessions_id_fk": { + "name": "upload_parts_session_id_upload_sessions_id_fk", + "tableFrom": "upload_parts", + "tableTo": "upload_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "upload_sessions": { + "name": "upload_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "cache_id": { + "name": "cache_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "store_path_hash": { + "name": "store_path_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "store_path": { + "name": "store_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nar_hash": { + "name": "nar_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nar_size": { + "name": "nar_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_hash": { + "name": "file_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "compression": { + "name": "compression", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "references_json": { + "name": "references_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "deriver": { + "name": "deriver", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "system": { + "name": "system", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "r2_upload_key": { + "name": "r2_upload_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "r2_upload_id": { + "name": "r2_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_upload_sessions_status": { + "name": "idx_upload_sessions_status", + "columns": [ + "status", + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "upload_sessions_cache_id_caches_id_fk": { + "name": "upload_sessions_cache_id_caches_id_fk", + "tableFrom": "upload_sessions", + "tableTo": "caches", + "columnsFrom": [ + "cache_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/workers/cache/migrations/meta/_journal.json b/workers/cache/migrations/meta/_journal.json index 2e084e2..b443059 100644 --- a/workers/cache/migrations/meta/_journal.json +++ b/workers/cache/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1777117423084, "tag": "0002_ordinary_wiccan", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1777289651373, + "tag": "0003_happy_eternity", + "breakpoints": true } ] } \ No newline at end of file