Skip to content

Commit 313ee10

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-24410-formula-related-field-refusal
2 parents 2293b1c + fa26308 commit 313ee10

16 files changed

Lines changed: 1510 additions & 352 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/plugin-hono-server': patch
3+
---
4+
5+
The `/auth/me` endpoints resolve their caller through `@objectstack/core`'s `resolveRequestExecutionContext`, the one request-to-`ExecutionContext` composition
6+
7+
Clause-②: no
8+
9+
- `makeExecutionContextResolver`, which `GET /auth/me/permissions`, `GET /auth/me/localization` and `GET /me/apps` call, resolves the caller by calling `resolveRequestExecutionContext` with this door's differences as named parameters: `anonymous: 'fail-closed'`, `authGate: 'withhold'`, `credentials: 'session-only'`, `sessionApi: 'required'`, `session: 'request'`, no tenancy posture, `localization: 'withhold'`, no request locale, `accessToken: 'withhold'`, and no OAuth provenance or admission channel. The per-request service reads, the environment-kernel resolution and the fail-closed `catch` stay at the door. Every platform door that resolves a request's caller now calls the one composition.
10+
- One answer changes. When the auth service's session read carries `session.userId` but no `user.id` (no user object, a user without an id, or `user.id: null`), these endpoints answered as signed out; they now answer as signed in as that user, which is how the runtime dispatcher, REST, the settings routes and the storage download already read it. No session producer yields that shape: in this repository better-auth's session read returns no session when the session's user is missing and plugin-auth passes its read through, and cloud's control plane uses the same plugin-auth read.
11+
- Every other answer is unchanged: no credential, an unrecognised cookie, a failing session read, an API key alone and a host with no auth service get each route's signed-out body; a session the authentication policy holds is still served on these allow-listed routes, with no `authGate` on its context; an unreadable permission store is still the host's `503 SERVICE_UNAVAILABLE`.
12+
- Nothing to do for a consumer: no export, route, configuration key or response shape changed.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/objectql': minor
4+
'@objectstack/runtime': patch
5+
---
6+
7+
fix(objectql,runtime): a hook whose `handler` names a function gets `ctx.title(field?)` in-process, as an inline handler and a `body` do
8+
9+
Clause-②: yes (widening)
10+
11+
- **What was wrong.** A hook whose `handler` is a string naming a function ran in-process without `ctx.title`: `defineStack({ functions })` plus a hook naming an entry on a source boot (`@objectstack/verify`'s `bootStack(config)`, `os serve` over a config), and a string `handler` resolved from the artifact's `runtimeModule` (what `objectstack build` ships for a callable it could not lower to a `body`). The engine's binder resolved the name after the runtime had wrapped the inline handlers, so the function was called with the bare engine context and `await ctx.title()` threw `TypeError: ctx.title is not a function`.
12+
- **What changed.** `bindHooksToEngine` (`@objectstack/objectql`) takes a `wrapHandler(handler, hook)` option, applied at bind to every handler a hook runs in-process, after a string `handler` resolves: an inline function, a name from the bind's `functions`, or a name the same package registered on the engine. A `body`'s handler (built by `bodyRunner`) never takes it. `ObjectQL.bindHooks` and the `IObjectQLEngine.bindHooks` contract (`@objectstack/spec`) carry the option. `bindAppArtifactHandlers` (`@objectstack/runtime`) passes the existing `withInProcessHookTitle` through it, in place of wrapping inline handlers before the bind, so the inline and the named handler take one wrap.
13+
- **Unchanged.** A bind that passes no `wrapHandler` binds every handler exactly as resolved, so a host or harness that calls `bindHooks` itself, and a hook registered with `ql.registerHook(...)`, are not handed `ctx.title`. `HookContext` still declares no `title`.

‎packages/core/src/security/authz-store-unavailable.test.ts‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -183,14 +183,14 @@ const TRANSPORT_LEDGER: Record<string, 'guarded' | 'propagates'> = {
183183
// letter A). It catches nothing around the resolver: every door that calls
184184
// it — the runtime dispatcher's `resolve-execution-context.ts` first, REST's
185185
// `rest-server.ts` second, the settings door's `settings-service-plugin.ts`
186-
// third and the storage download door's `storage-service-plugin.ts` fourth,
187-
// each of the last three through a fail-closed `catch` that re-raises the
188-
// outage — receives the outage and answers it at its own net.
186+
// third, the storage download door's `storage-service-plugin.ts` fourth and
187+
// the `/auth/me` door's `current-user-endpoints.ts` fifth (ruling record
188+
// 6257816853), each of the last four through a fail-closed `catch` that
189+
// re-raises the outage — receives the outage and answers it at its own net.
189190
'packages/core/src/security/request-execution-context.ts': 'propagates',
190191
'packages/mcp/src/plugin.ts': 'propagates',
191192
'packages/services/service-datasource/src/admin-routes.ts': 'guarded',
192193
'packages/plugins/plugin-sharing/src/sharing-plugin.ts': 'guarded',
193-
'packages/plugins/plugin-hono-server/src/current-user-endpoints.ts': 'guarded',
194194
'packages/cloud-connection/src/marketplace-install-local-plugin.ts': 'guarded',
195195
'packages/cloud-connection/src/cloud-connection-plugin.ts': 'guarded',
196196
// The print door: its caller-resolution `catch` relays the outage with its
@@ -297,8 +297,15 @@ describe('[#13279] every transport that authorizes through resolveAuthzContext',
297297
it('CONTROL: the scanner finds transports at all, and finds THIS repo', () => {
298298
// Without this, a broken walk would return [] and the set-equality audit
299299
// below would be comparing two empty sets and passing.
300+
//
301+
// [#23217 slice 5] The floor is the ledger's size, 7 rows in 6 packages
302+
// since the last door moved onto the one composition. Set equality below
303+
// already fails a walk that loses any listed file; the floor, with the
304+
// known member asserted next, refuses the one case set equality cannot
305+
// see: a broken walk and a gutted ledger together. Every door now calls
306+
// the composition, so no further conversion moves this number.
300307
const found = discoverTransports();
301-
expect(found.length).toBeGreaterThanOrEqual(8);
308+
expect(found.length).toBeGreaterThanOrEqual(7);
302309
// A transport this repo is known to hold. [#23217 slice 2] It named
303310
// `rest-server.ts` until that door moved onto the one composition, which
304311
// is the call site REST's answers now come from.

‎packages/objectql/src/engine.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ import { deriveViewContainerObject } from '@objectstack/metadata/view-container'
290290
// [#20331] The divergent view-container `name` refusal — the ONE judge this
291291
// registrar and `os validate` both call.
292292
import { viewContainerNameRefusal } from './view-container-name-refusal.js';
293-
import { bindHooksToEngine } from './hook-binder.js';
293+
import { bindHooksToEngine, type BindHooksOptions } from './hook-binder.js';
294294
import { declaredHookCondition } from './hook-wrappers.js';
295295
import { validateRecord, validateRecordInScope, normalizeMultiValueFields, normalizeBlankTypedValues, normalizeNumericStringValues, coerceBooleanFields, ValidationError, buildFieldError, resolveFieldLabel, valueShapePostureSetByEnv, mediaPostureSetByEnv, isScannableValueShapeField, valueShapeStrictEffective, mediaStrictEffective } from './validation/record-validator.js';
296296
import type { AdmittedValueShapeViolation, AdmittedValueShapeViolationSink } from './validation/record-validator.js';
@@ -4908,6 +4908,8 @@ export class ObjectQL implements IObjectQLEngine {
49084908
strict?: boolean;
49094909
warnLegacyHandler?: boolean;
49104910
metrics?: any;
4911+
/** Wraps each in-process handler after its name resolves — see {@link BindHooksOptions.wrapHandler}. */
4912+
wrapHandler?: BindHooksOptions['wrapHandler'];
49114913
}): void {
49124914
const merged = { ...(opts ?? {}), logger: this.logger } as any;
49134915
if (!merged.bodyRunner && this._defaultBodyRunner) {
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #24289 — `bindHooksToEngine`'s `wrapHandler` option: one wrap for every
5+
* handler a hook runs in-process, applied after a string `handler` resolves.
6+
*
7+
* - an inline function, a name from the bind's `functions` and a name the
8+
* same package registered on the engine all take the wrap;
9+
* - a `body`'s handler (built by `bodyRunner`) never does;
10+
* - control: a bind with no `wrapHandler` calls the resolved function with
11+
* the engine's own context object, exactly as before.
12+
*/
13+
14+
import { describe, it, expect } from 'vitest';
15+
import { ObjectQL } from './engine.js';
16+
import { bindHooksToEngine } from './hook-binder.js';
17+
import type { Hook, HookContext } from '@objectstack/spec/data';
18+
19+
const ctx = (): HookContext =>
20+
({ object: 'account', event: 'beforeInsert', input: { data: {} }, ql: undefined }) as HookContext;
21+
22+
/** Marks each context the wrapped handler is called with, and records which hook it wrapped. */
23+
function marking(wrappedFor: string[]) {
24+
return (handler: (context: HookContext) => unknown, hook: Hook) => {
25+
wrappedFor.push(hook.name);
26+
return (context: HookContext) => handler({ ...context, wrapped: hook.name } as HookContext);
27+
};
28+
}
29+
30+
const hook = (name: string, handler: Hook['handler']): Hook => ({
31+
name,
32+
object: 'account',
33+
events: ['beforeInsert'],
34+
handler,
35+
});
36+
37+
describe('#24289: bindHooksToEngine wrapHandler', () => {
38+
it('wraps an inline handler and both kinds of named handler, once each, at bind', async () => {
39+
const engine = new ObjectQL();
40+
engine.registerFunction('registered_fn', async (c: any) => { seen.push(`registered:${c.wrapped}`); }, {
41+
packageId: 'app:wrap',
42+
});
43+
const seen: string[] = [];
44+
const wrappedFor: string[] = [];
45+
46+
const result = bindHooksToEngine(
47+
engine,
48+
[
49+
hook('inline', async (c: any) => { seen.push(`inline:${c.wrapped}`); }),
50+
hook('from_bind', 'bound_fn'),
51+
hook('from_registry', 'registered_fn'),
52+
],
53+
{
54+
packageId: 'app:wrap',
55+
functions: { bound_fn: async (c: any) => { seen.push(`bound:${c.wrapped}`); } },
56+
wrapHandler: marking(wrappedFor),
57+
},
58+
);
59+
expect(result.errors).toEqual([]);
60+
expect(wrappedFor).toEqual(['inline', 'from_bind', 'from_registry']);
61+
62+
await engine.triggerHooks('beforeInsert', ctx());
63+
await engine.triggerHooks('beforeInsert', ctx());
64+
expect(seen.sort()).toEqual([
65+
'bound:from_bind', 'bound:from_bind',
66+
'inline:inline', 'inline:inline',
67+
'registered:from_registry', 'registered:from_registry',
68+
]);
69+
expect(wrappedFor, 'the wrap is applied at bind, never per event').toHaveLength(3);
70+
});
71+
72+
it("never wraps the handler a body's runner builds", async () => {
73+
const engine = new ObjectQL();
74+
const seen: unknown[] = [];
75+
const wrappedFor: string[] = [];
76+
bindHooksToEngine(
77+
engine,
78+
[{ ...hook('with_body', undefined), body: { language: 'js', source: 'return;' } } as Hook],
79+
{
80+
bodyRunner: () => async (c: any) => { seen.push(c.wrapped); },
81+
wrapHandler: marking(wrappedFor),
82+
},
83+
);
84+
85+
await engine.triggerHooks('beforeInsert', ctx());
86+
expect(wrappedFor).toEqual([]);
87+
expect(seen).toEqual([undefined]);
88+
});
89+
90+
it("control: without wrapHandler, a named handler is called with the engine's own context", async () => {
91+
const engine = new ObjectQL();
92+
const received: unknown[] = [];
93+
const fn = async (c: unknown) => { received.push(c); };
94+
bindHooksToEngine(engine, [hook('plain_named', 'plain_fn'), hook('plain_inline', fn)], {
95+
packageId: 'app:plain',
96+
functions: { plain_fn: fn },
97+
});
98+
99+
const context = ctx();
100+
await engine.triggerHooks('beforeInsert', context);
101+
expect(received).toEqual([context, context]);
102+
expect(received.every((c) => c === context)).toBe(true);
103+
});
104+
});

‎packages/objectql/src/hook-binder.ts‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
* metadata-aware behaviour lives in this binder + the wrapper module.
2222
*/
2323

24-
import type { Hook } from '@objectstack/spec/data';
24+
import type { Hook, HookContext } from '@objectstack/spec/data';
2525
import { normalizeFlowFunctionEntry, type FlowFunctionEntry } from '@objectstack/spec/automation';
2626
import type { ObjectQL, HookHandler } from './engine.js';
2727
import { wrapDeclarativeHook, type HookDiagnosticsLogger } from './hook-wrappers.js';
@@ -58,6 +58,22 @@ export interface BindHooksOptions {
5858
*/
5959
bodyRunner?: (hook: Hook) => HookHandler | undefined;
6060

61+
/**
62+
* Optional wrap for each handler the hook runs IN-PROCESS: an inline
63+
* `handler` function, and the function a string `handler` names (from
64+
* `functions` or the package's registered functions). It is applied here,
65+
* after the name is resolved, so both kinds take the one wrap; the handler
66+
* `bodyRunner` builds from a `body` never does. Called once per hook, at bind.
67+
*
68+
* The runtime passes the wrap that hands an in-process handler the
69+
* `ctx.title(field?)` a sandboxed `body` gets (#24289). Absent, every handler
70+
* binds exactly as resolved.
71+
*/
72+
wrapHandler?: (
73+
handler: (context: HookContext) => unknown,
74+
hook: Hook,
75+
) => (context: HookContext) => unknown;
76+
6177
/**
6278
* When true, treat unresolved hooks (body present but no runner, or handler
6379
* string with no implementation) as fatal errors instead of warnings. Used
@@ -392,6 +408,21 @@ function resolveHandler(
392408
return undefined;
393409
}
394410

411+
const fn = resolveInProcessHandler(engine, hook, opts);
412+
if (!fn || typeof opts.wrapHandler !== 'function') return fn;
413+
// The engine awaits a handler and discards its return value; so does this.
414+
const wrapped = opts.wrapHandler(fn, hook);
415+
return async (context) => {
416+
await wrapped(context);
417+
};
418+
}
419+
420+
/** The in-process handler a hook with no `body` names: its inline function, or the function its string names. */
421+
function resolveInProcessHandler(
422+
engine: ObjectQL,
423+
hook: Hook,
424+
opts: BindHooksOptions,
425+
): HookHandler | undefined {
395426
const h = hook.handler;
396427
if (typeof h === 'function') return h as HookHandler;
397428
if (typeof h === 'string' && h.length > 0) {

0 commit comments

Comments
 (0)