@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.
@@ -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 doc = await db().document.findFirst({
3783
- where: {
3784
- collection: matchedCollection,
3785
- deletedAt: null,
3786
- status: 'PUBLISHED',
3787
- OR: isRootPath
3788
- ? [
3789
- { data: { path: ['slug'], equals: 'home' } },
3790
- { data: { path: ['slug'], equals: 'index' } },
3791
- { slug: 'home' },
3792
- { slug: 'index' },
3793
- ]
3794
- : [{ data: { path: ['slug'], equals: docSlug } }, { slug: docSlug }],
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
- return json({ data: { token: session.token, expiresAt: session.expiresAt } });
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) => {