Skip to content

push notifications - #230

Closed
encryptedDegen wants to merge 12 commits into
devfrom
push-notifs
Closed

push notifications#230
encryptedDegen wants to merge 12 commits into
devfrom
push-notifs

Conversation

@encryptedDegen

@encryptedDegen encryptedDegen commented Jun 21, 2026

Copy link
Copy Markdown

Summary

  • Add push subscription storage and authenticated subscription CRUD endpoints for browser Web Push.
  • Add VAPID config/env support and public VAPID key endpoint for frontend subscription setup.
  • Extend notification delivery to fan out Web Push after canonical notification creation, clean stale subscriptions, and publish ownership-loss cancellation notifications.

@greptile-apps

greptile-apps Bot commented Jun 21, 2026

Copy link
Copy Markdown

Greptile Summary

This PR introduces browser Web Push notification delivery: a new push_subscriptions table, authenticated CRUD endpoints for managing subscriptions, VAPID key configuration, and fan-out from the notification worker after each canonical notification is logged. The ownership worker is extended to publish listing-cancelled jobs when an ENS name transfer unfunds active listings.

  • services/api/src/routes/push.ts (new, 431 lines): VAPID public-key endpoint + three authenticated subscription handlers with SSRF-mitigating DNS validation; the ON CONFLICT (endpoint) DO UPDATE SET user_id = EXCLUDED.user_id clause can silently transfer a subscription from one user to another.
  • services/workers/src/workers/notifications.ts: Adds ~170 lines of push fan-out (subscription lookup → sendNotification → stale-subscription cleanup); VAPID global state is re-initialized on every notification delivery rather than once at startup.
  • services/workers/src/workers/ownership.ts: Builds and dispatches listing-cancelled notification jobs after the ownership-update transaction commits, scoped to registered users via an address-to-user-id lookup.

Confidence Score: 3/5

The push subscription upsert allows any authenticated user to claim another user's endpoint, redirecting that user's future push notifications. This needs to be resolved before the feature goes live.

The subscription upsert unconditionally overwrites the owner of any endpoint on conflict without checking whether the endpoint already belongs to a different user. A malicious or mistaken client could silently redirect another user's push notifications to their own account. The DNS validation gap is a secondary concern. The rest of the changes — migration, config, ownership worker extension — are straightforward and low-risk.

services/api/src/routes/push.ts deserves the most scrutiny; services/workers/src/workers/notifications.ts should have its VAPID initialization path reviewed.

Security Review

  • Cross-user subscription hijacking (services/api/src/routes/push.ts L283-294): The ON CONFLICT (endpoint) DO UPDATE SET user_id = EXCLUDED.user_id clause allows any authenticated user who knows another user's push endpoint URL to silently take ownership of it. The pre-flight check only confirms whether the requesting user already owns the endpoint, not whether anyone does; a successful upsert redirects the victim's future push notifications to the attacker's account.
  • DNS rebinding / SSRF gap (services/api/src/routes/push.ts L135): The DNS hostname resolution occurs only at registration time. An endpoint whose DNS later changes to an internal range will receive outbound HTTPS requests from the worker without re-validation.

Important Files Changed

