publish(tasks 19+20): signer-stabilisierung für wiederholte runs

probleme auf der realen amber-infrastruktur behoben:

1. ohne festen CLIENT_SECRET_HEX erzeugt applesauce bei jedem lauf einen
   neuen client-pubkey. amber bindet permissions pro client-pubkey, also
   sah jeder lauf wie eine neue unberechtigte app aus und bekam
   "no permission" als auto-antwort.
   → CLIENT_SECRET_HEX in config + cli, SimpleSigner.fromKey durchgereicht.

2. applesauce wirft bei "already connected"/"no permission" unhandled
   rejections, weil response-promises asynchron reagieren.
   → globaler unhandledrejection-handler, der diese benannten fehler
   schluckt; connect() im try/catch mit open+force als fallback.

3. timeout auf bunker connect auf 60s erhöht (amber-pairing kann
   menschliches tap dauern beim ersten mal).

einzel-post-publish live verifiziert:
- offenheit-das-wesentliche als kind:30023 publiziert
- alle 5 write-relays haben bestätigt
- bild auf beide blossom-server hochgeladen
- SPA rendert das bild von blossom.edufeed.org

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jörg Lohrer 2026-04-18 06:42:28 +02:00
parent 18d9dad56e
commit db61149924
5 changed files with 74 additions and 9 deletions

1
publish/.gitignore vendored
View File

@ -1,2 +1,3 @@
.env
logs/
deno.lock

View File

@ -79,9 +79,15 @@ async function cmdPublish(flags: {
const runId = uuid()
const logger = createLogger({ mode, runId })
const signer = await createBunkerSigner(config.bunkerUrl)
console.log('[1/3] signer…')
const signer = await createBunkerSigner(config.bunkerUrl, {
clientSecretHex: config.clientSecretHex,
})
console.log('[2/3] outbox…')
const outbox = await loadOutbox(config.bootstrapRelay, config.authorPubkeyHex)
console.log('[3/3] blossom-server-liste…')
const blossomServers = await loadBlossomServers(config.bootstrapRelay, config.authorPubkeyHex)
console.log('setup done')
if (outbox.write.length === 0) {
console.error('no write relays in kind:10002')
return 1

View File

@ -5,6 +5,7 @@ export interface Config {
contentRoot: string
clientTag: string
minRelayAcks: number
clientSecretHex?: string
}
type EnvReader = (key: string) => string | undefined
@ -36,6 +37,10 @@ export function loadConfig(read: EnvReader = (k) => Deno.env.get(k)): Config {
if (!Number.isInteger(minAcks) || minAcks < 1) {
throw new Error(`MIN_RELAY_ACKS must be a positive integer, got "${minAcksRaw}"`)
}
const clientSecretHex = read('CLIENT_SECRET_HEX')
if (clientSecretHex && !/^[0-9a-f]{64}$/.test(clientSecretHex)) {
throw new Error('CLIENT_SECRET_HEX must be 64 lowercase hex characters')
}
return {
bunkerUrl: values.BUNKER_URL,
authorPubkeyHex: values.AUTHOR_PUBKEY_HEX,
@ -43,5 +48,6 @@ export function loadConfig(read: EnvReader = (k) => Deno.env.get(k)): Config {
contentRoot: read('CONTENT_ROOT') ?? DEFAULTS.CONTENT_ROOT,
clientTag: read('CLIENT_TAG') ?? DEFAULTS.CLIENT_TAG,
minRelayAcks: minAcks,
clientSecretHex,
}
}

View File

@ -1,4 +1,4 @@
import { NostrConnectSigner } from 'applesauce-signers'
import { NostrConnectSigner, SimpleSigner } from 'applesauce-signers'
import { RelayPool } from 'applesauce-relay'
import type { UnsignedEvent } from './event.ts'
import type { SignedEvent } from './relays.ts'
@ -13,6 +13,25 @@ const signerPool = new RelayPool()
NostrConnectSigner.subscriptionMethod = (relays, filters) => signerPool.req(relays, filters)
NostrConnectSigner.publishMethod = (relays, event) => signerPool.event(relays, event)
// Workaround: amber sendet bei wiederholten connect-requests mit bereits
// bekanntem secret "already connected" oder "no permission". applesauce-
// signers wirft daraufhin unhandled rejections, weil der request intern
// schon aufgelöst wurde. wir schlucken diese benannten fehler prozessweit.
const BENIGN_CONNECT_ERRORS = ['already connected', 'no permission']
function isBenignConnectError(msg: string): boolean {
const lower = msg.toLowerCase()
return BENIGN_CONNECT_ERRORS.some((e) => lower.includes(e))
}
globalThis.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
const reason = e.reason
const msg = reason instanceof Error ? reason.message : String(reason)
if (isBenignConnectError(msg)) {
e.preventDefault()
}
})
function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
let timerId: number | undefined
const timeoutPromise = new Promise<never>((_r, rej) => {
@ -23,13 +42,44 @@ function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
}) as Promise<T>
}
export async function createBunkerSigner(bunkerUrl: string): Promise<Signer> {
const signer = await withTimeout(
NostrConnectSigner.fromBunkerURI(bunkerUrl),
30_000,
'Bunker connect',
)
export interface CreateSignerOptions {
clientSecretHex?: string
}
export async function createBunkerSigner(
bunkerUrl: string,
options: CreateSignerOptions = {},
): Promise<Signer> {
const { remote, relays, secret } = NostrConnectSigner.parseBunkerURI(bunkerUrl)
console.log(` signer: setup (remote=${remote.slice(0, 8)}…, relays=${relays.length})`)
// Stabile client-identität: ohne festen CLIENT_SECRET_HEX erzeugt
// applesauce pro lauf einen zufälligen key, und amber sieht jeden lauf
// als neue app → permissions greifen nie. mit festem key bleibt die
// identität über läufe erhalten.
const clientSigner = options.clientSecretHex
? SimpleSigner.fromKey(options.clientSecretHex)
: undefined
const signer = new NostrConnectSigner({ relays, remote, signer: clientSigner })
const clientPubkey = await signer.signer.getPublicKey()
console.log(` signer: client-pubkey=${clientPubkey.slice(0, 8)}`)
// connect() beim ersten mal nötig (damit amber die app registriert);
// bei späteren runs ist amber schon gepaired mit diesem client-pubkey
// und antwortet auf get_public_key / sign_event ohne erneuten connect.
// wir versuchen connect, schlucken benign errors, und fallen-back auf
// manuelles open().
try {
await withTimeout(signer.connect(secret), 60_000, 'Bunker connect')
console.log(' signer: connect ok')
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (!isBenignConnectError(msg)) throw err
console.log(` signer: connect benign "${msg}", fallback to open+force`)
await signer.open()
;(signer as unknown as { isConnected: boolean }).isConnected = true
}
console.log(' signer: getPublicKey…')
const pubkey = await withTimeout(signer.getPublicKey(), 30_000, 'Bunker getPublicKey')
console.log(` signer: pubkey ok (${pubkey.slice(0, 8)}…)`)
return {
getPublicKey: () => Promise.resolve(pubkey),
signEvent: async (ev: UnsignedEvent) => {

View File

@ -12,7 +12,9 @@ export async function runCheck(config: Config): Promise<CheckResult> {
const issues: string[] = []
try {
const signer = await createBunkerSigner(config.bunkerUrl)
const signer = await createBunkerSigner(config.bunkerUrl, {
clientSecretHex: config.clientSecretHex,
})
const pk = await signer.getPublicKey()
if (pk !== config.authorPubkeyHex) {
issues.push(