Skip to content

Add same-device wallet links to the wallet chooser - #200

Merged
dlongley merged 1 commit into
mainfrom
feature/same-device-wallet-link
Sep 4, 2026
Merged

Add same-device wallet links to the wallet chooser#200
dlongley merged 1 commit into
mainfrom
feature/same-device-wallet-link

Conversation

@djscruggs

@djscruggs djscruggs commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Phase 2 of cross-device-wallet-selection-spec.md, whose Phase 1 (the cross-device QR code) shipped in 7.2.0. Addresses #166.

What this adds

Two rows in the wallet chooser, shown whenever a relying party supplies an
interaction URL:

  • Open a wallet app on this device — hands the interaction URL to the OS
    as interaction:<url>
  • Open a wallet website — hands it to the browser as
    web+interaction:<url>

Both sit under a single "Don't see your wallet?" heading that also covers
the cross-device QR expander, so the chooser asks the question once and then
lists every way to answer it.

They reach a wallet that registered a URL scheme but is not registered as a
credential handler in this browser. Such a wallet was previously unreachable:
it never appears in the chooser, and a QR code cannot be scanned by the device
displaying it — the case a phone always hits.

Why it belongs here and not in coordinator sites

The same argument that moved the QR code into CHAPI. A coordinator site cannot
know which wallets a user has registered, so it cannot decide which link to
show, or whether to show one at all. The chooser can. Coordinators keep a
single button that calls CHAPI and get this behavior — and later fixes —
without changing their code.

Each row appears only where it can work

Wallet app row Wallet website row
Phone / tablet shown shown where the API exists
Desktop hidden (QR serves it) shown where the API exists
Safari, or any browser on iOS shown hidden
  • Wallet app row is gated on a mobile device, detected by pointer capability
    rather than viewport width: the chooser renders in a 500px popup on the
    desktop, the same width range as a phone, so a breakpoint cannot tell them
    apart.
  • Wallet website row is gated on navigator.registerProtocolHandler() existing.
    Nothing can claim a web+ scheme without it, and Safari has never shipped
    the API — there the link cannot resolve and the browser reports the address
    as invalid, so the row would offer a guaranteed error. This covers every
    browser on iOS, since they are all WebKit; feature detection gets that right
    without enumerating browsers.

Neither row is a fallback for the other: a native app can only claim the bare
scheme, a web app only the web+ form, and nothing in the browser reveals
which the user registered. Custom-scheme navigation also fails silently, with
no event to detect it, so no fallback can be inferred. The user knows whether
their wallet is an app or a website, so both are offered where both can work
and the copy says what silence means.

Layout

Captures from the /test/wallet-chooser harness, generated by
npm run gallery. A single "Don't see your wallet?" heading introduces
every way out of the registered list, and each row underneath it names its
destination on one line.

Both link rows available

Chromium at the 500px popup width, with both gates forced on.

No wallet registered One wallet registered Registered + recommended

Wallet chooser with no registered wallet: the greeting states that no registered wallet has the requested credential, then the heading, the wallet app row highlighted with a blue accent bar, the wallet website row, and the QR code expanded below

Wallet chooser with Demo Wallet 1 listed, then the heading, both link rows in neutral styling, and the collapsed cross-device expander

Wallet chooser with two registered wallets, then a separated section of two recommended wallets, then the heading and both link rows above the collapsed expander

The greeting says what is actually true — no registered wallet holds the requested credential — and keeps the route to obtaining one. The wallet app row is highlighted: with nothing registered, an installed app is the likeliest thing the user has. The QR opens automatically, being the only other option. The wallet list keeps precedence, so both link rows are neutral. The heading reads correctly here, which is what the old "Already have a wallet?" could not: it asks about a wallet not in the list above. Recommended wallets render in their own section below a separator. The heading sits below both sections and covers the rows and the expander alike, so three groups resolve into one question.

Where a row is gated off

Safari on the desktop Phone, dark theme

WebKit with five registered wallets: no link rows at all, the heading sitting directly above the collapsed cross-device expander

iPhone viewport in dark theme with one registered wallet, the heading, both link rows, and the collapsed cross-device expander

