@adea-ai/spatial 0.94.0 → 0.96.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.
- package/README.md +57 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +7 -2
- package/dist/sim-bridge/availability.d.ts +98 -0
- package/dist/sim-bridge/availability.js +181 -0
- package/dist/sim-bridge/character.d.ts +17 -0
- package/dist/sim-bridge/character.js +49 -0
- package/dist/sim-bridge/cookie.d.ts +21 -0
- package/dist/sim-bridge/cookie.js +85 -0
- package/dist/sim-bridge/index.d.ts +8 -0
- package/dist/sim-bridge/index.js +8 -0
- package/dist/sim-bridge/launch.d.ts +111 -0
- package/dist/sim-bridge/launch.js +153 -0
- package/dist/sim-bridge/messages.d.ts +133 -0
- package/dist/sim-bridge/messages.js +244 -0
- package/dist/sim-bridge/origin.d.ts +21 -0
- package/dist/sim-bridge/origin.js +51 -0
- package/dist/sim-bridge/record.d.ts +16 -0
- package/dist/sim-bridge/record.js +56 -0
- package/dist/sim-bridge/save-client.d.ts +25 -0
- package/dist/sim-bridge/save-client.js +55 -0
- package/dist/sim-bridge/service.d.ts +85 -0
- package/dist/sim-bridge/service.js +166 -0
- package/package.json +7 -2
- package/src/index.ts +7 -2
- package/src/sim-bridge/availability.ts +238 -0
- package/src/sim-bridge/character.ts +53 -0
- package/src/sim-bridge/cookie.ts +82 -0
- package/src/sim-bridge/index.ts +8 -0
- package/src/sim-bridge/launch.ts +227 -0
- package/src/sim-bridge/messages.ts +338 -0
- package/src/sim-bridge/origin.ts +61 -0
- package/src/sim-bridge/record.ts +60 -0
- package/src/sim-bridge/save-client.ts +87 -0
- package/src/sim-bridge/service.ts +279 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { boundedString, isPlainRecord, strictRecord } from './record'
|
|
2
|
+
import type { OriginAllowlist } from './origin'
|
|
3
|
+
import { SIM_SURFACES, type SimSurface } from './launch'
|
|
4
|
+
import { parseSimCharacterValue } from './character'
|
|
5
|
+
import { SIM_UNAVAILABLE_REASONS, type SimUnavailableReason } from './availability'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Versioned message schema between the Adea host window and the hosted Agent Sim frame.
|
|
9
|
+
*
|
|
10
|
+
* The bridge is a closed set of typed messages and nothing else: there is no generic call,
|
|
11
|
+
* eval, filesystem, native-bridge or credential message, and no field of any message may carry a
|
|
12
|
+
* token. Every message is validated against the exact frame origin, the exact frame window, the
|
|
13
|
+
* per-mount `bridgeId` the host issued, the protocol version and a strict payload shape. Anything
|
|
14
|
+
* else is dropped and never acted on.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const SIM_BRIDGE_CHANNEL = 'adea.agent-sim.bridge'
|
|
18
|
+
export const SIM_BRIDGE_VERSION = 1
|
|
19
|
+
export const MAX_SIM_MESSAGE_BYTES = 2048
|
|
20
|
+
|
|
21
|
+
const BRIDGE_ID = /^[A-Za-z0-9_-]{16,64}$/
|
|
22
|
+
const LAUNCH_CODE = /^[A-Za-z0-9_-]{32,128}$/
|
|
23
|
+
const REQUEST_ID = /^[A-Za-z0-9_-]{1,32}$/
|
|
24
|
+
|
|
25
|
+
export const SIM_FRAME_STATES = ['loading', 'running', 'unavailable', 'offline'] as const
|
|
26
|
+
export type SimFrameState = (typeof SIM_FRAME_STATES)[number]
|
|
27
|
+
export const SIM_FRAME_ERRORS = [
|
|
28
|
+
'launch_rejected',
|
|
29
|
+
'asset_unavailable',
|
|
30
|
+
'engine_failed',
|
|
31
|
+
'bridge_incompatible',
|
|
32
|
+
] as const
|
|
33
|
+
export type SimFrameError = (typeof SIM_FRAME_ERRORS)[number]
|
|
34
|
+
|
|
35
|
+
/** Where HQ may send the host: the designers only. A designer returns with `frame.close`. */
|
|
36
|
+
export const SIM_NAVIGATE_TARGETS = ['room-designer', 'character-designer'] as const
|
|
37
|
+
export type SimNavigateTarget = (typeof SIM_NAVIGATE_TARGETS)[number]
|
|
38
|
+
export const SIM_SAVE_FAILURES = ['refused', 'failed', 'timeout'] as const
|
|
39
|
+
export type SimSaveFailure = (typeof SIM_SAVE_FAILURES)[number]
|
|
40
|
+
|
|
41
|
+
/** What the frame reports about its own launch; the pure launch reducer consumes only these. */
|
|
42
|
+
export type SimFrameStatusMessage =
|
|
43
|
+
| Readonly<{ type: 'frame.ready'; payload: Readonly<Record<string, never>> }>
|
|
44
|
+
| Readonly<{
|
|
45
|
+
type: 'frame.state'
|
|
46
|
+
payload: Readonly<{ state: SimFrameState; reason?: SimUnavailableReason }>
|
|
47
|
+
}>
|
|
48
|
+
| Readonly<{ type: 'frame.close'; payload: Readonly<Record<string, never>> }>
|
|
49
|
+
| Readonly<{ type: 'frame.error'; payload: Readonly<{ code: SimFrameError }> }>
|
|
50
|
+
|
|
51
|
+
/** Closed requests the frame may make of the host. Each is answered or ends the attempt. */
|
|
52
|
+
export type SimFrameRequestMessage =
|
|
53
|
+
| Readonly<{
|
|
54
|
+
type: 'frame.navigate'
|
|
55
|
+
payload: Readonly<{ requestId: string; target: SimNavigateTarget }>
|
|
56
|
+
}>
|
|
57
|
+
| Readonly<{
|
|
58
|
+
type: 'frame.character.save'
|
|
59
|
+
payload: Readonly<{ requestId: string; character: string }>
|
|
60
|
+
}>
|
|
61
|
+
|
|
62
|
+
export type SimFrameMessage = SimFrameStatusMessage | SimFrameRequestMessage
|
|
63
|
+
|
|
64
|
+
export type SimHostMessage =
|
|
65
|
+
| Readonly<{
|
|
66
|
+
type: 'host.launch'
|
|
67
|
+
payload: Readonly<{
|
|
68
|
+
/** Opaque single-use code. It is exchanged at the sim origin for a sim-only session. */
|
|
69
|
+
code: string
|
|
70
|
+
expiresAt: string
|
|
71
|
+
surface: SimSurface
|
|
72
|
+
character?: string
|
|
73
|
+
}>
|
|
74
|
+
}>
|
|
75
|
+
| Readonly<{ type: 'host.dispose'; payload: Readonly<Record<string, never>> }>
|
|
76
|
+
| Readonly<{
|
|
77
|
+
type: 'host.character.save.result'
|
|
78
|
+
payload: Readonly<{ requestId: string; saved: boolean; reason?: SimSaveFailure }>
|
|
79
|
+
}>
|
|
80
|
+
|
|
81
|
+
export type SimBridgeFailure =
|
|
82
|
+
| 'origin_refused'
|
|
83
|
+
| 'source_mismatch'
|
|
84
|
+
| 'not_a_bridge_message'
|
|
85
|
+
| 'version_unsupported'
|
|
86
|
+
| 'bridge_mismatch'
|
|
87
|
+
| 'unknown_type'
|
|
88
|
+
| 'invalid_payload'
|
|
89
|
+
| 'too_large'
|
|
90
|
+
|
|
91
|
+
export type SimBridgeResult<T> =
|
|
92
|
+
| Readonly<{ ok: true; message: T }>
|
|
93
|
+
| Readonly<{ ok: false; reason: SimBridgeFailure }>
|
|
94
|
+
|
|
95
|
+
export type SimMessageEventLike = Readonly<{ origin: unknown; source: unknown; data: unknown }>
|
|
96
|
+
|
|
97
|
+
const ENVELOPE_KEYS = ['channel', 'version', 'bridgeId', 'type', 'payload'] as const
|
|
98
|
+
|
|
99
|
+
function envelope(
|
|
100
|
+
event: SimMessageEventLike,
|
|
101
|
+
bridgeId: string
|
|
102
|
+
): SimBridgeResult<{ type: string; payload: unknown }> {
|
|
103
|
+
const data = strictRecord(event.data, ENVELOPE_KEYS)
|
|
104
|
+
if (!data || data.channel !== 'adea.agent-sim.bridge') return fail('not_a_bridge_message')
|
|
105
|
+
if (data.version !== SIM_BRIDGE_VERSION) return fail('version_unsupported')
|
|
106
|
+
if (data.bridgeId !== bridgeId) return fail('bridge_mismatch')
|
|
107
|
+
if (typeof data.type !== 'string' || !isPlainRecord(data.payload)) return fail('invalid_payload')
|
|
108
|
+
if (!withinBudget(data)) return fail('too_large')
|
|
109
|
+
return { ok: true, message: { type: data.type, payload: data.payload } }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Traversal limits for the size check. A record costs more than its keys so that even a cyclic
|
|
113
|
+
// structure (reachable through structured clone) exhausts the budget in a bounded number of steps.
|
|
114
|
+
const MAX_NODES = 32
|
|
115
|
+
const MAX_DEPTH = 3
|
|
116
|
+
const MAX_KEYS_PER_RECORD = 16
|
|
117
|
+
const RECORD_COST = 16
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A finite size check over plain data: strings, numbers, booleans and nested plain records. It
|
|
121
|
+
* stops at `MAX_NODES` nodes, `MAX_DEPTH` levels and `MAX_KEYS_PER_RECORD` keys per record, charges
|
|
122
|
+
* every record against the byte budget, and refuses accessors, so no input (cyclic, deep or wide)
|
|
123
|
+
* can make it loop or run sender code.
|
|
124
|
+
*/
|
|
125
|
+
function withinBudget(value: unknown): boolean {
|
|
126
|
+
let budget = MAX_SIM_MESSAGE_BYTES
|
|
127
|
+
let nodes = 0
|
|
128
|
+
const stack: { item: unknown; depth: number }[] = [{ item: value, depth: 0 }]
|
|
129
|
+
while (stack.length > 0) {
|
|
130
|
+
const { item, depth } = stack.pop()!
|
|
131
|
+
if (++nodes > MAX_NODES) return false
|
|
132
|
+
if (typeof item === 'string') budget -= item.length
|
|
133
|
+
else if (typeof item === 'number' || typeof item === 'boolean') budget -= 8
|
|
134
|
+
else if (isPlainRecord(item)) {
|
|
135
|
+
if (depth >= MAX_DEPTH) return false
|
|
136
|
+
const keys = Object.getOwnPropertyNames(item)
|
|
137
|
+
if (keys.length > MAX_KEYS_PER_RECORD) return false
|
|
138
|
+
budget -= RECORD_COST
|
|
139
|
+
for (const key of keys) {
|
|
140
|
+
const descriptor = Object.getOwnPropertyDescriptor(item, key)
|
|
141
|
+
// Reading through a getter would run sender-controlled code; refuse accessors outright.
|
|
142
|
+
if (!descriptor || !('value' in descriptor)) return false
|
|
143
|
+
budget -= key.length + 1
|
|
144
|
+
stack.push({ item: descriptor.value, depth: depth + 1 })
|
|
145
|
+
}
|
|
146
|
+
} else if (item !== undefined) return false
|
|
147
|
+
if (budget < 0) return false
|
|
148
|
+
}
|
|
149
|
+
return true
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function fail<T>(reason: SimBridgeFailure): SimBridgeResult<T> {
|
|
153
|
+
return { ok: false, reason }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export type SimHostContext = Readonly<{
|
|
157
|
+
origins: OriginAllowlist
|
|
158
|
+
/** The frame element's `contentWindow`, read when the event arrives. */
|
|
159
|
+
frameWindow: unknown
|
|
160
|
+
/** Issued by the host for this mount; the frame must echo it. */
|
|
161
|
+
bridgeId: string
|
|
162
|
+
}>
|
|
163
|
+
|
|
164
|
+
/** Host side: accept a message only from the exact frame window at an allowlisted origin. */
|
|
165
|
+
export function parseFrameMessage(
|
|
166
|
+
event: SimMessageEventLike,
|
|
167
|
+
context: SimHostContext
|
|
168
|
+
): SimBridgeResult<SimFrameMessage> {
|
|
169
|
+
if (!BRIDGE_ID.test(context.bridgeId)) return fail('bridge_mismatch')
|
|
170
|
+
if (!context.origins.has(event.origin)) return fail('origin_refused')
|
|
171
|
+
if (event.source == null || event.source !== context.frameWindow) return fail('source_mismatch')
|
|
172
|
+
const opened = envelope(event, context.bridgeId)
|
|
173
|
+
if (!opened.ok) return opened
|
|
174
|
+
const { type, payload } = opened.message
|
|
175
|
+
switch (type) {
|
|
176
|
+
case 'frame.ready':
|
|
177
|
+
case 'frame.close':
|
|
178
|
+
return strictRecord(payload, [])
|
|
179
|
+
? { ok: true, message: { type, payload: {} } }
|
|
180
|
+
: fail('invalid_payload')
|
|
181
|
+
case 'frame.state': {
|
|
182
|
+
const body = strictRecord(payload, ['state'], ['reason'])
|
|
183
|
+
const state = SIM_FRAME_STATES.find((item) => item === body?.state)
|
|
184
|
+
if (!body || !state) return fail('invalid_payload')
|
|
185
|
+
const reason = SIM_UNAVAILABLE_REASONS.find((item) => item === body.reason)
|
|
186
|
+
// A reason belongs to `unavailable` and nothing else.
|
|
187
|
+
if ('reason' in body ? !reason || state !== 'unavailable' : state === 'unavailable')
|
|
188
|
+
return fail('invalid_payload')
|
|
189
|
+
return { ok: true, message: { type, payload: reason ? { state, reason } : { state } } }
|
|
190
|
+
}
|
|
191
|
+
case 'frame.error': {
|
|
192
|
+
const body = strictRecord(payload, ['code'])
|
|
193
|
+
const code = SIM_FRAME_ERRORS.find((item) => item === body?.code)
|
|
194
|
+
return code ? { ok: true, message: { type, payload: { code } } } : fail('invalid_payload')
|
|
195
|
+
}
|
|
196
|
+
case 'frame.navigate': {
|
|
197
|
+
const body = strictRecord(payload, ['requestId', 'target'])
|
|
198
|
+
const requestId = boundedString(body?.requestId, REQUEST_ID)
|
|
199
|
+
const target = SIM_NAVIGATE_TARGETS.find((item) => item === body?.target)
|
|
200
|
+
return requestId && target
|
|
201
|
+
? { ok: true, message: { type, payload: { requestId, target } } }
|
|
202
|
+
: fail('invalid_payload')
|
|
203
|
+
}
|
|
204
|
+
case 'frame.character.save': {
|
|
205
|
+
const body = strictRecord(payload, ['requestId', 'character'])
|
|
206
|
+
const requestId = boundedString(body?.requestId, REQUEST_ID)
|
|
207
|
+
const character = parseSimCharacterValue(body?.character)
|
|
208
|
+
return requestId && character
|
|
209
|
+
? { ok: true, message: { type, payload: { requestId, character } } }
|
|
210
|
+
: fail('invalid_payload')
|
|
211
|
+
}
|
|
212
|
+
default:
|
|
213
|
+
return fail('unknown_type')
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export type SimFrameContext = Readonly<{
|
|
218
|
+
/** The exact origin of the Adea host that embeds this frame. */
|
|
219
|
+
parentOrigin: string
|
|
220
|
+
/** `window.parent`, compared by identity. */
|
|
221
|
+
parentWindow: unknown
|
|
222
|
+
bridgeId: string
|
|
223
|
+
}>
|
|
224
|
+
|
|
225
|
+
/** Frame side: accept a message only from the embedding host window at its exact origin. */
|
|
226
|
+
export function parseHostMessage(
|
|
227
|
+
event: SimMessageEventLike,
|
|
228
|
+
context: SimFrameContext
|
|
229
|
+
): SimBridgeResult<SimHostMessage> {
|
|
230
|
+
if (!BRIDGE_ID.test(context.bridgeId)) return fail('bridge_mismatch')
|
|
231
|
+
if (event.origin !== context.parentOrigin) return fail('origin_refused')
|
|
232
|
+
if (event.source == null || event.source !== context.parentWindow) return fail('source_mismatch')
|
|
233
|
+
const opened = envelope(event, context.bridgeId)
|
|
234
|
+
if (!opened.ok) return opened
|
|
235
|
+
const { type, payload } = opened.message
|
|
236
|
+
switch (type) {
|
|
237
|
+
case 'host.dispose':
|
|
238
|
+
return strictRecord(payload, [])
|
|
239
|
+
? { ok: true, message: { type, payload: {} } }
|
|
240
|
+
: fail('invalid_payload')
|
|
241
|
+
case 'host.launch': {
|
|
242
|
+
const body = strictRecord(payload, ['code', 'expiresAt', 'surface'], ['character'])
|
|
243
|
+
const code = boundedString(body?.code, LAUNCH_CODE)
|
|
244
|
+
const surface = SIM_SURFACES.find((item) => item === body?.surface)
|
|
245
|
+
const expiresAt = body?.expiresAt
|
|
246
|
+
const character =
|
|
247
|
+
body && 'character' in body ? parseSimCharacterValue(body.character) : undefined
|
|
248
|
+
if (
|
|
249
|
+
!body ||
|
|
250
|
+
!code ||
|
|
251
|
+
!surface ||
|
|
252
|
+
typeof expiresAt !== 'string' ||
|
|
253
|
+
Number.isNaN(Date.parse(expiresAt)) ||
|
|
254
|
+
character === null
|
|
255
|
+
)
|
|
256
|
+
return fail('invalid_payload')
|
|
257
|
+
return {
|
|
258
|
+
ok: true,
|
|
259
|
+
message: {
|
|
260
|
+
type,
|
|
261
|
+
payload: { code, expiresAt, surface, ...(character ? { character } : {}) },
|
|
262
|
+
},
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
case 'host.character.save.result': {
|
|
266
|
+
const body = strictRecord(payload, ['requestId', 'saved'], ['reason'])
|
|
267
|
+
const requestId = boundedString(body?.requestId, REQUEST_ID)
|
|
268
|
+
if (!body || !requestId || typeof body.saved !== 'boolean') return fail('invalid_payload')
|
|
269
|
+
const reason = SIM_SAVE_FAILURES.find((item) => item === body.reason)
|
|
270
|
+
// A failure names its reason; a success has none.
|
|
271
|
+
if (body.saved ? 'reason' in body : !reason) return fail('invalid_payload')
|
|
272
|
+
return {
|
|
273
|
+
ok: true,
|
|
274
|
+
message: {
|
|
275
|
+
type,
|
|
276
|
+
payload: reason ? { requestId, saved: false, reason } : { requestId, saved: true },
|
|
277
|
+
},
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
default:
|
|
281
|
+
return fail('unknown_type')
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** A fresh per-mount nonce: 128 random bits as base64url. The host issues it; the frame echoes it. */
|
|
286
|
+
export function createBridgeId(
|
|
287
|
+
fill: (bytes: Uint8Array) => Uint8Array = (bytes) => crypto.getRandomValues(bytes)
|
|
288
|
+
): string {
|
|
289
|
+
const bytes = fill(new Uint8Array(16))
|
|
290
|
+
let binary = ''
|
|
291
|
+
for (const byte of bytes) binary += String.fromCharCode(byte)
|
|
292
|
+
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '')
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function wrap(bridgeId: string, type: string, payload: object) {
|
|
296
|
+
if (!BRIDGE_ID.test(bridgeId)) throw new Error('Invalid Agent Sim bridge id')
|
|
297
|
+
return {
|
|
298
|
+
channel: SIM_BRIDGE_CHANNEL,
|
|
299
|
+
version: SIM_BRIDGE_VERSION,
|
|
300
|
+
bridgeId,
|
|
301
|
+
type,
|
|
302
|
+
payload,
|
|
303
|
+
} as const
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function frameEnvelope(bridgeId: string, message: SimFrameMessage) {
|
|
307
|
+
return wrap(bridgeId, message.type, message.payload)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function hostEnvelope(bridgeId: string, message: SimHostMessage) {
|
|
311
|
+
return wrap(bridgeId, message.type, message.payload)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
type PostTarget = Readonly<{ postMessage(message: unknown, targetOrigin: string): void }>
|
|
315
|
+
|
|
316
|
+
/** Host to frame. The target origin must be allowlisted; `*` can never be used. */
|
|
317
|
+
export function postToFrame(
|
|
318
|
+
frameWindow: PostTarget,
|
|
319
|
+
frameOrigin: string,
|
|
320
|
+
origins: OriginAllowlist,
|
|
321
|
+
bridgeId: string,
|
|
322
|
+
message: SimHostMessage
|
|
323
|
+
): void {
|
|
324
|
+
if (!origins.has(frameOrigin)) throw new Error('Refusing to post to an unlisted Agent Sim origin')
|
|
325
|
+
frameWindow.postMessage(hostEnvelope(bridgeId, message), frameOrigin)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Frame to host. The target is the exact embedding origin the frame was configured with. */
|
|
329
|
+
export function postToHost(
|
|
330
|
+
parentWindow: PostTarget,
|
|
331
|
+
parentOrigin: string,
|
|
332
|
+
bridgeId: string,
|
|
333
|
+
message: SimFrameMessage
|
|
334
|
+
): void {
|
|
335
|
+
if (parentOrigin === '*' || parentOrigin === 'null' || !parentOrigin.includes('://'))
|
|
336
|
+
throw new Error('Refusing to post to a non-exact Agent Sim host origin')
|
|
337
|
+
parentWindow.postMessage(frameEnvelope(bridgeId, message), parentOrigin)
|
|
338
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { parseSimCharacterValue } from './character'
|
|
2
|
+
import type { SimFrameRequestMessage, SimHostMessage } from './messages'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Frame side of the acknowledged character save.
|
|
6
|
+
*
|
|
7
|
+
* `save` resolves `true` only when the host answers the very request it sent with
|
|
8
|
+
* `saved: true`. A refusal, a failure, a timeout, a host dispose, `dispose()`, a second save while
|
|
9
|
+
* one is open, an invalid value, a post that throws, and a late or mismatched answer all resolve
|
|
10
|
+
* `false`, so the engine never shows a saved value the host did not commit.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type CharacterSaveClient = Readonly<{
|
|
14
|
+
save(character: string): Promise<boolean>
|
|
15
|
+
/** Feed every parsed host message in; unrelated ones are ignored. */
|
|
16
|
+
receive(message: SimHostMessage): void
|
|
17
|
+
/** Fails any open save. Call when the frame is torn down. */
|
|
18
|
+
dispose(): void
|
|
19
|
+
}>
|
|
20
|
+
|
|
21
|
+
export type CharacterSaveClientOptions = Readonly<{
|
|
22
|
+
post(message: SimFrameRequestMessage): void
|
|
23
|
+
/** Longer than the host's own timeout so the host's answer normally arrives first. */
|
|
24
|
+
timeoutMs?: number
|
|
25
|
+
mintRequestId?: () => string
|
|
26
|
+
schedule?: (run: () => void, ms: number) => () => void
|
|
27
|
+
}>
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_SAVE_CLIENT_TIMEOUT_MS = 8_000
|
|
30
|
+
|
|
31
|
+
export function createCharacterSaveClient(
|
|
32
|
+
options: CharacterSaveClientOptions
|
|
33
|
+
): CharacterSaveClient {
|
|
34
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_SAVE_CLIENT_TIMEOUT_MS
|
|
35
|
+
const schedule =
|
|
36
|
+
options.schedule ??
|
|
37
|
+
((run, ms) => {
|
|
38
|
+
const id = setTimeout(run, ms)
|
|
39
|
+
return () => clearTimeout(id)
|
|
40
|
+
})
|
|
41
|
+
let counter = 0
|
|
42
|
+
const mint = options.mintRequestId ?? (() => `save${++counter}`)
|
|
43
|
+
let open: { requestId: string; settle(saved: boolean): void } | null = null
|
|
44
|
+
let disposed = false
|
|
45
|
+
|
|
46
|
+
function failOpen() {
|
|
47
|
+
open?.settle(false)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
save(character) {
|
|
52
|
+
if (disposed || open || parseSimCharacterValue(character) === null)
|
|
53
|
+
return Promise.resolve(false)
|
|
54
|
+
const requestId = mint()
|
|
55
|
+
return new Promise<boolean>((resolve) => {
|
|
56
|
+
const cancel = schedule(() => failOpen(), timeoutMs)
|
|
57
|
+
const entry = {
|
|
58
|
+
requestId,
|
|
59
|
+
settle(saved: boolean) {
|
|
60
|
+
if (open !== entry) return
|
|
61
|
+
open = null
|
|
62
|
+
cancel()
|
|
63
|
+
resolve(saved)
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
open = entry
|
|
67
|
+
try {
|
|
68
|
+
options.post({ type: 'frame.character.save', payload: { requestId, character } })
|
|
69
|
+
} catch {
|
|
70
|
+
entry.settle(false)
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
},
|
|
74
|
+
receive(message) {
|
|
75
|
+
if (message.type === 'host.dispose') failOpen()
|
|
76
|
+
else if (
|
|
77
|
+
message.type === 'host.character.save.result' &&
|
|
78
|
+
open?.requestId === message.payload.requestId
|
|
79
|
+
)
|
|
80
|
+
open.settle(message.payload.saved)
|
|
81
|
+
},
|
|
82
|
+
dispose() {
|
|
83
|
+
disposed = true
|
|
84
|
+
failOpen()
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
}
|