@cedarjs/tenancy 7.0.0-canary.3092
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 +98 -0
- package/dist/auth.d.ts +28 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +47 -0
- package/dist/context.d.ts +121 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +84 -0
- package/dist/errors.d.ts +10 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/prismaExtension.d.ts +89 -0
- package/dist/prismaExtension.d.ts.map +1 -0
- package/dist/prismaExtension.js +583 -0
- package/dist/web/OrgContext.d.ts +8 -0
- package/dist/web/OrgContext.d.ts.map +1 -0
- package/dist/web/OrgContext.js +7 -0
- package/dist/web/OrgScope.d.ts +29 -0
- package/dist/web/OrgScope.d.ts.map +1 -0
- package/dist/web/OrgScope.js +107 -0
- package/dist/web/getMemberships.d.ts +8 -0
- package/dist/web/getMemberships.d.ts.map +1 -0
- package/dist/web/getMemberships.js +25 -0
- package/dist/web/hasOrgRole.d.ts +12 -0
- package/dist/web/hasOrgRole.d.ts.map +1 -0
- package/dist/web/hasOrgRole.js +16 -0
- package/dist/web/index.d.ts +9 -0
- package/dist/web/index.d.ts.map +1 -0
- package/dist/web/index.js +14 -0
- package/dist/web/orgClients.d.ts +27 -0
- package/dist/web/orgClients.d.ts.map +1 -0
- package/dist/web/orgClients.js +37 -0
- package/dist/web/types.d.ts +36 -0
- package/dist/web/types.d.ts.map +1 -0
- package/dist/web/types.js +0 -0
- package/dist/web/useCurrentOrg.d.ts +10 -0
- package/dist/web/useCurrentOrg.d.ts.map +1 -0
- package/dist/web/useCurrentOrg.js +12 -0
- package/package.json +105 -0
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
import { Prisma as PrismaExtension } from "@prisma/client/extension";
|
|
2
|
+
import { context } from "@cedarjs/context";
|
|
3
|
+
import { getAsyncStoreInstance } from "@cedarjs/context/dist/store.js";
|
|
4
|
+
import { getCurrentOrg } from "./context.js";
|
|
5
|
+
import { TenantScopeError } from "./errors.js";
|
|
6
|
+
function getRuntimeDataModel(client) {
|
|
7
|
+
const candidate = client;
|
|
8
|
+
if (!candidate._runtimeDataModel || typeof candidate._runtimeDataModel !== "object" || typeof candidate._runtimeDataModel.models !== "object") {
|
|
9
|
+
throw new Error(
|
|
10
|
+
"@cedarjs/tenancy could not find a `_runtimeDataModel` on this Prisma Client. createTenancyExtension() reads it to discover relations between models; check that the installed `@prisma/client` version supports Client Extensions (`$extends`)."
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
return candidate._runtimeDataModel;
|
|
14
|
+
}
|
|
15
|
+
function getInlineSchemaText(client) {
|
|
16
|
+
const candidate = client;
|
|
17
|
+
const inlineSchema = candidate._engineConfig?.inlineSchema;
|
|
18
|
+
return typeof inlineSchema === "string" ? inlineSchema : void 0;
|
|
19
|
+
}
|
|
20
|
+
function findModelBodyRange(schemaText, searchFrom) {
|
|
21
|
+
const headerPattern = /model\s+(\w+)\s*\{/g;
|
|
22
|
+
headerPattern.lastIndex = searchFrom;
|
|
23
|
+
const headerMatch = headerPattern.exec(schemaText);
|
|
24
|
+
if (!headerMatch) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
const modelName = headerMatch[1];
|
|
28
|
+
const bodyStart = headerMatch.index + headerMatch[0].length;
|
|
29
|
+
let depth = 1;
|
|
30
|
+
let index = bodyStart;
|
|
31
|
+
let quote;
|
|
32
|
+
for (; index < schemaText.length && depth > 0; index++) {
|
|
33
|
+
const char = schemaText[index];
|
|
34
|
+
if (quote) {
|
|
35
|
+
if (char === "\\") {
|
|
36
|
+
index++;
|
|
37
|
+
} else if (char === quote) {
|
|
38
|
+
quote = void 0;
|
|
39
|
+
}
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (char === '"' || char === "'") {
|
|
43
|
+
quote = char;
|
|
44
|
+
} else if (char === "/" && schemaText[index + 1] === "/") {
|
|
45
|
+
const newlineIndex = schemaText.indexOf("\n", index);
|
|
46
|
+
index = newlineIndex === -1 ? schemaText.length : newlineIndex - 1;
|
|
47
|
+
} else if (char === "{") {
|
|
48
|
+
depth++;
|
|
49
|
+
} else if (char === "}") {
|
|
50
|
+
depth--;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (depth !== 0) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
return { modelName, bodyStart, bodyEnd: index - 1 };
|
|
57
|
+
}
|
|
58
|
+
function parseListFieldsFromSchema(schemaText) {
|
|
59
|
+
const fieldsByModel = /* @__PURE__ */ new Map();
|
|
60
|
+
let searchFrom = 0;
|
|
61
|
+
let block = findModelBodyRange(schemaText, searchFrom);
|
|
62
|
+
while (block !== null) {
|
|
63
|
+
const { modelName, bodyStart, bodyEnd } = block;
|
|
64
|
+
const body = schemaText.slice(bodyStart, bodyEnd);
|
|
65
|
+
const knownFields = /* @__PURE__ */ new Set();
|
|
66
|
+
const listFields = /* @__PURE__ */ new Set();
|
|
67
|
+
for (const rawLine of body.split("\n")) {
|
|
68
|
+
const line = rawLine.split("//")[0].trim();
|
|
69
|
+
if (!line || line.startsWith("@@")) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const [fieldName, fieldType] = line.split(/\s+/);
|
|
73
|
+
if (!fieldName || !fieldType) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
knownFields.add(fieldName);
|
|
77
|
+
if (fieldType.endsWith("[]")) {
|
|
78
|
+
listFields.add(fieldName);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
fieldsByModel.set(modelName, { knownFields, listFields });
|
|
82
|
+
searchFrom = bodyEnd + 1;
|
|
83
|
+
block = findModelBodyRange(schemaText, searchFrom);
|
|
84
|
+
}
|
|
85
|
+
return fieldsByModel;
|
|
86
|
+
}
|
|
87
|
+
function getListFieldsByModel(client) {
|
|
88
|
+
const schemaText = getInlineSchemaText(client);
|
|
89
|
+
return schemaText === void 0 ? void 0 : parseListFieldsFromSchema(schemaText);
|
|
90
|
+
}
|
|
91
|
+
function pascalToCamel(name) {
|
|
92
|
+
return name.length === 0 ? name : name.charAt(0).toLowerCase() + name.slice(1);
|
|
93
|
+
}
|
|
94
|
+
const FRAMEWORK_MODELS = /* @__PURE__ */ new Set(["RW_DataMigration"]);
|
|
95
|
+
function resolveTenantModelNames(modelsConfig, runtimeDataModel) {
|
|
96
|
+
const allModelNames = Object.keys(runtimeDataModel.models).filter(
|
|
97
|
+
(name) => !FRAMEWORK_MODELS.has(name)
|
|
98
|
+
);
|
|
99
|
+
const allAccessorNames = new Set(allModelNames.map(pascalToCamel));
|
|
100
|
+
if (Array.isArray(modelsConfig)) {
|
|
101
|
+
const configured = new Set(modelsConfig.map((name) => String(name)));
|
|
102
|
+
const unknown2 = [...configured].filter(
|
|
103
|
+
(name) => !allAccessorNames.has(name)
|
|
104
|
+
);
|
|
105
|
+
if (unknown2.length > 0) {
|
|
106
|
+
throw new TenantScopeError(
|
|
107
|
+
`Unknown model${unknown2.length > 1 ? "s" : ""} in tenancy config.models: ${unknown2.join(", ")}. Expected one of: ${[...allAccessorNames].join(", ")}.`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return new Set(
|
|
111
|
+
allModelNames.filter((name) => configured.has(pascalToCamel(name)))
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
const excluded = new Set(modelsConfig.allExcept.map((name) => String(name)));
|
|
115
|
+
const unknown = [...excluded].filter((name) => !allAccessorNames.has(name));
|
|
116
|
+
if (unknown.length > 0) {
|
|
117
|
+
throw new TenantScopeError(
|
|
118
|
+
`Unknown model${unknown.length > 1 ? "s" : ""} in tenancy config.models.allExcept: ${unknown.join(", ")}. Expected one of: ${[...allAccessorNames].join(", ")}.`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return new Set(
|
|
122
|
+
allModelNames.filter((name) => !excluded.has(pascalToCamel(name)))
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
function tenantScopeError(model) {
|
|
126
|
+
const inRequest = getAsyncStoreInstance().getStore() !== void 0;
|
|
127
|
+
if (!inRequest) {
|
|
128
|
+
return new TenantScopeError(
|
|
129
|
+
`"${model}" is tenant-owned, and this code is running outside a request, so there is no organization in scope. Use \`db.$forOrg(organizationId)\` when the organization is known (a job or a webhook), or \`db.$withoutTenant()\` when the code works across organizations on purpose (a seed, a data migration, an admin task).`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (!context.currentUser) {
|
|
133
|
+
return new TenantScopeError(
|
|
134
|
+
`"${model}" is tenant-owned, and this request has nobody signed in, so no organization was resolved for it. Read one organization's data for an anonymous visitor with \`db.$forOrg(organizationId)\`, which names the organization explicitly.`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return new TenantScopeError(
|
|
138
|
+
`"${model}" is tenant-owned, and the signed-in user has no organization in scope for this request. Either the request carried no \`cedar-org\` header, which queries made outside \`OrgScope\` and functions not wrapped in \`withTenancy\` do not, or the user has no membership yet and needs one before they can read tenant-owned data.`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
function requireTenantId(ctx, model) {
|
|
142
|
+
const tenantId = ctx.getTenantId();
|
|
143
|
+
if (tenantId === void 0) {
|
|
144
|
+
throw tenantScopeError(model);
|
|
145
|
+
}
|
|
146
|
+
return tenantId;
|
|
147
|
+
}
|
|
148
|
+
function isPlainObject(value) {
|
|
149
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
150
|
+
}
|
|
151
|
+
function findField(runtimeDataModel, modelName, fieldName) {
|
|
152
|
+
return runtimeDataModel.models[modelName]?.fields.find(
|
|
153
|
+
(field) => field.name === fieldName
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
function isRelationField(field) {
|
|
157
|
+
return field?.kind === "object";
|
|
158
|
+
}
|
|
159
|
+
function isListByForeignKeyHeuristic(ctx, modelName, field) {
|
|
160
|
+
const foreignKeyFieldName = `${field.name}Id`;
|
|
161
|
+
const ownFields = ctx.runtimeDataModel.models[modelName]?.fields ?? [];
|
|
162
|
+
const hasOwnForeignKey = ownFields.some(
|
|
163
|
+
(f) => f.kind === "scalar" && f.name === foreignKeyFieldName
|
|
164
|
+
);
|
|
165
|
+
return !hasOwnForeignKey;
|
|
166
|
+
}
|
|
167
|
+
function isListRelationField(ctx, modelName, field) {
|
|
168
|
+
const parsedModel = ctx.listFieldsByModel?.get(modelName);
|
|
169
|
+
if (parsedModel?.knownFields.has(field.name)) {
|
|
170
|
+
return parsedModel.listFields.has(field.name);
|
|
171
|
+
}
|
|
172
|
+
return isListByForeignKeyHeuristic(ctx, modelName, field);
|
|
173
|
+
}
|
|
174
|
+
function mapRows(value, fn) {
|
|
175
|
+
if (value === void 0 || value === null) {
|
|
176
|
+
return value;
|
|
177
|
+
}
|
|
178
|
+
if (Array.isArray(value)) {
|
|
179
|
+
return value.map(fn);
|
|
180
|
+
}
|
|
181
|
+
return fn(value);
|
|
182
|
+
}
|
|
183
|
+
function mergeWhereWithTenant(where, tenantField, tenantId) {
|
|
184
|
+
if (where === void 0 || where === null) {
|
|
185
|
+
return { [tenantField]: tenantId };
|
|
186
|
+
}
|
|
187
|
+
return { AND: [where, { [tenantField]: tenantId }] };
|
|
188
|
+
}
|
|
189
|
+
function injectWhereRow(ctx, model, where) {
|
|
190
|
+
return mapRows(where, (row) => {
|
|
191
|
+
if (!isPlainObject(row)) {
|
|
192
|
+
return row;
|
|
193
|
+
}
|
|
194
|
+
if (!ctx.tenantModels.has(model)) {
|
|
195
|
+
return row;
|
|
196
|
+
}
|
|
197
|
+
const tenantId = requireTenantId(ctx, model);
|
|
198
|
+
return { ...row, [ctx.tenantField]: tenantId };
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
function injectCreateRow(ctx, model, data, options = {}) {
|
|
202
|
+
return mapRows(data, (row) => injectCreateRowSingle(ctx, model, row, options));
|
|
203
|
+
}
|
|
204
|
+
function injectCreateRowSingle(ctx, model, row, options) {
|
|
205
|
+
if (!isPlainObject(row)) {
|
|
206
|
+
return row;
|
|
207
|
+
}
|
|
208
|
+
const next = { ...row };
|
|
209
|
+
if (ctx.tenantModels.has(model)) {
|
|
210
|
+
const tenantId = requireTenantId(ctx, model);
|
|
211
|
+
const existing = next[ctx.tenantField];
|
|
212
|
+
if (existing !== void 0 && existing !== tenantId) {
|
|
213
|
+
throw new TenantScopeError(
|
|
214
|
+
`Cannot set "${model}.${ctx.tenantField}" to a different organization than the current tenant.`
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
next[ctx.tenantField] = tenantId;
|
|
218
|
+
}
|
|
219
|
+
if (!options.shallow) {
|
|
220
|
+
for (const [key, value] of Object.entries(next)) {
|
|
221
|
+
const field = findField(ctx.runtimeDataModel, model, key);
|
|
222
|
+
if (isRelationField(field) && isPlainObject(value)) {
|
|
223
|
+
next[key] = processRelationOperations(ctx, field, value);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return next;
|
|
228
|
+
}
|
|
229
|
+
function validateAndRecurseUpdateData(ctx, model, data) {
|
|
230
|
+
if (!isPlainObject(data)) {
|
|
231
|
+
return data;
|
|
232
|
+
}
|
|
233
|
+
const next = { ...data };
|
|
234
|
+
if (ctx.tenantModels.has(model) && ctx.tenantField in next) {
|
|
235
|
+
const raw = next[ctx.tenantField];
|
|
236
|
+
const value = isPlainObject(raw) && "set" in raw ? raw.set : raw;
|
|
237
|
+
const tenantId = requireTenantId(ctx, model);
|
|
238
|
+
if (value !== tenantId) {
|
|
239
|
+
throw new TenantScopeError(
|
|
240
|
+
`Cannot change "${model}.${ctx.tenantField}" to a different organization.`
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
for (const [key, value] of Object.entries(next)) {
|
|
245
|
+
const field = findField(ctx.runtimeDataModel, model, key);
|
|
246
|
+
if (isRelationField(field) && isPlainObject(value)) {
|
|
247
|
+
next[key] = processRelationOperations(ctx, field, value);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return next;
|
|
251
|
+
}
|
|
252
|
+
function processUpdateEntry(ctx, model, entry) {
|
|
253
|
+
if (!isPlainObject(entry)) {
|
|
254
|
+
return entry;
|
|
255
|
+
}
|
|
256
|
+
if ("where" in entry && "data" in entry) {
|
|
257
|
+
return {
|
|
258
|
+
...entry,
|
|
259
|
+
where: injectWhereRow(ctx, model, entry.where),
|
|
260
|
+
data: validateAndRecurseUpdateData(ctx, model, entry.data)
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
return validateAndRecurseUpdateData(ctx, model, entry);
|
|
264
|
+
}
|
|
265
|
+
function processUpdateManyEntry(ctx, model, entry) {
|
|
266
|
+
if (!isPlainObject(entry)) {
|
|
267
|
+
return entry;
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
...entry,
|
|
271
|
+
where: injectWhereRow(ctx, model, entry.where),
|
|
272
|
+
data: validateAndRecurseUpdateData(ctx, model, entry.data)
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
function processUpsertEntry(ctx, model, entry) {
|
|
276
|
+
if (!isPlainObject(entry)) {
|
|
277
|
+
return entry;
|
|
278
|
+
}
|
|
279
|
+
const next = { ...entry };
|
|
280
|
+
if ("where" in next) {
|
|
281
|
+
next.where = injectWhereRow(ctx, model, next.where);
|
|
282
|
+
}
|
|
283
|
+
if ("create" in next) {
|
|
284
|
+
next.create = injectCreateRow(ctx, model, next.create);
|
|
285
|
+
}
|
|
286
|
+
if ("update" in next) {
|
|
287
|
+
next.update = validateAndRecurseUpdateData(ctx, model, next.update);
|
|
288
|
+
}
|
|
289
|
+
return next;
|
|
290
|
+
}
|
|
291
|
+
function processRelationOperations(ctx, field, opsValue) {
|
|
292
|
+
const targetModel = field.type;
|
|
293
|
+
const next = { ...opsValue };
|
|
294
|
+
if ("create" in next) {
|
|
295
|
+
next.create = injectCreateRow(ctx, targetModel, next.create);
|
|
296
|
+
}
|
|
297
|
+
if ("createMany" in next && isPlainObject(next.createMany)) {
|
|
298
|
+
const createMany = next.createMany;
|
|
299
|
+
next.createMany = {
|
|
300
|
+
...createMany,
|
|
301
|
+
data: injectCreateRow(ctx, targetModel, createMany.data, {
|
|
302
|
+
shallow: true
|
|
303
|
+
})
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
if ("connect" in next) {
|
|
307
|
+
next.connect = injectWhereRow(ctx, targetModel, next.connect);
|
|
308
|
+
}
|
|
309
|
+
if ("connectOrCreate" in next) {
|
|
310
|
+
next.connectOrCreate = mapRows(next.connectOrCreate, (entry) => {
|
|
311
|
+
if (!isPlainObject(entry)) {
|
|
312
|
+
return entry;
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
...entry,
|
|
316
|
+
where: injectWhereRow(ctx, targetModel, entry.where),
|
|
317
|
+
create: injectCreateRow(ctx, targetModel, entry.create)
|
|
318
|
+
};
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
if ("disconnect" in next) {
|
|
322
|
+
next.disconnect = injectWhereRow(ctx, targetModel, next.disconnect);
|
|
323
|
+
}
|
|
324
|
+
if ("set" in next) {
|
|
325
|
+
next.set = injectWhereRow(ctx, targetModel, next.set);
|
|
326
|
+
}
|
|
327
|
+
if ("delete" in next) {
|
|
328
|
+
next.delete = injectWhereRow(ctx, targetModel, next.delete);
|
|
329
|
+
}
|
|
330
|
+
if ("deleteMany" in next) {
|
|
331
|
+
next.deleteMany = injectWhereRow(ctx, targetModel, next.deleteMany);
|
|
332
|
+
}
|
|
333
|
+
if ("update" in next) {
|
|
334
|
+
next.update = mapRows(
|
|
335
|
+
next.update,
|
|
336
|
+
(entry) => processUpdateEntry(ctx, targetModel, entry)
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
if ("updateMany" in next) {
|
|
340
|
+
next.updateMany = mapRows(
|
|
341
|
+
next.updateMany,
|
|
342
|
+
(entry) => processUpdateManyEntry(ctx, targetModel, entry)
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
if ("upsert" in next) {
|
|
346
|
+
next.upsert = mapRows(
|
|
347
|
+
next.upsert,
|
|
348
|
+
(entry) => processUpsertEntry(ctx, targetModel, entry)
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
return next;
|
|
352
|
+
}
|
|
353
|
+
function walkRelationIncludeValue(ctx, model, field, value) {
|
|
354
|
+
const targetModel = field.type;
|
|
355
|
+
const shouldScope = ctx.tenantModels.has(targetModel) && isListRelationField(ctx, model, field);
|
|
356
|
+
if (value === true) {
|
|
357
|
+
if (!shouldScope) {
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
const tenantId = requireTenantId(ctx, targetModel);
|
|
361
|
+
return { where: { [ctx.tenantField]: tenantId } };
|
|
362
|
+
}
|
|
363
|
+
if (!isPlainObject(value)) {
|
|
364
|
+
return value;
|
|
365
|
+
}
|
|
366
|
+
const next = { ...value };
|
|
367
|
+
if (shouldScope) {
|
|
368
|
+
const tenantId = requireTenantId(ctx, targetModel);
|
|
369
|
+
next.where = mergeWhereWithTenant(next.where, ctx.tenantField, tenantId);
|
|
370
|
+
}
|
|
371
|
+
if ("include" in next) {
|
|
372
|
+
next.include = walkIncludeOrSelect(ctx, targetModel, next.include);
|
|
373
|
+
}
|
|
374
|
+
if ("select" in next) {
|
|
375
|
+
next.select = walkIncludeOrSelect(ctx, targetModel, next.select);
|
|
376
|
+
}
|
|
377
|
+
return next;
|
|
378
|
+
}
|
|
379
|
+
function expandCountShorthand(ctx, model) {
|
|
380
|
+
const listRelations = (ctx.runtimeDataModel.models[model]?.fields ?? []).filter(
|
|
381
|
+
(field) => isRelationField(field) && isListRelationField(ctx, model, field)
|
|
382
|
+
);
|
|
383
|
+
const scopesAnything = listRelations.some(
|
|
384
|
+
(field) => ctx.tenantModels.has(field.type)
|
|
385
|
+
);
|
|
386
|
+
if (!scopesAnything) {
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
const select = {};
|
|
390
|
+
for (const field of listRelations) {
|
|
391
|
+
select[field.name] = walkRelationIncludeValue(ctx, model, field, true);
|
|
392
|
+
}
|
|
393
|
+
return { select };
|
|
394
|
+
}
|
|
395
|
+
function walkCountSelect(ctx, model, value) {
|
|
396
|
+
if (value === true) {
|
|
397
|
+
return expandCountShorthand(ctx, model);
|
|
398
|
+
}
|
|
399
|
+
if (!isPlainObject(value)) {
|
|
400
|
+
return value;
|
|
401
|
+
}
|
|
402
|
+
const next = { ...value };
|
|
403
|
+
if (isPlainObject(next.select)) {
|
|
404
|
+
const select = { ...next.select };
|
|
405
|
+
for (const [key, fieldValue] of Object.entries(select)) {
|
|
406
|
+
const field = findField(ctx.runtimeDataModel, model, key);
|
|
407
|
+
if (isRelationField(field)) {
|
|
408
|
+
select[key] = walkRelationIncludeValue(ctx, model, field, fieldValue);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
next.select = select;
|
|
412
|
+
}
|
|
413
|
+
return next;
|
|
414
|
+
}
|
|
415
|
+
function walkIncludeOrSelect(ctx, model, node) {
|
|
416
|
+
if (!isPlainObject(node)) {
|
|
417
|
+
return node;
|
|
418
|
+
}
|
|
419
|
+
const next = { ...node };
|
|
420
|
+
for (const [key, value] of Object.entries(next)) {
|
|
421
|
+
if (key === "_count") {
|
|
422
|
+
next._count = walkCountSelect(ctx, model, value);
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
const field = findField(ctx.runtimeDataModel, model, key);
|
|
426
|
+
if (!isRelationField(field)) {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
next[key] = walkRelationIncludeValue(ctx, model, field, value);
|
|
430
|
+
}
|
|
431
|
+
return next;
|
|
432
|
+
}
|
|
433
|
+
const UNIQUE_WHERE_OPERATIONS = /* @__PURE__ */ new Set([
|
|
434
|
+
"findUnique",
|
|
435
|
+
"findUniqueOrThrow",
|
|
436
|
+
"update",
|
|
437
|
+
"delete",
|
|
438
|
+
"upsert"
|
|
439
|
+
]);
|
|
440
|
+
const FILTER_WHERE_OPERATIONS = /* @__PURE__ */ new Set([
|
|
441
|
+
"findFirst",
|
|
442
|
+
"findFirstOrThrow",
|
|
443
|
+
"findMany",
|
|
444
|
+
"count",
|
|
445
|
+
"aggregate",
|
|
446
|
+
"groupBy",
|
|
447
|
+
"updateMany",
|
|
448
|
+
"updateManyAndReturn",
|
|
449
|
+
"deleteMany"
|
|
450
|
+
]);
|
|
451
|
+
const SUPPORTED_OPERATIONS = /* @__PURE__ */ new Set([
|
|
452
|
+
...UNIQUE_WHERE_OPERATIONS,
|
|
453
|
+
...FILTER_WHERE_OPERATIONS,
|
|
454
|
+
"create",
|
|
455
|
+
"createMany",
|
|
456
|
+
"createManyAndReturn"
|
|
457
|
+
]);
|
|
458
|
+
function rewriteOperationArgs(ctx, model, operation, argsIn) {
|
|
459
|
+
if (!SUPPORTED_OPERATIONS.has(operation)) {
|
|
460
|
+
throw new TenantScopeError(
|
|
461
|
+
`Operation '${operation}' is not supported on a tenant-scoped client. Use db.$withoutTenant() for operations that intentionally bypass tenant scoping.`
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
const args = isPlainObject(argsIn) ? { ...argsIn } : {};
|
|
465
|
+
const isTenantOwned = ctx.tenantModels.has(model);
|
|
466
|
+
if (isTenantOwned) {
|
|
467
|
+
if (UNIQUE_WHERE_OPERATIONS.has(operation)) {
|
|
468
|
+
const tenantId = requireTenantId(ctx, model);
|
|
469
|
+
args.where = {
|
|
470
|
+
...isPlainObject(args.where) ? args.where : {},
|
|
471
|
+
[ctx.tenantField]: tenantId
|
|
472
|
+
};
|
|
473
|
+
} else if (FILTER_WHERE_OPERATIONS.has(operation)) {
|
|
474
|
+
const tenantId = requireTenantId(ctx, model);
|
|
475
|
+
args.where = mergeWhereWithTenant(args.where, ctx.tenantField, tenantId);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if ("data" in args) {
|
|
479
|
+
if (operation === "createMany" || operation === "createManyAndReturn") {
|
|
480
|
+
args.data = injectCreateRow(ctx, model, args.data, { shallow: true });
|
|
481
|
+
} else if (operation === "create") {
|
|
482
|
+
args.data = injectCreateRow(ctx, model, args.data);
|
|
483
|
+
} else if (operation === "update" || operation === "updateMany" || operation === "updateManyAndReturn") {
|
|
484
|
+
args.data = validateAndRecurseUpdateData(ctx, model, args.data);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (operation === "upsert") {
|
|
488
|
+
if ("create" in args) {
|
|
489
|
+
args.create = injectCreateRow(ctx, model, args.create);
|
|
490
|
+
}
|
|
491
|
+
if ("update" in args) {
|
|
492
|
+
args.update = validateAndRecurseUpdateData(ctx, model, args.update);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if ("include" in args) {
|
|
496
|
+
args.include = walkIncludeOrSelect(ctx, model, args.include);
|
|
497
|
+
}
|
|
498
|
+
if ("select" in args) {
|
|
499
|
+
args.select = walkIncludeOrSelect(ctx, model, args.select);
|
|
500
|
+
}
|
|
501
|
+
return args;
|
|
502
|
+
}
|
|
503
|
+
const RAW_QUERY_MESSAGE = "Raw SQL is not allowed on a tenant-scoped client. Use db.$withoutTenant() for raw queries that intentionally bypass tenant scoping.";
|
|
504
|
+
function throwRawQueryBlocked() {
|
|
505
|
+
throw new TenantScopeError(RAW_QUERY_MESSAGE);
|
|
506
|
+
}
|
|
507
|
+
function createTenancyExtension(config) {
|
|
508
|
+
const tenantField = config.tenantField ?? "organizationId";
|
|
509
|
+
const getTenantId = config.getTenantId ?? (() => getCurrentOrg()?.id);
|
|
510
|
+
return PrismaExtension.defineExtension((client) => {
|
|
511
|
+
const runtimeDataModel = getRuntimeDataModel(client);
|
|
512
|
+
const tenantModels = resolveTenantModelNames(
|
|
513
|
+
config.models,
|
|
514
|
+
runtimeDataModel
|
|
515
|
+
);
|
|
516
|
+
const listFieldsByModel = getListFieldsByModel(client);
|
|
517
|
+
function buildScopedClient(tenantIdSource) {
|
|
518
|
+
const ctx = {
|
|
519
|
+
tenantField,
|
|
520
|
+
tenantModels,
|
|
521
|
+
runtimeDataModel,
|
|
522
|
+
listFieldsByModel,
|
|
523
|
+
getTenantId: tenantIdSource
|
|
524
|
+
};
|
|
525
|
+
return client.$extends({
|
|
526
|
+
name: "cedarjs-tenancy",
|
|
527
|
+
query: {
|
|
528
|
+
$allModels: {
|
|
529
|
+
// Prisma's extension callback args are opaque (`JsArgs`, a
|
|
530
|
+
// union over every model's every operation's args type); this
|
|
531
|
+
// extension's whole job is to handle them generically via the
|
|
532
|
+
// runtime data model, so they're read as `unknown` and rebuilt
|
|
533
|
+
// as plain objects rather than cast to a specific args type.
|
|
534
|
+
async $allOperations({ model, operation, args, query }) {
|
|
535
|
+
const rewritten = rewriteOperationArgs(
|
|
536
|
+
ctx,
|
|
537
|
+
model,
|
|
538
|
+
operation,
|
|
539
|
+
args
|
|
540
|
+
);
|
|
541
|
+
return query(rewritten);
|
|
542
|
+
}
|
|
543
|
+
},
|
|
544
|
+
$queryRaw: throwRawQueryBlocked,
|
|
545
|
+
$queryRawUnsafe: throwRawQueryBlocked,
|
|
546
|
+
$executeRaw: throwRawQueryBlocked,
|
|
547
|
+
$executeRawUnsafe: throwRawQueryBlocked
|
|
548
|
+
},
|
|
549
|
+
client: {
|
|
550
|
+
$queryRaw: throwRawQueryBlocked,
|
|
551
|
+
$queryRawUnsafe: throwRawQueryBlocked,
|
|
552
|
+
$executeRaw: throwRawQueryBlocked,
|
|
553
|
+
$executeRawUnsafe: throwRawQueryBlocked
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
const scopedClient = buildScopedClient(getTenantId);
|
|
558
|
+
return scopedClient.$extends({
|
|
559
|
+
client: {
|
|
560
|
+
/**
|
|
561
|
+
* A client scoped to `organizationId` regardless of request
|
|
562
|
+
* context — for background jobs, webhooks, and other code that
|
|
563
|
+
* knows the tenant but doesn't run inside a request.
|
|
564
|
+
*/
|
|
565
|
+
$forOrg(organizationId) {
|
|
566
|
+
return buildScopedClient(() => organizationId);
|
|
567
|
+
},
|
|
568
|
+
/**
|
|
569
|
+
* An unscoped client for code that intentionally reads or writes
|
|
570
|
+
* across organizations: seeds, data migrations, admin tooling.
|
|
571
|
+
* Nothing here is scoped, and raw SQL is allowed.
|
|
572
|
+
*/
|
|
573
|
+
$withoutTenant() {
|
|
574
|
+
return client;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
export {
|
|
581
|
+
createTenancyExtension,
|
|
582
|
+
parseListFieldsFromSchema
|
|
583
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { OrgContextValue } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Carries the organization `OrgScope` resolved for the current route.
|
|
5
|
+
* Read through `useCurrentOrg()`; only defined under `OrgScope`.
|
|
6
|
+
*/
|
|
7
|
+
export declare const OrgContext: React.Context<OrgContextValue | undefined>;
|
|
8
|
+
//# sourceMappingURL=OrgContext.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"OrgContext.d.ts","sourceRoot":"","sources":["../../src/web/OrgContext.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAEzB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAEjD;;;GAGG;AACH,eAAO,MAAM,UAAU,4CAEtB,CAAA"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { UseAuth } from '@cedarjs/auth';
|
|
3
|
+
export interface OrgScopeProps {
|
|
4
|
+
/** Overrides the `orgSlug` route param. */
|
|
5
|
+
orgSlug?: string;
|
|
6
|
+
/** Rendered when the user has no membership matching the slug. */
|
|
7
|
+
notAMember?: React.ReactNode;
|
|
8
|
+
/**
|
|
9
|
+
* The app's `useAuth`, so `OrgScope` can read `currentUser.memberships`
|
|
10
|
+
* without importing app code. Defaults to `useNoAuth`.
|
|
11
|
+
*/
|
|
12
|
+
useAuth?: UseAuth;
|
|
13
|
+
/**
|
|
14
|
+
* Called by `useCurrentOrg().setOrg` instead of navigating, for apps that
|
|
15
|
+
* select the organization from state rather than a URL segment.
|
|
16
|
+
*/
|
|
17
|
+
onSetOrg?: (idOrSlug: string) => void;
|
|
18
|
+
children: React.ReactNode;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Provides the per-organization Apollo client and `OrgContext` for the
|
|
22
|
+
* organization matching `orgSlug` (a prop, or else the `orgSlug` route
|
|
23
|
+
* param). Renders `notAMember` when the current user has no membership in
|
|
24
|
+
* that organization; otherwise wraps `children` in that organization's own
|
|
25
|
+
* `ApolloProvider`, so every Cell, `useQuery` and `useMutation` under it
|
|
26
|
+
* carries the `cedar-org` header and reads from that organization's cache.
|
|
27
|
+
*/
|
|
28
|
+
export declare function OrgScope({ orgSlug: orgSlugProp, notAMember, useAuth, onSetOrg, children, }: OrgScopeProps): React.JSX.Element;
|
|
29
|
+
//# sourceMappingURL=OrgScope.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"OrgScope.d.ts","sourceRoot":"","sources":["../../src/web/OrgScope.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAIzB,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,eAAe,CAAA;AAe5C,MAAM,WAAW,aAAa;IAC5B,2CAA2C;IAC3C,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,kEAAkE;IAClE,UAAU,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;IAC5B;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB;;;OAGG;IACH,QAAQ,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAA;IACrC,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAA;CAC1B;AAYD;;;;;;;GAOG;AACH,wBAAgB,QAAQ,CAAC,EACvB,OAAO,EAAE,WAAW,EACpB,UAAiB,EACjB,OAAmB,EACnB,QAAQ,EACR,QAAQ,GACT,EAAE,aAAa,GAAG,KAAK,CAAC,GAAG,CAAC,OAAO,CAsInC"}
|