@beignet/core 0.0.32 → 0.0.34

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 (139) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +409 -34
  3. package/dist/application/index.d.ts.map +1 -1
  4. package/dist/application/index.js +63 -47
  5. package/dist/application/index.js.map +1 -1
  6. package/dist/contracts/contract-builder.d.ts +0 -4
  7. package/dist/contracts/contract-builder.d.ts.map +1 -1
  8. package/dist/contracts/contract-builder.js +0 -6
  9. package/dist/contracts/contract-builder.js.map +1 -1
  10. package/dist/events/index.d.ts +6 -0
  11. package/dist/events/index.d.ts.map +1 -1
  12. package/dist/events/index.js +20 -10
  13. package/dist/events/index.js.map +1 -1
  14. package/dist/flags/index.d.ts +0 -4
  15. package/dist/flags/index.d.ts.map +1 -1
  16. package/dist/flags/index.js +0 -4
  17. package/dist/flags/index.js.map +1 -1
  18. package/dist/jobs/index.d.ts +415 -4
  19. package/dist/jobs/index.d.ts.map +1 -1
  20. package/dist/jobs/index.js +392 -3
  21. package/dist/jobs/index.js.map +1 -1
  22. package/dist/notifications/index.d.ts +194 -10
  23. package/dist/notifications/index.d.ts.map +1 -1
  24. package/dist/notifications/index.js +397 -59
  25. package/dist/notifications/index.js.map +1 -1
  26. package/dist/outbox/index.d.ts +150 -1
  27. package/dist/outbox/index.d.ts.map +1 -1
  28. package/dist/outbox/index.js +170 -1
  29. package/dist/outbox/index.js.map +1 -1
  30. package/dist/ports/events.d.ts +2 -2
  31. package/dist/ports/index.d.ts +7 -2
  32. package/dist/ports/index.d.ts.map +1 -1
  33. package/dist/ports/index.js +2 -1
  34. package/dist/ports/index.js.map +1 -1
  35. package/dist/providers/instrumentation.d.ts +4 -0
  36. package/dist/providers/instrumentation.d.ts.map +1 -1
  37. package/dist/providers/instrumentation.js.map +1 -1
  38. package/dist/providers/provider.d.ts +1 -1
  39. package/dist/schedules/index.d.ts +8 -7
  40. package/dist/schedules/index.d.ts.map +1 -1
  41. package/dist/schedules/index.js +47 -16
  42. package/dist/schedules/index.js.map +1 -1
  43. package/dist/server/hooks/index.d.ts +1 -0
  44. package/dist/server/hooks/index.d.ts.map +1 -1
  45. package/dist/server/hooks/index.js +1 -0
  46. package/dist/server/hooks/index.js.map +1 -1
  47. package/dist/server/hooks/rate-limit.d.ts +26 -13
  48. package/dist/server/hooks/rate-limit.d.ts.map +1 -1
  49. package/dist/server/hooks/rate-limit.js +28 -34
  50. package/dist/server/hooks/rate-limit.js.map +1 -1
  51. package/dist/server/hooks/security.d.ts +179 -0
  52. package/dist/server/hooks/security.d.ts.map +1 -0
  53. package/dist/server/hooks/security.js +225 -0
  54. package/dist/server/hooks/security.js.map +1 -0
  55. package/dist/server/index.d.ts +8 -0
  56. package/dist/server/index.d.ts.map +1 -1
  57. package/dist/server/index.js +8 -0
  58. package/dist/server/index.js.map +1 -1
  59. package/dist/server/instrumentation.d.ts.map +1 -1
  60. package/dist/server/instrumentation.js +22 -6
  61. package/dist/server/instrumentation.js.map +1 -1
  62. package/dist/server/request-context.d.ts +4 -0
  63. package/dist/server/request-context.d.ts.map +1 -1
  64. package/dist/server/request-context.js +3 -0
  65. package/dist/server/request-context.js.map +1 -1
  66. package/dist/server/runtime-integrity.d.ts +84 -0
  67. package/dist/server/runtime-integrity.d.ts.map +1 -0
  68. package/dist/server/runtime-integrity.js +160 -0
  69. package/dist/server/runtime-integrity.js.map +1 -0
  70. package/dist/server/server.d.ts +10 -10
  71. package/dist/server/server.d.ts.map +1 -1
  72. package/dist/server/server.js +75 -11
  73. package/dist/server/server.js.map +1 -1
  74. package/dist/server/trusted-proxy.d.ts +77 -0
  75. package/dist/server/trusted-proxy.d.ts.map +1 -0
  76. package/dist/server/trusted-proxy.js +129 -0
  77. package/dist/server/trusted-proxy.js.map +1 -0
  78. package/dist/tasks/index.d.ts +6 -7
  79. package/dist/tasks/index.d.ts.map +1 -1
  80. package/dist/tasks/index.js +22 -5
  81. package/dist/tasks/index.js.map +1 -1
  82. package/dist/tenancy/index.d.ts +41 -0
  83. package/dist/tenancy/index.d.ts.map +1 -0
  84. package/dist/tenancy/index.js +34 -0
  85. package/dist/tenancy/index.js.map +1 -0
  86. package/dist/testing/index.d.ts +1 -1
  87. package/dist/testing/index.d.ts.map +1 -1
  88. package/dist/testing/index.js +1 -0
  89. package/dist/testing/index.js.map +1 -1
  90. package/dist/tracing/execution.d.ts +14 -0
  91. package/dist/tracing/execution.d.ts.map +1 -0
  92. package/dist/tracing/execution.js +17 -0
  93. package/dist/tracing/execution.js.map +1 -0
  94. package/dist/tracing/index.d.ts +49 -0
  95. package/dist/tracing/index.d.ts.map +1 -1
  96. package/dist/tracing/index.js +59 -0
  97. package/dist/tracing/index.js.map +1 -1
  98. package/dist/uploads/client.d.ts.map +1 -1
  99. package/dist/uploads/client.js +44 -6
  100. package/dist/uploads/client.js.map +1 -1
  101. package/dist/uploads/index.d.ts +71 -0
  102. package/dist/uploads/index.d.ts.map +1 -1
  103. package/dist/uploads/index.js +344 -15
  104. package/dist/uploads/index.js.map +1 -1
  105. package/dist/webhooks/index.d.ts +55 -4
  106. package/dist/webhooks/index.d.ts.map +1 -1
  107. package/dist/webhooks/index.js +106 -2
  108. package/dist/webhooks/index.js.map +1 -1
  109. package/package.json +5 -5
  110. package/skills/app-architecture/SKILL.md +31 -2
  111. package/src/application/index.ts +88 -56
  112. package/src/contracts/contract-builder.ts +0 -7
  113. package/src/events/index.ts +27 -14
  114. package/src/flags/index.ts +0 -5
  115. package/src/jobs/index.ts +895 -7
  116. package/src/notifications/index.ts +678 -70
  117. package/src/outbox/index.ts +358 -2
  118. package/src/ports/events.ts +2 -2
  119. package/src/ports/index.ts +17 -1
  120. package/src/providers/instrumentation.ts +4 -0
  121. package/src/providers/provider.ts +1 -1
  122. package/src/schedules/index.ts +60 -26
  123. package/src/server/hooks/index.ts +10 -0
  124. package/src/server/hooks/rate-limit.ts +52 -46
  125. package/src/server/hooks/security.ts +503 -0
  126. package/src/server/index.ts +8 -0
  127. package/src/server/instrumentation.ts +25 -5
  128. package/src/server/request-context.ts +7 -0
  129. package/src/server/runtime-integrity.ts +322 -0
  130. package/src/server/server.ts +88 -35
  131. package/src/server/trusted-proxy.ts +249 -0
  132. package/src/tasks/index.ts +29 -12
  133. package/src/tenancy/index.ts +55 -0
  134. package/src/testing/index.ts +2 -0
  135. package/src/tracing/execution.ts +37 -0
  136. package/src/tracing/index.ts +137 -0
  137. package/src/uploads/client.ts +65 -6
  138. package/src/uploads/index.ts +539 -14
  139. package/src/webhooks/index.ts +238 -9
