@trylight/app 0.1.9 → 0.2.1

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.
Files changed (69) hide show
  1. package/README.md +3 -18
  2. package/dist/auth/config.d.ts +5 -0
  3. package/dist/auth/config.js +16 -0
  4. package/dist/auth/context.d.ts +11 -0
  5. package/dist/auth/context.js +1 -0
  6. package/dist/auth/core.d.ts +64 -0
  7. package/dist/auth/core.js +50 -0
  8. package/dist/auth/db.d.ts +7 -0
  9. package/dist/auth/db.js +57 -0
  10. package/dist/auth/lookup.d.ts +47 -0
  11. package/dist/auth/lookup.js +120 -0
  12. package/dist/auth/mode.d.ts +8 -0
  13. package/dist/auth/mode.js +52 -0
  14. package/dist/auth/next-path.d.ts +1 -0
  15. package/dist/auth/next-path.js +10 -0
  16. package/dist/auth/plaza.d.ts +68 -0
  17. package/dist/auth/plaza.js +154 -0
  18. package/dist/auth/problems.d.ts +4 -0
  19. package/dist/auth/problems.js +36 -0
  20. package/dist/auth/users.d.ts +8 -0
  21. package/dist/auth/users.js +25 -0
  22. package/dist/brand.generated.d.ts +2 -0
  23. package/dist/brand.generated.js +3 -0
  24. package/dist/chat/paths.d.ts +10 -0
  25. package/dist/chat/paths.js +10 -0
  26. package/dist/chat/server.d.ts +24 -0
  27. package/dist/chat/server.js +161 -0
  28. package/dist/client.d.ts +3 -3
  29. package/dist/client.js +2 -2
  30. package/dist/db/cli.js +3 -40
  31. package/dist/db/migrations.d.ts +2 -2
  32. package/dist/db/migrations.js +2 -2
  33. package/dist/invoke/contracts.d.ts +28 -0
  34. package/dist/invoke/contracts.js +1 -0
  35. package/dist/invoke/error.d.ts +11 -0
  36. package/dist/invoke/error.js +13 -0
  37. package/dist/invoke/index.d.ts +73 -0
  38. package/dist/invoke/index.js +274 -0
  39. package/dist/invoke/paths.d.ts +2 -0
  40. package/dist/invoke/paths.js +2 -0
  41. package/dist/invoke/server.d.ts +45 -0
  42. package/dist/invoke/server.js +259 -0
  43. package/dist/platform/index.d.ts +71 -0
  44. package/dist/platform/index.js +80 -0
  45. package/dist/platform/pane-bridge.d.ts +1 -0
  46. package/dist/platform/pane-bridge.js +216 -0
  47. package/dist/platform/server-fn-door.d.ts +27 -0
  48. package/dist/platform/server-fn-door.js +32 -0
  49. package/dist/platform.d.ts +2 -2
  50. package/dist/platform.js +3 -3
  51. package/dist/request-error.d.ts +10 -0
  52. package/dist/request-error.js +29 -0
  53. package/dist/schema.d.ts +1 -1
  54. package/dist/server/db-retry.d.ts +7 -0
  55. package/dist/server/db-retry.js +58 -0
  56. package/dist/server/entities.js +1 -1
  57. package/dist/server/health.d.ts +17 -0
  58. package/dist/server/health.js +42 -0
  59. package/dist/server/index.d.ts +7 -6
  60. package/dist/server/index.js +7 -6
  61. package/dist/server/sentry-tunnel.d.ts +6 -0
  62. package/dist/server/sentry-tunnel.js +52 -0
  63. package/dist/server/session.d.ts +1 -1
  64. package/dist/server/session.js +2 -2
  65. package/dist/uuid.d.ts +1 -0
  66. package/dist/uuid.js +7 -0
  67. package/package.json +12 -10
  68. package/dist/db/baseline.d.ts +0 -36
  69. package/dist/db/baseline.js +0 -439
