first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,84 @@
# `tenant/cross-tenant`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Design doc §9.1 (final paragraph) requires that a saga step whose
target tenant differs from the saga initiator's tenant be gated by
the `saga.cross-tenant` capability on the owner plugin. Without the
capability the orchestrator MUST refuse dispatch with
`SAGA.CROSS_TENANT_DENIED` — quietly running the step is a tenant
isolation violation.
This module exports the canonical capability id and two helpers the
orchestrator (and any saga aware middleware) funnels every dispatch
through. The check is pure and side-effect-free so it can be applied
at registry register time, at orchestrator start time, and at every
step dispatch — three checkpoints that catch wiring drift at three
levels of granularity.
## Features
- `CROSS_TENANT_CAPABILITY` — the literal `"saga.cross-tenant"`
capability id, pinned in the `@suaveplan/api-types` capabilities
table.
- `hasCrossTenantCapability(owner)` — predicate for non-throwing
branches (e.g. when computing UI affordances).
- `assertCrossTenantAllowed(owner, initiator, target)` — throws
`SagaError(SAGA.CROSS_TENANT_DENIED)` when the gate refuses; no-op
otherwise.
## Usage: Basic
```ts
import {
assertCrossTenantAllowed,
CROSS_TENANT_CAPABILITY,
} from "@suaveplan/api-saga/tenant/cross-tenant";
assertCrossTenantAllowed(owner, "acme", "acme"); // same-tenant — ok
assertCrossTenantAllowed(owner, "acme", "globex"); // throws unless owner declares capability
```
## Usage: Advanced
Compose the gate with the cross-cutting capability checker used
elsewhere in the host — `hasCrossTenantCapability` is non-throwing so
it composes inside conditional pipelines:
```ts
import { hasCrossTenantCapability } from "@suaveplan/api-saga";
const canDispatch =
initiatorTenantId === targetTenantId ||
hasCrossTenantCapability(owner);
if (!canDispatch) {
metrics.crossTenantDeniedTotal.add(1, {
saga: owner.saga.name,
owner: owner.pluginName,
});
throw new SagaError(SAGA_CODES.CROSS_TENANT_DENIED, "denied");
}
```
## API Reference
- `CROSS_TENANT_CAPABILITY: "saga.cross-tenant"` — literal constant.
- `hasCrossTenantCapability(owner: RegisteredSagaOwner): boolean`.
- `assertCrossTenantAllowed(owner, initiatorTenantId,
targetTenantId): void` — no-op on accept, throws
`SAGA.CROSS_TENANT_DENIED` on refuse.
## Implementation Notes
The capability is owner-scoped rather than step-scoped because the
owner declares both. Per-step opt-outs would require manifest changes
to `sagas.owns[].steps[]` and threading a per-step capability list
through the orchestrator — a strictly larger surface than the design
doc calls for. If a future change needs finer granularity, the
registry's `RegisteredSagaOwner.saga.capabilities` shape is the
forward-compatible extension point.