Skip to content

Commit d9576cd

Browse files
authored
Merge pull request #69 from coder13/codex/notifycomp-remote
[codex] add notifycomp remote controls
2 parents 7f3571e + d5a4d12 commit d9576cd

55 files changed

Lines changed: 3788 additions & 68 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,4 @@ yarn-error.log*
2727
storybook-static
2828

2929
.cache
30+
.netlify

netlify/functions/notify-comp-token.js

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,24 @@ const headers = {
88
};
99

1010
const base64Url = (value) => Buffer.from(value).toString('base64url');
11+
const REMOTE_SCOPE = 'notifycomp.remote';
12+
const PUSH_SCOPE = 'assignment_notifications';
13+
const REMOTE_TOKEN_TTL_SECONDS = 12 * 60 * 60;
14+
const PUSH_TOKEN_TTL_SECONDS = 10 * 60;
15+
16+
const getCompetitionManagers = (competition) => {
17+
const data = competition?.competition || competition;
18+
return [...(data?.organizers || []), ...(data?.delegates || [])];
19+
};
20+
21+
const isListedCompetitionManager = (competition, userId) =>
22+
getCompetitionManagers(competition).some((user) => Number(user?.id) === Number(userId));
23+
24+
const getManagedCompetitionIds = (competitions, userId) =>
25+
competitions
26+
.filter((competition) => isListedCompetitionManager(competition, userId))
27+
.map((competition) => competition.id)
28+
.filter(Boolean);
1129

1230
const signJwt = (claims, secret) => {
1331
const encodedHeader = base64Url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
@@ -42,7 +60,19 @@ exports.handler = async (event) => {
4260
};
4361
}
4462

