From 0402c60e110caacb43c080771fba65312b6540f0 Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 26 Sep 2026 18:57:15 +0200 Subject: [PATCH 01/11] Add guard-preserving borrowed scope execution Capture scope authority at admission so host integrations can vary correlation and link cancellation without substituting a mutable principal or tenant. Share the ambient boundary with owned operations while retaining their cleanup behavior and singleton failure handling. --- Documentation/dependency-injection.md | 6 ++ Source/Core/ArcServer.ts | 12 ++++ .../dependencyInjection/ServiceRegistry.ts | 11 ++- .../Core/dependencyInjection/ServiceScope.ts | 19 +++++- Source/Core/execution/runInScope.ts | 22 ++++++ Source/Core/execution/runOwned.ts | 11 ++- Source/Core/execution/snapshotPrincipal.ts | 20 ++++++ .../Core/execution/withExecutionBoundary.ts | 19 ++++++ .../with_a_failing_singleton.ts | 30 ++++++++ .../with_a_live_singleton_factory.ts | 35 ++++++++++ .../with_invalid_scopes.ts | 32 +++++++++ .../with_mutated_authority.ts | 68 +++++++++++++++++++ .../with_nested_invocations.ts | 54 +++++++++++++++ .../with_registry_shutdown.ts | 44 ++++++++++++ .../with_server_local_metadata.ts | 34 ++++++++++ 15 files changed, 405 insertions(+), 12 deletions(-) create mode 100644 Source/Core/execution/runInScope.ts create mode 100644 Source/Core/execution/snapshotPrincipal.ts create mode 100644 Source/Core/execution/withExecutionBoundary.ts create mode 100644 Source/Core/for_ArcServer/when_borrowing_a_scope/with_a_failing_singleton.ts create mode 100644 Source/Core/for_ArcServer/when_borrowing_a_scope/with_a_live_singleton_factory.ts create mode 100644 Source/Core/for_ArcServer/when_borrowing_a_scope/with_invalid_scopes.ts create mode 100644 Source/Core/for_ArcServer/when_borrowing_a_scope/with_mutated_authority.ts create mode 100644 Source/Core/for_ArcServer/when_borrowing_a_scope/with_nested_invocations.ts create mode 100644 Source/Core/for_ArcServer/when_borrowing_a_scope/with_registry_shutdown.ts create mode 100644 Source/Core/for_ArcServer/when_borrowing_a_scope/with_server_local_metadata.ts diff --git a/Documentation/dependency-injection.md b/Documentation/dependency-injection.md index f3d785d1..5d12e8e8 100644 --- a/Documentation/dependency-injection.md +++ b/Documentation/dependency-injection.md @@ -101,6 +101,12 @@ Registry shutdown drains admitted work and pending singleton construction, then Nested commands and queries keep their causal dependency ancestry for cycle detection but get their own execution identity and scoped lifetime guard; the same scoped token in two independent nested scopes is not a cycle. +## Borrow a scope in a host integration + +A trusted host integration can call `server.runInScope(scope, callback, { correlationId, signal })` to run construction and callbacks with `currentContext()` and `currentServices()` set. Create the scope with `server.services.createScope(context)` and dispose it yourself after all borrowed work settles; `runInScope` does not own it. The scope captures tenant, principal (including roles and claims), transport identity, severity, and cancellation authority at creation. An invocation may change only its correlation ID and add a cancellation signal linked to the scope's signal. Nested calls restore the prior context when they settle. + +This is a trusted host API, **not an authorization mechanism** or a way to authenticate a principal. Use the normal command or query pipeline for authorization; do not expose scope creation or borrowed execution to untrusted callers. + ## Related - [Build an application](core/getting-started.md) diff --git a/Source/Core/ArcServer.ts b/Source/Core/ArcServer.ts index d9c9a582..ca197e88 100644 --- a/Source/Core/ArcServer.ts +++ b/Source/Core/ArcServer.ts @@ -24,6 +24,8 @@ import { requestContext } from './execution/RequestContextStore.js'; import { isObservableOperation } from './queries/observable/ObservableOperation.js'; import { CommandOperationBoundary } from './commands/CommandOperationBoundary.js'; import { runOwned } from './execution/runOwned.js'; +import { runInScope } from './execution/runInScope.js'; +import type { ServiceScope } from './dependencyInjection/ServiceScope.js'; import { runProvider } from './execution/runProvider.js'; import { disposeObservableServer } from './queries/observable/disposeObservableServer.js'; import type { ObservableQuerySession } from './queries/observable/ObservableQuerySession.js'; @@ -86,6 +88,16 @@ export class ArcServer { registerObservableCleanup(this, this.#sessions); } + /** + * Run trusted host work in a borrowed scope without disposing it. Authority comes from the + * scope's creation snapshot; only correlation and an additional cancellation signal can vary. + * This is not an authorization mechanism. The caller must dispose the scope when work ends. + */ + runInScope(scope: ServiceScope, callback: () => T | Promise, + options?: { correlationId?: string; signal?: AbortSignal }): Promise { + return runInScope(this.services, this.#generatedMetadata, scope, callback, options); + } + private runScoped(operation: Operation, input: unknown, context: ExecutionContext, options?: QueryOptions, mode = OperationMode.Execute): Promise { if (operation.kind === 'command' && CommandOperationBoundary.attempt(this)) diff --git a/Source/Core/dependencyInjection/ServiceRegistry.ts b/Source/Core/dependencyInjection/ServiceRegistry.ts index eb2829a5..3ff7ea50 100644 --- a/Source/Core/dependencyInjection/ServiceRegistry.ts +++ b/Source/Core/dependencyInjection/ServiceRegistry.ts @@ -107,14 +107,21 @@ export class ServiceRegistry { return true; } /** Keep the pipeline alive until its result and scope cleanup have completed. */ - async runExecution(callback: () => Promise, completed?: (result: T, hasLivingAncestor: boolean) => Promise): Promise { + runExecution(callback: () => Promise, completed?: (result: T, hasLivingAncestor: boolean) => Promise): Promise { + return this.trackExecution(() => withServiceResolutionBoundary(callback), completed); + } + /** @internal Borrowed callbacks preserve the active singleton captive-dependency guard. */ + runBorrowedExecution(callback: () => Promise): Promise { + return this.trackExecution(callback); + } + private async trackExecution(callback: () => Promise, completed?: (result: T, hasLivingAncestor: boolean) => Promise): Promise { this.assertLive(); let finish!: () => void; const completion = new Promise(resolve => { finish = resolve; }); const frame: ServiceExecutionFrame = { completion, parent: this.#activeExecution.getStore(), state: ServiceExecutionState.Running }; this.#executions.add(completion); let result: T; - try { result = await this.#activeExecution.run(frame, () => withServiceResolutionBoundary(callback)); } + try { result = await this.#activeExecution.run(frame, callback); } finally { frame.state = ServiceExecutionState.Drained; this.#executions.delete(completion); finish(); } return completed ? completed(result, this.hasLivingExecution() || hasLivingServiceResolution(this)) : result; } diff --git a/Source/Core/dependencyInjection/ServiceScope.ts b/Source/Core/dependencyInjection/ServiceScope.ts index 87c644fe..39f2b43e 100644 --- a/Source/Core/dependencyInjection/ServiceScope.ts +++ b/Source/Core/dependencyInjection/ServiceScope.ts @@ -3,6 +3,7 @@ import { ServiceLifetime } from './ServiceLifetime.js'; import { AsyncLocalStorage } from 'node:async_hooks'; import type { ExecutionContext } from '../execution/ExecutionContext.js'; +import { snapshotPrincipal } from '../execution/snapshotPrincipal.js'; import type { ServiceToken } from './ServiceToken.js'; import { normalizeServiceToken, type ServiceIdentifier } from './ServiceIdentifier.js'; import type { ServiceRegistry } from './ServiceRegistry.js'; @@ -14,7 +15,8 @@ import { withoutRequestContext } from '../execution/RequestContextStore.js'; import type { ServiceDisposalFrame } from './ServiceDisposalFrame.js'; import { ServiceDisposalState } from './ServiceDisposalState.js'; const singletonCapability = Symbol('singleton scope'); -const internals = new WeakMap Promise; disposeCreated: () => Promise }>(); +const internals = new WeakMap Promise; disposeCreated: () => Promise; + authority: () => ExecutionContext | undefined }>(); const disposal = new AsyncLocalStorage(); /** Package-private shutdown entry points; never methods on the public scope. */ export function createSingletonServiceScope(registry: ServiceRegistry): ServiceScope { @@ -23,6 +25,13 @@ export function createSingletonServiceScope(registry: ServiceRegistry): ServiceS export function closeServiceScope(scope: ServiceScope): Promise { return internals.get(scope)!.close(); } export function disposeCreatedServices(scope: ServiceScope): Promise { return internals.get(scope)!.disposeCreated(); } export function serviceScopeRegistry(scope: ServiceScope): ServiceRegistry { return internals.get(scope)!.registry; } +/** Check unshadowable scope state before borrowing an admitted scope. */ +export function borrowedScopeAuthority(scope: ServiceScope, registry: ServiceRegistry): ExecutionContext { + const internal = internals.get(scope); + if (!internal || internal.registry !== registry || !internal.authority()) + throw new ServiceDependencyError('Invalid Arc service scope'); + return internal.authority()!; +} /** A live disposer cannot join registry shutdown, including through nested disposal. */ export function hasLivingServiceDisposal(registry: ServiceRegistry, scope?: ServiceScope): boolean { let frame = disposal.getStore(); @@ -62,16 +71,20 @@ export class ServiceScope { readonly #singleton: boolean; readonly #registry: ServiceRegistry; readonly #identity: ExecutionContext | undefined; + readonly #authority: ExecutionContext | undefined; constructor(registry: ServiceRegistry, identity: ExecutionContext | undefined); constructor(registry: ServiceRegistry, identity: ExecutionContext | undefined, ...capability: unknown[]) { if (capability.length && (capability.length !== 1 || capability[0] !== singletonCapability)) throw new ServiceDependencyError('Invalid service scope construction'); this.#registry = registry; this.#identity = identity; + this.#authority = identity && Object.freeze({ ...identity, + principal: identity.principal ? snapshotPrincipal(identity.principal) : undefined }); this.#singleton = capability.length === 1; if (this.#singleton) registry.assertLive(); else registry.admitScope(this); - internals.set(this, { registry, close: () => this.#closeInternal(), disposeCreated: () => this.#disposeCreated() }); + internals.set(this, { registry, close: () => this.#closeInternal(), disposeCreated: () => this.#disposeCreated(), + authority: () => !this.#singleton && this.#state === ServiceScopeState.Open ? this.#authority : undefined }); } get singleton(): boolean { return this.#singleton; } get registry(): ServiceRegistry { return this.#registry; } @@ -92,7 +105,7 @@ export class ServiceScope { const chain = active?.chain.filter(node => node.state === ServiceResolutionState.Pending) ?? []; const owner = active?.owner; const inherit = owner?.state === ServiceResolutionState.Pending && serviceScopeRegistry(owner.scope) === this.#registry; - const identity = this.#singleton ? undefined : this.#identity; + const identity = this.#singleton ? undefined : this.#authority; const captive = inherit ? active?.singleton ?? false : false; const task = this.resolveInChain(token, identity, chain, captive); if (chain.length || current.getStore() === this) return task; diff --git a/Source/Core/execution/runInScope.ts b/Source/Core/execution/runInScope.ts new file mode 100644 index 00000000..bd2f01dc --- /dev/null +++ b/Source/Core/execution/runInScope.ts @@ -0,0 +1,22 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import type { ArtifactMetadata } from '../reflection/ArtifactMetadata.js'; +import type { ClassType } from '../reflection/ClassType.js'; +import type { ServiceRegistry } from '../dependencyInjection/ServiceRegistry.js'; +import type { ServiceScope } from '../dependencyInjection/ServiceScope.js'; +import { borrowedScopeAuthority } from '../dependencyInjection/ServiceScope.js'; +import { ServiceDependencyError } from '../dependencyInjection/ServiceDependencyError.js'; +import { withExecutionBoundary } from './withExecutionBoundary.js'; + +/** Borrow a live scope for trusted host work; the caller remains responsible for its disposal. */ +export async function runInScope(services: ServiceRegistry, metadata: ReadonlyMap | undefined, + scope: ServiceScope, callback: () => T | Promise, options?: { correlationId?: string; signal?: AbortSignal }): Promise { + const authority = borrowedScopeAuthority(scope, services); + const context = Object.freeze({ ...authority, correlationId: options?.correlationId ?? authority.correlationId, + signal: options?.signal ? AbortSignal.any([authority.signal, options.signal]) : authority.signal }); + return withExecutionBoundary(services, metadata, scope, context, async () => { + const result = await callback(); + if (services.singletonFailed) throw new ServiceDependencyError('Service registry is disposed'); + return result; + }, true); +} diff --git a/Source/Core/execution/runOwned.ts b/Source/Core/execution/runOwned.ts index 0779b84a..ce363c73 100644 --- a/Source/Core/execution/runOwned.ts +++ b/Source/Core/execution/runOwned.ts @@ -1,20 +1,17 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. import type { ExecutionContext } from './ExecutionContext.js'; -import { requestContext } from './RequestContextStore.js'; -import { withGeneratedMetadata } from '../reflection/registerGeneratedMetadata.js'; +import { withExecutionBoundary } from './withExecutionBoundary.js'; import type { ArtifactMetadata } from '../reflection/ArtifactMetadata.js'; import type { ClassType } from '../reflection/ClassType.js'; import type { ServiceRegistry } from '../dependencyInjection/ServiceRegistry.js'; -import { withServices } from '../dependencyInjection/ServiceScope.js'; /** Run an operation and its service cleanup in the same execution boundary. */ export function runOwned(services: ServiceRegistry, metadata: ReadonlyMap | undefined, context: ExecutionContext, callback: () => T | Promise, isSuccess: (value: T) => boolean, fail: (error: unknown, previous?: T) => T): Promise { const scope = services.createScope(context); - return withGeneratedMetadata(metadata, () => services.runExecution(() => - requestContext.run(context, () => withServices(scope, async () => { + return withExecutionBoundary(services, metadata, scope, context, async () => { let result: T; try { result = await callback(); } catch (error) { result = fail(error); } @@ -23,7 +20,7 @@ export function runOwned(services: ServiceRegistry, metadata: ReadonlyMap { + }, false, async (initial, hasLivingAncestor) => { let result = initial; const checkAvailability = (): void => { if (isSuccess(result) && services.singletonFailed) @@ -36,5 +33,5 @@ export function runOwned(services: ServiceRegistry, metadata: ReadonlyMap(); + const freeze = (value: unknown, depth: number): void => { + if (!value || typeof value !== 'object' || seen.has(value)) return; + if (depth > 32) throw new Error('Principal claim graph exceeds maximum depth'); + seen.add(value); + for (const member of Object.values(value)) freeze(member, depth + 1); + Object.freeze(value); + }; + freeze(copy.claims, 0); + Object.freeze(copy.roles); + return Object.freeze(copy); +} diff --git a/Source/Core/execution/withExecutionBoundary.ts b/Source/Core/execution/withExecutionBoundary.ts new file mode 100644 index 00000000..62d713f0 --- /dev/null +++ b/Source/Core/execution/withExecutionBoundary.ts @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import type { ExecutionContext } from './ExecutionContext.js'; +import { requestContext } from './RequestContextStore.js'; +import { withGeneratedMetadata } from '../reflection/registerGeneratedMetadata.js'; +import type { ArtifactMetadata } from '../reflection/ArtifactMetadata.js'; +import type { ClassType } from '../reflection/ClassType.js'; +import type { ServiceScope } from '../dependencyInjection/ServiceScope.js'; +import { withServices } from '../dependencyInjection/ServiceScope.js'; +import type { ServiceRegistry } from '../dependencyInjection/ServiceRegistry.js'; + +/** Share server-local ambient state without changing how each caller tracks its execution. */ +export function withExecutionBoundary(services: ServiceRegistry, metadata: ReadonlyMap | undefined, + scope: ServiceScope, context: ExecutionContext, callback: () => Promise, borrowed = false, + completed?: (result: T, hasLivingAncestor: boolean) => Promise): Promise { + return withGeneratedMetadata(metadata, () => (borrowed + ? services.runBorrowedExecution(() => requestContext.run(context, () => withServices(scope, callback))) + : services.runExecution(() => requestContext.run(context, () => withServices(scope, callback)), completed))); +} diff --git a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_a_failing_singleton.ts b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_a_failing_singleton.ts new file mode 100644 index 00000000..42a6e3e6 --- /dev/null +++ b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_a_failing_singleton.ts @@ -0,0 +1,30 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { beforeEach, describe, it, should } from 'vitest'; +import { ArcServer } from '../../ArcServer.js'; +import { ServiceLifetime } from '../../dependencyInjection/ServiceLifetime.js'; +import { serviceToken } from '../../dependencyInjection/ServiceToken.js'; +import { beforeDeadline, captureFailure, serviceContext } from '../../dependencyInjection/for_ServiceRegistry/given/a_service_lifecycle.js'; + +should(); +describe('when singleton construction fails inside borrowed work', () => { + let failure: unknown; + let shutdownFailure: unknown; + beforeEach(async () => { + const broken = serviceToken('broken singleton'); + const server = new ArcServer({ services: [ + { token: broken, lifetime: ServiceLifetime.Singleton, factory: () => { throw new Error('construction failed'); } } + ] }); + const scope = server.services.createScope(serviceContext('tenant')); + // The callback catches the factory error; the borrowed boundary must still report registry poisoning. + failure = await beforeDeadline(captureFailure(server.runInScope(scope, async () => { + await captureFailure(scope.resolve(broken)); + return 'must not publish'; + })), 'borrowed singleton failure'); + shutdownFailure = await beforeDeadline(captureFailure(server.services.dispose()), 'singleton shutdown'); + }); + it('should reject the borrowed success without joining its own shutdown', () => { + (failure as Error).message.should.equal('Service registry is disposed'); + (shutdownFailure === undefined).should.equal(true); + }); +}); diff --git a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_a_live_singleton_factory.ts b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_a_live_singleton_factory.ts new file mode 100644 index 00000000..37ca09d3 --- /dev/null +++ b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_a_live_singleton_factory.ts @@ -0,0 +1,35 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { beforeEach, describe, it, should } from 'vitest'; +import { ArcServer } from '../../ArcServer.js'; +import { ServiceLifetime } from '../../dependencyInjection/ServiceLifetime.js'; +import { ServiceDependencyError } from '../../dependencyInjection/ServiceDependencyError.js'; +import { serviceToken } from '../../dependencyInjection/ServiceToken.js'; +import { captureFailure, serviceContext, beforeDeadline } from '../../dependencyInjection/for_ServiceRegistry/given/a_service_lifecycle.js'; + +should(); +describe('when a singleton factory borrows a scope and resolves a scoped dependency', () => { + let failure: unknown; + let captiveFailure: unknown; + beforeEach(async () => { + const scoped = serviceToken('borrowed scoped dependency'); + const singleton = serviceToken('singleton owner'); + const server = new ArcServer({ services: [ + { token: scoped, lifetime: ServiceLifetime.Scoped, factory: () => ({}) }, + { token: singleton, lifetime: ServiceLifetime.Singleton, factory: async () => { + const borrowed = server.services.createScope(serviceContext('borrowed')); + try { captiveFailure = await captureFailure(server.runInScope(borrowed, () => borrowed.resolve(scoped))); } + finally { await borrowed.dispose(); } + return {}; + } } + ] }); + const root = server.services.createScope(serviceContext('root')); + try { failure = await beforeDeadline(captureFailure(root.resolve(singleton)), 'singleton factory'); } + finally { await root.dispose(); await server.dispose(); } + }); + it('should retain the singleton captive dependency guard', () => { + (captiveFailure instanceof ServiceDependencyError).should.equal(true); + (captiveFailure as Error).message.should.match(/Captive service dependency/); + }); + it('should leave the registry usable when the factory handles the captive failure', () => (failure === undefined).should.equal(true)); +}); diff --git a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_invalid_scopes.ts b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_invalid_scopes.ts new file mode 100644 index 00000000..2ea2c6eb --- /dev/null +++ b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_invalid_scopes.ts @@ -0,0 +1,32 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { beforeEach, describe, it, should } from 'vitest'; +import { ArcServer } from '../../ArcServer.js'; +import { ServiceScope } from '../../dependencyInjection/ServiceScope.js'; +import { ServiceDependencyError } from '../../dependencyInjection/ServiceDependencyError.js'; +import { Severity } from '../../validation/Severity.js'; +import { captureFailure } from '../../dependencyInjection/for_ServiceRegistry/given/a_service_lifecycle.js'; + +should(); +describe('when borrowing scopes without the server registry authority', () => { + let failures: unknown[]; + beforeEach(async () => { + const server = new ArcServer({}); + const foreign = new ArcServer({}); + const identity = { correlationId: 'original', principal: undefined, tenantId: 'tenant', + signal: new AbortController().signal, allowedSeverity: Severity.Warning }; + const closed = server.services.createScope(identity); + await closed.dispose(); + const forged = Object.create(ServiceScope.prototype) as ServiceScope; + const shadowed = foreign.services.createScope(identity); + Object.defineProperties(shadowed, { registry: { value: server.services }, singleton: { value: false }, disposed: { value: false } }); + try { + failures = await Promise.all([shadowed, closed, server.services.singletonScope(), forged] + .map(scope => captureFailure(Promise.resolve().then(() => server.runInScope(scope, () => 'unexpected'))))); + } finally { await foreign.dispose(); await server.dispose(); } + }); + it('should reject foreign closed singleton and fabricated scopes', () => { + failures.should.have.length(4); + failures.forEach(failure => (failure instanceof ServiceDependencyError).should.be.true); + }); +}); diff --git a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_mutated_authority.ts b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_mutated_authority.ts new file mode 100644 index 00000000..f1ccf3f3 --- /dev/null +++ b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_mutated_authority.ts @@ -0,0 +1,68 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { beforeEach, describe, it, should } from 'vitest'; +import { ArcServer, currentContext } from '../../ArcServer.js'; +import { currentServices } from '../../dependencyInjection/ServiceScope.js'; +import { Severity } from '../../validation/Severity.js'; +import { ServiceLifetime } from '../../dependencyInjection/ServiceLifetime.js'; +import { serviceToken } from '../../dependencyInjection/ServiceToken.js'; +import type { ExecutionContext } from '../../execution/ExecutionContext.js'; + +should(); +describe('when borrowing a scope after its original authority is mutated', () => { + let observed: ReturnType; + let factoryContext: ReturnType; + let factoryAuthority: ExecutionContext; + let matchesScope: boolean; + let originalSignal: AbortSignal; + beforeEach(async () => { + const authority = serviceToken('factory authority'); + const server = new ArcServer({ services: [ + { token: authority, lifetime: ServiceLifetime.Scoped, factory: (_scope, execution) => execution } + ] }); + const principal = { id: 'original', roles: ['Reader'], isAuthenticated: true, scheme: 'Verified', + claims: { group: { name: 'before' } } }; + const context = { correlationId: 'initial', tenantId: 'first', principal, connectionId: 'connection', + remoteAddress: 'local', signal: new AbortController().signal, allowedSeverity: Severity.Warning }; + originalSignal = context.signal; + const scope = server.services.createScope(context); + context.tenantId = 'second'; + context.connectionId = 'other'; + context.remoteAddress = 'remote'; + context.allowedSeverity = Severity.Error; + context.signal = new AbortController().signal; + principal.id = 'changed'; + principal.scheme = 'Other'; + principal.roles.push('Admin'); + principal.claims.group.name = 'after'; + try { + observed = await server.runInScope(scope, async () => { + matchesScope = currentServices() === scope; + await Promise.resolve(); + factoryContext = currentContext(); + factoryAuthority = await currentServices().resolve(authority); + return currentContext(); + }, { correlationId: 'invocation' }); + } finally { await scope.dispose(); await server.dispose(); } + }); + it('should retain the scope tenant and transport authority', () => { + observed!.tenantId!.should.equal('first'); + observed!.connectionId!.should.equal('connection'); + observed!.remoteAddress!.should.equal('local'); + observed!.allowedSeverity.should.equal(Severity.Warning); + observed!.signal.should.equal(originalSignal); + }); + it('should retain the principal identity roles and claims', () => { + observed!.principal!.id.should.equal('original'); + observed!.principal!.scheme!.should.equal('Verified'); + observed!.principal!.roles.should.deep.equal(['Reader']); + factoryAuthority.principal!.roles.should.deep.equal(['Reader']); + factoryAuthority.correlationId.should.equal('initial'); + (observed!.principal!.claims as object).should.deep.equal({ group: { name: 'before' } }); + }); + it('should expose only the invocation correlation and the borrowed services', () => { + observed!.correlationId.should.equal('invocation'); + (factoryContext === observed).should.equal(true); + matchesScope.should.equal(true); + }); +}); diff --git a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_nested_invocations.ts b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_nested_invocations.ts new file mode 100644 index 00000000..d079b4a1 --- /dev/null +++ b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_nested_invocations.ts @@ -0,0 +1,54 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { beforeEach, describe, it, should } from 'vitest'; +import { ArcServer, currentContext } from '../../ArcServer.js'; +import { requestContext } from '../../execution/RequestContextStore.js'; +import { currentServices } from '../../dependencyInjection/ServiceScope.js'; +import { Severity } from '../../validation/Severity.js'; +import { captureFailure } from '../../dependencyInjection/for_ServiceRegistry/given/a_service_lifecycle.js'; + +should(); +describe('when borrowing a scope with nested and rejected invocations', () => { + let correlations: (string | undefined)[]; + let restoredServices: boolean; + let failure: unknown; + let linkedSignal: AbortSignal; + beforeEach(async () => { + correlations = []; + const server = new ArcServer({}); + const controller = new AbortController(); + const extra = new AbortController(); + const original = { correlationId: 'outside', tenantId: 'outside', principal: undefined, + signal: new AbortController().signal, allowedSeverity: Severity.Error }; + const scope = server.services.createScope({ ...original, correlationId: 'scope', tenantId: 'scope', signal: controller.signal }); + try { + await requestContext.run(original, async () => { + correlations.push(currentContext()?.correlationId); + await server.runInScope(scope, async () => { + correlations.push(currentContext()?.correlationId); + linkedSignal = currentContext()!.signal; + await server.runInScope(scope, async () => { + await Promise.resolve(); + correlations.push(currentContext()?.correlationId); + }, { correlationId: 'nested' }); + correlations.push(currentContext()?.correlationId); + failure = await captureFailure(server.runInScope(scope, async () => { + correlations.push(currentContext()?.correlationId); + throw new Error('callback rejected'); + }, { correlationId: 'rejected' })); + correlations.push(currentContext()?.correlationId); + restoredServices = currentServices() === scope; + }, { correlationId: 'outer', signal: extra.signal }); + correlations.push(currentContext()?.correlationId); + }); + correlations.push(currentContext()?.correlationId); + extra.abort(); + } finally { await scope.dispose(); await server.dispose(); } + }); + it('should restore context on return and rejection', () => { + correlations.should.deep.equal(['outside', 'outer', 'nested', 'outer', 'rejected', 'outer', 'outside', undefined]); + restoredServices.should.equal(true); + (failure as Error).message.should.equal('callback rejected'); + }); + it('should link the extra signal without replacing the scope signal', () => linkedSignal.aborted.should.be.true); +}); diff --git a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_registry_shutdown.ts b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_registry_shutdown.ts new file mode 100644 index 00000000..804fa03f --- /dev/null +++ b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_registry_shutdown.ts @@ -0,0 +1,44 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { beforeEach, describe, it, should } from 'vitest'; +import { ArcServer } from '../../ArcServer.js'; +import { ServiceLifetime } from '../../dependencyInjection/ServiceLifetime.js'; +import { serviceToken } from '../../dependencyInjection/ServiceToken.js'; +import { beforeDeadline, gate, serviceContext } from '../../dependencyInjection/for_ServiceRegistry/given/a_service_lifecycle.js'; + +should(); +describe('when shutdown begins during borrowed execution', () => { + let events: string[]; + let settledBeforeRelease: boolean; + beforeEach(async () => { + events = []; + const resource = serviceToken('borrowed resource'); + const server = new ArcServer({ services: [ + { token: resource, lifetime: ServiceLifetime.Scoped, + factory: () => ({ [Symbol.dispose]: () => { events.push('disposed'); } }) } + ] }); + const scope = server.services.createScope(serviceContext('tenant')); + const entered = gate(); + const release = gate(); + try { + await server.runInScope(scope, () => scope.resolve(resource)); + events.push('first returned'); + const running = server.runInScope(scope, async () => { + entered.release(); + await release.promise; + events.push('callback returned'); + }); + await beforeDeadline(entered.promise, 'borrowed callback admission'); + let settled = false; + const shutdown = server.services.dispose().finally(() => { settled = true; }); + await Promise.resolve(); + settledBeforeRelease = settled; + release.release(); + await beforeDeadline(Promise.all([running, shutdown]), 'borrowed execution drain'); + } finally { release.release(); await server.dispose(); } + }); + it('should leave the borrowed scope open on return and drain execution before disposing it', () => { + settledBeforeRelease.should.equal(false); + events.should.deep.equal(['first returned', 'callback returned', 'disposed']); + }); +}); diff --git a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_server_local_metadata.ts b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_server_local_metadata.ts new file mode 100644 index 00000000..f07d5a67 --- /dev/null +++ b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_server_local_metadata.ts @@ -0,0 +1,34 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { beforeEach, describe, it, should } from 'vitest'; +import { ArcServer } from '../../ArcServer.js'; +import { generatedMetadataFor } from '../../reflection/registerGeneratedMetadata.js'; +import { serviceContext } from '../../dependencyInjection/for_ServiceRegistry/given/a_service_lifecycle.js'; + +class Artifact {} + +should(); +describe('when borrowing scopes from servers with distinct generated metadata', () => { + let observed: (string | undefined)[]; + beforeEach(async () => { + const first = new ArcServer({}, new Map([[Artifact, { namespace: 'first' }]])); + const second = new ArcServer({}, new Map([[Artifact, { namespace: 'second' }]])); + const firstScope = first.services.createScope(serviceContext('first')); + const secondScope = second.services.createScope(serviceContext('second')); + try { + observed = []; + await first.runInScope(firstScope, async () => { + observed.push(generatedMetadataFor(Artifact)?.namespace); + await second.runInScope(secondScope, async () => { + await Promise.resolve(); + observed.push(generatedMetadataFor(Artifact)?.namespace); + }); + observed.push(generatedMetadataFor(Artifact)?.namespace); + }); + observed.push(generatedMetadataFor(Artifact)?.namespace); + } finally { await firstScope.dispose(); await secondScope.dispose(); await first.dispose(); await second.dispose(); } + }); + it('should restore the correct server metadata for each boundary', () => { + observed.should.deep.equal(['first', 'second', 'first', undefined]); + }); +}); From dc967bb5d1ce49d03d593bd014bc810f9bc9d82e Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 26 Sep 2026 19:24:07 +0200 Subject: [PATCH 02/11] Fix borrowed scope authority and reject unsafe overrides --- Documentation/dependency-injection.md | 4 +- .../when_resolving_in_a_mutated_scope.ts | 28 +++++++++ Source/Core/ArcServer.ts | 3 +- .../Core/dependencyInjection/ServiceScope.ts | 28 +++++---- .../with_public_getters.ts | 10 +++- Source/Core/execution/RunInScopeOptions.ts | 9 +++ Source/Core/execution/index.ts | 1 + Source/Core/execution/runInScope.ts | 15 ++++- Source/Core/execution/snapshotPrincipal.ts | 11 ++-- .../with_extra_principal_fields.ts | 28 +++++++++ .../with_invalid_overrides.ts | 38 ++++++++++++ .../with_mutated_authority.ts | 16 ++++- .../with_nested_invocations.ts | 10 ++-- .../with_unsnapshotable_principals.ts | 59 +++++++++++++++++++ .../when_paging_across_tenants/with_sqlite.ts | 4 +- .../with_tenant.ts | 4 +- 16 files changed, 235 insertions(+), 33 deletions(-) create mode 100644 Source/Chronicle/for_withChronicle/when_resolving_in_a_mutated_scope.ts create mode 100644 Source/Core/execution/RunInScopeOptions.ts create mode 100644 Source/Core/for_ArcServer/when_borrowing_a_scope/with_extra_principal_fields.ts create mode 100644 Source/Core/for_ArcServer/when_borrowing_a_scope/with_invalid_overrides.ts create mode 100644 Source/Core/for_ArcServer/when_borrowing_a_scope/with_unsnapshotable_principals.ts diff --git a/Documentation/dependency-injection.md b/Documentation/dependency-injection.md index 5d12e8e8..52141dab 100644 --- a/Documentation/dependency-injection.md +++ b/Documentation/dependency-injection.md @@ -103,7 +103,9 @@ Nested commands and queries keep their causal dependency ancestry for cycle dete ## Borrow a scope in a host integration -A trusted host integration can call `server.runInScope(scope, callback, { correlationId, signal })` to run construction and callbacks with `currentContext()` and `currentServices()` set. Create the scope with `server.services.createScope(context)` and dispose it yourself after all borrowed work settles; `runInScope` does not own it. The scope captures tenant, principal (including roles and claims), transport identity, severity, and cancellation authority at creation. An invocation may change only its correlation ID and add a cancellation signal linked to the scope's signal. Nested calls restore the prior context when they settle. +A trusted host integration can call `server.runInScope(scope, callback, { correlationId, signal })` to run construction and callbacks with `currentContext()` and `currentServices()` set. Create the scope with `server.services.createScope(context)` and dispose it yourself after all borrowed work settles; `runInScope` does not own it. The scope captures tenant, principal (including roles, claims, and extra fields), transport identity, severity, and cancellation authority at creation. A borrowed invocation may supply only a valid UUID correlation ID (normalized to lowercase) and an additional cancellation signal linked to the scope's signal. It rejects a signal already aborted before the invocation starts. Nested calls restore the prior context when they settle. + +Principal fields are detached and recursively frozen for borrowed work. `Map`, `Set`, and `Date` values are cloned, but their mutating methods still work after `Object.freeze` and must not be changed by borrowed code. If a legacy principal cannot be cloned (for example, claims containing functions or symbols, nesting beyond the supported depth, or non-array roles), ordinary requests continue with the original principal; that scope cannot be borrowed and `runInScope` rejects it. This is a trusted host API, **not an authorization mechanism** or a way to authenticate a principal. Use the normal command or query pipeline for authorization; do not expose scope creation or borrowed execution to untrusted callers. diff --git a/Source/Chronicle/for_withChronicle/when_resolving_in_a_mutated_scope.ts b/Source/Chronicle/for_withChronicle/when_resolving_in_a_mutated_scope.ts new file mode 100644 index 00000000..f3b682a2 --- /dev/null +++ b/Source/Chronicle/for_withChronicle/when_resolving_in_a_mutated_scope.ts @@ -0,0 +1,28 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { beforeEach, describe, it, should } from 'vitest'; +import { ArcApplication, Severity } from '@cratis/arc.core'; +import type { IChronicleClient, IEventStore } from '@cratis/chronicle'; +import { ChronicleReadModels } from '../ChronicleReadModels.js'; +import '../index.js'; + +should(); +describe('when resolving Chronicle read models in a mutated scope', () => { + let namespace: string; + beforeEach(async () => { + const client = { getEventStore: async (_store: string, requested: string) => { + namespace = requested; + return {} as IEventStore; + } } as unknown as IChronicleClient; + const builder = ArcApplication.createBuilder(); + builder.withChronicle({ client, eventStore: 'Items' }); + const application = await builder.build(); + const identity = { tenantId: 'first', correlationId: crypto.randomUUID(), principal: undefined, + signal: new AbortController().signal, allowedSeverity: Severity.Warning }; + const scope = application.server.services.createScope(identity); + identity.tenantId = 'second'; + try { await (await scope.resolve(ChronicleReadModels)).getStore(); } + finally { await scope.dispose(); await application.dispose(); } + }); + it('should resolve the creation-time tenant namespace', () => namespace.should.equal('first')); +}); diff --git a/Source/Core/ArcServer.ts b/Source/Core/ArcServer.ts index ca197e88..570e67a2 100644 --- a/Source/Core/ArcServer.ts +++ b/Source/Core/ArcServer.ts @@ -25,6 +25,7 @@ import { isObservableOperation } from './queries/observable/ObservableOperation. import { CommandOperationBoundary } from './commands/CommandOperationBoundary.js'; import { runOwned } from './execution/runOwned.js'; import { runInScope } from './execution/runInScope.js'; +import type { RunInScopeOptions } from './execution/RunInScopeOptions.js'; import type { ServiceScope } from './dependencyInjection/ServiceScope.js'; import { runProvider } from './execution/runProvider.js'; import { disposeObservableServer } from './queries/observable/disposeObservableServer.js'; @@ -94,7 +95,7 @@ export class ArcServer { * This is not an authorization mechanism. The caller must dispose the scope when work ends. */ runInScope(scope: ServiceScope, callback: () => T | Promise, - options?: { correlationId?: string; signal?: AbortSignal }): Promise { + options?: RunInScopeOptions): Promise { return runInScope(this.services, this.#generatedMetadata, scope, callback, options); } diff --git a/Source/Core/dependencyInjection/ServiceScope.ts b/Source/Core/dependencyInjection/ServiceScope.ts index 39f2b43e..d9fbedd4 100644 --- a/Source/Core/dependencyInjection/ServiceScope.ts +++ b/Source/Core/dependencyInjection/ServiceScope.ts @@ -16,7 +16,7 @@ import type { ServiceDisposalFrame } from './ServiceDisposalFrame.js'; import { ServiceDisposalState } from './ServiceDisposalState.js'; const singletonCapability = Symbol('singleton scope'); const internals = new WeakMap Promise; disposeCreated: () => Promise; - authority: () => ExecutionContext | undefined }>(); + authority: () => ExecutionContext | undefined; borrowable: () => boolean }>(); const disposal = new AsyncLocalStorage(); /** Package-private shutdown entry points; never methods on the public scope. */ export function createSingletonServiceScope(registry: ServiceRegistry): ServiceScope { @@ -28,9 +28,11 @@ export function serviceScopeRegistry(scope: ServiceScope): ServiceRegistry { ret /** Check unshadowable scope state before borrowing an admitted scope. */ export function borrowedScopeAuthority(scope: ServiceScope, registry: ServiceRegistry): ExecutionContext { const internal = internals.get(scope); - if (!internal || internal.registry !== registry || !internal.authority()) - throw new ServiceDependencyError('Invalid Arc service scope'); - return internal.authority()!; + if (!internal || internal.registry !== registry) throw new ServiceDependencyError('Invalid Arc service scope'); + if (!internal.borrowable()) throw new ServiceDependencyError('Arc service scope has an unsnapshotable principal'); + const authority = internal.authority(); + if (!authority) throw new ServiceDependencyError('Invalid Arc service scope'); + return authority; } /** A live disposer cannot join registry shutdown, including through nested disposal. */ export function hasLivingServiceDisposal(registry: ServiceRegistry, scope?: ServiceScope): boolean { @@ -70,25 +72,31 @@ export class ServiceScope { #closing: Promise | undefined; readonly #singleton: boolean; readonly #registry: ServiceRegistry; - readonly #identity: ExecutionContext | undefined; readonly #authority: ExecutionContext | undefined; + readonly #borrowable: boolean; constructor(registry: ServiceRegistry, identity: ExecutionContext | undefined); constructor(registry: ServiceRegistry, identity: ExecutionContext | undefined, ...capability: unknown[]) { if (capability.length && (capability.length !== 1 || capability[0] !== singletonCapability)) throw new ServiceDependencyError('Invalid service scope construction'); this.#registry = registry; - this.#identity = identity; - this.#authority = identity && Object.freeze({ ...identity, - principal: identity.principal ? snapshotPrincipal(identity.principal) : undefined }); + let principal = identity?.principal; + let borrowable = true; + if (principal) { + try { principal = snapshotPrincipal(principal); } + catch { borrowable = false; } // Legacy opaque principals still work for ordinary requests. + } + this.#authority = identity && (borrowable ? Object.freeze({ ...identity, principal }) : identity); + this.#borrowable = borrowable; this.#singleton = capability.length === 1; if (this.#singleton) registry.assertLive(); else registry.admitScope(this); internals.set(this, { registry, close: () => this.#closeInternal(), disposeCreated: () => this.#disposeCreated(), - authority: () => !this.#singleton && this.#state === ServiceScopeState.Open ? this.#authority : undefined }); + authority: () => !this.#singleton && this.#state === ServiceScopeState.Open ? this.#authority : undefined, + borrowable: () => this.#borrowable }); } get singleton(): boolean { return this.#singleton; } get registry(): ServiceRegistry { return this.#registry; } - get identity(): ExecutionContext | undefined { return this.#identity; } + get identity(): ExecutionContext | undefined { return this.#authority; } get disposed(): boolean { return this.#state !== ServiceScopeState.Open; } /** Closing scopes only accept dependencies from their own still-live factory attempts. */ canResolve(): boolean { diff --git a/Source/Core/dependencyInjection/for_ServiceScope/when_assigning_scope_identity/with_public_getters.ts b/Source/Core/dependencyInjection/for_ServiceScope/when_assigning_scope_identity/with_public_getters.ts index a2d82f66..d0b6ecdf 100644 --- a/Source/Core/dependencyInjection/for_ServiceScope/when_assigning_scope_identity/with_public_getters.ts +++ b/Source/Core/dependencyInjection/for_ServiceScope/when_assigning_scope_identity/with_public_getters.ts @@ -11,7 +11,7 @@ describe('when assigning scope identity with public getters', () => { let identitiesPreserved: boolean; beforeEach(async () => { const registry = new ServiceRegistry(); - const identity = serviceContext('original'); + const identity = { ...serviceContext('original'), principal: { id: 'user', roles: ['Reader'], isAuthenticated: true } }; const scope = registry.createScope(identity); const readOnlyAssignments = (target: ServiceScope): void => { // @ts-expect-error singleton has no public setter @@ -31,12 +31,16 @@ describe('when assigning scope identity with public getters', () => { own: Object.hasOwn(scope, name), changed: Reflect.set(scope, name, replacement), throws, preserved: Reflect.get(scope, name) === original }; }); - identitiesPreserved = !scope.singleton && scope.registry === registry && scope.identity === identity; + identitiesPreserved = !scope.singleton && scope.registry === registry && scope.identity !== identity && + scope.identity?.tenantId === identity.tenantId && scope.identity?.correlationId === identity.correlationId && + scope.identity?.signal === identity.signal && scope.identity?.principal?.id === identity.principal.id && + scope.identity?.principal?.roles[0] === identity.principal.roles[0] && + scope.identity?.principal !== identity.principal && Object.isFrozen(scope.identity); } finally { await registry.dispose(); } }); it('should have no writable own or prototype identity setters', () => { results.map(result => [result.setter, result.own, result.changed, result.throws, result.preserved]) .should.deep.equal(Array(3).fill(undefined).map(() => [undefined, false, false, true, true])); }); - it('should retain the original identity', () => identitiesPreserved.should.be.true); + it('should retain the creation-time identity in a frozen non-replaceable getter', () => identitiesPreserved.should.be.true); }); diff --git a/Source/Core/execution/RunInScopeOptions.ts b/Source/Core/execution/RunInScopeOptions.ts new file mode 100644 index 00000000..22cdbe9a --- /dev/null +++ b/Source/Core/execution/RunInScopeOptions.ts @@ -0,0 +1,9 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +/** Per-invocation overrides for trusted borrowed scope work. */ +export interface RunInScopeOptions { + /** A valid UUID; normalized to lowercase. */ + readonly correlationId?: string; + /** Additional cancellation, linked to the scope's signal. */ + readonly signal?: AbortSignal; +} diff --git a/Source/Core/execution/index.ts b/Source/Core/execution/index.ts index f8bb4f85..923b4b90 100644 --- a/Source/Core/execution/index.ts +++ b/Source/Core/execution/index.ts @@ -1,3 +1,4 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. export type { ExecutionContext } from './ExecutionContext.js'; +export type { RunInScopeOptions } from './RunInScopeOptions.js'; diff --git a/Source/Core/execution/runInScope.ts b/Source/Core/execution/runInScope.ts index bd2f01dc..0c33cd7b 100644 --- a/Source/Core/execution/runInScope.ts +++ b/Source/Core/execution/runInScope.ts @@ -7,13 +7,22 @@ import type { ServiceScope } from '../dependencyInjection/ServiceScope.js'; import { borrowedScopeAuthority } from '../dependencyInjection/ServiceScope.js'; import { ServiceDependencyError } from '../dependencyInjection/ServiceDependencyError.js'; import { withExecutionBoundary } from './withExecutionBoundary.js'; +import { correlation } from './correlation.js'; +import type { RunInScopeOptions } from './RunInScopeOptions.js'; /** Borrow a live scope for trusted host work; the caller remains responsible for its disposal. */ export async function runInScope(services: ServiceRegistry, metadata: ReadonlyMap | undefined, - scope: ServiceScope, callback: () => T | Promise, options?: { correlationId?: string; signal?: AbortSignal }): Promise { + scope: ServiceScope, callback: () => T | Promise, options?: RunInScopeOptions): Promise { const authority = borrowedScopeAuthority(scope, services); - const context = Object.freeze({ ...authority, correlationId: options?.correlationId ?? authority.correlationId, - signal: options?.signal ? AbortSignal.any([authority.signal, options.signal]) : authority.signal }); + const override = options?.correlationId; + if (override !== undefined && (typeof override !== 'string' || correlation(override) !== override.toLowerCase())) + throw new ServiceDependencyError('Invalid correlation ID override'); + if (authority.signal.aborted || options?.signal?.aborted) + throw new ServiceDependencyError('Borrowed scope signal is already aborted'); + // AbortSignal.any has no unsubscribe API; the dependent signal is collectible after this invocation settles. + const signal = options?.signal ? AbortSignal.any([authority.signal, options.signal]) : authority.signal; + if (signal.aborted) throw new ServiceDependencyError('Borrowed scope signal is already aborted'); + const context = Object.freeze({ ...authority, correlationId: override === undefined ? authority.correlationId : correlation(override), signal }); return withExecutionBoundary(services, metadata, scope, context, async () => { const result = await callback(); if (services.singletonFailed) throw new ServiceDependencyError('Service registry is disposed'); diff --git a/Source/Core/execution/snapshotPrincipal.ts b/Source/Core/execution/snapshotPrincipal.ts index e2c9d0be..af20a24a 100644 --- a/Source/Core/execution/snapshotPrincipal.ts +++ b/Source/Core/execution/snapshotPrincipal.ts @@ -2,10 +2,10 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import type { Principal } from '../identity/Principal.js'; -/** Preserve host principal fields while detaching roles and claims from their mutable source. */ +/** Detach and freeze the principal's roles, claims, and extra fields for borrowed work. */ export function snapshotPrincipal(principal: Principal): Principal { - const copy: Principal = { ...principal, roles: [...principal.roles], - ...(principal.claims === undefined ? {} : { claims: structuredClone(principal.claims) }) }; + if (!Array.isArray(principal.roles)) throw new TypeError('Principal roles must be an array'); + const copy = structuredClone(principal); const seen = new WeakSet(); const freeze = (value: unknown, depth: number): void => { if (!value || typeof value !== 'object' || seen.has(value)) return; @@ -14,7 +14,6 @@ export function snapshotPrincipal(principal: Principal): Principal { for (const member of Object.values(value)) freeze(member, depth + 1); Object.freeze(value); }; - freeze(copy.claims, 0); - Object.freeze(copy.roles); - return Object.freeze(copy); + freeze(copy, 0); + return copy; } diff --git a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_extra_principal_fields.ts b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_extra_principal_fields.ts new file mode 100644 index 00000000..0af6a34b --- /dev/null +++ b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_extra_principal_fields.ts @@ -0,0 +1,28 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { beforeEach, describe, it, should } from 'vitest'; +import { ArcServer, currentContext } from '../../ArcServer.js'; +import { serviceContext } from '../../dependencyInjection/for_ServiceRegistry/given/a_service_lifecycle.js'; + +should(); +describe('when borrowing a scope with extra principal fields', () => { + let department: string; + let frozen: boolean; + beforeEach(async () => { + const server = new ArcServer({}); + const principal = { id: 'user', isAuthenticated: true, roles: ['Reader'], + claims: { group: { name: 'original' } }, department: { team: { name: 'original' } } }; + const scope = server.services.createScope({ ...serviceContext('first'), principal }); + principal.department.team.name = 'changed'; + try { + await server.runInScope(scope, () => { + const captured = currentContext()!.principal as typeof principal; + department = captured.department.team.name; + frozen = Object.isFrozen(captured.department) && Object.isFrozen(captured.department.team) && + Object.isFrozen(captured.claims.group) && Object.isFrozen(captured.roles); + }); + } finally { await scope.dispose(); await server.dispose(); } + }); + it('should detach nested extra fields from the caller', () => department.should.equal('original')); + it('should freeze nested extra fields and claims', () => frozen.should.be.true); +}); diff --git a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_invalid_overrides.ts b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_invalid_overrides.ts new file mode 100644 index 00000000..ab5c60b3 --- /dev/null +++ b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_invalid_overrides.ts @@ -0,0 +1,38 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { beforeEach, describe, it, should } from 'vitest'; +import { ArcServer } from '../../ArcServer.js'; +import { ServiceDependencyError } from '../../dependencyInjection/ServiceDependencyError.js'; +import { captureFailure, serviceContext } from '../../dependencyInjection/for_ServiceRegistry/given/a_service_lifecycle.js'; + +should(); +describe('when borrowing a scope with an invalid override or an aborted signal', () => { + let failures: unknown[]; + let callbacks: number; + beforeEach(async () => { + const server = new ArcServer({}); + const scopeController = new AbortController(); + const extra = new AbortController(); + const scope = server.services.createScope({ ...serviceContext('tenant'), signal: scopeController.signal }); + callbacks = 0; + const callback = () => { callbacks++; }; + try { + failures = await Promise.all(['', 'not-a-uuid', '00000000-0000-0000-0000-000000000000', 42] + .map(correlationId => captureFailure(server.runInScope(scope, callback, { correlationId: correlationId as string })))); + extra.abort(); + failures.push(await captureFailure(server.runInScope(scope, callback, { signal: extra.signal }))); + scopeController.abort(); + failures.push(await captureFailure(server.runInScope(scope, callback))); + } finally { await scope.dispose(); await server.dispose(); } + }); + it('should reject invalid correlations with a clear error', () => { + failures.slice(0, 4).forEach(failure => { + (failure instanceof ServiceDependencyError).should.equal(true); + (failure as Error).message.should.equal('Invalid correlation ID override'); + }); + }); + it('should reject both already-aborted sources without calling the callback', () => { + failures.slice(4).forEach(failure => (failure as Error).message.should.equal('Borrowed scope signal is already aborted')); + callbacks.should.equal(0); + }); +}); diff --git a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_mutated_authority.ts b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_mutated_authority.ts index f1ccf3f3..d83eb7d7 100644 --- a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_mutated_authority.ts +++ b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_mutated_authority.ts @@ -13,12 +13,19 @@ describe('when borrowing a scope after its original authority is mutated', () => let observed: ReturnType; let factoryContext: ReturnType; let factoryAuthority: ExecutionContext; + let getterTenant: string | undefined; + let getterSignal: AbortSignal | undefined; let matchesScope: boolean; let originalSignal: AbortSignal; beforeEach(async () => { const authority = serviceToken('factory authority'); + const fromScope = serviceToken('scope tenant'); const server = new ArcServer({ services: [ - { token: authority, lifetime: ServiceLifetime.Scoped, factory: (_scope, execution) => execution } + { token: authority, lifetime: ServiceLifetime.Scoped, factory: (_scope, execution) => execution }, + { token: fromScope, lifetime: ServiceLifetime.Scoped, factory: scope => { + getterSignal = scope.identity!.signal; + return scope.identity!.tenantId!; + } } ] }); const principal = { id: 'original', roles: ['Reader'], isAuthenticated: true, scheme: 'Verified', claims: { group: { name: 'before' } } }; @@ -41,8 +48,9 @@ describe('when borrowing a scope after its original authority is mutated', () => await Promise.resolve(); factoryContext = currentContext(); factoryAuthority = await currentServices().resolve(authority); + getterTenant = await scope.resolve(fromScope); return currentContext(); - }, { correlationId: 'invocation' }); + }, { correlationId: 'E51A25C3-465D-4701-95CA-1F8B84C308D8' }); } finally { await scope.dispose(); await server.dispose(); } }); it('should retain the scope tenant and transport authority', () => { @@ -51,6 +59,8 @@ describe('when borrowing a scope after its original authority is mutated', () => observed!.remoteAddress!.should.equal('local'); observed!.allowedSeverity.should.equal(Severity.Warning); observed!.signal.should.equal(originalSignal); + getterTenant!.should.equal('first'); + getterSignal!.should.equal(originalSignal); }); it('should retain the principal identity roles and claims', () => { observed!.principal!.id.should.equal('original'); @@ -61,7 +71,7 @@ describe('when borrowing a scope after its original authority is mutated', () => (observed!.principal!.claims as object).should.deep.equal({ group: { name: 'before' } }); }); it('should expose only the invocation correlation and the borrowed services', () => { - observed!.correlationId.should.equal('invocation'); + observed!.correlationId.should.equal('e51a25c3-465d-4701-95ca-1f8b84c308d8'); (factoryContext === observed).should.equal(true); matchesScope.should.equal(true); }); diff --git a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_nested_invocations.ts b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_nested_invocations.ts index d079b4a1..6d28be69 100644 --- a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_nested_invocations.ts +++ b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_nested_invocations.ts @@ -30,15 +30,15 @@ describe('when borrowing a scope with nested and rejected invocations', () => { await server.runInScope(scope, async () => { await Promise.resolve(); correlations.push(currentContext()?.correlationId); - }, { correlationId: 'nested' }); + }, { correlationId: 'a0f0a604-be5b-4713-b7e5-850570f11275' }); correlations.push(currentContext()?.correlationId); failure = await captureFailure(server.runInScope(scope, async () => { correlations.push(currentContext()?.correlationId); throw new Error('callback rejected'); - }, { correlationId: 'rejected' })); + }, { correlationId: 'b0f0a604-be5b-4713-b7e5-850570f11275' })); correlations.push(currentContext()?.correlationId); restoredServices = currentServices() === scope; - }, { correlationId: 'outer', signal: extra.signal }); + }, { correlationId: 'c0f0a604-be5b-4713-b7e5-850570f11275', signal: extra.signal }); correlations.push(currentContext()?.correlationId); }); correlations.push(currentContext()?.correlationId); @@ -46,7 +46,9 @@ describe('when borrowing a scope with nested and rejected invocations', () => { } finally { await scope.dispose(); await server.dispose(); } }); it('should restore context on return and rejection', () => { - correlations.should.deep.equal(['outside', 'outer', 'nested', 'outer', 'rejected', 'outer', 'outside', undefined]); + correlations.should.deep.equal(['outside', 'c0f0a604-be5b-4713-b7e5-850570f11275', + 'a0f0a604-be5b-4713-b7e5-850570f11275', 'c0f0a604-be5b-4713-b7e5-850570f11275', + 'b0f0a604-be5b-4713-b7e5-850570f11275', 'c0f0a604-be5b-4713-b7e5-850570f11275', 'outside', undefined]); restoredServices.should.equal(true); (failure as Error).message.should.equal('callback rejected'); }); diff --git a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_unsnapshotable_principals.ts b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_unsnapshotable_principals.ts new file mode 100644 index 00000000..ed06e8a1 --- /dev/null +++ b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_unsnapshotable_principals.ts @@ -0,0 +1,59 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { beforeEach, describe, it, should } from 'vitest'; +import { z } from 'zod'; +import { ArcServer } from '../../ArcServer.js'; +import { ServiceDependencyError } from '../../dependencyInjection/ServiceDependencyError.js'; +import { ServiceLifetime } from '../../dependencyInjection/ServiceLifetime.js'; +import { currentServices } from '../../dependencyInjection/ServiceScope.js'; +import { serviceToken } from '../../dependencyInjection/ServiceToken.js'; +import { captureFailure, serviceContext } from '../../dependencyInjection/for_ServiceRegistry/given/a_service_lifecycle.js'; +import { defineQuery } from '../../queries/defineQuery.js'; +import type { Principal } from '../../identity/Principal.js'; + +should(); +describe('when a principal cannot be snapshotted for borrowing', () => { + let responses: unknown[]; + let failures: unknown[]; + let callbacks: number; + beforeEach(async () => { + const token = serviceToken('legacy principal'); + const server = new ArcServer({ services: [{ token, lifetime: ServiceLifetime.Scoped, + factory: (_scope, execution) => execution.principal! }], + queries: [defineQuery({ name: 'Legacy', schema: z.object({}), handlerDependencies: [token], + perform: async () => (await currentServices().resolve(token)).id })] }); + let deep: Record = { value: 'end' }; + for (let depth = 0; depth < 34; depth++) deep = { child: deep }; + const principals: Principal[] = [ + { id: 'function', isAuthenticated: true, roles: ['Reader'], claims: { call: () => true } }, + { id: 'symbol', isAuthenticated: true, roles: ['Reader'], claims: { value: Symbol('claim') } }, + { id: 'deep', isAuthenticated: true, roles: ['Reader'], claims: deep }, + { id: 'roles', isAuthenticated: true, roles: 'Reader' as unknown as string[] } + ]; + responses = []; + failures = []; + callbacks = 0; + try { + for (const principal of principals) { + const identity = { ...serviceContext('first'), principal }; + const scope = server.services.createScope(identity); + try { + if (scope.identity?.principal !== principal) throw new Error('Legacy principal changed'); + const response = await server.performQuery('Legacy', {}, identity); + responses.push(response.data); + failures.push(await captureFailure(server.runInScope(scope, () => { callbacks++; }))); + } finally { await scope.dispose(); } + } + } finally { await server.dispose(); } + }); + it('should keep ordinary query requests working with their original principal', () => { + responses.should.deep.equal(['function', 'symbol', 'deep', 'roles']); + }); + it('should reject borrowed execution before the callback runs', () => { + callbacks.should.equal(0); + failures.forEach(failure => { + (failure instanceof ServiceDependencyError).should.equal(true); + (failure as Error).message.should.contain('unsnapshotable principal'); + }); + }); +}); diff --git a/Source/Drizzle/for_DrizzleReadModels/when_paging_across_tenants/with_sqlite.ts b/Source/Drizzle/for_DrizzleReadModels/when_paging_across_tenants/with_sqlite.ts index 27cf1c64..d1808f15 100644 --- a/Source/Drizzle/for_DrizzleReadModels/when_paging_across_tenants/with_sqlite.ts +++ b/Source/Drizzle/for_DrizzleReadModels/when_paging_across_tenants/with_sqlite.ts @@ -22,8 +22,10 @@ describe('when paging across tenants', given(a_sqlite_database, context => { const app = await builder.build(); const identity = (tenantId: string) => ({ tenantId, principal: undefined, allowedSeverity: Severity.Warning, signal: new AbortController().signal, correlationId: crypto.randomUUID() }); - const a = app.server.services.createScope(identity('a')); + const original = identity('a'); + const a = app.server.services.createScope(original); const b = app.server.services.createScope(identity('b')); + original.tenantId = 'b'; try { const first = await a.resolve(drizzleReadModel(TaskRecord)); const second = await b.resolve(drizzleReadModel(TaskRecord)); diff --git a/Source/MongoDB/for_MongoCollection/when_resolving_a_collection/with_tenant.ts b/Source/MongoDB/for_MongoCollection/when_resolving_a_collection/with_tenant.ts index 3e49431f..909cb6a9 100644 --- a/Source/MongoDB/for_MongoCollection/when_resolving_a_collection/with_tenant.ts +++ b/Source/MongoDB/for_MongoCollection/when_resolving_a_collection/with_tenant.ts @@ -15,7 +15,9 @@ describe('when resolving a collection with a tenant', given(a_tenant_collection, const builder = ArcApplication.createBuilder(); builder.withMongoDB({ client: context.client, database: 'tasks', readModels: [TaskRecord] }); const application = await builder.build(); - const scope = application.server.services.createScope(executionContext('acme')); + const identity = { ...executionContext('acme') }; + const scope = application.server.services.createScope(identity); + identity.tenantId = 'other'; try { const collection = await scope.resolve(mongoCollection(TaskRecord)); collectionMatches = Object.is(collection.native, context.collection); From 3917a7bd0e41922985215b71a33406419b4f0d4d Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 26 Sep 2026 19:29:11 +0200 Subject: [PATCH 03/11] Keep creation-time context for unsnapshotable principals and document class-instance principals --- Documentation/dependency-injection.md | 2 +- Source/Core/dependencyInjection/ServiceScope.ts | 2 +- Source/Core/execution/snapshotPrincipal.ts | 2 +- .../with_unsnapshotable_principals.ts | 7 +++++++ 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/Documentation/dependency-injection.md b/Documentation/dependency-injection.md index 52141dab..7400d3ca 100644 --- a/Documentation/dependency-injection.md +++ b/Documentation/dependency-injection.md @@ -105,7 +105,7 @@ Nested commands and queries keep their causal dependency ancestry for cycle dete A trusted host integration can call `server.runInScope(scope, callback, { correlationId, signal })` to run construction and callbacks with `currentContext()` and `currentServices()` set. Create the scope with `server.services.createScope(context)` and dispose it yourself after all borrowed work settles; `runInScope` does not own it. The scope captures tenant, principal (including roles, claims, and extra fields), transport identity, severity, and cancellation authority at creation. A borrowed invocation may supply only a valid UUID correlation ID (normalized to lowercase) and an additional cancellation signal linked to the scope's signal. It rejects a signal already aborted before the invocation starts. Nested calls restore the prior context when they settle. -Principal fields are detached and recursively frozen for borrowed work. `Map`, `Set`, and `Date` values are cloned, but their mutating methods still work after `Object.freeze` and must not be changed by borrowed code. If a legacy principal cannot be cloned (for example, claims containing functions or symbols, nesting beyond the supported depth, or non-array roles), ordinary requests continue with the original principal; that scope cannot be borrowed and `runInScope` rejects it. +Principal fields are detached and recursively frozen for borrowed work. `Map`, `Set`, and `Date` values are cloned, but their mutating methods still work after `Object.freeze` and must not be changed by borrowed code. A principal that is a class instance is copied as plain data: its prototype methods, getters and private fields are not part of the snapshot. If a legacy principal cannot be cloned (for example, claims containing functions or symbols, nesting beyond the supported depth, or non-array roles), ordinary requests continue with the original principal, the scope still keeps its creation-time tenant and other context fields, and `runInScope` rejects the scope. This is a trusted host API, **not an authorization mechanism** or a way to authenticate a principal. Use the normal command or query pipeline for authorization; do not expose scope creation or borrowed execution to untrusted callers. diff --git a/Source/Core/dependencyInjection/ServiceScope.ts b/Source/Core/dependencyInjection/ServiceScope.ts index d9fbedd4..fae7d17b 100644 --- a/Source/Core/dependencyInjection/ServiceScope.ts +++ b/Source/Core/dependencyInjection/ServiceScope.ts @@ -85,7 +85,7 @@ export class ServiceScope { try { principal = snapshotPrincipal(principal); } catch { borrowable = false; } // Legacy opaque principals still work for ordinary requests. } - this.#authority = identity && (borrowable ? Object.freeze({ ...identity, principal }) : identity); + this.#authority = identity && Object.freeze({ ...identity, principal }); this.#borrowable = borrowable; this.#singleton = capability.length === 1; if (this.#singleton) registry.assertLive(); diff --git a/Source/Core/execution/snapshotPrincipal.ts b/Source/Core/execution/snapshotPrincipal.ts index af20a24a..5f22683a 100644 --- a/Source/Core/execution/snapshotPrincipal.ts +++ b/Source/Core/execution/snapshotPrincipal.ts @@ -14,6 +14,6 @@ export function snapshotPrincipal(principal: Principal): Principal { for (const member of Object.values(value)) freeze(member, depth + 1); Object.freeze(value); }; - freeze(copy, 0); + freeze(copy, -1); // Claims start at depth 0, as before principal-level freezing. return copy; } diff --git a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_unsnapshotable_principals.ts b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_unsnapshotable_principals.ts index ed06e8a1..96a3c2a7 100644 --- a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_unsnapshotable_principals.ts +++ b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_unsnapshotable_principals.ts @@ -16,6 +16,7 @@ describe('when a principal cannot be snapshotted for borrowing', () => { let responses: unknown[]; let failures: unknown[]; let callbacks: number; + let tenants: (string | undefined)[]; beforeEach(async () => { const token = serviceToken('legacy principal'); const server = new ArcServer({ services: [{ token, lifetime: ServiceLifetime.Scoped, @@ -33,12 +34,15 @@ describe('when a principal cannot be snapshotted for borrowing', () => { responses = []; failures = []; callbacks = 0; + tenants = []; try { for (const principal of principals) { const identity = { ...serviceContext('first'), principal }; const scope = server.services.createScope(identity); try { if (scope.identity?.principal !== principal) throw new Error('Legacy principal changed'); + (identity as { tenantId?: string }).tenantId = 'second'; + tenants.push(scope.identity?.tenantId); const response = await server.performQuery('Legacy', {}, identity); responses.push(response.data); failures.push(await captureFailure(server.runInScope(scope, () => { callbacks++; }))); @@ -49,6 +53,9 @@ describe('when a principal cannot be snapshotted for borrowing', () => { it('should keep ordinary query requests working with their original principal', () => { responses.should.deep.equal(['function', 'symbol', 'deep', 'roles']); }); + it('should keep the creation-time tenant when the original context changes', () => { + tenants.should.deep.equal(['first', 'first', 'first', 'first']); + }); it('should reject borrowed execution before the callback runs', () => { callbacks.should.equal(0); failures.forEach(failure => { From b94a220756e9a6dd34451981cabddc3f5651a47f Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 26 Sep 2026 19:29:35 +0200 Subject: [PATCH 04/11] Prepare the v0.40.0 source preview --- ContractTests/Client/package.json | 2 +- Documentation/index.md | 2 +- Documentation/reference/packages.md | 2 +- README.md | 2 +- Source/Chronicle/package.json | 6 +++--- Source/CodeAnalysis/package.json | 2 +- Source/Core/package.json | 2 +- Source/Cratis/package.json | 2 +- Source/Drizzle/package.json | 4 ++-- Source/Express/package.json | 2 +- Source/Fastify/package.json | 2 +- Source/Hono/package.json | 2 +- Source/MongoDB/package.json | 4 ++-- Source/Testing/package.json | 2 +- Source/Tools/ProxyGenerator/package.json | 2 +- yarn.lock | 8 ++++---- 16 files changed, 23 insertions(+), 23 deletions(-) diff --git a/ContractTests/Client/package.json b/ContractTests/Client/package.json index 697a5329..76bd398e 100644 --- a/ContractTests/Client/package.json +++ b/ContractTests/Client/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.core-client-contract", - "version": "0.39.0", + "version": "0.40.0", "private": true, "type": "module", "dependencies": { diff --git a/Documentation/index.md b/Documentation/index.md index e24d033c..dfe1397b 100644 --- a/Documentation/index.md +++ b/Documentation/index.md @@ -8,7 +8,7 @@ Arc for TypeScript is a Node.js server implementation of [Arc](/arc/), the Crati Without it, a Node.js backend for an Arc frontend means writing every route, request parser, validation response, and status code by hand, then keeping all of it in step with the frontend. With it, commands and queries run through one pipeline that owns those concerns, the wire behavior follows Arc on .NET, and the proxy generator writes the typed frontend client from your source. :::caution[Source preview, no full parity] -No package is published to npm; the manifests are at version 0.39.0 for a source preview. Arc for TypeScript does **not** have full parity with Arc on .NET, and package names and APIs can still change. The [capability reference](reference/capabilities.md) is the single place for status and evidence. +No package is published to npm; the manifests are at version 0.40.0 for a source preview. Arc for TypeScript does **not** have full parity with Arc on .NET, and package names and APIs can still change. The [capability reference](reference/capabilities.md) is the single place for status and evidence. ::: ## What it looks like diff --git a/Documentation/reference/packages.md b/Documentation/reference/packages.md index b7e4582e..27bd7c82 100644 --- a/Documentation/reference/packages.md +++ b/Documentation/reference/packages.md @@ -3,7 +3,7 @@ title: Packages description: The packages this repository builds, what each exports, their peer dependencies and Node.js requirements, and how they relate to the published @cratis/arc client. --- -Every package in this repository is at version 0.39.0, the version of the source preview. **None is published to npm.** They ship ES modules only. Clone this repository, run `yarn install` and `yarn build`, and then use the packages in one of two ways: +Every package in this repository is at version 0.40.0, the version of the source preview. **None is published to npm.** They ship ES modules only. Clone this repository, run `yarn install` and `yarn build`, and then use the packages in one of two ways: - **Inside the clone.** Put your application in a folder under `Samples/`, which the root `workspaces` list includes, and reference the packages with the `workspace:^` protocol, as [`Samples/Tasks/package.json`](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/package.json) does. `workspace:^` resolves only inside this repository's Yarn workspace. - **In your own project.** Pack each package you need with `yarn workspace pack --out ` and install the tarballs with npm. Use `yarn pack`: it rewrites `workspace:^` dependencies to version ranges, and `npm pack` does not. `yarn check:consumers` installs packed packages this way to check NodeNext and Bundler consumers. diff --git a/README.md b/README.md index 5ea9b60e..52bfe82b 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ export class TaskItem { | `@cratis/arc.chronicle` | [`Source/Chronicle`](Source/Chronicle) | **Experimental.** `builder.withChronicle` appends returned events and resolves registered read models by command key; nested command returns join one event-log batch. In-memory command assertions are available under `@cratis/arc.chronicle/testing`. SDK 6.14.0 imports natively and infers read models from projections/reducers; an opt-in kernel suite covers aggregate replay and reactor commands. Full .NET transaction parity remains unverified. | | `@cratis/cratis` | [`Source/Cratis`](Source/Cratis) | **Experimental source preview.** `CratisApplication.createBuilder()` and `builder.addCratis()` compose Arc and a Chronicle client without installing authentication; not yet published to npm. | -Every package manifest is at version 0.39.0. That is the version of this source preview, not an npm release, and the Chronicle package is experimental. The packages ship ES modules only, and schemas use Zod 4. The default core entry, host adapters, MongoDB, and Drizzle packages need Node.js 22 or later. The Fetch entry has a neutral bundle with `node:async_hooks` as its only Node import; its command, query, and SSE paths run in a Next.js App Router route handler on the Node.js runtime, with Bun and Deno smoke checks; Cloudflare Workers and the Next.js Edge runtime are not supported. See [Fetch API runtimes](Documentation/hosts/fetch-runtimes.md). The root workspace needs Node.js 22.19 or later, because it installs the Chronicle SDK; Node.js 24 LTS is recommended. +Every package manifest is at version 0.40.0. That is the version of this source preview, not an npm release, and the Chronicle package is experimental. The packages ship ES modules only, and schemas use Zod 4. The default core entry, host adapters, MongoDB, and Drizzle packages need Node.js 22 or later. The Fetch entry has a neutral bundle with `node:async_hooks` as its only Node import; its command, query, and SSE paths run in a Next.js App Router route handler on the Node.js runtime, with Bun and Deno smoke checks; Cloudflare Workers and the Next.js Edge runtime are not supported. See [Fetch API runtimes](Documentation/hosts/fetch-runtimes.md). The root workspace needs Node.js 22.19 or later, because it installs the Chronicle SDK; Node.js 24 LTS is recommended. ## Try it diff --git a/Source/Chronicle/package.json b/Source/Chronicle/package.json index 8ea669eb..cf9fa013 100644 --- a/Source/Chronicle/package.json +++ b/Source/Chronicle/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.chronicle", - "version": "0.39.0", + "version": "0.40.0", "publishConfig": { "access": "public" }, @@ -34,8 +34,8 @@ "README.md" ], "peerDependencies": { - "@cratis/arc.core": "^0.39.0", - "@cratis/arc.testing": "^0.39.0", + "@cratis/arc.core": "^0.40.0", + "@cratis/arc.testing": "^0.40.0", "@cratis/chronicle": "^6.7.0", "@cratis/fundamentals": "^7.19.6", "rxjs": "^7.8.2", diff --git a/Source/CodeAnalysis/package.json b/Source/CodeAnalysis/package.json index 181db466..3ef54c3b 100644 --- a/Source/CodeAnalysis/package.json +++ b/Source/CodeAnalysis/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/eslint-plugin-arc-core", - "version": "0.39.0", + "version": "0.40.0", "type": "module", "license": "MIT", "description": "ESLint diagnostics for Arc for TypeScript server artifacts", diff --git a/Source/Core/package.json b/Source/Core/package.json index 08cc02fb..d0662d28 100644 --- a/Source/Core/package.json +++ b/Source/Core/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.core", - "version": "0.39.0", + "version": "0.40.0", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/Cratis/package.json b/Source/Cratis/package.json index 57bdfe7b..54f4d7b7 100644 --- a/Source/Cratis/package.json +++ b/Source/Cratis/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/cratis", - "version": "0.39.0", + "version": "0.40.0", "type": "module", "license": "MIT", "description": "Arc and experimental Chronicle composition for Node.js", diff --git a/Source/Drizzle/package.json b/Source/Drizzle/package.json index 934863fb..96bed5d7 100644 --- a/Source/Drizzle/package.json +++ b/Source/Drizzle/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.drizzle", - "version": "0.39.0", + "version": "0.40.0", "type": "module", "license": "MIT", "publishConfig": { @@ -29,7 +29,7 @@ "README.md" ], "peerDependencies": { - "@cratis/arc.core": "^0.39.0", + "@cratis/arc.core": "^0.40.0", "@cratis/fundamentals": "^7.19.6", "drizzle-orm": "^0.45.0", "rxjs": "^7.8.2" diff --git a/Source/Express/package.json b/Source/Express/package.json index 5e11c9d4..e30234cd 100644 --- a/Source/Express/package.json +++ b/Source/Express/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.express", - "version": "0.39.0", + "version": "0.40.0", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/Fastify/package.json b/Source/Fastify/package.json index 69beefb6..d30b2ac9 100644 --- a/Source/Fastify/package.json +++ b/Source/Fastify/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.fastify", - "version": "0.39.0", + "version": "0.40.0", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/Hono/package.json b/Source/Hono/package.json index c7d63bae..bcde31a8 100644 --- a/Source/Hono/package.json +++ b/Source/Hono/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.hono", - "version": "0.39.0", + "version": "0.40.0", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/MongoDB/package.json b/Source/MongoDB/package.json index c5719d27..b797d504 100644 --- a/Source/MongoDB/package.json +++ b/Source/MongoDB/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.mongodb", - "version": "0.39.0", + "version": "0.40.0", "type": "module", "license": "MIT", "publishConfig": { @@ -29,7 +29,7 @@ "README.md" ], "peerDependencies": { - "@cratis/arc.core": "^0.39.0", + "@cratis/arc.core": "^0.40.0", "@cratis/fundamentals": "^7.19.6", "@opentelemetry/api": "^1.9.0", "mongodb": "^6.21.0", diff --git a/Source/Testing/package.json b/Source/Testing/package.json index ded88e95..6b830de1 100644 --- a/Source/Testing/package.json +++ b/Source/Testing/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.testing", - "version": "0.39.0", + "version": "0.40.0", "type": "module", "license": "MIT", "publishConfig": { diff --git a/Source/Tools/ProxyGenerator/package.json b/Source/Tools/ProxyGenerator/package.json index 41c50fc5..a371040f 100644 --- a/Source/Tools/ProxyGenerator/package.json +++ b/Source/Tools/ProxyGenerator/package.json @@ -1,6 +1,6 @@ { "name": "@cratis/arc.proxygenerator", - "version": "0.39.0", + "version": "0.40.0", "description": "TypeScript source analyzer and deterministic Arc client proxy generator", "repository": { "type": "git", diff --git a/yarn.lock b/yarn.lock index 51fb1db5..4a66bc7c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -45,8 +45,8 @@ __metadata: rxjs: "npm:^7.8.2" zod: "npm:^4.1.0" peerDependencies: - "@cratis/arc.core": ^0.39.0 - "@cratis/arc.testing": ^0.39.0 + "@cratis/arc.core": ^0.40.0 + "@cratis/arc.testing": ^0.40.0 "@cratis/chronicle": ^6.7.0 "@cratis/fundamentals": ^7.19.6 rxjs: ^7.8.2 @@ -151,7 +151,7 @@ __metadata: rxjs: "npm:^7.8.2" sql.js: "npm:^1.14.2" peerDependencies: - "@cratis/arc.core": ^0.39.0 + "@cratis/arc.core": ^0.40.0 "@cratis/fundamentals": ^7.19.6 drizzle-orm: ^0.45.0 rxjs: ^7.8.2 @@ -217,7 +217,7 @@ __metadata: mongodb: "npm:^6.21.0" rxjs: "npm:^7.8.2" peerDependencies: - "@cratis/arc.core": ^0.39.0 + "@cratis/arc.core": ^0.40.0 "@cratis/fundamentals": ^7.19.6 "@opentelemetry/api": ^1.9.0 mongodb: ^6.21.0 From a99657a48903b05e980e57b1d9abc812a2d5ade1 Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 26 Sep 2026 19:48:56 +0200 Subject: [PATCH 05/11] Reject class-instance principals before borrowing scopes Structured cloning silently strips prototype-backed identity fields, leaving borrowed scopes with incomplete authority. Preserve original principals for ordinary requests while detaching and validating plain-principal snapshots. Document the creation-time factory context and borrowed-scope boundary. --- Documentation/dependency-injection.md | 4 +- Documentation/reference/capabilities.md | 2 +- Source/Core/execution/snapshotPrincipal.ts | 36 ++++++++++++-- .../with_plain_accessor_principal.ts | 49 +++++++++++++++++++ .../with_unsnapshotable_principals.ts | 16 +++++- 5 files changed, 99 insertions(+), 8 deletions(-) create mode 100644 Source/Core/for_ArcServer/when_borrowing_a_scope/with_plain_accessor_principal.ts diff --git a/Documentation/dependency-injection.md b/Documentation/dependency-injection.md index 7400d3ca..b58fc9ba 100644 --- a/Documentation/dependency-injection.md +++ b/Documentation/dependency-injection.md @@ -99,13 +99,15 @@ A factory declares its own `dependencies` for preflight and resolves them with i Registry shutdown drains admitted work and pending singleton construction, then aborts the registry signal and disposes singletons. It rejects new executions and scopes. A singleton factory that fails poisons the registry and triggers the same shutdown. Cancellation is cooperative: factories and handlers must observe their signal. Do not await `registry.dispose()` from inside its own handler, factory, or disposer; it rejects to prevent a deadlock. Stop singleton background loops when the registry signal aborts, then join them in the singleton's disposer. +For every scope, `scope.identity` and the `execution` argument passed to scoped factories use the creation-time context: the context fields are frozen at scope creation, and plain principals (including roles, claims, and extra fields) are detached and frozen. Class-instance principals cannot be snapshotted without losing getters, methods, or private fields; ordinary requests keep the original principal object in that frozen context, but cannot borrow the scope with `runInScope`. + Nested commands and queries keep their causal dependency ancestry for cycle detection but get their own execution identity and scoped lifetime guard; the same scoped token in two independent nested scopes is not a cycle. ## Borrow a scope in a host integration A trusted host integration can call `server.runInScope(scope, callback, { correlationId, signal })` to run construction and callbacks with `currentContext()` and `currentServices()` set. Create the scope with `server.services.createScope(context)` and dispose it yourself after all borrowed work settles; `runInScope` does not own it. The scope captures tenant, principal (including roles, claims, and extra fields), transport identity, severity, and cancellation authority at creation. A borrowed invocation may supply only a valid UUID correlation ID (normalized to lowercase) and an additional cancellation signal linked to the scope's signal. It rejects a signal already aborted before the invocation starts. Nested calls restore the prior context when they settle. -Principal fields are detached and recursively frozen for borrowed work. `Map`, `Set`, and `Date` values are cloned, but their mutating methods still work after `Object.freeze` and must not be changed by borrowed code. A principal that is a class instance is copied as plain data: its prototype methods, getters and private fields are not part of the snapshot. If a legacy principal cannot be cloned (for example, claims containing functions or symbols, nesting beyond the supported depth, or non-array roles), ordinary requests continue with the original principal, the scope still keeps its creation-time tenant and other context fields, and `runInScope` rejects the scope. +Principal fields are detached and recursively frozen for borrowed work. `Map`, `Set`, and `Date` values are cloned, but their mutating methods still work after `Object.freeze` and must not be changed by borrowed code. Class-instance principals or nested claim values are not snapshotted: cloning them would silently lose prototype getters, methods, and private fields. If a principal cannot be snapshotted (for example, claims containing functions, symbols, or class instances, nesting beyond the supported depth, or non-array roles), ordinary requests continue with the original principal, the scope still keeps its creation-time tenant and other context fields, and `runInScope` rejects the scope. This is a trusted host API, **not an authorization mechanism** or a way to authenticate a principal. Use the normal command or query pipeline for authorization; do not expose scope creation or borrowed execution to untrusted callers. diff --git a/Documentation/reference/capabilities.md b/Documentation/reference/capabilities.md index 847be9f4..f159230b 100644 --- a/Documentation/reference/capabilities.md +++ b/Documentation/reference/capabilities.md @@ -51,7 +51,7 @@ Evidence paths are relative to the repository root. Spec folders follow `for_ typeof role === 'string')) throw new TypeError('Invalid principal identity'); + // Explicit fields also capture non-enumerable getters on plain principals. + const source = { ...principal, id, isAuthenticated, roles, + ...(name === undefined ? {} : { name }), ...(scheme === undefined ? {} : { scheme }), + ...(claims === undefined ? {} : { claims }) }; const seen = new WeakSet(); - const freeze = (value: unknown, depth: number): void => { + const verify = (value: unknown, depth: number): void => { if (!value || typeof value !== 'object' || seen.has(value)) return; if (depth > 32) throw new Error('Principal claim graph exceeds maximum depth'); + const memberPrototype = Object.getPrototypeOf(value); + if (memberPrototype !== Object.prototype && memberPrototype !== null && memberPrototype !== Array.prototype && + memberPrototype !== Map.prototype && memberPrototype !== Set.prototype && memberPrototype !== Date.prototype) + throw new TypeError('Principal contains an unsnapshotable value'); seen.add(value); + if (value instanceof Map) for (const [key, member] of value) { + verify(key, depth + 1); + verify(member, depth + 1); + } + if (value instanceof Set) for (const member of value) verify(member, depth + 1); + for (const member of Object.values(value)) verify(member, depth + 1); + }; + verify(source, -1); // Claims start at depth 0, as before principal-level freezing. + const copy: Principal = structuredClone(source); + if (copy.id !== id || copy.isAuthenticated !== isAuthenticated || !Array.isArray(copy.roles) || + copy.roles.length !== roles.length || copy.roles.some((role, index) => role !== roles[index])) + throw new TypeError('Principal snapshot lost identity'); + const frozen = new WeakSet(); + const freeze = (value: unknown, depth: number): void => { + if (!value || typeof value !== 'object' || frozen.has(value)) return; + if (depth > 32) throw new Error('Principal claim graph exceeds maximum depth'); + frozen.add(value); for (const member of Object.values(value)) freeze(member, depth + 1); Object.freeze(value); }; - freeze(copy, -1); // Claims start at depth 0, as before principal-level freezing. + freeze(copy, -1); return copy; } diff --git a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_plain_accessor_principal.ts b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_plain_accessor_principal.ts new file mode 100644 index 00000000..139fe5d6 --- /dev/null +++ b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_plain_accessor_principal.ts @@ -0,0 +1,49 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { beforeEach, describe, it, should } from 'vitest'; +import { ArcServer, currentContext } from '../../ArcServer.js'; +import { ServiceLifetime } from '../../dependencyInjection/ServiceLifetime.js'; +import { currentServices } from '../../dependencyInjection/ServiceScope.js'; +import { serviceToken } from '../../dependencyInjection/ServiceToken.js'; +import { serviceContext } from '../../dependencyInjection/for_ServiceRegistry/given/a_service_lifecycle.js'; +import type { Principal } from '../../identity/Principal.js'; + +should(); +describe('when borrowing a scope with a plain principal with accessor fields', () => { + let snapshot: Principal; + let factoryPrincipal: Principal; + let original: Principal; + beforeEach(async () => { + const token = serviceToken('factory principal'); + const server = new ArcServer({ services: [{ token, lifetime: ServiceLifetime.Scoped, + factory: (_scope, execution) => execution.principal! }] }); + const principal: Principal = { id: 'initial', isAuthenticated: false, roles: ['Reader'], claims: { department: 'original' } }; + Object.defineProperties(principal, { + id: { get: () => 'user' }, + isAuthenticated: { get: () => true } + }); + original = principal; + const scope = server.services.createScope({ ...serviceContext('first'), principal }); + (principal.roles as string[]).push('Admin'); + (principal.claims as { department: string }).department = 'changed'; + try { + snapshot = await server.runInScope(scope, async () => { + factoryPrincipal = await currentServices().resolve(token); + return currentContext()!.principal!; + }); + } finally { await scope.dispose(); await server.dispose(); } + }); + it('should preserve the required principal fields', () => { + snapshot.id.should.equal('user'); + snapshot.isAuthenticated.should.equal(true); + snapshot.roles.should.deep.equal(['Reader']); + }); + it('should detach and freeze the plain principal', () => { + (snapshot === original).should.equal(false); + Object.isFrozen(snapshot).should.equal(true); + (snapshot.claims as { department: string }).department.should.equal('original'); + }); + it('should give the scoped factory the same principal snapshot', () => { + (factoryPrincipal === snapshot).should.equal(true); + }); +}); diff --git a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_unsnapshotable_principals.ts b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_unsnapshotable_principals.ts index 96a3c2a7..c2de8002 100644 --- a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_unsnapshotable_principals.ts +++ b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_unsnapshotable_principals.ts @@ -25,7 +25,19 @@ describe('when a principal cannot be snapshotted for borrowing', () => { perform: async () => (await currentServices().resolve(token)).id })] }); let deep: Record = { value: 'end' }; for (let depth = 0; depth < 34; depth++) deep = { child: deep }; + class LegacyPrincipal implements Principal { + readonly #identifier = 'class'; + readonly isAuthenticated = true; + readonly roles = ['Reader']; + get id(): string { return this.#identifier; } + } + class LegacyClaim { + readonly #value = 'nested'; + get value(): string { return this.#value; } + } const principals: Principal[] = [ + new LegacyPrincipal(), + { id: 'nested', isAuthenticated: true, roles: ['Reader'], claims: { legacy: new LegacyClaim() } }, { id: 'function', isAuthenticated: true, roles: ['Reader'], claims: { call: () => true } }, { id: 'symbol', isAuthenticated: true, roles: ['Reader'], claims: { value: Symbol('claim') } }, { id: 'deep', isAuthenticated: true, roles: ['Reader'], claims: deep }, @@ -51,10 +63,10 @@ describe('when a principal cannot be snapshotted for borrowing', () => { } finally { await server.dispose(); } }); it('should keep ordinary query requests working with their original principal', () => { - responses.should.deep.equal(['function', 'symbol', 'deep', 'roles']); + responses.should.deep.equal(['class', 'nested', 'function', 'symbol', 'deep', 'roles']); }); it('should keep the creation-time tenant when the original context changes', () => { - tenants.should.deep.equal(['first', 'first', 'first', 'first']); + tenants.should.deep.equal(['first', 'first', 'first', 'first', 'first', 'first']); }); it('should reject borrowed execution before the callback runs', () => { callbacks.should.equal(0); From 83e302224960fc2bf14bc4adf6f137d5276897df Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 26 Sep 2026 20:08:02 +0200 Subject: [PATCH 06/11] Preserve ordinary principals and restrict borrowed scope snapshots Capture declared execution fields through getters while keeping the original principal for ordinary scopes. Borrow only deeply frozen plain-data principals, rejecting lossy shapes before running callbacks. --- Documentation/dependency-injection.md | 6 +- Documentation/reference/capabilities.md | 2 +- .../Core/dependencyInjection/ServiceScope.ts | 32 +++++++--- .../with_class_context_getters.ts | 53 ++++++++++++++++ .../with_public_getters.ts | 2 +- Source/Core/execution/runInScope.ts | 6 +- Source/Core/execution/snapshotPrincipal.ts | 61 ++++++++----------- .../with_extra_principal_fields.ts | 10 +++ .../with_mutated_authority.ts | 5 +- .../with_plain_accessor_principal.ts | 37 +++++------ .../with_unsnapshotable_principals.ts | 20 +++++- .../when_resolving_with_a_mutated_context.ts | 29 +++++++++ 12 files changed, 187 insertions(+), 76 deletions(-) create mode 100644 Source/Core/dependencyInjection/for_ServiceScope/when_assigning_scope_identity/with_class_context_getters.ts create mode 100644 Source/MongoDB/for_withMongoDB/when_resolving_with_a_mutated_context.ts diff --git a/Documentation/dependency-injection.md b/Documentation/dependency-injection.md index b58fc9ba..cfbce141 100644 --- a/Documentation/dependency-injection.md +++ b/Documentation/dependency-injection.md @@ -99,15 +99,15 @@ A factory declares its own `dependencies` for preflight and resolves them with i Registry shutdown drains admitted work and pending singleton construction, then aborts the registry signal and disposes singletons. It rejects new executions and scopes. A singleton factory that fails poisons the registry and triggers the same shutdown. Cancellation is cooperative: factories and handlers must observe their signal. Do not await `registry.dispose()` from inside its own handler, factory, or disposer; it rejects to prevent a deadlock. Stop singleton background loops when the registry signal aborts, then join them in the singleton's disposer. -For every scope, `scope.identity` and the `execution` argument passed to scoped factories use the creation-time context: the context fields are frozen at scope creation, and plain principals (including roles, claims, and extra fields) are detached and frozen. Class-instance principals cannot be snapshotted without losing getters, methods, or private fields; ordinary requests keep the original principal object in that frozen context, but cannot borrow the scope with `runInScope`. +For every scope, `scope.identity` and the `execution` argument passed to scoped factories use a frozen, plain creation-time snapshot of the declared execution context fields, including fields supplied by class getters (such as `tenantId` and `signal`). The `principal` in ordinary scopes is always the **original object reference**, not a clone or frozen copy. Its mutable roles and claims remain mutable for ordinary requests; changing the caller's context fields after scope creation does not change the scoped tenant or signal. Nested commands and queries keep their causal dependency ancestry for cycle detection but get their own execution identity and scoped lifetime guard; the same scoped token in two independent nested scopes is not a cycle. ## Borrow a scope in a host integration -A trusted host integration can call `server.runInScope(scope, callback, { correlationId, signal })` to run construction and callbacks with `currentContext()` and `currentServices()` set. Create the scope with `server.services.createScope(context)` and dispose it yourself after all borrowed work settles; `runInScope` does not own it. The scope captures tenant, principal (including roles, claims, and extra fields), transport identity, severity, and cancellation authority at creation. A borrowed invocation may supply only a valid UUID correlation ID (normalized to lowercase) and an additional cancellation signal linked to the scope's signal. It rejects a signal already aborted before the invocation starts. Nested calls restore the prior context when they settle. +A trusted host integration can call `server.runInScope(scope, callback, { correlationId, signal })` to run construction and callbacks with `currentContext()` and `currentServices()` set. Create the scope with `server.services.createScope(context)` and dispose it yourself after all borrowed work settles; `runInScope` does not own it. The scope captures the declared context fields at creation, including tenant, transport identity, severity, and cancellation authority. For borrowing only, it also creates a separate, deeply frozen principal copy at scope creation. `currentContext()` and factories first resolved during borrowed work receive that copy with the invocation's correlation ID and linked signal; `scope.identity` still exposes the ordinary scope snapshot with the original principal. A borrowed invocation may supply only a valid UUID correlation ID (normalized to lowercase) and an additional cancellation signal linked to the scope's signal. It rejects a signal already aborted before the invocation starts. Nested calls restore the prior context when they settle. -Principal fields are detached and recursively frozen for borrowed work. `Map`, `Set`, and `Date` values are cloned, but their mutating methods still work after `Object.freeze` and must not be changed by borrowed code. Class-instance principals or nested claim values are not snapshotted: cloning them would silently lose prototype getters, methods, and private fields. If a principal cannot be snapshotted (for example, claims containing functions, symbols, or class instances, nesting beyond the supported depth, or non-array roles), ordinary requests continue with the original principal, the scope still keeps its creation-time tenant and other context fields, and `runInScope` rejects the scope. +Borrowing requires a strictly plain-data principal throughout its roles, claims, and extra fields: ordinary objects (including null-prototype objects), arrays, and primitive values, with only own enumerable string properties and no accessors. Arrays' built-in `length` is allowed. Symbol keys or values, non-enumerable properties, getters, functions, `Map`, `Set`, `Date`, class instances, invalid identity fields, or nesting beyond the supported depth make the scope non-borrowable. Ordinary requests still use the original principal reference and their creation-time context fields; `runInScope` rejects a non-borrowable scope before calling back. This is a trusted host API, **not an authorization mechanism** or a way to authenticate a principal. Use the normal command or query pipeline for authorization; do not expose scope creation or borrowed execution to untrusted callers. diff --git a/Documentation/reference/capabilities.md b/Documentation/reference/capabilities.md index f159230b..175cdf0b 100644 --- a/Documentation/reference/capabilities.md +++ b/Documentation/reference/capabilities.md @@ -51,7 +51,7 @@ Evidence paths are relative to the repository root. Spec folders follow `for_(); const current = new AsyncLocalStorage(); +const borrowedIdentity = new AsyncLocalStorage<{ scope: ServiceScope; context: ExecutionContext }>(); +/** Scoped factories created during borrowed work receive its detached authority. */ +export function withBorrowedServiceAuthority(scope: ServiceScope, context: ExecutionContext, callback: () => T): T { + return borrowedIdentity.run({ scope, context }, callback); +} /** Includes detached factories after their originating request frame has drained. */ export function hasLivingServiceResolution(registry: ServiceRegistry): boolean { return resolution.getStore()?.chain.some(node => serviceScopeRegistry(node.scope) === registry && node.state === ServiceResolutionState.Pending) ?? false; @@ -73,25 +78,37 @@ export class ServiceScope { readonly #singleton: boolean; readonly #registry: ServiceRegistry; readonly #authority: ExecutionContext | undefined; + readonly #borrowedAuthority: ExecutionContext | undefined; readonly #borrowable: boolean; constructor(registry: ServiceRegistry, identity: ExecutionContext | undefined); constructor(registry: ServiceRegistry, identity: ExecutionContext | undefined, ...capability: unknown[]) { if (capability.length && (capability.length !== 1 || capability[0] !== singletonCapability)) throw new ServiceDependencyError('Invalid service scope construction'); this.#registry = registry; - let principal = identity?.principal; + // Record every declared field through property access, including inherited getters. + this.#authority = identity && Object.freeze({ + correlationId: identity.correlationId, + principal: identity.principal, + tenantId: identity.tenantId, + connectionId: identity.connectionId, + remoteAddress: identity.remoteAddress, + signal: identity.signal, + allowedSeverity: identity.allowedSeverity + } satisfies ExecutionContext & Record); + let borrowedPrincipal = this.#authority?.principal; let borrowable = true; - if (principal) { - try { principal = snapshotPrincipal(principal); } - catch { borrowable = false; } // Legacy opaque principals still work for ordinary requests. + if (borrowedPrincipal) { + try { borrowedPrincipal = snapshotPrincipal(borrowedPrincipal); } + catch { borrowable = false; } // Opaque principals remain usable in ordinary scopes. } - this.#authority = identity && Object.freeze({ ...identity, principal }); + this.#borrowedAuthority = borrowable && this.#authority + ? Object.freeze({ ...this.#authority, principal: borrowedPrincipal }) : undefined; this.#borrowable = borrowable; this.#singleton = capability.length === 1; if (this.#singleton) registry.assertLive(); else registry.admitScope(this); internals.set(this, { registry, close: () => this.#closeInternal(), disposeCreated: () => this.#disposeCreated(), - authority: () => !this.#singleton && this.#state === ServiceScopeState.Open ? this.#authority : undefined, + authority: () => !this.#singleton && this.#state === ServiceScopeState.Open ? this.#borrowedAuthority : undefined, borrowable: () => this.#borrowable }); } get singleton(): boolean { return this.#singleton; } @@ -113,7 +130,8 @@ export class ServiceScope { const chain = active?.chain.filter(node => node.state === ServiceResolutionState.Pending) ?? []; const owner = active?.owner; const inherit = owner?.state === ServiceResolutionState.Pending && serviceScopeRegistry(owner.scope) === this.#registry; - const identity = this.#singleton ? undefined : this.#authority; + const borrowed = borrowedIdentity.getStore(); + const identity = this.#singleton ? undefined : borrowed?.scope === this ? borrowed.context : this.#authority; const captive = inherit ? active?.singleton ?? false : false; const task = this.resolveInChain(token, identity, chain, captive); if (chain.length || current.getStore() === this) return task; diff --git a/Source/Core/dependencyInjection/for_ServiceScope/when_assigning_scope_identity/with_class_context_getters.ts b/Source/Core/dependencyInjection/for_ServiceScope/when_assigning_scope_identity/with_class_context_getters.ts new file mode 100644 index 00000000..f57c25f1 --- /dev/null +++ b/Source/Core/dependencyInjection/for_ServiceScope/when_assigning_scope_identity/with_class_context_getters.ts @@ -0,0 +1,53 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { beforeEach, describe, it, should } from 'vitest'; +import { ServiceRegistry } from '../../ServiceRegistry.js'; +import { ServiceLifetime } from '../../ServiceLifetime.js'; +import { serviceToken } from '../../ServiceToken.js'; +import { Severity } from '../../../validation/Severity.js'; +import type { ExecutionContext } from '../../../execution/ExecutionContext.js'; +import type { Principal } from '../../../identity/Principal.js'; + +should(); +describe('when creating a scope from a class-based execution context', () => { + let observed: ExecutionContext; + let scopeContext: ExecutionContext; + let principal: Principal; + let originalSignal: AbortSignal; + beforeEach(async () => { + const token = serviceToken('execution'); + const registry = new ServiceRegistry([ + { token, lifetime: ServiceLifetime.Scoped, factory: (_scope, execution) => execution } + ]); + principal = { id: 'user', roles: ['Reader'], isAuthenticated: true }; + class Context implements ExecutionContext { + tenant = 'first'; + cancellation = new AbortController().signal; + readonly correlationId = 'original'; + readonly principal = principal; + readonly allowedSeverity = Severity.Warning; + get tenantId(): string { return this.tenant; } + get signal(): AbortSignal { return this.cancellation; } + } + const context = new Context(); + originalSignal = context.signal; + const scope = registry.createScope(context); + context.tenant = 'second'; + context.cancellation = new AbortController().signal; + try { + scopeContext = scope.identity!; + observed = await scope.resolve(token); + } finally { await scope.dispose(); await registry.dispose(); } + }); + it('should capture inherited tenant and signal getters at creation', () => { + scopeContext.tenantId!.should.equal('first'); + scopeContext.signal.should.equal(originalSignal); + observed.tenantId!.should.equal('first'); + observed.signal.should.equal(originalSignal); + Object.isFrozen(scopeContext).should.equal(true); + }); + it('should retain the original principal reference for ordinary factories', () => { + (scopeContext.principal === principal).should.equal(true); + (observed.principal === principal).should.equal(true); + }); +}); diff --git a/Source/Core/dependencyInjection/for_ServiceScope/when_assigning_scope_identity/with_public_getters.ts b/Source/Core/dependencyInjection/for_ServiceScope/when_assigning_scope_identity/with_public_getters.ts index d0b6ecdf..f91e9eff 100644 --- a/Source/Core/dependencyInjection/for_ServiceScope/when_assigning_scope_identity/with_public_getters.ts +++ b/Source/Core/dependencyInjection/for_ServiceScope/when_assigning_scope_identity/with_public_getters.ts @@ -35,7 +35,7 @@ describe('when assigning scope identity with public getters', () => { scope.identity?.tenantId === identity.tenantId && scope.identity?.correlationId === identity.correlationId && scope.identity?.signal === identity.signal && scope.identity?.principal?.id === identity.principal.id && scope.identity?.principal?.roles[0] === identity.principal.roles[0] && - scope.identity?.principal !== identity.principal && Object.isFrozen(scope.identity); + scope.identity?.principal === identity.principal && Object.isFrozen(scope.identity); } finally { await registry.dispose(); } }); it('should have no writable own or prototype identity setters', () => { diff --git a/Source/Core/execution/runInScope.ts b/Source/Core/execution/runInScope.ts index 0c33cd7b..5fa18ad3 100644 --- a/Source/Core/execution/runInScope.ts +++ b/Source/Core/execution/runInScope.ts @@ -4,7 +4,7 @@ import type { ArtifactMetadata } from '../reflection/ArtifactMetadata.js'; import type { ClassType } from '../reflection/ClassType.js'; import type { ServiceRegistry } from '../dependencyInjection/ServiceRegistry.js'; import type { ServiceScope } from '../dependencyInjection/ServiceScope.js'; -import { borrowedScopeAuthority } from '../dependencyInjection/ServiceScope.js'; +import { borrowedScopeAuthority, withBorrowedServiceAuthority } from '../dependencyInjection/ServiceScope.js'; import { ServiceDependencyError } from '../dependencyInjection/ServiceDependencyError.js'; import { withExecutionBoundary } from './withExecutionBoundary.js'; import { correlation } from './correlation.js'; @@ -23,9 +23,9 @@ export async function runInScope(services: ServiceRegistry, metadata: Readonl const signal = options?.signal ? AbortSignal.any([authority.signal, options.signal]) : authority.signal; if (signal.aborted) throw new ServiceDependencyError('Borrowed scope signal is already aborted'); const context = Object.freeze({ ...authority, correlationId: override === undefined ? authority.correlationId : correlation(override), signal }); - return withExecutionBoundary(services, metadata, scope, context, async () => { + return withBorrowedServiceAuthority(scope, context, () => withExecutionBoundary(services, metadata, scope, context, async () => { const result = await callback(); if (services.singletonFailed) throw new ServiceDependencyError('Service registry is disposed'); return result; - }, true); + }, true)); } diff --git a/Source/Core/execution/snapshotPrincipal.ts b/Source/Core/execution/snapshotPrincipal.ts index 5fcef409..15245655 100644 --- a/Source/Core/execution/snapshotPrincipal.ts +++ b/Source/Core/execution/snapshotPrincipal.ts @@ -2,46 +2,35 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import type { Principal } from '../identity/Principal.js'; -/** Detach and freeze the principal's roles, claims, and extra fields for borrowed work. */ +/** Detach and freeze strictly plain principal data for borrowed work. */ export function snapshotPrincipal(principal: Principal): Principal { - const prototype = Object.getPrototypeOf(principal); - if (prototype !== Object.prototype && prototype !== null) throw new TypeError('Principal cannot be snapshotted'); - const { id, isAuthenticated, roles, name, scheme, claims } = principal; - if (typeof id !== 'string' || typeof isAuthenticated !== 'boolean' || !Array.isArray(roles) || - !roles.every(role => typeof role === 'string')) throw new TypeError('Invalid principal identity'); - // Explicit fields also capture non-enumerable getters on plain principals. - const source = { ...principal, id, isAuthenticated, roles, - ...(name === undefined ? {} : { name }), ...(scheme === undefined ? {} : { scheme }), - ...(claims === undefined ? {} : { claims }) }; - const seen = new WeakSet(); - const verify = (value: unknown, depth: number): void => { - if (!value || typeof value !== 'object' || seen.has(value)) return; + const copies = new WeakMap(); + const clone = (value: unknown, depth: number): unknown => { + if (typeof value === 'function' || typeof value === 'symbol') throw new TypeError('Principal contains an unsnapshotable value'); + if (value === null || typeof value !== 'object') return value; if (depth > 32) throw new Error('Principal claim graph exceeds maximum depth'); - const memberPrototype = Object.getPrototypeOf(value); - if (memberPrototype !== Object.prototype && memberPrototype !== null && memberPrototype !== Array.prototype && - memberPrototype !== Map.prototype && memberPrototype !== Set.prototype && memberPrototype !== Date.prototype) + const prototype = Object.getPrototypeOf(value); + const array = Array.isArray(value); + if (array ? prototype !== Array.prototype : prototype !== Object.prototype && prototype !== null) throw new TypeError('Principal contains an unsnapshotable value'); - seen.add(value); - if (value instanceof Map) for (const [key, member] of value) { - verify(key, depth + 1); - verify(member, depth + 1); + const existing = copies.get(value); + if (existing) return existing; + const copy: object = array ? new Array((value as unknown[]).length) : Object.create(prototype) as object; + copies.set(value, copy); + for (const key of Reflect.ownKeys(value)) { + // Array length is the sole intrinsic non-enumerable field allowed. + if (array && key === 'length') continue; + const descriptor = Object.getOwnPropertyDescriptor(value, key)!; + if (typeof key !== 'string' || !descriptor.enumerable || !('value' in descriptor)) + throw new TypeError('Principal contains an unsnapshotable property'); + Object.defineProperty(copy, key, { value: clone(descriptor.value, depth + 1), + enumerable: true, configurable: true, writable: true }); } - if (value instanceof Set) for (const member of value) verify(member, depth + 1); - for (const member of Object.values(value)) verify(member, depth + 1); + Object.freeze(copy); + return copy; }; - verify(source, -1); // Claims start at depth 0, as before principal-level freezing. - const copy: Principal = structuredClone(source); - if (copy.id !== id || copy.isAuthenticated !== isAuthenticated || !Array.isArray(copy.roles) || - copy.roles.length !== roles.length || copy.roles.some((role, index) => role !== roles[index])) - throw new TypeError('Principal snapshot lost identity'); - const frozen = new WeakSet(); - const freeze = (value: unknown, depth: number): void => { - if (!value || typeof value !== 'object' || frozen.has(value)) return; - if (depth > 32) throw new Error('Principal claim graph exceeds maximum depth'); - frozen.add(value); - for (const member of Object.values(value)) freeze(member, depth + 1); - Object.freeze(value); - }; - freeze(copy, -1); + const copy = clone(principal, 0) as Principal; + if (typeof copy.id !== 'string' || typeof copy.isAuthenticated !== 'boolean' || !Array.isArray(copy.roles) || + !copy.roles.every(role => typeof role === 'string')) throw new TypeError('Invalid principal identity'); return copy; } diff --git a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_extra_principal_fields.ts b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_extra_principal_fields.ts index 0af6a34b..02477a0b 100644 --- a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_extra_principal_fields.ts +++ b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_extra_principal_fields.ts @@ -8,6 +8,8 @@ should(); describe('when borrowing a scope with extra principal fields', () => { let department: string; let frozen: boolean; + let mutationFailure: unknown; + let nextClaim: string; beforeEach(async () => { const server = new ArcServer({}); const principal = { id: 'user', isAuthenticated: true, roles: ['Reader'], @@ -20,9 +22,17 @@ describe('when borrowing a scope with extra principal fields', () => { department = captured.department.team.name; frozen = Object.isFrozen(captured.department) && Object.isFrozen(captured.department.team) && Object.isFrozen(captured.claims.group) && Object.isFrozen(captured.roles); + try { captured.claims.group.name = 'forged'; } catch (error) { mutationFailure = error; } + }); + await server.runInScope(scope, () => { + nextClaim = (currentContext()!.principal as typeof principal).claims.group.name; }); } finally { await scope.dispose(); await server.dispose(); } }); it('should detach nested extra fields from the caller', () => department.should.equal('original')); it('should freeze nested extra fields and claims', () => frozen.should.be.true); + it('should prevent one borrowed callback from changing the next callback claims', () => { + (mutationFailure instanceof TypeError).should.equal(true); + nextClaim.should.equal('original'); + }); }); diff --git a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_mutated_authority.ts b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_mutated_authority.ts index d83eb7d7..d9c5f0c1 100644 --- a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_mutated_authority.ts +++ b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_mutated_authority.ts @@ -17,6 +17,7 @@ describe('when borrowing a scope after its original authority is mutated', () => let getterSignal: AbortSignal | undefined; let matchesScope: boolean; let originalSignal: AbortSignal; + let ordinaryPrincipal: ExecutionContext['principal']; beforeEach(async () => { const authority = serviceToken('factory authority'); const fromScope = serviceToken('scope tenant'); @@ -42,6 +43,7 @@ describe('when borrowing a scope after its original authority is mutated', () => principal.scheme = 'Other'; principal.roles.push('Admin'); principal.claims.group.name = 'after'; + ordinaryPrincipal = scope.identity!.principal; try { observed = await server.runInScope(scope, async () => { matchesScope = currentServices() === scope; @@ -67,7 +69,8 @@ describe('when borrowing a scope after its original authority is mutated', () => observed!.principal!.scheme!.should.equal('Verified'); observed!.principal!.roles.should.deep.equal(['Reader']); factoryAuthority.principal!.roles.should.deep.equal(['Reader']); - factoryAuthority.correlationId.should.equal('initial'); + (factoryAuthority.principal === ordinaryPrincipal).should.equal(false); + factoryAuthority.correlationId.should.equal('e51a25c3-465d-4701-95ca-1f8b84c308d8'); (observed!.principal!.claims as object).should.deep.equal({ group: { name: 'before' } }); }); it('should expose only the invocation correlation and the borrowed services', () => { diff --git a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_plain_accessor_principal.ts b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_plain_accessor_principal.ts index 139fe5d6..42e43ef2 100644 --- a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_plain_accessor_principal.ts +++ b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_plain_accessor_principal.ts @@ -1,18 +1,18 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { beforeEach, describe, it, should } from 'vitest'; -import { ArcServer, currentContext } from '../../ArcServer.js'; +import { ArcServer } from '../../ArcServer.js'; import { ServiceLifetime } from '../../dependencyInjection/ServiceLifetime.js'; -import { currentServices } from '../../dependencyInjection/ServiceScope.js'; import { serviceToken } from '../../dependencyInjection/ServiceToken.js'; -import { serviceContext } from '../../dependencyInjection/for_ServiceRegistry/given/a_service_lifecycle.js'; +import { captureFailure, serviceContext } from '../../dependencyInjection/for_ServiceRegistry/given/a_service_lifecycle.js'; import type { Principal } from '../../identity/Principal.js'; should(); -describe('when borrowing a scope with a plain principal with accessor fields', () => { - let snapshot: Principal; +describe('when a plain principal has accessor fields', () => { + let scopePrincipal: Principal; let factoryPrincipal: Principal; let original: Principal; + let failure: unknown; beforeEach(async () => { const token = serviceToken('factory principal'); const server = new ArcServer({ services: [{ token, lifetime: ServiceLifetime.Scoped, @@ -24,26 +24,19 @@ describe('when borrowing a scope with a plain principal with accessor fields', ( }); original = principal; const scope = server.services.createScope({ ...serviceContext('first'), principal }); - (principal.roles as string[]).push('Admin'); - (principal.claims as { department: string }).department = 'changed'; try { - snapshot = await server.runInScope(scope, async () => { - factoryPrincipal = await currentServices().resolve(token); - return currentContext()!.principal!; - }); + scopePrincipal = scope.identity!.principal!; + factoryPrincipal = await scope.resolve(token); + failure = await captureFailure(server.runInScope(scope, () => 'unexpected')); } finally { await scope.dispose(); await server.dispose(); } }); - it('should preserve the required principal fields', () => { - snapshot.id.should.equal('user'); - snapshot.isAuthenticated.should.equal(true); - snapshot.roles.should.deep.equal(['Reader']); + it('should preserve the original principal for ordinary factories and scope identity', () => { + (scopePrincipal === original).should.equal(true); + (factoryPrincipal === original).should.equal(true); + factoryPrincipal.id.should.equal('user'); + factoryPrincipal.isAuthenticated.should.equal(true); }); - it('should detach and freeze the plain principal', () => { - (snapshot === original).should.equal(false); - Object.isFrozen(snapshot).should.equal(true); - (snapshot.claims as { department: string }).department.should.equal('original'); - }); - it('should give the scoped factory the same principal snapshot', () => { - (factoryPrincipal === snapshot).should.equal(true); + it('should reject borrowing a principal with accessors', () => { + (failure as Error).message.should.contain('unsnapshotable principal'); }); }); diff --git a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_unsnapshotable_principals.ts b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_unsnapshotable_principals.ts index c2de8002..b83a248c 100644 --- a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_unsnapshotable_principals.ts +++ b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_unsnapshotable_principals.ts @@ -35,11 +35,27 @@ describe('when a principal cannot be snapshotted for borrowing', () => { readonly #value = 'nested'; get value(): string { return this.#value; } } + const symbolKey = Symbol('claim key'); + const nonEnumerable = { department: 'research' }; + Object.defineProperty(nonEnumerable, 'private', { value: 'hidden', enumerable: false }); + const getterClaim = { department: 'research' }; + Object.defineProperty(getterClaim, 'department', { get: () => 'research', enumerable: true }); + const topSymbolPrincipal = { id: 'top symbol', isAuthenticated: true, roles: ['Reader'], [symbolKey]: 'secret' }; + const hiddenPrincipal = { id: 'hidden', isAuthenticated: true, roles: ['Reader'] }; + Object.defineProperty(hiddenPrincipal, 'department', { value: 'research', enumerable: false }); const principals: Principal[] = [ new LegacyPrincipal(), + topSymbolPrincipal, + hiddenPrincipal, { id: 'nested', isAuthenticated: true, roles: ['Reader'], claims: { legacy: new LegacyClaim() } }, { id: 'function', isAuthenticated: true, roles: ['Reader'], claims: { call: () => true } }, { id: 'symbol', isAuthenticated: true, roles: ['Reader'], claims: { value: Symbol('claim') } }, + { id: 'symbol key', isAuthenticated: true, roles: ['Reader'], claims: { [symbolKey]: 'secret' } }, + { id: 'nonenumerable', isAuthenticated: true, roles: ['Reader'], claims: nonEnumerable }, + { id: 'getter', isAuthenticated: true, roles: ['Reader'], claims: getterClaim }, + { id: 'map', isAuthenticated: true, roles: ['Reader'], claims: { groups: new Map([['a', { role: 'Admin' }]]) } }, + { id: 'set', isAuthenticated: true, roles: ['Reader'], claims: { groups: new Set([{ role: 'Admin' }]) } }, + { id: 'date', isAuthenticated: true, roles: ['Reader'], claims: { issued: new Date() } }, { id: 'deep', isAuthenticated: true, roles: ['Reader'], claims: deep }, { id: 'roles', isAuthenticated: true, roles: 'Reader' as unknown as string[] } ]; @@ -63,10 +79,10 @@ describe('when a principal cannot be snapshotted for borrowing', () => { } finally { await server.dispose(); } }); it('should keep ordinary query requests working with their original principal', () => { - responses.should.deep.equal(['class', 'nested', 'function', 'symbol', 'deep', 'roles']); + responses.should.deep.equal(['class', 'top symbol', 'hidden', 'nested', 'function', 'symbol', 'symbol key', 'nonenumerable', 'getter', 'map', 'set', 'date', 'deep', 'roles']); }); it('should keep the creation-time tenant when the original context changes', () => { - tenants.should.deep.equal(['first', 'first', 'first', 'first', 'first', 'first']); + tenants.should.deep.equal(Array(14).fill('first')); }); it('should reject borrowed execution before the callback runs', () => { callbacks.should.equal(0); diff --git a/Source/MongoDB/for_withMongoDB/when_resolving_with_a_mutated_context.ts b/Source/MongoDB/for_withMongoDB/when_resolving_with_a_mutated_context.ts new file mode 100644 index 00000000..57dcd659 --- /dev/null +++ b/Source/MongoDB/for_withMongoDB/when_resolving_with_a_mutated_context.ts @@ -0,0 +1,29 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { beforeEach, describe, it, should } from 'vitest'; +import { ArcApplication, Severity } from '@cratis/arc.core'; +import type { MongoClient } from 'mongodb'; +import { mongoCollection } from '../collectionToken.js'; +import { TaskRecord } from '../for_MongoCollection/given/TaskRecord.js'; +import '../withMongoDB.js'; + +should(); +describe('when resolving a MongoDB collection after changing its context tenant', () => { + let requestedDatabase: string; + beforeEach(async () => { + const client = { db: (name: string) => { + requestedDatabase = name; + return { collection: () => ({}) }; + } } as unknown as MongoClient; + const builder = ArcApplication.createBuilder(); + builder.withMongoDB({ client, database: 'Items', readModels: [TaskRecord] }); + const application = await builder.build(); + const identity = { tenantId: 'first', correlationId: crypto.randomUUID(), principal: undefined, + signal: new AbortController().signal, allowedSeverity: Severity.Warning }; + const scope = application.server.services.createScope(identity); + identity.tenantId = 'second'; + try { await scope.resolve(mongoCollection(TaskRecord)); } + finally { await scope.dispose(); await application.dispose(); } + }); + it('should resolve the creation-time tenant database', () => requestedDatabase.should.equal('Items+first')); +}); From 791416c392a23988020a2249e227fdb5a3fc998f Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 26 Sep 2026 20:21:55 +0200 Subject: [PATCH 07/11] Keep scope factory authority stable during borrowed executions Factory contexts must not capture an invocation's correlation or linked cancellation signal, because scoped services persist across invocations. Keep per-invocation overrides only in the ambient request context and document the two lifetimes. --- Documentation/dependency-injection.md | 4 +- .../Core/dependencyInjection/ServiceScope.ts | 8 +-- Source/Core/execution/runInScope.ts | 7 ++- .../with_a_cached_service_and_extra_signal.ts | 57 ++++++++++++++++++ .../with_mutated_authority.ts | 14 ++++- ...ested_scopes_resolving_from_outer_scope.ts | 60 +++++++++++++++++++ 6 files changed, 135 insertions(+), 15 deletions(-) create mode 100644 Source/Core/for_ArcServer/when_borrowing_a_scope/with_a_cached_service_and_extra_signal.ts create mode 100644 Source/Core/for_ArcServer/when_borrowing_a_scope/with_nested_scopes_resolving_from_outer_scope.ts diff --git a/Documentation/dependency-injection.md b/Documentation/dependency-injection.md index cfbce141..4c536d18 100644 --- a/Documentation/dependency-injection.md +++ b/Documentation/dependency-injection.md @@ -99,13 +99,13 @@ A factory declares its own `dependencies` for preflight and resolves them with i Registry shutdown drains admitted work and pending singleton construction, then aborts the registry signal and disposes singletons. It rejects new executions and scopes. A singleton factory that fails poisons the registry and triggers the same shutdown. Cancellation is cooperative: factories and handlers must observe their signal. Do not await `registry.dispose()` from inside its own handler, factory, or disposer; it rejects to prevent a deadlock. Stop singleton background loops when the registry signal aborts, then join them in the singleton's disposer. -For every scope, `scope.identity` and the `execution` argument passed to scoped factories use a frozen, plain creation-time snapshot of the declared execution context fields, including fields supplied by class getters (such as `tenantId` and `signal`). The `principal` in ordinary scopes is always the **original object reference**, not a clone or frozen copy. Its mutable roles and claims remain mutable for ordinary requests; changing the caller's context fields after scope creation does not change the scoped tenant or signal. +For every scope, `scope.identity` and the `execution` argument passed to scoped **and transient** factories always use the same frozen, plain creation-time snapshot of the declared execution context fields, including fields supplied by class getters (such as `tenantId` and `signal`). This remains true even when a factory is first resolved during borrowed work. The snapshot retains the caller's **original principal object reference**, not a clone or frozen copy. Its mutable roles and claims remain mutable for ordinary requests; changing the caller's context fields after scope creation does not change the scoped tenant, correlation ID, or signal. Built-in Chronicle, MongoDB, and Drizzle factories also read this stable scope identity. Nested commands and queries keep their causal dependency ancestry for cycle detection but get their own execution identity and scoped lifetime guard; the same scoped token in two independent nested scopes is not a cycle. ## Borrow a scope in a host integration -A trusted host integration can call `server.runInScope(scope, callback, { correlationId, signal })` to run construction and callbacks with `currentContext()` and `currentServices()` set. Create the scope with `server.services.createScope(context)` and dispose it yourself after all borrowed work settles; `runInScope` does not own it. The scope captures the declared context fields at creation, including tenant, transport identity, severity, and cancellation authority. For borrowing only, it also creates a separate, deeply frozen principal copy at scope creation. `currentContext()` and factories first resolved during borrowed work receive that copy with the invocation's correlation ID and linked signal; `scope.identity` still exposes the ordinary scope snapshot with the original principal. A borrowed invocation may supply only a valid UUID correlation ID (normalized to lowercase) and an additional cancellation signal linked to the scope's signal. It rejects a signal already aborted before the invocation starts. Nested calls restore the prior context when they settle. +A trusted host integration can call `server.runInScope(scope, callback, { correlationId, signal })` to run callbacks with `currentContext()` and `currentServices()` set. Create the scope with `server.services.createScope(context)` and dispose it yourself after all borrowed work settles; `runInScope` does not own it. The scope captures the declared context fields at creation, including tenant, transport identity, severity, and cancellation authority. For borrowing only, it also creates a separate, deeply frozen plain principal copy at scope creation. **Only `currentContext()`** within the borrowed invocation receives that copy, the invocation's correlation ID, and a signal linked to the scope's signal and any additional signal. A borrowed invocation may supply only a valid UUID correlation ID (normalized to lowercase) and an additional cancellation signal; it rejects a signal already aborted before the invocation starts. The linked signal belongs to the invocation's ambient context, not to the scope or its factories: a scoped instance created during one invocation retains the scope's original signal when reused in later invocations, even after an earlier additional signal aborts. Nested calls restore the prior ambient context when they settle, including when borrowing a different scope; resolving a service from either scope still uses that scope's stable creation-time factory context. Borrowing requires a strictly plain-data principal throughout its roles, claims, and extra fields: ordinary objects (including null-prototype objects), arrays, and primitive values, with only own enumerable string properties and no accessors. Arrays' built-in `length` is allowed. Symbol keys or values, non-enumerable properties, getters, functions, `Map`, `Set`, `Date`, class instances, invalid identity fields, or nesting beyond the supported depth make the scope non-borrowable. Ordinary requests still use the original principal reference and their creation-time context fields; `runInScope` rejects a non-borrowable scope before calling back. diff --git a/Source/Core/dependencyInjection/ServiceScope.ts b/Source/Core/dependencyInjection/ServiceScope.ts index 3aaf6862..1ee1896c 100644 --- a/Source/Core/dependencyInjection/ServiceScope.ts +++ b/Source/Core/dependencyInjection/ServiceScope.ts @@ -45,11 +45,6 @@ export function hasLivingServiceDisposal(registry: ServiceRegistry, scope?: Serv } const resolution = new AsyncLocalStorage<{ owner: ServiceResolutionNode | undefined; chain: readonly ServiceResolutionNode[]; singleton: boolean; identity: ExecutionContext | undefined } | undefined>(); const current = new AsyncLocalStorage(); -const borrowedIdentity = new AsyncLocalStorage<{ scope: ServiceScope; context: ExecutionContext }>(); -/** Scoped factories created during borrowed work receive its detached authority. */ -export function withBorrowedServiceAuthority(scope: ServiceScope, context: ExecutionContext, callback: () => T): T { - return borrowedIdentity.run({ scope, context }, callback); -} /** Includes detached factories after their originating request frame has drained. */ export function hasLivingServiceResolution(registry: ServiceRegistry): boolean { return resolution.getStore()?.chain.some(node => serviceScopeRegistry(node.scope) === registry && node.state === ServiceResolutionState.Pending) ?? false; @@ -130,8 +125,7 @@ export class ServiceScope { const chain = active?.chain.filter(node => node.state === ServiceResolutionState.Pending) ?? []; const owner = active?.owner; const inherit = owner?.state === ServiceResolutionState.Pending && serviceScopeRegistry(owner.scope) === this.#registry; - const borrowed = borrowedIdentity.getStore(); - const identity = this.#singleton ? undefined : borrowed?.scope === this ? borrowed.context : this.#authority; + const identity = this.#singleton ? undefined : this.#authority; const captive = inherit ? active?.singleton ?? false : false; const task = this.resolveInChain(token, identity, chain, captive); if (chain.length || current.getStore() === this) return task; diff --git a/Source/Core/execution/runInScope.ts b/Source/Core/execution/runInScope.ts index 5fa18ad3..bf078100 100644 --- a/Source/Core/execution/runInScope.ts +++ b/Source/Core/execution/runInScope.ts @@ -4,7 +4,7 @@ import type { ArtifactMetadata } from '../reflection/ArtifactMetadata.js'; import type { ClassType } from '../reflection/ClassType.js'; import type { ServiceRegistry } from '../dependencyInjection/ServiceRegistry.js'; import type { ServiceScope } from '../dependencyInjection/ServiceScope.js'; -import { borrowedScopeAuthority, withBorrowedServiceAuthority } from '../dependencyInjection/ServiceScope.js'; +import { borrowedScopeAuthority } from '../dependencyInjection/ServiceScope.js'; import { ServiceDependencyError } from '../dependencyInjection/ServiceDependencyError.js'; import { withExecutionBoundary } from './withExecutionBoundary.js'; import { correlation } from './correlation.js'; @@ -19,13 +19,14 @@ export async function runInScope(services: ServiceRegistry, metadata: Readonl throw new ServiceDependencyError('Invalid correlation ID override'); if (authority.signal.aborted || options?.signal?.aborted) throw new ServiceDependencyError('Borrowed scope signal is already aborted'); + // Only ambient work observes the linked signal; scope factories retain their creation-time signal. // AbortSignal.any has no unsubscribe API; the dependent signal is collectible after this invocation settles. const signal = options?.signal ? AbortSignal.any([authority.signal, options.signal]) : authority.signal; if (signal.aborted) throw new ServiceDependencyError('Borrowed scope signal is already aborted'); const context = Object.freeze({ ...authority, correlationId: override === undefined ? authority.correlationId : correlation(override), signal }); - return withBorrowedServiceAuthority(scope, context, () => withExecutionBoundary(services, metadata, scope, context, async () => { + return withExecutionBoundary(services, metadata, scope, context, async () => { const result = await callback(); if (services.singletonFailed) throw new ServiceDependencyError('Service registry is disposed'); return result; - }, true)); + }, true); } diff --git a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_a_cached_service_and_extra_signal.ts b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_a_cached_service_and_extra_signal.ts new file mode 100644 index 00000000..9659e2e5 --- /dev/null +++ b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_a_cached_service_and_extra_signal.ts @@ -0,0 +1,57 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { beforeEach, describe, it, should } from 'vitest'; +import { ArcServer, currentContext } from '../../ArcServer.js'; +import { ServiceLifetime } from '../../dependencyInjection/ServiceLifetime.js'; +import { serviceToken } from '../../dependencyInjection/ServiceToken.js'; +import { serviceContext } from '../../dependencyInjection/for_ServiceRegistry/given/a_service_lifecycle.js'; +import type { ExecutionContext } from '../../execution/ExecutionContext.js'; + +should(); +describe('when borrowing a cached scoped service after an extra signal aborts', () => { + let first: { execution: ExecutionContext }; + let second: { execution: ExecutionContext }; + let scopeSignal: AbortSignal; + let firstAmbientSignal: AbortSignal; + let secondAmbientSignal: AbortSignal; + let constructed: number; + beforeEach(async () => { + constructed = 0; + const service = serviceToken<{ execution: ExecutionContext }>('cached execution'); + const server = new ArcServer({ services: [ + { token: service, lifetime: ServiceLifetime.Scoped, factory: (_scope, execution) => { + constructed++; + return { execution }; + } } + ] }); + const scope = server.services.createScope(serviceContext('scope')); + scopeSignal = scope.identity!.signal; + const extra = new AbortController(); + try { + first = await server.runInScope(scope, async () => { + firstAmbientSignal = currentContext()!.signal; + return scope.resolve(service); + }, { signal: extra.signal }); + extra.abort(); + second = await server.runInScope(scope, async () => { + secondAmbientSignal = currentContext()!.signal; + return scope.resolve(service); + }); + } finally { await scope.dispose(); await server.dispose(); } + }); + it('should reuse the scoped instance across invocations', () => { + (second === first).should.equal(true); + constructed.should.equal(1); + }); + it('should keep the factory signal live after the extra signal aborts', () => { + first.execution.signal.should.equal(scopeSignal); + second.execution.signal.aborted.should.equal(false); + scopeSignal.aborted.should.equal(false); + }); + it('should link the extra signal only to the first ambient invocation', () => { + (firstAmbientSignal === scopeSignal).should.equal(false); + firstAmbientSignal.aborted.should.equal(true); + secondAmbientSignal.should.equal(scopeSignal); + secondAmbientSignal.aborted.should.equal(false); + }); +}); diff --git a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_mutated_authority.ts b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_mutated_authority.ts index d9c5f0c1..65fec672 100644 --- a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_mutated_authority.ts +++ b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_mutated_authority.ts @@ -16,6 +16,7 @@ describe('when borrowing a scope after its original authority is mutated', () => let getterTenant: string | undefined; let getterSignal: AbortSignal | undefined; let matchesScope: boolean; + let matchesScopeAuthority: boolean; let originalSignal: AbortSignal; let ordinaryPrincipal: ExecutionContext['principal']; beforeEach(async () => { @@ -50,6 +51,7 @@ describe('when borrowing a scope after its original authority is mutated', () => await Promise.resolve(); factoryContext = currentContext(); factoryAuthority = await currentServices().resolve(authority); + matchesScopeAuthority = factoryAuthority === scope.identity; getterTenant = await scope.resolve(fromScope); return currentContext(); }, { correlationId: 'E51A25C3-465D-4701-95CA-1F8B84C308D8' }); @@ -68,14 +70,20 @@ describe('when borrowing a scope after its original authority is mutated', () => observed!.principal!.id.should.equal('original'); observed!.principal!.scheme!.should.equal('Verified'); observed!.principal!.roles.should.deep.equal(['Reader']); - factoryAuthority.principal!.roles.should.deep.equal(['Reader']); - (factoryAuthority.principal === ordinaryPrincipal).should.equal(false); - factoryAuthority.correlationId.should.equal('e51a25c3-465d-4701-95ca-1f8b84c308d8'); + factoryAuthority.principal!.roles.should.deep.equal(['Reader', 'Admin']); + (factoryAuthority.principal === ordinaryPrincipal).should.equal(true); + matchesScopeAuthority.should.equal(true); + factoryAuthority.correlationId.should.equal('initial'); + factoryAuthority.signal.should.equal(originalSignal); (observed!.principal!.claims as object).should.deep.equal({ group: { name: 'before' } }); }); it('should expose only the invocation correlation and the borrowed services', () => { observed!.correlationId.should.equal('e51a25c3-465d-4701-95ca-1f8b84c308d8'); (factoryContext === observed).should.equal(true); + (observed!.principal === ordinaryPrincipal).should.equal(false); + Object.isFrozen(observed).should.equal(true); + Object.isFrozen(observed!.principal!.roles).should.equal(true); + Object.isFrozen(observed!.principal!.claims).should.equal(true); matchesScope.should.equal(true); }); }); diff --git a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_nested_scopes_resolving_from_outer_scope.ts b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_nested_scopes_resolving_from_outer_scope.ts new file mode 100644 index 00000000..9a7b062e --- /dev/null +++ b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_nested_scopes_resolving_from_outer_scope.ts @@ -0,0 +1,60 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { beforeEach, describe, it, should } from 'vitest'; +import { ArcServer, currentContext } from '../../ArcServer.js'; +import { ServiceLifetime } from '../../dependencyInjection/ServiceLifetime.js'; +import { serviceToken } from '../../dependencyInjection/ServiceToken.js'; +import { serviceContext } from '../../dependencyInjection/for_ServiceRegistry/given/a_service_lifecycle.js'; +import type { ExecutionContext } from '../../execution/ExecutionContext.js'; + +should(); +describe('when resolving from an outer scope inside a nested borrowed scope', () => { + let scopedExecution: ExecutionContext; + let transientExecution: ExecutionContext; + let innerExecution: ExecutionContext; + let outerIdentity: ExecutionContext; + let innerIdentity: ExecutionContext; + let ambient: ExecutionContext; + beforeEach(async () => { + const scoped = serviceToken<{ execution: ExecutionContext }>('scoped execution'); + const transient = serviceToken<{ execution: ExecutionContext }>('transient execution'); + const server = new ArcServer({ services: [ + { token: scoped, lifetime: ServiceLifetime.Scoped, factory: (_scope, execution) => ({ execution }) }, + { token: transient, lifetime: ServiceLifetime.Transient, factory: (_scope, execution) => ({ execution }) } + ] }); + const outerPrincipal = { id: 'outer', roles: ['Reader'], isAuthenticated: true }; + const innerPrincipal = { id: 'inner', roles: ['Writer'], isAuthenticated: true }; + const outer = server.services.createScope({ ...serviceContext('outer'), principal: outerPrincipal, + correlationId: 'b0f0a604-be5b-4713-b7e5-850570f11275' }); + const inner = server.services.createScope({ ...serviceContext('inner'), principal: innerPrincipal, + correlationId: 'c0f0a604-be5b-4713-b7e5-850570f11275' }); + outerIdentity = outer.identity!; + innerIdentity = inner.identity!; + try { + await server.runInScope(outer, async () => { + await server.runInScope(inner, async () => { + ambient = currentContext()!; + scopedExecution = (await outer.resolve(scoped)).execution; + transientExecution = (await outer.resolve(transient)).execution; + innerExecution = (await inner.resolve(scoped)).execution; + }, { correlationId: 'a0f0a604-be5b-4713-b7e5-850570f11275' }); + }); + } finally { await outer.dispose(); await inner.dispose(); await server.dispose(); } + }); + it('should give factories resolving from the outer scope its stable authority', () => { + scopedExecution.should.equal(outerIdentity); + transientExecution.should.equal(outerIdentity); + scopedExecution.principal!.id.should.equal('outer'); + scopedExecution.correlationId.should.equal('b0f0a604-be5b-4713-b7e5-850570f11275'); + }); + it('should give factories resolving from the inner scope its own stable authority', () => { + innerExecution.should.equal(innerIdentity); + innerExecution.correlationId.should.equal('c0f0a604-be5b-4713-b7e5-850570f11275'); + }); + it('should expose only the inner invocation override as ambient context', () => { + ambient.correlationId.should.equal('a0f0a604-be5b-4713-b7e5-850570f11275'); + ambient.principal!.id.should.equal('inner'); + (ambient.principal === innerIdentity.principal).should.equal(false); + Object.isFrozen(ambient.principal!.roles).should.equal(true); + }); +}); From 724827d3e5bb132f661c665883e0d9e2ae919da4 Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 26 Sep 2026 20:40:21 +0200 Subject: [PATCH 08/11] Reject poisoned borrowed executions after frame drain --- Documentation/dependency-injection.md | 2 +- Source/Core/execution/runInScope.ts | 8 ++-- .../with_a_detached_failing_singleton.ts | 38 +++++++++++++++++++ .../with_a_successful_return.ts | 34 +++++++++++++++++ 4 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 Source/Core/for_ArcServer/when_borrowing_a_scope/with_a_detached_failing_singleton.ts create mode 100644 Source/Core/for_ArcServer/when_borrowing_a_scope/with_a_successful_return.ts diff --git a/Documentation/dependency-injection.md b/Documentation/dependency-injection.md index 4c536d18..74cb325d 100644 --- a/Documentation/dependency-injection.md +++ b/Documentation/dependency-injection.md @@ -105,7 +105,7 @@ Nested commands and queries keep their causal dependency ancestry for cycle dete ## Borrow a scope in a host integration -A trusted host integration can call `server.runInScope(scope, callback, { correlationId, signal })` to run callbacks with `currentContext()` and `currentServices()` set. Create the scope with `server.services.createScope(context)` and dispose it yourself after all borrowed work settles; `runInScope` does not own it. The scope captures the declared context fields at creation, including tenant, transport identity, severity, and cancellation authority. For borrowing only, it also creates a separate, deeply frozen plain principal copy at scope creation. **Only `currentContext()`** within the borrowed invocation receives that copy, the invocation's correlation ID, and a signal linked to the scope's signal and any additional signal. A borrowed invocation may supply only a valid UUID correlation ID (normalized to lowercase) and an additional cancellation signal; it rejects a signal already aborted before the invocation starts. The linked signal belongs to the invocation's ambient context, not to the scope or its factories: a scoped instance created during one invocation retains the scope's original signal when reused in later invocations, even after an earlier additional signal aborts. Nested calls restore the prior ambient context when they settle, including when borrowing a different scope; resolving a service from either scope still uses that scope's stable creation-time factory context. +A trusted host integration can call `server.runInScope(scope, callback, { correlationId, signal })` to run callbacks with `currentContext()` and `currentServices()` set. Create the scope with `server.services.createScope(context)` and dispose it yourself after all borrowed work settles; `runInScope` does not own it. The scope captures the declared context fields at creation, including tenant, transport identity, severity, and cancellation authority. For borrowing only, it also creates a separate, deeply frozen plain principal copy at scope creation. **Only `currentContext()`** within the borrowed invocation receives that copy, the invocation's correlation ID, and a signal linked to the scope's signal and any additional signal. A borrowed invocation may supply only a valid UUID correlation ID (normalized to lowercase) and an additional cancellation signal; it rejects a signal already aborted before the invocation starts. The linked signal belongs to the invocation's ambient context, not to the scope or its factories: a scoped instance created during one invocation retains the scope's original signal when reused in later invocations, even after an earlier additional signal aborts. Nested calls restore the prior ambient context when they settle, including when borrowing a different scope; resolving a service from either scope still uses that scope's stable creation-time factory context. If a singleton factory fails before a borrowed invocation drains, `runInScope` rejects with a service dependency error rather than returning the callback's value; registry shutdown waits for the invocation without being joined inside it. Borrowing requires a strictly plain-data principal throughout its roles, claims, and extra fields: ordinary objects (including null-prototype objects), arrays, and primitive values, with only own enumerable string properties and no accessors. Arrays' built-in `length` is allowed. Symbol keys or values, non-enumerable properties, getters, functions, `Map`, `Set`, `Date`, class instances, invalid identity fields, or nesting beyond the supported depth make the scope non-borrowable. Ordinary requests still use the original principal reference and their creation-time context fields; `runInScope` rejects a non-borrowable scope before calling back. diff --git a/Source/Core/execution/runInScope.ts b/Source/Core/execution/runInScope.ts index bf078100..27ab1a0e 100644 --- a/Source/Core/execution/runInScope.ts +++ b/Source/Core/execution/runInScope.ts @@ -24,9 +24,11 @@ export async function runInScope(services: ServiceRegistry, metadata: Readonl const signal = options?.signal ? AbortSignal.any([authority.signal, options.signal]) : authority.signal; if (signal.aborted) throw new ServiceDependencyError('Borrowed scope signal is already aborted'); const context = Object.freeze({ ...authority, correlationId: override === undefined ? authority.correlationId : correlation(override), signal }); - return withExecutionBoundary(services, metadata, scope, context, async () => { - const result = await callback(); + const result = await withExecutionBoundary(services, metadata, scope, context, async () => { + const value = await callback(); if (services.singletonFailed) throw new ServiceDependencyError('Service registry is disposed'); - return result; + return value; }, true); + if (services.singletonFailed) throw new ServiceDependencyError('Service registry is disposed'); + return result; } diff --git a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_a_detached_failing_singleton.ts b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_a_detached_failing_singleton.ts new file mode 100644 index 00000000..edfe59bb --- /dev/null +++ b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_a_detached_failing_singleton.ts @@ -0,0 +1,38 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { beforeEach, describe, it, should } from 'vitest'; +import { ArcServer } from '../../ArcServer.js'; +import { ServiceDependencyError } from '../../dependencyInjection/ServiceDependencyError.js'; +import { ServiceLifetime } from '../../dependencyInjection/ServiceLifetime.js'; +import { serviceToken } from '../../dependencyInjection/ServiceToken.js'; +import { beforeDeadline, captureFailure, serviceContext } from '../../dependencyInjection/for_ServiceRegistry/given/a_service_lifecycle.js'; + +should(); +describe('when a detached singleton fails before borrowed execution drains', () => { + let failure: unknown; + let poisoned: boolean; + let shutdownFailure: unknown; + beforeEach(async () => { + const broken = serviceToken('broken singleton'); + const server = new ArcServer({ services: [ + { token: broken, lifetime: ServiceLifetime.Singleton, factory: () => { throw new Error('construction failed'); } } + ] }); + const scope = server.services.createScope(serviceContext('tenant')); + try { + failure = await beforeDeadline(captureFailure(server.runInScope(scope, async () => { + void scope.resolve(broken).catch(() => {}); + await Promise.resolve(); + await Promise.resolve(); + return 'must not publish'; + })), 'detached singleton failure'); + poisoned = server.services.singletonFailed; + shutdownFailure = await beforeDeadline(captureFailure(server.services.dispose()), 'singleton shutdown'); + } finally { await beforeDeadline(server.dispose(), 'detached singleton cleanup'); } + }); + it('should reject the borrowed success after registry poisoning', () => { + poisoned.should.equal(true); + (failure instanceof ServiceDependencyError).should.equal(true); + (failure as Error).message.should.equal('Service registry is disposed'); + }); + it('should not join registry disposal from borrowed work', () => (shutdownFailure === undefined).should.equal(true)); +}); diff --git a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_a_successful_return.ts b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_a_successful_return.ts new file mode 100644 index 00000000..31aa89f3 --- /dev/null +++ b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_a_successful_return.ts @@ -0,0 +1,34 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { beforeEach, describe, it, should } from 'vitest'; +import { ArcServer } from '../../ArcServer.js'; +import { ServiceLifetime } from '../../dependencyInjection/ServiceLifetime.js'; +import { serviceToken } from '../../dependencyInjection/ServiceToken.js'; +import { beforeDeadline, captureFailure, serviceContext } from '../../dependencyInjection/for_ServiceRegistry/given/a_service_lifecycle.js'; + +should(); +describe('when borrowed execution succeeds before registry shutdown', () => { + let result: string; + let shutdownFailure: unknown; + let disposed: boolean; + beforeEach(async () => { + disposed = false; + const resource = serviceToken('borrowed resource'); + const server = new ArcServer({ services: [ + { token: resource, lifetime: ServiceLifetime.Scoped, factory: () => ({ [Symbol.dispose]: () => { disposed = true; } }) } + ] }); + const scope = server.services.createScope(serviceContext('tenant')); + try { + result = await beforeDeadline(server.runInScope(scope, async () => { + await scope.resolve(resource); + return 'borrowed value'; + }), 'successful borrowed execution'); + shutdownFailure = await beforeDeadline(captureFailure(server.services.dispose()), 'borrowed shutdown'); + } finally { await beforeDeadline(server.dispose(), 'borrowed cleanup'); } + }); + it('should return the callback value', () => result.should.equal('borrowed value')); + it('should complete shutdown and dispose the borrowed scope', () => { + (shutdownFailure === undefined).should.equal(true); + disposed.should.equal(true); + }); +}); From 8d77948efa9620f61abe7e85304eb9b5857e0f3a Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 26 Sep 2026 21:00:34 +0200 Subject: [PATCH 09/11] Fix scoped factory ambient context in borrowed executions Construct scoped and transient services under their owning scope snapshot so invocation-only cancellation, correlation and principal copies cannot leak into cached services. --- Documentation/dependency-injection.md | 4 +- .../Core/dependencyInjection/ServiceScope.ts | 4 +- .../with_a_cached_service_and_extra_signal.ts | 31 ++++++----- ...ested_scopes_resolving_from_outer_scope.ts | 28 +++++++--- .../with_factory_ambient_context.ts | 52 +++++++++++++++++++ 5 files changed, 96 insertions(+), 23 deletions(-) create mode 100644 Source/Core/for_ArcServer/when_handling_a_query/with_factory_ambient_context.ts diff --git a/Documentation/dependency-injection.md b/Documentation/dependency-injection.md index 74cb325d..92b3bb37 100644 --- a/Documentation/dependency-injection.md +++ b/Documentation/dependency-injection.md @@ -99,13 +99,13 @@ A factory declares its own `dependencies` for preflight and resolves them with i Registry shutdown drains admitted work and pending singleton construction, then aborts the registry signal and disposes singletons. It rejects new executions and scopes. A singleton factory that fails poisons the registry and triggers the same shutdown. Cancellation is cooperative: factories and handlers must observe their signal. Do not await `registry.dispose()` from inside its own handler, factory, or disposer; it rejects to prevent a deadlock. Stop singleton background loops when the registry signal aborts, then join them in the singleton's disposer. -For every scope, `scope.identity` and the `execution` argument passed to scoped **and transient** factories always use the same frozen, plain creation-time snapshot of the declared execution context fields, including fields supplied by class getters (such as `tenantId` and `signal`). This remains true even when a factory is first resolved during borrowed work. The snapshot retains the caller's **original principal object reference**, not a clone or frozen copy. Its mutable roles and claims remain mutable for ordinary requests; changing the caller's context fields after scope creation does not change the scoped tenant, correlation ID, or signal. Built-in Chronicle, MongoDB, and Drizzle factories also read this stable scope identity. +For every scope, `scope.identity`, the `execution` argument passed to scoped **and transient** factories, and `currentContext()` during their construction use the same frozen, plain creation-time snapshot of the declared execution context fields, including fields supplied by class getters (such as `tenantId` and `signal`). This remains true even when a factory is first resolved during borrowed work or an outer scope's service is resolved from a nested invocation. The snapshot retains the caller's **original principal object reference**, not a clone or frozen copy. Its mutable roles and claims remain mutable for ordinary requests; changing the caller's context fields after scope creation does not change the scoped tenant, correlation ID, or signal. Outside factory construction, `currentContext()` continues to reflect the current execution. Built-in Chronicle, MongoDB, and Drizzle factories also read this stable scope identity. Nested commands and queries keep their causal dependency ancestry for cycle detection but get their own execution identity and scoped lifetime guard; the same scoped token in two independent nested scopes is not a cycle. ## Borrow a scope in a host integration -A trusted host integration can call `server.runInScope(scope, callback, { correlationId, signal })` to run callbacks with `currentContext()` and `currentServices()` set. Create the scope with `server.services.createScope(context)` and dispose it yourself after all borrowed work settles; `runInScope` does not own it. The scope captures the declared context fields at creation, including tenant, transport identity, severity, and cancellation authority. For borrowing only, it also creates a separate, deeply frozen plain principal copy at scope creation. **Only `currentContext()`** within the borrowed invocation receives that copy, the invocation's correlation ID, and a signal linked to the scope's signal and any additional signal. A borrowed invocation may supply only a valid UUID correlation ID (normalized to lowercase) and an additional cancellation signal; it rejects a signal already aborted before the invocation starts. The linked signal belongs to the invocation's ambient context, not to the scope or its factories: a scoped instance created during one invocation retains the scope's original signal when reused in later invocations, even after an earlier additional signal aborts. Nested calls restore the prior ambient context when they settle, including when borrowing a different scope; resolving a service from either scope still uses that scope's stable creation-time factory context. If a singleton factory fails before a borrowed invocation drains, `runInScope` rejects with a service dependency error rather than returning the callback's value; registry shutdown waits for the invocation without being joined inside it. +A trusted host integration can call `server.runInScope(scope, callback, { correlationId, signal })` to run callbacks with `currentContext()` and `currentServices()` set. Create the scope with `server.services.createScope(context)` and dispose it yourself after all borrowed work settles; `runInScope` does not own it. The scope captures the declared context fields at creation, including tenant, transport identity, severity, and cancellation authority. For borrowing only, it also creates a separate, deeply frozen plain principal copy at scope creation. The callback's ambient `currentContext()` receives that copy, the invocation's correlation ID, and a signal linked to the scope's signal and any additional signal; factories instead see the scope snapshot. A borrowed invocation may supply only a valid UUID correlation ID (normalized to lowercase) and an additional cancellation signal; it rejects a signal already aborted before the invocation starts. The linked signal belongs to the invocation's ambient context, not to the scope or its factories: both `execution` and `currentContext()` inside a scoped or transient factory use the scope's original signal, correlation ID, and principal reference. A scoped instance created during one invocation retains those values when reused in later invocations, even after an earlier additional signal aborts. Nested calls restore the prior ambient context when they settle, including when borrowing a different scope; resolving a service from either scope still uses that scope's stable creation-time factory context. If a singleton factory fails before a borrowed invocation drains, `runInScope` rejects with a service dependency error rather than returning the callback's value; registry shutdown waits for the invocation without being joined inside it. Borrowing requires a strictly plain-data principal throughout its roles, claims, and extra fields: ordinary objects (including null-prototype objects), arrays, and primitive values, with only own enumerable string properties and no accessors. Arrays' built-in `length` is allowed. Symbol keys or values, non-enumerable properties, getters, functions, `Map`, `Set`, `Date`, class instances, invalid identity fields, or nesting beyond the supported depth make the scope non-borrowable. Ordinary requests still use the original principal reference and their creation-time context fields; `runInScope` rejects a non-borrowable scope before calling back. diff --git a/Source/Core/dependencyInjection/ServiceScope.ts b/Source/Core/dependencyInjection/ServiceScope.ts index 1ee1896c..a874f3e7 100644 --- a/Source/Core/dependencyInjection/ServiceScope.ts +++ b/Source/Core/dependencyInjection/ServiceScope.ts @@ -11,7 +11,7 @@ import { ServiceDependencyError } from './ServiceDependencyError.js'; import type { ServiceResolutionNode } from './ServiceResolutionNode.js'; import { ServiceResolutionState } from './ServiceResolutionState.js'; import { ServiceScopeState } from './ServiceScopeState.js'; -import { withoutRequestContext } from '../execution/RequestContextStore.js'; +import { requestContext, withoutRequestContext } from '../execution/RequestContextStore.js'; import type { ServiceDisposalFrame } from './ServiceDisposalFrame.js'; import { ServiceDisposalState } from './ServiceDisposalState.js'; const singletonCapability = Symbol('singleton scope'); @@ -160,7 +160,7 @@ export class ServiceScope { registration.lifetime === ServiceLifetime.Singleton, identity }, () => Promise.resolve().then(() => withServices(this, () => registration.lifetime === ServiceLifetime.Singleton ? withoutRequestContext(() => this.construct(token, () => registration.factory?.(this, this.#registry.singletonContext) as T | Promise | undefined, registration.instance as T | undefined)) - : this.construct(token, () => identity && registration.factory?.(this, identity) as T | Promise | undefined)))); + : requestContext.run(identity, () => this.construct(token, () => identity && registration.factory?.(this, identity) as T | Promise | undefined))))); this.#pending.add(task); void task.then(() => { node.state = ServiceResolutionState.Settled; this.#pending.delete(task); }, () => { diff --git a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_a_cached_service_and_extra_signal.ts b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_a_cached_service_and_extra_signal.ts index 9659e2e5..c76fa144 100644 --- a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_a_cached_service_and_extra_signal.ts +++ b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_a_cached_service_and_extra_signal.ts @@ -9,29 +9,31 @@ import type { ExecutionContext } from '../../execution/ExecutionContext.js'; should(); describe('when borrowing a cached scoped service after an extra signal aborts', () => { - let first: { execution: ExecutionContext }; - let second: { execution: ExecutionContext }; - let scopeSignal: AbortSignal; + let first: { execution: ExecutionContext; ambient: ExecutionContext | undefined }; + let second: { execution: ExecutionContext; ambient: ExecutionContext | undefined }; + let scopeIdentity: ExecutionContext; let firstAmbientSignal: AbortSignal; + let firstAmbientCorrelation: string; let secondAmbientSignal: AbortSignal; let constructed: number; beforeEach(async () => { constructed = 0; - const service = serviceToken<{ execution: ExecutionContext }>('cached execution'); + const service = serviceToken<{ execution: ExecutionContext; ambient: ExecutionContext | undefined }>('cached execution'); const server = new ArcServer({ services: [ { token: service, lifetime: ServiceLifetime.Scoped, factory: (_scope, execution) => { constructed++; - return { execution }; + return { execution, ambient: currentContext() }; } } ] }); const scope = server.services.createScope(serviceContext('scope')); - scopeSignal = scope.identity!.signal; + scopeIdentity = scope.identity!; const extra = new AbortController(); try { first = await server.runInScope(scope, async () => { firstAmbientSignal = currentContext()!.signal; + firstAmbientCorrelation = currentContext()!.correlationId; return scope.resolve(service); - }, { signal: extra.signal }); + }, { signal: extra.signal, correlationId: 'a0f0a604-be5b-4713-b7e5-850570f11275' }); extra.abort(); second = await server.runInScope(scope, async () => { secondAmbientSignal = currentContext()!.signal; @@ -44,14 +46,19 @@ describe('when borrowing a cached scoped service after an extra signal aborts', constructed.should.equal(1); }); it('should keep the factory signal live after the extra signal aborts', () => { - first.execution.signal.should.equal(scopeSignal); - second.execution.signal.aborted.should.equal(false); - scopeSignal.aborted.should.equal(false); + first.execution.should.equal(scopeIdentity); + first.ambient!.should.equal(scopeIdentity); + first.ambient!.correlationId.should.equal(scopeIdentity.correlationId); + first.ambient!.signal.aborted.should.equal(false); + second.ambient!.signal.aborted.should.equal(false); + second.ambient!.correlationId.should.equal(scopeIdentity.correlationId); + scopeIdentity.signal.aborted.should.equal(false); }); it('should link the extra signal only to the first ambient invocation', () => { - (firstAmbientSignal === scopeSignal).should.equal(false); + (firstAmbientSignal === scopeIdentity.signal).should.equal(false); firstAmbientSignal.aborted.should.equal(true); - secondAmbientSignal.should.equal(scopeSignal); + firstAmbientCorrelation.should.equal('a0f0a604-be5b-4713-b7e5-850570f11275'); + secondAmbientSignal.should.equal(scopeIdentity.signal); secondAmbientSignal.aborted.should.equal(false); }); }); diff --git a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_nested_scopes_resolving_from_outer_scope.ts b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_nested_scopes_resolving_from_outer_scope.ts index 9a7b062e..d7b29cfe 100644 --- a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_nested_scopes_resolving_from_outer_scope.ts +++ b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_nested_scopes_resolving_from_outer_scope.ts @@ -10,17 +10,20 @@ import type { ExecutionContext } from '../../execution/ExecutionContext.js'; should(); describe('when resolving from an outer scope inside a nested borrowed scope', () => { let scopedExecution: ExecutionContext; + let scopedAmbient: ExecutionContext | undefined; let transientExecution: ExecutionContext; + let transientAmbient: ExecutionContext | undefined; let innerExecution: ExecutionContext; + let innerAmbient: ExecutionContext | undefined; let outerIdentity: ExecutionContext; let innerIdentity: ExecutionContext; let ambient: ExecutionContext; beforeEach(async () => { - const scoped = serviceToken<{ execution: ExecutionContext }>('scoped execution'); - const transient = serviceToken<{ execution: ExecutionContext }>('transient execution'); + const scoped = serviceToken<{ execution: ExecutionContext; ambient: ExecutionContext | undefined }>('scoped execution'); + const transient = serviceToken<{ execution: ExecutionContext; ambient: ExecutionContext | undefined }>('transient execution'); const server = new ArcServer({ services: [ - { token: scoped, lifetime: ServiceLifetime.Scoped, factory: (_scope, execution) => ({ execution }) }, - { token: transient, lifetime: ServiceLifetime.Transient, factory: (_scope, execution) => ({ execution }) } + { token: scoped, lifetime: ServiceLifetime.Scoped, factory: (_scope, execution) => ({ execution, ambient: currentContext() }) }, + { token: transient, lifetime: ServiceLifetime.Transient, factory: (_scope, execution) => ({ execution, ambient: currentContext() }) } ] }); const outerPrincipal = { id: 'outer', roles: ['Reader'], isAuthenticated: true }; const innerPrincipal = { id: 'inner', roles: ['Writer'], isAuthenticated: true }; @@ -34,9 +37,15 @@ describe('when resolving from an outer scope inside a nested borrowed scope', () await server.runInScope(outer, async () => { await server.runInScope(inner, async () => { ambient = currentContext()!; - scopedExecution = (await outer.resolve(scoped)).execution; - transientExecution = (await outer.resolve(transient)).execution; - innerExecution = (await inner.resolve(scoped)).execution; + const outerScoped = await outer.resolve(scoped); + const outerTransient = await outer.resolve(transient); + const innerScoped = await inner.resolve(scoped); + scopedExecution = outerScoped.execution; + scopedAmbient = outerScoped.ambient; + transientExecution = outerTransient.execution; + transientAmbient = outerTransient.ambient; + innerExecution = innerScoped.execution; + innerAmbient = innerScoped.ambient; }, { correlationId: 'a0f0a604-be5b-4713-b7e5-850570f11275' }); }); } finally { await outer.dispose(); await inner.dispose(); await server.dispose(); } @@ -44,11 +53,16 @@ describe('when resolving from an outer scope inside a nested borrowed scope', () it('should give factories resolving from the outer scope its stable authority', () => { scopedExecution.should.equal(outerIdentity); transientExecution.should.equal(outerIdentity); + scopedAmbient!.should.equal(outerIdentity); + transientAmbient!.should.equal(outerIdentity); + (scopedAmbient!.principal === outerIdentity.principal).should.equal(true); + (transientAmbient!.principal === outerIdentity.principal).should.equal(true); scopedExecution.principal!.id.should.equal('outer'); scopedExecution.correlationId.should.equal('b0f0a604-be5b-4713-b7e5-850570f11275'); }); it('should give factories resolving from the inner scope its own stable authority', () => { innerExecution.should.equal(innerIdentity); + innerAmbient!.should.equal(innerIdentity); innerExecution.correlationId.should.equal('c0f0a604-be5b-4713-b7e5-850570f11275'); }); it('should expose only the inner invocation override as ambient context', () => { diff --git a/Source/Core/for_ArcServer/when_handling_a_query/with_factory_ambient_context.ts b/Source/Core/for_ArcServer/when_handling_a_query/with_factory_ambient_context.ts new file mode 100644 index 00000000..a6512313 --- /dev/null +++ b/Source/Core/for_ArcServer/when_handling_a_query/with_factory_ambient_context.ts @@ -0,0 +1,52 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { beforeEach, describe, it, should } from 'vitest'; +import { z } from 'zod'; +import { ArcServer, currentContext } from '../../ArcServer.js'; +import { ServiceLifetime } from '../../dependencyInjection/ServiceLifetime.js'; +import { currentServices } from '../../dependencyInjection/ServiceScope.js'; +import { serviceToken } from '../../dependencyInjection/ServiceToken.js'; +import { serviceContext } from '../../dependencyInjection/for_ServiceRegistry/given/a_service_lifecycle.js'; +import { defineQuery } from '../../queries/defineQuery.js'; +import type { ExecutionContext } from '../../execution/ExecutionContext.js'; + +should(); +describe('when constructing services during an ordinary query', () => { + let factoryContexts: { execution: ExecutionContext; ambient: ExecutionContext | undefined; scope: ExecutionContext | undefined }[]; + let handlerContext: ExecutionContext | undefined; + let principal: ExecutionContext['principal']; + let success: boolean; + beforeEach(async () => { + factoryContexts = []; + principal = { id: 'caller', roles: ['Reader'], isAuthenticated: true }; + const scoped = serviceToken('scoped'); + const transient = serviceToken('transient'); + const factory = (_scope: unknown, execution: ExecutionContext) => { + factoryContexts.push({ execution, ambient: currentContext(), scope: currentServices().identity }); + return {}; + }; + const server = new ArcServer({ services: [ + { token: scoped, lifetime: ServiceLifetime.Scoped, factory }, + { token: transient, lifetime: ServiceLifetime.Transient, factory } + ], queries: [defineQuery({ name: 'FactoryContext', schema: z.object({}), handlerDependencies: [scoped, transient], + perform: () => { handlerContext = currentContext(); return 'done'; } })] }); + try { + const result = await server.performQuery('FactoryContext', {}, { ...serviceContext('tenant'), principal }); + success = result.isSuccess; + } finally { await server.dispose(); } + }); + it('should give both factories the scope snapshot and original principal', () => { + success.should.equal(true); + factoryContexts.should.have.lengthOf(2); + for (const { execution, ambient, scope } of factoryContexts) { + ambient!.should.equal(scope); + ambient!.should.equal(execution); + (ambient!.principal === principal).should.equal(true); + ambient!.tenantId!.should.equal('tenant'); + } + }); + it('should restore the ordinary request context for the handler', () => { + handlerContext!.tenantId!.should.equal('tenant'); + (handlerContext!.principal === principal).should.equal(true); + }); +}); From 7cb14f7d985c28cfbdc84da95948cbb228c36527 Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 26 Sep 2026 21:46:43 +0200 Subject: [PATCH 10/11] Skip principal snapshots for Arc-owned scopes --- Documentation/dependency-injection.md | 2 +- Source/Core/build/preflight.ts | 3 +- .../dependencyInjection/ServiceRegistry.ts | 4 +- .../Core/dependencyInjection/ServiceScope.ts | 31 +++++++++---- Source/Core/execution/runOwned.ts | 3 +- .../with_a_registry_created_scope.ts | 30 +++++++++++++ .../with_an_owned_observable_scope.ts | 33 ++++++++++++++ .../with_an_owned_query_scope.ts | 44 +++++++++++++++++++ .../observable/ObservableQuerySession.ts | 4 +- 9 files changed, 138 insertions(+), 16 deletions(-) create mode 100644 Source/Core/for_ArcServer/when_borrowing_a_scope/with_a_registry_created_scope.ts create mode 100644 Source/Core/for_ArcServer/when_borrowing_a_scope/with_an_owned_observable_scope.ts create mode 100644 Source/Core/for_ArcServer/when_borrowing_a_scope/with_an_owned_query_scope.ts diff --git a/Documentation/dependency-injection.md b/Documentation/dependency-injection.md index 92b3bb37..62cbac32 100644 --- a/Documentation/dependency-injection.md +++ b/Documentation/dependency-injection.md @@ -105,7 +105,7 @@ Nested commands and queries keep their causal dependency ancestry for cycle dete ## Borrow a scope in a host integration -A trusted host integration can call `server.runInScope(scope, callback, { correlationId, signal })` to run callbacks with `currentContext()` and `currentServices()` set. Create the scope with `server.services.createScope(context)` and dispose it yourself after all borrowed work settles; `runInScope` does not own it. The scope captures the declared context fields at creation, including tenant, transport identity, severity, and cancellation authority. For borrowing only, it also creates a separate, deeply frozen plain principal copy at scope creation. The callback's ambient `currentContext()` receives that copy, the invocation's correlation ID, and a signal linked to the scope's signal and any additional signal; factories instead see the scope snapshot. A borrowed invocation may supply only a valid UUID correlation ID (normalized to lowercase) and an additional cancellation signal; it rejects a signal already aborted before the invocation starts. The linked signal belongs to the invocation's ambient context, not to the scope or its factories: both `execution` and `currentContext()` inside a scoped or transient factory use the scope's original signal, correlation ID, and principal reference. A scoped instance created during one invocation retains those values when reused in later invocations, even after an earlier additional signal aborts. Nested calls restore the prior ambient context when they settle, including when borrowing a different scope; resolving a service from either scope still uses that scope's stable creation-time factory context. If a singleton factory fails before a borrowed invocation drains, `runInScope` rejects with a service dependency error rather than returning the callback's value; registry shutdown waits for the invocation without being joined inside it. +A trusted host integration can call `server.runInScope(scope, callback, { correlationId, signal })` to run callbacks with `currentContext()` and `currentServices()` set. Only scopes created with `server.services.createScope(context)` can be borrowed; Arc's own request scopes skip the principal copy. Dispose the host-created scope yourself after all borrowed work settles; `runInScope` does not own it. The scope captures the declared context fields at creation, including tenant, transport identity, severity, and cancellation authority. For borrowing only, it also creates a separate, deeply frozen plain principal copy at scope creation. The callback's ambient `currentContext()` receives that copy, the invocation's correlation ID, and a signal linked to the scope's signal and any additional signal; factories instead see the scope snapshot. A borrowed invocation may supply only a valid UUID correlation ID (normalized to lowercase) and an additional cancellation signal; it rejects a signal already aborted before the invocation starts. The linked signal belongs to the invocation's ambient context, not to the scope or its factories: both `execution` and `currentContext()` inside a scoped or transient factory use the scope's original signal, correlation ID, and principal reference. A scoped instance created during one invocation retains those values when reused in later invocations, even after an earlier additional signal aborts. Nested calls restore the prior ambient context when they settle, including when borrowing a different scope; resolving a service from either scope still uses that scope's stable creation-time factory context. If a singleton factory fails before a borrowed invocation drains, `runInScope` rejects with a service dependency error rather than returning the callback's value; registry shutdown waits for the invocation without being joined inside it. Borrowing requires a strictly plain-data principal throughout its roles, claims, and extra fields: ordinary objects (including null-prototype objects), arrays, and primitive values, with only own enumerable string properties and no accessors. Arrays' built-in `length` is allowed. Symbol keys or values, non-enumerable properties, getters, functions, `Map`, `Set`, `Date`, class instances, invalid identity fields, or nesting beyond the supported depth make the scope non-borrowable. Ordinary requests still use the original principal reference and their creation-time context fields; `runInScope` rejects a non-borrowable scope before calling back. diff --git a/Source/Core/build/preflight.ts b/Source/Core/build/preflight.ts index a5694ca9..2bea8ab7 100644 --- a/Source/Core/build/preflight.ts +++ b/Source/Core/build/preflight.ts @@ -6,6 +6,7 @@ import type { Artifact } from '../reflection/Artifact.js'; import type { ClassType } from '../reflection/ClassType.js'; import { ownMetadata } from '../reflection/ownMetadata.js'; import type { ServiceIdentifier } from '../dependencyInjection/ServiceIdentifier.js'; +import { createOwnedServiceScope } from '../dependencyInjection/ServiceScope.js'; import type { AuthorizationPolicy, AuthorizationPolicyRegistration } from '../authorization/AuthorizationPolicy.js'; import { readModelArgument } from '../commands/modelBound/commandReadModel.js'; import { BaseValidator } from '../validation/BaseValidator.js'; @@ -20,7 +21,7 @@ export async function preflight(server: ArcServer, dependencies: ServiceIdentifi server.services.preflight([...dependencies, ...[...policies.values()].filter( (policy): policy is (abstract new (...arguments_: never[]) => AuthorizationPolicy) => typeof policy.prototype?.authorize === 'function')]); - const scope = server.services.createScope({ correlationId: '', principal: undefined, tenantId: undefined, + const scope = createOwnedServiceScope(server.services, { correlationId: '', principal: undefined, tenantId: undefined, signal: new AbortController().signal, allowedSeverity: Severity.Error }); try { const resolvers = await Promise.all([...options.readModelForCommandResolvers ?? [], ...readModelResolvers] diff --git a/Source/Core/dependencyInjection/ServiceRegistry.ts b/Source/Core/dependencyInjection/ServiceRegistry.ts index 3ff7ea50..46531f9b 100644 --- a/Source/Core/dependencyInjection/ServiceRegistry.ts +++ b/Source/Core/dependencyInjection/ServiceRegistry.ts @@ -6,7 +6,7 @@ import type { ExecutionContext } from '../execution/ExecutionContext.js'; import type { ServiceRegistration } from './ServiceRegistration.js'; import type { ServiceToken } from './ServiceToken.js'; import { normalizeServiceToken, type ServiceIdentifier } from './ServiceIdentifier.js'; -import { ServiceScope, closeServiceScope, createSingletonServiceScope, disposeCreatedServices, hasLivingServiceDisposal, hasLivingServiceResolution, serviceScopeRegistry, withServiceResolutionBoundary } from './ServiceScope.js'; +import { ServiceScope, closeServiceScope, createBorrowableServiceScope, createSingletonServiceScope, disposeCreatedServices, hasLivingServiceDisposal, hasLivingServiceResolution, serviceScopeRegistry, withServiceResolutionBoundary } from './ServiceScope.js'; import type { ServiceResolutionNode } from './ServiceResolutionNode.js'; import type { ServiceExecutionFrame } from './ServiceExecutionFrame.js'; import { ServiceDependencyError } from './ServiceDependencyError.js'; @@ -155,7 +155,7 @@ export class ServiceRegistry { } createScope(identity: ExecutionContext): ServiceScope { this.assertLive(); - return new ServiceScope(this, identity); + return createBorrowableServiceScope(this, identity); } singletonScope(): ServiceScope { return this.#singletons; } /** Directly constructed scopes participate in admission and shutdown too. */ diff --git a/Source/Core/dependencyInjection/ServiceScope.ts b/Source/Core/dependencyInjection/ServiceScope.ts index a874f3e7..de655a47 100644 --- a/Source/Core/dependencyInjection/ServiceScope.ts +++ b/Source/Core/dependencyInjection/ServiceScope.ts @@ -15,6 +15,7 @@ import { requestContext, withoutRequestContext } from '../execution/RequestConte import type { ServiceDisposalFrame } from './ServiceDisposalFrame.js'; import { ServiceDisposalState } from './ServiceDisposalState.js'; const singletonCapability = Symbol('singleton scope'); +const borrowableCapability = Symbol('borrowable scope'); const internals = new WeakMap Promise; disposeCreated: () => Promise; authority: () => ExecutionContext | undefined; borrowable: () => boolean }>(); const disposal = new AsyncLocalStorage(); @@ -22,6 +23,14 @@ const disposal = new AsyncLocalStorage(); export function createSingletonServiceScope(registry: ServiceRegistry): ServiceScope { return Reflect.construct(ServiceScope, [registry, undefined, singletonCapability]) as ServiceScope; } +/** @internal Only public registry-created scopes carry borrowing authority. */ +export function createBorrowableServiceScope(registry: ServiceRegistry, identity: ExecutionContext): ServiceScope { + return Reflect.construct(ServiceScope, [registry, identity, borrowableCapability]) as ServiceScope; +} +/** @internal Arc-owned scopes have no borrowing authority. */ +export function createOwnedServiceScope(registry: ServiceRegistry, identity: ExecutionContext): ServiceScope { + return new ServiceScope(registry, identity); +} export function closeServiceScope(scope: ServiceScope): Promise { return internals.get(scope)!.close(); } export function disposeCreatedServices(scope: ServiceScope): Promise { return internals.get(scope)!.disposeCreated(); } export function serviceScopeRegistry(scope: ServiceScope): ServiceRegistry { return internals.get(scope)!.registry; } @@ -77,7 +86,8 @@ export class ServiceScope { readonly #borrowable: boolean; constructor(registry: ServiceRegistry, identity: ExecutionContext | undefined); constructor(registry: ServiceRegistry, identity: ExecutionContext | undefined, ...capability: unknown[]) { - if (capability.length && (capability.length !== 1 || capability[0] !== singletonCapability)) + if (capability.length && (capability.length !== 1 || + capability[0] !== singletonCapability && capability[0] !== borrowableCapability)) throw new ServiceDependencyError('Invalid service scope construction'); this.#registry = registry; // Record every declared field through property access, including inherited getters. @@ -90,16 +100,19 @@ export class ServiceScope { signal: identity.signal, allowedSeverity: identity.allowedSeverity } satisfies ExecutionContext & Record); - let borrowedPrincipal = this.#authority?.principal; - let borrowable = true; - if (borrowedPrincipal) { - try { borrowedPrincipal = snapshotPrincipal(borrowedPrincipal); } - catch { borrowable = false; } // Opaque principals remain usable in ordinary scopes. + let borrowable = capability[0] === borrowableCapability; + let borrowedAuthority: ExecutionContext | undefined; + if (borrowable && this.#authority) { + let borrowedPrincipal = this.#authority.principal; + if (borrowedPrincipal) { + try { borrowedPrincipal = snapshotPrincipal(borrowedPrincipal); } + catch { borrowable = false; } // Opaque principals remain usable in ordinary scopes. + } + if (borrowable) borrowedAuthority = Object.freeze({ ...this.#authority, principal: borrowedPrincipal }); } - this.#borrowedAuthority = borrowable && this.#authority - ? Object.freeze({ ...this.#authority, principal: borrowedPrincipal }) : undefined; + this.#borrowedAuthority = borrowedAuthority; this.#borrowable = borrowable; - this.#singleton = capability.length === 1; + this.#singleton = capability[0] === singletonCapability; if (this.#singleton) registry.assertLive(); else registry.admitScope(this); internals.set(this, { registry, close: () => this.#closeInternal(), disposeCreated: () => this.#disposeCreated(), diff --git a/Source/Core/execution/runOwned.ts b/Source/Core/execution/runOwned.ts index ce363c73..f8895edd 100644 --- a/Source/Core/execution/runOwned.ts +++ b/Source/Core/execution/runOwned.ts @@ -5,12 +5,13 @@ import { withExecutionBoundary } from './withExecutionBoundary.js'; import type { ArtifactMetadata } from '../reflection/ArtifactMetadata.js'; import type { ClassType } from '../reflection/ClassType.js'; import type { ServiceRegistry } from '../dependencyInjection/ServiceRegistry.js'; +import { createOwnedServiceScope } from '../dependencyInjection/ServiceScope.js'; /** Run an operation and its service cleanup in the same execution boundary. */ export function runOwned(services: ServiceRegistry, metadata: ReadonlyMap | undefined, context: ExecutionContext, callback: () => T | Promise, isSuccess: (value: T) => boolean, fail: (error: unknown, previous?: T) => T): Promise { - const scope = services.createScope(context); + const scope = createOwnedServiceScope(services, context); return withExecutionBoundary(services, metadata, scope, context, async () => { let result: T; try { result = await callback(); } diff --git a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_a_registry_created_scope.ts b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_a_registry_created_scope.ts new file mode 100644 index 00000000..31a30866 --- /dev/null +++ b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_a_registry_created_scope.ts @@ -0,0 +1,30 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { beforeEach, describe, it, should } from 'vitest'; +import { ArcServer, currentContext } from '../../ArcServer.js'; +import { serviceContext } from '../../dependencyInjection/for_ServiceRegistry/given/a_service_lifecycle.js'; + +should(); +describe('when borrowing a registry-created scope', () => { + let observed: ReturnType; + let originalPrincipal: object; + let scopePrincipal: object; + beforeEach(async () => { + const server = new ArcServer({}); + const principal = { id: 'original', isAuthenticated: true, roles: ['Reader'] }; + const scope = server.services.createScope({ ...serviceContext('tenant'), principal }); + originalPrincipal = principal; + scopePrincipal = scope.identity!.principal!; + principal.roles.push('Admin'); + try { observed = await server.runInScope(scope, () => currentContext()); } + finally { await scope.dispose(); await server.dispose(); } + }); + it('should retain the original principal for scope factories', () => { + (scopePrincipal === originalPrincipal).should.equal(true); + }); + it('should expose the creation-time frozen principal to borrowed work', () => { + observed!.principal!.roles.should.deep.equal(['Reader']); + Object.isFrozen(observed!.principal!.roles).should.equal(true); + observed!.tenantId!.should.equal('tenant'); + }); +}); diff --git a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_an_owned_observable_scope.ts b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_an_owned_observable_scope.ts new file mode 100644 index 00000000..768fae12 --- /dev/null +++ b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_an_owned_observable_scope.ts @@ -0,0 +1,33 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { beforeEach, describe, it, should } from 'vitest'; +import { z } from 'zod'; +import { ArcServer } from '../../ArcServer.js'; +import { currentServices } from '../../dependencyInjection/ServiceScope.js'; +import { captureFailure, serviceContext } from '../../dependencyInjection/for_ServiceRegistry/given/a_service_lifecycle.js'; +import { CurrentValueSubject } from '../../queries/observable/CurrentValueSubject.js'; +import { defineObservableQuery } from '../../queries/observable/defineObservableQuery.js'; + +should(); +describe('when borrowing an Arc-owned observable scope', () => { + let failure: unknown; + let callbacks: number; + beforeEach(async () => { + callbacks = 0; + const server = new ArcServer({ observableQueries: [defineObservableQuery({ + name: 'OwnedObservable', schema: z.object({}), observe: async () => { + failure = await captureFailure(server.runInScope(currentServices(), () => { callbacks++; })); + return new CurrentValueSubject('ready'); + } + })] }); + try { + const session = await server.openObservableQuery('OwnedObservable', {}, { ...serviceContext('tenant'), + principal: { id: 'caller', isAuthenticated: true, roles: ['Reader'] } }); + await session.close(); + } finally { await server.dispose(); } + }); + it('should reject borrowing before calling back', () => { + (failure as Error).message.should.contain('unsnapshotable principal'); + callbacks.should.equal(0); + }); +}); diff --git a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_an_owned_query_scope.ts b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_an_owned_query_scope.ts new file mode 100644 index 00000000..dcac9e52 --- /dev/null +++ b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_an_owned_query_scope.ts @@ -0,0 +1,44 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +import { beforeEach, describe, it, should } from 'vitest'; +import { z } from 'zod'; +import { ArcServer } from '../../ArcServer.js'; +import { currentServices } from '../../dependencyInjection/ServiceScope.js'; +import { captureFailure, serviceContext } from '../../dependencyInjection/for_ServiceRegistry/given/a_service_lifecycle.js'; +import { defineQuery } from '../../queries/defineQuery.js'; +import type { Principal } from '../../identity/Principal.js'; + +should(); +describe('when borrowing an Arc-owned query scope', () => { + let failure: unknown; + let callbacks: number; + let traversals: number; + let originalPrincipal: boolean; + let succeeded: boolean; + beforeEach(async () => { + callbacks = 0; + traversals = 0; + const principal = new Proxy({ id: 'caller', isAuthenticated: true, roles: ['Reader'] }, { + getPrototypeOf: () => { traversals++; throw new Error('Principal should not be traversed'); } + }); + const server = new ArcServer({ queries: [defineQuery({ name: 'Owned', schema: z.object({}), + perform: async () => { + const scope = currentServices(); + originalPrincipal = scope.identity?.principal === principal; + failure = await captureFailure(server.runInScope(scope, () => { callbacks++; })); + return 'done'; + } })] }); + try { + succeeded = (await server.performQuery('Owned', {}, { ...serviceContext('tenant'), principal })).isSuccess; + } finally { await server.dispose(); } + }); + it('should reject borrowing before calling back', () => { + (failure as Error).message.should.contain('unsnapshotable principal'); + callbacks.should.equal(0); + succeeded.should.equal(true); + }); + it('should not traverse the principal when creating the owned scope', () => { + traversals.should.equal(0); + originalPrincipal.should.equal(true); + }); +}); diff --git a/Source/Core/queries/observable/ObservableQuerySession.ts b/Source/Core/queries/observable/ObservableQuerySession.ts index 5bb502be..8238c93c 100644 --- a/Source/Core/queries/observable/ObservableQuerySession.ts +++ b/Source/Core/queries/observable/ObservableQuerySession.ts @@ -5,7 +5,7 @@ import type { QueryResult } from '../QueryResult.js'; import { hasFailure, originalFailure } from '../../execution/failureTracking.js'; import { queryResult } from '../createQueryResult.js'; import { requestContext } from '../../execution/RequestContextStore.js'; -import { withServices } from '../../dependencyInjection/ServiceScope.js'; +import { createOwnedServiceScope, withServices } from '../../dependencyInjection/ServiceScope.js'; import type { ObservableSource } from './ObservableSource.js'; import { toEmissions } from './toEmissions.js'; import { ObservableEmissionDecision } from './ObservableEmissionDecision.js'; @@ -33,7 +33,7 @@ export class ObservableQuerySession { private constructor(private readonly config: ObservableSessionConfig) { this.#context = Object.freeze({ ...config.context, principal: clonePrincipal(config.context.principal), signal: AbortSignal.any([config.context.signal, this.#controller.signal]) }); - this.#scope = config.services.createScope(this.#context); + this.#scope = createOwnedServiceScope(config.services, this.#context); this.#subscription = beginSubscription( config.operation.fullyQualifiedName, this.#context.correlationId); } From 089b5c0dcb585d97005c574f855d79fae71a10e3 Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 26 Sep 2026 22:40:36 +0200 Subject: [PATCH 11/11] Explain why an Arc-owned scope cannot be borrowed runInScope reported an unsnapshotable principal for every non-borrowable scope, including Arc-owned request scopes whose principal was never inspected. --- Source/Core/dependencyInjection/ServiceScope.ts | 6 ++++-- .../with_an_owned_observable_scope.ts | 2 +- .../when_borrowing_a_scope/with_an_owned_query_scope.ts | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Source/Core/dependencyInjection/ServiceScope.ts b/Source/Core/dependencyInjection/ServiceScope.ts index de655a47..e8f5ae1e 100644 --- a/Source/Core/dependencyInjection/ServiceScope.ts +++ b/Source/Core/dependencyInjection/ServiceScope.ts @@ -17,7 +17,7 @@ import { ServiceDisposalState } from './ServiceDisposalState.js'; const singletonCapability = Symbol('singleton scope'); const borrowableCapability = Symbol('borrowable scope'); const internals = new WeakMap Promise; disposeCreated: () => Promise; - authority: () => ExecutionContext | undefined; borrowable: () => boolean }>(); + authority: () => ExecutionContext | undefined; borrowable: () => boolean; createdForBorrowing: boolean }>(); const disposal = new AsyncLocalStorage(); /** Package-private shutdown entry points; never methods on the public scope. */ export function createSingletonServiceScope(registry: ServiceRegistry): ServiceScope { @@ -38,6 +38,8 @@ export function serviceScopeRegistry(scope: ServiceScope): ServiceRegistry { ret export function borrowedScopeAuthority(scope: ServiceScope, registry: ServiceRegistry): ExecutionContext { const internal = internals.get(scope); if (!internal || internal.registry !== registry) throw new ServiceDependencyError('Invalid Arc service scope'); + if (!internal.createdForBorrowing) + throw new ServiceDependencyError('Arc service scope was not created by services.createScope and cannot be borrowed'); if (!internal.borrowable()) throw new ServiceDependencyError('Arc service scope has an unsnapshotable principal'); const authority = internal.authority(); if (!authority) throw new ServiceDependencyError('Invalid Arc service scope'); @@ -117,7 +119,7 @@ export class ServiceScope { else registry.admitScope(this); internals.set(this, { registry, close: () => this.#closeInternal(), disposeCreated: () => this.#disposeCreated(), authority: () => !this.#singleton && this.#state === ServiceScopeState.Open ? this.#borrowedAuthority : undefined, - borrowable: () => this.#borrowable }); + borrowable: () => this.#borrowable, createdForBorrowing: capability[0] === borrowableCapability }); } get singleton(): boolean { return this.#singleton; } get registry(): ServiceRegistry { return this.#registry; } diff --git a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_an_owned_observable_scope.ts b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_an_owned_observable_scope.ts index 768fae12..d212afe5 100644 --- a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_an_owned_observable_scope.ts +++ b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_an_owned_observable_scope.ts @@ -27,7 +27,7 @@ describe('when borrowing an Arc-owned observable scope', () => { } finally { await server.dispose(); } }); it('should reject borrowing before calling back', () => { - (failure as Error).message.should.contain('unsnapshotable principal'); + (failure as Error).message.should.contain('was not created by services.createScope'); callbacks.should.equal(0); }); }); diff --git a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_an_owned_query_scope.ts b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_an_owned_query_scope.ts index dcac9e52..410b4626 100644 --- a/Source/Core/for_ArcServer/when_borrowing_a_scope/with_an_owned_query_scope.ts +++ b/Source/Core/for_ArcServer/when_borrowing_a_scope/with_an_owned_query_scope.ts @@ -33,7 +33,7 @@ describe('when borrowing an Arc-owned query scope', () => { } finally { await server.dispose(); } }); it('should reject borrowing before calling back', () => { - (failure as Error).message.should.contain('unsnapshotable principal'); + (failure as Error).message.should.contain('was not created by services.createScope'); callbacks.should.equal(0); succeeded.should.equal(true); });