diff --git a/pkg/core/config/upgrades.go b/pkg/core/config/upgrades.go index 1cf6b71b..27eaa5fc 100644 --- a/pkg/core/config/upgrades.go +++ b/pkg/core/config/upgrades.go @@ -47,6 +47,22 @@ type UpgradeSchedule struct { // activation point: the migration replay seeds a cid for every track that // exists. ContentAuthEnforcementHeight int64 + + // ContentAuthStrictHeight closes the first-assertion window that + // ContentAuthEnforcementHeight opens. Between the two heights a cid nobody + // holds goes to whoever asserts it first; from this height a Track + // create/update may only assert cids the content-auth state already + // records as the acting user's, and a cid nobody holds is refused. + // + // The window exists for the genesis migration: the API flusher replays + // old-chain track writes naming cids nobody attested (old-chain storage + // never did, and the replay seeds only the snapshot's tracks) and stalls + // on the first refused row. It is not safe to leave open — the upload row + // is public while it transcodes and the attestation is visible in the + // mempool before it lands, so anyone who reads a cid there can take the + // claim by naming it first. Schedule strict as soon as the flusher has + // drained; never before, or it stalls. + ContentAuthStrictHeight int64 } // Rules is the resolved rule set for a single height: a flat description of @@ -54,6 +70,8 @@ type UpgradeSchedule struct { type Rules struct { AuthEnforced bool ContentAuthEnforced bool + // ContentAuthStrict only means anything with ContentAuthEnforced. + ContentAuthStrict bool } // RulesetAt resolves the rules governing the given block height. A nil @@ -65,6 +83,7 @@ func (u *UpgradeSchedule) RulesetAt(height int64) Rules { return Rules{ AuthEnforced: activeAt(u.AuthEnforcementHeight, height), ContentAuthEnforced: activeAt(u.ContentAuthEnforcementHeight, height), + ContentAuthStrict: activeAt(u.ContentAuthStrictHeight, height), } } @@ -82,11 +101,13 @@ var upgradeSchedules = map[string]*UpgradeSchedule{ "openaudio-devnet": { AuthEnforcementHeight: 1, ContentAuthEnforcementHeight: 1, + ContentAuthStrictHeight: 1, }, // sandbox "audius-devnet": { AuthEnforcementHeight: 1, ContentAuthEnforcementHeight: 1, + ContentAuthStrictHeight: 1, }, // stage "audius-testnet-alpha": {}, diff --git a/pkg/core/config/upgrades_test.go b/pkg/core/config/upgrades_test.go index 952689ba..5ac18fa6 100644 --- a/pkg/core/config/upgrades_test.go +++ b/pkg/core/config/upgrades_test.go @@ -26,6 +26,26 @@ func TestRulesetAtZeroMeansNever(t *testing.T) { } } +// Strict content auth is a second height over the same gate: enforcement +// alone opens the first-assertion window, strict closes it, and a schedule +// that activates both at one height never opens it. +func TestContentAuthStrictCloseWindow(t *testing.T) { + u := &UpgradeSchedule{ContentAuthEnforcementHeight: 10, ContentAuthStrictHeight: 20} + if r := u.RulesetAt(9); r.ContentAuthEnforced || r.ContentAuthStrict { + t.Fatal("nothing should be active before enforcement") + } + if r := u.RulesetAt(15); !r.ContentAuthEnforced || r.ContentAuthStrict { + t.Fatal("between the heights the window is open: enforced, not strict") + } + if r := u.RulesetAt(20); !r.ContentAuthEnforced || !r.ContentAuthStrict { + t.Fatal("at the strict height the window is closed") + } + same := &UpgradeSchedule{ContentAuthEnforcementHeight: 1, ContentAuthStrictHeight: 1} + if r := same.RulesetAt(1); !r.ContentAuthEnforced || !r.ContentAuthStrict { + t.Fatal("equal heights must be strict from the first enforced block") + } +} + // A nil schedule (tests, manually built configs) resolves to no active // upgrades instead of panicking. func TestRulesetAtNilSchedule(t *testing.T) { @@ -40,9 +60,13 @@ func TestRulesetAtNilSchedule(t *testing.T) { // must never inherit another network's activations. func TestScheduleForChainID(t *testing.T) { for _, chainID := range []string{"openaudio-devnet", "audius-devnet"} { - if !ScheduleForChainID(chainID).RulesetAt(1).AuthEnforced { + r := ScheduleForChainID(chainID).RulesetAt(1) + if !r.AuthEnforced { t.Fatalf("%s: auth enforcement should be active from height 1", chainID) } + if !r.ContentAuthEnforced || !r.ContentAuthStrict { + t.Fatalf("%s: content auth should be strict from height 1, got %+v", chainID, r) + } } for _, chainID := range []string{"audius-testnet-alpha", "audius-mainnet-alpha-beta", "some-future-chain"} { if ScheduleForChainID(chainID).RulesetAt(1<<40) != (Rules{}) { diff --git a/pkg/core/server/abci.go b/pkg/core/server/abci.go index ec25c616..2c0bf5e1 100644 --- a/pkg/core/server/abci.go +++ b/pkg/core/server/abci.go @@ -1332,7 +1332,7 @@ func (s *Server) finalizeTransaction(ctx context.Context, req *abcitypes.Finaliz case *v1.SignedTransaction_Plays: return s.finalizePlayTransaction(ctx, msg) case *v1.SignedTransaction_ManageEntity: - return s.finalizeManageEntity(ctx, msg) + return s.finalizeManageEntity(ctx, msg, blockHeight) case *v1.SignedTransaction_ManageEntityMigration: return s.finalizeManageEntityMigration(ctx, msg) case *v1.SignedTransaction_Attestation: diff --git a/pkg/core/server/auth_enforcement.go b/pkg/core/server/auth_enforcement.go index 678ee848..3a3a90e8 100644 --- a/pkg/core/server/auth_enforcement.go +++ b/pkg/core/server/auth_enforcement.go @@ -43,7 +43,7 @@ func (s *Server) validateManageEntityAuth(ctx context.Context, rules config.Rule // cid it has no claim to is rejected without leaving the entity behind in // the overlay for later transactions in the proposal to build on. if rules.ContentAuthEnforced { - if err := validateTrackContentAuth(ctx, overlay, tx); err != nil { + if err := validateTrackContentAuth(ctx, overlay, tx, rules); err != nil { if isAuthValidationError(err) { return &authRejectionError{reason: err.Error()} } @@ -51,7 +51,7 @@ func (s *Server) validateManageEntityAuth(ctx context.Context, rules config.Rule } } - err = applyAuthProjection(ctx, overlay, tx) + err = applyAuthProjection(ctx, overlay, tx, rules) if err == nil { return nil } diff --git a/pkg/core/server/auth_state.go b/pkg/core/server/auth_state.go index d6d94467..015d702a 100644 --- a/pkg/core/server/auth_state.go +++ b/pkg/core/server/auth_state.go @@ -8,6 +8,7 @@ import ( "strings" v1 "github.com/OpenAudio/go-openaudio/pkg/api/core/v1" + "github.com/OpenAudio/go-openaudio/pkg/core/config" "github.com/OpenAudio/go-openaudio/pkg/core/db" ) @@ -286,7 +287,11 @@ func validateAuthSigner(ctx context.Context, st authReader, userID int64, signer // is not auth-tracked at all — the projection has no opinion there. An // authValidationError is the projection mirroring an ETL rejection; any other // error is a store failure. -func applyAuthProjection(ctx context.Context, st authStore, tx authTx) error { +// +// rules is the ruleset resolved for the height the transaction executes at. +// The projection branches on it only for the first-assertion cid claim +// (content_auth_state.go); migration rows ignore it. +func applyAuthProjection(ctx context.Context, st authStore, tx authTx, rules config.Rules) error { entityType, ok := canonicalAuthEntityType(tx.EntityType) if !ok { return nil @@ -310,7 +315,21 @@ func applyAuthProjection(ctx context.Context, st authStore, tx authTx) error { // and cids left unseeded there make enforcement unactivatable. Runs // only after the entity itself projects: a track that was skipped must // not leave claims behind. - return projectMigratedTrackCids(ctx, st, tx) + if err := projectMigratedTrackCids(ctx, st, tx); err != nil { + return err + } + return projectAssertedTrackCids(ctx, st, tx, rules) + case entityType == authEntityTypeTrack && action == authActionUpdate: + // Track updates are otherwise untracked (ownership on update is the + // ETL's rule); the only projection is the first-assertion claim, and + // it requires the same signer authority a create does. + if tx.Migration || !rules.ContentAuthEnforced || rules.ContentAuthStrict { + return nil + } + if err := validateAuthSigner(ctx, st, tx.UserID, tx.Signer); err != nil { + return err + } + return projectAssertedTrackCids(ctx, st, tx, rules) case (entityType == authEntityTypeTrack || entityType == authEntityTypePlaylist) && action == authActionDelete: return projectEntityDelete(ctx, st, tx, entityType) case entityType == authEntityTypeGrant && action == authActionCreate: @@ -629,7 +648,8 @@ func projectAppDelete(ctx context.Context, st authStore, tx authTx) error { // it, with reason describing why. A migration replays state the source system // already accepted, so any skip is a defect the caller should surface. func ProjectMigrationAuthState(ctx context.Context, q *db.Queries, me *v1.ManageEntityLegacyMigration) (skipped bool, reason string, err error) { - err = applyAuthProjection(ctx, &dbAuthStore{q: q}, authTxFromManageEntityMigration(me)) + // A migration row's projection does not branch on the ruleset. + err = applyAuthProjection(ctx, &dbAuthStore{q: q}, authTxFromManageEntityMigration(me), config.Rules{}) switch { case err == nil: return false, "", nil diff --git a/pkg/core/server/auth_state_test.go b/pkg/core/server/auth_state_test.go index 7a6801e4..6b3a8c76 100644 --- a/pkg/core/server/auth_state_test.go +++ b/pkg/core/server/auth_state_test.go @@ -6,6 +6,18 @@ import ( "testing" v1 "github.com/OpenAudio/go-openaudio/pkg/api/core/v1" + "github.com/OpenAudio/go-openaudio/pkg/core/config" +) + +// Rulesets the projection tests run under. Most tests do not care and use +// the zero ruleset (pre-gate); the content-auth tests pick a side of the +// first-assertion window explicitly. +var ( + // windowRules: content auth enforced, not yet strict — an unclaimed cid + // goes to the first asserter. + windowRules = config.Rules{AuthEnforced: true, ContentAuthEnforced: true} + // strictRules: the steady state — every cid must already be claimed. + strictRules = config.Rules{AuthEnforced: true, ContentAuthEnforced: true, ContentAuthStrict: true} ) // memAuthStore is a map-backed authStore so projection logic tests run @@ -186,14 +198,19 @@ func (m *memAuthStore) SetEntityDeleted(_ context.Context, entityType string, en func mustProject(t *testing.T, st authStore, tx authTx) { t.Helper() - if err := applyAuthProjection(context.Background(), st, tx); err != nil { + mustProjectUnder(t, st, tx, config.Rules{}) +} + +func mustProjectUnder(t *testing.T, st authStore, tx authTx, rules config.Rules) { + t.Helper() + if err := applyAuthProjection(context.Background(), st, tx, rules); err != nil { t.Fatalf("expected projection to apply: %v", err) } } func mustSkip(t *testing.T, st authStore, tx authTx, wantReason string) { t.Helper() - err := applyAuthProjection(context.Background(), st, tx) + err := applyAuthProjection(context.Background(), st, tx, config.Rules{}) if err == nil { t.Fatalf("expected projection to skip (%s), but it applied", wantReason) } @@ -396,7 +413,7 @@ func TestProjectUntrackedPairIsNil(t *testing.T) { st := newMemAuthStore() if err := applyAuthProjection(context.Background(), st, authTx{ UserID: 1, EntityType: "Follow", Action: "Create", Signer: "0xw1", - }); err != nil { + }, config.Rules{}); err != nil { t.Fatalf("untracked pair must project to nil, got %v", err) } } diff --git a/pkg/core/server/content_auth_state.go b/pkg/core/server/content_auth_state.go index 2aea43ea..2d712b3f 100644 --- a/pkg/core/server/content_auth_state.go +++ b/pkg/core/server/content_auth_state.go @@ -5,6 +5,7 @@ import ( "strings" v1 "github.com/OpenAudio/go-openaudio/pkg/api/core/v1" + "github.com/OpenAudio/go-openaudio/pkg/core/config" ) // Consensus-side content authorization (core_auth_cids): which user may @@ -91,13 +92,26 @@ func projectMigratedTrackCids(ctx context.Context, st authStore, tx authTx) erro } // validateTrackContentAuth is the enforcement check, active only under -// Rules.ContentAuthEnforced: every cid the transaction asserts must be recorded -// to a wallet authorized to act for the writing user. +// Rules.ContentAuthEnforced: no cid the transaction asserts may be held by a +// user other than the writing user, and once Rules.ContentAuthStrict is +// active every cid must be held by the writing user. +// +// Between the two heights a cid nobody holds passes, and +// the projection then records it to the writer (projectAssertedTrackCids): +// first assertion wins. That window exists for the genesis migration, whose +// API flusher replays old-chain track writes naming cids nobody attested and +// stalls on the first refused row. It is not safe to leave open: the upload +// row is public while it transcodes and the attestation is visible in the +// mempool before it lands, so whoever reads a cid there can take the claim by +// naming it first. Strict closes it once the flusher drains, and from then on +// every cid a track may legitimately name is already claimed — migrated +// tracks are seeded by the replay and uploads to an enforcing node are +// attested before the upload reads done. // // Only cids present in this transaction are checked, so metadata-only edits on // a track whose audio predates the projection keep working, and an audio // replacement is checked against the new cid alone. -func validateTrackContentAuth(ctx context.Context, st authReader, tx authTx) error { +func validateTrackContentAuth(ctx context.Context, st authReader, tx authTx, rules config.Rules) error { if tx.Migration || tx.EntityType != authEntityTypeTrack { return nil } @@ -130,10 +144,50 @@ func validateTrackContentAuth(ctx context.Context, st authReader, tx authTx) err if err != nil { return err } - if !known { + if known { + return authValidationErrorf("%s %q was not uploaded for user %d", key, cid, tx.UserID) + } + // Only reached under ContentAuthEnforced, so strict alone decides. + if rules.ContentAuthStrict { return authValidationErrorf("%s %q is not attested to any uploader", key, cid) } - return authValidationErrorf("%s %q was not uploaded for user %d", key, cid, tx.UserID) + } + return nil +} + +// projectAssertedTrackCids records the writing user as claimant of every cid +// the transaction names that nobody holds yet — the projection half of +// first-assertion-wins (see validateTrackContentAuth). A cid someone already +// holds is left alone: at proposal time validation has rejected the write, +// and at finalize a block from a proposer without the gate must not hand a +// claim to whoever named the cid. +// +// Runs only for live writes inside the first-assertion window: content auth +// enforced but not yet strict. Unlike validation this runs for every live +// write, so the enforced check is explicit — before the gate a chain must +// accumulate no claims from live traffic, or activating enforcement later +// would trust state built from unverified assertions. Under strict there is +// nothing unclaimed to record. Migration rows are seeded by +// projectMigratedTrackCids. +func projectAssertedTrackCids(ctx context.Context, st authStore, tx authTx, rules config.Rules) error { + if tx.Migration || !rules.ContentAuthEnforced || rules.ContentAuthStrict || tx.EntityType != authEntityTypeTrack { + return nil + } + for _, key := range trackCidMetadataKeys { + cid := tx.metaString(key) + if cid == "" { + continue + } + known, err := st.CidIsClaimed(ctx, cid) + if err != nil { + return err + } + if known { + continue + } + if err := st.InsertCid(ctx, cid, tx.UserID, ""); err != nil { + return err + } } return nil } diff --git a/pkg/core/server/content_auth_state_test.go b/pkg/core/server/content_auth_state_test.go index 63f3ea78..e804d6f7 100644 --- a/pkg/core/server/content_auth_state_test.go +++ b/pkg/core/server/content_auth_state_test.go @@ -6,6 +6,7 @@ import ( "testing" v1 "github.com/OpenAudio/go-openaudio/pkg/api/core/v1" + "github.com/OpenAudio/go-openaudio/pkg/core/config" ) func trackCidTx(userID, trackID int64, signer, action string, cids map[string]any) authTx { @@ -32,16 +33,28 @@ func attestationFor(userID int64, validator string, cids ...string) *v1.ContentA } } +// The default helpers check under strict rules, the steady state; the +// first-assertion tests pass windowRules explicitly. func mustContentAuth(t *testing.T, st authReader, tx authTx) { t.Helper() - if err := validateTrackContentAuth(context.Background(), st, tx); err != nil { + mustContentAuthUnder(t, st, tx, strictRules) +} + +func mustContentAuthUnder(t *testing.T, st authReader, tx authTx, rules config.Rules) { + t.Helper() + if err := validateTrackContentAuth(context.Background(), st, tx, rules); err != nil { t.Fatalf("expected content auth to pass: %v", err) } } func mustRejectContentAuth(t *testing.T, st authReader, tx authTx, wantReason string) { t.Helper() - err := validateTrackContentAuth(context.Background(), st, tx) + mustRejectContentAuthUnder(t, st, tx, strictRules, wantReason) +} + +func mustRejectContentAuthUnder(t *testing.T, st authReader, tx authTx, rules config.Rules, wantReason string) { + t.Helper() + err := validateTrackContentAuth(context.Background(), st, tx, rules) if err == nil { t.Fatalf("expected content auth rejection (%s), but it passed", wantReason) } @@ -140,13 +153,101 @@ func TestContentAuthRejectsClaimingAnotherUsersCid(t *testing.T) { mustRejectContentAuth(t, st, decoy, "was not uploaded for user 2") } -// A cid nobody has attested cannot be asserted at all. -func TestContentAuthRejectsUnattestedCid(t *testing.T) { +// A cid nobody holds goes to whoever names it first: the write passes and the +// projection records the writer as claimant, after which it is theirs alone. +// This is what lets audio that reached storage without an attestation — an +// upload to a node before it ran the gate, or a multipart upload naming no +// user — still be published by its creator. +func TestContentAuthUnattestedCidGoesToFirstAsserter(t *testing.T) { + ctx := context.Background() + st := newMemAuthStore() + mustProject(t, st, userCreateTx(1, "0xartist", "artist")) + mustProject(t, st, userCreateTx(2, "0xthief", "thief")) + + first := trackCidTx(1, 2_000_001, "0xartist", "Create", map[string]any{"track_cid": "nobody-attested-this"}) + mustContentAuthUnder(t, st, first, windowRules) + mustProjectUnder(t, st, first, windowRules) + if ok, _ := st.IsCidClaimedByUser(ctx, "nobody-attested-this", 1); !ok { + t.Fatal("the first asserter must hold the claim") + } + + second := trackCidTx(2, 2_000_002, "0xthief", "Create", map[string]any{"track_cid": "nobody-attested-this"}) + mustRejectContentAuthUnder(t, st, second, windowRules, "was not uploaded for user 2") + + edit := trackCidTx(1, 2_000_001, "0xartist", "Update", map[string]any{"track_cid": "nobody-attested-this"}) + mustContentAuthUnder(t, st, edit, windowRules) +} + +// Under strict content auth the window is closed: a cid nobody holds is +// refused, and a write naming one leaves no claim behind. +func TestStrictContentAuthRejectsUnattestedCid(t *testing.T) { + ctx := context.Background() st := newMemAuthStore() mustProject(t, st, userCreateTx(1, "0xartist", "artist")) tx := trackCidTx(1, 2_000_001, "0xartist", "Create", map[string]any{"track_cid": "nobody-attested-this"}) - mustRejectContentAuth(t, st, tx, "is not attested to any uploader") + mustRejectContentAuthUnder(t, st, tx, strictRules, "is not attested to any uploader") + mustProjectUnder(t, st, tx, strictRules) // finalize projects the entity; the claim must not follow + if ok, _ := st.CidIsClaimed(ctx, "nobody-attested-this"); ok { + t.Fatal("a strict-mode write must not leave a claim behind") + } +} + +// The projection never hands out a claim someone already holds, so a block +// built by a proposer without the gate cannot transfer a cid by naming it. +func TestAssertedClaimNeverOverridesAnExistingOne(t *testing.T) { + ctx := context.Background() + st := newMemAuthStore() + mustProject(t, st, userCreateTx(1, "0xartist", "artist")) + mustProject(t, st, userCreateTx(2, "0xthief", "thief")) + if err := projectContentAttestationCids(ctx, st, attestationFor(1, "0xv1", "victim-320"), "tx1"); err != nil { + t.Fatal(err) + } + + decoy := trackCidTx(2, 2_000_002, "0xthief", "Create", map[string]any{"track_cid": "victim-320"}) + mustRejectContentAuthUnder(t, st, decoy, windowRules, "was not uploaded for user 2") + mustProjectUnder(t, st, decoy, windowRules) // as finalize would apply it from an ungated proposer + if ok, _ := st.IsCidClaimedByUser(ctx, "victim-320", 2); ok { + t.Fatal("naming a held cid must not grant a claim") + } +} + +// Before the gate a chain must accumulate no claims from live traffic, or +// activating enforcement later would trust state built from unverified +// assertions. The zero ruleset is pre-gate. +func TestPreGateLiveTrackClaimsNothing(t *testing.T) { + st := newMemAuthStore() + mustProject(t, st, userCreateTx(1, "0xartist", "artist")) + + create := trackCidTx(1, 2_000_001, "0xartist", "Create", map[string]any{"track_cid": "pre-gate"}) + mustProject(t, st, create) + update := trackCidTx(1, 2_000_001, "0xartist", "Update", map[string]any{"track_cid": "pre-gate-edit"}) + mustProject(t, st, update) + if len(st.cids) != 0 { + t.Fatalf("live writes before the gate must claim nothing, got %v", st.cids) + } +} + +// An update claims on the same terms as a create: only when the signer is +// authorized for the track's user, so a stranger's update cannot claim. +func TestUpdateClaimsUnattestedCidOnlyForAuthorizedSigner(t *testing.T) { + ctx := context.Background() + st := newMemAuthStore() + mustProject(t, st, userCreateTx(1, "0xartist", "artist")) + + stranger := trackCidTx(1, 2_000_001, "0xstranger", "Update", map[string]any{"track_cid": "new-audio"}) + if err := applyAuthProjection(ctx, st, stranger, windowRules); err == nil || !isAuthValidationError(err) { + t.Fatalf("expected a signer rejection, got %v", err) + } + if ok, _ := st.IsCidClaimedByUser(ctx, "new-audio", 1); ok { + t.Fatal("an unauthorized update must not claim") + } + + owner := trackCidTx(1, 2_000_001, "0xartist", "Update", map[string]any{"track_cid": "new-audio"}) + mustProjectUnder(t, st, owner, windowRules) + if ok, _ := st.IsCidClaimedByUser(ctx, "new-audio", 1); !ok { + t.Fatal("the owner's update must claim the cid") + } } // orig_file_cid is the download path — the lossless master — so it is checked @@ -404,11 +505,14 @@ func TestProjectContentAttestationCidsAccumulateAcrossAttestations(t *testing.T) if err := projectContentAttestationCids(ctx, st, attestation("0xval", "origcid", "320cid"), "tx1"); err != nil { t.Fatal(err) } - tx := trackCidTx(1, 10, "0xup", "Create", map[string]any{"track_cid": "320cid", "preview_cid": "latepreview"}) - mustRejectContentAuth(t, st, tx, "not attested to any uploader") - if err := projectContentAttestationCids(ctx, st, attestation("0xval", "latepreview"), "tx2"); err != nil { t.Fatal(err) } - mustContentAuth(t, st, tx) + if ok, _ := st.IsCidClaimedByUser(ctx, "320cid", 1); !ok { + t.Fatal("the earlier attestation's claim must survive the later one") + } + // Both attestations credit user 1, so the late preview is theirs to name + // and nobody else's. + mustContentAuth(t, st, trackCidTx(1, 10, "0xup", "Create", map[string]any{"track_cid": "320cid", "preview_cid": "latepreview"})) + mustRejectContentAuth(t, st, trackCidTx(2, 11, "0xother", "Create", map[string]any{"preview_cid": "latepreview"}), "was not uploaded for user 2") } diff --git a/pkg/core/server/manage_entity.go b/pkg/core/server/manage_entity.go index 24418cc8..f4ff484a 100644 --- a/pkg/core/server/manage_entity.go +++ b/pkg/core/server/manage_entity.go @@ -9,6 +9,7 @@ import ( "strings" v1 "github.com/OpenAudio/go-openaudio/pkg/api/core/v1" + "github.com/OpenAudio/go-openaudio/pkg/core/config" "github.com/OpenAudio/go-openaudio/pkg/core/db" "github.com/jackc/pgx/v5/pgtype" "go.uber.org/zap" @@ -48,7 +49,8 @@ func (s *Server) finalizeManageEntityMigration(ctx context.Context, stx *v1.Sign } } - s.projectManageEntityAuthState(ctx, authTxFromManageEntityMigration(me)) + // A migration row's projection does not branch on the ruleset. + s.projectManageEntityAuthState(ctx, authTxFromManageEntityMigration(me), config.Rules{}) return me, nil } @@ -62,7 +64,7 @@ type trackMetadata struct { } `json:"data"` } -func (s *Server) finalizeManageEntity(ctx context.Context, stx *v1.SignedTransaction) (proto.Message, error) { +func (s *Server) finalizeManageEntity(ctx context.Context, stx *v1.SignedTransaction, blockHeight int64) (proto.Message, error) { tx, err := s.validateManageEntity(ctx, stx) if err != nil { return nil, fmt.Errorf("invalid manage entity: %v", err) @@ -81,7 +83,7 @@ func (s *Server) finalizeManageEntity(ctx context.Context, stx *v1.SignedTransac } } - s.projectManageEntityAuthState(ctx, authTxFromManageEntity(manageEntity)) + s.projectManageEntityAuthState(ctx, authTxFromManageEntity(manageEntity), s.config.Upgrades.RulesetAt(blockHeight)) return manageEntity, nil } @@ -92,9 +94,9 @@ func (s *Server) finalizeManageEntity(ctx context.Context, stx *v1.SignedTransac // expected; store errors are logged without failing the tx, matching how the // rest of FinalizeBlock treats db errors so a local infra hiccup cannot make // this node's tx results diverge from its peers'. -func (s *Server) projectManageEntityAuthState(ctx context.Context, tx authTx) { +func (s *Server) projectManageEntityAuthState(ctx context.Context, tx authTx, rules config.Rules) { store := &dbAuthStore{q: s.getDb()} - err := applyAuthProjection(ctx, store, tx) + err := applyAuthProjection(ctx, store, tx, rules) switch { case err == nil: case isAuthValidationError(err): diff --git a/pkg/integration_tests/14_content_auth_test.go b/pkg/integration_tests/14_content_auth_test.go index 2f44b7ae..42c42f32 100644 --- a/pkg/integration_tests/14_content_auth_test.go +++ b/pkg/integration_tests/14_content_auth_test.go @@ -91,6 +91,9 @@ func TestContentAuth(t *testing.T) { }) t.Run("UnattestedCidIsRejected", func(t *testing.T) { + // The devnet schedules strict content auth from height 1, so the + // first-assertion window the genesis migration relies on is never + // open here; pkg/core/server's unit tests cover it. _, err := sendManageEntity(ctx, chainNode, owner, "Track", nextTrackID(), "Create", trackMetadata(owner, "Never uploaded", map[string]any{ "track_cid": "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG",