From c00aab456d445191e8097cc69550f15fce1e5793 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 10 Sep 2026 12:48:31 -0400 Subject: [PATCH] feat(beehive): add offline standalone infrastructure registration Signed-off-by: Logan Johnson --- beehive/README.md | 28 ++++++++++++ beehive/src/cli.ts | 7 ++- beehive/src/enrollment-input.ts | 39 +++++++++++++++++ beehive/src/host-attestation.ts | 5 ++- beehive/src/host-identity.ts | 42 +++++++++--------- beehive/src/host-registration.ts | 43 ++++++++++++++++++ beehive/src/key-input.ts | 4 +- beehive/test/enrollment-input.test.ts | 63 +++++++++++++++++++++++++++ beehive/test/nostr-transport.test.ts | 20 ++++++--- 9 files changed, 222 insertions(+), 29 deletions(-) create mode 100644 beehive/src/enrollment-input.ts create mode 100644 beehive/src/host-registration.ts create mode 100644 beehive/test/enrollment-input.test.ts diff --git a/beehive/README.md b/beehive/README.md index 91df6245c4..7fc92f4057 100644 --- a/beehive/README.md +++ b/beehive/README.md @@ -10,6 +10,34 @@ can close while the host/runner continues. No direct-host HTTP API or controller service exists. This package intentionally lives alongside, not inside, the native clients: it is a separately authorized product experiment. +## Offline standalone host enrollment (partial, no network) + +`node src/cli.ts setup ` without an identity file opens Beehive's +own enrollment interface. No Buzz Desktop is needed. Use **create** on a fresh +host directory: enter its label, relay URL and your existing owner **public** key. +The host generates its durable independent key and exports a private pairing file. +Compare the full displayed fingerprint in your owner context. + +Run the same command in your separate owner context and choose **approve**. Read +the pairing file, verify the host/key/label/relay/fingerprint, confirm registration, +choose an expiration and a new approval file, then enter your existing owner key +through hidden local input. Do not run owner approval on a host you do not trust +with that input. No new controller key is created and the owner key is not saved. +Transfer the approval privately back to the host and choose **import**. **export** +can repeat pairing-file export without replacing the retained host key. + +Files must be owner-only regular files; transfer them privately and retain mode +0600. Registration is domain-separated from agent NIP-OA, binds this exact pairing +request, and grants neither agent placement nor owner account permissions. +Registration expiration is checked locally; offline verification cannot discover +remote revocation, prove a physical machine, or erase a copied key. + +**This milestone stops at registered/pending/no-network.** Narrow relay admission, +private host inventory in the TUI, and Start/reply/Move/reply over the new transport +are not implemented. Broad NIP-OA cannot enroll a host. The legacy diagnostic +workflow below remains separate and stores a shared owner key; do not use it for +real host enrollment. Existing a8 OA identity files are rejected, not migrated. + ## Run an isolated fixture Requires Node >=22.18 (tested with Hermit 24.15.0), POSIX process groups and diff --git a/beehive/src/cli.ts b/beehive/src/cli.ts index 3701754ae9..d73e96239a 100644 --- a/beehive/src/cli.ts +++ b/beehive/src/cli.ts @@ -7,6 +7,7 @@ import { claudeGuidance } from './claude.ts'; import { readAgentSecret } from './key-input.ts'; import { conversationInput } from './conversation-input.ts'; import { localSetup } from './local-setup.ts'; +import { enrollmentInput } from './enrollment-input.ts'; import { Profiles, profile, profileRevision, type Profile } from './profiles.ts'; import { createInterface } from 'node:readline/promises'; import { stdin, stdout } from 'node:process'; @@ -26,7 +27,8 @@ const shellQuote = (s: string) => `'${s.replaceAll("'", "'\"'\"'")}'`; const [command, ...args] = process.argv.slice(2); const help = `Beehive — isolated development preview (loopback relay only) identity Create a NEW local owner identity - setup [identity-file] New host or existing local wizard + setup Offline host pairing/owner approval/import + setup LEGACY loopback diagnostic setup (owner key copied) presets Local process-free preset discovery/setup guidance local-setup Bindings; new/reuse/hidden standby/restore identity migrate-slots Explicit stopped upgrade, preserves journal @@ -54,6 +56,9 @@ async function main() { showPresets(); } else if (command === 'setup') { const dir = resolve(text(args[0])); + if (args.length === 1 && !existsSync(join(dir, 'setup.json'))) { + await enrollmentInput(dir); return; + } if (existsSync(dir)) { if (args.length !== 1) throw Error('Existing setup: supply host directory only; retained owner cannot be replaced'); await localSetup(dir); return; diff --git a/beehive/src/enrollment-input.ts b/beehive/src/enrollment-input.ts new file mode 100644 index 0000000000..c113af8196 --- /dev/null +++ b/beehive/src/enrollment-input.ts @@ -0,0 +1,39 @@ +import { createInterface } from 'node:readline/promises'; +import { stdin, stdout } from 'node:process'; +import { resolve } from 'node:path'; +import { bootstrapHostIdentity, enrollHostIdentity, readHostIdentity } from './host-identity.ts'; +import { hostPairing, pairingFingerprint, registerHost } from './host-registration.ts'; +import { readPrivate, writePrivate } from './storage.ts'; +import { readAgentSecret } from './key-input.ts'; +import { text } from './protocol.ts'; + +/** Standalone offline enrollment UI. File exchange avoids circular relay bootstrap. */ +export async function enrollmentInput(directory: string): Promise { + const ui = createInterface({ input: stdin, output: stdout }); + try { + console.log('Private infrastructure enrollment. No relay connection, agent authority, or runtime launch. Approval must run in your owner context, not on an untrusted host.'); + const action = await ui.question('Enrollment [create / export / approve / import / cancel]: '); + if (action === 'cancel') return; + if (action === 'create' || action === 'export') { + const identity = action === 'create' ? bootstrapHostIdentity(directory, text(await ui.question('Host label: ')), text(await ui.question('Owner PUBLIC key (hex): ')), text(await ui.question('Relay URL: '))) : readHostIdentity(directory); + console.log(`Pairing fingerprint: ${pairingFingerprint(identity.pairing)}`); + const file = resolve(text(await ui.question('NEW private pairing file: '))); + writePrivate(file, identity.pairing, true); + console.log('Pairing exported. Transfer privately to your standalone owner context. Pending/no network. Export can be repeated if interrupted.'); + } else if (action === 'approve') { + const request = hostPairing(readPrivate(resolve(text(await ui.question('Private pairing file: '))))); + console.log(`Host: ${request.host}\nLabel: ${request.label}\nOwner: ${request.owner}\nRelay: ${request.relay}\nFingerprint: ${pairingFingerprint(request)}`); + if (await ui.question('Compare full fingerprint with host. Register ONLY this infrastructure (not agents or relay delegation)? [yes/no]: ') !== 'yes') return; + const expires = Number(await ui.question('Registration expiration (Unix seconds): ')); + if (!Number.isSafeInteger(expires) || expires <= Math.floor(Date.now() / 1000)) throw Error('Expiration must be in the future'); + const file = resolve(text(await ui.question('NEW private approval file: '))); + ui.close(); + const secret = await readAgentSecret('Owner'); + writePrivate(file, registerHost(request, secret, expires), true); + console.log('Registration signed. Owner secret not persisted. Transfer approval privately back to host. Relay admission remains pending.'); + } else if (action === 'import') { + enrollHostIdentity(directory, readPrivate(resolve(text(await ui.question('Private approval file: '))))); + console.log('Host registered. Pending/no network: narrow relay admission is not implemented. No agent authorized or started.'); + } else throw Error('Unknown enrollment action'); + } finally { ui.close(); } +} diff --git a/beehive/src/host-attestation.ts b/beehive/src/host-attestation.ts index 1f5bfc7d10..28e578c759 100644 --- a/beehive/src/host-attestation.ts +++ b/beehive/src/host-attestation.ts @@ -1,7 +1,10 @@ import { schnorr } from '@noble/curves/secp256k1'; import { digest, publicKey } from './protocol.ts'; -/** NIP-OA tag as consumed by Buzz 051c3a2 nip_oa.rs. No owner secret is needed to verify/import. */ +/** Historical source-contract fixture only: NIP-OA as consumed by Buzz 051c3a2. + * NOT infrastructure enrollment. AUTH ignores kind clauses and may establish an + * agent-owner relationship. Production host registration must never use this tag. + */ export type HostAttestation = ['auth', string, string, string]; function clauses(conditions: string): string[] { if (conditions.length > 1024) throw Error('Attestation conditions too long'); diff --git a/beehive/src/host-identity.ts b/beehive/src/host-identity.ts index fa466d3cbf..0076558f7a 100644 --- a/beehive/src/host-identity.ts +++ b/beehive/src/host-identity.ts @@ -1,45 +1,47 @@ import { existsSync, mkdirSync, rmdirSync } from 'node:fs'; import { join } from 'node:path'; -import { fields, newKey, object, publicKey, text } from './protocol.ts'; +import { fields, newKey, object, publicKey } from './protocol.ts'; import { readPrivate, writePrivate } from './storage.ts'; -import { verifyHostAttestation, type HostAttestation } from './host-attestation.ts'; +import { hostPairing, verifyHostRegistration, type HostPairing, type HostRegistration } from './host-registration.ts'; -/** Independent management identity. Agent slots and assignment authority are separate. */ -export type HostIdentity = { version: 1; label: string; owner: string; secret: string; attestation: HostAttestation | null }; +/** Infrastructure registration is separate from relay admission and agent placement. */ +export type HostIdentity = { version: 2; pairing: HostPairing; secret: string; registration: HostRegistration | null }; function locked(directory: string, operation: () => T): T { mkdirSync(directory, { recursive: true, mode: 0o700 }); const lock = join(directory, 'host.lock'); mkdirSync(lock, { mode: 0o700 }); try { return operation(); } finally { rmdirSync(lock); } } -/** Owner-public-only bootstrap. Pending until genuine owner attestation import; never migrates legacy keys. */ -export function bootstrapHostIdentity(directory: string, label: string, owner: string): HostIdentity { - text(label); - if (!/^[0-9a-f]{64}$/.test(owner)) throw Error('Expected owner public key'); +/** Offline bootstrap; neither requires admission nor copies the owner's secret. */ +export function bootstrapHostIdentity(directory: string, label: string, owner: string, relay: string): HostIdentity { return locked(directory, () => { if (existsSync(join(directory, 'setup.json'))) throw Error('Legacy installation retained; use a clean directory'); - const identity: HostIdentity = { version: 1, label, owner, secret: newKey(), attestation: null }; + const secret = newKey(); + const pairing = hostPairing({ version: 1, purpose: 'beehive-host-registration', host: publicKey(secret), owner, label, relay, nonce: newKey() }); + const identity: HostIdentity = { version: 2, pairing, secret, registration: null }; writePrivate(join(directory, 'host-identity.json'), identity, true); return identity; }); } -/** Read local identity without converting absent/expired enrollment into trust. */ +/** Reject legacy broad OA records rather than silently migrating their authority. */ export function readHostIdentity(directory: string): HostIdentity { const value = object(readPrivate(join(directory, 'host-identity.json'))); - fields(value, ['version', 'label', 'owner', 'secret', 'attestation']); - if (value.version !== 1 || typeof value.owner !== 'string' || !/^[0-9a-f]{64}$/.test(value.owner) || typeof value.secret !== 'string' || !/^[0-9a-f]{64}$/.test(value.secret)) throw Error('Invalid host identity'); - text(value.label); publicKey(value.secret); - return value as HostIdentity; + fields(value, ['version', 'pairing', 'secret', 'registration']); + if (value.version !== 2 || typeof value.secret !== 'string' || !/^[0-9a-f]{64}$/.test(value.secret)) throw Error('Invalid host identity; legacy OA is not infrastructure enrollment'); + const pairing = hostPairing(value.pairing); + if (publicKey(value.secret) !== pairing.host) throw Error('Host key differs from pairing request'); + return { version: 2, pairing, secret: value.secret, registration: value.registration as HostRegistration | null }; } -/** Atomically import genuine authorization under the same installation lock as lifecycle setup. */ -export function enrollHostIdentity(directory: string, attestation: unknown, now = Math.floor(Date.now() / 1000)): void { +/** Atomically import only an owner registration matching the exact retained request. */ +export function enrollHostIdentity(directory: string, registration: unknown, now = Math.floor(Date.now() / 1000)): void { locked(directory, () => { const identity = readHostIdentity(directory); - identity.attestation = verifyHostAttestation(attestation, publicKey(identity.secret), identity.owner, now); + identity.registration = verifyHostRegistration(registration, identity.pairing, now); writePrivate(join(directory, 'host-identity.json'), identity); }); } -/** Admission prerequisite, not a substitute for message/assignment authorization. */ -export function requireHostEnrollment(identity: HostIdentity, now = Math.floor(Date.now() / 1000)): HostAttestation { - return verifyHostAttestation(identity.attestation, publicKey(identity.secret), identity.owner, now); +/** Valid registration is NOT relay admission. Remains no-network until a narrow contract exists. */ +export function requireHostEnrollment(identity: HostIdentity, now = Math.floor(Date.now() / 1000)): never { + verifyHostRegistration(identity.registration, identity.pairing, now); + throw Error('Host registered locally; relay admission pending: broad NIP-OA owner delegation is prohibited'); } diff --git a/beehive/src/host-registration.ts b/beehive/src/host-registration.ts new file mode 100644 index 0000000000..d882cb2b36 --- /dev/null +++ b/beehive/src/host-registration.ts @@ -0,0 +1,43 @@ +import { schnorr } from '@noble/curves/secp256k1'; +import { digest, fields, object, publicKey, text } from './protocol.ts'; + +/** Offline pairing request. This is private file-exchange material, not a relay event. */ +export type HostPairing = { version: 1; purpose: 'beehive-host-registration'; host: string; owner: string; label: string; relay: string; nonce: string }; +/** Owner endorsement of infrastructure only; never a NIP-OA tag or relay admission. */ +export type HostRegistration = { request: HostPairing; expires: number; signature: string }; +/** Validate untrusted pairing files before displaying or signing them. */ +export function hostPairing(value: unknown): HostPairing { + const p = object(value); + fields(p, ['version', 'purpose', 'host', 'owner', 'label', 'relay', 'nonce']); + if (p.version !== 1 || p.purpose !== 'beehive-host-registration') throw Error('Invalid registration purpose'); + for (const key of ['host', 'owner', 'nonce']) if (typeof p[key] !== 'string' || !/^[0-9a-f]{64}$/.test(p[key] as string)) throw Error('Invalid pairing identity/nonce'); + if (p.host === p.owner) throw Error('Host must have independent identity'); + const label = text(p.label); + if (label.length > 128 || /[\x00-\x1f\x7f]/.test(label)) throw Error('Invalid host label'); + const relay = text(p.relay); const url = new URL(relay); + if (relay.length > 2048 || url.username || url.password || url.hash || url.search || !(url.protocol === 'wss:' || (url.protocol === 'ws:' && url.hostname === '127.0.0.1'))) throw Error('Invalid pairing relay'); + return { version: 1, purpose: 'beehive-host-registration', host: p.host as string, owner: p.owner as string, label, relay, nonce: p.nonce as string }; +} +/** Full fingerprint binds host, owner, label, relay and this installation request. */ +export function pairingFingerprint(value: unknown): string { + return digest(JSON.stringify(hostPairing(value))).toString('hex'); +} +function preimage(request: HostPairing, expires: number): Buffer { + if (!Number.isSafeInteger(expires) || expires <= 0) throw Error('Invalid registration expiration'); + return digest(`beehive:host-registration:v1:${pairingFingerprint(request)}:${expires}`); +} +/** Explicit existing-owner signing; the caller must not persist the owner secret. */ +export function registerHost(request: unknown, ownerSecret: string, expires: number): HostRegistration { + const pairing = hostPairing(request); + if (pairing.owner !== publicKey(ownerSecret)) throw Error('Wrong owner signer'); + return { request: pairing, expires, signature: Buffer.from(schnorr.sign(preimage(pairing, expires), ownerSecret)).toString('hex') }; +} +/** Verifies only registration and locally observed expiration, not remote revocation or admission. */ +export function verifyHostRegistration(value: unknown, expected: unknown, now = Math.floor(Date.now() / 1000)): HostRegistration { + const r = object(value); fields(r, ['request', 'expires', 'signature']); + const request = hostPairing(r.request); + if (pairingFingerprint(request) !== pairingFingerprint(expected)) throw Error('Registration does not match pairing request'); + if (typeof r.expires !== 'number' || !Number.isSafeInteger(now) || now >= r.expires || typeof r.signature !== 'string' || !/^[0-9a-f]{128}$/.test(r.signature)) throw Error('Invalid/expired host registration'); + if (!schnorr.verify(r.signature, preimage(request, r.expires), request.owner)) throw Error('Invalid owner registration signature'); + return { request, expires: r.expires, signature: r.signature }; +} diff --git a/beehive/src/key-input.ts b/beehive/src/key-input.ts index 4d6ad9a274..09a5cdf5d3 100644 --- a/beehive/src/key-input.ts +++ b/beehive/src/key-input.ts @@ -5,11 +5,11 @@ import { publicKey } from './protocol.ts'; /** Local secret entry only: disable terminal echo and never route key bytes to output. * Piped input is supported for protected automation; no secret argv or environment. */ -export async function readAgentSecret(): Promise { +export async function readAgentSecret(role: 'Agent' | 'Owner' = 'Agent'): Promise { const muted = new Writable({ write(_chunk, _encoding, done) { done(); } }); const ui = createInterface({ input: stdin, output: muted, terminal: Boolean(stdin.isTTY) }); try { - stdout.write('Agent private key (64 hex; hidden, never passed as an argument): '); + stdout.write(`${role} private key (64 hex; hidden, never passed as an argument): `); const value = await ui.question(''); if (!/^[0-9a-fA-F]{64}$/.test(value)) throw Error('Invalid private key; expected 64 hex characters'); const secret = value.toLowerCase(); diff --git a/beehive/test/enrollment-input.test.ts b/beehive/test/enrollment-input.test.ts new file mode 100644 index 0000000000..5a5d70f662 --- /dev/null +++ b/beehive/test/enrollment-input.test.ts @@ -0,0 +1,63 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { newKey, publicKey } from '../src/protocol.ts'; +import { readHostIdentity, requireHostEnrollment } from '../src/host-identity.ts'; +import { pairingFingerprint } from '../src/host-registration.ts'; + +async function wizard(directory: string, steps: [string, string][]): Promise { + const env = { ...process.env }; + delete env.BUZZ_PRIVATE_KEY; delete env.BUZZ_AUTH_TAG; delete env.BUZZ_RELAY_URL; + const child = spawn(process.execPath, [fileURLToPath(new URL('../src/cli.ts', import.meta.url)), 'setup', directory], { env, stdio: ['pipe', 'pipe', 'pipe'] }); + let output = '', cursor = 0, next = 0; + const timer = setTimeout(() => child.kill(), 10000); + child.stdout.on('data', data => { + output += data.toString(); + if (next < steps.length) { + const [prompt, answer] = steps[next]!; + const index = output.indexOf(prompt, cursor); + if (index !== -1) { cursor = index + prompt.length; next++; child.stdin.write(`${answer}\n`); } + } + }); + child.stderr.on('data', data => { output += data.toString(); }); + try { + const code = await new Promise((resolve, reject) => { child.on('error', reject); child.on('exit', resolve); }); + assert.equal(code, 0, output); assert.equal(next, steps.length, output); + return output; + } finally { clearTimeout(timer); child.stdin.end(); } +} + +test('actual standalone CLI private pairing, existing-owner approval and bootstrap return stay offline', async () => { + const root = mkdtempSync(join(tmpdir(), 'beehive-pairing-cli-')); + const host = join(root, 'host'), ownerContext = join(root, 'owner-context'); + const request = join(root, 'request.json'), approval = join(root, 'approval.json'); + const ownerSecret = newKey(); + try { + const created = await wizard(host, [ + ['Enrollment [', 'create'], ['Host label: ', 'desktop'], ['Owner PUBLIC key (hex): ', publicKey(ownerSecret)], + ['Relay URL: ', 'wss://example.invalid'], ['NEW private pairing file: ', request], + ]); + const original = readHostIdentity(host); + assert.ok(created.includes(pairingFingerprint(original.pairing))); + assert.equal(original.registration, null); + const signed = await wizard(ownerContext, [ + ['Enrollment [', 'approve'], ['Private pairing file: ', request], ['[yes/no]: ', 'yes'], + ['Registration expiration (Unix seconds): ', String(Math.floor(Date.now() / 1000) + 300)], + ['NEW private approval file: ', approval], ['Owner private key (64 hex; hidden, never passed as an argument): ', ownerSecret], + ]); + assert.ok(signed.includes(pairingFingerprint(original.pairing))); + assert.ok(!signed.includes(ownerSecret)); + const imported = await wizard(host, [['Enrollment [', 'import'], ['Private approval file: ', approval]]); + assert.ok(imported.includes('Pending/no network')); + const enrolled = readHostIdentity(host); + assert.equal(enrolled.secret, original.secret); + assert.deepEqual(enrolled.pairing, original.pairing); + assert.throws(() => requireHostEnrollment(enrolled), /relay admission pending/); + assert.deepEqual(readdirSync(host), ['host-identity.json']); + for (const path of [request, approval, join(host, 'host-identity.json')]) assert.ok(!readFileSync(path, 'utf8').includes(ownerSecret)); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/beehive/test/nostr-transport.test.ts b/beehive/test/nostr-transport.test.ts index e28b9e97d6..b35a6a2e44 100644 --- a/beehive/test/nostr-transport.test.ts +++ b/beehive/test/nostr-transport.test.ts @@ -1,3 +1,4 @@ +import { registerHost, verifyHostRegistration } from '../src/host-registration.ts'; import test from 'node:test'; import { WebSocket } from 'ws'; import assert from 'node:assert/strict'; @@ -68,20 +69,29 @@ test('durable owner-public-only pending bootstrap, genuine enrollment and instal const directory = mkdtempSync(join(tmpdir(), 'beehive-host-key-')); try { const owner = generateSecretKey(); - const identity = bootstrapHostIdentity(directory, 'desktop', getPublicKey(owner)); - assert.equal(identity.attestation, null); + const identity = bootstrapHostIdentity(directory, 'desktop', getPublicKey(owner), 'wss://example.invalid'); + assert.equal(identity.registration, null); assert.notEqual(identity.secret, hex(owner)); assert.throws(() => requireHostEnrollment(identity)); assert.equal(statSync(join(directory, 'host-identity.json')).mode & 0o777, 0o600); assert.deepEqual(readHostIdentity(directory), identity); - assert.throws(() => bootstrapHostIdentity(directory, 'other', identity.owner)); + assert.throws(() => bootstrapHostIdentity(directory, 'other', identity.pairing.owner, 'wss://example.invalid')); const tag = attestHost(hex(owner), getPublicKey(Buffer.from(identity.secret, 'hex')), 'kind=1059'); mkdirSync(join(directory, 'host.lock')); assert.throws(() => enrollHostIdentity(directory, tag)); rmdirSync(join(directory, 'host.lock')); assert.throws(() => enrollHostIdentity(directory, attestHost(hex(generateSecretKey()), getPublicKey(Buffer.from(identity.secret, 'hex')), ''))); - enrollHostIdentity(directory, tag); - assert.deepEqual(requireHostEnrollment(readHostIdentity(directory)), tag); + assert.throws(() => enrollHostIdentity(directory, tag), 'broad OA must never enroll infrastructure'); + const registration = registerHost(identity.pairing, hex(owner), Math.floor(Date.now() / 1000) + 60); + for (const key of ['host', 'owner', 'label', 'relay', 'nonce'] as const) { + const changed = { ...identity.pairing, [key]: key === 'label' ? 'other' : key === 'relay' ? 'wss://other.invalid' : hex(generateSecretKey()) }; + assert.throws(() => verifyHostRegistration(registration, changed)); + } + assert.throws(() => verifyHostRegistration({ ...registration, expires: registration.expires + 1 }, identity.pairing)); + assert.throws(() => verifyHostRegistration(registration, identity.pairing, registration.expires)); + enrollHostIdentity(directory, registration); + assert.deepEqual(readHostIdentity(directory).registration, registration); + assert.throws(() => requireHostEnrollment(readHostIdentity(directory)), /relay admission pending/); } finally { rmSync(directory, { recursive: true, force: true }); } });