@frontera-sdk/cli 1.53.7 → 1.53.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/package.json +4 -4
  2. package/src/api/governed-action-api.ts +9 -3
  3. package/src/blueprint/action-rules.ts +175 -9
  4. package/src/blueprint/diff.ts +27 -8
  5. package/src/blueprint/ontology-edit-plan.ts +155 -4
  6. package/src/blueprint/org-name-check.ts +10 -8
  7. package/src/blueprint/projection.ts +13 -3
  8. package/src/blueprint/property-intents.ts +248 -0
  9. package/src/blueprint/publish-plan.ts +40 -4
  10. package/src/blueprint/render.ts +4 -0
  11. package/src/blueprint/tree.ts +22 -0
  12. package/src/commands/action/deploy.ts +31 -24
  13. package/src/commands/action/retire.ts +9 -1
  14. package/src/commands/action/submit.ts +21 -5
  15. package/src/commands/auth/login.ts +2 -2
  16. package/src/commands/blueprint/authoring.ts +22 -4
  17. package/src/commands/blueprint/declarative.ts +140 -32
  18. package/src/commands/knowledge/index-commands.ts +3 -4
  19. package/src/commands/local/check.ts +6 -4
  20. package/src/commands/local/index-commands.ts +8 -2
  21. package/src/commands/local/sync-request.ts +252 -66
  22. package/src/commands/local/sync.ts +29 -24
  23. package/src/device-login.ts +6 -1
  24. package/src/local/local-actions.ts +13 -1
  25. package/src/local/local-applied.ts +1 -1
  26. package/src/local/local-blueprint-schemas.ts +40 -6
  27. package/src/local/local-dev-references.ts +4 -3
  28. package/src/local/local-jobs.ts +28 -0
  29. package/src/local/local-portability.ts +1 -1
  30. package/src/local/local-sync-link.ts +62 -4
  31. package/src/local/local-sync-merge.ts +37 -1
  32. package/src/local/local-sync-plan.ts +44 -6
  33. package/src/local/local-sync-precheck.ts +2 -1
  34. package/src/local/local-sync-push.ts +22 -11
  35. package/src/local/local-sync-remote.ts +13 -8
  36. package/src/local/local-synthetic-data.ts +2 -2
  37. package/src/main.ts +7 -2
  38. package/src/vendor/sdk-sources.json +2 -2
