@firedrill-tools/resend 0.1.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.
- package/LICENSE +201 -0
- package/README.md +198 -0
- package/firedrill/agent.target.json +16 -0
- package/firedrill/baseline.scenario.json +1687 -0
- package/firedrill/conformance.suite.json +19 -0
- package/firedrill/contacts-outage.scenario.json +11 -0
- package/firedrill/quota-exhausted.scenario.json +17 -0
- package/firedrill/rate-limited.scenario.json +11 -0
- package/firedrill/resend-audience.drill.json +407 -0
- package/firedrill/resend-contacts-outage.drill.json +63 -0
- package/firedrill/resend-denied.drill.json +68 -0
- package/firedrill/resend-domains-and-keys.drill.json +307 -0
- package/firedrill/resend-email-flow.drill.json +286 -0
- package/firedrill/resend-fresh-install.drill.json +73 -0
- package/firedrill/resend-invalid-key.drill.json +803 -0
- package/firedrill/resend-large-page.drill.json +53 -0
- package/firedrill/resend-quota-exhausted.drill.json +82 -0
- package/firedrill/resend-rate-limited.drill.json +81 -0
- package/firedrill/resend-restricted-key.drill.json +441 -0
- package/firedrill/resend-tight-limits.drill.json +163 -0
- package/firedrill/tight-limits.scenario.json +16 -0
- package/firedrill/tools/resend/app/assets/ATTRIBUTION.md +30 -0
- package/firedrill/tools/resend/app/assets/fonts/Inter-OFL.txt +93 -0
- package/firedrill/tools/resend/app/assets/fonts/JetBrainsMono-OFL.txt +93 -0
- package/firedrill/tools/resend/app/assets/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/firedrill/tools/resend/app/assets/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/firedrill/tools/resend/app/assets/resend-icon-white.svg +3 -0
- package/firedrill/tools/resend/app/assets/resend-wordmark-white.svg +3 -0
- package/firedrill/tools/resend/app/assets/resend.svg +3 -0
- package/firedrill/tools/resend/app/site/app.js +90 -0
- package/firedrill/tools/resend/app/site/assets/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/firedrill/tools/resend/app/site/assets/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/firedrill/tools/resend/app/site/assets/resend-icon-white.svg +3 -0
- package/firedrill/tools/resend/app/site/assets/resend-wordmark-white.svg +3 -0
- package/firedrill/tools/resend/app/site/assets/resend.svg +3 -0
- package/firedrill/tools/resend/app/site/contact-panel.js +86 -0
- package/firedrill/tools/resend/app/site/icons.js +70 -0
- package/firedrill/tools/resend/app/site/index.html +56 -0
- package/firedrill/tools/resend/app/site/list.js +41 -0
- package/firedrill/tools/resend/app/site/styles.css +67 -0
- package/firedrill/tools/resend/app/site/ui.js +259 -0
- package/firedrill/tools/resend/app/site/view-audience.js +160 -0
- package/firedrill/tools/resend/app/site/view-domains.js +106 -0
- package/firedrill/tools/resend/app/site/view-email-detail.js +126 -0
- package/firedrill/tools/resend/app/site/view-emails.js +83 -0
- package/firedrill/tools/resend/app/site/view-keys.js +91 -0
- package/firedrill/tools/resend/app/site/views.css +141 -0
- package/firedrill/tools/resend/behavior.mjs +114 -0
- package/firedrill/tools/resend/lib/check.mjs +75 -0
- package/firedrill/tools/resend/lib/core.mjs +112 -0
- package/firedrill/tools/resend/lib/json-depth.mjs +45 -0
- package/firedrill/tools/resend/lib/page.mjs +66 -0
- package/firedrill/tools/resend/lib/time.mjs +60 -0
- package/firedrill/tools/resend/lib/wire.mjs +98 -0
- package/firedrill/tools/resend/ops/contacts.mjs +199 -0
- package/firedrill/tools/resend/ops/domains.mjs +125 -0
- package/firedrill/tools/resend/ops/email-model.mjs +100 -0
- package/firedrill/tools/resend/ops/emails.mjs +139 -0
- package/firedrill/tools/resend/ops/keys-segments.mjs +97 -0
- package/firedrill/tools/resend/resend.tool.json +4804 -0
- package/firedrill/world.json +2148 -0
- package/firedrill.json +5 -0
- package/package.json +62 -0
- package/starter.json +1686 -0
- package/test/conformance.mjs +480 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 24px; min-height: 36px; }
|
|
2
|
+
.page-head h1 { font-size: 28px; line-height: 1.2; font-weight: 600; letter-spacing: -0.02em; margin: 0; }
|
|
3
|
+
.head-actions { display: flex; gap: 8px; align-items: center; }
|
|
4
|
+
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 32px; padding: 0 12px; border-radius: 6px; border: 1px solid var(--border-strong); background: transparent; color: var(--text); font-size: 13.5px; font-weight: 500; cursor: pointer; white-space: nowrap; }
|
|
5
|
+
.btn:hover { background: var(--hover); }
|
|
6
|
+
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
7
|
+
.btn-primary { background: #fff; color: #000; border-color: #fff; }
|
|
8
|
+
.btn-primary:hover { background: #e4e4e7; }
|
|
9
|
+
.btn-danger { background: #e5484d; border-color: #e5484d; color: #fff; }
|
|
10
|
+
.btn-danger:hover { background: #ec5d5e; }
|
|
11
|
+
.btn-ghost { border-color: transparent; color: var(--muted); }
|
|
12
|
+
.btn-ghost:hover { color: var(--text); }
|
|
13
|
+
.btn.sm { height: 28px; padding: 0 10px; font-size: 13px; }
|
|
14
|
+
.icon-btn { display: inline-grid; place-items: center; width: 28px; height: 28px; border: 0; border-radius: 6px; background: transparent; color: var(--muted); cursor: pointer; }
|
|
15
|
+
.icon-btn:hover { background: var(--hover); color: var(--text); }
|
|
16
|
+
.icon-btn.copy { width: 22px; height: 22px; }
|
|
17
|
+
|
|
18
|
+
.tabs { display: flex; gap: 20px; border-bottom: 1px solid var(--border); margin-bottom: 20px; }
|
|
19
|
+
.tabs button { position: relative; padding: 0 0 10px; border: 0; background: none; color: var(--muted); cursor: pointer; font-size: 14px; }
|
|
20
|
+
.tabs button:hover { color: var(--text); }
|
|
21
|
+
.tabs button[aria-selected="true"] { color: var(--text); }
|
|
22
|
+
.tabs button[aria-selected="true"]::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 1px; background: var(--text); }
|
|
23
|
+
|
|
24
|
+
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
|
|
25
|
+
.search { position: relative; flex: 1 1 260px; max-width: 420px; }
|
|
26
|
+
.search .i { position: absolute; left: 10px; top: 9px; color: var(--faint); }
|
|
27
|
+
.input, .select, textarea.input { height: 32px; width: 100%; padding: 0 10px; border: 1px solid var(--border-strong); border-radius: 6px; background: transparent; color: var(--text); font-size: 13.5px; }
|
|
28
|
+
.search .input { padding-left: 32px; }
|
|
29
|
+
textarea.input { height: auto; min-height: 84px; padding: 8px 10px; resize: vertical; }
|
|
30
|
+
.input::placeholder { color: var(--faint); }
|
|
31
|
+
.input:focus, .select:focus { outline: none; border-color: rgba(255, 255, 255, 0.35); box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.06); }
|
|
32
|
+
.select { width: auto; min-width: 150px; appearance: none; padding-right: 30px; background-image: linear-gradient(45deg, transparent 50%, #a1a1aa 50%), linear-gradient(135deg, #a1a1aa 50%, transparent 50%); background-position: calc(100% - 15px) 14px, calc(100% - 11px) 14px; background-size: 4px 4px; background-repeat: no-repeat; }
|
|
33
|
+
.select option { background: #111; }
|
|
34
|
+
.fake-select { color: var(--muted); }
|
|
35
|
+
|
|
36
|
+
.table-wrap { border: 1px solid var(--border); border-radius: var(--radius); overflow-x: auto; }
|
|
37
|
+
table.table { width: 100%; border-collapse: collapse; }
|
|
38
|
+
.table th { text-align: left; font-weight: 500; font-size: 13px; color: var(--muted); height: 40px; padding: 0 16px; border-bottom: 1px solid var(--border); background: var(--panel); white-space: nowrap; }
|
|
39
|
+
.table td { height: 52px; padding: 0 16px; border-bottom: 1px solid var(--border); font-size: 14px; max-width: 360px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
40
|
+
.table tbody tr:last-child td { border-bottom: 0; }
|
|
41
|
+
.table tbody tr.row { cursor: pointer; }
|
|
42
|
+
.table tbody tr.row:hover td { background: rgba(255, 255, 255, 0.025); }
|
|
43
|
+
.table td.muted, .muted { color: var(--muted); }
|
|
44
|
+
.table td.actions { width: 48px; text-align: right; }
|
|
45
|
+
.cell-main { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
|
46
|
+
.cell-main > span:last-child { overflow: hidden; text-overflow: ellipsis; }
|
|
47
|
+
.tile { display: grid; place-items: center; width: 32px; height: 32px; flex: none; border-radius: 7px; border: 1px solid var(--border-strong); background: linear-gradient(180deg, #1a1a1d, #0c0c0e); color: var(--text); }
|
|
48
|
+
.tile.lg { width: 56px; height: 56px; border-radius: 12px; }
|
|
49
|
+
.skeleton td .sk { display: block; height: 10px; width: 70%; border-radius: 4px; background: linear-gradient(90deg, #141416, #1f1f23, #141416); background-size: 200% 100%; animation: sk 1.2s linear infinite; }
|
|
50
|
+
@keyframes sk { to { background-position: -200% 0; } }
|
|
51
|
+
|
|
52
|
+
.badge { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: 6px; font-size: 12.5px; font-weight: 500; white-space: nowrap; }
|
|
53
|
+
.tone-green { color: var(--green); background: var(--green-bg); }
|
|
54
|
+
.tone-red { color: var(--red); background: var(--red-bg); }
|
|
55
|
+
.tone-amber { color: var(--amber); background: var(--amber-bg); }
|
|
56
|
+
.tone-blue { color: var(--blue); background: var(--blue-bg); }
|
|
57
|
+
.tone-gray { color: #b4b4bb; background: var(--gray-bg); }
|
|
58
|
+
|
|
59
|
+
.pager { display: flex; align-items: center; justify-content: space-between; margin-top: 12px; color: var(--muted); font-size: 13px; }
|
|
60
|
+
.pager .btns { display: flex; gap: 6px; }
|
|
61
|
+
.empty { border: 1px dashed var(--border-strong); border-radius: var(--radius); padding: 56px 24px; text-align: center; }
|
|
62
|
+
.empty h3 { margin: 0 0 6px; font-size: 16px; font-weight: 600; }
|
|
63
|
+
.empty p { margin: 0 auto 16px; color: var(--muted); max-width: 420px; }
|
|
64
|
+
.callout { display: flex; align-items: flex-start; gap: 12px; padding: 12px 14px; border-radius: var(--radius); border: 1px solid; margin-bottom: 16px; }
|
|
65
|
+
.callout strong { font-weight: 500; }
|
|
66
|
+
.callout p { margin: 2px 0 0; color: var(--muted); }
|
|
67
|
+
.callout .btn { margin-left: auto; }
|
|
68
|
+
.callout-error { border-color: rgba(255, 99, 105, 0.3); background: var(--red-bg); color: var(--red); }
|
|
69
|
+
.callout-warn { border-color: rgba(255, 202, 22, 0.25); background: var(--amber-bg); color: var(--amber); }
|
|
70
|
+
.callout-info { border-color: rgba(112, 184, 255, 0.25); background: var(--blue-bg); color: var(--blue); }
|
|
71
|
+
|
|
72
|
+
.overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.7); display: grid; place-items: center; z-index: 50; padding: 16px; }
|
|
73
|
+
.dialog { width: 100%; max-width: 460px; background: #0d0d0f; border: 1px solid var(--border-strong); border-radius: 12px; padding: 20px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6); }
|
|
74
|
+
.dialog-wide { max-width: 620px; }
|
|
75
|
+
.dialog-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; }
|
|
76
|
+
.dialog-head h2 { margin: 0; font-size: 17px; font-weight: 600; }
|
|
77
|
+
.dialog-desc, .dialog-text { color: var(--muted); margin: 6px 0 16px; }
|
|
78
|
+
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
|
|
79
|
+
.field { display: flex; flex-direction: column; gap: 6px; margin-top: 14px; }
|
|
80
|
+
.field label { font-size: 13px; color: var(--text); font-weight: 500; }
|
|
81
|
+
.hint { margin: 0; font-size: 12.5px; color: var(--faint); }
|
|
82
|
+
.form-error { color: var(--red); font-size: 13px; margin: 12px 0 0; }
|
|
83
|
+
.token-box { display: flex; align-items: center; gap: 8px; border: 1px solid var(--border-strong); border-radius: 6px; padding: 8px 10px; margin-top: 12px; background: var(--panel); word-break: break-all; }
|
|
84
|
+
.toasts { position: fixed; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 8px; z-index: 60; }
|
|
85
|
+
.toast { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-radius: 8px; background: #161618; border: 1px solid var(--border-strong); font-size: 13.5px; }
|
|
86
|
+
.toast-error { color: var(--red); }
|
|
87
|
+
.back { display: inline-flex; align-items: center; gap: 4px; color: var(--muted); font-size: 13.5px; margin-bottom: 20px; }
|
|
88
|
+
.back:hover { color: var(--text); }
|
|
89
|
+
.detail-head { display: flex; align-items: center; gap: 16px; margin-bottom: 28px; }
|
|
90
|
+
.detail-title { flex: 1; min-width: 0; }
|
|
91
|
+
.detail-title h1 { margin: 2px 0 0; font-size: 24px; font-weight: 600; letter-spacing: -0.02em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
92
|
+
.eyebrow { margin: 0; font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; font-weight: 500; }
|
|
93
|
+
.meta-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px 24px; padding-bottom: 24px; margin-bottom: 24px; border-bottom: 1px solid var(--border); }
|
|
94
|
+
.meta-item > div { margin-top: 6px; overflow: hidden; text-overflow: ellipsis; }
|
|
95
|
+
.meta-item .mono { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
96
|
+
.tags { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
97
|
+
.section { margin-top: 28px; }
|
|
98
|
+
.section h2 { font-size: 16px; font-weight: 600; margin: 0 0 14px; }
|
|
99
|
+
.timeline { list-style: none; margin: 0; padding: 18px 20px; display: flex; align-items: center; gap: 12px; border: 1px solid var(--border); border-radius: var(--radius); flex-wrap: wrap; }
|
|
100
|
+
.timeline li { display: flex; align-items: center; gap: 10px; }
|
|
101
|
+
.timeline li strong { font-weight: 500; font-size: 13.5px; }
|
|
102
|
+
.timeline li p { margin: 0; font-size: 12.5px; }
|
|
103
|
+
.timeline li.line { flex: 1 1 40px; min-width: 24px; height: 1px; background: var(--border-strong); }
|
|
104
|
+
.dot { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 999px; }
|
|
105
|
+
.content-panel { border: 1px solid var(--border); border-radius: var(--radius); min-height: 220px; overflow: auto; max-height: 560px; }
|
|
106
|
+
.email-preview { background: #fff; color: #1c2024; padding: 28px 32px; font-size: 15px; line-height: 1.6; min-height: 220px; }
|
|
107
|
+
.email-preview h1, .email-preview h2 { line-height: 1.25; }
|
|
108
|
+
.email-preview .fake-link { color: #0d74ce; text-decoration: underline; }
|
|
109
|
+
.email-preview table { border-collapse: collapse; }
|
|
110
|
+
.email-preview td, .email-preview th { padding: 4px 8px; }
|
|
111
|
+
pre.plain { margin: 0; padding: 20px; white-space: pre-wrap; word-break: break-word; font-family: var(--mono); font-size: 12.5px; color: var(--text); }
|
|
112
|
+
@media (max-width: 900px) { .meta-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
|
113
|
+
@media (max-width: 768px) {
|
|
114
|
+
.shell { grid-template-columns: 1fr; }
|
|
115
|
+
.sidebar { position: fixed; inset: 0 auto 0 0; width: 260px; z-index: 40; transform: translateX(-100%); transition: transform 0.15s; }
|
|
116
|
+
body.nav-open .sidebar { transform: none; }
|
|
117
|
+
.icon-btn.mobile-menu { display: inline-grid; position: absolute; top: 12px; left: 12px; }
|
|
118
|
+
#view { padding: 56px 16px 48px; }
|
|
119
|
+
.detail-head { flex-wrap: wrap; }
|
|
120
|
+
}
|
|
121
|
+
.tabs a.tab-link { position: relative; padding: 0 0 10px; color: var(--muted); font-size: 14px; }
|
|
122
|
+
.tabs a.tab-link:hover { color: var(--text); }
|
|
123
|
+
.tabs a.tab-link[aria-selected="true"] { color: var(--text); }
|
|
124
|
+
.tabs a.tab-link[aria-selected="true"]::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 1px; background: var(--text); }
|
|
125
|
+
.tile.avatar { border-radius: 999px; font-size: 12px; font-weight: 600; color: var(--muted); }
|
|
126
|
+
.link-btn { border: 0; background: none; padding: 0; color: var(--text); cursor: pointer; font-size: 14px; }
|
|
127
|
+
.link-btn:hover { text-decoration: underline; }
|
|
128
|
+
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
|
129
|
+
.checks { border: 0; padding: 0; margin: 14px 0 0; }
|
|
130
|
+
.checks legend { font-size: 13px; font-weight: 500; margin-bottom: 6px; }
|
|
131
|
+
.check { display: flex; align-items: center; gap: 8px; font-size: 13.5px; margin-top: 6px; cursor: pointer; }
|
|
132
|
+
.check input { accent-color: #fff; width: 15px; height: 15px; }
|
|
133
|
+
.toggle { margin-top: 16px; }
|
|
134
|
+
.field .label { font-size: 13px; font-weight: 500; }
|
|
135
|
+
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
136
|
+
.chip { display: inline-flex; align-items: center; gap: 2px; padding: 0 2px 0 8px; height: 24px; border-radius: 6px; background: var(--gray-bg); font-size: 12.5px; }
|
|
137
|
+
.panel-meta { display: flex; align-items: center; gap: 10px; margin: 8px 0 4px; }
|
|
138
|
+
.props { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0; padding: 10px 12px; border: 1px solid var(--border); border-radius: 6px; }
|
|
139
|
+
.props dt { color: var(--muted); }
|
|
140
|
+
.props dd { margin: 0; }
|
|
141
|
+
.dialog-actions.split .spacer { flex: 1; }
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Synthetic Resend team for Firedrill. Every operation computes from `context.state`: ids from `meta/counters` plus
|
|
2
|
+
// seeded randomness, timestamps from the virtual clock, delivery outcomes from a fixed documented rule (a recipient in
|
|
3
|
+
// the reserved `.invalid` TLD bounces). Nothing here sends e-mail, queries DNS or contacts any real service.
|
|
4
|
+
import { guardRoutes } from "./lib/json-depth.mjs";
|
|
5
|
+
import { contactRef, encodeOutcome, header, isPlainObject, jsonObject, operationInput, pageArguments, pick, query } from "./lib/wire.mjs";
|
|
6
|
+
import { contactsAddSegment, contactsCreate, contactsGet, contactsList, contactsListSegments, contactsRemove, contactsRemoveSegment, contactsUpdate } from "./ops/contacts.mjs";
|
|
7
|
+
import { domainsCreate, domainsGet, domainsList, domainsRemove, domainsVerify } from "./ops/domains.mjs";
|
|
8
|
+
import { emailsCancel, emailsGet, emailsList, emailsSend, emailsSendBatch, emailsUpdate } from "./ops/emails.mjs";
|
|
9
|
+
import { apiKeysCreate, apiKeysList, apiKeysRemove, segmentsCreate, segmentsList, segmentsRemove, workspaceContext } from "./ops/keys-segments.mjs";
|
|
10
|
+
|
|
11
|
+
const operations = {
|
|
12
|
+
"emails.send": emailsSend,
|
|
13
|
+
"emails.send_batch": emailsSendBatch,
|
|
14
|
+
"emails.list": emailsList,
|
|
15
|
+
"emails.get": emailsGet,
|
|
16
|
+
"emails.update": emailsUpdate,
|
|
17
|
+
"emails.cancel": emailsCancel,
|
|
18
|
+
"domains.create": domainsCreate,
|
|
19
|
+
"domains.list": domainsList,
|
|
20
|
+
"domains.get": domainsGet,
|
|
21
|
+
"domains.verify": domainsVerify,
|
|
22
|
+
"domains.remove": domainsRemove,
|
|
23
|
+
"api_keys.create": apiKeysCreate,
|
|
24
|
+
"api_keys.list": apiKeysList,
|
|
25
|
+
"api_keys.remove": apiKeysRemove,
|
|
26
|
+
"segments.create": segmentsCreate,
|
|
27
|
+
"segments.list": segmentsList,
|
|
28
|
+
"segments.remove": segmentsRemove,
|
|
29
|
+
"contacts.create": contactsCreate,
|
|
30
|
+
"contacts.list": contactsList,
|
|
31
|
+
"contacts.get": contactsGet,
|
|
32
|
+
"contacts.update": contactsUpdate,
|
|
33
|
+
"contacts.remove": contactsRemove,
|
|
34
|
+
"contacts.add_segment": contactsAddSegment,
|
|
35
|
+
"contacts.remove_segment": contactsRemoveSegment,
|
|
36
|
+
"contacts.list_segments": contactsListSegments,
|
|
37
|
+
"workspace.context": workspaceContext,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const SEND_FIELDS = [
|
|
41
|
+
["from", "from"], ["to", "to"], ["subject", "subject"], ["html", "html"], ["text", "text"], ["cc", "cc"], ["bcc", "bcc"],
|
|
42
|
+
["reply_to", "replyTo"], ["scheduled_at", "scheduledAt"], ["headers", "headers"], ["tags", "tags"],
|
|
43
|
+
["attachments", "attachments"], ["template", "template"], ["topic_id", "topicId"],
|
|
44
|
+
];
|
|
45
|
+
const DOMAIN_FIELDS = [
|
|
46
|
+
["name", "name"], ["region", "region"], ["custom_return_path", "customReturnPath"], ["open_tracking", "openTracking"],
|
|
47
|
+
["click_tracking", "clickTracking"], ["tls", "tls"], ["capabilities", "capabilities"], ["tracking_subdomain", "trackingSubdomain"],
|
|
48
|
+
];
|
|
49
|
+
const CONTACT_FIELDS = [["first_name", "firstName"], ["last_name", "lastName"], ["unsubscribed", "unsubscribed"], ["properties", "properties"]];
|
|
50
|
+
|
|
51
|
+
function route(decode) {
|
|
52
|
+
return { decode, encode: encodeOutcome };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const byPath = (name, argument = "id") => (request) => ({ arguments: { [argument]: request.path[name] } });
|
|
56
|
+
const writeByPath = (name) => (request) => operationInput(request, { id: request.path[name] });
|
|
57
|
+
const listOnly = (request) => ({ arguments: pageArguments(request) });
|
|
58
|
+
|
|
59
|
+
function batchDecode(request) {
|
|
60
|
+
const args = {};
|
|
61
|
+
if (request.body.kind === "json" && Array.isArray(request.body.value)) {
|
|
62
|
+
args.emails = request.body.value.map((entry) => (isPlainObject(entry) ? pick(entry, SEND_FIELDS) : entry));
|
|
63
|
+
}
|
|
64
|
+
const mode = header(request, "x-batch-validation");
|
|
65
|
+
if (mode !== undefined && mode !== "strict") args.batchValidation = mode.length > 0 ? mode : "(empty)";
|
|
66
|
+
return operationInput(request, args);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function contactCreateDecode(request) {
|
|
70
|
+
const body = jsonObject(request);
|
|
71
|
+
const args = pick(body, [["email", "email"], ...CONTACT_FIELDS, ["topics", "topics"], ["audience_id", "audienceId"]]);
|
|
72
|
+
if (Object.hasOwn(body, "segments")) {
|
|
73
|
+
const segments = body.segments;
|
|
74
|
+
args.segmentIds = Array.isArray(segments)
|
|
75
|
+
? segments.map((entry) => (isPlainObject(entry) && typeof entry.id === "string" ? entry.id : typeof entry === "string" ? entry : ""))
|
|
76
|
+
: [""];
|
|
77
|
+
}
|
|
78
|
+
return operationInput(request, args);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const http = {
|
|
82
|
+
"send-email": route((request) => operationInput(request, pick(jsonObject(request), SEND_FIELDS))),
|
|
83
|
+
"send-batch": route(batchDecode),
|
|
84
|
+
"list-emails": route(listOnly),
|
|
85
|
+
"get-email": route(byPath("email_id")),
|
|
86
|
+
"update-email": route((request) => operationInput(request, pick(jsonObject(request), [["scheduled_at", "scheduledAt"]], { id: request.path.email_id }))),
|
|
87
|
+
"cancel-email": route(writeByPath("email_id")),
|
|
88
|
+
"create-domain": route((request) => operationInput(request, pick(jsonObject(request), DOMAIN_FIELDS))),
|
|
89
|
+
"list-domains": route(listOnly),
|
|
90
|
+
"get-domain": route(byPath("domain_id")),
|
|
91
|
+
"verify-domain": route(writeByPath("domain_id")),
|
|
92
|
+
"remove-domain": route(writeByPath("domain_id")),
|
|
93
|
+
"create-api-key": route((request) => operationInput(request, pick(jsonObject(request), [["name", "name"], ["permission", "permission"], ["domain_id", "domainId"]]))),
|
|
94
|
+
"list-api-keys": route(listOnly),
|
|
95
|
+
"remove-api-key": route(writeByPath("api_key_id")),
|
|
96
|
+
"create-segment": route((request) => operationInput(request, pick(jsonObject(request), [["name", "name"], ["filter", "filter"], ["audience_id", "audienceId"]]))),
|
|
97
|
+
"list-segments": route(listOnly),
|
|
98
|
+
"remove-segment": route(writeByPath("id")),
|
|
99
|
+
"create-contact": route(contactCreateDecode),
|
|
100
|
+
"list-contacts": route((request) => {
|
|
101
|
+
const args = pageArguments(request);
|
|
102
|
+
const segment = query(request, "segment_id");
|
|
103
|
+
if (segment !== undefined) args.segmentId = segment;
|
|
104
|
+
return { arguments: args };
|
|
105
|
+
}),
|
|
106
|
+
"get-contact": route((request) => ({ arguments: contactRef(request.path.id, "id") })),
|
|
107
|
+
"update-contact": route((request) => operationInput(request, pick(jsonObject(request), [...CONTACT_FIELDS, ["email", "newEmail"]], contactRef(request.path.id, "id")))),
|
|
108
|
+
"remove-contact": route((request) => operationInput(request, contactRef(request.path.id, "id"))),
|
|
109
|
+
"list-contact-segments": route((request) => ({ arguments: pageArguments(request, contactRef(request.path.contact_id, "contactId")) })),
|
|
110
|
+
"add-contact-segment": route((request) => operationInput(request, contactRef(request.path.contact_id, "contactId", { segmentId: request.path.segment_id }))),
|
|
111
|
+
"remove-contact-segment": route((request) => operationInput(request, contactRef(request.path.contact_id, "contactId", { segmentId: request.path.segment_id }))),
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export default { operations, http: guardRoutes(http) };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Input validation helpers raising Resend's declared errors. Every regex runs on length-bounded input.
|
|
2
|
+
import { fail, hasReservedKey } from "./core.mjs";
|
|
3
|
+
|
|
4
|
+
const ADDRESS = /^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@([A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+)$/;
|
|
5
|
+
const NAMED = /^([^<>]{0,200})<([^<>\s]+)>$/;
|
|
6
|
+
|
|
7
|
+
export function present(value) {
|
|
8
|
+
return value !== undefined && value !== null && !(typeof value === "string" && value.trim().length === 0);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** `addr` or `Name <addr>` → `{ address, domain }` (domain lowercased) or `null`. */
|
|
12
|
+
export function parseAddress(text) {
|
|
13
|
+
if (typeof text !== "string" || text.length === 0 || text.length > 320 || text.includes("�")) return null;
|
|
14
|
+
const trimmed = text.trim();
|
|
15
|
+
const named = NAMED.exec(trimmed);
|
|
16
|
+
const address = named === null ? trimmed : named[2];
|
|
17
|
+
if (address.length > 254) return null;
|
|
18
|
+
const match = ADDRESS.exec(address);
|
|
19
|
+
if (match === null || address.split("@")[0].length > 64) return null;
|
|
20
|
+
return { address, domain: match[1].toLowerCase() };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Normalise a `string | string[]` recipient field into a list, validating each address. */
|
|
24
|
+
export function addressList(context, value, field, { max = 50, required = false } = {}) {
|
|
25
|
+
if (!present(value) || (Array.isArray(value) && value.length === 0)) {
|
|
26
|
+
if (required) fail(context, "MISSING_REQUIRED_FIELD", `Missing \`${field}\` field.`);
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
const list = Array.isArray(value) ? value : [value];
|
|
30
|
+
if (list.length > max) fail(context, "VALIDATION_ERROR", `The \`${field}\` field must contain at most ${max} addresses.`);
|
|
31
|
+
for (const entry of list) {
|
|
32
|
+
if (parseAddress(entry) === null) {
|
|
33
|
+
fail(context, "VALIDATION_ERROR", `Invalid \`${field}\` field. The email address needs to follow the \`email@example.com\` or \`Name <email@example.com>\` format.`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return list.map((entry) => entry.trim());
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function boundedString(context, value, field, { min = 1, max, code = "VALIDATION_ERROR" }) {
|
|
40
|
+
if (typeof value !== "string" || value.length < min || value.length > max) {
|
|
41
|
+
fail(context, code, `The \`${field}\` field must be between ${min} and ${max} characters.`);
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function utf8Bytes(text) {
|
|
47
|
+
let bytes = 0;
|
|
48
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
49
|
+
const code = text.charCodeAt(i);
|
|
50
|
+
if (code < 0x80) bytes += 1;
|
|
51
|
+
else if (code < 0x800) bytes += 2;
|
|
52
|
+
else if (code >= 0xd800 && code <= 0xdbff) {
|
|
53
|
+
bytes += 4;
|
|
54
|
+
i += 1;
|
|
55
|
+
} else bytes += 3;
|
|
56
|
+
}
|
|
57
|
+
return bytes;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Plain object check that also refuses reserved prototype keys. */
|
|
61
|
+
export function safeObject(context, value, field, maxKeys) {
|
|
62
|
+
if (typeof value !== "object" || value === null || Array.isArray(value) || hasReservedKey(value)) {
|
|
63
|
+
fail(context, "VALIDATION_ERROR", `The \`${field}\` field must be an object without reserved keys.`);
|
|
64
|
+
}
|
|
65
|
+
if (Object.keys(value).length > maxKeys) fail(context, "VALIDATION_ERROR", `The \`${field}\` field accepts at most ${maxKeys} keys.`);
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function rejectPresent(context, input, fields, message) {
|
|
70
|
+
for (const field of fields) {
|
|
71
|
+
if (Object.hasOwn(input, field) && input[field] !== null && input[field] !== undefined) {
|
|
72
|
+
fail(context, "VALIDATION_ERROR", message(field));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Shared handler plumbing: declared failures, API-key resolution, bounded scans and id generation.
|
|
2
|
+
// Everything reads and writes `context.state`; no module-level mutable state.
|
|
3
|
+
|
|
4
|
+
export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
5
|
+
const HEX = "0123456789abcdef";
|
|
6
|
+
const DEFAULT_MAX_SCAN = 5000;
|
|
7
|
+
const CHUNK = 500;
|
|
8
|
+
|
|
9
|
+
export function fail(context, code, message) {
|
|
10
|
+
return context.fail({ code, message, retryable: code === "RATE_LIMIT_EXCEEDED" || code === "APPLICATION_ERROR" });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Lowercased UUID or `null` when the text is not a UUID. Input length is bounded before the regex. */
|
|
14
|
+
export function normalizeUuid(value) {
|
|
15
|
+
if (typeof value !== "string" || value.length !== 36) return null;
|
|
16
|
+
const lower = value.toLowerCase();
|
|
17
|
+
return UUID_RE.test(lower) ? lower : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function requireUuid(context, value, name = "id") {
|
|
21
|
+
const id = normalizeUuid(value);
|
|
22
|
+
if (id === null) fail(context, "INVALID_PARAMETER", `The \`${name}\` must be a valid UUID.`);
|
|
23
|
+
return id;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function team(context) {
|
|
27
|
+
const row = context.state.get("meta", "team");
|
|
28
|
+
if (row === null) fail(context, "APPLICATION_ERROR", "The team record is missing from state.");
|
|
29
|
+
return row;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the calling API key. `resendApiKeyId` absent → the team's default key; present but unknown → INVALID_API_KEY.
|
|
34
|
+
* `options.send` marks operations a sending_access key may call.
|
|
35
|
+
*/
|
|
36
|
+
export function resolveKey(context, options = {}) {
|
|
37
|
+
const attributes = context.actor.attributes ?? {};
|
|
38
|
+
const claimed = Object.hasOwn(attributes, "resendApiKeyId") ? attributes.resendApiKeyId : undefined;
|
|
39
|
+
const teamRow = team(context);
|
|
40
|
+
const keyId = claimed === undefined ? teamRow.defaultApiKeyId : normalizeUuid(claimed);
|
|
41
|
+
const key = keyId === null ? null : context.state.get("api-keys", keyId);
|
|
42
|
+
if (key === null) fail(context, "INVALID_API_KEY", "API key is invalid");
|
|
43
|
+
if (key.permission !== "full_access" && options.send !== true) {
|
|
44
|
+
fail(context, "RESTRICTED_API_KEY", "This API key is restricted to only send emails");
|
|
45
|
+
}
|
|
46
|
+
return { key, team: teamRow };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function checkIdempotencyKey(context, input) {
|
|
50
|
+
if (!Object.hasOwn(input, "idempotencyKey")) return;
|
|
51
|
+
const value = input.idempotencyKey;
|
|
52
|
+
if (typeof value !== "string" || value.length < 1 || value.length > 256) {
|
|
53
|
+
fail(context, "INVALID_IDEMPOTENCY_KEY", "Idempotency key must be between 1-256 characters");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function maxScanRows(context) {
|
|
58
|
+
const limits = context.state.get("meta", "limits");
|
|
59
|
+
const value = limits === null ? DEFAULT_MAX_SCAN : limits.maxScanRows;
|
|
60
|
+
return Number.isInteger(value) && value >= 1 && value <= 10000 ? value : DEFAULT_MAX_SCAN;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Every row of `namespace` (or only rows whose id starts with `prefix`), in row-id order. Exceeding the configured
|
|
65
|
+
* bound fails APPLICATION_ERROR instead of returning a truncated list.
|
|
66
|
+
*/
|
|
67
|
+
export function scanAll(context, namespace, prefix) {
|
|
68
|
+
const bound = maxScanRows(context);
|
|
69
|
+
const rows = [];
|
|
70
|
+
let after = prefix;
|
|
71
|
+
for (;;) {
|
|
72
|
+
const chunk = context.state.scan(namespace, after === undefined ? { limit: CHUNK } : { afterRowId: after, limit: CHUNK });
|
|
73
|
+
for (const record of chunk) {
|
|
74
|
+
if (prefix !== undefined && !record.rowId.startsWith(prefix)) return rows;
|
|
75
|
+
if (rows.length >= bound) {
|
|
76
|
+
fail(context, "APPLICATION_ERROR", `state exceeds the supported bound of ${bound} rows`);
|
|
77
|
+
}
|
|
78
|
+
rows.push(record);
|
|
79
|
+
}
|
|
80
|
+
if (chunk.length < CHUNK) return rows;
|
|
81
|
+
after = chunk[chunk.length - 1].rowId;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function randomHex(context, count) {
|
|
86
|
+
let out = "";
|
|
87
|
+
for (let i = 0; i < count; i += 1) out += HEX[context.random.nextInteger(0, 16)];
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* New UUID v4 whose first 8 hex digits hold a descending sequence, so row-id order is newest first.
|
|
93
|
+
* `counter` is the field of `meta/counters` to advance.
|
|
94
|
+
*/
|
|
95
|
+
export function newId(context, counter) {
|
|
96
|
+
const counters = context.state.get("meta", "counters") ?? { emails: 0, domains: 0, apiKeys: 0, segments: 0, contacts: 0 };
|
|
97
|
+
const seq = (counters[counter] ?? 0) + 1;
|
|
98
|
+
context.state.put("meta", "counters", { ...counters, [counter]: seq });
|
|
99
|
+
const prefix = (0xffffffff - seq).toString(16).padStart(8, "0");
|
|
100
|
+
const variant = "89ab"[context.random.nextInteger(0, 4)];
|
|
101
|
+
return `${prefix}-${randomHex(context, 4)}-4${randomHex(context, 3)}-${variant}${randomHex(context, 3)}-${randomHex(context, 12)}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function randomChars(context, alphabet, count) {
|
|
105
|
+
let out = "";
|
|
106
|
+
for (let i = 0; i < count; i += 1) out += alphabet[context.random.nextInteger(0, alphabet.length)];
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function hasReservedKey(object) {
|
|
111
|
+
return Object.keys(object).some((key) => key === "__proto__" || key === "constructor" || key === "prototype");
|
|
112
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Bounded JSON nesting guard for route decoders. The framework's body parser accepts bodies nested about 3,000 levels,
|
|
2
|
+
// which later overflow recursive argument validation and answer an opaque 500. Every JSON route decode measures depth
|
|
3
|
+
// iteratively (an explicit stack, never recursion) and throws above MAX_JSON_DEPTH, so the request answers 400 first.
|
|
4
|
+
|
|
5
|
+
export const MAX_JSON_DEPTH = 512;
|
|
6
|
+
|
|
7
|
+
/** Nesting depth of a JSON value (a scalar is 0, `[]` or `{}` is 1), stopping as soon as `limit` is exceeded. */
|
|
8
|
+
export function jsonDepth(value, limit = MAX_JSON_DEPTH) {
|
|
9
|
+
let deepest = 0;
|
|
10
|
+
const stack = [[value, 1]];
|
|
11
|
+
while (stack.length > 0) {
|
|
12
|
+
const [node, depth] = stack.pop();
|
|
13
|
+
if (node === null || typeof node !== "object") continue;
|
|
14
|
+
if (depth > deepest) deepest = depth;
|
|
15
|
+
if (deepest > limit) return deepest;
|
|
16
|
+
const children = Array.isArray(node) ? node : Object.values(node);
|
|
17
|
+
for (const child of children) if (child !== null && typeof child === "object") stack.push([child, depth + 1]);
|
|
18
|
+
}
|
|
19
|
+
return deepest;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Throw when a JSON request body (or a text body that parses as JSON) is nested deeper than MAX_JSON_DEPTH. */
|
|
23
|
+
export function assertBodyDepth(request) {
|
|
24
|
+
const body = request?.body;
|
|
25
|
+
if (body === null || typeof body !== "object") return;
|
|
26
|
+
let value;
|
|
27
|
+
if (body.kind === "json") value = body.value;
|
|
28
|
+
else if (body.kind === "text" && typeof body.value === "string") {
|
|
29
|
+
try {
|
|
30
|
+
value = JSON.parse(body.value);
|
|
31
|
+
} catch {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
} else return;
|
|
35
|
+
if (jsonDepth(value) > MAX_JSON_DEPTH) throw new TypeError(`request body is nested deeper than ${MAX_JSON_DEPTH} levels`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Wrap every route codec's decode with the depth guard. */
|
|
39
|
+
export function guardRoutes(http) {
|
|
40
|
+
for (const [id, codec] of Object.entries(http)) {
|
|
41
|
+
const decode = codec.decode;
|
|
42
|
+
http[id] = { ...codec, decode: (request, ...rest) => { assertBodyDepth(request); return decode(request, ...rest); } };
|
|
43
|
+
}
|
|
44
|
+
return http;
|
|
45
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Resend cursor pagination over a fully materialised, bounded row list: `limit` 1–100 (default 20), `after` and
|
|
2
|
+
// `before` are exclusive object ids, output `{ object: "list", has_more, data }`.
|
|
3
|
+
import { fail, normalizeUuid } from "./core.mjs";
|
|
4
|
+
import { utf8Bytes } from "./check.mjs";
|
|
5
|
+
|
|
6
|
+
// UTF-8 bytes of the rendered rows (plus separators), kept well under the 1 MiB response cap with room for the envelope.
|
|
7
|
+
const MAX_PAGE_BYTES = 900_000;
|
|
8
|
+
|
|
9
|
+
function parseLimit(context, raw) {
|
|
10
|
+
if (raw === undefined || raw === null) return 20;
|
|
11
|
+
let value = raw;
|
|
12
|
+
if (typeof raw === "string") value = /^[0-9]{1,6}$/.test(raw) ? Number(raw) : NaN;
|
|
13
|
+
if (!Number.isInteger(value) || value < 1 || value > 100) {
|
|
14
|
+
fail(context, "VALIDATION_ERROR", "The `limit` parameter must be an integer between 1 and 100.");
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function cursorIndex(context, ids, raw, name) {
|
|
20
|
+
const id = normalizeUuid(raw);
|
|
21
|
+
if (id === null) fail(context, "VALIDATION_ERROR", `The \`${name}\` parameter must be a valid id.`);
|
|
22
|
+
const index = ids.indexOf(id);
|
|
23
|
+
if (index < 0) fail(context, "VALIDATION_ERROR", `The \`${name}\` cursor does not match any object in this list.`);
|
|
24
|
+
return index;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Read `limit`/`after`/`before` from `input` and slice `items` (each with a string `id`). `render` maps an item. */
|
|
28
|
+
export function paginate(context, input, items, render) {
|
|
29
|
+
const limit = parseLimit(context, input.limit);
|
|
30
|
+
const hasAfter = input.after !== undefined && input.after !== null;
|
|
31
|
+
const hasBefore = input.before !== undefined && input.before !== null;
|
|
32
|
+
if (hasAfter && hasBefore) fail(context, "VALIDATION_ERROR", "You can only use `after` or `before`, not both.");
|
|
33
|
+
const ids = items.map((item) => item.id);
|
|
34
|
+
let start = 0;
|
|
35
|
+
let end = items.length;
|
|
36
|
+
if (hasAfter) start = cursorIndex(context, ids, input.after, "after") + 1;
|
|
37
|
+
const data = [];
|
|
38
|
+
let bytes = 0;
|
|
39
|
+
if (hasBefore) {
|
|
40
|
+
// Fill backward from the row just before the cursor so a byte-capped page stays adjacent to `before` and
|
|
41
|
+
// every earlier row remains reachable through `has_more`.
|
|
42
|
+
end = cursorIndex(context, ids, input.before, "before");
|
|
43
|
+
const floor = Math.max(0, end - limit);
|
|
44
|
+
let first = end;
|
|
45
|
+
for (let i = end - 1; i >= floor; i -= 1) {
|
|
46
|
+
const rendered = render(items[i]);
|
|
47
|
+
bytes += utf8Bytes(JSON.stringify(rendered)) + 1;
|
|
48
|
+
if (data.length > 0 && bytes > MAX_PAGE_BYTES) break;
|
|
49
|
+
data.push(rendered);
|
|
50
|
+
first = i;
|
|
51
|
+
}
|
|
52
|
+
data.reverse();
|
|
53
|
+
return { object: "list", has_more: first > 0, data };
|
|
54
|
+
}
|
|
55
|
+
end = Math.min(items.length, start + limit);
|
|
56
|
+
for (let i = start; i < end; i += 1) {
|
|
57
|
+
const rendered = render(items[i]);
|
|
58
|
+
bytes += utf8Bytes(JSON.stringify(rendered)) + 1;
|
|
59
|
+
if (data.length > 0 && bytes > MAX_PAGE_BYTES) {
|
|
60
|
+
end = i;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
data.push(rendered);
|
|
64
|
+
}
|
|
65
|
+
return { object: "list", has_more: end < items.length, data };
|
|
66
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Virtual-time helpers. All arithmetic is UTC and integer microseconds; nothing reads a wall clock.
|
|
2
|
+
|
|
3
|
+
const US_PER_MS = 1000;
|
|
4
|
+
export const US_PER_SECOND = 1_000_000;
|
|
5
|
+
export const US_PER_DAY = 86_400 * US_PER_SECOND;
|
|
6
|
+
|
|
7
|
+
function pad(value, width) {
|
|
8
|
+
return String(value).padStart(width, "0");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function isoParts(us) {
|
|
12
|
+
const ms = Math.floor(us / US_PER_MS);
|
|
13
|
+
const micros = us - ms * US_PER_MS;
|
|
14
|
+
const iso = new Date(ms).toISOString(); // always UTC: YYYY-MM-DDTHH:MM:SS.mmmZ
|
|
15
|
+
return { date: iso.slice(0, 10), time: iso.slice(11, 19), fraction: iso.slice(20, 23) + pad(micros, 3) };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Resend's timestamp rendering: `2026-09-15 14:00:00.000000+00`. */
|
|
19
|
+
export function wireTime(us) {
|
|
20
|
+
if (us === null || us === undefined) return null;
|
|
21
|
+
const parts = isoParts(us);
|
|
22
|
+
return `${parts.date} ${parts.time}.${parts.fraction}+00`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** ISO-8601 with microseconds and `Z`: `2026-09-15T14:00:00.000000Z`. */
|
|
26
|
+
export function isoTime(us) {
|
|
27
|
+
const parts = isoParts(us);
|
|
28
|
+
return `${parts.date}T${parts.time}.${parts.fraction}Z`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function utcDay(us) {
|
|
32
|
+
return isoParts(us).date;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const ISO = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?(Z|z|[+-]\d{2}:?\d{2})$/;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Parse an ISO-8601 timestamp that carries `Z` or a numeric offset into microseconds. Zone-less input, impossible
|
|
39
|
+
* calendar values (2026-02-31) and anything else return `null`; the caller raises the declared validation error.
|
|
40
|
+
*/
|
|
41
|
+
export function parseTimestamp(text) {
|
|
42
|
+
if (typeof text !== "string" || text.length > 40) return null;
|
|
43
|
+
const match = ISO.exec(text);
|
|
44
|
+
if (match === null) return null;
|
|
45
|
+
const [, y, mo, d, h, mi, s, frac, zone] = match;
|
|
46
|
+
const year = Number(y), month = Number(mo), day = Number(d), hour = Number(h), minute = Number(mi), second = Number(s);
|
|
47
|
+
if (month < 1 || month > 12 || day < 1 || hour > 23 || minute > 59 || second > 59) return null;
|
|
48
|
+
const ms = Date.UTC(year, month - 1, day, hour, minute, second);
|
|
49
|
+
const check = new Date(ms);
|
|
50
|
+
if (check.getUTCFullYear() !== year || check.getUTCMonth() !== month - 1 || check.getUTCDate() !== day) return null;
|
|
51
|
+
let offsetMinutes = 0;
|
|
52
|
+
if (zone !== "Z" && zone !== "z") {
|
|
53
|
+
const digits = zone.replace(":", "");
|
|
54
|
+
const oh = Number(digits.slice(1, 3)), om = Number(digits.slice(3, 5));
|
|
55
|
+
if (oh > 23 || om > 59) return null;
|
|
56
|
+
offsetMinutes = (oh * 60 + om) * (digits[0] === "-" ? -1 : 1);
|
|
57
|
+
}
|
|
58
|
+
const micros = frac === undefined ? 0 : Number(frac.padEnd(6, "0"));
|
|
59
|
+
return (ms - offsetMinutes * 60_000) * US_PER_MS + micros;
|
|
60
|
+
}
|