@@ -0,0 +1,154 @@
1
+ import { z } from "zod/v4";
2
+ import { miiAuthConfig } from "./config.js";
3
+ /**
4
+ * Check an email and password with the platform, and get back who the person is and
5
+ * the session the platform signed for them.
6
+ */
7
+ export async function signInWithPlaza(email, password) {
8
+ const { plazaUrl, appId } = miiAuthConfig();
9
+ let response;
10
+ try {
11
+ response = await fetch(`${plazaUrl}/api/app-auth/sign-in`, {
12
+ method: "POST",
13
+ headers: { "Content-Type": "application/json" },
14
+ body: JSON.stringify({ appId, email, password }),
15
+ cache: "no-store"
16
+ });
17
+ }
18
+ catch (cause) {
19
+ return {
20
+ ok: false,
21
+ failure: {
22
+ kind: "unavailable",
23
+ message: "Sign-in is unreachable right now. Try again in a moment.",
24
+ cause
25
+ }
26
+ };
27
+ }
28
+ if (response.ok) {
29
+ const body = await response.json();
30
+ const signedIn = asSignedIn(body);
31
+ if (signedIn !== null) {
32
+ return { ok: true, ...signedIn };
33
+ }
34
+ return {
35
+ ok: false,
36
+ failure: {
37
+ kind: "unavailable",
38
+ message: "Sign-in answered with something unexpected. Try again.",
39
+ cause: new Error(`Platform sign-in answered 200 without an identity and session: ${JSON.stringify(body)}`)
40
+ }
41
+ };
42
+ }
43
+ return {
44
+ ok: false,
45
+ failure: failureFor(response.status, await response.text())
46
+ };
47
+ }
48
+ /**
49
+ * Spend a code for the person behind it, or null if it is spent or stale.
50
+ *
51
+ * Throws when the platform itself is the problem — a 5xx or an unreadable answer — so
52
+ * that reaches the route's error reporting rather than looking like a stale
53
+ * code and sending the person round again.
54
+ */
55
+ export async function redeemWithPlaza(code) {
56
+ const { plazaUrl, appId } = miiAuthConfig();
57
+ const response = await fetch(`${plazaUrl}/api/app-auth/redeem`, {
58
+ method: "POST",
59
+ headers: { "Content-Type": "application/json" },
60
+ body: JSON.stringify({ appId, code }),
61
+ cache: "no-store"
62
+ });
63
+ if (response.status >= 500) {
64
+ throw new Error(`Platform redeem answered ${response.status}: ${await response.text()}`);
65
+ }
66
+ if (!response.ok) {
67
+ return null;
68
+ }
69
+ const body = await response.json();
70
+ const signedIn = asSignedIn(body);
71
+ if (signedIn === null) {
72
+ throw new Error(`Platform redeem answered 200 without an identity and session: ${JSON.stringify(body)}`);
73
+ }
74
+ return signedIn;
75
+ }
76
+ /**
77
+ * Whether the platform still stands behind a session: "ended" when the token is no
78
+ * longer good or the person has left the org. Throws when the platform cannot say, so
79
+ * an outage is reported rather than read as either answer.
80
+ */
81
+ export async function checkPlazaSession(token) {
82
+ const { plazaUrl } = miiAuthConfig();
83
+ // Asked from inside a page load, so the platform that hangs must not hang the page.
84
+ const response = await fetch(`${plazaUrl}/api/app-chat/v1/session`, {
85
+ headers: { Authorization: `Bearer ${token}` },
86
+ cache: "no-store",
87
+ signal: AbortSignal.timeout(PLAZA_SESSION_TIMEOUT_MS)
88
+ });
89
+ if (response.ok)
90
+ return "valid";
91
+ if (response.status === 401 || response.status === 403)
92
+ return "ended";
93
+ throw new Error(`Platform session check answered ${response.status}: ${(await response.text()).slice(0, 300)}`);
94
+ }
95
+ /**
96
+ * Tell the platform a session is over, so the token stops working now rather than when
97
+ * it expires. Throws when the platform did not take it.
98
+ */
99
+ export async function revokePlazaSession(token) {
100
+ const { plazaUrl } = miiAuthConfig();
101
+ const response = await fetch(`${plazaUrl}/api/app-chat/v1/session`, {
102
+ method: "DELETE",
103
+ headers: { Authorization: `Bearer ${token}` },
104
+ cache: "no-store",
105
+ signal: AbortSignal.timeout(PLAZA_SESSION_TIMEOUT_MS)
106
+ });
107
+ if (!response.ok) {
108
+ throw new Error(`Platform session revoke answered ${response.status}: ${(await response.text()).slice(0, 300)}`);
109
+ }
110
+ }
111
+ const PLAZA_SESSION_TIMEOUT_MS = 5000;
112
+ /** A sign-in or redeem answer: who the person is, and the session the platform signed. */
113
+ const signedInBody = z.object({
114
+ miiId: z.string(),
115
+ orgId: z.string(),
116
+ name: z.string(),
117
+ email: z.string().nullable().catch(null),
118
+ avatarUrl: z.string().nullable().catch(null),
119
+ // A platform that does not say `isAgent` is one whose agents do not sign in
120
+ // to apps, and an app can be running ahead of the platform in front of it.
121
+ isAgent: z.boolean().catch(false),
122
+ session: z.object({ token: z.string(), expiresAt: z.string().pipe(z.coerce.date()) })
123
+ });
124
+ function asSignedIn(body) {
125
+ const read = signedInBody.safeParse(body);
126
+ if (!read.success)
127
+ return null;
128
+ const { session, ...identity } = read.data;
129
+ return { identity, session };
130
+ }
131
+ function failureFor(status, body) {
132
+ if (status === 401) {
133
+ return { kind: "credentials", message: "Wrong email or password." };
134
+ }
135
+ if (status === 403) {
136
+ return {
137
+ kind: "not_a_member",
138
+ // Said plainly: they have just proved they own the account, so this is
139
+ // not a secret from them, and it is the one thing they can act on.
140
+ message: "That account isn't set up for this app. Ask whoever runs it."
141
+ };
142
+ }
143
+ if (status === 429) {
144
+ return {
145
+ kind: "throttled",
146
+ message: "Too many wrong tries. Wait a minute and try again."
147
+ };
148
+ }
149
+ return {
150
+ kind: "unavailable",
151
+ message: "Sign-in isn't answering right now. Try again in a moment.",
152
+ cause: new Error(`Platform sign-in answered ${status}: ${body.slice(0, 300)}`)
153
+ };
154
+ }
@@ -0,0 +1,4 @@
1
+ export declare const SIGN_IN_PROBLEMS: Record<string, {
2
+ readonly title: string;
3
+ readonly body: string;
4
+ }>;
@@ -0,0 +1,36 @@
1
+ // Why a sign-in did not happen, in words worth reading.
2
+ //
3
+ // The code travels in the URL and the words live here, so a failure never puts
4
+ // an email, a password, or a message the platform wrote into someone's address bar or
5
+ // their history. Each one says what to do next, because "try again" is only the
6
+ // right advice for some of them.
7
+ export const SIGN_IN_PROBLEMS = {
8
+ credentials: {
9
+ title: "Wrong email or password",
10
+ body: "Check both and try again. It's the same account you use to talk to your team's agents."
11
+ },
12
+ not_a_member: {
13
+ title: "That account can't open this app",
14
+ body: "The password was right, but the account isn't in this app's org. Ask whoever runs it to add you."
15
+ },
16
+ deactivated: {
17
+ title: "Your access here has been turned off",
18
+ body: "Ask whoever runs this app to turn it back on."
19
+ },
20
+ throttled: {
21
+ title: "Too many wrong tries",
22
+ body: "Wait a minute, then try again."
23
+ },
24
+ expired: {
25
+ title: "That took too long",
26
+ body: "The sign-in expired before it finished. Try again."
27
+ },
28
+ unavailable: {
29
+ title: "Sign-in isn't answering",
30
+ body: "This is our side, not yours. Try again in a moment."
31
+ },
32
+ missing: {
33
+ title: "Enter your email and password",
34
+ body: "Both are needed to sign in."
35
+ }
36
+ };
@@ -0,0 +1,8 @@
1
+ import type { AuthContext } from "./context.js";
2
+ import type { MiiIdentity } from "./plaza.js";
3
+ export declare function upsertMemberFromIdentity(ctx: AuthContext, identity: MiiIdentity): Promise<{
4
+ id: string;
5
+ status: string;
6
+ }>;
7
+ /** Deactivate a user: their sessions stop working on the next request. */
8
+ export declare function deactivateUser(ctx: AuthContext, userId: string): Promise<void>;
@@ -0,0 +1,25 @@
1
+ // The user row behind a sign-in. Created the first time someone arrives,
2
+ // refreshed on every visit from what the platform says now (name, email, avatar), and
3
+ // keyed on the actor id — the one thing about a person that never changes.
4
+ export async function upsertMemberFromIdentity(ctx, identity) {
5
+ const sql = await ctx.getSql();
6
+ const kind = identity.isAgent ? "agent" : "member";
7
+ const [user = null] = await sql `
8
+ insert into mii_auth_users (mii_id, email, name, avatar_url, kind, last_login_at)
9
+ values (${identity.miiId}, ${identity.email}, ${identity.name}, ${identity.avatarUrl}, ${kind}, now())
10
+ on conflict (mii_id) do update set
11
+ email = excluded.email,
12
+ name = excluded.name,
13
+ avatar_url = excluded.avatar_url,
14
+ last_login_at = now()
15
+ ${identity.isAgent ? sql `, kind = 'agent'` : sql ``}
16
+ returning id, status`;
17
+ if (user === null)
18
+ throw new Error("Upserting into mii_auth_users returned no row.");
19
+ return user;
20
+ }
21
+ /** Deactivate a user: their sessions stop working on the next request. */
22
+ export async function deactivateUser(ctx, userId) {
23
+ const sql = await ctx.getSql();
24
+ await sql `update mii_auth_users set status = 'inactive' where id = ${userId}`;
25
+ }
@@ -0,0 +1,2 @@
1
+ /** The platform app's origins, current domain first. */
2
+ export declare const APP_ORIGINS: readonly string[];
@@ -0,0 +1,3 @@
1
+ // Generated from config/brand.json by scripts/write-brand.ts. Do not edit.
2
+ /** The platform app's origins, current domain first. */
3
+ export const APP_ORIGINS = ["https://trylight.ai", "https://plaza.miis.run"];
@@ -0,0 +1,10 @@
1
+ /** Where an app mounts its chat routes, and where the widget calls them. */
2
+ export declare const CHAT_BASE_PATH = "/api/mii/chat";
3
+ /**
4
+ * What the app's chat server can do beyond the first contract, listed on its
5
+ * answers. The platform serves the widget to apps on every runtime version, so the
6
+ * widget offers only what this app's server says it forwards.
7
+ */
8
+ export declare const CHAT_FEATURES_HEADER = "X-Mii-Chat-Features";
9
+ export declare const REACTIONS_FEATURE = "reactions";
10
+ export declare const CREDITS_FEATURE = "credits";
@@ -0,0 +1,10 @@
1
+ /** Where an app mounts its chat routes, and where the widget calls them. */
2
+ export const CHAT_BASE_PATH = "/api/mii/chat";
3
+ /**
4
+ * What the app's chat server can do beyond the first contract, listed on its
5
+ * answers. The platform serves the widget to apps on every runtime version, so the
6
+ * widget offers only what this app's server says it forwards.
7
+ */
8
+ export const CHAT_FEATURES_HEADER = "X-Mii-Chat-Features";
9
+ export const REACTIONS_FEATURE = "reactions";
10
+ export const CREDITS_FEATURE = "credits";
@@ -0,0 +1,24 @@
1
+ import type { MiiAuthCore } from "../auth/core.js";
2
+ export type MiiChatHandlerOptions = {
3
+ /** Where to report failures that are ours. Defaults to console.error. */
4
+ readonly reportError?: (error: unknown, context: Record<string, string>) => void;
5
+ };
6
+ /** Why the platform stopped standing behind a session. */
7
+ export type ChatRevokeReason = "token_rejected" | "not_a_member";
8
+ export type ChatAuth = Pick<MiiAuthCore, "resolveSessionWithPlaza" | "SESSION_COOKIE"> & {
9
+ /**
10
+ * Let go of a session the platform has refused. An app whose session is the platform's own
11
+ * ends it whatever the reason; one that keeps its own session ends only the
12
+ * part the platform gave it, and only when signing in again would help.
13
+ */
14
+ revokeSession(token: string, reason?: ChatRevokeReason): Promise<void>;
15
+ };
16
+ export declare function createMiiChatHandler(auth: ChatAuth, { reportError }?: MiiChatHandlerOptions): (request: Request) => Promise<Response>;
17
+ /**
18
+ * Whether the request came from this app's own pages. Browsers send
19
+ * `Sec-Fetch-Site` on every fetch; `Origin` covers the ones that predate it.
20
+ * A request with neither is not from a browser page, and a non-browser caller
21
+ * has no cookie to borrow.
22
+ */
23
+ export declare function isSameOrigin(request: Request, url: URL): boolean;
24
+ export declare function readCookie(header: string | null, name: string): string | null;
@@ -0,0 +1,161 @@
1
+ // The server half of an org app's chat: the routes the widget calls, on the
2
+ // app's own origin, forwarded to the platform as the signed-in person.
3
+ //
4
+ // Framework-free: a Web `Request` in, a `Response` out. The app mounts it once
5
+ // on a catch-all route under CHAT_BASE_PATH:
6
+ //
7
+ // // src/routes/api/mii/chat/$.ts (TanStack Start)
8
+ // const chat = createMiiChatHandler(auth);
9
+ // export const Route = createFileRoute("/api/mii/chat/$")({
10
+ // server: { handlers: { GET: ({ request }) => chat(request), POST: ({ request }) => chat(request) } }
11
+ // });
12
+ //
13
+ // The browser never holds a credential for the platform. It sends this app's own
14
+ // session cookie; this server looks the session up and sends the platform the token
15
+ // the platform signed when the person signed in. The platform checks that token and the
16
+ // person's membership on every request.
17
+ import { miiAuthConfig } from "../auth/config.js";
18
+ import { CHAT_BASE_PATH, CHAT_FEATURES_HEADER, CREDITS_FEATURE, REACTIONS_FEATURE } from "./paths.js";
19
+ // The platform's browser-facing chat routes all sit under this one prefix, and this
20
+ // server forwards any path beneath it: a widget route the platform adds needs no
21
+ // release of this package. The platform's server-only routes (session) are outside it,
22
+ // and each route under it validates its own ids.
23
+ const PLAZA_CHAT_PATH = "/api/app-chat/v1/chat";
24
+ // Plain segments only: no dots, no percent-encoding, no empty segment. A path
25
+ // that could climb out of the platform's prefix (`..`, `%2e%2e`, `%2f`, `//`) never
26
+ // leaves this server.
27
+ const FORWARDABLE_PATH = /^(\/[A-Za-z0-9_-]+)+$/;
28
+ // What the thread's poll and its pages may carry through. Everything else in
29
+ // the query string is dropped rather than forwarded.
30
+ const FORWARDED_QUERY = ["after", "afterId", "limit", "before", "beforeId"];
31
+ export function createMiiChatHandler(auth, { reportError = (error, context) => console.error("mii-chat error", context, error) } = {}) {
32
+ return async (request) => {
33
+ const url = new URL(request.url);
34
+ if (!url.pathname.startsWith(`${CHAT_BASE_PATH}/`)) {
35
+ return refuse(404, "not_found", "There's no chat here.");
36
+ }
37
+ const path = url.pathname.slice(CHAT_BASE_PATH.length);
38
+ const method = request.method;
39
+ if ((method !== "GET" && method !== "POST") || !FORWARDABLE_PATH.test(path)) {
40
+ return refuse(404, "not_found", "There's no chat here.");
41
+ }
42
+ // Every org app shares one site with every other (*.<domain>), so a page
43
+ // on another org's app can send this app's cookie along. Only this app's
44
+ // own pages may use its chat.
45
+ if (!isSameOrigin(request, url)) {
46
+ return refuse(403, "cross_site", "Chat is only available from this app's own pages.");
47
+ }
48
+ const token = readCookie(request.headers.get("cookie"), auth.SESSION_COOKIE);
49
+ if (token === null) {
50
+ return refuse(401, "not_signed_in", "Sign in to chat with your agents.");
51
+ }
52
+ const session = await auth.resolveSessionWithPlaza(token);
53
+ if (session === null) {
54
+ // Signed in here, holding nothing to act with. Its own answer, so the
55
+ // widget can say why rather than not being there.
56
+ return refuse(401, "sign_in_again", "Sign out and back in to use it here.");
57
+ }
58
+ const { plazaUrl } = miiAuthConfig();
59
+ const target = new URL(`${plazaUrl}${PLAZA_CHAT_PATH}${path}`);
60
+ for (const key of FORWARDED_QUERY) {
61
+ const value = url.searchParams.get(key);
62
+ if (value !== null)
63
+ target.searchParams.set(key, value);
64
+ }
65
+ let answer;
66
+ try {
67
+ // `duplex` is how fetch takes a streamed body; DOM's RequestInit predates it.
68
+ const init = {
69
+ method,
70
+ // Only these: never the browser's cookie or its own Authorization.
71
+ // The platform's chat routes take JSON; files go straight to storage.
72
+ headers: {
73
+ Authorization: `Bearer ${session.plazaToken}`,
74
+ ...(method === "POST" ? { "Content-Type": "application/json" } : {})
75
+ },
76
+ body: method === "POST" ? request.body : undefined,
77
+ duplex: "half",
78
+ cache: "no-store",
79
+ // An agent with a photo answers with a redirect to it: the browser
80
+ // follows that itself, rather than this server fetching the photo.
81
+ redirect: "manual"
82
+ };
83
+ answer = await fetch(target, init);
84
+ }
85
+ catch (error) {
86
+ reportError(error, { where: "mii-chat", step: "forward" });
87
+ return refuse(502, "unavailable", "Couldn't reach the chat. Try again in a moment.");
88
+ }
89
+ // The platform no longer stands behind this session: the token is no good, or the
90
+ // person is no longer in the org. Which one matters to the app — one is
91
+ // fixed by signing in again and the other is not — so it is passed on.
92
+ if (answer.status === 401 || answer.status === 403) {
93
+ await auth.revokeSession(token, answer.status === 403 ? "not_a_member" : "token_rejected");
94
+ }
95
+ if (answer.status >= 500) {
96
+ reportError(new Error(`Platform chat answered ${answer.status}: ${(await answer.clone().text()).slice(0, 300)}`), { where: "mii-chat", step: "plaza" });
97
+ }
98
+ // An avatar may be cached for the person; chat data never is.
99
+ const cacheControl = answer.headers.get("cache-control");
100
+ const headers = new Headers({
101
+ "Cache-Control": cacheControl !== null && cacheControl.startsWith("private") ? cacheControl : "no-store",
102
+ [CHAT_FEATURES_HEADER]: [REACTIONS_FEATURE, CREDITS_FEATURE].join(",")
103
+ });
104
+ const contentType = answer.headers.get("content-type");
105
+ if (contentType !== null)
106
+ headers.set("Content-Type", contentType);
107
+ const location = answer.headers.get("location");
108
+ if (answer.status >= 300 && answer.status < 400 && location !== null) {
109
+ headers.set("Location", location);
110
+ }
111
+ const policy = answer.headers.get("content-security-policy");
112
+ if (policy !== null)
113
+ headers.set("Content-Security-Policy", policy);
114
+ return new Response(answer.body, { status: answer.status, headers });
115
+ };
116
+ }
117
+ /**
118
+ * Whether the request came from this app's own pages. Browsers send
119
+ * `Sec-Fetch-Site` on every fetch; `Origin` covers the ones that predate it.
120
+ * A request with neither is not from a browser page, and a non-browser caller
121
+ * has no cookie to borrow.
122
+ */
123
+ export function isSameOrigin(request, url) {
124
+ const site = request.headers.get("sec-fetch-site");
125
+ if (site !== null && site !== "same-origin") {
126
+ return false;
127
+ }
128
+ const origin = request.headers.get("origin");
129
+ return origin === null || origin === url.origin || servedAt(request).includes(new URL(origin).host);
130
+ }
131
+ /**
132
+ * Every address this request reached the app on. Behind proxies the request
133
+ * URL can carry an internal host; each hop appends the host it was asked for
134
+ * to X-Forwarded-Host, and a hop may rewrite Origin to its own address.
135
+ */
136
+ function servedAt(request) {
137
+ return [request.headers.get("x-forwarded-host"), request.headers.get("host")]
138
+ .filter((value) => value !== null)
139
+ .flatMap((value) => value.split(",").map((host) => host.trim()));
140
+ }
141
+ export function readCookie(header, name) {
142
+ if (header === null)
143
+ return null;
144
+ for (const part of header.split(";")) {
145
+ const index = part.indexOf("=");
146
+ if (index === -1)
147
+ continue;
148
+ if (part.slice(0, index).trim() === name) {
149
+ const value = part.slice(index + 1).trim();
150
+ return value === "" ? null : value;
151
+ }
152
+ }
153
+ return null;
154
+ }
155
+ // The platform's error shape: the sentence a person reads, and the reason as a code.
156
+ function refuse(status, code, error) {
157
+ return new Response(JSON.stringify({ error, code }), {
158
+ status,
159
+ headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }
160
+ });
161
+ }
package/dist/client.d.ts CHANGED
@@ -1,15 +1,15 @@
1
1
  import { type QueryKey } from "@tanstack/react-query";
