eleven / ocho Public

registration: bind account allocation to verified browser work

Serve the framework worker with sealed, single-use challenges, retain idempotent readiness retries, test both browser engines, and document self-hosting and hosted blockers. Extend content-free storage diagnostics with ownership and capacity.
Co-Authored-By: Codex <noreply@openai.com>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev 03e22a4aeb430a9e0207304bbb9b9086a5ef8035 parent 86a0b1e
14 files changed, +356 −11
  • README.md +26 −1
  • apple/Infinity/InfinityApp.swift +1 −1
  • docs/infinity/prompts.md +29 −0
  • docs/infinity/status.md +3 −3
  • internal/infinity/static/onboarding.js +4 −1
  • internal/infinity/static/registration.js +20 −0
  • internal/infinitydiag/storage.go +1 −0
  • internal/infinitydiag/storage_other.go +8 −0
  • internal/infinitydiag/storage_unix.go +21 −0
  • internal/infinityhome/home.go +43 −4
  • internal/infinityhome/home_test.go +46 −1
  • internal/infinityhome/proof.go +48 −0
  • internal/infinityhome/proof_test.go +86 −0
  • test/infinity/registration.mjs +20 −0
diff --git a/README.md b/README.md
index efff2cb..61410ce 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,32 @@
A personal, end-to-end encrypted messenger. Share a link, connect, chat. No phone number. One private instance per person, built on Carlos. DMs and group chats in one list; voice and video come later.
-This fork is under active construction. See [DESIGN.md](DESIGN.md) for the privacy boundary and [PLAN.md](PLAN.md) for implementation status. The inherited Eleven entry point is not the ∞ runtime. Build/run instructions will be added with the working runtime.
+Text DMs, consent-gated groups, encrypted history, delivery retries, recovery files and Apple app shells are implemented. Local browser and protocol checks pass. Hosted startup verification is still blocked by Carloku storage/release-adoption problems; this is not a public-launch claim. See [current status](docs/infinity/status.md) and [DESIGN.md](DESIGN.md).
+
+## Build and check
+
+Use Go with the pinned toolchain, Node 24, and Playwright's Chromium/WebKit dependencies:
+
+```sh
+npm ci --prefix test/infinity
+npx --prefix test/infinity playwright install --with-deps chromium webkit
+tools/infinity/check all
+CGO_ENABLED=0 go build -o .build/infinity ./cmd/infinity
+```
+
+`check all` runs the server, crypto, protocol, registration and three-instance browser tests. [Apple build instructions](apple/README.md) cover iOS and macOS. The inherited Eleven entry point is retained for provenance; `cmd/infinity` is this app's runtime.
+
+## Run your own instance
+
+Run the binary with `--db /persistent/path/infinity.db` and either `--socket /run/infinity.sock` behind an HTTPS proxy or `--addr 127.0.0.1:8080` behind an HTTPS proxy. Set `INFINITY_SETUP_TOKEN` to a random 32-byte base64url secret for first setup, and open your origin with `/#claim=<that secret>`. Keep the secret out of server access logs and shell history. The first successful claim permanently binds the instance to client-generated keys. Subsequent owner requests use a separate client-held credential. Save a password-encrypted recovery file from the account menu immediately.
+
+Every person needs a separate process, database and HTTPS origin. The shared registration service is optional for self-hosting; it provisions processes but never generates a person's encryption keys. Allow outbound HTTPS for peer delivery and back up SQLite consistently, including uncheckpointed WAL. An encrypted database backup alone does not restore client keys: keep your recovery file and password separately. Without the Carlos scheduler, pending delivery retries run while the owner's client is open.
+
+`INFINITY_ALLOW_LOCAL=1` allows localhost HTTP for automated development tests only; hosted instances must leave it unset. [DESIGN.md](DESIGN.md) describes the routing metadata and recovery boundary.
+
+## Hosted review
+
+`tools/infinity/deploy personal` and `tools/infinity/deploy home` build both Linux architectures and deploy to this fork's dedicated Carlos canaries. They require a clean committed checkout and an authenticated Carlos CLI. Provisioning credentials are sealed platform secrets and are not committed. Current endpoints, DNS requirements and unresolved platform failures are recorded in [status](docs/infinity/status.md).
## Honest trade-offs
diff --git a/apple/Infinity/InfinityApp.swift b/apple/Infinity/InfinityApp.swift
index 45bbdf9..8cbe6bd 100644
--- a/apple/Infinity/InfinityApp.swift
+++ b/apple/Infinity/InfinityApp.swift
@@ -1,4 +1,4 @@
-// 🤖 ∞ Apple shell. The shared, audited-by-tests client performs all encryption.
+// 🤖 ∞ Apple shell. The shared, tested client performs all encryption.
import SwiftUI
@main
diff --git a/docs/infinity/prompts.md b/docs/infinity/prompts.md
new file mode 100644
index 0000000..0354557
--- /dev/null
+++ b/docs/infinity/prompts.md
@@ -0,0 +1,29 @@
+# 🤖 Prompt and authorship ledger
+
+Human direction below is preserved verbatim from the task, with local paths and account URLs redacted. Product direction is Paul's; implementation, tests and documentation are assisted by OpenAI Codex. No AI service is required to use the messenger.
+
+1. “Can we do a fork of [local Eleven Messenger checkout] but using https://github.com/carlosframework/skills from the start
+
+ It's a messenger app in the vein of Signal/iMessage/WhatsApp/Messenger
+
+ You send someone a link, and they can connect with you. No phone number required
+
+ Every person is their own instance: all their messages etc.
+
+ The main app is a flat list of DMs or group chats. Teams/Communities etc go on Eleven.
+
+ E2EE
+
+ The logo is the same as Eleven, maybe a different colour, with the Myriad Pro infinity symbol rather than the "
+
+ That's basically it. First version is chat only, then later we can add voice and video calling.
+
+ Any questions?”
+2. “∞”
+3. “continue overnight until that's done”
+4. “I created an ocho carloku account here [account URL redacted]”
+5. “and the domain [domain redacted] is registered”
+6. “continue overnight until done. Need more permissions?”
+7. “continue”
+
+Energy consumption and carbon emissions were not measured. No emissions estimate is claimed. Repository commit trailers identify AI assistance; no human authorship is inferred for generated code.
diff --git a/docs/infinity/status.md b/docs/infinity/status.md
index 2c7fd6e..fcc24d8 100644
--- a/docs/infinity/status.md
+++ b/docs/infinity/status.md
@@ -2,15 +2,15 @@
## Implemented
-Carlos personal-instance runtime; separate registration binary with an app-scoped instance credential and public-key-verified setup tickets; 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 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.
## Validation executed
-All inherited Go and Node tests passed. New Go protocol/storage tests, 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, 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.
## Hosted verification still in progress
-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 are being added to identify the underlying platform cause.
+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 hosted end-to-end chat drive has not passed.
`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.
diff --git a/internal/infinity/static/onboarding.js b/internal/infinity/static/onboarding.js
index 588bda1..e6d5cc6 100644
--- a/internal/infinity/static/onboarding.js
+++ b/internal/infinity/static/onboarding.js
@@ -3,6 +3,7 @@ import { el, button, mark, field, message, busy } from './dom.js';
import { createIdentity, importRecovery, b64, unb64, random } from './crypto.js';
import { request, peerOrigin } from './api.js';
import { Client } from './client.js';
+import { registrationProof } from './registration.js';
export const storageKey = 'infinity.identity.v1';
export function remember(id) { localStorage.setItem(storageKey, JSON.stringify(id)); }
export function readIdentity() { try { return JSON.parse(localStorage.getItem(storageKey)); } catch { return null; } }
@@ -37,10 +38,12 @@ export function welcome(root, params, ready) {
history.replaceState(null, '', '/' + (params.invite ? '#invite=' + encodeURIComponent(params.invite) : '')); ready(c); return;
}
const nonce = sessionStorage.getItem('infinity.signup') || random(); sessionStorage.setItem('infinity.signup', nonce);
+ note.replaceChildren(message('Preparing your private account…'));
+ const proof = await registrationProof(nonce);
let result;
for (let attempt = 0; attempt < 80; attempt++) {
note.replaceChildren(message('Preparing your private account…'));
- result = await request(location.origin, '/api/accounts', { method: 'POST', body: { nonce } });
+ result = await request(location.origin, '/api/accounts', { method: 'POST', body: { nonce, proof } });
if (!result.preparing) break;
await new Promise(resolve => setTimeout(resolve, 1500));
}
diff --git a/internal/infinity/static/registration.js b/internal/infinity/static/registration.js
new file mode 100644
index 0000000..4eb595f
--- /dev/null
+++ b/internal/infinity/static/registration.js
@@ -0,0 +1,20 @@
+// 🤖 Use the framework's matching worker/verifier; account creation stays bounded.
+import { request } from './api.js';
+export async function registrationProof(binding) {
+ const challenge = await request(location.origin, '/api/registration-challenge');
+ const started = Date.now();
+ const workerURL = new URL(challenge.worker, location.origin);
+ if (workerURL.origin !== location.origin) throw new Error('Account setup is unavailable.');
+ const worker = new Worker(workerURL, { type: 'module' });
+ try {
+ const counter = await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error('Account setup took too long. Please try again.')), 60000);
+ worker.onmessage = ({ data }) => { if (data.done) { clearTimeout(timer); resolve(data.counter); } };
+ worker.onerror = () => { clearTimeout(timer); reject(new Error('Could not prepare your account. Please try again.')); };
+ worker.postMessage({ nonce: challenge.nonce, binding, difficulty: challenge.difficulty });
+ });
+ challenge.fields.values[challenge.fields.counter] = String(counter);
+ await new Promise(resolve => setTimeout(resolve, Math.max(0, challenge.wait - (Date.now() - started))));
+ return challenge.fields.values;
+ } finally { worker.terminate(); }
+}
diff --git a/internal/infinitydiag/storage.go b/internal/infinitydiag/storage.go
index ee4f307..9f37dd3 100644
--- a/internal/infinitydiag/storage.go
+++ b/internal/infinitydiag/storage.go
@@ -15,6 +15,7 @@ func Storage(path string) {
continue
}
attrs := []any{"path", p, "mode", st.Mode().String(), "size", st.Size(), "process_uid", os.Getuid()}
+ attrs = append(attrs, platformMetadata(p, st)...)
if !st.IsDir() {
f, e := os.OpenFile(p, os.O_RDWR, 0)
if e != nil {
diff --git a/internal/infinitydiag/storage_other.go b/internal/infinitydiag/storage_other.go
new file mode 100644
index 0000000..109197b
--- /dev/null
+++ b/internal/infinitydiag/storage_other.go
@@ -0,0 +1,8 @@
+//go:build !linux && !darwin
+
+// 🤖 Basic diagnostics remain available on other operating systems.
+package infinitydiag
+
+import "os"
+
+func platformMetadata(string, os.FileInfo) []any { return nil }
diff --git a/internal/infinitydiag/storage_unix.go b/internal/infinitydiag/storage_unix.go
new file mode 100644
index 0000000..328c015
--- /dev/null
+++ b/internal/infinitydiag/storage_unix.go
@@ -0,0 +1,21 @@
+//go:build linux || darwin
+
+// 🤖 Diagnose ownership and capacity without reading or modifying database bytes.
+package infinitydiag
+
+import (
+ "os"
+ "syscall"
+)
+
+func platformMetadata(path string, st os.FileInfo) []any {
+ var attrs []any
+ if s, ok := st.Sys().(*syscall.Stat_t); ok {
+ attrs = append(attrs, "owner_uid", s.Uid, "owner_gid", s.Gid)
+ }
+ var fs syscall.Statfs_t
+ if err := syscall.Statfs(path, &fs); err != nil {
+ return append(attrs, "statfs_error", err)
+ }
+ return append(attrs, "available_bytes", uint64(fs.Bavail)*uint64(fs.Bsize), "free_inodes", fs.Ffree)
+}
diff --git a/internal/infinityhome/home.go b/internal/infinityhome/home.go
index 7c69553..f69c20e 100644
--- a/internal/infinityhome/home.go
+++ b/internal/infinityhome/home.go
@@ -2,9 +2,11 @@
package infinityhome
import (
+ "amadan.net/rastrillo/rastrillo"
"amadan.net/rastrillo/rastrillo/csrf"
"amadan.net/rastrillo/rastrillo/db"
"amadan.net/rastrillo/rastrillo/migrate"
+ "amadan.net/rastrillo/rastrillo/pow"
"bytes"
"context"
"crypto/ed25519"
@@ -37,6 +39,7 @@ type Home struct {
cfg Config
db *sql.DB
client *http.Client
+ guard *pow.Guard
}
func New(d *db.DB, cfg Config) (*Home, error) {
@@ -49,7 +52,7 @@ func New(d *db.DB, cfg Config) (*Home, error) {
if cfg.DailyLimit <= 0 {
cfg.DailyLimit = 100
}
- if _, e := migrate.Apply(context.Background(), d, migrate.MustFromFS(schema, "infinity_home")); e != nil {
+ if _, e := migrate.Apply(context.Background(), d, migrate.Merge(pow.Schema, migrate.MustFromFS(schema, "infinity_home"))); e != nil {
return nil, e
}
s, e := d.G.DB()
@@ -60,10 +63,20 @@ func New(d *db.DB, cfg Config) (*Home, error) {
if c == nil {
c = &http.Client{Timeout: 20 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
}
- return &Home{cfg: cfg, db: s, client: c}, nil
+ guard, e := pow.New(pow.Config{InstanceKey: string(cfg.ClaimKey.Seed()), Nonces: pow.SQLNonces(s)})
+ if e != nil {
+ return nil, e
+ }
+ return &Home{cfg: cfg, db: s, client: c, guard: guard}, nil
}
func (h *Home) Handler() *http.ServeMux {
m := infinity.WebHandler()
+ assets := rastrillo.NewAssets(pow.Assets())
+ m.Handle("GET /pow/", http.StripPrefix("/pow/", assets.Handler()))
+ m.HandleFunc("GET /api/registration-challenge", func(w http.ResponseWriter, r *http.Request) {
+ c := h.guard.Issue(time.Now())
+ write(w, 200, map[string]any{"fields": challengeFields(c), "nonce": c.Nonce, "difficulty": c.Difficulty, "worker": "/pow" + assets.Path("pow-worker.js"), "wait": pow.DefaultMinAge.Milliseconds()})
+ })
m.Handle("POST /api/accounts", csrf.Protect(h.cfg.Origin)(http.HandlerFunc(h.create)))
m.HandleFunc("GET /api/profile", func(w http.ResponseWriter, r *http.Request) {
write(w, 200, map[string]any{"ready": false, "registration": true})
@@ -80,9 +93,10 @@ func failure(w http.ResponseWriter, status int, text string) {
write(w, status, map[string]string{"error": text})
}
func (h *Home) create(w http.ResponseWriter, r *http.Request) {
- r.Body = http.MaxBytesReader(w, r.Body, 1024)
+ r.Body = http.MaxBytesReader(w, r.Body, 4096)
var p struct {
- Nonce string `json:"nonce"`
+ Nonce string `json:"nonce"`
+ Proof map[string]string `json:"proof"`
}
d := json.NewDecoder(r.Body)
d.DisallowUnknownFields()
@@ -98,6 +112,31 @@ func (h *Home) create(w http.ResponseWriter, r *http.Request) {
sum := sha256.Sum256(nonce)
key := hex.EncodeToString(sum[:])
host := "p-" + key[:24] + "." + h.cfg.Domain
+ // Readiness retries know an unguessable registration nonce. Only new
+ // registrations spend work; never hold the writer while Guard.Check writes.
+ var found string
+ e = h.db.QueryRowContext(r.Context(), "SELECT host FROM registrations WHERE nonce_hash=?", key).Scan(&found)
+ if errors.Is(e, sql.ErrNoRows) {
+ check := r.Clone(r.Context())
+ check.Form, check.PostForm = url.Values{}, url.Values{}
+ for k, v := range p.Proof {
+ check.PostForm.Set(k, v)
+ }
+ if reason, ok := h.guard.Check(check, p.Nonce); !ok {
+ status := http.StatusForbidden
+ if reason == pow.ReasonUnavailable {
+ status = http.StatusServiceUnavailable
+ }
+ failure(w, status, "Could not verify account setup. Please try again.")
+ return
+ }
+ // The daily account quota bounds useful writes. Expired replay records
+ // can be removed safely after a valid proof, without a public sweep route.
+ _ = h.guard.Sweep(time.Now())
+ } else if e != nil {
+ failure(w, 503, "Account setup is temporarily unavailable.")
+ return
+ }
tx, e := h.db.BeginTx(r.Context(), nil)
if e != nil {
failure(w, 503, "Account setup is temporarily unavailable.")
diff --git a/internal/infinityhome/home_test.go b/internal/infinityhome/home_test.go
index bbddf1e..3444bdb 100644
--- a/internal/infinityhome/home_test.go
+++ b/internal/infinityhome/home_test.go
@@ -3,18 +3,22 @@ package infinityhome
import (
"amadan.net/rastrillo/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/pow"
"bytes"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/json"
+ "golang.org/x/net/html"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
+ "strconv"
"strings"
"testing"
+ "time"
)
func TestRegistrationTicketRetryQuotaAndOrigin(t *testing.T) {
@@ -63,7 +67,7 @@ func TestRegistrationTicketRetryQuotaAndOrigin(t *testing.T) {
t.Fatal(e)
}
request := func(nonce, origin string, status int) map[string]string {
- b, _ := json.Marshal(map[string]string{"nonce": nonce})
+ b, _ := json.Marshal(map[string]any{"nonce": nonce, "proof": solve(h.guard, nonce)})
r := httptest.NewRequest("POST", "https://ocho.chat/api/accounts", bytes.NewReader(b))
r.Header.Set("Origin", origin)
w := httptest.NewRecorder()
@@ -102,3 +106,44 @@ func TestRegistrationTicketRetryQuotaAndOrigin(t *testing.T) {
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
+
+// Parse the package-generated fields rather than duplicating its private names.
+func solve(g *pow.Guard, binding string) map[string]string {
+ c := g.Issue(time.Now().Add(-4 * time.Second))
+ fields := map[string]string{}
+ z := html.NewTokenizer(strings.NewReader(string(c.Fields())))
+ for {
+ kind := z.Next()
+ if kind == html.ErrorToken {
+ break
+ }
+ if kind != html.StartTagToken && kind != html.SelfClosingTagToken {
+ continue
+ }
+ tok := z.Token()
+ if tok.Data != "input" {
+ continue
+ }
+ name, value, counter := "", "", false
+ for _, a := range tok.Attr {
+ switch a.Key {
+ case "name":
+ name = a.Val
+ case "value":
+ value = a.Val
+ case "data-pow-counter":
+ counter = true
+ }
+ }
+ if counter {
+ for i := 0; ; i++ {
+ value = strconv.Itoa(i)
+ if pow.Verify(c.Nonce, binding, value, c.Difficulty) {
+ break
+ }
+ }
+ }
+ fields[name] = value
+ }
+ return fields
+}
diff --git a/internal/infinityhome/proof.go b/internal/infinityhome/proof.go
new file mode 100644
index 0000000..87c7a80
--- /dev/null
+++ b/internal/infinityhome/proof.go
@@ -0,0 +1,48 @@
+// 🤖 Adapt framework-generated fields to the JSON client without copying wire names.
+package infinityhome
+
+import (
+ "amadan.net/rastrillo/rastrillo/pow"
+ "golang.org/x/net/html"
+ "strings"
+)
+
+type proofFields struct {
+ Values map[string]string `json:"values"`
+ Counter string `json:"counter"`
+}
+
+// The framework's HTML includes inline honeypot positioning. This client uses
+// JSON, so extract input values without sending styling into its strict CSP.
+func challengeFields(c pow.Challenge) proofFields {
+ out := proofFields{Values: map[string]string{}}
+ z := html.NewTokenizer(strings.NewReader(string(c.Fields())))
+ for {
+ kind := z.Next()
+ if kind == html.ErrorToken {
+ return out
+ }
+ if kind != html.StartTagToken && kind != html.SelfClosingTagToken {
+ continue
+ }
+ tok := z.Token()
+ if tok.Data != "input" {
+ continue
+ }
+ name, value, counter := "", "", false
+ for _, a := range tok.Attr {
+ switch a.Key {
+ case "name":
+ name = a.Val
+ case "value":
+ value = a.Val
+ case "data-pow-counter":
+ counter = true
+ }
+ }
+ if counter {
+ out.Counter = name
+ }
+ out.Values[name] = value
+ }
+}
diff --git a/internal/infinityhome/proof_test.go b/internal/infinityhome/proof_test.go
new file mode 100644
index 0000000..55e283d
--- /dev/null
+++ b/internal/infinityhome/proof_test.go
@@ -0,0 +1,86 @@
+// 🤖 Missing work cannot allocate an instance; browser work is single-use and bound.
+package infinityhome
+
+import (
+ "bytes"
+ "crypto/ed25519"
+ "crypto/rand"
+ "encoding/json"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "amadan.net/rastrillo/rastrillo/db"
+)
+
+func TestRegistrationWorkInBrowsers(t *testing.T) {
+ _, key, _ := ed25519.GenerateKey(rand.Reader)
+ d, err := db.Open(filepath.Join(t.TempDir(), "home.db"), slog.New(slog.NewTextHandler(io.Discard, nil)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer d.Close()
+ h, err := New(d, Config{Origin: "https://ocho.chat", Console: "https://unused.test", Account: "test", App: "infinity", Domain: "test.invalid", Token: "test", ClaimKey: key})
+ if err != nil {
+ t.Fatal(err)
+ }
+ handler := h.Handler()
+ req := httptest.NewRequest("POST", "https://ocho.chat/api/accounts", strings.NewReader(`{"nonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}`))
+ req.Header.Set("Origin", "https://ocho.chat")
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+ if rec.Code != 403 {
+ t.Fatalf("unproved allocation: %d", rec.Code)
+ }
+ var count int
+ if err := h.db.QueryRow("SELECT count(*) FROM registrations").Scan(&count); err != nil || count != 0 {
+ t.Fatalf("unproved write: %d %v", count, err)
+ }
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'")
+ handler.ServeHTTP(w, r)
+ }))
+ defer server.Close()
+ cmd := exec.Command("node", "../../test/infinity/registration.mjs", server.URL)
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ out, err := cmd.Output()
+ if err != nil {
+ t.Fatalf("browser registration: %v\n%s", err, stderr.String())
+ }
+ lines := strings.Split(strings.TrimSpace(string(out)), "\n")
+ if len(lines) != 2 {
+ t.Fatalf("expected two browser proofs, got %d", len(lines))
+ }
+ for _, line := range lines {
+ var fields map[string]string
+ if err := json.Unmarshal([]byte(line), &fields); err != nil {
+ t.Fatal(err)
+ }
+ check := func(binding string) bool {
+ r := httptest.NewRequest("POST", "/", nil)
+ r.Form = url.Values{}
+ r.PostForm = url.Values{}
+ for k, v := range fields {
+ r.PostForm.Set(k, v)
+ }
+ _, ok := h.guard.Check(r, binding)
+ return ok
+ }
+ if check("another-registration") {
+ t.Fatal("proof accepted for a different account")
+ }
+ if !check("test-registration") {
+ t.Fatal("browser proof rejected")
+ }
+ if check("test-registration") {
+ t.Fatal("spent proof accepted again")
+ }
+ }
+}
diff --git a/test/infinity/registration.mjs b/test/infinity/registration.mjs
new file mode 100644
index 0000000..a069cfe
--- /dev/null
+++ b/test/infinity/registration.mjs
@@ -0,0 +1,20 @@
+// 🤖 The shipped framework worker must satisfy the actual registration verifier.
+import { createRequire } from 'node:module';
+import assert from 'node:assert/strict';
+const require = createRequire(new URL('./package.json', import.meta.url));
+const { chromium, webkit } = require('playwright');
+for (const engine of [chromium, webkit]) {
+ const browser = await engine.launch({ headless: true });
+ try {
+ const page = await browser.newPage(), errors = [];
+ page.on('pageerror', e => errors.push(e.message));
+ page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
+ await page.goto(process.argv[2]);
+ const proof = await page.evaluate(async () => {
+ const { registrationProof } = await import('/static/registration.js');
+ return registrationProof('test-registration');
+ });
+ assert.deepEqual(errors, []);
+ process.stdout.write(JSON.stringify(proof) + '\n');
+ } finally { await browser.close(); }
+}