@trylight/app 0.3.7 → 0.3.9

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/README.md CHANGED
@@ -60,8 +60,10 @@ every `POLL_MS` while the tab is visible, at once on focus and reconnect. The
60
60
  route answers from `light_changes` with the tables changed since the cursor
61
61
  (looking `CHANGES_OVERLAP` behind it, for transactions that committed after
62
62
  the last poll read) and the next cursor; never rows. Each changed table's
63
- queries are invalidated by its key (except a table this tab is writing: the
64
- write's own refetch follows it), and the route loaders reloaded. `light_changes`' `created_at` index is made
63
+ queries are invalidated: those under its key, and those that list its table in
64
+ `meta.tables` (a query that reads several tables, e.g. a server function's
65
+ join), except a table this tab is writing (the write's own refetch follows
66
+ it); the route loaders are reloaded. `light_changes`' `created_at` index is made
65
67
  after every migrate (`src/db/audit.ts`). The poll is one `ChangeFeed`; a pushed
66
68
  transport can replace it.
67
69
 
@@ -1,7 +1,7 @@
1
1
  import type { AuthContext } from "./context.js";
2
2
  import { type PlazaSession } from "./plaza.js";
3
3
  /** The cookie holding this app's own session. Nothing of the platform's is read here. */
4
- export { SESSION_COOKIE } from "../platform/contract.js";
4
+ export { APP_SESSION_COOKIE as SESSION_COOKIE } from "../brand.generated.js";
5
5
  /**
6
6
  * How often a session asks the platform whether it still stands. Someone removed from
7
7
  * the org is signed out of the app within this long; their chat stops at once,
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import { checkPlazaSession, revokePlazaSession } from "./plaza.js";
3
3
  /** The cookie holding this app's own session. Nothing of the platform's is read here. */
4
- export { SESSION_COOKIE } from "../platform/contract.js";
4
+ export { APP_SESSION_COOKIE as SESSION_COOKIE } from "../brand.generated.js";
5
5
  /**
6
6
  * How often a session asks the platform whether it still stands. Someone removed from
7
7
  * the org is signed out of the app within this long; their chat stops at once,
@@ -1,2 +1,4 @@
1
1
  /** The platform app's origins, current domain first. */
2
2
  export declare const APP_ORIGINS: readonly string[];
3
+ /** The cookie holding an app's own session. */
4
+ export declare const APP_SESSION_COOKIE = "mii_session";
@@ -1,3 +1,5 @@
1
1
  // Generated from config/brand.json by scripts/write-brand.ts. Do not edit.
2
2
  /** The platform app's origins, current domain first. */
3
3
  export const APP_ORIGINS = ["https://trylight.ai", "https://plaza.miis.run"];
4
+ /** The cookie holding an app's own session. */
5
+ export const APP_SESSION_COOKIE = "mii_session";
package/dist/client.d.ts CHANGED
@@ -2,6 +2,14 @@ import { type QueryKey } from "@tanstack/react-query";
2
2
  import type { Contracts } from "./invoke/server.js";
3
3
  import { EntityError, type ListOptions, type NewRow, type Patch, type Row } from "./entities.js";
4
4
  import type { Entity } from "./schema.js";
5
+ declare module "@tanstack/react-query" {
6
+ interface Register {
7
+ /** `tables`: the tables a query reads when it isn't keyed under one (a server function's join); a write to any refetches it. */
8
+ queryMeta: {
9
+ tables: readonly Entity[];
10
+ };
11
+ }
12
+ }
5
13
  export { EntityError, LIST_LIMIT, type ListOptions, type NewRow, type Sort, type Patch, type Row, type Where } from "./entities.js";
6
14
  export type { FieldSource, FieldSources } from "./schema.js";
7
15
  export { siteOf } from "./schema.js";
@@ -68,7 +76,8 @@ declare function tableClient<T extends Entity>(entity: T): {
68
76
  /**
69
77
  * The query key everything this table caches sits under. A page's own
70
78
  * query over the table (a server function's totals) keyed under it is
71
- * refetched after every write too.
79
+ * refetched after every write too; one over several tables names them in
80
+ * `meta.tables` instead.
72
81
  */
73
82
  key: string[];
74
83
  list: (options?: ListOptions<Row<T>>) => Promise<Row<T>[]>;
package/dist/client.js CHANGED
@@ -13,6 +13,7 @@ import { z } from "zod/v4";
13
13
  import { ENTITIES_PATH } from "./platform/contract.js";
14
14
  import { EntityError, LIST_LIMIT, tableQueryKey } from "./entities.js";
15
15
  import { fetchThroughRestart } from "./platform/restart.js";
16
+ import { reads } from "./platform/live-data.js";
16
17
  export { EntityError, LIST_LIMIT } from "./entities.js";
17
18
  export { siteOf } from "./schema.js";
18
19
  export { imageSrc } from "./images.js";
@@ -124,8 +125,9 @@ function putBack(client, id, before) {
124
125
  }
125
126
  /** One entity table's rows: plain calls, and TanStack Query hooks over them. */
126
127
  function tableClient(entity) {
127
- const base = `${ENTITIES_PATH}/${getTableName(entity)}`;
128
- const all = tableQueryKey(getTableName(entity));
128
+ const table = getTableName(entity);
129
+ const base = `${ENTITIES_PATH}/${table}`;
130
+ const all = tableQueryKey(table);
129
131
  const keys = {
130
132
  all,
131
133
  list: (options) => [...all, "list", options],
@@ -168,7 +170,7 @@ function tableClient(entity) {
168
170
  const client = useQueryClient();
169
171
  const refetch = async () => {
170
172
  if (client.isMutating({ mutationKey: keys.write }) === 1)
171
- await client.invalidateQueries({ queryKey: keys.all });
173
+ await client.invalidateQueries({ predicate: (query) => reads(query, table) });
172
174
  };
173
175
  return (_data, error) => {
174
176
  if (error === null)
@@ -180,7 +182,8 @@ function tableClient(entity) {
180
182
  /**
181
183
  * The query key everything this table caches sits under. A page's own
182
184
  * query over the table (a server function's totals) keyed under it is
183
- * refetched after every write too.
185
+ * refetched after every write too; one over several tables names them in
186
+ * `meta.tables` instead.
184
187
  */
185
188
  key: keys.all,
186
189
  list,
@@ -20,7 +20,7 @@ export type MiiInvoke<C extends Contracts> = {
20
20
  readonly handler: (request: Request) => Promise<Response>;
21
21
  /**
22
22
  * Start a contract from a POST server function, as the person whose request
23
- * it is answering, and return the run's id without waiting for it: for work
23
+ * it is answering (on a public app, a visitor with no session), and return the run's id without waiting for it: for work
24
24
  * that must be recorded with its run (a job row that names it). A page
25
25
  * follows it with `resume(id)`. Refusals reject with InvocationError, with
26
26
  * the codes a button's press gets.
@@ -6,7 +6,8 @@ import { isSameOrigin, readCookie } from "../chat/server.js";
6
6
  import { errorBody } from "../request-error.js";
7
7
  import { isUuid } from "../uuid.js";
8
8
  import { failedRun, InvocationError } from "./error.js";
9
- import { INVOKE_BASE_PATH } from "../platform/contract.js";
9
+ import { APP_CALLER_HEADERS, INVOKE_BASE_PATH } from "../platform/contract.js";
10
+ import { env } from "../server/env.js";
10
11
  export { InvocationError };
11
12
  function routeOf(request) {
12
13
  const { pathname } = new URL(request.url);
@@ -21,6 +22,14 @@ function routeOf(request) {
21
22
  const [, id = ""] = read;
22
23
  return { kind: "read", id };
23
24
  }
25
+ function platformPath(route) {
26
+ switch (route.kind) {
27
+ case "start":
28
+ return "/invocations";
29
+ case "read":
30
+ return `/invocations/${route.id}`;
31
+ }
32
+ }
24
33
  const invokeBody = z.object({ key: z.string(), input: z.unknown() });
25
34
  const plazaInvocation = z.object({
26
35
  id: z.string(),
@@ -67,28 +76,40 @@ function startBody(contract, key, rawInput) {
67
76
  return JSON.stringify({ contractId: contract.id, input: input.data, outputSchema: contract.outputSchema });
68
77
  }
69
78
  /**
70
- * The person behind a request to this app: the session cookie, from one of
79
+ * The caller behind a request to this app: the session cookie, from one of
71
80
  * this app's own pages. Every org app shares one site with every other
72
81
  * (*.<domain>), so a page on another org's app can send this app's cookie along.
73
82
  */
74
- async function signedIn(auth, request) {
83
+ async function callerOf(auth, request) {
75
84
  if (!isSameOrigin(request, new URL(request.url))) {
76
85
  throw new InvocationError(403, "cross_site", "Contracts can only be invoked from this app's own pages.");
77
86
  }
78
87
  const token = readCookie(request.headers.get("cookie"), auth.SESSION_COOKIE);
79
- if (token === null)
80
- throw new InvocationError(401, "not_signed_in", "Sign in to use this.");
88
+ if (token === null) {
89
+ const { appSecret } = env();
90
+ if (appSecret === null)
91
+ throw new InvocationError(401, "not_signed_in", "Sign in to use this.");
92
+ return { kind: "visitor", appId: miiAuthConfig().appId, secret: appSecret };
93
+ }
81
94
  const session = await auth.resolveSessionWithPlaza(token);
82
95
  if (session === null)
83
96
  throw new InvocationError(401, "sign_in_again", "Sign out and back in to use it here.");
84
- return { token, plazaToken: session.plazaToken };
97
+ return { kind: "person", token, plazaToken: session.plazaToken };
98
+ }
99
+ function callerHeaders(caller) {
100
+ switch (caller.kind) {
101
+ case "person":
102
+ return { Authorization: `Bearer ${caller.plazaToken}` };
103
+ case "visitor":
104
+ return { [APP_CALLER_HEADERS.appId]: caller.appId, [APP_CALLER_HEADERS.secret]: caller.secret };
105
+ }
85
106
  }
86
- /** One call to the platform's contract API as the person; a refused token ends their session. Rejects only when the platform can't be reached. */
87
- async function callPlaza(auth, person, call) {
107
+ /** One call to the platform's contract API as the caller; a refused token ends a person's session. Rejects only when the platform can't be reached. */
108
+ async function callPlaza(auth, caller, call) {
88
109
  const answer = await fetch(call.target, {
89
110
  method: call.method,
90
111
  headers: {
91
- Authorization: `Bearer ${person.plazaToken}`,
112
+ ...callerHeaders(caller),
92
113
  ...(call.body === null ? {} : { "Content-Type": "application/json" })
93
114
  },
94
115
  body: call.body,
@@ -96,17 +117,17 @@ async function callPlaza(auth, person, call) {
96
117
  redirect: "manual",
97
118
  signal: call.signal
98
119
  });
99
- if (answer.status === 401 || answer.status === 403) {
100
- await auth.revokeSession(person.token, answer.status === 403 ? "not_a_member" : "token_rejected");
120
+ if (caller.kind === "person" && (answer.status === 401 || answer.status === 403)) {
121
+ await auth.revokeSession(caller.token, answer.status === 403 ? "not_a_member" : "token_rejected");
101
122
  }
102
123
  return answer;
103
124
  }
104
125
  const plazaTarget = (path) => new URL(`${miiAuthConfig().plazaUrl}/api/app-contracts/v1${path}`);
105
- /** One call to the platform as the person, read as JSON. A refusal, or no answer, rejects with InvocationError. */
106
- async function askPlaza(auth, person, call) {
126
+ /** One call to the platform as the caller, read as JSON. A refusal, or no answer, rejects with InvocationError. */
127
+ async function askPlaza(auth, caller, call) {
107
128
  let answer;
108
129
  try {
109
- answer = await callPlaza(auth, person, call);
130
+ answer = await callPlaza(auth, caller, call);
110
131
  }
111
132
  catch (error) {
112
133
  if (call.signal !== null && call.signal.aborted)
@@ -143,10 +164,10 @@ export function createMiiInvoke(auth, contracts, { reportError = (error, context
143
164
  if (route.kind === "read" && !isUuid(route.id)) {
144
165
  return refuse(404, "not_found", "Invocation not found.");
145
166
  }
146
- let person;
167
+ let caller;
147
168
  let body = null;
148
169
  try {
149
- person = await signedIn(auth, request);
170
+ caller = await callerOf(auth, request);
150
171
  if (route.kind === "start")
151
172
  body = await readInvoke(request, declared);
152
173
  }
@@ -155,7 +176,7 @@ export function createMiiInvoke(auth, contracts, { reportError = (error, context
155
176
  throw error;
156
177
  return refuse(error.status, error.code, error.message);
157
178
  }
158
- const target = plazaTarget(route.kind === "start" ? "/invocations" : `/invocations/${route.id}`);
179
+ const target = plazaTarget(platformPath(route));
159
180
  // How long the platform may hold a read of a pending invocation. This app's own
160
181
  // function limit has to outlast it (see the README).
161
182
  const wait = new URL(request.url).searchParams.get("wait");
@@ -164,7 +185,7 @@ export function createMiiInvoke(auth, contracts, { reportError = (error, context
164
185
  let answer;
165
186
  try {
166
187
  // The browser giving up ends the platform's hold too.
167
- answer = await callPlaza(auth, person, { method: request.method, target, body, signal: request.signal });
188
+ answer = await callPlaza(auth, caller, { method: request.method, target, body, signal: request.signal });
168
189
  }
169
190
  catch (error) {
170
191
  if (!request.signal.aborted)
@@ -190,17 +211,17 @@ export function createMiiInvoke(auth, contracts, { reportError = (error, context
190
211
  return respond(answer.status, plazaInvocation.parse(await answer.json()), declared, reportError);
191
212
  };
192
213
  const start = async ({ request, key, input, signal = null }) => {
193
- const person = await signedIn(auth, request);
214
+ const caller = await callerOf(auth, request);
194
215
  const body = startBody(declared.get(key), key, input);
195
- const answer = await askPlaza(auth, person, { method: "POST", target: plazaTarget("/invocations"), body, signal });
216
+ const answer = await askPlaza(auth, caller, { method: "POST", target: plazaTarget("/invocations"), body, signal });
196
217
  return { id: plazaInvocation.parse(answer).id };
197
218
  };
198
219
  const result = async (key, id, request) => {
199
- const person = await signedIn(auth, request);
220
+ const caller = await callerOf(auth, request);
200
221
  const contract = contracts[key];
201
222
  if (!isUuid(id))
202
223
  throw new InvocationError(404, "not_found", "Invocation not found.");
203
- const invocation = plazaInvocation.parse(await askPlaza(auth, person, { method: "GET", target: plazaTarget(`/invocations/${id}`), body: null, signal: null }));
224
+ const invocation = plazaInvocation.parse(await askPlaza(auth, caller, { method: "GET", target: plazaTarget(`/invocations/${id}`), body: null, signal: null }));
204
225
  if (invocation.contractId !== contract.id) {
205
226
  throw new InvocationError(404, "not_found", `Invocation ${id} is not a run of "${key}".`);
206
227
  }
@@ -16,6 +16,13 @@ export declare const PLATFORM_AUTH_PATHS: {
16
16
  /** "Continue with Google / Microsoft": the platform signs the person in and returns with a one-time code. */
17
17
  socialSignIn: string;
18
18
  };
19
+ /** An app's server calling the platform as the app, for a visitor with no session: which app, and its secret. */
20
+ export declare const APP_CALLER_HEADERS: {
21
+ appId: string;
22
+ secret: string;
23
+ };
24
+ /** The server env that secret is provisioned in. */
25
+ export declare const APP_SECRET_ENV = "PLATFORM_APP_SECRET";
19
26
  /** The platform's chat API: an app's chat route forwards any path beneath it, so a new widget route needs no SDK release. */
20
27
  export declare const PLATFORM_CHAT_PATH = "/api/app-chat/v1/chat";
21
28
  /** What this app's chat server forwards beyond the first contract: the widget, served to every app version, offers only these. */
@@ -24,7 +31,6 @@ export declare const CHAT_FEATURES_HEADER = "X-Mii-Chat-Features";
24
31
  export declare const STARTING_HEADER = "x-mii-app-starting";
25
32
  /** Every TanStack query the SDK owns sits under this key. */
26
33
  export declare const QUERY_ROOT = "light";
27
- export declare const SESSION_COOKIE = "mii_session";
28
34
  export declare const STORAGE_KEYS: {
29
35
  /** sessionStorage: the build this tab already reloaded for after a missing chunk, so a second miss on it can't loop. */
30
36
  chunkRetryBuildId: string;
@@ -2,7 +2,7 @@
2
2
  // apps and the platform's pane depend on every value here: rename freely, never
3
3
  // change a value without both sides moving.
4
4
  const NAMESPACE = "light";
5
- /** The platform's former name: the pane protocol, the session cookie and two routes still carry it. */
5
+ /** The platform's former name: the pane protocol and two routes still carry it. */
6
6
  const LEGACY_NAMESPACE = "mii";
7
7
  export const ENTITIES_PATH = `/api/${NAMESPACE}/entities`;
8
8
  export const CHANGES_PATH = `${ENTITIES_PATH}/_changes`;
@@ -22,6 +22,10 @@ export const PLATFORM_AUTH_PATHS = {
22
22
  /** "Continue with Google / Microsoft": the platform signs the person in and returns with a one-time code. */
23
23
  socialSignIn: "/api/app-auth/social"
24
24
  };
25
+ /** An app's server calling the platform as the app, for a visitor with no session: which app, and its secret. */
26
+ export const APP_CALLER_HEADERS = { appId: "x-app-id", secret: "x-app-secret" };
27
+ /** The server env that secret is provisioned in. */
28
+ export const APP_SECRET_ENV = "PLATFORM_APP_SECRET";
25
29
  /** The platform's chat API: an app's chat route forwards any path beneath it, so a new widget route needs no SDK release. */
26
30
  export const PLATFORM_CHAT_PATH = "/api/app-chat/v1/chat";
27
31
  /** What this app's chat server forwards beyond the first contract: the widget, served to every app version, offers only these. */
@@ -30,7 +34,6 @@ export const CHAT_FEATURES_HEADER = "X-Mii-Chat-Features";
30
34
  export const STARTING_HEADER = `x-${LEGACY_NAMESPACE}-app-starting`;
31
35
  /** Every TanStack query the SDK owns sits under this key. */
32
36
  export const QUERY_ROOT = NAMESPACE;
33
- export const SESSION_COOKIE = `${LEGACY_NAMESPACE}_session`;
34
37
  export const STORAGE_KEYS = {
35
38
  /** sessionStorage: the build this tab already reloaded for after a missing chunk, so a second miss on it can't loop. */
36
39
  chunkRetryBuildId: `${NAMESPACE}:chunk-retry-build-id`,
@@ -1,4 +1,4 @@
1
- import { type QueryClient } from "@tanstack/react-query";
1
+ import { type Query, type QueryClient } from "@tanstack/react-query";
2
2
  import { z } from "zod/v4";
3
3
  declare const changes: z.ZodObject<{
4
4
  cursor: z.ZodString;
@@ -20,9 +20,12 @@ export type ChangeFeed = (onChanged: (tables: string[]) => void) => () => void;
20
20
  export declare function pollChanges(queries: QueryClient, fetchChanges: (since: string | null) => Promise<Poll>): ChangeFeed;
21
21
  export declare function fetchChanges(since: string | null): Promise<Poll>;
22
22
  /**
23
- * Each changed table's on-screen queries refetch, and the route loaders. A
24
- * table this tab is writing is left to its write, whose own refetch follows
25
- * it (client.ts's settle) and brings these changes too.
23
+ * Each changed table's on-screen queries refetch, in one go: those keyed under
24
+ * it, and those that read it among others and say so in `meta.tables`. A table
25
+ * this tab is writing is left to its write, whose own refetch follows it
26
+ * (client.ts's settle) and brings these changes too.
26
27
  */
27
28
  export declare function refreshTables(queries: QueryClient, tables: string[]): void;
29
+ /** Whether a query shows `table`'s rows: keyed under it, or listing it in `meta.tables` (typed in client.ts). */
30
+ export declare function reads(query: Query, table: string): boolean;
28
31
  export {};
@@ -4,7 +4,8 @@
4
4
  // of a change is a ChangeFeed; today a poll of `_changes` that TanStack Query
5
5
  // itself runs (refetchInterval while the tab is visible, at once on focus and
6
6
  // reconnect), later a pushed one (SSE) behind the same type.
7
- import { QueryObserver } from "@tanstack/react-query";
7
+ import { QueryObserver, partialMatchKey } from "@tanstack/react-query";
8
+ import { getTableName } from "drizzle-orm";
8
9
  import { z } from "zod/v4";
9
10
  import { EntityError, tableQueryKey } from "../entities.js";
10
11
  import { CHANGES_PATH, QUERY_ROOT } from "./contract.js";
@@ -73,14 +74,18 @@ export async function fetchChanges(since) {
73
74
  return changes.parse(await response.json());
74
75
  }
75
76
  /**
76
- * Each changed table's on-screen queries refetch, and the route loaders. A
77
- * table this tab is writing is left to its write, whose own refetch follows
78
- * it (client.ts's settle) and brings these changes too.
77
+ * Each changed table's on-screen queries refetch, in one go: those keyed under
78
+ * it, and those that read it among others and say so in `meta.tables`. A table
79
+ * this tab is writing is left to its write, whose own refetch follows it
80
+ * (client.ts's settle) and brings these changes too.
79
81
  */
80
82
  export function refreshTables(queries, tables) {
81
- for (const table of tables) {
82
- const key = tableQueryKey(table);
83
- if (queries.isMutating({ mutationKey: [...key, "write"] }) === 0)
84
- void queries.invalidateQueries({ queryKey: key });
85
- }
83
+ const quiet = tables.filter((table) => queries.isMutating({ mutationKey: [...tableQueryKey(table), "write"] }) === 0);
84
+ if (quiet.length > 0)
85
+ void queries.invalidateQueries({ predicate: (query) => quiet.some((table) => reads(query, table)) });
86
+ }
87
+ /** Whether a query shows `table`'s rows: keyed under it, or listing it in `meta.tables` (typed in client.ts). */
88
+ export function reads(query, table) {
89
+ // eslint-disable-next-line no-restricted-syntax -- TanStack's meta is optional: most queries name no tables
90
+ return partialMatchKey(query.queryKey, tableQueryKey(table)) || query.meta?.tables.some((entity) => getTableName(entity) === table) === true;
86
91
  }
@@ -2,6 +2,7 @@
2
2
  export declare function env(): {
3
3
  databaseUrl: string | undefined;
4
4
  unpooledDatabaseUrl: string | undefined;
5
+ appSecret: string | null;
5
6
  /** Sign-in needs the platform, this app's id in it, and a database for sessions. */
6
7
  configured: boolean;
7
8
  /**
@@ -1,6 +1,7 @@
1
1
  // The server env the SDK reads, in one schema: what the platform injects
2
2
  // (Vercel env, the sandbox's .env.local).
3
3
  import { z } from "zod/v4";
4
+ import { APP_SECRET_ENV } from "../platform/contract.js";
4
5
  const serverEnv = z
5
6
  .object({
6
7
  DATABASE_URL: z.string().min(1).optional(),
@@ -8,11 +9,14 @@ const serverEnv = z
8
9
  DATABASE_URL_UNPOOLED: z.string().min(1).optional(),
9
10
  MII_PLAZA_URL: z.url().optional(),
10
11
  MII_APP_ID: z.string().min(1).optional(),
12
+ /** What this app's server presents to call the platform as the app, for a visitor with no session. */
13
+ [APP_SECRET_ENV]: z.string().min(1).nullable().default(null),
11
14
  NODE_ENV: z.string().optional()
12
15
  })
13
16
  .transform((env) => ({
14
17
  databaseUrl: env.DATABASE_URL,
15
18
  unpooledDatabaseUrl: env.DATABASE_URL_UNPOOLED,
19
+ appSecret: env[APP_SECRET_ENV],
16
20
  /** Sign-in needs the platform, this app's id in it, and a database for sessions. */
17
21
  configured: Boolean(env.MII_PLAZA_URL && env.MII_APP_ID && env.DATABASE_URL),
18
22
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trylight/app",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "description": "The Light SDK every v2 org app depends on: entities (Drizzle tables with a typed client), sign-in, contracts and in-app chat.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",