Add mobile push subscription REST API

Add mobile push subscription REST API

What does this MR do and why?

Adds the registration endpoints the mobile app uses to enroll a device for push notifications, behind the mobile_push_registration_api feature flag (disabled by default):

  • POST /api/v4/user/push_subscriptions — idempotent upsert. Clients re-register on every app start, which doubles as the APNs token-rotation handler and the last_seen_at heartbeat. A token registered by a different user is reassigned (device changed hands).
  • DELETE /api/v4/user/push_subscriptions — sign-out. The token rides in the request body rather than the URL path so it is masked by filter_parameters in api_json.log and never appears in proxy access logs, consistent with the column being encrypted at rest.

Both routes carry route_setting :authorization metadata per the granular access REST guide, and the generated OpenAPI document (doc/api/openapi/openapi_v3.yaml) is regenerated. The client-facing bundle_id parameter maps to the bundle_identifier column (renamed in part 2).

Includes API documentation (doc/api/mobile_push_subscriptions.md).

Part 3 of 4 in the mobile push notifications stack (stacked on the registry MR; registrations are inert until part 4 dispatches to them):

  1. apnotic gem — !248021 (merged)
  2. Device push subscription registry — !248022 (merged)
  3. → this MR: registration REST API
  4. To-do dispatch worker + APNs delivery — !248026 (merged)

How to test

Feature.enable(:mobile_push_registration_api)
curl --request POST --header "PRIVATE-TOKEN: $TOKEN" \
  --data "device_token=abcdef0123456789abcdef0123456789" \
  --data "apns_environment=sandbox" \
  "$GDK/api/v4/user/push_subscriptions"       # => 201 {"id":1,...}; repeat => same id
curl --request DELETE --header "PRIVATE-TOKEN: $TOKEN" \
  --data "device_token=abcdef0123456789abcdef0123456789" \
  "$GDK/api/v4/user/push_subscriptions"       # => 204

Database

The table (mobile_device_push_subscriptions) is introduced in !248022 (merged) (part 2 of this stack) and does not exist on GitLab.com yet, so there is no production data for postgres.ai to clone. The plans below are EXPLAIN (ANALYZE, BUFFERS) from a local PostgreSQL 17.8 instance seeded with 100,000 rows spread across 20 users (ANALYZE run after seeding), captured by exercising the endpoints in the test environment. db:gitlabcom-database-testing ran on the migration MR.

device_token is deterministically encrypted (encrypts :device_token, deterministic: true), so the token literals below are the ciphertext jsonb the application actually sends.

POST /api/v4/user/push_subscriptions

Notifications::MobileDevicePushSubscriptions::RegisterService executes, in order:

1. Token lookup (find_or_initialize_by(device_token:, apns_environment:)):

SELECT "mobile_device_push_subscriptions".* FROM "mobile_device_push_subscriptions" WHERE "mobile_device_push_subscriptions"."device_token" = '{"p":"G2Yq+R5zBPLrXZ4LigwgEdesvDOtzvjlO+RVB+2OIe7ic0j51QLV8e5/4ZQfDOxDuKowVeDaLmcCR8LSROMRMX/x","h":{"iv":"QvkTAti4wBvK1SOu","at":"A16hSW/GtvenJ80F7HPskQ==","i":"ZGM4Mw=="}}' AND "mobile_device_push_subscriptions"."apns_environment" = 1 LIMIT 1
Execution plan
Limit  (cost=0.54..2.56 rows=1 width=354) (actual time=0.045..0.046 rows=1 loops=1)
  Buffers: shared hit=5
  ->  Index Scan using idx_mobile_push_subscriptions_on_token_and_environment on mobile_device_push_subscriptions  (cost=0.54..2.56 rows=1 width=354) (actual time=0.044..0.045 rows=1 loops=1)
        Index Cond: ((device_token = '{"h": {"i": "ZGM4Mw==", "at": "A16hSW/GtvenJ80F7HPskQ==", "iv": "QvkTAti4wBvK1SOu"}, "p": "G2Yq+R5zBPLrXZ4LigwgEdesvDOtzvjlO+RVB+2OIe7ic0j51QLV8e5/4ZQfDOxDuKowVeDaLmcCR8LSROMRMX/x"}'::jsonb) AND (apns_environment = 1))
        Buffers: shared hit=5
Planning Time: 0.171 ms
Execution Time: 0.072 ms

2. Per-user cap validation (runs on create and on reassignment — new_record? || user_id_changed?; the value it counts is itself bounded by the cap, ≤ 20 rows per user, so the index scan touches at most ~20 entries):

SELECT COUNT(*) FROM "mobile_device_push_subscriptions" WHERE "mobile_device_push_subscriptions"."user_id" = 100
Execution plan
Aggregate  (cost=2.31..2.32 rows=1 width=8) (actual time=0.279..0.279 rows=1 loops=1)
  Buffers: shared hit=3
  ->  Index Only Scan using index_mobile_device_push_subscriptions_on_user_id on mobile_device_push_subscriptions  (cost=0.29..2.31 rows=1 width=0) (actual time=0.274..0.274 rows=0 loops=1)
        Index Cond: (user_id = 100)
        Heap Fetches: 2
        Buffers: shared hit=3
