@monok/server 1.3.0 → 1.5.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/AGENTS.md +442 -0
- package/README.md +107 -16
- package/dist/affiliate.d.ts +15 -0
- package/dist/affiliate.d.ts.map +1 -0
- package/dist/affiliate.js +96 -0
- package/dist/affiliate.js.map +1 -0
- package/dist/client.d.ts +8 -3
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +38 -6
- package/dist/client.js.map +1 -1
- package/dist/express.d.ts.map +1 -1
- package/dist/express.js +115 -15
- package/dist/express.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +98 -15
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +40 -4
- package/dist/utils.js.map +1 -1
- package/dist-cjs/affiliate.js +96 -0
- package/dist-cjs/affiliate.js.map +1 -0
- package/dist-cjs/client.js +38 -6
- package/dist-cjs/client.js.map +1 -1
- package/dist-cjs/express.js +115 -15
- package/dist-cjs/express.js.map +1 -1
- package/dist-cjs/index.js +1 -0
- package/dist-cjs/index.js.map +1 -1
- package/dist-cjs/utils.js +40 -4
- package/dist-cjs/utils.js.map +1 -1
- package/package.json +5 -2
package/AGENTS.md
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
# Implementing `@monok/server` in a host application
|
|
2
|
+
|
|
3
|
+
This file is a self-contained implementation contract for AI coding assistants
|
|
4
|
+
and developers integrating Monok with an Express application or CMS. Follow the
|
|
5
|
+
requirements below rather than inventing alternate endpoint paths or response
|
|
6
|
+
shapes.
|
|
7
|
+
|
|
8
|
+
## Choose the integration mode
|
|
9
|
+
|
|
10
|
+
Before changing the host application, establish which mode the developer wants:
|
|
11
|
+
|
|
12
|
+
1. **Content only** — implement `createMonokRouter` and publication linkbacks.
|
|
13
|
+
2. **Affiliate only** — implement `monokAffiliateAttribution` and
|
|
14
|
+
`createMonokReferral`; do not expose content endpoints.
|
|
15
|
+
3. **Content and affiliate** — implement both independent contracts.
|
|
16
|
+
|
|
17
|
+
Do not assume affiliate functionality merely because the package supports it.
|
|
18
|
+
Updating an existing content-only installation requires no affiliate code or
|
|
19
|
+
configuration. Do not assume content delivery for an affiliate-only merchant.
|
|
20
|
+
|
|
21
|
+
## Content integration
|
|
22
|
+
|
|
23
|
+
Follow this section only for **content only** or **content and affiliate** mode.
|
|
24
|
+
Expose a public HTTPS Monok content integration at these routes:
|
|
25
|
+
|
|
26
|
+
- `POST /api/monok/push`
|
|
27
|
+
- `GET /api/monok/info`
|
|
28
|
+
- `GET /api/monok/postfilter` when article discovery is supported
|
|
29
|
+
|
|
30
|
+
Use `createMonokRouter` from `@monok/server`. Mount it at `/api/monok` for the
|
|
31
|
+
default API base, or at an intentional custom base such as
|
|
32
|
+
`/something/specialAPI/monok`. When connecting the integration, enter either the
|
|
33
|
+
site origin to select the default or the complete custom base URL. Monok appends
|
|
34
|
+
`/info`, `/push`, and `/postfilter` to that base.
|
|
35
|
+
|
|
36
|
+
## Content implementation
|
|
37
|
+
|
|
38
|
+
Install the package and Express:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npm install @monok/server express
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Create one router with the same secret API key configured for the Monok
|
|
45
|
+
workspace:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import express from "express";
|
|
49
|
+
import {
|
|
50
|
+
createMonokRouter,
|
|
51
|
+
sendMonokLinkback,
|
|
52
|
+
type MonokArticle,
|
|
53
|
+
type PostFilterQuery,
|
|
54
|
+
type PostFilterResult,
|
|
55
|
+
type SiteInfoInput
|
|
56
|
+
} from "@monok/server";
|
|
57
|
+
|
|
58
|
+
const app = express();
|
|
59
|
+
|
|
60
|
+
app.use(
|
|
61
|
+
"/api/monok",
|
|
62
|
+
createMonokRouter({
|
|
63
|
+
apiKey: process.env.MONOK_API_KEY!,
|
|
64
|
+
imageDir: "./uploads/monok",
|
|
65
|
+
getSiteInfo: async (): Promise<SiteInfoInput> => loadSiteInfo(),
|
|
66
|
+
searchArticles: async (
|
|
67
|
+
filters: PostFilterQuery
|
|
68
|
+
): Promise<PostFilterResult[]> => searchPublishedArticles(filters),
|
|
69
|
+
onArticle: async (article, context) => {
|
|
70
|
+
await upsertMonokArticle(article, context);
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
);
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`onArticle` and the application's persistence work are always required.
|
|
77
|
+
`getSiteInfo` is optional. The router always exposes authenticated `/info`; when
|
|
78
|
+
the callback is omitted it returns minimal `Custom CMS` metadata so the standard
|
|
79
|
+
Monok dashboard connection and sync flow can still detect the installation.
|
|
80
|
+
`searchArticles` and `imageDir` are optional; omitting article search disables
|
|
81
|
+
covered-article and backlink discovery.
|
|
82
|
+
|
|
83
|
+
Do not add another JSON body parser solely for these routes. The router already
|
|
84
|
+
parses JSON with a 20 MB limit. A reverse proxy in front of the application must
|
|
85
|
+
also permit the request size.
|
|
86
|
+
|
|
87
|
+
## Receiving and storing articles
|
|
88
|
+
|
|
89
|
+
`onArticle` receives:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
async function onArticle(
|
|
93
|
+
article: MonokArticle,
|
|
94
|
+
context: {
|
|
95
|
+
action?: "draft" | "publish";
|
|
96
|
+
saveThumbImage(fileNameBase?: string): Promise<string | undefined>;
|
|
97
|
+
}
|
|
98
|
+
): Promise<void>
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The host must:
|
|
102
|
+
|
|
103
|
+
1. Validate any application-specific constraints.
|
|
104
|
+
2. Upsert by `article.id`, or enqueue an idempotent job keyed by `article.id`, so
|
|
105
|
+
repeated delivery cannot create duplicate content.
|
|
106
|
+
3. Preserve `article.id` with the CMS record or queued job for later linkbacks
|
|
107
|
+
and updates.
|
|
108
|
+
4. Store `article.content` as the rendered HTML body when processing the job.
|
|
109
|
+
5. Apply the supplied slug, author, categories, sections, tags, language, and
|
|
110
|
+
CMS post type when the host supports them.
|
|
111
|
+
6. Treat the requested action as draft unless `context.action === "publish"`.
|
|
112
|
+
7. Before returning, await either durable CMS persistence or durable queue
|
|
113
|
+
acceptance. Do not acknowledge an in-memory, fire-and-forget task.
|
|
114
|
+
8. Throw when persistence or durable queueing fails.
|
|
115
|
+
|
|
116
|
+
The draft/publish action normally arrives in the JSON body. The package also
|
|
117
|
+
supports the legacy query parameter. Do not read `article.action` directly when
|
|
118
|
+
deciding publication state; use the validated `context.action` value.
|
|
119
|
+
|
|
120
|
+
`onArticle` returns `void` or `Promise<void>`. It must not construct an HTTP
|
|
121
|
+
response. After the callback durably saves or queues the work, the package
|
|
122
|
+
returns:
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
{
|
|
126
|
+
"ok": true,
|
|
127
|
+
"status": "accepted",
|
|
128
|
+
"monokId": "the-article-id"
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
This response acknowledges delivery only. It does not promise that a draft URL,
|
|
133
|
+
CMS record ID, thumbnail URL, or public canonical URL already exists. Custom CMS
|
|
134
|
+
implementations may render, moderate, deploy, or publish asynchronously.
|
|
135
|
+
|
|
136
|
+
Thrown errors become JSON error responses. An error with a numeric `status`
|
|
137
|
+
property uses that HTTP status; other errors return `500`.
|
|
138
|
+
|
|
139
|
+
## Article payload
|
|
140
|
+
|
|
141
|
+
The important `MonokArticle` fields are:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
interface MonokArticle {
|
|
145
|
+
id: string | number;
|
|
146
|
+
title: string;
|
|
147
|
+
shortTitle?: string;
|
|
148
|
+
description?: string;
|
|
149
|
+
content?: string;
|
|
150
|
+
text?: string;
|
|
151
|
+
slug?: string;
|
|
152
|
+
action?: "draft" | "publish";
|
|
153
|
+
author?: { id: string | number; name: string };
|
|
154
|
+
categories?: Array<{ name: string; weight?: number }>;
|
|
155
|
+
sections?: string[];
|
|
156
|
+
tags?: string[] | null;
|
|
157
|
+
language?: string;
|
|
158
|
+
type?: string;
|
|
159
|
+
thumbnail?: string;
|
|
160
|
+
thumbImageData?: string;
|
|
161
|
+
thumbAlt?: string;
|
|
162
|
+
thumbImageDescription?: string;
|
|
163
|
+
keywords?: string[];
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Treat unknown additional fields as forward-compatible metadata. Do not reject a
|
|
168
|
+
payload merely because it contains fields added by a newer Monok version.
|
|
169
|
+
|
|
170
|
+
## JPEG thumbnail handling
|
|
171
|
+
|
|
172
|
+
Monok sends `thumbImageData` as JPEG base64. It is normally raw base64, while a
|
|
173
|
+
legacy path can wrap it in `data:image/jpeg;base64,...`.
|
|
174
|
+
|
|
175
|
+
When `imageDir` is configured, call `await context.saveThumbImage()` to validate
|
|
176
|
+
and save it. The return value is the local `.jpg` path, or `undefined` when no
|
|
177
|
+
image directory or image data was provided. The default decoded-image limit is
|
|
178
|
+
15 MiB and can be changed with `maxImageBytes`.
|
|
179
|
+
|
|
180
|
+
Saving a file is not the same as making it public. If the CMS has a media API or
|
|
181
|
+
object storage, the host may instead process `article.thumbImageData` itself.
|
|
182
|
+
Preserve `thumbAlt` or fall back to `thumbImageDescription`, then the title, for
|
|
183
|
+
accessible alt text.
|
|
184
|
+
|
|
185
|
+
Do not label the saved image as PNG and do not log its base64 data.
|
|
186
|
+
|
|
187
|
+
## Site information
|
|
188
|
+
|
|
189
|
+
Implement `getSiteInfo` when Monok should offer real CMS authors, categories,
|
|
190
|
+
tags, post types, or languages. Return this shape:
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
const info: SiteInfoInput = {
|
|
194
|
+
js_scripts: "Enabled",
|
|
195
|
+
name: "Site name",
|
|
196
|
+
logo: "https://example.com/logo.jpg",
|
|
197
|
+
categories: ["News", "Analysis"],
|
|
198
|
+
post_types: ["post", "page"],
|
|
199
|
+
tags: ["Technology", "Business"],
|
|
200
|
+
authors: [
|
|
201
|
+
{
|
|
202
|
+
id: "author-1",
|
|
203
|
+
name: "Jane Doe",
|
|
204
|
+
profile: "Editor",
|
|
205
|
+
avatar: "https://example.com/jane.jpg",
|
|
206
|
+
roles: ["editor"]
|
|
207
|
+
}
|
|
208
|
+
],
|
|
209
|
+
languages: {
|
|
210
|
+
en: { name: "English", slug: "en", locale: "en_US" }
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Do not return `version` from `getSiteInfo`. The router always injects the
|
|
216
|
+
installed `@monok/server` package version into the `/info` response.
|
|
217
|
+
|
|
218
|
+
Return empty arrays when a supported collection has no entries. Values may be
|
|
219
|
+
loaded dynamically on every request. The router sends private no-cache headers.
|
|
220
|
+
If `getSiteInfo` is omitted, `/info` returns this authenticated fallback:
|
|
221
|
+
|
|
222
|
+
```json
|
|
223
|
+
{
|
|
224
|
+
"js_scripts": "Disabled",
|
|
225
|
+
"version": "1.5.0",
|
|
226
|
+
"name": "Custom CMS",
|
|
227
|
+
"logo": "",
|
|
228
|
+
"categories": [],
|
|
229
|
+
"post_types": ["post"],
|
|
230
|
+
"tags": [],
|
|
231
|
+
"authors": []
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
## Article discovery with `/postfilter`
|
|
236
|
+
|
|
237
|
+
Implement `searchArticles` to expose published host content to Monok. The
|
|
238
|
+
callback receives normalized values:
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
interface PostFilterQuery {
|
|
242
|
+
postType: string; // defaults to "post"
|
|
243
|
+
section?: string;
|
|
244
|
+
tag?: string;
|
|
245
|
+
query?: string;
|
|
246
|
+
amount: number; // defaults to 10
|
|
247
|
+
page: number; // defaults to 1
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Apply all supplied filters, return only published/public articles, and paginate
|
|
252
|
+
deterministically. Return an array using these exact snake-case keys:
|
|
253
|
+
|
|
254
|
+
```ts
|
|
255
|
+
const result: PostFilterResult = {
|
|
256
|
+
canonical_url: "https://example.com/articles/example",
|
|
257
|
+
post_id: "cms-record-id",
|
|
258
|
+
title: "Example article",
|
|
259
|
+
description: "A short plain-text description"
|
|
260
|
+
};
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Return `[]` when nothing matches. `title` and `description` may be `null`.
|
|
264
|
+
Do not return drafts, private content, article bodies, credentials, or internal
|
|
265
|
+
metadata. If `searchArticles` is omitted, `/postfilter` returns `404`.
|
|
266
|
+
|
|
267
|
+
## Authentication and cache behavior
|
|
268
|
+
|
|
269
|
+
The router performs authentication. The host should not duplicate it inside
|
|
270
|
+
callbacks.
|
|
271
|
+
|
|
272
|
+
- `/push` accepts Monok's `token` header, `X-API-Key`, or legacy `?apikey=`.
|
|
273
|
+
- `/info` and `/postfilter` accept `X-API-Key` or legacy `?apikey=`.
|
|
274
|
+
- Comparisons are timing-safe.
|
|
275
|
+
- `/info` and `/postfilter` responses include private no-cache headers.
|
|
276
|
+
|
|
277
|
+
Keep `MONOK_API_KEY` server-side. Never expose it to browser JavaScript, commit
|
|
278
|
+
it, include it in a URL shown to users, or log it. Prefer `X-API-Key` over query
|
|
279
|
+
authentication for custom calls because URLs are commonly logged by proxies.
|
|
280
|
+
|
|
281
|
+
## Linkback after publication
|
|
282
|
+
|
|
283
|
+
When an article is actually published, notify Monok only after the public
|
|
284
|
+
canonical URL exists. This applies both to a draft published later and to an
|
|
285
|
+
asynchronous `action: "publish"` job:
|
|
286
|
+
|
|
287
|
+
The default destination is `https://app.monok.com/api/v1/linkback`.
|
|
288
|
+
`monokBaseUrl` is only an override for another Monok environment.
|
|
289
|
+
|
|
290
|
+
```ts
|
|
291
|
+
await sendMonokLinkback({
|
|
292
|
+
monokId: storedMonokId,
|
|
293
|
+
externalId: cmsArticleId,
|
|
294
|
+
url: canonicalUrl,
|
|
295
|
+
title,
|
|
296
|
+
thumbnailUrl: publicThumbnailUrl
|
|
297
|
+
}, {
|
|
298
|
+
apiKey: process.env.MONOK_API_KEY
|
|
299
|
+
});
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
Call this from the host's real publication lifecycle, not merely when `/push`
|
|
303
|
+
returns or when a job enters a queue. The linkback is the authoritative signal
|
|
304
|
+
that publication completed and a canonical URL exists. A retry should be safe.
|
|
305
|
+
When useful, `externalId` is the stable article ID assigned by the host CMS; it
|
|
306
|
+
is optional because the canonical URL also identifies the publication. When
|
|
307
|
+
supplied, `thumbnailUrl` is the public thumbnail URL.
|
|
308
|
+
|
|
309
|
+
## Content verification checklist
|
|
310
|
+
|
|
311
|
+
Before declaring the integration complete, verify all applicable items:
|
|
312
|
+
|
|
313
|
+
- `GET /api/monok/info` returns `401` without a key.
|
|
314
|
+
- The same route succeeds with `X-API-Key` and with legacy `?apikey=`.
|
|
315
|
+
- Info responses contain `Cache-Control: no-store`.
|
|
316
|
+
- `POST /api/monok/push` accepts a body containing `action: "draft"`.
|
|
317
|
+
- A body containing `action: "publish"` is durably persisted or queued with the
|
|
318
|
+
requested publication intent.
|
|
319
|
+
- Successful pushes return `ok: true`, `status: "accepted"`, and the same
|
|
320
|
+
`monokId`, without requiring a canonical URL.
|
|
321
|
+
- Repeating the same `article.id` updates one record instead of duplicating it.
|
|
322
|
+
- Invalid action values return `400`.
|
|
323
|
+
- JPEG data saves as `.jpg`, and invalid or oversized image data is rejected.
|
|
324
|
+
- `/postfilter` applies search, type, section, tag, amount, and page filters.
|
|
325
|
+
- `/postfilter` returns exact `canonical_url`, `post_id`, `title`, and
|
|
326
|
+
`description` fields.
|
|
327
|
+
- Synchronous or asynchronous publication sends a linkback only after the public
|
|
328
|
+
canonical URL exists.
|
|
329
|
+
- The integration is reachable through its production HTTPS reverse proxy.
|
|
330
|
+
|
|
331
|
+
Do not claim completion based only on TypeScript compilation. Exercise the HTTP
|
|
332
|
+
routes and confirm one durable draft or draft job, one publication or publication
|
|
333
|
+
job, one repeated delivery, and one discovery query against the host
|
|
334
|
+
application's persistence layer. For asynchronous publishing, also complete one
|
|
335
|
+
job and verify its eventual linkback.
|
|
336
|
+
|
|
337
|
+
## Affiliate integration
|
|
338
|
+
|
|
339
|
+
Follow this section only for **affiliate only** or **content and affiliate**
|
|
340
|
+
mode. A content-only installation must not be changed based on this section.
|
|
341
|
+
|
|
342
|
+
For Express, mount attribution before landing, signup, authentication, and
|
|
343
|
+
account-creation routes:
|
|
344
|
+
|
|
345
|
+
```ts
|
|
346
|
+
import express from "express";
|
|
347
|
+
import {
|
|
348
|
+
createMonokReferral,
|
|
349
|
+
monokAffiliateAttribution
|
|
350
|
+
} from "@monok/server";
|
|
351
|
+
|
|
352
|
+
const app = express();
|
|
353
|
+
|
|
354
|
+
app.use(monokAffiliateAttribution());
|
|
355
|
+
app.use(express.json());
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
The middleware reads the signed `monok_ref` query parameter, stores it in an
|
|
359
|
+
HttpOnly, Secure, SameSite=Lax cookie, and redirects to the same URL without the
|
|
360
|
+
token. The offer controls attribution duration; cookie expiry comes from the
|
|
361
|
+
token. Do not add a merchant-configured attribution duration. Only set
|
|
362
|
+
`secure: false` for local HTTP development.
|
|
363
|
+
|
|
364
|
+
For an apex landing site and an account application on a subdomain, explicitly
|
|
365
|
+
share the cookie across the parent domain:
|
|
366
|
+
|
|
367
|
+
```ts
|
|
368
|
+
app.use(monokAffiliateAttribution({ cookieDomain: ".example.com" }));
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
A cookie created on `example.com` with `Domain=.example.com` is sent to
|
|
372
|
+
`app.example.com`. Omit `cookieDomain` when landing and signup use the same
|
|
373
|
+
hostname. Avoid simultaneous host-only and parent-domain cookies with the same
|
|
374
|
+
name because they produce ambiguous duplicate cookies.
|
|
375
|
+
|
|
376
|
+
After durably creating a new customer, call Monok from that same successful
|
|
377
|
+
account-creation path:
|
|
378
|
+
|
|
379
|
+
```ts
|
|
380
|
+
if (req.monokAttribution) {
|
|
381
|
+
await createMonokReferral(
|
|
382
|
+
{
|
|
383
|
+
attributionToken: req.monokAttribution,
|
|
384
|
+
customerReference: customer.id
|
|
385
|
+
},
|
|
386
|
+
{ apiKey: process.env.MONOK_API_KEY! }
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
Use a durable internal customer ID for `customerReference`, never an email or
|
|
392
|
+
other personal data. Do not register referrals during ordinary login,
|
|
393
|
+
invitation acceptance, or flows that do not create a new merchant customer.
|
|
394
|
+
Registration is idempotent for an offer/customer. A temporary Monok failure
|
|
395
|
+
must not roll back an already-created customer; retain the token and customer ID
|
|
396
|
+
in durable retry work. Keep `MONOK_API_KEY` server-side.
|
|
397
|
+
|
|
398
|
+
Monok's own sites use this same public package and API flow. Do not replace it
|
|
399
|
+
with direct database writes, an internal-only attribution service, or
|
|
400
|
+
`referredWorkspaceId` handling.
|
|
401
|
+
|
|
402
|
+
`monokAffiliateAttribution` is Express middleware;
|
|
403
|
+
`createMonokReferral` is framework-independent server code. In another server
|
|
404
|
+
framework, reproduce the capture contract server-side: read `monok_ref`, derive
|
|
405
|
+
expiry from the token, set the secure HttpOnly cookie, and redirect to the clean
|
|
406
|
+
URL. Then read the cookie during customer creation and pass it to
|
|
407
|
+
`createMonokReferral`. A static frontend cannot create an HttpOnly cookie; it
|
|
408
|
+
requires a server or CDN/edge capture handler. Client-side JavaScript cookies
|
|
409
|
+
are not an equivalent secure integration.
|
|
410
|
+
|
|
411
|
+
### Configure the offer on app.monok.com
|
|
412
|
+
|
|
413
|
+
Briefly tell the developer to:
|
|
414
|
+
|
|
415
|
+
1. Sign in to `app.monok.com` and select the merchant workspace.
|
|
416
|
+
2. Open the relevant Brand and choose **Affiliate Program**.
|
|
417
|
+
3. Create the offer with its exact HTTPS landing URL, commission terms,
|
|
418
|
+
duration, and attribution window.
|
|
419
|
+
4. Deploy the integration, click **Test setup**, and confirm **Capture
|
|
420
|
+
verified**.
|
|
421
|
+
5. Activate the offer when it is ready to receive attributed traffic.
|
|
422
|
+
|
|
423
|
+
The Affiliate Program page is currently development-only. If it is absent,
|
|
424
|
+
explain that it is not enabled in that environment. Do not invent another route
|
|
425
|
+
or substitute the content `/info` endpoint.
|
|
426
|
+
|
|
427
|
+
Affiliate integration does not require `createMonokRouter`, `/push`, `/info`,
|
|
428
|
+
or `/postfilter`.
|
|
429
|
+
|
|
430
|
+
### Affiliate verification checklist
|
|
431
|
+
|
|
432
|
+
- Test the exact production landing URL through **Test setup**; do not fabricate
|
|
433
|
+
an attribution token locally.
|
|
434
|
+
- Confirm the offer becomes **Capture verified** before activation.
|
|
435
|
+
- For cross-subdomain flows, confirm the parent-domain cookie reaches the
|
|
436
|
+
account-creation hostname.
|
|
437
|
+
- Exercise each qualifying customer-creation path, including credentials and
|
|
438
|
+
OAuth where applicable, and confirm it calls `createMonokReferral`.
|
|
439
|
+
- Confirm login and invitation-only flows do not create referrals.
|
|
440
|
+
- Do not claim end-to-end verification yet. The dashboard currently validates
|
|
441
|
+
capture only; one-time test-customer callback validation is reserved for a
|
|
442
|
+
later implementation.
|
package/README.md
CHANGED
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
# `@monok/server`
|
|
4
4
|
|
|
5
5
|
Monok server integration helpers for Node.js/Express.
|
|
6
|
-
|
|
6
|
+
Connect a backend to Monok for content delivery, affiliate attribution, or both.
|
|
7
|
+
|
|
8
|
+
Using an AI coding assistant? Give it [`AGENTS.md`](./AGENTS.md). The guide contains
|
|
9
|
+
the complete host-implementation contract, response shapes, and verification checklist.
|
|
7
10
|
|
|
8
11
|
---
|
|
9
12
|
|
|
@@ -16,7 +19,7 @@ import { createMonokRouter, type MonokArticle } from "@monok/server";
|
|
|
16
19
|
const app = express();
|
|
17
20
|
|
|
18
21
|
app.use(
|
|
19
|
-
"/monok",
|
|
22
|
+
"/api/monok",
|
|
20
23
|
createMonokRouter({
|
|
21
24
|
apiKey: process.env.MONOK_API_KEY!,
|
|
22
25
|
onArticle: async (article: MonokArticle, { saveThumbImage, action }) => {
|
|
@@ -46,6 +49,52 @@ npm install @monok/server express
|
|
|
46
49
|
|
|
47
50
|
## 📚 Usage
|
|
48
51
|
|
|
52
|
+
### Affiliate attribution (independent of content delivery)
|
|
53
|
+
|
|
54
|
+
The affiliate middleware preserves the signed `monok_ref` value from a Monok
|
|
55
|
+
redirect in an HttpOnly, SameSite=Lax cookie, then redirects to the same URL
|
|
56
|
+
without the token. The offer controls the attribution window and the middleware
|
|
57
|
+
derives the cookie expiry from the token, so merchants do not configure it.
|
|
58
|
+
Mount it before the merchant's landing and signup routes. After the customer has
|
|
59
|
+
been durably created, register the referral using the merchant's own stable
|
|
60
|
+
customer ID.
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
import express from "express";
|
|
64
|
+
import {
|
|
65
|
+
createMonokReferral,
|
|
66
|
+
monokAffiliateAttribution
|
|
67
|
+
} from "@monok/server";
|
|
68
|
+
|
|
69
|
+
const app = express();
|
|
70
|
+
app.use(monokAffiliateAttribution());
|
|
71
|
+
app.use(express.json());
|
|
72
|
+
|
|
73
|
+
app.post("/signup", async (req, res) => {
|
|
74
|
+
const customer = await createCustomer(req.body);
|
|
75
|
+
|
|
76
|
+
if (req.monokAttribution) {
|
|
77
|
+
await createMonokReferral(
|
|
78
|
+
{
|
|
79
|
+
attributionToken: req.monokAttribution,
|
|
80
|
+
customerReference: customer.id
|
|
81
|
+
},
|
|
82
|
+
{ apiKey: process.env.MONOK_API_KEY! }
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
res.status(201).json({ id: customer.id });
|
|
87
|
+
});
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The middleware defaults to secure cookies. For local HTTP development only, use
|
|
91
|
+
`monokAffiliateAttribution({ secure: false })`. `customerReference` should be a
|
|
92
|
+
durable internal ID, not an email address or other personal data. Repeating the
|
|
93
|
+
same offer/customer registration is idempotent and retains the first referral.
|
|
94
|
+
|
|
95
|
+
Affiliate-only merchants do **not** need to mount `createMonokRouter` or expose
|
|
96
|
+
`/push`, `/info`, or `/postfilter`.
|
|
97
|
+
|
|
49
98
|
### Express setup (TypeScript)
|
|
50
99
|
|
|
51
100
|
```ts
|
|
@@ -59,19 +108,21 @@ import {
|
|
|
59
108
|
const app = express();
|
|
60
109
|
|
|
61
110
|
app.use(
|
|
62
|
-
"/monok",
|
|
111
|
+
"/api/monok",
|
|
63
112
|
createMonokRouter({
|
|
64
113
|
apiKey: process.env.MONOK_API_KEY!,
|
|
65
114
|
imageDir: "./uploads", // optional
|
|
66
115
|
// Optional: provide site info if you want Monok to query it
|
|
67
116
|
getSiteInfo: {
|
|
68
117
|
js_scripts: "Enabled",
|
|
69
|
-
version: "1.0.0",
|
|
70
118
|
name: "My Site",
|
|
71
119
|
logo: "https://example.com/logo.png",
|
|
72
120
|
categories: ["Content", "News Articles", "Uncategorized"],
|
|
73
121
|
post_types: ["post", "page"],
|
|
74
122
|
tags: ["Tech", "Health", "News"],
|
|
123
|
+
languages: {
|
|
124
|
+
en: { name: "English", slug: "en", locale: "en_US" }
|
|
125
|
+
},
|
|
75
126
|
authors: [
|
|
76
127
|
{
|
|
77
128
|
name: "Jane Doe",
|
|
@@ -82,6 +133,15 @@ app.use(
|
|
|
82
133
|
}
|
|
83
134
|
]
|
|
84
135
|
},
|
|
136
|
+
searchArticles: async ({ postType, section, tag, query, amount, page }) => {
|
|
137
|
+
// Search published content in your CMS and return the requested page.
|
|
138
|
+
return [{
|
|
139
|
+
canonical_url: "https://example.com/blog/an-article",
|
|
140
|
+
post_id: "cms-123",
|
|
141
|
+
title: "An article",
|
|
142
|
+
description: "Article summary"
|
|
143
|
+
}];
|
|
144
|
+
},
|
|
85
145
|
onArticle: async (article: MonokArticle, { saveThumbImage, action }) => {
|
|
86
146
|
const imagePath = await saveThumbImage();
|
|
87
147
|
console.log("Received article:", article.title);
|
|
@@ -95,9 +155,9 @@ app.use(
|
|
|
95
155
|
// when you publish later, notify Monok:
|
|
96
156
|
// await sendMonokLinkback({
|
|
97
157
|
// monokId: article.id,
|
|
98
|
-
//
|
|
158
|
+
// externalId: savedArticle.id,
|
|
159
|
+
// url: "https://example.com/blog/" + (article.slug ?? article.id),
|
|
99
160
|
// title: article.title,
|
|
100
|
-
// articleTextSample: article.text?.slice(0, 500),
|
|
101
161
|
// thumbnailUrl: imagePath
|
|
102
162
|
// ? "https://example.com/uploads/" + require("path").basename(imagePath)
|
|
103
163
|
// : undefined
|
|
@@ -118,7 +178,7 @@ const { createMonokRouter } = require("@monok/server");
|
|
|
118
178
|
const app = express();
|
|
119
179
|
|
|
120
180
|
app.use(
|
|
121
|
-
"/monok",
|
|
181
|
+
"/api/monok",
|
|
122
182
|
createMonokRouter({
|
|
123
183
|
apiKey: process.env.MONOK_API_KEY,
|
|
124
184
|
onArticle: async (article, { saveThumbImage, action }) => {
|
|
@@ -141,29 +201,58 @@ app.listen(3000);
|
|
|
141
201
|
|
|
142
202
|
## 🔑 Endpoints
|
|
143
203
|
|
|
144
|
-
|
|
204
|
+
Mounting the router at `/api/monok` uses Monok's default API base. A custom or
|
|
205
|
+
versioned base is also supported, for example:
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
app.use("/something/specialAPI/monok", createMonokRouter({ /* ... */ }));
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Enter that complete base URL when connecting the integration in Monok. Monok
|
|
212
|
+
appends `/info`, `/push`, and `/postfilter` to the configured base.
|
|
213
|
+
|
|
214
|
+
* **`POST /api/monok/push`**
|
|
145
215
|
Monok pushes new article data here.
|
|
146
216
|
Header: `token: <your-api-key>`
|
|
147
217
|
Body: JSON payload of the article.
|
|
148
218
|
|
|
149
|
-
* `action`
|
|
219
|
+
* `action` is read from the JSON body and may be `draft` or `publish`.
|
|
220
|
+
* A legacy `?action=draft|publish` query parameter is also accepted.
|
|
221
|
+
* After `onArticle` durably saves or queues the work, the route acknowledges
|
|
222
|
+
it with `{ "ok": true, "status": "accepted", "monokId": "..." }`. This
|
|
223
|
+
does not claim that asynchronous publication has completed.
|
|
224
|
+
|
|
225
|
+
* **`GET /api/monok/info`**
|
|
226
|
+
Monok fetches this to authenticate and learn your site branding, categories, tags, and authors.
|
|
227
|
+
If `getSiteInfo` is not provided, the route remains available and returns a
|
|
228
|
+
minimal `Custom CMS` response with empty metadata collections.
|
|
150
229
|
|
|
151
|
-
* **`GET /monok/
|
|
152
|
-
If `
|
|
153
|
-
|
|
230
|
+
* **`GET /api/monok/postfilter`**
|
|
231
|
+
If `searchArticles` is configured, Monok uses this route to search published
|
|
232
|
+
content. Supported parameters are `post_type`, `section`, `tag`, `q`, `amount`,
|
|
233
|
+
and `page`. If it is not configured, the route responds with `404`.
|
|
234
|
+
|
|
235
|
+
`GET /info` and `GET /postfilter` accept the API key in the `X-API-Key` header
|
|
236
|
+
(preferred) or the legacy `?apikey=` query parameter. Their responses include
|
|
237
|
+
private no-cache headers. `POST /push` also accepts Monok's `token` header.
|
|
154
238
|
|
|
155
239
|
---
|
|
156
240
|
|
|
157
241
|
## 🔗 Linkback after publishing
|
|
158
242
|
|
|
243
|
+
The push acknowledgement confirms delivery only. If publication is asynchronous,
|
|
244
|
+
send the linkback later, after the public canonical URL actually exists.
|
|
245
|
+
Linkbacks default to `https://app.monok.com/api/v1/linkback`; use
|
|
246
|
+
`monokBaseUrl` only when targeting another Monok environment.
|
|
247
|
+
|
|
159
248
|
```ts
|
|
160
249
|
import { sendMonokLinkback } from "@monok/server";
|
|
161
250
|
|
|
162
251
|
await sendMonokLinkback({
|
|
163
252
|
monokId: article.id,
|
|
164
|
-
|
|
253
|
+
externalId: cmsArticle.id,
|
|
254
|
+
url: "https://example.com/blog/" + (article.slug ?? article.id),
|
|
165
255
|
title: article.title,
|
|
166
|
-
articleTextSample: article.text?.slice(0, 500),
|
|
167
256
|
thumbnailUrl: "https://example.com/uploads/" + imageFile
|
|
168
257
|
});
|
|
169
258
|
```
|
|
@@ -176,8 +265,10 @@ await sendMonokLinkback({
|
|
|
176
265
|
| ------------- | ----------------------------------------- | -------- | --------------------------------------------------------------------------------- |
|
|
177
266
|
| `apiKey` | `string` | ✅ | Token Monok sends in headers / query. |
|
|
178
267
|
| `onArticle` | `(article, ctx) => void \| Promise<void>` | ✅ | Handle new article pushes. `ctx` includes `saveThumbImage` and optional `action`. |
|
|
179
|
-
| `getSiteInfo` | `
|
|
180
|
-
| `
|
|
268
|
+
| `getSiteInfo` | `SiteInfoInput \| () => SiteInfoInput` | ❌ | Return host metadata; the router injects its package version. A minimal fallback is used when omitted. |
|
|
269
|
+
| `searchArticles` | `(query) => PostFilterResult[]` | ❌ | Enables authenticated article discovery through `GET /postfilter`. |
|
|
270
|
+
| `imageDir` | `string` | ❌ | Directory in which validated JPEG thumbnails are saved. |
|
|
271
|
+
| `maxImageBytes` | `number` | ❌ | Maximum decoded JPEG size; defaults to 15 MiB. |
|
|
181
272
|
| `basePath` | `string` | ❌ | Custom prefix for routes (default ""). |
|
|
182
273
|
|
|
183
274
|
---
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { RequestHandler } from "express";
|
|
2
|
+
import type { MonokAffiliateAttributionOptions } from "./types.js";
|
|
3
|
+
declare global {
|
|
4
|
+
namespace Express {
|
|
5
|
+
interface Request {
|
|
6
|
+
monokAttribution?: string;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Preserve Monok's signed redirect token in an HttpOnly cookie and expose it as
|
|
12
|
+
* `req.monokAttribution`. This does not expose any content-receiving endpoint.
|
|
13
|
+
*/
|
|
14
|
+
export declare function monokAffiliateAttribution(options?: MonokAffiliateAttributionOptions): RequestHandler;
|
|
15
|
+
//# sourceMappingURL=affiliate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"affiliate.d.ts","sourceRoot":"","sources":["../src/affiliate.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAyB,cAAc,EAAY,MAAM,SAAS,CAAC;AAC/E,OAAO,KAAK,EAAE,gCAAgC,EAAE,MAAM,YAAY,CAAC;AAcnE,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,OAAO,CAAC;QAChB,UAAU,OAAO;YACf,gBAAgB,CAAC,EAAE,MAAM,CAAC;SAC3B;KACF;CACF;AAgED;;;GAGG;AACH,wBAAgB,yBAAyB,CACvC,OAAO,GAAE,gCAAqC,GAC7C,cAAc,CAuChB"}
|