@frontera-sdk/cli 1.45.11 → 1.45.13
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.
|
@@ -1,35 +1,35 @@
|
|
|
1
1
|
{
|
|
2
|
-
"sdkVersion": "1.45.
|
|
2
|
+
"sdkVersion": "1.45.11",
|
|
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
|
+
"frontera/core/react.ts": "export {\n FronteraAppProvider,\n useFronteraApp,\n type FronteraAppProviderProps,\n type FronteraAppValue,\n type FronteraProvider,\n} from './create-frontera-app'\nexport type { FronteraAppMode } from './app-session'\n",
|
|
5
6
|
"frontera/core/bridge-client.ts": "import { isHostMessage, type BridgeInit, type AppMessage } from './bridge-protocol'\nimport { readRuntimeGlobal } from './config'\nimport type { HostTheme } from './theme'\n\n/**\n * App side of the bridge.\n *\n * The app announces itself with `frontera:ready` and waits for `frontera:init`,\n * which carries the API credential. It does NOT read the credential from its\n * own URL — the only thing there is the narrow asset token, and treating that\n * as a data credential would turn a loggable URL into data access.\n *\n * `event.origin` is checked against the parent's origin on every message.\n */\nexport interface BridgeSession {\n init: BridgeInit\n /** Send a message to the host. */\n send(message: AppMessage): void\n /** Subscribe to shared-state updates pushed by the host. */\n onState(handler: (state: Record<string, unknown>) => void): () => void\n /** Subscribe to credential refreshes. */\n onToken(handler: (token: string) => void): () => void\n /** Subscribe to host palette / colour-scheme changes. */\n onTheme(handler: (theme: HostTheme) => void): () => void\n dispose(): void\n}\n\nexport interface ConnectOptions {\n /**\n * Origin of the embedding platform. Defaults to the injected runtime config,\n * then `document.referrer`.\n */\n parentOrigin?: string\n timeoutMs?: number\n}\n\n/**\n * Who is framing us.\n *\n * The injected runtime config comes FIRST and `document.referrer` is only a\n * fallback, which is the opposite of the original order and the reason the\n * handshake could not complete in production: app responses are served with\n * `referrer-policy: no-referrer`, so the referrer is empty exactly where it\n * mattered. `platformOrigin` exists in the injected config for this purpose.\n */\nfunction resolveParentOrigin(explicit?: string): string | null {\n if (explicit) return explicit\n const injected = readRuntimeGlobal().platformOrigin\n if (injected) return injected\n if (typeof document === 'undefined') return null\n try {\n return document.referrer ? new URL(document.referrer).origin : null\n } catch {\n return null\n }\n}\n\n/**\n * Complete the handshake.\n *\n * Rejects rather than hanging if the host never answers: a frame that waits\n * forever looks identical to a slow network, and an author debugging a broken\n * mount deserves a real error.\n */\nexport function connectToHost(options: ConnectOptions = {}): Promise<BridgeSession> {\n const parentOrigin = resolveParentOrigin(options.parentOrigin)\n const timeoutMs = options.timeoutMs ?? 10_000\n\n if (typeof window === 'undefined' || window.parent === window) {\n return Promise.reject(\n new Error('Not running inside a Frontera host frame — connectToHost() needs a parent window.'),\n )\n }\n if (!parentOrigin) {\n return Promise.reject(\n new Error('Could not determine the host origin; pass parentOrigin explicitly.'),\n )\n }\n\n const stateHandlers = new Set<(s: Record<string, unknown>) => void>()\n const tokenHandlers = new Set<(t: string) => void>()\n const themeHandlers = new Set<(t: HostTheme) => void>()\n\n return new Promise<BridgeSession>((resolve, reject) => {\n let settled = false\n\n const send = (message: AppMessage) => window.parent.postMessage(message, parentOrigin)\n\n const onMessage = (event: MessageEvent) => {\n if (event.origin !== parentOrigin) return\n if (!isHostMessage(event.data)) return\n const message = event.data\n\n switch (message.type) {\n case 'frontera:init':\n if (settled) return\n settled = true\n clearTimeout(timer)\n resolve({\n init: message,\n send,\n onState(handler) {\n stateHandlers.add(handler)\n return () => stateHandlers.delete(handler)\n },\n onToken(handler) {\n tokenHandlers.add(handler)\n return () => tokenHandlers.delete(handler)\n },\n onTheme(handler) {\n themeHandlers.add(handler)\n return () => themeHandlers.delete(handler)\n },\n dispose() {\n window.removeEventListener('message', onMessage)\n stateHandlers.clear()\n tokenHandlers.clear()\n themeHandlers.clear()\n },\n })\n break\n case 'frontera:state':\n for (const handler of stateHandlers) handler(message.state)\n break\n case 'frontera:token':\n for (const handler of tokenHandlers) handler(message.token)\n break\n // The host pushes this on every platform theme change — a system\n // dark-mode switch, a surface that forces its own scheme. Without a\n // handler the message was received and dropped, so an app followed the\n // palette it was mounted with and then never again.\n case 'frontera:theme':\n for (const handler of themeHandlers) {\n handler({ tokens: message.tokens, colorScheme: message.colorScheme })\n }\n break\n default:\n break\n }\n }\n\n const timer = setTimeout(() => {\n if (settled) return\n settled = true\n window.removeEventListener('message', onMessage)\n reject(new Error(`Frontera host did not respond within ${timeoutMs}ms`))\n }, timeoutMs)\n\n window.addEventListener('message', onMessage)\n send({ type: 'frontera:ready' })\n })\n}\n",
|
|
6
|
-
"frontera/core/errors.ts": "/**\n * Error taxonomy for the Frontera SDK.\n *\n * `code` mirrors the service's `ErrorCode` enum\n * (packages/service/src/lib/errors.ts) so callers branch on the same strings\n * the API emits. Codes are deliberately not enumerated here — the service adds\n * new ones, and an SDK that rejected unknown codes would need a release every\n * time it did.\n */\nexport class FronteraError extends Error {\n readonly code: string\n readonly status?: number\n readonly details?: unknown\n\n constructor(message: string, opts: { code: string; status?: number; details?: unknown }) {\n super(message)\n this.name = 'FronteraError'\n this.code = opts.code\n this.status = opts.status\n this.details = opts.details\n }\n}\n\n/** Codes used when the response body carries no envelope of its own. */\nconst STATUS_FALLBACK: Record<number, string> = {\n 400: 'BAD_REQUEST',\n 401: 'UNAUTHORIZED',\n 403: 'FORBIDDEN',\n 404: 'NOT_FOUND',\n 409: 'CONFLICT',\n 429: 'RATE_LIMITED',\n}\n\nfunction isErrorEnvelope(body: unknown): body is { error: true; code?: string; message?: string } {\n return typeof body === 'object' && body !== null && (body as { error?: unknown }).error === true\n}\n\n/** Turn a non-2xx response body into a `FronteraError`. */\nexport function errorFromResponse(status: number, body: unknown): FronteraError {\n if (isErrorEnvelope(body)) {\n return new FronteraError(body.message ?? `request failed with ${status}`, {\n code: body.code ?? STATUS_FALLBACK[status] ?? 'INTERNAL_ERROR',\n status,\n details: body,\n })\n }\n return new FronteraError(`request failed with ${status}`, {\n code: STATUS_FALLBACK[status] ?? 'INTERNAL_ERROR',\n status,\n details: body ?? undefined,\n })\n}\n",
|
|
7
7
|
"frontera/core/create-frontera-app.tsx": "import {\n Component,\n StrictMode,\n createContext,\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 type { BridgeInit } 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'\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 /** Ask the platform to change its URL — the counterpart to `init.path`. */\n navigate(path: string): void\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 * 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\nfunction diagnostic(title: string, detail: string): ReactNode {\n return (\n <div\n role=\"alert\"\n style={{\n fontFamily: 'var(--font-sans, system-ui, sans-serif)',\n color: 'var(--foreground, #111)',\n padding: 24,\n lineHeight: 1.5,\n }}\n >\n <strong>{title}</strong>\n <div style={{ marginTop: 4, opacity: 0.75, fontSize: 14 }}>{detail}</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 }),\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\n const value: FronteraAppValue = {\n mode,\n init,\n client,\n navigate: (path) => {\n if (mode === 'embedded') session.send({ type: 'frontera:navigate', path })\n },\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 errorFallback?: (error: Error) => ReactNode\n /** Compatibility hooks used by createFronteraApp. */\n queryClient?: QueryClient\n timeoutMs?: number\n}\n\n/**\n * Transport-neutral React entry point for embedded, standalone, and local Apps.\n * Mode selection happens before connection, so an ordinary standalone page\n * never waits for an iframe handshake that cannot succeed.\n */\nexport function FronteraAppProvider({\n children,\n providers = [],\n loading = diagnostic('Connecting to Frontera', 'Establishing an authenticated App session.'),\n errorFallback,\n queryClient: providedQueryClient,\n timeoutMs,\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\n useEffect(() => {\n const runtime = readRuntimeGlobal()\n const framed = typeof window !== 'undefined' && window.parent !== window\n const mode = detectAppMode(runtime, framed)\n if (!mode) {\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 =\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 }, [])\n\n if (error) return errorFallback?.(error) ?? diagnostic('Frontera App could not start', error.message)\n if (!connection) return loading\n\n return (\n <QueryClientProvider client={queryClient}>\n <FronteraRoot\n mode={connection.mode}\n session={connection.session}\n providers={providers}\n errorFallback={errorFallback}\n >\n {children}\n </FronteraRoot>\n </QueryClientProvider>\n )\n}\n\nexport async function createFronteraApp(\n app: ReactNode,\n options: CreateFronteraAppOptions = {},\n): Promise<void> {\n const rootId = options.rootId ?? 'root'\n const element = document.getElementById(rootId)\n if (!element) {\n throw new Error(`createFronteraApp: no #${rootId} element — check index.html.`)\n }\n\n const root = createRoot(element)\n\n root.render(\n <StrictMode>\n <FronteraAppProvider\n providers={options.providers}\n queryClient={options.queryClient}\n timeoutMs={options.timeoutMs}\n >\n {app}\n </FronteraAppProvider>\n </StrictMode>,\n )\n}\n",
|
|
8
|
-
"frontera/core/client.ts": "import { resolveConfig, type FronteraConfig, type FronteraCredential } from './config'\nimport { httpRequest, type RequestOptions } from './transport'\n\n/**\n * The transport-level entry point. Domain clients (`@frontera-sdk/blueprint`, and\n * later chat and automation) take one of these rather than reimplementing\n * auth, URL building, or error mapping.\n *\n * `withCredential` exists because a bridge-hosted app receives a refreshed\n * token mid-session: swapping produces a new client rather than mutating one\n * that in-flight requests may still hold.\n */\nexport class FronteraClient {\n readonly config: FronteraConfig\n private readonly fetchImpl: typeof fetch\n\n constructor(config: Partial<FronteraConfig>, fetchImpl: typeof fetch = fetch) {\n this.config = resolveConfig(config)\n this.fetchImpl = fetchImpl\n }\n\n request<T>(path: string, options: RequestOptions = {}): Promise<T> {\n return httpRequest<T>(this.config, path, options, this.fetchImpl)\n }\n\n withCredential(credential: FronteraCredential): FronteraClient {\n return new FronteraClient({ ...this.config, credential }, this.fetchImpl)\n }\n}\n",
|
|
9
|
-
"frontera/core/bridge-protocol.ts": "/**\n * postMessage protocol between the platform host and an app iframe.\n *\n * The app runs on its own origin (`<appId>.apps.<domain>`), so postMessage is\n * the ONLY channel. Both sides validate `event.origin` on every message: Luigi,\n * a mature framework with this same architecture, carries an open issue for\n * omitting exactly that check, and the consequence is any page being able to\n * drive an embedded app.\n */\n\nexport interface BridgeInit {\n type: 'frontera:init'\n /** Scoped API credential. Arrives here, never in a URL. */\n token: string\n apiBaseUrl: string\n appId: string\n version: string\n orgId: string | null\n workspaceId: string | null\n /**\n * The host's palette and light/dark state.\n *\n * Defaults, not a house style: `applyHostTheme` writes them behind the app's\n * own stylesheets so a customer's brand still wins.\n */\n theme: { tokens: Record<string, string>; colorScheme?: 'light' | 'dark'; fontCss?: string }\n /** Current shared app state, already validated by the host. */\n state: Record<string, unknown>\n /**\n * Where the app should open, taken from the platform URL.\n *\n * The counterpart to `frontera:navigate`: without it a shared link to\n * `/apps/<slug>/shipments` mounts the app at its home page, and the address\n * bar describes a view the user is not looking at. Absent means the app's\n * own default.\n */\n path?: string\n}\n\nexport type HostMessage =\n | BridgeInit\n | { type: 'frontera:state'; state: Record<string, unknown> }\n | {\n type: 'frontera:theme'\n tokens: Record<string, string>\n colorScheme?: 'light' | 'dark'\n fontCss?: string\n }\n | { type: 'frontera:token'; token: string }\n\nexport type AppMessage =\n | { type: 'frontera:ready' }\n | { type: 'frontera:navigate'; path: string }\n | { type: 'frontera:resize'; height: number }\n | { type: 'frontera:state-write'; patch: Record<string, unknown> }\n | { type: 'frontera:error'; message: string; stack?: string }\n | { type: 'frontera:state-repair'; keys: string[] }\n\nexport function isHostMessage(data: unknown): data is HostMessage {\n if (!data || typeof data !== 'object') return false\n const m = data as Record<string, unknown>\n switch (m.type) {\n case 'frontera:init':\n return typeof m.token === 'string' && typeof m.apiBaseUrl === 'string' && typeof m.appId === 'string'\n case 'frontera:state':\n return typeof m.state === 'object' && m.state !== null\n case 'frontera:theme':\n return typeof m.tokens === 'object' && m.tokens !== null\n case 'frontera:token':\n return typeof m.token === 'string'\n default:\n return false\n }\n}\n\nexport function isAppMessage(data: unknown): data is AppMessage {\n if (!data || typeof data !== 'object') return false\n const m = data as Record<string, unknown>\n switch (m.type) {\n case 'frontera:ready':\n return true\n case 'frontera:navigate':\n return typeof m.path === 'string'\n case 'frontera:resize':\n return typeof m.height === 'number' && Number.isFinite(m.height)\n case 'frontera:state-write':\n return typeof m.patch === 'object' && m.patch !== null\n case 'frontera:error':\n return typeof m.message === 'string'\n case 'frontera:state-repair':\n return Array.isArray(m.keys)\n default:\n return false\n }\n}\n",
|
|
10
8
|
"frontera/core/app-state.ts": "/**\n * Shared app state: typed, validated, and legible to a mounted agent.\n *\n * Free-form JSON was explicitly rejected. It types as `any`, so a typo compiles,\n * and a write from a stale app version or a guessing agent can put a string\n * where the app expects an object and take the render down — worse than V1,\n * where a schema made invalid state unrepresentable.\n *\n * One declaration does three jobs: it types `useAppState` via inference, it\n * validates every read and write at runtime, and the build emits it as JSON\n * Schema so the agent receives a typed tool rather than a free-form object.\n */\n\n/** Minimal structural type for a Zod-like schema, so this file has no Zod dependency. */\nexport interface StandardSchema<T> {\n safeParse(value: unknown): { success: true; data: T } | { success: false; error: unknown }\n}\n\nexport interface AppStateDefinition<T extends Record<string, unknown>> {\n schema: StandardSchema<T>\n defaults: T\n /** Per-key schemas, when available, enable per-key recovery. */\n shape?: Record<string, StandardSchema<unknown>>\n}\n\nexport interface DefineAppStateInput<T extends Record<string, unknown>> {\n schema: StandardSchema<T> & { shape?: Record<string, StandardSchema<unknown>> }\n defaults: T\n}\n\nexport function defineAppState<T extends Record<string, unknown>>(\n input: DefineAppStateInput<T>,\n): AppStateDefinition<T> {\n return {\n schema: input.schema,\n defaults: input.defaults,\n shape: (input.schema as { shape?: Record<string, StandardSchema<unknown>> }).shape,\n }\n}\n\nexport interface RecoveryResult<T> {\n state: T\n /** Keys that failed validation and were reset to their default. */\n repaired: string[]\n /** Keys present in storage but absent from the schema — hidden, not dropped. */\n unknown: string[]\n}\n\n/**\n * Parse a stored state document into something the app can render.\n *\n * **Invariant: this never throws.** Recovery is per key — a malformed or\n * missing key resets to its default while valid keys survive — so the app\n * always receives a well-formed, correctly-typed object. Unknown keys (written\n * by a newer version, or invented by an agent) are hidden from the app but\n * PRESERVED by the caller in storage, so a rollback does not lose them.\n *\n * Whole-document validation is tried first because it is the common path and\n * cheap; per-key recovery is the fallback that keeps a single bad field from\n * blanking everything else.\n */\nexport function parseAppState<T extends Record<string, unknown>>(\n definition: AppStateDefinition<T>,\n stored: unknown,\n): RecoveryResult<T> {\n const document = (typeof stored === 'object' && stored !== null ? stored : {}) as Record<string, unknown>\n\n const whole = definition.schema.safeParse(document)\n if (whole.success) {\n // Project onto known keys explicitly rather than trusting the schema to\n // strip: a `.passthrough()` (or any hand-rolled) schema would otherwise\n // hand the app a key it has no type for.\n const projected = {} as Record<string, unknown>\n for (const key of Object.keys(definition.defaults)) {\n projected[key] = (whole.data as Record<string, unknown>)[key] ?? definition.defaults[key]\n }\n return {\n state: projected as T,\n repaired: [],\n unknown: Object.keys(document).filter((k) => !(k in definition.defaults)),\n }\n }\n\n const state = { ...definition.defaults } as Record<string, unknown>\n const repaired: string[] = []\n const unknownKeys: string[] = []\n\n for (const key of Object.keys(document)) {\n if (!(key in definition.defaults)) {\n unknownKeys.push(key)\n continue\n }\n const keySchema = definition.shape?.[key]\n if (!keySchema) {\n // No per-key schema available: accept only if it matches the default's\n // primitive shape, otherwise fall back. Cheap, and better than trusting it.\n const sameShape = typeof document[key] === typeof definition.defaults[key]\n if (sameShape) state[key] = document[key]\n else repaired.push(key)\n continue\n }\n const parsed = keySchema.safeParse(document[key])\n if (parsed.success) state[key] = parsed.data\n else repaired.push(key)\n }\n\n // Keys the document omitted entirely already hold their default.\n return { state: state as T, repaired, unknown: unknownKeys }\n}\n",
|
|
9
|
+
"frontera/core/client.ts": "import { resolveConfig, type FronteraConfig, type FronteraCredential } from './config'\nimport { httpRequest, type RequestOptions } from './transport'\n\n/**\n * The transport-level entry point. Domain clients (`@frontera-sdk/blueprint`, and\n * later chat and automation) take one of these rather than reimplementing\n * auth, URL building, or error mapping.\n *\n * `withCredential` exists because a bridge-hosted app receives a refreshed\n * token mid-session: swapping produces a new client rather than mutating one\n * that in-flight requests may still hold.\n */\nexport class FronteraClient {\n readonly config: FronteraConfig\n private readonly fetchImpl: typeof fetch\n\n constructor(config: Partial<FronteraConfig>, fetchImpl: typeof fetch = fetch) {\n this.config = resolveConfig(config)\n this.fetchImpl = fetchImpl\n }\n\n request<T>(path: string, options: RequestOptions = {}): Promise<T> {\n return httpRequest<T>(this.config, path, options, this.fetchImpl)\n }\n\n withCredential(credential: FronteraCredential): FronteraClient {\n return new FronteraClient({ ...this.config, credential }, this.fetchImpl)\n }\n}\n",
|
|
11
10
|
"frontera/core/transport.ts": "import type { FronteraConfig } from './config'\nimport { errorFromResponse } from './errors'\n\nexport interface RequestOptions {\n // PATCH is included because the platform uses it for partial writes —\n // staging an agent draft is one. The transport already passes any method\n // straight to fetch, so its absence here was a type narrower than the\n // runtime, which type-checks a correct call as an error.\n method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'\n body?: unknown\n query?: Record<string, string | number | boolean | undefined>\n signal?: AbortSignal\n /**\n * Extra headers for ONE request. Added for `idempotency-key`, which the\n * governed write plane reads from the header rather than the body — a key in\n * the payload would be part of the invocation it is meant to deduplicate.\n *\n * Auth is not overridable: the credential headers are applied after these, so\n * a caller cannot swap the principal by passing `authorization` here.\n */\n headers?: Record<string, string>\n}\n\n/**\n * Auth and scope headers for one request.\n *\n * Both credential kinds travel as `Authorization: Bearer`. `x-org-id` and\n * `x-workspace-id` mirror what the web app sends and are what the service's\n * auth middleware reads to scope a session request. A workspace key ignores\n * them and resolves its own scope from the key row, so they can never widen\n * access beyond what the credential already allows.\n */\nexport function authHeaders(config: FronteraConfig): Record<string, string> {\n const token =\n config.credential.kind === 'apiKey' ? config.credential.key : config.credential.token\n const headers: Record<string, string> = { authorization: `Bearer ${token}` }\n if (config.orgId) headers['x-org-id'] = config.orgId\n if (config.workspaceId) headers['x-workspace-id'] = config.workspaceId\n return headers\n}\n\n/**\n * Unwrap the service response envelope when present.\n *\n * `ok()` returns `{ data, message, error: false }`, but several routers — the\n * Blueprint query router among them — return their payload raw. Detect the\n * envelope rather than assuming it.\n */\nexport function unwrap<T>(body: unknown): T {\n if (\n typeof body === 'object' &&\n body !== null &&\n !Array.isArray(body) &&\n (body as { error?: unknown }).error === false &&\n 'data' in body\n ) {\n return (body as { data: T }).data\n }\n return body as T\n}\n\nfunction lowerCasedKeys(headers: Record<string, string> | undefined): Record<string, string> {\n if (!headers) return {}\n return Object.fromEntries(\n Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]),\n )\n}\n\nfunction buildUrl(config: FronteraConfig, path: string, query?: RequestOptions['query']): string {\n const url = new URL(`${config.apiBaseUrl}${path.startsWith('/') ? path : `/${path}`}`)\n if (query) {\n for (const [key, value] of Object.entries(query)) {\n if (value === undefined) continue\n url.searchParams.set(key, String(value))\n }\n }\n return url.toString()\n}\n\n/**\n * `fetchImpl` is injectable so tests never touch the network. Production\n * callers omit it and get the platform `fetch`.\n */\nexport async function httpRequest<T>(\n config: FronteraConfig,\n path: string,\n options: RequestOptions = {},\n fetchImpl: typeof fetch = fetch,\n): Promise<T> {\n // Caller headers first, so the credential and content type below win. A\n // request that could overwrite `authorization` would let any call site act as\n // another principal, which is precisely what the transport exists to prevent.\n //\n // Lower-cased on the way in, because \"later key wins\" is a property of the\n // OBJECT and header names are case-insensitive on the wire: `Authorization`\n // and `authorization` are two distinct properties here and one name to\n // `Headers`, which COMBINES them into `forged, real` rather than letting the\n // credential replace the forgery.\n const headers: Record<string, string> = {\n ...lowerCasedKeys(options.headers),\n ...authHeaders(config),\n accept: 'application/json',\n }\n if (options.body !== undefined) headers['content-type'] = 'application/json'\n\n const response = await fetchImpl(buildUrl(config, path, options.query), {\n method: options.method ?? 'GET',\n headers,\n body: options.body === undefined ? undefined : JSON.stringify(options.body),\n signal: options.signal,\n })\n\n const text = await response.text()\n let parsed: unknown = null\n if (text.length > 0) {\n try {\n parsed = JSON.parse(text)\n } catch {\n parsed = text\n }\n }\n\n if (!response.ok) throw errorFromResponse(response.status, parsed)\n return unwrap<T>(parsed)\n}\n",
|
|
12
|
-
"frontera/core/
|
|
11
|
+
"frontera/core/bridge-protocol.ts": "/**\n * postMessage protocol between the platform host and an app iframe.\n *\n * The app runs on its own origin (`<appId>.apps.<domain>`), so postMessage is\n * the ONLY channel. Both sides validate `event.origin` on every message: Luigi,\n * a mature framework with this same architecture, carries an open issue for\n * omitting exactly that check, and the consequence is any page being able to\n * drive an embedded app.\n */\n\nexport interface BridgeInit {\n type: 'frontera:init'\n /** Scoped API credential. Arrives here, never in a URL. */\n token: string\n apiBaseUrl: string\n appId: string\n version: string\n orgId: string | null\n workspaceId: string | null\n /**\n * The host's palette and light/dark state.\n *\n * Defaults, not a house style: `applyHostTheme` writes them behind the app's\n * own stylesheets so a customer's brand still wins.\n */\n theme: { tokens: Record<string, string>; colorScheme?: 'light' | 'dark'; fontCss?: string }\n /** Current shared app state, already validated by the host. */\n state: Record<string, unknown>\n /**\n * Where the app should open, taken from the platform URL.\n *\n * The counterpart to `frontera:navigate`: without it a shared link to\n * `/apps/<slug>/shipments` mounts the app at its home page, and the address\n * bar describes a view the user is not looking at. Absent means the app's\n * own default.\n */\n path?: string\n}\n\nexport type HostMessage =\n | BridgeInit\n | { type: 'frontera:state'; state: Record<string, unknown> }\n | {\n type: 'frontera:theme'\n tokens: Record<string, string>\n colorScheme?: 'light' | 'dark'\n fontCss?: string\n }\n | { type: 'frontera:token'; token: string }\n\nexport type AppMessage =\n | { type: 'frontera:ready' }\n | { type: 'frontera:navigate'; path: string }\n | { type: 'frontera:resize'; height: number }\n | { type: 'frontera:state-write'; patch: Record<string, unknown> }\n | { type: 'frontera:error'; message: string; stack?: string }\n | { type: 'frontera:state-repair'; keys: string[] }\n\nexport function isHostMessage(data: unknown): data is HostMessage {\n if (!data || typeof data !== 'object') return false\n const m = data as Record<string, unknown>\n switch (m.type) {\n case 'frontera:init':\n return typeof m.token === 'string' && typeof m.apiBaseUrl === 'string' && typeof m.appId === 'string'\n case 'frontera:state':\n return typeof m.state === 'object' && m.state !== null\n case 'frontera:theme':\n return typeof m.tokens === 'object' && m.tokens !== null\n case 'frontera:token':\n return typeof m.token === 'string'\n default:\n return false\n }\n}\n\nexport function isAppMessage(data: unknown): data is AppMessage {\n if (!data || typeof data !== 'object') return false\n const m = data as Record<string, unknown>\n switch (m.type) {\n case 'frontera:ready':\n return true\n case 'frontera:navigate':\n return typeof m.path === 'string'\n case 'frontera:resize':\n return typeof m.height === 'number' && Number.isFinite(m.height)\n case 'frontera:state-write':\n return typeof m.patch === 'object' && m.patch !== null\n case 'frontera:error':\n return typeof m.message === 'string'\n case 'frontera:state-repair':\n return Array.isArray(m.keys)\n default:\n return false\n }\n}\n",
|
|
13
12
|
"frontera/core/config.ts": "import { FronteraError } from './errors'\n\n/**\n * How a caller proves who it is.\n *\n * `apiKey` is a long-lived `sk-ws-` workspace key, used by servers and CLIs.\n * `token` is a short-lived credential minted by the platform and handed to an\n * app at bridge handshake. Both travel as `Authorization: Bearer`; they are\n * distinct variants because refresh behaviour differs and callers branch on it.\n */\nexport type FronteraCredential =\n | { kind: 'apiKey'; key: string }\n | { kind: 'token'; token: string }\n\nexport interface FronteraConfig {\n /** Origin of the Frontera API, without a trailing slash. */\n apiBaseUrl: string\n orgId?: string\n workspaceId?: string\n credential: FronteraCredential\n}\n\n/**\n * Shape the app runtime injects into the page at serve time.\n *\n * This mirrors `RuntimeConfig` on the serving side, in full. It was previously\n * narrowed to the three fields `resolveConfig` reads, which made the other two\n * invisible to every caller — and `platformOrigin` is the one an app cannot do\n * without: responses carry `referrer-policy: no-referrer`, so `document.referrer`\n * is empty and this is the ONLY way to learn who is framing you.\n */\nexport interface FronteraRuntimeGlobal {\n apiBaseUrl?: string\n orgId?: string\n workspaceId?: string\n appId?: string\n version?: string\n platformOrigin?: string\n /** Same-origin short-lived user session for direct App visits. */\n sessionEndpoint?: string\n /** Loopback-only CLI broker used during authenticated local development. */\n devSessionEndpoint?: string\n}\n\nexport function readRuntimeGlobal(): FronteraRuntimeGlobal {\n const injected = (globalThis as { __FRONTERA_CONFIG__?: FronteraRuntimeGlobal })\n .__FRONTERA_CONFIG__\n return injected ?? {}\n}\n\nfunction readGlobal(): FronteraRuntimeGlobal {\n return readRuntimeGlobal()\n}\n\nfunction missing(field: string): never {\n throw new FronteraError(\n `Frontera SDK is not configured: ${field} is missing. Pass it explicitly, or set the matching FRONTERA_* environment variable.`,\n { code: 'CONFIG_MISSING' },\n )\n}\n\n/**\n * Resolve configuration from, in order of precedence: explicit argument, the\n * runtime global injected into an app page, then environment variables.\n *\n * `env` is a parameter rather than a direct `process.env` read so tests never\n * mutate global state, and so this works unchanged in a browser bundle where\n * `process` does not exist.\n */\nexport function resolveConfig(\n explicit?: Partial<FronteraConfig>,\n env: Record<string, string | undefined> = typeof process === 'undefined' ? {} : process.env,\n): FronteraConfig {\n const injected = readGlobal()\n\n const rawBaseUrl = explicit?.apiBaseUrl ?? injected.apiBaseUrl ?? env.FRONTERA_API_URL\n if (!rawBaseUrl) missing('apiBaseUrl')\n\n const credential: FronteraCredential | undefined =\n explicit?.credential ??\n (env.FRONTERA_TOKEN ? { kind: 'apiKey', key: env.FRONTERA_TOKEN } : undefined)\n if (!credential) missing('credential')\n\n return {\n apiBaseUrl: rawBaseUrl.replace(/\\/+$/, ''),\n orgId: explicit?.orgId ?? injected.orgId ?? env.FRONTERA_ORG_ID,\n workspaceId: explicit?.workspaceId ?? injected.workspaceId ?? env.FRONTERA_WORKSPACE_ID,\n credential,\n }\n}\n",
|
|
14
|
-
"frontera/core/
|
|
13
|
+
"frontera/core/theme.ts": "/**\n * Applying the host's palette inside an app.\n *\n * The point is that an app sitting in the platform should not look pasted in —\n * it follows the deployment's colours and its light/dark state without the\n * author wiring anything.\n *\n * The hard part is precedence. An app that has defined its own brand must keep\n * it; the host is supplying DEFAULTS, not a house style. So host tokens are\n * written into a stylesheet inserted as the FIRST child of `<head>`, before the\n * app's own imports. Same `:root` specificity, earlier in the cascade — the\n * app's `brand.css` still wins every token it names, and inherits the rest.\n *\n * Inline styles on the root element would have been simpler and wrong: they\n * beat every stylesheet, so the host would silently overwrite the customer's\n * brand the moment it was mounted.\n */\n\nconst STYLE_ID = 'frontera-host-theme'\n\nexport interface HostTheme {\n tokens: Record<string, string>\n colorScheme?: 'light' | 'dark'\n}\n\n/** CSS custom properties only — never arbitrary declarations from the host. */\nfunction isTokenName(name: string): boolean {\n return /^--[a-zA-Z0-9_-]+$/.test(name)\n}\n\n/**\n * A token value the host sent.\n *\n * Restricted because this string is interpolated into a stylesheet: `}` would\n * close the rule and let anything after it become a new one. The host is\n * trusted, but a value that escapes its rule is a bug class worth closing at\n * the boundary rather than reasoning about.\n */\nfunction isTokenValue(value: string): boolean {\n return typeof value === 'string' && value.length < 200 && !/[{}<>;]/.test(value)\n}\n\nexport function applyHostTheme(theme: HostTheme | undefined): void {\n if (typeof document === 'undefined' || !theme) return\n\n const declarations = Object.entries(theme.tokens ?? {})\n .filter(([name, value]) => isTokenName(name) && isTokenValue(value))\n .map(([name, value]) => `${name}: ${value};`)\n .join('\\n ')\n\n if (declarations) {\n let style = document.getElementById(STYLE_ID) as HTMLStyleElement | null\n if (!style) {\n style = document.createElement('style')\n style.id = STYLE_ID\n // First in <head>, so every app stylesheet that follows can override.\n document.head.prepend(style)\n }\n style.textContent = `:root {\\n ${declarations}\\n}`\n }\n\n if (theme.colorScheme) {\n const dark = theme.colorScheme === 'dark'\n document.documentElement.classList.toggle('dark', dark)\n // Tells the browser to pick the matching form controls and scrollbars,\n // which no amount of custom properties can do.\n document.documentElement.style.colorScheme = dark ? 'dark' : 'light'\n }\n}\n",
|
|
14
|
+
"frontera/core/errors.ts": "/**\n * Error taxonomy for the Frontera SDK.\n *\n * `code` mirrors the service's `ErrorCode` enum\n * (packages/service/src/lib/errors.ts) so callers branch on the same strings\n * the API emits. Codes are deliberately not enumerated here — the service adds\n * new ones, and an SDK that rejected unknown codes would need a release every\n * time it did.\n */\nexport class FronteraError extends Error {\n readonly code: string\n readonly status?: number\n readonly details?: unknown\n\n constructor(message: string, opts: { code: string; status?: number; details?: unknown }) {\n super(message)\n this.name = 'FronteraError'\n this.code = opts.code\n this.status = opts.status\n this.details = opts.details\n }\n}\n\n/** Codes used when the response body carries no envelope of its own. */\nconst STATUS_FALLBACK: Record<number, string> = {\n 400: 'BAD_REQUEST',\n 401: 'UNAUTHORIZED',\n 403: 'FORBIDDEN',\n 404: 'NOT_FOUND',\n 409: 'CONFLICT',\n 429: 'RATE_LIMITED',\n}\n\nfunction isErrorEnvelope(body: unknown): body is { error: true; code?: string; message?: string } {\n return typeof body === 'object' && body !== null && (body as { error?: unknown }).error === true\n}\n\n/** Turn a non-2xx response body into a `FronteraError`. */\nexport function errorFromResponse(status: number, body: unknown): FronteraError {\n if (isErrorEnvelope(body)) {\n return new FronteraError(body.message ?? `request failed with ${status}`, {\n code: body.code ?? STATUS_FALLBACK[status] ?? 'INTERNAL_ERROR',\n status,\n details: body,\n })\n }\n return new FronteraError(`request failed with ${status}`, {\n code: STATUS_FALLBACK[status] ?? 'INTERNAL_ERROR',\n status,\n details: body ?? undefined,\n })\n}\n",
|
|
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\nexport function detectAppMode(\n runtime: FronteraRuntimeGlobal,\n framed: boolean,\n): FronteraAppMode | null {\n if (framed && runtime.platformOrigin) 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) throw new Error(`Frontera session request failed with ${response.status}`)\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 dispose() {\n disposed = true\n if (timer !== undefined) clearTimer(timer)\n tokenHandlers.clear()\n },\n }\n}\n",
|
|
16
16
|
"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",
|
|
17
|
-
"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/** Where a Request has got to. Only `succeeded` and the failures are terminal. */\nexport type ActionRequestLifecycle =\n | 'ready'\n | 'awaiting_approval'\n | 'dispatching'\n | 'finalizing'\n | 'succeeded'\n | 'failed'\n | 'cancelled'\n | 'rejected'\n | 'awaiting_resolution'\n\n/**\n * What is known about the effect on the target — NOT whether the request is\n * finished. `outcome_unknown` is the honest state after a dispatch whose\n * outcome could not be established, and a UI must not render it as failure:\n * the write may well have landed.\n */\nexport type ActionEffectCertainty =\n | 'not_attempted'\n | 'confirmed_applied'\n | 'confirmed_not_applied'\n | 'outcome_unknown'\n\nconst TERMINAL: ReadonlySet<ActionRequestLifecycle> = new Set([\n 'succeeded', 'failed', 'cancelled', 'rejected',\n])\n\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 'confirmed_not_applied': return 'refused'\n case 'outcome_unknown': return 'uncertain'\n default: break\n }\n // No certainty reported. A terminal lifecycle still answers the question —\n // a rejected or cancelled Request never reached the target at all — while\n // anything else is genuinely still in flight.\n return request.lifecycle && isTerminalLifecycle(request.lifecycle)\n && request.lifecycle !== 'succeeded'\n ? 'refused'\n : 'pending'\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 from the INSTANCE\n * route — a list query's rows do not carry it, and only an editable type has\n * one at all. Reaching into `_meta` by hand is how a caller ends up sending\n * `undefined`, which does not fail: the compare-and-set is simply skipped, and\n * two people overwrite each other with no refusal and no evidence.\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\nexport interface ActionApprovalPolicy {\n mode: 'none' | 'required'\n threshold?: number\n separationOfDuties?: boolean\n}\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\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\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 /** Primary key of the object being changed. Omit only for `mode: 'create'`. */\n objectId?: string\n /** Parameter values, keyed by the Action's parameter API names. */\n input: Record<string, unknown>\n /**\n * The version the caller believes it read. Sent as the subject version AND,\n * when the Action declares a compare-and-set parameter, as that parameter —\n * so a stale write is refused rather than clobbering a concurrent 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-client.ts": "import type { FronteraClient } from '@frontera-sdk/core/client'\nimport type {\n ActionDescriptor,\n ActionRequest,\n ActionRequestLifecycle,\n SubmitActionInput,\n} from './action-types'\n\nconst BASE = '/v1/blueprint/governed-actions'\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 */\nexport class ActionClient {\n constructor(private readonly client: FronteraClient) {}\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 * 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 return this.client.request<ActionRequest[]>(`${BASE}/requests`, {\n query: lifecycle?.length ? { lifecycle: lifecycle.join(',') } : undefined,\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 decide(requestId: string, decision: 'approve' | 'reject', reason: string): Promise<ActionRequest> {\n return this.client.request<ActionRequest>(\n `${BASE}/requests/${encodeURIComponent(requestId)}/approvals`,\n { method: 'POST', body: { decision, reason } },\n )\n }\n\n /**\n * Cancel takes NO body. It reads the request and the caller from the URL and\n * the credential; a `reason` sent here is refused as an invalid command body\n * rather than ignored, because the route accepts an exact key set.\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/**\n * The wire envelope, assembled from the flat input a caller actually has.\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",
|
|
19
|
-
"frontera/blueprint/blueprint-client.ts": "import type { FronteraClient } from '@frontera-sdk/core/client'\nimport type {\n AggregateGroupBy,\n AggregateRequest,\n AggregateResponse,\n BlueprintFilterableProperty,\n BlueprintObjectName,\n BlueprintRow,\n MetricQueryRequest,\n ObjectInstance,\n QueryRequest,\n QueryResponse,\n} from './types'\n\n/**\n * Typed client for the Blueprint read API.\n *\n * Every method is workspace-scoped by the credential on the underlying\n * `FronteraClient`. An object type the workspace has not been granted is absent\n * from the server's catalog snapshot, so the query compiler raises\n * `UNKNOWN_OBJECT_TYPE` — grant violations arrive as a 404, not a 403, and that\n * is intentional: the caller cannot distinguish \"does not exist\" from \"not\n * granted\", which is the point.\n */\nexport class BlueprintClient {\n constructor(private readonly client: FronteraClient) {}\n\n query<TRow = Record<string, unknown>>(request: QueryRequest): Promise<QueryResponse<TRow>> {\n return this.client.request<QueryResponse<TRow>>('/v1/blueprint/query', {\n method: 'POST',\n body: request,\n })\n }\n\n queryObjectSet<TRow = Record<string, unknown>>(\n objectSetId: string,\n request: Omit<QueryRequest, 'objectSet'> = {},\n ): Promise<QueryResponse<TRow>> {\n return this.client.request<QueryResponse<TRow>>(\n `/v1/blueprint/object-sets/${encodeURIComponent(objectSetId)}/query`,\n { method: 'POST', body: request },\n )\n }\n\n /**\n * Run an aggregation.\n *\n * `groupBy` is required by the service; it is defaulted here so a grand\n * total reads as `aggregate({ objectSet, aggregations })` rather than\n * forcing every caller to remember an empty array.\n */\n aggregate(\n request: Omit<AggregateRequest, 'groupBy'> & { groupBy?: AggregateGroupBy[] },\n ): Promise<AggregateResponse> {\n return this.client.request<AggregateResponse>('/v1/blueprint/aggregate', {\n method: 'POST',\n // Default AFTER the spread: an explicit `undefined` in the request must\n // not win over the fallback.\n body: { ...request, groupBy: request.groupBy ?? [] },\n })\n }\n\n instance<\n TLegacy extends object = never,\n const TObject extends BlueprintObjectName = BlueprintObjectName,\n >(\n objectType: TObject,\n pk: string,\n ): Promise<ObjectInstance<[TLegacy] extends [never] ? BlueprintRow<TObject> : TLegacy>> {\n return this.client.request<ObjectInstance<[TLegacy] extends [never] ? BlueprintRow<TObject> : TLegacy>>(\n `/v1/blueprint/object-types/${encodeURIComponent(objectType)}/instances/${encodeURIComponent(pk)}`,\n )\n }\n\n /**\n * Distinct values for one property — the source for a filter's options.\n *\n * The service answers `{ kind: 'values', values, truncated }`, not a bare\n * array. This returned the envelope while claiming `string[]`, so every\n * caller that trusted the type got an object where it expected a list — and\n * `.map` on it threw at runtime in code that type-checked.\n *\n * `truncated` is dropped here deliberately: it means the distinct set hit the\n * service's cap, which a filter cannot act on beyond showing what it has.\n * Use `propertyValuesWithTruncation` when it matters.\n */\n async propertyValues<const TObject extends BlueprintObjectName = BlueprintObjectName>(\n objectType: TObject,\n property: BlueprintFilterableProperty<TObject>,\n ): Promise<string[]> {\n return (await this.propertyValuesWithTruncation(objectType, property)).values\n }\n\n /**\n * Narrowed against the committed contract, like `instance` above: a property\n * the workspace does not expose for filtering has no distinct-value endpoint,\n * and asking for one is a 400 that only shows up when a filter is opened.\n * Before generation, `BlueprintFilterableProperty` is `string` and this is the\n * loose signature it always was.\n */\n propertyValuesWithTruncation<const TObject extends BlueprintObjectName = BlueprintObjectName>(\n objectType: TObject,\n property: BlueprintFilterableProperty<TObject>,\n ): Promise<{ values: string[]; truncated: boolean }> {\n return this.client\n .request<{ kind?: string; values?: string[]; truncated?: boolean }>(\n `/v1/blueprint/object-types/${encodeURIComponent(objectType)}/properties/${encodeURIComponent(property)}/values`,\n )\n // `Array.isArray`, not a truthiness check: reading `.values` off an ARRAY\n // returns `Array.prototype.values` — the iterator function — so a payload\n // in the older bare-array shape would hand every caller a function where\n // it expected a list, which is a worse failure than the one being fixed.\n .then((payload) => ({\n values: Array.isArray(payload?.values)\n ? payload.values\n : Array.isArray(payload) ? (payload as string[]) : [],\n truncated: payload?.truncated === true,\n }))\n }\n\n metricQuery(apiName: string, request: MetricQueryRequest = {}): Promise<AggregateResponse> {\n return this.client.request<AggregateResponse>(\n `/v1/blueprint/metrics/${encodeURIComponent(apiName)}/query`,\n { method: 'POST', body: request },\n )\n }\n}\n",
|
|
20
17
|
"frontera/blueprint/action-hooks.ts": "import { createContext, useContext, useEffect } from 'react'\nimport {\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 actionEffectOf,\n isTerminalLifecycle,\n type ActionDescriptor,\n type ActionRequest,\n type ActionRequestLifecycle,\n type SubmitActionInput,\n} from './action-types'\n\nexport const actionKeys = {\n all: ['blueprint', 'actions'] as const,\n discovery: () => ['blueprint', 'actions', 'discovery'] 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}\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 ...options,\n })\n}\n\n/**\n * One Action by name, or `null` when this user may not invoke it.\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.\n */\nexport function useAction(\n apiName: string,\n options: ReadOptions<ActionDescriptor[]> = {},\n): { action: ActionDescriptor | null; isLoading: boolean; error: Error | null } {\n const { data, isLoading, error } = useActions(options)\n return {\n action: data?.find((entry) => entry.apiName === apiName) ?? null,\n isLoading,\n error: error ?? null,\n }\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 },\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/**\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 */\nexport function useDecideActionRequest(): UseMutationResult<\n ActionRequest,\n Error,\n { requestId: string; decision: 'approve' | 'reject'; reason: string }\n> {\n const client = useActionClient()\n const queryClient = useQueryClient()\n\n return useMutation({\n mutationFn: ({ requestId, decision, reason }) => client.decide(requestId, decision, reason),\n onSuccess: (request) => {\n queryClient.setQueryData(actionKeys.request(request.id), request)\n void queryClient.invalidateQueries({ queryKey: actionKeys.requests() })\n },\n })\n}\n",
|
|
18
|
+
"frontera/blueprint/blueprint-client.ts": "import type { FronteraClient } from '@frontera-sdk/core/client'\nimport type {\n AggregateGroupBy,\n AggregateRequest,\n AggregateResponse,\n BlueprintFilterableProperty,\n BlueprintObjectName,\n BlueprintRow,\n MetricQueryRequest,\n ObjectInstance,\n QueryRequest,\n QueryResponse,\n} from './types'\n\n/**\n * Typed client for the Blueprint read API.\n *\n * Every method is workspace-scoped by the credential on the underlying\n * `FronteraClient`. An object type the workspace has not been granted is absent\n * from the server's catalog snapshot, so the query compiler raises\n * `UNKNOWN_OBJECT_TYPE` — grant violations arrive as a 404, not a 403, and that\n * is intentional: the caller cannot distinguish \"does not exist\" from \"not\n * granted\", which is the point.\n */\nexport class BlueprintClient {\n constructor(private readonly client: FronteraClient) {}\n\n query<TRow = Record<string, unknown>>(request: QueryRequest): Promise<QueryResponse<TRow>> {\n return this.client.request<QueryResponse<TRow>>('/v1/blueprint/query', {\n method: 'POST',\n body: request,\n })\n }\n\n queryObjectSet<TRow = Record<string, unknown>>(\n objectSetId: string,\n request: Omit<QueryRequest, 'objectSet'> = {},\n ): Promise<QueryResponse<TRow>> {\n return this.client.request<QueryResponse<TRow>>(\n `/v1/blueprint/object-sets/${encodeURIComponent(objectSetId)}/query`,\n { method: 'POST', body: request },\n )\n }\n\n /**\n * Run an aggregation.\n *\n * `groupBy` is required by the service; it is defaulted here so a grand\n * total reads as `aggregate({ objectSet, aggregations })` rather than\n * forcing every caller to remember an empty array.\n */\n aggregate(\n request: Omit<AggregateRequest, 'groupBy'> & { groupBy?: AggregateGroupBy[] },\n ): Promise<AggregateResponse> {\n return this.client.request<AggregateResponse>('/v1/blueprint/aggregate', {\n method: 'POST',\n // Default AFTER the spread: an explicit `undefined` in the request must\n // not win over the fallback.\n body: { ...request, groupBy: request.groupBy ?? [] },\n })\n }\n\n instance<\n TLegacy extends object = never,\n const TObject extends BlueprintObjectName = BlueprintObjectName,\n >(\n objectType: TObject,\n pk: string,\n ): Promise<ObjectInstance<[TLegacy] extends [never] ? BlueprintRow<TObject> : TLegacy>> {\n return this.client.request<ObjectInstance<[TLegacy] extends [never] ? BlueprintRow<TObject> : TLegacy>>(\n `/v1/blueprint/object-types/${encodeURIComponent(objectType)}/instances/${encodeURIComponent(pk)}`,\n )\n }\n\n /**\n * Distinct values for one property — the source for a filter's options.\n *\n * The service answers `{ kind: 'values', values, truncated }`, not a bare\n * array. This returned the envelope while claiming `string[]`, so every\n * caller that trusted the type got an object where it expected a list — and\n * `.map` on it threw at runtime in code that type-checked.\n *\n * `truncated` is dropped here deliberately: it means the distinct set hit the\n * service's cap, which a filter cannot act on beyond showing what it has.\n * Use `propertyValuesWithTruncation` when it matters.\n */\n async propertyValues<const TObject extends BlueprintObjectName = BlueprintObjectName>(\n objectType: TObject,\n property: BlueprintFilterableProperty<TObject>,\n ): Promise<string[]> {\n return (await this.propertyValuesWithTruncation(objectType, property)).values\n }\n\n /**\n * Narrowed against the committed contract, like `instance` above: a property\n * the workspace does not expose for filtering has no distinct-value endpoint,\n * and asking for one is a 400 that only shows up when a filter is opened.\n * Before generation, `BlueprintFilterableProperty` is `string` and this is the\n * loose signature it always was.\n */\n propertyValuesWithTruncation<const TObject extends BlueprintObjectName = BlueprintObjectName>(\n objectType: TObject,\n property: BlueprintFilterableProperty<TObject>,\n ): Promise<{ values: string[]; truncated: boolean }> {\n return this.client\n .request<{ kind?: string; values?: string[]; truncated?: boolean }>(\n `/v1/blueprint/object-types/${encodeURIComponent(objectType)}/properties/${encodeURIComponent(property)}/values`,\n )\n // `Array.isArray`, not a truthiness check: reading `.values` off an ARRAY\n // returns `Array.prototype.values` — the iterator function — so a payload\n // in the older bare-array shape would hand every caller a function where\n // it expected a list, which is a worse failure than the one being fixed.\n .then((payload) => ({\n values: Array.isArray(payload?.values)\n ? payload.values\n : Array.isArray(payload) ? (payload as string[]) : [],\n truncated: payload?.truncated === true,\n }))\n }\n\n metricQuery(apiName: string, request: MetricQueryRequest = {}): Promise<AggregateResponse> {\n return this.client.request<AggregateResponse>(\n `/v1/blueprint/metrics/${encodeURIComponent(apiName)}/query`,\n { method: 'POST', body: request },\n )\n }\n}\n",
|
|
19
|
+
"frontera/blueprint/provider.tsx": "import type { ReactNode } from 'react'\nimport type { FronteraClient } from '@frontera-sdk/core/client'\n\nimport { ActionClient } from './action-client'\nimport { FronteraActionContext } from './action-hooks'\nimport { BlueprintClient } from './blueprint-client'\nimport { FronteraBlueprintContext } from './hooks'\n\n/**\n * Plug Blueprint reads AND governed writes into `createFronteraApp`.\n *\n * The dependency runs one way — `@frontera-sdk/blueprint` knows about\n * `@frontera-sdk/core`, never the reverse — so the app entry point composes the\n * two rather than sdk-core importing a domain package it should not know exists.\n * That is why this is a callback the entry passes in:\n *\n * ```tsx\n * createFronteraApp(<App />, { providers: [blueprintProvider] })\n * ```\n *\n * Both clients come from ONE provider deliberately. Splitting them would mean\n * an app that reads compiles and an app that acts throws at runtime with\n * \"no ActionClient in context\" — a failure no type checks and every author\n * hits exactly once, on the line where they added their first button.\n *\n * Both are rebuilt whenever the host rotates the credential, because the\n * `FronteraClient` they wrap is replaced rather than mutated. That matters more\n * for writes than reads: a submit carrying a stale token is refused after the\n * user has already confirmed the thing they wanted to happen.\n */\nexport function blueprintProvider(\n value: { client: FronteraClient },\n children: ReactNode,\n): ReactNode {\n return (\n <FronteraBlueprintContext.Provider value={new BlueprintClient(value.client)}>\n <FronteraActionContext.Provider value={new ActionClient(value.client)}>\n {children}\n </FronteraActionContext.Provider>\n </FronteraBlueprintContext.Provider>\n )\n}\n",
|
|
20
|
+
"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/** Where a Request has got to. Only `succeeded` and the failures are terminal. */\nexport type ActionRequestLifecycle =\n | 'ready'\n | 'awaiting_approval'\n | 'dispatching'\n | 'finalizing'\n | 'succeeded'\n | 'failed'\n | 'cancelled'\n | 'rejected'\n | 'awaiting_resolution'\n\n/**\n * What is known about the effect on the target — NOT whether the request is\n * finished. `outcome_unknown` is the honest state after a dispatch whose\n * outcome could not be established, and a UI must not render it as failure:\n * the write may well have landed.\n */\nexport type ActionEffectCertainty =\n | 'not_attempted'\n | 'confirmed_applied'\n | 'confirmed_not_applied'\n | 'outcome_unknown'\n\nconst TERMINAL: ReadonlySet<ActionRequestLifecycle> = new Set([\n 'succeeded', 'failed', 'cancelled', 'rejected',\n])\n\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 'confirmed_not_applied': return 'refused'\n case 'outcome_unknown': return 'uncertain'\n default: break\n }\n // No certainty reported. A terminal lifecycle still answers the question —\n // a rejected or cancelled Request never reached the target at all — while\n // anything else is genuinely still in flight.\n return request.lifecycle && isTerminalLifecycle(request.lifecycle)\n && request.lifecycle !== 'succeeded'\n ? 'refused'\n : 'pending'\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 from the INSTANCE\n * route — a list query's rows do not carry it, and only an editable type has\n * one at all. Reaching into `_meta` by hand is how a caller ends up sending\n * `undefined`, which does not fail: the compare-and-set is simply skipped, and\n * two people overwrite each other with no refusal and no evidence.\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\nexport interface ActionApprovalPolicy {\n mode: 'none' | 'required'\n threshold?: number\n separationOfDuties?: boolean\n}\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\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\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 /** Primary key of the object being changed. Omit only for `mode: 'create'`. */\n objectId?: string\n /** Parameter values, keyed by the Action's parameter API names. */\n input: Record<string, unknown>\n /**\n * The version the caller believes it read. Sent as the subject version AND,\n * when the Action declares a compare-and-set parameter, as that parameter —\n * so a stale write is refused rather than clobbering a concurrent 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",
|
|
21
21
|
"frontera/blueprint/types.ts": "/**\n * Request and response types for the Blueprint read API.\n *\n * `ObjectSetExpr` is deliberately loose. The service validates the expression\n * tree in `query/validate.ts` with error messages far better than a structural\n * type could produce, and mirroring that grammar here would mean shipping an\n * SDK release every time the service gained a node kind. The common shapes are\n * named so callers get completion for what they actually write.\n */\n/**\n * Condition operators, mirroring the service's `ConditionOp`.\n *\n * Filtering belongs in the object-set expression, NOT in the app: a client-side\n * `.filter()` over a fetched page silently reduces \"8,961 cancelled shipments\"\n * to \"the cancelled ones that happened to be in the last 200 rows\", and the\n * counts stop matching the table.\n */\nexport type ConditionOp =\n | 'eq' | 'ne' | 'in' | 'notIn' | 'contains' | 'startsWith'\n | 'isNull' | 'isNotNull'\n | 'gt' | 'gte' | 'lt' | 'lte' | 'between'\n | 'dateRange'\n\nexport type DatePreset =\n | 'TODAY' | 'YESTERDAY' | 'LAST_7_DAYS' | 'LAST_30_DAYS' | 'LAST_90_DAYS'\n | 'THIS_MONTH' | 'LAST_MONTH' | 'THIS_QUARTER' | 'THIS_YEAR'\n\n/**\n * Workspace-specific object types are added here by the App-local generated\n * file. An empty registry deliberately degrades to the existing string-keyed\n * SDK so Apps do not need code generation to remain compatible.\n */\nexport interface BlueprintRegistry {}\n\nexport interface BlueprintObjectSchema<\n TRow,\n TFilterable extends keyof TRow & string,\n TSortable extends keyof TRow & string,\n> {\n row: TRow\n filterable: TFilterable\n sortable: TSortable\n}\n\ntype RegisteredObjectName = Extract<keyof BlueprintRegistry, string>\n\nexport type BlueprintObjectName =\n [RegisteredObjectName] extends [never] ? string : RegisteredObjectName\n\nexport type BlueprintRow<TObject extends BlueprintObjectName> =\n TObject extends keyof BlueprintRegistry\n ? BlueprintRegistry[TObject] extends BlueprintObjectSchema<infer TRow, any, any>\n ? TRow\n : never\n : Record<string, unknown>\n\nexport type BlueprintFilterableProperty<TObject extends BlueprintObjectName> =\n TObject extends keyof BlueprintRegistry\n ? BlueprintRegistry[TObject] extends BlueprintObjectSchema<infer _TRow, infer TFilterable, infer _TSortable>\n ? TFilterable\n : never\n : string\n\nexport type BlueprintSortableProperty<TObject extends BlueprintObjectName> =\n TObject extends keyof BlueprintRegistry\n ? BlueprintRegistry[TObject] extends BlueprintObjectSchema<infer _TRow, infer _TFilterable, infer TSortable>\n ? TSortable\n : never\n : string\n\ntype PropertyValue<TRow, TProperty extends string> =\n TProperty extends keyof TRow ? TRow[TProperty] : unknown\n\nexport type TypedPropertyCondition<TRow, TProperty extends string> =\n TProperty extends unknown\n ? {\n property: TProperty\n op: ConditionOp\n value?: PropertyValue<TRow, TProperty>\n values?: Array<PropertyValue<TRow, TProperty>>\n preset?: DatePreset\n timezone?: string\n }\n : never\n\nexport type TypedWhereNode<TRow, TProperty extends string> =\n | TypedPropertyCondition<TRow, TProperty>\n | { and: Array<TypedWhereNode<TRow, TProperty>> }\n | { or: Array<TypedWhereNode<TRow, TProperty>> }\n | { not: TypedWhereNode<TRow, TProperty> }\n\nexport type BlueprintWhereNode<TObject extends BlueprintObjectName> = TypedWhereNode<\n BlueprintRow<TObject>,\n BlueprintFilterableProperty<TObject>\n>\n\nexport interface PropertyCondition {\n property: string\n op: ConditionOp\n value?: unknown\n values?: unknown[]\n /** `dateRange` only — resolved to [start, end) server-side. */\n preset?: DatePreset\n timezone?: string\n}\n\nexport type WhereNode =\n | PropertyCondition\n | { and: WhereNode[] }\n | { or: WhereNode[] }\n | { not: WhereNode }\n\nexport type ObjectSetExpr =\n | { type: 'base'; objectType: string }\n | { type: 'filter'; objectSet: ObjectSetExpr; where: WhereNode }\n | { type: 'searchAround'; objectSet: ObjectSetExpr; link: string }\n | { type: 'union' | 'intersect' | 'subtract'; objectSets: ObjectSetExpr[] }\n | { type: 'reference'; objectSetId: string }\n | { type: 'static'; objectType: string; pks: Array<string | number> }\n | { type: string; [key: string]: unknown }\n\nexport interface OrderBy {\n property: string\n dir: 'asc' | 'desc'\n}\n\nexport interface QueryRequest {\n objectSet: ObjectSetExpr\n select?: string[]\n orderBy?: OrderBy[]\n pageSize?: number\n /**\n * Cursor for the next page — the previous response's `nextPageToken`.\n *\n * There is no `page`. Offset paging was removed because it has no defined\n * meaning over an unordered scan, and the service now REFUSES a request\n * carrying it (`INVALID_PAGE`) rather than quietly serving page 1 forever.\n * This SDK declared `page` for a while after that, so every caller following\n * the types sent a parameter guaranteed to fail.\n */\n pageToken?: string\n}\n\n/**\n * `properties` is the FULL projection for the object type regardless of what\n * `select` asked for — filter display columns client-side rather than assuming\n * this list matches `select`.\n */\nexport interface QueryResponse<TRow = Record<string, unknown>> {\n rows: TRow[]\n properties: Array<{\n apiName: string\n displayName?: string\n propertyType?: string\n dataType?: string\n }>\n objectType: string\n pageSize: number\n /** Whether another page exists. `nextPageToken` is present exactly when this is true. */\n hasMore: boolean\n /**\n * Pass back as `pageToken`. Its ABSENCE is how a scan learns it has finished\n * — there is no total, so a caller that waits for one waits forever.\n */\n nextPageToken?: string\n}\n\nexport const AGGREGATE_FUNCTIONS = [\n 'count',\n 'sum',\n 'avg',\n 'min',\n 'max',\n 'countDistinct',\n] as const\n\nexport type AggregateFunction = (typeof AGGREGATE_FUNCTIONS)[number]\n\nexport type TimeGrain = 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'\n\nexport interface AggregateSpec {\n /**\n * Result column name.\n *\n * `alias`, not `name` — this mirrors the service's `aggregateBody` schema\n * exactly (blueprint-query-router.ts). Getting it wrong produces a 400 that\n * says only \"Expected required property\", which is a miserable thing to\n * debug from inside an app.\n */\n alias: string\n fn: AggregateFunction\n property?: string\n /** Per-aggregation filter expression, validated server-side. */\n filters?: unknown\n}\n\nexport interface AggregateGroupBy {\n property: string\n /** Set for a temporal property to bucket by grain; the service aliases the\n * result column as `${property}_${grain}`. */\n bucket?: TimeGrain\n}\n\nexport interface AggregateRequest {\n objectSet: ObjectSetExpr\n aggregations: AggregateSpec[]\n /** REQUIRED by the service — pass `[]` for a grand total. */\n groupBy: AggregateGroupBy[]\n having?: unknown\n sort?: Array<{ alias: string; dir: 'asc' | 'desc' }>\n limit?: number\n}\n\nexport interface AggregateResponse {\n rows: Array<Record<string, unknown>>\n}\n\nexport interface MetricQueryRequest {\n measures?: string[]\n dimensions?: string[]\n grain?: TimeGrain\n limit?: number\n}\n\n/**\n * One object, as the service returns it: the property bag itself.\n *\n * NOT wrapped in `{ objectType, primaryKey, properties }` — the instance route\n * responds with the properties flat under the envelope's `data`, so a wrapper\n * type here would make `.properties` permanently undefined and render an empty\n * detail panel with no error to explain it.\n */\nexport type ObjectInstance<TProps = Record<string, unknown>> = TProps\n\n/** Wrap an object set in a filter. `where` undefined returns the set unchanged. */\nexport function filtered(objectSet: ObjectSetExpr, where?: WhereNode): ObjectSetExpr {\n return where ? { type: 'filter', objectSet, where } : objectSet\n}\n\n/** The common case: one object type, optionally filtered. */\nexport function objectsOf(objectType: string, where?: WhereNode): ObjectSetExpr {\n return filtered({ type: 'base', objectType }, where)\n}\n",
|
|
22
|
+
"frontera/blueprint/action-client.ts": "import type { FronteraClient } from '@frontera-sdk/core/client'\nimport type {\n ActionDescriptor,\n ActionRequest,\n ActionRequestLifecycle,\n SubmitActionInput,\n} from './action-types'\n\nconst BASE = '/v1/blueprint/governed-actions'\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 */\nexport class ActionClient {\n constructor(private readonly client: FronteraClient) {}\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 * 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 return this.client.request<ActionRequest[]>(`${BASE}/requests`, {\n query: lifecycle?.length ? { lifecycle: lifecycle.join(',') } : undefined,\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 decide(requestId: string, decision: 'approve' | 'reject', reason: string): Promise<ActionRequest> {\n return this.client.request<ActionRequest>(\n `${BASE}/requests/${encodeURIComponent(requestId)}/approvals`,\n { method: 'POST', body: { decision, reason } },\n )\n }\n\n /**\n * Cancel takes NO body. It reads the request and the caller from the URL and\n * the credential; a `reason` sent here is refused as an invalid command body\n * rather than ignored, because the route accepts an exact key set.\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/**\n * The wire envelope, assembled from the flat input a caller actually has.\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",
|
|
22
23
|
"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 { objectsOf } from './types'\nimport type {\n AggregateRequest,\n AggregateResponse,\n BlueprintFilterableProperty,\n BlueprintObjectName,\n BlueprintRow,\n BlueprintSortableProperty,\n MetricQueryRequest,\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\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\ntype ObjectsOptions<\n TLegacy,\n TObject extends BlueprintObjectName,\n TSelect extends readonly Extract<keyof EffectiveRow<TLegacy, TObject>, string>[] | undefined,\n> = ReadOptions<QueryResponse<SelectedRow<EffectiveRow<TLegacy, TObject>, TSelect>>> &\n Omit<QueryRequest, 'objectSet' | 'select' | 'orderBy'> & {\n select?: TSelect\n orderBy?: Array<{ property: EffectiveSortable<TLegacy, TObject>; dir: 'asc' | 'desc' }>\n where?: TypedWhereNode<EffectiveRow<TLegacy, TObject>, EffectiveFilterable<TLegacy, TObject>>\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>(\n objectType: TObject,\n options: ObjectsOptions<NoInfer<TLegacy>, TObject, TSelect> = {},\n): UseQueryResult<QueryResponse<SelectedRow<EffectiveRow<TLegacy, TObject>, TSelect>>, Error> {\n const { select, orderBy, pageSize, pageToken, where, ...queryOptions } = options\n return useObjectQuery<SelectedRow<EffectiveRow<TLegacy, TObject>, TSelect>>(\n {\n objectSet: objectsOf(objectType, where as WhereNode | undefined),\n select: select ? [...select] : undefined,\n orderBy,\n pageSize,\n pageToken,\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<AggregateResponse> = {},\n): UseQueryResult<AggregateResponse, 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",
|
|
23
|
-
"frontera/blueprint/provider.tsx": "import type { ReactNode } from 'react'\nimport type { FronteraClient } from '@frontera-sdk/core/client'\n\nimport { ActionClient } from './action-client'\nimport { FronteraActionContext } from './action-hooks'\nimport { BlueprintClient } from './blueprint-client'\nimport { FronteraBlueprintContext } from './hooks'\n\n/**\n * Plug Blueprint reads AND governed writes into `createFronteraApp`.\n *\n * The dependency runs one way — `@frontera-sdk/blueprint` knows about\n * `@frontera-sdk/core`, never the reverse — so the app entry point composes the\n * two rather than sdk-core importing a domain package it should not know exists.\n * That is why this is a callback the entry passes in:\n *\n * ```tsx\n * createFronteraApp(<App />, { providers: [blueprintProvider] })\n * ```\n *\n * Both clients come from ONE provider deliberately. Splitting them would mean\n * an app that reads compiles and an app that acts throws at runtime with\n * \"no ActionClient in context\" — a failure no type checks and every author\n * hits exactly once, on the line where they added their first button.\n *\n * Both are rebuilt whenever the host rotates the credential, because the\n * `FronteraClient` they wrap is replaced rather than mutated. That matters more\n * for writes than reads: a submit carrying a stale token is refused after the\n * user has already confirmed the thing they wanted to happen.\n */\nexport function blueprintProvider(\n value: { client: FronteraClient },\n children: ReactNode,\n): ReactNode {\n return (\n <FronteraBlueprintContext.Provider value={new BlueprintClient(value.client)}>\n <FronteraActionContext.Provider value={new ActionClient(value.client)}>\n {children}\n </FronteraActionContext.Provider>\n </FronteraBlueprintContext.Provider>\n )\n}\n",
|
|
24
24
|
"frontera/automation/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",
|
|
25
|
-
"frontera/automation/testing.ts": "import { AsyncLocalStorage } from 'node:async_hooks'\nimport {\n duplicateStepMessage,\n duplicateSubmissionMessage,\n emptySubmissionKeyMessage,\n missingGrantMessage,\n submitOutsideStepMessage,\n} from './messages'\nimport type {\n ActionSubmission,\n ActionSubmitResult,\n AutomationContext,\n BlueprintQueryOptions,\n BlueprintQueryResult,\n Grant,\n HttpRequest,\n HttpResponse,\n PluginCallResult,\n} from './types'\n\n/**\n * A `ctx` you can hand your handler in a unit test.\n *\n * Until this existed the only way to find out whether an automation worked was\n * to deploy it and run it — a loop measured in tens of seconds, against real\n * data, for a question as small as \"does the empty branch return the right\n * shape\".\n *\n * It enforces what the platform enforces, in the platform's own words: a\n * missing grant and a repeated step name fail here exactly as they fail in\n * production, so a green test means something.\n *\n * What it does NOT simulate is resumption. In production a handler is re-entered\n * after every step, so code outside a step runs many times; here the handler is\n * called once, straight through. Steps still memoize by name within the run, and\n * everything the handler did is recorded on `calls`.\n */\n\n/**\n * A call REFUSED before it happened is not recorded.\n *\n * A missing grant, a missing stub, a submit outside a step, a duplicate\n * submission — none of these appear in `calls`, because none of them did\n * anything. A real run differs here in one direction worth knowing: it writes\n * an errored ctx-call row for a refused `ctx.action.submit`, so the Console\n * trace shows the attempt where this list does not. Assert on the thrown error\n * for a refusal, and on `calls` for what ran.\n */\nexport interface TestCall {\n kind: 'step' | 'log' | 'agent' | 'plugin' | 'http' | 'blueprint' | 'action'\n /** Step name, log message, agent slug, `install:capability`, URL, object type, or Action apiName. */\n label: string\n /** Present on a step: how it ended. */\n status?: 'ok' | 'error'\n}\n\n\nexport interface TestContextOptions {\n runId?: string\n workspaceId?: string\n /** What the run was started with. Passed through verbatim — a unit test\n * states exactly what the handler sees; defaults are `startRun`'s job. */\n input?: Record<string, unknown>\n /**\n * The grants the manifest declares.\n *\n * Given, they are enforced — which is the point: a missing grant is one of\n * the few automation bugs that only shows up in a deployed run, and it is\n * exactly the kind a unit test should catch.\n *\n * Omitted, nothing is refused, so an existing test does not have to enumerate\n * grants to keep passing.\n */\n grants?: readonly Grant[]\n /** Per-slug agent answers. An unstubbed agent throws rather than answering. */\n agents?: Record<string, (prompt: string) => Promise<{ text: string }> | { text: string }>\n /**\n * Per-install, per-capability plugin answers: `{ crm: { create_ticket: (input) => ({ data }) } }`.\n * An unstubbed capability throws rather than answering — a fabricated\n * `{ data: {} }` is a test that passes while asserting nothing.\n */\n plugins?: Record<\n string,\n Record<string, (input: Record<string, unknown>) => Promise<PluginCallResult> | PluginCallResult>\n >\n /** Answers outbound requests. Unstubbed, `ctx.http.fetch` throws. */\n http?: (req: HttpRequest) => Promise<HttpResponse> | HttpResponse\n /** Rows per object type. An unstubbed type returns no rows, which is a real\n * answer and usually the branch worth testing. */\n blueprint?: Record<string, BlueprintQueryResult<never> | BlueprintQueryResult<Record<string, unknown>>>\n /**\n * Per-apiName Action outcomes. An unstubbed Action throws rather than\n * answering.\n *\n * Throws for the same reason the agent stub does, and the reason is sharper\n * here: the returned `lifecycle` is a branch an author writes code against —\n * `awaiting_approval` means a human still has to decide — so inventing\n * `ready` would silently pick one arm and pass.\n */\n actions?: Record<\n string,\n (request: ActionSubmission) => Promise<ActionSubmitResult> | ActionSubmitResult\n >\n}\n\nexport interface TestContext {\n ctx: AutomationContext\n /** Everything the handler did, in order. */\n calls: TestCall[]\n /** Step names, in the order they ran. */\n steps: string[]\n logs: Array<{ message: string; data?: Record<string, unknown> }>\n}\n\nexport function createTestContext(options: TestContextOptions = {}): TestContext {\n const calls: TestCall[] = []\n const steps: string[] = []\n const logs: TestContext['logs'] = []\n const seenNames = new Set<string>()\n /**\n * Which step the running code is inside.\n *\n * `AsyncLocalStorage`, matching the real context exactly, and NOT a stack.\n * A stack gets the concurrent case wrong in the direction that matters:\n * `Promise.all([ctx.step.run('a', …), ctx.action.submit(…)])` is legal, and\n * with a shared mutable stack the bare submit sees `a` open and is allowed —\n * so the test double passes what production refuses, which is the one failure\n * mode a test double must not have.\n *\n * Enforced here for the same reason grants and duplicate names are: a rule\n * the unit test does not apply is a rule the author meets for the first time\n * in a deployed run.\n */\n const stepScope = new AsyncLocalStorage<{ stepName: string; submitted: Set<string> }>()\n\n const requireGrant = (grant: string): void => {\n // No grant list means the test is not about grants. Enforcing an empty list\n // would fail every existing test for a reason its author never chose.\n if (!options.grants) return\n if (!options.grants.includes(grant as Grant)) throw new Error(missingGrantMessage(grant))\n }\n\n const ctx: AutomationContext = {\n runId: options.runId ?? 'test-run',\n workspaceId: options.workspaceId ?? 'test-workspace',\n input: options.input ?? {},\n\n step: {\n async run<T>(name: string, fn: () => Promise<T>): Promise<T> {\n if (seenNames.has(name)) throw new Error(duplicateStepMessage(name))\n seenNames.add(name)\n steps.push(name)\n return await stepScope.run({ stepName: name, submitted: new Set<string>() }, async () => {\n try {\n const out = await fn()\n calls.push({ kind: 'step', label: name, status: 'ok' })\n return out\n } catch (err) {\n calls.push({ kind: 'step', label: name, status: 'error' })\n throw err\n }\n })\n },\n\n async sleep(name: string): Promise<void> {\n if (seenNames.has(name)) throw new Error(duplicateStepMessage(name))\n seenNames.add(name)\n steps.push(name)\n // Recorded, never waited: a test suite that really slept out its\n // backoffs would take minutes to say nothing.\n calls.push({ kind: 'step', label: name, status: 'ok' })\n },\n },\n\n async log(message, data) {\n logs.push({ message, ...(data ? { data } : {}) })\n calls.push({ kind: 'log', label: message })\n },\n\n agent(slug: string) {\n return {\n async run(prompt: string) {\n requireGrant(`agent:${slug}:run`)\n calls.push({ kind: 'agent', label: slug })\n const stub = options.agents?.[slug]\n // Throwing beats answering with an empty string: a test whose agent\n // silently returns '' passes while asserting nothing about the step\n // that matters most.\n if (!stub) {\n throw new Error(\n `No agent stub for \"${slug}\". Pass agents: { '${slug}': () => ({ text: '…' }) } ` +\n 'to createTestContext.',\n )\n }\n return await stub(prompt)\n },\n }\n },\n\n plugin(install: string) {\n return {\n async call<T = unknown>(capability: string, input?: Record<string, unknown>) {\n requireGrant(`plugin:${install}:${capability}`)\n const stub = options.plugins?.[install]?.[capability]\n // Before the record, matching the contract on `TestCall` and the\n // `action` arm. (`agent` and `http` record first — a pre-existing\n // divergence.)\n if (!stub) {\n throw new Error(\n `No plugin stub for \"${install}\".${capability}. Pass ` +\n // Quoted, unlike a bare identifier: an install name defaults to\n // the catalog kind (kebab, e.g. \"github-prod\") and a capability\n // can be dotted (\"run.query\") — neither survives as an object\n // key without quotes, so the unquoted form the author would\n // paste back in does not parse.\n `plugins: { '${install}': { '${capability}': () => ({ data: … }) } } to createTestContext.`,\n )\n }\n calls.push({ kind: 'plugin', label: `${install}:${capability}` })\n return (await stub(input ?? {})) as PluginCallResult<T>\n },\n }\n },\n\n http: {\n async fetch(req: HttpRequest) {\n let host: string\n try {\n host = new URL(req.url).hostname.toLowerCase()\n } catch {\n throw new Error(`ctx.http: invalid URL ${req.url}`)\n }\n requireGrant(`http:${host}`)\n calls.push({ kind: 'http', label: req.url })\n // Same reasoning as the agent: a fabricated 200 is a false pass.\n if (!options.http) {\n throw new Error(\n `No http stub. Pass http: (req) => ({ status: 200, headers: {}, body: '' }) ` +\n 'to createTestContext.',\n )\n }\n return await options.http(req)\n },\n },\n\n action: {\n async submit(request: ActionSubmission): Promise<ActionSubmitResult> {\n const scope = stepScope.getStore()\n if (!scope) throw new Error(submitOutsideStepMessage(request.action))\n // The batch loop is the shape this catches, and a one-row fixture never\n // reaches it — so the double has to enforce it or an author meets it\n // for the first time on their second production row, after the first\n // has already been applied.\n if (request.submissionKey !== undefined && request.submissionKey.length === 0) {\n throw new Error(emptySubmissionKeyMessage(request.action))\n }\n requireGrant(`governed:${request.action}`)\n const stub = options.actions?.[request.action]\n // Both refusals that mean \"this never happened\" come BEFORE the\n // reservation, matching the runtime's grant check: reserving first left\n // an author who fixed the missing stub and re-ran a loop facing a\n // duplicate accusation for a call that never answered.\n if (!stub) {\n throw new Error(\n `No action stub for \"${request.action}\". Pass actions: { '${request.action}': ` +\n \"() => ({ requestId: 'req-1', lifecycle: 'ready' }) } to createTestContext.\",\n )\n }\n const submissionIdentity = `${request.action}\\u0000${request.submissionKey ?? ''}`\n // Reserved synchronously and released on failure, matching the runtime\n // exactly. A double that checked and recorded across an await would let\n // `Promise.all([submit(x), submit(x)])` through — and a double that\n // permits what production refuses is the one failure mode a double must\n // not have.\n if (scope.submitted.has(submissionIdentity)) {\n throw new Error(duplicateSubmissionMessage(request.action))\n }\n scope.submitted.add(submissionIdentity)\n calls.push({ kind: 'action', label: request.action })\n try {\n return await stub(request)\n } catch (err) {\n // A throwing stub stands in for a submission that never landed.\n scope.submitted.delete(submissionIdentity)\n throw err\n }\n },\n },\n\n blueprint: {\n async query<T = Record<string, unknown>>(\n objectType: string,\n _options?: BlueprintQueryOptions,\n ): Promise<BlueprintQueryResult<T>> {\n requireGrant('blueprint:read')\n calls.push({ kind: 'blueprint', label: objectType })\n const stub = options.blueprint?.[objectType]\n // Empty is a real answer, and the branch an author most often forgets\n // to test — so this one defaults rather than throwing.\n return (stub ?? { rows: [], hasMore: false }) as BlueprintQueryResult<T>\n },\n },\n }\n\n return { ctx, calls, steps, logs }\n}\n",
|
|
25
|
+
"frontera/automation/manifest.ts": "import { CronExpressionParser } from 'cron-parser'\nimport { MAX_INPUT_BYTES, checkInputFieldSpec } from './inputs'\n\nconst SEGMENT = '[a-z][a-z0-9]*(?:-[a-z0-9]+)*'\nconst NAME_RE = new RegExp(`^${SEGMENT}$`)\n\n/**\n * Runtime gate for a grant: `<namespace>:<name>[:<action>]`, every segment\n * sharing NAME_RE's grammar so the whole vocabulary is consistent.\n *\n * WIDER than the `Grant` union on namespaces — a server must not reject\n * `notify:email` merely because this build predates it. NARROWER than the\n * union's `agent:${string}:run` arm on the slug, which admits `agent::run` and\n * `agent:AGENT:run`; both are rejected here. No legitimate slug is affected —\n * this repo's agent slugs are already lowercase-kebab.\n */\nconst GRANT_RE = new RegExp(`^${SEGMENT}:${SEGMENT}(?::${SEGMENT})?$`)\n\n/**\n * `http:<host>` and `secret:<NAME>` need their own grammars, because SEGMENT is\n * lowercase-kebab and neither value is.\n *\n * A host contains DOTS (`api.stripe.com`); a secret name is conventionally\n * SCREAMING_SNAKE_CASE (`STRIPE_KEY`). Validating them with SEGMENT rejected both\n * realistic forms — found by deploying an automation that used them.\n *\n * Deliberately not solved by widening SEGMENT: that governs agent slugs too, and\n * loosening it there would admit `agent:AGENT:run`, which the comment above says\n * is rejected on purpose.\n */\nconst HOST_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/\n// Matches SECRET_NAME_PATTERN in workspace-secrets-router exactly. Being MORE\n// permissive here would let a manifest declare `secret:myKey`, validate cleanly,\n// and then never be satisfiable — no such secret can be created. A validator that\n// accepts the unsatisfiable is worse than one that is strict.\nconst SECRET_NAME_RE = /^[A-Z][A-Z0-9_]*$/\n// Matches `apiNameSchema` in the Blueprint Action definition schema exactly.\n// Same reasoning as SECRET_NAME_RE: a looser grammar here would accept\n// `governed:Approve_Invoice`, validate cleanly, and name an Action that can\n// never exist — no published Action carries that apiName, so the grant is\n// unsatisfiable and the automation fails at its first submit instead of at\n// deploy.\nconst ACTION_API_NAME_RE = /^[a-z][A-Za-z0-9]{0,99}$/\n\n// `plugin:<install>:<capability>`. The service does NOT validate\n// `app_installs.install_name` — it is `t.String({ minLength: 1 })`, so an\n// admin can name an install \"My CRM\" and it works fine everywhere except\n// here. This grammar (lowercase, dot/dash/underscore, no spaces — the catalog\n// default is kebab) is what makes an install's name usable from a manifest;\n// one outside it has to be renamed before an automation can grant it. The\n// capability half is deliberately wider: MCP tool names and spec capability\n// names are `create_issue` / `listIssues`, neither of which is a SEGMENT. No\n// wildcard in either half — the manifest is the reviewable list of what the\n// automation can reach, same as `http:`.\nconst PLUGIN_GRANT_RE = /^[a-z0-9][a-z0-9._-]{0,63}:[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/\n\n/** Namespaces whose value is not a SEGMENT. */\nconst TYPED_NAMESPACES: Record<string, { re: RegExp; hint: string }> = {\n http: { re: HOST_RE, hint: 'a hostname, e.g. \"http:api.stripe.com\" (no scheme, no path, no wildcard)' },\n secret: { re: SECRET_NAME_RE, hint: 'a workspace secret name, e.g. \"secret:STRIPE_KEY\"' },\n governed: {\n re: ACTION_API_NAME_RE,\n hint: 'one published Action apiName, e.g. \"governed:approveInvoice\" (camelCase, no wildcard)',\n },\n plugin: {\n re: PLUGIN_GRANT_RE,\n hint:\n '<install>:<capability>, e.g. \"plugin:crm:create_ticket\" — the install name from '\n + '`frontera plugin list` (lowercase, no spaces) and one capability name (no wildcard)'\n + ' — rename the install if its name has capitals or spaces',\n },\n}\n\n/**\n * Shortest description an agent-callable automation may carry.\n *\n * Not a round number picked for looks: it is about the length of one honest\n * clause (\"Reconcile open invoices against the settlement file\"), and it is\n * chosen to be long enough that the slug restated as a sentence — \"reconcile\n * invoices\" — does not clear it. A description that only repeats the name\n * tells a model nothing it did not already have from the tool name.\n */\nexport const AGENT_DESCRIPTION_MIN_CHARS = 24\n\nconst KNOWN_KEYS = new Set([\n 'name', 'trigger', 'grants', 'inputs', 'concurrency', 'retries', 'description',\n])\n\nexport interface ValidationResult {\n valid: boolean\n errors: string[]\n /**\n * Non-fatal. An unknown manifest key lands here rather than in `errors`:\n * a newer SDK must be able to add a field without an older service refusing\n * the deploy. The CLI prints these, so a typo like `concurrancy: 100` — which\n * would otherwise deploy \"successfully\" with the default of 1 — is caught at\n * author time, where the SDK and the manifest are the same version.\n */\n warnings: string[]\n}\n\n/**\n * Takes `unknown`, on purpose.\n *\n * The authoritative call site is the service, validating a manifest that\n * arrived over HTTP — untrusted, and not yet known to have any shape. Typing\n * the parameter as `AutomationManifest` would force every honest caller to\n * launder untrusted input through a cast, which is how a validator ends up\n * trusting the thing it exists to check.\n *\n * The regexes here are deliberately wider than the `Grant` union in `types.ts`:\n * that union is an author-time affordance, this is a runtime gate, and a server\n * must not reject a grant merely because this build predates it.\n */\nexport function validateManifest(input: unknown): ValidationResult {\n const errors: string[] = []\n const warnings: string[] = []\n const m = (input ?? {}) as {\n name?: unknown\n trigger?: unknown\n grants?: unknown\n inputs?: unknown\n concurrency?: unknown\n retries?: unknown\n description?: unknown\n }\n\n if (typeof m.name !== 'string' || !NAME_RE.test(m.name)) {\n errors.push('name must be lowercase kebab-case')\n } else if (m.name.length > 64) {\n errors.push('name must be 64 characters or fewer')\n }\n\n const trigger = m.trigger as\n | { cron?: string; manual?: boolean; agent?: boolean }\n | undefined\n if (\n !trigger\n || (trigger.cron === undefined && trigger.manual !== true && trigger.agent !== true)\n ) {\n errors.push('trigger must be { cron }, { manual: true }, or { agent: true }')\n } else if (trigger.agent !== undefined && trigger.agent !== true) {\n // Not folded into the arm above: `{ manual: true, agent: false }` is a\n // legal-looking manifest that means nothing. `agent` is a permission, and\n // the way to withhold a permission is to omit it, not to write it false —\n // the same rule the grant list follows.\n errors.push(\n 'trigger.agent must be true when present — omit the key to mean \"not agent-callable\"',\n )\n }\n // Separate `if`, not the old `else if`: with three arms the cron check has to\n // run whenever a cron is present, including on `{ cron, agent: true }`, and\n // an `else if` chained off the acceptance test above would skip it there.\n if (trigger?.cron !== undefined) {\n if (typeof trigger.cron !== 'string') {\n errors.push('invalid cron expression: must be a string')\n } else {\n // Both field-count branches exist because `cron-parser` accepts an\n // off-count expression rather than throwing, so neither case would ever\n // reach the `catch` below:\n // `* * * * * *` -> reads field 1 as SECONDS and fires sub-minute.\n // `0 7 * *` -> left-pads, scheduling something the author never wrote.\n // Only an exactly-5-field expression means what it looks like it means.\n const fields = trigger.cron.trim().split(/\\s+/).length\n if (fields !== 5) {\n // One message shape for one class of fault. Splitting it meant a\n // 7-field expression was told it was \"sub-minute\" — a diagnosis\n // asserted rather than derived — while a 4-field one got no diagnosis\n // at all.\n errors.push(\n `invalid cron expression: expected 5 fields, got ${fields}` +\n (fields > 5 ? '; sub-minute schedules are not supported' : ''),\n )\n } else {\n try {\n CronExpressionParser.parse(trigger.cron, { tz: 'UTC' })\n } catch (err) {\n errors.push(`invalid cron expression: ${(err as Error).message}`)\n }\n }\n }\n }\n\n if (m.grants !== undefined && !Array.isArray(m.grants)) {\n errors.push('grants must be an array')\n } else {\n for (const g of (m.grants as unknown[]) ?? []) {\n // String(g), not `${g}` — a template literal THROWS on a symbol, and a\n // validator that exists to absorb hostile input must not have a throwing\n // path. The message names the fix, not just the verdict.\n if (typeof g !== 'string') {\n errors.push(\n `malformed grant \"${String(g)}\" — expected \"<namespace>:<action>\", ` +\n 'e.g. \"blueprint:read\" or \"agent:risk-analyst:run\"',\n )\n continue\n }\n const colon = g.indexOf(':')\n const typed = colon > 0 ? TYPED_NAMESPACES[g.slice(0, colon)] : undefined\n if (typed) {\n // A typed namespace validates its OWN value grammar. `http:` and\n // `secret:` carry hosts and secret names, neither of which is a SEGMENT.\n if (!typed.re.test(g.slice(colon + 1))) {\n errors.push(`malformed grant \"${g}\" — the part after the colon must be ${typed.hint}`)\n }\n continue\n }\n if (!GRANT_RE.test(g)) {\n errors.push(\n `malformed grant \"${String(g)}\" — expected \"<namespace>:<action>\", ` +\n 'e.g. \"blueprint:read\" or \"agent:risk-analyst:run\"',\n )\n }\n }\n }\n\n const c = m.concurrency\n if (c !== undefined && (!Number.isInteger(c) || (c as number) < 1 || (c as number) > 50)) {\n errors.push('concurrency must be an integer between 1 and 50')\n }\n\n // Capped at 5. Above that it is not a retry policy, it is a loop — and every\n // attempt re-runs whatever side effects the previous one already performed.\n const r = m.retries\n if (r !== undefined && (!Number.isInteger(r) || (r as number) < 0 || (r as number) > 5)) {\n errors.push('retries must be an integer between 0 and 5')\n }\n\n const inputs = m.inputs\n if (inputs !== undefined) {\n if (!inputs || typeof inputs !== 'object' || Array.isArray(inputs)) {\n errors.push('inputs must be an object of { name: { type, … } }')\n } else {\n // Per-field rules (name shape, type, required/default shape, enum) live\n // in `checkInputFieldSpec` — shared with `sanitizeInputsSchema` so the\n // two can never drift on what \"a well-formed input field\" means.\n for (const [key, raw] of Object.entries(inputs as Record<string, unknown>)) {\n const field = checkInputFieldSpec(key, raw)\n errors.push(...field.errors)\n warnings.push(...field.warnings)\n }\n // A cron fire has no one to prompt: every required field must be\n // satisfiable from defaults, or the schedule would fail on every tick.\n const trig = m.trigger as { cron?: unknown } | undefined\n if (typeof trig?.cron === 'string') {\n for (const [key, raw] of Object.entries(inputs as Record<string, unknown>)) {\n const spec = raw as { required?: unknown; default?: unknown }\n if (spec?.required === true && spec.default === undefined) {\n errors.push(\n `input \"${key}\" is required with no default, and the trigger is a cron — `\n + 'cron has nobody to ask. Add a default or make the trigger manual.',\n )\n }\n }\n }\n // A run's input is capped at MAX_INPUT_BYTES when it starts. If the\n // declared defaults alone already exceed that, a cron fire (or a bare\n // Run-now) fails inside run-open before any run row exists — a silent\n // death only visible in runner logs. Catch it here, the one place the\n // author is still looking at the file.\n const defaultsOnly: Record<string, unknown> = {}\n for (const [key, raw] of Object.entries(inputs as Record<string, unknown>)) {\n const spec = raw as { default?: unknown }\n if (spec && typeof spec === 'object' && spec.default !== undefined) {\n defaultsOnly[key] = spec.default\n }\n }\n try {\n const bytes = new TextEncoder().encode(JSON.stringify(defaultsOnly)).length\n if (bytes > MAX_INPUT_BYTES) {\n errors.push(\n `input defaults alone serialize to ${bytes} bytes — over the ${MAX_INPUT_BYTES}-byte `\n + 'run-input cap, so every run would fail at start. Slim the defaults.',\n )\n }\n } catch {\n // A default that JSON.stringify chokes on (circular, throwing toJSON)\n // is practically unreachable — the manifest itself must serialize to\n // deploy at all — but the size check must never be the thing that throws.\n }\n }\n }\n\n // `trigger: { agent: true }` turns this manifest into the source of a tool\n // definition a language model reads and decides from. Two fields that are\n // courtesies everywhere else become load-bearing here, so they are errors\n // rather than warnings: a model handed an undescribed tool, or an undescribed\n // argument, does not fail loudly — it guesses, and the guess starts a real\n // run against real systems. Checked at deploy, where the author still has the\n // file open, rather than at bind time in a Console someone else is using.\n if (trigger?.agent === true) {\n const description = m.description\n if (typeof description !== 'string' || description.trim().length < AGENT_DESCRIPTION_MIN_CHARS) {\n errors.push(\n `trigger { agent: true } requires a description of at least ${AGENT_DESCRIPTION_MIN_CHARS} `\n + 'characters — it becomes the tool description an agent reads before calling this '\n + 'automation.',\n )\n }\n const agentInputs = m.inputs\n if (agentInputs && typeof agentInputs === 'object' && !Array.isArray(agentInputs)) {\n for (const [key, raw] of Object.entries(agentInputs as Record<string, unknown>)) {\n const spec = raw as { description?: unknown } | null\n if (typeof spec?.description !== 'string' || spec.description.trim().length === 0) {\n errors.push(\n `input \"${key}\" needs a description: trigger { agent: true } publishes every input as `\n + 'a tool argument, and an agent cannot fill an argument it has no description for.',\n )\n }\n }\n }\n }\n\n if (input && typeof input === 'object' && !Array.isArray(input)) {\n for (const key of Object.keys(input)) {\n if (!KNOWN_KEYS.has(key)) {\n warnings.push(`unknown manifest key \"${key}\" — ignored`)\n }\n }\n }\n\n return { valid: errors.length === 0, errors, warnings }\n}\n",
|
|
26
26
|
"frontera/automation/messages.ts": "/**\n * Everything an author reads when the platform turns their code away.\n *\n * They live in the SDK, not in the runner, because three surfaces have to say\n * exactly the same sentence: the runner refusing a call before it makes it, the\n * service refusing it after, and `createTestContext` refusing it on the author's\n * own machine. Three copies of a message drift, and a test that fails with\n * different words than production is a test that teaches the wrong lesson.\n */\n\n/**\n * A `ctx` call the manifest does not permit.\n *\n * Names the fix, not the verdict: the author is looking at CLI output, and\n * \"missing grant\" without the remedy costs them a round trip through the docs.\n */\nexport function missingGrantMessage(grant: string): string {\n return `Automation is missing the \"${grant}\" grant. Add it to the manifest and redeploy.`\n}\n\n/**\n * One step name used twice in a run.\n *\n * The platform memoizes by name, so the second call would return the FIRST\n * step's result — no error, no warning, a wrong value flowing on. The remedy\n * names THIS step rather than a placeholder, because a hint that reads as code\n * to paste gets pasted.\n */\nexport function duplicateStepMessage(name: string): string {\n return (\n `Duplicate automation step name \"${name}\". Step names must be unique within a run — ` +\n \"the platform memoizes by name, so this call would return the first step's result \" +\n 'instead of running again. If this is a loop, add the index: ' +\n `ctx.step.run(\\`${name}:\\${i}\\`, ...)`\n )\n}\n\n/**\n * A submit inside a step whose own row never recorded.\n *\n * Recording a step is contractually non-fatal — telemetry must not fail a run —\n * so the id comes back empty and everything else carries on. A submission\n * cannot: the row is what the idempotency key is derived from, and improvising\n * one is how the same effect happens twice. Named here rather than left to the\n * service's generic invalid-submission, which would blame the payload.\n */\nexport function lostStepRowMessage(stepName: string): string {\n return (\n `ctx.action.submit cannot run in step \"${stepName}\": the step's own record failed to write, ` +\n 'so there is nothing stable to key the submission on and it is refused rather than sent ' +\n 'twice. This is a transient service failure — retry the run.'\n )\n}\n\n/**\n * The same Action submitted twice from one step with no way to tell them apart.\n *\n * Refused HERE, locally, rather than left to the write plane, because the plane\n * cannot refuse it: two identical submissions derive one idempotency key AND\n * one semantic fingerprint, so it replays the first request and answers both\n * calls with the same request id. Nothing errors, one effect happens, and the\n * run reports success — the failure mode a batch loop hits on its second row\n * and not on a one-row fixture.\n *\n * Naming the remedy matters more than usual: `submissionKey` is the one field\n * an author has to reach for to fix this, and it is not guessable from a 409.\n */\nexport function duplicateSubmissionMessage(apiName: string): string {\n return (\n `Step already submitted \"${apiName}\" with the same submissionKey. Two submissions the ` +\n 'platform cannot tell apart become ONE request — the plane replays the first and the second ' +\n 'effect never happens. If this is a batch, give each submission a distinct submissionKey ' +\n \"keyed on what it acts on: ctx.action.submit({ action: '\" + apiName + \"', submissionKey: \" +\n 'row.id, ... }). If it is a retry, it is already idempotent — drop the loop.'\n )\n}\n\n/**\n * An empty `submissionKey`.\n *\n * Refused rather than treated as absent, and refused in both layers: the\n * service rejects it by name, so folding it into the no-key identity here\n * would make the SDK and the service disagree about what the author asked for.\n */\nexport function emptySubmissionKeyMessage(apiName: string): string {\n return (\n `ctx.action.submit(\"${apiName}\") was given an empty submissionKey. Omit it entirely to mean ` +\n '\"this step submits once\", or pass a value identifying what this submission acts on.'\n )\n}\n\n/**\n * `ctx.action.submit` called outside a step.\n *\n * States the consequence rather than the rule, because the rule on its own\n * reads as ceremony: code outside a step runs again after EVERY step the\n * handler completes, so a submit there is not one submission with a retry\n * risk — it is one submission per step boundary, every time the run resumes.\n * The step row is also what the idempotency key is derived from, so there is\n * nothing to derive one from out here.\n */\nexport function submitOutsideStepMessage(apiName: string): string {\n return (\n `ctx.action.submit(\"${apiName}\") must be called inside ctx.step.run. Code outside a step ` +\n 're-executes every time the run resumes, so this would submit once per step boundary. ' +\n `Wrap it: ctx.step.run('submit-${apiName}', () => ctx.action.submit({ ... }))`\n )\n}\n",
|
|
27
|
-
"frontera/automation/inputs.ts": "/**\n * Run-input validation — the value-side twin of `validateManifest`.\n *\n * Three callers must agree on the verdict and the wording: the run route\n * (fast 400 before anything queues), `startRun` (authoritative — the runner\n * posts whatever rode the event), and the Console form (client courtesy).\n * Living in the SDK is what keeps them one implementation.\n */\nimport type { InputFieldSpec, InputsSchema } from './types'\n\nexport type InputValidation =\n | { ok: true; value: Record<string, unknown> }\n | { ok: false; errors: string[] }\n\n/**\n * The five input types' runtime validators, keyed by `InputFieldSpec['type']`.\n *\n * Single source of truth for \"does this value have this type\" — `validateInputValue`'s\n * value check and `checkInputFieldSpec`'s default/enum checks all call this instead\n * of re-deriving it, so a tightening here (the `Number.isFinite` guard that excludes\n * `Infinity`/`NaN` from `number`) or a future widening can never drift between\n * deploy-time and run-time again. It drifting once — `manifest.ts`'s old `okDefault`\n * used `typeof spec.default === 'number'` and admitted `default: Infinity` — is why\n * this is exported rather than kept module-private.\n */\nexport const TYPE_CHECK: Record<InputFieldSpec['type'], (v: unknown) => boolean> = {\n string: (v) => typeof v === 'string',\n number: (v) => typeof v === 'number' && Number.isFinite(v),\n boolean: (v) => typeof v === 'boolean',\n object: (v) => typeof v === 'object' && v !== null && !Array.isArray(v),\n array: Array.isArray,\n}\n\n/** Lowercase kebab, matching `validateManifest`'s automation-`name` grammar. */\nconst INPUT_NAME_KEBAB_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/\n/** Lowercase snake — the one allowance kebab doesn't cover. */\nconst INPUT_NAME_SNAKE_RE = /^[a-z][a-z0-9_]*$/\n\nconst INPUT_TYPES = new Set<InputFieldSpec['type']>(['string', 'number', 'boolean', 'object', 'array'])\n\n/** Keys `checkInputFieldSpec` understands on ONE input spec (`inputs.<name>`).\n * Anything else there is a warning, same philosophy as `manifest.ts`'s\n * top-level unknown-key warning: a typo like `requred` should be visible,\n * but a field a newer SDK added must not fail an older validator's deploy. */\nconst INPUT_SPEC_KEYS = new Set(['type', 'required', 'default', 'description', 'enum'])\n\n/**\n * Serialized cap on a run's input object — the same 64KB the service enforces\n * when a run starts. Exported so `validateManifest` can refuse a manifest\n * whose *defaults alone* exceed it at deploy time: past that point a cron\n * fire would fail inside run-open with no run row, which is invisible.\n */\nexport const MAX_INPUT_BYTES = 64 * 1024\n\n/** Input names that smell like credentials — warned at deploy, never blocked.\n * Tails are anchored so `max_tokens` (a count) and `secretary` stay quiet\n * while `api_key`, `auth-token`, `client_secret`, `secret_key` still warn. */\nconst CREDENTIAL_NAME_RE = /([_-]token$|[_-]key$|password|(^|[_-])secret([_-]|$))/i\n\nexport interface InputFieldCheck {\n errors: string[]\n warnings: string[]\n}\n\n/**\n * Structural rules for ONE `{ name: spec }` entry in an inputs schema — name\n * shape, declared type, required/default shape, enum.\n *\n * The shared source for both `validateManifest` (deploy-time; also surfaces\n * the non-fatal warnings) and `sanitizeInputsSchema` (runtime; pass/fail\n * only) so the two can never quietly diverge on what \"a well-formed input\n * field\" means — which is exactly how `manifest.ts`'s default-type check once\n * drifted from `TYPE_CHECK` and admitted `default: Infinity`.\n */\nexport function checkInputFieldSpec(key: string, raw: unknown): InputFieldCheck {\n const errors: string[] = []\n const warnings: string[] = []\n\n if (!INPUT_NAME_KEBAB_RE.test(key) && !INPUT_NAME_SNAKE_RE.test(key)) {\n errors.push(`input \"${key}\" — names are lowercase snake or kebab`)\n return { errors, warnings }\n }\n\n // Inputs land on the run row and in traces permanently; there is no way to\n // detect a secret in a value, but a name that says \"credential\" is an honest\n // mistake we can flag while the author is still looking at the file.\n if (CREDENTIAL_NAME_RE.test(key)) {\n warnings.push(\n `input \"${key}\" looks like a credential — inputs are stored on the run row `\n + 'and visible in traces. Use a `secret:` grant instead.',\n )\n }\n\n if (raw && typeof raw === 'object' && !Array.isArray(raw)) {\n for (const specKey of Object.keys(raw as Record<string, unknown>)) {\n if (!INPUT_SPEC_KEYS.has(specKey)) {\n warnings.push(`unknown key \"${specKey}\" on input \"${key}\" — ignored`)\n }\n }\n }\n\n const spec = (raw ?? {}) as {\n type?: unknown\n required?: unknown\n default?: unknown\n enum?: unknown\n description?: unknown\n }\n\n if (spec.description !== undefined && typeof spec.description !== 'string') {\n warnings.push(`input \"${key}\": description is not a string — ignored`)\n }\n\n if (typeof spec.type !== 'string' || !INPUT_TYPES.has(spec.type as InputFieldSpec['type'])) {\n errors.push(`input \"${key}\": type must be one of string, number, boolean, object, array`)\n return { errors, warnings }\n }\n const t = spec.type as InputFieldSpec['type']\n\n // Deploy-side and runtime must share this exact predicate (`=== true`), not\n // a truthy check — `inputs.ts`'s own `validateInputValue` only treats\n // `required` as active when it is literally `true`. Without this, a plain\n // `required: 1` would deploy clean and then never actually be enforced.\n if (spec.required !== undefined && typeof spec.required !== 'boolean') {\n errors.push(`input \"${key}\": required must be a boolean`)\n }\n\n if (spec.required === true && spec.default !== undefined) {\n errors.push(`input \"${key}\": required and default are mutually exclusive — a default always satisfies required`)\n }\n\n let enumOk = true\n if (spec.enum !== undefined) {\n if (t !== 'string' && t !== 'number') {\n errors.push(`input \"${key}\": enum is only valid for string and number types`)\n enumOk = false\n } else if (!Array.isArray(spec.enum) || spec.enum.length === 0) {\n errors.push(`input \"${key}\": enum must not be empty`)\n enumOk = false\n } else if (spec.enum.some((e) => typeof e !== t)) {\n errors.push(`input \"${key}\": enum values must match the declared type`)\n enumOk = false\n } else if (t === 'number' && spec.enum.some((e) => !Number.isFinite(e as number))) {\n // TYPE_CHECK's own `number` check already excludes NaN/Infinity from\n // values — a member of `enum` that no value could ever equal is\n // unreachable and can only be an authoring mistake.\n errors.push(`input \"${key}\": enum values must be finite numbers`)\n enumOk = false\n }\n }\n\n let defaultOk = true\n if (spec.default !== undefined) {\n if (!TYPE_CHECK[t](spec.default)) {\n errors.push(`input \"${key}\": default must match the declared type`)\n defaultOk = false\n }\n }\n\n if (spec.enum !== undefined && spec.default !== undefined && enumOk && defaultOk) {\n if (!(spec.enum as unknown[]).includes(spec.default)) {\n errors.push(`input \"${key}\": default must be one of the enum values`)\n }\n }\n\n return { errors, warnings }\n}\n\nexport function validateInputValue(\n schema: InputsSchema | undefined,\n value: Record<string, unknown> | undefined | null,\n): InputValidation {\n const given = value ?? {}\n if (!schema || Object.keys(schema).length === 0) {\n return Object.keys(given).length === 0\n ? { ok: true, value: {} }\n : { ok: false, errors: ['this automation declares no inputs — remove the input and run again'] }\n }\n const errors: string[] = []\n const out: Record<string, unknown> = {}\n for (const key of Object.keys(given)) {\n // `Object.hasOwn`, not `key in schema`: the `in` operator also sees\n // inherited members — every plain object \"has\" `toString` via\n // `Object.prototype` — so a value keyed `toString` would slip past an\n // undeclared-field check that used `in`.\n if (!Object.hasOwn(schema, key)) errors.push(`\"${key}\" is not a declared input`)\n }\n for (const [key, spec] of Object.entries(schema)) {\n // Same reasoning in reverse: plain `given[key]` for key `constructor`\n // resolves to `Object.prototype.constructor` (a function) rather than\n // `undefined` when the caller never supplied one, which would run type\n // checks against Object's own constructor instead of treating the field\n // as absent.\n const v = Object.hasOwn(given, key) ? given[key] : undefined\n if (v === undefined) {\n if (spec.default !== undefined) out[key] = structuredClone(spec.default)\n // `=== true`, not truthy: a legacy/malformed `required: 1` must not be\n // silently enforced here when `validateManifest` already refuses it as\n // \"not a boolean\" — the two sides share one predicate on purpose.\n else if (spec.required === true) errors.push(`\"${key}\" is required`)\n continue\n }\n // `Object.hasOwn`, not a plain lookup: `TYPE_CHECK` is an object literal,\n // so `TYPE_CHECK['toString']` resolves to `Object.prototype.toString` —\n // truthy, and callable — rather than `undefined`. A spec of `{ type:\n // 'toString' }` would then pass `check(v)` for ANY `v` instead of being\n // refused as the unknown type it is.\n const check = Object.hasOwn(TYPE_CHECK, spec.type) ? TYPE_CHECK[spec.type as InputFieldSpec['type']] : undefined\n if (!check) {\n // A stored manifest can predate this SDK version and carry a `type`\n // this build has never heard of (pre-input-validation, `inputs` was an\n // unknown key with no shape checking at all). Fail the field, don't\n // crash the run route.\n errors.push(`\"${key}\" has an unknown declared type \"${String(spec.type)}\"`)\n continue\n }\n if (!check(v)) {\n errors.push(`\"${key}\" must be of type ${spec.type}`)\n continue\n }\n if (spec.enum && !spec.enum.includes(v as string | number)) {\n errors.push(`\"${key}\" must be one of ${spec.enum.join(', ')}`)\n continue\n }\n out[key] = v\n }\n return errors.length > 0 ? { ok: false, errors } : { ok: true, value: out }\n}\n\n/**\n * A stored manifest's `inputs` key, admitted only when structurally valid.\n *\n * Versions deployed before inputs existed could carry ANY value under this\n * key (it was warn-and-store), and `startRun` must not let a stray legacy\n * blob retroactively break a working schedule — an invalid schema is treated\n * as \"declares no inputs\", never as a refusal.\n */\nexport function sanitizeInputsSchema(inputs: unknown): InputsSchema | undefined {\n if (!inputs || typeof inputs !== 'object' || Array.isArray(inputs)) return undefined\n for (const [key, raw] of Object.entries(inputs as Record<string, unknown>)) {\n if (checkInputFieldSpec(key, raw).errors.length > 0) return undefined\n }\n return inputs as InputsSchema\n}\n",
|
|
28
|
-
"frontera/automation/types.ts": "// `import type`, so this is erased at compile time and adds no runtime import —\n// but `@frontera-sdk/blueprint` is still a real `dependencies` entry, because\n// `WhereNode` is part of this package's PUBLIC type surface: anyone consuming\n// `AutomationContext` needs it to resolve. See the packaging note in\n// docs/superpowers/specs/2026-07-29-automations-ctx-blueprint-query-design.md —\n// a subset install that cannot resolve it aborts `bun install` outright.\nimport type { WhereNode } from '@frontera-sdk/blueprint/types'\n\n/**\n * Cron and manual only for now; event and webhook land with Event Triggers.\n *\n * The `?: never` members are load-bearing. Without them `{ cron, manual }`\n * typechecks — TypeScript's excess-property check admits any key present on\n * *some* member of a union — and the runner would have to decide at runtime\n * what a both-shaped trigger means.\n */\nexport type AutomationTrigger =\n | { cron: string; manual?: never }\n | { cron?: never; manual: true }\n\n/**\n * An author-time affordance, not a validation gate.\n *\n * `blueprint:read` is a literal, so the compiler completes it and offers \"Did\n * you mean 'blueprint:read'?\" on a typo. `agent:${string}:run` admits any slug\n * — including one that names no agent — so the union cannot be read as proof\n * that a grant is well-formed. The runtime gate is `validateManifest` in\n * `manifest.ts`; this exists to guide the author as they type.\n *\n * Widening it later (adding `notify:*`, `governed:*` with Governed Writes) is a\n * non-breaking change. Narrowing `string` to a union later would break every\n * automation already written, so it starts narrow.\n */\nexport type Grant =\n | 'blueprint:read'\n | `agent:${string}:run`\n /**\n * One capability of one Plugin install: `plugin:<install>:<capability>`.\n *\n * `<install>` is the install's name as `frontera plugin list` shows it\n * (lowercase, no spaces); `<capability>` is the capability's name on that\n * install. One grant per capability — there is no wildcard, for the same\n * reason `http:` has none: the manifest is the reviewable list of what the\n * automation can reach.\n */\n | `plugin:${string}:${string}`\n /** One EXACT host, no wildcards. `http:api.stripe.com` matches that host and\n * nothing else — a wildcard would ask a reviewer to reason about\n * subdomain-takeover risk, and the answer is usually wrong. */\n | `http:${string}`\n /** The NAME of a workspace secret. Its VALUE never enters this process: you\n * name it, the platform injects it server-side. */\n | `secret:${string}`\n /** One EXACT published Action apiName. `governed:approveInvoice` permits\n * submitting that Action and nothing else.\n *\n * Not wildcardable, for the same reason `http:` is not: a reviewer reading\n * `governed:*` would have to know the whole current Action catalog — and the\n * answer changes with every release — to know what the automation may do. */\n | `governed:${string}`\n\n/** One declared run input. A deliberate subset of JSON Schema — the same\n * philosophy as the grant grammar: small enough that a wrong shape is\n * refusable with a sentence, wide enough for real parameters. */\nexport interface InputFieldSpec {\n type: 'string' | 'number' | 'boolean' | 'object' | 'array'\n /** Refused at run start when absent. Mutually exclusive with `default`. */\n required?: boolean\n /** Applied at run start when the field is absent. Cron runs rely on these. */\n default?: unknown\n description?: string\n /** Allowed values — string and number types only. */\n enum?: readonly (string | number)[]\n}\n\nexport type InputsSchema = Record<string, InputFieldSpec>\n\nexport interface AutomationManifest {\n name: string\n trigger: AutomationTrigger\n grants?: readonly Grant[]\n /**\n * Declared run inputs, validated and defaulted at run start. Absent means\n * this automation takes no input — starting a run WITH input for such a\n * version is refused. See `InputFieldSpec`.\n */\n inputs?: InputsSchema\n concurrency?: number\n /**\n * Times the platform may retry a run that FAILED. Default 0, and the opt-in\n * is the contract.\n *\n * Setting this asserts your handler is safe to run twice. With `ctx.http` that\n * is a real claim rather than a formality — a retried run that charged a card\n * charges it again, and the platform cannot check idempotency on your behalf.\n * Per-automation, not global, because you are the only one who knows.\n *\n * Retries do NOT extend the ctx call budget: each attempt is a separate run\n * with its own meter.\n *\n * With steps, this is a bound on RUN attempts, and a step that fails is what\n * consumes one. Completed steps are not re-executed on the next attempt —\n * they return their stored results — so a retry resumes from the failure\n * rather than starting the work again. That is the point of putting a call\n * that costs something inside a step: `retries: 2` on a handler whose work is\n * all in steps re-runs only the step that failed, while the same setting on a\n * handler with no steps re-runs everything.\n */\n retries?: number\n description?: string\n}\n\n/**\n * What `automation()` guarantees once defaults are applied — nothing optional\n * left for a consumer to re-handle. Downstream code takes this, not\n * `AutomationManifest`, so it never re-derives a fact already established.\n */\nexport interface ResolvedAutomationManifest extends AutomationManifest {\n // Every member is readonly, not just the two with defaults. `Object.freeze`\n // in `define.ts` freezes the whole object at runtime, so leaving `name` or\n // `description` mutable in the type means `d.manifest.name = 'x'` compiles\n // and then throws — the same compile-clean/throw-at-runtime gap that the\n // removed `as string[]` cast used to create.\n readonly name: string\n readonly trigger: Readonly<AutomationTrigger>\n readonly grants: readonly Grant[]\n readonly inputs?: Readonly<InputsSchema>\n readonly concurrency: number\n readonly retries: number\n readonly description?: string\n}\n\nexport interface AgentHandle {\n run(prompt: string): Promise<{ text: string }>\n}\n\n/** What `ctx.plugin(install).call(...)` resolves to. */\nexport interface PluginCallResult<T = unknown> {\n /** Whatever the capability returned. Shape is the plugin's, not the platform's. */\n data: T\n}\n\nexport interface PluginHandle {\n /**\n * Invoke one capability of this install.\n *\n * Governed by the install's policy exactly as an agent's tool call is —\n * a disabled install, a `read_only` Action policy, a parameter constraint\n * or a missing workspace account all refuse here with the reason named.\n * A capability that requires approval cannot be called from an automation\n * at all (nobody to ask), and `deploy` refuses the grant up front.\n *\n * A failure reported by the plugin itself is thrown, carrying the plugin's\n * message. A success resolves to `{ data }` — there is no `ok` flag to\n * branch on, only the value.\n *\n * Dry in a dev run: returns `{ data: null }` and sends nothing.\n */\n call<T = unknown>(\n capability: string,\n input?: Record<string, unknown>,\n ): Promise<PluginCallResult<T>>\n}\n\n/**\n * Durable steps.\n *\n * A step is the unit the platform can memoize, retry and draw. Work inside one\n * runs at most once per run; work outside one runs again every time the\n * platform resumes the handler, which it does after every step completes.\n *\n * That resumption is the whole model and it is what the three rules below are\n * about — none of them is a style preference.\n */\nexport interface StepApi {\n /**\n * Run `fn` as a durable step and return its result.\n *\n * Three rules, all enforced or observable rather than advisory:\n *\n * 1. **`name` must be unique within a run.** The platform memoizes by it, so a\n * repeated name would silently hand back the FIRST call's result. Inside a\n * loop, put the index in the name — `` `submit:${i}` ``. A repeat fails the\n * run naming the collision rather than returning the wrong value.\n * 2. **The result must be JSON-serializable.** It is stored and replayed, so a\n * `Date` comes back as a string and a class instance comes back as a plain\n * object. Return data, not objects with behaviour.\n * 3. **Code outside a step re-executes.** After each step the handler restarts\n * from the top with completed steps returning their stored results. A\n * `ctx.http` call sitting outside a step therefore fires once per step, and\n * spends its call budget every time. The Console flags such calls on a run\n * that used steps.\n */\n run<T>(name: string, fn: () => Promise<T>): Promise<T>\n\n /**\n * Park the run for `ms` milliseconds, durably, under a unique name.\n *\n * On the platform this is a real checkpoint: the run stops occupying a\n * worker and resumes after the delay — pace provider polls with it (a\n * measured 429 arrived after ~7 back-to-back polls). In `createTestContext`\n * and in dev runs it records and returns immediately, so tests and dry runs\n * never actually wait. Shares the name-uniqueness rule with `run`: the\n * platform memoizes both by name.\n */\n sleep(name: string, ms: number): Promise<void>\n}\n\n/**\n * The 13 lifecycle states a governed Action Request can hold.\n *\n * A deliberate copy of a WIRE contract, not shared code — same reasoning as\n * `RegistryEntry` in the runner: this package must install from public npm with\n * a three-package dependency list, and importing the service's own enum would\n * drag drizzle and the schema into an author's `bun install`. The service's\n * `ACTION_REQUEST_LIFECYCLE_STATES` is the source of truth; the response proves\n * the two agree.\n */\nexport type ActionRequestLifecycle =\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\n/**\n * `subjectRef` and `expectedSubjectVersion` are paired deliberately.\n *\n * The plane requires BOTH for an Action over an existing subject and refuses\n * BOTH for a create Action, so independently-optional fields would let an\n * author write a submission that cannot be accepted and only find out at\n * runtime. Which arm applies is the Action's decision, not the caller's — read\n * it off the Action's `subject.mode`.\n */\nexport type ActionSubmission = {\n /** Published Action apiName. Requires a `governed:<apiName>` grant. */\n action: string\n input: Record<string, unknown>\n /** Required when the Action's definition says so. */\n reason?: string\n /**\n * Tells two submissions from the SAME step apart.\n *\n * A step submits once by default. The idempotency key is derived from the run\n * and the step alone, so a submission re-reached by a resumption or by a\n * retried attempt is the SAME key and the plane hands back the original\n * request instead of making a second one. Your own retry loop behaves the\n * same way: a submission that FAILED is not recorded, so submitting again\n * after catching a transport error re-sends and the plane replays.\n *\n * What you cannot do by default is submit twice on purpose. Two submissions\n * the platform cannot tell apart derive one key AND one semantic\n * fingerprint, so the plane would replay the first and answer both calls with\n * the same id — no error, one effect, a green run. Rather than let that\n * happen, the second call is refused before it leaves your process, naming\n * this field.\n *\n * Pass a distinct `submissionKey` per submission to say you meant it — a\n * business identity is the right value, not a counter:\n *\n * ```ts\n * await ctx.step.run('flag', async () => {\n * for (const row of rows) {\n * await ctx.action.submit({\n * action: 'flagForAudit',\n * // Stable for THIS row across every attempt. An array index is not:\n * // if the re-read returns the rows in another order, an index would\n * // bind row B's submission to row A's key.\n * submissionKey: row.id,\n * input: { rowId: row.id },\n * })\n * }\n * })\n * ```\n *\n * It must be stable across attempts for the same intended submission, which\n * is why the platform cannot derive it for you — only your code knows which\n * of two submissions is \"the same one again\". An empty string is refused;\n * omit it entirely to mean \"this step submits once\".\n */\n submissionKey?: string\n} & (\n | {\n subjectRef: { objectTypeId: string; objectId: string }\n /** The version you believe the subject is at: a submission built from a\n * stale read must lose rather than overwrite. */\n expectedSubjectVersion: string\n }\n | { subjectRef?: never; expectedSubjectVersion?: never }\n)\n\nexport interface ActionSubmitResult {\n requestId: string\n /**\n * Where the request stopped, NOT whether the effect happened.\n *\n * `ready` means accepted and queued for dispatch. `awaiting_approval` means\n * the Action requires a human and one has not decided yet — a normal return,\n * not an error. Neither is a completed business fact.\n */\n lifecycle: ActionRequestLifecycle\n}\n\n/**\n * `notify` still arrives with a later slice; `governed` is here.\n */\nexport interface AutomationContext {\n runId: string\n workspaceId: string\n /**\n * The values this run was started with — validated against the manifest's\n * `inputs` schema and fixed on the run row at start, so every resumption\n * and retried attempt sees the same object. `{}` when the manifest declares\n * no inputs. Visible in the run trace by design: never put a secret here —\n * `secret:` grants are the credential path.\n */\n input: Record<string, unknown>\n /** Never rejects — telemetry must not be able to fail a run. */\n log(message: string, data?: Record<string, unknown>): Promise<void>\n agent(slug: string): AgentHandle\n /** One Plugin install, by the name `frontera plugin list` shows. Needs `plugin:<install>:<capability>` per call. */\n plugin(install: string): PluginHandle\n http: {\n /**\n * Call an allowlisted host, optionally with a workspace secret injected\n * server-side.\n *\n * Requires an `http:<host>` grant, and an `secret:<name>` grant when `auth`\n * is used. The secret's VALUE never enters this process — that is deliberate:\n * a credential this process never held cannot be leaked by a stray\n * `ctx.log`, an exception serialiser, or a dependency, and step details are\n * rendered verbatim in the Console.\n *\n * An upstream 4xx/5xx comes back as `status`, not as a throw. An API\n * answering 404 is data; only failures of the mechanism reject.\n */\n fetch(req: HttpRequest): Promise<HttpResponse>\n }\n blueprint: {\n query<T = Record<string, unknown>>(\n objectType: string,\n options?: BlueprintQueryOptions,\n ): Promise<BlueprintQueryResult<T>>\n }\n /**\n * Durable steps. See `StepApi`.\n *\n * Present on every automation — a handler that uses no steps behaves exactly\n * as it did before this existed, because a run with no steps is never\n * resumed.\n */\n step: StepApi\n action: {\n /**\n * Ask the governed write plane to perform one named business change.\n *\n * This is the ONLY way an automation changes a system of record. Your code\n * never holds a write handle: you describe the change, and the plane\n * authorizes it, approves it if the Action says so, dispatches it, confirms\n * it and records it. An Action that declares `approval: required` cannot be\n * talked out of it by the caller.\n *\n * Two rules:\n *\n * 1. **It must be called inside `ctx.step.run`.** Code outside a step\n * re-executes after every step boundary, so a submit sitting there would\n * fire once per boundary. Inside a step it runs once, and the step —\n * identified by the row the service itself issued — is what makes the\n * idempotency key stable across resumption and across a retried run.\n *\n * The service checks this rather than taking your word for it: the\n * submission carries a step row id, and a submission whose id names no\n * open step of this run is refused. What that check cannot do is make a\n * determined bundle behave — your code runs unsandboxed in the same\n * process as the run token, so it could open a step row purely to submit\n * inside it. The bound is that such a step is a real row and shows up in\n * the run trace, not that it is impossible.\n * 2. **It never waits.** It returns as soon as the request is durably\n * accepted. A run has nobody to ask for an approval and ten minutes to\n * live, so blocking on a human is not something this can offer —\n * `awaiting_approval` is a normal return value.\n *\n * The returned `lifecycle` is where the request stopped, not proof of\n * effect. Poll the ledger, or let the Action's Business Event tell you.\n */\n submit(request: ActionSubmission): Promise<ActionSubmitResult>\n }\n}\n\nexport interface HttpRequest {\n url: string\n method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'\n headers?: Record<string, string>\n /** String only — no streaming, no binary. */\n body?: string\n /** Inject a workspace secret into one header. Needs a `secret:<name>` grant. */\n auth?: { header: string; secret: string; prefix?: string }\n}\n\nexport interface HttpResponse {\n status: number\n headers: Record<string, string>\n /** Capped at 1 MB. Exceeding the cap is an error, never a truncation — a\n * silently shortened response is a wrong answer that looks right. */\n body: string\n}\n\nexport interface BlueprintQueryOptions {\n /** The real Blueprint filter DSL, not a shorthand — `and`/`or`/`not`, ranges\n * and date presets all work. A convenience subset with no escape hatch is the\n * thing the first \"overdue OR flagged\" automation would have to work around. */\n where?: WhereNode\n select?: string[]\n /** `dir`, not `direction` — matches `QueryRequest` exactly. */\n orderBy?: Array<{ property: string; dir: 'asc' | 'desc' }>\n /** Default 100, clamped to 1000. Exceeding the cap sets `hasMore`; it never\n * truncates silently. */\n limit?: number\n /** Opaque. Pass back the previous result's `nextPageToken`; absent means the\n * first page. Cursor-based, so a scan stays correct while the table moves\n * underneath it — which a cron-driven automation's table always does. */\n pageToken?: string\n}\n\nexport interface BlueprintQueryResult<T = Record<string, unknown>> {\n rows: T[]\n /** True when the query matched more rows than were returned. */\n hasMore: boolean\n /** Present iff `hasMore`. Feed to the next call's `pageToken`.\n *\n * Forwarded rather than narrowed away on purpose: `hasMore` on its own is a\n * fact the author can do nothing about, which is how a digest over the first\n * 500 of 5,000 rows reports success. */\n nextPageToken?: string\n}\n\nexport type AutomationHandler = (ctx: AutomationContext) => Promise<unknown>\n\nexport interface AutomationDescriptor {\n readonly manifest: ResolvedAutomationManifest\n readonly handler: AutomationHandler\n}\n",
|
|
29
|
-
"frontera/automation/runtime-context.ts": "/**\n * The REAL `ctx` a handler receives — the one that talks to the service.\n *\n * It lives in the SDK rather than in the runner because it now has two\n * consumers: the deployed runner executing a bundle, and the CLI's dev worker\n * executing a file on a developer's machine. One implementation means a dev run\n * and a production run cannot drift in what they enforce or how they word a\n * refusal, which is the whole reason a dev loop is worth trusting.\n *\n * NOT re-exported from `index.ts`, and NOT in `AUTOMATION_SDK_FILES`: a\n * scaffolded project vendors the authoring surface, and this file reaches the\n * network. Authors get `createTestContext`; the two runtimes get this.\n */\nimport { AsyncLocalStorage } from 'node:async_hooks'\n// Wording lives in the SDK, not here: the runner refusing a call before it makes\n// it, the service's own 403, and `createTestContext` on the author's machine all\n// have to say the same sentence — a test that fails in different words than\n// production teaches the wrong lesson. Re-exported below because this module is\n// where the runner's code and tests have always reached for them.\nimport {\n duplicateStepMessage as duplicateStepMessageText,\n missingGrantMessage as missingGrantMessageText,\n submitOutsideStepMessage as submitOutsideStepMessageText,\n lostStepRowMessage as lostStepRowMessageText,\n duplicateSubmissionMessage as duplicateSubmissionMessageText,\n emptySubmissionKeyMessage as emptySubmissionKeyMessageText,\n} from './messages'\nimport type {\n ActionSubmission,\n ActionSubmitResult,\n AutomationContext,\n BlueprintQueryOptions,\n BlueprintQueryResult,\n HttpRequest,\n HttpResponse,\n PluginCallResult,\n} from './types'\n\nconst SERVICE_URL = process.env.SERVICE_URL ?? 'http://localhost:4000'\n\n/**\n * The step tools this module needs, declared structurally rather than imported\n * from `inngest`.\n *\n * Structural because it keeps the whole file testable with a two-line stub, and\n * because it states exactly what `ctx` depends on — one method — instead of the\n * platform's entire step surface. `function-builder.ts` passes the real object\n * straight in, so the compiler still checks the two agree.\n */\nexport interface StepTools {\n /**\n * Returns `unknown`, deliberately, and not the body's own type.\n *\n * What comes back is not the value the body returned but its JSON round trip:\n * the platform stores a step's result and replays it on the next execution, so\n * a `Date` returns as a string and a class instance as a plain object. Typing\n * this as `Promise<T>` here would erase that at exactly the boundary where it\n * happens. `ctx.step.run` narrows it once, at the seam, with the same\n * reasoning `ctx.blueprint.query` narrows a warehouse row.\n */\n run<T>(id: string, fn: () => Promise<T>): Promise<unknown>\n /** Absent on hosts that predate it — the runtime falls back to an inline wait. */\n sleep?(id: string, ms: number): Promise<void>\n}\n\ninterface Deps {\n runId: string\n workspaceId: string\n runToken: string\n grants: string[]\n /** The platform's step tools for THIS execution. */\n step: StepTools\n /** The run's frozen input row, or absent when the manifest declares none. */\n input?: Record<string, unknown>\n /** Zero-indexed run attempt, stamped onto every row this context writes. */\n attempt?: number\n /**\n * Where the service lives, when the caller knows better than the environment.\n *\n * The deployed runner reads `SERVICE_URL` from its own env; the CLI's dev\n * worker knows it from the origin the developer logged into, and a\n * module-level const read at import time cannot be told. Overriding here keeps\n * this module usable in both processes rather than forked for one.\n */\n serviceUrl?: string\n /**\n * The deployment-wide runner secret, or absent.\n *\n * PASSED IN, never read from the environment here. This module now runs in two\n * processes, and only one of them may hold this token: the runner does, a\n * developer's laptop must not. Reading `process.env` inside shared code moves\n * that decision into an environment nobody reviews — a developer who has the\n * variable exported for any reason, a copied env file, a locally-run runner,\n * would have `automation dev` sending a workspace-wide credential from their\n * machine with nothing on screen to say so.\n *\n * As a parameter the rule is structural: the dev worker cannot send it,\n * because it has nothing to pass.\n */\n runnerToken?: string\n}\n\nexport {\n duplicateStepMessage,\n duplicateSubmissionMessage,\n emptySubmissionKeyMessage,\n lostStepRowMessage,\n missingGrantMessage,\n submitOutsideStepMessage,\n} from './messages'\n\nexport class DuplicateStepNameError extends Error {\n constructor(readonly stepName: string) {\n super(duplicateStepMessageText(stepName))\n this.name = 'DuplicateStepNameError'\n }\n}\n\nclass GrantError extends Error {\n constructor(grant: string) {\n super(missingGrantMessageText(grant))\n this.name = 'GrantError'\n }\n}\n\nexport function buildContext(deps: Deps): AutomationContext {\n const serviceUrl = deps.serviceUrl ?? SERVICE_URL\n const requireGrant = (grant: string) => {\n if (!deps.grants.includes(grant)) throw new GrantError(grant)\n }\n\n /**\n * Step and finish writes carry BOTH credentials, and the service takes either.\n *\n * The deployed runner has the shared secret; a dev worker on a developer's\n * laptop must never hold it, and has only the run's own token — which is the\n * stronger claim for a row that belongs to one run. Sending both means this\n * module works unchanged in either process, which is the whole reason it can\n * be reused by the CLI rather than forked.\n *\n * An empty runner token is omitted rather than sent blank: the service treats\n * a PRESENT runner header as an assertion to verify, so a blank one would be\n * a 401 instead of a fall-through to the run token.\n */\n const runnerHeaders: Record<string, string> = {\n 'content-type': 'application/json',\n 'x-automation-run-token': deps.runToken,\n ...(deps.runnerToken ? { 'x-automation-runner-token': deps.runnerToken } : {}),\n }\n\n /**\n * Which author-declared step the code writing a row is running inside.\n *\n * Async-local rather than a plain variable because two steps can be in flight\n * at once — `Promise.all([ctx.step.run('a', …), ctx.step.run('b', …)])` is\n * legal, and a shared mutable \"current step\" would file `a`'s ctx calls under\n * `b` depending on interleaving. This is per-run, not module-global: two runs\n * in one process must never see each other's scope.\n */\n const stepScope = new AsyncLocalStorage<{\n stepId: string\n stepName: string\n /**\n * `action` + `submissionKey` for every submit this step body has made.\n *\n * The write plane CANNOT catch a repeat: two identical submissions derive\n * one key and one semantic fingerprint, so it replays the first request and\n * answers both calls with the same id — no error, one effect, a green run.\n * The check has to be local, and per step EXECUTION so that a genuine\n * resumption or retry (which re-enters the body from scratch) is unaffected.\n */\n submitted: Set<string>\n }>()\n\n /**\n * Append a row to the run's audit trail, returning the id the service gave it.\n *\n * Never throws. A step row is a record OF the work, not part of it — so a\n * service blip while recording must not turn a completed operation into a\n * failed run, and must not replace an in-flight failure with a transport\n * error on the way to reporting it. The same reasoning is why a failure\n * returns an empty id rather than propagating: losing the parent link on one\n * row is strictly better than losing the run.\n */\n const recordStep = async (body: Record<string, unknown>): Promise<string> => {\n const parentStepId = stepScope.getStore()?.stepId\n try {\n const res = await fetch(`${serviceUrl}/v1/automations/runner/runs/${deps.runId}/steps`, {\n method: 'POST',\n headers: runnerHeaders,\n body: JSON.stringify({\n // Only when there IS a parent. A step whose own row failed to write\n // leaves an empty id in scope, and sending that empty string reaches\n // Postgres as `''::uuid`, which errors — so the child row would be\n // dropped too, quietly, because this whole path is non-fatal. One\n // lost step row must not cost the calls made inside it.\n ...(parentStepId ? { parentStepId } : {}),\n attempt: deps.attempt ?? 0,\n ...body,\n }),\n })\n // `fetch` resolves on a 4xx/5xx, so the status is the only place a\n // rejected step surfaces at all.\n if (!res.ok) {\n console.warn(`[ctx] step record failed (non-fatal): ${res.status}`)\n return ''\n }\n return ((await res.json()) as { data?: { id?: string } }).data?.id ?? ''\n } catch (err) {\n console.warn('[ctx] step record failed (non-fatal):', (err as Error).message)\n return ''\n }\n }\n\n /** Close an author-declared step row. Never throws, for the same reason. */\n const completeStep = async (stepId: string, body: Record<string, unknown>): Promise<void> => {\n if (!stepId) return\n try {\n const res = await fetch(\n `${serviceUrl}/v1/automations/runner/runs/${deps.runId}/steps/${stepId}/complete`,\n { method: 'POST', headers: runnerHeaders, body: JSON.stringify(body) },\n )\n if (!res.ok) console.warn(`[ctx] step complete failed (non-fatal): ${res.status}`)\n } catch (err) {\n console.warn('[ctx] step complete failed (non-fatal):', (err as Error).message)\n }\n }\n\n /**\n * The message an author should read when a ctx call is refused.\n *\n * The service answers with an envelope (`{error, message, code}`), so the raw\n * body pasted into an error reads `ctx.http → 400 {\"error\":true,\"message\":...}`\n * — the useful sentence is in there, wrapped in JSON the author did not ask\n * for and cannot act on. This unwraps it and falls back to the raw body when\n * the response is not one of ours (a proxy 502, say), because an empty message\n * would be worse than a noisy one.\n */\n const refusal = async (res: Response): Promise<string> => {\n const body = await res.text()\n try {\n const parsed = JSON.parse(body) as { message?: unknown }\n if (typeof parsed.message === 'string' && parsed.message) return parsed.message\n } catch {\n // Not JSON. Fall through to the body.\n }\n return body\n }\n\n const step = async (kind: string, label: string, fn: () => Promise<unknown>): Promise<unknown> => {\n const t0 = Date.now()\n try {\n const out = await fn()\n await recordStep({ kind, label, status: 'ok', durationMs: Date.now() - t0 })\n return out\n } catch (err) {\n await recordStep({\n kind,\n label,\n status: 'error',\n detail: { message: (err as Error).message },\n durationMs: Date.now() - t0,\n })\n throw err\n }\n }\n\n /**\n * Every ctx call carries the per-run token, never a workspace credential.\n *\n * The fixed headers go LAST so `init.headers` cannot override them — the run\n * token is the entire authority of this call, and a caller that could replace\n * it could replace the run's scope.\n */\n const scoped = (path: string, init?: RequestInit) =>\n // `/automations/runner` — the ctx endpoints live on `automationRunnerRouter`,\n // which is prefixed, because they authenticate by run token rather than by\n // session. Addressing them as `/automations/...` reaches the session-guarded\n // router instead and 404s. This is only caught end to end: both sides pass\n // their own tests, and the mismatch is between them.\n fetch(`${serviceUrl}/v1/automations/runner${path}`, {\n ...init,\n headers: {\n ...(init?.headers ?? {}),\n 'content-type': 'application/json',\n 'x-automation-run-token': deps.runToken,\n 'x-workspace-id': deps.workspaceId,\n },\n })\n\n /**\n * Names used by this EXECUTION, which is what the uniqueness rule is about.\n *\n * A run that uses steps is executed many times — once more after each step\n * completes — and every execution walks the handler from the top, naming the\n * same steps again. That is not a duplicate. A duplicate is the same name\n * twice within one walk, which is what this set sees, because a fresh context\n * is built per execution.\n */\n const namesThisExecution = new Set<string>()\n\n /**\n * Run `fn` as a durable step.\n *\n * The row is written from INSIDE the step body, and that placement is the\n * whole design rather than an implementation detail. Code after\n * `await step.run(...)` does not run in the same execution — the platform\n * checkpoints the step and resumes the handler in a fresh execution — so a\n * report written there would land one execution late, time the memoized\n * return instead of the work, and repeat on every later resumption. A body\n * runs exactly once per real execution of the step, so a report inside it is\n * written exactly once and times what actually happened.\n *\n * Open-then-close rather than one write at the end: ctx calls made inside the\n * body need the parent row to exist before they record, and opening first also\n * puts the step ahead of its own children in `seq`.\n */\n const runStep = async <T>(name: string, fn: () => Promise<T>): Promise<T> => {\n if (namesThisExecution.has(name)) throw new DuplicateStepNameError(name)\n namesThisExecution.add(name)\n\n // The cast is the honest boundary: the platform hands back the JSON round\n // trip of what the body returned, and nothing here can verify the author's\n // `T` survived it. Rule 2 on `StepApi` is that contract, stated where the\n // author reads it.\n return (await deps.step.run(name, async () => {\n const stepId = await recordStep({\n kind: 'step',\n label: name,\n stepName: name,\n status: 'running',\n })\n const t0 = Date.now()\n try {\n // `stepName` rides alongside `stepId` because `ctx.action.submit` needs\n // the NAME, not the row id: the id is fresh on every execution, and an\n // idempotency key derived from it would differ on each resumption —\n // which is the exact duplicate-submission this scope exists to prevent.\n // The service reads the name off the row rather than trusting this\n // copy; it travels here only so a refusal can name it.\n const out = await stepScope.run(\n { stepId, stepName: name, submitted: new Set<string>() },\n fn,\n )\n await completeStep(stepId, { status: 'ok', durationMs: Date.now() - t0 })\n return out\n } catch (err) {\n await completeStep(stepId, {\n status: 'error',\n durationMs: Date.now() - t0,\n detail: { message: (err as Error).message },\n })\n throw err\n }\n })) as T\n }\n\n /**\n * Durable pause. No step row is written: a row recorded after a memoized\n * sleep would be re-recorded by every later execution (the code after an\n * awaited memoized step re-runs per resumption), and unlike `runStep`\n * there is no body to write it from exactly once.\n */\n const sleepStep = async (name: string, ms: number): Promise<void> => {\n if (namesThisExecution.has(name)) throw new DuplicateStepNameError(name)\n namesThisExecution.add(name)\n if (deps.step.sleep) {\n await deps.step.sleep(name, ms)\n return\n }\n // Host without a sleep arm (an old dev worker): wait inline. Correct,\n // just not durable — acceptable for the host that cannot resume anyway.\n await new Promise((resolve) => setTimeout(resolve, ms))\n }\n\n return {\n runId: deps.runId,\n workspaceId: deps.workspaceId,\n // The host passes the run row's frozen copy; the SDK never re-validates — `startRun` is the authority.\n input: deps.input ?? {},\n\n step: { run: runStep, sleep: sleepStep },\n\n async log(message, data) {\n // Swallowed on purpose, inside `recordStep`. `ctx.log` is telemetry, and a\n // blip reaching the service must not take down an otherwise-healthy run —\n // the signature promises callers it never rejects.\n await recordStep({ kind: 'log', label: message, detail: data ?? {} })\n },\n\n agent(slug: string) {\n return {\n run: (prompt: string) =>\n step('agent', `agent:${slug}`, async () => {\n requireGrant(`agent:${slug}:run`)\n const res = await scoped('/ctx/agent-run', {\n method: 'POST',\n body: JSON.stringify({ slug, prompt }),\n })\n if (!res.ok) throw new Error(`agent ${slug} → ${res.status} ${await refusal(res)}`)\n return ((await res.json()) as { data: { text: string } }).data\n }) as Promise<{ text: string }>,\n }\n },\n\n plugin(install: string) {\n return {\n call: <T = unknown>(capability: string, input?: Record<string, unknown>) =>\n step('plugin', `plugin:${install}:${capability}`, async () => {\n // Pre-flighted locally so an author reads the grant by name, in the\n // same words the service uses. The service checks it again — this\n // copy exists for the message, not for the authority.\n requireGrant(`plugin:${install}:${capability}`)\n const res = await scoped('/ctx/plugin-call', {\n method: 'POST',\n body: JSON.stringify({ install, capability, input: input ?? {} }),\n })\n if (!res.ok) {\n throw new Error(\n `ctx.plugin(\"${install}\").call(\"${capability}\") → ${res.status} ${await refusal(res)}`,\n )\n }\n // Two levels: the service envelope's data, then PluginCallResult's own data.\n return ((await res.json()) as { data: PluginCallResult<T> }).data\n }) as Promise<PluginCallResult<T>>,\n }\n },\n\n http: {\n fetch: (req: HttpRequest) =>\n step('http', `${req.method ?? 'GET'} ${req.url}`, async () => {\n // Pre-flight the HOST grant so an author sees it named locally, in the\n // same wording the service uses. The SECRET grant is deliberately not\n // pre-flighted: the service derives it, and duplicating that derivation\n // here would be a second place to get it wrong.\n let host: string\n try {\n host = new URL(req.url).hostname.toLowerCase()\n } catch {\n throw new Error(`ctx.http: invalid URL ${req.url}`)\n }\n requireGrant(`http:${host}`)\n const res = await scoped('/ctx/http', {\n method: 'POST',\n body: JSON.stringify(req),\n })\n if (!res.ok) throw new Error(`ctx.http → ${res.status} ${await refusal(res)}`)\n return ((await res.json()) as { data: HttpResponse }).data\n }) as Promise<HttpResponse>,\n },\n\n action: {\n submit: (request: ActionSubmission) =>\n step('action', `action:${request.action}`, async () => {\n // Step scope BEFORE the grant. Both are the author's mistake, but this\n // one is structural: a submit outside a step is wrong even with every\n // grant in place, and the remedy is a code change rather than a\n // manifest change. Naming the manifest first would send them to the\n // wrong file.\n const scope = stepScope.getStore()\n if (!scope) throw new Error(submitOutsideStepMessageText(request.action))\n // A step whose own row was lost cannot be submitted from. `recordStep`\n // is contractually non-fatal and hands back an empty id, which is\n // right for telemetry — one lost row must not cost the calls made\n // inside it — but a submission has nothing to key on without it, and\n // improvising a key is how an effect happens twice. Refused HERE so\n // the cause is named; the service would otherwise see an empty string\n // and answer with a generic invalid-submission.\n if (!scope.stepId) throw new Error(lostStepRowMessageText(scope.stepName))\n // NUL-joined because both halves are author strings; `a:b` with no\n // key must not collide with `a` keyed `b`.\n // Refused locally, matching the service's own field-named rejection\n // — folding `''` into the no-key identity would make the two layers\n // disagree about what the author asked for.\n if (request.submissionKey !== undefined && request.submissionKey.length === 0) {\n throw new Error(emptySubmissionKeyMessageText(request.action))\n }\n // Ahead of the reservation, and synchronous so check-and-reserve still\n // land in one tick. Below it, a missing grant left the identity\n // reserved and the author's next attempt was told they had duplicated\n // a submission that never left the process — pointing at\n // `submissionKey` when the fix is one line in the manifest. A call\n // that is both ungranted and a duplicate now reports the grant, which\n // is the more actionable of the two.\n requireGrant(`governed:${request.action}`)\n const submissionIdentity = `${request.action}\\u0000${request.submissionKey ?? ''}`\n // RESERVE, synchronously. The check and the record must land in one\n // tick: with an await between them, `Promise.all([submit(x),\n // submit(x)])` passes both checks before either records, both reach\n // the plane, and — same key, same fingerprint — the plane replays the\n // first for the second. Two calls, one effect, a green run, which is\n // the exact failure this guard exists to prevent.\n //\n // Released again in the catch below, so a submission that never\n // landed does not burn its identity and the author's retry loop still\n // works. Reserve-then-release is what satisfies both at once.\n if (scope.submitted.has(submissionIdentity)) {\n throw new Error(duplicateSubmissionMessageText(request.action))\n }\n scope.submitted.add(submissionIdentity)\n // The step ROW id, which the service issued. The service resolves the\n // row, takes the step NAME off it, and derives the key from that — so\n // what identifies the submission comes from the database rather than\n // from this process. No ordinal: a positional one made an in-body\n // retry mint a fresh key and duplicate the effect, and reordered\n // concurrent submits bind each other's keys. `submissionKey` is how an\n // author says two submissions are genuinely two.\n let res: Response\n try {\n res = await scoped('/ctx/action-submit', {\n method: 'POST',\n body: JSON.stringify({ ...request, stepId: scope.stepId }),\n })\n } catch (err) {\n // Never reached the service. Release, so a retry is a retry rather\n // than a duplicate accusation for a step that submitted zero times.\n scope.submitted.delete(submissionIdentity)\n throw err\n }\n if (!res.ok) {\n // Refused, so nothing was bound to this identity. A 5xx is the\n // interesting case: the author catches it and submits again, and\n // that second call must be allowed through to the plane, where the\n // key — unchanged — makes it a replay rather than a second effect.\n scope.submitted.delete(submissionIdentity)\n throw new Error(`ctx.action.submit → ${res.status} ${await refusal(res)}`)\n }\n try {\n return ((await res.json()) as { data: ActionSubmitResult }).data\n } catch (err) {\n // The submission LANDED, so keeping the reservation would be\n // defensible — but the reasoning that releases a 503 applies here\n // with more force: the key is unchanged, so a resubmit can only\n // replay, and replaying is the only way the author recovers a\n // request id they never received. Keeping it ends the run accusing\n // them of two submissions when there was one and an unreadable\n // answer.\n scope.submitted.delete(submissionIdentity)\n throw err\n }\n }) as Promise<ActionSubmitResult>,\n },\n\n blueprint: {\n query: <T = Record<string, unknown>>(objectType: string, options?: BlueprintQueryOptions) =>\n step('blueprint', `query:${objectType}`, async () => {\n requireGrant('blueprint:read')\n // Spread rather than forwarded field-by-field so adding an option to\n // `BlueprintQueryOptions` does not silently drop it here — the\n // service validates the body, so an unknown key is refused there\n // rather than ignored in transit.\n const res = await scoped('/ctx/blueprint-query', {\n method: 'POST',\n body: JSON.stringify({ objectType, ...(options ?? {}) }),\n })\n if (!res.ok) throw new Error(`blueprint query → ${res.status} ${await refusal(res)}`)\n // `T` is an author-supplied shape for rows the warehouse returns\n // untyped. The cast is the honest boundary: nothing here can verify\n // it, and pretending otherwise would just move the lie deeper.\n return ((await res.json()) as { data: BlueprintQueryResult<T> }).data\n }) as Promise<BlueprintQueryResult<T>>,\n },\n }\n}\n",
|
|
30
|
-
"frontera/automation/manifest.ts": "import { CronExpressionParser } from 'cron-parser'\nimport { MAX_INPUT_BYTES, checkInputFieldSpec } from './inputs'\n\nconst SEGMENT = '[a-z][a-z0-9]*(?:-[a-z0-9]+)*'\nconst NAME_RE = new RegExp(`^${SEGMENT}$`)\n\n/**\n * Runtime gate for a grant: `<namespace>:<name>[:<action>]`, every segment\n * sharing NAME_RE's grammar so the whole vocabulary is consistent.\n *\n * WIDER than the `Grant` union on namespaces — a server must not reject\n * `notify:email` merely because this build predates it. NARROWER than the\n * union's `agent:${string}:run` arm on the slug, which admits `agent::run` and\n * `agent:AGENT:run`; both are rejected here. No legitimate slug is affected —\n * this repo's agent slugs are already lowercase-kebab.\n */\nconst GRANT_RE = new RegExp(`^${SEGMENT}:${SEGMENT}(?::${SEGMENT})?$`)\n\n/**\n * `http:<host>` and `secret:<NAME>` need their own grammars, because SEGMENT is\n * lowercase-kebab and neither value is.\n *\n * A host contains DOTS (`api.stripe.com`); a secret name is conventionally\n * SCREAMING_SNAKE_CASE (`STRIPE_KEY`). Validating them with SEGMENT rejected both\n * realistic forms — found by deploying an automation that used them.\n *\n * Deliberately not solved by widening SEGMENT: that governs agent slugs too, and\n * loosening it there would admit `agent:AGENT:run`, which the comment above says\n * is rejected on purpose.\n */\nconst HOST_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/\n// Matches SECRET_NAME_PATTERN in workspace-secrets-router exactly. Being MORE\n// permissive here would let a manifest declare `secret:myKey`, validate cleanly,\n// and then never be satisfiable — no such secret can be created. A validator that\n// accepts the unsatisfiable is worse than one that is strict.\nconst SECRET_NAME_RE = /^[A-Z][A-Z0-9_]*$/\n// Matches `apiNameSchema` in the Blueprint Action definition schema exactly.\n// Same reasoning as SECRET_NAME_RE: a looser grammar here would accept\n// `governed:Approve_Invoice`, validate cleanly, and name an Action that can\n// never exist — no published Action carries that apiName, so the grant is\n// unsatisfiable and the automation fails at its first submit instead of at\n// deploy.\nconst ACTION_API_NAME_RE = /^[a-z][A-Za-z0-9]{0,99}$/\n\n// `plugin:<install>:<capability>`. The service does NOT validate\n// `app_installs.install_name` — it is `t.String({ minLength: 1 })`, so an\n// admin can name an install \"My CRM\" and it works fine everywhere except\n// here. This grammar (lowercase, dot/dash/underscore, no spaces — the catalog\n// default is kebab) is what makes an install's name usable from a manifest;\n// one outside it has to be renamed before an automation can grant it. The\n// capability half is deliberately wider: MCP tool names and spec capability\n// names are `create_issue` / `listIssues`, neither of which is a SEGMENT. No\n// wildcard in either half — the manifest is the reviewable list of what the\n// automation can reach, same as `http:`.\nconst PLUGIN_GRANT_RE = /^[a-z0-9][a-z0-9._-]{0,63}:[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/\n\n/** Namespaces whose value is not a SEGMENT. */\nconst TYPED_NAMESPACES: Record<string, { re: RegExp; hint: string }> = {\n http: { re: HOST_RE, hint: 'a hostname, e.g. \"http:api.stripe.com\" (no scheme, no path, no wildcard)' },\n secret: { re: SECRET_NAME_RE, hint: 'a workspace secret name, e.g. \"secret:STRIPE_KEY\"' },\n governed: {\n re: ACTION_API_NAME_RE,\n hint: 'one published Action apiName, e.g. \"governed:approveInvoice\" (camelCase, no wildcard)',\n },\n plugin: {\n re: PLUGIN_GRANT_RE,\n hint:\n '<install>:<capability>, e.g. \"plugin:crm:create_ticket\" — the install name from '\n + '`frontera plugin list` (lowercase, no spaces) and one capability name (no wildcard)'\n + ' — rename the install if its name has capitals or spaces',\n },\n}\n\nconst KNOWN_KEYS = new Set([\n 'name', 'trigger', 'grants', 'inputs', 'concurrency', 'retries', 'description',\n])\n\nexport interface ValidationResult {\n valid: boolean\n errors: string[]\n /**\n * Non-fatal. An unknown manifest key lands here rather than in `errors`:\n * a newer SDK must be able to add a field without an older service refusing\n * the deploy. The CLI prints these, so a typo like `concurrancy: 100` — which\n * would otherwise deploy \"successfully\" with the default of 1 — is caught at\n * author time, where the SDK and the manifest are the same version.\n */\n warnings: string[]\n}\n\n/**\n * Takes `unknown`, on purpose.\n *\n * The authoritative call site is the service, validating a manifest that\n * arrived over HTTP — untrusted, and not yet known to have any shape. Typing\n * the parameter as `AutomationManifest` would force every honest caller to\n * launder untrusted input through a cast, which is how a validator ends up\n * trusting the thing it exists to check.\n *\n * The regexes here are deliberately wider than the `Grant` union in `types.ts`:\n * that union is an author-time affordance, this is a runtime gate, and a server\n * must not reject a grant merely because this build predates it.\n */\nexport function validateManifest(input: unknown): ValidationResult {\n const errors: string[] = []\n const warnings: string[] = []\n const m = (input ?? {}) as {\n name?: unknown\n trigger?: unknown\n grants?: unknown\n inputs?: unknown\n concurrency?: unknown\n retries?: unknown\n }\n\n if (typeof m.name !== 'string' || !NAME_RE.test(m.name)) {\n errors.push('name must be lowercase kebab-case')\n } else if (m.name.length > 64) {\n errors.push('name must be 64 characters or fewer')\n }\n\n const trigger = m.trigger as { cron?: string; manual?: boolean } | undefined\n if (!trigger || (trigger.cron === undefined && trigger.manual !== true)) {\n errors.push('trigger must be { cron } or { manual: true }')\n } else if (trigger.cron !== undefined) {\n if (typeof trigger.cron !== 'string') {\n errors.push('invalid cron expression: must be a string')\n } else {\n // Both field-count branches exist because `cron-parser` accepts an\n // off-count expression rather than throwing, so neither case would ever\n // reach the `catch` below:\n // `* * * * * *` -> reads field 1 as SECONDS and fires sub-minute.\n // `0 7 * *` -> left-pads, scheduling something the author never wrote.\n // Only an exactly-5-field expression means what it looks like it means.\n const fields = trigger.cron.trim().split(/\\s+/).length\n if (fields !== 5) {\n // One message shape for one class of fault. Splitting it meant a\n // 7-field expression was told it was \"sub-minute\" — a diagnosis\n // asserted rather than derived — while a 4-field one got no diagnosis\n // at all.\n errors.push(\n `invalid cron expression: expected 5 fields, got ${fields}` +\n (fields > 5 ? '; sub-minute schedules are not supported' : ''),\n )\n } else {\n try {\n CronExpressionParser.parse(trigger.cron, { tz: 'UTC' })\n } catch (err) {\n errors.push(`invalid cron expression: ${(err as Error).message}`)\n }\n }\n }\n }\n\n if (m.grants !== undefined && !Array.isArray(m.grants)) {\n errors.push('grants must be an array')\n } else {\n for (const g of (m.grants as unknown[]) ?? []) {\n // String(g), not `${g}` — a template literal THROWS on a symbol, and a\n // validator that exists to absorb hostile input must not have a throwing\n // path. The message names the fix, not just the verdict.\n if (typeof g !== 'string') {\n errors.push(\n `malformed grant \"${String(g)}\" — expected \"<namespace>:<action>\", ` +\n 'e.g. \"blueprint:read\" or \"agent:risk-analyst:run\"',\n )\n continue\n }\n const colon = g.indexOf(':')\n const typed = colon > 0 ? TYPED_NAMESPACES[g.slice(0, colon)] : undefined\n if (typed) {\n // A typed namespace validates its OWN value grammar. `http:` and\n // `secret:` carry hosts and secret names, neither of which is a SEGMENT.\n if (!typed.re.test(g.slice(colon + 1))) {\n errors.push(`malformed grant \"${g}\" — the part after the colon must be ${typed.hint}`)\n }\n continue\n }\n if (!GRANT_RE.test(g)) {\n errors.push(\n `malformed grant \"${String(g)}\" — expected \"<namespace>:<action>\", ` +\n 'e.g. \"blueprint:read\" or \"agent:risk-analyst:run\"',\n )\n }\n }\n }\n\n const c = m.concurrency\n if (c !== undefined && (!Number.isInteger(c) || (c as number) < 1 || (c as number) > 50)) {\n errors.push('concurrency must be an integer between 1 and 50')\n }\n\n // Capped at 5. Above that it is not a retry policy, it is a loop — and every\n // attempt re-runs whatever side effects the previous one already performed.\n const r = m.retries\n if (r !== undefined && (!Number.isInteger(r) || (r as number) < 0 || (r as number) > 5)) {\n errors.push('retries must be an integer between 0 and 5')\n }\n\n const inputs = m.inputs\n if (inputs !== undefined) {\n if (!inputs || typeof inputs !== 'object' || Array.isArray(inputs)) {\n errors.push('inputs must be an object of { name: { type, … } }')\n } else {\n // Per-field rules (name shape, type, required/default shape, enum) live\n // in `checkInputFieldSpec` — shared with `sanitizeInputsSchema` so the\n // two can never drift on what \"a well-formed input field\" means.\n for (const [key, raw] of Object.entries(inputs as Record<string, unknown>)) {\n const field = checkInputFieldSpec(key, raw)\n errors.push(...field.errors)\n warnings.push(...field.warnings)\n }\n // A cron fire has no one to prompt: every required field must be\n // satisfiable from defaults, or the schedule would fail on every tick.\n const trig = m.trigger as { cron?: unknown } | undefined\n if (typeof trig?.cron === 'string') {\n for (const [key, raw] of Object.entries(inputs as Record<string, unknown>)) {\n const spec = raw as { required?: unknown; default?: unknown }\n if (spec?.required === true && spec.default === undefined) {\n errors.push(\n `input \"${key}\" is required with no default, and the trigger is a cron — `\n + 'cron has nobody to ask. Add a default or make the trigger manual.',\n )\n }\n }\n }\n // A run's input is capped at MAX_INPUT_BYTES when it starts. If the\n // declared defaults alone already exceed that, a cron fire (or a bare\n // Run-now) fails inside run-open before any run row exists — a silent\n // death only visible in runner logs. Catch it here, the one place the\n // author is still looking at the file.\n const defaultsOnly: Record<string, unknown> = {}\n for (const [key, raw] of Object.entries(inputs as Record<string, unknown>)) {\n const spec = raw as { default?: unknown }\n if (spec && typeof spec === 'object' && spec.default !== undefined) {\n defaultsOnly[key] = spec.default\n }\n }\n try {\n const bytes = new TextEncoder().encode(JSON.stringify(defaultsOnly)).length\n if (bytes > MAX_INPUT_BYTES) {\n errors.push(\n `input defaults alone serialize to ${bytes} bytes — over the ${MAX_INPUT_BYTES}-byte `\n + 'run-input cap, so every run would fail at start. Slim the defaults.',\n )\n }\n } catch {\n // A default that JSON.stringify chokes on (circular, throwing toJSON)\n // is practically unreachable — the manifest itself must serialize to\n // deploy at all — but the size check must never be the thing that throws.\n }\n }\n }\n\n if (input && typeof input === 'object' && !Array.isArray(input)) {\n for (const key of Object.keys(input)) {\n if (!KNOWN_KEYS.has(key)) {\n warnings.push(`unknown manifest key \"${key}\" — ignored`)\n }\n }\n }\n\n return { valid: errors.length === 0, errors, warnings }\n}\n",
|
|
31
|
-
"frontera/automation/index.ts": "export { automation } from './define'\nexport { validateManifest } from './manifest'\nexport type { ValidationResult } from './manifest'\nexport {\n duplicateStepMessage,\n duplicateSubmissionMessage,\n emptySubmissionKeyMessage,\n lostStepRowMessage,\n missingGrantMessage,\n submitOutsideStepMessage,\n} from './messages'\nexport { createTestContext } from './testing'\nexport type { TestCall, TestContext, TestContextOptions } from './testing'\nexport { MAX_INPUT_BYTES, sanitizeInputsSchema, validateInputValue } from './inputs'\nexport type { InputValidation } from './inputs'\nexport type * from './types'\n",
|
|
32
27
|
"frontera/automation/define.ts": "import type {\n AutomationDescriptor,\n AutomationHandler,\n AutomationManifest,\n AutomationTrigger,\n InputsSchema,\n} from './types'\n\nexport function automation(\n manifest: AutomationManifest,\n handler: AutomationHandler,\n): AutomationDescriptor {\n if (!manifest?.name) throw new Error('Automation name is required')\n if (typeof handler !== 'function') throw new Error('Automation handler must be a function')\n\n // Copied, not aliased. `{ ...manifest }` is shallow, so without this the\n // frozen manifest would still hold a live reference to the caller's trigger\n // object — and a later mutation of it would silently change the schedule the\n // runner registers.\n const trigger: AutomationTrigger = { ...manifest.trigger }\n\n let inputs: Readonly<InputsSchema> | undefined\n if (manifest.inputs) {\n try {\n inputs = Object.freeze(structuredClone(manifest.inputs))\n } catch {\n // `structuredClone` throws a raw `DataCloneError` DOMException on a\n // function/symbol/etc default, which names neither the automation nor\n // the field — useless in a deploy log. Rethrow with both.\n throw new Error(`Automation \"${manifest.name}\": input defaults must be JSON-serializable values`)\n }\n }\n\n return Object.freeze({\n manifest: Object.freeze({\n ...manifest,\n trigger: Object.freeze(trigger),\n grants: Object.freeze([...(manifest.grants ?? [])]),\n ...(inputs ? { inputs } : {}),\n concurrency: manifest.concurrency ?? 1,\n retries: manifest.retries ?? 0,\n }),\n handler,\n })\n}\n",
|
|
33
|
-
"theme.css": "/* GENERATED from packages/web/src/app/globals.css — do not edit.\n * Refresh with `frontera app add theme`.\n *\n * Gives an app the same Tailwind utilities and design tokens the platform\n * uses, so copied components look native rather than unstyled. Import this\n * once from your entry (`import './theme.css'`).\n */\n@import \"tailwindcss\";\n\n@custom-variant dark (&:is(.dark *));\n\n@theme inline {\n --color-background: hsl(var(--background));\n --color-foreground: hsl(var(--foreground));\n --color-dot: var(--dot-foreground);\n --color-card: var(--card);\n --color-card-foreground: var(--card-foreground);\n --color-popover: var(--popover);\n --color-popover-foreground: var(--popover-foreground);\n --color-primary: var(--primary);\n --color-primary-foreground: var(--primary-foreground);\n --color-secondary: var(--secondary);\n --color-secondary-foreground: var(--secondary-foreground);\n --color-muted: var(--muted);\n --color-muted-foreground: var(--muted-foreground);\n --color-accent: var(--accent);\n --color-accent-foreground: var(--accent-foreground);\n --color-destructive: var(--destructive);\n --color-destructive-foreground: var(--destructive-foreground);\n --color-success: var(--success);\n --color-success-foreground: var(--success-foreground);\n --color-warning: var(--warning);\n --color-warning-foreground: var(--warning-foreground);\n --color-info: var(--info);\n --color-info-foreground: var(--info-foreground);\n --color-border: var(--border);\n --color-border-secondary: var(--border-secondary);\n --color-input: var(--input);\n --color-ring: var(--ring);\n --color-sidebar: var(--sidebar);\n --color-sidebar-foreground: var(--sidebar-foreground);\n --color-sidebar-primary: var(--sidebar-primary);\n --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n --color-sidebar-accent: var(--sidebar-accent);\n --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n --color-sidebar-border: var(--sidebar-border);\n --color-sidebar-ring: var(--sidebar-ring);\n --color-chart-1: var(--chart-1);\n --color-chart-2: var(--chart-2);\n --color-chart-3: var(--chart-3);\n --color-chart-4: var(--chart-4);\n --color-chart-5: var(--chart-5);\n --color-chart-6: var(--chart-6);\n --color-chart-7: var(--chart-7);\n --color-chart-8: var(--chart-8);\n --color-chart-9: var(--chart-9);\n --color-chart-10: var(--chart-10);\n --color-chart-11: var(--chart-11);\n --color-chart-12: var(--chart-12);\n --radius-sm: calc(var(--radius) - 4px);\n --radius-md: calc(var(--radius) - 2px);\n --radius-lg: var(--radius);\n --radius-xl: calc(var(--radius) + 4px);\n --radius-2xl: calc(var(--radius) + 8px);\n\n /* Shadow elevation tokens — Attio-inspired multi-layer */\n /* --shadow-xs: var(--elevation-xs);\n --shadow-sm: var(--elevation-sm);\n --shadow-md: var(--elevation-md);\n --shadow-lg: var(--elevation-lg);\n --shadow-xl: var(--elevation-xl); */\n\n /* Surface elevation tokens */\n --color-surface-inset-deep: var(--surface-inset-deep);\n --color-surface-inset-deep-hover: var(--surface-inset-deep-hover);\n --color-surface-inset-deep-active: var(--surface-inset-deep-active);\n --color-surface-inset: var(--surface-inset);\n --color-surface-inset-hover: var(--surface-inset-hover);\n --color-surface-inset-active: var(--surface-inset-active);\n --color-surface: var(--surface);\n --color-surface-chat: var(--surface-chat);\n --color-surface-hover: var(--surface-hover);\n --color-surface-active: var(--surface-active);\n --color-surface-raised: var(--surface-raised);\n --color-surface-raised-hover: var(--surface-raised-hover);\n --color-surface-raised-active: var(--surface-raised-active);\n /* --color-surface-overlay: var(--surface-raised);\n --color-surface-overlay-hover: var(--surface-raised-hover);\n --color-surface-overlay-active: var(--surface-raised-active); */\n --color-surface-overlay: var(--surface-overlay);\n --color-surface-overlay-hover: var(--surface-overlay-hover);\n --color-surface-overlay-active: var(--surface-overlay-active);\n /* Quint-out. For transitions long enough (250ms+) that Tailwind's `ease-out`\n — cubic-bezier(0, 0, 0.2, 1) — reads as near-constant motion: this covers\n 58% of the distance in the first 15% of the time against ease-out's 37%,\n so the element commits immediately and then settles. Use it for a surface\n opening or expanding; keep `ease-out` for short state changes, where the\n difference isn't perceptible. */\n --ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1);\n\n /* Image lightbox chrome sits over arbitrary pixels while still matching the\n active light/dark theme. */\n --color-image-overlay-scrim: var(--image-overlay-scrim);\n --color-image-overlay-control: var(--image-overlay-control);\n --color-image-overlay-control-hover: var(--image-overlay-control-hover);\n --color-image-overlay-border: var(--image-overlay-border);\n --color-image-overlay-foreground: var(--image-overlay-foreground);\n --color-image-overlay-muted: var(--image-overlay-muted);\n --color-image-overlay-thumb: var(--image-overlay-thumb);\n --color-image-checker-a: var(--image-checker-a);\n --color-image-checker-b: var(--image-checker-b);\n}\n\n:root {\n --radius: 0.625rem;\n\n /* Widest a config-row chip (skill / pack / app / context) may grow before\n its label truncates. Roughly 28 characters at text-xs — long enough to\n read a name, short enough that one chip can't monopolise the row. */\n --chip-max-width: 13rem;\n\n --background: 0 0% 100%;\n\n /* React flow canvas tokens */\n --dot-foreground: #c9c9c9;\n\n /* ── Surface elevation tokens ──────────────────── */\n --surface-inset-deep: hsl(0 0% 93%);\n --surface-inset-deep-hover: hsl(0 0% 89%);\n --surface-inset-deep-active: hsl(0 0% 86%);\n --surface-inset: hsl(0 0% 96%);\n --surface-inset-hover: hsl(0 0% 91%);\n --surface-inset-active: hsl(0 0% 88%);\n --surface: hsl(0 0% 98%);\n /* Chat-view backdrop — slightly warmer off-white than --surface, light only. */\n --surface-chat: #f9f9f9;\n --surface-hover: hsl(0 0% 96%);\n --surface-active: hsl(0 0% 94%);\n --surface-raised: hsl(0 0% 100%);\n --surface-raised-hover: hsl(0 0% 96%);\n --surface-raised-active: hsl(0 0% 94%);\n --surface-overlay: hsl(0 0% 100%);\n --surface-overlay-hover: hsl(0 0% 95%);\n --surface-overlay-active: hsl(0 0% 91%);\n --image-overlay-scrim: hsl(0 0% 0% / 70%);\n --image-overlay-control: var(--surface-overlay);\n --image-overlay-control-hover: var(--surface-overlay-hover);\n --image-overlay-border: var(--border);\n --image-overlay-foreground: hsl(var(--foreground));\n --image-overlay-muted: var(--muted-foreground);\n --image-overlay-thumb: var(--surface-inset);\n /* Transparency checkerboard, following the theme (.dark overrides below).\n The scrim is 70% black, which composites over the page rather than\n replacing it, so the lightbox backdrop lands near 30% lightness in light\n theme and near black in dark — a light checker reads against one and a\n dark checker against the other.\n\n Known tradeoff, chosen deliberately: a theme-following check sits close in\n lightness to same-polarity artwork, so a white-ink transparent logo is\n weak on the light check (and a black-ink one on the dark check). A neutral\n mid-grey avoids that but reads as theme-agnostic. If the washout ever\n matters more than the theme character, pull both pairs toward mid —\n 62/82 and 34/54 keep both polarities legible.\n\n Light is the Photoshop/Figma convention (#ccc on #fff). It matters that\n the lighter square is pure white: the chat surface is #f9f9f9 (97.6%), so\n a checker whose mean sits far below that reads as a dark patch inset into\n the page rather than as transparency. Keep the two squares ~20 lightness\n points apart in light and ~17 in dark. */\n --image-checker-a: hsl(0 0% 80%);\n --image-checker-b: hsl(0 0% 100%);\n\n /* ── Shadow elevations: (light) ───── */\n --elevation-xs: 0px 0px 0px 1px rgba(42, 28, 0, 0.07);\n\n --elevation-sm:\n 0px 2px 4px 0px rgba(0, 0, 0, 0.04), 0px 0px 0px 1px rgba(42, 28, 0, 0.07);\n\n --elevation-md:\n 0px 8px 12px 0px rgba(25, 25, 25, 0.027),\n 0px 2px 6px 0px rgba(25, 25, 25, 0.027),\n 0px 0px 0px 1px rgba(42, 28, 0, 0.07);\n\n --elevation-lg:\n 0px 20px 24px 0px rgba(25, 25, 25, 0.05),\n 0px 5px 8px 0px rgba(25, 25, 25, 0.027),\n 0px 0px 0px 1px rgba(42, 28, 0, 0.07);\n\n --elevation-xl:\n 0px 24px 48px 0px rgba(25, 25, 25, 0.24),\n 0px 4px 12px 0px rgba(25, 25, 25, 0.14),\n 0px 0px 0px 1px rgba(42, 28, 0, 0.07);\n\n --foreground: 47 13% 14%;\n --card: var(--surface-raised);\n --card-foreground: 47 13% 14%;\n --popover: oklch(1 0 0);\n --popover-foreground: 47 13% 14%;\n --primary: oklch(0.21 0.006 285.885);\n --primary-foreground: oklch(0.985 0 0);\n --secondary: oklch(0.967 0.001 286.375);\n --secondary-foreground: oklch(0.21 0.006 285.885);\n --muted: oklch(0.967 0.001 286.375);\n --muted-foreground: oklch(0.5 0.016 285.938);\n --accent: oklch(0.967 0.001 286.375);\n --accent-foreground: 47 13% 14%;\n --destructive: oklch(0.577 0.245 27.325);\n --destructive-foreground: oklch(0.985 0 0);\n --success: oklch(0.627 0.194 149.214);\n --success-foreground: oklch(0.985 0 0);\n --warning: oklch(0.735 0.166 70.67);\n --warning-foreground: oklch(0.21 0.006 285.885);\n --info: oklch(0.6 0.15 240);\n --info-foreground: oklch(0.985 0 0);\n --border: hsl(240 100 6 / 0.05);\n --border-secondary: hsl(214 32% 96%);\n --input: oklch(0.92 0.004 286.32 / 0.75);\n --ring: oklch(0.705 0.015 286.067);\n --chart-1: #5b8dee;\n --chart-2: #3dab82;\n --chart-3: #e8883e;\n --chart-4: #9b7ef5;\n --chart-5: #e05c78;\n --chart-6: #3ab5cc;\n --chart-7: #a4b83a;\n --chart-8: #d47a4a;\n --chart-9: #748cd4;\n --chart-10: #4cad6a;\n /* Dashboard analytics series — extends the fixed chart palette. */\n --chart-11: #3b82f6;\n --chart-12: #10b981;\n --sidebar: oklch(0.985 0 0);\n --sidebar-foreground: oklch(0.141 0.005 285.823);\n --sidebar-primary: oklch(0.21 0.006 285.885);\n --sidebar-primary-foreground: oklch(0.985 0 0);\n --sidebar-accent: oklch(0.967 0.001 286.375);\n --sidebar-accent-foreground: oklch(0.21 0.006 285.885);\n --sidebar-border: oklch(0.92 0.004 286.32);\n --sidebar-ring: oklch(0.705 0.015 286.067);\n}\n\n.dark {\n --background: 0 0% 2%;\n\n /* React flow canvas tokens */\n --dot-foreground: #363636;\n\n /* ── Shadow elevations (dark) ───────────────────── */\n --elevation-xs: 0 0 0 1px #383836;\n\n --elevation-sm: 0 0 0 1px #383836, 0 2px 4px 0 rgba(0, 0, 0, 0.18);\n\n --elevation-md:\n 0 0 0 1px #383836, 0 8px 16px -4px rgba(0, 0, 0, 0.36),\n 0 2px 6px -2px rgba(0, 0, 0, 0.28);\n\n --elevation-lg:\n 0 0 0 1px #383836, 0 14px 28px -6px rgba(0, 0, 0, 0.44),\n 0 2px 4px -1px rgba(0, 0, 0, 0.28);\n\n --elevation-xl:\n 0 0 0 1px #383836, 0 24px 48px 0 rgba(0, 0, 0, 0.56),\n 0 4px 12px 0 rgba(0, 0, 0, 0.4);\n\n /* ── Surface elevation tokens ──────────────────── */\n --surface-inset-deep: hsl(0, 0%, 6%);\n --surface-inset-deep-hover: hsl(0 0% 8%);\n --surface-inset-deep-active: hsl(0 0% 11%);\n --surface-inset: hsl(0 0% 7.5%);\n --surface-inset-hover: hsl(0 0% 10%);\n --surface-inset-active: hsl(0 0% 12%);\n --surface: hsl(0 0% 9%);\n /* Dark mirrors --surface — chat backdrop is only retuned in light. */\n --surface-chat: hsl(0 0% 9%);\n --surface-hover: hsl(0 0% 14%);\n --surface-active: hsl(0 0% 17%);\n --surface-raised: hsl(0 0% 13%);\n --surface-raised-hover: hsl(0 0% 15%);\n --surface-raised-active: hsl(0 0% 17%);\n --surface-overlay: hsl(0 0% 9%);\n --surface-overlay-hover: hsl(0 0% 15%);\n --surface-overlay-active: hsl(0 0% 19%);\n --image-overlay-scrim: hsl(0 0% 0% / 70%);\n --image-overlay-control: hsl(0 0% 12% / 96%);\n --image-overlay-control-hover: hsl(0 0% 18% / 98%);\n --image-overlay-border: hsl(0 0% 100% / 12%);\n --image-overlay-foreground: hsl(0 0% 100%);\n --image-overlay-muted: hsl(0 0% 100% / 65%);\n --image-overlay-thumb: hsl(0 0% 0% / 35%);\n --image-checker-a: hsl(0 0% 22%);\n --image-checker-b: hsl(0 0% 39%);\n\n --foreground: 0 0% 83%;\n --card: var(--surface-raised);\n --card-foreground: hsl(0 0% 98%);\n --popover: hsl(0 0% 7%);\n --popover-foreground: hsl(0 0% 98%);\n --primary: hsl(0 0% 90%);\n --primary-foreground: hsl(0 0% 5%);\n --secondary: hsl(0 0% 12%);\n --secondary-foreground: hsl(0 0% 98%);\n --muted: hsl(0 0% 9%);\n --muted-foreground: hsl(0 0% 52%);\n --accent: hsl(0 0% 9%);\n --accent-foreground: hsl(0 0% 98%);\n --destructive: oklch(0.704 0.191 22.216);\n --destructive-foreground: oklch(0.985 0 0);\n --success: oklch(0.723 0.191 142.542);\n --success-foreground: oklch(0.985 0 0);\n --warning: oklch(0.815 0.152 78.2);\n --warning-foreground: oklch(0.21 0.006 285.885);\n --info: oklch(0.7 0.15 240);\n --info-foreground: oklch(0.985 0 0);\n --border: oklch(0.9296 0.007 106.53 / 0.08);\n --border-secondary: oklch(0.9296 0.007 106.53 / 0.03);\n --input: oklch(1 0 0 / 10%);\n --ring: oklch(0.552 0.016 285.938);\n --chart-1: #5b8dee;\n --chart-2: #3dab82;\n --chart-3: #e8883e;\n --chart-4: #9b7ef5;\n --chart-5: #e05c78;\n --chart-6: #3ab5cc;\n --chart-7: #a4b83a;\n --chart-8: #d47a4a;\n --chart-9: #748cd4;\n --chart-10: #4cad6a;\n --chart-11: #3b82f6;\n --chart-12: #10b981;\n --sidebar: oklch(0.21 0.006 285.885);\n --sidebar-foreground: oklch(0.985 0 0);\n --sidebar-primary: oklch(0.488 0.243 264.376);\n --sidebar-primary-foreground: oklch(0.985 0 0);\n --sidebar-accent: oklch(0.274 0.006 286.033);\n --sidebar-accent-foreground: oklch(0.985 0 0);\n --sidebar-border: oklch(1 0 0 / 10%);\n --sidebar-ring: oklch(0.552 0.016 285.938);\n}\n\n@layer base {\n * {\n border-color: var(--border);\n }\n body {\n background: var(--app-canvas, var(--surface));\n color: var(--foreground);\n font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif);\n -webkit-font-smoothing: antialiased;\n }\n}\n\n/* ── Make it the CUSTOMER'S app, not ours ────────────────────────────────\n *\n * Everything above is a DEFAULT, not a house style. A Frontera app should\n * look like the product it belongs to, so override any token below in your\n * own stylesheet, imported after this file:\n *\n * :root {\n * --primary: #0b5fff; [your brand]\n * --radius: 0.25rem; [your shape]\n * --font-sans: \"Inter\", sans-serif;\n * --app-canvas: #f7f8fa; [page background]\n * --app-gutter: 2rem; [page padding]\n * --app-radius: 0.25rem; [card and tile corners]\n * --app-section-gap: 2rem; [vertical rhythm]\n * }\n *\n * The components read tokens, never literals, so redefining these restyles\n * the whole app without forking a single component. The app-* tokens are the\n * layout knobs the app-kit exposes; the rest are the shared semantic set.\n */\n"
|
|
28
|
+
"frontera/automation/types.ts": "// `import type`, so this is erased at compile time and adds no runtime import —\n// but `@frontera-sdk/blueprint` is still a real `dependencies` entry, because\n// `WhereNode` is part of this package's PUBLIC type surface: anyone consuming\n// `AutomationContext` needs it to resolve. See the packaging note in\n// docs/superpowers/specs/2026-07-29-automations-ctx-blueprint-query-design.md —\n// a subset install that cannot resolve it aborts `bun install` outright.\nimport type { WhereNode } from '@frontera-sdk/blueprint/types'\n\n/**\n * Cron, manual, and agent for now; event and webhook land with Event Triggers.\n *\n * The `?: never` members are load-bearing. Without them `{ cron, manual }`\n * typechecks — TypeScript's excess-property check admits any key present on\n * *some* member of a union — and the runner would have to decide at runtime\n * what a both-shaped trigger means.\n *\n * `agent` is deliberately NOT part of that exclusion. Cron and manual answer\n * \"what fires this on its own\"; `agent: true` answers \"may a bound agent call\n * this\", which is an orthogonal question — a nightly reconciliation that an\n * analyst can also ask an agent to run on demand is one automation, not two.\n * So `agent` rides alongside either, and the third arm exists for the\n * agent-only automation, which has no self-starting trigger at all.\n *\n * Declaring it is only the AUTHOR's half of the permission. A workspace\n * operator must still bind the automation to one named agent before any tool\n * is projected; see the design in\n * docs/superpowers/specs/2026-08-27-agent-callable-automations-design.md.\n */\nexport type AutomationTrigger =\n | { cron: string; manual?: never; agent?: true }\n | { cron?: never; manual: true; agent?: true }\n | { cron?: never; manual?: never; agent: true }\n\n/**\n * An author-time affordance, not a validation gate.\n *\n * `blueprint:read` is a literal, so the compiler completes it and offers \"Did\n * you mean 'blueprint:read'?\" on a typo. `agent:${string}:run` admits any slug\n * — including one that names no agent — so the union cannot be read as proof\n * that a grant is well-formed. The runtime gate is `validateManifest` in\n * `manifest.ts`; this exists to guide the author as they type.\n *\n * Widening it later (adding `notify:*`, `governed:*` with Governed Writes) is a\n * non-breaking change. Narrowing `string` to a union later would break every\n * automation already written, so it starts narrow.\n */\nexport type Grant =\n | 'blueprint:read'\n | `agent:${string}:run`\n /**\n * One capability of one Plugin install: `plugin:<install>:<capability>`.\n *\n * `<install>` is the install's name as `frontera plugin list` shows it\n * (lowercase, no spaces); `<capability>` is the capability's name on that\n * install. One grant per capability — there is no wildcard, for the same\n * reason `http:` has none: the manifest is the reviewable list of what the\n * automation can reach.\n */\n | `plugin:${string}:${string}`\n /** One EXACT host, no wildcards. `http:api.stripe.com` matches that host and\n * nothing else — a wildcard would ask a reviewer to reason about\n * subdomain-takeover risk, and the answer is usually wrong. */\n | `http:${string}`\n /** The NAME of a workspace secret. Its VALUE never enters this process: you\n * name it, the platform injects it server-side. */\n | `secret:${string}`\n /** One EXACT published Action apiName. `governed:approveInvoice` permits\n * submitting that Action and nothing else.\n *\n * Not wildcardable, for the same reason `http:` is not: a reviewer reading\n * `governed:*` would have to know the whole current Action catalog — and the\n * answer changes with every release — to know what the automation may do. */\n | `governed:${string}`\n\n/** One declared run input. A deliberate subset of JSON Schema — the same\n * philosophy as the grant grammar: small enough that a wrong shape is\n * refusable with a sentence, wide enough for real parameters. */\nexport interface InputFieldSpec {\n type: 'string' | 'number' | 'boolean' | 'object' | 'array'\n /** Refused at run start when absent. Mutually exclusive with `default`. */\n required?: boolean\n /** Applied at run start when the field is absent. Cron runs rely on these. */\n default?: unknown\n description?: string\n /** Allowed values — string and number types only. */\n enum?: readonly (string | number)[]\n}\n\nexport type InputsSchema = Record<string, InputFieldSpec>\n\nexport interface AutomationManifest {\n name: string\n trigger: AutomationTrigger\n grants?: readonly Grant[]\n /**\n * Declared run inputs, validated and defaulted at run start. Absent means\n * this automation takes no input — starting a run WITH input for such a\n * version is refused. See `InputFieldSpec`.\n */\n inputs?: InputsSchema\n concurrency?: number\n /**\n * Times the platform may retry a run that FAILED. Default 0, and the opt-in\n * is the contract.\n *\n * Setting this asserts your handler is safe to run twice. With `ctx.http` that\n * is a real claim rather than a formality — a retried run that charged a card\n * charges it again, and the platform cannot check idempotency on your behalf.\n * Per-automation, not global, because you are the only one who knows.\n *\n * Retries do NOT extend the ctx call budget: each attempt is a separate run\n * with its own meter.\n *\n * With steps, this is a bound on RUN attempts, and a step that fails is what\n * consumes one. Completed steps are not re-executed on the next attempt —\n * they return their stored results — so a retry resumes from the failure\n * rather than starting the work again. That is the point of putting a call\n * that costs something inside a step: `retries: 2` on a handler whose work is\n * all in steps re-runs only the step that failed, while the same setting on a\n * handler with no steps re-runs everything.\n */\n retries?: number\n description?: string\n}\n\n/**\n * What `automation()` guarantees once defaults are applied — nothing optional\n * left for a consumer to re-handle. Downstream code takes this, not\n * `AutomationManifest`, so it never re-derives a fact already established.\n */\nexport interface ResolvedAutomationManifest extends AutomationManifest {\n // Every member is readonly, not just the two with defaults. `Object.freeze`\n // in `define.ts` freezes the whole object at runtime, so leaving `name` or\n // `description` mutable in the type means `d.manifest.name = 'x'` compiles\n // and then throws — the same compile-clean/throw-at-runtime gap that the\n // removed `as string[]` cast used to create.\n readonly name: string\n readonly trigger: Readonly<AutomationTrigger>\n readonly grants: readonly Grant[]\n readonly inputs?: Readonly<InputsSchema>\n readonly concurrency: number\n readonly retries: number\n readonly description?: string\n}\n\nexport interface AgentHandle {\n run(prompt: string): Promise<{ text: string }>\n}\n\n/** What `ctx.plugin(install).call(...)` resolves to. */\nexport interface PluginCallResult<T = unknown> {\n /** Whatever the capability returned. Shape is the plugin's, not the platform's. */\n data: T\n}\n\nexport interface PluginHandle {\n /**\n * Invoke one capability of this install.\n *\n * Governed by the install's policy exactly as an agent's tool call is —\n * a disabled install, a `read_only` Action policy, a parameter constraint\n * or a missing workspace account all refuse here with the reason named.\n * A capability that requires approval cannot be called from an automation\n * at all (nobody to ask), and `deploy` refuses the grant up front.\n *\n * A failure reported by the plugin itself is thrown, carrying the plugin's\n * message. A success resolves to `{ data }` — there is no `ok` flag to\n * branch on, only the value.\n *\n * Dry in a dev run: returns `{ data: null }` and sends nothing.\n */\n call<T = unknown>(\n capability: string,\n input?: Record<string, unknown>,\n ): Promise<PluginCallResult<T>>\n}\n\n/**\n * Durable steps.\n *\n * A step is the unit the platform can memoize, retry and draw. Work inside one\n * runs at most once per run; work outside one runs again every time the\n * platform resumes the handler, which it does after every step completes.\n *\n * That resumption is the whole model and it is what the three rules below are\n * about — none of them is a style preference.\n */\nexport interface StepApi {\n /**\n * Run `fn` as a durable step and return its result.\n *\n * Three rules, all enforced or observable rather than advisory:\n *\n * 1. **`name` must be unique within a run.** The platform memoizes by it, so a\n * repeated name would silently hand back the FIRST call's result. Inside a\n * loop, put the index in the name — `` `submit:${i}` ``. A repeat fails the\n * run naming the collision rather than returning the wrong value.\n * 2. **The result must be JSON-serializable.** It is stored and replayed, so a\n * `Date` comes back as a string and a class instance comes back as a plain\n * object. Return data, not objects with behaviour.\n * 3. **Code outside a step re-executes.** After each step the handler restarts\n * from the top with completed steps returning their stored results. A\n * `ctx.http` call sitting outside a step therefore fires once per step, and\n * spends its call budget every time. The Console flags such calls on a run\n * that used steps.\n */\n run<T>(name: string, fn: () => Promise<T>): Promise<T>\n\n /**\n * Park the run for `ms` milliseconds, durably, under a unique name.\n *\n * On the platform this is a real checkpoint: the run stops occupying a\n * worker and resumes after the delay — pace provider polls with it (a\n * measured 429 arrived after ~7 back-to-back polls). In `createTestContext`\n * and in dev runs it records and returns immediately, so tests and dry runs\n * never actually wait. Shares the name-uniqueness rule with `run`: the\n * platform memoizes both by name.\n */\n sleep(name: string, ms: number): Promise<void>\n}\n\n/**\n * The 13 lifecycle states a governed Action Request can hold.\n *\n * A deliberate copy of a WIRE contract, not shared code — same reasoning as\n * `RegistryEntry` in the runner: this package must install from public npm with\n * a three-package dependency list, and importing the service's own enum would\n * drag drizzle and the schema into an author's `bun install`. The service's\n * `ACTION_REQUEST_LIFECYCLE_STATES` is the source of truth; the response proves\n * the two agree.\n */\nexport type ActionRequestLifecycle =\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\n/**\n * `subjectRef` and `expectedSubjectVersion` are paired deliberately.\n *\n * The plane requires BOTH for an Action over an existing subject and refuses\n * BOTH for a create Action, so independently-optional fields would let an\n * author write a submission that cannot be accepted and only find out at\n * runtime. Which arm applies is the Action's decision, not the caller's — read\n * it off the Action's `subject.mode`.\n */\nexport type ActionSubmission = {\n /** Published Action apiName. Requires a `governed:<apiName>` grant. */\n action: string\n input: Record<string, unknown>\n /** Required when the Action's definition says so. */\n reason?: string\n /**\n * Tells two submissions from the SAME step apart.\n *\n * A step submits once by default. The idempotency key is derived from the run\n * and the step alone, so a submission re-reached by a resumption or by a\n * retried attempt is the SAME key and the plane hands back the original\n * request instead of making a second one. Your own retry loop behaves the\n * same way: a submission that FAILED is not recorded, so submitting again\n * after catching a transport error re-sends and the plane replays.\n *\n * What you cannot do by default is submit twice on purpose. Two submissions\n * the platform cannot tell apart derive one key AND one semantic\n * fingerprint, so the plane would replay the first and answer both calls with\n * the same id — no error, one effect, a green run. Rather than let that\n * happen, the second call is refused before it leaves your process, naming\n * this field.\n *\n * Pass a distinct `submissionKey` per submission to say you meant it — a\n * business identity is the right value, not a counter:\n *\n * ```ts\n * await ctx.step.run('flag', async () => {\n * for (const row of rows) {\n * await ctx.action.submit({\n * action: 'flagForAudit',\n * // Stable for THIS row across every attempt. An array index is not:\n * // if the re-read returns the rows in another order, an index would\n * // bind row B's submission to row A's key.\n * submissionKey: row.id,\n * input: { rowId: row.id },\n * })\n * }\n * })\n * ```\n *\n * It must be stable across attempts for the same intended submission, which\n * is why the platform cannot derive it for you — only your code knows which\n * of two submissions is \"the same one again\". An empty string is refused;\n * omit it entirely to mean \"this step submits once\".\n */\n submissionKey?: string\n} & (\n | {\n subjectRef: { objectTypeId: string; objectId: string }\n /** The version you believe the subject is at: a submission built from a\n * stale read must lose rather than overwrite. */\n expectedSubjectVersion: string\n }\n | { subjectRef?: never; expectedSubjectVersion?: never }\n)\n\nexport interface ActionSubmitResult {\n requestId: string\n /**\n * Where the request stopped, NOT whether the effect happened.\n *\n * `ready` means accepted and queued for dispatch. `awaiting_approval` means\n * the Action requires a human and one has not decided yet — a normal return,\n * not an error. Neither is a completed business fact.\n */\n lifecycle: ActionRequestLifecycle\n}\n\n/**\n * `notify` still arrives with a later slice; `governed` is here.\n */\nexport interface AutomationContext {\n runId: string\n workspaceId: string\n /**\n * The values this run was started with — validated against the manifest's\n * `inputs` schema and fixed on the run row at start, so every resumption\n * and retried attempt sees the same object. `{}` when the manifest declares\n * no inputs. Visible in the run trace by design: never put a secret here —\n * `secret:` grants are the credential path.\n */\n input: Record<string, unknown>\n /** Never rejects — telemetry must not be able to fail a run. */\n log(message: string, data?: Record<string, unknown>): Promise<void>\n agent(slug: string): AgentHandle\n /** One Plugin install, by the name `frontera plugin list` shows. Needs `plugin:<install>:<capability>` per call. */\n plugin(install: string): PluginHandle\n http: {\n /**\n * Call an allowlisted host, optionally with a workspace secret injected\n * server-side.\n *\n * Requires an `http:<host>` grant, and an `secret:<name>` grant when `auth`\n * is used. The secret's VALUE never enters this process — that is deliberate:\n * a credential this process never held cannot be leaked by a stray\n * `ctx.log`, an exception serialiser, or a dependency, and step details are\n * rendered verbatim in the Console.\n *\n * An upstream 4xx/5xx comes back as `status`, not as a throw. An API\n * answering 404 is data; only failures of the mechanism reject.\n */\n fetch(req: HttpRequest): Promise<HttpResponse>\n }\n blueprint: {\n query<T = Record<string, unknown>>(\n objectType: string,\n options?: BlueprintQueryOptions,\n ): Promise<BlueprintQueryResult<T>>\n }\n /**\n * Durable steps. See `StepApi`.\n *\n * Present on every automation — a handler that uses no steps behaves exactly\n * as it did before this existed, because a run with no steps is never\n * resumed.\n */\n step: StepApi\n action: {\n /**\n * Ask the governed write plane to perform one named business change.\n *\n * This is the ONLY way an automation changes a system of record. Your code\n * never holds a write handle: you describe the change, and the plane\n * authorizes it, approves it if the Action says so, dispatches it, confirms\n * it and records it. An Action that declares `approval: required` cannot be\n * talked out of it by the caller.\n *\n * Two rules:\n *\n * 1. **It must be called inside `ctx.step.run`.** Code outside a step\n * re-executes after every step boundary, so a submit sitting there would\n * fire once per boundary. Inside a step it runs once, and the step —\n * identified by the row the service itself issued — is what makes the\n * idempotency key stable across resumption and across a retried run.\n *\n * The service checks this rather than taking your word for it: the\n * submission carries a step row id, and a submission whose id names no\n * open step of this run is refused. What that check cannot do is make a\n * determined bundle behave — your code runs unsandboxed in the same\n * process as the run token, so it could open a step row purely to submit\n * inside it. The bound is that such a step is a real row and shows up in\n * the run trace, not that it is impossible.\n * 2. **It never waits.** It returns as soon as the request is durably\n * accepted. A run has nobody to ask for an approval and ten minutes to\n * live, so blocking on a human is not something this can offer —\n * `awaiting_approval` is a normal return value.\n *\n * The returned `lifecycle` is where the request stopped, not proof of\n * effect. Poll the ledger, or let the Action's Business Event tell you.\n */\n submit(request: ActionSubmission): Promise<ActionSubmitResult>\n }\n}\n\nexport interface HttpRequest {\n url: string\n method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'\n headers?: Record<string, string>\n /** String only — no streaming, no binary. */\n body?: string\n /** Inject a workspace secret into one header. Needs a `secret:<name>` grant. */\n auth?: { header: string; secret: string; prefix?: string }\n}\n\nexport interface HttpResponse {\n status: number\n headers: Record<string, string>\n /** Capped at 1 MB. Exceeding the cap is an error, never a truncation — a\n * silently shortened response is a wrong answer that looks right. */\n body: string\n}\n\nexport interface BlueprintQueryOptions {\n /** The real Blueprint filter DSL, not a shorthand — `and`/`or`/`not`, ranges\n * and date presets all work. A convenience subset with no escape hatch is the\n * thing the first \"overdue OR flagged\" automation would have to work around. */\n where?: WhereNode\n select?: string[]\n /** `dir`, not `direction` — matches `QueryRequest` exactly. */\n orderBy?: Array<{ property: string; dir: 'asc' | 'desc' }>\n /** Default 100, clamped to 1000. Exceeding the cap sets `hasMore`; it never\n * truncates silently. */\n limit?: number\n /** Opaque. Pass back the previous result's `nextPageToken`; absent means the\n * first page. Cursor-based, so a scan stays correct while the table moves\n * underneath it — which a cron-driven automation's table always does. */\n pageToken?: string\n}\n\nexport interface BlueprintQueryResult<T = Record<string, unknown>> {\n rows: T[]\n /** True when the query matched more rows than were returned. */\n hasMore: boolean\n /** Present iff `hasMore`. Feed to the next call's `pageToken`.\n *\n * Forwarded rather than narrowed away on purpose: `hasMore` on its own is a\n * fact the author can do nothing about, which is how a digest over the first\n * 500 of 5,000 rows reports success. */\n nextPageToken?: string\n}\n\nexport type AutomationHandler = (ctx: AutomationContext) => Promise<unknown>\n\nexport interface AutomationDescriptor {\n readonly manifest: ResolvedAutomationManifest\n readonly handler: AutomationHandler\n}\n",
|
|
29
|
+
"frontera/automation/index.ts": "export { automation } from './define'\nexport { validateManifest } from './manifest'\nexport type { ValidationResult } from './manifest'\nexport {\n duplicateStepMessage,\n duplicateSubmissionMessage,\n emptySubmissionKeyMessage,\n lostStepRowMessage,\n missingGrantMessage,\n submitOutsideStepMessage,\n} from './messages'\nexport { createTestContext } from './testing'\nexport type { TestCall, TestContext, TestContextOptions } from './testing'\nexport { MAX_INPUT_BYTES, sanitizeInputsSchema, validateInputValue } from './inputs'\nexport type { InputValidation } from './inputs'\nexport type * from './types'\n",
|
|
30
|
+
"frontera/automation/testing.ts": "import { AsyncLocalStorage } from 'node:async_hooks'\nimport {\n duplicateStepMessage,\n duplicateSubmissionMessage,\n emptySubmissionKeyMessage,\n missingGrantMessage,\n submitOutsideStepMessage,\n} from './messages'\nimport type {\n ActionSubmission,\n ActionSubmitResult,\n AutomationContext,\n BlueprintQueryOptions,\n BlueprintQueryResult,\n Grant,\n HttpRequest,\n HttpResponse,\n PluginCallResult,\n} from './types'\n\n/**\n * A `ctx` you can hand your handler in a unit test.\n *\n * Until this existed the only way to find out whether an automation worked was\n * to deploy it and run it — a loop measured in tens of seconds, against real\n * data, for a question as small as \"does the empty branch return the right\n * shape\".\n *\n * It enforces what the platform enforces, in the platform's own words: a\n * missing grant and a repeated step name fail here exactly as they fail in\n * production, so a green test means something.\n *\n * What it does NOT simulate is resumption. In production a handler is re-entered\n * after every step, so code outside a step runs many times; here the handler is\n * called once, straight through. Steps still memoize by name within the run, and\n * everything the handler did is recorded on `calls`.\n */\n\n/**\n * A call REFUSED before it happened is not recorded.\n *\n * A missing grant, a missing stub, a submit outside a step, a duplicate\n * submission — none of these appear in `calls`, because none of them did\n * anything. A real run differs here in one direction worth knowing: it writes\n * an errored ctx-call row for a refused `ctx.action.submit`, so the Console\n * trace shows the attempt where this list does not. Assert on the thrown error\n * for a refusal, and on `calls` for what ran.\n */\nexport interface TestCall {\n kind: 'step' | 'log' | 'agent' | 'plugin' | 'http' | 'blueprint' | 'action'\n /** Step name, log message, agent slug, `install:capability`, URL, object type, or Action apiName. */\n label: string\n /** Present on a step: how it ended. */\n status?: 'ok' | 'error'\n}\n\n\nexport interface TestContextOptions {\n runId?: string\n workspaceId?: string\n /** What the run was started with. Passed through verbatim — a unit test\n * states exactly what the handler sees; defaults are `startRun`'s job. */\n input?: Record<string, unknown>\n /**\n * The grants the manifest declares.\n *\n * Given, they are enforced — which is the point: a missing grant is one of\n * the few automation bugs that only shows up in a deployed run, and it is\n * exactly the kind a unit test should catch.\n *\n * Omitted, nothing is refused, so an existing test does not have to enumerate\n * grants to keep passing.\n */\n grants?: readonly Grant[]\n /** Per-slug agent answers. An unstubbed agent throws rather than answering. */\n agents?: Record<string, (prompt: string) => Promise<{ text: string }> | { text: string }>\n /**\n * Per-install, per-capability plugin answers: `{ crm: { create_ticket: (input) => ({ data }) } }`.\n * An unstubbed capability throws rather than answering — a fabricated\n * `{ data: {} }` is a test that passes while asserting nothing.\n */\n plugins?: Record<\n string,\n Record<string, (input: Record<string, unknown>) => Promise<PluginCallResult> | PluginCallResult>\n >\n /** Answers outbound requests. Unstubbed, `ctx.http.fetch` throws. */\n http?: (req: HttpRequest) => Promise<HttpResponse> | HttpResponse\n /** Rows per object type. An unstubbed type returns no rows, which is a real\n * answer and usually the branch worth testing. */\n blueprint?: Record<string, BlueprintQueryResult<never> | BlueprintQueryResult<Record<string, unknown>>>\n /**\n * Per-apiName Action outcomes. An unstubbed Action throws rather than\n * answering.\n *\n * Throws for the same reason the agent stub does, and the reason is sharper\n * here: the returned `lifecycle` is a branch an author writes code against —\n * `awaiting_approval` means a human still has to decide — so inventing\n * `ready` would silently pick one arm and pass.\n */\n actions?: Record<\n string,\n (request: ActionSubmission) => Promise<ActionSubmitResult> | ActionSubmitResult\n >\n}\n\nexport interface TestContext {\n ctx: AutomationContext\n /** Everything the handler did, in order. */\n calls: TestCall[]\n /** Step names, in the order they ran. */\n steps: string[]\n logs: Array<{ message: string; data?: Record<string, unknown> }>\n}\n\nexport function createTestContext(options: TestContextOptions = {}): TestContext {\n const calls: TestCall[] = []\n const steps: string[] = []\n const logs: TestContext['logs'] = []\n const seenNames = new Set<string>()\n /**\n * Which step the running code is inside.\n *\n * `AsyncLocalStorage`, matching the real context exactly, and NOT a stack.\n * A stack gets the concurrent case wrong in the direction that matters:\n * `Promise.all([ctx.step.run('a', …), ctx.action.submit(…)])` is legal, and\n * with a shared mutable stack the bare submit sees `a` open and is allowed —\n * so the test double passes what production refuses, which is the one failure\n * mode a test double must not have.\n *\n * Enforced here for the same reason grants and duplicate names are: a rule\n * the unit test does not apply is a rule the author meets for the first time\n * in a deployed run.\n */\n const stepScope = new AsyncLocalStorage<{ stepName: string; submitted: Set<string> }>()\n\n const requireGrant = (grant: string): void => {\n // No grant list means the test is not about grants. Enforcing an empty list\n // would fail every existing test for a reason its author never chose.\n if (!options.grants) return\n if (!options.grants.includes(grant as Grant)) throw new Error(missingGrantMessage(grant))\n }\n\n const ctx: AutomationContext = {\n runId: options.runId ?? 'test-run',\n workspaceId: options.workspaceId ?? 'test-workspace',\n input: options.input ?? {},\n\n step: {\n async run<T>(name: string, fn: () => Promise<T>): Promise<T> {\n if (seenNames.has(name)) throw new Error(duplicateStepMessage(name))\n seenNames.add(name)\n steps.push(name)\n return await stepScope.run({ stepName: name, submitted: new Set<string>() }, async () => {\n try {\n const out = await fn()\n calls.push({ kind: 'step', label: name, status: 'ok' })\n return out\n } catch (err) {\n calls.push({ kind: 'step', label: name, status: 'error' })\n throw err\n }\n })\n },\n\n async sleep(name: string): Promise<void> {\n if (seenNames.has(name)) throw new Error(duplicateStepMessage(name))\n seenNames.add(name)\n steps.push(name)\n // Recorded, never waited: a test suite that really slept out its\n // backoffs would take minutes to say nothing.\n calls.push({ kind: 'step', label: name, status: 'ok' })\n },\n },\n\n async log(message, data) {\n logs.push({ message, ...(data ? { data } : {}) })\n calls.push({ kind: 'log', label: message })\n },\n\n agent(slug: string) {\n return {\n async run(prompt: string) {\n requireGrant(`agent:${slug}:run`)\n calls.push({ kind: 'agent', label: slug })\n const stub = options.agents?.[slug]\n // Throwing beats answering with an empty string: a test whose agent\n // silently returns '' passes while asserting nothing about the step\n // that matters most.\n if (!stub) {\n throw new Error(\n `No agent stub for \"${slug}\". Pass agents: { '${slug}': () => ({ text: '…' }) } ` +\n 'to createTestContext.',\n )\n }\n return await stub(prompt)\n },\n }\n },\n\n plugin(install: string) {\n return {\n async call<T = unknown>(capability: string, input?: Record<string, unknown>) {\n requireGrant(`plugin:${install}:${capability}`)\n const stub = options.plugins?.[install]?.[capability]\n // Before the record, matching the contract on `TestCall` and the\n // `action` arm. (`agent` and `http` record first — a pre-existing\n // divergence.)\n if (!stub) {\n throw new Error(\n `No plugin stub for \"${install}\".${capability}. Pass ` +\n // Quoted, unlike a bare identifier: an install name defaults to\n // the catalog kind (kebab, e.g. \"github-prod\") and a capability\n // can be dotted (\"run.query\") — neither survives as an object\n // key without quotes, so the unquoted form the author would\n // paste back in does not parse.\n `plugins: { '${install}': { '${capability}': () => ({ data: … }) } } to createTestContext.`,\n )\n }\n calls.push({ kind: 'plugin', label: `${install}:${capability}` })\n return (await stub(input ?? {})) as PluginCallResult<T>\n },\n }\n },\n\n http: {\n async fetch(req: HttpRequest) {\n let host: string\n try {\n host = new URL(req.url).hostname.toLowerCase()\n } catch {\n throw new Error(`ctx.http: invalid URL ${req.url}`)\n }\n requireGrant(`http:${host}`)\n calls.push({ kind: 'http', label: req.url })\n // Same reasoning as the agent: a fabricated 200 is a false pass.\n if (!options.http) {\n throw new Error(\n `No http stub. Pass http: (req) => ({ status: 200, headers: {}, body: '' }) ` +\n 'to createTestContext.',\n )\n }\n return await options.http(req)\n },\n },\n\n action: {\n async submit(request: ActionSubmission): Promise<ActionSubmitResult> {\n const scope = stepScope.getStore()\n if (!scope) throw new Error(submitOutsideStepMessage(request.action))\n // The batch loop is the shape this catches, and a one-row fixture never\n // reaches it — so the double has to enforce it or an author meets it\n // for the first time on their second production row, after the first\n // has already been applied.\n if (request.submissionKey !== undefined && request.submissionKey.length === 0) {\n throw new Error(emptySubmissionKeyMessage(request.action))\n }\n requireGrant(`governed:${request.action}`)\n const stub = options.actions?.[request.action]\n // Both refusals that mean \"this never happened\" come BEFORE the\n // reservation, matching the runtime's grant check: reserving first left\n // an author who fixed the missing stub and re-ran a loop facing a\n // duplicate accusation for a call that never answered.\n if (!stub) {\n throw new Error(\n `No action stub for \"${request.action}\". Pass actions: { '${request.action}': ` +\n \"() => ({ requestId: 'req-1', lifecycle: 'ready' }) } to createTestContext.\",\n )\n }\n const submissionIdentity = `${request.action}\\u0000${request.submissionKey ?? ''}`\n // Reserved synchronously and released on failure, matching the runtime\n // exactly. A double that checked and recorded across an await would let\n // `Promise.all([submit(x), submit(x)])` through — and a double that\n // permits what production refuses is the one failure mode a double must\n // not have.\n if (scope.submitted.has(submissionIdentity)) {\n throw new Error(duplicateSubmissionMessage(request.action))\n }\n scope.submitted.add(submissionIdentity)\n calls.push({ kind: 'action', label: request.action })\n try {\n return await stub(request)\n } catch (err) {\n // A throwing stub stands in for a submission that never landed.\n scope.submitted.delete(submissionIdentity)\n throw err\n }\n },\n },\n\n blueprint: {\n async query<T = Record<string, unknown>>(\n objectType: string,\n _options?: BlueprintQueryOptions,\n ): Promise<BlueprintQueryResult<T>> {\n requireGrant('blueprint:read')\n calls.push({ kind: 'blueprint', label: objectType })\n const stub = options.blueprint?.[objectType]\n // Empty is a real answer, and the branch an author most often forgets\n // to test — so this one defaults rather than throwing.\n return (stub ?? { rows: [], hasMore: false }) as BlueprintQueryResult<T>\n },\n },\n }\n\n return { ctx, calls, steps, logs }\n}\n",
|
|
31
|
+
"frontera/automation/runtime-context.ts": "/**\n * The REAL `ctx` a handler receives — the one that talks to the service.\n *\n * It lives in the SDK rather than in the runner because it now has two\n * consumers: the deployed runner executing a bundle, and the CLI's dev worker\n * executing a file on a developer's machine. One implementation means a dev run\n * and a production run cannot drift in what they enforce or how they word a\n * refusal, which is the whole reason a dev loop is worth trusting.\n *\n * NOT re-exported from `index.ts`, and NOT in `AUTOMATION_SDK_FILES`: a\n * scaffolded project vendors the authoring surface, and this file reaches the\n * network. Authors get `createTestContext`; the two runtimes get this.\n */\nimport { AsyncLocalStorage } from 'node:async_hooks'\n// Wording lives in the SDK, not here: the runner refusing a call before it makes\n// it, the service's own 403, and `createTestContext` on the author's machine all\n// have to say the same sentence — a test that fails in different words than\n// production teaches the wrong lesson. Re-exported below because this module is\n// where the runner's code and tests have always reached for them.\nimport {\n duplicateStepMessage as duplicateStepMessageText,\n missingGrantMessage as missingGrantMessageText,\n submitOutsideStepMessage as submitOutsideStepMessageText,\n lostStepRowMessage as lostStepRowMessageText,\n duplicateSubmissionMessage as duplicateSubmissionMessageText,\n emptySubmissionKeyMessage as emptySubmissionKeyMessageText,\n} from './messages'\nimport type {\n ActionSubmission,\n ActionSubmitResult,\n AutomationContext,\n BlueprintQueryOptions,\n BlueprintQueryResult,\n HttpRequest,\n HttpResponse,\n PluginCallResult,\n} from './types'\n\nconst SERVICE_URL = process.env.SERVICE_URL ?? 'http://localhost:4000'\n\n/**\n * The step tools this module needs, declared structurally rather than imported\n * from `inngest`.\n *\n * Structural because it keeps the whole file testable with a two-line stub, and\n * because it states exactly what `ctx` depends on — one method — instead of the\n * platform's entire step surface. `function-builder.ts` passes the real object\n * straight in, so the compiler still checks the two agree.\n */\nexport interface StepTools {\n /**\n * Returns `unknown`, deliberately, and not the body's own type.\n *\n * What comes back is not the value the body returned but its JSON round trip:\n * the platform stores a step's result and replays it on the next execution, so\n * a `Date` returns as a string and a class instance as a plain object. Typing\n * this as `Promise<T>` here would erase that at exactly the boundary where it\n * happens. `ctx.step.run` narrows it once, at the seam, with the same\n * reasoning `ctx.blueprint.query` narrows a warehouse row.\n */\n run<T>(id: string, fn: () => Promise<T>): Promise<unknown>\n /** Absent on hosts that predate it — the runtime falls back to an inline wait. */\n sleep?(id: string, ms: number): Promise<void>\n}\n\ninterface Deps {\n runId: string\n workspaceId: string\n runToken: string\n grants: string[]\n /** The platform's step tools for THIS execution. */\n step: StepTools\n /** The run's frozen input row, or absent when the manifest declares none. */\n input?: Record<string, unknown>\n /** Zero-indexed run attempt, stamped onto every row this context writes. */\n attempt?: number\n /**\n * Where the service lives, when the caller knows better than the environment.\n *\n * The deployed runner reads `SERVICE_URL` from its own env; the CLI's dev\n * worker knows it from the origin the developer logged into, and a\n * module-level const read at import time cannot be told. Overriding here keeps\n * this module usable in both processes rather than forked for one.\n */\n serviceUrl?: string\n /**\n * The deployment-wide runner secret, or absent.\n *\n * PASSED IN, never read from the environment here. This module now runs in two\n * processes, and only one of them may hold this token: the runner does, a\n * developer's laptop must not. Reading `process.env` inside shared code moves\n * that decision into an environment nobody reviews — a developer who has the\n * variable exported for any reason, a copied env file, a locally-run runner,\n * would have `automation dev` sending a workspace-wide credential from their\n * machine with nothing on screen to say so.\n *\n * As a parameter the rule is structural: the dev worker cannot send it,\n * because it has nothing to pass.\n */\n runnerToken?: string\n}\n\nexport {\n duplicateStepMessage,\n duplicateSubmissionMessage,\n emptySubmissionKeyMessage,\n lostStepRowMessage,\n missingGrantMessage,\n submitOutsideStepMessage,\n} from './messages'\n\nexport class DuplicateStepNameError extends Error {\n constructor(readonly stepName: string) {\n super(duplicateStepMessageText(stepName))\n this.name = 'DuplicateStepNameError'\n }\n}\n\nclass GrantError extends Error {\n constructor(grant: string) {\n super(missingGrantMessageText(grant))\n this.name = 'GrantError'\n }\n}\n\nexport function buildContext(deps: Deps): AutomationContext {\n const serviceUrl = deps.serviceUrl ?? SERVICE_URL\n const requireGrant = (grant: string) => {\n if (!deps.grants.includes(grant)) throw new GrantError(grant)\n }\n\n /**\n * Step and finish writes carry BOTH credentials, and the service takes either.\n *\n * The deployed runner has the shared secret; a dev worker on a developer's\n * laptop must never hold it, and has only the run's own token — which is the\n * stronger claim for a row that belongs to one run. Sending both means this\n * module works unchanged in either process, which is the whole reason it can\n * be reused by the CLI rather than forked.\n *\n * An empty runner token is omitted rather than sent blank: the service treats\n * a PRESENT runner header as an assertion to verify, so a blank one would be\n * a 401 instead of a fall-through to the run token.\n */\n const runnerHeaders: Record<string, string> = {\n 'content-type': 'application/json',\n 'x-automation-run-token': deps.runToken,\n ...(deps.runnerToken ? { 'x-automation-runner-token': deps.runnerToken } : {}),\n }\n\n /**\n * Which author-declared step the code writing a row is running inside.\n *\n * Async-local rather than a plain variable because two steps can be in flight\n * at once — `Promise.all([ctx.step.run('a', …), ctx.step.run('b', …)])` is\n * legal, and a shared mutable \"current step\" would file `a`'s ctx calls under\n * `b` depending on interleaving. This is per-run, not module-global: two runs\n * in one process must never see each other's scope.\n */\n const stepScope = new AsyncLocalStorage<{\n stepId: string\n stepName: string\n /**\n * `action` + `submissionKey` for every submit this step body has made.\n *\n * The write plane CANNOT catch a repeat: two identical submissions derive\n * one key and one semantic fingerprint, so it replays the first request and\n * answers both calls with the same id — no error, one effect, a green run.\n * The check has to be local, and per step EXECUTION so that a genuine\n * resumption or retry (which re-enters the body from scratch) is unaffected.\n */\n submitted: Set<string>\n }>()\n\n /**\n * Append a row to the run's audit trail, returning the id the service gave it.\n *\n * Never throws. A step row is a record OF the work, not part of it — so a\n * service blip while recording must not turn a completed operation into a\n * failed run, and must not replace an in-flight failure with a transport\n * error on the way to reporting it. The same reasoning is why a failure\n * returns an empty id rather than propagating: losing the parent link on one\n * row is strictly better than losing the run.\n */\n const recordStep = async (body: Record<string, unknown>): Promise<string> => {\n const parentStepId = stepScope.getStore()?.stepId\n try {\n const res = await fetch(`${serviceUrl}/v1/automations/runner/runs/${deps.runId}/steps`, {\n method: 'POST',\n headers: runnerHeaders,\n body: JSON.stringify({\n // Only when there IS a parent. A step whose own row failed to write\n // leaves an empty id in scope, and sending that empty string reaches\n // Postgres as `''::uuid`, which errors — so the child row would be\n // dropped too, quietly, because this whole path is non-fatal. One\n // lost step row must not cost the calls made inside it.\n ...(parentStepId ? { parentStepId } : {}),\n attempt: deps.attempt ?? 0,\n ...body,\n }),\n })\n // `fetch` resolves on a 4xx/5xx, so the status is the only place a\n // rejected step surfaces at all.\n if (!res.ok) {\n console.warn(`[ctx] step record failed (non-fatal): ${res.status}`)\n return ''\n }\n return ((await res.json()) as { data?: { id?: string } }).data?.id ?? ''\n } catch (err) {\n console.warn('[ctx] step record failed (non-fatal):', (err as Error).message)\n return ''\n }\n }\n\n /** Close an author-declared step row. Never throws, for the same reason. */\n const completeStep = async (stepId: string, body: Record<string, unknown>): Promise<void> => {\n if (!stepId) return\n try {\n const res = await fetch(\n `${serviceUrl}/v1/automations/runner/runs/${deps.runId}/steps/${stepId}/complete`,\n { method: 'POST', headers: runnerHeaders, body: JSON.stringify(body) },\n )\n if (!res.ok) console.warn(`[ctx] step complete failed (non-fatal): ${res.status}`)\n } catch (err) {\n console.warn('[ctx] step complete failed (non-fatal):', (err as Error).message)\n }\n }\n\n /**\n * The message an author should read when a ctx call is refused.\n *\n * The service answers with an envelope (`{error, message, code}`), so the raw\n * body pasted into an error reads `ctx.http → 400 {\"error\":true,\"message\":...}`\n * — the useful sentence is in there, wrapped in JSON the author did not ask\n * for and cannot act on. This unwraps it and falls back to the raw body when\n * the response is not one of ours (a proxy 502, say), because an empty message\n * would be worse than a noisy one.\n */\n const refusal = async (res: Response): Promise<string> => {\n const body = await res.text()\n try {\n const parsed = JSON.parse(body) as { message?: unknown }\n if (typeof parsed.message === 'string' && parsed.message) return parsed.message\n } catch {\n // Not JSON. Fall through to the body.\n }\n return body\n }\n\n const step = async (kind: string, label: string, fn: () => Promise<unknown>): Promise<unknown> => {\n const t0 = Date.now()\n try {\n const out = await fn()\n await recordStep({ kind, label, status: 'ok', durationMs: Date.now() - t0 })\n return out\n } catch (err) {\n await recordStep({\n kind,\n label,\n status: 'error',\n detail: { message: (err as Error).message },\n durationMs: Date.now() - t0,\n })\n throw err\n }\n }\n\n /**\n * Every ctx call carries the per-run token, never a workspace credential.\n *\n * The fixed headers go LAST so `init.headers` cannot override them — the run\n * token is the entire authority of this call, and a caller that could replace\n * it could replace the run's scope.\n */\n const scoped = (path: string, init?: RequestInit) =>\n // `/automations/runner` — the ctx endpoints live on `automationRunnerRouter`,\n // which is prefixed, because they authenticate by run token rather than by\n // session. Addressing them as `/automations/...` reaches the session-guarded\n // router instead and 404s. This is only caught end to end: both sides pass\n // their own tests, and the mismatch is between them.\n fetch(`${serviceUrl}/v1/automations/runner${path}`, {\n ...init,\n headers: {\n ...(init?.headers ?? {}),\n 'content-type': 'application/json',\n 'x-automation-run-token': deps.runToken,\n 'x-workspace-id': deps.workspaceId,\n },\n })\n\n /**\n * Names used by this EXECUTION, which is what the uniqueness rule is about.\n *\n * A run that uses steps is executed many times — once more after each step\n * completes — and every execution walks the handler from the top, naming the\n * same steps again. That is not a duplicate. A duplicate is the same name\n * twice within one walk, which is what this set sees, because a fresh context\n * is built per execution.\n */\n const namesThisExecution = new Set<string>()\n\n /**\n * Run `fn` as a durable step.\n *\n * The row is written from INSIDE the step body, and that placement is the\n * whole design rather than an implementation detail. Code after\n * `await step.run(...)` does not run in the same execution — the platform\n * checkpoints the step and resumes the handler in a fresh execution — so a\n * report written there would land one execution late, time the memoized\n * return instead of the work, and repeat on every later resumption. A body\n * runs exactly once per real execution of the step, so a report inside it is\n * written exactly once and times what actually happened.\n *\n * Open-then-close rather than one write at the end: ctx calls made inside the\n * body need the parent row to exist before they record, and opening first also\n * puts the step ahead of its own children in `seq`.\n */\n const runStep = async <T>(name: string, fn: () => Promise<T>): Promise<T> => {\n if (namesThisExecution.has(name)) throw new DuplicateStepNameError(name)\n namesThisExecution.add(name)\n\n // The cast is the honest boundary: the platform hands back the JSON round\n // trip of what the body returned, and nothing here can verify the author's\n // `T` survived it. Rule 2 on `StepApi` is that contract, stated where the\n // author reads it.\n return (await deps.step.run(name, async () => {\n const stepId = await recordStep({\n kind: 'step',\n label: name,\n stepName: name,\n status: 'running',\n })\n const t0 = Date.now()\n try {\n // `stepName` rides alongside `stepId` because `ctx.action.submit` needs\n // the NAME, not the row id: the id is fresh on every execution, and an\n // idempotency key derived from it would differ on each resumption —\n // which is the exact duplicate-submission this scope exists to prevent.\n // The service reads the name off the row rather than trusting this\n // copy; it travels here only so a refusal can name it.\n const out = await stepScope.run(\n { stepId, stepName: name, submitted: new Set<string>() },\n fn,\n )\n await completeStep(stepId, { status: 'ok', durationMs: Date.now() - t0 })\n return out\n } catch (err) {\n await completeStep(stepId, {\n status: 'error',\n durationMs: Date.now() - t0,\n detail: { message: (err as Error).message },\n })\n throw err\n }\n })) as T\n }\n\n /**\n * Durable pause. No step row is written: a row recorded after a memoized\n * sleep would be re-recorded by every later execution (the code after an\n * awaited memoized step re-runs per resumption), and unlike `runStep`\n * there is no body to write it from exactly once.\n */\n const sleepStep = async (name: string, ms: number): Promise<void> => {\n if (namesThisExecution.has(name)) throw new DuplicateStepNameError(name)\n namesThisExecution.add(name)\n if (deps.step.sleep) {\n await deps.step.sleep(name, ms)\n return\n }\n // Host without a sleep arm (an old dev worker): wait inline. Correct,\n // just not durable — acceptable for the host that cannot resume anyway.\n await new Promise((resolve) => setTimeout(resolve, ms))\n }\n\n return {\n runId: deps.runId,\n workspaceId: deps.workspaceId,\n // The host passes the run row's frozen copy; the SDK never re-validates — `startRun` is the authority.\n input: deps.input ?? {},\n\n step: { run: runStep, sleep: sleepStep },\n\n async log(message, data) {\n // Swallowed on purpose, inside `recordStep`. `ctx.log` is telemetry, and a\n // blip reaching the service must not take down an otherwise-healthy run —\n // the signature promises callers it never rejects.\n await recordStep({ kind: 'log', label: message, detail: data ?? {} })\n },\n\n agent(slug: string) {\n return {\n run: (prompt: string) =>\n step('agent', `agent:${slug}`, async () => {\n requireGrant(`agent:${slug}:run`)\n const res = await scoped('/ctx/agent-run', {\n method: 'POST',\n body: JSON.stringify({ slug, prompt }),\n })\n if (!res.ok) throw new Error(`agent ${slug} → ${res.status} ${await refusal(res)}`)\n return ((await res.json()) as { data: { text: string } }).data\n }) as Promise<{ text: string }>,\n }\n },\n\n plugin(install: string) {\n return {\n call: <T = unknown>(capability: string, input?: Record<string, unknown>) =>\n step('plugin', `plugin:${install}:${capability}`, async () => {\n // Pre-flighted locally so an author reads the grant by name, in the\n // same words the service uses. The service checks it again — this\n // copy exists for the message, not for the authority.\n requireGrant(`plugin:${install}:${capability}`)\n const res = await scoped('/ctx/plugin-call', {\n method: 'POST',\n body: JSON.stringify({ install, capability, input: input ?? {} }),\n })\n if (!res.ok) {\n throw new Error(\n `ctx.plugin(\"${install}\").call(\"${capability}\") → ${res.status} ${await refusal(res)}`,\n )\n }\n // Two levels: the service envelope's data, then PluginCallResult's own data.\n return ((await res.json()) as { data: PluginCallResult<T> }).data\n }) as Promise<PluginCallResult<T>>,\n }\n },\n\n http: {\n fetch: (req: HttpRequest) =>\n step('http', `${req.method ?? 'GET'} ${req.url}`, async () => {\n // Pre-flight the HOST grant so an author sees it named locally, in the\n // same wording the service uses. The SECRET grant is deliberately not\n // pre-flighted: the service derives it, and duplicating that derivation\n // here would be a second place to get it wrong.\n let host: string\n try {\n host = new URL(req.url).hostname.toLowerCase()\n } catch {\n throw new Error(`ctx.http: invalid URL ${req.url}`)\n }\n requireGrant(`http:${host}`)\n const res = await scoped('/ctx/http', {\n method: 'POST',\n body: JSON.stringify(req),\n })\n if (!res.ok) throw new Error(`ctx.http → ${res.status} ${await refusal(res)}`)\n return ((await res.json()) as { data: HttpResponse }).data\n }) as Promise<HttpResponse>,\n },\n\n action: {\n submit: (request: ActionSubmission) =>\n step('action', `action:${request.action}`, async () => {\n // Step scope BEFORE the grant. Both are the author's mistake, but this\n // one is structural: a submit outside a step is wrong even with every\n // grant in place, and the remedy is a code change rather than a\n // manifest change. Naming the manifest first would send them to the\n // wrong file.\n const scope = stepScope.getStore()\n if (!scope) throw new Error(submitOutsideStepMessageText(request.action))\n // A step whose own row was lost cannot be submitted from. `recordStep`\n // is contractually non-fatal and hands back an empty id, which is\n // right for telemetry — one lost row must not cost the calls made\n // inside it — but a submission has nothing to key on without it, and\n // improvising a key is how an effect happens twice. Refused HERE so\n // the cause is named; the service would otherwise see an empty string\n // and answer with a generic invalid-submission.\n if (!scope.stepId) throw new Error(lostStepRowMessageText(scope.stepName))\n // NUL-joined because both halves are author strings; `a:b` with no\n // key must not collide with `a` keyed `b`.\n // Refused locally, matching the service's own field-named rejection\n // — folding `''` into the no-key identity would make the two layers\n // disagree about what the author asked for.\n if (request.submissionKey !== undefined && request.submissionKey.length === 0) {\n throw new Error(emptySubmissionKeyMessageText(request.action))\n }\n // Ahead of the reservation, and synchronous so check-and-reserve still\n // land in one tick. Below it, a missing grant left the identity\n // reserved and the author's next attempt was told they had duplicated\n // a submission that never left the process — pointing at\n // `submissionKey` when the fix is one line in the manifest. A call\n // that is both ungranted and a duplicate now reports the grant, which\n // is the more actionable of the two.\n requireGrant(`governed:${request.action}`)\n const submissionIdentity = `${request.action}\\u0000${request.submissionKey ?? ''}`\n // RESERVE, synchronously. The check and the record must land in one\n // tick: with an await between them, `Promise.all([submit(x),\n // submit(x)])` passes both checks before either records, both reach\n // the plane, and — same key, same fingerprint — the plane replays the\n // first for the second. Two calls, one effect, a green run, which is\n // the exact failure this guard exists to prevent.\n //\n // Released again in the catch below, so a submission that never\n // landed does not burn its identity and the author's retry loop still\n // works. Reserve-then-release is what satisfies both at once.\n if (scope.submitted.has(submissionIdentity)) {\n throw new Error(duplicateSubmissionMessageText(request.action))\n }\n scope.submitted.add(submissionIdentity)\n // The step ROW id, which the service issued. The service resolves the\n // row, takes the step NAME off it, and derives the key from that — so\n // what identifies the submission comes from the database rather than\n // from this process. No ordinal: a positional one made an in-body\n // retry mint a fresh key and duplicate the effect, and reordered\n // concurrent submits bind each other's keys. `submissionKey` is how an\n // author says two submissions are genuinely two.\n let res: Response\n try {\n res = await scoped('/ctx/action-submit', {\n method: 'POST',\n body: JSON.stringify({ ...request, stepId: scope.stepId }),\n })\n } catch (err) {\n // Never reached the service. Release, so a retry is a retry rather\n // than a duplicate accusation for a step that submitted zero times.\n scope.submitted.delete(submissionIdentity)\n throw err\n }\n if (!res.ok) {\n // Refused, so nothing was bound to this identity. A 5xx is the\n // interesting case: the author catches it and submits again, and\n // that second call must be allowed through to the plane, where the\n // key — unchanged — makes it a replay rather than a second effect.\n scope.submitted.delete(submissionIdentity)\n throw new Error(`ctx.action.submit → ${res.status} ${await refusal(res)}`)\n }\n try {\n return ((await res.json()) as { data: ActionSubmitResult }).data\n } catch (err) {\n // The submission LANDED, so keeping the reservation would be\n // defensible — but the reasoning that releases a 503 applies here\n // with more force: the key is unchanged, so a resubmit can only\n // replay, and replaying is the only way the author recovers a\n // request id they never received. Keeping it ends the run accusing\n // them of two submissions when there was one and an unreadable\n // answer.\n scope.submitted.delete(submissionIdentity)\n throw err\n }\n }) as Promise<ActionSubmitResult>,\n },\n\n blueprint: {\n query: <T = Record<string, unknown>>(objectType: string, options?: BlueprintQueryOptions) =>\n step('blueprint', `query:${objectType}`, async () => {\n requireGrant('blueprint:read')\n // Spread rather than forwarded field-by-field so adding an option to\n // `BlueprintQueryOptions` does not silently drop it here — the\n // service validates the body, so an unknown key is refused there\n // rather than ignored in transit.\n const res = await scoped('/ctx/blueprint-query', {\n method: 'POST',\n body: JSON.stringify({ objectType, ...(options ?? {}) }),\n })\n if (!res.ok) throw new Error(`blueprint query → ${res.status} ${await refusal(res)}`)\n // `T` is an author-supplied shape for rows the warehouse returns\n // untyped. The cast is the honest boundary: nothing here can verify\n // it, and pretending otherwise would just move the lie deeper.\n return ((await res.json()) as { data: BlueprintQueryResult<T> }).data\n }) as Promise<BlueprintQueryResult<T>>,\n },\n }\n}\n",
|
|
32
|
+
"frontera/automation/inputs.ts": "/**\n * Run-input validation — the value-side twin of `validateManifest`.\n *\n * Three callers must agree on the verdict and the wording: the run route\n * (fast 400 before anything queues), `startRun` (authoritative — the runner\n * posts whatever rode the event), and the Console form (client courtesy).\n * Living in the SDK is what keeps them one implementation.\n */\nimport type { InputFieldSpec, InputsSchema } from './types'\n\nexport type InputValidation =\n | { ok: true; value: Record<string, unknown> }\n | { ok: false; errors: string[] }\n\n/**\n * The five input types' runtime validators, keyed by `InputFieldSpec['type']`.\n *\n * Single source of truth for \"does this value have this type\" — `validateInputValue`'s\n * value check and `checkInputFieldSpec`'s default/enum checks all call this instead\n * of re-deriving it, so a tightening here (the `Number.isFinite` guard that excludes\n * `Infinity`/`NaN` from `number`) or a future widening can never drift between\n * deploy-time and run-time again. It drifting once — `manifest.ts`'s old `okDefault`\n * used `typeof spec.default === 'number'` and admitted `default: Infinity` — is why\n * this is exported rather than kept module-private.\n */\nexport const TYPE_CHECK: Record<InputFieldSpec['type'], (v: unknown) => boolean> = {\n string: (v) => typeof v === 'string',\n number: (v) => typeof v === 'number' && Number.isFinite(v),\n boolean: (v) => typeof v === 'boolean',\n object: (v) => typeof v === 'object' && v !== null && !Array.isArray(v),\n array: Array.isArray,\n}\n\n/** Lowercase kebab, matching `validateManifest`'s automation-`name` grammar. */\nconst INPUT_NAME_KEBAB_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/\n/** Lowercase snake — the one allowance kebab doesn't cover. */\nconst INPUT_NAME_SNAKE_RE = /^[a-z][a-z0-9_]*$/\n\nconst INPUT_TYPES = new Set<InputFieldSpec['type']>(['string', 'number', 'boolean', 'object', 'array'])\n\n/** Keys `checkInputFieldSpec` understands on ONE input spec (`inputs.<name>`).\n * Anything else there is a warning, same philosophy as `manifest.ts`'s\n * top-level unknown-key warning: a typo like `requred` should be visible,\n * but a field a newer SDK added must not fail an older validator's deploy. */\nconst INPUT_SPEC_KEYS = new Set(['type', 'required', 'default', 'description', 'enum'])\n\n/**\n * Serialized cap on a run's input object — the same 64KB the service enforces\n * when a run starts. Exported so `validateManifest` can refuse a manifest\n * whose *defaults alone* exceed it at deploy time: past that point a cron\n * fire would fail inside run-open with no run row, which is invisible.\n */\nexport const MAX_INPUT_BYTES = 64 * 1024\n\n/** Input names that smell like credentials — warned at deploy, never blocked.\n * Tails are anchored so `max_tokens` (a count) and `secretary` stay quiet\n * while `api_key`, `auth-token`, `client_secret`, `secret_key` still warn. */\nconst CREDENTIAL_NAME_RE = /([_-]token$|[_-]key$|password|(^|[_-])secret([_-]|$))/i\n\nexport interface InputFieldCheck {\n errors: string[]\n warnings: string[]\n}\n\n/**\n * Structural rules for ONE `{ name: spec }` entry in an inputs schema — name\n * shape, declared type, required/default shape, enum.\n *\n * The shared source for both `validateManifest` (deploy-time; also surfaces\n * the non-fatal warnings) and `sanitizeInputsSchema` (runtime; pass/fail\n * only) so the two can never quietly diverge on what \"a well-formed input\n * field\" means — which is exactly how `manifest.ts`'s default-type check once\n * drifted from `TYPE_CHECK` and admitted `default: Infinity`.\n */\nexport function checkInputFieldSpec(key: string, raw: unknown): InputFieldCheck {\n const errors: string[] = []\n const warnings: string[] = []\n\n if (!INPUT_NAME_KEBAB_RE.test(key) && !INPUT_NAME_SNAKE_RE.test(key)) {\n errors.push(`input \"${key}\" — names are lowercase snake or kebab`)\n return { errors, warnings }\n }\n\n // Inputs land on the run row and in traces permanently; there is no way to\n // detect a secret in a value, but a name that says \"credential\" is an honest\n // mistake we can flag while the author is still looking at the file.\n if (CREDENTIAL_NAME_RE.test(key)) {\n warnings.push(\n `input \"${key}\" looks like a credential — inputs are stored on the run row `\n + 'and visible in traces. Use a `secret:` grant instead.',\n )\n }\n\n if (raw && typeof raw === 'object' && !Array.isArray(raw)) {\n for (const specKey of Object.keys(raw as Record<string, unknown>)) {\n if (!INPUT_SPEC_KEYS.has(specKey)) {\n warnings.push(`unknown key \"${specKey}\" on input \"${key}\" — ignored`)\n }\n }\n }\n\n const spec = (raw ?? {}) as {\n type?: unknown\n required?: unknown\n default?: unknown\n enum?: unknown\n description?: unknown\n }\n\n if (spec.description !== undefined && typeof spec.description !== 'string') {\n warnings.push(`input \"${key}\": description is not a string — ignored`)\n }\n\n if (typeof spec.type !== 'string' || !INPUT_TYPES.has(spec.type as InputFieldSpec['type'])) {\n errors.push(`input \"${key}\": type must be one of string, number, boolean, object, array`)\n return { errors, warnings }\n }\n const t = spec.type as InputFieldSpec['type']\n\n // Deploy-side and runtime must share this exact predicate (`=== true`), not\n // a truthy check — `inputs.ts`'s own `validateInputValue` only treats\n // `required` as active when it is literally `true`. Without this, a plain\n // `required: 1` would deploy clean and then never actually be enforced.\n if (spec.required !== undefined && typeof spec.required !== 'boolean') {\n errors.push(`input \"${key}\": required must be a boolean`)\n }\n\n if (spec.required === true && spec.default !== undefined) {\n errors.push(`input \"${key}\": required and default are mutually exclusive — a default always satisfies required`)\n }\n\n let enumOk = true\n if (spec.enum !== undefined) {\n if (t !== 'string' && t !== 'number') {\n errors.push(`input \"${key}\": enum is only valid for string and number types`)\n enumOk = false\n } else if (!Array.isArray(spec.enum) || spec.enum.length === 0) {\n errors.push(`input \"${key}\": enum must not be empty`)\n enumOk = false\n } else if (spec.enum.some((e) => typeof e !== t)) {\n errors.push(`input \"${key}\": enum values must match the declared type`)\n enumOk = false\n } else if (t === 'number' && spec.enum.some((e) => !Number.isFinite(e as number))) {\n // TYPE_CHECK's own `number` check already excludes NaN/Infinity from\n // values — a member of `enum` that no value could ever equal is\n // unreachable and can only be an authoring mistake.\n errors.push(`input \"${key}\": enum values must be finite numbers`)\n enumOk = false\n }\n }\n\n let defaultOk = true\n if (spec.default !== undefined) {\n if (!TYPE_CHECK[t](spec.default)) {\n errors.push(`input \"${key}\": default must match the declared type`)\n defaultOk = false\n }\n }\n\n if (spec.enum !== undefined && spec.default !== undefined && enumOk && defaultOk) {\n if (!(spec.enum as unknown[]).includes(spec.default)) {\n errors.push(`input \"${key}\": default must be one of the enum values`)\n }\n }\n\n return { errors, warnings }\n}\n\nexport function validateInputValue(\n schema: InputsSchema | undefined,\n value: Record<string, unknown> | undefined | null,\n): InputValidation {\n const given = value ?? {}\n if (!schema || Object.keys(schema).length === 0) {\n return Object.keys(given).length === 0\n ? { ok: true, value: {} }\n : { ok: false, errors: ['this automation declares no inputs — remove the input and run again'] }\n }\n const errors: string[] = []\n const out: Record<string, unknown> = {}\n for (const key of Object.keys(given)) {\n // `Object.hasOwn`, not `key in schema`: the `in` operator also sees\n // inherited members — every plain object \"has\" `toString` via\n // `Object.prototype` — so a value keyed `toString` would slip past an\n // undeclared-field check that used `in`.\n if (!Object.hasOwn(schema, key)) errors.push(`\"${key}\" is not a declared input`)\n }\n for (const [key, spec] of Object.entries(schema)) {\n // Same reasoning in reverse: plain `given[key]` for key `constructor`\n // resolves to `Object.prototype.constructor` (a function) rather than\n // `undefined` when the caller never supplied one, which would run type\n // checks against Object's own constructor instead of treating the field\n // as absent.\n const v = Object.hasOwn(given, key) ? given[key] : undefined\n if (v === undefined) {\n if (spec.default !== undefined) out[key] = structuredClone(spec.default)\n // `=== true`, not truthy: a legacy/malformed `required: 1` must not be\n // silently enforced here when `validateManifest` already refuses it as\n // \"not a boolean\" — the two sides share one predicate on purpose.\n else if (spec.required === true) errors.push(`\"${key}\" is required`)\n continue\n }\n // `Object.hasOwn`, not a plain lookup: `TYPE_CHECK` is an object literal,\n // so `TYPE_CHECK['toString']` resolves to `Object.prototype.toString` —\n // truthy, and callable — rather than `undefined`. A spec of `{ type:\n // 'toString' }` would then pass `check(v)` for ANY `v` instead of being\n // refused as the unknown type it is.\n const check = Object.hasOwn(TYPE_CHECK, spec.type) ? TYPE_CHECK[spec.type as InputFieldSpec['type']] : undefined\n if (!check) {\n // A stored manifest can predate this SDK version and carry a `type`\n // this build has never heard of (pre-input-validation, `inputs` was an\n // unknown key with no shape checking at all). Fail the field, don't\n // crash the run route.\n errors.push(`\"${key}\" has an unknown declared type \"${String(spec.type)}\"`)\n continue\n }\n if (!check(v)) {\n errors.push(`\"${key}\" must be of type ${spec.type}`)\n continue\n }\n if (spec.enum && !spec.enum.includes(v as string | number)) {\n errors.push(`\"${key}\" must be one of ${spec.enum.join(', ')}`)\n continue\n }\n out[key] = v\n }\n return errors.length > 0 ? { ok: false, errors } : { ok: true, value: out }\n}\n\n/**\n * A stored manifest's `inputs` key, admitted only when structurally valid.\n *\n * Versions deployed before inputs existed could carry ANY value under this\n * key (it was warn-and-store), and `startRun` must not let a stray legacy\n * blob retroactively break a working schedule — an invalid schema is treated\n * as \"declares no inputs\", never as a refusal.\n */\nexport function sanitizeInputsSchema(inputs: unknown): InputsSchema | undefined {\n if (!inputs || typeof inputs !== 'object' || Array.isArray(inputs)) return undefined\n for (const [key, raw] of Object.entries(inputs as Record<string, unknown>)) {\n if (checkInputFieldSpec(key, raw).errors.length > 0) return undefined\n }\n return inputs as InputsSchema\n}\n",
|
|
33
|
+
"theme.css": "/* GENERATED from packages/web/src/app/globals.css — do not edit.\n * Refresh with `frontera app add theme`.\n *\n * Gives an app the same Tailwind utilities and design tokens the platform\n * uses, so copied components look native rather than unstyled. Import this\n * once from your entry (`import './theme.css'`).\n */\n@import \"tailwindcss\";\n\n@custom-variant dark (&:is(.dark *));\n\n@theme inline {\n --color-background: hsl(var(--background));\n --color-foreground: hsl(var(--foreground));\n --color-dot: var(--dot-foreground);\n --color-card: var(--card);\n --color-card-foreground: var(--card-foreground);\n --color-popover: var(--popover);\n --color-popover-foreground: var(--popover-foreground);\n --color-primary: var(--primary);\n --color-primary-foreground: var(--primary-foreground);\n --color-secondary: var(--secondary);\n --color-secondary-foreground: var(--secondary-foreground);\n --color-muted: var(--muted);\n --color-muted-foreground: var(--muted-foreground);\n --color-accent: var(--accent);\n --color-accent-foreground: var(--accent-foreground);\n --color-destructive: var(--destructive);\n --color-destructive-foreground: var(--destructive-foreground);\n --color-success: var(--success);\n --color-success-foreground: var(--success-foreground);\n --color-warning: var(--warning);\n --color-warning-foreground: var(--warning-foreground);\n --color-info: var(--info);\n --color-info-foreground: var(--info-foreground);\n --color-border: var(--border);\n --color-border-secondary: var(--border-secondary);\n --color-input: var(--input);\n --color-ring: var(--ring);\n --color-sidebar: var(--sidebar);\n --color-sidebar-foreground: var(--sidebar-foreground);\n --color-sidebar-primary: var(--sidebar-primary);\n --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n --color-sidebar-accent: var(--sidebar-accent);\n --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n --color-sidebar-border: var(--sidebar-border);\n --color-sidebar-ring: var(--sidebar-ring);\n --color-chart-1: var(--chart-1);\n --color-chart-2: var(--chart-2);\n --color-chart-3: var(--chart-3);\n --color-chart-4: var(--chart-4);\n --color-chart-5: var(--chart-5);\n --color-chart-6: var(--chart-6);\n --color-chart-7: var(--chart-7);\n --color-chart-8: var(--chart-8);\n --color-chart-9: var(--chart-9);\n --color-chart-10: var(--chart-10);\n --color-chart-11: var(--chart-11);\n --color-chart-12: var(--chart-12);\n --radius-sm: calc(var(--radius) - 4px);\n --radius-md: calc(var(--radius) - 2px);\n --radius-lg: var(--radius);\n --radius-xl: calc(var(--radius) + 4px);\n --radius-2xl: calc(var(--radius) + 8px);\n\n /* Shadow elevation tokens — Attio-inspired multi-layer */\n /* --shadow-xs: var(--elevation-xs);\n --shadow-sm: var(--elevation-sm);\n --shadow-md: var(--elevation-md);\n --shadow-lg: var(--elevation-lg);\n --shadow-xl: var(--elevation-xl); */\n\n /* Surface elevation tokens */\n --color-surface-inset-deep: var(--surface-inset-deep);\n --color-surface-inset-deep-hover: var(--surface-inset-deep-hover);\n --color-surface-inset-deep-active: var(--surface-inset-deep-active);\n --color-surface-inset: var(--surface-inset);\n --color-surface-inset-hover: var(--surface-inset-hover);\n --color-surface-inset-active: var(--surface-inset-active);\n --color-surface: var(--surface);\n --color-surface-chat: var(--surface-chat);\n --color-surface-hover: var(--surface-hover);\n --color-surface-active: var(--surface-active);\n --color-surface-raised: var(--surface-raised);\n --color-surface-raised-hover: var(--surface-raised-hover);\n --color-surface-raised-active: var(--surface-raised-active);\n /* --color-surface-overlay: var(--surface-raised);\n --color-surface-overlay-hover: var(--surface-raised-hover);\n --color-surface-overlay-active: var(--surface-raised-active); */\n --color-surface-overlay: var(--surface-overlay);\n --color-surface-overlay-hover: var(--surface-overlay-hover);\n --color-surface-overlay-active: var(--surface-overlay-active);\n /* The modal scrim, shared by every Dialog and Sheet. */\n --color-scrim: var(--scrim);\n /* Quint-out. For transitions long enough (250ms+) that Tailwind's `ease-out`\n — cubic-bezier(0, 0, 0.2, 1) — reads as near-constant motion: this covers\n 58% of the distance in the first 15% of the time against ease-out's 37%,\n so the element commits immediately and then settles. Use it for a surface\n opening or expanding; keep `ease-out` for short state changes, where the\n difference isn't perceptible. */\n --ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1);\n\n /* Image lightbox chrome sits over arbitrary pixels while still matching the\n active light/dark theme. */\n --color-image-overlay-scrim: var(--image-overlay-scrim);\n --color-image-overlay-control: var(--image-overlay-control);\n --color-image-overlay-control-hover: var(--image-overlay-control-hover);\n --color-image-overlay-border: var(--image-overlay-border);\n --color-image-overlay-foreground: var(--image-overlay-foreground);\n --color-image-overlay-muted: var(--image-overlay-muted);\n --color-image-overlay-thumb: var(--image-overlay-thumb);\n --color-image-checker-a: var(--image-checker-a);\n --color-image-checker-b: var(--image-checker-b);\n}\n\n:root {\n --radius: 0.625rem;\n\n /* Widest a config-row chip (skill / pack / app / context) may grow before\n its label truncates. Roughly 28 characters at text-xs — long enough to\n read a name, short enough that one chip can't monopolise the row. */\n --chip-max-width: 13rem;\n\n /* Bottom padding every `/console` page ends with, so a scrolled list never\n stops flush against the panel edge and the last row clears the home\n indicator on a notched phone. ONE value at every breakpoint — \"the same\n bottom padding on every console page\" is the whole point of it, and\n `env()` resolves to 0 off a notched device.\n\n Reach for the `console-page` utility below, which already applies this.\n Use `pb-(--console-page-tail)` directly only where the container sets no\n padding shorthand of its own — a bare `pb-*` is emitted with the\n unprefixed utilities and so LOSES to a later `sm:p-*`/`md:py-*` on the\n same element. That is measured, not assumed: in the built stylesheet the\n unprefixed padding block sits around 250kB and `sm:p-4` at 402kB. */\n --console-page-tail: calc(2rem + env(safe-area-inset-bottom));\n\n --background: 0 0% 100%;\n\n /* React flow canvas tokens */\n --dot-foreground: #c9c9c9;\n\n /* ── Surface elevation tokens ──────────────────── */\n --surface-inset-deep: hsl(0 0% 93%);\n --surface-inset-deep-hover: hsl(0 0% 89%);\n --surface-inset-deep-active: hsl(0 0% 86%);\n --surface-inset: hsl(0 0% 96%);\n --surface-inset-hover: hsl(0 0% 91%);\n --surface-inset-active: hsl(0 0% 88%);\n --surface: hsl(0 0% 98%);\n /* Chat-view backdrop — slightly warmer off-white than --surface, light only. */\n --surface-chat: #f9f9f9;\n --surface-hover: hsl(0 0% 96%);\n --surface-active: hsl(0 0% 94%);\n --surface-raised: hsl(0 0% 100%);\n --surface-raised-hover: hsl(0 0% 96%);\n --surface-raised-active: hsl(0 0% 94%);\n --surface-overlay: hsl(0 0% 100%);\n --surface-overlay-hover: hsl(0 0% 95%);\n --surface-overlay-active: hsl(0 0% 91%);\n /* ── Modal scrim ────────────────────────────────────────────────\n ONE value for every Dialog and Sheet. Was `bg-surface-inset-deep/50`\n written inline in both primitives, which in light mode is a 93% grey at\n half alpha — a wash, not a scrim, and it left surfaces that had opted into\n something darker looking like a different app. Black in both themes: what\n a scrim has to do is push the page back, and only black does that at a\n usable alpha in light. */\n --scrim: hsl(0 0% 0% / 60%);\n\n --image-overlay-scrim: hsl(0 0% 0% / 70%);\n --image-overlay-control: var(--surface-overlay);\n --image-overlay-control-hover: var(--surface-overlay-hover);\n --image-overlay-border: var(--border);\n --image-overlay-foreground: hsl(var(--foreground));\n --image-overlay-muted: var(--muted-foreground);\n --image-overlay-thumb: var(--surface-inset);\n /* Transparency checkerboard, following the theme (.dark overrides below).\n The scrim is 70% black, which composites over the page rather than\n replacing it, so the lightbox backdrop lands near 30% lightness in light\n theme and near black in dark — a light checker reads against one and a\n dark checker against the other.\n\n Known tradeoff, chosen deliberately: a theme-following check sits close in\n lightness to same-polarity artwork, so a white-ink transparent logo is\n weak on the light check (and a black-ink one on the dark check). A neutral\n mid-grey avoids that but reads as theme-agnostic. If the washout ever\n matters more than the theme character, pull both pairs toward mid —\n 62/82 and 34/54 keep both polarities legible.\n\n Light is the Photoshop/Figma convention (#ccc on #fff). It matters that\n the lighter square is pure white: the chat surface is #f9f9f9 (97.6%), so\n a checker whose mean sits far below that reads as a dark patch inset into\n the page rather than as transparency. Keep the two squares ~20 lightness\n points apart in light and ~17 in dark. */\n --image-checker-a: hsl(0 0% 80%);\n --image-checker-b: hsl(0 0% 100%);\n\n /* ── Shadow elevations: (light) ───── */\n --elevation-xs: 0px 0px 0px 1px rgba(42, 28, 0, 0.07);\n\n --elevation-sm:\n 0px 2px 4px 0px rgba(0, 0, 0, 0.04), 0px 0px 0px 1px rgba(42, 28, 0, 0.07);\n\n --elevation-md:\n 0px 8px 12px 0px rgba(25, 25, 25, 0.027),\n 0px 2px 6px 0px rgba(25, 25, 25, 0.027),\n 0px 0px 0px 1px rgba(42, 28, 0, 0.07);\n\n --elevation-lg:\n 0px 20px 24px 0px rgba(25, 25, 25, 0.05),\n 0px 5px 8px 0px rgba(25, 25, 25, 0.027),\n 0px 0px 0px 1px rgba(42, 28, 0, 0.07);\n\n --elevation-xl:\n 0px 24px 48px 0px rgba(25, 25, 25, 0.24),\n 0px 4px 12px 0px rgba(25, 25, 25, 0.14),\n 0px 0px 0px 1px rgba(42, 28, 0, 0.07);\n\n --foreground: 47 13% 14%;\n --card: var(--surface-raised);\n --card-foreground: 47 13% 14%;\n --popover: oklch(1 0 0);\n --popover-foreground: 47 13% 14%;\n --primary: oklch(0.21 0.006 285.885);\n --primary-foreground: oklch(0.985 0 0);\n --secondary: oklch(0.967 0.001 286.375);\n --secondary-foreground: oklch(0.21 0.006 285.885);\n --muted: oklch(0.967 0.001 286.375);\n --muted-foreground: oklch(0.5 0.016 285.938);\n --accent: oklch(0.967 0.001 286.375);\n --accent-foreground: 47 13% 14%;\n --destructive: oklch(0.577 0.245 27.325);\n --destructive-foreground: oklch(0.985 0 0);\n --success: oklch(0.627 0.194 149.214);\n --success-foreground: oklch(0.985 0 0);\n --warning: oklch(0.735 0.166 70.67);\n --warning-foreground: oklch(0.21 0.006 285.885);\n --info: oklch(0.6 0.15 240);\n --info-foreground: oklch(0.985 0 0);\n --border: hsl(240 100 6 / 0.05);\n --border-secondary: hsl(214 32% 96%);\n --input: oklch(0.92 0.004 286.32 / 0.75);\n --ring: oklch(0.705 0.015 286.067);\n --chart-1: #5b8dee;\n --chart-2: #3dab82;\n --chart-3: #e8883e;\n --chart-4: #9b7ef5;\n --chart-5: #e05c78;\n --chart-6: #3ab5cc;\n --chart-7: #a4b83a;\n --chart-8: #d47a4a;\n --chart-9: #748cd4;\n --chart-10: #4cad6a;\n /* Dashboard analytics series — extends the fixed chart palette. */\n --chart-11: #3b82f6;\n --chart-12: #10b981;\n --sidebar: oklch(0.985 0 0);\n --sidebar-foreground: oklch(0.141 0.005 285.823);\n --sidebar-primary: oklch(0.21 0.006 285.885);\n --sidebar-primary-foreground: oklch(0.985 0 0);\n --sidebar-accent: oklch(0.967 0.001 286.375);\n --sidebar-accent-foreground: oklch(0.21 0.006 285.885);\n --sidebar-border: oklch(0.92 0.004 286.32);\n --sidebar-ring: oklch(0.705 0.015 286.067);\n}\n\n.dark {\n --background: 0 0% 2%;\n\n /* React flow canvas tokens */\n --dot-foreground: #363636;\n\n /* ── Shadow elevations (dark) ───────────────────── */\n --elevation-xs: 0 0 0 1px #383836;\n\n --elevation-sm: 0 0 0 1px #383836, 0 2px 4px 0 rgba(0, 0, 0, 0.18);\n\n --elevation-md:\n 0 0 0 1px #383836, 0 8px 16px -4px rgba(0, 0, 0, 0.36),\n 0 2px 6px -2px rgba(0, 0, 0, 0.28);\n\n --elevation-lg:\n 0 0 0 1px #383836, 0 14px 28px -6px rgba(0, 0, 0, 0.44),\n 0 2px 4px -1px rgba(0, 0, 0, 0.28);\n\n --elevation-xl:\n 0 0 0 1px #383836, 0 24px 48px 0 rgba(0, 0, 0, 0.56),\n 0 4px 12px 0 rgba(0, 0, 0, 0.4);\n\n /* ── Surface elevation tokens ──────────────────── */\n --surface-inset-deep: hsl(0, 0%, 6%);\n --surface-inset-deep-hover: hsl(0 0% 8%);\n --surface-inset-deep-active: hsl(0 0% 11%);\n --surface-inset: hsl(0 0% 7.5%);\n --surface-inset-hover: hsl(0 0% 10%);\n --surface-inset-active: hsl(0 0% 12%);\n --surface: hsl(0 0% 9%);\n /* Dark mirrors --surface — chat backdrop is only retuned in light. */\n --surface-chat: hsl(0 0% 9%);\n --surface-hover: hsl(0 0% 14%);\n --surface-active: hsl(0 0% 17%);\n --surface-raised: hsl(0 0% 13%);\n --surface-raised-hover: hsl(0 0% 15%);\n --surface-raised-active: hsl(0 0% 17%);\n --surface-overlay: hsl(0 0% 9%);\n --surface-overlay-hover: hsl(0 0% 15%);\n --surface-overlay-active: hsl(0 0% 19%);\n --scrim: hsl(0 0% 0% / 60%);\n --image-overlay-scrim: hsl(0 0% 0% / 70%);\n --image-overlay-control: hsl(0 0% 12% / 96%);\n --image-overlay-control-hover: hsl(0 0% 18% / 98%);\n --image-overlay-border: hsl(0 0% 100% / 12%);\n --image-overlay-foreground: hsl(0 0% 100%);\n --image-overlay-muted: hsl(0 0% 100% / 65%);\n --image-overlay-thumb: hsl(0 0% 0% / 35%);\n --image-checker-a: hsl(0 0% 22%);\n --image-checker-b: hsl(0 0% 39%);\n\n --foreground: 0 0% 83%;\n --card: var(--surface-raised);\n --card-foreground: hsl(0 0% 98%);\n --popover: hsl(0 0% 7%);\n --popover-foreground: hsl(0 0% 98%);\n --primary: hsl(0 0% 90%);\n --primary-foreground: hsl(0 0% 5%);\n --secondary: hsl(0 0% 12%);\n --secondary-foreground: hsl(0 0% 98%);\n --muted: hsl(0 0% 9%);\n --muted-foreground: hsl(0 0% 52%);\n --accent: hsl(0 0% 9%);\n --accent-foreground: hsl(0 0% 98%);\n --destructive: oklch(0.704 0.191 22.216);\n --destructive-foreground: oklch(0.985 0 0);\n --success: oklch(0.723 0.191 142.542);\n --success-foreground: oklch(0.985 0 0);\n --warning: oklch(0.815 0.152 78.2);\n --warning-foreground: oklch(0.21 0.006 285.885);\n --info: oklch(0.7 0.15 240);\n --info-foreground: oklch(0.985 0 0);\n --border: oklch(0.9296 0.007 106.53 / 0.08);\n --border-secondary: oklch(0.9296 0.007 106.53 / 0.03);\n --input: oklch(1 0 0 / 10%);\n --ring: oklch(0.552 0.016 285.938);\n --chart-1: #5b8dee;\n --chart-2: #3dab82;\n --chart-3: #e8883e;\n --chart-4: #9b7ef5;\n --chart-5: #e05c78;\n --chart-6: #3ab5cc;\n --chart-7: #a4b83a;\n --chart-8: #d47a4a;\n --chart-9: #748cd4;\n --chart-10: #4cad6a;\n --chart-11: #3b82f6;\n --chart-12: #10b981;\n --sidebar: oklch(0.21 0.006 285.885);\n --sidebar-foreground: oklch(0.985 0 0);\n --sidebar-primary: oklch(0.488 0.243 264.376);\n --sidebar-primary-foreground: oklch(0.985 0 0);\n --sidebar-accent: oklch(0.274 0.006 286.033);\n --sidebar-accent-foreground: oklch(0.985 0 0);\n --sidebar-border: oklch(1 0 0 / 10%);\n --sidebar-ring: oklch(0.552 0.016 285.938);\n}\n\n@layer base {\n * {\n border-color: var(--border);\n }\n body {\n background: var(--app-canvas, var(--surface));\n color: var(--foreground);\n font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif);\n -webkit-font-smoothing: antialiased;\n }\n}\n\n/* ── Make it the CUSTOMER'S app, not ours ────────────────────────────────\n *\n * Everything above is a DEFAULT, not a house style. A Frontera app should\n * look like the product it belongs to, so override any token below in your\n * own stylesheet, imported after this file:\n *\n * :root {\n * --primary: #0b5fff; [your brand]\n * --radius: 0.25rem; [your shape]\n * --font-sans: \"Inter\", sans-serif;\n * --app-canvas: #f7f8fa; [page background]\n * --app-gutter: 2rem; [page padding]\n * --app-radius: 0.25rem; [card and tile corners]\n * --app-section-gap: 2rem; [vertical rhythm]\n * }\n *\n * The components read tokens, never literals, so redefining these restyles\n * the whole app without forking a single component. The app-* tokens are the\n * layout knobs the app-kit exposes; the rest are the shared semantic set.\n */\n"
|
|
34
34
|
}
|
|
35
35
|
}
|