@actuate-media/cms-core 0.17.0 → 0.19.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/dist/__tests__/api/preview-and-scheduling.test.d.ts +2 -0
- package/dist/__tests__/api/preview-and-scheduling.test.d.ts.map +1 -0
- package/dist/__tests__/api/preview-and-scheduling.test.js +426 -0
- package/dist/__tests__/api/preview-and-scheduling.test.js.map +1 -0
- package/dist/__tests__/fields/relations.test.d.ts +2 -0
- package/dist/__tests__/fields/relations.test.d.ts.map +1 -0
- package/dist/__tests__/fields/relations.test.js +243 -0
- package/dist/__tests__/fields/relations.test.js.map +1 -0
- package/dist/__tests__/preview.test.d.ts +2 -0
- package/dist/__tests__/preview.test.d.ts.map +1 -0
- package/dist/__tests__/preview.test.js +71 -0
- package/dist/__tests__/preview.test.js.map +1 -0
- package/dist/actions.d.ts +10 -0
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +71 -0
- package/dist/actions.js.map +1 -1
- package/dist/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +288 -20
- package/dist/api/handlers.js.map +1 -1
- package/dist/collections/presets.d.ts.map +1 -1
- package/dist/collections/presets.js +17 -1
- package/dist/collections/presets.js.map +1 -1
- package/dist/fields/index.d.ts +3 -1
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +2 -1
- package/dist/fields/index.js.map +1 -1
- package/dist/fields/presets.d.ts +28 -0
- package/dist/fields/presets.d.ts.map +1 -1
- package/dist/fields/presets.js +45 -0
- package/dist/fields/presets.js.map +1 -1
- package/dist/fields/relations.d.ts +87 -0
- package/dist/fields/relations.d.ts.map +1 -0
- package/dist/fields/relations.js +199 -0
- package/dist/fields/relations.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/preview/index.d.ts +23 -1
- package/dist/preview/index.d.ts.map +1 -1
- package/dist/preview/index.js +30 -7
- package/dist/preview/index.js.map +1 -1
- package/package.json +1 -1
package/dist/api/handlers.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { listDocuments, getDocument, createDocument, updateDocument, deleteDocument, getGlobal, updateGlobal, } from '../actions.js';
|
|
1
|
+
import { listDocuments, getDocument, createDocument, updateDocument, deleteDocument, getGlobal, updateGlobal, buildRelationLookup, } from '../actions.js';
|
|
2
|
+
import { populateRelations, parsePopulateParam } from '../fields/relations.js';
|
|
2
3
|
import { verifyPassword, hashPassword, needsRehash, compareToDummyHash } from '../auth/password.js';
|
|
3
4
|
import { createSession, verifySession, revokeSession } from '../auth/session.js';
|
|
4
5
|
import { createPasswordReset, executePasswordReset } from '../auth/reset.js';
|
|
@@ -44,6 +45,41 @@ async function importAIPlugin() {
|
|
|
44
45
|
const mod = '@actuate-media/' + 'plugin-ai';
|
|
45
46
|
return import(/* webpackIgnore: true */ mod);
|
|
46
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Common helper for the read endpoints. Reads the `?populate=` query param,
|
|
50
|
+
* fetches the target collection's field schema, and expands every relation
|
|
51
|
+
* reference into a {@link RelationSummary}. When the caller didn't ask for
|
|
52
|
+
* population (or the doc has no `data` field) we short-circuit and return
|
|
53
|
+
* the doc untouched.
|
|
54
|
+
*
|
|
55
|
+
* Lives here (not in `actions.ts`) because the rest of the actions API
|
|
56
|
+
* stays purely DB-shaped — population is an HTTP concern.
|
|
57
|
+
*/
|
|
58
|
+
async function maybePopulate(request, collection, doc, dbInstance) {
|
|
59
|
+
if (!doc)
|
|
60
|
+
return doc;
|
|
61
|
+
const url = new URL(request.url);
|
|
62
|
+
const opts = parsePopulateParam(url.searchParams.get('populate'));
|
|
63
|
+
if (!opts)
|
|
64
|
+
return doc;
|
|
65
|
+
const cfg = getActuateConfig();
|
|
66
|
+
const fields = cfg?.collections?.[collection]?.fields;
|
|
67
|
+
if (!fields)
|
|
68
|
+
return doc;
|
|
69
|
+
const data = doc.data && typeof doc.data === 'object' ? doc.data : null;
|
|
70
|
+
if (!data)
|
|
71
|
+
return doc;
|
|
72
|
+
try {
|
|
73
|
+
const populated = await populateRelations(fields, data, buildRelationLookup(dbInstance), opts);
|
|
74
|
+
return { ...doc, data: populated };
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
// Never let a populate failure mask the underlying document — log and
|
|
78
|
+
// return the unpopulated doc.
|
|
79
|
+
console.error('[actuate][api] populate failed for', collection, err);
|
|
80
|
+
return doc;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
47
83
|
const SECURITY_HEADERS = {
|
|
48
84
|
'Content-Type': 'application/json',
|
|
49
85
|
'X-Content-Type-Options': 'nosniff',
|
|
@@ -1338,7 +1374,13 @@ export function registerCMSRoutes(router) {
|
|
|
1338
1374
|
// top-level page builder envelope and strips internal keys from
|
|
1339
1375
|
// `data`. Field-level access has been applied as well — we don't
|
|
1340
1376
|
// need to re-apply it here.
|
|
1341
|
-
|
|
1377
|
+
//
|
|
1378
|
+
// Opt-in relation population: ?populate=author,relatedPosts (or *)
|
|
1379
|
+
// replaces stored IDs with `{ id, title, slug, status, collection }`
|
|
1380
|
+
// summaries. We only run this when the caller asked because it's an
|
|
1381
|
+
// extra DB roundtrip and most internal callers don't need it.
|
|
1382
|
+
const populated = await maybePopulate(request, params.slug, doc, db());
|
|
1383
|
+
return json({ data: populated });
|
|
1342
1384
|
}
|
|
1343
1385
|
catch (err) {
|
|
1344
1386
|
return internalError(err);
|
|
@@ -3736,26 +3778,85 @@ export function registerCMSRoutes(router) {
|
|
|
3736
3778
|
if (!matchedCollection || !docSlug) {
|
|
3737
3779
|
return errorResponse('Could not resolve path', 404);
|
|
3738
3780
|
}
|
|
3781
|
+
// ?preview=<token> — when a valid preview token is presented we drop
|
|
3782
|
+
// the `status: PUBLISHED` filter so admins (and clients with a signed
|
|
3783
|
+
// share link) can render an unpublished draft at the document's real
|
|
3784
|
+
// URL. The token is tied to a (collection, documentId) pair so we
|
|
3785
|
+
// verify the URL actually points at the document the token was
|
|
3786
|
+
// minted for; this prevents using one token to peek at unrelated
|
|
3787
|
+
// drafts in the same collection.
|
|
3788
|
+
const previewToken = url.searchParams.get('preview');
|
|
3789
|
+
let previewSession = null;
|
|
3790
|
+
if (previewToken) {
|
|
3791
|
+
try {
|
|
3792
|
+
const preview = createPreviewAdapter(getSessionSecret(), db());
|
|
3793
|
+
const session = await preview.validatePreviewToken(previewToken);
|
|
3794
|
+
if (session) {
|
|
3795
|
+
previewSession = {
|
|
3796
|
+
collection: session.collection,
|
|
3797
|
+
documentId: session.documentId,
|
|
3798
|
+
expiresAt: session.expiresAt,
|
|
3799
|
+
};
|
|
3800
|
+
}
|
|
3801
|
+
}
|
|
3802
|
+
catch (err) {
|
|
3803
|
+
console.error('[actuate][api] resolve preview token validation failed:', err);
|
|
3804
|
+
}
|
|
3805
|
+
}
|
|
3739
3806
|
const isRootPath = segments.length === 0;
|
|
3740
|
-
const
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3807
|
+
const slugFilter = {
|
|
3808
|
+
OR: isRootPath
|
|
3809
|
+
? [
|
|
3810
|
+
{ data: { path: ['slug'], equals: 'home' } },
|
|
3811
|
+
{ data: { path: ['slug'], equals: 'index' } },
|
|
3812
|
+
{ slug: 'home' },
|
|
3813
|
+
{ slug: 'index' },
|
|
3814
|
+
]
|
|
3815
|
+
: [{ data: { path: ['slug'], equals: docSlug } }, { slug: docSlug }],
|
|
3816
|
+
};
|
|
3817
|
+
const doc = previewSession
|
|
3818
|
+
? await db().document.findFirst({
|
|
3819
|
+
where: {
|
|
3820
|
+
collection: matchedCollection,
|
|
3821
|
+
deletedAt: null,
|
|
3822
|
+
...slugFilter,
|
|
3823
|
+
},
|
|
3824
|
+
})
|
|
3825
|
+
: await db().document.findFirst({
|
|
3826
|
+
where: {
|
|
3827
|
+
collection: matchedCollection,
|
|
3828
|
+
deletedAt: null,
|
|
3829
|
+
status: 'PUBLISHED',
|
|
3830
|
+
...slugFilter,
|
|
3831
|
+
},
|
|
3832
|
+
});
|
|
3755
3833
|
if (!doc) {
|
|
3756
3834
|
return errorResponse('Document not found', 404);
|
|
3757
3835
|
}
|
|
3758
|
-
|
|
3836
|
+
// Enforce the token ↔ document binding. A leaked or copied token can
|
|
3837
|
+
// only unlock the exact document it was issued for.
|
|
3838
|
+
if (previewSession &&
|
|
3839
|
+
(previewSession.documentId !== doc.id || previewSession.collection !== matchedCollection)) {
|
|
3840
|
+
return errorResponse('Preview token does not match this URL', 403);
|
|
3841
|
+
}
|
|
3842
|
+
let docData = doc.data && typeof doc.data === 'object' ? doc.data : {};
|
|
3843
|
+
// Opt-in: ?populate=author,relatedPosts (or *) — expand relation IDs
|
|
3844
|
+
// into `{ id, title, slug, status, collection }` summaries so the
|
|
3845
|
+
// frontend renders authors / related case studies without a second
|
|
3846
|
+
// request per reference. `resolve` is the highest-leverage place to
|
|
3847
|
+
// do this because it's what marketing pages call from SSR.
|
|
3848
|
+
const populateOpts = parsePopulateParam(new URL(request.url).searchParams.get('populate'));
|
|
3849
|
+
if (populateOpts) {
|
|
3850
|
+
const fields = getActuateConfig()?.collections?.[matchedCollection]?.fields;
|
|
3851
|
+
if (fields) {
|
|
3852
|
+
try {
|
|
3853
|
+
docData = await populateRelations(fields, docData, buildRelationLookup(db()), populateOpts);
|
|
3854
|
+
}
|
|
3855
|
+
catch (err) {
|
|
3856
|
+
console.error('[actuate][api] resolve populate failed:', err);
|
|
3857
|
+
}
|
|
3858
|
+
}
|
|
3859
|
+
}
|
|
3759
3860
|
const layout = await resolveLayout(pathParam, docData, matchedCollection);
|
|
3760
3861
|
const { _layout: _omit, ...cleanData } = docData;
|
|
3761
3862
|
// Compose page meta + JSON-LD up front so client renderers (Next.js
|
|
@@ -3800,6 +3901,14 @@ export function registerCMSRoutes(router) {
|
|
|
3800
3901
|
jsonLd: composed.jsonLd,
|
|
3801
3902
|
jsonLdHtml: composed.jsonLdHtml,
|
|
3802
3903
|
...(Object.keys(layout).length > 0 ? { layout } : {}),
|
|
3904
|
+
...(previewSession
|
|
3905
|
+
? {
|
|
3906
|
+
preview: {
|
|
3907
|
+
active: true,
|
|
3908
|
+
expiresAt: previewSession.expiresAt.toISOString(),
|
|
3909
|
+
},
|
|
3910
|
+
}
|
|
3911
|
+
: {}),
|
|
3803
3912
|
});
|
|
3804
3913
|
}
|
|
3805
3914
|
catch (err) {
|
|
@@ -4007,9 +4116,36 @@ export function registerCMSRoutes(router) {
|
|
|
4007
4116
|
if (!body.collection || !body.documentId) {
|
|
4008
4117
|
return errorResponse('collection and documentId are required', 400);
|
|
4009
4118
|
}
|
|
4119
|
+
// Verify the document actually exists so we don't issue tokens for
|
|
4120
|
+
// hypothetical ids — a leaked token would otherwise silently work
|
|
4121
|
+
// once a doc with that id appeared.
|
|
4122
|
+
const doc = await db().document.findFirst({
|
|
4123
|
+
where: { id: body.documentId, collection: body.collection, deletedAt: null },
|
|
4124
|
+
select: { id: true, slug: true },
|
|
4125
|
+
});
|
|
4126
|
+
if (!doc)
|
|
4127
|
+
return errorResponse('Document not found', 404);
|
|
4010
4128
|
const preview = createPreviewAdapter(getSessionSecret(), db());
|
|
4011
|
-
const session = await preview.createPreviewSession(body.collection, body.documentId
|
|
4012
|
-
|
|
4129
|
+
const session = await preview.createPreviewSession(body.collection, body.documentId, {
|
|
4130
|
+
ttlSeconds: body.ttlSeconds,
|
|
4131
|
+
issuedBy: auth.session.userId,
|
|
4132
|
+
});
|
|
4133
|
+
await logEvent({
|
|
4134
|
+
event: 'preview_token_issued',
|
|
4135
|
+
userId: auth.session.userId,
|
|
4136
|
+
details: {
|
|
4137
|
+
collection: body.collection,
|
|
4138
|
+
documentId: body.documentId,
|
|
4139
|
+
expiresAt: session.expiresAt.toISOString(),
|
|
4140
|
+
},
|
|
4141
|
+
});
|
|
4142
|
+
return json({
|
|
4143
|
+
data: {
|
|
4144
|
+
token: session.token,
|
|
4145
|
+
expiresAt: session.expiresAt,
|
|
4146
|
+
slug: doc.slug,
|
|
4147
|
+
},
|
|
4148
|
+
});
|
|
4013
4149
|
}
|
|
4014
4150
|
catch (err) {
|
|
4015
4151
|
return internalError(err, 'create preview token');
|
|
@@ -4144,6 +4280,138 @@ export function registerCMSRoutes(router) {
|
|
|
4144
4280
|
}
|
|
4145
4281
|
});
|
|
4146
4282
|
// ---------------------------------------------------------------------------
|
|
4283
|
+
// Per-document scheduling — sets `scheduledAt` (when to publish) and/or
|
|
4284
|
+
// `scheduledUnpublishAt` (when to unpublish). The actual transitions are
|
|
4285
|
+
// performed by the scheduling cron (`/cron/publish`). Decoupling the
|
|
4286
|
+
// schedule from the publish action means a missed cron tick just delays
|
|
4287
|
+
// the publish — it can never accidentally publish twice or skip a doc.
|
|
4288
|
+
// ---------------------------------------------------------------------------
|
|
4289
|
+
router.post('/collections/:slug/:id/schedule', async (request, params) => {
|
|
4290
|
+
try {
|
|
4291
|
+
const auth = await requireAuth(request);
|
|
4292
|
+
if (auth.error)
|
|
4293
|
+
return auth.error;
|
|
4294
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
4295
|
+
if (roleErr)
|
|
4296
|
+
return roleErr;
|
|
4297
|
+
const body = (await request.json());
|
|
4298
|
+
const parseFuture = (v, label) => {
|
|
4299
|
+
if (v === null || v === undefined)
|
|
4300
|
+
return null;
|
|
4301
|
+
const d = new Date(v);
|
|
4302
|
+
if (Number.isNaN(d.getTime())) {
|
|
4303
|
+
throw new Error(`${label} is not a valid ISO date`);
|
|
4304
|
+
}
|
|
4305
|
+
if (d.getTime() <= Date.now()) {
|
|
4306
|
+
throw new Error(`${label} must be in the future`);
|
|
4307
|
+
}
|
|
4308
|
+
return d;
|
|
4309
|
+
};
|
|
4310
|
+
let publishAt;
|
|
4311
|
+
let unpublishAt;
|
|
4312
|
+
try {
|
|
4313
|
+
publishAt = parseFuture(body.scheduledAt, 'scheduledAt');
|
|
4314
|
+
unpublishAt = parseFuture(body.scheduledUnpublishAt, 'scheduledUnpublishAt');
|
|
4315
|
+
}
|
|
4316
|
+
catch (err) {
|
|
4317
|
+
return errorResponse(err instanceof Error ? err.message : 'Invalid schedule', 400);
|
|
4318
|
+
}
|
|
4319
|
+
if (!publishAt && !unpublishAt) {
|
|
4320
|
+
return errorResponse('At least one of scheduledAt or scheduledUnpublishAt is required', 400);
|
|
4321
|
+
}
|
|
4322
|
+
if (publishAt && unpublishAt && unpublishAt <= publishAt) {
|
|
4323
|
+
return errorResponse('scheduledUnpublishAt must be after scheduledAt', 400);
|
|
4324
|
+
}
|
|
4325
|
+
const doc = await db().document.findFirst({
|
|
4326
|
+
where: { id: params.id, collection: params.slug, deletedAt: null },
|
|
4327
|
+
});
|
|
4328
|
+
if (!doc)
|
|
4329
|
+
return errorResponse('Document not found', 404);
|
|
4330
|
+
// If we're scheduling a future publish, force status → SCHEDULED so the
|
|
4331
|
+
// cron picks it up. If we're only scheduling an unpublish on an already
|
|
4332
|
+
// published doc we leave status as PUBLISHED.
|
|
4333
|
+
const nextStatus = publishAt ? 'SCHEDULED' : doc.status;
|
|
4334
|
+
const updateData = {
|
|
4335
|
+
updatedById: auth.session.userId,
|
|
4336
|
+
};
|
|
4337
|
+
if (publishAt) {
|
|
4338
|
+
updateData.scheduledAt = publishAt;
|
|
4339
|
+
updateData.status = nextStatus;
|
|
4340
|
+
}
|
|
4341
|
+
if (body.scheduledUnpublishAt !== undefined) {
|
|
4342
|
+
updateData.scheduledUnpublishAt = unpublishAt;
|
|
4343
|
+
}
|
|
4344
|
+
const updated = await db().document.update({
|
|
4345
|
+
where: { id: doc.id },
|
|
4346
|
+
data: updateData,
|
|
4347
|
+
});
|
|
4348
|
+
await logEvent({
|
|
4349
|
+
event: 'document_scheduled',
|
|
4350
|
+
userId: auth.session.userId,
|
|
4351
|
+
details: {
|
|
4352
|
+
collection: params.slug,
|
|
4353
|
+
documentId: doc.id,
|
|
4354
|
+
scheduledAt: publishAt?.toISOString() ?? null,
|
|
4355
|
+
scheduledUnpublishAt: unpublishAt?.toISOString() ?? null,
|
|
4356
|
+
},
|
|
4357
|
+
});
|
|
4358
|
+
return json({
|
|
4359
|
+
data: {
|
|
4360
|
+
id: updated.id,
|
|
4361
|
+
status: updated.status,
|
|
4362
|
+
scheduledAt: updated.scheduledAt,
|
|
4363
|
+
scheduledUnpublishAt: updated.scheduledUnpublishAt,
|
|
4364
|
+
},
|
|
4365
|
+
});
|
|
4366
|
+
}
|
|
4367
|
+
catch (err) {
|
|
4368
|
+
return internalError(err, 'schedule document');
|
|
4369
|
+
}
|
|
4370
|
+
});
|
|
4371
|
+
router.delete('/collections/:slug/:id/schedule', async (request, params) => {
|
|
4372
|
+
try {
|
|
4373
|
+
const auth = await requireAuth(request);
|
|
4374
|
+
if (auth.error)
|
|
4375
|
+
return auth.error;
|
|
4376
|
+
const roleErr = requireRole(auth.session.role, WRITE_ROLES);
|
|
4377
|
+
if (roleErr)
|
|
4378
|
+
return roleErr;
|
|
4379
|
+
const doc = await db().document.findFirst({
|
|
4380
|
+
where: { id: params.id, collection: params.slug, deletedAt: null },
|
|
4381
|
+
});
|
|
4382
|
+
if (!doc)
|
|
4383
|
+
return errorResponse('Document not found', 404);
|
|
4384
|
+
// If the doc was SCHEDULED (not yet published) cancelling drops it
|
|
4385
|
+
// back to DRAFT. PUBLISHED docs with a future unpublish stay live.
|
|
4386
|
+
const nextStatus = doc.status === 'SCHEDULED' ? 'DRAFT' : doc.status;
|
|
4387
|
+
const updated = await db().document.update({
|
|
4388
|
+
where: { id: doc.id },
|
|
4389
|
+
data: {
|
|
4390
|
+
scheduledAt: null,
|
|
4391
|
+
scheduledUnpublishAt: null,
|
|
4392
|
+
status: nextStatus,
|
|
4393
|
+
updatedById: auth.session.userId,
|
|
4394
|
+
},
|
|
4395
|
+
});
|
|
4396
|
+
await logEvent({
|
|
4397
|
+
event: 'document_schedule_cancelled',
|
|
4398
|
+
userId: auth.session.userId,
|
|
4399
|
+
details: { collection: params.slug, documentId: doc.id },
|
|
4400
|
+
});
|
|
4401
|
+
return json({
|
|
4402
|
+
data: {
|
|
4403
|
+
id: updated.id,
|
|
4404
|
+
status: updated.status,
|
|
4405
|
+
scheduledAt: null,
|
|
4406
|
+
scheduledUnpublishAt: null,
|
|
4407
|
+
},
|
|
4408
|
+
});
|
|
4409
|
+
}
|
|
4410
|
+
catch (err) {
|
|
4411
|
+
return internalError(err, 'cancel schedule');
|
|
4412
|
+
}
|
|
4413
|
+
});
|
|
4414
|
+
// ---------------------------------------------------------------------------
|
|
4147
4415
|
// Scheduling routes
|
|
4148
4416
|
// ---------------------------------------------------------------------------
|
|
4149
4417
|
router.post('/scheduling/run', async (request) => {
|