Skip to content
Merged
2 changes: 2 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import { previewRoutes } from './routes/preview.js';
import { attachmentRoutes } from './routes/attachments.js';
import { chatRoutes } from './routes/chat.js';
import { moderationRoutes } from './routes/moderation.js';
import { webhookRoutes } from './routes/webhooks.js';
import { samlRoutes } from './routes/saml.js';
import { internalRoutes } from './routes/internal.js';

Expand Down Expand Up @@ -203,6 +204,7 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise<FastifyInsta
await fastify.register(attachmentRoutes);
await fastify.register(chatRoutes);
await fastify.register(moderationRoutes);
await fastify.register(webhookRoutes);
await fastify.register(samlRoutes);
await fastify.register(internalRoutes);

Expand Down
86 changes: 86 additions & 0 deletions apps/api/src/auth/github-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,20 @@ export interface GitHubUser {
readonly login: string;
readonly name: string | null;
readonly avatar_url?: string;
/** Reputation facts from the same /user response (specs/api/auth.md step 5). */
readonly created_at: string | null;
readonly public_repos: number | null;
readonly followers: number | null;
readonly following: number | null;
readonly type: string | null;
}

export type GitHubProbeStatus = 'ok' | 'gone';

export interface GitHubProbeResult {
readonly status: GitHubProbeStatus;
/** Present when status is `ok`. */
readonly user: GitHubUser | null;
}

export interface GitHubEmail {
Expand Down Expand Up @@ -153,14 +167,65 @@ export async function fetchGitHubUser(accessToken: string): Promise<GitHubUser>
if (!body || typeof body.id !== 'number' || typeof body.login !== 'string') {
throw new GitHubApiError('GitHub /user returned unexpected shape', 'github_unreachable');
}
return toGitHubUser(body as Partial<GitHubUser> & { id: number; login: string });
}

function toGitHubUser(body: Partial<GitHubUser> & { id: number; login: string }): GitHubUser {
return {
id: body.id,
login: body.login,
name: typeof body.name === 'string' ? body.name : null,
...(typeof body.avatar_url === 'string' ? { avatar_url: body.avatar_url } : {}),
created_at: typeof body.created_at === 'string' ? body.created_at : null,
public_repos: typeof body.public_repos === 'number' ? body.public_repos : null,
followers: typeof body.followers === 'number' ? body.followers : null,
following: typeof body.following === 'number' ? body.following : null,
type: typeof body.type === 'string' ? body.type : null,
};
}

/**
* Is the linked GitHub account still there? `GET /user/{id}` authenticated
* with the OAuth app's client credentials (5,000 req/h). GitHub answers 404
* once it has deleted or suspended the account; that is the signal. Any other
* non-2xx throws so the caller keeps its previous record rather than
* misreading an outage as a verdict.
*/
export async function probeGitHubUser(
githubUserId: number,
clientId: string,
clientSecret: string,
opts: { readonly timeoutMs?: number } = {},
): Promise<GitHubProbeResult> {
const url = `https://api.github.com/user/${githubUserId}`;
const basic = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
let res: Response;
try {
res = await fetch(url, {
method: 'GET',
headers: {
Authorization: `Basic ${basic}`,
Accept: 'application/vnd.github+json',
'User-Agent': USER_AGENT,
},
signal: AbortSignal.timeout(opts.timeoutMs ?? 4000),
});
} catch (err) {
throw new GitHubApiError(`GitHub API transport error: ${url}`, 'github_unreachable', { cause: err });
}
if (res.status === 404) return { status: 'gone', user: null };
if (!res.ok) {
throw new GitHubApiError(`GitHub API ${url} returned ${res.status}`, 'github_unreachable', {
status: res.status,
});
}
const body = (await res.json().catch(() => null)) as Partial<GitHubUser> | null;
if (!body || typeof body.id !== 'number' || typeof body.login !== 'string') {
throw new GitHubApiError('GitHub /user/{id} returned unexpected shape', 'github_unreachable');
}
return { status: 'ok', user: toGitHubUser(body as Partial<GitHubUser> & { id: number; login: string }) };
}

