@trylight/app 0.3.8 → 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 +9 -5
- package/dist/auth/lookup.d.ts +1 -1
- package/dist/auth/lookup.js +1 -1
- package/dist/auth/plaza.d.ts +4 -3
- package/dist/auth/plaza.js +38 -4
- package/dist/auth/problems.js +4 -0
- package/dist/brand.generated.d.ts +2 -0
- package/dist/brand.generated.js +2 -0
- package/dist/client.d.ts +21 -3
- package/dist/client.js +18 -4
- package/dist/entities.d.ts +2 -0
- package/dist/platform/contract.d.ts +2 -1
- package/dist/platform/contract.js +3 -2
- package/dist/platform/live-data.d.ts +7 -4
- package/dist/platform/live-data.js +14 -9
- package/dist/server/entities.js +35 -14
- package/dist/server/index.d.ts +2 -2
- package/dist/server/index.js +2 -2
- package/dist/server/session.d.ts +5 -2
- package/dist/server/session.js +8 -6
- package/package.json +12 -10
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
|
|
|
@@ -60,8 +62,10 @@ every `POLL_MS` while the tab is visible, at once on focus and reconnect. The
|
|
|
60
62
|
route answers from `light_changes` with the tables changed since the cursor
|
|
61
63
|
(looking `CHANGES_OVERLAP` behind it, for transactions that committed after
|
|
62
64
|
the last poll read) and the next cursor; never rows. Each changed table's
|
|
63
|
-
queries are invalidated
|
|
64
|
-
|
|
65
|
+
queries are invalidated: those under its key, and those that list its table in
|
|
66
|
+
`meta.tables` (a query that reads several tables, e.g. a server function's
|
|
67
|
+
join), except a table this tab is writing (the write's own refetch follows
|
|
68
|
+
it); the route loaders are reloaded. `light_changes`' `created_at` index is made
|
|
65
69
|
after every migrate (`src/db/audit.ts`). The poll is one `ChangeFeed`; a pushed
|
|
66
70
|
transport can replace it.
|
|
67
71
|
|
package/dist/auth/lookup.d.ts
CHANGED
|
@@ -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 "../
|
|
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,
|
package/dist/auth/lookup.js
CHANGED
|
@@ -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 "../
|
|
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,
|
package/dist/auth/plaza.d.ts
CHANGED
|
@@ -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
|
} | {
|
package/dist/auth/plaza.js
CHANGED
|
@@ -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
|
+
});
|
package/dist/auth/problems.js
CHANGED
|
@@ -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/brand.generated.js
CHANGED
|
@@ -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
|
@@ -1,8 +1,16 @@
|
|
|
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
|
+
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
|
+
}
|
|
13
|
+
export { EntityError, LIST_LIMIT, type CountOptions, 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";
|
|
8
16
|
export { imageSrc } from "./images.js";
|
|
@@ -68,10 +76,12 @@ 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>[]>;
|
|
84
|
+
count: (options?: CountOptions<Row<T>>) => Promise<number>;
|
|
75
85
|
get: (id: string) => Promise<Row<T>>;
|
|
76
86
|
create: (data: NewRow<T>) => Promise<Row<T>>;
|
|
77
87
|
update: (id: string, patch: Patch<T>) => Promise<Row<T>>;
|
|
@@ -686,6 +696,14 @@ declare function tableClient<T extends Entity>(entity: T): {
|
|
|
686
696
|
isEnabled: boolean;
|
|
687
697
|
fetchStatus: import("@tanstack/react-query").FetchStatus;
|
|
688
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>;
|
|
689
707
|
/** One row; `null` waits (nothing open yet). */
|
|
690
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>;
|
|
691
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
|
@@ -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,16 +125,19 @@ 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
|
|
128
|
-
const
|
|
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],
|
|
132
134
|
pages: (options) => [...all, "pages", options],
|
|
135
|
+
count: (options) => [...all, "count", options],
|
|
133
136
|
get: (id) => [...all, "get", id],
|
|
134
137
|
write: [...all, "write"]
|
|
135
138
|
};
|
|
136
139
|
const list = (options = {}) => call("GET", `${base}?q=${encodeURIComponent(JSON.stringify(options))}`);
|
|
140
|
+
const count = (options = {}) => call("GET", `${base}?count=${encodeURIComponent(JSON.stringify(options))}`);
|
|
137
141
|
const get = (id) => call("GET", `${base}/${id}`);
|
|
138
142
|
const create = (data) => call("POST", base, json(data));
|
|
139
143
|
const update = (id, patch) => call("PATCH", `${base}/${id}`, json(patch));
|
|
@@ -168,7 +172,7 @@ function tableClient(entity) {
|
|
|
168
172
|
const client = useQueryClient();
|
|
169
173
|
const refetch = async () => {
|
|
170
174
|
if (client.isMutating({ mutationKey: keys.write }) === 1)
|
|
171
|
-
await client.invalidateQueries({
|
|
175
|
+
await client.invalidateQueries({ predicate: (query) => reads(query, table) });
|
|
172
176
|
};
|
|
173
177
|
return (_data, error) => {
|
|
174
178
|
if (error === null)
|
|
@@ -180,10 +184,12 @@ function tableClient(entity) {
|
|
|
180
184
|
/**
|
|
181
185
|
* The query key everything this table caches sits under. A page's own
|
|
182
186
|
* query over the table (a server function's totals) keyed under it is
|
|
183
|
-
* refetched after every write too
|
|
187
|
+
* refetched after every write too; one over several tables names them in
|
|
188
|
+
* `meta.tables` instead.
|
|
184
189
|
*/
|
|
185
190
|
key: keys.all,
|
|
186
191
|
list,
|
|
192
|
+
count,
|
|
187
193
|
get,
|
|
188
194
|
create,
|
|
189
195
|
update,
|
|
@@ -269,6 +275,14 @@ function tableClient(entity) {
|
|
|
269
275
|
};
|
|
270
276
|
return { ...query, ...view };
|
|
271
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 }),
|
|
272
286
|
/** One row; `null` waits (nothing open yet). */
|
|
273
287
|
useGet: (id) => useQuery({
|
|
274
288
|
queryKey: keys.get(id),
|
package/dist/entities.d.ts
CHANGED
|
@@ -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;
|
|
@@ -31,7 +33,6 @@ export declare const CHAT_FEATURES_HEADER = "X-Mii-Chat-Features";
|
|
|
31
33
|
export declare const STARTING_HEADER = "x-mii-app-starting";
|
|
32
34
|
/** Every TanStack query the SDK owns sits under this key. */
|
|
33
35
|
export declare const QUERY_ROOT = "light";
|
|
34
|
-
export declare const SESSION_COOKIE = "mii_session";
|
|
35
36
|
export declare const STORAGE_KEYS: {
|
|
36
37
|
/** sessionStorage: the build this tab already reloaded for after a missing chunk, so a second miss on it can't loop. */
|
|
37
38
|
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
|
|
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,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. */
|
|
@@ -34,7 +36,6 @@ export const CHAT_FEATURES_HEADER = "X-Mii-Chat-Features";
|
|
|
34
36
|
export const STARTING_HEADER = `x-${LEGACY_NAMESPACE}-app-starting`;
|
|
35
37
|
/** Every TanStack query the SDK owns sits under this key. */
|
|
36
38
|
export const QUERY_ROOT = NAMESPACE;
|
|
37
|
-
export const SESSION_COOKIE = `${LEGACY_NAMESPACE}_session`;
|
|
38
39
|
export const STORAGE_KEYS = {
|
|
39
40
|
/** sessionStorage: the build this tab already reloaded for after a missing chunk, so a second miss on it can't loop. */
|
|
40
41
|
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,
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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,
|
|
77
|
-
*
|
|
78
|
-
*
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
}
|
package/dist/server/entities.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/server/index.d.ts
CHANGED
|
@@ -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>;
|
package/dist/server/index.js
CHANGED
|
@@ -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() {
|
package/dist/server/session.d.ts
CHANGED
|
@@ -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
|
} | {
|
package/dist/server/session.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"url": "https://github.com/shubh9/mii.git",
|
|
13
13
|
"directory": "packages/app-sdk"
|
|
14
14
|
},
|
|
15
|
+
"packageManager": "pnpm@11.0.9",
|
|
15
16
|
"files": [
|
|
16
17
|
"dist",
|
|
17
18
|
"bin"
|
|
@@ -41,6 +42,15 @@
|
|
|
41
42
|
"default": "./dist/vite.js"
|
|
42
43
|
}
|
|
43
44
|
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
47
|
+
"typecheck": "tsc --noEmit",
|
|
48
|
+
"lint": "eslint src test --max-warnings 0",
|
|
49
|
+
"test": "node --test --import tsx \"test/**/*.test.ts\"",
|
|
50
|
+
"check": "tsx scripts/write-brand.ts --check && pnpm run typecheck && pnpm run lint && pnpm run test && pnpm run build",
|
|
51
|
+
"prepack": "pnpm run build",
|
|
52
|
+
"brand": "tsx scripts/write-brand.ts"
|
|
53
|
+
},
|
|
44
54
|
"peerDependencies": {
|
|
45
55
|
"@tanstack/react-query": ">=5.69",
|
|
46
56
|
"@tanstack/react-router": ">=1.170",
|
|
@@ -74,13 +84,5 @@
|
|
|
74
84
|
"typescript-eslint": "^8.70.1",
|
|
75
85
|
"vite": "^8.2.0",
|
|
76
86
|
"zod": "^4.6.5"
|
|
77
|
-
},
|
|
78
|
-
"scripts": {
|
|
79
|
-
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
80
|
-
"typecheck": "tsc --noEmit",
|
|
81
|
-
"lint": "eslint src test --max-warnings 0",
|
|
82
|
-
"test": "node --test --import tsx \"test/**/*.test.ts\"",
|
|
83
|
-
"check": "tsx scripts/write-brand.ts --check && pnpm run typecheck && pnpm run lint && pnpm run test && pnpm run build",
|
|
84
|
-
"brand": "tsx scripts/write-brand.ts"
|
|
85
87
|
}
|
|
86
|
-
}
|
|
88
|
+
}
|