From 763a44bbddc330875cd0dcc83f50f8728e6c8dcf Mon Sep 17 00:00:00 2001 From: Grant Date: Fri, 8 May 2026 11:20:17 -0500 Subject: [PATCH] =?UTF-8?q?v0.1.0:46=20=E2=80=94=20idempotent=20Connect=20?= =?UTF-8?q?BTCPay,=20Go=20SDK=20now=20part=20of=20toolchain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last T1 BTCPay UX gap from V0.2_PLAN. Connect now checks /v1/admin/btcpay/status first; if a connection exists, returns a clear "already connected" guidance message pointing the operator at Disconnect → Connect for re-authorize cases. Without this guard, re-clicking Connect spawned a new webhook subscription on BTCPay's side every time, leaving orphan webhooks BTCPay would keep trying to deliver to. The Go SDK has been written and verified — all 4 crosscheck tests pass against the shared tests/crosscheck/vector.json (the same file the Rust/TS/Python SDKs and the daemon test against). Pure stdlib, zero third-party dependencies. Hosted in its own repo at github.com/keysat-xyz/keysat-client-go (private during alpha). This release IS the 5th-language milestone: daemon + Rust + TS + Python + Go all agree byte-for-byte on the LIC1 wire format. Daemon binary unchanged — wrapper-only revision. --- startos/actions/configureBtcpay.ts | 45 ++++++++++++++++++++++++++++++ startos/versions/v0.1.0.ts | 14 +++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/startos/actions/configureBtcpay.ts b/startos/actions/configureBtcpay.ts index 5f93b0f..ca582d4 100644 --- a/startos/actions/configureBtcpay.ts +++ b/startos/actions/configureBtcpay.ts @@ -33,6 +33,51 @@ export const configureBtcpay = sdk.Action.withoutInput( async () => { const storeData = await store.read().once() if (!storeData) throw new Error('Store not initialized — restart the service.') + + // Idempotency guard: if Keysat is already connected to a BTCPay + // store, re-running Connect would spawn a NEW webhook subscription + // on BTCPay's side (because the authorize flow always registers + // one). That leaves orphan webhooks pointing at this Keysat that + // BTCPay will keep trying to deliver to, and confuses + // reconciliation. Steer the operator to Disconnect first instead. + try { + const statusResp = await adminCall( + LICENSING_URL, + storeData.admin_api_key, + '/v1/admin/btcpay/status', + { method: 'GET' }, + ) + if (statusResp.ok) { + const status = (await statusResp.json()) as { + connected?: boolean + store_id?: string | null + base_url?: string | null + } + if (status.connected) { + return { + version: '1', + title: 'BTCPay already connected', + message: + `Keysat is already connected to ` + + `${status.base_url ?? '(unknown URL)'} ` + + `(store ${status.store_id ?? '(unknown id)'}).\n\n` + + `To re-authorize (e.g., switch stores or rotate the API key), ` + + `run "Disconnect BTCPay" first, then re-run "Connect BTCPay". ` + + `Existing license keys, products, and policies are unaffected ` + + `by a Disconnect/Connect cycle.\n\n` + + `If you're seeing connection problems, "Check BTCPay connection" ` + + `also reports wallet / payment-method status that the connect ` + + `flow doesn't surface.`, + result: null, + } + } + } + // Status check failure is non-fatal — fall through to the + // authorize flow. Same UX as before. + } catch (_) { + // Same — non-fatal. + } + const resp = await adminCall( LICENSING_URL, storeData.admin_api_key, diff --git a/startos/versions/v0.1.0.ts b/startos/versions/v0.1.0.ts index 28c5bb2..19c2e89 100644 --- a/startos/versions/v0.1.0.ts +++ b/startos/versions/v0.1.0.ts @@ -9,8 +9,20 @@ import { VersionInfo } from '@start9labs/start-sdk' export const v0_1_0 = VersionInfo.of({ - version: '0.1.0:45', + version: '0.1.0:46', releaseNotes: [ + `Alpha-iteration revision 46 of v0.1.0 — Idempotent BTCPay Connect, plus the Go SDK is now part of the published toolchain.`, + ``, + `**Idempotent Connect.** "Connect BTCPay" no longer blindly initiates a fresh authorize flow when Keysat is already connected. It now checks \`/v1/admin/btcpay/status\` first; if a connection exists, it returns a clear "already connected" message that points the operator at "Disconnect BTCPay" → "Connect BTCPay" for the re-authorize case. Closes the v0.2-plan T1 item that was the last outstanding BTCPay UX gap. Without this, re-clicking Connect spawned a new webhook subscription on BTCPay's side every time, leaving orphan webhooks BTCPay would keep trying to deliver to.`, + ``, + `**Go SDK lands.** A pure-Go (stdlib only, no third-party deps) implementation of the LIC1 wire format goes live alongside this release at \`github.com/keysat-xyz/keysat-client-go\`. Verified byte-for-byte against the same \`tests/crosscheck/vector.json\` the Rust, TypeScript, Python SDKs and the daemon itself test against — all four crosscheck fixtures (v1 legacy, v2 trial with entitlements, v2 perpetual unbound, plus end-to-end PEM-load → ParseAndVerify roundtrip) pass. Five independent implementations of the wire format now agree.`, + ``, + `Go SDK API: \`keysat.ParseKey\`, \`keysat.Verify\`, \`keysat.ParseAndVerify\`, \`keysat.HashFingerprint\`, \`keysat.LoadPublicKeyPEM\` for offline use; \`keysat.Client.Validate\` and \`keysat.Client.PublicKey\` for online checks. Idiomatic Go method receivers on \`LicensePayload\` (\`IsTrial\`, \`IsFingerprintBound\`, \`IsExpiredAt\`, \`HasEntitlement\`).`, + ``, + `**Daemon binary unchanged.** This is a wrapper-only revision — no Rust source files moved between :45 and :46. Test count remains 31 on the daemon side; the Go SDK's 4 crosscheck tests run independently against \`go test ./...\`.`, + ``, + `**Upgrade path.** v0.1.0:45 → v0.1.0:46 is a straight drop-in. No new migrations, no schema changes.`, + ``, `Alpha-iteration revision 45 of v0.1.0 — Buyer self-service license recovery and a database-health admin endpoint. Two operator-facing additions that close real friction points the v0.2 plan called out.`, ``, `**Buyer self-service recovery.** Until now, the recovery flow for "I lost my license key" was "DM the operator with your invoice id and we re-send" — operator-time scaling badly. v0.1.0:45 ships \`POST /v1/recover\` and a server-rendered \`GET /recover\` HTML form. A buyer enters their invoice id (handed to them at checkout) and the email they paid with; if both match a settled invoice in the daemon's database, the same signed license key is re-derived and returned. No support ticket, no operator involvement.`,