@nexpress/core 0.1.0
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/LICENSE +21 -0
- package/README.md +69 -0
- package/dist/audit-54XLVCWD.js +14 -0
- package/dist/audit-54XLVCWD.js.map +1 -0
- package/dist/auth.d.ts +640 -0
- package/dist/auth.js +94 -0
- package/dist/auth.js.map +1 -0
- package/dist/can-YLUHRJAB.js +19 -0
- package/dist/can-YLUHRJAB.js.map +1 -0
- package/dist/chunk-2G264RCD.js +68 -0
- package/dist/chunk-2G264RCD.js.map +1 -0
- package/dist/chunk-2YDGE7YX.js +92 -0
- package/dist/chunk-2YDGE7YX.js.map +1 -0
- package/dist/chunk-473S4TER.js +538 -0
- package/dist/chunk-473S4TER.js.map +1 -0
- package/dist/chunk-4ZLMEKFX.js +18 -0
- package/dist/chunk-4ZLMEKFX.js.map +1 -0
- package/dist/chunk-55FU6WED.js +179 -0
- package/dist/chunk-55FU6WED.js.map +1 -0
- package/dist/chunk-6YI5K2TI.js +1959 -0
- package/dist/chunk-6YI5K2TI.js.map +1 -0
- package/dist/chunk-BHK3AD3Q.js +41 -0
- package/dist/chunk-BHK3AD3Q.js.map +1 -0
- package/dist/chunk-CRUQBZUF.js +39 -0
- package/dist/chunk-CRUQBZUF.js.map +1 -0
- package/dist/chunk-CTSQ7BRI.js +175 -0
- package/dist/chunk-CTSQ7BRI.js.map +1 -0
- package/dist/chunk-DK2JBJH7.js +81 -0
- package/dist/chunk-DK2JBJH7.js.map +1 -0
- package/dist/chunk-DP2PREDU.js +597 -0
- package/dist/chunk-DP2PREDU.js.map +1 -0
- package/dist/chunk-EQ2Z3KMD.js +24 -0
- package/dist/chunk-EQ2Z3KMD.js.map +1 -0
- package/dist/chunk-FZ7O6DWI.js +305 -0
- package/dist/chunk-FZ7O6DWI.js.map +1 -0
- package/dist/chunk-ISLYFQWL.js +1270 -0
- package/dist/chunk-ISLYFQWL.js.map +1 -0
- package/dist/chunk-JJL74ZPK.js +68 -0
- package/dist/chunk-JJL74ZPK.js.map +1 -0
- package/dist/chunk-JKXAPSU4.js +24 -0
- package/dist/chunk-JKXAPSU4.js.map +1 -0
- package/dist/chunk-KU5M27ZC.js +24 -0
- package/dist/chunk-KU5M27ZC.js.map +1 -0
- package/dist/chunk-LSHHRDVR.js +34 -0
- package/dist/chunk-LSHHRDVR.js.map +1 -0
- package/dist/chunk-M43PGOQY.js +715 -0
- package/dist/chunk-M43PGOQY.js.map +1 -0
- package/dist/chunk-MEJAHXIO.js +150 -0
- package/dist/chunk-MEJAHXIO.js.map +1 -0
- package/dist/chunk-NUCGHWCF.js +101 -0
- package/dist/chunk-NUCGHWCF.js.map +1 -0
- package/dist/chunk-OK5HOCQI.js +845 -0
- package/dist/chunk-OK5HOCQI.js.map +1 -0
- package/dist/chunk-OROPGO65.js +13 -0
- package/dist/chunk-OROPGO65.js.map +1 -0
- package/dist/chunk-PPAS4SZR.js +176 -0
- package/dist/chunk-PPAS4SZR.js.map +1 -0
- package/dist/chunk-PPBWRKO2.js +171 -0
- package/dist/chunk-PPBWRKO2.js.map +1 -0
- package/dist/chunk-PZ5AY32C.js +10 -0
- package/dist/chunk-PZ5AY32C.js.map +1 -0
- package/dist/chunk-QO7LAQZH.js +321 -0
- package/dist/chunk-QO7LAQZH.js.map +1 -0
- package/dist/chunk-QVJ2HCAX.js +225 -0
- package/dist/chunk-QVJ2HCAX.js.map +1 -0
- package/dist/chunk-RIPHIRPP.js +68 -0
- package/dist/chunk-RIPHIRPP.js.map +1 -0
- package/dist/chunk-S27S42QY.js +134 -0
- package/dist/chunk-S27S42QY.js.map +1 -0
- package/dist/chunk-SBCVAC2Z.js +40 -0
- package/dist/chunk-SBCVAC2Z.js.map +1 -0
- package/dist/chunk-TFJ4MKPH.js +694 -0
- package/dist/chunk-TFJ4MKPH.js.map +1 -0
- package/dist/chunk-THX3SHYA.js +75 -0
- package/dist/chunk-THX3SHYA.js.map +1 -0
- package/dist/chunk-UGQSQO5B.js +222 -0
- package/dist/chunk-UGQSQO5B.js.map +1 -0
- package/dist/chunk-V2UNHGAP.js +26 -0
- package/dist/chunk-V2UNHGAP.js.map +1 -0
- package/dist/chunk-VGTPQXNQ.js +2790 -0
- package/dist/chunk-VGTPQXNQ.js.map +1 -0
- package/dist/chunk-VNIHXQ7W.js +194 -0
- package/dist/chunk-VNIHXQ7W.js.map +1 -0
- package/dist/chunk-WV272MPW.js +31 -0
- package/dist/chunk-WV272MPW.js.map +1 -0
- package/dist/chunk-X5KKBOUS.js +26 -0
- package/dist/chunk-X5KKBOUS.js.map +1 -0
- package/dist/chunk-XANPEOJC.js +17 -0
- package/dist/chunk-XANPEOJC.js.map +1 -0
- package/dist/chunk-XPVQIHAQ.js +83 -0
- package/dist/chunk-XPVQIHAQ.js.map +1 -0
- package/dist/chunk-ZCINJSS4.js +75 -0
- package/dist/chunk-ZCINJSS4.js.map +1 -0
- package/dist/community.d.ts +1425 -0
- package/dist/community.js +206 -0
- package/dist/community.js.map +1 -0
- package/dist/config-2GDU7PCK.js +32 -0
- package/dist/config-2GDU7PCK.js.map +1 -0
- package/dist/context-MNZ4QXPC.js +16 -0
- package/dist/context-MNZ4QXPC.js.map +1 -0
- package/dist/db-schema.d.ts +4 -0
- package/dist/db-schema.js +102 -0
- package/dist/db-schema.js.map +1 -0
- package/dist/db.d.ts +7 -0
- package/dist/db.js +117 -0
- package/dist/db.js.map +1 -0
- package/dist/digest-SY42GQSU.js +17 -0
- package/dist/digest-SY42GQSU.js.map +1 -0
- package/dist/errors-5OS3S2J3.js +22 -0
- package/dist/errors-5OS3S2J3.js.map +1 -0
- package/dist/host-OBOI4MJK.js +51 -0
- package/dist/host-OBOI4MJK.js.map +1 -0
- package/dist/i18n.d.ts +301 -0
- package/dist/i18n.js +68 -0
- package/dist/i18n.js.map +1 -0
- package/dist/index-B6-_vr_m.d.ts +590 -0
- package/dist/index-CY55LC0u.d.ts +4722 -0
- package/dist/index-CeiTvwbp.d.ts +168 -0
- package/dist/index-XwP1ET8b.d.ts +61 -0
- package/dist/index.d.ts +2037 -0
- package/dist/index.js +2205 -0
- package/dist/index.js.map +1 -0
- package/dist/job-log-VZXWQUDK.js +24 -0
- package/dist/job-log-VZXWQUDK.js.map +1 -0
- package/dist/jobs.d.ts +4 -0
- package/dist/jobs.js +76 -0
- package/dist/jobs.js.map +1 -0
- package/dist/logger-DqGaOU_j.d.ts +29 -0
- package/dist/logger-S7REWDNE.js +16 -0
- package/dist/logger-S7REWDNE.js.map +1 -0
- package/dist/media.d.ts +5 -0
- package/dist/media.js +41 -0
- package/dist/media.js.map +1 -0
- package/dist/mentions-2IHFVSHW.js +23 -0
- package/dist/mentions-2IHFVSHW.js.map +1 -0
- package/dist/mutes-EWAE5FZR.js +21 -0
- package/dist/mutes-EWAE5FZR.js.map +1 -0
- package/dist/notification-prefs-VPJDU7I6.js +21 -0
- package/dist/notification-prefs-VPJDU7I6.js.map +1 -0
- package/dist/observability.d.ts +156 -0
- package/dist/observability.js +32 -0
- package/dist/observability.js.map +1 -0
- package/dist/profanity-adapter-NU2JQSLX.js +12 -0
- package/dist/profanity-adapter-NU2JQSLX.js.map +1 -0
- package/dist/queue-XE5BC75T.js +14 -0
- package/dist/queue-XE5BC75T.js.map +1 -0
- package/dist/rate-limit.d.ts +99 -0
- package/dist/rate-limit.js +14 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/registry-XIXDEPVI.js +31 -0
- package/dist/registry-XIXDEPVI.js.map +1 -0
- package/dist/reputation-JRL2YQHM.js +11 -0
- package/dist/reputation-JRL2YQHM.js.map +1 -0
- package/dist/routes.d.ts +43 -0
- package/dist/routes.js +12 -0
- package/dist/routes.js.map +1 -0
- package/dist/scheduled-CIQM57HT.js +20 -0
- package/dist/scheduled-CIQM57HT.js.map +1 -0
- package/dist/seo.d.ts +410 -0
- package/dist/seo.js +44 -0
- package/dist/seo.js.map +1 -0
- package/dist/settings-FOBIESPB.js +17 -0
- package/dist/settings-FOBIESPB.js.map +1 -0
- package/dist/spam-adapter-XX3G737Z.js +12 -0
- package/dist/spam-adapter-XX3G737Z.js.map +1 -0
- package/dist/strings-VAE47B2C.js +29 -0
- package/dist/strings-VAE47B2C.js.map +1 -0
- package/dist/templates-IFVJMCJ6.js +12 -0
- package/dist/templates-IFVJMCJ6.js.map +1 -0
- package/dist/types-TlsbXS0T.d.ts +871 -0
- package/package.json +129 -0
|
@@ -0,0 +1,2790 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getI18nConfig
|
|
3
|
+
} from "./chunk-4ZLMEKFX.js";
|
|
4
|
+
import {
|
|
5
|
+
NP_DEFAULT_SITE_ID,
|
|
6
|
+
getCollectionConfig,
|
|
7
|
+
getCollectionRegistration,
|
|
8
|
+
getCollectionTable
|
|
9
|
+
} from "./chunk-FZ7O6DWI.js";
|
|
10
|
+
import {
|
|
11
|
+
getCurrentSiteId
|
|
12
|
+
} from "./chunk-SBCVAC2Z.js";
|
|
13
|
+
import {
|
|
14
|
+
NpError,
|
|
15
|
+
NpForbiddenError,
|
|
16
|
+
NpNotFoundError,
|
|
17
|
+
NpValidationError
|
|
18
|
+
} from "./chunk-ZCINJSS4.js";
|
|
19
|
+
import {
|
|
20
|
+
reportError
|
|
21
|
+
} from "./chunk-WV272MPW.js";
|
|
22
|
+
import {
|
|
23
|
+
deleteMedia,
|
|
24
|
+
getMediaById,
|
|
25
|
+
getStorageAdapter,
|
|
26
|
+
listMedia,
|
|
27
|
+
uploadMedia
|
|
28
|
+
} from "./chunk-473S4TER.js";
|
|
29
|
+
import {
|
|
30
|
+
enqueueJob
|
|
31
|
+
} from "./chunk-V2UNHGAP.js";
|
|
32
|
+
import {
|
|
33
|
+
getLogger,
|
|
34
|
+
getScopedLogger
|
|
35
|
+
} from "./chunk-JJL74ZPK.js";
|
|
36
|
+
import {
|
|
37
|
+
getDb
|
|
38
|
+
} from "./chunk-XANPEOJC.js";
|
|
39
|
+
import {
|
|
40
|
+
NP_GLOBAL_PLUGIN_SITE_ID,
|
|
41
|
+
npComments,
|
|
42
|
+
npMediaRefs,
|
|
43
|
+
npPluginStorage,
|
|
44
|
+
npPlugins,
|
|
45
|
+
npReactions,
|
|
46
|
+
npReports,
|
|
47
|
+
npRevisions,
|
|
48
|
+
npSettings,
|
|
49
|
+
npSlugHistory
|
|
50
|
+
} from "./chunk-M43PGOQY.js";
|
|
51
|
+
|
|
52
|
+
// src/plugins/context.ts
|
|
53
|
+
import { and, eq as eq2, gt, isNull, like, or } from "drizzle-orm";
|
|
54
|
+
|
|
55
|
+
// src/collections/pipeline.ts
|
|
56
|
+
import { randomUUID } from "crypto";
|
|
57
|
+
import { asc, count, desc, eq, inArray, sql as sql2 } from "drizzle-orm";
|
|
58
|
+
|
|
59
|
+
// src/collections/slug.ts
|
|
60
|
+
function slugify(value) {
|
|
61
|
+
return value.toLowerCase().normalize("NFKD").replace(/[\u0300-\u036f]/g, "").normalize("NFC").replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 96);
|
|
62
|
+
}
|
|
63
|
+
function applySlugField(config, data, originalDoc) {
|
|
64
|
+
if (!config.slugField) return;
|
|
65
|
+
const existingSlug = typeof data.slug === "string" ? data.slug.trim() : "";
|
|
66
|
+
if (existingSlug.length > 0) {
|
|
67
|
+
data.slug = slugify(existingSlug) || existingSlug;
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (originalDoc && typeof originalDoc.slug === "string" && originalDoc.slug.length > 0) {
|
|
71
|
+
data.slug = originalDoc.slug;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const useField = typeof config.slugField === "object" && config.slugField.useField ? config.slugField.useField : "title";
|
|
75
|
+
const source = data[useField];
|
|
76
|
+
const candidate = typeof source === "string" ? slugify(source) : "";
|
|
77
|
+
if (candidate.length === 0) {
|
|
78
|
+
throw new NpValidationError("Slug generation failed", [
|
|
79
|
+
{
|
|
80
|
+
field: "slug",
|
|
81
|
+
message: `Cannot derive a slug \u2014 provide "slug" or a non-empty "${useField}".`
|
|
82
|
+
}
|
|
83
|
+
]);
|
|
84
|
+
}
|
|
85
|
+
data.slug = candidate;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// src/collections/validation.ts
|
|
89
|
+
import { z } from "zod";
|
|
90
|
+
function buildZodSchema(fields) {
|
|
91
|
+
const shape = {};
|
|
92
|
+
for (const field of fields) {
|
|
93
|
+
if (field.type === "row" || field.type === "collapsible") {
|
|
94
|
+
Object.assign(shape, buildZodSchema(field.fields).shape);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (field.type === "group") {
|
|
98
|
+
const schema = buildZodSchema(field.fields);
|
|
99
|
+
shape[field.name] = applyOptionality(schema, field.required);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
shape[field.name] = applyOptionality(buildFieldSchema(field), field.required);
|
|
103
|
+
}
|
|
104
|
+
return z.object(shape);
|
|
105
|
+
}
|
|
106
|
+
function getCollectionZodSchema(config) {
|
|
107
|
+
const base = buildZodSchema(config.fields).extend({
|
|
108
|
+
// Phase 21.17 — per-doc visibility flag. Optional on writes;
|
|
109
|
+
// the pipeline lets the column default to "public" when the
|
|
110
|
+
// caller doesn't specify. Allowed values are the same
|
|
111
|
+
// codegen enum from `getBaseColumns`.
|
|
112
|
+
visibility: z.enum(["public", "private"]).optional()
|
|
113
|
+
});
|
|
114
|
+
if (config.i18n) {
|
|
115
|
+
return base.extend({
|
|
116
|
+
locale: z.string().min(1).optional(),
|
|
117
|
+
translationGroupId: z.string().uuid().optional()
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return base;
|
|
121
|
+
}
|
|
122
|
+
function buildFieldSchema(field) {
|
|
123
|
+
switch (field.type) {
|
|
124
|
+
case "text": {
|
|
125
|
+
let schema = z.string();
|
|
126
|
+
if (field.minLength !== void 0) schema = schema.min(field.minLength);
|
|
127
|
+
if (field.maxLength !== void 0) schema = schema.max(field.maxLength);
|
|
128
|
+
return schema;
|
|
129
|
+
}
|
|
130
|
+
case "textarea": {
|
|
131
|
+
let schema = z.string();
|
|
132
|
+
if (field.minLength !== void 0) schema = schema.min(field.minLength);
|
|
133
|
+
if (field.maxLength !== void 0) schema = schema.max(field.maxLength);
|
|
134
|
+
return schema;
|
|
135
|
+
}
|
|
136
|
+
case "email":
|
|
137
|
+
return z.string().email();
|
|
138
|
+
case "number": {
|
|
139
|
+
let schema = z.number();
|
|
140
|
+
if (field.integerOnly) schema = schema.int();
|
|
141
|
+
if (field.min !== void 0) schema = schema.min(field.min);
|
|
142
|
+
if (field.max !== void 0) schema = schema.max(field.max);
|
|
143
|
+
return schema;
|
|
144
|
+
}
|
|
145
|
+
case "checkbox":
|
|
146
|
+
return z.boolean();
|
|
147
|
+
case "select":
|
|
148
|
+
return createEnumSchema(field.options.map((option) => option.value));
|
|
149
|
+
case "radio":
|
|
150
|
+
return createEnumSchema(field.options.map((option) => option.value));
|
|
151
|
+
case "relationship":
|
|
152
|
+
return field.hasMany ? z.array(z.string().uuid()) : z.string().uuid();
|
|
153
|
+
case "upload":
|
|
154
|
+
return z.string().uuid();
|
|
155
|
+
case "date":
|
|
156
|
+
return z.coerce.date();
|
|
157
|
+
case "richText":
|
|
158
|
+
case "blocks":
|
|
159
|
+
case "json":
|
|
160
|
+
return z.unknown();
|
|
161
|
+
case "array": {
|
|
162
|
+
let schema = z.array(buildZodSchema(field.fields));
|
|
163
|
+
if (field.minRows !== void 0) schema = schema.min(field.minRows);
|
|
164
|
+
if (field.maxRows !== void 0) schema = schema.max(field.maxRows);
|
|
165
|
+
return schema;
|
|
166
|
+
}
|
|
167
|
+
default:
|
|
168
|
+
return z.unknown();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function applyOptionality(schema, required) {
|
|
172
|
+
return required ? schema : schema.optional().nullable();
|
|
173
|
+
}
|
|
174
|
+
function createEnumSchema(values) {
|
|
175
|
+
const [first, ...rest] = values;
|
|
176
|
+
if (!first) {
|
|
177
|
+
return z.string();
|
|
178
|
+
}
|
|
179
|
+
return z.enum([first, ...rest]);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// src/collections/search.ts
|
|
183
|
+
import { sql } from "drizzle-orm";
|
|
184
|
+
function buildSearchVector(config, data) {
|
|
185
|
+
const parts = [];
|
|
186
|
+
for (const field of config.fields) {
|
|
187
|
+
if (field.type === "text" || field.type === "textarea") {
|
|
188
|
+
const value = data[field.name];
|
|
189
|
+
if (typeof value === "string") parts.push(value);
|
|
190
|
+
}
|
|
191
|
+
if (field.type === "richText") {
|
|
192
|
+
const value = data[field.name];
|
|
193
|
+
if (value) parts.push(extractPlainText(value));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return parts.join(" ");
|
|
197
|
+
}
|
|
198
|
+
var TITLE_LIKE_NAMES = /* @__PURE__ */ new Set(["title", "name"]);
|
|
199
|
+
function buildSearchVectorParts(config, data) {
|
|
200
|
+
const parts = { a: "", b: "", c: "", d: "" };
|
|
201
|
+
const append = (bucket, value) => {
|
|
202
|
+
parts[bucket] = parts[bucket] ? `${parts[bucket]} ${value}` : value;
|
|
203
|
+
};
|
|
204
|
+
for (const field of config.fields) {
|
|
205
|
+
if (field.type === "text" || field.type === "textarea" || field.type === "email") {
|
|
206
|
+
const value = data[field.name];
|
|
207
|
+
if (typeof value !== "string" || value.length === 0) continue;
|
|
208
|
+
if (TITLE_LIKE_NAMES.has(field.name)) {
|
|
209
|
+
append("a", value);
|
|
210
|
+
} else {
|
|
211
|
+
append("b", value);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (field.type === "richText") {
|
|
215
|
+
const value = data[field.name];
|
|
216
|
+
if (!value) continue;
|
|
217
|
+
const text = extractPlainText(value);
|
|
218
|
+
if (text.length > 0) append("c", text);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return parts;
|
|
222
|
+
}
|
|
223
|
+
function buildWeightedSearchVectorSql(config, data) {
|
|
224
|
+
const parts = buildSearchVectorParts(config, data);
|
|
225
|
+
const chunks = [];
|
|
226
|
+
if (parts.a)
|
|
227
|
+
chunks.push(sql`setweight(to_tsvector('english', ${parts.a}), 'A')`);
|
|
228
|
+
if (parts.b)
|
|
229
|
+
chunks.push(sql`setweight(to_tsvector('english', ${parts.b}), 'B')`);
|
|
230
|
+
if (parts.c)
|
|
231
|
+
chunks.push(sql`setweight(to_tsvector('english', ${parts.c}), 'C')`);
|
|
232
|
+
if (parts.d)
|
|
233
|
+
chunks.push(sql`setweight(to_tsvector('english', ${parts.d}), 'D')`);
|
|
234
|
+
if (chunks.length === 0) {
|
|
235
|
+
return sql`''::tsvector`;
|
|
236
|
+
}
|
|
237
|
+
if (chunks.length === 1) {
|
|
238
|
+
return chunks[0];
|
|
239
|
+
}
|
|
240
|
+
return sql.join(chunks, sql` || `);
|
|
241
|
+
}
|
|
242
|
+
function extractPlainText(content) {
|
|
243
|
+
if (!content || typeof content !== "object") return "";
|
|
244
|
+
const root = content.root;
|
|
245
|
+
if (!root?.children) return "";
|
|
246
|
+
const parts = [];
|
|
247
|
+
walkNodes(root.children, parts);
|
|
248
|
+
return parts.join(" ");
|
|
249
|
+
}
|
|
250
|
+
function walkNodes(nodes, parts) {
|
|
251
|
+
for (const node of nodes) {
|
|
252
|
+
if (!node || typeof node !== "object") continue;
|
|
253
|
+
const n = node;
|
|
254
|
+
if (typeof n.text === "string") {
|
|
255
|
+
parts.push(n.text);
|
|
256
|
+
}
|
|
257
|
+
if (Array.isArray(n.children)) {
|
|
258
|
+
walkNodes(n.children, parts);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// src/collections/pipeline.ts
|
|
264
|
+
function actorUserOrNull(actor) {
|
|
265
|
+
return actor.kind === "staff" ? actor.user : null;
|
|
266
|
+
}
|
|
267
|
+
function actorUserId(actor) {
|
|
268
|
+
return actor.kind === "staff" ? actor.user.id : null;
|
|
269
|
+
}
|
|
270
|
+
function actorPrincipal(actor) {
|
|
271
|
+
switch (actor.kind) {
|
|
272
|
+
case "staff":
|
|
273
|
+
return { kind: "staff", user: actor.user };
|
|
274
|
+
case "member":
|
|
275
|
+
return { kind: "member", memberId: actor.memberId };
|
|
276
|
+
default: {
|
|
277
|
+
const _exhaustive = actor;
|
|
278
|
+
void _exhaustive;
|
|
279
|
+
throw new Error("actorPrincipal: unhandled SaveActor kind");
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
async function runPostCommit(label, context, fn) {
|
|
284
|
+
try {
|
|
285
|
+
await fn();
|
|
286
|
+
} catch (err) {
|
|
287
|
+
const { getLogger: getLogger2 } = await import("./logger-S7REWDNE.js");
|
|
288
|
+
getLogger2().error(
|
|
289
|
+
`post-commit ${label} failed \u2014 document persisted, follow-up skipped`,
|
|
290
|
+
{
|
|
291
|
+
collection: context.collection,
|
|
292
|
+
documentId: context.documentId,
|
|
293
|
+
operation: context.operation,
|
|
294
|
+
label,
|
|
295
|
+
error: err instanceof Error ? err.message : String(err),
|
|
296
|
+
stack: err instanceof Error ? err.stack : void 0
|
|
297
|
+
}
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
async function saveDocument(collection, docId, data, user, options) {
|
|
302
|
+
return saveDocumentImpl(collection, docId, data, { kind: "staff", user }, options);
|
|
303
|
+
}
|
|
304
|
+
async function updateMemberDocument(collection, docId, data, memberId, options) {
|
|
305
|
+
const memberOptions = { ...options ?? {} };
|
|
306
|
+
delete memberOptions.status;
|
|
307
|
+
const config = getCollectionConfig(collection);
|
|
308
|
+
if (!config.community?.memberWrite?.update) {
|
|
309
|
+
throw new NpForbiddenError(collection, "update");
|
|
310
|
+
}
|
|
311
|
+
const table = getCollectionTable(collection);
|
|
312
|
+
const dbForGate = getDb();
|
|
313
|
+
const originalDoc = await getDocumentByIdInternal(dbForGate, table, collection, docId);
|
|
314
|
+
if (!originalDoc) {
|
|
315
|
+
throw new NpNotFoundError(collection, docId);
|
|
316
|
+
}
|
|
317
|
+
const authorId = originalDoc.memberAuthorId ?? null;
|
|
318
|
+
if (authorId !== memberId) {
|
|
319
|
+
throw new NpForbiddenError(collection, "update");
|
|
320
|
+
}
|
|
321
|
+
const { assertNotBanned } = await import("./can-YLUHRJAB.js");
|
|
322
|
+
await assertNotBanned(memberId);
|
|
323
|
+
const moderation = await runMemberDocModeration({
|
|
324
|
+
collection,
|
|
325
|
+
data,
|
|
326
|
+
memberId,
|
|
327
|
+
targetId: docId
|
|
328
|
+
});
|
|
329
|
+
if (moderation.flaggedBy.length > 0) {
|
|
330
|
+
memberOptions.status = "pending";
|
|
331
|
+
}
|
|
332
|
+
const result = await saveDocumentImpl(
|
|
333
|
+
collection,
|
|
334
|
+
docId,
|
|
335
|
+
data,
|
|
336
|
+
{ kind: "member", memberId },
|
|
337
|
+
memberOptions
|
|
338
|
+
);
|
|
339
|
+
const { recordAuditEvent } = await import("./audit-54XLVCWD.js");
|
|
340
|
+
await recordAuditEvent({
|
|
341
|
+
actor: { kind: "member", memberId },
|
|
342
|
+
action: moderation.flaggedBy.length > 0 ? "document.flag" : "document.update",
|
|
343
|
+
targetType: collection,
|
|
344
|
+
targetId: docId,
|
|
345
|
+
payload: {
|
|
346
|
+
collectionSlug: collection,
|
|
347
|
+
event: "update",
|
|
348
|
+
...moderation.flaggedBy.length > 0 ? { sources: moderation.flaggedBy } : {},
|
|
349
|
+
...moderation.profanityVerdict ? { profanityVerdict: moderation.profanityVerdict } : {},
|
|
350
|
+
...moderation.spamVerdict ? { spamVerdict: moderation.spamVerdict } : {}
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
const resultStatus = result.doc.status;
|
|
354
|
+
if (resultStatus === "published") {
|
|
355
|
+
const { extractMentionHandlesFromDocData, fanOutMentionNotifications } = await import("./mentions-2IHFVSHW.js");
|
|
356
|
+
const previousHandles = new Set(extractMentionHandlesFromDocData(originalDoc));
|
|
357
|
+
await fanOutMentionNotifications({
|
|
358
|
+
actorMemberId: memberId,
|
|
359
|
+
kind: "document.mention",
|
|
360
|
+
data,
|
|
361
|
+
previousHandles,
|
|
362
|
+
payload: {
|
|
363
|
+
collectionSlug: collection,
|
|
364
|
+
documentId: docId
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
return result;
|
|
369
|
+
}
|
|
370
|
+
async function createMemberDocument(collection, data, memberId, options) {
|
|
371
|
+
const config = getCollectionConfig(collection);
|
|
372
|
+
if (!config.community?.memberWrite?.create) {
|
|
373
|
+
throw new NpForbiddenError(collection, "create");
|
|
374
|
+
}
|
|
375
|
+
const { assertNotBanned } = await import("./can-YLUHRJAB.js");
|
|
376
|
+
await assertNotBanned(memberId);
|
|
377
|
+
const defaultStatus = config.community?.memberWrite?.defaultStatus === "pending" ? "pending" : "published";
|
|
378
|
+
const moderation = await runMemberDocModeration({
|
|
379
|
+
collection,
|
|
380
|
+
data,
|
|
381
|
+
memberId,
|
|
382
|
+
targetId: ""
|
|
383
|
+
});
|
|
384
|
+
const flaggedBy = moderation.flaggedBy;
|
|
385
|
+
const spamStatus = flaggedBy.length > 0 ? "pending" : defaultStatus;
|
|
386
|
+
const memberOptions = { ...options ?? {}, status: spamStatus };
|
|
387
|
+
const result = await saveDocumentImpl(
|
|
388
|
+
collection,
|
|
389
|
+
null,
|
|
390
|
+
data,
|
|
391
|
+
{ kind: "member", memberId },
|
|
392
|
+
memberOptions
|
|
393
|
+
);
|
|
394
|
+
const { applyReputation } = await import("./reputation-JRL2YQHM.js");
|
|
395
|
+
const { recordAuditEvent } = await import("./audit-54XLVCWD.js");
|
|
396
|
+
const documentId = getRecordId(result.doc);
|
|
397
|
+
await recordAuditEvent({
|
|
398
|
+
actor: { kind: "member", memberId },
|
|
399
|
+
action: flaggedBy.length > 0 ? "document.flag" : "document.create",
|
|
400
|
+
targetType: collection,
|
|
401
|
+
targetId: documentId,
|
|
402
|
+
payload: {
|
|
403
|
+
collectionSlug: collection,
|
|
404
|
+
event: "create",
|
|
405
|
+
...flaggedBy.length > 0 ? { sources: flaggedBy } : {},
|
|
406
|
+
...moderation.profanityVerdict ? { profanityVerdict: moderation.profanityVerdict } : {},
|
|
407
|
+
...moderation.spamVerdict ? { spamVerdict: moderation.spamVerdict } : {}
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
if (spamStatus === "published") {
|
|
411
|
+
await applyReputation(memberId, {
|
|
412
|
+
kind: "document.created",
|
|
413
|
+
collectionSlug: collection,
|
|
414
|
+
documentId,
|
|
415
|
+
memberId
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
if (spamStatus === "published") {
|
|
419
|
+
const { fanOutMentionNotifications } = await import("./mentions-2IHFVSHW.js");
|
|
420
|
+
await fanOutMentionNotifications({
|
|
421
|
+
actorMemberId: memberId,
|
|
422
|
+
kind: "document.mention",
|
|
423
|
+
data,
|
|
424
|
+
payload: {
|
|
425
|
+
collectionSlug: collection,
|
|
426
|
+
documentId
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
return result;
|
|
431
|
+
}
|
|
432
|
+
async function runMemberDocModeration(input) {
|
|
433
|
+
const { collection, data, memberId, targetId } = input;
|
|
434
|
+
const config = getCollectionConfig(collection);
|
|
435
|
+
const { getSpamAdapter } = await import("./spam-adapter-XX3G737Z.js");
|
|
436
|
+
const { getProfanityAdapter } = await import("./profanity-adapter-NU2JQSLX.js");
|
|
437
|
+
const { getLogger: getLogger2 } = await import("./logger-S7REWDNE.js");
|
|
438
|
+
const moderationText = buildSearchVector(config, data);
|
|
439
|
+
const ctx = {
|
|
440
|
+
memberId,
|
|
441
|
+
targetType: collection,
|
|
442
|
+
targetId,
|
|
443
|
+
parentId: null
|
|
444
|
+
};
|
|
445
|
+
let profanityVerdict = null;
|
|
446
|
+
try {
|
|
447
|
+
const verdict = await getProfanityAdapter().check(moderationText, ctx);
|
|
448
|
+
if (verdict.kind === "reject") {
|
|
449
|
+
throw new NpValidationError("Invalid input", [
|
|
450
|
+
{
|
|
451
|
+
field: "body",
|
|
452
|
+
message: verdict.reason ?? "Submission contains prohibited language"
|
|
453
|
+
}
|
|
454
|
+
]);
|
|
455
|
+
}
|
|
456
|
+
if (verdict.kind === "flag") {
|
|
457
|
+
profanityVerdict = {
|
|
458
|
+
reason: verdict.reason ?? null,
|
|
459
|
+
metadata: verdict.metadata ?? null
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
} catch (err) {
|
|
463
|
+
if (err instanceof NpValidationError) throw err;
|
|
464
|
+
getLogger2().warn("profanity adapter threw on doc write \u2014 treating as pass", {
|
|
465
|
+
error: err instanceof Error ? err.message : String(err),
|
|
466
|
+
collection,
|
|
467
|
+
memberId
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
let spamVerdict = null;
|
|
471
|
+
try {
|
|
472
|
+
const verdict = await getSpamAdapter().check(moderationText, ctx);
|
|
473
|
+
if (verdict.kind === "reject") {
|
|
474
|
+
throw new NpValidationError("Invalid input", [
|
|
475
|
+
{
|
|
476
|
+
field: "body",
|
|
477
|
+
message: verdict.reason ?? "Submission rejected"
|
|
478
|
+
}
|
|
479
|
+
]);
|
|
480
|
+
}
|
|
481
|
+
if (verdict.kind === "flag") {
|
|
482
|
+
spamVerdict = {
|
|
483
|
+
reason: verdict.reason ?? null,
|
|
484
|
+
metadata: verdict.metadata ?? null
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
} catch (err) {
|
|
488
|
+
if (err instanceof NpValidationError) throw err;
|
|
489
|
+
getLogger2().warn("spam adapter threw on doc write \u2014 treating as pass", {
|
|
490
|
+
error: err instanceof Error ? err.message : String(err),
|
|
491
|
+
collection,
|
|
492
|
+
memberId
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
const flaggedBy = [];
|
|
496
|
+
if (profanityVerdict) flaggedBy.push("profanity");
|
|
497
|
+
if (spamVerdict) flaggedBy.push("spam");
|
|
498
|
+
return { flaggedBy, profanityVerdict, spamVerdict };
|
|
499
|
+
}
|
|
500
|
+
async function initSaveContext(collection, docId, data, actor, options) {
|
|
501
|
+
const config = getCollectionConfig(collection);
|
|
502
|
+
const registration = getCollectionRegistration(collection);
|
|
503
|
+
const table = getCollectionTable(collection);
|
|
504
|
+
const db = getDb();
|
|
505
|
+
const validatedData = toRecord(getCollectionZodSchema(config).parse(data));
|
|
506
|
+
const operation = docId ? "update" : "create";
|
|
507
|
+
const originalDoc = docId ? await getDocumentByIdInternal(db, table, collection, docId) : null;
|
|
508
|
+
return {
|
|
509
|
+
collection,
|
|
510
|
+
docId,
|
|
511
|
+
validatedData,
|
|
512
|
+
actor,
|
|
513
|
+
options,
|
|
514
|
+
config,
|
|
515
|
+
registration,
|
|
516
|
+
table,
|
|
517
|
+
db,
|
|
518
|
+
operation,
|
|
519
|
+
originalDoc,
|
|
520
|
+
userForHooks: actorUserOrNull(actor),
|
|
521
|
+
principal: actorPrincipal(actor)
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
async function validateActorAccess(ctx) {
|
|
525
|
+
if (ctx.actor.kind === "staff") {
|
|
526
|
+
await assertWriteAccess(
|
|
527
|
+
ctx.config,
|
|
528
|
+
ctx.collection,
|
|
529
|
+
ctx.operation,
|
|
530
|
+
ctx.actor.user,
|
|
531
|
+
ctx.validatedData,
|
|
532
|
+
ctx.originalDoc
|
|
533
|
+
);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
const { assertNotBanned } = await import("./can-YLUHRJAB.js");
|
|
537
|
+
if (ctx.operation === "create") {
|
|
538
|
+
if (!ctx.config.community?.memberWrite?.create) {
|
|
539
|
+
throw new NpForbiddenError(ctx.collection, "create");
|
|
540
|
+
}
|
|
541
|
+
await assertNotBanned(ctx.actor.memberId);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
if (!ctx.originalDoc) {
|
|
545
|
+
throw new NpNotFoundError(ctx.collection, ctx.docId ?? "unknown");
|
|
546
|
+
}
|
|
547
|
+
if (!ctx.config.community?.memberWrite?.update) {
|
|
548
|
+
throw new NpForbiddenError(ctx.collection, "update");
|
|
549
|
+
}
|
|
550
|
+
const authorId = ctx.originalDoc.memberAuthorId ?? null;
|
|
551
|
+
if (authorId !== ctx.actor.memberId) {
|
|
552
|
+
throw new NpForbiddenError(ctx.collection, "update");
|
|
553
|
+
}
|
|
554
|
+
await assertNotBanned(ctx.actor.memberId);
|
|
555
|
+
}
|
|
556
|
+
async function prepareDocumentForWrite(c) {
|
|
557
|
+
c.hookData = await runHooks(
|
|
558
|
+
c.operation === "create" ? c.config.hooks?.beforeCreate : c.config.hooks?.beforeUpdate,
|
|
559
|
+
{
|
|
560
|
+
data: c.validatedData,
|
|
561
|
+
user: c.userForHooks,
|
|
562
|
+
principal: c.principal,
|
|
563
|
+
collection: c.collection,
|
|
564
|
+
originalDoc: c.originalDoc
|
|
565
|
+
}
|
|
566
|
+
);
|
|
567
|
+
applySlugField(c.config, c.hookData, c.originalDoc);
|
|
568
|
+
let i18nResolved = null;
|
|
569
|
+
if (c.config.i18n) {
|
|
570
|
+
const i18n = getI18nConfig();
|
|
571
|
+
if (!i18n) {
|
|
572
|
+
throw new Error(
|
|
573
|
+
`Collection "${c.collection}" is i18n-enabled but the framework has no i18n config (setI18nConfig was never called).`
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
if (c.operation === "create") {
|
|
577
|
+
const requestedLocale = c.hookData.locale;
|
|
578
|
+
const locale = typeof requestedLocale === "string" && requestedLocale.length > 0 ? requestedLocale : i18n.defaultLocale;
|
|
579
|
+
if (!i18n.locales.includes(locale)) {
|
|
580
|
+
throw new NpValidationError("Invalid input", [
|
|
581
|
+
{
|
|
582
|
+
field: "locale",
|
|
583
|
+
message: `Locale "${locale}" is not configured. Allowed: ${i18n.locales.join(", ")}.`
|
|
584
|
+
}
|
|
585
|
+
]);
|
|
586
|
+
}
|
|
587
|
+
const requestedGroup = c.hookData.translationGroupId;
|
|
588
|
+
const translationGroupId = typeof requestedGroup === "string" && requestedGroup.length > 0 ? requestedGroup : randomUUID();
|
|
589
|
+
i18nResolved = { locale, translationGroupId };
|
|
590
|
+
} else {
|
|
591
|
+
const original = c.originalDoc;
|
|
592
|
+
if (!original?.locale || !original.translationGroupId) {
|
|
593
|
+
throw new Error(
|
|
594
|
+
`i18n collection "${c.collection}" doc ${c.docId} is missing locale/translationGroupId. The row predates i18n opt-in; backfill required.`
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
i18nResolved = {
|
|
598
|
+
locale: original.locale,
|
|
599
|
+
translationGroupId: original.translationGroupId
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
c.prepared = prepareDocumentData(c.config.fields, c.hookData);
|
|
604
|
+
if (c.options?.status) {
|
|
605
|
+
c.prepared.mainData.status = c.options.status;
|
|
606
|
+
}
|
|
607
|
+
if (i18nResolved) {
|
|
608
|
+
c.prepared.mainData.locale = i18nResolved.locale;
|
|
609
|
+
c.prepared.mainData.translationGroupId = i18nResolved.translationGroupId;
|
|
610
|
+
}
|
|
611
|
+
if (c.operation === "create") {
|
|
612
|
+
const resolved = await getCurrentSiteId();
|
|
613
|
+
c.prepared.mainData.siteId = resolved ?? NP_DEFAULT_SITE_ID;
|
|
614
|
+
} else {
|
|
615
|
+
const original = c.originalDoc;
|
|
616
|
+
c.prepared.mainData.siteId = original?.siteId ?? NP_DEFAULT_SITE_ID;
|
|
617
|
+
}
|
|
618
|
+
if (c.actor.kind === "member") {
|
|
619
|
+
if (c.operation === "create") {
|
|
620
|
+
c.prepared.mainData.memberAuthorId = c.actor.memberId;
|
|
621
|
+
} else {
|
|
622
|
+
delete c.prepared.mainData.memberAuthorId;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
c.now = /* @__PURE__ */ new Date();
|
|
626
|
+
const desiredStatus = c.prepared.mainData.status;
|
|
627
|
+
const publishedAtValue = c.prepared.mainData.publishedAt;
|
|
628
|
+
if (desiredStatus === "published" && publishedAtValue instanceof Date && publishedAtValue > c.now) {
|
|
629
|
+
c.prepared.mainData.status = "scheduled";
|
|
630
|
+
}
|
|
631
|
+
c.searchVector = buildWeightedSearchVectorSql(c.config, c.hookData);
|
|
632
|
+
const nextStatus = c.prepared.mainData.status ?? (c.operation === "update" ? c.originalDoc?.status ?? "published" : "published");
|
|
633
|
+
const previousStatus = c.originalDoc?.status;
|
|
634
|
+
const wasPublished = previousStatus === "published";
|
|
635
|
+
const willBePublished = nextStatus === "published";
|
|
636
|
+
c.publishTransition = !wasPublished && willBePublished;
|
|
637
|
+
c.unpublishTransition = wasPublished && !willBePublished;
|
|
638
|
+
}
|
|
639
|
+
async function persistDocumentTx(ctx) {
|
|
640
|
+
await runHook(
|
|
641
|
+
ctx.operation === "create" ? "content:beforeCreate" : "content:beforeUpdate",
|
|
642
|
+
{
|
|
643
|
+
collection: ctx.collection,
|
|
644
|
+
data: ctx.hookData,
|
|
645
|
+
originalDoc: ctx.originalDoc,
|
|
646
|
+
user: ctx.userForHooks,
|
|
647
|
+
principal: ctx.principal,
|
|
648
|
+
operation: ctx.operation
|
|
649
|
+
}
|
|
650
|
+
);
|
|
651
|
+
if (ctx.publishTransition) {
|
|
652
|
+
await runHook("content:beforePublish", {
|
|
653
|
+
collection: ctx.collection,
|
|
654
|
+
data: ctx.hookData,
|
|
655
|
+
originalDoc: ctx.originalDoc,
|
|
656
|
+
user: ctx.userForHooks,
|
|
657
|
+
principal: ctx.principal
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
if (ctx.unpublishTransition) {
|
|
661
|
+
await runHook("content:beforeUnpublish", {
|
|
662
|
+
collection: ctx.collection,
|
|
663
|
+
data: ctx.hookData,
|
|
664
|
+
originalDoc: ctx.originalDoc,
|
|
665
|
+
user: ctx.userForHooks,
|
|
666
|
+
principal: ctx.principal
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
return ctx.db.transaction(async (tx) => {
|
|
670
|
+
const persistedDoc = ctx.operation === "update" ? await updateMainDocument(
|
|
671
|
+
tx,
|
|
672
|
+
ctx.table,
|
|
673
|
+
ctx.collection,
|
|
674
|
+
ctx.docId,
|
|
675
|
+
ctx.prepared.mainData,
|
|
676
|
+
ctx.searchVector,
|
|
677
|
+
ctx.config,
|
|
678
|
+
ctx.userForHooks,
|
|
679
|
+
ctx.now
|
|
680
|
+
) : await createMainDocument(
|
|
681
|
+
tx,
|
|
682
|
+
ctx.table,
|
|
683
|
+
ctx.prepared.mainData,
|
|
684
|
+
ctx.searchVector,
|
|
685
|
+
ctx.config,
|
|
686
|
+
ctx.userForHooks,
|
|
687
|
+
ctx.now
|
|
688
|
+
);
|
|
689
|
+
const persistedDocId = getRecordId(persistedDoc);
|
|
690
|
+
await syncChildTables(tx, ctx.registration.childTables, ctx.prepared.childRows, persistedDocId);
|
|
691
|
+
await syncJoinTables(tx, ctx.registration.joinTables, ctx.prepared.joinRows, persistedDocId);
|
|
692
|
+
await syncMediaRefsForDocument(tx, ctx.collection, persistedDocId, ctx.config.fields, ctx.hookData);
|
|
693
|
+
if (ctx.operation === "update" && ctx.config.slugField && ctx.originalDoc && typeof ctx.originalDoc.slug === "string" && typeof persistedDoc.slug === "string" && ctx.originalDoc.slug.length > 0 && ctx.originalDoc.slug !== persistedDoc.slug) {
|
|
694
|
+
const siteId = persistedDoc.siteId ?? NP_DEFAULT_SITE_ID;
|
|
695
|
+
await tx.insert(npSlugHistory).values({
|
|
696
|
+
siteId,
|
|
697
|
+
collection: ctx.collection,
|
|
698
|
+
documentId: String(persistedDocId),
|
|
699
|
+
oldSlug: ctx.originalDoc.slug,
|
|
700
|
+
newSlug: persistedDoc.slug
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
if (ctx.config.versions) {
|
|
704
|
+
const docStatus = persistedDoc.status;
|
|
705
|
+
const revisionStatus = docStatus === "published" ? "published" : "draft";
|
|
706
|
+
const maxRevisions = typeof ctx.config.versions === "object" && ctx.config.versions.max !== void 0 ? ctx.config.versions.max : void 0;
|
|
707
|
+
await insertRevision(
|
|
708
|
+
tx,
|
|
709
|
+
ctx.collection,
|
|
710
|
+
persistedDocId,
|
|
711
|
+
ctx.operation,
|
|
712
|
+
ctx.hookData,
|
|
713
|
+
ctx.originalDoc,
|
|
714
|
+
ctx.userForHooks,
|
|
715
|
+
revisionStatus,
|
|
716
|
+
maxRevisions
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
return persistedDoc;
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
async function firePostCommitHooks(ctx, savedDoc) {
|
|
723
|
+
const savedDocId = getRecordId(savedDoc);
|
|
724
|
+
const postCommitCtx = {
|
|
725
|
+
collection: ctx.collection,
|
|
726
|
+
documentId: savedDocId,
|
|
727
|
+
operation: ctx.operation
|
|
728
|
+
};
|
|
729
|
+
await runPostCommit(
|
|
730
|
+
"enqueue:content:afterSave",
|
|
731
|
+
postCommitCtx,
|
|
732
|
+
() => enqueueJob("content:afterSave", {
|
|
733
|
+
collection: ctx.collection,
|
|
734
|
+
documentId: savedDocId,
|
|
735
|
+
operation: ctx.operation,
|
|
736
|
+
userId: actorUserId(ctx.actor)
|
|
737
|
+
})
|
|
738
|
+
);
|
|
739
|
+
const pluginHookName = ctx.operation === "create" ? "content:afterCreate" : "content:afterUpdate";
|
|
740
|
+
await runPostCommit(
|
|
741
|
+
`hook:${pluginHookName}`,
|
|
742
|
+
postCommitCtx,
|
|
743
|
+
() => runHook(pluginHookName, {
|
|
744
|
+
collection: ctx.collection,
|
|
745
|
+
doc: savedDoc,
|
|
746
|
+
operation: ctx.operation,
|
|
747
|
+
user: ctx.userForHooks,
|
|
748
|
+
principal: ctx.principal
|
|
749
|
+
})
|
|
750
|
+
);
|
|
751
|
+
if (ctx.publishTransition) {
|
|
752
|
+
await runPostCommit(
|
|
753
|
+
"hook:content:afterPublish",
|
|
754
|
+
postCommitCtx,
|
|
755
|
+
() => runHook("content:afterPublish", {
|
|
756
|
+
collection: ctx.collection,
|
|
757
|
+
doc: savedDoc,
|
|
758
|
+
operation: ctx.operation,
|
|
759
|
+
user: ctx.userForHooks,
|
|
760
|
+
principal: ctx.principal
|
|
761
|
+
})
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
async function saveDocumentImpl(collection, docId, data, actor, options) {
|
|
766
|
+
const ctxBase = await initSaveContext(collection, docId, data, actor, options);
|
|
767
|
+
await validateActorAccess(ctxBase);
|
|
768
|
+
const ctx = ctxBase;
|
|
769
|
+
await prepareDocumentForWrite(ctx);
|
|
770
|
+
const savedDoc = await persistDocumentTx(ctx);
|
|
771
|
+
await firePostCommitHooks(ctx, savedDoc);
|
|
772
|
+
return { doc: savedDoc, operation: ctx.operation };
|
|
773
|
+
}
|
|
774
|
+
async function autosaveRevision(collection, documentId, data, user) {
|
|
775
|
+
const config = getCollectionConfig(collection);
|
|
776
|
+
const registration = getCollectionRegistration(collection);
|
|
777
|
+
const table = getCollectionTable(collection);
|
|
778
|
+
const db = getDb();
|
|
779
|
+
const drafts = config.versions?.drafts;
|
|
780
|
+
if (!drafts) {
|
|
781
|
+
throw new NpValidationError("Autosave not available", [
|
|
782
|
+
{
|
|
783
|
+
field: "collection",
|
|
784
|
+
message: `Collection "${collection}" has versions.drafts disabled \u2014 autosave is unavailable.`
|
|
785
|
+
}
|
|
786
|
+
]);
|
|
787
|
+
}
|
|
788
|
+
const autosaveEnabled = typeof drafts === "object" && drafts.autosave === true;
|
|
789
|
+
if (!autosaveEnabled) {
|
|
790
|
+
throw new NpValidationError("Autosave disabled", [
|
|
791
|
+
{
|
|
792
|
+
field: "collection",
|
|
793
|
+
message: `Autosave is not enabled for "${collection}" \u2014 set versions.drafts.autosave = true.`
|
|
794
|
+
}
|
|
795
|
+
]);
|
|
796
|
+
}
|
|
797
|
+
const originalDoc = await getDocumentByIdInternal(db, table, collection, documentId);
|
|
798
|
+
if (!originalDoc) {
|
|
799
|
+
throw new NpNotFoundError(collection, documentId);
|
|
800
|
+
}
|
|
801
|
+
await assertWriteAccess(config, collection, "update", user, data, originalDoc);
|
|
802
|
+
const [latestAutosave] = await db.select({
|
|
803
|
+
id: npRevisions.id,
|
|
804
|
+
version: npRevisions.version,
|
|
805
|
+
snapshot: npRevisions.snapshot,
|
|
806
|
+
createdAt: npRevisions.createdAt
|
|
807
|
+
}).from(npRevisions).where(
|
|
808
|
+
sql2`${eq(npRevisions.collection, collection)} and ${eq(npRevisions.documentId, documentId)} and ${eq(npRevisions.status, "autosave")}`
|
|
809
|
+
).orderBy(desc(npRevisions.version)).limit(1);
|
|
810
|
+
if (latestAutosave && stableJson(latestAutosave.snapshot) === stableJson(data)) {
|
|
811
|
+
return {
|
|
812
|
+
id: latestAutosave.id,
|
|
813
|
+
version: latestAutosave.version,
|
|
814
|
+
status: "autosave",
|
|
815
|
+
createdAt: latestAutosave.createdAt,
|
|
816
|
+
reused: true
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
const maxRevisions = typeof config.versions === "object" && config.versions.max !== void 0 ? config.versions.max : void 0;
|
|
820
|
+
const inserted = await db.transaction(async (tx) => {
|
|
821
|
+
const [revisionCount] = await tx.select({ total: count() }).from(npRevisions).where(
|
|
822
|
+
sql2`${eq(npRevisions.collection, collection)} and ${eq(npRevisions.documentId, documentId)}`
|
|
823
|
+
);
|
|
824
|
+
const nextVersion = Number(revisionCount?.total ?? 0) + 1;
|
|
825
|
+
const createdAt = /* @__PURE__ */ new Date();
|
|
826
|
+
await tx.insert(npRevisions).values({
|
|
827
|
+
collection,
|
|
828
|
+
documentId,
|
|
829
|
+
version: nextVersion,
|
|
830
|
+
status: "autosave",
|
|
831
|
+
snapshot: data,
|
|
832
|
+
changedFields: getChangedFields(data, originalDoc, "update"),
|
|
833
|
+
authorId: user.id,
|
|
834
|
+
createdAt
|
|
835
|
+
});
|
|
836
|
+
if (maxRevisions !== void 0 && maxRevisions > 0 && nextVersion > maxRevisions) {
|
|
837
|
+
const overflow = nextVersion - maxRevisions;
|
|
838
|
+
const toDelete = await tx.select({ id: npRevisions.id }).from(npRevisions).where(
|
|
839
|
+
sql2`${eq(npRevisions.collection, collection)} and ${eq(npRevisions.documentId, documentId)}`
|
|
840
|
+
).orderBy(asc(npRevisions.version)).limit(overflow);
|
|
841
|
+
if (toDelete.length > 0) {
|
|
842
|
+
const ids = toDelete.map((r) => r.id);
|
|
843
|
+
await tx.delete(npRevisions).where(sql2`${npRevisions.id} = any(${ids}::uuid[])`);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
const [row] = await tx.select({ id: npRevisions.id }).from(npRevisions).where(
|
|
847
|
+
sql2`${eq(npRevisions.collection, collection)} and ${eq(npRevisions.documentId, documentId)} and ${eq(npRevisions.version, nextVersion)}`
|
|
848
|
+
).limit(1);
|
|
849
|
+
return { id: row?.id ?? "", version: nextVersion, createdAt };
|
|
850
|
+
});
|
|
851
|
+
void registration;
|
|
852
|
+
return { ...inserted, status: "autosave", reused: false };
|
|
853
|
+
}
|
|
854
|
+
function stableJson(value) {
|
|
855
|
+
return JSON.stringify(value, (_key, val) => {
|
|
856
|
+
if (val && typeof val === "object" && !Array.isArray(val)) {
|
|
857
|
+
const sorted = {};
|
|
858
|
+
for (const k of Object.keys(val).sort()) {
|
|
859
|
+
sorted[k] = val[k];
|
|
860
|
+
}
|
|
861
|
+
return sorted;
|
|
862
|
+
}
|
|
863
|
+
return val;
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
async function deleteDocument(collection, docId, user) {
|
|
867
|
+
return deleteDocumentImpl(collection, docId, { kind: "staff", user });
|
|
868
|
+
}
|
|
869
|
+
async function deleteMemberDocument(collection, docId, memberId) {
|
|
870
|
+
const table = getCollectionTable(collection);
|
|
871
|
+
const db = getDb();
|
|
872
|
+
const original = await getDocumentByIdInternal(db, table, collection, docId);
|
|
873
|
+
const wasPublished = typeof original?.status === "string" && original.status === "published";
|
|
874
|
+
await deleteDocumentImpl(collection, docId, { kind: "member", memberId });
|
|
875
|
+
const { applyReputation } = await import("./reputation-JRL2YQHM.js");
|
|
876
|
+
const { recordAuditEvent } = await import("./audit-54XLVCWD.js");
|
|
877
|
+
await recordAuditEvent({
|
|
878
|
+
actor: { kind: "member", memberId },
|
|
879
|
+
action: "document.delete",
|
|
880
|
+
targetType: collection,
|
|
881
|
+
targetId: docId,
|
|
882
|
+
payload: {
|
|
883
|
+
collectionSlug: collection,
|
|
884
|
+
// Capture the status that was in effect at delete time so a
|
|
885
|
+
// mod re-reading the audit log can tell "they deleted a
|
|
886
|
+
// pending submission" from "they retracted a published
|
|
887
|
+
// post."
|
|
888
|
+
previousStatus: typeof original?.status === "string" ? original.status : null
|
|
889
|
+
}
|
|
890
|
+
});
|
|
891
|
+
if (wasPublished) {
|
|
892
|
+
await applyReputation(memberId, {
|
|
893
|
+
kind: "document.deleted",
|
|
894
|
+
collectionSlug: collection,
|
|
895
|
+
documentId: docId,
|
|
896
|
+
memberId
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
async function promoteMemberDocument(collection, docId, staffUserId) {
|
|
901
|
+
const table = getCollectionTable(collection);
|
|
902
|
+
const db = getDb();
|
|
903
|
+
const originalDoc = await getDocumentByIdInternal(db, table, collection, docId);
|
|
904
|
+
if (!originalDoc) {
|
|
905
|
+
throw new NpNotFoundError(collection, docId);
|
|
906
|
+
}
|
|
907
|
+
const status = originalDoc.status;
|
|
908
|
+
if (status !== "pending") {
|
|
909
|
+
throw new NpValidationError("Invalid input", [
|
|
910
|
+
{
|
|
911
|
+
field: "status",
|
|
912
|
+
message: `Cannot promote: document is ${status ?? "unknown"}, expected pending`
|
|
913
|
+
}
|
|
914
|
+
]);
|
|
915
|
+
}
|
|
916
|
+
const memberAuthorId = originalDoc.memberAuthorId ?? null;
|
|
917
|
+
if (!memberAuthorId) {
|
|
918
|
+
throw new NpValidationError("Invalid input", [
|
|
919
|
+
{
|
|
920
|
+
field: "memberAuthorId",
|
|
921
|
+
message: "Cannot promote: document is not member-authored"
|
|
922
|
+
}
|
|
923
|
+
]);
|
|
924
|
+
}
|
|
925
|
+
const requestSiteId = await getCurrentSiteId() ?? NP_DEFAULT_SITE_ID;
|
|
926
|
+
const now = /* @__PURE__ */ new Date();
|
|
927
|
+
const updated = await db.update(table).set({ status: "published", updatedAt: now, updatedBy: staffUserId }).where(
|
|
928
|
+
sql2`${eq(getTableColumn(table, "id"), docId)} and ${eq(getTableColumn(table, "status"), "pending")} and ${eq(getTableColumn(table, "siteId"), requestSiteId)}`
|
|
929
|
+
).returning();
|
|
930
|
+
if (updated.length === 0) {
|
|
931
|
+
throw new NpValidationError("Invalid input", [
|
|
932
|
+
{
|
|
933
|
+
field: "status",
|
|
934
|
+
message: "Cannot promote: row is no longer pending (concurrent change)"
|
|
935
|
+
}
|
|
936
|
+
]);
|
|
937
|
+
}
|
|
938
|
+
const persistedDoc = toRecord(updated[0]);
|
|
939
|
+
const { applyReputation } = await import("./reputation-JRL2YQHM.js");
|
|
940
|
+
const { recordAuditEvent } = await import("./audit-54XLVCWD.js");
|
|
941
|
+
await recordAuditEvent({
|
|
942
|
+
actor: { kind: "staff", userId: staffUserId },
|
|
943
|
+
action: "document.promote",
|
|
944
|
+
targetType: collection,
|
|
945
|
+
targetId: docId,
|
|
946
|
+
payload: {
|
|
947
|
+
collectionSlug: collection,
|
|
948
|
+
memberAuthorId,
|
|
949
|
+
previousStatus: "pending"
|
|
950
|
+
}
|
|
951
|
+
});
|
|
952
|
+
await applyReputation(memberAuthorId, {
|
|
953
|
+
kind: "document.created",
|
|
954
|
+
collectionSlug: collection,
|
|
955
|
+
documentId: docId,
|
|
956
|
+
memberId: memberAuthorId
|
|
957
|
+
});
|
|
958
|
+
return { doc: persistedDoc, operation: "update" };
|
|
959
|
+
}
|
|
960
|
+
async function deleteDocumentImpl(collection, docId, actor) {
|
|
961
|
+
const config = getCollectionConfig(collection);
|
|
962
|
+
const registration = getCollectionRegistration(collection);
|
|
963
|
+
const table = getCollectionTable(collection);
|
|
964
|
+
const db = getDb();
|
|
965
|
+
const originalDoc = await getDocumentByIdInternal(db, table, collection, docId);
|
|
966
|
+
if (!originalDoc) {
|
|
967
|
+
throw new NpNotFoundError(collection, docId);
|
|
968
|
+
}
|
|
969
|
+
if (actor.kind === "staff") {
|
|
970
|
+
if (config.access?.delete) {
|
|
971
|
+
const allowed = await config.access.delete({ user: actor.user, doc: originalDoc });
|
|
972
|
+
if (!allowed) {
|
|
973
|
+
throw new NpForbiddenError(collection, "delete");
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
} else {
|
|
977
|
+
if (!config.community?.memberWrite?.delete) {
|
|
978
|
+
throw new NpForbiddenError(collection, "delete");
|
|
979
|
+
}
|
|
980
|
+
const authorId = originalDoc.memberAuthorId ?? null;
|
|
981
|
+
if (authorId !== actor.memberId) {
|
|
982
|
+
throw new NpForbiddenError(collection, "delete");
|
|
983
|
+
}
|
|
984
|
+
const { assertNotBanned } = await import("./can-YLUHRJAB.js");
|
|
985
|
+
await assertNotBanned(actor.memberId);
|
|
986
|
+
}
|
|
987
|
+
const userForHooks = actorUserOrNull(actor);
|
|
988
|
+
const principal = actorPrincipal(actor);
|
|
989
|
+
await runHooks(config.hooks?.beforeDelete, {
|
|
990
|
+
data: originalDoc,
|
|
991
|
+
user: userForHooks,
|
|
992
|
+
principal,
|
|
993
|
+
collection,
|
|
994
|
+
originalDoc
|
|
995
|
+
});
|
|
996
|
+
await runHook("content:beforeDelete", {
|
|
997
|
+
collection,
|
|
998
|
+
doc: originalDoc,
|
|
999
|
+
user: userForHooks,
|
|
1000
|
+
principal
|
|
1001
|
+
});
|
|
1002
|
+
await db.transaction(async (tx) => {
|
|
1003
|
+
await deleteChildTables(tx, registration.childTables, docId);
|
|
1004
|
+
await deleteJoinTables(tx, registration.joinTables, docId);
|
|
1005
|
+
await tx.delete(npMediaRefs).where(
|
|
1006
|
+
sql2`${eq(getTableColumn(npMediaRefs, "collection"), collection)} and ${eq(getTableColumn(npMediaRefs, "documentId"), docId)}`
|
|
1007
|
+
);
|
|
1008
|
+
const commentIdRows = await tx.select({
|
|
1009
|
+
id: getTableColumn(npComments, "id")
|
|
1010
|
+
}).from(npComments).where(
|
|
1011
|
+
sql2`${eq(getTableColumn(npComments, "targetType"), collection)} and ${eq(getTableColumn(npComments, "targetId"), docId)}`
|
|
1012
|
+
);
|
|
1013
|
+
if (commentIdRows.length > 0) {
|
|
1014
|
+
const commentIds = commentIdRows.map((row) => row.id);
|
|
1015
|
+
await tx.delete(npReactions).where(
|
|
1016
|
+
sql2`${eq(getTableColumn(npReactions, "targetType"), "comment")} and ${inArray(getTableColumn(npReactions, "targetId"), commentIds)}`
|
|
1017
|
+
);
|
|
1018
|
+
await tx.delete(npReports).where(
|
|
1019
|
+
sql2`${eq(getTableColumn(npReports, "targetType"), "comment")} and ${inArray(getTableColumn(npReports, "targetId"), commentIds)}`
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
await tx.delete(npComments).where(
|
|
1023
|
+
sql2`${eq(getTableColumn(npComments, "targetType"), collection)} and ${eq(getTableColumn(npComments, "targetId"), docId)}`
|
|
1024
|
+
);
|
|
1025
|
+
await tx.delete(npReactions).where(
|
|
1026
|
+
sql2`${eq(getTableColumn(npReactions, "targetType"), collection)} and ${eq(getTableColumn(npReactions, "targetId"), docId)}`
|
|
1027
|
+
);
|
|
1028
|
+
await tx.delete(npReports).where(
|
|
1029
|
+
sql2`${eq(getTableColumn(npReports, "targetType"), collection)} and ${eq(getTableColumn(npReports, "targetId"), docId)}`
|
|
1030
|
+
);
|
|
1031
|
+
await tx.delete(table).where(eq(getTableColumn(table, "id"), docId));
|
|
1032
|
+
});
|
|
1033
|
+
const postCommitCtx = { collection, documentId: docId, operation: "delete" };
|
|
1034
|
+
await runPostCommit(
|
|
1035
|
+
"enqueue:content:afterDelete",
|
|
1036
|
+
postCommitCtx,
|
|
1037
|
+
() => enqueueJob("content:afterDelete", {
|
|
1038
|
+
collection,
|
|
1039
|
+
documentId: docId,
|
|
1040
|
+
userId: actorUserId(actor)
|
|
1041
|
+
})
|
|
1042
|
+
);
|
|
1043
|
+
await runPostCommit(
|
|
1044
|
+
"hook:content:afterDelete",
|
|
1045
|
+
postCommitCtx,
|
|
1046
|
+
() => runHook("content:afterDelete", {
|
|
1047
|
+
collection,
|
|
1048
|
+
documentId: docId,
|
|
1049
|
+
user: userForHooks,
|
|
1050
|
+
principal
|
|
1051
|
+
})
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
async function findDocuments(collection, options, user) {
|
|
1055
|
+
const config = getCollectionConfig(collection);
|
|
1056
|
+
const table = getCollectionTable(collection);
|
|
1057
|
+
const db = getDb();
|
|
1058
|
+
const page = normalizePage(options.page);
|
|
1059
|
+
const limit = normalizeLimit(options.limit);
|
|
1060
|
+
const offset = (page - 1) * limit;
|
|
1061
|
+
await assertReadAccess(config, collection, user ?? null);
|
|
1062
|
+
let effectiveWhere = options.where ?? {};
|
|
1063
|
+
if (config.i18n && options.locale) {
|
|
1064
|
+
effectiveWhere = { ...effectiveWhere, locale: options.locale };
|
|
1065
|
+
}
|
|
1066
|
+
if (effectiveWhere.siteId === void 0) {
|
|
1067
|
+
const resolved = await getCurrentSiteId();
|
|
1068
|
+
effectiveWhere = {
|
|
1069
|
+
...effectiveWhere,
|
|
1070
|
+
siteId: resolved ?? NP_DEFAULT_SITE_ID
|
|
1071
|
+
};
|
|
1072
|
+
} else if (effectiveWhere.siteId === "*") {
|
|
1073
|
+
const { siteId: _siteId, ...rest } = effectiveWhere;
|
|
1074
|
+
void _siteId;
|
|
1075
|
+
effectiveWhere = rest;
|
|
1076
|
+
}
|
|
1077
|
+
if (effectiveWhere.visibility === void 0 && !user) {
|
|
1078
|
+
effectiveWhere = { ...effectiveWhere, visibility: "public" };
|
|
1079
|
+
} else if (effectiveWhere.visibility === "*") {
|
|
1080
|
+
const { visibility: _vis, ...rest } = effectiveWhere;
|
|
1081
|
+
void _vis;
|
|
1082
|
+
effectiveWhere = rest;
|
|
1083
|
+
}
|
|
1084
|
+
const effectiveOptions = {
|
|
1085
|
+
...options,
|
|
1086
|
+
where: effectiveWhere
|
|
1087
|
+
};
|
|
1088
|
+
const conditions = buildQueryConditions(table, effectiveOptions);
|
|
1089
|
+
const whereClause = combineConditions(conditions);
|
|
1090
|
+
const docs = await executeFindQuery(db, table, options, whereClause, limit, offset);
|
|
1091
|
+
const totalResult = await (whereClause ? db.select({ total: count() }).from(table).where(whereClause) : db.select({ total: count() }).from(table).limit(1));
|
|
1092
|
+
const totalDocs = Number(totalResult[0]?.total ?? 0);
|
|
1093
|
+
const totalPages = totalDocs === 0 ? 0 : Math.ceil(totalDocs / limit);
|
|
1094
|
+
return {
|
|
1095
|
+
// Runtime rows are `Record<string, unknown>`; the generic `T`
|
|
1096
|
+
// is a structural promise — generated wrapper functions narrow
|
|
1097
|
+
// it to the per-collection document shape. We don't validate
|
|
1098
|
+
// at runtime (Drizzle has already shaped the row to the table
|
|
1099
|
+
// schema, which the generator derived from the same field
|
|
1100
|
+
// configs T was generated from).
|
|
1101
|
+
docs,
|
|
1102
|
+
totalDocs,
|
|
1103
|
+
totalPages,
|
|
1104
|
+
page,
|
|
1105
|
+
limit,
|
|
1106
|
+
hasNextPage: page < totalPages,
|
|
1107
|
+
hasPrevPage: page > 1 && totalDocs > 0
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
async function getDocumentById(collection, id, user) {
|
|
1111
|
+
const config = getCollectionConfig(collection);
|
|
1112
|
+
const table = getCollectionTable(collection);
|
|
1113
|
+
const db = getDb();
|
|
1114
|
+
const doc = await getDocumentByIdOptional(db, table, id);
|
|
1115
|
+
if (!doc) {
|
|
1116
|
+
return null;
|
|
1117
|
+
}
|
|
1118
|
+
const requestSiteId = await getCurrentSiteId() ?? NP_DEFAULT_SITE_ID;
|
|
1119
|
+
const docSiteId = typeof doc.siteId === "string" && doc.siteId.length > 0 ? doc.siteId : NP_DEFAULT_SITE_ID;
|
|
1120
|
+
if (docSiteId !== requestSiteId) {
|
|
1121
|
+
throw new NpForbiddenError(collection, "cross-site");
|
|
1122
|
+
}
|
|
1123
|
+
if (config.access?.read) {
|
|
1124
|
+
const allowed = await config.access.read({ user: user ?? null, doc });
|
|
1125
|
+
if (!allowed) {
|
|
1126
|
+
throw new NpForbiddenError(collection, "read");
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
return doc;
|
|
1130
|
+
}
|
|
1131
|
+
async function assertWriteAccess(config, collection, operation, user, data, originalDoc) {
|
|
1132
|
+
const access = operation === "create" ? config.access?.create : config.access?.update;
|
|
1133
|
+
if (!access) {
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
const allowed = await access({ user, doc: originalDoc ?? void 0, data });
|
|
1137
|
+
if (!allowed) {
|
|
1138
|
+
throw new NpForbiddenError(collection, operation);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
async function assertReadAccess(config, collection, user) {
|
|
1142
|
+
if (!config.access?.read) {
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
const allowed = await config.access.read({ user });
|
|
1146
|
+
if (!allowed) {
|
|
1147
|
+
throw new NpForbiddenError(collection, "read");
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
async function runHooks(hooks, args) {
|
|
1151
|
+
let nextData = args.data;
|
|
1152
|
+
for (const hook of hooks ?? []) {
|
|
1153
|
+
nextData = await hook({
|
|
1154
|
+
...args,
|
|
1155
|
+
data: nextData
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
return nextData;
|
|
1159
|
+
}
|
|
1160
|
+
async function createMainDocument(tx, table, mainData, searchVectorSql, config, user, now) {
|
|
1161
|
+
const values = {
|
|
1162
|
+
id: randomUUID(),
|
|
1163
|
+
status: "published",
|
|
1164
|
+
...mainData,
|
|
1165
|
+
createdBy: user?.id ?? null,
|
|
1166
|
+
updatedBy: user?.id ?? null,
|
|
1167
|
+
// Phase 10.7 — composed setweight() tsvector so titles
|
|
1168
|
+
// outrank body matches at query time. The 11.x
|
|
1169
|
+
// to_tsvector wrap (so colon-containing content doesn't
|
|
1170
|
+
// crash the cast) is preserved inside each setweight call
|
|
1171
|
+
// by buildWeightedSearchVectorSql.
|
|
1172
|
+
searchVector: searchVectorSql
|
|
1173
|
+
};
|
|
1174
|
+
if (config.timestamps !== false) {
|
|
1175
|
+
values.createdAt = now;
|
|
1176
|
+
values.updatedAt = now;
|
|
1177
|
+
}
|
|
1178
|
+
const [created] = await tx.insert(table).values(values).returning();
|
|
1179
|
+
return toRecord(created);
|
|
1180
|
+
}
|
|
1181
|
+
async function updateMainDocument(tx, table, collection, docId, mainData, searchVectorSql, config, user, now) {
|
|
1182
|
+
if (!docId) {
|
|
1183
|
+
throw new NpNotFoundError(collection, "unknown");
|
|
1184
|
+
}
|
|
1185
|
+
const values = {
|
|
1186
|
+
...mainData,
|
|
1187
|
+
updatedBy: user?.id ?? null,
|
|
1188
|
+
// Phase 10.7 — see createMainDocument: weighted setweight()
|
|
1189
|
+
// tsvector preserves the 11.x to_tsvector safety AND adds
|
|
1190
|
+
// title boost.
|
|
1191
|
+
searchVector: searchVectorSql
|
|
1192
|
+
};
|
|
1193
|
+
if (config.timestamps !== false) {
|
|
1194
|
+
values.updatedAt = now;
|
|
1195
|
+
}
|
|
1196
|
+
const [updated] = await tx.update(table).set(values).where(eq(getTableColumn(table, "id"), docId)).returning();
|
|
1197
|
+
if (!updated) {
|
|
1198
|
+
throw new NpNotFoundError(collection, docId);
|
|
1199
|
+
}
|
|
1200
|
+
return toRecord(updated);
|
|
1201
|
+
}
|
|
1202
|
+
async function syncChildTables(tx, childTables, childRows, documentId) {
|
|
1203
|
+
for (const [fieldPath, rows] of Object.entries(childRows)) {
|
|
1204
|
+
const table = resolveRelatedTable(childTables, fieldPath);
|
|
1205
|
+
if (!table) {
|
|
1206
|
+
continue;
|
|
1207
|
+
}
|
|
1208
|
+
const pgTable = table;
|
|
1209
|
+
const parentColumnName = findParentColumnName(pgTable, ["parentId"]);
|
|
1210
|
+
await tx.delete(pgTable).where(eq(getTableColumn(pgTable, parentColumnName), documentId));
|
|
1211
|
+
if (rows.length === 0) {
|
|
1212
|
+
continue;
|
|
1213
|
+
}
|
|
1214
|
+
const values = rows.map((row, index) => ({
|
|
1215
|
+
id: randomUUID(),
|
|
1216
|
+
...row,
|
|
1217
|
+
[parentColumnName]: documentId,
|
|
1218
|
+
order: index
|
|
1219
|
+
}));
|
|
1220
|
+
await tx.insert(pgTable).values(values);
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
async function syncJoinTables(tx, joinTables, joinRows, documentId) {
|
|
1224
|
+
for (const [fieldPath, ids] of Object.entries(joinRows)) {
|
|
1225
|
+
const table = resolveRelatedTable(joinTables, fieldPath);
|
|
1226
|
+
if (!table) {
|
|
1227
|
+
continue;
|
|
1228
|
+
}
|
|
1229
|
+
const pgTable = table;
|
|
1230
|
+
const parentColumnName = findParentColumnName(pgTable, ["parentId"]);
|
|
1231
|
+
await tx.delete(pgTable).where(eq(getTableColumn(pgTable, parentColumnName), documentId));
|
|
1232
|
+
if (ids.length === 0) {
|
|
1233
|
+
continue;
|
|
1234
|
+
}
|
|
1235
|
+
const values = ids.map((targetId, index) => ({
|
|
1236
|
+
id: randomUUID(),
|
|
1237
|
+
[parentColumnName]: documentId,
|
|
1238
|
+
targetId,
|
|
1239
|
+
order: index
|
|
1240
|
+
}));
|
|
1241
|
+
await tx.insert(pgTable).values(values);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
async function deleteChildTables(tx, childTables, documentId) {
|
|
1245
|
+
for (const table of Object.values(childTables ?? {})) {
|
|
1246
|
+
const pgTable = table;
|
|
1247
|
+
const parentColumnName = findParentColumnName(pgTable, ["parentId"]);
|
|
1248
|
+
await tx.delete(pgTable).where(eq(getTableColumn(pgTable, parentColumnName), documentId));
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
async function deleteJoinTables(tx, joinTables, documentId) {
|
|
1252
|
+
for (const table of Object.values(joinTables ?? {})) {
|
|
1253
|
+
const pgTable = table;
|
|
1254
|
+
const parentColumnName = findParentColumnName(pgTable, ["parentId"]);
|
|
1255
|
+
await tx.delete(pgTable).where(eq(getTableColumn(pgTable, parentColumnName), documentId));
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
async function insertRevision(tx, collection, documentId, operation, data, originalDoc, user, status, maxRevisions) {
|
|
1259
|
+
const revisionConditions = sql2`${eq(npRevisions.collection, collection)} and ${eq(npRevisions.documentId, documentId)}`;
|
|
1260
|
+
const [revisionCount] = await tx.select({ total: count() }).from(npRevisions).where(revisionConditions);
|
|
1261
|
+
await tx.insert(npRevisions).values({
|
|
1262
|
+
collection,
|
|
1263
|
+
documentId,
|
|
1264
|
+
version: Number(revisionCount?.total ?? 0) + 1,
|
|
1265
|
+
status,
|
|
1266
|
+
snapshot: data,
|
|
1267
|
+
changedFields: getChangedFields(data, originalDoc, operation),
|
|
1268
|
+
// `authorId` references np_users; member-authored revisions
|
|
1269
|
+
// store null and the audit log carries the actual member id.
|
|
1270
|
+
authorId: user?.id ?? null,
|
|
1271
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
1272
|
+
});
|
|
1273
|
+
if (maxRevisions !== void 0 && maxRevisions > 0) {
|
|
1274
|
+
const currentCount = Number(revisionCount?.total ?? 0) + 1;
|
|
1275
|
+
const overflow = currentCount - maxRevisions;
|
|
1276
|
+
if (overflow > 0) {
|
|
1277
|
+
const toDelete = await tx.select({ id: npRevisions.id }).from(npRevisions).where(revisionConditions).orderBy(asc(npRevisions.version)).limit(overflow);
|
|
1278
|
+
if (toDelete.length > 0) {
|
|
1279
|
+
const ids = toDelete.map((r) => r.id);
|
|
1280
|
+
await tx.delete(npRevisions).where(sql2`${npRevisions.id} = any(${ids}::uuid[])`);
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
function buildQueryConditions(table, options) {
|
|
1286
|
+
const conditions = [];
|
|
1287
|
+
if (options.where) {
|
|
1288
|
+
for (const [field, value] of Object.entries(options.where)) {
|
|
1289
|
+
if (value === void 0) {
|
|
1290
|
+
continue;
|
|
1291
|
+
}
|
|
1292
|
+
if (Array.isArray(value)) {
|
|
1293
|
+
if (value.length === 0) {
|
|
1294
|
+
conditions.push(sql2`false`);
|
|
1295
|
+
} else {
|
|
1296
|
+
conditions.push(inArray(getTableColumn(table, field), value));
|
|
1297
|
+
}
|
|
1298
|
+
continue;
|
|
1299
|
+
}
|
|
1300
|
+
conditions.push(eq(getTableColumn(table, field), value));
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
if (options.search) {
|
|
1304
|
+
conditions.push(
|
|
1305
|
+
sql2`${getTableColumn(table, "searchVector")} @@ plainto_tsquery('english', ${options.search})`
|
|
1306
|
+
);
|
|
1307
|
+
}
|
|
1308
|
+
return conditions;
|
|
1309
|
+
}
|
|
1310
|
+
async function executeFindQuery(db, table, options, whereClause, limit, offset) {
|
|
1311
|
+
if (options.search) {
|
|
1312
|
+
const query = whereClause ? db.select().from(table).where(whereClause).orderBy(
|
|
1313
|
+
sql2`ts_rank(${getTableColumn(table, "searchVector")}, plainto_tsquery('english', ${options.search})) DESC`
|
|
1314
|
+
).limit(limit).offset(offset) : db.select().from(table).orderBy(
|
|
1315
|
+
sql2`ts_rank(${getTableColumn(table, "searchVector")}, plainto_tsquery('english', ${options.search})) DESC`
|
|
1316
|
+
).limit(limit).offset(offset);
|
|
1317
|
+
return await query;
|
|
1318
|
+
}
|
|
1319
|
+
const orderClause = getSortOrderClause(table, options.sort);
|
|
1320
|
+
if (whereClause && orderClause) {
|
|
1321
|
+
return await db.select().from(table).where(whereClause).orderBy(orderClause).limit(limit).offset(offset);
|
|
1322
|
+
}
|
|
1323
|
+
if (whereClause) {
|
|
1324
|
+
return await db.select().from(table).where(whereClause).limit(limit).offset(offset);
|
|
1325
|
+
}
|
|
1326
|
+
if (orderClause) {
|
|
1327
|
+
return await db.select().from(table).orderBy(orderClause).limit(limit).offset(offset);
|
|
1328
|
+
}
|
|
1329
|
+
return await db.select().from(table).limit(limit).offset(offset);
|
|
1330
|
+
}
|
|
1331
|
+
function getSortOrderClause(table, sortValue) {
|
|
1332
|
+
const sort = sortValue?.trim();
|
|
1333
|
+
if (!sort) {
|
|
1334
|
+
return void 0;
|
|
1335
|
+
}
|
|
1336
|
+
const isDescending = sort.startsWith("-");
|
|
1337
|
+
const field = isDescending ? sort.slice(1) : sort;
|
|
1338
|
+
const column = getTableColumn(table, field);
|
|
1339
|
+
return isDescending ? desc(column) : asc(column);
|
|
1340
|
+
}
|
|
1341
|
+
async function getDocumentByIdInternal(db, table, collection, id, options) {
|
|
1342
|
+
const doc = await getDocumentByIdOptional(db, table, id);
|
|
1343
|
+
if (!doc) {
|
|
1344
|
+
throw new NpNotFoundError(collection, id);
|
|
1345
|
+
}
|
|
1346
|
+
if (!options?.allowCrossSite) {
|
|
1347
|
+
const requestSiteId = await getCurrentSiteId() ?? NP_DEFAULT_SITE_ID;
|
|
1348
|
+
const docSiteId = typeof doc.siteId === "string" && doc.siteId.length > 0 ? doc.siteId : NP_DEFAULT_SITE_ID;
|
|
1349
|
+
if (docSiteId !== requestSiteId) {
|
|
1350
|
+
throw new NpForbiddenError(collection, "cross-site");
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
return doc;
|
|
1354
|
+
}
|
|
1355
|
+
async function getDocumentByIdOptional(db, table, id) {
|
|
1356
|
+
const [doc] = await db.select().from(table).where(eq(getTableColumn(table, "id"), id)).limit(1);
|
|
1357
|
+
return doc ? toRecord(doc) : null;
|
|
1358
|
+
}
|
|
1359
|
+
function prepareDocumentData(fields, data) {
|
|
1360
|
+
const prepared = {
|
|
1361
|
+
mainData: {},
|
|
1362
|
+
childRows: {},
|
|
1363
|
+
joinRows: {}
|
|
1364
|
+
};
|
|
1365
|
+
collectPreparedDocumentData(fields, data, prepared, []);
|
|
1366
|
+
if (typeof data.slug === "string") {
|
|
1367
|
+
prepared.mainData.slug = data.slug;
|
|
1368
|
+
}
|
|
1369
|
+
if (typeof data.locale === "string") {
|
|
1370
|
+
prepared.mainData.locale = data.locale;
|
|
1371
|
+
}
|
|
1372
|
+
if (typeof data.translationGroupId === "string") {
|
|
1373
|
+
prepared.mainData.translationGroupId = data.translationGroupId;
|
|
1374
|
+
}
|
|
1375
|
+
if (typeof data.siteId === "string") {
|
|
1376
|
+
prepared.mainData.siteId = data.siteId;
|
|
1377
|
+
}
|
|
1378
|
+
if (typeof data.visibility === "string") {
|
|
1379
|
+
prepared.mainData.visibility = data.visibility;
|
|
1380
|
+
}
|
|
1381
|
+
return prepared;
|
|
1382
|
+
}
|
|
1383
|
+
function collectPreparedDocumentData(fields, data, prepared, prefix) {
|
|
1384
|
+
for (const field of fields) {
|
|
1385
|
+
if (field.type === "row" || field.type === "collapsible") {
|
|
1386
|
+
collectPreparedDocumentData(field.fields, data, prepared, prefix);
|
|
1387
|
+
continue;
|
|
1388
|
+
}
|
|
1389
|
+
if (field.type === "group") {
|
|
1390
|
+
const groupValue = toOptionalRecord(data[field.name]);
|
|
1391
|
+
if (groupValue) {
|
|
1392
|
+
collectPreparedDocumentData(field.fields, groupValue, prepared, [...prefix, field.name]);
|
|
1393
|
+
}
|
|
1394
|
+
continue;
|
|
1395
|
+
}
|
|
1396
|
+
const fieldPath = [...prefix, field.name];
|
|
1397
|
+
const fieldKey = fieldPath.join(".");
|
|
1398
|
+
const value = data[field.name];
|
|
1399
|
+
if (field.type === "array") {
|
|
1400
|
+
prepared.childRows[fieldKey] = normalizeChildRows(field.fields, value);
|
|
1401
|
+
continue;
|
|
1402
|
+
}
|
|
1403
|
+
if (field.type === "relationship" && field.hasMany) {
|
|
1404
|
+
prepared.joinRows[fieldKey] = normalizeJoinIds(value);
|
|
1405
|
+
continue;
|
|
1406
|
+
}
|
|
1407
|
+
prepared.mainData[getFlattenedFieldName(prefix, field.name)] = value ?? null;
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
function normalizeChildRows(fields, value) {
|
|
1411
|
+
if (!Array.isArray(value)) {
|
|
1412
|
+
return [];
|
|
1413
|
+
}
|
|
1414
|
+
return value.map((item) => {
|
|
1415
|
+
const row = toOptionalRecord(item) ?? {};
|
|
1416
|
+
const prepared = {
|
|
1417
|
+
mainData: {},
|
|
1418
|
+
childRows: {},
|
|
1419
|
+
joinRows: {}
|
|
1420
|
+
};
|
|
1421
|
+
collectPreparedDocumentData(fields, row, prepared, []);
|
|
1422
|
+
return prepared.mainData;
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
function normalizeJoinIds(value) {
|
|
1426
|
+
if (!Array.isArray(value)) {
|
|
1427
|
+
return [];
|
|
1428
|
+
}
|
|
1429
|
+
return value.filter((item) => typeof item === "string");
|
|
1430
|
+
}
|
|
1431
|
+
async function syncMediaRefsForDocument(tx, collection, documentId, fields, data) {
|
|
1432
|
+
const refs = extractMediaIdsFromFields(fields, data, []);
|
|
1433
|
+
if (refs.length === 0) {
|
|
1434
|
+
await tx.delete(npMediaRefs).where(
|
|
1435
|
+
sql2`${eq(getTableColumn(npMediaRefs, "collection"), collection)} and ${eq(getTableColumn(npMediaRefs, "documentId"), documentId)}`
|
|
1436
|
+
);
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
await tx.delete(npMediaRefs).where(
|
|
1440
|
+
sql2`${eq(getTableColumn(npMediaRefs, "collection"), collection)} and ${eq(getTableColumn(npMediaRefs, "documentId"), documentId)}`
|
|
1441
|
+
);
|
|
1442
|
+
const values = refs.map((ref) => ({
|
|
1443
|
+
id: randomUUID(),
|
|
1444
|
+
mediaId: ref.mediaId,
|
|
1445
|
+
collection,
|
|
1446
|
+
documentId,
|
|
1447
|
+
field: ref.field
|
|
1448
|
+
}));
|
|
1449
|
+
await tx.insert(npMediaRefs).values(values);
|
|
1450
|
+
}
|
|
1451
|
+
function extractMediaIdsFromFields(fields, data, prefix) {
|
|
1452
|
+
const refs = [];
|
|
1453
|
+
for (const field of fields) {
|
|
1454
|
+
if (field.type === "row" || field.type === "collapsible") {
|
|
1455
|
+
refs.push(...extractMediaIdsFromFields(field.fields, data, prefix));
|
|
1456
|
+
continue;
|
|
1457
|
+
}
|
|
1458
|
+
if (field.type === "group") {
|
|
1459
|
+
const groupData = toOptionalRecord(data[field.name]);
|
|
1460
|
+
if (groupData) {
|
|
1461
|
+
refs.push(...extractMediaIdsFromFields(field.fields, groupData, [...prefix, field.name]));
|
|
1462
|
+
}
|
|
1463
|
+
continue;
|
|
1464
|
+
}
|
|
1465
|
+
const fieldPath = [...prefix, field.name].join(".");
|
|
1466
|
+
if (field.type === "upload") {
|
|
1467
|
+
const mediaId = data[field.name];
|
|
1468
|
+
if (typeof mediaId === "string" && mediaId.length > 0) {
|
|
1469
|
+
refs.push({ mediaId, field: fieldPath });
|
|
1470
|
+
}
|
|
1471
|
+
continue;
|
|
1472
|
+
}
|
|
1473
|
+
if (field.type === "richText") {
|
|
1474
|
+
const richTextValue = data[field.name];
|
|
1475
|
+
if (richTextValue && typeof richTextValue === "object") {
|
|
1476
|
+
refs.push(...extractMediaIdsFromLexicalJson(richTextValue, fieldPath));
|
|
1477
|
+
}
|
|
1478
|
+
continue;
|
|
1479
|
+
}
|
|
1480
|
+
if (field.type === "array") {
|
|
1481
|
+
const arrayValue = data[field.name];
|
|
1482
|
+
if (Array.isArray(arrayValue)) {
|
|
1483
|
+
for (const item of arrayValue) {
|
|
1484
|
+
const itemRecord = toOptionalRecord(item);
|
|
1485
|
+
if (itemRecord) {
|
|
1486
|
+
refs.push(
|
|
1487
|
+
...extractMediaIdsFromFields(field.fields, itemRecord, [...prefix, field.name])
|
|
1488
|
+
);
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
continue;
|
|
1493
|
+
}
|
|
1494
|
+
if (field.type === "blocks") {
|
|
1495
|
+
const blocksValue = data[field.name];
|
|
1496
|
+
if (Array.isArray(blocksValue)) {
|
|
1497
|
+
for (const block of blocksValue) {
|
|
1498
|
+
const blockRecord = toOptionalRecord(block);
|
|
1499
|
+
if (blockRecord) {
|
|
1500
|
+
extractBlockMediaIds(blockRecord, fieldPath, refs);
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
continue;
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
return refs;
|
|
1508
|
+
}
|
|
1509
|
+
function extractMediaIdsFromLexicalJson(node, fieldPath) {
|
|
1510
|
+
const refs = [];
|
|
1511
|
+
if (!node || typeof node !== "object") {
|
|
1512
|
+
return refs;
|
|
1513
|
+
}
|
|
1514
|
+
const record = node;
|
|
1515
|
+
if (record.type === "image" || record.type === "upload") {
|
|
1516
|
+
const mediaId = record.mediaId ?? record.value;
|
|
1517
|
+
if (typeof mediaId === "string" && mediaId.length > 0) {
|
|
1518
|
+
refs.push({ mediaId, field: fieldPath });
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
const children = record.children ?? toOptionalRecord(record.root)?.children;
|
|
1522
|
+
if (Array.isArray(children)) {
|
|
1523
|
+
for (const child of children) {
|
|
1524
|
+
refs.push(...extractMediaIdsFromLexicalJson(child, fieldPath));
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
return refs;
|
|
1528
|
+
}
|
|
1529
|
+
function extractBlockMediaIds(block, fieldPath, refs) {
|
|
1530
|
+
for (const [key, value] of Object.entries(block)) {
|
|
1531
|
+
if (key === "blockType" || key === "id") {
|
|
1532
|
+
continue;
|
|
1533
|
+
}
|
|
1534
|
+
if (typeof value === "string" && isUuid(value)) {
|
|
1535
|
+
refs.push({ mediaId: value, field: `${fieldPath}.${key}` });
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
function isUuid(value) {
|
|
1540
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
|
|
1541
|
+
}
|
|
1542
|
+
function getChangedFields(data, originalDoc, operation) {
|
|
1543
|
+
if (operation === "create" || !originalDoc) {
|
|
1544
|
+
return Object.keys(data);
|
|
1545
|
+
}
|
|
1546
|
+
return Object.keys(data).filter((field) => !Object.is(data[field], originalDoc[field]));
|
|
1547
|
+
}
|
|
1548
|
+
function combineConditions(conditions) {
|
|
1549
|
+
if (conditions.length === 0) {
|
|
1550
|
+
return void 0;
|
|
1551
|
+
}
|
|
1552
|
+
return sql2`${sql2.join(conditions, sql2` and `)}`;
|
|
1553
|
+
}
|
|
1554
|
+
function resolveRelatedTable(tables, fieldPath) {
|
|
1555
|
+
return tables?.[fieldPath] ?? tables?.[fieldPath.split(".").at(-1) ?? fieldPath];
|
|
1556
|
+
}
|
|
1557
|
+
function findParentColumnName(table, preferred) {
|
|
1558
|
+
const keys = Object.keys(table);
|
|
1559
|
+
for (const key of preferred) {
|
|
1560
|
+
if (keys.includes(key)) {
|
|
1561
|
+
return key;
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
const derived = keys.find(
|
|
1565
|
+
(key) => key !== "id" && key !== "targetId" && key !== "order" && key.endsWith("Id")
|
|
1566
|
+
);
|
|
1567
|
+
if (!derived) {
|
|
1568
|
+
throw new Error("Unable to resolve parent column for related table.");
|
|
1569
|
+
}
|
|
1570
|
+
return derived;
|
|
1571
|
+
}
|
|
1572
|
+
function getTableColumn(table, key) {
|
|
1573
|
+
const column = table[key];
|
|
1574
|
+
if (!column) {
|
|
1575
|
+
throw new Error(`Column '${key}' not found on table.`);
|
|
1576
|
+
}
|
|
1577
|
+
return column;
|
|
1578
|
+
}
|
|
1579
|
+
function getRecordId(record) {
|
|
1580
|
+
const id = record.id;
|
|
1581
|
+
if (typeof id !== "string") {
|
|
1582
|
+
throw new Error("Expected saved document to include a string id.");
|
|
1583
|
+
}
|
|
1584
|
+
return id;
|
|
1585
|
+
}
|
|
1586
|
+
function toRecord(value) {
|
|
1587
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1588
|
+
throw new Error("Expected object record.");
|
|
1589
|
+
}
|
|
1590
|
+
return value;
|
|
1591
|
+
}
|
|
1592
|
+
function toOptionalRecord(value) {
|
|
1593
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1594
|
+
return null;
|
|
1595
|
+
}
|
|
1596
|
+
return value;
|
|
1597
|
+
}
|
|
1598
|
+
function normalizePage(page) {
|
|
1599
|
+
if (!page || page < 1) {
|
|
1600
|
+
return 1;
|
|
1601
|
+
}
|
|
1602
|
+
return Math.floor(page);
|
|
1603
|
+
}
|
|
1604
|
+
function normalizeLimit(limit) {
|
|
1605
|
+
if (!limit || limit < 1) {
|
|
1606
|
+
return 10;
|
|
1607
|
+
}
|
|
1608
|
+
return Math.floor(limit);
|
|
1609
|
+
}
|
|
1610
|
+
function getFlattenedFieldName(prefix, name) {
|
|
1611
|
+
if (prefix.length === 0) {
|
|
1612
|
+
return toCamelCase(name);
|
|
1613
|
+
}
|
|
1614
|
+
return `${prefix.map(toPascalCase).join("")}${toPascalCase(name)}`.replace(
|
|
1615
|
+
/^./u,
|
|
1616
|
+
(char) => char.toLowerCase()
|
|
1617
|
+
);
|
|
1618
|
+
}
|
|
1619
|
+
function toCamelCase(value) {
|
|
1620
|
+
const parts = splitName(value);
|
|
1621
|
+
const [first = "", ...rest] = parts;
|
|
1622
|
+
return `${first}${rest.map(toPascalCase).join("")}`;
|
|
1623
|
+
}
|
|
1624
|
+
function toPascalCase(value) {
|
|
1625
|
+
return splitName(value).map(capitalize).join("");
|
|
1626
|
+
}
|
|
1627
|
+
function splitName(value) {
|
|
1628
|
+
return value.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^a-zA-Z0-9]+/).map((part) => part.toLowerCase()).filter(Boolean);
|
|
1629
|
+
}
|
|
1630
|
+
function capitalize(value) {
|
|
1631
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
// src/plugins/context.ts
|
|
1635
|
+
async function resolveStorageSiteId() {
|
|
1636
|
+
return await getCurrentSiteId() ?? NP_GLOBAL_PLUGIN_SITE_ID;
|
|
1637
|
+
}
|
|
1638
|
+
async function resolveSettingsSiteId() {
|
|
1639
|
+
return await getCurrentSiteId() ?? NP_DEFAULT_SITE_ID;
|
|
1640
|
+
}
|
|
1641
|
+
var pluginPrincipal = (pluginId) => ({
|
|
1642
|
+
id: `plugin:${pluginId}`,
|
|
1643
|
+
email: `${pluginId}@plugins.local`,
|
|
1644
|
+
name: `plugin/${pluginId}`,
|
|
1645
|
+
role: "admin",
|
|
1646
|
+
tokenVersion: 0
|
|
1647
|
+
});
|
|
1648
|
+
var pluginCache = /* @__PURE__ */ new Map();
|
|
1649
|
+
function cacheKey(pluginId, key) {
|
|
1650
|
+
return `${pluginId}:${key}`;
|
|
1651
|
+
}
|
|
1652
|
+
function assertCap(pluginId, capabilities, required) {
|
|
1653
|
+
if (!capabilities.includes(required)) {
|
|
1654
|
+
throw new NpForbiddenError(
|
|
1655
|
+
`plugin:${pluginId}`,
|
|
1656
|
+
`capability "${required}" not declared in manifest`
|
|
1657
|
+
);
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
async function loadOptionalNextCache() {
|
|
1661
|
+
try {
|
|
1662
|
+
const moduleId = "next/cache";
|
|
1663
|
+
const mod = await import(moduleId);
|
|
1664
|
+
return mod;
|
|
1665
|
+
} catch {
|
|
1666
|
+
return null;
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
function createPluginRuntimeContext(options) {
|
|
1670
|
+
const { pluginId, capabilities, allowedHosts, config, registration, lookupRegistration } = options;
|
|
1671
|
+
const db = () => getDb();
|
|
1672
|
+
const principal = pluginPrincipal(pluginId);
|
|
1673
|
+
const pluginLog = getScopedLogger({ pluginId });
|
|
1674
|
+
return {
|
|
1675
|
+
pluginId,
|
|
1676
|
+
config,
|
|
1677
|
+
capabilities,
|
|
1678
|
+
content: {
|
|
1679
|
+
async find(collection, query) {
|
|
1680
|
+
assertCap(pluginId, capabilities, "content:read");
|
|
1681
|
+
return findDocuments(collection, query ?? {}, principal);
|
|
1682
|
+
},
|
|
1683
|
+
async findOne(collection, id) {
|
|
1684
|
+
assertCap(pluginId, capabilities, "content:read");
|
|
1685
|
+
const doc = await getDocumentById(collection, id, principal);
|
|
1686
|
+
return doc ?? null;
|
|
1687
|
+
},
|
|
1688
|
+
async create(collection, data) {
|
|
1689
|
+
assertCap(pluginId, capabilities, "content:write");
|
|
1690
|
+
const result = await saveDocument(collection, null, data, principal);
|
|
1691
|
+
return result.doc;
|
|
1692
|
+
},
|
|
1693
|
+
async update(collection, id, data) {
|
|
1694
|
+
assertCap(pluginId, capabilities, "content:write");
|
|
1695
|
+
const result = await saveDocument(collection, id, data, principal);
|
|
1696
|
+
return result.doc;
|
|
1697
|
+
},
|
|
1698
|
+
async delete(collection, id) {
|
|
1699
|
+
assertCap(pluginId, capabilities, "content:delete");
|
|
1700
|
+
await deleteDocument(collection, id, principal);
|
|
1701
|
+
},
|
|
1702
|
+
async count(collection) {
|
|
1703
|
+
assertCap(pluginId, capabilities, "content:read");
|
|
1704
|
+
const result = await findDocuments(collection, { limit: 1 }, principal);
|
|
1705
|
+
return result.totalDocs;
|
|
1706
|
+
}
|
|
1707
|
+
},
|
|
1708
|
+
media: {
|
|
1709
|
+
async list(query) {
|
|
1710
|
+
assertCap(pluginId, capabilities, "media:read");
|
|
1711
|
+
return listMedia({
|
|
1712
|
+
page: query?.page,
|
|
1713
|
+
limit: query?.limit,
|
|
1714
|
+
mimeType: query?.mimeType,
|
|
1715
|
+
folderId: query?.folder
|
|
1716
|
+
});
|
|
1717
|
+
},
|
|
1718
|
+
async getById(id) {
|
|
1719
|
+
assertCap(pluginId, capabilities, "media:read");
|
|
1720
|
+
return getMediaById(id);
|
|
1721
|
+
},
|
|
1722
|
+
async getUrl(id) {
|
|
1723
|
+
assertCap(pluginId, capabilities, "media:read");
|
|
1724
|
+
const media = await getMediaById(id);
|
|
1725
|
+
if (!media || typeof media.storageKey !== "string") return "";
|
|
1726
|
+
const adapter = getStorageAdapter();
|
|
1727
|
+
return adapter.getUrl(media.storageKey);
|
|
1728
|
+
},
|
|
1729
|
+
async upload(file, metadata) {
|
|
1730
|
+
assertCap(pluginId, capabilities, "media:write");
|
|
1731
|
+
const buffer = Buffer.from(file instanceof ArrayBuffer ? new Uint8Array(file) : file);
|
|
1732
|
+
return uploadMedia(
|
|
1733
|
+
{
|
|
1734
|
+
buffer,
|
|
1735
|
+
originalFilename: metadata.filename,
|
|
1736
|
+
mimeType: metadata.mimeType
|
|
1737
|
+
},
|
|
1738
|
+
// `uploaded_by` is a nullable FK to `np_users.id`. The
|
|
1739
|
+
// previous `plugin:<id>` synthetic value violated the FK
|
|
1740
|
+
// and threw at insert time, leaving the storage object
|
|
1741
|
+
// orphaned. (#62) Plugin attribution lives in the audit
|
|
1742
|
+
// log + plugin-storage layer; the uploader column is for
|
|
1743
|
+
// staff-user provenance only.
|
|
1744
|
+
null,
|
|
1745
|
+
metadata.folder
|
|
1746
|
+
);
|
|
1747
|
+
},
|
|
1748
|
+
async delete(id) {
|
|
1749
|
+
assertCap(pluginId, capabilities, "media:delete");
|
|
1750
|
+
const result = await deleteMedia(id);
|
|
1751
|
+
if (!result.deleted && result.references && result.references.length > 0) {
|
|
1752
|
+
throw new NpError(
|
|
1753
|
+
`[plugin:${pluginId}] media.delete: ${id} is referenced by ${result.references.length} document(s).`,
|
|
1754
|
+
"CONFLICT",
|
|
1755
|
+
409
|
|
1756
|
+
);
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
},
|
|
1760
|
+
storage: {
|
|
1761
|
+
// Phase 17 — every storage call resolves the current
|
|
1762
|
+
// site at call time and uses it as part of the composite
|
|
1763
|
+
// PK `(plugin_id, site_id, key)`. Background workers and
|
|
1764
|
+
// scripts (no site resolver) fall back to the
|
|
1765
|
+
// `_global_` sentinel so legacy single-site callers keep
|
|
1766
|
+
// their existing keyspace.
|
|
1767
|
+
async get(key) {
|
|
1768
|
+
assertCap(pluginId, capabilities, "storage:kv");
|
|
1769
|
+
const siteId = await resolveStorageSiteId();
|
|
1770
|
+
const now = /* @__PURE__ */ new Date();
|
|
1771
|
+
const rows = await db().select().from(npPluginStorage).where(
|
|
1772
|
+
and(
|
|
1773
|
+
eq2(npPluginStorage.pluginId, pluginId),
|
|
1774
|
+
eq2(npPluginStorage.siteId, siteId),
|
|
1775
|
+
eq2(npPluginStorage.key, key),
|
|
1776
|
+
or(isNull(npPluginStorage.expiresAt), gt(npPluginStorage.expiresAt, now))
|
|
1777
|
+
)
|
|
1778
|
+
).limit(1);
|
|
1779
|
+
const row = rows[0];
|
|
1780
|
+
return row?.value ?? null;
|
|
1781
|
+
},
|
|
1782
|
+
async set(key, value, opts) {
|
|
1783
|
+
assertCap(pluginId, capabilities, "storage:kv");
|
|
1784
|
+
const siteId = await resolveStorageSiteId();
|
|
1785
|
+
const expiresAt = opts?.ttl && opts.ttl > 0 ? new Date(Date.now() + opts.ttl * 1e3) : null;
|
|
1786
|
+
await db().insert(npPluginStorage).values({
|
|
1787
|
+
pluginId,
|
|
1788
|
+
siteId,
|
|
1789
|
+
key,
|
|
1790
|
+
value,
|
|
1791
|
+
expiresAt,
|
|
1792
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1793
|
+
}).onConflictDoUpdate({
|
|
1794
|
+
target: [npPluginStorage.pluginId, npPluginStorage.siteId, npPluginStorage.key],
|
|
1795
|
+
set: { value, expiresAt, updatedAt: /* @__PURE__ */ new Date() }
|
|
1796
|
+
});
|
|
1797
|
+
},
|
|
1798
|
+
async delete(key) {
|
|
1799
|
+
assertCap(pluginId, capabilities, "storage:kv");
|
|
1800
|
+
const siteId = await resolveStorageSiteId();
|
|
1801
|
+
await db().delete(npPluginStorage).where(
|
|
1802
|
+
and(
|
|
1803
|
+
eq2(npPluginStorage.pluginId, pluginId),
|
|
1804
|
+
eq2(npPluginStorage.siteId, siteId),
|
|
1805
|
+
eq2(npPluginStorage.key, key)
|
|
1806
|
+
)
|
|
1807
|
+
);
|
|
1808
|
+
},
|
|
1809
|
+
async list(prefix) {
|
|
1810
|
+
assertCap(pluginId, capabilities, "storage:kv");
|
|
1811
|
+
const siteId = await resolveStorageSiteId();
|
|
1812
|
+
const now = /* @__PURE__ */ new Date();
|
|
1813
|
+
const where = prefix ? and(
|
|
1814
|
+
eq2(npPluginStorage.pluginId, pluginId),
|
|
1815
|
+
eq2(npPluginStorage.siteId, siteId),
|
|
1816
|
+
like(npPluginStorage.key, `${prefix}%`),
|
|
1817
|
+
or(isNull(npPluginStorage.expiresAt), gt(npPluginStorage.expiresAt, now))
|
|
1818
|
+
) : and(
|
|
1819
|
+
eq2(npPluginStorage.pluginId, pluginId),
|
|
1820
|
+
eq2(npPluginStorage.siteId, siteId),
|
|
1821
|
+
or(isNull(npPluginStorage.expiresAt), gt(npPluginStorage.expiresAt, now))
|
|
1822
|
+
);
|
|
1823
|
+
const rows = await db().select({ key: npPluginStorage.key }).from(npPluginStorage).where(where);
|
|
1824
|
+
return rows.map((row) => row.key);
|
|
1825
|
+
},
|
|
1826
|
+
async has(key) {
|
|
1827
|
+
assertCap(pluginId, capabilities, "storage:kv");
|
|
1828
|
+
const siteId = await resolveStorageSiteId();
|
|
1829
|
+
const now = /* @__PURE__ */ new Date();
|
|
1830
|
+
const rows = await db().select({ key: npPluginStorage.key }).from(npPluginStorage).where(
|
|
1831
|
+
and(
|
|
1832
|
+
eq2(npPluginStorage.pluginId, pluginId),
|
|
1833
|
+
eq2(npPluginStorage.siteId, siteId),
|
|
1834
|
+
eq2(npPluginStorage.key, key),
|
|
1835
|
+
or(isNull(npPluginStorage.expiresAt), gt(npPluginStorage.expiresAt, now))
|
|
1836
|
+
)
|
|
1837
|
+
).limit(1);
|
|
1838
|
+
return rows.length > 0;
|
|
1839
|
+
}
|
|
1840
|
+
},
|
|
1841
|
+
cache: {
|
|
1842
|
+
// The cache namespace is in-memory today (a process-
|
|
1843
|
+
// scoped Map). The interface is `Promise<...>` so a
|
|
1844
|
+
// future Redis-backed implementation can swap in
|
|
1845
|
+
// without breaking plugin authors; the sync
|
|
1846
|
+
// implementations return resolved promises directly so
|
|
1847
|
+
// the require-await rule stays happy.
|
|
1848
|
+
get(key) {
|
|
1849
|
+
const entry = pluginCache.get(cacheKey(pluginId, key));
|
|
1850
|
+
if (!entry) return Promise.resolve(null);
|
|
1851
|
+
if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
|
|
1852
|
+
pluginCache.delete(cacheKey(pluginId, key));
|
|
1853
|
+
return Promise.resolve(null);
|
|
1854
|
+
}
|
|
1855
|
+
return Promise.resolve(entry.value);
|
|
1856
|
+
},
|
|
1857
|
+
set(key, value, ttl) {
|
|
1858
|
+
pluginCache.set(cacheKey(pluginId, key), {
|
|
1859
|
+
value,
|
|
1860
|
+
expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null
|
|
1861
|
+
});
|
|
1862
|
+
return Promise.resolve();
|
|
1863
|
+
},
|
|
1864
|
+
invalidate(key) {
|
|
1865
|
+
pluginCache.delete(cacheKey(pluginId, key));
|
|
1866
|
+
return Promise.resolve();
|
|
1867
|
+
},
|
|
1868
|
+
invalidateAll() {
|
|
1869
|
+
const prefix = `${pluginId}:`;
|
|
1870
|
+
for (const key of pluginCache.keys()) {
|
|
1871
|
+
if (key.startsWith(prefix)) pluginCache.delete(key);
|
|
1872
|
+
}
|
|
1873
|
+
return Promise.resolve();
|
|
1874
|
+
}
|
|
1875
|
+
},
|
|
1876
|
+
settings: {
|
|
1877
|
+
async getSite() {
|
|
1878
|
+
assertCap(pluginId, capabilities, "settings:read");
|
|
1879
|
+
const siteId = await resolveSettingsSiteId();
|
|
1880
|
+
const rows = await db().select().from(npSettings).where(and(eq2(npSettings.siteId, siteId), eq2(npSettings.key, "site")));
|
|
1881
|
+
const row = rows[0];
|
|
1882
|
+
if (!row || !row.value || typeof row.value !== "object" || Array.isArray(row.value)) {
|
|
1883
|
+
return {};
|
|
1884
|
+
}
|
|
1885
|
+
return row.value;
|
|
1886
|
+
},
|
|
1887
|
+
async getPlugin() {
|
|
1888
|
+
const { getPluginConfig } = await import("./config-2GDU7PCK.js");
|
|
1889
|
+
const value = await getPluginConfig(pluginId);
|
|
1890
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
1891
|
+
return value;
|
|
1892
|
+
}
|
|
1893
|
+
return {};
|
|
1894
|
+
},
|
|
1895
|
+
async setPlugin(data) {
|
|
1896
|
+
const { setPluginConfig } = await import("./config-2GDU7PCK.js");
|
|
1897
|
+
const { getPluginRegistration: getPluginRegistration2 } = await import("./host-OBOI4MJK.js");
|
|
1898
|
+
const reg = getPluginRegistration2(pluginId);
|
|
1899
|
+
if (reg?.configSchema) {
|
|
1900
|
+
await setPluginConfig(pluginId, data, null);
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
const siteId = await getCurrentSiteId() ?? NP_DEFAULT_SITE_ID;
|
|
1904
|
+
const now = /* @__PURE__ */ new Date();
|
|
1905
|
+
await db().insert(npSettings).values({
|
|
1906
|
+
siteId,
|
|
1907
|
+
key: `plugin.config:${pluginId}`,
|
|
1908
|
+
value: { __npVersion: 1, __npSettings: data },
|
|
1909
|
+
updatedAt: now,
|
|
1910
|
+
updatedBy: null
|
|
1911
|
+
}).onConflictDoUpdate({
|
|
1912
|
+
target: [npSettings.siteId, npSettings.key],
|
|
1913
|
+
set: {
|
|
1914
|
+
value: { __npVersion: 1, __npSettings: data },
|
|
1915
|
+
updatedAt: now,
|
|
1916
|
+
updatedBy: null
|
|
1917
|
+
}
|
|
1918
|
+
});
|
|
1919
|
+
}
|
|
1920
|
+
},
|
|
1921
|
+
theme: {
|
|
1922
|
+
async getTokens() {
|
|
1923
|
+
assertCap(pluginId, capabilities, "theme:read");
|
|
1924
|
+
const siteId = await resolveSettingsSiteId();
|
|
1925
|
+
const rows = await db().select().from(npSettings).where(and(eq2(npSettings.siteId, siteId), eq2(npSettings.key, "theme")));
|
|
1926
|
+
const row = rows[0];
|
|
1927
|
+
if (!row || !row.value || typeof row.value !== "object" || Array.isArray(row.value)) {
|
|
1928
|
+
return {};
|
|
1929
|
+
}
|
|
1930
|
+
return row.value;
|
|
1931
|
+
},
|
|
1932
|
+
async setTokens(partial) {
|
|
1933
|
+
assertCap(pluginId, capabilities, "theme:write");
|
|
1934
|
+
const siteId = await resolveSettingsSiteId();
|
|
1935
|
+
const rows = await db().select().from(npSettings).where(and(eq2(npSettings.siteId, siteId), eq2(npSettings.key, "theme")));
|
|
1936
|
+
const existing = rows[0] && rows[0].value && typeof rows[0].value === "object" && !Array.isArray(rows[0].value) ? rows[0].value : {};
|
|
1937
|
+
const merged = { ...existing, ...partial };
|
|
1938
|
+
await db().insert(npSettings).values({ siteId, key: "theme", value: merged, updatedAt: /* @__PURE__ */ new Date() }).onConflictDoUpdate({
|
|
1939
|
+
target: [npSettings.siteId, npSettings.key],
|
|
1940
|
+
set: { value: merged, updatedAt: /* @__PURE__ */ new Date() }
|
|
1941
|
+
});
|
|
1942
|
+
}
|
|
1943
|
+
},
|
|
1944
|
+
http: {
|
|
1945
|
+
async fetch(url, opts) {
|
|
1946
|
+
assertCap(pluginId, capabilities, "network:fetch");
|
|
1947
|
+
let target;
|
|
1948
|
+
try {
|
|
1949
|
+
target = new URL(url);
|
|
1950
|
+
} catch {
|
|
1951
|
+
throw new NpError(
|
|
1952
|
+
`[plugin:${pluginId}] http.fetch: invalid URL "${url}"`,
|
|
1953
|
+
"INVALID_URL",
|
|
1954
|
+
400
|
|
1955
|
+
);
|
|
1956
|
+
}
|
|
1957
|
+
const hostMatches = allowedHosts.some((pattern) => {
|
|
1958
|
+
if (pattern === target.hostname) return true;
|
|
1959
|
+
if (pattern.startsWith("*.") && target.hostname.endsWith(pattern.slice(1))) return true;
|
|
1960
|
+
return false;
|
|
1961
|
+
});
|
|
1962
|
+
if (!hostMatches) {
|
|
1963
|
+
throw new NpForbiddenError(
|
|
1964
|
+
`plugin:${pluginId}`,
|
|
1965
|
+
`http.fetch to "${target.hostname}" blocked; add it to manifest.allowedHosts`
|
|
1966
|
+
);
|
|
1967
|
+
}
|
|
1968
|
+
const timeoutMs = opts?.timeoutMs ?? 1e4;
|
|
1969
|
+
const controller = new AbortController();
|
|
1970
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
1971
|
+
try {
|
|
1972
|
+
let body;
|
|
1973
|
+
if (opts?.body !== void 0 && opts.body !== null) {
|
|
1974
|
+
if (typeof opts.body === "string") {
|
|
1975
|
+
body = opts.body;
|
|
1976
|
+
} else if (opts.body instanceof Uint8Array) {
|
|
1977
|
+
body = opts.body;
|
|
1978
|
+
} else {
|
|
1979
|
+
body = JSON.stringify(opts.body);
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
const response = await globalThis.fetch(url, {
|
|
1983
|
+
method: opts?.method ?? (body !== void 0 ? "POST" : "GET"),
|
|
1984
|
+
headers: opts?.headers,
|
|
1985
|
+
body,
|
|
1986
|
+
signal: controller.signal
|
|
1987
|
+
});
|
|
1988
|
+
const headers = {};
|
|
1989
|
+
response.headers.forEach((v, k) => {
|
|
1990
|
+
headers[k] = v;
|
|
1991
|
+
});
|
|
1992
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
1993
|
+
let parsedBody = void 0;
|
|
1994
|
+
if (contentType.includes("application/json")) {
|
|
1995
|
+
parsedBody = await response.json().catch(() => void 0);
|
|
1996
|
+
} else if (contentType.startsWith("text/")) {
|
|
1997
|
+
parsedBody = await response.text();
|
|
1998
|
+
}
|
|
1999
|
+
return {
|
|
2000
|
+
ok: response.ok,
|
|
2001
|
+
status: response.status,
|
|
2002
|
+
headers,
|
|
2003
|
+
body: parsedBody
|
|
2004
|
+
};
|
|
2005
|
+
} finally {
|
|
2006
|
+
clearTimeout(timeout);
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
},
|
|
2010
|
+
log: {
|
|
2011
|
+
debug(message, data) {
|
|
2012
|
+
pluginLog.debug(message, data);
|
|
2013
|
+
},
|
|
2014
|
+
info(message, data) {
|
|
2015
|
+
pluginLog.info(message, data);
|
|
2016
|
+
},
|
|
2017
|
+
warn(message, data) {
|
|
2018
|
+
pluginLog.warn(message, data);
|
|
2019
|
+
},
|
|
2020
|
+
error(message, data) {
|
|
2021
|
+
pluginLog.error(message, data);
|
|
2022
|
+
}
|
|
2023
|
+
},
|
|
2024
|
+
errors: {
|
|
2025
|
+
// Plugin-side error reporting with pluginId auto-tagged. The host
|
|
2026
|
+
// already auto-reports thrown hook handlers (in `dispatchHookHandler`),
|
|
2027
|
+
// so plugins typically only need this when *catching* an error
|
|
2028
|
+
// internally — e.g. a non-fatal upstream failure they want to log to
|
|
2029
|
+
// Sentry but recover from.
|
|
2030
|
+
report(error, context) {
|
|
2031
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
2032
|
+
return reportError(err, {
|
|
2033
|
+
tags: { source: "plugin", pluginId, ...context?.tags },
|
|
2034
|
+
extra: context?.extra,
|
|
2035
|
+
user: context?.user
|
|
2036
|
+
});
|
|
2037
|
+
}
|
|
2038
|
+
},
|
|
2039
|
+
next: {
|
|
2040
|
+
async revalidatePath(path) {
|
|
2041
|
+
const mod = await loadOptionalNextCache();
|
|
2042
|
+
mod?.revalidatePath?.(path);
|
|
2043
|
+
},
|
|
2044
|
+
async revalidateTag(tag) {
|
|
2045
|
+
const mod = await loadOptionalNextCache();
|
|
2046
|
+
const fn = mod?.revalidateTag;
|
|
2047
|
+
if (typeof fn !== "function") return;
|
|
2048
|
+
if (fn.length >= 2) {
|
|
2049
|
+
fn(tag, "default");
|
|
2050
|
+
} else {
|
|
2051
|
+
fn(tag);
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
},
|
|
2055
|
+
actions: {
|
|
2056
|
+
register(actionName, handler) {
|
|
2057
|
+
registration.actions.set(actionName, handler);
|
|
2058
|
+
},
|
|
2059
|
+
async dispatch(targetPluginId, actionName, data) {
|
|
2060
|
+
const target = lookupRegistration(targetPluginId);
|
|
2061
|
+
const action = target?.actions.get(actionName);
|
|
2062
|
+
if (!action) {
|
|
2063
|
+
return {
|
|
2064
|
+
ok: false,
|
|
2065
|
+
error: `Action "${actionName}" not found on plugin "${targetPluginId}"`
|
|
2066
|
+
};
|
|
2067
|
+
}
|
|
2068
|
+
return action(data);
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
};
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
// src/plugins/enabled-gate.ts
|
|
2075
|
+
import { eq as eq3 } from "drizzle-orm";
|
|
2076
|
+
var DEFAULT_TTL_MS = 5e3;
|
|
2077
|
+
var cache = /* @__PURE__ */ new Map();
|
|
2078
|
+
var inflight = /* @__PURE__ */ new Map();
|
|
2079
|
+
var generation = /* @__PURE__ */ new Map();
|
|
2080
|
+
var ttlMs = DEFAULT_TTL_MS;
|
|
2081
|
+
function currentGeneration(pluginId) {
|
|
2082
|
+
return generation.get(pluginId) ?? 0;
|
|
2083
|
+
}
|
|
2084
|
+
async function fetchEnabled(pluginId) {
|
|
2085
|
+
if (fetchOverride) return fetchOverride(pluginId);
|
|
2086
|
+
try {
|
|
2087
|
+
const db = getDb();
|
|
2088
|
+
const rows = await db.select({ enabled: npPlugins.enabled }).from(npPlugins).where(eq3(npPlugins.id, pluginId)).limit(1);
|
|
2089
|
+
const row = rows[0];
|
|
2090
|
+
if (row && typeof row.enabled === "boolean") {
|
|
2091
|
+
return row.enabled;
|
|
2092
|
+
}
|
|
2093
|
+
return true;
|
|
2094
|
+
} catch {
|
|
2095
|
+
return true;
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
async function isPluginEnabled(pluginId) {
|
|
2099
|
+
const now = Date.now();
|
|
2100
|
+
const cached = cache.get(pluginId);
|
|
2101
|
+
if (cached && cached.expiresAt > now) {
|
|
2102
|
+
return cached.enabled;
|
|
2103
|
+
}
|
|
2104
|
+
const existing = inflight.get(pluginId);
|
|
2105
|
+
if (existing) return existing;
|
|
2106
|
+
const fetchGeneration = currentGeneration(pluginId);
|
|
2107
|
+
const promise = fetchEnabled(pluginId).then((enabled) => {
|
|
2108
|
+
if (currentGeneration(pluginId) === fetchGeneration) {
|
|
2109
|
+
cache.set(pluginId, { enabled, expiresAt: Date.now() + ttlMs });
|
|
2110
|
+
}
|
|
2111
|
+
return enabled;
|
|
2112
|
+
}).finally(() => {
|
|
2113
|
+
if (inflight.get(pluginId) === promise) {
|
|
2114
|
+
inflight.delete(pluginId);
|
|
2115
|
+
}
|
|
2116
|
+
});
|
|
2117
|
+
inflight.set(pluginId, promise);
|
|
2118
|
+
return promise;
|
|
2119
|
+
}
|
|
2120
|
+
function invalidatePluginEnabled(pluginId) {
|
|
2121
|
+
cache.delete(pluginId);
|
|
2122
|
+
inflight.delete(pluginId);
|
|
2123
|
+
generation.set(pluginId, currentGeneration(pluginId) + 1);
|
|
2124
|
+
}
|
|
2125
|
+
var fetchOverride = null;
|
|
2126
|
+
|
|
2127
|
+
// src/plugins/compat.ts
|
|
2128
|
+
var FRAMEWORK_VERSION_FROM_PACKAGE = "0.1.0";
|
|
2129
|
+
var frameworkVersion = FRAMEWORK_VERSION_FROM_PACKAGE;
|
|
2130
|
+
function getFrameworkVersion() {
|
|
2131
|
+
return frameworkVersion;
|
|
2132
|
+
}
|
|
2133
|
+
function parse(version) {
|
|
2134
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-.]+))?(?:\+[0-9A-Za-z-.]+)?$/.exec(version);
|
|
2135
|
+
if (!match) return null;
|
|
2136
|
+
const [, majorStr, minorStr, patchStr, prerelease] = match;
|
|
2137
|
+
return {
|
|
2138
|
+
major: Number.parseInt(majorStr ?? "0", 10),
|
|
2139
|
+
minor: Number.parseInt(minorStr ?? "0", 10),
|
|
2140
|
+
patch: Number.parseInt(patchStr ?? "0", 10),
|
|
2141
|
+
prerelease: prerelease ?? null
|
|
2142
|
+
};
|
|
2143
|
+
}
|
|
2144
|
+
function compareSemver(a, b) {
|
|
2145
|
+
const pa = parse(a);
|
|
2146
|
+
const pb = parse(b);
|
|
2147
|
+
if (!pa || !pb) {
|
|
2148
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
2149
|
+
}
|
|
2150
|
+
if (pa.major !== pb.major) return pa.major < pb.major ? -1 : 1;
|
|
2151
|
+
if (pa.minor !== pb.minor) return pa.minor < pb.minor ? -1 : 1;
|
|
2152
|
+
if (pa.patch !== pb.patch) return pa.patch < pb.patch ? -1 : 1;
|
|
2153
|
+
if (pa.prerelease === pb.prerelease) return 0;
|
|
2154
|
+
if (pa.prerelease === null) return 1;
|
|
2155
|
+
if (pb.prerelease === null) return -1;
|
|
2156
|
+
return pa.prerelease < pb.prerelease ? -1 : 1;
|
|
2157
|
+
}
|
|
2158
|
+
function checkNexpressCompat(manifest, framework = getFrameworkVersion()) {
|
|
2159
|
+
const min = manifest.nexpress?.minVersion;
|
|
2160
|
+
if (!min) {
|
|
2161
|
+
return { compatible: true };
|
|
2162
|
+
}
|
|
2163
|
+
if (compareSemver(framework, min) < 0) {
|
|
2164
|
+
return {
|
|
2165
|
+
compatible: false,
|
|
2166
|
+
reason: `requires NexPress >= ${min}, host is ${framework}`
|
|
2167
|
+
};
|
|
2168
|
+
}
|
|
2169
|
+
const max = manifest.nexpress?.maxVersion;
|
|
2170
|
+
if (max && compareSemver(framework, max) > 0) {
|
|
2171
|
+
return {
|
|
2172
|
+
compatible: false,
|
|
2173
|
+
reason: `requires NexPress <= ${max}, host is ${framework}`
|
|
2174
|
+
};
|
|
2175
|
+
}
|
|
2176
|
+
return { compatible: true };
|
|
2177
|
+
}
|
|
2178
|
+
function topoSort(plugins) {
|
|
2179
|
+
const skipped = [];
|
|
2180
|
+
let eligible = [...plugins];
|
|
2181
|
+
while (true) {
|
|
2182
|
+
const eligibleIds2 = new Set(eligible.map((p) => p.id));
|
|
2183
|
+
const stillEligible = [];
|
|
2184
|
+
let dropped = false;
|
|
2185
|
+
for (const plugin of eligible) {
|
|
2186
|
+
const missing = plugin.requires.filter((dep) => !eligibleIds2.has(dep));
|
|
2187
|
+
if (missing.length > 0) {
|
|
2188
|
+
skipped.push({
|
|
2189
|
+
id: plugin.id,
|
|
2190
|
+
reason: `missing required plugin(s): ${missing.join(", ")}`
|
|
2191
|
+
});
|
|
2192
|
+
dropped = true;
|
|
2193
|
+
continue;
|
|
2194
|
+
}
|
|
2195
|
+
stillEligible.push(plugin);
|
|
2196
|
+
}
|
|
2197
|
+
eligible = stillEligible;
|
|
2198
|
+
if (!dropped) break;
|
|
2199
|
+
}
|
|
2200
|
+
const eligibleIds = new Set(eligible.map((p) => p.id));
|
|
2201
|
+
const indegree = /* @__PURE__ */ new Map();
|
|
2202
|
+
const dependents = /* @__PURE__ */ new Map();
|
|
2203
|
+
for (const plugin of eligible) {
|
|
2204
|
+
indegree.set(plugin.id, 0);
|
|
2205
|
+
dependents.set(plugin.id, []);
|
|
2206
|
+
}
|
|
2207
|
+
for (const plugin of eligible) {
|
|
2208
|
+
for (const dep of plugin.requires) {
|
|
2209
|
+
if (!eligibleIds.has(dep)) continue;
|
|
2210
|
+
indegree.set(plugin.id, (indegree.get(plugin.id) ?? 0) + 1);
|
|
2211
|
+
dependents.get(dep).push(plugin.id);
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
const queue = eligible.filter((p) => (indegree.get(p.id) ?? 0) === 0);
|
|
2215
|
+
const ordered = [];
|
|
2216
|
+
const byId = new Map(eligible.map((p) => [p.id, p]));
|
|
2217
|
+
while (queue.length > 0) {
|
|
2218
|
+
const next = queue.shift();
|
|
2219
|
+
ordered.push(next);
|
|
2220
|
+
for (const dependent of dependents.get(next.id) ?? []) {
|
|
2221
|
+
const updated = (indegree.get(dependent) ?? 0) - 1;
|
|
2222
|
+
indegree.set(dependent, updated);
|
|
2223
|
+
if (updated === 0) {
|
|
2224
|
+
const plugin = byId.get(dependent);
|
|
2225
|
+
if (plugin) queue.push(plugin);
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
if (ordered.length !== eligible.length) {
|
|
2230
|
+
for (const plugin of eligible) {
|
|
2231
|
+
if (!ordered.includes(plugin)) {
|
|
2232
|
+
skipped.push({
|
|
2233
|
+
id: plugin.id,
|
|
2234
|
+
reason: "dependency cycle \u2014 refusing to load"
|
|
2235
|
+
});
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
return { ordered, skipped };
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
// src/plugins/host.ts
|
|
2243
|
+
function hookCapabilityFor(hookName) {
|
|
2244
|
+
const namespace = hookName.split(":")[0];
|
|
2245
|
+
if (!namespace) return null;
|
|
2246
|
+
return `hooks:${namespace}`;
|
|
2247
|
+
}
|
|
2248
|
+
function assertCapability(pluginId, requirement, declared) {
|
|
2249
|
+
if (declared.includes(requirement)) return;
|
|
2250
|
+
throw new Error(
|
|
2251
|
+
`[plugin:${pluginId}] declares capabilities ${JSON.stringify(declared)} but is registering something that requires "${requirement}". Add "${requirement}" to the plugin manifest's capabilities array.`
|
|
2252
|
+
);
|
|
2253
|
+
}
|
|
2254
|
+
var pluginRegistry = /* @__PURE__ */ new Map();
|
|
2255
|
+
var globalHooks = /* @__PURE__ */ new Map();
|
|
2256
|
+
var globalRoutes = [];
|
|
2257
|
+
var DEFAULT_HOOK_PRIORITY = 100;
|
|
2258
|
+
function normalizeHookValue(value) {
|
|
2259
|
+
if (typeof value === "function") {
|
|
2260
|
+
return { handler: value, priority: DEFAULT_HOOK_PRIORITY };
|
|
2261
|
+
}
|
|
2262
|
+
if (value && typeof value === "object") {
|
|
2263
|
+
const v = value;
|
|
2264
|
+
if (typeof v.handler !== "function") return null;
|
|
2265
|
+
return {
|
|
2266
|
+
handler: v.handler,
|
|
2267
|
+
priority: typeof v.priority === "number" ? v.priority : DEFAULT_HOOK_PRIORITY,
|
|
2268
|
+
timeoutMs: typeof v.timeoutMs === "number" && v.timeoutMs > 0 ? v.timeoutMs : void 0
|
|
2269
|
+
};
|
|
2270
|
+
}
|
|
2271
|
+
return null;
|
|
2272
|
+
}
|
|
2273
|
+
function insertSortedByPriority(list, entry) {
|
|
2274
|
+
list.push(entry);
|
|
2275
|
+
list.sort((a, b) => a.priority - b.priority);
|
|
2276
|
+
}
|
|
2277
|
+
async function loadPluginConfig(pluginId) {
|
|
2278
|
+
const { getPluginConfig } = await import("./config-2GDU7PCK.js");
|
|
2279
|
+
const value = await getPluginConfig(pluginId);
|
|
2280
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
2281
|
+
return value;
|
|
2282
|
+
}
|
|
2283
|
+
return {};
|
|
2284
|
+
}
|
|
2285
|
+
async function buildCtxFor(pluginId) {
|
|
2286
|
+
const registration = pluginRegistry.get(pluginId);
|
|
2287
|
+
if (!registration) {
|
|
2288
|
+
throw new Error(`[plugin:${pluginId}] attempted to build ctx before registration.`);
|
|
2289
|
+
}
|
|
2290
|
+
const config = await loadPluginConfig(pluginId);
|
|
2291
|
+
return createPluginRuntimeContext({
|
|
2292
|
+
pluginId,
|
|
2293
|
+
capabilities: registration.capabilities,
|
|
2294
|
+
allowedHosts: registration.allowedHosts,
|
|
2295
|
+
config,
|
|
2296
|
+
registration,
|
|
2297
|
+
lookupRegistration: (id) => pluginRegistry.get(id)
|
|
2298
|
+
});
|
|
2299
|
+
}
|
|
2300
|
+
function isResolvedPlugin(value) {
|
|
2301
|
+
if (!value || typeof value !== "object") return false;
|
|
2302
|
+
const candidate = value;
|
|
2303
|
+
if (!candidate.manifest || typeof candidate.manifest !== "object") return false;
|
|
2304
|
+
const manifest = candidate.manifest;
|
|
2305
|
+
return typeof manifest.id === "string" && Array.isArray(manifest.capabilities);
|
|
2306
|
+
}
|
|
2307
|
+
function registerHookHandler(registration, hookName, handler) {
|
|
2308
|
+
if (!registration.hooks.has(hookName)) {
|
|
2309
|
+
registration.hooks.set(hookName, []);
|
|
2310
|
+
}
|
|
2311
|
+
insertSortedByPriority(registration.hooks.get(hookName), handler);
|
|
2312
|
+
if (!globalHooks.has(hookName)) {
|
|
2313
|
+
globalHooks.set(hookName, []);
|
|
2314
|
+
}
|
|
2315
|
+
insertSortedByPriority(globalHooks.get(hookName), handler);
|
|
2316
|
+
}
|
|
2317
|
+
function createPluginContext(pluginId, registration) {
|
|
2318
|
+
return {
|
|
2319
|
+
addCollection: () => {
|
|
2320
|
+
throw new Error(
|
|
2321
|
+
`[plugin:${pluginId}] Runtime collection registration not supported in v1. Add collections to nexpress.config.ts.`
|
|
2322
|
+
);
|
|
2323
|
+
},
|
|
2324
|
+
addBlock: () => {
|
|
2325
|
+
throw new Error(
|
|
2326
|
+
`[plugin:${pluginId}] Runtime block registration not supported in v1. Add blocks to nexpress.config.ts.`
|
|
2327
|
+
);
|
|
2328
|
+
},
|
|
2329
|
+
addHook: (collection, event, hook) => {
|
|
2330
|
+
const hookName = `content:${event}`;
|
|
2331
|
+
const requirement = hookCapabilityFor(hookName);
|
|
2332
|
+
if (requirement) {
|
|
2333
|
+
assertCapability(pluginId, requirement, registration.capabilities);
|
|
2334
|
+
}
|
|
2335
|
+
registerHookHandler(registration, hookName, {
|
|
2336
|
+
pluginId,
|
|
2337
|
+
priority: DEFAULT_HOOK_PRIORITY,
|
|
2338
|
+
handler: async (data) => {
|
|
2339
|
+
if (typeof data.collection === "string" && data.collection !== collection) {
|
|
2340
|
+
return;
|
|
2341
|
+
}
|
|
2342
|
+
await hook({ data, collection });
|
|
2343
|
+
}
|
|
2344
|
+
});
|
|
2345
|
+
}
|
|
2346
|
+
};
|
|
2347
|
+
}
|
|
2348
|
+
async function loadResolvedPlugin(plugin) {
|
|
2349
|
+
const { manifest } = plugin;
|
|
2350
|
+
const previous = pluginRegistry.get(manifest.id);
|
|
2351
|
+
if (previous) {
|
|
2352
|
+
for (const [hookName, list] of previous.hooks) {
|
|
2353
|
+
const global = globalHooks.get(hookName);
|
|
2354
|
+
if (!global) continue;
|
|
2355
|
+
const filtered = global.filter((h) => !list.includes(h));
|
|
2356
|
+
if (filtered.length === 0) globalHooks.delete(hookName);
|
|
2357
|
+
else globalHooks.set(hookName, filtered);
|
|
2358
|
+
}
|
|
2359
|
+
for (const route of previous.routes) {
|
|
2360
|
+
const idx = globalRoutes.indexOf(route);
|
|
2361
|
+
if (idx !== -1) globalRoutes.splice(idx, 1);
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
const registration = {
|
|
2365
|
+
id: manifest.id,
|
|
2366
|
+
name: manifest.name,
|
|
2367
|
+
version: manifest.version,
|
|
2368
|
+
description: manifest.description,
|
|
2369
|
+
capabilities: [...manifest.capabilities],
|
|
2370
|
+
allowedHosts: [...manifest.allowedHosts ?? []],
|
|
2371
|
+
admin: plugin.admin,
|
|
2372
|
+
hooks: /* @__PURE__ */ new Map(),
|
|
2373
|
+
routes: [],
|
|
2374
|
+
actions: /* @__PURE__ */ new Map(),
|
|
2375
|
+
schedules: /* @__PURE__ */ new Map(),
|
|
2376
|
+
configSchema: plugin.configSchema,
|
|
2377
|
+
configVersion: plugin.configVersion,
|
|
2378
|
+
configMigrate: plugin.configMigrate,
|
|
2379
|
+
pageRoutes: normalizePageRoutes(plugin)
|
|
2380
|
+
};
|
|
2381
|
+
pluginRegistry.set(manifest.id, registration);
|
|
2382
|
+
if (registration.configSchema !== void 0 && plugin.admin?.settings?.fields && plugin.admin.settings.fields.length > 0) {
|
|
2383
|
+
getLogger().warn("Plugin declares both configSchema and admin.settings.fields", {
|
|
2384
|
+
pluginId: manifest.id,
|
|
2385
|
+
note: "Auto-form wins; admin.settings.fields is ignored at render time. Remove admin.settings.fields when migrating to configSchema."
|
|
2386
|
+
});
|
|
2387
|
+
}
|
|
2388
|
+
const scheduledRaw = plugin.scheduled;
|
|
2389
|
+
if (Array.isArray(scheduledRaw)) {
|
|
2390
|
+
for (const entry of scheduledRaw) {
|
|
2391
|
+
if (!entry || typeof entry !== "object") continue;
|
|
2392
|
+
const e = entry;
|
|
2393
|
+
if (typeof e.id !== "string" || e.id.length === 0) continue;
|
|
2394
|
+
if (typeof e.cron !== "string" || e.cron.length === 0) continue;
|
|
2395
|
+
if (typeof e.handler !== "function") continue;
|
|
2396
|
+
registration.schedules.set(e.id, {
|
|
2397
|
+
pluginId: manifest.id,
|
|
2398
|
+
taskId: e.id,
|
|
2399
|
+
cron: e.cron,
|
|
2400
|
+
description: typeof e.description === "string" ? e.description : void 0,
|
|
2401
|
+
handler: e.handler
|
|
2402
|
+
});
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
for (const [hookName, rawValue] of Object.entries(plugin.hooks ?? {})) {
|
|
2406
|
+
const normalized = normalizeHookValue(rawValue);
|
|
2407
|
+
if (!normalized) continue;
|
|
2408
|
+
const requirement = hookCapabilityFor(hookName);
|
|
2409
|
+
if (requirement) {
|
|
2410
|
+
assertCapability(manifest.id, requirement, registration.capabilities);
|
|
2411
|
+
}
|
|
2412
|
+
const userHandler = normalized.handler;
|
|
2413
|
+
registerHookHandler(registration, hookName, {
|
|
2414
|
+
pluginId: manifest.id,
|
|
2415
|
+
priority: normalized.priority,
|
|
2416
|
+
timeoutMs: normalized.timeoutMs,
|
|
2417
|
+
handler: async (data) => {
|
|
2418
|
+
const collection = typeof data.collection === "string" ? data.collection : void 0;
|
|
2419
|
+
const ctx = await buildCtxFor(manifest.id);
|
|
2420
|
+
return await userHandler({ hook: hookName, data, collection, ctx });
|
|
2421
|
+
}
|
|
2422
|
+
});
|
|
2423
|
+
}
|
|
2424
|
+
for (const route of plugin.routes ?? []) {
|
|
2425
|
+
if (typeof route.handler !== "function") continue;
|
|
2426
|
+
assertCapability(manifest.id, "api:route", registration.capabilities);
|
|
2427
|
+
const userHandler = route.handler;
|
|
2428
|
+
const wrapped = async (req) => {
|
|
2429
|
+
const ctx = await buildCtxFor(manifest.id);
|
|
2430
|
+
return userHandler(req, ctx);
|
|
2431
|
+
};
|
|
2432
|
+
const auth = route.auth === true;
|
|
2433
|
+
const method = route.method.toUpperCase();
|
|
2434
|
+
if (!auth && method !== "GET" && method !== "HEAD" && method !== "OPTIONS") {
|
|
2435
|
+
getLogger().warn("Plugin registered a public mutating route", {
|
|
2436
|
+
pluginId: manifest.id,
|
|
2437
|
+
path: route.path,
|
|
2438
|
+
method,
|
|
2439
|
+
note: "Plugins are responsible for their own auth on `auth: false` routes. The framework rate-limits the plugin catch-all to 30 req/min/IP; verify the handler enforces signature / token checks before mutating state."
|
|
2440
|
+
});
|
|
2441
|
+
}
|
|
2442
|
+
const entry = {
|
|
2443
|
+
pluginId: manifest.id,
|
|
2444
|
+
path: route.path,
|
|
2445
|
+
method,
|
|
2446
|
+
auth,
|
|
2447
|
+
handler: wrapped
|
|
2448
|
+
};
|
|
2449
|
+
registration.routes.push(entry);
|
|
2450
|
+
globalRoutes.push(entry);
|
|
2451
|
+
}
|
|
2452
|
+
const i18nBundles = plugin.i18n;
|
|
2453
|
+
if (i18nBundles && typeof i18nBundles === "object") {
|
|
2454
|
+
const { addStrings } = await import("./strings-VAE47B2C.js");
|
|
2455
|
+
for (const [locale, bundle] of Object.entries(i18nBundles)) {
|
|
2456
|
+
if (bundle && typeof bundle === "object") {
|
|
2457
|
+
addStrings(locale, bundle);
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
const pluginTemplates = plugin.templates;
|
|
2462
|
+
if (pluginTemplates && typeof pluginTemplates === "object") {
|
|
2463
|
+
const { registerPluginTemplates } = await import("./templates-IFVJMCJ6.js");
|
|
2464
|
+
registerPluginTemplates(manifest.id, pluginTemplates);
|
|
2465
|
+
}
|
|
2466
|
+
const setup = plugin.setup;
|
|
2467
|
+
if (typeof setup === "function") {
|
|
2468
|
+
const ctx = await buildCtxFor(manifest.id);
|
|
2469
|
+
await setup(ctx);
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
async function loadLegacyPlugin(plugin) {
|
|
2473
|
+
const registration = {
|
|
2474
|
+
id: plugin.id,
|
|
2475
|
+
name: plugin.name,
|
|
2476
|
+
capabilities: ["hooks:content"],
|
|
2477
|
+
allowedHosts: [],
|
|
2478
|
+
hooks: /* @__PURE__ */ new Map(),
|
|
2479
|
+
routes: [],
|
|
2480
|
+
actions: /* @__PURE__ */ new Map(),
|
|
2481
|
+
schedules: /* @__PURE__ */ new Map(),
|
|
2482
|
+
// Legacy `init()` plugins predate the page-routes contract;
|
|
2483
|
+
// they always register zero routes. Kept as a literal `[]` so
|
|
2484
|
+
// the registration shape is consistent across the two paths
|
|
2485
|
+
// and `getPluginPageRoutes()` doesn't need to special-case
|
|
2486
|
+
// legacy entries.
|
|
2487
|
+
pageRoutes: []
|
|
2488
|
+
};
|
|
2489
|
+
pluginRegistry.set(plugin.id, registration);
|
|
2490
|
+
if (plugin.init) {
|
|
2491
|
+
const ctx = createPluginContext(plugin.id, registration);
|
|
2492
|
+
await plugin.init(ctx);
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
async function loadPlugins(plugins) {
|
|
2496
|
+
const filtered = [];
|
|
2497
|
+
for (const plugin of plugins) {
|
|
2498
|
+
if (isResolvedPlugin(plugin)) {
|
|
2499
|
+
const compat = checkNexpressCompat(plugin.manifest);
|
|
2500
|
+
if (!compat.compatible) {
|
|
2501
|
+
getLogger().warn("Skipping incompatible plugin", {
|
|
2502
|
+
pluginId: plugin.manifest.id,
|
|
2503
|
+
reason: compat.reason
|
|
2504
|
+
});
|
|
2505
|
+
continue;
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
filtered.push(plugin);
|
|
2509
|
+
}
|
|
2510
|
+
const legacy = [];
|
|
2511
|
+
const resolved = [];
|
|
2512
|
+
for (const plugin of filtered) {
|
|
2513
|
+
if (isResolvedPlugin(plugin)) {
|
|
2514
|
+
resolved.push(plugin);
|
|
2515
|
+
} else {
|
|
2516
|
+
legacy.push(plugin);
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
const sortInput = resolved.map((plugin) => ({
|
|
2520
|
+
id: plugin.manifest.id,
|
|
2521
|
+
requires: plugin.manifest.requires ?? [],
|
|
2522
|
+
plugin
|
|
2523
|
+
}));
|
|
2524
|
+
const { ordered, skipped } = topoSort(sortInput);
|
|
2525
|
+
for (const entry of skipped) {
|
|
2526
|
+
getLogger().warn("Skipping plugin with unsatisfied dependency", {
|
|
2527
|
+
pluginId: entry.id,
|
|
2528
|
+
reason: entry.reason
|
|
2529
|
+
});
|
|
2530
|
+
}
|
|
2531
|
+
for (const plugin of legacy) {
|
|
2532
|
+
try {
|
|
2533
|
+
await loadLegacyPlugin(plugin);
|
|
2534
|
+
} catch (err) {
|
|
2535
|
+
pluginRegistry.delete(plugin.id);
|
|
2536
|
+
getLogger().error("Plugin failed to load \u2014 skipped", {
|
|
2537
|
+
pluginId: plugin.id,
|
|
2538
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2539
|
+
});
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
for (const entry of ordered) {
|
|
2543
|
+
try {
|
|
2544
|
+
await loadResolvedPlugin(entry.plugin);
|
|
2545
|
+
} catch (err) {
|
|
2546
|
+
const pluginId = entry.plugin.manifest.id;
|
|
2547
|
+
pluginRegistry.delete(pluginId);
|
|
2548
|
+
getLogger().error("Plugin failed to load \u2014 skipped", {
|
|
2549
|
+
pluginId,
|
|
2550
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2551
|
+
});
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
async function dispatchHookHandler(hookName, handler, data) {
|
|
2556
|
+
try {
|
|
2557
|
+
const result = handler.handler(data);
|
|
2558
|
+
if (handler.timeoutMs === void 0 || !(result instanceof Promise)) {
|
|
2559
|
+
const value = await result;
|
|
2560
|
+
return { ok: true, value };
|
|
2561
|
+
}
|
|
2562
|
+
let timer;
|
|
2563
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
2564
|
+
timer = setTimeout(() => {
|
|
2565
|
+
reject(
|
|
2566
|
+
new Error(
|
|
2567
|
+
`Plugin hook handler timed out after ${handler.timeoutMs}ms`
|
|
2568
|
+
)
|
|
2569
|
+
);
|
|
2570
|
+
}, handler.timeoutMs);
|
|
2571
|
+
});
|
|
2572
|
+
try {
|
|
2573
|
+
const value = await Promise.race([result, timeoutPromise]);
|
|
2574
|
+
return { ok: true, value };
|
|
2575
|
+
} finally {
|
|
2576
|
+
if (timer) clearTimeout(timer);
|
|
2577
|
+
}
|
|
2578
|
+
} catch (error) {
|
|
2579
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
2580
|
+
getLogger().error("Plugin hook handler threw", {
|
|
2581
|
+
pluginId: handler.pluginId,
|
|
2582
|
+
hook: hookName,
|
|
2583
|
+
timeoutMs: handler.timeoutMs,
|
|
2584
|
+
message: err.message,
|
|
2585
|
+
stack: err.stack
|
|
2586
|
+
});
|
|
2587
|
+
void reportError(err, {
|
|
2588
|
+
tags: { source: "plugin-hook", pluginId: handler.pluginId, hook: hookName }
|
|
2589
|
+
});
|
|
2590
|
+
return { ok: false };
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
async function runHook(hookName, data) {
|
|
2594
|
+
const handlers = globalHooks.get(hookName);
|
|
2595
|
+
if (!handlers || handlers.length === 0) return;
|
|
2596
|
+
for (const handler of handlers) {
|
|
2597
|
+
if (!await isPluginEnabled(handler.pluginId)) continue;
|
|
2598
|
+
await dispatchHookHandler(hookName, handler, data);
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
async function runHookAndCollect(hookName, data) {
|
|
2602
|
+
const handlers = globalHooks.get(hookName);
|
|
2603
|
+
if (!handlers || handlers.length === 0) return [];
|
|
2604
|
+
const results = [];
|
|
2605
|
+
for (const handler of handlers) {
|
|
2606
|
+
if (!await isPluginEnabled(handler.pluginId)) continue;
|
|
2607
|
+
const outcome = await dispatchHookHandler(hookName, handler, data);
|
|
2608
|
+
if (outcome.ok && outcome.value !== void 0 && outcome.value !== null) {
|
|
2609
|
+
results.push(outcome.value);
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
return results;
|
|
2613
|
+
}
|
|
2614
|
+
function getPluginRoutes() {
|
|
2615
|
+
return globalRoutes;
|
|
2616
|
+
}
|
|
2617
|
+
function getPluginPageRoutes() {
|
|
2618
|
+
const out = [];
|
|
2619
|
+
for (const [pluginId, registration] of pluginRegistry) {
|
|
2620
|
+
for (const route of registration.pageRoutes) {
|
|
2621
|
+
out.push({ pluginId, route });
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
return out;
|
|
2625
|
+
}
|
|
2626
|
+
function normalizePageRoutes(plugin) {
|
|
2627
|
+
const raw = plugin.pageRoutes;
|
|
2628
|
+
if (!Array.isArray(raw)) return [];
|
|
2629
|
+
const out = [];
|
|
2630
|
+
for (const entry of raw) {
|
|
2631
|
+
if (!entry || typeof entry !== "object") continue;
|
|
2632
|
+
const r = entry;
|
|
2633
|
+
if (typeof r.pattern !== "string" || r.pattern.length === 0) continue;
|
|
2634
|
+
if (typeof r.component !== "function") {
|
|
2635
|
+
if (typeof r.component !== "object" || r.component === null) continue;
|
|
2636
|
+
}
|
|
2637
|
+
out.push({
|
|
2638
|
+
pattern: r.pattern,
|
|
2639
|
+
component: r.component,
|
|
2640
|
+
metadata: r.metadata,
|
|
2641
|
+
surface: r.surface === "member" ? "member" : "site",
|
|
2642
|
+
locale: r.locale === "none" ? "none" : "auto"
|
|
2643
|
+
});
|
|
2644
|
+
}
|
|
2645
|
+
return out;
|
|
2646
|
+
}
|
|
2647
|
+
function getPluginRegistration(pluginId) {
|
|
2648
|
+
return pluginRegistry.get(pluginId);
|
|
2649
|
+
}
|
|
2650
|
+
function getAllPluginIds() {
|
|
2651
|
+
return [...pluginRegistry.keys()];
|
|
2652
|
+
}
|
|
2653
|
+
function getPluginAdminExtension(pluginId) {
|
|
2654
|
+
return pluginRegistry.get(pluginId)?.admin;
|
|
2655
|
+
}
|
|
2656
|
+
function getCollectionTabsForSlug(collectionSlug) {
|
|
2657
|
+
const result = [];
|
|
2658
|
+
for (const registration of pluginRegistry.values()) {
|
|
2659
|
+
const tabs = registration.admin?.collectionTabs;
|
|
2660
|
+
if (!tabs || tabs.length === 0) continue;
|
|
2661
|
+
for (const tab of tabs) {
|
|
2662
|
+
const matches = tab.collections === "*" || Array.isArray(tab.collections) && tab.collections.includes(collectionSlug);
|
|
2663
|
+
if (!matches) continue;
|
|
2664
|
+
result.push({
|
|
2665
|
+
pluginId: registration.id,
|
|
2666
|
+
pluginName: registration.name,
|
|
2667
|
+
id: tab.id,
|
|
2668
|
+
label: tab.label,
|
|
2669
|
+
widgets: tab.widgets,
|
|
2670
|
+
actions: tab.actions,
|
|
2671
|
+
description: tab.description
|
|
2672
|
+
});
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
return result;
|
|
2676
|
+
}
|
|
2677
|
+
function getDashboardWidgetsFromPlugins() {
|
|
2678
|
+
const result = [];
|
|
2679
|
+
for (const registration of pluginRegistry.values()) {
|
|
2680
|
+
const widgets = registration.admin?.dashboardWidgets;
|
|
2681
|
+
if (!widgets || widgets.length === 0) continue;
|
|
2682
|
+
for (const widget of widgets) {
|
|
2683
|
+
result.push({
|
|
2684
|
+
pluginId: registration.id,
|
|
2685
|
+
pluginName: registration.name,
|
|
2686
|
+
id: widget.id,
|
|
2687
|
+
label: widget.label,
|
|
2688
|
+
kind: widget.kind,
|
|
2689
|
+
actionId: widget.actionId,
|
|
2690
|
+
description: widget.description,
|
|
2691
|
+
priority: widget.priority
|
|
2692
|
+
});
|
|
2693
|
+
}
|
|
2694
|
+
}
|
|
2695
|
+
return result.map((widget, index) => ({ widget, index })).sort((a, b) => {
|
|
2696
|
+
const ap = a.widget.priority ?? Number.POSITIVE_INFINITY;
|
|
2697
|
+
const bp = b.widget.priority ?? Number.POSITIVE_INFINITY;
|
|
2698
|
+
if (ap !== bp) return ap - bp;
|
|
2699
|
+
return a.index - b.index;
|
|
2700
|
+
}).map(({ widget }) => widget);
|
|
2701
|
+
}
|
|
2702
|
+
async function dispatchPluginAction(pluginId, actionId, data) {
|
|
2703
|
+
const registration = pluginRegistry.get(pluginId);
|
|
2704
|
+
if (!registration) {
|
|
2705
|
+
return { ok: false, error: `Plugin "${pluginId}" is not registered` };
|
|
2706
|
+
}
|
|
2707
|
+
if (!await isPluginEnabled(pluginId)) {
|
|
2708
|
+
return { ok: false, error: `Plugin "${pluginId}" is disabled` };
|
|
2709
|
+
}
|
|
2710
|
+
const handler = registration.actions.get(actionId);
|
|
2711
|
+
if (!handler) {
|
|
2712
|
+
return { ok: false, error: `Action "${actionId}" not found on plugin "${pluginId}"` };
|
|
2713
|
+
}
|
|
2714
|
+
return handler(data);
|
|
2715
|
+
}
|
|
2716
|
+
async function schedulePluginTask(pluginId, taskId) {
|
|
2717
|
+
const { enqueueJob: enqueueJob2 } = await import("./queue-XE5BC75T.js");
|
|
2718
|
+
await enqueueJob2("plugin:scheduledTask", { pluginId, taskId });
|
|
2719
|
+
}
|
|
2720
|
+
function getRegisteredPluginSchedules() {
|
|
2721
|
+
const out = [];
|
|
2722
|
+
for (const reg of pluginRegistry.values()) {
|
|
2723
|
+
for (const schedule of reg.schedules.values()) {
|
|
2724
|
+
out.push(schedule);
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
return out;
|
|
2728
|
+
}
|
|
2729
|
+
async function runPluginScheduledTask(pluginId, taskId) {
|
|
2730
|
+
const registration = pluginRegistry.get(pluginId);
|
|
2731
|
+
if (!registration) {
|
|
2732
|
+
throw new Error(`Plugin "${pluginId}" is not registered`);
|
|
2733
|
+
}
|
|
2734
|
+
if (!await isPluginEnabled(pluginId)) {
|
|
2735
|
+
getLogger().debug("Skipping plugin scheduled task \u2014 plugin disabled", {
|
|
2736
|
+
pluginId,
|
|
2737
|
+
taskId
|
|
2738
|
+
});
|
|
2739
|
+
return;
|
|
2740
|
+
}
|
|
2741
|
+
const entry = registration.schedules.get(taskId);
|
|
2742
|
+
if (!entry) {
|
|
2743
|
+
throw new Error(`Plugin "${pluginId}" has no scheduled task with id "${taskId}"`);
|
|
2744
|
+
}
|
|
2745
|
+
const ctx = await buildCtxFor(pluginId);
|
|
2746
|
+
await entry.handler(ctx);
|
|
2747
|
+
}
|
|
2748
|
+
function resetPlugins() {
|
|
2749
|
+
pluginRegistry.clear();
|
|
2750
|
+
globalHooks.clear();
|
|
2751
|
+
globalRoutes.length = 0;
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
export {
|
|
2755
|
+
buildSearchVector,
|
|
2756
|
+
buildSearchVectorParts,
|
|
2757
|
+
buildWeightedSearchVectorSql,
|
|
2758
|
+
buildZodSchema,
|
|
2759
|
+
getCollectionZodSchema,
|
|
2760
|
+
isPluginEnabled,
|
|
2761
|
+
invalidatePluginEnabled,
|
|
2762
|
+
getFrameworkVersion,
|
|
2763
|
+
compareSemver,
|
|
2764
|
+
checkNexpressCompat,
|
|
2765
|
+
loadPlugins,
|
|
2766
|
+
runHook,
|
|
2767
|
+
runHookAndCollect,
|
|
2768
|
+
getPluginRoutes,
|
|
2769
|
+
getPluginPageRoutes,
|
|
2770
|
+
getPluginRegistration,
|
|
2771
|
+
getAllPluginIds,
|
|
2772
|
+
getPluginAdminExtension,
|
|
2773
|
+
getCollectionTabsForSlug,
|
|
2774
|
+
getDashboardWidgetsFromPlugins,
|
|
2775
|
+
dispatchPluginAction,
|
|
2776
|
+
schedulePluginTask,
|
|
2777
|
+
getRegisteredPluginSchedules,
|
|
2778
|
+
runPluginScheduledTask,
|
|
2779
|
+
resetPlugins,
|
|
2780
|
+
saveDocument,
|
|
2781
|
+
updateMemberDocument,
|
|
2782
|
+
createMemberDocument,
|
|
2783
|
+
autosaveRevision,
|
|
2784
|
+
deleteDocument,
|
|
2785
|
+
deleteMemberDocument,
|
|
2786
|
+
promoteMemberDocument,
|
|
2787
|
+
findDocuments,
|
|
2788
|
+
getDocumentById
|
|
2789
|
+
};
|
|
2790
|
+
//# sourceMappingURL=chunk-VGTPQXNQ.js.map
|