@lotics/app-sdk 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/hooks.js CHANGED
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { useCallback, useEffect, useState } from "react";
17
17
  import { rpc } from "./rpc.js";
18
+ import { getMockRows } from "./mock.js";
18
19
  export function useWorkflow(alias) {
19
20
  return useCallback((inputs) => rpc("workflow", { alias, inputs: inputs ?? {} }), [alias]);
20
21
  }
@@ -26,12 +27,25 @@ export function useQuery(alias, params) {
26
27
  // refetch callback mutates only this counter; state transitions still
27
28
  // happen inside the effect so loading / error / rows flow consistently.
28
29
  const [refetchToken, setRefetchToken] = useState(0);
29
- const [state, setState] = useState({
30
- rows: [],
31
- loading: true,
32
- error: null,
30
+ // Initialize from the fixture when mock mode is on and the app registered
31
+ // rows for this alias — avoids a flash of `loading: true` on first paint.
32
+ // Computed lazily so subsequent renders don't re-read `window.location`.
33
+ const [state, setState] = useState(() => {
34
+ const mockRows = getMockRows(alias);
35
+ if (mockRows)
36
+ return { rows: mockRows, loading: false, error: null };
37
+ return { rows: [], loading: true, error: null };
33
38
  });
34
39
  useEffect(() => {
40
+ // Re-check on every effect run so HMR updates to the fixture (mount-time
41
+ // re-registration) propagate without a hard reload. When the fixture has
42
+ // an entry we short-circuit the RPC — partial mocking still works because
43
+ // aliases without fixture entries fall through to the live path below.
44
+ const mockRows = getMockRows(alias);
45
+ if (mockRows) {
46
+ setState({ rows: mockRows, loading: false, error: null });
47
+ return;
48
+ }
35
49
  let cancelled = false;
36
50
  setState((s) => ({ rows: s.rows, loading: true, error: null }));
37
51
  rpc("query", { alias, params: params ?? {} })
@@ -10,8 +10,10 @@
10
10
  * depending on packages/ui's React Native Web setup.
11
11
  */
12
12
  export { mount } from "./mount.js";
13
+ export type { MountOptions } from "./mount.js";
13
14
  export { useWorkflow, useQuery, useFileUpload } from "./hooks.js";
14
15
  export type { UploadedFile } from "./hooks.js";
15
16
  export { rpc } from "./rpc.js";
16
17
  export type { RpcOp } from "./rpc.js";
18
+ export type { AppFixture } from "./mock.js";
17
19
  export type { AppWorkflows, AppQueries } from "./types.js";
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Demo / design-time fixture support for `useQuery`.
3
+ *
4
+ * Activation contract:
5
+ *
6
+ * 1. App passes `{ fixture }` to `mount(<App />, { fixture })`. The fixture
7
+ * is a `{ queries: { alias: Row[] } }` map keyed by the same aliases the
8
+ * app declared in `package.json#lotics.queries`.
9
+ * 2. At runtime, the user (or a screenshot script) loads the app with the
10
+ * `?__mock=1` URL search param. Without that param the SDK ignores the
11
+ * fixture entirely and `useQuery` flows through the RPC bridge as usual.
12
+ *
13
+ * The two-step gate keeps demo data shipping in the bundle from leaking into
14
+ * normal traffic — the param namespace (`__mock` prefix) is reserved and
15
+ * unlikely to collide with app-side query state. Apps that don't pass a
16
+ * fixture pay nothing: `getMockRows` returns `null` for every alias and the
17
+ * hook path is unchanged.
18
+ *
19
+ * What's deliberately *not* mocked yet:
20
+ * - `useWorkflow` — mutations have side effects (notifications, audit
21
+ * trail). A workflow mock would have to also produce realistic followup
22
+ * state, which is more design than this iteration warrants.
23
+ * - `useFileUpload` — same reason.
24
+ *
25
+ * If those become needed, extend `AppFixture` with `workflows`/`uploads` and
26
+ * route from each hook.
27
+ */
28
+ export interface AppFixture {
29
+ /** Map of query alias → rows the hook should return when mock mode is on. */
30
+ queries?: Record<string, Array<Record<string, unknown>>>;
31
+ }
32
+ /**
33
+ * Called by `mount({ fixture })`. Module-level state because the SDK has no
34
+ * React context boundary around the iframe — every hook resolves against the
35
+ * same registration. Calling twice replaces (last-write-wins) which is fine
36
+ * for HMR.
37
+ */
38
+ export declare function registerMockFixture(fixture: AppFixture | undefined): void;
39
+ /**
40
+ * Returns the fixture rows for an alias when mock mode is active *and* the
41
+ * fixture has an entry for that alias. Otherwise null — the hook falls
42
+ * through to the real RPC path. The distinction matters: an app may mock
43
+ * only some queries and let the rest flow through to real data.
44
+ */
45
+ export declare function getMockRows(alias: string): Array<Record<string, unknown>> | null;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Demo / design-time fixture support for `useQuery`.
3
+ *
4
+ * Activation contract:
5
+ *
6
+ * 1. App passes `{ fixture }` to `mount(<App />, { fixture })`. The fixture
7
+ * is a `{ queries: { alias: Row[] } }` map keyed by the same aliases the
8
+ * app declared in `package.json#lotics.queries`.
9
+ * 2. At runtime, the user (or a screenshot script) loads the app with the
10
+ * `?__mock=1` URL search param. Without that param the SDK ignores the
11
+ * fixture entirely and `useQuery` flows through the RPC bridge as usual.
12
+ *
13
+ * The two-step gate keeps demo data shipping in the bundle from leaking into
14
+ * normal traffic — the param namespace (`__mock` prefix) is reserved and
15
+ * unlikely to collide with app-side query state. Apps that don't pass a
16
+ * fixture pay nothing: `getMockRows` returns `null` for every alias and the
17
+ * hook path is unchanged.
18
+ *
19
+ * What's deliberately *not* mocked yet:
20
+ * - `useWorkflow` — mutations have side effects (notifications, audit
21
+ * trail). A workflow mock would have to also produce realistic followup
22
+ * state, which is more design than this iteration warrants.
23
+ * - `useFileUpload` — same reason.
24
+ *
25
+ * If those become needed, extend `AppFixture` with `workflows`/`uploads` and
26
+ * route from each hook.
27
+ */
28
+ let registeredFixture;
29
+ /**
30
+ * Called by `mount({ fixture })`. Module-level state because the SDK has no
31
+ * React context boundary around the iframe — every hook resolves against the
32
+ * same registration. Calling twice replaces (last-write-wins) which is fine
33
+ * for HMR.
34
+ */
35
+ export function registerMockFixture(fixture) {
36
+ registeredFixture = fixture;
37
+ }
38
+ /**
39
+ * True iff the iframe URL carries the `__mock=1` activation flag AND a
40
+ * fixture was registered. Safe to call before mount — returns false when no
41
+ * fixture exists. Throws never; a malformed URL or missing `window` (SSR /
42
+ * jsdom without location) silently returns false.
43
+ */
44
+ function isMockMode() {
45
+ if (!registeredFixture)
46
+ return false;
47
+ try {
48
+ return new URLSearchParams(window.location.search).get("__mock") === "1";
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ /**
55
+ * Returns the fixture rows for an alias when mock mode is active *and* the
56
+ * fixture has an entry for that alias. Otherwise null — the hook falls
57
+ * through to the real RPC path. The distinction matters: an app may mock
58
+ * only some queries and let the rest flow through to real data.
59
+ */
60
+ export function getMockRows(alias) {
61
+ if (!isMockMode())
62
+ return null;
63
+ const rows = registeredFixture?.queries?.[alias];
64
+ return rows ?? null;
65
+ }
@@ -10,9 +10,23 @@
10
10
  * mount(<App />);
11
11
  * ```
12
12
  *
13
+ * Optional second argument registers a demo/design-time fixture. When the
14
+ * iframe is loaded with `?__mock=1`, `useQuery` returns rows from the
15
+ * fixture instead of hitting the RPC bridge. Without the URL flag the
16
+ * fixture is inert — useful for taking screenshots or iterating on
17
+ * dashboard layouts before real data exists.
18
+ *
19
+ * ```tsx
20
+ * mount(<App />, {
21
+ * fixture: {
22
+ * queries: { customers: MOCK_CUSTOMERS, deals: MOCK_DEALS },
23
+ * },
24
+ * });
25
+ * ```
26
+ *
13
27
  * `mount` wires up React 19's createRoot against `#root` in the iframe shell,
14
28
  * sets up window.onerror/unhandledrejection forwarding so runtime crashes
15
- * surface in the parent's debug pane (PR 2 of the debug-loop phase), and
29
+ * surface in the parent's debug pane, registers the fixture (if any), and
16
30
  * renders the user's tree.
17
31
  *
18
32
  * If the bundler doesn't ship #root in the user's `index.html`, we create it
@@ -20,4 +34,14 @@
20
34
  * mount resilient.
21
35
  */
22
36
  import type { ReactNode } from "react";
23
- export declare function mount(element: ReactNode): void;
37
+ import { type AppFixture } from "./mock.js";
38
+ export interface MountOptions {
39
+ /**
40
+ * Optional demo fixture. When the iframe URL contains `?__mock=1`,
41
+ * `useQuery(alias)` returns `fixture.queries[alias]` instead of calling
42
+ * the RPC bridge. Aliases not present in the fixture still flow through
43
+ * the real path — partial mocking is supported.
44
+ */
45
+ fixture?: AppFixture;
46
+ }
47
+ export declare function mount(element: ReactNode, options?: MountOptions): void;
package/dist/src/mount.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { createRoot } from "react-dom/client";
2
- export function mount(element) {
2
+ import { registerMockFixture } from "./mock.js";
3
+ export function mount(element, options = {}) {
4
+ registerMockFixture(options.fixture);
3
5
  let container = document.getElementById("root");
4
6
  if (!container) {
5
7
  container = document.createElement("div");
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Vanilla DOM password overlay for password-protected public apps.
3
+ *
4
+ * The SDK can't assume the host app's React tree has mounted yet (this fires
5
+ * before any data call resolves), so the overlay is built with plain DOM
6
+ * APIs and self-contained inline styles. It rejects the boot promise only
7
+ * on `cancel()` — which is currently never wired to a button; the visitor
8
+ * either enters the correct password or leaves the page.
9
+ *
10
+ * One overlay at a time. Concurrent calls reuse the in-flight promise so a
11
+ * burst of failing API calls produces a single modal, not several stacked.
12
+ */
13
+ interface OverlayController {
14
+ showError(message: string): void;
15
+ setSubmitting(submitting: boolean): void;
16
+ }
17
+ export interface PasswordPromptArgs {
18
+ /**
19
+ * Validates the entered password. Resolve to keep the overlay open on a
20
+ * wrong password (the SDK calls `controller.showError(...)` afterwards),
21
+ * or `accept()` to dismiss the overlay and resolve the prompt with the
22
+ * password the visitor typed.
23
+ */
24
+ authenticate: (password: string, controller: OverlayController) => Promise<{
25
+ ok: boolean;
26
+ }>;
27
+ }
28
+ export declare function promptForPassword(args: PasswordPromptArgs): Promise<string>;
29
+ export {};
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Vanilla DOM password overlay for password-protected public apps.
3
+ *
4
+ * The SDK can't assume the host app's React tree has mounted yet (this fires
5
+ * before any data call resolves), so the overlay is built with plain DOM
6
+ * APIs and self-contained inline styles. It rejects the boot promise only
7
+ * on `cancel()` — which is currently never wired to a button; the visitor
8
+ * either enters the correct password or leaves the page.
9
+ *
10
+ * One overlay at a time. Concurrent calls reuse the in-flight promise so a
11
+ * burst of failing API calls produces a single modal, not several stacked.
12
+ */
13
+ let activePrompt = null;
14
+ export function promptForPassword(args) {
15
+ if (activePrompt)
16
+ return activePrompt;
17
+ activePrompt = new Promise((resolve) => {
18
+ const root = document.createElement("div");
19
+ root.dataset.loticsAppPasswordGate = "true";
20
+ root.style.cssText = [
21
+ "position:fixed",
22
+ "inset:0",
23
+ "z-index:2147483647",
24
+ "background:rgba(15,23,42,0.55)",
25
+ "backdrop-filter:blur(8px)",
26
+ "-webkit-backdrop-filter:blur(8px)",
27
+ "display:flex",
28
+ "align-items:center",
29
+ "justify-content:center",
30
+ "padding:24px",
31
+ "font:14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Inter,sans-serif",
32
+ "color:#0f172a",
33
+ ].join(";");
34
+ const card = document.createElement("form");
35
+ card.setAttribute("role", "dialog");
36
+ card.setAttribute("aria-modal", "true");
37
+ card.setAttribute("aria-labelledby", "lotics-app-password-title");
38
+ card.style.cssText = [
39
+ "background:#fff",
40
+ "border-radius:14px",
41
+ "padding:28px",
42
+ "max-width:380px",
43
+ "width:100%",
44
+ "box-shadow:0 25px 50px -12px rgba(0,0,0,0.25)",
45
+ "display:flex",
46
+ "flex-direction:column",
47
+ "gap:16px",
48
+ ].join(";");
49
+ const title = document.createElement("h2");
50
+ title.id = "lotics-app-password-title";
51
+ title.textContent = "Password required";
52
+ title.style.cssText = "margin:0;font-size:18px;font-weight:600;letter-spacing:-0.01em;";
53
+ const subtitle = document.createElement("p");
54
+ subtitle.textContent = "Enter the password to access this app.";
55
+ subtitle.style.cssText = "margin:0;color:#475569;font-size:13.5px;";
56
+ const input = document.createElement("input");
57
+ input.type = "password";
58
+ input.autocomplete = "current-password";
59
+ input.required = true;
60
+ input.placeholder = "Password";
61
+ input.setAttribute("aria-label", "Password");
62
+ input.style.cssText = [
63
+ "width:100%",
64
+ "box-sizing:border-box",
65
+ "padding:10px 12px",
66
+ "border:1px solid #cbd5e1",
67
+ "border-radius:8px",
68
+ "font-size:14px",
69
+ "outline:none",
70
+ "transition:border-color 0.15s,box-shadow 0.15s",
71
+ ].join(";");
72
+ input.addEventListener("focus", () => {
73
+ input.style.borderColor = "#2563eb";
74
+ input.style.boxShadow = "0 0 0 3px rgba(37,99,235,0.18)";
75
+ });
76
+ input.addEventListener("blur", () => {
77
+ input.style.borderColor = "#cbd5e1";
78
+ input.style.boxShadow = "none";
79
+ });
80
+ const error = document.createElement("div");
81
+ error.setAttribute("role", "alert");
82
+ error.style.cssText = "color:#dc2626;font-size:13px;min-height:18px;";
83
+ const submit = document.createElement("button");
84
+ submit.type = "submit";
85
+ submit.textContent = "Continue";
86
+ submit.style.cssText = [
87
+ "padding:10px 12px",
88
+ "background:#0f172a",
89
+ "color:#fff",
90
+ "border:none",
91
+ "border-radius:8px",
92
+ "font-size:14px",
93
+ "font-weight:500",
94
+ "cursor:pointer",
95
+ "transition:background 0.15s,opacity 0.15s",
96
+ ].join(";");
97
+ const setSubmitting = (s) => {
98
+ submit.disabled = s;
99
+ input.disabled = s;
100
+ submit.textContent = s ? "Checking…" : "Continue";
101
+ submit.style.opacity = s ? "0.7" : "1";
102
+ submit.style.cursor = s ? "wait" : "pointer";
103
+ };
104
+ const showError = (message) => {
105
+ error.textContent = message;
106
+ };
107
+ card.append(title, subtitle, input, error, submit);
108
+ root.append(card);
109
+ document.body.append(root);
110
+ // Defer focus until the element is in layout — some browsers ignore focus
111
+ // on freshly-attached inputs without a microtask break.
112
+ queueMicrotask(() => input.focus());
113
+ card.addEventListener("submit", (e) => {
114
+ e.preventDefault();
115
+ const value = input.value;
116
+ if (!value) {
117
+ showError("Enter a password.");
118
+ return;
119
+ }
120
+ showError("");
121
+ setSubmitting(true);
122
+ args
123
+ .authenticate(value, { showError, setSubmitting })
124
+ .then((res) => {
125
+ if (res.ok) {
126
+ root.remove();
127
+ activePrompt = null;
128
+ resolve(value);
129
+ }
130
+ else {
131
+ setSubmitting(false);
132
+ input.select();
133
+ }
134
+ })
135
+ .catch((err) => {
136
+ setSubmitting(false);
137
+ showError(err instanceof Error ? err.message : "Something went wrong.");
138
+ });
139
+ });
140
+ });
141
+ return activePrompt;
142
+ }
package/dist/src/rpc.js CHANGED
@@ -1,23 +1,5 @@
1
- /**
2
- * RPC bridge for a custom-code app's data operations.
3
- *
4
- * An app reaches the Lotics API one of two ways, chosen automatically:
5
- *
6
- * - **Bridged** — the app is embedded by a Lotics host (the authenticated
7
- * product, or `lotics app dev`), which passes its origin via the
8
- * `?lotics_host=` query param. The host holds the session; the app posts
9
- * ops over postMessage and the host makes the API call. Used by internal
10
- * apps — the member's credentials must never reach the app.
11
- *
12
- * - **Standalone** — the app is served on its own at `<slug>.lotics.app`
13
- * with no host. It calls the public `/v1/apps/{id}/*` endpoints directly;
14
- * those are anonymous-accessible for a publicly-shared app. The app holds
15
- * no credentials, so there is nothing to protect.
16
- *
17
- * Wire protocol (bridged — must match `app_iframe_host.tsx`):
18
- * app → host: { id, op, payload }
19
- * host → app: { id, type: "result", data } | { id, type: "error", message }
20
- */
1
+ import { promptForPassword } from "./password_gate.js";
2
+ import { runUploadPipeline } from "./upload/pipeline.js";
21
3
  /** The embedding Lotics host's origin — present iff the app is bridged. */
22
4
  const hostOrigin = new URLSearchParams(window.location.search).get("lotics_host");
23
5
  export function rpc(op, payload) {
@@ -63,41 +45,134 @@ function rpcBridged(op, payload, host) {
63
45
  // Standalone apps run only at `<slug>.lotics.app` (production); dev apps are
64
46
  // always bridged by the `lotics app dev` wrapper.
65
47
  const API_BASE = "https://api.lotics.ai";
66
- /** Resolved once — the app_id this standalone app's data calls are keyed by. */
67
- let appIdPromise = null;
68
- function resolveAppId() {
69
- if (!appIdPromise) {
70
- const slug = window.location.hostname.split(".")[0];
71
- const attempt = apiCall("GET", `/v1/apps/by-subdomain/${encodeURIComponent(slug)}`).then((r) => r.app_id);
72
- // Don't cache a rejection — a transient failure on first load would
73
- // otherwise brick every later data call for the session. Clear the slot
74
- // so the next call retries.
75
- attempt.catch(() => {
76
- if (appIdPromise === attempt)
77
- appIdPromise = null;
78
- });
79
- appIdPromise = attempt;
48
+ const PASSWORD_REQUIRED_CODE = "PASSWORD_REQUIRED";
49
+ /**
50
+ * Boot-time resolution result. Promise is shared so concurrent first calls
51
+ * (e.g. two `useQuery` hooks rendering simultaneously) coalesce into one
52
+ * `/by-subdomain` fetch and at most one password prompt.
53
+ */
54
+ let bootPromise = null;
55
+ let sessionToken = null;
56
+ function sessionStorageKey(appId) {
57
+ return `lotics_app_session:${appId}`;
58
+ }
59
+ function readStoredSession(appId) {
60
+ try {
61
+ const raw = window.localStorage.getItem(sessionStorageKey(appId));
62
+ if (!raw)
63
+ return null;
64
+ const parsed = JSON.parse(raw);
65
+ if (!parsed.token || !parsed.expires_at)
66
+ return null;
67
+ if (Date.parse(parsed.expires_at) <= Date.now()) {
68
+ window.localStorage.removeItem(sessionStorageKey(appId));
69
+ return null;
70
+ }
71
+ return parsed.token;
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ function writeStoredSession(appId, token, expiresAt) {
78
+ try {
79
+ window.localStorage.setItem(sessionStorageKey(appId), JSON.stringify({ token, expires_at: expiresAt }));
80
+ }
81
+ catch {
82
+ // localStorage unavailable (Safari private mode, quota): the token still
83
+ // lives in memory for this page lifetime — that's good enough for a single
84
+ // session, the next reload simply re-prompts.
85
+ }
86
+ }
87
+ function clearStoredSession(appId) {
88
+ try {
89
+ window.localStorage.removeItem(sessionStorageKey(appId));
80
90
  }
81
- return appIdPromise;
91
+ catch {
92
+ // ignore
93
+ }
94
+ }
95
+ async function boot() {
96
+ if (bootPromise)
97
+ return bootPromise;
98
+ const slug = window.location.hostname.split(".")[0];
99
+ const attempt = (async () => {
100
+ const info = (await apiCall("GET", `/v1/apps/by-subdomain/${encodeURIComponent(slug)}`));
101
+ if (info.requires_password) {
102
+ const stored = readStoredSession(info.app_id);
103
+ if (stored) {
104
+ sessionToken = stored;
105
+ }
106
+ else {
107
+ await acquireSessionToken(info.app_id);
108
+ }
109
+ }
110
+ return info;
111
+ })();
112
+ // Don't cache a rejection — a transient failure on first load would brick
113
+ // every later data call. Clear the slot so the next call retries.
114
+ attempt.catch(() => {
115
+ if (bootPromise === attempt)
116
+ bootPromise = null;
117
+ });
118
+ bootPromise = attempt;
119
+ return attempt;
120
+ }
121
+ async function acquireSessionToken(appId) {
122
+ await promptForPassword({
123
+ authenticate: async (password, controller) => {
124
+ try {
125
+ const res = (await apiCall("POST", `/v1/apps/${appId}/public/authenticate`, { password }, { skipAuth: true }));
126
+ sessionToken = res.session_token;
127
+ writeStoredSession(appId, res.session_token, res.expires_at);
128
+ return { ok: true };
129
+ }
130
+ catch (err) {
131
+ const message = err instanceof Error && err.message ? err.message : "Incorrect password.";
132
+ controller.showError(message);
133
+ return { ok: false };
134
+ }
135
+ },
136
+ });
82
137
  }
83
- async function apiCall(method, path, body) {
138
+ async function apiCall(method, path, body, opts) {
139
+ const headers = {};
140
+ if (body)
141
+ headers["content-type"] = "application/json";
142
+ if (sessionToken && !opts?.skipAuth) {
143
+ headers["authorization"] = `Bearer ${sessionToken}`;
144
+ }
84
145
  const res = await fetch(`${API_BASE}${path}`, {
85
146
  method,
86
- headers: body ? { "content-type": "application/json" } : undefined,
147
+ headers,
87
148
  body: body ? JSON.stringify(body) : undefined,
88
149
  });
89
150
  const text = await res.text();
90
- if (!res.ok) {
91
- let message = text;
151
+ let parsed = null;
152
+ if (text) {
92
153
  try {
93
- message = JSON.parse(text).message ?? text;
154
+ parsed = JSON.parse(text);
94
155
  }
95
156
  catch {
96
- // response body is not JSON — use it verbatim
157
+ // not JSON — body left as raw text
158
+ }
159
+ }
160
+ if (!res.ok) {
161
+ const errorBody = parsed;
162
+ // Password gate (re-)prompt: the stored token went stale because the owner
163
+ // rotated or cleared the password, or it was never present. Drop the
164
+ // current token, ask the visitor again, and retry the original call once.
165
+ if (res.status === 401 && errorBody?.error_code === PASSWORD_REQUIRED_CODE && !opts?.skipAuth) {
166
+ const appId = opts?.appId ?? (await boot()).app_id;
167
+ sessionToken = null;
168
+ clearStoredSession(appId);
169
+ await acquireSessionToken(appId);
170
+ return apiCall(method, path, body, { ...opts, appId });
97
171
  }
172
+ const message = errorBody?.message ?? text;
98
173
  throw new Error(message || `HTTP ${res.status}`);
99
174
  }
100
- return text ? JSON.parse(text) : {};
175
+ return parsed ?? (text ? text : {});
101
176
  }
102
177
  function rpcStandalone(op, payload) {
103
178
  switch (op) {
@@ -110,39 +185,22 @@ function rpcStandalone(op, payload) {
110
185
  }
111
186
  }
112
187
  async function standaloneQuery(p) {
113
- const appId = await resolveAppId();
114
- const r = (await apiCall("POST", `/v1/apps/${appId}/query`, {
115
- alias: p.alias,
116
- params: p.params,
117
- }));
188
+ const { app_id } = await boot();
189
+ const r = (await apiCall("POST", `/v1/apps/${app_id}/query`, { alias: p.alias, params: p.params }, { appId: app_id }));
118
190
  return { rows: r.rows ?? [] };
119
191
  }
120
192
  async function standaloneWorkflow(p) {
121
- const appId = await resolveAppId();
122
- return apiCall("POST", `/v1/apps/${appId}/workflows/${encodeURIComponent(p.alias)}/execute`, { inputs: p.inputs });
193
+ const { app_id } = await boot();
194
+ return apiCall("POST", `/v1/apps/${app_id}/workflows/${encodeURIComponent(p.alias)}/execute`, { inputs: p.inputs }, { appId: app_id });
123
195
  }
124
196
  async function standaloneUpload(file) {
125
197
  if (!(file instanceof File)) {
126
198
  throw new Error("upload payload must include a File");
127
199
  }
128
- const appId = await resolveAppId();
129
- const init = (await apiCall("POST", `/v1/apps/${appId}/files/upload-url`, {
130
- filename: file.name,
131
- mime_type: file.type,
132
- file_size: file.size,
133
- }));
134
- const put = await fetch(init.upload_url, {
135
- method: "PUT",
136
- body: file,
137
- headers: { "Content-Type": file.type },
200
+ const { app_id } = await boot();
201
+ const uploaded = await runUploadPipeline(file, {
202
+ initUpload: (input) => apiCall("POST", `/v1/apps/${app_id}/files/upload-url`, input, { appId: app_id }),
203
+ completeUpload: (input) => apiCall("POST", `/v1/apps/${app_id}/files/complete`, input, { appId: app_id }),
138
204
  });
139
- if (!put.ok) {
140
- throw new Error(`Storage upload failed (${put.status})`);
141
- }
142
- const done = (await apiCall("POST", `/v1/apps/${appId}/files/complete`, {
143
- file_id: init.file_id,
144
- file_storage_key: init.file_storage_key,
145
- filename: file.name,
146
- }));
147
- return done.file;
205
+ return uploaded;
148
206
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Browser-side image compression for app uploads.
3
+ *
4
+ * Phone-camera photos are huge (4–10 MB HEIC/JPEG) but documents only need
5
+ * legible bytes, not megapixel ones. Resize to ≤1280px on the long edge and
6
+ * re-encode as JPEG at q=0.75 before uploading — typically 10–20× smaller
7
+ * with no visible quality loss for the doc-scan use case.
8
+ *
9
+ * HEIC/HEIF / PNG / WebP are converted to JPEG. Non-image files (PDF, etc.)
10
+ * pass through unchanged. Falls through gracefully on any browser API gap
11
+ * — the original file is always returned as a usable upload candidate.
12
+ *
13
+ * Ported from `frontend/lib/upload_file_optimization.ts` so public-app forms
14
+ * get the same mobile-friendly upload behavior as the in-Lotics UI. Kept
15
+ * dependency-free (no logger, no UploadIntent abstraction) so the SDK ships
16
+ * as a single drop-in.
17
+ */
18
+ export type OptimizationReason = "optimized" | "skipped_unsupported_format" | "skipped_environment_unsupported" | "skipped_decode_unavailable" | "skipped_invalid_dimensions" | "skipped_small_dimensions" | "skipped_canvas_unavailable" | "skipped_canvas_type_mismatch";
19
+ export interface OptimizationResult {
20
+ file: File;
21
+ optimized: boolean;
22
+ reason: OptimizationReason;
23
+ originalSizeBytes: number;
24
+ optimizedSizeBytes: number;
25
+ width: number;
26
+ height: number;
27
+ targetWidth: number;
28
+ targetHeight: number;
29
+ }
30
+ export declare function optimizeImageForUpload(file: File): Promise<OptimizationResult>;
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Browser-side image compression for app uploads.
3
+ *
4
+ * Phone-camera photos are huge (4–10 MB HEIC/JPEG) but documents only need
5
+ * legible bytes, not megapixel ones. Resize to ≤1280px on the long edge and
6
+ * re-encode as JPEG at q=0.75 before uploading — typically 10–20× smaller
7
+ * with no visible quality loss for the doc-scan use case.
8
+ *
9
+ * HEIC/HEIF / PNG / WebP are converted to JPEG. Non-image files (PDF, etc.)
10
+ * pass through unchanged. Falls through gracefully on any browser API gap
11
+ * — the original file is always returned as a usable upload candidate.
12
+ *
13
+ * Ported from `frontend/lib/upload_file_optimization.ts` so public-app forms
14
+ * get the same mobile-friendly upload behavior as the in-Lotics UI. Kept
15
+ * dependency-free (no logger, no UploadIntent abstraction) so the SDK ships
16
+ * as a single drop-in.
17
+ */
18
+ const MAX_IMAGE_DIMENSION_PX = 1280;
19
+ const JPEG_QUALITY = 0.75;
20
+ const CONVERTIBLE_EXTENSION_PATTERN = /\.(heic|heif|png|webp)$/i;
21
+ export async function optimizeImageForUpload(file) {
22
+ const format = getOptimizableFormat(file);
23
+ const mimeType = format?.outputMimeType;
24
+ const unsupportedReason = getUnsupportedReason(file, mimeType);
25
+ if (unsupportedReason)
26
+ return unchanged(file, unsupportedReason);
27
+ if (!mimeType || !format)
28
+ return unchanged(file, "skipped_unsupported_format");
29
+ const loaded = await loadImage(file);
30
+ if (loaded.status === "decode_unavailable")
31
+ return unchanged(file, "skipped_decode_unavailable");
32
+ const { source, width, height } = loaded;
33
+ if (width <= 0 || height <= 0) {
34
+ closeSource(source);
35
+ return unchanged(file, "skipped_invalid_dimensions");
36
+ }
37
+ if (Math.max(width, height) <= MAX_IMAGE_DIMENSION_PX) {
38
+ closeSource(source);
39
+ return unchanged(file, "skipped_small_dimensions", width, height);
40
+ }
41
+ const { width: targetWidth, height: targetHeight } = scale(width, height);
42
+ const canvas = document.createElement("canvas");
43
+ canvas.width = targetWidth;
44
+ canvas.height = targetHeight;
45
+ const ctx = canvas.getContext("2d");
46
+ if (!ctx || typeof canvas.toBlob !== "function") {
47
+ closeSource(source);
48
+ return unchanged(file, "skipped_canvas_unavailable", width, height);
49
+ }
50
+ ctx.imageSmoothingEnabled = true;
51
+ ctx.imageSmoothingQuality = "high";
52
+ ctx.drawImage(source, 0, 0, targetWidth, targetHeight);
53
+ closeSource(source);
54
+ const blob = await canvasToBlob(canvas, mimeType);
55
+ if (blob.type !== mimeType) {
56
+ return unchanged(file, "skipped_canvas_type_mismatch", width, height, targetWidth, targetHeight);
57
+ }
58
+ return {
59
+ file: new File([blob], format.outputFilename, {
60
+ type: mimeType,
61
+ lastModified: file.lastModified,
62
+ }),
63
+ optimized: true,
64
+ reason: "optimized",
65
+ originalSizeBytes: file.size,
66
+ optimizedSizeBytes: blob.size,
67
+ width,
68
+ height,
69
+ targetWidth,
70
+ targetHeight,
71
+ };
72
+ }
73
+ function getUnsupportedReason(file, mimeType) {
74
+ void file;
75
+ if (mimeType === undefined)
76
+ return "skipped_unsupported_format";
77
+ if (typeof window === "undefined" ||
78
+ typeof document === "undefined" ||
79
+ typeof document.createElement !== "function" ||
80
+ typeof URL.createObjectURL !== "function" ||
81
+ typeof URL.revokeObjectURL !== "function" ||
82
+ typeof Image === "undefined") {
83
+ return "skipped_environment_unsupported";
84
+ }
85
+ return undefined;
86
+ }
87
+ function getOptimizableFormat(file) {
88
+ const mimeType = normalizeMimeType(file.type);
89
+ if (!mimeType)
90
+ return undefined;
91
+ if (mimeType === "image/jpeg") {
92
+ return { outputMimeType: "image/jpeg", outputFilename: file.name };
93
+ }
94
+ // PNG / WebP / HEIC / HEIF → JPEG (transparency lost on PNG; acceptable
95
+ // for document uploads where we explicitly opt into lossy compression).
96
+ return { outputMimeType: "image/jpeg", outputFilename: replaceExtensionWithJpeg(file.name) };
97
+ }
98
+ function normalizeMimeType(mimeType) {
99
+ const m = mimeType.toLowerCase();
100
+ if (m === "image/jpeg" || m === "image/jpg")
101
+ return "image/jpeg";
102
+ if (m === "image/heic" || m === "image/heif")
103
+ return m;
104
+ if (m === "image/png" || m === "image/webp")
105
+ return m;
106
+ return undefined;
107
+ }
108
+ function replaceExtensionWithJpeg(filename) {
109
+ if (CONVERTIBLE_EXTENSION_PATTERN.test(filename)) {
110
+ return filename.replace(CONVERTIBLE_EXTENSION_PATTERN, ".jpg");
111
+ }
112
+ return `${filename}.jpg`;
113
+ }
114
+ function unchanged(file, reason, width = 0, height = 0, targetWidth = width, targetHeight = height) {
115
+ return {
116
+ file,
117
+ optimized: false,
118
+ reason,
119
+ originalSizeBytes: file.size,
120
+ optimizedSizeBytes: file.size,
121
+ width,
122
+ height,
123
+ targetWidth,
124
+ targetHeight,
125
+ };
126
+ }
127
+ function closeSource(source) {
128
+ if ("close" in source && typeof source.close === "function")
129
+ source.close();
130
+ }
131
+ function scale(width, height) {
132
+ const largest = Math.max(width, height);
133
+ if (largest <= MAX_IMAGE_DIMENSION_PX)
134
+ return { width, height };
135
+ const factor = MAX_IMAGE_DIMENSION_PX / largest;
136
+ return {
137
+ width: Math.max(1, Math.round(width * factor)),
138
+ height: Math.max(1, Math.round(height * factor)),
139
+ };
140
+ }
141
+ async function loadImage(file) {
142
+ // Prefer createImageBitmap — off-main-thread decode, more reliable on
143
+ // mobile under memory pressure where Image() silently fails.
144
+ if (typeof createImageBitmap === "function") {
145
+ try {
146
+ const bitmap = await createImageBitmap(file);
147
+ return { status: "decoded", source: bitmap, width: bitmap.width, height: bitmap.height };
148
+ }
149
+ catch {
150
+ // unsupported format / corrupt — fall back to Image()
151
+ }
152
+ }
153
+ const objectUrl = URL.createObjectURL(file);
154
+ return new Promise((resolve) => {
155
+ const image = new Image();
156
+ const cleanup = () => {
157
+ image.onload = null;
158
+ image.onerror = null;
159
+ URL.revokeObjectURL(objectUrl);
160
+ };
161
+ image.onload = () => {
162
+ cleanup();
163
+ resolve({ status: "decoded", source: image, width: image.naturalWidth, height: image.naturalHeight });
164
+ };
165
+ image.onerror = () => {
166
+ cleanup();
167
+ resolve({ status: "decode_unavailable" });
168
+ };
169
+ image.src = objectUrl;
170
+ });
171
+ }
172
+ async function canvasToBlob(canvas, mimeType) {
173
+ return new Promise((resolve, reject) => {
174
+ canvas.toBlob((blob) => {
175
+ if (!blob) {
176
+ reject(new Error("Canvas export returned no data during upload optimization"));
177
+ return;
178
+ }
179
+ resolve(blob);
180
+ }, mimeType, JPEG_QUALITY);
181
+ });
182
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Orchestrator for the app-upload pipeline.
3
+ *
4
+ * File → optimize (image only) → request presigned URL →
5
+ * PUT to storage (with retry) → complete → ProcessedFile
6
+ *
7
+ * Each step is a focused module (see `optimize.ts`, `transport.ts`); this
8
+ * file just wires them together so `useFileUpload` callers get one robust
9
+ * "uploaded" promise instead of a bare-fetch happy path.
10
+ *
11
+ * Surface is intentionally minimal: the only knob is an optional
12
+ * `AbortSignal` for caller cancellation. Optimization, retries, and
13
+ * timeouts use platform-internal defaults — same conventions as the
14
+ * in-Lotics direct-upload pipeline.
15
+ */
16
+ interface UploadInitResponse {
17
+ upload_url: string;
18
+ file_id: string;
19
+ file_storage_key: string;
20
+ }
21
+ interface CompleteResponseFile {
22
+ id: string;
23
+ filename: string;
24
+ mime_type: string;
25
+ url?: string;
26
+ thumbnail_url?: string;
27
+ preview_url?: string;
28
+ }
29
+ /**
30
+ * Caller injects the RPC primitives so this module stays decoupled from the
31
+ * SDK's auth / bootstrap layer. The pipeline targets a specific app's upload
32
+ * endpoints via the supplied `initUpload` and `completeUpload`.
33
+ */
34
+ export interface UploadRpc {
35
+ initUpload(input: {
36
+ filename: string;
37
+ mime_type: string;
38
+ file_size: number;
39
+ }): Promise<UploadInitResponse>;
40
+ completeUpload(input: {
41
+ file_id: string;
42
+ file_storage_key: string;
43
+ filename: string;
44
+ }): Promise<{
45
+ file: CompleteResponseFile;
46
+ }>;
47
+ }
48
+ export interface RunUploadPipelineOptions {
49
+ signal?: AbortSignal;
50
+ }
51
+ export declare function runUploadPipeline(file: File, rpc: UploadRpc, options?: RunUploadPipelineOptions): Promise<CompleteResponseFile>;
52
+ export {};
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Orchestrator for the app-upload pipeline.
3
+ *
4
+ * File → optimize (image only) → request presigned URL →
5
+ * PUT to storage (with retry) → complete → ProcessedFile
6
+ *
7
+ * Each step is a focused module (see `optimize.ts`, `transport.ts`); this
8
+ * file just wires them together so `useFileUpload` callers get one robust
9
+ * "uploaded" promise instead of a bare-fetch happy path.
10
+ *
11
+ * Surface is intentionally minimal: the only knob is an optional
12
+ * `AbortSignal` for caller cancellation. Optimization, retries, and
13
+ * timeouts use platform-internal defaults — same conventions as the
14
+ * in-Lotics direct-upload pipeline.
15
+ */
16
+ import { optimizeImageForUpload } from "./optimize.js";
17
+ import { putToStorageWithRetry } from "./transport.js";
18
+ export async function runUploadPipeline(file, rpc, options = {}) {
19
+ const { signal } = options;
20
+ // 1. Compress images. Non-image files return unchanged. Failures here are
21
+ // intentionally swallowed — the user shouldn't see an upload error
22
+ // because canvas threw; we just upload the original bytes.
23
+ let candidate = file;
24
+ try {
25
+ const optimized = await optimizeImageForUpload(file);
26
+ candidate = optimized.file;
27
+ }
28
+ catch {
29
+ // fall through with original file
30
+ }
31
+ if (signal?.aborted)
32
+ throw new Error("Upload aborted");
33
+ // 2. Ask the backend for a presigned upload URL.
34
+ const init = await rpc.initUpload({
35
+ filename: candidate.name,
36
+ mime_type: candidate.type,
37
+ file_size: candidate.size,
38
+ });
39
+ // 3. PUT the bytes with timeout + retry. This is the mobile-network
40
+ // failure point.
41
+ const putResponse = await putToStorageWithRetry(init.upload_url, candidate, signal);
42
+ if (!putResponse.ok) {
43
+ throw new Error(`Storage upload failed (${putResponse.status}). Please check your connection and try again.`);
44
+ }
45
+ // 4. Finalize — the server validates the object and creates the file row.
46
+ const { file: uploaded } = await rpc.completeUpload({
47
+ file_id: init.file_id,
48
+ file_storage_key: init.file_storage_key,
49
+ filename: candidate.name,
50
+ });
51
+ return uploaded;
52
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Transport-layer helpers for the SDK upload pipeline.
3
+ *
4
+ * Two concerns this module owns:
5
+ *
6
+ * 1. **Per-request timeout.** A bare `fetch` hangs forever if the network is
7
+ * unreachable; we wrap it in an `AbortController` so a stuck request fails
8
+ * after `UPLOAD_TIMEOUT_MS` instead of leaving the user staring at a
9
+ * spinner.
10
+ *
11
+ * 2. **Retry with exponential backoff** for the presigned `PUT` to object
12
+ * storage — the single most failure-prone step on mobile networks. We
13
+ * retry on network errors / 5xx / timeouts up to `UPLOAD_PUT_MAX_ATTEMPTS`
14
+ * with 1s → 2s → 4s waits between attempts. We do NOT retry on 4xx
15
+ * (client error, retrying won't help) or on caller-initiated aborts.
16
+ *
17
+ * Mirrors the conventions in `frontend/lib/api_utils.ts` and
18
+ * `frontend/lib/file_upload.ts`, simplified for the SDK (no logger, no
19
+ * correlation headers).
20
+ */
21
+ export declare const UPLOAD_TIMEOUT_MS: number;
22
+ export declare class UploadTimeoutError extends Error {
23
+ readonly url: string;
24
+ readonly timeoutMs: number;
25
+ readonly name = "UploadTimeoutError";
26
+ constructor(url: string, timeoutMs: number);
27
+ }
28
+ export declare class UploadAbortedError extends Error {
29
+ readonly name = "UploadAbortedError";
30
+ constructor();
31
+ }
32
+ /** `fetch` with timeout + optional external `AbortSignal`. */
33
+ export declare function fetchWithTimeout(url: string, init: RequestInit, timeoutMs: number): Promise<Response>;
34
+ /**
35
+ * Presigned-PUT to object storage with retry + backoff.
36
+ *
37
+ * Retries network errors, timeouts, and 5xx responses up to
38
+ * `UPLOAD_PUT_MAX_ATTEMPTS` times. A 4xx response is returned to the caller
39
+ * unchanged (retrying a 403/400 won't help; the caller decides). Aborts
40
+ * propagate immediately without retry.
41
+ */
42
+ export declare function putToStorageWithRetry(uploadUrl: string, file: File, signal: AbortSignal | undefined): Promise<Response>;
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Transport-layer helpers for the SDK upload pipeline.
3
+ *
4
+ * Two concerns this module owns:
5
+ *
6
+ * 1. **Per-request timeout.** A bare `fetch` hangs forever if the network is
7
+ * unreachable; we wrap it in an `AbortController` so a stuck request fails
8
+ * after `UPLOAD_TIMEOUT_MS` instead of leaving the user staring at a
9
+ * spinner.
10
+ *
11
+ * 2. **Retry with exponential backoff** for the presigned `PUT` to object
12
+ * storage — the single most failure-prone step on mobile networks. We
13
+ * retry on network errors / 5xx / timeouts up to `UPLOAD_PUT_MAX_ATTEMPTS`
14
+ * with 1s → 2s → 4s waits between attempts. We do NOT retry on 4xx
15
+ * (client error, retrying won't help) or on caller-initiated aborts.
16
+ *
17
+ * Mirrors the conventions in `frontend/lib/api_utils.ts` and
18
+ * `frontend/lib/file_upload.ts`, simplified for the SDK (no logger, no
19
+ * correlation headers).
20
+ */
21
+ export const UPLOAD_TIMEOUT_MS = 5 * 60 * 1000;
22
+ const UPLOAD_PUT_MAX_ATTEMPTS = 3;
23
+ const UPLOAD_PUT_BACKOFF_BASE_MS = 1000;
24
+ export class UploadTimeoutError extends Error {
25
+ url;
26
+ timeoutMs;
27
+ name = "UploadTimeoutError";
28
+ constructor(url, timeoutMs) {
29
+ super(`Upload request to ${url} timed out after ${timeoutMs}ms`);
30
+ this.url = url;
31
+ this.timeoutMs = timeoutMs;
32
+ }
33
+ }
34
+ export class UploadAbortedError extends Error {
35
+ name = "UploadAbortedError";
36
+ constructor() {
37
+ super("Upload aborted");
38
+ }
39
+ }
40
+ /** `fetch` with timeout + optional external `AbortSignal`. */
41
+ export async function fetchWithTimeout(url, init, timeoutMs) {
42
+ const controller = new AbortController();
43
+ let didTimeout = false;
44
+ const timeoutId = setTimeout(() => {
45
+ didTimeout = true;
46
+ controller.abort();
47
+ }, timeoutMs);
48
+ if (init.signal) {
49
+ if (init.signal.aborted) {
50
+ clearTimeout(timeoutId);
51
+ throw new UploadAbortedError();
52
+ }
53
+ init.signal.addEventListener("abort", () => controller.abort(), { once: true });
54
+ }
55
+ try {
56
+ return await fetch(url, { ...init, signal: controller.signal });
57
+ }
58
+ catch (err) {
59
+ if (didTimeout)
60
+ throw new UploadTimeoutError(url, timeoutMs);
61
+ if (init.signal?.aborted)
62
+ throw new UploadAbortedError();
63
+ throw err;
64
+ }
65
+ finally {
66
+ clearTimeout(timeoutId);
67
+ }
68
+ }
69
+ /**
70
+ * Presigned-PUT to object storage with retry + backoff.
71
+ *
72
+ * Retries network errors, timeouts, and 5xx responses up to
73
+ * `UPLOAD_PUT_MAX_ATTEMPTS` times. A 4xx response is returned to the caller
74
+ * unchanged (retrying a 403/400 won't help; the caller decides). Aborts
75
+ * propagate immediately without retry.
76
+ */
77
+ export async function putToStorageWithRetry(uploadUrl, file, signal) {
78
+ let lastError;
79
+ for (let attempt = 1; attempt <= UPLOAD_PUT_MAX_ATTEMPTS; attempt += 1) {
80
+ try {
81
+ const response = await fetchWithTimeout(uploadUrl, {
82
+ method: "PUT",
83
+ headers: { "Content-Type": file.type },
84
+ body: file,
85
+ signal,
86
+ }, UPLOAD_TIMEOUT_MS);
87
+ if (response.ok)
88
+ return response;
89
+ // 4xx is terminal — retrying a malformed/expired URL is pointless.
90
+ if (response.status >= 400 && response.status < 500)
91
+ return response;
92
+ // 5xx — fall through to retry.
93
+ lastError = new Error(`Storage PUT returned ${response.status}`);
94
+ }
95
+ catch (err) {
96
+ // Caller-initiated abort: stop immediately.
97
+ if (err instanceof UploadAbortedError)
98
+ throw err;
99
+ lastError = err;
100
+ }
101
+ if (attempt < UPLOAD_PUT_MAX_ATTEMPTS) {
102
+ const delayMs = UPLOAD_PUT_BACKOFF_BASE_MS * 2 ** (attempt - 1);
103
+ await sleep(delayMs, signal);
104
+ }
105
+ }
106
+ throw lastError instanceof Error ? lastError : new Error("Storage PUT failed");
107
+ }
108
+ function sleep(ms, signal) {
109
+ return new Promise((resolve, reject) => {
110
+ if (signal?.aborted) {
111
+ reject(new UploadAbortedError());
112
+ return;
113
+ }
114
+ const timeoutId = setTimeout(() => {
115
+ cleanup();
116
+ resolve();
117
+ }, ms);
118
+ const cleanup = () => {
119
+ clearTimeout(timeoutId);
120
+ signal?.removeEventListener("abort", onAbort);
121
+ };
122
+ const onAbort = () => {
123
+ cleanup();
124
+ reject(new UploadAbortedError());
125
+ };
126
+ signal?.addEventListener("abort", onAbort, { once: true });
127
+ });
128
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {