@ultimat3/http 20.2.1 → 21.0.0

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/CLAUDE.md CHANGED
@@ -126,6 +126,21 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
126
126
  `logger` loses to the request's own field and stays reachable as `ctx.services['actor']`; the
127
127
  context's meaning never depends on what an app named a service. `context.test.ts` pins the
128
128
  factory install, the spread and the collision order.
129
+ - **A member's saved locale and zone apply, re-resolved after `auth`** (21.0.0). The `locale`
130
+ stage runs before `auth`, so it resolved from the cookie and `Accept-Language` alone and the
131
+ `user` rung of `resolveLocale` / `resolveTimeZone` was filled by nothing since 2.0.0. Core's
132
+ `Actor` now carries `locale?` / `tz?` (the saved preferences, set by whoever authenticates), and
133
+ the `auth` stage re-runs `resolvePreferences` when either is present — the owners' order, so a
134
+ locale cookie still beats a saved locale and a saved zone beats the cookie. `member-preferences.test.ts`.
135
+ - **A request's registered services are LAZY and bound to the actor that authenticated**
136
+ (`request-services.ts`, 21.0.0). The context is built before the `auth` stage names anyone, and
137
+ core's constructor built every `defineService` factory right there — so every service acted as
138
+ the anonymous actor for the whole request while `ctx.actor` read the real one: the reference
139
+ app's like answered 500 `X_ORG_NOT_A_MEMBER` for a member. `createRequestContext` passes core
140
+ `installServices: false` and `bindRequestServices` turns `ctx.services` and each registered
141
+ `ctx.<name>` into getters: built on first read, rebuilt only if the actor, locale or tz it closed
142
+ over changed. One build for a handler; a hook that reads a service before auth gets its own.
143
+ `request-services.test.ts` drives the real pipeline. Pre-existing since 13.0.0.
129
144
  - **The two inbound ids are read BEFORE the context and the span, in `correlation.ts`.** `startSpan`
130
145
  resolves its parent from `currentSpanContext()`, which reads `ctx.traceId`, so a `traceparent`
131
146
  parsed by a stage arrived one frame after the span's context was already frozen: the caller's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/http",
3
- "version": "20.2.1",
3
+ "version": "21.0.0",
4
4
  "description": "Owned request lifecycle over Bun.serve: router, ordered pipeline, problem+json errors",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,9 +31,9 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "20.2.1",
35
- "@ultimat3/i18n": "20.2.1",
36
- "@ultimat3/schema": "20.2.1",
37
- "@ultimat3/time": "20.2.1"
34
+ "@ultimat3/core": "21.0.0",
35
+ "@ultimat3/i18n": "21.0.0",
36
+ "@ultimat3/schema": "21.0.0",
37
+ "@ultimat3/time": "21.0.0"
38
38
  }
39
39
  }
package/src/context.ts CHANGED
@@ -24,6 +24,7 @@ import type { AuthzDecision } from './hooks';
24
24
  import { readCookie } from './locale';
25
25
  import type { PeerIdentity } from './peer-identity';
26
26
  import type { RateLimitDecision } from './rate-limit';
27
+ import { bindRequestServices } from './request-services';
27
28
  import type { CacheHint, RedirectIntent } from './response';
28
29
  import type { Route, RouteParams } from './router';
29
30
 
@@ -195,8 +196,11 @@ export const createRequestContext = (init: RequestContextInit): RequestContext =
195
196
  ? {}
196
197
  : { deadlineAt: init.deadlineAt }),
197
198
  ...(init.services === undefined ? {} : { services: init.services }),
199
+ // Registered services are bound lazily below, to the actor the `auth` stage authenticates —
200
+ // built here they would close over the anonymous actor for the whole request.
201
+ installServices: false,
198
202
  });
199
- return {
203
+ const ctx: RequestContext = {
200
204
  ...base,
201
205
  // Everything below is either this package's own or a core member the PIPELINE rewrites: the
202
206
  // mutable slots are re-declared here so a stage can write them, and they must therefore be
@@ -227,6 +231,8 @@ export const createRequestContext = (init: RequestContextInit): RequestContext =
227
231
  response: undefined,
228
232
  error: undefined,
229
233
  };
234
+ bindRequestServices(ctx, init.services ?? {});
235
+ return ctx;
230
236
  };
231
237
 
