@lotics/app-sdk 0.60.0 → 0.60.3

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 CHANGED
@@ -17,12 +17,12 @@ signature; open the file.**
17
17
  | [docs/queries.md](./docs/queries.md) | **The query engine authoring reference** — AST node kinds, per-field-type operator support, filters/params/pruning, free-text search, combining tables (join/union/link/`unnest`/`record_id`), shaping (aggregates, date buckets, windows), runtime refinement bounds, limits & the efficiency playbook. |
18
18
  | [docs/data_fetching.md](./docs/data_fetching.md) | The three read hooks (`useQuery`/`useInfiniteQuery`/`usePaginatedQuery`), cell readers (`row.*`, `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`), `useFieldOptions`, data discipline, the search-as-you-type + record-picker patterns. |
19
19
  | [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path), the `WorkflowResult` resolve-never-throw contract, typed inputs, diff-before-update, locked records, `useOptimistic`, read-after-write ordering (a re-read must not overtake an in-flight write). |
20
- | [docs/workflows.md](./docs/workflows.md) | **The workflow-BODY authoring reference** — the JS subset a `src/workflows/<alias>.ts` body may use: the parse-at-save/never-execute model, opaque `fld_*`/`opt_*` keys, expression sources + link descent, every step form (tool call, `agent`, waits, `validate`, `return`), the accepted sugar and its canonical lowering, helpers + callback rules, record-write surfaces, the traps, the bright line, and the `lotics app workflow check` loop. |
21
- | [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload`, `useAttachments`, `readFiles`/presigned URLs, workflow-generated files, preview pairing, filter operators, the server-side delivery bounds. **Uploads declare a `fidelity`** (`standard` / `high` / `original`) — the app picks how much of the image survives storage; use `high` whenever text must stay legible. |
20
+ | [docs/workflows.md](./docs/workflows.md) | **The workflow-BODY authoring reference** — the JS subset a `src/workflows/<alias>.ts` body may use: the parse-at-save/never-execute model, opaque `fld_*`/`opt_*` keys, expression sources + link descent, every step form (tool call, `agent`, waits, `validate`, `return`), the accepted sugar and its canonical lowering, helpers + callback rules, record-write surfaces, the traps, the bright line, and the verify loop — `check` (the only local gate: the app's own `npm run typecheck` never sees a body) → `dry_run_workflow` (static green is not a run) → `set`. |
21
+ | [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload`, `useAttachments`, `readFiles`/presigned URLs (**a bearer credential for the bytes** — never logged, reported, or persisted), workflow-generated files, **naming a zip's entries** (`{ id, name }` per file — a file name, never a path), preview pairing, filter operators, the server-side delivery bounds. **Uploads declare a `fidelity`** (`standard` / `high` / `original`) — the app picks how much of the image survives storage; use `high` whenever text must stay legible. |
22
22
  | [docs/members_and_options.md](./docs/members_and_options.md) | People + select options + comments — `useMembers`, `useFieldOptions`, `useViewer`, `useComments`, and the `@lotics/ui` components they feed. |
23
23
  | [docs/navigation_and_state.md](./docs/navigation_and_state.md) | `AppRouter` (embedded/standalone URL model), `useUrlState` + `urlParam` codecs, `useRecents`. |
24
24
  | [docs/ai.md](./docs/ai.md) | `useAgentRun` (structured vs free-text, streaming ai-sdk `parts` → `AgentRun`, the agent's ask-back — `pendingChoice`/`answerChoice` over the parked `awaiting_input` state), `askAi` — plus the fields-vs-file razor for choosing between them — and `useAiContext` (push the current screen's view state to the member's ambient chat agent; caps, push-only semantics, auto query-refetch on chat mutation). **A `file` input carries its own content** — images/PDFs are perceived natively, Word/Excel/CSV/text are materialized into the run; no reader tool to declare. **An agent reaches record DATA only through its declared `query_aliases` / `workflow_aliases`** (`run_app_query` / `run_app_workflow`); the raw record read/write tools are rejected. |
25
- | [docs/security.md](./docs/security.md) | **Read before shipping** — the owner-principal model, `is_current_member` scoping, write attribution, group gates, public-app bounds, what runtime refinement cannot widen. |
25
+ | [docs/security.md](./docs/security.md) | **Read before shipping** — the owner-principal model, `is_current_member` scoping, write attribution, group gates, public-app bounds, what runtime refinement cannot widen, and why a per-input bound is a tenancy floor rather than an authorization check (a caller-supplied id must be intersected with the record server-side). |
26
26
  | [docs/runtime.md](./docs/runtime.md) | `mount()`, the two transports, `rpc()`, `openExternal`/`downloadFile`, geofencing, analytics, `useConfig` (App-Packages installation config), `getAppBinding` (package apps' runtime `F`/`OPT`/`ROLE` resolution via the generated `.lotics/app_fields.ts`), and the publish chain for package contributors. |
27
27
 
28
28
  ## Non-negotiables (each detailed in its doc)
package/docs/ai.md CHANGED
@@ -306,18 +306,60 @@ Declarative and lifecycle-bound: mounting or changing `context` pushes it; unmou
306
306
  | `slot` | ≤ 50 chars |
307
307
  | `description` | ≤ 1000 chars (truncated with `…`) |
308
308
  | `records` | ≤ 20 refs |
309
- | `data` | must JSON-serialize to ≤ 2000 chars, else the `data` field is **dropped** (the description is kept) |
309
+ | `data` | must JSON-serialize to ≤ 2000 chars, else it is **replaced by an `omitted` marker** the agent reads (the description is kept) |
310
310
  | slots per app | ≤ 8 (a 9th evicts the least-recently-updated) |
311
311
 
312
- Keep `description` tight and human-readable — it is the line the agent reads. Put anything structured (filter/sort/form values) in `data`, and remember it is dropped whole past its size cap, so don't hide load-bearing facts there that aren't also in `description`.
312
+ Keep `description` tight and human-readable — it is the line the agent reads. Put anything structured (filter/sort/form values) in `data`, and remember it is replaced whole past its size cap: the agent is told the state existed but not what it was, so don't hide load-bearing facts there that aren't also in `description`. It is replaced rather than silently dropped so the agent cannot mistake "too big to send" for "there was nothing".
313
313
 
314
314
  ### Security — push-only, a snapshot of what was already rendered
315
315
 
316
- This is a **one-way push of data the app already showed this member**never a channel for chat to pull app-authority data back out. Two consequences hold it to that:
316
+ This hook is a **one-way push of data the app already showed this member**. (Chat can also PULL, through the app's declared aliases — a separate channel with its own `app:use` gate, below.) Two consequences hold the push to what was rendered:
317
317
 
318
318
  - **`records` are raw `{ table_id, record_id }` refs, passed UNRESOLVED.** The app does not resolve them here; the member's own chat agent may read or act on them only where **that member's IAM already allows**. A ref to a record the member can't see stays inert.
319
319
  - **`description` and `data` enter the agent's prompt as clearly-labeled DATA, never as instructions.** Text an app renders can't hijack the agent — the host wraps it as app-supplied view state (see [security](./security.md) for the labeled-data convention).
320
320
 
321
+ ### The chat can also USE the app, not just be told about it
322
+
323
+ `useAiContext` is a PUSH — a snapshot of what one screen rendered. It cannot
324
+ answer a question about a record that is not on screen, and it is capped, so it
325
+ was never the whole story.
326
+
327
+ While a member has an app open, their chat agent can also **call that app's own
328
+ declared aliases** — named queries via `run_app_query`, workflows via
329
+ `run_app_workflow`. This adds no exposure: every declared alias is already
330
+ callable by that member against `POST /v1/apps/{id}/query` and
331
+ `POST /v1/apps/{id}/workflows/{alias}/execute`, so the manifest already IS
332
+ their reachable surface — the agent simply reaches it without devtools. It is
333
+ gated per turn on `app:use` for the app in the member's workspace, and runs
334
+ under the app's OWNER authority with `is_current_member` bound to the MEMBER
335
+ (exactly as the app's own UI does).
336
+
337
+ **Writes carry a second gate that reads do not: `run_app_workflow` asks the
338
+ member to approve every call from chat.** A wrong read is fixed by asking
339
+ again; a wrong write is not. The alias list bounds what an agent *could* reach;
340
+ the approval is what stops the wrong one. Approval applies to the chat surface
341
+ only — an app agent's run is already an explicit act with no human in the loop
342
+ to answer.
343
+
344
+ The prompt names the action and lists the inputs the agent chose, so **your
345
+ input names are what the member reads before authorizing the write**. Name them
346
+ for that reader (`ma_khach_hang`, `so_tien`), and keep the decisive ones
347
+ top-level — a value nested inside an opaque payload shows as JSON, and long
348
+ values are truncated. Context ids the member never chose (`app_id`,
349
+ `workspace_id`, `organization_id`) are filtered out.
350
+
351
+ Nothing about your app changes to get this — declare a workflow as you already
352
+ do, and it becomes reachable. If a workflow should NOT be agent-reachable, it
353
+ does not belong in the manifest at all, since the member can already fire it
354
+ from the app's own buttons.
355
+
356
+ **What this means for what you push.** Keep `description` — the one line that
357
+ says who is on screen and where they stand, which is what makes "this one"
358
+ resolvable. Don't pack bulk rows into `data` to pre-answer questions that may
359
+ never be asked: the agent can fetch them when needed, fresher than a snapshot,
360
+ and a payload pushed on every message is carried by every later turn (see the
361
+ caps above).
362
+
321
363
  ### Query freshness — the mutation companion
322
364
 
323
365
  When the ambient chat agent's turn ends and it mutated records, the host pushes every mounted query hook to re-read, so the screen the member is looking at reflects the agent's change without a manual refresh. That companion behavior is automatic — you write no code for it — and is documented with the query caching contract in [data_fetching](./data_fetching.md#caching-loading-states-and-errors).
package/docs/files.md CHANGED
@@ -210,8 +210,11 @@ file. It is pure and never throws.
210
210
  - **Thumbnails are optimistic.** `thumbnail_url` is emitted for every image without checking that
211
211
  the variant exists; a not-yet-generated variant 404s on fetch. `@lotics/ui`'s `FileThumbnail`
212
212
  falls back to `url` on image error — a hand-rolled `<img src={thumbnail_url}>` must do the same.
213
- - **Don't persist the URLs.** They expire in 24 h. Apps re-query on load, so the TTL refreshes
214
- naturally; store `id`s, never `url`s.
213
+ - **A presigned `url` is a bearer credential for the bytes.** It carries its own authorization
214
+ anyone holding it fetches the file for 24 h with no session — so it must never leave the render
215
+ path: not into a log line, not into telemetry, not into an error report or a bug ticket. Don't
216
+ persist it either; apps re-query on load, so the TTL refreshes naturally. Store `id`s, never
217
+ `url`s.
215
218
  - **Warning:** SVG, HTML, and XHTML files are presigned with a forced-download disposition — their
216
219
  `url` downloads instead of rendering inline (stored-XSS defense). An SVG in an `<img>` will not
217
220
  display; treat these types as download-only.
@@ -278,6 +281,30 @@ if (result.status === "success" && result.files?.length) {
278
281
  - For bytes the app builds *in the browser* (a client-side .xlsx/CSV export), the counterpart is
279
282
  `downloadFile(filename, data, mimeType?)` — see [runtime](./runtime.md).
280
283
 
284
+ ### Naming what goes into a zip
285
+
286
+ A workflow that bundles files (`zip_files`) names each entry itself: an item in `file_ids` is a
287
+ file id, **or** `{ id, name }`. An entry given no name keeps whatever the uploader called the file
288
+ (`IMG_4821.jpg`, `scan (3).pdf`) — which is exactly what the recipient opens — so pass the name the
289
+ app already knows (document type, record key, sequence). Renaming the stored file first is not
290
+ required, and would change what the record shows everyone else.
291
+
292
+ ```js
293
+ // in a workflow body — the two forms mix freely
294
+ const bundle = await zip_files({
295
+ file_ids: [{ id: inputs.signed_doc, name: "Bản ký" }, inputs.appendix],
296
+ filename: "ho-so",
297
+ });
298
+ ```
299
+
300
+ An entry name is a **file name, not a path**. Separators are folded away, so a document number
301
+ survives readably (`Số 123/QĐ-BXD` arrives as `Số 123-QĐ-BXD`); a name without an extension is given
302
+ the stored file's; two files handed the same name are suffixed `_2`, `_3`. A name that cannot be
303
+ read as one fails the run rather than being quietly rewritten — a control character, a non-string,
304
+ over 200 characters, punctuation alone, or the empty string a name expression yields when it found
305
+ nothing. `null` is the one absence that is not an error (an empty record field reads as `null`): it
306
+ means "no name", and that entry keeps the stored filename.
307
+
281
308
  ## Previewing — wiring to `@lotics/ui`
282
309
 
283
310
  Render any file inline — image, PDF, video, audio, Word, Excel, CSV — with `@lotics/ui`. Never
package/docs/queries.md CHANGED
@@ -120,6 +120,15 @@ Delivery-layer enrichment (applied to the response, per request):
120
120
  only for authenticated members of the app's own org).
121
121
  - **`select` cells** — bare option-key arrays become `{ key, label }[]` (colors ride in
122
122
  `useFieldOptions`, not the cell — see [members_and_options.md](./members_and_options.md)).
123
+ **Enrichment needs the column's source addressing**, which a passthrough projection carries, a
124
+ union carries when its arms agree (or per-row, when the compiler emits it), and a **link
125
+ extraction** carries as of the target field it reads. The one shape that has none is a select
126
+ reached through a **lookup FIELD**: the column addresses the lookup, whose own type is `lookup`,
127
+ not `select` — so the cell stays bare `opt_*` keys AND `useFieldOptions` omits the column
128
+ entirely, and reading `.label` off it renders the option KEY. Carry the keys out
129
+ (`readSelect(cell).map((o) => o.key)`) and resolve them against an alias that projects the
130
+ underlying field DIRECTLY — `useFieldOptions` takes no params, so any alias whose schema
131
+ contains it works.
123
132
  - **`number` columns** arrive as JS numbers (Postgres `numeric` strings are coerced; a
124
133
  high-precision decimal that would lose digits stays a string). **`date`/`datetime`** columns
125
134
  arrive as canonical wall-clock strings (`YYYY-MM-DD` / `YYYY-MM-DDTHH:mm`).
@@ -350,6 +359,12 @@ target table can silently re-point (or break) the extraction — prefer field **
350
359
  target's names are volatile. Each extraction is a correlated subquery evaluated per output
351
360
  row — cheap on a filtered detail read, expensive over thousands of rows.
352
361
 
362
+ An extracted column **is** addressed to the field it reads on the target table, so the delivery
363
+ layer treats it like any other field-backed column: an extracted `select` enriches to
364
+ `{key,label}` and appears in `useFieldOptions`, and an extracted date resolves relative-date
365
+ filters in the source field's timezone. It is still never **writable** — a correlated subquery is
366
+ not a write path, and `writable_target` rejects it.
367
+
353
368
  ### The expression escape hatch
354
369
 
355
370
  `{ "expression": "…" }` compiles a small jexpr subset to SQL. Column references are
package/docs/runtime.md CHANGED
@@ -288,6 +288,17 @@ It is the client-side counterpart to the server path (workflow generates a file
288
288
  the browser attributes the download to the user gesture. Build the bytes in
289
289
  the handler, then call `downloadFile` — an `await` between the click and the
290
290
  call can void the gesture and get the download blocked.
291
+ - **One gesture buys one download.** Browsers block — or prompt over — repeated
292
+ programmatic downloads from a single interaction, so a loop calling
293
+ `downloadFile` once per file delivers the first and silently loses the rest.
294
+ Several files leave as **one archive**: build the bundle, hand it over in a
295
+ single call.
296
+ - **Guard a URL before fetching bytes you did not build.** `fetch("")` — an
297
+ empty or relative string where a file URL was expected — resolves against the
298
+ app's own page and returns **200** with the app's HTML, which then saves under
299
+ the filename you intended: a plausible-looking file with the wrong contents
300
+ and no error anywhere. Require the URL to be present and absolute, and treat a
301
+ missing one as a failure, never as an empty download.
291
302
  - `mimeType` defaults to `application/octet-stream`. The blob URL is revoked on
292
303
  the next task, after the download has started.
293
304
 
package/docs/security.md CHANGED
@@ -30,13 +30,21 @@ Callers never submit query ASTs or workflow definitions — the server holds the
30
30
  | `record_link` | Every submitted record id must live in the declared table |
31
31
  | `file` | Every submitted file id must live in the app's workspace — a foreign tenant's `file_id` is rejected |
32
32
 
33
+ **Each of those bounds is a tenancy floor, not an authorization check.** It proves the value
34
+ belongs to the app's own workspace (or to a declared group or table) — never that *this caller*
35
+ should reach *that* row, file, or member. A `file` input is floored to the **workspace**, so a
36
+ caller may substitute the id of any file in it, including one attached to a record they have
37
+ never opened; a `record_link` id is floored to its declared **table**, not to the records the
38
+ caller may see. Whenever a run acts on ids the caller supplied, intersect them server-side with
39
+ what the target record actually holds before reading, bundling, or writing them.
40
+
33
41
  Workflows and agent runs additionally execute behind a **workspace tenant floor**: every table a run touches must live in the app's own workspace, so no input value can steer a run at another workspace's data even under an admin owner's authority. An **agent** run is bounded harder still: it reaches records only through the queries and workflows its declaration names (`query_aliases` / `workflow_aliases`), never a raw table — so the ceiling is what you declared for that agent, not what the owner can see. See [ai](./ai.md).
34
42
 
35
43
  What the platform does **not** constrain is the *meaning* of your params. A query param is a value hole in the template (a filter value, a search term); the server checks its type only — the group/table bindings above apply to workflow and agent-run inputs, **not** to query params — never whether the value should have been derived from the caller's identity. That's the next section.
36
44
 
37
45
  ### The devtools test
38
46
 
39
- Before shipping any query or workflow, ask: **could a member open the browser devtools, replay the app's RPC, and pass someone else's id?** Every declared alias is callable by every member with app access (and by *anyone*, if the app is public) with arbitrary typed values — the app's UI is not a boundary. If substituting another member's id (or another record's id) into a param would show that member's data or perform a write only they should trigger, the design has an IDOR. The fixes are always server-side:
47
+ Before shipping any query or workflow, ask: **could a member open the browser devtools, replay the app's RPC, and pass someone else's id?** Every declared alias is callable by every member with app access (and by *anyone*, if the app is public) with arbitrary typed values — the app's UI is not a boundary. If substituting another member's id (or another record's or file's id) into a param would show that member's data or perform a write only they should trigger, the design has an IDOR. The fixes are always server-side:
40
48
 
41
49
  - Reads scoped to "the current member" → an `is_current_member` filter **in the query template**, never a client-supplied member-id param.
42
50
  - Writes attributed to the caller → `runtime.triggered_by_member_id` **in the workflow body**, never a client-supplied member input.
package/docs/workflows.md CHANGED
@@ -50,6 +50,13 @@ leave `outputs` off to let the body's `return({ data })` derive it), write the b
50
50
  `check`, then `set`. Input declaration vocabulary and validation:
51
51
  [mutations](./mutations.md#declaring-workflow-inputs).
52
52
 
53
+ **Adding an input to an alias that is already bound reverses that order.** `codegen` types a
54
+ *registered* alias from the server's **bound** schema — the manifest declaration is only the
55
+ fallback for an alias the server has never seen — so a newly-declared input is absent from the
56
+ local globals and `check` rejects every read of it, however correct the body is. `set` pushes the
57
+ manifest's `inputs` with the body and the server verifies against *those*, so the working order
58
+ is `set` → `codegen` → `check`.
59
+
53
60
  An app workflow body carries **no trigger declaration** — the binding supplies the trigger
54
61
  context. A stray `on({ … })` line is rejected.
55
62
 
@@ -598,7 +605,9 @@ genuinely recursive.
598
605
  `lotics app workflow check [alias]` runs the server's **own** parse and type passes locally: the
599
606
  same subset parser, the same generated `.d.ts` and envelope, the same compiler options, one
600
607
  isolated program per alias. **Green means pushable and red means real** — do not push through a
601
- red check.
608
+ red check. It is also the *only* local gate on a body: the app project's own `npm run typecheck`
609
+ excludes `src/workflows` (bodies compile against the server's globals, not the app's DOM lib), so
610
+ it never sees one — a fully green app typecheck says nothing about any workflow.
602
611
 
603
612
  Order matters. A subset rejection is reported *alone* and the compiler is skipped, because a body
604
613
  the parser refuses never reaches the type checker on the server anyway. Diagnostics carry
@@ -621,6 +630,22 @@ There is no separate verify endpoint: the loop is `set` → read the returned di
621
630
  round trip, not one per fix — and raw TypeScript shape errors are rewritten into field-naming,
622
631
  fix-stating messages before you see them.
623
632
 
633
+ ### Static green is not a run
634
+
635
+ `check` and `set` prove parse, types, name resolution, and lint — **none of them evaluates an
636
+ expression**. A body that saves clean can still take the wrong branch, hand a tool a filter it
637
+ rejects, or read a path that is null on real data. The rehearsal for that is `dry_run_workflow`
638
+ (`lotics run dry_run_workflow '<json>'`): pass the `source`, `trigger_type: "app_workflow"`,
639
+ `table_id: null`, the input **values** as `trigger_payload`, and the declared schema as
640
+ `app_workflow_inputs`. It walks the real step tree with the production expression evaluator and
641
+ hands back `planned_calls` (every tool call in order, with its fully-resolved input),
642
+ `return_value`, `validation_failures`, `evaluation_errors`, and `tool_input_errors` — inputs the
643
+ target tool would reject, which can only surface once the values are computed. **Write tools are
644
+ recorded, never dispatched**, and nothing is persisted. For a table-triggered body, pass
645
+ `record_id` + `table_id` instead of `trigger_payload` and the read-only tools (`query_records`,
646
+ `get_record`, `aggregate_records`) dispatch for real, so data-dependent gates are exercised
647
+ against actual rows. Do this before the first live run of anything that writes.
648
+
624
649
  ## A worked body
625
650
 
626
651
  An app action that creates an order after checking for a duplicate, then returns the new id.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.60.0",
3
+ "version": "0.60.3",
4
4
  "description": "Runtime SDK for Lotics custom-code apps \u2014 typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {