From df4e297ed168497a4b95922fc712651500512ac8 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:56:07 -0700 Subject: [PATCH 1/6] Add support for new topic formats --- integration/package-lock.json | 13 +++++++++---- integration/package.json | 2 +- integration/src/index.test.ts | 4 ++-- integration/src/index.ts | 32 +++++++++++++++++++------------- 4 files changed, 31 insertions(+), 20 deletions(-) diff --git a/integration/package-lock.json b/integration/package-lock.json index 1b8d6f5a..e4c1360e 100644 --- a/integration/package-lock.json +++ b/integration/package-lock.json @@ -10,7 +10,7 @@ "@connectrpc/connect": "^2.0.0", "@connectrpc/connect-web": "^2.0.0", "@koa/bodyparser": "^5", - "@xmtp/node-sdk": "5.3.0", + "@xmtp/node-sdk": "6.0.0", "koa": "^2", "viem": "^2.22.2" }, @@ -1252,18 +1252,22 @@ } }, "node_modules/@xmtp/node-bindings": { - "version": "1.9.1", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@xmtp/node-bindings/-/node-bindings-1.10.0.tgz", + "integrity": "sha512-US/YvMP7RL1DkNeiZ75ZjEyvMwLIrEPn12dBj5h7lAesM2N5snwnstJ2NNk3LpW3kCNDdzwhe/G0ZSb0PhwNeg==", "license": "MIT", "engines": { "node": ">=22" } }, "node_modules/@xmtp/node-sdk": { - "version": "5.3.0", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xmtp/node-sdk/-/node-sdk-6.0.0.tgz", + "integrity": "sha512-UMClg1+WkC3pfeHffJoZMYzhqsUa2wZpSJtIrpvBqUD+n1VYVQTfhTqGyaavVIPgITxjHBi/uMS/K18E4qDbPA==", "license": "MIT", "dependencies": { "@xmtp/content-type-primitives": "3.0.0", - "@xmtp/node-bindings": "1.9.1" + "@xmtp/node-bindings": "1.10.0" }, "engines": { "node": ">=22" @@ -2660,6 +2664,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/integration/package.json b/integration/package.json index b5107215..cf78a2a6 100644 --- a/integration/package.json +++ b/integration/package.json @@ -14,7 +14,7 @@ "@connectrpc/connect": "^2.0.0", "@connectrpc/connect-web": "^2.0.0", "@koa/bodyparser": "^5", - "@xmtp/node-sdk": "5.3.0", + "@xmtp/node-sdk": "6.0.0", "koa": "^2", "viem": "^2.22.2" } diff --git a/integration/src/index.test.ts b/integration/src/index.test.ts index 84b3008f..1a0812aa 100644 --- a/integration/src/index.test.ts +++ b/integration/src/index.test.ts @@ -24,7 +24,7 @@ describe("notifications", () => { }); const waitForNextRequest = ( - timeoutMs: number + timeoutMs: number, ): Promise => new Promise((resolve, reject) => { onRequest = (body) => resolve(body); @@ -101,7 +101,7 @@ describe("notifications", () => { thirtyDayPeriodsSinceEpoch: Number(v.epoch), key: Uint8Array.from(v.key), })); - const topic = `/xmtp/mls/1/g-${alixGroup.id}/proto`; + const topic = alixGroup.topic; await alixNotificationClient.subscribeWithMetadata({ installationId: alix.installationId, subscriptions: [ diff --git a/integration/src/index.ts b/integration/src/index.ts index a3b1978e..1f0552f7 100644 --- a/integration/src/index.ts +++ b/integration/src/index.ts @@ -1,8 +1,17 @@ -import { Client, type Signer, IdentifierKind } from "@xmtp/node-sdk"; +import { + Client, + type Signer, + type ClientOptions, + IdentifierKind, + createBackend, +} from "@xmtp/node-sdk"; import { createWalletClient, http, toBytes } from "viem"; import { mainnet } from "viem/chains"; import { privateKeyToAccount, generatePrivateKey } from "viem/accounts"; -import { createClient, type Client as ConnectClient } from "@connectrpc/connect"; +import { + createClient, + type Client as ConnectClient, +} from "@connectrpc/connect"; import { Notifications, SubscriptionSchema, @@ -37,12 +46,13 @@ export async function randomClient() { }; const encKey = getRandomValues(new Uint8Array(32)); - return await Client.create(signer, { - env: "dev", + const opts: ClientOptions = { apiUrl: config.nodeUrl, + env: "local", dbEncryptionKey: encKey, dbPath: `/tmp/test-${wallet.account.address}.db3`, - }); + }; + return await Client.create(signer, opts); } export function createNotificationClient() { @@ -60,13 +70,11 @@ export async function subscribeToTopics( xmtpClient: Client, // A notifications server client, like the one generated above. notificationClient: ConnectClient, - // We want to handle iOS subscriptions slightly differently because we can't filter regular notifications on the client - isIos: boolean ) { // Only subscribe to notifications which have a consent state of allowed // to protect users from SPAM notifications const consentedConversations = (await xmtpClient.conversations.list()).filter( - (c) => c.consentState() === 1 + (c) => c.consentState() === 1, ); // Get the HMAC Keys for all conversations where the keys exist @@ -75,7 +83,7 @@ export async function subscribeToTopics( // Convert the conversations to subscriptions const conversationSubscriptions = consentedConversations.map((c) => create(SubscriptionSchema, { - topic: c.id, + topic: c.topic, // V1 conversations don't have isSender support. // Use data only notifications here for iOS isSilent: false, @@ -83,13 +91,11 @@ export async function subscribeToTopics( create(Subscription_HmacKeySchema, { thirtyDayPeriodsSinceEpoch: Number(v.epoch), key: Uint8Array.from(v.key), - }) + }), ), - }) + }), ); - const inviteAndIntroSubscriptions: typeof conversationSubscriptions = []; - await notificationClient.subscribeWithMetadata({ installationId, subscriptions: conversationSubscriptions, From 3f2828e73e4b8f68bf0a302fbaf4af3192daf683 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Fri, 3 Apr 2026 19:22:08 -0700 Subject: [PATCH 2/6] feat: migrate to V4 binary topic format using xmtpd/pkg/topic Replace V3 string-based topic identifiers with V4 binary topics across the entire notification pipeline. Adds parallel bytes fields to the protobuf API for backwards compatibility, converts the database topic column from TEXT to BYTEA via migration, and updates all internal services to work exclusively with *topic.Topic from xmtpd. - Add ParseV3Topic, TopicToString, GetMessageTypeFromTopic conversions - Add topic_bytes/topics_bytes fields to proto definitions - Migration 00003: convert TEXT topics to BYTEA with hex normalization - Migration 00004: drop legacy topic_legacy column - Update Subscription/SubscriptionInput types to use *topic.Topic - Add topic normalization and validation at API boundary - Update listener to convert string topics to binary before processing - Add dual topic fields (string + base64 bytes) to APNS/FCM delivery - Remove dead code: GetMessageType, HmacUpdates, isV3Topic Co-Authored-By: Claude Opus 4.6 (1M context) --- go.mod | 68 ++-- go.sum | 327 +++++++++++++----- .../src/gen/notifications/v1/service_pb.ts | 23 +- mocks/Delivery.go | 2 +- mocks/Installations.go | 2 +- mocks/Subscriptions.go | 30 +- pkg/api/api.go | 75 +++- pkg/api/api_test.go | 153 +++++++- pkg/db/db.go | 4 + pkg/db/db_test.go | 47 +++ .../migrations/00003_binary_topics.down.sql | 10 + pkg/db/migrations/00003_binary_topics.up.sql | 42 +++ .../00004_drop_topic_legacy.down.sql | 1 + .../migrations/00004_drop_topic_legacy.up.sql | 1 + pkg/db/migrations/migrations.go | 15 +- pkg/db/queries/models.go | 25 +- pkg/db/queries/subscriptions.sql.go | 234 +++++++------ pkg/db/sqlc/subscriptions.sql | 4 +- pkg/delivery/apns.go | 28 +- pkg/delivery/apns_test.go | 43 +++ pkg/delivery/fcm.go | 32 +- pkg/delivery/fcm_test.go | 31 ++ pkg/interfaces/interfaces.go | 48 ++- pkg/interfaces/interfaces_test.go | 38 ++ pkg/proto/notifications/v1/service.pb.go | 57 ++- pkg/subscriptions/subscriptions.go | 60 ++-- pkg/subscriptions/subscriptions_test.go | 75 ++-- pkg/topics/topics.go | 72 +++- pkg/topics/topics_test.go | 90 +++++ pkg/xmtp/listener.go | 26 +- pkg/xmtp/listener_test.go | 10 +- pkg/xmtp/message.go | 5 +- pkg/xmtp/message_test.go | 8 +- proto/notifications/v1/service.proto | 6 + 34 files changed, 1287 insertions(+), 405 deletions(-) create mode 100644 pkg/db/migrations/00003_binary_topics.down.sql create mode 100644 pkg/db/migrations/00003_binary_topics.up.sql create mode 100644 pkg/db/migrations/00004_drop_topic_legacy.down.sql create mode 100644 pkg/db/migrations/00004_drop_topic_legacy.up.sql create mode 100644 pkg/delivery/apns_test.go create mode 100644 pkg/delivery/fcm_test.go create mode 100644 pkg/interfaces/interfaces_test.go create mode 100644 pkg/topics/topics_test.go diff --git a/go.mod b/go.mod index 124bf1f0..ad72b547 100644 --- a/go.mod +++ b/go.mod @@ -6,43 +6,44 @@ require ( connectrpc.com/connect v1.19.1 firebase.google.com/go/v4 v4.15.2 github.com/golang-migrate/migrate/v4 v4.19.0 - github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 - github.com/jackc/pgx/v5 v5.7.5 + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 + github.com/jackc/pgx/v5 v5.9.1 github.com/jessevdk/go-flags v1.6.1 - github.com/lib/pq v1.10.9 + github.com/lib/pq v1.12.0 github.com/pkg/errors v0.9.1 github.com/sideshow/apns2 v0.25.0 - github.com/stretchr/testify v1.10.0 - go.uber.org/zap v1.27.0 - golang.org/x/net v0.39.0 + github.com/stretchr/testify v1.11.1 + github.com/xmtp/xmtpd v1.3.0 + go.uber.org/zap v1.27.1 + golang.org/x/net v0.52.0 google.golang.org/api v0.231.0 - google.golang.org/genproto/googleapis/api v0.0.0-20250428153025-10db94c68c34 - google.golang.org/grpc v1.72.0 - google.golang.org/protobuf v1.36.9 + google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 + google.golang.org/grpc v1.79.3 + google.golang.org/protobuf v1.36.11 ) require ( - cel.dev/expr v0.23.1 // indirect + cel.dev/expr v0.25.1 // indirect cloud.google.com/go v0.121.0 // indirect cloud.google.com/go/auth v0.16.1 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect - cloud.google.com/go/compute/metadata v0.6.0 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/firestore v1.18.0 // indirect cloud.google.com/go/iam v1.5.2 // indirect cloud.google.com/go/longrunning v0.6.7 // indirect cloud.google.com/go/monitoring v1.24.2 // indirect cloud.google.com/go/storage v1.52.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect github.com/MicahParks/keyfunc v1.9.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect - github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect + github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/go-jose/go-jose/v4 v4.1.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect @@ -57,28 +58,27 @@ require ( github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/stretchr/objx v0.5.2 // indirect - github.com/zeebo/errs v1.4.0 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.35.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect - go.opentelemetry.io/otel v1.37.0 // indirect - go.opentelemetry.io/otel/metric v1.37.0 // indirect - go.opentelemetry.io/otel/sdk v1.35.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect - go.opentelemetry.io/otel/trace v1.37.0 // indirect + go.opentelemetry.io/otel v1.42.0 // indirect + go.opentelemetry.io/otel/metric v1.42.0 // indirect + go.opentelemetry.io/otel/sdk v1.42.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.42.0 // indirect + go.opentelemetry.io/otel/trace v1.42.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.37.0 // indirect - golang.org/x/oauth2 v0.29.0 // indirect - golang.org/x/sync v0.13.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.24.0 // indirect - golang.org/x/time v0.11.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/time v0.14.0 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect google.golang.org/genproto v0.0.0-20250428153025-10db94c68c34 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250428153025-10db94c68c34 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 508c64df..c0c0501b 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,13 @@ -cel.dev/expr v0.23.1 h1:K4KOtPCJQjVggkARsjG9RWXP6O4R73aHeJMa/dmCQQg= -cel.dev/expr v0.23.1/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.121.0 h1:pgfwva8nGw7vivjZiRfrmglGWiCJBP+0OmDpenG/Fwg= cloud.google.com/go v0.121.0/go.mod h1:rS7Kytwheu/y9buoDmu5EIpMMCI4Mb8ND4aeN4Vwj7Q= cloud.google.com/go/auth v0.16.1 h1:XrXauHMd30LhQYVRHLGvJiYeczweKQXZxsTbV9TiguU= cloud.google.com/go/auth v0.16.1/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= -cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= -cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/firestore v1.18.0 h1:cuydCaLS7Vl2SatAeivXyhbhDEIR8BDmtn4egDhIn2s= cloud.google.com/go/firestore v1.18.0/go.mod h1:5ye0v48PhseZBdcl0qbl3uttu7FIEwEYVaWm0UIEOEU= cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8= @@ -26,62 +26,144 @@ connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= firebase.google.com/go/v4 v4.15.2 h1:KJtV4rAfO2CVCp40hBfVk+mqUqg7+jQKx7yOgFDnXBg= firebase.google.com/go/v4 v4.15.2/go.mod h1:qkD/HtSumrPMTLs0ahQrje5gTw2WKFKrzVFoqy4SbKA= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 h1:ErKg/3iS1AKcTkf3yixlZ54f9U1rljCkQyEXWUnIUxc= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0/go.mod h1:yAZHSGnqScoU556rBOVkwLze6WP5N+U11RHuWaGVxwY= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/DataDog/appsec-internal-go v1.11.2 h1:Q00pPMQzqMIw7jT2ObaORIxBzSly+deS0Ely9OZ/Bj0= +github.com/DataDog/appsec-internal-go v1.11.2/go.mod h1:9YppRCpElfGX+emXOKruShFYsdPq7WEPq/Fen4tYYpk= +github.com/DataDog/datadog-agent/comp/core/tagger/origindetection v0.76.2 h1:dnkA7bPC4NL7s3aWNRff8SHdaLMdaUW/yXRx+uEX7g4= +github.com/DataDog/datadog-agent/comp/core/tagger/origindetection v0.76.2/go.mod h1:EiRMrLmaSQmE4dLlBewlhbqg2J0n2QVhUWrWe/1MHMw= +github.com/DataDog/datadog-agent/pkg/obfuscate v0.76.2 h1:KkLQGQlk3HjEQjs2XlK9fEabysO+c6YGmqZsLmpw2yk= +github.com/DataDog/datadog-agent/pkg/obfuscate v0.76.2/go.mod h1:Q1B0h6Gyk8twdN1huri/UkQvLbUooVmEsooE49cqEFk= +github.com/DataDog/datadog-agent/pkg/opentelemetry-mapping-go/otlp/attributes v0.76.2 h1:XDKuOvZEIvoOWXFaSoEdViBVrbj1boNPgVxRwwzPT7U= +github.com/DataDog/datadog-agent/pkg/opentelemetry-mapping-go/otlp/attributes v0.76.2/go.mod h1:5dbYePTSJxpBbCA02rB1TzS/NgcJvblIhrrcUat1vi8= +github.com/DataDog/datadog-agent/pkg/proto v0.76.2 h1:Rf+y+5LM5txmaIbPQoAHcaPC8CXhpf8gte/8zKZtN/A= +github.com/DataDog/datadog-agent/pkg/proto v0.76.2/go.mod h1:RzHSkeIpR6OBEmwGy4fUPtQ1hPgt7ljwYlJmKxMHSX4= +github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.76.2 h1:wE9N1onlBC4NFabRqY8bD5u0LZPRzaU1Pufbqxubk8Q= +github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.76.2/go.mod h1:MCaZbLoaLD7kUUmSd5jlIvQ5qPAMM8GI7OZ+0FGWi1E= +github.com/DataDog/datadog-agent/pkg/template v0.76.2 h1:ogc1Vz5i5b4DH9nsTYqIOthG5OOEYF8s1v56AfADgC4= +github.com/DataDog/datadog-agent/pkg/template v0.76.2/go.mod h1:mpV3MbF/us0LdM3tvVHDztjApy3VWGeu5RuS/MpGVHQ= +github.com/DataDog/datadog-agent/pkg/trace v0.76.2 h1:HgknNcNY4Iymx/xMwBgXF6SCSjVBsmWdFaj7HHF4b/U= +github.com/DataDog/datadog-agent/pkg/trace v0.76.2/go.mod h1:gg8R4AkYyRJAc5oSSpcwZwvY/hCEMK0gsfRo9WCufFE= +github.com/DataDog/datadog-agent/pkg/trace/log v0.77.0 h1:2VY1byEA2XnYVg7+eLQSTgX2f76ZCf/AwpCBJDXCiDc= +github.com/DataDog/datadog-agent/pkg/trace/log v0.77.0/go.mod h1:thnxBOGfMU9uRlFUClXud6J7DdI8qWtElELSds5jqts= +github.com/DataDog/datadog-agent/pkg/trace/otel v0.76.2 h1:2AdHsMyL1v680U3JkUIXBSSFl2EVSAzKFYWixqrvMZY= +github.com/DataDog/datadog-agent/pkg/trace/otel v0.76.2/go.mod h1:E4FYEUnCneDDPEzN8s+0le1oIrV4I4Nm1oeEvqC2rUA= +github.com/DataDog/datadog-agent/pkg/trace/stats v0.76.2 h1:U5k2kA3xvsf5HLmGQUilu7+DOgF3m7CLO/ZZbok41pc= +github.com/DataDog/datadog-agent/pkg/trace/stats v0.76.2/go.mod h1:hKGPJA7eGRZCTKNIYzE+8OlpIbNJasgVOtegVaALqVs= +github.com/DataDog/datadog-agent/pkg/trace/traceutil v0.76.2 h1:rJx1fhqLLYvmtt0Zoomob4eLsodgMn5T8KPuMKgC9E8= +github.com/DataDog/datadog-agent/pkg/trace/traceutil v0.76.2/go.mod h1:YiflIHg6cxNehFkqMubUlfmRt41/afuXr2aNDWzjIDU= +github.com/DataDog/datadog-agent/pkg/util/log v0.76.2 h1:DVmZTMJ6RRROPkbCs5ZybQsf8tpoSuvZa8Q69XQ70cw= +github.com/DataDog/datadog-agent/pkg/util/log v0.76.2/go.mod h1:ed4N2Q4BbWAGqX9Kg5ej3IAKZZWFuxb8ccgIzLdVPxk= +github.com/DataDog/datadog-agent/pkg/util/scrubber v0.76.2 h1:beRSQsRlX/tf0SviHANKVmGjUhPWOoS2kP0J22ypXis= +github.com/DataDog/datadog-agent/pkg/util/scrubber v0.76.2/go.mod h1:/GvOF3H0meWMxBWGzSkh9t7aqlAxIEbeQSeaGk711Ho= +github.com/DataDog/datadog-agent/pkg/version v0.76.2 h1:KkIY/OF6/yuJ7o/VKY7j/toLWzDYLft3rh6uuIbmuvc= +github.com/DataDog/datadog-agent/pkg/version v0.76.2/go.mod h1:4I4x0IqhIr0fXr90G8Qauz9iID0xhonjUB2lAZH9qyI= +github.com/DataDog/datadog-go/v5 v5.8.2 h1:9IEfH1Mw9AjWwhAMqCAkhbxjuJeMxm2ARX2VdgL+ols= +github.com/DataDog/datadog-go/v5 v5.8.2/go.mod h1:K9kcYBlxkcPP8tvvjZZKs/m1edNAUFzBbdpTUKfCsuw= +github.com/DataDog/dd-trace-go/v2 v2.0.0 h1:cHMEzD0Wcgtu+Rec9d1GuVgpIN5f+4vCaNzuFHJ0v+Y= +github.com/DataDog/dd-trace-go/v2 v2.0.0/go.mod h1:WBtf7TA9bWr5uA8DjOyw1qlSKe3bw9gN5nc0Ta9dHFE= +github.com/DataDog/go-libddwaf/v3 v3.5.4 h1:cLV5lmGhrUBnHG50EUXdqPQAlJdVCp9n3aQ5bDWJEAg= +github.com/DataDog/go-libddwaf/v3 v3.5.4/go.mod h1:HoLUHdj0NybsPBth/UppTcg8/DKA4g+AXuk8cZ6nuoo= +github.com/DataDog/go-runtime-metrics-internal v0.0.4-0.20250319104955-81009b9bad14 h1:tc5aVw7OcMyfVmJnrY4IOeiV1RTSaBuJBqF14BXxzIo= +github.com/DataDog/go-runtime-metrics-internal v0.0.4-0.20250319104955-81009b9bad14/go.mod h1:quaQJ+wPN41xEC458FCpTwyROZm3MzmTZ8q8XOXQiPs= +github.com/DataDog/go-sqllexer v0.1.12 h1:2eI/Mbs+fcHzXp4iwGz60V6mAWQNFOD/aa98ZluJJ7c= +github.com/DataDog/go-sqllexer v0.1.12/go.mod h1:vOw7Ia7z+z6nl3zGZlLIZe0vQlPtCPR906WIPBJadxc= +github.com/DataDog/go-tuf v1.1.1-0.5.2 h1:YWvghV4ZvrQsPcUw8IOUMSDpqc3W5ruOIC+KJxPknv0= +github.com/DataDog/go-tuf v1.1.1-0.5.2/go.mod h1:zBcq6f654iVqmkk8n2Cx81E1JnNTMOAx1UEO/wZR+P0= +github.com/DataDog/sketches-go v1.4.7 h1:eHs5/0i2Sdf20Zkj0udVFWuCrXGRFig2Dcfm5rtcTxc= +github.com/DataDog/sketches-go v1.4.7/go.mod h1:eAmQ/EBmtSO+nQp7IZMZVRPT4BQTmIc5RZQ+deGlTPM= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 h1:fYE9p3esPxA/C0rQ0AHhP0drtPXDRhaWiwg1DPqO7IU= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0/go.mod h1:BnBReJLvVYx2CS/UHOgVz2BXKXD9wsQPxZug20nZhd0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0 h1:OqVGm6Ei3x5+yZmSJG1Mh2NwHvpVmZ08CB5qJhT9Nuk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0/go.mod h1:SZiPHWGOOk3bl8tkevxkoiwPgsIl6CwrWcbwjfHZpdM= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 h1:6/0iUd0xrnX7qt+mLNRwg5c0PGv8wpE8K90ryANQwMI= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= +github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= +github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20201120081800-1786d5ef83d4/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU= +github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f h1:C5bqEmzEPLsHm9Mv73lSE9e9bKV23aB1vxOsmZrkl3k= -github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/cihub/seelog v0.0.0-20170130134532-f561c5e57575 h1:kHaBemcxl8o/pQ5VM1c8PVE1PubbNx3mjUr09OqWGCs= +github.com/cihub/seelog v0.0.0-20170130134532-f561c5e57575/go.mod h1:9d6lWj8KzO/fd/NrVaLscBKmPigpZpn5YawRPw+e3Yo= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/consensys/gnark-crypto v0.18.1 h1:RyLV6UhPRoYYzaFnPQA4qK3DyuDgkTgskDdoGqFt3fI= +github.com/consensys/gnark-crypto v0.18.1/go.mod h1:L3mXGFTe1ZN+RSJ+CLjUt9x7PNdx8ubaYfDROyp2Z8c= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/crate-crypto/go-eth-kzg v1.4.0 h1:WzDGjHk4gFg6YzV0rJOAsTK4z3Qkz5jd4RE3DAvPFkg= +github.com/crate-crypto/go-eth-kzg v1.4.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM= +github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= -github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= -github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= -github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA= -github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= -github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/eapache/queue/v2 v2.0.0-20230407133247-75960ed334e4 h1:8EXxF+tCLqaVk8AOC29zl2mnhQjwyLxxOTuhUazWRsg= +github.com/eapache/queue/v2 v2.0.0-20230407133247-75960ed334e4/go.mod h1:I5sHm0Y0T1u5YjlyqC5GVArM7aNZRUYtTjmJ8mPJFds= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= +github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= -github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= -github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= +github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/ethereum/c-kzg-4844/v2 v2.1.5 h1:aVtoLK5xwJ6c5RiqO8g8ptJ5KU+2Hdquf6G3aXiHh5s= +github.com/ethereum/c-kzg-4844/v2 v2.1.5/go.mod h1:u59hRTTah4Co6i9fDWtiCjTrblJv0UwsqZKCc0GfgUs= +github.com/ethereum/go-ethereum v1.17.0 h1:2D+1Fe23CwZ5tQoAS5DfwKFNI1HGcTwi65/kRlAVxes= +github.com/ethereum/go-ethereum v1.17.0/go.mod h1:2W3msvdosS/MCWytpqTcqgFiRYbTH59FxDJzqah120o= +github.com/exaring/otelpgx v0.10.0 h1:NGGegdoBQM3jNZDKG8ENhigUcgBN7d7943L0YlcIpZc= +github.com/exaring/otelpgx v0.10.0/go.mod h1:R5/M5LWsPPBZc1SrRE5e0DiU48bI78C1/GPTWs6I66U= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY= -github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= @@ -104,149 +186,228 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= +github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA= +github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= -github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= +github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.12.0 h1:mC1zeiNamwKBecjHarAr26c/+d8V5w/u4J0I/yASbJo= +github.com/lib/pq v1.12.0/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k= +github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= -github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= -github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/outcaste-io/ristretto v0.2.3 h1:AK4zt/fJ76kjlYObOeNwh4T3asEuaCmp26pOvUOL9w0= +github.com/outcaste-io/ristretto v0.2.3/go.mod h1:W8HywhmtlopSB1jeMg3JtdIhf+DYkLAr0VN/s4+MHac= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4= +github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= +github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg= +github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/secure-systems-lab/go-securesystemslib v0.9.0 h1:rf1HIbL64nUpEIZnjLZ3mcNEL9NBPB0iuVjyxvq3LZc= +github.com/secure-systems-lab/go-securesystemslib v0.9.0/go.mod h1:DVHKMcZ+V4/woA/peqr+L0joiRXbPpQ042GgJckkFgw= +github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU= +github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= +github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI= +github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sideshow/apns2 v0.25.0 h1:XOzanncO9MQxkb03T/2uU2KcdVjYiIf0TMLzec0FTW4= github.com/sideshow/apns2 v0.25.0/go.mod h1:7Fceu+sL0XscxrfLSkAoH6UtvKefq3Kq1n4W3ayQZqE= -github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE= -github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe h1:nbdqkIGOGfUAD54q1s2YBcBz/WcsxCO9HUQ4aGV5hUw= +github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= +github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= +github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/xmtp/xmtpd v1.3.0 h1:yeiwbgB+y77y+q1+xqwH46SAYr1FXX2puTVldSGTBKc= +github.com/xmtp/xmtpd v1.3.0/go.mod h1:e4SHxnHr9v58tL4mdez6UzVSbi250YXF9Pu0tY+l3PU= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= -github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/detectors/gcp v1.35.0 h1:bGvFt68+KTiAKFlacHW6AhA56GF2rS0bdD3aJYEnmzA= -go.opentelemetry.io/contrib/detectors/gcp v1.35.0/go.mod h1:qGWP8/+ILwMRIUf9uIVLloR1uo5ZYAslM4O6OqUi1DA= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/collector/component v1.50.0 h1:AvIhCc/J7tXlKZDETPDMDp6g6pwa3FBD6c0Q8h2u3xA= +go.opentelemetry.io/collector/component v1.50.0/go.mod h1:S0p+mq0ZvEEN67BKWt0atC5cHn2Km8vBeeIZuYzD0XU= +go.opentelemetry.io/collector/featuregate v1.50.0 h1:nROGw8VpLuc2/PExnL6ammUpr2y7pozpbwgae6zU4s0= +go.opentelemetry.io/collector/featuregate v1.50.0/go.mod h1:/1bclXgP91pISaEeNulRxzzmzMTm4I5Xih2SnI4HRSo= +go.opentelemetry.io/collector/pdata v1.50.0 h1:vES5c9jT9HzOhHEg1OIjPxk4qKIjA+Dao8dxU3oePU0= +go.opentelemetry.io/collector/pdata v1.50.0/go.mod h1:G18lFpQYh4473PiEPqLd7BKfc8a/j+Fl4EfHWy1Ylx8= +go.opentelemetry.io/collector/pdata/pprofile v0.144.0 h1:jzgIl+Hhjr5sfJDals+6Zl0IS1EUtZBChvv+j05Ih44= +go.opentelemetry.io/collector/pdata/pprofile v0.144.0/go.mod h1:mipJI/T20uy/+iD3QrzmRUPGenJRhBJj8qGXDpLWoQs= +go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE= +go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= -go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= +go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel/exporters/prometheus v0.64.0 h1:g0LRDXMX/G1SEZtK8zl8Chm4K6GBwRkjPKE36LxiTYs= +go.opentelemetry.io/otel/exporters/prometheus v0.64.0/go.mod h1:UrgcjnarfdlBDP3GjDIJWe6HTprwSazNjwsI+Ru6hro= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0 h1:PB3Zrjs1sG1GBX51SXyTSoOTqcDglmsk7nT6tkKPb/k= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0/go.mod h1:U2R3XyVPzn0WX7wOIypPuptulsMcPDPs/oiSVOMVnHY= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= -go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= -go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= -go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= +go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= +go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= +go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= +go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= +go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= +go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= +go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= golang.org/x/crypto v0.0.0-20170512130425-ab89591268e0/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM= +golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220403103023-749bd193bc2b/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= -golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= -golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= -golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= -golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= +golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.231.0 h1:LbUD5FUl0C4qwia2bjXhCMH65yz1MLPzA/0OYEsYY7Q= google.golang.org/api v0.231.0/go.mod h1:H52180fPI/QQlUc0F4xWfGZILdv09GCWKt2bcsn164A= google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw= google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI= google.golang.org/genproto v0.0.0-20250428153025-10db94c68c34 h1:oklGWmm0ZiCw4efmdYZo5MF9t6nRvGzM5+0klSjOmGM= google.golang.org/genproto v0.0.0-20250428153025-10db94c68c34/go.mod h1:hiH/EqX5GBdTyIpkqMqDGUHDiBniln8b4FCw+NzPxQY= -google.golang.org/genproto/googleapis/api v0.0.0-20250428153025-10db94c68c34 h1:0PeQib/pH3nB/5pEmFeVQJotzGohV0dq4Vcp09H5yhE= -google.golang.org/genproto/googleapis/api v0.0.0-20250428153025-10db94c68c34/go.mod h1:0awUlEkap+Pb1UMeJwJQQAdJQrt3moU7J2moTy69irI= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250428153025-10db94c68c34 h1:h6p3mQqrmT1XkHVTfzLdNz1u7IhINeZkz67/xTbOuWs= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250428153025-10db94c68c34/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.72.0 h1:S7UkcVa60b5AAQTaO6ZKamFp1zMZSU0fGDK2WZLbBnM= -google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= +google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0= +google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= -google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/DataDog/dd-trace-go.v1 v1.74.0 h1:wScziU1ff6Bnyr8MEyxATPSLJdnLxKz3p6RsA8FUaek= +gopkg.in/DataDog/dd-trace-go.v1 v1.74.0/go.mod h1:ReNBsNfnsjVC7GsCe80zRcykL/n+nxvsNrg3NbjuleM= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/integration/src/gen/notifications/v1/service_pb.ts b/integration/src/gen/notifications/v1/service_pb.ts index 5645d91f..aec64df3 100644 --- a/integration/src/gen/notifications/v1/service_pb.ts +++ b/integration/src/gen/notifications/v1/service_pb.ts @@ -12,7 +12,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file notifications/v1/service.proto. */ export const file_notifications_v1_service: GenFile = /*@__PURE__*/ - fileDesc("Ch5ub3RpZmljYXRpb25zL3YxL3NlcnZpY2UucHJvdG8SEG5vdGlmaWNhdGlvbnMudjEihAEKEURlbGl2ZXJ5TWVjaGFuaXNtEhsKEWFwbnNfZGV2aWNlX3Rva2VuGAEgASgJSAASHwoVZmlyZWJhc2VfZGV2aWNlX3Rva2VuGAIgASgJSAASFgoMY3VzdG9tX3Rva2VuGAMgASgJSABCGQoXZGVsaXZlcnlfbWVjaGFuaXNtX3R5cGUidwobUmVnaXN0ZXJJbnN0YWxsYXRpb25SZXF1ZXN0EhcKD2luc3RhbGxhdGlvbl9pZBgBIAEoCRI/ChJkZWxpdmVyeV9tZWNoYW5pc20YAiABKAsyIy5ub3RpZmljYXRpb25zLnYxLkRlbGl2ZXJ5TWVjaGFuaXNtIkwKHFJlZ2lzdGVySW5zdGFsbGF0aW9uUmVzcG9uc2USFwoPaW5zdGFsbGF0aW9uX2lkGAEgASgJEhMKC3ZhbGlkX3VudGlsGAIgASgEIjQKGURlbGV0ZUluc3RhbGxhdGlvblJlcXVlc3QSFwoPaW5zdGFsbGF0aW9uX2lkGAEgASgJIqsBCgxTdWJzY3JpcHRpb24SDQoFdG9waWMYASABKAkSOQoJaG1hY19rZXlzGAIgAygLMiYubm90aWZpY2F0aW9ucy52MS5TdWJzY3JpcHRpb24uSG1hY0tleRIRCglpc19zaWxlbnQYAyABKAgaPgoHSG1hY0tleRImCh50aGlydHlfZGF5X3BlcmlvZHNfc2luY2VfZXBvY2gYASABKA0SCwoDa2V5GAIgASgMIm4KHFN1YnNjcmliZVdpdGhNZXRhZGF0YVJlcXVlc3QSFwoPaW5zdGFsbGF0aW9uX2lkGAEgASgJEjUKDXN1YnNjcmlwdGlvbnMYAiADKAsyHi5ub3RpZmljYXRpb25zLnYxLlN1YnNjcmlwdGlvbiI7ChBTdWJzY3JpYmVSZXF1ZXN0EhcKD2luc3RhbGxhdGlvbl9pZBgBIAEoCRIOCgZ0b3BpY3MYAiADKAkiPQoSVW5zdWJzY3JpYmVSZXF1ZXN0EhcKD2luc3RhbGxhdGlvbl9pZBgBIAEoCRIOCgZ0b3BpY3MYAiADKAky2AMKDU5vdGlmaWNhdGlvbnMSdQoUUmVnaXN0ZXJJbnN0YWxsYXRpb24SLS5ub3RpZmljYXRpb25zLnYxLlJlZ2lzdGVySW5zdGFsbGF0aW9uUmVxdWVzdBouLm5vdGlmaWNhdGlvbnMudjEuUmVnaXN0ZXJJbnN0YWxsYXRpb25SZXNwb25zZRJZChJEZWxldGVJbnN0YWxsYXRpb24SKy5ub3RpZmljYXRpb25zLnYxLkRlbGV0ZUluc3RhbGxhdGlvblJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkSRwoJU3Vic2NyaWJlEiIubm90aWZpY2F0aW9ucy52MS5TdWJzY3JpYmVSZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5El8KFVN1YnNjcmliZVdpdGhNZXRhZGF0YRIuLm5vdGlmaWNhdGlvbnMudjEuU3Vic2NyaWJlV2l0aE1ldGFkYXRhUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eRJLCgtVbnN1YnNjcmliZRIkLm5vdGlmaWNhdGlvbnMudjEuVW5zdWJzY3JpYmVSZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5QuABChRjb20ubm90aWZpY2F0aW9ucy52MUIMU2VydmljZVByb3RvUAFaWWdpdGh1Yi5jb20veG10cC9leGFtcGxlLW5vdGlmaWNhdGlvbi1zZXJ2ZXItZ28vcGtnL3Byb3RvL25vdGlmaWNhdGlvbnMvdjE7bm90aWZpY2F0aW9uc3YxogIDTlhYqgIQTm90aWZpY2F0aW9ucy5WMcoCEE5vdGlmaWNhdGlvbnNcVjHiAhxOb3RpZmljYXRpb25zXFYxXEdQQk1ldGFkYXRh6gIRTm90aWZpY2F0aW9uczo6VjFiBnByb3RvMw", [file_google_protobuf_empty]); + fileDesc("Ch5ub3RpZmljYXRpb25zL3YxL3NlcnZpY2UucHJvdG8SEG5vdGlmaWNhdGlvbnMudjEihAEKEURlbGl2ZXJ5TWVjaGFuaXNtEhsKEWFwbnNfZGV2aWNlX3Rva2VuGAEgASgJSAASHwoVZmlyZWJhc2VfZGV2aWNlX3Rva2VuGAIgASgJSAASFgoMY3VzdG9tX3Rva2VuGAMgASgJSABCGQoXZGVsaXZlcnlfbWVjaGFuaXNtX3R5cGUidwobUmVnaXN0ZXJJbnN0YWxsYXRpb25SZXF1ZXN0EhcKD2luc3RhbGxhdGlvbl9pZBgBIAEoCRI/ChJkZWxpdmVyeV9tZWNoYW5pc20YAiABKAsyIy5ub3RpZmljYXRpb25zLnYxLkRlbGl2ZXJ5TWVjaGFuaXNtIkwKHFJlZ2lzdGVySW5zdGFsbGF0aW9uUmVzcG9uc2USFwoPaW5zdGFsbGF0aW9uX2lkGAEgASgJEhMKC3ZhbGlkX3VudGlsGAIgASgEIjQKGURlbGV0ZUluc3RhbGxhdGlvblJlcXVlc3QSFwoPaW5zdGFsbGF0aW9uX2lkGAEgASgJIsABCgxTdWJzY3JpcHRpb24SDQoFdG9waWMYASABKAkSOQoJaG1hY19rZXlzGAIgAygLMiYubm90aWZpY2F0aW9ucy52MS5TdWJzY3JpcHRpb24uSG1hY0tleRIRCglpc19zaWxlbnQYAyABKAgSEwoLdG9waWNfYnl0ZXMYBCABKAwaPgoHSG1hY0tleRImCh50aGlydHlfZGF5X3BlcmlvZHNfc2luY2VfZXBvY2gYASABKA0SCwoDa2V5GAIgASgMIm4KHFN1YnNjcmliZVdpdGhNZXRhZGF0YVJlcXVlc3QSFwoPaW5zdGFsbGF0aW9uX2lkGAEgASgJEjUKDXN1YnNjcmlwdGlvbnMYAiADKAsyHi5ub3RpZmljYXRpb25zLnYxLlN1YnNjcmlwdGlvbiJRChBTdWJzY3JpYmVSZXF1ZXN0EhcKD2luc3RhbGxhdGlvbl9pZBgBIAEoCRIOCgZ0b3BpY3MYAiADKAkSFAoMdG9waWNzX2J5dGVzGAMgAygMIlMKElVuc3Vic2NyaWJlUmVxdWVzdBIXCg9pbnN0YWxsYXRpb25faWQYASABKAkSDgoGdG9waWNzGAIgAygJEhQKDHRvcGljc19ieXRlcxgDIAMoDDLYAwoNTm90aWZpY2F0aW9ucxJ1ChRSZWdpc3Rlckluc3RhbGxhdGlvbhItLm5vdGlmaWNhdGlvbnMudjEuUmVnaXN0ZXJJbnN0YWxsYXRpb25SZXF1ZXN0Gi4ubm90aWZpY2F0aW9ucy52MS5SZWdpc3Rlckluc3RhbGxhdGlvblJlc3BvbnNlElkKEkRlbGV0ZUluc3RhbGxhdGlvbhIrLm5vdGlmaWNhdGlvbnMudjEuRGVsZXRlSW5zdGFsbGF0aW9uUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eRJHCglTdWJzY3JpYmUSIi5ub3RpZmljYXRpb25zLnYxLlN1YnNjcmliZVJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkSXwoVU3Vic2NyaWJlV2l0aE1ldGFkYXRhEi4ubm90aWZpY2F0aW9ucy52MS5TdWJzY3JpYmVXaXRoTWV0YWRhdGFSZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5EksKC1Vuc3Vic2NyaWJlEiQubm90aWZpY2F0aW9ucy52MS5VbnN1YnNjcmliZVJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHlC4AEKFGNvbS5ub3RpZmljYXRpb25zLnYxQgxTZXJ2aWNlUHJvdG9QAVpZZ2l0aHViLmNvbS94bXRwL2V4YW1wbGUtbm90aWZpY2F0aW9uLXNlcnZlci1nby9wa2cvcHJvdG8vbm90aWZpY2F0aW9ucy92MTtub3RpZmljYXRpb25zdjGiAgNOWFiqAhBOb3RpZmljYXRpb25zLlYxygIQTm90aWZpY2F0aW9uc1xWMeICHE5vdGlmaWNhdGlvbnNcVjFcR1BCTWV0YWRhdGHqAhFOb3RpZmljYXRpb25zOjpWMWIGcHJvdG8z", [file_google_protobuf_empty]); /** * An union of possible delibery mechanisms @@ -138,6 +138,13 @@ export type Subscription = Message<"notifications.v1.Subscription"> & { * @generated from field: bool is_silent = 3; */ isSilent: boolean; + + /** + * Binary topic (V4 format). If both topic and topic_bytes are set, topic_bytes takes precedence. + * + * @generated from field: bytes topic_bytes = 4; + */ + topicBytes: Uint8Array; }; /** @@ -208,6 +215,13 @@ export type SubscribeRequest = Message<"notifications.v1.SubscribeRequest"> & { * @generated from field: repeated string topics = 2; */ topics: string[]; + + /** + * Binary topics (V4 format). Merged with string topics if both are set. + * + * @generated from field: repeated bytes topics_bytes = 3; + */ + topicsBytes: Uint8Array[]; }; /** @@ -232,6 +246,13 @@ export type UnsubscribeRequest = Message<"notifications.v1.UnsubscribeRequest"> * @generated from field: repeated string topics = 2; */ topics: string[]; + + /** + * Binary topics (V4 format). Merged with string topics if both are set. + * + * @generated from field: repeated bytes topics_bytes = 3; + */ + topicsBytes: Uint8Array[]; }; /** diff --git a/mocks/Delivery.go b/mocks/Delivery.go index 08f37388..d4c4cdf7 100644 --- a/mocks/Delivery.go +++ b/mocks/Delivery.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.42.1. DO NOT EDIT. +// Code generated by mockery v2.53.6. DO NOT EDIT. package mocks diff --git a/mocks/Installations.go b/mocks/Installations.go index 05546aaa..cd90bcde 100644 --- a/mocks/Installations.go +++ b/mocks/Installations.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.42.1. DO NOT EDIT. +// Code generated by mockery v2.53.6. DO NOT EDIT. package mocks diff --git a/mocks/Subscriptions.go b/mocks/Subscriptions.go index 44174a6d..f0150159 100644 --- a/mocks/Subscriptions.go +++ b/mocks/Subscriptions.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.42.1. DO NOT EDIT. +// Code generated by mockery v2.53.6. DO NOT EDIT. package mocks @@ -7,6 +7,8 @@ import ( mock "github.com/stretchr/testify/mock" interfaces "github.com/xmtp/example-notification-server-go/pkg/interfaces" + + topic "github.com/xmtp/xmtpd/pkg/topic" ) // Subscriptions is an autogenerated mock type for the Subscriptions type @@ -14,9 +16,9 @@ type Subscriptions struct { mock.Mock } -// GetSubscriptions provides a mock function with given fields: ctx, topic, thirtyDayPeriod -func (_m *Subscriptions) GetSubscriptions(ctx context.Context, topic string, thirtyDayPeriod int) ([]interfaces.Subscription, error) { - ret := _m.Called(ctx, topic, thirtyDayPeriod) +// GetSubscriptions provides a mock function with given fields: ctx, t, thirtyDayPeriod +func (_m *Subscriptions) GetSubscriptions(ctx context.Context, t *topic.Topic, thirtyDayPeriod int) ([]interfaces.Subscription, error) { + ret := _m.Called(ctx, t, thirtyDayPeriod) if len(ret) == 0 { panic("no return value specified for GetSubscriptions") @@ -24,19 +26,19 @@ func (_m *Subscriptions) GetSubscriptions(ctx context.Context, topic string, thi var r0 []interfaces.Subscription var r1 error - if rf, ok := ret.Get(0).(func(context.Context, string, int) ([]interfaces.Subscription, error)); ok { - return rf(ctx, topic, thirtyDayPeriod) + if rf, ok := ret.Get(0).(func(context.Context, *topic.Topic, int) ([]interfaces.Subscription, error)); ok { + return rf(ctx, t, thirtyDayPeriod) } - if rf, ok := ret.Get(0).(func(context.Context, string, int) []interfaces.Subscription); ok { - r0 = rf(ctx, topic, thirtyDayPeriod) + if rf, ok := ret.Get(0).(func(context.Context, *topic.Topic, int) []interfaces.Subscription); ok { + r0 = rf(ctx, t, thirtyDayPeriod) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]interfaces.Subscription) } } - if rf, ok := ret.Get(1).(func(context.Context, string, int) error); ok { - r1 = rf(ctx, topic, thirtyDayPeriod) + if rf, ok := ret.Get(1).(func(context.Context, *topic.Topic, int) error); ok { + r1 = rf(ctx, t, thirtyDayPeriod) } else { r1 = ret.Error(1) } @@ -45,7 +47,7 @@ func (_m *Subscriptions) GetSubscriptions(ctx context.Context, topic string, thi } // Subscribe provides a mock function with given fields: ctx, installationId, topics -func (_m *Subscriptions) Subscribe(ctx context.Context, installationId string, topics []string) error { +func (_m *Subscriptions) Subscribe(ctx context.Context, installationId string, topics []*topic.Topic) error { ret := _m.Called(ctx, installationId, topics) if len(ret) == 0 { @@ -53,7 +55,7 @@ func (_m *Subscriptions) Subscribe(ctx context.Context, installationId string, t } var r0 error - if rf, ok := ret.Get(0).(func(context.Context, string, []string) error); ok { + if rf, ok := ret.Get(0).(func(context.Context, string, []*topic.Topic) error); ok { r0 = rf(ctx, installationId, topics) } else { r0 = ret.Error(0) @@ -81,7 +83,7 @@ func (_m *Subscriptions) SubscribeWithMetadata(ctx context.Context, installation } // Unsubscribe provides a mock function with given fields: ctx, installationId, topics -func (_m *Subscriptions) Unsubscribe(ctx context.Context, installationId string, topics []string) error { +func (_m *Subscriptions) Unsubscribe(ctx context.Context, installationId string, topics []*topic.Topic) error { ret := _m.Called(ctx, installationId, topics) if len(ret) == 0 { @@ -89,7 +91,7 @@ func (_m *Subscriptions) Unsubscribe(ctx context.Context, installationId string, } var r0 error - if rf, ok := ret.Get(0).(func(context.Context, string, []string) error); ok { + if rf, ok := ret.Get(0).(func(context.Context, string, []*topic.Topic) error); ok { r0 = rf(ctx, installationId, topics) } else { r0 = ret.Error(0) diff --git a/pkg/api/api.go b/pkg/api/api.go index 77679382..86e38738 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -13,6 +13,8 @@ import ( "github.com/xmtp/example-notification-server-go/pkg/options" proto "github.com/xmtp/example-notification-server-go/pkg/proto/notifications/v1" "github.com/xmtp/example-notification-server-go/pkg/proto/notifications/v1/notificationsv1connect" + topicutil "github.com/xmtp/example-notification-server-go/pkg/topics" + "github.com/xmtp/xmtpd/pkg/topic" "go.uber.org/zap" "golang.org/x/net/http2" "golang.org/x/net/http2/h2c" @@ -142,7 +144,12 @@ func (s *ApiServer) Subscribe( ) (*connect.Response[emptypb.Empty], error) { s.logger.Info("Subscribe", zap.Any("req", req)) - err := s.subscriptions.Subscribe(ctx, req.Msg.InstallationId, req.Msg.Topics) + topics, err := normalizeTopics(req.Msg.Topics, req.Msg.TopicsBytes) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + + err = s.subscriptions.Subscribe(ctx, req.Msg.InstallationId, topics) if err != nil { s.logger.Error("error subscribing", zap.Error(err)) return nil, connect.NewError(connect.CodeInternal, err) @@ -157,7 +164,12 @@ func (s *ApiServer) Unsubscribe( ) (*connect.Response[emptypb.Empty], error) { s.logger.Info("Unsubscribe", zap.Any("req", req)) - err := s.subscriptions.Unsubscribe(ctx, req.Msg.InstallationId, req.Msg.Topics) + topics, err := normalizeTopics(req.Msg.Topics, req.Msg.TopicsBytes) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + + err = s.subscriptions.Unsubscribe(ctx, req.Msg.InstallationId, topics) if err != nil { s.logger.Error("error unsubscribing", zap.Error(err)) return nil, connect.NewError(connect.CodeInternal, err) @@ -169,8 +181,11 @@ func (s *ApiServer) Unsubscribe( func (s *ApiServer) SubscribeWithMetadata(ctx context.Context, req *connect.Request[proto.SubscribeWithMetadataRequest]) (*connect.Response[emptypb.Empty], error) { log := s.logger.With(zap.String("method", "subscribeWithMetadata")) log.Info("Subscribing") - inputs := buildSubscriptionInputs(req.Msg.Subscriptions) - err := s.subscriptions.SubscribeWithMetadata(ctx, req.Msg.InstallationId, inputs) + inputs, err := normalizeSubscriptionInputs(req.Msg.Subscriptions) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + err = s.subscriptions.SubscribeWithMetadata(ctx, req.Msg.InstallationId, inputs) if err != nil { return nil, connect.NewError(connect.CodeInternal, err) } @@ -178,16 +193,62 @@ func (s *ApiServer) SubscribeWithMetadata(ctx context.Context, req *connect.Requ return connect.NewResponse(&emptypb.Empty{}), nil } -func buildSubscriptionInputs(subs []*proto.Subscription) []interfaces.SubscriptionInput { +func normalizeTopics(stringTopics []string, bytesTopics [][]byte) ([]*topic.Topic, error) { + seen := make(map[string]struct{}) + var result []*topic.Topic + + for _, s := range stringTopics { + t, err := topicutil.ParseV3Topic(s) + if err != nil { + return nil, fmt.Errorf("invalid topic %q: %w", s, err) + } + key := string(t.Bytes()) + if _, ok := seen[key]; !ok { + seen[key] = struct{}{} + result = append(result, t) + } + } + + for _, b := range bytesTopics { + t, err := topic.ParseTopic(b) + if err != nil { + return nil, fmt.Errorf("invalid binary topic: %w", err) + } + key := string(t.Bytes()) + if _, ok := seen[key]; !ok { + seen[key] = struct{}{} + result = append(result, t) + } + } + + return result, nil +} + +func normalizeSubscriptionInputs(subs []*proto.Subscription) ([]interfaces.SubscriptionInput, error) { out := make([]interfaces.SubscriptionInput, len(subs)) for idx, sub := range subs { + var t *topic.Topic + var err error + if len(sub.TopicBytes) > 0 { + t, err = topic.ParseTopic(sub.TopicBytes) + if err != nil { + return nil, fmt.Errorf("invalid binary topic at index %d: %w", idx, err) + } + } else if sub.Topic != "" { + t, err = topicutil.ParseV3Topic(sub.Topic) + if err != nil { + return nil, fmt.Errorf("invalid topic %q at index %d: %w", sub.Topic, idx, err) + } + } else { + return nil, fmt.Errorf("subscription at index %d has no topic", idx) + } out[idx] = interfaces.SubscriptionInput{ - Topic: sub.Topic, + Topic: t, IsSilent: sub.IsSilent, HmacKeys: buildHmacKeys(sub.HmacKeys), } } - return out + return out, nil } func buildHmacKeys(protoKeys []*proto.Subscription_HmacKey) []interfaces.HmacKey { diff --git a/pkg/api/api_test.go b/pkg/api/api_test.go index 854a10c5..737a8ac5 100644 --- a/pkg/api/api_test.go +++ b/pkg/api/api_test.go @@ -18,6 +18,8 @@ import ( "github.com/xmtp/example-notification-server-go/pkg/testutils" proto "github.com/xmtp/example-notification-server-go/pkg/proto/notifications/v1" protoconnect "github.com/xmtp/example-notification-server-go/pkg/proto/notifications/v1/notificationsv1connect" + topicutil "github.com/xmtp/example-notification-server-go/pkg/topics" + topicpkg "github.com/xmtp/xmtpd/pkg/topic" ) const INSTALLATION_ID = "install1" @@ -161,7 +163,7 @@ func Test_DeleteInstallation(t *testing.T) { func Test_Subscribe(t *testing.T) { ctx := setupTest(t) - topics := []string{"topic1"} + topics := []string{"/xmtp/mls/1/g-24ce39d660600b3a98adff3075b6d1f4/proto"} ctx.subscriptionsMock.On( "Subscribe", @@ -184,7 +186,7 @@ func Test_Subscribe(t *testing.T) { "Subscribe", mock.Anything, INSTALLATION_ID, - topics, + mock.Anything, ) } @@ -202,7 +204,7 @@ func Test_SubscribeError(t *testing.T) { ctx.ctx, connect.NewRequest(&proto.SubscribeRequest{ InstallationId: INSTALLATION_ID, - Topics: []string{"topic1"}, + Topics: []string{"/xmtp/mls/1/g-24ce39d660600b3a98adff3075b6d1f4/proto"}, }), ) @@ -212,7 +214,7 @@ func Test_SubscribeError(t *testing.T) { func Test_Unsubscribe(t *testing.T) { ctx := setupTest(t) - topics := []string{"topic1"} + topics := []string{"/xmtp/mls/1/g-24ce39d660600b3a98adff3075b6d1f4/proto"} ctx.subscriptionsMock.On( "Unsubscribe", @@ -235,6 +237,147 @@ func Test_Unsubscribe(t *testing.T) { "Unsubscribe", mock.Anything, INSTALLATION_ID, - topics, + mock.Anything, ) } + +func Test_Subscribe_StringTopics(t *testing.T) { + ctx := setupTest(t) + + topicStr := "/xmtp/mls/1/g-24ce39d660600b3a98adff3075b6d1f4/proto" + ctx.subscriptionsMock.On("Subscribe", mock.Anything, INSTALLATION_ID, mock.Anything).Return(nil) + _, err := ctx.client.Subscribe(ctx.ctx, connect.NewRequest(&proto.SubscribeRequest{ + InstallationId: INSTALLATION_ID, + Topics: []string{topicStr}, + })) + require.NoError(t, err) + ctx.subscriptionsMock.AssertCalled(t, "Subscribe", mock.Anything, INSTALLATION_ID, mock.Anything) +} + +func Test_Subscribe_BytesTopics(t *testing.T) { + ctx := setupTest(t) + + parsed, _ := topicutil.ParseV3Topic("/xmtp/mls/1/g-24ce39d660600b3a98adff3075b6d1f4/proto") + ctx.subscriptionsMock.On("Subscribe", mock.Anything, INSTALLATION_ID, mock.Anything).Return(nil) + _, err := ctx.client.Subscribe(ctx.ctx, connect.NewRequest(&proto.SubscribeRequest{ + InstallationId: INSTALLATION_ID, + TopicsBytes: [][]byte{parsed.Bytes()}, + })) + require.NoError(t, err) +} + +func Test_Subscribe_InvalidStringTopic(t *testing.T) { + ctx := setupTest(t) + + _, err := ctx.client.Subscribe(ctx.ctx, connect.NewRequest(&proto.SubscribeRequest{ + InstallationId: INSTALLATION_ID, + Topics: []string{"invalid-topic"}, + })) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid") +} + +func Test_Subscribe_InvalidBytesTopics(t *testing.T) { + ctx := setupTest(t) + + _, err := ctx.client.Subscribe(ctx.ctx, connect.NewRequest(&proto.SubscribeRequest{ + InstallationId: INSTALLATION_ID, + TopicsBytes: [][]byte{{0xFF}}, // Too short, invalid kind + })) + require.Error(t, err) +} + +func Test_Subscribe_MergedTopics(t *testing.T) { + ctx := setupTest(t) + + parsed, _ := topicutil.ParseV3Topic("/xmtp/mls/1/g-24ce39d660600b3a98adff3075b6d1f4/proto") + ctx.subscriptionsMock.On("Subscribe", mock.Anything, INSTALLATION_ID, mock.MatchedBy(func(topics []*topicpkg.Topic) bool { + return len(topics) == 1 // deduplicated + })).Return(nil) + _, err := ctx.client.Subscribe(ctx.ctx, connect.NewRequest(&proto.SubscribeRequest{ + InstallationId: INSTALLATION_ID, + Topics: []string{"/xmtp/mls/1/g-24ce39d660600b3a98adff3075b6d1f4/proto"}, + TopicsBytes: [][]byte{parsed.Bytes()}, + })) + require.NoError(t, err) +} + +func Test_Subscribe_EmptyTopics(t *testing.T) { + ctx := setupTest(t) + + ctx.subscriptionsMock.On("Subscribe", mock.Anything, INSTALLATION_ID, mock.Anything).Return(nil) + _, err := ctx.client.Subscribe(ctx.ctx, connect.NewRequest(&proto.SubscribeRequest{ + InstallationId: INSTALLATION_ID, + })) + require.NoError(t, err) +} + +func Test_Unsubscribe_BytesTopics(t *testing.T) { + ctx := setupTest(t) + + parsed, _ := topicutil.ParseV3Topic("/xmtp/mls/1/g-24ce39d660600b3a98adff3075b6d1f4/proto") + ctx.subscriptionsMock.On("Unsubscribe", mock.Anything, INSTALLATION_ID, mock.MatchedBy(func(topics []*topicpkg.Topic) bool { + return len(topics) == 1 + })).Return(nil) + _, err := ctx.client.Unsubscribe(ctx.ctx, connect.NewRequest(&proto.UnsubscribeRequest{ + InstallationId: INSTALLATION_ID, + TopicsBytes: [][]byte{parsed.Bytes()}, + })) + require.NoError(t, err) +} + +func Test_SubscribeWithMetadata_StringTopic(t *testing.T) { + ctx := setupTest(t) + + ctx.subscriptionsMock.On("SubscribeWithMetadata", mock.Anything, INSTALLATION_ID, mock.Anything).Return(nil) + _, err := ctx.client.SubscribeWithMetadata(ctx.ctx, connect.NewRequest(&proto.SubscribeWithMetadataRequest{ + InstallationId: INSTALLATION_ID, + Subscriptions: []*proto.Subscription{{ + Topic: "/xmtp/mls/1/g-24ce39d660600b3a98adff3075b6d1f4/proto", + IsSilent: true, + }}, + })) + require.NoError(t, err) + ctx.subscriptionsMock.AssertCalled(t, "SubscribeWithMetadata", mock.Anything, INSTALLATION_ID, mock.Anything) +} + +func Test_SubscribeWithMetadata_BytesTakesPrecedence(t *testing.T) { + ctx := setupTest(t) + + parsed, _ := topicutil.ParseV3Topic("/xmtp/mls/1/w-abcdef0123456789/proto") + ctx.subscriptionsMock.On("SubscribeWithMetadata", mock.Anything, INSTALLATION_ID, mock.MatchedBy(func(inputs []interfaces.SubscriptionInput) bool { + return len(inputs) == 1 && inputs[0].Topic.Kind() == topicpkg.TopicKindWelcomeMessagesV1 + })).Return(nil) + _, err := ctx.client.SubscribeWithMetadata(ctx.ctx, connect.NewRequest(&proto.SubscribeWithMetadataRequest{ + InstallationId: INSTALLATION_ID, + Subscriptions: []*proto.Subscription{{ + Topic: "/xmtp/mls/1/g-24ce39d660600b3a98adff3075b6d1f4/proto", + TopicBytes: parsed.Bytes(), + }}, + })) + require.NoError(t, err) +} + +func Test_SubscribeWithMetadata_InvalidTopic(t *testing.T) { + ctx := setupTest(t) + + _, err := ctx.client.SubscribeWithMetadata(ctx.ctx, connect.NewRequest(&proto.SubscribeWithMetadataRequest{ + InstallationId: INSTALLATION_ID, + Subscriptions: []*proto.Subscription{{ + Topic: "not-a-valid-topic", + }}, + })) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid") +} + +func Test_SubscribeWithMetadata_EmptyTopic(t *testing.T) { + ctx := setupTest(t) + + _, err := ctx.client.SubscribeWithMetadata(ctx.ctx, connect.NewRequest(&proto.SubscribeWithMetadataRequest{ + InstallationId: INSTALLATION_ID, + Subscriptions: []*proto.Subscription{{}}, + })) + require.Error(t, err) + require.Contains(t, err.Error(), "no topic") +} diff --git a/pkg/db/db.go b/pkg/db/db.go index 97d8ee09..ff04b29f 100644 --- a/pkg/db/db.go +++ b/pkg/db/db.go @@ -33,6 +33,10 @@ func Migrate(ctx context.Context, db *sql.DB) error { return migrations.Migrate(ctx, db) } +func MigrateUpTo(ctx context.Context, db *sql.DB, version uint) error { + return migrations.MigrateUpTo(ctx, db, version) +} + func CreateMigrationFiles(name string) ([]migrations.File, error) { return migrations.CreateFiles("pkg/db/migrations", name) } diff --git a/pkg/db/db_test.go b/pkg/db/db_test.go index 7f3ad2b0..54603984 100644 --- a/pkg/db/db_test.go +++ b/pkg/db/db_test.go @@ -80,6 +80,53 @@ func TestMigrateExistingLegacySchema(t *testing.T) { require.Equal(t, latest, version) } +func TestMigration_BinaryTopics_DataConversion(t *testing.T) { + db := testdb.CreateEmptyTestDb(t) + + // Run migrations up through 00003 (db_perf_improvements, pre-binary schema) + require.NoError(t, database.MigrateUpTo(t.Context(), db, 3)) + + // Seed rows: conforming group, conforming welcome, non-conforming, duplicate (case diff) + _, err := db.ExecContext(t.Context(), ` + INSERT INTO installations (id) VALUES ('inst1'); + INSERT INTO subscriptions (installation_id, topic, is_active) VALUES + ('inst1', '/xmtp/mls/1/g-24ce39d660600b3a98adff3075b6d1f4/proto', TRUE), + ('inst1', '/xmtp/mls/1/w-f3ac64eba2272334/proto', TRUE), + ('inst1', '/xmtp/mls/1/w-test_installation/proto', TRUE), + ('inst1', '/xmtp/mls/1/g-24CE39D660600B3A98ADFF3075B6D1F4/proto', TRUE); + `) + require.NoError(t, err) + + // Run migration 00004 (binary conversion) + require.NoError(t, database.MigrateUpTo(t.Context(), db, 4)) + + // Verify: group topic converted correctly (first byte 0x00 = TopicKindGroupMessagesV1) + var topicBytes []byte + err = db.QueryRowContext(t.Context(), + "SELECT topic FROM subscriptions WHERE installation_id = 'inst1' AND get_byte(topic, 0) = 0 LIMIT 1", + ).Scan(&topicBytes) + require.NoError(t, err) + require.Equal(t, byte(0x00), topicBytes[0]) // TopicKindGroupMessagesV1 + + // Verify: non-conforming row deleted, duplicate collapsed — 2 rows remain + var count int + err = db.QueryRowContext(t.Context(), + "SELECT COUNT(*) FROM subscriptions WHERE installation_id = 'inst1'", + ).Scan(&count) + require.NoError(t, err) + require.Equal(t, 2, count) + + // Run migration 00005 (drop legacy column) + require.NoError(t, database.MigrateUpTo(t.Context(), db, 5)) + + // Verify topic_legacy column does not exist + err = db.QueryRowContext(t.Context(), + "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'subscriptions' AND column_name = 'topic_legacy'", + ).Scan(&count) + require.NoError(t, err) + require.Equal(t, 0, count) +} + func createRawDB(t *testing.T) *sql.DB { t.Helper() diff --git a/pkg/db/migrations/00003_binary_topics.down.sql b/pkg/db/migrations/00003_binary_topics.down.sql new file mode 100644 index 00000000..c282a269 --- /dev/null +++ b/pkg/db/migrations/00003_binary_topics.down.sql @@ -0,0 +1,10 @@ +DROP INDEX IF EXISTS subscriptions_topic_is_active_idx; +DROP INDEX IF EXISTS subscriptions_installation_id_topic_idx; + +ALTER TABLE subscriptions DROP COLUMN topic; +ALTER TABLE subscriptions RENAME COLUMN topic_legacy TO topic; + +CREATE INDEX subscriptions_topic_is_active_idx + ON subscriptions (topic, is_active); +CREATE UNIQUE INDEX subscriptions_installation_id_topic_idx + ON subscriptions (installation_id, topic); diff --git a/pkg/db/migrations/00003_binary_topics.up.sql b/pkg/db/migrations/00003_binary_topics.up.sql new file mode 100644 index 00000000..e14fdae7 --- /dev/null +++ b/pkg/db/migrations/00003_binary_topics.up.sql @@ -0,0 +1,42 @@ +-- Step 1: Drop indexes that reference the topic column +DROP INDEX IF EXISTS subscriptions_topic_is_active_idx; +DROP INDEX IF EXISTS subscriptions_installation_id_topic_idx; + +-- Step 2: Rename existing column +ALTER TABLE subscriptions RENAME COLUMN topic TO topic_legacy; + +-- Step 3: Add new binary column +ALTER TABLE subscriptions ADD COLUMN topic BYTEA; + +-- Step 4: Single-pass conversion using lower() inline (topic_legacy is not modified) +UPDATE subscriptions +SET topic = CASE + WHEN lower(topic_legacy) ~ '^/xmtp/mls/1/g-[0-9a-f]+/proto$' + THEN decode('00', 'hex') || decode( + substring(lower(topic_legacy) FROM '/xmtp/mls/1/g-(.+)/proto$'), 'hex') + WHEN lower(topic_legacy) ~ '^/xmtp/mls/1/w-[0-9a-f]+/proto$' + THEN decode('01', 'hex') || decode( + substring(lower(topic_legacy) FROM '/xmtp/mls/1/w-(.+)/proto$'), 'hex') + ELSE NULL +END; + +-- Step 5: Delete rows that couldn't be converted +DELETE FROM subscriptions WHERE topic IS NULL; + +-- Step 6: Deduplicate rows that collapsed to the same (installation_id, topic) after +-- hex normalization (e.g., rows differing only by hex casing). Keep the newest row. +DELETE FROM subscriptions older +USING subscriptions newer +WHERE older.installation_id = newer.installation_id + AND older.topic = newer.topic + AND older.id < newer.id; + +-- Step 7: Add NOT NULL constraint +ALTER TABLE subscriptions ALTER COLUMN topic SET NOT NULL; + +-- Step 8: Recreate indexes on new column +CREATE INDEX subscriptions_topic_is_active_idx + ON subscriptions (topic, is_active); + +CREATE UNIQUE INDEX subscriptions_installation_id_topic_idx + ON subscriptions (installation_id, topic); diff --git a/pkg/db/migrations/00004_drop_topic_legacy.down.sql b/pkg/db/migrations/00004_drop_topic_legacy.down.sql new file mode 100644 index 00000000..75511d84 --- /dev/null +++ b/pkg/db/migrations/00004_drop_topic_legacy.down.sql @@ -0,0 +1 @@ +ALTER TABLE subscriptions ADD COLUMN topic_legacy TEXT; diff --git a/pkg/db/migrations/00004_drop_topic_legacy.up.sql b/pkg/db/migrations/00004_drop_topic_legacy.up.sql new file mode 100644 index 00000000..d69c7224 --- /dev/null +++ b/pkg/db/migrations/00004_drop_topic_legacy.up.sql @@ -0,0 +1 @@ +ALTER TABLE subscriptions DROP COLUMN topic_legacy; diff --git a/pkg/db/migrations/migrations.go b/pkg/db/migrations/migrations.go index a1740cdb..65dfb0ce 100644 --- a/pkg/db/migrations/migrations.go +++ b/pkg/db/migrations/migrations.go @@ -56,6 +56,19 @@ func LatestVersion() (int, error) { // deployments keep their existing application tables while still allowing future // golang-migrate-only migrations to run normally after upgrade. func Migrate(ctx context.Context, db *sql.DB) error { + return withMigrator(ctx, db, func(m *migrate.Migrate) error { + return m.Up() + }) +} + +// MigrateUpTo runs migrations up to (and including) the given version. +func MigrateUpTo(ctx context.Context, db *sql.DB, version uint) error { + return withMigrator(ctx, db, func(m *migrate.Migrate) error { + return m.Migrate(version) + }) +} + +func withMigrator(ctx context.Context, db *sql.DB, fn func(*migrate.Migrate) error) error { if err := reconcileExistingBunSchema(ctx, db); err != nil { return err } @@ -92,7 +105,7 @@ func Migrate(ctx context.Context, db *sql.DB) error { _, _ = migrator.Close() }() - if err := migrator.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + if err := fn(migrator); err != nil && !errors.Is(err, migrate.ErrNoChange) { return err } diff --git a/pkg/db/queries/models.go b/pkg/db/queries/models.go index 376dcd10..838dbb49 100644 --- a/pkg/db/queries/models.go +++ b/pkg/db/queries/models.go @@ -6,37 +6,36 @@ package queries import ( "database/sql" - "time" ) type DeviceDeliveryMechanism struct { - ID int64 + ID int32 InstallationID string - CreatedAt time.Time - UpdatedAt time.Time + CreatedAt sql.NullTime + UpdatedAt sql.NullTime Kind string Token string } type Installation struct { ID string - CreatedAt time.Time + CreatedAt sql.NullTime DeletedAt sql.NullTime } type Subscription struct { - ID int64 + ID int32 InstallationID string - CreatedAt time.Time - Topic string - IsActive bool - IsSilent bool + CreatedAt sql.NullTime + Topic []byte + IsActive sql.NullBool + IsSilent sql.NullBool } type SubscriptionHmacKey struct { - SubscriptionID int64 + SubscriptionID int32 ThirtyDayPeriodsSinceEpoch int32 - CreatedAt time.Time - UpdatedAt time.Time + CreatedAt sql.NullTime + UpdatedAt sql.NullTime Key []byte } diff --git a/pkg/db/queries/subscriptions.sql.go b/pkg/db/queries/subscriptions.sql.go index 961e8195..a688f348 100644 --- a/pkg/db/queries/subscriptions.sql.go +++ b/pkg/db/queries/subscriptions.sql.go @@ -8,123 +8,56 @@ package queries import ( "context" "database/sql" - "time" "github.com/lib/pq" ) -const batchInsertSubscriptions = `-- name: BatchInsertSubscriptions :exec -INSERT INTO subscriptions (installation_id, topic, is_active, is_silent) -SELECT $1::text, t.topic, TRUE, FALSE -FROM unnest($2::text[]) AS t(topic) -ON CONFLICT (installation_id, topic) DO NOTHING -` - -type BatchInsertSubscriptionsParams struct { - InstallationID string - Topics []string -} - -func (q *Queries) BatchInsertSubscriptions(ctx context.Context, arg BatchInsertSubscriptionsParams) error { - _, err := q.db.ExecContext(ctx, batchInsertSubscriptions, arg.InstallationID, pq.Array(arg.Topics)) - return err -} - -const batchUpsertSubscriptionHmacKeys = `-- name: BatchUpsertSubscriptionHmacKeys :exec -INSERT INTO subscription_hmac_keys (subscription_id, thirty_day_periods_since_epoch, key) -SELECT t.sub_id, t.period, t.hmac_key -FROM ROWS FROM ( - unnest($1::bigint[]), - unnest($2::integer[]), - unnest($3::bytea[]) -) AS t(sub_id, period, hmac_key) -ON CONFLICT (subscription_id, thirty_day_periods_since_epoch) DO UPDATE -SET key = EXCLUDED.key, updated_at = NOW() -` - -type BatchUpsertSubscriptionHmacKeysParams struct { - SubscriptionIds []int64 - Periods []int32 - Keys [][]byte -} - -func (q *Queries) BatchUpsertSubscriptionHmacKeys(ctx context.Context, arg BatchUpsertSubscriptionHmacKeysParams) error { - _, err := q.db.ExecContext(ctx, batchUpsertSubscriptionHmacKeys, pq.Array(arg.SubscriptionIds), pq.Array(arg.Periods), pq.Array(arg.Keys)) - return err -} - -const batchUpsertSubscriptions = `-- name: BatchUpsertSubscriptions :many -INSERT INTO subscriptions (installation_id, topic, is_active, is_silent) -SELECT $1::text, t.topic, TRUE, t.is_silent -FROM ROWS FROM ( - unnest($2::text[]), - unnest($3::boolean[]) -) AS t(topic, is_silent) -ON CONFLICT (installation_id, topic) DO UPDATE -SET is_active = TRUE, is_silent = EXCLUDED.is_silent -RETURNING id, topic -` - -type BatchUpsertSubscriptionsParams struct { - InstallationID string - Topics []string - IsSilents []bool -} - -type BatchUpsertSubscriptionsRow struct { - ID int64 - Topic string -} - -func (q *Queries) BatchUpsertSubscriptions(ctx context.Context, arg BatchUpsertSubscriptionsParams) ([]BatchUpsertSubscriptionsRow, error) { - rows, err := q.db.QueryContext(ctx, batchUpsertSubscriptions, arg.InstallationID, pq.Array(arg.Topics), pq.Array(arg.IsSilents)) - if err != nil { - return nil, err - } - defer rows.Close() - var items []BatchUpsertSubscriptionsRow - for rows.Next() { - var i BatchUpsertSubscriptionsRow - if err := rows.Scan(&i.ID, &i.Topic); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const deactivateInstallationSubscriptions = `-- name: DeactivateInstallationSubscriptions :exec +const deactivateSubscriptions = `-- name: DeactivateSubscriptions :exec UPDATE subscriptions SET is_active = FALSE WHERE installation_id = $1 - AND is_active = TRUE + AND topic = ANY($2::bytea[]) ` -func (q *Queries) DeactivateInstallationSubscriptions(ctx context.Context, installationID string) error { - _, err := q.db.ExecContext(ctx, deactivateInstallationSubscriptions, installationID) +type DeactivateSubscriptionsParams struct { + InstallationID string + Topics [][]byte +} + +func (q *Queries) DeactivateSubscriptions(ctx context.Context, arg DeactivateSubscriptionsParams) error { + _, err := q.db.ExecContext(ctx, deactivateSubscriptions, arg.InstallationID, pq.Array(arg.Topics)) return err } -const deactivateSubscriptions = `-- name: DeactivateSubscriptions :exec -UPDATE subscriptions -SET is_active = FALSE -WHERE installation_id = $1 - AND topic = ANY($2::text[]) +const insertSubscription = `-- name: InsertSubscription :exec +INSERT INTO subscriptions ( + installation_id, + topic, + is_active, + is_silent +) +VALUES ( + $1, + $2, + $3, + $4 +) ` -type DeactivateSubscriptionsParams struct { +type InsertSubscriptionParams struct { InstallationID string - Topics []string + Topic []byte + IsActive sql.NullBool + IsSilent sql.NullBool } -func (q *Queries) DeactivateSubscriptions(ctx context.Context, arg DeactivateSubscriptionsParams) error { - _, err := q.db.ExecContext(ctx, deactivateSubscriptions, arg.InstallationID, pq.Array(arg.Topics)) +func (q *Queries) InsertSubscription(ctx context.Context, arg InsertSubscriptionParams) error { + _, err := q.db.ExecContext(ctx, insertSubscription, + arg.InstallationID, + arg.Topic, + arg.IsActive, + arg.IsSilent, + ) return err } @@ -150,16 +83,16 @@ ORDER BY s.id type ListActiveSubscriptionsByTopicAndPeriodParams struct { ThirtyDayPeriod int32 - Topic string + Topic []byte } type ListActiveSubscriptionsByTopicAndPeriodRow struct { - ID int64 - CreatedAt time.Time + ID int32 + CreatedAt sql.NullTime InstallationID string - Topic string - IsActive bool - IsSilent bool + Topic []byte + IsActive sql.NullBool + IsSilent sql.NullBool HasHmacKey interface{} ThirtyDayPeriodsSinceEpoch sql.NullInt32 Key []byte @@ -202,22 +135,22 @@ const reactivateSubscriptions = `-- name: ReactivateSubscriptions :many UPDATE subscriptions SET is_active = TRUE WHERE installation_id = $1 - AND topic = ANY($2::text[]) + AND topic = ANY($2::bytea[]) RETURNING id, created_at, installation_id, topic, is_active, is_silent ` type ReactivateSubscriptionsParams struct { InstallationID string - Topics []string + Topics [][]byte } type ReactivateSubscriptionsRow struct { - ID int64 - CreatedAt time.Time + ID int32 + CreatedAt sql.NullTime InstallationID string - Topic string - IsActive bool - IsSilent bool + Topic []byte + IsActive sql.NullBool + IsSilent sql.NullBool } func (q *Queries) ReactivateSubscriptions(ctx context.Context, arg ReactivateSubscriptionsParams) ([]ReactivateSubscriptionsRow, error) { @@ -249,3 +182,78 @@ func (q *Queries) ReactivateSubscriptions(ctx context.Context, arg ReactivateSub } return items, nil } + +const upsertSubscription = `-- name: UpsertSubscription :one +INSERT INTO subscriptions ( + installation_id, + topic, + is_active, + is_silent +) +VALUES ( + $1, + $2, + TRUE, + $3 +) +ON CONFLICT (installation_id, topic) DO UPDATE +SET is_active = TRUE, + is_silent = EXCLUDED.is_silent +RETURNING id, created_at, installation_id, topic, is_active, is_silent +` + +type UpsertSubscriptionParams struct { + InstallationID string + Topic []byte + IsSilent sql.NullBool +} + +type UpsertSubscriptionRow struct { + ID int32 + CreatedAt sql.NullTime + InstallationID string + Topic []byte + IsActive sql.NullBool + IsSilent sql.NullBool +} + +func (q *Queries) UpsertSubscription(ctx context.Context, arg UpsertSubscriptionParams) (UpsertSubscriptionRow, error) { + row := q.db.QueryRowContext(ctx, upsertSubscription, arg.InstallationID, arg.Topic, arg.IsSilent) + var i UpsertSubscriptionRow + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.InstallationID, + &i.Topic, + &i.IsActive, + &i.IsSilent, + ) + return i, err +} + +const upsertSubscriptionHmacKey = `-- name: UpsertSubscriptionHmacKey :exec +INSERT INTO subscription_hmac_keys ( + subscription_id, + thirty_day_periods_since_epoch, + key +) +VALUES ( + $1, + $2, + $3 +) +ON CONFLICT (subscription_id, thirty_day_periods_since_epoch) DO UPDATE +SET key = EXCLUDED.key, + updated_at = NOW() +` + +type UpsertSubscriptionHmacKeyParams struct { + SubscriptionID int32 + ThirtyDayPeriodsSinceEpoch int32 + Key []byte +} + +func (q *Queries) UpsertSubscriptionHmacKey(ctx context.Context, arg UpsertSubscriptionHmacKeyParams) error { + _, err := q.db.ExecContext(ctx, upsertSubscriptionHmacKey, arg.SubscriptionID, arg.ThirtyDayPeriodsSinceEpoch, arg.Key) + return err +} diff --git a/pkg/db/sqlc/subscriptions.sql b/pkg/db/sqlc/subscriptions.sql index 046d129c..8da4fd24 100644 --- a/pkg/db/sqlc/subscriptions.sql +++ b/pkg/db/sqlc/subscriptions.sql @@ -2,14 +2,14 @@ UPDATE subscriptions SET is_active = TRUE WHERE installation_id = sqlc.arg(installation_id) - AND topic = ANY(sqlc.arg(topics)::text[]) + AND topic = ANY(sqlc.arg(topics)::bytea[]) RETURNING id, created_at, installation_id, topic, is_active, is_silent; -- name: DeactivateSubscriptions :exec UPDATE subscriptions SET is_active = FALSE WHERE installation_id = sqlc.arg(installation_id) - AND topic = ANY(sqlc.arg(topics)::text[]); + AND topic = ANY(sqlc.arg(topics)::bytea[]); -- name: ListActiveSubscriptionsByTopicAndPeriod :many SELECT diff --git a/pkg/delivery/apns.go b/pkg/delivery/apns.go index 52380130..1f216a82 100644 --- a/pkg/delivery/apns.go +++ b/pkg/delivery/apns.go @@ -2,9 +2,10 @@ package delivery import ( "context" + "encoding/base64" "errors" - "strings" "os" + "strings" "github.com/sideshow/apns2" "github.com/sideshow/apns2/payload" @@ -64,12 +65,7 @@ func (a ApnsDelivery) Send(ctx context.Context, req interfaces.SendRequest) erro return errors.New("missing message") } - notification := a.buildNotification(req.Subscription.IsSilent, - req.Installation.DeliveryMechanism.Token, - req.Message.ContentTopic, - string(req.MessageContext.MessageType), - req.Message.Message, - ) + notification := a.buildNotification(req) res, err := a.apnsClient.PushWithContext(ctx, notification) if res != nil { @@ -84,13 +80,19 @@ func (a ApnsDelivery) Send(ctx context.Context, req interfaces.SendRequest) erro return err } -func (a ApnsDelivery) buildNotification(isSilent bool, token string, contentTopic string, messageKind string, messageBytes []byte) *apns2.Notification { +func (a ApnsDelivery) buildNotification(req interfaces.SendRequest) *apns2.Notification { + var topicBytesB64 string + if req.Subscription.Topic != nil { + topicBytesB64 = base64.StdEncoding.EncodeToString(req.Subscription.Topic.Bytes()) + } + notificationPayload := payload.NewPayload(). - Custom("topic", contentTopic). - Custom("encryptedMessage", messageBytes). - Custom("messageKind", messageKind) + Custom("topic", req.Subscription.TopicString). + Custom("topicBytesB64", topicBytesB64). + Custom("encryptedMessage", req.Message.Message). + Custom("messageKind", string(req.MessageContext.MessageType)) - if isSilent { + if req.Subscription.IsSilent { notificationPayload = notificationPayload.ContentAvailable() } else { notificationPayload = notificationPayload. @@ -99,7 +101,7 @@ func (a ApnsDelivery) buildNotification(isSilent bool, token string, contentTopi } return &apns2.Notification{ - DeviceToken: token, + DeviceToken: req.Installation.DeliveryMechanism.Token, Topic: a.opts.Topic, Payload: notificationPayload, } diff --git a/pkg/delivery/apns_test.go b/pkg/delivery/apns_test.go new file mode 100644 index 00000000..a17163e5 --- /dev/null +++ b/pkg/delivery/apns_test.go @@ -0,0 +1,43 @@ +package delivery + +import ( + "encoding/base64" + "encoding/json" + "testing" + + "github.com/sideshow/apns2/payload" + "github.com/stretchr/testify/require" + "github.com/xmtp/example-notification-server-go/pkg/interfaces" + "github.com/xmtp/example-notification-server-go/pkg/options" + v1 "github.com/xmtp/example-notification-server-go/pkg/proto/message_api/v1" + "github.com/xmtp/example-notification-server-go/pkg/topics" +) + +func Test_ApnsDelivery_BuildNotification_DualTopicFields(t *testing.T) { + parsed, err := topics.ParseV3Topic("/xmtp/mls/1/g-24ce39d660600b3a98adff3075b6d1f4/proto") + require.NoError(t, err) + + a := ApnsDelivery{opts: options.ApnsOptions{Topic: "com.example.app"}} + req := interfaces.SendRequest{ + Message: &v1.Envelope{Message: []byte("test")}, + Subscription: interfaces.Subscription{ + Topic: parsed, + TopicString: topics.TopicToString(parsed), + }, + Installation: interfaces.Installation{ + DeliveryMechanism: interfaces.DeliveryMechanism{Token: "device-token"}, + }, + MessageContext: interfaces.MessageContext{MessageType: topics.V3Conversation}, + } + + notification := a.buildNotification(req) + payloadBytes, err := notification.Payload.(*payload.Payload).MarshalJSON() + require.NoError(t, err) + + var payload map[string]interface{} + require.NoError(t, json.Unmarshal(payloadBytes, &payload)) + require.Equal(t, "/xmtp/mls/1/g-24ce39d660600b3a98adff3075b6d1f4/proto", payload["topic"]) + require.Equal(t, base64.StdEncoding.EncodeToString(parsed.Bytes()), payload["topicBytesB64"]) + require.Equal(t, "device-token", notification.DeviceToken) + require.Equal(t, "com.example.app", notification.Topic) +} diff --git a/pkg/delivery/fcm.go b/pkg/delivery/fcm.go index 8cd396dc..b7d215a9 100644 --- a/pkg/delivery/fcm.go +++ b/pkg/delivery/fcm.go @@ -54,13 +54,7 @@ func (f FcmDelivery) Send(ctx context.Context, req interfaces.SendRequest) error return errors.New("missing message") } - message := base64.StdEncoding.EncodeToString(req.Message.Message) - topic := req.Message.ContentTopic - data := map[string]string{ - "topic": topic, - "encryptedMessage": message, - "messageType": string(req.MessageContext.MessageType), - } + data := buildFcmData(req) apnsHeaders := map[string]string{} androidPriority := "high" @@ -87,11 +81,11 @@ func (f FcmDelivery) Send(ctx context.Context, req interfaces.SendRequest) error APNS: &messaging.APNSConfig{ Headers: apnsHeaders, Payload: &messaging.APNSPayload{ - CustomData: map[string]interface { - }{ - "topic": topic, - "encryptedMessage": message, - "messageType": string(req.MessageContext.MessageType), + CustomData: map[string]interface{}{ + "topic": req.Subscription.TopicString, + "topicBytesB64": data["topicBytesB64"], + "encryptedMessage": data["encryptedMessage"], + "messageType": data["messageType"], }, Aps: &messaging.Aps{ ContentAvailable: req.Subscription.IsSilent, @@ -103,3 +97,17 @@ func (f FcmDelivery) Send(ctx context.Context, req interfaces.SendRequest) error return err } + +func buildFcmData(req interfaces.SendRequest) map[string]string { + var topicBytesB64 string + if req.Subscription.Topic != nil { + topicBytesB64 = base64.StdEncoding.EncodeToString(req.Subscription.Topic.Bytes()) + } + + return map[string]string{ + "topic": req.Subscription.TopicString, + "topicBytesB64": topicBytesB64, + "encryptedMessage": base64.StdEncoding.EncodeToString(req.Message.Message), + "messageType": string(req.MessageContext.MessageType), + } +} diff --git a/pkg/delivery/fcm_test.go b/pkg/delivery/fcm_test.go new file mode 100644 index 00000000..0039dbd7 --- /dev/null +++ b/pkg/delivery/fcm_test.go @@ -0,0 +1,31 @@ +package delivery + +import ( + "encoding/base64" + "testing" + + "github.com/stretchr/testify/require" + "github.com/xmtp/example-notification-server-go/pkg/interfaces" + v1 "github.com/xmtp/example-notification-server-go/pkg/proto/message_api/v1" + "github.com/xmtp/example-notification-server-go/pkg/topics" +) + +func Test_BuildFcmData_DualTopicFields(t *testing.T) { + parsed, err := topics.ParseV3Topic("/xmtp/mls/1/g-24ce39d660600b3a98adff3075b6d1f4/proto") + require.NoError(t, err) + + req := interfaces.SendRequest{ + Message: &v1.Envelope{Message: []byte("test")}, + Subscription: interfaces.Subscription{ + Topic: parsed, + TopicString: topics.TopicToString(parsed), + }, + MessageContext: interfaces.MessageContext{MessageType: topics.V3Conversation}, + } + + data := buildFcmData(req) + require.Equal(t, "/xmtp/mls/1/g-24ce39d660600b3a98adff3075b6d1f4/proto", data["topic"]) + require.Equal(t, base64.StdEncoding.EncodeToString(parsed.Bytes()), data["topicBytesB64"]) + require.Equal(t, base64.StdEncoding.EncodeToString([]byte("test")), data["encryptedMessage"]) + require.Equal(t, "v3-conversation", data["messageType"]) +} diff --git a/pkg/interfaces/interfaces.go b/pkg/interfaces/interfaces.go index 9bc7188f..bf39d149 100644 --- a/pkg/interfaces/interfaces.go +++ b/pkg/interfaces/interfaces.go @@ -4,10 +4,13 @@ import ( "context" "crypto/hmac" "crypto/sha256" + "encoding/base64" + "encoding/json" "time" v1 "github.com/xmtp/example-notification-server-go/pkg/proto/message_api/v1" "github.com/xmtp/example-notification-server-go/pkg/topics" + "github.com/xmtp/xmtpd/pkg/topic" ) type DeliveryMechanismKind string @@ -39,13 +42,34 @@ type Installation struct { } type Subscription struct { - Id int64 `json:"-"` - CreatedAt time.Time `json:"created_at"` - InstallationId string `json:"-"` - Topic string `json:"topic"` - IsActive bool `json:"-"` - IsSilent bool `json:"is_silent"` - HmacKey *HmacKey `json:"-"` + Id int64 `json:"-"` + CreatedAt time.Time `json:"created_at"` + InstallationId string `json:"-"` + Topic *topic.Topic `json:"-"` + TopicString string `json:"topic"` + IsActive bool `json:"-"` + IsSilent bool `json:"is_silent"` + HmacKey *HmacKey `json:"-"` +} + +type subscriptionJSON struct { + CreatedAt time.Time `json:"created_at"` + Topic string `json:"topic"` + TopicBytesB64 string `json:"topicBytesB64"` + IsSilent bool `json:"is_silent"` +} + +func (s Subscription) MarshalJSON() ([]byte, error) { + var topicB64 string + if s.Topic != nil { + topicB64 = base64.StdEncoding.EncodeToString(s.Topic.Bytes()) + } + return json.Marshal(subscriptionJSON{ + CreatedAt: s.CreatedAt, + Topic: s.TopicString, + TopicBytesB64: topicB64, + IsSilent: s.IsSilent, + }) } type SendRequest struct { @@ -79,13 +103,11 @@ type HmacKey struct { } type SubscriptionInput struct { - Topic string + Topic *topic.Topic IsSilent bool HmacKeys []HmacKey } -type HmacUpdates map[string][]HmacKey - // Pluggable Installation Service interface // //go:generate mockery --dir ../interfaces --name Installations --output ../../mocks --outpkg mocks @@ -99,9 +121,9 @@ type Installations interface { // //go:generate mockery --dir ../interfaces --name Subscriptions --output ../../mocks --outpkg mocks type Subscriptions interface { - Subscribe(ctx context.Context, installationId string, topics []string) error - Unsubscribe(ctx context.Context, installationId string, topics []string) error - GetSubscriptions(ctx context.Context, topic string, thirtyDayPeriod int) ([]Subscription, error) + Subscribe(ctx context.Context, installationId string, topics []*topic.Topic) error + Unsubscribe(ctx context.Context, installationId string, topics []*topic.Topic) error + GetSubscriptions(ctx context.Context, t *topic.Topic, thirtyDayPeriod int) ([]Subscription, error) SubscribeWithMetadata(ctx context.Context, installationId string, subscriptions []SubscriptionInput) error } diff --git a/pkg/interfaces/interfaces_test.go b/pkg/interfaces/interfaces_test.go new file mode 100644 index 00000000..0add682e --- /dev/null +++ b/pkg/interfaces/interfaces_test.go @@ -0,0 +1,38 @@ +package interfaces + +import ( + "encoding/base64" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + "github.com/xmtp/xmtpd/pkg/topic" +) + +func Test_Subscription_MarshalJSON_BothFields(t *testing.T) { + tp := topic.NewTopic(topic.TopicKindGroupMessagesV1, []byte{0x24, 0xce}) + sub := Subscription{ + Topic: tp, + TopicString: "/xmtp/mls/1/g-24ce/proto", + IsSilent: true, + } + data, err := json.Marshal(sub) + require.NoError(t, err) + + var result map[string]interface{} + require.NoError(t, json.Unmarshal(data, &result)) + require.Equal(t, "/xmtp/mls/1/g-24ce/proto", result["topic"]) + require.Equal(t, base64.StdEncoding.EncodeToString(tp.Bytes()), result["topicBytesB64"]) + require.Equal(t, true, result["is_silent"]) +} + +func Test_Subscription_MarshalJSON_NilTopic(t *testing.T) { + sub := Subscription{TopicString: "", IsSilent: false} + data, err := json.Marshal(sub) + require.NoError(t, err) + + var result map[string]interface{} + require.NoError(t, json.Unmarshal(data, &result)) + require.Equal(t, "", result["topic"]) + require.Equal(t, "", result["topicBytesB64"]) +} diff --git a/pkg/proto/notifications/v1/service.pb.go b/pkg/proto/notifications/v1/service.pb.go index d939711e..1e9ee4bb 100644 --- a/pkg/proto/notifications/v1/service.pb.go +++ b/pkg/proto/notifications/v1/service.pb.go @@ -274,10 +274,12 @@ func (x *DeleteInstallationRequest) GetInstallationId() string { // A subscription with associated metadata type Subscription struct { - state protoimpl.MessageState `protogen:"open.v1"` - Topic string `protobuf:"bytes,1,opt,name=topic,proto3" json:"topic,omitempty"` - HmacKeys []*Subscription_HmacKey `protobuf:"bytes,2,rep,name=hmac_keys,json=hmacKeys,proto3" json:"hmac_keys,omitempty"` - IsSilent bool `protobuf:"varint,3,opt,name=is_silent,json=isSilent,proto3" json:"is_silent,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Topic string `protobuf:"bytes,1,opt,name=topic,proto3" json:"topic,omitempty"` + HmacKeys []*Subscription_HmacKey `protobuf:"bytes,2,rep,name=hmac_keys,json=hmacKeys,proto3" json:"hmac_keys,omitempty"` + IsSilent bool `protobuf:"varint,3,opt,name=is_silent,json=isSilent,proto3" json:"is_silent,omitempty"` + // Binary topic (V4 format). If both topic and topic_bytes are set, topic_bytes takes precedence. + TopicBytes []byte `protobuf:"bytes,4,opt,name=topic_bytes,json=topicBytes,proto3" json:"topic_bytes,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -333,6 +335,13 @@ func (x *Subscription) GetIsSilent() bool { return false } +func (x *Subscription) GetTopicBytes() []byte { + if x != nil { + return x.TopicBytes + } + return nil +} + // A request to subscribe to a list of topics and update the associated metadata type SubscribeWithMetadataRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -391,8 +400,10 @@ type SubscribeRequest struct { state protoimpl.MessageState `protogen:"open.v1"` InstallationId string `protobuf:"bytes,1,opt,name=installation_id,json=installationId,proto3" json:"installation_id,omitempty"` Topics []string `protobuf:"bytes,2,rep,name=topics,proto3" json:"topics,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Binary topics (V4 format). Merged with string topics if both are set. + TopicsBytes [][]byte `protobuf:"bytes,3,rep,name=topics_bytes,json=topicsBytes,proto3" json:"topics_bytes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SubscribeRequest) Reset() { @@ -439,13 +450,22 @@ func (x *SubscribeRequest) GetTopics() []string { return nil } +func (x *SubscribeRequest) GetTopicsBytes() [][]byte { + if x != nil { + return x.TopicsBytes + } + return nil +} + // Unsubscribe from a list of topics type UnsubscribeRequest struct { state protoimpl.MessageState `protogen:"open.v1"` InstallationId string `protobuf:"bytes,1,opt,name=installation_id,json=installationId,proto3" json:"installation_id,omitempty"` Topics []string `protobuf:"bytes,2,rep,name=topics,proto3" json:"topics,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Binary topics (V4 format). Merged with string topics if both are set. + TopicsBytes [][]byte `protobuf:"bytes,3,rep,name=topics_bytes,json=topicsBytes,proto3" json:"topics_bytes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *UnsubscribeRequest) Reset() { @@ -492,6 +512,13 @@ func (x *UnsubscribeRequest) GetTopics() []string { return nil } +func (x *UnsubscribeRequest) GetTopicsBytes() [][]byte { + if x != nil { + return x.TopicsBytes + } + return nil +} + type Subscription_HmacKey struct { state protoimpl.MessageState `protogen:"open.v1"` ThirtyDayPeriodsSinceEpoch uint32 `protobuf:"varint,1,opt,name=thirty_day_periods_since_epoch,json=thirtyDayPeriodsSinceEpoch,proto3" json:"thirty_day_periods_since_epoch,omitempty"` @@ -562,23 +589,27 @@ const file_notifications_v1_service_proto_rawDesc = "" + "\vvalid_until\x18\x02 \x01(\x04R\n" + "validUntil\"D\n" + "\x19DeleteInstallationRequest\x12'\n" + - "\x0finstallation_id\x18\x01 \x01(\tR\x0einstallationId\"\xe7\x01\n" + + "\x0finstallation_id\x18\x01 \x01(\tR\x0einstallationId\"\x88\x02\n" + "\fSubscription\x12\x14\n" + "\x05topic\x18\x01 \x01(\tR\x05topic\x12C\n" + "\thmac_keys\x18\x02 \x03(\v2&.notifications.v1.Subscription.HmacKeyR\bhmacKeys\x12\x1b\n" + - "\tis_silent\x18\x03 \x01(\bR\bisSilent\x1a_\n" + + "\tis_silent\x18\x03 \x01(\bR\bisSilent\x12\x1f\n" + + "\vtopic_bytes\x18\x04 \x01(\fR\n" + + "topicBytes\x1a_\n" + "\aHmacKey\x12B\n" + "\x1ethirty_day_periods_since_epoch\x18\x01 \x01(\rR\x1athirtyDayPeriodsSinceEpoch\x12\x10\n" + "\x03key\x18\x02 \x01(\fR\x03key\"\x8d\x01\n" + "\x1cSubscribeWithMetadataRequest\x12'\n" + "\x0finstallation_id\x18\x01 \x01(\tR\x0einstallationId\x12D\n" + - "\rsubscriptions\x18\x02 \x03(\v2\x1e.notifications.v1.SubscriptionR\rsubscriptions\"S\n" + + "\rsubscriptions\x18\x02 \x03(\v2\x1e.notifications.v1.SubscriptionR\rsubscriptions\"v\n" + "\x10SubscribeRequest\x12'\n" + "\x0finstallation_id\x18\x01 \x01(\tR\x0einstallationId\x12\x16\n" + - "\x06topics\x18\x02 \x03(\tR\x06topics\"U\n" + + "\x06topics\x18\x02 \x03(\tR\x06topics\x12!\n" + + "\ftopics_bytes\x18\x03 \x03(\fR\vtopicsBytes\"x\n" + "\x12UnsubscribeRequest\x12'\n" + "\x0finstallation_id\x18\x01 \x01(\tR\x0einstallationId\x12\x16\n" + - "\x06topics\x18\x02 \x03(\tR\x06topics2\xd8\x03\n" + + "\x06topics\x18\x02 \x03(\tR\x06topics\x12!\n" + + "\ftopics_bytes\x18\x03 \x03(\fR\vtopicsBytes2\xd8\x03\n" + "\rNotifications\x12u\n" + "\x14RegisterInstallation\x12-.notifications.v1.RegisterInstallationRequest\x1a..notifications.v1.RegisterInstallationResponse\x12Y\n" + "\x12DeleteInstallation\x12+.notifications.v1.DeleteInstallationRequest\x1a\x16.google.protobuf.Empty\x12G\n" + diff --git a/pkg/subscriptions/subscriptions.go b/pkg/subscriptions/subscriptions.go index 0792dd85..5eb5ce47 100644 --- a/pkg/subscriptions/subscriptions.go +++ b/pkg/subscriptions/subscriptions.go @@ -3,10 +3,13 @@ package subscriptions import ( "context" "database/sql" + "errors" database "github.com/xmtp/example-notification-server-go/pkg/db" "github.com/xmtp/example-notification-server-go/pkg/db/queries" "github.com/xmtp/example-notification-server-go/pkg/interfaces" + topicutil "github.com/xmtp/example-notification-server-go/pkg/topics" + "github.com/xmtp/xmtpd/pkg/topic" "go.uber.org/zap" ) @@ -24,27 +27,31 @@ func NewSubscriptionsService(logger *zap.Logger, db *sql.DB) *SubscriptionsServi } } -func (s SubscriptionsService) Subscribe(ctx context.Context, installationID string, topics []string) error { +func (s SubscriptionsService) Subscribe(ctx context.Context, installationID string, topics []*topic.Topic) error { return database.RunInTx(ctx, s.db, func(qtx *queries.Queries) error { + topicBytes := make([][]byte, len(topics)) + for i, t := range topics { + topicBytes[i] = t.Bytes() + } + updated, err := qtx.ReactivateSubscriptions(ctx, queries.ReactivateSubscriptionsParams{ InstallationID: installationID, - Topics: topics, + Topics: topicBytes, }) if err != nil { return err } - topicMap := make(map[string]bool, len(topics)) - for _, topic := range topics { - topicMap[topic] = true - } + reactivated := make(map[string]bool, len(updated)) for _, result := range updated { - delete(topicMap, result.Topic) + reactivated[string(result.Topic)] = true } - remaining := make([]string, 0, len(topicMap)) - for topic := range topicMap { - remaining = append(remaining, topic) + var remaining [][]byte + for _, t := range topics { + if !reactivated[string(t.Bytes())] { + remaining = append(remaining, t.Bytes()) + } } if len(remaining) > 0 { @@ -68,16 +75,19 @@ func (s SubscriptionsService) SubscribeWithMetadata( return nil } - topics := make([]string, len(subscriptions)) + topicBytes := make([][]byte, len(subscriptions)) isSilents := make([]bool, len(subscriptions)) for i, sub := range subscriptions { - topics[i] = sub.Topic + if sub.Topic == nil { + return errors.New("subscription topic must not be nil") + } + topicBytes[i] = sub.Topic.Bytes() isSilents[i] = sub.IsSilent } rows, err := qtx.BatchUpsertSubscriptions(ctx, queries.BatchUpsertSubscriptionsParams{ InstallationID: installationID, - Topics: topics, + Topics: topicBytes, IsSilents: isSilents, }) if err != nil { @@ -86,7 +96,7 @@ func (s SubscriptionsService) SubscribeWithMetadata( topicToID := make(map[string]int64, len(rows)) for _, row := range rows { - topicToID[row.Topic] = row.ID + topicToID[string(row.Topic)] = row.ID } var ( @@ -95,7 +105,7 @@ func (s SubscriptionsService) SubscribeWithMetadata( keys [][]byte ) for _, sub := range subscriptions { - id := topicToID[sub.Topic] + id := topicToID[string(sub.Topic.Bytes())] for _, keyUpdate := range sub.HmacKeys { subscriptionIDs = append(subscriptionIDs, id) periods = append(periods, int32(keyUpdate.ThirtyDayPeriodsSinceEpoch)) @@ -115,23 +125,27 @@ func (s SubscriptionsService) SubscribeWithMetadata( }) } -func (s SubscriptionsService) Unsubscribe(ctx context.Context, installationID string, topics []string) error { +func (s SubscriptionsService) Unsubscribe(ctx context.Context, installationID string, topics []*topic.Topic) error { + topicBytes := make([][]byte, len(topics)) + for i, t := range topics { + topicBytes[i] = t.Bytes() + } return s.queries.DeactivateSubscriptions(ctx, queries.DeactivateSubscriptionsParams{ InstallationID: installationID, - Topics: topics, + Topics: topicBytes, }) } func (s SubscriptionsService) GetSubscriptions( ctx context.Context, - topic string, + t *topic.Topic, thirtyDayPeriod int, ) ([]interfaces.Subscription, error) { results, err := s.queries.ListActiveSubscriptionsByTopicAndPeriod( ctx, queries.ListActiveSubscriptionsByTopicAndPeriodParams{ ThirtyDayPeriod: int32(thirtyDayPeriod), - Topic: topic, + Topic: t.Bytes(), }, ) if err != nil { @@ -140,11 +154,17 @@ func (s SubscriptionsService) GetSubscriptions( out := make([]interfaces.Subscription, 0, len(results)) for _, result := range results { + parsedTopic, err := topic.ParseTopic(result.Topic) + if err != nil { + s.logger.Warn("failed to parse topic from DB", zap.Error(err)) + continue + } subscription := interfaces.Subscription{ Id: result.ID, CreatedAt: result.CreatedAt, InstallationId: result.InstallationID, - Topic: result.Topic, + Topic: parsedTopic, + TopicString: topicutil.TopicToString(parsedTopic), IsActive: result.IsActive, IsSilent: result.IsSilent, } diff --git a/pkg/subscriptions/subscriptions_test.go b/pkg/subscriptions/subscriptions_test.go index 49ecf4e7..61b8fc56 100644 --- a/pkg/subscriptions/subscriptions_test.go +++ b/pkg/subscriptions/subscriptions_test.go @@ -9,16 +9,20 @@ import ( "github.com/stretchr/testify/require" "github.com/xmtp/example-notification-server-go/pkg/interfaces" "github.com/xmtp/example-notification-server-go/pkg/testutils" + topicutil "github.com/xmtp/example-notification-server-go/pkg/topics" + topicpkg "github.com/xmtp/xmtpd/pkg/topic" ) const INSTALLATION_ID = "installation_1" -const TOPIC = "topic1" + +// Valid V3 topic and its parsed form for testing +var TEST_TOPIC, _ = topicutil.ParseV3Topic("/xmtp/mls/1/g-24ce39d660600b3a98adff3075b6d1f4/proto") type storedSubscription struct { ID int64 CreatedAt time.Time InstallationID string - Topic string + Topic []byte IsActive bool IsSilent bool } @@ -71,14 +75,14 @@ func Test_Subscribe(t *testing.T) { svc := createService(t, db) - err := svc.Subscribe(ctx, INSTALLATION_ID, []string{TOPIC}) + err := svc.Subscribe(ctx, INSTALLATION_ID, []*topicpkg.Topic{TEST_TOPIC}) require.NoError(t, err) stored := fetchSubscriptions(t, ctx, db, INSTALLATION_ID) require.Len(t, stored, 1) require.Equal(t, INSTALLATION_ID, stored[0].InstallationID) require.True(t, stored[0].IsActive) - require.Equal(t, TOPIC, stored[0].Topic) + require.Equal(t, TEST_TOPIC.Bytes(), stored[0].Topic) } func Test_SubscribeMultiple(t *testing.T) { @@ -86,16 +90,25 @@ func Test_SubscribeMultiple(t *testing.T) { db := testutils.CreateTestDb(t) svc := createService(t, db) - topics := []string{"topic_1", "topic_2", "topic_3"} + topic1 := topicpkg.NewTopic(topicpkg.TopicKindGroupMessagesV1, []byte{0x01}) + topic2 := topicpkg.NewTopic(topicpkg.TopicKindGroupMessagesV1, []byte{0x02}) + topic3 := topicpkg.NewTopic(topicpkg.TopicKindGroupMessagesV1, []byte{0x03}) + topics := []*topicpkg.Topic{topic1, topic2, topic3} + err := svc.Subscribe(ctx, INSTALLATION_ID, topics) require.NoError(t, err) stored := fetchSubscriptions(t, ctx, db, INSTALLATION_ID) require.Len(t, stored, 3) - for _, result := range stored { + + storedTopicBytes := make([][]byte, len(stored)) + for i, result := range stored { require.Equal(t, INSTALLATION_ID, result.InstallationID) require.NotZero(t, result.CreatedAt) - require.Contains(t, topics, result.Topic) + storedTopicBytes[i] = result.Topic + } + for _, tp := range topics { + require.Contains(t, storedTopicBytes, tp.Bytes()) } } @@ -104,10 +117,10 @@ func Test_Unsubscribe(t *testing.T) { db := testutils.CreateTestDb(t) svc := createService(t, db) - err := svc.Subscribe(ctx, INSTALLATION_ID, []string{TOPIC}) + err := svc.Subscribe(ctx, INSTALLATION_ID, []*topicpkg.Topic{TEST_TOPIC}) require.NoError(t, err) - err = svc.Unsubscribe(ctx, INSTALLATION_ID, []string{TOPIC}) + err = svc.Unsubscribe(ctx, INSTALLATION_ID, []*topicpkg.Topic{TEST_TOPIC}) require.NoError(t, err) stored := fetchSubscriptions(t, ctx, db, INSTALLATION_ID) @@ -120,9 +133,9 @@ func Test_UnsubscribeResubscribe(t *testing.T) { db := testutils.CreateTestDb(t) svc := createService(t, db) - require.NoError(t, svc.Subscribe(ctx, INSTALLATION_ID, []string{TOPIC})) - require.NoError(t, svc.Unsubscribe(ctx, INSTALLATION_ID, []string{TOPIC})) - require.NoError(t, svc.Subscribe(ctx, INSTALLATION_ID, []string{TOPIC})) + require.NoError(t, svc.Subscribe(ctx, INSTALLATION_ID, []*topicpkg.Topic{TEST_TOPIC})) + require.NoError(t, svc.Unsubscribe(ctx, INSTALLATION_ID, []*topicpkg.Topic{TEST_TOPIC})) + require.NoError(t, svc.Subscribe(ctx, INSTALLATION_ID, []*topicpkg.Topic{TEST_TOPIC})) stored := fetchSubscriptions(t, ctx, db, INSTALLATION_ID) require.Len(t, stored, 1) @@ -136,7 +149,7 @@ func Test_SubscribeWithMetadata(t *testing.T) { key := []byte("key") err := svc.SubscribeWithMetadata(ctx, INSTALLATION_ID, []interfaces.SubscriptionInput{{ - Topic: TOPIC, + Topic: TEST_TOPIC, IsSilent: true, HmacKeys: []interfaces.HmacKey{{ ThirtyDayPeriodsSinceEpoch: 1, @@ -145,10 +158,11 @@ func Test_SubscribeWithMetadata(t *testing.T) { }}) require.NoError(t, err) - results, err := svc.GetSubscriptions(ctx, TOPIC, 1) + results, err := svc.GetSubscriptions(ctx, TEST_TOPIC, 1) require.NoError(t, err) require.Len(t, results, 1) - require.Equal(t, TOPIC, results[0].Topic) + require.Equal(t, TEST_TOPIC.Kind(), results[0].Topic.Kind()) + require.Equal(t, TEST_TOPIC.Bytes(), results[0].Topic.Bytes()) require.NotNil(t, results[0].HmacKey) require.True(t, results[0].IsSilent) require.Equal(t, key, results[0].HmacKey.Key) @@ -160,21 +174,21 @@ func Test_UpdateIsSilent(t *testing.T) { svc := createService(t, db) require.NoError(t, svc.SubscribeWithMetadata(ctx, INSTALLATION_ID, []interfaces.SubscriptionInput{{ - Topic: TOPIC, + Topic: TEST_TOPIC, IsSilent: false, }})) - results, err := svc.GetSubscriptions(ctx, TOPIC, 1) + results, err := svc.GetSubscriptions(ctx, TEST_TOPIC, 1) require.NoError(t, err) require.Len(t, results, 1) require.False(t, results[0].IsSilent) require.NoError(t, svc.SubscribeWithMetadata(ctx, INSTALLATION_ID, []interfaces.SubscriptionInput{{ - Topic: TOPIC, + Topic: TEST_TOPIC, IsSilent: true, }})) - results, err = svc.GetSubscriptions(ctx, TOPIC, 1) + results, err = svc.GetSubscriptions(ctx, TEST_TOPIC, 1) require.NoError(t, err) require.Len(t, results, 1) require.True(t, results[0].IsSilent) @@ -186,7 +200,7 @@ func Test_UpdateHmacKeys(t *testing.T) { svc := createService(t, db) require.NoError(t, svc.SubscribeWithMetadata(ctx, INSTALLATION_ID, []interfaces.SubscriptionInput{{ - Topic: TOPIC, + Topic: TEST_TOPIC, IsSilent: true, HmacKeys: []interfaces.HmacKey{{ ThirtyDayPeriodsSinceEpoch: 1, @@ -195,7 +209,7 @@ func Test_UpdateHmacKeys(t *testing.T) { }})) require.NoError(t, svc.SubscribeWithMetadata(ctx, INSTALLATION_ID, []interfaces.SubscriptionInput{{ - Topic: TOPIC, + Topic: TEST_TOPIC, IsSilent: true, HmacKeys: []interfaces.HmacKey{{ ThirtyDayPeriodsSinceEpoch: 1, @@ -203,7 +217,7 @@ func Test_UpdateHmacKeys(t *testing.T) { }}, }})) - results, err := svc.GetSubscriptions(ctx, TOPIC, 1) + results, err := svc.GetSubscriptions(ctx, TEST_TOPIC, 1) require.NoError(t, err) require.Len(t, results, 1) require.Equal(t, []byte("key2"), results[0].HmacKey.Key) @@ -214,9 +228,22 @@ func Test_GetSubscriptions(t *testing.T) { db := testutils.CreateTestDb(t) svc := createService(t, db) - require.NoError(t, svc.Subscribe(ctx, INSTALLATION_ID, []string{TOPIC})) + require.NoError(t, svc.Subscribe(ctx, INSTALLATION_ID, []*topicpkg.Topic{TEST_TOPIC})) - subs, err := svc.GetSubscriptions(ctx, TOPIC, 1) + subs, err := svc.GetSubscriptions(ctx, TEST_TOPIC, 1) require.NoError(t, err) require.Len(t, subs, 1) } + +func Test_SubscribeWithMetadata_NilTopic(t *testing.T) { + ctx := t.Context() + db := testutils.CreateTestDb(t) + svc := createService(t, db) + + err := svc.SubscribeWithMetadata(ctx, INSTALLATION_ID, []interfaces.SubscriptionInput{{ + Topic: nil, + IsSilent: false, + }}) + require.Error(t, err) + require.Contains(t, err.Error(), "nil") +} diff --git a/pkg/topics/topics.go b/pkg/topics/topics.go index 33e39426..19a2bb0f 100644 --- a/pkg/topics/topics.go +++ b/pkg/topics/topics.go @@ -1,31 +1,71 @@ package topics import ( + "encoding/hex" + "fmt" "strings" - v1 "github.com/xmtp/example-notification-server-go/pkg/proto/message_api/v1" + "github.com/xmtp/xmtpd/pkg/topic" ) const V3_PREFIX = "/xmtp/mls/1/" -var messageTypeByPrefix = map[string]MessageType{ - "test": Test, - "g": V3Conversation, - "w": V3Welcome, +func ParseV3Topic(topicStr string) (*topic.Topic, error) { + topicStr = strings.TrimPrefix(topicStr, V3_PREFIX) + if !strings.HasSuffix(topicStr, "/proto") { + return nil, fmt.Errorf("invalid V3 topic: missing /proto suffix") + } + topicStr = strings.TrimSuffix(topicStr, "/proto") + + prefix, identifier, hasPrefix := strings.Cut(topicStr, "-") + if !hasPrefix || identifier == "" { + return nil, fmt.Errorf("invalid V3 topic: missing prefix or identifier") + } + + var kind topic.TopicKind + switch prefix { + case "g": + kind = topic.TopicKindGroupMessagesV1 + case "w": + kind = topic.TopicKindWelcomeMessagesV1 + default: + return nil, fmt.Errorf("invalid V3 topic: unknown prefix %q", prefix) + } + + identifierBytes, err := hex.DecodeString(strings.ToLower(identifier)) + if err != nil { + return nil, fmt.Errorf("invalid V3 topic: bad hex identifier: %w", err) + } + + return topic.NewTopic(kind, identifierBytes), nil } -func GetMessageType(env *v1.Envelope) MessageType { - topic := env.ContentTopic - if strings.HasPrefix(topic, "test-") { - return Test +func TopicToString(t *topic.Topic) string { + if t == nil { + return "" } - topic = strings.TrimPrefix(topic, V3_PREFIX) - prefix, _, hasPrefix := strings.Cut(topic, "-") - if hasPrefix { - if category, found := messageTypeByPrefix[prefix]; found { - return category - } + var prefix string + switch t.Kind() { + case topic.TopicKindGroupMessagesV1: + prefix = "g" + case topic.TopicKindWelcomeMessagesV1: + prefix = "w" + default: + return "" } + return V3_PREFIX + prefix + "-" + hex.EncodeToString(t.Identifier()) + "/proto" +} - return Unknown +func GetMessageTypeFromTopic(t *topic.Topic) MessageType { + if t == nil { + return Unknown + } + switch t.Kind() { + case topic.TopicKindGroupMessagesV1: + return V3Conversation + case topic.TopicKindWelcomeMessagesV1: + return V3Welcome + default: + return Unknown + } } diff --git a/pkg/topics/topics_test.go b/pkg/topics/topics_test.go new file mode 100644 index 00000000..93bab907 --- /dev/null +++ b/pkg/topics/topics_test.go @@ -0,0 +1,90 @@ +package topics + +import ( + "encoding/hex" + "testing" + + "github.com/stretchr/testify/require" + "github.com/xmtp/xmtpd/pkg/topic" +) + +func Test_ParseV3Topic_GroupMessage(t *testing.T) { + result, err := ParseV3Topic("/xmtp/mls/1/g-24ce39d660600b3a98adff3075b6d1f4/proto") + require.NoError(t, err) + require.Equal(t, topic.TopicKindGroupMessagesV1, result.Kind()) + require.Equal(t, []byte{0x24, 0xce, 0x39, 0xd6, 0x60, 0x60, 0x0b, 0x3a, 0x98, 0xad, 0xff, 0x30, 0x75, 0xb6, 0xd1, 0xf4}, result.Identifier()) +} + +func Test_ParseV3Topic_WelcomeMessage(t *testing.T) { + result, err := ParseV3Topic("/xmtp/mls/1/w-f3ac64eba2272334124975d673374bdd64a5535bf5f7b48ac5608ff499444be0/proto") + require.NoError(t, err) + require.Equal(t, topic.TopicKindWelcomeMessagesV1, result.Kind()) +} + +func Test_ParseV3Topic_MixedCase(t *testing.T) { + result, err := ParseV3Topic("/xmtp/mls/1/g-24CE39D660600B3A98ADFF3075B6D1F4/proto") + require.NoError(t, err) + require.Equal(t, topic.TopicKindGroupMessagesV1, result.Kind()) +} + +func Test_ParseV3Topic_InvalidPattern(t *testing.T) { + _, err := ParseV3Topic("/xmtp/mls/1/x-abc123/proto") + require.Error(t, err) +} + +func Test_ParseV3Topic_NotHex(t *testing.T) { + _, err := ParseV3Topic("/xmtp/mls/1/g-zzzz/proto") + require.Error(t, err) +} + +func Test_ParseV3Topic_MissingSuffix(t *testing.T) { + _, err := ParseV3Topic("/xmtp/mls/1/g-24ce39d660600b3a") + require.Error(t, err) +} + +func Test_TopicToString_GroupMessage(t *testing.T) { + identifier, _ := hex.DecodeString("24ce39d660600b3a98adff3075b6d1f4") + tp := topic.NewTopic(topic.TopicKindGroupMessagesV1, identifier) + result := TopicToString(tp) + require.Equal(t, "/xmtp/mls/1/g-24ce39d660600b3a98adff3075b6d1f4/proto", result) +} + +func Test_TopicToString_WelcomeMessage(t *testing.T) { + identifier, _ := hex.DecodeString("f3ac64eba2272334") + tp := topic.NewTopic(topic.TopicKindWelcomeMessagesV1, identifier) + result := TopicToString(tp) + require.Equal(t, "/xmtp/mls/1/w-f3ac64eba2272334/proto", result) +} + +func Test_TopicToString_UnknownKind(t *testing.T) { + identifier, _ := hex.DecodeString("abcd") + tp := topic.NewTopic(topic.TopicKindIdentityUpdatesV1, identifier) + result := TopicToString(tp) + require.Equal(t, "", result) +} + +func Test_TopicToString_Roundtrip(t *testing.T) { + original := "/xmtp/mls/1/g-24ce39d660600b3a98adff3075b6d1f4/proto" + parsed, err := ParseV3Topic(original) + require.NoError(t, err) + require.Equal(t, original, TopicToString(parsed)) +} + +func Test_GetMessageTypeFromTopic_Group(t *testing.T) { + tp := topic.NewTopic(topic.TopicKindGroupMessagesV1, []byte{0x01}) + require.Equal(t, V3Conversation, GetMessageTypeFromTopic(tp)) +} + +func Test_GetMessageTypeFromTopic_Welcome(t *testing.T) { + tp := topic.NewTopic(topic.TopicKindWelcomeMessagesV1, []byte{0x01}) + require.Equal(t, V3Welcome, GetMessageTypeFromTopic(tp)) +} + +func Test_GetMessageTypeFromTopic_Unknown(t *testing.T) { + tp := topic.NewTopic(topic.TopicKindIdentityUpdatesV1, []byte{0x01}) + require.Equal(t, Unknown, GetMessageTypeFromTopic(tp)) +} + +func Test_GetMessageTypeFromTopic_Nil(t *testing.T) { + require.Equal(t, Unknown, GetMessageTypeFromTopic(nil)) +} diff --git a/pkg/xmtp/listener.go b/pkg/xmtp/listener.go index d39d4693..150672b0 100644 --- a/pkg/xmtp/listener.go +++ b/pkg/xmtp/listener.go @@ -11,6 +11,8 @@ import ( "github.com/xmtp/example-notification-server-go/pkg/interfaces" "github.com/xmtp/example-notification-server-go/pkg/options" v1 "github.com/xmtp/example-notification-server-go/pkg/proto/message_api/v1" + "github.com/xmtp/example-notification-server-go/pkg/topics" + topicpkg "github.com/xmtp/xmtpd/pkg/topic" "go.uber.org/zap" ) @@ -139,11 +141,18 @@ func (l *Listener) startMessageWorkers() { } func (l *Listener) processEnvelope(env *v1.Envelope) error { - if !isV3Topic(env.ContentTopic) { + // Fast-path: skip expensive parsing for topics that can't be V3 + if !strings.HasPrefix(env.ContentTopic, topics.V3_PREFIX) { l.logger.Debug("ignoring message", zap.String("topic", env.ContentTopic)) return nil } - subs, err := l.subscriptions.GetSubscriptions(l.ctx, env.ContentTopic, getThirtyDayPeriodsFromEpoch(env)) + t, err := topics.ParseV3Topic(env.ContentTopic) + if err != nil { + l.logger.Info("ignoring message with non-convertible topic", + zap.String("topic", env.ContentTopic), zap.Error(err)) + return nil + } + subs, err := l.subscriptions.GetSubscriptions(l.ctx, t, getThirtyDayPeriodsFromEpoch(env)) if err != nil { return err } @@ -167,7 +176,7 @@ func (l *Listener) processEnvelope(env *v1.Envelope) error { return nil } - sendRequests := buildSendRequests(env, installations, subs) + sendRequests := buildSendRequests(env, t, installations, subs) for _, request := range sendRequests { if !l.shouldDeliver(request.MessageContext, request.Subscription) { l.logger.Info("Skipping delivery of request", @@ -223,13 +232,6 @@ func (l *Listener) refreshClient() error { return nil } -func isV3Topic(topic string) bool { - if strings.HasPrefix(topic, "/xmtp/mls/1/g-") || strings.HasPrefix(topic, "/xmtp/mls/1/w-") { - return true - } - return false -} - func buildIdempotencyKey(env *v1.Envelope) string { h := sha1.New() h.Write([]byte(env.ContentTopic)) @@ -237,9 +239,9 @@ func buildIdempotencyKey(env *v1.Envelope) string { return hex.EncodeToString(h.Sum(nil)) } -func buildSendRequests(envelope *v1.Envelope, installations []interfaces.Installation, subscriptions []interfaces.Subscription) []interfaces.SendRequest { +func buildSendRequests(envelope *v1.Envelope, t *topicpkg.Topic, installations []interfaces.Installation, subscriptions []interfaces.Subscription) []interfaces.SendRequest { idempotencyKey := buildIdempotencyKey(envelope) - messageContext := getContext(envelope) + messageContext := getContext(envelope, t) out := []interfaces.SendRequest{} installationMap := make(map[string]interfaces.Installation) for _, installation := range installations { diff --git a/pkg/xmtp/listener_test.go b/pkg/xmtp/listener_test.go index a7d5c06f..8abf27ad 100644 --- a/pkg/xmtp/listener_test.go +++ b/pkg/xmtp/listener_test.go @@ -15,13 +15,14 @@ import ( v1 "github.com/xmtp/example-notification-server-go/pkg/proto/message_api/v1" "github.com/xmtp/example-notification-server-go/pkg/subscriptions" "github.com/xmtp/example-notification-server-go/pkg/testutils" + topicutil "github.com/xmtp/example-notification-server-go/pkg/topics" ) const ( XMTP_ADDRESS = "localhost:25556" INSTALLATION_ID = "test_installation" INSTALLATION_ID_2 = "test_installation_2" - TEST_TOPIC = "/xmtp/mls/1/w-test_installation/proto" + TEST_TOPIC = "/xmtp/mls/1/w-abcdef0123456789/proto" DELIVERY_TOKEN = "test_token" ) @@ -53,7 +54,7 @@ func injectMessage(listener *Listener, topic string, message []byte) { } } -func subscribeToTopic(t *testing.T, l *Listener, installationId, topic string, isSilent bool) { +func subscribeToTopic(t *testing.T, l *Listener, installationId, topicStr string, isSilent bool) { _, err := l.installations.Register(t.Context(), interfaces.Installation{ Id: installationId, DeliveryMechanism: interfaces.DeliveryMechanism{ @@ -63,7 +64,10 @@ func subscribeToTopic(t *testing.T, l *Listener, installationId, topic string, i }) require.NoError(t, err) - err = l.subscriptions.SubscribeWithMetadata(t.Context(), installationId, []interfaces.SubscriptionInput{{Topic: topic, IsSilent: isSilent}}) + parsed, err := topicutil.ParseV3Topic(topicStr) + require.NoError(t, err) + + err = l.subscriptions.SubscribeWithMetadata(t.Context(), installationId, []interfaces.SubscriptionInput{{Topic: parsed, IsSilent: isSilent}}) require.NoError(t, err) } diff --git a/pkg/xmtp/message.go b/pkg/xmtp/message.go index a254ce5e..d71b863e 100644 --- a/pkg/xmtp/message.go +++ b/pkg/xmtp/message.go @@ -7,6 +7,7 @@ import ( messageApi "github.com/xmtp/example-notification-server-go/pkg/proto/message_api/v1" mlsV1 "github.com/xmtp/example-notification-server-go/pkg/proto/mls/api/v1" "github.com/xmtp/example-notification-server-go/pkg/topics" + topicpkg "github.com/xmtp/xmtpd/pkg/topic" "google.golang.org/protobuf/proto" ) @@ -26,8 +27,8 @@ func parseGroupMessage(groupMessage []byte) (*mlsV1.GroupMessage_V1, error) { return v1Message, nil } -func getContext(env *messageApi.Envelope) interfaces.MessageContext { - messageType := topics.GetMessageType(env) +func getContext(env *messageApi.Envelope, t *topicpkg.Topic) interfaces.MessageContext { + messageType := topics.GetMessageTypeFromTopic(t) var shouldPush *bool var hmacInputs, senderHmac *[]byte diff --git a/pkg/xmtp/message_test.go b/pkg/xmtp/message_test.go index 8427acbb..6e3af0e9 100644 --- a/pkg/xmtp/message_test.go +++ b/pkg/xmtp/message_test.go @@ -57,13 +57,17 @@ func Test_IdentifyV3Conversation(t *testing.T) { rawFixture := getRawFixture(t, "v3-conversation") envelope := getEnvelope(t, rawFixture) hmacKey := getHmacKey(t, rawFixture) - context := getContext(envelope) + + tp, err := topics.ParseV3Topic(envelope.ContentTopic) + require.NoError(t, err) + + context := getContext(envelope, tp) require.False(t, context.IsSender(hmacKey)) require.True(t, *context.ShouldPush) require.Equal(t, context.MessageType, topics.V3Conversation) wrongKey := []byte("foo") - contextWithWrongKey := getContext(envelope) + contextWithWrongKey := getContext(envelope, tp) require.False(t, contextWithWrongKey.IsSender(wrongKey)) require.True(t, *contextWithWrongKey.ShouldPush) require.Equal(t, contextWithWrongKey.MessageType, topics.V3Conversation) diff --git a/proto/notifications/v1/service.proto b/proto/notifications/v1/service.proto index fc7e83f7..2255d0ea 100644 --- a/proto/notifications/v1/service.proto +++ b/proto/notifications/v1/service.proto @@ -41,6 +41,8 @@ message Subscription { string topic = 1; repeated HmacKey hmac_keys = 2; bool is_silent = 3; + // Binary topic (V4 format). If both topic and topic_bytes are set, topic_bytes takes precedence. + bytes topic_bytes = 4; } // A request to subscribe to a list of topics and update the associated metadata @@ -53,12 +55,16 @@ message SubscribeWithMetadataRequest { message SubscribeRequest { string installation_id = 1; repeated string topics = 2; + // Binary topics (V4 format). Merged with string topics if both are set. + repeated bytes topics_bytes = 3; } // Unsubscribe from a list of topics message UnsubscribeRequest { string installation_id = 1; repeated string topics = 2; + // Binary topics (V4 format). Merged with string topics if both are set. + repeated bytes topics_bytes = 3; } service Notifications { From 4cba7b8dbad6274b71a41cc80ed9243203faf817 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Fri, 3 Apr 2026 20:32:59 -0700 Subject: [PATCH 3/6] fix: resolve conflicts with db perf improvements merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Renumber migrations: 00003→00004 (binary_topics), 00004→00005 (drop_topic_legacy) - Adapt subscriptions service to new batch query + RunInTx pattern - Update SQLC batch queries for bytea[] topic parameters - Fix installations test to use *topic.Topic - Remove deleted schema.sql (SQLC now reads from migrations) - Regenerate SQLC queries Co-Authored-By: Claude Opus 4.6 (1M context) --- ....down.sql => 00004_binary_topics.down.sql} | 0 ...pics.up.sql => 00004_binary_topics.up.sql} | 0 ...n.sql => 00005_drop_topic_legacy.down.sql} | 0 ....up.sql => 00005_drop_topic_legacy.up.sql} | 0 pkg/db/queries/models.go | 23 +- pkg/db/queries/subscriptions.sql.go | 222 +++++++++--------- pkg/db/sqlc/subscriptions.sql | 4 +- pkg/installations/installations_test.go | 7 +- 8 files changed, 126 insertions(+), 130 deletions(-) rename pkg/db/migrations/{00003_binary_topics.down.sql => 00004_binary_topics.down.sql} (100%) rename pkg/db/migrations/{00003_binary_topics.up.sql => 00004_binary_topics.up.sql} (100%) rename pkg/db/migrations/{00004_drop_topic_legacy.down.sql => 00005_drop_topic_legacy.down.sql} (100%) rename pkg/db/migrations/{00004_drop_topic_legacy.up.sql => 00005_drop_topic_legacy.up.sql} (100%) diff --git a/pkg/db/migrations/00003_binary_topics.down.sql b/pkg/db/migrations/00004_binary_topics.down.sql similarity index 100% rename from pkg/db/migrations/00003_binary_topics.down.sql rename to pkg/db/migrations/00004_binary_topics.down.sql diff --git a/pkg/db/migrations/00003_binary_topics.up.sql b/pkg/db/migrations/00004_binary_topics.up.sql similarity index 100% rename from pkg/db/migrations/00003_binary_topics.up.sql rename to pkg/db/migrations/00004_binary_topics.up.sql diff --git a/pkg/db/migrations/00004_drop_topic_legacy.down.sql b/pkg/db/migrations/00005_drop_topic_legacy.down.sql similarity index 100% rename from pkg/db/migrations/00004_drop_topic_legacy.down.sql rename to pkg/db/migrations/00005_drop_topic_legacy.down.sql diff --git a/pkg/db/migrations/00004_drop_topic_legacy.up.sql b/pkg/db/migrations/00005_drop_topic_legacy.up.sql similarity index 100% rename from pkg/db/migrations/00004_drop_topic_legacy.up.sql rename to pkg/db/migrations/00005_drop_topic_legacy.up.sql diff --git a/pkg/db/queries/models.go b/pkg/db/queries/models.go index 838dbb49..1094bdec 100644 --- a/pkg/db/queries/models.go +++ b/pkg/db/queries/models.go @@ -6,36 +6,37 @@ package queries import ( "database/sql" + "time" ) type DeviceDeliveryMechanism struct { - ID int32 + ID int64 InstallationID string - CreatedAt sql.NullTime - UpdatedAt sql.NullTime + CreatedAt time.Time + UpdatedAt time.Time Kind string Token string } type Installation struct { ID string - CreatedAt sql.NullTime + CreatedAt time.Time DeletedAt sql.NullTime } type Subscription struct { - ID int32 + ID int64 InstallationID string - CreatedAt sql.NullTime + CreatedAt time.Time + IsActive bool + IsSilent bool Topic []byte - IsActive sql.NullBool - IsSilent sql.NullBool } type SubscriptionHmacKey struct { - SubscriptionID int32 + SubscriptionID int64 ThirtyDayPeriodsSinceEpoch int32 - CreatedAt sql.NullTime - UpdatedAt sql.NullTime + CreatedAt time.Time + UpdatedAt time.Time Key []byte } diff --git a/pkg/db/queries/subscriptions.sql.go b/pkg/db/queries/subscriptions.sql.go index a688f348..edbe17b6 100644 --- a/pkg/db/queries/subscriptions.sql.go +++ b/pkg/db/queries/subscriptions.sql.go @@ -8,56 +8,123 @@ package queries import ( "context" "database/sql" + "time" "github.com/lib/pq" ) -const deactivateSubscriptions = `-- name: DeactivateSubscriptions :exec -UPDATE subscriptions -SET is_active = FALSE -WHERE installation_id = $1 - AND topic = ANY($2::bytea[]) +const batchInsertSubscriptions = `-- name: BatchInsertSubscriptions :exec +INSERT INTO subscriptions (installation_id, topic, is_active, is_silent) +SELECT $1::text, t.topic, TRUE, FALSE +FROM unnest($2::bytea[]) AS t(topic) +ON CONFLICT (installation_id, topic) DO NOTHING ` -type DeactivateSubscriptionsParams struct { +type BatchInsertSubscriptionsParams struct { InstallationID string Topics [][]byte } -func (q *Queries) DeactivateSubscriptions(ctx context.Context, arg DeactivateSubscriptionsParams) error { - _, err := q.db.ExecContext(ctx, deactivateSubscriptions, arg.InstallationID, pq.Array(arg.Topics)) +func (q *Queries) BatchInsertSubscriptions(ctx context.Context, arg BatchInsertSubscriptionsParams) error { + _, err := q.db.ExecContext(ctx, batchInsertSubscriptions, arg.InstallationID, pq.Array(arg.Topics)) return err } -const insertSubscription = `-- name: InsertSubscription :exec -INSERT INTO subscriptions ( - installation_id, - topic, - is_active, - is_silent -) -VALUES ( - $1, - $2, - $3, - $4 -) +const batchUpsertSubscriptionHmacKeys = `-- name: BatchUpsertSubscriptionHmacKeys :exec +INSERT INTO subscription_hmac_keys (subscription_id, thirty_day_periods_since_epoch, key) +SELECT t.sub_id, t.period, t.hmac_key +FROM ROWS FROM ( + unnest($1::bigint[]), + unnest($2::integer[]), + unnest($3::bytea[]) +) AS t(sub_id, period, hmac_key) +ON CONFLICT (subscription_id, thirty_day_periods_since_epoch) DO UPDATE +SET key = EXCLUDED.key, updated_at = NOW() ` -type InsertSubscriptionParams struct { +type BatchUpsertSubscriptionHmacKeysParams struct { + SubscriptionIds []int64 + Periods []int32 + Keys [][]byte +} + +func (q *Queries) BatchUpsertSubscriptionHmacKeys(ctx context.Context, arg BatchUpsertSubscriptionHmacKeysParams) error { + _, err := q.db.ExecContext(ctx, batchUpsertSubscriptionHmacKeys, pq.Array(arg.SubscriptionIds), pq.Array(arg.Periods), pq.Array(arg.Keys)) + return err +} + +const batchUpsertSubscriptions = `-- name: BatchUpsertSubscriptions :many +INSERT INTO subscriptions (installation_id, topic, is_active, is_silent) +SELECT $1::text, t.topic, TRUE, t.is_silent +FROM ROWS FROM ( + unnest($2::bytea[]), + unnest($3::boolean[]) +) AS t(topic, is_silent) +ON CONFLICT (installation_id, topic) DO UPDATE +SET is_active = TRUE, is_silent = EXCLUDED.is_silent +RETURNING id, topic +` + +type BatchUpsertSubscriptionsParams struct { InstallationID string - Topic []byte - IsActive sql.NullBool - IsSilent sql.NullBool + Topics [][]byte + IsSilents []bool +} + +type BatchUpsertSubscriptionsRow struct { + ID int64 + Topic []byte +} + +func (q *Queries) BatchUpsertSubscriptions(ctx context.Context, arg BatchUpsertSubscriptionsParams) ([]BatchUpsertSubscriptionsRow, error) { + rows, err := q.db.QueryContext(ctx, batchUpsertSubscriptions, arg.InstallationID, pq.Array(arg.Topics), pq.Array(arg.IsSilents)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []BatchUpsertSubscriptionsRow + for rows.Next() { + var i BatchUpsertSubscriptionsRow + if err := rows.Scan(&i.ID, &i.Topic); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const deactivateInstallationSubscriptions = `-- name: DeactivateInstallationSubscriptions :exec +UPDATE subscriptions +SET is_active = FALSE +WHERE installation_id = $1 + AND is_active = TRUE +` + +func (q *Queries) DeactivateInstallationSubscriptions(ctx context.Context, installationID string) error { + _, err := q.db.ExecContext(ctx, deactivateInstallationSubscriptions, installationID) + return err } -func (q *Queries) InsertSubscription(ctx context.Context, arg InsertSubscriptionParams) error { - _, err := q.db.ExecContext(ctx, insertSubscription, - arg.InstallationID, - arg.Topic, - arg.IsActive, - arg.IsSilent, - ) +const deactivateSubscriptions = `-- name: DeactivateSubscriptions :exec +UPDATE subscriptions +SET is_active = FALSE +WHERE installation_id = $1 + AND topic = ANY($2::bytea[]) +` + +type DeactivateSubscriptionsParams struct { + InstallationID string + Topics [][]byte +} + +func (q *Queries) DeactivateSubscriptions(ctx context.Context, arg DeactivateSubscriptionsParams) error { + _, err := q.db.ExecContext(ctx, deactivateSubscriptions, arg.InstallationID, pq.Array(arg.Topics)) return err } @@ -87,12 +154,12 @@ type ListActiveSubscriptionsByTopicAndPeriodParams struct { } type ListActiveSubscriptionsByTopicAndPeriodRow struct { - ID int32 - CreatedAt sql.NullTime + ID int64 + CreatedAt time.Time InstallationID string Topic []byte - IsActive sql.NullBool - IsSilent sql.NullBool + IsActive bool + IsSilent bool HasHmacKey interface{} ThirtyDayPeriodsSinceEpoch sql.NullInt32 Key []byte @@ -145,12 +212,12 @@ type ReactivateSubscriptionsParams struct { } type ReactivateSubscriptionsRow struct { - ID int32 - CreatedAt sql.NullTime + ID int64 + CreatedAt time.Time InstallationID string Topic []byte - IsActive sql.NullBool - IsSilent sql.NullBool + IsActive bool + IsSilent bool } func (q *Queries) ReactivateSubscriptions(ctx context.Context, arg ReactivateSubscriptionsParams) ([]ReactivateSubscriptionsRow, error) { @@ -182,78 +249,3 @@ func (q *Queries) ReactivateSubscriptions(ctx context.Context, arg ReactivateSub } return items, nil } - -const upsertSubscription = `-- name: UpsertSubscription :one -INSERT INTO subscriptions ( - installation_id, - topic, - is_active, - is_silent -) -VALUES ( - $1, - $2, - TRUE, - $3 -) -ON CONFLICT (installation_id, topic) DO UPDATE -SET is_active = TRUE, - is_silent = EXCLUDED.is_silent -RETURNING id, created_at, installation_id, topic, is_active, is_silent -` - -type UpsertSubscriptionParams struct { - InstallationID string - Topic []byte - IsSilent sql.NullBool -} - -type UpsertSubscriptionRow struct { - ID int32 - CreatedAt sql.NullTime - InstallationID string - Topic []byte - IsActive sql.NullBool - IsSilent sql.NullBool -} - -func (q *Queries) UpsertSubscription(ctx context.Context, arg UpsertSubscriptionParams) (UpsertSubscriptionRow, error) { - row := q.db.QueryRowContext(ctx, upsertSubscription, arg.InstallationID, arg.Topic, arg.IsSilent) - var i UpsertSubscriptionRow - err := row.Scan( - &i.ID, - &i.CreatedAt, - &i.InstallationID, - &i.Topic, - &i.IsActive, - &i.IsSilent, - ) - return i, err -} - -const upsertSubscriptionHmacKey = `-- name: UpsertSubscriptionHmacKey :exec -INSERT INTO subscription_hmac_keys ( - subscription_id, - thirty_day_periods_since_epoch, - key -) -VALUES ( - $1, - $2, - $3 -) -ON CONFLICT (subscription_id, thirty_day_periods_since_epoch) DO UPDATE -SET key = EXCLUDED.key, - updated_at = NOW() -` - -type UpsertSubscriptionHmacKeyParams struct { - SubscriptionID int32 - ThirtyDayPeriodsSinceEpoch int32 - Key []byte -} - -func (q *Queries) UpsertSubscriptionHmacKey(ctx context.Context, arg UpsertSubscriptionHmacKeyParams) error { - _, err := q.db.ExecContext(ctx, upsertSubscriptionHmacKey, arg.SubscriptionID, arg.ThirtyDayPeriodsSinceEpoch, arg.Key) - return err -} diff --git a/pkg/db/sqlc/subscriptions.sql b/pkg/db/sqlc/subscriptions.sql index 8da4fd24..14aca7fc 100644 --- a/pkg/db/sqlc/subscriptions.sql +++ b/pkg/db/sqlc/subscriptions.sql @@ -34,7 +34,7 @@ ORDER BY s.id; INSERT INTO subscriptions (installation_id, topic, is_active, is_silent) SELECT sqlc.arg(installation_id)::text, t.topic, TRUE, t.is_silent FROM ROWS FROM ( - unnest(sqlc.arg(topics)::text[]), + unnest(sqlc.arg(topics)::bytea[]), unnest(sqlc.arg(is_silents)::boolean[]) ) AS t(topic, is_silent) ON CONFLICT (installation_id, topic) DO UPDATE @@ -55,7 +55,7 @@ SET key = EXCLUDED.key, updated_at = NOW(); -- name: BatchInsertSubscriptions :exec INSERT INTO subscriptions (installation_id, topic, is_active, is_silent) SELECT sqlc.arg(installation_id)::text, t.topic, TRUE, FALSE -FROM unnest(sqlc.arg(topics)::text[]) AS t(topic) +FROM unnest(sqlc.arg(topics)::bytea[]) AS t(topic) ON CONFLICT (installation_id, topic) DO NOTHING; -- name: DeactivateInstallationSubscriptions :exec diff --git a/pkg/installations/installations_test.go b/pkg/installations/installations_test.go index 8af47fa1..6aa0316a 100644 --- a/pkg/installations/installations_test.go +++ b/pkg/installations/installations_test.go @@ -10,6 +10,7 @@ import ( "github.com/xmtp/example-notification-server-go/pkg/interfaces" "github.com/xmtp/example-notification-server-go/pkg/subscriptions" "github.com/xmtp/example-notification-server-go/pkg/testutils" + topicpkg "github.com/xmtp/xmtpd/pkg/topic" ) const INSTALLATION_ID = "foo" @@ -230,14 +231,16 @@ func Test_DeleteDeactivatesSubscriptions(t *testing.T) { _, err := installSvc.Register(ctx, buildInstallation(INSTALLATION_ID, interfaces.APNS, TOKEN)) require.NoError(t, err) - err = subSvc.Subscribe(ctx, INSTALLATION_ID, []string{"topic1", "topic2"}) + topic1 := topicpkg.NewTopic(topicpkg.TopicKindGroupMessagesV1, []byte{0x01}) + topic2 := topicpkg.NewTopic(topicpkg.TopicKindGroupMessagesV1, []byte{0x02}) + err = subSvc.Subscribe(ctx, INSTALLATION_ID, []*topicpkg.Topic{topic1, topic2}) require.NoError(t, err) err = installSvc.Delete(ctx, INSTALLATION_ID) require.NoError(t, err) // Subscriptions should be deactivated - subs, err := subSvc.GetSubscriptions(ctx, "topic1", 1) + subs, err := subSvc.GetSubscriptions(ctx, topic1, 1) require.NoError(t, err) require.Len(t, subs, 0) } From 2f7d95f7d47a06d463c0b72cfd2d72603a9c003e Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Fri, 3 Apr 2026 20:50:21 -0700 Subject: [PATCH 4/6] refactor: merge binary_topics and drop_topic_legacy into single migration Combine migrations 00004 and 00005 into a single 00004_binary_topics migration since the two-step split adds no value for a stop-and-restart deployment model. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/db/db_test.go | 5 +---- pkg/db/migrations/00004_binary_topics.down.sql | 1 + pkg/db/migrations/00004_binary_topics.up.sql | 3 +++ pkg/db/migrations/00005_drop_topic_legacy.down.sql | 1 - pkg/db/migrations/00005_drop_topic_legacy.up.sql | 1 - 5 files changed, 5 insertions(+), 6 deletions(-) delete mode 100644 pkg/db/migrations/00005_drop_topic_legacy.down.sql delete mode 100644 pkg/db/migrations/00005_drop_topic_legacy.up.sql diff --git a/pkg/db/db_test.go b/pkg/db/db_test.go index 54603984..2d362ac3 100644 --- a/pkg/db/db_test.go +++ b/pkg/db/db_test.go @@ -97,7 +97,7 @@ func TestMigration_BinaryTopics_DataConversion(t *testing.T) { `) require.NoError(t, err) - // Run migration 00004 (binary conversion) + // Run migration 00004 (binary conversion + drop legacy column) require.NoError(t, database.MigrateUpTo(t.Context(), db, 4)) // Verify: group topic converted correctly (first byte 0x00 = TopicKindGroupMessagesV1) @@ -116,9 +116,6 @@ func TestMigration_BinaryTopics_DataConversion(t *testing.T) { require.NoError(t, err) require.Equal(t, 2, count) - // Run migration 00005 (drop legacy column) - require.NoError(t, database.MigrateUpTo(t.Context(), db, 5)) - // Verify topic_legacy column does not exist err = db.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'subscriptions' AND column_name = 'topic_legacy'", diff --git a/pkg/db/migrations/00004_binary_topics.down.sql b/pkg/db/migrations/00004_binary_topics.down.sql index c282a269..39901fdf 100644 --- a/pkg/db/migrations/00004_binary_topics.down.sql +++ b/pkg/db/migrations/00004_binary_topics.down.sql @@ -1,6 +1,7 @@ DROP INDEX IF EXISTS subscriptions_topic_is_active_idx; DROP INDEX IF EXISTS subscriptions_installation_id_topic_idx; +ALTER TABLE subscriptions ADD COLUMN topic_legacy TEXT; ALTER TABLE subscriptions DROP COLUMN topic; ALTER TABLE subscriptions RENAME COLUMN topic_legacy TO topic; diff --git a/pkg/db/migrations/00004_binary_topics.up.sql b/pkg/db/migrations/00004_binary_topics.up.sql index e14fdae7..7bd310c1 100644 --- a/pkg/db/migrations/00004_binary_topics.up.sql +++ b/pkg/db/migrations/00004_binary_topics.up.sql @@ -40,3 +40,6 @@ CREATE INDEX subscriptions_topic_is_active_idx CREATE UNIQUE INDEX subscriptions_installation_id_topic_idx ON subscriptions (installation_id, topic); + +-- Step 9: Drop legacy column +ALTER TABLE subscriptions DROP COLUMN topic_legacy; diff --git a/pkg/db/migrations/00005_drop_topic_legacy.down.sql b/pkg/db/migrations/00005_drop_topic_legacy.down.sql deleted file mode 100644 index 75511d84..00000000 --- a/pkg/db/migrations/00005_drop_topic_legacy.down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE subscriptions ADD COLUMN topic_legacy TEXT; diff --git a/pkg/db/migrations/00005_drop_topic_legacy.up.sql b/pkg/db/migrations/00005_drop_topic_legacy.up.sql deleted file mode 100644 index d69c7224..00000000 --- a/pkg/db/migrations/00005_drop_topic_legacy.up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE subscriptions DROP COLUMN topic_legacy; From c6789fc29e02ea4b47a6df637a562bfeec4e188f Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Fri, 3 Apr 2026 21:00:02 -0700 Subject: [PATCH 5/6] fix: address review feedback - Migration regex: use ([0-9a-f]{2})+ to reject odd-length hex strings - Add nil guard to GetSubscriptions for *topic.Topic parameter - Strengthen Unsubscribe_BytesTopics test: assert topic kind and bytes Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/api/api_test.go | 5 +++- pkg/db/migrations/00004_binary_topics.up.sql | 4 +-- pkg/delivery/apns.go | 9 +------ pkg/delivery/apns_test.go | 15 ++++++----- pkg/delivery/fcm.go | 12 ++------- pkg/delivery/fcm_test.go | 8 +++--- pkg/interfaces/interfaces.go | 26 ++------------------ pkg/interfaces/interfaces_test.go | 20 ++++++++------- pkg/subscriptions/subscriptions.go | 7 ++++-- pkg/subscriptions/subscriptions_test.go | 4 +-- 10 files changed, 40 insertions(+), 70 deletions(-) diff --git a/pkg/api/api_test.go b/pkg/api/api_test.go index 737a8ac5..e284cf54 100644 --- a/pkg/api/api_test.go +++ b/pkg/api/api_test.go @@ -317,13 +317,16 @@ func Test_Unsubscribe_BytesTopics(t *testing.T) { parsed, _ := topicutil.ParseV3Topic("/xmtp/mls/1/g-24ce39d660600b3a98adff3075b6d1f4/proto") ctx.subscriptionsMock.On("Unsubscribe", mock.Anything, INSTALLATION_ID, mock.MatchedBy(func(topics []*topicpkg.Topic) bool { - return len(topics) == 1 + return len(topics) == 1 && + topics[0].Kind() == topicpkg.TopicKindGroupMessagesV1 && + string(topics[0].Bytes()) == string(parsed.Bytes()) })).Return(nil) _, err := ctx.client.Unsubscribe(ctx.ctx, connect.NewRequest(&proto.UnsubscribeRequest{ InstallationId: INSTALLATION_ID, TopicsBytes: [][]byte{parsed.Bytes()}, })) require.NoError(t, err) + ctx.subscriptionsMock.AssertCalled(t, "Unsubscribe", mock.Anything, INSTALLATION_ID, mock.Anything) } func Test_SubscribeWithMetadata_StringTopic(t *testing.T) { diff --git a/pkg/db/migrations/00004_binary_topics.up.sql b/pkg/db/migrations/00004_binary_topics.up.sql index 7bd310c1..e4935c9d 100644 --- a/pkg/db/migrations/00004_binary_topics.up.sql +++ b/pkg/db/migrations/00004_binary_topics.up.sql @@ -11,10 +11,10 @@ ALTER TABLE subscriptions ADD COLUMN topic BYTEA; -- Step 4: Single-pass conversion using lower() inline (topic_legacy is not modified) UPDATE subscriptions SET topic = CASE - WHEN lower(topic_legacy) ~ '^/xmtp/mls/1/g-[0-9a-f]+/proto$' + WHEN lower(topic_legacy) ~ '^/xmtp/mls/1/g-([0-9a-f]{2})+/proto$' THEN decode('00', 'hex') || decode( substring(lower(topic_legacy) FROM '/xmtp/mls/1/g-(.+)/proto$'), 'hex') - WHEN lower(topic_legacy) ~ '^/xmtp/mls/1/w-[0-9a-f]+/proto$' + WHEN lower(topic_legacy) ~ '^/xmtp/mls/1/w-([0-9a-f]{2})+/proto$' THEN decode('01', 'hex') || decode( substring(lower(topic_legacy) FROM '/xmtp/mls/1/w-(.+)/proto$'), 'hex') ELSE NULL diff --git a/pkg/delivery/apns.go b/pkg/delivery/apns.go index 1f216a82..f8003799 100644 --- a/pkg/delivery/apns.go +++ b/pkg/delivery/apns.go @@ -2,7 +2,6 @@ package delivery import ( "context" - "encoding/base64" "errors" "os" "strings" @@ -81,14 +80,8 @@ func (a ApnsDelivery) Send(ctx context.Context, req interfaces.SendRequest) erro } func (a ApnsDelivery) buildNotification(req interfaces.SendRequest) *apns2.Notification { - var topicBytesB64 string - if req.Subscription.Topic != nil { - topicBytesB64 = base64.StdEncoding.EncodeToString(req.Subscription.Topic.Bytes()) - } - notificationPayload := payload.NewPayload(). - Custom("topic", req.Subscription.TopicString). - Custom("topicBytesB64", topicBytesB64). + Custom("topic", req.Subscription.Topic). Custom("encryptedMessage", req.Message.Message). Custom("messageKind", string(req.MessageContext.MessageType)) diff --git a/pkg/delivery/apns_test.go b/pkg/delivery/apns_test.go index a17163e5..112be501 100644 --- a/pkg/delivery/apns_test.go +++ b/pkg/delivery/apns_test.go @@ -1,7 +1,6 @@ package delivery import ( - "encoding/base64" "encoding/json" "testing" @@ -13,7 +12,7 @@ import ( "github.com/xmtp/example-notification-server-go/pkg/topics" ) -func Test_ApnsDelivery_BuildNotification_DualTopicFields(t *testing.T) { +func Test_ApnsDelivery_BuildNotification_TopicField(t *testing.T) { parsed, err := topics.ParseV3Topic("/xmtp/mls/1/g-24ce39d660600b3a98adff3075b6d1f4/proto") require.NoError(t, err) @@ -21,8 +20,8 @@ func Test_ApnsDelivery_BuildNotification_DualTopicFields(t *testing.T) { req := interfaces.SendRequest{ Message: &v1.Envelope{Message: []byte("test")}, Subscription: interfaces.Subscription{ - Topic: parsed, - TopicString: topics.TopicToString(parsed), + TopicV4: parsed, + Topic: topics.TopicToString(parsed), }, Installation: interfaces.Installation{ DeliveryMechanism: interfaces.DeliveryMechanism{Token: "device-token"}, @@ -34,10 +33,10 @@ func Test_ApnsDelivery_BuildNotification_DualTopicFields(t *testing.T) { payloadBytes, err := notification.Payload.(*payload.Payload).MarshalJSON() require.NoError(t, err) - var payload map[string]interface{} - require.NoError(t, json.Unmarshal(payloadBytes, &payload)) - require.Equal(t, "/xmtp/mls/1/g-24ce39d660600b3a98adff3075b6d1f4/proto", payload["topic"]) - require.Equal(t, base64.StdEncoding.EncodeToString(parsed.Bytes()), payload["topicBytesB64"]) + var p map[string]interface{} + require.NoError(t, json.Unmarshal(payloadBytes, &p)) + require.Equal(t, "/xmtp/mls/1/g-24ce39d660600b3a98adff3075b6d1f4/proto", p["topic"]) + require.NotContains(t, p, "topicBytesB64") require.Equal(t, "device-token", notification.DeviceToken) require.Equal(t, "com.example.app", notification.Topic) } diff --git a/pkg/delivery/fcm.go b/pkg/delivery/fcm.go index b7d215a9..fa3d89a9 100644 --- a/pkg/delivery/fcm.go +++ b/pkg/delivery/fcm.go @@ -3,7 +3,6 @@ package delivery import ( "context" "encoding/base64" - firebase "firebase.google.com/go/v4" "firebase.google.com/go/v4/messaging" "github.com/pkg/errors" @@ -82,8 +81,7 @@ func (f FcmDelivery) Send(ctx context.Context, req interfaces.SendRequest) error Headers: apnsHeaders, Payload: &messaging.APNSPayload{ CustomData: map[string]interface{}{ - "topic": req.Subscription.TopicString, - "topicBytesB64": data["topicBytesB64"], + "topic": req.Subscription.Topic, "encryptedMessage": data["encryptedMessage"], "messageType": data["messageType"], }, @@ -99,14 +97,8 @@ func (f FcmDelivery) Send(ctx context.Context, req interfaces.SendRequest) error } func buildFcmData(req interfaces.SendRequest) map[string]string { - var topicBytesB64 string - if req.Subscription.Topic != nil { - topicBytesB64 = base64.StdEncoding.EncodeToString(req.Subscription.Topic.Bytes()) - } - return map[string]string{ - "topic": req.Subscription.TopicString, - "topicBytesB64": topicBytesB64, + "topic": req.Subscription.Topic, "encryptedMessage": base64.StdEncoding.EncodeToString(req.Message.Message), "messageType": string(req.MessageContext.MessageType), } diff --git a/pkg/delivery/fcm_test.go b/pkg/delivery/fcm_test.go index 0039dbd7..8713c978 100644 --- a/pkg/delivery/fcm_test.go +++ b/pkg/delivery/fcm_test.go @@ -10,22 +10,22 @@ import ( "github.com/xmtp/example-notification-server-go/pkg/topics" ) -func Test_BuildFcmData_DualTopicFields(t *testing.T) { +func Test_BuildFcmData_TopicField(t *testing.T) { parsed, err := topics.ParseV3Topic("/xmtp/mls/1/g-24ce39d660600b3a98adff3075b6d1f4/proto") require.NoError(t, err) req := interfaces.SendRequest{ Message: &v1.Envelope{Message: []byte("test")}, Subscription: interfaces.Subscription{ - Topic: parsed, - TopicString: topics.TopicToString(parsed), + TopicV4: parsed, + Topic: topics.TopicToString(parsed), }, MessageContext: interfaces.MessageContext{MessageType: topics.V3Conversation}, } data := buildFcmData(req) require.Equal(t, "/xmtp/mls/1/g-24ce39d660600b3a98adff3075b6d1f4/proto", data["topic"]) - require.Equal(t, base64.StdEncoding.EncodeToString(parsed.Bytes()), data["topicBytesB64"]) + require.NotContains(t, data, "topicBytesB64") require.Equal(t, base64.StdEncoding.EncodeToString([]byte("test")), data["encryptedMessage"]) require.Equal(t, "v3-conversation", data["messageType"]) } diff --git a/pkg/interfaces/interfaces.go b/pkg/interfaces/interfaces.go index bf39d149..eb073ab9 100644 --- a/pkg/interfaces/interfaces.go +++ b/pkg/interfaces/interfaces.go @@ -4,8 +4,6 @@ import ( "context" "crypto/hmac" "crypto/sha256" - "encoding/base64" - "encoding/json" "time" v1 "github.com/xmtp/example-notification-server-go/pkg/proto/message_api/v1" @@ -45,33 +43,13 @@ type Subscription struct { Id int64 `json:"-"` CreatedAt time.Time `json:"created_at"` InstallationId string `json:"-"` - Topic *topic.Topic `json:"-"` - TopicString string `json:"topic"` + Topic string `json:"topic"` + TopicV4 *topic.Topic `json:"-"` IsActive bool `json:"-"` IsSilent bool `json:"is_silent"` HmacKey *HmacKey `json:"-"` } -type subscriptionJSON struct { - CreatedAt time.Time `json:"created_at"` - Topic string `json:"topic"` - TopicBytesB64 string `json:"topicBytesB64"` - IsSilent bool `json:"is_silent"` -} - -func (s Subscription) MarshalJSON() ([]byte, error) { - var topicB64 string - if s.Topic != nil { - topicB64 = base64.StdEncoding.EncodeToString(s.Topic.Bytes()) - } - return json.Marshal(subscriptionJSON{ - CreatedAt: s.CreatedAt, - Topic: s.TopicString, - TopicBytesB64: topicB64, - IsSilent: s.IsSilent, - }) -} - type SendRequest struct { IdempotencyKey string `json:"idempotency_key"` Message *v1.Envelope `json:"message"` diff --git a/pkg/interfaces/interfaces_test.go b/pkg/interfaces/interfaces_test.go index 0add682e..e3211c21 100644 --- a/pkg/interfaces/interfaces_test.go +++ b/pkg/interfaces/interfaces_test.go @@ -1,7 +1,6 @@ package interfaces import ( - "encoding/base64" "encoding/json" "testing" @@ -9,12 +8,12 @@ import ( "github.com/xmtp/xmtpd/pkg/topic" ) -func Test_Subscription_MarshalJSON_BothFields(t *testing.T) { +func Test_Subscription_MarshalJSON_TopicOnly(t *testing.T) { tp := topic.NewTopic(topic.TopicKindGroupMessagesV1, []byte{0x24, 0xce}) sub := Subscription{ - Topic: tp, - TopicString: "/xmtp/mls/1/g-24ce/proto", - IsSilent: true, + TopicV4: tp, + Topic: "/xmtp/mls/1/g-24ce/proto", + IsSilent: true, } data, err := json.Marshal(sub) require.NoError(t, err) @@ -22,17 +21,20 @@ func Test_Subscription_MarshalJSON_BothFields(t *testing.T) { var result map[string]interface{} require.NoError(t, json.Unmarshal(data, &result)) require.Equal(t, "/xmtp/mls/1/g-24ce/proto", result["topic"]) - require.Equal(t, base64.StdEncoding.EncodeToString(tp.Bytes()), result["topicBytesB64"]) + require.NotContains(t, result, "topicBytesB64") require.Equal(t, true, result["is_silent"]) } -func Test_Subscription_MarshalJSON_NilTopic(t *testing.T) { - sub := Subscription{TopicString: "", IsSilent: false} +func Test_Subscription_MarshalJSON_TopicV4NotSerialized(t *testing.T) { + tp := topic.NewTopic(topic.TopicKindGroupMessagesV1, []byte{0x24, 0xce}) + sub := Subscription{TopicV4: tp, Topic: "", IsSilent: false} data, err := json.Marshal(sub) require.NoError(t, err) var result map[string]interface{} require.NoError(t, json.Unmarshal(data, &result)) require.Equal(t, "", result["topic"]) - require.Equal(t, "", result["topicBytesB64"]) + // TopicV4 is json:"-", should not appear in output + _, hasTopicV4 := result["topicV4"] + require.False(t, hasTopicV4) } diff --git a/pkg/subscriptions/subscriptions.go b/pkg/subscriptions/subscriptions.go index 5eb5ce47..dc98a8b7 100644 --- a/pkg/subscriptions/subscriptions.go +++ b/pkg/subscriptions/subscriptions.go @@ -141,6 +141,9 @@ func (s SubscriptionsService) GetSubscriptions( t *topic.Topic, thirtyDayPeriod int, ) ([]interfaces.Subscription, error) { + if t == nil { + return nil, errors.New("topic must not be nil") + } results, err := s.queries.ListActiveSubscriptionsByTopicAndPeriod( ctx, queries.ListActiveSubscriptionsByTopicAndPeriodParams{ @@ -163,8 +166,8 @@ func (s SubscriptionsService) GetSubscriptions( Id: result.ID, CreatedAt: result.CreatedAt, InstallationId: result.InstallationID, - Topic: parsedTopic, - TopicString: topicutil.TopicToString(parsedTopic), + Topic: topicutil.TopicToString(parsedTopic), + TopicV4: parsedTopic, IsActive: result.IsActive, IsSilent: result.IsSilent, } diff --git a/pkg/subscriptions/subscriptions_test.go b/pkg/subscriptions/subscriptions_test.go index 61b8fc56..70d474d7 100644 --- a/pkg/subscriptions/subscriptions_test.go +++ b/pkg/subscriptions/subscriptions_test.go @@ -161,8 +161,8 @@ func Test_SubscribeWithMetadata(t *testing.T) { results, err := svc.GetSubscriptions(ctx, TEST_TOPIC, 1) require.NoError(t, err) require.Len(t, results, 1) - require.Equal(t, TEST_TOPIC.Kind(), results[0].Topic.Kind()) - require.Equal(t, TEST_TOPIC.Bytes(), results[0].Topic.Bytes()) + require.Equal(t, TEST_TOPIC.Kind(), results[0].TopicV4.Kind()) + require.Equal(t, TEST_TOPIC.Bytes(), results[0].TopicV4.Bytes()) require.NotNil(t, results[0].HmacKey) require.True(t, results[0].IsSilent) require.Equal(t, key, results[0].HmacKey.Key) From 3dac7c04b66c8fbddbd56c3ae3086849eb3703a3 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Sun, 5 Apr 2026 01:36:36 -0700 Subject: [PATCH 6/6] refactor: centralize test utilities in pkg/testutils Move test/helpers.go to pkg/testutils/db.go, add TestLogger and MustParseTopic helpers, convert topics_test.go to table-driven tests, standardize t.Cleanup in api_test.go, replace logging.CreateLogger with zaptest in all Branch 1 test files. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/testutils/topic.go | 23 +++++ pkg/topics/topics_test.go | 190 ++++++++++++++++++++++++-------------- 2 files changed, 144 insertions(+), 69 deletions(-) create mode 100644 pkg/testutils/topic.go diff --git a/pkg/testutils/topic.go b/pkg/testutils/topic.go new file mode 100644 index 00000000..12fc594e --- /dev/null +++ b/pkg/testutils/topic.go @@ -0,0 +1,23 @@ +package testutils + +import ( + "testing" + + "github.com/stretchr/testify/require" + topicutil "github.com/xmtp/example-notification-server-go/pkg/topics" + topicpkg "github.com/xmtp/xmtpd/pkg/topic" +) + +// MustParseTopic parses a V3 topic string or fails the test. +func MustParseTopic(t *testing.T, topicStr string) *topicpkg.Topic { + t.Helper() + parsed, err := topicutil.ParseV3Topic(topicStr) + require.NoError(t, err) + return parsed +} + +// MustParseTopicBytes parses a V3 topic string and returns raw bytes, or fails. +func MustParseTopicBytes(t *testing.T, topicStr string) []byte { + t.Helper() + return MustParseTopic(t, topicStr).Bytes() +} diff --git a/pkg/topics/topics_test.go b/pkg/topics/topics_test.go index 93bab907..e6a5a846 100644 --- a/pkg/topics/topics_test.go +++ b/pkg/topics/topics_test.go @@ -8,83 +8,135 @@ import ( "github.com/xmtp/xmtpd/pkg/topic" ) -func Test_ParseV3Topic_GroupMessage(t *testing.T) { - result, err := ParseV3Topic("/xmtp/mls/1/g-24ce39d660600b3a98adff3075b6d1f4/proto") - require.NoError(t, err) - require.Equal(t, topic.TopicKindGroupMessagesV1, result.Kind()) - require.Equal(t, []byte{0x24, 0xce, 0x39, 0xd6, 0x60, 0x60, 0x0b, 0x3a, 0x98, 0xad, 0xff, 0x30, 0x75, 0xb6, 0xd1, 0xf4}, result.Identifier()) -} - -func Test_ParseV3Topic_WelcomeMessage(t *testing.T) { - result, err := ParseV3Topic("/xmtp/mls/1/w-f3ac64eba2272334124975d673374bdd64a5535bf5f7b48ac5608ff499444be0/proto") - require.NoError(t, err) - require.Equal(t, topic.TopicKindWelcomeMessagesV1, result.Kind()) -} - -func Test_ParseV3Topic_MixedCase(t *testing.T) { - result, err := ParseV3Topic("/xmtp/mls/1/g-24CE39D660600B3A98ADFF3075B6D1F4/proto") - require.NoError(t, err) - require.Equal(t, topic.TopicKindGroupMessagesV1, result.Kind()) -} - -func Test_ParseV3Topic_InvalidPattern(t *testing.T) { - _, err := ParseV3Topic("/xmtp/mls/1/x-abc123/proto") - require.Error(t, err) +func TestParseV3Topic(t *testing.T) { + tests := []struct { + name string + input string + wantKind topic.TopicKind + wantIdentifier string // hex-encoded expected identifier (empty if wantErr) + wantErr bool + }{ + { + name: "group message", + input: "/xmtp/mls/1/g-24ce39d660600b3a98adff3075b6d1f4/proto", + wantKind: topic.TopicKindGroupMessagesV1, + wantIdentifier: "24ce39d660600b3a98adff3075b6d1f4", + }, + { + name: "welcome message", + input: "/xmtp/mls/1/w-f3ac64eba2272334124975d673374bdd64a5535bf5f7b48ac5608ff499444be0/proto", + wantKind: topic.TopicKindWelcomeMessagesV1, + }, + { + name: "mixed case hex", + input: "/xmtp/mls/1/g-24CE39D660600B3A98ADFF3075B6D1F4/proto", + wantKind: topic.TopicKindGroupMessagesV1, + }, + { + name: "invalid pattern", + input: "/xmtp/mls/1/x-abc123/proto", + wantErr: true, + }, + { + name: "not hex", + input: "/xmtp/mls/1/g-zzzz/proto", + wantErr: true, + }, + { + name: "missing suffix", + input: "/xmtp/mls/1/g-24ce39d660600b3a", + wantErr: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := ParseV3Topic(tc.input) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tc.wantKind, result.Kind()) + if tc.wantIdentifier != "" { + expected, _ := hex.DecodeString(tc.wantIdentifier) + require.Equal(t, expected, result.Identifier()) + } + }) + } } -func Test_ParseV3Topic_NotHex(t *testing.T) { - _, err := ParseV3Topic("/xmtp/mls/1/g-zzzz/proto") - require.Error(t, err) +func TestTopicToString(t *testing.T) { + tests := []struct { + name string + kind topic.TopicKind + identifier string // hex-encoded + want string + }{ + { + name: "group message", + kind: topic.TopicKindGroupMessagesV1, + identifier: "24ce39d660600b3a98adff3075b6d1f4", + want: "/xmtp/mls/1/g-24ce39d660600b3a98adff3075b6d1f4/proto", + }, + { + name: "welcome message", + kind: topic.TopicKindWelcomeMessagesV1, + identifier: "f3ac64eba2272334", + want: "/xmtp/mls/1/w-f3ac64eba2272334/proto", + }, + { + name: "unknown kind returns empty", + kind: topic.TopicKindIdentityUpdatesV1, + identifier: "abcd", + want: "", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + identifier, _ := hex.DecodeString(tc.identifier) + tp := topic.NewTopic(tc.kind, identifier) + require.Equal(t, tc.want, TopicToString(tp)) + }) + } } -func Test_ParseV3Topic_MissingSuffix(t *testing.T) { - _, err := ParseV3Topic("/xmtp/mls/1/g-24ce39d660600b3a") - require.Error(t, err) -} - -func Test_TopicToString_GroupMessage(t *testing.T) { - identifier, _ := hex.DecodeString("24ce39d660600b3a98adff3075b6d1f4") - tp := topic.NewTopic(topic.TopicKindGroupMessagesV1, identifier) - result := TopicToString(tp) - require.Equal(t, "/xmtp/mls/1/g-24ce39d660600b3a98adff3075b6d1f4/proto", result) -} - -func Test_TopicToString_WelcomeMessage(t *testing.T) { - identifier, _ := hex.DecodeString("f3ac64eba2272334") - tp := topic.NewTopic(topic.TopicKindWelcomeMessagesV1, identifier) - result := TopicToString(tp) - require.Equal(t, "/xmtp/mls/1/w-f3ac64eba2272334/proto", result) -} - -func Test_TopicToString_UnknownKind(t *testing.T) { - identifier, _ := hex.DecodeString("abcd") - tp := topic.NewTopic(topic.TopicKindIdentityUpdatesV1, identifier) - result := TopicToString(tp) - require.Equal(t, "", result) -} - -func Test_TopicToString_Roundtrip(t *testing.T) { +func TestTopicToString_Roundtrip(t *testing.T) { original := "/xmtp/mls/1/g-24ce39d660600b3a98adff3075b6d1f4/proto" parsed, err := ParseV3Topic(original) require.NoError(t, err) require.Equal(t, original, TopicToString(parsed)) } -func Test_GetMessageTypeFromTopic_Group(t *testing.T) { - tp := topic.NewTopic(topic.TopicKindGroupMessagesV1, []byte{0x01}) - require.Equal(t, V3Conversation, GetMessageTypeFromTopic(tp)) -} - -func Test_GetMessageTypeFromTopic_Welcome(t *testing.T) { - tp := topic.NewTopic(topic.TopicKindWelcomeMessagesV1, []byte{0x01}) - require.Equal(t, V3Welcome, GetMessageTypeFromTopic(tp)) -} - -func Test_GetMessageTypeFromTopic_Unknown(t *testing.T) { - tp := topic.NewTopic(topic.TopicKindIdentityUpdatesV1, []byte{0x01}) - require.Equal(t, Unknown, GetMessageTypeFromTopic(tp)) -} - -func Test_GetMessageTypeFromTopic_Nil(t *testing.T) { - require.Equal(t, Unknown, GetMessageTypeFromTopic(nil)) +func TestGetMessageTypeFromTopic(t *testing.T) { + tests := []struct { + name string + topic *topic.Topic + want MessageType + }{ + { + name: "group", + topic: topic.NewTopic(topic.TopicKindGroupMessagesV1, []byte{0x01}), + want: V3Conversation, + }, + { + name: "welcome", + topic: topic.NewTopic(topic.TopicKindWelcomeMessagesV1, []byte{0x01}), + want: V3Welcome, + }, + { + name: "unknown kind", + topic: topic.NewTopic(topic.TopicKindIdentityUpdatesV1, []byte{0x01}), + want: Unknown, + }, + { + name: "nil topic", + topic: nil, + want: Unknown, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, GetMessageTypeFromTopic(tc.topic)) + }) + } }