@sanity/sdk 3.3.0-rc.1 → 3.3.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 (51) hide show
  1. package/dist/_exports/_internal.d.ts +13 -5
  2. package/dist/_exports/_internal.d.ts.map +1 -1
  3. package/dist/_exports/_internal.js +24 -6
  4. package/dist/_exports/_internal.js.map +1 -1
  5. package/dist/_exports/agent.d.ts +1 -1
  6. package/dist/_exports/agent.js +1 -1
  7. package/dist/_exports/comlink.d.ts +1 -1
  8. package/dist/_exports/dashboard.d.ts +1 -1
  9. package/dist/_exports/dashboard.js +1 -1
  10. package/dist/{authStore-7dZCfKUq.d.ts → applications-pRS8tuC2.d.ts} +350 -363
  11. package/dist/applications-pRS8tuC2.d.ts.map +1 -0
  12. package/dist/{clientStore-QDrJNTM4.js → clientStore-BI4D8HhJ.js} +10 -14
  13. package/dist/clientStore-BI4D8HhJ.js.map +1 -0
  14. package/dist/{createSanityInstance--Lrldto8.d.ts → createSanityInstance-BQIqyGLX.d.ts} +3 -6
  15. package/dist/createSanityInstance-BQIqyGLX.d.ts.map +1 -0
  16. package/dist/index.d.ts +2 -2
  17. package/dist/index.js +142 -182
  18. package/dist/index.js.map +1 -1
  19. package/dist/{topicStore-Ct7IFNtY.js → topicStore-BJ54_X6H.js} +3 -1
  20. package/dist/topicStore-BJ54_X6H.js.map +1 -0
  21. package/dist/{topicStore-CAnJsiqI.d.ts → topicStore-D-jEwzlX.d.ts} +54 -25
  22. package/dist/topicStore-D-jEwzlX.d.ts.map +1 -0
  23. package/dist/{version-CYs30C7v.js → version-CD4aBigm.js} +3 -3
  24. package/dist/{version-CYs30C7v.js.map → version-CD4aBigm.js.map} +1 -1
  25. package/package.json +7 -7
  26. package/src/_exports/_internal.ts +2 -0
  27. package/src/applications/applicationOrigin.test.ts +51 -0
  28. package/src/applications/applicationOrigin.ts +26 -0
  29. package/src/auth/authStore.test.ts +0 -18
  30. package/src/auth/authStore.ts +1 -4
  31. package/src/auth/handleAuthCallback.test.ts +0 -33
  32. package/src/auth/handleAuthCallback.ts +1 -11
  33. package/src/auth/logout.test.ts +1 -9
  34. package/src/auth/logout.ts +1 -18
  35. package/src/auth/oauth/oauthActions.test.ts +1 -178
  36. package/src/auth/oauth/oauthActions.ts +46 -111
  37. package/src/auth/oauth/oauthAuth.test.ts +0 -23
  38. package/src/auth/oauth/oauthAuth.ts +8 -18
  39. package/src/config/authConfig.ts +2 -5
  40. package/src/dashboard/messageBus/bus.test.ts +39 -0
  41. package/src/dashboard/messageBus/bus.ts +32 -22
  42. package/src/dashboard/messageBus/topics.test.ts +36 -2
  43. package/src/dashboard/messageBus/topics.ts +51 -16
  44. package/src/installations/installations.test-d.ts +11 -0
  45. package/src/installations/installations.test.ts +6 -4
  46. package/src/installations/installations.ts +13 -2
  47. package/dist/authStore-7dZCfKUq.d.ts.map +0 -1
  48. package/dist/clientStore-QDrJNTM4.js.map +0 -1
  49. package/dist/createSanityInstance--Lrldto8.d.ts.map +0 -1
  50. package/dist/topicStore-CAnJsiqI.d.ts.map +0 -1
  51. package/dist/topicStore-Ct7IFNtY.js.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
  }
@@ -500,6 +500,45 @@ describe('application connections', () => {
500
500
  expect(app.subscribe('auth.token').getCurrent()).toBe('trusted')
501
501
  })
502
502
 
