@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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/package.json +41 -4
  4. package/src/access.ts +114 -0
  5. package/src/adapters/assistant.ts +229 -0
  6. package/src/adapters/blueprints.ts +163 -0
  7. package/src/adapters/bundles.ts +432 -0
  8. package/src/adapters/content.ts +783 -0
  9. package/src/adapters/dictionary.ts +76 -0
  10. package/src/adapters/documents.ts +637 -0
  11. package/src/adapters/elements.ts +403 -0
  12. package/src/adapters/file-system.ts +195 -0
  13. package/src/adapters/media.ts +347 -0
  14. package/src/adapters/member-groups.ts +90 -0
  15. package/src/adapters/members.ts +261 -0
  16. package/src/adapters/public-access.ts +116 -0
  17. package/src/adapters/redirects.ts +73 -0
  18. package/src/adapters/references.ts +67 -0
  19. package/src/adapters/schema-files.ts +204 -0
  20. package/src/adapters/schema-proposals.ts +180 -0
  21. package/src/adapters/template-files.ts +58 -0
  22. package/src/adapters/template-query.ts +127 -0
  23. package/src/adapters/users.ts +963 -0
  24. package/src/assistant.ts +487 -0
  25. package/src/backup.ts +52 -0
  26. package/src/bundles.ts +508 -0
  27. package/src/component-snippets.ts +163 -0
  28. package/src/components.ts +266 -0
  29. package/src/config.ts +548 -0
  30. package/src/content-type-udt.ts +256 -0
  31. package/src/database.ts +109 -0
  32. package/src/diagnostics.ts +143 -0
  33. package/src/dictionary-transfer.ts +123 -0
  34. package/src/domains.ts +384 -0
  35. package/src/editor-types.ts +108 -0
  36. package/src/email-http.ts +79 -0
  37. package/src/email-notice.ts +36 -0
  38. package/src/email-postmark.ts +78 -0
  39. package/src/email-resend.ts +69 -0
  40. package/src/email-ses.ts +307 -0
  41. package/src/email.ts +402 -0
  42. package/src/extensions-notice.ts +35 -0
  43. package/src/file-intake.ts +84 -0
  44. package/src/form-files.ts +241 -0
  45. package/src/form-submit.ts +397 -0
  46. package/src/form-workflows.ts +397 -0
  47. package/src/forms.ts +170 -0
  48. package/src/git-github.ts +171 -0
  49. package/src/git-routes.ts +154 -0
  50. package/src/git.ts +88 -0
  51. package/src/imaging.ts +434 -0
  52. package/src/index.ts +62 -0
  53. package/src/jobs.ts +140 -0
  54. package/src/listen.ts +36 -0
  55. package/src/log-expressions.ts +524 -0
  56. package/src/log-viewer.ts +190 -0
  57. package/src/logging.ts +126 -0
  58. package/src/marketplace.ts +297 -0
  59. package/src/media-files.ts +276 -0
  60. package/src/media-store-azure.ts +218 -0
  61. package/src/media-store-s3.ts +113 -0
  62. package/src/media-store.ts +248 -0
  63. package/src/member-auth.ts +384 -0
  64. package/src/models-builder.ts +106 -0
  65. package/src/no-nodes.ts +64 -0
  66. package/src/not-implemented.ts +44 -0
  67. package/src/oembed.ts +144 -0
  68. package/src/ports.ts +553 -0
  69. package/src/redirects.ts +132 -0
  70. package/src/schema-classify.ts +82 -0
  71. package/src/schema-store.ts +145 -0
  72. package/src/schema.ts +388 -0
  73. package/src/seo.ts +371 -0
  74. package/src/server-events.ts +131 -0
  75. package/src/server.ts +1579 -0
  76. package/src/status.ts +246 -0
  77. package/src/udt.ts +168 -0
  78. package/src/value-schemas.ts +64 -0
  79. package/src/view-runtime.ts +42 -0
  80. package/src/zip.ts +152 -0
  81. 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
+ }
@@ -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
+ }