diff --git a/cloudflare/src/account_deletion.ts b/cloudflare/src/account_deletion.ts new file mode 100644 index 0000000..1f93e96 --- /dev/null +++ b/cloudflare/src/account_deletion.ts @@ -0,0 +1,373 @@ +import type { AuthContext } from "./auth.js"; +import { authSessionCacheKvKey } from "./auth.js"; +import type { ElyD1PreparedStatement, Env } from "./bindings.js"; +import { StorageObjectError, deleteKnownObject } from "./storage.js"; + +const ACCOUNT_DELETION_CONFIRMATION = "delete-elydora-account"; +const IDEMPOTENCY_KEY_PATTERN = /^[a-zA-Z0-9._:-]{16,128}$/; +const ACCOUNT_DELETION_EVENT_QUERY = ` + SELECT actor_device_id, outcome, subject_id, created_at + FROM audit_events + WHERE event_id = ? AND event_type = 'account.delete' +`; +const ACCOUNT_DELETION_COUNTS_QUERY = ` + SELECT + (SELECT COUNT(*) FROM user_devices WHERE user_id = ?) AS devices, + (SELECT COUNT(*) FROM device_approvals WHERE user_id = ?) AS approvals, + (SELECT COUNT(*) FROM sync_objects WHERE user_id = ?) AS sync_objects, + (SELECT COUNT(*) FROM sync_change_log WHERE user_id = ?) AS sync_changes, + (SELECT COUNT(*) FROM sync_snapshots WHERE user_id = ?) AS sync_snapshots, + (SELECT COUNT(*) FROM sync_tombstones WHERE user_id = ?) AS sync_tombstones, + (SELECT COUNT(*) FROM audit_events WHERE user_id = ?) AS audit_events, + (SELECT COUNT(*) FROM better_auth_session_device_context WHERE user_id = ?) AS session_device_contexts, + (SELECT COUNT(*) FROM better_auth_session WHERE userId = ?) AS sessions, + (SELECT COUNT(*) FROM better_auth_account WHERE userId = ?) AS accounts, + (SELECT COUNT(*) FROM better_auth_user WHERE id = ?) AS users +`; +const ACCOUNT_DELETION_R2_KEYS_QUERY = ` + SELECT payload_r2_key AS r2_key + FROM sync_objects + WHERE user_id = ? AND payload_r2_key IS NOT NULL + UNION + SELECT r2_key + FROM sync_snapshots + WHERE user_id = ? + ORDER BY r2_key ASC +`; +const DELETE_SYNC_CHANGE_LOG_QUERY = "DELETE FROM sync_change_log WHERE user_id = ?"; +const DELETE_SYNC_TOMBSTONES_QUERY = "DELETE FROM sync_tombstones WHERE user_id = ?"; +const DELETE_SYNC_SNAPSHOTS_QUERY = "DELETE FROM sync_snapshots WHERE user_id = ?"; +const DELETE_SYNC_OBJECTS_QUERY = "DELETE FROM sync_objects WHERE user_id = ?"; +const DELETE_DEVICE_APPROVALS_QUERY = "DELETE FROM device_approvals WHERE user_id = ?"; +const DELETE_USER_DEVICES_QUERY = "DELETE FROM user_devices WHERE user_id = ?"; +const DELETE_SESSION_DEVICE_CONTEXTS_QUERY = + "DELETE FROM better_auth_session_device_context WHERE user_id = ?"; +const DELETE_BETTER_AUTH_SESSIONS_QUERY = "DELETE FROM better_auth_session WHERE userId = ?"; +const DELETE_BETTER_AUTH_ACCOUNTS_QUERY = "DELETE FROM better_auth_account WHERE userId = ?"; +const DELETE_BETTER_AUTH_USER_QUERY = "DELETE FROM better_auth_user WHERE id = ?"; +const DELETE_USER_AUDIT_EVENTS_QUERY = "DELETE FROM audit_events WHERE user_id = ?"; +const ACCOUNT_DELETION_AUDIT_INSERT_QUERY = ` + INSERT INTO audit_events ( + event_id, + user_id, + actor_device_id, + event_type, + subject_type, + subject_id, + outcome, + metadata_hash, + created_at + ) VALUES (?, NULL, ?, 'account.delete', 'account', ?, 'success', ?, ?) + ON CONFLICT(event_id) DO NOTHING +`; + +export interface AccountDeletionDocument { + version: 1; + account_hash: string; + device_id: string; + idempotency_key: string; + deleted_at: number; + deleted: AccountDeletionDeletedDocument; +} + +export interface AccountDeletionDeletedDocument { + devices: number; + approvals: number; + sync_objects: number; + sync_changes: number; + sync_snapshots: number; + sync_tombstones: number; + audit_events: number; + session_device_contexts: number; + sessions: number; + accounts: number; + users: number; + r2_objects: number; + kv_session_cache: number; +} + +interface AccountDeletionRequest { + idempotencyKey: string; +} + +interface AccountDeletionEventRow { + actor_device_id: unknown; + outcome: unknown; + subject_id: unknown; + created_at: unknown; +} + +interface AccountDeletionCountsRow { + devices: unknown; + approvals: unknown; + sync_objects: unknown; + sync_changes: unknown; + sync_snapshots: unknown; + sync_tombstones: unknown; + audit_events: unknown; + session_device_contexts: unknown; + sessions: unknown; + accounts: unknown; + users: unknown; +} + +interface AccountDeletionR2KeyRow { + r2_key: unknown; +} + +type RequestBody = Record; + +export class AccountDeletionRequestError extends Error { + constructor(message: string) { + super(message); + this.name = "AccountDeletionRequestError"; + } +} + +export class AccountDeletionPersistenceError extends Error { + constructor(message: string) { + super(message); + this.name = "AccountDeletionPersistenceError"; + } +} + +export async function accountDeletionDocument( + request: Request, + env: Env, + context: AuthContext, + nowSeconds = Math.floor(Date.now() / 1000), +): Promise { + const deviceId = currentDeviceId(context); + const deletion = await accountDeletionRequest(request); + const accountHash = await sha256Hex(textBytes(context.userId)); + const idempotencyHash = await sha256Hex(textBytes(deletion.idempotencyKey)); + const eventId = accountDeletionEventId(accountHash, idempotencyHash); + const existingEvent = await env.ELY_DB.prepare(ACCOUNT_DELETION_EVENT_QUERY) + .bind(eventId) + .first(); + if (existingEvent !== null) { + return existingDeletionDocument(accountHash, deviceId, deletion, existingEvent); + } + + const counts = await accountDeletionCounts(env, context.userId); + const r2Keys = await accountDeletionR2Keys(env, context.userId); + for (const key of r2Keys) { + await deleteAccountObject(env, key); + } + + await env.ELY_DB.batch( + accountDeletionStatements( + env, + context.userId, + deviceId, + accountHash, + idempotencyHash, + eventId, + nowSeconds, + ), + ); + await deleteCurrentSessionCache(env, context.tokenHash); + + return { + version: 1, + account_hash: accountHash, + device_id: deviceId, + idempotency_key: deletion.idempotencyKey, + deleted_at: nowSeconds, + deleted: { ...counts, r2_objects: r2Keys.length, kv_session_cache: 1 }, + }; +} + +function existingDeletionDocument( + accountHash: string, + deviceId: string, + deletion: AccountDeletionRequest, + row: AccountDeletionEventRow, +): AccountDeletionDocument { + if ( + row.actor_device_id !== deviceId || + row.outcome !== "success" || + row.subject_id !== accountHash + ) { + throw new AccountDeletionRequestError("account_deletion_replay_mismatch"); + } + return { + version: 1, + account_hash: accountHash, + device_id: deviceId, + idempotency_key: deletion.idempotencyKey, + deleted_at: integer(row.created_at, "created_at"), + deleted: emptyDeletedDocument(), + }; +} + +async function accountDeletionCounts( + env: Env, + userId: string, +): Promise> { + const row = await env.ELY_DB.prepare(ACCOUNT_DELETION_COUNTS_QUERY) + .bind(userId, userId, userId, userId, userId, userId, userId, userId, userId, userId, userId) + .first(); + if (row === null) { + throw new AccountDeletionPersistenceError("account_deletion_counts_missing"); + } + return { + devices: integer(row.devices, "devices"), + approvals: integer(row.approvals, "approvals"), + sync_objects: integer(row.sync_objects, "sync_objects"), + sync_changes: integer(row.sync_changes, "sync_changes"), + sync_snapshots: integer(row.sync_snapshots, "sync_snapshots"), + sync_tombstones: integer(row.sync_tombstones, "sync_tombstones"), + audit_events: integer(row.audit_events, "audit_events"), + session_device_contexts: integer(row.session_device_contexts, "session_device_contexts"), + sessions: integer(row.sessions, "sessions"), + accounts: integer(row.accounts, "accounts"), + users: integer(row.users, "users"), + }; +} + +async function accountDeletionR2Keys(env: Env, userId: string): Promise { + const result = await env.ELY_DB.prepare(ACCOUNT_DELETION_R2_KEYS_QUERY) + .bind(userId, userId) + .all(); + return result.results.map(r2Key); +} + +function accountDeletionStatements( + env: Env, + userId: string, + deviceId: string, + accountHash: string, + idempotencyHash: string, + eventId: string, + nowSeconds: number, +): ElyD1PreparedStatement[] { + return [ + env.ELY_DB.prepare(DELETE_SYNC_CHANGE_LOG_QUERY).bind(userId), + env.ELY_DB.prepare(DELETE_SYNC_TOMBSTONES_QUERY).bind(userId), + env.ELY_DB.prepare(DELETE_SYNC_SNAPSHOTS_QUERY).bind(userId), + env.ELY_DB.prepare(DELETE_SYNC_OBJECTS_QUERY).bind(userId), + env.ELY_DB.prepare(DELETE_DEVICE_APPROVALS_QUERY).bind(userId), + env.ELY_DB.prepare(DELETE_SESSION_DEVICE_CONTEXTS_QUERY).bind(userId), + env.ELY_DB.prepare(DELETE_USER_DEVICES_QUERY).bind(userId), + env.ELY_DB.prepare(DELETE_BETTER_AUTH_SESSIONS_QUERY).bind(userId), + env.ELY_DB.prepare(DELETE_BETTER_AUTH_ACCOUNTS_QUERY).bind(userId), + env.ELY_DB.prepare(DELETE_BETTER_AUTH_USER_QUERY).bind(userId), + env.ELY_DB.prepare(DELETE_USER_AUDIT_EVENTS_QUERY).bind(userId), + env.ELY_DB.prepare(ACCOUNT_DELETION_AUDIT_INSERT_QUERY).bind( + eventId, + deviceId, + accountHash, + idempotencyHash, + nowSeconds, + ), + ]; +} + +async function deleteAccountObject(env: Env, key: string): Promise { + try { + await deleteKnownObject(env.ELY_STORAGE, key); + } catch (error) { + if (error instanceof StorageObjectError) { + throw new AccountDeletionPersistenceError(error.message); + } + throw error; + } +} + +function deleteCurrentSessionCache(env: Env, tokenHash: string): Promise { + return env.ELY_KV.delete(authSessionCacheKvKey(env.ELY_ENVIRONMENT, tokenHash)); +} + +async function accountDeletionRequest(request: Request): Promise { + const body = await requestBody(request); + assertOnlyFields(body, ["version", "confirmation", "idempotency_key"]); + if (body.version !== 1) { + throw new AccountDeletionRequestError("version_invalid"); + } + if (body.confirmation !== ACCOUNT_DELETION_CONFIRMATION) { + throw new AccountDeletionRequestError("confirmation_invalid"); + } + return { idempotencyKey: idempotencyKey(body.idempotency_key) }; +} + +async function requestBody(request: Request): Promise { + let value: unknown; + try { + value = await request.json(); + } catch { + throw new AccountDeletionRequestError("json_invalid"); + } + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new AccountDeletionRequestError("body_invalid"); + } + return value as RequestBody; +} + +function assertOnlyFields(value: RequestBody, fields: string[]): void { + const allowed = new Set(fields); + for (const field of Object.keys(value)) { + if (!allowed.has(field)) { + throw new AccountDeletionRequestError(`unexpected_field:${field}`); + } + } +} + +function currentDeviceId(context: AuthContext): string { + if (context.deviceId === undefined) { + throw new AccountDeletionRequestError("device_context_required"); + } + return context.deviceId; +} + +function idempotencyKey(value: unknown): string { + if (typeof value !== "string" || !IDEMPOTENCY_KEY_PATTERN.test(value)) { + throw new AccountDeletionRequestError("idempotency_key_invalid"); + } + return value; +} + +function r2Key(row: AccountDeletionR2KeyRow): string { + if (typeof row.r2_key !== "string") { + throw new AccountDeletionPersistenceError("r2_key_invalid"); + } + return row.r2_key; +} + +function integer(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + throw new AccountDeletionPersistenceError(`${label}_invalid`); + } + return value; +} + +function emptyDeletedDocument(): AccountDeletionDeletedDocument { + return { + devices: 0, + approvals: 0, + sync_objects: 0, + sync_changes: 0, + sync_snapshots: 0, + sync_tombstones: 0, + audit_events: 0, + session_device_contexts: 0, + sessions: 0, + accounts: 0, + users: 0, + r2_objects: 0, + kv_session_cache: 0, + }; +} + +function accountDeletionEventId(accountHash: string, idempotencyHash: string): string { + return `account-delete:${accountHash}:${idempotencyHash}`; +} + +function textBytes(value: string): Uint8Array { + return new TextEncoder().encode(value); +} + +async function sha256Hex(payload: Uint8Array): Promise { + const digest = await crypto.subtle.digest("SHA-256", payload); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} diff --git a/cloudflare/src/bindings.ts b/cloudflare/src/bindings.ts index caeb7e0..8258cc0 100644 --- a/cloudflare/src/bindings.ts +++ b/cloudflare/src/bindings.ts @@ -1,5 +1,6 @@ export interface ElyKvNamespace { get(key: string): Promise; + delete(key: string): Promise; } export interface ElyR2Object { diff --git a/cloudflare/src/index.ts b/cloudflare/src/index.ts index bdd6a34..bc07083 100644 --- a/cloudflare/src/index.ts +++ b/cloudflare/src/index.ts @@ -1,5 +1,14 @@ import type { Env } from "./bindings.js"; -import { withAuthenticatedApiControls, withPublicApiControls } from "./api_controls.js"; +import { + withApprovedDeviceApiControls, + withAuthenticatedApiControls, + withPublicApiControls, +} from "./api_controls.js"; +import { + AccountDeletionPersistenceError, + AccountDeletionRequestError, + accountDeletionDocument, +} from "./account_deletion.js"; import { handleBetterAuthRoute } from "./better_auth.js"; import { DevicePermissionError, @@ -178,6 +187,37 @@ export async function handleRequest(request: Request, env: Env): Promise { + try { + return jsonResponse(await accountDeletionDocument(request, env, context), 200, { + "Cache-Control": "no-store", + }); + } catch (error) { + if (error instanceof AccountDeletionRequestError) { + return jsonResponse( + { error: "invalid_account_deletion" }, + 400, + { "Cache-Control": "no-store" }, + ); + } + if (error instanceof AccountDeletionPersistenceError) { + return jsonResponse( + { error: "account_deletion_failed" }, + 500, + { "Cache-Control": "no-store" }, + ); + } + throw error; + } + }, + ); + } const syncResponse = await handleSyncRoute(request, env, url); if (syncResponse !== null) { return syncResponse; diff --git a/cloudflare/tests/account_deletion_routes.test.ts b/cloudflare/tests/account_deletion_routes.test.ts new file mode 100644 index 0000000..e26f3f3 --- /dev/null +++ b/cloudflare/tests/account_deletion_routes.test.ts @@ -0,0 +1,295 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { describe, it } from "node:test"; + +import { authSessionCacheKvKey, authTokenHash } from "../src/auth.js"; +import { handleRequest } from "../src/index.js"; +import { ACCESS_TOKEN, sessionDocument, testD1Database, testEnv } from "./devices_test_support.js"; + +const USER_ID = "user-01"; +const DEVICE_ID = "device-01"; +const IDEMPOTENCY_KEY = "account-delete-000001"; +const PAYLOAD_HASH = "b".repeat(64); +const USER_HASH = sha256(bytes(USER_ID)); +const IDEMPOTENCY_HASH = sha256(bytes(IDEMPOTENCY_KEY)); +const PAYLOAD_KEY = `sync-payloads/us-east/${USER_HASH}/tabs/tab-01/${PAYLOAD_HASH}.bin`; +const SNAPSHOT_KEY = `sync-snapshots/us-east/${USER_HASH}/snapshot-01.bin`; + +describe("account deletion routes", () => { + it("deletes account data and revokes the current auth session cache", async () => { + const kvDeletes: string[] = []; + const r2Deletes: string[] = []; + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const sessionCacheKey = authSessionCacheKvKey("local", tokenHash); + const d1 = testD1Database({ + firstRows: [{ device_id: DEVICE_ID }, null, deletionCountsRow()], + allRows: [{ r2_key: PAYLOAD_KEY }, { r2_key: SNAPSHOT_KEY }], + }); + + const response = await handleRequest( + accountDeleteRequest(accountDeleteBody()), + testEnv({ + d1, + kvDeletes, + r2Deletes, + kvEntries: [[sessionCacheKey, sessionDocument(DEVICE_ID)]], + }), + ); + + const body = (await response.json()) as { + version: number; + account_hash: string; + device_id: string; + idempotency_key: string; + deleted_at: number; + deleted: Record; + }; + assert.equal(response.status, 200); + assert.equal(response.headers.get("cache-control"), "no-store"); + assert.equal(body.version, 1); + assert.equal(body.account_hash, USER_HASH); + assert.equal(body.device_id, DEVICE_ID); + assert.equal(body.idempotency_key, IDEMPOTENCY_KEY); + assert.ok(Number.isSafeInteger(body.deleted_at)); + assert.deepEqual(body.deleted, { + devices: 2, + approvals: 3, + sync_objects: 4, + sync_changes: 9, + sync_snapshots: 2, + sync_tombstones: 1, + audit_events: 7, + session_device_contexts: 1, + sessions: 2, + accounts: 1, + users: 1, + r2_objects: 2, + kv_session_cache: 1, + }); + assert.deepEqual(r2Deletes, [PAYLOAD_KEY, SNAPSHOT_KEY]); + assert.deepEqual(kvDeletes, [sessionCacheKey]); + assert.equal(d1.batches[0], 12); + assert.ok(d1.queries[1]?.includes("FROM audit_events")); + assert.ok(d1.queries[2]?.includes("FROM user_devices")); + assert.ok(d1.queries[3]?.includes("UNION")); + assert.ok(d1.queries[4]?.includes("DELETE FROM sync_change_log")); + assert.ok(d1.queries[9]?.includes("DELETE FROM better_auth_session_device_context")); + assert.ok(d1.queries[10]?.includes("DELETE FROM user_devices")); + assert.ok(d1.queries[15]?.includes("INSERT INTO audit_events")); + assert.deepEqual(d1.binds[1], [accountDeletionEventId()]); + assert.deepEqual(d1.binds[3], [USER_ID, USER_ID]); + assert.deepEqual(d1.binds[15], [ + accountDeletionEventId(), + DEVICE_ID, + USER_HASH, + IDEMPOTENCY_HASH, + body.deleted_at, + ]); + }); + + it("returns an idempotent deletion document for existing audit events", async () => { + const kvDeletes: string[] = []; + const r2Deletes: string[] = []; + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const d1 = testD1Database({ + firstRows: [ + { device_id: DEVICE_ID }, + { + actor_device_id: DEVICE_ID, + outcome: "success", + subject_id: USER_HASH, + created_at: 1_780_001_000, + }, + ], + allRows: [{ r2_key: PAYLOAD_KEY }], + }); + + const response = await handleRequest( + accountDeleteRequest(accountDeleteBody()), + testEnv({ + d1, + kvDeletes, + r2Deletes, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], + }), + ); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + version: 1, + account_hash: USER_HASH, + device_id: DEVICE_ID, + idempotency_key: IDEMPOTENCY_KEY, + deleted_at: 1_780_001_000, + deleted: { + devices: 0, + approvals: 0, + sync_objects: 0, + sync_changes: 0, + sync_snapshots: 0, + sync_tombstones: 0, + audit_events: 0, + session_device_contexts: 0, + sessions: 0, + accounts: 0, + users: 0, + r2_objects: 0, + kv_session_cache: 0, + }, + }); + assert.deepEqual(r2Deletes, []); + assert.deepEqual(kvDeletes, []); + assert.equal(d1.queries.length, 2); + assert.deepEqual(d1.batches, []); + }); + + it("rejects replay mismatches before deleting account data", async () => { + const kvDeletes: string[] = []; + const r2Deletes: string[] = []; + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const d1 = testD1Database({ + firstRows: [ + { device_id: DEVICE_ID }, + { + actor_device_id: "device-02", + outcome: "success", + subject_id: USER_HASH, + created_at: 1_780_001_000, + }, + ], + }); + + const response = await handleRequest( + accountDeleteRequest(accountDeleteBody()), + testEnv({ + d1, + kvDeletes, + r2Deletes, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], + }), + ); + + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { error: "invalid_account_deletion" }); + assert.deepEqual(r2Deletes, []); + assert.deepEqual(kvDeletes, []); + assert.deepEqual(d1.batches, []); + }); + + it("rejects missing confirmation before account deletion reads", async () => { + const kvDeletes: string[] = []; + const r2Deletes: string[] = []; + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }] }); + + const response = await handleRequest( + accountDeleteRequest(accountDeleteBody({ confirmation: "delete" })), + testEnv({ + d1, + kvDeletes, + r2Deletes, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], + }), + ); + + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { error: "invalid_account_deletion" }); + assert.deepEqual(r2Deletes, []); + assert.deepEqual(kvDeletes, []); + assert.equal(d1.queries.length, 1); + assert.deepEqual(d1.batches, []); + }); + + it("rejects revoked devices before reading account deletion bodies", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const d1 = testD1Database({ firstRows: [null] }); + + const response = await handleRequest( + accountDeleteRequest(accountDeleteBody()), + testEnv({ + d1, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], + }), + ); + + assert.equal(response.status, 403); + assert.deepEqual(await response.json(), { error: "device_not_approved" }); + assert.equal(d1.queries.length, 1); + assert.deepEqual(d1.batches, []); + }); + + it("fails closed when stored R2 keys are malformed", async () => { + const kvDeletes: string[] = []; + const r2Deletes: string[] = []; + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const d1 = testD1Database({ + firstRows: [{ device_id: DEVICE_ID }, null, deletionCountsRow()], + allRows: [{ r2_key: "sync-snapshots/../bad.bin" }], + }); + + const response = await handleRequest( + accountDeleteRequest(accountDeleteBody()), + testEnv({ + d1, + kvDeletes, + r2Deletes, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], + }), + ); + + assert.equal(response.status, 500); + assert.deepEqual(await response.json(), { error: "account_deletion_failed" }); + assert.deepEqual(r2Deletes, []); + assert.deepEqual(kvDeletes, []); + assert.deepEqual(d1.batches, []); + }); +}); + +function accountDeleteRequest(body: Record): Request { + return new Request("https://elydora.test/api/account/delete", { + method: "POST", + headers: { + authorization: `Bearer ${ACCESS_TOKEN}`, + "content-type": "application/json", + }, + body: JSON.stringify(body), + }); +} + +function accountDeleteBody(overrides: Record = {}): Record { + return { + version: 1, + confirmation: "delete-elydora-account", + idempotency_key: IDEMPOTENCY_KEY, + ...overrides, + }; +} + +function deletionCountsRow(overrides: Record = {}): Record { + return { + devices: 2, + approvals: 3, + sync_objects: 4, + sync_changes: 9, + sync_snapshots: 2, + sync_tombstones: 1, + audit_events: 7, + session_device_contexts: 1, + sessions: 2, + accounts: 1, + users: 1, + ...overrides, + }; +} + +function accountDeletionEventId(): string { + return `account-delete:${USER_HASH}:${IDEMPOTENCY_HASH}`; +} + +function bytes(value: string): Uint8Array { + return new TextEncoder().encode(value); +} + +function sha256(payload: Uint8Array): string { + return createHash("sha256").update(payload).digest("hex"); +} diff --git a/cloudflare/tests/api_controls.test.ts b/cloudflare/tests/api_controls.test.ts index 649dc88..94e5a2d 100644 --- a/cloudflare/tests/api_controls.test.ts +++ b/cloudflare/tests/api_controls.test.ts @@ -319,6 +319,10 @@ function testEnv(options: TestEnvOptions = {}): Env { options.kvReads?.push(key); return Promise.resolve(values.get(key) ?? null); }, + delete(key: string): Promise { + values.delete(key); + return Promise.resolve(); + }, }, ELY_STORAGE: testR2Bucket(), ELY_RATE_LIMITER: { diff --git a/cloudflare/tests/devices_test_support.ts b/cloudflare/tests/devices_test_support.ts index 6e55226..9f79861 100644 --- a/cloudflare/tests/devices_test_support.ts +++ b/cloudflare/tests/devices_test_support.ts @@ -14,6 +14,7 @@ export interface TestEnvOptions { diagnosticEvents?: ElyAnalyticsDataPoint[]; d1?: RecordedD1Database; kvEntries?: [string, string][]; + kvDeletes?: string[]; kvReads?: string[]; r2Deletes?: string[]; r2Gets?: string[]; @@ -50,6 +51,11 @@ export function testEnv(options: TestEnvOptions): Env { options.kvReads?.push(key); return Promise.resolve(values.get(key) ?? null); }, + delete(key: string): Promise { + options.kvDeletes?.push(key); + values.delete(key); + return Promise.resolve(); + }, }, ELY_STORAGE: testR2Bucket( options.r2Puts, diff --git a/cloudflare/tests/index.test.ts b/cloudflare/tests/index.test.ts index 349d585..ea00047 100644 --- a/cloudflare/tests/index.test.ts +++ b/cloudflare/tests/index.test.ts @@ -359,6 +359,10 @@ function testEnv( get(key: string): Promise { return Promise.resolve(values.get(key) ?? null); }, + delete(key: string): Promise { + values.delete(key); + return Promise.resolve(); + }, }, ELY_STORAGE: testR2Bucket(), ELY_RATE_LIMITER: {