prostgles-server 4.2.643 → 4.2.644

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 (96) hide show
  1. package/debug/fixes-review-build.log +4 -0
  2. package/debug/fixes-review-client-schema.backup +745 -0
  3. package/debug/fixes-review-generated-schema.backup +489 -0
  4. package/debug/fixes-review-probes-build.log +0 -0
  5. package/debug/fixes-review-probes.patch +43 -0
  6. package/debug/fixes-review-restore-build.log +0 -0
  7. package/debug/fixes-review-tests-build.log +0 -0
  8. package/debug/fixes-review-typecheck.log +8 -0
  9. package/debug/review-current-fixes-build.log +4 -0
  10. package/debug/review-current-fixes-client.backup +745 -0
  11. package/debug/review-current-fixes-probes-build.log +0 -0
  12. package/debug/review-current-fixes-probes.patch +55 -0
  13. package/debug/review-current-fixes-restore-build.log +0 -0
  14. package/debug/review-current-fixes-rls.backup +478 -0
  15. package/debug/review-current-fixes-schema.backup +489 -0
  16. package/debug/review-current-fixes-test-build.log +0 -0
  17. package/debug/review-current-fixes-typecheck.log +8 -0
  18. package/debug/review-user-identity-build.log +4 -0
  19. package/debug/review-user-identity-probes-build.log +0 -0
  20. package/debug/review-user-identity-probes.log +558 -0
  21. package/debug/review-user-identity-probes.patch +45 -0
  22. package/debug/review-user-identity-restore-build.log +0 -0
  23. package/debug/review-user-identity-rls.backup +463 -0
  24. package/debug/review-user-identity-schema.backup +720 -0
  25. package/debug/review-user-identity-server.log +3248 -0
  26. package/debug/review-user-identity-test-build.log +0 -0
  27. package/debug/review-user-identity-typecheck.log +8 -0
  28. package/debug/server-user-lint.log +4 -0
  29. package/debug/server-user-test-server.log +538 -0
  30. package/debug/server-user-typecheck.log +8 -0
  31. package/debug/user-identity-direct-call-build.log +0 -0
  32. package/debug/user-identity-direct-call-schema.backup +489 -0
  33. package/debug/user-identity-regressions-build.log +4 -0
  34. package/debug/user-identity-regressions-generated-schema.backup +489 -0
  35. package/debug/user-identity-regressions-test-build.log +0 -0
  36. package/dist/Auth/AuthHandler.js +1 -1
  37. package/dist/Auth/AuthHandler.js.map +1 -1
  38. package/dist/Auth/AuthTypes.d.ts +8 -6
  39. package/dist/Auth/AuthTypes.d.ts.map +1 -1
  40. package/dist/Auth/AuthTypes.js.map +1 -1
  41. package/dist/Auth/utils/getClientRequestIPsInfo.d.ts +2 -1
  42. package/dist/Auth/utils/getClientRequestIPsInfo.d.ts.map +1 -1
  43. package/dist/Auth/utils/getClientRequestIPsInfo.js +8 -1
  44. package/dist/Auth/utils/getClientRequestIPsInfo.js.map +1 -1
  45. package/dist/Auth/utils/getSessionUser.d.ts +4 -3
  46. package/dist/Auth/utils/getSessionUser.d.ts.map +1 -1
  47. package/dist/Auth/utils/getSessionUser.js +4 -2
  48. package/dist/Auth/utils/getSessionUser.js.map +1 -1
  49. package/dist/Auth/utils/getSessionUser.spec.js +2 -4
  50. package/dist/Auth/utils/getSessionUser.spec.js.map +1 -1
  51. package/dist/Auth/utils/handleGetUser.d.ts.map +1 -1
  52. package/dist/Auth/utils/handleGetUser.js +16 -0
  53. package/dist/Auth/utils/handleGetUser.js.map +1 -1
  54. package/dist/Auth/utils/serverSideRequest.d.ts +17 -0
  55. package/dist/Auth/utils/serverSideRequest.d.ts.map +1 -0
  56. package/dist/Auth/utils/serverSideRequest.js +20 -0
  57. package/dist/Auth/utils/serverSideRequest.js.map +1 -0
  58. package/dist/DboBuilder/ViewHandler/ViewHandler.d.ts +0 -4
  59. package/dist/DboBuilder/ViewHandler/ViewHandler.d.ts.map +1 -1
  60. package/dist/DboBuilder/ViewHandler/ViewHandler.js +5 -8
  61. package/dist/DboBuilder/ViewHandler/ViewHandler.js.map +1 -1
  62. package/dist/DboBuilder/ViewHandler/count.d.ts.map +1 -1
  63. package/dist/DboBuilder/ViewHandler/count.js +2 -1
  64. package/dist/DboBuilder/ViewHandler/count.js.map +1 -1
  65. package/dist/DboBuilder/ViewHandler/find.d.ts.map +1 -1
  66. package/dist/DboBuilder/ViewHandler/find.js +2 -1
  67. package/dist/DboBuilder/ViewHandler/find.js.map +1 -1
  68. package/dist/DboBuilder/ViewHandler/getDbHandlerWithAbort.d.ts +9 -2
  69. package/dist/DboBuilder/ViewHandler/getDbHandlerWithAbort.d.ts.map +1 -1
  70. package/dist/DboBuilder/ViewHandler/getDbHandlerWithAbort.js +57 -23
  71. package/dist/DboBuilder/ViewHandler/getDbHandlerWithAbort.js.map +1 -1
  72. package/dist/DboBuilder/ViewHandler/size.d.ts.map +1 -1
  73. package/dist/DboBuilder/ViewHandler/size.js +2 -1
  74. package/dist/DboBuilder/ViewHandler/size.js.map +1 -1
  75. package/dist/initProstgles.d.ts +12 -2
  76. package/dist/initProstgles.d.ts.map +1 -1
  77. package/dist/initProstgles.js +10 -1
  78. package/dist/initProstgles.js.map +1 -1
  79. package/dist/runClientRequest.d.ts.map +1 -1
  80. package/dist/runClientRequest.js +4 -3
  81. package/dist/runClientRequest.js.map +1 -1
  82. package/lib/Auth/AuthHandler.ts +1 -1
  83. package/lib/Auth/AuthTypes.ts +9 -6
  84. package/lib/Auth/utils/getClientRequestIPsInfo.ts +11 -2
  85. package/lib/Auth/utils/getSessionUser.spec.ts +4 -6
  86. package/lib/Auth/utils/getSessionUser.ts +8 -5
  87. package/lib/Auth/utils/handleGetUser.ts +13 -0
  88. package/lib/Auth/utils/serverSideRequest.ts +27 -0
  89. package/lib/DboBuilder/ViewHandler/ViewHandler.ts +11 -11
  90. package/lib/DboBuilder/ViewHandler/count.ts +2 -1
  91. package/lib/DboBuilder/ViewHandler/find.ts +2 -1
  92. package/lib/DboBuilder/ViewHandler/getDbHandlerWithAbort.ts +72 -29
  93. package/lib/DboBuilder/ViewHandler/size.ts +2 -1
  94. package/lib/initProstgles.ts +31 -5
  95. package/lib/runClientRequest.ts +8 -5
  96. package/package.json +1 -1
