Skip to content

Commit 4ea4f30

Browse files
Merge pull request #189 from CodeForPhilly/feat/roster-signals
feat: roster signals — origin, GitHub reputation, Slack SSO, bounces
2 parents d1ed8ea + 7fd8984 commit 4ea4f30

24 files changed

Lines changed: 1346 additions & 87 deletions

‎apps/api/src/app.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import { previewRoutes } from './routes/preview.js';
6161
import { attachmentRoutes } from './routes/attachments.js';
6262
import { chatRoutes } from './routes/chat.js';
6363
import { moderationRoutes } from './routes/moderation.js';
64+
import { webhookRoutes } from './routes/webhooks.js';
6465
import { samlRoutes } from './routes/saml.js';
6566
import { internalRoutes } from './routes/internal.js';
6667

@@ -203,6 +204,7 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise<FastifyInsta
203204
await fastify.register(attachmentRoutes);
204205
await fastify.register(chatRoutes);
205206
await fastify.register(moderationRoutes);
207+
await fastify.register(webhookRoutes);
206208
await fastify.register(samlRoutes);
207209
await fastify.register(internalRoutes);
208210

‎apps/api/src/auth/github-client.ts‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,20 @@ export interface GitHubUser {
2424
readonly login: string;
2525
readonly name: string | null;
2626
readonly avatar_url?: string;
27+
/** Reputation facts from the same /user response (specs/api/auth.md step 5). */
28+
readonly created_at: string | null;
29+
readonly public_repos: number | null;
30+
readonly followers: number | null;
31+
readonly following: number | null;
32+
readonly type: string | null;
33+
}
34+
35+
export type GitHubProbeStatus = 'ok' | 'gone';
36+
37+
export interface GitHubProbeResult {
38+
readonly status: GitHubProbeStatus;
39+
/** Present when status is `ok`. */
40+
readonly user: GitHubUser | null;
2741
}
2842

2943
export interface GitHubEmail {
@@ -153,14 +167,65 @@ export async function fetchGitHubUser(accessToken: string): Promise<GitHubUser>
153167
if (!body || typeof body.id !== 'number' || typeof body.login !== 'string') {
154168
throw new GitHubApiError('GitHub /user returned unexpected shape', 'github_unreachable');
155169
}
170+
return toGitHubUser(body as Partial<GitHubUser> & { id: number; login: string });
171+
}
172+
173+
function toGitHubUser(body: Partial<GitHubUser> & { id: number; login: string }): GitHubUser {
156174
return {
157175
id: body.id,
158176
login: body.login,
159177
name: typeof body.name === 'string' ? body.name : null,
160178
...(typeof body.avatar_url === 'string' ? { avatar_url: body.avatar_url } : {}),
179+
created_at: typeof body.created_at === 'string' ? body.created_at : null,
180+
public_repos: typeof body.public_repos === 'number' ? body.public_repos : null,
181+
followers: typeof body.followers === 'number' ? body.followers : null,
182+
following: typeof body.following === 'number' ? body.following : null,
183+
type: typeof body.type === 'string' ? body.type : null,
161184
};
162185
}
163186

