@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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # @fabricorg/platform-host
2
2
 
3
+ ## 7.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 1b81b20: Publish with real build provenance, and name the repository each attestation points at. `pnpm publish` accepts `--provenance` and drops it, so every release until now was unattested while the release document claimed otherwise. The pipeline now packs with pnpm and uploads the tarball with `npm publish --provenance`, and every package declares `repository`, `homepage`, and `bugs`.
8
+
9
+ ## 7.2.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 715e920: `tenantIsolationChecks()` and `runTenantIsolationChecks(subject)`: six checks proving that scope is a key on every read, write, claim, and completion. An invocation cannot be read, executed, or completed by callback under another tenant's scope; the same idempotency key in two tenants is two invocations; a tenant's event stream carries no other tenant's events; a tenant-scoped worker claims nothing of another's; and, when the subject lends a governed read, a viewer in the other tenant sees none of ours. Each check carries a positive control, so a store or host that does nothing fails it, and each has a teeth test against the flaw it names. The claim check uses a probe space of its own and retires its probe row, so a backlog cannot hide a tenant-ignoring claim and a live worker never runs the probe. Rows a lent read returns must carry their `tenantId`.
14
+
3
15
  ## 7.1.2
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -300,6 +300,15 @@ migration 3 adds the `pending_completion`, `external_completion`, and `completio
300
300
  a partial index on the due instant the overdue sweep uses; the due instant is a real column because a
301
301
  cast in an index expression is not immutable and PostgreSQL refuses it.
302
302
 
303
+ ## Tenant isolation
304
+
305
+ `tenantIsolationChecks()` and `runTenantIsolationChecks(subject)` prove that scope is a key on every
306
+ read, write, claim, and completion: an invocation is invisible, inert, and uncompletable under another
307
+ tenant's scope; the same idempotency key in two tenants is two invocations; event streams carry only
308
+ their own tenant's events; a tenant-scoped worker claims nothing of another's; and a governed read
309
+ the subject lends shows the other tenant none of ours. The subject supplies a host, a recoverable
310
+ store, a registered action, and two scopes in different tenants.
311
+
303
312
  ## Runtime observability and health
304
313
 
305
314
  Platform Host exposes vendor-neutral lifecycle telemetry through the optional `telemetry` sink on
package/dist/index.cjs CHANGED
@@ -715,24 +715,24 @@ function createGovernedActionHost(options) {
715
715
  }
716
716
  const action = actionResolver(invocation.actionId);
717
717
  if (!action) {
718
- return fail(invocation, "failed", `Unknown action: ${invocation.actionId}`);
718
+ return fail2(invocation, "failed", `Unknown action: ${invocation.actionId}`);
719
719
  }
720
720
  if (action.kind === "saga") {
721
- return fail(
721
+ return fail2(
722
722
  invocation,
723
723
  "failed",
724
724
  `Saga action ${invocation.actionId} requires a durable PlatformActionDispatcher and cannot execute as an atomic Host action.`
725
725
  );
726
726
  }
727
727
  if (resumingRunningInvocation && invocation.attemptCount === 0 && !invocation.leaseOwner && !action.idempotent) {
728
- return fail(
728
+ return fail2(
729
729
  invocation,
730
730
  "failed",
731
731
  `Interrupted inline action ${invocation.actionId} is not declared idempotent; manual reconciliation is required.`
732
732
  );
733
733
  }
734
734
  if (invocation.attemptCount > 1 && !action.idempotent) {
735
- return fail(
735
+ return fail2(
736
736
  invocation,
737
737
  "failed",
738
738
  `Interrupted action ${invocation.actionId} is not declared idempotent; manual reconciliation is required.`
@@ -756,12 +756,12 @@ function createGovernedActionHost(options) {
756
756
  const parsed = action.schema.safeParse(invocation.parameters);
757
757
  if (!parsed.success) {
758
758
  const message = parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
759
- return fail(invocation, "validation_failed", message);
759
+ return fail2(invocation, "validation_failed", message);
760
760
  }
761
761
  if (options.resolveMutationGovernance && !invocation.mutationFootprint) {
762
762
  const governanceStore2 = asGovernanceStore(options.store);
763
763
  if (!governanceStore2) {
764
- return fail(invocation, "failed", "The configured mutation-governance resolver requires a governance-capable store.");
764
+ return fail2(invocation, "failed", "The configured mutation-governance resolver requires a governance-capable store.");
765
765
  }
766
766
  const context = await options.resolveMutationGovernance({
767
767
  actionInvocationId,
@@ -779,7 +779,7 @@ function createGovernedActionHost(options) {
779
779
  const hitlParameters = isRecord(parsed.data) ? parsed.data : invocation.parameters;
780
780
  const approvalStore = asApprovalStore(options.store);
781
781
  if (!approvalStore) {
782
- return fail(
782
+ return fail2(
783
783
  invocation,
784
784
  "failed",
785
785
  "The configured HITL evaluator requires a store with approval persistence support."
@@ -810,7 +810,7 @@ function createGovernedActionHost(options) {
810
810
  );
811
811
  }
812
812
  if (invocation.hitlRoute === "rejected") {
813
- return fail(
813
+ return fail2(
814
814
  invocation,
815
815
  "failed",
816
816
  `HITL rejected: ${invocation.hitlReason ?? "no reason provided"}`
@@ -829,7 +829,7 @@ function createGovernedActionHost(options) {
829
829
  const authorizationInput = toAuthorizationInput(action, invocation);
830
830
  const executionReason = executionReasonOverride ?? (invocation.attemptCount > 1 || resumingRunningInvocation && invocation.attemptCount === 0 ? "recovery" : invocation.executionReason ?? "initial");
831
831
  if (!await options.authorization.checkEntitlement(authorizationInput)) {
832
- return fail(
832
+ return fail2(
833
833
  invocation,
834
834
  "failed",
835
835
  `Module "${action.namespace}" is no longer enabled for tenant ${tenantId}`
@@ -869,7 +869,7 @@ function createGovernedActionHost(options) {
869
869
  }) : await options.authorization.authorize(authorizationInput);
870
870
  if (!executionAuthorized) {
871
871
  if (!action.execution) {
872
- return fail(invocation, "failed", `Actor ${invocation.actorId} is not authorized to execute action ${action.actionId}`);
872
+ return fail2(invocation, "failed", `Actor ${invocation.actorId} is not authorized to execute action ${action.actionId}`);
873
873
  }
874
874
  const reconciliation = {
875
875
  kind: "authorization_denied",
@@ -919,12 +919,12 @@ function createGovernedActionHost(options) {
919
919
  subjectId: actionInvocationId,
920
920
  payload: { actionId: action.actionId, policyId: aggregate.policyId, reason }
921
921
  }, `compliance:${aggregate.policyId}`);
922
- return fail(invocation, "blocked_by_policy", reason);
922
+ return fail2(invocation, "blocked_by_policy", reason);
923
923
  }
924
924
  if (options.resolvePolicyObligations) {
925
925
  const governanceStore2 = asGovernanceStore(options.store);
926
926
  if (!governanceStore2) {
927
- return fail(invocation, "failed", "Policy obligations require a governance-capable store.");
927
+ return fail2(invocation, "failed", "Policy obligations require a governance-capable store.");
928
928
  }
929
929
  const existing = await governanceStore2.listPolicyObligations(actionInvocationId, tenantId, spaceId);
930
930
  if (existing.length === 0) {
@@ -1039,7 +1039,7 @@ function createGovernedActionHost(options) {
1039
1039
  data = executed.data;
1040
1040
  domainEvents = executed.domainEvents;
1041
1041
  } catch (error) {
1042
- return fail(invocation, "failed", errorMessage(error));
1042
+ return fail2(invocation, "failed", errorMessage(error));
1043
1043
  }
1044
1044
  let pendingHandoff = invocation.pendingCompletion;
1045
1045
  for (const [stepIndex, step] of (action.adapterSteps ?? []).entries()) {
@@ -1219,7 +1219,7 @@ function createGovernedActionHost(options) {
1219
1219
  subjectId: adapterEventSubject.subjectId,
1220
1220
  payload: { adapterType: step.adapterType, operation: step.operation }
1221
1221
  }, `adapter:${stepIndex}:failed`);
1222
- return fail(invocation, "failed", result2.error ?? "Adapter failed");
1222
+ return fail2(invocation, "failed", result2.error ?? "Adapter failed");
1223
1223
  }
1224
1224
  const resultRecord = result2;
1225
1225
  const output = isRecord(resultRecord.output) ? resultRecord.output : Object.fromEntries(
@@ -1323,7 +1323,7 @@ function createGovernedActionHost(options) {
1323
1323
  error: message,
1324
1324
  updatedAt: now()
1325
1325
  });
1326
- return fail(invocation, "failed", message);
1326
+ return fail2(invocation, "failed", message);
1327
1327
  } finally {
1328
1328
  if (adapterDeadlineTimer) clearTimeout(adapterDeadlineTimer);
1329
1329
  }
@@ -1345,7 +1345,7 @@ function createGovernedActionHost(options) {
1345
1345
  }
1346
1346
  return withConsistency({ actionInvocationId, status: "reconciliation_required", error, pendingCompletion: pendingHandoff }, action.actionId);
1347
1347
  }
1348
- return fail(invocation, "failed", message);
1348
+ return fail2(invocation, "failed", message);
1349
1349
  }
1350
1350
  }
1351
1351
  const result = withoutPrivateHostFields(data, eventResultFields);
@@ -1418,7 +1418,7 @@ function createGovernedActionHost(options) {
1418
1418
  };
1419
1419
  } catch (error) {
1420
1420
  if (error instanceof RecoverableFinalizationError) throw error;
1421
- return fail(invocation, "failed", errorMessage(error));
1421
+ return fail2(invocation, "failed", errorMessage(error));
1422
1422
  }
1423
1423
  }
1424
1424
  async function resumeApprovedInvocation(actionInvocationId, tenantId, spaceId, decision) {
@@ -1437,7 +1437,7 @@ function createGovernedActionHost(options) {
1437
1437
  };
1438
1438
  }
1439
1439
  const action = actionResolver(invocation.actionId);
1440
- if (!action) return fail(invocation, "failed", `Unknown action: ${invocation.actionId}`);
1440
+ if (!action) return fail2(invocation, "failed", `Unknown action: ${invocation.actionId}`);
1441
1441
  const approvalAuthorizationInput = toAuthorizationInput(action, {
1442
1442
  tenantId,
1443
1443
  spaceId,
@@ -1590,7 +1590,7 @@ function createGovernedActionHost(options) {
1590
1590
  await (transaction ?? options.store).appendEvent(envelope);
1591
1591
  }
1592
1592
  }
1593
- async function fail(invocation, status, error) {
1593
+ async function fail2(invocation, status, error) {
1594
1594
  await persistInvocation(invocation, { status, error });
1595
1595
  return {
1596
1596
  actionInvocationId: invocation.id,
@@ -3564,6 +3564,181 @@ function createDurableSagaParentLifecycle(options) {
3564
3564
  };
3565
3565
  }
3566
3566
 
3567
+ // src/tenant-isolation.ts
3568
+ function fail(message) {
3569
+ throw new Error(message);
3570
+ }
3571
+ function submitInput(subject, scope, idempotencyKey) {
3572
+ return {
3573
+ actionId: subject.actionId,
3574
+ parameters: subject.parameters ?? {},
3575
+ tenantId: scope.tenantId,
3576
+ spaceId: scope.spaceId,
3577
+ actorId: scope.actorId,
3578
+ actorType: scope.actorType,
3579
+ idempotencyKey
3580
+ };
3581
+ }
3582
+ function tenantIsolationChecks() {
3583
+ return [
3584
+ {
3585
+ id: "fabric.tenancy.invocation-scoped-read.v1",
3586
+ async run(subject) {
3587
+ await subject.reset?.();
3588
+ if (subject.ours.tenantId === subject.theirs.tenantId) fail("the two scopes must be in different tenants");
3589
+ const ours = await subject.host.submitAction(submitInput(subject, subject.ours, "iso:read"));
3590
+ const crossRead = await subject.store.getActionInvocation(ours.actionInvocationId, subject.theirs.tenantId, subject.theirs.spaceId);
3591
+ if (crossRead !== void 0) fail("an invocation was readable under another tenant's scope");
3592
+ const shape = (record) => JSON.stringify({ status: record?.status, result: record?.result, error: record?.error, attemptCount: record?.attemptCount, updatedAt: record?.updatedAt });
3593
+ const before = await subject.store.getActionInvocation(ours.actionInvocationId, subject.ours.tenantId, subject.ours.spaceId);
3594
+ if (!before) fail("our own invocation was not readable under our scope, so the check proves nothing");
3595
+ const untouched = shape(before);
3596
+ let crossExecute;
3597
+ try {
3598
+ crossExecute = await subject.host.executeInvocation(ours.actionInvocationId, subject.theirs.tenantId, subject.theirs.spaceId);
3599
+ } catch {
3600
+ crossExecute = void 0;
3601
+ }
3602
+ if (crossExecute !== void 0) fail("an invocation was executable under another tenant's scope");
3603
+ const after = await subject.store.getActionInvocation(ours.actionInvocationId, subject.ours.tenantId, subject.ours.spaceId);
3604
+ if (untouched !== shape(after)) fail("an execution attempted under another tenant's scope changed our invocation");
3605
+ return ["invocation readable under our scope, invisible and untouched under the other tenant's"];
3606
+ }
3607
+ },
3608
+ {
3609
+ id: "fabric.tenancy.idempotency-key-scoped.v1",
3610
+ async run(subject) {
3611
+ await subject.reset?.();
3612
+ const ours = await subject.host.submitAction(submitInput(subject, subject.ours, "iso:shared-key"));
3613
+ const again = await subject.host.submitAction(submitInput(subject, subject.ours, "iso:shared-key"));
3614
+ 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");
3615
+ const theirs = await subject.host.submitAction(submitInput(subject, subject.theirs, "iso:shared-key"));
3616
+ if (ours.actionInvocationId === theirs.actionInvocationId) fail("the same idempotency key in two tenants resolved to one invocation");
3617
+ return ["one idempotency key: one invocation within a tenant, two across tenants"];
3618
+ }
3619
+ },
3620
+ {
3621
+ id: "fabric.tenancy.events-scoped.v1",
3622
+ async run(subject) {
3623
+ await subject.reset?.();
3624
+ const ours = await subject.host.submitAction(submitInput(subject, subject.ours, "iso:events"));
3625
+ const theirEvents = await subject.store.listEvents(subject.theirs.tenantId, subject.theirs.spaceId);
3626
+ const leaked = theirEvents.filter((event) => event.tenantId === subject.ours.tenantId || event.actionInvocationId === ours.actionInvocationId);
3627
+ if (leaked.length > 0) fail(`${leaked.length} of our events appeared in the other tenant's stream`);
3628
+ const ourEvents = await subject.store.listEvents(subject.ours.tenantId, subject.ours.spaceId);
3629
+ 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");
3630
+ if (ourEvents.some((event) => event.tenantId !== subject.ours.tenantId)) fail("another tenant's event appeared in our stream");
3631
+ return ["event streams carry their own tenant's events and only those"];
3632
+ }
3633
+ },
3634
+ {
3635
+ id: "fabric.tenancy.worker-claim-scoped.v1",
3636
+ async run(subject) {
3637
+ await subject.reset?.();
3638
+ const probeSpace = `iso-probe-${Date.now()}`;
3639
+ const created = await subject.store.createActionInvocation({
3640
+ id: `iso_pending_${Date.now()}`,
3641
+ tenantId: subject.ours.tenantId,
3642
+ spaceId: probeSpace,
3643
+ actionId: subject.actionId,
3644
+ actionVersion: 1,
3645
+ actorId: subject.ours.actorId,
3646
+ actorType: subject.ours.actorType,
3647
+ status: "pending",
3648
+ parameters: subject.parameters ?? {},
3649
+ result: {},
3650
+ correlationId: `iso-corr-${Date.now()}`
3651
+ });
3652
+ try {
3653
+ const theirs = await subject.store.claimActionInvocations({
3654
+ workerId: "iso-worker",
3655
+ leaseDurationMs: 1e3,
3656
+ limit: 1e3,
3657
+ tenantId: subject.theirs.tenantId,
3658
+ spaceId: probeSpace
3659
+ });
3660
+ if (theirs.some((row) => row.id === created.id)) fail("a worker scoped to another tenant claimed our pending invocation");
3661
+ const ours = await subject.store.claimActionInvocations({
3662
+ workerId: "iso-worker",
3663
+ leaseDurationMs: 1e3,
3664
+ limit: 1e3,
3665
+ tenantId: subject.ours.tenantId,
3666
+ spaceId: probeSpace
3667
+ });
3668
+ 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");
3669
+ } finally {
3670
+ await subject.store.updateActionInvocation(created.id, subject.ours.tenantId, probeSpace, {
3671
+ status: "failed",
3672
+ error: "tenant isolation probe; never executed"
3673
+ });
3674
+ }
3675
+ return ["tenant-scoped claim takes our work and leaves other tenants' alone"];
3676
+ }
3677
+ },
3678
+ {
3679
+ id: "fabric.tenancy.completion-scoped.v1",
3680
+ async run(subject) {
3681
+ await subject.reset?.();
3682
+ const ours = await subject.host.submitAction(submitInput(subject, subject.ours, "iso:complete"));
3683
+ let outcome;
3684
+ try {
3685
+ await subject.host.completeExternalInvocation(ours.actionInvocationId, subject.theirs.tenantId, subject.theirs.spaceId, {
3686
+ externalReference: "iso-ref",
3687
+ outcome: "completed",
3688
+ observedAt: (/* @__PURE__ */ new Date()).toISOString()
3689
+ });
3690
+ outcome = "accepted";
3691
+ } catch (error) {
3692
+ const refusal = error.refusal;
3693
+ outcome = refusal ?? "threw";
3694
+ }
3695
+ if (outcome !== "not_found") fail(`a completion under another tenant's scope was ${outcome}; it must be refused as not_found`);
3696
+ let mine;
3697
+ try {
3698
+ await subject.host.completeExternalInvocation(ours.actionInvocationId, subject.ours.tenantId, subject.ours.spaceId, {
3699
+ externalReference: "iso-ref",
3700
+ outcome: "completed",
3701
+ observedAt: (/* @__PURE__ */ new Date()).toISOString()
3702
+ });
3703
+ mine = "accepted";
3704
+ } catch (error) {
3705
+ mine = error.refusal ?? "threw";
3706
+ }
3707
+ if (mine === "not_found") fail("a completion under our own scope was refused as not_found, so the check proves nothing");
3708
+ return ["completion under the other tenant's scope refused as not_found; found under ours"];
3709
+ }
3710
+ },
3711
+ {
3712
+ id: "fabric.tenancy.projection-scoped.v1",
3713
+ async run(subject) {
3714
+ if (!subject.read) return ["skipped: subject lends no read"];
3715
+ await subject.reset?.();
3716
+ await subject.host.submitAction(submitInput(subject, subject.ours, "iso:projection"));
3717
+ const ours = await subject.read(subject.ours);
3718
+ const theirs = await subject.read(subject.theirs);
3719
+ if (!ours.allowed) fail("our own viewer was denied the read");
3720
+ if (ours.rows.length === 0) fail("our own viewer saw nothing, so the check proves nothing");
3721
+ 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");
3722
+ const leaked = theirs.allowed ? theirs.rows.filter((row) => row?.tenantId === subject.ours.tenantId) : [];
3723
+ if (leaked.length > 0) fail(`a viewer in the other tenant saw ${leaked.length} of our rows`);
3724
+ return [theirs.allowed ? "other tenant's viewer sees none of our rows" : "other tenant's viewer is denied"];
3725
+ }
3726
+ }
3727
+ ];
3728
+ }
3729
+ async function runTenantIsolationChecks(subject) {
3730
+ const checks = [];
3731
+ for (const check of tenantIsolationChecks()) {
3732
+ try {
3733
+ const evidence = await check.run(subject);
3734
+ checks.push({ id: check.id, status: evidence[0]?.startsWith("skipped") ? "skipped" : "passed", evidence });
3735
+ } catch (error) {
3736
+ checks.push({ id: check.id, status: "failed", evidence: [], error: error instanceof Error ? error.message : String(error) });
3737
+ }
3738
+ }
3739
+ return { passed: checks.every((check) => check.status !== "failed"), checks };
3740
+ }
3741
+
3567
3742
  exports.ExternalCompletionError = ExternalCompletionError;
3568
3743
  exports.IdempotencyConflictError = IdempotencyConflictError;
3569
3744
  exports.MemoryPlatformHostStore = MemoryPlatformHostStore;
@@ -3584,6 +3759,8 @@ exports.getPlatformHostHealthSnapshot = getPlatformHostHealthSnapshot;
3584
3759
  exports.runOutboxRelayCycle = runOutboxRelayCycle;
3585
3760
  exports.runPlatformActionWorker = runPlatformActionWorker;
3586
3761
  exports.runPlatformActionWorkerCycle = runPlatformActionWorkerCycle;
3762
+ exports.runTenantIsolationChecks = runTenantIsolationChecks;
3763
+ exports.tenantIsolationChecks = tenantIsolationChecks;
3587
3764
  exports.toEnterpriseEventEnvelope = toEnterpriseEventEnvelope;
3588
3765
  //# sourceMappingURL=index.cjs.map
3589
3766
  //# sourceMappingURL=index.cjs.map