diff --git a/docs/hosted.md b/docs/hosted.md index c5e2e6c..ae902ed 100644 --- a/docs/hosted.md +++ b/docs/hosted.md @@ -44,14 +44,14 @@ Pending uploads expire after five minutes; the one-minute cron reclaims them, ex ## Deploy prerequisites -1. Node.js 22+ and a Cloudflare account with Workers, D1 and R2 enabled. Review Workers CPU limits for scrypt password work; removing email does not guarantee that the entire deployment fits a free plan. No email service or sender domain is required. +1. Node.js 22+ and a Cloudflare account with Workers Paid, D1 and R2 enabled. The hosted config sets a 1,000 ms CPU ceiling for scrypt password work; Cloudflare rejects that setting on Workers Free. Upgrading a plan requires operator approval. No email service or sender domain is required. 2. Dedicated Worker `shotsync-hosted`, R2 bucket `shotsync-hosted`, and D1 database `shotsync-hosted`. Never bind the personal or demo bucket. Put the returned D1 UUID into `wrangler.hosted.jsonc`. 3. Set `PUBLIC_ORIGIN` to the final HTTPS origin, and `TURNSTILE_SITE_KEY` to a widget restricted to that hostname. Store `TURNSTILE_SECRET_KEY` as a Worker secret. No other site's Turnstile keys are reused. 4. Configure the bucket's eight-day lifecycle and observability/billing alerts. Review registration and upload caps. Use a custom domain if stronger edge rules are needed. 5. With explicit deployment authorization: `npm run deploy:hosted`. It checks placeholders, applies the new hosted database migrations, then deploys the Worker. Do not run any personal/demo setup or seed scripts. 6. Test registration, saving the recovery code, login, recovery-code rotation, rejection of old credentials, and cross-device transfer. Confirm Turnstile hostname validation, cron cleanup and dashboard metrics. -The checked-in config deliberately contains a local origin, blank Turnstile site key and placeholder DB UUID; the deployment preflight refuses these values. Run `npm run dev:hosted` only for local development. Local HTTPS is needed for browser session cookies; see the browser test for a fully isolated fixture environment. +The checked-in config identifies the operator's dedicated hosted resources. For your own deployment, replace the origin, Turnstile site key, D1 ID and bucket with resources in your account; never copy another operator's resource IDs. The preflight rejects missing values and local origins. Run `npm run dev:hosted` only for local development. Local HTTPS is needed for browser session cookies; see the browser test for a fully isolated fixture environment. ## Verification and rollback @@ -65,3 +65,5 @@ To suspend new writes, set `UPLOADS_ENABLED=0` and deploy. Retain the hosted dat Official references: [D1 transactions](https://developers.cloudflare.com/d1/worker-api/d1-database/), [R2 lifecycle](https://developers.cloudflare.com/r2/buckets/object-lifecycles/). Toolchain note: the compatible Vitest/Workers test stack currently reports development-only npm advisories (8 at implementation time); these packages are not imported by the deployed Worker. Run development servers on loopback only. The package resolver rejected the newest advertised Wrangler version with a publication-date cutoff; this change uses the resolved lockfile and its supported compatibility date. Track the toolchain updates separately before exposing any development server. + +Launch status (2026-09-19): the dedicated D1, R2 bucket and Turnstile widget have been created, all three migrations applied, and the eight-day `users/` R2 expiry configured. Worker deployment is blocked until Workers Paid is enabled; the Turnstile secret still needs to be installed. The service is not publicly available yet. Remote D1 rejected unparenthesized CASE expressions in trigger bodies; migration 0002 now parenthesizes those expressions without changing quota behavior. diff --git a/migrations/0002_files.sql b/migrations/0002_files.sql index f48be23..f81985d 100644 --- a/migrations/0002_files.sql +++ b/migrations/0002_files.sql @@ -26,15 +26,15 @@ CREATE TABLE shares ( ); CREATE INDEX shares_file ON shares(file_id); CREATE TRIGGER reserve_file BEFORE INSERT ON files BEGIN - SELECT CASE WHEN (SELECT COUNT(*) FROM files WHERE state='pending') >= 1 THEN RAISE(ABORT,'quota:global-concurrency') END; - SELECT CASE WHEN (SELECT COUNT(*) FROM files WHERE user_id=NEW.user_id AND state='pending') >= 2 THEN RAISE(ABORT,'quota:concurrency') END; - SELECT CASE WHEN COALESCE((SELECT bytes FROM storage_usage WHERE scope=NEW.user_id),0)+NEW.size > 209715200 THEN RAISE(ABORT,'quota:storage') END; - SELECT CASE WHEN COALESCE((SELECT items FROM storage_usage WHERE scope=NEW.user_id),0) >= 100 THEN RAISE(ABORT,'quota:items') END; - SELECT CASE WHEN COALESCE((SELECT bytes FROM storage_usage WHERE scope='global'),0)+NEW.size > 10737418240 THEN RAISE(ABORT,'quota:global-storage') END; - SELECT CASE WHEN COALESCE((SELECT uploads FROM daily_usage WHERE scope=NEW.user_id AND day=NEW.day),0) >= 50 THEN RAISE(ABORT,'quota:daily-count') END; - SELECT CASE WHEN COALESCE((SELECT bytes FROM daily_usage WHERE scope=NEW.user_id AND day=NEW.day),0)+NEW.size > 104857600 THEN RAISE(ABORT,'quota:daily-bytes') END; - SELECT CASE WHEN COALESCE((SELECT uploads FROM daily_usage WHERE scope='global' AND day=NEW.day),0) >= 2000 THEN RAISE(ABORT,'quota:global-count') END; - SELECT CASE WHEN COALESCE((SELECT bytes FROM daily_usage WHERE scope='global' AND day=NEW.day),0)+NEW.size > 2147483648 THEN RAISE(ABORT,'quota:global-bytes') END; + SELECT (CASE WHEN (SELECT COUNT(*) FROM files WHERE state='pending') >= 1 THEN RAISE(ABORT,'quota:global-concurrency') END); + SELECT (CASE WHEN (SELECT COUNT(*) FROM files WHERE user_id=NEW.user_id AND state='pending') >= 2 THEN RAISE(ABORT,'quota:concurrency') END); + SELECT (CASE WHEN COALESCE((SELECT bytes FROM storage_usage WHERE scope=NEW.user_id),0)+NEW.size > 209715200 THEN RAISE(ABORT,'quota:storage') END); + SELECT (CASE WHEN COALESCE((SELECT items FROM storage_usage WHERE scope=NEW.user_id),0) >= 100 THEN RAISE(ABORT,'quota:items') END); + SELECT (CASE WHEN COALESCE((SELECT bytes FROM storage_usage WHERE scope='global'),0)+NEW.size > 10737418240 THEN RAISE(ABORT,'quota:global-storage') END); + SELECT (CASE WHEN COALESCE((SELECT uploads FROM daily_usage WHERE scope=NEW.user_id AND day=NEW.day),0) >= 50 THEN RAISE(ABORT,'quota:daily-count') END); + SELECT (CASE WHEN COALESCE((SELECT bytes FROM daily_usage WHERE scope=NEW.user_id AND day=NEW.day),0)+NEW.size > 104857600 THEN RAISE(ABORT,'quota:daily-bytes') END); + SELECT (CASE WHEN COALESCE((SELECT uploads FROM daily_usage WHERE scope='global' AND day=NEW.day),0) >= 2000 THEN RAISE(ABORT,'quota:global-count') END); + SELECT (CASE WHEN COALESCE((SELECT bytes FROM daily_usage WHERE scope='global' AND day=NEW.day),0)+NEW.size > 2147483648 THEN RAISE(ABORT,'quota:global-bytes') END); END; CREATE TRIGGER file_reserved AFTER INSERT ON files BEGIN INSERT INTO storage_usage(scope,bytes,items) VALUES(NEW.user_id,NEW.size,1) ON CONFLICT(scope) DO UPDATE SET bytes=bytes+NEW.size,items=items+1; @@ -60,10 +60,10 @@ CREATE TABLE downloads ( ); CREATE INDEX downloads_created ON downloads(created_at); CREATE TRIGGER download_reserve BEFORE INSERT ON downloads BEGIN - SELECT CASE WHEN COALESCE((SELECT downloads FROM daily_usage WHERE scope=NEW.user_id AND day=NEW.day),0) >= 2000 THEN RAISE(ABORT,'quota:downloads') END; - SELECT CASE WHEN COALESCE((SELECT download_bytes FROM daily_usage WHERE scope=NEW.user_id AND day=NEW.day),0)+NEW.bytes > 1073741824 THEN RAISE(ABORT,'quota:download-bytes') END; - SELECT CASE WHEN COALESCE((SELECT downloads FROM daily_usage WHERE scope='global' AND day=NEW.day),0) >= 20000 THEN RAISE(ABORT,'quota:global-downloads') END; - SELECT CASE WHEN COALESCE((SELECT download_bytes FROM daily_usage WHERE scope='global' AND day=NEW.day),0)+NEW.bytes > 21474836480 THEN RAISE(ABORT,'quota:global-download-bytes') END; + SELECT (CASE WHEN COALESCE((SELECT downloads FROM daily_usage WHERE scope=NEW.user_id AND day=NEW.day),0) >= 2000 THEN RAISE(ABORT,'quota:downloads') END); + SELECT (CASE WHEN COALESCE((SELECT download_bytes FROM daily_usage WHERE scope=NEW.user_id AND day=NEW.day),0)+NEW.bytes > 1073741824 THEN RAISE(ABORT,'quota:download-bytes') END); + SELECT (CASE WHEN COALESCE((SELECT downloads FROM daily_usage WHERE scope='global' AND day=NEW.day),0) >= 20000 THEN RAISE(ABORT,'quota:global-downloads') END); + SELECT (CASE WHEN COALESCE((SELECT download_bytes FROM daily_usage WHERE scope='global' AND day=NEW.day),0)+NEW.bytes > 21474836480 THEN RAISE(ABORT,'quota:global-download-bytes') END); END; CREATE TRIGGER download_recorded AFTER INSERT ON downloads BEGIN INSERT INTO daily_usage(scope,day,downloads,download_bytes) VALUES(NEW.user_id,NEW.day,1,NEW.bytes) ON CONFLICT(scope,day) DO UPDATE SET downloads=downloads+1,download_bytes=download_bytes+NEW.bytes; diff --git a/scripts/test-hosted-browser.mjs b/scripts/test-hosted-browser.mjs index 480dbc2..c5e7d15 100644 --- a/scripts/test-hosted-browser.mjs +++ b/scripts/test-hosted-browser.mjs @@ -19,7 +19,7 @@ try { const sql=join(temp,'fixture.sql'); writeFileSync(sql,`INSERT INTO users(id,email,password_hash,verified_at,created_at) VALUES('browser','browser@example.com','${validHash}',NULL,1);`); run(['d1','execute','shotsync-hosted','--local','--file',sql,...common]); - server=spawn(process.execPath,[cli,'dev','--local','--ip','127.0.0.1','--local-protocol','https','--port','8788','--var','PUBLIC_ORIGIN:'+origin,...common],{stdio:['ignore','pipe','pipe']}); + server=spawn(process.execPath,[cli,'dev','--local','--ip','127.0.0.1','--local-protocol','https','--port','8788','--var','PUBLIC_ORIGIN:'+origin,'--var','TURNSTILE_SITE_KEY:',...common],{stdio:['ignore','pipe','pipe']}); let output='';server.stdout.on('data',x=>output+=x);server.stderr.on('data',x=>output+=x); await new Promise((resolve,reject)=>{const started=Date.now();const timer=setInterval(()=>{if(output.includes('Ready on')){clearInterval(timer);resolve();}else if(server.exitCode!==null||Date.now()-started>30000){clearInterval(timer);reject(new Error(output));}},100);}); browser=await chromium.launch({headless:true}); diff --git a/wrangler.hosted.jsonc b/wrangler.hosted.jsonc index d4e7055..2d83735 100644 --- a/wrangler.hosted.jsonc +++ b/wrangler.hosted.jsonc @@ -3,16 +3,38 @@ "name": "shotsync-hosted", "main": "src/hosted/index.ts", "compatibility_date": "2026-08-22", - "compatibility_flags": ["nodejs_compat"], - "observability": {"enabled": true}, - "limits": {"cpu_ms": 1000}, + "compatibility_flags": [ + "nodejs_compat" + ], + "observability": { + "enabled": true + }, + "limits": { + "cpu_ms": 1000 + }, "vars": { - "PUBLIC_ORIGIN": "http://localhost:8787", - "TURNSTILE_SITE_KEY": "", + "PUBLIC_ORIGIN": "https://shotsync-hosted.defiabell.workers.dev", + "TURNSTILE_SITE_KEY": "0x4AAAAAAE8wLBNWAJtUWmVb", "REGISTRATION_LIMIT": "100", "UPLOADS_ENABLED": "1" }, - "d1_databases": [{"binding": "DB", "database_name": "shotsync-hosted", "database_id": "00000000-0000-0000-0000-000000000000", "migrations_dir": "migrations"}], - "r2_buckets": [{"binding": "BUCKET", "bucket_name": "shotsync-hosted"}], - "triggers": {"crons": ["* * * * *"]} + "d1_databases": [ + { + "binding": "DB", + "database_name": "shotsync-hosted", + "database_id": "e45d4d92-d303-40de-9ae3-7de017417b16", + "migrations_dir": "migrations" + } + ], + "r2_buckets": [ + { + "binding": "BUCKET", + "bucket_name": "shotsync-hosted" + } + ], + "triggers": { + "crons": [ + "* * * * *" + ] + } }