@trylight/app 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -0
- package/bin/light-db.mjs +6 -0
- package/dist/client.d.ts +40 -0
- package/dist/client.js +135 -0
- package/dist/db/cli.d.ts +1 -0
- package/dist/db/cli.js +112 -0
- package/dist/db/migrations.d.ts +44 -0
- package/dist/db/migrations.js +158 -0
- package/dist/entities.d.ts +32 -0
- package/dist/entities.js +12 -0
- package/dist/platform.d.ts +2 -0
- package/dist/platform.js +5 -0
- package/dist/schema.d.ts +242 -0
- package/dist/schema.js +81 -0
- package/dist/server/entities.d.ts +12 -0
- package/dist/server/entities.js +270 -0
- package/dist/server/env.d.ts +11 -0
- package/dist/server/env.js +24 -0
- package/dist/server/index.d.ts +27 -0
- package/dist/server/index.js +58 -0
- package/dist/server/session.d.ts +18 -0
- package/dist/server/session.js +36 -0
- package/dist/vite.d.ts +2 -0
- package/dist/vite.js +62 -0
- package/package.json +81 -0
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { PgTable, type PgColumnBuilderBase, type PgTableWithColumns, type PgTableExtraConfigValue } from "drizzle-orm/pg-core";
|
|
2
|
+
import type { BuildColumns, BuildExtraConfigColumns } from "drizzle-orm";
|
|
3
|
+
import { z } from "zod/v4";
|
|
4
|
+
/** Where one value was read: the page, and the words on it that say so. */
|
|
5
|
+
export declare const fieldSource: z.ZodObject<{
|
|
6
|
+
url: z.ZodURL;
|
|
7
|
+
evidence: z.ZodString;
|
|
8
|
+
}, z.core.$strip>;
|
|
9
|
+
export type FieldSource = z.infer<typeof fieldSource>;
|
|
10
|
+
/** The site a source's url names, as people read it: "https://www.acme.com/about" is "acme.com". */
|
|
11
|
+
export declare function siteOf(source: FieldSource): string;
|
|
12
|
+
/** A row's `field_sources`: where each of its values was read, by column. */
|
|
13
|
+
export type FieldSources = Partial<Record<string, FieldSource>>;
|
|
14
|
+
/**
|
|
15
|
+
* Who may use a table through `app.table(...)`. Reads always go through the
|
|
16
|
+
* app's door: members only, or anyone with the link on a public app. Writes
|
|
17
|
+
* are either people signed in to the app ("door") or only the org's agents,
|
|
18
|
+
* through `mii db write` ("agents").
|
|
19
|
+
*/
|
|
20
|
+
export type Access = {
|
|
21
|
+
readonly read: "door";
|
|
22
|
+
readonly write: "door" | "agents";
|
|
23
|
+
};
|
|
24
|
+
/** Every entity has these; the platform fills them in. */
|
|
25
|
+
declare function standardColumns(): {
|
|
26
|
+
id: import("drizzle-orm").HasDefault<import("drizzle-orm").IsPrimaryKey<import("drizzle-orm").NotNull<import("drizzle-orm/pg-core").PgUUIDBuilderInitial<"">>>>;
|
|
27
|
+
created_at: import("drizzle-orm").HasDefault<import("drizzle-orm").NotNull<import("drizzle-orm/pg-core").PgTimestampStringBuilderInitial<"">>>;
|
|
28
|
+
updated_at: import("drizzle-orm").HasDefault<import("drizzle-orm").HasDefault<import("drizzle-orm").NotNull<import("drizzle-orm/pg-core").PgTimestampStringBuilderInitial<"">>>>;
|
|
29
|
+
/** The signed-in person who created it from the app; null for agent writes. */
|
|
30
|
+
created_by: import("drizzle-orm/pg-core").PgTextBuilderInitial<"", [string, ...string[]]>;
|
|
31
|
+
field_sources: import("drizzle-orm").HasDefault<import("drizzle-orm").NotNull<import("drizzle-orm").$Type<import("drizzle-orm/pg-core").PgJsonbBuilderInitial<"">, Partial<Record<string, {
|
|
32
|
+
url: string;
|
|
33
|
+
evidence: string;
|
|
34
|
+
}>>>>>;
|
|
35
|
+
};
|
|
36
|
+
type StandardColumns = ReturnType<typeof standardColumns>;
|
|
37
|
+
/** The columns the platform sets, as a zod omit mask: never in a create or update. */
|
|
38
|
+
export declare const PLATFORM_COLUMNS: {
|
|
39
|
+
id: true;
|
|
40
|
+
created_at: true;
|
|
41
|
+
updated_at: true;
|
|
42
|
+
created_by: true;
|
|
43
|
+
};
|
|
44
|
+
export type PlatformColumn = keyof typeof PLATFORM_COLUMNS;
|
|
45
|
+
/**
|
|
46
|
+
* A table of the app, as a Drizzle table with `id`, `created_at`,
|
|
47
|
+
* `updated_at`, `created_by` and `field_sources` added.
|
|
48
|
+
*
|
|
49
|
+
* ```ts
|
|
50
|
+
* export const companies = entity("companies", {
|
|
51
|
+
* name: text().notNull(),
|
|
52
|
+
* employees: integer()
|
|
53
|
+
* }, { read: "door", write: "door" });
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export declare function entity<TName extends string, TColumns extends Record<string, PgColumnBuilderBase>>(name: TName, columns: TColumns, access: Access, extra?: (self: BuildExtraConfigColumns<TName, StandardColumns & TColumns, "pg">) => PgTableExtraConfigValue[]): PgTableWithColumns<{
|
|
57
|
+
name: TName;
|
|
58
|
+
schema: undefined;
|
|
59
|
+
columns: BuildColumns<TName, StandardColumns & TColumns, "pg">;
|
|
60
|
+
dialect: "pg";
|
|
61
|
+
}> & {
|
|
62
|
+
readonly $access: Access;
|
|
63
|
+
};
|
|
64
|
+
/** Any entity table: what the route and the client handle generically. */
|
|
65
|
+
export type Entity = PgTable & {
|
|
66
|
+
readonly $access: Access;
|
|
67
|
+
readonly $inferSelect: {
|
|
68
|
+
field_sources: FieldSources;
|
|
69
|
+
};
|
|
70
|
+
readonly $inferInsert: {
|
|
71
|
+
field_sources?: FieldSources;
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
export declare function isEntity(value: unknown): value is Entity;
|
|
75
|
+
/**
|
|
76
|
+
* Every change a person makes through `app.table(...)`, with who made it, in
|
|
77
|
+
* the same transaction as the change. `changes` is the new row for a create,
|
|
78
|
+
* the old row for a delete, and `{ column: { from, to } }` for an update.
|
|
79
|
+
*/
|
|
80
|
+
export declare const lightChanges: PgTableWithColumns<{
|
|
81
|
+
name: "light_changes";
|
|
82
|
+
schema: undefined;
|
|
83
|
+
columns: {
|
|
84
|
+
id: import("drizzle-orm/pg-core").PgColumn<{
|
|
85
|
+
name: "id";
|
|
86
|
+
tableName: "light_changes";
|
|
87
|
+
dataType: "string";
|
|
88
|
+
columnType: "PgUUID";
|
|
89
|
+
data: string;
|
|
90
|
+
driverParam: string;
|
|
91
|
+
notNull: true;
|
|
92
|
+
hasDefault: true;
|
|
93
|
+
isPrimaryKey: true;
|
|
94
|
+
isAutoincrement: false;
|
|
95
|
+
hasRuntimeDefault: false;
|
|
96
|
+
enumValues: undefined;
|
|
97
|
+
baseColumn: never;
|
|
98
|
+
identity: undefined;
|
|
99
|
+
generated: undefined;
|
|
100
|
+
}, {}, {}>;
|
|
101
|
+
table_name: import("drizzle-orm/pg-core").PgColumn<{
|
|
102
|
+
name: "table_name";
|
|
103
|
+
tableName: "light_changes";
|
|
104
|
+
dataType: "string";
|
|
105
|
+
columnType: "PgText";
|
|
106
|
+
data: string;
|
|
107
|
+
driverParam: string;
|
|
108
|
+
notNull: true;
|
|
109
|
+
hasDefault: false;
|
|
110
|
+
isPrimaryKey: false;
|
|
111
|
+
isAutoincrement: false;
|
|
112
|
+
hasRuntimeDefault: false;
|
|
113
|
+
enumValues: [string, ...string[]];
|
|
114
|
+
baseColumn: never;
|
|
115
|
+
identity: undefined;
|
|
116
|
+
generated: undefined;
|
|
117
|
+
}, {}, {}>;
|
|
118
|
+
row_id: import("drizzle-orm/pg-core").PgColumn<{
|
|
119
|
+
name: "row_id";
|
|
120
|
+
tableName: "light_changes";
|
|
121
|
+
dataType: "string";
|
|
122
|
+
columnType: "PgUUID";
|
|
123
|
+
data: string;
|
|
124
|
+
driverParam: string;
|
|
125
|
+
notNull: true;
|
|
126
|
+
hasDefault: false;
|
|
127
|
+
isPrimaryKey: false;
|
|
128
|
+
isAutoincrement: false;
|
|
129
|
+
hasRuntimeDefault: false;
|
|
130
|
+
enumValues: undefined;
|
|
131
|
+
baseColumn: never;
|
|
132
|
+
identity: undefined;
|
|
133
|
+
generated: undefined;
|
|
134
|
+
}, {}, {}>;
|
|
135
|
+
action: import("drizzle-orm/pg-core").PgColumn<{
|
|
136
|
+
name: "action";
|
|
137
|
+
tableName: "light_changes";
|
|
138
|
+
dataType: "string";
|
|
139
|
+
columnType: "PgText";
|
|
140
|
+
data: "create" | "update" | "delete";
|
|
141
|
+
driverParam: string;
|
|
142
|
+
notNull: true;
|
|
143
|
+
hasDefault: false;
|
|
144
|
+
isPrimaryKey: false;
|
|
145
|
+
isAutoincrement: false;
|
|
146
|
+
hasRuntimeDefault: false;
|
|
147
|
+
enumValues: ["create", "update", "delete"];
|
|
148
|
+
baseColumn: never;
|
|
149
|
+
identity: undefined;
|
|
150
|
+
generated: undefined;
|
|
151
|
+
}, {}, {}>;
|
|
152
|
+
changes: import("drizzle-orm/pg-core").PgColumn<{
|
|
153
|
+
name: "changes";
|
|
154
|
+
tableName: "light_changes";
|
|
155
|
+
dataType: "json";
|
|
156
|
+
columnType: "PgJsonb";
|
|
157
|
+
data: Record<string, unknown>;
|
|
158
|
+
driverParam: unknown;
|
|
159
|
+
notNull: true;
|
|
160
|
+
hasDefault: false;
|
|
161
|
+
isPrimaryKey: false;
|
|
162
|
+
isAutoincrement: false;
|
|
163
|
+
hasRuntimeDefault: false;
|
|
164
|
+
enumValues: undefined;
|
|
165
|
+
baseColumn: never;
|
|
166
|
+
identity: undefined;
|
|
167
|
+
generated: undefined;
|
|
168
|
+
}, {}, {
|
|
169
|
+
$type: Record<string, unknown>;
|
|
170
|
+
}>;
|
|
171
|
+
actor_kind: import("drizzle-orm/pg-core").PgColumn<{
|
|
172
|
+
name: "actor_kind";
|
|
173
|
+
tableName: "light_changes";
|
|
174
|
+
dataType: "string";
|
|
175
|
+
columnType: "PgText";
|
|
176
|
+
data: "person" | "agent";
|
|
177
|
+
driverParam: string;
|
|
178
|
+
notNull: true;
|
|
179
|
+
hasDefault: false;
|
|
180
|
+
isPrimaryKey: false;
|
|
181
|
+
isAutoincrement: false;
|
|
182
|
+
hasRuntimeDefault: false;
|
|
183
|
+
enumValues: ["person", "agent"];
|
|
184
|
+
baseColumn: never;
|
|
185
|
+
identity: undefined;
|
|
186
|
+
generated: undefined;
|
|
187
|
+
}, {}, {}>;
|
|
188
|
+
actor_id: import("drizzle-orm/pg-core").PgColumn<{
|
|
189
|
+
name: "actor_id";
|
|
190
|
+
tableName: "light_changes";
|
|
191
|
+
dataType: "string";
|
|
192
|
+
columnType: "PgText";
|
|
193
|
+
data: string;
|
|
194
|
+
driverParam: string;
|
|
195
|
+
notNull: false;
|
|
196
|
+
hasDefault: false;
|
|
197
|
+
isPrimaryKey: false;
|
|
198
|
+
isAutoincrement: false;
|
|
199
|
+
hasRuntimeDefault: false;
|
|
200
|
+
enumValues: [string, ...string[]];
|
|
201
|
+
baseColumn: never;
|
|
202
|
+
identity: undefined;
|
|
203
|
+
generated: undefined;
|
|
204
|
+
}, {}, {}>;
|
|
205
|
+
actor_name: import("drizzle-orm/pg-core").PgColumn<{
|
|
206
|
+
name: "actor_name";
|
|
207
|
+
tableName: "light_changes";
|
|
208
|
+
dataType: "string";
|
|
209
|
+
columnType: "PgText";
|
|
210
|
+
data: string;
|
|
211
|
+
driverParam: string;
|
|
212
|
+
notNull: true;
|
|
213
|
+
hasDefault: false;
|
|
214
|
+
isPrimaryKey: false;
|
|
215
|
+
isAutoincrement: false;
|
|
216
|
+
hasRuntimeDefault: false;
|
|
217
|
+
enumValues: [string, ...string[]];
|
|
218
|
+
baseColumn: never;
|
|
219
|
+
identity: undefined;
|
|
220
|
+
generated: undefined;
|
|
221
|
+
}, {}, {}>;
|
|
222
|
+
created_at: import("drizzle-orm/pg-core").PgColumn<{
|
|
223
|
+
name: "created_at";
|
|
224
|
+
tableName: "light_changes";
|
|
225
|
+
dataType: "string";
|
|
226
|
+
columnType: "PgTimestampString";
|
|
227
|
+
data: string;
|
|
228
|
+
driverParam: string;
|
|
229
|
+
notNull: true;
|
|
230
|
+
hasDefault: true;
|
|
231
|
+
isPrimaryKey: false;
|
|
232
|
+
isAutoincrement: false;
|
|
233
|
+
hasRuntimeDefault: false;
|
|
234
|
+
enumValues: undefined;
|
|
235
|
+
baseColumn: never;
|
|
236
|
+
identity: undefined;
|
|
237
|
+
generated: undefined;
|
|
238
|
+
}, {}, {}>;
|
|
239
|
+
};
|
|
240
|
+
dialect: "pg";
|
|
241
|
+
}>;
|
|
242
|
+
export {};
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// What an app's tables are made of: `db/schema/*.ts` declares each with
|
|
2
|
+
// `entity()`, a Drizzle table that also says who may read and write it from
|
|
3
|
+
// the app. Imported by drizzle-kit and the browser's types, so nothing here
|
|
4
|
+
// may touch a database or the DOM.
|
|
5
|
+
import { is } from "drizzle-orm";
|
|
6
|
+
import { PgTable, index, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
|
7
|
+
import { z } from "zod/v4";
|
|
8
|
+
/** Where one value was read: the page, and the words on it that say so. */
|
|
9
|
+
export const fieldSource = z.object({
|
|
10
|
+
url: z.url({ protocol: /^https?$/ }),
|
|
11
|
+
evidence: z.string().min(1)
|
|
12
|
+
});
|
|
13
|
+
/** The site a source's url names, as people read it: "https://www.acme.com/about" is "acme.com". */
|
|
14
|
+
export function siteOf(source) {
|
|
15
|
+
return source.url.replace(/^https?:\/\/(www\.)?/, "").replace(/[/?#].*$/s, "");
|
|
16
|
+
}
|
|
17
|
+
/** Every entity has these; the platform fills them in. */
|
|
18
|
+
function standardColumns() {
|
|
19
|
+
return {
|
|
20
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
21
|
+
created_at: timestamp({ withTimezone: true, mode: "string" })
|
|
22
|
+
.notNull()
|
|
23
|
+
.defaultNow(),
|
|
24
|
+
updated_at: timestamp({ withTimezone: true, mode: "string" })
|
|
25
|
+
.notNull()
|
|
26
|
+
.defaultNow()
|
|
27
|
+
.$onUpdate(() => new Date().toISOString()),
|
|
28
|
+
/** The signed-in person who created it from the app; null for agent writes. */
|
|
29
|
+
created_by: text(),
|
|
30
|
+
field_sources: jsonb().$type().notNull().default({})
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** The columns the platform sets, as a zod omit mask: never in a create or update. */
|
|
34
|
+
export const PLATFORM_COLUMNS = {
|
|
35
|
+
id: true,
|
|
36
|
+
created_at: true,
|
|
37
|
+
updated_at: true,
|
|
38
|
+
created_by: true
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* A table of the app, as a Drizzle table with `id`, `created_at`,
|
|
42
|
+
* `updated_at`, `created_by` and `field_sources` added.
|
|
43
|
+
*
|
|
44
|
+
* ```ts
|
|
45
|
+
* export const companies = entity("companies", {
|
|
46
|
+
* name: text().notNull(),
|
|
47
|
+
* employees: integer()
|
|
48
|
+
* }, { read: "door", write: "door" });
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export function entity(name, columns, access, extra) {
|
|
52
|
+
const standard = standardColumns();
|
|
53
|
+
const taken = Object.keys(columns).filter((key) => key in standard);
|
|
54
|
+
if (taken.length > 0) {
|
|
55
|
+
throw new Error(`${name}: ${taken.join(", ")} ${taken.length === 1 ? "is" : "are"} added by entity(); leave ${taken.length === 1 ? "it" : "them"} out.`);
|
|
56
|
+
}
|
|
57
|
+
const all = { ...standard, ...columns };
|
|
58
|
+
const table = pgTable(name, all, extra);
|
|
59
|
+
return Object.assign(table, { $access: access });
|
|
60
|
+
}
|
|
61
|
+
export function isEntity(value) {
|
|
62
|
+
return is(value, PgTable) && "$access" in value;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Every change a person makes through `app.table(...)`, with who made it, in
|
|
66
|
+
* the same transaction as the change. `changes` is the new row for a create,
|
|
67
|
+
* the old row for a delete, and `{ column: { from, to } }` for an update.
|
|
68
|
+
*/
|
|
69
|
+
export const lightChanges = pgTable("light_changes", {
|
|
70
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
71
|
+
table_name: text().notNull(),
|
|
72
|
+
row_id: uuid().notNull(),
|
|
73
|
+
action: text({ enum: ["create", "update", "delete"] }).notNull(),
|
|
74
|
+
changes: jsonb().$type().notNull(),
|
|
75
|
+
actor_kind: text({ enum: ["person", "agent"] }).notNull(),
|
|
76
|
+
actor_id: text(),
|
|
77
|
+
actor_name: text().notNull(),
|
|
78
|
+
created_at: timestamp({ withTimezone: true, mode: "string" })
|
|
79
|
+
.notNull()
|
|
80
|
+
.defaultNow()
|
|
81
|
+
}, (t) => [index("light_changes_row_idx").on(t.table_name, t.row_id)]);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
2
|
+
import type { SessionState } from "./session.js";
|
|
3
|
+
export type Actor = {
|
|
4
|
+
id: string | null;
|
|
5
|
+
name: string;
|
|
6
|
+
};
|
|
7
|
+
export type EntitiesOptions = {
|
|
8
|
+
schema: Record<string, unknown>;
|
|
9
|
+
db: () => PostgresJsDatabase<Record<string, unknown>>;
|
|
10
|
+
session: (request: Request) => Promise<SessionState>;
|
|
11
|
+
};
|
|
12
|
+
export declare function createEntitiesHandler(options: EntitiesOptions): (request: Request) => Promise<Response>;
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// The one route behind `app.table(...)`: list, get, create, update and delete
|
|
2
|
+
// on any entity table, each request checked against the table's own Zod
|
|
3
|
+
// schema and the app's door, and every change recorded in light_changes with
|
|
4
|
+
// who made it, in the same transaction.
|
|
5
|
+
import { and, asc, desc, eq, getTableColumns, getTableName, inArray, isNull } from "drizzle-orm";
|
|
6
|
+
import { isDeepStrictEqual } from "node:util";
|
|
7
|
+
import { createSchemaFactory } from "drizzle-zod";
|
|
8
|
+
import { isSameOrigin } from "@shubh90/app-runtime/chat/server";
|
|
9
|
+
import { z } from "zod/v4";
|
|
10
|
+
import { ENTITIES_PATH, LIST_LIMIT } from "../entities.js";
|
|
11
|
+
import { PLATFORM_COLUMNS, fieldSource, isEntity, lightChanges } from "../schema.js";
|
|
12
|
+
import { env } from "./env.js";
|
|
13
|
+
/** Who writes when the app runs with no sign-in at all: a bare local dev server. */
|
|
14
|
+
const LOCAL_DEVELOPER = { id: null, name: "Local developer" };
|
|
15
|
+
// Postgres's integrity violations: the person's request, not a failure, told with Postgres's reason.
|
|
16
|
+
const UNIQUE_VIOLATION = "23505";
|
|
17
|
+
const FOREIGN_KEY_VIOLATION = "23503";
|
|
18
|
+
const CHECK_VIOLATION = "23514";
|
|
19
|
+
const NOT_NULL_VIOLATION = "23502";
|
|
20
|
+
/** A duplicate or a missing reference: the row conflicts with others. */
|
|
21
|
+
const CONFLICT_CODES = new Set([UNIQUE_VIOLATION, FOREIGN_KEY_VIOLATION]);
|
|
22
|
+
/** A value its column refuses: the request is wrong on its own. */
|
|
23
|
+
const INVALID_CODES = new Set([CHECK_VIOLATION, NOT_NULL_VIOLATION]);
|
|
24
|
+
// JSON carries a date as its ISO string; a date-mode timestamp column takes it.
|
|
25
|
+
const { createInsertSchema, createSelectSchema, createUpdateSchema } = createSchemaFactory({
|
|
26
|
+
coerce: { date: true }
|
|
27
|
+
});
|
|
28
|
+
/** Where a request points: a whole table, or one row of it. */
|
|
29
|
+
const target = z.union([
|
|
30
|
+
z.tuple([z.string()]).transform(([table]) => ({ table, id: null })),
|
|
31
|
+
z.tuple([z.string(), z.uuid()]).transform(([table, id]) => ({ table, id }))
|
|
32
|
+
]);
|
|
33
|
+
const postgresError = z.object({ cause: z.object({ code: z.string(), message: z.string() }) });
|
|
34
|
+
export function createEntitiesHandler(options) {
|
|
35
|
+
const tables = new Map(Object.values(options.schema)
|
|
36
|
+
.filter(isEntity)
|
|
37
|
+
.map((entity) => [getTableName(entity), tableRules(entity)]));
|
|
38
|
+
return async (request) => {
|
|
39
|
+
const url = new URL(request.url);
|
|
40
|
+
// Every org app shares one site (*.miis.run): only this app's pages may use it.
|
|
41
|
+
if (!isSameOrigin(request, url)) {
|
|
42
|
+
return refuse(403, "cross_site", "Only this app's own pages can use its data.");
|
|
43
|
+
}
|
|
44
|
+
// No Plaza to sign in with: only a bare local dev server serves (as the
|
|
45
|
+
// local developer). A deployment or a sandbox missing its sign-in config serves nothing.
|
|
46
|
+
const { configured, local } = env();
|
|
47
|
+
if (!configured && !local) {
|
|
48
|
+
return refuse(503, "auth_not_configured", "Sign-in isn't configured for this app.");
|
|
49
|
+
}
|
|
50
|
+
const path = target.safeParse(url.pathname.slice(ENTITIES_PATH.length + 1).split("/"));
|
|
51
|
+
const table = path.success ? tables.get(path.data.table) : undefined;
|
|
52
|
+
if (!path.success || table === undefined) {
|
|
53
|
+
return refuse(404, "not_found", `Nothing is at ${url.pathname}.`);
|
|
54
|
+
}
|
|
55
|
+
const session = await options.session(request);
|
|
56
|
+
const db = options.db();
|
|
57
|
+
const id = path.data.id;
|
|
58
|
+
const method = request.method;
|
|
59
|
+
if (method === "GET") {
|
|
60
|
+
// Members-only: someone signed in; anyone with the link (and a local dev server): anyone.
|
|
61
|
+
if (session.user === null && session.mode === "members") {
|
|
62
|
+
return refuse(401, "not_signed_in", "Sign in to use this app.");
|
|
63
|
+
}
|
|
64
|
+
return id === null ? list(db, table, url) : answer(() => get(db, table, id));
|
|
65
|
+
}
|
|
66
|
+
if (table.entity.$access.write === "agents") {
|
|
67
|
+
return refuse(403, "agents_only", `${table.name} is written by the org's agents, not from the app.`);
|
|
68
|
+
}
|
|
69
|
+
const actor = local ? LOCAL_DEVELOPER : signedIn(session);
|
|
70
|
+
if (actor === null)
|
|
71
|
+
return refuse(401, "not_signed_in", "Sign in to change this app's data.");
|
|
72
|
+
if (id === null && method === "POST") {
|
|
73
|
+
const data = await readBody(request, table.create);
|
|
74
|
+
if (!data.success)
|
|
75
|
+
return refuse(400, "invalid", z.prettifyError(data.error));
|
|
76
|
+
return answer(() => create(db, table, actor, data.data), 201);
|
|
77
|
+
}
|
|
78
|
+
if (id !== null && method === "PATCH") {
|
|
79
|
+
const patch = await readBody(request, table.update);
|
|
80
|
+
if (!patch.success)
|
|
81
|
+
return refuse(400, "invalid", z.prettifyError(patch.error));
|
|
82
|
+
return answer(() => update(db, table, actor, id, patch.data));
|
|
83
|
+
}
|
|
84
|
+
if (id !== null && method === "DELETE")
|
|
85
|
+
return answer(() => remove(db, table, actor, id));
|
|
86
|
+
return refuse(405, "method", `${method} isn't something ${url.pathname} does.`);
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** A table's checks, built once from its Drizzle columns. */
|
|
90
|
+
function tableRules(entity) {
|
|
91
|
+
const columns = getTableColumns(entity);
|
|
92
|
+
const names = Object.keys(columns);
|
|
93
|
+
const column = (name) => {
|
|
94
|
+
const found = columns[name];
|
|
95
|
+
if (found === undefined)
|
|
96
|
+
throw new Error(`No column ${name}.`);
|
|
97
|
+
return found;
|
|
98
|
+
};
|
|
99
|
+
// Only the table's own columns have sources, and each is a {url, evidence}.
|
|
100
|
+
const sources = z.partialRecord(z.literal(names), fieldSource).optional();
|
|
101
|
+
/** What a person may send: the table's columns less the platform's, strictly. */
|
|
102
|
+
const writable = (shape) => z
|
|
103
|
+
.object({
|
|
104
|
+
...Object.fromEntries(Object.entries(shape).filter(([key]) => !(key in PLATFORM_COLUMNS))),
|
|
105
|
+
field_sources: sources
|
|
106
|
+
})
|
|
107
|
+
.strict();
|
|
108
|
+
const match = Object.fromEntries(Object.entries(createSelectSchema(entity).shape).map(([key, value]) => [key, z.union([value, z.array(value)])]));
|
|
109
|
+
return {
|
|
110
|
+
name: getTableName(entity),
|
|
111
|
+
entity,
|
|
112
|
+
column,
|
|
113
|
+
id: column("id"),
|
|
114
|
+
create: writable(createInsertSchema(entity).shape),
|
|
115
|
+
update: writable(createUpdateSchema(entity).shape),
|
|
116
|
+
list: z
|
|
117
|
+
.object({
|
|
118
|
+
where: z.object(match).partial().strict().default({}),
|
|
119
|
+
sort: z.literal([...names, ...names.map((name) => `-${name}`)]).default("-created_at"),
|
|
120
|
+
limit: z.int().min(1).max(LIST_LIMIT).default(LIST_LIMIT),
|
|
121
|
+
offset: z.int().min(0).default(0)
|
|
122
|
+
})
|
|
123
|
+
.strict()
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
async function list(db, table, url) {
|
|
127
|
+
const q = url.searchParams.get("q");
|
|
128
|
+
const query = table.list.safeParse(q === null ? {} : readJson(q));
|
|
129
|
+
if (!query.success)
|
|
130
|
+
return refuse(400, "invalid", z.prettifyError(query.error));
|
|
131
|
+
const { where, sort, limit, offset } = query.data;
|
|
132
|
+
const order = sort.startsWith("-") ? desc(table.column(sort.slice(1))) : asc(table.column(sort));
|
|
133
|
+
const conditions = Object.entries(where).map(([name, value]) => {
|
|
134
|
+
const column = table.column(name);
|
|
135
|
+
if (value === null)
|
|
136
|
+
return isNull(column);
|
|
137
|
+
if (Array.isArray(value))
|
|
138
|
+
return inArray(column, value);
|
|
139
|
+
return eq(column, value);
|
|
140
|
+
});
|
|
141
|
+
const rows = await db
|
|
142
|
+
.select()
|
|
143
|
+
.from(table.entity)
|
|
144
|
+
.where(and(...conditions))
|
|
145
|
+
// The id breaks ties, so paging with offset never repeats or skips a row.
|
|
146
|
+
.orderBy(order, asc(table.id))
|
|
147
|
+
.limit(limit)
|
|
148
|
+
.offset(offset);
|
|
149
|
+
return Response.json(rows);
|
|
150
|
+
}
|
|
151
|
+
async function get(db, table, id) {
|
|
152
|
+
return atMostOne(await db.select().from(table.entity).where(eq(table.id, id)));
|
|
153
|
+
}
|
|
154
|
+
async function create(db, table, actor, data) {
|
|
155
|
+
return db.transaction(async (tx) => {
|
|
156
|
+
const row = exactlyOne(await tx
|
|
157
|
+
.insert(table.entity)
|
|
158
|
+
.values({ ...data, created_by: actor.id })
|
|
159
|
+
.returning());
|
|
160
|
+
await audit(tx, table, rowId(row), "create", row, actor);
|
|
161
|
+
return row;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
async function update(db, table, actor, id, patch) {
|
|
165
|
+
return db.transaction(async (tx) => {
|
|
166
|
+
const before = atMostOne(await tx.select().from(table.entity).where(eq(table.id, id)).for("update"));
|
|
167
|
+
if (before === null)
|
|
168
|
+
return null;
|
|
169
|
+
const changed = Object.keys(patch).filter((key) => !isDeepStrictEqual(patch[key], before[key]));
|
|
170
|
+
// A value a person sets is no longer what its source said; sources sent are merged in.
|
|
171
|
+
const field_sources = { ...withoutSources(before.field_sources, changed), ...patch.field_sources };
|
|
172
|
+
const row = exactlyOne(await tx
|
|
173
|
+
.update(table.entity)
|
|
174
|
+
.set({ ...patch, field_sources })
|
|
175
|
+
.where(eq(table.id, id))
|
|
176
|
+
.returning());
|
|
177
|
+
const changes = Object.fromEntries([...changed, "field_sources"]
|
|
178
|
+
.filter((key) => !isDeepStrictEqual(before[key], row[key]))
|
|
179
|
+
.map((key) => [key, { from: before[key], to: row[key] }]));
|
|
180
|
+
if (Object.keys(changes).length > 0)
|
|
181
|
+
await audit(tx, table, id, "update", changes, actor);
|
|
182
|
+
return row;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
async function remove(db, table, actor, id) {
|
|
186
|
+
return db.transaction(async (tx) => {
|
|
187
|
+
const row = atMostOne(await tx.delete(table.entity).where(eq(table.id, id)).returning());
|
|
188
|
+
if (row === null)
|
|
189
|
+
return null;
|
|
190
|
+
await audit(tx, table, id, "delete", row, actor);
|
|
191
|
+
return { id };
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
function audit(tx, table, rowId, action, changes, actor) {
|
|
195
|
+
return tx.insert(lightChanges).values({
|
|
196
|
+
table_name: table.name,
|
|
197
|
+
row_id: rowId,
|
|
198
|
+
action,
|
|
199
|
+
changes,
|
|
200
|
+
actor_kind: "person",
|
|
201
|
+
actor_id: actor.id,
|
|
202
|
+
actor_name: actor.name
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
/** The row a statement by primary key touched, or null when there is none. */
|
|
206
|
+
function atMostOne(rows) {
|
|
207
|
+
const [row = null, ...more] = rows;
|
|
208
|
+
if (more.length > 0)
|
|
209
|
+
throw new Error(`Expected at most one row, got ${rows.length}.`);
|
|
210
|
+
return row;
|
|
211
|
+
}
|
|
212
|
+
/** The row an insert or an update that found its row returned. */
|
|
213
|
+
function exactlyOne(rows) {
|
|
214
|
+
const row = atMostOne(rows);
|
|
215
|
+
if (row === null)
|
|
216
|
+
throw new Error("Expected a row back, got none.");
|
|
217
|
+
return row;
|
|
218
|
+
}
|
|
219
|
+
// What's stored may come from an agent's own write: read as it is, never refused here.
|
|
220
|
+
const storedSources = z.record(z.string(), z.unknown());
|
|
221
|
+
const storedId = z.object({ id: z.uuid() });
|
|
222
|
+
function rowId(row) {
|
|
223
|
+
return storedId.parse(row).id;
|
|
224
|
+
}
|
|
225
|
+
function withoutSources(stored, columns) {
|
|
226
|
+
const sources = storedSources.parse(stored);
|
|
227
|
+
return Object.fromEntries(Object.entries(sources).filter(([column]) => !columns.includes(column)));
|
|
228
|
+
}
|
|
229
|
+
function signedIn(session) {
|
|
230
|
+
return session.user === null ? null : { id: session.user.id, name: session.user.name };
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Runs a read or write and answers it: the row, 404 for none, 409 for a
|
|
234
|
+
* duplicate or a missing reference, 400 for a value a check or NOT NULL refuses.
|
|
235
|
+
*/
|
|
236
|
+
async function answer(run, status = 200) {
|
|
237
|
+
try {
|
|
238
|
+
const result = await run();
|
|
239
|
+
if (result === null)
|
|
240
|
+
return refuse(404, "not_found", "That row doesn't exist.");
|
|
241
|
+
return Response.json(result, { status });
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
// Drizzle wraps the driver's error; Postgres's code and words are its cause.
|
|
245
|
+
const refused = postgresError.safeParse(error);
|
|
246
|
+
if (refused.success && CONFLICT_CODES.has(refused.data.cause.code)) {
|
|
247
|
+
return refuse(409, "conflict", refused.data.cause.message);
|
|
248
|
+
}
|
|
249
|
+
if (refused.success && INVALID_CODES.has(refused.data.cause.code)) {
|
|
250
|
+
return refuse(400, "invalid", refused.data.cause.message);
|
|
251
|
+
}
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/** A request body read with its table's schema; text that isn't JSON is refused by the schema too. */
|
|
256
|
+
async function readBody(request, schema) {
|
|
257
|
+
return schema.safeParse(readJson(await request.text()));
|
|
258
|
+
}
|
|
259
|
+
/** JSON as sent, or the text itself for a schema to refuse. */
|
|
260
|
+
function readJson(text) {
|
|
261
|
+
try {
|
|
262
|
+
return JSON.parse(text);
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return text;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function refuse(status, code, error) {
|
|
269
|
+
return Response.json({ error, code }, { status });
|
|
270
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Read when asked, not at import: a build imports this without a runtime env. */
|
|
2
|
+
export declare function env(): {
|
|
3
|
+
databaseUrl: string | undefined;
|
|
4
|
+
/** Sign-in needs Plaza, this app's id in it, and a database for sessions. */
|
|
5
|
+
configured: boolean;
|
|
6
|
+
/**
|
|
7
|
+
* A bare local dev server: no Plaza and no app id at all (the platform
|
|
8
|
+
* gives every sandbox and deployment its MII_APP_ID), and not production.
|
|
9
|
+
*/
|
|
10
|
+
local: boolean;
|
|
11
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// The server env the SDK reads, in one schema: what the platform injects
|
|
2
|
+
// (Vercel env, the sandbox's .env.local).
|
|
3
|
+
import { z } from "zod/v4";
|
|
4
|
+
const serverEnv = z
|
|
5
|
+
.object({
|
|
6
|
+
DATABASE_URL: z.string().min(1).optional(),
|
|
7
|
+
MII_PLAZA_URL: z.url().optional(),
|
|
8
|
+
MII_APP_ID: z.string().min(1).optional(),
|
|
9
|
+
NODE_ENV: z.string().optional()
|
|
10
|
+
})
|
|
11
|
+
.transform((env) => ({
|
|
12
|
+
databaseUrl: env.DATABASE_URL,
|
|
13
|
+
/** Sign-in needs Plaza, this app's id in it, and a database for sessions. */
|
|
14
|
+
configured: Boolean(env.MII_PLAZA_URL && env.MII_APP_ID && env.DATABASE_URL),
|
|
15
|
+
/**
|
|
16
|
+
* A bare local dev server: no Plaza and no app id at all (the platform
|
|
17
|
+
* gives every sandbox and deployment its MII_APP_ID), and not production.
|
|
18
|
+
*/
|
|
19
|
+
local: !env.MII_PLAZA_URL && !env.MII_APP_ID && env.NODE_ENV !== "production"
|
|
20
|
+
}));
|
|
21
|
+
/** Read when asked, not at import: a build imports this without a runtime env. */
|
|
22
|
+
export function env() {
|
|
23
|
+
return serverEnv.parse(process.env);
|
|
24
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
2
|
+
import { type Contracts } from "@shubh90/app-runtime/invoke/server";
|
|
3
|
+
export type { SessionState, SessionUser, SignIn } from "./session.js";
|
|
4
|
+
export { cookieAttributes, safeNext, SESSION_COOKIE } from "@shubh90/app-runtime/auth/core";
|
|
5
|
+
export { defineContracts, InvocationError, type Contracts } from "@shubh90/app-runtime/invoke/server";
|
|
6
|
+
export { createHealthHandler, createSentryTunnelHandler } from "@shubh90/app-runtime/server";
|
|
7
|
+
export declare function createLightServer<S extends Record<string, unknown>, C extends Contracts>(options: {
|
|
8
|
+
schema: S;
|
|
9
|
+
contracts: C;
|
|
10
|
+
}): {
|
|
11
|
+
auth: import("@shubh90/app-runtime/auth/core").MiiAuthCore;
|
|
12
|
+
session: (request: Request) => Promise<import("./session.js").SessionState>;
|
|
13
|
+
/** Whether sign-in is configured (a bare local dev server has none). */
|
|
14
|
+
configured: () => boolean;
|
|
15
|
+
/** Email and password, checked by Plaza; the session to set as a cookie, or why not. */
|
|
16
|
+
signIn: (email: string, password: string) => Promise<import("./session.js").SignIn>;
|
|
17
|
+
/** The app's database, typed by its schema: `light.db.select().from(companies)`. */
|
|
18
|
+
readonly db: PostgresJsDatabase<S>;
|
|
19
|
+
/** `/api/light/entities/*`: what `app.table(...)` calls. */
|
|
20
|
+
entities: (request: Request) => Promise<Response>;
|
|
21
|
+
/** Server-side starts and reads of the app's contracts. */
|
|
22
|
+
invoke: import("@shubh90/app-runtime/invoke/server").MiiInvoke<C>;
|
|
23
|
+
/** `/api/mii/invoke/*`: the contract buttons, forwarded to Plaza as the person. */
|
|
24
|
+
invokeRoute: (request: Request) => Promise<Response>;
|
|
25
|
+
/** `/api/mii/chat/*`: the chat bubble's calls to the org's agents. */
|
|
26
|
+
chatRoute: (request: Request) => Promise<Response>;
|
|
27
|
+
};
|