From b20ef9fad34814b08b4fba6599a9178c4ebdb09f Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Fri, 13 Nov 2026 23:29:23 +0530 Subject: [PATCH 1/2] feat(platform): add shared custom-domain verification Adds normalizeHostname, isApexHostname, issueOwnershipToken, createDnsResolver and createDomainVerification so FrontLit and CourseLit can drop their copies. Lookups take an injectable resolver that can pin trusted DNS servers and time out; public suffixes come from tldts. --- .changeset/domain-verification.md | 10 + bun.lock | 25 +- packages/platform/README.md | 39 ++- packages/platform/package.json | 3 + .../platform/src/domain-verification.test.ts | 238 ++++++++++++++++++ packages/platform/src/domain-verification.ts | 182 ++++++++++++++ packages/platform/src/index.ts | 8 + 7 files changed, 495 insertions(+), 10 deletions(-) create mode 100644 .changeset/domain-verification.md create mode 100644 packages/platform/src/domain-verification.test.ts create mode 100644 packages/platform/src/domain-verification.ts diff --git a/.changeset/domain-verification.md b/.changeset/domain-verification.md new file mode 100644 index 0000000..60e42ea --- /dev/null +++ b/.changeset/domain-verification.md @@ -0,0 +1,10 @@ +--- +"@codelitdev/platform": minor +--- + +Adds custom-domain verification to the root export: `normalizeHostname`, `isApexHostname`, `issueOwnershipToken`, `createDnsResolver` and `createDomainVerification` (`ownershipRecord`, `verifyOwnership`, `verifyRouting`). It keeps the behaviour FrontLit and CourseLit each carry today: the TXT value is the bare token, tokens are 16 hex bytes, CNAME chains are followed with loop protection, and an apex may use CNAME flattening or A records that match a configured set. Lookups go through an injectable resolver that can pin trusted DNS servers and times out after 5 seconds by default. The package now depends on `tldts` for public-suffix handling. + +**Product changes for this release** + +- FrontLit (`domains/queries.ts`) and CourseLit (`school-hosts.ts`, `school-host.ts`) can replace their copies with this. Apex/`www` pairing and one-domain-per-school stay in the apps. +- `normalizeHostname` replaces CourseLit's `normalizeCustomHostname`. It also rejects public suffixes and IP literals by default, and `isApexHostname` now uses the public suffix list rather than counting labels. diff --git a/bun.lock b/bun.lock index 363ed43..7c6e262 100644 --- a/bun.lock +++ b/bun.lock @@ -80,7 +80,7 @@ }, "packages/billing": { "name": "@codelitdev/billing", - "version": "0.9.0", + "version": "0.10.0", "bin": { "codelit-billing": "./bin/codelit-billing.js", }, @@ -106,7 +106,7 @@ }, "packages/design-system": { "name": "@codelitdev/design-system", - "version": "0.9.0", + "version": "0.10.0", "devDependencies": { "@types/react": "^19.2.17", "@types/react-dom": "^19.2.5", @@ -124,7 +124,7 @@ }, "packages/mcp-server-kit": { "name": "@codelitdev/mcp-server-kit", - "version": "0.9.0", + "version": "0.10.0", "dependencies": { "@modelcontextprotocol/server": "^2.4.0", "zod": "^3.25.76", @@ -143,7 +143,7 @@ }, "packages/oauth-server-kit": { "name": "@codelitdev/oauth-server-kit", - "version": "0.9.0", + "version": "0.10.0", "devDependencies": { "@better-auth/core": "1.7.22", "@better-auth/oauth-provider": "1.7.22", @@ -167,7 +167,7 @@ }, "packages/observability": { "name": "@codelitdev/observability", - "version": "0.9.0", + "version": "0.10.0", "dependencies": { "pino": "^9.6.0", }, @@ -192,7 +192,10 @@ }, "packages/platform": { "name": "@codelitdev/platform", - "version": "0.9.0", + "version": "0.10.0", + "dependencies": { + "tldts": "7.4.19", + }, "devDependencies": { "@codelitdev/billing": "workspace:*", "@electric-sql/pglite": "^0.2.17", @@ -202,7 +205,7 @@ "typescript": "^5.9.3", }, "peerDependencies": { - "@codelitdev/billing": ">=0.9.0", + "@codelitdev/billing": ">=0.10.0", "drizzle-orm": "^0.45.2", }, "optionalPeers": [ @@ -212,7 +215,7 @@ }, "packages/platform-cli": { "name": "@codelitdev/platform-cli", - "version": "0.9.0", + "version": "0.10.0", "bin": { "codelit-platform": "./dist/bin.js", }, @@ -224,7 +227,7 @@ }, "packages/platform-conformance": { "name": "@codelitdev/platform-conformance", - "version": "0.9.0", + "version": "0.10.0", "devDependencies": { "@codelitdev/platform": "workspace:*", "@types/bun": "^1.4.1", @@ -1367,6 +1370,10 @@ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "tldts": ["tldts@7.4.19", "", { "dependencies": { "tldts-core": "^7.4.19" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q=="], + + "tldts-core": ["tldts-core@7.5.2", "", {}, "sha512-8W1topazDwhMKbm2I83IPa57Ow5VUxFgLbXv42+vSILnv8FY5OHvdEY/tS6WoWeMbxkHZQhra5cmeFHriKptSQ=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], diff --git a/packages/platform/README.md b/packages/platform/README.md index fbc7f77..3227342 100644 --- a/packages/platform/README.md +++ b/packages/platform/README.md @@ -5,11 +5,48 @@ safe errors, UUIDv7 / prefixed public IDs, health/readiness/shutdown, and HTTP/MCP mapping conventions. The package imports no product schema, reads no environment at import, and -performs no network I/O. Products implement authentication, tenant, and +performs no network I/O unless a product calls the DNS helpers below. Products implement authentication, tenant, and authorization adapters. See `docs/architecture.md` §6.4 and ADR 0001. +## Domain verification + +Custom-domain checks shared by every product (root export). No storage, no +environment reads, no product rules: apps keep their own apex/`www` pairing or +one-domain-per-tenant policy. + +```ts +import { + createDnsResolver, + createDomainVerification, + issueOwnershipToken, + normalizeHostname, +} from "@codelitdev/platform"; + +const hostname = normalizeHostname(userInput); // null when invalid +const token = issueOwnershipToken(); // 16 random bytes, hex +const domains = createDomainVerification({ + label: "_myproduct-verification", + resolver: createDnsResolver({ servers: ["1.1.1.1", "8.8.8.8"] }), +}); + +domains.ownershipRecord(hostname, token); // { type: "TXT", name, value: token } +await domains.verifyOwnership(hostname, token); +await domains.verifyRouting(hostname, { + cnameTarget: "edge.example.net", + aRecords: ["203.0.113.10"], // optional, apex only +}); +``` + +| Export | Use | +| --- | --- | +| `normalizeHostname(input, { allowIpLiteral?, allowPublicSuffix? })` | Lowercase, punycode, no trailing dot; null for ports, paths, credentials, bad labels, single-label names, IP literals and public suffixes (`com`, `co.uk`, `github.io`) unless allowed. | +| `isApexHostname(hostname)` | True when the host is its registrable domain (`example.co.uk` yes, `www.example.co.uk` no). Uses the public suffix list including its private section. | +| `issueOwnershipToken()` | 16 random bytes, hex. | +| `createDnsResolver({ servers?, timeoutMs? })` | `node:dns` resolver; `servers` pins trusted recursive resolvers, every lookup times out (default 5000 ms). Inject any `DnsResolver` in tests. | +| `createDomainVerification({ label, resolver? })` | `ownershipRecord`, `verifyOwnership` (TXT at `