@@ -0,0 +1,720 @@
1
+ import express from "express";
2
+ import { strict as assert } from "node:assert";
3
+ import { randomUUID } from "node:crypto";
4
+ import { once } from "node:events";
5
+ import { readFileSync } from "node:fs";
6
+ import { createServer } from "node:http";
7
+ import path from "node:path";
8
+ import { test } from "node:test";
9
+ import prostgles, {
10
+ DB_GENERATED_NAMES,
11
+ defineFunction,
12
+ getLocalStorageClient,
13
+ type ProstglesInitOptions,
14
+ type PublishContextValue,
15
+ } from "prostgles-server";
16
+ import { getConnectionDetails } from "prostgles-server/dist/DboBuilder/runSql/getAdminClient";
17
+ import type { DB } from "prostgles-server/dist/Prostgles";
18
+ import { CHANNELS, type AnyObject, type ClientSchema } from "prostgles-types";
19
+ import { Server } from "socket.io";
20
+ import { io as createClient, type Socket } from "socket.io-client";
21
+ import ts from "typescript";
22
+
23
+ export const testClientSchemaTypes = async (db: DB) => {
24
+ await test(
25
+ "client publish profiles compile at startup and follow user types",
26
+ { timeout: 30000 },
27
+ async (t) => {
28
+ const app = express();
29
+ app.use(express.json());
30
+ app.use((req, _res, next) => {
31
+ req.cookies = Object.fromEntries(
32
+ (req.headers.cookie?.split(";") ?? []).map((cookie) => cookie.trim().split("=")),
33
+ );
34
+ next();
35
+ });
36
+ const http = createServer(app);
37
+ const io = new Server(http);
38
+ const sockets: Socket[] = [];
39
+ let instance:
40
+ | Pick<
41
+ Awaited<ReturnType<typeof prostgles>>,
42
+ | "update"
43
+ | "db"
44
+ | "destroy"
45
+ | "getTSSchema"
46
+ | "reWriteDBSchema"
47
+ | "options"
48
+ | "getClientDBHandlers"
49
+ >
50
+ | undefined;
51
+ const suffix = randomUUID().slice(0, 8);
52
+ const schemaName = `client_schema_types_${suffix}`;
53
+ const tableName = "correspondence";
54
+ const privateTableName = "private_table";
55
+ const fileTableName = "files";
56
+ const generatedTypesDir = path.resolve("debug/client-schema-types", suffix);
57
+ try {
58
+ await db.none(`
59
+ CREATE SCHEMA ${schemaName};
60
+ CREATE TABLE ${schemaName}.${tableName} (
61
+ id SERIAL PRIMARY KEY,
62
+ body TEXT NOT NULL,
63
+ created_by TEXT NOT NULL,
64
+ note TEXT,
65
+ internal TEXT NOT NULL DEFAULT 'default',
66
+ synced BIGINT NOT NULL
67
+ );
68
+ CREATE TABLE ${schemaName}.${privateTableName} (id SERIAL PRIMARY KEY);
69
+ `);
70
+ http.listen(0, "127.0.0.1");
71
+ await once(http, "listening");
72
+ const address = http.address();
73
+ assert(address && typeof address === "object");
74
+ const userIdContextValue = {
75
+ $prostglesContext: { objectName: "user", objectPropertyName: "id" },
76
+ } as const satisfies PublishContextValue<{ user: { id: string } }>;
77
+ const serverUsers = new Map([
78
+ ["user-guest", { id: "user-guest", type: "guest" }],
79
+ ["user-admin", { id: "user-admin", type: "admin" }],
80
+ ]);
81
+ instance = await prostgles({
82
+ dbConnection: {
83
+ ...getConnectionDetails(db),
84
+ options: `-c search_path=${schemaName}`,
85
+ } as unknown as ProstglesInitOptions["dbConnection"],
86
+ tsGeneratedTypesDir: generatedTypesDir,
87
+ transactions: true,
88
+ schemaFilter: { [schemaName]: 1 },
89
+ tableConfig: {
90
+ [tableName]: {
91
+ syncConfig: { id_fields: ["id"], synced_field: "synced" },
92
+ },
93
+ },
94
+ fileTable: {
95
+ tableName: fileTableName,
96
+ expressApp: express(),
97
+ storageClient: getLocalStorageClient({
98
+ localFolderPath: path.join(generatedTypesDir, "files"),
99
+ }),
100
+ },
101
+ io,
102
+ restApi: { expressApp: app, path: "/api" },
103
+ publishRawSQL: ({ user }) => user?.type === "admin",
104
+ auth: {
105
+ sidKeyName: "token",
106
+ getUser: (sid) => {
107
+ if (!sid) return undefined;
108
+ return {
109
+ user: { id: `user-${sid}`, type: sid },
110
+ clientUser: { id: sid, type: sid },
111
+ };
112
+ },
113
+ findUser: (filter) =>
114
+ "id" in filter && typeof filter.id === "string" ? serverUsers.get(filter.id)
115
+ : (
116
+ "$and" in filter &&
117
+ filter.$and?.every((condition: AnyObject) => condition.id === "user-guest")
118
+ ) ?
119
+ { id: "user-guest", type: "guest" }
120
+ : undefined,
121
+ },
122
+ functions: {
123
+ guest: {
124
+ userFilter: { id: "user-guest" },
125
+ functions: {
126
+ transactionTest: defineFunction({
127
+ input: { fail: "boolean" },
128
+ run: ({ fail }, { withClientDbTx }) =>
129
+ withClientDbTx(async (tx) => {
130
+ const row = await tx[tableName]!.insert!(
131
+ { body: fail ? "function-rollback" : "function-commit" },
132
+ { returning: "*" },
133
+ );
134
+ if (fail) {
135
+ await tx[tableName]!.update!({ id: row.id }, { internal: "forbidden" });
136
+ }
137
+ return row;
138
+ }),
139
+ }),
140
+ },
141
+ },
142
+ },
143
+ publish: [
144
+ {
145
+ name: "GuestDBSchema",
146
+ userTypes: ["guest", "member"],
147
+ publish: {
148
+ [fileTableName]: { select: "*", insert: "*", update: "*" },
149
+ [tableName]: {
150
+ select: {
151
+ fields: "*",
152
+ forcedFilter: {
153
+ created_by: userIdContextValue as unknown as string,
154
+ },
155
+ },
156
+ update: {
157
+ fields: ["body", "note", "created_by", "synced"],
158
+ disableMethods: { updateBatch: 1 },
159
+ postValidate: ({ row }) => {
160
+ if (row.body === "denied-by-post-validate") {
161
+ throw new Error("Update rejected by postValidate");
162
+ }
163
+ },
164
+ forcedData: {
165
+ created_by: userIdContextValue,
166
+ },
167
+ },
168
+ insert: {
169
+ fields: { internal: 0 },
170
+ checkFilter: { body: { $ne: "denied-by-check-filter" } },
171
+ forcedData: {
172
+ created_by: userIdContextValue,
173
+ },
174
+ },
175
+ },
176
+ },
177
+ },
178
+ {
179
+ name: "AdminDBSchema",
180
+ userTypes: ["admin"],
181
+ publish: {
182
+ [tableName]: { select: "*", insert: "*", update: "*" },
183
+ [privateTableName]: { select: "*" },
184
+ },
185
+ },
186
+ {
187
+ name: "ViewerDBSchema",
188
+ userTypes: ["viewer"],
189
+ publish: { [tableName]: { select: "*" } },
190
+ },
191
+ ],
192
+ onReady: () => {},
193
+ });
194
+ const { tsSchema } = await instance.getTSSchema();
195
+ assert.doesNotMatch(tsSchema, /\bimport(?:\s|\()/);
196
+ assert.equal(
197
+ readFileSync(path.join(generatedTypesDir, `${DB_GENERATED_NAMES.SCHEMA}.ts`), "utf8"),
198
+ tsSchema,
199
+ );
200
+ await instance.reWriteDBSchema();
201
+ assert.equal((await instance.getTSSchema()).tsSchema, tsSchema);
202
+ assert.equal(io.sockets.sockets.size, 0);
203
+ checkTypes(
204
+ tsSchema,
205
+ `
206
+ import type { TableHandler, InsertDataWithNested, ClientSchemaFor } from "prostgles-types";
207
+ import type { DBHandlerClient } from "prostgles-client";
208
+ import type { DBOFullyTypedClient, InitResult, SessionUser } from "../server/node_modules/prostgles-server";
209
+ import type { RestrictedFunctionContext, UnrestrictedFunctionContext } from "../server/node_modules/prostgles-server/dist/PublishParser/defineServerFunction";
210
+ import type { getClientHandlers } from "../server/node_modules/prostgles-server/dist/WebsocketAPI/getClientHandlers";
211
+ type Name = "${tableName}";
212
+ type FileName = "${fileTableName}";
213
+ declare const guest: TableHandler<GuestDBSchema, Name>;
214
+ declare const files: TableHandler<GuestDBSchema, FileName>;
215
+ declare const admin: TableHandler<AdminDBSchema, Name>;
216
+ declare const combined: TableHandler<ClientDBSchema, Name>;
217
+ declare const server: TableHandler<${DB_GENERATED_NAMES.SCHEMA}, Name>;
218
+ declare const viewer: TableHandler<ViewerDBSchema, Name>;
219
+ declare const client: DBHandlerClient<ClientDBSchema>;
220
+ declare const adminClient: DBHandlerClient<AdminDBSchema>;
221
+ declare const restricted: RestrictedFunctionContext<GuestDBSchema>;
222
+ declare const unrestricted: UnrestrictedFunctionContext<DBGeneratedSchema>;
223
+ declare const serverClient: DBOFullyTypedClient<ClientDBSchema>;
224
+ declare const handlers: Awaited<ReturnType<typeof getClientHandlers<GuestDBSchema>>>;
225
+ declare const instance: InitResult<DBGeneratedSchema, SessionUser, undefined, ClientDBSchema>;
226
+ declare const guestClientSchema: ClientSchemaFor<ClientSchemas, "guest">;
227
+ guestClientSchema satisfies GuestDBSchema;
228
+ async () => {
229
+ const combinedHandlers = await instance.getClientDBHandlers({ userId: "user-admin" }, undefined);
230
+ const guestHandlers = await instance.getClientDBHandlers<GuestDBSchema>({ userId: "user-guest" }, undefined);
231
+ await combinedHandlers.clientDb["${privateTableName}"]?.find();
232
+ // @ts-expect-error the guest profile does not publish private_table
233
+ await guestHandlers.clientDb["${privateTableName}"].find();
234
+ const restrictedRow = await restricted.dbo["${tableName}"].insert({ body: "hello" }, { returning: "*" });
235
+ restrictedRow.created_by satisfies string;
236
+ await handlers.clientDb["${tableName}"].update({}, { body: "changed" });
237
+ const transactionResult = await handlers.withClientDbTx(async (tx) => {
238
+ const row = await tx["${tableName}"].insert({ body: "transaction" }, { returning: "*" });
239
+ row.created_by satisfies string;
240
+ // @ts-expect-error transactions preserve profile field restrictions
241
+ await tx["${tableName}"].update({}, { internal: "private" });
242
+ // @ts-expect-error transactions do not expose unpublished tables
243
+ await tx["${privateTableName}"].find();
244
+ // @ts-expect-error transactions do not expose raw SQL
245
+ tx.sql("SELECT 1");
246
+ // @ts-expect-error transactions do not expose nested transactions
247
+ tx.tx(() => {});
248
+ return row.id;
249
+ });
250
+ transactionResult satisfies number;
251
+ restricted.withClientDbTx satisfies typeof handlers.withClientDbTx;
252
+ // @ts-expect-error callbacks do not receive the unrestricted SQL transaction
253
+ handlers.withClientDbTx((tx, rawTx) => rawTx.any("SELECT 1"));
254
+ // @ts-expect-error getClientHandlers preserves profile field restrictions
255
+ await handlers.clientDb["${tableName}"].update({}, { internal: "private" });
256
+ // @ts-expect-error restricted functions respect forced insert fields
257
+ await restricted.dbo["${tableName}"].insert({ body: "hello", created_by: "other" });
258
+ // @ts-expect-error restricted functions cannot start transactions
259
+ restricted.dbo.tx(() => {});
260
+ // @ts-expect-error client wrappers do not expose isView
261
+ restricted.dbo["${tableName}"].isView;
262
+ // @ts-expect-error table is absent for some profiles
263
+ await serverClient["${privateTableName}"].find();
264
+ await serverClient["${privateTableName}"]?.find();
265
+ await unrestricted.dbo.tx(async (tx) => {
266
+ await tx["${tableName}"].update({}, { internal: "private" });
267
+ });
268
+
269
+ await client["${tableName}"].update({}, { body: "changed" });
270
+ await guest.update({}, { body: 123 });
271
+ // @ts-expect-error table is absent for guests and viewers
272
+ await client["${privateTableName}"].find();
273
+ await client["${privateTableName}"]?.find();
274
+ await adminClient["${privateTableName}"].find();
275
+ const updated = await guest.update({}, { body: "changed", note: null }, { returning: "*" });
276
+ updated?.[0]?.created_by satisfies string | undefined;
277
+ await guest.updateBatch([[{}, { body: "changed" }]]);
278
+ // @ts-expect-error excluded update field
279
+ await guest.update({}, { internal: "private" });
280
+ const forcedUpdate = { body: "changed", created_by: "other" };
281
+ // @ts-expect-error forced update field through a variable
282
+ await guest.update({}, forcedUpdate);
283
+ // @ts-expect-error casts must not bypass excluded fields
284
+ await guest.update({}, { created_by: { $merge: [] } });
285
+ // @ts-expect-error updateBatch respects allowed fields
286
+ await guest.updateBatch([[{}, { internal: "private" }]]);
287
+ // @ts-expect-error read-only profiles cannot update
288
+ await viewer.update({}, {});
289
+ await admin.update({}, { internal: "private" });
290
+ await combined.update({}, { internal: "private" });
291
+ await server.update({}, { internal: "private" });
292
+ const row = await guest.insert({ body: "hello", note: null }, { returning: "*" });
293
+ row.created_by satisfies string;
294
+ row.synced satisfies string;
295
+ await guest.insertMany([{ body: "hello" }]);
296
+ // @ts-expect-error excluded fields cannot be inserted
297
+ await guest.insert({ body: "hello", internal: "private" });
298
+ // @ts-expect-error a read-only profile cannot insert
299
+ await viewer.insert({ body: "hello" });
300
+ // @ts-expect-error body remains required
301
+ await guest.insert({});
302
+ // @ts-expect-error forced values are forbidden
303
+ await guest.insert({ body: "hello", created_by: "other" });
304
+ const supplied = { body: "hello", created_by: "other" };
305
+ // @ts-expect-error also reject forced fields through variables
306
+ await guest.insert(supplied);
307
+ await admin.insert({ body: "hello", created_by: "admin" });
308
+ // @ts-expect-error admin must supply created_by
309
+ await admin.insert({ body: "hello" });
310
+ await combined.insert({ body: "hello" });
311
+ await combined.insert({ body: "hello", created_by: "admin" });
312
+ // @ts-expect-error read-only profiles do not relax required insert fields
313
+ await combined.insert({});
314
+ // @ts-expect-error server insert defaults are unchanged
315
+ await server.insert({ body: "hello", created_by: "admin" });
316
+ await server.insert({ body: "hello", created_by: "admin", synced: 1 });
317
+ await files.insert({ original_name: "hello.txt", data: new Uint8Array() });
318
+ await files.insert({ id: "file-id", original_name: "hello.txt", original_last_modified: null, data: new Uint8Array() });
319
+ // @ts-expect-error file metadata is generated by the server
320
+ await files.insert({ original_name: "hello.txt", data: new Uint8Array(), content_type: "text/plain" });
321
+ const fileWithGeneratedMetadata = { original_name: "hello.txt", data: new Uint8Array(), content_type: "text/plain" };
322
+ // @ts-expect-error file metadata is also rejected through variables
323
+ await files.insert(fileWithGeneratedMetadata);
324
+ // @ts-expect-error original_name is required
325
+ await files.insert({ data: new Uint8Array() });
326
+ await files.update({}, { original_name: "updated.txt", data: new Uint8Array() });
327
+ // @ts-expect-error file metadata cannot be updated directly
328
+ await files.update({}, { content_type: "text/plain" });
329
+ // @ts-expect-error file metadata updates are also rejected through variables
330
+ await files.update({}, fileWithGeneratedMetadata);
331
+ // @ts-expect-error unpublished tables are absent
332
+ type Private = GuestDBSchema["${privateTableName}"];
333
+ type Nested = InsertDataWithNested<{}, GuestDBSchema, "parent">;
334
+ const nested: Nested = { "${tableName}": [{ body: "hello" }] };
335
+ // @ts-expect-error nested inputs also exclude forced fields
336
+ const badNested: Nested = { "${tableName}": [{ created_by: "other" }] };
337
+ };
338
+ `,
339
+ );
340
+ const originalPublish = instance.options.publish;
341
+ // await instance.update({ publish: originalPublish });
342
+ const profiles: Record<string, ClientSchema> = {};
343
+ for (const [role, name] of [
344
+ ["guest", "GuestDBSchema"],
345
+ ["admin", "AdminDBSchema"],
346
+ ["viewer", "ViewerDBSchema"],
347
+ ["member", "MemberDBSchema"],
348
+ ["unknown", "UnknownDBSchema"],
349
+ ["", "AnonymousDBSchema"],
350
+ ] as const) {
351
+ const socket = createClient(`http://127.0.0.1:${address.port}`, {
352
+ transports: ["websocket"],
353
+ reconnection: false,
354
+ query: { ...(role ? { token: role } : {}), userId: "user-admin" },
355
+ auth: { userId: "user-admin" },
356
+ extraHeaders: { userId: "user-admin", cookie: "userId=user-admin" },
357
+ });
358
+ sockets.push(socket);
359
+ profiles[name] = await new Promise<ClientSchema>((resolve, reject) => {
360
+ socket.once("connect_error", reject);
361
+ socket.once(CHANNELS.SCHEMA, resolve);
362
+ });
363
+ }
364
+ const guestSchema = profiles.GuestDBSchema!;
365
+ assert.equal(guestSchema.err, undefined);
366
+ const table = guestSchema.tableSchema.find((t) => t.name === tableName);
367
+ assert(table);
368
+ assert.equal(table.columns.find((c) => c.name === "created_by")?.insert, false);
369
+ assert.deepEqual(profiles.MemberDBSchema!.tableSchema, guestSchema.tableSchema);
370
+ assert.deepEqual(profiles.UnknownDBSchema!.tableSchema, []);
371
+ assert.deepEqual(profiles.AnonymousDBSchema!.tableSchema, []);
372
+ const serverSocket = io.sockets.sockets.get(sockets[0]!.id!);
373
+ assert(serverSocket);
374
+ const handlers = await instance.getClientDBHandlers({ socket: serverSocket }, undefined);
375
+ assert.equal(privateTableName in handlers.clientDb, false);
376
+ assert.equal("delete" in handlers.clientDb[tableName]!, false);
377
+ assert.equal(typeof handlers.clientDb[tableName]!.upsert, "function");
378
+ assert.equal(typeof handlers.clientDb[tableName]!.insertMany, "function");
379
+ assert.equal("updateBatch" in handlers.clientDb[tableName]!, false);
380
+ await t.test("server user handlers enforce permissions and reject client impersonation", async () => {
381
+ assert(instance);
382
+ const admin = await instance.getClientDBHandlers({ userId: "user-admin" }, undefined);
383
+ assert.equal(typeof admin.clientDb[privateTableName]!.find, "function");
384
+ assert.deepEqual(await admin.clientDb[privateTableName]!.find!(), []);
385
+ assert.equal(await admin.clientSql("SELECT 1", [], { returnType: "value" }), 1);
386
+ const guest = await instance.getClientDBHandlers({ userId: "user-guest" }, undefined);
387
+ assert.equal(privateTableName in guest.clientDb, false);
388
+ const row = await guest.withClientDbTx((tx) =>
389
+ tx[tableName]!.insert!({ body: "server-user" }, { returning: "*" }),
390
+ );
391
+ assert.equal(row.created_by, "user-guest");
392
+ assert.equal(await guest.clientDb[tableName]!.count!({ id: row.id }), 1);
393
+ await assert.rejects(guest.clientSql("SELECT 1"));
394
+ await assert.rejects(() => guest.clientMethods.transactionTest!.run({ fail: true }) as any);
395
+ assert.equal(await guest.clientDb[tableName]!.count!({ body: "function-rollback" }), 0);
396
+ const scoped = await instance.getClientDBHandlers(
397
+ { userId: "user-admin" },
398
+ { tables: { [tableName]: { select: { forcedFilter: { id: row.id } } } } },
399
+ );
400
+ assert.deepEqual(Object.keys(scoped.clientDb), [tableName]);
401
+ assert.equal("insert" in scoped.clientDb[tableName]!, false);
402
+ assert.equal(await scoped.withClientDbTx((tx) => tx[tableName]!.count!()), 1);
403
+ assert.throws(() => scoped.clientSql("SELECT 1"), /PermissionScope/);
404
+
405
+ for (const userId of ["missing", "", undefined, 1, { $ne: null }]) {
406
+ await assert.rejects(async () =>
407
+ instance!.getClientDBHandlers({ userId: userId as string }, undefined),
408
+ );
409
+ }
410
+ await assert.rejects(async () => instance!.getClientDBHandlers(
411
+ // @ts-expect-error Explicit identity cannot be combined with a network request.
412
+ { userId: "user-admin", socket: serverSocket },
413
+ undefined,
414
+ ), /userId cannot be combined/);
415
+
416
+ const socketError = await new Promise((resolve) => {
417
+ sockets[0]!.emit(CHANNELS.DEFAULT, {
418
+ tableName: privateTableName,
419
+ command: "find",
420
+ userId: "user-admin",
421
+ clientReq: { userId: "user-admin" },
422
+ }, (error: unknown) => resolve(error));
423
+ });
424
+ assert(socketError);
425
+ for (const token of ["guest", undefined]) {
426
+ const headers = {
427
+ "content-type": "application/json",
428
+ userId: "user-admin",
429
+ cookie: "userId=user-admin",
430
+ ...(token ? { authorization: `Bearer ${Buffer.from(token).toString("base64")}` } : {}),
431
+ };
432
+ const response = await fetch(`http://127.0.0.1:${address.port}/api/schema?userId=user-admin`, {
433
+ method: "POST",
434
+ headers,
435
+ body: JSON.stringify({ userId: "user-admin", clientReq: { userId: "user-admin" } }),
436
+ });
437
+ const schema = await response.json() as ClientSchema;
438
+ assert.equal(response.status, 200, JSON.stringify(schema));
439
+ assert.equal(schema.rawSQL, false);
440
+ assert.equal(schema.tableSchema.some((table) => table.name === privateTableName), false);
441
+ const denied = await fetch(`http://127.0.0.1:${address.port}/api/db/${privateTableName}/find?userId=user-admin`, {
442
+ method: "POST",
443
+ headers,
444
+ body: JSON.stringify([{}, {}, { userId: "user-admin" }]),
445
+ });
446
+ assert.equal(denied.status, 400);
447
+ }
448
+
449
+ serverUsers.set("user-admin", { id: "user-admin", type: "guest" });
450
+ await assert.rejects(admin.clientDb[privateTableName]!.find!());
451
+ await assert.rejects(admin.clientSql("SELECT 1"));
452
+ assert.equal(await admin.clientDb[tableName]!.count!({ id: row.id }), 0);
453
+ serverUsers.delete("user-admin");
454
+ await assert.rejects(admin.clientDb[tableName]!.find!(), /User not found/);
455
+ serverUsers.set("user-admin", { id: "wrong-id", type: "admin" });
456
+ await assert.rejects(
457
+ instance.getClientDBHandlers({ userId: "user-admin" }, undefined),
458
+ /User not found/,
459
+ );
460
+ serverUsers.set("user-admin", { id: "user-admin", type: "admin" });
461
+ await instance.db[tableName]!.delete!({ id: row.id });
462
+ });
463
+ const viewerSocket = io.sockets.sockets.get(sockets[2]!.id!);
464
+ assert(viewerSocket);
465
+ const viewerHandlers = await instance.getClientDBHandlers(
466
+ { socket: viewerSocket },
467
+ undefined,
468
+ );
469
+ assert.deepEqual(Object.keys(viewerHandlers.clientDb).sort(), [tableName]);
470
+ assert.deepEqual(Object.keys(viewerHandlers.clientDb[tableName]!).sort(), [
471
+ "count",
472
+ "find",
473
+ "findOne",
474
+ "getColumns",
475
+ "getInfo",
476
+ "size",
477
+ "subscribe",
478
+ "subscribeOne",
479
+ ]);
480
+ const inserted = await handlers.clientDb[tableName]!.insert!(
481
+ { body: "hello" },
482
+ { returning: "*" },
483
+ );
484
+ assert.equal(inserted.created_by, "user-guest");
485
+ assert(Number(inserted.synced) > 0);
486
+ const updated = await handlers.clientDb[tableName]!.update!(
487
+ { id: inserted.id },
488
+ { body: "updated" },
489
+ { returning: "*" },
490
+ );
491
+ assert.equal(updated![0]!.body, "updated");
492
+ assert.equal(updated![0]!.created_by, "user-guest");
493
+ assert.equal(
494
+ (await handlers.clientDb[tableName]!.findOne!({
495
+ id: inserted.id,
496
+ }))!.created_by,
497
+ "user-guest",
498
+ );
499
+ await assert.rejects(
500
+ handlers.clientDb[tableName]!.update!({ id: inserted.id }, { internal: "forbidden" }),
501
+ );
502
+ const transactionRow = await handlers.withClientDbTx(async (...args) => {
503
+ assert.equal(args.length, 1);
504
+ const [tx] = args;
505
+ assert.equal("sql" in tx, false);
506
+ const row = await tx[tableName]!.insert!(
507
+ { body: "transaction", created_by: "other" },
508
+ { returning: "*" },
509
+ );
510
+ assert.equal(row.created_by, "user-guest");
511
+ assert.equal(await tx[tableName]!.count!({ id: row.id }), 1);
512
+ assert.equal(await handlers.clientDb[tableName]!.count!({ id: row.id }), 0);
513
+ await tx[tableName]!.update!({ id: row.id }, { body: "committed" });
514
+ return tx[tableName]!.findOne!({ id: row.id });
515
+ });
516
+ assert.equal(transactionRow!.body, "committed");
517
+ assert.equal(await handlers.clientDb[tableName]!.count!({ id: transactionRow!.id }), 1);
518
+ await instance.db[tableName]!.insert!({ body: "hidden", created_by: "other" });
519
+ await handlers.withClientDbTx(async (tx) => {
520
+ assert.equal(await tx[tableName]!.count!({ body: "hidden" }), 0);
521
+ assert.equal(privateTableName in tx, false);
522
+ assert.equal("delete" in tx[tableName]!, false);
523
+ assert.equal("db" in tx[tableName]!, false);
524
+ assert.equal("tx" in tx[tableName]!, false);
525
+ });
526
+ await t.test("caught subscription errors still roll back client transactions", async () => {
527
+ const row = { body: "before-subscription-failure" };
528
+ const error = { message: /subscribe/ };
529
+ await assert.rejects(
530
+ handlers.withClientDbTx(async (tx) => {
531
+ await tx[tableName]!.insert!(row);
532
+ await assert.rejects(tx[tableName]!.subscribe!({}, {}, undefined as any), error);
533
+ }),
534
+ error,
535
+ );
536
+ assert.equal(await handlers.clientDb[tableName]!.count!(row), 0);
537
+ });
538
+ for (const failure of ["throw", "permission", "database"] as const) {
539
+ const body = `rollback-${failure}`;
540
+ await assert.rejects(
541
+ handlers.withClientDbTx(async (tx) => {
542
+ await tx[tableName]!.insert!({ body });
543
+ if (failure === "permission") {
544
+ await tx[tableName]!.update!({}, { internal: "forbidden" });
545
+ } else if (failure === "database") {
546
+ await tx[tableName]!.insert!({ id: transactionRow!.id, body });
547
+ } else {
548
+ throw new Error("rollback");
549
+ }
550
+ }),
551
+ );
552
+ assert.equal(await handlers.clientDb[tableName]!.count!({ body }), 0);
553
+ }
554
+ await t.test("caught checkFilter errors still roll back client transactions", async () => {
555
+ const row = { body: "denied-by-check-filter" };
556
+ const error = { message: /failed the check condition/ };
557
+ await assert.rejects(handlers.clientDb[tableName]!.insert!(row), error);
558
+ assert.equal(await handlers.clientDb[tableName]!.count!(row), 0);
559
+
560
+ await assert.rejects(
561
+ handlers.withClientDbTx(async (tx) => {
562
+ await tx[tableName]!.insert!({ body: "before-check-filter-failure" });
563
+ await assert.rejects(tx[tableName]!.insert!(row), error);
564
+ await assert.rejects(tx[tableName]!.update!({}, { internal: "forbidden" }));
565
+ }),
566
+ error,
567
+ );
568
+ assert.equal(await handlers.clientDb[tableName]!.count!(row), 0);
569
+ assert.equal(
570
+ await handlers.clientDb[tableName]!.count!({ body: "before-check-filter-failure" }),
571
+ 0,
572
+ );
573
+ });
574
+ await t.test("caught postValidate errors still roll back client transactions", async () => {
575
+ const error = { message: "Update rejected by postValidate" };
576
+ await assert.rejects(
577
+ handlers.withClientDbTx(async (tx) => {
578
+ await assert.rejects(
579
+ tx[tableName]!.update!(
580
+ { id: transactionRow!.id },
581
+ { body: "denied-by-post-validate" },
582
+ ),
583
+ error,
584
+ );
585
+ }),
586
+ error,
587
+ );
588
+ const row = await handlers.clientDb[tableName]!.findOne!({ id: transactionRow!.id });
589
+ assert.equal(row!.body, "committed");
590
+ });
591
+ const scopedHandlers = await instance.getClientDBHandlers(
592
+ { socket: serverSocket },
593
+ { tables: { [tableName]: { select: { forcedFilter: { body: "committed" } } } } },
594
+ );
595
+ assert.deepEqual(Object.keys(scopedHandlers.clientDb).sort(), [tableName]);
596
+ assert.equal("insert" in scopedHandlers.clientDb[tableName]!, false);
597
+ assert.equal("update" in scopedHandlers.clientDb[tableName]!, false);
598
+ assert.equal("upsert" in scopedHandlers.clientDb[tableName]!, false);
599
+ await scopedHandlers.withClientDbTx(async (tx) => {
600
+ assert.equal(await tx[tableName]!.count!(), 1);
601
+ assert.deepEqual(Object.keys(tx).sort(), Object.keys(scopedHandlers.clientDb).sort());
602
+ assert.deepEqual(
603
+ Object.keys(tx[tableName]!),
604
+ Object.keys(scopedHandlers.clientDb[tableName]!),
605
+ );
606
+ });
607
+ const functionRow = await handlers.clientMethods.transactionTest!.run({ fail: false });
608
+ const committedRow = await handlers.clientDb[tableName]!.findOne!({
609
+ body: "function-commit",
610
+ });
611
+ assert.equal(committedRow!.created_by, "user-guest");
612
+ assert.deepEqual(functionRow, committedRow);
613
+ await assert.rejects(
614
+ () => handlers.clientMethods.transactionTest!.run({ fail: true }) as any,
615
+ );
616
+ assert.equal(await handlers.clientDb[tableName]!.count!({ body: "function-rollback" }), 0);
617
+ sockets.forEach((socket) => socket.disconnect());
618
+ io.disconnectSockets(true);
619
+ await instance.update({ publish: originalPublish });
620
+ await instance.update({
621
+ publish: [{ name: "EmptyDBSchema", userTypes: ["empty"], publish: null }],
622
+ });
623
+ assert(
624
+ (await instance.getTSSchema()).tsSchema.includes(
625
+ "export type EmptyDBSchema = Record<string, never>;",
626
+ ),
627
+ );
628
+ await instance.update({ publish: originalPublish });
629
+ for (const [publish, error] of [
630
+ [[{ userTypes: [], publish: null }], /userTypes must not be empty/],
631
+ [
632
+ [
633
+ { userTypes: ["guest"], publish: null },
634
+ { userTypes: ["guest"], publish: null },
635
+ ],
636
+ /Duplicate publish user type/,
637
+ ],
638
+ [
639
+ [{ name: "ClientDBSchema", userTypes: ["guest"], publish: null }],
640
+ /Invalid client schema name/,
641
+ ],
642
+ [
643
+ [{ name: "ClientSchemas", userTypes: ["guest"], publish: null }],
644
+ /Invalid client schema name/,
645
+ ],
646
+ [
647
+ [
648
+ { name: "SameSchema", userTypes: ["a"], publish: null },
649
+ { name: "SameSchema", userTypes: ["b"], publish: null },
650
+ ],
651
+ /Duplicate publish schema name/,
652
+ ],
653
+ ] as const) {
654
+ // await instance.update({ publish: publish });
655
+ // await assert.rejects((handlers.clientDb as DBHandlerServer)[tableName]!.find!(), error);
656
+ // await instance.update({ publish: originalPublish });
657
+ await assert.rejects(instance.update({ publish }), error);
658
+ assert.equal((await instance.getTSSchema()).tsSchema, tsSchema);
659
+ }
660
+ await instance.update({
661
+ publish: [{ userTypes: ["guest"], publish: { [tableName]: { select: "*" } } }],
662
+ });
663
+ const updatedSchema = (await instance.getTSSchema()).tsSchema;
664
+ assert(updatedSchema.includes("export type Publish1Schema"));
665
+ assert(!updatedSchema.includes("GuestDBSchema"));
666
+ assert.equal(
667
+ readFileSync(path.join(generatedTypesDir, `${DB_GENERATED_NAMES.SCHEMA}.ts`), "utf8"),
668
+ updatedSchema,
669
+ );
670
+ await instance.update({ publish: originalPublish });
671
+ assert.equal((await instance.getTSSchema()).tsSchema, tsSchema);
672
+ await t.test("server user handlers require configured authentication", async () => {
673
+ assert(instance);
674
+ const handlers = await instance.getClientDBHandlers({ userId: "user-admin" }, undefined);
675
+ await instance.update({ auth: undefined });
676
+ await assert.rejects(handlers.clientDb[privateTableName]!.find!(), /auth.findUser/);
677
+ await assert.rejects(
678
+ instance.getClientDBHandlers({ userId: "user-admin" }, undefined),
679
+ /auth.findUser/,
680
+ );
681
+ });
682
+ } finally {
683
+ sockets.forEach((socket) => socket.disconnect());
684
+ await instance?.destroy();
685
+ await new Promise<void>((resolve) => io.close(() => resolve()));
686
+ await db.none(`DROP SCHEMA IF EXISTS ${schemaName} CASCADE`);
687
+ }
688
+ },
689
+ );
690
+ };
691
+
692
+ const checkTypes = (schema: string, checks: string) => {
693
+ const filename = path.resolve(__dirname, "../../../client/client-schema-typecheck.ts");
694
+ const options: ts.CompilerOptions = {
695
+ strict: true,
696
+ noEmit: true,
697
+ skipLibCheck: true,
698
+ target: ts.ScriptTarget.ES2022,
699
+ module: ts.ModuleKind.CommonJS,
700
+ moduleResolution: ts.ModuleResolutionKind.Node10,
701
+ };
702
+ const host = ts.createCompilerHost(options);
703
+ // eslint-disable-next-line @typescript-eslint/unbound-method
704
+ const getSourceFile = host.getSourceFile;
705
+ host.getSourceFile = (name, languageVersion, onError, shouldCreateNewSourceFile) =>
706
+ name === filename ?
707
+ ts.createSourceFile(name, schema + checks, languageVersion, true)
708
+ : getSourceFile(name, languageVersion, onError, shouldCreateNewSourceFile);
709
+ const program = ts.createProgram([filename], options, host);
710
+ const diagnostics = ts.getPreEmitDiagnostics(program);
711
+ assert.equal(
712
+ diagnostics.length,
713
+ 0,
714
+ ts.formatDiagnosticsWithColorAndContext(diagnostics, {
715
+ getCanonicalFileName: (name) => name,
716
+ getCurrentDirectory: () => process.cwd(),
717
+ getNewLine: () => "\n",
718
+ }),
719
+ );
720
+ };