feat: integrate Just Works app and native business setup

Preserve hosted website UX inside Archipelago, add isolated Business and owner-authorized native wallet setup, and include deployment handoff. Based on main with native signer import already accepted.
This commit is contained in:
yaya
2026-10-08 19:39:38 +01:00
parent 8d17597202
commit 94899e69d3
152 changed files with 22873 additions and 2 deletions
+14 -2
View File
@@ -145,6 +145,14 @@
</template>
</div>
<JustworksSetup
v-else-if="step.id === 'connect-justworks-site'"
@connected="completeVerifyStep(step)"
/>
<JustworksPayments
v-else-if="step.id === 'connect-justworks-payments'"
@connected="completeVerifyStep(step)"
/>
<button
v-else-if="step.action === 'configure'"
@click="openConfigureStep(step)"
@@ -224,6 +232,8 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import JustworksPayments from './goals/JustworksPayments.vue'
import JustworksSetup from './goals/JustworksSetup.vue'
import BalanceAmount from '@/components/BalanceAmount.vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
@@ -247,6 +257,7 @@ import {
/** Map appId to its icon file path under /assets/img/app-icons/ */
const APP_ICON_MAP: Record<string, string> = {
justworks: '/assets/img/app-icons/justworks.svg',
'bitcoin-knots': '/assets/img/app-icons/bitcoin-knots.webp',
lnd: '/assets/img/app-icons/lnd.png',
'btcpay-server': '/assets/img/app-icons/btcpay-server.png',
@@ -270,6 +281,7 @@ function stepIconUrl(step: GoalStep): string | undefined {
* overlay on top of the current screen; X-Frame-Options apps open a tab).
*/
const GOAL_COMPLETION_CTA: Record<string, { label: string; route?: string; launchAppId?: string }> = {
'instant-business-site': { label: 'Open Just Works', launchAppId: 'justworks' },
'open-a-shop': { label: 'Go to my shop (BTCPay)', launchAppId: 'btcpay-server' },
'accept-payments': { label: 'Go to Lightning (LND)', route: '/dashboard/apps/lnd' },
'run-lightning-node': { label: 'View my channels', route: '/dashboard/apps/lnd/channels' },
@@ -398,7 +410,7 @@ async function installApp(step: GoalStep) {
function openConfigureStep(step: GoalStep) {
ensureGoalStarted()
goalStore.completeStep(goalId.value, step.id)
if (step.completion !== 'app') goalStore.completeStep(goalId.value, step.id)
const override = goalStepRouteOverride(step)
if (override) {
// Internal screens (channels, web5, settings) — tag where we came from so
@@ -408,7 +420,7 @@ function openConfigureStep(step: GoalStep) {
// Launch the app itself: iframe apps overlay on top of the wizard,
// tab-only apps open a tab (mobile: the in-app browser) — the app
// launcher handles every case.
useAppLauncherStore().openSession(step.appId)
useAppLauncherStore().openSession(step.appId, { path: step.appPath })
}
}
@@ -21,6 +21,7 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
"immich": 2283,
"indeedhub": 7778,
"jellyfin": 8096,
"justworks": 8340,
"lnd-ui": 18083,
"mempool": 4080,
"mempool-api": 8999,
@@ -79,6 +80,7 @@ export const GENERATED_APP_TITLES: Record<string, string> = {
"indeedhub-redis": "IndeedHub Redis",
"indeedhub-relay": "IndeedHub Nostr Relay",
"jellyfin": "Jellyfin",
"justworks": "Just Works",
"lnd": "LND",
"lnd-ui": "LND UI",
"mempool": "Mempool Explorer",
@@ -120,4 +122,5 @@ export const GENERATED_NEW_TAB_APPS = new Set<string>([
])
export const GENERATED_HOST_FRAME_APPS = new Set<string>([
"justworks",
])
@@ -0,0 +1,79 @@
<template>
<div class="space-y-3 rounded-xl border border-white/15 bg-white/5 p-4">
<p class="text-sm text-white/70">Save the owner key from website creation once, as a separate business identity in Archipelago.</p>
<p v-if="identity" class="text-sm text-white/80">Saved identity: {{ identity.name }}</p>
<template v-else-if="importAvailable">
<label class="block text-sm">Website owner nsec
<input v-model="nsec" type="password" autocomplete="off" maxlength="100" :disabled="busy || disabled" class="mt-1 w-full rounded-lg bg-white/10 px-3 py-2" />
</label>
<p class="text-xs text-white/60">Copy the website owner nsec from Just Works. The key must match this website; your existing identities remain unchanged.</p>
</template>
<label v-if="identity || importAvailable" class="block text-sm">Archipelago node password
<input v-model="password" type="password" autocomplete="current-password" :disabled="busy || disabled" class="mt-1 w-full rounded-lg bg-white/10 px-3 py-2" />
</label>
<label v-if="identity" class="flex items-start gap-2 text-sm text-white/80">
<input v-model="approved" type="checkbox" :disabled="busy || disabled" class="mt-1" />
Allow this request to send this identity’s private Nostr key to justworks.cash for the selected website action. This is key sharing, not local signing.
</label>
<p role="status" class="text-sm text-white/90">{{ status }}</p>
<button v-if="identity || importAvailable" type="button" :disabled="busy || disabled || !npub" @click="identity ? useKey() : importKey()" class="glass-button rounded-lg px-4 py-2 text-sm disabled:opacity-50">{{ busy ? 'Working…' : identity ? 'Use saved key' : 'Add key to native signer' }}</button>
<button v-if="loadFailed" type="button" @click="load" class="text-sm underline">Retry identity lookup</button>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { rpcClient } from '@/api/rpc-client'
const props = defineProps<{ siteId: string; npub: string; disabled?: boolean }>()
const emit = defineEmits<{ credentials: [credentials: {ownerKey: string}] }>()
interface Identity { id: string; name: string; nostr_npub?: string; is_node?: boolean }
const identity = ref<Identity | null>(null)
const nsec = ref(''), password = ref(''), approved = ref(false), busy = ref(false), status = ref(''), loadFailed = ref(false)
const importAvailable = ref(false)
let mounted = true
function clear() { nsec.value = ''; password.value = ''; approved.value = false }
async function load() {
loadFailed.value = false
try {
const result = await rpcClient.call<{identities: Identity[]}>({method:'identity.list'})
if (!mounted) return
identity.value = result.identities.find(i => !i.is_node && i.nostr_npub === props.npub) || null
if (!identity.value) {
try {
const capabilities = await rpcClient.call<{import_nostr?:boolean}>({method:'identity.capabilities', maxRetries:1})
if (mounted) importAvailable.value = capabilities.import_nostr === true
} catch { importAvailable.value = false }
if (mounted && !importAvailable.value) status.value = 'This node needs a native signer update before it can import a website key. You can still use the owner key directly in the setup guide.'
}
} catch { if (mounted) { loadFailed.value = true; status.value = 'Could not load native identities. Retry before importing a key.' } }
}
async function importKey() {
if (busy.value) return
if (loadFailed.value) { status.value = 'Retry identity lookup before importing.'; return }
if (!nsec.value.trim().startsWith('nsec1') || !password.value) { status.value = 'Enter the website owner nsec and your Archipelago node password.'; return }
busy.value = true; status.value = 'Adding the matching website key to your native signer…'
try {
const result = await rpcClient.call<Identity>({method:'identity.import-nostr', params:{name:`Just Works: ${props.siteId}`.slice(0,100), nsec:nsec.value.trim(), expected_npub:props.npub, password:password.value}, maxRetries:1})
if (!mounted) return
if (result.nostr_npub !== props.npub) throw new Error('Identity mismatch')
identity.value = result; status.value = 'Key saved in Archipelago. Authorize its use below to connect payments.'
} catch { if (mounted) status.value = 'Could not import the key. Check the key and node password. Your node must support identity.import-nostr.' }
finally { clear(); busy.value = false }
}
async function useKey() {
if (busy.value || !identity.value) return
if (!approved.value || !password.value) { status.value = 'Enter your node password and approve sharing this key with Just Works.'; return }
busy.value = true; status.value = 'Unlocking the saved website key…'
try {
const result = await rpcClient.call<{nostr_npub?:string; nostr_nsec?:string}>({method:'identity.export-keys', params:{id:identity.value.id, password:password.value}, maxRetries:1})
if (!mounted) return
if (result.nostr_npub !== props.npub || !result.nostr_nsec?.startsWith('nsec1')) throw new Error('Identity mismatch')
const ownerKey = result.nostr_nsec
result.nostr_nsec = undefined
emit('credentials', {ownerKey})
status.value = 'Request sent. Check the publication status below.'
} catch { if (mounted) status.value = 'Could not unlock the matching website key. Check your node password and retry.' }
finally { clear(); busy.value = false }
}
onMounted(load)
onUnmounted(() => { mounted = false; clear() })
</script>
@@ -0,0 +1,149 @@
<template>
<div class="space-y-4">
<p v-if="IS_DEMO" class="text-sm text-white/60">Connect payments from your own node. This preview does not change a live payment address.</p>
<template v-else>
<p v-if="siteId" class="text-sm text-white/70 break-all">Website: https://justworks.cash/{{ siteId }}</p>
<button v-if="!address" :disabled="busy || !siteId" @click="prepare" class="glass-button rounded-lg px-5 py-2 text-sm disabled:opacity-50">{{ busy ? 'Getting wallet address…' : 'Use my Archipelago wallet' }}</button>
<form v-else novalidate @submit.prevent="publish" class="space-y-3">
<p class="text-sm text-white/80 break-all">Payments will go to <strong>{{ address }}</strong>.</p>
<p class="text-sm text-white/60">Authorize Just Works to update and republish your website. Your owner sign-in is used for this request only and is not saved.</p>
<label class="block text-sm text-white/80">Sign in with
<select v-model="mode" :disabled="busy" class="block mt-1 w-full rounded-lg bg-black/30 border border-white/20 px-3 py-2"><option value="login">Just Works owner login</option><option value="key">Just Works owner key</option><option value="native">Saved Archipelago identity</option></select>
</label>
<template v-if="mode === 'login'">
<label class="block text-sm text-white/80">Owner login<input v-model="ownerIdentifier" :disabled="busy" required autocomplete="username" maxlength="200" class="block mt-1 w-full rounded-lg bg-white/10 border border-white/20 px-3 py-2" /></label>
<label class="block text-sm text-white/80">Owner password<input v-model="ownerPassword" :disabled="busy" required type="password" autocomplete="current-password" maxlength="2048" class="block mt-1 w-full rounded-lg bg-white/10 border border-white/20 px-3 py-2" /></label>
</template>
<template v-else-if="mode === 'key'">
<label class="block text-sm text-white/80">Owner key<input v-model="ownerKey" :disabled="busy" required type="password" autocomplete="off" maxlength="2048" placeholder="nsec1… or ncryptsec1…" class="block mt-1 w-full rounded-lg bg-white/10 border border-white/20 px-3 py-2" /></label>
<label class="block text-sm text-white/80">Key password (if encrypted)<input v-model="keyPassword" :disabled="busy" type="password" autocomplete="off" maxlength="2048" class="block mt-1 w-full rounded-lg bg-white/10 border border-white/20 px-3 py-2" /></label>
</template>
<label v-if="mode === 'native'" class="block text-sm">Use the saved key to
<select v-model="nativeAction" :disabled="busy" class="block mt-1 rounded-lg bg-black/30 px-3 py-2"><option value="payment">Connect wallet &amp; publish</option><option value="business">Sign into Business tools</option></select>
</label>
<JustworksNativeKey v-if="mode === 'native'" :site-id="siteId" :npub="siteNpub" :disabled="busy" @credentials="publishNative" />
<p role="status" aria-live="polite" class="text-sm text-white/90">{{ status }}</p>
<button v-if="mode !== 'native'" type="submit" :disabled="busy" class="glass-button rounded-lg px-5 py-2 text-sm disabled:opacity-50">{{ busy ? 'Publishing and verifying…' : 'Connect wallet & publish' }}</button>
</form>
<button v-if="!siteId" @click="useAppLauncherStore().openSession('justworks')" class="glass-button rounded-lg px-4 py-2 text-sm">Open Just Works</button>
<button @click="showReceive = true" class="text-sm text-white/70 underline">Open wallet Receive</button>
<p class="text-xs text-white/50">Uses the existing hosted Lightning address and mint. Open wallet Receive → Ecash to claim incoming payments.</p>
</template>
<p v-if="!address" role="status" class="text-sm text-white/75">{{ status }}</p>
<iframe v-if="!IS_DEMO" ref="bridge" :src="bridgeUrl" @load="loadConnection" title="Just Works payment connection" hidden />
<ReceiveBitcoinModal :show="showReceive" @close="showReceive = false" />
</div>
</template>
<script setup lang="ts">
import JustworksNativeKey from './JustworksNativeKey.vue'
import { ref, onMounted, onUnmounted } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import { IS_DEMO } from '@/composables/useDemoIntro'
import { useAppLauncherStore } from '@/stores/appLauncher'
import ReceiveBitcoinModal from '@/components/ReceiveBitcoinModal.vue'
import { resolveAppUrl } from '../appSession/appSessionConfig'
const emit = defineEmits<{ connected: [] }>()
const bridge = ref<HTMLIFrameElement | null>(null)
const bridgeUrl = resolveAppUrl('justworks', '/bridge.html')
const origin = new URL(bridgeUrl, location.origin).origin
const nativeAction = ref('payment')
const siteNpub = ref('')
const siteId = ref(''), address = ref(''), status = ref('Checking your connected website…')
const busy = ref(false), showReceive = ref(false), mode = ref('login')
const ownerIdentifier = ref(''), ownerPassword = ref(''), ownerKey = ref(''), keyPassword = ref('')
let pending = '', operation = '', timer: ReturnType<typeof setTimeout> | undefined
function clearSecrets() { ownerPassword.value = ''; ownerKey.value = ''; keyPassword.value = '' }
function send(type: string, payload?: unknown) {
pending = Array.from(crypto.getRandomValues(new Uint32Array(4))).join('-')
operation = type
bridge.value?.contentWindow?.postMessage({type, requestId:pending, payload}, origin)
clearTimeout(timer)
timer = setTimeout(() => {
pending = ''; busy.value = false; clearSecrets()
status.value = type === 'justworks:connect-payment' ? 'Publication could not be verified. The address may have been saved. Retry to check and publish again.' : 'Open Just Works to check app access, then reopen this guide.'
}, type === 'justworks:connect-payment' ? 180000 : type === 'justworks:business-login' ? 65000 : 15000)
}
function loadConnection() { send('justworks:get-connection') }
async function prepare() {
busy.value = true; status.value = 'Getting the address from your native wallet…'
try {
const result = await rpcClient.call<{address?: string}>({method:'wallet.ecash-lnaddress', timeout:60000})
if (!result.address || !/^[a-z0-9._+-]{1,100}@minibits\.cash$/.test(result.address)) throw new Error('Native wallet address is unavailable. Open wallet Receive → Ecash to finish wallet setup, then retry.')
address.value = result.address
status.value = 'Ready for your Just Works owner authorization.'
} catch { status.value = 'Could not get the native wallet address. Open wallet Receive → Ecash to finish setup, then retry.' }
finally { busy.value = false }
}
function publishNative(credentials: {ownerKey: string}) {
if (busy.value) return
if (nativeAction.value === 'business') {
busy.value = true
status.value = 'Signing into your local Business tools…'
try { send('justworks:business-login', {npub:siteNpub.value, ownerKey:credentials.ownerKey}) }
catch { busy.value = false; pending = ''; status.value = 'Could not contact Business tools. Reload and retry.' }
return
}
ownerKey.value = credentials.ownerKey
mode.value = 'key'
publish()
mode.value = 'native'
}
function publish() {
if (busy.value) return
// Native validation can prevent submit without visible feedback on mobile.
// Accept pasted owner keys in either mode; never send them as login identifiers.
const identifier = ownerIdentifier.value.trim()
const pastedKey = /^(nsec1|ncryptsec1)/.test(identifier)
const key = (mode.value === 'key' ? ownerKey.value : pastedKey ? identifier : '').trim()
if (!siteId.value || !address.value) {
status.value = 'Reconnect your website and select your Archipelago wallet first.'
return
}
if (mode.value === 'key' || pastedKey) {
if (!/^(nsec1|ncryptsec1)/.test(key)) {
status.value = 'Enter the website owner’s nsec1… or ncryptsec1… key. A public npub cannot authorize changes.'
return
}
} else if (!identifier || !ownerPassword.value) {
status.value = 'Enter your Just Works owner login and password, or choose Just Works owner key to use an nsec.'
return
}
const credentials = key
? {ownerKey:key, password:mode.value === 'key' ? keyPassword.value : ownerPassword.value}
: {ownerIdentifier:identifier, ownerPassword:ownerPassword.value}
busy.value = true
status.value = 'Saving the payment address, publishing, and checking the public website…'
try {
if (!bridge.value?.contentWindow) throw new Error('Connector unavailable')
send('justworks:connect-payment', {siteId:siteId.value, address:address.value, credentials})
} catch {
clearTimeout(timer); pending = ''; busy.value = false
status.value = 'Could not contact the Just Works app. Reload this guide and try again.'
} finally {
if (pastedKey) ownerIdentifier.value = ''
clearSecrets()
}
}
function receive(event: MessageEvent) {
if (!pending || event.source !== bridge.value?.contentWindow || event.origin !== origin || event.data?.requestId !== pending) return
if (operation === 'justworks:get-connection' && event.data.type === 'justworks:connection') {
siteNpub.value = typeof event.data.npub === 'string' ? event.data.npub : ''
const id = event.data.siteId
siteId.value = typeof id === 'string' && id.length <= 160 && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id) ? id : ''
status.value = siteId.value ? '' : 'Connect your website in the previous step first, in this browser.'
} else if (operation === 'justworks:business-login' && event.data.type === 'justworks:business-result') {
status.value = event.data.ok === true && event.data.npub === siteNpub.value
? 'Business tools signed in. Open Just Works and choose Business tools.'
: 'Business tools could not verify the owner key. Your website has not been changed.'
} else if (operation === 'justworks:connect-payment' && event.data.type === 'justworks:payment-result') {
if (event.data.verified === true && event.data.siteId === siteId.value && event.data.address === address.value) {
status.value = 'The published website now uses your Archipelago wallet address.'
emit('connected')
} else status.value = typeof event.data.error === 'string' ? event.data.error : 'The published payment destination could not be verified. Retry.'
} else if (event.data.type === 'justworks:site-error') status.value = 'Could not reach Just Works. Retry after checking app access.'
else return
clearTimeout(timer); pending = ''; busy.value = false; clearSecrets()
}
onMounted(() => window.addEventListener('message', receive))
onUnmounted(() => { clearTimeout(timer); clearSecrets(); window.removeEventListener('message', receive) })
</script>
@@ -0,0 +1,62 @@
<template>
<div class="space-y-4">
<p class="text-sm text-white/70">1. Open Just Works and choose Create. Add your business name and existing website or social links, then publish using the usual Just Works setup.</p>
<button @click="useAppLauncherStore().openSession('justworks')" class="glass-button inline-flex rounded-lg px-5 py-2 text-sm">Open Just Works</button>
<p class="text-sm text-white/70">2. Return here with your published page link. Connecting your first website reveals Business tools in the launcher. Use your existing Just Works owner login there.</p>
<p v-if="IS_DEMO" class="text-sm text-amber-200">Demo: explore the original Just Works site above. Connecting a published page requires the installed app on your node.</p>
<form v-else class="space-y-3" @submit.prevent="connect">
<label class="block text-sm text-white/80">Your Just Works page or npub
<input v-model="url" required maxlength="240" type="text" inputmode="url" autocomplete="url" placeholder="https://justworks.cash/your-business" class="mt-2 w-full rounded-lg bg-white/10 border border-white/20 px-3 py-2 text-white" />
</label>
<p class="text-xs text-white/50">Use a public page link or npub. Connecting a page does not sign you in or establish ownership.</p>
<button :disabled="busy || !bridgeReady" class="glass-button rounded-lg px-5 py-2 text-sm disabled:opacity-50">{{ busy ? 'Checking…' : 'Check and connect' }}</button>
</form>
<p role="status" class="text-sm text-white/75">{{ status }}</p>
<iframe v-if="!IS_DEMO" ref="bridge" :src="bridgeUrl" @load="bridgeReady = true" title="Just Works connection" hidden />
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { IS_DEMO } from '@/composables/useDemoIntro'
import { resolveAppUrl } from '../appSession/appSessionConfig'
import { isJustworksCompletion } from './justworksCompletion'
const emit = defineEmits<{ connected: [] }>()
const bridge = ref<HTMLIFrameElement | null>(null)
const bridgeUrl = resolveAppUrl('justworks', '/bridge.html')
const origin = new URL(bridgeUrl, window.location.origin).origin
const requestedSite = useRoute().query.site
const url = ref(typeof requestedSite === 'string' && requestedSite.length <= 240 ? requestedSite : '')
const bridgeReady = ref(false)
const busy = ref(false)
const status = ref('')
let requestId = ''
let timer: ReturnType<typeof setTimeout> | undefined
function connect() {
busy.value = true
status.value = 'Checking your published website…'
requestId = Array.from(crypto.getRandomValues(new Uint32Array(4))).join('-')
bridge.value?.contentWindow?.postMessage({type:'justworks:check-site', requestId, url:url.value.trim()}, origin)
clearTimeout(timer)
timer = setTimeout(() => {
busy.value = false
requestId = ''
status.value = 'Could not connect. Open the installed Just Works app to check access, then return here and retry.'
}, 20000)
}
function receive(event: MessageEvent) {
if (!busy.value || event.source !== bridge.value?.contentWindow || event.origin !== origin || event.data?.requestId !== requestId) return
if (isJustworksCompletion(event, 'justworks', bridgeUrl, bridge.value?.contentWindow ?? null)) {
status.value = event.data.remembered ? 'Website connected. Business tools are now available in your launcher.' : 'Allow browser storage for Just Works, then reconnect so the launcher can remember your website.'
if (event.data.remembered) emit('connected')
} else if (event.data?.type === 'justworks:site-error') {
status.value = typeof event.data.error === 'string' ? event.data.error : 'Could not check this website. Try again.'
} else return
clearTimeout(timer)
busy.value = false
requestId = ''
}
onMounted(() => window.addEventListener('message', receive))
onUnmounted(() => { clearTimeout(timer); window.removeEventListener('message', receive) })
</script>
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { isJustworksCompletion } from '../justworksCompletion'
import { GOALS } from '@/data/goals'
describe('Just Works verified setup handoff', () => {
const frame = {} as Window
const url = 'https://node.local:8340/setup'
const message = (changes = {}) => ({
source: frame, origin: 'https://node.local:8340',
data: { type: 'justworks:site-connected', site: { id: 'my-bakery', published: true } },
...changes,
}) as MessageEvent
it('accepts a published page from the active Just Works frame', () => {
expect(isJustworksCompletion(message(), 'justworks', url, frame)).toBe(true)
})
it('rejects other windows, origins, apps, and incomplete results', () => {
for (const event of [message({ source: {} }), message({ origin: 'https://evil.test' }),
message({ origin: 'http://node.local:8340' }), message({ data: null }),
message({ data: { type: 'justworks:site-connected', site: { id: '../x', published: true } } }),
message({ data: { type: 'justworks:site-connected', site: { id: 'my-bakery', published: false } } }),
]) expect(isJustworksCompletion(event, 'justworks', url, frame)).toBe(false)
expect(isJustworksCompletion(message(), 'filebrowser', url, frame)).toBe(false)
expect(isJustworksCompletion(message(), 'justworks', url, null)).toBe(false)
})
it('requires verified app completion, with no payment-stack dependency', () => {
const goal = GOALS.find(g => g.id === 'instant-business-site')!
expect(goal.requiredApps).toEqual(['justworks'])
const configure = goal.steps.find(s => s.action === 'configure')!
expect(configure.completion).toBe('app')
expect(configure.appPath).toBeUndefined()
})
it('never completes real setup from demo progress', () => {
const demo = message({ data: { type: 'justworks:demo-site-connected', site: { id: 'corner-bakery-demo', published: false } } })
expect(isJustworksCompletion(demo, 'justworks', url, frame)).toBe(false)
expect(isJustworksCompletion(demo, 'justworks', url, frame, true)).toBe(true)
})
})
@@ -0,0 +1,22 @@
/** Accept setup progress only from the active, gate-protected Just Works frame.
* A connected public page is not proof of merchant ownership or a login. */
export function isJustworksCompletion(
event: MessageEvent,
appId: string,
appUrl: string,
frameWindow: Window | null,
allowDemo = false,
): boolean {
if (appId !== 'justworks' || !frameWindow || event.source !== frameWindow) return false
try {
if (event.origin !== new URL(appUrl, window.location.origin).origin) return false
} catch { return false }
const site = event.data?.site
const verified = event.data?.type === 'justworks:site-connected' && site?.published === true
const sample = allowDemo && event.data?.type === 'justworks:demo-site-connected'
&& site?.published === false && site?.id === 'corner-bakery-demo'
return (verified || sample)
&& typeof site.id === 'string'
&& site.id.length <= 160
&& /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(site.id)
}