From 31f4713809f99b9c23da115b14fd915dc9c6c0ee Mon Sep 17 00:00:00 2001 From: Sergio Visinoni Date: Fri, 15 May 2026 16:50:26 +0200 Subject: [PATCH 01/19] chore: upgrade wasm-bindgen to the latest version --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From 76ea834999955c57f9329241721dd2d4f7902785 Mon Sep 17 00:00:00 2001 From: Sergio Visinoni Date: Fri, 15 May 2026 16:52:29 +0200 Subject: [PATCH 02/19] feat: Add custom domains support with Cloudflare for SaaS This commit implements organization-level custom domains with SSL certificates via Cloudflare for SaaS, including: Backend: - Custom domain model and repository - Cloudflare for SaaS API integration - Custom domain CRUD API endpoints - KV dual-write for hostname:short_code mappings - Redirect handler updates for custom domain resolution - Scheduled polling for domain status updates - Admin API for manual domain polling and listing - Database migration for custom_domains table Frontend: - Custom domain management UI - Admin domains page with manual polling button - Admin API client updates - Domains API client - Admin sidebar navigation updates Infrastructure: - Added CF_SAAS_API_TOKEN secret to staging and production workflows - Added domain polling cron to production Tier limits enforced at organization level. --- .github/workflows/deploy-ephemeral.yml | 7 + README.md | 2 +- frontend/src/config/pricing.ts | 2 + frontend/src/lib/api/admin.ts | 22 ++ frontend/src/lib/api/domains.ts | 55 +++ frontend/src/routes/admin/+layout.svelte | 18 +- .../src/routes/admin/domains/+page.svelte | 283 ++++++++++++++++ .../src/routes/dashboard/org/+page.svelte | 318 ++++++++++++++++++ migrations/0030_custom_domains.sql | 15 + src/api/admin/domains.rs | 112 ++++++ src/api/admin/mod.rs | 1 + src/api/domains/create.rs | 186 ++++++++++ src/api/domains/delete.rs | 82 +++++ src/api/domains/list.rs | 34 ++ src/api/domains/mod.rs | 13 + src/api/domains/refresh.rs | 128 +++++++ src/api/links/redirect.rs | 32 +- src/api/mod.rs | 1 + src/api/router.rs | 26 ++ src/kv/links.rs | 41 +++ src/kv/mod.rs | 2 + src/kv/sync.rs | 40 +++ src/models/custom_domain.rs | 85 +++++ src/models/mod.rs | 2 + src/models/tier.rs | 16 +- src/repositories/custom_domain_repository.rs | 222 ++++++++++++ src/repositories/mod.rs | 2 + .../downgrade_expired_subscriptions.rs | 11 + src/scheduled/mod.rs | 1 + src/scheduled/poll_domain_status.rs | 142 ++++++++ src/services/link_service.rs | 15 +- src/utils/cf_saas.rs | 240 +++++++++++++ src/utils/mod.rs | 1 + 33 files changed, 2150 insertions(+), 7 deletions(-) create mode 100644 frontend/src/lib/api/domains.ts create mode 100644 frontend/src/routes/admin/domains/+page.svelte create mode 100644 migrations/0030_custom_domains.sql create mode 100644 src/api/admin/domains.rs create mode 100644 src/api/domains/create.rs create mode 100644 src/api/domains/delete.rs create mode 100644 src/api/domains/list.rs create mode 100644 src/api/domains/mod.rs create mode 100644 src/api/domains/refresh.rs create mode 100644 src/kv/sync.rs create mode 100644 src/models/custom_domain.rs create mode 100644 src/repositories/custom_domain_repository.rs create mode 100644 src/scheduled/poll_domain_status.rs create mode 100644 src/utils/cf_saas.rs diff --git a/.github/workflows/deploy-ephemeral.yml b/.github/workflows/deploy-ephemeral.yml index 72d0a20a..7c5c66d3 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 * * *"] @@ -284,6 +286,7 @@ jobs: 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 +331,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 +342,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/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/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..fc794ff6 --- /dev/null +++ b/frontend/src/lib/api/domains.ts @@ -0,0 +1,55 @@ +import { apiClient } from "./client"; + +export interface CustomDomain { + id: string; + org_id: string; + hostname: string; + status: "pending" | "active" | "failed"; + cf_hostname_id: string | null; + created_at: number; + verified_at: number | null; +} + +export interface DnsInstructions { + cname_target: string; + txt_name: string | null; + txt_value: string | null; + needs_cname: boolean; + needs_txt: boolean; +} + +export interface CreateDomainResponse { + domain: CustomDomain; + dns_instructions: DnsInstructions; +} + +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/routes/admin/+layout.svelte b/frontend/src/routes/admin/+layout.svelte index 28791585..9e3fa52b 100644 --- a/frontend/src/routes/admin/+layout.svelte +++ b/frontend/src/routes/admin/+layout.svelte @@ -15,7 +15,8 @@ | "blacklist" | "reports" | "api-keys" - | "settings"; + | "settings" + | "domains"; const { children } = $props(); @@ -122,6 +123,13 @@ 🔗 Links + + + + {#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/org/+page.svelte b/frontend/src/routes/dashboard/org/+page.svelte index 39988bb0..822b8c27 100644 --- a/frontend/src/routes/dashboard/org/+page.svelte +++ b/frontend/src/routes/dashboard/org/+page.svelte @@ -2,6 +2,8 @@ import type { BillingStatus } from "$lib/api/billing"; import { billingApi } from "$lib/api/billing"; import { resolveLogoUrl } from "$lib/api/client"; + import type { CreateDomainResponse, CustomDomain } from "$lib/api/domains"; + import { domainsApi } from "$lib/api/domains"; import { tagsApi } from "$lib/api/links"; import { orgsApi } from "$lib/api/orgs"; import Avatar from "$lib/components/Avatar.svelte"; @@ -39,6 +41,18 @@ let actionError = $state(""); let actionSuccess = $state(""); + // Custom domains + let domains = $state([]); + let domainsLoading = $state(false); + let domainsError = $state(""); + let newHostname = $state(""); + let addingDomain = $state(false); + let addDomainError = $state(""); + let newDomainResult = $state(null); + let deletingDomain = $state(null); + let refreshingDomain = $state(null); + let confirmingDomainHostname = $state(null); + // Tags management let tags = $state([]); let tagsLoading = $state(false); @@ -62,6 +76,7 @@ orgDetails = await orgsApi.getOrg(currentOrgId); await loadOrgSettings(currentOrgId); await loadTags(); + await loadDomains(currentOrgId); } catch (e: unknown) { error = e instanceof Error ? e.message : "Failed to load organization details."; @@ -241,6 +256,82 @@ } } + async function loadDomains(orgId: string) { + domainsLoading = true; + domainsError = ""; + try { + domains = await domainsApi.listDomains(orgId); + } catch (e: unknown) { + domainsError = e instanceof Error ? e.message : "Failed to load domains."; + } finally { + domainsLoading = false; + } + } + + async function handleAddDomain() { + if (!orgDetails || !newHostname.trim()) return; + addingDomain = true; + addDomainError = ""; + newDomainResult = null; + try { + const result = await domainsApi.addDomain( + orgDetails.org.id, + newHostname.trim() + ); + domains = [...domains, result.domain]; + newDomainResult = result; + newHostname = ""; + } catch (e: unknown) { + addDomainError = e instanceof Error ? e.message : "Failed to add domain."; + } finally { + addingDomain = false; + } + } + + async function handleDeleteDomain(hostname: string) { + if (!orgDetails) return; + confirmingDomainHostname = hostname; + } + + async function confirmDeleteDomain() { + if (!orgDetails || !confirmingDomainHostname) return; + const hostname = confirmingDomainHostname; + deletingDomain = hostname; + try { + await domainsApi.deleteDomain(orgDetails.org.id, hostname); + domains = domains.filter((d) => d.hostname !== hostname); + if (newDomainResult?.domain.hostname === hostname) newDomainResult = null; + } catch (e: unknown) { + actionError = e instanceof Error ? e.message : "Failed to remove domain."; + setTimeout(() => (actionError = ""), 5000); + } finally { + deletingDomain = null; + confirmingDomainHostname = null; + } + } + + function closeConfirmDomain() { + confirmingDomainHostname = null; + } + + async function handleRefreshDomain(hostname: string) { + if (!orgDetails) return; + refreshingDomain = hostname; + try { + const updated = await domainsApi.refreshDomain( + orgDetails.org.id, + hostname + ); + domains = domains.map((d) => (d.hostname === hostname ? updated : d)); + } catch (e: unknown) { + actionError = + e instanceof Error ? e.message : "Failed to refresh domain status."; + setTimeout(() => (actionError = ""), 5000); + } finally { + refreshingDomain = null; + } + } + async function loadTags() { tagsLoading = true; tagsError = ""; @@ -940,6 +1031,192 @@ {/if} + +
+
+

