Skip to content

Commit 434ceba

Browse files
committed
Add durable NotifyComp push sessions
1 parent 4fce6fb commit 434ceba

5 files changed

Lines changed: 278 additions & 31 deletions

File tree

src/containers/MyCompetitions/MyCompetitions.query.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,18 @@ export const useMyCompetitionsQuery = (userId?: number, options: { enabled?: boo
2525
return undefined;
2626
}
2727

28-
const upcoming_competitions = JSON.parse(
29-
getLocalStorage('my.upcoming_competitions') || '[]',
30-
) as ApiCompetition[];
31-
const ongoing_competitions = JSON.parse(
32-
getLocalStorage('my.ongoing_competitions') || '[]',
33-
) as ApiCompetition[];
28+
const rawUpcomingCompetitions = getLocalStorage('my.upcoming_competitions');
29+
const rawOngoingCompetitions = getLocalStorage('my.ongoing_competitions');
30+
if (!rawUpcomingCompetitions && !rawOngoingCompetitions) {
31+
return undefined;
32+
}
33+
34+
const upcoming_competitions = JSON.parse(rawUpcomingCompetitions || '[]') as ApiCompetition[];
35+
const ongoing_competitions = JSON.parse(rawOngoingCompetitions || '[]') as ApiCompetition[];
36+
37+
if (!upcoming_competitions.length && !ongoing_competitions.length) {
38+
return undefined;
39+
}
3440

3541
return { user: user, upcoming_competitions, ongoing_competitions };
3642
},

src/lib/notifications/assignmentNotifications.ts

Lines changed: 162 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { deleteLocalStorage, getLocalStorage, setLocalStorage } from '@/lib/localStorage';
22
import { getStoredWcaAccessToken } from '@/lib/wcaAccessToken';
3+
import {
4+
clearNotifyCompPushSessionToken,
5+
getNotifyCompPushSessionToken,
6+
setNotifyCompPushSessionToken,
7+
} from './notifyCompPushSession';
38

49
const NOTIFY_COMP_ORIGIN =
510
import.meta.env.VITE_NOTIFY_COMP_ORIGIN ?? 'https://api.notifycomp.com/api';
@@ -15,6 +20,17 @@ interface PushSubscriptionJson {
1520
};
1621
}
1722

