@forumone/throughline-publishing 0.3.1 → 0.3.3
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/CHANGELOG.md +34 -0
- package/README.md +30 -0
- package/dist/admin/PublishButton.d.ts +0 -2
- package/dist/admin/PublishButton.d.ts.map +1 -1
- package/dist/admin/PublishButton.js +18 -15
- package/dist/admin/PublishButton.js.map +1 -1
- package/dist/hooks/block-status-writes.d.ts +27 -5
- package/dist/hooks/block-status-writes.d.ts.map +1 -1
- package/dist/hooks/block-status-writes.js +96 -16
- package/dist/hooks/block-status-writes.js.map +1 -1
- package/dist/hooks/draft-writes.d.ts +32 -0
- package/dist/hooks/draft-writes.d.ts.map +1 -0
- package/dist/hooks/draft-writes.js +76 -0
- package/dist/hooks/draft-writes.js.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +13 -9
- package/dist/plugin.js.map +1 -1
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# @forumone/throughline-publishing
|
|
2
2
|
|
|
3
|
+
## 0.3.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- de1d480: Fix two defects reported against 0.3.2.
|
|
8
|
+
|
|
9
|
+
**A direct unpublish was allowed once a draft version existed.** `beforeChange` compared `data._status` against `originalDoc._status` and treated a match as a harmless no-op — but `originalDoc` is the _latest version_, not the live document. After any draft save of a published page, `originalDoc._status` is `'draft'` while the page is still live, so an unpublish matched the no-op branch and went through with no pipeline, no audit event and no revalidation. The hook now asks whether the write changes what the public sees, reading the live row when `originalDoc` cannot answer. Promoting a pending draft with a direct `_status: 'published'` write is blocked too — the live status never changes there, so no status comparison could have caught it. Ordinary edits of published documents are unaffected.
|
|
10
|
+
|
|
11
|
+
**The publish button left the form showing pre-edit content.** After publishing, the button reset the form from `useDocumentInfo().data` — the document as it was when the edit view mounted — which replaced the editor's just-saved values with the pre-edit ones and read as though the publish had discarded them. The draft save already merges the server's response into form state, so the reset is removed rather than replaced.
|
|
12
|
+
|
|
13
|
+
Also adds an integration suite that exercises the hooks inside a real Payload instance against a real database, covering the full matrix of draft saves, edits, publishes and unpublishes. Every defect in this hook so far came from unit tests encoding assumptions Payload does not hold.
|
|
14
|
+
|
|
15
|
+
## 0.3.2
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- 4eeb721: Fix published documents being uneditable: allow draft writes through the trust boundary.
|
|
20
|
+
|
|
21
|
+
Editing an already-published document failed with `Direct writes to _status are not allowed` — from both the plugin's own Publish button and Payload's native Save Draft, which discarded the editor's work. Only first publishes worked, because an unmodified document skips the draft save.
|
|
22
|
+
|
|
23
|
+
Payload sets `data._status = 'draft'` on every `draft: true` update _before_ `beforeChange` runs, whether or not the caller supplied it. By the time the hook saw the write, saving a draft of a published document was indistinguishable from unpublishing it: `data._status` was `'draft'` and `originalDoc._status` was `'published'` in both cases. A draft save writes a version and leaves the live document alone, so it should never have been blocked.
|
|
24
|
+
|
|
25
|
+
The plugin now installs a `beforeOperation` hook that records the operation's own `draft` argument, which `beforeChange` reads to tell the two apart. That argument is visible identically on the Local API, REST and GraphQL, unlike `req.query.draft`, which is only populated on the REST path.
|
|
26
|
+
|
|
27
|
+
A `draft: true` request that sets `_status: 'published'` is still blocked — Payload's own `isSavingDraft` excludes it, so it is a real publish and belongs in the pipeline. Genuine unpublishes and direct publishes are unchanged. Installing the status-write hook without the recorder fails closed.
|
|
28
|
+
|
|
29
|
+
- 4eeb721: Fix two defects reported against 0.3.0.
|
|
30
|
+
|
|
31
|
+
**The `alt-text` check false-positived on Payload upload derivatives.** `walkForImages` descended into a populated upload's `sizes` map, and every generated size carries `filename` and `mimeType` but never `alt` — that lives on the parent document. Each configured `imageSize` therefore produced one false failure, and any page carrying a sized image could not be published. The walk now skips `sizes` on an object that is itself an image; alt text is checked once, on the parent. A parent with missing or empty alt still fails, at the parent's path, and a host with no `imageSizes` is unaffected.
|
|
32
|
+
|
|
33
|
+
**A failed Inngest emission failed a publish that had already succeeded.** The event is sent after the document is written, so a transport failure (an invalid `INNGEST_EVENT_KEY`, say) returned 500 for a document that was published — and lost the audit record for it. Publish, unpublish, rollback and `schedule_publish` now report success and carry the emission failure as a `warnings` array on the result. The admin renders it as a warning toast on an otherwise successful publish.
|
|
34
|
+
|
|
35
|
+
Also adds `disableAccessibilityChecks`, naming built-in checks to skip. `accessibilityChecks` only appends, so previously a built-in that misfired on a host's content shape blocked every publish until the plugin shipped a fix.
|
|
36
|
+
|
|
3
37
|
## 0.3.1
|
|
4
38
|
|
|
5
39
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -96,6 +96,36 @@ if (!result.published) {
|
|
|
96
96
|
|
|
97
97
|
All three run the same pipeline the admin and MCP paths use, so host code cannot drift from the plugin's policy. `getPublishStatus` runs every check except the write and mutates nothing.
|
|
98
98
|
|
|
99
|
+
## What the trust boundary blocks
|
|
100
|
+
|
|
101
|
+
The plugin installs two hooks on every configured collection, and they work as a pair:
|
|
102
|
+
|
|
103
|
+
- `beforeOperation` records whether the update in flight is a draft write.
|
|
104
|
+
- `beforeChange` rejects writes that change the **live** document's `_status`.
|
|
105
|
+
|
|
106
|
+
The question they answer is not "did `_status` change" but **would this write change what the public sees**:
|
|
107
|
+
|
|
108
|
+
| Write | Result |
|
|
109
|
+
|---|---|
|
|
110
|
+
| Any `draft: true` save | **allowed** — writes a version, the live document is untouched |
|
|
111
|
+
| A field edit of a published document with nothing pending | **allowed** — status unchanged, no draft to promote |
|
|
112
|
+
| A `_status: 'draft'` write to a document that was never published, or is already down | **allowed** — nothing is live |
|
|
113
|
+
| `_status: 'draft'` on a live document | **blocked** — unpublish through the pipeline |
|
|
114
|
+
| `_status: 'published'` on a draft document | **blocked** — publish through the pipeline |
|
|
115
|
+
| A non-draft write while a draft is pending | **blocked** — this is the publish, or an accidental unpublish |
|
|
116
|
+
| `draft: true` with `_status: 'published'` | **blocked** — Payload treats this as a publish, not a draft save |
|
|
117
|
+
| Any write carrying `bypassPublishingServer` | **allowed** |
|
|
118
|
+
|
|
119
|
+
Three things about Payload's update pipeline make this less obvious than it looks, and each has caused a defect here:
|
|
120
|
+
|
|
121
|
+
1. **`data` is the stored document merged with the caller's changes**, so `_status` is present on nearly every update. An ordinary field edit of a published page arrives carrying `_status: 'published'`. The presence of `_status` means nothing by itself.
|
|
122
|
+
2. **Payload injects `data._status = 'draft'` into every `draft: true` update** before `beforeChange` runs, whether or not the caller supplied it — so a draft save of a published document is shaped exactly like an unpublish. The real `draft` flag is read in `beforeOperation`, which sees it identically on the Local API, REST and GraphQL. (`req.query.draft` is only populated on the REST path, so keying on the request would cover the admin and silently miss scripts.)
|
|
123
|
+
3. **`originalDoc` is the latest version, not the live document.** Once a draft is pending on a published page, `originalDoc._status` is `'draft'` while the page is still live. Comparing the two statuses therefore reads a genuine unpublish as a harmless no-op.
|
|
124
|
+
|
|
125
|
+
A consequence of (1) and (3) worth knowing: once a draft is pending, a plain `payload.update(...)` with no `draft: true` would carry the pending draft's `'draft'` status and take the page down. That is blocked. Pass `draft: true` to edit the draft, or publish through the pipeline.
|
|
126
|
+
|
|
127
|
+
If you install `createBlockStatusWritesHook` yourself without the recorder, it fails closed: every status change is blocked. The behaviour above is verified against a real Payload instance in `block-status-writes.integration.test.ts`.
|
|
128
|
+
|
|
99
129
|
## Bypassing the pipeline
|
|
100
130
|
|
|
101
131
|
`bypassPublishingServer: true` in the Payload request context is the only way to write `_status` without the pipeline. It is intended for seed scripts and migrations:
|
|
@@ -2,8 +2,6 @@ import React from 'react';
|
|
|
2
2
|
export interface ThroughlinePublishButtonProps {
|
|
3
3
|
/** Route prefix the plugin is mounted under. Injected via `clientProps`. */
|
|
4
4
|
routePrefix?: string;
|
|
5
|
-
/** Publish-timestamp field for this collection. Injected via `clientProps`. */
|
|
6
|
-
publishedAtField?: string;
|
|
7
5
|
}
|
|
8
6
|
/**
|
|
9
7
|
* Replaces Payload's Publish button on collections the publishing plugin
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PublishButton.d.ts","sourceRoot":"","sources":["../../src/admin/PublishButton.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAgC,MAAM,OAAO,CAAA;AAYpD,MAAM,WAAW,6BAA6B;IAC5C,4EAA4E;IAC5E,WAAW,CAAC,EAAE,MAAM,CAAA;
|
|
1
|
+
{"version":3,"file":"PublishButton.d.ts","sourceRoot":"","sources":["../../src/admin/PublishButton.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAgC,MAAM,OAAO,CAAA;AAYpD,MAAM,WAAW,6BAA6B;IAC5C,4EAA4E;IAC5E,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,aAAa,CAAC,KAAK,GAAE,6BAAkC,GAAG,KAAK,CAAC,SAAS,CAyIxF"}
|
|
@@ -20,9 +20,9 @@ import { callPublishingEndpoint, describeBlock } from './publishing-client.js';
|
|
|
20
20
|
*/
|
|
21
21
|
export function PublishButton(props = {}) {
|
|
22
22
|
const routePrefix = props.routePrefix ?? '/publishing';
|
|
23
|
-
const { collectionSlug,
|
|
23
|
+
const { collectionSlug, hasPublishedDoc, hasPublishPermission, id, setHasPublishedDoc, setMostRecentVersionIsAutosaved, setUnpublishedVersionCount, unpublishedVersionCount, uploadStatus, } = useDocumentInfo();
|
|
24
24
|
const { config } = useConfig();
|
|
25
|
-
const {
|
|
25
|
+
const { submit } = useForm();
|
|
26
26
|
const modified = useFormModified();
|
|
27
27
|
const { t } = useTranslation();
|
|
28
28
|
const [publishing, setPublishing] = useState(false);
|
|
@@ -33,9 +33,14 @@ export function PublishButton(props = {}) {
|
|
|
33
33
|
return;
|
|
34
34
|
setPublishing(true);
|
|
35
35
|
try {
|
|
36
|
-
// Persist pending edits first.
|
|
37
|
-
//
|
|
38
|
-
//
|
|
36
|
+
// Persist pending edits first, as a draft. Payload writes those to the
|
|
37
|
+
// versions table and leaves the published document alone, so the trust
|
|
38
|
+
// boundary lets them through; the pipeline then evaluates what was
|
|
39
|
+
// actually saved rather than what is on screen.
|
|
40
|
+
//
|
|
41
|
+
// `?draft=true` is what makes this a draft write. The `_status`
|
|
42
|
+
// override only pins the intent when form state carries a `_status` of
|
|
43
|
+
// its own, and mirrors what Payload's native Save Draft sends.
|
|
39
44
|
if (modified) {
|
|
40
45
|
const saved = await submit({
|
|
41
46
|
action: `${serverURL}${api}/${collectionSlug}/${id}?draft=true&depth=0`,
|
|
@@ -69,13 +74,14 @@ export function PublishButton(props = {}) {
|
|
|
69
74
|
});
|
|
70
75
|
return;
|
|
71
76
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
// Deliberately not resetting the form here. The draft save above
|
|
78
|
+
// already merged the server's response into form state, so the fields
|
|
79
|
+
// on screen are what was just written. Resetting from
|
|
80
|
+
// `useDocumentInfo().data` — the document as it was when the view
|
|
81
|
+
// mounted — would replace the editor's saved edits with the pre-edit
|
|
82
|
+
// values and read as though the publish had silently discarded them.
|
|
83
|
+
//
|
|
84
|
+
// The status indicators below are what actually needs updating.
|
|
79
85
|
setHasPublishedDoc(true);
|
|
80
86
|
setUnpublishedVersionCount(0);
|
|
81
87
|
setMostRecentVersionIsAutosaved(false);
|
|
@@ -99,12 +105,9 @@ export function PublishButton(props = {}) {
|
|
|
99
105
|
}, [
|
|
100
106
|
api,
|
|
101
107
|
collectionSlug,
|
|
102
|
-
data,
|
|
103
108
|
id,
|
|
104
109
|
modified,
|
|
105
|
-
props.publishedAtField,
|
|
106
110
|
publishing,
|
|
107
|
-
reset,
|
|
108
111
|
routePrefix,
|
|
109
112
|
serverURL,
|
|
110
113
|
setHasPublishedDoc,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PublishButton.js","sourceRoot":"","sources":["../../src/admin/PublishButton.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAA;;AAEZ,OAAc,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AACpD,OAAO,EACL,UAAU,EACV,KAAK,EACL,SAAS,EACT,eAAe,EACf,OAAO,EACP,eAAe,EACf,cAAc,GACf,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAAE,sBAAsB,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAA;
|
|
1
|
+
{"version":3,"file":"PublishButton.js","sourceRoot":"","sources":["../../src/admin/PublishButton.tsx"],"names":[],"mappings":"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"}
|
|
@@ -1,16 +1,38 @@
|
|
|
1
1
|
import type { CollectionBeforeChangeHook } from 'payload';
|
|
2
2
|
/**
|
|
3
3
|
* The trust boundary. This `beforeChange` hook rejects every update that
|
|
4
|
-
*
|
|
5
|
-
* `bypassPublishingServer` context flag (set by the pipeline's
|
|
4
|
+
* changes whether a document is live, unless the request explicitly carries
|
|
5
|
+
* the `bypassPublishingServer` context flag (set by the pipeline's
|
|
6
|
+
* `executeStep`).
|
|
6
7
|
*
|
|
7
8
|
* Without this hook, the Payload MCP could let Claude flip a document to
|
|
8
9
|
* `published` directly, sidestepping every check the publishing server
|
|
9
10
|
* exists to enforce. With it, the only sanctioned path is the pipeline.
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* Three details of Payload's update pipeline shape this:
|
|
13
|
+
*
|
|
14
|
+
* 1. `data` is the existing document merged with the incoming changes, so
|
|
15
|
+
* `_status` is present on essentially every update — an ordinary field
|
|
16
|
+
* edit on a published document arrives carrying `_status: 'published'`.
|
|
17
|
+
* The presence of `_status` therefore means nothing on its own.
|
|
18
|
+
*
|
|
19
|
+
* 2. Payload injects `data._status = 'draft'` into every `draft: true`
|
|
20
|
+
* update before this hook runs, so a draft save of a published document
|
|
21
|
+
* arrives looking like an unpublish. `createRecordDraftWritesHook`
|
|
22
|
+
* captures the operation's real `draft` flag to tell them apart; both
|
|
23
|
+
* hooks must be installed together.
|
|
24
|
+
*
|
|
25
|
+
* 3. `originalDoc` is the *latest version*, not the live document. Once any
|
|
26
|
+
* draft version exists on a published document, `originalDoc._status` is
|
|
27
|
+
* `'draft'` while the document is still live — so comparing against it
|
|
28
|
+
* reads a genuine unpublish as a harmless no-op.
|
|
29
|
+
*
|
|
30
|
+
* So the question asked here is narrow and concrete: **would this write
|
|
31
|
+
* change what the public sees?** A draft write never does. A non-draft
|
|
32
|
+
* write does when it takes a live document down, or when it puts content
|
|
33
|
+
* live — including promoting a pending draft, which leaves `_status` on
|
|
34
|
+
* `published` throughout and so cannot be caught by comparing statuses
|
|
35
|
+
* alone.
|
|
14
36
|
*/
|
|
15
37
|
export declare function createBlockStatusWritesHook(): CollectionBeforeChangeHook;
|
|
16
38
|
//# sourceMappingURL=block-status-writes.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"block-status-writes.d.ts","sourceRoot":"","sources":["../../src/hooks/block-status-writes.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,0BAA0B,
|
|
1
|
+
{"version":3,"file":"block-status-writes.d.ts","sourceRoot":"","sources":["../../src/hooks/block-status-writes.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,0BAA0B,EAAkB,MAAM,SAAS,CAAA;AAGzE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,wBAAgB,2BAA2B,IAAI,0BAA0B,CAsCxE"}
|
|
@@ -1,37 +1,117 @@
|
|
|
1
1
|
import { APIError } from 'payload';
|
|
2
|
+
import { isDraftWrite } from './draft-writes.js';
|
|
2
3
|
/**
|
|
3
4
|
* The trust boundary. This `beforeChange` hook rejects every update that
|
|
4
|
-
*
|
|
5
|
-
* `bypassPublishingServer` context flag (set by the pipeline's
|
|
5
|
+
* changes whether a document is live, unless the request explicitly carries
|
|
6
|
+
* the `bypassPublishingServer` context flag (set by the pipeline's
|
|
7
|
+
* `executeStep`).
|
|
6
8
|
*
|
|
7
9
|
* Without this hook, the Payload MCP could let Claude flip a document to
|
|
8
10
|
* `published` directly, sidestepping every check the publishing server
|
|
9
11
|
* exists to enforce. With it, the only sanctioned path is the pipeline.
|
|
10
12
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
13
|
+
* Three details of Payload's update pipeline shape this:
|
|
14
|
+
*
|
|
15
|
+
* 1. `data` is the existing document merged with the incoming changes, so
|
|
16
|
+
* `_status` is present on essentially every update — an ordinary field
|
|
17
|
+
* edit on a published document arrives carrying `_status: 'published'`.
|
|
18
|
+
* The presence of `_status` therefore means nothing on its own.
|
|
19
|
+
*
|
|
20
|
+
* 2. Payload injects `data._status = 'draft'` into every `draft: true`
|
|
21
|
+
* update before this hook runs, so a draft save of a published document
|
|
22
|
+
* arrives looking like an unpublish. `createRecordDraftWritesHook`
|
|
23
|
+
* captures the operation's real `draft` flag to tell them apart; both
|
|
24
|
+
* hooks must be installed together.
|
|
25
|
+
*
|
|
26
|
+
* 3. `originalDoc` is the *latest version*, not the live document. Once any
|
|
27
|
+
* draft version exists on a published document, `originalDoc._status` is
|
|
28
|
+
* `'draft'` while the document is still live — so comparing against it
|
|
29
|
+
* reads a genuine unpublish as a harmless no-op.
|
|
30
|
+
*
|
|
31
|
+
* So the question asked here is narrow and concrete: **would this write
|
|
32
|
+
* change what the public sees?** A draft write never does. A non-draft
|
|
33
|
+
* write does when it takes a live document down, or when it puts content
|
|
34
|
+
* live — including promoting a pending draft, which leaves `_status` on
|
|
35
|
+
* `published` throughout and so cannot be caught by comparing statuses
|
|
36
|
+
* alone.
|
|
14
37
|
*/
|
|
15
38
|
export function createBlockStatusWritesHook() {
|
|
16
|
-
return ({ data, originalDoc, operation, context, req }) => {
|
|
39
|
+
return async ({ collection, data, originalDoc, operation, context, req }) => {
|
|
17
40
|
if (operation !== 'update')
|
|
18
41
|
return data;
|
|
19
|
-
if (!
|
|
42
|
+
if (!carriesStatus(data))
|
|
20
43
|
return data;
|
|
21
|
-
// No-op writes (status set to its current value) are harmless.
|
|
22
|
-
if (originalDoc &&
|
|
23
|
-
typeof originalDoc === 'object' &&
|
|
24
|
-
data['_status'] ===
|
|
25
|
-
originalDoc['_status']) {
|
|
26
|
-
return data;
|
|
27
|
-
}
|
|
28
44
|
if (isBypassed(context) || isBypassed(req?.context))
|
|
29
45
|
return data;
|
|
46
|
+
const nextStatus = data['_status'];
|
|
47
|
+
const previousStatus = originalDoc?.['_status'];
|
|
48
|
+
const id = originalDoc?.id;
|
|
49
|
+
// A draft save writes a version and leaves the live document alone.
|
|
50
|
+
//
|
|
51
|
+
// Mirror Payload's own `isSavingDraft`, which excludes `published`: a
|
|
52
|
+
// request asking for `draft: true` while setting `_status: 'published'`
|
|
53
|
+
// is a real publish, so `draft` cannot become a way around the pipeline.
|
|
54
|
+
if (nextStatus !== 'published' && isDraftWrite(req, collection?.slug, id)) {
|
|
55
|
+
return data;
|
|
56
|
+
}
|
|
57
|
+
if (nextStatus === 'published') {
|
|
58
|
+
// This puts content live. Allowed only when the document is already
|
|
59
|
+
// live *and* nothing is pending: a latest version of `published`
|
|
60
|
+
// means there is no newer draft for this write to promote. When a
|
|
61
|
+
// draft is pending, this is the publish and it belongs in the
|
|
62
|
+
// pipeline — even though the live status does not change.
|
|
63
|
+
if (previousStatus === 'published')
|
|
64
|
+
return data;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
// This takes the live document down. Harmless only if nothing is
|
|
68
|
+
// live: a document that was never published, or is already down.
|
|
69
|
+
const liveStatus = await resolveLiveStatus(req, collection?.slug, id, previousStatus);
|
|
70
|
+
if (liveStatus !== undefined && liveStatus !== 'published')
|
|
71
|
+
return data;
|
|
72
|
+
}
|
|
30
73
|
throw new APIError('Direct writes to `_status` are not allowed. Use the publishing server (publish / unpublish / rollback) so the policy pipeline runs.', 400);
|
|
31
74
|
};
|
|
32
75
|
}
|
|
33
|
-
|
|
34
|
-
|
|
76
|
+
/**
|
|
77
|
+
* The live document's current `_status`, or `undefined` when it cannot be
|
|
78
|
+
* established — in which case the caller blocks the write.
|
|
79
|
+
*
|
|
80
|
+
* `originalDoc` is the latest version. A latest version of `published`
|
|
81
|
+
* means the live document is published, so that answer is taken directly.
|
|
82
|
+
* A latest version of `draft` is ambiguous — the document may still be live
|
|
83
|
+
* with a newer draft sitting on top of it — and only then is the published
|
|
84
|
+
* row read. Draft writes return before reaching this, so the admin's
|
|
85
|
+
* publish flow never pays for the query.
|
|
86
|
+
*/
|
|
87
|
+
async function resolveLiveStatus(req, collectionSlug, id, previousStatus) {
|
|
88
|
+
if (previousStatus === 'published')
|
|
89
|
+
return 'published';
|
|
90
|
+
if (!req?.payload || !collectionSlug || id === undefined || id === null)
|
|
91
|
+
return undefined;
|
|
92
|
+
try {
|
|
93
|
+
const live = await req.payload.findByID({
|
|
94
|
+
collection: collectionSlug,
|
|
95
|
+
id: id,
|
|
96
|
+
depth: 0,
|
|
97
|
+
overrideAccess: true,
|
|
98
|
+
// Share the request so this reads inside the open transaction.
|
|
99
|
+
req,
|
|
100
|
+
});
|
|
101
|
+
const status = live?.['_status'];
|
|
102
|
+
return typeof status === 'string' ? status : undefined;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Whether `_status` is present at all. Payload merges the stored document
|
|
110
|
+
* into `data`, so this is true for nearly every update — it only filters
|
|
111
|
+
* out collections without drafts.
|
|
112
|
+
*/
|
|
113
|
+
function carriesStatus(data) {
|
|
114
|
+
return typeof data === 'object' && data !== null && '_status' in data;
|
|
35
115
|
}
|
|
36
116
|
function isBypassed(context) {
|
|
37
117
|
if (!context || typeof context !== 'object')
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"block-status-writes.js","sourceRoot":"","sources":["../../src/hooks/block-status-writes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAA;
|
|
1
|
+
{"version":3,"file":"block-status-writes.js","sourceRoot":"","sources":["../../src/hooks/block-status-writes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAA;AAElC,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAEhD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,MAAM,UAAU,2BAA2B;IACzC,OAAO,KAAK,EAAE,EAAE,UAAU,EAAE,IAAI,EAAE,WAAW,EAAE,SAAS,EAAE,OAAO,EAAE,GAAG,EAAE,EAAE,EAAE;QAC1E,IAAI,SAAS,KAAK,QAAQ;YAAE,OAAO,IAAI,CAAA;QACvC,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC;YAAE,OAAO,IAAI,CAAA;QACrC,IAAI,UAAU,CAAC,OAAO,CAAC,IAAI,UAAU,CAAC,GAAG,EAAE,OAAO,CAAC;YAAE,OAAO,IAAI,CAAA;QAEhE,MAAM,UAAU,GAAI,IAAgC,CAAC,SAAS,CAAC,CAAA;QAC/D,MAAM,cAAc,GAAI,WAAmD,EAAE,CAAC,SAAS,CAAC,CAAA;QACxF,MAAM,EAAE,GAAI,WAA4C,EAAE,EAAE,CAAA;QAE5D,oEAAoE;QACpE,EAAE;QACF,sEAAsE;QACtE,wEAAwE;QACxE,yEAAyE;QACzE,IAAI,UAAU,KAAK,WAAW,IAAI,YAAY,CAAC,GAAG,EAAE,UAAU,EAAE,IAAI,EAAE,EAAE,CAAC,EAAE,CAAC;YAC1E,OAAO,IAAI,CAAA;QACb,CAAC;QAED,IAAI,UAAU,KAAK,WAAW,EAAE,CAAC;YAC/B,oEAAoE;YACpE,iEAAiE;YACjE,kEAAkE;YAClE,8DAA8D;YAC9D,0DAA0D;YAC1D,IAAI,cAAc,KAAK,WAAW;gBAAE,OAAO,IAAI,CAAA;QACjD,CAAC;aAAM,CAAC;YACN,iEAAiE;YACjE,iEAAiE;YACjE,MAAM,UAAU,GAAG,MAAM,iBAAiB,CAAC,GAAG,EAAE,UAAU,EAAE,IAAI,EAAE,EAAE,EAAE,cAAc,CAAC,CAAA;YACrF,IAAI,UAAU,KAAK,SAAS,IAAI,UAAU,KAAK,WAAW;gBAAE,OAAO,IAAI,CAAA;QACzE,CAAC;QAED,MAAM,IAAI,QAAQ,CAChB,qIAAqI,EACrI,GAAG,CACJ,CAAA;IACH,CAAC,CAAA;AACH,CAAC;AAED;;;;;;;;;;GAUG;AACH,KAAK,UAAU,iBAAiB,CAC9B,GAA+B,EAC/B,cAAkC,EAClC,EAAW,EACX,cAAuB;IAEvB,IAAI,cAAc,KAAK,WAAW;QAAE,OAAO,WAAW,CAAA;IACtD,IAAI,CAAC,GAAG,EAAE,OAAO,IAAI,CAAC,cAAc,IAAI,EAAE,KAAK,SAAS,IAAI,EAAE,KAAK,IAAI;QAAE,OAAO,SAAS,CAAA;IAEzF,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,OAAO,CAAC,QAAQ,CAAC;YACtC,UAAU,EAAE,cAAc;YAC1B,EAAE,EAAE,EAAqB;YACzB,KAAK,EAAE,CAAC;YACR,cAAc,EAAE,IAAI;YACpB,+DAA+D;YAC/D,GAAG;SACJ,CAAC,CAAA;QACF,MAAM,MAAM,GAAI,IAAuC,EAAE,CAAC,SAAS,CAAC,CAAA;QACpE,OAAO,OAAO,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,CAAA;IACxD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAA;IAClB,CAAC;AACH,CAAC;AAED;;;;GAIG;AACH,SAAS,aAAa,CAAC,IAAa;IAClC,OAAO,OAAO,IAAI,KAAK,QAAQ,IAAI,IAAI,KAAK,IAAI,IAAI,SAAS,IAAI,IAAI,CAAA;AACvE,CAAC;AAED,SAAS,UAAU,CAAC,OAAgB;IAClC,IAAI,CAAC,OAAO,IAAI,OAAO,OAAO,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAA;IACzD,OAAQ,OAAmC,CAAC,wBAAwB,CAAC,KAAK,IAAI,CAAA;AAChF,CAAC"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { CollectionBeforeOperationHook, PayloadRequest } from 'payload';
|
|
2
|
+
/**
|
|
3
|
+
* Records whether the update in flight is a draft write, so the
|
|
4
|
+
* status-write hook can tell a draft save apart from an unpublish.
|
|
5
|
+
*
|
|
6
|
+
* It has to be recorded here because `beforeChange` cannot work it out:
|
|
7
|
+
* Payload sets `data._status = 'draft'` for any `draft: true` update
|
|
8
|
+
* *before* calling the hook, whether or not the caller supplied it —
|
|
9
|
+
*
|
|
10
|
+
* const isSavingDraft = Boolean(draftArg && hasDraftsEnabled(config))
|
|
11
|
+
* && data._status !== 'published' && !publishAllLocales
|
|
12
|
+
* if (isSavingDraft) { data._status = 'draft' }
|
|
13
|
+
*
|
|
14
|
+
* so by the time `beforeChange` runs, saving a draft of a published
|
|
15
|
+
* document and unpublishing it look identical: `data._status` is `'draft'`
|
|
16
|
+
* and `originalDoc._status` is `'published'` in both cases.
|
|
17
|
+
*
|
|
18
|
+
* `beforeOperation` is the one place that sees the operation's own `draft`
|
|
19
|
+
* argument, and it sees it identically on the Local API, REST and GraphQL —
|
|
20
|
+
* unlike `req.query.draft`, which is only populated on the REST path.
|
|
21
|
+
* `beforeOperation` shares `req.context` with `beforeChange`, which is how
|
|
22
|
+
* the flag gets across.
|
|
23
|
+
*/
|
|
24
|
+
export declare function createRecordDraftWritesHook(): CollectionBeforeOperationHook;
|
|
25
|
+
/**
|
|
26
|
+
* Whether the update in flight on this document is a draft write.
|
|
27
|
+
*
|
|
28
|
+
* Defaults to `false` when nothing was recorded, so a caller that installs
|
|
29
|
+
* the status-write hook without this one keeps the stricter behaviour.
|
|
30
|
+
*/
|
|
31
|
+
export declare function isDraftWrite(req: Pick<PayloadRequest, 'context'> | undefined, collectionSlug: string | undefined, id: unknown): boolean;
|
|
32
|
+
//# sourceMappingURL=draft-writes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"draft-writes.d.ts","sourceRoot":"","sources":["../../src/hooks/draft-writes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,6BAA6B,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAM5E;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,2BAA2B,IAAI,6BAA6B,CAW3E;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAC1B,GAAG,EAAE,IAAI,CAAC,cAAc,EAAE,SAAS,CAAC,GAAG,SAAS,EAChD,cAAc,EAAE,MAAM,GAAG,SAAS,EAClC,EAAE,EAAE,OAAO,GACV,OAAO,CAcT"}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const DRAFT_WRITES_KEY = '__throughlinePublishingDraftWrites';
|
|
2
|
+
/**
|
|
3
|
+
* Records whether the update in flight is a draft write, so the
|
|
4
|
+
* status-write hook can tell a draft save apart from an unpublish.
|
|
5
|
+
*
|
|
6
|
+
* It has to be recorded here because `beforeChange` cannot work it out:
|
|
7
|
+
* Payload sets `data._status = 'draft'` for any `draft: true` update
|
|
8
|
+
* *before* calling the hook, whether or not the caller supplied it —
|
|
9
|
+
*
|
|
10
|
+
* const isSavingDraft = Boolean(draftArg && hasDraftsEnabled(config))
|
|
11
|
+
* && data._status !== 'published' && !publishAllLocales
|
|
12
|
+
* if (isSavingDraft) { data._status = 'draft' }
|
|
13
|
+
*
|
|
14
|
+
* so by the time `beforeChange` runs, saving a draft of a published
|
|
15
|
+
* document and unpublishing it look identical: `data._status` is `'draft'`
|
|
16
|
+
* and `originalDoc._status` is `'published'` in both cases.
|
|
17
|
+
*
|
|
18
|
+
* `beforeOperation` is the one place that sees the operation's own `draft`
|
|
19
|
+
* argument, and it sees it identically on the Local API, REST and GraphQL —
|
|
20
|
+
* unlike `req.query.draft`, which is only populated on the REST path.
|
|
21
|
+
* `beforeOperation` shares `req.context` with `beforeChange`, which is how
|
|
22
|
+
* the flag gets across.
|
|
23
|
+
*/
|
|
24
|
+
export function createRecordDraftWritesHook() {
|
|
25
|
+
return ({ args, operation, req }) => {
|
|
26
|
+
// `update` and `updateByID` both arrive as the `update` hook operation.
|
|
27
|
+
if (operation !== 'update')
|
|
28
|
+
return;
|
|
29
|
+
const updateArgs = args;
|
|
30
|
+
const slug = collectionSlug(args);
|
|
31
|
+
if (!slug)
|
|
32
|
+
return;
|
|
33
|
+
registry(req)[draftWriteKey(slug, updateArgs.id)] = updateArgs.draft === true;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Whether the update in flight on this document is a draft write.
|
|
38
|
+
*
|
|
39
|
+
* Defaults to `false` when nothing was recorded, so a caller that installs
|
|
40
|
+
* the status-write hook without this one keeps the stricter behaviour.
|
|
41
|
+
*/
|
|
42
|
+
export function isDraftWrite(req, collectionSlug, id) {
|
|
43
|
+
if (!req || !collectionSlug)
|
|
44
|
+
return false;
|
|
45
|
+
const entries = req.context?.[DRAFT_WRITES_KEY];
|
|
46
|
+
if (!entries)
|
|
47
|
+
return false;
|
|
48
|
+
// Keyed per document so a nested write to a different document during the
|
|
49
|
+
// same request cannot be mistaken for this one.
|
|
50
|
+
const scoped = entries[draftWriteKey(collectionSlug, id)];
|
|
51
|
+
if (scoped !== undefined)
|
|
52
|
+
return scoped;
|
|
53
|
+
// Bulk updates match by `where` and carry no id.
|
|
54
|
+
return entries[draftWriteKey(collectionSlug, undefined)] === true;
|
|
55
|
+
}
|
|
56
|
+
function draftWriteKey(collectionSlug, id) {
|
|
57
|
+
return `${collectionSlug}:${id === undefined || id === null ? '*' : String(id)}`;
|
|
58
|
+
}
|
|
59
|
+
function collectionSlug(args) {
|
|
60
|
+
const slug = args?.collection?.config
|
|
61
|
+
?.slug;
|
|
62
|
+
return typeof slug === 'string' ? slug : undefined;
|
|
63
|
+
}
|
|
64
|
+
function registry(req) {
|
|
65
|
+
// Payload always assigns `req.context`, but attach it if absent rather
|
|
66
|
+
// than writing the flag to an object nothing else can see.
|
|
67
|
+
if (!req.context) {
|
|
68
|
+
req.context = {};
|
|
69
|
+
}
|
|
70
|
+
const context = req.context;
|
|
71
|
+
if (!context[DRAFT_WRITES_KEY]) {
|
|
72
|
+
context[DRAFT_WRITES_KEY] = {};
|
|
73
|
+
}
|
|
74
|
+
return context[DRAFT_WRITES_KEY];
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=draft-writes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"draft-writes.js","sourceRoot":"","sources":["../../src/hooks/draft-writes.ts"],"names":[],"mappings":"AAEA,MAAM,gBAAgB,GAAG,oCAAoC,CAAA;AAI7D;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,UAAU,2BAA2B;IACzC,OAAO,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,GAAG,EAAE,EAAE,EAAE;QAClC,wEAAwE;QACxE,IAAI,SAAS,KAAK,QAAQ;YAAE,OAAM;QAElC,MAAM,UAAU,GAAG,IAAiD,CAAA;QACpE,MAAM,IAAI,GAAG,cAAc,CAAC,IAAI,CAAC,CAAA;QACjC,IAAI,CAAC,IAAI;YAAE,OAAM;QAEjB,QAAQ,CAAC,GAAG,CAAC,CAAC,aAAa,CAAC,IAAI,EAAE,UAAU,CAAC,EAAE,CAAC,CAAC,GAAG,UAAU,CAAC,KAAK,KAAK,IAAI,CAAA;IAC/E,CAAC,CAAA;AACH,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,YAAY,CAC1B,GAAgD,EAChD,cAAkC,EAClC,EAAW;IAEX,IAAI,CAAC,GAAG,IAAI,CAAC,cAAc;QAAE,OAAO,KAAK,CAAA;IACzC,MAAM,OAAO,GAAI,GAAG,CAAC,OAA+C,EAAE,CACpE,gBAAgB,CACiB,CAAA;IACnC,IAAI,CAAC,OAAO;QAAE,OAAO,KAAK,CAAA;IAE1B,0EAA0E;IAC1E,gDAAgD;IAChD,MAAM,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,cAAc,EAAE,EAAE,CAAC,CAAC,CAAA;IACzD,IAAI,MAAM,KAAK,SAAS;QAAE,OAAO,MAAM,CAAA;IAEvC,iDAAiD;IACjD,OAAO,OAAO,CAAC,aAAa,CAAC,cAAc,EAAE,SAAS,CAAC,CAAC,KAAK,IAAI,CAAA;AACnE,CAAC;AAED,SAAS,aAAa,CAAC,cAAsB,EAAE,EAAW;IACxD,OAAO,GAAG,cAAc,IAAI,EAAE,KAAK,SAAS,IAAI,EAAE,KAAK,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,CAAA;AAClF,CAAC;AAED,SAAS,cAAc,CAAC,IAAa;IACnC,MAAM,IAAI,GAAI,IAAyD,EAAE,UAAU,EAAE,MAAM;QACzF,EAAE,IAAI,CAAA;IACR,OAAO,OAAO,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAA;AACpD,CAAC;AAED,SAAS,QAAQ,CAAC,GAAmB;IACnC,uEAAuE;IACvE,2DAA2D;IAC3D,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,CAAC;QACjB,GAAG,CAAC,OAAO,GAAG,EAA+B,CAAA;IAC/C,CAAC;IACD,MAAM,OAAO,GAAG,GAAG,CAAC,OAA6C,CAAA;IACjE,IAAI,CAAC,OAAO,CAAC,gBAAgB,CAAC,EAAE,CAAC;QAC/B,OAAO,CAAC,gBAAgB,CAAC,GAAG,EAA+B,CAAA;IAC7D,CAAC;IACD,OAAO,OAAO,CAAC,gBAAgB,CAAuB,CAAA;AACxD,CAAC"}
|
package/dist/plugin.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,uCAAuC,CAAA;AAQvE,OAAO,
|
|
1
|
+
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,uCAAuC,CAAA;AAQvE,OAAO,EAAE,KAAK,uBAAuB,EAAmB,MAAM,cAAc,CAAA;AAqB5E,eAAO,MAAM,gBAAgB,EAAE,UAAU,CAAC,uBAAuB,CA8G9D,CAAA"}
|
package/dist/plugin.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { getPluginRegistry } from '@forumone/throughline-plugin-contract';
|
|
2
2
|
import { createMcpHandler, createNamedLogger, defaultLogger, getAuditWriter, } from '@forumone/throughline-core';
|
|
3
|
-
import {
|
|
3
|
+
import { validateOptions } from './options.js';
|
|
4
4
|
import { createBlockStatusWritesHook } from './hooks/block-status-writes.js';
|
|
5
|
+
import { createRecordDraftWritesHook } from './hooks/draft-writes.js';
|
|
5
6
|
import { createAdminEndpoints } from './endpoints/admin.js';
|
|
6
7
|
import { attachPublishingService, createPublishingService } from './service.js';
|
|
7
8
|
import { createGetPublishStatusTool, createPublishTool, createRollbackTool, createSchedulePublishTool, createUnpublishTool, } from './tools/index.js';
|
|
@@ -24,16 +25,23 @@ export const publishingPlugin = (rawOptions) => (incomingConfig) => {
|
|
|
24
25
|
return collection;
|
|
25
26
|
return {
|
|
26
27
|
...collection,
|
|
27
|
-
// The trust boundary: nothing may
|
|
28
|
+
// The trust boundary: nothing may change the live document's
|
|
29
|
+
// `_status` outside the pipeline. `beforeOperation` records whether
|
|
30
|
+
// the update is a draft write, which is the only place Payload
|
|
31
|
+
// exposes that; `beforeChange` enforces. They must ship together.
|
|
28
32
|
hooks: {
|
|
29
33
|
...(collection.hooks ?? {}),
|
|
34
|
+
beforeOperation: [
|
|
35
|
+
...(collection.hooks?.beforeOperation ?? []),
|
|
36
|
+
createRecordDraftWritesHook(),
|
|
37
|
+
],
|
|
30
38
|
beforeChange: [
|
|
31
39
|
...(collection.hooks?.beforeChange ?? []),
|
|
32
40
|
createBlockStatusWritesHook(),
|
|
33
41
|
],
|
|
34
42
|
},
|
|
35
43
|
...(adminComponents
|
|
36
|
-
? { admin: withAdminControls(collection, routePrefix
|
|
44
|
+
? { admin: withAdminControls(collection, routePrefix) }
|
|
37
45
|
: {}),
|
|
38
46
|
};
|
|
39
47
|
});
|
|
@@ -107,8 +115,7 @@ export const publishingPlugin = (rawOptions) => (incomingConfig) => {
|
|
|
107
115
|
* Hosts must run `payload generate:importmap` after adding the plugin (the
|
|
108
116
|
* dev server does it automatically) so Payload can resolve these paths.
|
|
109
117
|
*/
|
|
110
|
-
function withAdminControls(collection, routePrefix
|
|
111
|
-
const resolved = resolveCollection(options, collection.slug);
|
|
118
|
+
function withAdminControls(collection, routePrefix) {
|
|
112
119
|
const edit = collection.admin?.components?.edit ?? {};
|
|
113
120
|
return {
|
|
114
121
|
...(collection.admin ?? {}),
|
|
@@ -121,10 +128,7 @@ function withAdminControls(collection, routePrefix, options) {
|
|
|
121
128
|
PublishButton: {
|
|
122
129
|
path: CLIENT_ENTRY,
|
|
123
130
|
exportName: 'PublishButton',
|
|
124
|
-
clientProps: {
|
|
125
|
-
routePrefix,
|
|
126
|
-
publishedAtField: resolved.publishedAtField,
|
|
127
|
-
},
|
|
131
|
+
clientProps: { routePrefix },
|
|
128
132
|
},
|
|
129
133
|
}
|
|
130
134
|
: {}),
|
package/dist/plugin.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plugin.js","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,iBAAiB,EAAE,MAAM,uCAAuC,CAAA;AACzE,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,aAAa,EACb,cAAc,GACf,MAAM,4BAA4B,CAAA;AACnC,OAAO,
|
|
1
|
+
{"version":3,"file":"plugin.js","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forumone/throughline-publishing",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Policy-gated publishing server for Throughline. Wraps Payload's update operation with composition, accessibility, required-field, embargo, and approval gating.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
"@forumone/throughline-plugin-contract": "0.2.1"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
|
+
"@payloadcms/db-sqlite": "^3.83.0",
|
|
66
67
|
"@payloadcms/ui": "^3.83.0",
|
|
67
68
|
"@types/node": "^20.17.0",
|
|
68
69
|
"@types/react": "^19.2.0",
|