@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.
- package/README.md +57 -0
- package/dist/sim-bridge/availability.d.ts +98 -0
- package/dist/sim-bridge/availability.js +181 -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 +6 -0
- package/dist/sim-bridge/index.js +6 -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 +105 -0
- package/dist/sim-bridge/messages.js +207 -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/service.d.ts +85 -0
- package/dist/sim-bridge/service.js +166 -0
- package/package.json +6 -1
- package/src/sim-bridge/availability.ts +234 -0
- package/src/sim-bridge/cookie.ts +82 -0
- package/src/sim-bridge/index.ts +6 -0
- package/src/sim-bridge/launch.ts +227 -0
- package/src/sim-bridge/messages.ts +282 -0
- package/src/sim-bridge/origin.ts +61 -0
- package/src/sim-bridge/record.ts +60 -0
- package/src/sim-bridge/service.ts +279 -0
|
@@ -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
|
+
}
|