187+
/**
188+
* Is the linked GitHub account still there? `GET /user/{id}` authenticated
189+
* with the OAuth app's client credentials (5,000 req/h). GitHub answers 404
190+
* once it has deleted or suspended the account; that is the signal. Any other
191+
* non-2xx throws so the caller keeps its previous record rather than
192+
* misreading an outage as a verdict.
193+
*/
194+
export async function probeGitHubUser(
195+
githubUserId: number,
196+
clientId: string,
197+
clientSecret: string,
198+
opts: { readonly timeoutMs?: number } = {},
199+
): Promise<GitHubProbeResult> {
200+
const url = `https://api.github.com/user/${githubUserId}`;
201+
const basic = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
202+
let res: Response;
203+
try {
204+
res = await fetch(url, {
205+
method: 'GET',
206+
headers: {
207+
Authorization: `Basic ${basic}`,
208+
Accept: 'application/vnd.github+json',
209+
'User-Agent': USER_AGENT,
210+
},
211+
signal: AbortSignal.timeout(opts.timeoutMs ?? 4000),
212+
});
213+
} catch (err) {
214+
throw new GitHubApiError(`GitHub API transport error: ${url}`, 'github_unreachable', { cause: err });
215+
}
216+
if (res.status === 404) return { status: 'gone', user: null };
217+
if (!res.ok) {
218+
throw new GitHubApiError(`GitHub API ${url} returned ${res.status}`, 'github_unreachable', {
219+
status: res.status,
220+
});
221+
}
222+
const body = (await res.json().catch(() => null)) as Partial<GitHubUser> | null;
223+
if (!body || typeof body.id !== 'number' || typeof body.login !== 'string') {
224+
throw new GitHubApiError('GitHub /user/{id} returned unexpected shape', 'github_unreachable');
225+
}
226+
return { status: 'ok', user: toGitHubUser(body as Partial<GitHubUser> & { id: number; login: string }) };
227+
}
228+
164229
export async function fetchGitHubEmails(accessToken: string): Promise<GitHubEmail[]> {
165230
const body = await ghGet(EMAILS_URL, accessToken);
166231
if (!Array.isArray(body)) {
@@ -197,6 +262,12 @@ export interface ResolvedGitHubIdentity {
197262
readonly name: string | null;
198263
readonly emails: readonly GitHubEmail[];
199264
readonly primaryEmail: string | null;
265+
/**
266+
* The full /user snapshot, kept so sign-in can record reputation facts.
267+
* Absent when the identity was rebuilt from a claim-pending token rather
268+
* than a live GitHub response.
269+
*/
270+
readonly user?: GitHubUser;
200271
}
201272

202273
export function resolveIdentitySnapshot(
@@ -211,5 +282,20 @@ export function resolveIdentitySnapshot(
211282
name: user.name,
212283
emails: verified,
213284
primaryEmail: primary?.email.toLowerCase() ?? null,
285+
user,
286+
};
287+
}
288+
289+
/** Shape the reputation facts for the private profile (specs/behaviors/private-storage.md). */
290+
export function githubFactsFrom(user: GitHubUser, status: GitHubProbeStatus, checkedAt: string) {
291+
return {
292+
login: user.login,
293+
accountCreatedAt: user.created_at,
294+
publicRepos: user.public_repos,
295+
followers: user.followers,
296+
following: user.following,
297+
type: user.type,
298+
status,
299+
checkedAt,
214300
};
215301
}

‎apps/api/src/env.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,12 @@ export const EnvSchema = z.object({
104104
CFP_NOTIFICATION_FROM: z
105105
.string()
106106
.default('Code for Philly <notifications@codeforphilly.org>'),
107+
/**
108+
* Shared secret Postmark presents on the bounce webhook (basic-auth password
109+
* or bearer token). Unset → POST /api/_webhooks/postmark/bounce answers 503.
110+
* See specs/api/webhooks.md.
111+
*/
112+
POSTMARK_WEBHOOK_SECRET: z.string().min(16).optional(),
107113
});
108114

109115
export type Env = z.infer<typeof EnvSchema>;
@@ -144,6 +150,7 @@ export const envJsonSchema = {
144150
CFP_SITE_HOST: { type: 'string', default: 'codeforphilly.org' },
145151
POSTMARK_SERVER_TOKEN: { type: 'string' },
146152
POSTMARK_MESSAGE_STREAM: { type: 'string', default: 'outbound' },
153+
POSTMARK_WEBHOOK_SECRET: { type: 'string', minLength: 16 },
147154
CFP_NOTIFICATION_FROM: {
148155
type: 'string',
149156
default: 'Code for Philly <notifications@codeforphilly.org>',

‎apps/api/src/plugins/services.ts‎

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { TagWriteService } from '../services/tag.write.js';
2929
import { GitHubAccountService } from '../services/github-account.js';
3030
import { AccountClaimService } from '../services/account-claim.js';
3131
import { ModerationService, ModerationWriteService } from '../services/moderation.js';
32+
import { probeGitHubUser } from '../auth/github-client.js';
3233
import { LoggingNotifier, type Notifier } from '../notify/index.js';
3334
import { EmailNotifier } from '../notify/email-notifier.js';
3435
import { PostmarkTransport } from '../notify/postmark-transport.js';
@@ -110,15 +111,36 @@ async function servicesPlugin(fastify: FastifyInstance): Promise<void> {
110111
tagsWrite: new TagWriteService(state),
111112
githubAccount,
112113
accountClaim: new AccountClaimService(state, fastify.store.private, githubAccount),
113-
moderation: new ModerationService(state, fastify.store.private, (personId) => {
114-
// Newest sign-in from session metadata; the auth plugin decorates it
115-
// after this one registers, so resolve lazily per call.
116-
let latest: string | null = null;
117-
for (const m of fastify.sessionMetadata?.getAll(personId) ?? []) {
118-
if (!latest || m.issuedAt > latest) latest = m.issuedAt;
119-
}
120-
return latest;
121-
}),
114+
moderation: new ModerationService(
115+
state,
116+
fastify.store.private,
117+
(personId) => {
118+
// Session facts from session metadata; the auth plugin decorates it
119+
// after this one registers, so resolve lazily per call.
120+
let latest: string | null = null;
121+
let count = 0;
122+
for (const m of fastify.sessionMetadata?.getAll(personId) ?? []) {
123+
count += 1;
124+
if (!latest || m.issuedAt > latest) latest = m.issuedAt;
125+
}
126+
return { lastLoginAt: latest, count };
127+
},
128+
{
129+
log: fastify.log,
130+
// The roster re-checks linked GitHub accounts against the API using the
131+
// OAuth app's client credentials; without them the probe is simply off.
132+
...(fastify.config.GITHUB_OAUTH_CLIENT_ID && fastify.config.GITHUB_OAUTH_CLIENT_SECRET
133+
? {
134+
probe: (githubUserId: number) =>
135+
probeGitHubUser(
136+
githubUserId,
137+
fastify.config.GITHUB_OAUTH_CLIENT_ID as string,
138+
fastify.config.GITHUB_OAUTH_CLIENT_SECRET as string,
139+
),
140+
}
141+
: {}),
142+
},
143+
),
122144
moderationWrite: new ModerationWriteService(state),
123145
});
124146
}

‎apps/api/src/routes/moderation.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { ok, paginated } from '../lib/response.js';
1313
import { ApiNotFoundError, ApiValidationError } from '../lib/errors.js';
1414
import { getCallerSession } from '../services/permissions.js';
1515
import { buildTransactionOptions } from '../store/commit-meta.js';
16-
import type { VoteFilter } from '../services/moderation.js';
16+
import type { MemberOrigin, VoteFilter } from '../services/moderation.js';
1717

1818
function requireStaffOr404(request: FastifyRequest): void {
1919
const level = request.session.accountLevel;
@@ -34,6 +34,7 @@ export async function moderationRoutes(fastify: FastifyInstance): Promise<void>
3434
properties: {
3535
q: { type: 'string' },
3636
vote: { type: 'string', enum: ['none', 'spam', 'legit'] },
37+
origin: { type: 'string', enum: ['imported', 'signed-up'] },
3738
joinedAfter: { type: 'string' },
3839
joinedBefore: { type: 'string' },
3940
includeDeactivated: { type: 'boolean' },
@@ -51,6 +52,7 @@ export async function moderationRoutes(fastify: FastifyInstance): Promise<void>
5152
const result = await fastify.services.moderation.listMembers({
5253
q: q['q'] as string | undefined,
5354
vote: q['vote'] as VoteFilter | undefined,
55+
origin: q['origin'] as MemberOrigin | undefined,
5456
joinedAfter: q['joinedAfter'] as string | undefined,
5557
joinedBefore: q['joinedBefore'] as string | undefined,
5658
includeDeactivated: q['includeDeactivated'] as boolean | undefined,

‎apps/api/src/routes/saml.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,13 +371,16 @@ async function handleSpInitiatedSso(
371371
{ relayState, customTagReplacement },
372372
);
373373

374+
await stampSlackSso(fastify, person.id);
375+
374376
const samlResponse = bindingCtx.context;
375377
const actionUrl =
376378
'entityEndpoint' in bindingCtx && typeof bindingCtx.entityEndpoint === 'string'
377379
? bindingCtx.entityEndpoint
378380
: acsUrl;
379381
const replyRelayState = 'relayState' in bindingCtx ? bindingCtx.relayState : relayState;
380382

383+
await stampSlackSso(fastify, person.id);
381384
return reply.header('Content-Type', 'text/html; charset=utf-8').send(
382385
renderPostForm({
383386
actionUrl,
@@ -387,6 +390,22 @@ async function handleSpInitiatedSso(
387390
);
388391
}
389392

393+
/**
394+
* Record that the IdP just vouched for this person to Slack. Best-effort: a
395+
* private-store hiccup must not turn a successful assertion into an error.
396+
* specs/api/saml.md → "Slack SSO stamp".
397+
*/
398+
async function stampSlackSso(fastify: FastifyInstance, personId: string): Promise<void> {
399+
try {
400+
const profile = await fastify.store.private.getProfile(personId);
401+
if (!profile) return;
402+
const now = new Date().toISOString();
403+
await fastify.store.private.putProfile({ ...profile, lastSlackSsoAt: now, updatedAt: now });
404+
} catch (err) {
405+
fastify.log.warn({ err, personId }, 'could not stamp lastSlackSsoAt');
406+
}
407+
}
408+
390409
// ---------------------------------------------------------------------------
391410
// Routes
392411
// ---------------------------------------------------------------------------
@@ -487,6 +506,7 @@ export async function samlRoutes(fastify: FastifyInstance): Promise<void> {
487506
);
488507

489508
// PostBindingContext.context holds the base64-encoded signed Response.
509+
await stampSlackSso(fastify, person.id);
490510
const samlResponse = bindingCtx.context;
491511
const relayState = 'relayState' in bindingCtx ? bindingCtx.relayState : query.redir;
492512
const actionUrl =
@@ -640,6 +660,8 @@ export async function samlRoutes(fastify: FastifyInstance): Promise<void> {
640660
{ relayState: resumeClaims.relayState, customTagReplacement },
641661
);
642662

663+
await stampSlackSso(fastify, person.id);
664+
643665
const samlResponse = bindingCtx.context;
644666
const actionUrl =
645667
'entityEndpoint' in bindingCtx && typeof bindingCtx.entityEndpoint === 'string'

0 commit comments

Comments
 (0)