503
+ it('delivers application status events to the host and rejects app responders', () => {
504
+ const statusHost = createMessageBus('dashboard')
505
+ const application = connectApplicationToMessageBus(statusHost, {
506
+ appId: 'favorites',
507
+ })
508
+ const responder = vi.fn()
509
+
510
+ statusHost.subscribe('applications.status.update', responder)
511
+ expect(() => application.subscribe('applications.status.update', vi.fn())).toThrowError(
512
+ expect.objectContaining({code: 'OWNERSHIP_MISMATCH'}),
513
+ )
514
+
515
+ application.emit('applications.status.update', {
516
+ name: 'list',
517
+ value: {label: 'Syncing'},
518
+ })
519
+ application.emit('applications.status.update', {
520
+ name: 'list',
521
+ value: {label: null},
522
+ })
523
+
524
+ expect(responder).toHaveBeenNthCalledWith(
525
+ 1,
526
+ expect.objectContaining({
527
+ type: 'applications.status.update',
528
+ payload: {name: 'list', value: {label: 'Syncing'}},
529
+ meta: expect.objectContaining({appId: 'favorites'}),
530
+ }),
531
+ )
532
+ expect(responder).toHaveBeenNthCalledWith(
533
+ 2,
534
+ expect.objectContaining({
535
+ type: 'applications.status.update',
536
+ payload: {name: 'list', value: {label: null}},
537
+ meta: expect.objectContaining({appId: 'favorites'}),
538
+ }),
539
+ )
540
+ })
541
+
503
542
  it('rejects state emits from every connection, including the host', () => {
504
543
  const host = createMessageBus('dashboard')
505
544
  const panel = {
@@ -18,6 +18,7 @@ import {type Application} from '../../applications/applications'
18
18
  import {
19
19
  DASHBOARD_TOPIC_MANIFEST,
20
20
  type EventTopic,
21
+ type MessageBusTopics,
21
22
  type PayloadOf,
22
23
  type ReplyOf,
23
24
  type StateTopic,
@@ -25,6 +26,7 @@ import {
25
26
  type TopicMigration,
26
27
  topicMigrations,
27
28
  type TopicName,
29
+ type Topics,
28
30
  type ValueOf,
29
31
  } from './topics'
30
32
 
@@ -78,9 +80,9 @@ export interface MessageBusMeta {
78
80
  * A message delivered to an event topic responder.
79
81
  * @public
80
82
  */
81
- export interface MessageBusMessage<T, R = never> {
83
+ export interface MessageBusMessage<T, R = never, K extends PropertyKey = TopicName> {
82
84
  /** The topic that carries the message. */
83
- type: TopicName
85
+ type: K
84
86
  /** The event payload. */
85
87
  payload: T
86
88
  /** The message provenance. */
@@ -148,39 +150,42 @@ export interface MessageBusEmitResult<R> extends PromiseLike<R> {
148
150
  * {@link MessageBusClient.emit}; a connection only ever reads its own values.
149
151
  * @public
150
152
  */
151
- export interface MessageBus {
153
+ export interface MessageBus<TTopics = MessageBusTopics> {
152
154
  /** Emits an event topic and provides its reply when awaited. */
153
- emit<K extends EventTopic>(
155
+ emit<K extends EventTopic<TTopics>>(
154
156
  type: K,
155
- ...rest: PayloadOf<K> extends void
157
+ ...rest: PayloadOf<K, TTopics> extends void
156
158
  ? [payload?: void, options?: MessageBusEmitOptions]
157
- : [payload: PayloadOf<K>, options?: MessageBusEmitOptions]
158
- ): MessageBusEmitResult<ReplyOf<K>>
159
+ : [payload: PayloadOf<K, TTopics>, options?: MessageBusEmitOptions]
160
+ ): MessageBusEmitResult<ReplyOf<K, TTopics>>
159
161
  /** Reads the current or next value of a state topic. */
160
- query<K extends StateTopic>(type: K, options?: MessageBusQueryOptions): Promise<ValueOf<K>>
162
+ query<K extends StateTopic<TTopics>>(
163
+ type: K,
164
+ options?: MessageBusQueryOptions,
165
+ ): Promise<ValueOf<K, TTopics>>
161
166
  /** Runs a responder for each event until its signal aborts. */
162
- subscribe<K extends EventTopic>(
167
+ subscribe<K extends EventTopic<TTopics>>(
163
168
  type: K,
164
- handler: (message: MessageBusMessage<PayloadOf<K>, ReplyOf<K>>) => void,
169
+ handler: (message: MessageBusMessage<PayloadOf<K, TTopics>, ReplyOf<K, TTopics>, K>) => void,
165
170
  options?: MessageBusAbortOptions,
166
171
  ): void
167
172
  /** Runs a handler for each state value until its signal aborts. */
168
- subscribe<K extends StateTopic>(
173
+ subscribe<K extends StateTopic<TTopics>>(
169
174
  type: K,
170
- handler: (value: ValueOf<K>) => void,
175
+ handler: (value: ValueOf<K, TTopics>) => void,
171
176
  options?: MessageBusAbortOptions,
172
177
  ): void
173
178
  /** Returns a state topic as a `MessageBusStateSource`. */
174
- subscribe<K extends StateTopic>(type: K): MessageBusStateSource<ValueOf<K>>
179
+ subscribe<K extends StateTopic<TTopics>>(type: K): MessageBusStateSource<ValueOf<K, TTopics>>
175
180
  /** Returns an event topic as an observable of its payloads. */
176
- subscribe<K extends EventTopic>(type: K): Observable<PayloadOf<K>>
181
+ subscribe<K extends EventTopic<TTopics>>(type: K): Observable<PayloadOf<K, TTopics>>
177
182
  }
178
183
 
179
184
  /**
180
185
  * A message bus connection that can be torn down independently of its siblings.
181
186
  * @public
182
187
  */
183
- export interface MessageBusConnection extends MessageBus {
188
+ export interface MessageBusConnection<TTopics = MessageBusTopics> extends MessageBus<TTopics> {
184
189
  /**
185
190
  * Tears down this connection: pending requests reject `ABORTED`, subscriptions
186
191
  * complete, and later `emit`, `query`, and `subscribe` calls fail with `ABORTED`.
@@ -214,7 +219,7 @@ export interface MessageBusClient {
214
219
  * a snapshot to each new client.
215
220
  * @public
216
221
  */
217
- export interface MessageBusHost extends MessageBusConnection {
222
+ export interface MessageBusHost<TTopics = MessageBusTopics> extends MessageBusConnection<TTopics> {
218
223
  /**
219
224
  * Every open connection other than this one first, then each new one as it joins. The same
220
225
  * {@link MessageBusClient} object is handed out for a connection across subscriptions, so it
@@ -641,7 +646,7 @@ export function createIsolatedMessageBus(
641
646
  config: {
642
647
  migrations?: ReadonlyMap<string, readonly TopicMigration[]>
643
648
  } = {},
644
- ): MessageBusHost {
649
+ ): MessageBusHost<Topics> {
645
650
  if (!appId) throwMissingAppId()
646
651
 
647
652
  const registry: MessageBusRegistry = {
@@ -658,7 +663,10 @@ export function createIsolatedMessageBus(
658
663
 
659
664
  mergeTopicManifest(registry, DASHBOARD_TOPIC_MANIFEST)
660
665
 
661
- return createConnection(registry, {appId, migrations: config.migrations}) as MessageBusHost
666
+ return createConnection(registry, {
667
+ appId,
668
+ migrations: config.migrations,
669
+ }) as MessageBusHost<Topics>
662
670
  }
663
671
 
664
672
  type TopicVersionAdapter = {
@@ -863,7 +871,7 @@ export interface ConnectApplicationToMessageBusOptions {
863
871
  export function connectApplicationToMessageBus(
864
872
  installedMessageBus: MessageBus,
865
873
  config: ConnectApplicationToMessageBusOptions,
866
- ): MessageBusConnection {
874
+ ): MessageBusConnection<Topics> {
867
875
  if (!config.appId) throwMissingAppId()
868
876
 
869
877
  const {appId} = config
@@ -902,7 +910,7 @@ export function connectApplicationToMessageBus(
902
910
  return createRejectedConnection(() => error)
903
911
  }
904
912
 
905
- return createConnection(registry, config)
913
+ return createConnection(registry, config) as MessageBusConnection<Topics>
906
914
  }
907
915
 
908
916
  // Hands the host the same client object for a connection across `connections` subscriptions.
@@ -1202,7 +1210,7 @@ export interface InstallMessageBusOptions {
1202
1210
  * connection it would get has no `connections`.
1203
1211
  * @internal
1204
1212
  */
1205
- export function installMessageBus(options: InstallMessageBusOptions = {}): MessageBusHost {
1213
+ export function installMessageBus(options: InstallMessageBusOptions = {}): MessageBusHost<Topics> {
1206
1214
  const appId = resolveAppId(options.appId) ?? throwMissingAppId()
1207
1215
  const installedMessageBus = getInstalledMessageBus()
1208
1216
  if (installedMessageBus) {
@@ -1214,7 +1222,9 @@ export function installMessageBus(options: InstallMessageBusOptions = {}): Messa
1214
1222
  `Cannot install the message bus as "${appId}": it is already installed by "${installedAppId}". Applications connect with connectMessageBus().`,
1215
1223
  )
1216
1224
  }
1217
- return connectApplicationToMessageBus(installedMessageBus, {appId}) as MessageBusHost
1225
+ return connectApplicationToMessageBus(installedMessageBus, {
1226
+ appId,
1227
+ }) as MessageBusHost<Topics>
1218
1228
  }
1219
1229
 
1220
1230
  const globals = globalThis as {[MESSAGE_BUS_KEY]?: MessageBus}
@@ -1,13 +1,18 @@
1
- import {describe, expectTypeOf, it} from 'vitest'
1
+ import {describe, expect, expectTypeOf, it} from 'vitest'
2
2
 
3
3
  import {type Application, type ApplicationInclude} from '../../applications/applications'
4
4
  import {type MessageBus} from './bus'
5
5
  import {
6
6
  type ApplicationConfig,
7
7
  type ApplicationConfigAppType,
8
+ type ApplicationStatusUpdate,
9
+ DASHBOARD_TOPIC_MANIFEST,
10
+ type DashboardTopics,
11
+ type EventTopic,
8
12
  type PayloadOf,
9
13
  type ReplyOf,
10
14
  type StateTopic,
15
+ type TopicName,
11
16
  type TopicResult,
12
17
  type ValueOf,
13
18
  } from './topics'
@@ -28,7 +33,26 @@ describe('dashboard topic types', () => {
28
33
  expectTypeOf<Parameters<MessageBus['query']>[0]>().toEqualTypeOf<StateTopic>()
29
34
  })
30
35
 
36
+ it('keeps application status internal', () => {
37
+ expectTypeOf<ApplicationStatusUpdate>().toEqualTypeOf<{
38
+ name: string
39
+ value: {label: string | null}
40
+ }>()
41
+ expect(DASHBOARD_TOPIC_MANIFEST['applications.status.update']).toEqual({
42
+ kind: 'event',
43
+ ownership: {type: 'same_app'},
44
+ })
45
+ expectTypeOf<'applications.status.update'>().toMatchTypeOf<TopicName>()
46
+ expectTypeOf<'applications.status.update'>().not.toMatchTypeOf<EventTopic>()
47
+ expectTypeOf<PayloadOf<'applications.status.update'>>().toEqualTypeOf<ApplicationStatusUpdate>()
48
+ expectTypeOf<ReplyOf<'applications.status.update'>>().toBeNever()
49
+ })
50
+
31
51
  it('exposes application state values', () => {
52
+ expectTypeOf<ValueOf<'applications.base-path'>>().toEqualTypeOf<TopicResult<string>>()
53
+ expectTypeOf<ValueOf<'applications.foreground', DashboardTopics>>().toEqualTypeOf<
54
+ Application['id'] | null
55
+ >()
32
56
  expectTypeOf<ValueOf<'applications.config'>>().toEqualTypeOf<ApplicationConfig[] | null>()
33
57
  expectTypeOf<ValueOf<'applications.list'>>().toEqualTypeOf<TopicResult<
34
58
  Application<ApplicationInclude>[]
@@ -60,12 +84,22 @@ describe('dashboard topic types', () => {
60
84
  })
61
85
 
62
86
  it('requires payloads only for events that declare one', () => {
63
- const messageBus = {emit: () => undefined} as unknown as MessageBus
87
+ const messageBus = {
88
+ emit: () => undefined,
89
+ query: () => undefined,
90
+ subscribe: () => undefined,
91
+ } as unknown as MessageBus
64
92
 
65
93
  messageBus.emit('auth.token.refresh')
66
94
  messageBus.emit('auth.token.refresh', undefined, {timeout: null})
67
95
  messageBus.emit('navigation.location.update', {url: '/'})
68
96
  // @ts-expect-error navigation.location.update requires a payload
69
97
  messageBus.emit('navigation.location.update')
98
+ // @ts-expect-error applications.status.update is internal
99
+ messageBus.emit('applications.status.update', {name: 'media', value: {label: null}})
100
+ // @ts-expect-error applications.status.update is internal
101
+ messageBus.subscribe('applications.status.update')
102
+ // @ts-expect-error applications.status.update is internal
103
+ messageBus.subscribe('applications.status.update', () => {})
70
104
  })
71
105
  })