@crombolo/server 0.0.0-stage → 0.9.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 +21 -0
- package/NOTICE +69 -0
- package/package.json +41 -4
- package/src/access.ts +114 -0
- package/src/adapters/assistant.ts +229 -0
- package/src/adapters/blueprints.ts +163 -0
- package/src/adapters/bundles.ts +432 -0
- package/src/adapters/content.ts +783 -0
- package/src/adapters/dictionary.ts +76 -0
- package/src/adapters/documents.ts +637 -0
- package/src/adapters/elements.ts +403 -0
- package/src/adapters/file-system.ts +195 -0
- package/src/adapters/media.ts +347 -0
- package/src/adapters/member-groups.ts +90 -0
- package/src/adapters/members.ts +261 -0
- package/src/adapters/public-access.ts +116 -0
- package/src/adapters/redirects.ts +73 -0
- package/src/adapters/references.ts +67 -0
- package/src/adapters/schema-files.ts +204 -0
- package/src/adapters/schema-proposals.ts +180 -0
- package/src/adapters/template-files.ts +58 -0
- package/src/adapters/template-query.ts +127 -0
- package/src/adapters/users.ts +963 -0
- package/src/assistant.ts +487 -0
- package/src/backup.ts +52 -0
- package/src/bundles.ts +508 -0
- package/src/component-snippets.ts +163 -0
- package/src/components.ts +266 -0
- package/src/config.ts +548 -0
- package/src/content-type-udt.ts +256 -0
- package/src/database.ts +109 -0
- package/src/diagnostics.ts +143 -0
- package/src/dictionary-transfer.ts +123 -0
- package/src/domains.ts +384 -0
- package/src/editor-types.ts +108 -0
- package/src/email-http.ts +79 -0
- package/src/email-notice.ts +36 -0
- package/src/email-postmark.ts +78 -0
- package/src/email-resend.ts +69 -0
- package/src/email-ses.ts +307 -0
- package/src/email.ts +402 -0
- package/src/extensions-notice.ts +35 -0
- package/src/file-intake.ts +84 -0
- package/src/form-files.ts +241 -0
- package/src/form-submit.ts +397 -0
- package/src/form-workflows.ts +397 -0
- package/src/forms.ts +170 -0
- package/src/git-github.ts +171 -0
- package/src/git-routes.ts +154 -0
- package/src/git.ts +88 -0
- package/src/imaging.ts +434 -0
- package/src/index.ts +62 -0
- package/src/jobs.ts +140 -0
- package/src/listen.ts +36 -0
- package/src/log-expressions.ts +524 -0
- package/src/log-viewer.ts +190 -0
- package/src/logging.ts +126 -0
- package/src/marketplace.ts +297 -0
- package/src/media-files.ts +276 -0
- package/src/media-store-azure.ts +218 -0
- package/src/media-store-s3.ts +113 -0
- package/src/media-store.ts +248 -0
- package/src/member-auth.ts +384 -0
- package/src/models-builder.ts +106 -0
- package/src/no-nodes.ts +64 -0
- package/src/not-implemented.ts +44 -0
- package/src/oembed.ts +144 -0
- package/src/ports.ts +553 -0
- package/src/redirects.ts +132 -0
- package/src/schema-classify.ts +82 -0
- package/src/schema-store.ts +145 -0
- package/src/schema.ts +388 -0
- package/src/seo.ts +371 -0
- package/src/server-events.ts +131 -0
- package/src/server.ts +1579 -0
- package/src/status.ts +246 -0
- package/src/udt.ts +168 -0
- package/src/value-schemas.ts +64 -0
- package/src/view-runtime.ts +42 -0
- package/src/zip.ts +152 -0
- package/README.md +0 -3
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E-mail through Resend's HTTPS API.
|
|
3
|
+
*
|
|
4
|
+
* One POST, a bearer token, no SDK. Resend verifies the sending domain, so a
|
|
5
|
+
* `from` outside a verified domain comes back as a 403 the caller can show.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
assertSecureEndpoint,
|
|
9
|
+
base64,
|
|
10
|
+
type EmailAdapterOptions,
|
|
11
|
+
type EmailPort,
|
|
12
|
+
formatAddress,
|
|
13
|
+
postToProvider,
|
|
14
|
+
validateMessage,
|
|
15
|
+
} from './email.ts'
|
|
16
|
+
|
|
17
|
+
const ENDPOINT = 'https://api.resend.com/emails'
|
|
18
|
+
|
|
19
|
+
export interface ResendOptions extends EmailAdapterOptions {
|
|
20
|
+
apiKey: string
|
|
21
|
+
/** For a self-hosted or proxied Resend-compatible endpoint. */
|
|
22
|
+
endpoint?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function resendEmail(options: ResendOptions): EmailPort {
|
|
26
|
+
if (!options.apiKey) throw new Error('A Resend e-mail adapter needs an API key.')
|
|
27
|
+
const call = options.fetch ?? fetch
|
|
28
|
+
const endpoint = options.endpoint ?? ENDPOINT
|
|
29
|
+
assertSecureEndpoint(endpoint, 'Resend')
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
description: 'resend',
|
|
33
|
+
provider: 'resend',
|
|
34
|
+
|
|
35
|
+
async send(message) {
|
|
36
|
+
const from = message.from ?? options.from
|
|
37
|
+
const invalid = validateMessage(message, from)
|
|
38
|
+
if (invalid) return { ok: false, error: invalid }
|
|
39
|
+
|
|
40
|
+
return postToProvider(
|
|
41
|
+
call,
|
|
42
|
+
endpoint,
|
|
43
|
+
{
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: {
|
|
46
|
+
authorization: `Bearer ${options.apiKey}`,
|
|
47
|
+
'content-type': 'application/json',
|
|
48
|
+
},
|
|
49
|
+
body: JSON.stringify({
|
|
50
|
+
from: formatAddress(from),
|
|
51
|
+
to: message.to.map(formatAddress),
|
|
52
|
+
cc: message.cc?.map(formatAddress),
|
|
53
|
+
bcc: message.bcc?.map(formatAddress),
|
|
54
|
+
reply_to: message.replyTo ? formatAddress(message.replyTo) : undefined,
|
|
55
|
+
subject: message.subject,
|
|
56
|
+
text: message.text,
|
|
57
|
+
html: message.html,
|
|
58
|
+
attachments: message.attachments?.map((attachment) => ({
|
|
59
|
+
filename: attachment.filename,
|
|
60
|
+
content: base64(attachment.bytes),
|
|
61
|
+
content_type: attachment.contentType,
|
|
62
|
+
})),
|
|
63
|
+
}),
|
|
64
|
+
},
|
|
65
|
+
(body) => (body as { id?: string }).id ?? null,
|
|
66
|
+
)
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/email-ses.ts
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E-mail through Amazon SES v2, signed with Signature Version 4.
|
|
3
|
+
*
|
|
4
|
+
* SES authenticates with a signature rather than a key, so the signing is here
|
|
5
|
+
* rather than in an SDK. It is verified two ways in `tests/email.test.ts`:
|
|
6
|
+
* against AWS's published signing-key derivation example, and against Bun's own
|
|
7
|
+
* SigV4 — `Bun.S3Client.presign` is an independent implementation, and a
|
|
8
|
+
* canonical request signed by both must come out identical. That is what makes
|
|
9
|
+
* hand-rolled signing honest here rather than hopeful.
|
|
10
|
+
*
|
|
11
|
+
* Attachments mean a raw MIME message: SES's Simple content carries a subject
|
|
12
|
+
* and a body and nothing else, so anything with a file becomes
|
|
13
|
+
* `multipart/mixed` and goes through `Content.Raw`. Bcc stays out of the MIME
|
|
14
|
+
* headers and travels in `Destination`, where it cannot leak to a recipient.
|
|
15
|
+
*
|
|
16
|
+
* Credentials come from the options, else from the environment AWS tools already
|
|
17
|
+
* read (`AWS_ACCESS_KEY_ID` and friends). Instance roles are not resolved — that
|
|
18
|
+
* needs the metadata service — so a container wanting SES needs static keys or
|
|
19
|
+
* a session token.
|
|
20
|
+
*/
|
|
21
|
+
import { createHash, createHmac } from 'node:crypto'
|
|
22
|
+
import {
|
|
23
|
+
assertSecureEndpoint,
|
|
24
|
+
base64,
|
|
25
|
+
type EmailAdapterOptions,
|
|
26
|
+
type EmailAttachment,
|
|
27
|
+
type EmailMessage,
|
|
28
|
+
type EmailPort,
|
|
29
|
+
formatAddress,
|
|
30
|
+
postToProvider,
|
|
31
|
+
validateMessage,
|
|
32
|
+
} from './email.ts'
|
|
33
|
+
|
|
34
|
+
export interface SesOptions extends EmailAdapterOptions {
|
|
35
|
+
region: string
|
|
36
|
+
accessKeyId?: string
|
|
37
|
+
secretAccessKey?: string
|
|
38
|
+
sessionToken?: string
|
|
39
|
+
/** For a VPC endpoint or a local fake; the regional SES host otherwise. */
|
|
40
|
+
endpoint?: string
|
|
41
|
+
/** SES's configuration set, when one governs sending. */
|
|
42
|
+
configurationSetName?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ------------------------------------------------------------------ signature v4
|
|
46
|
+
|
|
47
|
+
const hmac = (key: Buffer | string, data: string) =>
|
|
48
|
+
createHmac('sha256', key).update(data, 'utf8').digest()
|
|
49
|
+
|
|
50
|
+
const sha256Hex = (data: string) => createHash('sha256').update(data, 'utf8').digest('hex')
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The four-step key derivation. Exported so the suite can hold it against
|
|
54
|
+
* AWS's published example, which is the only way to know it is right.
|
|
55
|
+
*/
|
|
56
|
+
export function sigv4SigningKey(
|
|
57
|
+
secretAccessKey: string,
|
|
58
|
+
date: string,
|
|
59
|
+
region: string,
|
|
60
|
+
service: string,
|
|
61
|
+
): Buffer {
|
|
62
|
+
return hmac(hmac(hmac(hmac(`AWS4${secretAccessKey}`, date), region), service), 'aws4_request')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `AWS4-HMAC-SHA256` over the canonical request. Exported for the same reason. */
|
|
66
|
+
export function sigv4Signature(
|
|
67
|
+
canonicalRequest: string,
|
|
68
|
+
amzDate: string,
|
|
69
|
+
scope: string,
|
|
70
|
+
key: Buffer,
|
|
71
|
+
): string {
|
|
72
|
+
return hmac(
|
|
73
|
+
key,
|
|
74
|
+
['AWS4-HMAC-SHA256', amzDate, scope, sha256Hex(canonicalRequest)].join('\n'),
|
|
75
|
+
).toString('hex')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `20260105T020404Z`, which is the only timestamp format SigV4 accepts. */
|
|
79
|
+
export function amzDate(now: Date): string {
|
|
80
|
+
return `${now
|
|
81
|
+
.toISOString()
|
|
82
|
+
.replace(/[-:]/g, '')
|
|
83
|
+
.replace(/\.\d{3}/, '')}`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface SignedRequest {
|
|
87
|
+
headers: Record<string, string>
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A POST signed for one service. Only what this adapter sends: a JSON body, no
|
|
92
|
+
* query string, and the handful of headers that go with it.
|
|
93
|
+
*/
|
|
94
|
+
export function signPost(options: {
|
|
95
|
+
url: URL
|
|
96
|
+
body: string
|
|
97
|
+
region: string
|
|
98
|
+
service: string
|
|
99
|
+
accessKeyId: string
|
|
100
|
+
secretAccessKey: string
|
|
101
|
+
sessionToken?: string
|
|
102
|
+
now: Date
|
|
103
|
+
}): SignedRequest {
|
|
104
|
+
const stamp = amzDate(options.now)
|
|
105
|
+
const day = stamp.slice(0, 8)
|
|
106
|
+
const payloadHash = sha256Hex(options.body)
|
|
107
|
+
|
|
108
|
+
// `host` is signed but never set on the request: fetch owns that header, and
|
|
109
|
+
// setting it is forbidden. Signing the value from the URL is what AWS expects.
|
|
110
|
+
const signed: Record<string, string> = {
|
|
111
|
+
'content-type': 'application/json',
|
|
112
|
+
host: options.url.host,
|
|
113
|
+
'x-amz-content-sha256': payloadHash,
|
|
114
|
+
'x-amz-date': stamp,
|
|
115
|
+
}
|
|
116
|
+
if (options.sessionToken) signed['x-amz-security-token'] = options.sessionToken
|
|
117
|
+
|
|
118
|
+
const names = Object.keys(signed).sort()
|
|
119
|
+
const canonicalRequest = [
|
|
120
|
+
'POST',
|
|
121
|
+
options.url.pathname,
|
|
122
|
+
'',
|
|
123
|
+
`${names.map((name) => `${name}:${signed[name]}`).join('\n')}\n`,
|
|
124
|
+
names.join(';'),
|
|
125
|
+
payloadHash,
|
|
126
|
+
].join('\n')
|
|
127
|
+
|
|
128
|
+
const scope = `${day}/${options.region}/${options.service}/aws4_request`
|
|
129
|
+
const signature = sigv4Signature(
|
|
130
|
+
canonicalRequest,
|
|
131
|
+
stamp,
|
|
132
|
+
scope,
|
|
133
|
+
sigv4SigningKey(options.secretAccessKey, day, options.region, options.service),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
const { host: _host, ...sendable } = signed
|
|
137
|
+
return {
|
|
138
|
+
headers: {
|
|
139
|
+
...sendable,
|
|
140
|
+
authorization:
|
|
141
|
+
`AWS4-HMAC-SHA256 Credential=${options.accessKeyId}/${scope}, ` +
|
|
142
|
+
`SignedHeaders=${names.join(';')}, Signature=${signature}`,
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ------------------------------------------------------------------ MIME, for attachments
|
|
148
|
+
|
|
149
|
+
/** Base64 at the 76 characters per line the encoding requires. */
|
|
150
|
+
function wrapped(value: string): string {
|
|
151
|
+
return (value.match(/.{1,76}/g) ?? []).join('\r\n')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** A header value with anything outside ASCII encoded, which keeps subjects intact. */
|
|
155
|
+
function headerValue(value: string): string {
|
|
156
|
+
if (!/[^\x20-\x7e]/.test(value)) return value
|
|
157
|
+
return `=?UTF-8?B?${Buffer.from(value, 'utf8').toString('base64')}?=`
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function bodyPart(contentType: string, content: string): string[] {
|
|
161
|
+
return [
|
|
162
|
+
`Content-Type: ${contentType}; charset=UTF-8`,
|
|
163
|
+
'Content-Transfer-Encoding: base64',
|
|
164
|
+
'',
|
|
165
|
+
wrapped(Buffer.from(content, 'utf8').toString('base64')),
|
|
166
|
+
]
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function attachmentPart(attachment: EmailAttachment): string[] {
|
|
170
|
+
const name = headerValue(attachment.filename)
|
|
171
|
+
return [
|
|
172
|
+
`Content-Type: ${attachment.contentType}; name="${name}"`,
|
|
173
|
+
`Content-Disposition: attachment; filename="${name}"`,
|
|
174
|
+
'Content-Transfer-Encoding: base64',
|
|
175
|
+
'',
|
|
176
|
+
wrapped(base64(attachment.bytes)),
|
|
177
|
+
]
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The message as `multipart/mixed`. Bcc is deliberately absent: it is an
|
|
182
|
+
* envelope instruction, and a header here would show every blind recipient to
|
|
183
|
+
* all the others.
|
|
184
|
+
*/
|
|
185
|
+
export function rawMime(
|
|
186
|
+
message: EmailMessage,
|
|
187
|
+
from: EmailMessage['from'],
|
|
188
|
+
boundary: string,
|
|
189
|
+
): string {
|
|
190
|
+
const alternative = `${boundary}-alt`
|
|
191
|
+
const lines: string[] = [
|
|
192
|
+
`From: ${formatAddress(from as NonNullable<typeof from>)}`,
|
|
193
|
+
`To: ${message.to.map(formatAddress).join(', ')}`,
|
|
194
|
+
]
|
|
195
|
+
if (message.cc?.length) lines.push(`Cc: ${message.cc.map(formatAddress).join(', ')}`)
|
|
196
|
+
if (message.replyTo) lines.push(`Reply-To: ${formatAddress(message.replyTo)}`)
|
|
197
|
+
lines.push(
|
|
198
|
+
`Subject: ${headerValue(message.subject)}`,
|
|
199
|
+
'MIME-Version: 1.0',
|
|
200
|
+
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
|
201
|
+
'',
|
|
202
|
+
`--${boundary}`,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
if (message.text && message.html) {
|
|
206
|
+
// Both bodies offered, so the client picks: the nested alternative is what
|
|
207
|
+
// makes a plain-text reader see text rather than markup.
|
|
208
|
+
lines.push(
|
|
209
|
+
`Content-Type: multipart/alternative; boundary="${alternative}"`,
|
|
210
|
+
'',
|
|
211
|
+
`--${alternative}`,
|
|
212
|
+
...bodyPart('text/plain', message.text),
|
|
213
|
+
`--${alternative}`,
|
|
214
|
+
...bodyPart('text/html', message.html),
|
|
215
|
+
`--${alternative}--`,
|
|
216
|
+
)
|
|
217
|
+
} else if (message.html) lines.push(...bodyPart('text/html', message.html))
|
|
218
|
+
else lines.push(...bodyPart('text/plain', message.text ?? ''))
|
|
219
|
+
|
|
220
|
+
for (const attachment of message.attachments ?? [])
|
|
221
|
+
lines.push(`--${boundary}`, ...attachmentPart(attachment))
|
|
222
|
+
|
|
223
|
+
lines.push(`--${boundary}--`, '')
|
|
224
|
+
return lines.join('\r\n')
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ------------------------------------------------------------------ the adapter
|
|
228
|
+
|
|
229
|
+
export function sesEmail(options: SesOptions): EmailPort {
|
|
230
|
+
if (!options.region) throw new Error('An SES e-mail adapter needs a region.')
|
|
231
|
+
const accessKeyId = options.accessKeyId ?? Bun.env.AWS_ACCESS_KEY_ID ?? ''
|
|
232
|
+
const secretAccessKey = options.secretAccessKey ?? Bun.env.AWS_SECRET_ACCESS_KEY ?? ''
|
|
233
|
+
if (!accessKeyId || !secretAccessKey)
|
|
234
|
+
throw new Error(
|
|
235
|
+
'An SES e-mail adapter needs credentials: pass accessKeyId and secretAccessKey, or set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY. Instance roles are not resolved.',
|
|
236
|
+
)
|
|
237
|
+
const sessionToken = options.sessionToken ?? Bun.env.AWS_SESSION_TOKEN ?? undefined
|
|
238
|
+
const call = options.fetch ?? fetch
|
|
239
|
+
const base = options.endpoint ?? `https://email.${options.region}.amazonaws.com`
|
|
240
|
+
assertSecureEndpoint(base, 'SES')
|
|
241
|
+
const endpoint = new URL(`${base}/v2/email/outbound-emails`)
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
description: `ses (${options.region})`,
|
|
245
|
+
provider: 'ses',
|
|
246
|
+
|
|
247
|
+
async send(message) {
|
|
248
|
+
const from = message.from ?? options.from
|
|
249
|
+
const invalid = validateMessage(message, from)
|
|
250
|
+
if (invalid) return { ok: false, error: invalid }
|
|
251
|
+
|
|
252
|
+
const destination = {
|
|
253
|
+
ToAddresses: message.to.map(formatAddress),
|
|
254
|
+
CcAddresses: message.cc?.length ? message.cc.map(formatAddress) : undefined,
|
|
255
|
+
BccAddresses: message.bcc?.length ? message.bcc.map(formatAddress) : undefined,
|
|
256
|
+
}
|
|
257
|
+
const content = message.attachments?.length
|
|
258
|
+
? {
|
|
259
|
+
Raw: {
|
|
260
|
+
Data: Buffer.from(
|
|
261
|
+
rawMime(message, from, `----crombolo-${crypto.randomUUID()}`),
|
|
262
|
+
'utf8',
|
|
263
|
+
).toString('base64'),
|
|
264
|
+
},
|
|
265
|
+
}
|
|
266
|
+
: {
|
|
267
|
+
Simple: {
|
|
268
|
+
Subject: { Data: message.subject, Charset: 'UTF-8' },
|
|
269
|
+
Body: {
|
|
270
|
+
Text: message.text ? { Data: message.text, Charset: 'UTF-8' } : undefined,
|
|
271
|
+
Html: message.html ? { Data: message.html, Charset: 'UTF-8' } : undefined,
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const body = JSON.stringify({
|
|
277
|
+
FromEmailAddress: formatAddress(from),
|
|
278
|
+
Destination: destination,
|
|
279
|
+
// Raw content carries its own Reply-To header, so it is not repeated.
|
|
280
|
+
ReplyToAddresses:
|
|
281
|
+
message.replyTo && !message.attachments?.length
|
|
282
|
+
? [formatAddress(message.replyTo)]
|
|
283
|
+
: undefined,
|
|
284
|
+
ConfigurationSetName: options.configurationSetName,
|
|
285
|
+
Content: content,
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
const signed = signPost({
|
|
289
|
+
url: endpoint,
|
|
290
|
+
body,
|
|
291
|
+
region: options.region,
|
|
292
|
+
service: 'ses',
|
|
293
|
+
accessKeyId,
|
|
294
|
+
secretAccessKey,
|
|
295
|
+
sessionToken,
|
|
296
|
+
now: new Date(),
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
return postToProvider(
|
|
300
|
+
call,
|
|
301
|
+
endpoint.toString(),
|
|
302
|
+
{ method: 'POST', headers: signed.headers, body },
|
|
303
|
+
(payload) => (payload as { MessageId?: string }).MessageId ?? null,
|
|
304
|
+
)
|
|
305
|
+
},
|
|
306
|
+
}
|
|
307
|
+
}
|