Filename Overview
services/api/src/routes/push.ts New 431-line route file adding VAPID key endpoint + 3 authenticated subscription CRUD handlers; contains an upsert that silently transfers push subscription ownership across users and a DNS validation gap before storing the endpoint.
services/workers/src/workers/notifications.ts Adds ~170 lines of push-notification fan-out after canonical notification insertion; VAPID global state is re-initialized on every delivery rather than once at startup, and the new-listing push body condition is always true.
services/workers/src/workers/ownership.ts Adds listing-cancelled notification jobs queued after the ownership-update transaction commits; correct transaction boundary and ensName is properly in scope.
services/api/migrations/seq/0894_create_push_subscriptions.sql Clean migration creating push_subscriptions table with correct FK cascade, unique endpoint constraint, partial index on enabled=TRUE, and an updated_at trigger.
services/shared/src/config/index.ts Adds webPush config block; ttlSeconds default is duplicated between rawConfig and the Zod schema, harmless.
services/workers/src/queue.ts Adds listing-cancelled notification type and tightens metadata type from Record<string,any> to Record<string,unknown>; clean change.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Browser
    participant API as API (push.ts)
    participant DB as PostgreSQL
    participant Worker as Notification Worker
    participant PushSvc as Push Service (FCM/Mozilla)

    Browser->>API: GET /api/v1/push/vapid-public-key
    API-->>Browser: publicKey (or 501 if unconfigured)

    Browser->>API: POST /api/v1/users/me/push-subscriptions
    Note over API: validatePushEndpoint (DNS check)
    API->>DB: pg_advisory_xact_lock(userId)
    API->>DB: SELECT existing by user_id + endpoint
    API->>DB: SELECT count for user
    API->>DB: "INSERT ... ON CONFLICT (endpoint) DO UPDATE SET user_id=EXCLUDED.user_id"
    DB-->>API: subscription row
    API-->>Browser: 201/200 subscription

    Note over Worker: On ownership change or listing event
    Worker->>DB: INSERT notification RETURNING id
    Worker->>DB: SELECT push_subscriptions WHERE user_id AND enabled
    loop Each subscription
        Worker->>PushSvc: sendNotification (VAPID-signed)
        alt 404/410
            Worker->>DB: DELETE stale subscription
        end
    end

    Browser->>API: DELETE /api/v1/users/me/push-subscriptions/:id
    API->>DB: DELETE WHERE id AND user_id
    API-->>Browser: 200 / 404
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Browser
    participant API as API (push.ts)
    participant DB as PostgreSQL
    participant Worker as Notification Worker
    participant PushSvc as Push Service (FCM/Mozilla)

    Browser->>API: GET /api/v1/push/vapid-public-key
    API-->>Browser: publicKey (or 501 if unconfigured)

    Browser->>API: POST /api/v1/users/me/push-subscriptions
    Note over API: validatePushEndpoint (DNS check)
    API->>DB: pg_advisory_xact_lock(userId)
    API->>DB: SELECT existing by user_id + endpoint
    API->>DB: SELECT count for user
    API->>DB: "INSERT ... ON CONFLICT (endpoint) DO UPDATE SET user_id=EXCLUDED.user_id"
    DB-->>API: subscription row
    API-->>Browser: 201/200 subscription

    Note over Worker: On ownership change or listing event
    Worker->>DB: INSERT notification RETURNING id
    Worker->>DB: SELECT push_subscriptions WHERE user_id AND enabled
    loop Each subscription
        Worker->>PushSvc: sendNotification (VAPID-signed)
        alt 404/410
            Worker->>DB: DELETE stale subscription
        end
    end

    Browser->>API: DELETE /api/v1/users/me/push-subscriptions/:id
    API->>DB: DELETE WHERE id AND user_id
    API-->>Browser: 200 / 404
Loading

Fix All in Conductor Fix All in Cursor Fix All in Codex Fix All in Claude Code

Reviews (1): Last reviewed commit: "ownership notification jobs" | Re-trigger Greptile

