@webstudio-is/trpc-interface 0.91.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.
- package/package.json +26 -25
- package/src/authorize/project.server.test.ts +443 -0
- package/src/authorize/project.server.ts +309 -121
- package/src/authorize/role.ts +18 -0
- package/src/context/context.server.ts +59 -24
- package/src/context/errors.server.ts +16 -0
- package/src/context/router.server.ts +19 -0
- package/src/index.server.ts +15 -3
- package/src/shared/client.ts +0 -2
- package/src/shared/deployment.ts +23 -6
- package/src/shared/domain.ts +3 -3
- package/src/shared/plan-client.server.ts +7 -0
- package/src/shared/plan-features.ts +7 -0
- package/src/shared/shared-router.ts +0 -2
- package/src/shared/trpc.ts +5 -1
- package/src/trpc-caller-link.test.ts +1 -1
- package/src/trpc-caller-link.ts +1 -2
- package/tsconfig.json +3 -0
- package/lib/authorize/authorization-token.server.js +0 -72
- package/lib/authorize/project.server.js +0 -103
- package/lib/cjs/authorize/authorization-token.server.js +0 -92
- package/lib/cjs/authorize/project.server.js +0 -123
- package/lib/cjs/context/context.server.js +0 -16
- package/lib/cjs/context/errors.server.js +0 -29
- package/lib/cjs/index.js +0 -18
- package/lib/cjs/index.server.js +0 -40
- package/lib/cjs/package.json +0 -1
- package/lib/cjs/shared/authorization-router.js +0 -184
- package/lib/cjs/shared/client.js +0 -63
- package/lib/cjs/shared/deployment.js +0 -51
- package/lib/cjs/shared/domain.js +0 -98
- package/lib/cjs/shared/shared-router.js +0 -32
- package/lib/cjs/shared/trpc.js +0 -31
- package/lib/cjs/trpc-caller-link.js +0 -46
- package/lib/context/context.server.js +0 -0
- package/lib/context/errors.server.js +0 -9
- package/lib/index.js +0 -1
- package/lib/index.server.js +0 -10
- package/lib/shared/authorization-router.js +0 -164
- package/lib/shared/client.js +0 -35
- package/lib/shared/deployment.js +0 -31
- package/lib/shared/domain.js +0 -78
- package/lib/shared/shared-router.js +0 -12
- package/lib/shared/trpc.js +0 -11
- package/lib/trpc-caller-link.js +0 -26
- package/lib/types/authorize/authorization-token.server.d.ts +0 -21
- package/lib/types/authorize/project.server.d.ts +0 -25
- package/lib/types/context/context.server.d.ts +0 -53
- package/lib/types/context/errors.server.d.ts +0 -1
- package/lib/types/index.d.ts +0 -1
- package/lib/types/index.server.d.ts +0 -7
- package/lib/types/shared/authorization-router.d.ts +0 -276
- package/lib/types/shared/client.d.ts +0 -8
- package/lib/types/shared/deployment.d.ts +0 -45
- package/lib/types/shared/domain.d.ts +0 -119
- package/lib/types/shared/shared-router.d.ts +0 -415
- package/lib/types/shared/trpc.d.ts +0 -48
- package/lib/types/trpc-caller-link.d.ts +0 -16
- package/lib/types/trpc-caller-link.test.d.ts +0 -49
- package/src/authorize/authorization-token.server.ts +0 -106
- package/src/shared/authorization-router.ts +0 -198
|
@@ -1,129 +1,321 @@
|
|
|
1
|
-
import type { Project } from "@webstudio-is/prisma-client";
|
|
2
|
-
import type { AuthPermit } from "../shared/authorization-router";
|
|
3
1
|
import type { AppContext } from "../context/context.server";
|
|
2
|
+
import type { Role } from "./role";
|
|
3
|
+
import memoize from "memoize";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
* For 3rd party authorization systems like Ory we need to register the project owner.
|
|
7
|
-
*
|
|
8
|
-
* We do that before the project create (and out of the transaction),
|
|
9
|
-
* so in case of an error we will have just stale records of non existed projects in authorization system.
|
|
10
|
-
*/
|
|
11
|
-
export const registerProjectOwner = async (
|
|
12
|
-
props: { projectId: string },
|
|
13
|
-
context: AppContext
|
|
14
|
-
) => {
|
|
15
|
-
const { authorization } = context;
|
|
16
|
-
const { userId, authorizeTrpc } = authorization;
|
|
5
|
+
type Relation = Role;
|
|
17
6
|
|
|
18
|
-
|
|
19
|
-
throw new Error("The user must be authenticated to create a project");
|
|
20
|
-
}
|
|
7
|
+
export type AuthPermit = "view" | "edit" | "build" | "admin" | "own";
|
|
21
8
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
9
|
+
type TokenAuthPermit = Exclude<AuthPermit, "own">;
|
|
10
|
+
|
|
11
|
+
type CheckInput = {
|
|
12
|
+
namespace: "Project";
|
|
13
|
+
id: string;
|
|
14
|
+
|
|
15
|
+
permit: AuthPermit;
|
|
16
|
+
|
|
17
|
+
subjectSet: {
|
|
18
|
+
namespace: "User" | "Token";
|
|
19
|
+
id: string;
|
|
20
|
+
};
|
|
31
21
|
};
|
|
32
22
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
) => {
|
|
40
|
-
const start = Date.now();
|
|
23
|
+
const permitToRelationRewrite: Record<TokenAuthPermit, Relation[]> = {
|
|
24
|
+
view: ["viewers", "editors", "builders", "administrators"],
|
|
25
|
+
edit: ["editors", "builders", "administrators"],
|
|
26
|
+
build: ["builders", "administrators"],
|
|
27
|
+
admin: ["administrators"],
|
|
28
|
+
};
|
|
41
29
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Pure function: checks whether a set of workspace relations grants a given
|
|
32
|
+
* permit. Used by the auth layer to evaluate workspace-based access.
|
|
33
|
+
*/
|
|
34
|
+
const isRolePermitted = (relations: string[], permit: AuthPermit): boolean => {
|
|
35
|
+
// Workspace owner gets all permits
|
|
36
|
+
if (relations.includes("own")) {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
// Only workspace owner gets "own" permit
|
|
40
|
+
if (permit === "own") {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
const permitted = permitToRelationRewrite[permit] ?? [];
|
|
44
|
+
return relations.some((r) => permitted.includes(r as Relation));
|
|
45
|
+
};
|
|
45
46
|
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
const check = async (
|
|
48
|
+
postgrestClient: AppContext["postgrest"]["client"],
|
|
49
|
+
input: CheckInput
|
|
50
|
+
) => {
|
|
51
|
+
const { subjectSet } = input;
|
|
48
52
|
|
|
49
|
-
|
|
50
|
-
if
|
|
51
|
-
|
|
53
|
+
if (subjectSet.namespace === "User") {
|
|
54
|
+
// Check if the user is the direct owner of the project
|
|
55
|
+
const row = await postgrestClient
|
|
56
|
+
.from("Project")
|
|
57
|
+
.select("id")
|
|
58
|
+
.eq("id", input.id)
|
|
59
|
+
.eq("userId", subjectSet.id)
|
|
60
|
+
.maybeSingle();
|
|
61
|
+
if (row.error) {
|
|
62
|
+
throw row.error;
|
|
52
63
|
}
|
|
53
64
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
// The plan is to make new permission for projects which are allowed to be publicly clonable by anyone
|
|
57
|
-
// https://github.com/webstudio-is/webstudio-builder/issues/1038
|
|
58
|
-
if (
|
|
59
|
-
props.permit === "view" &&
|
|
60
|
-
props.projectId === "62154aaef0cb0860ccf85d6e"
|
|
61
|
-
) {
|
|
62
|
-
return true;
|
|
65
|
+
if (row.data !== null) {
|
|
66
|
+
return { allowed: true };
|
|
63
67
|
}
|
|
64
68
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
69
|
+
// Workspace-based authorization
|
|
70
|
+
const wpaRows = await postgrestClient
|
|
71
|
+
.from("WorkspaceProjectAuthorization")
|
|
72
|
+
.select("relation")
|
|
73
|
+
.eq("userId", subjectSet.id)
|
|
74
|
+
.eq("projectId", input.id);
|
|
75
|
+
|
|
76
|
+
if (wpaRows.error) {
|
|
77
|
+
throw wpaRows.error;
|
|
70
78
|
}
|
|
71
79
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
authorizeTrpc.check.query({
|
|
76
|
-
subjectSet: {
|
|
77
|
-
namespace: "User",
|
|
78
|
-
id: authorization.userId,
|
|
79
|
-
},
|
|
80
|
-
namespace,
|
|
81
|
-
id: props.projectId,
|
|
82
|
-
permit: props.permit,
|
|
83
|
-
})
|
|
80
|
+
if (wpaRows.data.length > 0) {
|
|
81
|
+
const relations = wpaRows.data.flatMap((r) =>
|
|
82
|
+
r.relation !== null ? [r.relation] : []
|
|
84
83
|
);
|
|
84
|
+
return { allowed: isRolePermitted(relations, input.permit) };
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
87
|
+
return { allowed: false };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (input.permit === "own") {
|
|
91
|
+
return { allowed: false };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (subjectSet.namespace !== "Token") {
|
|
95
|
+
return { allowed: false };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const row = await postgrestClient
|
|
99
|
+
.from("AuthorizationToken")
|
|
100
|
+
.select("token")
|
|
101
|
+
.eq("token", subjectSet.id)
|
|
102
|
+
.in("relation", [...permitToRelationRewrite[input.permit]])
|
|
103
|
+
.maybeSingle();
|
|
104
|
+
|
|
105
|
+
if (row.error) {
|
|
106
|
+
throw row.error;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { allowed: row.data !== null };
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// doesn't work in cloudflare workers
|
|
113
|
+
const memoizedCheck = memoize(check, {
|
|
114
|
+
// Short TTL so plan downgrades propagate quickly. No cache invalidation
|
|
115
|
+
// hook exists yet — keep this low until one is added.
|
|
116
|
+
maxAge: 10 * 1000,
|
|
117
|
+
cacheKey: ([_context, input]) => JSON.stringify(input),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
type AuthInfo =
|
|
121
|
+
| {
|
|
122
|
+
type: "user";
|
|
123
|
+
userId: string;
|
|
124
|
+
}
|
|
125
|
+
| {
|
|
126
|
+
type: "token";
|
|
127
|
+
authToken: string;
|
|
101
128
|
}
|
|
129
|
+
| {
|
|
130
|
+
type: "service";
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export const checkProjectPermit = async ({
|
|
134
|
+
projectId,
|
|
135
|
+
permit,
|
|
136
|
+
authInfo,
|
|
137
|
+
postgrestClient,
|
|
138
|
+
}: {
|
|
139
|
+
projectId: string;
|
|
140
|
+
permit: AuthPermit;
|
|
141
|
+
authInfo: AuthInfo;
|
|
142
|
+
postgrestClient: AppContext["postgrest"]["client"];
|
|
143
|
+
}) => {
|
|
144
|
+
const checks = [];
|
|
145
|
+
const namespace = "Project";
|
|
146
|
+
|
|
147
|
+
if (authInfo.type === "service") {
|
|
148
|
+
return permit === "view";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// @todo Delete and use tokens
|
|
152
|
+
const templateIds = [
|
|
153
|
+
// Production
|
|
154
|
+
"5e086cf4-4293-471c-8eab-ddca8b5cd4db",
|
|
155
|
+
"94e6e1b8-c6c4-485a-9d7a-8282e11920c0",
|
|
156
|
+
"05954204-fcee-407e-b47f-77a38de74431",
|
|
157
|
+
"afc162c2-6396-41b7-a855-8fc04604a7b1",
|
|
158
|
+
"3f260731-825b-486a-b534-e747f0ed6106",
|
|
159
|
+
"400b1bde-def1-49e0-9b64-e26416d326fa",
|
|
160
|
+
"2e802ad7-ef32-48e6-8706-3a162785ef95",
|
|
161
|
+
"01f6f1d8-06f5-4a6c-a3b1-89a0448046c7",
|
|
162
|
+
"5b33acf4-53cf-4f03-8973-d5679772edee",
|
|
163
|
+
"909a139b-1f2d-415a-ac90-382fa19fa7d8",
|
|
164
|
+
"ef82ee51-e4d6-4a69-a4cc-7bf1dee65ed7",
|
|
165
|
+
"e761178f-6ac6-47f6-b881-56cc75640d73",
|
|
166
|
+
// Staging IDs
|
|
167
|
+
"c236999d-be6b-43fb-9edc-78a2ba59e56d",
|
|
168
|
+
"a1371dce-752c-4ccf-8ea4-88bab577fe50",
|
|
169
|
+
"6204396c-3f9e-4d29-8d19-ff0f76960a74",
|
|
170
|
+
];
|
|
102
171
|
|
|
103
|
-
|
|
172
|
+
// @todo Delete and use tokens
|
|
173
|
+
if (permit === "view" && templateIds.includes(projectId)) {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (authInfo.type === "token") {
|
|
178
|
+
// Token doesn't have "own" permit, do not check it
|
|
179
|
+
if (permit === "own") {
|
|
104
180
|
return false;
|
|
105
181
|
}
|
|
106
182
|
|
|
107
|
-
|
|
183
|
+
checks.push(
|
|
184
|
+
memoizedCheck(postgrestClient, {
|
|
185
|
+
namespace,
|
|
186
|
+
id: projectId,
|
|
187
|
+
subjectSet: {
|
|
188
|
+
id: authInfo.authToken,
|
|
189
|
+
namespace: "Token",
|
|
190
|
+
},
|
|
191
|
+
permit: permit,
|
|
192
|
+
})
|
|
193
|
+
);
|
|
194
|
+
}
|
|
108
195
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
196
|
+
// Check if the user is allowed to access the project
|
|
197
|
+
if (authInfo.type === "user") {
|
|
198
|
+
checks.push(
|
|
199
|
+
memoizedCheck(postgrestClient, {
|
|
200
|
+
subjectSet: {
|
|
201
|
+
namespace: "User",
|
|
202
|
+
id: authInfo.userId,
|
|
203
|
+
},
|
|
204
|
+
namespace,
|
|
205
|
+
id: projectId,
|
|
206
|
+
permit: permit,
|
|
207
|
+
})
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (checks.length === 0) {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const authResults = await Promise.allSettled(checks);
|
|
216
|
+
|
|
217
|
+
for (const authResult of authResults) {
|
|
218
|
+
if (authResult.status === "rejected") {
|
|
219
|
+
throw new Error(`Authorization call failed ${authResult.reason}`);
|
|
113
220
|
}
|
|
221
|
+
}
|
|
114
222
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
223
|
+
const allowed = authResults.some(
|
|
224
|
+
(authResult) =>
|
|
225
|
+
authResult.status === "fulfilled" && authResult.value.allowed
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
return allowed;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Look up the workspace owner's userId for a given project.
|
|
233
|
+
* Returns undefined when the project has no workspace.
|
|
234
|
+
*/
|
|
235
|
+
const getWorkspaceOwnerIdForProject = async (
|
|
236
|
+
projectId: string,
|
|
237
|
+
context: Pick<AppContext, "postgrest">
|
|
238
|
+
): Promise<string | undefined> => {
|
|
239
|
+
const project = await context.postgrest.client
|
|
240
|
+
.from("Project")
|
|
241
|
+
.select("workspaceId")
|
|
242
|
+
.eq("id", projectId)
|
|
243
|
+
.maybeSingle();
|
|
244
|
+
|
|
245
|
+
if (project.error || project.data?.workspaceId == null) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const workspace = await context.postgrest.client
|
|
250
|
+
.from("Workspace")
|
|
251
|
+
.select("userId")
|
|
252
|
+
.eq("id", project.data.workspaceId)
|
|
253
|
+
.eq("isDeleted", false)
|
|
254
|
+
.maybeSingle();
|
|
255
|
+
|
|
256
|
+
return workspace.data?.userId ?? undefined;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
export const hasProjectPermit = async (
|
|
260
|
+
props: {
|
|
261
|
+
projectId: string;
|
|
262
|
+
permit: AuthPermit;
|
|
263
|
+
},
|
|
264
|
+
context: AppContext
|
|
265
|
+
) => {
|
|
266
|
+
const { authorization } = context;
|
|
267
|
+
|
|
268
|
+
if (authorization.type === "anonymous") {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const authInfo: AuthInfo = authorization;
|
|
273
|
+
|
|
274
|
+
if (authInfo === undefined) {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const allowed = await checkProjectPermit({
|
|
279
|
+
projectId: props.projectId,
|
|
280
|
+
permit: props.permit,
|
|
281
|
+
authInfo,
|
|
282
|
+
postgrestClient: context.postgrest.client,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
if (allowed === false) {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Workspace downgrade check: when a workspace member accesses a project,
|
|
290
|
+
// verify the workspace owner's plan still supports workspace features.
|
|
291
|
+
// Direct project owners and workspace owners are not affected.
|
|
292
|
+
if (authorization.type === "user") {
|
|
293
|
+
// "own" permit is only granted to direct owners and workspace owners —
|
|
294
|
+
// both are unaffected by downgrade. This call is memoized.
|
|
295
|
+
const isOwner = await checkProjectPermit({
|
|
296
|
+
projectId: props.projectId,
|
|
297
|
+
permit: "own",
|
|
298
|
+
authInfo,
|
|
299
|
+
postgrestClient: context.postgrest.client,
|
|
300
|
+
});
|
|
119
301
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
302
|
+
if (isOwner === false) {
|
|
303
|
+
// User is a workspace member — verify the owner's plan
|
|
304
|
+
const workspaceOwnerId = await getWorkspaceOwnerIdForProject(
|
|
305
|
+
props.projectId,
|
|
306
|
+
context
|
|
307
|
+
);
|
|
123
308
|
|
|
124
|
-
|
|
125
|
-
|
|
309
|
+
if (workspaceOwnerId !== undefined) {
|
|
310
|
+
const ownerPlan = await context.getOwnerPlanFeatures(workspaceOwnerId);
|
|
311
|
+
if (ownerPlan.maxWorkspaces <= 1) {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
126
316
|
}
|
|
317
|
+
|
|
318
|
+
return true;
|
|
127
319
|
};
|
|
128
320
|
|
|
129
321
|
/**
|
|
@@ -131,37 +323,33 @@ export const hasProjectPermit = async (
|
|
|
131
323
|
* @todo think about caching to authorizeTrpc.check.query
|
|
132
324
|
* batching check queries would help too https://github.com/ory/keto/issues/812
|
|
133
325
|
*/
|
|
134
|
-
export const getProjectPermit = async
|
|
326
|
+
export const getProjectPermit = async (
|
|
135
327
|
props: {
|
|
136
328
|
projectId: string;
|
|
137
|
-
permits: readonly
|
|
329
|
+
permits: readonly AuthPermit[];
|
|
138
330
|
},
|
|
139
331
|
context: AppContext
|
|
140
|
-
): Promise<
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
try {
|
|
144
|
-
const permitToCheck = props.permits;
|
|
145
|
-
|
|
146
|
-
const permits = await Promise.allSettled(
|
|
147
|
-
permitToCheck.map((permit) =>
|
|
148
|
-
hasProjectPermit({ projectId: props.projectId, permit }, context)
|
|
149
|
-
)
|
|
150
|
-
);
|
|
332
|
+
): Promise<AuthPermit | undefined> => {
|
|
333
|
+
const permitToCheck = props.permits;
|
|
151
334
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
335
|
+
const permits = await Promise.allSettled(
|
|
336
|
+
permitToCheck.map((permit) =>
|
|
337
|
+
hasProjectPermit({ projectId: props.projectId, permit }, context)
|
|
338
|
+
)
|
|
339
|
+
);
|
|
156
340
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
341
|
+
for (const permit of permits) {
|
|
342
|
+
if (permit.status === "rejected") {
|
|
343
|
+
throw new Error(`Authorization call failed ${permit.reason}`);
|
|
160
344
|
}
|
|
161
|
-
} finally {
|
|
162
|
-
const diff = Date.now() - start;
|
|
163
345
|
|
|
164
|
-
|
|
165
|
-
|
|
346
|
+
if (permit.value === true) {
|
|
347
|
+
return permitToCheck[permits.indexOf(permit)];
|
|
348
|
+
}
|
|
166
349
|
}
|
|
167
350
|
};
|
|
351
|
+
|
|
352
|
+
export const __testing__ = {
|
|
353
|
+
isRolePermitted,
|
|
354
|
+
getWorkspaceOwnerIdForProject,
|
|
355
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const roles = [
|
|
2
|
+
"viewers",
|
|
3
|
+
"editors",
|
|
4
|
+
"builders",
|
|
5
|
+
"administrators",
|
|
6
|
+
] as const;
|
|
7
|
+
|
|
8
|
+
export type Role = (typeof roles)[number];
|
|
9
|
+
|
|
10
|
+
/** Safest default when role is unknown — principle of least privilege */
|
|
11
|
+
export const defaultRole: Role = "viewers";
|
|
12
|
+
|
|
13
|
+
export const roleLabels: Record<Role, string> = {
|
|
14
|
+
viewers: "Viewer",
|
|
15
|
+
editors: "Editor",
|
|
16
|
+
builders: "Builder",
|
|
17
|
+
administrators: "Admin",
|
|
18
|
+
};
|
|
@@ -1,32 +1,45 @@
|
|
|
1
1
|
import type { TrpcInterfaceClient } from "../shared/shared-router";
|
|
2
|
+
import type { Client } from "@webstudio-is/postgrest/index.server";
|
|
3
|
+
import type { PlanFeatures, Purchase } from "@webstudio-is/plans";
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* All necessary parameters for Authorization
|
|
5
7
|
*/
|
|
6
|
-
type AuthorizationContext =
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
isServiceCall: boolean;
|
|
8
|
+
type AuthorizationContext =
|
|
9
|
+
| {
|
|
10
|
+
type: "user";
|
|
11
|
+
/**
|
|
12
|
+
* userId of the current authenticated user
|
|
13
|
+
*/
|
|
14
|
+
userId: string;
|
|
15
|
+
sessionCreatedAt: number;
|
|
16
|
+
/**
|
|
17
|
+
* Has projectId in the tracked sessions
|
|
18
|
+
*/
|
|
19
|
+
isLoggedInToBuilder: (projectId: string) => Promise<boolean>;
|
|
20
|
+
}
|
|
21
|
+
| {
|
|
22
|
+
type: "token";
|
|
23
|
+
/**
|
|
24
|
+
* token URLSearchParams or hostname
|
|
25
|
+
*/
|
|
26
|
+
authToken: string;
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
/**
|
|
29
|
+
* In case of authToken, this is the ownerId of the project
|
|
30
|
+
*/
|
|
31
|
+
ownerId: string;
|
|
32
|
+
}
|
|
33
|
+
| {
|
|
34
|
+
type: "service";
|
|
35
|
+
/**
|
|
36
|
+
* Allow service 2 service communications to skip authorization for view calls
|
|
37
|
+
*/
|
|
38
|
+
isServiceCall: boolean;
|
|
39
|
+
}
|
|
40
|
+
| {
|
|
41
|
+
type: "anonymous";
|
|
42
|
+
};
|
|
30
43
|
|
|
31
44
|
type DomainContext = {
|
|
32
45
|
domainTrpc: TrpcInterfaceClient["domain"];
|
|
@@ -46,10 +59,21 @@ type DeploymentContext = {
|
|
|
46
59
|
deploymentTrpc: TrpcInterfaceClient["deployment"];
|
|
47
60
|
env: {
|
|
48
61
|
BUILDER_ORIGIN: string;
|
|
49
|
-
|
|
62
|
+
GITHUB_REF_NAME: string;
|
|
63
|
+
GITHUB_SHA: string | undefined;
|
|
64
|
+
PUBLISHER_HOST: string;
|
|
50
65
|
};
|
|
51
66
|
};
|
|
52
67
|
|
|
68
|
+
type TrpcCache = {
|
|
69
|
+
setMaxAge: (path: string, value: number) => void;
|
|
70
|
+
getMaxAge: (path: string) => number | undefined;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
type PostgrestContext = {
|
|
74
|
+
client: Client;
|
|
75
|
+
};
|
|
76
|
+
|
|
53
77
|
/**
|
|
54
78
|
* AppContext is a global context that is passed to all trpc/api queries/mutations
|
|
55
79
|
* "authorization" is made inside the namespace because eventually there will be
|
|
@@ -60,4 +84,15 @@ export type AppContext = {
|
|
|
60
84
|
domain: DomainContext;
|
|
61
85
|
deployment: DeploymentContext;
|
|
62
86
|
entri: EntriContext;
|
|
87
|
+
planFeatures: PlanFeatures;
|
|
88
|
+
purchases: Array<Purchase>;
|
|
89
|
+
trpcCache: TrpcCache;
|
|
90
|
+
postgrest: PostgrestContext;
|
|
91
|
+
createTokenContext: (token: string) => Promise<AppContext>;
|
|
92
|
+
/**
|
|
93
|
+
* Resolves plan features for a given user ID.
|
|
94
|
+
* Used by workspace authorization to check whether the workspace owner's plan
|
|
95
|
+
* still supports workspace features (maxWorkspaces > 1) after a potential downgrade.
|
|
96
|
+
*/
|
|
97
|
+
getOwnerPlanFeatures: (userId: string) => Promise<PlanFeatures>;
|
|
63
98
|
};
|
|
@@ -5,3 +5,19 @@ export const AuthorizationError = customErrorFactory(
|
|
|
5
5
|
this.message = message;
|
|
6
6
|
}
|
|
7
7
|
);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Standard response for any client-server communication with an error.
|
|
11
|
+
*/
|
|
12
|
+
export const createErrorResponse = (error: unknown) => {
|
|
13
|
+
const message =
|
|
14
|
+
typeof error === "string"
|
|
15
|
+
? error
|
|
16
|
+
: error && typeof error === "object" && "message" in error
|
|
17
|
+
? String(error.message)
|
|
18
|
+
: "Unknown error";
|
|
19
|
+
return {
|
|
20
|
+
success: false as const,
|
|
21
|
+
error: message,
|
|
22
|
+
};
|
|
23
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { initTRPC } from "@trpc/server";
|
|
2
|
+
import type { AppContext } from "./context.server";
|
|
3
|
+
|
|
4
|
+
export const {
|
|
5
|
+
router,
|
|
6
|
+
procedure,
|
|
7
|
+
middleware,
|
|
8
|
+
mergeRouters,
|
|
9
|
+
createCallerFactory,
|
|
10
|
+
} = initTRPC.context<AppContext>().create();
|
|
11
|
+
|
|
12
|
+
export const createCacheMiddleware = (seconds: number) =>
|
|
13
|
+
middleware(async ({ path, ctx, next }) => {
|
|
14
|
+
// tRPC batches multiple requests into a single network call.
|
|
15
|
+
// The `path` is used as key to find the least max age among all paths for caching
|
|
16
|
+
ctx.trpcCache.setMaxAge(path, seconds);
|
|
17
|
+
|
|
18
|
+
return next({ ctx });
|
|
19
|
+
});
|
package/src/index.server.ts
CHANGED
|
@@ -2,7 +2,19 @@ export type { SharedRouter } from "./shared/shared-router";
|
|
|
2
2
|
export { createTrpcProxyServiceClient } from "./shared/client";
|
|
3
3
|
|
|
4
4
|
export type { AppContext } from "./context/context.server";
|
|
5
|
-
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
AuthorizationError,
|
|
8
|
+
createErrorResponse,
|
|
9
|
+
} from "./context/errors.server";
|
|
6
10
|
export * as authorizeProject from "./authorize/project.server";
|
|
7
|
-
export
|
|
8
|
-
|
|
11
|
+
export type { AuthPermit } from "./authorize/project.server";
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
router,
|
|
15
|
+
procedure,
|
|
16
|
+
middleware,
|
|
17
|
+
mergeRouters,
|
|
18
|
+
createCacheMiddleware,
|
|
19
|
+
createCallerFactory,
|
|
20
|
+
} from "./context/router.server";
|
package/src/shared/client.ts
CHANGED
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
type SharedRouter,
|
|
6
6
|
} from "./shared-router";
|
|
7
7
|
import { callerLink } from "../trpc-caller-link";
|
|
8
|
-
import fetch from "node-fetch";
|
|
9
8
|
|
|
10
9
|
type SharedClientOptions = {
|
|
11
10
|
url: string;
|
|
@@ -21,7 +20,6 @@ export const createTrpcProxyServiceClient = (
|
|
|
21
20
|
links: [
|
|
22
21
|
httpBatchLink({
|
|
23
22
|
url: options.url,
|
|
24
|
-
fetch: fetch as never,
|
|
25
23
|
headers: () => ({
|
|
26
24
|
Authorization: options.token,
|
|
27
25
|
// We use this header for SaaS preview service discovery proxy
|