@lotics/app-sdk 0.125.9 → 0.126.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.
- package/AGENTS.md +2 -2
- package/dist/{chunk-2KJSE73G.js → chunk-GACWCLEV.js} +14 -13
- package/dist/index.d.ts +0 -1
- package/dist/index.js +51 -16
- package/dist/open_external.d.ts +1 -1
- package/dist/router.d.ts +4 -0
- package/dist/router.js +11 -2
- package/dist/rpc.d.ts +4 -11
- package/dist/shared_types.d.ts +3 -26
- package/dist/viewer.d.ts +0 -5
- package/docs/data_fetching.md +1 -1
- package/docs/members_and_options.md +2 -96
- package/docs/mutations.md +1 -2
- package/docs/navigation_and_state.md +7 -0
- package/docs/runtime.md +9 -64
- package/docs/security.md +0 -4
- package/package.json +1 -1
- package/dist/open_app.d.ts +0 -24
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Members & select options
|
|
2
2
|
|
|
3
|
-
How an app renders and picks **people** and **select-field options**, the host context they are
|
|
4
|
-
read from
|
|
3
|
+
How an app renders and picks **people** and **select-field options**, and the host context they are
|
|
4
|
+
read from. Query mechanics live in [queries](./queries.md); the authority
|
|
5
5
|
model in [security](./security.md).
|
|
6
6
|
|
|
7
7
|
## Cells vs. catalogs — the model
|
|
@@ -261,11 +261,6 @@ host did not say (a visitor from outside the organization, an older host). Both
|
|
|
261
261
|
context, read once as the app opens. Display only: the workflow body re-checks who may act
|
|
262
262
|
(`current_member_stands_in_for(...)`).
|
|
263
263
|
|
|
264
|
-
`openableApps` lists the workspace's apps the viewer can open, each by its `id` and its `name`, and `table_id`, the table its register lists, where the host says — what a
|
|
265
|
-
way through to a sibling app (`openApp`, `peekApp`) is offered by. `null` where the host did not say (standalone, a
|
|
266
|
-
render, an older host, or a page that had not listed its apps when the app opened). Display only: the
|
|
267
|
-
app opened checks the viewer itself.
|
|
268
|
-
|
|
269
264
|
`hostNamesApp` is `true` where the host's own chrome already names the app on screen (the workspace's
|
|
270
265
|
app page), `false` everywhere else and from an older host. Where it is `true`, a screen that titles
|
|
271
266
|
itself with the app's name draws no visible title, keeping its `<h1>` for assistive technology alone
|
|
@@ -290,94 +285,6 @@ until it resolves, where it failed, and from a host that predates them.
|
|
|
290
285
|
where the field states no code of its own. Pass it as the money's `currency`: a money format
|
|
291
286
|
given no code prints a default market, which says nothing about this workspace.
|
|
292
287
|
|
|
293
|
-
## Comments: `useComments` / `useCommentCounts`
|
|
294
|
-
|
|
295
|
-
Record comments — member-to-member discussion attached to any record the app reaches
|
|
296
|
-
(`dist/comments.d.ts`).
|
|
297
|
-
|
|
298
|
-
### Enabling comments
|
|
299
|
-
|
|
300
|
-
Opt in on the app — a JSON app states it in its model; a custom-code app sets it live, as a new
|
|
301
|
-
version:
|
|
302
|
-
|
|
303
|
-
```jsonc
|
|
304
|
-
// set_app
|
|
305
|
-
{ "app_id": "app_…", "capabilities": { "comments": true } }
|
|
306
|
-
```
|
|
307
|
-
|
|
308
|
-
Both hooks expose **`available`** — `true` only when a member is signed in **and** the app declared
|
|
309
|
-
the capability. Gate the composer on it. When `false` (any standalone/public app view, or an app
|
|
310
|
-
that didn't opt in) lists stay empty, `loading` settles to `false`, and every mutation rejects —
|
|
311
|
-
client-side up front, and re-checked server-side.
|
|
312
|
-
|
|
313
|
-
### Authority & attribution
|
|
314
|
-
|
|
315
|
-
- **Access runs under the app, not the member's table IAM.** A member who can use the app (and the
|
|
316
|
-
app declares `comments`) may read/write comments on any record the app's workspace holds,
|
|
317
|
-
regardless of their own table access. The one floor: the record must live in the app's own
|
|
318
|
-
workspace — a crafted foreign `record_id` errors.
|
|
319
|
-
- **The author is always the real signed-in member** — correct attribution, enforced server-side.
|
|
320
|
-
**Warning:** under "View as", comments still author as the real member (the admin), not the
|
|
321
|
-
view-as target — unlike `useViewer` and `is_current_member` scoping, which follow the target.
|
|
322
|
-
- **Edit and delete are author-only**, checked server-side against the viewer. `Timeline`'s
|
|
323
|
-
`currentMemberId` prop drives the matching affordance client-side.
|
|
324
|
-
|
|
325
|
-
### Reading & writing
|
|
326
|
-
|
|
327
|
-
```tsx
|
|
328
|
-
// In a record screen, where an id definitionally exists:
|
|
329
|
-
const { comments, available, createComment } = useComments({ record_id: recordId });
|
|
330
|
-
|
|
331
|
-
// From a row, gate the whole panel rather than passing a placeholder:
|
|
332
|
-
{row.__source_record_id && <CommentsPanel recordId={row.__source_record_id} />}
|
|
333
|
-
```
|
|
334
|
-
|
|
335
|
-
`record_id` must be a **real** id — the fetch keys on `available` alone, so an empty string is not
|
|
336
|
-
skipped, it fetches comments for nothing. When the id comes from a row's `__source_record_id`
|
|
337
|
-
addressing column (see [queries](./queries.md)) narrow it first: a grouped query answers its addressing
|
|
338
|
-
columns as `null`, and a hook cannot be called conditionally, so the guard belongs at the component that
|
|
339
|
-
renders the panel. State: `{ comments, loading, error, available, createComment,
|
|
340
|
-
updateComment, deleteComment, refetch }`.
|
|
341
|
-
|
|
342
|
-
- `comments` — newest first on the wire (server order); a thread usually reads oldest-first, so
|
|
343
|
-
reverse it for display. Each `AppComment`: `{ id, record_id,
|
|
344
|
-
table_id, member_id, author, content, files, workspace_id, created_at, updated_at }`. Attachments
|
|
345
|
-
(`AppCommentFile`) carry `id` / `filename` / `mime_type` and a presigned `url` (plus
|
|
346
|
-
`thumbnail_url` for an image), valid 24 hours and refreshed by every read. A file's identity is
|
|
347
|
-
its `id`: the server reads every attachment from the file by that id, so nothing else you hold
|
|
348
|
-
about a file can affect what is stored.
|
|
349
|
-
- `createComment({ content, file_ids? })` — posts as the viewing member. `file_ids` come from
|
|
350
|
-
`useFileUpload` / `useAttachments` (see [files](./files.md)). A comment must have content or at
|
|
351
|
-
least one file (empty input is a client no-op; the server enforces the same rule). A file id
|
|
352
|
-
that names no live file in this workspace refuses the comment. Content max 10,000 characters.
|
|
353
|
-
- `updateComment(id, { content, files? })` — omit `files` to keep the current attachment set (a
|
|
354
|
-
text-only edit never drops attachments); pass `files` to replace the whole set. An id the comment does not already hold must name a live
|
|
355
|
-
file in this workspace; one it holds stays even once its file is archived.
|
|
356
|
-
- All three mutations apply **optimistically** with rollback on error, then repopulate from the
|
|
357
|
-
server. **Limitation:** an optimistic create renders a placeholder row (temporary id, empty
|
|
358
|
-
`table_id`/`workspace_id`, `files: null`) until the refetch lands — don't persist anything keyed
|
|
359
|
-
on it.
|
|
360
|
-
- **The author names itself.** `author` carries `{ name, image? }`, resolved server-side on every
|
|
361
|
-
comment the server returns — list, create and update alike — including one written from another
|
|
362
|
-
app by someone the record references nowhere, so a thread needs no declared member access.
|
|
363
|
-
`name` is `null` when the id no longer resolves in the org (a removed member) — render a
|
|
364
|
-
fallback (`Timeline` has an `unknownMember` label for exactly this). The one comment with no
|
|
365
|
-
`author` is the optimistic row `createComment` renders locally: the SDK knows the viewer's id and
|
|
366
|
-
not their name, and it will not invent one. The server's row replaces it when the refetch lands.
|
|
367
|
-
- **Freshness:** cached like a query — re-read on arrival and on focus/reconnect, so another
|
|
368
|
-
viewer's comment appears on the next arrival, focus or explicit `refetch()` — not on its own: the realtime channel carries **record**
|
|
369
|
-
changes, and a comment emits no record event. Your queries DO get pushed
|
|
370
|
-
([data_fetching.md](./data_fetching.md)).
|
|
371
|
-
|
|
372
|
-
### Counts
|
|
373
|
-
|
|
374
|
-
`useCommentCounts({ table_id })` → `{ counts, loading, error, available, refetch }` where `counts`
|
|
375
|
-
is `{ record_id: count }` for the whole table — row badges without shipping any comment content
|
|
376
|
-
(one server-side aggregate). Same gate and authority as `useComments`. `table_id` is the source
|
|
377
|
-
table's id: read it off a row of the query that renders the badges (`rows[0]?.__source_table_id`)
|
|
378
|
-
rather than pasting a `tbl_` id, which a copy of the app cannot carry. Pass `undefined` while no
|
|
379
|
-
row is on screen — no request is sent and `counts` is `{}`.
|
|
380
|
-
|
|
381
288
|
## Outside links: `useOutsideLinks`
|
|
382
289
|
|
|
383
290
|
`useOutsideLinks(recordId)` (`dist/outside_link.d.ts`) → `{ links, loading, error, available, create, revoke }` — a
|
|
@@ -400,4 +307,3 @@ to the link made, or `null` when the member backed out; `revoke` to whether it d
|
|
|
400
307
|
| `useMembers` | ✓ (same-org + declaration gates) | ✗ errors |
|
|
401
308
|
| `useViewer` | member id (view-as target) | `null` |
|
|
402
309
|
| `useWorkspaceTimezone` / `useWorkspaceCurrency` | ✓ | ✓ |
|
|
403
|
-
| `useComments` / `useCommentCounts` | ✓ when capability declared | ✗ `available: false` |
|
package/docs/mutations.md
CHANGED
|
@@ -332,8 +332,7 @@ const onClose = async (recordId: string) => {
|
|
|
332
332
|
Only on **success**: a refused write changed nothing. A body that names a table only at run time,
|
|
333
333
|
a run whose writes could not be predicted, a run before the app's write model has loaded, a run
|
|
334
334
|
by a visitor from outside your organization, and a `useAgentRun` leg that lands, re-read every
|
|
335
|
-
mounted query.
|
|
336
|
-
every `useCommentCounts`. Under a design-time
|
|
335
|
+
mounted query. Under a design-time
|
|
337
336
|
fixture every mounted read re-reads, since a fixture has no body to read tables from. The host's
|
|
338
337
|
realtime push is separate: it carries changes **other** people make, and is far too slow for your
|
|
339
338
|
own.
|
|
@@ -54,6 +54,9 @@ routes, and splats all work. Inside the tree, use react-router normally:
|
|
|
54
54
|
message naming the path and a link to the first route — instead of an empty
|
|
55
55
|
page, and a layout route keeps its shell around that message. Declaring your
|
|
56
56
|
own `{ path: "*" }` replaces it.
|
|
57
|
+
- **A route that decides its own addresses draws the same screen.** `NotFoundScreen`
|
|
58
|
+
(`{ home, words }`) is exported from `@lotics/app-sdk/router` for a splat route
|
|
59
|
+
that parses the path itself and finds none of its screens at it.
|
|
57
60
|
- **The not-found screen speaks the app's language.** Its two strings default
|
|
58
61
|
to English; an app whose reader reads another language passes them as
|
|
59
62
|
`notFound`, next to `routes`:
|
|
@@ -130,6 +133,10 @@ never the query or hash. The host counts one view per change, so a
|
|
|
130
133
|
static segment is reported as written, so never build a route's `path` from
|
|
131
134
|
your data: a record's id or name goes in a `:param`.
|
|
132
135
|
|
|
136
|
+
A splat route (`path: "/*"`) stands for many screens, so it states each one's
|
|
137
|
+
shape itself, from the path alone and with no id or text in it:
|
|
138
|
+
`handle: { shape: (pathname) => "/:view/:row" }`.
|
|
139
|
+
|
|
133
140
|
A drawer the app opens at a query key rather than a path is a screen too when
|
|
134
141
|
`drawers` names its key, listed from the one drawn lowest:
|
|
135
142
|
|
package/docs/runtime.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
How an app boots and talks to the platform: **`mount()`** (with the design-time mock harness),
|
|
4
4
|
the **two transports** the SDK switches between (app code never branches), the raw **`rpc()`**
|
|
5
5
|
escape hatch, and the browser capabilities the sandbox would otherwise block — **`openExternal`**,
|
|
6
|
-
**`
|
|
6
|
+
**`downloadFile`**, and **`requestGeofencedLocation`** / **`isWithinZone`**.
|
|
7
7
|
|
|
8
8
|
## `mount()` — the entry point
|
|
9
9
|
|
|
@@ -114,7 +114,7 @@ pending state; `fixture.exportApps[key]` answers `exportApp(key, narrowing)` the
|
|
|
114
114
|
"live", elapsed_seconds: 754, inputs: { site: "rec_1" } } }` makes
|
|
115
115
|
`useRecording("log_visit")` available with that `state`; `start` and `stop`
|
|
116
116
|
resolve without recording anything or moving the state.
|
|
117
|
-
- **Not mocked:** uploads, `useFieldOptions`, members
|
|
117
|
+
- **Not mocked:** uploads, `useFieldOptions`, members. In mock mode
|
|
118
118
|
those still hit the real transport.
|
|
119
119
|
- **Analytics is disabled** whenever `?__mock=1` is present, fixture or not — a
|
|
120
120
|
screenshot/design-time load emits no events.
|
|
@@ -142,7 +142,7 @@ helper works identically in both. `isEmbedded(): boolean` is exported
|
|
|
142
142
|
| 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) |
|
|
143
143
|
| 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 |
|
|
144
144
|
| 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 |
|
|
145
|
-
| Viewer identity | The signed-in member (`useViewer`,
|
|
145
|
+
| Viewer identity | The signed-in member (`useViewer`, agent runs available) | `member_id` is `null`; members-only surfaces reject |
|
|
146
146
|
|
|
147
147
|
### Embedded: the postMessage bridge
|
|
148
148
|
|
|
@@ -218,13 +218,10 @@ surfaces:
|
|
|
218
218
|
| Op | Embedded (product) | Standalone |
|
|
219
219
|
|---|---|---|
|
|
220
220
|
| `query`, `field_options`, `workflow`, `members`, `context`, `upload`, `urlState.get/set`, `openExternal` | yes | yes |
|
|
221
|
-
| `comments.*` | yes | rejects — `"Comments are available only in embedded apps — a signed-in member is required."` |
|
|
222
221
|
| `agentRun` (streaming, internal to `useAgentRun`) | yes | yes |
|
|
223
222
|
| `agentRun.get`, `agentRun.cancel` | yes | yes |
|
|
224
223
|
| `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 …"`) |
|
|
225
224
|
| `askAi` | yes | rejects — `"askAi is only available when the app runs inside Lotics"` |
|
|
226
|
-
| `openApp` | yes — routes to the sibling in the same tab | rejects — `"openApp needs the Lotics host …"` |
|
|
227
|
-
| `peekApp` | yes, when the context states `peek_app_enabled` — opens the sibling in a sheet over this app | rejects — `"peekApp needs the Lotics host …"` |
|
|
228
225
|
| `recording.start`, `recording.stop` | yes, when the context states `recording_enabled` | rejects — `"Recording needs the Lotics host and a signed-in member …"` |
|
|
229
226
|
|
|
230
227
|
**Limitation:** Don't build a session-log UI on `useAgentRuns`; keep the log in app
|
|
@@ -259,21 +256,18 @@ semantics are documented:
|
|
|
259
256
|
|---|---|---|---|
|
|
260
257
|
| `query` | `{ alias, params?, limit?, offset?, … }` | `{ rows }` | [queries](./queries.md), [data fetching](./data_fetching.md) |
|
|
261
258
|
| `export` | `{ alias, params?, filter?, sort?, report, file }` (`exportQuery`) or `{ export: { key, … } }` (`exportApp`) | `{ file }` | [data fetching](./data_fetching.md) |
|
|
262
|
-
| `spec` | `{}` | `HostSpec`: the app's compiled `app.json` itself (format
|
|
259
|
+
| `spec` | `{}` | `HostSpec`: the app's compiled `app.json` itself (format 31: `views`, the root Lists and Sheets in order; `pages` by entity, each with the Views its Lists draw; `entities`; flat `acts` and `checks`), never the route's wrapper | this doc |
|
|
263
260
|
| `field_options` | `{ alias }` | `{ fields }` | [members & options](./members_and_options.md) |
|
|
264
261
|
| `workflow` | `{ alias, inputs }` | `WorkflowResult` | [mutations](./mutations.md) |
|
|
265
262
|
| `upload` | `{ file: File }` (the `File` crosses the bridge by structured clone) | uploaded-file object | [files](./files.md) |
|
|
266
263
|
| `members` | `{ group? }` | `{ members }` | [members & options](./members_and_options.md) |
|
|
267
264
|
| `context` | `{}` | app identity (below) | this doc |
|
|
268
265
|
| `openExternal` | `{ url }` | `void` | this doc |
|
|
269
|
-
| `openApp` | `{ app_id, route }` | `void` | this doc |
|
|
270
|
-
| `peekApp` | `{ app_id, route }` | `void` | this doc |
|
|
271
266
|
| `askAi` | prompt/files/records seed | `void` | [ai](./ai.md) |
|
|
272
267
|
| `agentRuns` | `{ session_id, limit?, offset? }` | `{ runs }` | [ai](./ai.md) |
|
|
273
268
|
| `agentRun.get` | `{ run_id }` | `{ run }` | [ai](./ai.md) |
|
|
274
269
|
| `agentRun.cancel` | `{ run_id }` | `{ ok: true }` | [ai](./ai.md) |
|
|
275
270
|
| `urlState.get` / `urlState.set` | — / `{ params }` | `UrlParams` / `void` | [navigation & state](./navigation_and_state.md) |
|
|
276
|
-
| `comments.list/create/update/delete/counts` | comment ops | comment payloads | [members & options](./members_and_options.md) |
|
|
277
271
|
| `recording.start` / `recording.stop` | `{ alias, inputs }` / `{ alias }` | `{ started }` / `void` | [mutations](./mutations.md#recording-into-a-workflow-userecording) |
|
|
278
272
|
| `outsideLink.list/create/revoke` | `{ record_id }` / `{ record_id, outside }` / `{ record_id, link_id }` | links / link or `null` / `{ id }` or `null` | [members & options](./members_and_options.md#outside-links-useoutsidelinks) |
|
|
279
273
|
|
|
@@ -285,7 +279,7 @@ The **streaming** agent-run op is *not* reachable through `rpc()` — its
|
|
|
285
279
|
response is a chunk stream, not a single value; it's internal to `useAgentRun`.
|
|
286
280
|
|
|
287
281
|
`rpc("context", {})` resolves the viewer and the workspace:
|
|
288
|
-
`{ member_id,
|
|
282
|
+
`{ member_id, timezone, default_currency, recording_enabled?, recordings? }`. `member_id` is the
|
|
289
283
|
signed-in member when embedded, `null` standalone; `timezone` is the workspace's
|
|
290
284
|
IANA zone and `default_currency` its ISO 4217 code, from the host when embedded
|
|
291
285
|
and from `/v1/apps/by-subdomain/{subdomain}` standalone. Read them through
|
|
@@ -317,9 +311,9 @@ embedded app iframe is sandboxed **without `allow-popups`**, so a direct
|
|
|
317
311
|
host frame (embedded) or the app's own top-level
|
|
318
312
|
page (standalone). Opens in a new tab with `noopener,noreferrer`.
|
|
319
313
|
|
|
320
|
-
- **Scheme validation at the point of open**: only `http
|
|
321
|
-
|
|
322
|
-
`openExternal: unsupported URL scheme "<
|
|
314
|
+
- **Scheme validation at the point of open**: only `http:`, `https:`, `tel:`,
|
|
315
|
+
`mailto:` (the last two open in place); any other scheme (`javascript:`, `data:`)
|
|
316
|
+
rejects with `openExternal: unsupported URL scheme "<scheme>"`, a non-string `url`
|
|
323
317
|
rejects with `openExternal requires a url string`. The opener re-validates on
|
|
324
318
|
its own side — a URL handed across the bridge is never trusted.
|
|
325
319
|
- Typical use: opening a workflow-generated file's `url` from
|
|
@@ -327,55 +321,6 @@ page (standalone). Opens in a new tab with `noopener,noreferrer`.
|
|
|
327
321
|
- **Not a preview mechanism.** To *view* a file inline, render it in the app
|
|
328
322
|
(see [files](./files.md)); `openExternal` is "leave the app".
|
|
329
323
|
|
|
330
|
-
## `openApp()` — the cross-app hop
|
|
331
|
-
|
|
332
|
-
```tsx
|
|
333
|
-
import { openApp, isEmbedded } from "@lotics/app-sdk";
|
|
334
|
-
await openApp(PLANNING_APP_ID, `/plan/${record.id}`);
|
|
335
|
-
```
|
|
336
|
-
|
|
337
|
-
`openApp(appId: string, route?: string): Promise<void>` (`dist/open_app.d.ts`).
|
|
338
|
-
A workspace built as several apps around one spine shows another app's record
|
|
339
|
-
read-only with a way THROUGH to the app that owns it; this is the way through.
|
|
340
|
-
The host owns app routing, so it lands the viewer on `route` inside `appId` in
|
|
341
|
-
the **same tab**, chrome kept — `_loc` carries the screen exactly as
|
|
342
|
-
`AppRouter` mirrors it, so the target boots at that record rather than at its
|
|
343
|
-
root. `route` is the target app's own in-app path, exactly as its router
|
|
344
|
-
declares it (`/` for its register); it is the target's to define.
|
|
345
|
-
|
|
346
|
-
- **Checked at the bridge, host-side**: the id by shape (`app_…`), the route as
|
|
347
|
-
an in-app path — starts with `/`, never `//` or a scheme. An app never
|
|
348
|
-
assembles the host's URL itself.
|
|
349
|
-
- **By transport**: embedded routes in place; standalone rejects (`openApp needs the Lotics host …`) — gate the
|
|
350
|
-
control on `isEmbedded()`.
|
|
351
|
-
- A sibling's id is this workspace's: a copy of the suite has different ones,
|
|
352
|
-
and the portability gate refuses a concrete `app_` in `src/` and in every
|
|
353
|
-
`.md`. The id reaches the app as data — a field the model binds, a query's
|
|
354
|
-
row — never as a literal.
|
|
355
|
-
- Put it on a control the person presses. A hop on mount pushes the host's
|
|
356
|
-
history without a gesture, and two apps that each do it leave Back nowhere
|
|
357
|
-
to go. A hop to the app's own id is refused — navigate inside an app with
|
|
358
|
-
its own router.
|
|
359
|
-
|
|
360
|
-
## `peekApp()` — a sibling's record, over this app
|
|
361
|
-
|
|
362
|
-
```tsx
|
|
363
|
-
import { peekApp, useAppContext } from "@lotics/app-sdk";
|
|
364
|
-
const { peekAppEnabled } = useAppContext();
|
|
365
|
-
if (peekAppEnabled) await peekApp(PARTIES_APP_ID, `/${party.id}`);
|
|
366
|
-
```
|
|
367
|
-
|
|
368
|
-
`peekApp(appId: string, route?: string): Promise<void>` (`dist/open_app.d.ts`). The host opens `route` of `appId` in
|
|
369
|
-
a side sheet over this app — a panel from the right on a desk, a full-height sheet on a phone — and this app stays as
|
|
370
|
-
it was beneath. The sibling draws its own record, in its own frame, under its own workflows and permissions; this app
|
|
371
|
-
never draws a second copy of it. The sheet's head carries the sibling's name, "Open in <name>" (what `openApp` does,
|
|
372
|
-
closing the sheet) and a close; Escape closes it too. One sheet at a time: a `peekApp` from inside the sheet replaces it.
|
|
373
|
-
|
|
374
|
-
- **Checked at the bridge**, as `openApp`'s target: the id by shape, the route as an in-app path. A hop to the app's own
|
|
375
|
-
id, and to an app the host's `openableApps` does not list, is refused.
|
|
376
|
-
- **By transport**: embedded in the product host opens the sheet; standalone rejects (`peekApp needs the Lotics host …`).
|
|
377
|
-
A host that does not serve the op states no `peekAppEnabled` in `context` — gate the control on it, else offer `openApp`.
|
|
378
|
-
|
|
379
324
|
## `downloadFile()` — save browser-built bytes
|
|
380
325
|
|
|
381
326
|
```tsx
|
|
@@ -465,7 +410,7 @@ response under a `Content-Security-Policy` whose `connect-src` reaches only the
|
|
|
465
410
|
Lotics API, so a third-party collector's requests are blocked and the only
|
|
466
411
|
visible result is console noise.
|
|
467
412
|
|
|
468
|
-
Most of what an app does is already recorded platform-side —
|
|
413
|
+
Most of what an app does is already recorded platform-side — an
|
|
469
414
|
upload, a workflow or agent run each leave a row, and a run's outcome, including
|
|
470
415
|
a user's Stop (`cancel_requested_at`), lands in `app_agent_runs`. App opens are
|
|
471
416
|
counted at the serving edge. If a funnel needs something those cannot answer,
|
package/docs/security.md
CHANGED
|
@@ -9,7 +9,6 @@ Every data operation an app performs — queries, workflows, agent runs — exec
|
|
|
9
9
|
| Named queries (`useQuery`, the query RPC) | App owner | Yes — bound server-side into `is_current_member` / `current_member` filter predicates |
|
|
10
10
|
| Workflows (`useWorkflow`) | App owner | Yes — `runtime.triggered_by_member_id` in the workflow body (`null` for anonymous) |
|
|
11
11
|
| Agent runs (`useAgentRun`) | App owner | Yes — requires an authenticated member; runs are private to that member |
|
|
12
|
-
| Comments (`useComments`) | App authority for **access**; the **author** is always the real member | Always — members-only, anonymous callers are rejected |
|
|
13
12
|
|
|
14
13
|
Consequences of owner authority:
|
|
15
14
|
|
|
@@ -18,8 +17,6 @@ Consequences of owner authority:
|
|
|
18
17
|
- **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
18
|
- **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
19
|
|
|
21
|
-
Comments are the one deliberate exception: access is app-authority (any member with `app:use` + the app's declared `comments` capability may read/post on any record in the app's workspace, regardless of their own table access — a tenant floor rejects record ids outside the app's workspace), but the **author is always the real authenticated member** — never the app owner, never a "View as" subject — and edit/delete are author-only. Anonymous visitors cannot comment: the comment endpoints require an authenticated member.
|
|
22
|
-
|
|
23
20
|
## The caller boundary
|
|
24
21
|
|
|
25
22
|
Callers never submit query ASTs or workflow definitions — the server holds the canonical, bind-validated template and the caller supplies only an **alias plus typed values**. Three per-input constraints are enforced server-side on **workflow and agent-run inputs** at invocation time, so a hand-crafted request can't redirect a run executing under owner authority:
|
|
@@ -100,7 +97,6 @@ A publicly-shared app (its own origin, or its public link) is reachable by **any
|
|
|
100
97
|
| File upload (workflow `file` inputs) | Yes — bounded to the app's workspace |
|
|
101
98
|
| Query/workflow file outputs | Yes — file cells and workflow-produced files return direct presigned URLs (24-hour TTL) that anonymous viewers can fetch; see [files](./files.md) |
|
|
102
99
|
| Agent runs (`useAgentRun`) | **No** — rejected: agent runs require an authenticated member, and each member's run history is private to them (a guessed session id cannot read another member's thread) |
|
|
103
|
-
| Comments (`useComments`) | **No** — members-only |
|
|
104
100
|
| Member roster (`useMembers`) | **No** — same-org members only (below) |
|
|
105
101
|
|
|
106
102
|
**Warning — `is_current_member` on a public app resolves to the app owner.** An anonymous request has no viewer to bind, so both `is_current_member` and `current_member in_any_group` fall back to the owner's identity and the owner's groups. A "my items" query shows every anonymous visitor the *owner's* items, and a manager-only read template gated on a group the owner belongs to **passes for every anonymous visitor**. Never rely on viewer-bound predicates in a public app.
|
package/package.json
CHANGED
package/dist/open_app.d.ts
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Land on `route` — the target app's own path — inside sibling app `appId`,
|
|
3
|
-
* same tab.
|
|
4
|
-
*
|
|
5
|
-
* ```tsx
|
|
6
|
-
* import { openApp } from "@lotics/app-sdk";
|
|
7
|
-
* await openApp(PLANNING_APP_ID, `/plan/${record.id}`);
|
|
8
|
-
* ```
|
|
9
|
-
*
|
|
10
|
-
* Rejects standalone; gate on `isEmbedded()`.
|
|
11
|
-
*/
|
|
12
|
-
export declare function openApp(appId: string, route?: string): Promise<void>;
|
|
13
|
-
/**
|
|
14
|
-
* Open sibling app `appId`'s own `route` in a sheet over this app, which stays as it was beneath. The sibling draws its
|
|
15
|
-
* record itself, in its own frame, under its own workflows and permissions.
|
|
16
|
-
*
|
|
17
|
-
* ```tsx
|
|
18
|
-
* import { peekApp } from "@lotics/app-sdk";
|
|
19
|
-
* await peekApp(PARTIES_APP_ID, `/${party.id}`);
|
|
20
|
-
* ```
|
|
21
|
-
*
|
|
22
|
-
* Rejects standalone, and on a host that does not state `useAppContext().peekAppEnabled`.
|
|
23
|
-
*/
|
|
24
|
-
export declare function peekApp(appId: string, route?: string): Promise<void>;
|