232
238
  /**
package/src/error-map.ts CHANGED
@@ -401,6 +401,19 @@ export const ERROR_STATUS = {
401
401
  // the answer. Deliberately not 409 — that spelling asks the client to reconcile and try again,
402
402
  // and a fenced answer has nothing to reconcile against.
403
403
  X_SUPERSEDED: 499,
404
+ // Plan 101's client seam. A typed call made from a server handler: an upstream that gave no usable
405
+ // answer is 502, the fence is `X_SUPERSEDED`'s twin. The rest are browser-side or declaration-time
406
+ // refusals no request carries; their rows keep the table closed.
407
+ X_CLIENT_TRANSPORT_FAILED: 502,
408
+ X_CLIENT_RECORD_ENVELOPE_INVALID: 502,
409
+ X_CLIENT_SCOPE_CHANGED: 499,
410
+ X_CHANNEL_DECLARATION_INVALID: 500,
411
+ X_LOCAL_STORE_UNAVAILABLE: 500,
412
+ X_MUTATOR_CLOCK_MISSING: 500,
413
+ X_REALTIME_UNINSTALLED: 500,
414
+ X_RECORD_KEY_MISSING: 500,
415
+ X_RECORD_REJECTED: 500,
416
+ X_SYNC_UNCONFIGURED: 500,
404
417
  X_INTERNAL: 500,
405
418
  // The keys are LITERAL — deliberately not `Readonly<Record<string, number>>`, which is what the
406
419
  // annotation used to say. This table is the closed one, so `ERROR_STATUS.X_QUERY_NOT_PAGABLE`
@@ -0,0 +1,65 @@
1
+ // A request's registered services (`defineService`), bound to the actor that AUTHENTICATED. The
2
+ // context exists before the `auth` stage names anyone, so core's constructor is told to install
3
+ // nothing and every registered name becomes a lazy member here instead: built on first read, and
4
+ // built again only if the actor, locale or time zone it closed over has since changed.
5
+
6
+ import type { CtxFacts, ServiceBag } from '@ultimat3/core';
7
+ import { installedServices, registeredServiceNames } from '@ultimat3/core';
8
+ import type { RequestContext } from './context';
9
+
10
+ interface Built {
11
+ readonly actor: CtxFacts['actor'];
12
+ readonly locale: string;
13
+ readonly tz: string;
14
+ readonly bag: ServiceBag;
15
+ }
16
+
17
+ /**
18
+ * Makes `ctx.services` and each registered `ctx.<name>` lazy. Non-enumerable, so building the
19
+ * preview a factory reads — the context's facts, no sibling service — never reads one back.
20
+ * An explicit service (`init.services`) stays what it was: a caller's mock overrides the real one.
21
+ */
22
+ export function bindRequestServices(ctx: RequestContext, explicit: ServiceBag): void {
23
+ let built: Built | undefined;
24
+ const bag = (): ServiceBag => {
25
+ if (
26
+ built !== undefined &&
27
+ built.actor === ctx.actor &&
28
+ built.locale === ctx.locale &&
29
+ built.tz === ctx.tz
30
+ ) {
31
+ return built.bag;
32
+ }
33
+ const preview: CtxFacts = Object.freeze({
34
+ ...explicit,
35
+ requestId: ctx.requestId,
36
+ traceId: ctx.traceId,
37
+ actor: ctx.actor,
38
+ locale: ctx.locale,
39
+ tz: ctx.tz,
40
+ buildId: ctx.buildId,
41
+ role: ctx.role,
42
+ clock: ctx.clock,
43
+ now: ctx.now,
44
+ logger: ctx.logger,
45
+ signal: ctx.signal,
46
+ deadlineAt: ctx.deadlineAt,
47
+ services: explicit,
48
+ });
49
+ const next = Object.freeze({ ...installedServices(preview), ...explicit });
50
+ built = { actor: ctx.actor, locale: ctx.locale, tz: ctx.tz, bag: next };
51
+ return next;
52
+ };
53
+ Object.defineProperty(ctx, 'services', { get: bag, enumerable: false, configurable: true });
54
+ for (const name of registeredServiceNames()) {
55
+ // Already an own property: a framework field (which keeps its meaning whatever an app named a
56
+ // service — core's rule) or an explicit `init.services` entry, which core's constructor spread
57
+ // onto the context and which overrides the registered factory of the same name.
58
+ if (Object.hasOwn(ctx, name)) continue;
59
+ Object.defineProperty(ctx, name, {
60
+ get: () => bag()[name],
61
+ enumerable: false,
62
+ configurable: true,
63
+ });
64
+ }
65
+ }
package/src/request.ts CHANGED
@@ -167,7 +167,20 @@ export class UltimateRequest {
167
167
  throw bodyInvalid(this.pathname, [`body is ${declared} bytes, limit is ${limit}`]);
168
168
  }
