@webstudio-is/trpc-interface 0.90.0 → 0.260.2

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 (61) hide show
  1. package/package.json +26 -25
  2. package/src/authorize/project.server.test.ts +443 -0
  3. package/src/authorize/project.server.ts +309 -121
  4. package/src/authorize/role.ts +18 -0
  5. package/src/context/context.server.ts +59 -24
  6. package/src/context/errors.server.ts +16 -0
  7. package/src/context/router.server.ts +19 -0
  8. package/src/index.server.ts +15 -3
  9. package/src/shared/client.ts +0 -2
  10. package/src/shared/deployment.ts +23 -6
  11. package/src/shared/domain.ts +3 -3
  12. package/src/shared/plan-client.server.ts +7 -0
  13. package/src/shared/plan-features.ts +7 -0
  14. package/src/shared/shared-router.ts +0 -2
  15. package/src/shared/trpc.ts +5 -1
  16. package/src/trpc-caller-link.test.ts +1 -1
  17. package/src/trpc-caller-link.ts +1 -2
  18. package/tsconfig.json +3 -0
  19. package/lib/authorize/authorization-token.server.js +0 -72
  20. package/lib/authorize/project.server.js +0 -103
  21. package/lib/cjs/authorize/authorization-token.server.js +0 -92
  22. package/lib/cjs/authorize/project.server.js +0 -123
  23. package/lib/cjs/context/context.server.js +0 -16
  24. package/lib/cjs/context/errors.server.js +0 -29
  25. package/lib/cjs/index.js +0 -18
  26. package/lib/cjs/index.server.js +0 -40
  27. package/lib/cjs/package.json +0 -1
  28. package/lib/cjs/shared/authorization-router.js +0 -184
  29. package/lib/cjs/shared/client.js +0 -63
  30. package/lib/cjs/shared/deployment.js +0 -51
  31. package/lib/cjs/shared/domain.js +0 -98
  32. package/lib/cjs/shared/shared-router.js +0 -32
  33. package/lib/cjs/shared/trpc.js +0 -31
  34. package/lib/cjs/trpc-caller-link.js +0 -46
  35. package/lib/context/context.server.js +0 -0
  36. package/lib/context/errors.server.js +0 -9
  37. package/lib/index.js +0 -1
  38. package/lib/index.server.js +0 -10
  39. package/lib/shared/authorization-router.js +0 -164
  40. package/lib/shared/client.js +0 -35
  41. package/lib/shared/deployment.js +0 -31
  42. package/lib/shared/domain.js +0 -78
  43. package/lib/shared/shared-router.js +0 -12
  44. package/lib/shared/trpc.js +0 -11
  45. package/lib/trpc-caller-link.js +0 -26
  46. package/lib/types/authorize/authorization-token.server.d.ts +0 -21
  47. package/lib/types/authorize/project.server.d.ts +0 -25
  48. package/lib/types/context/context.server.d.ts +0 -53
  49. package/lib/types/context/errors.server.d.ts +0 -1
  50. package/lib/types/index.d.ts +0 -1
  51. package/lib/types/index.server.d.ts +0 -7
  52. package/lib/types/shared/authorization-router.d.ts +0 -276
  53. package/lib/types/shared/client.d.ts +0 -8
  54. package/lib/types/shared/deployment.d.ts +0 -45
  55. package/lib/types/shared/domain.d.ts +0 -119
  56. package/lib/types/shared/shared-router.d.ts +0 -415
  57. package/lib/types/shared/trpc.d.ts +0 -48
  58. package/lib/types/trpc-caller-link.d.ts +0 -16
  59. package/lib/types/trpc-caller-link.test.d.ts +0 -49
  60. package/src/authorize/authorization-token.server.ts +0 -106
  61. package/src/shared/authorization-router.ts +0 -198
