@sanity/sdk 3.4.0-rc.0 → 3.4.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.
Files changed (48) hide show
  1. package/dist/_exports/_internal.d.ts +3 -3
  2. package/dist/_exports/_internal.js +5 -5
  3. package/dist/_exports/agent.d.ts +1 -1
  4. package/dist/_exports/agent.js +1 -1
  5. package/dist/_exports/comlink.d.ts +1 -1
  6. package/dist/_exports/dashboard.d.ts +2 -2
  7. package/dist/_exports/dashboard.js +2 -2
  8. package/dist/{applications-903KQTrD.d.ts → applications-Bb-0Kgkc.d.ts} +6 -20
  9. package/dist/applications-Bb-0Kgkc.d.ts.map +1 -0
  10. package/dist/{clientStore-QDrJNTM4.js → clientStore-CD-USpIU.js} +11 -15
  11. package/dist/clientStore-CD-USpIU.js.map +1 -0
  12. package/dist/{createSanityInstance--Lrldto8.d.ts → createSanityInstance-BQIqyGLX.d.ts} +3 -6
  13. package/dist/createSanityInstance-BQIqyGLX.d.ts.map +1 -0
  14. package/dist/index.d.ts +2 -2
  15. package/dist/index.js +204 -211
  16. package/dist/index.js.map +1 -1
  17. package/dist/{topicStore-BJ54_X6H.js → topicStore-CPhGph6z.js} +53 -13
  18. package/dist/topicStore-CPhGph6z.js.map +1 -0
  19. package/dist/{topicStore-VQILEI1R.d.ts → topicStore-fPK2CfqH.d.ts} +91 -4
  20. package/dist/topicStore-fPK2CfqH.d.ts.map +1 -0
  21. package/dist/{version-CYqZgJX1.js → version-C7m7OAy9.js} +3 -3
  22. package/dist/{version-CYqZgJX1.js.map → version-C7m7OAy9.js.map} +1 -1
  23. package/package.json +6 -6
  24. package/src/_exports/dashboard.ts +6 -0
  25. package/src/auth/authStore.test.ts +0 -18
  26. package/src/auth/authStore.ts +1 -4
  27. package/src/auth/handleAuthCallback.test.ts +0 -33
  28. package/src/auth/handleAuthCallback.ts +1 -11
  29. package/src/auth/logout.test.ts +1 -9
  30. package/src/auth/logout.ts +1 -18
  31. package/src/auth/oauth/oauthActions.test.ts +1 -178
  32. package/src/auth/oauth/oauthActions.ts +46 -111
  33. package/src/auth/oauth/oauthAuth.test.ts +0 -23
  34. package/src/auth/oauth/oauthAuth.ts +8 -18
  35. package/src/config/authConfig.ts +2 -5
  36. package/src/dashboard/messageBus/bus.test.ts +118 -0
  37. package/src/dashboard/messageBus/bus.ts +81 -7
  38. package/src/dashboard/messageBus/topics.test.ts +20 -0
  39. package/src/dashboard/messageBus/topics.ts +74 -0
  40. package/src/document/documentConstants.ts +5 -2
  41. package/src/document/documentStore.test.ts +194 -0
  42. package/src/document/documentStore.ts +79 -39
  43. package/src/document/reducers.ts +2 -2
  44. package/dist/applications-903KQTrD.d.ts.map +0 -1
  45. package/dist/clientStore-QDrJNTM4.js.map +0 -1
  46. package/dist/createSanityInstance--Lrldto8.d.ts.map +0 -1
  47. package/dist/topicStore-BJ54_X6H.js.map +0 -1
  48. package/dist/topicStore-VQILEI1R.d.ts.map +0 -1
@@ -17,25 +17,6 @@ export const OAUTH_VERIFIER_KEY = '__sanity_oauth_verifier'
17
17
  /** sessionStorage key for the CSRF `state` value. */
18
18
  export const OAUTH_STATE_KEY = '__sanity_oauth_state'
19
19
 
