@adea-ai/spatial 0.97.0 → 0.99.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.
@@ -35,6 +35,15 @@ export type SimEntitlementChecker = (query: SimEntitlementQuery) => MaybePromise
35
35
  export type SimLaunchCodeStore = Readonly<{
36
36
  issue(code: string, binding: LaunchCodeBinding, expiresAt: number): MaybePromise<void>;
37
37
  redeem(code: string, presented: Omit<LaunchCodeBinding, 'surface'>, now: number): MaybePromise<LaunchCodeRedemption>;
38
+ /**
39
+ * Optional atomic form of redeem-then-save. A store that provides it spends the code and saves the
40
+ * session in one step: when saving fails, the spend is rolled back and the code stays redeemable. The
41
+ * session surface is the code's bound surface. A store without it uses redeem, then sessions.save.
42
+ */
43
+ redeemAndSave?(code: string, presented: Omit<LaunchCodeBinding, 'surface'>, now: number, session: Readonly<{
44
+ value: string;
45
+ expiresAt: number;
46
+ }>): MaybePromise<LaunchCodeRedemption>;
38
47
  }>;
39
48
  export type SimSessionRecord = Readonly<{
40
49
  surface: SimSurface;
@@ -99,19 +99,31 @@ export function createSimSessionExchange(config) {
99
99
  }
100
100
  if (!expiresAtIso)
101
101
  return { body: { status: 'unavailable' } };
102
+ const presented = { bridgeId: request.bridgeId, origin };
102
103
  let redemption;
104
+ if (config.store.redeemAndSave) {
105
+ // One atomic step: a failed session save rolls the spend back, so the code stays redeemable.
106
+ try {
107
+ redemption = await config.store.redeemAndSave(request.code, presented, at, {
108
+ value,
109
+ expiresAt,
110
+ });
111
+ }
112
+ catch {
113
+ return { body: { status: 'unavailable' } };
114
+ }
115
+ if (!redemption.ok)
116
+ return { body: rejectedBody(redemption.reason) };
117
+ return { body: { status: 'session', expiresAt: expiresAtIso }, setCookie };
118
+ }
103
119
  try {
104
- redemption = await config.store.redeem(request.code, { bridgeId: request.bridgeId, origin }, at);
120
+ redemption = await config.store.redeem(request.code, presented, at);
105
121
  }
106
122
  catch {
107
123
  return { body: { status: 'unavailable' } };
108
124
  }
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
- }
125
+ if (!redemption.ok)
126
+ return { body: rejectedBody(redemption.reason) };
115
127
  // The code is spent from here on: if saving fails the same code is not retryable, and the client
116
128
  // starts a new launch.
117
129
  try {
@@ -123,6 +135,12 @@ export function createSimSessionExchange(config) {
123
135
  return { body: { status: 'session', expiresAt: expiresAtIso }, setCookie };
124
136
  };
125
137
  }
138
+ /** A refused redemption, with the reason the reply may carry. */
139
+ function rejectedBody(reason) {
140
+ // An unknown code carries no more information than an expired one; the reply never says
141
+ // whether a code ever existed.
142
+ return { status: 'rejected', reason: reason === 'unknown' ? 'code_expired' : reason };
143
+ }
126
144
  /**
127
145
  * A configured lifetime in the unit the caller passes (milliseconds or seconds): the default when
128
146
  * absent, clamped to [1, max] when a finite number, and null for anything else (NaN, Infinity, a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adea-ai/spatial",
3
- "version": "0.97.0",
3
+ "version": "0.99.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",
@@ -59,6 +59,17 @@ export type SimLaunchCodeStore = Readonly<{
59
59
  presented: Omit<LaunchCodeBinding, 'surface'>,
60
60
  now: number
61
61
  ): MaybePromise<LaunchCodeRedemption>
62
+ /**
63
+ * Optional atomic form of redeem-then-save. A store that provides it spends the code and saves the
64
+ * session in one step: when saving fails, the spend is rolled back and the code stays redeemable. The
65
+ * session surface is the code's bound surface. A store without it uses redeem, then sessions.save.
66
+ */
67
+ redeemAndSave?(
68
+ code: string,
69
+ presented: Omit<LaunchCodeBinding, 'surface'>,
70
+ now: number,
71
+ session: Readonly<{ value: string; expiresAt: number }>
72
+ ): MaybePromise<LaunchCodeRedemption>
62
73
  }>
63
74
 
64
75
  export type SimSessionRecord = Readonly<{ surface: SimSurface; expiresAt: number }>
@@ -209,22 +220,28 @@ export function createSimSessionExchange(
209
220
  }
210
221
  if (!expiresAtIso) return { body: { status: 'unavailable' } }
211
222
 
223
+ const presented = { bridgeId: request.bridgeId, origin }
212
224
  let redemption: LaunchCodeRedemption
225
+ if (config.store.redeemAndSave) {
226
+ // One atomic step: a failed session save rolls the spend back, so the code stays redeemable.
227
+ try {
228
+ redemption = await config.store.redeemAndSave(request.code, presented, at, {
229
+ value,
230
+ expiresAt,
231
+ })
232
+ } catch {
233
+ return { body: { status: 'unavailable' } }
234
+ }
235
+ if (!redemption.ok) return { body: rejectedBody(redemption.reason) }
236
+ return { body: { status: 'session', expiresAt: expiresAtIso }, setCookie }
237
+ }
238
+
213
239
  try {
214
- redemption = await config.store.redeem(
215
- request.code,
216
- { bridgeId: request.bridgeId, origin },
217
- at
218
- )
240
+ redemption = await config.store.redeem(request.code, presented, at)
219
241
  } catch {
220
242
  return { body: { status: 'unavailable' } }
221
243
  }
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
- }
244
+ if (!redemption.ok) return { body: rejectedBody(redemption.reason) }
228
245
 
229
246
  // The code is spent from here on: if saving fails the same code is not retryable, and the client
230
247
  // starts a new launch.
@@ -237,6 +254,13 @@ export function createSimSessionExchange(
237
254
  }
238
255
  }
239
256
 
257
+ /** A refused redemption, with the reason the reply may carry. */
258
+ function rejectedBody(reason: 'unknown' | 'code_expired' | 'code_used' | 'origin_refused') {
259
+ // An unknown code carries no more information than an expired one; the reply never says
260
+ // whether a code ever existed.
261
+ return { status: 'rejected' as const, reason: reason === 'unknown' ? 'code_expired' : reason }
262
+ }
263
+
240
264
  /**
241
265
  * A configured lifetime in the unit the caller passes (milliseconds or seconds): the default when
242
266
  * absent, clamped to [1, max] when a finite number, and null for anything else (NaN, Infinity, a