export async function fetchGitHubEmails(accessToken: string): Promise<GitHubEmail[]> {
const body = await ghGet(EMAILS_URL, accessToken);
if (!Array.isArray(body)) {
Expand Down Expand Up @@ -197,6 +262,12 @@ export interface ResolvedGitHubIdentity {
readonly name: string | null;
readonly emails: readonly GitHubEmail[];
readonly primaryEmail: string | null;
/**
* The full /user snapshot, kept so sign-in can record reputation facts.
* Absent when the identity was rebuilt from a claim-pending token rather
* than a live GitHub response.
*/
readonly user?: GitHubUser;
}

export function resolveIdentitySnapshot(
Expand All @@ -211,5 +282,20 @@ export function resolveIdentitySnapshot(
name: user.name,
emails: verified,
primaryEmail: primary?.email.toLowerCase() ?? null,
user,
};
}

/** Shape the reputation facts for the private profile (specs/behaviors/private-storage.md). */
export function githubFactsFrom(user: GitHubUser, status: GitHubProbeStatus, checkedAt: string) {
return {
login: user.login,
accountCreatedAt: user.created_at,
publicRepos: user.public_repos,
followers: user.followers,
following: user.following,
type: user.type,
status,
checkedAt,
};
}
7 changes: 7 additions & 0 deletions apps/api/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ export const EnvSchema = z.object({
CFP_NOTIFICATION_FROM: z
.string()
.default('Code for Philly <notifications@codeforphilly.org>'),
/**
* Shared secret Postmark presents on the bounce webhook (basic-auth password
* or bearer token). Unset → POST /api/_webhooks/postmark/bounce answers 503.
* See specs/api/webhooks.md.
*/
POSTMARK_WEBHOOK_SECRET: z.string().min(16).optional(),
});

export type Env = z.infer<typeof EnvSchema>;
Expand Down Expand Up @@ -144,6 +150,7 @@ export const envJsonSchema = {
CFP_SITE_HOST: { type: 'string', default: 'codeforphilly.org' },
POSTMARK_SERVER_TOKEN: { type: 'string' },
POSTMARK_MESSAGE_STREAM: { type: 'string', default: 'outbound' },
POSTMARK_WEBHOOK_SECRET: { type: 'string', minLength: 16 },
CFP_NOTIFICATION_FROM: {
type: 'string',
default: 'Code for Philly <notifications@codeforphilly.org>',
Expand Down
40 changes: 31 additions & 9 deletions apps/api/src/plugins/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { TagWriteService } from '../services/tag.write.js';
import { GitHubAccountService } from '../services/github-account.js';
import { AccountClaimService } from '../services/account-claim.js';
import { ModerationService, ModerationWriteService } from '../services/moderation.js';
import { probeGitHubUser } from '../auth/github-client.js';
import { LoggingNotifier, type Notifier } from '../notify/index.js';
import { EmailNotifier } from '../notify/email-notifier.js';
import { PostmarkTransport } from '../notify/postmark-transport.js';
Expand Down Expand Up @@ -110,15 +111,36 @@ async function servicesPlugin(fastify: FastifyInstance): Promise<void> {
tagsWrite: new TagWriteService(state),
githubAccount,
accountClaim: new AccountClaimService(state, fastify.store.private, githubAccount),
moderation: new ModerationService(state, fastify.store.private, (personId) => {
// Newest sign-in from session metadata; the auth plugin decorates it
// after this one registers, so resolve lazily per call.
let latest: string | null = null;
for (const m of fastify.sessionMetadata?.getAll(personId) ?? []) {
if (!latest || m.issuedAt > latest) latest = m.issuedAt;
}
return latest;
}),
moderation: new ModerationService(
state,
fastify.store.private,
(personId) => {
// Session facts from session metadata; the auth plugin decorates it
// after this one registers, so resolve lazily per call.
let latest: string | null = null;
let count = 0;
for (const m of fastify.sessionMetadata?.getAll(personId) ?? []) {
count += 1;
if (!latest || m.issuedAt > latest) latest = m.issuedAt;
}
return { lastLoginAt: latest, count };
},
{
log: fastify.log,
// The roster re-checks linked GitHub accounts against the API using the
// OAuth app's client credentials; without them the probe is simply off.
...(fastify.config.GITHUB_OAUTH_CLIENT_ID && fastify.config.GITHUB_OAUTH_CLIENT_SECRET
? {
probe: (githubUserId: number) =>
probeGitHubUser(
githubUserId,
fastify.config.GITHUB_OAUTH_CLIENT_ID as string,
fastify.config.GITHUB_OAUTH_CLIENT_SECRET as string,
),
}
: {}),
},
),
moderationWrite: new ModerationWriteService(state),
});
}
Expand Down
4 changes: 3 additions & 1 deletion apps/api/src/routes/moderation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { ok, paginated } from '../lib/response.js';
import { ApiNotFoundError, ApiValidationError } from '../lib/errors.js';
import { getCallerSession } from '../services/permissions.js';
import { buildTransactionOptions } from '../store/commit-meta.js';
import type { VoteFilter } from '../services/moderation.js';
import type { MemberOrigin, VoteFilter } from '../services/moderation.js';

function requireStaffOr404(request: FastifyRequest): void {
const level = request.session.accountLevel;
Expand All @@ -34,6 +34,7 @@ export async function moderationRoutes(fastify: FastifyInstance): Promise<void>
properties: {
q: { type: 'string' },
vote: { type: 'string', enum: ['none', 'spam', 'legit'] },
origin: { type: 'string', enum: ['imported', 'signed-up'] },
joinedAfter: { type: 'string' },
joinedBefore: { type: 'string' },
includeDeactivated: { type: 'boolean' },
Expand All @@ -51,6 +52,7 @@ export async function moderationRoutes(fastify: FastifyInstance): Promise<void>
const result = await fastify.services.moderation.listMembers({
q: q['q'] as string | undefined,
vote: q['vote'] as VoteFilter | undefined,
origin: q['origin'] as MemberOrigin | undefined,
joinedAfter: q['joinedAfter'] as string | undefined,
joinedBefore: q['joinedBefore'] as string | undefined,
includeDeactivated: q['includeDeactivated'] as boolean | undefined,
Expand Down
22 changes: 22 additions & 0 deletions apps/api/src/routes/saml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,13 +371,16 @@ async function handleSpInitiatedSso(
{ relayState, customTagReplacement },
);

await stampSlackSso(fastify, person.id);

const samlResponse = bindingCtx.context;
const actionUrl =
'entityEndpoint' in bindingCtx && typeof bindingCtx.entityEndpoint === 'string'
? bindingCtx.entityEndpoint
: acsUrl;
const replyRelayState = 'relayState' in bindingCtx ? bindingCtx.relayState : relayState;

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

/**
* Record that the IdP just vouched for this person to Slack. Best-effort: a
* private-store hiccup must not turn a successful assertion into an error.
* specs/api/saml.md → "Slack SSO stamp".
*/
async function stampSlackSso(fastify: FastifyInstance, personId: string): Promise<void> {
try {
const profile = await fastify.store.private.getProfile(personId);
if (!profile) return;
const now = new Date().toISOString();
await fastify.store.private.putProfile({ ...profile, lastSlackSsoAt: now, updatedAt: now });
} catch (err) {
fastify.log.warn({ err, personId }, 'could not stamp lastSlackSsoAt');
}
}

// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -487,6 +506,7 @@ export async function samlRoutes(fastify: FastifyInstance): Promise<void> {
);

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

await stampSlackSso(fastify, person.id);

const samlResponse = bindingCtx.context;
const actionUrl =
'entityEndpoint' in bindingCtx && typeof bindingCtx.entityEndpoint === 'string'
Expand Down
Loading
Loading