Custom Domains

+ {#if orgDetails.org.tier === "free"} + Pro+ feature + {/if} +
+

+ Point your own domain at your short links (e.g. go.example.com). +

+ + {#if orgDetails.org.tier === "free"} +
+ {:else} + {#if domainsLoading} +
+
+ Loading domains... +
+ {:else if domainsError} +
{domainsError}
+ {/if} + + {#if domains.length > 0} +
    + {#each domains as domain (domain.id)} +
  • +
    +
    + {domain.hostname} + {#if domain.status === "active"} + Active + {:else if domain.status === "pending"} + Pending DNS + {:else} + Failed + {/if} +
    + {#if domain.status === "pending"} +

    + Add the CNAME record, then click Refresh to check + status. +

    + {/if} +
    +
    + {#if domain.status === "pending"} + + {/if} + +
    +
  • + {/each} +
+ {:else if !domainsLoading} +

+ No custom domains configured yet. +

+ {/if} + + + {#if newDomainResult} +
+

+ DNS Setup Required +

+

+ Add the following DNS records at your DNS provider to verify + ownership and enable routing: +

+
+ {#if newDomainResult.dns_instructions.needs_cname} +
+
+ CNAME Record +
+
+ {newDomainResult.domain.hostname} + + {newDomainResult.dns_instructions.cname_target} +
+
+ {/if} + {#if newDomainResult.dns_instructions.needs_txt && newDomainResult.dns_instructions.txt_name} +
+
+ TXT Record (ownership verification) +
+
+ {newDomainResult.dns_instructions.txt_name} + = + {newDomainResult.dns_instructions.txt_value} +
+
+ {/if} +
+

+ DNS propagation can take a few minutes. Use the Refresh button + to check verification status. +

+ +
+ {/if} + +
+ { + if (e.key === "Enter") handleAddDomain(); + }} + /> + +
+ {#if addDomainError} +

{addDomainError}

+ {/if} + {/if} +
+

Tags

@@ -1239,6 +1516,47 @@
{/if} + +{#if confirmingDomainHostname} + +{/if} +