diff --git a/cloudflare/src/devices.ts b/cloudflare/src/devices.ts new file mode 100644 index 0000000..222d7b6 --- /dev/null +++ b/cloudflare/src/devices.ts @@ -0,0 +1,138 @@ +import type { AuthContext } from "./auth.js"; +import type { Env } from "./bindings.js"; + +const DEVICE_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,128}$/; +const PUBLIC_KEY_PATTERN = /^[a-fA-F0-9]{64,256}$/; +const DEVICE_TEXT_PATTERN = /^[^\p{Cc}\p{Cs}]{1,128}$/u; +const APPROVAL_STATUS = new Set(["pending", "approved", "revoked"]); + +const DEVICE_LIST_QUERY = ` + SELECT + device_id, + public_key, + device_name, + platform, + approval_status, + created_at, + approved_at, + last_active_at, + revoked_at + FROM user_devices + WHERE user_id = ? + ORDER BY + revoked_at IS NOT NULL, + COALESCE(last_active_at, approved_at, created_at) DESC, + device_id ASC +`; + +export interface DeviceListDocument { + version: 1; + user_id: string; + devices: DeviceDocument[]; +} + +export interface DeviceDocument { + device_id: string; + public_key: string; + device_name: string; + platform: string; + approval_status: "pending" | "approved" | "revoked"; + created_at: number; + approved_at: number | null; + last_active_at: number | null; + revoked_at: number | null; + current: boolean; +} + +interface DeviceRow { + device_id: unknown; + public_key: unknown; + device_name: unknown; + platform: unknown; + approval_status: unknown; + created_at: unknown; + approved_at: unknown; + last_active_at: unknown; + revoked_at: unknown; +} + +export class DeviceSchemaError extends Error { + constructor(message: string) { + super(message); + this.name = "DeviceSchemaError"; + } +} + +export async function deviceListDocument( + env: Env, + context: AuthContext, +): Promise { + const result = await env.ELY_DB.prepare(DEVICE_LIST_QUERY).bind(context.userId).all(); + return { + version: 1, + user_id: context.userId, + devices: result.results.map((row) => deviceDocument(row, context)), + }; +} + +function deviceDocument(row: DeviceRow, context: AuthContext): DeviceDocument { + const deviceId = deviceIdValue(row.device_id, "device_id"); + return { + device_id: deviceId, + public_key: publicKeyValue(row.public_key), + device_name: deviceText(row.device_name, "device_name"), + platform: deviceText(row.platform, "platform"), + approval_status: approvalStatus(row.approval_status), + created_at: timestamp(row.created_at, "created_at"), + approved_at: nullableTimestamp(row.approved_at, "approved_at"), + last_active_at: nullableTimestamp(row.last_active_at, "last_active_at"), + revoked_at: nullableTimestamp(row.revoked_at, "revoked_at"), + current: context.deviceId === deviceId, + }; +} + +function deviceIdValue(value: unknown, label: string): string { + if (typeof value !== "string" || !DEVICE_ID_PATTERN.test(value)) { + throw new DeviceSchemaError(`${label}_invalid`); + } + return value; +} + +function publicKeyValue(value: unknown): string { + if (typeof value !== "string" || !PUBLIC_KEY_PATTERN.test(value)) { + throw new DeviceSchemaError("public_key_invalid"); + } + return value.toLowerCase(); +} + +function deviceText(value: unknown, label: string): string { + if (typeof value !== "string") { + throw new DeviceSchemaError(`${label}_invalid`); + } + const trimmed = value.trim(); + if (!DEVICE_TEXT_PATTERN.test(trimmed)) { + throw new DeviceSchemaError(`${label}_invalid`); + } + return trimmed; +} + +function approvalStatus(value: unknown): DeviceDocument["approval_status"] { + if (typeof value !== "string" || !APPROVAL_STATUS.has(value)) { + throw new DeviceSchemaError("approval_status_invalid"); + } + return value as DeviceDocument["approval_status"]; +} + +function nullableTimestamp(value: unknown, label: string): number | null { + if (value === null) { + return null; + } + return timestamp(value, label); +} + +function timestamp(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + throw new DeviceSchemaError(`${label}_invalid`); + } + return value; +} diff --git a/cloudflare/src/index.ts b/cloudflare/src/index.ts index 9db8a40..4d2c712 100644 --- a/cloudflare/src/index.ts +++ b/cloudflare/src/index.ts @@ -1,5 +1,6 @@ import type { Env } from "./bindings.js"; -import { withPublicApiControls } from "./api_controls.js"; +import { withAuthenticatedApiControls, withPublicApiControls } from "./api_controls.js"; +import { DeviceSchemaError, deviceListDocument } from "./devices.js"; import { PluginRegistrySchemaError, parsePluginRegistryDocument, @@ -32,6 +33,20 @@ export default { export async function handleRequest(request: Request, env: Env): Promise { const url = new URL(request.url); + if (url.pathname === "/api/devices") { + return withAuthenticatedApiControls(request, env, "devices.list", ["GET"], async (context) => { + try { + return jsonResponse(await deviceListDocument(env, context), 200, { + "Cache-Control": "no-store", + }); + } catch (error) { + if (error instanceof DeviceSchemaError) { + return jsonResponse({ error: "devices_invalid" }, 500, { "Cache-Control": "no-store" }); + } + throw error; + } + }); + } if (url.pathname === "/api/plugins/signing-keys") { return withPublicApiControls(request, env, "plugins.signing_keys", ["GET"], () => handlePublicSigningKeys(env), diff --git a/cloudflare/tests/devices_routes.test.ts b/cloudflare/tests/devices_routes.test.ts new file mode 100644 index 0000000..a7ab4c7 --- /dev/null +++ b/cloudflare/tests/devices_routes.test.ts @@ -0,0 +1,258 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import type { + ElyAnalyticsDataPoint, + ElyD1Database, + ElyD1PreparedStatement, + ElyR2PutOptions, + Env, +} from "../src/bindings.js"; +import { authSessionCacheKvKey, authTokenHash } from "../src/auth.js"; +import { handleRequest } from "../src/index.js"; + +const ACCESS_TOKEN = "D".repeat(48); +const PUBLIC_KEY = "a".repeat(64); + +describe("device routes", () => { + it("returns authenticated user devices from D1", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const auditEvents: ElyAnalyticsDataPoint[] = []; + const d1 = testD1Database([ + { + device_id: "device-01", + public_key: PUBLIC_KEY, + device_name: "MacBook Pro", + platform: "macOS", + approval_status: "approved", + created_at: 1_780_000_000, + approved_at: 1_780_000_010, + last_active_at: 1_780_000_020, + revoked_at: null, + }, + { + device_id: "device-02", + public_key: PUBLIC_KEY, + device_name: "iMac", + platform: "macOS", + approval_status: "revoked", + created_at: 1_770_000_000, + approved_at: 1_770_000_010, + last_active_at: 1_770_000_020, + revoked_at: 1_770_000_030, + }, + ]); + + const response = await handleRequest( + new Request("https://elydora.test/api/devices", { + headers: { authorization: `Bearer ${ACCESS_TOKEN}`, "cf-ray": "ray-devices" }, + }), + testEnv({ + auditEvents, + d1, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument()]], + }), + ); + + assert.equal(response.status, 200); + assert.equal(response.headers.get("cache-control"), "no-store"); + assert.deepEqual(await response.json(), { + version: 1, + user_id: "user-01", + devices: [ + { + device_id: "device-01", + public_key: PUBLIC_KEY, + device_name: "MacBook Pro", + platform: "macOS", + approval_status: "approved", + created_at: 1_780_000_000, + approved_at: 1_780_000_010, + last_active_at: 1_780_000_020, + revoked_at: null, + current: true, + }, + { + device_id: "device-02", + public_key: PUBLIC_KEY, + device_name: "iMac", + platform: "macOS", + approval_status: "revoked", + created_at: 1_770_000_000, + approved_at: 1_770_000_010, + last_active_at: 1_770_000_020, + revoked_at: 1_770_000_030, + current: false, + }, + ], + }); + assert.deepEqual(d1.binds, [["user-01"]]); + assert.ok(d1.queries[0]?.includes("FROM user_devices")); + assert.deepEqual(auditEvents[0]?.blobs?.slice(0, 8), [ + "devices.list", + "GET", + "/api/devices", + "handled", + "ray-devices", + "", + "user-01", + "device-01", + ]); + }); + + it("rejects unauthenticated device list requests before D1 reads", async () => { + const d1 = testD1Database([]); + const response = await handleRequest( + new Request("https://elydora.test/api/devices"), + testEnv({ d1 }), + ); + + assert.equal(response.status, 401); + assert.deepEqual(await response.json(), { error: "authorization_missing" }); + assert.deepEqual(d1.queries, []); + }); + + it("rejects unsupported device list methods before session reads", async () => { + const d1 = testD1Database([]); + const kvReads: string[] = []; + const response = await handleRequest( + new Request("https://elydora.test/api/devices", { method: "POST" }), + testEnv({ d1, kvReads }), + ); + + assert.equal(response.status, 405); + assert.equal(response.headers.get("allow"), "GET"); + assert.deepEqual(await response.json(), { error: "method_not_allowed" }); + assert.deepEqual(kvReads, []); + assert.deepEqual(d1.queries, []); + }); + + it("returns a generic server error for malformed D1 device rows", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const response = await handleRequest( + new Request("https://elydora.test/api/devices", { + headers: { authorization: `Bearer ${ACCESS_TOKEN}` }, + }), + testEnv({ + d1: testD1Database([ + { + device_id: "device-01", + public_key: PUBLIC_KEY, + device_name: "MacBook Pro", + platform: "macOS", + approval_status: "deleted", + created_at: 1_780_000_000, + approved_at: null, + last_active_at: null, + revoked_at: null, + }, + ]), + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument()]], + }), + ); + + assert.equal(response.status, 500); + assert.deepEqual(await response.json(), { error: "devices_invalid" }); + }); +}); + +interface TestEnvOptions { + auditEvents?: ElyAnalyticsDataPoint[]; + d1?: RecordedD1Database; + kvEntries?: [string, string][]; + kvReads?: string[]; +} + +interface RecordedD1Database extends ElyD1Database { + binds: unknown[][]; + queries: string[]; +} + +function testEnv(options: TestEnvOptions): Env { + const values = new Map(options.kvEntries ?? []); + return { + ELY_ENVIRONMENT: "local", + ELY_DB: options.d1 ?? testD1Database([]), + ELY_KV: { + get(key: string): Promise { + options.kvReads?.push(key); + return Promise.resolve(values.get(key) ?? null); + }, + }, + ELY_STORAGE: testR2Bucket(), + ELY_RATE_LIMITER: { + limit(): Promise<{ success: boolean }> { + return Promise.resolve({ success: true }); + }, + }, + ELY_API_AUDIT: { + writeDataPoint(event?: ElyAnalyticsDataPoint): void { + if (event !== undefined) { + options.auditEvents?.push(event); + } + }, + }, + }; +} + +function testD1Database(rows: unknown[]): RecordedD1Database { + const binds: unknown[][] = []; + const queries: string[] = []; + return { + binds, + queries, + prepare(query: string) { + queries.push(query); + return testD1PreparedStatement(rows, binds); + }, + batch() { + return Promise.resolve([]); + }, + exec() { + return Promise.resolve({}); + }, + }; +} + +function testD1PreparedStatement(rows: unknown[], binds: unknown[][]): ElyD1PreparedStatement { + return { + bind(...values: unknown[]) { + binds.push(values); + return this; + }, + first() { + return Promise.resolve(null); + }, + all() { + return Promise.resolve({ results: rows as T[] }); + }, + run() { + return Promise.resolve({}); + }, + }; +} + +function testR2Bucket(): Env["ELY_STORAGE"] { + return { + get() { + return Promise.resolve(null); + }, + put(_key: string, value: ArrayBuffer, _options?: ElyR2PutOptions) { + return Promise.resolve({ + arrayBuffer() { + return Promise.resolve(value); + }, + }); + }, + }; +} + +function sessionDocument(): string { + return JSON.stringify({ + version: 1, + user_id: "user-01", + session_id: "session-01", + device_id: "device-01", + expires_at: "2099-01-01T00:00:00.000Z", + }); +}