@react-foundry/fastify-consent-cookies 0.1.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,22 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (C) 2019-2025 Crown Copyright
4
+ Copyright (C) 2019-2026 Daniel A.C. Martin
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
7
+ this software and associated documentation files (the "Software"), to deal in
8
+ the Software without restriction, including without limitation the rights to
9
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10
+ of the Software, and to permit persons to whom the Software is furnished to do
11
+ so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,176 @@
1
+ React Foundry - Fastify Consent-Cookies
2
+ =======================================
3
+
4
+ Fastify plugin to parse and set cookies only with user consent. This
5
+ aids compliance with European regulations.
6
+
7
+ Also provides a session via a cookie. (Accessible by reading and writing
8
+ to `req.session`.)
9
+
10
+ Features
11
+ --------
12
+
13
+ - Essential cookies
14
+ - Optional cookies with opt-in system
15
+ - Sessions
16
+ - Encryption (except when `httpOnly` is set to false)
17
+ - Secure by default
18
+
19
+
20
+ Using this package
21
+ ------------------
22
+
23
+ First install the package into your project:
24
+
25
+ ```shell
26
+ npm install -S @react-foundry/fastify-consent-cookies
27
+ ```
28
+
29
+ Then use it in your code as follows:
30
+
31
+ ```ts
32
+ import Fastify from 'fastify';
33
+ import { type Cookie, type FastifyConsentCookiesOptions, fastifyConsentCookies } from '@react-foundry/fastify-consent-cookies';
34
+
35
+ const myCookies = [
36
+ {
37
+ name: 'ga',
38
+ description: 'Enables us to track you use of the site and helps us to optimise your experience.',
39
+ group: 'Analytics',
40
+ httpOnly: false
41
+ },
42
+ {
43
+ name: 'seen,
44
+ description: 'Allows us to know whether you have visited the site before.',
45
+ group: 'Analytics'
46
+ }
47
+ ];
48
+
49
+ const httpd = Fastify();
50
+
51
+ httpd.register(fastifyConsentCookies, {
52
+ cookies: myCookies,
53
+ secret: 'changeme' // Change this to a secret string that is shared across instances of your application
54
+ });
55
+
56
+ httpd.get('/', async (req, reply) => {
57
+ const seen = req.cookies['seen'] || false;
58
+ const message = (
59
+ seen
60
+ ? 'Hello!'
61
+ : 'Welcome back.'
62
+ );
63
+
64
+ reply.setCookie('seen', true);
65
+
66
+ return message;
67
+ });
68
+
69
+ await httpd.listen({
70
+ host: '::'
71
+ port: 8080
72
+ });
73
+ ```
74
+
75
+ Route handlers and subsequent hooks will be able to discover the cookies
76
+ available for use via `req.cookiesMeta` as well as the current cookies of
77
+ enabled cookies via `req.cookies`. New cookies can be set with
78
+ `reply.setCookie()`.
79
+
80
+
81
+ ### `fastifyConsentCookies`
82
+
83
+ A Fastify plugin which can be 'registered' with the following options.
84
+
85
+ Options object:
86
+
87
+ - **`cookies: object`**
88
+ A description of all the cookies your application uses. Only cookies
89
+ described here will be available for reading and writing.
90
+ - **`name: string`**
91
+ The name of the cookie.
92
+ - **`description: string`**
93
+ A description of the cookies purpose. (To be shown to the user.)
94
+ - **`group?: string`**
95
+ The category that an optional cookie belongs to. e.g. 'Analytics'. If
96
+ the cookie is mandatory, do not define this and the cookie will
97
+ always be available.
98
+ - **`httpOnly?: boolean = true`**
99
+ Whether the cookie can be accessed on the client. Unlike other cookie
100
+ options, this _must_ be defined here and cannot be provided later,
101
+ when setting the cookie. This is because consent-cookies will encrypt
102
+ all httpOnly cookies as a security precaution and so needs to know
103
+ whether to decrypt them when reading them.
104
+ - **Other `cookie.serialize()` options besides `encode`**
105
+ See: https://www.npmjs.com/package/cookie
106
+ - **`secret: string`**
107
+ A secret that is used to encrypt your cookies. You should ensure that
108
+ you set this in production to ensure that they cannot be decrypted by
109
+ an attacker. If you horizontally scale your application, you must
110
+ ensure that the secret is shared between each instance, so that they
111
+ can decrypt cookies that were set by other instances.
112
+ - **`cookie.serialize()` options besides `encode` and `httpOnly`**
113
+ These will become the defaults within your application. Note that we
114
+ set some sensible defaults for your with a 'secure by default' mindset,
115
+ so you should only need to provide these options in order to loosen up
116
+ the security.
117
+ See: https://www.npmjs.com/package/cookie
118
+
119
+
120
+ ### `reply.setCookie(name: string, value: any[, options: object])`
121
+
122
+ Sets a cookie.
123
+
124
+ - **`name: string`**
125
+ The name of the cookie you wish to set.
126
+ **Note:** If this cookie has not been consented to and is not
127
+ mandatory, an exception will be thrown.
128
+ - **`value: any`**
129
+ The value you wish to set for the cookie. This can be anything that
130
+ `JSON.stringify` can serialise but must fit within the size limits of
131
+ cookies.
132
+ - **`options: object`**
133
+ Any `cookie.serialize()` option besides `encode` and `httpOnly`.
134
+ (To set `httpOnly` you must declare it when initially describing the
135
+ cookie.)
136
+ See: https://www.npmjs.com/package/cookie
137
+
138
+
139
+ ### `req.cookies`
140
+
141
+ An object containing the data from all active cookies.
142
+
143
+
144
+ ### `req.cookiesMeta`
145
+
146
+ An object that contains a description of all supported cookies and
147
+ whether the user has consented to them.
148
+
149
+
150
+ Working on this package
151
+ -----------------------
152
+
153
+ Before working on this package you must install its dependencies using
154
+ the following command:
155
+
156
+ ```shell
157
+ pnpm install
158
+ ```
159
+
160
+
161
+ ### Building
162
+
163
+ Build the package by compiling the source code.
164
+
165
+ ```shell
166
+ npm run build
167
+ ```
168
+
169
+
170
+ ### Clean-up
171
+
172
+ Remove any previously built files.
173
+
174
+ ```shell
175
+ npm run clean
176
+ ```
@@ -0,0 +1,25 @@
1
+ import type { SerializeOptions } from 'cookie';
2
+ import type { FastifyInstance, FastifyRequest as _Request, FastifyReply as _Reply } from 'fastify';
3
+ import type { Promised } from '@react-foundry/types-helpers';
4
+ export type CookieOptions = Omit<SerializeOptions, 'encode'>;
5
+ export type SetCookie = (this: ReplyFull, name: string, value: any, options?: Omit<CookieOptions, 'httpOnly'>) => void;
6
+ export type SetCookieConsent = (this: ReplyFull, value: string[]) => void;
7
+ export type RequestExtras = {
8
+ cookies: Record<string, Cookie>;
9
+ cookiesMeta: object;
10
+ };
11
+ export type Request = _Request & Partial<RequestExtras>;
12
+ export type RequestFull = Request & RequestExtras;
13
+ type ReplyExtras = {
14
+ setCookie: SetCookie;
15
+ setCookieConsent: SetCookieConsent;
16
+ };
17
+ export type Reply = _Reply & Partial<ReplyExtras>;
18
+ export type ReplyFull = _Reply & ReplyExtras;
19
+ export type RouteHandlerMethod = (this: FastifyInstance, request: RequestFull, reply: ReplyFull) => Promised<unknown | void>;
20
+ export type Cookie = CookieOptions & {
21
+ name: string;
22
+ description: string;
23
+ group?: string;
24
+ };
25
+ export {};
package/dist/common.js ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ import type { FastifyPluginCallback } from 'fastify';
2
+ import type { Cookie, CookieOptions } from './common';
3
+ export type FastifyConsentCookiePluginOptions = CookieOptions & {
4
+ cookies: Cookie[];
5
+ secret: string;
6
+ };
7
+ export declare const defaultSecret = "changeme";
8
+ export declare const fastifyConsentCookies: FastifyPluginCallback<FastifyConsentCookiePluginOptions>;
9
+ export default fastifyConsentCookies;
10
+ export type { FastifyConsentCookiePluginOptions as FastifyConsentCookiesOptions };
11
+ export type { Cookie, CookieOptions, RouteHandlerMethod, Request, RequestFull, Reply, ReplyFull } from './common';
package/dist/index.js ADDED
@@ -0,0 +1,128 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.fastifyConsentCookies = exports.defaultSecret = void 0;
7
+ const cookie_1 = __importDefault(require("cookie"));
8
+ const cryptr_1 = __importDefault(require("cryptr"));
9
+ const fastify_plugin_1 = __importDefault(require("fastify-plugin"));
10
+ const consentCookie = {
11
+ name: 'consent',
12
+ description: 'A store of the cookies that you have consented to.',
13
+ httpOnly: false,
14
+ sameSite: 'lax'
15
+ };
16
+ const id = (v) => v;
17
+ const encodeClear = (v) => JSON.stringify(v);
18
+ const decodeClear = (v, req) => {
19
+ try {
20
+ return JSON.parse(v);
21
+ }
22
+ catch (e) {
23
+ const log = (req?.log || console);
24
+ log.warn('Unable to parse cookie data as JSON');
25
+ return undefined;
26
+ }
27
+ };
28
+ const joinSet = (v, s) => Array.from(v).join(s);
29
+ exports.defaultSecret = 'changeme';
30
+ const fastifyConsentCookiesPlugin = async (fastify, { cookies: _cookies, secret = exports.defaultSecret, ...defaults }) => {
31
+ if (secret === exports.defaultSecret) {
32
+ fastify.log.warn('Cookie secret has not been set; cookies could be decrypted!');
33
+ }
34
+ const cryptr = new cryptr_1.default(secret, { encoding: 'base64' });
35
+ const encodeSecure = (v) => cryptr.encrypt(encodeClear(v));
36
+ const decodeSecure = (v, req) => {
37
+ try {
38
+ return decodeClear(cryptr.decrypt(v), req);
39
+ }
40
+ catch (e) {
41
+ const log = (req || fastify).log;
42
+ log.warn('Unable to decrypt cookie data');
43
+ return undefined;
44
+ }
45
+ };
46
+ const cookies = ([
47
+ consentCookie,
48
+ ..._cookies
49
+ ]).filter(id);
50
+ fastify.decorateRequest('cookiesMeta');
51
+ fastify.decorateRequest('cookies');
52
+ fastify.decorateReply('setCookie');
53
+ fastify.decorateReply('setCookieConsent');
54
+ fastify.addHook('preHandler', async (req, reply) => {
55
+ const cookieData = cookie_1.default.parse(req.headers.cookie || '');
56
+ const _consent = cookieData[consentCookie.name];
57
+ const consent = _consent || '';
58
+ const active = (cookies
59
+ .filter(e => e.group === undefined || consent.includes(e.name))
60
+ .reduce((acc, { name, ...cur }) => ({
61
+ ...acc,
62
+ [name]: cur
63
+ }), {}));
64
+ const decryptedCookies = (Object.keys(cookieData)
65
+ .filter(e => active[e])
66
+ .reduce((acc, cur) => ({
67
+ ...acc,
68
+ [cur]: (active[cur].httpOnly === false
69
+ ? decodeClear(cookieData[cur])
70
+ : decodeSecure(cookieData[cur]))
71
+ }), {}));
72
+ const foundCookieNames = new Set(Object.keys(cookieData));
73
+ const activeNames = new Set(Object.keys(active));
74
+ const cookieNames = new Set(Object.keys(decryptedCookies));
75
+ const rejected = foundCookieNames.difference(cookieNames);
76
+ req.log.debug(`${foundCookieNames.size} cookies found on request; ${joinSet(foundCookieNames, ', ')}`);
77
+ req.log.debug(`${activeNames.size} cookies are active; ${joinSet(activeNames, ', ')}`);
78
+ req.log.debug(`${cookieNames.size} cookies are available; ${joinSet(cookieNames, ', ')}`);
79
+ if (rejected.size) {
80
+ req.log.warn(`${rejected.size} cookies were rejected; ${joinSet(rejected, ', ')}`);
81
+ }
82
+ req.cookiesMeta = cookies.map(({ name, description, group }) => ({
83
+ name,
84
+ description,
85
+ group,
86
+ consent: consent.includes(name)
87
+ }));
88
+ req.cookies = decryptedCookies;
89
+ const setCookie = function (name, value, options) {
90
+ if (active[name]) {
91
+ const { description, group, httpOnly: _httpOnly, ...declaration } = active[name];
92
+ const httpOnly = _httpOnly !== false;
93
+ const content = (!httpOnly
94
+ ? encodeClear(value)
95
+ : encodeSecure(value));
96
+ const size = content.length;
97
+ const maxSize = 4096;
98
+ if (size > maxSize) {
99
+ const overrun = size - maxSize;
100
+ req.log.warn(`Attempting to set cookie, '${name}', which is ${overrun} bytes larger than allowed (4kiB) and likely to be rejected`);
101
+ }
102
+ this.header('Set-Cookie', cookie_1.default.serialize(name, content, {
103
+ path: '/',
104
+ domain: undefined,
105
+ sameSite: 'strict',
106
+ secure: true,
107
+ ...defaults,
108
+ ...declaration,
109
+ ...options,
110
+ httpOnly
111
+ }));
112
+ }
113
+ else {
114
+ throw new Error(`No consent for cookie, "${name}".`);
115
+ }
116
+ };
117
+ const setCookieConsent = function (value) {
118
+ this.setCookie(consentCookie.name, value);
119
+ };
120
+ reply.setCookie = setCookie;
121
+ reply.setCookieConsent = setCookieConsent;
122
+ });
123
+ };
124
+ exports.fastifyConsentCookies = (0, fastify_plugin_1.default)(fastifyConsentCookiesPlugin, {
125
+ fastify: '5.x',
126
+ name: 'consent-cookies',
127
+ });
128
+ exports.default = exports.fastifyConsentCookies;
package/dist/index.mjs ADDED
@@ -0,0 +1,122 @@
1
+ import cookie from 'cookie';
2
+ import Cryptr from 'cryptr';
3
+ import fp from 'fastify-plugin';
4
+ const consentCookie = {
5
+ name: 'consent',
6
+ description: 'A store of the cookies that you have consented to.',
7
+ httpOnly: false,
8
+ sameSite: 'lax'
9
+ };
10
+ const id = (v) => v;
11
+ const encodeClear = (v) => JSON.stringify(v);
12
+ const decodeClear = (v, req) => {
13
+ try {
14
+ return JSON.parse(v);
15
+ }
16
+ catch (e) {
17
+ const log = (req?.log || console);
18
+ log.warn('Unable to parse cookie data as JSON');
19
+ return undefined;
20
+ }
21
+ };
22
+ const joinSet = (v, s) => Array.from(v).join(s);
23
+ export const defaultSecret = 'changeme';
24
+ const fastifyConsentCookiesPlugin = async (fastify, { cookies: _cookies, secret = defaultSecret, ...defaults }) => {
25
+ if (secret === defaultSecret) {
26
+ fastify.log.warn('Cookie secret has not been set; cookies could be decrypted!');
27
+ }
28
+ const cryptr = new Cryptr(secret, { encoding: 'base64' });
29
+ const encodeSecure = (v) => cryptr.encrypt(encodeClear(v));
30
+ const decodeSecure = (v, req) => {
31
+ try {
32
+ return decodeClear(cryptr.decrypt(v), req);
33
+ }
34
+ catch (e) {
35
+ const log = (req || fastify).log;
36
+ log.warn('Unable to decrypt cookie data');
37
+ return undefined;
38
+ }
39
+ };
40
+ const cookies = ([
41
+ consentCookie,
42
+ ..._cookies
43
+ ]).filter(id);
44
+ fastify.decorateRequest('cookiesMeta');
45
+ fastify.decorateRequest('cookies');
46
+ fastify.decorateReply('setCookie');
47
+ fastify.decorateReply('setCookieConsent');
48
+ fastify.addHook('preHandler', async (req, reply) => {
49
+ const cookieData = cookie.parse(req.headers.cookie || '');
50
+ const _consent = cookieData[consentCookie.name];
51
+ const consent = _consent || '';
52
+ const active = (cookies
53
+ .filter(e => e.group === undefined || consent.includes(e.name))
54
+ .reduce((acc, { name, ...cur }) => ({
55
+ ...acc,
56
+ [name]: cur
57
+ }), {}));
58
+ const decryptedCookies = (Object.keys(cookieData)
59
+ .filter(e => active[e])
60
+ .reduce((acc, cur) => ({
61
+ ...acc,
62
+ [cur]: (active[cur].httpOnly === false
63
+ ? decodeClear(cookieData[cur])
64
+ : decodeSecure(cookieData[cur]))
65
+ }), {}));
66
+ const foundCookieNames = new Set(Object.keys(cookieData));
67
+ const activeNames = new Set(Object.keys(active));
68
+ const cookieNames = new Set(Object.keys(decryptedCookies));
69
+ const rejected = foundCookieNames.difference(cookieNames);
70
+ req.log.debug(`${foundCookieNames.size} cookies found on request; ${joinSet(foundCookieNames, ', ')}`);
71
+ req.log.debug(`${activeNames.size} cookies are active; ${joinSet(activeNames, ', ')}`);
72
+ req.log.debug(`${cookieNames.size} cookies are available; ${joinSet(cookieNames, ', ')}`);
73
+ if (rejected.size) {
74
+ req.log.warn(`${rejected.size} cookies were rejected; ${joinSet(rejected, ', ')}`);
75
+ }
76
+ req.cookiesMeta = cookies.map(({ name, description, group }) => ({
77
+ name,
78
+ description,
79
+ group,
80
+ consent: consent.includes(name)
81
+ }));
82
+ req.cookies = decryptedCookies;
83
+ const setCookie = function (name, value, options) {
84
+ if (active[name]) {
85
+ const { description, group, httpOnly: _httpOnly, ...declaration } = active[name];
86
+ const httpOnly = _httpOnly !== false;
87
+ const content = (!httpOnly
88
+ ? encodeClear(value)
89
+ : encodeSecure(value));
90
+ const size = content.length;
91
+ const maxSize = 4096;
92
+ if (size > maxSize) {
93
+ const overrun = size - maxSize;
94
+ req.log.warn(`Attempting to set cookie, '${name}', which is ${overrun} bytes larger than allowed (4kiB) and likely to be rejected`);
95
+ }
96
+ this.header('Set-Cookie', cookie.serialize(name, content, {
97
+ path: '/',
98
+ domain: undefined,
99
+ sameSite: 'strict',
100
+ secure: true,
101
+ ...defaults,
102
+ ...declaration,
103
+ ...options,
104
+ httpOnly
105
+ }));
106
+ }
107
+ else {
108
+ throw new Error(`No consent for cookie, "${name}".`);
109
+ }
110
+ };
111
+ const setCookieConsent = function (value) {
112
+ this.setCookie(consentCookie.name, value);
113
+ };
114
+ reply.setCookie = setCookie;
115
+ reply.setCookieConsent = setCookieConsent;
116
+ });
117
+ };
118
+ export const fastifyConsentCookies = fp(fastifyConsentCookiesPlugin, {
119
+ fastify: '5.x',
120
+ name: 'consent-cookies',
121
+ });
122
+ export default fastifyConsentCookies;
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@react-foundry/fastify-consent-cookies",
3
+ "version": "0.1.0",
4
+ "description": "Fastify plugin to parse and set cookies only with user consent.",
5
+ "main": "dist/index.js",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "import": "./dist/index.mjs",
10
+ "require": "./dist/index.js",
11
+ "default": "./dist/index.mjs"
12
+ }
13
+ },
14
+ "files": [
15
+ "/dist"
16
+ ],
17
+ "author": "Daniel A.C. Martin <npm@daniel-martin.co.uk> (http://daniel-martin.co.uk/)",
18
+ "license": "MIT",
19
+ "engines": {
20
+ "node": ">=22.0.0"
21
+ },
22
+ "devDependencies": {
23
+ "fastify": "5.7.1",
24
+ "jest": "30.2.0",
25
+ "jest-environment-jsdom": "30.2.0",
26
+ "ts-jest": "29.4.6",
27
+ "typescript": "5.9.3",
28
+ "@react-foundry/types-helpers": "0.1.0"
29
+ },
30
+ "dependencies": {
31
+ "cookie": "^1.1.1",
32
+ "cryptr": "^6.4.0",
33
+ "fastify-plugin": "^5.1.0"
34
+ },
35
+ "scripts": {
36
+ "test": "NODE_OPTIONS=--experimental-vm-modules jest",
37
+ "build": "npm run build:esm && npm run build:cjs",
38
+ "build:esm": "tsc -m es2022 && find dist -name '*.js' -exec sh -c 'mv \"$0\" \"${0%.js}.mjs\"' {} \\;",
39
+ "build:cjs": "tsc",
40
+ "clean": "rm -rf dist tsconfig.tsbuildinfo"
41
+ },
42
+ "module": "dist/index.mjs",
43
+ "typings": "dist/index.d.ts"
44
+ }