mirror of
https://github.com/wahyd4/heygo.git
synced 2026-08-08 21:05:48 +10:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+19
@@ -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",
|
||||
]
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
cloudflare = {
|
||||
source = "cloudflare/cloudflare"
|
||||
version = "~> 5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "cloudflare" {
|
||||
api_token = var.cloudflare_api_token
|
||||
}
|
||||
+11
-1
@@ -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(
|
||||
|
||||
@@ -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
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user