eleven / ocho Public

Resume interrupted invitations and harden native link handling

Keep signup Back navigation natural, resume exact encrypted handshakes after reopening, and pin native owner-authenticated invitation claims to the trusted front door.
Co-Authored-By: Codex <noreply@openai.com>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev 6d125ff774c54675a3b8eb555bfcd799565140ef parent 825a93f
14 files changed, +126 −10
  • android/app/build.gradle.kts +1 −1
  • apple/project.yml +1 −1
  • docs/infinity/simple-onboarding.md +2 −0
  • docs/infinity/status.md +2 −2
  • internal/infinity/static/app.js +1 −0
  • internal/infinity/static/client.js +3 −0
  • internal/infinity/static/onboarding.js +1 −1
  • mobile/client.go +9 −0
  • mobile/consent_test.go +8 −0
  • mobile/handshake_test.go +69 −0
  • mobile/invite.go +4 −0
  • mobile/short_invite.go +6 −0
  • test/infinity/handshake.test.mjs +14 −0
  • test/infinity/home-browser.mjs +5 −5
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 3121be5..b113f64 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -2,7 +2,7 @@ plugins { id("com.android.application"); id("org.jetbrains.kotlin.android"); id(
android {
namespace = "chat.ocho.app"
compileSdk = 35
- defaultConfig { applicationId = "chat.ocho.app"; minSdk = 26; targetSdk = 35; versionCode = 2; versionName = "0.2.0"; testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" }
+ defaultConfig { applicationId = "chat.ocho.app"; minSdk = 26; targetSdk = 35; versionCode = 3; versionName = "0.2.0"; testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" }
buildFeatures { compose = true; buildConfig = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
kotlinOptions { jvmTarget = "17" }
diff --git a/apple/project.yml b/apple/project.yml
index f4a6dd4..2a61a6e 100644
--- a/apple/project.yml
+++ b/apple/project.yml
@@ -8,7 +8,7 @@ settings:
base:
SWIFT_VERSION: '5.0'
MARKETING_VERSION: '0.2.0'
- CURRENT_PROJECT_VERSION: '6'
+ CURRENT_PROJECT_VERSION: '7'
GENERATE_INFOPLIST_FILE: YES
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
CODE_SIGN_STYLE: Automatic
diff --git a/docs/infinity/simple-onboarding.md b/docs/infinity/simple-onboarding.md
index 9f88a91..142426f 100644
--- a/docs/infinity/simple-onboarding.md
+++ b/docs/infinity/simple-onboarding.md
@@ -15,3 +15,5 @@ Profile editing persists display name/avatar in the encrypted vault and sends si
Acceptance: passkey signup and sign-in, cancellation before allocation, lost replies/retry, no tenant navigation, short-link entropy and encryption, preview without consumption, simultaneous redemption isolation, direct chat with inviter offline, existing chat/group integrity, avatar/name synchronization, and native compilation. Preserve pending push work and deploy both features after their checks. No test messaging real contacts or resetting user instances.
Older clients are refused a vault write after format 2 is saved; new web and Go clients preserve profile, preapproval and pending handshake fields. Existing encrypted history is unchanged. Interrupted provisioning retains its nonce and private identity locally; an expired pending ceremony starts a fresh passkey ceremony for the same allocation. Installed older apps need the new build before editing a format-2 account.
+
+An interrupted outgoing invitation is resumed on account refresh from its saved encrypted handshake. Both native and web clients reuse the exact envelope ID, ciphertext and redemption ticket across process/page recreation. Native short-link redemption sends owner credentials only to ocho.chat; localhost front doors are restricted to development accounts.
diff --git a/docs/infinity/status.md b/docs/infinity/status.md
index f2626fd..70e0ceb 100644
--- a/docs/infinity/status.md
+++ b/docs/infinity/status.md
@@ -6,7 +6,7 @@ The new onboarding is passkey-only. At ocho.chat, Sign up opens Name → Create
Invitations use a 22-character fragment with 128 bits of randomness. The server holds only an encrypted descriptor and derived lookup/redemption values. Preview decrypts locally, names the inviter and does not consume the link. Acceptance binds it to one recipient and opens a usable conversation even while the inviter's client is closed. Duplicate acceptance by that recipient is retryable; another recipient cannot reuse it. Name/avatar updates are encrypted and signed. Avatars are bounded embedded raster images.
-SwiftUI iOS/macOS and Kotlin/Compose Android remain fully native, sharing the Go/gomobile engine. The updated clients preserve invitation/profile state; the server prevents older clients from overwriting the newer encrypted vault format. Apple build 6 uses Team Tito Limited (`9U4A84C4WK`), identifiers `chat.ocho.app` and `chat.ocho.app.mac`; Android debug build 2 uses `chat.ocho.app`. Existing personal-team installations are preserved until account access in the new app is confirmed.
+SwiftUI iOS/macOS and Kotlin/Compose Android remain fully native, sharing the Go/gomobile engine. The updated clients preserve invitation/profile state; the server prevents older clients from overwriting the newer encrypted vault format. Apple build 7 uses Team Tito Limited (`9U4A84C4WK`), identifiers `chat.ocho.app` and `chat.ocho.app.mac`; Android debug build 3 uses `chat.ocho.app`. Existing personal-team installations are preserved until account access in the new app is confirmed.
Apple notifications are opt-in and contain only a fixed Ocho activity alert. No sender, message, account, thread or ciphertext goes to Apple. Durable per-device wake delivery retries through the Carlos scheduler. The provider key is sealed on the home service only; personal instances hold scoped wake capabilities. Real-device APNs delivery and physical passkey ceremonies still need user-present verification.
@@ -23,7 +23,7 @@ Apple notifications are opt-in and contain only a fixed Ocho activity alert. No
Dedicated Carlos account `ocho`, apps `infinity-home` and `infinity`, channel `canary/infinity`. Public alias ocho.chat is managed in DNSimple's Eleven Messenger team (`eleven`, account 176940). Deploy binaries sequentially and verify `/api/version` response bodies: version headers can lead process adoption. No user tenant database or WAL is reset.
-The new signup release is prepared for deployment; the preceding push release was shipped. Record final adoption below once verified. New native builds do not replace old installed apps automatically. No App Store Connect record, TestFlight upload, team invitations or Android store release has been created in this session. Android push has no FCM configuration.
+Both Carlos apps adopted signup release `825a93fe`, verified from their response bodies. The live front-door test passed signup, lost-response retry, short invitations, inviter-offline chat, profile sync and passkey history restoration. A follow-up adds browser Back handling and automatic persisted-handshake resumption; deployment verification is recorded below. Team Tito iPhone build 7 is installed and verified by device app metadata; the older personal-team installation remains intact. Signed Mac build 7 and Android debug build 3 are available locally. No App Store Connect record, TestFlight upload, team invitations or Android store release has been created in this session. Android push has no FCM configuration.
Source lives on isolated branch `infinity-v1` at https://amadan.net/eleven/ocho. Main and the original Eleven checkout remain unmerged. Build commands are in the Apple/Android READMEs; architecture and metadata limits are in `simple-onboarding.md`, `push.md` and the design documents.
diff --git a/internal/infinity/static/app.js b/internal/infinity/static/app.js
index 4137f7e..40ec80b 100644
--- a/internal/infinity/static/app.js
+++ b/internal/infinity/static/app.js
@@ -50,3 +50,4 @@ async function boot() {
function showError(error) { root.replaceChildren(el('div', { class: 'welcome-wrap' }, el('section', { class: 'welcome' }, el('h1', {}, 'Couldn’t open Ocho'), message(error.message, true), button('Try again', () => location.reload(), { class: 'primary' })))); }
boot().catch(showError);
window.addEventListener('hashchange',()=>location.reload());
+window.addEventListener('popstate',()=>location.reload());
diff --git a/internal/infinity/static/client.js b/internal/infinity/static/client.js
index 2e35979..260c936 100644
--- a/internal/infinity/static/client.js
+++ b/internal/infinity/static/client.js
@@ -34,6 +34,9 @@ export class Client {
}
async refresh() {
await this.load();
+ for(const [thread,items] of Object.entries(this.state.handshakes||{})) {
+ await this.api('/api/send',{method:'POST',body:{items}});delete this.state.handshakes[thread];await this.save();
+ }
await this.api('/api/retry', { method: 'POST' });
for (;;) {
const rows = await this.api('/api/events?after=' + this.cursor);
diff --git a/internal/infinity/static/onboarding.js b/internal/infinity/static/onboarding.js
index 311d89b..f1476e9 100644
--- a/internal/infinity/static/onboarding.js
+++ b/internal/infinity/static/onboarding.js
@@ -64,7 +64,7 @@ export function welcome(root,params,ready,saved=null) {
const submit=el('button',{type:'submit',class:'primary'},inviter?'Connect with '+inviter:'Create');
const form=el('form',{class:'onboard-form'},name.node,submit);
const showName=()=>{
- history.pushState(null,'','/signup'+hash);note.replaceChildren();
+ if(location.pathname!=='/signup')history.pushState(null,'','/signup'+hash);note.replaceChildren();
shell.replaceChildren(mark(),el('h1',{},inviter?'Connect with '+inviter:'What’s your name?'),form,note,button('Back',showChoices,{class:'quiet'}));name.input.focus();
};
const showChoices=()=>{
diff --git a/mobile/client.go b/mobile/client.go
index b47f6da..d8f2b1b 100644
--- a/mobile/client.go
+++ b/mobile/client.go
@@ -122,6 +122,15 @@ func (c *Client) refresh() (projection, error) {
if e := c.load(); e != nil {
return out, e
}
+ for thread, items := range c.state.Handshakes {
+ if e := c.api("/api/send", "POST", map[string]any{"items": items}, nil); e != nil {
+ return out, e
+ }
+ delete(c.state.Handshakes, thread)
+ if e := c.save(); e != nil {
+ return out, e
+ }
+ }
if e := c.api("/api/retry", "POST", nil, nil); e != nil {
return out, e
}
diff --git a/mobile/consent_test.go b/mobile/consent_test.go
index f157326..551efe5 100644
--- a/mobile/consent_test.go
+++ b/mobile/consent_test.go
@@ -84,3 +84,11 @@ func TestProfileCannotChangeKeysOrFetchRemoteImage(t *testing.T) {
t.Fatal("profile changed identity keys")
}
}
+
+func TestShortLinkCannotSendOwnerBearerToLocalService(t *testing.T) {
+ raw, _ := NewIdentity("https://p-0123456789abcdef01234567.bdy.oncarlos.com", "Alice")
+ client, _ := NewClient(raw)
+ if _, e := client.claimShort("http://127.0.0.1:1", encode(randomBytes(16))); e == nil || e.Error() != "Open an invitation from ocho.chat." {
+ t.Fatal("production owner allowed an untrusted home", e)
+ }
+}
diff --git a/mobile/handshake_test.go b/mobile/handshake_test.go
new file mode 100644
index 0000000..8207425
--- /dev/null
+++ b/mobile/handshake_test.go
@@ -0,0 +1,69 @@
+// 🤖 Process recreation retries the same encrypted invite rather than minting another envelope.
+package ocho
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestRefreshResumesSavedHandshake(t *testing.T) {
+ var disk vaultReply
+ var calls []string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/api/vault":
+ if r.Method == "GET" {
+ json.NewEncoder(w).Encode(disk)
+ return
+ }
+ var next vaultReply
+ json.NewDecoder(r.Body).Decode(&next)
+ disk = vaultReply{Revision: next.Revision + 1, Box: next.Box, Format: next.Format}
+ json.NewEncoder(w).Encode(disk)
+ case "/api/send":
+ b, _ := io.ReadAll(r.Body)
+ calls = append(calls, string(b))
+ if len(calls) == 1 {
+ w.WriteHeader(503)
+ w.Write([]byte(`{"error":"lost response"}`))
+ return
+ }
+ w.Write([]byte(`{}`))
+ case "/api/retry":
+ w.Write([]byte(`{}`))
+ default:
+ w.Write([]byte(`[]`))
+ }
+ }))
+ defer server.Close()
+ raw, _ := NewIdentity(server.URL, "Self")
+ id, _ := readIdentity(raw)
+ state := newVault("Self")
+ state.Handshakes["thread"] = []deliveryItem{{Destination: "https://peer.example", Capability: "scoped", Envelope: envelope{ID: "durable-envelope", Box: "ciphertext"}, Claim: "selected-recipient-ticket"}}
+ plain, _ := marshal(state)
+ disk.Box, _ = Lock(id.VaultKey, plain)
+ disk.Revision = 1
+ first, _ := NewClient(raw)
+ if _, e := first.Refresh(); e == nil {
+ t.Fatal("lost response was ignored")
+ }
+ second, _ := NewClient(raw)
+ if _, e := second.Refresh(); e != nil {
+ t.Fatal(e)
+ }
+ if len(calls) != 2 || calls[0] != calls[1] {
+ t.Fatal("retry changed the handshake")
+ }
+ opened, e := Unlock(id.VaultKey, disk.Box)
+ if e != nil {
+ t.Fatal(e)
+ }
+ var saved vault
+ if e = parse(opened, &saved); e != nil || len(saved.Handshakes) != 0 || disk.Format != 2 {
+ t.Fatal("handshake was not cleared after durable send", e)
+ }
+}
diff --git a/mobile/invite.go b/mobile/invite.go
index 6f7a06a..8bc592a 100644
--- a/mobile/invite.go
+++ b/mobile/invite.go
@@ -58,6 +58,10 @@ func parseInvite(text string) (invite, error) {
return out, errors.New("This invitation is incomplete.")
}
}
+ out.SourceLink = ""
+ out.Claim = ""
+ out.Grant = ""
+ out.Signature = ""
return out, nil
}
func inviteLink(inv invite) (string, error) {
diff --git a/mobile/short_invite.go b/mobile/short_invite.go
index 3e056cc..476d53b 100644
--- a/mobile/short_invite.go
+++ b/mobile/short_invite.go
@@ -96,6 +96,12 @@ func previewShort(link, home, code string) (invite, error) {
return inv, e
}
func (c *Client) claimShort(home, code string) (invite, error) {
+ if home != publicHome {
+ origin, e := secureOrigin(c.id.Origin)
+ if e != nil || origin.Scheme != "http" {
+ return invite{}, errors.New("Open an invitation from ocho.chat.")
+ }
+ }
locator, key, proof := inviteParts(code)
var out struct {
Box string `json:"box"`
diff --git a/test/infinity/handshake.test.mjs b/test/infinity/handshake.test.mjs
new file mode 100644
index 0000000..1a66f74
--- /dev/null
+++ b/test/infinity/handshake.test.mjs
@@ -0,0 +1,14 @@
+// 🤖 An interrupted invite resumes from encrypted state with the identical delivery ID.
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {Client} from '../../internal/infinity/static/client.js';
+import {createIdentity} from '../../internal/infinity/static/crypto.js';
+test('reopening resumes the exact saved invite handshake',async()=>{
+ const identity=await createIdentity('https://self.example','Self');
+ const item={destination:'https://peer.example',capability:'scoped',envelope:{id:'durable-envelope',box:'ciphertext'},claim:'selected-recipient-ticket'};
+ let disk={name:'Self',contacts:{},invites:{},accepted:{},blocked:{},handshakes:{thread:[item]}},calls=[];
+ const reopen=()=>{const client=new Client(identity);client.load=async()=>{client.state=structuredClone(disk);};client.save=async()=>{disk=structuredClone(client.state);};client.api=async(path,options)=>{if(path==='/api/send'){calls.push(structuredClone(options.body));if(calls.length===1)throw Error('lost delivery response');return{};}return path==='/api/retry'?{}:[];};return client;};
+ await assert.rejects(reopen().refresh(),/lost delivery response/);
+ assert.deepEqual(disk.handshakes.thread,[item]);
+ await reopen().refresh();assert.deepEqual(calls,[{items:[item]},{items:[item]}]);assert.deepEqual(disk.handshakes,{});
+});
diff --git a/test/infinity/home-browser.mjs b/test/infinity/home-browser.mjs
index 6926f30..1157b8e 100644
--- a/test/infinity/home-browser.mjs
+++ b/test/infinity/home-browser.mjs
@@ -7,18 +7,18 @@ const {chromium,webkit}=require('playwright');
const home=process.env.OCHO_HOME_URL||'http://localhost:18860',live=process.env.OCHO_LIVE==='1';
if(!live&&home!=='http://localhost:18860')throw Error('Remote verification requires OCHO_LIVE=1');
const server=live?null:spawn('.build/native-check/home-test',['-test.run','^TestHomeBrowserHarness$','-test.timeout','10m'],{env:{...process.env,OCHO_HOME_BROWSER:'1'},stdio:['ignore','pipe','pipe']});
-let browser;
+let browser;const debugPages=[];
async function account(browser,prf=true){const context=await browser.newContext();context.setDefaultTimeout(15000);const page=await context.newPage(),cdp=await context.newCDPSession(page);await cdp.send('WebAuthn.enable');const {authenticatorId}=await cdp.send('WebAuthn.addVirtualAuthenticator',{options:{protocol:'ctap2',ctap2Version:'ctap2_1',transport:'internal',hasResidentKey:true,hasUserVerification:true,isUserVerified:true,automaticPresenceSimulation:true,hasPrf:prf}});return{context,page,cdp,authenticatorId};}
async function chat(page){try{await page.getByRole('button',{name:'Invite',exact:true}).waitFor({timeout:90000});}catch{throw Error((await page.locator('#app').innerText()).slice(0,2000));}assert.equal(new URL(page.url()).origin,home);}
try{
for(let n=0;;n++){try{if((await fetch(home+'/api/profile')).ok)break;}catch{}if(n>100)throw Error('home harness failed');await new Promise(r=>setTimeout(r,100));}
- browser=await chromium.launch();const alice=await account(browser),bob=await account(browser);const errors=[];alice.page.on('pageerror',e=>errors.push(e.message));bob.page.on('pageerror',e=>errors.push(e.message));
+ browser=await chromium.launch();const alice=await account(browser),bob=await account(browser);const errors=[];debugPages.push(alice.page,bob.page);alice.page.on('pageerror',e=>errors.push(e.message));bob.page.on('pageerror',e=>errors.push(e.message));
let allocated=0,createdPasskey=false;
alice.page.on('request',r=>{if(r.url().endsWith('/api/accounts')){allocated++;assert.ok(createdPasskey,'instance allocated before passkey');}});
await alice.cdp.send('WebAuthn.enable');alice.cdp.on('WebAuthn.credentialAdded',()=>{createdPasskey=true;});
let lostFinish=false;await alice.page.route('**/api/signup/finish',async route=>{if(!lostFinish){lostFinish=true;const response=await route.fetch();assert.equal(response.status(),201);await route.abort();}else await route.continue();});
console.log('Opening signup');await alice.page.goto(home);assert.equal(await alice.page.getByLabel('Name',{exact:true}).count(),0);assert.equal(await alice.page.getByText(/recovery file/i).count(),0);
- await alice.page.getByRole('button',{name:'Sign up',exact:true}).click();await alice.page.getByLabel('Name',{exact:true}).fill('Paul');await alice.page.getByRole('button',{name:'Create',exact:true}).click();await alice.page.getByRole('alert').waitFor();await alice.page.getByRole('button',{name:'Create',exact:true}).waitFor();await alice.page.getByRole('button',{name:'Create',exact:true}).click();await chat(alice.page);console.log('Signup passed');assert.ok(allocated>0);
+ await alice.page.getByRole('button',{name:'Sign up',exact:true}).click();await alice.page.goBack();await alice.page.getByRole('button',{name:'Sign up',exact:true}).click();await alice.page.getByLabel('Name',{exact:true}).fill('Paul');await alice.page.getByRole('button',{name:'Create',exact:true}).click();await alice.page.getByRole('alert').waitFor({timeout:180000});await alice.page.getByRole('button',{name:'Create',exact:true}).waitFor();await alice.page.getByRole('button',{name:'Create',exact:true}).click();await chat(alice.page);console.log('Signup passed');assert.ok(allocated>0);
const identity=await alice.page.evaluate(()=>JSON.parse(localStorage.getItem('infinity.identity.v1')));
await alice.page.getByRole('button',{name:'Invite',exact:true}).click();const link=alice.page.getByLabel('Invitation link');await link.waitFor();const invitation=await link.inputValue();assert.match(invitation,new RegExp('^'+home.replaceAll('.','\\.')+'/#[-_A-Za-z0-9]{22}$'));
await alice.page.getByRole('button',{name:'Close',exact:true}).click();
@@ -28,7 +28,7 @@ try{
await bob.page.getByRole('button',{name:'Connect with Paul',exact:true}).click();await chat(bob.page);console.log('Invite signup passed');await bob.page.getByRole('textbox',{name:'Message',exact:true}).fill('Straight into our conversation');await bob.page.getByRole('button',{name:'Send message',exact:true}).click();await bob.page.getByRole('log').getByText('Straight into our conversation',{exact:true}).waitFor();
await alice.page.goto(home);await chat(alice.page);await alice.page.locator('.thread-row').filter({hasText:'Bob'}).click();await alice.page.getByRole('log').getByText('Straight into our conversation',{exact:true}).waitFor();assert.equal(await alice.page.getByRole('button',{name:'Accept',exact:true}).count(),0);
await alice.page.getByRole('textbox',{name:'Message',exact:true}).fill('Hello Bob');await alice.page.getByRole('button',{name:'Send message',exact:true}).click();await bob.page.getByRole('log').getByText('Hello Bob',{exact:true}).waitFor();
- await alice.page.locator('.account-menu summary').click();await alice.page.getByRole('button',{name:'Your profile',exact:true}).click();await alice.page.getByLabel('Display name',{exact:true}).fill('Paul updated');await alice.page.getByRole('button',{name:'Save',exact:true}).click();await bob.page.getByRole('heading',{name:'Paul updated',exact:true}).waitFor();
+ await alice.page.locator('.account-menu summary').click();await alice.page.getByRole('button',{name:'Your profile',exact:true}).click();await alice.page.getByLabel('Display name',{exact:true}).fill('Paul updated');const png=await alice.page.evaluate(()=>{const canvas=document.createElement('canvas');canvas.width=canvas.height=16;canvas.getContext('2d').fillRect(0,0,16,16);return canvas.toDataURL('image/png').split(',')[1];});await alice.page.getByLabel('Avatar',{exact:true}).setInputFiles({name:'avatar.png',mimeType:'image/png',buffer:Buffer.from(png,'base64')});await alice.page.getByAltText('Your avatar',{exact:true}).waitFor();await alice.page.getByRole('button',{name:'Save',exact:true}).click();await bob.page.getByRole('heading',{name:'Paul updated',exact:true}).waitFor();await bob.page.locator('.chat-header img.avatar').waitFor();assert.match(await bob.page.locator('.chat-header img.avatar').getAttribute('src'),/^data:image\/jpeg;base64,/);
await alice.page.evaluate(()=>localStorage.clear());await alice.page.reload();await alice.page.getByRole('button',{name:'Sign in',exact:true}).click();await chat(alice.page);
const restored=await alice.page.evaluate(()=>JSON.parse(localStorage.getItem('infinity.identity.v1')));assert.equal(restored.enc,identity.enc);assert.equal(restored.vaultKey,identity.vaultKey);await alice.page.locator('.thread-row').filter({hasText:'Bob'}).click();await alice.page.getByRole('log').getByText('Hello Bob',{exact:true}).waitFor();
await bob.page.goto(invitation);await bob.page.getByRole('button',{name:'Accept',exact:true}).click();await chat(bob.page);await bob.page.getByRole('log').getByText('Hello Bob',{exact:true}).waitFor();
@@ -36,4 +36,4 @@ try{
const cancelled=await account(browser);let cancelledAllocations=0;cancelled.page.on('request',r=>{if(r.url().endsWith('/api/accounts'))cancelledAllocations++;});await cancelled.page.goto(home);await cancelled.page.evaluate(()=>{navigator.credentials.create=async()=>{throw new DOMException('Cancelled','NotAllowedError');};});await cancelled.page.getByRole('button',{name:'Sign up',exact:true}).click();await cancelled.page.getByLabel('Name',{exact:true}).fill('Cancelled');await cancelled.page.getByRole('button',{name:'Create',exact:true}).click();await cancelled.page.getByText('Passkey creation or sign-in was cancelled. Try again when you’re ready.',{exact:true}).waitFor();assert.equal(cancelledAllocations,0);
assert.deepEqual(errors,[]);console.log('Chromium: passkey-first signup, no recovery UI, 22-character invite, read-only preview, inviter-offline direct consent, DMs, profile sync, passkey history restoration and same-recipient invite retry passed.');
const safari=await webkit.launch();try{const page=await safari.newPage();await page.goto(home);await page.getByRole('button',{name:'Sign up',exact:true}).waitFor();await page.getByRole('button',{name:'Sign up',exact:true}).click();await page.getByLabel('Name',{exact:true}).fill('Safari');assert.equal(new URL(page.url()).origin,home);assert.equal(await page.getByText(/recovery file/i).count(),0);console.log('WebKit: public entry and name step stay on the front door with no recovery-file controls. Real Safari passkey ceremony requires user presence.');}finally{await safari.close();}
-}finally{await browser?.close();server?.kill('SIGTERM');}
+}catch(error){for(const page of debugPages)if(!page.isClosed())console.log('Test screen:',(await page.locator('#app').innerText()).slice(0,1800));throw error;}finally{await browser?.close();server?.kill('SIGTERM');}