registration: persist allocation before polling readiness
Keep retries on the same account after transient console failures, avoid repeated instance-create requests once confirmed, and recover an allocation whose success reply was lost. Tests exercise all three paths. Hosted release state and pending gates remain explicit.
Co-Authored-By: Codex <noreply@openai.com>
6 files changed,
+58
−18
.github/workflows/infinity.yml+1 −1docs/infinity/status.md+15 −7internal/infinityhome/home.go+13 −4internal/infinityhome/home_test.go+21 −1internal/infinityhome/migrations/0002_provisioned.sql+2 −0test/infinity/live.mjs+6 −5
diff --git a/.github/workflows/infinity.yml b/.github/workflows/infinity.yml| index c97fdaf..d250651 100644 |
| --- a/.github/workflows/infinity.yml |
| +++ b/.github/workflows/infinity.yml |
| @@ -2,7 +2,7 @@ |
| name: Infinity |
| on: |
| pull_request: |
| - paths: ['cmd/**', 'internal/infinity/**', 'internal/infinityhome/**', 'test/infinity/**', 'tools/infinity/**', 'go.mod', 'go.sum', '.github/workflows/infinity.yml'] |
| + paths: ['cmd/**', 'internal/infinity/**', 'internal/infinityhome/**', 'internal/infinitydiag/**', 'test/infinity/**', 'tools/infinity/**', 'go.mod', 'go.sum', '.github/workflows/infinity.yml'] |
| workflow_dispatch: |
| permissions: |
| contents: read |
diff --git a/docs/infinity/status.md b/docs/infinity/status.md| index 17e8def..e79b4f9 100644 |
| --- a/docs/infinity/status.md |
| +++ b/docs/infinity/status.md |
| @@ -2,20 +2,28 @@ |
| ## Implemented |
| -Carlos personal-instance runtime; separate registration binary with an app-scoped instance credential and public-key-verified setup tickets; registration proof of work and a persisted daily account budget; signed, encrypted, consent-gated DMs; group creation/acceptance/removal; flat mobile/desktop chat; ciphertext history in every participant's instance; durable outbox, retries and delivery status; encrypted account-state compare-and-swap; password-encrypted recovery and another-device restore; Myriad Pro infinity outline; isolated iOS/macOS WebKit apps. |
| +Carlos personal-instance runtime; separate registration binary with an app-scoped instance credential, public-key-verified setup tickets, sealed proof of work and a persisted daily account budget; signed, encrypted, consent-gated DMs; group creation/acceptance/removal; flat mobile/desktop chat; ciphertext history in every participant's instance; durable outbox, retries and delivery status; encrypted account-state compare-and-swap; password-encrypted recovery and another-device restore; Myriad Pro infinity outline; isolated iOS/macOS WebKit apps. |
| + |
| +The latest registration change persists confirmed provisioning so readiness polls stop repeating allocation requests. Transient provisioning failures remain retryable on the same nonce. Tests cover failed allocation, delayed readiness, idempotent polling and recovery after a lost allocation reply. |
| ## Validation executed |
| -All inherited Go and Node tests passed. New Go protocol/storage tests, real Chromium/WebKit registration proof verification, Node crypto/projection tests and three-instance Chromium/WebKit drives passed (including recovery on an empty browser context). Both Apple targets compiled with signing disabled. govulncheck reported no vulnerabilities after Go 1.25.13 replaced the inherited 1.25.12 toolchain. The browser screenshot helper injects inline CSS in WebKit; visual captures use Chromium, while the full WebKit interaction drive runs under the unchanged strict CSP without screenshot injection. |
| +All inherited Go and Node tests passed. New Go protocol/storage tests, Chromium/WebKit registration-proof verification, Node crypto/projection tests and three-instance Chromium/WebKit drives passed, including recovery on an empty browser context. Both Apple targets compiled with signing disabled after fixing account persistence on asynchronous setup/restore. govulncheck found no reachable vulnerabilities with Go 1.25.13. |
| + |
| +A hosted WebKit drive using two already-provisioned test instances passed setup, invitation/consent and bidirectional E2EE delivery without browser errors. Public signup passed the live proof check and created two distinct instance records, but timed out waiting for platform route materialization. This is not a public-launch claim. |
| + |
| +## Hosted state |
| + |
| +Account `ocho` / `bdy`; apps `infinity` and `infinity-home`; channel `canary/infinity`. At 22:58 UTC the personal review endpoint `https://infinity-test.bdy.oncarlos.com` served `7daa45a0`; registration at `https://infinity-home-test.bdy.oncarlos.com` served `4c6bb26b`. Both adopted after their deployment watches timed out. The subsequent provisioning-idempotency change is validated locally and awaiting deployment. |
| -## Hosted verification still in progress |
| +Earlier restarts failed with `SQLITE_IOERR_SHMSIZE (4874)`. Read-only host metadata showed correct file ownership, 4 KB shared-memory files and roughly 95 MB of usable storage left on a 20 GiB root volume. Both canaries later woke successfully, with 32 KB shared-memory files, without database intervention. No database or WAL has been deleted/reset. Shared-volume expansion from 20 to 40 GiB is prepared and awaiting approval; capacity is a suspected contributor, not a proven errno diagnosis. |
| -Account `ocho` / `bdy`; apps `infinity` and `infinity-home`; channel `canary/infinity`. Review endpoints are `https://infinity-test.bdy.oncarlos.com` and `https://infinity-home-test.bdy.oncarlos.com`. Initial deployment served verified build `3f8e1eab`. Registration created a distinct instance, but redirected before route adoption; the client now waits on the home origin until the process is ready. A subsequent new instance served `f38aaac1`. Existing canaries remained on the older build until restart; restart then exposed `SQLITE_IOERR_SHMSIZE (4874)` at database open. No database has been deleted or reset. Filesystem metadata diagnostics now report ownership, available storage and read/write-open failures without touching content. A separate platform defect leaves already-staged releases waiting on a privileged symlink update; a focused platform fix is being prepared and tested in an isolated branch. No platform change has been merged or deployed. At 22:35 UTC both existing canaries woke successfully without database changes. Read-only host metadata showed roughly 95 MB of usable storage left on a 20 GiB root volume; expansion approval is pending. Newly provisioned routes still await materialization, so the public-registration browser drive has not passed. A separate hosted WebKit drive using two already-provisioned test instances passed setup, invitation/consent and bidirectional E2EE delivery without browser errors. The platform adoption fix is commit `90b0878` on remote branch `fix/relay-blocked-live-adoption`; independent review and the full forge CI gate passed. |
| +The separate platform adoption fix is `90b0878`, branch `fix/relay-blocked-live-adoption` in `carlos/platform`. It relays permission errors replacing staged live symlinks through the existing privileged adoption pass. Its regression failed before the fix and passes afterward; independent review and full forge CI passed. The branch was pushed, but no platform change has been merged or deployed. |
| -`ocho.chat` is attached to the registration canary. DNS is pending: apex A records at 99.81.104.219, 54.252.186.195, 16.60.78.128 and 44.210.206.26, TTL 300. Provider information has been requested. |
| +`ocho.chat` is attached to the registration canary. DNS is pending: apex A records at 99.81.104.219, 54.252.186.195, 16.60.78.128 and 44.210.206.26, TTL 300. Provider information has been requested. Once DNS/TLS is verified, change the registration origin from the canary to `https://ocho.chat` and rerun public signup on the domain. |
| ## Explicit limits |
| -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 currently 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. |
| +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. A private `ocho/ocho` Amadan remote is proposed, but does not yet exist. The forge requires a disclosed agent identity; setting up a Codex identity and namespace grants is awaiting approval. This status is updated as verification progresses; it is not a completion claim. |
| +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. A private `ocho/ocho` Amadan remote is proposed but does not yet exist. Setting up a disclosed Codex identity and namespace grants is awaiting approval. All credentials remain outside the repository; no remote code writes should use another agent's identity. |
diff --git a/internal/infinityhome/home.go b/internal/infinityhome/home.go| index f69c20e..1eb3af6 100644 |
| --- a/internal/infinityhome/home.go |
| +++ b/internal/infinityhome/home.go |
| @@ -20,6 +20,7 @@ import ( |
| "fmt" |
| "io" |
| "lchat/internal/infinity" |
| + "log/slog" |
| "net/http" |
| "net/url" |
| "strings" |
| @@ -144,7 +145,8 @@ func (h *Home) create(w http.ResponseWriter, r *http.Request) { |
| } |
| defer tx.Rollback() |
| var existing string |
| - e = tx.QueryRowContext(r.Context(), "SELECT host FROM registrations WHERE nonce_hash=?", key).Scan(&existing) |
| + var provisioned bool |
| + e = tx.QueryRowContext(r.Context(), "SELECT host,provisioned FROM registrations WHERE nonce_hash=?", key).Scan(&existing, &provisioned) |
| if e != nil && !errors.Is(e, sql.ErrNoRows) { |
| failure(w, 503, "Account setup is temporarily unavailable.") |
| return |
| @@ -172,9 +174,16 @@ func (h *Home) create(w http.ResponseWriter, r *http.Request) { |
| } |
| ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second) |
| defer cancel() |
| - if e = h.provision(ctx, host); e != nil { |
| - failure(w, 503, "Your account is being prepared. Please try again in a moment.") |
| - return |
| + if !provisioned { |
| + if e = h.provision(ctx, host); e != nil { |
| + slog.Warn("infinity provisioning pending", "host", host, "error", e) |
| + write(w, 202, map[string]bool{"preparing": true}) |
| + return |
| + } |
| + if _, e = h.db.ExecContext(ctx, "UPDATE registrations SET provisioned=1 WHERE nonce_hash=?", key); e != nil { |
| + failure(w, 503, "Account setup is temporarily unavailable.") |
| + return |
| + } |
| } |
| if !h.ready(ctx, host) { |
| write(w, 202, map[string]bool{"preparing": true}) |
diff --git a/internal/infinityhome/home_test.go b/internal/infinityhome/home_test.go| index 3444bdb..ce4141a 100644 |
| --- a/internal/infinityhome/home_test.go |
| +++ b/internal/infinityhome/home_test.go |
| @@ -25,6 +25,7 @@ func TestRegistrationTicketRetryQuotaAndOrigin(t *testing.T) { |
| pub, priv, _ := ed25519.GenerateKey(rand.Reader) |
| hosts := map[string]bool{} |
| calls := 0 |
| + profileReady := false |
| console := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| if r.Header.Get("Authorization") != "Bearer service-secret" { |
| t.Error("missing scoped credential") |
| @@ -41,6 +42,10 @@ func TestRegistrationTicketRetryQuotaAndOrigin(t *testing.T) { |
| } |
| json.NewDecoder(r.Body).Decode(&p) |
| calls++ |
| + if calls == 1 { |
| + w.WriteHeader(503) |
| + return |
| + } |
| if p.Channel != "canary/infinity" { |
| t.Error("wrong channel") |
| } |
| @@ -59,6 +64,9 @@ func TestRegistrationTicketRetryQuotaAndOrigin(t *testing.T) { |
| defer d.Close() |
| h, e := New(d, Config{Origin: "https://ocho.chat", Console: console.URL, Account: "bdy", App: "infinity", Domain: "bdy.oncarlos.com", Channel: "canary/infinity", Token: "service-secret", ClaimKey: priv, DailyLimit: 1, Client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { |
| if r.URL.Path == "/api/profile" { |
| + if !profileReady { |
| + return &http.Response{StatusCode: 503, Body: io.NopCloser(strings.NewReader("preparing")), Header: make(http.Header)}, nil |
| + } |
| return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"ready":false}`)), Header: make(http.Header)}, nil |
| } |
| return http.DefaultTransport.RoundTrip(r) |
| @@ -83,6 +91,13 @@ func TestRegistrationTicketRetryQuotaAndOrigin(t *testing.T) { |
| rand.Read(b) |
| nonce := base64.RawURLEncoding.EncodeToString(b) |
| request(nonce, "https://evil.test", 403) |
| + request(nonce, "https://ocho.chat", 202) // transient console failure |
| + request(nonce, "https://ocho.chat", 202) // allocation succeeds, route still pending |
| + request(nonce, "https://ocho.chat", 202) // readiness only |
| + if calls != 2 { |
| + t.Fatalf("readiness repeated allocation: %d", calls) |
| + } |
| + profileReady = true |
| first := request(nonce, "https://ocho.chat", 201) |
| again := request(nonce, "https://ocho.chat", 201) |
| if first["origin"] != again["origin"] || first["claim"] != again["claim"] { |
| @@ -96,9 +111,14 @@ func TestRegistrationTicketRetryQuotaAndOrigin(t *testing.T) { |
| if ed25519.Verify(pub, []byte("infinity-claim-v1\nanother.bdy.oncarlos.com"), sig) { |
| t.Fatal("claim valid for another host") |
| } |
| + // A crash after remote allocation but before recording it recovers via 409 + GET. |
| + if _, err := h.db.Exec("UPDATE registrations SET provisioned=0"); err != nil { |
| + t.Fatal(err) |
| + } |
| + request(nonce, "https://ocho.chat", 201) |
| rand.Read(b) |
| request(base64.RawURLEncoding.EncodeToString(b), "https://ocho.chat", 429) |
| - if calls != 2 { |
| + if calls != 3 { |
| t.Fatal(calls) |
| } |
| } |
diff --git a/internal/infinityhome/migrations/0002_provisioned.sql b/internal/infinityhome/migrations/0002_provisioned.sql| new file mode 100644 |
| index 0000000..95adfcc |
| --- /dev/null |
| +++ b/internal/infinityhome/migrations/0002_provisioned.sql |
| @@ -0,0 +1,2 @@ |
| +-- 🤖 Persist successful allocation; readiness polling never allocates again. |
| +ALTER TABLE registrations ADD COLUMN provisioned INTEGER NOT NULL DEFAULT 0; |
diff --git a/test/infinity/live.mjs b/test/infinity/live.mjs| index c145e23..27ea07f 100644 |
| --- a/test/infinity/live.mjs |
| +++ b/test/infinity/live.mjs |
| @@ -4,20 +4,21 @@ import {createRequire} from 'node:module';import assert from 'node:assert/strict |
| const require=createRequire(process.env.INFINITY_PLAYWRIGHT_PATH||new URL('./package.json',import.meta.url));const{webkit}=require('playwright'); |
| const home=process.env.INFINITY_TEST_HOME||'https://infinity-home-test.bdy.oncarlos.com'; |
| const fixtures=process.env.INFINITY_TEST_ACCOUNTS_FILE?JSON.parse(await readFile(process.env.INFINITY_TEST_ACCOUNTS_FILE,'utf8')):null; |
| +const started=Date.now(); |
| const browser=await webkit.launch({headless:true});const errors=[];const pages=[]; |
| try{ |
| - for(const name of ['Canary Alice','Canary Bob']){ |
| - const ctx=await browser.newContext(),page=await ctx.newPage();page.setDefaultTimeout(90000);pages.push(page); |
| + await Promise.all(['Canary Alice','Canary Bob'].map(async (name,index)=>{ |
| + const ctx=await browser.newContext(),page=await ctx.newPage();page.setDefaultTimeout(90000);pages[index]=page; |
| page.on('pageerror',e=>errors.push(e.message));page.on('console',m=>{if(m.type()==='error')errors.push(m.text())}); |
| - const fixture=fixtures?.[pages.length-1]; |
| + const fixture=fixtures?.[index]; |
| await page.goto(fixture?fixture.origin+'/#claim='+fixture.claim:home);await page.getByLabel('Your name',{exact:true}).fill(name);await page.getByRole('button',{name:'Create your account',exact:true}).click(); |
| await page.getByRole('button',{name:'Invite',exact:true}).waitFor(); |
| - } |
| + })); |
| const[a,b]=pages; |
| await a.getByRole('button',{name:'Invite',exact:true}).click();await a.getByRole('button',{name:'Create invitation',exact:true}).click();const link=await a.getByLabel('Invitation link').inputValue();await a.getByRole('button',{name:'Close',exact:true}).click(); |
| await b.locator('.account-menu summary').click();await b.getByRole('button',{name:'Connect with a link',exact:true}).click();await b.getByLabel('Invitation link').fill(link);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 a.getByRole('heading',{name:'Canary Bob',exact:true}).waitFor(); |
| await b.locator('.thread-row').filter({hasText:'Canary Alice'}).click();await b.getByRole('textbox',{name:'Message',exact:true}).fill('The live encrypted delivery works.');await b.getByRole('button',{name:'Send message',exact:true}).click();await a.getByRole('log').getByText('The live encrypted delivery works.',{exact:true}).waitFor(); |
| await a.getByRole('textbox',{name:'Message',exact:true}).fill('Confirmed from the other personal instance.');await a.getByRole('button',{name:'Send message',exact:true}).click();await b.getByRole('log').getByText('Confirmed from the other personal instance.',{exact:true}).waitFor(); |
| - assert.deepEqual(errors,[]);console.log(JSON.stringify({result:'passed',home,accounts:pages.map(p=>new URL(p.url()).origin),browser:'webkit',scenarios:[fixtures?'pre-provisioned test-account setup':'public registration','distinct personal instances','invitation and consent','bidirectional E2EE delivery','no browser errors']})); |
| + assert.deepEqual(errors,[]);console.log(JSON.stringify({result:'passed',elapsedMs:Date.now()-started,home,accounts:pages.map(p=>new URL(p.url()).origin),browser:'webkit',scenarios:[fixtures?'pre-provisioned test-account setup':'public registration','distinct personal instances','invitation and consent','bidirectional E2EE delivery','no browser errors']})); |
| }catch(e){for(let i=0;i<pages.length;i++)console.log('PAGE',i,new URL(pages[i].url()).origin,await pages[i].locator('body').innerText());throw new Error(e.message.split('Call log:')[0]);}finally{await browser.close();} |