@lotics/app-sdk 0.46.1 → 0.47.1
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/AGENTS.md +49 -397
- package/docs/ai.md +201 -0
- package/docs/data_fetching.md +349 -0
- package/docs/files.md +312 -0
- package/docs/members_and_options.md +307 -0
- package/docs/mutations.md +438 -0
- package/docs/navigation_and_state.md +289 -0
- package/docs/queries.md +916 -0
- package/docs/runtime.md +432 -0
- package/docs/security.md +116 -0
- package/package.json +3 -2
package/docs/runtime.md
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
# The app runtime
|
|
2
|
+
|
|
3
|
+
How a custom-code app boots and talks to the platform: **`mount()`** (the entry
|
|
4
|
+
point, including the design-time mock harness and automatic PostHog analytics),
|
|
5
|
+
the **two transports** the SDK switches between (embedded postMessage bridge vs.
|
|
6
|
+
standalone direct API — app code never branches), the raw **`rpc()`** escape
|
|
7
|
+
hatch, and the browser capabilities the sandbox would otherwise block —
|
|
8
|
+
**`openExternal`**, **`downloadFile`**, and **`requestGeofencedLocation`** /
|
|
9
|
+
**`isWithinZone`**. Ends with the contribution contract for the package itself
|
|
10
|
+
(publish chain, wiring a new RPC op, bundler constraints). Read this when wiring
|
|
11
|
+
an app's entry file, when a browser capability misbehaves inside the iframe,
|
|
12
|
+
when you need to drop below the hooks, or when changing the SDK.
|
|
13
|
+
|
|
14
|
+
## `mount()` — the entry point
|
|
15
|
+
|
|
16
|
+
```tsx
|
|
17
|
+
import { mount } from "@lotics/app-sdk";
|
|
18
|
+
import App from "./App";
|
|
19
|
+
|
|
20
|
+
mount(<App />);
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`mount(element: ReactNode, options?: MountOptions): void` (exact signature:
|
|
24
|
+
`dist/src/mount.d.ts`) is called **once** from the app's entry file. It:
|
|
25
|
+
|
|
26
|
+
1. **Registers the mock fixture**, if `options.fixture` was passed (below).
|
|
27
|
+
2. **Finds or creates `#root`.** Uses the `<div id="root">` from the starter's
|
|
28
|
+
`index.html`; if the bundler didn't ship one, it creates and appends it.
|
|
29
|
+
3. **Installs visible error handlers.** `window` `error` and
|
|
30
|
+
`unhandledrejection` listeners render a fixed red monospace banner (message +
|
|
31
|
+
stack) above the app — a render crash or an unhandled promise rejection is
|
|
32
|
+
visible in the iframe itself, even before any host telemetry is wired up.
|
|
33
|
+
Banners are informational only; they are not removed automatically.
|
|
34
|
+
4. **Renders the tree** with React 19's `createRoot`.
|
|
35
|
+
5. **Boots analytics** fire-and-forget (see [Automatic analytics](#automatic-analytics-posthog)).
|
|
36
|
+
Never awaited — it cannot delay first paint, and any failure leaves the app
|
|
37
|
+
fully functional and untracked.
|
|
38
|
+
|
|
39
|
+
### The mock harness (`options.fixture` + `?__mock=1`)
|
|
40
|
+
|
|
41
|
+
The optional second argument registers a demo/design-time fixture:
|
|
42
|
+
|
|
43
|
+
```tsx
|
|
44
|
+
mount(<App />, {
|
|
45
|
+
fixture: {
|
|
46
|
+
queries: {
|
|
47
|
+
orders: MOCK_ORDERS, // alias → rows, same aliases as package.json#lotics.queries
|
|
48
|
+
customers: MOCK_CUSTOMERS,
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Activation is a **two-step gate** — both must hold, so demo data shipping in the
|
|
55
|
+
bundle never leaks into normal traffic:
|
|
56
|
+
|
|
57
|
+
1. A fixture is registered via `mount({ fixture })` (`AppFixture` type:
|
|
58
|
+
`dist/src/mock.d.ts` — `{ queries?: Record<string, Array<Record<string, unknown>>> }`).
|
|
59
|
+
2. The page URL carries `?__mock=1` (exactly `1`). Without the flag the fixture
|
|
60
|
+
is completely inert.
|
|
61
|
+
|
|
62
|
+
When active, the [query hooks](./data_fetching.md) return `fixture.queries[alias]`
|
|
63
|
+
instead of making any request (`loading` stays `false`). **Partial mocking is
|
|
64
|
+
supported**: an alias absent from the fixture still flows through the real
|
|
65
|
+
transport. Calling `mount` again (HMR) replaces the registration last-write-wins.
|
|
66
|
+
|
|
67
|
+
- **Fixture rows stand in for wire rows.** They pass through the same code path
|
|
68
|
+
as fetched rows, so shape them exactly like the query's real output — the same
|
|
69
|
+
serialized cells your `row.*` / `readSelect` / `readFiles` readers decode —
|
|
70
|
+
or the readers will decode nothing.
|
|
71
|
+
- **Not mocked:** workflows (`useWorkflow`), uploads, `useFieldOptions`, members,
|
|
72
|
+
comments, agent runs. In mock mode those still hit the real transport;
|
|
73
|
+
mutations have side effects, so there is deliberately no workflow mock.
|
|
74
|
+
- **Analytics is disabled** whenever `?__mock=1` is present, fixture or not — a
|
|
75
|
+
screenshot/design-time load emits no events.
|
|
76
|
+
- The `__mock` param-name prefix is reserved by the SDK; don't use it for
|
|
77
|
+
[URL state](./navigation_and_state.md) keys.
|
|
78
|
+
|
|
79
|
+
**Warning:** anyone can append `?__mock=1` to a deployed app's URL and see the
|
|
80
|
+
fixture rows instead of real data. That is harmless by design — the fixture is
|
|
81
|
+
compiled into the publicly served bundle either way — but it means fixtures must
|
|
82
|
+
only ever contain invented data, never a copy of real records.
|
|
83
|
+
|
|
84
|
+
## The two transports
|
|
85
|
+
|
|
86
|
+
An app reaches the Lotics API one of two ways. The SDK picks **automatically**
|
|
87
|
+
at each call from the iframe URL: the embedding host passes its origin via the
|
|
88
|
+
reserved `?lotics_host=` query param — present (non-empty) means **embedded**,
|
|
89
|
+
absent means **standalone**. App code never branches on the mode; every hook and
|
|
90
|
+
helper works identically in both. `isEmbedded(): boolean` is exported
|
|
91
|
+
(`dist/src/rpc.d.ts`) for the rare display-level need, and the
|
|
92
|
+
[security doc](./security.md) explains what mode implies for identity.
|
|
93
|
+
|
|
94
|
+
| | **Embedded (bridged)** | **Standalone (direct)** |
|
|
95
|
+
|---|---|---|
|
|
96
|
+
| Where | Iframe inside the Lotics product, or the `lotics app dev` wrapper page | The app's own top-level page at `<slug>.lotics.app` |
|
|
97
|
+
| Who holds credentials | The **host** — the member's session never reaches the app | Nobody — the visitor is anonymous (an optional app password gates access, not identity) |
|
|
98
|
+
| How ops travel | `postMessage` to the parent frame; the host makes the API call with its session | `fetch` to the public `https://api.lotics.ai/v1/apps/{id}/*` endpoints |
|
|
99
|
+
| Viewer identity | The signed-in member (`useViewer`, comments, agent runs available) | `member_id` is `null`; members-only surfaces reject |
|
|
100
|
+
|
|
101
|
+
### Embedded: the postMessage bridge
|
|
102
|
+
|
|
103
|
+
The app posts `{ id, op, payload }` to `window.parent` targeted at the host
|
|
104
|
+
origin; the host replies `{ id, type: "result", data }` or
|
|
105
|
+
`{ id, type: "error", message }` (streaming agent runs use a multi-message
|
|
106
|
+
variant: `run-id`? → `stream-chunk`\* → `stream-end` | `error`). The bridge is
|
|
107
|
+
**origin-locked both ways**: the SDK only accepts messages whose `source` is the
|
|
108
|
+
parent window *and* whose `origin` equals the `lotics_host` value, and every
|
|
109
|
+
outgoing message is targeted at that origin — a third frame can neither inject
|
|
110
|
+
results nor observe payloads. A host error with no message rejects as
|
|
111
|
+
`"RPC failed"`.
|
|
112
|
+
|
|
113
|
+
**Warning:** a bridged call has **no timeout**. An op the host doesn't
|
|
114
|
+
implement does reject (`"Unknown RPC op: …"`), but a message the host drops
|
|
115
|
+
without replying (a crashed handler, a malformed envelope) leaves the returned
|
|
116
|
+
promise pending forever. Don't build UI that deadlocks awaiting an op you
|
|
117
|
+
haven't verified exists in the host (see
|
|
118
|
+
[op availability](#op-availability-by-transport)).
|
|
119
|
+
|
|
120
|
+
**Warning (dev loop):** `lotics app dev` prints a wrapper URL — always drive
|
|
121
|
+
*that* page. The wrapper embeds the app iframe with `?lotics_host=` and bridges
|
|
122
|
+
ops to the API; the bare Vite origin has no host param, so the SDK falls into
|
|
123
|
+
standalone mode, tries to resolve an app from the hostname, and every data call
|
|
124
|
+
fails. Dev apps are always bridged; standalone mode exists only on the deployed
|
|
125
|
+
`<slug>.lotics.app` host.
|
|
126
|
+
|
|
127
|
+
### Standalone: direct public API
|
|
128
|
+
|
|
129
|
+
On first data call the SDK resolves the app's identity from its own subdomain
|
|
130
|
+
(one shared `GET /v1/apps/by-subdomain/{slug}` fetch — concurrent first calls
|
|
131
|
+
coalesce; a transient failure isn't cached, the next call retries). If the app
|
|
132
|
+
is password-protected, the SDK renders its own full-screen password overlay
|
|
133
|
+
(plain DOM, so it works before React data arrives), exchanges the password for a
|
|
134
|
+
session token, and stores it in `localStorage` under
|
|
135
|
+
`lotics_app_session:<app_id>` with its expiry. The token rides as a `Bearer`
|
|
136
|
+
header on subsequent calls; a `401` with error code `PASSWORD_REQUIRED` (the
|
|
137
|
+
owner rotated or cleared the password) drops the stored token, re-prompts, and
|
|
138
|
+
retries the original call once. There is no cancel button — the visitor enters
|
|
139
|
+
the password or leaves. When `localStorage` is unavailable (private browsing,
|
|
140
|
+
quota) the token lives in memory for the page's lifetime and the next reload
|
|
141
|
+
re-prompts. What the password does and doesn't protect: [security](./security.md).
|
|
142
|
+
|
|
143
|
+
### Transport error semantics
|
|
144
|
+
|
|
145
|
+
Both transports guarantee an `Error.message` (or a `WorkflowResult.message`)
|
|
146
|
+
that is safe to render:
|
|
147
|
+
|
|
148
|
+
- A genuine JSON error response (a 4xx carrying `{ message }`) surfaces its
|
|
149
|
+
message verbatim.
|
|
150
|
+
- Any **5xx**, any **non-JSON body** (a gateway HTML error page), or a JSON body
|
|
151
|
+
without a `message` becomes a body-free, status-derived message:
|
|
152
|
+
- `524` → `"The request took too long to finish (gateway timeout). It may
|
|
153
|
+
still be running — check back in a moment, or try again."`
|
|
154
|
+
- other `5xx` → `"The service is temporarily unavailable. Please try again
|
|
155
|
+
shortly."`
|
|
156
|
+
- otherwise → `"The service returned an unexpected response. Please try again."`
|
|
157
|
+
- The `workflow` op **resolves instead of rejecting** on a transport/gateway
|
|
158
|
+
failure — you get `{ status: "error", message }`, uniform with a handled
|
|
159
|
+
workflow error. Always check `result.status`, not just `try/catch` — see
|
|
160
|
+
[mutations](./mutations.md).
|
|
161
|
+
|
|
162
|
+
### Op availability by transport
|
|
163
|
+
|
|
164
|
+
Most ops work everywhere; the exceptions are members-only or host-only
|
|
165
|
+
surfaces:
|
|
166
|
+
|
|
167
|
+
| Op | Embedded (product) | `lotics app dev` | Standalone |
|
|
168
|
+
|---|---|---|---|
|
|
169
|
+
| `query`, `field_options`, `workflow`, `members`, `context`, `upload`, `urlState.get/set`, `openExternal` | yes | yes | yes |
|
|
170
|
+
| `comments.*` | yes | yes | rejects — `"Comments are available only in embedded apps — a signed-in member is required."` |
|
|
171
|
+
| `agentRun` (streaming, internal to `useAgentRun`) | yes | yes | yes |
|
|
172
|
+
| `agentRuns`, `agentRun.get`, `agentRun.cancel` | yes | **no** — the dev forwarder doesn't implement them (`"Unknown RPC op: …"`) | yes |
|
|
173
|
+
| `askAi` | yes | **no** — `"Unknown RPC op: askAi"` | rejects — `"askAi is only available when the app runs inside Lotics"` |
|
|
174
|
+
|
|
175
|
+
**Limitation:** in the dev loop, run *history* and server-side *cancel* for
|
|
176
|
+
agent runs error (the local abort of a live stream still works); verify those
|
|
177
|
+
paths on a deployed app. The standalone `query` transport forwards only
|
|
178
|
+
`alias`/`params`/`limit`/`offset` — runtime `sort`/`filter`/`count` refinement
|
|
179
|
+
is embedded-only (see [data fetching](./data_fetching.md)).
|
|
180
|
+
|
|
181
|
+
## `rpc()` — the raw bridge (escape hatch)
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
import { rpc } from "@lotics/app-sdk";
|
|
185
|
+
|
|
186
|
+
const { rows } = await rpc<{ rows: Record<string, unknown>[] }>("query", {
|
|
187
|
+
alias: "orders",
|
|
188
|
+
params: {},
|
|
189
|
+
limit: 500,
|
|
190
|
+
offset: 1000,
|
|
191
|
+
});
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`rpc<T = unknown>(op: RpcOp, payload: unknown): Promise<T>` sends one op over
|
|
195
|
+
whichever transport is active. **Prefer the hooks** — they add SWR caching,
|
|
196
|
+
loading/error state, analytics, and typing from your declared aliases. `rpc()`
|
|
197
|
+
exists for the imperative cases the hooks don't model, chiefly paging a query to
|
|
198
|
+
exhaustion for a full export — `rpc("query", { alias, params, limit, offset })`
|
|
199
|
+
until a short page, then feed the rows to `downloadFile` (below). `T` is *your*
|
|
200
|
+
assertion — the SDK does not validate the result shape.
|
|
201
|
+
|
|
202
|
+
The full `RpcOp` union (`dist/src/rpc.d.ts`), each op's payload, and where its
|
|
203
|
+
semantics are documented:
|
|
204
|
+
|
|
205
|
+
| Op | Payload | Resolves to | Owning doc |
|
|
206
|
+
|---|---|---|---|
|
|
207
|
+
| `query` | `{ alias, params?, limit?, offset?, … }` | `{ rows }` | [queries](./queries.md), [data fetching](./data_fetching.md) |
|
|
208
|
+
| `field_options` | `{ alias }` | `{ fields }` | [members & options](./members_and_options.md) |
|
|
209
|
+
| `workflow` | `{ alias, inputs }` | `WorkflowResult` | [mutations](./mutations.md) |
|
|
210
|
+
| `upload` | `{ file: File }` (the `File` crosses the bridge by structured clone) | uploaded-file object | [files](./files.md) |
|
|
211
|
+
| `members` | `{ group? }` | `{ members }` | [members & options](./members_and_options.md) |
|
|
212
|
+
| `context` | `{}` | app identity (below) | this doc |
|
|
213
|
+
| `openExternal` | `{ url }` | `void` | this doc |
|
|
214
|
+
| `askAi` | prompt/files/records seed | `void` | [ai](./ai.md) |
|
|
215
|
+
| `agentRuns` | `{ session_id, limit?, offset? }` | `{ runs }` | [ai](./ai.md) |
|
|
216
|
+
| `agentRun.get` | `{ run_id }` | `{ run }` | [ai](./ai.md) |
|
|
217
|
+
| `agentRun.cancel` | `{ run_id }` | `{ ok: true }` | [ai](./ai.md) |
|
|
218
|
+
| `urlState.get` / `urlState.set` | — / `{ params }` | `UrlParams` / `void` | [navigation & state](./navigation_and_state.md) |
|
|
219
|
+
| `comments.list/create/update/delete/counts` | comment ops | comment payloads | [members & options](./members_and_options.md) |
|
|
220
|
+
|
|
221
|
+
The **streaming** agent-run op is *not* reachable through `rpc()` — its
|
|
222
|
+
response is a chunk stream, not a single value; it's internal to `useAgentRun`.
|
|
223
|
+
|
|
224
|
+
`rpc("context", {})` resolves the app's identity: `{ app_id, app_name,
|
|
225
|
+
workspace_id, organization_id, member_id, comments_enabled }`. `member_id` is
|
|
226
|
+
the signed-in member when embedded, `null` standalone. **Limitation:** the
|
|
227
|
+
context type is not exported from the package root — type the result yourself
|
|
228
|
+
via the `rpc<T>` generic.
|
|
229
|
+
|
|
230
|
+
## `openExternal()` — open a link in a new tab
|
|
231
|
+
|
|
232
|
+
```tsx
|
|
233
|
+
import { openExternal } from "@lotics/app-sdk";
|
|
234
|
+
await openExternal(result.files[0].url);
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
`openExternal(url: string): Promise<void>` (`dist/src/open_external.d.ts`). The
|
|
238
|
+
embedded app iframe is sandboxed **without `allow-popups`**, so a direct
|
|
239
|
+
`window.open` from app code is *silently dropped* — no error, nothing happens.
|
|
240
|
+
`openExternal` routes the URL to whoever can actually open it: the un-sandboxed
|
|
241
|
+
host frame (embedded — including the dev wrapper) or the app's own top-level
|
|
242
|
+
page (standalone). Opens in a new tab with `noopener,noreferrer`.
|
|
243
|
+
|
|
244
|
+
- **Scheme validation at the point of open**: only `http:` and `https:` are
|
|
245
|
+
allowed; any other scheme (`javascript:`, `data:`, …) rejects the promise with
|
|
246
|
+
`openExternal: unsupported URL scheme "<protocol>"`, and a non-string `url`
|
|
247
|
+
rejects with `openExternal requires a url string`. The opener re-validates on
|
|
248
|
+
its own side — a URL handed across the bridge is never trusted.
|
|
249
|
+
- Typical use: opening a workflow-generated file's `url` from
|
|
250
|
+
`WorkflowResult.files[]` (see [mutations](./mutations.md)).
|
|
251
|
+
- **Not a preview mechanism.** To *view* a file inline, use `@lotics/ui`'s
|
|
252
|
+
`FilePreview`/`FileGalleryModal` (see [files](./files.md)); `openExternal` is
|
|
253
|
+
"leave the app".
|
|
254
|
+
|
|
255
|
+
## `downloadFile()` — save browser-built bytes
|
|
256
|
+
|
|
257
|
+
```tsx
|
|
258
|
+
import { downloadFile } from "@lotics/app-sdk";
|
|
259
|
+
import { buildDataWorkbook, exportWorkbook } from "@lotics/xlsx";
|
|
260
|
+
|
|
261
|
+
function onExportClick() {
|
|
262
|
+
const wb = buildDataWorkbook({ columns, rows });
|
|
263
|
+
downloadFile(
|
|
264
|
+
"report.xlsx",
|
|
265
|
+
exportWorkbook(wb),
|
|
266
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
`downloadFile(filename: string, data: Uint8Array | Blob | string, mimeType?:
|
|
272
|
+
string): void` (`dist/src/download.d.ts`) saves bytes the app generated **in the
|
|
273
|
+
browser** (an exported .xlsx, a CSV, generated text) to the visitor's device.
|
|
274
|
+
It is the client-side counterpart to the server path (workflow generates a file
|
|
275
|
+
→ `WorkflowResult.files[].url` → `openExternal`).
|
|
276
|
+
|
|
277
|
+
- **Pure DOM, no RPC.** The embed sandbox includes `allow-downloads`, so a
|
|
278
|
+
same-origin blob download triggered from a user gesture needs no host
|
|
279
|
+
mediation; standalone pages download natively.
|
|
280
|
+
- **Call it synchronously from the click handler that produced the bytes** so
|
|
281
|
+
the browser attributes the download to the user gesture. Build the bytes in
|
|
282
|
+
the handler, then call `downloadFile` — an `await` between the click and the
|
|
283
|
+
call can void the gesture and get the download blocked.
|
|
284
|
+
- `mimeType` defaults to `application/octet-stream`. The blob URL is revoked on
|
|
285
|
+
the next task, after the download has started.
|
|
286
|
+
|
|
287
|
+
## Device location & geofencing
|
|
288
|
+
|
|
289
|
+
The host grants the app iframe the `geolocation` Permissions-Policy, so app code
|
|
290
|
+
reads the device position **directly through the browser** — no RPC. The browser
|
|
291
|
+
permission prompt appears as usual (attributed to the embedding site when
|
|
292
|
+
embedded). Exact signatures: `dist/src/geolocation.d.ts`.
|
|
293
|
+
|
|
294
|
+
**`isWithinZone(latitude, longitude, zone): boolean`** — pure great-circle
|
|
295
|
+
(haversine) check: is the point within `zone.radius` meters of
|
|
296
|
+
`zone.coordinates`? `GeofenceZone` is `{ coordinates: [latitude, longitude],
|
|
297
|
+
radius: number /* meters */ }`.
|
|
298
|
+
|
|
299
|
+
**`requestGeofencedLocation(zones: GeofenceZone[], opts?): Promise<GeofenceOutcome>`**
|
|
300
|
+
gets a position fix and checks it against the allowed zones, returning a
|
|
301
|
+
**structured outcome** — never a thrown permission error — so the app renders
|
|
302
|
+
its own guidance in its own language:
|
|
303
|
+
|
|
304
|
+
```tsx
|
|
305
|
+
const r = await requestGeofencedLocation(ALLOWED_SITES);
|
|
306
|
+
if (!r.ok) return showLocationGuidance(r.reason); // "denied" | "unavailable" | "outside"
|
|
307
|
+
await checkIn({ latitude: r.coords.latitude, longitude: r.coords.longitude });
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
| Outcome | Meaning |
|
|
311
|
+
|---|---|
|
|
312
|
+
| `{ ok: true, coords }` | A fix landed inside at least one zone (or `zones` is empty). `coords` = `{ latitude, longitude, accuracy /* meters, browser-reported */ }`. |
|
|
313
|
+
| `{ ok: false, reason: "denied" }` | Location permission not granted. A hard denial is fast-pathed via the Permissions API (where available) — no hang until timeout, and the browser will not re-prompt once blocked. |
|
|
314
|
+
| `{ ok: false, reason: "unavailable" }` | No fix: geolocation unsupported, location services off, or the request timed out (`opts.timeoutMs`, default 15 000 ms). |
|
|
315
|
+
| `{ ok: false, reason: "outside" }` | A fix was obtained but falls in no zone. |
|
|
316
|
+
|
|
317
|
+
- An **empty `zones` array means "no geofence"** — resolves `ok` with the raw
|
|
318
|
+
position; use it as a plain location read.
|
|
319
|
+
- The fix is standard accuracy (high-accuracy mode is not requested) and never
|
|
320
|
+
served from cache.
|
|
321
|
+
- **Warning — the gate is advisory, client-side UX, not security.** It prevents
|
|
322
|
+
honest-user mistakes; it cannot stop a tampered client, and the server never
|
|
323
|
+
verifies position. Pass `coords` into a workflow input to *record* where an
|
|
324
|
+
action happened; any real authorization belongs in the workflow
|
|
325
|
+
(see [security](./security.md)).
|
|
326
|
+
- **A blocked outcome leaves no server-side trace** — the action never ran, so
|
|
327
|
+
there is no workflow log to find. If field failures need diagnosing, surface
|
|
328
|
+
the `reason` visibly in the UI.
|
|
329
|
+
|
|
330
|
+
## Automatic analytics (PostHog)
|
|
331
|
+
|
|
332
|
+
`mount()` boots a PostHog instance per app — apps are a separate cross-origin
|
|
333
|
+
bundle, invisible to the product's own analytics. **No per-app wiring**: don't
|
|
334
|
+
install `posthog-js` or call any analytics API from app code.
|
|
335
|
+
|
|
336
|
+
- **Explicit events only.** Autocapture, pageviews, and session replay are off
|
|
337
|
+
(autocapture is disabled at the project level — a client flag could not
|
|
338
|
+
re-enable it). Data reads (`useQuery` fetches) are deliberately not events.
|
|
339
|
+
- Events the SDK emits automatically:
|
|
340
|
+
|
|
341
|
+
| Event | Fired when | Properties |
|
|
342
|
+
|---|---|---|
|
|
343
|
+
| `app_opened` | analytics finished initializing after `mount()` | — |
|
|
344
|
+
| `app_workflow_run` | each `useWorkflow` call settles | `alias`, `ok` |
|
|
345
|
+
| `app_file_uploaded` | a `useFileUpload` upload succeeds | `mime_type` |
|
|
346
|
+
| `app_agent_run` | a `useAgentRun` run finishes | `alias`, `ok` |
|
|
347
|
+
| `app_comment_created` | a comment is created via `useComments` | `has_files` |
|
|
348
|
+
| `app_comment_updated` / `app_comment_deleted` | comment edit/delete succeeds | — |
|
|
349
|
+
|
|
350
|
+
- Every event carries the app identity as super-properties (`app_id`,
|
|
351
|
+
`app_name`, `workspace_id`, `organization_id`) and rolls up under the
|
|
352
|
+
`organization` group. Embedded apps `identify` the signed-in member — app and
|
|
353
|
+
product events share one person; standalone visitors stay anonymous.
|
|
354
|
+
- Uncaught exceptions are captured (PostHog error tracking) in addition to
|
|
355
|
+
`mount()`'s visible banner.
|
|
356
|
+
- **Tracking is gated to the deployed app host** (`*.lotics.app`). `lotics app
|
|
357
|
+
dev` (localhost) and any `?__mock=1` load emit nothing. Best-effort
|
|
358
|
+
throughout: a failed init or a failed `context` resolution disables tracking
|
|
359
|
+
and never breaks the app. Events fired before init completes are buffered
|
|
360
|
+
(bounded) and drained on init.
|
|
361
|
+
- **Limitation:** there is no public API for custom app events — the capture
|
|
362
|
+
function is internal to the SDK. If a bespoke funnel matters, model it as a
|
|
363
|
+
workflow (which produces `app_workflow_run`) or request the surface as a
|
|
364
|
+
platform change.
|
|
365
|
+
- **Limitation:** PostHog's default bot/user-agent filter applies — headless
|
|
366
|
+
browsers (e.g. Playwright) are never tracked, so analytics cannot be verified
|
|
367
|
+
through headless automation.
|
|
368
|
+
|
|
369
|
+
## For package contributors
|
|
370
|
+
|
|
371
|
+
Everything below concerns changing `@lotics/app-sdk` itself (in the Lotics
|
|
372
|
+
monorepo), not building apps with it.
|
|
373
|
+
|
|
374
|
+
### The publish chain — nothing reaches apps without a version bump
|
|
375
|
+
|
|
376
|
+
Apps install the SDK from **npm**. Publish CI runs on pushes to `main` touching
|
|
377
|
+
the package and publishes **only when `package.json#version` differs from the
|
|
378
|
+
version on npm** — nothing else triggers it. `dist/`, `AGENTS.md`, and `docs/`
|
|
379
|
+
(this file) are what ship (`package.json#files`), so a docs-only fix needs the
|
|
380
|
+
same chain. A source or doc change reaches zero apps until:
|
|
381
|
+
|
|
382
|
+
1. the version is bumped and the change merges to `main` (publish CI builds and
|
|
383
|
+
publishes),
|
|
384
|
+
2. each app widens/updates its dependency range and reinstalls,
|
|
385
|
+
3. the app redeploys.
|
|
386
|
+
|
|
387
|
+
### Wiring a new RPC op — all four transports, or it 404s somewhere
|
|
388
|
+
|
|
389
|
+
A new bridge op must be implemented in every transport, or it works in one place
|
|
390
|
+
and breaks in another:
|
|
391
|
+
|
|
392
|
+
1. `packages/app-sdk/src/rpc.ts` — the `RpcOp` union **and** the standalone
|
|
393
|
+
(direct-API) implementation,
|
|
394
|
+
2. `frontend/features/app_ui/app_iframe_host.tsx` — the product host's bridge
|
|
395
|
+
handler,
|
|
396
|
+
3. `packages/sdk/src/dev/` — the `lotics app dev` wrapper page and its Node
|
|
397
|
+
forwarder (`rpc_handler.ts`),
|
|
398
|
+
4. the `LoticsClient` method (`packages/sdk/src/client.ts`) that forwarder
|
|
399
|
+
calls.
|
|
400
|
+
|
|
401
|
+
The backend endpoint deploys with the PR, but `lotics app dev` forwards to the
|
|
402
|
+
**production** API — a new op is not usable in the dev loop until the backend
|
|
403
|
+
has deployed. Skipping a transport produces exactly the gaps in the
|
|
404
|
+
[op availability table](#op-availability-by-transport) above — every "no" there
|
|
405
|
+
is a transport that wasn't wired.
|
|
406
|
+
|
|
407
|
+
### Bundler & dependency constraints
|
|
408
|
+
|
|
409
|
+
- **Pure ESM, browser-only.** Apps bundle the SDK with Vite. No `require()`,
|
|
410
|
+
no dynamic `import()`, no Node built-ins. Don't touch `window` at module top
|
|
411
|
+
level — resolve lazily (test environments import modules before `jsdom` is
|
|
412
|
+
ready).
|
|
413
|
+
- **Two bundlers in the family.** Apps build with Vite; the Lotics product
|
|
414
|
+
frontend builds with Metro (React Native Web). The SDK itself is never
|
|
415
|
+
Metro-bundled — the host frontend is forbidden from importing
|
|
416
|
+
`@lotics/app-sdk` (enforced by a dependency-direction test) — but sibling
|
|
417
|
+
packages apps share with the product (`@lotics/ui`, `@lotics/xlsx`,
|
|
418
|
+
`@lotics/docx`) build under both. When a shared package must diverge per
|
|
419
|
+
target, use platform files (`x.web.ts` / `x.ts`) plus conditional package
|
|
420
|
+
`exports` — never a runtime `require` or dynamic import.
|
|
421
|
+
- **Self-contained on npm.** The SDK cannot import workspace-private or
|
|
422
|
+
host-only packages (`@lotics/shared`, `@lotics/ui-internal`, the frontend's
|
|
423
|
+
`@/` alias) or any React Native / Expo module — also test-enforced. A helper
|
|
424
|
+
that exists privately elsewhere gets a local copy here, with a comment saying
|
|
425
|
+
why.
|
|
426
|
+
- **Data + RPC only — zero UI.** Never re-export a `@lotics/ui` component; the
|
|
427
|
+
SDK stays off the React-Native-Web dependency tree. Apps import `@lotics/ui`
|
|
428
|
+
directly.
|
|
429
|
+
- **Keep the dependency set minimal.** Runtime deps are `posthog-js` and `swr`;
|
|
430
|
+
`react`/`react-dom` are peers. `react-router-dom` is an **optional** peer
|
|
431
|
+
pulled in only by the `@lotics/app-sdk/router` subpath export — the root entry
|
|
432
|
+
must never import it.
|
package/docs/security.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Security: authority & scoping
|
|
2
|
+
|
|
3
|
+
Every data operation an app performs — queries, workflows, agent runs — executes under the **app owner's** IAM principal, not the viewing member's. That single fact drives the whole author-facing security contract: the platform decides *what the app can reach* (the owner's access), and **you** decide *what each caller gets* — per-user scoping, write attribution, and privilege gates are authored into query templates and workflow bodies, never inferred from the client. Read this before shipping any app that shows per-user data, performs writes on behalf of a member, gates an action to a role, or is shared publicly. Related: [queries](./queries.md) (the read surface these rules apply to), [mutations](./mutations.md) (workflows), [ai](./ai.md) (agent runs), [files](./files.md) (presigned URLs).
|
|
4
|
+
|
|
5
|
+
## The owner-principal model
|
|
6
|
+
|
|
7
|
+
| Surface | Runs under | Caller identity available? |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| Named queries (`useQuery`, the query RPC) | App owner | Yes — bound server-side into `is_current_member` / `current_member` filter predicates |
|
|
10
|
+
| Workflows (`useWorkflow`) | App owner | Yes — `runtime.triggered_by_member_id` in the workflow body (`null` for anonymous) |
|
|
11
|
+
| Agent runs (`useAgentRun`) | App owner | Yes — requires an authenticated member; runs are private to that member |
|
|
12
|
+
| Comments (`useComments`) | App authority for **access**; the **author** is always the real member | Always — members-only, anonymous callers are rejected |
|
|
13
|
+
|
|
14
|
+
Consequences of owner authority:
|
|
15
|
+
|
|
16
|
+
- **Data reach is the owner's.** A query reaches exactly the tables the owner can access; referencing a table the owner cannot reach fails with an "inaccessible tables" error.
|
|
17
|
+
- **Row security evaluates against the owner, not the viewer.** Table-level row scoping (private filters) is applied for the *owner's* identity. If the owner is an org admin/owner, no row scope applies at all — the query sees every row of every table it references. An app can therefore surface rows the viewing member's own table permissions would hide. That is the design: the owner *chose* to expose those columns and rows by declaring the query.
|
|
18
|
+
- **The viewer never widens or narrows a data op by existing.** A member with zero table access sees whatever the app's queries project, and a member with admin table access sees no more than that. `app:use` (or the public grant, below) is the only gate on invoking the app's declared surface.
|
|
19
|
+
- **Who the model protects against.** The boundary is not "what the owner can do" — the owner declared the queries and workflows, so their authority is intentional. The boundary is **what a caller can do**: any caller, including an anonymous public visitor, can only invoke the declared aliases with typed, per-input-constrained values. An owner weakening their own declared constraint is a footgun, not an escalation — but a *caller* gaining data or capability the aliases don't declare is a real vulnerability, and the sections below are how you prevent the ones the platform cannot detect for you.
|
|
20
|
+
|
|
21
|
+
Comments are the one deliberate exception: access is app-authority (any member with `app:use` + the app's declared `comments` capability may read/post on any record in the app's workspace, regardless of their own table access — a tenant floor rejects record ids outside the app's workspace), but the **author is always the real authenticated member** — never the app owner, never a "View as" subject — and edit/delete are author-only. Anonymous visitors cannot comment: the comment endpoints require an authenticated member.
|
|
22
|
+
|
|
23
|
+
## The caller boundary
|
|
24
|
+
|
|
25
|
+
Callers never submit query ASTs or workflow definitions — the server holds the canonical, deploy-validated template and the caller supplies only an **alias plus typed values**. Three per-input constraints are enforced server-side on **workflow and agent-run inputs** at invocation time, so a hand-crafted request can't redirect a run executing under owner authority:
|
|
26
|
+
|
|
27
|
+
| Input type | Server-enforced bound |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `member` (with a declared `group`) | Every submitted member id must belong to the declared group — the group scope is a write-time constraint, not a cosmetic picker filter |
|
|
30
|
+
| `record_link` | Every submitted record id must live in the declared table |
|
|
31
|
+
| `file` | Every submitted file id must live in the app's workspace — a foreign tenant's `file_id` is rejected |
|
|
32
|
+
|
|
33
|
+
Workflows and agent runs additionally execute behind a **workspace tenant floor**: every table a run touches must live in the app's own workspace, so no input value can steer a run at another workspace's data even under an admin owner's authority.
|
|
34
|
+
|
|
35
|
+
What the platform does **not** constrain is the *meaning* of your params. A query param is a value hole in the template (a filter value, a search term); the server checks its type only — the group/table bindings above apply to workflow and agent-run inputs, **not** to query params — never whether the value should have been derived from the caller's identity. That's the next section.
|
|
36
|
+
|
|
37
|
+
### The devtools test
|
|
38
|
+
|
|
39
|
+
Before shipping any query or workflow, ask: **could a member open the browser devtools, replay the app's RPC, and pass someone else's id?** Every declared alias is callable by every member with app access (and by *anyone*, if the app is public) with arbitrary typed values — the app's UI is not a boundary. If substituting another member's id (or another record's id) into a param would show that member's data or perform a write only they should trigger, the design has an IDOR. The fixes are always server-side:
|
|
40
|
+
|
|
41
|
+
- Reads scoped to "the current member" → an `is_current_member` filter **in the query template**, never a client-supplied member-id param.
|
|
42
|
+
- Writes attributed to the caller → `runtime.triggered_by_member_id` **in the workflow body**, never a client-supplied member input.
|
|
43
|
+
- Writes only some members may perform → `current_member_in_any_group(...)` **in the workflow body**, never a client-side role check.
|
|
44
|
+
|
|
45
|
+
## Scoping reads: `is_current_member` in the template
|
|
46
|
+
|
|
47
|
+
To show a member only their own rows, put the scoping in the query template itself — a filter condition with the `is_current_member` operator on a member field (or `is_not_current_member` for the inverse). The server binds the predicate to the signed-in viewer at execution time and the client receives only the surviving rows. A client-supplied `member_id` param achieves the same UI with none of the security: any member can replay the query with a different id and read that member's rows (the devtools test fails).
|
|
48
|
+
|
|
49
|
+
Who the server binds `is_current_member` to:
|
|
50
|
+
|
|
51
|
+
| Caller | Binds to |
|
|
52
|
+
|---|---|
|
|
53
|
+
| Authenticated member of the app's org | That member |
|
|
54
|
+
| Admin using "View as" (product UI, or `lotics app dev --view-as <member_id>`) | The **viewed** member — so an admin previews exactly what the member sees |
|
|
55
|
+
| Authenticated member of a *different* org (public app, cross-org) | Their own member id — which matches no member cells in the app's workspace, so self-scoped queries return no rows |
|
|
56
|
+
| Anonymous visitor (public app) | **The app owner** — see the public-app warning below |
|
|
57
|
+
|
|
58
|
+
For **manager-only reads**, the template can carry a requester-level predicate: `current_member` with the `in_any_group` operator and a list of group ids. It matches when the *viewer* belongs to any listed group — so a "pending approvals" query returns rows only to members of the approvers group, and returns nothing to everyone else. Pair it with the workflow-side gate below; the read hides the queue, the write gate enforces it.
|
|
59
|
+
|
|
60
|
+
## Attributing writes: `runtime.triggered_by_member_id`
|
|
61
|
+
|
|
62
|
+
Who performed a write is derived server-side. In the workflow body, `runtime.triggered_by_member_id` is the authenticated member who invoked the workflow — stamped by the server from the session, `null` when an anonymous visitor invoked it through a public app. Use it to fill "requested by" / "submitted by" fields and to attribute side effects.
|
|
63
|
+
|
|
64
|
+
A client-passed `member` input is **not** an identity: it's a spoofable value like any other param. The only thing the server binds a member input to is its *declared group* (the table above) — never to the caller. Use member inputs for genuine member *selection* (assign this order to a teammate), and `runtime.triggered_by_member_id` for *actor* identity.
|
|
65
|
+
|
|
66
|
+
Anonymous invocations are audited with a `public` actor; member invocations carry the member id in the change origin.
|
|
67
|
+
|
|
68
|
+
**"View as" does not carry into writes.** Under impersonation, *reads* bind the viewed member (the table above), but `runtime.triggered_by_member_id` — and the group gate below — bind the **real** authenticated member. An admin previewing a member's app stays attributed as themselves, and cannot acquire the viewed member's group privileges (nor lose their own).
|
|
69
|
+
|
|
70
|
+
## Gating privileged writes: `current_member_in_any_group`
|
|
71
|
+
|
|
72
|
+
A write only some members may perform — approve, override, close out — is authorized **in the workflow body**, because the workflow runs under owner authority and every member with app access can invoke it (devtools test). The workflow expression helper:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
current_member_in_any_group(["grp_abc123"])
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
returns `true` when the **triggering member** belongs to any of the listed groups, and is fail-closed: no triggering member (anonymous or system trigger), a member in no groups, or a deleted/unknown group id all yield `false`. Branch on it and fail the run (or route to a rejection path) when it's `false`.
|
|
79
|
+
|
|
80
|
+
**Model approvers as a group**, not as a hardcoded member id or a client-side check: a group survives personnel changes, is auditable, and is the unit both predicates understand — `current_member_in_any_group([...])` gates the write while `current_member in_any_group [...]` in a query template gates the matching read, so non-approvers never see the pending queue *and* can't act on it.
|
|
81
|
+
|
|
82
|
+
## Public apps: anonymous reach and its bounds
|
|
83
|
+
|
|
84
|
+
A publicly-shared app (`<slug>.lotics.app`, or its public link) is reachable by **anyone** — the public share grants `app:use` to anonymous visitors and to authenticated members of any other org alike. What anonymous visitors can and cannot do:
|
|
85
|
+
|
|
86
|
+
| Surface | Anonymous access |
|
|
87
|
+
|---|---|
|
|
88
|
+
| Named queries + field options | Yes — under owner authority |
|
|
89
|
+
| Workflow execution | Yes — under owner authority; `runtime.triggered_by_member_id` is `null`, audit actor is `public` |
|
|
90
|
+
| File upload (workflow `file` inputs) | Yes — bounded to the app's workspace |
|
|
91
|
+
| Query/workflow file outputs | Yes — file cells and workflow-produced files return direct presigned URLs (24-hour TTL) that anonymous viewers can fetch; see [files](./files.md) |
|
|
92
|
+
| Agent runs (`useAgentRun`) | **No** — rejected: agent runs require an authenticated member, and each member's run history is private to them (a guessed session id cannot read another member's thread) |
|
|
93
|
+
| Comments (`useComments`) | **No** — members-only |
|
|
94
|
+
| Member roster (`useMembers`) | **No** — same-org members only (below) |
|
|
95
|
+
|
|
96
|
+
**Warning — `is_current_member` on a public app resolves to the app owner.** An anonymous request has no viewer to bind, so both `is_current_member` and `current_member in_any_group` fall back to the owner's identity and the owner's groups. A "my items" query shows every anonymous visitor the *owner's* items, and a manager-only read template gated on a group the owner belongs to **passes for every anonymous visitor**. Never rely on viewer-bound predicates in a public app.
|
|
97
|
+
|
|
98
|
+
**Per-user data does not ship in a public app.** A public app has no visitor identity to scope by, so a free-param lookup — `getOrder(order_id)` — is an enumerable IDOR: anyone can walk the id space. No server mechanism detects a "sensitive param"; the only correct designs are (a) an **embedded** (authenticated) app with `is_current_member` scoping, or (b) a public app whose every query is safe to show to the whole internet. A shared **password** can be set on a public app (exchanged for a session token, rate-limited) — it gates *access to the app*, but password-holding visitors are still anonymous: no identity binds, all the fallbacks above still apply.
|
|
99
|
+
|
|
100
|
+
### Member roster access
|
|
101
|
+
|
|
102
|
+
Listing members (`useMembers`) is deliberately narrow, because an org roster with emails must not leak through an arbitrary app. Three gates, all server-enforced: (1) the caller must be an **authenticated member of the app's own org** with `app:use` — anonymous and cross-org callers get 403; (2) the app must have **declared member access** — at least one workflow input or query param of type `member`; an app that never works with members cannot enumerate the roster; (3) a `group_id` filter is honored only for a group **declared** on one of those member inputs — an app cannot enumerate groups it never uses. In query *results*, projected member cells resolve to `{ id, name }` for everyone; `email` is included only for authenticated members of the app's own org. See [members_and_options](./members_and_options.md).
|
|
103
|
+
|
|
104
|
+
## What runtime refinement cannot widen
|
|
105
|
+
|
|
106
|
+
The query RPC accepts runtime `filter` and `sort` (for search boxes, sortable tables, pickers) — but these are **bounded to the named query's output columns**. A `field_key` naming a column the query does not project is rejected, so a caller can never filter or sort by — and thereby probe — a field the author didn't expose. The caller's `limit` is clamped to the server row cap, params fill the template's *value holes* only — filter values and the search term; tables, joins, and projections are author-fixed — and `count` mode returns a single total over the same bounded filter. Full mechanics in [queries](./queries.md).
|
|
107
|
+
|
|
108
|
+
**Warning — templated free-text search is not output-bounded.** A `search` term in the query template (typically a `{{params.q}}` hole) matches against the record's **whole search document**: every searchable field of the table — text, numbers, dates (in three formats), select option names, member names, linked-record display text, formula/rollup/lookup values, and autonumbers (only booleans, buttons, and file fields are excluded). It is *not* restricted to the columns the query projects. A caller who controls the search term can therefore probe the *contents* of unprojected fields by watching which rows match — a row-membership oracle. Put a `search` hole only in queries over tables where every searchable field is acceptable to probe for that audience; for a search box over a table with sensitive unprojected fields, use runtime `filter` with `contains` on the projected columns instead.
|
|
109
|
+
|
|
110
|
+
## `useViewer` is display-only
|
|
111
|
+
|
|
112
|
+
`useViewer()` returns the signed-in member currently viewing the app — the view-as target under "View as", `null` for an anonymous public visitor or while the context loads. It exists to **personalize and prefill**: greet the member, default an assignment to them, pass them into a picker. It is **never an authorization fact and never a scoping mechanism** — anything the client sends, including a `useViewer`-derived id, is replayable with a different value. Row scoping belongs in the query template (`is_current_member`, which the server resolves to the same person `useViewer` reports, view-as included); actor identity belongs in the workflow body (`runtime.triggered_by_member_id`); privilege belongs in the workflow gate (`current_member_in_any_group`). Signature: `dist/src/viewer.d.ts`.
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
*For platform maintainers*: the full IAM model, the public-access grant mechanics, and the rationale behind owner authority live in the monorepo's `docs/apps.md` and `docs/iam.md` (not shipped with this package).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/app-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.47.1",
|
|
4
4
|
"description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"types": "./dist/src/index.d.ts",
|
|
17
17
|
"files": [
|
|
18
18
|
"dist",
|
|
19
|
-
"AGENTS.md"
|
|
19
|
+
"AGENTS.md",
|
|
20
|
+
"docs"
|
|
20
21
|
],
|
|
21
22
|
"scripts": {
|
|
22
23
|
"build": "tsgo",
|