package/package.json CHANGED
@@ -1,45 +1,46 @@
1
1
  {
2
2
  "name": "@webstudio-is/trpc-interface",
3
- "version": "0.90.0",
3
+ "version": "0.260.2",
4
4
  "description": "Webstudio TRPC Interface",
5
5
  "author": "Webstudio <github@webstudio.is>",
6
6
  "homepage": "https://webstudio.is",
7
7
  "type": "module",
8
8
  "dependencies": {
9
- "@trpc/client": "^10.9.0",
10
- "@trpc/server": "^10.9.0",
9
+ "@trpc/client": "^10.45.2",
10
+ "@trpc/server": "^10.45.2",
11
+ "memoize": "^10.0.0",
11
12
  "ts-custom-error": "^3.3.1",
12
- "uuid": "^9.0.0",
13
- "zod": "^3.21.4",
14
- "@webstudio-is/prisma-client": "^0.90.0"
13
+ "zod": "^3.24.2",
14
+ "@webstudio-is/feature-flags": "0.0.0",
15
+ "@webstudio-is/plans": "0.260.2"
15
16
  },
16
17
  "devDependencies": {
17
- "@types/node": "^18.11.18",
18
- "typescript": "5.1.6",
19
- "@webstudio-is/jest-config": "^1.0.7",
20
- "@webstudio-is/scripts": "^0.0.0",
21
- "@webstudio-is/tsconfig": "^1.0.7"
18
+ "vitest": "^3.1.2",
19
+ "@webstudio-is/postgrest": "0.260.2",
20
+ "@webstudio-is/tsconfig": "1.0.7"
22
21
  },
23
22
  "exports": {
24
23
  "./index.server": {
25
- "source": "./src/index.server.ts",
26
- "import": "./lib/index.server.js"
24
+ "webstudio": "./src/index.server.ts",
25
+ "import": "./src/index.server.ts"
26
+ },
27
+ "./plan-features": {
28
+ "webstudio": "./src/shared/plan-features.ts",
29
+ "import": "./src/shared/plan-features.ts"
30
+ },
31
+ "./plan-client": {
32
+ "webstudio": "./src/shared/plan-client.server.ts",
33
+ "import": "./src/shared/plan-client.server.ts"
34
+ },
35
+ "./authorize": {
36
+ "webstudio": "./src/authorize/role.ts",
37
+ "import": "./src/authorize/role.ts"
27
38
  }
28
39
  },
29
- "files": [
30
- "lib/*",
31
- "src/*",
32
- "!*.test.*"
33
- ],
34
40
  "license": "AGPL-3.0-or-later",
35
- "private": false,
36
41
  "sideEffects": false,
37
42
  "scripts": {
38
- "typecheck": "tsc",
39
- "test": "NODE_OPTIONS=--experimental-vm-modules jest",
40
- "checks": "pnpm typecheck",
41
- "dev": "build-package --watch",
42
- "build": "build-package",
43
- "dts": "tsc --project tsconfig.dts.json"
43
+ "typecheck": "tsgo --noEmit",
44
+ "test": "vitest run"
44
45
  }
45
46
  }
