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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
#
# Commercial licensing: contact@marketingprowess.simplelogin.com — see COMMERCIAL.md
#
# On Netlify, set these under Site settings → Environment variables instead
# (see DEPLOY.md). APP_URL must be the deployed site URL there, since it is
# the base of the Google OAuth redirect. DRY_RUN=true previews enforcement
# (audit rows only, no YouTube writes); set it to false to act on YouTube.

GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
Expand Down
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ Moderaty is a SvelteKit 2 app using Svelte 5 and TypeScript. Routes live in
`vite.config.ts` for Vite-only settings. Do not edit
generated `.svelte-kit/` files or commit build output.

The cron trigger is a Netlify Scheduled Function in
`netlify/functions/cron.mjs` (every 15 minutes; calls `GET $APP_URL/api/cron`
with the secret in an `Authorization: Bearer` header; the endpoint also keeps
the plan-documented `?secret=` query form for manual triggers). Deployment
steps live in [DEPLOY.md](DEPLOY.md).

Approved dependencies only (execution plan v3): `drizzle-orm`,
`@libsql/client`, the SvelteKit adapter, `re2` (runtime); `drizzle-kit`,
`vitest` (dev). No auth libraries, no googleapis SDK, no OpenAI SDK, no CSS
Expand Down
84 changes: 84 additions & 0 deletions DEPLOY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<!--
# Moderaty — YouTube Comment Auto-Moderation Tool
# Copyright (C) 2026 Andrew Philip Weilbacher

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.

Commercial licensing: contact@marketingprowess.simplelogin.com — see COMMERCIAL.md
-->

# Deploying Moderaty to Netlify

The repo is deploy-ready: `netlify.toml` pins the build (`npm run build`,
publish `build`, Node 24) and `netlify/functions/cron.mjs` is a Netlify
Scheduled Function that triggers one bounded moderation run every 15 minutes.
The steps below are the one-time manual setup.

## 1. Database (Turso)

- Create the production Turso database and note its URL and auth token.
- Apply migrations once from a checkout with the production values sourced:
`npm run db:migrate` (loads `TURSO_DATABASE_URL` / `TURSO_AUTH_TOKEN`).

## 2. Netlify site

- Add the site from the Git repo; build settings come from `netlify.toml`.
- Set these in **Site settings → Environment variables**:

| Variable | Notes |
| --- | --- |
| `TURSO_DATABASE_URL` | `libsql://...` from step 1 |
| `TURSO_AUTH_TOKEN` | from step 1 |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | Google Cloud OAuth client (Web) |
| `OPENAI_API_KEY` | for AI scoring |
| `ENCRYPTION_KEY` | `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"` |
| `CRON_SECRET` | any long random string; also used to trigger cron manually |
| `APP_URL` | the deployed site URL, e.g. `https://moderaty.netlify.app` |
| `DRY_RUN` | start with `true`; flip to `false` after verifying a dry run |

## 3. Google Cloud Console

- Add the production redirect URI to the OAuth client:
`https://<your-site>/api/auth/google/callback`
- Consent screen: app name **Moderaty**, scope
`https://www.googleapis.com/auth/youtube.force-ssl`; while unverified, add
each channel owner's Gmail as a test user.

## 4. Cron

- `netlify/functions/cron.mjs` runs on a `*/15 * * * *` schedule and calls
`GET $APP_URL/api/cron` with the secret in an `Authorization: Bearer` header
(never in the URL). Each invocation processes exactly
one channel (least-recently-run first), so the 15-minute schedule sets the
per-channel scan cadence. A failed run throws and appears as a failed
invocation in **Netlify → Functions → cron** logs.
- **Function timeout:** Netlify's default is 10s, below the trigger's 25s
abort and the endpoint's 20s run budget. Raise it to 26s (Site settings →
Functions) so the graceful-timeout path can fire; on a 10s limit the
platform kills first (runs still recover via lease expiry, but failures are
reported less cleanly).
- Manual trigger (e.g. right after connecting a channel) — prefer the header
form so the secret stays out of shell history and logs:
`curl -H "Authorization: Bearer <CRON_SECRET>" "https://<your-site>/api/cron"`
(the endpoint also accepts the plan-documented `?secret=` query form as a
fallback)

## 5. Post-launch verification

- Deploy, then open the site and connect a channel via Google OAuth.
- Trigger cron manually (above) with `DRY_RUN=true`; expect `dryRun: true`
counts and `dry-run` audit rows, with no YouTube-side changes.
- Set `DRY_RUN=false`, redeploy/restart env, trigger again; confirm held
comments appear in YouTube Studio → Comments → Held for review.
- Watch the next scheduled invocation succeed in the Netlify function logs.
53 changes: 53 additions & 0 deletions netlify/functions/cron.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Moderaty — YouTube Comment Auto-Moderation Tool
// Copyright (C) 2026 Andrew Philip Weilbacher
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// Commercial licensing: contact@marketingprowess.simplelogin.com — see COMMERCIAL.md

