From efe36152281462ba50389bd8438e9693fc86aff4 Mon Sep 17 00:00:00 2001
From: Jeff Emmett
Date: Wed, 15 Apr 2026 19:23:28 -0400
Subject: [PATCH] fix(auth,rcred): passkey autofill for mobile + rcred write
access
- Add conditional mediation to sign-in modal so mobile browsers show
saved passkeys with usernames in the autofill area (desktop parity)
- Add publicWrite to rcred module so recompute route's own auth runs
instead of being blocked by the global write-access middleware
Co-Authored-By: Claude Opus 4.6
---
lib/rspace-header.ts | 97 ++++++++++++++++++++++++++++++++++++++++++++
modules/rcred/mod.ts | 1 +
2 files changed, 98 insertions(+)
diff --git a/lib/rspace-header.ts b/lib/rspace-header.ts
index f8323cb8..27866069 100644
--- a/lib/rspace-header.ts
+++ b/lib/rspace-header.ts
@@ -459,6 +459,24 @@ const HEADER_STYLES = `
min-height: 1.2em;
}
+ .rspace-auth-modal__divider {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin: 1rem 0;
+ font-size: 0.8rem;
+ color: #64748b;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ }
+ .rspace-auth-modal__divider::before,
+ .rspace-auth-modal__divider::after {
+ content: '';
+ flex: 1;
+ height: 1px;
+ background: rgba(255,255,255,0.1);
+ }
+
.rspace-auth-modal__toggle {
margin-top: 1rem;
font-size: 0.85rem;
@@ -515,6 +533,7 @@ interface AuthModalCallbacks {
let activeModal: HTMLElement | null = null;
let headerRenderFn: (() => void) | null = null;
+let conditionalAbort: AbortController | null = null;
/**
* Show the EncryptID auth modal for sign-in or registration.
@@ -528,8 +547,73 @@ export function showAuthModal(callbacks?: Partial): void {
let mode: AuthMode = 'signin';
function render() {
+ abortConditional();
overlay.innerHTML = mode === 'signin' ? renderSignIn() : renderRegister();
attachModalListeners();
+ if (mode === 'signin') startConditional();
+ }
+
+ function abortConditional() {
+ if (conditionalAbort) {
+ conditionalAbort.abort();
+ conditionalAbort = null;
+ }
+ }
+
+ /** Start conditional mediation (passkey autofill) for mobile/desktop parity */
+ async function startConditional() {
+ // Check if conditional mediation is supported
+ // @ts-ignore
+ if (!window.PublicKeyCredential?.isConditionalMediationAvailable) return;
+ // @ts-ignore
+ const available = await PublicKeyCredential.isConditionalMediationAvailable();
+ if (!available) return;
+
+ try {
+ const startRes = await fetch(`${ENCRYPTID_URL}/api/auth/start`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({}),
+ });
+ if (!startRes.ok) return;
+ const { options: serverOptions } = await startRes.json();
+
+ conditionalAbort = new AbortController();
+ const credential = await navigator.credentials.get({
+ // @ts-ignore - conditional mediation
+ mediation: 'conditional',
+ signal: conditionalAbort.signal,
+ publicKey: {
+ challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)),
+ rpId: serverOptions.rpId || 'rspace.online',
+ userVerification: 'required',
+ timeout: 120000,
+ },
+ }) as PublicKeyCredential;
+ if (!credential) return;
+
+ // User selected a passkey via autofill — complete auth
+ const errorEl = overlay.querySelector('#auth-error') as HTMLElement;
+ const btn = overlay.querySelector('[data-action="signin"]') as HTMLButtonElement;
+ if (btn) { btn.disabled = true; btn.innerHTML = ' Authenticating...'; }
+
+ const completeRes = await fetch(`${ENCRYPTID_URL}/api/auth/complete`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ challenge: serverOptions.challenge,
+ credential: { credentialId: bufferToBase64url(credential.rawId) },
+ }),
+ });
+ const data = await completeRes.json();
+ if (!completeRes.ok || !data.success) throw new Error(data.error || 'Authentication failed');
+
+ storeSession(data.token, data.username || '', data.did || '');
+ closeModal();
+ callbacks?.onSuccess?.();
+ } catch (err: any) {
+ if (err.name === 'AbortError') return; // user switched to register or closed modal
+ }
}
function renderSignIn(): string {
@@ -540,6 +624,15 @@ export function showAuthModal(callbacks?: Partial): void {
Use your passkey to sign in instantly. No passwords needed —
just your fingerprint, face, or device PIN.
+
+ or