@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,279 @@
1
+ import { createOriginAllowlist } from './origin'
2
+ import {
3
+ MAX_SIM_LAUNCH_TTL_MS,
4
+ parseSimLaunchRequest,
5
+ parseSimSessionExchangeRequest,
6
+ type LaunchCodeBinding,
7
+ type LaunchCodeRedemption,
8
+ type SimLaunchGrant,
9
+ type SimPlatform,
10
+ type SimSessionExchangeResult,
11
+ type SimSurface,
12
+ } from './launch'
13
+ import { MAX_SIM_SESSION_SECONDS, buildSimSessionSetCookie } from './cookie'
14
+
15
+ /**
16
+ * Backend logic for the hosted Agent Sim launch, written against injectable ports.
17
+ *
18
+ * Nothing here picks an entitlement source, a sim origin or a storage engine: the caller supplies
19
+ * each as a port or a config value, and an absent value fails closed as `not_configured`. The
20
+ * module does no I/O, owns no endpoint and is not imported by the app. A real deployment adds the
21
+ * HTTP routes, the durable stores and the entitlement lookup around it.
22
+ *
23
+ * Two entry points mirror the two steps of the launch contract in `launch.ts`:
24
+ *
25
+ * - `createSimLaunchIssuer` (Adea backend): checks the signed-in account's entitlement and issues a
26
+ * single-use code bound to the host's `bridgeId`, the sim origin and the surface.
27
+ * - `createSimSessionExchange` (sim origin): redeems that code once and mints a sim-only session
28
+ * whose value travels only in the `Set-Cookie` header, never in a body.
29
+ */
30
+
31
+ type MaybePromise<T> = T | Promise<T>
32
+
33
+ export type SimEntitlementQuery = Readonly<{
34
+ workspaceId: string
35
+ surface: SimSurface
36
+ /**
37
+ * The platform the client says it is. It is a hint for the entitlement source and never proof
38
+ * of anything: localhost, an embedded secret or a desktop shell's claim cannot entitle a launch.
39
+ */
40
+ platform: SimPlatform
41
+ }>
42
+
43
+ export type SimEntitlementVerdict = 'entitled' | 'not_entitled' | 'unauthenticated' | 'unavailable'
44
+
45
+ /**
46
+ * Decides whether the account behind the current request may launch. The port is bound to that
47
+ * request's authenticated session by whoever builds it, so no account identity passes through the
48
+ * launch request body.
49
+ */
50
+ export type SimEntitlementChecker = (
51
+ query: SimEntitlementQuery
52
+ ) => MaybePromise<SimEntitlementVerdict>
53
+
54
+ /** Durable single-use code storage. `createLaunchCodeLedger()` is the in-memory reference. */
55
+ export type SimLaunchCodeStore = Readonly<{
56
+ issue(code: string, binding: LaunchCodeBinding, expiresAt: number): MaybePromise<void>
57
+ redeem(
58
+ code: string,
59
+ presented: Omit<LaunchCodeBinding, 'surface'>,
60
+ now: number
61
+ ): MaybePromise<LaunchCodeRedemption>
62
+ }>
63
+
64
+ export type SimSessionRecord = Readonly<{ surface: SimSurface; expiresAt: number }>
65
+
66
+ /** Where the sim origin keeps the sessions it mints; looked up later by the asset gate. */
67
+ export type SimSessionStore = Readonly<{
68
+ save(value: string, record: SimSessionRecord): MaybePromise<void>
69
+ }>
70
+
71
+ type Clock = () => number
72
+ type RandomBytes = (length: number) => Uint8Array
73
+
74
+ const systemClock: Clock = () => Date.now()
75
+ const systemRandom: RandomBytes = (length) => crypto.getRandomValues(new Uint8Array(length))
76
+
77
+ const DEFAULT_LAUNCH_TTL_MS = 60_000
78
+ const DEFAULT_SESSION_SECONDS = MAX_SIM_SESSION_SECONDS
79
+ const CODE_BYTES = 32
80
+ const SESSION_BYTES = 32
81
+
82
+ export type SimLaunchIssuerConfig = Readonly<{
83
+ /** The sim origin to embed. Absent or invalid means "not configured", never a default. */
84
+ frameOrigin: string | null | undefined
85
+ allowLoopbackHttp?: boolean
86
+ entitlement: SimEntitlementChecker
87
+ store: SimLaunchCodeStore
88
+ /** Code lifetime in milliseconds, clamped to the contract maximum. */
89
+ codeTtlMs?: number
90
+ now?: Clock
91
+ randomBytes?: RandomBytes
92
+ }>
93
+
94
+ export type SimLaunchIssueResult = SimLaunchGrant | Readonly<{ status: 'invalid_request' }>
95
+
96
+ /** The backend half of the launch: a request body in, a grant (or a refusal) out. */
97
+ export function createSimLaunchIssuer(
98
+ config: SimLaunchIssuerConfig
99
+ ): (body: unknown) => Promise<SimLaunchIssueResult> {
100
+ const frameOrigin = configuredOrigin(config.frameOrigin, config.allowLoopbackHttp === true)
101
+ const now = config.now ?? systemClock
102
+ const random = config.randomBytes ?? systemRandom
103
+ const ttl = clampedLifetime(config.codeTtlMs, DEFAULT_LAUNCH_TTL_MS, MAX_SIM_LAUNCH_TTL_MS)
104
+
105
+ return async (body) => {
106
+ const request = parseSimLaunchRequest(body)
107
+ if (!request) return { status: 'invalid_request' }
108
+ // A lifetime that is not a finite number is a misconfiguration, treated like a missing origin.
109
+ if (!frameOrigin || ttl === null) return { status: 'unavailable', reason: 'not_configured' }
110
+
111
+ let verdict: SimEntitlementVerdict
112
+ try {
113
+ verdict = await config.entitlement({
114
+ workspaceId: request.workspaceId,
115
+ surface: request.surface,
116
+ platform: request.platform,
117
+ })
118
+ } catch {
119
+ return { status: 'unavailable', reason: 'service_unavailable' }
120
+ }
121
+ if (verdict === 'unauthenticated') return { status: 'denied', reason: 'unauthenticated' }
122
+ if (verdict === 'not_entitled') return { status: 'denied', reason: 'not_entitled' }
123
+ // Anything but an explicit "entitled" fails closed, including a verdict this code does not know.
124
+ if (verdict !== 'entitled') return { status: 'unavailable', reason: 'service_unavailable' }
125
+
126
+ // Everything that can fail on bad injected input is computed before the store is touched, so a
127
+ // bad clock or random source leaves no stored code behind.
128
+ let code: string
129
+ let expiresAt: number
130
+ let expiresAtIso: string | null
131
+ try {
132
+ code = base64url(random(CODE_BYTES))
133
+ expiresAt = readClock(now) + ttl
134
+ expiresAtIso = isoInstant(expiresAt)
135
+ } catch {
136
+ return { status: 'unavailable', reason: 'service_unavailable' }
137
+ }
138
+ if (!expiresAtIso) return { status: 'unavailable', reason: 'service_unavailable' }
139
+ try {
140
+ await config.store.issue(
141
+ code,
142
+ { bridgeId: request.bridgeId, origin: frameOrigin, surface: request.surface },
143
+ expiresAt
144
+ )
145
+ } catch {
146
+ return { status: 'unavailable', reason: 'service_unavailable' }
147
+ }
148
+ return {
149
+ status: 'granted',
150
+ code,
151
+ expiresAt: expiresAtIso,
152
+ surface: request.surface,
153
+ frameOrigin,
154
+ }
155
+ }
156
+ }
157
+
158
+ export type SimSessionExchangeConfig = Readonly<{
159
+ /** This sim origin, as the issuer bound it into the code. Absent or invalid is "not configured". */
160
+ origin: string | null | undefined
161
+ allowLoopbackHttp?: boolean
162
+ store: SimLaunchCodeStore
163
+ sessions: SimSessionStore
164
+ /** Session lifetime in seconds, clamped to the contract maximum. */
165
+ sessionSeconds?: number
166
+ now?: Clock
167
+ randomBytes?: RandomBytes
168
+ }>
169
+
170
+ /** What the sim origin answers: a JSON body, plus the cookie header only for a new session. */
171
+ export type SimSessionExchangeResponse = Readonly<{
172
+ body: SimSessionExchangeResult | Readonly<{ status: 'invalid_request' }>
173
+ setCookie?: string
174
+ }>
175
+
176
+ /** The sim origin's half of the launch: redeems a code once and sets the sim-only cookie. */
177
+ export function createSimSessionExchange(
178
+ config: SimSessionExchangeConfig
179
+ ): (body: unknown) => Promise<SimSessionExchangeResponse> {
180
+ const origin = configuredOrigin(config.origin, config.allowLoopbackHttp === true)
181
+ const now = config.now ?? systemClock
182
+ const random = config.randomBytes ?? systemRandom
183
+ const seconds = clampedLifetime(
184
+ config.sessionSeconds,
185
+ DEFAULT_SESSION_SECONDS,
186
+ MAX_SIM_SESSION_SECONDS
187
+ )
188
+
189
+ return async (body) => {
190
+ const request = parseSimSessionExchangeRequest(body)
191
+ if (!request) return { body: { status: 'invalid_request' } }
192
+ if (!origin || seconds === null) return { body: { status: 'unavailable' } }
193
+
194
+ // The session is fully prepared before the code is redeemed. Redeeming spends the code, so a
195
+ // bad clock or random source must fail here, where nothing has been consumed or stored.
196
+ let at: number
197
+ let value: string
198
+ let expiresAt: number
199
+ let expiresAtIso: string | null
200
+ let setCookie: string
201
+ try {
202
+ at = readClock(now)
203
+ value = base64url(random(SESSION_BYTES))
204
+ expiresAt = at + seconds * 1000
205
+ expiresAtIso = isoInstant(expiresAt)
206
+ setCookie = buildSimSessionSetCookie(value, seconds)
207
+ } catch {
208
+ return { body: { status: 'unavailable' } }
209
+ }
210
+ if (!expiresAtIso) return { body: { status: 'unavailable' } }
211
+
212
+ let redemption: LaunchCodeRedemption
213
+ try {
214
+ redemption = await config.store.redeem(
215
+ request.code,
216
+ { bridgeId: request.bridgeId, origin },
217
+ at
218
+ )
219
+ } catch {
220
+ return { body: { status: 'unavailable' } }
221
+ }
222
+ if (!redemption.ok) {
223
+ // An unknown code carries no more information than an expired one; the reply never says
224
+ // whether a code ever existed.
225
+ const reason = redemption.reason === 'unknown' ? 'code_expired' : redemption.reason
226
+ return { body: { status: 'rejected', reason } }
227
+ }
228
+
229
+ // The code is spent from here on: if saving fails the same code is not retryable, and the client
230
+ // starts a new launch.
231
+ try {
232
+ await config.sessions.save(value, { surface: redemption.surface, expiresAt })
233
+ } catch {
234
+ return { body: { status: 'unavailable' } }
235
+ }
236
+ return { body: { status: 'session', expiresAt: expiresAtIso }, setCookie }
237
+ }
238
+ }
239
+
240
+ /**
241
+ * A configured lifetime in the unit the caller passes (milliseconds or seconds): the default when
242
+ * absent, clamped to [1, max] when a finite number, and null for anything else (NaN, Infinity, a
243
+ * non-number), which callers treat as a misconfiguration rather than guessing a value.
244
+ */
245
+ function clampedLifetime(value: unknown, fallback: number, max: number): number | null {
246
+ if (value === undefined) return fallback
247
+ if (typeof value !== 'number' || !Number.isFinite(value)) return null
248
+ return Math.min(Math.max(1, Math.floor(value)), max)
249
+ }
250
+
251
+ /** One clock reading, which must be a finite number of milliseconds; anything else throws. */
252
+ function readClock(now: Clock): number {
253
+ const reading: unknown = now()
254
+ if (typeof reading !== 'number' || !Number.isFinite(reading)) {
255
+ throw new RangeError('clock returned a non-finite reading')
256
+ }
257
+ return reading
258
+ }
259
+
260
+ /** The ISO string for an instant, or null when it is not a valid Date (NaN, beyond the Date range). */
261
+ function isoInstant(ms: number): string | null {
262
+ const date = new Date(ms)
263
+ return Number.isNaN(date.getTime()) ? null : date.toISOString()
264
+ }
265
+
266
+ function configuredOrigin(origin: unknown, allowLoopbackHttp: boolean): string | null {
267
+ if (origin == null) return null
268
+ try {
269
+ return createOriginAllowlist([origin], { allowLoopbackHttp }).origins[0] ?? null
270
+ } catch {
271
+ return null
272
+ }
273
+ }
274
+
275
+ function base64url(bytes: Uint8Array): string {
276
+ let binary = ''
277
+ for (const byte of bytes) binary += String.fromCharCode(byte)
278
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '')
279
+ }