Compare commits

..
3 Commits
Author SHA1 Message Date
ssmithx cedfc9f99c docs: record Archipelago app packaging as done
apps/podsteadr, apps/podsteadr-mediamtx, apps/podsteadr-blossom manifests
now exist on the archy repo's feat/podsteadr-app-package branch, per the
guidelines in archy's docs/app-developer-guide.md. Updates the "Remaining
work" list accordingly and notes the separate, complementary external-app
dashboard bookmark integration on feat/podsteadr-external-nostr-identity.
2026-08-07 14:57:57 +00:00
ssmithx 1ca688adda fix(ui): display npub instead of raw hex for the logged-in identity
App.vue fell back to the first 8 hex chars of the pubkey whenever
displayName wasn't set (the common case right after nostr sign-in,
before any kind-0 metadata has been fetched) — indistinguishable
between different identities at a glance, and not a form anyone
recognizes as "their" nostr identity. nostr-tools was already a
frontend dependency; just wasn't used for npub encoding anywhere.

Full hex pubkey is still available via a title tooltip on hover.
2026-08-02 16:20:23 +00:00
ssmithx 133558d923 feat(auth): bridge NIP-07 sign-in to Archipelago's identity manager
Vendors Archipelago's NIP-07 provider shim (neode-ui/public/
nostr-provider.js) and loads it in index.html's <head>. It's a no-op
outside an Archipelago iframe (the shim's own window === window.top
guard), so this is always safe to include.

When podsteadr is opened from the Archipelago dashboard (registered
there as an external identity-aware app — see the companion archy
change on branch feat/podsteadr-external-nostr-identity), the parent
frame lets the user pick one of their node's stored nostr identities
and posts window.nostr signing requests through to it. The shim then
runs the existing NIP-98 flow against our own auth (nip07.ts, auth.ts,
routes/auth.ts) exactly as if a browser extension had signed it —
nothing on the server needed to change.