package/README.md CHANGED
@@ -58,7 +58,7 @@ name the framework area they depend on.
58
58
  | `@beignet/core/events` | Events and listeners |
59
59
  | `@beignet/core/flags` | Feature flag definitions, FlagsPort, memory/static adapters, and helpers |
60
60
  | `@beignet/core/idempotency` | Retry-safe command, webhook, and job primitives |
61
- | `@beignet/core/jobs` | Job definitions, retry policies, and inline job dispatch |
61
+ | `@beignet/core/jobs` | Job definitions, retry policies, timeout guards, execution hooks, execution lease helpers, uniqueness guards, and inline job dispatch |
62
62
  | `@beignet/core/locks` | Lease-backed LocksPort, memory adapter, memory provider, and helpers |
63
63
  | `@beignet/core/mail` | Mail port, memory mailer, and memory mailer provider |
64
64
  | `@beignet/core/memo` | Request-scoped memoization for port lookups |
@@ -68,14 +68,14 @@ name the framework area they depend on.
68
68
  | `@beignet/core/payments` | Payments port, memory payments adapter, and memory payments provider |
69
69
  | `@beignet/core/pagination` | Offset/cursor page types, normalizers, and result helpers |
70
70
  | `@beignet/core/ports` | App-facing ports, auth, audit, policies, cache, storage, logging, and redaction |
71
- | `@beignet/core/ports/testing` | Port and policy test helpers |
72
71
  | `@beignet/core/providers` | Provider lifecycle and instrumentation primitives |
73
72
  | `@beignet/core/search` | Search index definitions, SearchPort, memory adapter, memory provider, and helpers |
74
73
  | `@beignet/core/schedules` | Schedule primitives |
75
- | `@beignet/core/server` | Framework-agnostic server runtime and hook helpers |
74
+ | `@beignet/core/server` | Framework-agnostic server runtime, security headers, CSRF, and hook helpers |
76
75
  | `@beignet/core/server-only` | Static lint marker for modules that must stay out of client bundles |
77
76
  | `@beignet/core/tasks` | Operational task definitions and inline task execution |
78
- | `@beignet/core/testing` | Test context factories, memory port fixtures, provider install helper, factories, seeds, and database harnesses |
77
+ | `@beignet/core/tenancy` | Branded tenant scope helpers for repository boundaries |
78
+ | `@beignet/core/testing` | Port and policy assertions, recording adapters, test context factories, memory port fixtures, provider install helper, factories, seeds, and database harnesses |
79
79
  | `@beignet/core/tracing` | Dependency-free W3C trace context primitives |
