@trylight/app 0.3.9 → 0.3.12

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
@@ -8,8 +8,8 @@ its tables and their typed client, its door, its contracts and its chat.
8
8
  | Import | What |
9
9
  |---|---|
10
10
  | `@trylight/app/schema` | `entity(name, columns, access)`: a Drizzle table plus `id`, `created_at`, `updated_at`, `created_by`, `field_sources`; `lightChanges` (the audit table); `lightImages` (the app's copy of its logos and photos); `fieldSource` (the `{url, evidence}` schema) |
11
- | `@trylight/app` | `createApp<typeof contracts>()` → `app.table(entity)` (`list/get/create/update/delete`, `useList/useInfiniteList/useGet/useCreate/useUpdate/useDelete`, typed by the entity passed; `useList(...).rows` is never undefined), `app.contracts` (`useInvocation`), and `imageSrc(url)` (where a logo or photo loads from) |
12
- | `@trylight/app/server` | `createLightServer({ schema, contracts })` → `light.db` (Drizzle), `light.session(request)`, `light.signIn(email, password)`, and the route handlers `light.entities`, `light.image`, `light.invoke.handler`, `light.chat` |
11
+ | `@trylight/app` | `createApp<typeof contracts>()` → `app.table(entity)` (`list/get/create/update/delete`, `useList/useInfiniteList/useCount/useGet/useCreate/useUpdate/useDelete`, typed by the entity passed; `useList(...).rows` is never undefined), `app.contracts` (`useInvocation`), and `imageSrc(url)` (where a logo or photo loads from) |
12
+ | `@trylight/app/server` | `createLightServer({ schema, contracts })` → `light.db` (Drizzle), `light.session(request)`, `light.signIn(email, password, code?)`, and the route handlers `light.entities`, `light.image`, `light.invoke.handler`, `light.chat` |
13
13
  | `@trylight/app/platform` | the door for server functions, framing, analytics/error policies, the pane bridge, `fetchThroughRestart` (`createStart`'s `serverFns.fetch`) |
14
14
  | `@trylight/app/vite` | `lightDb()`: the dev server syncs `db/schema` into the database on every change |
15
15
  | `light-db` (bin) | `sync`, `migrate`, `check`: the app's `pnpm db:*` scripts |
@@ -37,7 +37,9 @@ new ones load (`isSwitching`), sorts on the server, and prefetches the
37
37
  sibling views it is given once it settles. A write refetches every page a
38
38
  list has loaded, one after another, before its mutation settles; the
39
39
  change itself shows at once. `search` matches words in any
40
- text column, case-insensitively, on the server.
40
+ text column, case-insensitively, on the server. `useCount({ where, search })` is how
41
+ many rows the same list would hold, counted on the server (a paged list's tab
42
+ total), and refetched after every write like the rest.
41
43
 
42
44
  Joins and aggregates stay typed server functions over `light.db`.
43
45
 
@@ -25,7 +25,7 @@ export type PlazaSession = {
25
25
  * (the platform unreachable, the platform answering nonsense), so the route can report them.
26
26
  */
27
27
  export type SignInFailure = {
28
- readonly kind: "credentials" | "not_a_member" | "throttled";
28
+ readonly kind: "credentials" | "not_a_member" | "unverified" | "throttled" | "code_required" | "wrong_code" | "two_factor_not_set_up";
29
29
  readonly message: string;
30
30
  } | {
31
31
  readonly kind: "unavailable";
@@ -44,9 +44,10 @@ export type SignInResult = ({
44
44
  };
45
45
  /**
46
46
  * Check an email and password with the platform, and get back who the person is and
47
- * the session the platform signed for them.
47
+ * the session the platform signed for them. `code` is from their authenticator app,
48
+ * sent once the platform has asked for it.
48
49
  */
49
- export declare function signInWithPlaza(email: string, password: string): Promise<SignInResult>;
50
+ export declare function signInWithPlaza(email: string, password: string, code?: string): Promise<SignInResult>;
50
51
  export type PasswordResetResult = {
51
52
  readonly ok: true;
52
53
  } | {
@@ -1,18 +1,19 @@
1
1
  import { z } from "zod/v4";
2
2
  import { miiAuthConfig } from "./config.js";
3
- import { PLATFORM_AUTH_PATHS } from "../platform/contract.js";
3
+ import { EMAIL_NOT_VERIFIED_REFUSAL, PLATFORM_AUTH_PATHS } from "../platform/contract.js";
4
4
  /**
5
5
  * Check an email and password with the platform, and get back who the person is and
6
- * the session the platform signed for them.
6
+ * the session the platform signed for them. `code` is from their authenticator app,
7
+ * sent once the platform has asked for it.
7
8
  */
8
- export async function signInWithPlaza(email, password) {
9
+ export async function signInWithPlaza(email, password, code) {
9
10
  const { plazaUrl, appId } = miiAuthConfig();
10
11
  let response;
11
12
  try {
12
13
  response = await fetch(`${plazaUrl}${PLATFORM_AUTH_PATHS.signIn}`, {
13
14
  method: "POST",
14
15
  headers: { "Content-Type": "application/json" },
15
- body: JSON.stringify({ appId, email, password }),
16
+ body: JSON.stringify({ appId, email, password, code: typedCode.parse(code) }),
16
17
  cache: "no-store"
17
18
  });
18
19
  }
@@ -46,6 +47,12 @@ export async function signInWithPlaza(email, password) {
46
47
  failure: failureFor(response.status, await response.text())
47
48
  };
48
49
  }
50
+ /** A blank code field is no code: the platform refuses an empty one outright. */
51
+ const typedCode = z
52
+ .string()
53
+ .trim()
54
+ .transform((typed) => (typed === "" ? undefined : typed))
55
+ .optional();
49
56
  /** `returnTo` is this app's login, where the platform's reset page sends the person afterwards. */
50
57
  export async function requestPasswordResetWithPlaza(email, returnTo) {
51
58
  const { plazaUrl, appId } = miiAuthConfig();
@@ -155,11 +162,33 @@ function asSignedIn(body) {
155
162
  const { session, ...identity } = read.data;
156
163
  return { identity, session };
157
164
  }
165
+ // A 403 is also "not a member"; the refusal's code tells them apart.
166
+ const unverifiedRefusal = z.object({ error: z.literal(EMAIL_NOT_VERIFIED_REFUSAL) });
167
+ // Not every answer is JSON: a proxy's error page, say.
168
+ function jsonIn(body) {
169
+ try {
170
+ return JSON.parse(body);
171
+ }
172
+ catch {
173
+ return null;
174
+ }
175
+ }
158
176
  function failureFor(status, body) {
177
+ const answer = jsonIn(body);
178
+ const refusal = codeRefusal.safeParse(answer);
179
+ if (refusal.success) {
180
+ return { kind: refusal.data.error, message: refusal.data.detail };
181
+ }
159
182
  if (status === 401) {
160
183
  return { kind: "credentials", message: "Wrong email or password." };
161
184
  }
162
185
  if (status === 403) {
186
+ if (unverifiedRefusal.safeParse(answer).success) {
187
+ return {
188
+ kind: "unverified",
189
+ message: "Confirm your email first: we sent you a link."
190
+ };
191
+ }
163
192
  return {
164
193
  kind: "not_a_member",
165
194
  // Said plainly: they have just proved they own the account, so this is
@@ -179,3 +208,8 @@ function failureFor(status, body) {
179
208
  cause: new Error(`Platform sign-in answered ${status}: ${body.slice(0, 300)}`)
180
209
  };
181
210
  }
211
+ // The refusals the platform words itself: it knows the lockout and the setup path.
212
+ const codeRefusal = z.object({
213
+ error: z.enum(["code_required", "wrong_code", "two_factor_not_set_up", "throttled"]),
214
+ detail: z.string().min(1)
215
+ });
@@ -13,6 +13,10 @@ export const SIGN_IN_PROBLEMS = {
13
13
  title: "That account can't open this app",
14
14
  body: "The password was right, but the account isn't in this app's org. Ask whoever runs it to add you."
15
15
  },
16
+ unverified: {
17
+ title: "Confirm your email first",
18
+ body: "We emailed you a link. Open it, then sign in here again."
19
+ },
16
20
  deactivated: {
17
21
  title: "Your access here has been turned off",
18
22
  body: "Ask whoever runs this app to turn it back on."
package/dist/client.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type QueryKey } from "@tanstack/react-query";
2
2
  import type { Contracts } from "./invoke/server.js";
3
- import { EntityError, type ListOptions, type NewRow, type Patch, type Row } from "./entities.js";
3
+ import { EntityError, type CountOptions, type ListOptions, type NewRow, type Patch, type Row } from "./entities.js";
4
4
  import type { Entity } from "./schema.js";
5
5
  declare module "@tanstack/react-query" {
6
6
  interface Register {
@@ -10,7 +10,7 @@ declare module "@tanstack/react-query" {
10
10
  };
11
11
  }
12
12
  }
13
- export { EntityError, LIST_LIMIT, type ListOptions, type NewRow, type Sort, type Patch, type Row, type Where } from "./entities.js";
13
+ export { EntityError, LIST_LIMIT, type CountOptions, type ListOptions, type NewRow, type Sort, type Patch, type Row, type Where } from "./entities.js";
14
14
  export type { FieldSource, FieldSources } from "./schema.js";
15
15
  export { siteOf } from "./schema.js";
16
16
  export { imageSrc } from "./images.js";
@@ -81,6 +81,7 @@ declare function tableClient<T extends Entity>(entity: T): {
81
81
  */
82
82
  key: string[];
83
83
  list: (options?: ListOptions<Row<T>>) => Promise<Row<T>[]>;
84
+ count: (options?: CountOptions<Row<T>>) => Promise<number>;
84
85
  get: (id: string) => Promise<Row<T>>;
85
86
  create: (data: NewRow<T>) => Promise<Row<T>>;
86
87
  update: (id: string, patch: Patch<T>) => Promise<Row<T>>;
@@ -695,6 +696,14 @@ declare function tableClient<T extends Entity>(entity: T): {
695
696
  isEnabled: boolean;
696
697
  fetchStatus: import("@tanstack/react-query").FetchStatus;
697
698
  };
699
+ /**
700
+ * How many rows match, counted on the server, so a paged list's tab can
701
+ * show its total; refetched after any write to this table. New options
702
+ * keep the last count until theirs lands.
703
+ *
704
+ * const drafts = app.table(posts).useCount({ where: { status: "draft" } });
705
+ */
706
+ useCount: (options?: CountOptions<Row<T>>) => import("@tanstack/react-query").UseQueryResult<number, EntityError>;
698
707
  /** One row; `null` waits (nothing open yet). */
699
708
  useGet: (id: string | null) => import("@tanstack/react-query").UseQueryResult<T["$inferSelect"] extends infer T_1 ? { [K in keyof T_1]: Date extends T_1[K] ? string | Exclude<T_1[K], T_1[K] & Date> : T_1[K]; } : never, EntityError>;
700
709
  useCreate: () => import("@tanstack/react-query").UseMutationResult<T["$inferSelect"] extends infer T_1 ? { [K in keyof T_1]: Date extends T_1[K] ? string | Exclude<T_1[K], T_1[K] & Date> : T_1[K]; } : never, EntityError, NewRow<T>, unknown>;
package/dist/client.js CHANGED
@@ -132,10 +132,12 @@ function tableClient(entity) {
132
132
  all,
133
133
  list: (options) => [...all, "list", options],
134
134
  pages: (options) => [...all, "pages", options],
135
+ count: (options) => [...all, "count", options],
135
136
  get: (id) => [...all, "get", id],
136
137
  write: [...all, "write"]
137
138
  };
138
139
  const list = (options = {}) => call("GET", `${base}?q=${encodeURIComponent(JSON.stringify(options))}`);
140
+ const count = (options = {}) => call("GET", `${base}?count=${encodeURIComponent(JSON.stringify(options))}`);
139
141
  const get = (id) => call("GET", `${base}/${id}`);
140
142
  const create = (data) => call("POST", base, json(data));
141
143
  const update = (id, patch) => call("PATCH", `${base}/${id}`, json(patch));
@@ -187,6 +189,7 @@ function tableClient(entity) {
187
189
  */
188
190
  key: keys.all,
189
191
  list,
192
+ count,
190
193
  get,
191
194
  create,
192
195
  update,
@@ -272,6 +275,14 @@ function tableClient(entity) {
272
275
  };
273
276
  return { ...query, ...view };
274
277
  },
278
+ /**
279
+ * How many rows match, counted on the server, so a paged list's tab can
280
+ * show its total; refetched after any write to this table. New options
281
+ * keep the last count until theirs lands.
282
+ *
283
+ * const drafts = app.table(posts).useCount({ where: { status: "draft" } });
284
+ */
285
+ useCount: (options = {}) => useQuery({ queryKey: keys.count(options), queryFn: () => count(options), placeholderData: keepPreviousData }),
275
286
  /** One row; `null` waits (nothing open yet). */
276
287
  useGet: (id) => useQuery({
277
288
  queryKey: keys.get(id),
@@ -27,6 +27,8 @@ export type ListOptions<R> = {
27
27
  limit?: number;
28
28
  offset?: number;
29
29
  };
30
+ /** What `useCount` takes: the rows a list with the same `where` and `search` would hold. */
31
+ export type CountOptions<R> = Pick<ListOptions<R>, "where" | "search">;
30
32
  /** A refused or failed entities call, with the route's own reason. */
31
33
  export declare class EntityError extends Error {
32
34
  readonly status: number;
@@ -16,6 +16,8 @@ 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
+ /** The platform's sign-in refusing a right password: the login's address is unconfirmed (a 403, like "not a member"). */
20
+ export declare const EMAIL_NOT_VERIFIED_REFUSAL = "email_not_verified";
19
21
  /** An app's server calling the platform as the app, for a visitor with no session: which app, and its secret. */
20
22
  export declare const APP_CALLER_HEADERS: {
21
23
  appId: string;
@@ -22,6 +22,8 @@ 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
+ /** The platform's sign-in refusing a right password: the login's address is unconfirmed (a 403, like "not a member"). */
26
+ export const EMAIL_NOT_VERIFIED_REFUSAL = "email_not_verified";
25
27
  /** An app's server calling the platform as the app, for a visitor with no session: which app, and its secret. */
26
28
  export const APP_CALLER_HEADERS = { appId: "x-app-id", secret: "x-app-secret" };
27
29
  /** The server env that secret is provisioned in. */
@@ -1,4 +1,4 @@
1
- // The one route behind `app.table(...)`: list, get, create, update and delete
1
+ // The one route behind `app.table(...)`: list, count, get, create, update and delete
2
2
  // on any entity table, each request checked against the table's own Zod
3
3
  // schema and the app's door. Each write names its person for the table's
4
4
  // trigger, which records the change in light_changes (src/db/audit.ts), which
@@ -82,7 +82,10 @@ export function createEntitiesHandler(options) {
82
82
  if (method === "GET") {
83
83
  if (!mayRead(session))
84
84
  return refuse(401, "not_signed_in", "Sign in to use this app.");
85
- return id === null ? list(db, table, url) : answer(() => get(db, table, id));
85
+ if (id !== null)
86
+ return answer(() => get(db, table, id));
87
+ const counted = url.searchParams.get("count");
88
+ return counted === null ? list(db, table, url) : count(db, table, counted);
86
89
  }
87
90
  if (table.entity.$access.write === "agents") {
88
91
  return refuse(403, "agents_only", `${table.name} is written by the org's agents, not from the app.`);
@@ -133,6 +136,10 @@ function tableRules(entity) {
133
136
  .filter(([name, c]) => !(name in PLATFORM_COLUMNS) && SEARCHED_TYPES.has(c.columnType))
134
137
  .map(([, c]) => c);
135
138
  const match = Object.fromEntries(Object.entries(createSelectSchema(entity).shape).map(([key, value]) => [key, z.union([value, z.array(value)])]));
139
+ const filters = {
140
+ where: z.object(match).partial().strict().default({}),
141
+ search: z.string().trim().max(SEARCH_MAX).default("")
142
+ };
136
143
  return {
137
144
  name: getTableName(entity),
138
145
  entity,
@@ -143,13 +150,13 @@ function tableRules(entity) {
143
150
  update: writable(createUpdateSchema(entity).shape),
144
151
  list: z
145
152
  .object({
146
- where: z.object(match).partial().strict().default({}),
147
- search: z.string().trim().max(SEARCH_MAX).default(""),
153
+ ...filters,
148
154
  sort: z.literal([...names, ...names.map((name) => `-${name}`)]).default("-created_at"),
149
155
  limit: z.int().min(1).max(LIST_LIMIT).default(LIST_LIMIT),
150
156
  offset: z.int().min(0).default(0)
151
157
  })
152
- .strict()
158
+ .strict(),
159
+ count: z.object(filters).strict()
153
160
  };
154
161
  }
155
162
  async function list(db, table, url) {
@@ -159,6 +166,28 @@ async function list(db, table, url) {
159
166
  return refuse(400, "invalid", z.prettifyError(query.error));
160
167
  const { where, search, sort, limit, offset } = query.data;
161
168
  const order = sort.startsWith("-") ? desc(table.column(sort.slice(1))) : asc(table.column(sort));
169
+ const rows = await db
170
+ .select()
171
+ .from(table.entity)
172
+ .where(and(...matching(table, where, search)))
173
+ // The id breaks ties, so paging with offset never repeats or skips a row.
174
+ .orderBy(order, asc(table.id))
175
+ .limit(limit)
176
+ .offset(offset);
177
+ return Response.json(rows);
178
+ }
179
+ /** How many rows a list with the same `where` and `search` would hold, without fetching them. */
180
+ async function count(db, table, asked) {
181
+ const query = table.count.safeParse(readJson(asked));
182
+ if (!query.success)
183
+ return refuse(400, "invalid", z.prettifyError(query.error));
184
+ const rows = await db
185
+ .select({ count: sql `count(*)::int` })
186
+ .from(table.entity)
187
+ .where(and(...matching(table, query.data.where, query.data.search)));
188
+ return Response.json(exactlyOne(rows).count);
189
+ }
190
+ function matching(table, where, search) {
162
191
  const conditions = Object.entries(where).map(([name, value]) => {
163
192
  const column = table.column(name);
164
193
  if (value === null)
@@ -172,15 +201,7 @@ async function list(db, table, url) {
172
201
  const pattern = `%${word.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
173
202
  conditions.push(or(...table.searched.map((column) => ilike(column, pattern))) ?? sql `false`);
174
203
  }
175
- const rows = await db
176
- .select()
177
- .from(table.entity)
178
- .where(and(...conditions))
179
- // The id breaks ties, so paging with offset never repeats or skips a row.
180
- .orderBy(order, asc(table.id))
181
- .limit(limit)
182
- .offset(offset);
183
- return Response.json(rows);
204
+ return conditions;
184
205
  }
185
206
  /** Members-only: someone signed in; anyone with the link (and a local dev server): anyone. */
186
207
  function mayRead(session) {
@@ -13,8 +13,8 @@ export declare function createLightServer<S extends Record<string, unknown>, C e
13
13
  session: (request: Request) => Promise<import("./session.js").SessionState>;
14
14
  /** Whether sign-in is configured (a bare local dev server has none). */
15
15
  configured: () => boolean;
16
- /** Email and password, checked by the platform; the session to set as a cookie, or why not. */
17
- signIn: (email: string, password: string) => Promise<import("./session.js").SignIn>;
16
+ /** Email and password (and the authenticator code once asked for), checked by the platform; the session to set as a cookie, or why not. */
17
+ signIn: (email: string, password: string, code?: string) => Promise<import("./session.js").SignIn>;
18
18
  requestPasswordReset: (email: string, returnTo: string) => Promise<import("./session.js").PasswordResetRequest>;
19
19
  /** The app's database, typed by its schema: `light.db.select().from(companies)`. */
20
20
  readonly db: PostgresJsDatabase<S>;
@@ -36,8 +36,8 @@ export function createLightServer(options) {
36
36
  session,
37
37
  /** Whether sign-in is configured (a bare local dev server has none). */
38
38
  configured: () => env().configured,
39
- /** Email and password, checked by the platform; the session to set as a cookie, or why not. */
40
- signIn: (email, password) => signIn(auth, email, password),
39
+ /** Email and password (and the authenticator code once asked for), checked by the platform; the session to set as a cookie, or why not. */
40
+ signIn: (email, password, code) => signIn(auth, email, password, code),
41
41
  requestPasswordReset: (email, returnTo) => requestPasswordReset(email, returnTo),
42
42
  /** The app's database, typed by its schema: `light.db.select().from(companies)`. */
43
43
  get db() {
@@ -10,12 +10,15 @@ export type SignIn = {
10
10
  ok: true;
11
11
  token: string;
12
12
  expiresAt: Date;
13
- } | {
13
+ }
14
+ /** `needsCode`: show a field for the authenticator code and send it with the password again. */
15
+ | {
14
16
  ok: false;
15
17
  message: string;
18
+ needsCode: boolean;
16
19
  };
17
20
  /** The login form's whole job: the platform checks the password, the app starts a session. */
18
- export declare function signIn(auth: MiiAuthCore, email: string, password: string): Promise<SignIn>;
21
+ export declare function signIn(auth: MiiAuthCore, email: string, password: string, code?: string): Promise<SignIn>;
19
22
  export type PasswordResetRequest = {
20
23
  ok: true;
21
24
  } | {
@@ -21,15 +21,17 @@ export async function readSession(auth, request) {
21
21
  return { user: { id, name, email, avatarUrl, role }, mode };
22
22
  }
23
23
  /** The login form's whole job: the platform checks the password, the app starts a session. */
24
- export async function signIn(auth, email, password) {
24
+ export async function signIn(auth, email, password, code) {
25
25
  if (!env().configured)
26
- return { ok: false, message: "Sign-in is not configured." };
27
- const result = await signInWithPlaza(email, password);
28
- if (!result.ok)
29
- return { ok: false, message: result.failure.message };
26
+ return { ok: false, message: "Sign-in is not configured.", needsCode: false };
27
+ const result = await signInWithPlaza(email, password, code);
28
+ if (!result.ok) {
29
+ const { kind, message } = result.failure;
30
+ return { ok: false, message, needsCode: kind === "code_required" || kind === "wrong_code" };
31
+ }
30
32
  const member = await auth.upsertMemberFromIdentity(result.identity);
31
33
  if (member.status !== "active") {
32
- return { ok: false, message: "Your account is not active in this workspace." };
34
+ return { ok: false, message: "Your account is not active in this workspace.", needsCode: false };
33
35
  }
34
36
  const session = await auth.issueSession(member.id, result.session);
35
37
  return { ok: true, ...session };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trylight/app",
3
- "version": "0.3.9",
3
+ "version": "0.3.12",
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",