diff --git a/.gitignore b/.gitignore index a326a3f..65537e2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,12 @@ dist/ .DS_Store npm-debug.log* /exports + +# Terraform +terraform/.terraform/ +terraform/*.tfstate +terraform/*.tfstate.* +terraform/*.tfvars +!terraform/environments/*.tfvars +terraform/crash.log +terraform/crash.*.log diff --git a/README.md b/README.md index bac9830..b6f7748 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,86 @@ npm test - `npm run build` type-checks the TypeScript project and builds the React SPA into `dist/client` for Workers Assets. - `npm test` runs Vitest tests against the Worker API handler. +## Deployment + +Heygo ships as a single Cloudflare Worker fronted by two custom domains: a +**public** shortlink host and a **private** shortlink host. There are two +environments, each with its own D1 database, KV namespace, Worker, and domains. + +| Environment | Public host | Private host | Worker | D1 database | +| --- | --- | --- | --- | --- | +| dev | `dev.heygo.cc` | `my.dev.junv.cc` | `heygo-dev` | `heygo-shortlinks-dev` | +| prod | `heygo.cc` | `my.heygo.cc` | `heygo` | `heygo-shortlinks` | + +The Worker reads `PUBLIC_HOST`, `PRIVATE_HOST`, `APP_BASE_URL`, and +`COOKIE_DOMAIN` from env vars (set in each wrangler config), so the same code +serves every environment without hardcoded hostnames. + +### Prerequisites + +- A Cloudflare account with the `heygo.cc` zone added (and `junv.cc` for dev's + private host). +- A Cloudflare API token with D1, Workers KV, Workers Scripts, and Workers + Routes permissions. +- Terraform >= 1.5 installed. + +### 1. Provision infrastructure with Terraform + +The `terraform/` directory provisions the D1 database, KV namespace, and both +Worker custom domains. See `terraform/README.md` for full details. + +```bash +export CLOUDFLARE_API_TOKEN=*** # fill account_id + zone_ids first +npm run infra:init +npm run infra:plan:dev # review, then: +npm run infra:apply:dev +npm run infra:plan:prod # review, then: +npm run infra:apply:prod +``` + +### 2. Copy Terraform outputs into Wrangler configs + +After `infra:apply:dev`, read the created resource IDs: + +```bash +terraform -chdir=terraform output -var-file=environments/dev.tfvars +``` + +Paste the printed `d1_database_id` into `wrangler.dev.jsonc` +(`d1_databases[].database_id`) and `kv_namespace_id` into +`wrangler.dev.jsonc` (`kv_namespaces[].id`). Repeat for prod outputs into +`wrangler.prod.jsonc`. The placeholder `00000000-...` IDs are there until you +fill them in. + +### 3. Apply D1 migrations + +```bash +npm run migrate:dev +npm run migrate:prod +``` + +### 4. Deploy the Worker + +```bash +npm run deploy:dev +npm run deploy:prod +``` + +### Dev environment cross-domain cookie caveat + +In dev the private host `my.dev.junv.cc` lives on the `junv.cc` zone, while the +app UI and auth flow are served from `dev.heygo.cc` (`.heygo.cc` cookie domain). +A session cookie scoped to `.heygo.cc` is **not** sent to `junv.cc`, so private +shortlink auth on `my.dev.junv.cc` will not work out of the box. Options: + +- Use `my.dev.heygo.cc` (same `.heygo.cc` domain) as the dev private host + instead — update `terraform/environments/dev.tfvars` and `wrangler.dev.jsonc` + accordingly. +- Manually set the `heygo_session` cookie on `junv.cc` for local dev testing. + +Prod does not have this limitation because `heygo.cc` and `my.heygo.cc` share +the `.heygo.cc` cookie domain. + ## Current API skeleton - `GET /api/health` returns JSON health status from the Worker. diff --git a/package.json b/package.json index c787444..9a534ee 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,16 @@ "build": "tsc --noEmit && vite build", "preview": "vite preview", "test": "vitest run", - "import:links": "npx tsx scripts/import-links-to-d1.ts" + "import:links": "npx tsx scripts/import-links-to-d1.ts", + "deploy:dev": "wrangler deploy --config wrangler.dev.jsonc", + "deploy:prod": "wrangler deploy --config wrangler.prod.jsonc", + "migrate:dev": "wrangler d1 migrations apply heygo-shortlinks-dev --config wrangler.dev.jsonc", + "migrate:prod": "wrangler d1 migrations apply heygo-shortlinks --config wrangler.prod.jsonc", + "infra:init": "terraform -chdir=terraform init", + "infra:plan:dev": "terraform -chdir=terraform plan -var-file=environments/dev.tfvars", + "infra:apply:dev": "terraform -chdir=terraform apply -var-file=environments/dev.tfvars", + "infra:plan:prod": "terraform -chdir=terraform plan -var-file=environments/prod.tfvars", + "infra:apply:prod": "terraform -chdir=terraform apply -var-file=environments/prod.tfvars" }, "dependencies": { "react": "^19.2.7", diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl new file mode 100644 index 0000000..e472cef --- /dev/null +++ b/terraform/.terraform.lock.hcl @@ -0,0 +1,19 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/cloudflare/cloudflare" { + version = "5.21.0" + constraints = "~> 5.0" + hashes = [ + "h1:GHS9VFEa0rXjux9Cipqy2QGEmQ0bst6Xqd5WUUJsOwI=", + "zh:2d3dc17f27dcf308f52bdede3b7bb00e6cda6c1a9fbdafd1bfe4a915e75fdc44", + "zh:413e3569ad0cc89f8ac425d8a197d9e892ff356ac24dedde386820cf5880a48e", + "zh:59f262b9af9a8845afeb2734a6bd83b260aa2c521e5c318850745823ef62b38d", + "zh:7d42963a47ff6dda8aada0cb73a26eb6807c7ff6bb4419cb91b32ce2412c8362", + "zh:89cad3906c9affa0f2947607d316d70c38541c399d0519ebee1f1ccc8aaf5675", + "zh:d5c7ff6c39d895e5746fed74ecc3f284c91c8c1f502da2e93f5c581f41f87f25", + "zh:dc425b5f40e94165e563dc55bd6e49cedfe879a03e668168610459ef071b1a87", + "zh:f0665907dd24ae93f9511216052d653bba0823c933e888cf02a4abf95f73dfe0", + "zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32", + ] +} diff --git a/terraform/README.md b/terraform/README.md new file mode 100644 index 0000000..3e0f935 --- /dev/null +++ b/terraform/README.md @@ -0,0 +1,76 @@ +# Terraform Infrastructure + +This directory provisions the Cloudflare resources that heygo depends on: +a D1 database, a Workers KV namespace, and two Worker custom domains +(public + private shortlink hosts). The same configuration targets both the +`dev` and `prod` environments via separate `.tfvars` files. + +## Resources managed + +| Resource | Terraform type | Purpose | +| --- | --- | --- | +| D1 database | `cloudflare_d1_database` | Stores links, sessions, users, promotion submissions | +| KV namespace | `cloudflare_workers_kv_namespace` | Public link cache in front of D1 | +| Public custom domain | `cloudflare_workers_custom_domain` | `dev.heygo.cc` / `heygo.cc` | +| Private custom domain | `cloudflare_workers_custom_domain` | `my.dev.junv.cc` / `my.heygo.cc` | + +## First-time setup + +1. Install Terraform >= 1.5. +2. Create a Cloudflare API token with these permissions: + - Account / D1: Read + Write + - Account / Workers KV Storage: Read + Write + - Account / Workers Scripts: Read + Write + - Zone / Workers Routes: Read + Write +3. Export the token: + + ```bash + export CLOUDFLARE_API_TOKEN=cf-token-here + ``` + +4. Fill in `account_id`, `heygo_cc_zone_id`, and `private_domain_zone_id` in + `environments/dev.tfvars` and `environments/prod.tfvars`. The other values + are pre-filled for each environment. + +## Apply per environment + +```bash +# Dev +terraform -chdir=terraform init +terraform -chdir=terraform plan -var-file=environments/dev.tfvars +terraform -chdir=terraform apply -var-file=environments/dev.tfvars + +# Prod +terraform -chdir=terraform plan -var-file=environments/prod.tfvars +terraform -chdir=terraform apply -var-file=environments/prod.tfvars +``` + +These commands are also exposed as npm scripts (`infra:init`, `infra:plan:dev`, +`infra:apply:dev`, `infra:plan:prod`, `infra:apply:prod`) from the project root. + +## Copy outputs into Wrangler configs + +After `terraform apply`, read the outputs: + +```bash +terraform -chdir=terraform output -var-file=environments/dev.tfvars +``` + +Copy the printed `d1_database_id` into the `d1_databases[].database_id` field +and `kv_namespace_id` into the `kv_namespaces[].id` field of the matching +wrangler config: + +- dev outputs → `wrangler.dev.jsonc` +- prod outputs → `wrangler.prod.jsonc` + +Then apply D1 migrations and deploy the Worker (see the project root README +Deployment section). + +## Notes + +- `terraform/*.tfvars` are committed because they contain no secrets, only + placeholder zone/account IDs. Real IDs are filled in locally. The + `.gitignore` keeps any other stray `*.tfvars` files out of version control. +- State files (`*.tfstate`) and the `.terraform/` plugin cache are git-ignored. + For team use, configure a remote backend (e.g. Cloudflare R2 + native + `backend` block) before the first `apply`. diff --git a/terraform/environments/dev.tfvars b/terraform/environments/dev.tfvars new file mode 100644 index 0000000..c768783 --- /dev/null +++ b/terraform/environments/dev.tfvars @@ -0,0 +1,9 @@ +environment = "dev" +account_id = "" # Fill in your Cloudflare account ID +heygo_cc_zone_id = "" # Fill in heygo.cc zone ID +private_domain_zone_id = "" # Fill in junv.cc zone ID +public_domain = "dev.heygo.cc" +private_domain = "my.dev.junv.cc" +d1_database_name = "heygo-shortlinks-dev" +kv_namespace_title = "heygo-public-cache-dev" +worker_name = "heygo-dev" diff --git a/terraform/environments/prod.tfvars b/terraform/environments/prod.tfvars new file mode 100644 index 0000000..b87a938 --- /dev/null +++ b/terraform/environments/prod.tfvars @@ -0,0 +1,9 @@ +environment = "prod" +account_id = "" # Fill in your Cloudflare account ID +heygo_cc_zone_id = "" # Fill in heygo.cc zone ID +private_domain_zone_id = "" # Fill in heygo.cc zone ID (same as heygo_cc_zone_id for prod) +public_domain = "heygo.cc" +private_domain = "my.heygo.cc" +d1_database_name = "heygo-shortlinks" +kv_namespace_title = "heygo-public-cache" +worker_name = "heygo" diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000..d7de9f6 --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,30 @@ +# D1 database that stores links, sessions, users, and promotion submissions. +resource "cloudflare_d1_database" "shortlinks" { + account_id = var.account_id + name = var.d1_database_name + primary_location_hint = var.primary_location_hint +} + +# Workers KV namespace used as a public link cache in front of D1. +resource "cloudflare_workers_kv_namespace" "public_cache" { + account_id = var.account_id + title = var.kv_namespace_title +} + +# Custom domain for the public shortlinks host (heygo.cc / dev.heygo.cc). +# Verified v5 resource name: cloudflare_workers_custom_domain +# (see https://developers.cloudflare.com/api/terraform/resources/workers/subresources/domains/). +resource "cloudflare_workers_custom_domain" "public" { + account_id = var.account_id + zone_id = var.heygo_cc_zone_id + hostname = var.public_domain + service = var.worker_name +} + +# Custom domain for the private shortlinks host (my.heygo.cc / my.dev.junv.cc). +resource "cloudflare_workers_custom_domain" "private" { + account_id = var.account_id + zone_id = var.private_domain_zone_id + hostname = var.private_domain + service = var.worker_name +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000..4551f45 --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,24 @@ +output "d1_database_id" { + value = cloudflare_d1_database.shortlinks.id + description = "D1 database UUID. Copy this into the database_id field of the matching wrangler config." +} + +output "kv_namespace_id" { + value = cloudflare_workers_kv_namespace.public_cache.id + description = "Workers KV namespace ID. Copy this into the id field of the matching wrangler config." +} + +output "worker_name" { + value = var.worker_name + description = "Name of the deployed Cloudflare Worker service." +} + +output "public_domain" { + value = var.public_domain + description = "Public shortlinks hostname served by the Worker." +} + +output "private_domain" { + value = var.private_domain + description = "Private shortlinks hostname served by the Worker." +} diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000..bbcaa33 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,67 @@ +variable "cloudflare_api_token" { + type = string + sensitive = true + description = "Cloudflare API token with D1, Workers KV, and Workers Custom Domains permissions. Export via the CLOUDFLARE_API_TOKEN env var instead of committing it." +} + +variable "account_id" { + type = string + description = "Cloudflare account ID that owns the D1 databases, KV namespaces, and Workers." +} + +variable "environment" { + type = string + description = "Deployment environment. Must be \"dev\" or \"prod\"." + validation { + condition = var.environment == "dev" || var.environment == "prod" + error_message = "environment must be either \"dev\" or \"prod\"." + } +} + +variable "heygo_cc_zone_id" { + type = string + description = "Cloudflare zone ID for heygo.cc. The public shortlinks hostname always lives under this zone." +} + +variable "private_domain_zone_id" { + type = string + description = "Zone ID where the private shortlinks hostname lives. For prod this is the same as heygo_cc_zone_id (my.heygo.cc is under heygo.cc); for dev it is the junv.cc zone ID (my.dev.junv.cc)." +} + +variable "public_domain" { + type = string + description = "Public shortlinks hostname (e.g. heygo.cc or dev.heygo.cc)." +} + +variable "private_domain" { + type = string + description = "Private shortlinks hostname (e.g. my.heygo.cc or my.dev.junv.cc)." +} + +variable "d1_database_name" { + type = string + description = "Name of the D1 database that stores short links and sessions." +} + +variable "kv_namespace_title" { + type = string + description = "Title of the Workers KV namespace used for public link caching." +} + +variable "worker_name" { + type = string + description = "Name of the Cloudflare Worker service that the custom domains route to." +} + +variable "primary_location_hint" { + type = string + default = "wnam" + description = "Region hint for the D1 primary. One of wnam, enam, weur, eeur, apac, oc." + validation { + condition = contains( + ["wnam", "enam", "weur", "eeur", "apac", "oc"], + var.primary_location_hint, + ) + error_message = "primary_location_hint must be one of wnam, enam, weur, eeur, apac, oc." + } +} diff --git a/terraform/versions.tf b/terraform/versions.tf new file mode 100644 index 0000000..c8471f2 --- /dev/null +++ b/terraform/versions.tf @@ -0,0 +1,12 @@ +terraform { + required_providers { + cloudflare = { + source = "cloudflare/cloudflare" + version = "~> 5.0" + } + } +} + +provider "cloudflare" { + api_token = var.cloudflare_api_token +} diff --git a/tests/api.links.test.ts b/tests/api.links.test.ts index 546aa26..5a7c31d 100644 --- a/tests/api.links.test.ts +++ b/tests/api.links.test.ts @@ -306,7 +306,17 @@ function cookie(token: string): string { function makeEnv(links: LinkRow[] = [], sessions: SessionRow[] = [], dbOptions: FakeD1Options = {}) { const db = new FakeD1Database(links, sessions, dbOptions); - return { env: { DB: db as unknown as D1Database }, db, ctx: new FakeExecutionContext() }; + return { + env: { + DB: db as unknown as D1Database, + PUBLIC_HOST: 'heygo.cc', + PRIVATE_HOST: 'my.heygo.cc', + APP_BASE_URL: 'https://heygo.cc', + COOKIE_DOMAIN: '.heygo.cc', + }, + db, + ctx: new FakeExecutionContext(), + }; } async function fetchWorker( diff --git a/tests/api.promotions.test.ts b/tests/api.promotions.test.ts index 0d84ba9..a82eb3d 100644 --- a/tests/api.promotions.test.ts +++ b/tests/api.promotions.test.ts @@ -391,7 +391,17 @@ function makeEnv( dbOptions: FakeD1Options = {}, ) { const db = new FakeD1Database(links, submissions, sessions, dbOptions); - return { env: { DB: db as unknown as D1Database }, db, ctx: new FakeExecutionContext() }; + return { + env: { + DB: db as unknown as D1Database, + PUBLIC_HOST: 'heygo.cc', + PRIVATE_HOST: 'my.heygo.cc', + APP_BASE_URL: 'https://heygo.cc', + COOKIE_DOMAIN: '.heygo.cc', + }, + db, + ctx: new FakeExecutionContext(), + }; } async function fetchWorker( diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 2953d30..c21a406 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -22,8 +22,8 @@ describe('auth configuration constants', () => { }); describe('session cookie policy', () => { - it('uses a shared heygo.cc cookie domain in production', () => { - expect(getSessionCookieAttributes('https://my.heygo.cc/app')).toEqual([ + it('uses the configured cookie domain when one is supplied', () => { + expect(getSessionCookieAttributes('https://my.heygo.cc/app', '.heygo.cc')).toEqual([ 'HttpOnly', 'Secure', 'SameSite=Lax', @@ -32,12 +32,22 @@ describe('session cookie policy', () => { ]); }); - it('omits Domain for localhost development', () => { - expect(getSessionCookieAttributes('http://localhost:5173/app')).toEqual([ + it('omits Domain when the cookie domain env var is empty (localhost development)', () => { + expect(getSessionCookieAttributes('http://localhost:5173/app', '')).toEqual([ 'HttpOnly', 'Secure', 'SameSite=Lax', 'Path=/', ]); }); + + it('honors an arbitrary dev cookie domain such as .junv.cc', () => { + expect(getSessionCookieAttributes('https://my.dev.junv.cc/app', '.junv.cc')).toEqual([ + 'HttpOnly', + 'Secure', + 'SameSite=Lax', + 'Path=/', + 'Domain=.junv.cc', + ]); + }); }); diff --git a/tests/multi-env.test.ts b/tests/multi-env.test.ts new file mode 100644 index 0000000..be6fba3 --- /dev/null +++ b/tests/multi-env.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; +import worker from '../worker/index'; +import { isHeygoPublicHost } from '../worker/routes/redirect'; +import { isHeygoPrivateHost } from '../worker/routes/private-redirect'; + +// Minimal fake D1 that returns no rows. These tests exercise host routing, +// not link data, so an empty database is sufficient. +class EmptyD1 { + prepare(): { bind(): { first(): Promise; all(): Promise<{ results: never[] }> }; first(): Promise; all(): Promise<{ results: never[] }> } { + const stmt = { + bind() { + return stmt; + }, + async first() { + return null; + }, + async all() { + return { results: [] as never[] }; + }, + }; + return stmt; + } +} + +class FakeExecutionContext { + waitUntil(): void {} + passThroughOnException(): void {} +} + +type HostVars = { + PUBLIC_HOST: string; + PRIVATE_HOST: string; + APP_BASE_URL: string; + COOKIE_DOMAIN: string; +}; + +const PROD_VARS: HostVars = { + PUBLIC_HOST: 'heygo.cc', + PRIVATE_HOST: 'my.heygo.cc', + APP_BASE_URL: 'https://heygo.cc', + COOKIE_DOMAIN: '.heygo.cc', +}; + +const DEV_VARS: HostVars = { + PUBLIC_HOST: 'dev.heygo.cc', + PRIVATE_HOST: 'my.dev.junv.cc', + APP_BASE_URL: 'https://dev.heygo.cc', + COOKIE_DOMAIN: '.heygo.cc', +}; + +async function fetchWorker(url: string, vars: HostVars) { + const env = { DB: new EmptyD1() as unknown as D1Database, ...vars }; + const ctx = new FakeExecutionContext(); + return worker.fetch( + new Request(url) as unknown as Parameters[0], + env as unknown as Parameters[1], + ctx as unknown as Parameters[2], + ); +} + +describe('isHeygoPublicHost / isHeygoPrivateHost are env-driven', () => { + it('matches only the configured public host, not the prod default', () => { + expect(isHeygoPublicHost('dev.heygo.cc', 'dev.heygo.cc')).toBe(true); + expect(isHeygoPublicHost('heygo.cc', 'dev.heygo.cc')).toBe(false); + expect(isHeygoPublicHost('heygo.cc', 'heygo.cc')).toBe(true); + }); + + it('matches only the configured private host', () => { + expect(isHeygoPrivateHost('my.dev.junv.cc', 'my.dev.junv.cc')).toBe(true); + expect(isHeygoPrivateHost('my.heygo.cc', 'my.dev.junv.cc')).toBe(false); + expect(isHeygoPrivateHost('my.heygo.cc', 'my.heygo.cc')).toBe(true); + }); +}); + +describe('worker routing with dev environment vars', () => { + it('treats dev.heygo.cc as the public shortlink host', async () => { + // No rows => unknown alias => public 404 HTML (proves it reached the public route). + const response = await fetchWorker('https://dev.heygo.cc/missing', DEV_VARS); + expect(response.status).toBe(404); + expect(response.headers.get('content-type')).toContain('text/html'); + await expect(response.text()).resolves.toContain('Not found'); + }); + + it('does NOT treat heygo.cc as public when dev vars are configured', async () => { + // heygo.cc is not the configured public host, so it falls through to the + // generic JSON 404 instead of the public HTML 404. + const response = await fetchWorker('https://heygo.cc/missing', DEV_VARS); + expect(response.status).toBe(404); + expect(response.headers.get('content-type')).toContain('application/json'); + await expect(response.json()).resolves.toEqual({ error: 'Not found' }); + }); + + it('treats my.dev.junv.cc as the private shortlink host (login prompt, no-store)', async () => { + const response = await fetchWorker('https://my.dev.junv.cc/foo', DEV_VARS); + expect(response.status).toBe(404); + expect(response.headers.get('content-type')).toContain('text/html'); + expect(response.headers.get('cache-control')).toBe('no-store'); + const body = await response.text(); + // Login link must point at the dev APP_BASE_URL, not the prod one. + expect(body).toContain('https://dev.heygo.cc/app/login'); + expect(body).not.toContain('https://heygo.cc/app/login'); + }); + + it('does NOT treat my.heygo.cc as private when dev vars are configured', async () => { + const response = await fetchWorker('https://my.heygo.cc/foo', DEV_VARS); + expect(response.status).toBe(404); + expect(response.headers.get('content-type')).toContain('application/json'); + await expect(response.json()).resolves.toEqual({ error: 'Not found' }); + }); +}); + +describe('worker routing with prod environment vars', () => { + it('routes heygo.cc to the public handler and my.heygo.cc to the private handler', async () => { + const pub = await fetchWorker('https://heygo.cc/missing', PROD_VARS); + expect(pub.status).toBe(404); + expect(pub.headers.get('content-type')).toContain('text/html'); + + const priv = await fetchWorker('https://my.heygo.cc/foo', PROD_VARS); + expect(priv.status).toBe(404); + expect(priv.headers.get('content-type')).toContain('text/html'); + expect(priv.headers.get('cache-control')).toBe('no-store'); + const body = await priv.text(); + expect(body).toContain('https://heygo.cc/app/login'); + }); +}); diff --git a/tests/private-redirect.test.ts b/tests/private-redirect.test.ts index 105b08f..28b737f 100644 --- a/tests/private-redirect.test.ts +++ b/tests/private-redirect.test.ts @@ -139,7 +139,13 @@ function futureIso(): string { } type EnvBundle = { - env: { DB: D1Database }; + env: { + DB: D1Database; + PUBLIC_HOST: string; + PRIVATE_HOST: string; + APP_BASE_URL: string; + COOKIE_DOMAIN: string; + }; db: FakeD1Database; ctx: FakeExecutionContext; }; @@ -147,7 +153,13 @@ type EnvBundle = { function makeEnv(links: LinkRow[] = [], sessions: SessionRow[] = []): EnvBundle { const db = new FakeD1Database(links, sessions); return { - env: { DB: db as unknown as D1Database }, + env: { + DB: db as unknown as D1Database, + PUBLIC_HOST: 'heygo.cc', + PRIVATE_HOST: 'my.heygo.cc', + APP_BASE_URL: 'https://heygo.cc', + COOKIE_DOMAIN: '.heygo.cc', + }, db, ctx: new FakeExecutionContext(), }; diff --git a/tests/redirect.test.ts b/tests/redirect.test.ts index a2b501f..caec0de 100644 --- a/tests/redirect.test.ts +++ b/tests/redirect.test.ts @@ -86,7 +86,16 @@ class FakeExecutionContext { function makeEnv(rows: LinkRow[] = []) { const db = new FakeD1Database(rows); - return { env: { DB: db as unknown as D1Database }, db }; + return { + env: { + DB: db as unknown as D1Database, + PUBLIC_HOST: 'heygo.cc', + PRIVATE_HOST: 'my.heygo.cc', + APP_BASE_URL: 'https://heygo.cc', + COOKIE_DOMAIN: '.heygo.cc', + }, + db, + }; } async function fetchWorker(url: string, rows: LinkRow[] = []) { diff --git a/tests/session.test.ts b/tests/session.test.ts index eecdb9b..bde11e7 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -66,7 +66,16 @@ class FakeSessionStatement { function makeEnv(rows: SessionRow[] = []): { env: Env; db: FakeSessionD1 } { const db = new FakeSessionD1(rows); - return { env: { DB: db as unknown as D1Database }, db }; + return { + env: { + DB: db as unknown as D1Database, + PUBLIC_HOST: 'heygo.cc', + PRIVATE_HOST: 'my.heygo.cc', + APP_BASE_URL: 'https://heygo.cc', + COOKIE_DOMAIN: '.heygo.cc', + }, + db, + }; } function makeRequest(cookieHeader?: string | null): Request { diff --git a/tests/worker.test.ts b/tests/worker.test.ts index 535f4e2..1b93afd 100644 --- a/tests/worker.test.ts +++ b/tests/worker.test.ts @@ -8,7 +8,13 @@ class FakeExecutionContext { function fetchWorker(path: string) { const request = new Request(`https://heygo.test${path}`); - const env = { DB: {} as D1Database }; + const env = { + DB: {} as D1Database, + PUBLIC_HOST: 'heygo.cc', + PRIVATE_HOST: 'my.heygo.cc', + APP_BASE_URL: 'https://heygo.cc', + COOKIE_DOMAIN: '.heygo.cc', + }; const ctx = new FakeExecutionContext(); return worker.fetch( diff --git a/worker/auth.ts b/worker/auth.ts index e9cbc2c..dde7012 100644 --- a/worker/auth.ts +++ b/worker/auth.ts @@ -29,21 +29,23 @@ export function isAuthProviderEnabled(provider: string): provider is EnabledAuth return ENABLED_PROVIDER_SET.has(provider); } -export function getSessionCookieAttributes(requestUrl: string | URL): string[] { - const url = typeof requestUrl === 'string' ? new URL(requestUrl) : requestUrl; +export function getSessionCookieAttributes( + requestUrl: string | URL, + cookieDomain: string, +): string[] { + // The request URL is retained so callers can derive a host-specific domain in + // the future; the shared Domain attribute now comes from the COOKIE_DOMAIN + // env var so each deployment (dev/prod/local) controls its own scope. + void requestUrl; const attributes = ['HttpOnly', 'Secure', 'SameSite=Lax', 'Path=/']; - if (isHeygoProductionHost(url.hostname)) { - attributes.push('Domain=.heygo.cc'); + if (cookieDomain) { + attributes.push(`Domain=${cookieDomain}`); } return attributes; } -function isHeygoProductionHost(hostname: string): boolean { - return hostname === 'heygo.cc' || hostname.endsWith('.heygo.cc'); -} - export class AuthError extends Error { readonly status: number; diff --git a/worker/env.ts b/worker/env.ts index a1e53a1..a1fe45d 100644 --- a/worker/env.ts +++ b/worker/env.ts @@ -2,4 +2,8 @@ export interface Env { DB: D1Database; PUBLIC_LINK_CACHE?: KVNamespace; ASSETS?: Fetcher; + PUBLIC_HOST: string; + PRIVATE_HOST: string; + APP_BASE_URL: string; + COOKIE_DOMAIN: string; } diff --git a/worker/index.ts b/worker/index.ts index 0252a34..a70de0b 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -21,8 +21,8 @@ function json(body: unknown, init: ResponseInit = {}) { }); } -function withPrivateHostNoStoreHeaders(url: URL, response: Response): Response { - return isHeygoPrivateHost(url) ? withPrivateNoStoreHeaders(response) : response; +function withPrivateHostNoStoreHeaders(url: URL, response: Response, privateHost: string): Response { + return isHeygoPrivateHost(url.hostname, privateHost) ? withPrivateNoStoreHeaders(response) : response; } export default { @@ -30,29 +30,29 @@ export default { const url = new URL(request.url); if (url.pathname === '/api/health') { - return withPrivateHostNoStoreHeaders(url, json({ ok: true, service: 'heygo-worker' })); + return withPrivateHostNoStoreHeaders(url, json({ ok: true, service: 'heygo-worker' }), env.PRIVATE_HOST); } if (url.pathname.startsWith('/api/')) { const apiResponse = await handleLinksApi(request, env); if (apiResponse) { - return withPrivateHostNoStoreHeaders(url, apiResponse); + return withPrivateHostNoStoreHeaders(url, apiResponse, env.PRIVATE_HOST); } const promotionsResponse = await handlePromotionsApi(request, env); if (promotionsResponse) { - return withPrivateHostNoStoreHeaders(url, promotionsResponse); + return withPrivateHostNoStoreHeaders(url, promotionsResponse, env.PRIVATE_HOST); } - return withPrivateHostNoStoreHeaders(url, json({ error: 'Not found' }, { status: 404 })); + return withPrivateHostNoStoreHeaders(url, json({ error: 'Not found' }, { status: 404 }), env.PRIVATE_HOST); } - if (isHeygoPublicHost(url) && !isReservedPublicPath(url.pathname)) { + if (isHeygoPublicHost(url.hostname, env.PUBLIC_HOST) && !isReservedPublicPath(url.pathname)) { return handlePublicShortlink(request, env, ctx); } - if (isHeygoPrivateHost(url) && !isReservedPublicPath(url.pathname)) { + if (isHeygoPrivateHost(url.hostname, env.PRIVATE_HOST) && !isReservedPublicPath(url.pathname)) { return handlePrivateShortlink(request, env, ctx); } - return withPrivateHostNoStoreHeaders(url, json({ error: 'Not found' }, { status: 404 })); + return withPrivateHostNoStoreHeaders(url, json({ error: 'Not found' }, { status: 404 }), env.PRIVATE_HOST); }, } satisfies ExportedHandler; diff --git a/worker/lib/responses.ts b/worker/lib/responses.ts index e79a687..aba48e8 100644 --- a/worker/lib/responses.ts +++ b/worker/lib/responses.ts @@ -61,19 +61,33 @@ export function publicBadRequestResponse(message = 'Invalid short link'): Respon ); } -export const PRIVATE_LOGIN_URL = 'https://heygo.cc/app/login'; -export const PRIVATE_APP_URL = 'https://heygo.cc/app/private'; +/** + * Build the app login URL for a given environment's APP_BASE_URL. + * Kept as a function (not a constant) so each deployment's base URL is honored. + */ +export function privateLoginUrl(appBaseUrl: string): string { + return `${appBaseUrl}/app/login`; +} -export function privateLoginRequiredResponse(): Response { +/** + * Build the private links app URL for a given environment's APP_BASE_URL. + */ +export function privateAppUrl(appBaseUrl: string): string { + return `${appBaseUrl}/app/private`; +} + +export function privateLoginRequiredResponse(appBaseUrl: string): Response { + const loginUrl = privateLoginUrl(appBaseUrl); return privateHtmlResponse( - `Login required

