Fix invitation navigation through configurable registration home
Preserve invitations during same-origin recovery and exercise actual link navigation in Chromium and WebKit.
Co-Authored-By: Codex <codex@openai.com>
6 files changed,
+32
−6
cmd/infinity/main.go+1 −1docs/infinity/status.md+4 −0internal/infinity/app.go+9 −0internal/infinity/static/app.js+2 −1internal/infinity/static/onboarding.js+1 −1test/infinity/browser.mjs+15 −3
Intent
diff --git a/cmd/infinity/main.go b/cmd/infinity/main.go| index 1c9d456..8ad043a 100644 |
| --- a/cmd/infinity/main.go |
| +++ b/cmd/infinity/main.go |
| @@ -30,7 +30,7 @@ func run() error { |
| } |
| defer d.Close() |
| setupPublic, _ := base64.RawURLEncoding.DecodeString(os.Getenv("INFINITY_SETUP_PUBLIC_KEY")) |
| - a, e := infinity.New(d, infinity.Config{SetupPublicKey: setupPublic, SetupToken: os.Getenv("INFINITY_SETUP_TOKEN"), AllowLocal: os.Getenv("INFINITY_ALLOW_LOCAL") == "1"}) |
| + a, e := infinity.New(d, infinity.Config{HomeOrigin: os.Getenv("INFINITY_HOME_ORIGIN"), SetupPublicKey: setupPublic, SetupToken: os.Getenv("INFINITY_SETUP_TOKEN"), AllowLocal: os.Getenv("INFINITY_ALLOW_LOCAL") == "1"}) |
| if e != nil { |
| return e |
| } |
diff --git a/docs/infinity/status.md b/docs/infinity/status.md| index 38ec71f..70927dd 100644 |
| --- a/docs/infinity/status.md |
| +++ b/docs/infinity/status.md |
| @@ -27,3 +27,7 @@ The separate platform adoption fix is `90b0878`, branch `fix/relay-blocked-live- |
| This is an early text-chat implementation, not an independently audited security product. Recipient-key encryption has no double-ratchet forward secrecy. Group removal takes effect at a sender when it receives the membership update; old epochs remain valid for authenticated history, with no global cutoff for delayed/backdated old-epoch messages. Web clients trust code served by their origin. Routing metadata, timing, sizes, public keys and outbound delivery capabilities are server-visible. Recovery clones the identity across devices; independent device revocation and passkey-based recovery remain future work. Apple apps share the tested web UI; universal links, background push and App Store signing are not configured. Attachments, voice and video are not implemented. |
| Source work is on `infinity-v1` in the isolated `.worktrees/infinity` checkout. The root checkout preserves the Eleven fork baseline; no main-branch merge has been performed. The user created `https://amadan.net/eleven/ocho` on 2026-09-06. The disclosed `codex` agent now has `eleven` and `carlos` grants, and local authentication has been verified. Codex CLI and Git authentication are configured. The initial baseline push was rejected with `No space left on device`. After external cleanup, the host had roughly 9 GB available and the original Eleven baseline was successfully pushed as `main`. The ∞ implementation is on the separate `infinity-v1` review branch; no implementation merge has been performed. All credentials remain outside the repository; no remote code writes should use another agent's identity. |
| + |
| +## Invitation preview correction — 2026-09-06 (Codex) |
| + |
| +Invitation landing pages use the personal app’s `INFINITY_HOME_ORIGIN` setting (default `https://ocho.chat`). The preview must point this at `https://infinity-home-test.bdy.oncarlos.com` until production DNS works. Invitation fragments survive the front-door redirect, saved-account navigation and same-origin recovery. Browser regression follows an actual invitation through the configured front door into the recipient’s existing account. |
diff --git a/internal/infinity/app.go b/internal/infinity/app.go| index f5dffc5..6616257 100644 |
| --- a/internal/infinity/app.go |
| +++ b/internal/infinity/app.go |
| @@ -26,6 +26,7 @@ import ( |
| var assets embed.FS |
| type Config struct { |
| + HomeOrigin string // public registration front door; configurable for previews/self-hosting |
| SetupPublicKey ed25519.PublicKey |
| SetupToken string |
| AllowLocal bool // explicit local-test/self-host development mode, never production default |
| @@ -54,6 +55,14 @@ func New(d *db.DB, cfg Config) (*App, error) { |
| func (a *App) Handler() *http.ServeMux { |
| m := http.NewServeMux() |
| m.HandleFunc("GET /api/profile", a.profile) |
| + m.HandleFunc("GET /api/client-config", func(w http.ResponseWriter, r *http.Request) { |
| + home := a.cfg.HomeOrigin |
| + if home == "" { |
| + home = "https://ocho.chat" |
| + } |
| + w.Header().Set("Content-Type", "application/json") |
| + json.NewEncoder(w).Encode(map[string]string{"homeOrigin": home}) |
| + }) |
| m.HandleFunc("POST /api/setup", a.setup) |
| m.HandleFunc("POST /api/inbox", a.receive) |
| m.Handle("GET /api/events", a.owner(a.events)) |
diff --git a/internal/infinity/static/app.js b/internal/infinity/static/app.js| index 735fab7..a1660b0 100644 |
| --- a/internal/infinity/static/app.js |
| +++ b/internal/infinity/static/app.js |
| @@ -1,4 +1,5 @@ |
| // 🤖 Entry point: no content is painted until account state has been decrypted. |
| +import { request, peerOrigin } from './api.js'; |
| import { Client } from './client.js'; |
| import { Chat } from './chat.js'; |
| import { fragment, readIdentity, remember, finishSetup, welcome } from './onboarding.js'; |
| @@ -23,7 +24,7 @@ async function boot() { |
| // Invitations open on the inviter's origin. Move to the public front door or an existing account. |
| if (params.invite && location.pathname === '/connect') { |
| const home = localStorage.getItem('infinity.home'); |
| - const target = home || 'https://ocho.chat'; |
| + const target = peerOrigin(home || (await request(location.origin, '/api/client-config')).homeOrigin); |
| root.replaceChildren(el('div', { class: 'welcome-wrap' }, el('section', { class: 'welcome' }, el('h1', {}, 'You’re invited.'), el('p', {}, 'Open your own account to connect.'), button('Continue', () => location.assign(target + '/#invite=' + encodeURIComponent(params.invite)), { class: 'primary' })))); return; |
| } |
| welcome(root, params, client => ready(client).catch(showError)); |
diff --git a/internal/infinity/static/onboarding.js b/internal/infinity/static/onboarding.js| index e6d5cc6..a66ab4d 100644 |
| --- a/internal/infinity/static/onboarding.js |
| +++ b/internal/infinity/static/onboarding.js |
| @@ -76,7 +76,7 @@ export function restore(root, params, ready) { |
| const transfer = b64(new TextEncoder().encode(JSON.stringify(id))); |
| location.assign(id.origin + '/#restore=' + transfer + (params.invite ? '&invite=' + encodeURIComponent(params.invite) : '')); return; |
| } |
| - const c = new Client(id); await c.refresh(); remember(id); history.replaceState(null, '', '/'); ready(c); |
| + const c = new Client(id); await c.refresh(); remember(id); history.replaceState(null, '', '/' + (params.invite ? '#invite=' + encodeURIComponent(params.invite) : '')); ready(c); |
| } catch { note.replaceChildren(message('Could not open that recovery file. Check the file and password.', true)); } }); |
| }); |
| root.replaceChildren(el('div', { class: 'welcome-wrap' }, el('section', { class: 'welcome' }, mark(), el('h1', {}, 'Welcome back.'), el('p', {}, 'Your file and password open your account on this device.'), form, button('Back', () => welcome(root, params, ready), { class: 'quiet' })))); |
diff --git a/test/infinity/browser.mjs b/test/infinity/browser.mjs| index 03c2275..20f423a 100644 |
| --- a/test/infinity/browser.mjs |
| +++ b/test/infinity/browser.mjs |
| @@ -8,7 +8,7 @@ const dir=await mkdtemp(path.join(tmpdir(),'infinity-e2e-'));const children=[];l |
| const errors=[]; const pages=[]; |
| try{ |
| for(let i=0;i<3;i++){ |
| - const child=spawn(process.env.INFINITY_BINARY || '/private/tmp/infinity-browser/infinity',['--addr',`127.0.0.1:${ports[i]}`,'--db',path.join(dir,i+'.db')],{env:{...process.env,INFINITY_SETUP_TOKEN:claim,INFINITY_ALLOW_LOCAL:'1'},stdio:['ignore','pipe','pipe']});children.push(child); |
| + const child=spawn(process.env.INFINITY_BINARY || '/private/tmp/infinity-browser/infinity',['--addr',`127.0.0.1:${ports[i]}`,'--db',path.join(dir,i+'.db')],{env:{...process.env,INFINITY_SETUP_TOKEN:claim,INFINITY_ALLOW_LOCAL:'1',INFINITY_HOME_ORIGIN:origins[2]},stdio:['ignore','pipe','pipe']});children.push(child); |
| for(let attempt=0;;attempt++){try{if((await fetch(origins[i]+'/api/profile')).ok)break;}catch{}if(attempt>100)throw Error('server failed to start');await new Promise(r=>setTimeout(r,100));} |
| } |
| browser=await engine.launch({headless:true}); |
| @@ -23,7 +23,17 @@ try{ |
| const [a,b,c]=pages; |
| async function invite(page){await page.getByRole('button',{name:'Invite',exact:true}).click();await page.getByRole('button',{name:'Create invitation',exact:true}).click();const link=await page.getByLabel('Invitation link').inputValue();await page.getByRole('button',{name:'Close',exact:true}).click();return link;} |
| async function connect(page,link){await page.locator('.account-menu summary').click();await page.getByRole('button',{name:'Connect with a link',exact:true}).click();await page.getByLabel('Invitation link').fill(link);await page.getByRole('button',{name:'Connect',exact:true}).click();await page.getByRole('dialog').waitFor({state:'detached'});} |
| - await connect(b,await invite(a));await a.getByRole('button',{name:'Accept',exact:true}).click(); |
| + // Follow the invitation as a recipient: inviter -> configured front door -> saved account. |
| + const directInvite=await invite(a); |
| + await b.goto(origins[2]);await b.evaluate(origin=>localStorage.setItem('infinity.home',origin),origins[1]); |
| + await b.goto(directInvite);await b.getByRole('button',{name:'Continue',exact:true}).click(); |
| + await b.getByRole('button',{name:'Open your account',exact:true}).waitFor(); |
| + assert.equal(new URL(b.url()).origin,origins[2]);assert.equal(new URL(b.url()).searchParams.size,0); |
| + assert.equal(new URLSearchParams(new URL(b.url()).hash.slice(1)).get('invite'),directInvite); |
| + await b.getByRole('button',{name:'Open your account',exact:true}).click(); |
| + assert.equal(await b.getByLabel('Invitation link').inputValue(),directInvite); |
| + await b.getByRole('button',{name:'Connect',exact:true}).click();await b.getByRole('dialog').waitFor({state:'detached'}); |
| + await a.getByRole('button',{name:'Accept',exact:true}).click(); |
| await b.locator('.thread-row').filter({hasText:'Alice'}).click(); |
| await b.getByRole('textbox',{name:'Message',exact:true}).fill('Hello Alice — private-canary-text');await b.getByRole('button',{name:'Send message',exact:true}).click(); |
| await a.getByRole('log').getByText('Hello Alice — private-canary-text',{exact:true}).waitFor(); |
| @@ -46,8 +56,10 @@ try{ |
| await a.locator('.account-menu summary').click();await a.getByRole('button',{name:'Recovery & another device',exact:true}).click(); |
| await a.getByLabel('Recovery password',{exact:true}).fill('our test recovery password');await a.getByLabel('Repeat password',{exact:true}).fill('our test recovery password'); |
| const downloaded=a.waitForEvent('download');await a.getByRole('button',{name:'Save recovery file',exact:true}).click();const download=await downloaded;const recoveryFile=path.join(dir,'recovery.json');await download.saveAs(recoveryFile);await a.getByRole('button',{name:'Close',exact:true}).click(); |
| - const recovered=await (await browser.newContext()).newPage();await recovered.goto(origins[0]);await recovered.getByRole('button',{name:'I have a recovery file',exact:true}).click(); |
| + const recoveryInvite=await invite(b); |
| + const recovered=await (await browser.newContext()).newPage();await recovered.goto(origins[0]+'/#invite='+encodeURIComponent(recoveryInvite));await recovered.getByRole('button',{name:'I have a recovery file',exact:true}).click(); |
| await recovered.getByLabel('Recovery file',{exact:true}).setInputFiles(recoveryFile);await recovered.getByLabel('Recovery password',{exact:true}).fill('our test recovery password');await recovered.getByRole('button',{name:'Open your account',exact:true}).click(); |
| + assert.equal(await recovered.getByLabel('Invitation link').inputValue(),recoveryInvite);await recovered.getByRole('button',{name:'Close',exact:true}).click(); |
| await recovered.locator('.thread-row').filter({hasText:'The weekend'}).click();await recovered.getByRole('log').getByText('Anyone for a walk?',{exact:true}).waitFor();await recovered.close(); |
| await b.setViewportSize({width:390,height:844});if(engine!==webkit)await b.screenshot({caret:'initial',path:path.join(dir,'mobile.png'),fullPage:true}); |
| assert(await b.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),'mobile overflow'); |