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
15 changes: 12 additions & 3 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
name: Deploy production
# Explicitly dispatch only after separate production authorization. Validate on main is still required. Pull request
# Deploys a commit whose Validate run on a push to main succeeded, or an explicitly dispatched main commit. Pull request
# runs, other branches and failed or cancelled runs never reach the deploy job.
# Publication requires explicit workflow_dispatch. No pull request code is
# workflow_run is used only to observe Validate's verdict for main. No pull request code is
# checked out or executed here; the candidate is a main commit that already passed CI.
# Database credentials exist only inside the GoDaddy runtime, so CI never touches the
# database. A release admitted with an expand-only migration backs the database up to its
# persistent volume and then migrates, once, at startup under the migration lock.
on: # zizmor: ignore[dangerous-triggers]
workflow_run:
workflows: [Validate]
types: [completed]
branches: [main]
workflow_dispatch:
inputs:
commit:
Expand All @@ -28,7 +32,12 @@ concurrency:
cancel-in-progress: false
jobs:
deploy:
if: github.event_name == 'workflow_dispatch'
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'main' &&
github.event.workflow_run.head_repository.full_name == github.repository)
runs-on: ubuntu-24.04
# Worst case: ship <=20, verify <=19 (incl. PREVIEW restart retries), rollback ship+verify <=39 minutes.
timeout-minutes: 80
Expand Down
12 changes: 6 additions & 6 deletions SHA256SUMS
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
b67a5f8294f917779e210dc5104c4b08851876dbcfd91419d691e27e03e1f7e2 .dockerignore
a8ee0dfbeba5f217873d77b9cd189879749da3028d864955c6e11ada8f9bda15 .env.example
70f3d6cd37568f51207435dbf859cf61542ab30860f26e3dc857e99648e22ca3 .github/workflows/ci.yml
8e4fce08f34b76ba6e44ff1611e812d92adc13fa1c2b943a62b3db5453c19a86 .github/workflows/deploy.yml
4b0d23a649c9ec6bf890d72b757f89fa584e59c1346c0406182c5497a6f90774 .github/workflows/deploy.yml
59b480ee3911da82044810c7d67df3416ee7968f2a399b5dfbae74f8f3a75967 .github/workflows/inspect-hosting.yml
51374f3516824cb7a88648dddda77fef8fa383d5e0bb118a6b4fa92444005df6 .gitignore
3a951f66e7e0f3e2dd1832473a871357c4a507b51100636b73bcf6ff0dd78b6c .npmrc
Expand All @@ -21,7 +21,7 @@ ecd8294e24a1cd55cc1aa0b3c5d6cde68c956524cfb1d7707766b4f6287b5e3e activity/index
6c8b74d4d57bff38a393ab038418c8f410627dc66721bc416905289110f151ac activity/legal/privacy.html
297c926eb462285e52e196af2d23f2ac70cb280324bfd30aeff74836e02f159c activity/legal/terms.html
92481a623badff677f19c6eb71a34038931ce059a1b5f6c4e652cb745e590b35 activity/src/connection.ts
3d47f4f00cb34228a30376b4e8ea68ce174f22800d5f2d1728817d4437cd6f32 activity/src/main.ts
8e1c4a409e52f6677d2fb51c3afabae5de8de05be606b83a44271c6d31f35992 activity/src/main.ts
8010da4164c86394863cb645eaf4567f066822114c40edf1d2762c1a5da3806a activity/src/onboarding.ts
66988416b7ab216ad9d3947ed528bfe9f2e45f74943971d506a686ff0b044508 activity/style.css
3fb468ea85cd082f252d85b34bb3da8b62a10ae4aa984b48ef93f0ecacc7017f data/scams/README.md
Expand Down Expand Up @@ -75,16 +75,16 @@ e77b36946c2cd3e87400a8ef30821a9e620775034537e5a61b1d00fa408d0f78 docs/API.md
24861f458f5bba54f223f5903aefe9300793c154cd25958b7ac4f46072a8647a docs/CONTENT_ANALYSIS.md
9c81d1916b565043cc438787fc43b2e5719ff7aac78846f5d32ec7035fdb9d7d docs/COSTS.md
9ade67158bf9c08e1bde20d12401658a23c08df186871f869f32fa476dbd800f docs/DEPENDENCIES.md
9aae43e617e681d49076da868ff96ad6eb915490f67c498ca5a3466a3ae51083 docs/DEPLOYMENT.md
48ccecccc69b49b8ac75e32f8db169e864aa411fd73c89b5e44ab236ce0a5b0e docs/DEPLOYMENT.md
e15373ed5926a2afe6e8069f0c832178ec2ffdcdd4d288525d0146c585271866 docs/HISTORICAL_SCANS.md
b2f2313e1b6cba9454ad457cb6f02850cba1396174a1edc568db6022714dddde docs/MATCHING.md
41cb490223ca17c8953febc5524bc3c48e5338786c0317a483e93687e13a8543 docs/MODERATION_PACKS.md
af79129af04ddd9d5895dba21327f736e00fedca755f7f722395c62803bbb693 docs/OPERATIONS.md
4bee7aa5e6d7ad8035b726ba0647d08918ee5ac1c0424172b32ff9c478cf33ad docs/OPERATIONS.md
e23f9dd58ab8dfeda5db3ed1395fb33aad2ef91918b316c91c13545b56ea8927 docs/PACK_VALIDATION.md
8fbd438d3abf7737814680b335c7250c35b8504eea13abbced98d1240dfb6e32 docs/PERMISSIONS.md
29cba5147c78a1c67d3619b9665dfabb57aaf450651d00ebfbe306aac5fb90bb docs/RESEARCH.md
548ec927e1d97352156b824b228b722e32f7e3554e1894d6d402c28c81987975 docs/SECURITY.md
31eb57212ea71f93d717356df2ebe084e7db371fec5036a4e8761586dc63ec09 docs/SETUP.md
a41b3b3295d8f742c58aa931da366847e1942f21789e0ff45568cae0cb918f6c docs/SETUP.md
8e68110bbe703f2e44fe68d382a7f3d46ea0e5215406ee4d1ae72e7815a3a885 docs/TEXT_MODERATION.md
f618ca2b276495eecdf4e556dd622792c36f44f5e1d694873b863bfd92d69a99 docs/TEXT_STARTERS.md
821210b0d31fce09ccfb03dedf521df28465327d021991dd76a92954b8109a18 docs/USER_SPECIFICATION.md
Expand Down Expand Up @@ -390,7 +390,7 @@ ced5890442d0278819e6afe2db64bae8282a49df4c3e158b6552be4a915435c1 scripts/test-p
5a4244a117b328bb01b54768151e2c881ff18cf90c0d365e645d89d613a0dd78 scripts/test-text-persistence.mjs
d5dd84f2a02c9199b8a971d31f66abf605e1c755f63456802e6207987bfd5a54 scripts/test-threshold-persistence.mjs
6281e428e99049a2cf6a04c9abac7e14f25b4162b8d077cd104f41833332b037 scripts/validate-text-dataset.mjs
eb55d9520146a83841b6d5da870c7556dc8f11dd392128ec2e5a40874c7b510e scripts/verify-production.mjs
029b29c40c468211013982847d99051f171c93fa6a77a6884d6463b872c5cba9 scripts/verify-production.mjs
a44e797125685b375a99f8ea8311972906255b05cdc4df91ea9beb0c8860e185 scripts/verify-provider-source.mjs
466a783814d37d2fc5a729c47705fc229fe397ab398a440b66fecc49602b3a69 server/backup.ts
15dec3c372b73a5932161f5adabacd86af6ed0d73f942b78482b7145559804ed server/config.ts
Expand Down
2 changes: 1 addition & 1 deletion activity/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -719,7 +719,7 @@ async function start(autoConnect = true) {
const inDiscord = new URLSearchParams(location.search).has('frame_id');
const showInstall = !preview && !inDiscord;
const installUrl = new URL('https://discord.com/oauth2/authorize');
installUrl.search = new URLSearchParams({ client_id: config.clientId, scope: 'bot applications.commands', permissions: '68608', integration_type: '0' }).toString();
installUrl.search = new URLSearchParams({ client_id: config.clientId, scope: 'bot applications.commands', permissions: '1374389611526', integration_type: '0' }).toString();
app.append(h('main', { class: 'login' }, h('div', { class: 'login-brand' }, h('span', { class: 'brand-mark' }, 'J'), h('p', { class: 'eyebrow' }, 'JEV / TYPECHECK')), badge(preview ? 'OFFLINE PREVIEW' : 'EXPERIMENTAL MODERATION', 'amber'), h('h1', {}, 'Text + image moderation.\nUnder your control.'), h('p', { class: 'login-copy' }, 'Manage text policies, reference images, permissions, and credentials inside Discord. The backend bot handles continuous monitoring.'),
h('div', { class: 'actions login-actions' },
showInstall ? h('a', { class: 'button primary', href: installUrl.href, target: '_blank', rel: 'noopener noreferrer', 'aria-describedby': 'install-help' }, 'Add to your server') : null,
Expand Down
4 changes: 2 additions & 2 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Production runs on GoDaddy Node.js Hosting. A release reaches it only through `.github/workflows/deploy.yml`:

PR → `Validate` and review → merge to `main` → `Validate` succeeds on the push to `main` → separate explicit production authorization and workflow dispatch → `Deploy production` deploys that exact commit → post-deployment verification.
PR → `Validate` and review → merge to `main` → `Validate` succeeds on the push to `main` → `Deploy production` (started automatically by that successful run, or by manual dispatch) deploys that exact commit → post-deployment verification.

## Architecture (release source updated 2026-10-17)

Expand All @@ -19,7 +19,7 @@ PR → `Validate` and review → merge to `main` → `Validate` succeeds on the

### Why deployment cannot bypass CI

- Production publication runs only by explicit authorized `workflow_dispatch`. Merging a PR or completing CI no longer automatically deploys production. Successful Validate on the exact main commit remains mandatory.
- Production publication runs automatically when `Validate` succeeds on a push to `main`: merging a PR deploys it once CI passes. Pull request runs, other branches and failed or cancelled runs never deploy. Manual `workflow_dispatch` remains available for redeploys and rollbacks.
- Manual dispatch must name a full SHA that is on `main` and has a successful `Validate` push run. The workflow checks both.
- Nothing else can deploy. GoDaddy has no push webhook, and the GoDaddy PAT exists only in the `production` environment, which is restricted to `main`.
- Review remains a process requirement: the repository is public, but main currently has no branch protection or ruleset. Direct pushes still require successful main CI before this deployment workflow runs. The deployment job has `contents: write` only to create release artifact branches; its code never writes main. It uses the short-lived workflow token, not a new credential.
Expand Down
2 changes: 1 addition & 1 deletion docs/OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ coverage exclusions, migration 003, privacy and validation instructions.

## Native text moderation (October 17, 2026)

Inspect native text status/blocked reasons and request correlation without logging message content. text_empty_or_unavailable requires intent/4014 and channel-access checks, never a safe fallback. Pause enforcement when diagnosing uncertainty; preserve action slots/job identity. Recovery refetches IDs/digests; stale edits requeue current content; deleted sources cannot punish. Historical scans remain image-only. Starter deletion is permanent. Before older-code downgrade, follow [JSON compatibility procedure](TEXT_MODERATION.md). Production publication requires separately authorized manual workflow dispatch after main CI.
Inspect native text status/blocked reasons and request correlation without logging message content. text_empty_or_unavailable requires intent/4014 and channel-access checks, never a safe fallback. Pause enforcement when diagnosing uncertainty; preserve action slots/job identity. Recovery refetches IDs/digests; stale edits requeue current content; deleted sources cannot punish. Historical scans remain image-only. Starter deletion is permanent. Before older-code downgrade, follow [JSON compatibility procedure](TEXT_MODERATION.md). Production publication runs automatically once Validate succeeds on main; see [deployment](DEPLOYMENT.md).

## Pack persistence and recovery

Expand Down
4 changes: 2 additions & 2 deletions docs/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ The server serves `/terms` and `/privacy` from `activity/legal/`. Set the Develo

The member who installs the app can immediately connect using their existing **Manage Server** permission; Discord owners and Administrators also have automatic management access. No extra TypeCheck role or role-creation permission is required. Other members still need a role grant under Permissions. Management authority is rechecked from Discord on every request.

The public homepage offers **Add to your server**, opening Discord's server installation flow in a new tab. It uses the configured application ID, `integration_type=0`, scopes `bot applications.commands`, and permission value `68608` (View Channels, Send Messages, Read Message History). It does not request Administrator or sanction permissions. The link is hidden in the offline preview and embedded Activity; the existing Activity connection flow remains available.
The public homepage offers **Add to your server**, opening Discord's server installation flow in a new tab. It uses the configured application ID, `integration_type=0`, scopes `bot applications.commands`, and permission value `1374389611526`: View Channels, Send Messages, Send Messages in Threads and Read Message History for monitoring, warnings and logs, plus Manage Messages, Moderate Members, Kick Members and Ban Members for the delete, timeout, kick and ban actions the bot checks before acting. It does not request Administrator. Discord lets the installing member untick any permission they do not want; actions whose permission is missing are refused before they run (`bot_missing_manage_messages`, `bot_missing_moderation_permission`). The link is hidden in the offline preview and embedded Activity; the existing Activity connection flow remains available.

Before public installation, enable **Public Bot** in the Developer Portal's Bot tab and keep **Requires OAuth2 Code Grant** disabled for this install flow. Under Installation, enable Guild Install and configure the Discord Provided Link with the same scopes and baseline permissions. Public Bot cannot be changed through the documented Edit Current Application bot API. The homepage supplies explicit scopes and permissions, so it does not depend on default install parameters.
Before public installation, enable **Public Bot** in the Developer Portal's Bot tab and keep **Requires OAuth2 Code Grant** disabled for this install flow. Under Installation, enable Guild Install and configure the install link with the same scopes and permission value. Public Bot cannot be changed through the documented Edit Current Application bot API. The homepage supplies explicit scopes and permissions, so it does not depend on default install parameters.

A member with Manage Server can install the bot, but TypeCheck configuration requires the server owner or an administrator (or explicitly delegated capabilities). After installation, launch the Activity, choose monitored channels, add the server's Jev API key, and configure rules. Grant additional bot permissions below only for selected moderation features. Installation does not enable automatic enforcement. Verify installation and Activity launch with a non-owner application user in a separate test server before treating public onboarding as accepted.

Expand Down
12 changes: 11 additions & 1 deletion scripts/verify-production.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ async function waitForRelease() {
throw new Error(`release not serving before timeout: ${last}`);
}

// A newly elected leader reports 503 until its Gateway connects; poll briefly instead of failing on the first read.
async function waitForReady() {
const until = Math.min(deadline, Date.now() + 90000);
for (;;) {
const ready = await get(`${origin}/api/ready`);
if ((ready.status === 200 && json(ready.body)?.status === 'ready') || Date.now() >= until) return ready;
await new Promise(r => setTimeout(r, 5000));
}
}

let health;
try { health = await waitForRelease(); }
catch (e) { check('release serving', false, e.message); }
Expand All @@ -42,7 +52,7 @@ if (health) try {
check('controller state recognized', ['leader', 'standby'].includes(health.status), health.status);
check('object storage persisted', !storage || health.storage === storage, `${health.storage} (expected ${storage ?? 'new marker'})`);
check('object storage writable', !expected || /^[0-9a-f]{32}$/.test(health.storage ?? ''), health.storage);
const ready = await get(`${origin}/api/ready`), r = json(ready.body);
const ready = await waitForReady(), r = json(ready.body);
check('readiness (database + controller)', ready.status === 200 && r?.status === 'ready', `${ready.status} ${ready.body.slice(0, 100)}`);
if (health.status === 'leader') check('leader remains ready', r?.controller === 'leader', r?.controller);
check('api responses not cached', /no-store/.test(ready.headers.get('cache-control') ?? '') && ready.headers.get('cf-cache-status') !== 'HIT', ready.headers.get('cache-control'));
Expand Down
Loading