/**
* Netlify Scheduled Function: triggers one bounded moderation run every
* 15 minutes by calling the app's cron endpoint on the deployed site.
* The endpoint itself enforces one channel per invocation, so this schedule
* sets the per-channel scan cadence. The secret travels in an Authorization
* header, never in the URL; the request aborts after 25s rather than hanging
* into the platform limit. Any failure throws so the invocation shows up as
* failed in the Netlify function logs.
*/
export default async function cron() {
const base = process.env.APP_URL;
if (!base) throw new Error('APP_URL environment variable is required (set it in Netlify Site settings)');
const secret = process.env.CRON_SECRET;
if (!secret) throw new Error('CRON_SECRET environment variable is required');
const endpoint = new URL('/api/cron', base);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
let res;
try {
res = await fetch(endpoint, {
headers: { authorization: `Bearer ${secret}` },
signal: controller.signal
});
} finally {
clearTimeout(timer);
}
// Bound what lands in Netlify logs; pipeline error bodies can be long.
const body = (await res.text()).slice(0, 500);
if (!res.ok) throw new Error(`cron endpoint failed: ${res.status} ${body}`);
console.log(`cron endpoint ok: ${body}`);
}

const TIMEOUT_MS = 25_000; // below Netlify's 26s function limit; the endpoint's own run budget is 20s

export const config = { schedule: '*/15 * * * *' };
108 changes: 108 additions & 0 deletions netlify/functions/cron.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Moderaty — YouTube Comment Auto-Moderation Tool
// Copyright (C) 2026 Andrew Philip Weilbacher
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// Commercial licensing: contact@marketingprowess.simplelogin.com — see COMMERCIAL.md

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import handler, { config } from './cron.mjs';

// 'test-secret' is a synthetic credential fixture — maintainer-approved
// documented exception per AGENTS.md (approved 2026-07-30, PR #13 review).
const ORIGINAL_ENV = { APP_URL: process.env.APP_URL, CRON_SECRET: process.env.CRON_SECRET };

function jsonResponse(body, status = 200) {
return new Response(JSON.stringify(body), { status });
}

beforeEach(() => {
process.env.APP_URL = 'https://moderaty.example.netlify.app';
process.env.CRON_SECRET = 'test-secret';
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ ok: true, dryRun: false, results: {} })));
vi.spyOn(console, 'log').mockImplementation(() => {});
});

afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
if (ORIGINAL_ENV.APP_URL === undefined) delete process.env.APP_URL;
else process.env.APP_URL = ORIGINAL_ENV.APP_URL;
if (ORIGINAL_ENV.CRON_SECRET === undefined) delete process.env.CRON_SECRET;
else process.env.CRON_SECRET = ORIGINAL_ENV.CRON_SECRET;
});

describe('scheduled cron trigger', () => {
it('runs every 15 minutes', () => {
expect(config.schedule).toBe('*/15 * * * *');
});

it('sends the secret as a bearer header, never in the URL', async () => {
await handler();

expect(fetch).toHaveBeenCalledTimes(1);
const [endpoint, init] = vi.mocked(fetch).mock.calls[0];
expect(endpoint.href).toBe('https://moderaty.example.netlify.app/api/cron');
expect(endpoint.search).toBe('');
expect(init.headers.authorization).toBe('Bearer test-secret');
});

it('rejects when the endpoint does not answer within the timeout', async () => {
vi.useFakeTimers();
vi.stubGlobal('fetch', vi.fn((_endpoint, init) => new Promise((_resolve, reject) => {
init.signal.addEventListener('abort', () => reject(new DOMException('The operation was aborted', 'AbortError')));
})));

const promise = handler();
const assertion = expect(promise).rejects.toThrow(/abort/i);
await vi.advanceTimersByTimeAsync(26_000);

await assertion;
vi.useRealTimers();
});

it('bounds response bodies written to logs and errors', async () => {
const huge = (overrides) => ({ results: { channel: { note: 'x'.repeat(2000) } }, ...overrides });
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse(huge({ ok: true }))));

await handler();

expect(vi.mocked(console.log).mock.calls[0][0].length).toBeLessThan(600);

vi.stubGlobal('fetch', vi.fn(async () => jsonResponse(huge({ ok: false }), 500)));

const failure = await handler().catch((error) => error);
expect(failure.message.length).toBeLessThan(600);
});

it('throws when CRON_SECRET is not configured', async () => {
delete process.env.CRON_SECRET;

await expect(handler()).rejects.toThrow('CRON_SECRET');
expect(fetch).not.toHaveBeenCalled();
});

it('throws when APP_URL is not configured', async () => {
delete process.env.APP_URL;

await expect(handler()).rejects.toThrow('APP_URL');
expect(fetch).not.toHaveBeenCalled();
});

it('throws loudly when the cron endpoint fails', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ ok: false, results: { channel: { error: 'YouTube quota' } } }, 500)));

await expect(handler()).rejects.toThrow('500');
});
});
Loading