Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
31f4713
chore: upgrade wasm-bindgen to the latest version
piffio May 15, 2026
76ea834
feat: Add custom domains support with Cloudflare for SaaS
piffio May 15, 2026
b19c7c8
feat: use a dedicated fallback domain
piffio May 15, 2026
55758a0
debug: add logging to custom domain creation
piffio May 15, 2026
52461be
fix: handle CF for SaaS quota error gracefully
piffio May 15, 2026
c26b86c
fix: use ACME validation records for Cloudflare for SaaS TXT verifica…
piffio May 15, 2026
bc09ad5
fix: support both ownership and SSL validation TXT records for custom…
piffio May 15, 2026
6176d5f
fix: fetch SSL validation records after custom hostname creation
piffio May 15, 2026
1e424b4
fix: fix refresh flow to return SSL validation TXT records
piffio May 15, 2026
37b787d
fix: return SSL validation TXT records on refresh when cert is pending
piffio May 15, 2026
d07aacd
fix clippy errors
piffio May 15, 2026
0850267
feat: Track SSL certificate status separately
piffio May 15, 2026
02c3e3d
fix: iterate on the correct index
piffio May 16, 2026
f5b2dc6
fix: Fix custom domain verification flow and DNS instructions display
piffio May 16, 2026
547a451
fix: correctly handle custom domain routing for Cloudflare for SaaS
piffio May 16, 2026
254ccce
fix: prevent infinite redirect loop on custom domain 404
piffio May 16, 2026
6aa3cf8
Add custom domain support to links
piffio May 16, 2026
8ab31b9
doc: Update OpenAPI spec
piffio May 16, 2026
ce943b7
Add tier-based custom domain quota enforcement
piffio May 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/deploy-ephemeral.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 * * *"]

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,14 @@ 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

- **Analytics aggregation**: Advanced queries and dashboard UI
- **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

Expand Down
16 changes: 16 additions & 0 deletions docs/SELF_HOSTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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**:
Expand Down Expand Up @@ -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` |
Expand All @@ -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

Expand Down
24 changes: 24 additions & 0 deletions docs/openapi/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
}
}
},
Expand Down Expand Up @@ -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"
}
}
},
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/config/pricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
],
Expand Down Expand Up @@ -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",
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/lib/api/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -18,6 +19,13 @@ export interface AdminUsersResponse {
org_tiers: Record<string, string>;
}

export interface PollDomainsResponse {
success: boolean;
domains_processed: number;
status_changes: number;
message: string;
}

export interface UpdateUserRoleRequest {
role: "admin" | "member";
}
Expand Down Expand Up @@ -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<CustomDomain[]> {
return apiClient.get<CustomDomain[]>("/api/admin/domains");
},

/**
* Manually poll all pending custom domains (admin only)
*/
async pollDomains(): Promise<PollDomainsResponse> {
return apiClient.post<PollDomainsResponse>("/api/admin/domains/poll", {});
}
};
66 changes: 66 additions & 0 deletions frontend/src/lib/api/domains.ts
Original file line number Diff line number Diff line change
@@ -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<CustomDomain[]> {
return apiClient.get<CustomDomain[]>(`/api/orgs/${orgId}/domains`);
},

async addDomain(
orgId: string,
hostname: string
): Promise<CreateDomainResponse> {
return apiClient.post<CreateDomainResponse>(`/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<DomainWithInstructions> {
return apiClient.post<DomainWithInstructions>(
`/api/orgs/${orgId}/domains/${hostname}/refresh`,
{}
);
}
};
6 changes: 5 additions & 1 deletion frontend/src/lib/components/LinkCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading