dkimkit 0.0.0-stage → 0.6.1

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