toiljs 0.0.116 → 0.0.117
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/CHANGELOG.md +5 -0
- package/README.md +16 -7
- package/build/backend/.tsbuildinfo +1 -1
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +2375 -258
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/dev/devtools.js +1 -1
- package/build/client/dev/error-overlay.js +1 -1
- package/build/client/routing/mount.js +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/auth-emails.js +7 -3
- package/build/compiler/emails.js +1 -1
- package/build/compiler/index.d.ts +1 -0
- package/build/compiler/index.js +25 -4
- package/build/compiler/pages.js +2 -3
- package/build/compiler/prerender.d.ts +3 -8
- package/build/compiler/prerender.js +88 -127
- package/build/compiler/ssg.js +3 -4
- package/build/compiler/template-build.js +3 -4
- package/build/compiler/toil-docs.generated.js +6 -5
- package/build/compiler/vite.js +3 -6
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/db/database.js +7 -3
- package/build/devserver/db/types.js +1 -1
- package/build/devserver/production.js +4 -3
- package/build/devserver/runtime/crypto.d.ts +1 -1
- package/build/io/.tsbuildinfo +1 -1
- package/build/logger/.tsbuildinfo +1 -1
- package/build/shared/.tsbuildinfo +1 -1
- package/examples/basic/client/routes/analytics.tsx +7 -5
- package/examples/basic/server/tsconfig.json +1 -3
- package/package.json +45 -44
- package/presets/no-uint8array-tostring.js +120 -153
- package/presets/oxlint.d.ts +5 -0
- package/presets/oxlint.js +224 -0
- package/server/runtime/tsconfig.json +1 -3
- package/tsconfig.json +2 -11
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -38
- package/.github/ISSUE_TEMPLATE/bug_report.yml +0 -90
- package/.github/ISSUE_TEMPLATE/config.yml +0 -8
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -20
- package/.github/PULL_REQUEST_TEMPLATE.md +0 -43
- package/.github/changelog-config.json +0 -45
- package/.github/dependabot.yml +0 -27
- package/.github/workflows/ci.yml +0 -222
- package/.vscode/settings.json +0 -10
- package/docs/README.md +0 -107
- package/docs/auth/README.md +0 -104
- package/docs/auth/configuration.md +0 -105
- package/docs/auth/emails.md +0 -92
- package/docs/auth/extending.md +0 -202
- package/docs/auth/how-it-works.md +0 -138
- package/docs/auth/usage.md +0 -188
- package/docs/backend/README.md +0 -143
- package/docs/backend/data.md +0 -351
- package/docs/backend/rest.md +0 -402
- package/docs/backend/rpc.md +0 -226
- package/docs/background/README.md +0 -114
- package/docs/background/daemons.md +0 -276
- package/docs/background/derive.md +0 -179
- package/docs/cli/README.md +0 -379
- package/docs/concepts/ai-guide.md +0 -132
- package/docs/concepts/config.md +0 -416
- package/docs/concepts/decorators.md +0 -127
- package/docs/concepts/security.md +0 -108
- package/docs/concepts/tiers.md +0 -166
- package/docs/concepts/types.md +0 -216
- package/docs/database/README.md +0 -143
- package/docs/database/capacity.md +0 -350
- package/docs/database/counters.md +0 -174
- package/docs/database/documents.md +0 -277
- package/docs/database/events.md +0 -307
- package/docs/database/membership.md +0 -246
- package/docs/database/setup.md +0 -155
- package/docs/database/unique.md +0 -216
- package/docs/database/views.md +0 -246
- package/docs/frontend/README.md +0 -104
- package/docs/frontend/components.md +0 -286
- package/docs/frontend/data-fetching.md +0 -260
- package/docs/frontend/images.md +0 -148
- package/docs/frontend/metadata.md +0 -344
- package/docs/frontend/navigation.md +0 -296
- package/docs/frontend/rendering.md +0 -236
- package/docs/frontend/routing.md +0 -346
- package/docs/frontend/scripts.md +0 -118
- package/docs/frontend/search.md +0 -191
- package/docs/frontend/styling.md +0 -147
- package/docs/frontend/toil-global.md +0 -216
- package/docs/getting-started/README.md +0 -81
- package/docs/getting-started/create-project.md +0 -131
- package/docs/getting-started/deploy.md +0 -101
- package/docs/getting-started/first-app.md +0 -215
- package/docs/getting-started/installation.md +0 -124
- package/docs/getting-started/migrating.md +0 -125
- package/docs/getting-started/project-structure.md +0 -163
- package/docs/introduction/README.md +0 -66
- package/docs/introduction/design-principles.md +0 -81
- package/docs/introduction/distributed.md +0 -91
- package/docs/introduction/how-it-works.md +0 -121
- package/docs/introduction/hyperscale.md +0 -70
- package/docs/introduction/modern-stack.md +0 -85
- package/docs/introduction/vs-other-frameworks.md +0 -64
- package/docs/introduction/why-toil.md +0 -99
- package/docs/llms.txt +0 -95
- package/docs/realtime/README.md +0 -102
- package/docs/realtime/channels.md +0 -211
- package/docs/realtime/streams.md +0 -369
- package/docs/services/README.md +0 -60
- package/docs/services/analytics.md +0 -279
- package/docs/services/caching.md +0 -175
- package/docs/services/cookies.md +0 -235
- package/docs/services/crypto.md +0 -209
- package/docs/services/email.md +0 -289
- package/docs/services/environment.md +0 -141
- package/docs/services/ratelimit.md +0 -174
- package/docs/services/time.md +0 -85
- package/eslint.config.js +0 -57
- package/examples/basic/client/routes/docs/[...slug].tsx +0 -12
- package/presets/eslint.js +0 -88
- package/src/backend/index.ts +0 -204
- package/src/cli/configure.ts +0 -363
- package/src/cli/create.ts +0 -924
- package/src/cli/db.ts +0 -158
- package/src/cli/diagnostics.ts +0 -665
- package/src/cli/doctor.ts +0 -982
- package/src/cli/features.ts +0 -170
- package/src/cli/index.ts +0 -326
- package/src/cli/notify.ts +0 -120
- package/src/cli/proc.ts +0 -56
- package/src/cli/ui.ts +0 -169
- package/src/cli/update.ts +0 -248
- package/src/cli/updates.ts +0 -92
- package/src/cli/validate.ts +0 -31
- package/src/cli/version-check.ts +0 -125
- package/src/client/auth.ts +0 -869
- package/src/client/channel/channel.ts +0 -146
- package/src/client/components/Form.tsx +0 -65
- package/src/client/components/Image.tsx +0 -160
- package/src/client/components/Script.tsx +0 -113
- package/src/client/components/Slot.tsx +0 -21
- package/src/client/dev/devtools.tsx +0 -1089
- package/src/client/dev/error-overlay.tsx +0 -225
- package/src/client/errors.ts +0 -11
- package/src/client/head/head.ts +0 -182
- package/src/client/head/metadata.ts +0 -110
- package/src/client/index.ts +0 -130
- package/src/client/navigation/Link.tsx +0 -108
- package/src/client/navigation/NavLink.tsx +0 -86
- package/src/client/navigation/navigation.ts +0 -235
- package/src/client/navigation/prefetch.ts +0 -169
- package/src/client/navigation/scroll.ts +0 -53
- package/src/client/routing/Router.tsx +0 -216
- package/src/client/routing/action.ts +0 -122
- package/src/client/routing/error-boundary.tsx +0 -43
- package/src/client/routing/hooks.ts +0 -140
- package/src/client/routing/lazy.ts +0 -77
- package/src/client/routing/loader.ts +0 -381
- package/src/client/routing/match.ts +0 -47
- package/src/client/routing/mount.tsx +0 -90
- package/src/client/routing/params-context.ts +0 -10
- package/src/client/routing/slot-context.ts +0 -7
- package/src/client/rpc.ts +0 -97
- package/src/client/search/search.ts +0 -189
- package/src/client/search/use-page-search.ts +0 -73
- package/src/client/ssr/markers.tsx +0 -172
- package/src/client/stream/client.ts +0 -237
- package/src/client/types.ts +0 -73
- package/src/compiler/auth-emails.ts +0 -312
- package/src/compiler/config.ts +0 -369
- package/src/compiler/docs.ts +0 -127
- package/src/compiler/email-preview.ts +0 -314
- package/src/compiler/emails.ts +0 -404
- package/src/compiler/fonts.ts +0 -89
- package/src/compiler/generate.ts +0 -471
- package/src/compiler/image-blur.ts +0 -42
- package/src/compiler/image-report.ts +0 -88
- package/src/compiler/index.ts +0 -1362
- package/src/compiler/pages.ts +0 -49
- package/src/compiler/plugin.ts +0 -391
- package/src/compiler/prerender.ts +0 -263
- package/src/compiler/routes.ts +0 -170
- package/src/compiler/seo.ts +0 -415
- package/src/compiler/sri.ts +0 -144
- package/src/compiler/ssg.ts +0 -177
- package/src/compiler/ssr-codegen.ts +0 -85
- package/src/compiler/template-build.ts +0 -676
- package/src/compiler/template.ts +0 -262
- package/src/compiler/toil-docs.generated.ts +0 -77
- package/src/compiler/vite.ts +0 -245
- package/src/devserver/analytics/index.ts +0 -317
- package/src/devserver/config/dotenv.ts +0 -102
- package/src/devserver/config/env.ts +0 -30
- package/src/devserver/config/ratelimit.ts +0 -123
- package/src/devserver/daemon/catalog.ts +0 -120
- package/src/devserver/daemon/cron.ts +0 -87
- package/src/devserver/daemon/host.ts +0 -134
- package/src/devserver/daemon/index.ts +0 -369
- package/src/devserver/daemon/runtime.ts +0 -48
- package/src/devserver/db/catalog.ts +0 -109
- package/src/devserver/db/database.ts +0 -1834
- package/src/devserver/db/derives.ts +0 -121
- package/src/devserver/db/index.ts +0 -20
- package/src/devserver/db/routeKinds.ts +0 -191
- package/src/devserver/db/types.ts +0 -168
- package/src/devserver/email/caps.ts +0 -0
- package/src/devserver/email/config.ts +0 -141
- package/src/devserver/email/console.ts +0 -348
- package/src/devserver/email/index.ts +0 -155
- package/src/devserver/email/providers.ts +0 -131
- package/src/devserver/email/status.ts +0 -23
- package/src/devserver/email/validate.ts +0 -37
- package/src/devserver/email/wire.ts +0 -55
- package/src/devserver/http/cache.ts +0 -0
- package/src/devserver/http/envelope.ts +0 -157
- package/src/devserver/http/proxy.ts +0 -172
- package/src/devserver/http/runtime.ts +0 -387
- package/src/devserver/index.ts +0 -43
- package/src/devserver/production-ipc.ts +0 -63
- package/src/devserver/production-worker.ts +0 -83
- package/src/devserver/production.ts +0 -718
- package/src/devserver/runtime/crypto.ts +0 -587
- package/src/devserver/runtime/host.ts +0 -427
- package/src/devserver/runtime/module.ts +0 -442
- package/src/devserver/server.ts +0 -251
- package/src/devserver/ssr.ts +0 -208
- package/src/devserver/stream/catalog.ts +0 -106
- package/src/devserver/stream/host.ts +0 -42
- package/src/devserver/stream/index.ts +0 -301
- package/src/devserver/stream/manager.ts +0 -167
- package/src/devserver/stream/router.ts +0 -101
- package/src/devserver/stream/wire.ts +0 -58
- package/src/devserver/stream/ws.ts +0 -78
- package/src/devserver/wasm/sections.ts +0 -59
- package/src/devserver/wasm/surface.ts +0 -115
- package/src/io/FastMap.ts +0 -151
- package/src/io/FastSet.ts +0 -113
- package/src/io/codec.ts +0 -362
- package/src/io/index.ts +0 -10
- package/src/io/lengths.ts +0 -14
- package/src/io/types.ts +0 -19
- package/src/logger/index.ts +0 -22
- package/src/shared/index.ts +0 -46
- package/test/analytics-dev.test.ts +0 -113
- package/test/assembly/aspect-shim.ts +0 -33
- package/test/assembly/cookie.spec.ts +0 -303
- package/test/assembly/example.spec.ts +0 -22
- package/test/assembly/ssr.spec.ts +0 -95
- package/test/assembly.test.ts +0 -59
- package/test/auth-emails.test.ts +0 -118
- package/test/built-ssr.test.ts +0 -148
- package/test/channel.test.ts +0 -21
- package/test/config-host-port.test.ts +0 -59
- package/test/configure.test.ts +0 -94
- package/test/daemon-build.test.ts +0 -278
- package/test/daemon-catalog.test.ts +0 -349
- package/test/daemon-emulation.test.ts +0 -256
- package/test/db.test.ts +0 -0
- package/test/devserver-database.test.ts +0 -1102
- package/test/devserver-email.test.ts +0 -306
- package/test/devserver-pqauth.test.ts +0 -93
- package/test/devserver-secrets.test.ts +0 -63
- package/test/devserver.test.ts +0 -259
- package/test/doctor.test.ts +0 -298
- package/test/dom/Image.test.tsx +0 -144
- package/test/dom/Link.test.tsx +0 -67
- package/test/dom/NavLink.test.tsx +0 -37
- package/test/dom/Script.test.tsx +0 -48
- package/test/dom/action.test.tsx +0 -146
- package/test/dom/error-overlay.test.tsx +0 -44
- package/test/dom/loader.test.tsx +0 -121
- package/test/dom/navigation.test.ts +0 -59
- package/test/dom/revalidate.test.tsx +0 -38
- package/test/dom/route-head.test.tsx +0 -56
- package/test/dom/router-loading.test.tsx +0 -44
- package/test/dom/scroll.test.ts +0 -56
- package/test/dom/slot.test.tsx +0 -131
- package/test/dom/use-metadata.test.tsx +0 -58
- package/test/dom/view-transitions.test.tsx +0 -53
- package/test/dynamic-routing.test.ts +0 -132
- package/test/email-console.test.ts +0 -86
- package/test/email-preview.test.ts +0 -73
- package/test/emails.test.ts +0 -58
- package/test/errors.test.ts +0 -21
- package/test/example-guestbook.test.ts +0 -123
- package/test/features.test.ts +0 -149
- package/test/fixtures/bignum-wire/spec.ts +0 -25
- package/test/fixtures/daemon-app.ts +0 -65
- package/test/fixtures/stream-echo.ts +0 -26
- package/test/fixtures/stream-gate.ts +0 -28
- package/test/fixtures/stream-trap.ts +0 -18
- package/test/fixtures/stream-typed.ts +0 -41
- package/test/fonts.test.ts +0 -28
- package/test/global-setup.ts +0 -17
- package/test/head.test.ts +0 -42
- package/test/image-blur.test.ts +0 -50
- package/test/io.test.ts +0 -117
- package/test/metadata.test.ts +0 -40
- package/test/navlink.test.ts +0 -28
- package/test/pages.test.ts +0 -105
- package/test/placeholder.test.ts +0 -9
- package/test/pqauth-e2e.test.ts +0 -216
- package/test/pqauth-email.test.ts +0 -801
- package/test/prerender.test.ts +0 -116
- package/test/prettier-plugin.test.ts +0 -46
- package/test/routes.test.ts +0 -76
- package/test/rpc-bignum-wire.test.ts +0 -164
- package/test/rpc-dispatch.test.ts +0 -132
- package/test/rpc-kinds.test.ts +0 -18
- package/test/rpc.test.ts +0 -66
- package/test/search.test.ts +0 -114
- package/test/seo.test.ts +0 -175
- package/test/slot-layouts.test.ts +0 -69
- package/test/sri.test.ts +0 -129
- package/test/ssg.test.ts +0 -36
- package/test/ssr-hydration.test.tsx +0 -122
- package/test/ssr-render.test.ts +0 -197
- package/test/ssr-template.test.tsx +0 -482
- package/test/stream-emulation.test.ts +0 -479
- package/test/ui.test.ts +0 -40
- package/test/update.test.ts +0 -58
- package/test/validate.test.ts +0 -42
- package/test/version-check.test.ts +0 -130
- package/tests/data-parity/generated-parity.ts +0 -99
- package/tests/data-parity/parity.ts +0 -80
- package/tests/data-parity/spec.ts +0 -46
|
@@ -1,306 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The dev / self-host email pipeline (src/devserver/email): wire parse,
|
|
3
|
-
* recipient validation, in-process caps (dedup + per-recipient + per-min/day
|
|
4
|
-
* budget), config resolution (toil.config + TOIL_EMAIL_* env), the Resend
|
|
5
|
-
* transport, and the NodeEmailService prepare/deliver split. Mirrors the edge
|
|
6
|
-
* (toil-backend host/email.rs, mailer.rs, email_budget.rs).
|
|
7
|
-
*/
|
|
8
|
-
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
9
|
-
|
|
10
|
-
import { EmailCaps } from '../src/devserver/email/caps.js';
|
|
11
|
-
import { resolveEmailConfig, type ResolvedEmailConfig } from '../src/devserver/email/config.js';
|
|
12
|
-
import {
|
|
13
|
-
NodeEmailService,
|
|
14
|
-
__sentEmails,
|
|
15
|
-
__clearSentEmails,
|
|
16
|
-
resetEmailService,
|
|
17
|
-
} from '../src/devserver/email/index.js';
|
|
18
|
-
import { sendResend } from '../src/devserver/email/providers.js';
|
|
19
|
-
import { EmailStatus } from '../src/devserver/email/status.js';
|
|
20
|
-
import { validFrom, validRecipient } from '../src/devserver/email/validate.js';
|
|
21
|
-
import { parseEmailBlob } from '../src/devserver/email/wire.js';
|
|
22
|
-
import { buildHostImports, freshDispatchState, type MemoryRef } from '../src/devserver/runtime/host.js';
|
|
23
|
-
|
|
24
|
-
/** Encode an `email_send` blob exactly like the guest (`server/globals/email.ts`). */
|
|
25
|
-
function encodeBlob(to: string, subject: string, purpose: string, body: string, html: string): Buffer {
|
|
26
|
-
const e = (s: string): Buffer => Buffer.from(s, 'utf8');
|
|
27
|
-
const [t, s, p, b, h] = [e(to), e(subject), e(purpose), e(body), e(html)];
|
|
28
|
-
const head = Buffer.alloc(14);
|
|
29
|
-
head.writeUInt16LE(t.length, 0);
|
|
30
|
-
head.writeUInt16LE(s.length, 2);
|
|
31
|
-
head.writeUInt16LE(p.length, 4);
|
|
32
|
-
head.writeUInt32LE(b.length, 6);
|
|
33
|
-
head.writeUInt32LE(h.length, 10);
|
|
34
|
-
return Buffer.concat([head, t, s, p, b, h]);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const reserved = (o: Record<string, string>): Map<string, string> => new Map(Object.entries(o));
|
|
38
|
-
|
|
39
|
-
describe('validate', () => {
|
|
40
|
-
it('accepts plain addresses, rejects injection / multiple / malformed', () => {
|
|
41
|
-
expect(validRecipient('user@example.com')).toBe(true);
|
|
42
|
-
expect(validRecipient('a.b+tag@sub.example.co')).toBe(true);
|
|
43
|
-
for (const bad of [
|
|
44
|
-
'a@b.com\r\nBcc: evil@x.com',
|
|
45
|
-
'a@b.com\nDATA',
|
|
46
|
-
'a@b.com\0',
|
|
47
|
-
'a@b.com,c@d.com',
|
|
48
|
-
'a@b.com;c@d.com',
|
|
49
|
-
'a@b.com c@d.com',
|
|
50
|
-
'<a@b.com>',
|
|
51
|
-
'"a"@b.com',
|
|
52
|
-
'',
|
|
53
|
-
'nobody',
|
|
54
|
-
'a@b@c.com',
|
|
55
|
-
'@b.com',
|
|
56
|
-
'a@bcom',
|
|
57
|
-
'a@.com',
|
|
58
|
-
'a@b.',
|
|
59
|
-
]) {
|
|
60
|
-
expect(validRecipient(bad), bad).toBe(false);
|
|
61
|
-
}
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('validFrom is lenient but rejects header injection', () => {
|
|
65
|
-
expect(validFrom('me@example.com')).toBe(true);
|
|
66
|
-
expect(validFrom('a@b.com\r\nBcc: x')).toBe(false);
|
|
67
|
-
expect(validFrom('no-at-sign')).toBe(false);
|
|
68
|
-
});
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
describe('wire parse', () => {
|
|
72
|
-
it('round-trips a well-formed blob', () => {
|
|
73
|
-
const raw = encodeBlob('a@b.com', 'Hi', 'verify', '123456', '<b>x</b>');
|
|
74
|
-
const p = parseEmailBlob(raw);
|
|
75
|
-
expect(p).toEqual({ to: 'a@b.com', subject: 'Hi', purpose: 'verify', body: '123456', html: '<b>x</b>' });
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('rejects truncated / trailing-garbage / non-UTF8', () => {
|
|
79
|
-
expect(parseEmailBlob(Buffer.alloc(4))).toBeNull(); // shorter than header
|
|
80
|
-
const raw = encodeBlob('a@b.com', 's', 'p', 'body', '');
|
|
81
|
-
expect(parseEmailBlob(raw.subarray(0, raw.length - 1))).toBeNull(); // truncated payload
|
|
82
|
-
expect(parseEmailBlob(Buffer.concat([raw, Buffer.from([0xff])]))).toBeNull(); // trailing garbage
|
|
83
|
-
const bad = Buffer.from(raw);
|
|
84
|
-
bad[bad.length - 1] = 0xff; // corrupt last body byte -> invalid UTF-8
|
|
85
|
-
expect(parseEmailBlob(bad)).toBeNull();
|
|
86
|
-
});
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
describe('caps', () => {
|
|
90
|
-
it('dedup collapses an identical (recipient, purpose) within the window', () => {
|
|
91
|
-
const c = new EmailCaps();
|
|
92
|
-
expect(c.isDuplicate('a@b.com', 'verify', 0)).toBe(false); // first
|
|
93
|
-
expect(c.isDuplicate('a@b.com', 'verify', 1_000)).toBe(true); // repeat in window
|
|
94
|
-
expect(c.isDuplicate('a@b.com', 'reset', 1_000)).toBe(false); // different purpose
|
|
95
|
-
expect(c.isDuplicate('a@b.com', 'verify', 31_000)).toBe(false); // after the 30s window
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it('per-recipient hourly cap holds then resets', () => {
|
|
99
|
-
const c = new EmailCaps();
|
|
100
|
-
expect(c.recipientOk('x@y.com', 2, 0)).toBe(true);
|
|
101
|
-
expect(c.recipientOk('x@y.com', 2, 10)).toBe(true);
|
|
102
|
-
expect(c.recipientOk('x@y.com', 2, 20)).toBe(false); // third over the cap
|
|
103
|
-
expect(c.recipientOk('z@y.com', 2, 20)).toBe(true); // a different recipient is independent
|
|
104
|
-
expect(c.recipientOk('x@y.com', 2, 3_600_001)).toBe(true); // next hour resets
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it('per-minute and per-day budgets both gate, 0 = unlimited', () => {
|
|
108
|
-
const c = new EmailCaps();
|
|
109
|
-
expect(c.budgetOk(2, 0, 0)).toBe(true);
|
|
110
|
-
expect(c.budgetOk(2, 0, 0)).toBe(true);
|
|
111
|
-
expect(c.budgetOk(2, 0, 0)).toBe(false); // minute cap (2) exhausted at the same instant
|
|
112
|
-
// A separate budget instance with only a day cap.
|
|
113
|
-
const d = new EmailCaps();
|
|
114
|
-
expect(d.budgetOk(0, 1, 0)).toBe(true);
|
|
115
|
-
expect(d.budgetOk(0, 1, 0)).toBe(false); // day cap (1) exhausted
|
|
116
|
-
// Unlimited (0/0) never blocks.
|
|
117
|
-
const u = new EmailCaps();
|
|
118
|
-
for (let i = 0; i < 100; i++) expect(u.budgetOk(0, 0, i)).toBe(true);
|
|
119
|
-
});
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
describe('config resolution', () => {
|
|
123
|
-
it('is unconfigured (silent) with no config + no env', () => {
|
|
124
|
-
expect(resolveEmailConfig(null, reserved({}))).toEqual({ config: null, warning: null });
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it('merges toil.config + env, api key from env, env wins', () => {
|
|
128
|
-
const { config } = resolveEmailConfig(
|
|
129
|
-
{ provider: 'resend', from: 'cfg@x.com', maxPerMin: 10 },
|
|
130
|
-
reserved({ TOIL_EMAIL_API_KEY: 're_k', TOIL_EMAIL_FROM: 'env@x.com' }),
|
|
131
|
-
);
|
|
132
|
-
expect(config).toMatchObject({ provider: 'resend', from: 'env@x.com', apiKey: 're_k', maxPerMin: 10 });
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it('gmail resolves to smtp with defaults', () => {
|
|
136
|
-
const { config } = resolveEmailConfig(
|
|
137
|
-
{ provider: 'gmail', from: 'me@gmail.com' },
|
|
138
|
-
reserved({ TOIL_EMAIL_API_KEY: 'app-pw' }),
|
|
139
|
-
);
|
|
140
|
-
expect(config).toMatchObject({ provider: 'smtp', smtp: { host: 'smtp.gmail.com', port: 587, user: 'me@gmail.com' } });
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it('warns (off) on partial/invalid config', () => {
|
|
144
|
-
// from but no api key
|
|
145
|
-
expect(resolveEmailConfig({ from: 'a@b.com' }, reserved({})).warning).toMatch(/API_KEY/);
|
|
146
|
-
// bad from
|
|
147
|
-
expect(resolveEmailConfig({ from: 'no-at' }, reserved({ TOIL_EMAIL_API_KEY: 'k' })).warning).toMatch(/from/);
|
|
148
|
-
// smtp without host
|
|
149
|
-
expect(
|
|
150
|
-
resolveEmailConfig({ provider: 'smtp', from: 'a@b.com' }, reserved({ TOIL_EMAIL_API_KEY: 'k' })).warning,
|
|
151
|
-
).toMatch(/SMTP_HOST/);
|
|
152
|
-
// unknown provider
|
|
153
|
-
expect(
|
|
154
|
-
resolveEmailConfig({ from: 'a@b.com' }, reserved({ TOIL_EMAIL_API_KEY: 'k', TOIL_EMAIL_PROVIDER: 'mailgun' }))
|
|
155
|
-
.warning,
|
|
156
|
-
).toMatch(/unknown/);
|
|
157
|
-
// gmail still needs an app password
|
|
158
|
-
expect(
|
|
159
|
-
resolveEmailConfig({ provider: 'gmail', from: 'me@gmail.com' }, reserved({})).warning,
|
|
160
|
-
).toMatch(/gmail/);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it('plain SMTP needs NO api key (a local / auth-less relay)', () => {
|
|
164
|
-
const { config, warning } = resolveEmailConfig(
|
|
165
|
-
{ provider: 'smtp', from: 'dev@dacely.local' },
|
|
166
|
-
reserved({ TOIL_EMAIL_SMTP_HOST: '127.0.0.1' }),
|
|
167
|
-
);
|
|
168
|
-
expect(warning).toBeNull();
|
|
169
|
-
expect(config).toMatchObject({
|
|
170
|
-
provider: 'smtp',
|
|
171
|
-
from: 'dev@dacely.local',
|
|
172
|
-
apiKey: '', // no credential -> the send path omits SMTP auth
|
|
173
|
-
smtp: { host: '127.0.0.1', port: 587 },
|
|
174
|
-
});
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
it('TOIL_EMAIL_ENABLED=false disables silently', () => {
|
|
178
|
-
expect(
|
|
179
|
-
resolveEmailConfig({ provider: 'resend', from: 'a@b.com' }, reserved({ TOIL_EMAIL_API_KEY: 'k', TOIL_EMAIL_ENABLED: 'false' })),
|
|
180
|
-
).toEqual({ config: null, warning: null });
|
|
181
|
-
});
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
describe('Resend transport', () => {
|
|
185
|
-
afterEach(() => vi.unstubAllGlobals());
|
|
186
|
-
|
|
187
|
-
const cfg: ResolvedEmailConfig = {
|
|
188
|
-
provider: 'resend',
|
|
189
|
-
from: 'noreply@x.com',
|
|
190
|
-
apiKey: 're_secret',
|
|
191
|
-
maxPerMin: 0,
|
|
192
|
-
maxPerDay: 0,
|
|
193
|
-
maxPerRecipientPerHour: 0,
|
|
194
|
-
};
|
|
195
|
-
|
|
196
|
-
it('2xx -> Sent, with the right payload + Bearer auth', async () => {
|
|
197
|
-
const fetchMock = vi.fn(async () => new Response(null, { status: 200 }));
|
|
198
|
-
vi.stubGlobal('fetch', fetchMock);
|
|
199
|
-
const status = await sendResend(cfg, { from: cfg.from, to: 'a@b.com', subject: 'Hi', body: 'text', html: '<b>h</b>' });
|
|
200
|
-
expect(status).toBe(EmailStatus.Sent);
|
|
201
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
202
|
-
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
203
|
-
expect((init.headers as Record<string, string>).authorization).toBe('Bearer re_secret');
|
|
204
|
-
expect(JSON.parse(init.body as string)).toEqual({ from: 'noreply@x.com', to: ['a@b.com'], subject: 'Hi', text: 'text', html: '<b>h</b>' });
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
it('4xx -> ProviderError, no retry', async () => {
|
|
208
|
-
const fetchMock = vi.fn(async () => new Response(null, { status: 422 }));
|
|
209
|
-
vi.stubGlobal('fetch', fetchMock);
|
|
210
|
-
const status = await sendResend(cfg, { from: cfg.from, to: 'a@b.com', subject: 's', body: 'b', html: '' });
|
|
211
|
-
expect(status).toBe(EmailStatus.ProviderError);
|
|
212
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
213
|
-
});
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
describe('NodeEmailService pipeline', () => {
|
|
217
|
-
afterEach(() => vi.unstubAllGlobals());
|
|
218
|
-
|
|
219
|
-
const base: ResolvedEmailConfig = {
|
|
220
|
-
provider: 'resend',
|
|
221
|
-
from: 'noreply@x.com',
|
|
222
|
-
apiKey: 're_secret',
|
|
223
|
-
maxPerMin: 0,
|
|
224
|
-
maxPerDay: 0,
|
|
225
|
-
maxPerRecipientPerHour: 0,
|
|
226
|
-
};
|
|
227
|
-
|
|
228
|
-
it('prepare returns terminal statuses and a parsed message to deliver', () => {
|
|
229
|
-
const svc = new NodeEmailService(base);
|
|
230
|
-
// Bad recipient.
|
|
231
|
-
expect(svc.prepare(encodeBlob('not-an-email', 's', 'p', 'b', ''), 0).status).toBe(EmailStatus.BadRecipient);
|
|
232
|
-
// Valid -> Sent + parsed.
|
|
233
|
-
const ok = svc.prepare(encodeBlob('a@b.com', 's', 'verify', 'b', ''), 0);
|
|
234
|
-
expect(ok.status).toBe(EmailStatus.Sent);
|
|
235
|
-
expect(ok.parsed?.to).toBe('a@b.com');
|
|
236
|
-
// Identical (to, purpose) again -> Deduped.
|
|
237
|
-
expect(svc.prepare(encodeBlob('a@b.com', 's', 'verify', 'b', ''), 1_000).status).toBe(EmailStatus.Deduped);
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
it('enforces the per-minute budget', () => {
|
|
241
|
-
const svc = new NodeEmailService({ ...base, maxPerMin: 2 });
|
|
242
|
-
// Distinct recipients so dedup/recipient caps don't fire first.
|
|
243
|
-
expect(svc.prepare(encodeBlob('a@x.com', 's', 'p', 'b', ''), 0).status).toBe(EmailStatus.Sent);
|
|
244
|
-
expect(svc.prepare(encodeBlob('b@x.com', 's', 'p', 'b', ''), 0).status).toBe(EmailStatus.Sent);
|
|
245
|
-
expect(svc.prepare(encodeBlob('c@x.com', 's', 'p', 'b', ''), 0).status).toBe(EmailStatus.Budget);
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
it('enforces the per-recipient hourly cap', () => {
|
|
249
|
-
const svc = new NodeEmailService({ ...base, maxPerRecipientPerHour: 2 });
|
|
250
|
-
// Same recipient, distinct purposes so dedup doesn't fire.
|
|
251
|
-
expect(svc.prepare(encodeBlob('a@x.com', 's', 'p1', 'b', ''), 0).status).toBe(EmailStatus.Sent);
|
|
252
|
-
expect(svc.prepare(encodeBlob('a@x.com', 's', 'p2', 'b', ''), 0).status).toBe(EmailStatus.Sent);
|
|
253
|
-
expect(svc.prepare(encodeBlob('a@x.com', 's', 'p3', 'b', ''), 0).status).toBe(EmailStatus.RecipientCapped);
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
it('deliver actually sends via the provider', async () => {
|
|
257
|
-
const fetchMock = vi.fn(async () => new Response(null, { status: 200 }));
|
|
258
|
-
vi.stubGlobal('fetch', fetchMock);
|
|
259
|
-
const svc = new NodeEmailService(base);
|
|
260
|
-
const { parsed } = svc.prepare(encodeBlob('a@b.com', 's', 'p', 'b', ''), 0);
|
|
261
|
-
expect(parsed).not.toBeNull();
|
|
262
|
-
expect(await svc.deliver(parsed!)).toBe(EmailStatus.Sent);
|
|
263
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
264
|
-
});
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
describe('host imports: email_send_detached (the anti-enumeration fire-and-forget path)', () => {
|
|
268
|
-
afterEach(() => {
|
|
269
|
-
resetEmailService();
|
|
270
|
-
__clearSentEmails();
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
/** Build a dev host import table over a fresh wasm memory holding `blob` at offset 0. */
|
|
274
|
-
function wireHost(blob: Buffer): {
|
|
275
|
-
env: Record<string, (...a: number[]) => number>;
|
|
276
|
-
len: number;
|
|
277
|
-
} {
|
|
278
|
-
const memory = new WebAssembly.Memory({ initial: 1 });
|
|
279
|
-
Buffer.from(memory.buffer).set(blob, 0);
|
|
280
|
-
const ref: MemoryRef = { memory };
|
|
281
|
-
const imports = buildHostImports(ref, freshDispatchState());
|
|
282
|
-
return { env: imports.env as Record<string, (...a: number[]) => number>, len: blob.length };
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
it('is registered and returns a queue-accept status immediately (no provider round-trip)', () => {
|
|
286
|
-
resetEmailService(); // unconfigured: log-only stub, still constant-time
|
|
287
|
-
__clearSentEmails();
|
|
288
|
-
const { env, len } = wireHost(encodeBlob('someone@x.com', 'Subj', 'verify', 'tok', '<b>x</b>'));
|
|
289
|
-
// The detached import exists (mirrors the edge's env.email_send_detached).
|
|
290
|
-
expect(typeof env.email_send_detached).toBe('function');
|
|
291
|
-
// It returns synchronously with a queue-accept status (Sent == accepted/queued).
|
|
292
|
-
expect(env.email_send_detached(0, len)).toBe(EmailStatus.Sent);
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
it('feeds the __sentEmails capture seam so confirm/reset tests still find the token', () => {
|
|
296
|
-
resetEmailService();
|
|
297
|
-
__clearSentEmails();
|
|
298
|
-
const link = 'https://app.example.com/reset#token=deadbeef';
|
|
299
|
-
const { env, len } = wireHost(encodeBlob('user@x.com', 'Reset', 'reset', link, ''));
|
|
300
|
-
env.email_send_detached(0, len);
|
|
301
|
-
const captured = __sentEmails.find((e) => e.to === 'user@x.com');
|
|
302
|
-
expect(captured).toBeTruthy();
|
|
303
|
-
expect(captured!.purpose).toBe('reset');
|
|
304
|
-
expect(captured!.text).toContain('token=deadbeef'); // the emailed token survives to the seam
|
|
305
|
-
});
|
|
306
|
-
});
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Dev-server post-quantum auth host mocks: the ML-KEM-768 decapsulation and the
|
|
3
|
-
* RFC 9497 OPRF evaluation must be byte-identical to the production Rust edge
|
|
4
|
-
* (`toil-backend` fips203 / `voprf` crate). The OPRF mock is asserted against the
|
|
5
|
-
* same RFC 9497 Appendix A.1.1 vector the edge test uses — if both match the RFC,
|
|
6
|
-
* the noble client interops with both servers. (Account/challenge persistence is
|
|
7
|
-
* ToilDB now, exercised end-to-end in `pqauth-e2e.test.ts`.)
|
|
8
|
-
*/
|
|
9
|
-
import { describe, expect, it } from 'vitest';
|
|
10
|
-
|
|
11
|
-
import { ml_kem768 } from '@dacely/noble-post-quantum/ml-kem.js';
|
|
12
|
-
|
|
13
|
-
import { buildCryptoImports, freshCryptoState } from '../src/devserver/runtime/crypto.js';
|
|
14
|
-
|
|
15
|
-
type Ref = { memory: WebAssembly.Memory | null };
|
|
16
|
-
|
|
17
|
-
function freshMem(pages = 4): Ref {
|
|
18
|
-
return { memory: new WebAssembly.Memory({ initial: pages }) };
|
|
19
|
-
}
|
|
20
|
-
function buf(ref: Ref): Buffer {
|
|
21
|
-
return Buffer.from(ref.memory!.buffer);
|
|
22
|
-
}
|
|
23
|
-
function put(ref: Ref, ptr: number, bytes: Uint8Array): void {
|
|
24
|
-
buf(ref).set(bytes, ptr);
|
|
25
|
-
}
|
|
26
|
-
const h2b = (h: string): Uint8Array => Uint8Array.from(Buffer.from(h, 'hex'));
|
|
27
|
-
const b2h = (u: Uint8Array): string => Buffer.from(u).toString('hex');
|
|
28
|
-
|
|
29
|
-
describe('crypto.mlkem_decapsulate dev mock', () => {
|
|
30
|
-
it('recovers the shared secret a noble client encapsulated (wiring round-trip)', () => {
|
|
31
|
-
const ref = freshMem();
|
|
32
|
-
const imports = buildCryptoImports(ref, freshCryptoState());
|
|
33
|
-
const decap = imports['crypto.mlkem_decapsulate'];
|
|
34
|
-
|
|
35
|
-
const seed = new Uint8Array(64).fill(9);
|
|
36
|
-
const { publicKey, secretKey } = ml_kem768.keygen(seed);
|
|
37
|
-
const { cipherText, sharedSecret } = ml_kem768.encapsulate(publicKey);
|
|
38
|
-
|
|
39
|
-
const ctPtr = 0;
|
|
40
|
-
const skPtr = 4096;
|
|
41
|
-
const outPtr = 16384;
|
|
42
|
-
put(ref, ctPtr, cipherText); // 1088
|
|
43
|
-
put(ref, skPtr, secretKey); // 2400
|
|
44
|
-
|
|
45
|
-
const rc = decap(ctPtr, cipherText.length, skPtr, secretKey.length, outPtr);
|
|
46
|
-
expect(rc).toBe(0);
|
|
47
|
-
const got = buf(ref).subarray(outPtr, outPtr + 32);
|
|
48
|
-
expect(b2h(got)).toBe(b2h(sharedSecret));
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it('rejects wrong sizes with -4', () => {
|
|
52
|
-
const ref = freshMem();
|
|
53
|
-
const decap = buildCryptoImports(ref, freshCryptoState())['crypto.mlkem_decapsulate'];
|
|
54
|
-
expect(decap(0, 1087, 4096, 2400, 16384)).toBe(-4);
|
|
55
|
-
expect(decap(0, 1088, 4096, 2399, 16384)).toBe(-4);
|
|
56
|
-
});
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
describe('crypto.voprf_evaluate dev mock (RFC 9497 A.1.1, ristretto255-SHA512)', () => {
|
|
60
|
-
// The interop gate: matching the RFC means the dev mock == the edge ==
|
|
61
|
-
// anything else RFC 9497-conformant (the noble client).
|
|
62
|
-
const SEED = 'a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3';
|
|
63
|
-
const KEY_INFO = '74657374206b6579'; // "test key"
|
|
64
|
-
const BLINDED = '609a0ae68c15a3cf6903766461307e5c8bb2f95e7e6550e1ffa2dc99e412803c';
|
|
65
|
-
const EVALUATED = '7ec6578ae5120958eb2db1745758ff379e77cb64fe77b0b2d8cc917ea0869c7e';
|
|
66
|
-
|
|
67
|
-
it('matches the RFC evaluated element', () => {
|
|
68
|
-
const ref = freshMem();
|
|
69
|
-
const evaluate = buildCryptoImports(ref, freshCryptoState())['crypto.voprf_evaluate'];
|
|
70
|
-
|
|
71
|
-
const seed = h2b(SEED);
|
|
72
|
-
const info = h2b(KEY_INFO);
|
|
73
|
-
const blinded = h2b(BLINDED);
|
|
74
|
-
const seedPtr = 0;
|
|
75
|
-
const infoPtr = 256;
|
|
76
|
-
const blindedPtr = 512;
|
|
77
|
-
const outPtr = 1024;
|
|
78
|
-
put(ref, seedPtr, seed);
|
|
79
|
-
put(ref, infoPtr, info);
|
|
80
|
-
put(ref, blindedPtr, blinded);
|
|
81
|
-
|
|
82
|
-
const rc = evaluate(seedPtr, seed.length, infoPtr, info.length, blindedPtr, blinded.length, outPtr);
|
|
83
|
-
expect(rc).toBe(0);
|
|
84
|
-
const got = buf(ref).subarray(outPtr, outPtr + 32);
|
|
85
|
-
expect(b2h(got)).toBe(EVALUATED);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it('rejects a bad blinded-element length with -4', () => {
|
|
89
|
-
const ref = freshMem();
|
|
90
|
-
const evaluate = buildCryptoImports(ref, freshCryptoState())['crypto.voprf_evaluate'];
|
|
91
|
-
expect(evaluate(0, 32, 256, 8, 512, 31, 1024)).toBe(-4);
|
|
92
|
-
});
|
|
93
|
-
});
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
buildHostImports,
|
|
5
|
-
freshDispatchState,
|
|
6
|
-
type MemoryRef,
|
|
7
|
-
} from '../src/devserver/runtime/host.js';
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* The dev host warns (once) when the guest reads a framework auth secret that is unset, since the
|
|
11
|
-
* wasm then falls back to a PUBLISHED dev key (see `server/globals/auth.ts`). This is the visible
|
|
12
|
-
* counterpart to the silent fallback: without it, a server running on the dev host would sign
|
|
13
|
-
* sessions under a forgeable key with no signal. The repo root has no `.env.secrets`, so every
|
|
14
|
-
* lookup below is absent.
|
|
15
|
-
*/
|
|
16
|
-
function setup() {
|
|
17
|
-
const memory = new WebAssembly.Memory({ initial: 1 });
|
|
18
|
-
const ref: MemoryRef = { memory };
|
|
19
|
-
const env = buildHostImports(ref, freshDispatchState()).env as Record<
|
|
20
|
-
string,
|
|
21
|
-
(...a: number[]) => number
|
|
22
|
-
>;
|
|
23
|
-
const buf = Buffer.from(memory.buffer);
|
|
24
|
-
return { env, buf };
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Writes `key` at offset 0 and runs `env_get_secure`, returning its status code. */
|
|
28
|
-
function getSecure(env: Record<string, (...a: number[]) => number>, buf: Buffer, key: string): number {
|
|
29
|
-
const b = Buffer.from(key, 'utf8');
|
|
30
|
-
b.copy(buf, 0);
|
|
31
|
-
return env.env_get_secure(0, b.length, 256, 256);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
afterEach(() => {
|
|
35
|
-
vi.restoreAllMocks();
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
describe('dev host secret fallback warning', () => {
|
|
39
|
-
it('warns once that an unset AUTH_SESSION_SECRET falls back to a published key', () => {
|
|
40
|
-
const write = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
|
|
41
|
-
const { env, buf } = setup();
|
|
42
|
-
|
|
43
|
-
expect(getSecure(env, buf, 'AUTH_SESSION_SECRET')).toBe(-2); // ABSENT
|
|
44
|
-
// Repeated reads (a fresh wasm instance per request hits this every time) warn only once.
|
|
45
|
-
expect(getSecure(env, buf, 'AUTH_SESSION_SECRET')).toBe(-2);
|
|
46
|
-
|
|
47
|
-
const warnings = write.mock.calls
|
|
48
|
-
.map((c) => String(c[0]))
|
|
49
|
-
.filter((s) => s.includes('AUTH_SESSION_SECRET'));
|
|
50
|
-
expect(warnings).toHaveLength(1);
|
|
51
|
-
expect(warnings[0]).toContain('forge');
|
|
52
|
-
expect(warnings[0]).toContain('deployed node');
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('does not warn for an ordinary (non-framework) absent secret', () => {
|
|
56
|
-
const write = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
|
|
57
|
-
const { env, buf } = setup();
|
|
58
|
-
|
|
59
|
-
expect(getSecure(env, buf, 'STRIPE_KEY')).toBe(-2); // ABSENT, no published default
|
|
60
|
-
const warned = write.mock.calls.some((c) => String(c[0]).includes('STRIPE_KEY'));
|
|
61
|
-
expect(warned).toBe(false);
|
|
62
|
-
});
|
|
63
|
-
});
|