eleven / ocho Public

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>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev 47a9b01443fcc2e94312020ecd59d59419c0e15f parent 3d49b8f
6 files changed, +32 −6
  • cmd/infinity/main.go +1 −1
  • docs/infinity/status.md +4 −0
  • internal/infinity/app.go +9 −0
  • internal/infinity/static/app.js +2 −1
  • internal/infinity/static/onboarding.js +1 −1
  • test/infinity/browser.mjs +15 −3
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');