feat(release): stage GitWorkshop and next node updates

This commit is contained in:
archipelago
2026-09-09 18:15:21 -04:00
parent 973356df16
commit f5c0ba85cd
97 changed files with 5716 additions and 1327 deletions
+32
View File
@@ -137,6 +137,38 @@ describe('useAppStore', () => {
const valid = await store.checkSession()
expect(valid).toBe(false)
expect(mockedRpc.call).not.toHaveBeenCalled()
})
it('checkSession can validate an app-gate cookie without a dashboard localStorage marker', async () => {
mockedRpc.call.mockResolvedValue('ping')
const store = useAppStore()
const valid = await store.checkSession({
allowCookieWithoutLocalMarker: true,
bootstrapDashboard: false,
})
expect(valid).toBe(true)
expect(mockedRpc.call).toHaveBeenCalledWith({
method: 'system.get-hostname',
})
expect(mockedRpc.call).toHaveBeenCalledOnce()
expect(mockedWs.connect).not.toHaveBeenCalled()
expect(store.data).toBeNull()
expect(store.isAuthenticated).toBe(true)
expect(localStorage.getItem('neode-auth')).toBe('true')
})
it('checkSession rejects a missing app-gate cookie when explicitly probed', async () => {
mockedRpc.call.mockRejectedValue(new Error('401 Unauthorized'))
const store = useAppStore()
const valid = await store.checkSession({ allowCookieWithoutLocalMarker: true })
expect(valid).toBe(false)
expect(store.isAuthenticated).toBe(false)
expect(localStorage.getItem('neode-auth')).toBeNull()
})
it('checkSession returns false and clears state on expired session', async () => {
@@ -14,9 +14,10 @@ const SIGNED = {
}
// vi.hoisted runs before vi.mock hoisting
const { mockPush, mockWindowOpen } = vi.hoisted(() => ({
const { mockPush, mockWindowOpen, mockRpcCall } = vi.hoisted(() => ({
mockPush: vi.fn(),
mockWindowOpen: vi.fn(),
mockRpcCall: vi.fn(),
}))
// Mock vue-router
@@ -26,6 +27,9 @@ vi.mock('vue-router', () => ({
vi.mock('@/router', () => ({
default: { push: mockPush, currentRoute: { value: { fullPath: '/dashboard/apps', name: 'apps' } } },
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: { call: mockRpcCall },
}))
vi.stubGlobal('open', mockWindowOpen)
@@ -35,6 +39,7 @@ describe('useAppLauncherStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockRpcCall.mockResolvedValue({ credentials: [] })
__setSignedCatalogForTests(SIGNED as never)
// Default to HTTP to avoid proxy rewriting
Object.defineProperty(window, 'location', {
@@ -66,9 +71,13 @@ describe('useAppLauncherStore', () => {
delete (window as any).ArchipelagoNative
})
it('openSession hands iframeable apps to the native WebView, never the iframe session', () => {
it('shows credentials before handing an app to the native WebView', async () => {
const store = useAppLauncherStore()
store.openSession('filebrowser')
await vi.waitFor(() => expect(store.credentialPrompt.loading).toBe(false))
expect(store.credentialPrompt.show).toBe(true)
expect(openInApp).not.toHaveBeenCalled()
store.continueCredentialLaunch()
expect(openInApp).toHaveBeenCalledWith(expect.stringContaining(':8083'))
expect(store.panelAppId).toBeNull()
expect(store.isOpen).toBe(false)
@@ -82,6 +91,16 @@ describe('useAppLauncherStore', () => {
expect(store.panelAppId).toBeNull()
})
it('opens GitWorkshop in the companion native WebView, never a dashboard iframe', () => {
const store = useAppLauncherStore()
store.openSession('archipelago-source')
expect(openInApp).toHaveBeenCalledWith(
'http://192.0.2.10/app/archipelago-source/',
)
expect(store.panelAppId).toBeNull()
expect(store.isOpen).toBe(false)
})
it('open() never falls through to the iframe overlay', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.0.2.10:9999', title: 'Unknown app' })
@@ -90,11 +109,13 @@ describe('useAppLauncherStore', () => {
})
})
it('routes known port apps to full-page session', () => {
it('routes known port apps to full-page session after the credential gate', async () => {
const store = useAppLauncherStore()
// Port 8083 maps to /app/filebrowser/ — should route to session
store.open({ url: 'http://192.0.2.10:8083', title: 'FileBrowser' })
await vi.waitFor(() => expect(store.credentialPrompt.loading).toBe(false))
store.continueCredentialLaunch()
// Default panel mode: sets panelAppId, doesn't open overlay
expect(store.isOpen).toBe(false)
@@ -102,6 +123,29 @@ describe('useAppLauncherStore', () => {
expect(mockWindowOpen).not.toHaveBeenCalled()
})
it('gates a Home-style Portainer launch until its first-run token is shown', async () => {
mockRpcCall.mockResolvedValueOnce({
title: 'Portainer first-run token',
description: 'Use this token to create the administrator account.',
credentials: [{ label: 'Token', value: 'test-token', sensitive: true }],
})
const store = useAppLauncherStore()
store.openSession('portainer')
await vi.waitFor(() => expect(store.credentialPrompt.loading).toBe(false))
expect(store.credentialPrompt.show).toBe(true)
expect(store.credentialPrompt.credentials[0]?.value).toBe('test-token')
expect(mockWindowOpen).not.toHaveBeenCalled()
store.continueCredentialLaunch()
expect(mockWindowOpen).toHaveBeenCalledWith(
expect.stringContaining(':9000'),
'_blank',
'noopener,noreferrer',
)
})
it('uses the store-driven panel on mobile (no route change, no background swap)', () => {
Object.defineProperty(window, 'innerWidth', {
value: 390,
@@ -378,7 +422,7 @@ describe('useAppLauncherStore', () => {
expect(mockPush).not.toHaveBeenCalled()
})
it('routes HTTPS same-host apps via session view', () => {
it('routes HTTPS same-host apps via session view after the credential gate', async () => {
Object.defineProperty(window, 'location', {
value: { origin: 'https://192.0.2.10', protocol: 'https:', hostname: '192.0.2.10' },
writable: true,
@@ -387,6 +431,8 @@ describe('useAppLauncherStore', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.0.2.10:8083', title: 'FileBrowser' })
await vi.waitFor(() => expect(store.credentialPrompt.loading).toBe(false))
store.continueCredentialLaunch()
// Known port — routes to session (panel mode by default)
expect(store.isOpen).toBe(false)
+190 -35
View File
@@ -4,11 +4,18 @@ import { rpcClient } from '@/api/rpc-client'
import { recordAppLaunch } from '@/utils/appUsage'
import { requestExternalOpen } from '@/api/remote-relay'
import { openInAppOrNewTab, isCompanionApp, type InAppLaunchMeta } from '@/utils/openExternal'
import { directAppUrl, HTTPS_APP_IDS, resolveAppUrl } from '@/views/appSession/appSessionConfig'
import { portIsGateFronted } from '@/views/discover/curatedApps'
import { directAppUrl, HOST_FRAME_APPS, HTTPS_APP_IDS, resolveAppUrl } from '@/views/appSession/appSessionConfig'
import { appPortIsGateFronted } from '@/views/appSession/appSessionConfig'
import { useAppStore } from '@/stores/app'
import { resolveAppIcon } from '@/views/apps/appsConfig'
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'
/**
* Open a URL in a new browser tab — but if a companion (phone) is currently
@@ -79,6 +86,22 @@ const NEW_TAB_APP_IDS = new Set([
'netbird',
])
/** Apps whose launch may require a platform-owned credential handoff. Keep
* this list deliberately narrow so ordinary new-tab launches retain their
* original synchronous user gesture. Portainer is dynamic (first-run only);
* File Browser and PhotoPrism have stable fallback credentials. */
export const CREDENTIAL_INTERSTITIAL_APPS = new Set([
'filebrowser',
'photoprism',
'portainer',
])
interface LaunchOptions {
path?: string
/** The shared interstitial already ran and the user pressed Continue. */
skipCredentialPrompt?: boolean
}
function mustOpenInNewTab(url: string): boolean {
try {
const u = new URL(url)
@@ -148,6 +171,7 @@ const PORT_TO_APP_ID: Record<string, string> = {
'8123': 'homeassistant',
'8240': 'tailscale',
'8334': 'bitcoin-knots',
'8337': 'archipelago-source',
'8888': 'searxng',
'9000': 'portainer',
'8087': 'netbird',
@@ -163,30 +187,12 @@ const PORT_TO_APP_ID: Record<string, string> = {
'50002': 'electrumx',
}
const APPROVED_ORIGINS_KEY = 'neode_nostr_approved_origins'
function getApprovedOrigins(): Set<string> {
try {
const stored = localStorage.getItem(APPROVED_ORIGINS_KEY)
if (!stored) return new Set()
const parsed: unknown = JSON.parse(stored)
if (!Array.isArray(parsed)) return new Set()
return new Set(parsed.filter((s: unknown) => typeof s === 'string'))
} catch {
return new Set()
}
}
function saveApprovedOrigin(origin: string) {
const origins = getApprovedOrigins()
origins.add(origin)
try { localStorage.setItem(APPROVED_ORIGINS_KEY, JSON.stringify([...origins])) } catch { /* localStorage full or unavailable */ }
}
export interface NostrConsentRequest {
appName: string
method: string
eventKind?: number
content?: string
identityLabel?: string
resolve: (remember: boolean) => void
reject: () => void
}
@@ -208,6 +214,22 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
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,
appId: '',
title: '',
description: '',
credentials: [] as AppCredential[],
copied: '',
})
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) */
@@ -215,15 +237,15 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
/** Optional deep-link path inside the active app (e.g. /tx/<hash> for mempool) */
const panelPath = ref<string | null>(null)
function openSession(appId: string, opts: { path?: string } = {}) {
function openSessionNow(appId: string, opts: LaunchOptions = {}) {
recordAppLaunch(appId)
const mobile = isMobileViewport()
// Companion app: EVERY app opens in the native in-app WebView — never an
// iframe. The WebView is more performant on the phone and carries the
// native back/forward/reload/close controls. Plain mobile browsers (PWA)
// keep the iframe session below.
if (!IS_DEMO && isCompanionApp()) {
// Companion app: ordinary apps open in the native in-app WebView for the
// phone controls and better performance. Apps with manifest-declared host
// integrations stay in the dashboard frame so their parent bridge remains
// connected (for example GitWorkshop's consent-gated NIP-07 provider).
if (!IS_DEMO && isCompanionApp() && !HOST_FRAME_APPS.has(appId)) {
const runtimeUrl = useAppStore().data?.['package-data']?.[appId]?.installed?.['interface-addresses']?.main?.['lan-address'] || undefined
const launchUrl = directAppUrl(appId) || resolveAppUrl(appId, opts.path, runtimeUrl)
if (launchUrl) {
@@ -264,6 +286,94 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
panelAppId.value = appId
}
/** One launch gate for Home, My Apps, Discover, Spotlight and details.
* Previously each Apps view owned a private modal, so Home skipped the
* Portainer first-run token entirely. */
function openSession(appId: string, opts: LaunchOptions = {}) {
if (!opts.skipCredentialPrompt && CREDENTIAL_INTERSTITIAL_APPS.has(appId)) {
void prepareCredentialLaunch(appId, opts.path)
return
}
openSessionNow(appId, opts)
}
async function prepareCredentialLaunch(appId: string, path?: string) {
const generation = ++credentialGeneration
const appName = useAppStore().data?.['package-data']?.[appId]?.manifest?.title || appId
pendingCredentialLaunch = { appId, path }
credentialPrompt.value = {
show: true,
loading: true,
appId,
title: `Checking ${appName}`,
description: 'Checking whether this app needs a first-run token or login details…',
credentials: [],
copied: '',
}
let result: AppCredentialsResponse | null
try {
// Portainer's token is lifecycle-dependent, so this must be live on
// every launch. Caching a pre-initialisation null (or an already-used
// token) recreates the skipped/stale interstitial bug.
result = await rpcClient.call<AppCredentialsResponse>({
method: 'package.credentials',
params: { app_id: appId },
timeout: 5000,
})
} catch {
result = null
}
if (generation !== credentialGeneration) return
const resolved = resolveAppCredentials(appId, result)
if (!resolved) {
credentialPrompt.value.show = false
pendingCredentialLaunch = null
openSessionNow(appId, { path, skipCredentialPrompt: true })
return
}
credentialPrompt.value = {
show: true,
loading: false,
appId,
title: resolved.title || `${appName} credentials`,
description: resolved.description || 'Use these credentials when the app asks you to sign in.',
credentials: resolved.credentials,
copied: '',
}
}
function cancelCredentialLaunch() {
credentialGeneration += 1
pendingCredentialLaunch = null
credentialPrompt.value.show = false
credentialPrompt.value.loading = false
}
function continueCredentialLaunch() {
const pending = pendingCredentialLaunch
credentialGeneration += 1
pendingCredentialLaunch = null
credentialPrompt.value.show = false
credentialPrompt.value.loading = false
if (pending) openSessionNow(pending.appId, { path: pending.path, skipCredentialPrompt: true })
}
async function copyCredential(label: string, value: string) {
try {
await navigator.clipboard.writeText(value)
} catch {
const textarea = document.createElement('textarea')
textarea.value = value
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
credentialPrompt.value.copied = label
}
function closePanel() {
panelAppId.value = null
panelPath.value = null
@@ -287,7 +397,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
const sameHost = u.hostname === window.location.hostname
const alwaysHttps = !!resolvedId && HTTPS_APP_IDS.has(resolvedId)
const httpsPage = window.location.protocol === 'https:'
const gateFronted = !!resolvedId && portIsGateFronted(resolvedId, u.port)
const gateFronted = !!resolvedId && appPortIsGateFronted(resolvedId, u.port)
if (u.protocol === 'http:' && sameHost && (alwaysHttps || (httpsPage && gateFronted))) {
// Pure prefix swap — never re-serialize the URL (URL.href would add
// a trailing slash and change the string the caller handed over).
@@ -366,6 +476,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
}
function close() {
if (showConsent.value) denyConsent()
const toRestore = previousActiveElement
previousActiveElement = null
isOpen.value = false
@@ -384,22 +495,50 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
function approveConsent(remember: boolean) {
if (consentRequest.value) {
consentRequest.value.resolve(remember)
consentRequest.value = null
}
showConsent.value = false
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 = ''
}
function requestConsent(appName: string, method: string, eventKind?: number, content?: string): Promise<boolean> {
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) => {
consentRequest.value = { appName, method, eventKind, content, resolve, reject }
consentGeneration += 1
consentRequest.value = {
appName, method, eventKind, content, identityLabel,
resolve, reject,
}
consentPhase.value = 'review'
consentError.value = ''
showConsent.value = true
})
}
@@ -416,6 +555,8 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
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_'
@@ -440,12 +581,18 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
'nip04.encrypt', 'nip04.decrypt',
'nip44.encrypt', 'nip44.decrypt',
])
if (CONSENT_METHODS.has(method) && !getApprovedOrigins().has(origin)) {
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)
if (remember) saveApprovedOrigin(origin)
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
@@ -508,9 +655,11 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
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)
}
}
@@ -533,8 +682,14 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
closePanel,
panelAppId,
panelPath,
credentialPrompt,
cancelCredentialLaunch,
continueCredentialLaunch,
copyCredential,
showConsent,
consentRequest,
consentPhase,
consentError,
approveConsent,
denyConsent,
}
+26 -8
View File
@@ -92,22 +92,40 @@ export const useAuthStore = defineStore('auth', () => {
}
}
async function checkSession(): Promise<boolean> {
if (!localStorage.getItem('neode-auth')) {
async function checkSession(options: {
allowCookieWithoutLocalMarker?: boolean
bootstrapDashboard?: boolean
} = {}): Promise<boolean> {
// `neode-auth` is only a client-side hint; the HttpOnly session cookie is
// the authority. Most dashboard navigations deliberately require the hint
// so logging out does not immediately resurrect a still-expiring cookie.
// The contained tab signer is the exception: an app-gate login happens on
// the app's port and sets the shared host cookie, but cannot set dashboard-
// origin localStorage. Let that route validate the real cookie explicitly.
if (!options.allowCookieWithoutLocalMarker && !localStorage.getItem('neode-auth')) {
return false
}
try {
await rpcClient.call({ method: 'server.echo', params: { message: 'ping' } })
// Unlike public `server.echo`, this implemented read-only method requires
// a valid session while remaining CSRF-exempt. That makes checkSession a
// real authentication check, including for the app-gate cookie bootstrap.
await rpcClient.call({ method: 'system.get-hostname' })
isAuthenticated.value = true
sessionValidated = true
try { localStorage.setItem('neode-auth', 'true') } catch { /* localStorage full or unavailable */ }
const sync = useSyncStore()
await sync.initializeData()
// The hidden signer broker only needs proof of the session cookie. Do
// not make its first consent prompt wait for a full dashboard snapshot
// and WebSocket connection; a normal dashboard check keeps this default.
if (options.bootstrapDashboard !== false) {
const sync = useSyncStore()
await sync.initializeData()
sync.connectWebSocket().catch((err) => {
if (import.meta.env.DEV) console.warn('[Store] WebSocket reconnection failed, will retry:', err)
})
sync.connectWebSocket().catch((err) => {
if (import.meta.env.DEV) console.warn('[Store] WebSocket reconnection failed, will retry:', err)
})
}
return true
} catch (err) {