@@ -1,5 +1,5 @@
1
1
  {
2
- "sdkVersion": "1.53.4",
2
+ "sdkVersion": "1.53.7",
3
3
  "files": {
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/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",
@@ -15,7 +15,7 @@
15
15
  "frontera/core/app-session.ts": "import type { BridgeSession } from './bridge-client'\nimport { isHostMessage, type BridgeInit } from './bridge-protocol'\nimport type { FronteraRuntimeGlobal } from './config'\n\nexport type FronteraAppMode = 'embedded' | 'standalone' | 'local'\n\n/**\n * Which transport this App should use.\n *\n * `framed` alone decides embedding — it is the only fact that answers \"is there\n * a parent to talk to\". `platformOrigin` is an INPUT to that conversation, not\n * evidence of it: `connectToHost` resolves the origin itself and falls back to\n * `document.referrer` when the host injected nothing.\n *\n * Requiring `platformOrigin` here made a framed App fall through to `null` and\n * report \"not configured\" — while the deployment was fine and only its\n * `CORS_ORIGIN` was `*`, which is the default. The App then hung before issuing\n * a single request, so nothing in the browser named the cause.\n */\nexport function detectAppMode(\n runtime: FronteraRuntimeGlobal,\n framed: boolean,\n): FronteraAppMode | null {\n if (framed) return 'embedded'\n if (runtime.sessionEndpoint) return 'standalone'\n if (runtime.devSessionEndpoint) return 'local'\n return null\n}\n\ninterface SessionResponse {\n init: BridgeInit\n expiresAt: number\n}\n\nexport interface EndpointSessionOptions {\n fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>\n now?: () => number\n setTimer?: (callback: () => void, delay: number) => unknown\n clearTimer?: (timer: unknown) => void\n}\n\nfunction parseExpiry(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 session response has an invalid expiresAt')\n return parsed\n}\n\nfunction parseSessionResponse(value: unknown): SessionResponse {\n const envelope = value as { data?: unknown } | null\n const raw = (envelope && typeof envelope === 'object' && 'data' in envelope ? envelope.data : value) as\n | Record<string, unknown>\n | null\n if (!raw || typeof raw !== 'object' || !isHostMessage(raw.init) || raw.init.type !== 'frontera:init') {\n throw new Error('Frontera session response is malformed')\n }\n return { init: raw.init, expiresAt: parseExpiry(raw.expiresAt) }\n}\n\nasync function fetchSession(\n endpoint: string,\n fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>,\n): Promise<SessionResponse> {\n const response = await fetchImpl(endpoint, {\n credentials: 'include',\n headers: { accept: 'application/json' },\n })\n if (!response.ok) {\n // The local dev server says why (`{ error }`): the runtime no longer has the App, or its credential.\n let reason = ''\n try {\n const body = (await response.json()) as { error?: unknown }\n if (typeof body?.error === 'string' && body.error) reason = `: ${body.error}`\n } catch {\n // No JSON body: the status is all there is.\n }\n throw new Error(`Frontera session request failed with ${response.status}${reason}`)\n }\n return parseSessionResponse(await response.json())\n}\n\n/** Build a bridge-compatible session from standalone or local HTTP auth. */\nexport async function connectToSessionEndpoint(\n endpoint: string,\n options: EndpointSessionOptions = {},\n): Promise<BridgeSession> {\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 const first = await fetchSession(endpoint, fetchImpl)\n const tokenHandlers = new Set<(token: string) => void>()\n let disposed = false\n let timer: unknown\n\n const schedule = (expiresAt: number) => {\n if (disposed) return\n const delay = Math.max(0, Math.floor((expiresAt - now()) * 0.8))\n timer = setTimer(() => {\n void fetchSession(endpoint, fetchImpl)\n .then((next) => {\n if (disposed) return\n for (const handler of tokenHandlers) handler(next.init.token)\n schedule(next.expiresAt)\n })\n // A revoked session must not keep emitting credentials. Requests made\n // with the previous short-lived token naturally stop at its expiry.\n .catch(() => {})\n }, delay)\n }\n schedule(first.expiresAt)\n\n return {\n init: first.init,\n send() {},\n onState() {\n return () => {}\n },\n onToken(handler) {\n tokenHandlers.add(handler)\n return () => tokenHandlers.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 },\n }\n}\n",
16
16
  "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",
17
17
  "frontera/blueprint/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",
18
- "frontera/blueprint/action-types.ts": "/**\n * Types for the governed write plane.\n *\n * Reads and writes are deliberately separate surfaces. A read is answered from\n * a catalog snapshot; a write is a REQUEST against a durable ledger that may be\n * approved by someone else, dispatched by a background worker minutes later,\n * and reconciled after that. Modelling both as \"call the server\" would hide the\n * one fact a UI has to show: submitting is not the same as done.\n */\n\n/**\n * Every state a Request can hold, in the platform's order.\n *\n * A copy of the platform's wire contract, not shared code: this package\n * installs from public npm and cannot import the service. A test pins the two\n * lists together, so a state added on one side fails the build on the other.\n */\nexport const ACTION_REQUEST_LIFECYCLES = [\n 'received',\n 'awaiting_approval',\n 'ready',\n 'executing',\n 'finalizing',\n 'succeeded',\n 'rejected',\n 'expired',\n 'cancelled',\n 'failed',\n 'outcome_unknown',\n 'awaiting_resolution',\n 'closed_unknown',\n] as const\n\n/**\n * Where a Request has got to.\n *\n * - `received`: recorded, not yet checked against its approval policy.\n * - `awaiting_approval`: waiting for someone to decide it.\n * - `ready`: cleared to run; a worker picks it up.\n * - `executing`: the write is being attempted now.\n * - `finalizing`: the write committed; the platform is verifying it.\n * - `succeeded`: applied and verified.\n * - `rejected`, `expired`, `cancelled`: closed before anything was attempted.\n * - `failed`: attempted, and the target refused it.\n * - `outcome_unknown`: attempted, and whether it landed could not be\n * established yet; the platform reconciles it against the target.\n * - `awaiting_resolution`: a person has to look at it, because verification or\n * reconciliation could not settle it.\n * - `closed_unknown`: closed by a person without the outcome ever being\n * established. The write may have landed.\n */\nexport type ActionRequestLifecycle = typeof ACTION_REQUEST_LIFECYCLES[number]\n\n/** Every effect certainty a Request can carry, in the platform's order. Pinned to the platform's list by a test. */\nexport const ACTION_EFFECT_CERTAINTIES = [\n 'not_attempted',\n 'in_flight',\n 'confirmed_not_applied',\n 'confirmed_applied',\n 'unknown',\n] as const\n\n/**\n * What is known about the effect on the target — NOT whether the request is\n * finished. `in_flight` is a dispatch under way. `unknown` is the honest state\n * after a dispatch whose outcome could not be established, and a UI must not\n * render it as failure: the write may well have landed.\n */\nexport type ActionEffectCertainty = typeof ACTION_EFFECT_CERTAINTIES[number]\n\n/**\n * The states a Request stops moving in by itself. `failed` and `closed_unknown`\n * can still change if late evidence about the target arrives, but nothing is\n * scheduled to move them, so polling stops there too.\n */\nconst TERMINAL: ReadonlySet<ActionRequestLifecycle> = new Set([\n 'succeeded', 'rejected', 'expired', 'cancelled', 'failed', 'closed_unknown',\n])\n\n/** Whether a Request has settled: nothing will move it on its own. */\nexport function isTerminalLifecycle(lifecycle: ActionRequestLifecycle): boolean {\n return TERMINAL.has(lifecycle)\n}\n\n/**\n * What to tell the person who pressed the button.\n *\n * Four answers, and they come from the CERTAINTY, not the lifecycle — which is\n * the distinction every app gets wrong, because \"succeeded\" reads like the\n * finish line and is not.\n *\n * A request reaches `confirmed_applied` the moment the write commits, and then\n * spends a while in `finalizing` while the platform verifies its own promise:\n * it re-reads the target, checks the properties the Action declared it would\n * change, evaluates the postconditions. That verification NEVER undoes the\n * write — its worst outcome is `awaiting_resolution`, which still carries\n * `confirmed_applied` and means a human should look at why the proof was\n * inconclusive. So waiting for `succeeded` before telling someone their ticket\n * exists leaves them staring at a spinner over a ticket that already exists.\n *\n * `uncertain` is the one that must not be collapsed into either neighbour. It\n * means a dispatch was attempted and the outcome could not be established —\n * the connection died mid-commit, and the write may well have landed. Rendering\n * it as failure invites a duplicate; rendering it as success invites a lie. Say\n * it is being checked; the platform reconciles it against the target and the\n * answer arrives on its own.\n */\nexport type ActionEffect = 'pending' | 'applied' | 'refused' | 'uncertain'\n\nexport function actionEffectOf(\n request: Pick<ActionRequest, 'lifecycle' | 'effectCertainty'> | null | undefined,\n): ActionEffect {\n if (!request) return 'pending'\n switch (request.effectCertainty) {\n case 'confirmed_applied': return 'applied'\n case 'unknown': return 'uncertain'\n // A refused attempt is final only once the Request has settled: `ready`\n // with this certainty is a retry the platform has already queued.\n case 'confirmed_not_applied':\n if (!request.lifecycle || isTerminalLifecycle(request.lifecycle)) return 'refused'\n return 'pending'\n default: break\n }\n // No certainty reported, or one that says nothing about the effect yet\n // (`not_attempted`, `in_flight`). The lifecycle still answers when it has\n // settled: a rejected, expired or cancelled Request never reached the\n // target, and an unknown outcome stays unknown. Anything else is genuinely\n // still in flight.\n switch (request.lifecycle) {\n case 'succeeded': return 'applied'\n case 'outcome_unknown':\n case 'closed_unknown': return 'uncertain'\n case 'rejected':\n case 'expired':\n case 'cancelled':\n case 'failed': return 'refused'\n default: return 'pending'\n }\n}\n\n/**\n * The record version to send with an edit, read off an object instance.\n *\n * The version arrives as `_meta.recordVersion`, and only an editable type has\n * one at all. The instance route always sends it; a query's rows carry it only\n * when the query asks (`recordVersions: true`, which Functions, workflows and\n * the CLI ask for), and this SDK's queries do not ask. Reaching into `_meta`\n * by hand is how a caller ends up sending `undefined`, and an edit of an\n * existing record always compares versions: one sent without a version is\n * refused, and nothing changes.\n *\n * So a control that edits a row from a table fetches the instance first:\n *\n * ```tsx\n * const instance = useObjectInstance('SupportTicket', selectedId)\n * submit.mutate({ …, expectedVersion: recordVersionOf(instance.data) })\n * ```\n *\n * Returns `undefined` for a type with no overlay, which is correct — there is\n * no version to assert, and the write path does not expect one.\n */\nexport function recordVersionOf(instance: unknown): number | undefined {\n if (!instance || typeof instance !== 'object') return undefined\n const meta = (instance as { _meta?: unknown })._meta\n if (!meta || typeof meta !== 'object') return undefined\n const version = (meta as { recordVersion?: unknown }).recordVersion\n return typeof version === 'number' ? version : undefined\n}\n\n/**\n * What a submission of this Action waits for.\n *\n * Any mode but `'none'` means the request may wait for approval. Test\n * `mode !== 'none'`, not `mode === 'required'`: a mode you did not test for\n * would otherwise read as \"no approval\". Under `'chain'` the request passes\n * its steps one after another, and a step whose condition does not hold for\n * it is skipped; read the request's `approval` to see where it stands.\n */\nexport interface ActionApprovalPolicy {\n mode: 'none' | 'required' | 'conditional' | 'chain'\n threshold?: number\n separationOfDuties?: boolean | 'strict'\n /** Under `'chain'`: how long a request may wait for its steps, in hours or days, such as `24h` or `3d`. */\n expiresAfter?: string\n /** Under `'chain'`: the steps, in order. Absent for every other mode. */\n stages?: ActionApprovalChainStep[]\n}\n\n/**\n * One step of an Action's approval chain, as discovery describes it.\n *\n * `conditional` says whether the step applies only to some requests. The\n * condition itself is not told: read a request's `approval` to see which\n * steps applied to it.\n *\n * A step whose `approver` is an App role is decided for requests raised\n * through an App that has that role. When such a step is not `conditional`,\n * every request for the Action must be raised through such an App: one raised\n * with no App is refused when it is submitted.\n */\nexport interface ActionApprovalChainStep {\n key: string\n /** Workspace members who may approve Action Requests, or the people who hold the named App role. */\n approver: 'members' | { role: string }\n /** How many approvals the step needs. */\n quorum: number\n conditional: boolean\n}\n\n/** Who decides a step of a request's approval chain: workspace members who may approve, or the people who hold an App role. */\nexport type ActionApproverRule = { members: true } | { role: string; scope: 'subject' }\n\n/**\n * One Action this caller may invoke.\n *\n * Discovery returns ONLY what the caller is authorized for. An Action absent\n * from this list may be unpublished, undeployed, or simply not permitted to\n * this user — the three are indistinguishable here, by design.\n */\nexport interface ActionDescriptor {\n actionDefinitionId: string\n apiName: string\n displayName: string\n description: string\n contractDigest: string\n activeReleaseId: string\n availability: string\n subject: { objectTypeId: string; mode: 'existing' | 'create' }\n approval: ActionApprovalPolicy\n /** JSON Schema for the whole invocation envelope, not just the inputs. */\n inputSchema: Record<string, unknown>\n}\n\n/**\n * Why an Action is not offered to this caller, as the platform explains it\n * (`discovery/unavailable`), in the order its gates refuse:\n *\n * - `not_deployed`: published, but no deployment runs it here (never\n * deployed, or switched off).\n * - `no_workspace_grant`: this workspace holds no Blueprint grant on the\n * Action's object type.\n * - `not_a_person`: the caller acts as a key, which belongs to no member.\n * - `not_a_member`: the person is not a member of this workspace.\n * - `business_user`: a Business user holds no governed authority here.\n * - `role_cannot_submit`: the person's role may not submit Action requests.\n * - `capability_not_granted`: the role does not hold the Action's invoke capability.\n * - `agent_not_active`, `agent_no_grant`: for an Agent caller.\n * - `agent_cannot_act`: the Agent may read the Action's object type, but its\n * grant there has no `act`, so it submits no Action on it.\n * - `refused`: refused for a reason not listed.\n * - `unknown`: the platform did not say (the caller may not ask, such as a\n * person signed in to an externally hosted App, or a platform that\n * predates the explanation).\n */\nexport type ActionUnavailableReason =\n | 'not_deployed'\n | 'no_workspace_grant'\n | 'not_a_person'\n | 'not_a_member'\n | 'business_user'\n | 'role_cannot_submit'\n | 'capability_not_granted'\n | 'agent_not_active'\n | 'agent_no_grant'\n | 'agent_cannot_act'\n | 'refused'\n | 'unknown'\n\nconst REASONS: readonly ActionUnavailableReason[] = [\n 'not_deployed', 'no_workspace_grant', 'not_a_person', 'not_a_member', 'business_user',\n 'role_cannot_submit', 'capability_not_granted', 'agent_not_active', 'agent_no_grant', 'agent_cannot_act', 'refused', 'unknown',\n]\n\n/** A reason as the platform sent it: one of the known ones, else `unknown`. */\nexport function unavailableReasonOf(value: unknown): ActionUnavailableReason {\n return typeof value === 'string' && (REASONS as readonly string[]).includes(value) ? (value as ActionUnavailableReason) : 'unknown'\n}\n\n/**\n * One Action discovery did not offer this caller, and why\n * (`GET /v1/blueprint/governed-actions/discovery/unavailable`). Only a caller\n * who may read the organization's Actions is told; the explanation is\n * advisory, and discovery and submit stay the decision.\n */\nexport interface UnavailableAction {\n apiName: string\n displayName: string\n reason: ActionUnavailableReason\n /** One sentence for a person. */\n message: string\n /** What would make it available, when there is something to do. */\n next: string | null\n invokeCapability: string\n /** The role the decision was made for, when the caller is a member. */\n role?: string\n /** For `not_deployed`: the deployment's state. */\n deploymentStatus?: string\n deploymentReason?: string\n}\n\n/** Whether a discovery entry can be invoked: discovery offers only what can, so anything but `available` (or no word) cannot. */\nexport function actionAvailabilityOf(entry: Pick<ActionDescriptor, 'availability'> | null | undefined): { available: true; reason: null } | { available: false; reason: ActionUnavailableReason } {\n if (!entry) return { available: false, reason: 'unknown' }\n return entry.availability === undefined || entry.availability === 'available' ? { available: true, reason: null } : { available: false, reason: 'not_deployed' }\n}\n\n/**\n * A sentence for a control that stays visible but cannot be used, when the\n * platform sent none of its own: show it beside the disabled button, or as\n * its tooltip. `unknown` stays neutral: it says nothing about why.\n */\nexport function actionUnavailableText(reason: ActionUnavailableReason): string {\n switch (reason) {\n case 'not_deployed':\n return \"This action isn't set up in this workspace yet.\"\n case 'capability_not_granted':\n case 'role_cannot_submit':\n case 'business_user':\n return \"Your role can't do this. Ask your administrator for access.\"\n case 'not_a_member':\n return \"You're not a member of this workspace.\"\n default:\n return \"This action isn't available to you here.\"\n }\n}\n\n/**\n * What raised a request, in the only terms a reader can act on.\n *\n * `person` is someone acting for themselves. `agent` is an Agent acting under\n * a person's authority — `agentName` says which Agent, `userId` says whose\n * authority. `automation` is an unattended caller that is not an Agent, so\n * there is no Agent name to show; `userId` still names the person it ran for.\n *\n * The split matters when you build an approval screen. \"Raised by Alia\" and\n * \"raised by the Collections Agent, under Alia's authority\" are different\n * things to approve, and flattening them into one line hides the one an\n * approver most needs to see.\n *\n * `applicationId` names the App a request was raised through: by the person\n * directly, or by a Function they started from that App. It does not change\n * `kind`. Absent from a platform that predates the field.\n *\n * `appUserId` and `appUserName` are set when the person is an App user: someone\n * signed in to an externally hosted App, who is not a workspace member. `kind`\n * is then `person`, `userId` is `null`, and `applicationId` is their App. Show\n * `appUserName` when it is present; it can be `null` when the person's identity\n * provider sent no name. Both are `null` for every other request, and absent\n * from a platform that predates the fields.\n *\n * A reader who is signed in to an externally hosted App is given names and no\n * identifier of anyone else: on a request someone else raised, `userId`,\n * `agentId`, `appUserId` and `applicationId` are all `null`. On a request they\n * raised themselves, `appUserId` and `applicationId` are their own.\n */\nexport interface ActionRequestRequester {\n kind: 'agent' | 'person' | 'automation'\n agentId: string | null\n agentName: string | null\n userId: string | null\n applicationId?: string | null\n appUserId?: string | null\n appUserName?: string | null\n}\n\n/** How to read one submitted parameter value: its label, declared kind and whether it is withheld. */\nexport interface ActionRequestInputField {\n /** The parameter's own name, for a label. */\n displayName: string\n /** The declared kind (`string`, `number`, `geopoint`, …), or `null` when the parameter is no longer published. */\n kind: string | null\n /** The value is withheld from this reader; `input` holds `\"[redacted]\"` in its place. */\n hidden: boolean\n}\n\n/**\n * What a request is asking for, ready to render.\n *\n * A queue row on its own says a request exists and what state it is in. This\n * says what would happen if it were approved: which Action, with which\n * parameter values, for what stated reason, raised by whom. Read-only — none\n * of it is sent back on a decision, so editing it locally changes nothing\n * about what the platform would run.\n *\n * `input` is keyed by the Action's parameter API names, the same names\n * `submit` takes — so a form that collects a value and a screen that reviews\n * it agree on what to call it.\n *\n * A parameter the Action declares as confidential or restricted arrives as the\n * placeholder string `'[redacted]'` with `inputFields[key].hidden` true. Decide\n * by `hidden`, never by matching the text: a submitted string can read\n * `'[redacted]'` too. Show it as withheld rather than as an empty value: the\n * parameter WAS supplied, and the approver is simply not the audience for it.\n */\nexport interface ActionRequestIntent {\n actionApiName: string\n actionDisplayName: string\n /** Submitted values, keyed by parameter API name. */\n input: Record<string, unknown>\n /**\n * How to read each `input` entry, under the same key. Format a value by its\n * declared `kind`, never by its shape (a `json` value can look like a\n * location); `hidden` means the value is withheld from this reader. Absent\n * on replies from a platform release that predates it.\n */\n inputFields?: Record<string, ActionRequestInputField>\n reason: string | null\n correlationId: string | null\n requester: ActionRequestRequester\n}\n\nexport interface ActionRequest {\n id: string\n actionDefinitionId: string\n apiName?: string\n lifecycle: ActionRequestLifecycle\n effectCertainty?: ActionEffectCertainty\n createdAt?: string\n updatedAt?: string\n /**\n * What the request is asking for. Present on reads of a single request and\n * on listed requests; absent on the reply to a write, where the caller\n * already holds what it just sent.\n *\n * `null` is a real state, not a failure to handle with a retry or an error\n * banner: the platform can still say what state the request is in but cannot\n * honestly say what it asks for — usually because the Action's published\n * definition has moved on and there is no longer a name or a parameter list\n * to show it against. Render the row with its lifecycle, say the details are\n * unavailable, and keep the decision controls governed as they already are.\n * Never fall back to a guess.\n */\n intent?: ActionRequestIntent | null\n /**\n * Where the request stands in its Action's approval chain. Present on reads\n * of a request whose Action uses approval mode `chain`; `null` for every\n * other request, and absent from a platform that predates the field.\n */\n approval?: ActionRequestApproval | null\n /**\n * Every approval or rejection recorded on the request, oldest first. Empty\n * when nobody has decided yet. Present on reads of a request (single and\n * listed), whatever its approval mode; absent from a platform that predates\n * the field.\n */\n approvalDecisions?: ActionRequestApprovalDecision[]\n}\n\n/**\n * One decision recorded on a request: an approval or a rejection.\n *\n * `stage` is the approval-chain step it decided, or `null` when the Action\n * approves in one step. `decidedBy.name` is `null` for someone no longer a\n * member of the organization, and for a person whose App sign-in carried no\n * name. `reason` is what the approver wrote; it is `null` when they wrote none\n * or the stored reason could not be read.\n */\nexport interface ActionRequestApprovalDecision {\n stage: string | null\n decision: 'approve' | 'reject'\n decidedBy: { name: string | null }\n /** When the decision was recorded (ISO 8601). */\n decidedAt: string\n reason: string | null\n}\n\n/** One step of a request's approval chain. */\nexport interface ActionRequestApprovalStep {\n /** The step's key, as the Action's approval chain names it. */\n key: string\n /**\n * `open` is the step waiting for decisions now; `pending` waits for the\n * steps before it. `skipped` never applied to this request: its condition\n * did not hold.\n */\n status: 'pending' | 'open' | 'approved' | 'skipped' | 'rejected'\n /**\n * Who decides the step: workspace members who may approve Action Requests,\n * or the people who hold the named role in the App the request was raised\n * through.\n */\n approverRule: ActionApproverRule\n /** How many approvals the step needs. */\n quorum: number\n /**\n * Who decided this step, in the order they decided, by display name.\n * `name` is `null` for someone no longer a member of the organization, and\n * for a person whose App sign-in carried no name.\n */\n decidedBy: Array<{ name: string | null }>\n /** When the step was approved or rejected; `null` while it is not settled. */\n decidedAt: string | null\n}\n\n/**\n * A request's approval chain: every step, the one waiting now, and the\n * rejection that closed the request, if one did.\n */\nexport interface ActionRequestApproval {\n stages: ActionRequestApprovalStep[]\n /** The key of the step waiting for decisions; `null` when none is. */\n currentStage: string | null\n /**\n * The rejection that closed the request. `reason` is what the approver\n * wrote; it is `null` when the stored reason could not be read.\n */\n rejection: { stage: string; reason: string | null; decidedBy: { name: string | null } } | null\n}\n\n/**\n * One page of a request list. `nextCursor` is `null` on the last page;\n * otherwise pass it back as `cursor` to read the next one.\n *\n * `truncated` is only ever true for `awaitingMe()`: the platform looks at a\n * bounded number of the most recent waiting requests for one page, and it\n * stopped there. Requests the person could decide may wait behind them, so a\n * short or empty page with no `nextCursor` is not proof that nothing else\n * waits. Tell the person the list may be incomplete, and narrow it with\n * `actionDefinitionId`.\n */\nexport interface ActionRequestPage {\n requests: ActionRequest[]\n nextCursor: string | null\n truncated: boolean\n}\n\n/** Which requests a list reads, and where in it. */\nexport interface ActionRequestListOptions {\n /** Only requests of this Action. */\n actionDefinitionId?: string\n /** Requests per page: 1 to 200. Left out, 50. */\n limit?: number\n /** `nextCursor` of the page before. */\n cursor?: string\n}\n\n/** A decision as the platform recorded it. */\nexport interface ActionApprovalDecision {\n id: string\n requestId: string\n decision: 'approve' | 'reject'\n /** The approval-chain step decided; `null` for an Action that does not use a chain. */\n stage: string | null\n decidedAt: string\n expiresAt?: string\n policyThreshold?: number\n separationOfDuties?: boolean\n humanApproverId?: string\n humanApproverRole?: string\n}\n\n/**\n * What `decide` resolves to: the request as the decision left it, with the\n * two parts the platform answers a decision with.\n *\n * It IS the request (`id`, `lifecycle` and the rest read as they do on any\n * `ActionRequest`), so code written against the earlier `ActionRequest`\n * return type keeps compiling and now reads real values. `request` is that\n * same request under the name the platform gives it, and `approvalDecision`\n * is the decision just recorded. The platform calls that part `approval`; it\n * has another name here because `approval` on a request already means where\n * the request stands in its approval chain.\n *\n * Like every reply to a write, it carries no `intent` and no chain progress:\n * read the request again for those.\n */\nexport interface ActionDecisionResult extends ActionRequest {\n request: ActionRequest\n approvalDecision: ActionApprovalDecision\n}\n\n/**\n * What a caller supplies to invoke an Action.\n *\n * Deliberately flatter than the wire envelope. The service takes a subject\n * reference, a separate string `expectedSubjectVersion`, an `input` map and a\n * `reason`; and an Action with compare-and-set ALSO takes a numeric version as\n * an ordinary input. Two version fields, one string and one number, meaning\n * related but different things, is the kind of contract a hand-written caller\n * gets wrong once and then debugs for an hour. `submit` assembles it.\n */\nexport interface SubmitActionInput {\n /**\n * Primary key of the object being changed, sent as the request's subject\n * (`subjectRef.objectId`). Omit only for `mode: 'create'`.\n *\n * The platform fills the Action's input named like the object type's\n * primary key (`id`, `ticketId`, …) from it when `input` leaves that out,\n * so `input` need not repeat the key. If `input` does carry it, the two must\n * be the same key: a request where they differ is refused, naming the input.\n */\n objectId?: string\n /**\n * Parameter values, keyed by the Action's parameter API names. Leave out\n * the key input and `expectedVersion`: they are filled from `objectId` and\n * `expectedVersion` (see those).\n */\n input: Record<string, unknown>\n /**\n * The record version the change was decided on: `recordVersionOf` of the\n * instance the person saw. Sent as the subject version\n * (`expectedSubjectVersion`, as text) AND, when the Action declares an\n * `expectedVersion` input, as that input (a number) — so a stale write is\n * refused rather than clobbering a concurrent one. An edit sent without it\n * is refused, never applied unchecked. When `input.expectedVersion` is given\n * as well and differs, the platform refuses the request rather than\n * picking one.\n */\n expectedVersion?: number | string\n /** Required when the Action declares `reason: 'required'`. */\n reason?: string\n correlationId?: string\n /**\n * Reused across retries of the SAME intended effect. Omit and the hook mints\n * one per user intent, which is almost always what you want: a retried\n * network call must not become a second escalation.\n */\n idempotencyKey?: string\n}\n",
18
+ "frontera/blueprint/action-types.ts": "/**\n * Types for the governed write plane.\n *\n * Reads and writes are deliberately separate surfaces. A read is answered from\n * a catalog snapshot; a write is a REQUEST against a durable ledger that may be\n * approved by someone else, dispatched by a background worker minutes later,\n * and reconciled after that. Modelling both as \"call the server\" would hide the\n * one fact a UI has to show: submitting is not the same as done.\n */\n\n/**\n * Every state a Request can hold, in the platform's order.\n *\n * A copy of the platform's wire contract, not shared code: this package\n * installs from public npm and cannot import the service. A test pins the two\n * lists together, so a state added on one side fails the build on the other.\n */\nexport const ACTION_REQUEST_LIFECYCLES = [\n 'received',\n 'awaiting_approval',\n 'ready',\n 'executing',\n 'finalizing',\n 'succeeded',\n 'rejected',\n 'expired',\n 'cancelled',\n 'failed',\n 'outcome_unknown',\n 'awaiting_resolution',\n 'closed_unknown',\n] as const\n\n/**\n * Where a Request has got to.\n *\n * - `received`: recorded, not yet checked against its approval policy.\n * - `awaiting_approval`: waiting for someone to decide it.\n * - `ready`: cleared to run; a worker picks it up.\n * - `executing`: the write is being attempted now.\n * - `finalizing`: the write committed; the platform is verifying it.\n * - `succeeded`: applied and verified.\n * - `rejected`, `expired`, `cancelled`: closed before anything was attempted.\n * - `failed`: attempted, and the target refused it.\n * - `outcome_unknown`: attempted, and whether it landed could not be\n * established yet; the platform reconciles it against the target.\n * - `awaiting_resolution`: a person has to look at it, because verification or\n * reconciliation could not settle it.\n * - `closed_unknown`: closed by a person without the outcome ever being\n * established. The write may have landed.\n */\nexport type ActionRequestLifecycle = typeof ACTION_REQUEST_LIFECYCLES[number]\n\n/** Every effect certainty a Request can carry, in the platform's order. Pinned to the platform's list by a test. */\nexport const ACTION_EFFECT_CERTAINTIES = [\n 'not_attempted',\n 'in_flight',\n 'confirmed_not_applied',\n 'confirmed_applied',\n 'unknown',\n] as const\n\n/**\n * What is known about the effect on the target — NOT whether the request is\n * finished. `in_flight` is a dispatch under way. `unknown` is the honest state\n * after a dispatch whose outcome could not be established, and a UI must not\n * render it as failure: the write may well have landed.\n */\nexport type ActionEffectCertainty = typeof ACTION_EFFECT_CERTAINTIES[number]\n\n/**\n * The states a Request stops moving in by itself. `failed` and `closed_unknown`\n * can still change if late evidence about the target arrives, but nothing is\n * scheduled to move them, so polling stops there too.\n */\nconst TERMINAL: ReadonlySet<ActionRequestLifecycle> = new Set([\n 'succeeded', 'rejected', 'expired', 'cancelled', 'failed', 'closed_unknown',\n])\n\n/** Whether a Request has settled: nothing will move it on its own. */\nexport function isTerminalLifecycle(lifecycle: ActionRequestLifecycle): boolean {\n return TERMINAL.has(lifecycle)\n}\n\n/**\n * What to tell the person who pressed the button.\n *\n * Four answers, and they come from the CERTAINTY, not the lifecycle — which is\n * the distinction every app gets wrong, because \"succeeded\" reads like the\n * finish line and is not.\n *\n * A request reaches `confirmed_applied` the moment the write commits, and then\n * spends a while in `finalizing` while the platform verifies its own promise:\n * it re-reads the target, checks the properties the Action declared it would\n * change, evaluates the postconditions. That verification NEVER undoes the\n * write — its worst outcome is `awaiting_resolution`, which still carries\n * `confirmed_applied` and means a human should look at why the proof was\n * inconclusive. So waiting for `succeeded` before telling someone their ticket\n * exists leaves them staring at a spinner over a ticket that already exists.\n *\n * `uncertain` is the one that must not be collapsed into either neighbour. It\n * means a dispatch was attempted and the outcome could not be established —\n * the connection died mid-commit, and the write may well have landed. Rendering\n * it as failure invites a duplicate; rendering it as success invites a lie. Say\n * it is being checked; the platform reconciles it against the target and the\n * answer arrives on its own.\n */\nexport type ActionEffect = 'pending' | 'applied' | 'refused' | 'uncertain'\n\nexport function actionEffectOf(\n request: Pick<ActionRequest, 'lifecycle' | 'effectCertainty'> | null | undefined,\n): ActionEffect {\n if (!request) return 'pending'\n switch (request.effectCertainty) {\n case 'confirmed_applied': return 'applied'\n case 'unknown': return 'uncertain'\n // A refused attempt is final only once the Request has settled: `ready`\n // with this certainty is a retry the platform has already queued.\n case 'confirmed_not_applied':\n if (!request.lifecycle || isTerminalLifecycle(request.lifecycle)) return 'refused'\n return 'pending'\n default: break\n }\n // No certainty reported, or one that says nothing about the effect yet\n // (`not_attempted`, `in_flight`). The lifecycle still answers when it has\n // settled: a rejected, expired or cancelled Request never reached the\n // target, and an unknown outcome stays unknown. Anything else is genuinely\n // still in flight.\n switch (request.lifecycle) {\n case 'succeeded': return 'applied'\n case 'outcome_unknown':\n case 'closed_unknown': return 'uncertain'\n case 'rejected':\n case 'expired':\n case 'cancelled':\n case 'failed': return 'refused'\n default: return 'pending'\n }\n}\n\n/**\n * The record version to send with an edit, read off an object instance.\n *\n * The version arrives as `_meta.recordVersion`, and only an editable type has\n * one at all. The instance route always sends it; a query's rows carry it only\n * when the query asks (`recordVersions: true`, which Functions, workflows and\n * the CLI ask for), and this SDK's queries do not ask. Reaching into `_meta`\n * by hand is how a caller ends up sending `undefined`, and an edit of an\n * existing record always compares versions: one sent without a version is\n * refused, and nothing changes.\n *\n * So a control that edits a row from a table fetches the instance first:\n *\n * ```tsx\n * const instance = useObjectInstance('SupportTicket', selectedId)\n * submit.mutate({ …, expectedVersion: recordVersionOf(instance.data) })\n * ```\n *\n * Returns `undefined` for a type with no overlay, which is correct — there is\n * no version to assert, and the write path does not expect one.\n */\nexport function recordVersionOf(instance: unknown): number | undefined {\n if (!instance || typeof instance !== 'object') return undefined\n const meta = (instance as { _meta?: unknown })._meta\n if (!meta || typeof meta !== 'object') return undefined\n const version = (meta as { recordVersion?: unknown }).recordVersion\n return typeof version === 'number' ? version : undefined\n}\n\n/**\n * What a submission of this Action waits for.\n *\n * Any mode but `'none'` means the request may wait for approval. Test\n * `mode !== 'none'`, not `mode === 'required'`: a mode you did not test for\n * would otherwise read as \"no approval\". Under `'chain'` the request passes\n * its steps one after another, and a step whose condition does not hold for\n * it is skipped; read the request's `approval` to see where it stands.\n */\nexport interface ActionApprovalPolicy {\n mode: 'none' | 'required' | 'conditional' | 'chain'\n threshold?: number\n separationOfDuties?: boolean | 'strict'\n /** Under `'chain'`: how long a request may wait for its steps, in hours or days, such as `24h` or `3d`. */\n expiresAfter?: string\n /** Under `'chain'`: the steps, in order. Absent for every other mode. */\n stages?: ActionApprovalChainStep[]\n}\n\n/**\n * One step of an Action's approval chain, as discovery describes it.\n *\n * `conditional` says whether the step applies only to some requests. The\n * condition itself is not told: read a request's `approval` to see which\n * steps applied to it.\n *\n * A step whose `approver` is an App role is decided for requests raised\n * through an App that has that role. When such a step is not `conditional`,\n * every request for the Action must be raised through such an App: one raised\n * with no App is refused when it is submitted.\n */\nexport interface ActionApprovalChainStep {\n key: string\n /** Workspace members who may approve Action Requests, or the people who hold the named App role. */\n approver: 'members' | { role: string }\n /** How many approvals the step needs. */\n quorum: number\n conditional: boolean\n}\n\n/** Who decides a step of a request's approval chain: workspace members who may approve, or the people who hold an App role. */\nexport type ActionApproverRule = { members: true } | { role: string; scope: 'subject' }\n\n/**\n * One Action this caller may invoke.\n *\n * Discovery returns ONLY what the caller is authorized for. An Action absent\n * from this list may be unpublished, undeployed, or simply not permitted to\n * this user — the three are indistinguishable here, by design.\n */\nexport interface ActionDescriptor {\n actionDefinitionId: string\n apiName: string\n displayName: string\n description: string\n contractDigest: string\n activeReleaseId: string\n availability: string\n subject: { objectTypeId: string; mode: 'existing' | 'create' }\n approval: ActionApprovalPolicy\n /** JSON Schema for the whole invocation envelope, not just the inputs. */\n inputSchema: Record<string, unknown>\n}\n\n/**\n * Why an Action is not offered to this caller, as the platform explains it\n * (`discovery/unavailable`), in the order its gates refuse:\n *\n * - `not_deployed`: published, but no deployment runs it here (never\n * deployed, or switched off).\n * - `no_workspace_grant`: this workspace holds no Blueprint grant on the\n * Action's object type, or on a type it writes records of.\n * - `not_a_person`: the caller acts as a key, which belongs to no member.\n * - `not_a_member`: the person is not a member of this workspace.\n * - `business_user`: a Business user holds no governed authority here.\n * - `role_cannot_submit`: the person's role may not submit Action requests.\n * - `capability_not_granted`: the role does not hold the Action's invoke capability.\n * - `agent_not_active`, `agent_no_grant`: for an Agent caller.\n * - `agent_cannot_act`: the Agent may read the Action's object type, or a\n * type it writes records of, but its grant there has no `act`.\n * - `refused`: refused for a reason not listed.\n * - `unknown`: the platform did not say (the caller may not ask, such as a\n * person signed in to an externally hosted App, or a platform that\n * predates the explanation).\n */\nexport type ActionUnavailableReason =\n | 'not_deployed'\n | 'no_workspace_grant'\n | 'not_a_person'\n | 'not_a_member'\n | 'business_user'\n | 'role_cannot_submit'\n | 'capability_not_granted'\n | 'agent_not_active'\n | 'agent_no_grant'\n | 'agent_cannot_act'\n | 'refused'\n | 'unknown'\n\nconst REASONS: readonly ActionUnavailableReason[] = [\n 'not_deployed', 'no_workspace_grant', 'not_a_person', 'not_a_member', 'business_user',\n 'role_cannot_submit', 'capability_not_granted', 'agent_not_active', 'agent_no_grant', 'agent_cannot_act', 'refused', 'unknown',\n]\n\n/** A reason as the platform sent it: one of the known ones, else `unknown`. */\nexport function unavailableReasonOf(value: unknown): ActionUnavailableReason {\n return typeof value === 'string' && (REASONS as readonly string[]).includes(value) ? (value as ActionUnavailableReason) : 'unknown'\n}\n\n/**\n * One Action discovery did not offer this caller, and why\n * (`GET /v1/blueprint/governed-actions/discovery/unavailable`). Only a caller\n * who may read the organization's Actions is told; the explanation is\n * advisory, and discovery and submit stay the decision.\n */\nexport interface UnavailableAction {\n apiName: string\n displayName: string\n reason: ActionUnavailableReason\n /** One sentence for a person. */\n message: string\n /** What would make it available, when there is something to do. */\n next: string | null\n invokeCapability: string\n /** The role the decision was made for, when the caller is a member. */\n role?: string\n /** For `not_deployed`: the deployment's state. */\n deploymentStatus?: string\n deploymentReason?: string\n}\n\n/** Whether a discovery entry can be invoked: discovery offers only what can, so anything but `available` (or no word) cannot. */\nexport function actionAvailabilityOf(entry: Pick<ActionDescriptor, 'availability'> | null | undefined): { available: true; reason: null } | { available: false; reason: ActionUnavailableReason } {\n if (!entry) return { available: false, reason: 'unknown' }\n return entry.availability === undefined || entry.availability === 'available' ? { available: true, reason: null } : { available: false, reason: 'not_deployed' }\n}\n\n/**\n * A sentence for a control that stays visible but cannot be used, when the\n * platform sent none of its own: show it beside the disabled button, or as\n * its tooltip. `unknown` stays neutral: it says nothing about why.\n */\nexport function actionUnavailableText(reason: ActionUnavailableReason): string {\n switch (reason) {\n case 'not_deployed':\n return \"This action isn't set up in this workspace yet.\"\n case 'capability_not_granted':\n case 'role_cannot_submit':\n case 'business_user':\n return \"Your role can't do this. Ask your administrator for access.\"\n case 'not_a_member':\n return \"You're not a member of this workspace.\"\n default:\n return \"This action isn't available to you here.\"\n }\n}\n\n/**\n * What raised a request, in the only terms a reader can act on.\n *\n * `person` is someone acting for themselves. `agent` is an Agent acting under\n * a person's authority — `agentName` says which Agent, `userId` says whose\n * authority. `automation` is an unattended caller that is not an Agent, so\n * there is no Agent name to show; `userId` still names the person it ran for.\n *\n * The split matters when you build an approval screen. \"Raised by Alia\" and\n * \"raised by the Collections Agent, under Alia's authority\" are different\n * things to approve, and flattening them into one line hides the one an\n * approver most needs to see.\n *\n * `applicationId` names the App a request was raised through: by the person\n * directly, or by a Function they started from that App. It does not change\n * `kind`. Absent from a platform that predates the field.\n *\n * `appUserId` and `appUserName` are set when the person is an App user: someone\n * signed in to an externally hosted App, who is not a workspace member. `kind`\n * is then `person`, `userId` is `null`, and `applicationId` is their App. Show\n * `appUserName` when it is present; it can be `null` when the person's identity\n * provider sent no name. Both are `null` for every other request, and absent\n * from a platform that predates the fields.\n *\n * A reader who is signed in to an externally hosted App is given names and no\n * identifier of anyone else: on a request someone else raised, `userId`,\n * `agentId`, `appUserId` and `applicationId` are all `null`. On a request they\n * raised themselves, `appUserId` and `applicationId` are their own.\n */\nexport interface ActionRequestRequester {\n kind: 'agent' | 'person' | 'automation'\n agentId: string | null\n agentName: string | null\n userId: string | null\n applicationId?: string | null\n appUserId?: string | null\n appUserName?: string | null\n}\n\n/** How to read one submitted parameter value: its label, declared kind and whether it is withheld. */\nexport interface ActionRequestInputField {\n /** The parameter's own name, for a label. */\n displayName: string\n /** The declared kind (`string`, `number`, `geopoint`, …), or `null` when the parameter is no longer published. */\n kind: string | null\n /** The value is withheld from this reader; `input` holds `\"[redacted]\"` in its place. */\n hidden: boolean\n}\n\n/**\n * What a request is asking for, ready to render.\n *\n * A queue row on its own says a request exists and what state it is in. This\n * says what would happen if it were approved: which Action, with which\n * parameter values, for what stated reason, raised by whom. Read-only — none\n * of it is sent back on a decision, so editing it locally changes nothing\n * about what the platform would run.\n *\n * `input` is keyed by the Action's parameter API names, the same names\n * `submit` takes — so a form that collects a value and a screen that reviews\n * it agree on what to call it.\n *\n * A parameter the Action declares as confidential or restricted arrives as the\n * placeholder string `'[redacted]'` with `inputFields[key].hidden` true. Decide\n * by `hidden`, never by matching the text: a submitted string can read\n * `'[redacted]'` too. Show it as withheld rather than as an empty value: the\n * parameter WAS supplied, and the approver is simply not the audience for it.\n */\nexport interface ActionRequestIntent {\n actionApiName: string\n actionDisplayName: string\n /** Submitted values, keyed by parameter API name. */\n input: Record<string, unknown>\n /**\n * How to read each `input` entry, under the same key. Format a value by its\n * declared `kind`, never by its shape (a `json` value can look like a\n * location); `hidden` means the value is withheld from this reader. Absent\n * on replies from a platform release that predates it.\n */\n inputFields?: Record<string, ActionRequestInputField>\n reason: string | null\n correlationId: string | null\n requester: ActionRequestRequester\n}\n\nexport interface ActionRequest {\n id: string\n actionDefinitionId: string\n apiName?: string\n lifecycle: ActionRequestLifecycle\n effectCertainty?: ActionEffectCertainty\n /**\n * The records the request names beside its subject, in the order the\n * Action declares them: records it creates and records it changes or\n * deletes. A record the request sets nothing on, because every input that\n * sets it is left out, is listed but not written. Absent when the Action\n * writes its subject only, and from a platform that predates the field.\n * Read it to refresh what the request touched once `actionEffectOf` reads\n * `applied`.\n */\n records?: Array<{ objectTypeId: string; objectId: string }>\n createdAt?: string\n updatedAt?: string\n /**\n * What the request is asking for. Present on reads of a single request and\n * on listed requests; absent on the reply to a write, where the caller\n * already holds what it just sent.\n *\n * `null` is a real state, not a failure to handle with a retry or an error\n * banner: the platform can still say what state the request is in but cannot\n * honestly say what it asks for — usually because the Action's published\n * definition has moved on and there is no longer a name or a parameter list\n * to show it against. Render the row with its lifecycle, say the details are\n * unavailable, and keep the decision controls governed as they already are.\n * Never fall back to a guess.\n */\n intent?: ActionRequestIntent | null\n /**\n * Where the request stands in its Action's approval chain. Present on reads\n * of a request whose Action uses approval mode `chain`; `null` for every\n * other request, and absent from a platform that predates the field.\n */\n approval?: ActionRequestApproval | null\n /**\n * Every approval or rejection recorded on the request, oldest first. Empty\n * when nobody has decided yet. Present on reads of a request (single and\n * listed), whatever its approval mode; absent from a platform that predates\n * the field.\n */\n approvalDecisions?: ActionRequestApprovalDecision[]\n}\n\n/**\n * One decision recorded on a request: an approval or a rejection.\n *\n * `stage` is the approval-chain step it decided, or `null` when the Action\n * approves in one step. `decidedBy.name` is `null` for someone no longer a\n * member of the organization, and for a person whose App sign-in carried no\n * name. `reason` is what the approver wrote; it is `null` when they wrote none\n * or the stored reason could not be read.\n */\nexport interface ActionRequestApprovalDecision {\n stage: string | null\n decision: 'approve' | 'reject'\n decidedBy: { name: string | null }\n /** When the decision was recorded (ISO 8601). */\n decidedAt: string\n reason: string | null\n}\n\n/** One step of a request's approval chain. */\nexport interface ActionRequestApprovalStep {\n /** The step's key, as the Action's approval chain names it. */\n key: string\n /**\n * `open` is the step waiting for decisions now; `pending` waits for the\n * steps before it. `skipped` never applied to this request: its condition\n * did not hold.\n */\n status: 'pending' | 'open' | 'approved' | 'skipped' | 'rejected'\n /**\n * Who decides the step: workspace members who may approve Action Requests,\n * or the people who hold the named role in the App the request was raised\n * through.\n */\n approverRule: ActionApproverRule\n /** How many approvals the step needs. */\n quorum: number\n /**\n * Who decided this step, in the order they decided, by display name.\n * `name` is `null` for someone no longer a member of the organization, and\n * for a person whose App sign-in carried no name.\n */\n decidedBy: Array<{ name: string | null }>\n /** When the step was approved or rejected; `null` while it is not settled. */\n decidedAt: string | null\n}\n\n/**\n * A request's approval chain: every step, the one waiting now, and the\n * rejection that closed the request, if one did.\n */\nexport interface ActionRequestApproval {\n stages: ActionRequestApprovalStep[]\n /** The key of the step waiting for decisions; `null` when none is. */\n currentStage: string | null\n /**\n * The rejection that closed the request. `reason` is what the approver\n * wrote; it is `null` when the stored reason could not be read.\n */\n rejection: { stage: string; reason: string | null; decidedBy: { name: string | null } } | null\n}\n\n/**\n * One page of a request list. `nextCursor` is `null` on the last page;\n * otherwise pass it back as `cursor` to read the next one.\n *\n * `truncated` is only ever true for `awaitingMe()`: the platform looks at a\n * bounded number of the most recent waiting requests for one page, and it\n * stopped there. Requests the person could decide may wait behind them, so a\n * short or empty page with no `nextCursor` is not proof that nothing else\n * waits. Tell the person the list may be incomplete, and narrow it with\n * `actionDefinitionId`.\n */\nexport interface ActionRequestPage {\n requests: ActionRequest[]\n nextCursor: string | null\n truncated: boolean\n}\n\n/** Which requests a list reads, and where in it. */\nexport interface ActionRequestListOptions {\n /** Only requests of this Action. */\n actionDefinitionId?: string\n /** Requests per page: 1 to 200. Left out, 50. */\n limit?: number\n /** `nextCursor` of the page before. */\n cursor?: string\n}\n\n/** A decision as the platform recorded it. */\nexport interface ActionApprovalDecision {\n id: string\n requestId: string\n decision: 'approve' | 'reject'\n /** The approval-chain step decided; `null` for an Action that does not use a chain. */\n stage: string | null\n decidedAt: string\n expiresAt?: string\n policyThreshold?: number\n separationOfDuties?: boolean\n humanApproverId?: string\n humanApproverRole?: string\n}\n\n/**\n * What `decide` resolves to: the request as the decision left it, with the\n * two parts the platform answers a decision with.\n *\n * It IS the request (`id`, `lifecycle` and the rest read as they do on any\n * `ActionRequest`), so code written against the earlier `ActionRequest`\n * return type keeps compiling and now reads real values. `request` is that\n * same request under the name the platform gives it, and `approvalDecision`\n * is the decision just recorded. The platform calls that part `approval`; it\n * has another name here because `approval` on a request already means where\n * the request stands in its approval chain.\n *\n * Like every reply to a write, it carries no `intent` and no chain progress:\n * read the request again for those.\n */\nexport interface ActionDecisionResult extends ActionRequest {\n request: ActionRequest\n approvalDecision: ActionApprovalDecision\n}\n\n/**\n * What a caller supplies to invoke an Action.\n *\n * Deliberately flatter than the wire envelope. The service takes a subject\n * reference, a separate string `expectedSubjectVersion`, an `input` map and a\n * `reason`; and an Action with compare-and-set ALSO takes a numeric version as\n * an ordinary input. Two version fields, one string and one number, meaning\n * related but different things, is the kind of contract a hand-written caller\n * gets wrong once and then debugs for an hour. `submit` assembles it.\n */\nexport interface SubmitActionInput {\n /**\n * Primary key of the object being changed, sent as the request's subject\n * (`subjectRef.objectId`). Omit only for `mode: 'create'`.\n *\n * The platform fills the Action's input named like the object type's\n * primary key (`id`, `ticketId`, …) from it when `input` leaves that out,\n * so `input` need not repeat the key. If `input` does carry it, the two must\n * be the same key: a request where they differ is refused, naming the input.\n */\n objectId?: string\n /**\n * Parameter values, keyed by the Action's parameter API names. Leave out\n * the key input and `expectedVersion`: they are filled from `objectId` and\n * `expectedVersion` (see those).\n *\n * An optional parameter with no default takes `null`, which empties the\n * property it sets; leaving it out keeps the property as it is. `undefined`\n * is leaving it out (JSON drops it), so clear a field with `null`.\n */\n input: Record<string, unknown>\n /**\n * The record version the change was decided on: `recordVersionOf` of the\n * instance the person saw. Sent as the subject version\n * (`expectedSubjectVersion`, as text) AND, when the Action declares an\n * `expectedVersion` input, as that input (a number) — so a stale write is\n * refused rather than clobbering a concurrent one. An edit sent without it\n * is refused, never applied unchecked. When `input.expectedVersion` is given\n * as well and differs, the platform refuses the request rather than\n * picking one.\n */\n expectedVersion?: number | string\n /** Required when the Action declares `reason: 'required'`. */\n reason?: string\n correlationId?: string\n /**\n * Reused across retries of the SAME intended effect. Omit and the hook mints\n * one per user intent, which is almost always what you want: a retried\n * network call must not become a second escalation.\n */\n idempotencyKey?: string\n}\n",
19
19
  "frontera/blueprint/hooks.ts": "import { createContext, useContext } from 'react'\nimport { useQuery, type UseQueryOptions, type UseQueryResult } from '@tanstack/react-query'\n\nimport type { BlueprintClient } from './blueprint-client'\nimport { DISTANCE_ROW_KEY, objectsOf } from './types'\nimport type {\n AggregateRequest,\n AggregateResponse,\n BlueprintFilterableProperty,\n BlueprintLocationProperty,\n BlueprintObjectName,\n BlueprintRow,\n BlueprintSortableProperty,\n MetricQueryRequest,\n MetricQueryResponse,\n NearestRequest,\n ObjectInstance,\n QueryRequest,\n QueryResponse,\n TypedWhereNode,\n WhereNode,\n} from './types'\n\n/**\n * Query-key factory.\n *\n * Requests are serialised into the key so two structurally equal requests share\n * a cache entry. Key order therefore matters: callers must build request\n * objects consistently, which they do because these hooks construct them.\n */\nexport const blueprintKeys = {\n all: ['blueprint'] as const,\n query: (request: QueryRequest) => ['blueprint', 'query', JSON.stringify(request)] as const,\n aggregate: (request: AggregateRequest) =>\n ['blueprint', 'aggregate', JSON.stringify(request)] as const,\n instance: (objectType: string, pk: string) =>\n ['blueprint', 'instance', objectType, pk] as const,\n metric: (apiName: string, request: MetricQueryRequest) =>\n ['blueprint', 'metric', apiName, JSON.stringify(request)] as const,\n}\n\nexport const FronteraBlueprintContext = createContext<BlueprintClient | null>(null)\n\nexport function useBlueprintClient(): BlueprintClient {\n const client = useContext(FronteraBlueprintContext)\n if (!client) {\n throw new Error(\n 'No BlueprintClient in context. Wrap the tree in FronteraBlueprintContext.Provider — createFronteraApp does this for you in a Frontera app.',\n )\n }\n return client\n}\n\ntype ReadOptions<TData> = Omit<UseQueryOptions<TData, Error>, 'queryKey' | 'queryFn' | 'select'>\n\n/** Run an arbitrary object-set query. */\nexport function useObjectQuery<TRow = Record<string, unknown>>(\n request: QueryRequest,\n options: ReadOptions<QueryResponse<TRow>> = {},\n): UseQueryResult<QueryResponse<TRow>, Error> {\n const client = useBlueprintClient()\n return useQuery({\n queryKey: blueprintKeys.query(request),\n queryFn: () => client.query<TRow>(request),\n ...options,\n })\n}\n\n/**\n * One object type, optionally filtered.\n *\n * `where` compiles into the object-set expression, so the SERVER filters and\n * pages. Filtering the returned rows in the component instead is the classic\n * mistake: it narrows only the page you happened to fetch, so a facet showing\n * 8,961 matches renders 5 rows and claims \"Page 1 of 1\".\n *\n * Paging is a CURSOR, not a page number. Hold the token from the previous\n * response and pass it back; `hasMore` is false and `nextPageToken` absent on\n * the last page. There is no total and no page count — an unordered scan cannot\n * produce one, which is exactly why offset paging was removed rather than left\n * to mislead.\n *\n * ```tsx\n * const [token, setToken] = useState<string | undefined>()\n * const page = useObjects<Ticket>('SupportTicket', { pageSize: 25, pageToken: token })\n * // next: setToken(page.data?.nextPageToken)\n * // restart: setToken(undefined)\n * ```\n */\ntype EffectiveRow<TLegacy, TObject extends BlueprintObjectName> =\n [TLegacy] extends [never] ? BlueprintRow<TObject> : TLegacy\n\ntype EffectiveFilterable<TLegacy, TObject extends BlueprintObjectName> =\n [TLegacy] extends [never]\n ? BlueprintFilterableProperty<TObject>\n : Extract<keyof TLegacy, string>\n\ntype EffectiveSortable<TLegacy, TObject extends BlueprintObjectName> =\n [TLegacy] extends [never]\n ? BlueprintSortableProperty<TObject>\n : Extract<keyof TLegacy, string>\n\n/** A location property of the row, or any name for an untyped object type. */\ntype EffectiveLocation<TLegacy, TObject extends BlueprintObjectName> =\n string extends EffectiveFilterable<TLegacy, TObject>\n ? string\n : BlueprintLocationProperty<EffectiveRow<TLegacy, TObject>>\n\ntype SelectedRow<TRow, TSelect> =\n undefined extends TSelect\n ? TRow\n : TSelect extends readonly (Extract<keyof TRow, string>)[]\n ? Pick<TRow, TSelect[number]>\n : TRow\n\n/** `nearest` adds the distance to every row, whatever `select` narrowed. */\ntype WithDistance<TRow, TNearest> =\n TNearest extends NearestRequest ? TRow & { [DISTANCE_ROW_KEY]: number } : TRow\n\ntype ObjectsRow<\n TLegacy,\n TObject extends BlueprintObjectName,\n TSelect,\n TNearest,\n> = WithDistance<SelectedRow<EffectiveRow<TLegacy, TObject>, TSelect>, TNearest>\n\ntype ObjectsOptions<\n TLegacy,\n TObject extends BlueprintObjectName,\n TSelect extends readonly Extract<keyof EffectiveRow<TLegacy, TObject>, string>[] | undefined,\n TNearest extends NearestRequest<EffectiveLocation<TLegacy, TObject>> | undefined,\n> = ReadOptions<QueryResponse<ObjectsRow<TLegacy, TObject, TSelect, TNearest>>> &\n Omit<QueryRequest, 'objectSet' | 'select' | 'orderBy' | 'nearest'> & {\n select?: TSelect\n orderBy?: Array<{ property: EffectiveSortable<TLegacy, TObject>; dir: 'asc' | 'desc' }>\n where?: TypedWhereNode<EffectiveRow<TLegacy, TObject>, EffectiveFilterable<TLegacy, TObject>>\n nearest?: TNearest\n }\n\nexport function useObjects<\n TLegacy extends object = never,\n const TObject extends BlueprintObjectName = BlueprintObjectName,\n const TSelect extends readonly Extract<keyof EffectiveRow<TLegacy, TObject>, string>[] | undefined =\n readonly Extract<keyof EffectiveRow<TLegacy, TObject>, string>[] | undefined,\n const TNearest extends NearestRequest<EffectiveLocation<TLegacy, TObject>> | undefined = undefined,\n>(\n objectType: TObject,\n options: ObjectsOptions<NoInfer<TLegacy>, TObject, TSelect, TNearest> = {},\n): UseQueryResult<QueryResponse<ObjectsRow<TLegacy, TObject, TSelect, TNearest>>, Error> {\n const { select, orderBy, pageSize, pageToken, where, nearest, ...queryOptions } = options\n return useObjectQuery<ObjectsRow<TLegacy, TObject, TSelect, TNearest>>(\n {\n objectSet: objectsOf(objectType, where as WhereNode | undefined),\n select: select ? [...select] : undefined,\n orderBy,\n pageSize,\n pageToken,\n ...(nearest ? { nearest } : {}),\n },\n queryOptions,\n )\n}\n\nexport function useAggregate(\n request: AggregateRequest,\n options: ReadOptions<AggregateResponse> = {},\n): UseQueryResult<AggregateResponse, Error> {\n const client = useBlueprintClient()\n return useQuery({\n queryKey: blueprintKeys.aggregate(request),\n queryFn: () => client.aggregate(request),\n ...options,\n })\n}\n\nexport function useObjectInstance<\n TLegacy extends object = never,\n const TObject extends BlueprintObjectName = BlueprintObjectName,\n>(\n objectType: TObject,\n pk: string | null | undefined,\n options: ReadOptions<ObjectInstance<EffectiveRow<TLegacy, TObject>>> = {},\n): UseQueryResult<ObjectInstance<EffectiveRow<TLegacy, TObject>>, Error> {\n const client = useBlueprintClient()\n return useQuery({\n queryKey: blueprintKeys.instance(objectType, pk ?? ''),\n queryFn: () => client.instance<TLegacy, TObject>(objectType, pk as string),\n enabled: Boolean(pk) && options.enabled !== false,\n ...options,\n })\n}\n\n/**\n * One metric the organization has already defined.\n *\n * A metric exists so every reader computes it the same way. Deriving the same\n * figure from raw columns in a component is how two dashboards end up\n * disagreeing about one number, and it is where the arithmetic bugs live — one\n * app shipped a share ratio whose numerator omitted the filter its denominator\n * applied, reading 551.7%, next to a defined metric that had been there all\n * along.\n *\n * This hook existed only as `client.metricQuery` for a while, so apps that\n * wanted a metric hand-rolled a hook around `useBlueprintClient` — the exact\n * detour the guidance tells authors not to take. Reach for this instead;\n * `useAggregate` is for figures nobody has defined yet.\n */\nexport function useMetric(\n apiName: string,\n request: MetricQueryRequest = {},\n options: ReadOptions<MetricQueryResponse> = {},\n): UseQueryResult<MetricQueryResponse, Error> {\n const client = useBlueprintClient()\n return useQuery({\n queryKey: blueprintKeys.metric(apiName, request),\n queryFn: () => client.metricQuery(apiName, request),\n ...options,\n })\n}\n",
20
20
  "frontera/blueprint/action-hooks.ts": "import { createContext, useContext, useEffect } from 'react'\nimport {\n useInfiniteQuery,\n useMutation,\n useQuery,\n useQueryClient,\n type UseMutationResult,\n type UseQueryOptions,\n type UseQueryResult,\n} from '@tanstack/react-query'\n\nimport type { ActionClient } from './action-client'\nimport { blueprintKeys } from './hooks'\nimport {\n actionAvailabilityOf,\n actionEffectOf,\n actionUnavailableText,\n isTerminalLifecycle,\n type ActionUnavailableReason,\n type UnavailableAction,\n type ActionDecisionResult,\n type ActionDescriptor,\n type ActionRequest,\n type ActionRequestLifecycle,\n type ActionRequestPage,\n type SubmitActionInput,\n} from './action-types'\n\n/** Which requests `useApprovalInbox` reads. */\nexport interface ApprovalInboxFilter {\n /** Only requests of this Action. */\n actionDefinitionId?: string\n /** Requests read per page: 1 to 200. Left out, 50. */\n pageSize?: number\n}\n\n/** Which requests `useMyActionRequests` reads. */\nexport interface MyActionRequestsFilter extends ApprovalInboxFilter {\n /** Only requests in these states. Left out, every state. */\n lifecycle?: readonly ActionRequestLifecycle[]\n}\n\nexport const actionKeys = {\n all: ['blueprint', 'actions'] as const,\n discovery: () => ['blueprint', 'actions', 'discovery'] as const,\n /** Why discovery leaves Actions out, for a caller who may be told. */\n unavailable: () => ['blueprint', 'actions', 'discovery', 'unavailable'] as const,\n requests: (lifecycle?: readonly ActionRequestLifecycle[]) =>\n ['blueprint', 'actions', 'requests', lifecycle?.join(',') ?? 'all'] as const,\n request: (requestId: string) => ['blueprint', 'actions', 'request', requestId] as const,\n /** With no filter: the prefix every approval inbox is cached under. */\n awaitingMe: (filter?: ApprovalInboxFilter) => (filter\n ? ['blueprint', 'actions', 'awaiting-me', filter.actionDefinitionId ?? 'all', filter.pageSize ?? 'default'] as const\n : ['blueprint', 'actions', 'awaiting-me'] as const),\n /** With no filter: the prefix every list of one's own requests is cached under. */\n mine: (filter?: MyActionRequestsFilter) => (filter\n ? [\n 'blueprint', 'actions', 'mine',\n filter.lifecycle?.join(',') ?? 'all',\n filter.actionDefinitionId ?? 'all',\n filter.pageSize ?? 'default',\n ] as const\n : ['blueprint', 'actions', 'mine'] as const),\n}\n\nexport const FronteraActionContext = createContext<ActionClient | null>(null)\n\nexport function useActionClient(): ActionClient {\n const client = useContext(FronteraActionContext)\n if (!client) {\n throw new Error(\n 'No ActionClient in context. Wrap the tree in FronteraActionContext.Provider — createFronteraApp does this for you in a Frontera app.',\n )\n }\n return client\n}\n\ntype ReadOptions<TData> = Omit<UseQueryOptions<TData, Error>, 'queryKey' | 'queryFn'>\n\n/**\n * Actions the signed-in user may invoke.\n *\n * Render buttons from THIS, never from a hard-coded list: the same page must\n * offer different actions to different colleagues, and only the server knows\n * which. A hard-coded button that 403s on click is a worse experience than one\n * that was never drawn.\n *\n * An empty list during development is much more likely to be permissions than\n * a bug in this hook — an Action is hidden unless it is published, deployed,\n * AND its invoke capability is held by the caller's role.\n */\nexport function useActions(\n options: ReadOptions<ActionDescriptor[]> = {},\n): UseQueryResult<ActionDescriptor[], Error> {\n const client = useActionClient()\n return useQuery({\n queryKey: actionKeys.discovery(),\n queryFn: () => client.discover(),\n // Only what the caller can invoke: entries listed just to say why not are for `useAction`.\n select: (entries) => entries.filter((entry) => actionAvailabilityOf(entry).available),\n ...options,\n })\n}\n\n/** What `useAction` answers: the Action when it can be invoked, else why not. */\nexport interface UseActionResult {\n /** The Action, or `null` when this caller cannot invoke it (or while discovery loads). */\n action: ActionDescriptor | null\n /** Whether it can be invoked. `false` while discovery loads too: check `isLoading`. */\n available: boolean\n /**\n * Why not, once known: the platform's reason when this caller may be told,\n * else `unknown`. `null` when available, or while discovery (or its\n * explanation) loads.\n */\n reason: ActionUnavailableReason | null\n /** A sentence for the person (a disabled control's tooltip or the line beside it): the platform's own when it sent one. `null` when available or still loading. */\n reasonText: string | null\n /** What would make it available, for the person building the App; only ever told to a caller who may read the organization's Actions. */\n next: string | null\n isLoading: boolean\n error: Error | null\n}\n\n/**\n * One Action by name, or `null` when this user may not invoke it, with why.\n *\n * `null` rather than a thrown error, because \"you may not do this\" is an\n * ordinary state for a UI to be in — it renders nothing, or a disabled control\n * with an explanation — not an exception. `reasonText` is that explanation: a\n * control that stays visible says it (a tooltip, or a line beside it) rather\n * than sitting disabled with no word of why.\n *\n * Only when discovery leaves the Action out does this ask the platform why\n * (`discovery/unavailable`), and only a caller who may be told is answered: a\n * person signed in to an externally hosted App gets the neutral `unknown`.\n */\nexport function useAction(\n apiName: string,\n options: ReadOptions<ActionDescriptor[]> = {},\n): UseActionResult {\n const client = useActionClient()\n const { data, isLoading, error } = useActions(options)\n const missing = !!data && !data.some((entry) => entry.apiName === apiName)\n const explained = useQuery({\n queryKey: actionKeys.unavailable(),\n queryFn: () => client.unavailable(),\n enabled: missing,\n // Advisory, and a refusal is an answer (null): never retried into a loop.\n retry: false,\n staleTime: 30_000,\n })\n const explanation = missing ? (explained.isPending ? undefined : (explained.data ?? null)) : null\n return { ...actionLookup(data, apiName, explanation), isLoading, error: error ?? null }\n}\n\n/**\n * One Action of a discovery answer, by name: it when it can be invoked, else\n * why not. `explained` is the platform's explanation of what discovery left\n * out: `undefined` while it loads, `null` when this caller may not be told.\n */\nexport function actionLookup(\n entries: readonly ActionDescriptor[] | undefined,\n apiName: string,\n explained?: readonly UnavailableAction[] | null,\n): Pick<UseActionResult, 'action' | 'available' | 'reason' | 'reasonText' | 'next'> {\n const none = { action: null, available: false, reason: null, reasonText: null, next: null }\n if (!entries) return none\n const entry = entries.find((candidate) => candidate.apiName === apiName)\n const availability = actionAvailabilityOf(entry)\n if (availability.available) return { action: entry!, available: true, reason: null, reasonText: null, next: null }\n if (entry) return { action: null, available: false, reason: availability.reason, reasonText: actionUnavailableText(availability.reason), next: null }\n if (explained === undefined) return none\n const why = explained?.find((row) => row.apiName === apiName)\n if (!why) return { action: null, available: false, reason: 'unknown', reasonText: actionUnavailableText('unknown'), next: null }\n return { action: null, available: false, reason: why.reason, reasonText: why.message || actionUnavailableText(why.reason), next: why.next ?? null }\n}\n\n/**\n * Submit an Action.\n *\n * Resolves when the Request is RECORDED, not when the effect has landed —\n * dispatch runs on a background worker. A button whose success toast fires here\n * is claiming something it does not know, so pass the returned request id to\n * `useActionRequest` and let the lifecycle drive what the user sees.\n *\n * The idempotency key is minted per `mutate` call, which is the correct\n * lifetime: React Query retrying a failed network call reuses the same key and\n * cannot double-apply, while a second click is a second intent and gets its\n * own.\n */\nexport function useSubmitAction(\n action: ActionDescriptor | null,\n): UseMutationResult<ActionRequest, Error, SubmitActionInput> {\n const client = useActionClient()\n const queryClient = useQueryClient()\n\n return useMutation<ActionRequest, Error, SubmitActionInput>({\n mutationFn: (input) => {\n if (!action) {\n return Promise.reject(new Error(\n 'This Action is not available to you. Render the control only when `useAction` returns one.',\n ))\n }\n return client.submit(action, input)\n },\n onSuccess: (request) => {\n queryClient.setQueryData(actionKeys.request(request.id), request)\n void queryClient.invalidateQueries({ queryKey: actionKeys.requests() })\n // A request they raised: their own list has one more.\n void queryClient.invalidateQueries({ queryKey: actionKeys.mine() })\n },\n })\n}\n\n/**\n * Follow one Request until it settles.\n *\n * Polls while the lifecycle is non-terminal and stops once it is, so a settled\n * Request costs nothing to keep on screen. The interval is deliberately short:\n * the gap between \"submitted\" and \"applied\" is the part users find alarming,\n * and the cheapest fix is showing it moving.\n */\nexport function useActionRequest(\n requestId: string | null | undefined,\n options: ReadOptions<ActionRequest> & { pollMs?: number } = {},\n): UseQueryResult<ActionRequest, Error> {\n const client = useActionClient()\n const queryClient = useQueryClient()\n const { pollMs = 1_500, ...queryOptions } = options\n\n const result = useQuery({\n queryKey: actionKeys.request(requestId ?? ''),\n queryFn: () => client.request(requestId as string),\n enabled: Boolean(requestId) && queryOptions.enabled !== false,\n refetchInterval: (query) => {\n const lifecycle = query.state.data?.lifecycle\n if (!lifecycle) return pollMs\n return isTerminalLifecycle(lifecycle) ? false : pollMs\n },\n ...queryOptions,\n })\n\n /**\n * Refresh what the app is READING once the write has actually landed.\n *\n * Not on submit: at that point the Request is recorded and the object is\n * unchanged, so refetching returns the old values and caches them as fresh —\n * the table would settle on stale data and stay there.\n *\n * Keyed on the CERTAINTY rather than the lifecycle. `confirmed_applied` is\n * the moment the write commits; `succeeded` comes later, after the platform\n * has verified its own promise, and refreshing only then leaves the table\n * showing yesterday's row for the whole verification pass. Nothing about the\n * data changes in that gap.\n *\n * Deliberately not on a refusal: nothing changed, and a refetch there is a\n * request per failure for no new information.\n */\n const landed = actionEffectOf(result.data) === 'applied'\n useEffect(() => {\n if (!landed) return\n void queryClient.invalidateQueries({ queryKey: blueprintKeys.all })\n }, [landed, queryClient])\n\n return result\n}\n\n/** The queue: Requests in this workspace, optionally narrowed by lifecycle. */\nexport function useActionRequests(\n lifecycle?: readonly ActionRequestLifecycle[],\n options: ReadOptions<ActionRequest[]> = {},\n): UseQueryResult<ActionRequest[], Error> {\n const client = useActionClient()\n return useQuery({\n queryKey: actionKeys.requests(lifecycle),\n queryFn: () => client.requests(lifecycle),\n ...options,\n })\n}\n\n/** One named list of requests, read page by page. */\nexport interface ActionRequestList {\n /** Every request read so far, newest first. */\n requests: ActionRequest[]\n /** True until the first page has arrived. */\n isLoading: boolean\n /** True while any page is being read, the first or a later one. */\n isFetching: boolean\n error: Error | null\n /** Whether the platform has at least one more request past what was read. */\n hasMore: boolean\n /**\n * The approval inbox only: a page stopped at the platform's limit before it\n * had looked at every waiting request, so the list may be incomplete even\n * when `hasMore` is false. Always false for `useMyActionRequests`.\n */\n truncated: boolean\n /** Read the next page. Does nothing when there is none, or one is on its way. */\n loadMore(): Promise<void>\n /** Read the list again from the top. */\n refetch(): Promise<void>\n}\n\ntype ListReadOptions = {\n /** Set false to read nothing until it is true. */\n enabled?: boolean\n /** Read the list again this often, in milliseconds. Left out, only on `refetch` and after a decision. */\n refetchInterval?: number | false\n}\n\n/** A named list as React Query reads it: one page per cursor. */\nexport interface ActionRequestListQuery {\n queryKey: readonly unknown[]\n queryFn(context: { pageParam: string | null }): Promise<ActionRequestPage>\n initialPageParam: string | null\n getNextPageParam(last: ActionRequestPage): string | null\n}\n\n/**\n * The query behind `useApprovalInbox`: its cache key and how it reads a page.\n * Exported for an App that prefetches or drives the cache itself.\n */\nexport function approvalInboxQuery(client: ActionClient, filter: ApprovalInboxFilter = {}): ActionRequestListQuery {\n return {\n queryKey: actionKeys.awaitingMe(filter),\n queryFn: ({ pageParam }) => client.awaitingMe({\n ...(filter.actionDefinitionId ? { actionDefinitionId: filter.actionDefinitionId } : {}),\n ...(filter.pageSize ? { limit: filter.pageSize } : {}),\n ...(pageParam ? { cursor: pageParam } : {}),\n }),\n initialPageParam: null,\n getNextPageParam: (last) => last.nextCursor,\n }\n}\n\n/** The query behind `useMyActionRequests`. */\nexport function myActionRequestsQuery(client: ActionClient, filter: MyActionRequestsFilter = {}): ActionRequestListQuery {\n return {\n queryKey: actionKeys.mine(filter),\n queryFn: ({ pageParam }) => client.mine({\n ...(filter.lifecycle?.length ? { lifecycle: filter.lifecycle } : {}),\n ...(filter.actionDefinitionId ? { actionDefinitionId: filter.actionDefinitionId } : {}),\n ...(filter.pageSize ? { limit: filter.pageSize } : {}),\n ...(pageParam ? { cursor: pageParam } : {}),\n }),\n initialPageParam: null,\n getNextPageParam: (last) => last.nextCursor,\n }\n}\n\n/**\n * The pages read so far as one list. A request that moved between two reads\n * can be on two pages; it is listed once, where it first appears.\n */\nexport function requestsOfPages(pages: readonly ActionRequestPage[] | undefined): ActionRequest[] {\n const seen = new Set<string>()\n const requests: ActionRequest[] = []\n for (const page of pages ?? []) {\n for (const request of page.requests) {\n if (seen.has(request.id)) continue\n seen.add(request.id)\n requests.push(request)\n }\n }\n return requests\n}\n\n/**\n * Whether any page read so far stopped at the platform's limit: the list may\n * then be incomplete, whatever its last page says about a next one.\n */\nexport function pagesTruncated(pages: readonly ActionRequestPage[] | undefined): boolean {\n return (pages ?? []).some((page) => page.truncated)\n}\n\nfunction useRequestList(query: ActionRequestListQuery, options: ListReadOptions): ActionRequestList {\n const result = useInfiniteQuery({\n ...query,\n ...(options.enabled === undefined ? {} : { enabled: options.enabled }),\n ...(options.refetchInterval === undefined ? {} : { refetchInterval: options.refetchInterval }),\n })\n return {\n requests: requestsOfPages(result.data?.pages),\n isLoading: result.isLoading,\n isFetching: result.isFetching,\n error: result.error ?? null,\n hasMore: result.hasNextPage,\n truncated: pagesTruncated(result.data?.pages),\n loadMore: async () => {\n if (result.hasNextPage && !result.isFetchingNextPage) await result.fetchNextPage()\n },\n refetch: async () => {\n await result.refetch()\n },\n }\n}\n\n/**\n * What awaits this person's approval: the waiting requests they could decide\n * right now, newest first.\n *\n * Works for a Frontera member and for a person signed in to an externally\n * hosted App, each under their own rules (`ActionClient.awaitingMe`). Render\n * the review screen from `requests`: each carries `intent` (what is asked,\n * and by whom) and `approval` (the steps, and the one waiting). Decide with\n * `useDecideActionRequest`, passing `approval.currentStage` as `stage`; the\n * list is read again after every decision made through that hook.\n *\n * `truncated` is true when a page stopped at the platform's limit of waiting\n * requests looked at: say that the list may be incomplete, and narrow it with\n * `actionDefinitionId`.\n *\n * The list is a moment's answer. Someone else may decide a request, or the\n * record may leave the person's data, between the read and the decision; the\n * decision then answers as it would for a request that is not there. Call\n * `refetch`, or set `refetchInterval`, to keep a screen left open current.\n */\nexport function useApprovalInbox(options: ApprovalInboxFilter & ListReadOptions = {}): ActionRequestList {\n const client = useActionClient()\n const { enabled, refetchInterval, ...filter } = options\n return useRequestList(approvalInboxQuery(client, filter), { enabled, refetchInterval })\n}\n\n/**\n * The requests this person raised, newest first: what became of each, the\n * approval steps it passed, who decided them by name and, for a rejected\n * request, the reason given.\n *\n * For a person signed in to an externally hosted App: the requests they\n * raised through this App. Read again after `useSubmitAction`,\n * `useDecideActionRequest` and `useCancelActionRequest` succeed.\n */\nexport function useMyActionRequests(options: MyActionRequestsFilter & ListReadOptions = {}): ActionRequestList {\n const client = useActionClient()\n const { enabled, refetchInterval, ...filter } = options\n return useRequestList(myActionRequestsQuery(client, filter), { enabled, refetchInterval })\n}\n\n/**\n * Approve or reject a Request awaiting a decision.\n *\n * An Action with separation of duties refuses a decision from whoever\n * submitted it, so this will fail for the requester — correctly. Surface that\n * refusal rather than hiding the control: \"someone else must approve this\" is\n * the information the user needs.\n *\n * Works with the session of a person signed in to an externally hosted App,\n * for a step their App role decides.\n *\n * For a request approved in steps, `stage` is required: pass\n * `request.approval.currentStage` from the request the person is looking at,\n * so a decision is never recorded on a step they did not read. The hook does\n * not fill it in from its cache: the cache may have been read again since the\n * screen was drawn, and only your component knows which step it showed. A\n * decision without it is refused (`ACTION_APPROVAL_STEP_REQUIRED`). Leave it\n * out for a request whose `approval` is `null`.\n *\n * Resolves to the request as the decision left it (`ActionDecisionResult`).\n * The request, the approval inbox and the lists of requests are read again.\n */\nexport function useDecideActionRequest(): UseMutationResult<\n ActionDecisionResult,\n Error,\n { requestId: string; decision: 'approve' | 'reject'; reason: string; stage?: string }\n> {\n const client = useActionClient()\n const queryClient = useQueryClient()\n\n return useMutation({\n mutationFn: ({ requestId, decision, reason, stage }) => client.decide(requestId, decision, reason, stage),\n onSuccess: (decided) => refreshAfterWrite(queryClient, decided.id),\n })\n}\n\n/**\n * Withdraw a Request that is still waiting.\n *\n * A person signed in to an externally hosted App withdraws only a request\n * they raised, while it waits for approval. A Frontera member needs the\n * permission to cancel requests. The request and the lists are read again.\n */\nexport function useCancelActionRequest(): UseMutationResult<ActionRequest, Error, string> {\n const client = useActionClient()\n const queryClient = useQueryClient()\n\n return useMutation({\n mutationFn: (requestId) => client.cancel(requestId),\n onSuccess: (cancelled) => refreshAfterWrite(queryClient, cancelled.id),\n })\n}\n\n/**\n * After a decision or a cancellation: read the request again rather than\n * cache the reply, which carries no `intent` and no approval steps, and read\n * every list it may have left or changed in.\n */\nexport function refreshAfterWrite(\n queryClient: { invalidateQueries(filters: { queryKey: readonly unknown[] }): Promise<void> },\n requestId: string,\n): void {\n for (const queryKey of [\n actionKeys.request(requestId),\n actionKeys.requests(),\n actionKeys.awaitingMe(),\n actionKeys.mine(),\n ]) void queryClient.invalidateQueries({ queryKey })\n}\n",
21
21
  "frontera/blueprint/action-client.ts": "import type { FronteraClient } from '@frontera-sdk/core/client'\nimport { FronteraError } from '@frontera-sdk/core/errors'\nimport type {\n ActionApprovalDecision,\n ActionDecisionResult,\n ActionDescriptor,\n ActionRequest,\n ActionRequestLifecycle,\n ActionRequestListOptions,\n ActionRequestPage,\n SubmitActionInput,\n UnavailableAction,\n} from './action-types'\nimport { unavailableReasonOf } from './action-types'\n\nconst BASE = '/v1/blueprint/governed-actions'\n\n/** The token of a person signed in to an externally hosted App. */\nconst APP_USER_TOKEN_PREFIX = 'sk-au-'\n\n/**\n * The governed write plane.\n *\n * Authorized against the PERSON, never the app. A hosted app is handed a token\n * carrying the signed-in user's id, and every call is checked against that\n * user's organization role, their workspace membership, and the workspace's\n * grant on the object type. So the same page can offer a button to one\n * colleague and not another, and neither the app nor its author decides which.\n *\n * A workspace key cannot invoke at all — its principal belongs to no\n * organization member — which is why a scaffolded dev host, holding one, will\n * read fine and refuse every write. See `README` on `frontera app init`.\n *\n * A person signed in to an EXTERNALLY HOSTED App is checked against the App's\n * roles instead: `discover` lists the Actions their roles hold, and `request`\n * reads a request they raised. `submit` is accepted only for records that a\n * role listing that Action lets the person read. A role that only reads never\n * widens what they can change. A referenced record need only be readable.\n *\n * Such a person also decides the approval steps their App role is given:\n * `awaitingMe` lists the requests waiting on one, `decide` approves or\n * rejects one, and `request` reads it. Any other request answers as not\n * found, whether or not it exists. `mine` lists the requests they raised, and\n * `cancel` withdraws one of those while it waits. `requests`, every request\n * in the workspace, is for Frontera members and is refused here for such a\n * person, before anything is sent.\n */\nexport class ActionClient {\n constructor(private readonly client: FronteraClient) {}\n\n /**\n * The refusal for a call only a Frontera member can make, when the session\n * belongs to a person signed in to an externally hosted App.\n *\n * The service answers such a call as if no credential had been sent. Left to\n * that, the App would see \"Authentication required\" and the session would\n * sign the person in again over a call that can never succeed. Said here\n * instead, by name.\n */\n private membersOnly(what: string, instead: string): FronteraError | null {\n const credential = this.client.config.credential\n if (credential.kind !== 'token' || !credential.token.startsWith(APP_USER_TOKEN_PREFIX)) return null\n return new FronteraError(\n `${what} is not available to people signed in to an externally hosted App. ${instead}`,\n { code: 'FORBIDDEN', status: 403 },\n )\n }\n\n /**\n * Actions this user may invoke, here, now.\n *\n * An empty list is ambiguous ON PURPOSE — unpublished, undeployed and\n * unpermitted are indistinguishable to a caller, so a probe cannot map what\n * exists. That is right for security and hostile to debugging, so treat an\n * unexpected empty list as a question about the CALLER's permissions first.\n */\n discover(): Promise<ActionDescriptor[]> {\n return this.client.request<ActionDescriptor[]>(`${BASE}/discovery`)\n }\n\n /**\n * Why discovery does not offer each Action it leaves out, or `null` when\n * this caller may not be told: only someone who may read the organization's\n * Actions (a member who builds, or the App's dev session on a local\n * runtime) is. A person signed in to an externally hosted App is never\n * asked for it; a 403 is the same `null`. Advisory: discovery and submit\n * stay the decision.\n */\n async unavailable(): Promise<UnavailableAction[] | null> {\n const credential = this.client.config.credential\n if (credential.kind === 'token' && credential.token.startsWith(APP_USER_TOKEN_PREFIX)) return null\n try {\n const rows = await this.client.request<UnavailableAction[]>(`${BASE}/discovery/unavailable`)\n return (Array.isArray(rows) ? rows : []).map((row) => ({ ...row, reason: unavailableReasonOf(row.reason) }))\n } catch (err) {\n if (err instanceof FronteraError && (err.status === 403 || err.status === 404)) return null\n throw err\n }\n }\n\n /**\n * Submit one Action. Returns as soon as the Request is recorded — NOT when\n * the effect has landed.\n *\n * The write is durable from this point: it survives a closed tab, a restarted\n * server, and a worker that is not running yet. What it does not do is finish\n * synchronously, so a UI that renders success here is lying. Poll the Request\n * (`useActionRequest`) and show the lifecycle.\n */\n async submit(\n action: ActionDescriptor,\n input: SubmitActionInput,\n ): Promise<ActionRequest> {\n const idempotencyKey = input.idempotencyKey ?? mintIdempotencyKey()\n return this.client.request<ActionRequest>(\n `${BASE}/actions/${encodeURIComponent(action.apiName)}/requests`,\n {\n method: 'POST',\n headers: { 'idempotency-key': idempotencyKey },\n // Wrapped, and the wrapper is EXACT: the route accepts a body whose\n // keys are precisely `['invocation']` and refuses anything else with\n // \"Governed Action HTTP command body is invalid.\" — a message that\n // names the body rather than the field, so sending the envelope at the\n // top level reads like a malformed invocation instead of a missing\n // wrapper.\n body: { invocation: buildInvocation(action, input) },\n },\n )\n }\n\n requests(lifecycle?: readonly ActionRequestLifecycle[]): Promise<ActionRequest[]> {\n const refused = this.membersOnly(\n 'Listing every Action request in the workspace',\n 'Read the requests waiting for their decision with `awaitingMe()` or `useApprovalInbox`, and the requests they raised with `mine()` or `useMyActionRequests`.',\n )\n if (refused) return Promise.reject(refused)\n return this.client.request<ActionRequest[]>(`${BASE}/requests`, {\n query: lifecycle?.length ? { lifecycle: lifecycle.join(',') } : undefined,\n })\n }\n\n /**\n * The waiting requests this person could decide right now, newest first.\n *\n * For a Frontera member: requests waiting on a step workspace members\n * decide, in a workspace where they may approve. For a person signed in to\n * an externally hosted App: requests waiting on a step that names an App\n * role they hold, for a record that role's data scope covers.\n *\n * Never one they raised, one they already decided a step of, or one past\n * its deadline. A request leaves the list as soon as any of that changes, so\n * a list read a moment ago can hold a request that `decide` now refuses.\n * Nothing here says how many other requests are waiting for other people.\n *\n * A page looks at a bounded number of the most recent waiting requests.\n * When it stopped there, the page says `truncated: true`: more may wait\n * behind them.\n */\n awaitingMe(options: ActionRequestListOptions = {}): Promise<ActionRequestPage> {\n return this.page({ awaiting: 'me', ...listQuery(options) })\n }\n\n /**\n * The requests this person raised, newest first, in every state unless\n * `lifecycle` narrows it. Each carries its approval steps, who decided them\n * by name and, for a rejected request, the reason.\n *\n * For a person signed in to an externally hosted App: the requests they\n * raised through this App.\n */\n mine(\n options: ActionRequestListOptions & { lifecycle?: readonly ActionRequestLifecycle[] } = {},\n ): Promise<ActionRequestPage> {\n return this.page({\n mine: 'true',\n ...(options.lifecycle?.length ? { lifecycle: options.lifecycle.join(',') } : {}),\n ...listQuery(options),\n })\n }\n\n /** One page of a named list: the requests, and where the next page starts. */\n private async page(query: Record<string, string>): Promise<ActionRequestPage> {\n const envelope = await this.client.requestEnvelope<{\n data?: ActionRequest[]\n nextCursor?: string | null\n truncated?: boolean\n } | null>(\n `${BASE}/requests`,\n { query },\n )\n return {\n requests: envelope?.data ?? [],\n nextCursor: envelope?.nextCursor ?? null,\n truncated: envelope?.truncated === true,\n }\n }\n\n request(requestId: string): Promise<ActionRequest> {\n return this.client.request<ActionRequest>(`${BASE}/requests/${encodeURIComponent(requestId)}`)\n }\n\n /**\n * Approve or reject. Separate from `submit` because it is a different act by\n * a different person — an Action with separation of duties refuses a decision\n * from whoever submitted it.\n *\n * `stage` names the approval-chain step being decided, as `approval.currentStage`\n * showed it to the person. It is required for a request approved in steps\n * (one whose `approval` is not `null`): without it the decision is refused as\n * invalid, with reason `ACTION_APPROVAL_STEP_REQUIRED`. If another step is\n * waiting by the time the decision arrives, it is refused with reason\n * `ACTION_APPROVAL_STEP_CHANGED` rather than recorded on that step. Leave it\n * out only for a request that has no approval steps, where naming one is\n * refused.\n *\n * A Frontera member decides the requests and steps that name workspace\n * members. A person signed in to an externally hosted App decides a step\n * that names an App role they hold, for a record that role's data scope\n * covers. To that person every other request answers `ACTION_NOT_FOUND`,\n * the same as a request that does not exist: show \"nothing to decide here\",\n * not an error about permissions.\n *\n * Resolves to the request as the decision left it, with `request` (the same\n * request) and `approvalDecision` (the decision recorded) beside it: see\n * `ActionDecisionResult`.\n */\n async decide(\n requestId: string,\n decision: 'approve' | 'reject',\n reason: string,\n stage?: string,\n ): Promise<ActionDecisionResult> {\n const answer = await this.client.request<{ approval: ActionApprovalDecision; request: ActionRequest }>(\n `${BASE}/requests/${encodeURIComponent(requestId)}/approvals`,\n { method: 'POST', body: { decision, reason, ...(stage ? { stage } : {}) } },\n )\n return { ...answer.request, request: answer.request, approvalDecision: answer.approval }\n }\n\n /**\n * Cancel sends no body. The platform reads the request from the URL and the\n * caller from the credential, and ignores anything sent as a body: a\n * `reason` sent here would be dropped, not recorded, so none is taken.\n *\n * A Frontera member with permission to cancel requests cancels a request\n * that waits for approval, or is approved and has not started to run. A person signed in to an externally hosted App withdraws\n * only a request they raised, and only while it waits for approval: any\n * other request answers as not found, and one already approved answers\n * `CONFLICT`.\n */\n cancel(requestId: string): Promise<ActionRequest> {\n return this.client.request<ActionRequest>(\n `${BASE}/requests/${encodeURIComponent(requestId)}/cancel`,\n { method: 'POST' },\n )\n }\n}\n\n/** The query a list sends for its filter and position: only what was given. */\nfunction listQuery(options: ActionRequestListOptions): Record<string, string> {\n return {\n ...(options.actionDefinitionId ? { actionDefinitionId: options.actionDefinitionId } : {}),\n ...(options.limit !== undefined ? { limit: String(options.limit) } : {}),\n ...(options.cursor ? { cursor: options.cursor } : {}),\n }\n}\n\n/**\n * The wire envelope, assembled from the flat input a caller actually has.\n *\n * For an edit it carries the subject (`subjectRef`, `expectedSubjectVersion`)\n * and, when the Action declares it, the numeric `expectedVersion` input. The\n * Action's key input is not guessed here (the descriptor does not say which\n * input it is): the platform fills it from `subjectRef.objectId` when `input`\n * leaves it out, and refuses a key or version in `input` that disagrees with\n * the subject.\n *\n * Exported for the tests: this is the part with a trap in it, and the trap is\n * silent — a wrong shape comes back as \"Action invocation is invalid\" with no\n * field named.\n */\nexport function buildInvocation(\n action: ActionDescriptor,\n input: SubmitActionInput,\n): Record<string, unknown> {\n const invocation: Record<string, unknown> = { input: { ...input.input } }\n\n if (action.subject.mode === 'existing') {\n if (!input.objectId) {\n throw new Error(\n `\"${action.apiName}\" changes an existing ${action.subject.objectTypeId}, so it needs an objectId.`,\n )\n }\n invocation.subjectRef = {\n objectTypeId: action.subject.objectTypeId,\n objectId: input.objectId,\n }\n if (input.expectedVersion !== undefined) {\n // A STRING here, deliberately: the subject version is an opaque token,\n // while the compare-and-set parameter below is the numeric record\n // version. Same number, two types, two meanings.\n invocation.expectedSubjectVersion = String(input.expectedVersion)\n }\n } else if (input.objectId) {\n throw new Error(`\"${action.apiName}\" creates an object, so it takes no objectId.`)\n }\n\n // Fed to the Action's own compare-and-set parameter when it declares one,\n // and only then — an Action without it would refuse the unknown key.\n const casParameter = compareAndSetParameter(action)\n if (casParameter && input.expectedVersion !== undefined) {\n const parameters = invocation.input as Record<string, unknown>\n if (!(casParameter in parameters)) parameters[casParameter] = Number(input.expectedVersion)\n }\n\n if (input.reason !== undefined) invocation.reason = input.reason\n if (input.correlationId !== undefined) invocation.correlationId = input.correlationId\n return invocation\n}\n\n/**\n * The parameter carrying the record version, read off the published schema\n * rather than assumed by name.\n */\nfunction compareAndSetParameter(action: ActionDescriptor): string | null {\n const input = (action.inputSchema as { properties?: Record<string, unknown> } | undefined)\n ?.properties?.input as { properties?: Record<string, unknown> } | undefined\n const properties = input?.properties\n if (!properties) return null\n return 'expectedVersion' in properties ? 'expectedVersion' : null\n}\n\n/**\n * One key per user intent.\n *\n * The service dedupes by this: the same key with a different invocation is\n * REFUSED, and the same key with the same invocation returns the original\n * Request rather than acting twice. So it must be stable across retries of one\n * intent and different between two intents — which is exactly the lifetime of\n * a single `submit` call, not of a component or a session.\n */\nfunction mintIdempotencyKey(): string {\n const random = globalThis.crypto?.randomUUID?.()\n ?? Math.random().toString(36).slice(2).padEnd(22, '0')\n return `frontera-app-${random}`\n}\n",