merge(13): AIUI source migrated in-repo — supersedes the two-repo split (D-15/D-18)
Brings AIUI's full 230-commit history under aiui/ via git subtree, plus main's
current head. Operator decision 2026-08-03: AIUI moves into this repo rather
than staying at git.tx1138.com. This also lands e30ac1d (13-01 Task 3), which
was stranded local-only while that remote was unreachable.
Plans 13-06, 13-09 and 13-11 still target /home/archipelago/Projects/AIUI paths
and must be re-planned against aiui/ before wave 2 runs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -486,6 +486,18 @@ describe('RPCClient convenience methods', () => {
|
||||
expect(getLastMethod()).toBe('federation.invite')
|
||||
})
|
||||
|
||||
it('federationInvite omits password when none is given', async () => {
|
||||
mockSuccess({ code: 'ABC', did: 'did:key:z', onion: 'abc.onion' })
|
||||
await rpcClient.federationInvite('observer')
|
||||
expect(getLastParams()).not.toHaveProperty('password')
|
||||
})
|
||||
|
||||
it('federationInvite forwards the password for a trusted invite', async () => {
|
||||
mockSuccess({ code: 'ABC', did: 'did:key:z', onion: 'abc.onion' })
|
||||
await rpcClient.federationInvite('trusted', 'hunter2')
|
||||
expect(getLastParams()).toMatchObject({ trust_level: 'trusted', password: 'hunter2' })
|
||||
})
|
||||
|
||||
it('federationJoin calls federation.join', async () => {
|
||||
mockSuccess({ joined: true, node: {} })
|
||||
await rpcClient.federationJoin('invite-code')
|
||||
@@ -510,6 +522,22 @@ describe('RPCClient convenience methods', () => {
|
||||
expect(getLastMethod()).toBe('federation.set-trust')
|
||||
})
|
||||
|
||||
it('federationSetTrust omits password on demotion', async () => {
|
||||
mockSuccess({ updated: true, did: 'did:key:z', trust_level: 'observer' })
|
||||
await rpcClient.federationSetTrust('did:key:z', 'observer')
|
||||
expect(getLastParams()).not.toHaveProperty('password')
|
||||
})
|
||||
|
||||
it('federationSetTrust forwards the password when promoting', async () => {
|
||||
mockSuccess({ updated: true, did: 'did:key:z', trust_level: 'trusted' })
|
||||
await rpcClient.federationSetTrust('did:key:z', 'trusted', 'hunter2')
|
||||
expect(getLastParams()).toMatchObject({
|
||||
did: 'did:key:z',
|
||||
trust_level: 'trusted',
|
||||
password: 'hunter2',
|
||||
})
|
||||
})
|
||||
|
||||
it('federationSyncState calls federation.sync-state', async () => {
|
||||
mockSuccess({ synced: 1, failed: 0, results: [] })
|
||||
await rpcClient.federationSyncState()
|
||||
|
||||
@@ -781,12 +781,18 @@ class RPCClient {
|
||||
}
|
||||
|
||||
// Federation
|
||||
/** Minting a `trusted` invite requires the node password — the backend
|
||||
* rejects it with a `PASSWORD_REQUIRED` error until one is supplied.
|
||||
* Observer invites never need one. */
|
||||
async federationInvite(
|
||||
trustLevel: 'trusted' | 'observer' = 'trusted'
|
||||
trustLevel: 'trusted' | 'observer' = 'trusted',
|
||||
password?: string,
|
||||
): Promise<{ code: string; did: string; onion: string; trust_level: string }> {
|
||||
const params: Record<string, unknown> = { trust_level: trustLevel }
|
||||
if (password) params.password = password
|
||||
return this.call({
|
||||
method: 'federation.invite',
|
||||
params: { trust_level: trustLevel },
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -855,13 +861,19 @@ class RPCClient {
|
||||
})
|
||||
}
|
||||
|
||||
/** Promotion TO `trusted` requires the node password — the backend rejects
|
||||
* it with a `PASSWORD_REQUIRED` error until one is supplied. Demotion is
|
||||
* never gated: making a peer less privileged must stay easy. */
|
||||
async federationSetTrust(
|
||||
did: string,
|
||||
trustLevel: 'trusted' | 'observer' | 'untrusted',
|
||||
password?: string,
|
||||
): Promise<{ updated: boolean; did: string; trust_level: string }> {
|
||||
const params: Record<string, unknown> = { did, trust_level: trustLevel }
|
||||
if (password) params.password = password
|
||||
return this.call({
|
||||
method: 'federation.set-trust',
|
||||
params: { did, trust_level: trustLevel },
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -223,6 +223,15 @@
|
||||
@confirm="confirmPresenceSign"
|
||||
@cancel="showPresenceSignModal = false"
|
||||
/>
|
||||
|
||||
<TrustPasswordModal
|
||||
:visible="showTrustPassword"
|
||||
:context="trustPasswordContext"
|
||||
:busy="trustPasswordBusy"
|
||||
:error="trustPasswordError"
|
||||
@confirm="submitTrustPassword"
|
||||
@close="closeTrustPassword"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -243,9 +252,10 @@ import JoinModal from './federation/JoinModal.vue'
|
||||
import PendingRequestsPanel from './federation/PendingRequestsPanel.vue'
|
||||
import DiscoverModal from './federation/DiscoverModal.vue'
|
||||
import PresenceSignModal from './federation/PresenceSignModal.vue'
|
||||
import TrustPasswordModal from './federation/TrustPasswordModal.vue'
|
||||
import type { FederatedNode, DwnStatus, SyncResult } from './federation/types'
|
||||
import type { PendingPeerRequest } from '@/api/rpc-client'
|
||||
import { nodeName, timeAgo } from './federation/utils'
|
||||
import { nodeName, nodeNameFromDid, timeAgo } from './federation/utils'
|
||||
|
||||
const transportStore = useTransportStore()
|
||||
const appStore = useAppStore()
|
||||
@@ -529,15 +539,73 @@ function handleGenerateInvite(type: 'trusted' | 'observer') {
|
||||
generateInvite()
|
||||
}
|
||||
|
||||
/** The backend is the only authority on whether a given change is an
|
||||
* escalation, so the UI never pre-judges: it attempts the call and prompts
|
||||
* only when the backend says a password is required. That keeps demotions —
|
||||
* and no-op re-sets of an already-Trusted peer — free of a pointless prompt
|
||||
* without the frontend having to duplicate the rule. */
|
||||
function isPasswordRequired(e: unknown): boolean {
|
||||
return e instanceof Error && e.message.includes('PASSWORD_REQUIRED')
|
||||
}
|
||||
|
||||
const showTrustPassword = ref(false)
|
||||
const trustPasswordContext = ref('')
|
||||
const trustPasswordBusy = ref(false)
|
||||
const trustPasswordError = ref('')
|
||||
let pendingTrustAction: ((password: string) => Promise<void>) | null = null
|
||||
|
||||
function promptForTrustPassword(context: string, action: (password: string) => Promise<void>) {
|
||||
trustPasswordContext.value = context
|
||||
trustPasswordError.value = ''
|
||||
pendingTrustAction = action
|
||||
showTrustPassword.value = true
|
||||
}
|
||||
|
||||
function closeTrustPassword() {
|
||||
showTrustPassword.value = false
|
||||
trustPasswordError.value = ''
|
||||
trustPasswordBusy.value = false
|
||||
pendingTrustAction = null
|
||||
}
|
||||
|
||||
async function submitTrustPassword(password: string) {
|
||||
if (!pendingTrustAction) return
|
||||
try {
|
||||
trustPasswordBusy.value = true
|
||||
trustPasswordError.value = ''
|
||||
await pendingTrustAction(password)
|
||||
closeTrustPassword()
|
||||
} catch (e) {
|
||||
// Keep the failure inside the modal so the operator can retry in place
|
||||
// rather than losing the pending action to the page-level banner.
|
||||
trustPasswordError.value = e instanceof Error ? e.message : 'Password verification failed'
|
||||
} finally {
|
||||
trustPasswordBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Raw call — throws so both the first attempt and the password retry can
|
||||
* route the error to the right place. */
|
||||
async function requestInvite(password?: string) {
|
||||
// The invite type is not cosmetic: it sets the trust level the invite
|
||||
// grants both sides ("Invite a Peer" = observer, "Link Your Nodes" = trusted)
|
||||
const result = await rpcClient.federationInvite(inviteType.value, password)
|
||||
inviteCode.value = result.code
|
||||
}
|
||||
|
||||
async function generateInvite() {
|
||||
try {
|
||||
generatingInvite.value = true
|
||||
error.value = ''
|
||||
// The invite type is not cosmetic: it sets the trust level the invite
|
||||
// grants both sides ("Invite a Peer" = observer, "Link Your Nodes" = trusted)
|
||||
const result = await rpcClient.federationInvite(inviteType.value)
|
||||
inviteCode.value = result.code
|
||||
await requestInvite()
|
||||
} catch (e) {
|
||||
if (isPasswordRequired(e)) {
|
||||
promptForTrustPassword(
|
||||
'This invite grants Trusted access to whoever redeems it — full read of this node\'s state, and the ability to deploy apps to it. Confirm with your node password.',
|
||||
requestInvite,
|
||||
)
|
||||
return
|
||||
}
|
||||
error.value = e instanceof Error ? e.message : 'Failed to generate invite'
|
||||
} finally {
|
||||
generatingInvite.value = false
|
||||
@@ -578,14 +646,27 @@ async function syncAll() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Raw call — throws; see `requestInvite`. */
|
||||
async function requestTrustChange(did: string, level: string, password?: string) {
|
||||
await rpcClient.federationSetTrust(did, level as 'trusted' | 'observer' | 'untrusted', password)
|
||||
await loadNodes()
|
||||
if (selectedNode.value?.did === did) {
|
||||
selectedNode.value = nodes.value.find(n => n.did === did) ?? null
|
||||
}
|
||||
}
|
||||
|
||||
async function changeTrust(did: string, level: string) {
|
||||
try {
|
||||
await rpcClient.federationSetTrust(did, level as 'trusted' | 'observer' | 'untrusted')
|
||||
await loadNodes()
|
||||
if (selectedNode.value?.did === did) {
|
||||
selectedNode.value = nodes.value.find(n => n.did === did) ?? null
|
||||
}
|
||||
await requestTrustChange(did, level)
|
||||
} catch (e) {
|
||||
if (isPasswordRequired(e)) {
|
||||
const name = nodeNameFromDid(did, nodes.value)
|
||||
promptForTrustPassword(
|
||||
`Granting ${name} Trusted lets it read this node's state and deploy apps to it. Confirm with your node password.`,
|
||||
(password) => requestTrustChange(did, level, password),
|
||||
)
|
||||
return
|
||||
}
|
||||
error.value = e instanceof Error ? e.message : 'Failed to update trust level'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<select
|
||||
:value="node.trust_level"
|
||||
@change="emit('change-trust', node!.did, ($event.target as HTMLSelectElement).value)"
|
||||
@change="onTrustChange"
|
||||
class="bg-black/30 text-white text-sm rounded px-2 py-1 border border-white/10"
|
||||
>
|
||||
<option value="trusted">Trusted</option>
|
||||
@@ -34,6 +34,9 @@
|
||||
<option value="untrusted">Blocked</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="text-xs text-white/40 mt-2">
|
||||
<span class="text-white/30">Granted via:</span> {{ trustSourceLabel }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-white/5 rounded-lg p-3">
|
||||
<p class="text-xs text-white/40 mb-1">Added</p>
|
||||
@@ -130,7 +133,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { FederatedNode } from './types'
|
||||
import { formatBytes, formatUptime } from './utils'
|
||||
|
||||
@@ -156,6 +159,32 @@ const emit = defineEmits<{
|
||||
const confirmRemove = ref(false)
|
||||
const deployAppId = ref('')
|
||||
|
||||
const TRUST_SOURCE_LABELS: Record<string, string> = {
|
||||
invite: 'An invite you minted',
|
||||
'uninvited-join': 'Joined without an invite — capped at Observer',
|
||||
'transitive-merge': 'Advertised by another peer — capped at Observer',
|
||||
manual: 'You set it here',
|
||||
}
|
||||
|
||||
/** Unknown provenance is stated plainly rather than hidden: a peer recorded
|
||||
* before this was tracked is precisely the one worth a second look. */
|
||||
const trustSourceLabel = computed(
|
||||
() => TRUST_SOURCE_LABELS[props.node?.trust_source ?? ''] ?? 'Unknown — recorded before this was tracked',
|
||||
)
|
||||
|
||||
/** Snap the select back to the node's actual level immediately. Promoting to
|
||||
* Trusted asks for the node password, and the operator may cancel or get it
|
||||
* wrong — without this the dropdown would keep displaying a level the node
|
||||
* never accepted. On success the parent reloads and the prop drives the new
|
||||
* value back in. */
|
||||
function onTrustChange(event: Event) {
|
||||
const select = event.target as HTMLSelectElement
|
||||
const level = select.value
|
||||
if (!props.node) return
|
||||
select.value = props.node.trust_level
|
||||
emit('change-trust', props.node.did, level)
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
confirmRemove.value = false
|
||||
deployAppId.value = ''
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="visible" class="fixed inset-0 z-[3000] flex items-center justify-center p-4" @click.self="handleClose">
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||
<div class="glass-card p-6 max-w-md w-full relative z-10">
|
||||
<h3 class="text-lg font-semibold text-white mb-2">Confirm Trusted Access</h3>
|
||||
<p class="text-sm text-white/60 mb-4">{{ context }}</p>
|
||||
<input
|
||||
ref="passwordInput"
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="Enter your node password to confirm"
|
||||
class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-orange-500/50 mb-4"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
<p v-if="error" class="text-red-400 text-xs mb-3">{{ error }}</p>
|
||||
<div class="flex gap-3">
|
||||
<button @click="handleClose" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
|
||||
<button
|
||||
@click="submit"
|
||||
:disabled="busy || !password"
|
||||
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/20 border-orange-500/30 disabled:opacity-50"
|
||||
>
|
||||
{{ busy ? 'Verifying…' : 'Grant Trusted' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
/** What is about to be granted, in the operator's terms. */
|
||||
context: string
|
||||
busy: boolean
|
||||
error: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
confirm: [password: string]
|
||||
}>()
|
||||
|
||||
const password = ref('')
|
||||
const passwordInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function submit() {
|
||||
if (!password.value || props.busy) return
|
||||
emit('confirm', password.value)
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
password.value = ''
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// Never leave the password sitting in memory once the modal is dismissed,
|
||||
// and put the cursor where the operator has to type anyway.
|
||||
watch(() => props.visible, async (val) => {
|
||||
if (!val) {
|
||||
password.value = ''
|
||||
return
|
||||
}
|
||||
await nextTick()
|
||||
passwordInput.value?.focus()
|
||||
})
|
||||
</script>
|
||||
@@ -40,6 +40,13 @@ export interface FederatedNode {
|
||||
last_sync_error?: string
|
||||
/** RFC 3339 timestamp of last_sync_error. */
|
||||
last_sync_error_at?: string
|
||||
/**
|
||||
* How this peer's trust level came to be what it is. `null` means it was
|
||||
* recorded before provenance was tracked — which is exactly the population
|
||||
* worth reviewing, since it may include grants made by the fail-open paths
|
||||
* that `uninvited-join` / `transitive-merge` now cap at Observer.
|
||||
*/
|
||||
trust_source?: 'invite' | 'uninvited-join' | 'transitive-merge' | 'manual' | null
|
||||
}
|
||||
|
||||
export interface DwnStatus {
|
||||
|
||||
Reference in New Issue
Block a user