@actuate-media/cms-core 0.18.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__/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/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +226 -17
- package/dist/api/handlers.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
|
@@ -3778,25 +3778,67 @@ export function registerCMSRoutes(router) {
|
|
|
3778
3778
|
if (!matchedCollection || !docSlug) {
|
|
3779
3779
|
return errorResponse('Could not resolve path', 404);
|
|
3780
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
|
+
}
|
|
3781
3806
|
const isRootPath = segments.length === 0;
|
|
3782
|
-
const
|
|
3783
|
-
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
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
|
+
});
|
|
3797
3833
|
if (!doc) {
|
|
3798
3834
|
return errorResponse('Document not found', 404);
|
|
3799
3835
|
}
|
|
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
|
+
}
|
|
3800
3842
|
let docData = doc.data && typeof doc.data === 'object' ? doc.data : {};
|
|
3801
3843
|
// Opt-in: ?populate=author,relatedPosts (or *) — expand relation IDs
|
|
3802
3844
|
// into `{ id, title, slug, status, collection }` summaries so the
|
|
@@ -3859,6 +3901,14 @@ export function registerCMSRoutes(router) {
|
|
|
3859
3901
|
jsonLd: composed.jsonLd,
|
|
3860
3902
|
jsonLdHtml: composed.jsonLdHtml,
|
|
3861
3903
|
...(Object.keys(layout).length > 0 ? { layout } : {}),
|
|
3904
|
+
...(previewSession
|
|
3905
|
+
? {
|
|
3906
|
+
preview: {
|
|
3907
|
+
active: true,
|
|
3908
|
+
expiresAt: previewSession.expiresAt.toISOString(),
|
|
3909
|
+
},
|
|
3910
|
+
}
|
|
3911
|
+
: {}),
|
|
3862
3912
|
});
|
|
3863
3913
|
}
|
|
3864
3914
|
catch (err) {
|
|
@@ -4066,9 +4116,36 @@ export function registerCMSRoutes(router) {
|
|
|
4066
4116
|
if (!body.collection || !body.documentId) {
|
|
4067
4117
|
return errorResponse('collection and documentId are required', 400);
|
|
4068
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);
|
|
4069
4128
|
const preview = createPreviewAdapter(getSessionSecret(), db());
|
|
4070
|
-
const session = await preview.createPreviewSession(body.collection, body.documentId
|
|
4071
|
-
|
|
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
|
+
});
|
|
4072
4149
|
}
|
|
4073
4150
|
catch (err) {
|
|
4074
4151
|
return internalError(err, 'create preview token');
|
|
@@ -4203,6 +4280,138 @@ export function registerCMSRoutes(router) {
|
|
|
4203
4280
|
}
|
|
4204
4281
|
});
|
|
4205
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
|
+
// ---------------------------------------------------------------------------
|
|
4206
4415
|
// Scheduling routes
|
|
4207
4416
|
// ---------------------------------------------------------------------------
|
|
4208
4417
|
router.post('/scheduling/run', async (request) => {
|