45-
const { accessToken } = JSON.parse(event.body || '{}');
63+
let body;
64+
try {
65+
body = JSON.parse(event.body || '{}');
66+
} catch {
67+
return {
68+
statusCode: 400,
69+
headers,
70+
body: JSON.stringify({ message: 'Invalid JSON body' }),
71+
};
72+
}
73+
74+
const { accessToken, competitionId, scope } = body;
75+
const tokenScope = scope === REMOTE_SCOPE ? REMOTE_SCOPE : PUSH_SCOPE;
4676
if (!accessToken) {
4777
return {
4878
statusCode: 400,
@@ -52,7 +82,9 @@ exports.handler = async (event) => {
5282
}
5383

5484
const wcaOrigin = process.env.WCA_ORIGIN || 'https://www.worldcubeassociation.org';
55-
const meResponse = await fetch(`${wcaOrigin}/api/v0/me`, {
85+
const meParams =
86+
tokenScope === REMOTE_SCOPE ? '?upcoming_competitions=true&ongoing_competitions=true' : '';
87+
const meResponse = await fetch(`${wcaOrigin}/api/v0/me${meParams}`, {
5688
headers: {
5789
Authorization: `Bearer ${accessToken}`,
5890
},
@@ -66,7 +98,7 @@ exports.handler = async (event) => {
6698
};
6799
}
68100

69-
const { me } = await meResponse.json();
101+
const { me, ongoing_competitions = [], upcoming_competitions = [] } = await meResponse.json();
70102
if (!me?.id) {
71103
return {
72104
statusCode: 401,
@@ -75,14 +107,68 @@ exports.handler = async (event) => {
75107
};
76108
}
77109

110+
const tokenTtlSeconds =
111+
tokenScope === REMOTE_SCOPE ? REMOTE_TOKEN_TTL_SECONDS : PUSH_TOKEN_TTL_SECONDS;
112+
if (tokenScope === REMOTE_SCOPE && !competitionId) {
113+
return {
114+
statusCode: 400,
115+
headers,
116+
body: JSON.stringify({ message: 'Missing competition ID for remote token' }),
117+
};
118+
}
119+
120+
let remoteCompetitionIds = [];
121+
122+
if (tokenScope === REMOTE_SCOPE) {
123+
const competitionResponse = await fetch(`${wcaOrigin}/api/v0/competitions/${competitionId}`, {
124+
headers: {
125+
Authorization: `Bearer ${accessToken}`,
126+
},
127+
});
128+
129+
if (!competitionResponse.ok) {
130+
return {
131+
statusCode: competitionResponse.status === 404 ? 404 : 502,
132+
headers,
133+
body: JSON.stringify({ message: 'Unable to verify competition remote access' }),
134+
};
135+
}
136+
137+
const competition = await competitionResponse.json();
138+
if (!isListedCompetitionManager(competition, me.id)) {
139+
return {
140+
statusCode: 403,
141+
headers,
142+
body: JSON.stringify({
143+
message:
144+
'Only listed competition delegates and organizers can use Live Activities Remote',
145+
}),
146+
};
147+
}
148+
149+
// Remote tokens are scoped to competitions the WCA API says this user manages. The
150+
// NotifyComp API must reject remote mutations for competition IDs outside this claim.
151+
remoteCompetitionIds = [
152+
...new Set([
153+
competitionId,
154+
...getManagedCompetitionIds([...ongoing_competitions, ...upcoming_competitions], me.id),
155+
]),
156+
];
157+
}
158+
78159
const now = Math.floor(Date.now() / 1000);
79160
const token = signJwt(
80161
{
81162
aud: process.env.COMPETITION_GROUPS_JWT_AUDIENCE || 'notifycomp',
82-
exp: now + 10 * 60,
163+
competitionIds: tokenScope === REMOTE_SCOPE ? remoteCompetitionIds : undefined,
164+
exp: now + tokenTtlSeconds,
83165
iat: now,
84166
iss: process.env.COMPETITION_GROUPS_JWT_ISSUER || 'competitiongroups.com',
167+
name: me.name,
168+
scope: tokenScope,
169+
scopes: [tokenScope],
85170
sub: `wca:${me.id}`,
171+
wcaUserId: me.id,
86172
wcaUserIds: [me.id],
87173
},
88174
secret,

src/App.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import CompetitionLive from './pages/Competition/Live';
1818
import CompetitionPerson from './pages/Competition/Person';
1919
import CompetitionPersonalBests from './pages/Competition/Person/PersonalBests';
2020
import { PsychSheetEvent } from './pages/Competition/PsychSheet/PsychSheetEvent';
21+
import CompetitionRemote from './pages/Competition/Remote';
2122
import CompetitionResults from './pages/Competition/Results';
2223
import {
2324
CompetitionActivity,
@@ -30,12 +31,15 @@ import CompetitionStats from './pages/Competition/Stats';
3031
import CompetitionStreamSchedule from './pages/Competition/StreamSchedule';
3132
import CompetitionSumOfRanks from './pages/Competition/SumOfRanks';
3233
import Home from './pages/Home';
34+
import LiveActivitiesAbout from './pages/LiveActivities/About';
3335
import Settings from './pages/Settings';
3436
import Support from './pages/Support';
3537
import Test from './pages/Test';
3638
import UserLogin from './pages/UserLogin';
3739
import { AppProvider } from './providers/AppProvider';
3840
import { AuthProvider, useAuth } from './providers/AuthProvider';
41+
import { ConfirmProvider } from './providers/ConfirmProvider';
42+
import { NotifyCompRemoteAuthProvider } from './providers/NotifyCompRemoteAuthProvider';
3943
import { QueryProvider } from './providers/QueryProvider/QueryProvider';
4044
import { UserSettingsProvider } from './providers/UserSettingsProvider';
4145
import { useWCIF } from './providers/WCIFProvider';
@@ -108,6 +112,7 @@ const Navigation = () => {
108112

109113
<Route path="psych-sheet" element={<PsychSheet />} />
110114
<Route path="psych-sheet/:eventId" element={<PsychSheetEvent />} />
115+
<Route path="remote" element={<CompetitionRemote />} />
111116
<Route path="results" element={<CompetitionResults />} />
112117
<Route path="results/:roundId" element={<CompetitionResults />} />
113118

@@ -126,6 +131,7 @@ const Navigation = () => {
126131
</Route>
127132
<Route path="/users/:userId" element={<UserLogin />} />
128133
<Route path="about" element={<About />} />
134+
<Route path="live-activities" element={<LiveActivitiesAbout />} />
129135
<Route path="settings" element={<Settings />} />
130136
<Route path="support" element={<Support />} />
131137
</Route>
@@ -141,9 +147,13 @@ const App = () => (
141147
<QueryProvider>
142148
<ApolloProvider client={client}>
143149
<BrowserRouter>
144-
<AuthProvider>
145-
<Navigation />
146-
</AuthProvider>
150+
<ConfirmProvider>
151+
<AuthProvider>
152+
<NotifyCompRemoteAuthProvider>
153+
<Navigation />
154+
</NotifyCompRemoteAuthProvider>
155+
</AuthProvider>
156+
</ConfirmProvider>
147157
</BrowserRouter>
148158
</ApolloProvider>
149159
</QueryProvider>

src/apolloClient.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,58 @@
11
import { ApolloClient, createHttpLink, InMemoryCache, split } from '@apollo/client';
2+
import { setContext } from '@apollo/client/link/context';
23
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
34
import { getMainDefinition } from '@apollo/client/utilities';
45
import { createClient } from 'graphql-ws';
6+
import { getNotifyCompRemoteToken } from './lib/notifyCompRemoteAuth';
7+
import { setNotifyCompWebSocketStatus } from './lib/notifyCompWebSocketStatus';
8+
import { NOTIFYCOMP_GRAPHQL_ORIGIN, NOTIFYCOMP_WS_ORIGIN } from './lib/remoteConfig';
59

610
const httpLink = createHttpLink({
7-
uri: import.meta.env.VITE_NOTIFYCOMP_API_ORIGIN || 'https://api.notifycomp.com/api/graphql',
11+
uri: NOTIFYCOMP_GRAPHQL_ORIGIN,
812
});
913

1014
const wsLink = new GraphQLWsLink(
1115
createClient({
12-
url: import.meta.env.VITE_NOTIFYCOMP_WS_ORIGIN || 'wss://api.notifycomp.com/api/graphql',
16+
url: NOTIFYCOMP_WS_ORIGIN,
17+
on: {
18+
connecting: () => {
19+
setNotifyCompWebSocketStatus({ status: 'connecting' });
20+
},
21+
connected: () => {
22+
setNotifyCompWebSocketStatus({ status: 'connected' });
23+
},
24+
closed: () => {
25+
setNotifyCompWebSocketStatus({ status: 'disconnected' });
26+
},
27+
error: () => {
28+
setNotifyCompWebSocketStatus({
29+
status: 'disconnected',
30+
message:
31+
'Unable to connect to NotifyComp live updates. Activity changes may not update automatically.',
32+
});
33+
},
34+
},
1335
}),
1436
);
1537

38+
const authLink = setContext((_, { headers }) => {
39+
const token = getNotifyCompRemoteToken();
40+
41+
return {
42+
headers: {
43+
...headers,
44+
...(token && { authorization: `Bearer ${token}` }),
45+
},
46+
};
47+
});
48+
1649
const splitLink = split(
1750
({ query }) => {
1851
const definition = getMainDefinition(query);
1952
return definition.kind === 'OperationDefinition' && definition.operation === 'subscription';
2053
},
2154
wsLink,
22-
httpLink,
55+
authLink.concat(httpLink),
2356
);
2457

2558
const client = new ApolloClient({
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import classNames from 'classnames';
2+
import { NoteBox } from '@/components/Notebox';
3+
import { useNotifyCompWebSocketStatus } from '@/hooks/useNotifyCompWebSocketStatus';
4+
5+
interface NotifyCompConnectionStatusProps {
6+
className?: string;
7+
compact?: boolean;
8+
}
9+
10+
const statusText = {
11+
connecting: 'Connecting to NotifyComp live updates...',
12+
disconnected:
13+
'Not connected to NotifyComp live updates. Activity changes may not update automatically.',
14+
idle: '',
15+
connected: '',
16+
};
17+
18+
export function NotifyCompConnectionStatus({
19+
className,
20+
compact = false,
21+
}: NotifyCompConnectionStatusProps) {
22+
const { message, status } = useNotifyCompWebSocketStatus();
23+
24+
if (status === 'idle' || status === 'connected') {
25+
return null;
26+
}
27+
28+
const text = message || statusText[status];
29+
const toneClassName =
30+
status === 'connecting' ? 'bg-yellow-400 dark:bg-yellow-300' : 'bg-red-500 dark:bg-red-400';
31+
32+
if (compact) {
33+
return (
34+
<div
35+
className={classNames(
36+
'flex items-center justify-center gap-1 text-xs font-medium text-muted',
37+
className,
38+
)}
39+
title={text}>
40+
<span className={classNames('h-2 w-2 rounded-full', toneClassName)} aria-hidden="true" />
41+
<span>{status === 'connecting' ? 'Connecting live updates' : 'Live updates offline'}</span>
42+
</div>
43+
);
44+
}
45+
46+
return <NoteBox className={className} prefix="Live updates" text={text} />;
47+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './NotifyCompConnectionStatus';

0 commit comments

Comments
 (0)