Not found

Login to use your private links.

`, + `Login required

Not found

Login to use your private links.

`, { status: 404 }, ); } -export function privateAliasNotFoundResponse(): Response { +export function privateAliasNotFoundResponse(appBaseUrl: string): Response { + const appUrl = privateAppUrl(appBaseUrl); return privateHtmlResponse( - `Not found

Not found

This private link does not exist.

Create this private link.

`, + `Not found

Not found

This private link does not exist.

Create this private link.

`, { status: 404 }, ); } diff --git a/worker/routes/private-redirect.ts b/worker/routes/private-redirect.ts index a27ea37..baf5939 100644 --- a/worker/routes/private-redirect.ts +++ b/worker/routes/private-redirect.ts @@ -3,8 +3,8 @@ import { getCurrentUser } from '../auth'; import { validateAlias } from '../lib/aliases'; import { renderCustomLinkHtml } from '../lib/custom-link'; import { - PRIVATE_APP_URL, privateAliasNotFoundResponse, + privateAppUrl, privateHtmlResponse, privateLoginRequiredResponse, privateRedirectResponse, @@ -30,8 +30,8 @@ LIMIT 1`; const CLICK_COUNT_UPDATE = `UPDATE links SET click_count = click_count + 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id=?`; -export function isHeygoPrivateHost(url: URL): boolean { - return url.hostname === 'my.heygo.cc'; +export function isHeygoPrivateHost(hostname: string, privateHost: string): boolean { + return hostname === privateHost; } export async function handlePrivateShortlink( @@ -45,9 +45,9 @@ export async function handlePrivateShortlink( if (url.pathname === '/') { const user = await getCurrentUser(request, env); if (!user) { - return privateLoginRequiredResponse(); + return privateLoginRequiredResponse(env.APP_BASE_URL); } - return privateRedirectResponse(PRIVATE_APP_URL, 302); + return privateRedirectResponse(privateAppUrl(env.APP_BASE_URL), 302); } const pathParts = parseAliasPath(url.pathname); @@ -62,21 +62,21 @@ export async function handlePrivateShortlink( const user = await getCurrentUser(request, env); if (!user) { - return privateLoginRequiredResponse(); + return privateLoginRequiredResponse(env.APP_BASE_URL); } const link = await env.DB.prepare(PRIVATE_LINK_QUERY) .bind(user.id, aliasValidation.value) .first(); if (!link) { - return privateAliasNotFoundResponse(); + return privateAliasNotFoundResponse(env.APP_BASE_URL); } ctx.waitUntil(recordClick(env.DB, link.id)); if (link.link_type === 'redirect') { if (!link.target_url) { - return privateAliasNotFoundResponse(); + return privateAliasNotFoundResponse(env.APP_BASE_URL); } try { @@ -100,15 +100,15 @@ export async function handlePrivateShortlink( return privateHtmlResponse(renderCustomLinkHtml(link), { status: 200 }); } - return privateAliasNotFoundResponse(); + return privateAliasNotFoundResponse(env.APP_BASE_URL); } async function privateNotFoundOrLogin(request: Request, env: Env): Promise { const user = await getCurrentUser(request, env); if (!user) { - return privateLoginRequiredResponse(); + return privateLoginRequiredResponse(env.APP_BASE_URL); } - return privateAliasNotFoundResponse(); + return privateAliasNotFoundResponse(env.APP_BASE_URL); } async function recordClick(db: D1Database, linkId: string): Promise { diff --git a/worker/routes/redirect.ts b/worker/routes/redirect.ts index 2de2ce5..16db5ec 100644 --- a/worker/routes/redirect.ts +++ b/worker/routes/redirect.ts @@ -26,8 +26,8 @@ export function isReservedPublicPath(pathname: string): boolean { || pathname === '/admin' || pathname.startsWith('/admin/'); } -export function isHeygoPublicHost(url: URL): boolean { - return url.hostname === 'heygo.cc'; +export function isHeygoPublicHost(hostname: string, publicHost: string): boolean { + return hostname === publicHost; } export async function handlePublicShortlink( diff --git a/wrangler.dev.jsonc b/wrangler.dev.jsonc new file mode 100644 index 0000000..c3b0d28 --- /dev/null +++ b/wrangler.dev.jsonc @@ -0,0 +1,33 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "heygo-dev", + "main": "worker/index.ts", + "compatibility_date": "2026-06-20", + "assets": { + "directory": "./dist/client", + "not_found_handling": "single-page-application" + }, + "observability": { + "enabled": true + }, + "vars": { + "PUBLIC_HOST": "dev.heygo.cc", + "PRIVATE_HOST": "my.dev.junv.cc", + "APP_BASE_URL": "https://dev.heygo.cc", + "COOKIE_DOMAIN": ".heygo.cc" + }, + "d1_databases": [ + { + "binding": "DB", + "database_name": "heygo-shortlinks-dev", + "database_id": "00000000-0000-0000-0000-000000000000", + "migrations_dir": "migrations" + } + ], + "kv_namespaces": [ + { + "binding": "PUBLIC_LINK_CACHE", + "id": "00000000-0000-0000-0000-000000000000" + } + ] +} diff --git a/wrangler.jsonc b/wrangler.jsonc index 10859ae..247eed3 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -10,6 +10,12 @@ "observability": { "enabled": true }, + "vars": { + "PUBLIC_HOST": "localhost", + "PRIVATE_HOST": "localhost", + "APP_BASE_URL": "http://localhost:5173", + "COOKIE_DOMAIN": "" + }, "d1_databases": [ { "binding": "DB", diff --git a/wrangler.prod.jsonc b/wrangler.prod.jsonc new file mode 100644 index 0000000..c5bebf7 --- /dev/null +++ b/wrangler.prod.jsonc @@ -0,0 +1,33 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "heygo", + "main": "worker/index.ts", + "compatibility_date": "2026-06-20", + "assets": { + "directory": "./dist/client", + "not_found_handling": "single-page-application" + }, + "observability": { + "enabled": true + }, + "vars": { + "PUBLIC_HOST": "heygo.cc", + "PRIVATE_HOST": "my.heygo.cc", + "APP_BASE_URL": "https://heygo.cc", + "COOKIE_DOMAIN": ".heygo.cc" + }, + "d1_databases": [ + { + "binding": "DB", + "database_name": "heygo-shortlinks", + "database_id": "00000000-0000-0000-0000-000000000000", + "migrations_dir": "migrations" + } + ], + "kv_namespaces": [ + { + "binding": "PUBLIC_LINK_CACHE", + "id": "00000000-0000-0000-0000-000000000000" + } + ] +}