@lotics/app-sdk 0.46.1 → 0.47.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.
@@ -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.
@@ -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.46.1",
3
+ "version": "0.47.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": {
@@ -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",