@lotics/app-sdk 0.100.1 → 0.101.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.
Files changed (108) hide show
  1. package/AGENTS.md +32 -47
  2. package/dist/agent_stream.d.ts +131 -0
  3. package/dist/ask_ai.d.ts +27 -0
  4. package/dist/attachments.d.ts +58 -0
  5. package/dist/chunk-ARV5FAU5.js +1132 -0
  6. package/dist/comments.d.ts +89 -0
  7. package/dist/error_report.d.ts +9 -0
  8. package/dist/folder_pick.d.ts +8 -0
  9. package/dist/geolocation.d.ts +42 -0
  10. package/dist/hooks.d.ts +251 -0
  11. package/dist/{src/index.d.ts → index.d.ts} +13 -22
  12. package/dist/index.js +31309 -0
  13. package/dist/index.js.LEGAL.txt +11 -0
  14. package/dist/members.d.ts +32 -0
  15. package/dist/mock.d.ts +37 -0
  16. package/dist/mount.d.ts +19 -0
  17. package/dist/new_record.d.ts +37 -0
  18. package/dist/open_app.d.ts +12 -0
  19. package/dist/open_external.d.ts +10 -0
  20. package/dist/overlay.d.ts +25 -0
  21. package/dist/queries.d.ts +231 -0
  22. package/dist/recording.d.ts +47 -0
  23. package/dist/recording_state.d.ts +43 -0
  24. package/dist/rename_file.d.ts +13 -0
  25. package/dist/router.d.ts +10 -0
  26. package/dist/router.js +97 -0
  27. package/dist/row.d.ts +87 -0
  28. package/dist/rpc.d.ts +114 -0
  29. package/dist/select.d.ts +24 -0
  30. package/dist/shared_types.d.ts +8 -0
  31. package/dist/store.d.ts +43 -0
  32. package/dist/types.d.ts +36 -0
  33. package/dist/upload/optimize.d.ts +30 -0
  34. package/dist/upload/pipeline.d.ts +36 -0
  35. package/dist/upload/transport.d.ts +19 -0
  36. package/dist/url_params.d.ts +55 -0
  37. package/dist/use_recents.d.ts +15 -0
  38. package/dist/{src/use_url_state.d.ts → use_url_state.d.ts} +0 -2
  39. package/dist/viewer.d.ts +41 -0
  40. package/dist/written.d.ts +77 -0
  41. package/docs/ai.md +74 -133
  42. package/docs/data_fetching.md +209 -290
  43. package/docs/files.md +61 -51
  44. package/docs/members_and_options.md +92 -62
  45. package/docs/mutations.md +135 -205
  46. package/docs/navigation_and_state.md +26 -35
  47. package/docs/queries.md +144 -207
  48. package/docs/recipes.md +21 -45
  49. package/docs/runtime.md +74 -137
  50. package/docs/security.md +8 -11
  51. package/docs/workflows.md +189 -174
  52. package/package.json +27 -28
  53. package/dist/src/agent_stream.d.ts +0 -200
  54. package/dist/src/agent_stream.js +0 -314
  55. package/dist/src/ask_ai.d.ts +0 -40
  56. package/dist/src/ask_ai.js +0 -35
  57. package/dist/src/attachments.d.ts +0 -68
  58. package/dist/src/attachments.js +0 -93
  59. package/dist/src/comments.d.ts +0 -127
  60. package/dist/src/comments.js +0 -192
  61. package/dist/src/download.js +0 -54
  62. package/dist/src/geolocation.d.ts +0 -64
  63. package/dist/src/geolocation.js +0 -96
  64. package/dist/src/hooks.d.ts +0 -781
  65. package/dist/src/hooks.js +0 -860
  66. package/dist/src/index.js +0 -34
  67. package/dist/src/members.d.ts +0 -105
  68. package/dist/src/members.js +0 -62
  69. package/dist/src/mock.d.ts +0 -118
  70. package/dist/src/mock.js +0 -124
  71. package/dist/src/mount.d.ts +0 -47
  72. package/dist/src/mount.js +0 -34
  73. package/dist/src/new_record.d.ts +0 -74
  74. package/dist/src/new_record.js +0 -117
  75. package/dist/src/open_app.d.ts +0 -15
  76. package/dist/src/open_app.js +0 -18
  77. package/dist/src/open_external.d.ts +0 -16
  78. package/dist/src/open_external.js +0 -19
  79. package/dist/src/recording.d.ts +0 -59
  80. package/dist/src/recording.js +0 -30
  81. package/dist/src/recording_state.d.ts +0 -59
  82. package/dist/src/recording_state.js +0 -94
  83. package/dist/src/router.d.ts +0 -17
  84. package/dist/src/router.js +0 -144
  85. package/dist/src/row.d.ts +0 -159
  86. package/dist/src/row.js +0 -254
  87. package/dist/src/rpc.d.ts +0 -207
  88. package/dist/src/rpc.js +0 -904
  89. package/dist/src/select.d.ts +0 -48
  90. package/dist/src/select.js +0 -40
  91. package/dist/src/types.d.ts +0 -115
  92. package/dist/src/types.js +0 -1
  93. package/dist/src/upload/optimize.d.ts +0 -54
  94. package/dist/src/upload/optimize.js +0 -207
  95. package/dist/src/upload/pipeline.d.ts +0 -55
  96. package/dist/src/upload/pipeline.js +0 -52
  97. package/dist/src/upload/transport.d.ts +0 -42
  98. package/dist/src/upload/transport.js +0 -128
  99. package/dist/src/url_params.d.ts +0 -93
  100. package/dist/src/url_params.js +0 -215
  101. package/dist/src/use_optimistic.d.ts +0 -27
  102. package/dist/src/use_optimistic.js +0 -27
  103. package/dist/src/use_recents.d.ts +0 -19
  104. package/dist/src/use_recents.js +0 -71
  105. package/dist/src/use_url_state.js +0 -73
  106. package/dist/src/viewer.d.ts +0 -26
  107. package/dist/src/viewer.js +0 -47
  108. /package/dist/{src/download.d.ts → download.d.ts} +0 -0