Safari has no registerProtocolHandler() and a desktop is not a phone, so neither row shows. The heading still holds: the QR expander beneath it is a way to answer the question. This is the case that made the heading unconditional. Both rows on a phone viewport, dark theme. The rows inherit the dialog's text colour rather than the UA link blue, so they read as the same class of action as the wallet row above.

The rows sit between the wallet list and the QR expander, using the same
wrm-item row shape as the wallet list — so opening a wallet you already have
reads as the same class of action as picking a listed one. The QR stays behind
its expander: it needs a second device, and it is tall enough to push
everything else off a phone screen.

The silent-failure note is not in these shots: it appears only after a row is
activated. It reads "this link" when one row is shown and "these links" when
both are.

With no wallet registered in this browser, the wallet app row is marked
recommended — there is no registered wallet to prefer and the QR needs a
second device, so an installed app is the likeliest thing the user has.

Tapping a row emits nothing and changes no mediator state. A user who taps has
not cancelled, and if nothing claims the scheme the dialog must remain as it
was; dismissal stays with the existing Close button, which resolves
OutOfBand as Phase 1 established.

Note on the schemes

interaction: and web+interaction: are a Digital Bazaar convention, not a
standard. Verified against the VCALM Editor's Draft on 2026-08-19: it defines
no interaction URL scheme, treating interaction URLs as plain HTTPS carrying
iuv=1. The names live in two constants in web/mediator/constants.js so a
rename after CCG/VCALM agreement is a one-line change.

This ships experimentally, which the parent spec's Open Question 1 permits
("after agreement or experimentally to gather feedback"). Feedback is what
agreement needs.

Also included

  • +20px on the cross-device popup height. The dialog does not size itself to
    its content, so each section added to it is paid for by hand here; without
    it the Close button can scroll out of reach. A stopgap until the dynamic
    sizing work lands.

  • The zero-wallet greeting is cut to one line and points at the QR when no
    link rows are available.

  • The cross-device expander is relabeled "Use a wallet on another device", and
    "Don't see your wallet?" is promoted to the heading above it — so it now
    introduces the expander and the same-device rows together, rather than
    labelling one of the three options.

  • /test/wallet-chooser gains app and web params to force either row on
    or off, since the real gates depend on the browser, and jit=N to render
    recommended wallets — a section no harness state could previously produce.

  • playwright.config.js takes an AUTHNIO_TEST_PORT override, so a local
    configs/local.js that moves the dev server off 33443 can still run the
    suites.

  • Config loading moved onto bedrock's config events. The entry points set
    config.paths.config and config.paths.envConfig, and lib/config.js
    imports the environment config plus any optional gitignored
    configs/local.js from handlers it registers. authn.io had been the only
    bedrock application importing its environment config from the entry point
    after lib/index.js, which is what forced local overrides to assign
    values without registering handlers — @bedrock/config-yaml throws unless
    its handler is last. Registering before it removes the constraint.

    Both bedrock-cli.parsed and bedrock.configure load the same file:
    bedrock reads paths.cache/paths.log while initializing loggers, right
    after the first event, so loading only at bedrock.configure sends file
    logging to bedrock's default directory instead. ES modules evaluate once,
    so the repeat import is a cached no-op.

Testing

npm run test:e2e across chromium, webkit, firefox, iphone, and
android-pixel: 305 passed, 40 failed. All 40 are the pre-existing
has no horizontal overflow assertion, marked KNOWN FAILING in the spec file
and unchanged by this branch — 8 states × 5 projects. The only reported
offender is wrm-slide, which comes from vue-web-request-mediator, not from
anything this branch adds.

The count rose from the earlier 28 because the state table grew, not because
new elements overflow: STATES now covers the two same-device link states and
a recommended-wallet state, each with both gates forced on so the rows are
exercised on every project rather than only where the browser satisfies them.

Verified by hand on a real iPhone against a tunnelled mediator: the wallet app
row appears, the wallet website row does not, and the rows render correctly in
the first-party popup. That run predates the copy change, so it confirms the
gating and the popup rendering rather than the current strings; the copy itself
is covered by the captures above and by the harness suites, which assert on the
rows in both states.

