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: 4 additions & 2 deletions docs/hosted.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
26 changes: 13 additions & 13 deletions migrations/0002_files.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion scripts/test-hosted-browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand Down
38 changes: 30 additions & 8 deletions wrangler.hosted.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
"* * * * *"
]
}
}
Loading