@createcms/core 0.1.1
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 +169 -0
- package/dist/ab-edge/index.cjs +214 -0
- package/dist/ab-edge/index.d.cts +121 -0
- package/dist/ab-edge/index.d.ts +121 -0
- package/dist/ab-edge/index.js +205 -0
- package/dist/bin/createcms.js +3082 -0
- package/dist/db.cjs +496 -0
- package/dist/db.d.cts +128 -0
- package/dist/db.d.ts +128 -0
- package/dist/db.js +488 -0
- package/dist/index.cjs +13789 -0
- package/dist/index.d.cts +10277 -0
- package/dist/index.d.ts +10277 -0
- package/dist/index.js +13737 -0
- package/dist/nanoid.cjs +50 -0
- package/dist/nanoid.d.cts +29 -0
- package/dist/nanoid.d.ts +29 -0
- package/dist/nanoid.js +47 -0
- package/dist/next/index.cjs +60 -0
- package/dist/next/index.d.cts +141 -0
- package/dist/next/index.d.ts +141 -0
- package/dist/next/index.js +58 -0
- package/dist/next/middleware.cjs +113 -0
- package/dist/next/middleware.d.cts +77 -0
- package/dist/next/middleware.d.ts +77 -0
- package/dist/next/middleware.js +111 -0
- package/dist/plugins/ab-test/analytics/upstash.cjs +345 -0
- package/dist/plugins/ab-test/analytics/upstash.d.cts +193 -0
- package/dist/plugins/ab-test/analytics/upstash.d.ts +193 -0
- package/dist/plugins/ab-test/analytics/upstash.js +343 -0
- package/dist/plugins/ab-test/client.cjs +686 -0
- package/dist/plugins/ab-test/client.d.cts +233 -0
- package/dist/plugins/ab-test/client.d.ts +233 -0
- package/dist/plugins/ab-test/client.js +684 -0
- package/dist/plugins/ab-test/index.cjs +3400 -0
- package/dist/plugins/ab-test/index.d.cts +1131 -0
- package/dist/plugins/ab-test/index.d.ts +1131 -0
- package/dist/plugins/ab-test/index.js +3367 -0
- package/dist/plugins/client.cjs +20 -0
- package/dist/plugins/client.d.cts +3 -0
- package/dist/plugins/client.d.ts +3 -0
- package/dist/plugins/client.js +3 -0
- package/dist/plugins/consent/client.cjs +315 -0
- package/dist/plugins/consent/client.d.cts +145 -0
- package/dist/plugins/consent/client.d.ts +145 -0
- package/dist/plugins/consent/client.js +313 -0
- package/dist/plugins/consent/index.cjs +267 -0
- package/dist/plugins/consent/index.d.cts +618 -0
- package/dist/plugins/consent/index.d.ts +618 -0
- package/dist/plugins/consent/index.js +258 -0
- package/dist/plugins/i18n/index.cjs +2177 -0
- package/dist/plugins/i18n/index.d.cts +562 -0
- package/dist/plugins/i18n/index.d.ts +562 -0
- package/dist/plugins/i18n/index.js +2150 -0
- package/dist/plugins/media-optimize/index.cjs +315 -0
- package/dist/plugins/media-optimize/index.d.cts +144 -0
- package/dist/plugins/media-optimize/index.d.ts +144 -0
- package/dist/plugins/media-optimize/index.js +311 -0
- package/dist/plugins/multi-tenant/index.cjs +210 -0
- package/dist/plugins/multi-tenant/index.d.cts +431 -0
- package/dist/plugins/multi-tenant/index.d.ts +431 -0
- package/dist/plugins/multi-tenant/index.js +207 -0
- package/dist/plugins/server.cjs +24 -0
- package/dist/plugins/server.d.cts +3 -0
- package/dist/plugins/server.d.ts +3 -0
- package/dist/plugins/server.js +3 -0
- package/dist/react/blocks.cjs +233 -0
- package/dist/react/blocks.d.cts +320 -0
- package/dist/react/blocks.d.ts +320 -0
- package/dist/react/blocks.js +226 -0
- package/dist/react/index.cjs +901 -0
- package/dist/react/index.d.cts +992 -0
- package/dist/react/index.d.ts +992 -0
- package/dist/react/index.js +872 -0
- package/dist/react/tracking.cjs +243 -0
- package/dist/react/tracking.d.cts +364 -0
- package/dist/react/tracking.d.ts +364 -0
- package/dist/react/tracking.js +216 -0
- package/dist/react/variant.cjs +59 -0
- package/dist/react/variant.d.cts +26 -0
- package/dist/react/variant.d.ts +26 -0
- package/dist/react/variant.js +57 -0
- package/package.json +303 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { useState, useRef, useMemo, useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
function isImageFile(file) {
|
|
4
|
+
return file.type.startsWith('image/');
|
|
5
|
+
}
|
|
6
|
+
function loadImage(file) {
|
|
7
|
+
return new Promise((resolve, reject)=>{
|
|
8
|
+
const img = new Image();
|
|
9
|
+
const url = URL.createObjectURL(file);
|
|
10
|
+
img.onload = ()=>{
|
|
11
|
+
URL.revokeObjectURL(url);
|
|
12
|
+
resolve(img);
|
|
13
|
+
};
|
|
14
|
+
img.onerror = ()=>{
|
|
15
|
+
URL.revokeObjectURL(url);
|
|
16
|
+
reject(new Error(`Failed to load image: ${file.name}`));
|
|
17
|
+
};
|
|
18
|
+
img.src = url;
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
function calculateDimensions(width, height, maxSize) {
|
|
22
|
+
if (width <= maxSize && height <= maxSize) {
|
|
23
|
+
return {
|
|
24
|
+
width,
|
|
25
|
+
height
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const ratio = width / height;
|
|
29
|
+
if (width > height) {
|
|
30
|
+
return {
|
|
31
|
+
width: maxSize,
|
|
32
|
+
height: Math.round(maxSize / ratio)
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
width: Math.round(maxSize * ratio),
|
|
37
|
+
height: maxSize
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function drawToCanvas(img, width, height) {
|
|
41
|
+
const canvas = document.createElement('canvas');
|
|
42
|
+
canvas.width = width;
|
|
43
|
+
canvas.height = height;
|
|
44
|
+
const ctx = canvas.getContext('2d');
|
|
45
|
+
if (!ctx) throw new Error('Failed to get canvas 2d context');
|
|
46
|
+
ctx.imageSmoothingEnabled = true;
|
|
47
|
+
ctx.imageSmoothingQuality = 'high';
|
|
48
|
+
ctx.drawImage(img, 0, 0, width, height);
|
|
49
|
+
return canvas;
|
|
50
|
+
}
|
|
51
|
+
function canvasToBlobAsync(canvas, mimeType, quality) {
|
|
52
|
+
return new Promise((resolve, reject)=>{
|
|
53
|
+
canvas.toBlob((blob)=>blob ? resolve(blob) : reject(new Error(`canvas.toBlob failed for ${mimeType}`)), mimeType, quality);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
let _nativeWebpSupported = null;
|
|
57
|
+
async function supportsNativeWebp() {
|
|
58
|
+
if (_nativeWebpSupported !== null) return _nativeWebpSupported;
|
|
59
|
+
const c = document.createElement('canvas');
|
|
60
|
+
c.width = 1;
|
|
61
|
+
c.height = 1;
|
|
62
|
+
const blob = await new Promise((r)=>c.toBlob((b)=>r(b), 'image/webp', 0.5));
|
|
63
|
+
_nativeWebpSupported = blob?.type === 'image/webp';
|
|
64
|
+
return _nativeWebpSupported;
|
|
65
|
+
}
|
|
66
|
+
async function encodeWebp(canvas, quality) {
|
|
67
|
+
if (await supportsNativeWebp()) {
|
|
68
|
+
return canvasToBlobAsync(canvas, 'image/webp', quality);
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const { encode } = await import(/* webpackIgnore: true */ '@jsquash/webp');
|
|
72
|
+
const ctx = canvas.getContext('2d');
|
|
73
|
+
if (!ctx) throw new Error('Failed to get canvas 2d context');
|
|
74
|
+
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
75
|
+
const buffer = await encode(imageData, {
|
|
76
|
+
quality: quality * 100
|
|
77
|
+
});
|
|
78
|
+
return new Blob([
|
|
79
|
+
buffer
|
|
80
|
+
], {
|
|
81
|
+
type: 'image/webp'
|
|
82
|
+
});
|
|
83
|
+
} catch {
|
|
84
|
+
throw new Error('WebP encoding not supported natively and @jsquash/webp is not installed. ' + 'Install it as a dependency: npm install @jsquash/webp');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function inferMimeFromName(name) {
|
|
88
|
+
const ext = name.split('.').pop()?.toLowerCase();
|
|
89
|
+
const map = {
|
|
90
|
+
png: 'image/png',
|
|
91
|
+
jpg: 'image/jpeg',
|
|
92
|
+
jpeg: 'image/jpeg',
|
|
93
|
+
gif: 'image/gif',
|
|
94
|
+
webp: 'image/webp',
|
|
95
|
+
svg: 'image/svg+xml',
|
|
96
|
+
bmp: 'image/bmp',
|
|
97
|
+
avif: 'image/avif'
|
|
98
|
+
};
|
|
99
|
+
return ext && map[ext] || 'image/jpeg';
|
|
100
|
+
}
|
|
101
|
+
function replaceExtension(filename, newExt) {
|
|
102
|
+
const dot = filename.lastIndexOf('.');
|
|
103
|
+
const base = dot > 0 ? filename.slice(0, dot) : filename;
|
|
104
|
+
return `${base}.${newExt}`;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Optimize an image file on the client before uploading.
|
|
108
|
+
*
|
|
109
|
+
* - **Resize**: Downscale if either dimension exceeds `config.resize.maxSize`
|
|
110
|
+
* - **Compress**: Reduce quality via `config.compress.quality` (1-100)
|
|
111
|
+
* - **Convert**: Convert to WebP when `config.convert.format` is set
|
|
112
|
+
* - **storeOriginal**: When `convert.storeOriginal` is true, also returns an
|
|
113
|
+
* `originalVariant` -- same resize + compress but kept in the original format
|
|
114
|
+
*
|
|
115
|
+
* Non-image files are returned unchanged with `optimized: false`.
|
|
116
|
+
*/ async function optimizeImage(file, config) {
|
|
117
|
+
if (!isImageFile(file)) {
|
|
118
|
+
return {
|
|
119
|
+
file,
|
|
120
|
+
optimized: false
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const hasResize = !!config.resize?.maxSize;
|
|
124
|
+
const hasCompress = config.compress?.quality != null;
|
|
125
|
+
const hasConvert = config.convert?.format === 'webp';
|
|
126
|
+
if (!hasResize && !hasCompress && !hasConvert) {
|
|
127
|
+
return {
|
|
128
|
+
file,
|
|
129
|
+
optimized: false
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const img = await loadImage(file);
|
|
133
|
+
const maxSize = config.resize?.maxSize ?? Math.max(img.naturalWidth, img.naturalHeight);
|
|
134
|
+
const quality = config.compress?.quality != null ? config.compress.quality / 100 : 0.8;
|
|
135
|
+
const { width, height } = calculateDimensions(img.naturalWidth, img.naturalHeight, maxSize);
|
|
136
|
+
const canvas = drawToCanvas(img, width, height);
|
|
137
|
+
let originalVariant;
|
|
138
|
+
const originalMime = file.type || inferMimeFromName(file.name);
|
|
139
|
+
if (hasConvert && config.convert.storeOriginal) {
|
|
140
|
+
const originalBlob = await canvasToBlobAsync(canvas, originalMime, quality);
|
|
141
|
+
originalVariant = new File([
|
|
142
|
+
originalBlob
|
|
143
|
+
], file.name, {
|
|
144
|
+
type: originalMime,
|
|
145
|
+
lastModified: Date.now()
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
let primaryBlob;
|
|
149
|
+
let primaryName;
|
|
150
|
+
let primaryType;
|
|
151
|
+
if (hasConvert) {
|
|
152
|
+
primaryBlob = await encodeWebp(canvas, quality);
|
|
153
|
+
primaryName = replaceExtension(file.name, 'webp');
|
|
154
|
+
primaryType = 'image/webp';
|
|
155
|
+
} else {
|
|
156
|
+
primaryBlob = await canvasToBlobAsync(canvas, originalMime, quality);
|
|
157
|
+
primaryName = file.name;
|
|
158
|
+
primaryType = originalMime;
|
|
159
|
+
}
|
|
160
|
+
const primaryFile = new File([
|
|
161
|
+
primaryBlob
|
|
162
|
+
], primaryName, {
|
|
163
|
+
type: primaryType,
|
|
164
|
+
lastModified: Date.now()
|
|
165
|
+
});
|
|
166
|
+
return {
|
|
167
|
+
file: primaryFile,
|
|
168
|
+
originalVariant,
|
|
169
|
+
optimized: true
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const INITIAL_STATE = {
|
|
174
|
+
results: null,
|
|
175
|
+
isOptimizing: false,
|
|
176
|
+
error: null
|
|
177
|
+
};
|
|
178
|
+
/**
|
|
179
|
+
* React hook that optimizes image files on the client.
|
|
180
|
+
*
|
|
181
|
+
* Accepts a single `File` or `File[]` and an `OptimizationConfig`.
|
|
182
|
+
* Runs `optimizeImage` automatically whenever the input reference changes.
|
|
183
|
+
* Non-image files pass through with `optimized: false`.
|
|
184
|
+
*
|
|
185
|
+
* ```tsx
|
|
186
|
+
* const { results, isOptimizing, error } = cmsClient.optimize.useOptimize(file, {
|
|
187
|
+
* compress: { quality: 80 },
|
|
188
|
+
* resize: { maxSize: 1200 },
|
|
189
|
+
* });
|
|
190
|
+
* ```
|
|
191
|
+
*/ function useOptimize(input, config) {
|
|
192
|
+
const [state, setState] = useState(INITIAL_STATE);
|
|
193
|
+
const abortRef = useRef(false);
|
|
194
|
+
const runIdRef = useRef(0);
|
|
195
|
+
const files = useMemo(()=>Array.isArray(input) ? input : [
|
|
196
|
+
input
|
|
197
|
+
], // Stabilize: same File instances → same array
|
|
198
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
199
|
+
[
|
|
200
|
+
Array.isArray(input) ? input.length : input
|
|
201
|
+
]);
|
|
202
|
+
const configKey = JSON.stringify(config);
|
|
203
|
+
const configRef = useRef(config);
|
|
204
|
+
configRef.current = config;
|
|
205
|
+
useEffect(()=>{
|
|
206
|
+
if (files.length === 0) {
|
|
207
|
+
setState((prev)=>prev.results?.length === 0 && !prev.isOptimizing ? prev : {
|
|
208
|
+
results: [],
|
|
209
|
+
isOptimizing: false,
|
|
210
|
+
error: null
|
|
211
|
+
});
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
abortRef.current = false;
|
|
215
|
+
const currentRun = ++runIdRef.current;
|
|
216
|
+
setState({
|
|
217
|
+
results: null,
|
|
218
|
+
isOptimizing: true,
|
|
219
|
+
error: null
|
|
220
|
+
});
|
|
221
|
+
Promise.all(files.map((file)=>optimizeImage(file, configRef.current))).then((results)=>{
|
|
222
|
+
if (abortRef.current || runIdRef.current !== currentRun) return;
|
|
223
|
+
setState({
|
|
224
|
+
results,
|
|
225
|
+
isOptimizing: false,
|
|
226
|
+
error: null
|
|
227
|
+
});
|
|
228
|
+
}).catch((err)=>{
|
|
229
|
+
if (abortRef.current || runIdRef.current !== currentRun) return;
|
|
230
|
+
setState({
|
|
231
|
+
results: null,
|
|
232
|
+
isOptimizing: false,
|
|
233
|
+
error: err instanceof Error ? err.message : 'Optimization failed'
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
return ()=>{
|
|
237
|
+
abortRef.current = true;
|
|
238
|
+
};
|
|
239
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
240
|
+
}, [
|
|
241
|
+
files,
|
|
242
|
+
configKey
|
|
243
|
+
]);
|
|
244
|
+
return state;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const PLUGIN_ID = 'media-optimize';
|
|
248
|
+
const $ERROR_CODES = {
|
|
249
|
+
OPTIMIZATION_FAILED: {
|
|
250
|
+
status: 422,
|
|
251
|
+
message: 'Image optimization failed'
|
|
252
|
+
},
|
|
253
|
+
WEBP_NOT_SUPPORTED: {
|
|
254
|
+
status: 422,
|
|
255
|
+
message: 'WebP encoding is not supported in this browser and @jsquash/webp is not installed'
|
|
256
|
+
},
|
|
257
|
+
CANVAS_CONTEXT_FAILED: {
|
|
258
|
+
status: 500,
|
|
259
|
+
message: 'Failed to acquire canvas 2D context for image processing'
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
/**
|
|
263
|
+
* Client plugin that adds image optimization under its own namespace.
|
|
264
|
+
*
|
|
265
|
+
* Exposes `cmsClient.optimize.useOptimize(file, config)` for client-side
|
|
266
|
+
* image optimization. Optimized files can then be passed to
|
|
267
|
+
* `cmsClient.media.useUploadAssets().upload(files)`.
|
|
268
|
+
*
|
|
269
|
+
* ```ts
|
|
270
|
+
* import { mediaOptimizeClient } from '@createcms/core/plugins/media-optimize';
|
|
271
|
+
*
|
|
272
|
+
* const client = createCMSClient<typeof cms>({
|
|
273
|
+
* baseURL: '/api/cms',
|
|
274
|
+
* plugins: [
|
|
275
|
+
* mediaOptimizeClient({
|
|
276
|
+
* compress: { quality: 90 },
|
|
277
|
+
* resize: { maxSize: 2000 },
|
|
278
|
+
* convert: { format: 'webp', storeOriginal: true },
|
|
279
|
+
* }),
|
|
280
|
+
* ],
|
|
281
|
+
* });
|
|
282
|
+
*
|
|
283
|
+
* // In a component:
|
|
284
|
+
* const { results, isOptimizing } = client.optimize.useOptimize(file, config);
|
|
285
|
+
* ```
|
|
286
|
+
*/ function mediaOptimizeClient(config) {
|
|
287
|
+
return {
|
|
288
|
+
id: PLUGIN_ID,
|
|
289
|
+
$ERROR_CODES,
|
|
290
|
+
async init (_$fetch, _$store) {
|
|
291
|
+
return {
|
|
292
|
+
context: {
|
|
293
|
+
[`${PLUGIN_ID}:config`]: config
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
},
|
|
297
|
+
getActions: ()=>({
|
|
298
|
+
optimize: {
|
|
299
|
+
useOptimize: (input, overrideConfig)=>useOptimize(input, overrideConfig ?? config)
|
|
300
|
+
}
|
|
301
|
+
}),
|
|
302
|
+
atomListeners: [
|
|
303
|
+
{
|
|
304
|
+
matcher: (path)=>path.startsWith('/media/createSignedUpload'),
|
|
305
|
+
signal: '$mediaSignal'
|
|
306
|
+
}
|
|
307
|
+
]
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export { mediaOptimizeClient, optimizeImage, useOptimize };
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
2
|
+
|
|
3
|
+
var betterCall = require('better-call');
|
|
4
|
+
var drizzleOrm = require('drizzle-orm');
|
|
5
|
+
|
|
6
|
+
function definePluginSchema(schema) {
|
|
7
|
+
return {
|
|
8
|
+
...schema
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Plugin schema that adds the `tenantSlug` column and tenant-scoped indexes
|
|
14
|
+
* to the core tables. The column does not exist in the core schema — it is
|
|
15
|
+
* entirely owned by this plugin.
|
|
16
|
+
*/ const multiTenantSchema = definePluginSchema({
|
|
17
|
+
extend: {
|
|
18
|
+
roots: {
|
|
19
|
+
columns: {
|
|
20
|
+
tenantSlug: {
|
|
21
|
+
type: 'text',
|
|
22
|
+
notNull: true
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
indexes: {
|
|
26
|
+
tenantCollectionIdx: {
|
|
27
|
+
columns: [
|
|
28
|
+
'tenantSlug',
|
|
29
|
+
'collection'
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
// Per-tenant slug uniqueness — the DB backstop for the (now per-tenant)
|
|
33
|
+
// app-level validateSlugUniqueness (the core slug index was demoted to
|
|
34
|
+
// non-unique). NESTED-only in practice (a NULL parentRootId is distinct in
|
|
35
|
+
// Postgres, so top-level relies on the app-level check); composes with the
|
|
36
|
+
// i18n plugin's (language,…) unique because each tenant+language has its
|
|
37
|
+
// own parent tree, so neither over-constrains the other.
|
|
38
|
+
tenantRootSlugUnique: {
|
|
39
|
+
columns: [
|
|
40
|
+
'tenantSlug',
|
|
41
|
+
'collection',
|
|
42
|
+
'parentRootId',
|
|
43
|
+
'slug'
|
|
44
|
+
],
|
|
45
|
+
unique: true
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
assetFolders: {
|
|
50
|
+
columns: {
|
|
51
|
+
tenantSlug: {
|
|
52
|
+
type: 'text',
|
|
53
|
+
notNull: true
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
indexes: {
|
|
57
|
+
tenantNameUnique: {
|
|
58
|
+
columns: [
|
|
59
|
+
'tenantSlug',
|
|
60
|
+
'parentId',
|
|
61
|
+
'name'
|
|
62
|
+
],
|
|
63
|
+
unique: true
|
|
64
|
+
},
|
|
65
|
+
tenantIdx: {
|
|
66
|
+
columns: [
|
|
67
|
+
'tenantSlug'
|
|
68
|
+
]
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
assets: {
|
|
73
|
+
columns: {
|
|
74
|
+
tenantSlug: {
|
|
75
|
+
type: 'text',
|
|
76
|
+
notNull: true
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
indexes: {
|
|
80
|
+
tenantIdx: {
|
|
81
|
+
columns: [
|
|
82
|
+
'tenantSlug'
|
|
83
|
+
]
|
|
84
|
+
},
|
|
85
|
+
tenantSlugUnique: {
|
|
86
|
+
columns: [
|
|
87
|
+
'tenantSlug',
|
|
88
|
+
'slug'
|
|
89
|
+
],
|
|
90
|
+
unique: true
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
// Redirects have NO core unique index (uniqueness is app-level), so the
|
|
95
|
+
// plugin owns the real per-tenant DB guarantee. PARTIAL (active rows only)
|
|
96
|
+
// mirrors the app-level checks: archiving a redirect frees its source.
|
|
97
|
+
redirects: {
|
|
98
|
+
columns: {
|
|
99
|
+
tenantSlug: {
|
|
100
|
+
type: 'text',
|
|
101
|
+
notNull: true
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
indexes: {
|
|
105
|
+
// NOTE: no per-tenant PATH-source unique. A path can legitimately have a
|
|
106
|
+
// different redirect per language (the i18n plugin adds `language`), and
|
|
107
|
+
// the correct compound key (tenant_slug, language, collection, sourcePath)
|
|
108
|
+
// can't be expressed by either plugin alone — so path-source uniqueness is
|
|
109
|
+
// the app-level authority (assertSourceUnique + the auto-create pre-check,
|
|
110
|
+
// both scope.redirects-aware). Lookup is still indexed below.
|
|
111
|
+
//
|
|
112
|
+
// PAGE-source IS safely per-tenant-unique: sourceRootId is a specific root
|
|
113
|
+
// (a single language), so this never over-constrains under i18n.
|
|
114
|
+
tenantSourceRootUnique: {
|
|
115
|
+
columns: [
|
|
116
|
+
'tenantSlug',
|
|
117
|
+
'sourceRootId'
|
|
118
|
+
],
|
|
119
|
+
unique: true,
|
|
120
|
+
where: 'archived_at IS NULL'
|
|
121
|
+
},
|
|
122
|
+
// Per-tenant lookup/listing (+ the path-source lookup, since the unique
|
|
123
|
+
// that used to cover it is gone).
|
|
124
|
+
tenantCollectionIdx: {
|
|
125
|
+
columns: [
|
|
126
|
+
'tenantSlug',
|
|
127
|
+
'collection'
|
|
128
|
+
]
|
|
129
|
+
},
|
|
130
|
+
tenantSourcePathIdx: {
|
|
131
|
+
columns: [
|
|
132
|
+
'tenantSlug',
|
|
133
|
+
'collection',
|
|
134
|
+
'sourcePath'
|
|
135
|
+
]
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Resolves the tenant slug from the incoming request context.
|
|
144
|
+
* Priority: body.tenantSlug -> query.tenantSlug -> fallback.
|
|
145
|
+
*
|
|
146
|
+
* Use this inside your `authMiddleware` to support per-request tenant
|
|
147
|
+
* overrides (e.g. for admin cross-tenant access) while keeping a
|
|
148
|
+
* sensible default from the user's session.
|
|
149
|
+
*
|
|
150
|
+
* @param ctx - The middleware context (must have `request`)
|
|
151
|
+
* @param fallback - Default tenant slug (e.g. `session.organizationSlug`)
|
|
152
|
+
*/ function resolveTenantSlug(ctx, fallback) {
|
|
153
|
+
return ctx.request?.body?.tenantSlug ?? ctx.request?.query?.tenantSlug ?? fallback;
|
|
154
|
+
}
|
|
155
|
+
const PLUGIN_ID = 'multi-tenant';
|
|
156
|
+
const $ERROR_CODES = {
|
|
157
|
+
TENANT_SLUG_REQUIRED: {
|
|
158
|
+
status: 400,
|
|
159
|
+
message: 'tenantSlug is required -- authMiddleware must return { tenantSlug } when multiTenant plugin is active'
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
function multiTenant() {
|
|
163
|
+
return {
|
|
164
|
+
id: PLUGIN_ID,
|
|
165
|
+
schema: multiTenantSchema,
|
|
166
|
+
$ERROR_CODES,
|
|
167
|
+
init (_ctx) {
|
|
168
|
+
const factory = (mwResult)=>{
|
|
169
|
+
const tenantSlug = mwResult.tenantSlug;
|
|
170
|
+
if (typeof tenantSlug !== 'string' || tenantSlug.length === 0) {
|
|
171
|
+
throw new betterCall.APIError(400, {
|
|
172
|
+
message: $ERROR_CODES.TENANT_SLUG_REQUIRED.message,
|
|
173
|
+
code: 'TENANT_SLUG_REQUIRED'
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
const insertColumns = {
|
|
177
|
+
tenant_slug: tenantSlug
|
|
178
|
+
};
|
|
179
|
+
return {
|
|
180
|
+
roots: {
|
|
181
|
+
where: drizzleOrm.sql`"cms"."roots"."tenant_slug" = ${tenantSlug}`,
|
|
182
|
+
insertColumns
|
|
183
|
+
},
|
|
184
|
+
assets: {
|
|
185
|
+
where: drizzleOrm.sql`"cms"."assets"."tenant_slug" = ${tenantSlug}`,
|
|
186
|
+
insertColumns
|
|
187
|
+
},
|
|
188
|
+
assetFolders: {
|
|
189
|
+
where: drizzleOrm.sql`"cms"."asset_folders"."tenant_slug" = ${tenantSlug}`,
|
|
190
|
+
insertColumns
|
|
191
|
+
},
|
|
192
|
+
redirects: {
|
|
193
|
+
where: drizzleOrm.sql`"cms"."redirects"."tenant_slug" = ${tenantSlug}`,
|
|
194
|
+
insertColumns
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
};
|
|
198
|
+
return {
|
|
199
|
+
context: {
|
|
200
|
+
scopeConditions: [
|
|
201
|
+
factory
|
|
202
|
+
]
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
exports.multiTenant = multiTenant;
|
|
210
|
+
exports.resolveTenantSlug = resolveTenantSlug;
|