80
80
  | `@beignet/core/uploads` | Upload definitions (`createUploads<AppContext>()` app-bound builder), router, signer port, and test signer |
81
81
  | `@beignet/core/uploads/client` | Browser upload client for server and direct uploads |
@@ -98,10 +98,100 @@ Jobs, outbox delivery, and schedule runners use the same terms:
98
98
  - `attempts` in a retry policy is the maximum total attempts, including the
99
99
  first try.
100
100
  - `backoff` is the delay before the next retry.
101
+ - `timeout` is the maximum execution window for one handler attempt.
102
+ - `hook` is app-owned behavior that wraps one handler attempt.
103
+ - `execution lease` is a TTL-backed lock around one handler attempt for a
104
+ logical job key.
101
105
  - `terminal failure` means the work should not be retried automatically.
102
106
  - `dead letter` is a durable terminal delivery state, currently owned by the
103
107
  outbox.
104
108
 
109
+ Jobs may also declare dispatch-time uniqueness and execution leases:
110
+
111
+ ```typescript
112
+ import {
113
+ createJobExecutionLeaseHook,
114
+ createInlineJobDispatcher,
115
+ createJobs,
116
+ createUniqueJobDispatcher,
117
+ type JobDef,
118
+ retry,
119
+ } from "@beignet/core/jobs";
120
+ import type { LocksPort } from "@beignet/core/locks";
121
+ import { z } from "zod";
122
+
123
+ type AppContext = {
124
+ ports: {
125
+ billing: {
126
+ syncAccount(
127
+ accountId: string,
128
+ options?: { signal?: AbortSignal },
129
+ ): Promise<void>;
130
+ };
131
+ locks: LocksPort;
132
+ };
133
+ };
134
+
135
+ const { defineJob } = createJobs<AppContext>();
136
+
137
+ const syncAccountPayloadSchema = z.object({
138
+ accountId: z.string().min(1),
139
+ });
140
+
141
+ const syncAccountExecutionLease = createJobExecutionLeaseHook<
142
+ JobDef<"billing.sync-account", typeof syncAccountPayloadSchema, AppContext>,
143
+ AppContext
144
+ >({
145
+ locks: ({ ctx }) => ctx.ports.locks,
146
+ key: ({ payload }) => payload.accountId,
147
+ ttl: "5m",
148
+ });
149
+
150
+ export const SyncAccountJob = defineJob("billing.sync-account", {
151
+ payload: syncAccountPayloadSchema,
152
+ unique: ({ payload }) => ({
153
+ key: payload.accountId,
154
+ ttl: "10m",
155
+ }),
156
+ timeout: "30s",
157
+ retry: retry.exponential({ attempts: 3 }),
158
+ hooks: [syncAccountExecutionLease],
159
+ async handle({ payload, ctx, signal }) {
160
+ await ctx.ports.billing.syncAccount(payload.accountId, { signal });
161
+ },
162
+ });
163
+
164
+ export function createJobsPort(ctx: AppContext) {
165
+ return createUniqueJobDispatcher({
166
+ jobs: createInlineJobDispatcher<AppContext>({ ctx }),
167
+ locks: ctx.ports.locks,
168
+ });
169
+ }
170
+ ```
171
+
172
+ `unique` suppresses duplicate dispatches while the resolved lock key's TTL is
173
+ active. It does not replace handler idempotency: providers may still execute a
174
+ queued job more than once after a worker crash or retry.
175
+
176
+ `timeout` bounds each handler attempt. When the timeout expires, Beignet throws
177
+ `JobTimeoutError`, aborts the handler's `signal`, and lets the job retry policy
178
+ decide whether the timeout should retry.
179
+
180
+ `hooks` wrap each handler attempt when the job runs through a Beignet
181
+ dispatcher or worker helper. Runner-level hooks, such as
182
+ `createInlineJobDispatcher({ hooks })`, wrap job-local hooks. Hook failures are
183
+ classified by the same retry policy as handler failures. When a runner can
184
+ report attempt metadata, hooks receive Beignet's one-based `attempt` and
185
+ `maxAttempts` values. Direct `job.handle(...)` calls bypass hooks; use
186
+ `runJobHandler(...)` or a dispatcher when a test needs hook behavior.
187
+
188
+ `createJobExecutionLeaseHook(...)` is the first built-in hook helper. It
189
+ acquires a TTL-backed `LocksPort` lease for one handler attempt, then releases
190
+ best effort in `finally`. It does not start renewal loops, so serverless
191
+ entrypoints can use it with a shared locks provider; the TTL remains the safety
192
+ boundary if the runtime terminates early. Unavailable leases skip by default,
193
+ or can throw `JobExecutionLeaseUnavailableError` for retry classification.
194
+
105
195
  Schedules do not own retry policies. They can carry provider attempt metadata
106
196
  through `ScheduleRunContext.attempt`, then dispatch jobs or outbox messages when
107
197
  the work needs Beignet-managed retry and dead-letter behavior.
@@ -180,9 +270,12 @@ provider `name` that defaults to `"memory-mailer"`.
180
270
  `{ notifications: NotificationPort }` backed by
181
271
  `createInlineNotificationDispatcher(...)`. Channel handlers receive an app
