openvibers/openvibe.examples Commits

webhook-consumer requires signature v2 (replay window); openvibe-sdk v0.4.0

OP OpenVibers committed 23 Sept 2026 · 6ea9cfd936 · 16 files, +52 −39

- Pin openvibe-sdk v0.4.0 everywhere (root, all nine examples, structure test, README, STATUS).
- webhook-consumer: parseDelivery(..., { requireV2: true }). A delivery is refused when its
  X-OpenVibe-Signature-V2 timestamp is more than 300 s from this clock, or when it has no v2
  header (a captured delivery replayed with v2 stripped). v1 alone is never accepted.
- Smoke test: blanking v1 also blanks v2; new cases for a v1-only delivery and a stale v2 (301 s).
- README notes describe the v2 scheme; the inbox stays mandatory (delivery is at least once).

Changes

ddiff --git a/README.md b/README.md
iindex 8c0ccda..1fd447f 100644
---- a/README.md
++++ b/README.md
@@@ -7,7 +7,7 @@
Node 22.22.1 against `openvibe-sdk/testing`'s mock platform (no network). `npm run e2e` runs three
of them (Media, Events pull, a Tools job) against the real platform with your own sandbox app; it
is not part of CI or `npm test`, and it was not run against production as part of this release.
--**Built on:** [openvibe-sdk v0.3.1](https://github.com/OpenVibers/OpenVibe.SDK/tree/v0.3.1), and
++**Built on:** [openvibe-sdk v0.4.0](https://github.com/OpenVibers/OpenVibe.SDK/tree/v0.4.0), and
openvibe-contracts v0.28.0 for the mod manifest.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §15.1
and §18.9; roadmap Wave 20, §30 (public SDK surface), ADR-014 (developer projects).
@@@ -22,7 +22,7 @@ OpenVibe services themselves are AGPL-3.0.
| [node-server-app](examples/node-server-app) | Client-credentials app token per audience and what it carries, registry discovery, contracts version check, trace propagation | `openvibe-sdk/auth` (`getTokenInfo`), `/registry`, `/core` |
| [media-uploader](examples/media-uploader) | Upload into, read from and delete in your project's Media tenant; sandbox files come back with signed URLs | `openvibe-sdk/media` |
| [event-subscriber](examples/event-subscriber) | Publish to your project's topic `app.<project_key>.*`, pull it with a durable cursor, gaps; anonymous realtime for public events | `openvibe-sdk/events` (`createAppEvents`), `/realtime` |
--| [webhook-consumer](examples/webhook-consumer) | An app subscription on your own topic; `X-OpenVibe-Signature` verified on the raw body; exactly-once handling with an inbox; secret rotation | `openvibe-sdk/events` (`createAppEvents`, `parseDelivery`, `createInbox`) |
++| [webhook-consumer](examples/webhook-consumer) | An app subscription on your own topic; `X-OpenVibe-Signature-V2` (±300 s replay window) verified on the raw body; exactly-once handling with an inbox; secret rotation | `openvibe-sdk/events` (`createAppEvents`, `parseDelivery`, `createInbox`) |
| [tool-job](examples/tool-job) | An OpenVibe.Tools job submitted once (idempotency key), reattached after a restart, its SSE stream resumed with `Last-Event-ID` | `openvibe-sdk/jobs` |
| [oauth-app](examples/oauth-app) | Authorization code + PKCE for a confidential app, exchange on the server, offline app-token verification, session hygiene | `openvibe-sdk/auth` |
| [chat-bot](examples/chat-bot) | A bot on Chat's WebSocket protocol that stays in its one configured room and always identifies as a bot | Chat `/ws/chat` protocol (no SDK client: Chat is first-party) |
@@@ -163,7 +163,7 @@ Repository checks (`test/`):
value in a `.env.example`.
- `structure.test.js` checks the nine examples are complete and copyable: README with "What it
proves" and "Run it against the real platform", a `.env.example` that names every variable the
-- code reads, MIT license, `openvibe-sdk` pinned to the v0.3.1 tag, no `file:` links; and that
++ code reads, MIT license, `openvibe-sdk` pinned to the v0.4.0 tag, no `file:` links; and that
`npm run e2e` is outside CI and `npm test` and refuses to start without its variables.
## Layout
@@@ -190,7 +190,7 @@ test/public-surface.test.js, test/structure.test.js
## Depends on
--- OpenVibe.SDK (v0.3.1), OpenVibe.Contracts (v0.28.0, mod manifest only)
++- OpenVibe.SDK (v0.4.0), OpenVibe.Contracts (v0.28.0, mod manifest only)
- at run time against the real platform: Network (tokens, registry, developer projects), Events,
Media, Tools, Chat (on openvibe.live)
ddiff --git a/STATUS.json b/STATUS.json
iindex 136609c..2da88a2 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W20",
"version": "0.2.0",
-- "sdk": "openvibe-sdk v0.3.1",
++ "sdk": "openvibe-sdk v0.4.0",
"contracts": "openvibe-contracts v0.28.0 (mod-manifest example)",
"ci": "every example's smoke test runs on Node 22.22.1 against openvibe-sdk/testing's mock platform (developer-app rules for Events, Media and Tools as in production) plus a local Chat WebSocket mock; none reaches the network",
"e2e": "npm run e2e runs media-uploader, event-subscriber (pull) and tool-job against the real platform with a sandbox app's OV_CLIENT_ID/OV_CLIENT_SECRET/OV_PROJECT_ID; on demand only, not in CI or npm test; not run against production as part of this release",
ddiff --git a/examples/browser-app/package.json b/examples/browser-app/package.json
iindex c4face4..5ae7bdd 100644
---- a/examples/browser-app/package.json
++++ b/examples/browser-app/package.json
@@@ -13,6 +13,6 @@
"node": ">=22"
},
"dependencies": {
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"
}
}
ddiff --git a/examples/event-subscriber/package.json b/examples/event-subscriber/package.json
iindex 52edb0d..e2c848e 100644
---- a/examples/event-subscriber/package.json
++++ b/examples/event-subscriber/package.json
@@@ -13,6 +13,6 @@
"node": ">=22"
},
"dependencies": {
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"
}
}
ddiff --git a/examples/media-uploader/package.json b/examples/media-uploader/package.json
iindex 0e3bd81..d4e5f37 100644
---- a/examples/media-uploader/package.json
++++ b/examples/media-uploader/package.json
@@@ -13,6 +13,6 @@
"node": ">=22"
},
"dependencies": {
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"
}
}
ddiff --git a/examples/mod-manifest/package.json b/examples/mod-manifest/package.json
iindex e4d8fba..aa011b9 100644
---- a/examples/mod-manifest/package.json
++++ b/examples/mod-manifest/package.json
@@@ -13,7 +13,7 @@
"node": ">=22"
},
"dependencies": {
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.28.0"
}
}
ddiff --git a/examples/node-server-app/package.json b/examples/node-server-app/package.json
iindex fde6263..c16023f 100644
---- a/examples/node-server-app/package.json
++++ b/examples/node-server-app/package.json
@@@ -13,6 +13,6 @@
"node": ">=22"
},
"dependencies": {
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"
}
}
ddiff --git a/examples/oauth-app/package.json b/examples/oauth-app/package.json
iindex 5886471..002dc9f 100644
---- a/examples/oauth-app/package.json
++++ b/examples/oauth-app/package.json
@@@ -13,6 +13,6 @@
"node": ">=22"
},
"dependencies": {
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"
}
}
ddiff --git a/examples/tool-job/package.json b/examples/tool-job/package.json
iindex aa2c350..90a3c05 100644
---- a/examples/tool-job/package.json
++++ b/examples/tool-job/package.json
@@@ -13,6 +13,6 @@
"node": ">=22"
},
"dependencies": {
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"
}
}
ddiff --git a/examples/webhook-consumer/README.md b/examples/webhook-consumer/README.md
iindex eb705e6..b3cf8ec 100644
---- a/examples/webhook-consumer/README.md
++++ b/examples/webhook-consumer/README.md
@@@ -1,7 +1,8 @@
# Webhook consumer
Receive OpenVibe.Events deliveries for your project's events over HTTPS, verify
--`X-OpenVibe-Signature`, and handle every event **exactly once** even though delivery is at least once.
++`X-OpenVibe-Signature-V2` (signature v2, with a replay window), and handle every event **exactly once**
++even though delivery is at least once.
```bash
node --env-file=.env subscribe.js https://hooks.example.com/webhooks/openvibe # app.<project_key>.*
@@@ -64,6 +65,12 @@ endpoints: a private address is `422 events.endpoint_not_allowed`).
Limits: a sandbox project may have 5 subscriptions. Sandbox subscriptions receive sandbox events
only. When the app is revoked or loses `events.app.subscribe`, Events disables its subscriptions.
--Signature scheme note: the HMAC covers the body only, with no timestamp, so a captured delivery
--can be replayed later. The inbox makes a replay harmless (it is a duplicate), which is why the
--inbox is not optional.
++Signature scheme note: every delivery carries `X-OpenVibe-Timestamp` and
++`X-OpenVibe-Signature-V2: t=<unix seconds>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">`, keyed with the
++subscription secret, besides the older body-only `X-OpenVibe-Signature` (v1). The consumer calls
++`parseDelivery(raw, headers, secret, { requireV2: true })`: it refuses a delivery whose `t` is more
++than ±300 s from its own clock, and it refuses a delivery with no v2 header, so a captured delivery
++cannot be replayed later with the v2 header stripped off. Keep the server's clock synced (NTP).
++Events signs every attempt afresh, so retries and replays from its dead-letter queue arrive inside
++the window. The inbox is still not optional: delivery is at least once, so a retry after a lost 2xx
++is a duplicate the inbox acknowledges without handling it again.
ddiff --git a/examples/webhook-consumer/package.json b/examples/webhook-consumer/package.json
iindex 56798c5..6069f23 100644
---- a/examples/webhook-consumer/package.json
++++ b/examples/webhook-consumer/package.json
@@@ -13,7 +13,7 @@
"node": ">=22"
},
"dependencies": {
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0",
"better-sqlite3": "^11.10.0"
}
}
ddiff --git a/examples/webhook-consumer/server.js b/examples/webhook-consumer/server.js
iindex a77fb6a..db5d787 100644
---- a/examples/webhook-consumer/server.js
++++ b/examples/webhook-consumer/server.js
@@@ -7,11 +7,14 @@
* node --env-file=.env server.js # POST http://localhost:3003/webhooks/openvibe
*
* A delivery is POSTed as { "event": <events.event-envelope@1>, "seq": <n> } with
-- * X-OpenVibe-Signature: sha256=<HMAC-SHA256 of the raw body with the subscription secret>.
++ * X-OpenVibe-Timestamp: <unix seconds> and
++ * X-OpenVibe-Signature-V2: t=<that timestamp>,v2=<HMAC-SHA256 of "<t>.<raw body>" with the subscription secret>
++ * (plus the older body-only X-OpenVibe-Signature, which this consumer does not accept on its own).
*
* 1. Read the RAW body (the signature covers the exact bytes; never re-serialize JSON first).
-- * 2. openvibe-sdk/events parseDelivery() checks the signature in constant time and parses it.
-- * A bad signature is 401 and nothing else is said.
++ * 2. openvibe-sdk/events parseDelivery(..., { requireV2: true }) checks the v2 signature in
++ * constant time, refuses a timestamp more than 300 s from this clock or a missing v2 header
++ * (a replayed capture), and parses it. A bad signature is 401 and nothing else is said.
* 3. Delivery is at least once (retries, replays). inbox.once() records the event id and runs
* the handler in ONE SQLite transaction, so a repeat is answered 200 without running it again,
* and a handler that throws leaves no receipt: the 500 makes Events retry later.
@@@ -68,7 +71,7 @@ function createConsumer(config, { db = openDatabase(config.dbPath), handle = ()
function verify(raw, headers) {
for (const secret of config.secrets) {
-- const d = parseDelivery(raw, headers, secret);
++ const d = parseDelivery(raw, headers, secret, { requireV2: true });
if (d) return d;
}
return null;
ddiff --git a/examples/webhook-consumer/test/smoke.test.js b/examples/webhook-consumer/test/smoke.test.js
iindex 5493d13..a1a911d 100644
---- a/examples/webhook-consumer/test/smoke.test.js
++++ b/examples/webhook-consumer/test/smoke.test.js
@@@ -11,7 +11,7 @@ const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { createMockPlatform } = require('openvibe-sdk/testing');
--const { signDelivery, projectKey, appSource } = require('openvibe-sdk/events');
++const { signDelivery, signDeliveryHeaders, projectKey, appSource } = require('openvibe-sdk/events');
const { createConsumer, loadConfig, openDatabase } = require('../server');
const { createSubscription, loadConfig: loadSubscribeConfig } = require('../subscribe');
@@@ -100,13 +100,16 @@ const ENDPOINT = 'https://hooks.example.com/webhooks/openvibe';
assert.deepEqual([round.delivered, round.attempts[0].attempt], [1, 2]);
assert.deepEqual(handled, [e1.event_id, e2.event_id]);
-- // Attacks: wrong secret, tampered body, missing signature: 401 and no side effect.
++ // Attacks: wrong secret, tampered body, missing signature, v1 only, stale v2: 401 and no side effect.
const last = sent.at(-1);
const e3 = appEvent('order.refunded');
const forgedBody = last.body.replaceAll(e2.event_id, e3.event_id);
assert.equal((await replay(last, { 'X-OpenVibe-Signature': signDelivery(forgedBody, 'whsec_not_the_secret'), 'X-OpenVibe-Event-Id': e3.event_id }, forgedBody)).status, 401);
assert.equal((await replay(last, { 'X-OpenVibe-Event-Id': e3.event_id }, forgedBody)).status, 401, 'changed bytes, old signature');
-- assert.equal((await replay(last, { 'X-OpenVibe-Signature': '' })).status, 401);
++ assert.equal((await replay(last, { 'X-OpenVibe-Signature': '', 'X-OpenVibe-Signature-V2': '' })).status, 401);
++ const { 'X-OpenVibe-Signature-V2': _v2, ...v1only } = last.headers;
++ assert.equal((await realFetch(local, { method: 'POST', headers: v1only, body: last.body })).status, 401, 'v1 only (v2 stripped): refused');
++ assert.equal((await replay(last, signDeliveryHeaders(last.body, created.secret, { now: Date.now() - 301000 }))).status, 401, 'stale v2 (outside the 300 s window): refused');
// Header and signed body disagree on the event id: refused.
assert.equal((await replay(last, { 'X-OpenVibe-Event-Id': e3.event_id })).status, 400);
assert.deepEqual(handled, [e1.event_id, e2.event_id]);
ddiff --git a/package-lock.json b/package-lock.json
iindex 0cba293..756c5d0 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -12,7 +12,7 @@
"examples/*"
],
"devDependencies": {
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"
},
"engines": {
"node": ">=22"
@@@ -23,7 +23,7 @@
"version": "0.2.0",
"license": "MIT",
"dependencies": {
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"
},
"engines": {
"node": ">=22"
@@@ -45,7 +45,7 @@
"version": "0.2.0",
"license": "MIT",
"dependencies": {
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"
},
"engines": {
"node": ">=22"
@@@ -56,7 +56,7 @@
"version": "0.2.0",
"license": "MIT",
"dependencies": {
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"
},
"engines": {
"node": ">=22"
@@@ -68,7 +68,7 @@
"license": "MIT",
"dependencies": {
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.28.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"
},
"engines": {
"node": ">=22"
@@@ -79,7 +79,7 @@
"version": "0.2.0",
"license": "MIT",
"dependencies": {
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"
},
"engines": {
"node": ">=22"
@@@ -90,7 +90,7 @@
"version": "0.2.0",
"license": "MIT",
"dependencies": {
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"
},
"engines": {
"node": ">=22"
@@@ -101,7 +101,7 @@
"version": "0.2.0",
"license": "MIT",
"dependencies": {
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"
},
"engines": {
"node": ">=22"
@@@ -113,7 +113,7 @@
"license": "MIT",
"dependencies": {
"better-sqlite3": "^11.10.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"
},
"engines": {
"node": ">=22"
@@@ -469,9 +469,9 @@
"link": true
},
"node_modules/openvibe-sdk": {
-- "version": "0.3.1",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1",
-- "integrity": "sha512-6fMz/kuIucCOYJW5CAUusHgK6rm5PhIC1/0IKmV87a4nAnEsjWk1h2JTX7HKw0JSO0O+9f/Fydj1Iwq08VnxGw==",
++ "version": "0.4.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0",
++ "integrity": "sha512-Hyz55YOMIs0xwCQ1NXsN0sUYhlbKXAX3uWY2fPCdEEL18FXUYVhK8wlG/5JMMypFHnjDiSd9VHzBNG12hWsmkQ==",
"license": "MIT",
"engines": {
"node": ">=20"
ddiff --git a/package.json b/package.json
iindex 490e461..b4b1680 100644
---- a/package.json
++++ b/package.json
@@@ -17,6 +17,6 @@
"node": ">=22"
},
"devDependencies": {
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"
}
}
ddiff --git a/test/structure.test.js b/test/structure.test.js
iindex c5304e3..7a0e8bd 100644
---- a/test/structure.test.js
++++ b/test/structure.test.js
@@@ -12,7 +12,7 @@ const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const EXPECTED = ['browser-app', 'chat-bot', 'event-subscriber', 'media-uploader', 'mod-manifest', 'node-server-app', 'oauth-app', 'tool-job', 'webhook-consumer'];
--const SDK = 'https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1';
++const SDK = 'https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0';
const dirs = fs.readdirSync(path.join(ROOT, 'examples')).filter((d) => fs.statSync(path.join(ROOT, 'examples', d)).isDirectory()).sort();
assert.deepEqual(dirs, EXPECTED, 'the nine charter examples');
@@@ -38,7 +38,7 @@ for (const name of EXPECTED) {
assert.equal(pkg.scripts.test, 'node test/smoke.test.js', `${name}: npm test runs the smoke test`);
for (const [dep, spec] of Object.entries({ ...pkg.dependencies, ...pkg.devDependencies })) {
assert.ok(!/^(file|link):/.test(spec), `${name}: ${dep} is not a local link`);
-- if (dep === 'openvibe-sdk') assert.equal(spec, SDK, `${name}: openvibe-sdk pinned to v0.3.1`);
++ if (dep === 'openvibe-sdk') assert.equal(spec, SDK, `${name}: openvibe-sdk pinned to v0.4.0`);
}
const readme = fs.readFileSync(path.join(dir, 'README.md'), 'utf8');