Unify legacy overlay signing with the queued iframe consent bridge

This commit is contained in:
archipelago
2026-10-06 11:43:33 -04:00
parent 367c32bb08
commit 08c93f4aad
5 changed files with 183 additions and 203 deletions
+41
View File
@@ -635,3 +635,44 @@ Still track the separate legacy `stores/appLauncher.ts` signing handler, which
has its own consent implementation; this deployment qualifies the AppSession /
shared bridge path. Do not describe every possible app launcher or the physical
companion grey-screen report as fully accepted from these checks.
## IndeeHub durable publication and relay delivery checkpoint
IndeeHub follow-up commits `c7cf672` and `46f128c` add a shared signed-offer
validator, authorized publication/database outbox transaction and bounded relay
worker. **53 backend tests pass**, including real disposable PostgreSQL and
WebSocket integration: concurrent publishers/workers, ownership/moderation and
registered-term checks, transaction rollback, lease recovery, lost relay ACK,
unchanged signed-event retry and exact acceptance. Backend build passes; **74
frontend tests** passed after sharing the protocol validator. The disposable DB,
volume and private credentials were removed. No public announcements or wallet
payments were made. See the IndeeHub implementation document for exact logs.
This is not a deployed IndeeHub source or a finished rental flow. Production
migration/scheduling, trusted node-media registration, authenticated Backstage
integration and settlement-backed FIPS playback remain open. Source inspection
also confirms the existing peer ecash path only creates its durable buyer record
after response headers: lost headers or interruption during minting can leave no
purchase record. It additionally falls back from Cashu to Fedimint after any
Cashu error. Complete purchase-intent/wallet-operation recovery and unambiguous
backend selection are required before reusing that path for rentals. Existing
successful two-node paid-file tests exercised cached delivery recovery, not that
initial mint/response-loss gap. No new paid test was performed here.
## Legacy overlay signer candidate
The legacy overlay now reuses `useNostrBridge` rather than maintaining another
single-promise consent implementation. The actual iframe window is registered
by the overlay; another same-origin window cannot drive the signer. Existing
URL-keyed identity selections and remembered consent remain compatible. Closing,
changing URL, replacing the iframe or disposing the store cancels pending work.
The approved completion animation remains unchanged.
56 focused signer/launcher tests and typecheck pass. The full dashboard suite
passes **1,293 tests across 161 files**, with production build passing. Logs:
`/tmp/archy-legacy-signer-tests.log`,
`/tmp/archy-legacy-signer-full-ui-tests.log`,
`/tmp/archy-legacy-signer-typecheck.log`,
`/tmp/archy-legacy-signer-production-build.log`.
The served-browser harness now covers both AppSession and the legacy overlay at
390/1440px. Deployment/live results will be recorded separately below.
@@ -229,6 +229,7 @@ interface PaymentRequest {
const store = useAppLauncherStore()
const closeBtnRef = ref<HTMLButtonElement | null>(null)
const iframeRef = ref<HTMLIFrameElement | null>(null)
watch(iframeRef, frame => store.setNostrFrame(frame?.contentWindow || null), { flush: 'post' })
const iframeRefreshKey = ref(0)
const isRefreshing = ref(false)
const iframeLoading = ref(true)
@@ -682,6 +683,7 @@ onMounted(() => {
})
onBeforeUnmount(() => {
store.setNostrFrame(null)
clearTimers()
stopProgress()
window.removeEventListener('keydown', onKeyDown, true)
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { __setSignedCatalogForTests } from '@/views/discover/curatedApps'
import { nextTick } from 'vue'
// The signed catalog's embedded manifests decide which ports the app gate
// fronts (TLS on the same port) — prime the same shape the live catalog
@@ -36,6 +37,90 @@ vi.stubGlobal('open', mockWindowOpen)
import { useAppLauncherStore, senderMatchesApp } from '../appLauncher'
import { useAppStore } from '../app'
describe('legacy overlay native signer lifecycle', () => {
let store: ReturnType<typeof useAppLauncherStore>
const appUrl = 'http://192.0.2.10:19999'
const identityKey = 'archipelago_app_identity_' + appUrl.replace(/[^a-z0-9]/gi, '_')
const frame = { postMessage: vi.fn() } as unknown as Window
function request(id: string, source = frame, origin = appUrl) {
const event = new MessageEvent('message', { origin, data: { type: 'nostr-request', id,
method: 'signEvent', params: { event: { kind: 27235, content: id } } } })
Object.defineProperty(event, 'source', { value: source })
window.dispatchEvent(event)
}
beforeEach(() => {
vi.useFakeTimers()
setActivePinia(createPinia())
localStorage.clear()
vi.clearAllMocks()
mockRpcCall.mockResolvedValue({ id: 'signed-fixture' })
Object.defineProperty(window, 'location', { value: { origin: 'http://192.0.2.10', protocol: 'http:', hostname: '192.0.2.10' }, configurable: true })
Object.defineProperty(window, 'innerWidth', { value: 1024, configurable: true })
localStorage.setItem(identityKey, JSON.stringify({ id: 'legacy-identity', name: 'Saved creator' }))
store = useAppLauncherStore()
store.open({ url: appUrl, title: 'Custom film app' })
store.setNostrFrame(frame)
})
afterEach(async () => {
store.close(); store.$dispose()
await vi.runAllTimersAsync()
vi.useRealTimers()
})
it('queues simultaneous requests and preserves the saved identity and success animation', async () => {
request('first'); request('second')
expect(store.consentRequest?.content).toBe('first')
expect(store.consentRequest?.identityLabel).toBe('Saved creator')
store.approveConsent(false)
await vi.advanceTimersByTimeAsync(350)
expect(store.consentPhase).toBe('success')
expect(mockRpcCall).toHaveBeenCalledTimes(1)
expect(mockRpcCall).toHaveBeenCalledWith({ method: 'identity.nostr-sign', params: {
id: 'legacy-identity', event: { kind: 27235, content: 'first' } } })
await vi.advanceTimersByTimeAsync(325)
expect(store.consentRequest?.content).toBe('second')
store.approveConsent(false)
await vi.advanceTimersByTimeAsync(675)
expect(frame.postMessage).toHaveBeenCalledTimes(2)
expect(frame.postMessage).toHaveBeenNthCalledWith(1, { type: 'nostr-response', id: 'first', result: { id: 'signed-fixture' } }, appUrl)
expect(frame.postMessage).toHaveBeenNthCalledWith(2, { type: 'nostr-response', id: 'second', result: { id: 'signed-fixture' } }, appUrl)
expect(store.showConsent).toBe(false)
})
it('rejects a same-origin window that is not the actual app iframe', async () => {
const other = { postMessage: vi.fn() } as unknown as Window
request('forged-window', other)
await nextTick()
expect(store.showConsent).toBe(false)
expect(mockRpcCall).not.toHaveBeenCalled()
expect(other.postMessage).not.toHaveBeenCalled()
})
it.each(['close', 'frame', 'url', 'dispose'])('settles all pending requests on %s without signing', async action => {
request('first'); request('second')
if (action === 'close') store.close()
if (action === 'frame') store.setNostrFrame(null)
if (action === 'url') store.url = 'http://192.0.2.10:19998'
if (action === 'dispose') store.$dispose()
await vi.runAllTimersAsync()
expect(mockRpcCall).not.toHaveBeenCalled()
expect(frame.postMessage).toHaveBeenCalledTimes(2)
for (const [response] of vi.mocked(frame.postMessage).mock.calls) expect(response).toHaveProperty('error')
})
it('rejects identity changes before approval and can reopen after closing', async () => {
request('old-identity')
localStorage.setItem(identityKey, JSON.stringify({ id: 'another-identity', name: 'Other' }))
store.approveConsent(false)
await vi.runAllTimersAsync()
expect(mockRpcCall).not.toHaveBeenCalled()
store.close()
store.open({ url: appUrl, title: 'Custom film app' })
store.setNostrFrame(frame)
request('reopened')
await vi.advanceTimersByTimeAsync(0)
store.approveConsent(false)
await vi.advanceTimersByTimeAsync(675)
expect(mockRpcCall).toHaveBeenCalledWith(expect.objectContaining({ params: expect.objectContaining({ id: 'another-identity' }) }))
})
});
describe('useAppLauncherStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
+36 -193
View File
@@ -1,6 +1,6 @@
import { appHasMediaBridge, ensureNodeAppAvailable, nodeAppIsAvailable } from '@/views/discover/curatedApps'
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { ref, watch, onScopeDispose } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import { recordAppLaunch } from '@/utils/appUsage'
import { requestExternalOpen } from '@/api/remote-relay'
@@ -13,11 +13,8 @@ import { useToast } from '@/composables/useToast'
import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
import type { AppCredential, AppCredentialsResponse } from '@/types/api'
import { resolveAppCredentials } from '@/views/apps/appCredentials'
import {
consentKey,
hasRememberedConsent,
rememberConsent,
} from '@/views/appSession/nostrConsent'
import { useNostrBridge } from '@/views/appSession/useNostrBridge'
import type { SelectedIdentity } from '@/views/appSession/useAppIdentity'
/**
* Open a URL in a new browser tab — but if a companion (phone) is currently
@@ -190,16 +187,6 @@ const PORT_TO_APP_ID: Record<string, string> = {
'50002': 'electrumx',
}
export interface NostrConsentRequest {
appName: string
method: string
eventKind?: number
content?: string
identityLabel?: string
resolve: (remember: boolean) => void
reject: () => void
}
/** App identity (catalog icon + display name) for the companion's native
* branded loader. Undefined when the app isn't in package-data. */
function launchMeta(appId: string): InAppLaunchMeta | undefined {
@@ -215,10 +202,6 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
const isOpen = ref(false)
const url = ref('')
const title = ref('')
const consentRequest = ref<NostrConsentRequest | null>(null)
const showConsent = ref(false)
const consentPhase = ref<'review' | 'signing' | 'success' | 'error'>('review')
const consentError = ref('')
const credentialPrompt = ref({
show: false,
loading: false,
@@ -230,9 +213,6 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
})
let pendingCredentialLaunch: { appId: string; path?: string } | null = null
let credentialGeneration = 0
let consentApprovedAt = 0
let consentGeneration = 0
let approvedGeneration = 0
let previousActiveElement: HTMLElement | null = null
/** Active app in the store-driven session (no route change) */
@@ -526,7 +506,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
}
function close() {
if (showConsent.value) denyConsent()
bridge.cancelPending()
const toRestore = previousActiveElement
previousActiveElement = null
isOpen.value = false
@@ -542,176 +522,37 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
}
}
function approveConsent(remember: boolean) {
if (consentRequest.value) {
consentRequest.value.resolve(remember)
}
consentApprovedAt = Date.now()
approvedGeneration = consentGeneration
consentPhase.value = 'signing'
}
function denyConsent() {
consentGeneration += 1
if (consentRequest.value) {
consentRequest.value.reject()
consentRequest.value = null
}
showConsent.value = false
consentPhase.value = 'review'
consentError.value = ''
}
async function finishConsentSuccess() {
const generation = approvedGeneration
const remaining = Math.max(0, 350 - (Date.now() - consentApprovedAt))
if (remaining) await new Promise(resolve => setTimeout(resolve, remaining))
if (generation !== consentGeneration || !showConsent.value) return
consentPhase.value = 'success'
await new Promise(resolve => setTimeout(resolve, 325))
if (generation !== consentGeneration) return
consentRequest.value = null
showConsent.value = false
consentPhase.value = 'review'
}
function finishConsentError(error: unknown) {
consentError.value = error instanceof Error ? error.message : 'The node could not complete this request.'
consentPhase.value = 'error'
}
function requestConsent(appName: string, method: string, eventKind?: number, content?: string, identityLabel?: string): Promise<boolean> {
return new Promise((resolve, reject) => {
consentGeneration += 1
consentRequest.value = {
appName, method, eventKind, content, identityLabel,
resolve, reject,
}
consentPhase.value = 'review'
consentError.value = ''
showConsent.value = true
})
}
// NIP-07 postMessage handler — responds to nostr-request from iframe apps
async function handleNostrRequest(event: MessageEvent) {
if (!event.data || event.data.type !== 'nostr-request') return
const { id, method, params } = event.data
const source = event.source as Window | null
if (!source) return
// Only the app we actually opened may drive this bridge — see
// senderMatchesApp for why the scheme is deliberately not compared.
if (!senderMatchesApp(url.value, event.origin)) return
const origin = event.origin
let prompted = false
const activeAppId = resolveAppIdFromUrl(url.value) || inferAppIdFromTitle(title.value) || 'unknown-app'
// Check if app has a per-app identity stored (from identity picker)
const IDENTITY_KEY = 'archipelago_app_identity_'
const appKey = IDENTITY_KEY + (url.value || '').replace(/[^a-z0-9]/gi, '_')
let appIdentityId: string | null = null
// Reuse the same bounded queue and consent lifecycle as AppSession. Keep
// the legacy URL-based identity storage key so existing selections survive.
let nostrFrame: Window | null = null
function overlayIdentity(): SelectedIdentity | null {
try {
const stored = localStorage.getItem(appKey)
if (stored) {
const parsed: unknown = JSON.parse(stored)
if (typeof parsed === 'object' && parsed !== null && 'id' in parsed) {
const idVal = (parsed as Record<string, unknown>).id
appIdentityId = typeof idVal === 'string' ? idVal : null
}
}
} catch { /* ignore */ }
// Every identity-sensitive method needs consent (or a remembered
// approval for this origin) — not just signEvent. getPublicKey
// deanonymizes; the decrypts turn the node into a decryption oracle.
const CONSENT_METHODS = new Set([
'getPublicKey', 'signEvent',
'nip04.encrypt', 'nip04.decrypt',
'nip44.encrypt', 'nip44.decrypt',
])
const scopedKey = consentKey(origin, activeAppId, appIdentityId || 'node-default', method)
const alreadyApproved = hasRememberedConsent(scopedKey)
if (CONSENT_METHODS.has(method) && !alreadyApproved) {
prompted = true
const eventKind = method === 'signEvent' ? (params?.event?.kind as number | undefined) : undefined
const content = method === 'signEvent' ? (params?.event?.content as string | undefined) : undefined
try {
const remember = await requestConsent(
title.value || 'App', method, eventKind, content,
appIdentityId || 'Node default identity',
)
if (remember) rememberConsent(scopedKey)
} catch {
source.postMessage({ type: 'nostr-response', id, error: `User denied ${method} request` }, origin || '*')
return
}
}
try {
let result: unknown
if (method === 'getPublicKey') {
if (appIdentityId) {
// Use the app-specific identity's Nostr key
const res = await rpcClient.call<{ nostr_pubkey: string; nostr_npub: string; id: string; name: string; pubkey: string; did: string; is_default: boolean }>({
method: 'identity.get', params: { id: appIdentityId }
})
result = res.nostr_pubkey
} else {
const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'node.nostr-pubkey' })
result = res.nostr_pubkey
}
} else if (method === 'signEvent') {
if (appIdentityId) {
// Sign with the app-specific identity's Nostr key
const res = await rpcClient.call<unknown>({
method: 'identity.nostr-sign',
params: { id: appIdentityId, event: params.event }
})
result = res
} else {
const res = await rpcClient.call<unknown>({ method: 'node.nostr-sign', params: { event: params.event } })
result = res
}
} else if (method === 'getRelays') {
result = {}
} else if (method === 'nip04.encrypt') {
const res = await rpcClient.call<{ ciphertext: string }>({
method: 'identity.nostr-encrypt-nip04',
params: { id: appIdentityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext }
})
result = res.ciphertext
} else if (method === 'nip04.decrypt') {
const res = await rpcClient.call<{ plaintext: string }>({
method: 'identity.nostr-decrypt-nip04',
params: { id: appIdentityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext }
})
result = res.plaintext
} else if (method === 'nip44.encrypt') {
const res = await rpcClient.call<{ ciphertext: string }>({
method: 'identity.nostr-encrypt-nip44',
params: { id: appIdentityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext }
})
result = res.ciphertext
} else if (method === 'nip44.decrypt') {
const res = await rpcClient.call<{ plaintext: string }>({
method: 'identity.nostr-decrypt-nip44',
params: { id: appIdentityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext }
})
result = res.plaintext
} else {
throw new Error(`Unsupported NIP-07 method: ${method}`)
}
source.postMessage({ type: 'nostr-response', id, result }, origin || '*')
if (prompted) void finishConsentSuccess()
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
source.postMessage({ type: 'nostr-response', id, error: message }, origin || '*')
if (prompted && showConsent.value) finishConsentError(err)
}
const key = 'archipelago_app_identity_' + url.value.replace(/[^a-z0-9]/gi, '_')
const stored = JSON.parse(localStorage.getItem(key) || 'null')
if (!stored || typeof stored.id !== 'string' || !stored.id) return null
return { ...stored, name: typeof stored.name === 'string' ? stored.name : stored.id }
} catch { return null }
}
const bridge = useNostrBridge(overlayIdentity, {
appId: () => resolveAppIdFromUrl(url.value) || inferAppIdFromTitle(title.value) || 'unknown-app',
appName: () => title.value || 'App',
appUrl: () => url.value,
frameWindow: () => isOpen.value ? nostrFrame : null,
})
const { handleNostrRequest, showConsent, consentRequest, consentPhase,
consentError, approveConsent, denyConsent } = bridge
function setNostrFrame(frame: Window | null) {
if (frame === nostrFrame) return
bridge.cancelPending()
nostrFrame = frame
}
watch(url, () => bridge.cancelPending(), { flush: 'sync' })
onScopeDispose(() => {
window.removeEventListener('message', handleNostrRequest)
bridge.dispose()
nostrFrame = null
})
// Listen for NIP-07 requests only while an app is open
watch(isOpen, (open) => {
@@ -719,8 +560,9 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
window.addEventListener('message', handleNostrRequest)
} else {
window.removeEventListener('message', handleNostrRequest)
bridge.cancelPending()
}
})
}, { flush: 'sync' })
return {
isOpen,
@@ -743,5 +585,6 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
consentError,
approveConsent,
denyConsent,
setNostrFrame,
}
})
+19 -10
View File
@@ -12,15 +12,20 @@ const {chromium,expect}=require(process.env.PLAYWRIGHT_MODULE || '@playwright/te
if(!privateHost || !['http:','https:'].includes(url.protocol) || url.username || url.password)throw new Error('Refusing to send qualification cookies outside a private node');
const cookies=JSON.parse(fs.readFileSync(cookieFile,'utf8'));
const browser=await chromium.connectOverCDP(process.env.BROWSER_CDP || 'http://127.0.0.1:32911');
for(const width of [390,1440]){
for(const mode of ['session','overlay']) for(const width of [390,1440]){
const fixturePort=mode==='overlay'?19999:7778;
const fixtureUrl=new URL(origin); fixtureUrl.port=String(fixturePort); fixtureUrl.pathname='/';
const overlayUrl=fixtureUrl.href;
const context=await browser.newContext({viewport:{width,height:900},serviceWorkers:'block'});
try{
await context.addCookies(Object.entries(cookies).map(([name,value])=>({name,value,url:origin,httpOnly:name!=='csrf_token'})));
await context.addInitScript(()=>{
await context.addInitScript(({overlayUrl})=>{
localStorage.setItem('neode-auth','true');
localStorage.removeItem('archipelago_nostr_consent_v2');
localStorage.setItem('archipelago_app_identity_indeedhub',JSON.stringify({id:'qualification-only',name:'Qualification identity',did:'did:key:qualification-only',pubkey:'a'.repeat(64),nostr_pubkey:'b'.repeat(64)}));
});
const identity={id:'qualification-only',name:'Qualification identity',did:'did:key:qualification-only',pubkey:'a'.repeat(64),nostr_pubkey:'b'.repeat(64)};
localStorage.setItem('archipelago_app_identity_indeedhub',JSON.stringify(identity));
localStorage.setItem('archipelago_app_identity_'+overlayUrl.replace(/[^a-z0-9]/gi,'_'),JSON.stringify(identity));
},{overlayUrl});
let signed=0, frameLoads=0; const signedContents=[];
await context.route('**/rpc/v1',async route=>{
let request;try{request=route.request().postDataJSON()}catch{return route.continue()}
@@ -34,7 +39,7 @@ const {chromium,expect}=require(process.env.PLAYWRIGHT_MODULE || '@playwright/te
}
return route.continue();
});
await context.route('**:7778/**',async route=>{
await context.route('**:'+fixturePort+'/**',async route=>{
if(route.request().resourceType()!=='document')return route.abort();
frameLoads++;
return route.fulfill({contentType:'text/html',body:`<!doctype html><html><body><p id="result">waiting</p><script>
@@ -44,10 +49,14 @@ const {chromium,expect}=require(process.env.PLAYWRIGHT_MODULE || '@playwright/te
</script></body></html>`});
});
const page=await context.newPage();
await page.goto(origin+'/dashboard/app-session/indeedhub',{waitUntil:'domcontentloaded'});
await page.goto(origin+(mode==='overlay'?'/dashboard/apps':'/dashboard/app-session/indeedhub'),{waitUntil:'domcontentloaded'});
if(mode==='overlay'){
await page.waitForFunction(()=>document.querySelector('#app')?.__vue_app__?.config.globalProperties.$pinia?._s.has('appLauncher'));
await page.evaluate(url=>document.querySelector('#app').__vue_app__.config.globalProperties.$pinia._s.get('appLauncher').open({url,title:'Qualification custom app'}),overlayUrl);
}
const approve=page.getByRole('button',{name:'Approve',exact:true});
try { await expect(approve).toBeVisible({timeout:30000}); } catch(error) {
console.log(JSON.stringify({node,width,path:new URL(page.url()).pathname,headings:await page.locator('h1').allTextContents(),frames:await page.locator('iframe').evaluateAll(items=>items.map(item=>{const u=new URL(item.src);return {origin:u.origin,path:u.pathname}}))}));
console.log(JSON.stringify({node,mode,width,path:new URL(page.url()).pathname,headings:await page.locator('h1').allTextContents(),frames:await page.locator('iframe').evaluateAll(items=>items.map(item=>{const u=new URL(item.src);return {origin:u.origin,path:u.pathname}}))}));
throw error;
}
await approve.click();
@@ -55,14 +64,14 @@ const {chromium,expect}=require(process.env.PLAYWRIGHT_MODULE || '@playwright/te
await expect(approve).toBeVisible({timeout:10000});
await approve.click();
await expect.poll(()=>signed,{timeout:10000}).toBe(2);
const frame=page.frameLocator('iframe[src*="7778"]');
const frame=page.frameLocator('iframe[src*="'+fixturePort+'"]');
await expect(frame.locator('#result')).toContainText('"id":"first","ok":true');
try { await expect(frame.locator('#result')).toContainText('"id":"second","ok":true'); } catch(error) { console.log(JSON.stringify({node,width,frameLoads,signedContents})); throw error; }
try { await expect(frame.locator('#result')).toContainText('"id":"second","ok":true'); } catch(error) { console.log(JSON.stringify({node,mode,width,frameLoads,signedContents})); throw error; }
await page.waitForTimeout(800); // allow the required 675ms completion presentation to finish
await expect(approve).toHaveCount(0);
expect(frameLoads).toBe(1);
expect(signedContents).toEqual(['qualification-first','qualification-second']);
console.log(JSON.stringify({node,width,result:'PASS',scope:'served dashboard, concurrent iframe requests and consent responses; signing RPC isolated with fixtures; no real signing/payment'}));
console.log(JSON.stringify({node,mode,width,result:'PASS',scope:'served dashboard, concurrent iframe requests and consent responses; signing RPC isolated with fixtures; no real signing/payment'}));
}finally{await context.close()}
}
process.exit(0);