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