182
272
  service context built lazily through the server context blueprint on each
183
- send, so registration order does not matter. Options accept the dispatcher's
184
- `onError` handler plus a provider `name` that defaults to
185
- `"inline-notifications"`.
273
+ send, so registration order does not matter. One failed channel does not block
274
+ the remaining channels. Inline sends return ordered `sent`, `skipped`, and
275
+ `failed` results; queued dispatchers also return `queued`. Set
276
+ `failureMode: "throw"` to reject after every channel has run. Options also
277
+ accept an app-owned `preferences` evaluator, the dispatcher's `onError` result
278
+ mapper, and a provider `name` that defaults to `"inline-notifications"`.
186
279
 
187
280
  ```typescript
188
281
  // server/providers.ts
@@ -199,8 +292,56 @@ export const providers = [
199
292
 
200
293
  Replace `createMemoryMailerProvider(...)` with a real mail provider such as
201
294
  `@beignet/provider-mail-resend` for production delivery. Production apps can
202
- keep the inline notifications provider and run it from jobs or listeners, or
203
- replace it with an app-owned dispatcher when delivery needs custom semantics.
295
+ keep the inline provider or define a central notification registry and a
296
+ `defineNotificationDeliveryJob(...)`. Install
297
+ `createQueuedNotificationsProvider(...)` after the app's jobs provider to
298
+ enqueue one independently retryable job per channel. Register the delivery job
299
+ with every BullMQ/Inngest worker or outbox registry that can receive it.
300
+
301
+ ```typescript
302
+ // server/notifications.ts
303
+ import {
304
+ defineNotificationDeliveryJob,
305
+ defineNotificationRegistry,
306
+ } from "@beignet/core/notifications";
307
+ import type { AppContext } from "@/app-context";
308
+ import { WelcomeNotification } from "@/features/users/notifications";
309
+
310
+ export const notificationRegistry = defineNotificationRegistry<AppContext>([
311
+ WelcomeNotification,
312
+ ]);
313
+
314
+ export const DeliverNotificationJob =
315
+ defineNotificationDeliveryJob<AppContext>({
316
+ registry: notificationRegistry,
317
+ });
318
+ ```
319
+
320
+ ```typescript
321
+ // server/index.ts
322
+ import { createQueuedNotificationsProvider } from "@beignet/core/notifications";
323
+ import { createNextServer, createNextServerLoader } from "@beignet/next";
324
+
325
+ export const getServer = createNextServerLoader(async () => {
326
+ const { providers } = await import("./providers");
327
+ const { DeliverNotificationJob } = await import("./notifications");
328
+
329
+ return createNextServer({
330
+ // ...
331
+ providers: [
332
+ ...providers,
333
+ createQueuedNotificationsProvider({
334
+ deliveryJob: DeliverNotificationJob,
335
+ }),
336
+ ],
337
+ });
338
+ });
339
+ ```
340
+
341
+ The delivery job defaults to three attempts with exponential backoff. The
342
+ queued dispatcher validates notification payloads before enqueueing and uses
343
+ the app's existing `jobs` port, so the same setup works with direct job
344
+ providers or `createOutboxJobDispatcher(...)`.
204
345
 
205
346
  ## Entitlements
206
347
 
@@ -215,6 +356,8 @@ import {
215
356
  type EntitlementDecisionObserver,
216
357
  requireEntitlement,
217
358
  } from "@beignet/core/entitlements";
359
+ import { createTenant } from "@beignet/core/ports";
360
+ import { createTenantScope } from "@beignet/core/tenancy";
218
361
 
219
362
  function createBillingEntitlements(
220
363
  billing: BillingRepository,
@@ -222,7 +365,10 @@ function createBillingEntitlements(
222
365
  ) {
223
366
  return createEntitlements({
224
367
  async inspect(input) {
225
- const account = await billing.findByTenantId(input.subject.id);
368
+ if (input.subject.type !== "tenant") return false;
369
+ const account = await billing.findByTenantScope(
370
+ createTenantScope(createTenant(input.subject.id)),
371
+ );
226
372
  return account?.status === "active";
227
373
  },
228
374
  onDecision: recordDecision,
@@ -353,6 +499,56 @@ Provider adapters may require query fields to be declared in the index
353
499
  metadata. For Meilisearch, `filters` and `facets` must use
354
500
  `filterableAttributes`, and `sort` must use `sortableAttributes`.
355
501
 
502
+ ## Uploads
503
+
504
+ Use `@beignet/core/uploads` for typed file workflows above `StoragePort`.
505
+ Upload definitions own metadata validation, authorization, storage keys, file
506
+ constraints, direct-upload signing, and completion hooks.
507
+
508
+ ```typescript
509
+ import { createUploads } from "@beignet/core/uploads";
510
+ import { z } from "zod";
511
+
512
+ const { defineUpload } = createUploads<AppContext>();
513
+
514
+ export const issueAttachmentUpload = defineUpload("issues.attachment", {
515
+ metadata: z.object({ issueKey: z.string() }),
516
+ file: {
517
+ contentTypes: ["application/pdf", "text/plain"],
518
+ maxSizeBytes: 5 * 1024 * 1024,
519
+ checksum: { algorithm: "sha256" },
520
+ },
521
+ authorize({ ctx }) {
522
+ return ctx.actor.type === "user";
523
+ },
524
+ key({ metadata, uploadId }) {
525
+ return `issues/${metadata.issueKey}/attachments/${uploadId}`;
526
+ },
527
+ async verifyFile({ ctx, file }) {
528
+ const scan = await ctx.ports.fileScanner.scanObject(file.key);
529
+
530
+ return scan.clean
531
+ ? true
532
+ : { valid: false, reason: "Upload did not pass scanning." };
533
+ },
534
+ async onComplete({ ctx, files }) {
535
+ await ctx.ports.issueAttachments.create({
536
+ id: files[0]!.uploadId,
537
+ key: files[0]!.key,
538
+ });
539
+ },
540
+ });
541
+ ```
542
+
543
+ For supported media types, uploads verify the declared content type against the
544
+ file signature before completion. Set `contentTypeVerification: false` only for
545
+ workflows that intentionally accept mismatched supported file types. Direct
546
+ uploads can require a SHA-256 checksum with `checksum: { algorithm: "sha256" }`;
547
+ browser clients need a client-safe manifest so `@beignet/core/uploads/client`
548
+ can compute the digest before `prepare`. Use `verifyFile(...)` for app-owned
549
+ scanning, moderation, and quarantine decisions that run after the object exists
550
+ in storage and before `onComplete(...)`.
551
+
356
552
  ## Webhooks
357
553
 
358
554
  Use `@beignet/core/webhooks` for provider-neutral inbound webhook definitions,
@@ -378,6 +574,10 @@ export const issueWebhook = defineWebhook("issues.provider", {
378
574
  secret: process.env.PROVIDER_WEBHOOK_SECRET ?? "",
379
575
  signatureHeader: "x-provider-signature",
380
576
  signaturePrefix: "sha256=",
577
+ timestamp: {
578
+ header: "x-provider-timestamp",
579
+ toleranceSec: 300,
580
+ },
381
581
  }),
382
582
  });
383
583
  ```
