@kumwe/studio-testkit 0.1.0-alpha.6 → 0.1.0-alpha.8
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/README.md +75 -10
- package/corpus-manifest.json +1106 -0
- package/dist/host-testbed.d.ts +22 -2
- package/dist/host-testbed.d.ts.map +1 -1
- package/dist/host-testbed.js +357 -31
- package/dist/host-testbed.js.map +1 -1
- package/dist/http-host-adapter.d.ts.map +1 -1
- package/dist/http-host-adapter.js +15 -0
- package/dist/http-host-adapter.js.map +1 -1
- package/dist/index.d.ts +40 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +36 -2
- package/dist/index.js.map +1 -1
- package/fixtures/field-adapter.example.json +27 -0
- package/fixtures/host-capabilities.example.json +11 -5
- package/fixtures/host-operations.example.json +270 -0
- package/fixtures/inspector.example.json +17 -0
- package/fixtures/media-upload-grant.example.json +14 -0
- package/fixtures/preview.render.example.json +1 -0
- package/fixtures/studio-config.example.json +4 -4
- package/invalid/host-capabilities.invalid-protocol-version.json +1 -1
- 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-upload-grant.insecure-url.json +14 -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 +1 -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/package.json +6 -4
- 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/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/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/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
package/dist/host-testbed.d.ts
CHANGED
|
@@ -1,14 +1,31 @@
|
|
|
1
|
-
import { type HostAdapter, type HostErrorCategory, type HostPortError, type HostPortResult, type HostRequestContext, type MediaAsset, type MediaUploadAcceptedAsset, type PreviewRenderPayload, type PreviewRenderedPayload, type QualifiedName, type ResourceSearchHit, type Revision, type StableId, type StudioArtifact, type StudioWireProtocolVersion, type TelemetryEvent } from '@kumwe/studio-protocol';
|
|
1
|
+
import { type HostAdapter, type HostErrorCategory, type HostPortError, type HostPortResult, type HostRequestContext, type JsonObject, type MediaAsset, type MediaUploadAcceptedAsset, type PreviewRenderPayload, type PreviewRenderedPayload, type QualifiedName, type ResourceSearchHit, type Revision, type StableId, type StudioArtifact, type StudioWireProtocolVersion, type TelemetryEvent } from '@kumwe/studio-protocol';
|
|
2
2
|
export type TestbedPortName = 'artifact' | 'localization' | 'media' | 'permission' | 'preview' | 'recovery' | 'resource' | 'telemetry';
|
|
3
3
|
export interface TestbedHostOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Permits the fixture-only `studio.test/operation` wildcard used by broad
|
|
6
|
+
* unit tests. Defaults to false and MUST remain false for conformance replay.
|
|
7
|
+
*/
|
|
8
|
+
allowTestOperationId?: boolean;
|
|
4
9
|
documents?: StudioArtifact[];
|
|
10
|
+
initialClockMilliseconds?: number;
|
|
5
11
|
mediaAssets?: MediaAsset[];
|
|
6
12
|
messages?: Record<string, Record<QualifiedName, string>>;
|
|
7
13
|
permissions?: QualifiedName[];
|
|
8
|
-
|
|
14
|
+
rateLimits?: TestbedRateLimitPolicy[];
|
|
15
|
+
render?: (payload: PreviewRenderPayload) => PreviewRenderedPayload | Promise<PreviewRenderedPayload>;
|
|
9
16
|
resources?: ResourceSearchHit[];
|
|
17
|
+
/** Exact live generation to seed before the first portable request. */
|
|
18
|
+
sessionGeneration?: Revision;
|
|
19
|
+
}
|
|
20
|
+
/** A deterministic fixed-window policy used by portable sequence replays. */
|
|
21
|
+
export interface TestbedRateLimitPolicy {
|
|
22
|
+
maximumRequests: number;
|
|
23
|
+
operationId: QualifiedName;
|
|
24
|
+
windowMilliseconds: number;
|
|
10
25
|
}
|
|
11
26
|
export interface TestbedControls {
|
|
27
|
+
advanceClock(milliseconds: number): void;
|
|
28
|
+
artifactStatus(id: StableId): StudioArtifact['status'] | undefined;
|
|
12
29
|
disconnect(): void;
|
|
13
30
|
failNext(portName: TestbedPortName, operation: string, category: HostErrorCategory): void;
|
|
14
31
|
/**
|
|
@@ -24,7 +41,10 @@ export interface TestbedControls {
|
|
|
24
41
|
*/
|
|
25
42
|
importExternalMedia(candidate: string, context: HostRequestContext): Promise<HostPortResult<MediaUploadAcceptedAsset>>;
|
|
26
43
|
reconnect(): void;
|
|
44
|
+
recoveryEnvelope(resourceContextKey: StableId): JsonObject | undefined;
|
|
27
45
|
revisionOf(id: StableId): Revision | undefined;
|
|
46
|
+
readonly pendingPreviewRenders: number;
|
|
47
|
+
readonly previewDeliveries: readonly string[];
|
|
28
48
|
readonly sessionGeneration: Revision;
|
|
29
49
|
setPermissions(permissions: QualifiedName[]): void;
|
|
30
50
|
readonly telemetryEvents: readonly TelemetryEvent[];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"host-testbed.d.ts","sourceRoot":"","sources":["../src/host-testbed.ts"],"names":[],"mappings":"AACA,OAAO,EAGL,KAAK,WAAW,EAChB,KAAK,iBAAiB,EACtB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,kBAAkB,
|
|
1
|
+
{"version":3,"file":"host-testbed.d.ts","sourceRoot":"","sources":["../src/host-testbed.ts"],"names":[],"mappings":"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"}
|
package/dist/host-testbed.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { validateExternalUrl } from '@kumwe/studio-core';
|
|
1
|
+
import { canonicalStringify, validateExternalUrl } from '@kumwe/studio-core';
|
|
2
2
|
import { STUDIO_CONTRACT_VERSION, STUDIO_WIRE_PROTOCOL_VERSION, } from '@kumwe/studio-protocol';
|
|
3
3
|
export class TestbedHostError extends Error {
|
|
4
4
|
error;
|
|
@@ -22,13 +22,32 @@ export function createHostRequestContextFixture(options = {}) {
|
|
|
22
22
|
sessionGeneration: options.sessionGeneration ?? 'session-r1',
|
|
23
23
|
};
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* The permissions this reference host gates artifact mutations behind. A host
|
|
27
|
+
* chooses its own names; what the contract fixes is that a mutation is
|
|
28
|
+
* authorized and that a withheld permission is refused as `forbidden`.
|
|
29
|
+
*/
|
|
30
|
+
const SAVE_PERMISSION = 'studio.permission/save';
|
|
31
|
+
const PUBLISH_PERMISSION = 'studio.permission/publish';
|
|
32
|
+
/** Deterministic bounds the reference host authorizes uploads within. */
|
|
33
|
+
const UPLOAD_CHUNK_BYTES = 5_242_880;
|
|
34
|
+
const UPLOAD_MAXIMUM_BYTES = 52_428_800;
|
|
35
|
+
const GRANT_EXPIRY = '2030-01-01T00:00:00Z';
|
|
36
|
+
const MAXIMUM_RATE_LIMIT_REQUESTS = 1000;
|
|
37
|
+
const MAXIMUM_RATE_LIMIT_WINDOW_MILLISECONDS = 86_400_000;
|
|
25
38
|
export function createTestbedHost(options = {}) {
|
|
26
39
|
const artifactStore = new Map();
|
|
27
40
|
for (const seeded of options.documents ?? []) {
|
|
28
41
|
const document = cloneValue(seeded);
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
42
|
+
if (artifactStore.has(document.id)) {
|
|
43
|
+
throw new TypeError(`Testbed artifact ${document.id} is duplicated.`);
|
|
44
|
+
}
|
|
45
|
+
const revision = document.revision;
|
|
46
|
+
artifactStore.set(document.id, {
|
|
47
|
+
document,
|
|
48
|
+
revision,
|
|
49
|
+
serial: seededRevisionSerial(document.id, revision),
|
|
50
|
+
});
|
|
32
51
|
}
|
|
33
52
|
const mediaAssets = cloneValue(options.mediaAssets ?? []);
|
|
34
53
|
const resources = cloneValue(options.resources ?? []);
|
|
@@ -37,13 +56,43 @@ export function createTestbedHost(options = {}) {
|
|
|
37
56
|
const telemetryEvents = [];
|
|
38
57
|
const injectedFailures = [];
|
|
39
58
|
const renderCallback = options.render ?? defaultRender;
|
|
59
|
+
const idempotencyRecords = new Map();
|
|
60
|
+
const pendingPreviewRenders = new Map();
|
|
61
|
+
const previewDeliveries = [];
|
|
62
|
+
const rateLimitPolicies = new Map();
|
|
63
|
+
const rateLimitWindows = new Map();
|
|
64
|
+
for (const policy of options.rateLimits ?? []) {
|
|
65
|
+
if (!Number.isSafeInteger(policy.maximumRequests) ||
|
|
66
|
+
policy.maximumRequests < 1 ||
|
|
67
|
+
policy.maximumRequests > MAXIMUM_RATE_LIMIT_REQUESTS ||
|
|
68
|
+
!Number.isSafeInteger(policy.windowMilliseconds) ||
|
|
69
|
+
policy.windowMilliseconds < 1 ||
|
|
70
|
+
policy.windowMilliseconds > MAXIMUM_RATE_LIMIT_WINDOW_MILLISECONDS) {
|
|
71
|
+
throw new RangeError('Testbed rate limits require positive safe-integer bounds.');
|
|
72
|
+
}
|
|
73
|
+
if (rateLimitPolicies.has(policy.operationId)) {
|
|
74
|
+
throw new TypeError(`Testbed rate limit ${policy.operationId} is duplicated.`);
|
|
75
|
+
}
|
|
76
|
+
rateLimitPolicies.set(policy.operationId, { ...policy });
|
|
77
|
+
}
|
|
78
|
+
const uploadGrants = new Map();
|
|
79
|
+
const acceptedUploads = new Map();
|
|
80
|
+
let uploadSerial = 0;
|
|
40
81
|
let permissions = (options.permissions ?? []).slice();
|
|
41
|
-
let
|
|
82
|
+
let liveGeneration = options.sessionGeneration ?? 'session-r1';
|
|
83
|
+
if (!isNonEmptyString(liveGeneration) || liveGeneration.length > 200) {
|
|
84
|
+
throw new TypeError('The testbed session generation must be a non-empty bounded revision.');
|
|
85
|
+
}
|
|
86
|
+
let generationSerial = seededRevisionSerial('session', liveGeneration);
|
|
42
87
|
let connected = true;
|
|
43
88
|
let correlationSerial = 0;
|
|
44
89
|
let importSerial = 0;
|
|
90
|
+
let clockMilliseconds = options.initialClockMilliseconds ?? 0;
|
|
91
|
+
if (!Number.isSafeInteger(clockMilliseconds) || clockMilliseconds < 0) {
|
|
92
|
+
throw new RangeError('The testbed logical clock must be a non-negative safe integer.');
|
|
93
|
+
}
|
|
45
94
|
function currentGeneration() {
|
|
46
|
-
return
|
|
95
|
+
return liveGeneration;
|
|
47
96
|
}
|
|
48
97
|
function createError(category, defaultMessage, extras = {}) {
|
|
49
98
|
correlationSerial += 1;
|
|
@@ -54,24 +103,19 @@ export function createTestbedHost(options = {}) {
|
|
|
54
103
|
kind: 'host-error',
|
|
55
104
|
message: { defaultMessage, key: `studio.testbed/${category}` },
|
|
56
105
|
retryable: extras.retryable ?? (category === 'rate-limited' || category === 'unavailable'),
|
|
106
|
+
...(extras.retryAfterMilliseconds === undefined
|
|
107
|
+
? {}
|
|
108
|
+
: { retryAfterMilliseconds: extras.retryAfterMilliseconds }),
|
|
57
109
|
...(extras.revision === undefined ? {} : { revision: extras.revision }),
|
|
58
110
|
};
|
|
59
111
|
}
|
|
60
112
|
function fail(category, defaultMessage, extras = {}) {
|
|
61
113
|
throw new TestbedHostError(createError(category, defaultMessage, extras));
|
|
62
114
|
}
|
|
63
|
-
function
|
|
115
|
+
function guardEnvelope(portName, operation, context) {
|
|
64
116
|
if (!connected) {
|
|
65
117
|
fail('unavailable', 'The testbed host is disconnected.');
|
|
66
118
|
}
|
|
67
|
-
const index = injectedFailures.findIndex((entry) => entry.portName === portName && entry.operation === operation);
|
|
68
|
-
if (index >= 0) {
|
|
69
|
-
const injected = injectedFailures[index];
|
|
70
|
-
injectedFailures.splice(index, 1);
|
|
71
|
-
if (injected !== undefined) {
|
|
72
|
-
fail(injected.category, 'The testbed injected this failure.');
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
119
|
if (!isNonEmptyString(context.requestId) ||
|
|
76
120
|
!isNonEmptyString(context.operationId) ||
|
|
77
121
|
!isNonEmptyString(context.resourceContextKey) ||
|
|
@@ -84,17 +128,120 @@ export function createTestbedHost(options = {}) {
|
|
|
84
128
|
if (context.sessionGeneration !== currentGeneration()) {
|
|
85
129
|
fail('invalid-request', 'The session generation is no longer valid.');
|
|
86
130
|
}
|
|
131
|
+
const expectedOperationId = `studio.operation/${portName}.${operation}`;
|
|
132
|
+
if (context.operationId !== expectedOperationId &&
|
|
133
|
+
!(options.allowTestOperationId === true && context.operationId === 'studio.test/operation')) {
|
|
134
|
+
fail('invalid-request', 'The request operation identifier does not match the invoked port.');
|
|
135
|
+
}
|
|
87
136
|
}
|
|
88
|
-
function
|
|
137
|
+
function applyOperationalGuards(portName, operation, context) {
|
|
138
|
+
const index = injectedFailures.findIndex((entry) => entry.portName === portName && entry.operation === operation);
|
|
139
|
+
if (index >= 0) {
|
|
140
|
+
const injected = injectedFailures[index];
|
|
141
|
+
injectedFailures.splice(index, 1);
|
|
142
|
+
if (injected !== undefined) {
|
|
143
|
+
fail(injected.category, 'The testbed injected this failure.');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const policy = rateLimitPolicies.get(context.operationId);
|
|
147
|
+
if (policy === undefined) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
let rateWindow = rateLimitWindows.get(context.operationId);
|
|
151
|
+
if (rateWindow === undefined ||
|
|
152
|
+
clockMilliseconds >= rateWindow.startedAtMilliseconds + policy.windowMilliseconds) {
|
|
153
|
+
rateWindow = { requestCount: 0, startedAtMilliseconds: clockMilliseconds };
|
|
154
|
+
rateLimitWindows.set(context.operationId, rateWindow);
|
|
155
|
+
}
|
|
156
|
+
if (rateWindow.requestCount >= policy.maximumRequests) {
|
|
157
|
+
fail('rate-limited', 'The operation has reached its deterministic request bound.', {
|
|
158
|
+
retryAfterMilliseconds: rateWindow.startedAtMilliseconds + policy.windowMilliseconds - clockMilliseconds,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
rateWindow.requestCount += 1;
|
|
162
|
+
}
|
|
163
|
+
async function executeBody(body) {
|
|
89
164
|
try {
|
|
90
|
-
|
|
91
|
-
return Promise.resolve(body());
|
|
165
|
+
return await body();
|
|
92
166
|
}
|
|
93
167
|
catch (error) {
|
|
94
168
|
if (error instanceof TestbedHostError) {
|
|
95
|
-
|
|
169
|
+
throw error;
|
|
96
170
|
}
|
|
97
|
-
|
|
171
|
+
throw new TestbedHostError(createError('internal', 'The testbed operation failed unexpectedly.'));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function run(portName, operation, context, body) {
|
|
175
|
+
try {
|
|
176
|
+
guardEnvelope(portName, operation, context);
|
|
177
|
+
applyOperationalGuards(portName, operation, context);
|
|
178
|
+
return executeBody(body);
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
return executeBody(() => {
|
|
182
|
+
throw error;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function runMutation(portName, operation, argument, context, body) {
|
|
187
|
+
try {
|
|
188
|
+
guardEnvelope(portName, operation, context);
|
|
189
|
+
const idempotencyKey = context.idempotencyKey;
|
|
190
|
+
if (idempotencyKey === undefined) {
|
|
191
|
+
applyOperationalGuards(portName, operation, context);
|
|
192
|
+
return executeBody(body);
|
|
193
|
+
}
|
|
194
|
+
const scope = canonicalStringify({
|
|
195
|
+
idempotencyKey,
|
|
196
|
+
operationId: context.operationId,
|
|
197
|
+
resourceContextKey: context.resourceContextKey,
|
|
198
|
+
sessionGeneration: context.sessionGeneration,
|
|
199
|
+
});
|
|
200
|
+
const fingerprint = canonicalStringify({
|
|
201
|
+
argument,
|
|
202
|
+
context: {
|
|
203
|
+
...(context.expectedRevision === undefined
|
|
204
|
+
? {}
|
|
205
|
+
: { expectedRevision: context.expectedRevision }),
|
|
206
|
+
...(context.locale === undefined ? {} : { locale: context.locale }),
|
|
207
|
+
protocolVersion: context.protocolVersion,
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
const prior = idempotencyRecords.get(scope);
|
|
211
|
+
if (prior !== undefined) {
|
|
212
|
+
if (prior.fingerprint !== fingerprint) {
|
|
213
|
+
fail('invalid-request', 'The idempotency key was already accepted for another intent.');
|
|
214
|
+
}
|
|
215
|
+
return prior.promise.then((result) => cloneValue(result));
|
|
216
|
+
}
|
|
217
|
+
applyOperationalGuards(portName, operation, context);
|
|
218
|
+
const accepted = executeBody(body).then((result) => cloneValue(result));
|
|
219
|
+
idempotencyRecords.set(scope, {
|
|
220
|
+
fingerprint,
|
|
221
|
+
promise: accepted,
|
|
222
|
+
});
|
|
223
|
+
void accepted.catch(() => {
|
|
224
|
+
if (idempotencyRecords.get(scope)?.promise === accepted) {
|
|
225
|
+
idempotencyRecords.delete(scope);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
return accepted.then((result) => cloneValue(result));
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
return executeBody(() => {
|
|
232
|
+
throw error;
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* The authorization gate a production host applies to every mutation. Reads
|
|
238
|
+
* are authorized by the resolved request context; mutations additionally
|
|
239
|
+
* require the host-declared permission for the operation, and a withheld
|
|
240
|
+
* permission is refused without disclosing whether the artifact exists.
|
|
241
|
+
*/
|
|
242
|
+
function requireArtifactPermission(permission) {
|
|
243
|
+
if (!permissions.includes(permission)) {
|
|
244
|
+
fail('forbidden', 'The session does not hold the permission this operation requires.');
|
|
98
245
|
}
|
|
99
246
|
}
|
|
100
247
|
function requireStored(id) {
|
|
@@ -118,6 +265,7 @@ export function createTestbedHost(options = {}) {
|
|
|
118
265
|
return stored.revision;
|
|
119
266
|
}
|
|
120
267
|
function setStatus(id, context, status) {
|
|
268
|
+
requireArtifactPermission(PUBLISH_PERMISSION);
|
|
121
269
|
const stored = requireStored(id);
|
|
122
270
|
ensureExpectedRevision(stored, context);
|
|
123
271
|
stored.document.status = status;
|
|
@@ -150,10 +298,11 @@ export function createTestbedHost(options = {}) {
|
|
|
150
298
|
});
|
|
151
299
|
},
|
|
152
300
|
publish(reference, context) {
|
|
153
|
-
return
|
|
301
|
+
return runMutation('artifact', 'publish', reference, context, () => setStatus(reference.id, context, 'published'));
|
|
154
302
|
},
|
|
155
303
|
save(document, context) {
|
|
156
|
-
return
|
|
304
|
+
return runMutation('artifact', 'save', document, context, () => {
|
|
305
|
+
requireArtifactPermission(SAVE_PERMISSION);
|
|
157
306
|
const stored = requireStored(document.id);
|
|
158
307
|
ensureExpectedRevision(stored, context);
|
|
159
308
|
stored.document = cloneValue(document);
|
|
@@ -161,7 +310,7 @@ export function createTestbedHost(options = {}) {
|
|
|
161
310
|
});
|
|
162
311
|
},
|
|
163
312
|
unpublish(reference, context) {
|
|
164
|
-
return
|
|
313
|
+
return runMutation('artifact', 'unpublish', reference, context, () => setStatus(reference.id, context, 'draft'));
|
|
165
314
|
},
|
|
166
315
|
},
|
|
167
316
|
localization: {
|
|
@@ -183,6 +332,53 @@ export function createTestbedHost(options = {}) {
|
|
|
183
332
|
},
|
|
184
333
|
},
|
|
185
334
|
media: {
|
|
335
|
+
abortUpload(uploadId, context) {
|
|
336
|
+
return runMutation('media', 'abort-upload', uploadId, context, () => {
|
|
337
|
+
if (!uploadGrants.delete(uploadId)) {
|
|
338
|
+
fail('not-found', 'The requested upload is not available.');
|
|
339
|
+
}
|
|
340
|
+
return { value: null };
|
|
341
|
+
});
|
|
342
|
+
},
|
|
343
|
+
authorizeUpload(request, context) {
|
|
344
|
+
return runMutation('media', 'authorize-upload', request, context, () => {
|
|
345
|
+
assertUploadRequest(request);
|
|
346
|
+
uploadSerial += 1;
|
|
347
|
+
const uploadId = `uploads/testbed-${String(uploadSerial)}`;
|
|
348
|
+
const grant = {
|
|
349
|
+
expiresAt: GRANT_EXPIRY,
|
|
350
|
+
headers: { 'X-Upload-Session': uploadId },
|
|
351
|
+
method: 'PUT',
|
|
352
|
+
plan: {
|
|
353
|
+
chunkBytes: UPLOAD_CHUNK_BYTES,
|
|
354
|
+
maximumBytes: UPLOAD_MAXIMUM_BYTES,
|
|
355
|
+
resumable: true,
|
|
356
|
+
},
|
|
357
|
+
uploadId,
|
|
358
|
+
url: `https://uploads.testbed.invalid/${uploadId}`,
|
|
359
|
+
};
|
|
360
|
+
uploadGrants.set(uploadId, { grant, request: cloneValue(request) });
|
|
361
|
+
return { value: cloneValue(grant) };
|
|
362
|
+
});
|
|
363
|
+
},
|
|
364
|
+
completeUpload(uploadId, context) {
|
|
365
|
+
return runMutation('media', 'complete-upload', uploadId, context, () => {
|
|
366
|
+
const pending = uploadGrants.get(uploadId);
|
|
367
|
+
if (pending === undefined) {
|
|
368
|
+
fail('not-found', 'The requested upload is not available.');
|
|
369
|
+
}
|
|
370
|
+
uploadGrants.delete(uploadId);
|
|
371
|
+
// The host verifies what it received; a declared media type is
|
|
372
|
+
// never trusted, so acceptance lands in `processing`.
|
|
373
|
+
const accepted = {
|
|
374
|
+
id: `assets/${uploadId.replace('uploads/', '')}`,
|
|
375
|
+
revision: `${uploadId}-r1`,
|
|
376
|
+
state: 'processing',
|
|
377
|
+
};
|
|
378
|
+
acceptedUploads.set(accepted.id, accepted);
|
|
379
|
+
return { revision: accepted.revision, value: cloneValue(accepted) };
|
|
380
|
+
});
|
|
381
|
+
},
|
|
186
382
|
get(assetId, context) {
|
|
187
383
|
return run('media', 'get', context, () => {
|
|
188
384
|
const asset = mediaAssets.find((entry) => entry.id === assetId);
|
|
@@ -212,6 +408,32 @@ export function createTestbedHost(options = {}) {
|
|
|
212
408
|
};
|
|
213
409
|
});
|
|
214
410
|
},
|
|
411
|
+
importExternal(url, context) {
|
|
412
|
+
return runMutation('media', 'import-external', url, context, () => {
|
|
413
|
+
const verdict = validateExternalUrl(url);
|
|
414
|
+
if (!verdict.ok) {
|
|
415
|
+
// The stable reason is disclosed; the candidate never is.
|
|
416
|
+
fail('validation-failed', `The external media candidate was refused: ${verdict.reason}.`);
|
|
417
|
+
}
|
|
418
|
+
importSerial += 1;
|
|
419
|
+
const accepted = {
|
|
420
|
+
id: `assets/imported-${String(importSerial)}`,
|
|
421
|
+
revision: `imports/testbed-${String(importSerial)}-r1`,
|
|
422
|
+
state: 'processing',
|
|
423
|
+
};
|
|
424
|
+
acceptedUploads.set(accepted.id, accepted);
|
|
425
|
+
return { revision: accepted.revision, value: cloneValue(accepted) };
|
|
426
|
+
});
|
|
427
|
+
},
|
|
428
|
+
uploadStatus(assetId, context) {
|
|
429
|
+
return run('media', 'upload-status', context, () => {
|
|
430
|
+
const accepted = acceptedUploads.get(assetId);
|
|
431
|
+
if (accepted === undefined) {
|
|
432
|
+
fail('not-found', 'The requested asset is not available.');
|
|
433
|
+
}
|
|
434
|
+
return { revision: accepted.revision, value: cloneValue(accepted) };
|
|
435
|
+
});
|
|
436
|
+
},
|
|
215
437
|
},
|
|
216
438
|
permission: {
|
|
217
439
|
explain(operation, context) {
|
|
@@ -236,17 +458,48 @@ export function createTestbedHost(options = {}) {
|
|
|
236
458
|
},
|
|
237
459
|
preview: {
|
|
238
460
|
cancel(draftDigest, context) {
|
|
239
|
-
return run('preview', 'cancel', context, () =>
|
|
461
|
+
return run('preview', 'cancel', context, () => {
|
|
462
|
+
const pending = pendingPreviewRenders.get(previewRenderKey(draftDigest, context));
|
|
463
|
+
pending?.cancel();
|
|
464
|
+
return { value: null };
|
|
465
|
+
});
|
|
240
466
|
},
|
|
241
467
|
render(payload, context) {
|
|
242
|
-
return run('preview', 'render', context, () =>
|
|
243
|
-
|
|
244
|
-
|
|
468
|
+
return run('preview', 'render', context, async () => {
|
|
469
|
+
const key = previewRenderKey(payload.draftDigest, context);
|
|
470
|
+
if (pendingPreviewRenders.has(key)) {
|
|
471
|
+
fail('invalid-request', 'A render for this draft is already in flight.');
|
|
472
|
+
}
|
|
473
|
+
let cancel = () => undefined;
|
|
474
|
+
const cancellation = new Promise((_resolve, reject) => {
|
|
475
|
+
cancel = () => {
|
|
476
|
+
reject(new TestbedHostError(createError('cancelled', 'The in-flight preview render was cancelled.', {
|
|
477
|
+
retryable: false,
|
|
478
|
+
})));
|
|
479
|
+
};
|
|
480
|
+
});
|
|
481
|
+
pendingPreviewRenders.set(key, { cancel });
|
|
482
|
+
try {
|
|
483
|
+
const rendered = await Promise.race([
|
|
484
|
+
Promise.resolve(renderCallback(cloneValue(payload))),
|
|
485
|
+
cancellation,
|
|
486
|
+
]);
|
|
487
|
+
if (rendered.draftDigest !== payload.draftDigest ||
|
|
488
|
+
rendered.requestId !== payload.requestId) {
|
|
489
|
+
fail('internal', 'The preview renderer returned a result for a different render attempt.');
|
|
490
|
+
}
|
|
491
|
+
previewDeliveries.push(payload.draftDigest);
|
|
492
|
+
return { value: cloneValue(rendered) };
|
|
493
|
+
}
|
|
494
|
+
finally {
|
|
495
|
+
pendingPreviewRenders.delete(key);
|
|
496
|
+
}
|
|
497
|
+
});
|
|
245
498
|
},
|
|
246
499
|
},
|
|
247
500
|
recovery: {
|
|
248
501
|
discard(context) {
|
|
249
|
-
return
|
|
502
|
+
return runMutation('recovery', 'discard', null, context, () => {
|
|
250
503
|
recoveryStore.delete(context.resourceContextKey);
|
|
251
504
|
return { value: null };
|
|
252
505
|
});
|
|
@@ -259,7 +512,7 @@ export function createTestbedHost(options = {}) {
|
|
|
259
512
|
});
|
|
260
513
|
},
|
|
261
514
|
store(envelope, context) {
|
|
262
|
-
return
|
|
515
|
+
return runMutation('recovery', 'store', envelope, context, () => {
|
|
263
516
|
recoveryStore.set(context.resourceContextKey, cloneValue(envelope));
|
|
264
517
|
return { value: null };
|
|
265
518
|
});
|
|
@@ -290,7 +543,7 @@ export function createTestbedHost(options = {}) {
|
|
|
290
543
|
},
|
|
291
544
|
telemetry: {
|
|
292
545
|
emit(event, context) {
|
|
293
|
-
return
|
|
546
|
+
return runMutation('telemetry', 'emit', event, context, () => {
|
|
294
547
|
if (event.attributes !== undefined) {
|
|
295
548
|
for (const attribute of Object.values(event.attributes)) {
|
|
296
549
|
if (!isJsonPrimitiveValue(attribute)) {
|
|
@@ -305,6 +558,19 @@ export function createTestbedHost(options = {}) {
|
|
|
305
558
|
},
|
|
306
559
|
};
|
|
307
560
|
const controls = {
|
|
561
|
+
advanceClock(milliseconds) {
|
|
562
|
+
if (!Number.isSafeInteger(milliseconds) || milliseconds < 0) {
|
|
563
|
+
throw new RangeError('Testbed clock advances must be non-negative safe integers.');
|
|
564
|
+
}
|
|
565
|
+
const next = clockMilliseconds + milliseconds;
|
|
566
|
+
if (!Number.isSafeInteger(next)) {
|
|
567
|
+
throw new RangeError('The testbed logical clock cannot exceed the safe-integer bound.');
|
|
568
|
+
}
|
|
569
|
+
clockMilliseconds = next;
|
|
570
|
+
},
|
|
571
|
+
artifactStatus(id) {
|
|
572
|
+
return artifactStore.get(id)?.document.status;
|
|
573
|
+
},
|
|
308
574
|
disconnect() {
|
|
309
575
|
connected = false;
|
|
310
576
|
},
|
|
@@ -338,15 +604,26 @@ export function createTestbedHost(options = {}) {
|
|
|
338
604
|
reconnect() {
|
|
339
605
|
connected = true;
|
|
340
606
|
},
|
|
607
|
+
recoveryEnvelope(resourceContextKey) {
|
|
608
|
+
const envelope = recoveryStore.get(resourceContextKey);
|
|
609
|
+
return envelope === undefined ? undefined : cloneValue(envelope);
|
|
610
|
+
},
|
|
341
611
|
revisionOf(id) {
|
|
342
612
|
return artifactStore.get(id)?.revision;
|
|
343
613
|
},
|
|
614
|
+
get pendingPreviewRenders() {
|
|
615
|
+
return pendingPreviewRenders.size;
|
|
616
|
+
},
|
|
617
|
+
get previewDeliveries() {
|
|
618
|
+
return previewDeliveries.slice();
|
|
619
|
+
},
|
|
344
620
|
get sessionGeneration() {
|
|
345
621
|
return currentGeneration();
|
|
346
622
|
},
|
|
347
623
|
setPermissions(next) {
|
|
348
624
|
permissions = next.slice();
|
|
349
625
|
generationSerial += 1;
|
|
626
|
+
liveGeneration = `session-r${generationSerial}`;
|
|
350
627
|
},
|
|
351
628
|
get telemetryEvents() {
|
|
352
629
|
return cloneValue(telemetryEvents);
|
|
@@ -355,7 +632,25 @@ export function createTestbedHost(options = {}) {
|
|
|
355
632
|
return { controls, host };
|
|
356
633
|
}
|
|
357
634
|
function defaultRender(payload) {
|
|
358
|
-
return {
|
|
635
|
+
return {
|
|
636
|
+
diagnostics: [],
|
|
637
|
+
draftDigest: payload.draftDigest,
|
|
638
|
+
markerMap: {},
|
|
639
|
+
markers: [],
|
|
640
|
+
requestId: payload.requestId,
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
function previewRenderKey(draftDigest, context) {
|
|
644
|
+
return `${context.sessionGeneration}\u0000${context.resourceContextKey}\u0000${draftDigest}`;
|
|
645
|
+
}
|
|
646
|
+
function seededRevisionSerial(id, revision) {
|
|
647
|
+
const prefix = `${id}-r`;
|
|
648
|
+
if (!revision.startsWith(prefix)) {
|
|
649
|
+
return 0;
|
|
650
|
+
}
|
|
651
|
+
const suffix = revision.slice(prefix.length);
|
|
652
|
+
const parsed = Number(suffix);
|
|
653
|
+
return Number.isSafeInteger(parsed) && parsed >= 0 && String(parsed) === suffix ? parsed : 0;
|
|
359
654
|
}
|
|
360
655
|
function isNonEmptyString(value) {
|
|
361
656
|
return typeof value === 'string' && value.length > 0;
|
|
@@ -413,4 +708,35 @@ function decodeCursor(cursor) {
|
|
|
413
708
|
function cloneValue(value) {
|
|
414
709
|
return JSON.parse(JSON.stringify(value));
|
|
415
710
|
}
|
|
711
|
+
/**
|
|
712
|
+
* The policy gate that runs before any byte moves: a declared upload larger
|
|
713
|
+
* than the host's bound, or one carrying a filename the canonical shape
|
|
714
|
+
* refuses, is rejected at authorization rather than after a transfer.
|
|
715
|
+
*/
|
|
716
|
+
function assertUploadRequest(request) {
|
|
717
|
+
if (request.byteSize > UPLOAD_MAXIMUM_BYTES) {
|
|
718
|
+
throw new TestbedHostError({
|
|
719
|
+
category: 'limit-exceeded',
|
|
720
|
+
contractVersion: STUDIO_CONTRACT_VERSION,
|
|
721
|
+
kind: 'host-error',
|
|
722
|
+
message: {
|
|
723
|
+
defaultMessage: 'The declared upload exceeds the accepted size.',
|
|
724
|
+
key: 'studio.testbed/limit-exceeded',
|
|
725
|
+
},
|
|
726
|
+
retryable: false,
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
if (request.filename.includes('/') || request.filename.includes('\\')) {
|
|
730
|
+
throw new TestbedHostError({
|
|
731
|
+
category: 'validation-failed',
|
|
732
|
+
contractVersion: STUDIO_CONTRACT_VERSION,
|
|
733
|
+
kind: 'host-error',
|
|
734
|
+
message: {
|
|
735
|
+
defaultMessage: 'The declared filename is not a canonical display name.',
|
|
736
|
+
key: 'studio.testbed/validation-failed',
|
|
737
|
+
},
|
|
738
|
+
retryable: false,
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
}
|
|
416
742
|
//# sourceMappingURL=host-testbed.js.map
|