@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.
Files changed (150) hide show
  1. package/README.md +75 -10
  2. package/corpus-manifest.json +1106 -0
  3. package/dist/host-testbed.d.ts +22 -2
  4. package/dist/host-testbed.d.ts.map +1 -1
  5. package/dist/host-testbed.js +357 -31
  6. package/dist/host-testbed.js.map +1 -1
  7. package/dist/http-host-adapter.d.ts.map +1 -1
  8. package/dist/http-host-adapter.js +15 -0
  9. package/dist/http-host-adapter.js.map +1 -1
  10. package/dist/index.d.ts +40 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +36 -2
  13. package/dist/index.js.map +1 -1
  14. package/fixtures/field-adapter.example.json +27 -0
  15. package/fixtures/host-capabilities.example.json +11 -5
  16. package/fixtures/host-operations.example.json +270 -0
  17. package/fixtures/inspector.example.json +17 -0
  18. package/fixtures/media-upload-grant.example.json +14 -0
  19. package/fixtures/preview.render.example.json +1 -0
  20. package/fixtures/studio-config.example.json +4 -4
  21. package/invalid/host-capabilities.invalid-protocol-version.json +1 -1
  22. package/invalid/host-sequence.pending-expectation.json +52 -0
  23. package/invalid/host-sequence.preview-render-missing-request-id.json +50 -0
  24. package/invalid/inspector.empty-block-types.json +20 -0
  25. package/invalid/media-upload-grant.insecure-url.json +14 -0
  26. package/invalid/preview-message.activated-unknown-interaction.json +16 -0
  27. package/invalid/preview-message.duplicate-marker.json +24 -0
  28. package/invalid/preview-message.invalid-digest.json +1 -0
  29. package/invalid/preview-message.invalid-marker-grammar.json +19 -0
  30. package/invalid/preview-message.missing-marker-map.json +20 -0
  31. package/invalid/preview-message.missing-render-request-id.json +18 -0
  32. package/invalid/preview-message.unknown-type.json +13 -0
  33. package/invalid/preview-message.viewport-height-too-small.json +13 -0
  34. package/invalid/preview-message.viewport-role-and-size.json +16 -0
  35. package/invalid/preview-message.viewport-unsafe-role.json +13 -0
  36. package/package.json +6 -4
  37. package/vectors/canonical/array-order.json +13 -0
  38. package/vectors/canonical/control-characters.json +13 -0
  39. package/vectors/canonical/depth-exceeded.json +21 -0
  40. package/vectors/canonical/empty-containers.json +14 -0
  41. package/vectors/canonical/forbidden-member.json +14 -0
  42. package/vectors/canonical/member-order.json +16 -0
  43. package/vectors/canonical/negative-zero.json +13 -0
  44. package/vectors/canonical/nested-order.json +19 -0
  45. package/vectors/canonical/non-ascii.json +13 -0
  46. package/vectors/canonical/numbers.json +16 -0
  47. package/vectors/canonical/string-escapes.json +13 -0
  48. package/vectors/canonical/surrogate-pair.json +13 -0
  49. package/vectors/host/artifact.dependencies.result.json +35 -0
  50. package/vectors/host/artifact.load.stored.json +36 -0
  51. package/vectors/host/artifact.load.unknown.error.json +37 -0
  52. package/vectors/host/artifact.publish.accepted.json +37 -0
  53. package/vectors/host/artifact.publish.forbidden.error.json +37 -0
  54. package/vectors/host/artifact.publish.stale.error.json +38 -0
  55. package/vectors/host/artifact.save.accepted.json +38 -0
  56. package/vectors/host/artifact.save.forbidden.error.json +38 -0
  57. package/vectors/host/artifact.save.stale.error.json +39 -0
  58. package/vectors/host/artifact.unpublish.stale.error.json +38 -0
  59. package/vectors/host/envelope.malformed-context.error.json +36 -0
  60. package/vectors/host/envelope.protocol-version.error.json +36 -0
  61. package/vectors/host/envelope.stale-generation.error.json +36 -0
  62. package/vectors/host/localization.messages.unknown-locale.error.json +36 -0
  63. package/vectors/host/media.abort-upload.unknown.error.json +35 -0
  64. package/vectors/host/media.authorize-upload.granted.json +37 -0
  65. package/vectors/host/media.authorize-upload.path-filename.error.json +39 -0
  66. package/vectors/host/media.authorize-upload.too-large.error.json +38 -0
  67. package/vectors/host/media.complete-upload.unknown.error.json +36 -0
  68. package/vectors/host/media.get.unknown.json +34 -0
  69. package/vectors/host/media.import-external.private-host.error.json +36 -0
  70. package/vectors/host/media.list.cursor.error.json +36 -0
  71. package/vectors/host/media.list.limit.error.json +35 -0
  72. package/vectors/host/media.upload-status.unknown.error.json +35 -0
  73. package/vectors/host/permission.explain.withheld.json +34 -0
  74. package/vectors/host/permission.refresh.snapshot.json +31 -0
  75. package/vectors/host/recovery.load.absent.json +31 -0
  76. package/vectors/host/telemetry.emit.accepted.json +37 -0
  77. package/vectors/host/telemetry.emit.non-primitive.error.json +40 -0
  78. package/vectors/host-sequence/artifact.publish.changed-intent.sequence.json +75 -0
  79. package/vectors/host-sequence/artifact.publish.idempotent-replay.sequence.json +111 -0
  80. package/vectors/host-sequence/preview.cancel.cross-context.sequence.json +78 -0
  81. package/vectors/host-sequence/preview.render.cancelled.sequence.json +75 -0
  82. package/vectors/host-sequence/recovery.store.canonical-number.sequence.json +72 -0
  83. package/vectors/host-sequence/recovery.store.changed-context.sequence.json +61 -0
  84. package/vectors/host-sequence/recovery.store.rate-limited.sequence.json +93 -0
  85. package/vectors/host-sequence/recovery.store.resource-scope.sequence.json +61 -0
  86. package/vectors/host-sequence/recovery.store.wrong-operation-id.sequence.json +56 -0
  87. package/vectors/preview/canonical-preorder.json +102 -0
  88. package/vectors/preview/empty-draft.json +36 -0
  89. package/vectors/schema-profile/alternative-limit.accepted.json +95 -0
  90. package/vectors/schema-profile/alternative-limit.rejected.json +92 -0
  91. package/vectors/schema-profile/array-composition.accepted.json +35 -0
  92. package/vectors/schema-profile/byte-limit.accepted.json +30 -0
  93. package/vectors/schema-profile/byte-limit.rejected.json +26 -0
  94. package/vectors/schema-profile/closed-object.accepted.json +38 -0
  95. package/vectors/schema-profile/decimal-multiple.accepted.json +54 -0
  96. package/vectors/schema-profile/depth-limit.accepted.json +90 -0
  97. package/vectors/schema-profile/depth-limit.rejected.json +88 -0
  98. package/vectors/schema-profile/description-limit.accepted.json +29 -0
  99. package/vectors/schema-profile/description-limit.rejected.json +25 -0
  100. package/vectors/schema-profile/diagnostic-precedence.rejected.json +26 -0
  101. package/vectors/schema-profile/duplicate-dependent-required.rejected.json +21 -0
  102. package/vectors/schema-profile/duplicate-required.rejected.json +18 -0
  103. package/vectors/schema-profile/empty-combinator.rejected.json +17 -0
  104. package/vectors/schema-profile/empty-enum.rejected.json +17 -0
  105. package/vectors/schema-profile/enum-limit.accepted.json +89 -0
  106. package/vectors/schema-profile/enum-limit.rejected.json +84 -0
  107. package/vectors/schema-profile/examples-limit.accepted.json +35 -0
  108. package/vectors/schema-profile/examples-limit.rejected.json +31 -0
  109. package/vectors/schema-profile/format-keyword.rejected.json +17 -0
  110. package/vectors/schema-profile/forward-reference-path-precedence.rejected.json +20 -0
  111. package/vectors/schema-profile/invalid-pointer.rejected.json +18 -0
  112. package/vectors/schema-profile/json-depth-limit.accepted.json +278 -0
  113. package/vectors/schema-profile/json-depth-limit.rejected.json +276 -0
  114. package/vectors/schema-profile/json-item-limit.accepted.json +686 -0
  115. package/vectors/schema-profile/json-item-limit.rejected.json +682 -0
  116. package/vectors/schema-profile/json-property-limit.accepted.json +1031 -0
  117. package/vectors/schema-profile/json-property-limit.rejected.json +1028 -0
  118. package/vectors/schema-profile/local-reference.accepted.json +28 -0
  119. package/vectors/schema-profile/memoized-diagnostic.accepted.json +31 -0
  120. package/vectors/schema-profile/node-limit.accepted.json +1097 -0
  121. package/vectors/schema-profile/node-limit.rejected.json +1094 -0
  122. package/vectors/schema-profile/non-object-root.rejected.json +9 -0
  123. package/vectors/schema-profile/non-schema-reference.rejected.json +21 -0
  124. package/vectors/schema-profile/object-key-limit.accepted.json +32 -0
  125. package/vectors/schema-profile/object-key-limit.rejected.json +28 -0
  126. package/vectors/schema-profile/open-root.rejected.json +13 -0
  127. package/vectors/schema-profile/percent-encoded-pointer.rejected.json +26 -0
  128. package/vectors/schema-profile/property-name-limit.accepted.json +543 -0
  129. package/vectors/schema-profile/property-name-limit.rejected.json +536 -0
  130. package/vectors/schema-profile/recursive-diagnostic-precedence.rejected.json +30 -0
  131. package/vectors/schema-profile/recursive-reference.rejected.json +24 -0
  132. package/vectors/schema-profile/reference-fanout.accepted.json +662 -0
  133. package/vectors/schema-profile/reference-length-limit.accepted.json +45 -0
  134. package/vectors/schema-profile/reference-length-limit.rejected.json +39 -0
  135. package/vectors/schema-profile/reference-limit.accepted.json +416 -0
  136. package/vectors/schema-profile/reference-limit.rejected.json +415 -0
  137. package/vectors/schema-profile/reference-vs-keyword-precedence.rejected.json +22 -0
  138. package/vectors/schema-profile/reserved-pointer.rejected.json +26 -0
  139. package/vectors/schema-profile/root-diagnostic-precedence.rejected.json +16 -0
  140. package/vectors/schema-profile/root-vs-reference-precedence.rejected.json +19 -0
  141. package/vectors/schema-profile/schema-map-limit.accepted.json +539 -0
  142. package/vectors/schema-profile/schema-map-limit.rejected.json +536 -0
  143. package/vectors/schema-profile/semantic-recursion-precedence.rejected.json +21 -0
  144. package/vectors/schema-profile/semantic-reference-precedence.rejected.json +21 -0
  145. package/vectors/schema-profile/title-limit.accepted.json +29 -0
  146. package/vectors/schema-profile/title-limit.rejected.json +25 -0
  147. package/vectors/schema-profile/unresolved-reference.rejected.json +17 -0
  148. package/vectors/schema-profile/unsafe-dependent-required.rejected.json +24 -0
  149. package/vectors/schema-profile/unsafe-member.rejected.json +17 -0
  150. package/vectors/schema-profile/unsafe-required.rejected.json +17 -0
