diff --git a/cloudflare/src/devices.ts b/cloudflare/src/devices.ts index 3e05cd5..9134d22 100644 --- a/cloudflare/src/devices.ts +++ b/cloudflare/src/devices.ts @@ -54,6 +54,65 @@ const DEVICE_BY_IDEMPOTENCY_KEY_QUERY = ` FROM user_devices WHERE user_id = ? AND idempotency_key = ? `; +const DEVICE_BY_ID_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 = ? AND device_id = ? +`; +const APPROVED_DEVICE_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 = ? AND device_id = ? AND approval_status = 'approved' AND revoked_at IS NULL +`; +const DEVICE_APPROVAL_BY_IDEMPOTENCY_KEY_QUERY = ` + SELECT + device_id, + requester_device_id, + status, + decided_at + FROM device_approvals + WHERE user_id = ? AND idempotency_key = ? +`; +const DEVICE_APPROVAL_INSERT_QUERY = ` + INSERT INTO device_approvals ( + user_id, + approval_id, + device_id, + requester_device_id, + status, + requested_at, + decided_at, + expires_at, + idempotency_key + ) VALUES (?, ?, ?, ?, 'approved', ?, ?, ?, ?) + ON CONFLICT(user_id, idempotency_key) DO NOTHING +`; +const DEVICE_APPROVE_QUERY = ` + UPDATE user_devices + SET + approval_status = 'approved', + approved_at = COALESCE(approved_at, ?), + last_active_at = ? + WHERE user_id = ? AND device_id = ? AND approval_status = 'pending' AND revoked_at IS NULL +`; export interface DeviceListDocument { version: 1; @@ -67,6 +126,14 @@ export interface DeviceRegistrationDocument { device: DeviceDocument; } +export interface DeviceApprovalDocument { + version: 1; + user_id: string; + approved_by_device_id: string; + approved_at: number; + device: DeviceDocument; +} + export interface DeviceDocument { device_id: string; public_key: string; @@ -100,7 +167,16 @@ interface DeviceRegistrationRequest { idempotencyKey: string; } -type DeviceRegistrationBody = Record; +type DeviceApprovalRequest = { deviceId: string; idempotencyKey: string }; + +interface DeviceApprovalRow { + device_id: unknown; + requester_device_id: unknown; + status: unknown; + decided_at: unknown; +} + +type DeviceRequestBody = Record; export class DeviceSchemaError extends Error { constructor(message: string) { @@ -172,16 +248,69 @@ export async function registerDeviceDocument( }; } +export async function approveDeviceDocument( + request: Request, + env: Env, + context: AuthContext, + nowSeconds = Math.floor(Date.now() / 1000), +): Promise { + const approval = await deviceApprovalRequest(request); + const requesterDeviceId = currentDeviceId(context); + if (requesterDeviceId === approval.deviceId) { + throw new DevicePermissionError("device_self_approval_forbidden"); + } + + const existingApproval = await env.ELY_DB.prepare(DEVICE_APPROVAL_BY_IDEMPOTENCY_KEY_QUERY) + .bind(context.userId, approval.idempotencyKey) + .first(); + if (existingApproval !== null) { + return existingApprovalDocument(env, context, approval, requesterDeviceId, existingApproval); + } + + const approver = await env.ELY_DB.prepare(APPROVED_DEVICE_QUERY) + .bind(context.userId, requesterDeviceId) + .first(); + if (approver === null) { + throw new DevicePermissionError("approver_device_unapproved"); + } + + const pendingDevice = await deviceRowById(env, context.userId, approval.deviceId); + if (pendingDevice === null) { + throw new DevicePermissionError("device_not_found"); + } + const pendingDocument = deviceDocument(pendingDevice, requesterDeviceId); + if (pendingDocument.approval_status !== "pending" || pendingDocument.revoked_at !== null) { + throw new DevicePermissionError("device_not_pending"); + } + + await env.ELY_DB.batch([ + env.ELY_DB.prepare(DEVICE_APPROVAL_INSERT_QUERY).bind( + context.userId, + approval.idempotencyKey, + approval.deviceId, + requesterDeviceId, + nowSeconds, + nowSeconds, + nowSeconds, + approval.idempotencyKey, + ), + env.ELY_DB.prepare(DEVICE_APPROVE_QUERY).bind( + nowSeconds, + nowSeconds, + context.userId, + approval.deviceId, + ), + ]); + + const approvedDevice = await deviceRowById(env, context.userId, approval.deviceId); + if (approvedDevice === null) { + throw new DevicePersistenceError("device_approval_missing"); + } + return approvedDeviceDocument(context.userId, requesterDeviceId, approvedDevice); +} + async function deviceRegistrationRequest(request: Request): Promise { - let value: unknown; - try { - value = await request.json(); - } catch { - throw new DeviceSchemaError("device_registration_json_invalid"); - } - if (!isRecord(value)) { - throw new DeviceSchemaError("device_registration_must_be_object"); - } + const value = await deviceRequestBody(request, "device_registration"); assertOnlyFields(value, [ "version", "device_id", @@ -203,6 +332,89 @@ async function deviceRegistrationRequest(request: Request): Promise { + const value = await deviceRequestBody(request, "device_approval"); + assertOnlyFields(value, ["version", "device_id", "idempotency_key"]); + if (value.version !== 1) { + throw new DeviceSchemaError("device_approval_version_invalid"); + } + + return { + deviceId: deviceIdValue(value.device_id, "device_id"), + idempotencyKey: idempotencyKeyValue(value.idempotency_key), + }; +} + +async function existingApprovalDocument( + env: Env, + context: AuthContext, + approval: DeviceApprovalRequest, + requesterDeviceId: string, + row: DeviceApprovalRow, +): Promise { + const approvedDeviceId = deviceIdValue(row.device_id, "device_id"); + const approvedByDeviceId = deviceIdValue(row.requester_device_id, "requester_device_id"); + if ( + approvedDeviceId !== approval.deviceId || + approvedByDeviceId !== requesterDeviceId || + row.status !== "approved" + ) { + throw new DevicePermissionError("device_approval_replay_mismatch"); + } + + const approvedDevice = await deviceRowById(env, context.userId, approvedDeviceId); + if (approvedDevice === null) { + throw new DevicePersistenceError("device_approval_missing"); + } + + return { + ...approvedDeviceDocument(context.userId, requesterDeviceId, approvedDevice), + approved_at: timestamp(row.decided_at, "decided_at"), + }; +} + +function approvedDeviceDocument( + userId: string, + approvedByDeviceId: string, + row: DeviceRow, +): DeviceApprovalDocument { + const device = deviceDocument(row, approvedByDeviceId); + if (device.approval_status !== "approved" || device.approved_at === null) { + throw new DevicePersistenceError("device_approval_missing"); + } + return { + version: 1, + user_id: userId, + approved_by_device_id: approvedByDeviceId, + approved_at: device.approved_at, + device, + }; +} + +function currentDeviceId(context: AuthContext): string { + if (context.deviceId === undefined) { + throw new DevicePermissionError("device_context_required"); + } + return context.deviceId; +} + +async function deviceRowById(env: Env, userId: string, deviceId: string): Promise { + return env.ELY_DB.prepare(DEVICE_BY_ID_QUERY).bind(userId, deviceId).first(); +} + +async function deviceRequestBody(request: Request, label: string): Promise { + let value: unknown; + try { + value = await request.json(); + } catch { + throw new DeviceSchemaError(`${label}_json_invalid`); + } + if (!isRecord(value)) { + throw new DeviceSchemaError(`${label}_must_be_object`); + } + return value; +} + function deviceDocument(row: DeviceRow, currentDeviceId: string | undefined): DeviceDocument { const deviceId = deviceIdValue(row.device_id, "device_id"); return { @@ -272,7 +484,7 @@ function timestamp(value: unknown, label: string): number { return value; } -function assertOnlyFields(value: DeviceRegistrationBody, fields: string[]): void { +function assertOnlyFields(value: DeviceRequestBody, fields: string[]): void { const allowed = new Set(fields); for (const field of Object.keys(value)) { if (!allowed.has(field)) { @@ -281,6 +493,6 @@ function assertOnlyFields(value: DeviceRegistrationBody, fields: string[]): void } } -function isRecord(value: unknown): value is DeviceRegistrationBody { +function isRecord(value: unknown): value is DeviceRequestBody { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/cloudflare/src/index.ts b/cloudflare/src/index.ts index 70526d5..124680a 100644 --- a/cloudflare/src/index.ts +++ b/cloudflare/src/index.ts @@ -4,6 +4,7 @@ import { DevicePermissionError, DevicePersistenceError, DeviceSchemaError, + approveDeviceDocument, deviceListDocument, registerDeviceDocument, } from "./devices.js"; @@ -91,6 +92,44 @@ export async function handleRequest(request: Request, env: Env): Promise { + try { + return jsonResponse(await approveDeviceDocument(request, env, context), 200, { + "Cache-Control": "no-store", + }); + } catch (error) { + if (error instanceof DevicePermissionError) { + return jsonResponse( + { error: "device_approval_forbidden" }, + 403, + { "Cache-Control": "no-store" }, + ); + } + if (error instanceof DeviceSchemaError) { + return jsonResponse( + { error: "invalid_device_approval" }, + 400, + { "Cache-Control": "no-store" }, + ); + } + if (error instanceof DevicePersistenceError) { + return jsonResponse( + { error: "device_approval_failed" }, + 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_approval_routes.test.ts b/cloudflare/tests/devices_approval_routes.test.ts new file mode 100644 index 0000000..c55813d --- /dev/null +++ b/cloudflare/tests/devices_approval_routes.test.ts @@ -0,0 +1,235 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { authSessionCacheKvKey, authTokenHash } from "../src/auth.js"; +import { handleRequest } from "../src/index.js"; +import { + ACCESS_TOKEN, + PUBLIC_KEY, + sessionDocument, + testD1Database, + testEnv, +} from "./devices_test_support.js"; + +const DEVICE_APPROVAL_IDEMPOTENCY_KEY = "device-approval-0001"; + +describe("device approval routes", () => { + it("approves a pending device from an approved current device", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const d1 = testD1Database({ + firstRows: [ + null, + deviceRow({ device_id: "device-01", approval_status: "approved" }), + deviceRow({ device_id: "device-02", approval_status: "pending", approved_at: null }), + deviceRow({ + device_id: "device-02", + approval_status: "approved", + approved_at: 1_780_000_300, + }), + ], + }); + + const response = await handleRequest( + new Request("https://elydora.test/api/devices/approve", { + method: "POST", + headers: { + authorization: `Bearer ${ACCESS_TOKEN}`, + "content-type": "application/json", + }, + body: JSON.stringify(deviceApprovalBody()), + }), + testEnv({ + d1, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]], + }), + ); + + 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", + approved_by_device_id: "device-01", + approved_at: 1_780_000_300, + device: { + device_id: "device-02", + public_key: PUBLIC_KEY, + device_name: "MacBook Pro", + platform: "macOS", + approval_status: "approved", + created_at: 1_780_000_000, + approved_at: 1_780_000_300, + last_active_at: 1_780_000_020, + revoked_at: null, + current: false, + }, + }); + assert.equal(d1.batches[0], 2); + assert.ok(d1.queries[0]?.includes("FROM device_approvals")); + assert.ok(d1.queries[1]?.includes("approval_status = 'approved'")); + assert.ok(d1.queries[3]?.includes("INSERT INTO device_approvals")); + assert.ok(d1.queries[4]?.includes("UPDATE user_devices")); + assert.deepEqual(d1.binds[0], ["user-01", DEVICE_APPROVAL_IDEMPOTENCY_KEY]); + assert.deepEqual(d1.binds[1], ["user-01", "device-01"]); + assert.deepEqual(d1.binds[2], ["user-01", "device-02"]); + assert.deepEqual(d1.binds[3]?.slice(0, 4), [ + "user-01", + DEVICE_APPROVAL_IDEMPOTENCY_KEY, + "device-02", + "device-01", + ]); + assert.equal(d1.binds[3]?.[7], DEVICE_APPROVAL_IDEMPOTENCY_KEY); + assert.deepEqual(d1.binds[5], ["user-01", "device-02"]); + }); + + it("returns the existing approval for an idempotent replay", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const d1 = testD1Database({ + firstRows: [ + { + device_id: "device-02", + requester_device_id: "device-01", + status: "approved", + decided_at: 1_780_000_300, + }, + deviceRow({ + device_id: "device-02", + approval_status: "approved", + approved_at: 1_780_000_300, + }), + ], + }); + + const response = await handleRequest( + new Request("https://elydora.test/api/devices/approve", { + method: "POST", + headers: { + authorization: `Bearer ${ACCESS_TOKEN}`, + "content-type": "application/json", + }, + body: JSON.stringify(deviceApprovalBody()), + }), + testEnv({ + d1, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]], + }), + ); + + assert.equal(response.status, 200); + const body = (await response.json()) as { approved_at: number }; + assert.equal(body.approved_at, 1_780_000_300); + assert.deepEqual(d1.batches, []); + assert.equal(d1.queries.length, 2); + assert.deepEqual(d1.binds, [ + ["user-01", DEVICE_APPROVAL_IDEMPOTENCY_KEY], + ["user-01", "device-02"], + ]); + }); + + it("rejects approval from a current device that is not approved", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const d1 = testD1Database({ firstRows: [null, null] }); + const response = await handleRequest( + new Request("https://elydora.test/api/devices/approve", { + method: "POST", + headers: { + authorization: `Bearer ${ACCESS_TOKEN}`, + "content-type": "application/json", + }, + body: JSON.stringify(deviceApprovalBody()), + }), + testEnv({ + d1, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]], + }), + ); + + assert.equal(response.status, 403); + assert.deepEqual(await response.json(), { error: "device_approval_forbidden" }); + assert.deepEqual(d1.batches, []); + }); + + it("rejects self approval before D1 writes", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const d1 = testD1Database([]); + const response = await handleRequest( + new Request("https://elydora.test/api/devices/approve", { + method: "POST", + headers: { + authorization: `Bearer ${ACCESS_TOKEN}`, + "content-type": "application/json", + }, + body: JSON.stringify({ ...deviceApprovalBody(), device_id: "device-01" }), + }), + testEnv({ + d1, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]], + }), + ); + + assert.equal(response.status, 403); + assert.deepEqual(d1.queries, []); + }); + + it("rejects invalid approval payloads before D1 writes", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const d1 = testD1Database([]); + const response = await handleRequest( + new Request("https://elydora.test/api/devices/approve", { + method: "POST", + headers: { + authorization: `Bearer ${ACCESS_TOKEN}`, + "content-type": "application/json", + }, + body: JSON.stringify({ ...deviceApprovalBody(), idempotency_key: "short" }), + }), + testEnv({ + d1, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]], + }), + ); + + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { error: "invalid_device_approval" }); + assert.deepEqual(d1.queries, []); + }); + + it("rejects unauthenticated device approval before D1 writes", async () => { + const d1 = testD1Database([]); + const response = await handleRequest( + new Request("https://elydora.test/api/devices/approve", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(deviceApprovalBody()), + }), + testEnv({ d1 }), + ); + + assert.equal(response.status, 401); + assert.deepEqual(await response.json(), { error: "authorization_missing" }); + assert.deepEqual(d1.queries, []); + }); +}); + +function deviceApprovalBody(): Record { + return { + version: 1, + device_id: "device-02", + idempotency_key: DEVICE_APPROVAL_IDEMPOTENCY_KEY, + }; +} + +function deviceRow(overrides: Record): Record { + return { + 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, + ...overrides, + }; +} diff --git a/cloudflare/tests/devices_routes.test.ts b/cloudflare/tests/devices_routes.test.ts index e999dae..1d4a639 100644 --- a/cloudflare/tests/devices_routes.test.ts +++ b/cloudflare/tests/devices_routes.test.ts @@ -1,18 +1,17 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import type { - ElyAnalyticsDataPoint, - ElyD1Database, - ElyD1PreparedStatement, - ElyR2PutOptions, - Env, -} from "../src/bindings.js"; +import type { ElyAnalyticsDataPoint } from "../src/bindings.js"; import { authSessionCacheKvKey, authTokenHash } from "../src/auth.js"; import { handleRequest } from "../src/index.js"; +import { + ACCESS_TOKEN, + PUBLIC_KEY, + sessionDocument, + testD1Database, + testEnv, +} from "./devices_test_support.js"; -const ACCESS_TOKEN = "D".repeat(48); -const PUBLIC_KEY = "a".repeat(64); const IDEMPOTENCY_KEY = "device-register-0001"; describe("device routes", () => { @@ -284,82 +283,6 @@ describe("device routes", () => { }); }); -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((rows[0] as T | undefined) ?? null); - }, - all() { - return Promise.resolve({ results: rows as T[] }); - }, - run() { - return Promise.resolve({}); - }, - }; -} - function deviceRegistrationBody(): Record { return { version: 1, @@ -370,28 +293,3 @@ function deviceRegistrationBody(): Record { idempotency_key: IDEMPOTENCY_KEY, }; } - -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", - }); -} diff --git a/cloudflare/tests/devices_test_support.ts b/cloudflare/tests/devices_test_support.ts new file mode 100644 index 0000000..6822f45 --- /dev/null +++ b/cloudflare/tests/devices_test_support.ts @@ -0,0 +1,128 @@ +import type { + ElyAnalyticsDataPoint, + ElyD1Database, + ElyD1PreparedStatement, + ElyR2PutOptions, + Env, +} from "../src/bindings.js"; + +export const ACCESS_TOKEN = "D".repeat(48); +export const PUBLIC_KEY = "a".repeat(64); + +export interface TestEnvOptions { + auditEvents?: ElyAnalyticsDataPoint[]; + d1?: RecordedD1Database; + kvEntries?: [string, string][]; + kvReads?: string[]; +} + +export interface RecordedD1Database extends ElyD1Database { + batches: number[]; + binds: unknown[][]; + queries: string[]; +} + +interface TestD1DatabaseOptions { + allRows?: unknown[]; + firstRows?: unknown[]; +} + +export 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); + } + }, + }, + }; +} + +export function testD1Database(rows: unknown[] | TestD1DatabaseOptions): RecordedD1Database { + const binds: unknown[][] = []; + const batches: number[] = []; + const queries: string[] = []; + const allRows = Array.isArray(rows) ? rows : rows.allRows ?? []; + const firstRows = Array.isArray(rows) ? rows : rows.firstRows ?? []; + let firstIndex = 0; + return { + batches, + binds, + queries, + prepare(query: string) { + queries.push(query); + return testD1PreparedStatement(allRows, firstRows, () => firstIndex++, binds); + }, + batch(statements: ElyD1PreparedStatement[]) { + batches.push(statements.length); + return Promise.resolve([]); + }, + exec() { + return Promise.resolve({}); + }, + }; +} + +export function sessionDocument(deviceId = "device-01"): string { + return JSON.stringify({ + version: 1, + user_id: "user-01", + session_id: "session-01", + device_id: deviceId, + expires_at: "2099-01-01T00:00:00.000Z", + }); +} + +function testD1PreparedStatement( + allRows: unknown[], + firstRows: unknown[], + nextFirstIndex: () => number, + binds: unknown[][], +): ElyD1PreparedStatement { + return { + bind(...values: unknown[]) { + binds.push(values); + return this; + }, + first() { + return Promise.resolve((firstRows[nextFirstIndex()] as T | undefined) ?? null); + }, + all() { + return Promise.resolve({ results: allRows 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); + }, + }); + }, + }; +}