feat: add terraform IaC, dev/prod environments, and multi-host worker support

- Terraform configs for D1 databases, KV namespaces, Worker custom domains
- wrangler.dev.jsonc and wrangler.prod.jsonc for environment-specific deployments
- Worker code refactored to use env vars for host checking (PUBLIC_HOST, PRIVATE_HOST)
- Configurable app URLs and cookie domain via env vars
- Deploy and migrate npm scripts for dev/prod
- Updated all tests with new env fixtures
- Deployment guide in README
This commit is contained in:
Hermes Agent
2026-06-20 14:26:56 +10:00
parent f4bf43189f
commit e4fbbaec14
28 changed files with 675 additions and 48 deletions
+9
View File
@@ -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
+80
View File
@@ -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.
+10 -1
View File
@@ -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",
+19
View File
@@ -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",
]
}
+76
View File
@@ -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`.
+9
View File
@@ -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"
+9
View File
@@ -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"
+30
View File
@@ -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
}
+24
View File
@@ -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."
}
+67
View File
@@ -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."
}
}
+12
View File
@@ -0,0 +1,12 @@
terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 5.0"
}
}
}
provider "cloudflare" {
api_token = var.cloudflare_api_token
}
+11 -1
View File
@@ -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(
+11 -1
View File
@@ -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(
+14 -4
View File
@@ -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',
]);
});
});
+125
View File
@@ -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<null>; all(): Promise<{ results: never[] }> }; first(): Promise<null>; 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<typeof worker.fetch>[0],
env as unknown as Parameters<typeof worker.fetch>[1],
ctx as unknown as Parameters<typeof worker.fetch>[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');
});
});
+14 -2
View File
@@ -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(),
};
+10 -1
View File
@@ -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[] = []) {
+10 -1
View File
@@ -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 {
+7 -1
View File
@@ -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(
+10 -8
View File
@@ -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;
+4
View File
@@ -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;
}
+9 -9
View File
@@ -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<Env>;
+20 -6
View File
@@ -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(
`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Login required</title></head><body><h1>Not found</h1><p><a href="${PRIVATE_LOGIN_URL}">Login to use your private links</a>.</p></body></html>`,
`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Login required</title></head><body><h1>Not found</h1><p><a href="${loginUrl}">Login to use your private links</a>.</p></body></html>`,
{ status: 404 },
);
}
export function privateAliasNotFoundResponse(): Response {
export function privateAliasNotFoundResponse(appBaseUrl: string): Response {
const appUrl = privateAppUrl(appBaseUrl);
return privateHtmlResponse(
`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Not found</title></head><body><h1>Not found</h1><p>This private link does not exist.</p><p><a href="${PRIVATE_APP_URL}">Create this private link</a>.</p></body></html>`,
`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Not found</title></head><body><h1>Not found</h1><p>This private link does not exist.</p><p><a href="${appUrl}">Create this private link</a>.</p></body></html>`,
{ status: 404 },
);
}
+11 -11
View File
@@ -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<PrivateLinkRow>();
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<Response> {
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<void> {
+2 -2
View File
@@ -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(
+33
View File
@@ -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"
}
]
}
+6
View File
@@ -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",
+33
View File
@@ -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"
}
]
}