Configured for our actual auth shape via data-* attrs the shim reads
from its own <script> tag: data-session-url="/api/auth/login" (ours,
not indeedhub's /api/auth/nostr/session), data-session-mode="cookie"
(we set a session cookie via @fastify/cookie rather than returning a
bearer token in JSON — the shim previously only knew the token shape),
data-me-url="/api/auth/me" (skip re-running the handshake if already
signed in), data-health-url="/api/health" (our actual health route).

Not wired through an Archipelago app manifest/hook — podsteadr isn't
an orchestrator-managed package, so this copy of nostr-provider.js
won't auto-update with archy OTA releases. Re-sync by hand from
archy/neode-ui/public/nostr-provider.js if that file changes upstream.

Verified: `npm run build` (vue-tsc + vite) clean, dist/index.html
includes the script tag with all four data-* attrs, dist/
nostr-provider.js present and syntactically valid.
2026-08-02 14:44:00 +00:00
4 changed files with 261 additions and 9 deletions
+23 -8
View File
@@ -153,14 +153,29 @@ frontend/ # Vue 3 + Vite + Tailwind + Pinia
## Remaining work ## Remaining work
- **Archipelago packaging** (the original deployment target, archy repo): - **Archipelago packaging — done (2026-08-07)**, on the archy repo branch
`apps/podsteadr/manifest.yml` + `apps/podsteadr-mediamtx` + `apps/podsteadr-blossom` `feat/podsteadr-app-package` (not yet merged/pushed): `apps/podsteadr/manifest.yml`
following the `apps/btcpay-server` (dependencies) + `apps/monero-ui` (`container.build` from this repo's own Dockerfile, following the
(`container.build` on `/opt/archipelago/docker/...`) patterns; bind volumes `apps/indeedhub` externally-sourced-app pattern) + `apps/podsteadr-mediamtx` +
under `/var/lib/archipelago/<app>`; add ports **8095, 1935, 8889, 8189/udp, `apps/podsteadr-blossom`, all three on a dedicated `podsteadr-net` bridge
8890, 8098** to `apps/PORTS.md` (chosen 2026-07-10 to avoid fleet collisions — network per the `apps/indeedhub-*` sibling-manifest pattern. Bind volumes
8888 is searxng, hence HLS on 8890). `interfaces.main` → port 8095. under `/var/lib/archipelago/<app>`. Ports **8095, 1935, 8889, 8189/udp, 8890,
- **No git remote yet** — decide where to push (gitea?). 8098** added to `apps/PORTS.md` (chosen 2026-07-10 to avoid fleet collisions —
8888 is searxng, hence HLS on 8890), all declared `auth: none` with a
rationale (public podcast/livestream server — RSS/HLS/blob reads must stay
reachable with no Archipelago session; podsteadr already gates its own
sensitive routes via NIP-98). `interfaces.main` → port 8095. Passes
`scripts/validate-app-manifest.sh` and `cargo test -p archipelago-container
manifest` in archy. Not yet verified against a real node install — the
`data_uid`/capabilities guesses for blossom and mediamtx (both root-running
images writing to fresh bind mounts) are flagged inline as unverified.
- Separately, podsteadr is also registered in archy's neode-ui dashboard as an
*external* identity-aware app (bookmark to the standalone
podsteadr.atobitcoin.io instance + NIP-07 bridge), on archy branch
`feat/podsteadr-external-nostr-identity` — a lighter integration than the
installable package above, for the already-hosted instance. The two are
complementary, not overlapping.
- Git remote: `http://146.59.87.168:3000/ssmithx/podsteadr.git` (gitea).
- Browser-tested only synthetically: the wizards should get a real pass with an - Browser-tested only synthetically: the wizards should get a real pass with an
actual NIP-07 extension + OBS (the API surface they call is fully covered by actual NIP-07 extension + OBS (the API surface they call is fully covered by
the e2e script, so surprises should be cosmetic). the e2e script, so surprises should be cosmetic).
+5
View File
@@ -4,6 +4,11 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>podsteadr</title> <title>podsteadr</title>
<!-- No-op outside an Archipelago iframe (see nostr-provider.js's own
window === window.top guard) — safe to always include. Provides
window.nostr + auto sign-in via the node's selected nostr identity
when opened inside the Archipelago shell. -->
<script src="/nostr-provider.js" data-session-url="/api/auth/login" data-session-mode="cookie" data-me-url="/api/auth/me" data-health-url="/api/health"></script>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+217
View File
@@ -0,0 +1,217 @@
/**
* NIP-07 Nostr Provider Shim — Archipelago
*
* Vendored from archy/neode-ui/public/nostr-provider.js (generalized version).
* Provides window.nostr (NIP-07) for iframe apps launched inside the
* Archipelago shell, bridging signing requests via postMessage to the
* parent frame, which relays them to the Archipelago node's identity
* manager. Auto sign-in: does NIP-98 auth against this app's own backend,
* then reloads so the app picks up the valid session.
*
* Not vendored via an Archipelago manifest hook (podsteadr isn't an
* orchestrator-managed package — see neode-ui's EXTERNAL_URLS /
* WEB_ONLY_APP-style "external web app" registration instead), so this
* copy won't auto-update with archy's OTA releases. Re-sync by hand from
* archy/neode-ui/public/nostr-provider.js if that file changes.
*/
(function () {
'use strict';
if (window.__archipelagoNostr) return;
window.__archipelagoNostr = true;
if (window === window.top) return;
var pending = {}, nextId = 1;
function request(method, params) {
return new Promise(function (resolve, reject) {
var id = nextId++;
pending[id] = { resolve: resolve, reject: reject };
window.parent.postMessage({ type: 'nostr-request', id: id, method: method, params: params || {} }, '*');
setTimeout(function () { if (pending[id]) { pending[id].reject(new Error('NIP-07 timeout')); delete pending[id]; } }, 30000);
});
}
window.addEventListener('message', function (e) {
if (!e.data || e.data.type !== 'nostr-response') return;
var h = pending[e.data.id]; if (!h) return; delete pending[e.data.id];
e.data.error ? h.reject(new Error(e.data.error)) : h.resolve(e.data.result);
});
window.nostr = {
getPublicKey: function () { return request('getPublicKey'); },
signEvent: function (ev) { return request('signEvent', { event: ev }); },
sign: function (ev) { return request('signEvent', { event: ev }); },
getRelays: function () { return request('getRelays'); },
nip04: {
encrypt: function (pk, pt) { return request('nip04.encrypt', { pubkey: pk, plaintext: pt }); },
decrypt: function (pk, ct) { return request('nip04.decrypt', { pubkey: pk, ciphertext: ct }); },
},
nip44: {
encrypt: function (pk, pt) { return request('nip44.encrypt', { pubkey: pk, plaintext: pt }); },
decrypt: function (pk, ct) { return request('nip44.decrypt', { pubkey: pk, ciphertext: ct }); },
},
};
// --- Loading Overlay ---
var overlay = null;
function showLoader(message) {
if (overlay) return;
overlay = document.createElement('div');
overlay.id = 'archipelago-auth-overlay';
overlay.innerHTML =
'<div style="display:flex;flex-direction:column;align-items:center;gap:16px;">' +
'<svg width="40" height="40" viewBox="0 0 24 24" fill="none" style="animation:archy-spin 1s linear infinite">' +
'<circle cx="12" cy="12" r="10" stroke="rgba(255,255,255,0.2)" stroke-width="3"/>' +
'<path d="M12 2a10 10 0 019.95 9" stroke="#fb923c" stroke-width="3" stroke-linecap="round"/>' +
'</svg>' +
'<div style="color:rgba(255,255,255,0.9);font:500 14px/1.4 -apple-system,system-ui,sans-serif">' + (message || 'Signing in...') + '</div>' +
'</div>';
overlay.style.cssText = 'position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.7);backdrop-filter:blur(8px);';
var style = document.createElement('style');
style.textContent = '@keyframes archy-spin{to{transform:rotate(360deg)}}';
document.head.appendChild(style);
document.body.appendChild(overlay);
}
function updateLoader(message) {
if (!overlay) return;
var txt = overlay.querySelector('div > div');
if (txt) txt.textContent = message;
}
function hideLoader() {
if (overlay) { overlay.remove(); overlay = null; }
}
// --- Per-app config (data-* attrs on the injected <script> tag). Defaults
// match indeedhub's original hardcoded values, so apps that don't set any
// overrides keep behaving exactly as before.
var scriptEl = document.currentScript;
var ds = (scriptEl && scriptEl.dataset) || {};
var cfg = {
healthUrl: ds.healthUrl || '/api/nostr-auth/health',
sessionUrl: ds.sessionUrl || '/api/auth/nostr/session',
sessionMethod: ds.sessionMethod || 'POST',
// 'token' (default): login response is JSON {accessToken, refreshToken};
// stored in sessionStorage, matches indeedhub.
// 'cookie': server sets the session cookie directly on the login
// response (Set-Cookie) — nothing to store client-side, just reload.
sessionMode: ds.sessionMode || 'token',
// Optional: for cookie-mode apps, check this endpoint first and skip
// the NIP-98 handshake entirely if it reports already-authenticated
// (401 otherwise) — avoids re-running sign-in on every iframe reload.
meUrl: ds.meUrl || null,
};
// --- Direct NIP-98 Auth ---
var authDone = false;
function performNip98Auth(pubkey) {
var healthUrl = window.location.origin + cfg.healthUrl;
var sessionUrl = window.location.origin + cfg.sessionUrl;
// 1. Check if API backend is reachable (3s timeout)
var hc = new AbortController();
var ht = setTimeout(function () { hc.abort(); }, 3000);
fetch(healthUrl, { signal: hc.signal }).then(function (r) {
clearTimeout(ht);
if (!r.ok) throw new Error('Health ' + r.status);
// 2. API is up — show loader and do NIP-98
showLoader('Signing in with Nostr...');
var now = Math.floor(Date.now() / 1000);
var event = {
kind: 27235, created_at: now, content: '', pubkey: pubkey,
tags: [['u', sessionUrl], ['method', cfg.sessionMethod]]
};
console.log('[nostr-provider] NIP-98: signing for', sessionUrl);
return window.nostr.signEvent(event);
}).then(function (signed) {
updateLoader('Creating session...');
var ac = new AbortController();
setTimeout(function () { ac.abort(); }, 10000);
return fetch(sessionUrl, {
method: cfg.sessionMethod,
headers: { 'Authorization': 'Nostr ' + btoa(JSON.stringify(signed)) },
signal: ac.signal
});
}).then(function (res) {
console.log('[nostr-provider] NIP-98: response', res.status);
if (!res.ok) throw new Error('Auth failed: ' + res.status);
if (cfg.sessionMode === 'cookie') {
// Session cookie already landed via Set-Cookie on this response.
updateLoader('Signed in! Loading...');
console.log('[nostr-provider] NIP-98: success (cookie session), reloading...');
setTimeout(function () { window.location.reload(); }, 400);
return null;
}
return res.json();
}).then(function (data) {
if (!data) return; // cookie-mode: handled above, nothing left to do
if (data.accessToken) {
sessionStorage.setItem('nostr_token', data.accessToken);
sessionStorage.setItem('nostr_pubkey', pubkey);
if (data.refreshToken) sessionStorage.setItem('refresh_token', data.refreshToken);
updateLoader('Signed in! Loading...');
console.log('[nostr-provider] NIP-98: success, reloading...');
setTimeout(function () { window.location.reload(); }, 400);
} else {
hideLoader(); authDone = false;
}
}).catch(function (err) {
hideLoader(); authDone = false;
var msg = err.message || String(err);
if (msg.indexOf('abort') > -1) msg = 'API timeout';
console.warn('[nostr-provider] NIP-98 skipped:', msg);
});
}
function doNip98Auth(pubkey) {
if (authDone) return;
authDone = true;
if (cfg.meUrl) {
// Already-authenticated check first — avoids re-running the NIP-98
// handshake (and its reload) on every iframe load for cookie-session
// apps, where there's no client-visible token to check locally.
fetch(window.location.origin + cfg.meUrl, { credentials: 'same-origin' })
.then(function (r) {
if (r.ok) {
console.log('[nostr-provider] Already authenticated (meUrl ok), skipping NIP-98');
authDone = false;
return;
}
performNip98Auth(pubkey);
})
.catch(function () { performNip98Auth(pubkey); });
return;
}
performNip98Auth(pubkey);
}
// Listen for identity from parent Archipelago frame
window.addEventListener('message', function (e) {
if (!e.data || e.data.type !== 'archipelago:identity') return;
var pk = e.data.nostr_pubkey;
console.log('[nostr-provider] Identity received:', pk ? pk.slice(0, 12) + '...' : 'none');
if (!pk) return;
// Skip if already signed in with a real token (not mock)
try {
var token = sessionStorage.getItem('nostr_token');
if (token && token.indexOf('mock-') === -1) {
console.log('[nostr-provider] Already signed in with real token');
return;
}
} catch (x) {}
setTimeout(function () { doNip98Auth(pk); }, 1500);
});
})();
+16 -1
View File
@@ -1,10 +1,25 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue';
import { nip19 } from 'nostr-tools';
import { useAuthStore } from './stores/auth'; import { useAuthStore } from './stores/auth';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
const auth = useAuthStore(); const auth = useAuthStore();
const router = useRouter(); const router = useRouter();
// Hex pubkeys look identical at a glance across identities — npub is the
// standard nostr display form and is what users actually recognize.
const identityLabel = computed(() => {
if (auth.displayName) return auth.displayName;
if (!auth.pubkey) return '';
try {
const npub = nip19.npubEncode(auth.pubkey);
return npub.slice(0, 12) + '…' + npub.slice(-6);
} catch {
return auth.pubkey.slice(0, 8) + '…';
}
});
async function logout() { async function logout() {
await auth.logout(); await auth.logout();
router.push('/login'); router.push('/login');
@@ -23,7 +38,7 @@ async function logout() {
<RouterLink to="/earnings" class="hover:text-orange-400">Earnings</RouterLink> <RouterLink to="/earnings" class="hover:text-orange-400">Earnings</RouterLink>
<RouterLink to="/settings" class="hover:text-orange-400">Settings</RouterLink> <RouterLink to="/settings" class="hover:text-orange-400">Settings</RouterLink>
<button class="btn-secondary !px-3 !py-1" @click="logout"> <button class="btn-secondary !px-3 !py-1" @click="logout">
<span class="max-w-[8rem] truncate font-mono text-xs">{{ auth.displayName || auth.pubkey.slice(0, 8) + '…' }}</span> <span class="max-w-[8rem] truncate font-mono text-xs" :title="auth.pubkey ?? undefined">{{ identityLabel }}</span>
Logout Logout
</button> </button>
</nav> </nav>