@kumwe/studio-testkit 0.1.0-alpha.10
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/LICENSE +21 -0
- package/README.md +138 -0
- package/THIRD_PARTY_NOTICES.md +9 -0
- package/conformance/authoring-web/non-drag-move-equivalence.json +199 -0
- package/conformance/renderer-web/blob-default-deny.json +47 -0
- package/conformance/renderer-web/chart-fallback.json +39 -0
- package/conformance/renderer-web/complete-catalog.json +634 -0
- package/conformance/renderer-web/escaped-heading.json +28 -0
- package/conformance/renderer-web/interactive-behaviors.json +226 -0
- package/conformance/renderer-web/presentation-capabilities.json +76 -0
- package/conformance/renderer-web/responsive-grid.json +33 -0
- package/conformance/renderer-web/security-fallbacks.json +169 -0
- package/conformance/rich-text/advanced.first-party.json +67 -0
- package/conformance/rich-text/blocks.structural.json +57 -0
- package/conformance/rich-text/document.multi-block.json +74 -0
- package/conformance/rich-text/embed.hard-break.json +24 -0
- package/conformance/rich-text/marks.nested.json +29 -0
- package/conformance/rich-text/offsets.astral.json +24 -0
- package/conformance/rich-text/paragraph.plain.json +20 -0
- package/conformance/rich-text/spans.adjacent.json +27 -0
- package/corpus-manifest.json +1212 -0
- package/dist/host-testbed.d.ts +71 -0
- package/dist/host-testbed.d.ts.map +1 -0
- package/dist/host-testbed.js +782 -0
- package/dist/host-testbed.js.map +1 -0
- package/dist/http-host-adapter.d.ts +74 -0
- package/dist/http-host-adapter.d.ts.map +1 -0
- package/dist/http-host-adapter.js +264 -0
- package/dist/http-host-adapter.js.map +1 -0
- package/dist/index.d.ts +112 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +211 -0
- package/dist/index.js.map +1 -0
- package/dist/web-conformance.d.ts +105 -0
- package/dist/web-conformance.d.ts.map +1 -0
- package/dist/web-conformance.js +72 -0
- package/dist/web-conformance.js.map +1 -0
- package/fixtures/authoring-message-catalog.en.json +649 -0
- package/fixtures/block.grid.example.json +76 -0
- package/fixtures/block.price.example.json +69 -0
- package/fixtures/blueprint.product.example.json +92 -0
- package/fixtures/command.move-node.example.json +18 -0
- package/fixtures/content-model.product.example.json +141 -0
- package/fixtures/design-vocabulary.example.json +85 -0
- package/fixtures/entry.product.example.json +55 -0
- package/fixtures/field-adapter.example.json +27 -0
- package/fixtures/host-capabilities.example.json +42 -0
- package/fixtures/host-error.conflict.example.json +23 -0
- package/fixtures/host-operations.example.json +270 -0
- package/fixtures/inspector.example.json +17 -0
- package/fixtures/media-asset.example.json +29 -0
- package/fixtures/media-asset.processing.example.json +15 -0
- package/fixtures/media-reference.example.json +26 -0
- package/fixtures/media-upload-grant.example.json +14 -0
- package/fixtures/media-upload-session.transferring.example.json +22 -0
- package/fixtures/migration.example.json +22 -0
- package/fixtures/pattern.example.json +49 -0
- package/fixtures/plugin.example.json +49 -0
- package/fixtures/preview.render.example.json +15 -0
- package/fixtures/provenance.example.json +38 -0
- package/fixtures/rich-text.example.json +48 -0
- package/fixtures/studio-config.example.json +139 -0
- package/fixtures/theme.example.json +123 -0
- package/fixtures/unresolved-contribution.example.json +23 -0
- package/invalid/binding-projection-vector.unknown-status.json +85 -0
- package/invalid/blueprint.control-character-member.json +59 -0
- package/invalid/blueprint.forbidden-property-member.json +61 -0
- package/invalid/blueprint.invalid-node-id.json +57 -0
- package/invalid/blueprint.invalid-slot-name.json +59 -0
- package/invalid/blueprint.published-empty-roots.json +45 -0
- package/invalid/blueprint.revoking-slot-marker.json +57 -0
- package/invalid/blueprint.unknown-member.json +58 -0
- package/invalid/command-vector.unknown-error-code.json +103 -0
- package/invalid/command.empty-batch.json +17 -0
- package/invalid/command.forbidden-id-map-member.json +20 -0
- package/invalid/command.malformed-size-role.json +18 -0
- package/invalid/command.negative-position.json +20 -0
- package/invalid/command.nested-batch.json +24 -0
- package/invalid/command.pattern-in-batch.json +40 -0
- package/invalid/command.reset-inherited-property-in-batch.json +24 -0
- package/invalid/command.unknown-member.json +23 -0
- package/invalid/command.wrong-contract-version.json +22 -0
- package/invalid/content-model.published-empty.json +22 -0
- package/invalid/design-vocabulary.unknown-control-kind.json +38 -0
- package/invalid/entry.forbidden-value-member.json +23 -0
- package/invalid/entry.invalid-locale.json +59 -0
- package/invalid/host-capabilities.invalid-protocol-version.json +40 -0
- package/invalid/host-sequence.pending-expectation.json +52 -0
- package/invalid/host-sequence.preview-render-missing-request-id.json +50 -0
- package/invalid/inspector.empty-block-types.json +20 -0
- package/invalid/media-reference.missing-accessibility.json +25 -0
- package/invalid/media-upload-grant.insecure-url.json +14 -0
- package/invalid/media-upload-session.complete-without-asset.json +20 -0
- package/invalid/migration.empty-artifact-kinds.json +22 -0
- package/invalid/preview-message.activated-unknown-interaction.json +16 -0
- package/invalid/preview-message.duplicate-marker.json +24 -0
- package/invalid/preview-message.invalid-digest.json +19 -0
- package/invalid/preview-message.invalid-marker-grammar.json +19 -0
- package/invalid/preview-message.missing-marker-map.json +20 -0
- package/invalid/preview-message.missing-render-request-id.json +18 -0
- package/invalid/preview-message.unknown-type.json +13 -0
- package/invalid/preview-message.viewport-height-too-small.json +13 -0
- package/invalid/preview-message.viewport-role-and-size.json +16 -0
- package/invalid/preview-message.viewport-unsafe-role.json +13 -0
- package/invalid/rich-text.embedded-html.json +13 -0
- package/invalid/rich-text.unknown-mark.json +19 -0
- package/invalid/studio-chart.script-config.json +10 -0
- package/invalid/studio-drawing.svg-payload.json +10 -0
- package/invalid/studio-money.float.json +8 -0
- package/invalid/studio-presentation.css-injection.json +8 -0
- package/invalid/studio-table.html-cell.json +8 -0
- package/invalid/theme.unknown-member.json +128 -0
- package/package.json +54 -0
- package/studio-release.json +18 -0
- package/vectors/binding-projection/app-content-fields.resolved.json +286 -0
- package/vectors/binding-projection/field-removed.diagnostic.json +135 -0
- package/vectors/binding-projection/model-revision-mismatch.diagnostic.json +124 -0
- package/vectors/canonical/array-order.json +13 -0
- package/vectors/canonical/control-characters.json +13 -0
- package/vectors/canonical/depth-exceeded.json +21 -0
- package/vectors/canonical/empty-containers.json +14 -0
- package/vectors/canonical/forbidden-member.json +14 -0
- package/vectors/canonical/member-order.json +16 -0
- package/vectors/canonical/negative-zero.json +13 -0
- package/vectors/canonical/nested-order.json +19 -0
- package/vectors/canonical/non-ascii.json +13 -0
- package/vectors/canonical/numbers.json +16 -0
- package/vectors/canonical/string-escapes.json +13 -0
- package/vectors/canonical/surrogate-pair.json +13 -0
- package/vectors/command/apply-pattern.colliding-id.error.json +97 -0
- package/vectors/command/apply-pattern.incomplete-id-map.error.json +111 -0
- package/vectors/command/apply-pattern.two-roots.json +259 -0
- package/vectors/command/batch.atomic-failure.error.json +100 -0
- package/vectors/command/batch.insert-and-configure.json +197 -0
- package/vectors/command/batch.size-roles.json +180 -0
- package/vectors/command/duplicate-node.existing-id.error.json +88 -0
- package/vectors/command/duplicate-node.explicit-destination.json +183 -0
- package/vectors/command/duplicate-node.incomplete-id-map.error.json +91 -0
- package/vectors/command/duplicate-node.subtree.json +201 -0
- package/vectors/command/insert-node.duplicate-id.error.json +87 -0
- package/vectors/command/insert-node.inner-duplicate-id.error.json +115 -0
- package/vectors/command/insert-node.invalid-index.error.json +87 -0
- package/vectors/command/insert-node.root.json +162 -0
- package/vectors/command/insert-node.slot.json +167 -0
- package/vectors/command/mode.blueprint.set-field-value.error.json +76 -0
- package/vectors/command/mode.blueprint.set-property.json +131 -0
- package/vectors/command/mode.content.insert-node.error.json +88 -0
- package/vectors/command/mode.hybrid.batch-atomic.error.json +158 -0
- package/vectors/command/mode.hybrid.insert-allowed.json +263 -0
- package/vectors/command/mode.hybrid.insert-designer-slot.error.json +145 -0
- package/vectors/command/mode.hybrid.insert-disallowed-type.error.json +145 -0
- package/vectors/command/mode.hybrid.insert-roots.error.json +143 -0
- package/vectors/command/mode.hybrid.remove-locked.error.json +130 -0
- package/vectors/command/mode.hybrid.reorder-with-locked.json +239 -0
- package/vectors/command/mode.hybrid.set-property.error.json +132 -0
- package/vectors/command/mode.hybrid.slot-marker-disallowed.error.json +125 -0
- package/vectors/command/mode.hybrid.slot-marker-insert.json +223 -0
- package/vectors/command/mode.hybrid.slot-marker-other-slot.error.json +125 -0
- package/vectors/command/mode.model.remove-node.error.json +75 -0
- package/vectors/command/mode.read-only.set-property.error.json +77 -0
- package/vectors/command/move-node.into-self.error.json +95 -0
- package/vectors/command/move-node.reparent.json +196 -0
- package/vectors/command/move-node.within-roots.json +188 -0
- package/vectors/command/remove-binding.base.json +159 -0
- package/vectors/command/remove-binding.missing.error.json +75 -0
- package/vectors/command/remove-node.empties-slot.json +167 -0
- package/vectors/command/remove-node.missing.error.json +74 -0
- package/vectors/command/remove-node.nested.json +192 -0
- package/vectors/command/reorder-children.not-permutation.error.json +85 -0
- package/vectors/command/reorder-children.roots.json +182 -0
- package/vectors/command/reorder-children.slot.json +192 -0
- package/vectors/command/reset-inherited-property.missing.error.json +82 -0
- package/vectors/command/reset-inherited-property.single-viewport.json +151 -0
- package/vectors/command/reset-inherited-property.two-viewports.json +168 -0
- package/vectors/command/restore-node.inner-duplicate-id.error.json +115 -0
- package/vectors/command/restore-node.subtree-into-slot.json +195 -0
- package/vectors/command/set-binding.add.json +159 -0
- package/vectors/command/set-binding.replace.json +178 -0
- package/vectors/command/set-property.add.json +143 -0
- package/vectors/command/set-property.replace.json +146 -0
- package/vectors/command/set-property.viewport-add.json +148 -0
- package/vectors/command/set-size-role.base.json +144 -0
- package/vectors/command/set-size-role.duplicate.json +148 -0
- package/vectors/command/set-size-role.viewport-add.json +154 -0
- package/vectors/command/unset-property.base.json +143 -0
- package/vectors/command/unset-property.missing.error.json +75 -0
- package/vectors/command/unset-property.viewport.json +154 -0
- package/vectors/command/unset-size-role.missing.error.json +78 -0
- package/vectors/command/unset-size-role.viewport.json +154 -0
- package/vectors/host/artifact.dependencies.result.json +35 -0
- package/vectors/host/artifact.load.stored.json +36 -0
- package/vectors/host/artifact.load.unknown.error.json +37 -0
- package/vectors/host/artifact.publish.accepted.json +37 -0
- package/vectors/host/artifact.publish.forbidden.error.json +37 -0
- package/vectors/host/artifact.publish.stale.error.json +38 -0
- package/vectors/host/artifact.save.accepted.json +38 -0
- package/vectors/host/artifact.save.forbidden.error.json +38 -0
- package/vectors/host/artifact.save.stale.error.json +39 -0
- package/vectors/host/artifact.unpublish.stale.error.json +38 -0
- package/vectors/host/envelope.malformed-context.error.json +36 -0
- package/vectors/host/envelope.protocol-version.error.json +36 -0
- package/vectors/host/envelope.stale-generation.error.json +36 -0
- package/vectors/host/localization.messages.unknown-locale.error.json +36 -0
- package/vectors/host/media.abort-upload.unknown.error.json +35 -0
- package/vectors/host/media.authorize-upload.granted.json +37 -0
- package/vectors/host/media.authorize-upload.path-filename.error.json +39 -0
- package/vectors/host/media.authorize-upload.too-large.error.json +38 -0
- package/vectors/host/media.complete-upload.unknown.error.json +36 -0
- package/vectors/host/media.get.unknown.json +34 -0
- package/vectors/host/media.import-external.private-host.error.json +36 -0
- package/vectors/host/media.list.cursor.error.json +36 -0
- package/vectors/host/media.list.limit.error.json +35 -0
- package/vectors/host/media.upload-status.unknown.error.json +35 -0
- package/vectors/host/model.get.stored.json +37 -0
- package/vectors/host/model.list.authorized.json +31 -0
- package/vectors/host/permission.explain.withheld.json +34 -0
- package/vectors/host/permission.refresh.snapshot.json +31 -0
- package/vectors/host/recovery.load.absent.json +31 -0
- package/vectors/host/telemetry.emit.accepted.json +37 -0
- package/vectors/host/telemetry.emit.non-primitive.error.json +40 -0
- package/vectors/host-sequence/artifact.publish.changed-intent.sequence.json +75 -0
- package/vectors/host-sequence/artifact.publish.idempotent-replay.sequence.json +111 -0
- package/vectors/host-sequence/preview.cancel.cross-context.sequence.json +78 -0
- package/vectors/host-sequence/preview.render.cancelled.sequence.json +75 -0
- package/vectors/host-sequence/recovery.store.canonical-number.sequence.json +72 -0
- package/vectors/host-sequence/recovery.store.changed-context.sequence.json +61 -0
- package/vectors/host-sequence/recovery.store.rate-limited.sequence.json +93 -0
- package/vectors/host-sequence/recovery.store.resource-scope.sequence.json +61 -0
- package/vectors/host-sequence/recovery.store.wrong-operation-id.sequence.json +56 -0
- package/vectors/media/accept.plan-deterministic.json +26 -0
- package/vectors/media/accept.size-at-boundary.json +26 -0
- package/vectors/media/cancel.complete-noop.json +30 -0
- package/vectors/media/cancel.requested.json +30 -0
- package/vectors/media/cancel.transferring.json +30 -0
- package/vectors/media/cancel.verifying.json +30 -0
- package/vectors/media/reject.disallowed-kind.json +23 -0
- package/vectors/media/reject.filename-empty.json +22 -0
- package/vectors/media/reject.filename-path.json +23 -0
- package/vectors/media/reject.size-one-byte-over.json +23 -0
- package/vectors/media/retry.fresh-session.json +26 -0
- package/vectors/preview/canonical-preorder.json +102 -0
- package/vectors/preview/empty-draft.json +36 -0
- package/vectors/schema-profile/alternative-limit.accepted.json +95 -0
- package/vectors/schema-profile/alternative-limit.rejected.json +92 -0
- package/vectors/schema-profile/array-composition.accepted.json +35 -0
- package/vectors/schema-profile/byte-limit.accepted.json +30 -0
- package/vectors/schema-profile/byte-limit.rejected.json +26 -0
- package/vectors/schema-profile/closed-object.accepted.json +38 -0
- package/vectors/schema-profile/decimal-multiple.accepted.json +54 -0
- package/vectors/schema-profile/depth-limit.accepted.json +90 -0
- package/vectors/schema-profile/depth-limit.rejected.json +88 -0
- package/vectors/schema-profile/description-limit.accepted.json +29 -0
- package/vectors/schema-profile/description-limit.rejected.json +25 -0
- package/vectors/schema-profile/diagnostic-precedence.rejected.json +26 -0
- package/vectors/schema-profile/duplicate-dependent-required.rejected.json +21 -0
- package/vectors/schema-profile/duplicate-required.rejected.json +18 -0
- package/vectors/schema-profile/empty-combinator.rejected.json +17 -0
- package/vectors/schema-profile/empty-enum.rejected.json +17 -0
- package/vectors/schema-profile/enum-limit.accepted.json +89 -0
- package/vectors/schema-profile/enum-limit.rejected.json +84 -0
- package/vectors/schema-profile/examples-limit.accepted.json +35 -0
- package/vectors/schema-profile/examples-limit.rejected.json +31 -0
- package/vectors/schema-profile/format-keyword.rejected.json +17 -0
- package/vectors/schema-profile/forward-reference-path-precedence.rejected.json +20 -0
- package/vectors/schema-profile/invalid-pointer.rejected.json +18 -0
- package/vectors/schema-profile/json-depth-limit.accepted.json +278 -0
- package/vectors/schema-profile/json-depth-limit.rejected.json +276 -0
- package/vectors/schema-profile/json-item-limit.accepted.json +686 -0
- package/vectors/schema-profile/json-item-limit.rejected.json +682 -0
- package/vectors/schema-profile/json-property-limit.accepted.json +1031 -0
- package/vectors/schema-profile/json-property-limit.rejected.json +1028 -0
- package/vectors/schema-profile/local-reference.accepted.json +28 -0
- package/vectors/schema-profile/memoized-diagnostic.accepted.json +31 -0
- package/vectors/schema-profile/node-limit.accepted.json +1097 -0
- package/vectors/schema-profile/node-limit.rejected.json +1094 -0
- package/vectors/schema-profile/non-object-root.rejected.json +9 -0
- package/vectors/schema-profile/non-schema-reference.rejected.json +21 -0
- package/vectors/schema-profile/object-key-limit.accepted.json +32 -0
- package/vectors/schema-profile/object-key-limit.rejected.json +28 -0
- package/vectors/schema-profile/open-root.rejected.json +13 -0
- package/vectors/schema-profile/percent-encoded-pointer.rejected.json +26 -0
- package/vectors/schema-profile/property-name-limit.accepted.json +543 -0
- package/vectors/schema-profile/property-name-limit.rejected.json +536 -0
- package/vectors/schema-profile/recursive-diagnostic-precedence.rejected.json +30 -0
- package/vectors/schema-profile/recursive-reference.rejected.json +24 -0
- package/vectors/schema-profile/reference-fanout.accepted.json +662 -0
- package/vectors/schema-profile/reference-length-limit.accepted.json +45 -0
- package/vectors/schema-profile/reference-length-limit.rejected.json +39 -0
- package/vectors/schema-profile/reference-limit.accepted.json +416 -0
- package/vectors/schema-profile/reference-limit.rejected.json +415 -0
- package/vectors/schema-profile/reference-vs-keyword-precedence.rejected.json +22 -0
- package/vectors/schema-profile/reserved-pointer.rejected.json +26 -0
- package/vectors/schema-profile/root-diagnostic-precedence.rejected.json +16 -0
- package/vectors/schema-profile/root-vs-reference-precedence.rejected.json +19 -0
- package/vectors/schema-profile/schema-map-limit.accepted.json +539 -0
- package/vectors/schema-profile/schema-map-limit.rejected.json +536 -0
- package/vectors/schema-profile/semantic-recursion-precedence.rejected.json +21 -0
- package/vectors/schema-profile/semantic-reference-precedence.rejected.json +21 -0
- package/vectors/schema-profile/title-limit.accepted.json +29 -0
- package/vectors/schema-profile/title-limit.rejected.json +25 -0
- package/vectors/schema-profile/unresolved-reference.rejected.json +17 -0
- package/vectors/schema-profile/unsafe-dependent-required.rejected.json +24 -0
- package/vectors/schema-profile/unsafe-member.rejected.json +17 -0
- package/vectors/schema-profile/unsafe-required.rejected.json +17 -0
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
import { canonicalStringify, validateExternalUrl } from '@kumwe/studio-core';
|
|
2
|
+
import { HostPortFailure, STUDIO_CONTRACT_VERSION, STUDIO_STALE_SESSION_GENERATION_DIAGNOSTIC_CODE, STUDIO_WIRE_PROTOCOL_VERSION, } from '@kumwe/studio-protocol';
|
|
3
|
+
export class TestbedHostError extends HostPortFailure {
|
|
4
|
+
constructor(error) {
|
|
5
|
+
super(error);
|
|
6
|
+
this.name = 'TestbedHostError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function createHostRequestContextFixture(options = {}) {
|
|
10
|
+
return {
|
|
11
|
+
...(options.expectedRevision === undefined
|
|
12
|
+
? {}
|
|
13
|
+
: { expectedRevision: options.expectedRevision }),
|
|
14
|
+
...(options.idempotencyKey === undefined ? {} : { idempotencyKey: options.idempotencyKey }),
|
|
15
|
+
...(options.locale === undefined ? {} : { locale: options.locale }),
|
|
16
|
+
operationId: options.operationId ?? 'studio.test/operation',
|
|
17
|
+
protocolVersion: options.protocolVersion ?? STUDIO_WIRE_PROTOCOL_VERSION,
|
|
18
|
+
requestId: options.requestId ?? 'requests/test-1',
|
|
19
|
+
resourceContextKey: options.resourceContextKey ?? 'contexts/test',
|
|
20
|
+
sessionGeneration: options.sessionGeneration ?? 'session-r1',
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The permissions this reference host gates artifact mutations behind. A host
|
|
25
|
+
* chooses its own names; what the contract fixes is that a mutation is
|
|
26
|
+
* authorized and that a withheld permission is refused as `forbidden`.
|
|
27
|
+
*/
|
|
28
|
+
const SAVE_PERMISSION = 'studio.permission/save';
|
|
29
|
+
const PUBLISH_PERMISSION = 'studio.permission/publish';
|
|
30
|
+
/** Deterministic bounds the reference host authorizes uploads within. */
|
|
31
|
+
const UPLOAD_CHUNK_BYTES = 5_242_880;
|
|
32
|
+
const UPLOAD_MAXIMUM_BYTES = 52_428_800;
|
|
33
|
+
const GRANT_EXPIRY = '2030-01-01T00:00:00Z';
|
|
34
|
+
const MAXIMUM_RATE_LIMIT_REQUESTS = 1000;
|
|
35
|
+
const MAXIMUM_RATE_LIMIT_WINDOW_MILLISECONDS = 86_400_000;
|
|
36
|
+
export function createTestbedHost(options = {}) {
|
|
37
|
+
const artifactStore = new Map();
|
|
38
|
+
for (const seeded of options.documents ?? []) {
|
|
39
|
+
const document = cloneValue(seeded);
|
|
40
|
+
if (artifactStore.has(document.id)) {
|
|
41
|
+
throw new TypeError(`Testbed artifact ${document.id} is duplicated.`);
|
|
42
|
+
}
|
|
43
|
+
const revision = document.revision;
|
|
44
|
+
artifactStore.set(document.id, {
|
|
45
|
+
document,
|
|
46
|
+
revision,
|
|
47
|
+
serial: seededRevisionSerial(document.id, revision),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const mediaAssets = cloneValue(options.mediaAssets ?? []);
|
|
51
|
+
const resources = cloneValue(options.resources ?? []);
|
|
52
|
+
const messageBundles = new Map(Object.entries(cloneValue(options.messages ?? {})));
|
|
53
|
+
const recoveryStore = new Map();
|
|
54
|
+
const telemetryEvents = [];
|
|
55
|
+
const injectedFailures = [];
|
|
56
|
+
const renderCallback = options.render ?? defaultRender;
|
|
57
|
+
const idempotencyRecords = new Map();
|
|
58
|
+
const pendingPreviewRenders = new Map();
|
|
59
|
+
const previewDeliveries = [];
|
|
60
|
+
const rateLimitPolicies = new Map();
|
|
61
|
+
const rateLimitWindows = new Map();
|
|
62
|
+
for (const policy of options.rateLimits ?? []) {
|
|
63
|
+
if (!Number.isSafeInteger(policy.maximumRequests) ||
|
|
64
|
+
policy.maximumRequests < 1 ||
|
|
65
|
+
policy.maximumRequests > MAXIMUM_RATE_LIMIT_REQUESTS ||
|
|
66
|
+
!Number.isSafeInteger(policy.windowMilliseconds) ||
|
|
67
|
+
policy.windowMilliseconds < 1 ||
|
|
68
|
+
policy.windowMilliseconds > MAXIMUM_RATE_LIMIT_WINDOW_MILLISECONDS) {
|
|
69
|
+
throw new RangeError('Testbed rate limits require positive safe-integer bounds.');
|
|
70
|
+
}
|
|
71
|
+
if (rateLimitPolicies.has(policy.operationId)) {
|
|
72
|
+
throw new TypeError(`Testbed rate limit ${policy.operationId} is duplicated.`);
|
|
73
|
+
}
|
|
74
|
+
rateLimitPolicies.set(policy.operationId, { ...policy });
|
|
75
|
+
}
|
|
76
|
+
const uploadGrants = new Map();
|
|
77
|
+
const acceptedUploads = new Map();
|
|
78
|
+
let uploadSerial = 0;
|
|
79
|
+
let permissions = (options.permissions ?? []).slice();
|
|
80
|
+
let liveGeneration = options.sessionGeneration ?? 'session-r1';
|
|
81
|
+
if (!isNonEmptyString(liveGeneration) || liveGeneration.length > 200) {
|
|
82
|
+
throw new TypeError('The testbed session generation must be a non-empty bounded revision.');
|
|
83
|
+
}
|
|
84
|
+
let generationSerial = seededRevisionSerial('session', liveGeneration);
|
|
85
|
+
let connected = true;
|
|
86
|
+
let correlationSerial = 0;
|
|
87
|
+
let importSerial = 0;
|
|
88
|
+
let clockMilliseconds = options.initialClockMilliseconds ?? 0;
|
|
89
|
+
if (!Number.isSafeInteger(clockMilliseconds) || clockMilliseconds < 0) {
|
|
90
|
+
throw new RangeError('The testbed logical clock must be a non-negative safe integer.');
|
|
91
|
+
}
|
|
92
|
+
function currentGeneration() {
|
|
93
|
+
return liveGeneration;
|
|
94
|
+
}
|
|
95
|
+
function createError(category, defaultMessage, extras = {}) {
|
|
96
|
+
correlationSerial += 1;
|
|
97
|
+
return {
|
|
98
|
+
category,
|
|
99
|
+
contractVersion: STUDIO_CONTRACT_VERSION,
|
|
100
|
+
correlationId: `testbed-correlation-${correlationSerial}`,
|
|
101
|
+
kind: 'host-error',
|
|
102
|
+
message: { defaultMessage, key: `studio.testbed/${category}` },
|
|
103
|
+
retryable: extras.retryable ?? (category === 'rate-limited' || category === 'unavailable'),
|
|
104
|
+
...(extras.diagnostics === undefined ? {} : { diagnostics: cloneValue(extras.diagnostics) }),
|
|
105
|
+
...(extras.retryAfterMilliseconds === undefined
|
|
106
|
+
? {}
|
|
107
|
+
: { retryAfterMilliseconds: extras.retryAfterMilliseconds }),
|
|
108
|
+
...(extras.revision === undefined ? {} : { revision: extras.revision }),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function fail(category, defaultMessage, extras = {}) {
|
|
112
|
+
throw new TestbedHostError(createError(category, defaultMessage, extras));
|
|
113
|
+
}
|
|
114
|
+
function guardEnvelope(portName, operation, context) {
|
|
115
|
+
if (!connected) {
|
|
116
|
+
fail('unavailable', 'The testbed host is disconnected.');
|
|
117
|
+
}
|
|
118
|
+
if (!isNonEmptyString(context.requestId) ||
|
|
119
|
+
!isNonEmptyString(context.operationId) ||
|
|
120
|
+
!isNonEmptyString(context.resourceContextKey) ||
|
|
121
|
+
!isNonEmptyString(context.sessionGeneration)) {
|
|
122
|
+
fail('invalid-request', 'The request context is structurally invalid.');
|
|
123
|
+
}
|
|
124
|
+
if (context.protocolVersion !== STUDIO_WIRE_PROTOCOL_VERSION) {
|
|
125
|
+
fail('incompatible', 'The request protocol version is not supported by this testbed.');
|
|
126
|
+
}
|
|
127
|
+
if (context.sessionGeneration !== currentGeneration()) {
|
|
128
|
+
fail('invalid-request', 'The session generation is no longer valid.', {
|
|
129
|
+
diagnostics: [
|
|
130
|
+
{
|
|
131
|
+
code: STUDIO_STALE_SESSION_GENERATION_DIAGNOSTIC_CODE,
|
|
132
|
+
message: {
|
|
133
|
+
defaultMessage: 'The request belongs to an obsolete Studio session generation.',
|
|
134
|
+
key: STUDIO_STALE_SESSION_GENERATION_DIAGNOSTIC_CODE,
|
|
135
|
+
},
|
|
136
|
+
severity: 'blocking',
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
const expectedOperationId = `studio.operation/${portName}.${operation}`;
|
|
142
|
+
if (context.operationId !== expectedOperationId &&
|
|
143
|
+
!(options.allowTestOperationId === true && context.operationId === 'studio.test/operation')) {
|
|
144
|
+
fail('invalid-request', 'The request operation identifier does not match the invoked port.');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function applyOperationalGuards(portName, operation, context) {
|
|
148
|
+
const index = injectedFailures.findIndex((entry) => entry.portName === portName && entry.operation === operation);
|
|
149
|
+
if (index >= 0) {
|
|
150
|
+
const injected = injectedFailures[index];
|
|
151
|
+
injectedFailures.splice(index, 1);
|
|
152
|
+
if (injected !== undefined) {
|
|
153
|
+
fail(injected.category, 'The testbed injected this failure.');
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const policy = rateLimitPolicies.get(context.operationId);
|
|
157
|
+
if (policy === undefined) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
let rateWindow = rateLimitWindows.get(context.operationId);
|
|
161
|
+
if (rateWindow === undefined ||
|
|
162
|
+
clockMilliseconds >= rateWindow.startedAtMilliseconds + policy.windowMilliseconds) {
|
|
163
|
+
rateWindow = { requestCount: 0, startedAtMilliseconds: clockMilliseconds };
|
|
164
|
+
rateLimitWindows.set(context.operationId, rateWindow);
|
|
165
|
+
}
|
|
166
|
+
if (rateWindow.requestCount >= policy.maximumRequests) {
|
|
167
|
+
fail('rate-limited', 'The operation has reached its deterministic request bound.', {
|
|
168
|
+
retryAfterMilliseconds: rateWindow.startedAtMilliseconds + policy.windowMilliseconds - clockMilliseconds,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
rateWindow.requestCount += 1;
|
|
172
|
+
}
|
|
173
|
+
async function executeBody(body) {
|
|
174
|
+
try {
|
|
175
|
+
return await body();
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (error instanceof TestbedHostError) {
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
throw new TestbedHostError(createError('internal', 'The testbed operation failed unexpectedly.'));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function run(portName, operation, context, body) {
|
|
185
|
+
try {
|
|
186
|
+
guardEnvelope(portName, operation, context);
|
|
187
|
+
applyOperationalGuards(portName, operation, context);
|
|
188
|
+
return executeBody(body);
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
return executeBody(() => {
|
|
192
|
+
throw error;
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function runMutation(portName, operation, argument, context, body) {
|
|
197
|
+
try {
|
|
198
|
+
guardEnvelope(portName, operation, context);
|
|
199
|
+
const idempotencyKey = context.idempotencyKey;
|
|
200
|
+
if (idempotencyKey === undefined) {
|
|
201
|
+
applyOperationalGuards(portName, operation, context);
|
|
202
|
+
return executeBody(body);
|
|
203
|
+
}
|
|
204
|
+
const scope = canonicalStringify({
|
|
205
|
+
idempotencyKey,
|
|
206
|
+
operationId: context.operationId,
|
|
207
|
+
resourceContextKey: context.resourceContextKey,
|
|
208
|
+
sessionGeneration: context.sessionGeneration,
|
|
209
|
+
});
|
|
210
|
+
const fingerprint = canonicalStringify({
|
|
211
|
+
argument,
|
|
212
|
+
context: {
|
|
213
|
+
...(context.expectedRevision === undefined
|
|
214
|
+
? {}
|
|
215
|
+
: { expectedRevision: context.expectedRevision }),
|
|
216
|
+
...(context.locale === undefined ? {} : { locale: context.locale }),
|
|
217
|
+
protocolVersion: context.protocolVersion,
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
const prior = idempotencyRecords.get(scope);
|
|
221
|
+
if (prior !== undefined) {
|
|
222
|
+
if (prior.fingerprint !== fingerprint) {
|
|
223
|
+
fail('invalid-request', 'The idempotency key was already accepted for another intent.');
|
|
224
|
+
}
|
|
225
|
+
return prior.promise.then((result) => cloneValue(result));
|
|
226
|
+
}
|
|
227
|
+
applyOperationalGuards(portName, operation, context);
|
|
228
|
+
const accepted = executeBody(body).then((result) => cloneValue(result));
|
|
229
|
+
idempotencyRecords.set(scope, {
|
|
230
|
+
fingerprint,
|
|
231
|
+
promise: accepted,
|
|
232
|
+
});
|
|
233
|
+
void accepted.catch(() => {
|
|
234
|
+
if (idempotencyRecords.get(scope)?.promise === accepted) {
|
|
235
|
+
idempotencyRecords.delete(scope);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
return accepted.then((result) => cloneValue(result));
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
return executeBody(() => {
|
|
242
|
+
throw error;
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* The authorization gate a production host applies to every mutation. Reads
|
|
248
|
+
* are authorized by the resolved request context; mutations additionally
|
|
249
|
+
* require the host-declared permission for the operation, and a withheld
|
|
250
|
+
* permission is refused without disclosing whether the artifact exists.
|
|
251
|
+
*/
|
|
252
|
+
function requireArtifactPermission(permission) {
|
|
253
|
+
if (!permissions.includes(permission)) {
|
|
254
|
+
fail('forbidden', 'The session does not hold the permission this operation requires.');
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function requireStored(id) {
|
|
258
|
+
const stored = artifactStore.get(id);
|
|
259
|
+
if (stored === undefined) {
|
|
260
|
+
fail('not-found', 'The requested artifact is not available.');
|
|
261
|
+
}
|
|
262
|
+
return stored;
|
|
263
|
+
}
|
|
264
|
+
function ensureExpectedRevision(stored, context) {
|
|
265
|
+
if (context.expectedRevision !== stored.revision) {
|
|
266
|
+
fail('conflict', 'The stored artifact revision differs from the expected revision.', {
|
|
267
|
+
revision: stored.revision,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
function advanceRevision(stored) {
|
|
272
|
+
stored.serial += 1;
|
|
273
|
+
stored.revision = `${stored.document.id}-r${stored.serial}`;
|
|
274
|
+
stored.document.revision = stored.revision;
|
|
275
|
+
return stored.revision;
|
|
276
|
+
}
|
|
277
|
+
function setStatus(id, context, status) {
|
|
278
|
+
requireArtifactPermission(PUBLISH_PERMISSION);
|
|
279
|
+
const stored = requireStored(id);
|
|
280
|
+
ensureExpectedRevision(stored, context);
|
|
281
|
+
stored.document.status = status;
|
|
282
|
+
return { revision: advanceRevision(stored), value: null };
|
|
283
|
+
}
|
|
284
|
+
function resolveStart(cursor) {
|
|
285
|
+
if (cursor === undefined) {
|
|
286
|
+
return 0;
|
|
287
|
+
}
|
|
288
|
+
const start = decodeCursor(cursor);
|
|
289
|
+
if (start === undefined) {
|
|
290
|
+
fail('invalid-request', 'The pagination cursor is not valid.');
|
|
291
|
+
}
|
|
292
|
+
return start;
|
|
293
|
+
}
|
|
294
|
+
function ensureLimit(limit) {
|
|
295
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
|
|
296
|
+
fail('invalid-request', 'The limit must be an integer between 1 and 100.');
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const host = {
|
|
300
|
+
artifact: {
|
|
301
|
+
dependencies(reference, context) {
|
|
302
|
+
return run('artifact', 'dependencies', context, () => ({ value: [] }));
|
|
303
|
+
},
|
|
304
|
+
load(reference, context) {
|
|
305
|
+
return run('artifact', 'load', context, () => {
|
|
306
|
+
const stored = requireStored(reference.id);
|
|
307
|
+
return { revision: stored.revision, value: cloneValue(stored.document) };
|
|
308
|
+
});
|
|
309
|
+
},
|
|
310
|
+
publish(reference, context) {
|
|
311
|
+
return runMutation('artifact', 'publish', reference, context, () => setStatus(reference.id, context, 'published'));
|
|
312
|
+
},
|
|
313
|
+
save(document, context) {
|
|
314
|
+
return runMutation('artifact', 'save', document, context, () => {
|
|
315
|
+
requireArtifactPermission(SAVE_PERMISSION);
|
|
316
|
+
const stored = requireStored(document.id);
|
|
317
|
+
ensureExpectedRevision(stored, context);
|
|
318
|
+
stored.document = cloneValue(document);
|
|
319
|
+
return { revision: advanceRevision(stored), value: null };
|
|
320
|
+
});
|
|
321
|
+
},
|
|
322
|
+
unpublish(reference, context) {
|
|
323
|
+
return runMutation('artifact', 'unpublish', reference, context, () => setStatus(reference.id, context, 'draft'));
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
localization: {
|
|
327
|
+
messages(locale, namespaces, context) {
|
|
328
|
+
return run('localization', 'messages', context, () => {
|
|
329
|
+
const bundle = messageBundles.get(locale);
|
|
330
|
+
if (bundle === undefined) {
|
|
331
|
+
fail('not-found', 'The requested locale is not available.');
|
|
332
|
+
}
|
|
333
|
+
const value = {};
|
|
334
|
+
for (const [key, message] of Object.entries(bundle)) {
|
|
335
|
+
const qualifiedKey = key;
|
|
336
|
+
if (namespaces.some((namespace) => qualifiedKey === namespace || qualifiedKey.startsWith(`${namespace}.`))) {
|
|
337
|
+
value[qualifiedKey] = message;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return { value };
|
|
341
|
+
});
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
media: {
|
|
345
|
+
abortUpload(uploadId, context) {
|
|
346
|
+
return runMutation('media', 'abort-upload', uploadId, context, () => {
|
|
347
|
+
if (!uploadGrants.delete(uploadId)) {
|
|
348
|
+
fail('not-found', 'The requested upload is not available.');
|
|
349
|
+
}
|
|
350
|
+
return { value: null };
|
|
351
|
+
});
|
|
352
|
+
},
|
|
353
|
+
authorizeUpload(request, context) {
|
|
354
|
+
return runMutation('media', 'authorize-upload', request, context, () => {
|
|
355
|
+
assertUploadRequest(request);
|
|
356
|
+
uploadSerial += 1;
|
|
357
|
+
const uploadId = `uploads/testbed-${String(uploadSerial)}`;
|
|
358
|
+
const grant = {
|
|
359
|
+
expiresAt: GRANT_EXPIRY,
|
|
360
|
+
headers: { 'X-Upload-Session': uploadId },
|
|
361
|
+
method: 'PUT',
|
|
362
|
+
plan: {
|
|
363
|
+
chunkBytes: UPLOAD_CHUNK_BYTES,
|
|
364
|
+
maximumBytes: UPLOAD_MAXIMUM_BYTES,
|
|
365
|
+
resumable: true,
|
|
366
|
+
},
|
|
367
|
+
uploadId,
|
|
368
|
+
url: `https://uploads.testbed.invalid/${uploadId}`,
|
|
369
|
+
};
|
|
370
|
+
uploadGrants.set(uploadId, { grant, request: cloneValue(request) });
|
|
371
|
+
return { value: cloneValue(grant) };
|
|
372
|
+
});
|
|
373
|
+
},
|
|
374
|
+
completeUpload(uploadId, context) {
|
|
375
|
+
return runMutation('media', 'complete-upload', uploadId, context, () => {
|
|
376
|
+
const pending = uploadGrants.get(uploadId);
|
|
377
|
+
if (pending === undefined) {
|
|
378
|
+
fail('not-found', 'The requested upload is not available.');
|
|
379
|
+
}
|
|
380
|
+
uploadGrants.delete(uploadId);
|
|
381
|
+
// The host verifies what it received; a declared media type is
|
|
382
|
+
// never trusted, so acceptance lands in `processing`.
|
|
383
|
+
const accepted = {
|
|
384
|
+
id: `assets/${uploadId.replace('uploads/', '')}`,
|
|
385
|
+
revision: `${uploadId}-r1`,
|
|
386
|
+
state: 'processing',
|
|
387
|
+
};
|
|
388
|
+
acceptedUploads.set(accepted.id, accepted);
|
|
389
|
+
return { revision: accepted.revision, value: cloneValue(accepted) };
|
|
390
|
+
});
|
|
391
|
+
},
|
|
392
|
+
get(assetId, context) {
|
|
393
|
+
return run('media', 'get', context, () => {
|
|
394
|
+
const asset = mediaAssets.find((entry) => entry.id === assetId);
|
|
395
|
+
const result = asset === undefined
|
|
396
|
+
? { value: null }
|
|
397
|
+
: { revision: asset.revision, value: cloneValue(asset) };
|
|
398
|
+
return result;
|
|
399
|
+
});
|
|
400
|
+
},
|
|
401
|
+
list(query, context) {
|
|
402
|
+
return run('media', 'list', context, () => {
|
|
403
|
+
ensureLimit(query.limit);
|
|
404
|
+
const search = query.search?.toLowerCase();
|
|
405
|
+
const filtered = mediaAssets.filter((asset) => {
|
|
406
|
+
if (query.mediaTypes !== undefined && !query.mediaTypes.includes(asset.mediaType)) {
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
return search === undefined || asset.filename.toLowerCase().includes(search);
|
|
410
|
+
});
|
|
411
|
+
const start = resolveStart(query.cursor);
|
|
412
|
+
const nextIndex = start + query.limit;
|
|
413
|
+
return {
|
|
414
|
+
value: {
|
|
415
|
+
assets: cloneValue(filtered.slice(start, nextIndex)),
|
|
416
|
+
...(nextIndex < filtered.length ? { nextCursor: encodeCursor(nextIndex) } : {}),
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
});
|
|
420
|
+
},
|
|
421
|
+
importExternal(url, context) {
|
|
422
|
+
return runMutation('media', 'import-external', url, context, () => {
|
|
423
|
+
const verdict = validateExternalUrl(url);
|
|
424
|
+
if (!verdict.ok) {
|
|
425
|
+
// The stable reason is disclosed; the candidate never is.
|
|
426
|
+
fail('validation-failed', `The external media candidate was refused: ${verdict.reason}.`);
|
|
427
|
+
}
|
|
428
|
+
importSerial += 1;
|
|
429
|
+
const accepted = {
|
|
430
|
+
id: `assets/imported-${String(importSerial)}`,
|
|
431
|
+
revision: `imports/testbed-${String(importSerial)}-r1`,
|
|
432
|
+
state: 'processing',
|
|
433
|
+
};
|
|
434
|
+
acceptedUploads.set(accepted.id, accepted);
|
|
435
|
+
return { revision: accepted.revision, value: cloneValue(accepted) };
|
|
436
|
+
});
|
|
437
|
+
},
|
|
438
|
+
uploadStatus(assetId, context) {
|
|
439
|
+
return run('media', 'upload-status', context, () => {
|
|
440
|
+
const accepted = acceptedUploads.get(assetId);
|
|
441
|
+
if (accepted === undefined) {
|
|
442
|
+
fail('not-found', 'The requested asset is not available.');
|
|
443
|
+
}
|
|
444
|
+
return { revision: accepted.revision, value: cloneValue(accepted) };
|
|
445
|
+
});
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
model: {
|
|
449
|
+
get(reference, context) {
|
|
450
|
+
return run('model', 'get', context, () => {
|
|
451
|
+
const stored = requireStored(reference.id);
|
|
452
|
+
if (stored.document.kind !== 'content-model' ||
|
|
453
|
+
stored.document.version !== reference.version ||
|
|
454
|
+
('revision' in reference && reference.revision !== stored.revision)) {
|
|
455
|
+
fail('not-found', 'The requested model projection is not available.');
|
|
456
|
+
}
|
|
457
|
+
return {
|
|
458
|
+
revision: stored.revision,
|
|
459
|
+
value: cloneValue(stored.document),
|
|
460
|
+
};
|
|
461
|
+
});
|
|
462
|
+
},
|
|
463
|
+
list(context) {
|
|
464
|
+
return run('model', 'list', context, () => {
|
|
465
|
+
const value = [...artifactStore.values()]
|
|
466
|
+
.filter((stored) => stored.document.kind === 'content-model')
|
|
467
|
+
.map((stored) => cloneValue(stored.document))
|
|
468
|
+
.sort((left, right) => compareCodeUnits(left.id, right.id) ||
|
|
469
|
+
compareCodeUnits(left.version, right.version) ||
|
|
470
|
+
compareCodeUnits(left.revision, right.revision));
|
|
471
|
+
return { value };
|
|
472
|
+
});
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
permission: {
|
|
476
|
+
explain(operation, context) {
|
|
477
|
+
return run('permission', 'explain', context, () => {
|
|
478
|
+
const explanation = permissions.includes(operation)
|
|
479
|
+
? { allowed: true }
|
|
480
|
+
: {
|
|
481
|
+
allowed: false,
|
|
482
|
+
reason: {
|
|
483
|
+
defaultMessage: 'The session does not hold this permission.',
|
|
484
|
+
key: 'studio.testbed/permission-denied',
|
|
485
|
+
},
|
|
486
|
+
};
|
|
487
|
+
return { value: explanation };
|
|
488
|
+
});
|
|
489
|
+
},
|
|
490
|
+
refresh(context) {
|
|
491
|
+
return run('permission', 'refresh', context, () => ({
|
|
492
|
+
value: { permissions: permissions.slice(), sessionGeneration: currentGeneration() },
|
|
493
|
+
}));
|
|
494
|
+
},
|
|
495
|
+
},
|
|
496
|
+
preview: {
|
|
497
|
+
cancel(draftDigest, context) {
|
|
498
|
+
return run('preview', 'cancel', context, () => {
|
|
499
|
+
const pending = pendingPreviewRenders.get(previewRenderKey(draftDigest, context));
|
|
500
|
+
pending?.cancel();
|
|
501
|
+
return { value: null };
|
|
502
|
+
});
|
|
503
|
+
},
|
|
504
|
+
render(payload, context) {
|
|
505
|
+
return run('preview', 'render', context, async () => {
|
|
506
|
+
const key = previewRenderKey(payload.draftDigest, context);
|
|
507
|
+
if (pendingPreviewRenders.has(key)) {
|
|
508
|
+
fail('invalid-request', 'A render for this draft is already in flight.');
|
|
509
|
+
}
|
|
510
|
+
let cancel = () => undefined;
|
|
511
|
+
const cancellation = new Promise((_resolve, reject) => {
|
|
512
|
+
cancel = () => {
|
|
513
|
+
reject(new TestbedHostError(createError('cancelled', 'The in-flight preview render was cancelled.', {
|
|
514
|
+
retryable: false,
|
|
515
|
+
})));
|
|
516
|
+
};
|
|
517
|
+
});
|
|
518
|
+
pendingPreviewRenders.set(key, { cancel });
|
|
519
|
+
try {
|
|
520
|
+
const rendered = await Promise.race([
|
|
521
|
+
Promise.resolve(renderCallback(cloneValue(payload))),
|
|
522
|
+
cancellation,
|
|
523
|
+
]);
|
|
524
|
+
if (rendered.draftDigest !== payload.draftDigest ||
|
|
525
|
+
rendered.requestId !== payload.requestId) {
|
|
526
|
+
fail('internal', 'The preview renderer returned a result for a different render attempt.');
|
|
527
|
+
}
|
|
528
|
+
previewDeliveries.push(payload.draftDigest);
|
|
529
|
+
return { value: cloneValue(rendered) };
|
|
530
|
+
}
|
|
531
|
+
finally {
|
|
532
|
+
pendingPreviewRenders.delete(key);
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
},
|
|
536
|
+
},
|
|
537
|
+
recovery: {
|
|
538
|
+
discard(context) {
|
|
539
|
+
return runMutation('recovery', 'discard', null, context, () => {
|
|
540
|
+
recoveryStore.delete(context.resourceContextKey);
|
|
541
|
+
return { value: null };
|
|
542
|
+
});
|
|
543
|
+
},
|
|
544
|
+
load(context) {
|
|
545
|
+
return run('recovery', 'load', context, () => {
|
|
546
|
+
const envelope = recoveryStore.get(context.resourceContextKey);
|
|
547
|
+
const result = envelope === undefined ? { value: null } : { value: cloneValue(envelope) };
|
|
548
|
+
return result;
|
|
549
|
+
});
|
|
550
|
+
},
|
|
551
|
+
store(envelope, context) {
|
|
552
|
+
return runMutation('recovery', 'store', envelope, context, () => {
|
|
553
|
+
recoveryStore.set(context.resourceContextKey, cloneValue(envelope));
|
|
554
|
+
return { value: null };
|
|
555
|
+
});
|
|
556
|
+
},
|
|
557
|
+
},
|
|
558
|
+
resource: {
|
|
559
|
+
search(query, context) {
|
|
560
|
+
return run('resource', 'search', context, () => {
|
|
561
|
+
ensureLimit(query.limit);
|
|
562
|
+
const search = query.search?.toLowerCase();
|
|
563
|
+
const filtered = resources.filter((hit) => {
|
|
564
|
+
if (hit.resourceType !== query.resourceType) {
|
|
565
|
+
return false;
|
|
566
|
+
}
|
|
567
|
+
return (search === undefined ||
|
|
568
|
+
(hit.label.defaultMessage?.toLowerCase().includes(search) ?? false));
|
|
569
|
+
});
|
|
570
|
+
const start = resolveStart(query.cursor);
|
|
571
|
+
const nextIndex = start + query.limit;
|
|
572
|
+
return {
|
|
573
|
+
value: {
|
|
574
|
+
items: cloneValue(filtered.slice(start, nextIndex)),
|
|
575
|
+
...(nextIndex < filtered.length ? { nextCursor: encodeCursor(nextIndex) } : {}),
|
|
576
|
+
},
|
|
577
|
+
};
|
|
578
|
+
});
|
|
579
|
+
},
|
|
580
|
+
},
|
|
581
|
+
telemetry: {
|
|
582
|
+
emit(event, context) {
|
|
583
|
+
return runMutation('telemetry', 'emit', event, context, () => {
|
|
584
|
+
if (event.attributes !== undefined) {
|
|
585
|
+
for (const attribute of Object.values(event.attributes)) {
|
|
586
|
+
if (!isJsonPrimitiveValue(attribute)) {
|
|
587
|
+
fail('invalid-request', 'Telemetry attribute values must be JSON primitives.');
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
telemetryEvents.push(cloneValue(event));
|
|
592
|
+
return { value: null };
|
|
593
|
+
});
|
|
594
|
+
},
|
|
595
|
+
},
|
|
596
|
+
};
|
|
597
|
+
const controls = {
|
|
598
|
+
advanceClock(milliseconds) {
|
|
599
|
+
if (!Number.isSafeInteger(milliseconds) || milliseconds < 0) {
|
|
600
|
+
throw new RangeError('Testbed clock advances must be non-negative safe integers.');
|
|
601
|
+
}
|
|
602
|
+
const next = clockMilliseconds + milliseconds;
|
|
603
|
+
if (!Number.isSafeInteger(next)) {
|
|
604
|
+
throw new RangeError('The testbed logical clock cannot exceed the safe-integer bound.');
|
|
605
|
+
}
|
|
606
|
+
clockMilliseconds = next;
|
|
607
|
+
},
|
|
608
|
+
artifactStatus(id) {
|
|
609
|
+
return artifactStore.get(id)?.document.status;
|
|
610
|
+
},
|
|
611
|
+
disconnect() {
|
|
612
|
+
connected = false;
|
|
613
|
+
},
|
|
614
|
+
failNext(portName, operation, category) {
|
|
615
|
+
injectedFailures.push({ category, operation, portName });
|
|
616
|
+
},
|
|
617
|
+
importExternalMedia(candidate, context) {
|
|
618
|
+
return run('media', 'import-external', context, () => {
|
|
619
|
+
const verdict = validateExternalUrl(candidate);
|
|
620
|
+
if (!verdict.ok) {
|
|
621
|
+
fail('validation-failed', `The external media source is not allowed by the URL policy (${verdict.reason}).`);
|
|
622
|
+
}
|
|
623
|
+
importSerial += 1;
|
|
624
|
+
const id = `media/import-${importSerial}`;
|
|
625
|
+
const revision = `${id}-r1`;
|
|
626
|
+
mediaAssets.push({
|
|
627
|
+
byteSize: 0,
|
|
628
|
+
contractVersion: STUDIO_CONTRACT_VERSION,
|
|
629
|
+
filename: `import-${importSerial}`,
|
|
630
|
+
id,
|
|
631
|
+
kind: 'media-asset',
|
|
632
|
+
mediaKind: 'other',
|
|
633
|
+
mediaType: 'application/octet-stream',
|
|
634
|
+
metadata: {},
|
|
635
|
+
revision,
|
|
636
|
+
state: 'processing',
|
|
637
|
+
});
|
|
638
|
+
return { revision, value: { id, revision, state: 'processing' } };
|
|
639
|
+
});
|
|
640
|
+
},
|
|
641
|
+
reconnect() {
|
|
642
|
+
connected = true;
|
|
643
|
+
},
|
|
644
|
+
recoveryEnvelope(resourceContextKey) {
|
|
645
|
+
const envelope = recoveryStore.get(resourceContextKey);
|
|
646
|
+
return envelope === undefined ? undefined : cloneValue(envelope);
|
|
647
|
+
},
|
|
648
|
+
revisionOf(id) {
|
|
649
|
+
return artifactStore.get(id)?.revision;
|
|
650
|
+
},
|
|
651
|
+
get pendingPreviewRenders() {
|
|
652
|
+
return pendingPreviewRenders.size;
|
|
653
|
+
},
|
|
654
|
+
get previewDeliveries() {
|
|
655
|
+
return previewDeliveries.slice();
|
|
656
|
+
},
|
|
657
|
+
get sessionGeneration() {
|
|
658
|
+
return currentGeneration();
|
|
659
|
+
},
|
|
660
|
+
setPermissions(next) {
|
|
661
|
+
permissions = next.slice();
|
|
662
|
+
generationSerial += 1;
|
|
663
|
+
liveGeneration = `session-r${generationSerial}`;
|
|
664
|
+
},
|
|
665
|
+
get telemetryEvents() {
|
|
666
|
+
return cloneValue(telemetryEvents);
|
|
667
|
+
},
|
|
668
|
+
};
|
|
669
|
+
return { controls, host };
|
|
670
|
+
}
|
|
671
|
+
function defaultRender(payload) {
|
|
672
|
+
return {
|
|
673
|
+
diagnostics: [],
|
|
674
|
+
draftDigest: payload.draftDigest,
|
|
675
|
+
markerMap: {},
|
|
676
|
+
markers: [],
|
|
677
|
+
requestId: payload.requestId,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
function previewRenderKey(draftDigest, context) {
|
|
681
|
+
return `${context.sessionGeneration}\u0000${context.resourceContextKey}\u0000${draftDigest}`;
|
|
682
|
+
}
|
|
683
|
+
function seededRevisionSerial(id, revision) {
|
|
684
|
+
const prefix = `${id}-r`;
|
|
685
|
+
if (!revision.startsWith(prefix)) {
|
|
686
|
+
return 0;
|
|
687
|
+
}
|
|
688
|
+
const suffix = revision.slice(prefix.length);
|
|
689
|
+
const parsed = Number(suffix);
|
|
690
|
+
return Number.isSafeInteger(parsed) && parsed >= 0 && String(parsed) === suffix ? parsed : 0;
|
|
691
|
+
}
|
|
692
|
+
function isNonEmptyString(value) {
|
|
693
|
+
return typeof value === 'string' && value.length > 0;
|
|
694
|
+
}
|
|
695
|
+
function isJsonPrimitiveValue(value) {
|
|
696
|
+
return (value === null ||
|
|
697
|
+
typeof value === 'boolean' ||
|
|
698
|
+
typeof value === 'string' ||
|
|
699
|
+
(typeof value === 'number' && Number.isFinite(value)));
|
|
700
|
+
}
|
|
701
|
+
const CURSOR_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
702
|
+
function encodeCursor(index) {
|
|
703
|
+
const text = `index:${index}`;
|
|
704
|
+
let encoded = '';
|
|
705
|
+
for (let position = 0; position < text.length; position += 3) {
|
|
706
|
+
const first = text.charCodeAt(position);
|
|
707
|
+
const second = position + 1 < text.length ? text.charCodeAt(position + 1) : undefined;
|
|
708
|
+
const third = position + 2 < text.length ? text.charCodeAt(position + 2) : undefined;
|
|
709
|
+
const chunk = (first << 16) | ((second ?? 0) << 8) | (third ?? 0);
|
|
710
|
+
encoded += CURSOR_ALPHABET.charAt((chunk >> 18) & 0x3f);
|
|
711
|
+
encoded += CURSOR_ALPHABET.charAt((chunk >> 12) & 0x3f);
|
|
712
|
+
encoded += second === undefined ? '=' : CURSOR_ALPHABET.charAt((chunk >> 6) & 0x3f);
|
|
713
|
+
encoded += third === undefined ? '=' : CURSOR_ALPHABET.charAt(chunk & 0x3f);
|
|
714
|
+
}
|
|
715
|
+
return encoded;
|
|
716
|
+
}
|
|
717
|
+
function decodeCursor(cursor) {
|
|
718
|
+
if (cursor.length === 0 || cursor.length % 4 !== 0) {
|
|
719
|
+
return undefined;
|
|
720
|
+
}
|
|
721
|
+
const paddingLength = cursor.endsWith('==') ? 2 : cursor.endsWith('=') ? 1 : 0;
|
|
722
|
+
const body = cursor.slice(0, cursor.length - paddingLength);
|
|
723
|
+
let bits = 0;
|
|
724
|
+
let bitCount = 0;
|
|
725
|
+
let text = '';
|
|
726
|
+
for (const character of body) {
|
|
727
|
+
const sextet = CURSOR_ALPHABET.indexOf(character);
|
|
728
|
+
if (sextet < 0) {
|
|
729
|
+
return undefined;
|
|
730
|
+
}
|
|
731
|
+
bits = (bits << 6) | sextet;
|
|
732
|
+
bitCount += 6;
|
|
733
|
+
if (bitCount >= 8) {
|
|
734
|
+
bitCount -= 8;
|
|
735
|
+
text += String.fromCharCode((bits >> bitCount) & 0xff);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
const match = /^index:(0|[1-9][0-9]*)$/u.exec(text);
|
|
739
|
+
const digits = match?.[1];
|
|
740
|
+
if (digits === undefined || digits.length > 9) {
|
|
741
|
+
return undefined;
|
|
742
|
+
}
|
|
743
|
+
return Number(digits);
|
|
744
|
+
}
|
|
745
|
+
function cloneValue(value) {
|
|
746
|
+
return JSON.parse(JSON.stringify(value));
|
|
747
|
+
}
|
|
748
|
+
function compareCodeUnits(left, right) {
|
|
749
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* The policy gate that runs before any byte moves: a declared upload larger
|
|
753
|
+
* than the host's bound, or one carrying a filename the canonical shape
|
|
754
|
+
* refuses, is rejected at authorization rather than after a transfer.
|
|
755
|
+
*/
|
|
756
|
+
function assertUploadRequest(request) {
|
|
757
|
+
if (request.byteSize > UPLOAD_MAXIMUM_BYTES) {
|
|
758
|
+
throw new TestbedHostError({
|
|
759
|
+
category: 'limit-exceeded',
|
|
760
|
+
contractVersion: STUDIO_CONTRACT_VERSION,
|
|
761
|
+
kind: 'host-error',
|
|
762
|
+
message: {
|
|
763
|
+
defaultMessage: 'The declared upload exceeds the accepted size.',
|
|
764
|
+
key: 'studio.testbed/limit-exceeded',
|
|
765
|
+
},
|
|
766
|
+
retryable: false,
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
if (request.filename.includes('/') || request.filename.includes('\\')) {
|
|
770
|
+
throw new TestbedHostError({
|
|
771
|
+
category: 'validation-failed',
|
|
772
|
+
contractVersion: STUDIO_CONTRACT_VERSION,
|
|
773
|
+
kind: 'host-error',
|
|
774
|
+
message: {
|
|
775
|
+
defaultMessage: 'The declared filename is not a canonical display name.',
|
|
776
|
+
key: 'studio.testbed/validation-failed',
|
|
777
|
+
},
|
|
778
|
+
retryable: false,
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
//# sourceMappingURL=host-testbed.js.map
|