Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
4a833a8
docs(api): design URL-backed notifications
AchoArnold Sep 2, 2026
7b92f2b
docs(api): plan adapter gateway integration
AchoArnold Sep 2, 2026
ee9127c
chore: ignore linked worktrees
AchoArnold Sep 2, 2026
72af3bb
feat(api): classify phone notification tokens
AchoArnold Sep 2, 2026
7bad65c
feat(api): classify phone notification tokens
AchoArnold Sep 2, 2026
b5182f0
feat(api): tighten phone token classification
AchoArnold Sep 2, 2026
b51921d
feat(api): validate adapter endpoints
AchoArnold Sep 2, 2026
56313f9
refactor(api): dispatch gateway notifications
AchoArnold Sep 2, 2026
6410ac2
feat(api): send notifications to adapters
AchoArnold Sep 2, 2026
7c3f158
fix(api): harden notification TLS transport
AchoArnold Sep 2, 2026
aa7549a
feat(api): route phone gateway wake-ups
AchoArnold Sep 2, 2026
967da4c
feat(api): enable URL-backed phone gateways
AchoArnold Sep 2, 2026
2038340
fix(api): preserve secured telemetry transport
AchoArnold Sep 2, 2026
f1a739c
fix(api): restrict trusted HTTP transports
AchoArnold Sep 2, 2026
cb1ea34
test(api): cover URL-backed phone gateways
AchoArnold Sep 3, 2026
5e069ac
fix(api): harden adapter notifications
AchoArnold Sep 3, 2026
1afd083
fix(api): relax adapter URL validation
AchoArnold Sep 3, 2026
2742f55
refactor(api): simplify adapter delivery
AchoArnold Sep 3, 2026
cc133f8
refactor(api): use standard logging paths
AchoArnold Sep 3, 2026
5482dce
refactor(api): focus notification delivery
AchoArnold Sep 3, 2026
9512759
refactor(api): map notification clients
AchoArnold Sep 3, 2026
63b4934
fix(api): restore phone request logs
AchoArnold Sep 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ jobs:
bash tests/generate-firebase-credentials.sh tests/firebase-credentials.json
echo "FIREBASE_CREDENTIALS=$(jq -c . tests/firebase-credentials.json)" >> $GITHUB_ENV

- name: Generate adapter certificates
run: bash tests/generate-adapter-certificates.sh tests/certs

- name: Start Services
working-directory: ./tests
run: docker compose up -d --build
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ android/app/debug/
android/app/release/

tests/firebase-credentials.json
tests/certs/
tests/emulator/emulator.exe
SECURITY_AUDIT_REPORT.md

