Widget: offerNwcConnection() -- consent-gated offer-to-save

The inverse of requestNwcConnection: an app that obtained an NWC URI by
its own means (its own wallet chooser, its own paste box) offers the
user a portable copy. The credential is already in the app's hands, so
the only question on screen is whether a copy enters the user's vault
-- and that write happens in BitLogin's own chrome or not at all, never
silently, because a page that can pump records into someone's account
without a tap is a spam vector wearing a feature's clothes.

Dedupe by connection identity (wallet pubkey + secret; relays and
labels may drift without making a new connection -- sameNwcCredential
in core): a duplicate resolves 'already-saved' with a silent
origin-binding refresh and no UI, which is what makes the call safe to
fire after every successful connect. 'unavailable' (no session, no
vault root) is a quiet no-op by contract.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
This commit is contained in:
thePR0M3TH3ANandClaude Fable 5 committed 2026-07-30 21:17:06 -04:00
1 parent 6841d647c2
commit 6b48de6168
9 files changed
+191 -3

No files matched your search

@@ -24,6 +24,7 @@ import {
VaultSession,
parseNwcUri,
toNwcUri,
sameNwcCredential,
validateNwcCredential,
type DecryptedConnectionRecord,
type NwcCredential
@@ -570,6 +571,39 @@ async function handle(action: string, payload: unknown): Promise<unknown> {
return {};
}
case "vaultOfferCheck": {
// The offer-to-save flow's dedupe: the same wallet + secret already
// stored means nothing NEW would enter the vault, so no consent screen
// is warranted. The origin binding is refreshed silently — the offering
// origin already HOLDS the credential (it supplied it), so binding is
// bookkeeping for later one-tap approvals, not a new grant.
const p = payload as { uri: string; origin: string | null };
const credential = parseNwcUri(p.uri);
const { vault, connections } = await listLiveConnections();
const existing = connections.find(
(c) =>
c.record.connection_type === "nwc" &&
sameNwcCredential(c.record.credential as unknown as NwcCredential, credential)
);
if (!existing) return { duplicate: false };
if (p.origin !== null && existing.record.application_binding.origin !== p.origin) {
const { record, event } = await vault.updateConnection(existing, {
application_binding: { origin: p.origin, app_pubkey: null }
});
const publish = await vault.publish({
event,
connectionId: record.connection_id,
relayUrls: vaultRelayUrls,
store
});
if (publish.success) {
const decrypted = await vault.decryptEvent(event);
return { duplicate: true, connection: summarize(decrypted!) };
}
}
return { duplicate: true, connection: summarize(existing) };
}
default:
throw new Error(`Unknown worker action: ${action}`);
}
+13
View File
@@ -220,6 +220,18 @@ export interface VaultDeletePayload {
connectionId: string;
}
export interface VaultOfferCheckPayload {
uri: string;
origin: string | null;
}
/** duplicate=true means the same wallet+secret is already stored; the worker
* has silently refreshed its origin binding, and no consent UI is needed —
* nothing new entered the vault. */
export interface VaultOfferCheckResult {
duplicate: boolean;
connection?: VaultConnectionSummary;
}
export type WorkerActionMap = {
configure: [ConfigurePayload, Record<string, never>];
register: [RegisterPayload, RegisterResult];
@@ -248,6 +260,7 @@ export type WorkerActionMap = {
vaultRevealNwc: [VaultRevealNwcPayload, VaultRevealNwcResult];
vaultSetBinding: [VaultSetBindingPayload, VaultConnectionSummary];
vaultDelete: [VaultDeletePayload, Record<string, never>];
vaultOfferCheck: [VaultOfferCheckPayload, VaultOfferCheckResult];
};
export type WorkerAction = keyof WorkerActionMap;
+5 -1
View File
@@ -21,7 +21,8 @@ import type {
VaultFindForOriginPayload,
VaultRevealNwcPayload,
VaultSetBindingPayload,
VaultDeletePayload
VaultDeletePayload,
VaultOfferCheckPayload
} from "./protocol.js";
export class WorkerClient {
@@ -141,6 +142,9 @@ export class WorkerClient {
vaultDelete(payload: VaultDeletePayload) {
return this.call("vaultDelete", payload);
}
vaultOfferCheck(payload: VaultOfferCheckPayload) {
return this.call("vaultOfferCheck", payload);
}
logout() {
return this.call("logout", {});
}