@frontera-sdk/core 1.50.79 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/core",
3
- "version": "1.50.79",
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"
@@ -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
- const mode = detectAppMode(runtime, framed)
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
- mode === 'embedded'
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'