Planning Time: 0.074 ms
Execution Time: 0.302 ms

3a. New registration — INSERT:

INSERT INTO "mobile_device_push_subscriptions" ("user_id", "last_seen_at", "created_at", "updated_at", "apns_environment", "device_token", "bundle_identifier", "device_name", "app_version", "locale") VALUES (100, '2026-08-05 13:49:16.680623', '2026-08-05 13:49:16.716673', '2026-08-05 13:49:16.716673', 1, '{"p":"Ds/J7rQN/tJZCXA9cTpNYu4zbxyDsDz/ZLbuFC+GGYT3EqReU15j+uvSmWLdgr/6yF3HUi80y/lG8fio3IJFsXAk","h":{"iv":"hNls11XIhpBSjKmL","at":"baxxsANmahsks2QMblUhEQ==","i":"ZGM4Mw=="}}', 'com.gitlab.mobile', 'iPhone', '1.0.0', 'en') RETURNING "id", "last_seen_at"
Execution plan
Insert on mobile_device_push_subscriptions  (cost=0.00..0.01 rows=1 width=206) (actual time=0.106..0.107 rows=1 loops=1)
  Buffers: shared hit=9
  ->  Result  (cost=0.00..0.01 rows=1 width=206) (actual time=0.011..0.012 rows=1 loops=1)
        Buffers: shared hit=1
Planning Time: 0.096 ms
Trigger for constraint fk_ce6837f03a: time=0.048 calls=1
Execution Time: 0.180 ms

3b. Repeat registration (heartbeat / attribute refresh) — UPDATE:

UPDATE "mobile_device_push_subscriptions" SET "last_seen_at" = '2026-08-05 13:49:17.365475', "updated_at" = '2026-08-05 13:49:17.377451', "app_version" = '1.0.1' WHERE "mobile_device_push_subscriptions"."id" = 200124
Execution plan
Update on mobile_device_push_subscriptions  (cost=0.29..2.31 rows=0 width=0) (actual time=0.372..0.372 rows=0 loops=1)
  Buffers: shared hit=19
  ->  Index Scan using mobile_device_push_subscriptions_pkey on mobile_device_push_subscriptions  (cost=0.29..2.31 rows=1 width=54) (actual time=0.014..0.015 rows=1 loops=1)
        Index Cond: (id = 200124)
        Buffers: shared hit=3
Planning Time: 0.061 ms
Trigger for constraint fk_ce6837f03a: time=0.055 calls=1
Execution Time: 0.451 ms

3c. Reassignment (token registered by another user) — UPDATE, same primary-key plan as 3b with user_id added to the SET list:

UPDATE "mobile_device_push_subscriptions" SET "user_id" = 101, "last_seen_at" = '2026-08-05 13:49:18.233497', "updated_at" = '2026-08-05 13:49:18.240558' WHERE "mobile_device_push_subscriptions"."id" = 200124

A concurrent registration of the same token races on the unique index; the loser rescues ActiveRecord::RecordNotUnique and retries once, re-running 1 → 3b.

DELETE /api/v4/user/push_subscriptions

A single delete_all; its returned row count doubles as the existence check for the 404, so the delete is the only statement:

DELETE FROM "mobile_device_push_subscriptions" WHERE "mobile_device_push_subscriptions"."user_id" = 101 AND "mobile_device_push_subscriptions"."device_token" = '{"p":"G2Yq+R5zBPLrXZ4LigwgEdesvDOtzvjlO+RVB+2OIe7ic0j51QLV8e5/4ZQfDOxDuKowVeDaLmcCR8LSROMRMX/x","h":{"iv":"QvkTAti4wBvK1SOu","at":"A16hSW/GtvenJ80F7HPskQ==","i":"ZGM4Mw=="}}'
Execution plan
Delete on mobile_device_push_subscriptions  (cost=0.29..2.31 rows=0 width=0) (actual time=0.019..0.020 rows=0 loops=1)
  Buffers: shared hit=4
  ->  Index Scan using index_mobile_device_push_subscriptions_on_user_id on mobile_device_push_subscriptions  (cost=0.29..2.31 rows=1 width=6) (actual time=0.008..0.009 rows=1 loops=1)
        Index Cond: (user_id = 101)
        Filter: (device_token = '{"h": {"i": "ZGM4Mw==", "at": "A16hSW/GtvenJ80F7HPskQ==", "iv": "QvkTAti4wBvK1SOu"}, "p": "G2Yq+R5zBPLrXZ4LigwgEdesvDOtzvjlO+RVB+2OIe7ic0j51QLV8e5/4ZQfDOxDuKowVeDaLmcCR8LSROMRMX/x"}'::jsonb)
        Buffers: shared hit=3
Planning Time: 0.048 ms
Execution Time: 0.032 ms

The planner satisfies the delete from the user_id index with a filter on the token ciphertext; the registration cap bounds a user's rows at 20, so the scan touches at most ~20 index entries.

Feature flag rollout

  • Flag: mobile_push_registration_api, actor: user. Rollout issue: #607602.

🤖 Generated with Claude Code

Edited by Marcel van Remmerden

Merge request reports

Loading