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.
- package/README.md +161 -2
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +398 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/dns-host.d.ts +10 -0
- package/dist/src/dns-host.js +31 -0
- package/dist/src/dns-host.js.map +1 -0
- package/dist/src/errors.d.ts +17 -0
- package/dist/src/errors.js +51 -0
- package/dist/src/errors.js.map +1 -0
- package/dist/src/hosted/api.d.ts +107 -0
- package/dist/src/hosted/api.js +919 -0
- package/dist/src/hosted/api.js.map +1 -0
- package/dist/src/hosted/auth.d.ts +80 -0
- package/dist/src/hosted/auth.js +147 -0
- package/dist/src/hosted/auth.js.map +1 -0
- package/dist/src/hosted/billing-dodo.d.ts +58 -0
- package/dist/src/hosted/billing-dodo.js +139 -0
- package/dist/src/hosted/billing-dodo.js.map +1 -0
- package/dist/src/hosted/billing-stripe.d.ts +82 -0
- package/dist/src/hosted/billing-stripe.js +205 -0
- package/dist/src/hosted/billing-stripe.js.map +1 -0
- package/dist/src/hosted/billing.d.ts +114 -0
- package/dist/src/hosted/billing.js +173 -0
- package/dist/src/hosted/billing.js.map +1 -0
- package/dist/src/hosted/bulk.d.ts +47 -0
- package/dist/src/hosted/bulk.js +66 -0
- package/dist/src/hosted/bulk.js.map +1 -0
- package/dist/src/hosted/cli.d.ts +2 -0
- package/dist/src/hosted/cli.js +318 -0
- package/dist/src/hosted/cli.js.map +1 -0
- package/dist/src/hosted/crypto.d.ts +22 -0
- package/dist/src/hosted/crypto.js +55 -0
- package/dist/src/hosted/crypto.js.map +1 -0
- package/dist/src/hosted/index.d.ts +7 -0
- package/dist/src/hosted/index.js +11 -0
- package/dist/src/hosted/index.js.map +1 -0
- package/dist/src/hosted/markdown.d.ts +16 -0
- package/dist/src/hosted/markdown.js +168 -0
- package/dist/src/hosted/markdown.js.map +1 -0
- package/dist/src/hosted/net.d.ts +9 -0
- package/dist/src/hosted/net.js +60 -0
- package/dist/src/hosted/net.js.map +1 -0
- package/dist/src/hosted/postgres-store.d.ts +62 -0
- package/dist/src/hosted/postgres-store.js +387 -0
- package/dist/src/hosted/postgres-store.js.map +1 -0
- package/dist/src/hosted/serialize.d.ts +144 -0
- package/dist/src/hosted/serialize.js +126 -0
- package/dist/src/hosted/serialize.js.map +1 -0
- package/dist/src/hosted/service.d.ts +170 -0
- package/dist/src/hosted/service.js +603 -0
- package/dist/src/hosted/service.js.map +1 -0
- package/dist/src/hosted/site.d.ts +84 -0
- package/dist/src/hosted/site.js +653 -0
- package/dist/src/hosted/site.js.map +1 -0
- package/dist/src/hosted/sqlite-store.d.ts +45 -0
- package/dist/src/hosted/sqlite-store.js +351 -0
- package/dist/src/hosted/sqlite-store.js.map +1 -0
- package/dist/src/hosted/store.d.ts +257 -0
- package/dist/src/hosted/store.js +181 -0
- package/dist/src/hosted/store.js.map +1 -0
- package/dist/src/hosted/widget.d.ts +46 -0
- package/dist/src/hosted/widget.js +130 -0
- package/dist/src/hosted/widget.js.map +1 -0
- package/dist/src/hosted/worker.d.ts +45 -0
- package/dist/src/hosted/worker.js +142 -0
- package/dist/src/hosted/worker.js.map +1 -0
- package/dist/src/http.d.ts +14 -0
- package/dist/src/http.js +54 -0
- package/dist/src/http.js.map +1 -0
- package/dist/src/index.d.ts +19 -0
- package/dist/src/index.js +19 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/orchestrator.d.ts +70 -0
- package/dist/src/orchestrator.js +69 -0
- package/dist/src/orchestrator.js.map +1 -0
- package/dist/src/propagation.d.ts +30 -0
- package/dist/src/propagation.js +76 -0
- package/dist/src/propagation.js.map +1 -0
- package/dist/src/providers/dns/aws.d.ts +28 -0
- package/dist/src/providers/dns/aws.js +103 -0
- package/dist/src/providers/dns/aws.js.map +1 -0
- package/dist/src/providers/dns/cloudflare.d.ts +26 -0
- package/dist/src/providers/dns/cloudflare.js +97 -0
- package/dist/src/providers/dns/cloudflare.js.map +1 -0
- package/dist/src/providers/dns/manual.d.ts +12 -0
- package/dist/src/providers/dns/manual.js +18 -0
- package/dist/src/providers/dns/manual.js.map +1 -0
- package/dist/src/providers/dns/route53.d.ts +34 -0
- package/dist/src/providers/dns/route53.js +176 -0
- package/dist/src/providers/dns/route53.js.map +1 -0
- package/dist/src/providers/email/factory.d.ts +29 -0
- package/dist/src/providers/email/factory.js +67 -0
- package/dist/src/providers/email/factory.js.map +1 -0
- package/dist/src/providers/email/mailgun.d.ts +36 -0
- package/dist/src/providers/email/mailgun.js +141 -0
- package/dist/src/providers/email/mailgun.js.map +1 -0
- package/dist/src/providers/email/postmark.d.ts +37 -0
- package/dist/src/providers/email/postmark.js +121 -0
- package/dist/src/providers/email/postmark.js.map +1 -0
- package/dist/src/providers/email/sendgrid.d.ts +31 -0
- package/dist/src/providers/email/sendgrid.js +109 -0
- package/dist/src/providers/email/sendgrid.js.map +1 -0
- package/dist/src/providers/email/ses.d.ts +39 -0
- package/dist/src/providers/email/ses.js +123 -0
- package/dist/src/providers/email/ses.js.map +1 -0
- package/dist/src/providers/factory.d.ts +14 -0
- package/dist/src/providers/factory.js +32 -0
- package/dist/src/providers/factory.js.map +1 -0
- package/dist/src/records.d.ts +35 -0
- package/dist/src/records.js +109 -0
- package/dist/src/records.js.map +1 -0
- package/dist/src/server.d.ts +67 -0
- package/dist/src/server.js +301 -0
- package/dist/src/server.js.map +1 -0
- package/dist/src/types.d.ts +75 -0
- package/dist/src/types.js +2 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/xml.d.ts +6 -0
- package/dist/src/xml.js +23 -0
- package/dist/src/xml.js.map +1 -0
- package/docs/billing.md +43 -0
- package/docs/getting-started.md +66 -0
- package/docs/hosted-api.md +192 -0
- package/docs/library.md +253 -0
- package/docs/self-hosting.md +153 -0
- package/docs/widget.md +121 -0
- package/package.json +63 -4
- package/web/dashboard.html +669 -0
- package/web/index.html +697 -0
- package/web/widget-frame.html +480 -0
- package/web/widget.js +91 -0
|
@@ -0,0 +1,919 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { ApiError, apiErrorHint, ConfigError } from '../errors.js';
|
|
5
|
+
import { isValidDomain, normalizeName } from '../records.js';
|
|
6
|
+
import { AccountAuth, LogMailer, SESSION_TTL_MS } from './auth.js';
|
|
7
|
+
import { BillingService, WebhookSignatureError } from './billing.js';
|
|
8
|
+
import { addDomains, MAX_BULK_DOMAINS, splitDomainList } from './bulk.js';
|
|
9
|
+
import { EMAIL_PROVIDERS, isEmailProvider } from '../providers/email/factory.js';
|
|
10
|
+
import { Vault } from './crypto.js';
|
|
11
|
+
import { FEEDBACK_KINDS, MAX_FEEDBACK_LENGTH, Site } from './site.js';
|
|
12
|
+
import { iso, toPublicAccount, toPublicDomain, toPublicEstimate, toPublicEvent, toPublicUsage, toWidgetDomain } from './serialize.js';
|
|
13
|
+
import { ConflictError, DomainService, NotFoundError, PlanLimitError, } from './service.js';
|
|
14
|
+
import { openStore, utcDay } from './store.js';
|
|
15
|
+
import { MAX_SESSION_SEC, WidgetService } from './widget.js';
|
|
16
|
+
import { BackgroundRunner } from './worker.js';
|
|
17
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
18
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
19
|
+
const escHtml = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
20
|
+
const STATUSES = ['pending', 'awaiting_dns', 'verifying', 'verified', 'conflict', 'failed'];
|
|
21
|
+
class HttpError extends Error {
|
|
22
|
+
status;
|
|
23
|
+
type;
|
|
24
|
+
extra;
|
|
25
|
+
constructor(status, type, message, extra = {}) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.status = status;
|
|
28
|
+
this.type = type;
|
|
29
|
+
this.extra = extra;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const invalid = (message) => new HttpError(400, 'invalid_request', message);
|
|
33
|
+
const WEB_DIR = new URL('../../../web/', import.meta.url);
|
|
34
|
+
export function loadWidgetAssets() {
|
|
35
|
+
return {
|
|
36
|
+
loader: readFileSync(new URL('widget.js', WEB_DIR), 'utf8'),
|
|
37
|
+
frame: readFileSync(new URL('widget-frame.html', WEB_DIR), 'utf8'),
|
|
38
|
+
dashboard: readFileSync(new URL('dashboard.html', WEB_DIR), 'utf8'),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const SESSION_COOKIE = 'dkimkit_session';
|
|
42
|
+
// Sessions started before the rename keep working until they expire.
|
|
43
|
+
const LEGACY_SESSION_COOKIE = 'sds_session';
|
|
44
|
+
/** The request handler, separate from the listener so it can be mounted elsewhere. */
|
|
45
|
+
export function createApiHandler(opts) {
|
|
46
|
+
const { service, widget, auth, billing } = opts;
|
|
47
|
+
const assets = opts.assets ?? loadWidgetAssets();
|
|
48
|
+
const now = opts.now ?? Date.now;
|
|
49
|
+
const log = opts.log ?? (() => { });
|
|
50
|
+
const limiter = new RateLimiter(opts.rateLimit?.perSecond ?? 10, opts.rateLimit?.burst ?? 30);
|
|
51
|
+
// Sign-in emails: a burst of 5 per client address, then one every 12 seconds.
|
|
52
|
+
const emailLimiter = new RateLimiter(1 / 12, 5);
|
|
53
|
+
// Feedback form: a burst of 5 per client address, then one a minute.
|
|
54
|
+
const feedbackLimiter = new RateLimiter(1 / 60, 5);
|
|
55
|
+
const secureCookies = opts.publicUrl?.startsWith('https://') ?? false;
|
|
56
|
+
const requireAuth = () => {
|
|
57
|
+
if (!auth || !opts.publicUrl)
|
|
58
|
+
throw new HttpError(404, 'not_found', 'Self-serve accounts are not enabled on this server.');
|
|
59
|
+
return auth;
|
|
60
|
+
};
|
|
61
|
+
const requireBilling = () => {
|
|
62
|
+
if (!billing)
|
|
63
|
+
throw invalid('Billing is not enabled on this server.');
|
|
64
|
+
return billing;
|
|
65
|
+
};
|
|
66
|
+
const sessionCookie = (token, maxAgeSec, name = SESSION_COOKIE) => `${name}=${token}; HttpOnly; Path=/; SameSite=Lax; Max-Age=${maxAgeSec}${secureCookies ? '; Secure' : ''}`;
|
|
67
|
+
const readCookie = (req, name) => req.headers.cookie
|
|
68
|
+
?.split(';')
|
|
69
|
+
.map((c) => c.trim().split('='))
|
|
70
|
+
.find(([k]) => k === name)?.[1];
|
|
71
|
+
const sessionToken = (req) => readCookie(req, SESSION_COOKIE) ?? readCookie(req, LEGACY_SESSION_COOKIE) ?? '';
|
|
72
|
+
const dashboardView = async (account) => ({
|
|
73
|
+
account: toPublicAccount(account),
|
|
74
|
+
email: account.email,
|
|
75
|
+
billing_enabled: Boolean(billing),
|
|
76
|
+
billing: billing ? toPublicEstimate(await billing.estimate(account)) : null,
|
|
77
|
+
feedback_enabled: Boolean(opts.site),
|
|
78
|
+
free_plan: !billing && opts.freePlan ? { free_domains: account.domainLimit ?? opts.freePlan.freeDomains, payments_coming_soon: true } : null,
|
|
79
|
+
usage: toPublicUsage(`${utcDay(now()).slice(0, 7)}-01`, utcDay(now()), await service.usage(account, `${utcDay(now()).slice(0, 7)}-01`, utcDay(now()), false), account),
|
|
80
|
+
});
|
|
81
|
+
const baseUrl = (req) => {
|
|
82
|
+
if (opts.publicUrl)
|
|
83
|
+
return opts.publicUrl.replace(/\/$/, '');
|
|
84
|
+
const proto = String(req.headers['x-forwarded-proto'] ?? 'http').split(',')[0].trim();
|
|
85
|
+
return `${proto}://${req.headers.host}`;
|
|
86
|
+
};
|
|
87
|
+
const sessionView = async (ctx) => {
|
|
88
|
+
const row = await widget.domainOf(ctx);
|
|
89
|
+
const host = row && row.status !== 'verified' ? await widget.hostOf(row.domain) : null;
|
|
90
|
+
return {
|
|
91
|
+
object: 'widget_session',
|
|
92
|
+
expires_at: iso(ctx.session.expiresAt),
|
|
93
|
+
app_name: ctx.account.name,
|
|
94
|
+
needs_domain: row === null,
|
|
95
|
+
domain: row && toWidgetDomain(row),
|
|
96
|
+
dns_host: host && { name: host.name, provider: host.provider },
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
const sendFeedback = async (form) => {
|
|
100
|
+
const label = FEEDBACK_KINDS[form.kind];
|
|
101
|
+
log('info', 'feedback received', { kind: form.kind, length: form.message.length });
|
|
102
|
+
if (!opts.feedback?.to) {
|
|
103
|
+
log('warn', 'feedback not emailed: CONTACT_EMAIL is not set', { kind: form.kind, email: form.email, message: form.message });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const from = form.email || 'anonymous (no email given)';
|
|
107
|
+
const summary = form.message.replace(/\s+/g, ' ').slice(0, 60);
|
|
108
|
+
await opts.feedback.mailer.send({
|
|
109
|
+
to: opts.feedback.to,
|
|
110
|
+
subject: `${label}: ${summary}${form.message.length > 60 ? '…' : ''}`,
|
|
111
|
+
text: `${label} from ${from}\n\n${form.message}\n`,
|
|
112
|
+
html: `<p><b>${escHtml(label)}</b> from ${escHtml(from)}</p><pre style="white-space:pre-wrap;font:inherit">${escHtml(form.message)}</pre>`,
|
|
113
|
+
...(form.email ? { replyTo: form.email } : {}),
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
const routes = [
|
|
117
|
+
['GET', /^\/v1\/account$/, async ({ account }) => [200, toPublicAccount(account)]],
|
|
118
|
+
['PATCH', /^\/v1\/account$/, async ({ account, body }) => [200, toPublicAccount(await service.updateAccount(account, parseAccountPatch(await body())))]],
|
|
119
|
+
['POST', /^\/v1\/account\/rotate-api-key$/, async ({ account }) => [200, { object: 'api_key', api_key: await service.rotateApiKey(account) }]],
|
|
120
|
+
['POST', /^\/v1\/account\/rotate-webhook-secret$/, async ({ account }) => [200, toPublicAccount(await service.rotateWebhookSecret(account))]],
|
|
121
|
+
['GET', /^\/v1\/usage$/, async ({ account, query }) => {
|
|
122
|
+
const { from, to } = parseUsagePeriod(query, now());
|
|
123
|
+
const groupBy = query.get('group_by');
|
|
124
|
+
if (groupBy !== null && groupBy !== 'tenant')
|
|
125
|
+
throw invalid('group_by must be "tenant".');
|
|
126
|
+
return [200, toPublicUsage(from, to, await service.usage(account, from, to, groupBy === 'tenant'), account)];
|
|
127
|
+
}],
|
|
128
|
+
['POST', /^\/v1\/domains$/, async ({ account, body }) => [201, toPublicDomain(await service.createDomain(account, parseDomainInput(await body())))]],
|
|
129
|
+
['GET', /^\/v1\/domains$/, async ({ account, query }) => {
|
|
130
|
+
const limit = query.has('limit') ? Number(query.get('limit')) : 20;
|
|
131
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100)
|
|
132
|
+
throw invalid('limit must be an integer from 1 to 100.');
|
|
133
|
+
const status = query.get('status') ?? undefined;
|
|
134
|
+
if (status && !STATUSES.includes(status))
|
|
135
|
+
throw invalid(`status must be one of: ${STATUSES.join(', ')}.`);
|
|
136
|
+
const page = await service.listDomains(account, {
|
|
137
|
+
limit,
|
|
138
|
+
tenantId: query.get('tenant_id') ?? undefined,
|
|
139
|
+
status: status,
|
|
140
|
+
after: query.get('starting_after') ?? undefined,
|
|
141
|
+
});
|
|
142
|
+
return [200, { object: 'list', data: page.domains.map(toPublicDomain), has_more: page.next !== null, next_cursor: page.next }];
|
|
143
|
+
}],
|
|
144
|
+
['GET', /^\/v1\/domains\/([\w-]+)$/, async ({ account, params }) => [200, toPublicDomain(await service.getDomain(account, params[0]))]],
|
|
145
|
+
['PATCH', /^\/v1\/domains\/([\w-]+)$/, async ({ account, params, body }) => [200, toPublicDomain(await service.updateDomain(account, params[0], parseDomainPatch(await body(), (await service.getDomain(account, params[0])).domain)))]],
|
|
146
|
+
['DELETE', /^\/v1\/domains\/([\w-]+)$/, async ({ account, params, query }) => {
|
|
147
|
+
await service.deleteDomain(account, params[0], { remote: query.get('remote') === 'true' });
|
|
148
|
+
return [200, { id: params[0], object: 'domain', deleted: true }];
|
|
149
|
+
}],
|
|
150
|
+
['POST', /^\/v1\/domains\/([\w-]+)\/verify$/, async ({ account, params }) => [202, toPublicDomain(await service.verifyNow(account, params[0]))]],
|
|
151
|
+
['GET', /^\/v1\/domains\/([\w-]+)\/events$/, async ({ account, params }) => {
|
|
152
|
+
await service.getDomain(account, params[0]); // 404 unless it's this account's domain
|
|
153
|
+
return [200, { object: 'list', data: (await service.listEvents(params[0])).map(toPublicEvent) }];
|
|
154
|
+
}],
|
|
155
|
+
// Widget: sessions are minted server-side with the secret key...
|
|
156
|
+
['POST', /^\/v1\/widget-sessions$/, async ({ req, account, body }) => {
|
|
157
|
+
const { session, token } = await widget.createSession(account, parseWidgetSession(await body()));
|
|
158
|
+
const base = baseUrl(req);
|
|
159
|
+
return [201, {
|
|
160
|
+
object: 'widget_session',
|
|
161
|
+
id: session.id,
|
|
162
|
+
client_secret: token,
|
|
163
|
+
domain_id: session.domainId,
|
|
164
|
+
expires_at: iso(session.expiresAt),
|
|
165
|
+
frame_url: `${base}/widget/frame?session=${token}`,
|
|
166
|
+
script_url: `${base}/widget.js`,
|
|
167
|
+
}];
|
|
168
|
+
}],
|
|
169
|
+
// ...and used from the browser with the session token.
|
|
170
|
+
['GET', /^\/v1\/widget\/session$/, async (ctx) => [200, await sessionView(ctx)], 'session'],
|
|
171
|
+
['POST', /^\/v1\/widget\/domain$/, async (ctx) => {
|
|
172
|
+
const body = await ctx.body();
|
|
173
|
+
rejectUnknown(body, ['domain']);
|
|
174
|
+
await widget.setDomain(ctx, optString(body, 'domain', 253) ?? '');
|
|
175
|
+
return [201, await sessionView(ctx)];
|
|
176
|
+
}, 'session'],
|
|
177
|
+
['POST', /^\/v1\/widget\/connect$/, async (ctx) => {
|
|
178
|
+
const dns = parseDns(await ctx.body());
|
|
179
|
+
await widget.connect(ctx, dns.provider, dns.credentials);
|
|
180
|
+
return [200, await sessionView(ctx)];
|
|
181
|
+
}, 'session'],
|
|
182
|
+
['POST', /^\/v1\/widget\/verify$/, async (ctx) => {
|
|
183
|
+
await widget.verify(ctx);
|
|
184
|
+
return [202, await sessionView(ctx)];
|
|
185
|
+
}, 'session'],
|
|
186
|
+
['POST', /^\/v1\/widget\/resolve-conflicts$/, async (ctx) => {
|
|
187
|
+
await widget.resolveConflicts(ctx);
|
|
188
|
+
return [200, await sessionView(ctx)];
|
|
189
|
+
}, 'session'],
|
|
190
|
+
['GET', /^\/widget\.js$/, async () => ({
|
|
191
|
+
status: 200,
|
|
192
|
+
headers: {
|
|
193
|
+
'content-type': 'text/javascript; charset=utf-8',
|
|
194
|
+
'cache-control': 'public, max-age=300',
|
|
195
|
+
'access-control-allow-origin': '*',
|
|
196
|
+
'x-content-type-options': 'nosniff',
|
|
197
|
+
},
|
|
198
|
+
body: assets.loader,
|
|
199
|
+
}), 'public'],
|
|
200
|
+
['GET', /^\/widget\/frame$/, async ({ query }) => renderFrame(assets.frame, await widget.authenticate(query.get('session') ?? ''), query), 'public'],
|
|
201
|
+
// Public website: landing, pricing, docs, about, contact, feedback and legal pages, and /site.js.
|
|
202
|
+
['GET', /^\/(pricing|about|contact|feedback|terms|privacy|refunds|site\.js|docs(\/[\w-]*)?)?$/, async ({ req }) => {
|
|
203
|
+
const path = new URL(req.url ?? '/', 'http://localhost').pathname;
|
|
204
|
+
const page = opts.site?.render(path);
|
|
205
|
+
if (page)
|
|
206
|
+
return page;
|
|
207
|
+
// Without a site, the root goes straight to the dashboard (if sign-up is on).
|
|
208
|
+
if (path === '/' && auth && opts.publicUrl)
|
|
209
|
+
return { status: 302, headers: { location: '/dashboard' }, body: '' };
|
|
210
|
+
throw new HttpError(404, 'not_found', `No page at ${path}.`);
|
|
211
|
+
}, 'public'],
|
|
212
|
+
// The website's feedback form: a plain HTML form post, answered with the page itself.
|
|
213
|
+
['POST', /^\/feedback$/, async ({ req, rawBody }) => {
|
|
214
|
+
const site = opts.site;
|
|
215
|
+
if (!site)
|
|
216
|
+
throw new HttpError(404, 'not_found', 'No page at /feedback.');
|
|
217
|
+
if (!req.headers['content-type']?.startsWith('application/x-www-form-urlencoded')) {
|
|
218
|
+
throw new HttpError(415, 'unsupported_media_type', 'Send the feedback form.');
|
|
219
|
+
}
|
|
220
|
+
const fields = new URLSearchParams(await rawBody());
|
|
221
|
+
const kind = (fields.get('kind') ?? '') in FEEDBACK_KINDS ? fields.get('kind') : 'feedback';
|
|
222
|
+
const form = { kind, message: (fields.get('message') ?? '').trim(), email: (fields.get('email') ?? '').trim() };
|
|
223
|
+
// Bots fill in the hidden field; they're thanked and nothing is sent.
|
|
224
|
+
if (fields.get('website'))
|
|
225
|
+
return site.feedbackPage({ ...form, sent: true });
|
|
226
|
+
if (!form.message)
|
|
227
|
+
return site.feedbackPage({ ...form, error: 'Please write a message.' });
|
|
228
|
+
if (form.message.length > MAX_FEEDBACK_LENGTH) {
|
|
229
|
+
return site.feedbackPage({ ...form, error: `Please keep it under ${MAX_FEEDBACK_LENGTH} characters.` });
|
|
230
|
+
}
|
|
231
|
+
if (form.email && (form.email.length > 254 || !EMAIL_RE.test(form.email))) {
|
|
232
|
+
return site.feedbackPage({ ...form, error: 'That email address doesn’t look right. Fix it, or leave it empty.' });
|
|
233
|
+
}
|
|
234
|
+
if (feedbackLimiter.take(`ip:${req.socket.remoteAddress}`, now()) > 0) {
|
|
235
|
+
return { ...site.feedbackPage({ ...form, error: 'You’ve sent a lot of feedback in a short time. Please wait a minute and try again.' }), status: 429 };
|
|
236
|
+
}
|
|
237
|
+
await sendFeedback(form);
|
|
238
|
+
return site.feedbackPage({ ...form, sent: true });
|
|
239
|
+
}, 'public'],
|
|
240
|
+
// Self-serve accounts: passwordless email links and the dashboard.
|
|
241
|
+
['GET', /^\/(signup|login)$/, async () => {
|
|
242
|
+
requireAuth();
|
|
243
|
+
return { status: 302, headers: { location: '/dashboard' }, body: '' };
|
|
244
|
+
}, 'public'],
|
|
245
|
+
['GET', /^\/dashboard$/, async () => {
|
|
246
|
+
requireAuth();
|
|
247
|
+
return renderDashboard(assets.dashboard, opts.site?.appName ?? 'DKIMkit', billing?.freeDomains ?? opts.freePlan?.freeDomains);
|
|
248
|
+
}, 'public'],
|
|
249
|
+
['POST', /^\/v1\/auth\/email-link$/, async ({ req, body }) => {
|
|
250
|
+
const accountAuth = requireAuth();
|
|
251
|
+
const retryAfter = emailLimiter.take(`ip:${req.socket.remoteAddress}`, now());
|
|
252
|
+
if (retryAfter > 0) {
|
|
253
|
+
throw new HttpError(429, 'rate_limited', 'Too many sign-in emails. Please wait a minute and try again.');
|
|
254
|
+
}
|
|
255
|
+
const input = await body();
|
|
256
|
+
rejectUnknown(input, ['email', 'name']);
|
|
257
|
+
await accountAuth.requestLink(optString(input, 'email', 254) ?? '', optString(input, 'name', 100));
|
|
258
|
+
// Same response whether or not the account exists.
|
|
259
|
+
return [202, { ok: true }];
|
|
260
|
+
}, 'public'],
|
|
261
|
+
['GET', /^\/auth\/callback$/, async ({ query }) => {
|
|
262
|
+
const accountAuth = requireAuth();
|
|
263
|
+
try {
|
|
264
|
+
const { sessionToken, created } = await accountAuth.completeLink(query.get('token') ?? '');
|
|
265
|
+
return {
|
|
266
|
+
status: 303,
|
|
267
|
+
headers: { location: created ? '/dashboard?welcome=1' : '/dashboard', 'set-cookie': sessionCookie(sessionToken, SESSION_TTL_MS / 1000), 'cache-control': 'no-store' },
|
|
268
|
+
body: '',
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
catch (err) {
|
|
272
|
+
if (!(err instanceof ConfigError))
|
|
273
|
+
throw err;
|
|
274
|
+
return { status: 303, headers: { location: '/dashboard?link=expired', 'cache-control': 'no-store' }, body: '' };
|
|
275
|
+
}
|
|
276
|
+
}, 'public'],
|
|
277
|
+
['GET', /^\/dashboard\/api\/me$/, async ({ account }) => [200, await dashboardView(account)], 'dashboard'],
|
|
278
|
+
['POST', /^\/dashboard\/api\/logout$/, async ({ req }) => {
|
|
279
|
+
await requireAuth().logout(sessionToken(req));
|
|
280
|
+
const cleared = [sessionCookie('', 0), sessionCookie('', 0, LEGACY_SESSION_COOKIE)];
|
|
281
|
+
return { status: 200, headers: { 'set-cookie': cleared, 'content-type': 'application/json' }, body: '{"ok":true}' };
|
|
282
|
+
}, 'dashboard'],
|
|
283
|
+
['POST', /^\/dashboard\/api\/api-key$/, async ({ account }) => [200, { object: 'api_key', api_key: await service.rotateApiKey(account) }], 'dashboard'],
|
|
284
|
+
['PATCH', /^\/dashboard\/api\/account$/, async ({ account, body }) => {
|
|
285
|
+
const updated = await service.updateAccount(account, parseAccountPatch(await body()));
|
|
286
|
+
return [200, await dashboardView(updated)];
|
|
287
|
+
}, 'dashboard'],
|
|
288
|
+
['GET', /^\/dashboard\/api\/domains$/, async ({ account }) => {
|
|
289
|
+
const rows = [];
|
|
290
|
+
for (let after; rows.length < 1000;) {
|
|
291
|
+
const page = await service.listDomains(account, { limit: 100, after });
|
|
292
|
+
rows.push(...page.domains);
|
|
293
|
+
if (page.next === null)
|
|
294
|
+
break;
|
|
295
|
+
after = page.next;
|
|
296
|
+
}
|
|
297
|
+
// Where each unfinished domain's DNS lives, so the dashboard can group records by DNS host.
|
|
298
|
+
const hosts = await Promise.all(rows.map((r) => (r.status === 'verified' ? null : widget.hostOf(r.domain).catch(() => null))));
|
|
299
|
+
return [200, { object: 'list', data: rows.map((r, i) => ({ ...toPublicDomain(r), dns_host: hosts[i] ? { name: hosts[i].name, provider: hosts[i].provider } : null })) }];
|
|
300
|
+
}, 'dashboard'],
|
|
301
|
+
['POST', /^\/dashboard\/api\/domains$/, async ({ account, body }) => {
|
|
302
|
+
const input = parseBulkInput(await body());
|
|
303
|
+
const results = await addDomains(service, (d) => widget.hostOf(d), account, input);
|
|
304
|
+
return [200, {
|
|
305
|
+
object: 'bulk_result',
|
|
306
|
+
data: [
|
|
307
|
+
...results.map((r) => r.ok
|
|
308
|
+
? { domain: r.domain, ok: true, id: r.row.id, dns: r.row.dnsProvider, dns_host: r.host.name }
|
|
309
|
+
: { domain: r.domain, ok: false, error: { type: toHttpError(r.error).type, message: toHttpError(r.error).message } }),
|
|
310
|
+
...input.invalid.map((entry) => ({ domain: entry, ok: false, error: { type: 'invalid_request', message: 'Not a valid domain name.' } })),
|
|
311
|
+
],
|
|
312
|
+
}];
|
|
313
|
+
}, 'dashboard'],
|
|
314
|
+
['POST', /^\/dashboard\/api\/domains\/([\w-]+)\/verify$/, async ({ account, params }) => [200, toPublicDomain(await service.verifyNow(account, params[0]))], 'dashboard'],
|
|
315
|
+
['DELETE', /^\/dashboard\/api\/domains\/([\w-]+)$/, async ({ account, params }) => {
|
|
316
|
+
// Also removes the domain and its inbound route at the email provider. DNS records are left alone.
|
|
317
|
+
await service.deleteDomain(account, params[0], { remote: true });
|
|
318
|
+
return [200, { id: params[0], object: 'domain', deleted: true }];
|
|
319
|
+
}, 'dashboard'],
|
|
320
|
+
['POST', /^\/dashboard\/api\/checkout$/, async ({ account }) => [200, { url: await requireBilling().checkoutUrl(account) }], 'dashboard'],
|
|
321
|
+
['POST', /^\/dashboard\/api\/portal$/, async ({ account }) => [200, { url: await requireBilling().portalUrl(account) }], 'dashboard'],
|
|
322
|
+
// Payment provider → us. Authenticated by the provider's signature over the raw body.
|
|
323
|
+
['POST', /^\/billing\/webhook$/, async ({ req, rawBody }) => {
|
|
324
|
+
const result = await requireBilling().handleWebhook(await rawBody(), req.headers);
|
|
325
|
+
return [200, { received: true, result }];
|
|
326
|
+
}, 'public'],
|
|
327
|
+
];
|
|
328
|
+
return async (req, res) => {
|
|
329
|
+
const started = now();
|
|
330
|
+
const requestId = typeof req.headers['x-request-id'] === 'string' ? req.headers['x-request-id'].slice(0, 64) : randomUUID();
|
|
331
|
+
res.setHeader('x-request-id', requestId);
|
|
332
|
+
let accountId;
|
|
333
|
+
let status = 500;
|
|
334
|
+
try {
|
|
335
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
336
|
+
if (req.method === 'GET' && url.pathname === '/healthz') {
|
|
337
|
+
status = 200;
|
|
338
|
+
return sendJson(res, 200, { ok: true });
|
|
339
|
+
}
|
|
340
|
+
const matching = routes.filter(([, re]) => re.test(url.pathname));
|
|
341
|
+
if (matching.length === 0)
|
|
342
|
+
throw new HttpError(404, 'not_found', `No route for ${url.pathname}.`);
|
|
343
|
+
const route = matching.find(([method]) => method === req.method);
|
|
344
|
+
if (!route)
|
|
345
|
+
throw new HttpError(405, 'method_not_allowed', `${req.method} is not supported here.`);
|
|
346
|
+
const [, re, handler, auth = 'account'] = route;
|
|
347
|
+
let account;
|
|
348
|
+
let session;
|
|
349
|
+
if (auth === 'dashboard') {
|
|
350
|
+
const accountAuth = requireAuth();
|
|
351
|
+
// Cookie-authenticated writes must come from our own pages (CSRF).
|
|
352
|
+
if (req.method !== 'GET' && req.headers.origin !== new URL(opts.publicUrl).origin) {
|
|
353
|
+
throw new HttpError(403, 'forbidden', 'Cross-site request rejected.');
|
|
354
|
+
}
|
|
355
|
+
account = await accountAuth.sessionAccount(sessionToken(req));
|
|
356
|
+
if (!account)
|
|
357
|
+
throw new HttpError(401, 'unauthorized', 'Please sign in.');
|
|
358
|
+
accountId = account.id;
|
|
359
|
+
const retryAfter = limiter.take(account.id, now());
|
|
360
|
+
if (retryAfter > 0)
|
|
361
|
+
throw new HttpError(429, 'rate_limited', 'Too many requests.');
|
|
362
|
+
}
|
|
363
|
+
else if (auth !== 'public') {
|
|
364
|
+
const key = /^Bearer\s+(\S+)$/i.exec(req.headers.authorization ?? '')?.[1] ?? '';
|
|
365
|
+
if (auth === 'session') {
|
|
366
|
+
const ctx = key.startsWith('wss_') ? await widget.authenticate(key) : undefined;
|
|
367
|
+
if (!ctx)
|
|
368
|
+
throw new HttpError(401, 'session_expired', 'This setup session has expired. Reload the page to start again.');
|
|
369
|
+
({ account, session } = ctx);
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
account = key && !key.startsWith('wss_') ? await service.authenticate(key) : undefined;
|
|
373
|
+
if (!account)
|
|
374
|
+
throw new HttpError(401, 'unauthorized', 'Missing or invalid API key. Send Authorization: Bearer <key>.');
|
|
375
|
+
}
|
|
376
|
+
accountId = account.id;
|
|
377
|
+
const retryAfter = limiter.take(session?.id ?? account.id, now());
|
|
378
|
+
if (retryAfter > 0) {
|
|
379
|
+
res.setHeader('retry-after', String(Math.ceil(retryAfter / 1000)));
|
|
380
|
+
throw new HttpError(429, 'rate_limited', 'Too many requests.');
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
const result = await handler({
|
|
384
|
+
req,
|
|
385
|
+
account: account,
|
|
386
|
+
session: session,
|
|
387
|
+
params: re.exec(url.pathname).slice(1),
|
|
388
|
+
query: url.searchParams,
|
|
389
|
+
body: () => readJsonObject(req),
|
|
390
|
+
rawBody: () => readRawBody(req),
|
|
391
|
+
});
|
|
392
|
+
if (Array.isArray(result)) {
|
|
393
|
+
status = result[0];
|
|
394
|
+
sendJson(res, result[0], result[1]);
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
status = result.status;
|
|
398
|
+
res.writeHead(result.status, result.headers);
|
|
399
|
+
res.end(result.body);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
catch (err) {
|
|
403
|
+
const e = toHttpError(err);
|
|
404
|
+
status = e.status;
|
|
405
|
+
if (e.status === 500)
|
|
406
|
+
log('error', 'request failed', { request_id: requestId, error: err instanceof Error ? err.stack : String(err) });
|
|
407
|
+
else if (e.status === 502)
|
|
408
|
+
log('warn', 'provider rejected request', { request_id: requestId, error: e.message });
|
|
409
|
+
sendJson(res, e.status, { error: { type: e.type, message: e.message, request_id: requestId, ...e.extra } });
|
|
410
|
+
}
|
|
411
|
+
finally {
|
|
412
|
+
log('info', 'request', { request_id: requestId, method: req.method, path: req.url?.split('?')[0], status, ms: now() - started, account_id: accountId });
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
function toHttpError(err) {
|
|
417
|
+
if (err instanceof HttpError)
|
|
418
|
+
return err;
|
|
419
|
+
if (err instanceof ConfigError)
|
|
420
|
+
return invalid(err.message);
|
|
421
|
+
if (err instanceof NotFoundError)
|
|
422
|
+
return new HttpError(404, 'not_found', err.message);
|
|
423
|
+
if (err instanceof PlanLimitError) {
|
|
424
|
+
return new HttpError(402, 'plan_limit_reached', err.message, { domain_limit: err.limit, ...(err.upgradeUrl ? { upgrade_url: err.upgradeUrl } : {}) });
|
|
425
|
+
}
|
|
426
|
+
if (err instanceof WebhookSignatureError)
|
|
427
|
+
return new HttpError(400, 'invalid_signature', err.message);
|
|
428
|
+
if (err instanceof ConflictError) {
|
|
429
|
+
return new HttpError(409, 'conflict', err.message, err.existingId ? { existing_id: err.existingId } : {});
|
|
430
|
+
}
|
|
431
|
+
if (err instanceof ApiError) {
|
|
432
|
+
return new HttpError(502, 'provider_error', err.message, {
|
|
433
|
+
provider: err.provider,
|
|
434
|
+
provider_status: err.status,
|
|
435
|
+
...(apiErrorHint(err) ? { hint: apiErrorHint(err) } : {}),
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
return new HttpError(500, 'internal_error', 'Something went wrong on our side.');
|
|
439
|
+
}
|
|
440
|
+
// ---- input parsing ----
|
|
441
|
+
const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
442
|
+
/** from/to as inclusive UTC days; defaults to the current calendar month so far. */
|
|
443
|
+
export function parseUsagePeriod(query, now) {
|
|
444
|
+
const today = new Date(now).toISOString().slice(0, 10);
|
|
445
|
+
const from = query.get('from') ?? `${today.slice(0, 7)}-01`;
|
|
446
|
+
const to = query.get('to') ?? today;
|
|
447
|
+
for (const [name, day] of [['from', from], ['to', to]]) {
|
|
448
|
+
if (!DAY_RE.test(day) || Number.isNaN(Date.parse(`${day}T00:00:00Z`)) || new Date(`${day}T00:00:00Z`).toISOString().slice(0, 10) !== day) {
|
|
449
|
+
throw invalid(`${name} must be a date like 2026-10-01.`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (from > to)
|
|
453
|
+
throw invalid('from must not be after to.');
|
|
454
|
+
if ((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / 86_400_000 > 366)
|
|
455
|
+
throw invalid('The period can be at most 366 days.');
|
|
456
|
+
return { from, to };
|
|
457
|
+
}
|
|
458
|
+
function rejectUnknown(body, allowed, prefix = '') {
|
|
459
|
+
const unknown = Object.keys(body).filter((k) => !allowed.includes(k));
|
|
460
|
+
if (unknown.length > 0)
|
|
461
|
+
throw invalid(`Unknown field${unknown.length > 1 ? 's' : ''}: ${unknown.map((k) => prefix + k).join(', ')}.`);
|
|
462
|
+
}
|
|
463
|
+
function optString(body, key, max, label = key) {
|
|
464
|
+
const v = body[key];
|
|
465
|
+
if (v === undefined || v === null)
|
|
466
|
+
return undefined;
|
|
467
|
+
if (typeof v !== 'string' || v.length > max)
|
|
468
|
+
throw invalid(`${label} must be a string of at most ${max} characters.`);
|
|
469
|
+
return v.trim() || undefined;
|
|
470
|
+
}
|
|
471
|
+
function optBool(body, key, label = key) {
|
|
472
|
+
const v = body[key];
|
|
473
|
+
if (v === undefined)
|
|
474
|
+
return undefined;
|
|
475
|
+
if (typeof v !== 'boolean')
|
|
476
|
+
throw invalid(`${label} must be true or false.`);
|
|
477
|
+
return v;
|
|
478
|
+
}
|
|
479
|
+
function asObject(v, label) {
|
|
480
|
+
if (!v || typeof v !== 'object' || Array.isArray(v))
|
|
481
|
+
throw invalid(`${label} must be an object.`);
|
|
482
|
+
return v;
|
|
483
|
+
}
|
|
484
|
+
function parseMetadata(v) {
|
|
485
|
+
const obj = asObject(v, 'metadata');
|
|
486
|
+
const entries = Object.entries(obj);
|
|
487
|
+
if (entries.length > 20)
|
|
488
|
+
throw invalid('metadata can have at most 20 keys.');
|
|
489
|
+
for (const [k, val] of entries) {
|
|
490
|
+
if (k.length > 40 || typeof val !== 'string' || val.length > 500) {
|
|
491
|
+
throw invalid('metadata keys must be at most 40 characters and values strings of at most 500.');
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return obj;
|
|
495
|
+
}
|
|
496
|
+
/** Without a hostname, the email provider's default applies (e.g. parse.<domain>, or the domain itself for Mailgun). */
|
|
497
|
+
function parseInbound(v, domain) {
|
|
498
|
+
if (v === null)
|
|
499
|
+
return null;
|
|
500
|
+
const obj = asObject(v, 'inbound');
|
|
501
|
+
rejectUnknown(obj, ['url', 'hostname', 'spam_check', 'send_raw'], 'inbound.');
|
|
502
|
+
const url = optString(obj, 'url', 2048, 'inbound.url');
|
|
503
|
+
if (!url || !/^https:\/\/\S+$/.test(url))
|
|
504
|
+
throw invalid('inbound.url must be an https:// URL.');
|
|
505
|
+
const given = optString(obj, 'hostname', 253, 'inbound.hostname');
|
|
506
|
+
const hostname = given === undefined ? undefined : normalizeName(given);
|
|
507
|
+
if (hostname !== undefined && (!isValidDomain(hostname) || !(hostname === domain || hostname.endsWith(`.${domain}`)))) {
|
|
508
|
+
throw invalid(`inbound.hostname must be ${domain} or a subdomain of it.`);
|
|
509
|
+
}
|
|
510
|
+
return { url, ...(hostname ? { hostname } : {}), spamCheck: optBool(obj, 'spam_check', 'inbound.spam_check') ?? false, sendRaw: optBool(obj, 'send_raw', 'inbound.send_raw') ?? false };
|
|
511
|
+
}
|
|
512
|
+
function parseDns(v) {
|
|
513
|
+
const obj = asObject(v, 'dns');
|
|
514
|
+
rejectUnknown(obj, ['provider', 'cloudflare_api_token', 'aws_access_key_id', 'aws_secret_access_key', 'aws_session_token', 'route53_hosted_zone_id'], 'dns.');
|
|
515
|
+
const provider = optString(obj, 'provider', 20, 'dns.provider');
|
|
516
|
+
if (!provider)
|
|
517
|
+
throw invalid('dns.provider is required.');
|
|
518
|
+
return {
|
|
519
|
+
provider,
|
|
520
|
+
credentials: {
|
|
521
|
+
cloudflareApiToken: optString(obj, 'cloudflare_api_token', 200, 'dns.cloudflare_api_token'),
|
|
522
|
+
awsAccessKeyId: optString(obj, 'aws_access_key_id', 128, 'dns.aws_access_key_id'),
|
|
523
|
+
awsSecretAccessKey: optString(obj, 'aws_secret_access_key', 128, 'dns.aws_secret_access_key'),
|
|
524
|
+
awsSessionToken: optString(obj, 'aws_session_token', 4096, 'dns.aws_session_token'),
|
|
525
|
+
route53HostedZoneId: optString(obj, 'route53_hosted_zone_id', 64, 'dns.route53_hosted_zone_id'),
|
|
526
|
+
},
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
export function parseDomainInput(body) {
|
|
530
|
+
rejectUnknown(body, ['domain', 'subdomain', 'tenant_id', 'metadata', 'sendgrid_subuser', 'inbound', 'dns', 'overwrite']);
|
|
531
|
+
const domain = normalizeName(optString(body, 'domain', 253) ?? '');
|
|
532
|
+
if (!isValidDomain(domain))
|
|
533
|
+
throw invalid('domain must be a valid domain name, like example.com.');
|
|
534
|
+
const subdomain = optString(body, 'subdomain', 63);
|
|
535
|
+
if (subdomain && !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i.test(subdomain))
|
|
536
|
+
throw invalid('subdomain must be a single DNS label, like "em".');
|
|
537
|
+
const dns = body.dns === undefined ? { provider: 'manual', credentials: {} } : parseDns(body.dns);
|
|
538
|
+
return {
|
|
539
|
+
domain,
|
|
540
|
+
subdomain: subdomain?.toLowerCase() ?? null,
|
|
541
|
+
tenantId: optString(body, 'tenant_id', 128) ?? null,
|
|
542
|
+
metadata: body.metadata === undefined ? {} : parseMetadata(body.metadata),
|
|
543
|
+
sendgridSubuser: optString(body, 'sendgrid_subuser', 64) ?? null,
|
|
544
|
+
inbound: body.inbound === undefined ? null : parseInbound(body.inbound, domain),
|
|
545
|
+
dnsProvider: dns.provider,
|
|
546
|
+
dnsCredentials: dns.credentials,
|
|
547
|
+
overwrite: optBool(body, 'overwrite') ?? false,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
function parseDomainPatch(body, domain) {
|
|
551
|
+
rejectUnknown(body, ['tenant_id', 'metadata', 'inbound', 'dns', 'overwrite']);
|
|
552
|
+
const patch = {};
|
|
553
|
+
if ('tenant_id' in body)
|
|
554
|
+
patch.tenantId = optString(body, 'tenant_id', 128) ?? null;
|
|
555
|
+
if (body.metadata !== undefined)
|
|
556
|
+
patch.metadata = parseMetadata(body.metadata);
|
|
557
|
+
if (body.inbound !== undefined)
|
|
558
|
+
patch.inbound = parseInbound(body.inbound, domain);
|
|
559
|
+
if (body.dns !== undefined) {
|
|
560
|
+
const dns = parseDns(body.dns);
|
|
561
|
+
patch.dnsProvider = dns.provider;
|
|
562
|
+
patch.dnsCredentials = Object.fromEntries(Object.entries(dns.credentials).filter(([, v]) => v !== undefined));
|
|
563
|
+
}
|
|
564
|
+
const overwrite = optBool(body, 'overwrite');
|
|
565
|
+
if (overwrite !== undefined)
|
|
566
|
+
patch.overwrite = overwrite;
|
|
567
|
+
return patch;
|
|
568
|
+
}
|
|
569
|
+
/** Dashboard bulk add: `domains` is pasted text (one per line or comma separated) or an array. */
|
|
570
|
+
function parseBulkInput(body) {
|
|
571
|
+
rejectUnknown(body, ['domains', 'inbound', 'cloudflare_api_token', 'aws_access_key_id', 'aws_secret_access_key']);
|
|
572
|
+
const raw = body.domains;
|
|
573
|
+
if (typeof raw !== 'string' && !(Array.isArray(raw) && raw.every((d) => typeof d === 'string'))) {
|
|
574
|
+
throw invalid('domains must be text with one domain per line, or an array of domains.');
|
|
575
|
+
}
|
|
576
|
+
const { domains, invalid: bad } = splitDomainList(Array.isArray(raw) ? raw.join('\n') : raw);
|
|
577
|
+
if (domains.length === 0 && bad.length === 0)
|
|
578
|
+
throw invalid('Enter at least one domain.');
|
|
579
|
+
if (domains.length > MAX_BULK_DOMAINS)
|
|
580
|
+
throw invalid(`Add at most ${MAX_BULK_DOMAINS} domains at a time.`);
|
|
581
|
+
let inbound = null;
|
|
582
|
+
if (body.inbound !== undefined && body.inbound !== null) {
|
|
583
|
+
const obj = asObject(body.inbound, 'inbound');
|
|
584
|
+
rejectUnknown(obj, ['url', 'hostname_prefix'], 'inbound.');
|
|
585
|
+
const url = optString(obj, 'url', 2048, 'inbound.url');
|
|
586
|
+
if (!url || !/^https:\/\/\S+$/.test(url))
|
|
587
|
+
throw invalid('inbound.url must be an https:// URL.');
|
|
588
|
+
const prefix = optString(obj, 'hostname_prefix', 63, 'inbound.hostname_prefix')?.toLowerCase();
|
|
589
|
+
if (prefix && !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(prefix))
|
|
590
|
+
throw invalid('inbound.hostname_prefix must be a single DNS label, like "parse".');
|
|
591
|
+
inbound = { url, ...(prefix ? { hostnamePrefix: prefix } : {}) };
|
|
592
|
+
}
|
|
593
|
+
const accessKeyId = optString(body, 'aws_access_key_id', 128);
|
|
594
|
+
const secretAccessKey = optString(body, 'aws_secret_access_key', 128);
|
|
595
|
+
if (Boolean(accessKeyId) !== Boolean(secretAccessKey))
|
|
596
|
+
throw invalid('Enter both the AWS access key ID and secret access key, or neither.');
|
|
597
|
+
return {
|
|
598
|
+
domains,
|
|
599
|
+
invalid: bad,
|
|
600
|
+
inbound,
|
|
601
|
+
cloudflareApiToken: optString(body, 'cloudflare_api_token', 200),
|
|
602
|
+
...(accessKeyId && secretAccessKey ? { aws: { accessKeyId, secretAccessKey } } : {}),
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
const EMAIL_CREDENTIAL_FIELDS = [
|
|
606
|
+
['api_key', 'apiKey', 300],
|
|
607
|
+
['account_token', 'accountToken', 200],
|
|
608
|
+
['inbound_server_id', 'inboundServerId', 40],
|
|
609
|
+
['region', 'region', 40],
|
|
610
|
+
['access_key_id', 'accessKeyId', 128],
|
|
611
|
+
['secret_access_key', 'secretAccessKey', 128],
|
|
612
|
+
['session_token', 'sessionToken', 4096],
|
|
613
|
+
];
|
|
614
|
+
/** `email_provider: { provider: "postmark", account_token: "…" }`, etc. Fields left out keep their saved values. */
|
|
615
|
+
function parseEmailProvider(v) {
|
|
616
|
+
const obj = asObject(v, 'email_provider');
|
|
617
|
+
rejectUnknown(obj, ['provider', ...EMAIL_CREDENTIAL_FIELDS.map(([f]) => f)], 'email_provider.');
|
|
618
|
+
const provider = optString(obj, 'provider', 20, 'email_provider.provider');
|
|
619
|
+
if (!provider || !isEmailProvider(provider))
|
|
620
|
+
throw invalid(`email_provider.provider must be one of: ${EMAIL_PROVIDERS.join(', ')}.`);
|
|
621
|
+
const credentials = {};
|
|
622
|
+
for (const [field, key, max] of EMAIL_CREDENTIAL_FIELDS) {
|
|
623
|
+
const value = optString(obj, field, max, `email_provider.${field}`);
|
|
624
|
+
if (value !== undefined)
|
|
625
|
+
credentials[key] = value;
|
|
626
|
+
}
|
|
627
|
+
return { provider, credentials };
|
|
628
|
+
}
|
|
629
|
+
function parseAccountPatch(body) {
|
|
630
|
+
rejectUnknown(body, ['name', 'webhook_url', 'sendgrid_api_key', 'email_provider', 'widget_origins']);
|
|
631
|
+
const patch = {};
|
|
632
|
+
const name = optString(body, 'name', 200);
|
|
633
|
+
if (name !== undefined)
|
|
634
|
+
patch.name = name;
|
|
635
|
+
if ('webhook_url' in body)
|
|
636
|
+
patch.webhookUrl = optString(body, 'webhook_url', 2048) ?? null;
|
|
637
|
+
const key = optString(body, 'sendgrid_api_key', 200);
|
|
638
|
+
if (key !== undefined)
|
|
639
|
+
patch.sendgridApiKey = key;
|
|
640
|
+
if (body.email_provider !== undefined)
|
|
641
|
+
patch.emailProvider = parseEmailProvider(body.email_provider);
|
|
642
|
+
if (body.widget_origins !== undefined)
|
|
643
|
+
patch.widgetOrigins = parseOrigins(body.widget_origins);
|
|
644
|
+
return patch;
|
|
645
|
+
}
|
|
646
|
+
const LOCAL_HOSTS = ['localhost', '127.0.0.1', '[::1]'];
|
|
647
|
+
function parseOrigins(v) {
|
|
648
|
+
if (!Array.isArray(v) || v.length > 20)
|
|
649
|
+
throw invalid('widget_origins must be an array of at most 20 origins.');
|
|
650
|
+
return v.map((o) => {
|
|
651
|
+
const hint = `widget_origins entries must be origins like https://app.example.com (http:// only for localhost): ${String(o)}`;
|
|
652
|
+
if (typeof o !== 'string')
|
|
653
|
+
throw invalid(hint);
|
|
654
|
+
let url;
|
|
655
|
+
try {
|
|
656
|
+
url = new URL(o);
|
|
657
|
+
}
|
|
658
|
+
catch {
|
|
659
|
+
throw invalid(hint);
|
|
660
|
+
}
|
|
661
|
+
const secure = url.protocol === 'https:' || (url.protocol === 'http:' && LOCAL_HOSTS.includes(url.hostname));
|
|
662
|
+
if (!secure || url.origin !== o.replace(/\/$/, ''))
|
|
663
|
+
throw invalid(hint);
|
|
664
|
+
return url.origin;
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
function parseWidgetSession(body) {
|
|
668
|
+
rejectUnknown(body, ['domain_id', 'tenant_id', 'metadata', 'subdomain', 'sendgrid_subuser', 'inbound_url', 'expires_in']);
|
|
669
|
+
let expiresInSec;
|
|
670
|
+
if (body.expires_in !== undefined) {
|
|
671
|
+
expiresInSec = Number(body.expires_in);
|
|
672
|
+
if (!Number.isInteger(expiresInSec) || expiresInSec < 60 || expiresInSec > MAX_SESSION_SEC) {
|
|
673
|
+
throw invalid(`expires_in must be between 60 and ${MAX_SESSION_SEC} seconds.`);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
const domainId = optString(body, 'domain_id', 64);
|
|
677
|
+
const tenantId = optString(body, 'tenant_id', 128);
|
|
678
|
+
if (domainId) {
|
|
679
|
+
if (Object.keys(body).some((k) => k !== 'domain_id' && k !== 'expires_in')) {
|
|
680
|
+
throw invalid('With domain_id, the domain\'s own settings apply; pass only domain_id and expires_in.');
|
|
681
|
+
}
|
|
682
|
+
return { domainId, expiresInSec };
|
|
683
|
+
}
|
|
684
|
+
if (!tenantId)
|
|
685
|
+
throw invalid('Pass domain_id for an existing domain, or tenant_id to let the user enter their domain.');
|
|
686
|
+
const inboundUrl = optString(body, 'inbound_url', 2048) ?? null;
|
|
687
|
+
if (inboundUrl && !/^https:\/\/\S+$/.test(inboundUrl))
|
|
688
|
+
throw invalid('inbound_url must be an https:// URL.');
|
|
689
|
+
const subdomain = optString(body, 'subdomain', 63)?.toLowerCase() ?? null;
|
|
690
|
+
if (subdomain && !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(subdomain))
|
|
691
|
+
throw invalid('subdomain must be a single DNS label, like "em".');
|
|
692
|
+
return {
|
|
693
|
+
expiresInSec,
|
|
694
|
+
defaults: {
|
|
695
|
+
tenantId,
|
|
696
|
+
metadata: body.metadata === undefined ? {} : parseMetadata(body.metadata),
|
|
697
|
+
subdomain,
|
|
698
|
+
sendgridSubuser: optString(body, 'sendgrid_subuser', 64) ?? null,
|
|
699
|
+
inboundUrl,
|
|
700
|
+
},
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
// ---- widget frame ----
|
|
704
|
+
const THEME_MODES = ['light', 'dark', 'auto'];
|
|
705
|
+
/** The iframe page. Embeddable only by the account's widget origins, since users type credentials into it. */
|
|
706
|
+
function renderFrame(template, ctx, query) {
|
|
707
|
+
const nonce = randomBytes(16).toString('base64');
|
|
708
|
+
const accent = query.get('accent') ?? '';
|
|
709
|
+
// Number(null) is 0, so an absent radius must not count as an explicit 0.
|
|
710
|
+
const radius = query.has('radius') ? Number(query.get('radius')) : NaN;
|
|
711
|
+
const config = {
|
|
712
|
+
allowedOrigins: ctx?.account.widgetOrigins ?? [],
|
|
713
|
+
valid: Boolean(ctx),
|
|
714
|
+
theme: {
|
|
715
|
+
accent: /^#[0-9a-f]{6}$/i.test(accent) ? accent : null,
|
|
716
|
+
// Light unless the host opts in: matching the host page matters more than the OS setting.
|
|
717
|
+
mode: THEME_MODES.includes(query.get('mode') ?? '') ? query.get('mode') : 'light',
|
|
718
|
+
radius: Number.isInteger(radius) && radius >= 0 && radius <= 24 ? radius : null,
|
|
719
|
+
},
|
|
720
|
+
};
|
|
721
|
+
const ancestors = ctx ? ctx.account.widgetOrigins.join(' ') : "'none'";
|
|
722
|
+
return {
|
|
723
|
+
status: ctx ? 200 : 401,
|
|
724
|
+
headers: {
|
|
725
|
+
'content-type': 'text/html; charset=utf-8',
|
|
726
|
+
'cache-control': 'no-store',
|
|
727
|
+
'referrer-policy': 'no-referrer',
|
|
728
|
+
'x-content-type-options': 'nosniff',
|
|
729
|
+
'content-security-policy': [
|
|
730
|
+
"default-src 'none'",
|
|
731
|
+
`script-src 'nonce-${nonce}'`,
|
|
732
|
+
"style-src 'unsafe-inline'",
|
|
733
|
+
"img-src 'self' data:",
|
|
734
|
+
"connect-src 'self'",
|
|
735
|
+
"base-uri 'none'",
|
|
736
|
+
"form-action 'none'",
|
|
737
|
+
`frame-ancestors ${ancestors}`,
|
|
738
|
+
].join('; '),
|
|
739
|
+
},
|
|
740
|
+
body: template
|
|
741
|
+
.replaceAll('__NONCE__', nonce)
|
|
742
|
+
// A function replacement, so `$` sequences in the JSON are never treated as patterns.
|
|
743
|
+
.replace('__CONFIG__', () => JSON.stringify(config).replace(/</g, '\\u003c')),
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
/** The account dashboard (sign-in and billing). Never framed; scripts by nonce only. */
|
|
747
|
+
/** `freeDomains`: the free plan's limit, shown on the sign-in screen. */
|
|
748
|
+
function renderDashboard(template, appName, freeDomains) {
|
|
749
|
+
const nonce = randomBytes(16).toString('base64');
|
|
750
|
+
const name = appName.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
751
|
+
return {
|
|
752
|
+
status: 200,
|
|
753
|
+
headers: {
|
|
754
|
+
'content-type': 'text/html; charset=utf-8',
|
|
755
|
+
'cache-control': 'no-store',
|
|
756
|
+
'referrer-policy': 'same-origin',
|
|
757
|
+
'x-content-type-options': 'nosniff',
|
|
758
|
+
'content-security-policy': [
|
|
759
|
+
"default-src 'none'",
|
|
760
|
+
`script-src 'nonce-${nonce}'`,
|
|
761
|
+
"style-src 'unsafe-inline'",
|
|
762
|
+
"img-src 'self' data:",
|
|
763
|
+
"connect-src 'self'",
|
|
764
|
+
"base-uri 'none'",
|
|
765
|
+
"form-action 'self'",
|
|
766
|
+
"frame-ancestors 'none'",
|
|
767
|
+
].join('; '),
|
|
768
|
+
},
|
|
769
|
+
// Function replacements, so `$` in the name is never treated as a pattern.
|
|
770
|
+
body: template
|
|
771
|
+
.replaceAll('__NONCE__', nonce)
|
|
772
|
+
.replaceAll('__APP_NAME__', () => name)
|
|
773
|
+
.replaceAll('__FREE_DOMAINS__', freeDomains === undefined ? '' : String(freeDomains)),
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
// ---- plumbing ----
|
|
777
|
+
/** The exact request body, for signature verification. */
|
|
778
|
+
async function readRawBody(req) {
|
|
779
|
+
const chunks = [];
|
|
780
|
+
let size = 0;
|
|
781
|
+
for await (const chunk of req) {
|
|
782
|
+
size += chunk.length;
|
|
783
|
+
if (size > 1024 * 1024)
|
|
784
|
+
throw new HttpError(413, 'payload_too_large', 'Request body is too large.');
|
|
785
|
+
chunks.push(chunk);
|
|
786
|
+
}
|
|
787
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
788
|
+
}
|
|
789
|
+
async function readJsonObject(req) {
|
|
790
|
+
if (!req.headers['content-type']?.startsWith('application/json')) {
|
|
791
|
+
throw new HttpError(415, 'unsupported_media_type', 'Send Content-Type: application/json.');
|
|
792
|
+
}
|
|
793
|
+
const chunks = [];
|
|
794
|
+
let size = 0;
|
|
795
|
+
for await (const chunk of req) {
|
|
796
|
+
size += chunk.length;
|
|
797
|
+
if (size > MAX_BODY_BYTES)
|
|
798
|
+
throw new HttpError(413, 'payload_too_large', 'Request body is too large.');
|
|
799
|
+
chunks.push(chunk);
|
|
800
|
+
}
|
|
801
|
+
let body;
|
|
802
|
+
try {
|
|
803
|
+
body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
804
|
+
}
|
|
805
|
+
catch {
|
|
806
|
+
throw invalid('Request body is not valid JSON.');
|
|
807
|
+
}
|
|
808
|
+
return asObject(body, 'Request body');
|
|
809
|
+
}
|
|
810
|
+
function sendJson(res, status, body) {
|
|
811
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
812
|
+
res.end(JSON.stringify(body));
|
|
813
|
+
}
|
|
814
|
+
/** Token bucket per account. Returns 0 when allowed, else ms until a token is available. */
|
|
815
|
+
class RateLimiter {
|
|
816
|
+
perSecond;
|
|
817
|
+
burst;
|
|
818
|
+
buckets = new Map();
|
|
819
|
+
constructor(perSecond, burst) {
|
|
820
|
+
this.perSecond = perSecond;
|
|
821
|
+
this.burst = burst;
|
|
822
|
+
}
|
|
823
|
+
take(key, now) {
|
|
824
|
+
const bucket = this.buckets.get(key) ?? { tokens: this.burst, at: now };
|
|
825
|
+
bucket.tokens = Math.min(this.burst, bucket.tokens + ((now - bucket.at) / 1000) * this.perSecond);
|
|
826
|
+
bucket.at = now;
|
|
827
|
+
this.buckets.set(key, bucket);
|
|
828
|
+
if (bucket.tokens >= 1) {
|
|
829
|
+
bucket.tokens -= 1;
|
|
830
|
+
return 0;
|
|
831
|
+
}
|
|
832
|
+
return ((1 - bucket.tokens) / this.perSecond) * 1000;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
/** The free plan's cap before payments launch. An operator-set domain limit replaces it. */
|
|
836
|
+
function freePlanCap(plan, contactEmail) {
|
|
837
|
+
const n = plan.freeDomains;
|
|
838
|
+
return (account) => account.domainLimit !== null
|
|
839
|
+
? null
|
|
840
|
+
: {
|
|
841
|
+
limit: n,
|
|
842
|
+
message: `The free plan includes ${n} domain${n === 1 ? '' : 's'}. Paid plans with more domains are coming soon${contactEmail ? `; email ${contactEmail} if you need more now` : ''}.`,
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
export async function startHostedApi(opts) {
|
|
846
|
+
if ((opts.signup || opts.billing || opts.site) && !opts.publicUrl) {
|
|
847
|
+
throw new ConfigError('Sign-up, billing and the website need a public URL (PUBLIC_URL).');
|
|
848
|
+
}
|
|
849
|
+
const store = opts.store ?? (await openStore(opts.database ?? ':memory:'));
|
|
850
|
+
const publicUrl = opts.publicUrl?.replace(/\/$/, '');
|
|
851
|
+
const billing = opts.billing ? new BillingService(store, opts.billing, { publicUrl: publicUrl, now: opts.now, log: opts.log }) : undefined;
|
|
852
|
+
const freePlan = billing ? undefined : opts.freePlan;
|
|
853
|
+
const service = new DomainService({
|
|
854
|
+
store,
|
|
855
|
+
vault: new Vault(opts.encryptionKey),
|
|
856
|
+
providers: opts.providers,
|
|
857
|
+
lookup: opts.lookup,
|
|
858
|
+
now: opts.now,
|
|
859
|
+
allowPrivateWebhooks: opts.allowPrivateWebhooks,
|
|
860
|
+
planCap: billing?.planCap ?? (freePlan && freePlanCap(freePlan, opts.site?.contactEmail)),
|
|
861
|
+
log: opts.log,
|
|
862
|
+
});
|
|
863
|
+
const auth = opts.signup
|
|
864
|
+
? new AccountAuth(store, service, opts.signup.mailer ?? new LogMailer(opts.log ?? (() => { })), {
|
|
865
|
+
publicUrl: publicUrl,
|
|
866
|
+
appName: opts.signup.appName ?? opts.site?.appName,
|
|
867
|
+
now: opts.now,
|
|
868
|
+
})
|
|
869
|
+
: undefined;
|
|
870
|
+
const site = opts.site
|
|
871
|
+
? new Site({
|
|
872
|
+
...opts.site,
|
|
873
|
+
publicUrl: publicUrl,
|
|
874
|
+
signupEnabled: Boolean(auth),
|
|
875
|
+
plan: billing
|
|
876
|
+
? { freeDomains: billing.freeDomains, unitAmount: billing.unitAmount, currency: billing.currency, provider: billing.provider.name }
|
|
877
|
+
: freePlan && { freeDomains: freePlan.freeDomains, unitAmount: 0, currency: 'usd', provider: '', comingSoon: true },
|
|
878
|
+
})
|
|
879
|
+
: undefined;
|
|
880
|
+
const runner = new BackgroundRunner(service, store, {
|
|
881
|
+
...opts.runner,
|
|
882
|
+
now: opts.now,
|
|
883
|
+
fetch: opts.fetch,
|
|
884
|
+
allowPrivateWebhooks: opts.allowPrivateWebhooks,
|
|
885
|
+
billing,
|
|
886
|
+
log: opts.log,
|
|
887
|
+
});
|
|
888
|
+
const widget = new WidgetService(service, store, { now: opts.now, detectHost: opts.detectHost });
|
|
889
|
+
const feedback = site && { mailer: opts.signup?.mailer ?? new LogMailer(opts.log ?? (() => { })), to: opts.site?.contactEmail };
|
|
890
|
+
const handler = createApiHandler({ service, widget, auth, billing, freePlan, site, feedback, publicUrl, rateLimit: opts.rateLimit, now: opts.now, log: opts.log });
|
|
891
|
+
const server = createServer((req, res) => void handler(req, res));
|
|
892
|
+
await new Promise((resolve, reject) => {
|
|
893
|
+
server.once('error', reject);
|
|
894
|
+
server.listen(opts.port ?? 8080, opts.host ?? '0.0.0.0', () => resolve());
|
|
895
|
+
});
|
|
896
|
+
if (opts.workers !== false)
|
|
897
|
+
runner.start();
|
|
898
|
+
const { port } = server.address();
|
|
899
|
+
return {
|
|
900
|
+
url: `http://${opts.host === '0.0.0.0' || !opts.host ? '127.0.0.1' : opts.host}:${port}`,
|
|
901
|
+
port,
|
|
902
|
+
server,
|
|
903
|
+
store,
|
|
904
|
+
service,
|
|
905
|
+
runner,
|
|
906
|
+
billing,
|
|
907
|
+
auth,
|
|
908
|
+
site,
|
|
909
|
+
async close() {
|
|
910
|
+
await new Promise((resolve) => {
|
|
911
|
+
server.close(() => resolve());
|
|
912
|
+
server.closeIdleConnections();
|
|
913
|
+
});
|
|
914
|
+
await runner.stop();
|
|
915
|
+
await store.close();
|
|
916
|
+
},
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
//# sourceMappingURL=api.js.map
|