@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.
@@ -0,0 +1,238 @@
1
+ import type { SimFrameStatusMessage } from './messages'
2
+ import type { SimLaunchGrant } from './launch'
3
+
4
+ /**
5
+ * What the host knows about a hosted Agent Sim launch, as a pure state machine. The host shows only
6
+ * what this state says: nothing is "running" until the frame reports it, and every failure names a
7
+ * cause. Chat and Dev never read this state, so a failed or offline sim cannot affect them.
8
+ */
9
+
10
+ export const SIM_UNAVAILABLE_REASONS = [
11
+ 'unauthenticated',
12
+ 'not_entitled',
13
+ 'not_configured',
14
+ 'service_unavailable',
15
+ 'origin_refused',
16
+ 'bridge_incompatible',
17
+ 'launch_rejected',
18
+ 'engine_failed',
19
+ 'frame_timeout',
20
+ ] as const
21
+ export type SimUnavailableReason = (typeof SIM_UNAVAILABLE_REASONS)[number]
22
+
23
+ type GrantedLaunch = Extract<SimLaunchGrant, { status: 'granted' }>
24
+
25
+ /**
26
+ * Every state carries the `attempt` it belongs to. `start` begins attempt N+1, and every event
27
+ * that answers a specific attempt (`grant`, `frame`, `timeout`) names the attempt it was issued
28
+ * for. An event for any other attempt is dropped, so a late grant, frame message or timer from an
29
+ * abandoned attempt can never launch, fail or close a newer one. Callers also mint a fresh
30
+ * `bridgeId` per attempt, which stops a stale frame at the message layer as well.
31
+ */
32
+ export type SimLaunchState =
33
+ | Readonly<{ phase: 'idle'; attempt: number }>
34
+ | Readonly<{ phase: 'requesting'; attempt: number }>
35
+ | Readonly<{ phase: 'awaiting_frame'; attempt: number; grant: GrantedLaunch }>
36
+ | Readonly<{ phase: 'launching'; attempt: number }>
37
+ | Readonly<{ phase: 'running'; attempt: number }>
38
+ | Readonly<{ phase: 'unavailable'; attempt: number; reason: SimUnavailableReason }>
39
+ | Readonly<{ phase: 'offline'; attempt: number }>
40
+
41
+ export type SimLaunchEvent =
42
+ /**
43
+ * Begins a new attempt. `online: false` means the host already knows it is offline: the attempt
44
+ * is recorded as `offline` and no grant is requested, so a surface that mounts offline shows the
45
+ * offline state (with its retry) instead of staying idle, and the backend is never asked.
46
+ */
47
+ | Readonly<{ type: 'start'; online?: boolean }>
48
+ /** The backend's parsed reply for `attempt`; `null` is a reply that failed validation. */
49
+ | Readonly<{ type: 'grant'; attempt: number; grant: SimLaunchGrant | null }>
50
+ | Readonly<{ type: 'frame'; attempt: number; message: SimFrameStatusMessage; now: number }>
51
+ | Readonly<{ type: 'timeout'; attempt: number }>
52
+ | Readonly<{ type: 'offline' }>
53
+ | Readonly<{ type: 'dispose' }>
54
+
55
+ export type SimLaunchEffect =
56
+ | Readonly<{ type: 'request_grant'; attempt: number }>
57
+ | Readonly<{ type: 'post_launch'; attempt: number; grant: GrantedLaunch }>
58
+ | Readonly<{ type: 'dispose_frame'; attempt: number }>
59
+
60
+ export type SimLaunchStep = Readonly<{ state: SimLaunchState; effect?: SimLaunchEffect }>
61
+
62
+ const FRAME_ERROR_REASONS = {
63
+ launch_rejected: 'launch_rejected',
64
+ asset_unavailable: 'service_unavailable',
65
+ engine_failed: 'engine_failed',
66
+ bridge_incompatible: 'bridge_incompatible',
67
+ } as const
68
+
69
+ export const SIM_IDLE: SimLaunchState = { phase: 'idle', attempt: 0 }
70
+
71
+ function stay(state: SimLaunchState): SimLaunchStep {
72
+ return { state }
73
+ }
74
+
75
+ function unavailable(attempt: number, reason: SimUnavailableReason): SimLaunchStep {
76
+ return { state: { phase: 'unavailable', attempt, reason } }
77
+ }
78
+
79
+ function reduceFrame(
80
+ state: SimLaunchState,
81
+ message: SimFrameStatusMessage,
82
+ now: number
83
+ ): SimLaunchStep {
84
+ const { attempt } = state
85
+ switch (message.type) {
86
+ case 'frame.ready': {
87
+ // Only the first ready of an awaited frame launches; a replay changes nothing.
88
+ if (state.phase !== 'awaiting_frame') return stay(state)
89
+ if (Date.parse(state.grant.expiresAt) <= now) return unavailable(attempt, 'frame_timeout')
90
+ return {
91
+ state: { phase: 'launching', attempt },
92
+ effect: { type: 'post_launch', attempt, grant: state.grant },
93
+ }
94
+ }
95
+ case 'frame.state': {
96
+ if (state.phase !== 'launching' && state.phase !== 'running') return stay(state)
97
+ const { state: reported, reason } = message.payload
98
+ if (reported === 'running') return stay({ phase: 'running', attempt })
99
+ if (reported === 'offline') return stay({ phase: 'offline', attempt })
100
+ if (reported === 'unavailable') return unavailable(attempt, reason ?? 'service_unavailable')
101
+ return stay(state)
102
+ }
103
+ case 'frame.error':
104
+ // A frame that fails before it ever reports ready (an engine that cannot start, a bridge
105
+ // version the host does not speak) is a failure of this attempt, not noise.
106
+ if (
107
+ state.phase !== 'awaiting_frame' &&
108
+ state.phase !== 'launching' &&
109
+ state.phase !== 'running'
110
+ )
111
+ return stay(state)
112
+ return unavailable(attempt, FRAME_ERROR_REASONS[message.payload.code])
113
+ case 'frame.close':
114
+ return state.phase === 'idle' ? stay(state) : stay({ phase: 'idle', attempt })
115
+ }
116
+ }
117
+
118
+ export function reduceSimLaunch(state: SimLaunchState, event: SimLaunchEvent): SimLaunchStep {
119
+ const { attempt } = state
120
+ switch (event.type) {
121
+ case 'start': {
122
+ if (state.phase !== 'idle' && state.phase !== 'unavailable' && state.phase !== 'offline')
123
+ return stay(state)
124
+ const next = attempt + 1
125
+ if (event.online === false) return stay({ phase: 'offline', attempt: next })
126
+ return {
127
+ state: { phase: 'requesting', attempt: next },
128
+ effect: { type: 'request_grant', attempt: next },
129
+ }
130
+ }
131
+ case 'grant': {
132
+ if (event.attempt !== attempt || state.phase !== 'requesting') return stay(state)
133
+ const { grant } = event
134
+ if (!grant) return unavailable(attempt, 'service_unavailable')
135
+ if (grant.status === 'granted') return stay({ phase: 'awaiting_frame', attempt, grant })
136
+ return unavailable(attempt, grant.reason)
137
+ }
138
+ case 'frame':
139
+ return event.attempt === attempt ? reduceFrame(state, event.message, event.now) : stay(state)
140
+ case 'timeout':
141
+ return event.attempt === attempt &&
142
+ (state.phase === 'awaiting_frame' || state.phase === 'launching')
143
+ ? unavailable(attempt, 'frame_timeout')
144
+ : stay(state)
145
+ case 'offline':
146
+ return state.phase === 'idle' ? stay(state) : stay({ phase: 'offline', attempt })
147
+ case 'dispose':
148
+ return state.phase === 'idle'
149
+ ? stay(state)
150
+ : { state: { phase: 'idle', attempt }, effect: { type: 'dispose_frame', attempt } }
151
+ }
152
+ }
153
+
154
+ export type SimAvailabilityNotice = Readonly<{
155
+ tone: 'neutral' | 'error'
156
+ title: string
157
+ detail: string
158
+ /** Whether trying again can help without the user changing anything else. */
159
+ retryable: boolean
160
+ }>
161
+
162
+ const REASON_COPY: Record<SimUnavailableReason, Omit<SimAvailabilityNotice, 'tone'>> = {
163
+ unauthenticated: {
164
+ title: 'Sign in to open Agent Sim',
165
+ detail: 'Agent Sim needs your Adea account.',
166
+ retryable: false,
167
+ },
168
+ not_entitled: {
169
+ title: 'Agent Sim is not included in this account',
170
+ detail: 'Your account does not include Agent Sim.',
171
+ retryable: false,
172
+ },
173
+ not_configured: {
174
+ title: 'Agent Sim is not set up here',
175
+ detail: 'This deployment has no Agent Sim service configured.',
176
+ retryable: false,
177
+ },
178
+ service_unavailable: {
179
+ title: 'Agent Sim is unavailable right now',
180
+ detail: 'The Agent Sim service did not respond.',
181
+ retryable: true,
182
+ },
183
+ origin_refused: {
184
+ title: 'Agent Sim was blocked',
185
+ detail: 'The Agent Sim frame did not come from an allowed address.',
186
+ retryable: false,
187
+ },
188
+ bridge_incompatible: {
189
+ title: 'Agent Sim needs an update',
190
+ detail: 'This version of Adea and Agent Sim cannot talk to each other.',
191
+ retryable: false,
192
+ },
193
+ launch_rejected: {
194
+ title: 'Agent Sim could not start',
195
+ detail: 'The launch was refused or had already expired.',
196
+ retryable: true,
197
+ },
198
+ engine_failed: {
199
+ title: 'Agent Sim stopped working',
200
+ detail: 'The simulation hit an error.',
201
+ retryable: true,
202
+ },
203
+ frame_timeout: {
204
+ title: 'Agent Sim took too long to start',
205
+ detail: 'It did not finish loading in time.',
206
+ retryable: true,
207
+ },
208
+ }
209
+
210
+ const INDEPENDENCE = ' Chat and Dev are not affected.'
211
+
212
+ /** Truthful copy for a state, or null when the surface has nothing to say (idle, loading, running). */
213
+ export function describeSimAvailability(state: SimLaunchState): SimAvailabilityNotice | null {
214
+ switch (state.phase) {
215
+ case 'offline':
216
+ return {
217
+ tone: 'error',
218
+ title: 'Agent Sim is offline',
219
+ detail: `You are offline, so Agent Sim cannot load.${INDEPENDENCE}`,
220
+ retryable: true,
221
+ }
222
+ case 'unavailable': {
223
+ const copy = REASON_COPY[state.reason]
224
+ return { tone: 'error', ...copy, detail: `${copy.detail}${INDEPENDENCE}` }
225
+ }
226
+ case 'requesting':
227
+ case 'awaiting_frame':
228
+ case 'launching':
229
+ return {
230
+ tone: 'neutral',
231
+ title: 'Opening Agent Sim',
232
+ detail: 'Checking your account and loading the simulation.',
233
+ retryable: false,
234
+ }
235
+ default:
236
+ return null
237
+ }
238
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The character value the bridge carries in `host.launch` and `frame.character.save`.
3
+ *
4
+ * Two forms, both finite and structurally checked. The bridge never learns the private part
5
+ * catalog: slot contents are bounded opaque ids and the engine keeps validating membership.
6
+ *
7
+ * 1. A catalog id, such as `configurable`.
8
+ * 2. `character:v1:` followed by exactly 13 `|`-separated slots in `SIM_CHARACTER_SLOTS` order.
9
+ * An empty slot is `-`, and `body` is required.
10
+ */
11
+
12
+ export const SIM_CHARACTER_SLOTS = [
13
+ 'body',
14
+ 'ears',
15
+ 'face',
16
+ 'hair',
17
+ 'hat',
18
+ 'top',
19
+ 'bottom',
20
+ 'shoes',
21
+ 'socks',
22
+ 'glasses',
23
+ 'gloves',
24
+ 'accessory',
25
+ 'costume',
26
+ ] as const
27
+
28
+ export const SIM_SERIALIZED_CHARACTER_PREFIX = 'character:v1:'
29
+ /** The audited serializer's longest value is 251 characters; this leaves headroom, not slack. */
30
+ export const MAX_SIM_SERIALIZED_CHARACTER_LENGTH = 384
31
+
32
+ const CATALOG_ID = /^[A-Za-z0-9_.:-]{1,64}$/
33
+ const SLOT_PART_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,47}$/
34
+ const EMPTY_SLOT = '-'
35
+
36
+ export function isSerializedSimCharacter(value: unknown): value is string {
37
+ if (
38
+ typeof value !== 'string' ||
39
+ value.length > MAX_SIM_SERIALIZED_CHARACTER_LENGTH ||
40
+ !value.startsWith(SIM_SERIALIZED_CHARACTER_PREFIX)
41
+ )
42
+ return false
43
+ const slots = value.slice(SIM_SERIALIZED_CHARACTER_PREFIX.length).split('|')
44
+ if (slots.length !== SIM_CHARACTER_SLOTS.length) return false
45
+ return slots.every((slot, index) => (slot === EMPTY_SLOT ? index !== 0 : SLOT_PART_ID.test(slot)))
46
+ }
47
+
48
+ /** The value when it is a catalog id or a well-formed serialized character; otherwise null. */
49
+ export function parseSimCharacterValue(value: unknown): string | null {
50
+ if (typeof value !== 'string') return null
51
+ if (value.startsWith('character:')) return isSerializedSimCharacter(value) ? value : null
52
+ return CATALOG_ID.test(value) ? value : null
53
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The sim-only session cookie set by the Agent Sim origin after a successful code exchange.
3
+ *
4
+ * It is `__Host-` prefixed (Secure, Path=/, no Domain), HttpOnly so no script on either origin can
5
+ * read it, and `SameSite=None; Partitioned` because the sim is embedded as a third-party frame: the
6
+ * browser keys it to the embedding Adea site and never sends it in any other context. It
7
+ * authorizes the sim origin's assets only; it is not an Adea account session and never appears in a
8
+ * response body or a bridge message.
9
+ */
10
+
11
+ export const SIM_SESSION_COOKIE_NAME = '__Host-adea-sim'
12
+ export const MAX_SIM_SESSION_SECONDS = 3600
13
+
14
+ const SESSION_VALUE = /^[A-Za-z0-9_-]{32,256}$/
15
+
16
+ export function buildSimSessionSetCookie(value: string, maxAgeSeconds: number): string {
17
+ if (!SESSION_VALUE.test(value)) throw new Error('Invalid Agent Sim session value')
18
+ return setCookie(value, boundedMaxAge(maxAgeSeconds))
19
+ }
20
+
21
+ /** Expires the session on the client. */
22
+ export function buildSimSessionClearCookie(): string {
23
+ return setCookie('', 0)
24
+ }
25
+
26
+ function boundedMaxAge(seconds: number): number {
27
+ if (!Number.isInteger(seconds) || seconds < 1 || seconds > MAX_SIM_SESSION_SECONDS)
28
+ throw new Error('Agent Sim session lifetime out of range')
29
+ return seconds
30
+ }
31
+
32
+ function setCookie(value: string, maxAge: number): string {
33
+ return [
34
+ `${SIM_SESSION_COOKIE_NAME}=${value}`,
35
+ `Max-Age=${maxAge}`,
36
+ 'Path=/',
37
+ 'Secure',
38
+ 'HttpOnly',
39
+ 'SameSite=None',
40
+ 'Partitioned',
41
+ ].join('; ')
42
+ }
43
+
44
+ /** Every way a `Set-Cookie` header departs from the contract; empty means it conforms. */
45
+ export function simSessionCookieViolations(header: string): readonly string[] {
46
+ const [pair = '', ...attributes] = header.split(';').map((part) => part.trim())
47
+ const violations: string[] = []
48
+ if (!pair.startsWith(`${SIM_SESSION_COOKIE_NAME}=`)) violations.push('name')
49
+ const names = new Map<string, string>()
50
+ for (const attribute of attributes) {
51
+ const index = attribute.indexOf('=')
52
+ const key = (index === -1 ? attribute : attribute.slice(0, index)).toLowerCase()
53
+ if (names.has(key)) violations.push(`duplicate:${key}`)
54
+ names.set(key, index === -1 ? '' : attribute.slice(index + 1))
55
+ }
56
+ if (!names.has('secure')) violations.push('secure')
57
+ if (!names.has('httponly')) violations.push('httponly')
58
+ if (!names.has('partitioned')) violations.push('partitioned')
59
+ if (names.get('path') !== '/') violations.push('path')
60
+ if (names.has('domain')) violations.push('domain')
61
+ if (names.get('samesite')?.toLowerCase() !== 'none') violations.push('samesite')
62
+ const maxAge = names.get('max-age')
63
+ if (maxAge === undefined || !/^\d+$/.test(maxAge) || Number(maxAge) > MAX_SIM_SESSION_SECONDS)
64
+ violations.push('max-age')
65
+ if (names.has('expires')) violations.push('expires')
66
+ return violations
67
+ }
68
+
69
+ /**
70
+ * The session value from a request's `Cookie` header, or null unless exactly one sim cookie of
71
+ * the right shape is present (a duplicate is refused rather than guessed between).
72
+ */
73
+ export function readSimSessionCookie(cookieHeader: string | null | undefined): string | null {
74
+ if (!cookieHeader) return null
75
+ const values = cookieHeader
76
+ .split(';')
77
+ .map((part) => part.trim())
78
+ .filter((part) => part.startsWith(`${SIM_SESSION_COOKIE_NAME}=`))
79
+ .map((part) => part.slice(SIM_SESSION_COOKIE_NAME.length + 1))
80
+ const [only] = values
81
+ return values.length === 1 && only !== undefined && SESSION_VALUE.test(only) ? only : null
82
+ }
@@ -0,0 +1,8 @@
1
+ export * from './availability'
2
+ export * from './character'
3
+ export * from './cookie'
4
+ export * from './launch'
5
+ export * from './messages'
6
+ export * from './origin'
7
+ export * from './save-client'
8
+ export * from './service'
@@ -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
+ }