20
- /** sessionStorage key for the location to return to after the callback. */
21
- export const OAUTH_RETURN_TO_KEY = '__sanity_oauth_return_to'
22
-
23
- const OAUTH_CALLBACK_PARAMS = ['code', 'state', 'error', 'error_description']
24
-
25
- /** Strips the OAuth callback params from a URL. */
26
- function stripOAuthParams(href: string): string {
27
- const url = new URL(href, DEFAULT_BASE)
28
- for (const param of OAUTH_CALLBACK_PARAMS) url.searchParams.delete(param)
29
- return url.toString()
30
- }
31
-
32
- /** Whether `href` is the registered redirect URI (origin + pathname; query and hash ignored). */
33
- function isCallbackRoute(href: string, redirectUri: string): boolean {
34
- const current = new URL(href, DEFAULT_BASE)
35
- const redirect = new URL(redirectUri, DEFAULT_BASE)
36
- return current.origin === redirect.origin && current.pathname === redirect.pathname
37
- }
38
-
39
20
  interface TokenEndpointResponse {
40
21
  access_token: string
41
22
  token_type: string
@@ -48,19 +29,6 @@ function getResourceIndicator(organizationId: string): string {
48
29
  return `urn:io.sanity:organization:${organizationId}`
49
30
  }
50
31
 
51
- /**
52
- * Appends the RFC 8707 `resource` indicator to `params` when an organisation
53
- * id is configured. Without one, the request is left unscoped.
54
- */
55
- function appendResourceIndicator(
56
- params: URLSearchParams,
57
- oauth: NonNullable<AuthOptions['oauth']>,
58
- ): void {
59
- if (oauth.organizationId) {
60
- params.append('resource', getResourceIndicator(oauth.organizationId))
61
- }
62
- }
63
-
64
32
  /**
65
33
  * Serialises tokens for storage, converting `expiresAt` to an ISO string.
66
34
  *
@@ -102,21 +70,6 @@ function createOAuthClient(options: AuthOptions): SanityClient {
102
70
  })
103
71
  }
104
72
 
105
- /** POSTs form-encoded params to the OAuth token endpoint. */
106
- function postTokenRequest(
107
- client: SanityClient,
108
- params: URLSearchParams,
109
- tag: string,
110
- ): Promise<TokenEndpointResponse> {
111
- return client.request<TokenEndpointResponse>({
112
- method: 'POST',
113
- url: '/auth/oauth/token',
114
- headers: {'content-type': 'application/x-www-form-urlencoded'},
115
- body: params.toString(),
116
- tag,
117
- })
118
- }
119
-
120
73
  /** Converts a token endpoint response to the {@link OAuthTokens} shape. */
121
74
  function toOAuthTokens(response: TokenEndpointResponse): OAuthTokens {
122
75
  return {
@@ -146,17 +99,6 @@ export const startOAuthAuthorization = bindActionGlobally(authStore, async ({sta
146
99
  const session = typeof sessionStorage !== 'undefined' ? sessionStorage : undefined
147
100
  session?.setItem(OAUTH_VERIFIER_KEY, codeVerifier)
148
101
  session?.setItem(OAUTH_STATE_KEY, oauthState)
149
- // The redirect URI must match the registered one exactly, so the current
150
- // location (deep link) is stashed here and restored by the callback. OAuth
151
- // params are only stripped when we are already on the callback route, so
152
- // legitimate app params like `?state=draft` survive the round trip.
153
- const currentHref = getDefaultLocation()
154
- if (currentHref !== DEFAULT_BASE) {
155
- const returnTo = isCallbackRoute(currentHref, options.oauth.redirectUri)
156
- ? stripOAuthParams(currentHref)
157
- : currentHref
158
- session?.setItem(OAUTH_RETURN_TO_KEY, returnTo)
159
- }
160
102
 
161
103
  const authorizeUrl = new URL(
162
104
  '/v1/auth/oauth/authorize',
@@ -168,7 +110,7 @@ export const startOAuthAuthorization = bindActionGlobally(authStore, async ({sta
168
110
  authorizeUrl.searchParams.set('state', oauthState)
169
111
  authorizeUrl.searchParams.set('code_challenge', codeChallenge)
170
112
  authorizeUrl.searchParams.set('code_challenge_method', 'S256')
171
- appendResourceIndicator(authorizeUrl.searchParams, options.oauth)
113
+ authorizeUrl.searchParams.append('resource', getResourceIndicator(options.oauth.organizationId))
172
114
 
173
115
  logger.info('Starting OAuth authorization')
174
116
  if (typeof window !== 'undefined' && typeof window.location?.assign === 'function') {
@@ -181,14 +123,9 @@ export const startOAuthAuthorization = bindActionGlobally(authStore, async ({sta
181
123
  * responses, exchanges the authorization `code` for tokens, persists them, and
182
124
  * transitions to `LOGGED_IN`.
183
125
  *
184
- * Returns the URL for the caller to `history.replaceState` to when a callback
185
- * was processed: on success, the same-origin location the user was on when
186
- * the flow started (if any), otherwise the callback URL cleaned of OAuth
187
- * params. Returns `false` when there was nothing to handle (no code, or an
188
- * exchange already in progress). A callback that fails validation while a
189
- * session is already `LOGGED_IN` (e.g. a remount before the params were
190
- * stripped) is ignored with a warning and still returns the cleaned URL, so a
191
- * string result does not imply an exchange ran.
126
+ * Returns the callback URL cleaned of OAuth params (for the caller to
127
+ * `history.replaceState`) when a callback was processed, or `false` when there
128
+ * was nothing to handle (no code, or an exchange already in progress).
192
129
  *
193
130
  * @public
194
131
  */
@@ -215,29 +152,22 @@ export const handleOAuthCallback = bindActionGlobally(
215
152
 
216
153
  const session = typeof sessionStorage !== 'undefined' ? sessionStorage : undefined
217
154
 
218
- const cleanedUrl = stripOAuthParams(locationHref)
219
-
220
- // Callback failures move to ERROR, unless a session is already
221
- // established: a stale callback URL (e.g. remount before the params were
222
- // stripped, after the artifacts were cleared) or a denied re-authorization
223
- // must not clobber LOGGED_IN.
224
- const rejectCallback = (name: string, message: string): string => {
225
- if (authState.type === AuthStateType.LOGGED_IN) {
226
- // Leave PKCE artifacts alone: a re-authorization may be in flight.
227
- logger.warn(`${message} — ignoring callback, session already established`)
228
- return cleanedUrl
229
- }
230
- logger.error(`${message} — rejecting callback`)
231
- clearOAuthArtifacts(session)
232
- state.set(name, {authState: {type: AuthStateType.ERROR, error: new Error(message)}})
233
- return cleanedUrl
155
+ const cleanedUrlObj = new URL(locationHref, DEFAULT_BASE)
156
+ for (const param of ['code', 'state', 'error', 'error_description']) {
157
+ cleanedUrlObj.searchParams.delete(param)
234
158
  }
159
+ const cleanedUrl = cleanedUrlObj.toString()
235
160
 
236
161
  if (error) {
237
- return rejectCallback(
238
- 'oauthCallbackError',
239
- errorDescription ? `${error}: ${errorDescription}` : error,
240
- )
162
+ logger.warn('OAuth callback returned an error', {error, errorDescription})
163
+ clearOAuthArtifacts(session)
164
+ state.set('oauthCallbackError', {
165
+ authState: {
166
+ type: AuthStateType.ERROR,
167
+ error: new Error(errorDescription ? `${error}: ${errorDescription}` : error),
168
+ },
169
+ })
170
+ return cleanedUrl
241
171
  }
242
172
 
243
173
  if (!code) {
@@ -247,12 +177,22 @@ export const handleOAuthCallback = bindActionGlobally(
247
177
 
248
178
  const storedState = session?.getItem(OAUTH_STATE_KEY) ?? null
249
179
  if (!returnedState || !storedState || returnedState !== storedState) {
250
- return rejectCallback('oauthStateMismatch', 'OAuth state mismatch')
180
+ logger.error('OAuth state mismatch — rejecting callback')
181
+ clearOAuthArtifacts(session)
182
+ state.set('oauthStateMismatch', {
183
+ authState: {type: AuthStateType.ERROR, error: new Error('OAuth state mismatch')},
184
+ })
185
+ return cleanedUrl
251
186
  }
252
187
 
253
188
  const codeVerifier = session?.getItem(OAUTH_VERIFIER_KEY) ?? null
254
189
  if (!codeVerifier) {
255
- return rejectCallback('oauthVerifierMissing', 'OAuth code verifier missing')
190
+ logger.error('OAuth code verifier missing — cannot exchange code')
191
+ clearOAuthArtifacts(session)
192
+ state.set('oauthVerifierMissing', {
193
+ authState: {type: AuthStateType.ERROR, error: new Error('OAuth code verifier missing')},
194
+ })
195
+ return cleanedUrl
256
196
  }
257
197
 
258
198
  logger.info('Exchanging OAuth code for tokens')
@@ -268,13 +208,18 @@ export const handleOAuthCallback = bindActionGlobally(
268
208
  code_verifier: codeVerifier,
269
209
  redirect_uri: options.oauth.redirectUri,
270
210
  client_id: options.oauth.clientId,
211
+ resource: getResourceIndicator(options.oauth.organizationId),
212
+ })
213
+ const response = await client.request<TokenEndpointResponse>({
214
+ method: 'POST',
215
+ url: '/auth/oauth/token',
216
+ headers: {'content-type': 'application/x-www-form-urlencoded'},
217
+ body: params.toString(),
218
+ tag: 'oauth.token',
271
219
  })
272
- appendResourceIndicator(params, options.oauth)
273
- const response = await postTokenRequest(client, params, 'oauth.token')
274
220
 
275
221
  const tokens = toOAuthTokens(response)
276
222
  options.storageArea?.setItem(options.storageKey, serializeTokens(tokens))
277
- const returnTo = getSameOriginReturnTo(session, options.oauth.redirectUri)
278
223
  clearOAuthArtifacts(session)
279
224
 
280
225
  logger.info('OAuth tokens obtained, user logged in')
@@ -282,7 +227,7 @@ export const handleOAuthCallback = bindActionGlobally(
282
227
  authState: createLoggedInAuthState(tokens.accessToken, null),
283
228
  oauthTokens: tokens,
284
229
  })
285
- return returnTo ?? cleanedUrl
230
+ return cleanedUrl
286
231
  } catch (exchangeError) {
287
232
  logger.error('Failed to exchange OAuth code for tokens', {error: exchangeError})
288
233
  clearOAuthArtifacts(session)
@@ -351,9 +296,15 @@ async function doRefreshOAuthTokens({
351
296
  grant_type: 'refresh_token',
352
297
  refresh_token: current.refreshToken,
353
298
  client_id: options.oauth.clientId,
299
+ resource: getResourceIndicator(options.oauth.organizationId),
300
+ })
301
+ const response = await client.request<TokenEndpointResponse>({
302
+ method: 'POST',
303
+ url: '/auth/oauth/token',
304
+ headers: {'content-type': 'application/x-www-form-urlencoded'},
305
+ body: params.toString(),
306
+ tag: 'oauth.refresh',
354
307
  })
355
- appendResourceIndicator(params, options.oauth)
356
- const response = await postTokenRequest(client, params, 'oauth.refresh')
357
308
 
358
309
  const tokens = toOAuthTokens(response)
359
310
  if (!tokens.refreshToken) tokens.refreshToken = current.refreshToken
@@ -435,20 +386,4 @@ export const getOAuthTokensState = bindActionGlobally(
435
386
  function clearOAuthArtifacts(session: Storage | undefined): void {
436
387
  session?.removeItem(OAUTH_VERIFIER_KEY)
437
388
  session?.removeItem(OAUTH_STATE_KEY)
438
- session?.removeItem(OAUTH_RETURN_TO_KEY)
439
- }
440
-
441
- /**
442
- * Reads the stashed pre-authorize location, only honouring it when it shares
443
- * the registered redirect URI's origin so a tampered value cannot redirect
444
- * off-site. (`origin` includes the scheme, so non-http(s) URLs are rejected.)
445
- */
446
- function getSameOriginReturnTo(session: Storage | undefined, redirectUri: string): string | null {
447
- const returnTo = session?.getItem(OAUTH_RETURN_TO_KEY) ?? null
448
- if (!returnTo) return null
449
- try {
450
- return new URL(returnTo).origin === new URL(redirectUri).origin ? returnTo : null
451
- } catch {
452
- return null
453
- }
454
389
  }
@@ -83,11 +83,6 @@ describe('serialize/deserialize tokens', () => {
83
83
  expect(deserializeTokens('{"foo":"bar"}')).toBeNull()
84
84
  expect(deserializeTokens('123')).toBeNull()
85
85
  })
86
-
87
- it('returns null when expiresAt does not parse to a valid date', () => {
88
- const raw = JSON.stringify({...JSON.parse(serializeTokens(tokens)), expiresAt: 'garbage'})
89
- expect(deserializeTokens(raw)).toBeNull()
90
- })
91
86
  })
92
87
 
93
88
  describe('getOauthInitialState', () => {
@@ -99,16 +94,6 @@ describe('getOauthInitialState', () => {
99
94
  expect(result.authMethod).toBe('localstorage')
100
95
  })
101
96
 
102
- it('returns LOGGED_OUT and clears storage when persisted tokens are corrupt', () => {
103
- const storageArea = createMemoryStorage({
104
- [OAUTH_TOKENS_KEY]: '{"accessToken":"a","expiresAt":"garbage"}',
105
- })
106
- const result = getOauthInitialState(baseOptions({storageArea}))
107
- expect(result.authState).toEqual({type: AuthStateType.LOGGED_OUT, isDestroyingSession: false})
108
- expect(result.oauthTokens).toBeUndefined()
109
- expect(storageArea.getItem(OAUTH_TOKENS_KEY)).toBeNull()
110
- })
111
-
112
97
  it('returns LOGGING_IN when the callback URL matches the redirect URI', () => {
113
98
  const storageArea = createMemoryStorage()
114
99
  const result = getOauthInitialState(
@@ -125,14 +110,6 @@ describe('getOauthInitialState', () => {
125
110
  expect(result.authState).toEqual({type: AuthStateType.LOGGED_OUT, isDestroyingSession: false})
126
111
  })
127
112
 
128
- it('returns LOGGED_OUT when the redirect URI only carries a stray state param', () => {
129
- const storageArea = createMemoryStorage()
130
- const result = getOauthInitialState(
131
- baseOptions({storageArea, initialLocationHref: 'https://app/callback?state=s'}),
132
- )
133
- expect(result.authState).toEqual({type: AuthStateType.LOGGED_OUT, isDestroyingSession: false})
134
- })
135
-
136
113
  it('returns LOGGED_OUT when there are no tokens and no callback', () => {
137
114
  const storageArea = createMemoryStorage()
138
115
  const result = getOauthInitialState(baseOptions({storageArea}))
@@ -19,9 +19,7 @@ interface SerializedOAuthTokens extends Omit<OAuthTokens, 'expiresAt'> {
19
19
 
20
20
  /**
21
21
  * Parses persisted token JSON back into {@link OAuthTokens}. Returns `null`
22
- * when the value is missing or malformed, including an `expiresAt` that does
23
- * not parse to a valid date (an `Invalid Date` would otherwise read as never
24
- * expiring, since `NaN <= now` is always `false`).
22
+ * when the value is missing or malformed.
25
23
  *
26
24
  * @internal
27
25
  */
@@ -40,13 +38,11 @@ export function deserializeTokens(raw: string | null): OAuthTokens | null {
40
38
  return null
41
39
  }
42
40
  const value = parsed as SerializedOAuthTokens
43
- const expiresAt = new Date(value.expiresAt)
44
- if (Number.isNaN(expiresAt.getTime())) return null
45
41
  return {
46
42
  accessToken: value.accessToken,
47
43
  tokenType: 'bearer',
48
44
  expiresIn: value.expiresIn,
49
- expiresAt,
45
+ expiresAt: new Date(value.expiresAt),
50
46
  ...(value.refreshToken !== undefined && {refreshToken: value.refreshToken}),
51
47
  }
52
48
  } catch {
@@ -59,7 +55,7 @@ export function deserializeTokens(raw: string | null): OAuthTokens | null {
59
55
  *
60
56
  * State discovery order:
61
57
  * 1. Persisted tokens in `__sanity_oauth_tokens` → `LOGGED_IN`
62
- * 2. Callback URL (contains `code` or `error` and matches `redirectUri`)
58
+ * 2. Callback URL (contains `code`/`state`/`error` and matches `redirectUri`)
63
59
  * → `LOGGING_IN`
64
60
  * 3. Otherwise → `LOGGED_OUT`
65
61
  *
@@ -72,12 +68,7 @@ export function getOauthInitialState(options: AuthStrategyOptions): AuthStrategy
72
68
  const redirectUri = authConfig.oauth?.redirectUri
73
69
 
74
70
  // Persisted tokens win
75
- const rawTokens = storageArea?.getItem(OAUTH_TOKENS_KEY) ?? null
76
- const tokens = deserializeTokens(rawTokens)
77
- if (rawTokens && !tokens) {
78
- // Corrupt entry: drop it so it cannot keep shadowing a fresh login
79
- storageArea?.removeItem(OAUTH_TOKENS_KEY)
80
- }
71
+ const tokens = deserializeTokens(storageArea?.getItem(OAUTH_TOKENS_KEY) ?? null)
81
72
  if (tokens) {
82
73
  return {
83
74
  authState: createLoggedInAuthState(tokens.accessToken, null),
@@ -89,12 +80,11 @@ export function getOauthInitialState(options: AuthStrategyOptions): AuthStrategy
89
80
  }
90
81
  }
91
82
 
92
- // Callback URL with code/error whose origin + pathname match our redirect
93
- // URI (query and hash are ignored). `state` alone is not a callback: the
94
- // server always sends `code` or `error`, and handleOAuthCallback would
95
- // otherwise leave the app stuck in LOGGING_IN.
83
+ // Callback URL with code/state/error whose origin + pathname match our
84
+ // redirect URI (query and hash are ignored).
96
85
  const {searchParams} = new URL(initialLocationHref, DEFAULT_BASE)
97
- const isCallback = searchParams.has('code') || searchParams.has('error')
86
+ const isCallback =
87
+ searchParams.has('code') || searchParams.has('state') || searchParams.has('error')
98
88
  if (redirectUri && isCallback) {
99
89
  const loc = new URL(initialLocationHref, DEFAULT_BASE)
100
90
  const redirect = new URL(redirectUri, DEFAULT_BASE)
@@ -90,11 +90,8 @@ export interface AuthConfig {
90
90
  redirectUri: string
91
91
 
92
92
  /**
93
- * Organisation id used for RFC 8707 resource scoping. Optional: when
94
- * omitted, the `resource` parameter is left off the authorize and token
95
- * requests, so the issued tokens are not restricted to a single
96
- * organisation.
93
+ * Organisation id used for RFC 8707 resource scoping.
97
94
  */
98
- organizationId?: string
95
+ organizationId: string
99
96
  }
100
97
  }
@@ -170,6 +170,14 @@ describe('dashboard connection', () => {
170
170
  expect(callerId).toBe('favorites')
171
171
  })
172
172
 
173
+ it('exposes the id a connection connected with', () => {
174
+ installMessageBus({appId: 'dashboard'})
175
+ const application = connectMessageBus({appId: 'favorites'})
176
+ if (!application) throw new Error('Expected a dashboard message bus')
177
+
178
+ expect(application.appId).toBe('favorites')
179
+ })
180
+
173
181
  it('writes state to a connected application through its client', () => {
174
182
  const dashboard = installMessageBus({appId: 'dashboard'})
175
183
  const application = connectMessageBus({appId: 'favorites'})
@@ -469,6 +477,102 @@ describe('event topics', () => {
469
477
  expect(code).toBe('NO_RESPONDER')
470
478
  expect(finalized).toBe(true)
471
479
  })
480
+
481
+ it('rejects a sender with REFUSED and the given message', async () => {
482
+ const messageBus = createMessageBus()
483
+ messageBus.subscribe('test.echo', (message) => message.reject('not on screen'))
484
+
485
+ const error = await messageBus.emit('test.echo', {n: 1}).catch((caught) => caught)
486
+ expect(error).toBeInstanceOf(MessageBusError)
487
+ expect(error).toMatchObject({code: 'REFUSED', message: 'not on screen'})
488
+ })
489
+
490
+ it('warns and ignores a reply or reject after a reject', async () => {
491
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
492
+ const messageBus = createMessageBus()
493
+ messageBus.subscribe('test.echo', (message) => {
494
+ message.reject('first')
495
+ message.reject('second')
496
+ message.reply({n: 100})
497
+ })
498
+
499
+ await expect(messageBus.emit('test.echo', {n: 1})).rejects.toMatchObject({
500
+ code: 'REFUSED',
501
+ message: 'first',
502
+ })
503
+ expect(warn).toHaveBeenCalledWith(
504
+ '[sanity-sdk:message-bus] reject ignored for "test.echo": no waiting caller or already replied',
505
+ )
506
+ expect(warn).toHaveBeenCalledWith(
507
+ '[sanity-sdk:message-bus] reply ignored for "test.echo": no waiting caller or already replied',
508
+ )
509
+ })
510
+ })
511
+
512
+ describe('state topic rejections', () => {
513
+ it('rejects a pending query with REFUSED when the host rejects', async () => {
514
+ const host = createMessageBus()
515
+ registerStateTopics(host, {'test.suspending': undefined})
516
+ const {app, client} = connect(host, {appId: 'favorites'})
517
+
518
+ const pending = app.query('test.suspending', {timeout: null})
519
+ client.reject('test.suspending', 'hidden')
520
+
521
+ await expect(pending).rejects.toMatchObject({code: 'REFUSED', message: 'hidden'})
522
+ })
523
+
524
+ it('rejects a later query with REFUSED until a value is written', async () => {
525
+ const host = createMessageBus()
526
+ registerStateTopics(host, {'test.suspending': undefined})
527
+ const {app, client} = connect(host, {appId: 'favorites'})
528
+ client.reject('test.suspending', 'hidden')
529
+
530
+ await expect(app.query('test.suspending', {timeout: null})).rejects.toMatchObject({
531
+ code: 'REFUSED',
532
+ message: 'hidden',
533
+ })
534
+
535
+ client.emit('test.suspending', 'ready')
536
+ await expect(app.query('test.suspending')).resolves.toBe('ready')
537
+ })
538
+
539
+ it('rejects a query for a topic the connection already holds once rejected', async () => {
540
+ const host = createMessageBus()
541
+ registerStateTopics(host, {'test.count': 0})
542
+ const {app, client} = connect(host, {appId: 'favorites'})
543
+ client.emit('test.count', 1)
544
+ await expect(app.query('test.count')).resolves.toBe(1)
545
+
546
+ client.reject('test.count', 'hidden')
547
+
548
+ await expect(app.query('test.count')).rejects.toMatchObject({code: 'REFUSED'})
549
+ expect(app.subscribe('test.count').getCurrent()).toBe(1)
550
+ })
551
+
552
+ it('leaves subscribers silent through a rejection and delivers the later value', () => {
553
+ const host = createMessageBus()
554
+ registerStateTopics(host, {'test.suspending': undefined})
555
+ const {app, client} = connect(host, {appId: 'favorites'})
556
+ const seen: string[] = []
557
+ let errored: unknown
558
+ app.subscribe('test.suspending', (value) => seen.push(value), {})
559
+ app.subscribe('test.suspending').subscribe({error: (error) => (errored = error)})
560
+
561
+ client.reject('test.suspending', 'hidden')
562
+ client.emit('test.suspending', 'ready')
563
+
564
+ expect(errored).toBeUndefined()
565
+ expect(seen).toEqual(['ready'])
566
+ })
567
+
568
+ it('ignores a rejection once the connection has closed', async () => {
569
+ const host = createMessageBus()
570
+ registerStateTopics(host, {'test.count': 0})
571
+ const {app, client} = connect(host, {appId: 'favorites'})
572
+
573
+ app.disconnect()
574
+ expect(() => client.reject('test.count', 'hidden')).not.toThrow()
575
+ })
472
576
  })
473
577
 
474
578
  describe('application connections', () => {
@@ -883,6 +987,20 @@ describe('reset', () => {
883
987
  expect(seen).toEqual([{ok: true, value: null}, panel])
884
988
  })
885
989
 
990
+ it("clears a connection's rejections along with its state", async () => {
991
+ const host = installMessageBus({appId: 'dashboard'})
992
+ const application = connectMessageBus({appId: 'favorites'})
993
+ if (!application) throw new Error('Expected a dashboard message bus')
994
+ let client: MessageBusClient | undefined
995
+ host.connections.subscribe((connected) => (client = connected))
996
+ client?.reject('panels.mode', 'hidden')
997
+ await expect(application.query('panels.mode')).rejects.toMatchObject({code: 'REFUSED'})
998
+
999
+ resetMessageBus()
1000
+
1001
+ await expect(application.query('panels.mode')).resolves.toEqual({ok: true, value: null})
1002
+ })
1003
+
886
1004
  it('keeps announcing new connections to a host subscribed before the reset', () => {
887
1005
  const host = installMessageBus({appId: 'dashboard'})
888
1006
  const seen: string[] = []