Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 6 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ Responsibilities:
- process old/catch-up events through the same idempotent ingestion path;
- expose operator pairing/reconnect/unpair controls.

Pairing refuses to replace an already-valid session. The operator must unpair first.
Google-account/emoji (Gaia) pairing is the primary flow. The operator supplies the same cookie material required by upstream mautrix-gmessages (cookie JSON, Cookie header, or DevTools Copy-as-cURL); values are not logged or echoed. QR pairing remains a fallback. Pairing refuses to replace an already-valid session or another in-progress pairing; the operator must cancel/unpair first.

The connector is optional. Payment/SMS records remain valid if Google's private protocol changes.

Expand All @@ -247,8 +247,11 @@ GET /api/paygate/health
GET /api/config # authenticated operator
GET /api/dashboard # authenticated operator
GET /api/connector/gmessages/status # authenticated operator
POST /api/connector/gmessages/pair
POST /api/connector/gmessages/pair/refresh
POST /api/connector/gmessages/pair/google
POST /api/connector/gmessages/pair/qr
POST /api/connector/gmessages/pair/qr/refresh
POST /api/connector/gmessages/pair # backward-compatible QR alias
POST /api/connector/gmessages/pair/refresh # backward-compatible QR refresh alias
POST /api/connector/gmessages/reconnect
DELETE /api/connector/gmessages/pair
```
Expand Down
10 changes: 6 additions & 4 deletions IMPLEMENTATION_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Use non-empty uniqueness for RRN, idempotency key and `(source, source_event_id)
- `GET /api/paygate/health`: PayGate DB/readiness and redacted connector summary.
- `GET /api/config`: authenticated safe configuration/connector status.
- `GET /api/dashboard`: authenticated summary.
- connector status/pair/refresh/reconnect/unpair routes are authenticated.
- connector status, Google-account/emoji pair, QR fallback/refresh, reconnect and unpair routes are dashboard-authenticated.

The SPA must never swallow unknown `/api/*` or `/_/*` routes.

Expand Down Expand Up @@ -101,14 +101,16 @@ UI pages: Login, Dashboard, Payments/create/details, SMS Events, Webhook Deliver
libgm is optional infrastructure, not the payment model.

- Persist AuthData under `pb_data/gmessages/session.json` with restrictive permissions.
- Console/API QR pairing refreshes short-lived QR tokens.
- Refuse accidental replacement of an existing valid pairing; unpair first.
- Prefer Google-account/Gaia pairing: validate the upstream-required Google cookie set, fetch config, display the derived emoji, wait for phone confirmation, then persist the completed session.
- Cookie input may be cookie JSON, a raw Cookie header, or a DevTools Copy-as-cURL request; values must never be logged or echoed.
- Keep QR pairing as a fallback and refresh short-lived QR tokens.
- Refuse accidental replacement of an existing valid pairing or another pairing already in progress; cancel/unpair first.
- When enabled/paired, connect on serve, persist token refreshes, process incoming text `WrappedMessage` events into the same SMS service, and reconnect/back off on failure.
- Keep provider message ID and original timestamp, including catch-up/old events.
- Report paired/connected/phone-responsive/timestamp/error state to authenticated operator views.
- Application remains healthy when unpaired/offline.
- Connector is read-only; do not send SMS/RCS.
- Real phone QR scanning is intentionally deferred until the operator performs the device test.
- Real phone Google-account/emoji pairing and reconnect persistence remain a device acceptance test; QR is fallback only.

## Expiry/background work

Expand Down
17 changes: 9 additions & 8 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,11 @@ This document records what was implemented and what must be proven before/after
- [x] Privacy-prefilter bank-credit-like messages before ingestion.
- [x] Handle connected/degraded/phone-response events.
- [x] Back off reconnect attempts.
- [x] Add QR pairing command/API.
- [x] Auto-refresh short-lived QR data.
- [x] Refuse accidental re-pair over an existing session.
- [ ] Scan/complete a real phone QR pairing — intentionally deferred by operator request.
- [x] Add Google-account/Gaia emoji pairing API and UI.
- [x] Accept/validate upstream-required cookie JSON, raw Cookie headers and DevTools Copy-as-cURL input without logging values.
- [x] Keep QR pairing as a fallback and auto-refresh short-lived QR data.
- [x] Refuse accidental re-pair over an existing session or another pairing in progress.
- [ ] Complete a real phone Google-account/emoji pairing and verify reconnect/session persistence.