@@ -399,6 +599,13 @@ direction. Attach provider verifiers at the route boundary through the
399
599
  (`createHmacWebhookVerifier(...)`, `createMemoryWebhookVerifier(...)`) and for
400
600
  tests.
401
601
 
602
+ Generic webhook catalogs reject verified event types that are not declared in
603
+ `events` by default. Set `allowUnknownEvents: true` on
604
+ `createWebhookRoute(...)` or `verifyWebhook(...)` only for broad provider
605
+ endpoints that intentionally acknowledge valid events the app does not handle.
606
+ When a generic HMAC provider signs a timestamp header or payload field, pass
607
+ `timestamp` to reject replayed deliveries outside the configured tolerance.
608
+
402
609
  ## Provider metadata
403
610
 
404
611
  Reusable provider packages should declare static metadata in `package.json`
@@ -418,7 +625,7 @@ without importing provider implementation code.
418
625
  "requiredTables": ["cache_entries"],
419
626
  "registration": {
420
627
  "required": true,
421
- "tokens": ["cacheProvider", "createCacheProvider"]
628
+ "tokens": ["createCacheProvider"]
422
629
  },
423
630
  "watchers": ["cache"]
424
631
  }
@@ -578,6 +785,45 @@ path with an unregistered method receives a framework-owned `405
578
785
  METHOD_NOT_ALLOWED` response with an `Allow` header listing the registered
579
786
  methods; `HEAD` is intentionally not served by `GET` handlers.
580
787
 
788
+ ### Runtime integrity
789
+
790
+ Workflow artifacts are explicit too. Use `createRuntimeIntegrity(...)` when an
791
+ app should fail startup if a listener, schedule, task, or outbox event/job is
792
+ listed in the app manifest but missing from the runtime registries:
793
+
794
+ ```ts
795
+ import {
796
+ createRuntimeIntegrity,
797
+ defineRuntimeManifest,
798
+ defineRuntimeRegistries,
799
+ } from "@beignet/core/server";
800
+ import { postEvents } from "@/features/posts/domain/events";
801
+ import { postJobs } from "@/features/posts/jobs";
802
+ import { postListeners } from "@/features/posts/listeners";
803
+ import { listeners } from "@/server/listeners";
804
+ import { outboxRegistry } from "@/server/outbox";
805
+
806
+ export const runtimeIntegrity = createRuntimeIntegrity({
807
+ manifest: defineRuntimeManifest({
808
+ listeners: [...postListeners],
809
+ outbox: {
810
+ events: [...postEvents],
811
+ jobs: [...postJobs],
812
+ },
813
+ }),
814
+ registries: defineRuntimeRegistries({
815
+ listeners,
816
+ outbox: outboxRegistry,
817
+ }),
818
+ });
819
+ ```
820
+
821
+ Pass `integrity: runtimeIntegrity` to `createServer(...)` or
822
+ `createNextServer(...)`. The check is pure and serverless-safe: it compares
823
+ imported definitions and registries in memory, without filesystem scanning,
824
+ provider calls, database access, worker startup, or background loops. Use
825
+ `mode: "warn"` to log findings without failing boot.
826
+
581
827
  Contract path templates intentionally support concrete segments and
582
828
  single-segment params such as `:id` and `[id]`. Framework or platform
583
829
  catch-all route files can expose a central Beignet handler, but individual