23+
interface PushSessionResponse {
24+
sessionToken?: string;
25+
token?: string;
26+
}
27+
28+
interface PushSubscriptionPayload {
29+
endpoint: string;
30+
p256dh: string;
31+
auth: string;
32+
}
33+
1834
export interface AssignmentNotificationWatch {
1935
competitionId: string;
2036
wcaUserId: number;
@@ -25,7 +41,7 @@ export type AssignmentNotificationStatus = NotificationPermission | 'reauthorize
2541
const notifyCompUrl = (path: string) => `${NOTIFY_COMP_ORIGIN}${path}`;
2642

2743
export const isAssignmentNotificationsEnabled = () =>
28-
getLocalStorage(ENABLED_STORAGE_KEY) === 'true';
44+
Boolean(getNotifyCompPushSessionToken()) || getLocalStorage(ENABLED_STORAGE_KEY) === 'true';
2945

3046
const toUint8Array = (base64: string) => {
3147
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
@@ -49,7 +65,7 @@ export const getAssignmentNotificationStatus = (): AssignmentNotificationStatus
4965
return 'unsupported';
5066
}
5167

52-
if (!getStoredWcaAccessToken()) {
68+
if (!getNotifyCompPushSessionToken() && !getStoredWcaAccessToken()) {
5369
return 'reauthorize';
5470
}
5571

@@ -105,6 +121,9 @@ const fetchNotifyCompToken = async () => {
105121
return payload.token;
106122
};
107123

124+
const canFallbackToLegacySubscriptions = (response: Response) =>
125+
response.status === 404 || response.status === 405;
126+
108127
const fetchVapidPublicKey = async () => {
109128
const response = await fetch(notifyCompUrl('/v0/external/push/vapid-public-key'));
110129

@@ -153,30 +172,33 @@ const getPushSubscription = async () => {
153172
});
154173
};
155174

156-
export const enableAssignmentNotifications = async (watches: AssignmentNotificationWatch[]) => {
157-
const permission = await requestAssignmentNotificationPermission();
158-
if (permission !== 'granted') {
159-
return permission;
160-
}
161-
162-
const token = await fetchNotifyCompToken();
163-
const subscription = await getPushSubscription();
175+
const pushSubscriptionPayload = (subscription: PushSubscription): PushSubscriptionPayload => {
164176
const payload = subscription.toJSON() as PushSubscriptionJson;
165177

166178
if (!payload.endpoint || !payload.keys?.p256dh || !payload.keys.auth) {
167179
throw new Error('Browser push subscription is missing required keys.');
168180
}
169181

182+
return {
183+
auth: payload.keys.auth,
184+
endpoint: payload.endpoint,
185+
p256dh: payload.keys.p256dh,
186+
};
187+
};
188+
189+
const registerLegacySubscription = async (
190+
authToken: string,
191+
payload: PushSubscriptionPayload,
192+
watches: AssignmentNotificationWatch[],
193+
) => {
170194
const response = await fetch(notifyCompUrl('/v0/external/push/subscriptions'), {
171195
method: 'POST',
172196
headers: {
173-
Authorization: `Bearer ${token}`,
197+
Authorization: `Bearer ${authToken}`,
174198
'Content-Type': 'application/json',
175199
},
176200
body: JSON.stringify({
177-
endpoint: payload.endpoint,
178-
p256dh: payload.keys.p256dh,
179-
auth: payload.keys.auth,
201+
...payload,
180202
watches,
181203
}),
182204
});
@@ -185,34 +207,150 @@ export const enableAssignmentNotifications = async (watches: AssignmentNotificat
185207
deleteLocalStorage(ENABLED_STORAGE_KEY);
186208
throw new Error(await readErrorMessage(response));
187209
}
210+
};
211+
212+
const createPushSession = async (
213+
authToken: string,
214+
payload: PushSubscriptionPayload,
215+
watches: AssignmentNotificationWatch[],
216+
) => {
217+
const response = await fetch(notifyCompUrl('/v0/external/push/sessions'), {
218+
method: 'POST',
219+
headers: {
220+
Authorization: `Bearer ${authToken}`,
221+
'Content-Type': 'application/json',
222+
},
223+
body: JSON.stringify({
224+
...payload,
225+
watches,
226+
}),
227+
});
228+
229+
if (canFallbackToLegacySubscriptions(response)) {
230+
await registerLegacySubscription(authToken, payload, watches);
231+
return;
232+
}
233+
234+
if (!response.ok) {
235+
clearNotifyCompPushSessionToken();
236+
deleteLocalStorage(ENABLED_STORAGE_KEY);
237+
throw new Error(await readErrorMessage(response));
238+
}
239+
240+
const session = (await response.json()) as PushSessionResponse;
241+
const sessionToken = session.sessionToken || session.token;
242+
if (!sessionToken) {
243+
clearNotifyCompPushSessionToken();
244+
deleteLocalStorage(ENABLED_STORAGE_KEY);
245+
throw new Error('NotifyComp push session response was missing a session token.');
246+
}
247+
248+
setNotifyCompPushSessionToken(sessionToken);
249+
};
250+
251+
const updatePushSession = async (
252+
sessionToken: string,
253+
payload: PushSubscriptionPayload,
254+
watches: AssignmentNotificationWatch[],
255+
) => {
256+
const response = await fetch(notifyCompUrl('/v0/external/push/sessions/current'), {
257+
method: 'PUT',
258+
headers: {
259+
Authorization: `Bearer ${sessionToken}`,
260+
'Content-Type': 'application/json',
261+
},
262+
body: JSON.stringify({
263+
...payload,
264+
watches,
265+
}),
266+
});
267+
268+
if (response.status === 401 || response.status === 403) {
269+
clearNotifyCompPushSessionToken();
270+
return false;
271+
}
272+
273+
if (!response.ok) {
274+
throw new Error(await readErrorMessage(response));
275+
}
276+
277+
return true;
278+
};
279+
280+
export const enableAssignmentNotifications = async (watches: AssignmentNotificationWatch[]) => {
281+
const permission = await requestAssignmentNotificationPermission();
282+
if (permission !== 'granted') {
283+
return permission;
284+
}
285+
286+
const subscription = await getPushSubscription();
287+
const payload = pushSubscriptionPayload(subscription);
288+
const sessionToken = getNotifyCompPushSessionToken();
289+
290+
if (sessionToken && (await updatePushSession(sessionToken, payload, watches))) {
291+
setLocalStorage(ENABLED_STORAGE_KEY, 'true');
292+
return permission;
293+
}
294+
295+
await createPushSession(await fetchNotifyCompToken(), payload, watches);
188296

189297
setLocalStorage(ENABLED_STORAGE_KEY, 'true');
190298
return permission;
191299
};
192300

301+
const deletePushSession = async (sessionToken: string, endpoint?: string) => {
302+
const response = await fetch(notifyCompUrl('/v0/external/push/sessions/current'), {
303+
method: 'DELETE',
304+
headers: {
305+
Authorization: `Bearer ${sessionToken}`,
306+
'Content-Type': 'application/json',
307+
},
308+
body: JSON.stringify({ endpoint }),
309+
});
310+
311+
if (
312+
!response.ok &&
313+
response.status !== 401 &&
314+
response.status !== 403 &&
315+
response.status !== 404
316+
) {
317+
throw new Error(await readErrorMessage(response));
318+
}
319+
};
320+
321+
const deleteLegacySubscription = async (endpoint: string) => {
322+
const token = await fetchNotifyCompToken();
323+
324+
await fetch(notifyCompUrl('/v0/external/push/subscriptions'), {
325+
method: 'DELETE',
326+
headers: {
327+
Authorization: `Bearer ${token}`,
328+
'Content-Type': 'application/json',
329+
},
330+
body: JSON.stringify({ endpoint }),
331+
});
332+
};
333+
193334
export const disableAssignmentNotifications = async () => {
194335
const registration = await getServiceWorkerRegistration();
195336
const subscription = await registration.pushManager.getSubscription();
337+
const sessionToken = getNotifyCompPushSessionToken();
196338

197339
if (!subscription) {
340+
clearNotifyCompPushSessionToken();
198341
deleteLocalStorage(ENABLED_STORAGE_KEY);
199342
return;
200343
}
201344

202-
const token = await fetchNotifyCompToken();
203345
const payload = subscription.toJSON() as PushSubscriptionJson;
204346

205-
if (payload.endpoint) {
206-
await fetch(notifyCompUrl('/v0/external/push/subscriptions'), {
207-
method: 'DELETE',
208-
headers: {
209-
Authorization: `Bearer ${token}`,
210-
'Content-Type': 'application/json',
211-
},
212-
body: JSON.stringify({ endpoint: payload.endpoint }),
213-
});
347+
if (sessionToken) {
348+
await deletePushSession(sessionToken, payload.endpoint);
349+
} else if (payload.endpoint) {
350+
await deleteLegacySubscription(payload.endpoint);
214351
}
215352

216353
await subscription.unsubscribe();
354+
clearNotifyCompPushSessionToken();
217355
deleteLocalStorage(ENABLED_STORAGE_KEY);
218356
};
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import {
2+
clearNotifyCompPushSessionToken,
3+
getNotifyCompPushSessionToken,
4+
setNotifyCompPushSessionToken,
5+
} from './notifyCompPushSession';
6+
7+
jest.mock('@/lib/localStorage', () => ({
8+
deleteLocalStorage: (key: string) => localStorage.removeItem(key),
9+
getLocalStorage: (key: string) => localStorage.getItem(key),
10+
setLocalStorage: (key: string, value: string) => localStorage.setItem(key, value),
11+
}));
12+
13+
const base64Url = (value: unknown) =>
14+
Buffer.from(JSON.stringify(value)).toString('base64url').replace(/=/g, '');
15+
16+
const jwtWithClaims = (claims: Record<string, unknown>) =>
17+
`${base64Url({ alg: 'HS256', typ: 'JWT' })}.${base64Url(claims)}.signature`;
18+
19+
describe('notifyCompPushSession', () => {
20+
beforeEach(() => {
21+
localStorage.clear();
22+
});
23+
24+
afterEach(() => {
25+
clearNotifyCompPushSessionToken();
26+
});
27+
28+
it('stores opaque NotifyComp push session tokens', () => {
29+
setNotifyCompPushSessionToken('opaque-session-token');
30+
31+
expect(getNotifyCompPushSessionToken()).toBe('opaque-session-token');
32+
});
33+
34+
it('reads unpadded JWT push session tokens', () => {
35+
const token = jwtWithClaims({
36+
exp: Math.floor(Date.now() / 1000) + 60,
37+
});
38+
39+
setNotifyCompPushSessionToken(token);
40+
41+
expect(getNotifyCompPushSessionToken()).toBe(token);
42+
});
43+
44+
it('clears expired JWT push session tokens', () => {
45+
setNotifyCompPushSessionToken(
46+
jwtWithClaims({
47+
exp: Math.floor(Date.now() / 1000) - 60,
48+
}),
49+
);
50+
51+
expect(getNotifyCompPushSessionToken()).toBe(null);
52+
});
53+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { deleteLocalStorage, getLocalStorage, setLocalStorage } from '@/lib/localStorage';
2+
3+
const PUSH_SESSION_TOKEN_KEY = 'notifyComp.pushSessionToken';
4+
5+
interface JwtClaims {
6+
exp?: number;
7+
}
8+
9+
const decodeJwtPayload = (token: string): JwtClaims | null => {
10+
try {
11+
const payload = token.split('.')[1];
12+
if (!payload) {
13+
return null;
14+
}
15+
16+
const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
17+
const normalized = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=');
18+
return JSON.parse(window.atob(normalized)) as JwtClaims;
19+
} catch {
20+
return null;
21+
}
22+
};
23+
24+
export const getNotifyCompPushSessionToken = () => {
25+
const token = getLocalStorage(PUSH_SESSION_TOKEN_KEY);
26+
if (!token) {
27+
return null;
28+
}
29+
30+
const claims = decodeJwtPayload(token);
31+
if (claims?.exp && claims.exp * 1000 <= Date.now()) {
32+
deleteLocalStorage(PUSH_SESSION_TOKEN_KEY);
33+
return null;
34+
}
35+
36+
return token;
37+
};
38+
39+
export const setNotifyCompPushSessionToken = (token: string) => {
40+
setLocalStorage(PUSH_SESSION_TOKEN_KEY, token);
41+
};
42+
43+
export const clearNotifyCompPushSessionToken = () => {
44+
deleteLocalStorage(PUSH_SESSION_TOKEN_KEY);
45+
};

0 commit comments

Comments
 (0)