@fabrika/app 0.8.0 → 0.8.2
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/package.json +9 -9
- package/skill/SKILL.md +1 -1
- package/skill/references/app-setup.md +12 -12
- package/skill/references/jobs.md +8 -1
- package/src/jobs/mail.ts +8 -0
- package/src/jobs/types.ts +6 -0
- package/src/testing/proxy.ts +10 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/app",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -93,14 +93,14 @@
|
|
|
93
93
|
"test": "bun test --path-ignore-patterns='src/react/**' && bun test --isolate src/react"
|
|
94
94
|
},
|
|
95
95
|
"dependencies": {
|
|
96
|
-
"@fabrika/auth": "0.8.
|
|
97
|
-
"@fabrika/auth-core": "0.8.
|
|
98
|
-
"@fabrika/db": "0.8.
|
|
99
|
-
"@fabrika/email": "0.8.
|
|
100
|
-
"@fabrika/jwks": "0.8.
|
|
101
|
-
"@fabrika/platform": "0.8.
|
|
102
|
-
"@fabrika/platform-node": "0.8.
|
|
103
|
-
"@fabrika/proxy-core": "0.8.
|
|
96
|
+
"@fabrika/auth": "0.8.2",
|
|
97
|
+
"@fabrika/auth-core": "0.8.2",
|
|
98
|
+
"@fabrika/db": "0.8.2",
|
|
99
|
+
"@fabrika/email": "0.8.2",
|
|
100
|
+
"@fabrika/jwks": "0.8.2",
|
|
101
|
+
"@fabrika/platform": "0.8.2",
|
|
102
|
+
"@fabrika/platform-node": "0.8.2",
|
|
103
|
+
"@fabrika/proxy-core": "0.8.2",
|
|
104
104
|
"altcha-lib": "2.6.0",
|
|
105
105
|
"jose": "^5.9.0"
|
|
106
106
|
},
|
package/skill/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: fabrika-app
|
|
3
|
-
version: 0.8.
|
|
3
|
+
version: 0.8.2
|
|
4
4
|
description: >
|
|
5
5
|
How to write and change a Fabrika application: src/vocabulary.ts, db/migrations/, src/db/tables.ts, the
|
|
6
6
|
slices' endpoints.ts with their handlers, fabrika.contract.ts, jobs, files, webhooks, share links, connectors
|
|
@@ -19,22 +19,22 @@ the SPA is added with it.
|
|
|
19
19
|
"test": "bun test --isolate"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@fabrika/app": "0.8.
|
|
23
|
-
"@fabrika/db": "0.8.
|
|
24
|
-
"@fabrika/provider-zerops": "0.8.
|
|
25
|
-
"@fabrika/proxy-core": "0.8.
|
|
26
|
-
"@fabrika/ui": "0.8.
|
|
22
|
+
"@fabrika/app": "0.8.2",
|
|
23
|
+
"@fabrika/db": "0.8.2",
|
|
24
|
+
"@fabrika/provider-zerops": "0.8.2",
|
|
25
|
+
"@fabrika/proxy-core": "0.8.2",
|
|
26
|
+
"@fabrika/ui": "0.8.2",
|
|
27
27
|
"kysely": "0.29.6",
|
|
28
28
|
"react": "^19.0.0",
|
|
29
29
|
"react-dom": "^19.0.0",
|
|
30
30
|
"zod": "^3.25.76"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
|
-
"@fabrika/check": "0.8.
|
|
34
|
-
"@fabrika/cli": "0.8.
|
|
35
|
-
"@fabrika/platform": "0.8.
|
|
33
|
+
"@fabrika/check": "0.8.2",
|
|
34
|
+
"@fabrika/cli": "0.8.2",
|
|
35
|
+
"@fabrika/platform": "0.8.2",
|
|
36
36
|
"@happy-dom/global-registrator": "^20.11.2",
|
|
37
|
-
"@types/bun": "
|
|
37
|
+
"@types/bun": "^1.4.3",
|
|
38
38
|
"@types/react": "^19.0.0",
|
|
39
39
|
"@types/react-dom": "^19.0.0",
|
|
40
40
|
"typescript": "^5.9.3"
|
|
@@ -245,9 +245,9 @@ zerops:
|
|
|
245
245
|
- bun run build:web
|
|
246
246
|
```
|
|
247
247
|
|
|
248
|
-
The platform's build runs the tests, writes the entrypoint, runs `fabrika-check` and the type check. Its
|
|
249
|
-
|
|
250
|
-
|
|
248
|
+
The platform's build runs the tests, writes the entrypoint, runs `fabrika-check` and the type check. Its init commands
|
|
249
|
+
run `fabrika db migrate`, `fabrika db codegen --verify` and `fabrika db test-isolation` before the server starts: a
|
|
250
|
+
version does not go live until every generated test passes. It writes every `FABRIKA_*` variable; never set one.
|
|
251
251
|
|
|
252
252
|
## The SPA (added when the application has one)
|
|
253
253
|
|
package/skill/references/jobs.md
CHANGED
|
@@ -177,7 +177,14 @@ that says so): it records an outbox row and kicks the job (below). `references/p
|
|
|
177
177
|
(RFC 8058), so a mailbox offers one-click unsubscribe. The URL is a public procedure of your own with a signed token
|
|
178
178
|
(`ctx.url('api.mail.unsubscribe', { query: { t: ctx.tokens.sign('unsubscribe', claims, { days: 400 }) } })`,
|
|
179
179
|
`references/endpoint.md`). There are no free headers. A transport without the header refuses the message.
|
|
180
|
-
-
|
|
180
|
+
- `broadcast: true` marks one message of a send to many recipients (a newsletter, an announcement to every student),
|
|
181
|
+
as opposed to mail a recipient's own action triggers (a sign-in link, a confirmation). Providers keep the two apart
|
|
182
|
+
and pause an account whose transactional channel carries broadcast mail, so mark every message of a bulk send. It
|
|
183
|
+
needs `unsubscribe`. A transport without `ctx.mail.capabilities.broadcast` refuses it as `invalid_message`. Under
|
|
184
|
+
`mailer: 'installation'` the capability is true even where the installation's provider sends none; the first
|
|
185
|
+
message then fails `invalid_message`, so stop a bulk send at its first refusal instead of failing every row.
|
|
186
|
+
- The idempotency key covers the attachments, the unsubscribe URL and `broadcast`: a repeated key with other content
|
|
187
|
+
is refused.
|
|
181
188
|
- `idempotencyKey` is 1 to 200 visible ASCII characters, unique per message in the application environment, and
|
|
182
189
|
derived from the outbox row (`intake-link-<link id>`), never random. `ctx.mail` puts a prefix of the installation
|
|
183
190
|
and the environment in front of it, so no other application on the same provider account shares it.
|
package/src/jobs/mail.ts
CHANGED
|
@@ -93,6 +93,10 @@ export function jobMail(input: JobMailInput): AppMail {
|
|
|
93
93
|
if (typeof key !== 'string' || !APP_MAIL_KEY.test(key)) {
|
|
94
94
|
throw new EmailDeliveryError(`Email idempotency key is not 1..${MAX_APP_MAIL_KEY_LENGTH} visible ASCII characters`, { code: 'invalid_message' })
|
|
95
95
|
}
|
|
96
|
+
// Checked here, not left to the sender: one written before `broadcast` existed would send it as transactional mail.
|
|
97
|
+
if (available && message.broadcast === true && sender.capabilities.broadcast !== true) {
|
|
98
|
+
throw new EmailDeliveryError('This mail transport sends no broadcast email', { code: 'invalid_message' })
|
|
99
|
+
}
|
|
96
100
|
// Without a transport the send fails `mail_not_configured` below, and no file is read for it.
|
|
97
101
|
const attachments = available ? await readAttachments(message.attachments ?? [], sender.capabilities.maxAttachmentBytes, open) : []
|
|
98
102
|
const transported: EmailMessage = {
|
|
@@ -104,6 +108,7 @@ export function jobMail(input: JobMailInput): AppMail {
|
|
|
104
108
|
...(message.replyTo === undefined ? {} : { replyTo: message.replyTo }),
|
|
105
109
|
...(message.fromName === undefined ? {} : { fromName: message.fromName }),
|
|
106
110
|
...(message.unsubscribe === undefined ? {} : { unsubscribeUrl: message.unsubscribe.url }),
|
|
111
|
+
...(message.broadcast === true ? { broadcast: true } : {}),
|
|
107
112
|
...(attachments.length === 0 ? {} : { attachments }),
|
|
108
113
|
}
|
|
109
114
|
try {
|
|
@@ -309,6 +314,9 @@ const SenderCapabilities = z.object({
|
|
|
309
314
|
maxBatch: z.int().positive(),
|
|
310
315
|
listUnsubscribe: z.boolean().optional(),
|
|
311
316
|
maxAttachmentBytes: z.int().positive().optional(),
|
|
317
|
+
refusedAttachmentExtensions: z.array(z.string()).readonly().optional(),
|
|
318
|
+
minBodyLength: z.int().positive().optional(),
|
|
319
|
+
broadcast: z.boolean().optional(),
|
|
312
320
|
})
|
|
313
321
|
|
|
314
322
|
/** What `serve`'s `mail` returned: a sender, `null` for none, `undefined` for anything else. */
|
package/src/jobs/types.ts
CHANGED
|
@@ -54,6 +54,12 @@ export interface AppMailMessage {
|
|
|
54
54
|
* an `https:` link, usually `ctx.url` of a public procedure that verifies a signed token.
|
|
55
55
|
*/
|
|
56
56
|
readonly unsubscribe?: { readonly url: string }
|
|
57
|
+
/**
|
|
58
|
+
* One message of a send to many recipients (a newsletter, an announcement), not a reply to what the recipient did.
|
|
59
|
+
* The transport sends it on the provider's channel for broadcast mail, which keeps it apart from sign-in links and
|
|
60
|
+
* receipts. It needs `unsubscribe`. A transport without `capabilities.broadcast` refuses it as `invalid_message`.
|
|
61
|
+
*/
|
|
62
|
+
readonly broadcast?: boolean
|
|
57
63
|
/**
|
|
58
64
|
* Files the message carries: references this attempt holds and may read, as `ctx.files.open` takes them. Only a
|
|
59
65
|
* `live` file is sent; the bytes of all of them together stay within the transport's `maxAttachmentBytes`, and a
|
package/src/testing/proxy.ts
CHANGED
|
@@ -25,12 +25,12 @@ import { contractProxyTable } from '@fabrika/proxy-core/manifest'
|
|
|
25
25
|
export type ProxyOutcome =
|
|
26
26
|
| {
|
|
27
27
|
readonly outcome: 'forward'
|
|
28
|
-
/** Every header only the proxy may assert, with the value the decision gave it. */
|
|
29
|
-
readonly asserted: ReadonlyMap<string, string>
|
|
30
28
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
29
|
+
* Every header only the proxy may assert, with the value the decision gave it. A credentialed request that carried
|
|
30
|
+
* `Authorization` has it replaced by the minted token, so the caller's `px_` key never reaches the application.
|
|
33
31
|
*/
|
|
32
|
+
readonly asserted: ReadonlyMap<string, string>
|
|
33
|
+
/** Whether the request's own `Authorization` reaches the application: only on a webhook row the decision marked to pass it on. */
|
|
34
34
|
readonly keepsAuthorization: boolean
|
|
35
35
|
/** What the application's answer is given for an admitted cross-origin request, over its own headers. */
|
|
36
36
|
readonly decorate: (headers: Headers) => void
|
|
@@ -105,10 +105,15 @@ export function endpointProxy(options: EndpointProxyOptions): TestProxy {
|
|
|
105
105
|
asserted.set(header, value)
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
|
+
const keepsAuthorization = verification.headers.get(FORWARD_AUTHORIZATION_HEADER) === '1'
|
|
109
|
+
const minted = asserted.get(PROXY_TOKEN_HEADER)
|
|
110
|
+
if (!keepsAuthorization && minted !== undefined && request.headers.has('Authorization')) {
|
|
111
|
+
asserted.set('Authorization', `Bearer ${minted}`)
|
|
112
|
+
}
|
|
108
113
|
return {
|
|
109
114
|
outcome: 'forward',
|
|
110
115
|
asserted,
|
|
111
|
-
keepsAuthorization
|
|
116
|
+
keepsAuthorization,
|
|
112
117
|
decorate: (answer) => {
|
|
113
118
|
if (allowOrigin !== null) applyCrossOriginHeaders(answer, crossOriginHeaders(allowOrigin))
|
|
114
119
|
},
|