Not covered

No web wallet registers web+interaction: yet, so that row is inert where it
shows — Chrome logs "the scheme does not have a registered handler". It is
harmless but unexercised; worth deciding whether to ship it now or hold it
until a wallet registers.

@applesnort

Copy link
Copy Markdown
Contributor

Reviewed the 10 files unique to this branch (#201's four are covered on that PR). The design reasoning in the comments is good — most of this is the code not matching it yet. Three to fix, then smaller things.

1. The new copy is false in its most common case

showWarning is hints.length === 0 (HintChooser.vue:18), and hints are only registrations that match the request — HintManager.reload() filters through matchCredentialRequest(). So "No wallet is registered in this browser." is wrong precisely when the user has a wallet that doesn't hold the requested credential. The replaced copy handled it with a requestType === 'credentialRequest' variant naming the origin, and this component's other warning branch still draws that distinction — so it now contradicts itself.

Also gone: "or visit your wallet website to register" (a first-timer now gets two rows that assume they already have one, and no route to getting one) and the RP's name.

2. The three harness URLs this PR documents show nothing

?hints=1&link=0, ?hints=1&app=1, ?hints=1&web=0qrEnabled requires qr to be present, and both link URLs are gated on it:

qrEnabled.value && linkEnabled.value && appLinkEnabled.value ? ... : ''

So none render a row, and app=1 can't force one on. Add qr=1 to the examples, or drop qrEnabled from the conjunct if a link row without the QR is a valid state.

3. Activating a row navigates the mediator away with nothing sent

Both anchors are plain <a :href> — no target, no click handler, no emit. Where a web+interaction: handler is registered, the click navigates the mediator's own context to the wallet and crossDevice() never runs. That contradicts "Tapping a row emits nothing and changes no mediator state."

I can't say what the RP sees: the popup path detects the dialog leaving by polling handle.closed (WebAppWindowPopupDialog.js:89-90), which a cross-origin navigation doesn't set, so whether the unload listener above it aborts cleanly needs a real registered handler. Latent today — which is the argument for fixing it now rather than discovering it later. Note target="_blank" alone trades silent failure for a blank tab, so it likely wants pairing with emitting the response on click.

Smaller

  • No test coverage. New app/web/link params, no spec uses them. One row in wallet-chooser.spec.js's STATES table ('hints=0&qr=1&app=1') runs the rows through all four existing invariants. The "152 passed" is a suite that can't see this change — I didn't re-run it, so I'm not disputing the number.
  • The rows don't line up with the wallet list they're meant to match. In the one-wallet screenshot "Demo Wallet 1" starts ~70px right of "Open my wallet app" (48px image slot vs. self-sizing glyph), and the wallet row is inset for Hide while the link row runs full width. The CSS comment's reason for the self-sizing icon is sound — this is just its cost, worth being a decision.
  • failureHint renders before any interaction, and in the one-wallet shot asserts no wallet handles the link with a wallet listed above it. Revealing it on activation says the same thing without talking the user out of their fastest option.
  • One sentence, two owners. HintChooserMessage emits the "Open one you already have:" clause while CrossDeviceOptions suppresses its own heading assuming that. Change either condition and you get a dangling colon or two headings.
  • All rights reserved. deleted from CrossDeviceOptions.vue's header.
  • Dead CSS: .wallet-link.wrm-button — the template no longer applies wrm-button — plus an orphaned comment about links sitting "side by side".
  • Non-blocking: pass {app, web} through as one walletLinks prop rather than flattening it across seven files, given constants.js anticipates renames; have the harness import IS_MOBILE_DEVICE/SUPPORTS_WEB_WALLET_LINK instead of re-implementing them; (any-hover: none) probably excludes an iPad with a keyboard, against "phone / tablet: shown"; the recommended row has no word saying so, and #4a90d9 is a fourth blue next to @wrm-blue and @wrm-primary.

Checked clean: getInteractionUrl() validates https + iuv=1, so the prefix can't yield a javascript: href; ?.app || '' means the String props never see null; - 2026-08-dd matches convention.

Pre-existing, not this PR

  • ThirdPartyMediatorWizard.vue:19 calls a handler that doesn't existsetup() never defines or returns crossDevice (FirstPartyMediatorWizard.vue:63 does). Already on main, but it's a TypeError waiting on whichever path reaches it.
  • getInteractionUrl() returns the raw string, not parsed.toString()new URL() strips tabs and newlines, so what ships can differ from what passed validation. No scheme escape possible; normalizing is free.
  • bindAddr = ['0.0.0.0'] shipped in v7.7.0 with no CHANGELOG entry — a release moved the dev server off loopback and the only record is a code comment. Belongs in a 7.7.0 backfill, not here.

Comment thread web/components/CrossDeviceOptions.vue Outdated
v-if="collapsible"
class="wrm-dark-gray"
style="padding-top: 0.5em">
Already have a wallet? Open it here:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This text is a little confusing because it can show up when the user "already has a wallet that is in the list above where text appears"

Suggested change
Already have a wallet? Open it here:
Have a wallet installed on this device? Open it here:

This suggestion might not be good enough because are Web wallets that only register the web+interaction scheme "installed on the device?" ... perhaps not. We need some better language, we should bring this to others to bikeshed it collectively.

Maybe we want "Don't see your wallet?" to be a simple text that we use for all of the cases -- and then we have buttons for opening a wallet "on this device" and the "QR code" for opening "on another device". That might solve this generally.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Took the restructure you proposed rather than the inline suggestion, since as you say a web wallet registering only web+interaction is not "installed on the device".

"Don't see your wallet?" is now a single heading over both the same-device rows and the cross-device QR expander, and it renders unconditionally. That is what makes it true in the case you caught: with wallets listed, the rows are for a wallet that is not in the list; with none, there is nothing to see. It no longer asks whether the user has a wallet directly above their wallets.

Grouping the QR under the same heading is the part that fixes "Choose a Wallet" and "Open a wallet..." reading as competing instructions — the chooser asks once, then lists every way to answer: on this device, in a browser, or on another device.

Side effect worth flagging: this reverses the rename in "Also included". The expander used to read "Don't see your wallet?" and I moved it off that string because it sat below a section answering the question. Making it the section heading instead is the better shape.

Comment thread web/components/CrossDeviceOptions.vue Outdated
<div class="wallet-link-text">
<strong>Open my web wallet</strong>
<div class="wrm-dark-gray wallet-link-caption">
A wallet website you already use

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly, this can show up even if there's a "wallet website you already use in the list above where this appears". So we should bikeshed this a bit too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same fault, and both captions are now deleted rather than reworded. "A wallet website you already use" could appear directly under a list of wallet websites the user already uses, and "An app installed on this device" had the matching problem.

The row titles carry the destination instead:

  • Open a wallet app on this device
  • Open a wallet website
  • Use a wallet on another device

Three parallel phrases under one question, and no low-contrast second line on any of them.

@djscruggs

Copy link
Copy Markdown
Contributor Author

Applied in 67070f5. Verified each claim against the code first; all three blocking ones held, and item 3 was worse than latent.

1. The copy was false

Confirmed: HintManager.reload() filters through matchCredentialRequest(), so hints.length === 0 means "nothing registered here holds what was asked for". Claiming no wallet is registered is wrong in exactly the case you name, and it contradicted the sibling branch three lines down.

Now request-type aware, with the relying party's name and the route to registering a wallet restored:

No wallet registered in this browser has the requested credential. Open a wallet you already have:
Check verifier.example to find out how to obtain it, or visit your wallet website to register.

You were right that two rows assuming an existing wallet, with no route to getting one, is worse for a first-timer than the copy I replaced.

2. The documented harness URLs showed nothing

Confirmed — qrEnabled requires qr to be present and both link URLs conjoin it, so all three examples rendered no rows and app=1 could not force one on. Added qr=1 to each. Kept qrEnabled in the conjunct: the interaction URL is what produces both, so a link row without the QR is not a state the mediator can reach, and the harness should not invent one.

3. Activation navigated away with nothing sent

This was the one worth catching. The rows now emit the out-of-band response on click, before the navigation. Your reasoning about handle.closed is why: a same-context navigation never sets it, so the relying party was left waiting on a dialog that no longer existed, with no signal either way.

I did not add target="_blank" — as you say, that trades silent failure for a blank tab. Emitting on click covers both outcomes, which is the most script can do given they are indistinguishable from here.

While there: ThirdPartyMediatorWizard.vue binds @cross-device but setup() never defined or returned a handler, so any path reaching it threw. Fixed here rather than deferred — it is on main, but this branch is the first to give a user a way to reach it.

Smaller

  • Test coverage. Two rows added to STATES with both gates forced on, so the rows run through all four invariants on every project instead of only where the browser satisfies the gates. Suite is now 212 passed / 28 failed; every failure is the known has no horizontal overflow assertion (7 states × 4 projects), which I confirmed by filtering. Fair hit on the earlier "152 passed" — that suite could not see this change.
  • Failure note revealed only after activation. Verified: 0 before the click, 1 after.
  • Harness duplication removed — it imports IS_MOBILE_DEVICE and SUPPORTS_WEB_WALLET_LINK now, so it cannot drift.
  • getInteractionUrl() returns parsed.toString().
  • Dead CSS and the orphaned comment removed.

Not changed, with reasons

  • All rights reserved. was deleted deliberately. DB convention drops the phrase, and the guidance is to remove it when you are already editing that header for another reason — which the year bump was. Happy to restore it if you read the convention differently.
  • Row alignment with the wallet list. Real, and the cost of the self-sizing icon: the wallet slot is a fixed 48px image, the link icon sizes from its glyph so a missing font leaves no empty square. I would rather keep the robustness and accept the ~70px offset than reserve a box that renders as an artifact when Font Awesome fails — which I hit on a real device during testing. Calling it out as the decision it is.
  • walletLinks as one prop. Agreed it is better shaped; deferred only to keep this diff reviewable. Worth doing when the scheme names change, which constants.js anticipates.
  • (any-hover: none) and iPads. Correct, an iPad with a trackpad reports hover and loses the row. The PR says "phone or tablet"; the gate is narrower than the prose. Left as-is because the alternative — (any-pointer: coarse) alone — pulls in touchscreen laptops, where there is no wallet app to open. Prose is the thing to fix.
  • #4a90d9 as a fourth blue, and no word marking the recommended row. Both fair. The colour should come from a variable, and "Recommended" as a label would say what the accent bar only implies.

Pre-existing

bindAddr shipping in 7.7.0 with no CHANGELOG entry — agreed that belongs in a backfill, not here.

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown

Vulnerability results

No change from base branch.

Total 4 packages affected by 12 known vulnerabilities (0 Critical, 8 High, 4 Medium, 0 Low, 0 Unknown) from 1 ecosystem.
12 vulnerabilities can be fixed.

OSV URL CVSS Ecosystem Package Version Fixed Version Source
https://osv.dev/GHSA-5jgf-p345-68v8 7.5 npm fast-uri 3.1.5 3.1.6 package-lock.json
https://osv.dev/GHSA-f65p-4m7j-42xc 7.5 npm fast-uri 3.1.5 3.1.6 package-lock.json
https://osv.dev/GHSA-fph4-wmhf-6fwf 7.5 npm fast-uri 3.1.5 3.1.6 package-lock.json
https://osv.dev/GHSA-jqff-g426-hqxp 7.5 npm fast-uri 3.1.5 3.1.6 package-lock.json
https://osv.dev/GHSA-5jgf-p345-68v8 7.5 npm fast-uri 4.1.2 4.1.3 package-lock.json
https://osv.dev/GHSA-f65p-4m7j-42xc 7.5 npm fast-uri 4.1.2 4.1.3 package-lock.json
https://osv.dev/GHSA-fph4-wmhf-6fwf 7.5 npm fast-uri 4.1.2 4.1.3 package-lock.json
https://osv.dev/GHSA-jqff-g426-hqxp 7.5 npm fast-uri 4.1.2 4.1.3 package-lock.json
https://osv.dev/GHSA-3m5p-2c4r-xxw2 6.1 npm fastify 5.12.0 5.12.1 package-lock.json
https://osv.dev/GHSA-w2qp-rph6-63g4 5.4 npm fastify 5.12.0 5.12.1 package-lock.json
https://osv.dev/GHSA-4mjr-xmp4-gh2g 6.3 npm qs 6.15.3 6.16.0 package-lock.json
https://osv.dev/GHSA-x5fp-wj9c-mxmx 6.3 npm qs 6.15.3 6.16.0 package-lock.json

npm audit

Severity Name Version Fix Available
moderate body-parser 1.20.5 - 1.20.6 true
moderate express 4.22.2 true
high fast-uri 3.0.0 - 3.1.5
moderate fastify <=5.12.0 true
moderate qs 2.2.5 - 6.15.3 true
npm list
  • @bedrock/express@8.7.0
    • body-parser@1.20.6
      • qs@6.15.3
    • express@4.22.2
      • body-parser@1.20.6
      • qs@6.15.3
    • fastify@5.12.0
      • @fastify/ajv-compiler@4.0.6
        • ajv-formats@3.0.1
          • ajv@8.20.0
            • fast-uri@3.1.5
        • ajv@8.20.0
          • fast-uri@3.1.5
        • fast-uri@4.1.2
      • fast-json-stringify@7.0.1
        • ajv@8.20.0
          • fast-uri@3.1.5
        • fast-uri@4.1.2
  • @bedrock/server@5.1.0
    • express@4.22.2
  • @bedrock/webpack@11.6.1
    • terser-webpack-plugin@5.6.1
      • schema-utils@4.3.3
        • ajv@8.20.0
          • fast-uri@3.1.5
    • webpack@5.109.2
      • minimizer-webpack-plugin@5.6.1
        • schema-utils@4.3.3
          • ajv@8.20.0
            • fast-uri@3.1.5
      • schema-utils@4.3.3
        • ajv@8.20.0
          • fast-uri@3.1.5

@dlongley dlongley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we just need some config reorg here and then we're good to go. I think the UI improvements are good enough to launch, but we could use some additional iterations after feedback from others to help further improve.

Comment thread configs/dev.js Outdated
Comment on lines +27 to +47
/* Optional local overrides, loaded last so they win. `configs/local.js` is
gitignored, so machine-specific settings -- e.g. pointing `server.host` at a
tunnel hostname so a phone can reach the mediator -- stay out of the tracked
config. See `configs/local.js.example`.

This is the development override channel. `@bedrock/config-yaml` (imported
from `lib/index.js`) is another, but it reads `/etc/bedrock-config/app.yaml`
or a base64 blob in `BEDROCK_CONFIG`, which suits deployment rather than a
working copy.

Assign config values only. The file is imported after `lib/index.js` has
imported `@bedrock/config-yaml`, so registering a `bedrock.events` handler
here fails with `"bedrock-config-yaml" must be the last import`.

`pathToFileURL` because `import()` takes a URL, not a filesystem path: a
checkout under a directory containing `#` truncates at the fragment, and a
Windows path parses its drive letter as a URL scheme. */
const localConfigPath = path.join(__dirname, 'local.js');
if(existsSync(localConfigPath)) {
await import(pathToFileURL(localConfigPath).href);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bit doesn't look right / isn't "the bedrock way" and invents a new mechanism.

I see the notes here on how this file loads after lib/index.js which imports bedrock/config-yaml, so what should be done is some appropriate reorganization so an optional local config file can be loaded before bedrock/config-yaml, rather than inventing a new and unfamiliar pattern.

cc: @jameseaster there are some "config bootstrap" patterns you've worked with, could you perhaps help here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reorganized in ca8ee68. You were right that the ad-hoc loader was the wrong shape, and the root cause turned out to be upstream of it: authn.io was the only bedrock app importing its environment config from the entry point after lib/index.js. Every other one sets config.paths.config, imports lib/index.js, and loads configs/*.js from handlers in lib/config.js.

So the entry points now do:

config.paths.config = path.join(__dirname, 'configs');
config.paths.envConfig = 'dev.js';   // or 'authn.io.js'

import './lib/index.js';

and lib/config.js imports the environment config and then any optional
configs/local.js from handlers it registers. Since those are registered before
lib/index.js imports @bedrock/config-yaml, config-yaml is still last and the
whole "assign values only, do not register handlers" caveat is gone — verified
by having a local config register a bedrock.init handler, which threw before
and works now.

One trap worth recording. Loading only at bedrock.configure looks right and is
not: bedrock reads paths.cache and paths.log while initializing loggers,
right after bedrock-cli.parsed, so file logging silently falls back to
/tmp/bedrock-dev. I hit that and confirmed it by removing the handler again.
Both events now load the same file — ES modules evaluate once, so the second
import is a no-op returning the cached module, which keeps one config file per
environment instead of splitting each into paths/core/rest as some repos do.

Suites unchanged at 305 passed / 40 failed, all 40 the known has no horizontal overflow assertion.

first-party wizard defines one and this did not, so any path reaching it
threw. Mirrors that one -- the user has chosen to continue out of band,
which is a response, not a cancellation. */
const crossDevice = async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is fine to add -- but I think we never actually hit the code path anymore so it should get cleaned up anyway once we address #157.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on both counts. Left as-is here: the binding existed with no handler, so any path that did reach it threw a TypeError, and this branch is the first to give a user a way to reach it. Happy for it to be removed wholesale with the rest of that path under #157 rather than maintained.

@djscruggs
djscruggs requested a review from dlongley September 1, 2026 00:52
Comment thread lib/config.js Outdated
Comment on lines +40 to +41
bedrock.events.on('bedrock-cli.parsed', () => _loadConfigs());
bedrock.events.on('bedrock.configure', () => _loadConfigs());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@davidlehn can you look at this? It looks like duplication of effort -- what's the right fix to avoid that?

@dlongley

dlongley commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

@djscruggs One other minor CSS note here -- is that the choices below "Don't see your wallet?" would probably look better if the icons and text were aligned, i.e., each item gets the same space for a centered icon, resulting in the text all being in "vertical alignment" as you look down the options.

Comment thread configs/local.js.example Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be useful to use a local.example.js name so linter would pick this up, and syntax highlighting would work. Given the short nature of it and it's mostly comments, maybe not a big deal here.

Comment thread web/mediator/constants.js Outdated
/* taller hint chooser popup that fits the cross-device QR section, plus
the same-device wallet link rows above it.

The extra 20px is a stopgap: the dialog does not size itself to its

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks odd. Is "20px" referring to the change in this PR? That's not going to make sense when reading the merged code.

@dlongley dlongley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving; this looks good to me, I don't see any config changes like before, so I think we're good to merge -- I'll do that and publish a release. Thanks!

Comment thread CHANGELOG.md Outdated
Add "Open a wallet app on this device" and "Open a wallet website" rows to
the wallet chooser, shown whenever a relying party supplies an interaction
URL. They hand that URL to a wallet on the same device by prefixing it with
the `interaction:` and `web+interaction:` schemes, so a wallet that
registered a scheme without registering as a credential handler can receive
the exchange. Previously such a wallet was unreachable: it never appears in
the chooser, and a QR code cannot be scanned by the device displaying it.

A single "Don't see your wallet?" heading introduces the rows and the
cross-device QR expander together, so the chooser asks the question once and
then lists every way to answer it. Each row appears only where it can work:
the app row on a phone or tablet, the website row only where
`navigator.registerProtocolHandler()` exists.

The rows use the same 48px icon slot as the registered wallet list, so every
row's text starts on the same vertical line. That also resolves the
horizontal overflow the earlier variable-width glyphs caused.

Addresses #166.
@djscruggs
djscruggs force-pushed the feature/same-device-wallet-link branch from 5c64c24 to ff34b17 Compare September 4, 2026 15:32
@dlongley
dlongley merged commit 4c73447 into main Sep 4, 2026
3 checks passed
@dlongley
dlongley deleted the feature/same-device-wallet-link branch September 4, 2026 15:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants