@lotics/app-sdk 0.100.1 → 0.101.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +32 -47
- package/dist/agent_stream.d.ts +131 -0
- package/dist/ask_ai.d.ts +27 -0
- package/dist/attachments.d.ts +58 -0
- package/dist/chunk-ARV5FAU5.js +1132 -0
- package/dist/comments.d.ts +89 -0
- package/dist/error_report.d.ts +9 -0
- package/dist/folder_pick.d.ts +8 -0
- package/dist/geolocation.d.ts +42 -0
- package/dist/hooks.d.ts +251 -0
- package/dist/{src/index.d.ts → index.d.ts} +13 -22
- package/dist/index.js +31331 -0
- package/dist/index.js.LEGAL.txt +11 -0
- package/dist/members.d.ts +32 -0
- package/dist/mock.d.ts +37 -0
- package/dist/mount.d.ts +19 -0
- package/dist/new_record.d.ts +37 -0
- package/dist/open_app.d.ts +12 -0
- package/dist/open_external.d.ts +10 -0
- package/dist/overlay.d.ts +25 -0
- package/dist/queries.d.ts +231 -0
- package/dist/recording.d.ts +47 -0
- package/dist/recording_state.d.ts +43 -0
- package/dist/rename_file.d.ts +13 -0
- package/dist/router.d.ts +10 -0
- package/dist/router.js +97 -0
- package/dist/row.d.ts +87 -0
- package/dist/rpc.d.ts +114 -0
- package/dist/select.d.ts +24 -0
- package/dist/shared_types.d.ts +8 -0
- package/dist/store.d.ts +43 -0
- package/dist/types.d.ts +36 -0
- package/dist/upload/optimize.d.ts +30 -0
- package/dist/upload/pipeline.d.ts +36 -0
- package/dist/upload/transport.d.ts +19 -0
- package/dist/url_params.d.ts +55 -0
- package/dist/use_recents.d.ts +15 -0
- package/dist/{src/use_url_state.d.ts → use_url_state.d.ts} +0 -2
- package/dist/viewer.d.ts +41 -0
- package/dist/written.d.ts +79 -0
- package/docs/ai.md +74 -133
- package/docs/data_fetching.md +209 -290
- package/docs/files.md +61 -51
- package/docs/members_and_options.md +92 -62
- package/docs/mutations.md +136 -205
- package/docs/navigation_and_state.md +26 -35
- package/docs/queries.md +144 -207
- package/docs/recipes.md +21 -45
- package/docs/runtime.md +74 -137
- package/docs/security.md +8 -11
- package/docs/workflows.md +189 -174
- package/package.json +27 -28
- package/dist/src/agent_stream.d.ts +0 -200
- package/dist/src/agent_stream.js +0 -314
- package/dist/src/ask_ai.d.ts +0 -40
- package/dist/src/ask_ai.js +0 -35
- package/dist/src/attachments.d.ts +0 -68
- package/dist/src/attachments.js +0 -93
- package/dist/src/comments.d.ts +0 -127
- package/dist/src/comments.js +0 -192
- package/dist/src/download.js +0 -54
- package/dist/src/geolocation.d.ts +0 -64
- package/dist/src/geolocation.js +0 -96
- package/dist/src/hooks.d.ts +0 -781
- package/dist/src/hooks.js +0 -860
- package/dist/src/index.js +0 -34
- package/dist/src/members.d.ts +0 -105
- package/dist/src/members.js +0 -62
- package/dist/src/mock.d.ts +0 -118
- package/dist/src/mock.js +0 -124
- package/dist/src/mount.d.ts +0 -47
- package/dist/src/mount.js +0 -34
- package/dist/src/new_record.d.ts +0 -74
- package/dist/src/new_record.js +0 -117
- package/dist/src/open_app.d.ts +0 -15
- package/dist/src/open_app.js +0 -18
- package/dist/src/open_external.d.ts +0 -16
- package/dist/src/open_external.js +0 -19
- package/dist/src/recording.d.ts +0 -59
- package/dist/src/recording.js +0 -30
- package/dist/src/recording_state.d.ts +0 -59
- package/dist/src/recording_state.js +0 -94
- package/dist/src/router.d.ts +0 -17
- package/dist/src/router.js +0 -144
- package/dist/src/row.d.ts +0 -159
- package/dist/src/row.js +0 -254
- package/dist/src/rpc.d.ts +0 -207
- package/dist/src/rpc.js +0 -904
- package/dist/src/select.d.ts +0 -48
- package/dist/src/select.js +0 -40
- package/dist/src/types.d.ts +0 -115
- package/dist/src/types.js +0 -1
- package/dist/src/upload/optimize.d.ts +0 -54
- package/dist/src/upload/optimize.js +0 -207
- package/dist/src/upload/pipeline.d.ts +0 -55
- package/dist/src/upload/pipeline.js +0 -52
- package/dist/src/upload/transport.d.ts +0 -42
- package/dist/src/upload/transport.js +0 -128
- package/dist/src/url_params.d.ts +0 -93
- package/dist/src/url_params.js +0 -215
- package/dist/src/use_optimistic.d.ts +0 -27
- package/dist/src/use_optimistic.js +0 -27
- package/dist/src/use_recents.d.ts +0 -19
- package/dist/src/use_recents.js +0 -71
- package/dist/src/use_url_state.js +0 -73
- package/dist/src/viewer.d.ts +0 -26
- package/dist/src/viewer.js +0 -47
- /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
|
-
|
|
4
|
-
|
|
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
|
|
19
|
-
|
|
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
|
|
46
|
-
|
|
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
|
|
55
|
-
|
|
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.
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
|
160
|
-
|
|
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
|
|
179
|
-
if (
|
|
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
|
|
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
|
|
4
|
-
|
|
5
|
-
the
|
|
6
|
-
|
|
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/
|
|
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,
|
|
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
|
|
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/
|
|
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`)
|
|
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` / `
|
|
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
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
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/
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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) |
|
|
201
|
-
|
|
202
|
-
| `query`, `field_options`, `workflow`, `members`, `context`, `upload`, `urlState.get/set`, `openExternal` | yes | yes |
|
|
203
|
-
| `comments.*` | yes |
|
|
204
|
-
| `agentRun` (streaming, internal to `useAgentRun`) | yes | yes |
|
|
205
|
-
| `agentRun.get`, `agentRun.cancel` | yes |
|
|
206
|
-
| `agentRuns` (session history) | **no** — the product host doesn't implement it either (`"Unknown RPC op: agentRuns"`) |
|
|
207
|
-
| `askAi` | yes |
|
|
208
|
-
| `openApp` | yes — routes to the sibling in the same tab |
|
|
209
|
-
| `recording.start`, `recording.stop` | yes, when the context states `recording_enabled` |
|
|
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)).
|
|
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
|
|
233
|
-
loading/error state, analytics, and typing from your declared aliases
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
|
264
|
-
recording_enabled?, recordings? }`. `member_id` is the
|
|
265
|
-
embedded, `null` standalone
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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/
|
|
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
|
|
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` (
|
|
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/
|
|
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;
|
|
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
|
-
|
|
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/
|
|
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/
|
|
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
|
-
##
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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.
|
|
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
|
-
- **
|
|
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
|
|
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"
|
|
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`)
|
|
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
|
|
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** —
|
|
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).
|