@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.
- package/dist/_exports/_internal.d.ts +13 -5
- package/dist/_exports/_internal.d.ts.map +1 -1
- package/dist/_exports/_internal.js +24 -6
- package/dist/_exports/_internal.js.map +1 -1
- package/dist/_exports/agent.d.ts +1 -1
- package/dist/_exports/agent.js +1 -1
- package/dist/_exports/comlink.d.ts +1 -1
- package/dist/_exports/dashboard.d.ts +1 -1
- package/dist/_exports/dashboard.js +1 -1
- package/dist/{authStore-7dZCfKUq.d.ts → applications-pRS8tuC2.d.ts} +350 -363
- package/dist/applications-pRS8tuC2.d.ts.map +1 -0
- package/dist/{clientStore-QDrJNTM4.js → clientStore-BI4D8HhJ.js} +10 -14
- package/dist/clientStore-BI4D8HhJ.js.map +1 -0
- package/dist/{createSanityInstance--Lrldto8.d.ts → createSanityInstance-BQIqyGLX.d.ts} +3 -6
- package/dist/createSanityInstance-BQIqyGLX.d.ts.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +142 -182
- package/dist/index.js.map +1 -1
- package/dist/{topicStore-Ct7IFNtY.js → topicStore-BJ54_X6H.js} +3 -1
- package/dist/topicStore-BJ54_X6H.js.map +1 -0
- package/dist/{topicStore-CAnJsiqI.d.ts → topicStore-D-jEwzlX.d.ts} +54 -25
- package/dist/topicStore-D-jEwzlX.d.ts.map +1 -0
- package/dist/{version-CYs30C7v.js → version-CD4aBigm.js} +3 -3
- package/dist/{version-CYs30C7v.js.map → version-CD4aBigm.js.map} +1 -1
- package/package.json +7 -7
- package/src/_exports/_internal.ts +2 -0
- package/src/applications/applicationOrigin.test.ts +51 -0
- package/src/applications/applicationOrigin.ts +26 -0
- package/src/auth/authStore.test.ts +0 -18
- package/src/auth/authStore.ts +1 -4
- package/src/auth/handleAuthCallback.test.ts +0 -33
- package/src/auth/handleAuthCallback.ts +1 -11
- package/src/auth/logout.test.ts +1 -9
- package/src/auth/logout.ts +1 -18
- package/src/auth/oauth/oauthActions.test.ts +1 -178
- package/src/auth/oauth/oauthActions.ts +46 -111
- package/src/auth/oauth/oauthAuth.test.ts +0 -23
- package/src/auth/oauth/oauthAuth.ts +8 -18
- package/src/config/authConfig.ts +2 -5
- package/src/dashboard/messageBus/bus.test.ts +39 -0
- package/src/dashboard/messageBus/bus.ts +32 -22
- package/src/dashboard/messageBus/topics.test.ts +36 -2
- package/src/dashboard/messageBus/topics.ts +51 -16
- package/src/installations/installations.test-d.ts +11 -0
- package/src/installations/installations.test.ts +6 -4
- package/src/installations/installations.ts +13 -2
- package/dist/authStore-7dZCfKUq.d.ts.map +0 -1
- package/dist/clientStore-QDrJNTM4.js.map +0 -1
- package/dist/createSanityInstance--Lrldto8.d.ts.map +0 -1
- package/dist/topicStore-CAnJsiqI.d.ts.map +0 -1
- 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
|
-
|
|
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
|
|
185
|
-
*
|
|
186
|
-
*
|
|
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
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
93
|
-
// URI (query and hash are ignored).
|
|
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 =
|
|
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)
|
package/src/config/authConfig.ts
CHANGED
|
@@ -90,11 +90,8 @@ export interface AuthConfig {
|
|
|
90
90
|
redirectUri: string
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
|
-
* Organisation id used for RFC 8707 resource scoping.
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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, {
|
|
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, {
|
|
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 = {
|
|
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
|
})
|