@frontera-sdk/cli 1.53.1 → 1.53.3
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/cli",
|
|
3
|
-
"version": "1.53.
|
|
3
|
+
"version": "1.53.3",
|
|
4
4
|
"description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"frontera",
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@anthropic-ai/claude-agent-sdk": "^0.3.251",
|
|
42
42
|
"@faker-js/faker": "10.6.0",
|
|
43
|
-
"@frontera-sdk/core": "1.53.
|
|
44
|
-
"@frontera-sdk/functions": "1.53.
|
|
43
|
+
"@frontera-sdk/core": "1.53.3",
|
|
44
|
+
"@frontera-sdk/functions": "1.53.3",
|
|
45
45
|
"ai": "^6.0.116",
|
|
46
46
|
"axe-core": "4.12.1",
|
|
47
47
|
"gray-matter": "^4.0.3",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"yaml": "^2.9.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
-
"@frontera-sdk/forge-contracts": "1.53.
|
|
52
|
+
"@frontera-sdk/forge-contracts": "1.53.3",
|
|
53
53
|
"@types/bun": "^1.3.14",
|
|
54
54
|
"typescript": "^5.9.3"
|
|
55
55
|
}
|
package/src/local/local-apps.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from 'node
|
|
|
2
2
|
import { basename, join, relative } from 'node:path'
|
|
3
3
|
|
|
4
4
|
import type { RemoteItem } from './local-sync-plan'
|
|
5
|
+
|
|
6
|
+
type AppIconSource = 'glyph' | 'image' | 'favicon'
|
|
5
7
|
import { readDevSessionState } from './local-app-dev-session'
|
|
6
8
|
import { isAlive } from './local-state'
|
|
7
9
|
|
|
@@ -28,6 +30,8 @@ export interface AppRow {
|
|
|
28
30
|
name: string
|
|
29
31
|
description?: string | null
|
|
30
32
|
icon?: string | null
|
|
33
|
+
iconSource?: AppIconSource | null
|
|
34
|
+
accent?: string | null
|
|
31
35
|
hosting?: 'platform' | 'external' | null
|
|
32
36
|
version?: string | null
|
|
33
37
|
manifest?: { routing?: unknown; connectDomains?: unknown; resourceDomains?: unknown } | null
|
|
@@ -40,6 +44,10 @@ export interface AppFile {
|
|
|
40
44
|
description?: string
|
|
41
45
|
/** An icon from the platform's set, by name (X6). */
|
|
42
46
|
icon?: string
|
|
47
|
+
/** What the tile shows; absent is the icon. `image` is uploaded on the platform. */
|
|
48
|
+
iconSource?: AppIconSource
|
|
49
|
+
/** The icon's accent from the application palette; null clears it. */
|
|
50
|
+
accent?: string | null
|
|
43
51
|
hosting?: 'external'
|
|
44
52
|
version?: string
|
|
45
53
|
routing?: string
|
|
@@ -61,6 +69,8 @@ export function appDocument(row: AppRow): AppFile {
|
|
|
61
69
|
name: row.name,
|
|
62
70
|
...(row.description ? { description: row.description } : {}),
|
|
63
71
|
...(row.icon ? { icon: row.icon } : {}),
|
|
72
|
+
...(row.iconSource && row.iconSource !== 'glyph' ? { iconSource: row.iconSource } : {}),
|
|
73
|
+
...(row.accent ? { accent: row.accent } : {}),
|
|
64
74
|
...(row.hosting === 'external' ? { hosting: 'external' as const } : {}),
|
|
65
75
|
...(row.version ? { version: row.version } : {}),
|
|
66
76
|
...(m && typeof m.routing === 'string' && m.routing !== 'spa' ? { routing: m.routing } : {}),
|
|
@@ -125,14 +135,22 @@ export async function applyApp(root: string, slug: string, call: Call, provides:
|
|
|
125
135
|
const name = typeof doc.name === 'string' && doc.name.trim() ? doc.name.trim() : slug
|
|
126
136
|
const description = typeof doc.description === 'string' ? doc.description : null
|
|
127
137
|
const icon = typeof doc.icon === 'string' && doc.icon.trim() ? doc.icon.trim() : null
|
|
128
|
-
|
|
138
|
+
// Absent leaves the App's accent as it is; null clears it.
|
|
139
|
+
const accent = doc.accent === undefined ? undefined : typeof doc.accent === 'string' && doc.accent ? doc.accent : null
|
|
140
|
+
const ensured = await call('POST', '/v1/platform-apps/ensure', { slug, displayName: name, kind: 'code', ...(description ? { description } : {}), ...(icon ? { icon } : {}), ...(accent ? { accent } : {}) })
|
|
129
141
|
if (ensured.status >= 400) return { ok: false, output: `error [APP]: ${errorOf(ensured)}` }
|
|
130
142
|
const lines: string[] = []
|
|
143
|
+
const id = encodeURIComponent(String(dataOf(ensured.body)?.id ?? ''))
|
|
131
144
|
if (!dataOf(ensured.body)?.created) {
|
|
132
145
|
// A file with no icon leaves the App's as it is.
|
|
133
|
-
const renamed = await call('PATCH', `/v1/platform-apps/${
|
|
146
|
+
const renamed = await call('PATCH', `/v1/platform-apps/${id}`, { displayName: name, description, ...(icon ? { icon } : {}), ...(accent !== undefined ? { accent } : {}) })
|
|
134
147
|
if (renamed.status >= 400) lines.push(` warning: Its name, description and icon could not be set here (${errorOf(renamed)})`)
|
|
135
148
|
}
|
|
149
|
+
// Apart, so an upload the platform does not have yet costs the icon choice and not the rename.
|
|
150
|
+
if (doc.iconSource) {
|
|
151
|
+
const source = await call('PATCH', `/v1/platform-apps/${id}`, { iconSource: doc.iconSource })
|
|
152
|
+
if (source.status >= 400) lines.push(` warning: Its icon source could not be set here (${errorOf(source)})`)
|
|
153
|
+
}
|
|
136
154
|
const bindings = Array.isArray(doc.functions) ? doc.functions : []
|
|
137
155
|
const named = new Set<string>()
|
|
138
156
|
const waiting: string[] = []
|
|
@@ -745,12 +745,14 @@ const APP_SCHEMA: JsonSchema = {
|
|
|
745
745
|
$schema: DRAFT,
|
|
746
746
|
title: 'Custom App (apps/<slug>.json)',
|
|
747
747
|
description:
|
|
748
|
-
'What a Custom App is and the Functions it may start. Its code is its own project in apps/<slug>/, deployed with `frontera app deploy`; who may open it and its addresses are set on the platform. `frontera local dev` saves the App by its file name (the slug) and binds the Functions listed; `frontera local sync push` proposes its name, description, icon and Functions, and a Function bound in the workspace that the file no longer lists is unbound when the request is applied. `hosting`, `version`, `routing` and the domains are what the workspace reports, read only here.',
|
|
748
|
+
'What a Custom App is and the Functions it may start. Its code is its own project in apps/<slug>/, deployed with `frontera app deploy`; who may open it and its addresses are set on the platform. `frontera local dev` saves the App by its file name (the slug) and binds the Functions listed; `frontera local sync push` proposes its name, description, icon (its source and colour too) and Functions, and a Function bound in the workspace that the file no longer lists is unbound when the request is applied. `hosting`, `version`, `routing` and the domains are what the workspace reports, read only here.',
|
|
749
749
|
type: 'object',
|
|
750
750
|
properties: {
|
|
751
751
|
name: { type: 'string', minLength: 1, maxLength: 120, description: 'The App’s name; the file name when absent.' },
|
|
752
752
|
description: { type: 'string', maxLength: 500 },
|
|
753
753
|
icon: { type: 'string', pattern: '^[a-z0-9][a-z0-9-]{0,119}$', description: 'An icon from the platform’s set, by name (`chart-bar`); the App’s card shows its first letter when the name is not one. Left out, the App keeps its icon.' },
|
|
754
|
+
iconSource: { enum: ['glyph', 'image', 'favicon'], description: 'What the App’s tile shows: `glyph` is `icon` in its `accent`; `image` is the image uploaded in the App’s settings on the platform, which a file cannot carry; `favicon` is the icon the deployed index.html links to, shown from the next deployment. Left out, the App keeps its choice.' },
|
|
755
|
+
accent: { anyOf: [{ type: 'string', pattern: '^#[0-9A-Fa-f]{6}$' }, { enum: ['red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'violet', 'amber', 'rose', 'slate', null] }], description: 'The colour of the icon’s tile, as `#RRGGBB` or a palette name. null clears it; left out, the App keeps its colour.' },
|
|
754
756
|
functions: {
|
|
755
757
|
type: 'array',
|
|
756
758
|
description: 'Every Function the App may start, through a Function bound to it; the App holds no secrets.',
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"frontera/core/LICENSE": "\n Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright 2026 Sebati\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n",
|
|
5
5
|
"frontera/core/bridge-client.ts": "import { isHostMessage, type BridgeInit, type AppMessage } from './bridge-protocol'\nimport { readRuntimeGlobal } from './config'\nimport type { HostTheme } from './theme'\n\n/**\n * App side of the bridge.\n *\n * The app announces itself with `frontera:ready` and waits for `frontera:init`,\n * which carries the API credential. It does NOT read the credential from its\n * own URL — the only thing there is the narrow asset token, and treating that\n * as a data credential would turn a loggable URL into data access.\n *\n * `event.origin` is checked against the parent's origin on every message.\n */\nexport interface BridgeSession {\n init: BridgeInit\n /** Send a message to the host. */\n send(message: AppMessage): void\n /** Subscribe to shared-state updates pushed by the host. */\n onState(handler: (state: Record<string, unknown>) => void): () => void\n /** Subscribe to credential refreshes. */\n onToken(handler: (token: string) => void): () => void\n /** Subscribe to host palette / colour-scheme changes. */\n onTheme(handler: (theme: HostTheme) => void): () => void\n /** Visibility changes do not revoke credentials or imply execution is paused. */\n onActivation(handler: (active: boolean) => void): () => void\n dispose(): void\n /**\n * The fetch the App's API client should use, when the session needs a say in\n * its requests — an externally hosted App re-signs in and retries once when\n * the service refuses a token that was revoked early. Absent: plain fetch.\n */\n fetchImpl?: typeof fetch\n}\n\nexport interface ConnectOptions {\n /**\n * Origin of the embedding platform. Defaults to the injected runtime config,\n * then `document.referrer`.\n */\n parentOrigin?: string\n timeoutMs?: number\n}\n\n/**\n * Who is framing us.\n *\n * The injected runtime config comes FIRST and `document.referrer` is only a\n * fallback. The injected value is preferred because it is exact and, when it\n * comes from the signed per-mount claim, unforgeable — not because the\n * referrer is empty. (An earlier version of this comment blamed the\n * `referrer-policy: no-referrer` these responses carry; that header governs\n * the referrer this document SENDS on its own requests, not the\n * `document.referrer` its parent handed it, which is set by the embedding\n * page's policy — the platform frame does populate it, and pre-1.45 Apps\n * mounted through exactly this fallback for months.)\n */\nfunction resolveParentOrigin(explicit?: string): string | null {\n if (explicit) return explicit\n const injected = readRuntimeGlobal().platformOrigin\n if (injected) return injected\n if (typeof document === 'undefined') return null\n try {\n return document.referrer ? new URL(document.referrer).origin : null\n } catch {\n return null\n }\n}\n\n/**\n * Complete the handshake.\n *\n * Rejects rather than hanging if the host never answers: a frame that waits\n * forever looks identical to a slow network, and an author debugging a broken\n * mount deserves a real error.\n */\nexport function connectToHost(options: ConnectOptions = {}): Promise<BridgeSession> {\n const parentOrigin = resolveParentOrigin(options.parentOrigin)\n const timeoutMs = options.timeoutMs ?? 10_000\n\n if (typeof window === 'undefined' || window.parent === window) {\n return Promise.reject(\n new Error('Not running inside a Frontera host frame — connectToHost() needs a parent window.'),\n )\n }\n if (!parentOrigin) {\n return Promise.reject(\n new Error('Could not determine the host origin; pass parentOrigin explicitly.'),\n )\n }\n\n const stateHandlers = new Set<(s: Record<string, unknown>) => void>()\n const tokenHandlers = new Set<(t: string) => void>()\n const themeHandlers = new Set<(t: HostTheme) => void>()\n const activationHandlers = new Set<(active: boolean) => void>()\n let active = true\n\n return new Promise<BridgeSession>((resolve, reject) => {\n let settled = false\n\n const send = (message: AppMessage) => window.parent.postMessage(message, parentOrigin)\n\n const onMessage = (event: MessageEvent) => {\n if (event.origin !== parentOrigin || event.source !== window.parent) return\n if (!isHostMessage(event.data)) return\n const message = event.data\n\n switch (message.type) {\n case 'frontera:init':\n if (settled) return\n settled = true\n clearTimeout(timer)\n active = message.active ?? active\n resolve({\n init: message,\n send,\n onState(handler) {\n stateHandlers.add(handler)\n return () => stateHandlers.delete(handler)\n },\n onToken(handler) {\n tokenHandlers.add(handler)\n return () => tokenHandlers.delete(handler)\n },\n onTheme(handler) {\n themeHandlers.add(handler)\n return () => themeHandlers.delete(handler)\n },\n onActivation(handler) {\n activationHandlers.add(handler)\n handler(active)\n return () => activationHandlers.delete(handler)\n },\n dispose() {\n window.removeEventListener('message', onMessage)\n stateHandlers.clear()\n tokenHandlers.clear()\n themeHandlers.clear()\n activationHandlers.clear()\n },\n })\n break\n case 'frontera:state':\n for (const handler of stateHandlers) handler(message.state)\n break\n case 'frontera:token':\n for (const handler of tokenHandlers) handler(message.token)\n break\n // The host pushes this on every platform theme change — a system\n // dark-mode switch, a surface that forces its own scheme. Without a\n // handler the message was received and dropped, so an app followed the\n // palette it was mounted with and then never again.\n case 'frontera:theme':\n for (const handler of themeHandlers) {\n handler({ tokens: message.tokens, colorScheme: message.colorScheme })\n }\n break\n case 'frontera:activation':\n active = message.active\n for (const handler of activationHandlers) handler(message.active)\n break\n default:\n break\n }\n }\n\n const timer = setTimeout(() => {\n if (settled) return\n settled = true\n window.removeEventListener('message', onMessage)\n reject(new Error(`Frontera host did not respond within ${timeoutMs}ms`))\n }, timeoutMs)\n\n window.addEventListener('message', onMessage)\n send({ type: 'frontera:ready' })\n })\n}\n",
|
|
6
6
|
"frontera/core/errors.ts": "/**\n * Error taxonomy for the Frontera SDK.\n *\n * `code` mirrors the service's `ErrorCode` enum\n * (packages/service/src/lib/errors.ts) so callers branch on the same strings\n * the API emits. Codes are deliberately not enumerated here — the service adds\n * new ones, and an SDK that rejected unknown codes would need a release every\n * time it did.\n */\nexport class FronteraError extends Error {\n readonly code: string\n readonly status?: number\n readonly details?: unknown\n\n constructor(message: string, opts: { code: string; status?: number; details?: unknown }) {\n super(message)\n this.name = 'FronteraError'\n this.code = opts.code\n this.status = opts.status\n this.details = opts.details\n }\n}\n\n/** Codes used when the response body carries no envelope of its own. */\nconst STATUS_FALLBACK: Record<number, string> = {\n 400: 'BAD_REQUEST',\n 401: 'UNAUTHORIZED',\n 403: 'FORBIDDEN',\n 404: 'NOT_FOUND',\n 409: 'CONFLICT',\n 429: 'RATE_LIMITED',\n}\n\nfunction isErrorEnvelope(body: unknown): body is { error: true; code?: string; message?: string } {\n return typeof body === 'object' && body !== null && (body as { error?: unknown }).error === true\n}\n\n/** Turn a non-2xx response body into a `FronteraError`. */\nexport function errorFromResponse(status: number, body: unknown): FronteraError {\n if (isErrorEnvelope(body)) {\n return new FronteraError(body.message ?? `request failed with ${status}`, {\n code: body.code ?? STATUS_FALLBACK[status] ?? 'INTERNAL_ERROR',\n status,\n details: body,\n })\n }\n return new FronteraError(`request failed with ${status}`, {\n code: STATUS_FALLBACK[status] ?? 'INTERNAL_ERROR',\n status,\n details: body ?? undefined,\n })\n}\n",
|
|
7
|
-
"frontera/core/create-frontera-app.tsx": "import {\n Component,\n StrictMode,\n createContext,\n useCallback,\n useContext,\n useEffect,\n useState,\n type ErrorInfo,\n type ReactNode,\n} from 'react'\nimport { createRoot } from 'react-dom/client'\nimport { QueryClient, QueryClientProvider } from '@tanstack/react-query'\n\nimport { connectToHost, type BridgeSession } from './bridge-client'\nimport { viewerOf, type BridgeInit, type FronteraViewer } from './bridge-protocol'\nimport { FronteraClient } from './client'\nimport { readRuntimeGlobal } from './config'\nimport {\n connectToSessionEndpoint,\n detectAppMode,\n type FronteraAppMode,\n} from './app-session'\nimport { applyHostTheme } from './theme'\nimport {\n connectExternalSession,\n isAccessRefusal,\n type AccessRefusal,\n type AccessRefusalReason,\n type ExternalAppSessionConfig,\n} from './external-session'\n\n/**\n * The entry point every app shares.\n *\n * Before this existed, the quickstart asked each author to hand-write the same\n * thirty-five lines: find the parent origin, postMessage `frontera:ready`,\n * listen for `frontera:init`, build a client from the token that arrives, and\n * only then mount. Copied boilerplate is where the origin check gets dropped —\n * and a missing origin check is the whole vulnerability class this architecture\n * exists to close. So it is written once, here.\n *\n * Mounting is DEFERRED until the handshake completes. An app has no data scope\n * until the host tells it who it is, so rendering earlier only produces a flash\n * of unauthorized requests, and every hook would need a \"not ready yet\" branch.\n */\n\nexport interface FronteraAppValue {\n mode: FronteraAppMode\n /** Everything the host said at handshake, including `path`. */\n init: BridgeInit\n client: FronteraClient\n /** Whether this app is the visible platform surface. */\n active: boolean\n /** Report local work explicitly; unreported state remains unknown to the host. */\n setUnsavedChanges(dirty: boolean): void\n /** Ask the platform to change its URL — the counterpart to `init.path`. */\n navigate(path: string): void\n /**\n * Who opened the app, from the host at handshake — or null when the host\n * did not say (an older host, or no person, as with a CLI key). Use it to\n * show and prefill; never to decide what the person may do.\n */\n viewer: FronteraViewer | null\n /**\n * True when the host says this frame shows a preview deployment rather than\n * production. Data is real either way; use it to show a \"Preview\" marker.\n */\n isPreview: boolean\n}\n\nconst FronteraAppReactContext = createContext<FronteraAppValue | null>(null)\n\nexport function useFronteraApp(): FronteraAppValue {\n const value = useContext(FronteraAppReactContext)\n if (!value) {\n throw new Error(\n 'useFronteraApp() outside a Frontera app — createFronteraApp() installs this provider.',\n )\n }\n return value\n}\n\n/**\n * The person who opened the app, or null when the host did not say.\n *\n * Shorthand for `useFronteraApp().viewer`: the common read, so an app does not\n * have to ask a person to type who they are.\n */\nexport function useViewer(): FronteraViewer | null {\n return useFronteraApp().viewer\n}\n\n/**\n * Whether the App is open from a preview deployment, as the host said at\n * handshake. Shorthand for `useFronteraApp().isPreview`.\n */\nexport function useIsPreview(): boolean {\n return useFronteraApp().isPreview\n}\n\n/**\n * A wrapper contributed by a domain package.\n *\n * This is a callback rather than `createFronteraApp` importing the domain\n * clients directly, because `@frontera-sdk/blueprint` already depends on\n * `@frontera-sdk/core` — reaching back the other way would make the two\n * packages mutually dependent. `blueprintProvider` from\n * `@frontera-sdk/blueprint/provider` is one of these.\n */\nexport type FronteraProvider = (value: FronteraAppValue, children: ReactNode) => ReactNode\n\nexport interface CreateFronteraAppOptions {\n /** Element id to mount into. Defaults to `root`. */\n rootId?: string\n providers?: FronteraProvider[]\n queryClient?: QueryClient\n /** How long to wait for the host before reporting a failed mount. */\n timeoutMs?: number\n}\n\ninterface BoundaryProps {\n onError: (error: Error, info: ErrorInfo) => void\n fallback?: (error: Error) => ReactNode\n children: ReactNode\n}\n\n/**\n * Report a render crash to the host instead of leaving a blank frame.\n *\n * The platform listens for `frontera:error` and surfaces it next to the app, so\n * the author sees the message rather than an empty rectangle they have to open\n * devtools to explain.\n */\nclass AppErrorBoundary extends Component<BoundaryProps, { error: Error | null }> {\n state: { error: Error | null } = { error: null }\n\n static getDerivedStateFromError(error: Error) {\n return { error }\n }\n\n componentDidCatch(error: Error, info: ErrorInfo) {\n this.props.onError(error, info)\n }\n\n render() {\n if (!this.state.error) return this.props.children\n return this.props.fallback?.(this.state.error) ?? diagnostic('This app stopped rendering', this.state.error.message)\n }\n}\n\n/**\n * The keyframes the boot screen needs.\n *\n * Inlined as a <style> element rather than a CSS import because this package\n * is consumed as source by app bundlers that are not all configured to handle\n * a stylesheet import, and a boot screen must never be the thing that breaks\n * the build it is meant to report on.\n */\nconst BOOT_KEYFRAMES =\n '@keyframes frontera-boot-cube{0%,70%,100%{transform:scale3d(.5,.5,1)}35%{transform:scale3d(0,0,1)}}' +\n '@keyframes frontera-boot-shimmer{0%{background-position:100% 0}100%{background-position:-100% 0}}' +\n '@media(prefers-reduced-motion:reduce){' +\n '[data-frontera-boot-cube]{animation-duration:3.9s!important}' +\n '[data-frontera-boot-shimmer]{animation:none!important}}'\n\n/**\n * Per-cube animation delays, in grid order.\n *\n * Copied from the platform's `.spinner-cube:nth-child(n)` rules rather than\n * re-derived, so the two read as the same object in motion. The pattern is a\n * diagonal wave: equal delays run bottom-left to top-right.\n */\nconst BOOT_CUBE_DELAYS = [0.2, 0.3, 0.4, 0.1, 0.2, 0.3, 0, 0.1, 0.2]\n\n/**\n * Colours for the boot screen.\n *\n * Tokens, with literals only for the app that defines none of them.\n *\n * This paints in whatever scheme the document is already in, which before the\n * handshake means the app's own `:root` — light, unless the app itself has put\n * `.dark` on the document. Nothing here sets the scheme: `applyHostTheme` runs\n * at handshake, by which point this screen is gone, and a guess made earlier\n * would be a write that outlives the screen and fights the app for control of\n * its own theme.\n *\n * There is deliberately no `foreground` entry. In the platform theme\n * `--foreground` holds a bare HSL triplet (`47 13% 14%`) rather than a colour —\n * it is wrapped as `hsl(var(--foreground))` at the point of use — and Tailwind's\n * `@theme inline` does not emit a `--color-foreground` custom property to reach\n * for instead. Naming either one here produces an invalid declaration that the\n * browser drops. Text therefore INHERITS, which lands on the app's own body\n * colour: already correct, in either scheme, with nothing to keep in sync.\n */\nconst BOOT_PALETTE = {\n // `--surface`, not `--background`: this screen sits where the app's own\n // surface will be, so booting on the surface colour means the handover to\n // the real UI is not also a change of backdrop. It needs no `--color-*`\n // form — unlike the two below it, `--surface` is already a colour.\n background: 'var(--surface, #fafafa)',\n muted: 'var(--muted-foreground, #6b7280)',\n border: 'var(--border, #e5e7eb)',\n surface: 'var(--surface-raised, var(--surface, #fafafa))',\n destructive: 'var(--destructive, #dc2626)',\n}\n\n/**\n * The spinner shown while the session is being established.\n *\n * The platform's own `GridSpinner` — a 3×3 of pulsing cubes — rebuilt here in\n * inline styles, because this package has neither Tailwind nor the stylesheet\n * that rule lives in. Matching it matters more than the few lines it costs: an\n * app booting inside the platform should not announce itself with a spinner\n * shape that appears nowhere else in the product.\n *\n * Not a top bar: `AppFrame` already shows `LoadingBar` outside the iframe for\n * exactly this wait, and a second bar inside the frame would draw the same\n * progress twice.\n *\n * Sized in `em` like the original, so the cubes track the font size rather\n * than needing a second number kept in sync.\n */\nfunction bootSpinner(): ReactNode {\n return (\n <div\n aria-hidden=\"true\"\n style={{\n display: 'inline-grid',\n gridTemplateColumns: 'repeat(3, 1fr)',\n gap: '0.08em',\n width: '1em',\n height: '1em',\n fontSize: 28,\n color: BOOT_PALETTE.muted,\n // Tops the container's 10 up to the 16 this sits above.\n marginBottom: 6,\n }}\n >\n {BOOT_CUBE_DELAYS.map((delay, index) => (\n <span\n key={index}\n data-frontera-boot-cube=\"\"\n style={{\n backgroundColor: 'currentColor',\n transform: 'scale3d(0.5, 0.5, 1)',\n animation: `frontera-boot-cube 1.3s ${delay}s infinite ease-in-out`,\n }}\n />\n ))}\n </div>\n )\n}\n\n/**\n * The screens shown before an app can render: connecting, and the two ways\n * starting can fail.\n *\n * Colours come from `BOOT_PALETTE` rather than being written inline, because\n * this paints BEFORE `applyHostTheme` has run and the values have to hold up\n * without it.\n */\nfunction diagnostic(title: string, detail: string, variant: 'loading' | 'error' = 'error'): ReactNode {\n const loading = variant === 'loading'\n return (\n <div\n // A loading screen is a status, not an alert: `alert` is assertive and\n // interrupts the screen-reader user on every single app boot.\n role={loading ? 'status' : 'alert'}\n aria-live={loading ? 'polite' : undefined}\n style={{\n boxSizing: 'border-box',\n minHeight: '100dvh',\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'center',\n padding: 32,\n fontFamily: 'var(--font-sans, system-ui, sans-serif)',\n background: BOOT_PALETTE.background,\n lineHeight: 1.5,\n }}\n >\n <style>{BOOT_KEYFRAMES}</style>\n <div\n style={{\n display: 'flex',\n flexDirection: 'column',\n alignItems: 'center',\n // The two gaps differ: 10 between the lines of text, 16 from the\n // indicator down to them. `gap` sets the smaller one for every pair\n // and the indicator adds the remainder below itself, so the two\n // numbers stay readable instead of being one compromise value.\n gap: 10,\n textAlign: 'center',\n maxWidth: 380,\n }}\n >\n {loading ? (\n bootSpinner()\n ) : (\n <svg\n aria-hidden=\"true\"\n viewBox=\"0 0 24 24\"\n width={30}\n height={30}\n fill=\"none\"\n stroke={BOOT_PALETTE.destructive}\n strokeWidth={2}\n strokeLinecap=\"round\"\n >\n <circle cx=\"12\" cy=\"12\" r=\"9\" />\n <path d=\"M12 8v5\" />\n <path d=\"M12 16.5h.01\" />\n </svg>\n )}\n <div\n data-frontera-boot-shimmer={loading ? '' : undefined}\n style={{\n // No font-size: `<strong>` inherited the body size on main, and\n // matching it keeps this screen the size it has always been.\n fontWeight: 'bold' as const,\n // A highlight sweeping across the glyphs themselves, clipped to the\n // text. Only while connecting: an error is not in progress, and\n // animating it would suggest the app is still trying.\n //\n // The gradient rests on `currentColor`, which is the heading's own\n // full-contrast colour — on main this was plain `--foreground`, and\n // a muted base would leave it at the same weight as the line\n // beneath it for most of the sweep.\n //\n // `currentColor` rather than the token, because a token can fail:\n // in the platform theme `--foreground` holds a bare HSL triplet, so\n // naming it inside `linear-gradient()` makes the whole declaration\n // invalid. The browser then drops the gradient but KEEPS\n // `-webkit-text-fill-color: transparent` — and the heading vanishes\n // entirely. `currentColor` is always a valid colour, so the worst\n // case is a shimmer that does not shimmer, never invisible text.\n //\n // The travelling stop is `muted-foreground`, NOT `primary`: primary\n // is near-black in the default light theme, which is also what the\n // heading already is, so that sweep was three identical stops and\n // no visible motion. Muted is the one token guaranteed to differ\n // from body text in both schemes — that is what it is for.\n ...(loading\n ? {\n backgroundImage: `linear-gradient(90deg, currentColor 30%, ${BOOT_PALETTE.muted} 50%, currentColor 70%)`,\n backgroundSize: '200% 100%',\n WebkitBackgroundClip: 'text',\n backgroundClip: 'text',\n fontSize: 18,\n // Transparent text is invisible if `background-clip: text` is\n // not honoured, so the fill is set the same way and the\n // colour underneath stays the readable one.\n WebkitTextFillColor: 'transparent',\n animation: 'frontera-boot-shimmer 2s linear infinite',\n }\n : {}),\n }}\n >\n {title}\n </div>\n <div\n style={{\n fontSize: 14,\n color: BOOT_PALETTE.muted,\n // An error detail is a raw message or stack fragment: monospaced,\n // left-aligned and boxed so a long one stays readable instead of\n // becoming a centred wall of text.\n ...(loading\n ? {}\n : {\n fontFamily: 'var(--font-mono, ui-monospace, monospace)',\n textAlign: 'left' as const,\n background: BOOT_PALETTE.surface,\n border: `1px solid ${BOOT_PALETTE.border}`,\n borderRadius: 'calc(var(--radius, 8px) - 2px)',\n padding: '10px 12px',\n maxWidth: '100%',\n overflowWrap: 'anywhere' as const,\n }),\n }}\n >\n {detail}\n </div>\n </div>\n </div>\n )\n}\n\n/**\n * What a person Frontera refused reads, one entry per refusal of the person.\n * Plain and calm: this is not a crash, and the next step is someone else's.\n */\nconst ACCESS_COPY: Record<AccessRefusalReason, { title: string; detail: string }> = {\n no_role: {\n title: \"You don't have access yet\",\n detail: \"You don't have access to this App yet. Ask your administrator to give you a role.\",\n },\n not_admitted: {\n title: \"You can't use this App\",\n detail: \"Your account isn't allowed to sign in to this App. If you need it, ask your administrator.\",\n },\n disabled: {\n title: 'Your access is turned off',\n detail: 'Your access to this App has been turned off. Ask your administrator if you need it back.',\n },\n not_linked: {\n title: \"We couldn't find your record\",\n detail: \"This App couldn't match you to a record. Ask your administrator to link your account.\",\n },\n link_ambiguous: {\n title: 'Your account needs a quick fix',\n detail: 'More than one record matches you. Ask your administrator to choose the right one.',\n },\n}\n\n/**\n * The default screen for a person Frontera refused to sign in: a title, one\n * sentence on what to do, and Try again. Deliberately unlike `diagnostic`'s\n * error variant (no alert icon, no monospaced message) because nothing is\n * broken. The service's own message stays out: it is written for the App's\n * builder, not the person.\n */\nfunction accessRefusedScreen(error: AccessRefusal, retry: () => void): ReactNode {\n const copy = ACCESS_COPY[error.reason]\n return (\n <div\n role=\"status\"\n data-frontera-access-refused={error.reason}\n style={{\n boxSizing: 'border-box',\n minHeight: '100dvh',\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'center',\n padding: 32,\n fontFamily: 'var(--font-sans, system-ui, sans-serif)',\n background: BOOT_PALETTE.background,\n lineHeight: 1.5,\n }}\n >\n <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, textAlign: 'center', maxWidth: 380 }}>\n <svg\n aria-hidden=\"true\"\n viewBox=\"0 0 24 24\"\n width={30}\n height={30}\n fill=\"none\"\n stroke={BOOT_PALETTE.muted}\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n style={{ marginBottom: 6 }}\n >\n <rect x=\"5\" y=\"11\" width=\"14\" height=\"10\" rx=\"2\" />\n <path d=\"M8 11V7a4 4 0 0 1 8 0v4\" />\n </svg>\n <div style={{ fontWeight: 'bold' as const, fontSize: 18 }}>{copy.title}</div>\n <div style={{ fontSize: 14, color: BOOT_PALETTE.muted }}>{copy.detail}</div>\n <button\n type=\"button\"\n onClick={retry}\n style={{\n marginTop: 6,\n padding: '6px 14px',\n font: 'inherit',\n fontSize: 14,\n color: 'inherit',\n background: BOOT_PALETTE.surface,\n border: `1px solid ${BOOT_PALETTE.border}`,\n borderRadius: 'calc(var(--radius, 8px) - 2px)',\n cursor: 'pointer',\n }}\n >\n Try again\n </button>\n </div>\n </div>\n )\n}\n\n/**\n * Holds what the host can change underneath a running app.\n *\n * The credential is state rather than a captured constant because the host\n * rotates it mid-session; `withCredential` returns a NEW client so requests\n * already in flight keep the one they started with.\n */\nfunction FronteraRoot({\n session,\n mode,\n children,\n providers,\n errorFallback,\n}: {\n session: BridgeSession\n mode: FronteraAppMode\n children: ReactNode\n providers: FronteraProvider[]\n errorFallback?: (error: Error) => ReactNode\n}) {\n const { init } = session\n const [client, setClient] = useState(\n () =>\n new FronteraClient({\n apiBaseUrl: init.apiBaseUrl,\n orgId: init.orgId ?? undefined,\n workspaceId: init.workspaceId ?? undefined,\n credential: { kind: 'token', token: init.token },\n }, session.fetchImpl),\n )\n\n useEffect(\n () => session.onToken((token) => setClient((c) => c.withCredential({ kind: 'token', token }))),\n [session],\n )\n\n useEffect(() => session.onTheme((theme) => applyHostTheme(theme)), [session])\n const [active, setActive] = useState(init.active ?? true)\n useEffect(() => session.onActivation(setActive), [session])\n const setUnsavedChanges = useCallback((dirty: boolean) => {\n if (mode === 'embedded') session.send({ type: 'frontera:unsaved', dirty })\n }, [mode, session])\n\n const value: FronteraAppValue = {\n mode,\n init,\n client,\n active,\n setUnsavedChanges,\n navigate: (path) => {\n if (mode === 'embedded') session.send({ type: 'frontera:navigate', path })\n },\n viewer: viewerOf(init.viewer),\n isPreview: init.deployment === 'preview',\n }\n\n let tree: ReactNode = children\n // Applied in reverse so the FIRST provider listed ends up outermost, which is\n // the order a reader expects from the array.\n for (const provider of [...providers].reverse()) tree = provider(value, tree)\n\n return (\n <FronteraAppReactContext.Provider value={value}>\n <AppErrorBoundary\n fallback={errorFallback}\n onError={(error) =>\n session.send({ type: 'frontera:error', message: error.message, stack: error.stack })\n }\n >\n {tree}\n </AppErrorBoundary>\n </FronteraAppReactContext.Provider>\n )\n}\n\nexport interface FronteraAppProviderProps {\n children: ReactNode\n providers?: FronteraProvider[]\n loading?: ReactNode\n /** Shown when the App cannot start or crashes while rendering. */\n errorFallback?: (error: Error) => ReactNode\n /**\n * External Apps only: shown instead of the App when Frontera refuses the\n * person, at start or when a later renewal is refused. `error.reason` is\n * one of `no_role`, `not_admitted`, `disabled`, `not_linked` or\n * `link_ambiguous`. `retry` signs in again. The default is a plain screen\n * that tells the person to ask their administrator. Every other sign-in\n * failure (a fault in the App's setup, `invalid_token`,\n * `issuer_unreachable`, no reason) still reaches `errorFallback`.\n */\n accessDenied?: (error: AccessRefusal, retry: () => void) => ReactNode\n /** Compatibility hooks used by createFronteraApp. */\n queryClient?: QueryClient\n timeoutMs?: number\n /**\n * For an App hosted OUTSIDE Frontera: how its people sign in. Given, the\n * provider does not look for a platform frame or a session endpoint — it\n * exchanges the customer's identity-provider token (or calls your backend)\n * for a Frontera App-user token and renews it before it expires.\n */\n session?: ExternalAppSessionConfig\n}\n\n/**\n * Transport-neutral React entry point for embedded, standalone, and local Apps.\n * Mode selection happens before connection, so an ordinary standalone page\n * never waits for an iframe handshake that cannot succeed.\n */\nexport function FronteraAppProvider({\n children,\n providers = [],\n loading = diagnostic('Connecting to Frontera', 'Establishing an authenticated App session.', 'loading'),\n errorFallback,\n accessDenied = accessRefusedScreen,\n queryClient: providedQueryClient,\n timeoutMs,\n session: externalSession,\n}: FronteraAppProviderProps): ReactNode {\n const [connection, setConnection] = useState<{\n mode: FronteraAppMode\n session: BridgeSession\n } | null>(null)\n const [error, setError] = useState<Error | null>(null)\n const [queryClient] = useState(\n () =>\n providedQueryClient ??\n new QueryClient({\n defaultOptions: { queries: { refetchOnWindowFocus: false, retry: 1 } },\n }),\n )\n // Bumped by Try again on the access screen: signs in from scratch. The\n // cache is emptied first, so the App never shows rows read by the session\n // that was refused.\n const [attempt, setAttempt] = useState(0)\n const retry = useCallback(() => {\n queryClient.clear()\n setConnection(null)\n setError(null)\n setAttempt((n) => n + 1)\n }, [queryClient])\n\n useEffect(() => {\n const runtime = readRuntimeGlobal()\n const framed = typeof window !== 'undefined' && window.parent !== window\n // An externally hosted App names its session explicitly; nothing else is\n // consulted, so a stray frame or leftover global cannot redirect it.\n const mode = externalSession ? 'standalone' : detectAppMode(runtime, framed)\n if (!mode) {\n // Reachable only when NOT framed: `detectAppMode` returns 'embedded' for\n // any framed document, so a null mode means there is no parent and no\n // session endpoint either. A framed App that cannot resolve a parent\n // origin fails later and more precisely, inside `connectToHost`.\n setError(\n new Error(\n 'Frontera App is not configured: no embedded host, standalone session endpoint, or local development session endpoint was provided.',\n ),\n )\n return\n }\n\n let active = true\n let sessionToDispose: BridgeSession | null = null\n const connected: Promise<BridgeSession> = externalSession\n ? connectExternalSession(externalSession).then((session) => {\n // Mid-session: signing in again was refused (a role removed, the\n // person disabled). The App's data is no longer theirs to see.\n session.onRefused((refusal) => {\n if (!active) return\n session.dispose()\n queryClient.clear()\n setConnection(null)\n setError(refusal)\n })\n return session\n })\n : mode === 'embedded'\n ? connectToHost({ parentOrigin: runtime.platformOrigin, timeoutMs })\n : connectToSessionEndpoint(\n mode === 'standalone' ? runtime.sessionEndpoint! : runtime.devSessionEndpoint!,\n )\n void connected\n .then((session) => {\n sessionToDispose = session\n if (!active) {\n session.dispose()\n return\n }\n applyHostTheme(session.init.theme)\n setConnection({ mode, session })\n })\n .catch((reason) => {\n if (active) setError(reason instanceof Error ? reason : new Error(String(reason)))\n })\n\n return () => {\n active = false\n sessionToDispose?.dispose()\n }\n // Read once at mount, like the runtime global: a session is established\n // for the page's life, and a new config object each render must not\n // reconnect. Only Try again (`attempt`) starts another.\n }, [attempt])\n\n if (isAccessRefusal(error)) return accessDenied(error, retry)\n if (error) return errorFallback?.(error) ?? diagnostic('Frontera App could not start', error.message)\n if (!connection) return loading\n\n return (\n <QueryClientProvider client={queryClient}>\n <FronteraRoot\n mode={connection.mode}\n session={connection.session}\n providers={providers}\n errorFallback={errorFallback}\n >\n {children}\n </FronteraRoot>\n </QueryClientProvider>\n )\n}\n\nexport async function createFronteraApp(\n app: ReactNode,\n options: CreateFronteraAppOptions = {},\n): Promise<void> {\n const rootId = options.rootId ?? 'root'\n const element = document.getElementById(rootId)\n if (!element) {\n throw new Error(`createFronteraApp: no #${rootId} element — check index.html.`)\n }\n\n const root = createRoot(element)\n\n root.render(\n <StrictMode>\n <FronteraAppProvider\n providers={options.providers}\n queryClient={options.queryClient}\n timeoutMs={options.timeoutMs}\n >\n {app}\n </FronteraAppProvider>\n </StrictMode>,\n )\n}\n",
|
|
7
|
+
"frontera/core/create-frontera-app.tsx": "import {\n Component,\n StrictMode,\n createContext,\n useCallback,\n useContext,\n useEffect,\n useState,\n type ErrorInfo,\n type ReactNode,\n} from 'react'\nimport { createRoot } from 'react-dom/client'\nimport { QueryClient, QueryClientProvider } from '@tanstack/react-query'\n\nimport { connectToHost, type BridgeSession } from './bridge-client'\nimport { viewerOf, type BridgeInit, type FronteraViewer } from './bridge-protocol'\nimport { FronteraClient } from './client'\nimport { readRuntimeGlobal } from './config'\nimport {\n connectToSessionEndpoint,\n detectAppMode,\n type FronteraAppMode,\n} from './app-session'\nimport { applyHostTheme } from './theme'\nimport {\n connectExternalSession,\n isAccessRefusal,\n type AccessRefusal,\n type AccessRefusalReason,\n type ExternalAppSessionConfig,\n} from './external-session'\n\n/**\n * The entry point every app shares.\n *\n * Before this existed, the quickstart asked each author to hand-write the same\n * thirty-five lines: find the parent origin, postMessage `frontera:ready`,\n * listen for `frontera:init`, build a client from the token that arrives, and\n * only then mount. Copied boilerplate is where the origin check gets dropped —\n * and a missing origin check is the whole vulnerability class this architecture\n * exists to close. So it is written once, here.\n *\n * Mounting is DEFERRED until the handshake completes. An app has no data scope\n * until the host tells it who it is, so rendering earlier only produces a flash\n * of unauthorized requests, and every hook would need a \"not ready yet\" branch.\n */\n\nexport interface FronteraAppValue {\n mode: FronteraAppMode\n /** Everything the host said at handshake, including `path`. */\n init: BridgeInit\n client: FronteraClient\n /** Whether this app is the visible platform surface. */\n active: boolean\n /** Report local work explicitly; unreported state remains unknown to the host. */\n setUnsavedChanges(dirty: boolean): void\n /** Ask the platform to change its URL — the counterpart to `init.path`. */\n navigate(path: string): void\n /**\n * Who opened the app, from the host at handshake — or null when the host\n * did not say (an older host, or no person, as with a CLI key). Use it to\n * show and prefill; never to decide what the person may do.\n */\n viewer: FronteraViewer | null\n /**\n * True when the host says this frame shows a preview deployment rather than\n * production. Data is real either way; use it to show a \"Preview\" marker.\n */\n isPreview: boolean\n}\n\nconst FronteraAppReactContext = createContext<FronteraAppValue | null>(null)\n\nexport function useFronteraApp(): FronteraAppValue {\n const value = useContext(FronteraAppReactContext)\n if (!value) {\n throw new Error(\n 'useFronteraApp() outside a Frontera app — createFronteraApp() installs this provider.',\n )\n }\n return value\n}\n\n/**\n * The person who opened the app, or null when the host did not say.\n *\n * Shorthand for `useFronteraApp().viewer`: the common read, so an app does not\n * have to ask a person to type who they are.\n */\nexport function useViewer(): FronteraViewer | null {\n return useFronteraApp().viewer\n}\n\n/**\n * Whether the App is open from a preview deployment, as the host said at\n * handshake. Shorthand for `useFronteraApp().isPreview`.\n */\nexport function useIsPreview(): boolean {\n return useFronteraApp().isPreview\n}\n\n/**\n * A wrapper contributed by a domain package.\n *\n * This is a callback rather than `createFronteraApp` importing the domain\n * clients directly, because `@frontera-sdk/blueprint` already depends on\n * `@frontera-sdk/core` — reaching back the other way would make the two\n * packages mutually dependent. `blueprintProvider` from\n * `@frontera-sdk/blueprint/provider` is one of these.\n */\nexport type FronteraProvider = (value: FronteraAppValue, children: ReactNode) => ReactNode\n\nexport interface CreateFronteraAppOptions {\n /** Element id to mount into. Defaults to `root`. */\n rootId?: string\n providers?: FronteraProvider[]\n queryClient?: QueryClient\n /** How long to wait for the host before reporting a failed mount. */\n timeoutMs?: number\n}\n\ninterface BoundaryProps {\n onError: (error: Error, info: ErrorInfo) => void\n fallback?: (error: Error) => ReactNode\n children: ReactNode\n}\n\n/**\n * Report a render crash to the host instead of leaving a blank frame.\n *\n * The platform listens for `frontera:error` and surfaces it next to the app, so\n * the author sees the message rather than an empty rectangle they have to open\n * devtools to explain.\n */\nclass AppErrorBoundary extends Component<BoundaryProps, { error: Error | null }> {\n state: { error: Error | null } = { error: null }\n\n static getDerivedStateFromError(error: Error) {\n return { error }\n }\n\n componentDidCatch(error: Error, info: ErrorInfo) {\n this.props.onError(error, info)\n }\n\n render() {\n if (!this.state.error) return this.props.children\n return this.props.fallback?.(this.state.error) ?? diagnostic('This app stopped rendering', this.state.error.message)\n }\n}\n\n/**\n * The keyframes the boot screen needs.\n *\n * Inlined as a <style> element rather than a CSS import because this package\n * is consumed as source by app bundlers that are not all configured to handle\n * a stylesheet import, and a boot screen must never be the thing that breaks\n * the build it is meant to report on.\n */\nconst BOOT_KEYFRAMES =\n '@keyframes frontera-boot-appear{to{opacity:1}}' +\n '@keyframes frontera-boot-sweep{0%{transform:translateX(-100%)}100%{transform:translateX(250%)}}' +\n '@media(prefers-reduced-motion:reduce){[data-frontera-boot-bar]{animation:none!important;width:100%!important;opacity:.4}}'\n\n/**\n * Colours for the boot screen.\n *\n * Tokens, with literals only for the app that defines none of them.\n *\n * This paints in whatever scheme the document is already in, which before the\n * handshake means the app's own `:root` — light, unless the app itself has put\n * `.dark` on the document. Nothing here sets the scheme: `applyHostTheme` runs\n * at handshake, by which point this screen is gone, and a guess made earlier\n * would be a write that outlives the screen and fights the app for control of\n * its own theme.\n *\n * There is deliberately no `foreground` entry. In the platform theme\n * `--foreground` holds a bare HSL triplet (`47 13% 14%`) rather than a colour —\n * it is wrapped as `hsl(var(--foreground))` at the point of use — and Tailwind's\n * `@theme inline` does not emit a `--color-foreground` custom property to reach\n * for instead. Naming either one here produces an invalid declaration that the\n * browser drops. Text therefore INHERITS, which lands on the app's own body\n * colour: already correct, in either scheme, with nothing to keep in sync.\n */\nconst BOOT_PALETTE = {\n // `--surface`, not `--background`: this screen sits where the app's own\n // surface will be, so booting on the surface colour means the handover to\n // the real UI is not also a change of backdrop. It needs no `--color-*`\n // form — unlike the two below it, `--surface` is already a colour.\n background: 'var(--surface, #fafafa)',\n muted: 'var(--muted-foreground, #6b7280)',\n border: 'var(--border, #e5e7eb)',\n surface: 'var(--surface-raised, var(--surface, #fafafa))',\n destructive: 'var(--destructive, #dc2626)',\n}\n\n/**\n * The screen shown while the session is being established: the app's own\n * surface with a thin bar sweeping along its top edge, and no words. Most\n * boots finish before the bar fades in, so a fast start shows only the\n * surface the app is about to paint over; a slow one shows that it is still\n * working without asking anyone to read about sessions.\n */\nfunction bootLoading(): ReactNode {\n return (\n <div\n // A status, not an alert: `alert` is assertive and interrupts the\n // screen-reader user on every single app boot.\n role=\"status\"\n aria-label=\"Loading\"\n style={{\n boxSizing: 'border-box',\n position: 'relative',\n minHeight: '100dvh',\n overflow: 'hidden',\n background: BOOT_PALETTE.background,\n }}\n >\n <style>{BOOT_KEYFRAMES}</style>\n <div\n aria-hidden=\"true\"\n style={{\n position: 'absolute',\n inset: '0 0 auto 0',\n height: 2,\n opacity: 0,\n animation: 'frontera-boot-appear 200ms ease-out 300ms forwards',\n }}\n >\n <div\n data-frontera-boot-bar=\"\"\n style={{\n width: '40%',\n height: '100%',\n borderRadius: 2,\n background: BOOT_PALETTE.muted,\n opacity: 0.6,\n animation: 'frontera-boot-sweep 1.2s cubic-bezier(.4,0,.2,1) infinite',\n }}\n />\n </div>\n </div>\n )\n}\n\n/**\n * The screen shown when an app cannot render: it failed to start, or it\n * threw while rendering.\n *\n * Colours come from `BOOT_PALETTE` rather than being written inline, because\n * this can paint BEFORE `applyHostTheme` has run and the values have to hold\n * up without it.\n */\nfunction diagnostic(title: string, detail: string): ReactNode {\n return (\n <div\n role=\"alert\"\n style={{\n boxSizing: 'border-box',\n minHeight: '100dvh',\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'center',\n padding: 32,\n fontFamily: 'var(--font-sans, system-ui, sans-serif)',\n background: BOOT_PALETTE.background,\n lineHeight: 1.5,\n }}\n >\n <div\n style={{\n display: 'flex',\n flexDirection: 'column',\n alignItems: 'center',\n // The two gaps differ: 10 between the lines of text, 16 from the\n // icon down to them. `gap` sets the smaller one for every pair and\n // the icon adds the remainder below itself.\n gap: 10,\n textAlign: 'center',\n maxWidth: 380,\n }}\n >\n <svg\n aria-hidden=\"true\"\n viewBox=\"0 0 24 24\"\n width={30}\n height={30}\n fill=\"none\"\n stroke={BOOT_PALETTE.destructive}\n strokeWidth={2}\n strokeLinecap=\"round\"\n style={{ marginBottom: 6 }}\n >\n <circle cx=\"12\" cy=\"12\" r=\"9\" />\n <path d=\"M12 8v5\" />\n <path d=\"M12 16.5h.01\" />\n </svg>\n <div style={{ fontWeight: 'bold' as const }}>{title}</div>\n <div\n style={{\n fontSize: 14,\n color: BOOT_PALETTE.muted,\n // An error detail is a raw message or stack fragment: monospaced,\n // left-aligned and boxed so a long one stays readable instead of\n // becoming a centred wall of text.\n fontFamily: 'var(--font-mono, ui-monospace, monospace)',\n textAlign: 'left' as const,\n background: BOOT_PALETTE.surface,\n border: `1px solid ${BOOT_PALETTE.border}`,\n borderRadius: 'calc(var(--radius, 8px) - 2px)',\n padding: '10px 12px',\n maxWidth: '100%',\n overflowWrap: 'anywhere' as const,\n }}\n >\n {detail}\n </div>\n </div>\n </div>\n )\n}\n\n/**\n * What a person Frontera refused reads, one entry per refusal of the person.\n * Plain and calm: this is not a crash, and the next step is someone else's.\n */\nconst ACCESS_COPY: Record<AccessRefusalReason, { title: string; detail: string }> = {\n no_role: {\n title: \"You don't have access yet\",\n detail: \"You don't have access to this App yet. Ask your administrator to give you a role.\",\n },\n not_admitted: {\n title: \"You can't use this App\",\n detail: \"Your account isn't allowed to sign in to this App. If you need it, ask your administrator.\",\n },\n disabled: {\n title: 'Your access is turned off',\n detail: 'Your access to this App has been turned off. Ask your administrator if you need it back.',\n },\n not_linked: {\n title: \"We couldn't find your record\",\n detail: \"This App couldn't match you to a record. Ask your administrator to link your account.\",\n },\n link_ambiguous: {\n title: 'Your account needs a quick fix',\n detail: 'More than one record matches you. Ask your administrator to choose the right one.',\n },\n}\n\n/**\n * The default screen for a person Frontera refused to sign in: a title, one\n * sentence on what to do, and Try again. Deliberately unlike `diagnostic`\n * (no alert icon, no monospaced message) because nothing is\n * broken. The service's own message stays out: it is written for the App's\n * builder, not the person.\n */\nfunction accessRefusedScreen(error: AccessRefusal, retry: () => void): ReactNode {\n const copy = ACCESS_COPY[error.reason]\n return (\n <div\n role=\"status\"\n data-frontera-access-refused={error.reason}\n style={{\n boxSizing: 'border-box',\n minHeight: '100dvh',\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'center',\n padding: 32,\n fontFamily: 'var(--font-sans, system-ui, sans-serif)',\n background: BOOT_PALETTE.background,\n lineHeight: 1.5,\n }}\n >\n <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, textAlign: 'center', maxWidth: 380 }}>\n <svg\n aria-hidden=\"true\"\n viewBox=\"0 0 24 24\"\n width={30}\n height={30}\n fill=\"none\"\n stroke={BOOT_PALETTE.muted}\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n style={{ marginBottom: 6 }}\n >\n <rect x=\"5\" y=\"11\" width=\"14\" height=\"10\" rx=\"2\" />\n <path d=\"M8 11V7a4 4 0 0 1 8 0v4\" />\n </svg>\n <div style={{ fontWeight: 'bold' as const, fontSize: 18 }}>{copy.title}</div>\n <div style={{ fontSize: 14, color: BOOT_PALETTE.muted }}>{copy.detail}</div>\n <button\n type=\"button\"\n onClick={retry}\n style={{\n marginTop: 6,\n padding: '6px 14px',\n font: 'inherit',\n fontSize: 14,\n color: 'inherit',\n background: BOOT_PALETTE.surface,\n border: `1px solid ${BOOT_PALETTE.border}`,\n borderRadius: 'calc(var(--radius, 8px) - 2px)',\n cursor: 'pointer',\n }}\n >\n Try again\n </button>\n </div>\n </div>\n )\n}\n\n/**\n * Holds what the host can change underneath a running app.\n *\n * The credential is state rather than a captured constant because the host\n * rotates it mid-session; `withCredential` returns a NEW client so requests\n * already in flight keep the one they started with.\n */\nfunction FronteraRoot({\n session,\n mode,\n children,\n providers,\n errorFallback,\n}: {\n session: BridgeSession\n mode: FronteraAppMode\n children: ReactNode\n providers: FronteraProvider[]\n errorFallback?: (error: Error) => ReactNode\n}) {\n const { init } = session\n const [client, setClient] = useState(\n () =>\n new FronteraClient({\n apiBaseUrl: init.apiBaseUrl,\n orgId: init.orgId ?? undefined,\n workspaceId: init.workspaceId ?? undefined,\n credential: { kind: 'token', token: init.token },\n }, session.fetchImpl),\n )\n\n useEffect(\n () => session.onToken((token) => setClient((c) => c.withCredential({ kind: 'token', token }))),\n [session],\n )\n\n useEffect(() => session.onTheme((theme) => applyHostTheme(theme)), [session])\n const [active, setActive] = useState(init.active ?? true)\n useEffect(() => session.onActivation(setActive), [session])\n const setUnsavedChanges = useCallback((dirty: boolean) => {\n if (mode === 'embedded') session.send({ type: 'frontera:unsaved', dirty })\n }, [mode, session])\n\n const value: FronteraAppValue = {\n mode,\n init,\n client,\n active,\n setUnsavedChanges,\n navigate: (path) => {\n if (mode === 'embedded') session.send({ type: 'frontera:navigate', path })\n },\n viewer: viewerOf(init.viewer),\n isPreview: init.deployment === 'preview',\n }\n\n let tree: ReactNode = children\n // Applied in reverse so the FIRST provider listed ends up outermost, which is\n // the order a reader expects from the array.\n for (const provider of [...providers].reverse()) tree = provider(value, tree)\n\n return (\n <FronteraAppReactContext.Provider value={value}>\n <AppErrorBoundary\n fallback={errorFallback}\n onError={(error) =>\n session.send({ type: 'frontera:error', message: error.message, stack: error.stack })\n }\n >\n {tree}\n </AppErrorBoundary>\n </FronteraAppReactContext.Provider>\n )\n}\n\nexport interface FronteraAppProviderProps {\n children: ReactNode\n providers?: FronteraProvider[]\n loading?: ReactNode\n /** Shown when the App cannot start or crashes while rendering. */\n errorFallback?: (error: Error) => ReactNode\n /**\n * External Apps only: shown instead of the App when Frontera refuses the\n * person, at start or when a later renewal is refused. `error.reason` is\n * one of `no_role`, `not_admitted`, `disabled`, `not_linked` or\n * `link_ambiguous`. `retry` signs in again. The default is a plain screen\n * that tells the person to ask their administrator. Every other sign-in\n * failure (a fault in the App's setup, `invalid_token`,\n * `issuer_unreachable`, no reason) still reaches `errorFallback`.\n */\n accessDenied?: (error: AccessRefusal, retry: () => void) => ReactNode\n /** Compatibility hooks used by createFronteraApp. */\n queryClient?: QueryClient\n timeoutMs?: number\n /**\n * For an App hosted OUTSIDE Frontera: how its people sign in. Given, the\n * provider does not look for a platform frame or a session endpoint — it\n * exchanges the customer's identity-provider token (or calls your backend)\n * for a Frontera App-user token and renews it before it expires.\n */\n session?: ExternalAppSessionConfig\n}\n\n/**\n * Transport-neutral React entry point for embedded, standalone, and local Apps.\n * Mode selection happens before connection, so an ordinary standalone page\n * never waits for an iframe handshake that cannot succeed.\n */\nexport function FronteraAppProvider({\n children,\n providers = [],\n loading = bootLoading(),\n errorFallback,\n accessDenied = accessRefusedScreen,\n queryClient: providedQueryClient,\n timeoutMs,\n session: externalSession,\n}: FronteraAppProviderProps): ReactNode {\n const [connection, setConnection] = useState<{\n mode: FronteraAppMode\n session: BridgeSession\n } | null>(null)\n const [error, setError] = useState<Error | null>(null)\n const [queryClient] = useState(\n () =>\n providedQueryClient ??\n new QueryClient({\n defaultOptions: { queries: { refetchOnWindowFocus: false, retry: 1 } },\n }),\n )\n // Bumped by Try again on the access screen: signs in from scratch. The\n // cache is emptied first, so the App never shows rows read by the session\n // that was refused.\n const [attempt, setAttempt] = useState(0)\n const retry = useCallback(() => {\n queryClient.clear()\n setConnection(null)\n setError(null)\n setAttempt((n) => n + 1)\n }, [queryClient])\n\n useEffect(() => {\n const runtime = readRuntimeGlobal()\n const framed = typeof window !== 'undefined' && window.parent !== window\n // An externally hosted App names its session explicitly; nothing else is\n // consulted, so a stray frame or leftover global cannot redirect it.\n const mode = externalSession ? 'standalone' : detectAppMode(runtime, framed)\n if (!mode) {\n // Reachable only when NOT framed: `detectAppMode` returns 'embedded' for\n // any framed document, so a null mode means there is no parent and no\n // session endpoint either. A framed App that cannot resolve a parent\n // origin fails later and more precisely, inside `connectToHost`.\n setError(\n new Error(\n 'Frontera App is not configured: no embedded host, standalone session endpoint, or local development session endpoint was provided.',\n ),\n )\n return\n }\n\n let active = true\n let sessionToDispose: BridgeSession | null = null\n const connected: Promise<BridgeSession> = externalSession\n ? connectExternalSession(externalSession).then((session) => {\n // Mid-session: signing in again was refused (a role removed, the\n // person disabled). The App's data is no longer theirs to see.\n session.onRefused((refusal) => {\n if (!active) return\n session.dispose()\n queryClient.clear()\n setConnection(null)\n setError(refusal)\n })\n return session\n })\n : mode === 'embedded'\n ? connectToHost({ parentOrigin: runtime.platformOrigin, timeoutMs })\n : connectToSessionEndpoint(\n mode === 'standalone' ? runtime.sessionEndpoint! : runtime.devSessionEndpoint!,\n )\n void connected\n .then((session) => {\n sessionToDispose = session\n if (!active) {\n session.dispose()\n return\n }\n applyHostTheme(session.init.theme)\n setConnection({ mode, session })\n })\n .catch((reason) => {\n if (active) setError(reason instanceof Error ? reason : new Error(String(reason)))\n })\n\n return () => {\n active = false\n sessionToDispose?.dispose()\n }\n // Read once at mount, like the runtime global: a session is established\n // for the page's life, and a new config object each render must not\n // reconnect. Only Try again (`attempt`) starts another.\n }, [attempt])\n\n if (isAccessRefusal(error)) return accessDenied(error, retry)\n if (error) return errorFallback?.(error) ?? diagnostic('Frontera App could not start', error.message)\n if (!connection) return loading\n\n return (\n <QueryClientProvider client={queryClient}>\n <FronteraRoot\n mode={connection.mode}\n session={connection.session}\n providers={providers}\n errorFallback={errorFallback}\n >\n {children}\n </FronteraRoot>\n </QueryClientProvider>\n )\n}\n\nexport async function createFronteraApp(\n app: ReactNode,\n options: CreateFronteraAppOptions = {},\n): Promise<void> {\n const rootId = options.rootId ?? 'root'\n const element = document.getElementById(rootId)\n if (!element) {\n throw new Error(`createFronteraApp: no #${rootId} element — check index.html.`)\n }\n\n const root = createRoot(element)\n\n root.render(\n <StrictMode>\n <FronteraAppProvider\n providers={options.providers}\n queryClient={options.queryClient}\n timeoutMs={options.timeoutMs}\n >\n {app}\n </FronteraAppProvider>\n </StrictMode>,\n )\n}\n",
|
|
8
8
|
"frontera/core/external-session.ts": "import type { BridgeSession } from './bridge-client'\nimport type { BridgeInit } from './bridge-protocol'\n\n/**\n * A session for an App hosted OUTSIDE Frontera, whose people sign in with the\n * customer's own identity provider (design docs/specs/2026-09-24-app-users-design.md\n * §7, §15).\n *\n * Two ways to get the Frontera App-user token, and the App picks one:\n *\n * - `getSubjectToken` — the browser holds the customer's identity-provider\n * token (their OIDC client already signed the person in) and exchanges it\n * directly at `POST /v1/app-users/token`.\n * - `sessionEndpoint` — the App's own backend makes the exchange with an App\n * key and returns `{ token, expiresAt }`. The key never reaches the page.\n *\n * Either way the page only ever holds the short-lived App-user token, renewed\n * before it expires.\n */\nexport interface ExternalAppSessionConfig {\n /** Frontera's API origin, e.g. `https://api.frontera.example`. */\n apiBaseUrl: string\n /** The External App's id. */\n appId: string\n /** Which of the App's audiences this page signs people into. Default `operator`. */\n audience?: 'operator' | 'customer'\n /** The customer's current identity-provider token (browser exchange). */\n getSubjectToken?: () => Promise<string>\n /** Or: your backend route that exchanges with an App key and returns `{ token, expiresAt }`. */\n sessionEndpoint?: string\n}\n\nexport interface AppUserSummary {\n id: string\n displayName: string | null\n audience: 'operator' | 'customer'\n roles: string[]\n attributes: Record<string, unknown>\n businessObject: { objectType: string; id: string } | null\n}\n\nexport interface ExchangedToken {\n token: string\n /**\n * Epoch ms, on THIS machine's clock: taken from the service's `expiresIn`\n * when it sends one, so a browser clock that is off never makes renewal loop.\n */\n expiresAt: number\n appUser?: AppUserSummary\n}\n\nexport class AppUserSignInError extends Error {\n constructor(\n message: string,\n /** Frontera's stable reason, e.g. `not_admitted`, `not_linked`, `disabled`. */\n readonly reason: string | null,\n readonly status: number,\n ) {\n super(message)\n this.name = 'AppUserSignInError'\n }\n}\n\n/**\n * The refusals that are about the person: Frontera knows who they are and\n * will not let them in. Signing in again gives the same answer until an\n * administrator changes something, so a running session stops renewing and\n * the App shows the refusal.\n *\n * An allow-list, so a reason added to the service later is not shown to\n * people as \"you have no access\" by accident. Everything else is the App's\n * to handle as before: a fault in the App's setup (`app_not_found`,\n * `audience_disabled`, `key_invalid`, `audience_misconfigured`), a token that\n * did not verify (`invalid_token`: the App may need to send the person to its\n * login), the identity provider being down (`issuer_unreachable`), and a\n * failure with no reason (a rate limit, a server fault, the App's own session\n * endpoint).\n */\nexport const ACCESS_REFUSAL_REASONS = ['no_role', 'not_admitted', 'disabled', 'not_linked', 'link_ambiguous'] as const\nexport type AccessRefusalReason = (typeof ACCESS_REFUSAL_REASONS)[number]\nexport type AccessRefusal = AppUserSignInError & { readonly reason: AccessRefusalReason }\n\nexport function isAccessRefusal(error: unknown): error is AccessRefusal {\n return error instanceof AppUserSignInError && (ACCESS_REFUSAL_REASONS as readonly (string | null)[]).includes(error.reason)\n}\n\ntype FetchImpl = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>\n\nfunction unwrap(body: unknown): Record<string, unknown> {\n const envelope = body as { data?: unknown } | null\n const data = envelope && typeof envelope === 'object' && 'data' in envelope ? envelope.data : body\n return (data && typeof data === 'object' ? data : {}) as Record<string, unknown>\n}\n\nfunction expiryOf(value: unknown): number {\n const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Date.parse(value) : Number.NaN\n if (!Number.isFinite(parsed)) throw new Error('Frontera sign-in response has an invalid expiresAt')\n return parsed\n}\n\nasync function readToken(response: Response, now: () => number = Date.now): Promise<ExchangedToken> {\n const body = await response.json().catch(() => null) as Record<string, unknown> | null\n if (!response.ok) {\n const details = (body?.details ?? null) as { reason?: string } | null\n throw new AppUserSignInError(\n typeof body?.message === 'string' ? body.message : `Frontera sign-in failed with ${response.status}`,\n details?.reason ?? null,\n response.status,\n )\n }\n const data = unwrap(body)\n if (typeof data.token !== 'string') throw new Error('Frontera sign-in response has no token')\n return {\n token: data.token,\n expiresAt: typeof data.expiresIn === 'number' && Number.isFinite(data.expiresIn)\n ? now() + data.expiresIn * 1000\n : expiryOf(data.expiresAt),\n ...(data.appUser ? { appUser: data.appUser as AppUserSummary } : {}),\n }\n}\n\n/** Exchange a customer identity-provider token for a Frontera App-user token. */\nexport async function exchangeCustomerToken(\n input: { apiBaseUrl: string; appId: string; audience?: 'operator' | 'customer'; subjectToken: string },\n fetchImpl: FetchImpl = fetch,\n now: () => number = Date.now,\n): Promise<ExchangedToken> {\n const response = await fetchImpl(`${input.apiBaseUrl.replace(/\\/+$/, '')}/v1/app-users/token`, {\n method: 'POST',\n headers: { 'content-type': 'application/json', accept: 'application/json' },\n body: JSON.stringify({\n grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',\n app: input.appId,\n audience: input.audience ?? 'operator',\n subject_token: input.subjectToken,\n subject_token_type: 'urn:ietf:params:oauth:token-type:jwt',\n }),\n })\n return readToken(response, now)\n}\n\nasync function nextToken(config: ExternalAppSessionConfig, fetchImpl: FetchImpl, now: () => number): Promise<ExchangedToken> {\n if (config.getSubjectToken) {\n return exchangeCustomerToken({\n apiBaseUrl: config.apiBaseUrl,\n appId: config.appId,\n audience: config.audience,\n subjectToken: await config.getSubjectToken(),\n }, fetchImpl, now)\n }\n if (config.sessionEndpoint) {\n const response = await fetchImpl(config.sessionEndpoint, { credentials: 'include', headers: { accept: 'application/json' } })\n return readToken(response, now)\n }\n throw new Error('An external App session needs getSubjectToken or sessionEndpoint.')\n}\n\nexport interface ExternalSessionOptions {\n fetchImpl?: FetchImpl\n now?: () => number\n setTimer?: (callback: () => void, delay: number) => unknown\n clearTimer?: (timer: unknown) => void\n}\n\n/** Never renew sooner than this after a token arrives. */\nconst MIN_RENEW_DELAY_MS = 30_000\n/** After a failed sign-in, wait this long before the next, doubling to the cap. */\nconst FIRST_BACKOFF_MS = 30_000\nconst MAX_BACKOFF_MS = 5 * 60_000\n\nexport interface ExternalSession extends BridgeSession {\n appUser: AppUserSummary | null\n /**\n * Called once when signing in again is refused for the person's own reason\n * (see `isAccessRefusal`). The session has stopped renewing by then.\n */\n onRefused(handler: (error: AccessRefusal) => void): () => void\n}\n\n/**\n * A bridge-compatible session for an externally hosted App: signs in once,\n * then renews the App-user token at 80% of its life. A request refused with\n * 401 signs in again once. A failed sign-in is remembered: until its backoff\n * passes, no request triggers another, so an unreachable identity provider\n * costs one attempt per backoff step, not one per request. Only a refusal of\n * the person (`isAccessRefusal`: no role, disabled, …) ends renewal permanently\n * and is reported to `onRefused`.\n */\nexport async function connectExternalSession(\n config: ExternalAppSessionConfig,\n options: ExternalSessionOptions = {},\n): Promise<ExternalSession> {\n const fetchImpl = options.fetchImpl ?? fetch\n const now = options.now ?? Date.now\n const setTimer = options.setTimer ?? ((callback, delay) => setTimeout(callback, delay))\n const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle as ReturnType<typeof setTimeout>))\n\n const first = await nextToken(config, fetchImpl, now)\n const tokenHandlers = new Set<(token: string) => void>()\n const refusalHandlers = new Set<(error: AccessRefusal) => void>()\n let refused: AccessRefusal | null = null\n let disposed = false\n let timer: unknown\n let currentToken = first.token\n // One renewal at a time: several requests refused together share it.\n let renewing: Promise<string | null> | null = null\n let failures = 0\n let retryAfter = 0\n\n const publish = (token: string) => {\n currentToken = token\n for (const handler of tokenHandlers) handler(token)\n }\n\n // One timer at a time: scheduling replaces any pending one, so a renewal\n // from a 401 and a scheduled one can never leave two chains running.\n const scheduleIn = (delay: number) => {\n if (timer !== undefined) clearTimer(timer)\n timer = undefined\n if (disposed) return\n timer = setTimer(() => {\n timer = undefined\n void renewNow()\n }, delay)\n }\n const schedule = (expiresAt: number) => scheduleIn(Math.max(MIN_RENEW_DELAY_MS, Math.floor((expiresAt - now()) * 0.8)))\n\n /**\n * Sign in again now: on schedule, or because the service refused the current\n * token (revoked, or settings changed). Single-flight — concurrent callers\n * share one exchange.\n */\n const renewNow = (): Promise<string | null> => {\n if (disposed || refused) return Promise.resolve(null)\n if (renewing) return renewing\n if (now() < retryAfter) return Promise.resolve(null)\n renewing = nextToken(config, fetchImpl, now)\n .then((next) => {\n if (disposed) return null\n failures = 0\n retryAfter = 0\n publish(next.token)\n schedule(next.expiresAt)\n return next.token\n })\n .catch((error: unknown) => {\n if (disposed) return null\n if (isAccessRefusal(error)) {\n refused = error\n if (timer !== undefined) clearTimer(timer)\n timer = undefined\n for (const handler of refusalHandlers) handler(error)\n return null\n }\n const backoff = Math.min(MAX_BACKOFF_MS, FIRST_BACKOFF_MS * 2 ** failures)\n failures++\n retryAfter = now() + backoff\n scheduleIn(backoff)\n return null\n })\n .finally(() => { renewing = null })\n return renewing\n }\n schedule(first.expiresAt)\n\n /**\n * Requests to Frontera go through here. A 401 on a request that carried the\n * App-user token means it was refused before it expired — the person was\n * disabled, or the audience's settings changed. Sign in again once and retry\n * the request with the new token; a second refusal stands.\n */\n const apiFetch: typeof fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {\n const response = await fetchImpl(input, init)\n const headers = new Headers(init?.headers)\n const sent = headers.get('authorization')\n // Only a request that carried an App-user token is ours to retry.\n if (response.status !== 401 || !sent?.startsWith('Bearer sk-au-')) return response\n const renewed = await renewNow()\n if (!renewed) return response\n headers.set('authorization', `Bearer ${renewed}`)\n return fetchImpl(input, { ...init, headers })\n }) as typeof fetch\n\n const init: BridgeInit = {\n type: 'frontera:init',\n token: first.token,\n apiBaseUrl: config.apiBaseUrl.replace(/\\/+$/, ''),\n appId: config.appId,\n version: 'external',\n // The App-user token names its organization and workspace itself; the\n // service reads them from the token, never from a header.\n orgId: null,\n workspaceId: null,\n theme: { tokens: {} },\n state: {},\n }\n\n return {\n init,\n appUser: first.appUser ?? null,\n fetchImpl: apiFetch,\n send() {},\n onState() {\n return () => {}\n },\n onToken(handler) {\n tokenHandlers.add(handler)\n return () => tokenHandlers.delete(handler)\n },\n onRefused(handler) {\n if (refused) {\n handler(refused)\n return () => {}\n }\n refusalHandlers.add(handler)\n return () => refusalHandlers.delete(handler)\n },\n onTheme() {\n return () => {}\n },\n onActivation() {\n return () => {}\n },\n dispose() {\n disposed = true\n if (timer !== undefined) clearTimer(timer)\n tokenHandlers.clear()\n refusalHandlers.clear()\n },\n }\n}\n",
|
|
9
9
|
"frontera/core/client.ts": "import { resolveConfig, type FronteraConfig, type FronteraCredential } from './config'\nimport {\n httpRequest,\n httpRequestEnvelope,\n httpRequestRaw,\n type RequestOptions,\n} from './transport'\n\n/**\n * The transport-level entry point. Domain clients (`@frontera-sdk/blueprint`, and\n * later chat and automation) take one of these rather than reimplementing\n * auth, URL building, or error mapping.\n *\n * `withCredential` exists because a bridge-hosted app receives a refreshed\n * token mid-session: swapping produces a new client rather than mutating one\n * that in-flight requests may still hold.\n */\nexport class FronteraClient {\n readonly config: FronteraConfig\n private readonly fetchImpl: typeof fetch\n\n constructor(config: Partial<FronteraConfig>, fetchImpl: typeof fetch = fetch) {\n this.config = resolveConfig(config)\n this.fetchImpl = fetchImpl\n }\n\n request<T>(path: string, options: RequestOptions = {}): Promise<T> {\n return httpRequest<T>(this.config, path, options, this.fetchImpl)\n }\n\n /**\n * Same auth and error mapping as `request`, but the 2xx `Response` comes back\n * with its body unread — for SSE and other non-JSON payloads. Domain clients\n * stream through this rather than rebuilding the Authorization header.\n */\n requestRaw(path: string, options: RequestOptions = {}): Promise<Response> {\n return httpRequestRaw(this.config, path, options, this.fetchImpl)\n }\n\n /**\n * Same as `request` but skips the envelope unwrap, so paginated routes keep\n * `nextCursor` (which sits BESIDE `data` in the envelope and is otherwise\n * dropped).\n */\n requestEnvelope<T>(path: string, options: RequestOptions = {}): Promise<T> {\n return httpRequestEnvelope<T>(this.config, path, options, this.fetchImpl)\n }\n\n withCredential(credential: FronteraCredential): FronteraClient {\n return new FronteraClient({ ...this.config, credential }, this.fetchImpl)\n }\n}\n",
|
|
10
10
|
"frontera/core/bridge-protocol.ts": "/**\n * postMessage protocol between the platform host and an app iframe.\n *\n * The app runs on its own origin (`<appId>.apps.<domain>`), so postMessage is\n * the ONLY channel. Both sides validate `event.origin` on every message: Luigi,\n * a mature framework with this same architecture, carries an open issue for\n * omitting exactly that check, and the consequence is any page being able to\n * drive an embedded app.\n */\n\nexport interface BridgeInit {\n type: 'frontera:init'\n /** Scoped API credential. Arrives here, never in a URL. */\n token: string\n apiBaseUrl: string\n appId: string\n version: string\n orgId: string | null\n workspaceId: string | null\n /**\n * The host's palette and light/dark state.\n *\n * Defaults, not a house style: `applyHostTheme` writes them behind the app's\n * own stylesheets so a customer's brand still wins.\n */\n theme: { tokens: Record<string, string>; colorScheme?: 'light' | 'dark'; fontCss?: string }\n /** Current shared app state, already validated by the host. */\n state: Record<string, unknown>\n /**\n * Where the app should open, taken from the platform URL.\n *\n * The counterpart to `frontera:navigate`: without it a shared link to\n * `/apps/<slug>/shipments` mounts the app at its home page, and the address\n * bar describes a view the user is not looking at. Absent means the app's\n * own default.\n */\n path?: string\n active?: boolean\n /**\n * The person who opened the app: what it may SHOW and use as a default —\n * \"you\", \"assigned to me\", an owner field — without asking them to type\n * who they are. `email` is the account email and may be null. Absent on a\n * host that predates it and for an app hosted outside Frontera; null when no\n * person is involved (a CLI key). Never an authorization input: what the app\n * may do is decided server-side from the token.\n */\n viewer?: FronteraViewer | null\n /**\n * Whether the frame shows the App's production deployment or a preview (a\n * branch or session link, or one deployment opened by its own link). A\n * preview reads and writes the same workspace data as production; Action\n * requests it raises are labelled as coming from a preview. Absent on a\n * host that predates it: treat that as production.\n */\n deployment?: 'production' | 'preview'\n}\n\nexport interface FronteraViewer {\n userId: string\n name: string | null\n email: string | null\n}\n\n/** A well-formed viewer, or null. The host is trusted; the shape still is not assumed. */\nexport function viewerOf(value: unknown): FronteraViewer | null {\n if (!value || typeof value !== 'object') return null\n const v = value as Record<string, unknown>\n if (typeof v.userId !== 'string' || !v.userId) return null\n return {\n userId: v.userId,\n name: typeof v.name === 'string' && v.name.trim() ? v.name : null,\n email: typeof v.email === 'string' && v.email.includes('@') ? v.email : null,\n }\n}\n\nexport type HostMessage =\n | BridgeInit\n | { type: 'frontera:host-ready' }\n | { type: 'frontera:host-navigate'; requestId: string; path: string }\n | { type: 'frontera:activation'; active: boolean }\n | { type: 'frontera:state'; state: Record<string, unknown> }\n | {\n type: 'frontera:theme'\n tokens: Record<string, string>\n colorScheme?: 'light' | 'dark'\n fontCss?: string\n }\n | { type: 'frontera:token'; token: string }\n\nexport type AppMessage =\n | { type: 'frontera:ready' }\n | { type: 'frontera:route-ready'; protocol: 1 }\n | { type: 'frontera:navigate'; path: string; mode?: 'push' | 'replace' | 'traverse' | 'hash' | 'load' }\n | { type: 'frontera:navigation-ack'; requestId: string; path: string }\n | { type: 'frontera:unsaved'; dirty: boolean }\n | { type: 'frontera:resize'; height: number }\n | { type: 'frontera:state-write'; patch: Record<string, unknown> }\n | { type: 'frontera:error'; message: string; stack?: string }\n | { type: 'frontera:state-repair'; keys: string[] }\n\nexport function isHostMessage(data: unknown): data is HostMessage {\n if (!data || typeof data !== 'object') return false\n const m = data as Record<string, unknown>\n switch (m.type) {\n case 'frontera:host-ready':\n return true\n case 'frontera:host-navigate':\n return typeof m.requestId === 'string' && isAppPath(m.path)\n case 'frontera:activation':\n return typeof m.active === 'boolean'\n case 'frontera:init':\n return typeof m.token === 'string' && typeof m.apiBaseUrl === 'string' && typeof m.appId === 'string'\n case 'frontera:state':\n return typeof m.state === 'object' && m.state !== null\n case 'frontera:theme':\n return typeof m.tokens === 'object' && m.tokens !== null\n case 'frontera:token':\n return typeof m.token === 'string'\n default:\n return false\n }\n}\n\nexport function isAppMessage(data: unknown): data is AppMessage {\n if (!data || typeof data !== 'object') return false\n const m = data as Record<string, unknown>\n switch (m.type) {\n case 'frontera:unsaved':\n return typeof m.dirty === 'boolean'\n case 'frontera:route-ready':\n return m.protocol === 1\n case 'frontera:navigation-ack':\n return typeof m.requestId === 'string' && isAppPath(m.path)\n case 'frontera:ready':\n return true\n case 'frontera:navigate':\n return isAppPath(m.path) && (m.mode === undefined || ['push', 'replace', 'traverse', 'hash', 'load'].includes(m.mode as string))\n case 'frontera:resize':\n return typeof m.height === 'number' && Number.isFinite(m.height)\n case 'frontera:state-write':\n return typeof m.patch === 'object' && m.patch !== null\n case 'frontera:error':\n return typeof m.message === 'string'\n case 'frontera:state-repair':\n return Array.isArray(m.keys)\n default:\n return false\n }\n}\n\n/** App locations are relative to the isolated app origin. */\nfunction isAppPath(path: unknown): path is string {\n return typeof path === 'string' && path.startsWith('/') && !path.startsWith('//') && !path.includes('\\\\')\n}\n",
|