diff --git a/CHANGELOG.md b/CHANGELOG.md index 33d5904..c9212a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ # Changelog +## v0.1.0-preview.26 — 2026-09-05 + +- Add optional self-profile readers and revision-checked username/display-name + writes. Recheck the active session, account and expected revision atomically + with a secret-free audit; preserve immutable user identity and ownership. +- Username edits revoke other sessions but preserve the acting session. A + password-confirmed write can require the exact verified credential hash, + rejecting a concurrent password reset. Applications own reauthentication, + operation-bound passkey approval, CSRF/origin checks and rate/concurrency limits. +- Add explicit SQLite schema 11 for monotonic profile revisions. Existing rows + begin at revision 1; startup with migrations disabled rejects older schemas. + Do not run older writers against schema 11 as a database rollback strategy. +- Email changes are deliberately absent; pending-address verification and mail + delivery are separate work. Test invalid/restricted sessions, collisions, + concurrent/stale edits, audit rollback, restart and schema-10 migration. + ## v0.1.0-preview.25 — 2026-09-05 - Add optional, credential-free user and organization directory readers for diff --git a/README.md b/README.md index f32401d..118a567 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ router, handlers, HTML, authorization decisions, cache behavior, and deployment. Adopt one boundary at a time; Go compiles and links only the packages you import. -> **Public preview:** `v0.1.0-preview.25`. APIs may change before a stable +> **Public preview:** `v0.1.0-preview.26`. APIs may change before a stable > release. Linux is the maintained release platform. ## Why Web Foundations? @@ -57,14 +57,14 @@ owns—and, just as importantly, what remains application policy. Pin the preview in an application module: ```bash -go get gamertan.com/web@v0.1.0-preview.25 +go get gamertan.com/web@v0.1.0-preview.26 go mod verify ``` An application may name the first package it intends to adopt: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.25 +go get gamertan.com/web/requestmeta@v0.1.0-preview.26 ``` The version belongs to the `gamertan.com/web` module. See the diff --git a/auth/profile.go b/auth/profile.go new file mode 100644 index 0000000..23e34f9 --- /dev/null +++ b/auth/profile.go @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MPL-2.0 + +package auth + +import ( + "context" + "errors" + "strings" + "time" + "unicode" + "unicode/utf8" +) + +var ( + ErrProfileInput = errors.New("auth: invalid profile change") + ErrProfileAccess = errors.New("auth: profile session is unavailable") + ErrProfileConflict = errors.New("auth: profile changed; reload before editing") + ErrUsernameUnavailable = errors.New("auth: username is unavailable") +) + +// OwnProfile contains mutable identity, not credentials or organization roles. +// Revision is independent of timestamps and increases for every profile edit. +type OwnProfile struct { + UserID, Username, Email, DisplayName string + Revision int64 +} + +// ProfileEdit is a trusted repository command, not an HTTP input model. The +// application must authenticate the session, validate CSRF/origin and rate-limit +// mutations. Username edits additionally require recent reauthentication (and +// any account-specific MFA). For password reauthentication, supply the verified +// hash so a concurrent password reset invalidates the write. After verified +// passkey approval, leave it empty. Do not log or serialize this command. +type ProfileEdit struct { + UserID string + SessionDigest [32]byte + ExpectedRevision int64 + Field, Value string + ExpectedPasswordHash string +} + +// NormalizeProfileValue validates only supported fields. Email is deliberately +// absent: verified mailbox changes need a separate pending/confirmation flow. +func NormalizeProfileValue(field, value string) (string, error) { + value = strings.TrimSpace(value) + switch field { + case "username": + if !identifierPattern.MatchString(value) { + return "", ErrProfileInput + } + case "display_name": + if value == "" || len(value) > 128 || !utf8.ValidString(value) { + return "", ErrProfileInput + } + for _, r := range value { + if unicode.IsControl(r) { + return "", ErrProfileInput + } + } + default: + return "", ErrProfileInput + } + return value, nil +} + +// OwnProfileRepository is optional; no change to the authentication Repository +// interface is required. It derives access from the current session, never from +// a site-wide administrator flag. Implementations atomically recheck identity, +// session and revision, mutate one field, and append the audit. Username edits +// revoke other sessions but preserve the acting session. They never reassign +// stable IDs, memberships, passkeys, billing identities or historical records. +type OwnProfileRepository interface { + OwnProfile(context.Context, [32]byte, time.Time) (OwnProfile, error) + UpdateOwnProfile(context.Context, ProfileEdit, AuditEvent) (OwnProfile, error) +} diff --git a/auth/profile_test.go b/auth/profile_test.go new file mode 100644 index 0000000..c5363c4 --- /dev/null +++ b/auth/profile_test.go @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MPL-2.0 + +package auth + +import "testing" + +func TestNormalizeProfileValue(t *testing.T) { + for _, value := range []struct{ field, value, want string }{ + {"username", " Reader.One ", "Reader.One"}, {"display_name", " Émilie ★ ", "Émilie ★"}, + } { + got, err := NormalizeProfileValue(value.field, value.value) + if err != nil || got != value.want { + t.Fatalf("normalization: %q %v", got, err) + } + } + for _, value := range []struct{ field, value string }{ + {"email", "new@example.test"}, {"role", "owner"}, {"username", "a"}, {"username", "foo@bar"}, + {"display_name", ""}, {"display_name", "hello\x00world"}, {"display_name", "hello\nworld"}, {"display_name", string([]byte{0xff})}, + } { + if _, err := NormalizeProfileValue(value.field, value.value); err == nil { + t.Fatalf("invalid field accepted: %s", value.field) + } + } +} + +func FuzzProfileValue(f *testing.F) { + f.Add("username", "reader.one") + f.Add("display_name", "Émilie") + f.Add("email", "a@example.test") + f.Fuzz(func(t *testing.T, field, value string) { + normal, err := NormalizeProfileValue(field, value) + if err != nil { + return + } + if len(normal) == 0 || len(normal) > 128 { + t.Fatal("unbounded value") + } + again, err := NormalizeProfileValue(field, normal) + if err != nil || again != normal { + t.Fatal("unstable normalization") + } + }) +} diff --git a/authsqlite/profile.go b/authsqlite/profile.go new file mode 100644 index 0000000..b1ad970 --- /dev/null +++ b/authsqlite/profile.go @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: MPL-2.0 + +package authsqlite + +import ( + "context" + "database/sql" + "errors" + "math" + "time" + + "gamertan.com/web/auth" + "modernc.org/sqlite" +) + +var _ auth.OwnProfileRepository = (*Store)(nil) + +const ownProfileQuery = `SELECT u.id,u.username,u.email,u.display_name,u.profile_revision + FROM gwf_users u JOIN gwf_auth_sessions s ON s.user_id=u.id + WHERE s.token_hash=? AND s.expires_at>? AND u.status='active' + AND u.registration_pending=0 AND u.password_change_required=0` + +func scanOwnProfile(row interface{ Scan(...any) error }) (auth.OwnProfile, error) { + var profile auth.OwnProfile + err := row.Scan(&profile.UserID, &profile.Username, &profile.Email, &profile.DisplayName, &profile.Revision) + if errors.Is(err, sql.ErrNoRows) { + return auth.OwnProfile{}, auth.ErrProfileAccess + } + return profile, err +} + +func (store *Store) OwnProfile(ctx context.Context, session [32]byte, now time.Time) (auth.OwnProfile, error) { + if zeroDigest(session) || now.IsZero() { + return auth.OwnProfile{}, auth.ErrProfileAccess + } + return scanOwnProfile(store.db.QueryRowContext(ctx, ownProfileQuery, session[:], now.Unix())) +} + +func (store *Store) UpdateOwnProfile(ctx context.Context, change auth.ProfileEdit, audit auth.AuditEvent) (auth.OwnProfile, error) { + value, err := auth.NormalizeProfileValue(change.Field, change.Value) + if err != nil || !opaqueID(change.UserID) || zeroDigest(change.SessionDigest) || change.ExpectedRevision < 1 || change.ExpectedRevision == math.MaxInt64 || + !validAuditEvent(audit) || audit.ActorUserID != change.UserID || audit.ResourceType != "user" || audit.ResourceID != change.UserID || audit.Action != "auth.profile."+change.Field || + change.ExpectedPasswordHash != "" && (change.Field != "username" || len(change.ExpectedPasswordHash) > 1024) { + return auth.OwnProfile{}, auth.ErrProfileInput + } + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return auth.OwnProfile{}, err + } + defer tx.Rollback() + // This first statement takes the writer lock and tests the real current + // session/account/revision together. No read-before-write lock upgrade race. + set := `display_name=?` + args := []any{value} + if change.Field == "username" { + set = `username=?,username_normalized=?` + args = append(args, normalize(value)) + } + args = append(args, audit.CreatedAt.Unix(), change.UserID, change.ExpectedRevision, change.SessionDigest[:], audit.CreatedAt.Unix(), change.ExpectedPasswordHash, change.ExpectedPasswordHash) + result, err := tx.ExecContext(ctx, `UPDATE gwf_users SET `+set+`,profile_revision=profile_revision+1,updated_at=MAX(updated_at,?) + WHERE id=? AND profile_revision=? AND status='active' AND registration_pending=0 AND password_change_required=0 + AND EXISTS (SELECT 1 FROM gwf_auth_sessions WHERE user_id=gwf_users.id AND token_hash=? AND expires_at>?) + AND (?='' OR EXISTS (SELECT 1 FROM gwf_password_credentials WHERE user_id=gwf_users.id AND password_hash=?))`, args...) + if err != nil { + var constraint *sqlite.Error + if errors.As(err, &constraint) && constraint.Code() == 2067 { + return auth.OwnProfile{}, auth.ErrUsernameUnavailable + } + return auth.OwnProfile{}, err + } + changed, err := result.RowsAffected() + if err != nil { + return auth.OwnProfile{}, err + } + if changed != 1 { + profile, err := scanOwnProfile(tx.QueryRowContext(ctx, ownProfileQuery, change.SessionDigest[:], audit.CreatedAt.Unix())) + if err != nil { + return auth.OwnProfile{}, err + } + if profile.UserID != change.UserID { + return auth.OwnProfile{}, auth.ErrProfileAccess + } + return auth.OwnProfile{}, auth.ErrProfileConflict + } + if change.Field == "username" { + if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE user_id=? AND token_hash<>?`, change.UserID, change.SessionDigest[:]); err != nil { + return auth.OwnProfile{}, err + } + } + if err = appendAudit(ctx, tx, audit); err != nil { + return auth.OwnProfile{}, err + } + profile, err := scanOwnProfile(tx.QueryRowContext(ctx, ownProfileQuery, change.SessionDigest[:], audit.CreatedAt.Unix())) + if err != nil { + return auth.OwnProfile{}, err + } + if err = tx.Commit(); err != nil { + return auth.OwnProfile{}, err + } + return profile, nil +} diff --git a/authsqlite/profile_test.go b/authsqlite/profile_test.go new file mode 100644 index 0000000..f4de25b --- /dev/null +++ b/authsqlite/profile_test.go @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: MPL-2.0 + +package authsqlite + +import ( + "crypto/sha256" + "errors" + "path/filepath" + "sync" + "testing" + "time" + + "gamertan.com/web/auth" +) + +type profileFixture struct { + store *Store + path string + now time.Time + user auth.User + session, other auth.Session +} + +func newProfileFixture(t *testing.T) profileFixture { + t.Helper() + path := filepath.Join(t.TempDir(), "identity.sqlite") + store, err := Open(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { store.Close() }) + now := time.Now().UTC().Truncate(time.Second) + user := auth.User{ID: "profile-user", Username: "profile.reader", Email: "profile@example.test", DisplayName: "Profile Reader", Status: "active", CreatedAt: now, UpdatedAt: now} + if err = store.CreateUser(t.Context(), user, "fixture-hash"); err != nil { + t.Fatal(err) + } + session := auth.Session{UserID: user.ID, Digest: sha256.Sum256([]byte("acting-session")), CreatedAt: now, LastSeenAt: now, ExpiresAt: now.Add(time.Hour)} + other := session + other.Digest = sha256.Sum256([]byte("other-session")) + for _, s := range []auth.Session{session, other} { + if err = store.CreateSession(t.Context(), s); err != nil { + t.Fatal(err) + } + } + return profileFixture{store, path, now, user, session, other} +} + +func (f profileFixture) change(field, value string, revision int64) (auth.ProfileEdit, auth.AuditEvent) { + return auth.ProfileEdit{UserID: f.user.ID, SessionDigest: f.session.Digest, ExpectedRevision: revision, Field: field, Value: value}, + auth.AuditEvent{ID: "profile-audit-" + field, ActorUserID: f.user.ID, Action: "auth.profile." + field, ResourceType: "user", ResourceID: f.user.ID, Summary: "Own profile field changed", CreatedAt: f.now} +} + +func TestOwnProfileStableIdentityAndSessionPolicy(t *testing.T) { + f := newProfileFixture(t) + initial, err := f.store.OwnProfile(t.Context(), f.session.Digest, f.now) + if err != nil || initial.Revision != 1 { + t.Fatalf("initial revision: %d %v", initial.Revision, err) + } + change, audit := f.change("display_name", " Émilie ★ ", 1) + updated, err := f.store.UpdateOwnProfile(t.Context(), change, audit) + if err != nil || updated.DisplayName != "Émilie ★" || updated.Revision != 2 || updated.UserID != initial.UserID || updated.Username != initial.Username || updated.Email != initial.Email { + t.Fatalf("display update: %+v %v", updated, err) + } + if _, err = f.store.OwnProfile(t.Context(), f.other.Digest, f.now); err != nil { + t.Fatal("display edit revoked session", err) + } + if _, err = f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrProfileConflict) { + t.Fatalf("stale: %v", err) + } + change, audit = f.change("username", "new.reader", 2) + change.ExpectedPasswordHash = "fixture-hash" + updated, err = f.store.UpdateOwnProfile(t.Context(), change, audit) + if err != nil || updated.Username != "new.reader" || updated.Revision != 3 || updated.UserID != initial.UserID || updated.Email != initial.Email { + t.Fatalf("username update: %+v %v", updated, err) + } + if _, err = f.store.OwnProfile(t.Context(), f.other.Digest, f.now); !errors.Is(err, auth.ErrProfileAccess) { + t.Fatalf("other session survived: %v", err) + } + user, hash, err := f.store.CredentialByIdentifier(t.Context(), "NEW.READER") + if err != nil || user.ID != initial.UserID || hash != "fixture-hash" { + t.Fatal("credential identity changed", err) + } + var count int + if err = f.store.db.QueryRow(`SELECT count(*) FROM gwf_audit_events WHERE actor_user_id=? AND resource_id=?`, f.user.ID, f.user.ID).Scan(&count); err != nil || count != 2 { + t.Fatalf("audits: %d %v", count, err) + } + reopened, err := OpenWithOptions(f.path, OpenOptions{Migrate: false}) + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + if recovered, err := reopened.OwnProfile(t.Context(), f.session.Digest, f.now); err != nil || recovered != updated { + t.Fatalf("restart: %+v %v", recovered, err) + } +} + +func TestOwnProfileAuthorizationAndRollback(t *testing.T) { + for _, test := range []struct{ name, sql string }{ + {"revoked-session", `DELETE FROM gwf_auth_sessions`}, + {"expired-session", `UPDATE gwf_auth_sessions SET expires_at=1`}, + {"suspended", `UPDATE gwf_users SET status='suspended'`}, + {"disabled", `UPDATE gwf_users SET status='disabled'`}, + {"registration-pending", `UPDATE gwf_users SET registration_pending=1`}, + {"password-change", `UPDATE gwf_users SET password_change_required=1`}, + } { + t.Run(test.name, func(t *testing.T) { + f := newProfileFixture(t) + if _, err := f.store.db.Exec(test.sql); err != nil { + t.Fatal(err) + } + change, audit := f.change("display_name", "not allowed", 1) + if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrProfileAccess) { + t.Fatalf("access: %v", err) + } + }) + } + f := newProfileFixture(t) + change, audit := f.change("username", "new.reader", 1) + change.UserID = "another-user" + audit.ActorUserID = change.UserID + audit.ResourceID = change.UserID + if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrProfileAccess) { + t.Fatalf("foreign user: %v", err) + } + change, audit = f.change("username", "new.reader", 1) + change.ExpectedPasswordHash = "old-verified-hash" + if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrProfileConflict) { + t.Fatalf("changed password: %v", err) + } + change.ExpectedPasswordHash = "fixture-hash" + if err := f.store.AppendAudit(t.Context(), audit); err != nil { + t.Fatal(err) + } + if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); err == nil { + t.Fatal("duplicate audit accepted") + } + if profile, err := f.store.OwnProfile(t.Context(), f.session.Digest, f.now); err != nil || profile.Revision != 1 || profile.Username != f.user.Username { + t.Fatalf("rollback: %+v %v", profile, err) + } + if _, err := f.store.OwnProfile(t.Context(), f.other.Digest, f.now); err != nil { + t.Fatal("audit failure revoked session", err) + } +} + +func TestOwnProfileUniquenessConcurrencyAndMigration(t *testing.T) { + f := newProfileFixture(t) + otherUser := f.user + otherUser.ID = "another-user" + otherUser.Username = "another.reader" + otherUser.Email = "another@example.test" + if err := f.store.CreateUser(t.Context(), otherUser, "fixture-hash"); err != nil { + t.Fatal(err) + } + change, audit := f.change("username", "ANOTHER.READER", 1) + if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrUsernameUnavailable) { + t.Fatalf("unique name: %v", err) + } + second, err := OpenWithOptions(f.path, OpenOptions{Migrate: false}) + if err != nil { + t.Fatal(err) + } + defer second.Close() + var wg sync.WaitGroup + results := make(chan error, 2) + for _, store := range []*Store{f.store, second} { + wg.Add(1) + go func(store *Store) { + defer wg.Done() + change, audit := f.change("display_name", "New Name", 1) + _, err := store.UpdateOwnProfile(t.Context(), change, audit) + results <- err + }(store) + } + wg.Wait() + close(results) + success, conflict := 0, 0 + for err := range results { + if err == nil { + success++ + } else if errors.Is(err, auth.ErrProfileConflict) { + conflict++ + } else { + t.Fatal(err) + } + } + if success != 1 || conflict != 1 { + t.Fatalf("concurrent writes: %d successes, %d conflicts", success, conflict) + } + // Recreate the actual previous schema without rewriting its identity rows. + if _, err = f.store.db.Exec(`ALTER TABLE gwf_users DROP COLUMN profile_revision`); err != nil { + t.Fatal(err) + } + if _, err = f.store.db.Exec(`DELETE FROM gamertan_web_migrations WHERE version=11`); err != nil { + t.Fatal(err) + } + if version, err := f.store.CurrentSchema(t.Context()); err != nil || version != 10 { + t.Fatalf("prior schema: %d %v", version, err) + } + if err = f.store.RequireCurrentSchema(t.Context()); err == nil { + t.Fatal("startup accepted old schema") + } + if err = f.store.Migrate(t.Context()); err != nil { + t.Fatal(err) + } + profile, err := f.store.OwnProfile(t.Context(), f.session.Digest, f.now) + if err != nil || profile.UserID != f.user.ID || profile.Email != f.user.Email || profile.DisplayName != "New Name" || profile.Revision != 1 { + t.Fatalf("migration: %+v %v", profile, err) + } + if err = f.store.Migrate(t.Context()); err != nil { + t.Fatal("idempotent migration", err) + } +} diff --git a/authsqlite/role_sets_test.go b/authsqlite/role_sets_test.go index d65d37e..fbf71ee 100644 --- a/authsqlite/role_sets_test.go +++ b/authsqlite/role_sets_test.go @@ -351,7 +351,8 @@ func TestRoleInvitationMigrationPreservesLegacyAndRequiresExplicitMigration(t *t // Reconstruct the previous invitation schema in this disposable database. if _, err = f.store.db.Exec(`ALTER TABLE gwf_organization_invitations DROP COLUMN direct_roles_json; ALTER TABLE gwf_organization_invitations DROP COLUMN required_owner_role; - DELETE FROM gamertan_web_migrations WHERE version=10`); err != nil { + ALTER TABLE gwf_users DROP COLUMN profile_revision; + DELETE FROM gamertan_web_migrations WHERE version>=10`); err != nil { t.Fatal(err) } if err = f.store.RequireCurrentSchema(t.Context()); err == nil { diff --git a/authsqlite/store.go b/authsqlite/store.go index 86172d2..18ced5e 100644 --- a/authsqlite/store.go +++ b/authsqlite/store.go @@ -77,7 +77,7 @@ func OpenWithOptions(path string, options OpenOptions) (*Store, error) { return store, nil } -const SchemaVersion = 10 +const SchemaVersion = 11 func (store *Store) CurrentSchema(ctx context.Context) (int, error) { var exists int @@ -179,6 +179,7 @@ func (store *Store) Migrate(ctx context.Context) error { table, column, definition string }{ {"gwf_users", "registration_pending", `INTEGER NOT NULL DEFAULT 0 CHECK(registration_pending IN (0,1))`}, + {"gwf_users", "profile_revision", `INTEGER NOT NULL DEFAULT 1 CHECK(profile_revision > 0)`}, {"gwf_organizations", "status", `TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived'))`}, {"gwf_organizations", "revision", `INTEGER NOT NULL DEFAULT 1 CHECK(revision > 0)`}, {"gwf_organizations", "updated_at", `INTEGER NOT NULL DEFAULT 0`}, @@ -244,6 +245,9 @@ func (store *Store) Migrate(ctx context.Context) error { if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(10,?)`, time.Now().UTC().Unix()); err != nil { return err } + if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(11,?)`, time.Now().UTC().Unix()); err != nil { + return err + } return tx.Commit() } diff --git a/docs/ADOPTION.md b/docs/ADOPTION.md index b9bc72c..a8ea42c 100644 --- a/docs/ADOPTION.md +++ b/docs/ADOPTION.md @@ -21,3 +21,26 @@ template. Its private evidence, persistent bans, account data, route policy, operator exclusions, synchronization, and publishing workflow remain application-owned. Useful pressure from that migration may improve a general interface, but it may not smuggle EQL-specific policy into this module. + +## Optional personal-profile editing + +`auth.OwnProfileRepository` supports a narrow self-service boundary independently +of instance-directory authorization. Load the profile using the current session +digest; derive the target from that result. Normalize one username or display +name using `auth.NormalizeProfileValue`. Never decode an HTTP body directly into +`auth.ProfileEdit`, which carries trusted identity and credential-check state. + +Require CSRF/origin validation for browser writes and rate-limit credential work. +For username changes, verify the current password (supply its hash as +`ExpectedPasswordHash`) or consume an exact operation-bound passkey approval; +enforce any additional authentication policy your application requires. Include +the session, user, value and expected profile revision in the passkey binding. +The SQLite transaction rechecks session/account/revision and any verified hash, +updates one field, revokes other sessions for username edits, and appends audit. +Do not log the command or include secret material in its audit. + +Schema 11 adds `profile_revision` without changing stable identity keys. Run an +explicit migration before starting an adopter with automatic migration disabled. +Keep the pre-migration backup; adjacent older binaries are not approved writers +for the migrated schema. Email-change enrollment/confirmation is not implemented +by this interface and must not be simulated with an unverified direct update. diff --git a/docs/DOGFOOD.md b/docs/DOGFOOD.md index b05ac6a..8dc2546 100644 --- a/docs/DOGFOOD.md +++ b/docs/DOGFOOD.md @@ -8,6 +8,18 @@ application concern belongs in the shared module. ## Gamertan accounts and commerce +- Personal identity editing is not instance administration. `OwnProfileRepository` + derives self-access from the active session; `ProfileEdit` is a trusted internal + command, never a browser request model. SQLite schema 11 adds a monotonic + revision because timestamps alone cannot distinguish two edits in one second. + Session/account/revision checks, mutation and audit share one write transaction. + Username edits invalidate other sessions without changing immutable IDs, + memberships, credentials, orders or provider billing identities. A password + proof binds the verified hash into that transaction; passkey proofs must bind + the exact user/session/field/value/revision before calling it. The application + chooses account-specific reauthentication and owns its credential-work limits. + Email requires a separate verified change protocol, not another accepted field. + - Instance operators need all-user/all-organization directories, not a staff roster or implicit membership in every business. Optional bounded readers now expose identity/profile records without credentials, independent of membership. @@ -47,8 +59,8 @@ application concern belongs in the shared module. policy; there is no new database schema or commerce dependency in Foundations. - The account email remains required and unique. Gamertan uses normalized - email as the canonical login identifier and keeps username as a stable public - identity. Until a mail package exists, the application must not describe an + email as the canonical login identifier; the immutable user ID, not the editable + username, owns account relationships. Until a mail package exists, it must not describe an address as verified merely because it was entered during registration. - Password authentication is sufficient for an ordinary customer base session. Privileged application actions use an exact operation binding with diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index d9db543..7155440 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -26,7 +26,7 @@ The packages are ordinary Go imports. Pin the current preview and verify its module checksum: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.25 +go get gamertan.com/web/requestmeta@v0.1.0-preview.26 go mod verify ``` diff --git a/scripts/public-snapshot.allow b/scripts/public-snapshot.allow index 1f9e8d4..461963c 100644 --- a/scripts/public-snapshot.allow +++ b/scripts/public-snapshot.allow @@ -30,6 +30,8 @@ analytics/geo.go auth/auth.go auth/context.go auth/directory.go +auth/profile.go +auth/profile_test.go auth/password.go auth/password_test.go authrecovery/recovery.go @@ -44,6 +46,8 @@ authsqlite/store.go authsqlite/store_test.go authsqlite/directory.go authsqlite/directory_test.go +authsqlite/profile.go +authsqlite/profile_test.go authsqlite/account.go authsqlite/account_test.go authsqlite/access.go