@frontera-sdk/core 1.50.78 → 1.50.80
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/package.json +9 -1
- package/src/bridge-client.ts +6 -0
- package/src/create-frontera-app.tsx +19 -4
- package/src/external-session.ts +278 -0
- package/src/react.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/core",
|
|
3
|
-
"version": "1.50.
|
|
3
|
+
"version": "1.50.80",
|
|
4
4
|
"description": "Frontera app runtime: the platform bridge client, app bootstrap and typed platform client.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"frontera",
|
|
@@ -57,6 +57,14 @@
|
|
|
57
57
|
"types": "./src/create-frontera-app.tsx",
|
|
58
58
|
"import": "./src/create-frontera-app.tsx"
|
|
59
59
|
},
|
|
60
|
+
"./external-session": {
|
|
61
|
+
"types": "./src/external-session.ts",
|
|
62
|
+
"import": "./src/external-session.ts"
|
|
63
|
+
},
|
|
64
|
+
"./app-session": {
|
|
65
|
+
"types": "./src/app-session.ts",
|
|
66
|
+
"import": "./src/app-session.ts"
|
|
67
|
+
},
|
|
60
68
|
"./react": {
|
|
61
69
|
"types": "./src/react.ts",
|
|
62
70
|
"import": "./src/react.ts"
|
package/src/bridge-client.ts
CHANGED
|
@@ -25,6 +25,12 @@ export interface BridgeSession {
|
|
|
25
25
|
/** Visibility changes do not revoke credentials or imply execution is paused. */
|
|
26
26
|
onActivation(handler: (active: boolean) => void): () => void
|
|
27
27
|
dispose(): void
|
|
28
|
+
/**
|
|
29
|
+
* The fetch the App's API client should use, when the session needs a say in
|
|
30
|
+
* its requests — an externally hosted App re-signs in and retries once when
|
|
31
|
+
* the service refuses a token that was revoked early. Absent: plain fetch.
|
|
32
|
+
*/
|
|
33
|
+
fetchImpl?: typeof fetch
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
export interface ConnectOptions {
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
type FronteraAppMode,
|
|
23
23
|
} from './app-session'
|
|
24
24
|
import { applyHostTheme } from './theme'
|
|
25
|
+
import { connectExternalSession, type ExternalAppSessionConfig } from './external-session'
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* The entry point every app shares.
|
|
@@ -379,7 +380,7 @@ function FronteraRoot({
|
|
|
379
380
|
orgId: init.orgId ?? undefined,
|
|
380
381
|
workspaceId: init.workspaceId ?? undefined,
|
|
381
382
|
credential: { kind: 'token', token: init.token },
|
|
382
|
-
}),
|
|
383
|
+
}, session.fetchImpl),
|
|
383
384
|
)
|
|
384
385
|
|
|
385
386
|
useEffect(
|
|
@@ -432,6 +433,13 @@ export interface FronteraAppProviderProps {
|
|
|
432
433
|
/** Compatibility hooks used by createFronteraApp. */
|
|
433
434
|
queryClient?: QueryClient
|
|
434
435
|
timeoutMs?: number
|
|
436
|
+
/**
|
|
437
|
+
* For an App hosted OUTSIDE Frontera: how its people sign in. Given, the
|
|
438
|
+
* provider does not look for a platform frame or a session endpoint — it
|
|
439
|
+
* exchanges the customer's identity-provider token (or calls your backend)
|
|
440
|
+
* for a Frontera App-user token and renews it before it expires.
|
|
441
|
+
*/
|
|
442
|
+
session?: ExternalAppSessionConfig
|
|
435
443
|
}
|
|
436
444
|
|
|
437
445
|
/**
|
|
@@ -446,6 +454,7 @@ export function FronteraAppProvider({
|
|
|
446
454
|
errorFallback,
|
|
447
455
|
queryClient: providedQueryClient,
|
|
448
456
|
timeoutMs,
|
|
457
|
+
session: externalSession,
|
|
449
458
|
}: FronteraAppProviderProps): ReactNode {
|
|
450
459
|
const [connection, setConnection] = useState<{
|
|
451
460
|
mode: FronteraAppMode
|
|
@@ -463,7 +472,9 @@ export function FronteraAppProvider({
|
|
|
463
472
|
useEffect(() => {
|
|
464
473
|
const runtime = readRuntimeGlobal()
|
|
465
474
|
const framed = typeof window !== 'undefined' && window.parent !== window
|
|
466
|
-
|
|
475
|
+
// An externally hosted App names its session explicitly; nothing else is
|
|
476
|
+
// consulted, so a stray frame or leftover global cannot redirect it.
|
|
477
|
+
const mode = externalSession ? 'standalone' : detectAppMode(runtime, framed)
|
|
467
478
|
if (!mode) {
|
|
468
479
|
// Reachable only when NOT framed: `detectAppMode` returns 'embedded' for
|
|
469
480
|
// any framed document, so a null mode means there is no parent and no
|
|
@@ -479,8 +490,9 @@ export function FronteraAppProvider({
|
|
|
479
490
|
|
|
480
491
|
let active = true
|
|
481
492
|
let sessionToDispose: BridgeSession | null = null
|
|
482
|
-
const connected =
|
|
483
|
-
|
|
493
|
+
const connected: Promise<BridgeSession> = externalSession
|
|
494
|
+
? connectExternalSession(externalSession)
|
|
495
|
+
: mode === 'embedded'
|
|
484
496
|
? connectToHost({ parentOrigin: runtime.platformOrigin, timeoutMs })
|
|
485
497
|
: connectToSessionEndpoint(
|
|
486
498
|
mode === 'standalone' ? runtime.sessionEndpoint! : runtime.devSessionEndpoint!,
|
|
@@ -503,6 +515,9 @@ export function FronteraAppProvider({
|
|
|
503
515
|
active = false
|
|
504
516
|
sessionToDispose?.dispose()
|
|
505
517
|
}
|
|
518
|
+
// Read once at mount, like the runtime global: a session is established
|
|
519
|
+
// for the page's life, and a new config object each render must not
|
|
520
|
+
// reconnect.
|
|
506
521
|
}, [])
|
|
507
522
|
|
|
508
523
|
if (error) return errorFallback?.(error) ?? diagnostic('Frontera App could not start', error.message)
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import type { BridgeSession } from './bridge-client'
|
|
2
|
+
import type { BridgeInit } from './bridge-protocol'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A session for an App hosted OUTSIDE Frontera, whose people sign in with the
|
|
6
|
+
* customer's own identity provider (design docs/specs/2026-09-24-app-users-design.md
|
|
7
|
+
* §7, §15).
|
|
8
|
+
*
|
|
9
|
+
* Two ways to get the Frontera App-user token, and the App picks one:
|
|
10
|
+
*
|
|
11
|
+
* - `getSubjectToken` — the browser holds the customer's identity-provider
|
|
12
|
+
* token (their OIDC client already signed the person in) and exchanges it
|
|
13
|
+
* directly at `POST /v1/app-users/token`.
|
|
14
|
+
* - `sessionEndpoint` — the App's own backend makes the exchange with an App
|
|
15
|
+
* key and returns `{ token, expiresAt }`. The key never reaches the page.
|
|
16
|
+
*
|
|
17
|
+
* Either way the page only ever holds the short-lived App-user token, renewed
|
|
18
|
+
* before it expires.
|
|
19
|
+
*/
|
|
20
|
+
export interface ExternalAppSessionConfig {
|
|
21
|
+
/** Frontera's API origin, e.g. `https://api.frontera.example`. */
|
|
22
|
+
apiBaseUrl: string
|
|
23
|
+
/** The External App's id. */
|
|
24
|
+
appId: string
|
|
25
|
+
/** Which of the App's audiences this page signs people into. Default `operator`. */
|
|
26
|
+
audience?: 'operator' | 'customer'
|
|
27
|
+
/** The customer's current identity-provider token (browser exchange). */
|
|
28
|
+
getSubjectToken?: () => Promise<string>
|
|
29
|
+
/** Or: your backend route that exchanges with an App key and returns `{ token, expiresAt }`. */
|
|
30
|
+
sessionEndpoint?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AppUserSummary {
|
|
34
|
+
id: string
|
|
35
|
+
displayName: string | null
|
|
36
|
+
audience: 'operator' | 'customer'
|
|
37
|
+
roles: string[]
|
|
38
|
+
attributes: Record<string, unknown>
|
|
39
|
+
businessObject: { objectType: string; id: string } | null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ExchangedToken {
|
|
43
|
+
token: string
|
|
44
|
+
/**
|
|
45
|
+
* Epoch ms, on THIS machine's clock: taken from the service's `expiresIn`
|
|
46
|
+
* when it sends one, so a browser clock that is off never makes renewal loop.
|
|
47
|
+
*/
|
|
48
|
+
expiresAt: number
|
|
49
|
+
appUser?: AppUserSummary
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class AppUserSignInError extends Error {
|
|
53
|
+
constructor(
|
|
54
|
+
message: string,
|
|
55
|
+
/** Frontera's stable reason, e.g. `not_admitted`, `not_linked`, `disabled`. */
|
|
56
|
+
readonly reason: string | null,
|
|
57
|
+
readonly status: number,
|
|
58
|
+
) {
|
|
59
|
+
super(message)
|
|
60
|
+
this.name = 'AppUserSignInError'
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type FetchImpl = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
|
65
|
+
|
|
66
|
+
function unwrap(body: unknown): Record<string, unknown> {
|
|
67
|
+
const envelope = body as { data?: unknown } | null
|
|
68
|
+
const data = envelope && typeof envelope === 'object' && 'data' in envelope ? envelope.data : body
|
|
69
|
+
return (data && typeof data === 'object' ? data : {}) as Record<string, unknown>
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function expiryOf(value: unknown): number {
|
|
73
|
+
const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Date.parse(value) : Number.NaN
|
|
74
|
+
if (!Number.isFinite(parsed)) throw new Error('Frontera sign-in response has an invalid expiresAt')
|
|
75
|
+
return parsed
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function readToken(response: Response, now: () => number = Date.now): Promise<ExchangedToken> {
|
|
79
|
+
const body = await response.json().catch(() => null) as Record<string, unknown> | null
|
|
80
|
+
if (!response.ok) {
|
|
81
|
+
const details = (body?.details ?? null) as { reason?: string } | null
|
|
82
|
+
throw new AppUserSignInError(
|
|
83
|
+
typeof body?.message === 'string' ? body.message : `Frontera sign-in failed with ${response.status}`,
|
|
84
|
+
details?.reason ?? null,
|
|
85
|
+
response.status,
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
const data = unwrap(body)
|
|
89
|
+
if (typeof data.token !== 'string') throw new Error('Frontera sign-in response has no token')
|
|
90
|
+
return {
|
|
91
|
+
token: data.token,
|
|
92
|
+
expiresAt: typeof data.expiresIn === 'number' && Number.isFinite(data.expiresIn)
|
|
93
|
+
? now() + data.expiresIn * 1000
|
|
94
|
+
: expiryOf(data.expiresAt),
|
|
95
|
+
...(data.appUser ? { appUser: data.appUser as AppUserSummary } : {}),
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Exchange a customer identity-provider token for a Frontera App-user token. */
|
|
100
|
+
export async function exchangeCustomerToken(
|
|
101
|
+
input: { apiBaseUrl: string; appId: string; audience?: 'operator' | 'customer'; subjectToken: string },
|
|
102
|
+
fetchImpl: FetchImpl = fetch,
|
|
103
|
+
now: () => number = Date.now,
|
|
104
|
+
): Promise<ExchangedToken> {
|
|
105
|
+
const response = await fetchImpl(`${input.apiBaseUrl.replace(/\/+$/, '')}/v1/app-users/token`, {
|
|
106
|
+
method: 'POST',
|
|
107
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
108
|
+
body: JSON.stringify({
|
|
109
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
|
|
110
|
+
app: input.appId,
|
|
111
|
+
audience: input.audience ?? 'operator',
|
|
112
|
+
subject_token: input.subjectToken,
|
|
113
|
+
subject_token_type: 'urn:ietf:params:oauth:token-type:jwt',
|
|
114
|
+
}),
|
|
115
|
+
})
|
|
116
|
+
return readToken(response, now)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function nextToken(config: ExternalAppSessionConfig, fetchImpl: FetchImpl, now: () => number): Promise<ExchangedToken> {
|
|
120
|
+
if (config.getSubjectToken) {
|
|
121
|
+
return exchangeCustomerToken({
|
|
122
|
+
apiBaseUrl: config.apiBaseUrl,
|
|
123
|
+
appId: config.appId,
|
|
124
|
+
audience: config.audience,
|
|
125
|
+
subjectToken: await config.getSubjectToken(),
|
|
126
|
+
}, fetchImpl, now)
|
|
127
|
+
}
|
|
128
|
+
if (config.sessionEndpoint) {
|
|
129
|
+
const response = await fetchImpl(config.sessionEndpoint, { credentials: 'include', headers: { accept: 'application/json' } })
|
|
130
|
+
return readToken(response, now)
|
|
131
|
+
}
|
|
132
|
+
throw new Error('An external App session needs getSubjectToken or sessionEndpoint.')
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface ExternalSessionOptions {
|
|
136
|
+
fetchImpl?: FetchImpl
|
|
137
|
+
now?: () => number
|
|
138
|
+
setTimer?: (callback: () => void, delay: number) => unknown
|
|
139
|
+
clearTimer?: (timer: unknown) => void
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Never renew sooner than this after a token arrives. */
|
|
143
|
+
const MIN_RENEW_DELAY_MS = 30_000
|
|
144
|
+
/** After a failed sign-in, wait this long before the next, doubling to the cap. */
|
|
145
|
+
const FIRST_BACKOFF_MS = 30_000
|
|
146
|
+
const MAX_BACKOFF_MS = 5 * 60_000
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* A bridge-compatible session for an externally hosted App: signs in once,
|
|
150
|
+
* then renews the App-user token at 80% of its life. A request refused with
|
|
151
|
+
* 401 signs in again once. A failed sign-in is remembered: until its backoff
|
|
152
|
+
* passes, no request triggers another, so a disabled person or an unreachable
|
|
153
|
+
* identity provider costs one attempt per backoff step, not one per request.
|
|
154
|
+
*/
|
|
155
|
+
export async function connectExternalSession(
|
|
156
|
+
config: ExternalAppSessionConfig,
|
|
157
|
+
options: ExternalSessionOptions = {},
|
|
158
|
+
): Promise<BridgeSession & { appUser: AppUserSummary | null }> {
|
|
159
|
+
const fetchImpl = options.fetchImpl ?? fetch
|
|
160
|
+
const now = options.now ?? Date.now
|
|
161
|
+
const setTimer = options.setTimer ?? ((callback, delay) => setTimeout(callback, delay))
|
|
162
|
+
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle as ReturnType<typeof setTimeout>))
|
|
163
|
+
|
|
164
|
+
const first = await nextToken(config, fetchImpl, now)
|
|
165
|
+
const tokenHandlers = new Set<(token: string) => void>()
|
|
166
|
+
let disposed = false
|
|
167
|
+
let timer: unknown
|
|
168
|
+
let currentToken = first.token
|
|
169
|
+
// One renewal at a time: several requests refused together share it.
|
|
170
|
+
let renewing: Promise<string | null> | null = null
|
|
171
|
+
let failures = 0
|
|
172
|
+
let retryAfter = 0
|
|
173
|
+
|
|
174
|
+
const publish = (token: string) => {
|
|
175
|
+
currentToken = token
|
|
176
|
+
for (const handler of tokenHandlers) handler(token)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// One timer at a time: scheduling replaces any pending one, so a renewal
|
|
180
|
+
// from a 401 and a scheduled one can never leave two chains running.
|
|
181
|
+
const scheduleIn = (delay: number) => {
|
|
182
|
+
if (timer !== undefined) clearTimer(timer)
|
|
183
|
+
timer = undefined
|
|
184
|
+
if (disposed) return
|
|
185
|
+
timer = setTimer(() => {
|
|
186
|
+
timer = undefined
|
|
187
|
+
void renewNow()
|
|
188
|
+
}, delay)
|
|
189
|
+
}
|
|
190
|
+
const schedule = (expiresAt: number) => scheduleIn(Math.max(MIN_RENEW_DELAY_MS, Math.floor((expiresAt - now()) * 0.8)))
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Sign in again now: on schedule, or because the service refused the current
|
|
194
|
+
* token (revoked, or settings changed). Single-flight — concurrent callers
|
|
195
|
+
* share one exchange.
|
|
196
|
+
*/
|
|
197
|
+
const renewNow = (): Promise<string | null> => {
|
|
198
|
+
if (disposed) return Promise.resolve(null)
|
|
199
|
+
if (renewing) return renewing
|
|
200
|
+
if (now() < retryAfter) return Promise.resolve(null)
|
|
201
|
+
renewing = nextToken(config, fetchImpl, now)
|
|
202
|
+
.then((next) => {
|
|
203
|
+
if (disposed) return null
|
|
204
|
+
failures = 0
|
|
205
|
+
retryAfter = 0
|
|
206
|
+
publish(next.token)
|
|
207
|
+
schedule(next.expiresAt)
|
|
208
|
+
return next.token
|
|
209
|
+
})
|
|
210
|
+
.catch(() => {
|
|
211
|
+
const backoff = Math.min(MAX_BACKOFF_MS, FIRST_BACKOFF_MS * 2 ** failures)
|
|
212
|
+
failures++
|
|
213
|
+
retryAfter = now() + backoff
|
|
214
|
+
scheduleIn(backoff)
|
|
215
|
+
return null
|
|
216
|
+
})
|
|
217
|
+
.finally(() => { renewing = null })
|
|
218
|
+
return renewing
|
|
219
|
+
}
|
|
220
|
+
schedule(first.expiresAt)
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Requests to Frontera go through here. A 401 on a request that carried the
|
|
224
|
+
* App-user token means it was refused before it expired — the person was
|
|
225
|
+
* disabled, or the audience's settings changed. Sign in again once and retry
|
|
226
|
+
* the request with the new token; a second refusal stands.
|
|
227
|
+
*/
|
|
228
|
+
const apiFetch: typeof fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
229
|
+
const response = await fetchImpl(input, init)
|
|
230
|
+
const headers = new Headers(init?.headers)
|
|
231
|
+
const sent = headers.get('authorization')
|
|
232
|
+
// Only a request that carried an App-user token is ours to retry.
|
|
233
|
+
if (response.status !== 401 || !sent?.startsWith('Bearer sk-au-')) return response
|
|
234
|
+
const renewed = await renewNow()
|
|
235
|
+
if (!renewed) return response
|
|
236
|
+
headers.set('authorization', `Bearer ${renewed}`)
|
|
237
|
+
return fetchImpl(input, { ...init, headers })
|
|
238
|
+
}) as typeof fetch
|
|
239
|
+
|
|
240
|
+
const init: BridgeInit = {
|
|
241
|
+
type: 'frontera:init',
|
|
242
|
+
token: first.token,
|
|
243
|
+
apiBaseUrl: config.apiBaseUrl.replace(/\/+$/, ''),
|
|
244
|
+
appId: config.appId,
|
|
245
|
+
version: 'external',
|
|
246
|
+
// The App-user token names its organization and workspace itself; the
|
|
247
|
+
// service reads them from the token, never from a header.
|
|
248
|
+
orgId: null,
|
|
249
|
+
workspaceId: null,
|
|
250
|
+
theme: { tokens: {} },
|
|
251
|
+
state: {},
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
init,
|
|
256
|
+
appUser: first.appUser ?? null,
|
|
257
|
+
fetchImpl: apiFetch,
|
|
258
|
+
send() {},
|
|
259
|
+
onState() {
|
|
260
|
+
return () => {}
|
|
261
|
+
},
|
|
262
|
+
onToken(handler) {
|
|
263
|
+
tokenHandlers.add(handler)
|
|
264
|
+
return () => tokenHandlers.delete(handler)
|
|
265
|
+
},
|
|
266
|
+
onTheme() {
|
|
267
|
+
return () => {}
|
|
268
|
+
},
|
|
269
|
+
onActivation() {
|
|
270
|
+
return () => {}
|
|
271
|
+
},
|
|
272
|
+
dispose() {
|
|
273
|
+
disposed = true
|
|
274
|
+
if (timer !== undefined) clearTimer(timer)
|
|
275
|
+
tokenHandlers.clear()
|
|
276
|
+
},
|
|
277
|
+
}
|
|
278
|
+
}
|
package/src/react.ts
CHANGED
|
@@ -6,3 +6,9 @@ export {
|
|
|
6
6
|
type FronteraProvider,
|
|
7
7
|
} from './create-frontera-app'
|
|
8
8
|
export type { FronteraAppMode } from './app-session'
|
|
9
|
+
export {
|
|
10
|
+
AppUserSignInError,
|
|
11
|
+
exchangeCustomerToken,
|
|
12
|
+
type AppUserSummary,
|
|
13
|
+
type ExternalAppSessionConfig,
|
|
14
|
+
} from './external-session'
|