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
+1
View File
@@ -21,6 +21,7 @@ COPY docker/bitcoin-ui /docker/bitcoin-ui
COPY docker/electrs-ui /docker/electrs-ui
COPY docker/lnd-ui /docker/lnd-ui
COPY docker/fedimint-ui /docker/fedimint-ui
COPY docker/justworks/public /docker/justworks/public
COPY demo/files /demo/files
COPY demo/content /demo/content
# Peer catalog media (posters/covers/photos for content.browse-peer mocks) —
+7
View File
@@ -1578,6 +1578,8 @@ function demoFederationNodes() {
// shell's bundled assets (qrcode.js, css, bg images, icons) resolve via their
// relative paths. The data endpoints each shell polls are mocked separately.
const DOCKER_UI = path.join(__dirname, '..', 'docker')
app.use('/app/justworks', express.static(path.join(DOCKER_UI, 'justworks/public')))
app.get('/app/justworks/setup', (_req, res) => res.sendFile(path.join(DOCKER_UI, 'justworks/public/index.html')))
for (const [prefixes, dir] of [
[['/app/bitcoin-core', '/app/bitcoin-knots', '/app/bitcoin-ui'], 'bitcoin-ui'],
[['/app/electrumx', '/app/electrs', '/app/archy-electrs-ui'], 'electrs-ui'],
@@ -1967,6 +1969,11 @@ app.post('/rpc/v1', (req, res) => {
})
}
// The dashboard uses this read-only RPC to validate its session on reload.
// Keep demo deep links (including setup guides) compatible with that probe.
case 'system.get-hostname':
return res.json({ result: { hostname: 'archipelago-demo', mdns_hostname: 'archipelago-demo.local', lan_ip: '127.0.0.1' } })
case 'auth.login': {
const { password } = params
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 100 100">
<!-- normalized by scripts/normalize-app-icon.py: margin=0.12 per side -->
<svg x="12.000" y="12.000" width="76.000" height="76.000" viewBox="0 0 32 32" preserveAspectRatio="xMidYMid meet">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><linearGradient id="portal-light" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#fafafa" stop-opacity=".44"/><stop offset="100%" stop-color="#fafafa" stop-opacity="0"/></linearGradient></defs><path fill="url(#portal-light)" d="M9 32V16a7 7 0 0 1 14 0v16Z"/><path fill="#fafafa" fill-rule="evenodd" d="M2 32V16C2 8.3 8.3 2 16 2s14 6.3 14 14v16h-7V16a7 7 0 0 0-14 0v16Z"/></svg>
</svg>
</svg>

After

Width:  |  Height:  |  Size: 754 B

+12
View File
@@ -673,6 +673,18 @@
"tier": "optional",
"icon": "/assets/img/app-icons/angor-green.png",
"repoUrl": "https://github.com/hoytech/strfry"
},
{
"id": "justworks",
"title": "Just Works",
"version": "0.1.0",
"description": "Turn your existing business links into a website with Just Works. Open your website editor and business tools from one lightweight launcher. Uses the hosted Just Works services; an internet connection is required.",
"icon": "/assets/img/app-icons/justworks.svg",
"author": "Just Works contributors",
"category": "business",
"tier": "optional",
"repoUrl": "https://github.com/bencoin21/justworks.cash",
"dockerImage": "localhost/archipelago-justworks:0.1.0"
}
]
}
+1
View File
@@ -65,6 +65,7 @@ const goalStatuses = goalStore.goalStatuses
/** 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',
+1
View File
@@ -38,6 +38,7 @@ const DEMO_PROXY_PORTS: Record<string, number> = {
// Apps loaded in the in-app iframe via a same-origin path served by the mock
// backend (per-app mock UIs and placeholder dashboards).
const DEMO_MOCK_UI: Record<string, string> = {
justworks: '/app/justworks/',
mempool: '/app/mempool/',
'mempool-web': '/app/mempool/',
'bitcoin-knots': '/app/bitcoin-knots/',
+38
View File
@@ -22,6 +22,44 @@ const FUND_WALLET_STEP: GoalStep = {
}
export const GOALS: GoalDefinition[] = [
{
id: 'instant-business-site',
title: 'Instant business site',
subtitle: 'Turn your existing business links into a website with Just Works',
icon: 'shop',
category: 'commerce',
requiredApps: ['justworks'],
steps: [
{
id: 'install-justworks',
title: 'Install Just Works',
description: 'A lightweight home for your website and business tools. Uses the existing Just Works services; no Bitcoin node or channel setup is needed.',
appId: 'justworks',
action: 'install',
isAutomatic: true,
},
{
id: 'connect-justworks-site',
title: 'Create or connect your business site',
description: 'Start with your existing website or social links, create your page in Just Works, then connect its public link. This step finishes when Just Works confirms the page is published. Owner login stays with Just Works.',
appId: 'justworks',
action: 'configure',
completion: 'app',
isAutomatic: false,
},
{
id: 'connect-justworks-payments',
title: 'Receive payments in your Archipelago wallet',
description: 'Use this node’s Lightning address on your hosted website. Customer payments become ecash in your Archipelago wallet. No Bitcoin node or LND setup is required.',
appId: 'justworks',
action: 'configure',
completion: 'app',
isAutomatic: false,
},
],
estimatedTime: '~5–10 min',
difficulty: 'beginner',
},
{
id: 'open-a-shop',
title: 'Open a Shop',
+4
View File
@@ -27,6 +27,10 @@ export interface GoalStep {
icon?: string
/** Custom label for the step's CTA button (configure steps) */
ctaLabel?: string
/** Path inside the app for a guided setup entry point. */
appPath?: string
/** App-owned verification must acknowledge completion, rather than a click. */
completion?: 'app'
}
export type GoalStatus = 'not-started' | 'in-progress' | 'completed' | 'error'
+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)
}