Expand All @@ -16,3 +17,4 @@ SECURITY_AUDIT_REPORT.md
.output
.agents/
skills-lock.json
.worktrees/
6 changes: 4 additions & 2 deletions api/docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -2420,7 +2420,7 @@ const docTemplate = `{
"ApiKeyAuth": []
}
],
"description": "Updates properties of a user's phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'",
"description": "Updates properties of a user's phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'. URL-backed phone gateways receive FCM-compatible HTTP wake-ups.",
"consumes": [
"application/json"
],
Expand Down Expand Up @@ -2483,7 +2483,7 @@ const docTemplate = `{
"ApiKeyAuth": []
}
],
"description": "Updates the FCM token of a phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'",
"description": "Updates the FCM token or adapter callback URL of a phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'. URL-backed phone gateways receive FCM-compatible HTTP wake-ups.",
"consumes": [
"application/json"
],
Expand Down Expand Up @@ -4947,6 +4947,7 @@ const docTemplate = `{
],
"properties": {
"fcm_token": {
"description": "FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL.",
"type": "string",
"example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....."
},
Expand Down Expand Up @@ -4975,6 +4976,7 @@ const docTemplate = `{
],
"properties": {
"fcm_token": {
"description": "FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL.",
"type": "string",
"example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....."
},
Expand Down
6 changes: 4 additions & 2 deletions api/docs/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -2417,7 +2417,7 @@
"ApiKeyAuth": []
}
],
"description": "Updates properties of a user's phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'",
"description": "Updates properties of a user's phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'. URL-backed phone gateways receive FCM-compatible HTTP wake-ups.",
"consumes": [
"application/json"
],
Expand Down Expand Up @@ -2480,7 +2480,7 @@
"ApiKeyAuth": []
}
],
"description": "Updates the FCM token of a phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'",
"description": "Updates the FCM token or adapter callback URL of a phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert'. URL-backed phone gateways receive FCM-compatible HTTP wake-ups.",
"consumes": [
"application/json"
],
Expand Down Expand Up @@ -4944,6 +4944,7 @@
],
"properties": {
"fcm_token": {
"description": "FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL.",
"type": "string",
"example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....."
},
Expand Down Expand Up @@ -4972,6 +4973,7 @@
],
"properties": {
"fcm_token": {
"description": "FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL.",
"type": "string",
"example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....."
},
Expand Down
13 changes: 10 additions & 3 deletions api/docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -971,6 +971,8 @@ definitions:
requests.PhoneFCMToken:
properties:
fcm_token:
description: FcmToken is either a Firebase registration token or a public
HTTPS adapter callback URL.
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd.....
type: string
phone_number:
Expand All @@ -989,6 +991,8 @@ definitions:
requests.PhoneUpsert:
properties:
fcm_token:
description: FcmToken is either a Firebase registration token or a public
HTTPS adapter callback URL.
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd.....
type: string
max_send_attempts:
Expand Down Expand Up @@ -3337,7 +3341,8 @@ paths:
consumes:
- application/json
description: Updates properties of a user's phone. If the phone with this number
does not exist, a new one will be created. Think of this method like an 'upsert'
does not exist, a new one will be created. Think of this method like an 'upsert'.
URL-backed phone gateways receive FCM-compatible HTTP wake-ups.
parameters:
- description: Payload of new phone number.
in: body
Expand Down Expand Up @@ -3417,8 +3422,10 @@ paths:
put:
consumes:
- application/json
description: Updates the FCM token of a phone. If the phone with this number
does not exist, a new one will be created. Think of this method like an 'upsert'
description: Updates the FCM token or adapter callback URL of a phone. If the
phone with this number does not exist, a new one will be created. Think of
this method like an 'upsert'. URL-backed phone gateways receive FCM-compatible
HTTP wake-ups.
parameters:
- description: Payload of new FCM token.
in: body
Expand Down
20 changes: 19 additions & 1 deletion api/pkg/di/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,24 @@ func (container *Container) FCMClient() services.FCMClient {
return services.NewFirebaseFCMClient(messagingClient)
}

// NotificationHTTPClient creates the OpenTelemetry-instrumented client for phone notification adapters.
func (container *Container) NotificationHTTPClient() *http.Client {
return &http.Client{
Transport: container.HTTPRoundTripperWithoutRetry("phone_notification_http"),
}
}

// PhoneNotificationClients creates notification clients keyed by phone transport.
func (container *Container) PhoneNotificationClients() map[entities.NotificationTransport]services.FCMClient {
return map[entities.NotificationTransport]services.FCMClient{
entities.NotificationTransportFCM: container.FCMClient(),
entities.NotificationTransportHTTP: services.NewHTTPNotificationSender(
container.Logger(),
container.NotificationHTTPClient(),
),
}
}

// FirebaseCredentials returns firebase credentials as bytes.
func (container *Container) FirebaseCredentials() []byte {
container.logger.Debug("creating firebase credentials")
Expand Down Expand Up @@ -1715,7 +1733,7 @@ func (container *Container) NotificationService() (service *services.PhoneNotifi
return services.NewNotificationService(
container.Logger(),
container.Tracer(),
container.FCMClient(),
container.PhoneNotificationClients(),
container.PhoneRepository(),
container.PhoneNotificationRepository(),
container.MessageSendScheduleRepository(),
Expand Down
36 changes: 36 additions & 0 deletions api/pkg/di/container_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package di

import (
"reflect"
"testing"

"github.com/NdoleStudio/httpsms/pkg/entities"
"github.com/NdoleStudio/httpsms/pkg/services"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestNotificationHTTPClientUsesOTelRoundTripperWithoutRetries(t *testing.T) {
t.Setenv("ENV", "local")
client := NewLiteContainer().NotificationHTTPClient()

assert.Zero(t, client.Timeout)
assert.Equal(t, "*otelroundtripper.otelRoundTripper", reflect.TypeOf(client.Transport).String())
assert.Nil(t, client.CheckRedirect)
}

func TestPhoneNotificationClientsMapsConfiguredTransports(t *testing.T) {
t.Setenv("ENV", "local")
t.Setenv("FCM_ENDPOINT", "http://localhost")

clients := NewLiteContainer().PhoneNotificationClients()

require.Len(t, clients, 2)
assert.IsType(t, &services.EmulatorFCMClient{}, clients[entities.NotificationTransportFCM])
httpSender, ok := clients[entities.NotificationTransportHTTP].(*services.HTTPNotificationSender)
require.True(t, ok)
client := reflect.ValueOf(httpSender).Elem().FieldByName("client").Elem()
transport := client.FieldByName("Transport").Elem()

assert.Equal(t, "*otelroundtripper.otelRoundTripper", transport.Type().String())
}
71 changes: 71 additions & 0 deletions api/pkg/entities/phone.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package entities

import (
"net/url"
"strings"
"time"

"github.com/NdoleStudio/stacktrace"
"github.com/google/uuid"
)

Expand Down Expand Up @@ -31,6 +34,25 @@ type Phone struct {
UpdatedAt time.Time `json:"updated_at" example:"2022-06-05T14:26:10.303278+03:00"`
}

// NotificationTransport identifies how a phone receives wake-up notifications.
type NotificationTransport string

const (
// NotificationTransportFCM sends notifications through Firebase.
NotificationTransportFCM NotificationTransport = "fcm"
// NotificationTransportHTTP sends notifications to an HTTPS endpoint.
NotificationTransportHTTP NotificationTransport = "http"
)

func isNotificationURLCandidate(token string) bool {
lower := strings.ToLower(token)

return strings.Contains(token, "://") ||
strings.HasPrefix(lower, "http:") ||
strings.HasPrefix(lower, "https:") ||
strings.HasPrefix(lower, "ftp:")
}

// MessageExpirationDuration returns the message expiration as time.Duration
func (phone *Phone) MessageExpirationDuration() time.Duration {
return time.Duration(int(phone.MessageExpirationSecondsSanitized())) * time.Second
Expand All @@ -51,3 +73,52 @@ func (phone *Phone) MaxSendAttemptsSanitized() uint {
}
return phone.MaxSendAttempts
}

// NotificationTransport returns the transport encoded by FcmToken.
func (phone *Phone) NotificationTransport() (NotificationTransport, error) {
if phone == nil || phone.FcmToken == nil {
return "", stacktrace.NewErrorf("phone has no notification token")
}

token := strings.TrimSpace(*phone.FcmToken)
if token == "" {
return "", stacktrace.NewErrorf("phone has no notification token")
}

if !isNotificationURLCandidate(token) {
return NotificationTransportFCM, nil
}

endpoint, err := url.Parse(token)
if err != nil {
return "", stacktrace.NewError("invalid notification URL")
}

if !strings.EqualFold(endpoint.Scheme, "https") {
return "", stacktrace.NewErrorf("notification URL must use https")
}
if endpoint.Hostname() == "" {
return "", stacktrace.NewErrorf("notification URL must include a hostname")
}

return NotificationTransportHTTP, nil
}

// NotificationURL returns the parsed endpoint for an HTTP notification token.
func (phone *Phone) NotificationURL() (*url.URL, error) {
transport, err := phone.NotificationTransport()
if err != nil {
return nil, err
}

if transport != NotificationTransportHTTP {
return nil, stacktrace.NewErrorf("phone notification transport is [%s], not HTTP", transport)
}

endpoint, err := url.Parse(strings.TrimSpace(*phone.FcmToken))
if err != nil {
return nil, stacktrace.NewError("cannot parse notification URL")
}

return endpoint, nil
}
2 changes: 1 addition & 1 deletion api/pkg/entities/phone_notification.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const (
// PhoneNotificationStatus is the status of a phone notification
type PhoneNotificationStatus string

// PhoneNotification represents an FCM notification to a mobile phone
// PhoneNotification represents a scheduled wake-up notification for a phone gateway.
type PhoneNotification struct {
ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;"`
MessageID uuid.UUID `json:"message_id"`
Expand Down
85 changes: 85 additions & 0 deletions api/pkg/entities/phone_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package entities

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func stringPointer(value string) *string {
return &value
}

func TestPhoneNotificationTransport(t *testing.T) {
tests := []struct {
name string
token *string
transport NotificationTransport
hasError bool
}{
{name: "firebase token with colon", token: stringPointer("fcm-token:value"), transport: NotificationTransportFCM},
{name: "opaque token with slash", token: stringPointer("projects/alpha/messages/123"), transport: NotificationTransportFCM},
{name: "public https url", token: stringPointer("https://adapter.example.com/notify"), transport: NotificationTransportHTTP},
{name: "missing token", token: nil, hasError: true},
{name: "empty token", token: stringPointer(" "), hasError: true},
{name: "http url", token: stringPointer("http://adapter.example.com/notify"), hasError: true},
{name: "ftp url", token: stringPointer("ftp://adapter.example.com/notify"), hasError: true},
{name: "scheme-like https token", token: stringPointer("https:adapter.example.com"), hasError: true},
{name: "scheme-like http token", token: stringPointer("http:foo"), hasError: true},
{name: "scheme-like ftp token", token: stringPointer("ftp:foo"), hasError: true},
{name: "missing host", token: stringPointer("https:///notify"), hasError: true},
{name: "embedded user information", token: stringPointer("https://user:password@adapter.example.com/notify"), transport: NotificationTransportHTTP},
{name: "malformed url", token: stringPointer("https://[::1"), hasError: true},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
phone := &Phone{FcmToken: test.token}

transport, err := phone.NotificationTransport()

if test.hasError {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, test.transport, transport)
})
}
}

func TestPhoneNotificationURL(t *testing.T) {
phone := &Phone{FcmToken: stringPointer("https://user:password@adapter.example.com/notify?tenant=42")}

endpoint, err := phone.NotificationURL()

require.NoError(t, err)
assert.Equal(t, "https", endpoint.Scheme)
assert.Equal(t, "user", endpoint.User.Username())
password, hasPassword := endpoint.User.Password()
assert.True(t, hasPassword)
assert.Equal(t, "password", password)
assert.Equal(t, "adapter.example.com", endpoint.Hostname())
assert.Equal(t, "/notify", endpoint.Path)
assert.Equal(t, "tenant=42", endpoint.RawQuery)
}

func TestPhoneNotificationURLRejectsFCMToken(t *testing.T) {
phone := &Phone{FcmToken: stringPointer("fcm-token:value")}

_, err := phone.NotificationURL()

require.Error(t, err)
}

func TestPhoneNotificationTransportDoesNotExposeMalformedToken(t *testing.T) {
token := "https://[::1/secret?token=customer-secret"
phone := &Phone{FcmToken: &token}

_, err := phone.NotificationTransport()

require.Error(t, err)
assert.NotContains(t, err.Error(), token)
assert.NotContains(t, err.Error(), "customer-secret")
}
Loading
Loading