Skip to content

Commit d5774ca

Browse files
AchoArnoldCopilot
andauthored
feat(api): support URL-backed phone gateways (#989)
* docs(api): design URL-backed notifications Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(api): plan adapter gateway integration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: ignore linked worktrees Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(api): classify phone notification tokens Add Phone.NotificationTransport and Phone.NotificationURL helpers with entity-level validation for FCM tokens and public HTTPS endpoints. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d * feat(api): classify phone notification tokens Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d * feat(api): tighten phone token classification Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d * feat(api): validate adapter endpoints * refactor(api): dispatch gateway notifications Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d * feat(api): send notifications to adapters Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d * fix(api): harden notification TLS transport Clear TLS dial hooks and ServerName so policy validation and hostname verification cannot be bypassed. Restrict retries to standard 5xx statuses. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d * feat(api): route phone gateway wake-ups Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d * feat(api): enable URL-backed phone gateways Validate adapter URLs with the same cached endpoint policy used by the secure notification dialer. Ignore private-host allowlists outside local environments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d * fix(api): preserve secured telemetry transport Keep trusted HTTP middleware around the endpoint-policy transport so notification delivery retains telemetry and connection hardening. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d * fix(api): restrict trusted HTTP transports Only service-created marked transports may preserve middleware. Opaque caller transports are replaced with policy-hardened transports so they cannot bypass SSRF dialing and TLS controls. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d * test(api): cover URL-backed phone gateways Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d * fix(api): harden adapter notifications Enforce transport provenance and per-attempt endpoint checks to close policy bypass and DNS timeout gaps. Redact callback tokens from logs and telemetry. Encode TTLs with protobuf JSON duration syntax. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d * fix(api): relax adapter URL validation Allow standard URL user information while retaining HTTPS and SSRF checks. Construct endpoint policies on demand and share one only within each HTTP sender graph so secured transport identity remains intact. Keep FCM-token examples opaque to preserve the existing API guidance. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d * refactor(api): simplify adapter delivery Use the existing OpenTelemetry HTTP transport and retry-go delivery pattern. Remove endpoint network policy and custom dialing while preserving callback URL redaction in telemetry. Reuse EventDispatcher directly and clarify the phone transport dispatcher name. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d * refactor(api): use standard logging paths Use the same OpenTelemetry HTTP client as webhooks and preserve default request, database, and notification logging without feature-specific redaction. Keep GORM query variables in traces. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d * refactor(api): focus notification delivery Reuse Firebase messages across transports and initialize one reusable retry policy per HTTP sender. Split phone transport dispatch into its own component and rely on the existing HTTP instrumentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d * refactor(api): map notification clients Reuse the existing FCMClient contract across Firebase and HTTP so new phone transports only require DI map configuration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d * fix(api): restore phone request logs Undo request-log scrubbing so phone handler failures retain the original URL and complete request parameters. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2884b08e-2828-4b50-a9e6-702dce51ec0d
1 parent 2d17a90 commit d5774ca

36 files changed

Lines changed: 5435 additions & 169 deletions

.github/workflows/api.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ jobs:
3030
bash tests/generate-firebase-credentials.sh tests/firebase-credentials.json
3131
echo "FIREBASE_CREDENTIALS=$(jq -c . tests/firebase-credentials.json)" >> $GITHUB_ENV
3232
33+
- name: Generate adapter certificates
34+
run: bash tests/generate-adapter-certificates.sh tests/certs
35+
3336
- name: Start Services
3437
working-directory: ./tests
3538
run: docker compose up -d --build

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ android/app/debug/
88
android/app/release/
99

1010
tests/firebase-credentials.json
11+
tests/certs/
1112
tests/emulator/emulator.exe
1213
SECURITY_AUDIT_REPORT.md
1314

@@ -16,3 +17,4 @@ SECURITY_AUDIT_REPORT.md
1617
.output
1718
.agents/
1819
skills-lock.json
20+
.worktrees/

api/docs/docs.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2420,7 +2420,7 @@ const docTemplate = `{
24202420
"ApiKeyAuth": []
24212421
}
24222422
],
2423-
"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'",
2423+
"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.",
24242424
"consumes": [
24252425
"application/json"
24262426
],
@@ -2483,7 +2483,7 @@ const docTemplate = `{
24832483
"ApiKeyAuth": []
24842484
}
24852485
],
2486-
"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'",
2486+
"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.",
24872487
"consumes": [
24882488
"application/json"
24892489
],
@@ -4947,6 +4947,7 @@ const docTemplate = `{
49474947
],
49484948
"properties": {
49494949
"fcm_token": {
4950+
"description": "FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL.",
49504951
"type": "string",
49514952
"example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....."
49524953
},
@@ -4975,6 +4976,7 @@ const docTemplate = `{
49754976
],
49764977
"properties": {
49774978
"fcm_token": {
4979+
"description": "FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL.",
49784980
"type": "string",
49794981
"example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....."
49804982
},

api/docs/swagger.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2417,7 +2417,7 @@
24172417
"ApiKeyAuth": []
24182418
}
24192419
],
2420-
"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'",
2420+
"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.",
24212421
"consumes": [
24222422
"application/json"
24232423
],
@@ -2480,7 +2480,7 @@
24802480
"ApiKeyAuth": []
24812481
}
24822482
],
2483-
"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'",
2483+
"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.",
24842484
"consumes": [
24852485
"application/json"
24862486
],
@@ -4944,6 +4944,7 @@
49444944
],
49454945
"properties": {
49464946
"fcm_token": {
4947+
"description": "FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL.",
49474948
"type": "string",
49484949
"example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....."
49494950
},
@@ -4972,6 +4973,7 @@
49724973
],
49734974
"properties": {
49744975
"fcm_token": {
4976+
"description": "FcmToken is either a Firebase registration token or a public HTTPS adapter callback URL.",
49754977
"type": "string",
49764978
"example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....."
49774979
},

api/docs/swagger.yaml

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -971,6 +971,8 @@ definitions:
971971
requests.PhoneFCMToken:
972972
properties:
973973
fcm_token:
974+
description: FcmToken is either a Firebase registration token or a public
975+
HTTPS adapter callback URL.
974976
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd.....
975977
type: string
976978
phone_number:
@@ -989,6 +991,8 @@ definitions:
989991
requests.PhoneUpsert:
990992
properties:
991993
fcm_token:
994+
description: FcmToken is either a Firebase registration token or a public
995+
HTTPS adapter callback URL.
992996
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd.....
993997
type: string
994998
max_send_attempts:
@@ -3337,7 +3341,8 @@ paths:
33373341
consumes:
33383342
- application/json
33393343
description: Updates properties of a user's phone. If the phone with this number
3340-
does not exist, a new one will be created. Think of this method like an 'upsert'
3344+
does not exist, a new one will be created. Think of this method like an 'upsert'.
3345+
URL-backed phone gateways receive FCM-compatible HTTP wake-ups.
33413346
parameters:
33423347
- description: Payload of new phone number.
33433348
in: body
@@ -3417,8 +3422,10 @@ paths:
34173422
put:
34183423
consumes:
34193424
- application/json
3420-
description: Updates the FCM token of a phone. If the phone with this number
3421-
does not exist, a new one will be created. Think of this method like an 'upsert'
3425+
description: Updates the FCM token or adapter callback URL of a phone. If the
3426+
phone with this number does not exist, a new one will be created. Think of
3427+
this method like an 'upsert'. URL-backed phone gateways receive FCM-compatible
3428+
HTTP wake-ups.
34223429
parameters:
34233430
- description: Payload of new FCM token.
34243431
in: body

api/pkg/di/container.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,24 @@ func (container *Container) FCMClient() services.FCMClient {
565565
return services.NewFirebaseFCMClient(messagingClient)
566566
}
567567

568+
// NotificationHTTPClient creates the OpenTelemetry-instrumented client for phone notification adapters.
569+
func (container *Container) NotificationHTTPClient() *http.Client {
570+
return &http.Client{
571+
Transport: container.HTTPRoundTripperWithoutRetry("phone_notification_http"),
572+
}
573+
}
574+
575+
// PhoneNotificationClients creates notification clients keyed by phone transport.
576+
func (container *Container) PhoneNotificationClients() map[entities.NotificationTransport]services.FCMClient {
577+
return map[entities.NotificationTransport]services.FCMClient{
578+
entities.NotificationTransportFCM: container.FCMClient(),
579+
entities.NotificationTransportHTTP: services.NewHTTPNotificationSender(
580+
container.Logger(),
581+
container.NotificationHTTPClient(),
582+
),
583+
}
584+
}
585+
568586
// FirebaseCredentials returns firebase credentials as bytes.
569587
func (container *Container) FirebaseCredentials() []byte {
570588
container.logger.Debug("creating firebase credentials")
@@ -1715,7 +1733,7 @@ func (container *Container) NotificationService() (service *services.PhoneNotifi
17151733
return services.NewNotificationService(
17161734
container.Logger(),
17171735
container.Tracer(),
1718-
container.FCMClient(),
1736+
container.PhoneNotificationClients(),
17191737
container.PhoneRepository(),
17201738
container.PhoneNotificationRepository(),
17211739
container.MessageSendScheduleRepository(),

api/pkg/di/container_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package di
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
7+
"github.com/NdoleStudio/httpsms/pkg/entities"
8+
"github.com/NdoleStudio/httpsms/pkg/services"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestNotificationHTTPClientUsesOTelRoundTripperWithoutRetries(t *testing.T) {
14+
t.Setenv("ENV", "local")
15+
client := NewLiteContainer().NotificationHTTPClient()
16+
17+
assert.Zero(t, client.Timeout)
18+
assert.Equal(t, "*otelroundtripper.otelRoundTripper", reflect.TypeOf(client.Transport).String())
19+
assert.Nil(t, client.CheckRedirect)
20+
}
21+
22+
func TestPhoneNotificationClientsMapsConfiguredTransports(t *testing.T) {
23+
t.Setenv("ENV", "local")
24+
t.Setenv("FCM_ENDPOINT", "http://localhost")
25+
26+
clients := NewLiteContainer().PhoneNotificationClients()
27+
28+
require.Len(t, clients, 2)
29+
assert.IsType(t, &services.EmulatorFCMClient{}, clients[entities.NotificationTransportFCM])
30+
httpSender, ok := clients[entities.NotificationTransportHTTP].(*services.HTTPNotificationSender)
31+
require.True(t, ok)
32+
client := reflect.ValueOf(httpSender).Elem().FieldByName("client").Elem()
33+
transport := client.FieldByName("Transport").Elem()
34+
35+
assert.Equal(t, "*otelroundtripper.otelRoundTripper", transport.Type().String())
36+
}

api/pkg/entities/phone.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package entities
22

33
import (
4+
"net/url"
5+
"strings"
46
"time"
57

8+
"github.com/NdoleStudio/stacktrace"
69
"github.com/google/uuid"
710
)
811

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

37+
// NotificationTransport identifies how a phone receives wake-up notifications.
38+
type NotificationTransport string
39+
40+
const (
41+
// NotificationTransportFCM sends notifications through Firebase.
42+
NotificationTransportFCM NotificationTransport = "fcm"
43+
// NotificationTransportHTTP sends notifications to an HTTPS endpoint.
44+
NotificationTransportHTTP NotificationTransport = "http"
45+
)
46+
47+
func isNotificationURLCandidate(token string) bool {
48+
lower := strings.ToLower(token)
49+
50+
return strings.Contains(token, "://") ||
51+
strings.HasPrefix(lower, "http:") ||
52+
strings.HasPrefix(lower, "https:") ||
53+
strings.HasPrefix(lower, "ftp:")
54+
}
55+
3456
// MessageExpirationDuration returns the message expiration as time.Duration
3557
func (phone *Phone) MessageExpirationDuration() time.Duration {
3658
return time.Duration(int(phone.MessageExpirationSecondsSanitized())) * time.Second
@@ -51,3 +73,52 @@ func (phone *Phone) MaxSendAttemptsSanitized() uint {
5173
}
5274
return phone.MaxSendAttempts
5375
}
76+
77+
// NotificationTransport returns the transport encoded by FcmToken.
78+
func (phone *Phone) NotificationTransport() (NotificationTransport, error) {
79+
if phone == nil || phone.FcmToken == nil {
80+
return "", stacktrace.NewErrorf("phone has no notification token")
81+
}
82+
83+
token := strings.TrimSpace(*phone.FcmToken)
84+
if token == "" {
85+
return "", stacktrace.NewErrorf("phone has no notification token")
86+
}
87+
88+
if !isNotificationURLCandidate(token) {
89+
return NotificationTransportFCM, nil
90+
}
91+
92+
endpoint, err := url.Parse(token)
93+
if err != nil {
94+
return "", stacktrace.NewError("invalid notification URL")
95+
}
96+
97+
if !strings.EqualFold(endpoint.Scheme, "https") {
98+
return "", stacktrace.NewErrorf("notification URL must use https")
99+
}
100+
if endpoint.Hostname() == "" {
101+
return "", stacktrace.NewErrorf("notification URL must include a hostname")
102+
}
103+
104+
return NotificationTransportHTTP, nil
105+
}
106+
107+
// NotificationURL returns the parsed endpoint for an HTTP notification token.
108+
func (phone *Phone) NotificationURL() (*url.URL, error) {
109+
transport, err := phone.NotificationTransport()
110+
if err != nil {
111+
return nil, err
112+
}
113+
114+
if transport != NotificationTransportHTTP {
115+
return nil, stacktrace.NewErrorf("phone notification transport is [%s], not HTTP", transport)
116+
}
117+
118+
endpoint, err := url.Parse(strings.TrimSpace(*phone.FcmToken))
119+
if err != nil {
120+
return nil, stacktrace.NewError("cannot parse notification URL")
121+
}
122+
123+
return endpoint, nil
124+
}

api/pkg/entities/phone_notification.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const (
1818
// PhoneNotificationStatus is the status of a phone notification
1919
type PhoneNotificationStatus string
2020

21-
// PhoneNotification represents an FCM notification to a mobile phone
21+
// PhoneNotification represents a scheduled wake-up notification for a phone gateway.
2222
type PhoneNotification struct {
2323
ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;"`
2424
MessageID uuid.UUID `json:"message_id"`

api/pkg/entities/phone_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package entities
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func stringPointer(value string) *string {
11+
return &value
12+
}
13+
14+
func TestPhoneNotificationTransport(t *testing.T) {
15+
tests := []struct {
16+
name string
17+
token *string
18+
transport NotificationTransport
19+
hasError bool
20+
}{
21+
{name: "firebase token with colon", token: stringPointer("fcm-token:value"), transport: NotificationTransportFCM},
22+
{name: "opaque token with slash", token: stringPointer("projects/alpha/messages/123"), transport: NotificationTransportFCM},
23+
{name: "public https url", token: stringPointer("https://adapter.example.com/notify"), transport: NotificationTransportHTTP},
24+
{name: "missing token", token: nil, hasError: true},
25+
{name: "empty token", token: stringPointer(" "), hasError: true},
26+
{name: "http url", token: stringPointer("http://adapter.example.com/notify"), hasError: true},
27+
{name: "ftp url", token: stringPointer("ftp://adapter.example.com/notify"), hasError: true},
28+
{name: "scheme-like https token", token: stringPointer("https:adapter.example.com"), hasError: true},
29+
{name: "scheme-like http token", token: stringPointer("http:foo"), hasError: true},
30+
{name: "scheme-like ftp token", token: stringPointer("ftp:foo"), hasError: true},
31+
{name: "missing host", token: stringPointer("https:///notify"), hasError: true},
32+
{name: "embedded user information", token: stringPointer("https://user:password@adapter.example.com/notify"), transport: NotificationTransportHTTP},
33+
{name: "malformed url", token: stringPointer("https://[::1"), hasError: true},
34+
}
35+
36+
for _, test := range tests {
37+
t.Run(test.name, func(t *testing.T) {
38+
phone := &Phone{FcmToken: test.token}
39+
40+
transport, err := phone.NotificationTransport()
41+
42+
if test.hasError {
43+
require.Error(t, err)
44+
return
45+
}
46+
require.NoError(t, err)
47+
assert.Equal(t, test.transport, transport)
48+
})
49+
}
50+
}
51+
52+
func TestPhoneNotificationURL(t *testing.T) {
53+
phone := &Phone{FcmToken: stringPointer("https://user:password@adapter.example.com/notify?tenant=42")}
54+
55+
endpoint, err := phone.NotificationURL()
56+
57+
require.NoError(t, err)
58+
assert.Equal(t, "https", endpoint.Scheme)
59+
assert.Equal(t, "user", endpoint.User.Username())
60+
password, hasPassword := endpoint.User.Password()
61+
assert.True(t, hasPassword)
62+
assert.Equal(t, "password", password)
63+
assert.Equal(t, "adapter.example.com", endpoint.Hostname())
64+
assert.Equal(t, "/notify", endpoint.Path)
65+
assert.Equal(t, "tenant=42", endpoint.RawQuery)
66+
}
67+
68+
func TestPhoneNotificationURLRejectsFCMToken(t *testing.T) {
69+
phone := &Phone{FcmToken: stringPointer("fcm-token:value")}
70+
71+
_, err := phone.NotificationURL()
72+
73+
require.Error(t, err)
74+
}
75+
76+
func TestPhoneNotificationTransportDoesNotExposeMalformedToken(t *testing.T) {
77+
token := "https://[::1/secret?token=customer-secret"
78+
phone := &Phone{FcmToken: &token}
79+
80+
_, err := phone.NotificationTransport()
81+
82+
require.Error(t, err)
83+
assert.NotContains(t, err.Error(), token)
84+
assert.NotContains(t, err.Error(), "customer-secret")
85+
}

0 commit comments

Comments
 (0)