@lotics/app-sdk 0.5.0 → 0.7.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.
@@ -56,4 +56,37 @@ type UseQueryParams<K extends keyof AppQueries & string> = AppQueries[K] extends
56
56
  */
57
57
  export declare function useQuery<K extends keyof AppQueries & string>(alias: K, ...rest: UseQueryParams<K>): QueryState<Record<string, unknown>>;
58
58
  export declare function useQuery(alias: string, params?: Record<string, unknown>): QueryState<Record<string, unknown>>;
59
+ /** A file the host has stored and resolved serving URLs for. */
60
+ export interface UploadedFile {
61
+ id: string;
62
+ filename: string;
63
+ mime_type: string;
64
+ url?: string;
65
+ thumbnail_url?: string;
66
+ }
67
+ interface FileUploadState {
68
+ /**
69
+ * Upload one file. Resolves to the stored file; pass `UploadedFile.id` into
70
+ * a `useWorkflow` call to attach it to a record. Rejects on failure — the
71
+ * file is never partially stored.
72
+ */
73
+ upload: (file: File) => Promise<UploadedFile>;
74
+ /** True while any upload from this hook is in flight. */
75
+ uploading: boolean;
76
+ /** Message of the most recent failed upload, cleared when a new one starts. */
77
+ error: string | null;
78
+ }
79
+ /**
80
+ * Upload files from an app. The bytes are stored via a presigned
81
+ * direct-to-storage upload the host mediates; the API server never proxies
82
+ * them. Works the same in a public (anonymous) app and a member-facing one.
83
+ *
84
+ * ```tsx
85
+ * const { upload, uploading } = useFileUpload();
86
+ * const submit = useWorkflow("submitApplication");
87
+ * const cccd = await upload(file);
88
+ * await submit({ ...fields, cccd_file_id: cccd.id });
89
+ * ```
90
+ */
91
+ export declare function useFileUpload(): FileUploadState;
59
92
  export {};
package/dist/src/hooks.js CHANGED
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * Typed React hooks for Lotics app data access.
3
3
  *
4
- * The SDK surface is two hooks: `useQuery` for reads, `useWorkflow` for every
5
- * mutation. App code never writes records directly all writes flow through
6
- * declared workflows, which gives the app owner a typed, audited chokepoint
7
- * and means a publicly-shared app exposes no anonymous direct-write path.
4
+ * The SDK surface is three hooks: `useQuery` for reads, `useWorkflow` for
5
+ * every mutation, and `useFileUpload` for attaching files. App code never
6
+ * writes records directly all writes flow through declared workflows, which
7
+ * gives the app owner a typed, audited chokepoint and means a publicly-shared
8
+ * app exposes no anonymous direct-write path. An uploaded file is inert until
9
+ * a workflow attaches it, so file upload keeps that same property.
8
10
  *
9
11
  * Every hook is a thin wrapper over the postMessage RPC bridge — the parent
10
12
  * does the actual API calls, results stream back through `rpc()`. Hooks manage
@@ -53,3 +55,35 @@ export function useQuery(alias, params) {
53
55
  }, []);
54
56
  return { ...state, refetch };
55
57
  }
58
+ /**
59
+ * Upload files from an app. The bytes are stored via a presigned
60
+ * direct-to-storage upload the host mediates; the API server never proxies
61
+ * them. Works the same in a public (anonymous) app and a member-facing one.
62
+ *
63
+ * ```tsx
64
+ * const { upload, uploading } = useFileUpload();
65
+ * const submit = useWorkflow("submitApplication");
66
+ * const cccd = await upload(file);
67
+ * await submit({ ...fields, cccd_file_id: cccd.id });
68
+ * ```
69
+ */
70
+ export function useFileUpload() {
71
+ const [inFlight, setInFlight] = useState(0);
72
+ const [error, setError] = useState(null);
73
+ const upload = useCallback(async (file) => {
74
+ setInFlight((n) => n + 1);
75
+ setError(null);
76
+ try {
77
+ return await rpc("upload", { file });
78
+ }
79
+ catch (err) {
80
+ const message = err instanceof Error ? err.message : "Upload failed";
81
+ setError(message);
82
+ throw err;
83
+ }
84
+ finally {
85
+ setInFlight((n) => n - 1);
86
+ }
87
+ }, []);
88
+ return { upload, uploading: inFlight > 0, error };
89
+ }
@@ -10,7 +10,8 @@
10
10
  * depending on packages/ui's React Native Web setup.
11
11
  */
12
12
  export { mount } from "./mount.js";
13
- export { useWorkflow, useQuery } from "./hooks.js";
13
+ export { useWorkflow, useQuery, useFileUpload } from "./hooks.js";
14
+ export type { UploadedFile } from "./hooks.js";
14
15
  export { rpc } from "./rpc.js";
15
16
  export type { RpcOp } from "./rpc.js";
16
17
  export type { AppWorkflows, AppQueries } from "./types.js";
package/dist/src/index.js CHANGED
@@ -10,5 +10,5 @@
10
10
  * depending on packages/ui's React Native Web setup.
11
11
  */
12
12
  export { mount } from "./mount.js";
13
- export { useWorkflow, useQuery } from "./hooks.js";
13
+ export { useWorkflow, useQuery, useFileUpload } from "./hooks.js";
14
14
  export { rpc } from "./rpc.js";
