diff --git a/.github/workflows/deploy-ephemeral.yml b/.github/workflows/deploy-ephemeral.yml index 72d0a20a..05c90570 100644 --- a/.github/workflows/deploy-ephemeral.yml +++ b/.github/workflows/deploy-ephemeral.yml @@ -247,6 +247,8 @@ jobs: # Format: array of cron expressions (Cloudflare doesn't support named triggers) # - "0 0 * * *" = midnight UTC daily (subscription downgrade) # - "0 4 * * *" = 4 AM UTC daily (webhook cleanup) + # NOTE: Custom domain polling is done manually via admin panel + # in ephemeral/staging due to cron trigger limits. [triggers] crons = ["0 0 * * *", "0 4 * * *"] @@ -278,12 +280,14 @@ jobs: MAILGUN_BASE_URL = "${{ secrets.MAILGUN_BASE_URL }}" MAILGUN_FROM = "${{ secrets.MAILGUN_FROM }}" DOMAIN = "rushomon-pr-${{ env.PR_NUMBER }}.${{ env.WORKERS_DOMAIN }}" + FALLBACK_DOMAIN = "rushomon-pr-${{ env.PR_NUMBER }}.${{ env.WORKERS_DOMAIN }}" FRONTEND_URL = "https://rushomon-pr-${{ env.PR_NUMBER }}.${{ env.WORKERS_DOMAIN }}" ALLOWED_ORIGINS = "http://localhost:5173,http://localhost:5174,https://rushomon-pr-${{ env.PR_NUMBER }}.${{ env.WORKERS_DOMAIN }}" EPHEMERAL_ORIGIN_PATTERN = "https://rushomon-pr-{}.${{ env.WORKERS_DOMAIN }}" ENABLE_KV_RATE_LIMITING = "false" POLAR_ORG_SLUG = "${{ secrets.POLAR_ORG_SLUG }}" POLAR_SANDBOX = "true" + CF_ZONE_ID = "${{ secrets.CF_ZONE_ID }}" EOF - name: Apply D1 Migrations @@ -328,6 +332,7 @@ jobs: MAILGUN_API_KEY: ${{ secrets.MAILGUN_API_KEY }} POLAR_ACCESS_TOKEN: ${{ secrets.POLAR_ACCESS_TOKEN }} POLAR_WEBHOOK_SECRET: ${{ secrets.POLAR_WEBHOOK_SECRET }} + CF_SAAS_API_TOKEN: ${{ secrets.CF_SAAS_API_TOKEN }} run: | # Set secrets using wrangler secrets API (not visible in Worker dashboard) echo "$GH_CLIENT_SECRET" | wrangler secret put GITHUB_CLIENT_SECRET -c wrangler.ephemeral.toml @@ -338,6 +343,9 @@ jobs: echo "$POLAR_ACCESS_TOKEN" | wrangler secret put POLAR_ACCESS_TOKEN -c wrangler.ephemeral.toml echo "$POLAR_WEBHOOK_SECRET" | wrangler secret put POLAR_WEBHOOK_SECRET -c wrangler.ephemeral.toml fi + if [ -n "$CF_SAAS_API_TOKEN" ]; then + echo "$CF_SAAS_API_TOKEN" | wrangler secret put CF_API_TOKEN -c wrangler.ephemeral.toml + fi - name: Deploy to Ephemeral Environment env: diff --git a/Cargo.toml b/Cargo.toml index b0710ef7..1d626c0a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ crate-type = ["cdylib", "rlib"] [dependencies] worker = { version = "0.8.0", features = ["d1"] } -wasm-bindgen = { version = "0.2.114", features = ["serde-serialize"] } +wasm-bindgen = { version = "0.2.121", features = ["serde-serialize"] } web-sys = { version = "0.3", features = ["Window", "Request", "RequestInit", "Response", "Headers", "RequestMode"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/README.md b/README.md index 72f5eb24..d8f32da1 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Never forget that naming things is a hard problem to solve. Nevertheless, I'm gl - **Rate Limiting**: Comprehensive IP, user, and session-based rate limiting - **Instance Settings**: Configurable admin settings including signup control and default tiers - **Email Notifications**: Transactional email via Mailgun for team invitations +- **Custom Domains**: Pro (1) and Business (3) custom domains per organization with SSL via Cloudflare for SaaS ## Planned Features @@ -39,7 +40,6 @@ Never forget that naming things is a hard problem to solve. Nevertheless, I'm gl - **More OAuth providers**: GitLab and other providers beyond GitHub/Google - **QR Codes Generation**: Generate QR codes for links - **Bulk link operations**: Import/export and batch management -- **Custom domains per organization**: Organization-specific branded domains ## How to try it out diff --git a/docs/SELF_HOSTING.md b/docs/SELF_HOSTING.md index 08483458..b1f45cab 100644 --- a/docs/SELF_HOSTING.md +++ b/docs/SELF_HOSTING.md @@ -478,6 +478,7 @@ GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token" GOOGLE_USER_URL = "https://openidconnect.googleapis.com/v1/userinfo" DOMAIN = "api.myapp.com" # Where OAuth callbacks go (your API domain) +FALLBACK_DOMAIN = "redirect.myapp.com" # CNAME target for custom domains (optional, defaults to DOMAIN) FRONTEND_URL = "https://myapp.com" # Main web interface URL ALLOWED_ORIGINS = "https://myapp.com,https://api.myapp.com" # CORS allowed origins # KV-based rate limiting is disabled by default in favor of Cloudflare rate limiting rules @@ -497,6 +498,7 @@ Replace the placeholder values: - `YOUR_GITHUB_CLIENT_ID` — from Step 3a (omit key entirely to disable GitHub login) - `YOUR_GOOGLE_CLIENT_ID` — from Step 3b (omit key entirely to disable Google login) - `api.myapp.com` — your API domain/subdomain (must match OAuth callback URL) +- `redirect.myapp.com` — optional fallback domain for custom domain CNAMEs (defaults to DOMAIN if not set) - `myapp.com` — your main web domain - Adjust `ALLOWED_ORIGINS` to match your domain setup - Set Mailgun values to match your [Mailgun account](https://www.mailgun.com/) configuration (see Step 3c) @@ -543,6 +545,17 @@ wrangler secret put JWT_SECRET -c wrangler.toml # Mailgun API key (from Step 3c) — required for team invitation emails wrangler secret put MAILGUN_API_KEY -c wrangler.toml + +# Cloudflare for SaaS (optional — required for custom domains) +# IMPORTANT: Cloudflare for SaaS is an Enterprise-only feature +# Requires quota allocation from Cloudflare - not available on Free/Pro/Business plans +# Contact Cloudflare sales or use Enterprise preview: https://developers.cloudflare.com/billing/understand/preview-services/ +# Get your Zone ID from Cloudflare Dashboard → Select zone → Overview → Zone ID +wrangler secret put CF_ZONE_ID -c wrangler.toml + +# Get API token from Cloudflare Dashboard → My Profile → API Tokens → Create Token +# Required permissions: Zone - SSL and Certificates - Edit +wrangler secret put CF_API_TOKEN -c wrangler.toml ``` **Security Requirements**: @@ -910,6 +923,7 @@ As an admin, you can: | `GITHUB_TOKEN_URL` | GitHub OAuth token endpoint | `https://github.com/login/oauth/access_token` | | `GITHUB_USER_URL` | GitHub user API endpoint | `https://api.github.com/user` | | `DOMAIN` | Domain where OAuth callbacks go (no protocol) | `api.myapp.com` | +| `FALLBACK_DOMAIN` | CNAME target for custom domains (optional, defaults to DOMAIN) | `redirect.myapp.com` | | `FRONTEND_URL` | Main web interface URL (with protocol) | `https://myapp.com` | | `ALLOWED_ORIGINS` | Comma-separated CORS origins | `https://myapp.com,https://api.myapp.com` | | `ENABLE_KV_RATE_LIMITING` | Enable KV-based rate limiting (default: false) | `false` | @@ -925,6 +939,8 @@ As an admin, you can: | `GOOGLE_CLIENT_SECRET` | Google OAuth App client secret (if enabled) | | `JWT_SECRET` | JWT signing key (32+ random characters) | | `MAILGUN_API_KEY` | Mailgun API key (team invitations) | +| `CF_ZONE_ID` | Cloudflare Zone ID for custom domains (Cloudflare for SaaS - Enterprise only) | +| `CF_API_TOKEN` | Cloudflare API token with SSL/Certificates Edit permission (custom domains - Enterprise only) | ### Frontend Build-Time Variables diff --git a/docs/openapi/main.json b/docs/openapi/main.json index 5ae40c82..03927cdd 100644 --- a/docs/openapi/main.json +++ b/docs/openapi/main.json @@ -3503,6 +3503,14 @@ ], "description": "Desktop-specific destination URL (Business tier feature).", "example": "https://example.com/desktop-app" + }, + "custom_domain": { + "type": [ + "string", + "null" + ], + "description": "Custom domain to associate this link with (must be an active domain on the org).\nImmutable after creation. None = use default short domain.", + "example": "go.mybrand.com" } }, "additionalProperties": false @@ -3652,6 +3660,14 @@ ], "description": "Desktop-specific destination URL (Business tier feature).\nRedirects desktop users (Windows, macOS, Linux) to this URL instead of the default.", "example": "https://example.com/desktop-app" + }, + "custom_domain": { + "type": [ + "string", + "null" + ], + "description": "Custom domain this link was created under (immutable after creation).\nNone means the link uses the default short domain.", + "example": "go.mybrand.com" } } }, @@ -4143,6 +4159,14 @@ "type": "integer", "format": "int64", "example": 50 + }, + "custom_domain": { + "type": [ + "string", + "null" + ], + "description": "Custom domain this link belongs to, if any.", + "example": "go.mybrand.com" } } }, diff --git a/frontend/src/config/pricing.ts b/frontend/src/config/pricing.ts index 4826e01a..69a53af9 100644 --- a/frontend/src/config/pricing.ts +++ b/frontend/src/config/pricing.ts @@ -125,6 +125,7 @@ export const createPricingTiers = ( "Custom short codes", "Advanced QR codes (sizes, SVG, org logo)", "Redirect type selection (301/307)", + "1 custom domain", "API access", "Email support" ], @@ -190,6 +191,7 @@ export const createPricingTiers = ( "3-year analytics retention", "3 organizations", "20 team members", + "3 custom domains", "Device-based routing", "Password protection", "API access", diff --git a/frontend/src/lib/api/admin.ts b/frontend/src/lib/api/admin.ts index c7c743c6..3777c10d 100644 --- a/frontend/src/lib/api/admin.ts +++ b/frontend/src/lib/api/admin.ts @@ -9,6 +9,7 @@ import type { User } from "$lib/types/api"; import { apiClient } from "./client"; +import type { CustomDomain } from "./domains"; export interface AdminUsersResponse { users: User[]; @@ -18,6 +19,13 @@ export interface AdminUsersResponse { org_tiers: Record; } +export interface PollDomainsResponse { + success: boolean; + domains_processed: number; + status_changes: number; + message: string; +} + export interface UpdateUserRoleRequest { role: "admin" | "member"; } @@ -654,5 +662,19 @@ export const adminApi = { `/api/admin/api-keys/${id}/restore`, { method: "POST" } ); + }, + + /** + * List all custom domains across all orgs (admin only) + */ + async listDomains(): Promise { + return apiClient.get("/api/admin/domains"); + }, + + /** + * Manually poll all pending custom domains (admin only) + */ + async pollDomains(): Promise { + return apiClient.post("/api/admin/domains/poll", {}); } }; diff --git a/frontend/src/lib/api/domains.ts b/frontend/src/lib/api/domains.ts new file mode 100644 index 00000000..25d69403 --- /dev/null +++ b/frontend/src/lib/api/domains.ts @@ -0,0 +1,66 @@ +import { apiClient } from "./client"; + +export interface CustomDomain { + id: string; + org_id: string; + hostname: string; + status: "pending" | "active" | "failed"; + cf_hostname_id: string | null; + ssl_status: "pending" | "active" | "failed"; + created_at: number; + verified_at: number | null; +} + +export interface DnsInstructions { + cname_target: string; + txt_records: TxtRecord[]; + needs_cname: boolean; + needs_txt: boolean; +} + +export interface TxtRecord { + name: string; + value: string; + purpose: "ownership" | "ssl_validation"; +} + +export interface DomainWithInstructions { + domain: CustomDomain; + dns_instructions: DnsInstructions | null; +} + +export type CreateDomainResponse = DomainWithInstructions; + +export const domainsApi = { + async listDomains(orgId: string): Promise { + return apiClient.get(`/api/orgs/${orgId}/domains`); + }, + + async addDomain( + orgId: string, + hostname: string + ): Promise { + return apiClient.post(`/api/orgs/${orgId}/domains`, { + hostname + }); + }, + + async deleteDomain( + orgId: string, + hostname: string + ): Promise<{ deleted: boolean }> { + return apiClient.delete<{ deleted: boolean }>( + `/api/orgs/${orgId}/domains/${hostname}` + ); + }, + + async refreshDomain( + orgId: string, + hostname: string + ): Promise { + return apiClient.post( + `/api/orgs/${orgId}/domains/${hostname}/refresh`, + {} + ); + } +}; diff --git a/frontend/src/lib/components/LinkCard.svelte b/frontend/src/lib/components/LinkCard.svelte index 753d093d..44d5b217 100644 --- a/frontend/src/lib/components/LinkCard.svelte +++ b/frontend/src/lib/components/LinkCard.svelte @@ -54,7 +54,11 @@ PUBLIC_VITE_SHORT_LINK_BASE_URL || PUBLIC_VITE_API_BASE_URL || "http://localhost:8787"; - const shortUrl = $derived(`${SHORT_LINK_BASE}/${link.short_code}`); + const shortUrl = $derived( + link.custom_domain + ? `https://${link.custom_domain}/${link.short_code}` + : `${SHORT_LINK_BASE}/${link.short_code}` + ); let showDeleteConfirm = $state(false); let copySuccess = $state(false); diff --git a/frontend/src/lib/components/LinkModal.svelte b/frontend/src/lib/components/LinkModal.svelte index f5528559..bbec5a5d 100644 --- a/frontend/src/lib/components/LinkModal.svelte +++ b/frontend/src/lib/components/LinkModal.svelte @@ -1,4 +1,5 @@ + +
+ + + {#if pollResult} +
+ {pollResult} +
+ {/if} + +
+ +
+ + {#if loading} +
Loading domains...
+ {:else if error} +
{error}
+ {:else if domains.length === 0} +
+

No custom domains have been added yet.

+
+ {:else} +
+ + + + + + + + + + + {#each domains as domain (domain.id)} + + + + + + + {/each} + +
HostnameOrganizationStatusCreated
{domain.hostname}{domain.org_id} + + {domain.status} + + {new Date(domain.created_at * 1000).toLocaleDateString()}
+
+ {/if} +
+ + diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte index 87cba02d..bdbb1864 100644 --- a/frontend/src/routes/dashboard/+page.svelte +++ b/frontend/src/routes/dashboard/+page.svelte @@ -1,5 +1,7 @@