1import {2 formatAddress,3 GLOBAL_DEFAULT_TTL,4 SITES_PER_VAULT,5 type Target,6 VAULT_SLOTS,7 vaultSlotOf,8} from "../../shared/mod.ts";9import type {10 AppReservationInfo,11 DefaultVaults,12 PushInfo,13 SiteDetail,14 SiteInfo,15 Ttl,16 VaultInfo,17 VaultLifecycle,18} from "../../shared/mod.ts";19import { type Env, siteUrl } from "./env.ts";2021export const HISTORY_CAP = 10;2223export interface SiteRow {24 address: number;25 vault_slot: number;26 alias: string | null;27 current_manifest: string;28 created_at: number;29 updated_at: number;30 expires_at: number | null;31 password_hash: string | null;32 content_security_policy: string | null;33 auth_version: number;34}3536export interface VaultRow {37 slot: number;38 name: string;39 description: string | null;40 default_ttl: number | null;41 created_at: number;42}4344export interface AppReservationRow {45 address: number;46 vault_slot: number;47 alias: string;48 created_at: number;49 retired_at: number | null;50}5152export async function getSiteByAddress(53 env: Env,54 address: number,55): Promise<SiteRow | null> {56 return await env.DB.prepare("SELECT * FROM sites WHERE address = ?")57 .bind(address).first<SiteRow>();58}5960export async function getVaultByName(61 env: Env,62 name: string,63): Promise<VaultRow | null> {64 return await env.DB.prepare("SELECT * FROM vaults WHERE name = ?").bind(name)65 .first<VaultRow>();66}6768export async function getVaultBySlot(69 env: Env,70 slot: number,71): Promise<VaultRow | null> {72 return await env.DB.prepare("SELECT * FROM vaults WHERE slot = ?").bind(slot)73 .first<VaultRow>();74}7576export async function getAppReservationByAddress(77 env: Env,78 address: number,79): Promise<AppReservationRow | null> {80 return await env.DB.prepare(81 "SELECT * FROM app_reservations WHERE address = ?",82 ).bind(address).first<AppReservationRow>();83}8485export async function getAppReservationByAlias(86 env: Env,87 vaultSlot: number,88 alias: string,89): Promise<AppReservationRow | null> {90 return await env.DB.prepare(91 "SELECT * FROM app_reservations WHERE vault_slot = ? AND alias = ?",92 ).bind(vaultSlot, alias).first<AppReservationRow>();93}9495/** Resolve an API target to an existing site row, or null if it doesn't exist. */96export async function resolveTarget(97 env: Env,98 target: Target,99): Promise<SiteRow | null> {100 if ("address" in target) return await getSiteByAddress(env, target.address);101 const vault = await getVaultByName(env, target.vault);102 if (!vault) return null;103 return await env.DB.prepare(104 "SELECT * FROM sites WHERE vault_slot = ? AND alias = ?",105 )106 .bind(vault.slot, target.alias).first<SiteRow>();107}108109/**110 * Pick a random free address within a vault. Random (not sequential) so URLs111 * don't leak site count or enumerate in order.112 */113export async function allocateAddress(env: Env, slot: number): Promise<number> {114 const taken = await env.DB.prepare(115 `SELECT address FROM sites WHERE vault_slot = ?116 UNION117 SELECT address FROM app_reservations WHERE vault_slot = ?`,118 )119 .bind(slot, slot).all<{ address: number }>();120 const used = new Set(taken.results.map((r) => r.address));121 if (used.size >= SITES_PER_VAULT) throw new Error("vault is full");122 for (let i = 0; i < 64; i++) {123 const candidate = (slot << 12) |124 (crypto.getRandomValues(new Uint32Array(1))[0] & 0xfff);125 if (!used.has(candidate)) return candidate;126 }127 // Vault nearly full and randomness unlucky: fall back to first free slot.128 for (let id = 0; id < SITES_PER_VAULT; id++) {129 const candidate = (slot << 12) | id;130 if (!used.has(candidate)) return candidate;131 }132 throw new Error("vault is full");133}134135export interface CommitParams {136 address: number;137 vaultSlot: number;138 alias: string | null;139 manifestHash: string;140 expiresAt: number | null;141 /** undefined preserves an existing password; null explicitly clears it */142 passwordHash?: string | null;143 /** undefined preserves an existing CSP; null explicitly clears it */144 contentSecurityPolicy?: string | null;145 isNew: boolean;146 note?: string;147}148149/** Atomic commit: upsert site, append history, prune beyond HISTORY_CAP. Returns new seq. */150export async function commitSite(env: Env, p: CommitParams): Promise<number> {151 const now = Math.floor(Date.now() / 1000);152 const seqRow = await env.DB.prepare(153 "SELECT COALESCE(MAX(seq), 0) AS s FROM pushes WHERE address = ?",154 )155 .bind(p.address).first<{ s: number }>();156 const seq = (seqRow?.s ?? 0) + 1;157158 const siteCommit = p.isNew159 ? env.DB.prepare(160 `INSERT INTO sites (161 address, vault_slot, alias, current_manifest, created_at, updated_at, expires_at,162 password_hash, content_security_policy163 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,164 ).bind(165 p.address,166 p.vaultSlot,167 p.alias,168 p.manifestHash,169 now,170 now,171 p.expiresAt,172 p.passwordHash ?? null,173 p.contentSecurityPolicy ?? null,174 )175 : p.passwordHash === undefined && p.contentSecurityPolicy === undefined176 ? env.DB.prepare(177 `UPDATE sites SET current_manifest = ?, updated_at = ?, expires_at = ?, alias = COALESCE(?, alias)178 WHERE address = ?`,179 ).bind(p.manifestHash, now, p.expiresAt, p.alias, p.address)180 : env.DB.prepare(181 `UPDATE sites SET current_manifest = ?, updated_at = ?, expires_at = ?,182 alias = COALESCE(?, alias),183 password_hash = CASE WHEN ? = 1 THEN ? ELSE password_hash END,184 content_security_policy = CASE WHEN ? = 1 THEN ? ELSE content_security_policy END,185 auth_version = auth_version + ?186 WHERE address = ?`,187 ).bind(188 p.manifestHash,189 now,190 p.expiresAt,191 p.alias,192 p.passwordHash === undefined ? 0 : 1,193 p.passwordHash ?? null,194 p.contentSecurityPolicy === undefined ? 0 : 1,195 p.contentSecurityPolicy ?? null,196 p.passwordHash === undefined ? 0 : 1,197 p.address,198 );199200 const statements = [201 siteCommit,202 env.DB.prepare(203 "INSERT INTO pushes (address, seq, manifest_hash, pushed_at, note) VALUES (?, ?, ?, ?, ?)",204 ).bind(p.address, seq, p.manifestHash, now, p.note ?? null),205 env.DB.prepare(206 "DELETE FROM pushes WHERE address = ? AND seq <= ?",207 ).bind(p.address, seq - HISTORY_CAP),208 ];209 await env.DB.batch(statements);210 return seq;211}212213export async function listSites(214 env: Env,215 vaultSlot?: number,216): Promise<(SiteRow & { vault_name: string })[]> {217 const base =218 `SELECT s.*, v.name AS vault_name FROM sites s JOIN vaults v ON v.slot = s.vault_slot`;219 const stmt = vaultSlot === undefined220 ? env.DB.prepare(`${base} ORDER BY s.updated_at DESC`)221 : env.DB.prepare(222 `${base} WHERE s.vault_slot = ? ORDER BY s.updated_at DESC`,223 ).bind(vaultSlot);224 const res = await stmt.all<SiteRow & { vault_name: string }>();225 return res.results;226}227228export async function siteHistory(229 env: Env,230 address: number,231): Promise<PushInfo[]> {232 const res = await env.DB.prepare(233 "SELECT seq, manifest_hash, pushed_at, note FROM pushes WHERE address = ? ORDER BY seq DESC",234 ).bind(address).all<235 {236 seq: number;237 manifest_hash: string;238 pushed_at: number;239 note: string | null;240 }241 >();242 return res.results.map((r) => ({243 seq: r.seq,244 manifestHash: r.manifest_hash,245 pushedAt: r.pushed_at,246 note: r.note,247 }));248}249250export function vaultRowToInfo(251 row: VaultRow,252 siteCount: number,253 defaultFor: VaultLifecycle[] = [],254): VaultInfo {255 const defaultTtl: Ttl | null = row.default_ttl === null256 ? null257 : row.default_ttl === 0258 ? "forever"259 : row.default_ttl;260 return {261 slot: row.slot,262 name: row.name,263 description: row.description,264 createdAt: row.created_at,265 siteCount,266 defaultTtl,267 effectiveDefaultTtl: defaultTtl ?? GLOBAL_DEFAULT_TTL,268 defaultFor,269 };270}271272export async function listVaults(env: Env): Promise<VaultInfo[]> {273 const [res, defaults] = await Promise.all([274 env.DB.prepare(275 `SELECT v.slot, v.name, v.description, v.default_ttl,276 v.created_at, COUNT(s.address) AS site_count277 FROM vaults v LEFT JOIN sites s ON s.vault_slot = v.slot278 GROUP BY v.slot ORDER BY v.slot`,279 ).all<VaultRow & { site_count: number }>(),280 env.DB.prepare("SELECT lifecycle, vault_slot FROM vault_defaults")281 .all<{ lifecycle: VaultLifecycle; vault_slot: number }>(),282 ]);283 return res.results.map((r) =>284 vaultRowToInfo(285 r,286 r.site_count,287 defaults.results.filter((item) => item.vault_slot === r.slot).map((288 item,289 ) => item.lifecycle),290 )291 );292}293294export async function createVault(295 env: Env,296 name: string,297 slot?: number,298 description: string | null = null,299 defaultTtl: Ttl | null = null,300 defaultFor: VaultLifecycle | null = null,301): Promise<VaultRow> {302 const now = Math.floor(Date.now() / 1000);303 let chosen = slot;304 if (chosen === undefined) {305 const takenRes = await env.DB.prepare("SELECT slot FROM vaults").all<306 { slot: number }307 >();308 const taken = new Set(takenRes.results.map((r) => r.slot));309 for (let s = 0; s < VAULT_SLOTS; s++) {310 if (!taken.has(s)) {311 chosen = s;312 break;313 }314 }315 if (chosen === undefined) throw new Error("all 16 vault slots are taken");316 }317 const insert = env.DB.prepare(318 `INSERT INTO vaults319 (slot, name, description, default_ttl, created_at)320 VALUES (?, ?, ?, ?, ?)`,321 )322 .bind(323 chosen,324 name,325 description,326 defaultTtl === "forever" ? 0 : defaultTtl,327 now,328 );329 if (defaultFor === null) await insert.run();330 else {331 await env.DB.batch([332 insert,333 env.DB.prepare(334 `INSERT INTO vault_defaults (lifecycle, vault_slot) VALUES (?, ?)335 ON CONFLICT(lifecycle) DO UPDATE SET vault_slot = excluded.vault_slot`,336 ).bind(defaultFor, chosen),337 ]);338 }339 return {340 slot: chosen,341 name,342 description,343 default_ttl: defaultTtl === "forever" ? 0 : defaultTtl,344 created_at: now,345 };346}347348export async function updateVault(349 env: Env,350 currentName: string,351 patch: {352 name?: string;353 description?: string | null;354 defaultTtl?: Ttl | null;355 defaultFor?: VaultLifecycle | null;356 },357): Promise<VaultInfo | null> {358 const current = await getVaultByName(env, currentName);359 if (!current) return null;360 const assignments: string[] = [];361 const values: unknown[] = [];362 if (patch.name !== undefined) {363 assignments.push("name = ?");364 values.push(patch.name);365 }366 if (patch.description !== undefined) {367 assignments.push("description = ?");368 values.push(patch.description);369 }370 if (patch.defaultTtl !== undefined) {371 assignments.push("default_ttl = ?");372 values.push(patch.defaultTtl === "forever" ? 0 : patch.defaultTtl);373 }374 const statements: D1PreparedStatement[] = [];375 if (assignments.length > 0) {376 statements.push(377 env.DB.prepare(378 `UPDATE vaults SET ${assignments.join(", ")} WHERE name = ?`,379 )380 .bind(...values, currentName),381 );382 }383 if (patch.defaultFor === null) {384 statements.push(385 env.DB.prepare("DELETE FROM vault_defaults WHERE vault_slot = ?").bind(386 current.slot,387 ),388 );389 } else if (patch.defaultFor !== undefined) {390 statements.push(391 env.DB.prepare(392 `INSERT INTO vault_defaults (lifecycle, vault_slot) VALUES (?, ?)393 ON CONFLICT(lifecycle) DO UPDATE SET vault_slot = excluded.vault_slot`,394 ).bind(patch.defaultFor, current.slot),395 );396 }397 if (statements.length === 0) return null;398 await env.DB.batch(statements);399400 const updatedName = patch.name ?? currentName;401 const row = await getVaultByName(env, updatedName);402 if (!row) return null;403 const count = await env.DB.prepare(404 "SELECT COUNT(*) AS n FROM sites WHERE vault_slot = ?",405 ).bind(row.slot).first<{ n: number }>();406 const defaults = await env.DB.prepare(407 "SELECT lifecycle FROM vault_defaults WHERE vault_slot = ? ORDER BY lifecycle",408 ).bind(row.slot).all<{ lifecycle: VaultLifecycle }>();409 return vaultRowToInfo(410 row,411 count?.n ?? 0,412 defaults.results.map((r) => r.lifecycle),413 );414}415416export async function defaultVaults(env: Env): Promise<DefaultVaults> {417 const rows = await env.DB.prepare(418 `SELECT v.name, d.lifecycle419 FROM vault_defaults d JOIN vaults v ON v.slot = d.vault_slot`,420 ).all<{ name: string; lifecycle: VaultLifecycle }>();421 const result: DefaultVaults = { temporary: null, permanent: null };422 for (const row of rows.results) result[row.lifecycle] = row.name;423 return result;424}425426export async function setDefaultVault(427 env: Env,428 lifecycle: VaultLifecycle,429 name: string,430): Promise<VaultInfo | null> {431 const vault = await getVaultByName(env, name);432 if (!vault) return null;433 await env.DB.prepare(434 `INSERT INTO vault_defaults (lifecycle, vault_slot) VALUES (?, ?)435 ON CONFLICT(lifecycle) DO UPDATE SET vault_slot = excluded.vault_slot`,436 ).bind(lifecycle, vault.slot).run();437 const count = await env.DB.prepare(438 "SELECT COUNT(*) AS n FROM sites WHERE vault_slot = ?",439 ).bind(vault.slot).first<{ n: number }>();440 const defaults = await env.DB.prepare(441 "SELECT lifecycle FROM vault_defaults WHERE vault_slot = ? ORDER BY lifecycle",442 ).bind(vault.slot).all<{ lifecycle: VaultLifecycle }>();443 return vaultRowToInfo(444 vault,445 count?.n ?? 0,446 defaults.results.map((r) => r.lifecycle),447 );448}449450export async function reserveAppOrigin(451 env: Env,452 vault: VaultRow,453 alias: string,454): Promise<AppReservationRow> {455 const existing = await getAppReservationByAlias(env, vault.slot, alias);456 if (existing) return existing;457458 const site = await resolveTarget(env, { vault: vault.name, alias });459 const address = site?.address ?? await allocateAddress(env, vault.slot);460 const now = Math.floor(Date.now() / 1000);461 try {462 await env.DB.prepare(463 `INSERT INTO app_reservations464 (address, vault_slot, alias, created_at, retired_at)465 VALUES (?, ?, ?, ?, NULL)`,466 ).bind(address, vault.slot, alias, now).run();467 } catch (cause) {468 const raced = await getAppReservationByAlias(env, vault.slot, alias);469 if (raced) return raced;470 throw cause;471 }472 return {473 address,474 vault_slot: vault.slot,475 alias,476 created_at: now,477 retired_at: null,478 };479}480481export async function appReservationToInfo(482 env: Env,483 row: AppReservationRow,484 vaultName: string,485): Promise<AppReservationInfo> {486 const address = formatAddress(row.address);487 return {488 address,489 vault: vaultName,490 alias: row.alias,491 url: siteUrl(env, address),492 createdAt: row.created_at,493 deployed: (await getSiteByAddress(env, row.address)) !== null,494 };495}496497export function siteRowToInfo(498 env: Env,499 row: SiteRow,500 vaultName: string,501): SiteInfo {502 const address = formatAddress(row.address);503 return {504 address,505 vault: vaultName,506 alias: row.alias,507 manifestHash: row.current_manifest,508 createdAt: row.created_at,509 updatedAt: row.updated_at,510 expiresAt: row.expires_at,511 url: siteUrl(env, address),512 protected: row.password_hash !== null,513 };514}515516export async function siteRowToDetail(517 env: Env,518 row: SiteRow,519): Promise<SiteDetail> {520 const vault = await getVaultBySlot(env, vaultSlotOf(row.address));521 return {522 ...siteRowToInfo(env, row, vault?.name ?? `slot-${row.vault_slot}`),523 history: await siteHistory(env, row.address),524 };525}