package/dist/src/rpc.d.ts CHANGED
@@ -1,17 +1,22 @@
1
1
  /**
2
- * postMessage RPC bridge to the parent Lotics frontend.
2
+ * RPC bridge for a custom-code app's data operations.
3
3
  *
4
- * The iframe runs `sandbox="allow-scripts"` with a unique (null) origin, so
5
- * direct fetch with credentials isn't possible. Every data operation flows
6
- * through the parent: the iframe posts an op + payload, the parent makes
7
- * the authenticated API call and posts the result back.
4
+ * An app reaches the Lotics API one of two ways, chosen automatically:
8
5
  *
9
- * Wire protocol (must match `frontend/features/app_ui/app_iframe_host.tsx`):
10
- * iframe parent: { id: number, op: string, payload: unknown }
11
- * parent iframe: { id: number, type: "result", data } | { id, type: "error", message }
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.
12
11
  *
13
- * Bumping protocol version requires a coordinated change in the parent.
14
- * Add new ops alongside existing ones; never repurpose them.
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 }
15
20
  */
16
- export type RpcOp = "query" | "workflow";
21
+ export type RpcOp = "query" | "workflow" | "upload";
17
22
  export declare function rpc<T = unknown>(op: RpcOp, payload: unknown): Promise<T>;
package/dist/src/rpc.js CHANGED
@@ -1,18 +1,30 @@
1
1
  /**
2
- * postMessage RPC bridge to the parent Lotics frontend.
2
+ * RPC bridge for a custom-code app's data operations.
3
3
  *
4
- * The iframe runs `sandbox="allow-scripts"` with a unique (null) origin, so
5
- * direct fetch with credentials isn't possible. Every data operation flows
6
- * through the parent: the iframe posts an op + payload, the parent makes
7
- * the authenticated API call and posts the result back.
4
+ * An app reaches the Lotics API one of two ways, chosen automatically:
8
5
  *
9
- * Wire protocol (must match `frontend/features/app_ui/app_iframe_host.tsx`):
10
- * iframe parent: { id: number, op: string, payload: unknown }
11
- * parent iframe: { id: number, type: "result", data } | { id, type: "error", message }
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.
12
11
  *
13
- * Bumping protocol version requires a coordinated change in the parent.
14
- * Add new ops alongside existing ones; never repurpose them.
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 }
15
20
  */
21
+ /** The embedding Lotics host's origin — present iff the app is bridged. */
22
+ const hostOrigin = new URLSearchParams(window.location.search).get("lotics_host");
23
+ export function rpc(op, payload) {
24
+ return hostOrigin
25
+ ? rpcBridged(op, payload, hostOrigin)
26
+ : rpcStandalone(op, payload);
27
+ }
16
28
  const pending = new Map();
17
29
  let nextRpcId = 0;
18
30
  let listenerInstalled = false;
@@ -21,10 +33,8 @@ function ensureListener() {
21
33
  return;
22
34
  listenerInstalled = true;
23
35
  window.addEventListener("message", (event) => {
24
- // Trust only messages from the parent window. The iframe's origin is null
25
- // (sandboxed), so we can't compare origins meaningfully — we trust the
26
- // window reference instead.
27
- if (event.source !== window.parent)
36
+ // Accept only messages from the parent window, at the host origin.
37
+ if (event.source !== window.parent || event.origin !== hostOrigin)
28
38
  return;
29
39
  const msg = event.data;
30
40
  if (!msg || typeof msg.id !== "number")
@@ -41,11 +51,98 @@ function ensureListener() {
41
51
  }
42
52
  });
43
53
  }
44
- export function rpc(op, payload) {
54
+ function rpcBridged(op, payload, host) {
45
55
  ensureListener();
46
56
  return new Promise((resolve, reject) => {
47
57
  const id = nextRpcId++;
48
58
  pending.set(id, { resolve: resolve, reject });
49
- window.parent.postMessage({ id, op, payload }, "*");
59
+ window.parent.postMessage({ id, op, payload }, host);
60
+ });
61
+ }
62
+ // ── Standalone transport ────────────────────────────────────────────────────
63
+ // Standalone apps run only at `<slug>.lotics.app` (production); dev apps are
64
+ // always bridged by the `lotics app dev` wrapper.
65
+ 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;
80
+ }
81
+ return appIdPromise;
82
+ }
83
+ async function apiCall(method, path, body) {
84
+ const res = await fetch(`${API_BASE}${path}`, {
85
+ method,
86
+ headers: body ? { "content-type": "application/json" } : undefined,
87
+ body: body ? JSON.stringify(body) : undefined,
88
+ });
89
+ const text = await res.text();
90
+ if (!res.ok) {
91
+ let message = text;
92
+ try {
93
+ message = JSON.parse(text).message ?? text;
94
+ }
95
+ catch {
96
+ // response body is not JSON — use it verbatim
97
+ }
98
+ throw new Error(message || `HTTP ${res.status}`);
99
+ }
100
+ return text ? JSON.parse(text) : {};
101
+ }
102
+ function rpcStandalone(op, payload) {
103
+ switch (op) {
104
+ case "query":
105
+ return standaloneQuery(payload);
106
+ case "workflow":
107
+ return standaloneWorkflow(payload);
108
+ case "upload":
109
+ return standaloneUpload(payload.file);
110
+ }
111
+ }
112
+ 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
+ }));
118
+ return { rows: r.rows ?? [] };
119
+ }
120
+ async function standaloneWorkflow(p) {
121
+ const appId = await resolveAppId();
122
+ return apiCall("POST", `/v1/apps/${appId}/workflows/${encodeURIComponent(p.alias)}/execute`, { inputs: p.inputs });
123
+ }
124
+ async function standaloneUpload(file) {
125
+ if (!(file instanceof File)) {
126
+ throw new Error("upload payload must include a File");
127
+ }
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 },
50
138
  });
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;
51
148
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.5.0",
3
+ "version": "0.7.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": {