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 thelast_seen_atheartbeat. 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 byfilter_parametersinapi_json.logand 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):
- apnotic gem — !248021 (merged)
- Device push subscription registry — !248022 (merged)
- → this MR: registration REST API
- 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" # => 204Database
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 1Execution 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 ms2. 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" = 100Execution 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 ms3a. 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 ms3b. 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" = 200124Execution 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 ms3c. 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" = 200124A 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 msThe 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.