clone

push a directory of html from the terminal and get a four-character url back. a deno cli on cloudflare

feat/site-feedback
nzip / worker / src / notify_enrollment.ts
notify_enrollment.ts 24.4K
1import type { Env } from "./env.ts";2import { json } from "./env.ts";3import { validatePushEndpoint as validateWebPushEndpoint } from "./web_push.ts";45const CLAIM_COOKIE = "__Host-nzip-notify";6const PENDING_SECONDS = 10 * 60;7const PAIRING_WINDOW_SECONDS = 10 * 60;8const APPROVED_SETUP_SECONDS = 24 * 60 * 60;9const CLAIM_SECONDS = 365 * 24 * 60 * 60;10const RENEW_WINDOW_SECONDS = 90 * 24 * 60 * 60;11const MAX_PENDING = 32;12const MAX_JSON_BYTES = 8 * 1024;13const CODE_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";14const CODE_RE = /^[2-9A-HJ-NP-Z]{4}-[2-9A-HJ-NP-Z]{4}$/;1516export type NotifyEnv = Env & {17 NOTIFY_ENABLED?: string;18 VAPID_PUBLIC_KEY?: string;19 WEB_PUSH_ORIGINS?: string;20 RL_NOTIFY_ENROLL?: RateLimit;21 RL_NOTIFY_READ?: RateLimit;22};2324interface DeviceRow {25 id: string;26 status: string;27 name: string | null;28 user_agent_summary: string | null;29 device_class: string | null;30 country: string | null;31 region: string | null;32 asn: number | null;33 created_at: number;34 pairing_expires_at: number | null;35 claim_expires_at: number | null;36 approved_at: number | null;37 active_at: number | null;38 last_attached_at: number | null;39 last_seen_at: number | null;40 last_success_at: number | null;41 last_error: string | null;42 endpoint_hash?: string | null;43 p256dh?: string | null;44 auth?: string | null;45}4647export class NotifyHttpError extends Error {48 status: number;49 headers: HeadersInit;5051 constructor(52 status: number,53 message: string,54 headers: HeadersInit = {},55 ) {56 super(message);57 this.status = status;58 this.headers = headers;59 }60}6162function nowSeconds(): number {63 return Math.floor(Date.now() / 1000);64}6566function randomBase64Url(bytes: number): string {67 const value = crypto.getRandomValues(new Uint8Array(bytes));68 let binary = "";69 for (const byte of value) binary += String.fromCharCode(byte);70 return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(71 /=+$/,72 "",73 );74}7576export function generatePairingCode(): string {77 const bytes = crypto.getRandomValues(new Uint8Array(8));78 let code = "";79 for (const byte of bytes) code += CODE_ALPHABET[byte % CODE_ALPHABET.length];80 return `${code.slice(0, 4)}-${code.slice(4)}`;81}8283export function normalizePairingCode(value: string): string | null {84 const compact = value.trim().toUpperCase().replace(/[^A-Z0-9]/g, "");85 if (compact.length !== 8) return null;86 const normalized = `${compact.slice(0, 4)}-${compact.slice(4)}`;87 return CODE_RE.test(normalized) ? normalized : null;88}8990async function hmacHex(91 secret: string,92 purpose: string,93 value: string,94): Promise<string> {95 const encoder = new TextEncoder();96 const key = await crypto.subtle.importKey(97 "raw",98 encoder.encode(secret),99 { name: "HMAC", hash: "SHA-256" },100 false,101 ["sign"],102 );103 const digest = new Uint8Array(104 await crypto.subtle.sign(105 "HMAC",106 key,107 encoder.encode(`${purpose}\0${value}`),108 ),109 );110 return [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("");111}112113function signingSecret(env: NotifyEnv): string {114 if (!env.NZIP_TOKEN) {115 throw new NotifyHttpError(503, "notifications unavailable");116 }117 return env.NZIP_TOKEN;118}119120export function claimCookie(value: string, maxAge: number): string {121 return `${CLAIM_COOKIE}=${value}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${maxAge}`;122}123124function clearClaimCookie(): string {125 return `${CLAIM_COOKIE}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0`;126}127128export function readClaimCookie(request: Request): string | null {129 const cookie = request.headers.get("cookie");130 if (!cookie) return null;131 for (const entry of cookie.split(";")) {132 const [name, ...parts] = entry.trim().split("=");133 if (name === CLAIM_COOKIE) {134 const value = parts.join("=");135 return /^[A-Za-z0-9_-]{40,64}$/.test(value) ? value : null;136 }137 }138 return null;139}140141export function isSameOriginBrowserRequest(request: Request): boolean {142 const url = new URL(request.url);143 const origin = request.headers.get("origin");144 if (origin !== null && origin !== url.origin) return false;145 const site = request.headers.get("sec-fetch-site");146 if (site !== null && site !== "same-origin" && site !== "none") return false;147 return origin !== null || site !== null;148}149150function boundedText(value: unknown, max: number): string | null {151 if (typeof value !== "string") return null;152 const normalized = value.trim();153 if (154 !normalized || [...normalized].length > max ||155 /[\u0000-\u001f\u007f]/.test(normalized)156 ) {157 return null;158 }159 return normalized;160}161162export async function readNotifyJsonObject(163 request: Request,164): Promise<Record<string, unknown>> {165 const type = request.headers.get("content-type")?.split(";", 1)[0].trim()166 .toLowerCase();167 if (type !== "application/json") {168 throw new NotifyHttpError(415, "application/json required");169 }170 const declared = request.headers.get("content-length");171 if (172 declared !== null &&173 (!/^\d+$/.test(declared) || Number(declared) > MAX_JSON_BYTES)174 ) {175 throw new NotifyHttpError(413, "request too large");176 }177 const bytes = new Uint8Array(await request.arrayBuffer());178 if (bytes.byteLength > MAX_JSON_BYTES) {179 throw new NotifyHttpError(413, "request too large");180 }181 try {182 const value: unknown = JSON.parse(new TextDecoder().decode(bytes));183 if (typeof value !== "object" || value === null || Array.isArray(value)) {184 throw new Error("not object");185 }186 return value as Record<string, unknown>;187 } catch {188 throw new NotifyHttpError(400, "invalid json");189 }190}191192function userAgentSummary(request: Request): string {193 return (request.headers.get("user-agent") ?? "unknown").replace(194 /[\u0000-\u001f\u007f]/g,195 " ",196 )197 .slice(0, 200);198}199200function deviceClass(userAgent: string): string {201 if (/iPhone|iPad|iPod/i.test(userAgent)) return "apple-mobile";202 if (/Android/i.test(userAgent)) return "android";203 if (/Mobile/i.test(userAgent)) return "mobile";204 return "desktop";205}206207function cfMetadata(208 request: Request,209): { country: string | null; region: string | null; asn: number | null } {210 const cf = request.cf;211 return {212 country: typeof cf?.country === "string" ? cf.country.slice(0, 2) : null,213 region: typeof cf?.regionCode === "string" ? cf.regionCode.slice(0, 8) : null,214 asn: typeof cf?.asn === "number" ? cf.asn : null,215 };216}217218function publicHeaders(extra: HeadersInit = {}): HeadersInit {219 return {220 "cache-control": "no-store",221 "vary": "Cookie",222 ...extra,223 };224}225226function publicJson(227 body: unknown,228 status = 200,229 headers: HeadersInit = {},230): Response {231 return json(body, status, publicHeaders(headers));232}233234async function rateLimit(235 binding: RateLimit | undefined,236 key: string,237): Promise<void> {238 if (!binding) return;239 const result = await binding.limit({ key });240 if (!result.success) {241 throw new NotifyHttpError(429, "rate limited", { "retry-after": "60" });242 }243}244245function clientKey(request: Request): string {246 return request.headers.get("cf-connecting-ip") ?? "local";247}248249export async function createEnrollment(250 request: Request,251 env: NotifyEnv,252): Promise<Response> {253 if (!isSameOriginBrowserRequest(request)) {254 throw new NotifyHttpError(403, "same-origin request required");255 }256 await rateLimit(env.RL_NOTIFY_ENROLL, clientKey(request));257 await readNotifyJsonObject(request);258259 const now = nowSeconds();260 await env.DB.prepare(261 "UPDATE notification_devices SET status = 'expired', pairing_code_hash = NULL, claim_hash = NULL WHERE status = 'pending' AND pairing_expires_at <= ?",262 ).bind(now).run();263264 const secret = signingSecret(env);265 const claim = randomBase64Url(32);266 const claimHash = await hmacHex(secret, "notify-claim", claim);267 const userAgent = userAgentSummary(request);268 const metadata = cfMetadata(request);269270 for (let attempt = 0; attempt < 8; attempt++) {271 const id = crypto.randomUUID();272 const code = generatePairingCode();273 const codeHash = await hmacHex(secret, "notify-code", code);274 try {275 const result = await env.DB.prepare(276 `INSERT INTO notification_devices277 (id, pairing_code_hash, claim_hash, status, user_agent_summary, device_class,278 country, region, asn, created_at, pairing_expires_at, claim_expires_at)279 SELECT ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?280 WHERE EXISTS (SELECT 1 FROM notification_pairing_window281 WHERE id = 1 AND enabled_until > ?)282 AND (SELECT COUNT(*) FROM notification_devices283 WHERE status = 'pending' AND pairing_expires_at > ?) < ?`,284 ).bind(285 id,286 codeHash,287 claimHash,288 userAgent,289 deviceClass(userAgent),290 metadata.country,291 metadata.region,292 metadata.asn,293 now,294 now + PENDING_SECONDS,295 now + PENDING_SECONDS,296 now,297 now,298 MAX_PENDING,299 ).run();300 if (result.meta.changes !== 1) {301 throw new NotifyHttpError(404, "pairing unavailable");302 }303 console.log(304 JSON.stringify({ event: "notify.enrollment_created", deviceId: id }),305 );306 return publicJson(307 { code, status: "pending", expiresAt: now + PENDING_SECONDS },308 201,309 { "set-cookie": claimCookie(claim, PENDING_SECONDS) },310 );311 } catch (error) {312 if (error instanceof NotifyHttpError) throw error;313 if (314 !/unique|constraint/i.test(315 error instanceof Error ? error.message : String(error),316 )317 ) throw error;318 }319 }320 throw new NotifyHttpError(503, "pairing temporarily unavailable", {321 "retry-after": "60",322 });323}324325/** Public state used to reveal the pairing action only during an owner-opened window. */326export async function getPairingWindow(327 request: Request,328 env: NotifyEnv,329): Promise<Response> {330 if (!isSameOriginBrowserRequest(request)) {331 throw new NotifyHttpError(403, "same-origin request required");332 }333 await rateLimit(env.RL_NOTIFY_READ, clientKey(request));334 const now = nowSeconds();335 const row = await env.DB.prepare(336 "SELECT enabled_until FROM notification_pairing_window WHERE id = 1 AND enabled_until > ?",337 ).bind(now).first<{ enabled_until: number }>();338 return publicJson({339 enabled: row !== null,340 expiresAt: row?.enabled_until ?? null,341 });342}343344/** Open a fixed, short pairing window from the owner-authenticated API. */345export async function openPairingWindow(env: NotifyEnv): Promise<Response> {346 const expiresAt = nowSeconds() + PAIRING_WINDOW_SECONDS;347 await env.DB.prepare(348 `INSERT INTO notification_pairing_window (id, enabled_until) VALUES (1, ?)349 ON CONFLICT(id) DO UPDATE SET enabled_until = excluded.enabled_until`,350 ).bind(expiresAt).run();351 console.log(JSON.stringify({ event: "notify.pairing_enabled", expiresAt }));352 return json({ enabled: true, expiresAt }, 200, {353 "cache-control": "no-store",354 });355}356357async function currentDevice(358 request: Request,359 env: NotifyEnv,360): Promise<{ row: DeviceRow; claim: string } | null> {361 const claim = readClaimCookie(request);362 if (!claim) return null;363 const claimHash = await hmacHex(signingSecret(env), "notify-claim", claim);364 const row = await env.DB.prepare(365 `SELECT id, status, name, user_agent_summary, device_class, country, region, asn,366 created_at, pairing_expires_at, claim_expires_at, approved_at, active_at,367 last_attached_at, last_seen_at, last_success_at, last_error,368 endpoint_hash, p256dh, auth369 FROM notification_devices WHERE claim_hash = ?`,370 ).bind(claimHash).first<DeviceRow>();371 return row ? { row, claim } : null;372}373374export async function authorizeNotificationClaim(375 request: Request,376 env: NotifyEnv,377): Promise<boolean> {378 const current = await currentDevice(request, env);379 const now = nowSeconds();380 return current !== null &&381 ["approved", "active"].includes(current.row.status) &&382 (current.row.claim_expires_at ?? 0) > now;383}384385function publicState(row: DeviceRow, now: number): Record<string, unknown> {386 if (row.status === "pending" && (row.pairing_expires_at ?? 0) <= now) {387 return { status: "expired" };388 }389 if (["revoked", "expired", "disabled"].includes(row.status)) {390 return { status: row.status };391 }392 return {393 status: row.status,394 paired: row.status === "approved" || row.status === "active",395 notifications: row.status === "active" ? "on" : "off",396 claimExpiresAt: row.claim_expires_at,397 };398}399400export async function getCurrentEnrollment(401 request: Request,402 env: NotifyEnv,403): Promise<Response> {404 if (!isSameOriginBrowserRequest(request)) {405 throw new NotifyHttpError(403, "same-origin request required");406 }407 const current = await currentDevice(request, env);408 if (!current) return publicJson({ status: "unpaired" });409 await rateLimit(env.RL_NOTIFY_READ, current.row.id);410 const now = nowSeconds();411 if (412 (current.row.claim_expires_at ?? 0) <= now &&413 current.row.status !== "pending"414 ) {415 return publicJson({ status: "expired" }, 200, {416 "set-cookie": clearClaimCookie(),417 });418 }419 return publicJson(publicState(current.row, now));420}421422export async function activateEnrollment(423 request: Request,424 env: NotifyEnv,425): Promise<Response> {426 if (!isSameOriginBrowserRequest(request)) {427 throw new NotifyHttpError(403, "same-origin request required");428 }429 await readNotifyJsonObject(request);430 const current = await currentDevice(request, env);431 const now = nowSeconds();432 if (433 !current || current.row.status !== "approved" ||434 (current.row.claim_expires_at ?? 0) <= now435 ) {436 throw new NotifyHttpError(404, "pairing unavailable");437 }438 const expiry = now + CLAIM_SECONDS;439 const result = await env.DB.prepare(440 "UPDATE notification_devices SET claim_expires_at = ?, last_seen_at = ? WHERE id = ? AND status = 'approved'",441 ).bind(expiry, now, current.row.id).run();442 if (result.meta.changes !== 1) {443 throw new NotifyHttpError(404, "pairing unavailable");444 }445 return publicJson(446 {447 status: "approved",448 paired: true,449 notifications: "off",450 claimExpiresAt: expiry,451 },452 200,453 { "set-cookie": claimCookie(current.claim, CLAIM_SECONDS) },454 );455}456457export async function renewEnrollment(458 request: Request,459 env: NotifyEnv,460): Promise<Response> {461 if (!isSameOriginBrowserRequest(request)) {462 throw new NotifyHttpError(403, "same-origin request required");463 }464 await readNotifyJsonObject(request);465 const current = await currentDevice(request, env);466 const now = nowSeconds();467 if (468 !current || !["approved", "active"].includes(current.row.status) ||469 (current.row.claim_expires_at ?? 0) <= now470 ) {471 throw new NotifyHttpError(404, "pairing unavailable");472 }473 const remaining = (current.row.claim_expires_at ?? 0) - now;474 if (remaining >= RENEW_WINDOW_SECONDS) {475 return publicJson(publicState(current.row, now));476 }477 const expiry = now + CLAIM_SECONDS;478 await env.DB.prepare(479 "UPDATE notification_devices SET claim_expires_at = ?, last_seen_at = ? WHERE id = ? AND status IN ('approved', 'active')",480 ).bind(expiry, now, current.row.id).run();481 return publicJson(482 { ...publicState(current.row, now), claimExpiresAt: expiry },483 200,484 { "set-cookie": claimCookie(current.claim, CLAIM_SECONDS) },485 );486}487488interface SubscriptionBody {489 endpoint: string;490 expirationTime: number | null;491 keys: { p256dh: string; auth: string };492}493494export function validateSubscriptionEndpoint(495 endpoint: string,496 env: NotifyEnv,497): URL {498 try {499 return validateWebPushEndpoint(env, endpoint);500 } catch (_error) {501 throw new NotifyHttpError(400, "invalid subscription");502 }503}504505function validSubscription(506 value: Record<string, unknown>,507): SubscriptionBody | null {508 const endpoint = boundedText(value.endpoint, 2048);509 const keys = value.keys;510 const expirationTime = value.expirationTime;511 if (512 !endpoint || typeof keys !== "object" || keys === null ||513 Array.isArray(keys) ||514 !(expirationTime === null ||515 (typeof expirationTime === "number" &&516 Number.isSafeInteger(expirationTime)))517 ) {518 return null;519 }520 const typedKeys = keys as Record<string, unknown>;521 const p256dh = boundedText(typedKeys.p256dh, 256);522 const auth = boundedText(typedKeys.auth, 128);523 if (524 !p256dh || !auth || !/^[A-Za-z0-9_-]+$/.test(p256dh) ||525 !/^[A-Za-z0-9_-]+$/.test(auth)526 ) {527 return null;528 }529 return { endpoint, expirationTime, keys: { p256dh, auth } };530}531532export async function attachSubscription(533 request: Request,534 env: NotifyEnv,535): Promise<Response> {536 if (!isSameOriginBrowserRequest(request)) {537 throw new NotifyHttpError(403, "same-origin request required");538 }539 const body = validSubscription(await readNotifyJsonObject(request));540 if (!body) throw new NotifyHttpError(400, "invalid subscription");541 const endpoint = validateSubscriptionEndpoint(body.endpoint, env).toString();542 const current = await currentDevice(request, env);543 const now = nowSeconds();544 if (545 !current || !["approved", "active"].includes(current.row.status) ||546 (current.row.claim_expires_at ?? 0) <= now547 ) {548 throw new NotifyHttpError(404, "pairing unavailable");549 }550 const endpointHash = await hmacHex(551 signingSecret(env),552 "notify-endpoint",553 endpoint,554 );555 if (556 current.row.endpoint_hash === endpointHash &&557 current.row.p256dh === body.keys.p256dh &&558 current.row.auth === body.keys.auth559 ) {560 await env.DB.prepare(561 "UPDATE notification_devices SET last_seen_at = ? WHERE id = ?",562 ).bind(563 now,564 current.row.id,565 ).run();566 } else {567 await env.DB.batch([568 env.DB.prepare(569 `UPDATE notification_devices SET status = 'disabled', endpoint = NULL,570 endpoint_hash = NULL, p256dh = NULL, auth = NULL, last_error = 'endpoint_reassigned'571 WHERE endpoint_hash = ? AND id <> ?`,572 ).bind(endpointHash, current.row.id),573 env.DB.prepare(574 `UPDATE notification_devices SET status = 'active', endpoint = ?, endpoint_hash = ?,575 p256dh = ?, auth = ?, active_at = COALESCE(active_at, ?), last_attached_at = ?,576 last_seen_at = ?, last_error = NULL577 WHERE id = ? AND status IN ('approved', 'active')`,578 ).bind(579 endpoint,580 endpointHash,581 body.keys.p256dh,582 body.keys.auth,583 now,584 now,585 now,586 current.row.id,587 ),588 ]);589 }590 console.log(591 JSON.stringify({592 event: "notify.subscription_attached",593 deviceId: current.row.id,594 }),595 );596 return publicJson({ status: "active", paired: true, notifications: "on" });597}598599export async function detachSubscription(600 request: Request,601 env: NotifyEnv,602): Promise<Response> {603 if (!isSameOriginBrowserRequest(request)) {604 throw new NotifyHttpError(403, "same-origin request required");605 }606 const current = await currentDevice(request, env);607 const now = nowSeconds();608 if (609 !current || !["approved", "active"].includes(current.row.status) ||610 (current.row.claim_expires_at ?? 0) <= now611 ) {612 throw new NotifyHttpError(404, "pairing unavailable");613 }614 await env.DB.prepare(615 `UPDATE notification_devices SET status = 'approved', endpoint = NULL,616 endpoint_hash = NULL, p256dh = NULL, auth = NULL, last_seen_at = ?, last_error = NULL617 WHERE id = ? AND status IN ('approved', 'active')`,618 ).bind(now, current.row.id).run();619 return publicJson({ status: "approved", paired: true, notifications: "off" });620}621622async function codeHash(env: NotifyEnv, raw: string): Promise<string | null> {623 const code = normalizePairingCode(raw);624 return code ? await hmacHex(signingSecret(env), "notify-code", code) : null;625}626627function genericApprovalError(): never {628 throw new NotifyHttpError(404, "pairing code unavailable");629}630631function ownerDevice(row: DeviceRow): Record<string, unknown> {632 return {633 id: row.id,634 name: row.name,635 status: row.status,636 createdAt: row.created_at,637 pairingExpiresAt: row.pairing_expires_at,638 claimExpiresAt: row.claim_expires_at,639 approvedAt: row.approved_at,640 deviceClass: row.device_class,641 userAgentSummary: row.user_agent_summary,642 country: row.country,643 region: row.region,644 asn: row.asn,645 activeAt: row.active_at,646 lastSeenAt: row.last_seen_at,647 lastAttachedAt: row.last_attached_at,648 lastSuccessAt: row.last_success_at,649 lastError: row.last_error,650 };651}652653export async function previewApproval(654 rawCode: string,655 env: NotifyEnv,656): Promise<Response> {657 const hash = await codeHash(env, rawCode);658 if (!hash) return genericApprovalError();659 const now = nowSeconds();660 const row = await env.DB.prepare(661 `SELECT id, status, name, user_agent_summary, device_class, country, region, asn,662 created_at, pairing_expires_at, claim_expires_at, approved_at, active_at,663 last_attached_at, last_seen_at, last_success_at, last_error664 FROM notification_devices665 WHERE pairing_code_hash = ? AND status = 'pending' AND pairing_expires_at > ?`,666 ).bind(hash, now).first<DeviceRow>();667 if (!row) return genericApprovalError();668 return json(669 {670 createdAt: row.created_at,671 userAgentSummary: row.user_agent_summary,672 deviceClass: row.device_class,673 country: row.country,674 region: row.region,675 asn: row.asn,676 },677 200,678 { "cache-control": "no-store" },679 );680}681682export async function approveEnrollment(683 request: Request,684 env: NotifyEnv,685): Promise<Response> {686 const body = await readNotifyJsonObject(request);687 const rawCode = boundedText(body.code, 32);688 const name = boundedText(body.name, 80);689 if (!rawCode || !name) {690 throw new NotifyHttpError(400, "code and name required");691 }692 const hash = await codeHash(env, rawCode);693 if (!hash) return genericApprovalError();694 const now = nowSeconds();695 const pending = await env.DB.prepare(696 "SELECT id FROM notification_devices WHERE pairing_code_hash = ? AND status = 'pending' AND pairing_expires_at > ?",697 ).bind(hash, now).first<{ id: string }>();698 if (!pending) return genericApprovalError();699 const result = await env.DB.prepare(700 `UPDATE notification_devices SET status = 'approved', name = ?, pairing_code_hash = NULL,701 approved_at = ?, claim_expires_at = ?702 WHERE id = ? AND pairing_code_hash = ? AND status = 'pending' AND pairing_expires_at > ?`,703 ).bind(name, now, now + APPROVED_SETUP_SECONDS, pending.id, hash, now).run();704 if (result.meta.changes !== 1) return genericApprovalError();705 const row = await env.DB.prepare(706 `SELECT id, status, name, user_agent_summary, device_class, country, region, asn,707 created_at, pairing_expires_at, claim_expires_at, approved_at, active_at,708 last_attached_at, last_seen_at, last_success_at, last_error709 FROM notification_devices WHERE id = ?`,710 ).bind(pending.id).first<DeviceRow>();711 if (!row) throw new NotifyHttpError(500, "internal error");712 console.log(JSON.stringify({ event: "notify.device_approved" }));713 return json(ownerDevice(row), 200, { "cache-control": "no-store" });714}715716export async function listNotificationDevices(717 env: NotifyEnv,718): Promise<Response> {719 const rows = await env.DB.prepare(720 `SELECT id, status, name, user_agent_summary, device_class, country, region, asn,721 created_at, pairing_expires_at, claim_expires_at, approved_at, active_at,722 last_attached_at, last_seen_at, last_success_at, last_error723 FROM notification_devices ORDER BY created_at DESC LIMIT 100`,724 ).all<DeviceRow>();725 return json(rows.results.map(ownerDevice), 200, {726 "cache-control": "no-store",727 });728}729730export async function revokeNotificationDevice(731 id: string,732 env: NotifyEnv,733): Promise<Response> {734 if (!/^[0-9a-f-]{36}$/i.test(id)) {735 throw new NotifyHttpError(404, "device not found");736 }737 const result = await env.DB.prepare(738 `UPDATE notification_devices SET status = 'revoked', pairing_code_hash = NULL,739 claim_hash = NULL, endpoint = NULL, endpoint_hash = NULL, p256dh = NULL, auth = NULL,740 last_error = NULL, last_seen_at = ? WHERE id = ? AND status <> 'revoked'`,741 ).bind(Math.floor(Date.now() / 1000), id).run();742 if (result.meta.changes !== 1) {743 throw new NotifyHttpError(404, "device not found");744 }745 console.log(JSON.stringify({ event: "notify.device_revoked", deviceId: id }));746 return json({ ok: true, deviceId: id }, 200, { "cache-control": "no-store" });747}748749export function notifyErrorResponse(error: unknown): Response {750 if (error instanceof NotifyHttpError) {751 return json({ error: error.message }, error.status, {752 "cache-control": "no-store",753 ...error.headers,754 });755 }756 console.error(757 JSON.stringify({ event: "notify.request_failed", error: "internal" }),758 );759 return json({ error: "internal error" }, 500, {760 "cache-control": "no-store",761 });762}