@fabricorg/platform-host 7.1.2 → 7.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +9 -0
- package/dist/index.cjs +196 -19
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +63 -1
- package/dist/index.d.ts +63 -1
- package/dist/index.js +195 -20
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/index.d.cts
CHANGED
|
@@ -1167,4 +1167,66 @@ interface CreateDurableSagaParentLifecycleOptions<TDb> {
|
|
|
1167
1167
|
*/
|
|
1168
1168
|
declare function createDurableSagaParentLifecycle<TDb>(options: CreateDurableSagaParentLifecycleOptions<TDb>): HostSagaParentLifecycle;
|
|
1169
1169
|
|
|
1170
|
-
|
|
1170
|
+
interface TenantIsolationScope {
|
|
1171
|
+
tenantId: string;
|
|
1172
|
+
spaceId: string;
|
|
1173
|
+
actorId: string;
|
|
1174
|
+
actorType: ActorType;
|
|
1175
|
+
}
|
|
1176
|
+
interface TenantIsolationSubject<TDb = unknown> {
|
|
1177
|
+
host: GovernedActionHost;
|
|
1178
|
+
store: RecoverablePlatformHostStore<TDb>;
|
|
1179
|
+
/** A governed action registered on the host, and parameters it accepts. */
|
|
1180
|
+
actionId: ActionId;
|
|
1181
|
+
parameters?: Record<string, unknown>;
|
|
1182
|
+
/** Two scopes in different tenants. */
|
|
1183
|
+
ours: TenantIsolationScope;
|
|
1184
|
+
theirs: TenantIsolationScope;
|
|
1185
|
+
/**
|
|
1186
|
+
* Optional: a governed read the subject exposes, scoped as the projection
|
|
1187
|
+
* host scopes it. Returns what a viewer in `scope` sees for the view. Every
|
|
1188
|
+
* row must carry the `tenantId` it belongs to, so the check can tell a row
|
|
1189
|
+
* of ours from a row of theirs that merely looks the same; two tenants may
|
|
1190
|
+
* legitimately hold equal-looking rows.
|
|
1191
|
+
*/
|
|
1192
|
+
read?(scope: TenantIsolationScope): Promise<{
|
|
1193
|
+
allowed: boolean;
|
|
1194
|
+
rows: readonly {
|
|
1195
|
+
tenantId: string;
|
|
1196
|
+
}[];
|
|
1197
|
+
}>;
|
|
1198
|
+
/**
|
|
1199
|
+
* Optional: put the host and store back to a known state before a check.
|
|
1200
|
+
* The checks tolerate accumulated state, and each cleans up any row it
|
|
1201
|
+
* creates directly, so this exists for subjects whose store is shared.
|
|
1202
|
+
*/
|
|
1203
|
+
reset?(): Promise<void> | void;
|
|
1204
|
+
}
|
|
1205
|
+
interface TenantIsolationCheck {
|
|
1206
|
+
id: "fabric.tenancy.invocation-scoped-read.v1" | "fabric.tenancy.idempotency-key-scoped.v1" | "fabric.tenancy.events-scoped.v1" | "fabric.tenancy.worker-claim-scoped.v1" | "fabric.tenancy.completion-scoped.v1" | "fabric.tenancy.projection-scoped.v1";
|
|
1207
|
+
run(subject: TenantIsolationSubject): Promise<string[]>;
|
|
1208
|
+
}
|
|
1209
|
+
interface TenantIsolationResult {
|
|
1210
|
+
passed: boolean;
|
|
1211
|
+
checks: Array<{
|
|
1212
|
+
id: TenantIsolationCheck["id"];
|
|
1213
|
+
status: "passed" | "failed" | "skipped";
|
|
1214
|
+
evidence: string[];
|
|
1215
|
+
error?: string;
|
|
1216
|
+
}>;
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1219
|
+
* The suite any deployment of the host must pass before it claims isolation.
|
|
1220
|
+
*
|
|
1221
|
+
* Six checks: an invocation cannot be read, executed, or completed under
|
|
1222
|
+
* another tenant's scope; an idempotency key is a tenant's own, so the same
|
|
1223
|
+
* key in two tenants is two invocations; a tenant's event stream carries no
|
|
1224
|
+
* other tenant's events; a worker scoped to one tenant never claims another's
|
|
1225
|
+
* work; and, when the subject lends a read, a viewer in the other tenant sees
|
|
1226
|
+
* nothing of ours.
|
|
1227
|
+
*/
|
|
1228
|
+
declare function tenantIsolationChecks(): readonly TenantIsolationCheck[];
|
|
1229
|
+
/** Run every check, returning each result rather than throwing on the first. */
|
|
1230
|
+
declare function runTenantIsolationChecks(subject: TenantIsolationSubject): Promise<TenantIsolationResult>;
|
|
1231
|
+
|
|
1232
|
+
export { type ActionApprovalDecision, type ActionAuthorizationInput, type ActionExecutionAuthorizationInput, type ActionExecutionReason, type ActionInvocationPatch, type ActionInvocationRecord, type AdapterInvocationRecord, type AdapterInvocationStatus, type AdapterReconciliationOutcome, type ApprovalDecisionTransitionResult, type ApprovalPlatformHostStore, type AtomicMutationPlatformHostStore, type AuthorizationBinding, type AuthorizationGoverningMoment, type AuthorizationReconciliationOutcome, type BeginApprovalDecisionInput, type ClaimActionInvocationsInput, type ClaimOutboxInput, type CreateActionInvocationInput, type CreateDurableSagaParentLifecycleOptions, type DispatchActionInput, type DispatchActionResult, type EnterpriseEventEnvelope, type EnterpriseEventPublisher, type EventPayloadClassification, type ExecuteActionResult, type ExecuteInvocationOptions, type ExecutionAttestationRecord, type ExternalCompletion, ExternalCompletionError, type ExternalCompletionRefusal, type ExternalReconciliationRecord, type GovernancePlatformHostStore, type GovernedActionHost, type HitlDecisionEvidence, type HostCompensationRecord, type HostSagaParentApprovalInput, type HostSagaParentCancellationInput, type HostSagaParentCompletionInput, type HostSagaParentFailureInput, type HostSagaParentLifecycle, type HostSagaParentProgressInput, type HostSagaParentProgressRecord, type IdempotencyConflict, IdempotencyConflictError, type InvocationProvenance, type InvocationSource, type KnownActionExecutionReason, type ListActionInvocationsInput, MemoryPlatformHostStore, type MemoryPlatformHostTransactionProvider, type NamespacedAttributes, type OutboxEventMetadata, type OutboxPlatformHostStore, type OutboxRecord, type OutboxRelayOptions, type OutboxRelayResult, type OutboxStatus, PARAMETER_DIGEST_ALGORITHM, PLATFORM_HOST_CONTRACT_VERSION, PLATFORM_HOST_HEALTH_CONTRACT_VERSION, PLATFORM_HOST_METRIC_NAMES, type PendingAssetEvent, type PendingExternalCompletion, type PersistedActionApprovalDecision, type PlatformActionDispatcher, type PlatformActionWorkerCycleResult, type PlatformActionWorkerOptions, type PlatformHostAuthorization, type PlatformHostHealthCounts, type PlatformHostHealthOptions, type PlatformHostHealthQuery, type PlatformHostHealthSnapshot, type PlatformHostHealthStatus, type PlatformHostHealthStore, type PlatformHostLifecycleTelemetry, type PlatformHostMetricName, type PlatformHostMetricTelemetry, type PlatformHostMetricType, type PlatformHostMutationTransaction, type PlatformHostOptions, type PlatformHostReadinessReason, type PlatformHostSqlClient, type PlatformHostSqlResult, type PlatformHostStore, type PlatformHostTelemetry, type PlatformHostTelemetryAttribute, type PlatformHostTelemetryEventName, type PlatformHostTelemetryHook, type PlatformHostTelemetryInput, type PlatformHostTelemetryRecord, type PlatformHostWorkerHealthInput, type PolicyEvaluationRecord, type PolicyObligationRecord, type PostgresMigration, PostgresPlatformHostStore, type PostgresPlatformHostTransactionContext, type PostgresPlatformHostTransactionProvider, type ReconcileOverdueCompletionsInput, type RecordedExternalCompletion, type RecoverablePlatformHostStore, type RenewActionInvocationLeaseInput, type SubmitActionInput, type SubmitActionResult, type TenantIsolationCheck, type TenantIsolationResult, type TenantIsolationScope, type TenantIsolationSubject, type UpdateLeasedActionInvocationInput, applyPostgresMigrations, canonicalJson, cloneOutboxRecord, createDurableSagaParentLifecycle, createGovernedActionHost, createStoreBackedActionDispatcher, digestParameters, emitPlatformHostTelemetry, getPlatformHostHealthSnapshot, runOutboxRelayCycle, runPlatformActionWorker, runPlatformActionWorkerCycle, runTenantIsolationChecks, tenantIsolationChecks, toEnterpriseEventEnvelope };
|
package/dist/index.d.ts
CHANGED
|
@@ -1167,4 +1167,66 @@ interface CreateDurableSagaParentLifecycleOptions<TDb> {
|
|
|
1167
1167
|
*/
|
|
1168
1168
|
declare function createDurableSagaParentLifecycle<TDb>(options: CreateDurableSagaParentLifecycleOptions<TDb>): HostSagaParentLifecycle;
|
|
1169
1169
|
|
|
1170
|
-
|
|
1170
|
+
interface TenantIsolationScope {
|
|
1171
|
+
tenantId: string;
|
|
1172
|
+
spaceId: string;
|
|
1173
|
+
actorId: string;
|
|
1174
|
+
actorType: ActorType;
|
|
1175
|
+
}
|
|
1176
|
+
interface TenantIsolationSubject<TDb = unknown> {
|
|
1177
|
+
host: GovernedActionHost;
|
|
1178
|
+
store: RecoverablePlatformHostStore<TDb>;
|
|
1179
|
+
/** A governed action registered on the host, and parameters it accepts. */
|
|
1180
|
+
actionId: ActionId;
|
|
1181
|
+
parameters?: Record<string, unknown>;
|
|
1182
|
+
/** Two scopes in different tenants. */
|
|
1183
|
+
ours: TenantIsolationScope;
|
|
1184
|
+
theirs: TenantIsolationScope;
|
|
1185
|
+
/**
|
|
1186
|
+
* Optional: a governed read the subject exposes, scoped as the projection
|
|
1187
|
+
* host scopes it. Returns what a viewer in `scope` sees for the view. Every
|
|
1188
|
+
* row must carry the `tenantId` it belongs to, so the check can tell a row
|
|
1189
|
+
* of ours from a row of theirs that merely looks the same; two tenants may
|
|
1190
|
+
* legitimately hold equal-looking rows.
|
|
1191
|
+
*/
|
|
1192
|
+
read?(scope: TenantIsolationScope): Promise<{
|
|
1193
|
+
allowed: boolean;
|
|
1194
|
+
rows: readonly {
|
|
1195
|
+
tenantId: string;
|
|
1196
|
+
}[];
|
|
1197
|
+
}>;
|
|
1198
|
+
/**
|
|
1199
|
+
* Optional: put the host and store back to a known state before a check.
|
|
1200
|
+
* The checks tolerate accumulated state, and each cleans up any row it
|
|
1201
|
+
* creates directly, so this exists for subjects whose store is shared.
|
|
1202
|
+
*/
|
|
1203
|
+
reset?(): Promise<void> | void;
|
|
1204
|
+
}
|
|
1205
|
+
interface TenantIsolationCheck {
|
|
1206
|
+
id: "fabric.tenancy.invocation-scoped-read.v1" | "fabric.tenancy.idempotency-key-scoped.v1" | "fabric.tenancy.events-scoped.v1" | "fabric.tenancy.worker-claim-scoped.v1" | "fabric.tenancy.completion-scoped.v1" | "fabric.tenancy.projection-scoped.v1";
|
|
1207
|
+
run(subject: TenantIsolationSubject): Promise<string[]>;
|
|
1208
|
+
}
|
|
1209
|
+
interface TenantIsolationResult {
|
|
1210
|
+
passed: boolean;
|
|
1211
|
+
checks: Array<{
|
|
1212
|
+
id: TenantIsolationCheck["id"];
|
|
1213
|
+
status: "passed" | "failed" | "skipped";
|
|
1214
|
+
evidence: string[];
|
|
1215
|
+
error?: string;
|
|
1216
|
+
}>;
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1219
|
+
* The suite any deployment of the host must pass before it claims isolation.
|
|
1220
|
+
*
|
|
1221
|
+
* Six checks: an invocation cannot be read, executed, or completed under
|
|
1222
|
+
* another tenant's scope; an idempotency key is a tenant's own, so the same
|
|
1223
|
+
* key in two tenants is two invocations; a tenant's event stream carries no
|
|
1224
|
+
* other tenant's events; a worker scoped to one tenant never claims another's
|
|
1225
|
+
* work; and, when the subject lends a read, a viewer in the other tenant sees
|
|
1226
|
+
* nothing of ours.
|
|
1227
|
+
*/
|
|
1228
|
+
declare function tenantIsolationChecks(): readonly TenantIsolationCheck[];
|
|
1229
|
+
/** Run every check, returning each result rather than throwing on the first. */
|
|
1230
|
+
declare function runTenantIsolationChecks(subject: TenantIsolationSubject): Promise<TenantIsolationResult>;
|
|
1231
|
+
|
|
1232
|
+
export { type ActionApprovalDecision, type ActionAuthorizationInput, type ActionExecutionAuthorizationInput, type ActionExecutionReason, type ActionInvocationPatch, type ActionInvocationRecord, type AdapterInvocationRecord, type AdapterInvocationStatus, type AdapterReconciliationOutcome, type ApprovalDecisionTransitionResult, type ApprovalPlatformHostStore, type AtomicMutationPlatformHostStore, type AuthorizationBinding, type AuthorizationGoverningMoment, type AuthorizationReconciliationOutcome, type BeginApprovalDecisionInput, type ClaimActionInvocationsInput, type ClaimOutboxInput, type CreateActionInvocationInput, type CreateDurableSagaParentLifecycleOptions, type DispatchActionInput, type DispatchActionResult, type EnterpriseEventEnvelope, type EnterpriseEventPublisher, type EventPayloadClassification, type ExecuteActionResult, type ExecuteInvocationOptions, type ExecutionAttestationRecord, type ExternalCompletion, ExternalCompletionError, type ExternalCompletionRefusal, type ExternalReconciliationRecord, type GovernancePlatformHostStore, type GovernedActionHost, type HitlDecisionEvidence, type HostCompensationRecord, type HostSagaParentApprovalInput, type HostSagaParentCancellationInput, type HostSagaParentCompletionInput, type HostSagaParentFailureInput, type HostSagaParentLifecycle, type HostSagaParentProgressInput, type HostSagaParentProgressRecord, type IdempotencyConflict, IdempotencyConflictError, type InvocationProvenance, type InvocationSource, type KnownActionExecutionReason, type ListActionInvocationsInput, MemoryPlatformHostStore, type MemoryPlatformHostTransactionProvider, type NamespacedAttributes, type OutboxEventMetadata, type OutboxPlatformHostStore, type OutboxRecord, type OutboxRelayOptions, type OutboxRelayResult, type OutboxStatus, PARAMETER_DIGEST_ALGORITHM, PLATFORM_HOST_CONTRACT_VERSION, PLATFORM_HOST_HEALTH_CONTRACT_VERSION, PLATFORM_HOST_METRIC_NAMES, type PendingAssetEvent, type PendingExternalCompletion, type PersistedActionApprovalDecision, type PlatformActionDispatcher, type PlatformActionWorkerCycleResult, type PlatformActionWorkerOptions, type PlatformHostAuthorization, type PlatformHostHealthCounts, type PlatformHostHealthOptions, type PlatformHostHealthQuery, type PlatformHostHealthSnapshot, type PlatformHostHealthStatus, type PlatformHostHealthStore, type PlatformHostLifecycleTelemetry, type PlatformHostMetricName, type PlatformHostMetricTelemetry, type PlatformHostMetricType, type PlatformHostMutationTransaction, type PlatformHostOptions, type PlatformHostReadinessReason, type PlatformHostSqlClient, type PlatformHostSqlResult, type PlatformHostStore, type PlatformHostTelemetry, type PlatformHostTelemetryAttribute, type PlatformHostTelemetryEventName, type PlatformHostTelemetryHook, type PlatformHostTelemetryInput, type PlatformHostTelemetryRecord, type PlatformHostWorkerHealthInput, type PolicyEvaluationRecord, type PolicyObligationRecord, type PostgresMigration, PostgresPlatformHostStore, type PostgresPlatformHostTransactionContext, type PostgresPlatformHostTransactionProvider, type ReconcileOverdueCompletionsInput, type RecordedExternalCompletion, type RecoverablePlatformHostStore, type RenewActionInvocationLeaseInput, type SubmitActionInput, type SubmitActionResult, type TenantIsolationCheck, type TenantIsolationResult, type TenantIsolationScope, type TenantIsolationSubject, type UpdateLeasedActionInvocationInput, applyPostgresMigrations, canonicalJson, cloneOutboxRecord, createDurableSagaParentLifecycle, createGovernedActionHost, createStoreBackedActionDispatcher, digestParameters, emitPlatformHostTelemetry, getPlatformHostHealthSnapshot, runOutboxRelayCycle, runPlatformActionWorker, runPlatformActionWorkerCycle, runTenantIsolationChecks, tenantIsolationChecks, toEnterpriseEventEnvelope };
|
package/dist/index.js
CHANGED
|
@@ -713,24 +713,24 @@ function createGovernedActionHost(options) {
|
|
|
713
713
|
}
|
|
714
714
|
const action = actionResolver(invocation.actionId);
|
|
715
715
|
if (!action) {
|
|
716
|
-
return
|
|
716
|
+
return fail2(invocation, "failed", `Unknown action: ${invocation.actionId}`);
|
|
717
717
|
}
|
|
718
718
|
if (action.kind === "saga") {
|
|
719
|
-
return
|
|
719
|
+
return fail2(
|
|
720
720
|
invocation,
|
|
721
721
|
"failed",
|
|
722
722
|
`Saga action ${invocation.actionId} requires a durable PlatformActionDispatcher and cannot execute as an atomic Host action.`
|
|
723
723
|
);
|
|
724
724
|
}
|
|
725
725
|
if (resumingRunningInvocation && invocation.attemptCount === 0 && !invocation.leaseOwner && !action.idempotent) {
|
|
726
|
-
return
|
|
726
|
+
return fail2(
|
|
727
727
|
invocation,
|
|
728
728
|
"failed",
|
|
729
729
|
`Interrupted inline action ${invocation.actionId} is not declared idempotent; manual reconciliation is required.`
|
|
730
730
|
);
|
|
731
731
|
}
|
|
732
732
|
if (invocation.attemptCount > 1 && !action.idempotent) {
|
|
733
|
-
return
|
|
733
|
+
return fail2(
|
|
734
734
|
invocation,
|
|
735
735
|
"failed",
|
|
736
736
|
`Interrupted action ${invocation.actionId} is not declared idempotent; manual reconciliation is required.`
|
|
@@ -754,12 +754,12 @@ function createGovernedActionHost(options) {
|
|
|
754
754
|
const parsed = action.schema.safeParse(invocation.parameters);
|
|
755
755
|
if (!parsed.success) {
|
|
756
756
|
const message = parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
|
|
757
|
-
return
|
|
757
|
+
return fail2(invocation, "validation_failed", message);
|
|
758
758
|
}
|
|
759
759
|
if (options.resolveMutationGovernance && !invocation.mutationFootprint) {
|
|
760
760
|
const governanceStore2 = asGovernanceStore(options.store);
|
|
761
761
|
if (!governanceStore2) {
|
|
762
|
-
return
|
|
762
|
+
return fail2(invocation, "failed", "The configured mutation-governance resolver requires a governance-capable store.");
|
|
763
763
|
}
|
|
764
764
|
const context = await options.resolveMutationGovernance({
|
|
765
765
|
actionInvocationId,
|
|
@@ -777,7 +777,7 @@ function createGovernedActionHost(options) {
|
|
|
777
777
|
const hitlParameters = isRecord(parsed.data) ? parsed.data : invocation.parameters;
|
|
778
778
|
const approvalStore = asApprovalStore(options.store);
|
|
779
779
|
if (!approvalStore) {
|
|
780
|
-
return
|
|
780
|
+
return fail2(
|
|
781
781
|
invocation,
|
|
782
782
|
"failed",
|
|
783
783
|
"The configured HITL evaluator requires a store with approval persistence support."
|
|
@@ -808,7 +808,7 @@ function createGovernedActionHost(options) {
|
|
|
808
808
|
);
|
|
809
809
|
}
|
|
810
810
|
if (invocation.hitlRoute === "rejected") {
|
|
811
|
-
return
|
|
811
|
+
return fail2(
|
|
812
812
|
invocation,
|
|
813
813
|
"failed",
|
|
814
814
|
`HITL rejected: ${invocation.hitlReason ?? "no reason provided"}`
|
|
@@ -827,7 +827,7 @@ function createGovernedActionHost(options) {
|
|
|
827
827
|
const authorizationInput = toAuthorizationInput(action, invocation);
|
|
828
828
|
const executionReason = executionReasonOverride ?? (invocation.attemptCount > 1 || resumingRunningInvocation && invocation.attemptCount === 0 ? "recovery" : invocation.executionReason ?? "initial");
|
|
829
829
|
if (!await options.authorization.checkEntitlement(authorizationInput)) {
|
|
830
|
-
return
|
|
830
|
+
return fail2(
|
|
831
831
|
invocation,
|
|
832
832
|
"failed",
|
|
833
833
|
`Module "${action.namespace}" is no longer enabled for tenant ${tenantId}`
|
|
@@ -867,7 +867,7 @@ function createGovernedActionHost(options) {
|
|
|
867
867
|
}) : await options.authorization.authorize(authorizationInput);
|
|
868
868
|
if (!executionAuthorized) {
|
|
869
869
|
if (!action.execution) {
|
|
870
|
-
return
|
|
870
|
+
return fail2(invocation, "failed", `Actor ${invocation.actorId} is not authorized to execute action ${action.actionId}`);
|
|
871
871
|
}
|
|
872
872
|
const reconciliation = {
|
|
873
873
|
kind: "authorization_denied",
|
|
@@ -917,12 +917,12 @@ function createGovernedActionHost(options) {
|
|
|
917
917
|
subjectId: actionInvocationId,
|
|
918
918
|
payload: { actionId: action.actionId, policyId: aggregate.policyId, reason }
|
|
919
919
|
}, `compliance:${aggregate.policyId}`);
|
|
920
|
-
return
|
|
920
|
+
return fail2(invocation, "blocked_by_policy", reason);
|
|
921
921
|
}
|
|
922
922
|
if (options.resolvePolicyObligations) {
|
|
923
923
|
const governanceStore2 = asGovernanceStore(options.store);
|
|
924
924
|
if (!governanceStore2) {
|
|
925
|
-
return
|
|
925
|
+
return fail2(invocation, "failed", "Policy obligations require a governance-capable store.");
|
|
926
926
|
}
|
|
927
927
|
const existing = await governanceStore2.listPolicyObligations(actionInvocationId, tenantId, spaceId);
|
|
928
928
|
if (existing.length === 0) {
|
|
@@ -1037,7 +1037,7 @@ function createGovernedActionHost(options) {
|
|
|
1037
1037
|
data = executed.data;
|
|
1038
1038
|
domainEvents = executed.domainEvents;
|
|
1039
1039
|
} catch (error) {
|
|
1040
|
-
return
|
|
1040
|
+
return fail2(invocation, "failed", errorMessage(error));
|
|
1041
1041
|
}
|
|
1042
1042
|
let pendingHandoff = invocation.pendingCompletion;
|
|
1043
1043
|
for (const [stepIndex, step] of (action.adapterSteps ?? []).entries()) {
|
|
@@ -1217,7 +1217,7 @@ function createGovernedActionHost(options) {
|
|
|
1217
1217
|
subjectId: adapterEventSubject.subjectId,
|
|
1218
1218
|
payload: { adapterType: step.adapterType, operation: step.operation }
|
|
1219
1219
|
}, `adapter:${stepIndex}:failed`);
|
|
1220
|
-
return
|
|
1220
|
+
return fail2(invocation, "failed", result2.error ?? "Adapter failed");
|
|
1221
1221
|
}
|
|
1222
1222
|
const resultRecord = result2;
|
|
1223
1223
|
const output = isRecord(resultRecord.output) ? resultRecord.output : Object.fromEntries(
|
|
@@ -1321,7 +1321,7 @@ function createGovernedActionHost(options) {
|
|
|
1321
1321
|
error: message,
|
|
1322
1322
|
updatedAt: now()
|
|
1323
1323
|
});
|
|
1324
|
-
return
|
|
1324
|
+
return fail2(invocation, "failed", message);
|
|
1325
1325
|
} finally {
|
|
1326
1326
|
if (adapterDeadlineTimer) clearTimeout(adapterDeadlineTimer);
|
|
1327
1327
|
}
|
|
@@ -1343,7 +1343,7 @@ function createGovernedActionHost(options) {
|
|
|
1343
1343
|
}
|
|
1344
1344
|
return withConsistency({ actionInvocationId, status: "reconciliation_required", error, pendingCompletion: pendingHandoff }, action.actionId);
|
|
1345
1345
|
}
|
|
1346
|
-
return
|
|
1346
|
+
return fail2(invocation, "failed", message);
|
|
1347
1347
|
}
|
|
1348
1348
|
}
|
|
1349
1349
|
const result = withoutPrivateHostFields(data, eventResultFields);
|
|
@@ -1416,7 +1416,7 @@ function createGovernedActionHost(options) {
|
|
|
1416
1416
|
};
|
|
1417
1417
|
} catch (error) {
|
|
1418
1418
|
if (error instanceof RecoverableFinalizationError) throw error;
|
|
1419
|
-
return
|
|
1419
|
+
return fail2(invocation, "failed", errorMessage(error));
|
|
1420
1420
|
}
|
|
1421
1421
|
}
|
|
1422
1422
|
async function resumeApprovedInvocation(actionInvocationId, tenantId, spaceId, decision) {
|
|
@@ -1435,7 +1435,7 @@ function createGovernedActionHost(options) {
|
|
|
1435
1435
|
};
|
|
1436
1436
|
}
|
|
1437
1437
|
const action = actionResolver(invocation.actionId);
|
|
1438
|
-
if (!action) return
|
|
1438
|
+
if (!action) return fail2(invocation, "failed", `Unknown action: ${invocation.actionId}`);
|
|
1439
1439
|
const approvalAuthorizationInput = toAuthorizationInput(action, {
|
|
1440
1440
|
tenantId,
|
|
1441
1441
|
spaceId,
|
|
@@ -1588,7 +1588,7 @@ function createGovernedActionHost(options) {
|
|
|
1588
1588
|
await (transaction ?? options.store).appendEvent(envelope);
|
|
1589
1589
|
}
|
|
1590
1590
|
}
|
|
1591
|
-
async function
|
|
1591
|
+
async function fail2(invocation, status, error) {
|
|
1592
1592
|
await persistInvocation(invocation, { status, error });
|
|
1593
1593
|
return {
|
|
1594
1594
|
actionInvocationId: invocation.id,
|
|
@@ -3562,6 +3562,181 @@ function createDurableSagaParentLifecycle(options) {
|
|
|
3562
3562
|
};
|
|
3563
3563
|
}
|
|
3564
3564
|
|
|
3565
|
-
|
|
3565
|
+
// src/tenant-isolation.ts
|
|
3566
|
+
function fail(message) {
|
|
3567
|
+
throw new Error(message);
|
|
3568
|
+
}
|
|
3569
|
+
function submitInput(subject, scope, idempotencyKey) {
|
|
3570
|
+
return {
|
|
3571
|
+
actionId: subject.actionId,
|
|
3572
|
+
parameters: subject.parameters ?? {},
|
|
3573
|
+
tenantId: scope.tenantId,
|
|
3574
|
+
spaceId: scope.spaceId,
|
|
3575
|
+
actorId: scope.actorId,
|
|
3576
|
+
actorType: scope.actorType,
|
|
3577
|
+
idempotencyKey
|
|
3578
|
+
};
|
|
3579
|
+
}
|
|
3580
|
+
function tenantIsolationChecks() {
|
|
3581
|
+
return [
|
|
3582
|
+
{
|
|
3583
|
+
id: "fabric.tenancy.invocation-scoped-read.v1",
|
|
3584
|
+
async run(subject) {
|
|
3585
|
+
await subject.reset?.();
|
|
3586
|
+
if (subject.ours.tenantId === subject.theirs.tenantId) fail("the two scopes must be in different tenants");
|
|
3587
|
+
const ours = await subject.host.submitAction(submitInput(subject, subject.ours, "iso:read"));
|
|
3588
|
+
const crossRead = await subject.store.getActionInvocation(ours.actionInvocationId, subject.theirs.tenantId, subject.theirs.spaceId);
|
|
3589
|
+
if (crossRead !== void 0) fail("an invocation was readable under another tenant's scope");
|
|
3590
|
+
const shape = (record) => JSON.stringify({ status: record?.status, result: record?.result, error: record?.error, attemptCount: record?.attemptCount, updatedAt: record?.updatedAt });
|
|
3591
|
+
const before = await subject.store.getActionInvocation(ours.actionInvocationId, subject.ours.tenantId, subject.ours.spaceId);
|
|
3592
|
+
if (!before) fail("our own invocation was not readable under our scope, so the check proves nothing");
|
|
3593
|
+
const untouched = shape(before);
|
|
3594
|
+
let crossExecute;
|
|
3595
|
+
try {
|
|
3596
|
+
crossExecute = await subject.host.executeInvocation(ours.actionInvocationId, subject.theirs.tenantId, subject.theirs.spaceId);
|
|
3597
|
+
} catch {
|
|
3598
|
+
crossExecute = void 0;
|
|
3599
|
+
}
|
|
3600
|
+
if (crossExecute !== void 0) fail("an invocation was executable under another tenant's scope");
|
|
3601
|
+
const after = await subject.store.getActionInvocation(ours.actionInvocationId, subject.ours.tenantId, subject.ours.spaceId);
|
|
3602
|
+
if (untouched !== shape(after)) fail("an execution attempted under another tenant's scope changed our invocation");
|
|
3603
|
+
return ["invocation readable under our scope, invisible and untouched under the other tenant's"];
|
|
3604
|
+
}
|
|
3605
|
+
},
|
|
3606
|
+
{
|
|
3607
|
+
id: "fabric.tenancy.idempotency-key-scoped.v1",
|
|
3608
|
+
async run(subject) {
|
|
3609
|
+
await subject.reset?.();
|
|
3610
|
+
const ours = await subject.host.submitAction(submitInput(subject, subject.ours, "iso:shared-key"));
|
|
3611
|
+
const again = await subject.host.submitAction(submitInput(subject, subject.ours, "iso:shared-key"));
|
|
3612
|
+
if (ours.actionInvocationId !== again.actionInvocationId) fail("the same idempotency key in one tenant produced two invocations, so the key is not doing anything and the check proves nothing");
|
|
3613
|
+
const theirs = await subject.host.submitAction(submitInput(subject, subject.theirs, "iso:shared-key"));
|
|
3614
|
+
if (ours.actionInvocationId === theirs.actionInvocationId) fail("the same idempotency key in two tenants resolved to one invocation");
|
|
3615
|
+
return ["one idempotency key: one invocation within a tenant, two across tenants"];
|
|
3616
|
+
}
|
|
3617
|
+
},
|
|
3618
|
+
{
|
|
3619
|
+
id: "fabric.tenancy.events-scoped.v1",
|
|
3620
|
+
async run(subject) {
|
|
3621
|
+
await subject.reset?.();
|
|
3622
|
+
const ours = await subject.host.submitAction(submitInput(subject, subject.ours, "iso:events"));
|
|
3623
|
+
const theirEvents = await subject.store.listEvents(subject.theirs.tenantId, subject.theirs.spaceId);
|
|
3624
|
+
const leaked = theirEvents.filter((event) => event.tenantId === subject.ours.tenantId || event.actionInvocationId === ours.actionInvocationId);
|
|
3625
|
+
if (leaked.length > 0) fail(`${leaked.length} of our events appeared in the other tenant's stream`);
|
|
3626
|
+
const ourEvents = await subject.store.listEvents(subject.ours.tenantId, subject.ours.spaceId);
|
|
3627
|
+
if (!ourEvents.some((event) => event.actionInvocationId === ours.actionInvocationId)) fail("our own stream does not carry the event we just caused, so the check proves nothing");
|
|
3628
|
+
if (ourEvents.some((event) => event.tenantId !== subject.ours.tenantId)) fail("another tenant's event appeared in our stream");
|
|
3629
|
+
return ["event streams carry their own tenant's events and only those"];
|
|
3630
|
+
}
|
|
3631
|
+
},
|
|
3632
|
+
{
|
|
3633
|
+
id: "fabric.tenancy.worker-claim-scoped.v1",
|
|
3634
|
+
async run(subject) {
|
|
3635
|
+
await subject.reset?.();
|
|
3636
|
+
const probeSpace = `iso-probe-${Date.now()}`;
|
|
3637
|
+
const created = await subject.store.createActionInvocation({
|
|
3638
|
+
id: `iso_pending_${Date.now()}`,
|
|
3639
|
+
tenantId: subject.ours.tenantId,
|
|
3640
|
+
spaceId: probeSpace,
|
|
3641
|
+
actionId: subject.actionId,
|
|
3642
|
+
actionVersion: 1,
|
|
3643
|
+
actorId: subject.ours.actorId,
|
|
3644
|
+
actorType: subject.ours.actorType,
|
|
3645
|
+
status: "pending",
|
|
3646
|
+
parameters: subject.parameters ?? {},
|
|
3647
|
+
result: {},
|
|
3648
|
+
correlationId: `iso-corr-${Date.now()}`
|
|
3649
|
+
});
|
|
3650
|
+
try {
|
|
3651
|
+
const theirs = await subject.store.claimActionInvocations({
|
|
3652
|
+
workerId: "iso-worker",
|
|
3653
|
+
leaseDurationMs: 1e3,
|
|
3654
|
+
limit: 1e3,
|
|
3655
|
+
tenantId: subject.theirs.tenantId,
|
|
3656
|
+
spaceId: probeSpace
|
|
3657
|
+
});
|
|
3658
|
+
if (theirs.some((row) => row.id === created.id)) fail("a worker scoped to another tenant claimed our pending invocation");
|
|
3659
|
+
const ours = await subject.store.claimActionInvocations({
|
|
3660
|
+
workerId: "iso-worker",
|
|
3661
|
+
leaseDurationMs: 1e3,
|
|
3662
|
+
limit: 1e3,
|
|
3663
|
+
tenantId: subject.ours.tenantId,
|
|
3664
|
+
spaceId: probeSpace
|
|
3665
|
+
});
|
|
3666
|
+
if (!ours.some((row) => row.id === created.id)) fail("a worker scoped to our own tenant did not claim our pending invocation, so the check proves nothing");
|
|
3667
|
+
} finally {
|
|
3668
|
+
await subject.store.updateActionInvocation(created.id, subject.ours.tenantId, probeSpace, {
|
|
3669
|
+
status: "failed",
|
|
3670
|
+
error: "tenant isolation probe; never executed"
|
|
3671
|
+
});
|
|
3672
|
+
}
|
|
3673
|
+
return ["tenant-scoped claim takes our work and leaves other tenants' alone"];
|
|
3674
|
+
}
|
|
3675
|
+
},
|
|
3676
|
+
{
|
|
3677
|
+
id: "fabric.tenancy.completion-scoped.v1",
|
|
3678
|
+
async run(subject) {
|
|
3679
|
+
await subject.reset?.();
|
|
3680
|
+
const ours = await subject.host.submitAction(submitInput(subject, subject.ours, "iso:complete"));
|
|
3681
|
+
let outcome;
|
|
3682
|
+
try {
|
|
3683
|
+
await subject.host.completeExternalInvocation(ours.actionInvocationId, subject.theirs.tenantId, subject.theirs.spaceId, {
|
|
3684
|
+
externalReference: "iso-ref",
|
|
3685
|
+
outcome: "completed",
|
|
3686
|
+
observedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3687
|
+
});
|
|
3688
|
+
outcome = "accepted";
|
|
3689
|
+
} catch (error) {
|
|
3690
|
+
const refusal = error.refusal;
|
|
3691
|
+
outcome = refusal ?? "threw";
|
|
3692
|
+
}
|
|
3693
|
+
if (outcome !== "not_found") fail(`a completion under another tenant's scope was ${outcome}; it must be refused as not_found`);
|
|
3694
|
+
let mine;
|
|
3695
|
+
try {
|
|
3696
|
+
await subject.host.completeExternalInvocation(ours.actionInvocationId, subject.ours.tenantId, subject.ours.spaceId, {
|
|
3697
|
+
externalReference: "iso-ref",
|
|
3698
|
+
outcome: "completed",
|
|
3699
|
+
observedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3700
|
+
});
|
|
3701
|
+
mine = "accepted";
|
|
3702
|
+
} catch (error) {
|
|
3703
|
+
mine = error.refusal ?? "threw";
|
|
3704
|
+
}
|
|
3705
|
+
if (mine === "not_found") fail("a completion under our own scope was refused as not_found, so the check proves nothing");
|
|
3706
|
+
return ["completion under the other tenant's scope refused as not_found; found under ours"];
|
|
3707
|
+
}
|
|
3708
|
+
},
|
|
3709
|
+
{
|
|
3710
|
+
id: "fabric.tenancy.projection-scoped.v1",
|
|
3711
|
+
async run(subject) {
|
|
3712
|
+
if (!subject.read) return ["skipped: subject lends no read"];
|
|
3713
|
+
await subject.reset?.();
|
|
3714
|
+
await subject.host.submitAction(submitInput(subject, subject.ours, "iso:projection"));
|
|
3715
|
+
const ours = await subject.read(subject.ours);
|
|
3716
|
+
const theirs = await subject.read(subject.theirs);
|
|
3717
|
+
if (!ours.allowed) fail("our own viewer was denied the read");
|
|
3718
|
+
if (ours.rows.length === 0) fail("our own viewer saw nothing, so the check proves nothing");
|
|
3719
|
+
if (ours.rows.some((row) => row?.tenantId !== subject.ours.tenantId)) fail("our own viewer saw rows that are not ours, or rows carrying no tenant");
|
|
3720
|
+
const leaked = theirs.allowed ? theirs.rows.filter((row) => row?.tenantId === subject.ours.tenantId) : [];
|
|
3721
|
+
if (leaked.length > 0) fail(`a viewer in the other tenant saw ${leaked.length} of our rows`);
|
|
3722
|
+
return [theirs.allowed ? "other tenant's viewer sees none of our rows" : "other tenant's viewer is denied"];
|
|
3723
|
+
}
|
|
3724
|
+
}
|
|
3725
|
+
];
|
|
3726
|
+
}
|
|
3727
|
+
async function runTenantIsolationChecks(subject) {
|
|
3728
|
+
const checks = [];
|
|
3729
|
+
for (const check of tenantIsolationChecks()) {
|
|
3730
|
+
try {
|
|
3731
|
+
const evidence = await check.run(subject);
|
|
3732
|
+
checks.push({ id: check.id, status: evidence[0]?.startsWith("skipped") ? "skipped" : "passed", evidence });
|
|
3733
|
+
} catch (error) {
|
|
3734
|
+
checks.push({ id: check.id, status: "failed", evidence: [], error: error instanceof Error ? error.message : String(error) });
|
|
3735
|
+
}
|
|
3736
|
+
}
|
|
3737
|
+
return { passed: checks.every((check) => check.status !== "failed"), checks };
|
|
3738
|
+
}
|
|
3739
|
+
|
|
3740
|
+
export { ExternalCompletionError, IdempotencyConflictError, MemoryPlatformHostStore, PARAMETER_DIGEST_ALGORITHM, PLATFORM_HOST_CONTRACT_VERSION, PLATFORM_HOST_HEALTH_CONTRACT_VERSION, PLATFORM_HOST_METRIC_NAMES, PostgresPlatformHostStore, applyPostgresMigrations, canonicalJson, cloneOutboxRecord, createDurableSagaParentLifecycle, createGovernedActionHost, createStoreBackedActionDispatcher, digestParameters, emitPlatformHostTelemetry, getPlatformHostHealthSnapshot, runOutboxRelayCycle, runPlatformActionWorker, runPlatformActionWorkerCycle, runTenantIsolationChecks, tenantIsolationChecks, toEnterpriseEventEnvelope };
|
|
3566
3741
|
//# sourceMappingURL=index.js.map
|
|
3567
3742
|
//# sourceMappingURL=index.js.map
|