@adea-ai/spatial 0.93.0 → 0.95.0

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.
@@ -0,0 +1,227 @@
1
+ import { boundedString, isoInstant, ownDataValue, strictRecord } from './record'
2
+ import type { OriginAllowlist } from './origin'
3
+
4
+ /**
5
+ * Launch contract between the Adea host, the Adea backend and the hosted Agent Sim origin.
6
+ *
7
+ * 1. The host asks the Adea backend to launch a surface (`SimLaunchRequest`). The backend checks
8
+ * the signed-in account's entitlement; localhost, an embedded secret or the desktop shell's
9
+ * claimed identity are never proof of it.
10
+ * 2. A granted reply carries one opaque, single-use, short-lived `code`, bound to the host's
11
+ * `bridgeId`, the sim origin and the surface. It is not a session and not a credential for any
12
+ * other Adea API.
13
+ * 3. The host posts the code to the sim frame (`host.launch`). The frame calls the sim origin's
14
+ * exchange (`SimSessionExchangeRequest`); the sim origin redeems the code once and sets a
15
+ * sim-only session cookie (see `cookie.ts`). No token appears in any request or response body.
16
+ *
17
+ * Every parser here is strict: unknown keys, wrong types and token-looking extras are refused.
18
+ */
19
+
20
+ export const SIM_LAUNCH_SCHEMA_VERSION = 1
21
+ /** A launch code is useless after this long, whatever the backend says. */
22
+ export const MAX_SIM_LAUNCH_TTL_MS = 120_000
23
+
24
+ export const SIM_SURFACES = ['hq', 'character-designer', 'room-designer'] as const
25
+ export type SimSurface = (typeof SIM_SURFACES)[number]
26
+ export const SIM_PLATFORMS = ['desktop', 'web'] as const
27
+ export type SimPlatform = (typeof SIM_PLATFORMS)[number]
28
+
29
+ const BRIDGE_ID = /^[A-Za-z0-9_-]{16,64}$/
30
+ const LAUNCH_CODE = /^[A-Za-z0-9_-]{32,128}$/
31
+ const WORKSPACE_ID = /^[A-Za-z0-9_.:-]{1,128}$/
32
+
33
+ export type SimLaunchRequest = Readonly<{
34
+ schemaVersion: typeof SIM_LAUNCH_SCHEMA_VERSION
35
+ workspaceId: string
36
+ surface: SimSurface
37
+ platform: SimPlatform
38
+ bridgeId: string
39
+ }>
40
+
41
+ export type SimLaunchDeniedReason = 'unauthenticated' | 'not_entitled'
42
+ export type SimLaunchUnavailableReason = 'not_configured' | 'service_unavailable'
43
+
44
+ export type SimLaunchGrant =
45
+ | Readonly<{
46
+ status: 'granted'
47
+ code: string
48
+ expiresAt: string
49
+ surface: SimSurface
50
+ frameOrigin: string
51
+ }>
52
+ | Readonly<{ status: 'denied'; reason: SimLaunchDeniedReason }>
53
+ | Readonly<{ status: 'unavailable'; reason: SimLaunchUnavailableReason }>
54
+
55
+ export type SimSessionExchangeRequest = Readonly<{
56
+ schemaVersion: typeof SIM_LAUNCH_SCHEMA_VERSION
57
+ code: string
58
+ bridgeId: string
59
+ }>
60
+
61
+ export type SimSessionRejectedReason =
62
+ | 'code_expired'
63
+ | 'code_used'
64
+ | 'not_entitled'
65
+ | 'origin_refused'
66
+
67
+ export type SimSessionExchangeResult =
68
+ | Readonly<{ status: 'session'; expiresAt: string }>
69
+ | Readonly<{ status: 'rejected'; reason: SimSessionRejectedReason }>
70
+ | Readonly<{ status: 'unavailable' }>
71
+
72
+ const DENIED_REASONS = ['unauthenticated', 'not_entitled'] as const
73
+ const UNAVAILABLE_REASONS = ['not_configured', 'service_unavailable'] as const
74
+ const REJECTED_REASONS = ['code_expired', 'code_used', 'not_entitled', 'origin_refused'] as const
75
+
76
+ export function parseSimLaunchRequest(value: unknown): SimLaunchRequest | null {
77
+ const body = strictRecord(value, [
78
+ 'schemaVersion',
79
+ 'workspaceId',
80
+ 'surface',
81
+ 'platform',
82
+ 'bridgeId',
83
+ ])
84
+ if (!body || body.schemaVersion !== SIM_LAUNCH_SCHEMA_VERSION) return null
85
+ const workspaceId = boundedString(body.workspaceId, WORKSPACE_ID)
86
+ const bridgeId = boundedString(body.bridgeId, BRIDGE_ID)
87
+ const surface = SIM_SURFACES.find((item) => item === body.surface)
88
+ const platform = SIM_PLATFORMS.find((item) => item === body.platform)
89
+ if (!workspaceId || !bridgeId || !surface || !platform) return null
90
+ return { schemaVersion: SIM_LAUNCH_SCHEMA_VERSION, workspaceId, surface, platform, bridgeId }
91
+ }
92
+
93
+ export type SimGrantExpectation = Readonly<{
94
+ origins: OriginAllowlist
95
+ /** Epoch milliseconds the host considers "now". */
96
+ now: number
97
+ /** The surface the host asked for; a grant for any other surface is refused. */
98
+ surface: SimSurface
99
+ }>
100
+
101
+ /**
102
+ * The backend's launch reply, or null when it is malformed, stale, longer-lived than the contract
103
+ * allows, for another surface, or points at a frame origin the host did not allowlist.
104
+ */
105
+ export function parseSimLaunchGrant(
106
+ value: unknown,
107
+ expected: SimGrantExpectation
108
+ ): SimLaunchGrant | null {
109
+ const status = ownDataValue(value, 'status')
110
+ if (status === 'denied') {
111
+ const body = strictRecord(value, ['status', 'reason'])
112
+ const reason = DENIED_REASONS.find((item) => item === body?.reason)
113
+ return reason ? { status, reason } : null
114
+ }
115
+ if (status === 'unavailable') {
116
+ const body = strictRecord(value, ['status', 'reason'])
117
+ const reason = UNAVAILABLE_REASONS.find((item) => item === body?.reason)
118
+ return reason ? { status, reason } : null
119
+ }
120
+ if (status !== 'granted') return null
121
+ const body = strictRecord(value, ['status', 'code', 'expiresAt', 'surface', 'frameOrigin'])
122
+ const code = boundedString(body?.code, LAUNCH_CODE)
123
+ const expiresAt = isoInstant(body?.expiresAt)
124
+ const surface = SIM_SURFACES.find((item) => item === body?.surface)
125
+ const frameOrigin = body?.frameOrigin
126
+ if (!body || !code || expiresAt === null || surface !== expected.surface) return null
127
+ if (!expected.origins.has(frameOrigin) || typeof frameOrigin !== 'string') return null
128
+ if (expiresAt <= expected.now || expiresAt > expected.now + MAX_SIM_LAUNCH_TTL_MS) return null
129
+ return { status, code, expiresAt: body.expiresAt as string, surface, frameOrigin }
130
+ }
131
+
132
+ export function parseSimSessionExchangeRequest(value: unknown): SimSessionExchangeRequest | null {
133
+ const body = strictRecord(value, ['schemaVersion', 'code', 'bridgeId'])
134
+ if (!body || body.schemaVersion !== SIM_LAUNCH_SCHEMA_VERSION) return null
135
+ const code = boundedString(body.code, LAUNCH_CODE)
136
+ const bridgeId = boundedString(body.bridgeId, BRIDGE_ID)
137
+ return code && bridgeId ? { schemaVersion: SIM_LAUNCH_SCHEMA_VERSION, code, bridgeId } : null
138
+ }
139
+
140
+ /** The sim origin's exchange reply. A session reply carries its expiry and nothing else. */
141
+ export function parseSimSessionExchangeResult(value: unknown): SimSessionExchangeResult | null {
142
+ const status = ownDataValue(value, 'status')
143
+ if (status === 'unavailable') return strictRecord(value, ['status']) ? { status } : null
144
+ if (status === 'rejected') {
145
+ const body = strictRecord(value, ['status', 'reason'])
146
+ const reason = REJECTED_REASONS.find((item) => item === body?.reason)
147
+ return reason ? { status, reason } : null
148
+ }
149
+ if (status !== 'session') return null
150
+ const body = strictRecord(value, ['status', 'expiresAt'])
151
+ return body && isoInstant(body.expiresAt) !== null
152
+ ? { status, expiresAt: body.expiresAt as string }
153
+ : null
154
+ }
155
+
156
+ export type SimLaunchPayload = Readonly<{
157
+ code: string
158
+ expiresAt: string
159
+ surface: SimSurface
160
+ character?: string
161
+ }>
162
+
163
+ /**
164
+ * Frame side: accepts the first launch payload still inside its window and refuses every later one,
165
+ * so a replayed or duplicated `host.launch` can never trigger a second exchange.
166
+ */
167
+ export function createLaunchOnce(): Readonly<{
168
+ take(payload: SimLaunchPayload, now: number): SimLaunchPayload | null
169
+ }> {
170
+ let taken = false
171
+ return {
172
+ take(payload, now) {
173
+ if (taken) return null
174
+ const expiresAt = isoInstant(payload.expiresAt)
175
+ if (expiresAt === null || expiresAt <= now) return null
176
+ taken = true
177
+ return payload
178
+ },
179
+ }
180
+ }
181
+
182
+ export type LaunchCodeBinding = Readonly<{ bridgeId: string; origin: string; surface: SimSurface }>
183
+ export type LaunchCodeRedemption =
184
+ | Readonly<{ ok: true; surface: SimSurface }>
185
+ | Readonly<{ ok: false; reason: 'unknown' | 'code_expired' | 'code_used' | 'origin_refused' }>
186
+
187
+ /**
188
+ * Reference single-use semantics for the backend's code exchange, used by the local bootstrap tests.
189
+ * A code redeems at most once: it is spent by its first presentation, whether or not that
190
+ * presentation matched the binding, so a leaked code cannot be retried with guessed bindings.
191
+ * A durable implementation keeps the same observable behaviour.
192
+ */
193
+ export function createLaunchCodeLedger(): Readonly<{
194
+ issue(code: string, binding: LaunchCodeBinding, expiresAt: number): void
195
+ redeem(
196
+ code: string,
197
+ presented: Omit<LaunchCodeBinding, 'surface'>,
198
+ now: number
199
+ ): LaunchCodeRedemption
200
+ }> {
201
+ const entries = new Map<
202
+ string,
203
+ { binding: LaunchCodeBinding; expiresAt: number; spent: boolean }
204
+ >()
205
+ return {
206
+ issue(code, binding, expiresAt) {
207
+ if (!Number.isFinite(expiresAt)) throw new RangeError('launch code expiry must be finite')
208
+ entries.set(code, { binding, expiresAt, spent: false })
209
+ },
210
+ redeem(code, presented, now) {
211
+ // An unusable clock reading cannot prove the code is still live, so it fails closed. Nothing
212
+ // is spent: the comparison below would otherwise be false for NaN and let an expired code pass.
213
+ if (!Number.isFinite(now)) return { ok: false, reason: 'code_expired' }
214
+ const entry = entries.get(code)
215
+ if (!entry) return { ok: false, reason: 'unknown' }
216
+ if (entry.spent) return { ok: false, reason: 'code_used' }
217
+ entry.spent = true
218
+ if (entry.expiresAt <= now) return { ok: false, reason: 'code_expired' }
219
+ if (
220
+ entry.binding.bridgeId !== presented.bridgeId ||
221
+ entry.binding.origin !== presented.origin
222
+ )
223
+ return { ok: false, reason: 'origin_refused' }
224
+ return { ok: true, surface: entry.binding.surface }
225
+ },
226
+ }
227
+ }
@@ -0,0 +1,282 @@
1
+ import { boundedString, isPlainRecord, strictRecord } from './record'
2
+ import type { OriginAllowlist } from './origin'
3
+ import { SIM_SURFACES, type SimSurface } from './launch'
4
+ import { SIM_UNAVAILABLE_REASONS, type SimUnavailableReason } from './availability'
5
+
6
+ /**
7
+ * Versioned message schema between the Adea host window and the hosted Agent Sim frame.
8
+ *
9
+ * The bridge is a closed set of typed messages and nothing else: there is no generic call,
10
+ * eval, filesystem, native-bridge or credential message, and no field of any message may carry a
11
+ * token. Every message is validated against the exact frame origin, the exact frame window, the
12
+ * per-mount `bridgeId` the host issued, the protocol version and a strict payload shape. Anything
13
+ * else is dropped and never acted on.
14
+ */
15
+
16
+ export const SIM_BRIDGE_CHANNEL = 'adea.agent-sim.bridge'
17
+ export const SIM_BRIDGE_VERSION = 1
18
+ export const MAX_SIM_MESSAGE_BYTES = 2048
19
+
20
+ const BRIDGE_ID = /^[A-Za-z0-9_-]{16,64}$/
21
+ const LAUNCH_CODE = /^[A-Za-z0-9_-]{32,128}$/
22
+ const CHARACTER_ID = /^[A-Za-z0-9_.:-]{1,64}$/
23
+
24
+ export const SIM_FRAME_STATES = ['loading', 'running', 'unavailable', 'offline'] as const
25
+ export type SimFrameState = (typeof SIM_FRAME_STATES)[number]
26
+ export const SIM_FRAME_ERRORS = [
27
+ 'launch_rejected',
28
+ 'asset_unavailable',
29
+ 'engine_failed',
30
+ 'bridge_incompatible',
31
+ ] as const
32
+ export type SimFrameError = (typeof SIM_FRAME_ERRORS)[number]
33
+
34
+ export type SimFrameMessage =
35
+ | Readonly<{ type: 'frame.ready'; payload: Readonly<Record<string, never>> }>
36
+ | Readonly<{
37
+ type: 'frame.state'
38
+ payload: Readonly<{ state: SimFrameState; reason?: SimUnavailableReason }>
39
+ }>
40
+ | Readonly<{ type: 'frame.close'; payload: Readonly<Record<string, never>> }>
41
+ | Readonly<{ type: 'frame.error'; payload: Readonly<{ code: SimFrameError }> }>
42
+
43
+ export type SimHostMessage =
44
+ | Readonly<{
45
+ type: 'host.launch'
46
+ payload: Readonly<{
47
+ /** Opaque single-use code. It is exchanged at the sim origin for a sim-only session. */
48
+ code: string
49
+ expiresAt: string
50
+ surface: SimSurface
51
+ character?: string
52
+ }>
53
+ }>
54
+ | Readonly<{ type: 'host.dispose'; payload: Readonly<Record<string, never>> }>
55
+
56
+ export type SimBridgeFailure =
57
+ | 'origin_refused'
58
+ | 'source_mismatch'
59
+ | 'not_a_bridge_message'
60
+ | 'version_unsupported'
61
+ | 'bridge_mismatch'
62
+ | 'unknown_type'
63
+ | 'invalid_payload'
64
+ | 'too_large'
65
+
66
+ export type SimBridgeResult<T> =
67
+ | Readonly<{ ok: true; message: T }>
68
+ | Readonly<{ ok: false; reason: SimBridgeFailure }>
69
+
70
+ export type SimMessageEventLike = Readonly<{ origin: unknown; source: unknown; data: unknown }>
71
+
72
+ const ENVELOPE_KEYS = ['channel', 'version', 'bridgeId', 'type', 'payload'] as const
73
+
74
+ function envelope(
75
+ event: SimMessageEventLike,
76
+ bridgeId: string
77
+ ): SimBridgeResult<{ type: string; payload: unknown }> {
78
+ const data = strictRecord(event.data, ENVELOPE_KEYS)
79
+ if (!data || data.channel !== 'adea.agent-sim.bridge') return fail('not_a_bridge_message')
80
+ if (data.version !== SIM_BRIDGE_VERSION) return fail('version_unsupported')
81
+ if (data.bridgeId !== bridgeId) return fail('bridge_mismatch')
82
+ if (typeof data.type !== 'string' || !isPlainRecord(data.payload)) return fail('invalid_payload')
83
+ if (!withinBudget(data)) return fail('too_large')
84
+ return { ok: true, message: { type: data.type, payload: data.payload } }
85
+ }
86
+
87
+ // Traversal limits for the size check. A record costs more than its keys so that even a cyclic
88
+ // structure (reachable through structured clone) exhausts the budget in a bounded number of steps.
89
+ const MAX_NODES = 32
90
+ const MAX_DEPTH = 3
91
+ const MAX_KEYS_PER_RECORD = 16
92
+ const RECORD_COST = 16
93
+
94
+ /**
95
+ * A finite size check over plain data: strings, numbers, booleans and nested plain records. It
96
+ * stops at `MAX_NODES` nodes, `MAX_DEPTH` levels and `MAX_KEYS_PER_RECORD` keys per record, charges
97
+ * every record against the byte budget, and refuses accessors, so no input (cyclic, deep or wide)
98
+ * can make it loop or run sender code.
99
+ */
100
+ function withinBudget(value: unknown): boolean {
101
+ let budget = MAX_SIM_MESSAGE_BYTES
102
+ let nodes = 0
103
+ const stack: { item: unknown; depth: number }[] = [{ item: value, depth: 0 }]
104
+ while (stack.length > 0) {
105
+ const { item, depth } = stack.pop()!
106
+ if (++nodes > MAX_NODES) return false
107
+ if (typeof item === 'string') budget -= item.length
108
+ else if (typeof item === 'number' || typeof item === 'boolean') budget -= 8
109
+ else if (isPlainRecord(item)) {
110
+ if (depth >= MAX_DEPTH) return false
111
+ const keys = Object.getOwnPropertyNames(item)
112
+ if (keys.length > MAX_KEYS_PER_RECORD) return false
113
+ budget -= RECORD_COST
114
+ for (const key of keys) {
115
+ const descriptor = Object.getOwnPropertyDescriptor(item, key)
116
+ // Reading through a getter would run sender-controlled code; refuse accessors outright.
117
+ if (!descriptor || !('value' in descriptor)) return false
118
+ budget -= key.length + 1
119
+ stack.push({ item: descriptor.value, depth: depth + 1 })
120
+ }
121
+ } else if (item !== undefined) return false
122
+ if (budget < 0) return false
123
+ }
124
+ return true
125
+ }
126
+
127
+ function fail<T>(reason: SimBridgeFailure): SimBridgeResult<T> {
128
+ return { ok: false, reason }
129
+ }
130
+
131
+ export type SimHostContext = Readonly<{
132
+ origins: OriginAllowlist
133
+ /** The frame element's `contentWindow`, read when the event arrives. */
134
+ frameWindow: unknown
135
+ /** Issued by the host for this mount; the frame must echo it. */
136
+ bridgeId: string
137
+ }>
138
+
139
+ /** Host side: accept a message only from the exact frame window at an allowlisted origin. */
140
+ export function parseFrameMessage(
141
+ event: SimMessageEventLike,
142
+ context: SimHostContext
143
+ ): SimBridgeResult<SimFrameMessage> {
144
+ if (!BRIDGE_ID.test(context.bridgeId)) return fail('bridge_mismatch')
145
+ if (!context.origins.has(event.origin)) return fail('origin_refused')
146
+ if (event.source == null || event.source !== context.frameWindow) return fail('source_mismatch')
147
+ const opened = envelope(event, context.bridgeId)
148
+ if (!opened.ok) return opened
149
+ const { type, payload } = opened.message
150
+ switch (type) {
151
+ case 'frame.ready':
152
+ case 'frame.close':
153
+ return strictRecord(payload, [])
154
+ ? { ok: true, message: { type, payload: {} } }
155
+ : fail('invalid_payload')
156
+ case 'frame.state': {
157
+ const body = strictRecord(payload, ['state'], ['reason'])
158
+ const state = SIM_FRAME_STATES.find((item) => item === body?.state)
159
+ if (!body || !state) return fail('invalid_payload')
160
+ const reason = SIM_UNAVAILABLE_REASONS.find((item) => item === body.reason)
161
+ // A reason belongs to `unavailable` and nothing else.
162
+ if ('reason' in body ? !reason || state !== 'unavailable' : state === 'unavailable')
163
+ return fail('invalid_payload')
164
+ return { ok: true, message: { type, payload: reason ? { state, reason } : { state } } }
165
+ }
166
+ case 'frame.error': {
167
+ const body = strictRecord(payload, ['code'])
168
+ const code = SIM_FRAME_ERRORS.find((item) => item === body?.code)
169
+ return code ? { ok: true, message: { type, payload: { code } } } : fail('invalid_payload')
170
+ }
171
+ default:
172
+ return fail('unknown_type')
173
+ }
174
+ }
175
+
176
+ export type SimFrameContext = Readonly<{
177
+ /** The exact origin of the Adea host that embeds this frame. */
178
+ parentOrigin: string
179
+ /** `window.parent`, compared by identity. */
180
+ parentWindow: unknown
181
+ bridgeId: string
182
+ }>
183
+
184
+ /** Frame side: accept a message only from the embedding host window at its exact origin. */
185
+ export function parseHostMessage(
186
+ event: SimMessageEventLike,
187
+ context: SimFrameContext
188
+ ): SimBridgeResult<SimHostMessage> {
189
+ if (!BRIDGE_ID.test(context.bridgeId)) return fail('bridge_mismatch')
190
+ if (event.origin !== context.parentOrigin) return fail('origin_refused')
191
+ if (event.source == null || event.source !== context.parentWindow) return fail('source_mismatch')
192
+ const opened = envelope(event, context.bridgeId)
193
+ if (!opened.ok) return opened
194
+ const { type, payload } = opened.message
195
+ switch (type) {
196
+ case 'host.dispose':
197
+ return strictRecord(payload, [])
198
+ ? { ok: true, message: { type, payload: {} } }
199
+ : fail('invalid_payload')
200
+ case 'host.launch': {
201
+ const body = strictRecord(payload, ['code', 'expiresAt', 'surface'], ['character'])
202
+ const code = boundedString(body?.code, LAUNCH_CODE)
203
+ const surface = SIM_SURFACES.find((item) => item === body?.surface)
204
+ const expiresAt = body?.expiresAt
205
+ const character =
206
+ body && 'character' in body ? boundedString(body.character, CHARACTER_ID) : undefined
207
+ if (
208
+ !body ||
209
+ !code ||
210
+ !surface ||
211
+ typeof expiresAt !== 'string' ||
212
+ Number.isNaN(Date.parse(expiresAt)) ||
213
+ character === null
214
+ )
215
+ return fail('invalid_payload')
216
+ return {
217
+ ok: true,
218
+ message: {
219
+ type,
220
+ payload: { code, expiresAt, surface, ...(character ? { character } : {}) },
221
+ },
222
+ }
223
+ }
224
+ default:
225
+ return fail('unknown_type')
226
+ }
227
+ }
228
+
229
+ /** A fresh per-mount nonce: 128 random bits as base64url. The host issues it; the frame echoes it. */
230
+ export function createBridgeId(
231
+ fill: (bytes: Uint8Array) => Uint8Array = (bytes) => crypto.getRandomValues(bytes)
232
+ ): string {
233
+ const bytes = fill(new Uint8Array(16))
234
+ let binary = ''
235
+ for (const byte of bytes) binary += String.fromCharCode(byte)
236
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '')
237
+ }
238
+
239
+ function wrap(bridgeId: string, type: string, payload: object) {
240
+ if (!BRIDGE_ID.test(bridgeId)) throw new Error('Invalid Agent Sim bridge id')
241
+ return {
242
+ channel: SIM_BRIDGE_CHANNEL,
243
+ version: SIM_BRIDGE_VERSION,
244
+ bridgeId,
245
+ type,
246
+ payload,
247
+ } as const
248
+ }
249
+
250
+ export function frameEnvelope(bridgeId: string, message: SimFrameMessage) {
251
+ return wrap(bridgeId, message.type, message.payload)
252
+ }
253
+
254
+ export function hostEnvelope(bridgeId: string, message: SimHostMessage) {
255
+ return wrap(bridgeId, message.type, message.payload)
256
+ }
257
+
258
+ type PostTarget = Readonly<{ postMessage(message: unknown, targetOrigin: string): void }>
259
+
260
+ /** Host to frame. The target origin must be allowlisted; `*` can never be used. */
261
+ export function postToFrame(
262
+ frameWindow: PostTarget,
263
+ frameOrigin: string,
264
+ origins: OriginAllowlist,
265
+ bridgeId: string,
266
+ message: SimHostMessage
267
+ ): void {
268
+ if (!origins.has(frameOrigin)) throw new Error('Refusing to post to an unlisted Agent Sim origin')
269
+ frameWindow.postMessage(hostEnvelope(bridgeId, message), frameOrigin)
270
+ }
271
+
272
+ /** Frame to host. The target is the exact embedding origin the frame was configured with. */
273
+ export function postToHost(
274
+ parentWindow: PostTarget,
275
+ parentOrigin: string,
276
+ bridgeId: string,
277
+ message: SimFrameMessage
278
+ ): void {
279
+ if (parentOrigin === '*' || parentOrigin === 'null' || !parentOrigin.includes('://'))
280
+ throw new Error('Refusing to post to a non-exact Agent Sim host origin')
281
+ parentWindow.postMessage(frameEnvelope(bridgeId, message), parentOrigin)
282
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Exact-origin allowlist for the hosted Agent Sim frame.
3
+ *
4
+ * An origin is allowed only by an exact string match against a canonical `scheme://host[:port]`.
5
+ * There are no wildcards, no suffix or subdomain matching, and `null`/opaque origins never match.
6
+ * Loopback `http:` origins are accepted only when the caller opts in explicitly for local
7
+ * development. Being on the allowlist (loopback included) says where a frame was served from; it
8
+ * never proves an official desktop identity or an entitlement.
9
+ */
10
+
11
+ export type OriginAllowlist = Readonly<{
12
+ /** Canonical origins in the order given. */
13
+ origins: readonly string[]
14
+ has(origin: unknown): boolean
15
+ }>
16
+
17
+ const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]']
18
+
19
+ /** The canonical origin when `value` is exactly one, otherwise null. */
20
+ export function canonicalBridgeOrigin(
21
+ value: unknown,
22
+ options: Readonly<{ allowLoopbackHttp?: boolean }> = {}
23
+ ): string | null {
24
+ if (typeof value !== 'string' || value.length === 0 || value.length > 256) return null
25
+ let url: URL
26
+ try {
27
+ url = new URL(value)
28
+ } catch {
29
+ return null
30
+ }
31
+ // `origin` is "null" for opaque schemes; requiring equality also rejects paths, queries,
32
+ // credentials, trailing slashes and non-canonical spellings.
33
+ if (url.origin !== value) return null
34
+ // The URL parser tolerates `*` and similar in a host; an allowlist entry must be a real host.
35
+ if (!/^(?:[a-z0-9-]+(?:\.[a-z0-9-]+)*|\[[0-9a-f:]+\])$/.test(url.hostname)) return null
36
+ if (url.protocol === 'https:') return value
37
+ if (
38
+ url.protocol === 'http:' &&
39
+ options.allowLoopbackHttp === true &&
40
+ LOOPBACK_HOSTS.includes(url.hostname)
41
+ )
42
+ return value
43
+ return null
44
+ }
45
+
46
+ export function createOriginAllowlist(
47
+ origins: readonly unknown[],
48
+ options: Readonly<{ allowLoopbackHttp?: boolean }> = {}
49
+ ): OriginAllowlist {
50
+ if (origins.length === 0) throw new Error('The Agent Sim bridge needs at least one origin')
51
+ const canonical = origins.map((origin) => {
52
+ const value = canonicalBridgeOrigin(origin, options)
53
+ if (value === null) throw new Error('Agent Sim bridge origins must be exact canonical origins')
54
+ return value
55
+ })
56
+ const unique = [...new Set(canonical)]
57
+ return Object.freeze({
58
+ origins: Object.freeze(unique),
59
+ has: (origin: unknown) => typeof origin === 'string' && unique.includes(origin),
60
+ })
61
+ }
@@ -0,0 +1,60 @@
1
+ /** Strict structural readers shared by the bridge validators. No coercion, no extra keys. */
2
+
3
+ export function isPlainRecord(value: unknown): value is Record<string, unknown> {
4
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
5
+ const prototype = Object.getPrototypeOf(value)
6
+ return (prototype === Object.prototype || prototype === null) && !hasSymbolKeys(value)
7
+ }
8
+
9
+ function hasSymbolKeys(value: object): boolean {
10
+ return Object.getOwnPropertySymbols(value).length > 0
11
+ }
12
+
13
+ /**
14
+ * The record when it is plain and carries exactly the allowed keys: every `required` key is
15
+ * present and no key outside `required` and `optional` exists. Accessor properties are refused so
16
+ * a getter can never run during validation.
17
+ */
18
+ export function strictRecord(
19
+ value: unknown,
20
+ required: readonly string[],
21
+ optional: readonly string[] = []
22
+ ): Record<string, unknown> | null {
23
+ if (!isPlainRecord(value)) return null
24
+ // Refuse a wide object before allocating a descriptor for each of its keys.
25
+ if (Object.getOwnPropertyNames(value).length > required.length + optional.length) return null
26
+ const descriptors = Object.getOwnPropertyDescriptors(value)
27
+ const keys = Object.keys(descriptors)
28
+ for (const key of keys) {
29
+ const descriptor = descriptors[key]!
30
+ if (!('value' in descriptor)) return null
31
+ if (!required.includes(key) && !optional.includes(key)) return null
32
+ }
33
+ for (const key of required) if (!keys.includes(key)) return null
34
+ return value
35
+ }
36
+
37
+ /**
38
+ * An own data property of a plain record, or undefined. Used to branch on a discriminant before
39
+ * the strict reader runs, without ever invoking a getter or walking the prototype chain.
40
+ */
41
+ export function ownDataValue(value: unknown, key: string): unknown {
42
+ if (!isPlainRecord(value)) return undefined
43
+ const descriptor = Object.getOwnPropertyDescriptor(value, key)
44
+ return descriptor && 'value' in descriptor ? descriptor.value : undefined
45
+ }
46
+
47
+ export function boundedString(value: unknown, pattern: RegExp): string | null {
48
+ return typeof value === 'string' && pattern.test(value) ? value : null
49
+ }
50
+
51
+ /** An ISO-8601 UTC instant as produced by `Date.prototype.toISOString`. */
52
+ export function isoInstant(value: unknown): number | null {
53
+ if (
54
+ typeof value !== 'string' ||
55
+ !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/.test(value)
56
+ )
57
+ return null
58
+ const time = Date.parse(value)
59
+ return Number.isFinite(time) ? time : null
60
+ }