@adea-ai/spatial 0.94.0 → 0.96.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +7 -2
- package/dist/sim-bridge/availability.d.ts +98 -0
- package/dist/sim-bridge/availability.js +181 -0
- package/dist/sim-bridge/character.d.ts +17 -0
- package/dist/sim-bridge/character.js +49 -0
- package/dist/sim-bridge/cookie.d.ts +21 -0
- package/dist/sim-bridge/cookie.js +85 -0
- package/dist/sim-bridge/index.d.ts +8 -0
- package/dist/sim-bridge/index.js +8 -0
- package/dist/sim-bridge/launch.d.ts +111 -0
- package/dist/sim-bridge/launch.js +153 -0
- package/dist/sim-bridge/messages.d.ts +133 -0
- package/dist/sim-bridge/messages.js +244 -0
- package/dist/sim-bridge/origin.d.ts +21 -0
- package/dist/sim-bridge/origin.js +51 -0
- package/dist/sim-bridge/record.d.ts +16 -0
- package/dist/sim-bridge/record.js +56 -0
- package/dist/sim-bridge/save-client.d.ts +25 -0
- package/dist/sim-bridge/save-client.js +55 -0
- package/dist/sim-bridge/service.d.ts +85 -0
- package/dist/sim-bridge/service.js +166 -0
- package/package.json +7 -2
- package/src/index.ts +7 -2
- package/src/sim-bridge/availability.ts +238 -0
- package/src/sim-bridge/character.ts +53 -0
- package/src/sim-bridge/cookie.ts +82 -0
- package/src/sim-bridge/index.ts +8 -0
- package/src/sim-bridge/launch.ts +227 -0
- package/src/sim-bridge/messages.ts +338 -0
- package/src/sim-bridge/origin.ts +61 -0
- package/src/sim-bridge/record.ts +60 -0
- package/src/sim-bridge/save-client.ts +87 -0
- package/src/sim-bridge/service.ts +279 -0
|
@@ -0,0 +1,51 @@
|
|
|
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
|
+
const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]'];
|
|
11
|
+
/** The canonical origin when `value` is exactly one, otherwise null. */
|
|
12
|
+
export function canonicalBridgeOrigin(value, options = {}) {
|
|
13
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 256)
|
|
14
|
+
return null;
|
|
15
|
+
let url;
|
|
16
|
+
try {
|
|
17
|
+
url = new URL(value);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
// `origin` is "null" for opaque schemes; requiring equality also rejects paths, queries,
|
|
23
|
+
// credentials, trailing slashes and non-canonical spellings.
|
|
24
|
+
if (url.origin !== value)
|
|
25
|
+
return null;
|
|
26
|
+
// The URL parser tolerates `*` and similar in a host; an allowlist entry must be a real host.
|
|
27
|
+
if (!/^(?:[a-z0-9-]+(?:\.[a-z0-9-]+)*|\[[0-9a-f:]+\])$/.test(url.hostname))
|
|
28
|
+
return null;
|
|
29
|
+
if (url.protocol === 'https:')
|
|
30
|
+
return value;
|
|
31
|
+
if (url.protocol === 'http:' &&
|
|
32
|
+
options.allowLoopbackHttp === true &&
|
|
33
|
+
LOOPBACK_HOSTS.includes(url.hostname))
|
|
34
|
+
return value;
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
export function createOriginAllowlist(origins, options = {}) {
|
|
38
|
+
if (origins.length === 0)
|
|
39
|
+
throw new Error('The Agent Sim bridge needs at least one origin');
|
|
40
|
+
const canonical = origins.map((origin) => {
|
|
41
|
+
const value = canonicalBridgeOrigin(origin, options);
|
|
42
|
+
if (value === null)
|
|
43
|
+
throw new Error('Agent Sim bridge origins must be exact canonical origins');
|
|
44
|
+
return value;
|
|
45
|
+
});
|
|
46
|
+
const unique = [...new Set(canonical)];
|
|
47
|
+
return Object.freeze({
|
|
48
|
+
origins: Object.freeze(unique),
|
|
49
|
+
has: (origin) => typeof origin === 'string' && unique.includes(origin),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Strict structural readers shared by the bridge validators. No coercion, no extra keys. */
|
|
2
|
+
export declare function isPlainRecord(value: unknown): value is Record<string, unknown>;
|
|
3
|
+
/**
|
|
4
|
+
* The record when it is plain and carries exactly the allowed keys: every `required` key is
|
|
5
|
+
* present and no key outside `required` and `optional` exists. Accessor properties are refused so
|
|
6
|
+
* a getter can never run during validation.
|
|
7
|
+
*/
|
|
8
|
+
export declare function strictRecord(value: unknown, required: readonly string[], optional?: readonly string[]): Record<string, unknown> | null;
|
|
9
|
+
/**
|
|
10
|
+
* An own data property of a plain record, or undefined. Used to branch on a discriminant before
|
|
11
|
+
* the strict reader runs, without ever invoking a getter or walking the prototype chain.
|
|
12
|
+
*/
|
|
13
|
+
export declare function ownDataValue(value: unknown, key: string): unknown;
|
|
14
|
+
export declare function boundedString(value: unknown, pattern: RegExp): string | null;
|
|
15
|
+
/** An ISO-8601 UTC instant as produced by `Date.prototype.toISOString`. */
|
|
16
|
+
export declare function isoInstant(value: unknown): number | null;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** Strict structural readers shared by the bridge validators. No coercion, no extra keys. */
|
|
2
|
+
export function isPlainRecord(value) {
|
|
3
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
4
|
+
return false;
|
|
5
|
+
const prototype = Object.getPrototypeOf(value);
|
|
6
|
+
return (prototype === Object.prototype || prototype === null) && !hasSymbolKeys(value);
|
|
7
|
+
}
|
|
8
|
+
function hasSymbolKeys(value) {
|
|
9
|
+
return Object.getOwnPropertySymbols(value).length > 0;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The record when it is plain and carries exactly the allowed keys: every `required` key is
|
|
13
|
+
* present and no key outside `required` and `optional` exists. Accessor properties are refused so
|
|
14
|
+
* a getter can never run during validation.
|
|
15
|
+
*/
|
|
16
|
+
export function strictRecord(value, required, optional = []) {
|
|
17
|
+
if (!isPlainRecord(value))
|
|
18
|
+
return null;
|
|
19
|
+
// Refuse a wide object before allocating a descriptor for each of its keys.
|
|
20
|
+
if (Object.getOwnPropertyNames(value).length > required.length + optional.length)
|
|
21
|
+
return null;
|
|
22
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
23
|
+
const keys = Object.keys(descriptors);
|
|
24
|
+
for (const key of keys) {
|
|
25
|
+
const descriptor = descriptors[key];
|
|
26
|
+
if (!('value' in descriptor))
|
|
27
|
+
return null;
|
|
28
|
+
if (!required.includes(key) && !optional.includes(key))
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
for (const key of required)
|
|
32
|
+
if (!keys.includes(key))
|
|
33
|
+
return null;
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* An own data property of a plain record, or undefined. Used to branch on a discriminant before
|
|
38
|
+
* the strict reader runs, without ever invoking a getter or walking the prototype chain.
|
|
39
|
+
*/
|
|
40
|
+
export function ownDataValue(value, key) {
|
|
41
|
+
if (!isPlainRecord(value))
|
|
42
|
+
return undefined;
|
|
43
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
44
|
+
return descriptor && 'value' in descriptor ? descriptor.value : undefined;
|
|
45
|
+
}
|
|
46
|
+
export function boundedString(value, pattern) {
|
|
47
|
+
return typeof value === 'string' && pattern.test(value) ? value : null;
|
|
48
|
+
}
|
|
49
|
+
/** An ISO-8601 UTC instant as produced by `Date.prototype.toISOString`. */
|
|
50
|
+
export function isoInstant(value) {
|
|
51
|
+
if (typeof value !== 'string' ||
|
|
52
|
+
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/.test(value))
|
|
53
|
+
return null;
|
|
54
|
+
const time = Date.parse(value);
|
|
55
|
+
return Number.isFinite(time) ? time : null;
|
|
56
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { SimFrameRequestMessage, SimHostMessage } from './messages';
|
|
2
|
+
/**
|
|
3
|
+
* Frame side of the acknowledged character save.
|
|
4
|
+
*
|
|
5
|
+
* `save` resolves `true` only when the host answers the very request it sent with
|
|
6
|
+
* `saved: true`. A refusal, a failure, a timeout, a host dispose, `dispose()`, a second save while
|
|
7
|
+
* one is open, an invalid value, a post that throws, and a late or mismatched answer all resolve
|
|
8
|
+
* `false`, so the engine never shows a saved value the host did not commit.
|
|
9
|
+
*/
|
|
10
|
+
export type CharacterSaveClient = Readonly<{
|
|
11
|
+
save(character: string): Promise<boolean>;
|
|
12
|
+
/** Feed every parsed host message in; unrelated ones are ignored. */
|
|
13
|
+
receive(message: SimHostMessage): void;
|
|
14
|
+
/** Fails any open save. Call when the frame is torn down. */
|
|
15
|
+
dispose(): void;
|
|
16
|
+
}>;
|
|
17
|
+
export type CharacterSaveClientOptions = Readonly<{
|
|
18
|
+
post(message: SimFrameRequestMessage): void;
|
|
19
|
+
/** Longer than the host's own timeout so the host's answer normally arrives first. */
|
|
20
|
+
timeoutMs?: number;
|
|
21
|
+
mintRequestId?: () => string;
|
|
22
|
+
schedule?: (run: () => void, ms: number) => () => void;
|
|
23
|
+
}>;
|
|
24
|
+
export declare const DEFAULT_SAVE_CLIENT_TIMEOUT_MS = 8000;
|
|
25
|
+
export declare function createCharacterSaveClient(options: CharacterSaveClientOptions): CharacterSaveClient;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { parseSimCharacterValue } from './character';
|
|
2
|
+
export const DEFAULT_SAVE_CLIENT_TIMEOUT_MS = 8_000;
|
|
3
|
+
export function createCharacterSaveClient(options) {
|
|
4
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_SAVE_CLIENT_TIMEOUT_MS;
|
|
5
|
+
const schedule = options.schedule ??
|
|
6
|
+
((run, ms) => {
|
|
7
|
+
const id = setTimeout(run, ms);
|
|
8
|
+
return () => clearTimeout(id);
|
|
9
|
+
});
|
|
10
|
+
let counter = 0;
|
|
11
|
+
const mint = options.mintRequestId ?? (() => `save${++counter}`);
|
|
12
|
+
let open = null;
|
|
13
|
+
let disposed = false;
|
|
14
|
+
function failOpen() {
|
|
15
|
+
open?.settle(false);
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
save(character) {
|
|
19
|
+
if (disposed || open || parseSimCharacterValue(character) === null)
|
|
20
|
+
return Promise.resolve(false);
|
|
21
|
+
const requestId = mint();
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
const cancel = schedule(() => failOpen(), timeoutMs);
|
|
24
|
+
const entry = {
|
|
25
|
+
requestId,
|
|
26
|
+
settle(saved) {
|
|
27
|
+
if (open !== entry)
|
|
28
|
+
return;
|
|
29
|
+
open = null;
|
|
30
|
+
cancel();
|
|
31
|
+
resolve(saved);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
open = entry;
|
|
35
|
+
try {
|
|
36
|
+
options.post({ type: 'frame.character.save', payload: { requestId, character } });
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
entry.settle(false);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
},
|
|
43
|
+
receive(message) {
|
|
44
|
+
if (message.type === 'host.dispose')
|
|
45
|
+
failOpen();
|
|
46
|
+
else if (message.type === 'host.character.save.result' &&
|
|
47
|
+
open?.requestId === message.payload.requestId)
|
|
48
|
+
open.settle(message.payload.saved);
|
|
49
|
+
},
|
|
50
|
+
dispose() {
|
|
51
|
+
disposed = true;
|
|
52
|
+
failOpen();
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { type LaunchCodeBinding, type LaunchCodeRedemption, type SimLaunchGrant, type SimPlatform, type SimSessionExchangeResult, type SimSurface } from './launch';
|
|
2
|
+
/**
|
|
3
|
+
* Backend logic for the hosted Agent Sim launch, written against injectable ports.
|
|
4
|
+
*
|
|
5
|
+
* Nothing here picks an entitlement source, a sim origin or a storage engine: the caller supplies
|
|
6
|
+
* each as a port or a config value, and an absent value fails closed as `not_configured`. The
|
|
7
|
+
* module does no I/O, owns no endpoint and is not imported by the app. A real deployment adds the
|
|
8
|
+
* HTTP routes, the durable stores and the entitlement lookup around it.
|
|
9
|
+
*
|
|
10
|
+
* Two entry points mirror the two steps of the launch contract in `launch.ts`:
|
|
11
|
+
*
|
|
12
|
+
* - `createSimLaunchIssuer` (Adea backend): checks the signed-in account's entitlement and issues a
|
|
13
|
+
* single-use code bound to the host's `bridgeId`, the sim origin and the surface.
|
|
14
|
+
* - `createSimSessionExchange` (sim origin): redeems that code once and mints a sim-only session
|
|
15
|
+
* whose value travels only in the `Set-Cookie` header, never in a body.
|
|
16
|
+
*/
|
|
17
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
18
|
+
export type SimEntitlementQuery = Readonly<{
|
|
19
|
+
workspaceId: string;
|
|
20
|
+
surface: SimSurface;
|
|
21
|
+
/**
|
|
22
|
+
* The platform the client says it is. It is a hint for the entitlement source and never proof
|
|
23
|
+
* of anything: localhost, an embedded secret or a desktop shell's claim cannot entitle a launch.
|
|
24
|
+
*/
|
|
25
|
+
platform: SimPlatform;
|
|
26
|
+
}>;
|
|
27
|
+
export type SimEntitlementVerdict = 'entitled' | 'not_entitled' | 'unauthenticated' | 'unavailable';
|
|
28
|
+
/**
|
|
29
|
+
* Decides whether the account behind the current request may launch. The port is bound to that
|
|
30
|
+
* request's authenticated session by whoever builds it, so no account identity passes through the
|
|
31
|
+
* launch request body.
|
|
32
|
+
*/
|
|
33
|
+
export type SimEntitlementChecker = (query: SimEntitlementQuery) => MaybePromise<SimEntitlementVerdict>;
|
|
34
|
+
/** Durable single-use code storage. `createLaunchCodeLedger()` is the in-memory reference. */
|
|
35
|
+
export type SimLaunchCodeStore = Readonly<{
|
|
36
|
+
issue(code: string, binding: LaunchCodeBinding, expiresAt: number): MaybePromise<void>;
|
|
37
|
+
redeem(code: string, presented: Omit<LaunchCodeBinding, 'surface'>, now: number): MaybePromise<LaunchCodeRedemption>;
|
|
38
|
+
}>;
|
|
39
|
+
export type SimSessionRecord = Readonly<{
|
|
40
|
+
surface: SimSurface;
|
|
41
|
+
expiresAt: number;
|
|
42
|
+
}>;
|
|
43
|
+
/** Where the sim origin keeps the sessions it mints; looked up later by the asset gate. */
|
|
44
|
+
export type SimSessionStore = Readonly<{
|
|
45
|
+
save(value: string, record: SimSessionRecord): MaybePromise<void>;
|
|
46
|
+
}>;
|
|
47
|
+
type Clock = () => number;
|
|
48
|
+
type RandomBytes = (length: number) => Uint8Array;
|
|
49
|
+
export type SimLaunchIssuerConfig = Readonly<{
|
|
50
|
+
/** The sim origin to embed. Absent or invalid means "not configured", never a default. */
|
|
51
|
+
frameOrigin: string | null | undefined;
|
|
52
|
+
allowLoopbackHttp?: boolean;
|
|
53
|
+
entitlement: SimEntitlementChecker;
|
|
54
|
+
store: SimLaunchCodeStore;
|
|
55
|
+
/** Code lifetime in milliseconds, clamped to the contract maximum. */
|
|
56
|
+
codeTtlMs?: number;
|
|
57
|
+
now?: Clock;
|
|
58
|
+
randomBytes?: RandomBytes;
|
|
59
|
+
}>;
|
|
60
|
+
export type SimLaunchIssueResult = SimLaunchGrant | Readonly<{
|
|
61
|
+
status: 'invalid_request';
|
|
62
|
+
}>;
|
|
63
|
+
/** The backend half of the launch: a request body in, a grant (or a refusal) out. */
|
|
64
|
+
export declare function createSimLaunchIssuer(config: SimLaunchIssuerConfig): (body: unknown) => Promise<SimLaunchIssueResult>;
|
|
65
|
+
export type SimSessionExchangeConfig = Readonly<{
|
|
66
|
+
/** This sim origin, as the issuer bound it into the code. Absent or invalid is "not configured". */
|
|
67
|
+
origin: string | null | undefined;
|
|
68
|
+
allowLoopbackHttp?: boolean;
|
|
69
|
+
store: SimLaunchCodeStore;
|
|
70
|
+
sessions: SimSessionStore;
|
|
71
|
+
/** Session lifetime in seconds, clamped to the contract maximum. */
|
|
72
|
+
sessionSeconds?: number;
|
|
73
|
+
now?: Clock;
|
|
74
|
+
randomBytes?: RandomBytes;
|
|
75
|
+
}>;
|
|
76
|
+
/** What the sim origin answers: a JSON body, plus the cookie header only for a new session. */
|
|
77
|
+
export type SimSessionExchangeResponse = Readonly<{
|
|
78
|
+
body: SimSessionExchangeResult | Readonly<{
|
|
79
|
+
status: 'invalid_request';
|
|
80
|
+
}>;
|
|
81
|
+
setCookie?: string;
|
|
82
|
+
}>;
|
|
83
|
+
/** The sim origin's half of the launch: redeems a code once and sets the sim-only cookie. */
|
|
84
|
+
export declare function createSimSessionExchange(config: SimSessionExchangeConfig): (body: unknown) => Promise<SimSessionExchangeResponse>;
|
|
85
|
+
export {};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { createOriginAllowlist } from './origin';
|
|
2
|
+
import { MAX_SIM_LAUNCH_TTL_MS, parseSimLaunchRequest, parseSimSessionExchangeRequest, } from './launch';
|
|
3
|
+
import { MAX_SIM_SESSION_SECONDS, buildSimSessionSetCookie } from './cookie';
|
|
4
|
+
const systemClock = () => Date.now();
|
|
5
|
+
const systemRandom = (length) => crypto.getRandomValues(new Uint8Array(length));
|
|
6
|
+
const DEFAULT_LAUNCH_TTL_MS = 60_000;
|
|
7
|
+
const DEFAULT_SESSION_SECONDS = MAX_SIM_SESSION_SECONDS;
|
|
8
|
+
const CODE_BYTES = 32;
|
|
9
|
+
const SESSION_BYTES = 32;
|
|
10
|
+
/** The backend half of the launch: a request body in, a grant (or a refusal) out. */
|
|
11
|
+
export function createSimLaunchIssuer(config) {
|
|
12
|
+
const frameOrigin = configuredOrigin(config.frameOrigin, config.allowLoopbackHttp === true);
|
|
13
|
+
const now = config.now ?? systemClock;
|
|
14
|
+
const random = config.randomBytes ?? systemRandom;
|
|
15
|
+
const ttl = clampedLifetime(config.codeTtlMs, DEFAULT_LAUNCH_TTL_MS, MAX_SIM_LAUNCH_TTL_MS);
|
|
16
|
+
return async (body) => {
|
|
17
|
+
const request = parseSimLaunchRequest(body);
|
|
18
|
+
if (!request)
|
|
19
|
+
return { status: 'invalid_request' };
|
|
20
|
+
// A lifetime that is not a finite number is a misconfiguration, treated like a missing origin.
|
|
21
|
+
if (!frameOrigin || ttl === null)
|
|
22
|
+
return { status: 'unavailable', reason: 'not_configured' };
|
|
23
|
+
let verdict;
|
|
24
|
+
try {
|
|
25
|
+
verdict = await config.entitlement({
|
|
26
|
+
workspaceId: request.workspaceId,
|
|
27
|
+
surface: request.surface,
|
|
28
|
+
platform: request.platform,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return { status: 'unavailable', reason: 'service_unavailable' };
|
|
33
|
+
}
|
|
34
|
+
if (verdict === 'unauthenticated')
|
|
35
|
+
return { status: 'denied', reason: 'unauthenticated' };
|
|
36
|
+
if (verdict === 'not_entitled')
|
|
37
|
+
return { status: 'denied', reason: 'not_entitled' };
|
|
38
|
+
// Anything but an explicit "entitled" fails closed, including a verdict this code does not know.
|
|
39
|
+
if (verdict !== 'entitled')
|
|
40
|
+
return { status: 'unavailable', reason: 'service_unavailable' };
|
|
41
|
+
// Everything that can fail on bad injected input is computed before the store is touched, so a
|
|
42
|
+
// bad clock or random source leaves no stored code behind.
|
|
43
|
+
let code;
|
|
44
|
+
let expiresAt;
|
|
45
|
+
let expiresAtIso;
|
|
46
|
+
try {
|
|
47
|
+
code = base64url(random(CODE_BYTES));
|
|
48
|
+
expiresAt = readClock(now) + ttl;
|
|
49
|
+
expiresAtIso = isoInstant(expiresAt);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return { status: 'unavailable', reason: 'service_unavailable' };
|
|
53
|
+
}
|
|
54
|
+
if (!expiresAtIso)
|
|
55
|
+
return { status: 'unavailable', reason: 'service_unavailable' };
|
|
56
|
+
try {
|
|
57
|
+
await config.store.issue(code, { bridgeId: request.bridgeId, origin: frameOrigin, surface: request.surface }, expiresAt);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return { status: 'unavailable', reason: 'service_unavailable' };
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
status: 'granted',
|
|
64
|
+
code,
|
|
65
|
+
expiresAt: expiresAtIso,
|
|
66
|
+
surface: request.surface,
|
|
67
|
+
frameOrigin,
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** The sim origin's half of the launch: redeems a code once and sets the sim-only cookie. */
|
|
72
|
+
export function createSimSessionExchange(config) {
|
|
73
|
+
const origin = configuredOrigin(config.origin, config.allowLoopbackHttp === true);
|
|
74
|
+
const now = config.now ?? systemClock;
|
|
75
|
+
const random = config.randomBytes ?? systemRandom;
|
|
76
|
+
const seconds = clampedLifetime(config.sessionSeconds, DEFAULT_SESSION_SECONDS, MAX_SIM_SESSION_SECONDS);
|
|
77
|
+
return async (body) => {
|
|
78
|
+
const request = parseSimSessionExchangeRequest(body);
|
|
79
|
+
if (!request)
|
|
80
|
+
return { body: { status: 'invalid_request' } };
|
|
81
|
+
if (!origin || seconds === null)
|
|
82
|
+
return { body: { status: 'unavailable' } };
|
|
83
|
+
// The session is fully prepared before the code is redeemed. Redeeming spends the code, so a
|
|
84
|
+
// bad clock or random source must fail here, where nothing has been consumed or stored.
|
|
85
|
+
let at;
|
|
86
|
+
let value;
|
|
87
|
+
let expiresAt;
|
|
88
|
+
let expiresAtIso;
|
|
89
|
+
let setCookie;
|
|
90
|
+
try {
|
|
91
|
+
at = readClock(now);
|
|
92
|
+
value = base64url(random(SESSION_BYTES));
|
|
93
|
+
expiresAt = at + seconds * 1000;
|
|
94
|
+
expiresAtIso = isoInstant(expiresAt);
|
|
95
|
+
setCookie = buildSimSessionSetCookie(value, seconds);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return { body: { status: 'unavailable' } };
|
|
99
|
+
}
|
|
100
|
+
if (!expiresAtIso)
|
|
101
|
+
return { body: { status: 'unavailable' } };
|
|
102
|
+
let redemption;
|
|
103
|
+
try {
|
|
104
|
+
redemption = await config.store.redeem(request.code, { bridgeId: request.bridgeId, origin }, at);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return { body: { status: 'unavailable' } };
|
|
108
|
+
}
|
|
109
|
+
if (!redemption.ok) {
|
|
110
|
+
// An unknown code carries no more information than an expired one; the reply never says
|
|
111
|
+
// whether a code ever existed.
|
|
112
|
+
const reason = redemption.reason === 'unknown' ? 'code_expired' : redemption.reason;
|
|
113
|
+
return { body: { status: 'rejected', reason } };
|
|
114
|
+
}
|
|
115
|
+
// The code is spent from here on: if saving fails the same code is not retryable, and the client
|
|
116
|
+
// starts a new launch.
|
|
117
|
+
try {
|
|
118
|
+
await config.sessions.save(value, { surface: redemption.surface, expiresAt });
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return { body: { status: 'unavailable' } };
|
|
122
|
+
}
|
|
123
|
+
return { body: { status: 'session', expiresAt: expiresAtIso }, setCookie };
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* A configured lifetime in the unit the caller passes (milliseconds or seconds): the default when
|
|
128
|
+
* absent, clamped to [1, max] when a finite number, and null for anything else (NaN, Infinity, a
|
|
129
|
+
* non-number), which callers treat as a misconfiguration rather than guessing a value.
|
|
130
|
+
*/
|
|
131
|
+
function clampedLifetime(value, fallback, max) {
|
|
132
|
+
if (value === undefined)
|
|
133
|
+
return fallback;
|
|
134
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
135
|
+
return null;
|
|
136
|
+
return Math.min(Math.max(1, Math.floor(value)), max);
|
|
137
|
+
}
|
|
138
|
+
/** One clock reading, which must be a finite number of milliseconds; anything else throws. */
|
|
139
|
+
function readClock(now) {
|
|
140
|
+
const reading = now();
|
|
141
|
+
if (typeof reading !== 'number' || !Number.isFinite(reading)) {
|
|
142
|
+
throw new RangeError('clock returned a non-finite reading');
|
|
143
|
+
}
|
|
144
|
+
return reading;
|
|
145
|
+
}
|
|
146
|
+
/** The ISO string for an instant, or null when it is not a valid Date (NaN, beyond the Date range). */
|
|
147
|
+
function isoInstant(ms) {
|
|
148
|
+
const date = new Date(ms);
|
|
149
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
150
|
+
}
|
|
151
|
+
function configuredOrigin(origin, allowLoopbackHttp) {
|
|
152
|
+
if (origin == null)
|
|
153
|
+
return null;
|
|
154
|
+
try {
|
|
155
|
+
return createOriginAllowlist([origin], { allowLoopbackHttp }).origins[0] ?? null;
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function base64url(bytes) {
|
|
162
|
+
let binary = '';
|
|
163
|
+
for (const byte of bytes)
|
|
164
|
+
binary += String.fromCharCode(byte);
|
|
165
|
+
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
|
|
166
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adea-ai/spatial",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.96.0",
|
|
4
4
|
"description": "Scene and asset manifest contracts plus the shell-to-engine spatial protocol for Adea workspaces: scene mount manifests, spawn URL helpers, and scene telemetry schemas. Pure types — no rendering, no binaries.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"assets",
|
|
@@ -32,6 +32,11 @@
|
|
|
32
32
|
"types": "./dist/index.d.ts",
|
|
33
33
|
"development": "./src/index.ts",
|
|
34
34
|
"import": "./dist/index.js"
|
|
35
|
+
},
|
|
36
|
+
"./sim-bridge": {
|
|
37
|
+
"types": "./dist/sim-bridge/index.d.ts",
|
|
38
|
+
"development": "./src/sim-bridge/index.ts",
|
|
39
|
+
"import": "./dist/sim-bridge/index.js"
|
|
35
40
|
}
|
|
36
41
|
},
|
|
37
42
|
"scripts": {
|
|
@@ -39,6 +44,6 @@
|
|
|
39
44
|
"build": "tsc -p tsconfig.json",
|
|
40
45
|
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
|
|
41
46
|
"test": "bun test test/*.test.ts",
|
|
42
|
-
"typecheck": "
|
|
47
|
+
"typecheck": "bun check -p tsconfig.json"
|
|
43
48
|
}
|
|
44
49
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isSerializedSimCharacter } from './sim-bridge/character'
|
|
2
|
+
|
|
1
3
|
export * from './asset-manifests'
|
|
2
4
|
export {
|
|
3
5
|
agentSimEngineManifestUrl,
|
|
@@ -35,8 +37,11 @@ export const DEFAULT_CHARACTER_ID = configurableCharacterId
|
|
|
35
37
|
/**
|
|
36
38
|
* Shell-side character param check. The engine validates against its
|
|
37
39
|
* reference/preset catalogs on mount; the shell only needs a plausible
|
|
38
|
-
* non-empty value to thread through.
|
|
40
|
+
* non-empty value to thread through. A serialized customization
|
|
41
|
+
* (`character:v1:` and 13 slots, 157 characters by default) is checked by the
|
|
42
|
+
* bridge's structural parser instead of the short-id length rule.
|
|
39
43
|
*/
|
|
40
44
|
export function isPlausibleCharacterId(value: string | undefined): value is string {
|
|
41
|
-
|
|
45
|
+
if (typeof value !== 'string' || value.length === 0) return false
|
|
46
|
+
return value.length <= 128 || isSerializedSimCharacter(value)
|
|
42
47
|
}
|