@@ -645,7 +891,7 @@ import { createTestContextFactory, createTestPorts } from "@beignet/core/testing
645
891
  import {
646
892
  createTestTenant,
647
893
  createTestUserActor,
648
- } from "@beignet/core/ports/testing";
894
+ } from "@beignet/core/testing";
649
895
 
650
896
  const fixture = createTestPorts<AppContext["ports"]>({
651
897
  base: appPorts,
@@ -785,7 +1031,7 @@ The `service` factory powers two server entrypoints:
785
1031
 
786
1032
  - `server.createServiceContext(...)` returns the built context and enters the
787
1033
  ambient correlation frame for the rest of the caller's async execution. Use
788
- it from long-lived runtimes only: servers, workers, and `bun test`.
1034
+ it from long-lived runtimes only: servers, workers, and test runners.
789
1035
  - `server.runServiceContext(...)` builds the same context and runs a callback
790
1036
  inside a scoped ambient frame, returning the callback's result. Use it from
791
1037
  plain scripts such as seeds and one-off maintenance work — the
@@ -807,18 +1053,47 @@ Both entrypoints require `context.service` in the blueprint, generate fresh
807
1053
  `tenant` on the ambient request context so audit and instrumentation wrappers
808
1054
  observe them at record time.
809
1055
 
1056
+ ### Tenant scopes
1057
+
1058
+ Use `@beignet/core/tenancy` when a repository method should be scoped to the
1059
+ current tenant without accepting arbitrary tenant IDs from callers:
1060
+
1061
+ ```ts
1062
+ import {
1063
+ requireTenantScope,
1064
+ tenantScopeId,
1065
+ type TenantScope,
1066
+ } from "@beignet/core/tenancy";
1067
+
1068
+ export interface TodoRepository {
1069
+ create(input: CreateTodoInput, scope: TenantScope): Promise<Todo>;
1070
+ }
1071
+
1072
+ const scope = requireTenantScope(ctx);
1073
+ await ctx.ports.todos.create(input, scope);
1074
+
1075
+ const tenantId = tenantScopeId(scope); // adapter boundary
1076
+ ```
1077
+
1078
+ Apps still own tenant resolution and tenant data modeling. `TenantScope` only
1079
+ brands the already-resolved `ctx.tenant` value for app-facing repository
1080
+ boundaries. `beignet doctor --strict` checks generated tenant-scoped Drizzle
1081
+ repositories, explicit raw `tenantId` repository boundaries, and scoped
1082
+ `tenantId`/`workspaceId` predicates as a conservative drift detector.
1083
+
810
1084
  ### Testing providers
811
1085
 
812
1086
  Use `installProviderForTest(...)` to run provider setup against test ports
813
1087
  without hand-rolling setup, port merge, and lifecycle plumbing:
814
1088
 
815
1089
  ```ts
1090
+ import type { CachePort } from "@beignet/core/ports";
816
1091
  import { installProviderForTest } from "@beignet/core/testing";
1092
+ import { createRedisCacheProvider } from "@beignet/provider-cache-redis";
817
1093
 
818
1094
  const { ports, result, start, stop } = await installProviderForTest(
819
- redisCacheProvider,
1095
+ createRedisCacheProvider(),
820
1096
  {
821
- ports: { devtools },
822
1097
  config: { URL: "redis://localhost:6379" },
823
1098
  },
824
1099
  );
@@ -899,7 +1174,7 @@ ORM table objects, or provider SDKs directly.
899
1174
 
900
1175
  ### Port testing helpers
901
1176
 
902
- Use `@beignet/core/ports/testing` when tests need stable actor, tenant,
1177
+ Use `@beignet/core/testing` when tests need stable actor, tenant,
903
1178
  authorization, or audit assertions:
904
1179
 
905
1180
  ```ts
@@ -909,7 +1184,7 @@ import {
909
1184
  createTestActivityContext,
910
1185
  createTestTenant,
911
1186
  createTestUserActor,
912
- } from "@beignet/core/ports/testing";
1187
+ } from "@beignet/core/testing";
913
1188
 
914
1189
  const activity = createTestActivityContext({
915
1190
  actor: createTestUserActor("user_1", { role: "admin" }),
@@ -963,7 +1238,7 @@ import {
963
1238
  createRecordingEventBus,
964
1239
  createRecordingJobDispatcher,
965
1240
  createRecordingProviderInstrumentation,
966
- } from "@beignet/core/ports/testing";
1241
+ } from "@beignet/core/testing";
967
1242
  import { drainOutbox } from "@beignet/core/outbox";
968
1243
  import { createProviderInstrumentation } from "@beignet/core/providers";
969
1244
 
@@ -1113,6 +1388,29 @@ For caching that must survive across requests, use the explicit tier —
1113
1388
  `ports.cache.remember` with keys that change when the data changes — and see
1114
1389
  the request lifecycle docs for context latency budgets.
1115
1390
 
1391
+ ### Trusted proxy request metadata
1392
+
1393
+ Beignet trusts no forwarding headers by default. Use
1394
+ `resolveTrustedRequest(...)` when app code needs external request metadata
1395
+ behind a platform edge or reverse proxy that strips or normalizes forwarding
1396
+ headers before they reach application code:
1397
+
1398
+ ```ts
1399
+ import { resolveTrustedRequest } from "@beignet/core/server";
1400
+
1401
+ const requestInfo = resolveTrustedRequest(req, {
1402
+ clientIp: "x-forwarded-for-last",
1403
+ });
1404
+
1405
+ requestInfo.origin; // "https://app.example.com"
1406
+ requestInfo.clientIp; // resolved only when clientIp is configured
1407
+ ```
1408
+
1409
+ The same `trustedProxy` policy is accepted by `createRateLimitHooks(...)` for
1410
+ IP-scoped keys and by `createCsrfHooks(...)` for comparing browser origins
1411
+ against the external host and protocol. Configure it only when every request
1412
+ passes through your trusted edge.
1413
+
1116
1414
  ### Rate limiting
1117
1415
 
1118
1416
  Use `createRateLimitHooks(...)` from `@beignet/core/server` to enforce
@@ -1131,14 +1429,15 @@ const server = await createServer<AppContext, AppPorts>({
1131
1429
  - `global` and `ip` scopes run in `onRequest` before parsing and context
1132
1430
  creation; `user` scope runs in `beforeHandle` after route hooks have resolved
1133
1431
  identity and `ctx.actor` exists.
1134
- - `ip` scopes require an explicit `ipSource`. The hook's `validate` phase
1135
- fails `createServer(...)` startup when a registered contract declares an
1136
- `ip`-scoped rate limit without one, and enforcement throws the same
1137
- configuration error for contracts added later through `server.route(...)`.
1138
- Use `"x-forwarded-for-last"` only behind a trusted proxy that appends the
1139
- socket address, `"x-forwarded-for-first"` when a trusted edge normalizes
1140
- the header, or pass a function for platform headers such as
1141
- `cf-connecting-ip`.
1432
+ - `ip` scopes require an explicit `trustedProxy.clientIp`, `ipSource`, or
1433
+ custom `earlyKey`. The hook's `validate` phase fails `createServer(...)`
1434
+ startup when a registered contract declares an `ip`-scoped rate limit without
1435
+ one, and enforcement throws the same configuration error for contracts added
1436
+ later through `server.route(...)`. Prefer
1437
+ `trustedProxy: { clientIp: "x-forwarded-for-last" }` behind a trusted proxy
1438
+ that appends the socket address, `"x-forwarded-for-first"` when a trusted
1439
+ edge normalizes the header, or
1440
+ `trustedProxy: { clientIp: "cf-connecting-ip" }` for platform headers.
1142
1441
  - Pass `ipSource: "none"` to explicitly opt out of client-IP resolution:
1143
1442
  Beignet then trusts no forwarding headers and all `ip`-scoped traffic
1144
1443
  shares one `ip:unknown` bucket.
@@ -1230,12 +1529,17 @@ import {
1230
1529
  createOutboxEventRecorder,
1231
1530
  defineOutboxRegistry,
1232
1531
  drainOutbox,
1532
+ type OutboxAdminPort,
1233
1533
  } from "@beignet/core/outbox";
1234
1534
  import {
1535
+ createDrizzleSqliteOutboxAdminPort,
1235
1536
  createDrizzleSqliteOutboxPort,
1236
1537
  createDrizzleSqliteUnitOfWork,
1237
1538
  } from "@beignet/provider-db-drizzle/sqlite";
1238
1539
 
1540
+ const outboxAdmin: OutboxAdminPort =
1541
+ createDrizzleSqliteOutboxAdminPort(db);
1542
+
1239
1543
  const uow = createDrizzleSqliteUnitOfWork({
1240
1544
  db,
1241
1545
  createTransactionPorts: (tx) => {
@@ -1265,6 +1569,10 @@ await drainOutbox({
1265
1569
  The outbox is at-least-once delivery. Use idempotent listeners or jobs when a
1266
1570
  duplicate delivery would be harmful.
1267
1571
 
1572
+ Use `OutboxAdminPort` only from operational contexts. It lets `beignet outbox`
1573
+ list, show, requeue, purge dead-lettered rows, and prune delivered rows without
1574
+ exposing those destructive operations to transaction-scoped use cases.
1575
+
1268
1576
  ### Schedules
1269
1577
 
1270
1578
  Use `@beignet/core/schedules` to define typed schedules and run them
@@ -1373,6 +1681,27 @@ const writerAuth = createAuthHooks<AppContext>()({
1373
1681
  });
1374
1682
  ```
1375
1683
 
1684
+ Use `createSecurityHeadersHooks(...)` for the default browser response-header
1685
+ baseline. The hook adds common headers such as `X-Content-Type-Options`,
1686
+ `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`,
1687
+ `Cross-Origin-Opener-Policy`, and `Cross-Origin-Resource-Policy`; it does not
1688
+ guess your CSP or HSTS policy:
1689
+
1690
+ ```ts
1691
+ import { createSecurityHeadersHooks } from "@beignet/core/server";
1692
+
1693
+ const securityHeaders = createSecurityHeadersHooks({
1694
+ contentSecurityPolicy: "default-src 'self'; frame-ancestors 'none'",
1695
+ strictTransportSecurity: {
1696
+ maxAgeSec: 31_536_000,
1697
+ includeSubDomains: true,
1698
+ },
1699
+ });
1700
+ ```
1701
+
1702
+ Existing response headers win, so routes that stream files, render HTML, or need
1703
+ a different CSP can set their own policy.
1704
+
1376
1705
  Use `createCorsHooks(...)` for app-wide CORS headers. Wildcard origins are
1377
1706
  accepted only for non-credentialed requests:
1378
1707
 
@@ -1390,6 +1719,31 @@ const browserAppCors = createCorsHooks({
1390
1719
  apps do not accidentally reflect arbitrary request origins for cookies or
1391
1720
  authorization headers.
1392
1721
 
1722
+ Use `createCsrfHooks(...)` when browser mutations depend on cookies. By default
1723
+ the hook protects unsafe methods by rejecting cross-origin `Origin` or `Referer`
1724
+ headers while still allowing requests that do not carry browser origin headers.
1725
+ Set `allowMissingOrigin: false` and enable token checks for stricter
1726
+ browser-only APIs:
1727
+
1728
+ ```ts
1729
+ import { createCsrfHooks } from "@beignet/core/server";
1730
+
1731
+ const csrf = createCsrfHooks({
1732
+ allowMissingOrigin: false,
1733
+ trustedProxy: {},
1734
+ trustedOrigins: ["https://app.example.com"],
1735
+ token: {
1736
+ cookieName: "csrf",
1737
+ headerName: "x-csrf-token",
1738
+ },
1739
+ skip: ({ contract }) => contract.name.startsWith("webhooks."),
1740
+ });
1741
+ ```
1742
+
1743
+ Pass `trustedProxy: {}` only when Beignet should compare `Origin` or `Referer`
1744
+ against the external `x-forwarded-host` and `x-forwarded-proto` values written
1745
+ by your trusted edge.
1746
+
1393
1747
  ### HTTP adapter boundary
1394
1748
 
1395
1749
  `@beignet/core/server` is framework-neutral. It owns route matching, hooks,
@@ -1471,15 +1825,37 @@ Service contexts created with `server.createServiceContext(...)` receive fresh
1471
1825
  sets its own `requestId`, headers and recorded events use it.
1472
1826
 
1473
1827
  Trace primitives live in `@beignet/core/tracing` (`TraceContext`,
1474
- `createTraceContext`, `createChildTraceContext`, `parseTraceparent`,
1475
- `createTraceparent`, `createTraceId`, `createSpanId`). The module is
1476
- dependency-free so app context types can be imported from client bundles.
1828
+ `TracingPort`, `TraceOperation`, `TraceSpan`, `createTraceContext`,
1829
+ `createChildTraceContext`, `parseTraceparent`, `createTraceparent`,
1830
+ `createTraceId`, `createSpanId`). The module is dependency-free so app context
1831
+ types can be imported from client bundles.
1832
+
1833
+ When final ports include `ports.tracing`, requests execute inside an active
1834
+ `beignet.request <contract>` span. Incoming `traceparent` and `tracestate`
1835
+ continue the trace; if the host already established an active span, Beignet's
1836
+ request span becomes its child. Use cases, listeners, job handlers, schedule
1837
+ handlers, and task handlers create nested active spans through the same port.
1838
+ Install `@beignet/provider-tracing-opentelemetry` to adapt this port to an
1839
+ app-owned OpenTelemetry SDK and emit baseline duration, error, and provider
1840
+ operation metrics.
1841
+
1842
+ Listener, job, schedule, and task runners accept both a lazy `ctx` factory and
1843
+ an explicit `tracing` port. Provider-backed runtimes should pass the installed
1844
+ port so Beignet starts the workflow span before resolving
1845
+ `server.createServiceContext(...)`; the resulting context then inherits the
1846
+ real active span instead of treating local correlation IDs as a remote parent.
1847
+
1848
+ For custom `TraceOperation` values, `attributes` are span-only. Use
1849
+ `metricAttributes` only for bounded operation dimensions such as a contract,
1850
+ use-case, job, schedule, or task name. Never add request, actor, tenant, or
1851
+ payload values to metric attributes.
1477
1852
 
1478
1853
  Use cases created with `createUseCase(...)` are instrumented by default. Each
1479
- run resolves the instrumentation port from `ctx.ports`, creates a child span
1480
- from the context's trace fields, and records `usecase` lifecycle events plus
1481
- correlated `error` events for failures. Pass `instrumentation: false` to opt
1482
- out.
1854
+ run resolves the instrumentation port from `ctx.ports` and records `usecase`
1855
+ lifecycle events plus correlated `error` events for failures. When a tracing
1856
+ port is installed, it also creates an active child span. Pass
1857
+ `instrumentation: false` to opt out of instrumentation events; tracing is
1858
+ controlled by whether the app installs `ports.tracing`.
1483
1859
 
1484
1860
  `createInstrumentedAuditLog({ audit, instrumentation })` from
1485
1861
  `@beignet/core/ports` writes durable audit entries first and mirrors sanitized
@@ -1558,7 +1934,6 @@ getTodo.schema.query; // Query parameter schema
1558
1934
  getTodo.schema.body; // Request body schema
1559
1935
  getTodo.schema.responses; // Response schemas by status code
1560
1936
  getTodo.path; // "/api/todos/:id"
1561
- getTodo.pathTemplate; // "/api/todos/:id" (alias)
1562
1937
  getTodo.method; // "GET"
1563
1938
  getTodo.metadata; // { auth: "required", ... }
1564
1939
  ```