Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions SHA256SUMS
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ ecd8294e24a1cd55cc1aa0b3c5d6cde68c956524cfb1d7707766b4f6287b5e3e activity/index
2c22d3a5e0aff57971932e24cbdf4dc93c12f589b060203cc20417fd97a75142 activity/legal/legal.css
6c8b74d4d57bff38a393ab038418c8f410627dc66721bc416905289110f151ac activity/legal/privacy.html
297c926eb462285e52e196af2d23f2ac70cb280324bfd30aeff74836e02f159c activity/legal/terms.html
e8b23240fdf9f8942c50da42f7b18ed1f4732764247eaaccba4c28d999e50f61 activity/src/connection.ts
6602d23db9ea01227e5e57b1f4c16942116fb3ce9100b0d2d865b6a12727cf79 activity/src/main.ts
81b6e414e8069db0699dd3fea09383ee82b772fa8d929bef60cbd730a9734cfb activity/src/connection.ts
fd8e9fa4ba6382219e4bb13cb415fdeea893eaaf3e58002b05dd71fbaf6a1a66 activity/src/main.ts
8ee444c30afb9d212532308df0f8beffd5c01c8f6e61e0fd1e05c607ecbac477 activity/src/onboarding.ts
66988416b7ab216ad9d3947ed528bfe9f2e45f74943971d506a686ff0b044508 activity/style.css
3fb468ea85cd082f252d85b34bb3da8b62a10ae4aa984b48ef93f0ecacc7017f data/scams/README.md
Expand Down Expand Up @@ -440,7 +440,7 @@ f379be142cd234fb82c6b55ebb66e5ea94c184cb972cda576606d8dd872187ff starters/combi
d1611476abf0bc8ab0e1d4ccfea0abaa232183aa144390d56a8cf00f7468d984 starters/custom-template.typecheck.json
79ba12d8b758c4bcabc7c79b357cebc584442d3f2a368c2142576ef6a95975dd starters/image-scam-analysis.typecheck.json
20868d9d3d3760a4f19cc5aa97ef6496dd3e076507d9eb1b333a8f5ea9691d1e starters/reviewed-scam-references.typecheck.json
03af3cd35cfdc8437f99c7bd900c164d426717185d88df51d845f4e1b22b5743 tests/activity-connection.test.ts
787e6f7eed60f8d56a71d00d1ddf13d20df3fb7047bf760737cc8a066ab1deac tests/activity-connection.test.ts
2257548bf1aa1de3113925dbdbd367db1211f5692ea6e0e69dae9830ef6ca611 tests/analytics.test.ts
977ac57a09795d86d5d5211ca91fa42db9e4f2b63442b0daadfb16025d42ef1c tests/compose.env
f9c22e2e2b1b12d1463e433966978b3cffb6f57e6903aae9d97d2d47a13eaed7 tests/compose.validation.yml
Expand Down
30 changes: 27 additions & 3 deletions activity/src/connection.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,35 @@
// Keep OAuth credentials only in memory for the current Activity instance.
export class HttpError extends Error {
constructor(message: string, readonly status: number) { super(message); }
constructor(message: string, readonly status: number, readonly reference = '') { super(message); }
}
export async function apiResponse<T = any>(response: Response): Promise<T> {
const reference = (value: unknown) => typeof value === 'string' && /^[a-zA-Z0-9-]{1,80}$/.test(value) ? value : '';
const headerReference = reference(response.headers.get('x-request-id')) || reference(response.headers.get('cf-ray'));
let data: unknown;
try { data = await response.json(); }
catch { throw new HttpError('The server returned an unreadable response', response.status, headerReference); }
if (!response.ok) {
const error = data && typeof data === 'object' && 'error' in data ? data.error : undefined;
const bodyReference = data && typeof data === 'object' && 'requestId' in data ? reference(data.requestId) : '';
throw new HttpError(typeof error === 'string' ? error : 'Request failed', response.status, bodyReference || headerReference);
}
return data as T;
}
export function connectionError(stage: string, error: unknown): Error {
const detail = error instanceof HttpError ? ` (HTTP ${error.status}: ${error.message})` :
if (error instanceof HttpError && error.status === 403 && error.message === 'no_delegated_access')
return new Error('You need permission to use TypeCheck in this server. Ask a server owner or administrator to open TypeCheck → Permissions and grant access to your Discord role, then click Connect in Discord. Server owners and administrators have access automatically.');
const detail = error instanceof HttpError ? ` (HTTP ${error.status}: ${error.message}${error.reference ? `; reference ${error.reference}` : ''})` :
error && typeof error === 'object' && 'code' in error && typeof error.code === 'number' ? ` (Discord code ${error.code})` : '';
return new Error(`${stage} failed${detail}. Click Connect in Discord to retry. If Discord continues rejecting authorization, close and reopen the Activity.`);
if (error instanceof HttpError && error.status >= 500)
return new Error(`TypeCheck could not complete sign-in because its server or an upstream service returned an error. Click Connect in Discord to retry. If it continues, share this error with TypeCheck support. ${stage}${detail}.`);
if (error instanceof HttpError)
return new Error(`${stage} failed${detail}. Click Connect in Discord to retry. If it continues, report this error to the TypeCheck administrator.`);
if (error instanceof TypeError || error instanceof SyntaxError)
return new Error(`${stage} failed: the TypeCheck server could not be reached or returned an unreadable response. Click Connect in Discord to retry. If it continues, report this error to the TypeCheck administrator.`);
const action = stage === 'Discord authorization' || stage === 'Discord authentication'
? 'Click Connect in Discord to retry. If it continues, close and reopen the Activity.'
: 'Click Connect in Discord to retry. If it continues, report this error to the TypeCheck administrator.';
return new Error(`Could not connect to TypeCheck. ${action} (${stage} failed${detail}.)`);
}
export function serializeConnection<T>(run: () => Promise<T>): () => Promise<T> {
let pending: Promise<T> | undefined;
Expand Down
14 changes: 5 additions & 9 deletions activity/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ async function packsPage(parent: HTMLElement) {
import { TEXT_STARTERS } from '../../shared/text-starters.js';
import { TEXT_LIMITS, parseText } from '../../shared/text.js';
import type { HistoricalScan } from '../../shared/historical.js';
import { activityAuth, HttpError, serializeConnection } from './connection.js';
import { activityAuth, apiResponse, HttpError, serializeConnection } from './connection.js';
import { timeoutPreset } from '../../shared/domain.js';
import { CONTENT_PRESETS, CONTENT_VERSION, parseContent } from '../../shared/content.js';
import { money, type AnalyticsReport, type Bucket, type Attempt, type Rate } from '../../shared/analytics.js';
Expand Down Expand Up @@ -187,7 +187,7 @@ function initializeAgentSetup() {
window.addEventListener('pagehide',clearAgentSetup);
const can = (cap: Capability) => authority?.capabilities.includes(cap) ?? false;
const admin = () => !!(authority?.owner || authority?.administrator);
const pretty = (s: string) => s === 'severe_threshold_must_not_be_lower' ? 'Action floors must satisfy timeout <= kick <= ban.' : s === 'match_threshold_must_be_a_finite_number_from_0.5_to_1.0' ? 'Match threshold must be a finite number from 0.5 to 1.0 (inclusive).' : s.replaceAll('_', ' ').replaceAll('.', ' / ');
const pretty = (s: string) => s === 'severe_threshold_must_not_be_lower' ? 'Action floors must satisfy timeout <= kick <= ban.' : s === 'match_threshold_must_be_a_finite_number_from_0.5_to_1.0' ? 'Match threshold must be a finite number from 0.5 to 1.0 (inclusive).' : s.replaceAll('_', ' ');
function h<K extends keyof HTMLElementTagNameMap>(tag: K, attrs: Record<string, unknown> = {}, ...children: Child[]): HTMLElementTagNameMap[K] {
const el = document.createElement(tag);
for (const [key, value] of Object.entries(attrs)) {
Expand Down Expand Up @@ -244,13 +244,9 @@ function table(headers: string[], rows: Child[][]) { return h('div', { class: 't
function empty(title: string, detail: string) { return h('div', { class: 'empty' }, h('h3', {}, title), h('p', {}, detail)); }
async function api<T = any>(path: string, method = 'GET', body?: unknown, global = false): Promise<T> {
const response = await fetch(global ? path : `/api/g/${guild}/${path}`, { method, headers: { ...(session ? { Authorization: `Bearer ${session}` } : {}), ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) }, body: body === undefined ? undefined : JSON.stringify(body) });
const data = await response.json();
if (!response.ok) {
if ([401,403].includes(response.status)) clearAgentSetup();
if (response.status === 401) session = '';
throw new HttpError(data.error ?? 'Request failed', response.status);
}
return data as T;
if (!response.ok && [401,403].includes(response.status)) clearAgentSetup();
if (response.status === 401) session = '';
return apiResponse<T>(response);
}
async function blobRequest(path: string) { const r = await fetch(`/api/g/${guild}/${path}`, { headers: { Authorization: `Bearer ${session}` } }); if (!r.ok)
throw new Error('Cannot retrieve file'); return r.blob(); }
Expand Down
56 changes: 53 additions & 3 deletions tests/activity-connection.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,54 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { activityAuth, HttpError } from '../activity/src/connection.js';
import { activityAuth, apiResponse, connectionError, HttpError } from '../activity/src/connection.js';

test('missing TypeCheck role grant explains access requirements instead of retrying Discord authorization', () => {
const error = connectionError('Authorization code exchange', new HttpError('no_delegated_access', 403));
assert.match(error.message, /Server owners and administrators/);
assert.match(error.message, /TypeCheck → Permissions and grant access to your Discord role/);
assert.doesNotMatch(error.message, /close and reopen|Discord continues rejecting/);
assert.match(connectionError('Authorization code exchange', new HttpError('discord_http_403', 403)).message, /HTTP 403: discord_http_403/);
});

test('non-JSON proxy failures preserve HTTP status without displaying the response body', async () => {
const response = new Response('<html>private upstream diagnostic</html>', {status: 502, headers: {'cf-ray': 'abc123-IAD'}});
await assert.rejects(apiResponse(response), (error: unknown) => {
assert.ok(error instanceof HttpError);
const message = connectionError('Authorization code exchange', error).message;
assert.match(message, /HTTP 502: The server returned an unreadable response/);
assert.match(message, /server or an upstream service returned an error/);
assert.match(message, /reference abc123-IAD/);
assert.doesNotMatch(message, /private upstream|Discord continues rejecting|need permission|grant access/);
return true;
});
});

test('server sign-in errors preserve a support reference without treating administrators as ungranted members', async () => {
await assert.rejects(apiResponse(Response.json({error: 'internal_error', requestId: 'test-request-123'}, {status: 500})), (error: unknown) => {
assert.ok(error instanceof HttpError);
const message = connectionError('Authorization code exchange', error).message;
assert.match(message, /could not complete sign-in/);
assert.match(message, /HTTP 500: internal_error; reference test-request-123/);
assert.doesNotMatch(message, /need permission|grant access|close and reopen/);
return true;
});
});

test('API response parsing preserves permission failures and successful session data', async () => {
await assert.rejects(apiResponse(Response.json({error: 'no_delegated_access'}, {status: 403})), (error: unknown) => {
assert.ok(error instanceof HttpError);
assert.equal(error.status, 403);
assert.match(connectionError('Authorization code exchange', error).message, /TypeCheck → Permissions/);
return true;
});
assert.deepEqual(await apiResponse(Response.json({sessionToken: 'test-session'})), {sessionToken: 'test-session'});
});

test('network failures are not described as Discord rejecting authorization', () => {
const message = connectionError('Authorization code exchange', new TypeError('Failed to fetch')).message;
assert.match(message, /server could not be reached/);
assert.doesNotMatch(message, /Discord continues rejecting/);
});

function flow() {
const calls: string[] = [];
Expand Down Expand Up @@ -34,12 +82,14 @@ test('expired OAuth token falls back using a fresh nonce and code', async () =>

test('plain SDK rejection explains the failed stage and retry remains possible', async () => {
const f = flow(); f.authorizeError({code: 4001, message: 'SDK error'});
await assert.rejects(f.connect(), /Discord authorization failed.*4001.*Connect in Discord/);
await assert.rejects(f.connect(), /Connect in Discord.*Discord authorization failed.*4001/);
f.authorizeError(undefined); await f.connect(); assert.ok(f.calls.includes('authorize:nonce-2'));
});

test('renewal permission or network failures do not start another OAuth flow', async () => {
const f = flow(); await f.connect(); f.renewError(new HttpError('no_delegated_access', 403));
await assert.rejects(f.connect(), /Session renewal failed.*403/);
await assert.rejects(f.connect(), /You need permission.*TypeCheck → Permissions/);
f.renewError(new TypeError('Failed to fetch'));
await assert.rejects(f.connect(), /Session renewal failed.*server could not be reached/);
assert.equal(f.calls.filter(c => c.startsWith('authorize:')).length, 1);
});
Loading