@@ -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
- render?: (payload: PreviewRenderPayload) => PreviewRenderedPayload;
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,EAGvB,KAAK,UAAU,EACf,KAAK,wBAAwB,EAE7B,KAAK,oBAAoB,EACzB,KAAK,sBAAsB,EAC3B,KAAK,aAAa,EAClB,KAAK,iBAAiB,EACtB,KAAK,QAAQ,EACb,KAAK,QAAQ,EACb,KAAK,cAAc,EACnB,KAAK,yBAAyB,EAC9B,KAAK,cAAc,EACpB,MAAM,wBAAwB,CAAC;AAEhC,MAAM,MAAM,eAAe,GACvB,UAAU,GACV,cAAc,GACd,OAAO,GACP,YAAY,GACZ,SAAS,GACT,UAAU,GACV,UAAU,GACV,WAAW,CAAC;AAEhB,MAAM,WAAW,kBAAkB;IACjC,SAAS,CAAC,EAAE,cAAc,EAAE,CAAC;IAC7B,WAAW,CAAC,EAAE,UAAU,EAAE,CAAC;IAC3B,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,aAAa,EAAE,MAAM,CAAC,CAAC,CAAC;IACzD,WAAW,CAAC,EAAE,aAAa,EAAE,CAAC;IAC9B,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,oBAAoB,KAAK,sBAAsB,CAAC;IACnE,SAAS,CAAC,EAAE,iBAAiB,EAAE,CAAC;CACjC;AAED,MAAM,WAAW,eAAe;IAC9B,UAAU,IAAI,IAAI,CAAC;IACnB,QAAQ,CAAC,QAAQ,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,iBAAiB,GAAG,IAAI,CAAC;IAC1F;;;;;;;;;;OAUG;IACH,mBAAmB,CACjB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,kBAAkB,GAC1B,OAAO,CAAC,cAAc,CAAC,wBAAwB,CAAC,CAAC,CAAC;IACrD,SAAS,IAAI,IAAI,CAAC;IAClB,UAAU,CAAC,EAAE,EAAE,QAAQ,GAAG,QAAQ,GAAG,SAAS,CAAC;IAC/C,QAAQ,CAAC,iBAAiB,EAAE,QAAQ,CAAC;IACrC,cAAc,CAAC,WAAW,EAAE,aAAa,EAAE,GAAG,IAAI,CAAC;IACnD,QAAQ,CAAC,eAAe,EAAE,SAAS,cAAc,EAAE,CAAC;CACrD;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,eAAe,CAAC;IAC1B,IAAI,EAAE,WAAW,CAAC;CACnB;AAED,MAAM,WAAW,gCAAgC;IAC/C,gBAAgB,CAAC,EAAE,QAAQ,CAAC;IAC5B,cAAc,CAAC,EAAE,QAAQ,CAAC;IAC1B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,aAAa,CAAC;IAC5B,eAAe,CAAC,EAAE,yBAAyB,CAAC;IAC5C,SAAS,CAAC,EAAE,QAAQ,CAAC;IACrB,kBAAkB,CAAC,EAAE,QAAQ,CAAC;IAC9B,iBAAiB,CAAC,EAAE,QAAQ,CAAC;CAC9B;AAED,qBAAa,gBAAiB,SAAQ,KAAK;IACzC,SAAgB,KAAK,EAAE,aAAa,CAAC;gBAElB,KAAK,EAAE,aAAa;CAKxC;AAED,wBAAgB,+BAA+B,CAC7C,OAAO,GAAE,gCAAqC,GAC7C,kBAAkB,CAapB;AAED,wBAAgB,iBAAiB,CAAC,OAAO,GAAE,kBAAuB,GAAG,WAAW,CAsY/E"}
1
+ {"version":3,"file":"host-testbed.d.ts","sourceRoot":"","sources":["../src/host-testbed.ts"],"names":[],"mappings":"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"}
@@ -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
- const revision = `${document.id}-r1`;
30
- document.revision = revision;
31
- artifactStore.set(document.id, { document, revision, serial: 1 });
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 generationSerial = 1;
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 `session-r${generationSerial}`;
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 guard(portName, operation, context) {
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 run(portName, operation, context, body) {
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
- guard(portName, operation, context);
91
- return Promise.resolve(body());
165
+ return await body();
92
166
  }
93
167
  catch (error) {
94
168
  if (error instanceof TestbedHostError) {
95
- return Promise.reject(error);
169
+ throw error;
96
170
  }
97
- return Promise.reject(new TestbedHostError(createError('internal', 'The testbed operation failed unexpectedly.')));
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 run('artifact', 'publish', context, () => setStatus(reference.id, context, 'published'));
301
+ return runMutation('artifact', 'publish', reference, context, () => setStatus(reference.id, context, 'published'));
154
302
  },
155
303
  save(document, context) {
156
- return run('artifact', 'save', context, () => {
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 run('artifact', 'unpublish', context, () => setStatus(reference.id, context, 'draft'));
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, () => ({ value: null }));
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
- value: cloneValue(renderCallback(cloneValue(payload))),
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 run('recovery', 'discard', context, () => {
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 run('recovery', 'store', context, () => {
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 run('telemetry', 'emit', context, () => {
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 { diagnostics: [], draftDigest: payload.draftDigest, markers: [] };
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