Comment on lines +283 to +294
`INSERT INTO push_subscriptions (
user_id, endpoint, p256dh, auth, expiration_time, device_name, user_agent, enabled, last_seen_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, TRUE, NOW())
ON CONFLICT (endpoint) DO UPDATE SET
user_id = EXCLUDED.user_id,
p256dh = EXCLUDED.p256dh,
auth = EXCLUDED.auth,
expiration_time = EXCLUDED.expiration_time,
device_name = EXCLUDED.device_name,
user_agent = EXCLUDED.user_agent,
enabled = TRUE,
last_seen_at = NOW()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Endpoint upsert silently transfers ownership across users

user_id = EXCLUDED.user_id in the conflict clause overwrites the user_id of any existing subscription, even one that belongs to a different user. The pre-check at L254-257 (WHERE user_id = $1 AND endpoint = $2) only checks whether the current user already owns the endpoint — it returns 0 rows when the endpoint belongs to user A, so the count guard passes and the upsert fires, moving the endpoint from user A to user B. After that, push notifications intended for user A are silently delivered to user B's browser. The intentional device-reuse path (same device, different login) would work correctly if you simply refused to update user_id on conflict: the previous owner's subscription stays in place, and the new user gets a fresh record (or you surface the conflict as a clear signal to revoke the old one first).

Fix in Conductor Fix in Cursor Fix in Codex Fix in Claude Code

Comment on lines +47 to +59
function configureWebPush(): boolean {
if (!config.webPush.enabled || !config.webPush.publicKey || !config.webPush.privateKey) {
return false;
}

webPush.setVapidDetails(
config.webPush.subject,
config.webPush.publicKey,
config.webPush.privateKey
);

return true;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 VAPID global state re-initialized on every notification delivery

configureWebPush() calls webPush.setVapidDetails() inside sendPushNotifications(), which is invoked once per notification job. setVapidDetails mutates module-level state in the web-push library. With teamConcurrency: 2 two worker goroutines can interleave these calls, and while Node's event loop keeps each JS call atomic, the function has no idempotency guard and performs unnecessary I/O-adjacent work on the hot path. The VAPID keys are stable config values — call webPush.setVapidDetails once during registerNotificationWorker startup (or even at module load) and remove configureWebPush from the per-notification path.

Fix in Conductor Fix in Cursor Fix in Codex Fix in Claude Code

Comment on lines +175 to +431
export async function userPushSubscriptionRoutes(fastify: FastifyInstance) {
const pool = getPostgresPool();

fastify.get('/', { preHandler: requireAuth }, async (request, reply) => {
if (!request.user) {
return reply.status(401).send({
success: false,
error: {
code: 'UNAUTHORIZED',
message: 'Not authenticated',
},
meta: {
timestamp: new Date().toISOString(),
},
});
}

try {
const userId = Number(request.user.sub);
const result = await pool.query<PushSubscriptionRow>(
`SELECT id, endpoint, device_name, enabled, last_seen_at, created_at
FROM push_subscriptions
WHERE user_id = $1
ORDER BY last_seen_at DESC`,
[userId]
);

const response: APIResponse<{ subscriptions: ReturnType<typeof serializeSubscription>[] }> = {
success: true,
data: {
subscriptions: result.rows.map(serializeSubscription),
},
meta: {
timestamp: new Date().toISOString(),
version: '1.0.0',
},
};

return reply.send(response);
} catch (error) {
fastify.log.error({ error }, 'Error fetching push subscriptions');
return reply.status(500).send({
success: false,
error: {
code: 'INTERNAL_ERROR',
message: 'Failed to fetch push subscriptions',
},
meta: {
timestamp: new Date().toISOString(),
},
});
}
});

fastify.post('/', { preHandler: requireAuth }, async (request, reply) => {
if (!request.user) {
return reply.status(401).send({
success: false,
error: {
code: 'UNAUTHORIZED',
message: 'Not authenticated',
},
meta: {
timestamp: new Date().toISOString(),
},
});
}

try {
const userId = Number(request.user.sub);
const body = PushSubscriptionBodySchema.parse(request.body);
await validatePushEndpoint(body.endpoint);

const client = await pool.connect();

try {
await client.query('BEGIN');
await client.query('SELECT pg_advisory_xact_lock($1)', [userId]);

const existingResult = await client.query<{ id: number }>(
'SELECT id FROM push_subscriptions WHERE user_id = $1 AND endpoint = $2',
[userId, body.endpoint]
);

if (existingResult.rows.length === 0) {
const countResult = await client.query<{ count: string }>(
'SELECT COUNT(*)::text AS count FROM push_subscriptions WHERE user_id = $1 AND enabled = TRUE',
[userId]
);
const activeCount = Number(countResult.rows[0]?.count || '0');

if (activeCount >= MAX_PUSH_SUBSCRIPTIONS_PER_USER) {
await client.query('ROLLBACK');
return reply.status(409).send({
success: false,
error: {
code: 'SUBSCRIPTION_LIMIT_REACHED',
message: `You can register up to ${MAX_PUSH_SUBSCRIPTIONS_PER_USER} push subscriptions`,
},
meta: {
timestamp: new Date().toISOString(),
},
});
}
}

const userAgent = getUserAgent(request.headers['user-agent']);
const result = await client.query<PushSubscriptionRow>(
`INSERT INTO push_subscriptions (
user_id, endpoint, p256dh, auth, expiration_time, device_name, user_agent, enabled, last_seen_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, TRUE, NOW())
ON CONFLICT (endpoint) DO UPDATE SET
user_id = EXCLUDED.user_id,
p256dh = EXCLUDED.p256dh,
auth = EXCLUDED.auth,
expiration_time = EXCLUDED.expiration_time,
device_name = EXCLUDED.device_name,
user_agent = EXCLUDED.user_agent,
enabled = TRUE,
last_seen_at = NOW()
RETURNING id, endpoint, device_name, enabled, last_seen_at, created_at`,
[
userId,
body.endpoint,
body.keys.p256dh,
body.keys.auth,
parseExpirationTime(body.expirationTime),
body.deviceName || null,
userAgent,
]
);

await client.query('COMMIT');

const response: APIResponse<{ subscription: ReturnType<typeof serializeSubscription> }> = {
success: true,
data: {
subscription: serializeSubscription(result.rows[0]),
},
meta: {
timestamp: new Date().toISOString(),
version: '1.0.0',
},
};

return reply.status(existingResult.rows.length === 0 ? 201 : 200).send(response);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
} catch (error) {
fastify.log.error({ error }, 'Error upserting push subscription');

if (error instanceof z.ZodError) {
return reply.status(400).send({
success: false,
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid push subscription payload',
details: error.errors,
},
meta: {
timestamp: new Date().toISOString(),
},
});
}

return reply.status(500).send({
success: false,
error: {
code: 'INTERNAL_ERROR',
message: 'Failed to save push subscription',
},
meta: {
timestamp: new Date().toISOString(),
},
});
}
});

fastify.delete('/:id', { preHandler: requireAuth }, async (request, reply) => {
if (!request.user) {
return reply.status(401).send({
success: false,
error: {
code: 'UNAUTHORIZED',
message: 'Not authenticated',
},
meta: {
timestamp: new Date().toISOString(),
},
});
}

try {
const userId = Number(request.user.sub);
const { id } = SubscriptionParamsSchema.parse(request.params);
const result = await pool.query<{ id: number }>(
'DELETE FROM push_subscriptions WHERE id = $1 AND user_id = $2 RETURNING id',
[id, userId]
);

if (result.rows.length === 0) {
return reply.status(404).send({
success: false,
error: {
code: 'NOT_FOUND',
message: 'Push subscription not found',
},
meta: {
timestamp: new Date().toISOString(),
},
});
}

const response: APIResponse<null> = {
success: true,
data: null,
meta: {
timestamp: new Date().toISOString(),
version: '1.0.0',
},
};

return reply.send(response);
} catch (error) {
fastify.log.error({ error }, 'Error deleting push subscription');

if (error instanceof z.ZodError) {
return reply.status(400).send({
success: false,
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid push subscription id',
details: error.errors,
},
meta: {
timestamp: new Date().toISOString(),
},
});
}

return reply.status(500).send({
success: false,
error: {
code: 'INTERNAL_ERROR',
message: 'Failed to delete push subscription',
},
meta: {
timestamp: new Date().toISOString(),
},
});
}
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 userPushSubscriptionRoutes is 256 lines; the file is 431 lines

The file contains a single large exported function with three inline route handlers, plus DNS/IP helpers and a schema — no logical boundary separates them. The three handlers (GET /, POST /, DELETE /:id) each repeat the same auth-guard pattern and are long enough to be their own files. Consider splitting into push-validation.ts (DNS/IP helpers, schema), push-subscriptions.ts (route handlers), and the existing push.ts for the unauthenticated VAPID key endpoint.

Context Used: Treat any function over 200 lines as a cry for hel... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Conductor Fix in Cursor Fix in Codex Fix in Claude Code

Comment on lines +132 to +142
return;
}

const resolved = await dns.lookup(hostname, { all: true, verbatim: false });
if (resolved.some((entry) => isBlockedIpAddress(entry.address))) {
throw new z.ZodError([{
code: z.ZodIssueCode.custom,
path: ['endpoint'],
message: 'Push endpoint resolves to a disallowed IP range',
}]);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 security DNS check at registration doesn't guard the actual push delivery (SSRF gap)

validatePushEndpoint resolves the hostname once at registration time. The resolved IP is not stored, so when web-push later makes the outbound HTTPS request, a DNS rebinding attack can swap the IP to an internal address (e.g. 169.254.169.254) after the validation passes. For most real-world push services (FCM, Mozilla) this is low-risk since their domains are stable, but a malicious or compromised endpoint can exploit the window. The closest reliable mitigation is to store the validated endpoint only after sending a test push and receiving a 201 from the push service, confirming the URL is a real push provider.

Fix in Conductor Fix in Cursor Fix in Codex Fix in Claude Code

Comment on lines +63 to +64
case 'new-listing':
return `${ensName} was listed${typeof metadata?.priceWei === 'string' ? ' on Grails' : ''}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 new-listing push body condition is always true for real listing events

typeof metadata?.priceWei === 'string' is always true for new-listing jobs because priceWei is always passed as a string in the job metadata. The ternary never takes the '' branch in practice, making the body always read "was listed on Grails" regardless. Either make it unconditional or check for the presence of a meaningful price threshold.

Suggested change
case 'new-listing':
return `${ensName} was listed${typeof metadata?.priceWei === 'string' ? ' on Grails' : ''}`;
case 'new-listing':
return `${ensName} was listed on Grails`;

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Conductor Fix in Cursor Fix in Codex Fix in Claude Code

@encryptedDegen

encryptedDegen commented Jun 22, 2026

Copy link
Copy Markdown
Author

Opened another PR from a dev branch #231

@encryptedDegen
encryptedDegen deleted the push-notifs branch June 22, 2026 09:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants