dkimkit 0.0.0-stage → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/README.md +161 -2
  2. package/dist/src/cli.d.ts +2 -0
  3. package/dist/src/cli.js +398 -0
  4. package/dist/src/cli.js.map +1 -0
  5. package/dist/src/dns-host.d.ts +10 -0
  6. package/dist/src/dns-host.js +31 -0
  7. package/dist/src/dns-host.js.map +1 -0
  8. package/dist/src/errors.d.ts +17 -0
  9. package/dist/src/errors.js +51 -0
  10. package/dist/src/errors.js.map +1 -0
  11. package/dist/src/hosted/api.d.ts +107 -0
  12. package/dist/src/hosted/api.js +919 -0
  13. package/dist/src/hosted/api.js.map +1 -0
  14. package/dist/src/hosted/auth.d.ts +80 -0
  15. package/dist/src/hosted/auth.js +147 -0
  16. package/dist/src/hosted/auth.js.map +1 -0
  17. package/dist/src/hosted/billing-dodo.d.ts +58 -0
  18. package/dist/src/hosted/billing-dodo.js +139 -0
  19. package/dist/src/hosted/billing-dodo.js.map +1 -0
  20. package/dist/src/hosted/billing-stripe.d.ts +82 -0
  21. package/dist/src/hosted/billing-stripe.js +205 -0
  22. package/dist/src/hosted/billing-stripe.js.map +1 -0
  23. package/dist/src/hosted/billing.d.ts +114 -0
  24. package/dist/src/hosted/billing.js +173 -0
  25. package/dist/src/hosted/billing.js.map +1 -0
  26. package/dist/src/hosted/bulk.d.ts +47 -0
  27. package/dist/src/hosted/bulk.js +66 -0
  28. package/dist/src/hosted/bulk.js.map +1 -0
  29. package/dist/src/hosted/cli.d.ts +2 -0
  30. package/dist/src/hosted/cli.js +318 -0
  31. package/dist/src/hosted/cli.js.map +1 -0
  32. package/dist/src/hosted/crypto.d.ts +22 -0
  33. package/dist/src/hosted/crypto.js +55 -0
  34. package/dist/src/hosted/crypto.js.map +1 -0
  35. package/dist/src/hosted/index.d.ts +7 -0
  36. package/dist/src/hosted/index.js +11 -0
  37. package/dist/src/hosted/index.js.map +1 -0
  38. package/dist/src/hosted/markdown.d.ts +16 -0
  39. package/dist/src/hosted/markdown.js +168 -0
  40. package/dist/src/hosted/markdown.js.map +1 -0
  41. package/dist/src/hosted/net.d.ts +9 -0
  42. package/dist/src/hosted/net.js +60 -0
  43. package/dist/src/hosted/net.js.map +1 -0
  44. package/dist/src/hosted/postgres-store.d.ts +62 -0
  45. package/dist/src/hosted/postgres-store.js +387 -0
  46. package/dist/src/hosted/postgres-store.js.map +1 -0
  47. package/dist/src/hosted/serialize.d.ts +144 -0
  48. package/dist/src/hosted/serialize.js +126 -0
  49. package/dist/src/hosted/serialize.js.map +1 -0
  50. package/dist/src/hosted/service.d.ts +170 -0
  51. package/dist/src/hosted/service.js +603 -0
  52. package/dist/src/hosted/service.js.map +1 -0
  53. package/dist/src/hosted/site.d.ts +84 -0
  54. package/dist/src/hosted/site.js +653 -0
  55. package/dist/src/hosted/site.js.map +1 -0
  56. package/dist/src/hosted/sqlite-store.d.ts +45 -0
  57. package/dist/src/hosted/sqlite-store.js +351 -0
  58. package/dist/src/hosted/sqlite-store.js.map +1 -0
  59. package/dist/src/hosted/store.d.ts +257 -0
  60. package/dist/src/hosted/store.js +181 -0
  61. package/dist/src/hosted/store.js.map +1 -0
  62. package/dist/src/hosted/widget.d.ts +46 -0
  63. package/dist/src/hosted/widget.js +130 -0
  64. package/dist/src/hosted/widget.js.map +1 -0
  65. package/dist/src/hosted/worker.d.ts +45 -0
  66. package/dist/src/hosted/worker.js +142 -0
  67. package/dist/src/hosted/worker.js.map +1 -0
  68. package/dist/src/http.d.ts +14 -0
  69. package/dist/src/http.js +54 -0
  70. package/dist/src/http.js.map +1 -0
  71. package/dist/src/index.d.ts +19 -0
  72. package/dist/src/index.js +19 -0
  73. package/dist/src/index.js.map +1 -0
  74. package/dist/src/orchestrator.d.ts +70 -0
  75. package/dist/src/orchestrator.js +69 -0
  76. package/dist/src/orchestrator.js.map +1 -0
  77. package/dist/src/propagation.d.ts +30 -0
  78. package/dist/src/propagation.js +76 -0
  79. package/dist/src/propagation.js.map +1 -0
  80. package/dist/src/providers/dns/aws.d.ts +28 -0
  81. package/dist/src/providers/dns/aws.js +103 -0
  82. package/dist/src/providers/dns/aws.js.map +1 -0
  83. package/dist/src/providers/dns/cloudflare.d.ts +26 -0
  84. package/dist/src/providers/dns/cloudflare.js +97 -0
  85. package/dist/src/providers/dns/cloudflare.js.map +1 -0
  86. package/dist/src/providers/dns/manual.d.ts +12 -0
  87. package/dist/src/providers/dns/manual.js +18 -0
  88. package/dist/src/providers/dns/manual.js.map +1 -0
  89. package/dist/src/providers/dns/route53.d.ts +34 -0
  90. package/dist/src/providers/dns/route53.js +176 -0
  91. package/dist/src/providers/dns/route53.js.map +1 -0
  92. package/dist/src/providers/email/factory.d.ts +29 -0
  93. package/dist/src/providers/email/factory.js +67 -0
  94. package/dist/src/providers/email/factory.js.map +1 -0
  95. package/dist/src/providers/email/mailgun.d.ts +36 -0
  96. package/dist/src/providers/email/mailgun.js +141 -0
  97. package/dist/src/providers/email/mailgun.js.map +1 -0
  98. package/dist/src/providers/email/postmark.d.ts +37 -0
  99. package/dist/src/providers/email/postmark.js +121 -0
  100. package/dist/src/providers/email/postmark.js.map +1 -0
  101. package/dist/src/providers/email/sendgrid.d.ts +31 -0
  102. package/dist/src/providers/email/sendgrid.js +109 -0
  103. package/dist/src/providers/email/sendgrid.js.map +1 -0
  104. package/dist/src/providers/email/ses.d.ts +39 -0
  105. package/dist/src/providers/email/ses.js +123 -0
  106. package/dist/src/providers/email/ses.js.map +1 -0
  107. package/dist/src/providers/factory.d.ts +14 -0
  108. package/dist/src/providers/factory.js +32 -0
  109. package/dist/src/providers/factory.js.map +1 -0
  110. package/dist/src/records.d.ts +35 -0
  111. package/dist/src/records.js +109 -0
  112. package/dist/src/records.js.map +1 -0
  113. package/dist/src/server.d.ts +67 -0
  114. package/dist/src/server.js +301 -0
  115. package/dist/src/server.js.map +1 -0
  116. package/dist/src/types.d.ts +75 -0
  117. package/dist/src/types.js +2 -0
  118. package/dist/src/types.js.map +1 -0
  119. package/dist/src/xml.d.ts +6 -0
  120. package/dist/src/xml.js +23 -0
  121. package/dist/src/xml.js.map +1 -0
  122. package/docs/billing.md +43 -0
  123. package/docs/getting-started.md +66 -0
  124. package/docs/hosted-api.md +192 -0
  125. package/docs/library.md +253 -0
  126. package/docs/self-hosting.md +153 -0
  127. package/docs/widget.md +121 -0
  128. package/package.json +63 -4
  129. package/web/dashboard.html +669 -0
  130. package/web/index.html +697 -0
  131. package/web/widget-frame.html +480 -0
  132. package/web/widget.js +91 -0