2
- import type { Contracts } from "@shubh90/app-runtime/invoke/server";
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
5
  export { EntityError, LIST_LIMIT, type ListOptions, type NewRow, type Sort, type Patch, type Row, type Where } from "./entities.js";
6
6
  export type { FieldSource, FieldSources } from "./schema.js";
7
7
  export { siteOf } from "./schema.js";
8
8
  export { imageSrc } from "./images.js";
9
- export { InvocationError, type InvocationStatus } from "@shubh90/app-runtime/invoke";
9
+ export { InvocationError, type InvocationStatus } from "./invoke/index.js";
10
10
  export declare function createApp<C extends Contracts = Record<never, never>>(): {
11
11
  table: typeof tableClient;
12
- contracts: import("@shubh90/app-runtime/invoke").Invoker<C>;
12
+ contracts: import("./invoke/index.js").Invoker<C>;
13
13
  };
14
14
  /** Rows one page of `useInfiniteList` asks for; scrolling near the end asks for the next. */
15
15
  export declare const PAGE_SIZE = 100;
package/dist/client.js CHANGED
@@ -7,14 +7,14 @@
7
7
  // shows at once and never snaps back. `app.contracts` asks the org's agents.
8
8
  import { hashKey, keepPreviousData, skipToken, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
9
9
  import { useEffect, useMemo, useState } from "react";
10
- import { createInvoker } from "@shubh90/app-runtime/invoke";
10
+ import { createInvoker } from "./invoke/index.js";
11
11
  import { getTableColumns, getTableName } from "drizzle-orm";
12
12
  import { z } from "zod/v4";
13
13
  import { ENTITIES_PATH, EntityError, LIST_LIMIT } from "./entities.js";
14
14
  export { EntityError, LIST_LIMIT } from "./entities.js";
15
15
  export { siteOf } from "./schema.js";
16
16
  export { imageSrc } from "./images.js";
17
- export { InvocationError } from "@shubh90/app-runtime/invoke";
17
+ export { InvocationError } from "./invoke/index.js";
18
18
  export function createApp() {
19
19
  return { table: tableClient, contracts: createInvoker() };
20
20
  }
package/dist/db/cli.js CHANGED
@@ -1,6 +1,5 @@
1
1
  // `light-db sync`, `light-db migrate`, `light-db check`: the
2
- // app's `pnpm db:*` scripts; `light-db baseline` moves an older app's tables
3
- // under db/schema, once. Run from the app's root.
2
+ // app's `pnpm db:*` scripts. Run from the app's root.
4
3
  import { existsSync } from "node:fs";
5
4
  import { request } from "node:http";
6
5
  import { userInfo } from "node:os";
@@ -11,7 +10,6 @@ import { migrate } from "drizzle-orm/postgres-js/migrator";
11
10
  import postgres from "postgres";
12
11
  import { z } from "zod/v4";
13
12
  import { archiveNotices, entityTables, prepareAudit, trackTables } from "./audit.js";
14
- import { BaselineRefused, baseline as writeBaseline } from "./baseline.js";
15
13
  import { MIGRATIONS_DIR, SCHEMA_ENTRY, plan, unwriteMigration, writeMigration } from "./migrations.js";
16
14
  import { env } from "../server/env.js";
17
15
  /**
@@ -127,42 +125,7 @@ async function migrateThroughPlatform() {
127
125
  process.stdout.write(reply.output);
128
126
  return reply.code;
129
127
  }
130
- /** Writes db/schema and a first migration from the live tables of an app made before db/schema. */
131
- async function baseline() {
132
- const { databaseUrl } = env();
133
- if (databaseUrl === undefined)
134
- throw new Error("DATABASE_URL is not set: baseline reads the app's database.");
135
- let written;
136
- try {
137
- written = await writeBaseline(root, databaseUrl);
138
- }
139
- catch (error) {
140
- if (!(error instanceof BaselineRefused))
141
- throw error;
142
- console.error(error.message);
143
- return 1;
144
- }
145
- console.log(`Wrote db/schema from the database, and ${MIGRATIONS_DIR}/0000_baseline.sql recording its tables as they are:`);
146
- for (const table of written.tables) {
147
- const lacks = table.lacks.length > 0 ? ` (lacks ${table.lacks.join(", ")}; the first sync adds them)` : "";
148
- console.log(` db/schema/${table.name}.ts${lacks}`);
149
- }
150
- console.log('Every entity is { read: "door", write: "door" }; make one `write: "agents"` if only agents fill it.');
151
- if (written.plain.length > 0) {
152
- console.log("Plain tables (no sync changes them; app.table can't serve them):");
153
- for (const table of written.plain)
154
- console.log(` db/schema/${table.name}.ts: ${table.why}`);
155
- }
156
- if (written.skipped.length > 0)
157
- console.log(`Skipped, the platform's own tables: ${written.skipped.join(", ")}.`);
158
- if (written.leftOut.length > 0)
159
- console.log(`Left in the database, not in db/schema: ${written.leftOut.join(", ")}.`);
160
- console.log(written.pending.length === 0
161
- ? "`pnpm db:sync` has nothing to change."
162
- : `\`pnpm db:sync\` will only add this; nothing already there changes:\n${written.pending.join("\n")}`);
163
- return 0;
164
- }
165
128
  // Last, so every const above is initialized before a command reads it.
166
- const commands = { sync, migrate: applyMigrations, check, baseline };
167
- const usage = z.enum(["sync", "migrate", "check", "baseline"], { error: "Usage: light-db sync | migrate | check | baseline" });
129
+ const commands = { sync, migrate: applyMigrations, check };
130
+ const usage = z.enum(["sync", "migrate", "check"], { error: "Usage: light-db sync | migrate | check" });
168
131
  process.exit(await commands[usage.parse(command)]());
@@ -10,7 +10,7 @@ export type Plan = {
10
10
  snapshot: Snapshot;
11
11
  };
12
12
  /** The parts of a drizzle-kit snapshot a removal touches; the rest passes through. */
13
- export declare const snapshotSchema: z.ZodObject<{
13
+ declare const snapshotSchema: z.ZodObject<{
14
14
  id: z.ZodString;
15
15
  tables: z.ZodRecord<z.ZodString, z.ZodObject<{
16
16
  columns: z.ZodRecord<z.ZodString, z.ZodUnknown>;
@@ -37,7 +37,6 @@ export type Snapshot = z.infer<typeof snapshotSchema>;
37
37
  * after the columns that used them have changed.
38
38
  */
39
39
  export declare function plan(root: string, schema: Record<string, unknown>): Promise<Plan>;
40
- export declare const EMPTY_SNAPSHOT: Snapshot;
41
40
  /**
42
41
  * Writes the plan as the next migration, in drizzle-kit's own layout (the
43
42
  * SQL, its snapshot, the journal entry), so drizzle's migrator applies it.
@@ -45,3 +44,4 @@ export declare const EMPTY_SNAPSHOT: Snapshot;
45
44
  export declare function writeMigration(root: string, planned: Plan, name?: string): string;
46
45
  /** Takes back the newest migration, one that didn't apply, so the schema's change is pending again. */
47
46
  export declare function unwriteMigration(root: string, tag: string): void;
47
+ export {};
@@ -12,7 +12,7 @@ export const SCHEMA_ENTRY = "db/schema/index.ts";
12
12
  export const MIGRATIONS_DIR = "db/migrations";
13
13
  const entries = z.record(z.string(), z.unknown());
14
14
  /** The parts of a drizzle-kit snapshot a removal touches; the rest passes through. */
15
- export const snapshotSchema = z.looseObject({
15
+ const snapshotSchema = z.looseObject({
16
16
  id: z.string(),
17
17
  tables: z.record(z.string(), z.looseObject({
18
18
  columns: entries,
@@ -102,7 +102,7 @@ const journalSchema = z.object({
102
102
  breakpoints: z.boolean()
103
103
  }))
104
104
  });
105
- export const EMPTY_SNAPSHOT = {
105
+ const EMPTY_SNAPSHOT = {
106
106
  version: "7",
107
107
  dialect: "postgresql",
108
108
  id: "00000000-0000-0000-0000-000000000000",
@@ -0,0 +1,28 @@
1
+ import type { z } from "zod/v4";
2
+ type ObjectSchema = z.ZodType<Record<string, unknown>, Record<string, unknown>>;
3
+ export type Contract = {
4
+ /** The id `mii contract create` printed. */
5
+ readonly id: string;
6
+ readonly input: ObjectSchema;
7
+ readonly output: ObjectSchema;
8
+ };
9
+ export type Contracts = Readonly<Record<string, Contract>>;
10
+ export type ContractInput<C extends Contracts, K extends keyof C> = z.input<C[K]["input"]>;
11
+ export type ContractOutput<C extends Contracts, K extends keyof C> = z.output<C[K]["output"]>;
12
+ export type InvocationState = "pending" | "succeeded" | "failed";
13
+ /** What the app's invoke routes answer with; `Output` is a contract's, once the app's schema has parsed it. */
14
+ export type Invocation<Output = Record<string, unknown>> = {
15
+ readonly id: string;
16
+ /** The input the run was started with: the JSON of the app's schema's parse at the start. */
17
+ readonly input: Record<string, unknown>;
18
+ readonly lastError: string | null;
19
+ readonly createdAt: string;
20
+ readonly finishedAt: string | null;
21
+ } & ({
22
+ readonly state: "pending" | "failed";
23
+ readonly result: null;
24
+ } | {
25
+ readonly state: "succeeded";
26
+ readonly result: Output;
27
+ });
28
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ import { RequestError } from "../request-error.js";
2
+ /**
3
+ * Why an invocation did not produce a result. `code` is the server's
4
+ * (`not_signed_in`, `invalid_input`, `unknown_contract`, `result_mismatch`, …),
5
+ * or `failed` when the agents could not produce a result matching the schema.
6
+ * `status` is 0 when no HTTP refusal is behind it: `failed`, or no answer at all.
7
+ */
8
+ export declare class InvocationError extends RequestError {
9
+ }
10
+ /** A run the agents closed without a result that matches its schema. */
11
+ export declare function failedRun(lastError: string | null): InvocationError;
@@ -0,0 +1,13 @@
1
+ import { RequestError } from "../request-error.js";
2
+ /**
3
+ * Why an invocation did not produce a result. `code` is the server's
4
+ * (`not_signed_in`, `invalid_input`, `unknown_contract`, `result_mismatch`, …),
5
+ * or `failed` when the agents could not produce a result matching the schema.
6
+ * `status` is 0 when no HTTP refusal is behind it: `failed`, or no answer at all.
7
+ */
8
+ export class InvocationError extends RequestError {
9
+ }
10
+ /** A run the agents closed without a result that matches its schema. */
11
+ export function failedRun(lastError) {
12
+ return new InvocationError(0, "failed", `The agents could not produce a valid result.${lastError === null ? "" : `\n${lastError}`}`);
13
+ }