@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.
Files changed (43) hide show
  1. package/dist/__tests__/api/preview-and-scheduling.test.d.ts +2 -0
  2. package/dist/__tests__/api/preview-and-scheduling.test.d.ts.map +1 -0
  3. package/dist/__tests__/api/preview-and-scheduling.test.js +426 -0
  4. package/dist/__tests__/api/preview-and-scheduling.test.js.map +1 -0
  5. package/dist/__tests__/fields/relations.test.d.ts +2 -0
  6. package/dist/__tests__/fields/relations.test.d.ts.map +1 -0
  7. package/dist/__tests__/fields/relations.test.js +243 -0
  8. package/dist/__tests__/fields/relations.test.js.map +1 -0
  9. package/dist/__tests__/preview.test.d.ts +2 -0
  10. package/dist/__tests__/preview.test.d.ts.map +1 -0
  11. package/dist/__tests__/preview.test.js +71 -0
  12. package/dist/__tests__/preview.test.js.map +1 -0
  13. package/dist/actions.d.ts +10 -0
  14. package/dist/actions.d.ts.map +1 -1
  15. package/dist/actions.js +71 -0
  16. package/dist/actions.js.map +1 -1
  17. package/dist/api/handlers.d.ts.map +1 -1
  18. package/dist/api/handlers.js +288 -20
  19. package/dist/api/handlers.js.map +1 -1
  20. package/dist/collections/presets.d.ts.map +1 -1
  21. package/dist/collections/presets.js +17 -1
  22. package/dist/collections/presets.js.map +1 -1
  23. package/dist/fields/index.d.ts +3 -1
  24. package/dist/fields/index.d.ts.map +1 -1
  25. package/dist/fields/index.js +2 -1
  26. package/dist/fields/index.js.map +1 -1
  27. package/dist/fields/presets.d.ts +28 -0
  28. package/dist/fields/presets.d.ts.map +1 -1
  29. package/dist/fields/presets.js +45 -0
  30. package/dist/fields/presets.js.map +1 -1
  31. package/dist/fields/relations.d.ts +87 -0
  32. package/dist/fields/relations.d.ts.map +1 -0
  33. package/dist/fields/relations.js +199 -0
  34. package/dist/fields/relations.js.map +1 -0
  35. package/dist/index.d.ts +2 -2
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +1 -1
  38. package/dist/index.js.map +1 -1
  39. package/dist/preview/index.d.ts +23 -1
  40. package/dist/preview/index.d.ts.map +1 -1
  41. package/dist/preview/index.js +30 -7
  42. package/dist/preview/index.js.map +1 -1
  43. package/package.json +1 -1
@@ -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
- return json({ data: doc });
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 doc = await db().document.findFirst({
3741
- where: {
3742
- collection: matchedCollection,
3743
- deletedAt: null,
3744
- status: 'PUBLISHED',
3745
- OR: isRootPath
3746
- ? [
3747
- { data: { path: ['slug'], equals: 'home' } },
3748
- { data: { path: ['slug'], equals: 'index' } },
3749
- { slug: 'home' },
3750
- { slug: 'index' },
3751
- ]
3752
- : [{ data: { path: ['slug'], equals: docSlug } }, { slug: docSlug }],
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
- const docData = doc.data && typeof doc.data === 'object' ? doc.data : {};
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
- 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
+ });
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) => {