### API/security

Expand Down Expand Up @@ -100,7 +101,7 @@ This document records what was implemented and what must be proven before/after
- [x] SMS evidence view.
- [x] Outgoing webhook-delivery view.
- [x] Connector health/settings.
- [x] QR rendering/refresh.
- [x] Google-account/emoji pairing UI plus QR fallback rendering/refresh.
- [x] Periodic auth refresh and 401 sign-out.
- [x] UI create retries preserve idempotency key.

Expand Down Expand Up @@ -163,7 +164,7 @@ Before replacing `main`, use a **new temporary Docker volume** with the final im
13. database survives container stop/removal/recreation on the same volume;
14. Docker health transitions to healthy after recreation.

QR scanning is excluded from this acceptance pass by explicit operator request.
Live Google Messages device pairing is excluded from the generic container acceptance pass and is validated separately with the real phone.

## Production cutover checklist

Expand All @@ -182,7 +183,7 @@ QR scanning is excluded from this acceptance pass by explicit operator request.
- [ ] Generate a new strong `SMS_WEBHOOK_SECRET`.
- [ ] Keep the old `WEBHOOK_SECRET` only if the current Android relay must survive the first cutover.
- [ ] Set `LEGACY_SMS_WEBHOOK_ENABLED=true` only for that transition.
- [ ] Keep Google Messages disabled until real QR testing if not paired yet.
- [x] Enable Google Messages only after production core cutover; live account/emoji pairing remains pending.
- [ ] Confirm no secret is printed into logs/history during the change.

### Branch/cutover
Expand All @@ -204,7 +205,7 @@ Once the new Android endpoint or Google Messages path is confirmed:
- rotate/update the Android relay to `/api/events/sms` with `SMS_WEBHOOK_SECRET` and timestamps/provider IDs;
- set `LEGACY_SMS_WEBHOOK_ENABLED=false`;
- remove the old weak `WEBHOOK_SECRET` from Dokploy;
- pair/test Google Messages with the real phone;
- pair/test Google Messages with the real phone using Google-account/emoji pairing (QR only as fallback);
- measure ingestion/matching latency and missed-event rate before treating libgm as the primary source.

## Definition of v1 done
Expand Down
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ The React UI at `/` provides:
- cancellation;
- SMS evidence records;
- outgoing webhook delivery records;
- Google Messages connector status and QR pairing controls;
- Google Messages connector status, Google-account/emoji pairing and QR fallback controls;
- safe non-secret configuration status.

Operator accounts use the PocketBase `users` auth collection. Domain collections are read-only through PocketBase APIs for authenticated `users`; state-changing payment operations go through custom Go handlers. Direct domain writes are locked.
Expand All @@ -226,11 +226,15 @@ The connector:
- records the Google message ID and original timestamp;
- reconnects/backoffs on connection failures;
- reports paired/connected/phone-responsive state;
- supports QR pairing and automatic QR refresh.
- supports the current Google-account + emoji (Gaia) pairing flow;
- accepts the upstream-required browser cookie set as cookie JSON, a raw Cookie header, or a DevTools Copy-as-cURL request;
- keeps QR pairing as a fallback and automatically refreshes short-lived QR data.

Starting a new pairing is refused while a valid session is already paired; explicitly unpair first.
Google-account pairing is the primary path. Browser cookie input is never logged or echoed; it remains transient until pairing succeeds, after which libgm's AuthData (including the cookies required for account reauthentication) is stored in `pb_data/gmessages/session.json` with restrictive permissions.

**Live phone QR scanning is the one intentionally deferred acceptance test.** The connector code is integrated and unit-tested, but the private Google Messages protocol can change and must be validated with the actual phone before relying on it as the only ingestion source.
Starting a new pairing is refused while another pairing is active or a valid session is already paired; explicitly cancel/unpair first.

**Live phone Google-account/emoji pairing is the remaining connector acceptance test.** The connector code is integrated and unit-tested, but the private Google Messages protocol can change and must be validated with the actual phone before relying on it as the only ingestion source.

## Configuration

Expand Down
2 changes: 1 addition & 1 deletion RESEARCH.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ This is why:
- an old event cannot confirm a payment created later;
- connector health is visible to the operator.

Real p50/p95/p99 latency must be measured with the actual bank/phone/network after live QR pairing.
Real p50/p95/p99 latency must be measured with the actual bank/phone/network after live Google-account/emoji pairing (or QR fallback if required).