@@ -0,0 +1,443 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import {
3
+ createTestServer,
4
+ db,
5
+ json,
6
+ testContext,
7
+ } from "@webstudio-is/postgrest/testing";
8
+ import type { AppContext } from "../context/context.server";
9
+ import { defaultPlanFeatures } from "../shared/plan-features";
10
+ import {
11
+ checkProjectPermit,
12
+ hasProjectPermit,
13
+ getProjectPermit,
14
+ __testing__,
15
+ } from "./project.server";
16
+
17
+ const { isRolePermitted, getWorkspaceOwnerIdForProject } = __testing__;
18
+
19
+ describe("isRolePermitted", () => {
20
+ describe("workspace owner (own relation)", () => {
21
+ test("own relation grants view permit", () => {
22
+ expect(isRolePermitted(["own"], "view")).toBe(true);
23
+ });
24
+
25
+ test("own relation grants edit permit", () => {
26
+ expect(isRolePermitted(["own"], "edit")).toBe(true);
27
+ });
28
+
29
+ test("own relation grants build permit", () => {
30
+ expect(isRolePermitted(["own"], "build")).toBe(true);
31
+ });
32
+
33
+ test("own relation grants admin permit", () => {
34
+ expect(isRolePermitted(["own"], "admin")).toBe(true);
35
+ });
36
+
37
+ test("own relation grants own permit", () => {
38
+ expect(isRolePermitted(["own"], "own")).toBe(true);
39
+ });
40
+ });
41
+
42
+ describe("administrators", () => {
43
+ test("grants view permit", () => {
44
+ expect(isRolePermitted(["administrators"], "view")).toBe(true);
45
+ });
46
+
47
+ test("grants edit permit", () => {
48
+ expect(isRolePermitted(["administrators"], "edit")).toBe(true);
49
+ });
50
+
51
+ test("grants build permit", () => {
52
+ expect(isRolePermitted(["administrators"], "build")).toBe(true);
53
+ });
54
+
55
+ test("grants admin permit", () => {
56
+ expect(isRolePermitted(["administrators"], "admin")).toBe(true);
57
+ });
58
+
59
+ test("denies own permit", () => {
60
+ expect(isRolePermitted(["administrators"], "own")).toBe(false);
61
+ });
62
+ });
63
+
64
+ describe("builders", () => {
65
+ test("grants view permit", () => {
66
+ expect(isRolePermitted(["builders"], "view")).toBe(true);
67
+ });
68
+
69
+ test("grants edit permit", () => {
70
+ expect(isRolePermitted(["builders"], "edit")).toBe(true);
71
+ });
72
+
73
+ test("grants build permit", () => {
74
+ expect(isRolePermitted(["builders"], "build")).toBe(true);
75
+ });
76
+
77
+ test("denies admin permit", () => {
78
+ expect(isRolePermitted(["builders"], "admin")).toBe(false);
79
+ });
80
+
81
+ test("denies own permit", () => {
82
+ expect(isRolePermitted(["builders"], "own")).toBe(false);
83
+ });
84
+ });
85
+
86
+ describe("editors", () => {
87
+ test("grants view permit", () => {
88
+ expect(isRolePermitted(["editors"], "view")).toBe(true);
89
+ });
90
+
91
+ test("grants edit permit", () => {
92
+ expect(isRolePermitted(["editors"], "edit")).toBe(true);
93
+ });
94
+
95
+ test("denies build permit", () => {
96
+ expect(isRolePermitted(["editors"], "build")).toBe(false);
97
+ });
98
+
99
+ test("denies admin permit", () => {
100
+ expect(isRolePermitted(["editors"], "admin")).toBe(false);
101
+ });
102
+
103
+ test("denies own permit", () => {
104
+ expect(isRolePermitted(["editors"], "own")).toBe(false);
105
+ });
106
+ });
107
+
108
+ describe("viewers", () => {
109
+ test("grants view permit", () => {
110
+ expect(isRolePermitted(["viewers"], "view")).toBe(true);
111
+ });
112
+
113
+ test("denies edit permit", () => {
114
+ expect(isRolePermitted(["viewers"], "edit")).toBe(false);
115
+ });
116
+
117
+ test("denies build permit", () => {
118
+ expect(isRolePermitted(["viewers"], "build")).toBe(false);
119
+ });
120
+
121
+ test("denies admin permit", () => {
122
+ expect(isRolePermitted(["viewers"], "admin")).toBe(false);
123
+ });
124
+
125
+ test("denies own permit", () => {
126
+ expect(isRolePermitted(["viewers"], "own")).toBe(false);
127
+ });
128
+ });
129
+
130
+ describe("multiple relations", () => {
131
+ test("uses highest privilege from multiple relations", () => {
132
+ // User has both viewer and builder relations
133
+ expect(isRolePermitted(["viewers", "builders"], "build")).toBe(true);
134
+ });
135
+
136
+ test("own in any position grants all", () => {
137
+ expect(isRolePermitted(["viewers", "own"], "own")).toBe(true);
138
+ });
139
+ });
140
+
141
+ describe("empty relations", () => {
142
+ test("empty relations deny all permits", () => {
143
+ expect(isRolePermitted([], "view")).toBe(false);
144
+ expect(isRolePermitted([], "edit")).toBe(false);
145
+ expect(isRolePermitted([], "build")).toBe(false);
146
+ expect(isRolePermitted([], "admin")).toBe(false);
147
+ expect(isRolePermitted([], "own")).toBe(false);
148
+ });
149
+ });
150
+
151
+ describe("unknown relation strings", () => {
152
+ test("unknown relation denies permit", () => {
153
+ expect(isRolePermitted(["unknown"], "view")).toBe(false);
154
+ });
155
+ });
156
+ });
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // MSW integration tests
160
+ // ---------------------------------------------------------------------------
161
+
162
+ const server = createTestServer();
163
+
164
+ // Each test uses a unique projectId so the memoized `check()` doesn't
165
+ // return a cached result from a previous test.
166
+ const uid = () => `proj-${Math.random().toString(36).slice(2)}`;
167
+
168
+ const makeUserCtx = (userId = "user-1", maxWorkspaces = 5): AppContext =>
169
+ ({
170
+ ...testContext,
171
+ authorization: { type: "user", userId },
172
+ getOwnerPlanFeatures: async () => ({
173
+ ...defaultPlanFeatures,
174
+ maxWorkspaces,
175
+ }),
176
+ }) as unknown as AppContext;
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // getWorkspaceOwnerIdForProject
180
+ // ---------------------------------------------------------------------------
181
+
182
+ describe("getWorkspaceOwnerIdForProject (msw)", () => {
183
+ test("returns undefined when project has no workspace", async () => {
184
+ const projectId = uid();
185
+ server.use(
186
+ db.get("Project", () => json({ id: projectId, workspaceId: null }))
187
+ );
188
+ const result = await getWorkspaceOwnerIdForProject(projectId, testContext);
189
+ expect(result).toBeUndefined();
190
+ });
191
+
192
+ test("returns workspace owner userId when workspace exists", async () => {
193
+ const projectId = uid();
194
+ server.use(
195
+ db.get("Project", () => json({ id: projectId, workspaceId: "ws-1" })),
196
+ db.get("Workspace", () => json({ id: "ws-1", userId: "owner-99" }))
197
+ );
198
+ const result = await getWorkspaceOwnerIdForProject(projectId, testContext);
199
+ expect(result).toBe("owner-99");
200
+ });
201
+
202
+ test("returns undefined when workspace not found", async () => {
203
+ const projectId = uid();
204
+ server.use(
205
+ db.get("Project", () => json({ id: projectId, workspaceId: "ws-gone" })),
206
+ db.get("Workspace", () => json(null))
207
+ );
208
+ const result = await getWorkspaceOwnerIdForProject(projectId, testContext);
209
+ expect(result).toBeUndefined();
210
+ });
211
+ });
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // checkProjectPermit — user auth
215
+ // ---------------------------------------------------------------------------
216
+
217
+ describe("checkProjectPermit — user direct owner (msw)", () => {
218
+ test("allows edit when user directly owns the project", async () => {
219
+ const projectId = uid();
220
+ server.use(db.get("Project", () => json({ id: projectId })));
221
+ const allowed = await checkProjectPermit({
222
+ projectId,
223
+ permit: "edit",
224
+ authInfo: { type: "user", userId: "user-1" },
225
+ postgrestClient: testContext.postgrest.client,
226
+ });
227
+ expect(allowed).toBe(true);
228
+ });
229
+
230
+ test("allows via workspace member relation", async () => {
231
+ const projectId = uid();
232
+ server.use(
233
+ db.get("Project", () => json(null)),
234
+ db.get("WorkspaceProjectAuthorization", () =>
235
+ json([{ relation: "builders" }])
236
+ )
237
+ );
238
+ const allowed = await checkProjectPermit({
239
+ projectId,
240
+ permit: "build",
241
+ authInfo: { type: "user", userId: "user-2" },
242
+ postgrestClient: testContext.postgrest.client,
243
+ });
244
+ expect(allowed).toBe(true);
245
+ });
246
+
247
+ test("denies when user has insufficient workspace relation", async () => {
248
+ const projectId = uid();
249
+ server.use(
250
+ db.get("Project", () => json(null)),
251
+ db.get("WorkspaceProjectAuthorization", () =>
252
+ json([{ relation: "viewers" }])
253
+ )
254
+ );
255
+ const allowed = await checkProjectPermit({
256
+ projectId,
257
+ permit: "edit",
258
+ authInfo: { type: "user", userId: "user-3" },
259
+ postgrestClient: testContext.postgrest.client,
260
+ });
261
+ expect(allowed).toBe(false);
262
+ });
263
+
264
+ test("denies when user has no access at all", async () => {
265
+ const projectId = uid();
266
+ server.use(
267
+ db.get("Project", () => json(null)),
268
+ db.get("WorkspaceProjectAuthorization", () => json([]))
269
+ );
270
+ const allowed = await checkProjectPermit({
271
+ projectId,
272
+ permit: "view",
273
+ authInfo: { type: "user", userId: "user-4" },
274
+ postgrestClient: testContext.postgrest.client,
275
+ });
276
+ expect(allowed).toBe(false);
277
+ });
278
+ });
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // checkProjectPermit — token auth
282
+ // ---------------------------------------------------------------------------
283
+
284
+ describe("checkProjectPermit — token auth (msw)", () => {
285
+ test("allows when token exists with matching relation", async () => {
286
+ const projectId = uid();
287
+ server.use(db.get("AuthorizationToken", () => json({ token: "tok-1" })));
288
+ const allowed = await checkProjectPermit({
289
+ projectId,
290
+ permit: "view",
291
+ authInfo: { type: "token", authToken: "tok-1" },
292
+ postgrestClient: testContext.postgrest.client,
293
+ });
294
+ expect(allowed).toBe(true);
295
+ });
296
+
297
+ test("denies when token not found", async () => {
298
+ const projectId = uid();
299
+ server.use(db.get("AuthorizationToken", () => json(null)));
300
+ const allowed = await checkProjectPermit({
301
+ projectId,
302
+ permit: "view",
303
+ authInfo: { type: "token", authToken: "tok-missing" },
304
+ postgrestClient: testContext.postgrest.client,
305
+ });
306
+ expect(allowed).toBe(false);
307
+ });
308
+
309
+ test("denies 'own' permit for tokens regardless of DB", async () => {
310
+ const projectId = uid();
311
+ const allowed = await checkProjectPermit({
312
+ projectId,
313
+ permit: "own",
314
+ authInfo: { type: "token", authToken: "tok-any" },
315
+ postgrestClient: testContext.postgrest.client,
316
+ });
317
+ expect(allowed).toBe(false);
318
+ });
319
+ });
320
+
321
+ // ---------------------------------------------------------------------------
322
+ // checkProjectPermit — service auth
323
+ // ---------------------------------------------------------------------------
324
+
325
+ describe("checkProjectPermit — service auth", () => {
326
+ test("service auth allows view without DB", async () => {
327
+ const projectId = uid();
328
+ const allowed = await checkProjectPermit({
329
+ projectId,
330
+ permit: "view",
331
+ authInfo: { type: "service" },
332
+ postgrestClient: testContext.postgrest.client,
333
+ });
334
+ expect(allowed).toBe(true);
335
+ });
336
+
337
+ test("service auth denies edit", async () => {
338
+ const projectId = uid();
339
+ const allowed = await checkProjectPermit({
340
+ projectId,
341
+ permit: "edit",
342
+ authInfo: { type: "service" },
343
+ postgrestClient: testContext.postgrest.client,
344
+ });
345
+ expect(allowed).toBe(false);
346
+ });
347
+ });
348
+
349
+ // ---------------------------------------------------------------------------
350
+ // hasProjectPermit — workspace downgrade guard
351
+ // ---------------------------------------------------------------------------
352
+
353
+ describe("hasProjectPermit — workspace downgrade guard (msw)", () => {
354
+ test("denies workspace member when owner plan has maxWorkspaces <= 1", async () => {
355
+ const projectId = uid();
356
+ server.use(
357
+ // Per-call routing: ownership check uses userId param; workspaceId lookup does not
358
+ db.get("Project", ({ request }) => {
359
+ const url = new URL(request.url);
360
+ if (url.searchParams.has("userId")) {
361
+ return json(null);
362
+ }
363
+ return json({ id: projectId, workspaceId: "ws-1" });
364
+ }),
365
+ db.get("WorkspaceProjectAuthorization", () =>
366
+ json([{ relation: "editors" }])
367
+ ),
368
+ db.get("Workspace", () => json({ id: "ws-1", userId: "owner-1" }))
369
+ );
370
+ const ctx = makeUserCtx("user-2", 1);
371
+
372
+ const allowed = await hasProjectPermit({ projectId, permit: "edit" }, ctx);
373
+ expect(allowed).toBe(false);
374
+ });
375
+
376
+ test("allows workspace member when owner plan has maxWorkspaces > 1", async () => {
377
+ const projectId = uid();
378
+ server.use(
379
+ db.get("Project", ({ request }) => {
380
+ const url = new URL(request.url);
381
+ if (url.searchParams.has("userId")) {
382
+ return json(null);
383
+ }
384
+ return json({ id: projectId, workspaceId: "ws-1" });
385
+ }),
386
+ db.get("WorkspaceProjectAuthorization", () =>
387
+ json([{ relation: "editors" }])
388
+ ),
389
+ db.get("Workspace", () => json({ id: "ws-1", userId: "owner-1" }))
390
+ );
391
+ const ctx = makeUserCtx("user-2", 5);
392
+
393
+ const allowed = await hasProjectPermit({ projectId, permit: "edit" }, ctx);
394
+ expect(allowed).toBe(true);
395
+ });
396
+
397
+ test("anonymous auth always denied without hitting DB", async () => {
398
+ const projectId = uid();
399
+ const ctx = {
400
+ ...testContext,
401
+ authorization: { type: "anonymous" },
402
+ getOwnerPlanFeatures: async () => defaultPlanFeatures,
403
+ } as unknown as AppContext;
404
+ const allowed = await hasProjectPermit({ projectId, permit: "view" }, ctx);
405
+ expect(allowed).toBe(false);
406
+ });
407
+ });
408
+
409
+ // ---------------------------------------------------------------------------
410
+ // getProjectPermit
411
+ // ---------------------------------------------------------------------------
412
+
413
+ describe("getProjectPermit (msw)", () => {
414
+ test("returns first matching permit from ordered list", async () => {
415
+ const projectId = uid();
416
+ server.use(
417
+ db.get("Project", () => json({ id: projectId })),
418
+ db.get("WorkspaceProjectAuthorization", () => json([]))
419
+ );
420
+ const ctx = makeUserCtx("user-1");
421
+
422
+ const permit = await getProjectPermit(
423
+ { projectId, permits: ["own", "admin", "edit"] },
424
+ ctx
425
+ );
426
+ expect(permit).toBe("own");
427
+ });
428
+
429
+ test("returns undefined when no permit matches", async () => {
430
+ const projectId = uid();
431
+ server.use(
432
+ db.get("Project", () => json(null)),
433
+ db.get("WorkspaceProjectAuthorization", () => json([]))
434
+ );
435
+ const ctx = makeUserCtx("user-no-access");
436
+
437
+ const permit = await getProjectPermit(
438
+ { projectId, permits: ["edit", "view"] },
439
+ ctx
440
+ );
441
+ expect(permit).toBeUndefined();
442
+ });
443
+ });