@sanoma-ai/connector-ghost 0.0.0-stage → 0.2.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/LICENSE ADDED
@@ -0,0 +1,105 @@
1
+ # Functional Source License, Version 1.1, ALv2 Future License
2
+
3
+ ## Abbreviation
4
+
5
+ FSL-1.1-ALv2
6
+
7
+ ## Notice
8
+
9
+ Copyright 2026 Sanoma-AI
10
+
11
+ ## Terms and Conditions
12
+
13
+ ### Licensor ("We")
14
+
15
+ The party offering the Software under these Terms and Conditions.
16
+
17
+ ### The Software
18
+
19
+ The "Software" is each version of the software that we make available under
20
+ these Terms and Conditions, as indicated by our inclusion of these Terms and
21
+ Conditions with the Software.
22
+
23
+ ### License Grant
24
+
25
+ Subject to your compliance with this License Grant and the Patents,
26
+ Redistribution and Trademark clauses below, we hereby grant you the right to
27
+ use, copy, modify, create derivative works, publicly perform, publicly display
28
+ and redistribute the Software for any Permitted Purpose identified below.
29
+
30
+ ### Permitted Purpose
31
+
32
+ A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
33
+ means making the Software available to others in a commercial product or
34
+ service that:
35
+
36
+ 1. substitutes for the Software;
37
+
38
+ 2. substitutes for any other product or service we offer using the Software
39
+ that exists as of the date we make the Software available; or
40
+
41
+ 3. offers the same or substantially similar functionality as the Software.
42
+
43
+ Permitted Purposes specifically include using the Software:
44
+
45
+ 1. for your internal use and access;
46
+
47
+ 2. for non-commercial education;
48
+
49
+ 3. for non-commercial research; and
50
+
51
+ 4. in connection with professional services that you provide to a licensee
52
+ using the Software in accordance with these Terms and Conditions.
53
+
54
+ ### Patents
55
+
56
+ To the extent your use for a Permitted Purpose would necessarily infringe our
57
+ patents, the license grant above includes a license under our patents. If you
58
+ make a claim against any party that the Software infringes or contributes to
59
+ the infringement of any patent, then your patent license to the Software ends
60
+ immediately.
61
+
62
+ ### Redistribution
63
+
64
+ The Terms and Conditions apply to all copies, modifications and derivatives of
65
+ the Software.
66
+
67
+ If you redistribute any copies, modifications or derivatives of the Software,
68
+ you must include a copy of or a link to these Terms and Conditions and not
69
+ remove any copyright notices provided in or with the Software.
70
+
71
+ ### Disclaimer
72
+
73
+ THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
74
+ IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
75
+ PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
76
+
77
+ IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
78
+ SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
79
+ EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
80
+
81
+ ### Trademarks
82
+
83
+ Except for displaying the License Details and identifying us as the origin of
84
+ the Software, you have no right under these Terms and Conditions to use our
85
+ trademarks, trade names, service marks or product names.
86
+
87
+ ## Grant of Future License
88
+
89
+ We hereby irrevocably grant you an additional license to use the Software under
90
+ the Apache License, Version 2.0 that is effective on the second anniversary of
91
+ the date we make the Software available. On or after that date, you may use the
92
+ Software under the Apache License, Version 2.0, in which case the following
93
+ will apply:
94
+
95
+ Licensed under the Apache License, Version 2.0 (the "License"); you may not use
96
+ this file except in compliance with the License.
97
+
98
+ You may obtain a copy of the License at
99
+
100
+ http://www.apache.org/licenses/LICENSE-2.0
101
+
102
+ Unless required by applicable law or agreed to in writing, software distributed
103
+ under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
104
+ CONDITIONS OF ANY KIND, either express or implied. See the License for the
105
+ specific language governing permissions and limitations under the License.
package/README.md CHANGED
@@ -1,3 +1,122 @@
1
- # Temporary Holding Version
1
+ # @sanoma-ai/connector-ghost
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [Ghost Admin API](https://ghost.org/docs/admin-api/) operations for [`@sanoma-ai/workflows`](https://www.npmjs.com/package/@sanoma-ai/workflows).
4
+
5
+ ## Usage
6
+
7
+ ```sh
8
+ npm install @sanoma-ai/connector-ghost
9
+ ```
10
+
11
+ | Operation | Effect | What it does |
12
+ | -------------------- | --------- | -------------------------------------------------------- |
13
+ | `ghost.post.create` | `write` | Create a draft post. Drafts are not visible on the site. |
14
+ | `ghost.post.publish` | `publish` | Publish a draft post on the site. Idempotent. |
15
+
16
+ ```ts
17
+ import { ghost } from "@sanoma-ai/connector-ghost";
18
+ import { defineWorkflow } from "@sanoma-ai/workflows";
19
+
20
+ defineWorkflow({
21
+ // ...
22
+ uses: [ghost.post.create, ghost.post.publish],
23
+ run: async (ctx, input) => {
24
+ const post = await ctx.ghost.post.create({ title: input.title, html: input.body, status: "draft" });
25
+ return ctx.ghost.post.publish({ id: post.id });
26
+ },
27
+ });
28
+ ```
29
+
30
+ The logo the app shows beside these operations is Ghost's glyph from [simple-icons](https://simpleicons.org/?q=ghost) (CC0), in near-black or white. It is there only to identify the service an operation calls. Ghost is a trademark of Ghost Foundation.
31
+
32
+ ## Driver
33
+
34
+ `@sanoma-ai/connector-ghost/driver` calls the [Ghost Admin API](https://docs.ghost.org/admin-api/). The `ghost` connector the package root exports carries it, made once per resolved config, and its fake (`withSetup` from `@sanoma-ai/workflows`), so a config names the connector alone and workflows never import the driver (the lint refuses that):
35
+
36
+ ```ts
37
+ import { ghost } from "@sanoma-ai/connector-ghost";
38
+
39
+ export default defineConfig({ workflows, connectors: [ghost], policy });
40
+ ```
41
+
42
+ `./driver` (`ghostDriver`; `ghostDriver({ timeoutMs })`) and `./fake` (`fakeGhost`) are internal entries, for tests.
43
+
44
+ - `post.create` posts the HTML with `?source=html`, which Ghost converts to its editor format (lossy; see [Creating a post](https://docs.ghost.org/admin-api/posts/creating-a-post)). A draft's `url` is its preview link; it becomes the public URL when published.
45
+ - `post.publish` reads the post, then saves it with `status: "published"` and the `updated_at` it read, which Ghost requires to refuse a save over someone else's. If someone saved the post in between (`UPDATE_COLLISION`), it reads the post again and tries once more. Publishing sends no newsletter email: Ghost emails a post only when a `newsletter` is named.
46
+
47
+ ### Environment
48
+
49
+ It reads two environment variables on every call, never when the config loads:
50
+
51
+ | Variable | What it is |
52
+ | --------------------- | --------------------------------------------------------------------------------------------- |
53
+ | `GHOST_ADMIN_URL` | The site's admin URL, such as `https://example.ghost.io` (on Ghost(Pro), the `ghost.io` one). |
54
+ | `GHOST_ADMIN_API_KEY` | A custom integration's Admin API key, `<id>:<secret>`. |
55
+
56
+ To make the key: in Ghost Admin, **Settings → Integrations → Add custom integration**, name it, and copy its **Admin API key** (not the Content API key). A missing or malformed variable fails the call, naming the variable, and is not retried. The driver declares them in its `env` ([Credentials](https://www.npmjs.com/package/@sanoma-ai/workflows#credentials)), so a worker refuses to start while one is missing or malformed, and the app's connector page shows each one's status and lets you set it.
57
+
58
+ ### Idempotency
59
+
60
+ Ghost takes no idempotency key, and the runtime never retries `post.create`. If its reply is lost, the run fails and a draft stays in Ghost; the next run makes a new one. A draft is not visible on the site; delete it in Ghost Admin. `post.publish` returns a post that is already published as it is, without saving it, so a retry after a lost reply changes nothing; so too a post Ghost sent as an email only (`status: "sent"`), which is not a draft.
61
+
62
+ ### Errors and retries
63
+
64
+ A timeout (10 s; `ghostDriver({ timeoutMs })` changes it), a network failure, a 408, a 429 or a 5xx is retryable; any other 4xx is not. A failure carries Ghost's HTTP `status` and, as `vendorCode`, its error `code` when it has one (`UPDATE_COLLISION`) or else its `type` (`ValidationError`, `UnauthorizedError`). Ghost documents no rate limit for the Admin API; Ghost(Pro) may answer 429 to protect itself.
65
+
66
+ ### Plan
67
+
68
+ You need a Ghost site of your own. Self-hosted Ghost is free and open source (`docker run -p 2368:2368 -e NODE_ENV=development ghost:6-alpine` runs one locally). Ghost(Pro) has a free trial but no free plan, and its Starter plan has no custom integrations or Admin API: you need Publisher or above ([pricing](https://ghost.org/pricing/)).
69
+
70
+ ### Testing the driver
71
+
72
+ `test/driver.test.ts` replays Ghost's recorded replies (`test/fixtures`) with [`@sanoma-ai/testing/replay`](https://github.com/Sanoma-AI/sanoma/blob/main/packages/testing/README.md#replaying-a-vendors-api), which says what `SANOMA_LIVE` and `SANOMA_RECORD` do: `pnpm vitest run connectors/ghost` needs no site. Live, they create posts titled `sanoma test <timestamp>` on the site, publish some, and delete them at the end:
73
+
74
+ ```sh
75
+ SANOMA_LIVE=1 GHOST_ADMIN_URL=https://example.ghost.io GHOST_ADMIN_API_KEY=<id>:<secret> pnpm vitest run connectors/ghost
76
+ ```
77
+
78
+ A recording keeps only the fields the driver reads (no authors or emails), puts every URL on `https://blog.example.test`, and renumbers ids. The error cases only replay; `update-collision.json` was taken by hand from a local Ghost, since a test cannot make Ghost collide on cue.
79
+
80
+ ## Steps and features
81
+
82
+ The connector carries steps for [scenarios](https://github.com/Sanoma-AI/sanoma/blob/main/packages/workflows/README.md#steps-a-connector-ships) (`ghostSteps`, attached with `withSetup`), which a company's scenario can use beside the generic steps and other connectors' ones:
83
+
84
+ | Keyword | Step | What it does |
85
+ | ------- | -------------------------------------- | ------------------------------------------------------------------------------------------------- |
86
+ | Given | `a Ghost draft titled {string}` | Seeds a draft through `ghost.post.create`, named `draft` (`$draft.id` in an operation's own test) |
87
+ | Then | `the Ghost post {string} is published` | The post with that title, seeded or created by the run, was published |
88
+
89
+ `features/post.feature` creates and publishes posts, with these steps and the generic ones for an operation's own test, on the fake and on Ghost's recorded replies (`create.json`, `publish-draft.json` and `publish-again.json`): `test/features.test.ts` runs them with [`describeFeatures`](https://github.com/Sanoma-AI/sanoma/blob/main/packages/testing/README.md#a-connectors-own-features). A scenario tagged `@fake-only` has no recording. The package ships `features/`.
90
+
91
+ ## Testing
92
+
93
+ `@sanoma-ai/connector-ghost/fake` is an in-memory Ghost, the fake the connector carries: each sandbox run makes one of its own. A test that reads or seeds one attaches it to the connector itself, then checks `state.posts` and `calls`.
94
+
95
+ ```ts
96
+ import { withSetup } from "@sanoma-ai/workflows";
97
+ import { ghost } from "@sanoma-ai/connector-ghost";
98
+ import { fakeGhost } from "@sanoma-ai/connector-ghost/fake";
99
+
100
+ const fake = fakeGhost();
101
+ const worker = await startWorker({
102
+ connectors: [withSetup(ghost, { driver: () => fake.driver, fake: () => fake })],
103
+ /* ... */
104
+ });
105
+
106
+ // ...run a workflow, then:
107
+ Object.values(fake.state.posts).map((p) => p.status); // ["published"]
108
+ fake.calls.map((c) => c.op); // ["ghost.post.create", "ghost.post.publish"]
109
+ ```
110
+
111
+ A repeated idempotency key gets the first reply and changes nothing, as a real vendor that dedupes would. To test what a workflow does when Ghost misbehaves, set up the next call to an operation before the run makes it:
112
+
113
+ - `fake.failNext("ghost.post.publish", err?)` throws `err` (default: a retryable `DriverError`).
114
+ - `fake.loseReply("ghost.post.publish")` publishes, then throws, as if the reply was lost.
115
+ - `fake.rateLimit("ghost.post.publish")` throws a `DriverError` with status 429.
116
+ - `const release = fake.hold("ghost.post.publish")` makes the call wait until `release()`.
117
+
118
+ `fake.reset()` empties it between tests. Pass `{ calls }` with one array to several fakes to see their calls in one order.
119
+
120
+ [`@sanoma-ai/testing`](https://github.com/Sanoma-AI/sanoma/blob/main/packages/testing/README.md) (in the sanoma repository, not published) has `startTestWorker` and `testDatabaseUrl` to run a test's worker with the fake on a database of its own.
121
+
122
+ License: FSL-1.1-ALv2.
@@ -0,0 +1,67 @@
1
+ import { z } from "zod";
2
+ /** A post, as the operations return it. */
3
+ export declare const Post: z.ZodObject<{
4
+ id: z.ZodString;
5
+ url: z.ZodString;
6
+ slug: z.ZodString;
7
+ status: z.ZodEnum<{
8
+ draft: "draft";
9
+ published: "published";
10
+ scheduled: "scheduled";
11
+ sent: "sent";
12
+ }>;
13
+ publishedAt: z.ZodNullable<z.ZodString>;
14
+ }, z.core.$strip>;
15
+ /** Ghost Admin API: https://ghost.org/docs/admin-api/ */
16
+ export declare const ghostOps: import("@sanoma-ai/workflows").Connector<"ghost", {
17
+ readonly post: {
18
+ readonly create: {
19
+ readonly effect: "write";
20
+ readonly description: "Create a post. Drafts are not visible on the site.";
21
+ readonly phrases: {
22
+ readonly given: "a post titled {title} exists";
23
+ readonly expect: "a post titled {title} is created";
24
+ };
25
+ readonly input: z.ZodObject<{
26
+ title: z.ZodString;
27
+ html: z.ZodString;
28
+ status: z.ZodDefault<z.ZodLiteral<"draft">>;
29
+ }, z.core.$strip>;
30
+ readonly output: z.ZodObject<{
31
+ id: z.ZodString;
32
+ url: z.ZodString;
33
+ slug: z.ZodString;
34
+ status: z.ZodEnum<{
35
+ draft: "draft";
36
+ published: "published";
37
+ scheduled: "scheduled";
38
+ sent: "sent";
39
+ }>;
40
+ publishedAt: z.ZodNullable<z.ZodString>;
41
+ }, z.core.$strip>;
42
+ };
43
+ readonly publish: {
44
+ readonly effect: "publish";
45
+ readonly description: "Publish a draft post on the site. Visible to everyone.";
46
+ readonly phrases: {
47
+ readonly expect: "post {id} is published";
48
+ };
49
+ readonly idempotent: true;
50
+ readonly input: z.ZodObject<{
51
+ id: z.ZodString;
52
+ }, z.core.$strip>;
53
+ readonly output: z.ZodObject<{
54
+ id: z.ZodString;
55
+ url: z.ZodString;
56
+ slug: z.ZodString;
57
+ status: z.ZodEnum<{
58
+ draft: "draft";
59
+ published: "published";
60
+ scheduled: "scheduled";
61
+ sent: "sent";
62
+ }>;
63
+ publishedAt: z.ZodNullable<z.ZodString>;
64
+ }, z.core.$strip>;
65
+ };
66
+ };
67
+ }>;
@@ -0,0 +1,43 @@
1
+ import { defineConnector } from "@sanoma-ai/workflows";
2
+ import { z } from "zod";
3
+ /** A post, as the operations return it. */
4
+ export const Post = z.object({
5
+ id: z.string(),
6
+ url: z.string(),
7
+ slug: z.string(),
8
+ /** `sent`: emailed to a newsletter only, never on the site. */
9
+ status: z.enum(["draft", "scheduled", "published", "sent"]),
10
+ publishedAt: z.string().nullable(),
11
+ });
12
+ /**
13
+ * Ghost's glyph, from simple-icons (https://simpleicons.org/?q=ghost, CC0; drawn from Ghost's
14
+ * own admin icon), in one colour: Ghost's near-black, white on dark backgrounds. Ghost publishes
15
+ * no SVG of its own.
16
+ */
17
+ const glyph = (fill) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="${fill}" d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm.256 2.313c2.47.005 5.116 2.008 5.898 2.962l.244.3c1.64 1.994 3.569 4.34 3.569 6.966 0 3.719-2.98 5.808-6.158 7.508-1.433.766-2.98 1.508-4.748 1.508-4.543 0-8.366-3.569-8.366-8.112 0-.706.17-1.425.342-2.15.122-.515.244-1.033.307-1.549.548-4.539 2.967-6.795 8.422-7.408a4.29 4.29 0 01.49-.026Z"/></svg>`;
18
+ /** Ghost Admin API: https://ghost.org/docs/admin-api/ */
19
+ export const ghostOps = defineConnector("ghost", {
20
+ post: {
21
+ create: {
22
+ effect: "write",
23
+ description: "Create a post. Drafts are not visible on the site.",
24
+ phrases: { given: "a post titled {title} exists", expect: "a post titled {title} is created" },
25
+ input: z.object({ title: z.string().min(1), html: z.string(), status: z.literal("draft").default("draft") }),
26
+ output: Post,
27
+ },
28
+ publish: {
29
+ effect: "publish",
30
+ description: "Publish a draft post on the site. Visible to everyone.",
31
+ phrases: { expect: "post {id} is published" },
32
+ idempotent: true,
33
+ input: z.object({ id: z.string() }),
34
+ output: Post,
35
+ },
36
+ },
37
+ }, {
38
+ title: "Ghost",
39
+ logo: { svg: glyph("#15171A"), dark: glyph("white") },
40
+ package: "@sanoma-ai/connector-ghost",
41
+ homepage: "https://github.com/Sanoma-AI/sanoma/tree/main/connectors/ghost#readme",
42
+ });
43
+ //# sourceMappingURL=connector.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"connector.js","sourceRoot":"","sources":["../src/connector.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AACvD,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,2CAA2C;AAC3C,MAAM,CAAC,MAAM,IAAI,GAAG,CAAC,CAAC,MAAM,CAAC;IAC3B,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE;IACd,GAAG,EAAE,CAAC,CAAC,MAAM,EAAE;IACf,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE;IAChB,+DAA+D;IAC/D,MAAM,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,CAAC,CAAC;IAC3D,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;CACnC,CAAC,CAAC;AAEH;;;;GAIG;AACH,MAAM,KAAK,GAAG,CAAC,IAAY,EAAE,EAAE,CAC7B,2EAA2E,IAAI,4XAA4X,CAAC;AAE9c,yDAAyD;AACzD,MAAM,CAAC,MAAM,QAAQ,GAAG,eAAe,CACrC,OAAO,EACP;IACE,IAAI,EAAE;QACJ,MAAM,EAAE;YACN,MAAM,EAAE,OAAO;YACf,WAAW,EAAE,oDAAoD;YACjE,OAAO,EAAE,EAAE,KAAK,EAAE,8BAA8B,EAAE,MAAM,EAAE,kCAAkC,EAAE;YAC9F,KAAK,EAAE,CAAC,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,EAAE,MAAM,EAAE,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;YAC5G,MAAM,EAAE,IAAI;SACb;QACD,OAAO,EAAE;YACP,MAAM,EAAE,SAAS;YACjB,WAAW,EAAE,wDAAwD;YACrE,OAAO,EAAE,EAAE,MAAM,EAAE,wBAAwB,EAAE;YAC7C,UAAU,EAAE,IAAI;YAChB,KAAK,EAAE,CAAC,CAAC,MAAM,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC;YACnC,MAAM,EAAE,IAAI;SACb;KACF;CACF,EACD;IACE,KAAK,EAAE,OAAO;IACd,IAAI,EAAE,EAAE,GAAG,EAAE,KAAK,CAAC,SAAS,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,OAAO,CAAC,EAAE;IACrD,OAAO,EAAE,4BAA4B;IACrC,QAAQ,EAAE,uEAAuE;CAClF,CACF,CAAC"}
@@ -0,0 +1,11 @@
1
+ export interface GhostDriverOptions {
2
+ /** How long one request to Ghost may take, in milliseconds. Default 10 000. */
3
+ timeoutMs?: number;
4
+ }
5
+ /**
6
+ * A token for the Admin API, signed with an Admin API key (`<id>:<secret>`), as Ghost asks:
7
+ * HS256, for `/admin/`, valid five minutes, the longest Ghost accepts.
8
+ */
9
+ export declare function adminToken(key: string): Promise<string>;
10
+ /** The real Ghost, through the Admin API, with `GHOST_ADMIN_URL` and `GHOST_ADMIN_API_KEY`. See the README. */
11
+ export declare function ghostDriver(options?: GhostDriverOptions): import("@sanoma-ai/workflows").Driver;
package/dist/driver.js ADDED
@@ -0,0 +1,151 @@
1
+ import { DriverError, defineDriver, retryableStatus } from "@sanoma-ai/workflows";
2
+ import { SignJWT } from "jose";
3
+ import { z } from "zod";
4
+ import { ghostOps, Post } from "./connector.js";
5
+ /** The minimum API version the driver needs: Ghost 5 and later answer it. */
6
+ const ACCEPT_VERSION = "v5.0";
7
+ /** Ghost's post object, as far as the driver reads it: the connector's `Post`, in Ghost's names. */
8
+ const GhostPost = Post.omit({ publishedAt: true }).extend({
9
+ published_at: z.string().nullable(),
10
+ updated_at: z.string(),
11
+ });
12
+ /** Ghost wraps the post it creates, reads or saves in a list of one. */
13
+ const GhostPosts = z.object({ posts: z.tuple([GhostPost]) });
14
+ /** Ghost's error body, `{ errors: [{ message, type, code, context, ... }] }`: the driver reads the first. */
15
+ const GhostErrors = z.object({
16
+ errors: z.array(z.object({
17
+ message: z.string(),
18
+ type: z.string().optional(),
19
+ code: z.string().nullish(),
20
+ // Which field failed, and sometimes the message again.
21
+ context: z.string().nullish(),
22
+ })),
23
+ });
24
+ const out = (post) => ({
25
+ id: post.id,
26
+ url: post.url,
27
+ slug: post.slug,
28
+ status: post.status,
29
+ publishedAt: post.published_at,
30
+ });
31
+ const misconfigured = (message) => new DriverError(`ghost: ${message}`, { retryable: false });
32
+ const ADMIN_API_KEY = /^([0-9a-f]{24}):([0-9a-f]{64})$/i;
33
+ const ADMIN_API_KEY_FORMAT = "<24 hex digits>:<64 hex digits>";
34
+ /** The environment variables the driver reads, on every call. */
35
+ const env = z.object({
36
+ GHOST_ADMIN_URL: z.url().describe("the site's admin URL, such as https://example.ghost.io"),
37
+ GHOST_ADMIN_API_KEY: z
38
+ .string()
39
+ .regex(ADMIN_API_KEY, `an Admin API key, ${ADMIN_API_KEY_FORMAT}`)
40
+ .describe("a custom integration's Admin API key, <id>:<secret>"),
41
+ });
42
+ /**
43
+ * A token for the Admin API, signed with an Admin API key (`<id>:<secret>`), as Ghost asks:
44
+ * HS256, for `/admin/`, valid five minutes, the longest Ghost accepts.
45
+ */
46
+ export async function adminToken(key) {
47
+ const [, id, secret] = ADMIN_API_KEY.exec(key) ?? [];
48
+ if (!id || !secret) {
49
+ throw misconfigured(`GHOST_ADMIN_API_KEY is not an Admin API key (${ADMIN_API_KEY_FORMAT})`);
50
+ }
51
+ return new SignJWT({})
52
+ .setProtectedHeader({ alg: "HS256", kid: id, typ: "JWT" })
53
+ .setIssuedAt()
54
+ .setExpirationTime("5m")
55
+ .setAudience("/admin/")
56
+ .sign(Buffer.from(secret, "hex"));
57
+ }
58
+ /** Reads the credentials, when an operation is called, and returns a function that sends one request. */
59
+ function connect(timeoutMs) {
60
+ const url = process.env.GHOST_ADMIN_URL;
61
+ const key = process.env.GHOST_ADMIN_API_KEY;
62
+ if (!url)
63
+ throw misconfigured(`GHOST_ADMIN_URL is not set (${env.shape.GHOST_ADMIN_URL.description})`);
64
+ if (!URL.canParse(url))
65
+ throw misconfigured("GHOST_ADMIN_URL is not a URL");
66
+ if (!key)
67
+ throw misconfigured(`GHOST_ADMIN_API_KEY is not set (${env.shape.GHOST_ADMIN_API_KEY.description})`);
68
+ const base = `${url.replace(/\/+$/, "")}/ghost/api/admin`;
69
+ return async (method, path, body) => {
70
+ // Each request gets its own token: one lasts at most five minutes.
71
+ const token = await adminToken(key);
72
+ const where = `${method} ${path}`;
73
+ let status;
74
+ let json;
75
+ try {
76
+ const res = await fetch(`${base}${path}`, {
77
+ method,
78
+ signal: AbortSignal.timeout(timeoutMs),
79
+ headers: {
80
+ Authorization: `Ghost ${token}`,
81
+ "Accept-Version": ACCEPT_VERSION,
82
+ "Content-Type": "application/json",
83
+ },
84
+ ...(body !== undefined && { body: JSON.stringify(body) }),
85
+ });
86
+ status = res.status;
87
+ // A body that is not JSON (a proxy's HTML error page) is no answer to read; a body cut off is.
88
+ json = await res.json().catch((err) => {
89
+ if (err instanceof SyntaxError)
90
+ return undefined;
91
+ throw err;
92
+ });
93
+ }
94
+ catch (cause) {
95
+ // No answer: the request may or may not have reached Ghost.
96
+ const why = cause.name === "TimeoutError" ? `no reply in ${timeoutMs} ms` : "the request failed";
97
+ throw new DriverError(`ghost: ${where}: ${why}`, { retryable: true, cause });
98
+ }
99
+ if (status < 200 || status >= 300) {
100
+ const err = GhostErrors.safeParse(json).data?.errors[0];
101
+ const detail = err?.context && err.context !== err.message ? ` ${err.context}` : "";
102
+ const message = err ? `${err.message}${detail}` : `HTTP ${status}`;
103
+ throw new DriverError(`ghost: ${where}: ${message}`, {
104
+ retryable: retryableStatus(status),
105
+ status,
106
+ vendorCode: err?.code ?? err?.type,
107
+ });
108
+ }
109
+ const parsed = GhostPosts.safeParse(json);
110
+ if (!parsed.success) {
111
+ throw new DriverError(`ghost: ${where}: the reply is not a post; is GHOST_ADMIN_URL the site's admin URL?`, {
112
+ retryable: false,
113
+ status,
114
+ cause: parsed.error,
115
+ });
116
+ }
117
+ return parsed.data.posts[0];
118
+ };
119
+ }
120
+ /** The real Ghost, through the Admin API, with `GHOST_ADMIN_URL` and `GHOST_ADMIN_API_KEY`. See the README. */
121
+ export function ghostDriver(options = {}) {
122
+ const timeoutMs = options.timeoutMs ?? 10_000;
123
+ return defineDriver(ghostOps, {
124
+ post: {
125
+ create: async ({ title, html, status }) => {
126
+ const send = connect(timeoutMs);
127
+ return out(await send("POST", "/posts/?source=html", { posts: [{ title, html, status }] }));
128
+ },
129
+ publish: async ({ id }) => {
130
+ const send = connect(timeoutMs);
131
+ const path = `/posts/${encodeURIComponent(id)}/`;
132
+ const save = async () => {
133
+ const post = await send("GET", path);
134
+ // Published already, or sent as an email only: it is no draft to publish.
135
+ if (post.status === "published" || post.status === "sent")
136
+ return post;
137
+ return send("PUT", path, { posts: [{ status: "published", updated_at: post.updated_at }] });
138
+ };
139
+ // Ghost refuses a save whose `updated_at` is not the post's latest (UPDATE_COLLISION):
140
+ // someone saved it in between. Read it again and save once more.
141
+ const post = await save().catch((err) => {
142
+ if (err instanceof DriverError && err.vendorCode === "UPDATE_COLLISION")
143
+ return save();
144
+ throw err;
145
+ });
146
+ return out(post);
147
+ },
148
+ },
149
+ }, { env });
150
+ }
151
+ //# sourceMappingURL=driver.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"driver.js","sourceRoot":"","sources":["../src/driver.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAClF,OAAO,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAC/B,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,gBAAgB,CAAC;AAehD,6EAA6E;AAC7E,MAAM,cAAc,GAAG,MAAM,CAAC;AAE9B,oGAAoG;AACpG,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC;IACxD,YAAY,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IACnC,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE;CACvB,CAAC,CAAC;AAGH,wEAAwE;AACxE,MAAM,UAAU,GAAG,CAAC,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC;AAE7D,6GAA6G;AAC7G,MAAM,WAAW,GAAG,CAAC,CAAC,MAAM,CAAC;IAC3B,MAAM,EAAE,CAAC,CAAC,KAAK,CACb,CAAC,CAAC,MAAM,CAAC;QACP,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE;QACnB,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;QAC3B,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,OAAO,EAAE;QAC1B,uDAAuD;QACvD,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,OAAO,EAAE;KAC9B,CAAC,CACH;CACF,CAAC,CAAC;AAEH,MAAM,GAAG,GAAG,CAAC,IAAe,EAAE,EAAE,CAAC,CAAC;IAChC,EAAE,EAAE,IAAI,CAAC,EAAE;IACX,GAAG,EAAE,IAAI,CAAC,GAAG;IACb,IAAI,EAAE,IAAI,CAAC,IAAI;IACf,MAAM,EAAE,IAAI,CAAC,MAAM;IACnB,WAAW,EAAE,IAAI,CAAC,YAAY;CAC/B,CAAC,CAAC;AAEH,MAAM,aAAa,GAAG,CAAC,OAAe,EAAE,EAAE,CAAC,IAAI,WAAW,CAAC,UAAU,OAAO,EAAE,EAAE,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC,CAAC;AAEtG,MAAM,aAAa,GAAG,kCAAkC,CAAC;AACzD,MAAM,oBAAoB,GAAG,iCAAiC,CAAC;AAE/D,iEAAiE;AACjE,MAAM,GAAG,GAAG,CAAC,CAAC,MAAM,CAAC;IACnB,eAAe,EAAE,CAAC,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,wDAAwD,CAAC;IAC3F,mBAAmB,EAAE,CAAC;SACnB,MAAM,EAAE;SACR,KAAK,CAAC,aAAa,EAAE,qBAAqB,oBAAoB,EAAE,CAAC;SACjE,QAAQ,CAAC,qDAAqD,CAAC;CACnE,CAAC,CAAC;AAEH;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,GAAW;IAC1C,MAAM,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,aAAa,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC;IACrD,IAAI,CAAC,EAAE,IAAI,CAAC,MAAM,EAAE,CAAC;QACnB,MAAM,aAAa,CAAC,gDAAgD,oBAAoB,GAAG,CAAC,CAAC;IAC/F,CAAC;IACD,OAAO,IAAI,OAAO,CAAC,EAAE,CAAC;SACnB,kBAAkB,CAAC,EAAE,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,EAAE,EAAE,GAAG,EAAE,KAAK,EAAE,CAAC;SACzD,WAAW,EAAE;SACb,iBAAiB,CAAC,IAAI,CAAC;SACvB,WAAW,CAAC,SAAS,CAAC;SACtB,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC,CAAC;AACtC,CAAC;AAED,yGAAyG;AACzG,SAAS,OAAO,CAAC,SAAiB;IAChC,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,eAAe,CAAC;IACxC,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,mBAAmB,CAAC;IAC5C,IAAI,CAAC,GAAG;QAAE,MAAM,aAAa,CAAC,+BAA+B,GAAG,CAAC,KAAK,CAAC,eAAe,CAAC,WAAW,GAAG,CAAC,CAAC;IACvG,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC;QAAE,MAAM,aAAa,CAAC,8BAA8B,CAAC,CAAC;IAC5E,IAAI,CAAC,GAAG;QAAE,MAAM,aAAa,CAAC,mCAAmC,GAAG,CAAC,KAAK,CAAC,mBAAmB,CAAC,WAAW,GAAG,CAAC,CAAC;IAC/G,MAAM,IAAI,GAAG,GAAG,GAAG,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,kBAAkB,CAAC;IAE1D,OAAO,KAAK,EAAE,MAA8B,EAAE,IAAY,EAAE,IAAc,EAAsB,EAAE;QAChG,mEAAmE;QACnE,MAAM,KAAK,GAAG,MAAM,UAAU,CAAC,GAAG,CAAC,CAAC;QACpC,MAAM,KAAK,GAAG,GAAG,MAAM,IAAI,IAAI,EAAE,CAAC;QAClC,IAAI,MAAc,CAAC;QACnB,IAAI,IAAa,CAAC;QAClB,IAAI,CAAC;YACH,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,GAAG,IAAI,GAAG,IAAI,EAAE,EAAE;gBACxC,MAAM;gBACN,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,SAAS,CAAC;gBACtC,OAAO,EAAE;oBACP,aAAa,EAAE,SAAS,KAAK,EAAE;oBAC/B,gBAAgB,EAAE,cAAc;oBAChC,cAAc,EAAE,kBAAkB;iBACnC;gBACD,GAAG,CAAC,IAAI,KAAK,SAAS,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,EAAE,CAAC;aAC1D,CAAC,CAAC;YACH,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC;YACpB,+FAA+F;YAC/F,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,GAAY,EAAE,EAAE;gBAC7C,IAAI,GAAG,YAAY,WAAW;oBAAE,OAAO,SAAS,CAAC;gBACjD,MAAM,GAAG,CAAC;YACZ,CAAC,CAAC,CAAC;QACL,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,4DAA4D;YAC5D,MAAM,GAAG,GAAI,KAAe,CAAC,IAAI,KAAK,cAAc,CAAC,CAAC,CAAC,eAAe,SAAS,KAAK,CAAC,CAAC,CAAC,oBAAoB,CAAC;YAC5G,MAAM,IAAI,WAAW,CAAC,UAAU,KAAK,KAAK,GAAG,EAAE,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;QAC/E,CAAC;QACD,IAAI,MAAM,GAAG,GAAG,IAAI,MAAM,IAAI,GAAG,EAAE,CAAC;YAClC,MAAM,GAAG,GAAG,WAAW,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC;YACxD,MAAM,MAAM,GAAG,GAAG,EAAE,OAAO,IAAI,GAAG,CAAC,OAAO,KAAK,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACpF,MAAM,OAAO,GAAG,GAAG,CAAC,CAAC,CAAC,GAAG,GAAG,CAAC,OAAO,GAAG,MAAM,EAAE,CAAC,CAAC,CAAC,QAAQ,MAAM,EAAE,CAAC;YACnE,MAAM,IAAI,WAAW,CAAC,UAAU,KAAK,KAAK,OAAO,EAAE,EAAE;gBACnD,SAAS,EAAE,eAAe,CAAC,MAAM,CAAC;gBAClC,MAAM;gBACN,UAAU,EAAE,GAAG,EAAE,IAAI,IAAI,GAAG,EAAE,IAAI;aACnC,CAAC,CAAC;QACL,CAAC;QACD,MAAM,MAAM,GAAG,UAAU,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;QAC1C,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;YACpB,MAAM,IAAI,WAAW,CAAC,UAAU,KAAK,qEAAqE,EAAE;gBAC1G,SAAS,EAAE,KAAK;gBAChB,MAAM;gBACN,KAAK,EAAE,MAAM,CAAC,KAAK;aACpB,CAAC,CAAC;QACL,CAAC;QACD,OAAO,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAC9B,CAAC,CAAC;AACJ,CAAC;AAED,+GAA+G;AAC/G,MAAM,UAAU,WAAW,CAAC,OAAO,GAAuB,EAAE;IAC1D,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,IAAI,MAAM,CAAC;IAC9C,OAAO,YAAY,CACjB,QAAQ,EACR;QACE,IAAI,EAAE;YACJ,MAAM,EAAE,KAAK,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE;gBACxC,MAAM,IAAI,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC;gBAChC,OAAO,GAAG,CAAC,MAAM,IAAI,CAAC,MAAM,EAAE,qBAAqB,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;YAC9F,CAAC;YACD,OAAO,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE;gBACxB,MAAM,IAAI,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC;gBAChC,MAAM,IAAI,GAAG,UAAU,kBAAkB,CAAC,EAAE,CAAC,GAAG,CAAC;gBACjD,MAAM,IAAI,GAAG,KAAK,IAAI,EAAE;oBACtB,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC;oBACrC,0EAA0E;oBAC1E,IAAI,IAAI,CAAC,MAAM,KAAK,WAAW,IAAI,IAAI,CAAC,MAAM,KAAK,MAAM;wBAAE,OAAO,IAAI,CAAC;oBACvE,OAAO,IAAI,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,WAAW,EAAE,UAAU,EAAE,IAAI,CAAC,UAAU,EAAE,CAAC,EAAE,CAAC,CAAC;gBAC9F,CAAC,CAAC;gBACF,uFAAuF;gBACvF,iEAAiE;gBACjE,MAAM,IAAI,GAAG,MAAM,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,GAAY,EAAE,EAAE;oBAC/C,IAAI,GAAG,YAAY,WAAW,IAAI,GAAG,CAAC,UAAU,KAAK,kBAAkB;wBAAE,OAAO,IAAI,EAAE,CAAC;oBACvF,MAAM,GAAG,CAAC;gBACZ,CAAC,CAAC,CAAC;gBACH,OAAO,GAAG,CAAC,IAAI,CAAC,CAAC;YACnB,CAAC;SACF;KACF,EACD,EAAE,GAAG,EAAE,CACR,CAAC;AACJ,CAAC"}
package/dist/fake.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { type FakeOptions } from "@sanoma-ai/workflows/fake";
2
+ export interface FakeGhostPost {
3
+ id: string;
4
+ title: string;
5
+ html: string;
6
+ slug: string;
7
+ status: "draft" | "scheduled" | "published";
8
+ publishedAt: string | null;
9
+ }
10
+ export interface FakeGhostState extends Record<string, unknown> {
11
+ seq: number;
12
+ posts: Record<string, FakeGhostPost>;
13
+ }
14
+ /**
15
+ * An in-memory Ghost for tests: posts live in `state.posts`, and their URLs are on
16
+ * `blog.example.test`.
17
+ */
18
+ export declare function fakeGhost(options?: FakeOptions): import("@sanoma-ai/workflows/fake").Fake<FakeGhostState, "ghost.post.create" | "ghost.post.publish">;
package/dist/fake.js ADDED
@@ -0,0 +1,43 @@
1
+ import { DriverError } from "@sanoma-ai/workflows";
2
+ import { defineFake } from "@sanoma-ai/workflows/fake";
3
+ import { ghostOps } from "./connector.js";
4
+ const out = (p) => ({
5
+ id: p.id,
6
+ url: `https://blog.example.test/${p.slug}/`,
7
+ slug: p.slug,
8
+ status: p.status,
9
+ publishedAt: p.publishedAt,
10
+ });
11
+ /**
12
+ * An in-memory Ghost for tests: posts live in `state.posts`, and their URLs are on
13
+ * `blog.example.test`.
14
+ */
15
+ export function fakeGhost(options = {}) {
16
+ return defineFake(ghostOps, {
17
+ initial: () => ({ seq: 0, posts: {} }),
18
+ ops: (state) => ({
19
+ post: {
20
+ create: async ({ title, html, status }) => {
21
+ const id = `post_${String(++state.seq).padStart(4, "0")}`;
22
+ const slug = title
23
+ .toLowerCase()
24
+ .replace(/[^a-z0-9]+/g, "-")
25
+ .replace(/^-|-$/g, "");
26
+ state.posts[id] = { id, title, html, slug, status, publishedAt: null };
27
+ return out(state.posts[id]);
28
+ },
29
+ publish: async ({ id }) => {
30
+ const post = state.posts[id];
31
+ if (!post)
32
+ throw new DriverError(`ghost: no post ${id}`, { retryable: false, status: 404 });
33
+ if (post.status !== "published") {
34
+ post.status = "published";
35
+ post.publishedAt = new Date().toISOString();
36
+ }
37
+ return out(post);
38
+ },
39
+ },
40
+ }),
41
+ }, options);
42
+ }
43
+ //# sourceMappingURL=fake.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fake.js","sourceRoot":"","sources":["../src/fake.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACnD,OAAO,EAAE,UAAU,EAAoB,MAAM,2BAA2B,CAAC;AACzE,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAgB1C,MAAM,GAAG,GAAG,CAAC,CAAgB,EAAE,EAAE,CAAC,CAAC;IACjC,EAAE,EAAE,CAAC,CAAC,EAAE;IACR,GAAG,EAAE,6BAA6B,CAAC,CAAC,IAAI,GAAG;IAC3C,IAAI,EAAE,CAAC,CAAC,IAAI;IACZ,MAAM,EAAE,CAAC,CAAC,MAAM;IAChB,WAAW,EAAE,CAAC,CAAC,WAAW;CAC3B,CAAC,CAAC;AAEH;;;GAGG;AACH,MAAM,UAAU,SAAS,CAAC,OAAO,GAAgB,EAAE;IACjD,OAAO,UAAU,CACf,QAAQ,EACR;QACE,OAAO,EAAE,GAAmB,EAAE,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC;QACtD,GAAG,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;YACf,IAAI,EAAE;gBACJ,MAAM,EAAE,KAAK,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE;oBACxC,MAAM,EAAE,GAAG,QAAQ,MAAM,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC;oBAC1D,MAAM,IAAI,GAAG,KAAK;yBACf,WAAW,EAAE;yBACb,OAAO,CAAC,aAAa,EAAE,GAAG,CAAC;yBAC3B,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;oBACzB,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC,GAAG,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC;oBACvE,OAAO,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC;gBAC9B,CAAC;gBACD,OAAO,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE;oBACxB,MAAM,IAAI,GAAG,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;oBAC7B,IAAI,CAAC,IAAI;wBAAE,MAAM,IAAI,WAAW,CAAC,kBAAkB,EAAE,EAAE,EAAE,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC;oBAC5F,IAAI,IAAI,CAAC,MAAM,KAAK,WAAW,EAAE,CAAC;wBAChC,IAAI,CAAC,MAAM,GAAG,WAAW,CAAC;wBAC1B,IAAI,CAAC,WAAW,GAAG,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;oBAC9C,CAAC;oBACD,OAAO,GAAG,CAAC,IAAI,CAAC,CAAC;gBACnB,CAAC;aACF;SACF,CAAC;KACH,EACD,OAAO,CACR,CAAC;AACJ,CAAC"}
@@ -0,0 +1,58 @@
1
+ export { Post } from "./connector.ts";
2
+ /**
3
+ * Ghost Admin API posts: `post.create` and `post.publish`. It carries its live driver
4
+ * (`ghostDriver`), which reads its credentials when called, its fake (`fakeGhost`), of which
5
+ * each sandbox run makes its own, and its steps for Gherkin tests (`ghostSteps`).
6
+ */
7
+ export declare const ghost: import("@sanoma-ai/workflows").Connector<"ghost", {
8
+ readonly post: {
9
+ readonly create: {
10
+ readonly effect: "write";
11
+ readonly description: "Create a post. Drafts are not visible on the site.";
12
+ readonly phrases: {
13
+ readonly given: "a post titled {title} exists";
14
+ readonly expect: "a post titled {title} is created";
15
+ };
16
+ readonly input: import("zod").ZodObject<{
17
+ title: import("zod").ZodString;
18
+ html: import("zod").ZodString;
19
+ status: import("zod").ZodDefault<import("zod").ZodLiteral<"draft">>;
20
+ }, import("zod/v4/core").$strip>;
21
+ readonly output: import("zod").ZodObject<{
22
+ id: import("zod").ZodString;
23
+ url: import("zod").ZodString;
24
+ slug: import("zod").ZodString;
25
+ status: import("zod").ZodEnum<{
26
+ draft: "draft";
27
+ published: "published";
28
+ scheduled: "scheduled";
29
+ sent: "sent";
30
+ }>;
31
+ publishedAt: import("zod").ZodNullable<import("zod").ZodString>;
32
+ }, import("zod/v4/core").$strip>;
33
+ };
34
+ readonly publish: {
35
+ readonly effect: "publish";
36
+ readonly description: "Publish a draft post on the site. Visible to everyone.";
37
+ readonly phrases: {
38
+ readonly expect: "post {id} is published";
39
+ };
40
+ readonly idempotent: true;
41
+ readonly input: import("zod").ZodObject<{
42
+ id: import("zod").ZodString;
43
+ }, import("zod/v4/core").$strip>;
44
+ readonly output: import("zod").ZodObject<{
45
+ id: import("zod").ZodString;
46
+ url: import("zod").ZodString;
47
+ slug: import("zod").ZodString;
48
+ status: import("zod").ZodEnum<{
49
+ draft: "draft";
50
+ published: "published";
51
+ scheduled: "scheduled";
52
+ sent: "sent";
53
+ }>;
54
+ publishedAt: import("zod").ZodNullable<import("zod").ZodString>;
55
+ }, import("zod/v4/core").$strip>;
56
+ };
57
+ };
58
+ }>;
package/dist/index.js ADDED
@@ -0,0 +1,13 @@
1
+ import { withSetup } from "@sanoma-ai/workflows";
2
+ import { ghostOps } from "./connector.js";
3
+ import { ghostDriver } from "./driver.js";
4
+ import { fakeGhost } from "./fake.js";
5
+ import { ghostSteps } from "./steps.js";
6
+ export { Post } from "./connector.js";
7
+ /**
8
+ * Ghost Admin API posts: `post.create` and `post.publish`. It carries its live driver
9
+ * (`ghostDriver`), which reads its credentials when called, its fake (`fakeGhost`), of which
10
+ * each sandbox run makes its own, and its steps for Gherkin tests (`ghostSteps`).
11
+ */
12
+ export const ghost = withSetup(ghostOps, { driver: ghostDriver, fake: fakeGhost, steps: ghostSteps });
13
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AACjD,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC1C,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AACtC,OAAO,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAExC,OAAO,EAAE,IAAI,EAAE,MAAM,gBAAgB,CAAC;AAEtC;;;;GAIG;AACH,MAAM,CAAC,MAAM,KAAK,GAAG,SAAS,CAAC,QAAQ,EAAE,EAAE,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,UAAU,EAAE,CAAC,CAAC"}
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Ghost's steps for Gherkin tests: a draft on the blog before the run (named `draft`, so an
3
+ * operation's own test can use `$draft.id`), and a post the run published, found by its title
4
+ * among the drafts seeded and created.
5
+ */
6
+ export declare const ghostSteps: import("@sanoma-ai/workflows").Steps;
package/dist/steps.js ADDED
@@ -0,0 +1,27 @@
1
+ import { defineSteps } from "@sanoma-ai/workflows";
2
+ import { ghostOps } from "./connector.js";
3
+ const { create, publish } = ghostOps.post;
4
+ /**
5
+ * Ghost's steps for Gherkin tests: a draft on the blog before the run (named `draft`, so an
6
+ * operation's own test can use `$draft.id`), and a post the run published, found by its title
7
+ * among the drafts seeded and created.
8
+ */
9
+ export const ghostSteps = defineSteps("ghost", ({ given, then }) => {
10
+ given("a Ghost draft titled {string}", (title) => ({ op: create, input: { title }, as: "draft" }));
11
+ then("the Ghost post {string} is published", (title) => ({
12
+ op: publish,
13
+ called: true,
14
+ check: (run) => {
15
+ const ids = run
16
+ .made(create)
17
+ .filter((c) => c.input.title === title)
18
+ .map((c) => c.output.id);
19
+ if (ids.length === 0)
20
+ return `no Ghost post titled "${title}" was drafted`;
21
+ if (run.calls(publish).some((c) => ids.includes(c.input.id)))
22
+ return undefined;
23
+ return `the Ghost post "${title}" (${ids.join(", ")}) was not published`;
24
+ },
25
+ }));
26
+ });
27
+ //# sourceMappingURL=steps.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"steps.js","sourceRoot":"","sources":["../src/steps.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACnD,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE1C,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,QAAQ,CAAC,IAAI,CAAC;AAE1C;;;;GAIG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,WAAW,CAAC,OAAO,EAAE,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE,EAAE;IACjE,KAAK,CAAC,+BAA+B,EAAE,CAAC,KAAa,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC;IAC3G,IAAI,CAAC,sCAAsC,EAAE,CAAC,KAAa,EAAE,EAAE,CAAC,CAAC;QAC/D,EAAE,EAAE,OAAO;QACX,MAAM,EAAE,IAAI;QACZ,KAAK,EAAE,CAAC,GAAG,EAAE,EAAE;YACb,MAAM,GAAG,GAAG,GAAG;iBACZ,IAAI,CAAC,MAAM,CAAC;iBACZ,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,KAAK,KAAK,CAAC;iBACtC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;YAC3B,IAAI,GAAG,CAAC,MAAM,KAAK,CAAC;gBAAE,OAAO,yBAAyB,KAAK,eAAe,CAAC;YAC3E,IAAI,GAAG,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;gBAAE,OAAO,SAAS,CAAC;YAC/E,OAAO,mBAAmB,KAAK,MAAM,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,qBAAqB,CAAC;QAC3E,CAAC;KACF,CAAC,CAAC,CAAC;AACN,CAAC,CAAC,CAAC"}
@@ -0,0 +1,33 @@
1
+ Feature: Ghost posts
2
+ Drafts are created, then published. Each scenario runs on the fake and on Ghost's recorded
3
+ replies (test/fixtures/, named after the scenario), unless tagged @fake-only.
4
+
5
+ Scenario: Create
6
+ When ghost.post.create is called with
7
+ | title | Launch |
8
+ | html | <p>Hello</p> |
9
+ Then it returns
10
+ | status | draft |
11
+
12
+ Scenario: Publish draft
13
+ Given a Ghost draft titled "Launch"
14
+ When ghost.post.publish is called with
15
+ | id | $draft.id |
16
+ Then it returns
17
+ | status | published |
18
+ And the Ghost post "Launch" is published
19
+
20
+ Scenario: Publish again
21
+ Given a Ghost draft titled "Launch"
22
+ When ghost.post.publish is called with
23
+ | id | $draft.id |
24
+ And ghost.post.publish is called with
25
+ | id | $draft.id |
26
+ Then it returns
27
+ | status | published |
28
+
29
+ @fake-only
30
+ Scenario: Publish a post that is not there
31
+ When ghost.post.publish is called with
32
+ | id | missing |
33
+ Then it fails with status 404
package/package.json CHANGED
@@ -1,6 +1,66 @@
1
1
  {
2
2
  "name": "@sanoma-ai/connector-ghost",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "Ghost Admin API operations for Sanoma workflows",
5
+ "keywords": [
6
+ "blog",
7
+ "connector",
8
+ "ghost",
9
+ "sanoma"
10
+ ],
11
+ "homepage": "https://github.com/Sanoma-AI/sanoma/tree/main/connectors/ghost#readme",
12
+ "bugs": "https://github.com/Sanoma-AI/sanoma/issues",
13
+ "license": "FSL-1.1-ALv2",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/Sanoma-AI/sanoma.git",
17
+ "directory": "connectors/ghost"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "features",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "type": "module",
26
+ "sideEffects": false,
27
+ "main": "./dist/index.js",
28
+ "types": "./dist/index.d.ts",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "default": "./dist/index.js"
33
+ },
34
+ "./fake": {
35
+ "types": "./dist/fake.d.ts",
36
+ "default": "./dist/fake.js"
37
+ },
38
+ "./driver": {
39
+ "types": "./dist/driver.d.ts",
40
+ "default": "./dist/driver.js"
41
+ }
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "dependencies": {
47
+ "jose": "^6.2.12"
48
+ },
49
+ "devDependencies": {
50
+ "msw": "^3.0.2",
51
+ "zod": "^4.6.5",
52
+ "@sanoma-ai/workflows": "0.2.0"
53
+ },
54
+ "peerDependencies": {
55
+ "@sanoma-ai/workflows": "^0.2.0",
56
+ "zod": "^4"
57
+ },
58
+ "engines": {
59
+ "node": ">=24"
60
+ },
61
+ "scripts": {
62
+ "build": "tsc -p tsconfig.build.json",
63
+ "clean": "rm -rf dist",
64
+ "watch": "tsc -p tsconfig.build.json --watch --preserveWatchOutput"
65
+ }
6
66
  }