From 7a87d6b9bf25c20a096ac6c327a18748f23a08fd Mon Sep 17 00:00:00 2001 From: Bretton May Date: Mon, 3 Nov 2025 22:10:56 -0800 Subject: [PATCH] feat(lexicon): migrate vote to feed namespace and apply atProto best practices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit migrates the vote lexicon to align with atProto conventions and fixes several pre-existing bugs discovered during the migration. ## Main Changes 1. **Namespace Migration**: social.coves.interaction.vote → social.coves.feed.vote - Follows Bluesky's pattern (app.bsky.feed.like) - All feed interactions now in consistent namespace - Updated all code references, tests, and Jetstream consumers 2. **atProto Best Practices** (per https://github.com/bluesky-social/atproto/discussions/4245): - Changed `enum` to `knownValues` for future extensibility - Use standard `com.atproto.repo.strongRef` instead of custom definition - Enhanced description to mention authentication requirement 3. **Added Core atProto Schemas**: - com.atproto.repo.strongRef.json - com.atproto.label.defs.json - Required for lexicon validation, standard practice for Go projects ## Bug Fixes 1. **Foreign Key Constraint Mismatch** (013_create_votes_table.sql): - REMOVED FK constraint on voter_did → users(did) - Code comments stated FK was removed, but migration still had it - Tests expected no FK for out-of-order Jetstream indexing - Now consistent: votes can be indexed before users 2. **Invalid Test Data** (tests/lexicon-test-data/feed/vote-valid.json): - Missing required `direction` field - `subject` was string instead of strongRef object - Now valid: includes direction, proper strongRef with uri+cid ## Files Changed **Lexicon & Test Data:** - Moved: internal/atproto/lexicon/social/coves/{interaction → feed}/vote.json - Moved: tests/lexicon-test-data/{interaction → feed}/vote-valid.json - Added: internal/atproto/lexicon/com/atproto/repo/strongRef.json - Added: internal/atproto/lexicon/com/atproto/label/defs.json **Code (10 files updated):** - internal/validation/lexicon.go - internal/validation/lexicon_test.go - internal/atproto/jetstream/vote_consumer.go - cmd/server/main.go (Jetstream URL) - internal/db/postgres/vote_repo_test.go (12 test URIs) - internal/db/migrations/013_create_votes_table.sql ## Tests ✅ All vote repository tests passing (11 tests) ✅ Validation tests passing with new lexicon path ✅ TestVoteRepo_Create_VoterNotFound passing (validates FK removal) ✅ Lexicon schema validation passing ✅ No regressions introduced 🤖 Generated with Claude Code Co-Authored-By: Claude --- cmd/server/main.go | 4 +- internal/atproto/jetstream/vote_consumer.go | 10 +- .../lexicon/com/atproto/label/defs.json | 156 ++++++++++++++++++ .../lexicon/com/atproto/repo/strongRef.json | 15 ++ .../coves/{interaction => feed}/vote.json | 25 +-- .../db/migrations/013_create_votes_table.sql | 13 +- internal/db/postgres/vote_repo_test.go | 24 +-- internal/validation/lexicon.go | 2 +- internal/validation/lexicon_test.go | 6 +- tests/lexicon-test-data/feed/vote-valid.json | 9 + .../interaction/vote-valid.json | 5 - 11 files changed, 215 insertions(+), 54 deletions(-) create mode 100644 internal/atproto/lexicon/com/atproto/label/defs.json create mode 100644 internal/atproto/lexicon/com/atproto/repo/strongRef.json rename internal/atproto/lexicon/social/coves/{interaction => feed}/vote.json (52%) create mode 100644 tests/lexicon-test-data/feed/vote-valid.json delete mode 100644 tests/lexicon-test-data/interaction/vote-valid.json diff --git a/cmd/server/main.go b/cmd/server/main.go index 15e3e21..2b6c5a2 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -353,7 +353,7 @@ func main() { voteJetstreamURL := os.Getenv("VOTE_JETSTREAM_URL") if voteJetstreamURL == "" { // Listen to vote record CREATE/DELETE events from user repositories - voteJetstreamURL = "ws://localhost:6008/subscribe?wantedCollections=social.coves.interaction.vote" + voteJetstreamURL = "ws://localhost:6008/subscribe?wantedCollections=social.coves.feed.vote" } voteEventConsumer := jetstream.NewVoteEventConsumer(voteRepo, userService, db) @@ -366,7 +366,7 @@ func main() { }() log.Printf("Started Jetstream vote consumer: %s", voteJetstreamURL) - log.Println(" - Indexing: social.coves.interaction.vote CREATE/DELETE operations") + log.Println(" - Indexing: social.coves.feed.vote CREATE/DELETE operations") log.Println(" - Updating: Post vote counts atomically") // Register XRPC routes diff --git a/internal/atproto/jetstream/vote_consumer.go b/internal/atproto/jetstream/vote_consumer.go index fbb8ac8..2996b27 100644 --- a/internal/atproto/jetstream/vote_consumer.go +++ b/internal/atproto/jetstream/vote_consumer.go @@ -12,7 +12,7 @@ import ( ) // VoteEventConsumer consumes vote-related events from Jetstream -// Handles CREATE and DELETE operations for social.coves.interaction.vote +// Handles CREATE and DELETE operations for social.coves.feed.vote type VoteEventConsumer struct { voteRepo votes.Repository userService users.UserService @@ -42,7 +42,7 @@ func (c *VoteEventConsumer) HandleEvent(ctx context.Context, event *JetstreamEve commit := event.Commit // Handle vote record operations - if commit.Collection == "social.coves.interaction.vote" { + if commit.Collection == "social.coves.feed.vote" { switch commit.Operation { case "create": return c.createVote(ctx, event.Did, commit) @@ -74,8 +74,8 @@ func (c *VoteEventConsumer) createVote(ctx context.Context, repoDID string, comm } // Build AT-URI for this vote - // Format: at://voter_did/social.coves.interaction.vote/rkey - uri := fmt.Sprintf("at://%s/social.coves.interaction.vote/%s", repoDID, commit.RKey) + // Format: at://voter_did/social.coves.feed.vote/rkey + uri := fmt.Sprintf("at://%s/social.coves.feed.vote/%s", repoDID, commit.RKey) // Parse timestamp from record createdAt, err := time.Parse(time.RFC3339, voteRecord.CreatedAt) @@ -109,7 +109,7 @@ func (c *VoteEventConsumer) createVote(ctx context.Context, repoDID string, comm // deleteVote soft-deletes a vote and updates post counts func (c *VoteEventConsumer) deleteVote(ctx context.Context, repoDID string, commit *CommitEvent) error { // Build AT-URI for the vote being deleted - uri := fmt.Sprintf("at://%s/social.coves.interaction.vote/%s", repoDID, commit.RKey) + uri := fmt.Sprintf("at://%s/social.coves.feed.vote/%s", repoDID, commit.RKey) // Get existing vote to know its direction (for decrementing the right counter) existingVote, err := c.voteRepo.GetByURI(ctx, uri) diff --git a/internal/atproto/lexicon/com/atproto/label/defs.json b/internal/atproto/lexicon/com/atproto/label/defs.json new file mode 100644 index 0000000..6f4c1ab --- /dev/null +++ b/internal/atproto/lexicon/com/atproto/label/defs.json @@ -0,0 +1,156 @@ +{ + "lexicon": 1, + "id": "com.atproto.label.defs", + "defs": { + "label": { + "type": "object", + "description": "Metadata tag on an atproto resource (eg, repo or record).", + "required": ["src", "uri", "val", "cts"], + "properties": { + "ver": { + "type": "integer", + "description": "The AT Protocol version of the label object." + }, + "src": { + "type": "string", + "format": "did", + "description": "DID of the actor who created this label." + }, + "uri": { + "type": "string", + "format": "uri", + "description": "AT URI of the record, repository (account), or other resource that this label applies to." + }, + "cid": { + "type": "string", + "format": "cid", + "description": "Optionally, CID specifying the specific version of 'uri' resource this label applies to." + }, + "val": { + "type": "string", + "maxLength": 128, + "description": "The short string name of the value or type of this label." + }, + "neg": { + "type": "boolean", + "description": "If true, this is a negation label, overwriting a previous label." + }, + "cts": { + "type": "string", + "format": "datetime", + "description": "Timestamp when this label was created." + }, + "exp": { + "type": "string", + "format": "datetime", + "description": "Timestamp at which this label expires (no longer applies)." + }, + "sig": { + "type": "bytes", + "description": "Signature of dag-cbor encoded label." + } + } + }, + "selfLabels": { + "type": "object", + "description": "Metadata tags on an atproto record, published by the author within the record.", + "required": ["values"], + "properties": { + "values": { + "type": "array", + "items": { "type": "ref", "ref": "#selfLabel" }, + "maxLength": 10 + } + } + }, + "selfLabel": { + "type": "object", + "description": "Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel.", + "required": ["val"], + "properties": { + "val": { + "type": "string", + "maxLength": 128, + "description": "The short string name of the value or type of this label." + } + } + }, + "labelValueDefinition": { + "type": "object", + "description": "Declares a label value and its expected interpretations and behaviors.", + "required": ["identifier", "severity", "blurs", "locales"], + "properties": { + "identifier": { + "type": "string", + "description": "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).", + "maxLength": 100, + "maxGraphemes": 100 + }, + "severity": { + "type": "string", + "description": "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.", + "knownValues": ["inform", "alert", "none"] + }, + "blurs": { + "type": "string", + "description": "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.", + "knownValues": ["content", "media", "none"] + }, + "defaultSetting": { + "type": "string", + "description": "The default setting for this label.", + "knownValues": ["ignore", "warn", "hide"], + "default": "warn" + }, + "adultOnly": { + "type": "boolean", + "description": "Does the user need to have adult content enabled in order to configure this label?" + }, + "locales": { + "type": "array", + "items": { "type": "ref", "ref": "#labelValueDefinitionStrings" } + } + } + }, + "labelValueDefinitionStrings": { + "type": "object", + "description": "Strings which describe the label in the UI, localized into a specific language.", + "required": ["lang", "name", "description"], + "properties": { + "lang": { + "type": "string", + "description": "The code of the language these strings are written in.", + "format": "language" + }, + "name": { + "type": "string", + "description": "A short human-readable name for the label.", + "maxGraphemes": 64, + "maxLength": 640 + }, + "description": { + "type": "string", + "description": "A longer description of what the label means and why it might be applied.", + "maxGraphemes": 10000, + "maxLength": 100000 + } + } + }, + "labelValue": { + "type": "string", + "knownValues": [ + "!hide", + "!no-promote", + "!warn", + "!no-unauthenticated", + "dmca-violation", + "doxxing", + "porn", + "sexual", + "nudity", + "nsfl", + "gore" + ] + } + } +} diff --git a/internal/atproto/lexicon/com/atproto/repo/strongRef.json b/internal/atproto/lexicon/com/atproto/repo/strongRef.json new file mode 100644 index 0000000..cb79625 --- /dev/null +++ b/internal/atproto/lexicon/com/atproto/repo/strongRef.json @@ -0,0 +1,15 @@ +{ + "lexicon": 1, + "id": "com.atproto.repo.strongRef", + "description": "A URI with a content-hash fingerprint.", + "defs": { + "main": { + "type": "object", + "required": ["uri", "cid"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" } + } + } + } +} diff --git a/internal/atproto/lexicon/social/coves/interaction/vote.json b/internal/atproto/lexicon/social/coves/feed/vote.json similarity index 52% rename from internal/atproto/lexicon/social/coves/interaction/vote.json rename to internal/atproto/lexicon/social/coves/feed/vote.json index 94a4c5a..d683942 100644 --- a/internal/atproto/lexicon/social/coves/interaction/vote.json +++ b/internal/atproto/lexicon/social/coves/feed/vote.json @@ -1,10 +1,10 @@ { "lexicon": 1, - "id": "social.coves.interaction.vote", + "id": "social.coves.feed.vote", "defs": { "main": { "type": "record", - "description": "A vote (upvote or downvote) on a post or comment", + "description": "Record declaring a vote (upvote or downvote) on a post or comment. Requires authentication.", "key": "tid", "record": { "type": "object", @@ -12,12 +12,12 @@ "properties": { "subject": { "type": "ref", - "ref": "#strongRef", + "ref": "com.atproto.repo.strongRef", "description": "Strong reference to the post or comment being voted on" }, "direction": { "type": "string", - "enum": ["up", "down"], + "knownValues": ["up", "down"], "description": "Vote direction: up for upvote, down for downvote" }, "createdAt": { @@ -27,23 +27,6 @@ } } } - }, - "strongRef": { - "type": "object", - "description": "Strong reference to a record (AT-URI + CID)", - "required": ["uri", "cid"], - "properties": { - "uri": { - "type": "string", - "format": "at-uri", - "description": "AT-URI of the record" - }, - "cid": { - "type": "string", - "format": "cid", - "description": "CID of the record content" - } - } } } } \ No newline at end of file diff --git a/internal/db/migrations/013_create_votes_table.sql b/internal/db/migrations/013_create_votes_table.sql index 2b95914..5b13499 100644 --- a/internal/db/migrations/013_create_votes_table.sql +++ b/internal/db/migrations/013_create_votes_table.sql @@ -3,7 +3,7 @@ -- Votes are indexed from the firehose after being written to user repositories CREATE TABLE votes ( id BIGSERIAL PRIMARY KEY, - uri TEXT UNIQUE NOT NULL, -- AT-URI (at://voter_did/social.coves.interaction.vote/rkey) + uri TEXT UNIQUE NOT NULL, -- AT-URI (at://voter_did/social.coves.feed.vote/rkey) cid TEXT NOT NULL, -- Content ID rkey TEXT NOT NULL, -- Record key (TID) voter_did TEXT NOT NULL, -- User who voted (from AT-URI repo field) @@ -18,10 +18,13 @@ CREATE TABLE votes ( -- Timestamps created_at TIMESTAMPTZ NOT NULL, -- Voter's timestamp from record indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- When indexed by AppView - deleted_at TIMESTAMPTZ, -- Soft delete (for firehose delete events) + deleted_at TIMESTAMPTZ -- Soft delete (for firehose delete events) - -- Foreign keys - CONSTRAINT fk_voter FOREIGN KEY (voter_did) REFERENCES users(did) ON DELETE CASCADE + -- NO foreign key constraint on voter_did to allow out-of-order indexing from Jetstream + -- Vote events may arrive before user events, which is acceptable since: + -- 1. Votes are authenticated by the user's PDS (security maintained) + -- 2. Orphaned votes from never-indexed users are harmless + -- 3. This prevents race conditions in the firehose consumer ); -- Indexes for common query patterns @@ -35,7 +38,7 @@ CREATE INDEX idx_votes_voter ON votes(voter_did, created_at DESC); -- Comment on table COMMENT ON TABLE votes IS 'Votes indexed from user repositories via Jetstream firehose consumer'; -COMMENT ON COLUMN votes.uri IS 'AT-URI in format: at://voter_did/social.coves.interaction.vote/rkey'; +COMMENT ON COLUMN votes.uri IS 'AT-URI in format: at://voter_did/social.coves.feed.vote/rkey'; COMMENT ON COLUMN votes.subject_uri IS 'Strong reference to post/comment being voted on'; COMMENT ON INDEX unique_voter_subject_active IS 'Ensures one active vote per user per subject (soft delete aware)'; diff --git a/internal/db/postgres/vote_repo_test.go b/internal/db/postgres/vote_repo_test.go index 5367219..69d2249 100644 --- a/internal/db/postgres/vote_repo_test.go +++ b/internal/db/postgres/vote_repo_test.go @@ -63,7 +63,7 @@ func TestVoteRepo_Create(t *testing.T) { createTestUser(t, db, "testvoter123.test", voterDID) vote := &votes.Vote{ - URI: "at://did:plc:testvoter123/social.coves.interaction.vote/3k1234567890", + URI: "at://did:plc:testvoter123/social.coves.feed.vote/3k1234567890", CID: "bafyreigtest123", RKey: "3k1234567890", VoterDID: voterDID, @@ -91,7 +91,7 @@ func TestVoteRepo_Create_Idempotent(t *testing.T) { createTestUser(t, db, "testvoter456.test", voterDID) vote := &votes.Vote{ - URI: "at://did:plc:testvoter456/social.coves.interaction.vote/3k9876543210", + URI: "at://did:plc:testvoter456/social.coves.feed.vote/3k9876543210", CID: "bafyreigtest456", RKey: "3k9876543210", VoterDID: voterDID, @@ -132,7 +132,7 @@ func TestVoteRepo_Create_VoterNotFound(t *testing.T) { // Don't create test user - vote should still be created (FK removed) // This allows votes to be indexed before users in Jetstream vote := &votes.Vote{ - URI: "at://did:plc:nonexistentvoter/social.coves.interaction.vote/3k1111111111", + URI: "at://did:plc:nonexistentvoter/social.coves.feed.vote/3k1111111111", CID: "bafyreignovoter", RKey: "3k1111111111", VoterDID: "did:plc:nonexistentvoter", @@ -164,7 +164,7 @@ func TestVoteRepo_GetByURI(t *testing.T) { // Create vote vote := &votes.Vote{ - URI: "at://did:plc:testvoter789/social.coves.interaction.vote/3k5555555555", + URI: "at://did:plc:testvoter789/social.coves.feed.vote/3k5555555555", CID: "bafyreigtest789", RKey: "3k5555555555", VoterDID: voterDID, @@ -192,7 +192,7 @@ func TestVoteRepo_GetByURI_NotFound(t *testing.T) { repo := NewVoteRepository(db) ctx := context.Background() - _, err := repo.GetByURI(ctx, "at://did:plc:nonexistent/social.coves.interaction.vote/nope") + _, err := repo.GetByURI(ctx, "at://did:plc:nonexistent/social.coves.feed.vote/nope") assert.ErrorIs(t, err, votes.ErrVoteNotFound) } @@ -211,7 +211,7 @@ func TestVoteRepo_GetByVoterAndSubject(t *testing.T) { // Create vote vote := &votes.Vote{ - URI: "at://did:plc:testvoter999/social.coves.interaction.vote/3k6666666666", + URI: "at://did:plc:testvoter999/social.coves.feed.vote/3k6666666666", CID: "bafyreigtest999", RKey: "3k6666666666", VoterDID: voterDID, @@ -255,7 +255,7 @@ func TestVoteRepo_Delete(t *testing.T) { // Create vote vote := &votes.Vote{ - URI: "at://did:plc:testvoterdelete/social.coves.interaction.vote/3k7777777777", + URI: "at://did:plc:testvoterdelete/social.coves.feed.vote/3k7777777777", CID: "bafyreigdelete", RKey: "3k7777777777", VoterDID: voterDID, @@ -293,7 +293,7 @@ func TestVoteRepo_Delete_Idempotent(t *testing.T) { createTestUser(t, db, "testvoterdelete2.test", voterDID) vote := &votes.Vote{ - URI: "at://did:plc:testvoterdelete2/social.coves.interaction.vote/3k8888888888", + URI: "at://did:plc:testvoterdelete2/social.coves.feed.vote/3k8888888888", CID: "bafyreigdelete2", RKey: "3k8888888888", VoterDID: voterDID, @@ -331,7 +331,7 @@ func TestVoteRepo_ListBySubject(t *testing.T) { // Create multiple votes on same subject vote1 := &votes.Vote{ - URI: "at://did:plc:testvoterlist1/social.coves.interaction.vote/3k9999999991", + URI: "at://did:plc:testvoterlist1/social.coves.feed.vote/3k9999999991", CID: "bafyreiglist1", RKey: "3k9999999991", VoterDID: voterDID1, @@ -341,7 +341,7 @@ func TestVoteRepo_ListBySubject(t *testing.T) { CreatedAt: time.Now(), } vote2 := &votes.Vote{ - URI: "at://did:plc:testvoterlist2/social.coves.interaction.vote/3k9999999992", + URI: "at://did:plc:testvoterlist2/social.coves.feed.vote/3k9999999992", CID: "bafyreiglist2", RKey: "3k9999999992", VoterDID: voterDID2, @@ -373,7 +373,7 @@ func TestVoteRepo_ListByVoter(t *testing.T) { // Create multiple votes by same voter vote1 := &votes.Vote{ - URI: "at://did:plc:testvoterlistvoter/social.coves.interaction.vote/3k0000000001", + URI: "at://did:plc:testvoterlistvoter/social.coves.feed.vote/3k0000000001", CID: "bafyreigvoter1", RKey: "3k0000000001", VoterDID: voterDID, @@ -383,7 +383,7 @@ func TestVoteRepo_ListByVoter(t *testing.T) { CreatedAt: time.Now(), } vote2 := &votes.Vote{ - URI: "at://did:plc:testvoterlistvoter/social.coves.interaction.vote/3k0000000002", + URI: "at://did:plc:testvoterlistvoter/social.coves.feed.vote/3k0000000002", CID: "bafyreigvoter2", RKey: "3k0000000002", VoterDID: voterDID, diff --git a/internal/validation/lexicon.go b/internal/validation/lexicon.go index c5d035f..89639d6 100644 --- a/internal/validation/lexicon.go +++ b/internal/validation/lexicon.go @@ -91,7 +91,7 @@ func (v *LexiconValidator) ValidateComment(comment map[string]interface{}) error // ValidateVote validates a vote record func (v *LexiconValidator) ValidateVote(vote map[string]interface{}) error { - return v.ValidateRecord(vote, "social.coves.interaction.vote") + return v.ValidateRecord(vote, "social.coves.feed.vote") } // ValidateModerationAction validates a moderation action (ban, tribunalVote, etc.) diff --git a/internal/validation/lexicon_test.go b/internal/validation/lexicon_test.go index 54c5f78..d1dc3db 100644 --- a/internal/validation/lexicon_test.go +++ b/internal/validation/lexicon_test.go @@ -93,7 +93,7 @@ func TestValidateRecordWithDifferentInputTypes(t *testing.T) { // Test with JSON string jsonString := `{ - "$type": "social.coves.interaction.vote", + "$type": "social.coves.feed.vote", "subject": { "uri": "at://did:plc:test/social.coves.community.post/abc123", "cid": "bafyreigj3fwnwjuzr35k2kuzmb5dixxczrzjhqkr5srlqplsh6gq3bj3si" @@ -102,13 +102,13 @@ func TestValidateRecordWithDifferentInputTypes(t *testing.T) { "createdAt": "2024-01-01T00:00:00Z" }` - if err := validator.ValidateRecord(jsonString, "social.coves.interaction.vote"); err != nil { + if err := validator.ValidateRecord(jsonString, "social.coves.feed.vote"); err != nil { t.Errorf("Failed to validate JSON string: %v", err) } // Test with JSON bytes jsonBytes := []byte(jsonString) - if err := validator.ValidateRecord(jsonBytes, "social.coves.interaction.vote"); err != nil { + if err := validator.ValidateRecord(jsonBytes, "social.coves.feed.vote"); err != nil { t.Errorf("Failed to validate JSON bytes: %v", err) } } diff --git a/tests/lexicon-test-data/feed/vote-valid.json b/tests/lexicon-test-data/feed/vote-valid.json new file mode 100644 index 0000000..84e8a72 --- /dev/null +++ b/tests/lexicon-test-data/feed/vote-valid.json @@ -0,0 +1,9 @@ +{ + "$type": "social.coves.feed.vote", + "subject": { + "uri": "at://did:plc:alice123/social.coves.community.post/3kbx2n5p", + "cid": "bafyreigj3fwnwjuzr35k2kuzmb5dixxczrzjhqkr5srlqplsh6gq3bj3si" + }, + "direction": "up", + "createdAt": "2025-01-09T15:00:00Z" +} \ No newline at end of file diff --git a/tests/lexicon-test-data/interaction/vote-valid.json b/tests/lexicon-test-data/interaction/vote-valid.json deleted file mode 100644 index 6822be6..0000000 --- a/tests/lexicon-test-data/interaction/vote-valid.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$type": "social.coves.interaction.vote", - "subject": "at://did:plc:alice123/social.coves.post.text/3kbx2n5p", - "createdAt": "2025-01-09T15:00:00Z" -} \ No newline at end of file -- 2.51.2