169
169
  const type = contentTypeOf(this.raw);
170
- if (type === '' || declared === 0) return undefined;
170
+ // An EMPTY form is a form with no fields, never "no input": a button-only `<form>` posts
171
+ // `content-length: 0`, and reading that as `undefined` failed every schema with 400.
172
+ const form = type === 'application/x-www-form-urlencoded' || type === 'multipart/form-data';
173
+ if (type === '') return undefined;
174
+ // A multipart body is only a FORM with the boundary its header must announce. Checked before
175
+ // the empty-form shortcut below, so a zero-length body cannot launder a malformed header into
176
+ // `{}` — without it the parser would have refused, and an empty body must refuse the same.
177
+ if (
178
+ type === 'multipart/form-data' &&
179
+ !/;\s*boundary=[^;\s]/i.test(this.header('content-type') ?? '')
180
+ ) {
181
+ throw bodyInvalid(this.pathname, ['multipart/form-data without a boundary parameter']);
182
+ }
183
+ if (declared === 0) return form ? {} : undefined;
171
184
 
172
185
  // One capped read for every content type, multipart included: the parser runs on bytes this
173
186
  // process already agreed to hold, never on a stream it hands to the runtime unbounded.
@@ -175,7 +188,7 @@ export class UltimateRequest {
175
188
  if ('over' in read) {
176
189
  throw bodyInvalid(this.pathname, [`body is at least ${read.over} bytes, limit is ${limit}`]);
177
190
  }
178
- if (read.bytes.byteLength === 0) return undefined;
191
+ if (read.bytes.byteLength === 0) return form ? {} : undefined;
179
192
 
180
193
  if (type === 'multipart/form-data') {
181
194
  try {
package/src/stages.ts CHANGED
@@ -188,16 +188,7 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
188
188
  * get one answer in the framework rather than one per package.
189
189
  */
190
190
  locale: (request, ctx) => {
191
- const cookies = request.header('cookie');
192
- ctx.locale = resolveLocale({
193
- header: request.header('accept-language'),
194
- cookie: readCookie(cookies, config.locale.cookie),
195
- }).locale;
196
- ctx.tz = resolveTimeZone({
197
- cookie: readCookie(cookies, config.tz.cookie),
198
- header: request.header(config.tz.header),
199
- }).zone;
200
- ctx.headers.set('content-language', ctx.locale);
191
+ resolvePreferences(request, ctx, config);
201
192
  return undefined;
202
193
  },
203
194
 
@@ -206,6 +197,11 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
206
197
  // The hook says "anonymous" with null; the context says it with core's anonymous actor,
207
198
  // because `asCtx` publishes this object as a `Ctx` and `Ctx.actor` is never null.
208
199
  ctx.actor = (await hooks.authenticate(request, ctx)) ?? anonymousActor();
200
+ // The `user` rung the `locale` stage could not know: re-resolved through the same owners,
201
+ // so a cookie the reader chose still beats a saved locale where i18n's order says it does.
202
+ if (ctx.actor.locale !== undefined || ctx.actor.tz !== undefined) {
203
+ resolvePreferences(request, ctx, config);
204
+ }
209
205
  }
210
206
  if (ctx.route?.meta.auth === 'required' && isAnonymous(ctx.actor)) {
211
207
  throw unauthenticated(ctx.url.pathname);
@@ -465,3 +461,28 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
465
461
  };
466
462
  return table;
467
463
  };
464
+
465
+ /**
466
+ * `ctx.locale`, `ctx.tz` and `content-language` from every source the request carries — the
467
+ * cookie, the header and, once `auth` has run, the actor's saved preference. One function for both
468
+ * stages, so the order is always the owners' (`resolveLocale`, `resolveTimeZone`) and never this
469
+ * file's.
470
+ */
471
+ function resolvePreferences(
472
+ request: UltimateRequest,
473
+ ctx: RequestContext,
474
+ config: HttpConfig,
475
+ ): void {
476
+ const cookies = request.header('cookie');
477
+ ctx.locale = resolveLocale({
478
+ header: request.header('accept-language'),
479
+ cookie: readCookie(cookies, config.locale.cookie),
480
+ user: ctx.actor.locale,
481
+ }).locale;
482
+ ctx.tz = resolveTimeZone({
483
+ cookie: readCookie(cookies, config.tz.cookie),
484
+ header: request.header(config.tz.header),
485
+ user: ctx.actor.tz ?? null,
486
+ }).zone;
487
+ ctx.headers.set('content-language', ctx.locale);
488
+ }