@lotics/app-sdk 0.84.0 → 0.86.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 CHANGED
@@ -24,7 +24,7 @@ signature; open the file.**
24
24
  | [docs/navigation_and_state.md](./docs/navigation_and_state.md) | `AppRouter` (embedded/standalone URL model), `useUrlState` + `urlParam` codecs, `useRecents`. |
25
25
  | [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 — and the `AgentRunLanding` every leg resolves), `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; a chat mutation refetches your queries through the realtime channel, not a separate poke). **A `file` input carries its own content** — no reader tool to declare. **An agent reaches record DATA only through its declared `query_aliases` / `workflow_aliases`.** Also **what the member's own chat agent can do with your app while it is open** — the alias catalog it reads and how to shape a mutating alias for it. |
26
26
  | [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). |
27
- | [docs/runtime.md](./docs/runtime.md) | `mount()`, the two transports, `rpc()`, the design-time mock harness (`fixture` + `?__mock=1` — queries AND workflows, so an AI screen's in-flight/done/error states are reviewable without running or paying for anything), `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
+ | [docs/runtime.md](./docs/runtime.md) | `mount()`, the two transports, `rpc()`, the design-time mock harness (`fixture` + `?__mock=1` — queries AND workflows, so an AI screen's in-flight/done/error states are reviewable without running or paying for anything), `openExternal`/`downloadFile`, geofencing, and the publish chain for SDK contributors. |
28
28
 
29
29
  ## Non-negotiables (each detailed in its doc)
30
30
 
@@ -21,12 +21,10 @@ export type { QueryRow, UploadedFile, AttachedFile, BaseQueryOptions, QueryOptio
21
21
  export { useComments, useCommentCounts } from "./comments.js";
22
22
  export type { AppComment, AppCommentFile, CommentsState, UseCommentsArgs, CommentCountsState, UseCommentCountsArgs, } from "./comments.js";
23
23
  export { useViewer } from "./viewer.js";
24
- export { useConfig } from "./config.js";
25
- export type { AppConfigValue } from "./rpc.js";
26
24
  export { requestGeofencedLocation, isWithinZone } from "./geolocation.js";
27
25
  export type { GeofenceZone, GeoCoords, GeofenceOutcome, GeofenceOptions } from "./geolocation.js";
28
- export { rpc, isEmbedded, getAppBinding } from "./rpc.js";
29
- export type { RpcOp, AppBinding, AiContextValue, AiContextRecordRef } from "./rpc.js";
26
+ export { rpc, isEmbedded } from "./rpc.js";
27
+ export type { RpcOp, AiContextValue, AiContextRecordRef } from "./rpc.js";
30
28
  export { openExternal } from "./open_external.js";
31
29
  export { askAi, type AskAiArgs } from "./ask_ai.js";
32
30
  export { downloadFile } from "./download.js";
package/dist/src/index.js CHANGED
@@ -18,9 +18,8 @@ export { mount } from "./mount.js";
18
18
  export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useCount, useFieldOptions, useFileUpload, useAttachments, useMembers, useAgentRun, useAgentRuns, useAiContext, buildChoiceOutput, } from "./hooks.js";
19
19
  export { useComments, useCommentCounts } from "./comments.js";
20
20
  export { useViewer } from "./viewer.js";
21
- export { useConfig } from "./config.js";
22
21
  export { requestGeofencedLocation, isWithinZone } from "./geolocation.js";
23
- export { rpc, isEmbedded, getAppBinding } from "./rpc.js";
22
+ export { rpc, isEmbedded } from "./rpc.js";
24
23
  export { openExternal } from "./open_external.js";
25
24
  export { askAi } from "./ask_ai.js";
26
25
  export { downloadFile } from "./download.js";
@@ -59,7 +59,7 @@ export declare function registerMockFixture(fixture: AppFixture | undefined): vo
59
59
  /**
60
60
  * True iff the iframe URL carries the `__mock=1` activation flag. Throws never;
61
61
  * a malformed URL or missing `window` (SSR / jsdom without location) silently
62
- * returns false. Distinct from `isMockMode`: analytics keys off the raw flag
62
+ * returns false. Distinct from `isMockMode`: a caller may key off the raw flag
63
63
  * (a design-time / screenshot load emits no events regardless of fixtures),
64
64
  * while query mocking additionally requires a registered fixture.
65
65
  */
package/dist/src/mock.js CHANGED
@@ -48,7 +48,7 @@ export function registerMockFixture(fixture) {
48
48
  /**
49
49
  * True iff the iframe URL carries the `__mock=1` activation flag. Throws never;
50
50
  * a malformed URL or missing `window` (SSR / jsdom without location) silently
51
- * returns false. Distinct from `isMockMode`: analytics keys off the raw flag
51
+ * returns false. Distinct from `isMockMode`: a caller may key off the raw flag
52
52
  * (a design-time / screenshot load emits no events regardless of fixtures),
53
53
  * while query mocking additionally requires a registered fixture.
54
54
  */
@@ -27,7 +27,7 @@
27
27
  * `mount` wires up React 19's createRoot against `#root` in the iframe shell,
28
28
  * sets up window.onerror/unhandledrejection forwarding so runtime crashes
29
29
  * surface in the parent's debug pane, registers the fixture (if any), renders
30
- * the user's tree, and bootstraps PostHog analytics (see `./analytics.ts`).
30
+ * the user's tree.
31
31
  *
32
32
  * If the bundler doesn't ship #root in the user's `index.html`, we create it
33
33
  * — Vite's default scaffold provides one, but defensive creation keeps the
package/dist/src/mount.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { createRoot } from "react-dom/client";
2
2
  import { registerMockFixture } from "./mock.js";
3
- import { bootstrapAnalytics } from "./analytics.js";
4
3
  export function mount(element, options = {}) {
5
4
  registerMockFixture(options.fixture);
6
5
  let container = document.getElementById("root");
@@ -14,11 +13,6 @@ export function mount(element, options = {}) {
14
13
  // *something* even before the parent's debug telemetry is wired up.
15
14
  installVisibleErrorHandlers(container);
16
15
  createRoot(container).render(element);
17
- // Fire-and-forget: resolve the app's identity and start PostHog (explicit
18
- // events — autocapture is disabled project-wide). Never awaited — analytics
19
- // must not delay first paint, and is best-effort so its failure can't break
20
- // the app. No-ops in mock mode and off the deployed app host.
21
- void bootstrapAnalytics();
22
16
  }
23
17
  function installVisibleErrorHandlers(container) {
24
18
  const showError = (message) => {
package/dist/src/rpc.d.ts CHANGED
@@ -20,7 +20,7 @@ import { type UrlParams, type UrlParamsPatch } from "./url_params.js";
20
20
  * app → host: { id, op, payload }
21
21
  * host → app: { id, type: "result", data } | { id, type: "error", message }
22
22
  */
23
- export type RpcOp = "query" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "members" | "context" | "binding" | "openExternal" | "askAi" | "urlState.get" | "urlState.set" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts";
23
+ export type RpcOp = "query" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "members" | "context" | "openExternal" | "askAi" | "urlState.get" | "urlState.set" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts";
24
24
  /** Payload for starting a streaming agent run. */
25
25
  export interface AgentRunPayload {
26
26
  alias: string;
@@ -37,24 +37,11 @@ export interface AgentRunHandle {
37
37
  * The app's identity, resolved once at startup to tag PostHog events.
38
38
  * Assembled by whichever transport is active:
39
39
  *
40
- * - **Bridged** — the host (authenticated) supplies `member_id` so app events
41
- * attach to the same PostHog person as the product, plus org/workspace/app
42
- * from its own context.
40
+ * - **Bridged** — the host (authenticated) supplies `member_id`, plus
41
+ * org/workspace/app from its own context.
43
42
  * - **Standalone** — the public `/by-subdomain` endpoint returns identity;
44
43
  * `member_id` is null (anonymous visitor).
45
- *
46
- * The PostHog key/host are not here — they're the public project key, hardcoded
47
- * in `analytics.ts`.
48
- */
49
- /**
50
- * A single installation config value. An app package declares typed config knobs
51
- * (labels, toggles, theme, column choices) with defaults; an installation stores
52
- * the customized values, which the SDK's `useConfig()` reads. The canonical type
53
- * lives in `@lotics/shared` (`AppInstallationConfigValue`); the SDK mirrors it
54
- * here so it stays a zero-internal-dependency published package (the boundary
55
- * mirror, like `AppContext` itself).
56
44
  */
57
- export type AppConfigValue = string | number | boolean;
58
45
  /** A raw reference to one record — table + record id, passed through UNRESOLVED.
59
46
  * The member's own chat agent may act on it only where that member's IAM already
60
47
  * allows; the app never resolves it into data here. */
@@ -87,10 +74,6 @@ export type HostNotification = {
87
74
  context: AiContextValue | null;
88
75
  };
89
76
  export interface AppContext {
90
- app_id: string;
91
- app_name: string;
92
- workspace_id: string;
93
- organization_id: string;
94
77
  member_id: string | null;
95
78
  /**
96
79
  * Whether the app declared the `comments` capability. `useComments` is
@@ -98,13 +81,6 @@ export interface AppContext {
98
81
  * app that didn't opt in, and (vacuously) for standalone visitors.
99
82
  */
100
83
  comments_enabled: boolean;
101
- /**
102
- * The installation's customization values — the package's config knobs by
103
- * alias (see `useConfig`). Empty for a bespoke app (no package) and until the
104
- * context resolves. Read-only; an app changes config through the product, not
105
- * the SDK.
106
- */
107
- config: Record<string, AppConfigValue>;
108
84
  }
109
85
  /**
110
86
  * Whether the app is running embedded in a Lotics host (vs. standalone at its
@@ -217,26 +193,3 @@ export declare function transportErrorMessage(status: number, parsed: unknown):
217
193
  * message, and a non-JSON or 5xx body never becomes one.
218
194
  */
219
195
  export declare function streamStartError(res: Response): Promise<Error>;
220
- /**
221
- * A package installation's alias→concrete-id maps — what the generated
222
- * `.lotics/app_fields.ts` of a package project resolves `F`/`OPT`/`ROLE`
223
- * through at module load. Keys are fully-qualified contract aliases
224
- * (`entity.field`, `entity.field:opt`, role alias); values are this
225
- * installation's concrete ids. 404s for a bespoke (non-package) app.
226
- */
227
- export interface AppBinding {
228
- fields: Record<string, string>;
229
- options: Record<string, string>;
230
- roles: Record<string, string>;
231
- }
232
- /**
233
- * Fetch the installation's binding, once per boot (module-cached — every
234
- * `app_fields` import shares the same in-flight promise). Called at module
235
- * load via top-level await, so it must work before `mount()` — and a failure
236
- * there rejects the whole module graph (blank frame, no ErrorBoundary can
237
- * catch module evaluation). Two mitigations, mirroring `boot()`:
238
- * bounded retries absorb a transient transport blip, and a rejection is never
239
- * cached so a remount/direct caller can retry rather than replaying the same
240
- * stale failure forever.
241
- */
242
- export declare function getAppBinding(): Promise<AppBinding>;
package/dist/src/rpc.js CHANGED
@@ -457,11 +457,11 @@ function readSessionCookie() {
457
457
  return null;
458
458
  }
459
459
  /**
460
- * Resolve the app's identity + analytics config from its own subdomain. Shared
460
+ * Resolve the app's identity from its own subdomain. Shared
461
461
  * promise so the context bootstrap and the first data call coalesce into one
462
- * `/by-subdomain` fetch. Does NOT touch the password gate — identity is needed
463
- * for analytics regardless of whether the visitor has unlocked the data, so a
464
- * password-gated app still resolves (and tracks) before the prompt.
462
+ * `/by-subdomain` fetch. Does NOT touch the password gate — the app's own
463
+ * identity is not the gated thing, so a password-gated app still resolves it
464
+ * before the prompt.
465
465
  */
466
466
  function resolveAppInfo() {
467
467
  if (appInfoPromise)
@@ -653,8 +653,6 @@ function rpcStandalone(op, payload) {
653
653
  return standaloneMembers(payload);
654
654
  case "context":
655
655
  return standaloneContext();
656
- case "binding":
657
- return standaloneBinding();
658
656
  case "openExternal":
659
657
  return standaloneOpenExternal(payload);
660
658
  case "askAi":
@@ -685,46 +683,6 @@ function rpcStandalone(op, payload) {
685
683
  function rejectCommentsStandalone() {
686
684
  return Promise.reject(new Error("Comments are available only in embedded apps — a signed-in member is required."));
687
685
  }
688
- let bindingPromise;
689
- /**
690
- * Fetch the installation's binding, once per boot (module-cached — every
691
- * `app_fields` import shares the same in-flight promise). Called at module
692
- * load via top-level await, so it must work before `mount()` — and a failure
693
- * there rejects the whole module graph (blank frame, no ErrorBoundary can
694
- * catch module evaluation). Two mitigations, mirroring `boot()`:
695
- * bounded retries absorb a transient transport blip, and a rejection is never
696
- * cached so a remount/direct caller can retry rather than replaying the same
697
- * stale failure forever.
698
- */
699
- export function getAppBinding() {
700
- if (bindingPromise === undefined) {
701
- const attempt = fetchBindingWithRetry();
702
- bindingPromise = attempt;
703
- attempt.catch(() => {
704
- if (bindingPromise === attempt)
705
- bindingPromise = undefined;
706
- });
707
- }
708
- return bindingPromise;
709
- }
710
- async function fetchBindingWithRetry() {
711
- const delays = [500, 1500];
712
- for (const delay of delays) {
713
- try {
714
- return await rpc("binding", {});
715
- }
716
- catch {
717
- await new Promise((resolve) => setTimeout(resolve, delay));
718
- }
719
- }
720
- return rpc("binding", {});
721
- }
722
- async function standaloneBinding() {
723
- const { app_id } = await boot();
724
- return (await apiCall("GET", `/v1/apps/${app_id}/binding`, undefined, {
725
- appId: app_id,
726
- }));
727
- }
728
686
  async function standaloneMembers(p) {
729
687
  const { app_id } = await boot();
730
688
  const qs = p.group ? `?group_id=${encodeURIComponent(p.group)}` : "";
@@ -764,15 +722,10 @@ async function standaloneUrlStateSet(p) {
764
722
  async function standaloneContext() {
765
723
  const info = await resolveAppInfo();
766
724
  return {
767
- app_id: info.app_id,
768
- app_name: info.app_name,
769
- workspace_id: info.workspace_id,
770
- organization_id: info.organization_id,
771
725
  // No host session in standalone mode — the visitor is anonymous, so
772
726
  // comments are unavailable regardless of the capability flag.
773
727
  member_id: null,
774
728
  comments_enabled: info.comments_enabled,
775
- config: info.config ?? {},
776
729
  };
777
730
  }
778
731
  async function standaloneQuery(p) {
@@ -1,13 +1,11 @@
1
- import { type AppConfigValue } from "./rpc.js";
2
1
  /**
3
2
  * Read the app's context once, shared across every hook via a stable SWR key.
4
3
  * The host (the product iframe, or `lotics app dev`) supplies the signed-in
5
- * member, the app's declared capabilities, and the installation's config.
4
+ * member and the app's declared capabilities.
6
5
  */
7
6
  export declare function useAppContext(): {
8
7
  memberId: string | null;
9
8
  commentsEnabled: boolean;
10
- config: Record<string, AppConfigValue>;
11
9
  resolved: boolean;
12
10
  };
13
11
  /**
@@ -1,14 +1,9 @@
1
1
  import useSWR from "swr";
2
2
  import { rpc } from "./rpc.js";
3
- // Stable identity for the not-yet-resolved / bespoke-app case. An inline `{}`
4
- // fallback would be a fresh reference every render, defeating useConfig's
5
- // memo (its result would recompute — and re-render consumers — on every
6
- // render even with unchanged values).
7
- const EMPTY_CONFIG = {};
8
3
  /**
9
4
  * Read the app's context once, shared across every hook via a stable SWR key.
10
5
  * The host (the product iframe, or `lotics app dev`) supplies the signed-in
11
- * member, the app's declared capabilities, and the installation's config.
6
+ * member and the app's declared capabilities.
12
7
  */
13
8
  export function useAppContext() {
14
9
  const { data } = useSWR("app-context", () => rpc("context", {}), {
@@ -20,7 +15,6 @@ export function useAppContext() {
20
15
  return {
21
16
  memberId: data?.member_id ?? null,
22
17
  commentsEnabled: data?.comments_enabled ?? false,
23
- config: data?.config ?? EMPTY_CONFIG,
24
18
  resolved: data !== undefined,
25
19
  };
26
20
  }
package/docs/runtime.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # The app runtime
2
2
 
3
3
  How a custom-code app boots and talks to the platform: **`mount()`** (the entry
4
- point, including the design-time mock harness and automatic PostHog analytics),
4
+ point, including the design-time mock harness),
5
5
  the **two transports** the SDK switches between (embedded postMessage bridge vs.
6
6
  standalone direct API — app code never branches), the raw **`rpc()`** escape
7
7
  hatch, and the browser capabilities the sandbox would otherwise block —
@@ -32,9 +32,6 @@ mount(<App />);
32
32
  visible in the iframe itself, even before any host telemetry is wired up.
33
33
  Banners are informational only; they are not removed automatically.
34
34
  4. **Renders the tree** with React 19's `createRoot`.
35
- 5. **Boots analytics** fire-and-forget (see [Automatic analytics](#automatic-analytics-posthog)).
36
- Never awaited — it cannot delay first paint, and any failure leaves the app
37
- fully functional and untracked.
38
35
 
39
36
  ### The mock harness (`options.fixture` + `?__mock=1`)
40
37
 
@@ -239,14 +236,11 @@ semantics are documented:
239
236
  The **streaming** agent-run op is *not* reachable through `rpc()` — its
240
237
  response is a chunk stream, not a single value; it's internal to `useAgentRun`.
241
238
 
242
- `rpc("context", {})` resolves the app's identity: `{ app_id, app_name,
243
- workspace_id, organization_id, member_id, comments_enabled, config }`.
244
- `member_id` is the signed-in member when embedded, `null` standalone; `config`
245
- is the installation's customization map read it through
246
- [`useConfig`](#installation-config--useconfig), not off this raw op. **Limitation:**
247
- the context type is not exported from the package root — type the result
248
- yourself via the `rpc<T>` generic (`AppConfigValue`, the config value type, *is*
249
- exported).
239
+ `rpc("context", {})` resolves the viewer: `{ member_id, comments_enabled }`.
240
+ `member_id` is the signed-in member when embedded, `null` standalone — read it
241
+ through `useViewer()` rather than this op. **Limitation:** the context type is
242
+ not exported from the package root, so type the result yourself via the `rpc<T>`
243
+ generic.
250
244
 
251
245
  ## `openExternal()` — open a link in a new tab
252
246
 
@@ -359,106 +353,21 @@ await checkIn({ latitude: r.coords.latitude, longitude: r.coords.longitude });
359
353
  there is no workflow log to find. If field failures need diagnosing, surface
360
354
  the `reason` visibly in the UI.
361
355
 
362
- ## Automatic analytics (PostHog)
356
+ ## Analytics
363
357
 
364
- `mount()` boots a PostHog instance per app apps are a separate cross-origin
365
- bundle, invisible to the product's own analytics. It is entirely automatic:
366
- don't install `posthog-js` or call any analytics API from app code, and there is
367
- no hook for app-defined events. If a bespoke funnel matters, request the surface
368
- as a platform change.
358
+ The SDK ships no analytics and no hook for app-defined events. Don't add
359
+ `posthog-js` or another browser SDK to an app either: the app host serves every
360
+ response under a `Content-Security-Policy` whose `connect-src` reaches only the
361
+ Lotics API, so a third-party collector's requests are blocked and the only
362
+ visible result is console noise.
369
363
 
370
- The SDK emits one event, `app_opened`, once PostHog finishes initializing.
371
- Everything an app *does* is already recorded by the platform — a comment, an
364
+ Most of what an app does is already recorded platform-side — a comment, an
372
365
  upload, a workflow or agent run each leave a row, and a run's outcome, including
373
- a user's Stop (`cancel_requested_at`), lands in `app_agent_runs`.
374
-
375
- - Autocapture, pageviews, dead clicks, and session replay are all off, set at
376
- init rather than relied on project-wide: the server-side setting fails open,
377
- and an app screen renders the customer's own records.
378
- - `app_opened` carries the app identity as super-properties — `app_id`,
379
- `workspace_id`, `organization_id`, ids only, since a name is the customer's
380
- own words — and rolls up under the `organization` group. Embedded apps `identify` the signed-in member — app and
381
- product events share one person; standalone visitors stay anonymous.
382
- - Uncaught exceptions are captured (PostHog error tracking) in addition to
383
- `mount()`'s visible banner — the app's one error channel.
384
- - **Tracking is gated to the deployed app host** (`*.lotics.app`). `lotics app
385
- dev` (localhost) and any `?__mock=1` load emit nothing. Best-effort
386
- throughout: a failed init or a failed `context` resolution leaves the app
387
- fully working and untracked.
388
- - **Limitation:** PostHog's default bot/user-agent filter applies — headless
389
- browsers (e.g. Playwright) are never tracked, so analytics cannot be verified
390
- through headless automation.
391
-
392
- ## Installation config — `useConfig()`
393
-
394
- **`useConfig(defaults?)`** → `{ config, loading }` — the App-Packages customization
395
- knobs for this installation (labels, toggles, theme, column choices), keyed by the
396
- alias the package contract declares. Exact signature: `dist/src/config.d.ts`.
366
+ a user's Stop (`cancel_requested_at`), lands in `app_agent_runs`. App opens are
367
+ counted at the serving edge. If a funnel needs something those cannot answer,
368
+ request it as a platform change.
397
369
 
398
- ```tsx
399
- const { config } = useConfig({ board_title: "Tasks", show_archive: false });
400
- ```
401
-
402
- - **Pass the contract's defaults.** They fill un-overridden knobs and paint
403
- flicker-free before the context resolves; the installation's stored values
404
- overlay them (a stored value always wins over the same-key default). `loading`
405
- is true only until the **one-shot** context RPC settles (it never revalidates)
406
- — gate on it only for config-derived layout that must not flash a default.
407
- - Values are `string | number | boolean` (`AppConfigValue`, exported from the
408
- package root). The returned object is referentially stable across renders while
409
- the values are unchanged — the merge is memoized, and the defaults are keyed by
410
- their *content*, so passing a fresh inline `{…}` literal every render is fine
411
- (no churn); it's safe as a `useEffect`/`useMemo` dependency.
412
- - **Bespoke apps** (not installed from a package) get `{}` from the host — the
413
- hook resolves to just the defaults, so shared code needs no package check.
414
- - **A standalone (public) app behind a password never receives its stored config
415
- in the client.** The knobs are gated server-side (they can carry business
416
- terms), and the SDK resolves the app's identity once — before the visitor
417
- authenticates — so `useConfig` returns the defaults only, even after unlock.
418
- Design a public, password-gated app to be correct on its defaults alone.
419
- (Embedded and standalone-*unprotected* apps both get the stored config.)
420
- - **Read-only.** Config is edited in the product (the installation's settings),
421
- never written from app code — there is no setter.
422
-
423
- ## Installation binding — `getAppBinding()` (package apps)
424
-
425
- **`getAppBinding()`** → `Promise<AppBinding>` — a package installation's
426
- alias→concrete-id maps: `{ fields, options, roles }`, keyed by fully-qualified
427
- contract aliases (`"tasks.title"`, `"tasks.status:to_do"`, role alias) with this
428
- workspace's `fld_`/`opt_`/`grp_` ids as values. Exact signature:
429
- `dist/src/rpc.d.ts`.
430
-
431
- You normally never call it yourself: on a package installation (a published
432
- origin, or an install of someone's package) `lotics app codegen` emits a
433
- binding-form `.lotics/app_fields.ts` that calls it once at **module load** via
434
- top-level await and exports plain-string `F` / `OPT` / `ROLE` maps — the same
435
- import surface as a bespoke app's baked codegen, but resolved per-installation
436
- at runtime:
437
-
438
- ```ts
439
- import { OPT } from "../.lotics/app_fields";
440
- const STATUS_DONE = OPT.TASKS.status.done; // this installation's "opt_…"
441
- ```
442
-
443
- - **One fetch per boot.** The promise is module-cached; every importer shares
444
- it. The ESM graph awaits it before any dependent module evaluates, so the
445
- values are ordinary strings everywhere — including module-top-level constants.
446
- Transient failures are retried (3 attempts, short backoff) and a rejection is
447
- never cached — a module-load failure rejects the whole graph (blank frame),
448
- so the fetch absorbs blips rather than bricking the boot on one lost request.
449
- - **Requires the starter's `build.target: "es2022"`** (top-level await does not
450
- exist below it). An app scaffolded before the starter set that target must
451
- set it in `vite.config.ts` before shipping the binding form.
452
- - **Fails loud.** A key missing from the binding throws at boot with the alias
453
- named — the binding is verified complete at install/adopt, so a miss means
454
- the generated file is stale relative to the installed contract version
455
- (re-run the codegen, republish).
456
- - **Bespoke apps 404.** An app that is not a package installation has no
457
- binding; codegen emits the baked variant for it instead. Shared code should
458
- not call this directly — import from `.lotics/app_fields` and let codegen
459
- pick the form.
460
-
461
- ## For package contributors
370
+ ## For SDK contributors
462
371
 
463
372
  Everything below concerns changing `@lotics/app-sdk` itself (in the Lotics
464
373
  monorepo), not building apps with it.
@@ -518,7 +427,7 @@ is a transport that wasn't wired.
518
427
  - **Data + RPC only — zero UI.** Never re-export a `@lotics/ui` component; the
519
428
  SDK stays off the React-Native-Web dependency tree. Apps import `@lotics/ui`
520
429
  directly.
521
- - **Keep the dependency set minimal.** Runtime deps are `posthog-js` and `swr`;
430
+ - **Keep the dependency set minimal.** Runtime deps are `ai` and `swr`;
522
431
  `react`/`react-dom` are peers. `react-router` is an **optional** peer
523
432
  pulled in only by the `@lotics/app-sdk/router` subpath export — the root entry
524
433
  must never import it. (`react-router` is the canonical package; the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.84.0",
3
+ "version": "0.86.0",
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": {
@@ -27,7 +27,6 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "ai": "^7.0.30",
30
- "posthog-js": "^1.352.0",
31
30
  "swr": "^2.4.1"
32
31
  },
33
32
  "peerDependencies": {
@@ -1 +0,0 @@
1
- export declare function bootstrapAnalytics(): Promise<void>;
@@ -1,113 +0,0 @@
1
- /**
2
- * PostHog analytics for custom-code apps.
3
- *
4
- * Apps are a separate cross-origin bundle at `<slug>.lotics.app`, invisible to
5
- * the product's PostHog — so `mount()` boots a PostHog instance per app.
6
- *
7
- * **Why explicit events, not autocapture:** DOM autocapture ships `$el_text` —
8
- * the visible text of whatever was clicked, i.e. the customer's own record
9
- * values — and Điều 3.2 of the service contract warrants that our telemetry
10
- * carries no record or document content. The flags below are what enforce
11
- * that; the project-level setting is NOT a backstop, since it is server-side
12
- * and fails open when the config fetch fails.
13
- *
14
- * **What earns an event:** who KNOWS the fact. An effect the platform performs
15
- * or persists is the platform's — a comment, an upload, a workflow or agent run
16
- * all leave rows, and a client copy would duplicate one AND keep arriving from
17
- * apps pinned to an old SDK long after the code is deleted. A FAILURE is not a
18
- * gesture either; it is system telemetry, and this bundle has no log sink to
19
- * carry it, so it is not an event here by default.
20
- *
21
- * That leaves exactly one: `app_opened`. Apps are a separate origin the
22
- * product's own analytics cannot see, and the app-host gate log records gate
23
- * DECISIONS rather than routine serves, so nothing else counts an app being
24
- * used. Everything a run does is already in `app_agent_runs` — including a
25
- * user's Stop, which stamps `cancel_requested_at`.
26
- *
27
- * Every event is tagged with app identity and rolls up under the existing
28
- * `organization` group; embedded apps `identify` the member the host passes
29
- * down so app + product events share one person. Exception capture is on (the
30
- * project opts in); session replay is off (on by default project-wide, but
31
- * apps — including anonymous public visitors — were never scoped for it).
32
- *
33
- * The key is the public, write-only project key (already shipped in every
34
- * browser), hardcoded here. Tracking is gated to the deployed app host
35
- * (`*.lotics.app`); `lotics app dev` runs on localhost and stays untracked.
36
- * Best-effort: any failure leaves the app fully functional and untracked.
37
- */
38
- import posthog from "posthog-js";
39
- import { rpc } from "./rpc.js";
40
- import { hasMockFlag } from "./mock.js";
41
- const POSTHOG_KEY = "phc_N1nyqSRdo9XMK3DODxrxX2Y9jG3dppybruOuMznbz62";
42
- const POSTHOG_HOST = "https://us.i.posthog.com";
43
- const APP_HOST_SUFFIX = ".lotics.app";
44
- function onDeployedAppHost() {
45
- try {
46
- return window.location.hostname.endsWith(APP_HOST_SUFFIX);
47
- }
48
- catch {
49
- return false;
50
- }
51
- }
52
- export async function bootstrapAnalytics() {
53
- // Only the deployed app host tracks: a design-time/screenshot load
54
- // (?__mock=1) and `lotics app dev` (localhost) emit nothing. This replaces
55
- // the old "no key off-prod" gate now that the key is hardcoded.
56
- if (hasMockFlag() || !onDeployedAppHost())
57
- return;
58
- let ctx;
59
- try {
60
- ctx = await rpc("context", {});
61
- }
62
- catch {
63
- // Best-effort: a context-resolution failure must never break the app.
64
- return;
65
- }
66
- posthog.init(POSTHOG_KEY, {
67
- // Pin the modern posthog-js init contract (the product uses the same).
68
- defaults: "2025-05-24",
69
- api_host: POSTHOG_HOST,
70
- // App analytics is explicit events — not DOM autocapture or pageviews.
71
- // Set here rather than relied on project-wide: the server-side setting
72
- // fails open, and an app screen renders the customer's own records.
73
- autocapture: false,
74
- mask_all_text: true,
75
- capture_pageview: false,
76
- capture_pageleave: false,
77
- // Dead-click autocapture (enabled by the defaults preset) is a UX-research
78
- // signal nobody consumes; explicit user actions + exception capture cover
79
- // the app's monitoring needs.
80
- capture_dead_clicks: false,
81
- // Error tracking (the project opts in). mount() only renders a local
82
- // banner, so this is the app's one exception channel.
83
- capture_exceptions: true,
84
- // Session replay is on by default project-wide; never on the app surface.
85
- disable_session_recording: true,
86
- // posthog-js keeps its default bot/user-agent filter, so headless/bot
87
- // traffic is never tracked (real users are unaffected). One consequence:
88
- // analytics can't be verified through headless Playwright — it's filtered.
89
- //
90
- // Tag + capture from `loaded` (once PostHog has initialized) — the robust
91
- // point to register super-properties and emit the first event.
92
- loaded: (ph) => {
93
- // Ids only. A super-property rides every event and every `$exception`
94
- // the session sends, and an app's NAME is the customer's own words —
95
- // routinely a counterparty — which Điều 3.2 keeps out of telemetry. The
96
- // id resolves to the name for anyone entitled to it.
97
- ph.register({
98
- app_id: ctx.app_id,
99
- workspace_id: ctx.workspace_id,
100
- organization_id: ctx.organization_id,
101
- });
102
- // Guarded: a bridged host that answered `context` before its auth member
103
- // loaded sends an empty org — never group on an empty key.
104
- if (ctx.organization_id)
105
- ph.group("organization", ctx.organization_id);
106
- // Embedded apps attach to the same person as the product; standalone
107
- // visitors stay anonymous (member_id null).
108
- if (ctx.member_id)
109
- ph.identify(ctx.member_id);
110
- ph.capture("app_opened");
111
- },
112
- });
113
- }
@@ -1,24 +0,0 @@
1
- import type { AppConfigValue } from "./rpc.js";
2
- /**
3
- * Read the installation's customization config — the first rung of the App
4
- * Packages customization ladder (see docs/packages.md § The customization
5
- * ladder). A package declares typed config knobs with defaults (labels, feature
6
- * toggles, theme, column choices); each installation stores the customized values
7
- * and the app reads them here. Updates flow: editing config in the product
8
- * changes what this returns, with no re-deploy.
9
- *
10
- * `defaults` is the package contract's declared defaults — the canonical fallback
11
- * (the codegen surface emits them so the call is fully typed). They fill any knob
12
- * the installation hasn't overridden and provide a flicker-free first paint:
13
- * before the context resolves the stored map is empty, so the defaults show
14
- * immediately and the stored values overlay them once resolved.
15
- *
16
- * const { config } = useConfig({ "deal.label": "Deal", "show_archived": false });
17
- * <Text>{config["deal.label"]}</Text>
18
- *
19
- * Gate on `loading` only for config-derived layout that must not flash a default.
20
- */
21
- export declare function useConfig<T extends Record<string, AppConfigValue> = Record<string, AppConfigValue>>(defaults?: T): {
22
- config: T;
23
- loading: boolean;
24
- };
@@ -1,33 +0,0 @@
1
- import { useMemo } from "react";
2
- import { useAppContext } from "./viewer.js";
3
- /**
4
- * Read the installation's customization config — the first rung of the App
5
- * Packages customization ladder (see docs/packages.md § The customization
6
- * ladder). A package declares typed config knobs with defaults (labels, feature
7
- * toggles, theme, column choices); each installation stores the customized values
8
- * and the app reads them here. Updates flow: editing config in the product
9
- * changes what this returns, with no re-deploy.
10
- *
11
- * `defaults` is the package contract's declared defaults — the canonical fallback
12
- * (the codegen surface emits them so the call is fully typed). They fill any knob
13
- * the installation hasn't overridden and provide a flicker-free first paint:
14
- * before the context resolves the stored map is empty, so the defaults show
15
- * immediately and the stored values overlay them once resolved.
16
- *
17
- * const { config } = useConfig({ "deal.label": "Deal", "show_archived": false });
18
- * <Text>{config["deal.label"]}</Text>
19
- *
20
- * Gate on `loading` only for config-derived layout that must not flash a default.
21
- */
22
- export function useConfig(defaults) {
23
- const ctx = useAppContext();
24
- const stored = ctx.config;
25
- // Key the memo on the defaults CONTENT, not identity. An inline literal is a
26
- // fresh reference each render, so a content hash keeps the result reference
27
- // stable while values are unchanged AND recomputes when a default value changes
28
- // (e.g. an i18n label after a locale switch) — which a ref-captured defaults
29
- // would silently miss. Config maps are tiny, so the stringify cost is trivial.
30
- const defaultsKey = JSON.stringify(defaults ?? {});
31
- const config = useMemo(() => ({ ...(defaults ?? {}), ...stored }), [stored, defaultsKey]);
32
- return { config, loading: !ctx.resolved };
33
- }