## 8. Why browser automation was rejected

Expand Down
31 changes: 31 additions & 0 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
const (
maxPaymentRequestBytes int64 = (1 << 20) + (64 << 10)
maxSMSRequestBytes int64 = 128 << 10
maxGMessagesPairBytes int64 = 128 << 10
)

type API struct {
Expand All @@ -48,6 +49,10 @@ func (a *API) Register(app core.App) {
e.Router.GET("/api/config", a.getConfig)
e.Router.GET("/api/dashboard", a.dashboard)
e.Router.GET("/api/connector/gmessages/status", a.gmessagesStatus)
e.Router.POST("/api/connector/gmessages/pair/google", a.gmessagesGooglePair).Bind(apis.BodyLimit(maxGMessagesPairBytes))
e.Router.POST("/api/connector/gmessages/pair/qr", a.gmessagesPair)
e.Router.POST("/api/connector/gmessages/pair/qr/refresh", a.gmessagesPairRefresh)
// Backward-compatible QR aliases from the first PayGate rebuild.
e.Router.POST("/api/connector/gmessages/pair", a.gmessagesPair)
e.Router.POST("/api/connector/gmessages/pair/refresh", a.gmessagesPairRefresh)
e.Router.POST("/api/connector/gmessages/reconnect", a.gmessagesReconnect)
Expand Down Expand Up @@ -258,6 +263,32 @@ func (a *API) gmessagesStatus(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, a.connectorStatus())
}

type googleMessagesPairBody struct {
CookieData string `json:"cookieData"`
}

func (a *API) gmessagesGooglePair(e *core.RequestEvent) error {
if !a.dashboardAuth(e) {
return e.UnauthorizedError("dashboard authentication is required", nil)
}
if a.GMessages == nil {
return e.BadRequestError("Google Messages connector is unavailable", nil)
}
var body googleMessagesPairBody
if err := decodeJSON(e, &body); err != nil {
return e.BadRequestError("invalid JSON body", err)
}
emoji, accountEmail, err := a.GMessages.BeginGooglePair(strings.TrimSpace(body.CookieData))
if err != nil {
return e.BadRequestError(err.Error(), nil)
}
return e.JSON(http.StatusOK, map[string]any{
"emoji": emoji,
"accountEmail": accountEmail,
"status": a.GMessages.Status(),
})
}

func (a *API) gmessagesPair(e *core.RequestEvent) error {
if !a.dashboardAuth(e) {
return e.UnauthorizedError("dashboard authentication is required", nil)
Expand Down
43 changes: 43 additions & 0 deletions internal/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,20 @@ import (
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"

"github.com/Phloraxx/payment-api/internal/config"
"github.com/Phloraxx/payment-api/internal/gmessages"
"github.com/Phloraxx/payment-api/internal/payments"
"github.com/Phloraxx/payment-api/internal/sms"
_ "github.com/Phloraxx/payment-api/migrations"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"github.com/rs/zerolog"
)

func apiTestFactory(t testing.TB, before func(*tests.TestApp, *payments.Service)) *tests.TestApp {
Expand Down Expand Up @@ -44,6 +47,24 @@ func apiTestFactoryWithConfig(t testing.TB, configure func(*config.Config), befo
return app
}

func apiTestFactoryWithGMessages(t testing.TB) *tests.TestApp {
app, err := tests.NewTestApp()
if err != nil {
t.Fatalf("create PocketBase test app: %v", err)
}
cfg := config.Config{
UPIID: "operator@bank", UPIPayeeName: "PayGate",
APIKey: "api-secret", SMSWebhookSecret: "sms-secret",
PaymentTTL: 5 * time.Minute, AmountQuarantine: 24 * time.Hour,
GMessagesEnabled: true, GMessagesSessionPath: filepath.Join(t.TempDir(), "session.json"),
}
paymentService := payments.NewService(app, cfg, nil)
smsService := sms.NewService(app, paymentService)
manager := gmessages.NewManager(cfg, zerolog.Nop(), nil)
New(cfg, paymentService, smsService, manager).Register(app)
return app
}

func TestPaymentAPIAuthenticationAndAmountValidation(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Expand Down Expand Up @@ -174,6 +195,28 @@ func TestPublicPaymentStatusRedactsSensitiveEvidence(t *testing.T) {
scenario.Test(t)
}

func TestGoogleMessagesAccountPairEndpointValidation(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "Google pair requires dashboard auth", Method: http.MethodPost, URL: "/api/connector/gmessages/pair/google",
Body: strings.NewReader(`{"cookieData":"SID=missing-rest"}`),
TestAppFactory: func(t testing.TB) *tests.TestApp { return apiTestFactoryWithGMessages(t) },
ExpectedStatus: http.StatusUnauthorized, ExpectedContent: []string{"Dashboard authentication is required."},
},
{
Name: "payment API key cannot control Google pairing", Method: http.MethodPost, URL: "/api/connector/gmessages/pair/google",
Headers: map[string]string{"Authorization": "Bearer api-secret"},
Body: strings.NewReader(`{"cookieData":"SID=missing-rest"}`),
TestAppFactory: func(t testing.TB) *tests.TestApp { return apiTestFactoryWithGMessages(t) },
ExpectedStatus: http.StatusUnauthorized, ExpectedContent: []string{"Dashboard authentication is required."},
},
}
// API keys intentionally do not grant dashboard-only connector access.
for i := range scenarios {
scenarios[i].Test(t)
}
}

func TestLegacySMSWebhookMatchesPayment(t *testing.T) {
const paymentID = "smstest00000001"
scenario := tests.ApiScenario{
Expand Down
102 changes: 102 additions & 0 deletions internal/gmessages/cookies.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package gmessages

import (
"encoding/json"
"errors"
"fmt"
"regexp"
"sort"
"strings"
)

var requiredGoogleCookies = []string{"SID", "HSID", "OSID", "SSID", "APISID", "SAPISID"}
var allowedGoogleCookies = map[string]struct{}{
"SID": {}, "HSID": {}, "OSID": {}, "SSID": {}, "APISID": {}, "SAPISID": {}, "__Secure-1PSIDTS": {},
}

var (
curlHeaderCookieRE = regexp.MustCompile(`(?is)(?:-H|--header)\s+(?:'([^']*cookie\s*:[^']*)'|"([^"]*cookie\s*:[^"]*)")`)
curlCookieRE = regexp.MustCompile(`(?is)(?:-b|--cookie)\s+(?:'([^']*)'|"([^"]*)")`)
)

func parseGoogleCookieInput(input string) (map[string]string, error) {
input = strings.TrimSpace(input)
if input == "" {
return nil, errors.New("google cookie data is required")
}

var cookies map[string]string
if strings.HasPrefix(input, "{") {
if err := json.Unmarshal([]byte(input), &cookies); err != nil {
return nil, errors.New("cookie JSON is invalid")
}
} else {
header := extractCookieHeader(input)
cookies = parseCookieHeader(header)
}
if err := validateGoogleCookies(cookies); err != nil {
return nil, err
}
filtered := make(map[string]string, len(allowedGoogleCookies))
for name := range allowedGoogleCookies {
if value := cookies[name]; value != "" {
filtered[name] = value
}
}
return filtered, nil
}

func extractCookieHeader(input string) string {
if match := curlHeaderCookieRE.FindStringSubmatch(input); len(match) > 0 {
header := firstNonEmpty(match[1], match[2])
if idx := strings.Index(strings.ToLower(header), "cookie:"); idx >= 0 {
return strings.TrimSpace(header[idx+len("cookie:"):])
}
}
if match := curlCookieRE.FindStringSubmatch(input); len(match) > 0 {
return strings.TrimSpace(firstNonEmpty(match[1], match[2]))
}
if idx := strings.Index(strings.ToLower(input), "cookie:"); idx >= 0 {
line := input[idx+len("cookie:"):]
if end := strings.IndexAny(line, "\r\n"); end >= 0 {
line = line[:end]
}
return strings.Trim(strings.TrimSpace(line), "'\"")
}
return input
}

func parseCookieHeader(header string) map[string]string {
cookies := make(map[string]string)
for _, part := range strings.Split(header, ";") {
name, value, ok := strings.Cut(strings.TrimSpace(part), "=")
if !ok || name == "" {
continue
}
cookies[name] = strings.TrimSpace(value)
}
return cookies
}

func validateGoogleCookies(cookies map[string]string) error {
var missing []string
for _, name := range requiredGoogleCookies {
if strings.TrimSpace(cookies[name]) == "" {
missing = append(missing, name)
}
}
if len(missing) > 0 {
sort.Strings(missing)
return fmt.Errorf("missing required Google cookies: %s", strings.Join(missing, ", "))
}
return nil
}

func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
Loading