package/docs/recipes.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Recipes — the app actions that are not obvious
2
2
 
3
- The other area docs describe contracts. This one is task-shaped: **"how do I do X"**, for the
4
- handful of actions whose mechanism is not guessable from the hooks alone.
3
+ Task-shaped: **"how do I do X"**, for the actions whose mechanism is not guessable from the
4
+ hooks. Each contract is its area doc's.
5
5
 
6
6
  ---
7
7
 
@@ -15,9 +15,8 @@ whose step output carries a `file_id` (`generate_pdf_from_template`, `generate_e
15
15
  `generate_word_from_template`) is auto-collected by the execute endpoint and comes back in
16
16
  **`result.files[]`**, each with a servable `url`.
17
17
 
18
- So the workflow only *generates*. It does not `update_records` — attaching the file to a record is a
19
- separate, optional step. And you cannot hand the file back through `return({...})`; that channel is
20
- for values, not files.
18
+ So the workflow only *generates*; attaching the file to a record is a separate, optional step, and
19
+ `return({...})` carries values, not files.
21
20
 
22
21
  ```js
23
22
  // workflow body — alias `genDebit`, input `record_id`
@@ -42,20 +41,16 @@ The `dtl_…` is a template you registered **once**, ahead of the app, through t
42
41
  `lotics docs document_templates` for the five types and how each is created. An app fills
43
42
  templates; it never authors them.
44
43
 
45
- **`openExternal` is the download primitive, not `window.open`.** The app runs in a sandboxed iframe
46
- with no `allow-popups`, so a bare `window.open` is dropped — no error, no navigation, nothing to
47
- debug. `openExternal` routes the open through the host frame.
44
+ **`openExternal` is the download primitive, not `window.open`**, which the sandbox drops silently
45
+ ([runtime](./runtime.md)).
48
46
 
49
47
  ## Return structured data from a workflow
50
48
 
51
49
  A workflow can hand back a computed total, a list of rows, a status object — read by the app as a
52
50
  typed `result.data`, validated server-side against the alias's `outputs`.
53
51
 
54
- **You usually declare nothing.** `outputs` are derived at save time from the inferred type of your
55
- `return({ data })`, and `lotics app workflow set` writes that derived schema back into the manifest
56
- and refreshes the types in place — so `result.data` is typed immediately, with no hand-copy and no
57
- second `codegen`. Declare an explicit `outputs` only to narrow beyond what is inferred; it is then
58
- authoritative and never overwritten.
52
+ **You usually declare nothing**: `outputs` are derived at save time from your `return({ data })`
53
+ ([mutations](./mutations.md)). Declare `outputs` only to narrow beyond what is inferred.
59
54
 
60
55
  ```js
61
56
  const rows = await query_records({ table_id: "tbl_x", filters: { /* … */ } });
@@ -76,8 +71,7 @@ literal enough to infer. Files still travel via `result.files`.
76
71
 
77
72
  ## Look up one record by a code the user types
78
73
 
79
- Filter **server-side** so the app never receives another row. A full-table query loaded and filtered
80
- in JS ships every record to the client.
74
+ Filter **server-side** so the app never receives another row.
81
75
 
82
76
  ```jsonc
83
77
  "lookupShipment": {
@@ -114,26 +108,11 @@ Full pattern, including the date-param trick for range filters: `lotics docs que
114
108
 
115
109
  ## Decode cells with the accessors, never by hand
116
110
 
117
- A query cell is `unknown` with a per-type serialized shape:
118
-
119
- - `row.opt` / `row.text` / `row.num` / `row.bool` / `row.date` — scalars and the first value of a
120
- select. `row.date` keeps only the calendar day; use **`row.datetime`** when the time matters.
121
- `row.num` and `row.bool` answer `null` for an empty cell — an unanswered checkbox is not a "no"
122
- any more than an empty number cell is a 0. Write `=== true` where only the affirmative acts, and
123
- `?? 0` / `?? false` only where that side is the cell's correct reading.
124
- - `readSelect` — the full `{ key, label }[]` of a multi-select.
125
- - `readMembers` — `select_member` cells.
126
- - `row.link` / `readLinks` — `select_record_link` → `{ id, display }`. Read `.display` to render,
127
- `.id` to correlate or filter.
128
-
129
- A hand-rolled `firstOpt` / `linkDisplay` is the most common drift in app code: it re-implements the
130
- serialization contract and rots silently when that changes. A select cell is `[{key,label}]`, so a
131
- reader that grabs the wrong half is *plausible and wrong* rather than broken.
132
-
133
- **Project what you render.** A bare `{ kind: "from_table", table_id }` with no `project` ships every
134
- column of every row — including `files` fields and any cost or PII column the UI never shows.
135
- Projecting file fields also mass-presigns, which 500s on a large result and survives only on tiny
136
- tables.
111
+ A query cell is `unknown` with a per-type serialized shape, and the readers own it: `row.opt` /
112
+ `row.text` / `row.num` / `row.bool` / `row.date` / `row.datetime`, `readSelect`, `readMembers`,
113
+ `row.link` / `readLinks` ([data fetching](./data_fetching.md#the-readers)). A hand-rolled `firstOpt` /
114
+ `linkDisplay` re-implements that contract: a select cell is `[{key,label}]`, so a reader that grabs
115
+ the wrong half is *plausible and wrong* rather than broken.
137
116
 
138
117
  ## Set the icon and colour
139
118
 
@@ -149,17 +128,15 @@ lotics run search_app_icons '{"query":"shipping"}' → ["truck","ship","packag
149
128
 
150
129
  `theme.color` is a named palette colour: `red orange amber yellow lime green emerald teal cyan sky
151
130
  blue indigo violet purple fuchsia pink rose slate gray zinc neutral stone`. `null` clears either.
152
- There is no `lotics app icon` subcommand; a deploy warns while they are unset.
131
+ `update_app` sets both; a deploy warns while they are unset.
153
132
 
154
133
  **In-app palette** — the app's own `src/theme.ts`, exact hex. This is what the app *renders* with;
155
134
  the launcher colour does not feed it. Pick the named colour closest to your brand hex.
156
135
 
157
136
  ## Test an AI action without spending credits
158
137
 
159
- An app's `useAgentRun` runs the **real** agent even under `lotics app dev` — the dev harness proxies
160
- to the production endpoint, so every dev click spends the app org's credits. The SDK's fixture mode
161
- (`?__mock=1`) covers `useQuery` only; it does not mock an agent run. Iterating on a review screen
162
- would re-bill on every reload.
138
+ An app's `useAgentRun` runs the **real** agent wherever the app runs, so every click spends the app
139
+ org's credits, and the fixture mode (`?__mock=1`) does not mock an agent run.
163
140
 
164
141
  **Replay one captured run.** Capture a real `run.output` once, paste it as a constant, and gate on
165
142
  the same `?__mock=1` flag — so mocked rows and a mocked run activate together and dev is one
@@ -175,17 +152,16 @@ function Action({ input }: { input: AgentInput }) {
175
152
 
176
153
  async function propose() {
177
154
  if (MOCK) { setProposal(OUTPUT_FIXTURE); return; } // no run, no credits
178
- const output = await agent.run(input, { sessionId: mySessionKey });
179
- if (agent.status === "error" || output === undefined) return;
180
- setProposal(output);
155
+ const landing = await agent.run(input, { sessionId: mySessionKey });
156
+ if (landing.kind !== "settled" || landing.output === undefined) return;
157
+ setProposal(landing.output);
181
158
  }
182
159
  return proposal ? <ChangeReview /* … */ /> : /* trigger propose */;
183
160
  }
184
161
  ```
185
162
 
186
163
  Hold the proposal in local state rather than reading `agent.output`, since `run()` never fires in
187
- mock mode. Capture the fixture with
188
- `run(input, { sessionId }).then((o) => console.log(JSON.stringify(o)))`.
164
+ mock mode. Capture the fixture by logging `JSON.stringify(landing.output)` from one real run.
189
165
 
190
166
  **This is not a substitute for one real end-to-end run before shipping.** The fixture exercises the
191
167
  UI downstream of the model and nothing else — not the agent, not its tools, not the server-side
package/docs/runtime.md CHANGED
@@ -1,15 +1,9 @@
1
1
  # The app runtime
2
2
 
3
- How a custom-code app boots and talks to the platform: **`mount()`** (the entry
4
- point, including the design-time mock harness),
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.
3
+ How an app boots and talks to the platform: **`mount()`** (with the design-time mock harness),
4
+ the **two transports** the SDK switches between (app code never branches), the raw **`rpc()`**
5
+ escape hatch, and the browser capabilities the sandbox would otherwise block — **`openExternal`**,
6
+ **`openApp`**, **`downloadFile`**, and **`requestGeofencedLocation`** / **`isWithinZone`**.
13
7
 
14
8
  ## `mount()` — the entry point
15
9
 
@@ -21,7 +15,7 @@ mount(<App />);
21
15
  ```
22
16
 
23
17
  `mount(element: ReactNode, options?: MountOptions): void` (exact signature:
24
- `dist/src/mount.d.ts`) is called **once** from the app's entry file. It:
18
+ `dist/mount.d.ts`) is called **once** from the app's entry file. It:
25
19
 
26
20
  1. **Registers the mock fixture**, if `options.fixture` was passed (below).
27
21
  2. **Finds or creates `#root`.** Uses the `<div id="root">` from the scaffold's
@@ -29,7 +23,9 @@ mount(<App />);
29
23
  3. **Installs visible error handlers.** `window` `error` and
30
24
  `unhandledrejection` listeners render a fixed red monospace banner (message +
31
25
  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.
26
+ visible in the iframe itself. Embedded, each also reaches Lotics through the
27
+ host (`reportAppError`: the error's class, a bounded message and its first
28
+ frame, with no origin, query or fragment; the first five per page load).
33
29
  Banners are informational only; they are not removed automatically.
34
30
  4. **Renders the tree** with React 19's `createRoot`.
35
31
 
@@ -41,7 +37,7 @@ The optional second argument registers a demo/design-time fixture:
41
37
  mount(<App />, {
42
38
  fixture: {
43
39
  queries: {
44
- orders: MOCK_ORDERS, // alias → rows, same aliases as package.json#lotics.queries
40
+ orders: MOCK_ORDERS, // alias → rows, same aliases the app binds
45
41
  customers: MOCK_CUSTOMERS,
46
42
  // Or a FUNCTION of the call — how a surface that narrows per call
47
43
  // (a master/detail drawer) renders what production would.
@@ -66,12 +62,13 @@ Activation is a **two-step gate** — both must hold, so demo data shipping in t
66
62
  bundle never leaks into normal traffic:
67
63
 
68
64
  1. A fixture is registered via `mount({ fixture })` (`AppFixture` type:
69
- `dist/src/mock.d.ts` — `{ queries?, workflows?, recordings? }`).
65
+ `dist/mock.d.ts` — `{ queries?, workflows?, recordings? }`).
70
66
  2. The page URL carries `?__mock=1` (exactly `1`). Without the flag the fixture
71
67
  is completely inert.
72
68
 
73
69
  When active, the [query hooks](./data_fetching.md) return `fixture.queries[alias]`
74
- instead of making any request (`loading` stays `false`). **Partial mocking is
70
+ instead of making any request (`loading` stays `false`); a read with `enabled: false` asks
71
+ the fixture nothing, as it asks the server nothing. **Partial mocking is
75
72
  supported**: an alias absent from the fixture still flows through the real
76
73
  transport. Calling `mount` again (HMR) replaces the registration last-write-wins.
77
74
 
@@ -80,11 +77,10 @@ transport. Calling `mount` again (HMR) replaces the registration last-write-wins
80
77
  serialized cells your `row.*` / `readSelect` / `readFiles` readers decode —
81
78
  or the readers will decode nothing.
82
79
  - **A ROW ARRAY answers every call to its alias identically.** The server applies
83
- the per-call `filter` / `sort` / `pageSize` *after* the named query; a static
80
+ the per-call `filter` / `sort` / `limit` *after* the named query; a static
84
81
  array cannot, so a drawer narrowed with
85
82
  `useQuery("orderLines", {}, { filter: byOpenOrder })` shows every parent's
86
- children under every parent — a screen that looks right and is wrong, which
87
- defeats the review the mock path exists for. **Make such a fixture a function
83
+ children under every parent. **Make such a fixture a function
88
84
  of the call** (`({ params, filter, sort, limit }) => rows`) and narrow it
89
85
  there; a filtered call against a row array warns once per alias in the
90
86
  console. Do NOT compensate with a client-side re-filter in the app — that
@@ -92,13 +88,14 @@ transport. Calling `mount` again (HMR) replaces the registration last-write-wins
92
88
  look right.
93
89
  - **A mocked workflow does not RUN.** `useWorkflow(alias)` resolves the fixture
94
90
  entry and sends nothing, so there is no notification, no audit trail and no
95
- spend — the side-effect argument is the reason to mock it, not a reason not
96
- to. What a mock cannot produce is the followup state a later query would read;
97
- that is yours to fixture too, exactly as it already is for queries.
91
+ spend. What a mock cannot produce is the followup state a later query would read;
92
+ that is yours to fixture too, exactly as it already is for queries. A settled
93
+ call re-asks every query fixture, so a workflow function that writes into what
94
+ a query function reads shows its effect with no reload — the same re-read a
95
+ deployed app makes against the server.
98
96
  **Reach the in-flight state with a function** that resolves on a timer: an app
99
97
  whose only AI surface is a workflow calling `agent(...)` otherwise has no
100
- non-billing path to its own thinking / done / error screens, which is how
101
- those three ship unreviewed.
98
+ non-billing path to its own thinking / done / error screens.
102
99
  - **A mocked recording is a canned state.** `recordings: { log_visit: { phase:
103
100
  "live", elapsed_seconds: 754, inputs: { site: "rec_1" } } }` makes
104
101
  `useRecording("log_visit")` available with that `state`; `start` and `stop`
@@ -122,15 +119,15 @@ at each call from the iframe URL: the embedding host passes its origin via the
122
119
  reserved `?lotics_host=` query param — present (non-empty) means **embedded**,
123
120
  absent means **standalone**. App code never branches on the mode; every hook and
124
121
  helper works identically in both. `isEmbedded(): boolean` is exported
125
- (`dist/src/rpc.d.ts`) for the rare display-level need, and the
122
+ (`dist/rpc.d.ts`) for the rare display-level need, and the
126
123
  [security doc](./security.md) explains what mode implies for identity.
127
124
 
128
125
  | | **Embedded (bridged)** | **Standalone (direct)** |
129
126
  |---|---|---|
130
- | Where | Iframe inside the Lotics product, or the `lotics app dev` wrapper page | The app's own top-level page, on its own origin |
127
+ | Where | Iframe inside the Lotics product | The app's own top-level page, on its own origin |
131
128
  | 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) |
132
129
  | How ops travel | `postMessage` to the parent frame; the host makes the API call with its session | `fetch` to the public `/v1/apps/{id}/*` endpoints, at the API address the serving host declared in the page |
133
- | Which API | The host's, implicitly — it makes the call | `<meta name="lotics-api-base" content="…">` in the served document, read on the first call. **No address is compiled in**: one bundle runs on any Lotics, and a page that declares none makes the SDK refuse rather than address an instance nobody named. Every serving host injects it (the app host and `lotics app dev`), so there is nothing for an app to set |
130
+ | Which API | The host's, implicitly — it makes the call | `<meta name="lotics-api-base" content="…">` in the served document, read on the first call. **No address is compiled in**: one bundle runs on any Lotics, and a page that declares none makes the SDK refuse rather than address an instance nobody named. The app host injects it, so there is nothing for an app to set |
134
131
  | Viewer identity | The signed-in member (`useViewer`, comments, agent runs available) | `member_id` is `null`; members-only surfaces reject |
135
132
 
136
133
  ### Embedded: the postMessage bridge
@@ -152,12 +149,10 @@ promise pending forever. Don't build UI that deadlocks awaiting an op you
152
149
  haven't verified exists in the host (see
153
150
  [op availability](#op-availability-by-transport)).
154
151
 
155
- **Warning (dev loop):** `lotics app dev` prints a wrapper URL — always drive
156
- *that* page. The wrapper embeds the app iframe with `?lotics_host=` and bridges
157
- ops to the API; the bare Vite origin has no host param, so the SDK falls into
152
+ **Warning:** a bare Vite dev server has no host param, so the SDK falls into
158
153
  standalone mode, tries to resolve an app from the hostname, and every data call
159
- fails. Dev apps are always bridged; standalone mode exists only on the deployed
160
- app host.
154
+ fails. Try an app by deploying it and opening it in the product; standalone mode
155
+ exists only on the deployed app host.
161
156
 
162
157
  ### Standalone: direct public API
163
158
 
@@ -197,21 +192,19 @@ that is safe to render:
197
192
  Most ops work everywhere; the exceptions are members-only or host-only
198
193
  surfaces:
199
194
 
200
- | Op | Embedded (product) | `lotics app dev` | Standalone |
201
- |---|---|---|---|
202
- | `query`, `field_options`, `workflow`, `members`, `context`, `upload`, `urlState.get/set`, `openExternal` | yes | yes | yes |
203
- | `comments.*` | yes | yes | rejects — `"Comments are available only in embedded apps — a signed-in member is required."` |
204
- | `agentRun` (streaming, internal to `useAgentRun`) | yes | yes | yes |
205
- | `agentRun.get`, `agentRun.cancel` | yes | **no** — the dev forwarder doesn't implement them (`"Unknown RPC op: …"`) | yes |
206
- | `agentRuns` (session history) | **no** — the product host doesn't implement it either (`"Unknown RPC op: agentRuns"`) | **no** | yes |
207
- | `askAi` | yes | **no** — `"Unknown RPC op: askAi"` | rejects — `"askAi is only available when the app runs inside Lotics"` |
208
- | `openApp` | yes — routes to the sibling in the same tab | yes — opens the sibling on the web app in a new tab (one app is served locally) | rejects — `"openApp needs the Lotics host …"` |
209
- | `recording.start`, `recording.stop` | yes, when the context states `recording_enabled` | **no** — `"Unknown RPC op: …"`; the context omits `recording_enabled` | rejects — `"Recording needs the Lotics host and a signed-in member …"` |
195
+ | Op | Embedded (product) | Standalone |
196
+ |---|---|---|
197
+ | `query`, `field_options`, `workflow`, `members`, `context`, `upload`, `urlState.get/set`, `openExternal` | yes | yes |
198
+ | `comments.*` | yes | rejects — `"Comments are available only in embedded apps — a signed-in member is required."` |
199
+ | `agentRun` (streaming, internal to `useAgentRun`) | yes | yes |
200
+ | `agentRun.get`, `agentRun.cancel` | yes | yes |
201
+ | `agentRuns` (session history) | **no** — the product host doesn't implement it either (`"Unknown RPC op: agentRuns"`) | rejects — a standalone caller is anonymous, and a history is its member's (`"App agent runs require an authenticated member …"`) |
202
+ | `askAi` | yes | rejects — `"askAi is only available when the app runs inside Lotics"` |
203
+ | `openApp` | yes — routes to the sibling in the same tab | rejects — `"openApp needs the Lotics host …"` |
204
+ | `recording.start`, `recording.stop` | yes, when the context states `recording_enabled` | rejects — `"Recording needs the Lotics host and a signed-in member …"` |
210
205
 
211
206
  **Limitation:** Don't build a session-log UI on `useAgentRuns`; keep the log in app
212
- state from live `useAgentRun` results (see [ai](./ai.md)). Server-side *cancel*
213
- also errors in the dev loop (the local abort of a live stream still works) —
214
- verify cancel on a deployed app. The standalone `query` transport forwards only
207
+ state from live `useAgentRun` results (see [ai](./ai.md)). The standalone `query` transport forwards only
215
208
  `alias`/`params`/`limit`/`offset` — runtime `sort`/`filter`/`count` refinement
216
209
  is embedded-only (see [data fetching](./data_fetching.md)).
217
210
 
@@ -229,14 +222,13 @@ const { rows } = await rpc<{ rows: Record<string, unknown>[] }>("query", {
229
222
  ```
230
223
 
231
224
  `rpc<T = unknown>(op: RpcOp, payload: unknown): Promise<T>` sends one op over
232
- whichever transport is active. **Prefer the hooks** — they add SWR caching,
233
- loading/error state, analytics, and typing from your declared aliases. `rpc()`
234
- exists for the imperative cases the hooks don't model, chiefly paging a query to
235
- exhaustion for a full export — `rpc("query", { alias, params, limit, offset })`
236
- until a short page, then feed the rows to `downloadFile` (below). `T` is *your*
237
- assertion — the SDK does not validate the result shape.
238
-
239
- The full `RpcOp` union (`dist/src/rpc.d.ts`), each op's payload, and where its
225
+ whichever transport is active. **Prefer the hooks** — they add the SDK's cache,
226
+ loading/error state, analytics, and typing from your declared aliases; every row
227
+ of a set for a full export is `queryAll` ([data_fetching](./data_fetching.md)), fed
228
+ to `downloadFile` (below). `rpc()` exists for the imperative cases neither
229
+ models. `T` is *your* assertion — the SDK does not validate the result shape.
230
+
231
+ The full `RpcOp` union (`dist/rpc.d.ts`), each op's payload, and where its
240
232
  semantics are documented:
241
233
 
242
234
  | Op | Payload | Resolves to | Owning doc |
@@ -260,12 +252,23 @@ semantics are documented:
260
252
  The **streaming** agent-run op is *not* reachable through `rpc()` — its
261
253
  response is a chunk stream, not a single value; it's internal to `useAgentRun`.
262
254
 
263
- `rpc("context", {})` resolves the viewer: `{ member_id, comments_enabled,
264
- recording_enabled?, recordings? }`. `member_id` is the signed-in member when
265
- embedded, `null` standalone — read it through `useViewer()` rather than this op;
266
- the recording fields are `useRecording`'s. **Limitation:** the context type is
267
- not exported from the package root, so type the result yourself via the `rpc<T>`
268
- generic.
255
+ `rpc("context", {})` resolves the viewer and the workspace:
256
+ `{ member_id, comments_enabled, timezone, default_currency, recording_enabled?, recordings? }`. `member_id` is the
257
+ signed-in member when embedded, `null` standalone; `timezone` is the workspace's
258
+ IANA zone and `default_currency` its ISO 4217 code, from the host when embedded
259
+ and from `/v1/apps/by-subdomain/{subdomain}` standalone. Read them through
260
+ `useViewer()`, `useWorkspaceTimezone()` and `useWorkspaceCurrency()`
261
+ ([members & options](./members_and_options.md)) rather than this op. A host that
262
+ predates the two workspace facts answers without them; the recording fields are
263
+ `useRecording`'s. **Limitation:** the
264
+ context type is not exported from the package root, so type the result yourself
265
+ via the `rpc<T>` generic.
266
+
267
+ An app's root draws nothing until this op has answered or failed, then hands
268
+ the zone to the kit's locale, so every instant the kit formats is dated in the
269
+ workspace's zone ([members & options](./members_and_options.md)); money whose
270
+ field states no code is counted in the workspace's currency. Where neither the field nor the host names a
271
+ currency, the screen refuses by name rather than printing one nobody stated.
269
272
 
270
273
  ## `openExternal()` — open a link in a new tab
271
274
 
@@ -274,11 +277,11 @@ import { openExternal } from "@lotics/app-sdk";
274
277
  await openExternal(result.files[0].url);
275
278
  ```
276
279
 
277
- `openExternal(url: string): Promise<void>` (`dist/src/open_external.d.ts`). The
280
+ `openExternal(url: string): Promise<void>` (`dist/open_external.d.ts`). The
278
281
  embedded app iframe is sandboxed **without `allow-popups`**, so a direct
279
282
  `window.open` from app code is *silently dropped* — no error, nothing happens.
280
283
  `openExternal` routes the URL to whoever can actually open it: the un-sandboxed
281
- host frame (embedded — including the dev wrapper) or the app's own top-level
284
+ host frame (embedded) or the app's own top-level
282
285
  page (standalone). Opens in a new tab with `noopener,noreferrer`.
283
286
 
284
287
  - **Scheme validation at the point of open**: only `http:` and `https:` are
@@ -289,7 +292,7 @@ page (standalone). Opens in a new tab with `noopener,noreferrer`.
289
292
  - Typical use: opening a workflow-generated file's `url` from
290
293
  `WorkflowResult.files[]` (see [mutations](./mutations.md)).
291
294
  - **Not a preview mechanism.** To *view* a file inline, use `@lotics/ui`'s
292
- `FilePreview`/`FileGalleryDialog` (`@lotics/ui` ≥ 48 — see [files](./files.md)); `openExternal` is
295
+ `FilePreview`/`FileGalleryDialog` (see [files](./files.md)); `openExternal` is
293
296
  "leave the app".
294
297
 
295
298
  ## `openApp()` — the cross-app hop
@@ -299,7 +302,7 @@ import { openApp, isEmbedded } from "@lotics/app-sdk";
299
302
  await openApp(PLANNING_APP_ID, `/plan/${record.id}`);
300
303
  ```
301
304
 
302
- `openApp(appId: string, route?: string): Promise<void>` (`dist/src/open_app.d.ts`).
305
+ `openApp(appId: string, route?: string): Promise<void>` (`dist/open_app.d.ts`).
303
306
  A workspace built as several apps around one spine shows another app's record
304
307
  read-only with a way THROUGH to the app that owns it; this is the way through.
305
308
  The host owns app routing, so it lands the viewer on `route` inside `appId` in
@@ -311,9 +314,7 @@ declares it (`/` for its register); it is the target's to define.
311
314
  - **Checked at the bridge, host-side**: the id by shape (`app_…`), the route as
312
315
  an in-app path — starts with `/`, never `//` or a scheme. An app never
313
316
  assembles the host's URL itself.
314
- - **By transport**: embedded routes in place; `lotics app dev` serves one app
315
- and has no shell to route inside, so it opens the sibling on the web app in a
316
- new tab; standalone rejects (`openApp needs the Lotics host …`) — gate the
317
+ - **By transport**: embedded routes in place; standalone rejects (`openApp needs the Lotics host …`) — gate the
317
318
  control on `isEmbedded()`.
318
319
  - A sibling's id is this workspace's: a copy of the suite has different ones,
319
320
  and the portability gate refuses a concrete `app_` in `src/` and in every
@@ -328,20 +329,14 @@ declares it (`/` for its register); it is the target's to define.
328
329
 
329
330
  ```tsx
330
331
  import { downloadFile } from "@lotics/app-sdk";
331
- import { buildDataWorkbook, exportWorkbook } from "@lotics/xlsx";
332
332
 
333
333
  function onExportClick() {
334
- const wb = buildDataWorkbook({ columns, rows });
335
- downloadFile(
336
- "report.xlsx",
337
- exportWorkbook(wb),
338
- "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
339
- );
334
+ downloadFile("report.csv", rows.map((r) => r.join(",")).join("\n"), "text/csv");
340
335
  }
341
336
  ```
342
337
 
343
338
  `downloadFile(filename: string, data: Uint8Array | Blob | string, mimeType?:
344
- string): void` (`dist/src/download.d.ts`) saves bytes the app generated **in the
339
+ string): void` (`dist/download.d.ts`) saves bytes the app generated **in the
345
340
  browser** (an exported .xlsx, a CSV, generated text) to the visitor's device.
346
341
  It is the client-side counterpart to the server path (workflow generates a file
347
342
  → `WorkflowResult.files[].url` → `openExternal`).
@@ -372,7 +367,7 @@ It is the client-side counterpart to the server path (workflow generates a file
372
367
  The host grants the app iframe the `geolocation` Permissions-Policy, so app code
373
368
  reads the device position **directly through the browser** — no RPC. The browser
374
369
  permission prompt appears as usual (attributed to the embedding site when
375
- embedded). Exact signatures: `dist/src/geolocation.d.ts`.
370
+ embedded). Exact signatures: `dist/geolocation.d.ts`.
376
371
 
377
372
  **`isWithinZone(latitude, longitude, zone): boolean`** — pure great-circle
378
373
  (haversine) check: is the point within `zone.radius` meters of
@@ -424,66 +419,8 @@ a user's Stop (`cancel_requested_at`), lands in `app_agent_runs`. App opens are
424
419
  counted at the serving edge. If a funnel needs something those cannot answer,
425
420
  request it as a platform change.
426
421
 
427
- ## For SDK contributors
428
-
429
- Everything below concerns changing `@lotics/app-sdk` itself (in the Lotics
430
- monorepo), not building apps with it.
431
-
432
- ### The publish chain — nothing reaches apps without a version bump
433
-
434
- Apps install the SDK from **npm**. Publish CI runs on pushes to `main` touching
435
- the package and publishes **only when `package.json#version` differs from the
436
- version on npm** — nothing else triggers it. `dist/`, `AGENTS.md`, and `docs/`
437
- (this file) are what ship (`package.json#files`), so a docs-only fix needs the
438
- same chain. A source or doc change reaches zero apps until:
439
-
440
- 1. the version is bumped and the change merges to `main` (publish CI builds and
441
- publishes),
442
- 2. each app widens/updates its dependency range and reinstalls,
443
- 3. the app redeploys.
444
-
445
- ### Wiring a new RPC op — all four transports, or it 404s somewhere
446
-
447
- A new bridge op must be implemented in every transport, or it works in one place
448
- and breaks in another:
449
-
450
- 1. `packages/app-sdk/src/rpc.ts` — the `RpcOp` union **and** the standalone
451
- (direct-API) implementation,
452
- 2. `frontend/features/app_ui/app_iframe_host.tsx` — the product host's bridge
453
- handler,
454
- 3. `packages/sdk/src/dev/` — the `lotics app dev` wrapper page and its Node
455
- forwarder (`rpc_handler.ts`),
456
- 4. the `LoticsClient` method (`packages/sdk/src/client.ts`) that forwarder
457
- calls.
458
-
459
- The backend endpoint deploys with the PR, but `lotics app dev` forwards to the
460
- **production** API — a new op is not usable in the dev loop until the backend
461
- has deployed. Skipping a transport produces exactly the gaps in the
462
- [op availability table](#op-availability-by-transport) above — every "no" there
463
- is a transport that wasn't wired.
464
-
465
- ### Bundler & dependency constraints
466
-
467
- - **Pure ESM, browser-only.** Apps bundle the SDK with Vite. No `require()`,
468
- no dynamic `import()`, no Node built-ins. Don't touch `window` at module top
469
- level — resolve lazily (test environments import modules before `jsdom` is
470
- ready).
471
- - **One module per entry.** A package apps share with the Lotics product
472
- (`@lotics/ui`, `@lotics/xlsx`, `@lotics/docx`) ships ONE module per entry: no
473
- platform twins, no per-target `exports` condition. The SDK itself is never
474
- bundled by the product at all — the host frontend is forbidden from importing
475
- `@lotics/app-sdk`, enforced by a dependency-direction test.
476
- - **Self-contained on npm.** The SDK cannot import workspace-private or
477
- host-only packages (`@lotics/shared`, `@lotics/ui-internal`, the frontend's
478
- `@/` alias) or any React Native / Expo module — also test-enforced. A helper
479
- that exists privately elsewhere gets a local copy here, with a comment saying
480
- why.
481
- - **Data + RPC only — zero UI.** Never re-export a `@lotics/ui` component; the
482
- SDK stays off the React-Native-Web dependency tree. Apps import `@lotics/ui`
483
- directly.
484
- - **Keep the dependency set minimal.** Runtime deps are `ai` and `swr`;
485
- `react`/`react-dom` are peers. `react-router` is an **optional** peer
486
- pulled in only by the `@lotics/app-sdk/router` subpath export — the root entry
487
- must never import it. (`react-router` is the canonical package; the
488
- `react-router-dom` shim also satisfies the peer, since it carries
489
- `react-router` in its own tree.)
422
+ ## Dependencies
423
+
424
+ The SDK's own dependency is `ai` (types only); `react`, `react-dom` and `react-router`
425
+ are peers, and only the `@lotics/app-sdk/router` entry imports `react-router` (the
426
+ `react-router-dom` shim also satisfies the peer, since it carries `react-router` in its own tree).
package/docs/security.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Security: authority & scoping
2
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).
3
+ Every data operation an app performs — queries, workflows, agent runs — executes under the **app owner's** IAM principal, not the viewing member's. The platform decides *what the app can reach* (the owner's access); **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. 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
4
 
5
5
  ## The owner-principal model
6
6
 
@@ -16,7 +16,7 @@ Consequences of owner authority:
16
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
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
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.
19
+ - **The boundary is what a CALLER can do**, not what the owner can: any caller, including an anonymous public visitor, can only invoke the declared aliases with typed, per-input-constrained values. A caller gaining data or capability the aliases don't declare is a vulnerability; the sections below prevent the ones the platform cannot detect for you.
20
20
 
21
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
22
 
@@ -52,14 +52,14 @@ Before shipping any query or workflow, ask: **could a member open the browser de
52
52
 
53
53
  ## Scoping reads: `is_current_member` in the template
54
54
 
55
- 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).
55
+ 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 draws the same UI and fails the devtools test.
56
56
 
57
57
  Who the server binds `is_current_member` to:
58
58
 
59
59
  | Caller | Binds to |
60
60
  |---|---|
61
61
  | Authenticated member of the app's org | That member |
62
- | 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 |
62
+ | Admin using "View as" in the product | The **viewed** member — so an admin previews exactly what the member sees |
63
63
  | 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 |
64
64
  | Anonymous visitor (public app) | **The app owner** — see the public-app warning below |
65
65
 
@@ -107,18 +107,15 @@ A publicly-shared app (its own origin, or its public link) is reachable by **any
107
107
 
108
108
  ### Member roster access
109
109
 
110
- 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).
110
+ Listing members (`useMembers`) takes an authenticated member of the app's own org, a declared `member` input or param, and a group only where one is declared; in query results a member cell carries `email` only for that same-org member. The gates and their errors: [members_and_options](./members_and_options.md#access-gates-when-it-errors).
111
111
 
112
112
  ## What runtime refinement cannot widen
113
113
 
114
- 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).
114
+ 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 total — with `count_by`, one per value of a projected column — over the same bounded filter. Full mechanics in [queries](./queries.md).
115
115
 
116
- **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.
116
+ **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, or AND the `search` with a template `contains` OR-group over the fields that may be probed — every row that group matches also matches the search, so the pair answers only for those fields.
117
117
 
118
118
  ## `useViewer` is display-only
119
119
 
120
- `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`.
120
+ `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** — a `useViewer`-derived id the client sends is replayable with a different value ([the devtools test](#the-devtools-test)); `is_current_member` resolves server-side to the same person `useViewer` reports, view-as included. Signature: `dist/viewer.d.ts`.
121
121
 
122
- ---
123
-
124
- *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).