@fabrika/app 0.8.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/app",
3
- "version": "0.8.1",
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.1",
97
- "@fabrika/auth-core": "0.8.1",
98
- "@fabrika/db": "0.8.1",
99
- "@fabrika/email": "0.8.1",
100
- "@fabrika/jwks": "0.8.1",
101
- "@fabrika/platform": "0.8.1",
102
- "@fabrika/platform-node": "0.8.1",
103
- "@fabrika/proxy-core": "0.8.1",
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.1
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.1",
23
- "@fabrika/db": "0.8.1",
24
- "@fabrika/provider-zerops": "0.8.1",
25
- "@fabrika/proxy-core": "0.8.1",
26
- "@fabrika/ui": "0.8.1",
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.1",
34
- "@fabrika/cli": "0.8.1",
35
- "@fabrika/platform": "0.8.1",
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": "latest",
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"
@@ -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
- - The idempotency key covers the attachments and the unsubscribe URL: a repeated key with other content is refused.
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
@@ -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
- * Whether the request's own `Authorization` reaches the application: not on an anonymous endpoint, except a webhook
32
- * row the decision marked to pass it on.
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: asserted.has(PROXY_TOKEN_HEADER) || verification.headers.get(FORWARD_AUTHORIZATION_HEADER) === '1',
116
+ keepsAuthorization,
112
117
  decorate: (answer) => {
113
118
  if (allowOrigin !== null) applyCrossOriginHeaders(answer, crossOriginHeaders(allowOrigin))
114
119
  },