Preserve signup birth evidence and immutable consent receipts
8 files changed,
+2620
−44
internal/console/account.go+4 −22internal/console/account_test.go+89 −6internal/console/billingmembership_test.go+1 −1internal/console/overagepreferencemigration_test.go+479 −0internal/console/overagepreferencestore.go+188 −12internal/console/signuprecords.go+795 −0internal/console/signuprecords_test.go+1050 −0internal/console/store.go+14 −3
CI passed — run details
diff --git a/internal/console/account.go b/internal/console/account.go| index e7529eeda..4a49c7311 100644 |
| --- a/internal/console/account.go |
| +++ b/internal/console/account.go |
| @@ -9,8 +9,6 @@ package console |
| import ( |
| "context" |
| "encoding/json" |
| - "errors" |
| - "os" |
| "time" |
| "github.com/carlosframework/platform/internal/domains" |
| @@ -39,9 +37,8 @@ type accountManifest = domains.AccountManifest |
| // registry only ever reads this; it never assigns an id of its own, which is |
| // what makes the identity survive a registry rebuild. |
| // |
| -// Overwrite-safe (read etag, put, one retry), the same shape the fleet |
| -// snapshot publisher uses: the store's Put is CAS-only, so a rename racing |
| -// another of the owner's own tabs must retry rather than fail. |
| +// Backfill is create-only. A manifest that wins the race after the caller's |
| +// absence check is already the bucket's account identity and is left intact. |
| func WriteAccountManifest(ctx context.Context, s release.Store, id int64, sqid, name string, created time.Time) error { |
| body, err := json.Marshal(accountManifest{ |
| ID: id, Sqid: sqid, Name: name, CreatedAt: created.UTC(), |
| @@ -49,21 +46,6 @@ func WriteAccountManifest(ctx context.Context, s release.Store, id int64, sqid, |
| if err != nil { |
| return err |
| } |
| - key := AccountManifestKey(sqid) |
| - put := func() error { |
| - etag := "" |
| - if _, cur, err := s.Get(ctx, key); err == nil { |
| - etag = cur |
| - } else if !errors.Is(err, os.ErrNotExist) { |
| - return err |
| - } |
| - _, err := s.Put(ctx, key, body, etag) |
| - return err |
| - } |
| - if err := put(); errors.Is(err, release.ErrCASConflict) { |
| - return put() // one retry on a lost race; a second loss surfaces |
| - } else if err != nil { |
| - return err |
| - } |
| - return nil |
| + _, err = s.Put(ctx, AccountManifestKey(sqid), body, "") |
| + return err |
| } |
diff --git a/internal/console/account_test.go b/internal/console/account_test.go| index 2727cab6f..875bcfe74 100644 |
| --- a/internal/console/account_test.go |
| +++ b/internal/console/account_test.go |
| @@ -1,8 +1,10 @@ |
| package console |
| import ( |
| + "bytes" |
| "context" |
| "encoding/json" |
| + "errors" |
| "strings" |
| "testing" |
| "time" |
| @@ -36,17 +38,98 @@ func TestWriteAccountManifestRoundTrips(t *testing.T) { |
| if m.ID != 42 || m.Sqid != "cdef" || m.Name != "Kass" || m.CreatedAt == "" { |
| t.Errorf("manifest = %+v", m) |
| } |
| - // Overwrite-safe: a rename must land, not lose to the CAS gate. |
| - if err := WriteAccountManifest(ctx, s, 42, "cdef", "Kassidy", time.Unix(1700000000, 0)); err != nil { |
| - t.Fatalf("rewrite: %v", err) |
| + winner := append([]byte(nil), body...) |
| + if err := WriteAccountManifest(ctx, s, 42, "cdef", "Kassidy", time.Unix(1700000000, 0)); !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatalf("rewrite error = %v, want CAS conflict", err) |
| } |
| body, _, _ = s.Get(ctx, "accounts/cdef/account.json") |
| - json.Unmarshal(body, &m) |
| - if m.Name != "Kassidy" { |
| - t.Errorf("rewrite lost the rename: %+v", m) |
| + if !bytes.Equal(body, winner) { |
| + t.Fatalf("refused rewrite changed manifest\ngot %s\nwant %s", body, winner) |
| } |
| } |
| +type accountBackfillRaceStore struct { |
| + release.Store |
| + putIfMatch []string |
| + beforePut func(context.Context, release.Store) |
| +} |
| + |
| +func (s *accountBackfillRaceStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + s.putIfMatch = append(s.putIfMatch, ifMatch) |
| + if before := s.beforePut; before != nil { |
| + s.beforePut = nil |
| + before(ctx, s.Store) |
| + } |
| + return s.Store.Put(ctx, key, body, ifMatch) |
| +} |
| + |
| +func TestEnsureAccountManifestBackfillIsCreateOnly(t *testing.T) { |
| + created := time.Unix(1700000000, 123).UTC() |
| + acct := &Account{ID: 42, Sqid: domains.Sqid(42), Name: "Local name", CreatedAt: created} |
| + key := AccountManifestKey(acct.Sqid) |
| + winner := []byte(`{"id":42,"sqid":"` + acct.Sqid + `","name":"Winner","created_at":"2026-10-03T23:00:00Z","owner":"winner@example.test","deleted_at":"2026-10-04T00:00:00Z","future_marker":{"opaque":true}}`) |
| + assertWinner := func(t *testing.T, got []byte) { |
| + t.Helper() |
| + var manifest accountManifest |
| + if err := json.Unmarshal(got, &manifest); err != nil { |
| + t.Fatalf("decode winner: %v", err) |
| + } |
| + if manifest.Owner != "winner@example.test" || manifest.DeletedAt.IsZero() { |
| + t.Fatalf("winner owner=%q deleted_at=%v", manifest.Owner, manifest.DeletedAt) |
| + } |
| + if !bytes.Equal(got, winner) { |
| + t.Fatalf("winner bytes changed\ngot %s\nwant %s", got, winner) |
| + } |
| + } |
| + |
| + t.Run("absent", func(t *testing.T) { |
| + store := &accountBackfillRaceStore{Store: release.NewDirStore(t.TempDir())} |
| + server := &Server{cfg: Config{Releases: store}} |
| + server.ensureAccountManifest(t.Context(), acct) |
| + body, _, err := store.Get(t.Context(), key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want, _ := json.Marshal(accountManifest{ID: acct.ID, Sqid: acct.Sqid, Name: acct.Name, CreatedAt: created}) |
| + if !bytes.Equal(body, want) || len(store.putIfMatch) != 1 || store.putIfMatch[0] != "" { |
| + t.Fatalf("body=%s put if-match=%q, want body=%s one create-only put", body, store.putIfMatch, want) |
| + } |
| + }) |
| + |
| + t.Run("existing", func(t *testing.T) { |
| + inner := release.NewDirStore(t.TempDir()) |
| + if _, err := inner.Put(t.Context(), key, winner, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + store := &accountBackfillRaceStore{Store: inner} |
| + (&Server{cfg: Config{Releases: store}}).ensureAccountManifest(t.Context(), acct) |
| + got, _, err := inner.Get(t.Context(), key) |
| + if err != nil || len(store.putIfMatch) != 0 { |
| + t.Fatalf("winner=%s puts=%q err=%v", got, store.putIfMatch, err) |
| + } |
| + assertWinner(t, got) |
| + }) |
| + |
| + t.Run("winner lands after absence precheck", func(t *testing.T) { |
| + inner := release.NewDirStore(t.TempDir()) |
| + store := &accountBackfillRaceStore{Store: inner} |
| + store.beforePut = func(ctx context.Context, inner release.Store) { |
| + if _, err := inner.Put(ctx, key, winner, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + (&Server{cfg: Config{Releases: store}}).ensureAccountManifest(t.Context(), acct) |
| + got, _, err := inner.Get(t.Context(), key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + assertWinner(t, got) |
| + if len(store.putIfMatch) != 1 || store.putIfMatch[0] != "" { |
| + t.Fatalf("backfill puts if-match=%q, want exactly one create-only attempt", store.putIfMatch) |
| + } |
| + }) |
| +} |
| + |
| // The sqid a minted account carries must be the frozen encoder over its id — |
| // nothing else, ever, or a platform domain stops resolving to its account. |
| func TestMintedSqidIsTheFrozenEncoding(t *testing.T) { |
diff --git a/internal/console/billingmembership_test.go b/internal/console/billingmembership_test.go| index 241cddf28..f9ea25a1e 100644 |
| --- a/internal/console/billingmembership_test.go |
| +++ b/internal/console/billingmembership_test.go |
| @@ -1038,7 +1038,7 @@ func TestMembershipWriterInventory(t *testing.T) { |
| "internal/console/store.go:AddMember", |
| "internal/console/store.go:AdoptManifestOwner", |
| "internal/console/store.go:BackfillOpsMemberships", |
| - "internal/console/store.go:CreateAccount", |
| + "internal/console/store.go:createAccountTx", |
| }, |
| // Role only, between two existing members. |
| "update": { |
diff --git a/internal/console/overagepreferencemigration_test.go b/internal/console/overagepreferencemigration_test.go| new file mode 100644 |
| index 000000000..d681a2525 |
| --- /dev/null |
| +++ b/internal/console/overagepreferencemigration_test.go |
| @@ -0,0 +1,479 @@ |
| +package console |
| + |
| +import ( |
| + "database/sql" |
| + "errors" |
| + "fmt" |
| + "path/filepath" |
| + "reflect" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| +) |
| + |
| +const oldOverageReceiptTable = `CREATE TABLE overage_preference_receipts ( |
| + operation_id TEXT PRIMARY KEY, account_id INTEGER NOT NULL, |
| + actor TEXT NOT NULL, request_digest TEXT NOT NULL, |
| + revision INTEGER NOT NULL, requested_on INTEGER NOT NULL, |
| + terms_version TEXT NOT NULL, price_book TEXT NOT NULL, |
| + currency TEXT NOT NULL, disclosure_digest TEXT NOT NULL, |
| + offer_digest TEXT NOT NULL, changed_at INTEGER NOT NULL, |
| + UNIQUE(account_id, revision) |
| +)` |
| + |
| +const overageReceiptColumns = `operation_id,account_id,actor,request_digest,revision,requested_on,terms_version,price_book,currency,disclosure_digest,offer_digest,changed_at` |
| + |
| +func downgradeOverageReceipts(t *testing.T, db *sql.DB) { |
| + t.Helper() |
| + for _, name := range []string{"overage_preference_receipts_no_update", "overage_preference_receipts_no_delete", "overage_preference_receipts_no_replace"} { |
| + if _, err := db.Exec(`DROP TRIGGER ` + name); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + for _, stmt := range []string{ |
| + `ALTER TABLE overage_preference_receipts RENAME TO receipts_new_shape`, |
| + oldOverageReceiptTable, |
| + `INSERT INTO overage_preference_receipts(` + overageReceiptColumns + `) SELECT ` + overageReceiptColumns + ` FROM receipts_new_shape`, |
| + `DROP TABLE receipts_new_shape`, |
| + } { |
| + if _, err := db.Exec(stmt); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := createOverageReceiptTriggers(db, false); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func oldReceiptFixture(t *testing.T, path string) *sql.DB { |
| + t.Helper() |
| + s, err := Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + db, err := sql.Open("sqlite", path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + downgradeOverageReceipts(t, db) |
| + return db |
| +} |
| + |
| +func receiptRows(t *testing.T, db *sql.DB) [][]string { |
| + t.Helper() |
| + rows, err := db.Query(`SELECT operation_id,typeof(operation_id),account_id,typeof(account_id),actor,typeof(actor),request_digest,typeof(request_digest),revision,typeof(revision),requested_on,typeof(requested_on),terms_version,typeof(terms_version),price_book,typeof(price_book),currency,typeof(currency),disclosure_digest,typeof(disclosure_digest),offer_digest,typeof(offer_digest),changed_at,typeof(changed_at) FROM main.overage_preference_receipts ORDER BY operation_id`) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer rows.Close() |
| + var out [][]string |
| + for rows.Next() { |
| + values := make([]any, 24) |
| + ptrs := make([]any, 24) |
| + for i := range values { |
| + ptrs[i] = &values[i] |
| + } |
| + if err := rows.Scan(ptrs...); err != nil { |
| + t.Fatal(err) |
| + } |
| + line := make([]string, 24) |
| + for i, value := range values { |
| + line[i] = fmt.Sprintf("%T:%v", value, value) |
| + } |
| + out = append(out, line) |
| + } |
| + if err := rows.Err(); err != nil { |
| + t.Fatal(err) |
| + } |
| + return out |
| +} |
| + |
| +func preferenceRows(t *testing.T, db *sql.DB) [][]string { |
| + t.Helper() |
| + rows, err := db.Query(`SELECT account_id,typeof(account_id),revision,typeof(revision),requested_on,typeof(requested_on),terms_version,typeof(terms_version),price_book,typeof(price_book),currency,typeof(currency),disclosure_digest,typeof(disclosure_digest),offer_digest,typeof(offer_digest),actor,typeof(actor),operation_id,typeof(operation_id),updated_at,typeof(updated_at) FROM overage_preferences ORDER BY account_id`) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer rows.Close() |
| + var out [][]string |
| + for rows.Next() { |
| + values := make([]any, 22) |
| + ptrs := make([]any, 22) |
| + for i := range values { |
| + ptrs[i] = &values[i] |
| + } |
| + if err := rows.Scan(ptrs...); err != nil { |
| + t.Fatal(err) |
| + } |
| + line := make([]string, 22) |
| + for i, value := range values { |
| + line[i] = fmt.Sprintf("%T:%v", value, value) |
| + } |
| + out = append(out, line) |
| + } |
| + if err := rows.Err(); err != nil { |
| + t.Fatal(err) |
| + } |
| + return out |
| +} |
| + |
| +func receiptSchema(t *testing.T, db *sql.DB) []string { |
| + t.Helper() |
| + rows, err := db.Query(`SELECT type,name,tbl_name,coalesce(sql,'') FROM sqlite_master WHERE name LIKE 'overage_preference_receipts%' ORDER BY type,name`) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer rows.Close() |
| + var out []string |
| + for rows.Next() { |
| + var typ, name, table, sqlText string |
| + if err := rows.Scan(&typ, &name, &table, &sqlText); err != nil { |
| + t.Fatal(err) |
| + } |
| + out = append(out, typ+"\x00"+name+"\x00"+table+"\x00"+sqlText) |
| + } |
| + if err := rows.Err(); err != nil { |
| + t.Fatal(err) |
| + } |
| + return out |
| +} |
| + |
| +func membershipSerialState(t *testing.T, db *sql.DB) (int, int64) { |
| + t.Helper() |
| + var rows int |
| + var serial int64 |
| + if err := db.QueryRow(`SELECT count(*),coalesce(max(serial),0) FROM membership_write_serial WHERE id=0`).Scan(&rows, &serial); err != nil { |
| + t.Fatal(err) |
| + } |
| + return rows, serial |
| +} |
| + |
| +func TestOverageReceiptRowIDMigrationPreservesHistory(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "console.db") |
| + now := time.Date(2026, 10, 4, 5, 0, 0, 0, time.UTC) |
| + s, err := Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + seedOverageAccount(t, s, 42, overageOwner, now) |
| + on := overageChange(42, overageOwner, 0, true, 71) |
| + if _, err := s.changeOveragePreference(on, now.Add(time.Second)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.changeOveragePreference(overageChange(42, overageOwner, 1, false, 72), now.Add(2*time.Second)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + raw, err := sql.Open("sqlite", path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + downgradeOverageReceipts(t, raw) |
| + before := receiptRows(t, raw) |
| + preferenceBefore := preferenceRows(t, raw) |
| + tx, err := raw.Begin() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := tx.Exec(`INSERT OR REPLACE INTO overage_preference_receipts(rowid,` + overageReceiptColumns + `) SELECT rowid,operation_id||'x',account_id+1,actor,request_digest,revision+10,requested_on,terms_version,price_book,currency,disclosure_digest,offer_digest,changed_at FROM overage_preference_receipts LIMIT 1`); err != nil { |
| + t.Fatalf("old-schema defect witness did not replace by rowid: %v", err) |
| + } |
| + _ = tx.Rollback() |
| + if err := raw.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + s, err = Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer s.Close() |
| + if got := receiptRows(t, s.db); !reflect.DeepEqual(got, before) { |
| + t.Fatalf("receipt rows changed\ngot %#v\nwant %#v", got, before) |
| + } |
| + if got := preferenceRows(t, s.db); !reflect.DeepEqual(got, preferenceBefore) { |
| + t.Fatalf("current preference changed\ngot %#v\nwant %#v", got, preferenceBefore) |
| + } |
| + var wr, triggers int |
| + if err := s.db.QueryRow(`SELECT wr FROM pragma_table_list WHERE schema='main' AND name='overage_preference_receipts'`).Scan(&wr); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type='trigger' AND tbl_name='overage_preference_receipts'`).Scan(&triggers); err != nil { |
| + t.Fatal(err) |
| + } |
| + if wr != 1 || triggers != 3 { |
| + t.Fatalf("wr=%d triggers=%d", wr, triggers) |
| + } |
| + replayed, err := s.changeOveragePreference(on, now.Add(3*time.Second)) |
| + if err != nil || replayed.Revision != 1 || !replayed.RequestedOn { |
| + t.Fatalf("replay=%+v err=%v", replayed, err) |
| + } |
| + current, err := s.getOveragePreference(42, overageOwner) |
| + if err != nil || current.Revision != 2 || current.RequestedOn { |
| + t.Fatalf("current=%+v err=%v", current, err) |
| + } |
| +} |
| + |
| +func TestOverageReceiptWithoutRowIDRefusesMutationRoutes(t *testing.T) { |
| + s := newOverageStore(t, filepath.Join(t.TempDir(), "console.db")) |
| + now := time.Date(2026, 10, 4, 6, 0, 0, 0, time.UTC) |
| + seedOverageAccount(t, s, 42, overageOwner, now) |
| + if _, err := s.changeOveragePreference(overageChange(42, overageOwner, 0, false, 73), now); err != nil { |
| + t.Fatal(err) |
| + } |
| + want := receiptRows(t, s.db) |
| + replacementValues := `operation_id||'x',account_id+100,actor,request_digest,revision+100,requested_on,terms_version,price_book,currency,disclosure_digest,offer_digest,changed_at` |
| + statements := []string{ |
| + `INSERT OR REPLACE INTO overage_preference_receipts(rowid,` + overageReceiptColumns + `) SELECT 1,` + replacementValues + ` FROM overage_preference_receipts`, |
| + `INSERT OR REPLACE INTO overage_preference_receipts(_rowid_,` + overageReceiptColumns + `) SELECT 1,` + replacementValues + ` FROM overage_preference_receipts`, |
| + `INSERT OR REPLACE INTO overage_preference_receipts(oid,` + overageReceiptColumns + `) SELECT 1,` + replacementValues + ` FROM overage_preference_receipts`, |
| + `UPDATE OR REPLACE overage_preference_receipts SET actor='changed'`, |
| + `INSERT OR REPLACE INTO overage_preference_receipts(` + overageReceiptColumns + `) SELECT operation_id,account_id+100,actor,request_digest,revision+100,requested_on,terms_version,price_book,currency,disclosure_digest,offer_digest,changed_at FROM overage_preference_receipts`, |
| + `INSERT OR REPLACE INTO overage_preference_receipts(` + overageReceiptColumns + `) SELECT operation_id||'x',account_id,actor,request_digest,revision,requested_on,terms_version,price_book,currency,disclosure_digest,offer_digest,changed_at FROM overage_preference_receipts`, |
| + `INSERT INTO overage_preference_receipts(` + overageReceiptColumns + `) SELECT ` + overageReceiptColumns + ` FROM overage_preference_receipts ON CONFLICT(operation_id) DO UPDATE SET actor='changed'`, |
| + `UPDATE overage_preference_receipts SET actor='changed'`, |
| + `DELETE FROM overage_preference_receipts`, |
| + } |
| + for _, stmt := range statements { |
| + if _, err := s.db.Exec(stmt); err == nil { |
| + t.Errorf("mutation succeeded: %s", stmt) |
| + } |
| + if got := receiptRows(t, s.db); !reflect.DeepEqual(got, want) { |
| + t.Fatalf("mutation changed rows: %s", stmt) |
| + } |
| + } |
| + // The frozen old migration remains a no-op against the new table. |
| + if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS ` + strings.TrimPrefix(oldOverageReceiptTable, "CREATE TABLE ")); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, trigger := range overageReceiptTriggers { |
| + if _, err := s.db.Exec(strings.Replace(trigger, "CREATE TRIGGER ", "CREATE TRIGGER IF NOT EXISTS ", 1)); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| +} |
| + |
| +func TestOverageReceiptMigrationRefusesUnknownOrInvalidOldState(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + contaminate func(*testing.T, *sql.DB) |
| + }{ |
| + {"null operation id", func(t *testing.T, db *sql.DB) { |
| + _, err := db.Exec(`INSERT INTO overage_preference_receipts(` + overageReceiptColumns + `) VALUES(NULL,1,'actor','digest',1,0,'','','','','',1)`) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + }}, |
| + {"reserved replacement", func(t *testing.T, db *sql.DB) { |
| + if _, err := db.Exec(`CREATE TABLE ` + overageReceiptReplacement + `(value TEXT)`); err != nil { |
| + t.Fatal(err) |
| + } |
| + }}, |
| + {"unknown index", func(t *testing.T, db *sql.DB) { |
| + if _, err := db.Exec(`CREATE INDEX unknown_receipt_index ON overage_preference_receipts(actor)`); err != nil { |
| + t.Fatal(err) |
| + } |
| + }}, |
| + {"trigger name collision", func(t *testing.T, db *sql.DB) { |
| + if _, err := db.Exec(`DROP TRIGGER overage_preference_receipts_no_update`); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := db.Exec(`CREATE TABLE other_receipts(value TEXT)`); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := db.Exec(`CREATE TRIGGER overage_preference_receipts_no_update BEFORE UPDATE ON other_receipts BEGIN SELECT 1; END`); err != nil { |
| + t.Fatal(err) |
| + } |
| + }}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "console.db") |
| + db := oldReceiptFixture(t, path) |
| + tc.contaminate(t, db) |
| + var schema string |
| + if err := db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name='overage_preference_receipts'`).Scan(&schema); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := db.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if s, err := Open(path); err == nil { |
| + s.Close() |
| + t.Fatal("Open accepted unsafe old state") |
| + } |
| + check, err := sql.Open("sqlite", path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer check.Close() |
| + var got string |
| + if err := check.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name='overage_preference_receipts'`).Scan(&got); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got != schema { |
| + t.Fatalf("rollback changed old schema\ngot %s\nwant %s", got, schema) |
| + } |
| + var replacements int |
| + if err := check.QueryRow(`SELECT count(*) FROM sqlite_master WHERE name=?`, overageReceiptReplacement).Scan(&replacements); err != nil { |
| + t.Fatal(err) |
| + } |
| + if tc.name != "reserved replacement" && replacements != 0 { |
| + t.Fatalf("replacement survived rollback: %d", replacements) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestOverageReceiptMigrationConcurrentAndIdempotent(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "console.db") |
| + if err := oldReceiptFixture(t, path).Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + start := make(chan struct{}) |
| + var wg sync.WaitGroup |
| + errs := make(chan error, 2) |
| + for range 2 { |
| + wg.Add(1) |
| + go func() { |
| + defer wg.Done() |
| + <-start |
| + s, err := Open(path) |
| + if err == nil { |
| + err = s.Close() |
| + } |
| + errs <- err |
| + }() |
| + } |
| + close(start) |
| + wg.Wait() |
| + close(errs) |
| + for openErr := range errs { |
| + if openErr != nil { |
| + retry, err := Open(path) |
| + if err != nil { |
| + t.Fatalf("concurrent Open error %v; retry: %v", openErr, err) |
| + } |
| + if err := retry.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + } |
| + s, err := Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var schema string |
| + var serial int64 |
| + if err := s.db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name='overage_preference_receipts'`).Scan(&schema); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.db.QueryRow(`SELECT serial FROM membership_write_serial WHERE id=0`).Scan(&serial); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + s, err = Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer s.Close() |
| + var schema2 string |
| + var serial2 int64 |
| + if err := s.db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name='overage_preference_receipts'`).Scan(&schema2); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.db.QueryRow(`SELECT serial FROM membership_write_serial WHERE id=0`).Scan(&serial2); err != nil { |
| + t.Fatal(err) |
| + } |
| + if schema2 != schema || serial2 != serial { |
| + t.Fatalf("idempotent reopen changed schema/serial: serial %d -> %d", serial, serial2) |
| + } |
| +} |
| + |
| +func TestOverageReceiptMigrationRollsBackDropWhenRenameFindsMalformedView(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "console.db") |
| + db := oldReceiptFixture(t, path) |
| + defer db.Close() |
| + if _, err := db.Exec(`INSERT INTO overage_preference_receipts(` + overageReceiptColumns + `) VALUES('operation',42,'actor','digest',1,0,'terms','book','eur','disclosure','offer',123)`); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := db.Exec(`CREATE VIEW malformed_receipt_dependency AS SELECT * FROM table_that_does_not_exist`); err != nil { |
| + t.Fatal(err) |
| + } |
| + wantRows := receiptRows(t, db) |
| + wantSchema := receiptSchema(t, db) |
| + wantSerialRows, wantSerial := membershipSerialState(t, db) |
| + |
| + s := &Store{db: db} |
| + err := s.rebuildOverageReceipts() |
| + if err == nil || !strings.Contains(err.Error(), "error in view malformed_receipt_dependency") { |
| + t.Fatalf("rebuild error = %v, want ALTER rename malformed-view failure", err) |
| + } |
| + if got := receiptRows(t, db); !reflect.DeepEqual(got, wantRows) { |
| + t.Fatalf("rollback changed rows\ngot %#v\nwant %#v", got, wantRows) |
| + } |
| + if got := receiptSchema(t, db); !reflect.DeepEqual(got, wantSchema) { |
| + t.Fatalf("rollback changed schema\ngot %#v\nwant %#v", got, wantSchema) |
| + } |
| + serialRows, serial := membershipSerialState(t, db) |
| + if serialRows != wantSerialRows || serial != wantSerial { |
| + t.Fatalf("rollback changed membership serial (%d,%d) -> (%d,%d)", wantSerialRows, wantSerial, serialRows, serial) |
| + } |
| + if _, err := db.Exec(`DROP VIEW malformed_receipt_dependency`); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.rebuildOverageReceipts(); err != nil { |
| + t.Fatalf("clean retry: %v", err) |
| + } |
| + var wr int |
| + if err := db.QueryRow(`SELECT wr FROM pragma_table_list WHERE schema='main' AND name='overage_preference_receipts'`).Scan(&wr); err != nil || wr != 1 { |
| + t.Fatalf("clean retry wr=%d err=%v", wr, err) |
| + } |
| +} |
| + |
| +func TestOverageReceiptMigrationRefusesSameConnectionTEMPShadow(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "console.db") |
| + s, err := Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer s.Close() |
| + now := time.Date(2026, 10, 4, 7, 0, 0, 0, time.UTC) |
| + seedOverageAccount(t, s, 42, overageOwner, now) |
| + if _, err := s.changeOveragePreference(overageChange(42, overageOwner, 0, false, 74), now); err != nil { |
| + t.Fatal(err) |
| + } |
| + wantRows := receiptRows(t, s.db) |
| + wantSchema := receiptSchema(t, s.db) |
| + wantSerialRows, wantSerial := membershipSerialState(t, s.db) |
| + if _, err := s.db.Exec(`CREATE TEMP TABLE overage_preference_receipts(value TEXT)`); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.migrateOveragePreference(); !errors.Is(err, errOveragePreferenceCorrupt) { |
| + t.Fatalf("TEMP-shadow migration error = %v", err) |
| + } |
| + if got := receiptRows(t, s.db); !reflect.DeepEqual(got, wantRows) { |
| + t.Fatalf("TEMP refusal changed main rows\ngot %#v\nwant %#v", got, wantRows) |
| + } |
| + if got := receiptSchema(t, s.db); !reflect.DeepEqual(got, wantSchema) { |
| + t.Fatalf("TEMP refusal changed main schema\ngot %#v\nwant %#v", got, wantSchema) |
| + } |
| + serialRows, serial := membershipSerialState(t, s.db) |
| + if serialRows != wantSerialRows || serial != wantSerial { |
| + t.Fatalf("TEMP refusal changed membership serial (%d,%d) -> (%d,%d)", wantSerialRows, wantSerial, serialRows, serial) |
| + } |
| + if _, err := s.db.Exec(`DROP TABLE temp.overage_preference_receipts`); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.migrateOveragePreference(); err != nil { |
| + t.Fatalf("migration after TEMP cleanup: %v", err) |
| + } |
| +} |
diff --git a/internal/console/overagepreferencestore.go b/internal/console/overagepreferencestore.go| index 2da74e953..eba0cea4b 100644 |
| --- a/internal/console/overagepreferencestore.go |
| +++ b/internal/console/overagepreferencestore.go |
| @@ -7,13 +7,35 @@ import ( |
| "encoding/hex" |
| "errors" |
| "math" |
| + "reflect" |
| "regexp" |
| + "strings" |
| "time" |
| "unicode/utf8" |
| "github.com/carlosframework/platform/internal/maintenanceguard" |
| ) |
| +const overageReceiptReplacement = "overage_preference_receipts_without_rowid" |
| + |
| +var overageReceiptTriggers = []string{ |
| + `CREATE TRIGGER overage_preference_receipts_no_update BEFORE UPDATE ON overage_preference_receipts BEGIN SELECT RAISE(ABORT,'overage_preference_receipt_immutable'); END`, |
| + `CREATE TRIGGER overage_preference_receipts_no_delete BEFORE DELETE ON overage_preference_receipts BEGIN SELECT RAISE(ABORT,'overage_preference_receipt_immutable'); END`, |
| + `CREATE TRIGGER overage_preference_receipts_no_replace BEFORE INSERT ON overage_preference_receipts |
| + WHEN EXISTS(SELECT 1 FROM overage_preference_receipts WHERE operation_id=NEW.operation_id OR (account_id=NEW.account_id AND revision=NEW.revision)) |
| + BEGIN SELECT RAISE(ABORT,'overage_preference_receipt_immutable'); END`, |
| +} |
| + |
| +const overageReceiptTable = `CREATE TABLE overage_preference_receipts ( |
| + operation_id TEXT PRIMARY KEY, account_id INTEGER NOT NULL, |
| + actor TEXT NOT NULL, request_digest TEXT NOT NULL, |
| + revision INTEGER NOT NULL, requested_on INTEGER NOT NULL, |
| + terms_version TEXT NOT NULL, price_book TEXT NOT NULL, |
| + currency TEXT NOT NULL, disclosure_digest TEXT NOT NULL, |
| + offer_digest TEXT NOT NULL, changed_at INTEGER NOT NULL, |
| + UNIQUE(account_id, revision) |
| +) WITHOUT ROWID` |
| + |
| var ( |
| errOveragePreferenceInvalid = errors.New("overage_preference_invalid") |
| errOveragePreferenceDenied = errors.New("overage_preference_denied") |
| @@ -72,7 +94,12 @@ func (s *Store) migrateOveragePreference() error { |
| disclosure_digest TEXT NOT NULL, offer_digest TEXT NOT NULL, |
| actor TEXT NOT NULL, operation_id TEXT NOT NULL, updated_at INTEGER NOT NULL |
| )`, |
| - `CREATE TABLE IF NOT EXISTS overage_preference_receipts ( |
| + } { |
| + if _, err := s.db.Exec(stmt); err != nil { |
| + return err |
| + } |
| + } |
| + if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS overage_preference_receipts ( |
| operation_id TEXT PRIMARY KEY, account_id INTEGER NOT NULL, |
| actor TEXT NOT NULL, request_digest TEXT NOT NULL, |
| revision INTEGER NOT NULL, requested_on INTEGER NOT NULL, |
| @@ -80,20 +107,161 @@ func (s *Store) migrateOveragePreference() error { |
| currency TEXT NOT NULL, disclosure_digest TEXT NOT NULL, |
| offer_digest TEXT NOT NULL, changed_at INTEGER NOT NULL, |
| UNIQUE(account_id, revision) |
| -)`, |
| - `CREATE TRIGGER IF NOT EXISTS overage_preference_receipts_no_update BEFORE UPDATE ON overage_preference_receipts BEGIN SELECT RAISE(ABORT,'overage_preference_receipt_immutable'); END`, |
| - `CREATE TRIGGER IF NOT EXISTS overage_preference_receipts_no_delete BEFORE DELETE ON overage_preference_receipts BEGIN SELECT RAISE(ABORT,'overage_preference_receipt_immutable'); END`, |
| - `CREATE TRIGGER IF NOT EXISTS overage_preference_receipts_no_replace BEFORE INSERT ON overage_preference_receipts |
| - WHEN EXISTS(SELECT 1 FROM overage_preference_receipts WHERE operation_id=NEW.operation_id OR (account_id=NEW.account_id AND revision=NEW.revision)) |
| - BEGIN SELECT RAISE(ABORT,'overage_preference_receipt_immutable'); END`, |
| - } { |
| - if _, err := s.db.Exec(stmt); err != nil { |
| +) WITHOUT ROWID`); err != nil { |
| + return err |
| + } |
| + wr, err := overageReceiptWR(s.db) |
| + if err != nil { |
| + return err |
| + } |
| + if wr == 1 { |
| + if err := validateOverageReceiptShape(s.db, true); err != nil { |
| + return err |
| + } |
| + } |
| + if wr == 0 { |
| + if err := s.rebuildOverageReceipts(); err != nil { |
| + return err |
| + } |
| + } |
| + return createOverageReceiptTriggers(s.db, true) |
| +} |
| + |
| +type overageReceiptExecer interface { |
| + Exec(string, ...any) (sql.Result, error) |
| +} |
| + |
| +func createOverageReceiptTriggers(db overageReceiptExecer, ifNotExists bool) error { |
| + for _, stmt := range overageReceiptTriggers { |
| + if ifNotExists { |
| + stmt = strings.Replace(stmt, "CREATE TRIGGER ", "CREATE TRIGGER IF NOT EXISTS ", 1) |
| + } |
| + if _, err := db.Exec(stmt); err != nil { |
| return err |
| } |
| } |
| return nil |
| } |
| +func overageReceiptWR(q interface{ QueryRow(string, ...any) *sql.Row }) (int, error) { |
| + var wr int |
| + err := q.QueryRow(`SELECT wr FROM pragma_table_list WHERE schema='main' AND name='overage_preference_receipts'`).Scan(&wr) |
| + return wr, err |
| +} |
| + |
| +func (s *Store) rebuildOverageReceipts() (err error) { |
| + tx, err := s.db.Begin() |
| + if err != nil { |
| + return err |
| + } |
| + defer func() { _ = tx.Rollback() }() |
| + if _, err := lockMembershipWritesTx(tx); err != nil { |
| + return err |
| + } |
| + wr, err := overageReceiptWR(tx) |
| + if err != nil { |
| + return err |
| + } |
| + if wr == 1 { |
| + return nil |
| + } |
| + if err := validateOverageReceiptShape(tx, false); err != nil { |
| + return err |
| + } |
| + create := strings.Replace(overageReceiptTable, "overage_preference_receipts", overageReceiptReplacement, 1) |
| + if _, err := tx.Exec(create); err != nil { |
| + return err |
| + } |
| + const columns = `operation_id,account_id,actor,request_digest,revision,requested_on,terms_version,price_book,currency,disclosure_digest,offer_digest,changed_at` |
| + if _, err := tx.Exec(`INSERT INTO ` + overageReceiptReplacement + `(` + columns + `) SELECT ` + columns + ` FROM overage_preference_receipts`); err != nil { |
| + return err |
| + } |
| + if _, err := tx.Exec(`DROP TABLE overage_preference_receipts`); err != nil { |
| + return err |
| + } |
| + if _, err := tx.Exec(`ALTER TABLE ` + overageReceiptReplacement + ` RENAME TO overage_preference_receipts`); err != nil { |
| + return err |
| + } |
| + if err := createOverageReceiptTriggers(tx, false); err != nil { |
| + return err |
| + } |
| + return tx.Commit() |
| +} |
| + |
| +type overageReceiptQueryer interface { |
| + Query(string, ...any) (*sql.Rows, error) |
| + QueryRow(string, ...any) *sql.Row |
| +} |
| + |
| +func validateOverageReceiptShape(tx overageReceiptQueryer, withoutRowID bool) error { |
| + var temp int |
| + if err := tx.QueryRow(`SELECT count(*) FROM sqlite_temp_master WHERE name IN ('overage_preference_receipts','` + overageReceiptReplacement + `')`).Scan(&temp); err != nil || temp != 0 { |
| + if err != nil { |
| + return err |
| + } |
| + return errOveragePreferenceCorrupt |
| + } |
| + type column struct { |
| + name, typ string |
| + notnull, pk int |
| + } |
| + pkNotNull := 0 |
| + if withoutRowID { |
| + pkNotNull = 1 |
| + } |
| + want := []column{{"operation_id", "TEXT", pkNotNull, 1}, {"account_id", "INTEGER", 1, 0}, {"actor", "TEXT", 1, 0}, {"request_digest", "TEXT", 1, 0}, {"revision", "INTEGER", 1, 0}, {"requested_on", "INTEGER", 1, 0}, {"terms_version", "TEXT", 1, 0}, {"price_book", "TEXT", 1, 0}, {"currency", "TEXT", 1, 0}, {"disclosure_digest", "TEXT", 1, 0}, {"offer_digest", "TEXT", 1, 0}, {"changed_at", "INTEGER", 1, 0}} |
| + rows, err := tx.Query(`PRAGMA main.table_info('overage_preference_receipts')`) |
| + if err != nil { |
| + return err |
| + } |
| + var got []column |
| + for rows.Next() { |
| + var cid int |
| + var c column |
| + var dflt any |
| + if err := rows.Scan(&cid, &c.name, &c.typ, &c.notnull, &dflt, &c.pk); err != nil { |
| + rows.Close() |
| + return err |
| + } |
| + got = append(got, c) |
| + } |
| + if err := rows.Err(); err != nil { |
| + rows.Close() |
| + return err |
| + } |
| + if err := rows.Close(); err != nil { |
| + return err |
| + } |
| + if !reflect.DeepEqual(got, want) { |
| + return errOveragePreferenceCorrupt |
| + } |
| + objects, err := tx.Query(`SELECT type,name FROM sqlite_master WHERE tbl_name='overage_preference_receipts' AND type IN ('index','trigger')`) |
| + if err != nil { |
| + return err |
| + } |
| + known := map[string]bool{"overage_preference_receipts_no_update": true, "overage_preference_receipts_no_delete": true, "overage_preference_receipts_no_replace": true} |
| + for objects.Next() { |
| + var typ, name string |
| + if err := objects.Scan(&typ, &name); err != nil { |
| + objects.Close() |
| + return err |
| + } |
| + if typ == "index" && strings.HasPrefix(name, "sqlite_autoindex_overage_preference_receipts_") { |
| + continue |
| + } |
| + if typ == "trigger" && known[name] { |
| + continue |
| + } |
| + objects.Close() |
| + return errOveragePreferenceCorrupt |
| + } |
| + if err := objects.Err(); err != nil { |
| + objects.Close() |
| + return err |
| + } |
| + return objects.Close() |
| +} |
| + |
| func validOverageEvidence(e overageEvidence) bool { |
| return overageEvidenceName.MatchString(e.TermsVersion) && |
| overageEvidenceName.MatchString(e.PriceBook) && |
| @@ -276,6 +444,17 @@ func (s *Store) changeOveragePreference(c overagePreferenceChange, now time.Time |
| if err := authorizeOverageOwnerTx(tx, c.AccountID, c.Actor); err != nil { |
| return overagePreferenceReceipt{}, err |
| } |
| + r, err := changeOveragePreferenceTx(tx, c, now) |
| + if err != nil { |
| + return overagePreferenceReceipt{}, err |
| + } |
| + if err := tx.Commit(); err != nil { |
| + return overagePreferenceReceipt{}, err |
| + } |
| + return r, nil |
| +} |
| + |
| +func changeOveragePreferenceTx(tx *sql.Tx, c overagePreferenceChange, now time.Time) (overagePreferenceReceipt, error) { |
| current, err := loadOveragePreferenceTx(tx, c.AccountID) |
| if err != nil { |
| return overagePreferenceReceipt{}, err |
| @@ -319,9 +498,6 @@ func (s *Store) changeOveragePreference(c overagePreferenceChange, now time.Time |
| c.Evidence.DisclosureDigest, c.Evidence.OfferDigest, changed.Unix()); err != nil { |
| return overagePreferenceReceipt{}, err |
| } |
| - if err := tx.Commit(); err != nil { |
| - return overagePreferenceReceipt{}, err |
| - } |
| return overagePreferenceReceipt{OperationID: c.OperationID, AccountID: c.AccountID, Actor: c.Actor, |
| Revision: nextRevision, RequestedOn: c.RequestedOn, Evidence: c.Evidence, ChangedAt: changed}, nil |
| } |
diff --git a/internal/console/signuprecords.go b/internal/console/signuprecords.go| new file mode 100644 |
| index 000000000..2eeabc2d7 |
| --- /dev/null |
| +++ b/internal/console/signuprecords.go |
| @@ -0,0 +1,795 @@ |
| +package console |
| + |
| +import ( |
| + "bytes" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "database/sql" |
| + "encoding/binary" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/maintenanceguard" |
| +) |
| + |
| +// These unmounted records bind caller-supplied scope and offer bytes. They do |
| +// not validate a backend, build an offer, or establish database continuity. |
| +type signupHeader struct { |
| + ID, OperationID, Subject, Name, PreferenceOperationID string |
| + Scope []byte |
| + CreatedAt time.Time |
| +} |
| +type signupOffer struct { |
| + Payload []byte |
| + Evidence overageEvidence |
| + ExpiresAt time.Time |
| +} |
| +type signupCeremony struct { |
| + Subject string |
| + AuthTime, RecordedAt int64 |
| + WindowSeconds int64 |
| +} |
| +type signupSeal struct { |
| + ChoicePresent, RequestedOn bool |
| + OfferDigest string |
| + Ceremony signupCeremony |
| +} |
| +type signupIntent struct { |
| + Header signupHeader |
| + Offer *signupOffer |
| + Seal *signupSeal |
| + Digest, Phase string |
| + Attempt int64 |
| +} |
| +type signupAttempt struct { |
| + IntentID string |
| + Number, AccountID int64 |
| + Sqid, Key, BindingDigest, BodyDigest string |
| + Body []byte |
| + CreatedAt time.Time |
| +} |
| +type signupObservation struct { |
| + AccountID int64 |
| + Sqid, Key, IntentID, BindingDigest string |
| + Attempt int64 |
| + CreatedAt time.Time |
| + Body []byte |
| + ETag string |
| + Retired bool |
| +} |
| +type signupAttemptRecord struct { |
| + Tuple signupAttempt |
| + State string |
| + Observation *signupObservation |
| +} |
| +type signupBirth struct { |
| + IntentID, IntentDigest, BindingDigest, ScopeDigest string |
| + Attempt, AccountID int64 |
| + Sqid, Name, Owner, Outcome string |
| + CreatedAt, CommittedAt time.Time |
| + Preference *overagePreferenceReceipt |
| + PreferenceDigest string |
| +} |
| +type signupBirthResult struct { |
| + Historical signupBirth |
| + Current Account |
| + Preference overagePreference |
| +} |
| + |
| +func signupDigest(domain string, fields ...[]byte) string { |
| + h := sha256.New() |
| + h.Write([]byte("carlos-signup-" + domain + "-v1\x00")) |
| + var n [8]byte |
| + for _, f := range fields { |
| + binary.BigEndian.PutUint64(n[:], uint64(len(f))) |
| + h.Write(n[:]) |
| + h.Write(f) |
| + } |
| + return hex.EncodeToString(h.Sum(nil)) |
| +} |
| +func signupBytes(v any) []byte { b, _ := json.Marshal(v); return b } |
| +func signupDecode(b []byte, v any) error { |
| + if err := json.Unmarshal(b, v); err != nil || !bytes.Equal(b, signupBytes(v)) { |
| + return errOveragePreferenceCorrupt |
| + } |
| + return nil |
| +} |
| +func signupNow(now time.Time) bool { return !now.IsZero() && now.Unix() > 0 } |
| +func signupID() (string, error) { |
| + var b [32]byte |
| + if _, err := rand.Read(b[:]); err != nil { |
| + return "", err |
| + } |
| + return hex.EncodeToString(b[:]), nil |
| +} |
| +func signupIdentity(id int64, sqid string) bool { |
| + return id > domains.ReservedAccountID && sqid == domains.Sqid(id) |
| +} |
| + |
| +func (s *Store) migrateSignupRecords() error { |
| + statements := []string{ |
| + `CREATE TABLE IF NOT EXISTS signup_intents ( |
| + id TEXT PRIMARY KEY, scope BLOB NOT NULL, subject TEXT NOT NULL, operation_id TEXT NOT NULL, |
| + header BLOB NOT NULL, offer BLOB NOT NULL DEFAULT X'', seal BLOB NOT NULL DEFAULT X'', |
| + digest TEXT NOT NULL DEFAULT '', phase TEXT NOT NULL CHECK(phase IN ('draft','prepared','cancelled','completed','retired')), |
| + attempt INTEGER NOT NULL DEFAULT 0 CHECK(attempt>=0), UNIQUE(scope,subject,operation_id)) WITHOUT ROWID`, |
| + `CREATE TABLE IF NOT EXISTS signup_mint_attempts ( |
| + intent_id TEXT NOT NULL, number INTEGER NOT NULL CHECK(number>0), account_id INTEGER NOT NULL, |
| + tuple BLOB NOT NULL, state TEXT NOT NULL CHECK(state IN ('prepared','held','collision','observed_live','observed_retired')), |
| + observation BLOB NOT NULL DEFAULT X'', PRIMARY KEY(intent_id,number)) WITHOUT ROWID`, |
| + `CREATE TABLE IF NOT EXISTS account_birth_receipts ( |
| + intent_id TEXT PRIMARY KEY, scope BLOB NOT NULL, account_id INTEGER NOT NULL, sqid TEXT NOT NULL, |
| + receipt BLOB NOT NULL, digest TEXT NOT NULL, UNIQUE(scope,account_id), UNIQUE(scope,sqid)) WITHOUT ROWID`, |
| + `CREATE TRIGGER IF NOT EXISTS signup_intents_no_replace BEFORE INSERT ON signup_intents |
| + WHEN EXISTS(SELECT 1 FROM signup_intents WHERE id=NEW.id OR (scope=NEW.scope AND subject=NEW.subject AND operation_id=NEW.operation_id)) |
| + BEGIN SELECT RAISE(ABORT,'signup_immutable'); END`, |
| + `CREATE TRIGGER IF NOT EXISTS signup_intents_no_delete BEFORE DELETE ON signup_intents BEGIN SELECT RAISE(ABORT,'signup_immutable'); END`, |
| + `CREATE TRIGGER IF NOT EXISTS signup_intents_update BEFORE UPDATE ON signup_intents |
| + WHEN NEW.id IS NOT OLD.id OR NEW.scope IS NOT OLD.scope OR NEW.subject IS NOT OLD.subject OR NEW.operation_id IS NOT OLD.operation_id OR NEW.header IS NOT OLD.header |
| + OR NOT ( |
| + (NEW.offer IS OLD.offer AND NEW.seal IS OLD.seal AND NEW.digest IS OLD.digest AND NEW.attempt=OLD.attempt AND |
| + (NEW.phase=OLD.phase OR (OLD.phase IN ('draft','prepared') AND NEW.phase='cancelled') OR (OLD.phase='prepared' AND NEW.phase IN ('completed','retired')))) |
| + OR (OLD.phase='draft' AND NEW.phase='draft' AND length(OLD.offer)=0 AND length(NEW.offer)>0 AND NEW.seal IS OLD.seal AND NEW.digest IS OLD.digest AND NEW.attempt=OLD.attempt) |
| + OR (OLD.phase='draft' AND NEW.phase='prepared' AND NEW.offer IS OLD.offer AND length(OLD.seal)=0 AND length(NEW.seal)>0 AND OLD.digest='' AND length(NEW.digest)=64 AND NEW.attempt=OLD.attempt) |
| + OR (OLD.phase='prepared' AND NEW.phase='prepared' AND NEW.offer IS OLD.offer AND NEW.seal IS OLD.seal AND NEW.digest IS OLD.digest AND NEW.attempt=OLD.attempt+1) |
| + ) BEGIN SELECT RAISE(ABORT,'signup_immutable'); END`, |
| + `CREATE TRIGGER IF NOT EXISTS signup_attempts_no_replace BEFORE INSERT ON signup_mint_attempts |
| + WHEN EXISTS(SELECT 1 FROM signup_mint_attempts WHERE intent_id=NEW.intent_id AND number=NEW.number) |
| + BEGIN SELECT RAISE(ABORT,'signup_immutable'); END`, |
| + `CREATE TRIGGER IF NOT EXISTS signup_attempts_no_delete BEFORE DELETE ON signup_mint_attempts BEGIN SELECT RAISE(ABORT,'signup_immutable'); END`, |
| + `CREATE TRIGGER IF NOT EXISTS signup_attempts_update BEFORE UPDATE ON signup_mint_attempts |
| + WHEN NEW.intent_id IS NOT OLD.intent_id OR NEW.number!=OLD.number OR NEW.account_id!=OLD.account_id OR NEW.tuple IS NOT OLD.tuple |
| + OR NOT ((NEW.state=OLD.state AND NEW.observation IS OLD.observation) |
| + OR (OLD.state IN ('prepared','held','observed_live') AND NEW.state IN ('held','observed_live','observed_retired','collision') AND (length(OLD.observation)=0 OR NEW.observation IS OLD.observation OR OLD.state IN ('held','observed_live')))) |
| + BEGIN SELECT RAISE(ABORT,'signup_immutable'); END`, |
| + `CREATE TRIGGER IF NOT EXISTS signup_birth_no_replace BEFORE INSERT ON account_birth_receipts |
| + WHEN EXISTS(SELECT 1 FROM account_birth_receipts WHERE intent_id=NEW.intent_id OR (scope=NEW.scope AND (account_id=NEW.account_id OR sqid=NEW.sqid))) |
| + BEGIN SELECT RAISE(ABORT,'signup_immutable'); END`, |
| + `CREATE TRIGGER IF NOT EXISTS signup_birth_no_update BEFORE UPDATE ON account_birth_receipts BEGIN SELECT RAISE(ABORT,'signup_immutable'); END`, |
| + `CREATE TRIGGER IF NOT EXISTS signup_birth_no_delete BEFORE DELETE ON account_birth_receipts BEGIN SELECT RAISE(ABORT,'signup_immutable'); END`, |
| + } |
| + for _, q := range statements { |
| + if _, err := s.db.Exec(q); err != nil { |
| + return err |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +func signupSessionTx(tx *sql.Tx, raw, subject string, now time.Time) error { |
| + if raw == "" || !signupNow(now) || subject != domains.NormalizeAddress(subject) || !domains.ValidAddress(subject) { |
| + return errOveragePreferenceDenied |
| + } |
| + var address string |
| + var expires int64 |
| + err := tx.QueryRow(`SELECT address,expires_at FROM sessions WHERE token_hash=?`, hashToken(raw)).Scan(&address, &expires) |
| + if errors.Is(err, sql.ErrNoRows) { |
| + return errOveragePreferenceDenied |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + if domains.NormalizeAddress(address) != subject || now.Unix() > expires { |
| + return errOveragePreferenceDenied |
| + } |
| + return nil |
| +} |
| +func signupFreshTx(tx *sql.Tx, raw, subject string, now time.Time) (signupCeremony, error) { |
| + var c signupCeremony |
| + err := tx.QueryRow(`SELECT address,auth_time,recorded_at FROM step_ups WHERE session_hash=?`, hashToken(raw)).Scan(&c.Subject, &c.AuthTime, &c.RecordedAt) |
| + if errors.Is(err, sql.ErrNoRows) { |
| + return c, errOveragePreferenceDenied |
| + } |
| + if err != nil { |
| + return c, err |
| + } |
| + age := now.Sub(time.Unix(c.AuthTime, 0)) |
| + if domains.NormalizeAddress(c.Subject) != subject || c.AuthTime <= 0 || c.RecordedAt <= 0 || c.RecordedAt > now.Unix() || age < -stepUpSkew || age > StepUpWindow { |
| + return c, errOveragePreferenceDenied |
| + } |
| + c.Subject = subject |
| + c.WindowSeconds = int64(StepUpWindow / time.Second) |
| + return c, nil |
| +} |
| +func (s *Store) signupTx() (*sql.Tx, error) { |
| + tx, err := s.db.Begin() |
| + if err != nil { |
| + return nil, err |
| + } |
| + if _, err = lockMembershipWritesTx(tx); err != nil { |
| + tx.Rollback() |
| + return nil, err |
| + } |
| + return tx, nil |
| +} |
| +func loadSignupTx(tx *sql.Tx, id string) (signupIntent, error) { |
| + var i signupIntent |
| + var h, o, z, scope []byte |
| + var subject, op string |
| + err := tx.QueryRow(`SELECT header,offer,seal,digest,phase,attempt,scope,subject,operation_id FROM signup_intents WHERE id=?`, id).Scan(&h, &o, &z, &i.Digest, &i.Phase, &i.Attempt, &scope, &subject, &op) |
| + if err != nil { |
| + return i, err |
| + } |
| + if err = signupDecode(h, &i.Header); err != nil { |
| + return i, err |
| + } |
| + if i.Header.ID != id || !bytes.Equal(i.Header.Scope, scope) || i.Header.Subject != subject || i.Header.OperationID != op || !overageDigest.MatchString(id) || !signupNow(i.Header.CreatedAt) || !domains.ValidAccountName(i.Header.Name) || !maintenanceguard.ValidID(i.Header.PreferenceOperationID) { |
| + return i, errOveragePreferenceCorrupt |
| + } |
| + if len(o) > 0 { |
| + i.Offer = &signupOffer{} |
| + if err = signupDecode(o, i.Offer); err != nil { |
| + return i, err |
| + } |
| + if len(i.Offer.Payload) == 0 || !validOverageEvidence(i.Offer.Evidence) || !signupNow(i.Offer.ExpiresAt) { |
| + return i, errOveragePreferenceCorrupt |
| + } |
| + } |
| + if len(z) > 0 { |
| + i.Seal = &signupSeal{} |
| + if err = signupDecode(z, i.Seal); err != nil { |
| + return i, err |
| + } |
| + c := i.Seal.Ceremony |
| + if c.Subject != subject || c.AuthTime <= 0 || c.RecordedAt <= 0 || c.WindowSeconds != int64(StepUpWindow/time.Second) || (i.Seal.RequestedOn && (!i.Seal.ChoicePresent || i.Offer == nil)) || i.Seal.OfferDigest != signupDigest("offer", o) || i.Digest != signupDigest("intent", h, o, z) { |
| + return i, errOveragePreferenceCorrupt |
| + } |
| + } else if i.Digest != "" || (i.Phase != "draft" && i.Phase != "cancelled") || i.Attempt != 0 { |
| + return i, errOveragePreferenceCorrupt |
| + } |
| + if i.Phase == "draft" && i.Seal != nil { |
| + return i, errOveragePreferenceCorrupt |
| + } |
| + return i, nil |
| +} |
| +func authorizedSignupTx(tx *sql.Tx, raw, id string, scope []byte, now time.Time) (signupIntent, error) { |
| + i, err := loadSignupTx(tx, id) |
| + if errors.Is(err, sql.ErrNoRows) { |
| + return i, errOveragePreferenceDenied |
| + } |
| + if err != nil { |
| + return i, err |
| + } |
| + if !bytes.Equal(scope, i.Header.Scope) { |
| + return signupIntent{}, errOveragePreferenceDenied |
| + } |
| + if err = signupSessionTx(tx, raw, i.Header.Subject, now); err != nil { |
| + return signupIntent{}, err |
| + } |
| + if i.Phase == "completed" { |
| + var accountID int64 |
| + if err = tx.QueryRow(`SELECT account_id FROM account_birth_receipts WHERE intent_id=?`, id).Scan(&accountID); err != nil { |
| + return signupIntent{}, errOveragePreferenceCorrupt |
| + } |
| + if err = authorizeOverageOwnerTx(tx, accountID, i.Header.Subject); err != nil { |
| + return signupIntent{}, err |
| + } |
| + } |
| + return i, nil |
| +} |
| +func (s *Store) createSignupDraft(raw, operation, name string, scope []byte, now time.Time) (signupIntent, error) { |
| + if !maintenanceguard.ValidID(operation) || !domains.ValidAccountName(name) || len(scope) == 0 || len(scope) > 65536 || !signupNow(now) { |
| + return signupIntent{}, os.ErrInvalid |
| + } |
| + tx, err := s.signupTx() |
| + if err != nil { |
| + return signupIntent{}, err |
| + } |
| + defer tx.Rollback() |
| + var subject string |
| + if err = tx.QueryRow(`SELECT address FROM sessions WHERE token_hash=?`, hashToken(raw)).Scan(&subject); err != nil { |
| + return signupIntent{}, errOveragePreferenceDenied |
| + } |
| + subject = domains.NormalizeAddress(subject) |
| + if err = signupSessionTx(tx, raw, subject, now); err != nil { |
| + return signupIntent{}, err |
| + } |
| + var id string |
| + err = tx.QueryRow(`SELECT id FROM signup_intents WHERE scope=? AND subject=? AND operation_id=?`, scope, subject, operation).Scan(&id) |
| + if err == nil { |
| + i, e := authorizedSignupTx(tx, raw, id, scope, now) |
| + if e != nil { |
| + return signupIntent{}, e |
| + } |
| + if i.Header.Name != name { |
| + return signupIntent{}, errOveragePreferenceConflict |
| + } |
| + return i, nil |
| + } |
| + if !errors.Is(err, sql.ErrNoRows) { |
| + return signupIntent{}, err |
| + } |
| + id, err = signupID() |
| + if err != nil { |
| + return signupIntent{}, err |
| + } |
| + pref, err := randomToken() |
| + if err != nil { |
| + return signupIntent{}, err |
| + } |
| + i := signupIntent{Header: signupHeader{ID: id, OperationID: operation, Subject: subject, Name: name, Scope: bytes.Clone(scope), CreatedAt: now.UTC(), PreferenceOperationID: pref}, Phase: "draft"} |
| + _, err = tx.Exec(`INSERT INTO signup_intents(id,scope,subject,operation_id,header,phase) VALUES(?,?,?,?,?,'draft')`, id, scope, subject, operation, signupBytes(i.Header)) |
| + if err != nil { |
| + return signupIntent{}, err |
| + } |
| + return i, tx.Commit() |
| +} |
| +func (s *Store) freezeSignupOffer(raw, id string, scope []byte, o signupOffer, now time.Time) (signupIntent, error) { |
| + if len(o.Payload) == 0 || len(o.Payload) > 1<<20 || !validOverageEvidence(o.Evidence) || !signupNow(o.ExpiresAt) { |
| + return signupIntent{}, os.ErrInvalid |
| + } |
| + o.ExpiresAt = o.ExpiresAt.UTC() |
| + tx, err := s.signupTx() |
| + if err != nil { |
| + return signupIntent{}, err |
| + } |
| + defer tx.Rollback() |
| + i, err := authorizedSignupTx(tx, raw, id, scope, now) |
| + if err != nil { |
| + return signupIntent{}, err |
| + } |
| + if i.Offer != nil { |
| + if !bytes.Equal(signupBytes(i.Offer), signupBytes(o)) { |
| + return signupIntent{}, errOveragePreferenceConflict |
| + } |
| + return i, nil |
| + } |
| + if i.Phase != "draft" { |
| + return signupIntent{}, errOveragePreferenceConflict |
| + } |
| + if _, err = tx.Exec(`UPDATE signup_intents SET offer=? WHERE id=? AND phase='draft' AND length(offer)=0`, signupBytes(o), id); err != nil { |
| + return signupIntent{}, err |
| + } |
| + i.Offer = &o |
| + return i, tx.Commit() |
| +} |
| +func (s *Store) sealSignup(raw, id string, scope []byte, present, on bool, offerDigest string, now time.Time) (signupIntent, error) { |
| + if on && !present { |
| + return signupIntent{}, os.ErrInvalid |
| + } |
| + tx, err := s.signupTx() |
| + if err != nil { |
| + return signupIntent{}, err |
| + } |
| + defer tx.Rollback() |
| + i, err := authorizedSignupTx(tx, raw, id, scope, now) |
| + if err != nil { |
| + return signupIntent{}, err |
| + } |
| + if i.Seal != nil { |
| + if i.Seal.ChoicePresent != present || i.Seal.RequestedOn != on || i.Seal.OfferDigest != offerDigest { |
| + return signupIntent{}, errOveragePreferenceConflict |
| + } |
| + return i, nil |
| + } |
| + var o []byte |
| + if i.Offer != nil { |
| + o = signupBytes(i.Offer) |
| + } |
| + if i.Phase != "draft" || offerDigest != signupDigest("offer", o) { |
| + return signupIntent{}, errOveragePreferenceConflict |
| + } |
| + if on && (i.Offer == nil || !now.Before(i.Offer.ExpiresAt)) { |
| + return signupIntent{}, os.ErrInvalid |
| + } |
| + c, err := signupFreshTx(tx, raw, i.Header.Subject, now) |
| + if err != nil { |
| + return signupIntent{}, err |
| + } |
| + i.Seal = &signupSeal{ChoicePresent: present, RequestedOn: on, OfferDigest: offerDigest, Ceremony: c} |
| + i.Digest = signupDigest("intent", signupBytes(i.Header), o, signupBytes(i.Seal)) |
| + i.Phase = "prepared" |
| + _, err = tx.Exec(`UPDATE signup_intents SET seal=?,digest=?,phase='prepared' WHERE id=? AND phase='draft'`, signupBytes(i.Seal), i.Digest, id) |
| + if err != nil { |
| + return signupIntent{}, err |
| + } |
| + return i, tx.Commit() |
| +} |
| +func (s *Store) cancelSignup(raw, id string, scope []byte, now time.Time) error { |
| + tx, err := s.signupTx() |
| + if err != nil { |
| + return err |
| + } |
| + defer tx.Rollback() |
| + i, err := authorizedSignupTx(tx, raw, id, scope, now) |
| + if err != nil { |
| + return err |
| + } |
| + if i.Phase == "cancelled" { |
| + return nil |
| + } |
| + if i.Phase != "draft" && i.Phase != "prepared" { |
| + return errOveragePreferenceConflict |
| + } |
| + _, err = tx.Exec(`UPDATE signup_intents SET phase='cancelled' WHERE id=? AND phase IN ('draft','prepared')`, id) |
| + if err != nil { |
| + return err |
| + } |
| + return tx.Commit() |
| +} |
| + |
| +func signupAttemptBinding(i signupIntent, a signupAttempt) string { |
| + return signupDigest("binding", []byte(i.Digest), signupBytes(struct { |
| + Number, AccountID int64 |
| + Sqid, Key string |
| + CreatedAt time.Time |
| + }{a.Number, a.AccountID, a.Sqid, a.Key, a.CreatedAt})) |
| +} |
| + |
| +const signupMaxAttempts = 5 |
| + |
| +func loadSignupCurrentAttemptTx(tx *sql.Tx, i signupIntent) (signupAttemptRecord, error) { |
| + if i.Attempt < 1 || i.Attempt > signupMaxAttempts { |
| + return signupAttemptRecord{}, errOveragePreferenceCorrupt |
| + } |
| + for number := int64(1); number < i.Attempt; number++ { |
| + previous, err := loadSignupAttemptTx(tx, i, number) |
| + if errors.Is(err, sql.ErrNoRows) { |
| + return signupAttemptRecord{}, errOveragePreferenceCorrupt |
| + } |
| + if err != nil { |
| + return signupAttemptRecord{}, err |
| + } |
| + if previous.State != "collision" { |
| + return signupAttemptRecord{}, errOveragePreferenceCorrupt |
| + } |
| + } |
| + current, err := loadSignupAttemptTx(tx, i, i.Attempt) |
| + if errors.Is(err, sql.ErrNoRows) { |
| + return signupAttemptRecord{}, errOveragePreferenceCorrupt |
| + } |
| + return current, err |
| +} |
| + |
| +func loadSignupAttemptTx(tx *sql.Tx, i signupIntent, number int64) (signupAttemptRecord, error) { |
| + var r signupAttemptRecord |
| + var b, o []byte |
| + var account int64 |
| + err := tx.QueryRow(`SELECT tuple,state,observation,account_id FROM signup_mint_attempts WHERE intent_id=? AND number=?`, i.Header.ID, number).Scan(&b, &r.State, &o, &account) |
| + if err != nil { |
| + return r, err |
| + } |
| + if err = signupDecode(b, &r.Tuple); err != nil { |
| + return r, err |
| + } |
| + a := r.Tuple |
| + if a.IntentID != i.Header.ID || a.Number != number || a.AccountID != account || !signupIdentity(a.AccountID, a.Sqid) || a.Key != domains.AccountManifestKey(a.Sqid) || !signupNow(a.CreatedAt) || len(a.Body) == 0 || a.BodyDigest != signupDigest("body", a.Body) || a.BindingDigest != signupAttemptBinding(i, a) { |
| + return r, errOveragePreferenceCorrupt |
| + } |
| + if len(o) > 0 { |
| + r.Observation = &signupObservation{} |
| + if err = signupDecode(o, r.Observation); err != nil { |
| + return r, err |
| + } |
| + if r.State != "held" && classifySignupObservation(a, *r.Observation) != r.State { |
| + return r, errOveragePreferenceCorrupt |
| + } |
| + } |
| + if (r.State == "collision" || r.State == "observed_live" || r.State == "observed_retired") && r.Observation == nil { |
| + return r, errOveragePreferenceCorrupt |
| + } |
| + return r, nil |
| +} |
| +func signupCandidateVacantTx(tx *sql.Tx, a signupAttempt) error { |
| + var n int |
| + err := tx.QueryRow(`SELECT (SELECT count(*) FROM accounts WHERE id=? OR sqid=?) + (SELECT count(*) FROM memberships WHERE account_id=?) + (SELECT count(*) FROM billing_retirements WHERE account_id=?)`, a.AccountID, a.Sqid, a.AccountID, a.AccountID).Scan(&n) |
| + if err != nil { |
| + return err |
| + } |
| + if n != 0 { |
| + return errOveragePreferenceConflict |
| + } |
| + return nil |
| +} |
| +func (s *Store) recordSignupAttempt(raw, id string, scope []byte, a signupAttempt, now time.Time) (signupAttemptRecord, error) { |
| + a.CreatedAt = a.CreatedAt.UTC() |
| + if a.BodyDigest == "" { |
| + a.BodyDigest = signupDigest("body", a.Body) |
| + } |
| + if a.BodyDigest != signupDigest("body", a.Body) { |
| + return signupAttemptRecord{}, os.ErrInvalid |
| + } |
| + tx, err := s.signupTx() |
| + if err != nil { |
| + return signupAttemptRecord{}, err |
| + } |
| + defer tx.Rollback() |
| + i, err := authorizedSignupTx(tx, raw, id, scope, now) |
| + if err != nil { |
| + return signupAttemptRecord{}, err |
| + } |
| + if i.Phase != "prepared" || a.IntentID != id || a.Number <= 0 || a.Number > signupMaxAttempts || !signupIdentity(a.AccountID, a.Sqid) || a.Key != domains.AccountManifestKey(a.Sqid) || !signupNow(a.CreatedAt) || len(a.Body) == 0 || len(a.Body) > 1<<20 || a.BindingDigest != signupAttemptBinding(i, a) { |
| + return signupAttemptRecord{}, os.ErrInvalid |
| + } |
| + if a.Number <= i.Attempt { |
| + r, e := loadSignupAttemptTx(tx, i, a.Number) |
| + if e != nil { |
| + return r, e |
| + } |
| + if !bytes.Equal(signupBytes(r.Tuple), signupBytes(a)) { |
| + return signupAttemptRecord{}, errOveragePreferenceConflict |
| + } |
| + return r, nil |
| + } |
| + if a.Number != i.Attempt+1 { |
| + return signupAttemptRecord{}, errOveragePreferenceConflict |
| + } |
| + if i.Attempt > 0 { |
| + previous, e := loadSignupAttemptTx(tx, i, i.Attempt) |
| + if e != nil { |
| + return signupAttemptRecord{}, e |
| + } |
| + if previous.State != "collision" { |
| + return signupAttemptRecord{}, errOveragePreferenceConflict |
| + } |
| + } |
| + if err = signupCandidateVacantTx(tx, a); err != nil { |
| + return signupAttemptRecord{}, err |
| + } |
| + _, err = tx.Exec(`INSERT INTO signup_mint_attempts(intent_id,number,account_id,tuple,state) VALUES(?,?,?,?,'prepared')`, id, a.Number, a.AccountID, signupBytes(a)) |
| + if err != nil { |
| + return signupAttemptRecord{}, err |
| + } |
| + _, err = tx.Exec(`UPDATE signup_intents SET attempt=? WHERE id=? AND phase='prepared' AND attempt=?`, a.Number, id, i.Attempt) |
| + if err != nil { |
| + return signupAttemptRecord{}, err |
| + } |
| + return signupAttemptRecord{Tuple: a, State: "prepared"}, tx.Commit() |
| +} |
| + |
| +// Observation fields are supplied by a later, independently validated reader. |
| +// No observation can create a membership or advance the recorded candidate. |
| +func classifySignupObservation(a signupAttempt, o signupObservation) string { |
| + if o.AccountID != a.AccountID || o.Sqid != a.Sqid || o.Key != a.Key || !signupNow(o.CreatedAt) || len(o.Body) == 0 { |
| + return "held" |
| + } |
| + if o.IntentID == a.IntentID { |
| + if o.Attempt != a.Number || o.BindingDigest != a.BindingDigest || !o.CreatedAt.Equal(a.CreatedAt) { |
| + return "held" |
| + } |
| + if o.Retired { |
| + return "observed_retired" |
| + } |
| + if !bytes.Equal(o.Body, a.Body) { |
| + return "held" |
| + } |
| + return "observed_live" |
| + } |
| + if o.IntentID != "" { |
| + if !overageDigest.MatchString(o.IntentID) || o.Attempt <= 0 || !overageDigest.MatchString(o.BindingDigest) { |
| + return "held" |
| + } |
| + return "collision" |
| + } |
| + if o.Attempt != 0 || o.BindingDigest != "" { |
| + return "held" |
| + } |
| + if !o.CreatedAt.Equal(a.CreatedAt) { |
| + return "collision" |
| + } |
| + return "held" |
| +} |
| +func signupObservationClaimsCandidate(a signupAttempt, o signupObservation) bool { |
| + return o.IntentID == a.IntentID || |
| + (o.IntentID == "" && o.Attempt == 0 && o.BindingDigest == "" && o.AccountID == a.AccountID && o.Sqid == a.Sqid && o.Key == a.Key && o.CreatedAt.Equal(a.CreatedAt)) |
| +} |
| + |
| +func (s *Store) observeSignupAttempt(id string, scope []byte, number int64, o signupObservation) (signupAttemptRecord, error) { |
| + o.CreatedAt = o.CreatedAt.UTC() |
| + if len(o.Body) > 1<<20 || len(o.ETag) > 4096 { |
| + return signupAttemptRecord{}, os.ErrInvalid |
| + } |
| + tx, err := s.signupTx() |
| + if err != nil { |
| + return signupAttemptRecord{}, err |
| + } |
| + defer tx.Rollback() |
| + i, err := loadSignupTx(tx, id) |
| + if err != nil { |
| + return signupAttemptRecord{}, err |
| + } |
| + if !bytes.Equal(scope, i.Header.Scope) { |
| + return signupAttemptRecord{}, errOveragePreferenceDenied |
| + } |
| + r, err := loadSignupAttemptTx(tx, i, number) |
| + if err != nil { |
| + return r, err |
| + } |
| + state := classifySignupObservation(r.Tuple, o) |
| + // Own or ambiguous-own evidence survives a later identity rewrite. |
| + // Keep that observation while held so later retries cannot forget it. |
| + if r.Observation != nil && signupObservationClaimsCandidate(r.Tuple, *r.Observation) && state != "observed_live" && state != "observed_retired" { |
| + state = "held" |
| + o = *r.Observation |
| + } |
| + |
| + if r.State == "collision" { |
| + old := r.Observation |
| + if state != "collision" || old == nil || old.AccountID != o.AccountID || old.Sqid != o.Sqid || old.Key != o.Key || old.IntentID != o.IntentID || old.Attempt != o.Attempt || old.BindingDigest != o.BindingDigest || !old.CreatedAt.Equal(o.CreatedAt) { |
| + return signupAttemptRecord{}, errOveragePreferenceConflict |
| + } |
| + return r, nil |
| + } |
| + if r.State == "observed_retired" || i.Phase == "completed" || i.Phase == "retired" { |
| + if r.State != state || !bytes.Equal(signupBytes(r.Observation), signupBytes(o)) { |
| + return signupAttemptRecord{}, errOveragePreferenceConflict |
| + } |
| + return r, nil |
| + } |
| + _, err = tx.Exec(`UPDATE signup_mint_attempts SET state=?,observation=? WHERE intent_id=? AND number=?`, state, signupBytes(o), id, number) |
| + if err != nil { |
| + return signupAttemptRecord{}, err |
| + } |
| + if state == "observed_retired" && i.Phase == "prepared" { |
| + _, err = tx.Exec(`UPDATE signup_intents SET phase='retired' WHERE id=? AND phase='prepared'`, id) |
| + if err != nil { |
| + return signupAttemptRecord{}, err |
| + } |
| + } |
| + r.State = state |
| + r.Observation = &o |
| + return r, tx.Commit() |
| +} |
| + |
| +func loadSignupBirthTx(tx *sql.Tx, i signupIntent) (signupBirthResult, error) { |
| + var out signupBirthResult |
| + var b, scope []byte |
| + var digest, sqid string |
| + var account int64 |
| + err := tx.QueryRow(`SELECT receipt,digest,scope,account_id,sqid FROM account_birth_receipts WHERE intent_id=?`, i.Header.ID).Scan(&b, &digest, &scope, &account, &sqid) |
| + if errors.Is(err, sql.ErrNoRows) { |
| + return out, errOveragePreferenceCorrupt |
| + } |
| + if err != nil { |
| + return out, err |
| + } |
| + if err = signupDecode(b, &out.Historical); err != nil { |
| + return out, err |
| + } |
| + h := out.Historical |
| + if digest != signupDigest("receipt", b) || h.IntentID != i.Header.ID || h.IntentDigest != i.Digest || !bytes.Equal(scope, i.Header.Scope) || h.ScopeDigest != signupDigest("scope", scope) || h.AccountID != account || h.Sqid != sqid || h.Name != i.Header.Name || h.Owner != i.Header.Subject || h.Attempt != i.Attempt || !signupNow(h.CommittedAt) { |
| + return out, errOveragePreferenceCorrupt |
| + } |
| + a, err := loadSignupCurrentAttemptTx(tx, i) |
| + if err != nil { |
| + return out, err |
| + } |
| + if a.State != "observed_live" || h.BindingDigest != a.Tuple.BindingDigest || account != a.Tuple.AccountID || !h.CreatedAt.Equal(a.Tuple.CreatedAt) { |
| + return out, errOveragePreferenceCorrupt |
| + } |
| + if err = authorizeOverageOwnerTx(tx, account, i.Header.Subject); err != nil { |
| + return signupBirthResult{}, err |
| + } |
| + var currentCreated int64 |
| + err = tx.QueryRow(`SELECT id,sqid,name,created_at FROM accounts WHERE id=? AND deleted_at=0`, account).Scan(&out.Current.ID, &out.Current.Sqid, &out.Current.Name, ¤tCreated) |
| + if err != nil { |
| + return signupBirthResult{}, err |
| + } |
| + out.Current.CreatedAt = time.Unix(currentCreated, 0).UTC() |
| + out.Current.Role = RoleOwner |
| + if out.Current.Sqid != h.Sqid { |
| + return signupBirthResult{}, errOveragePreferenceCorrupt |
| + } |
| + out.Preference, err = loadOveragePreferenceTx(tx, account) |
| + if err != nil { |
| + return signupBirthResult{}, err |
| + } |
| + if h.Outcome == "on" { |
| + if i.Seal == nil || !i.Seal.RequestedOn || h.Preference == nil || h.PreferenceDigest == "" { |
| + return signupBirthResult{}, errOveragePreferenceCorrupt |
| + } |
| + p, d, e := scanOverageReceipt(tx.QueryRow(`SELECT operation_id,account_id,actor,request_digest,revision,requested_on,terms_version,price_book,currency,disclosure_digest,offer_digest,changed_at FROM overage_preference_receipts WHERE operation_id=?`, i.Header.PreferenceOperationID)) |
| + if e != nil { |
| + return signupBirthResult{}, errOveragePreferenceCorrupt |
| + } |
| + if d != h.PreferenceDigest || !bytes.Equal(signupBytes(p), signupBytes(h.Preference)) || p.AccountID != account || p.Actor != i.Header.Subject || p.Revision != 1 || !p.RequestedOn || p.Evidence != i.Offer.Evidence { |
| + return signupBirthResult{}, errOveragePreferenceCorrupt |
| + } |
| + } else { |
| + if h.Preference != nil || h.PreferenceDigest != "" || (h.Outcome != "off" && h.Outcome != "choice_not_applied") || (h.Outcome == "off" && i.Seal.RequestedOn) || (h.Outcome == "choice_not_applied" && !i.Seal.RequestedOn) { |
| + return signupBirthResult{}, errOveragePreferenceCorrupt |
| + } |
| + } |
| + return out, nil |
| +} |
| + |
| +// applyChoice is a caller decision after offer/capability validation. It is not |
| +// derived from stored bytes. Expiry additionally prevents applying an old ON. |
| +func (s *Store) completeSignup(raw, id string, scope []byte, intentDigest string, applyChoice bool, now time.Time) (signupBirthResult, error) { |
| + tx, err := s.signupTx() |
| + if err != nil { |
| + return signupBirthResult{}, err |
| + } |
| + defer tx.Rollback() |
| + i, err := authorizedSignupTx(tx, raw, id, scope, now) |
| + if err != nil { |
| + return signupBirthResult{}, err |
| + } |
| + if i.Digest != intentDigest { |
| + return signupBirthResult{}, errOveragePreferenceConflict |
| + } |
| + if i.Phase == "completed" { |
| + return loadSignupBirthTx(tx, i) |
| + } |
| + if i.Phase != "prepared" || i.Attempt == 0 { |
| + return signupBirthResult{}, errOveragePreferenceConflict |
| + } |
| + a, err := loadSignupCurrentAttemptTx(tx, i) |
| + if err != nil { |
| + return signupBirthResult{}, err |
| + } |
| + if a.State != "observed_live" { |
| + return signupBirthResult{}, errOveragePreferenceConflict |
| + } |
| + if err = signupCandidateVacantTx(tx, a.Tuple); err != nil { |
| + return signupBirthResult{}, err |
| + } |
| + current, err := loadOveragePreferenceTx(tx, a.Tuple.AccountID) |
| + if err != nil { |
| + return signupBirthResult{}, err |
| + } |
| + if current.Revision != 0 { |
| + return signupBirthResult{}, errOveragePreferenceConflict |
| + } |
| + if applyChoice && !i.Seal.RequestedOn { |
| + return signupBirthResult{}, os.ErrInvalid |
| + } |
| + if err = createAccountTx(tx, a.Tuple.AccountID, a.Tuple.Sqid, i.Header.Name, i.Header.Subject, a.Tuple.CreatedAt); err != nil { |
| + return signupBirthResult{}, err |
| + } |
| + h := signupBirth{IntentID: id, IntentDigest: i.Digest, BindingDigest: a.Tuple.BindingDigest, ScopeDigest: signupDigest("scope", scope), Attempt: a.Tuple.Number, AccountID: a.Tuple.AccountID, Sqid: a.Tuple.Sqid, Name: i.Header.Name, Owner: i.Header.Subject, Outcome: "off", CreatedAt: a.Tuple.CreatedAt, CommittedAt: now.UTC()} |
| + if i.Seal.RequestedOn { |
| + h.Outcome = "choice_not_applied" |
| + if applyChoice && now.Before(i.Offer.ExpiresAt) { |
| + if _, err = signupFreshTx(tx, raw, i.Header.Subject, now); err != nil { |
| + return signupBirthResult{}, err |
| + } |
| + c := overagePreferenceChange{AccountID: h.AccountID, Actor: h.Owner, OperationID: i.Header.PreferenceOperationID, RequestedOn: true, Evidence: i.Offer.Evidence} |
| + if err = validateOverageChange(c); err != nil { |
| + return signupBirthResult{}, err |
| + } |
| + if err = authorizeOverageOwnerTx(tx, h.AccountID, h.Owner); err != nil { |
| + return signupBirthResult{}, err |
| + } |
| + // A birth requires a new operation, never an existing receipt replay. |
| + var n int |
| + if err = tx.QueryRow(`SELECT count(*) FROM overage_preference_receipts WHERE operation_id=?`, c.OperationID).Scan(&n); err != nil { |
| + return signupBirthResult{}, err |
| + } |
| + if n != 0 { |
| + return signupBirthResult{}, errOveragePreferenceConflict |
| + } |
| + p, e := changeOveragePreferenceTx(tx, c, now) |
| + if e != nil { |
| + return signupBirthResult{}, e |
| + } |
| + h.Preference = &p |
| + h.PreferenceDigest = overageChangeDigest(c) |
| + h.Outcome = "on" |
| + } |
| + } |
| + b := signupBytes(h) |
| + _, err = tx.Exec(`INSERT INTO account_birth_receipts(intent_id,scope,account_id,sqid,receipt,digest) VALUES(?,?,?,?,?,?)`, id, scope, h.AccountID, h.Sqid, b, signupDigest("receipt", b)) |
| + if err != nil { |
| + return signupBirthResult{}, err |
| + } |
| + _, err = tx.Exec(`UPDATE signup_intents SET phase='completed' WHERE id=? AND phase='prepared'`, id) |
| + if err != nil { |
| + return signupBirthResult{}, err |
| + } |
| + i.Phase = "completed" |
| + out, err := loadSignupBirthTx(tx, i) |
| + if err != nil { |
| + return signupBirthResult{}, err |
| + } |
| + if err = tx.Commit(); err != nil { |
| + return signupBirthResult{}, err |
| + } |
| + // Last-account navigation is not part of birth authority or its receipt. |
| + _ = s.SetLastAccount(i.Header.Subject, h.AccountID) |
| + return out, nil |
| +} |
diff --git a/internal/console/signuprecords_test.go b/internal/console/signuprecords_test.go| new file mode 100644 |
| index 000000000..9664454bc |
| --- /dev/null |
| +++ b/internal/console/signuprecords_test.go |
| @@ -0,0 +1,1050 @@ |
| +package console |
| + |
| +import ( |
| + "bytes" |
| + "errors" |
| + "fmt" |
| + "path/filepath" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/domains" |
| +) |
| + |
| +type signupFixture struct { |
| + s *Store |
| + raw string |
| + now time.Time |
| + scope []byte |
| + i signupIntent |
| + a signupAttempt |
| +} |
| + |
| +func newSignupFixture(t *testing.T, on bool) signupFixture { |
| + t.Helper() |
| + s := newOverageStore(t, filepath.Join(t.TempDir(), "db")) |
| + now := time.Date(2026, 10, 4, 5, 0, 0, 123, time.UTC) |
| + raw, e := s.CreateSession(overageOwner, "", now) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = s.RecordStepUp(raw, overageOwner, now.Add(-time.Second), now); e != nil { |
| + t.Fatal(e) |
| + } |
| + f := signupFixture{s: s, raw: raw, now: now, scope: []byte("opaque-reviewed-scope")} |
| + f.i, e = s.createSignupDraft(raw, overageOperation(1), "original", f.scope, now) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if on { |
| + f.i, e = s.freezeSignupOffer(raw, f.i.Header.ID, f.scope, signupOffer{Payload: []byte("bound caller offer:" + f.i.Header.ID), Evidence: overageEvidenceForTest(), ExpiresAt: now.Add(time.Hour)}, now) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + return f |
| +} |
| +func (f *signupFixture) seal(t *testing.T, on bool) { |
| + t.Helper() |
| + var b []byte |
| + if f.i.Offer != nil { |
| + b = signupBytes(f.i.Offer) |
| + } |
| + var e error |
| + f.i, e = f.s.sealSignup(f.raw, f.i.Header.ID, f.scope, true, on, signupDigest("offer", b), f.now) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| +} |
| +func (f *signupFixture) candidate(t *testing.T) { |
| + t.Helper() |
| + f.a = signupAttempt{IntentID: f.i.Header.ID, Number: 1, AccountID: 42, Sqid: domains.Sqid(42), CreatedAt: f.now, Body: []byte("exact frozen body")} |
| + f.a.Key = domains.AccountManifestKey(f.a.Sqid) |
| + f.a.BindingDigest = signupAttemptBinding(f.i, f.a) |
| + if _, e := f.s.recordSignupAttempt(f.raw, f.i.Header.ID, f.scope, f.a, f.now); e != nil { |
| + t.Fatal(e) |
| + } |
| +} |
| +func (f *signupFixture) live(t *testing.T) { |
| + t.Helper() |
| + o := signupObservation{AccountID: f.a.AccountID, Sqid: f.a.Sqid, Key: f.a.Key, IntentID: f.i.Header.ID, BindingDigest: f.a.BindingDigest, Attempt: f.a.Number, CreatedAt: f.a.CreatedAt, Body: f.a.Body, ETag: "etag"} |
| + if _, e := f.s.observeSignupAttempt(f.i.Header.ID, f.scope, f.a.Number, o); e != nil { |
| + t.Fatal(e) |
| + } |
| +} |
| +func (f *signupFixture) complete(on bool) (signupBirthResult, error) { |
| + return f.s.completeSignup(f.raw, f.i.Header.ID, f.scope, f.i.Digest, on, f.now) |
| +} |
| +func signupCount(t *testing.T, s *Store, q string, args ...any) int { |
| + t.Helper() |
| + var n int |
| + if e := s.db.QueryRow(q, args...).Scan(&n); e != nil { |
| + t.Fatal(e) |
| + } |
| + return n |
| +} |
| + |
| +func TestSignupDraftSealReplayAndSnapshot(t *testing.T) { |
| + f := newSignupFixture(t, true) |
| + original := f.i |
| + retry, e := f.s.createSignupDraft(f.raw, overageOperation(1), "original", f.scope, f.now.Add(time.Second)) |
| + if e != nil || !bytes.Equal(signupBytes(original), signupBytes(retry)) { |
| + t.Fatalf("draft replay changed: %v", e) |
| + } |
| + if _, e = f.s.createSignupDraft(f.raw, overageOperation(1), "changed", f.scope, f.now); !errors.Is(e, errOveragePreferenceConflict) { |
| + t.Fatalf("changed draft: %v", e) |
| + } |
| + altered := *f.i.Offer |
| + altered.Payload = []byte("changed") |
| + if _, e = f.s.freezeSignupOffer(f.raw, f.i.Header.ID, f.scope, altered, f.now); !errors.Is(e, errOveragePreferenceConflict) { |
| + t.Fatalf("offer overwrite: %v", e) |
| + } |
| + f.seal(t, true) |
| + sealed := signupBytes(f.i) |
| + if e = f.s.ClearStepUp(f.raw); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = f.s.RecordStepUp(f.raw, overageOwner, f.now.Add(time.Hour), f.now.Add(time.Hour)); e != nil { |
| + t.Fatal(e) |
| + } |
| + retry, e = f.s.sealSignup(f.raw, f.i.Header.ID, f.scope, true, true, f.i.Seal.OfferDigest, f.now.Add(2*time.Hour)) |
| + if e != nil || !bytes.Equal(sealed, signupBytes(retry)) { |
| + t.Fatalf("seal recovery after expiry changed: %v", e) |
| + } |
| + if bytes.Contains(sealed, []byte(f.raw)) || bytes.Contains(sealed, []byte(hashToken(f.raw))) { |
| + t.Fatal("session evidence persisted") |
| + } |
| + if _, e = f.s.sealSignup(f.raw, f.i.Header.ID, f.scope, true, false, f.i.Seal.OfferDigest, f.now); !errors.Is(e, errOveragePreferenceConflict) { |
| + t.Fatalf("changed seal: %v", e) |
| + } |
| +} |
| +func TestSignupFreshnessAndCurrentSession(t *testing.T) { |
| + for _, kind := range []string{"no-step-up", "wrong-address", "stale", "no-session", "expired-session"} { |
| + t.Run(kind, func(t *testing.T) { |
| + f := newSignupFixture(t, false) |
| + switch kind { |
| + case "no-step-up": |
| + f.s.ClearStepUp(f.raw) |
| + case "wrong-address": |
| + f.s.RecordStepUp(f.raw, "other@example.test", f.now, f.now) |
| + case "stale": |
| + f.s.RecordStepUp(f.raw, overageOwner, f.now.Add(-StepUpWindow-time.Second), f.now) |
| + case "no-session": |
| + f.s.DeleteSession(f.raw) |
| + case "expired-session": |
| + _, e := f.s.db.Exec(`UPDATE sessions SET expires_at=?`, f.now.Add(-time.Second).Unix()) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + if _, e := f.s.sealSignup(f.raw, f.i.Header.ID, f.scope, true, false, signupDigest("offer", nil), f.now); !errors.Is(e, errOveragePreferenceDenied) { |
| + t.Fatalf("authority %s: %v", kind, e) |
| + } |
| + if signupCount(t, f.s, `SELECT count(*) FROM signup_intents WHERE phase='draft'`) != 1 { |
| + t.Fatal("refusal changed draft") |
| + } |
| + }) |
| + } |
| +} |
| +func TestSignupBirthAtomicRollback(t *testing.T) { |
| + for _, table := range []string{"accounts", "memberships", "overage_preferences", "overage_preference_receipts", "account_birth_receipts", "signup_intents"} { |
| + t.Run(table, func(t *testing.T) { |
| + f := newSignupFixture(t, true) |
| + f.seal(t, true) |
| + f.candidate(t) |
| + f.live(t) |
| + verb := "INSERT" |
| + when := "" |
| + if table == "signup_intents" { |
| + verb = "UPDATE" |
| + when = " WHEN NEW.phase='completed'" |
| + } |
| + if _, e := f.s.db.Exec(fmt.Sprintf(`CREATE TRIGGER test_fail BEFORE %s ON %s%s BEGIN SELECT RAISE(ABORT,'injected'); END`, verb, table, when)); e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e := f.complete(true); e == nil { |
| + t.Fatal("injected failure accepted") |
| + } |
| + for _, q := range []string{`SELECT count(*) FROM accounts WHERE id=42`, `SELECT count(*) FROM memberships WHERE account_id=42`, `SELECT count(*) FROM overage_preferences WHERE account_id=42`, `SELECT count(*) FROM overage_preference_receipts WHERE account_id=42`, `SELECT count(*) FROM account_birth_receipts`} { |
| + if signupCount(t, f.s, q) != 0 { |
| + t.Fatalf("partial commit: %s", q) |
| + } |
| + } |
| + if signupCount(t, f.s, `SELECT count(*) FROM signup_intents WHERE phase='prepared'`) != 1 { |
| + t.Fatal("intent completed despite rollback") |
| + } |
| + if _, e := f.s.db.Exec(`DROP TRIGGER test_fail`); e != nil { |
| + t.Fatal(e) |
| + } |
| + out, e := f.complete(true) |
| + if e != nil || out.Historical.Outcome != "on" { |
| + t.Fatalf("retry after rollback: %+v %v", out, e) |
| + } |
| + }) |
| + } |
| +} |
| +func TestSignupBirthReplayAfterOFFRenameOwnerLoss(t *testing.T) { |
| + f := newSignupFixture(t, true) |
| + f.seal(t, true) |
| + f.candidate(t) |
| + f.live(t) |
| + first, e := f.complete(true) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if first.Preference.Revision != 1 || !first.Preference.RequestedOn { |
| + t.Fatalf("initial preference: %+v", first) |
| + } |
| + if _, e = f.s.changeOveragePreference(overageChange(42, overageOwner, 1, false, 2), f.now.Add(time.Second)); e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e = f.s.db.Exec(`UPDATE accounts SET name='Renamed' WHERE id=42`); e != nil { |
| + t.Fatal(e) |
| + } |
| + again, e := f.complete(true) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if !bytes.Equal(signupBytes(first.Historical), signupBytes(again.Historical)) || again.Current.Name != "Renamed" || again.Preference.RequestedOn || again.Preference.Revision != 2 { |
| + t.Fatalf("historical/current mixed: %+v", again) |
| + } |
| + if signupCount(t, f.s, `SELECT count(*) FROM overage_preference_receipts WHERE account_id=42`) != 2 { |
| + t.Fatal("replay mutated history") |
| + } |
| + if e = f.s.AddMember(42, "new@example.test", RoleOwner, f.now); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = f.s.RemoveMember(42, overageOwner); e != nil { |
| + t.Fatal(e) |
| + } |
| + if out, e := f.complete(true); !errors.Is(e, errOveragePreferenceDenied) || out.Historical.IntentID != "" { |
| + t.Fatalf("former owner disclosure: %+v %v", out, e) |
| + } |
| + other, e := f.s.CreateSession("new@example.test", "", f.now) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e = f.s.completeSignup(other, f.i.Header.ID, f.scope, f.i.Digest, true, f.now); !errors.Is(e, errOveragePreferenceDenied) { |
| + t.Fatalf("new owner inherited intent: %v", e) |
| + } |
| +} |
| +func TestSignupExpiredOrInvalidatedChoiceStaysOff(t *testing.T) { |
| + for _, expired := range []bool{false, true} { |
| + t.Run(fmt.Sprint(expired), func(t *testing.T) { |
| + f := newSignupFixture(t, true) |
| + f.seal(t, true) |
| + f.candidate(t) |
| + f.live(t) |
| + if expired { |
| + f.now = f.now.Add(2 * time.Hour) |
| + } |
| + out, e := f.complete(expired) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if out.Historical.Outcome != "choice_not_applied" || out.Preference.Revision != 0 || !f.i.Seal.RequestedOn { |
| + t.Fatalf("invalid choice applied: %+v", out) |
| + } |
| + }) |
| + } |
| + f := newSignupFixture(t, false) |
| + f.seal(t, false) |
| + f.candidate(t) |
| + f.live(t) |
| + out, e := f.complete(false) |
| + if e != nil || out.Historical.Outcome != "off" || out.Preference.Revision != 0 { |
| + t.Fatalf("default off: %+v %v", out, e) |
| + } |
| +} |
| +func TestSignupConcurrentSealsAndAttachments(t *testing.T) { |
| + f := newSignupFixture(t, true) |
| + start := make(chan struct{}) |
| + errs := make(chan error, 2) |
| + for _, on := range []bool{false, true} { |
| + go func(on bool) { |
| + <-start |
| + _, e := f.s.sealSignup(f.raw, f.i.Header.ID, f.scope, true, on, signupDigest("offer", signupBytes(f.i.Offer)), f.now) |
| + errs <- e |
| + }(on) |
| + } |
| + close(start) |
| + successes, conflicts := 0, 0 |
| + for k := 0; k < 2; k++ { |
| + e := <-errs |
| + if e == nil { |
| + successes++ |
| + } else if errors.Is(e, errOveragePreferenceConflict) { |
| + conflicts++ |
| + } else { |
| + t.Fatal(e) |
| + } |
| + } |
| + if successes != 1 || conflicts != 1 { |
| + t.Fatalf("seal winners %d conflicts %d", successes, conflicts) |
| + } |
| + f = newSignupFixture(t, false) |
| + start = make(chan struct{}) |
| + errs = make(chan error, 2) |
| + for _, payload := range []string{"one", "two"} { |
| + go func(p string) { |
| + <-start |
| + _, e := f.s.freezeSignupOffer(f.raw, f.i.Header.ID, f.scope, signupOffer{Payload: []byte(p), Evidence: overageEvidenceForTest(), ExpiresAt: f.now.Add(time.Hour)}, f.now) |
| + errs <- e |
| + }(payload) |
| + } |
| + close(start) |
| + successes, conflicts = 0, 0 |
| + for k := 0; k < 2; k++ { |
| + e := <-errs |
| + if e == nil { |
| + successes++ |
| + } else if errors.Is(e, errOveragePreferenceConflict) { |
| + conflicts++ |
| + } else { |
| + t.Fatal(e) |
| + } |
| + } |
| + if successes != 1 || conflicts != 1 { |
| + t.Fatalf("offer winners %d conflicts %d", successes, conflicts) |
| + } |
| +} |
| +func TestSignupSealAttachmentOrdering(t *testing.T) { |
| + for _, sealFirst := range []bool{false, true} { |
| + t.Run(fmt.Sprint(sealFirst), func(t *testing.T) { |
| + f := newSignupFixture(t, false) |
| + o := signupOffer{Payload: []byte("offer"), Evidence: overageEvidenceForTest(), ExpiresAt: f.now.Add(time.Hour)} |
| + if sealFirst { |
| + f.seal(t, false) |
| + if _, e := f.s.freezeSignupOffer(f.raw, f.i.Header.ID, f.scope, o, f.now); !errors.Is(e, errOveragePreferenceConflict) { |
| + t.Fatalf("offer attached after seal: %v", e) |
| + } |
| + } else { |
| + if _, e := f.s.freezeSignupOffer(f.raw, f.i.Header.ID, f.scope, o, f.now); e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e := f.s.sealSignup(f.raw, f.i.Header.ID, f.scope, true, false, signupDigest("offer", nil), f.now); !errors.Is(e, errOveragePreferenceConflict) { |
| + t.Fatalf("seal ignored newly frozen offer: %v", e) |
| + } |
| + } |
| + }) |
| + } |
| +} |
| +func TestSignupCancellationAndRetiredTerminal(t *testing.T) { |
| + f := newSignupFixture(t, false) |
| + if e := f.s.cancelSignup(f.raw, f.i.Header.ID, f.scope, f.now); e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e := f.s.sealSignup(f.raw, f.i.Header.ID, f.scope, true, false, signupDigest("offer", nil), f.now); !errors.Is(e, errOveragePreferenceConflict) { |
| + t.Fatalf("cancelled draft sealed: %v", e) |
| + } |
| + f = newSignupFixture(t, true) |
| + f.seal(t, true) |
| + f.candidate(t) |
| + f.live(t) |
| + if e := f.s.cancelSignup(f.raw, f.i.Header.ID, f.scope, f.now); e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e := f.complete(true); !errors.Is(e, errOveragePreferenceConflict) { |
| + t.Fatalf("cancelled birth: %v", e) |
| + } |
| + if signupCount(t, f.s, `SELECT count(*) FROM signup_mint_attempts`) != 1 { |
| + t.Fatal("cancellation discarded attempt") |
| + } |
| + f = newSignupFixture(t, false) |
| + f.seal(t, false) |
| + f.candidate(t) |
| + f.live(t) |
| + o := signupObservation{AccountID: 42, Sqid: f.a.Sqid, Key: f.a.Key, IntentID: f.i.Header.ID, Attempt: 1, BindingDigest: f.a.BindingDigest, CreatedAt: f.now, Body: []byte("tombstone"), Retired: true} |
| + if _, e := f.s.observeSignupAttempt(f.i.Header.ID, f.scope, 1, o); e != nil { |
| + t.Fatal(e) |
| + } |
| + if signupCount(t, f.s, `SELECT count(*) FROM signup_intents WHERE phase='retired'`) != 1 { |
| + t.Fatal("retirement not terminal") |
| + } |
| + if _, e := f.complete(false); e == nil { |
| + t.Fatal("retired birth accepted") |
| + } |
| + if _, e := f.s.db.Exec(`UPDATE signup_intents SET phase='prepared'`); e == nil { |
| + t.Fatal("retirement reversed") |
| + } |
| +} |
| +func TestSignupCandidateExclusionAndCollision(t *testing.T) { |
| + for _, kind := range []string{"account", "membership", "retirement"} { |
| + t.Run(kind, func(t *testing.T) { |
| + f := newSignupFixture(t, false) |
| + f.seal(t, false) |
| + switch kind { |
| + case "account": |
| + if e := f.s.CreateAccount(42, domains.Sqid(42), "Existing", overageOwner, f.now); e != nil { |
| + t.Fatal(e) |
| + } |
| + case "membership": |
| + if _, e := f.s.db.Exec(`INSERT INTO memberships(account_id,address,role,added_at) VALUES(42,?,'owner',1)`, overageOwner); e != nil { |
| + t.Fatal(e) |
| + } |
| + case "retirement": |
| + if _, e := f.s.db.Exec(`INSERT INTO billing_retirements(account_id,started_at) VALUES(42,1)`); e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + a := signupAttempt{IntentID: f.i.Header.ID, Number: 1, AccountID: 42, Sqid: domains.Sqid(42), Key: domains.AccountManifestKey(domains.Sqid(42)), CreatedAt: f.now, Body: []byte("body")} |
| + a.BindingDigest = signupAttemptBinding(f.i, a) |
| + if _, e := f.s.recordSignupAttempt(f.raw, f.i.Header.ID, f.scope, a, f.now); !errors.Is(e, errOveragePreferenceConflict) { |
| + t.Fatalf("local %s reused: %v", kind, e) |
| + } |
| + }) |
| + } |
| + f := newSignupFixture(t, false) |
| + f.seal(t, false) |
| + f.candidate(t) |
| + next := f.a |
| + next.Number = 2 |
| + next.AccountID = 43 |
| + next.Sqid = domains.Sqid(43) |
| + next.Key = domains.AccountManifestKey(next.Sqid) |
| + next.BindingDigest = signupAttemptBinding(f.i, next) |
| + if _, e := f.s.recordSignupAttempt(f.raw, f.i.Header.ID, f.scope, next, f.now); !errors.Is(e, errOveragePreferenceConflict) { |
| + t.Fatalf("unresolved advance: %v", e) |
| + } |
| + o := signupObservation{AccountID: 42, Sqid: f.a.Sqid, Key: f.a.Key, CreatedAt: time.Time{}, Body: []byte("unmarked")} |
| + r, e := f.s.observeSignupAttempt(f.i.Header.ID, f.scope, 1, o) |
| + if e != nil || r.State != "held" { |
| + t.Fatalf("inconclusive observation: %+v %v", r, e) |
| + } |
| + o.CreatedAt = f.now.Add(-time.Second) |
| + r, e = f.s.observeSignupAttempt(f.i.Header.ID, f.scope, 1, o) |
| + if e != nil || r.State != "collision" { |
| + t.Fatalf("foreign winner: %+v %v", r, e) |
| + } |
| + if _, e = f.s.recordSignupAttempt(f.raw, f.i.Header.ID, f.scope, next, f.now); e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e = f.s.observeSignupAttempt(f.i.Header.ID, f.scope, 1, signupObservation{}); !errors.Is(e, errOveragePreferenceConflict) { |
| + t.Fatalf("terminal collision altered: %v", e) |
| + } |
| +} |
| +func TestSignupImmutability(t *testing.T) { |
| + f := newSignupFixture(t, true) |
| + f.seal(t, true) |
| + f.candidate(t) |
| + f.live(t) |
| + if _, e := f.complete(true); e != nil { |
| + t.Fatal(e) |
| + } |
| + queries := []string{ |
| + `UPDATE signup_intents SET header=X'00'`, `UPDATE signup_intents SET offer=X'00'`, `UPDATE signup_intents SET seal=X'00'`, `DELETE FROM signup_intents`, |
| + `INSERT OR REPLACE INTO signup_intents SELECT * FROM signup_intents`, |
| + `UPDATE signup_mint_attempts SET tuple=X'00'`, `DELETE FROM signup_mint_attempts`, `INSERT OR REPLACE INTO signup_mint_attempts SELECT * FROM signup_mint_attempts`, |
| + `UPDATE account_birth_receipts SET receipt=X'00'`, `DELETE FROM account_birth_receipts`, `INSERT OR REPLACE INTO account_birth_receipts SELECT * FROM account_birth_receipts`, |
| + `UPDATE signup_intents SET phase='retired'`, `UPDATE signup_intents SET phase='prepared'`, |
| + } |
| + for _, q := range queries { |
| + if _, e := f.s.db.Exec(q); e == nil { |
| + t.Fatalf("immutable write accepted: %s", q) |
| + } |
| + } |
| + if _, e := f.complete(true); e != nil { |
| + t.Fatalf("refused writes damaged receipt: %v", e) |
| + } |
| +} |
| +func TestSignupReplayCorruptionRefuses(t *testing.T) { |
| + for _, kind := range []string{"birth-missing", "birth-malformed", "current-missing", "receipt-missing", "current-malformed"} { |
| + t.Run(kind, func(t *testing.T) { |
| + f := newSignupFixture(t, true) |
| + f.seal(t, true) |
| + f.candidate(t) |
| + f.live(t) |
| + if _, e := f.complete(true); e != nil { |
| + t.Fatal(e) |
| + } |
| + var qs []string |
| + switch kind { |
| + case "birth-missing": |
| + qs = []string{`DROP TRIGGER signup_birth_no_delete`, `DELETE FROM account_birth_receipts`} |
| + case "birth-malformed": |
| + qs = []string{`DROP TRIGGER signup_birth_no_update`, `UPDATE account_birth_receipts SET receipt=X'00'`} |
| + case "current-missing": |
| + qs = []string{`DELETE FROM overage_preferences`} |
| + case "receipt-missing": |
| + qs = []string{`DROP TRIGGER overage_preference_receipts_no_delete`, `DELETE FROM overage_preference_receipts`} |
| + case "current-malformed": |
| + qs = []string{`UPDATE overage_preferences SET revision=9`} |
| + } |
| + for _, q := range qs { |
| + if _, e := f.s.db.Exec(q); e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + if _, e := f.complete(true); e == nil { |
| + t.Fatal("corrupt replay accepted") |
| + } |
| + }) |
| + } |
| +} |
| +func TestSignupGlobalOperationCollisionRollback(t *testing.T) { |
| + f := newSignupFixture(t, true) |
| + f.seal(t, true) |
| + f.candidate(t) |
| + f.live(t) |
| + seedOverageAccount(t, f.s, 90, overageOwner, f.now) |
| + c := overageChange(90, overageOwner, 0, false, 2) |
| + c.OperationID = f.i.Header.PreferenceOperationID |
| + if _, e := f.s.changeOveragePreference(c, f.now); e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e := f.complete(true); !errors.Is(e, errOveragePreferenceConflict) { |
| + t.Fatalf("operation collision: %v", e) |
| + } |
| + if signupCount(t, f.s, `SELECT count(*) FROM accounts WHERE id=42`) != 0 { |
| + t.Fatal("collision committed birth") |
| + } |
| +} |
| +func TestSignupConcurrentBirthAndOFF(t *testing.T) { |
| + f := newSignupFixture(t, true) |
| + f.seal(t, true) |
| + f.candidate(t) |
| + f.live(t) |
| + var wg sync.WaitGroup |
| + errs := make(chan error, 2) |
| + start := make(chan struct{}) |
| + for k := 0; k < 2; k++ { |
| + wg.Add(1) |
| + go func() { defer wg.Done(); <-start; _, e := f.complete(true); errs <- e }() |
| + } |
| + close(start) |
| + wg.Wait() |
| + close(errs) |
| + for e := range errs { |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + } |
| + if signupCount(t, f.s, `SELECT count(*) FROM account_birth_receipts`) != 1 || signupCount(t, f.s, `SELECT count(*) FROM overage_preference_receipts WHERE account_id=42`) != 1 { |
| + t.Fatal("multiple birth commits") |
| + } |
| + errs = make(chan error, 2) |
| + start = make(chan struct{}) |
| + for _, op := range []byte{3, 4} { |
| + go func(op byte) { |
| + <-start |
| + _, e := f.s.changeOveragePreference(overageChange(42, overageOwner, 1, false, op), f.now.Add(time.Second)) |
| + errs <- e |
| + }(op) |
| + } |
| + close(start) |
| + win, stale := 0, 0 |
| + for k := 0; k < 2; k++ { |
| + e := <-errs |
| + if e == nil { |
| + win++ |
| + } else if errors.Is(e, errOveragePreferenceStale) { |
| + stale++ |
| + } else { |
| + t.Fatal(e) |
| + } |
| + } |
| + if win != 1 || stale != 1 { |
| + t.Fatalf("revision ownership: %d %d", win, stale) |
| + } |
| + out, e := f.complete(true) |
| + if e != nil || out.Preference.RequestedOn || out.Preference.Revision != 2 { |
| + t.Fatalf("replay overwrote concurrent OFF: %+v %v", out, e) |
| + } |
| +} |
| +func TestSignupSameIntentContradictionsHold(t *testing.T) { |
| + for _, field := range []string{"key", "attempt", "binding", "created"} { |
| + t.Run(field, func(t *testing.T) { |
| + f := newSignupFixture(t, false) |
| + f.seal(t, false) |
| + f.candidate(t) |
| + o := signupObservation{AccountID: 42, Sqid: f.a.Sqid, Key: f.a.Key, IntentID: f.i.Header.ID, Attempt: 1, BindingDigest: f.a.BindingDigest, CreatedAt: f.now, Body: f.a.Body} |
| + switch field { |
| + case "key": |
| + o.Key = "wrong" |
| + case "attempt": |
| + o.Attempt = 2 |
| + case "binding": |
| + o.BindingDigest = strings.Repeat("a", 64) |
| + case "created": |
| + o.CreatedAt = f.now.Add(time.Second) |
| + } |
| + r, e := f.s.observeSignupAttempt(f.i.Header.ID, f.scope, 1, o) |
| + if e != nil || r.State != "held" { |
| + t.Fatalf("same-intent contradiction classified foreign: %+v %v", r, e) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestSignupOwnObservationCannotBecomeForeign(t *testing.T) { |
| + f := newSignupFixture(t, false) |
| + f.seal(t, false) |
| + f.candidate(t) |
| + f.live(t) |
| + foreign := signupObservation{AccountID: 42, Sqid: f.a.Sqid, Key: f.a.Key, CreatedAt: f.now.Add(time.Second), Body: []byte("replacement")} |
| + for k := 0; k < 2; k++ { |
| + r, e := f.s.observeSignupAttempt(f.i.Header.ID, f.scope, 1, foreign) |
| + if e != nil || r.State != "held" { |
| + t.Fatalf("own claim released on retry %d: %+v %v", k, r, e) |
| + } |
| + } |
| + if _, e := f.complete(false); e == nil { |
| + t.Fatal("held replacement birthed") |
| + } |
| + next := f.a |
| + next.Number = 2 |
| + next.BindingDigest = signupAttemptBinding(f.i, next) |
| + if _, e := f.s.recordSignupAttempt(f.raw, f.i.Header.ID, f.scope, next, f.now); !errors.Is(e, errOveragePreferenceConflict) { |
| + t.Fatalf("own replacement advanced: %v", e) |
| + } |
| + f.live(t) |
| + if _, e := f.complete(false); e != nil { |
| + t.Fatalf("same own proof could not resolve hold: %v", e) |
| + } |
| +} |
| +func TestSignupForeignCollisionDoesNotOwnOtherIntent(t *testing.T) { |
| + f := newSignupFixture(t, false) |
| + f.seal(t, false) |
| + f.candidate(t) |
| + other, e := f.s.createSignupDraft(f.raw, overageOperation(7), "other", f.scope, f.now) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + other, e = f.s.sealSignup(f.raw, other.Header.ID, f.scope, true, false, signupDigest("offer", nil), f.now) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + a := f.a |
| + a.IntentID = other.Header.ID |
| + a.BindingDigest = signupAttemptBinding(other, a) |
| + if _, e = f.s.recordSignupAttempt(f.raw, a.IntentID, f.scope, a, f.now); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = f.s.cancelSignup(f.raw, f.i.Header.ID, f.scope, f.now); e != nil { |
| + t.Fatal(e) |
| + } |
| + o := signupObservation{AccountID: 42, Sqid: a.Sqid, Key: a.Key, CreatedAt: a.CreatedAt, Body: a.Body, IntentID: a.IntentID, Attempt: 1, BindingDigest: a.BindingDigest} |
| + if r, e := f.s.observeSignupAttempt(f.i.Header.ID, f.scope, 1, o); e != nil || r.State != "collision" { |
| + t.Fatalf("cancelled observer: %+v %v", r, e) |
| + } |
| + if _, e = f.s.observeSignupAttempt(a.IntentID, f.scope, 1, o); e != nil { |
| + t.Fatal(e) |
| + } |
| + out, e := f.s.completeSignup(f.raw, a.IntentID, f.scope, other.Digest, false, f.now) |
| + if e != nil || out.Historical.IntentID != a.IntentID { |
| + t.Fatalf("foreign losing claim blocked winner: %+v %v", out, e) |
| + } |
| +} |
| +func TestSignupRestartPreservesReceiptAndOFF(t *testing.T) { |
| + f := newSignupFixture(t, true) |
| + f.seal(t, true) |
| + f.candidate(t) |
| + f.live(t) |
| + first, e := f.complete(true) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e = f.s.changeOveragePreference(overageChange(42, overageOwner, 1, false, 8), f.now.Add(time.Second)); e != nil { |
| + t.Fatal(e) |
| + } |
| + var seq int |
| + var name, path string |
| + if e = f.s.db.QueryRow(`PRAGMA database_list`).Scan(&seq, &name, &path); e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = f.s.Close(); e != nil { |
| + t.Fatal(e) |
| + } |
| + f.s = newOverageStore(t, path) |
| + out, e := f.complete(true) |
| + if e != nil || !bytes.Equal(signupBytes(first.Historical), signupBytes(out.Historical)) || out.Preference.RequestedOn || out.Preference.Revision != 2 { |
| + t.Fatalf("restart changed history/current: %+v %v", out, e) |
| + } |
| +} |
| +func TestSignupCancelBirthRace(t *testing.T) { |
| + f := newSignupFixture(t, true) |
| + f.seal(t, true) |
| + f.candidate(t) |
| + f.live(t) |
| + start := make(chan struct{}) |
| + cancelled := make(chan error, 1) |
| + birthed := make(chan error, 1) |
| + go func() { <-start; cancelled <- f.s.cancelSignup(f.raw, f.i.Header.ID, f.scope, f.now) }() |
| + go func() { <-start; _, e := f.complete(true); birthed <- e }() |
| + close(start) |
| + ce, be := <-cancelled, <-birthed |
| + if ce == nil { |
| + if !errors.Is(be, errOveragePreferenceConflict) || signupCount(t, f.s, `SELECT count(*) FROM accounts WHERE id=42`) != 0 { |
| + t.Fatalf("cancel winner leaked birth: %v", be) |
| + } |
| + } else if be == nil { |
| + if !errors.Is(ce, errOveragePreferenceConflict) || signupCount(t, f.s, `SELECT count(*) FROM account_birth_receipts`) != 1 { |
| + t.Fatalf("birth winner cancelled: %v", ce) |
| + } |
| + } else { |
| + t.Fatalf("neither serialized operation won: %v %v", ce, be) |
| + } |
| +} |
| +func TestSignupAllUniqueKeysRefuseReplacement(t *testing.T) { |
| + f := newSignupFixture(t, false) |
| + f.seal(t, false) |
| + f.candidate(t) |
| + f.live(t) |
| + if _, e := f.complete(false); e != nil { |
| + t.Fatal(e) |
| + } |
| + for _, q := range []string{ |
| + `INSERT OR REPLACE INTO signup_intents(id,scope,subject,operation_id,header,offer,seal,digest,phase,attempt) SELECT 'different',scope,subject,operation_id,header,offer,seal,digest,phase,attempt FROM signup_intents`, |
| + `INSERT OR REPLACE INTO account_birth_receipts SELECT 'different',scope,account_id,'different',receipt,digest FROM account_birth_receipts`, |
| + `INSERT OR REPLACE INTO account_birth_receipts SELECT 'different',scope,999,sqid,receipt,digest FROM account_birth_receipts`, |
| + } { |
| + if _, e := f.s.db.Exec(q); e == nil { |
| + t.Fatalf("alternate unique replacement allowed: %s", q) |
| + } |
| + } |
| + if _, e := f.complete(false); e != nil { |
| + t.Fatal(e) |
| + } |
| +} |
| + |
| +func TestSignupAttemptBodyDigestDetectsCorruption(t *testing.T) { |
| + f := newSignupFixture(t, false) |
| + f.seal(t, false) |
| + f.candidate(t) |
| + var b []byte |
| + if e := f.s.db.QueryRow(`SELECT tuple FROM signup_mint_attempts`).Scan(&b); e != nil { |
| + t.Fatal(e) |
| + } |
| + var a signupAttempt |
| + if e := signupDecode(b, &a); e != nil { |
| + t.Fatal(e) |
| + } |
| + if a.BodyDigest == "" { |
| + t.Fatal("missing independent body digest") |
| + } |
| + a.Body = []byte("different valid bytes") |
| + if _, e := f.s.db.Exec(`DROP TRIGGER signup_attempts_update`); e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e := f.s.db.Exec(`UPDATE signup_mint_attempts SET tuple=?`, signupBytes(a)); e != nil { |
| + t.Fatal(e) |
| + } |
| + o := signupObservation{AccountID: 42, Sqid: a.Sqid, Key: a.Key, IntentID: a.IntentID, Attempt: a.Number, BindingDigest: a.BindingDigest, CreatedAt: a.CreatedAt, Body: a.Body} |
| + if _, e := f.s.observeSignupAttempt(f.i.Header.ID, f.scope, 1, o); !errors.Is(e, errOveragePreferenceCorrupt) { |
| + t.Fatalf("body corruption accepted: %v", e) |
| + } |
| + if signupCount(t, f.s, `SELECT count(*) FROM accounts WHERE id=42`) != 0 { |
| + t.Fatal("corrupt tuple birthed account") |
| + } |
| +} |
| + |
| +func TestSignupCollisionRevalidationPreservesSealedObservation(t *testing.T) { |
| + f := newSignupFixture(t, false) |
| + f.seal(t, false) |
| + f.candidate(t) |
| + o := signupObservation{AccountID: 42, Sqid: f.a.Sqid, Key: f.a.Key, CreatedAt: f.now.Add(-time.Second), Body: []byte("foreign original"), ETag: "first"} |
| + first, e := f.s.observeSignupAttempt(f.i.Header.ID, f.scope, 1, o) |
| + if e != nil || first.State != "collision" { |
| + t.Fatalf("foreign: %+v %v", first, e) |
| + } |
| + o.Body = []byte("foreign renamed, different owner, tombstoned") |
| + o.ETag = "second" |
| + o.Retired = true |
| + again, e := f.s.observeSignupAttempt(f.i.Header.ID, f.scope, 1, o) |
| + if e != nil || !bytes.Equal(signupBytes(first), signupBytes(again)) { |
| + t.Fatalf("immutable foreign binding replay: %+v %v", again, e) |
| + } |
| + o.CreatedAt = o.CreatedAt.Add(time.Second) |
| + if _, e = f.s.observeSignupAttempt(f.i.Header.ID, f.scope, 1, o); !errors.Is(e, errOveragePreferenceConflict) { |
| + t.Fatalf("foreign identity changed: %v", e) |
| + } |
| +} |
| + |
| +func signupTableSnapshot(t *testing.T, s *Store, table string) []byte { |
| + t.Helper() |
| + rows, err := s.db.Query(`SELECT * FROM ` + table + ` ORDER BY 1,2`) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer rows.Close() |
| + columns, err := rows.Columns() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var records [][]any |
| + for rows.Next() { |
| + values := make([]any, len(columns)) |
| + dest := make([]any, len(columns)) |
| + for k := range values { |
| + dest[k] = &values[k] |
| + } |
| + if err = rows.Scan(dest...); err != nil { |
| + t.Fatal(err) |
| + } |
| + records = append(records, values) |
| + } |
| + if err = rows.Err(); err != nil { |
| + t.Fatal(err) |
| + } |
| + return signupBytes(records) |
| +} |
| +func TestSignupRowidReplacementRefused(t *testing.T) { |
| + for _, table := range []string{"signup_intents", "signup_mint_attempts", "account_birth_receipts"} { |
| + for _, verb := range []string{"update", "insert"} { |
| + t.Run(table+"/"+verb, func(t *testing.T) { |
| + f := newSignupFixture(t, false) |
| + f.seal(t, false) |
| + f.candidate(t) |
| + f.live(t) |
| + if _, err := f.complete(false); err != nil { |
| + t.Fatal(err) |
| + } |
| + other := f |
| + var err error |
| + other.i, err = f.s.createSignupDraft(f.raw, overageOperation(9), "other", f.scope, f.now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + other.seal(t, false) |
| + other.a = signupAttempt{IntentID: other.i.Header.ID, Number: 1, AccountID: 43, Sqid: domains.Sqid(43), CreatedAt: f.now, Body: []byte("other exact body")} |
| + other.a.Key = domains.AccountManifestKey(other.a.Sqid) |
| + other.a.BindingDigest = signupAttemptBinding(other.i, other.a) |
| + if _, err = f.s.recordSignupAttempt(f.raw, other.i.Header.ID, f.scope, other.a, f.now); err != nil { |
| + t.Fatal(err) |
| + } |
| + other.live(t) |
| + if _, err = other.complete(false); err != nil { |
| + t.Fatal(err) |
| + } |
| + before := signupTableSnapshot(t, f.s, table) |
| + key := "intent_id" |
| + if table == "signup_intents" { |
| + key = "id" |
| + } |
| + q := `UPDATE OR REPLACE ` + table + ` SET rowid=(SELECT rowid FROM ` + table + ` WHERE ` + key + `=?) WHERE ` + key + `=?` |
| + if verb == "insert" { |
| + switch table { |
| + case "signup_intents": |
| + q = `INSERT OR REPLACE INTO signup_intents(rowid,id,scope,subject,operation_id,header,offer,seal,digest,phase,attempt) SELECT (SELECT rowid FROM signup_intents WHERE id=?),'replacement',scope,subject,'replacement',header,offer,seal,digest,phase,attempt FROM signup_intents WHERE id=?` |
| + case "signup_mint_attempts": |
| + q = `INSERT OR REPLACE INTO signup_mint_attempts(rowid,intent_id,number,account_id,tuple,state,observation) SELECT (SELECT rowid FROM signup_mint_attempts WHERE intent_id=?),'replacement',number,account_id,tuple,state,observation FROM signup_mint_attempts WHERE intent_id=?` |
| + case "account_birth_receipts": |
| + q = `INSERT OR REPLACE INTO account_birth_receipts(rowid,intent_id,scope,account_id,sqid,receipt,digest) SELECT (SELECT rowid FROM account_birth_receipts WHERE intent_id=?),'replacement',scope,999,'replacement',receipt,digest FROM account_birth_receipts WHERE intent_id=?` |
| + } |
| + } |
| + if _, err = f.s.db.Exec(q, other.i.Header.ID, f.i.Header.ID); err == nil { |
| + t.Errorf("explicit rowid %s accepted", verb) |
| + } |
| + if after := signupTableSnapshot(t, f.s, table); !bytes.Equal(before, after) { |
| + t.Errorf("original %s records changed through rowid %s", table, verb) |
| + } |
| + if _, err = f.complete(false); err != nil { |
| + t.Errorf("first original birth no longer replays: %v", err) |
| + } |
| + if _, err = other.complete(false); err != nil { |
| + t.Errorf("second original birth no longer replays: %v", err) |
| + } |
| + }) |
| + } |
| + } |
| +} |
| + |
| +func TestSignupHeldClaimSurvivesForeignRewrite(t *testing.T) { |
| + for _, claim := range []string{"ambiguous-unmarked", "same-intent-contradiction"} { |
| + for _, rewrite := range []string{"foreign-marker", "changed-created-at"} { |
| + t.Run(claim+"/"+rewrite, func(t *testing.T) { |
| + f := newSignupFixture(t, false) |
| + f.seal(t, false) |
| + f.candidate(t) |
| + original := signupObservation{AccountID: 42, Sqid: f.a.Sqid, Key: f.a.Key, CreatedAt: f.now, Body: []byte("ambiguous own observation")} |
| + if claim == "same-intent-contradiction" { |
| + original.IntentID = f.i.Header.ID |
| + original.Attempt = 1 |
| + original.BindingDigest = strings.Repeat("a", 64) |
| + } |
| + first, err := f.s.observeSignupAttempt(f.i.Header.ID, f.scope, 1, original) |
| + if err != nil || first.State != "held" { |
| + t.Fatalf("first held evidence: %+v %v", first, err) |
| + } |
| + changed := original |
| + changed.IntentID = "" |
| + changed.Attempt = 0 |
| + changed.BindingDigest = "" |
| + changed.CreatedAt = f.now.Add(time.Second) |
| + changed.Body = []byte("rewritten foreign identity") |
| + if rewrite == "foreign-marker" { |
| + changed.CreatedAt = f.now |
| + changed.IntentID = strings.Repeat("b", 64) |
| + changed.Attempt = 1 |
| + changed.BindingDigest = strings.Repeat("c", 64) |
| + } |
| + for k := 0; k < 2; k++ { |
| + held, e := f.s.observeSignupAttempt(f.i.Header.ID, f.scope, 1, changed) |
| + if e != nil || held.State != "held" || !bytes.Equal(signupBytes(held.Observation), signupBytes(first.Observation)) { |
| + t.Fatalf("held provenance released on rewrite %d: %+v %v", k, held, e) |
| + } |
| + } |
| + next := f.a |
| + next.Number = 2 |
| + next.AccountID = 43 |
| + next.Sqid = domains.Sqid(43) |
| + next.Key = domains.AccountManifestKey(next.Sqid) |
| + next.BindingDigest = signupAttemptBinding(f.i, next) |
| + if _, err = f.s.recordSignupAttempt(f.raw, f.i.Header.ID, f.scope, next, f.now); !errors.Is(err, errOveragePreferenceConflict) { |
| + t.Fatalf("rewritten held claim advanced: %v", err) |
| + } |
| + if _, err = f.complete(false); !errors.Is(err, errOveragePreferenceConflict) { |
| + t.Fatalf("held claim consumed: %v", err) |
| + } |
| + f.live(t) |
| + if _, err = f.complete(false); err != nil { |
| + t.Fatalf("exact own proof did not resolve held claim: %v", err) |
| + } |
| + }) |
| + } |
| + } |
| +} |
| +func TestSignupHeldClaimResolvesRetired(t *testing.T) { |
| + f := newSignupFixture(t, false) |
| + f.seal(t, false) |
| + f.candidate(t) |
| + o := signupObservation{AccountID: 42, Sqid: f.a.Sqid, Key: f.a.Key, CreatedAt: f.now, Body: []byte("ambiguous")} |
| + if r, err := f.s.observeSignupAttempt(f.i.Header.ID, f.scope, 1, o); err != nil || r.State != "held" { |
| + t.Fatalf("hold: %+v %v", r, err) |
| + } |
| + o.IntentID = f.i.Header.ID |
| + o.Attempt = 1 |
| + o.BindingDigest = f.a.BindingDigest |
| + o.Retired = true |
| + o.Body = []byte("own tombstone") |
| + if r, err := f.s.observeSignupAttempt(f.i.Header.ID, f.scope, 1, o); err != nil || r.State != "observed_retired" { |
| + t.Fatalf("retired proof: %+v %v", r, err) |
| + } |
| + if signupCount(t, f.s, `SELECT count(*) FROM signup_intents WHERE phase='retired'`) != 1 { |
| + t.Fatal("held own retirement not terminal") |
| + } |
| +} |
| +func signupCollisionChain(t *testing.T, count int64) signupFixture { |
| + t.Helper() |
| + f := newSignupFixture(t, true) |
| + f.seal(t, true) |
| + for n := int64(1); n <= count; n++ { |
| + f.a = signupAttempt{IntentID: f.i.Header.ID, Number: n, AccountID: 41 + n, Sqid: domains.Sqid(41 + n), CreatedAt: f.now, Body: []byte(fmt.Sprintf("exact body %d", n))} |
| + f.a.Key = domains.AccountManifestKey(f.a.Sqid) |
| + f.a.BindingDigest = signupAttemptBinding(f.i, f.a) |
| + if _, err := f.s.recordSignupAttempt(f.raw, f.i.Header.ID, f.scope, f.a, f.now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if n < count { |
| + foreign := signupObservation{AccountID: f.a.AccountID, Sqid: f.a.Sqid, Key: f.a.Key, CreatedAt: f.now.Add(-time.Second), Body: []byte("foreign winner")} |
| + if r, err := f.s.observeSignupAttempt(f.i.Header.ID, f.scope, n, foreign); err != nil || r.State != "collision" { |
| + t.Fatalf("chain collision: %+v %v", r, err) |
| + } |
| + } else { |
| + f.live(t) |
| + } |
| + } |
| + return f |
| +} |
| +func TestSignupValidCollisionChainBirthAndOFFReplay(t *testing.T) { |
| + f := signupCollisionChain(t, 5) |
| + out, err := f.complete(true) |
| + if err != nil || out.Historical.Attempt != 5 { |
| + t.Fatalf("valid chain: %+v %v", out, err) |
| + } |
| + if _, err = f.s.changeOveragePreference(overageChange(f.a.AccountID, overageOwner, 1, false, 11), f.now.Add(time.Second)); err != nil { |
| + t.Fatal(err) |
| + } |
| + retry, err := f.complete(true) |
| + if err != nil || retry.Preference.RequestedOn || retry.Preference.Revision != 2 || !bytes.Equal(signupBytes(out.Historical), signupBytes(retry.Historical)) { |
| + t.Fatalf("chain replay after OFF: %+v %v", retry, err) |
| + } |
| +} |
| +func TestSignupBrokenPriorAttemptChainRefuses(t *testing.T) { |
| + for _, stage := range []string{"completion", "replay-after-off"} { |
| + for _, damage := range []string{"missing", "corrupt-tuple", "corrupt-observation", "not-collision"} { |
| + t.Run(stage+"/"+damage, func(t *testing.T) { |
| + f := signupCollisionChain(t, 3) |
| + if stage == "replay-after-off" { |
| + if _, err := f.complete(true); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := f.s.changeOveragePreference(overageChange(f.a.AccountID, overageOwner, 1, false, 12), f.now.Add(time.Second)); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + switch damage { |
| + case "missing": |
| + if _, err := f.s.db.Exec(`DROP TRIGGER signup_attempts_no_delete`); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := f.s.db.Exec(`DELETE FROM signup_mint_attempts WHERE number=1`); err != nil { |
| + t.Fatal(err) |
| + } |
| + default: |
| + if _, err := f.s.db.Exec(`DROP TRIGGER signup_attempts_update`); err != nil { |
| + t.Fatal(err) |
| + } |
| + q := `UPDATE signup_mint_attempts SET tuple=X'00' WHERE number=1` |
| + if damage == "corrupt-observation" { |
| + q = `UPDATE signup_mint_attempts SET observation=X'00' WHERE number=1` |
| + } |
| + if damage == "not-collision" { |
| + q = `UPDATE signup_mint_attempts SET state='held' WHERE number=1` |
| + } |
| + if _, err := f.s.db.Exec(q); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + before := map[string][]byte{} |
| + for _, table := range []string{"signup_intents", "signup_mint_attempts", "accounts", "memberships", "overage_preferences", "overage_preference_receipts", "account_birth_receipts"} { |
| + before[table] = signupTableSnapshot(t, f.s, table) |
| + } |
| + if _, err := f.complete(true); !errors.Is(err, errOveragePreferenceCorrupt) { |
| + t.Errorf("broken prior chain accepted or misclassified: %v", err) |
| + } |
| + for table, want := range before { |
| + if got := signupTableSnapshot(t, f.s, table); !bytes.Equal(want, got) { |
| + t.Errorf("refused chain repaired/mutated %s", table) |
| + } |
| + } |
| + if stage == "replay-after-off" { |
| + p, err := f.s.getOveragePreference(f.a.AccountID, overageOwner) |
| + if err != nil || p.RequestedOn || p.Revision != 2 { |
| + t.Fatalf("OFF changed: %+v %v", p, err) |
| + } |
| + } else if signupCount(t, f.s, `SELECT count(*) FROM accounts WHERE id=?`, f.a.AccountID) != 0 { |
| + t.Fatal("broken chain birthed account") |
| + } |
| + }) |
| + } |
| + } |
| +} |
| +func TestSignupOutOfRangeAttemptChainRefuses(t *testing.T) { |
| + f := signupCollisionChain(t, 5) |
| + foreign := signupObservation{AccountID: f.a.AccountID, Sqid: f.a.Sqid, Key: f.a.Key, CreatedAt: f.now.Add(-time.Second), Body: []byte("foreign winner")} |
| + if _, err := f.s.db.Exec(`UPDATE signup_mint_attempts SET state='collision',observation=? WHERE number=5`, signupBytes(foreign)); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.a.Number = 6 |
| + f.a.AccountID = 47 |
| + f.a.Sqid = domains.Sqid(47) |
| + f.a.Key = domains.AccountManifestKey(f.a.Sqid) |
| + f.a.BindingDigest = signupAttemptBinding(f.i, f.a) |
| + f.a.BodyDigest = signupDigest("body", f.a.Body) |
| + own := signupObservation{AccountID: f.a.AccountID, Sqid: f.a.Sqid, Key: f.a.Key, IntentID: f.i.Header.ID, Attempt: 6, BindingDigest: f.a.BindingDigest, CreatedAt: f.a.CreatedAt, Body: f.a.Body} |
| + if _, err := f.s.db.Exec(`INSERT INTO signup_mint_attempts(intent_id,number,account_id,tuple,state,observation) VALUES(?,?,?,?,?,?)`, f.i.Header.ID, 6, f.a.AccountID, signupBytes(f.a), "observed_live", signupBytes(own)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := f.s.db.Exec(`UPDATE signup_intents SET attempt=6 WHERE id=?`, f.i.Header.ID); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := f.complete(true); !errors.Is(err, errOveragePreferenceCorrupt) { |
| + t.Fatalf("out-of-range chain accepted: %v", err) |
| + } |
| + if signupCount(t, f.s, `SELECT count(*) FROM accounts WHERE id=?`, f.a.AccountID) != 0 || signupCount(t, f.s, `SELECT count(*) FROM overage_preferences`) != 0 { |
| + t.Fatal("out-of-range chain consumed") |
| + } |
| +} |
diff --git a/internal/console/store.go b/internal/console/store.go| index 564ad7591..57118b659 100644 |
| --- a/internal/console/store.go |
| +++ b/internal/console/store.go |
| @@ -135,7 +135,8 @@ func setConsoleSQLitePragmas(q url.Values) { |
| // Close releases the underlying database handle. |
| func (s *Store) Close() error { return s.db.Close() } |
| -// migrate is additive-only (CREATE TABLE IF NOT EXISTS); future columns go |
| +// migrate is additive-only except for the apps primary-key and immutable |
| +// overage-receipt WITHOUT ROWID rebuilds; future columns go |
| // in the ALTER list, whose "duplicate column" error is swallowed so the list |
| // stays idempotent. |
| func (s *Store) migrate() error { |
| @@ -437,7 +438,10 @@ func (s *Store) migrate() error { |
| if err := s.migrateBilling(); err != nil { |
| return err |
| } |
| - return s.migrateOveragePreference() |
| + if err := s.migrateOveragePreference(); err != nil { |
| + return err |
| + } |
| + return s.migrateSignupRecords() |
| } |
| // migrateAppsKey moves the apps table from PRIMARY KEY(app) — a GLOBAL name |
| @@ -816,6 +820,13 @@ func (s *Store) CreateAccount(id int64, sqid, name, ownerAddress string, now tim |
| return err |
| } |
| defer tx.Rollback() |
| + if err := createAccountTx(tx, id, sqid, name, ownerAddress, now); err != nil { |
| + return err |
| + } |
| + return tx.Commit() |
| +} |
| + |
| +func createAccountTx(tx *sql.Tx, id int64, sqid, name, ownerAddress string, now time.Time) error { |
| if _, err := lockMembershipWritesTx(tx); err != nil { |
| return err |
| } |
| @@ -832,7 +843,7 @@ func (s *Store) CreateAccount(id int64, sqid, name, ownerAddress string, now tim |
| id, ownerAddress, RoleOwner, now.Unix()); err != nil { |
| return err |
| } |
| - return tx.Commit() |
| + return nil |
| } |
| // EnsureAccount records an account the console did NOT mint — one that already |