@@ -0,0 +1,653 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { renderMarkdown } from './markdown.js';
3
+ export const FEEDBACK_KINDS = { feature: 'Feature request', feedback: 'Feedback', problem: 'Something isn’t working' };
4
+ export const MAX_FEEDBACK_LENGTH = 5000;
5
+ const DEFAULT_APP_NAME = 'DKIMkit';
6
+ const DEFAULT_EFFECTIVE_DATE = '2026-10-25';
7
+ const DOCS_DIR = new URL('../../../docs/', import.meta.url);
8
+ /** Public docs, in sidebar order. Other files in docs/ (e.g. self-hosting.md) are not published. */
9
+ const DOCS = [
10
+ { slug: 'getting-started', file: 'getting-started.md', label: 'Getting started' },
11
+ { slug: 'hosted-api', file: 'hosted-api.md', label: 'API reference' },
12
+ { slug: 'widget', file: 'widget.md', label: 'Embeddable widget' },
13
+ { slug: 'library', file: 'library.md', label: 'Node.js library and CLI' },
14
+ { slug: 'billing', file: 'billing.md', label: 'Plans and billing', needsBilling: true },
15
+ ];
16
+ const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
17
+ export class Site {
18
+ ctx;
19
+ appName;
20
+ /** Payments are live (not just a free plan). */
21
+ billing;
22
+ docs = new Map();
23
+ constructor(ctx) {
24
+ this.ctx = ctx;
25
+ this.appName = ctx.appName || DEFAULT_APP_NAME;
26
+ this.billing = Boolean(ctx.plan && !ctx.plan.comingSoon);
27
+ const price = ctx.plan ? this.money(ctx.plan.unitAmount) : '';
28
+ for (const doc of DOCS) {
29
+ if ('needsBilling' in doc && doc.needsBilling && !this.billing)
30
+ continue;
31
+ const source = readFileSync(new URL(doc.file, DOCS_DIR), 'utf8')
32
+ // {{#billing}}…{{/billing}}: text that only applies when billing (and so the free plan) is on.
33
+ .replace(/\{\{#billing\}\}([\s\S]*?)\{\{\/billing\}\}/g, (_, inner) => (this.billing ? inner : ''))
34
+ .replaceAll('{{APP_NAME}}', this.appName)
35
+ .replaceAll('{{PUBLIC_URL}}', ctx.publicUrl)
36
+ .replaceAll('{{FREE_DOMAINS}}', String(ctx.plan?.freeDomains ?? ''))
37
+ .replaceAll('{{PRICE}}', price)
38
+ // Examples in the docs use a placeholder host; show this server's.
39
+ .replaceAll('https://dns.example.com', ctx.publicUrl);
40
+ const rendered = renderMarkdown(source, { resolveLink: (href) => this.docLink(href) });
41
+ this.docs.set(doc.slug, { ...rendered, label: doc.label });
42
+ }
43
+ }
44
+ /** Missing settings show as a visible placeholder, so they're noticed before launch. */
45
+ missingSettings() {
46
+ return [
47
+ !this.ctx.legalName && 'LEGAL_NAME',
48
+ !this.ctx.contactEmail && 'CONTACT_EMAIL',
49
+ !this.ctx.legalCountry && 'LEGAL_COUNTRY',
50
+ ].filter((x) => Boolean(x));
51
+ }
52
+ /** Routes a request path to a page, or null if it isn't a site page. */
53
+ render(path) {
54
+ switch (path) {
55
+ case '/':
56
+ return this.page('', this.landing(), 'home');
57
+ case '/pricing':
58
+ return this.ctx.plan ? this.page('Pricing', this.pricing(), 'pricing') : null;
59
+ case '/about':
60
+ return this.page('About', this.about(), 'about');
61
+ case '/contact':
62
+ return this.page('Contact', this.contact(), 'contact');
63
+ case '/feedback':
64
+ return this.feedbackPage();
65
+ case '/terms':
66
+ return this.page('Terms of Service', this.terms(), 'legal');
67
+ case '/privacy':
68
+ return this.page('Privacy Policy', this.privacy(), 'legal');
69
+ case '/refunds':
70
+ return this.page('Refund Policy', this.refunds(), 'legal');
71
+ case '/site.js':
72
+ return {
73
+ status: 200,
74
+ headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'public, max-age=300', 'x-content-type-options': 'nosniff' },
75
+ body: SITE_JS,
76
+ };
77
+ case '/docs':
78
+ case '/docs/':
79
+ return { status: 302, headers: { location: '/docs/getting-started' }, body: '' };
80
+ }
81
+ const doc = /^\/docs\/([\w-]+)$/.exec(path);
82
+ if (doc && this.docs.has(doc[1]))
83
+ return this.page(this.docs.get(doc[1]).title, this.docPage(doc[1]), 'docs');
84
+ return null;
85
+ }
86
+ /** The feedback page, blank or with what was submitted. Its form posts back to /feedback. */
87
+ feedbackPage(form) {
88
+ const page = this.page('Feedback', this.feedback(form), 'feedback', { formAction: true });
89
+ if (form)
90
+ page.headers['cache-control'] = 'no-store';
91
+ if (form?.error)
92
+ page.status = 400;
93
+ return page;
94
+ }
95
+ // ---- helpers ----
96
+ money(cents) {
97
+ const currency = (this.ctx.plan?.currency ?? 'usd').toUpperCase();
98
+ return new Intl.NumberFormat('en-US', { style: 'currency', currency, minimumFractionDigits: cents % 100 ? 2 : 0 }).format(cents / 100);
99
+ }
100
+ docLink(href) {
101
+ // Only links to published docs survive; others (e.g. self-hosting.md) render as plain text.
102
+ const [target, hash] = href.split('#');
103
+ const doc = DOCS.find((d) => d.file === target || `/docs/${d.slug}` === target);
104
+ if (!doc || ('needsBilling' in doc && doc.needsBilling && !this.billing))
105
+ return null;
106
+ return `/docs/${doc.slug}${hash ? `#${hash}` : ''}`;
107
+ }
108
+ /** A value the operator must configure; shown highlighted until it is. */
109
+ setting(value, name) {
110
+ return value ? esc(value) : `<mark title="Set ${name}">[${name}]</mark>`;
111
+ }
112
+ get operator() {
113
+ return this.setting(this.ctx.legalName, 'LEGAL_NAME');
114
+ }
115
+ get email() {
116
+ return this.ctx.contactEmail
117
+ ? `<a href="mailto:${esc(this.ctx.contactEmail)}">${esc(this.ctx.contactEmail)}</a>`
118
+ : this.setting(undefined, 'CONTACT_EMAIL');
119
+ }
120
+ get effective() {
121
+ const date = this.ctx.effectiveDate || DEFAULT_EFFECTIVE_DATE;
122
+ return new Date(`${date}T00:00:00Z`).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' });
123
+ }
124
+ cta(primary = 'Start free') {
125
+ return this.ctx.signupEnabled ? `<a class="btn primary" href="/dashboard">${primary}</a>` : '';
126
+ }
127
+ // ---- layout ----
128
+ page(title, main, section, opts = {}) {
129
+ const app = esc(this.appName);
130
+ const nav = (href, label, key) => `<a href="${href}"${section === key ? ' aria-current="page"' : ''}>${label}</a>`;
131
+ const year = new Date().getUTCFullYear();
132
+ const body = `<!doctype html>
133
+ <html lang="en">
134
+ <head>
135
+ <meta charset="utf-8">
136
+ <meta name="viewport" content="width=device-width, initial-scale=1">
137
+ <title>${title ? `${esc(title)} · ${app}` : `${app}: verified sending domains for your customers`}</title>
138
+ <meta name="description" content="${app} authenticates your customers' sending domains with SendGrid, Postmark, Mailgun or Amazon SES: DNS records written or shown, verification tracked, webhooks when ready.">
139
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%233b5bdb'/%3E%3Cpath d='M8 11h16v11H8z M8 12l8 6 8-6' fill='none' stroke='white' stroke-width='2.2' stroke-linejoin='round'/%3E%3C/svg%3E">
140
+ <style>${CSS}</style>
141
+ <script src="/site.js" defer></script>
142
+ </head>
143
+ <body>
144
+ <header class="site">
145
+ <div class="wrap bar">
146
+ <div class="hist" hidden>${HISTORY_BUTTONS}</div>
147
+ <a class="brand" href="/"><span class="mark" aria-hidden="true">${LOGO}</span>${app}</a>
148
+ <nav aria-label="Main">
149
+ ${nav('/docs/getting-started', 'Docs', 'docs')}
150
+ ${this.ctx.plan ? nav('/pricing', 'Pricing', 'pricing') : ''}
151
+ ${nav('/about', 'About', 'about')}
152
+ ${this.ctx.signupEnabled ? '<a class="btn small" href="/dashboard">Sign in</a>' : ''}
153
+ </nav>
154
+ </div>
155
+ </header>
156
+ <main>${main}</main>
157
+ <footer class="site">
158
+ <div class="wrap foot">
159
+ <div>© ${year} ${this.operator}</div>
160
+ <nav aria-label="Footer">
161
+ <a href="/docs/getting-started">Docs</a>
162
+ ${this.ctx.plan ? '<a href="/pricing">Pricing</a>' : ''}
163
+ <a href="/about">About</a>
164
+ <a href="/contact">Contact</a>
165
+ <a href="/feedback">Feedback</a>
166
+ <a href="/terms">Terms</a>
167
+ <a href="/privacy">Privacy</a>
168
+ ${this.ctx.plan ? '<a href="/refunds">Refunds</a>' : ''}
169
+ </nav>
170
+ </div>
171
+ </footer>
172
+ </body>
173
+ </html>`;
174
+ return {
175
+ status: 200,
176
+ headers: {
177
+ 'content-type': 'text/html; charset=utf-8',
178
+ 'cache-control': 'public, max-age=300',
179
+ 'x-content-type-options': 'nosniff',
180
+ 'referrer-policy': 'strict-origin-when-cross-origin',
181
+ 'content-security-policy': `default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; img-src 'self' data:; base-uri 'none'; form-action ${opts.formAction ? "'self'" : "'none'"}; frame-ancestors 'none'`,
182
+ },
183
+ body,
184
+ };
185
+ }
186
+ // ---- pages ----
187
+ landing() {
188
+ const plan = this.ctx.plan;
189
+ const features = [
190
+ ['Writes the DNS records', 'Connect a customer’s Cloudflare or Route 53 and the records are added for them. On any other host, they get the exact records to paste.'],
191
+ ['Watches until verified', 'Checks the authoritative nameservers, then your email provider, with backoff. Re-checks verified domains daily and tells you if one breaks.'],
192
+ ['Embeddable widget', 'Drop a setup flow into your app with two lines of code. Customers’ DNS credentials never touch your servers or your frontend.'],
193
+ ['Signed webhooks', 'Get domain.verified, domain.conflict and more as they happen, signed and retried, so your app updates itself.'],
194
+ ['Inbound email too', 'Optionally route replies to your app: the MX record and inbound forwarding are configured in the same flow (SendGrid, Postmark and Mailgun).'],
195
+ ['Bulk setup, no code', 'Paste up to 100 domains from any registrar into the dashboard. Each gets sender authentication, and inbound email if you want it, with records grouped by DNS host and a zone file to import.'],
196
+ ];
197
+ return `
198
+ <section class="hero wrap">
199
+ <h1>Your customers’ sending domains, <span class="accent">verified automatically</span></h1>
200
+ <p class="lead">${esc(this.appName)} handles sending-domain authentication with SendGrid, Postmark, Mailgun or Amazon SES for every customer of your app: DNS records written or shown, verification tracked, and a webhook when each domain is ready to send.</p>
201
+ <div class="row">${this.cta()}<a class="btn" href="/docs/getting-started">Read the docs</a></div>
202
+ ${plan ? `<p class="small muted">Free for up to ${plan.freeDomains} domains. No credit card required.</p>` : ''}
203
+ </section>
204
+
205
+ <section class="wrap band">
206
+ <h2>How it works</h2>
207
+ <ol class="steps">
208
+ <li><b>Register the domain</b><span>One API call from your backend, or let customers enter it in the widget.</span></li>
209
+ <li><b>DNS gets set up</b><span>We write the records through Cloudflare or Route 53, or show your customer exactly what to add.</span></li>
210
+ <li><b>Verified, and you’re told</b><span>We track propagation and your provider’s verification, then send you a signed webhook.</span></li>
211
+ </ol>
212
+ </section>
213
+
214
+ <section class="wrap">
215
+ <div class="grid3">${features.map(([t, d]) => `<div class="card"><h3>${t}</h3><p>${d}</p></div>`).join('')}</div>
216
+ </section>
217
+
218
+ <section class="wrap split">
219
+ <div>
220
+ <h2>One call to start</h2>
221
+ <p>Register a customer’s domain and get the DNS records back immediately. Everything after that happens in the background. Tag domains with your own tenant ids and see usage per customer for your own billing.</p>
222
+ <a href="/docs/hosted-api">API reference →</a>
223
+ </div>
224
+ <pre><code>curl -X POST ${esc(this.ctx.publicUrl)}/v1/domains \\
225
+ -H "Authorization: Bearer $API_KEY" \\
226
+ -d '{"domain": "customer.com",
227
+ "tenant_id": "cus_42",
228
+ "dns": {"provider": "cloudflare",
229
+ "cloudflare_api_token": "…"}}'</code></pre>
230
+ </section>
231
+
232
+ ${this.roadmap()}
233
+
234
+ <section class="wrap band">
235
+ <h2>Credentials handled with care</h2>
236
+ <p class="muted">Email provider keys and DNS tokens are encrypted at rest (AES-256-GCM), API keys are stored only as hashes, and the widget runs on our domain so your customers’ DNS credentials never pass through your app. <a href="/privacy">How we handle data</a></p>
237
+ </section>
238
+
239
+ ${plan ? `<section class="wrap center">
240
+ <h2>${plan.comingSoon ? 'Start free' : 'Start free, pay as you grow'}</h2>
241
+ <p class="muted">${plan.freeDomains} domains free. ${plan.comingSoon ? 'Paid plans with unlimited domains are coming soon.' : `Then ${this.money(plan.unitAmount)} per active domain per month.`}</p>
242
+ <div class="row center">${this.cta('Create your free account')}<a class="btn" href="/pricing">See pricing</a></div>
243
+ </section>` : ''}`;
244
+ }
245
+ /** What's being built now, then what's planned after it. */
246
+ roadmap() {
247
+ const soon = [
248
+ ...(this.ctx.plan?.comingSoon ? [['Paid plans', 'Add a payment method to go past the free plan’s domains. Pay per active domain, monthly, cancel any time.']] : []),
249
+ ['Managed DNS', 'Point your domain’s nameservers to us once, and we handle every record from then on, at any registrar.'],
250
+ ['Buy domains in bulk', 'Search for and register many sending domains at once, right from the dashboard. Each one is authenticated automatically, with no registrar account or DNS setup.'],
251
+ ];
252
+ const planned = [
253
+ ['More DNS providers', 'Records written automatically at GoDaddy, Namecheap and DigitalOcean, plus Domain Connect where your registrar supports it.'],
254
+ ['One-click DNS connect', 'Sign in to your DNS provider instead of creating and pasting an API token.'],
255
+ ['Inbound email for Amazon SES', 'Receive email on SES domains too, delivered to your webhook like the other providers.'],
256
+ ];
257
+ const items = (list, tag) => list.map(([t, d]) => `<li><b>${t} <span class="soon">${tag}</span></b><span>${d}</span></li>`).join('');
258
+ return `<section class="wrap">
259
+ <h2>What’s next</h2>
260
+ <p class="muted">What we’re building now and what’s planned after it. Want something else? <a href="/feedback">Request a feature</a>.</p>
261
+ <ul class="roadmap">${items(soon, 'Coming soon')}${items(planned, 'Planned')}</ul>
262
+ </section>`;
263
+ }
264
+ pricing() {
265
+ const p = this.ctx.plan;
266
+ if (p.comingSoon)
267
+ return this.pricingComingSoon(p.freeDomains);
268
+ const price = this.money(p.unitAmount);
269
+ const faq = [
270
+ ['What is an active domain?', `A domain that was verified on at least one day of the billing period. Each domain counts once per period, however many days it was active. Domains that never verify aren’t counted.`],
271
+ ['What happens when I reach the free limit?', `Creating more than ${p.freeDomains} domains is blocked until you add a payment method. Your existing domains keep working.`],
272
+ ['How am I billed?', `Monthly, for the active domains in each billing period. The first ${p.freeDomains} are always free. Your dashboard shows the estimated amount as the month goes.`],
273
+ ['Can I cancel?', 'Yes, any time from Manage billing in your dashboard. Your domains keep working and you return to the free plan’s limit.'],
274
+ ['Do prices include tax?', 'Payments are handled by Dodo Payments, our merchant of record, which adds any VAT, GST or sales tax that applies where you are.'],
275
+ ];
276
+ return `
277
+ <section class="wrap hero small-hero">
278
+ <h1>Simple, usage-based pricing</h1>
279
+ <p class="lead">Start free. Pay only for the domains your customers actually use.</p>
280
+ </section>
281
+ <section class="wrap">
282
+ <div class="plans">
283
+ <div class="plan">
284
+ <h2>Free</h2>
285
+ <div class="price">${this.money(0)}<small> forever</small></div>
286
+ <ul>
287
+ <li>Up to ${p.freeDomains} domains</li>
288
+ <li>API and embeddable widget</li>
289
+ <li>Cloudflare, Route 53 and manual DNS</li>
290
+ <li>Signed webhooks and usage reports</li>
291
+ <li>No credit card required</li>
292
+ </ul>
293
+ ${this.cta('Start free')}
294
+ </div>
295
+ <div class="plan featured">
296
+ <h2>Pay as you go</h2>
297
+ <div class="price">${price}<small> / active domain / month</small></div>
298
+ <ul>
299
+ <li>First ${p.freeDomains} active domains free</li>
300
+ <li>Unlimited domains</li>
301
+ <li>Everything in Free</li>
302
+ <li>Billed monthly for what you use</li>
303
+ <li>Cancel any time</li>
304
+ </ul>
305
+ ${this.cta('Get started')}
306
+ </div>
307
+ </div>
308
+ <p class="small muted center">Example: 10 active domains in a month = (10 − ${p.freeDomains}) × ${price} = ${this.money((10 - p.freeDomains) * p.unitAmount)}.</p>
309
+ </section>
310
+ <section class="wrap narrow">
311
+ <h2>Questions</h2>
312
+ ${faq.map(([q, a]) => `<details><summary>${q}</summary><p>${a}</p></details>`).join('')}
313
+ </section>`;
314
+ }
315
+ /** Before payments launch: the free plan, and a paid plan marked as coming soon. */
316
+ pricingComingSoon(freeDomains) {
317
+ const faq = [
318
+ ['What happens when I reach the free limit?', `Adding more than ${freeDomains} domains is blocked for now. Your existing domains keep working. Need more today? Email ${this.email}.`],
319
+ ['When will paid plans be available?', 'Soon. You’ll be able to add a payment method from your dashboard to unlock unlimited domains. We’ll announce prices before anything is charged.'],
320
+ ['Will my free domains stay free?', `Yes. The first ${freeDomains} domains stay free when paid plans launch.`],
321
+ ];
322
+ return `
323
+ <section class="wrap hero small-hero">
324
+ <h1>Simple pricing</h1>
325
+ <p class="lead">Start free today. Paid plans for more domains are coming soon.</p>
326
+ </section>
327
+ <section class="wrap">
328
+ <div class="plans">
329
+ <div class="plan featured">
330
+ <h2>Free</h2>
331
+ <div class="price">${this.money(0)}<small> forever</small></div>
332
+ <ul>
333
+ <li>Up to ${freeDomains} domains</li>
334
+ <li>Bulk setup from the dashboard</li>
335
+ <li>Sender authentication and inbound email</li>
336
+ <li>API, embeddable widget and signed webhooks</li>
337
+ <li>No credit card required</li>
338
+ </ul>
339
+ ${this.cta('Start free')}
340
+ </div>
341
+ <div class="plan">
342
+ <h2>Pay as you go <span class="soon">Coming soon</span></h2>
343
+ <div class="price"><small>Usage-based, per active domain per month</small></div>
344
+ <ul>
345
+ <li>Unlimited domains</li>
346
+ <li>First ${freeDomains} domains still free</li>
347
+ <li>Everything in Free</li>
348
+ <li>Billed monthly for what you use</li>
349
+ <li>Cancel any time</li>
350
+ </ul>
351
+ <span class="btn" aria-disabled="true">Coming soon</span>
352
+ </div>
353
+ </div>
354
+ </section>
355
+ <section class="wrap narrow">
356
+ <h2>Questions</h2>
357
+ ${faq.map(([q, a]) => `<details><summary>${q}</summary><p>${a}</p></details>`).join('')}
358
+ </section>`;
359
+ }
360
+ about() {
361
+ const country = this.ctx.legalCountry ? `, based in ${esc(this.ctx.legalCountry)}` : '';
362
+ return `
363
+ <article class="wrap narrow prose">
364
+ <h1>About ${esc(this.appName)}</h1>
365
+ <p>${esc(this.appName)} helps software companies let their customers send email from their own domains.</p>
366
+ <p>Authenticating a sending domain with an email provider like SendGrid, Postmark, Mailgun or Amazon SES means creating DNS records, getting them added at the right DNS host, waiting for them to propagate, and confirming with the provider. Each step can go wrong, and each one usually ends up as a support ticket. ${esc(this.appName)} turns that into one API call, or a widget your customers complete themselves, and lets your app know the moment a domain is ready.</p>
367
+ <h2>Who we are</h2>
368
+ <p>${esc(this.appName)} is built and operated by ${this.operator}${country}.</p>
369
+ <h2>Get in touch</h2>
370
+ <p>Questions? Email ${this.email} or see our <a href="/contact">contact page</a>. Feedback or a feature you need? <a href="/feedback">Tell us</a>.</p>
371
+ </article>`;
372
+ }
373
+ contact() {
374
+ return `
375
+ <article class="wrap narrow prose">
376
+ <h1>Contact</h1>
377
+ <p>Email ${this.email}. We read every message and will get back to you as soon as we can.</p>
378
+ <h2>Before you write</h2>
379
+ <ul>
380
+ <li><b>Setting up a domain?</b> The <a href="/docs/getting-started">getting started guide</a> and <a href="/docs/hosted-api">API reference</a> cover most questions.</li>
381
+ ${this.billing ? '<li><b>Billing, invoices or cancelling?</b> Use <b>Manage billing</b> in your <a href="/dashboard">dashboard</a>. For anything else about a charge, email us with your account email.</li>' : ''}
382
+ <li><b>Feedback or a feature request?</b> Use the <a href="/feedback">feedback form</a>.</li>
383
+ <li><b>Found a security issue?</b> Email us with “Security” in the subject. Please don’t disclose it publicly until we’ve had a chance to fix it.</li>
384
+ </ul>
385
+ <h2>Operator</h2>
386
+ <p>${this.operator}${this.ctx.legalCountry ? `, ${esc(this.ctx.legalCountry)}` : ''}</p>
387
+ </article>`;
388
+ }
389
+ feedback(form) {
390
+ if (form?.sent) {
391
+ return `
392
+ <article class="wrap narrow prose">
393
+ <h1>Thanks for the feedback</h1>
394
+ <p>It’s on its way to the team. We read every message${form.email ? ' and will reply by email if we have questions' : ''}.</p>
395
+ <p><a class="btn" href="/feedback">Send more</a> <a class="btn" href="/">Back to home</a></p>
396
+ </article>`;
397
+ }
398
+ const kind = form?.kind ?? 'feature';
399
+ const kinds = Object.entries(FEEDBACK_KINDS)
400
+ .map(([value, label]) => `<label class="choice"><input type="radio" name="kind" value="${value}"${value === kind ? ' checked' : ''}> ${label}</label>`)
401
+ .join('');
402
+ return `
403
+ <article class="wrap narrow prose">
404
+ <h1>Feedback and feature requests</h1>
405
+ <p>Tell us what’s working, what isn’t, or what you’d like ${esc(this.appName)} to do next. Requests from users decide what we build, and every message is read.</p>
406
+ ${form?.error ? `<p class="error" role="alert">${esc(form.error)}</p>` : ''}
407
+ <form class="form" method="post" action="/feedback">
408
+ <fieldset><legend>What’s it about?</legend>${kinds}</fieldset>
409
+ <label>Your message
410
+ <textarea name="message" rows="7" maxlength="${MAX_FEEDBACK_LENGTH}" required placeholder="What would help you, and what are you trying to do?">${esc(form?.message ?? '')}</textarea>
411
+ </label>
412
+ <label>Your email <span class="muted">(optional, so we can reply)</span>
413
+ <input type="email" name="email" maxlength="254" autocomplete="email" value="${esc(form?.email ?? '')}">
414
+ </label>
415
+ <div class="hp" aria-hidden="true"><label>Leave this empty <input name="website" tabindex="-1" autocomplete="off"></label></div>
416
+ <button class="btn primary" type="submit">Send</button>
417
+ </form>
418
+ <p class="muted small">Reporting a problem with a domain? Include the domain name and, if you have one, the request ID from the error. For anything private, email ${this.email}.</p>
419
+ </article>`;
420
+ }
421
+ docPage(slug) {
422
+ const doc = this.docs.get(slug);
423
+ const sidebar = [...this.docs.entries()]
424
+ .map(([s, d]) => `<a href="/docs/${s}"${s === slug ? ' aria-current="page"' : ''}>${esc(d.label)}</a>`)
425
+ .join('');
426
+ const toc = doc.headings.filter((h) => h.level === 2);
427
+ return `
428
+ <div class="wrap docs">
429
+ <aside>
430
+ <nav aria-label="Documentation">${sidebar}</nav>
431
+ ${toc.length > 1 ? `<div class="toc"><div class="small muted">On this page</div>${toc.map((h) => `<a href="#${esc(h.id)}">${h.text}</a>`).join('')}</div>` : ''}
432
+ </aside>
433
+ <article class="prose">${doc.html}</article>
434
+ </div>`;
435
+ }
436
+ // ---- legal (drafts: the operator should have these reviewed) ----
437
+ legal(title, sections) {
438
+ return `
439
+ <article class="wrap narrow prose legal">
440
+ <h1>${title}</h1>
441
+ <p class="muted">Last updated ${this.effective}</p>
442
+ ${sections.map(([h, body]) => `<h2>${h}</h2>${body}`).join('\n')}
443
+ </article>`;
444
+ }
445
+ terms() {
446
+ const app = esc(this.appName);
447
+ const p = this.billing ? this.ctx.plan : undefined;
448
+ const free = this.ctx.plan?.comingSoon ? this.ctx.plan.freeDomains : null;
449
+ return this.legal('Terms of Service', [
450
+ ['1. Agreement', `<p>These terms govern your use of ${app} (the “Service”), operated by ${this.operator} (“we”, “us”). By creating an account or using the Service you agree to them. If you use the Service for an organization, you accept them on its behalf.</p>`],
451
+ ['2. The Service', `<p>The Service creates and verifies email sending domains with your chosen email provider (SendGrid, Postmark, Mailgun or Amazon SES) on your behalf. It can write DNS records through DNS providers you or your customers connect, check DNS and verification status, configure inbound email, and notify your systems by webhook. It relies on third-party services (your email provider, DNS providers and our payment provider), whose own terms also apply to your use of them.</p>`],
452
+ ['3. Your account', `<p>You sign in with a link sent to your email address. Keep your email account and API keys secure: anyone with them can act on your account. You’re responsible for activity under your account, and you must tell us promptly about any unauthorized use.</p>`],
453
+ ['4. Domains and authorization', `<p>Only set up domains, and only connect DNS providers or email provider accounts, that you or your customers own or are authorized to manage. If your customers connect their DNS through you, you’re responsible for having their permission and for giving them any notices required by law. We use the credentials you or your customers provide only to perform the operations you request.</p>`],
454
+ ['5. Acceptable use', `<p>You must not use the Service to:</p><ul><li>send spam or unsolicited email, or break anti-spam laws or your email provider’s acceptable use policy;</li><li>configure domains you don’t control or impersonate others;</li><li>disrupt, overload or probe the Service or anyone else’s systems;</li><li>break any law that applies to you.</li></ul><p>We may suspend accounts that do.</p>`],
455
+ ['6. Plans, fees and payment', p
456
+ ? `<p>The free plan includes up to ${p.freeDomains} domains. The pay-as-you-go plan is billed monthly per active domain beyond the free allowance, at the prices shown on our <a href="/pricing">pricing page</a>. An active domain is one verified on at least one day of the billing period. Payments are processed by Dodo Payments as merchant of record, which may add applicable taxes and whose terms apply to the purchase. We’ll give at least 30 days’ notice of price increases.</p>`
457
+ : `<p>The Service is currently provided without charge${free !== null ? `, on a free plan that includes up to ${free} domains` : ''}. If we introduce fees, we’ll tell you in advance and you can stop using the Service before they apply.</p>`],
458
+ ['7. Cancellation and termination', `<p>You can stop using the Service ${p ? 'and cancel your subscription ' : ''}at any time${p ? ' from your dashboard' : ''}. We may suspend or end your access if you breach these terms${p ? ' or don’t pay amounts due' : ''}, and we’ll tell you why where we reasonably can. After your account ends, we delete your data as described in our <a href="/privacy">Privacy Policy</a>.</p>`],
459
+ ['8. Your data', `<p>You keep all rights to the data you provide. You allow us to use it only to provide, secure and improve the Service. Our <a href="/privacy">Privacy Policy</a> explains how we handle personal data.</p>`],
460
+ ['9. Availability and changes', `<p>We work to keep the Service reliable but don’t guarantee it will be uninterrupted or error-free, and it depends on third-party services we don’t control. We may change or discontinue features; if we discontinue the Service, we’ll give reasonable notice.</p>`],
461
+ ['10. Disclaimer', `<p>The Service is provided “as is” and “as available”. To the extent the law allows, we disclaim all warranties, including merchantability, fitness for a particular purpose and non-infringement. We don’t guarantee that email sent from authenticated domains will be delivered.</p>`],
462
+ ['11. Limitation of liability', `<p>To the extent the law allows, we are not liable for indirect, incidental, special, consequential or punitive damages, or for lost profits, revenue or data. Our total liability for any claim relating to the Service is limited to the amount you paid us in the 12 months before the claim${p ? '' : ' (or zero, if you paid nothing)'}. Nothing in these terms limits liability that can’t be limited by law.</p>`],
463
+ ['12. Indemnity', `<p>You’ll defend and indemnify us against claims arising from your use of the Service, the domains and content you or your customers send, or your breach of these terms.</p>`],
464
+ ['13. Changes to these terms', `<p>We may update these terms. We’ll post the new version here and, for material changes, tell you by email at least 30 days before they take effect. Continuing to use the Service after that means you accept them.</p>`],
465
+ ['14. Governing law', `<p>These terms are governed by the laws of ${this.setting(this.ctx.legalCountry, 'LEGAL_COUNTRY')}, and its courts have jurisdiction over disputes, except where the law where you live says otherwise.</p>`],
466
+ ['15. Contact', `<p>Questions about these terms: ${this.email}.</p>`],
467
+ ]);
468
+ }
469
+ privacy() {
470
+ const app = esc(this.appName);
471
+ const p = this.billing;
472
+ return this.legal('Privacy Policy', [
473
+ ['Who we are', `<p>${app} is operated by ${this.operator}${this.ctx.legalCountry ? `, ${esc(this.ctx.legalCountry)}` : ''}, which is responsible for your personal data. Contact: ${this.email}.</p>`],
474
+ ['What we collect', `<ul>
475
+ <li><b>Account details:</b> your email address and the name you give us.</li>
476
+ <li><b>Feedback you send:</b> your message and, if you give it, your email address, so we can reply.</li>
477
+ <li><b>Credentials you provide:</b> email provider API keys and DNS provider tokens or keys. They’re encrypted at rest and used only to perform the operations you request. Our own API keys are stored only as one-way hashes.</li>
478
+ <li><b>Domain data:</b> domain names, DNS records, verification status, and any tenant ids or metadata you send with them.</li>
479
+ <li><b>Usage records:</b> which domains were active on which days, used to calculate usage and bills.</li>
480
+ <li><b>Technical data:</b> IP addresses and request details in our server logs, used for security, rate limiting and troubleshooting.</li>
481
+ ${p ? '<li><b>Billing data:</b> your payment provider customer id and subscription status. Card details are collected by Dodo Payments and never reach us.</li>' : ''}
482
+ </ul>`],
483
+ ['Cookies', `<p>We use one cookie, a session cookie that keeps you signed in to the dashboard. It’s strictly necessary for the Service. We don’t use advertising or analytics cookies, and we don’t track you across other sites.</p>`],
484
+ ['How we use it', `<p>To provide and secure the Service, send sign-in emails and service notices, calculate usage${p ? ' and bill you' : ''}, respond to you, and meet legal obligations. We don’t sell personal data, and we don’t use it for advertising.</p>`],
485
+ ['Who we share it with', `<p>Only service providers that help us run the Service, under appropriate safeguards:</p><ul>
486
+ <li><b>The email provider you connect</b> (SendGrid, Postmark, Mailgun or Amazon SES): to authenticate your domains.</li>
487
+ ${this.ctx.mailProvider ? `<li><b>${esc(this.ctx.mailProvider)}:</b> sending our sign-in emails.</li>` : ''}
488
+ <li><b>DNS providers you connect</b> (such as Cloudflare or Amazon Route 53): to manage the records you ask us to.</li>
489
+ ${p ? '<li><b>Dodo Payments:</b> payment processing as merchant of record.</li>' : ''}
490
+ <li><b>Our hosting and database providers:</b> to store and run the Service.</li>
491
+ </ul><p>We may also disclose data when the law requires it.</p>`],
492
+ ['International transfers', `<p>Our providers may process data in countries other than yours. Where the law requires it, we rely on appropriate safeguards for these transfers.</p>`],
493
+ ['How long we keep it', `<p>Account data, credentials and domain data are kept while your account exists, and credentials are deleted as soon as you remove them or the domain. When you delete a domain, its usage records stay, because we need them for billing and accounting. When your account is closed, we delete your data within 30 days, except records we must keep by law. Server logs are kept for a limited time for security.</p>`],
494
+ ['Security', `<p>Connections use HTTPS. Credentials are encrypted with AES-256-GCM and API keys are hashed. Access to production systems is restricted. No system is perfectly secure, but we work to protect your data and will notify you of a breach where the law requires it.</p>`],
495
+ ['Your rights', `<p>Depending on where you live, including under the EU and UK GDPR and India’s Digital Personal Data Protection Act, 2023, you may have the right to access, correct, delete or export your personal data, to object to or restrict some processing, and to withdraw consent. To exercise them, email ${this.email}. You can also complain to your data protection authority.</p>`],
496
+ ['Your customers’ data', `<p>If you use the Service for your own customers, you decide what data you send us about them, and we process it on your behalf to provide the Service.</p>`],
497
+ ['Children', `<p>The Service is for businesses and isn’t intended for children.</p>`],
498
+ ['Changes', `<p>We’ll post updates here and tell you by email about significant changes.</p>`],
499
+ ]);
500
+ }
501
+ refunds() {
502
+ const p = this.ctx.plan;
503
+ if (p?.comingSoon) {
504
+ return this.legal('Refund Policy', [
505
+ ['Free plan', `<p>The free plan costs nothing, so there’s nothing to refund.</p>`],
506
+ ['Paid plans', `<p>Paid plans aren’t available yet, so nothing is charged. We’ll publish their refund terms here before they launch.</p>`],
507
+ ['Questions', `<p>Contact ${this.email}.</p>`],
508
+ ]);
509
+ }
510
+ return this.legal('Refund Policy', [
511
+ ['Free plan', `<p>The free plan costs nothing, so there’s nothing to refund.</p>`],
512
+ ['Pay as you go', `<p>You’re billed monthly in arrears for the active domains you actually used in each billing period${p ? `, after the first ${p.freeDomains}, which are free` : ''}. Because you only pay for usage that has already happened, charges are generally not refundable.</p><p>You can cancel at any time from <b>Manage billing</b> in your dashboard to stop future charges. You won’t be charged for periods after you cancel.</p>`],
513
+ ['Billing mistakes', `<p>If you were charged incorrectly, for example for a domain that wasn’t active or because of a technical error on our side, email ${this.email} within 30 days of the charge with your account email and the invoice. We’ll correct it and refund the difference.</p>`],
514
+ ['How refunds are paid', `<p>Payments are processed by Dodo Payments, our merchant of record. Approved refunds go back to your original payment method through Dodo Payments; how long it takes to appear depends on your bank or card issuer.</p>`],
515
+ ['Questions', `<p>Contact ${this.email}.</p>`],
516
+ ]);
517
+ }
518
+ }
519
+ const LOGO = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></svg>`;
520
+ const HISTORY_BUTTONS = '<button type="button" data-history="back" aria-label="Back" title="Back"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg></button>' +
521
+ '<button type="button" data-history="forward" aria-label="Forward" title="Forward"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg></button>';
522
+ /** Back and Forward buttons. Where the browser says whether there's somewhere to go, they're disabled when there isn't. */
523
+ const SITE_JS = `(() => {
524
+ const box = document.querySelector('.hist');
525
+ if (!box) return;
526
+ const back = box.querySelector('[data-history="back"]');
527
+ const forward = box.querySelector('[data-history="forward"]');
528
+ back.addEventListener('click', () => history.back());
529
+ forward.addEventListener('click', () => history.forward());
530
+ const nav = window.navigation;
531
+ const update = () => {
532
+ if (!nav || !('canGoBack' in nav)) return;
533
+ back.disabled = !nav.canGoBack;
534
+ forward.disabled = !nav.canGoForward;
535
+ };
536
+ update();
537
+ if (nav) nav.addEventListener('currententrychange', update);
538
+ addEventListener('pageshow', update);
539
+ box.hidden = false;
540
+ })();
541
+ `;
542
+ const CSS = `
543
+ :root { --bg:#ffffff; --bg-2:#f6f7f9; --surface:#ffffff; --border:#e2e5ea; --text:#15181d; --muted:#5d6573; --accent:#3b5bdb; --accent-hover:#3249b8; --accent-soft:#e8edff; --on-accent:#fff; --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; color-scheme: light; }
544
+ @media (prefers-color-scheme: dark) { :root { --bg:#0f1115; --bg-2:#141821; --surface:#171a20; --border:#2a2f39; --text:#e8eaee; --muted:#9aa3b2; --accent:#7b93ff; --accent-hover:#93a7ff; --accent-soft:#1f2645; --on-accent:#0b0f1f; color-scheme: dark; } }
545
+ * { box-sizing: border-box; }
546
+ body { margin:0; background:var(--bg); color:var(--text); font:16px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif; }
547
+ a { color:var(--accent); }
548
+ /* Horizontal padding only: sections set their own vertical spacing. */
549
+ .wrap { max-width:1080px; margin:0 auto; padding-left:20px; padding-right:20px; }
550
+ .narrow { max-width:760px; }
551
+ header.site { border-bottom:1px solid var(--border); background:var(--surface); position:sticky; top:0; z-index:1; }
552
+ .bar { display:flex; align-items:center; gap:16px; min-height:60px; flex-wrap:wrap; }
553
+ .hist { display:flex; gap:4px; }
554
+ .hist[hidden] { display:none; }
555
+ .hist button { width:32px; height:32px; display:grid; place-items:center; padding:0; border:1px solid var(--border); border-radius:8px; background:var(--surface); color:var(--text); cursor:pointer; }
556
+ .hist button:hover:not(:disabled) { background:var(--bg-2); }
557
+ .hist button:disabled { opacity:.4; cursor:default; }
558
+ .hist button:focus-visible { outline:2px solid var(--accent); outline-offset:1px; }
559
+ .brand { display:flex; align-items:center; gap:10px; font-weight:650; color:var(--text); text-decoration:none; }
560
+ .mark { width:28px; height:28px; border-radius:8px; background:var(--accent); color:var(--on-accent); display:grid; place-items:center; }
561
+ header nav { margin-left:auto; display:flex; gap:18px; align-items:center; flex-wrap:wrap; }
562
+ header nav a { color:var(--muted); text-decoration:none; font-size:15px; }
563
+ header nav a:hover, header nav a[aria-current] { color:var(--text); }
564
+ .btn { display:inline-block; font-weight:550; font-size:15px; border-radius:8px; padding:10px 18px; border:1px solid var(--border); background:var(--surface); color:var(--text) !important; text-decoration:none; }
565
+ .btn:hover { background:var(--bg-2); }
566
+ .btn.primary { background:var(--accent); border-color:var(--accent); color:var(--on-accent) !important; }
567
+ .btn.primary:hover { background:var(--accent-hover); }
568
+ .btn.small { padding:6px 14px; font-size:14px; }
569
+ .btn[aria-disabled="true"] { opacity:.6; cursor:default; justify-self:start; }
570
+ .btn[aria-disabled="true"]:hover { background:var(--surface); }
571
+ .soon { display:inline-block; vertical-align:middle; margin-left:6px; font-size:12px; font-weight:600; padding:2px 10px; border-radius:99px; background:var(--accent-soft); color:var(--accent); }
572
+ .row { display:flex; gap:12px; flex-wrap:wrap; align-items:center; }
573
+ .center { text-align:center; justify-content:center; }
574
+ .muted { color:var(--muted); }
575
+ .small { font-size:14px; }
576
+ section { padding:56px 0 0; }
577
+ .hero { padding-top:80px; max-width:820px; }
578
+ .small-hero { padding-top:56px; }
579
+ .hero h1 { font-size:clamp(32px,5vw,52px); line-height:1.1; letter-spacing:-.02em; margin:0 0 18px; }
580
+ .accent { color:var(--accent); }
581
+ .lead { font-size:19px; color:var(--muted); margin:0 0 26px; }
582
+ h2 { font-size:26px; letter-spacing:-.01em; margin:0 0 14px; }
583
+ .band h2 { margin-bottom:22px; }
584
+ .steps { list-style:none; counter-reset:s; padding:0; margin:0; display:grid; grid-template-columns:repeat(3,1fr); gap:16px; }
585
+ .steps li { counter-increment:s; background:var(--bg-2); border:1px solid var(--border); border-radius:12px; padding:20px; display:grid; gap:6px; align-content:start; }
586
+ .steps li::before { content:counter(s); width:28px; height:28px; border-radius:50%; background:var(--accent-soft); color:var(--accent); display:grid; place-items:center; font-weight:700; font-size:14px; }
587
+ .steps span { color:var(--muted); font-size:15px; }
588
+ .grid3 { display:grid; grid-template-columns:repeat(3,1fr); gap:16px; }
589
+ .roadmap { list-style:none; padding:0; margin:0; display:grid; grid-template-columns:repeat(2,1fr); gap:12px; }
590
+ .roadmap li { border:1px solid var(--border); border-radius:12px; padding:16px 18px; display:grid; gap:4px; background:var(--surface); }
591
+ .roadmap b .soon { margin-left:8px; }
592
+ .roadmap span:not(.soon) { color:var(--muted); font-size:15px; }
593
+ .form { display:grid; gap:16px; margin:20px 0; }
594
+ .form label { display:grid; gap:6px; font-weight:550; }
595
+ .form fieldset { border:0; padding:0; margin:0; display:flex; gap:8px 18px; flex-wrap:wrap; }
596
+ .form legend { font-weight:550; padding:0; margin-bottom:6px; }
597
+ .form .choice { display:flex; gap:6px; align-items:center; font-weight:400; }
598
+ .form textarea, .form input[type=email] { font:inherit; font-weight:400; padding:10px 12px; border:1px solid var(--border); border-radius:8px; background:var(--surface); color:var(--text); width:100%; box-sizing:border-box; }
599
+ .form textarea:focus, .form input:focus { outline:2px solid var(--accent); outline-offset:1px; }
600
+ .form button { justify-self:start; cursor:pointer; font:inherit; font-weight:550; }
601
+ .form .hp { position:absolute; left:-10000px; width:1px; height:1px; overflow:hidden; }
602
+ .error { border:1px solid #e03131; color:#e03131; border-radius:8px; padding:10px 14px; }
603
+ .card { border:1px solid var(--border); border-radius:12px; padding:20px; background:var(--surface); }
604
+ .card h3 { margin:0 0 6px; font-size:17px; }
605
+ .card p { margin:0; color:var(--muted); font-size:15px; }
606
+ .split { display:grid; grid-template-columns:1fr 1.2fr; gap:32px; align-items:center; }
607
+ pre { margin:0; font-family:var(--mono); font-size:13.5px; line-height:1.55; background:#0f1115; color:#e8eaee; border:1px solid var(--border); border-radius:12px; padding:18px; overflow-x:auto; }
608
+ code { font-family:var(--mono); font-size:.9em; }
609
+ :not(pre) > code { background:var(--bg-2); border:1px solid var(--border); border-radius:5px; padding:1px 5px; }
610
+ .plans { display:grid; grid-template-columns:1fr 1fr; gap:20px; max-width:820px; margin:0 auto 18px; }
611
+ .plan { border:1px solid var(--border); border-radius:14px; padding:26px; display:grid; gap:14px; align-content:start; background:var(--surface); }
612
+ .plan.featured { border-color:var(--accent); box-shadow:0 0 0 1px var(--accent); }
613
+ .plan h2 { margin:0; font-size:20px; }
614
+ .price { font-size:36px; font-weight:700; letter-spacing:-.02em; }
615
+ .price small { font-size:15px; font-weight:400; color:var(--muted); letter-spacing:0; }
616
+ .plan ul { margin:0; padding-left:20px; display:grid; gap:6px; }
617
+ details { border-bottom:1px solid var(--border); padding:14px 0; }
618
+ summary { cursor:pointer; font-weight:600; }
619
+ details p { color:var(--muted); margin:10px 0 0; }
620
+ .prose h1 { font-size:36px; letter-spacing:-.02em; line-height:1.15; margin:48px 0 8px; }
621
+ .prose h2 { font-size:22px; margin:36px 0 10px; }
622
+ .prose h3 { font-size:17px; margin:26px 0 8px; }
623
+ .prose p, .prose li { color:var(--text); }
624
+ .prose ul, .prose ol { padding-left:22px; }
625
+ .prose li { margin:4px 0; }
626
+ .prose pre { margin:14px 0; }
627
+ .legal h2 { font-size:19px; }
628
+ .table { overflow-x:auto; margin:14px 0; }
629
+ table { border-collapse:collapse; width:100%; font-size:15px; }
630
+ th, td { text-align:left; vertical-align:top; padding:8px 10px; border-bottom:1px solid var(--border); }
631
+ th { font-weight:600; background:var(--bg-2); }
632
+ mark { background:#fff3bf; color:#5c3c00; padding:0 4px; border-radius:4px; }
633
+ .docs { display:grid; grid-template-columns:220px 1fr; gap:40px; padding-bottom:40px; }
634
+ .docs aside { position:sticky; top:76px; align-self:start; padding-top:48px; display:grid; gap:22px; }
635
+ .docs aside nav, .toc { display:grid; gap:6px; }
636
+ .docs aside a { color:var(--muted); text-decoration:none; font-size:15px; }
637
+ .docs aside a[aria-current] { color:var(--accent); font-weight:600; }
638
+ .toc a { font-size:14px; }
639
+ .docs article { min-width:0; }
640
+ footer.site { margin-top:72px; border-top:1px solid var(--border); background:var(--bg-2); }
641
+ .foot { display:flex; gap:16px; justify-content:space-between; flex-wrap:wrap; padding:24px 20px; color:var(--muted); font-size:14px; }
642
+ .foot nav { display:flex; gap:16px; flex-wrap:wrap; }
643
+ .foot a { color:var(--muted); text-decoration:none; }
644
+ .foot a:hover { color:var(--text); }
645
+ @media (max-width: 820px) {
646
+ .steps, .grid3, .split, .plans, .roadmap { grid-template-columns:1fr; }
647
+ .docs { grid-template-columns:1fr; gap:0; }
648
+ .docs aside { position:static; padding-top:24px; }
649
+ .toc { display:none; }
650
+ .hero { padding-top:48px; }
651
+ }
652
+ `;
653
+ //# sourceMappingURL=site.js.map