@alxia/secure-headers 0.2.0 → 0.3.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/README.md +23 -6
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -17
- package/dist/index.js.map +4 -4
- package/dist/nonce.d.ts +1 -8
- package/dist/nonce.d.ts.map +1 -1
- package/dist/secure-headers.d.ts +18 -9
- package/dist/secure-headers.d.ts.map +1 -1
- package/docs/README.md +1 -1
- package/docs/guide.md +85 -64
- package/docs/roadmap.md +6 -2
- package/docs/troubleshooting.md +143 -67
- package/package.json +3 -4
package/README.md
CHANGED
|
@@ -18,14 +18,16 @@ app.use(secureHeaders());
|
|
|
18
18
|
app.use(secureHeaders({ contentSecurityPolicy: "default-src 'self'", xFrameOptions: false }));
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
it
|
|
21
|
+
`secureHeaders()` is a middleware: every response that comes back through
|
|
22
|
+
it carries the headers, a 404's, an error's and a refused preflight's
|
|
23
|
+
included. A header a route sets itself is kept: a page that needs its own
|
|
24
|
+
policy sets it on its reply.
|
|
23
25
|
|
|
24
26
|
## A nonce per request
|
|
25
27
|
|
|
26
28
|
`nonce: true` makes a fresh nonce for each request (128 random bits,
|
|
27
29
|
base64), puts it in the policy as `'nonce-…'`, and gives it to the routes
|
|
28
|
-
declared after the
|
|
30
|
+
declared after the middleware as `ctx.nonce`. The header and the context always
|
|
29
31
|
carry the same one, so a page's inline scripts need no `'unsafe-inline'`.
|
|
30
32
|
|
|
31
33
|
```ts
|
|
@@ -94,15 +96,30 @@ value is refused at startup. `nonce` is off by default.
|
|
|
94
96
|
|
|
95
97
|
| export | |
|
|
96
98
|
| --- | --- |
|
|
97
|
-
| `secureHeaders(options?)` | the
|
|
99
|
+
| `secureHeaders(options?)` | the middleware, for `app.use`: a `SecureHeaders`, or with `nonce: true` a `NonceMiddleware` |
|
|
98
100
|
| `SecureHeadersOptions` | its options: the headers and `hidePoweredBy`; `nonce` is added by each overload |
|
|
99
101
|
| `Setting` | a header option's type: its value, or `false` to leave it out |
|
|
100
102
|
| `NONCE` | where the nonce goes in `contentSecurityPolicy`, each time it is named |
|
|
101
103
|
| `NonceContext` | what `nonce: true` adds to the context: `nonce`, a string |
|
|
102
|
-
| `
|
|
104
|
+
| `SecureHeaders` | what `secureHeaders()` returns: a middleware that adds nothing to the context |
|
|
105
|
+
| `NonceMiddleware` | what `secureHeaders({ nonce: true })` returns: a middleware adding `NonceContext` |
|
|
106
|
+
| `NoncePlugin` | deprecated: the former name of `NonceMiddleware` |
|
|
107
|
+
|
|
108
|
+
## Traps
|
|
109
|
+
|
|
110
|
+
`use` it first: a route declared before `app.use(secureHeaders())` gets no
|
|
111
|
+
header, and `ctx.nonce` exists only on routes declared after it. It
|
|
112
|
+
settles `next()` without swallowing the error: a `try`/`catch` middleware
|
|
113
|
+
catches it wherever it stands, but only one declared after
|
|
114
|
+
`secureHeaders()` has the headers on its reply. Declare error-handling
|
|
115
|
+
middleware after it.
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
const app = alxia().use(secureHeaders()).use(errorHandler).get(...);
|
|
119
|
+
```
|
|
103
120
|
|
|
104
121
|
## Documentation
|
|
105
122
|
|
|
106
|
-
- [Guide](https://github.com/softistx/alxia/tree/develop/packages/secure-headers/docs): every option and its default, the per-request nonce, which responses get the headers, a page's own policy, the order with other
|
|
123
|
+
- [Guide](https://github.com/softistx/alxia/tree/develop/packages/secure-headers/docs): every option and its default, the per-request nonce, which responses get the headers, a page's own policy, the order with other middlewares, and recipes.
|
|
107
124
|
- [Troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/secure-headers/docs/troubleshooting.md): an error message or a browser refusal, and what to do about it.
|
|
108
125
|
- [Roadmap](https://github.com/softistx/alxia/blob/develop/packages/secure-headers/docs/roadmap.md): what is coming, and what is not planned.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { NONCE, type NonceContext } from './nonce';
|
|
2
|
-
export { type NoncePlugin, type SecureHeadersOptions, type Setting, secureHeaders, } from './secure-headers';
|
|
2
|
+
export { type NonceMiddleware, type NoncePlugin, type SecureHeaders, type SecureHeadersOptions, type Setting, secureHeaders, } from './secure-headers';
|
|
3
3
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,KAAK,YAAY,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EACN,KAAK,WAAW,EAChB,KAAK,oBAAoB,EACzB,KAAK,OAAO,EACZ,aAAa,GACb,MAAM,kBAAkB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,KAAK,YAAY,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EACN,KAAK,eAAe,EACpB,KAAK,WAAW,EAChB,KAAK,aAAa,EAClB,KAAK,oBAAoB,EACzB,KAAK,OAAO,EACZ,aAAa,GACb,MAAM,kBAAkB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -16,20 +16,10 @@ function freshNonce() {
|
|
|
16
16
|
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
17
17
|
return btoa(String.fromCharCode(...bytes));
|
|
18
18
|
}
|
|
19
|
-
function nonceStore() {
|
|
20
|
-
const nonces = new WeakMap;
|
|
21
|
-
return (url) => {
|
|
22
|
-
let nonce = nonces.get(url);
|
|
23
|
-
if (nonce === undefined) {
|
|
24
|
-
nonce = freshNonce();
|
|
25
|
-
nonces.set(url, nonce);
|
|
26
|
-
}
|
|
27
|
-
return nonce;
|
|
28
|
-
};
|
|
29
|
-
}
|
|
30
19
|
// src/secure-headers.ts
|
|
31
20
|
import {
|
|
32
|
-
|
|
21
|
+
defineMiddleware,
|
|
22
|
+
settle,
|
|
33
23
|
withHeaders
|
|
34
24
|
} from "@alxia/core";
|
|
35
25
|
var DEFAULTS = {
|
|
@@ -69,17 +59,19 @@ function secureHeaders(options = {}) {
|
|
|
69
59
|
throw new TypeError("secureHeaders: contentSecurityPolicy names NONCE, but nonce is off: give nonce: true");
|
|
70
60
|
}
|
|
71
61
|
const set = setter(headers, hide);
|
|
72
|
-
return (
|
|
62
|
+
return defineMiddleware(async (ctx, next) => set(await settle(ctx, next())));
|
|
73
63
|
}
|
|
74
64
|
if (policy === undefined) {
|
|
75
65
|
throw new TypeError("secureHeaders: nonce is on, but contentSecurityPolicy is false: a nonce is only read through the content-security-policy header");
|
|
76
66
|
}
|
|
77
67
|
headers.delete("content-security-policy");
|
|
78
68
|
const withNonce = policyWithNonce(policy);
|
|
79
|
-
const nonceOf = nonceStore();
|
|
80
69
|
const set = setter(headers, hide);
|
|
81
|
-
|
|
82
|
-
|
|
70
|
+
return defineMiddleware(async (ctx, next) => {
|
|
71
|
+
const added = { nonce: freshNonce() };
|
|
72
|
+
const response = await settle(ctx, next(added));
|
|
73
|
+
return set(response, withNonce(added.nonce));
|
|
74
|
+
});
|
|
83
75
|
}
|
|
84
76
|
function fixedHeaders(options) {
|
|
85
77
|
const headers = new Map;
|
|
@@ -114,5 +106,5 @@ export {
|
|
|
114
106
|
secureHeaders
|
|
115
107
|
};
|
|
116
108
|
|
|
117
|
-
//# debugId=
|
|
109
|
+
//# debugId=73869EA605B3C65B64756E2164756E21
|
|
118
110
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/nonce.ts", "../src/secure-headers.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"/**\n * The per-request nonce of `secureHeaders({ nonce: true })`: where it goes\n * in the policy, how one is made
|
|
6
|
-
"import {\n\
|
|
5
|
+
"/**\n * The per-request nonce of `secureHeaders({ nonce: true })`: where it goes\n * in the policy, and how one is made.\n */\n\n/**\n * Where the nonce goes in `contentSecurityPolicy`: each occurrence becomes\n * `'nonce-<value>'`, the request's own value. Without it, `nonce: true`\n * adds the nonce to `script-src` and `script-src-elem`.\n *\n * ```ts\n * secureHeaders({\n * nonce: true,\n * contentSecurityPolicy: `default-src 'self'; style-src 'self' ${NONCE}`,\n * });\n * ```\n */\nexport const NONCE = \"'nonce-{alxia}'\";\n\n/** What `secureHeaders({ nonce: true })` adds to the context of the routes after it. */\nexport interface NonceContext {\n\t/**\n\t * This request's nonce, base64, the one its `Content-Security-Policy`\n\t * allows: what a page puts on its `<script nonce>`.\n\t */\n\treadonly nonce: string;\n}\n\n/** The directives the nonce is added to when the policy does not place it. */\nconst SCRIPT_DIRECTIVES = new Set(['script-src', 'script-src-elem']);\n\n/**\n * The policy as a function of the nonce: split where `NONCE` stands, or\n * where it is added to the script directives. Throws when there is\n * nowhere to put it.\n */\nexport function policyWithNonce(policy: string): (nonce: string) => string {\n\tconst placed = policy.includes(NONCE)\n\t\t? policy\n\t\t: policy\n\t\t\t\t.split(';')\n\t\t\t\t.map((directive) => {\n\t\t\t\t\tconst name = directive.trim().split(/\\s+/)[0]?.toLowerCase() ?? '';\n\t\t\t\t\treturn SCRIPT_DIRECTIVES.has(name)\n\t\t\t\t\t\t? `${directive.trimEnd()} ${NONCE}`\n\t\t\t\t\t\t: directive;\n\t\t\t\t})\n\t\t\t\t.join(';');\n\tif (!placed.includes(NONCE)) {\n\t\tthrow new TypeError(\n\t\t\t`secureHeaders: nonce is on, but the content-security-policy has no script-src to add it to: write one, or place NONCE where the nonce goes (the policy is \"${policy}\")`,\n\t\t);\n\t}\n\tconst parts = placed.split(NONCE);\n\treturn (nonce) => parts.join(`'nonce-${nonce}'`);\n}\n\n/** 128 random bits, base64: a fresh nonce. */\nexport function freshNonce(): string {\n\tconst bytes = crypto.getRandomValues(new Uint8Array(16));\n\treturn btoa(String.fromCharCode(...bytes));\n}\n",
|
|
6
|
+
"import {\n\tdefineMiddleware,\n\ttype Empty,\n\ttype Middleware,\n\ttype MiddlewareMark,\n\ttype Next,\n\tsettle,\n\twithHeaders,\n} from '@alxia/core';\nimport { freshNonce, NONCE, type NonceContext, policyWithNonce } from './nonce';\n\n/** A header's value, or `false` to leave it out. An empty value is refused. */\nexport type Setting = string | false;\n\nexport interface SecureHeadersOptions {\n\t/** Defaults to a policy for an API: nothing loads, nothing frames it. */\n\treadonly contentSecurityPolicy?: Setting;\n\treadonly strictTransportSecurity?: Setting;\n\treadonly xContentTypeOptions?: Setting;\n\treadonly xFrameOptions?: Setting;\n\treadonly referrerPolicy?: Setting;\n\treadonly crossOriginOpenerPolicy?: Setting;\n\treadonly crossOriginResourcePolicy?: Setting;\n\treadonly crossOriginEmbedderPolicy?: Setting;\n\treadonly originAgentCluster?: Setting;\n\treadonly xDnsPrefetchControl?: Setting;\n\treadonly xPermittedCrossDomainPolicies?: Setting;\n\t/** Off by default: a policy is the app's to write. */\n\treadonly permissionsPolicy?: Setting;\n\t/** Whether `X-Powered-By` and `Server` are removed. On by default. */\n\treadonly hidePoweredBy?: boolean;\n}\n\n/**\n * The nonce switch, beside `SecureHeadersOptions` rather than in it, so that\n * options typed by that interface still pick the plugin without a nonce.\n */\ninterface NonceOption {\n\t/**\n\t * A fresh nonce for each request, in `contentSecurityPolicy` and on the\n\t * context as `nonce`. Placed where the policy names `NONCE`, or added to\n\t * its `script-src`. Off by default.\n\t */\n\treadonly nonce?: boolean;\n}\n\n/**\n * What `secureHeaders()` returns: a middleware, for `app.use`, that sets\n * the headers on every response after it.\n */\nexport type SecureHeaders = Middleware<Empty, Promise<Response>> &\n\tMiddlewareMark;\n\n/**\n * What `secureHeaders({ nonce: true })` returns: a middleware, for\n * `app.use`, whose routes after it read `nonce`, and which sets the header\n * with the same one.\n */\nexport type NonceMiddleware = Middleware<Empty, Promise<Next<NonceContext>>> &\n\tMiddlewareMark;\n\n/** @deprecated Renamed `NonceMiddleware`: it is a middleware. */\nexport type NoncePlugin = NonceMiddleware;\n\nconst DEFAULTS = {\n\t'content-security-policy':\n\t\t\"default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'\",\n\t'strict-transport-security': 'max-age=31536000; includeSubDomains',\n\t'x-content-type-options': 'nosniff',\n\t'x-frame-options': 'DENY',\n\t'referrer-policy': 'no-referrer',\n\t'cross-origin-opener-policy': 'same-origin',\n\t'cross-origin-resource-policy': 'same-origin',\n\t'cross-origin-embedder-policy': false,\n\t'origin-agent-cluster': '?1',\n\t'x-dns-prefetch-control': 'off',\n\t'x-permitted-cross-domain-policies': 'none',\n\t'permissions-policy': false,\n} as const satisfies Record<string, Setting>;\n\nconst OPTION: Record<keyof typeof DEFAULTS, keyof SecureHeadersOptions> = {\n\t'content-security-policy': 'contentSecurityPolicy',\n\t'strict-transport-security': 'strictTransportSecurity',\n\t'x-content-type-options': 'xContentTypeOptions',\n\t'x-frame-options': 'xFrameOptions',\n\t'referrer-policy': 'referrerPolicy',\n\t'cross-origin-opener-policy': 'crossOriginOpenerPolicy',\n\t'cross-origin-resource-policy': 'crossOriginResourcePolicy',\n\t'cross-origin-embedder-policy': 'crossOriginEmbedderPolicy',\n\t'origin-agent-cluster': 'originAgentCluster',\n\t'x-dns-prefetch-control': 'xDnsPrefetchControl',\n\t'x-permitted-cross-domain-policies': 'xPermittedCrossDomainPolicies',\n\t'permissions-policy': 'permissionsPolicy',\n};\n\n/**\n * Secure headers on every response, as a middleware: each response of the\n * routes after it and of a request no route matches — a 404, an error —\n * gets them. A header a route set itself is kept: a page that needs its\n * own `Content-Security-Policy` — `@alxia/graphql`'s IDE — sets it.\n *\n * ```ts\n * app.use(secureHeaders({ contentSecurityPolicy: \"default-src 'self'\" }));\n * ```\n *\n * With `nonce: true`, each request gets a nonce of its own, in the policy\n * and on the context of the routes declared after it:\n *\n * ```ts\n * app\n * .use(secureHeaders({ nonce: true, contentSecurityPolicy: \"script-src 'self'\" }))\n * .get('/', ({ nonce, reply }) => reply(200, `<script nonce=\"${nonce}\">…</script>`));\n * ```\n */\nexport function secureHeaders(\n\toptions: SecureHeadersOptions & { readonly nonce: true },\n): NonceMiddleware;\nexport function secureHeaders(\n\toptions?: SecureHeadersOptions & { readonly nonce?: false },\n): SecureHeaders;\nexport function secureHeaders(\n\toptions: SecureHeadersOptions & NonceOption = {},\n): SecureHeaders | NonceMiddleware {\n\tconst headers = fixedHeaders(options);\n\tconst hide = options.hidePoweredBy ?? true;\n\tconst policy = headers.get('content-security-policy');\n\tif (!options.nonce) {\n\t\tif (policy?.includes(NONCE)) {\n\t\t\tthrow new TypeError(\n\t\t\t\t'secureHeaders: contentSecurityPolicy names NONCE, but nonce is off: give nonce: true',\n\t\t\t);\n\t\t}\n\t\tconst set = setter(headers, hide);\n\t\treturn defineMiddleware(async (ctx, next) =>\n\t\t\tset(await settle(ctx, next())),\n\t\t);\n\t}\n\tif (policy === undefined) {\n\t\tthrow new TypeError(\n\t\t\t'secureHeaders: nonce is on, but contentSecurityPolicy is false: a nonce is only read through the content-security-policy header',\n\t\t);\n\t}\n\theaders.delete('content-security-policy');\n\tconst withNonce = policyWithNonce(policy);\n\tconst set = setter(headers, hide);\n\treturn defineMiddleware(async (ctx, next) => {\n\t\tconst added: NonceContext = { nonce: freshNonce() };\n\t\tconst response = await settle(ctx, next(added));\n\t\treturn set(response, withNonce(added.nonce)) as typeof response;\n\t});\n}\n\n/** Each header the options leave in, by name, with its value. Throws on an empty one. */\nfunction fixedHeaders(options: SecureHeadersOptions): Map<string, string> {\n\tconst headers = new Map<string, string>();\n\tfor (const [name, fallback] of Object.entries(DEFAULTS) as [\n\t\tkeyof typeof DEFAULTS,\n\t\tSetting,\n\t][]) {\n\t\tconst setting = options[OPTION[name]];\n\t\tif (typeof setting === 'string' && setting.trim() === '') {\n\t\t\t// An empty header says nothing a browser can act on: leave it out.\n\t\t\tthrow new TypeError(\n\t\t\t\t`secureHeaders: ${OPTION[name]} is empty; give false to leave the ${name} header out`,\n\t\t\t);\n\t\t}\n\t\tconst value = setting === undefined ? fallback : setting;\n\t\tif (typeof value === 'string') headers.set(name, value);\n\t}\n\treturn headers;\n}\n\n/** Every header the response lacks, then the policy given per request. */\nfunction setter(headers: ReadonlyMap<string, string>, hide: boolean) {\n\treturn (response: Response, policy?: string) =>\n\t\twithHeaders(response, (current) => {\n\t\t\tfor (const [name, value] of headers) {\n\t\t\t\tif (!current.has(name)) current.set(name, value);\n\t\t\t}\n\t\t\tif (policy !== undefined && !current.has('content-security-policy')) {\n\t\t\t\tcurrent.set('content-security-policy', policy);\n\t\t\t}\n\t\t\tif (hide) {\n\t\t\t\tcurrent.delete('x-powered-by');\n\t\t\t\tcurrent.delete('server');\n\t\t\t}\n\t\t});\n}\n"
|
|
7
7
|
],
|
|
8
|
-
"mappings": ";
|
|
9
|
-
"debugId": "
|
|
8
|
+
"mappings": "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",
|
|
9
|
+
"debugId": "73869EA605B3C65B64756E2164756E21",
|
|
10
10
|
"names": []
|
|
11
11
|
}
|
package/dist/nonce.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The per-request nonce of `secureHeaders({ nonce: true })`: where it goes
|
|
3
|
-
* in the policy, how one is made
|
|
4
|
-
* the same one.
|
|
3
|
+
* in the policy, and how one is made.
|
|
5
4
|
*/
|
|
6
5
|
/**
|
|
7
6
|
* Where the nonce goes in `contentSecurityPolicy`: each occurrence becomes
|
|
@@ -32,10 +31,4 @@ export interface NonceContext {
|
|
|
32
31
|
export declare function policyWithNonce(policy: string): (nonce: string) => string;
|
|
33
32
|
/** 128 random bits, base64: a fresh nonce. */
|
|
34
33
|
export declare function freshNonce(): string;
|
|
35
|
-
/**
|
|
36
|
-
* One nonce per request, made when first asked for. Keyed by the request's
|
|
37
|
-
* `URL`: the one object every hook and the route of a request share, where
|
|
38
|
-
* a `bodyLimit` hands the route a `Request` of its own.
|
|
39
|
-
*/
|
|
40
|
-
export declare function nonceStore(): (url: URL) => string;
|
|
41
34
|
//# sourceMappingURL=nonce.d.ts.map
|
package/dist/nonce.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"nonce.d.ts","sourceRoot":"","sources":["../src/nonce.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"nonce.d.ts","sourceRoot":"","sources":["../src/nonce.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,KAAK,oBAAoB,CAAC;AAEvC,wFAAwF;AACxF,MAAM,WAAW,YAAY;IAC5B;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACvB;AAKD;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAmBzE;AAED,8CAA8C;AAC9C,wBAAgB,UAAU,IAAI,MAAM,CAGnC"}
|
package/dist/secure-headers.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type Empty, type Middleware, type MiddlewareMark, type Next } from '@alxia/core';
|
|
2
2
|
import { type NonceContext } from './nonce';
|
|
3
3
|
/** A header's value, or `false` to leave it out. An empty value is refused. */
|
|
4
4
|
export type Setting = string | false;
|
|
@@ -21,14 +21,23 @@ export interface SecureHeadersOptions {
|
|
|
21
21
|
readonly hidePoweredBy?: boolean;
|
|
22
22
|
}
|
|
23
23
|
/**
|
|
24
|
-
* What `secureHeaders(
|
|
25
|
-
*
|
|
24
|
+
* What `secureHeaders()` returns: a middleware, for `app.use`, that sets
|
|
25
|
+
* the headers on every response after it.
|
|
26
26
|
*/
|
|
27
|
-
export type
|
|
27
|
+
export type SecureHeaders = Middleware<Empty, Promise<Response>> & MiddlewareMark;
|
|
28
28
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
29
|
+
* What `secureHeaders({ nonce: true })` returns: a middleware, for
|
|
30
|
+
* `app.use`, whose routes after it read `nonce`, and which sets the header
|
|
31
|
+
* with the same one.
|
|
32
|
+
*/
|
|
33
|
+
export type NonceMiddleware = Middleware<Empty, Promise<Next<NonceContext>>> & MiddlewareMark;
|
|
34
|
+
/** @deprecated Renamed `NonceMiddleware`: it is a middleware. */
|
|
35
|
+
export type NoncePlugin = NonceMiddleware;
|
|
36
|
+
/**
|
|
37
|
+
* Secure headers on every response, as a middleware: each response of the
|
|
38
|
+
* routes after it and of a request no route matches — a 404, an error —
|
|
39
|
+
* gets them. A header a route set itself is kept: a page that needs its
|
|
40
|
+
* own `Content-Security-Policy` — `@alxia/graphql`'s IDE — sets it.
|
|
32
41
|
*
|
|
33
42
|
* ```ts
|
|
34
43
|
* app.use(secureHeaders({ contentSecurityPolicy: "default-src 'self'" }));
|
|
@@ -45,8 +54,8 @@ export type NoncePlugin = Alxia<NonceContext, Empty, '', never> & Requiring<Empt
|
|
|
45
54
|
*/
|
|
46
55
|
export declare function secureHeaders(options: SecureHeadersOptions & {
|
|
47
56
|
readonly nonce: true;
|
|
48
|
-
}):
|
|
57
|
+
}): NonceMiddleware;
|
|
49
58
|
export declare function secureHeaders(options?: SecureHeadersOptions & {
|
|
50
59
|
readonly nonce?: false;
|
|
51
|
-
}):
|
|
60
|
+
}): SecureHeaders;
|
|
52
61
|
//# sourceMappingURL=secure-headers.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"secure-headers.d.ts","sourceRoot":"","sources":["../src/secure-headers.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"secure-headers.d.ts","sourceRoot":"","sources":["../src/secure-headers.ts"],"names":[],"mappings":"AAAA,OAAO,EAEN,KAAK,KAAK,EACV,KAAK,UAAU,EACf,KAAK,cAAc,EACnB,KAAK,IAAI,EAGT,MAAM,aAAa,CAAC;AACrB,OAAO,EAAqB,KAAK,YAAY,EAAmB,MAAM,SAAS,CAAC;AAEhF,+EAA+E;AAC/E,MAAM,MAAM,OAAO,GAAG,MAAM,GAAG,KAAK,CAAC;AAErC,MAAM,WAAW,oBAAoB;IACpC,yEAAyE;IACzE,QAAQ,CAAC,qBAAqB,CAAC,EAAE,OAAO,CAAC;IACzC,QAAQ,CAAC,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAC3C,QAAQ,CAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC;IACvC,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,CAAC;IACjC,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,CAAC;IAClC,QAAQ,CAAC,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAC3C,QAAQ,CAAC,yBAAyB,CAAC,EAAE,OAAO,CAAC;IAC7C,QAAQ,CAAC,yBAAyB,CAAC,EAAE,OAAO,CAAC;IAC7C,QAAQ,CAAC,kBAAkB,CAAC,EAAE,OAAO,CAAC;IACtC,QAAQ,CAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC;IACvC,QAAQ,CAAC,6BAA6B,CAAC,EAAE,OAAO,CAAC;IACjD,sDAAsD;IACtD,QAAQ,CAAC,iBAAiB,CAAC,EAAE,OAAO,CAAC;IACrC,sEAAsE;IACtE,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,CAAC;CACjC;AAeD;;;GAGG;AACH,MAAM,MAAM,aAAa,GAAG,UAAU,CAAC,KAAK,EAAE,OAAO,CAAC,QAAQ,CAAC,CAAC,GAC/D,cAAc,CAAC;AAEhB;;;;GAIG;AACH,MAAM,MAAM,eAAe,GAAG,UAAU,CAAC,KAAK,EAAE,OAAO,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC,CAAC,GAC3E,cAAc,CAAC;AAEhB,iEAAiE;AACjE,MAAM,MAAM,WAAW,GAAG,eAAe,CAAC;AAiC1C;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,aAAa,CAC5B,OAAO,EAAE,oBAAoB,GAAG;IAAE,QAAQ,CAAC,KAAK,EAAE,IAAI,CAAA;CAAE,GACtD,eAAe,CAAC;AACnB,wBAAgB,aAAa,CAC5B,OAAO,CAAC,EAAE,oBAAoB,GAAG;IAAE,QAAQ,CAAC,KAAK,CAAC,EAAE,KAAK,CAAA;CAAE,GACzD,aAAa,CAAC"}
|
package/docs/README.md
CHANGED
|
@@ -6,6 +6,6 @@ headers, and what to do when a browser refuses what they forbid.
|
|
|
6
6
|
|
|
7
7
|
| Page | Read it when |
|
|
8
8
|
| --- | --- |
|
|
9
|
-
| [Guide](guide.md) | changing or dropping a header, a nonce per request, giving a page its own policy, ordering it with other
|
|
9
|
+
| [Guide](guide.md) | changing or dropping a header, a nonce per request, giving a page its own policy, ordering it with other middlewares, or testing the headers |
|
|
10
10
|
| [Troubleshooting](troubleshooting.md) | something went wrong and you have the message: a TypeScript error, a browser refusal, a header that is or is not there |
|
|
11
11
|
| [Roadmap](roadmap.md) | wondering what is coming, and what is not planned |
|
package/docs/guide.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
This page covers `secureHeaders`: what it sends, how to change or drop each
|
|
4
4
|
header, the per-request nonce, which responses it reaches, and how a page or
|
|
5
|
-
another
|
|
5
|
+
another middleware sets its own policy.
|
|
6
6
|
|
|
7
7
|
```ts
|
|
8
8
|
import { alxia } from '@alxia/core';
|
|
@@ -21,8 +21,8 @@ Every response of that app now carries the headers below, and none carries
|
|
|
21
21
|
## The signature
|
|
22
22
|
|
|
23
23
|
```ts
|
|
24
|
-
function secureHeaders(options: SecureHeadersOptions & { readonly nonce: true }):
|
|
25
|
-
function secureHeaders(options?: SecureHeadersOptions & { readonly nonce?: false }):
|
|
24
|
+
function secureHeaders(options: SecureHeadersOptions & { readonly nonce: true }): NonceMiddleware;
|
|
25
|
+
function secureHeaders(options?: SecureHeadersOptions & { readonly nonce?: false }): SecureHeaders;
|
|
26
26
|
|
|
27
27
|
interface SecureHeadersOptions {
|
|
28
28
|
readonly contentSecurityPolicy?: string | false;
|
|
@@ -42,22 +42,25 @@ interface SecureHeadersOptions {
|
|
|
42
42
|
```
|
|
43
43
|
|
|
44
44
|
`nonce` is not in `SecureHeadersOptions`: each overload adds it, so options
|
|
45
|
-
typed by that interface still give the plain `
|
|
46
|
-
|
|
47
|
-
`secureHeaders` returns a
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
45
|
+
typed by that interface still give the plain `SecureHeaders`.
|
|
46
|
+
|
|
47
|
+
`secureHeaders` returns a middleware: give it to `app.use`, called, and the
|
|
48
|
+
app keeps its type. `SecureHeaders` is a `Middleware` from `@alxia/core`
|
|
49
|
+
that adds nothing to the context. It waits for the rest of the chain with
|
|
50
|
+
`settle`, then sets the headers on the response, so it covers every route
|
|
51
|
+
declared after it, and every request no route matches — a 404, a 405 —
|
|
52
|
+
because those run all of the app's top-level middlewares too. A route
|
|
53
|
+
declared **before** it is not covered. Used inside a `group`, it covers only
|
|
54
|
+
that group's routes, and not a 404 under the group's prefix: declare it on
|
|
55
|
+
the app. To vary a header for some routes, set it on their replies
|
|
53
56
|
([below](#a-header-a-route-sets-is-kept)).
|
|
54
57
|
|
|
55
58
|
The options are read once, when `secureHeaders(…)` is called; the header
|
|
56
59
|
values are fixed from then on, but for the nonce, which is new on every
|
|
57
60
|
request.
|
|
58
61
|
|
|
59
|
-
With `nonce: true` it returns a `
|
|
60
|
-
given to `use` called,
|
|
62
|
+
With `nonce: true` it returns a `NonceMiddleware` instead: a middleware, still
|
|
63
|
+
given to `app.use` called, that covers the same responses as above, and which
|
|
61
64
|
adds `nonce` to the context of the routes declared after it
|
|
62
65
|
([below](#a-nonce-per-request)).
|
|
63
66
|
|
|
@@ -103,7 +106,7 @@ app.use(
|
|
|
103
106
|
|
|
104
107
|
### `false`
|
|
105
108
|
|
|
106
|
-
`false` leaves the header out of every response the
|
|
109
|
+
`false` leaves the header out of every response the middleware touches.
|
|
107
110
|
|
|
108
111
|
```ts
|
|
109
112
|
app.use(secureHeaders({ xFrameOptions: false, strictTransportSecurity: false }));
|
|
@@ -143,11 +146,11 @@ const app = alxia()
|
|
|
143
146
|
(await app.request('/')).headers.get('x-powered-by'); // null
|
|
144
147
|
```
|
|
145
148
|
|
|
146
|
-
`hidePoweredBy: false` keeps whatever the route or another
|
|
149
|
+
`hidePoweredBy: false` keeps whatever the route or another middleware sent.
|
|
147
150
|
|
|
148
151
|
## A header a route sets is kept
|
|
149
152
|
|
|
150
|
-
The
|
|
153
|
+
The middleware only fills in a header the response does not have yet. A route
|
|
151
154
|
that needs its own policy sets it on its reply, and the other defaults
|
|
152
155
|
still apply:
|
|
153
156
|
|
|
@@ -171,11 +174,10 @@ response.headers.get('x-frame-options'); // null — f
|
|
|
171
174
|
```
|
|
172
175
|
|
|
173
176
|
`set.headers` in a handler or a `derive` works the same way, since it ends
|
|
174
|
-
up on the response before the
|
|
177
|
+
up on the response before the middleware reads it.
|
|
175
178
|
|
|
176
|
-
That is how `@alxia/
|
|
177
|
-
|
|
178
|
-
needs, and `secureHeaders` keeps it.
|
|
179
|
+
That is how `@alxia/graphql`'s IDE loads under the strict default: it sets
|
|
180
|
+
the `Content-Security-Policy` it needs, and `secureHeaders` keeps it.
|
|
179
181
|
|
|
180
182
|
## A nonce per request
|
|
181
183
|
|
|
@@ -213,8 +215,8 @@ await response.text();
|
|
|
213
215
|
### What it is
|
|
214
216
|
|
|
215
217
|
16 bytes from `crypto.getRandomValues`, base64: 24 characters, `==` at the
|
|
216
|
-
end. Each request makes its own, when the
|
|
217
|
-
and every reader of that request — the route, its
|
|
218
|
+
end. Each request makes its own, when the middleware runs,
|
|
219
|
+
and every reader of that request — the route, its middlewares, the header — gets
|
|
218
220
|
the same one. Two requests never share one.
|
|
219
221
|
|
|
220
222
|
### Where it goes in the policy
|
|
@@ -251,14 +253,15 @@ which take no nonce, stop applying
|
|
|
251
253
|
|
|
252
254
|
### Who reads it
|
|
253
255
|
|
|
254
|
-
The header covers every response
|
|
255
|
-
|
|
256
|
-
`ctx.nonce` is typed and set on the routes declared **after** `use`, in
|
|
257
|
-
their
|
|
256
|
+
The header covers every response that comes back through the middleware, as
|
|
257
|
+
without the nonce: a 404 and a 500 get a policy with a nonce of their own.
|
|
258
|
+
`ctx.nonce` is typed and set on the routes declared **after** `app.use`, in
|
|
259
|
+
their middlewares and their handler, as any middleware's context. A route
|
|
260
|
+
declared before it gets neither a policy nor a `ctx.nonce`:
|
|
258
261
|
|
|
259
262
|
```ts
|
|
260
263
|
alxia()
|
|
261
|
-
.get('/early', ({ reply }) => reply(200, 'policy
|
|
264
|
+
.get('/early', ({ reply }) => reply(200, 'no policy from this middleware, no ctx.nonce'))
|
|
262
265
|
.use(secureHeaders({ nonce: true, contentSecurityPolicy: "script-src 'self'" }))
|
|
263
266
|
.derive(({ nonce }) => ({ scriptTag: (code: string) => `<script nonce="${nonce}">${code}</script>` }))
|
|
264
267
|
.get('/late', ({ scriptTag, reply }) => reply(200, scriptTag('…')));
|
|
@@ -283,17 +286,18 @@ startup, so the placeholder never reaches a browser.
|
|
|
283
286
|
|
|
284
287
|
## Which responses get the headers
|
|
285
288
|
|
|
286
|
-
The
|
|
287
|
-
|
|
289
|
+
The middleware settles `next()`: it sets the headers on the response the
|
|
290
|
+
client would get, whatever produced it.
|
|
288
291
|
|
|
289
292
|
| Response | Headers |
|
|
290
293
|
| --- | --- |
|
|
291
294
|
| a route's reply, `static` and `file` included | yes |
|
|
292
|
-
| the core's 400, 404, 405 and 500 | yes |
|
|
293
|
-
| a `Response`
|
|
294
|
-
| a
|
|
295
|
-
| a
|
|
296
|
-
|
|
|
295
|
+
| the core's 400, 404, 405 and 500, and an `onError` reply | yes |
|
|
296
|
+
| a `Response` another middleware returned (a CORS preflight, a redirect) | yes, when that middleware is declared after `secureHeaders` |
|
|
297
|
+
| a route declared before `app.use(secureHeaders())` | no: the middleware does not run for it |
|
|
298
|
+
| a WebSocket upgrade that succeeds (`101`) | no: the upgrade is not a response a middleware can decorate |
|
|
299
|
+
| a page served with `page()` | no: `Bun.serve` serves it, around no middleware |
|
|
300
|
+
| the 500 sent when a deprecated `around` hook itself throws | no |
|
|
297
301
|
|
|
298
302
|
```ts
|
|
299
303
|
const app = alxia().use(secureHeaders());
|
|
@@ -303,58 +307,75 @@ response.status; // 404
|
|
|
303
307
|
response.headers.get('x-content-type-options'); // 'nosniff'
|
|
304
308
|
```
|
|
305
309
|
|
|
306
|
-
## Order with other
|
|
310
|
+
## Order with other middlewares
|
|
307
311
|
|
|
308
|
-
|
|
309
|
-
|
|
312
|
+
Middlewares nest: the first `use` is the outermost, and on the way out each
|
|
313
|
+
one sees the response of the ones after it. The middleware never overwrites
|
|
314
|
+
a header that is already there. So:
|
|
310
315
|
|
|
311
|
-
- a
|
|
312
|
-
wins;
|
|
313
|
-
- a
|
|
314
|
-
overwriting;
|
|
315
|
-
- a
|
|
316
|
-
it:
|
|
316
|
+
- a middleware declared **after** `secureHeaders` that sets one of its
|
|
317
|
+
headers wins: `secureHeaders` finds it already there;
|
|
318
|
+
- a middleware declared **before** it that sets one of its headers wins too,
|
|
319
|
+
by overwriting;
|
|
320
|
+
- a middleware declared **before** it that adds `X-Powered-By` or `Server`
|
|
321
|
+
keeps it: `secureHeaders` deleted them before that one ran.
|
|
317
322
|
|
|
318
323
|
```ts
|
|
319
|
-
import { alxia, withHeaders } from '@alxia/core';
|
|
324
|
+
import { alxia, defineMiddleware, settle, withHeaders } from '@alxia/core';
|
|
320
325
|
import { secureHeaders } from '@alxia/secure-headers';
|
|
321
326
|
|
|
327
|
+
const referrerOrigin = defineMiddleware(async (ctx, next) =>
|
|
328
|
+
withHeaders(await settle(ctx, next()), (headers) => headers.set('referrer-policy', 'origin')),
|
|
329
|
+
);
|
|
330
|
+
|
|
322
331
|
const app = alxia()
|
|
323
|
-
.onResponse((response) =>
|
|
324
|
-
withHeaders(response, (headers) => headers.set('referrer-policy', 'origin')),
|
|
325
|
-
)
|
|
326
332
|
.use(secureHeaders())
|
|
333
|
+
.use(referrerOrigin)
|
|
327
334
|
.get('/', ({ reply }) => reply(200, 'ok'));
|
|
328
335
|
|
|
329
336
|
(await app.request('/')).headers.get('referrer-policy'); // 'origin'
|
|
330
337
|
```
|
|
331
338
|
|
|
332
|
-
|
|
339
|
+
Declare `secureHeaders()` among the observers, first: `logger()`,
|
|
340
|
+
`telemetry()`, `secureHeaders()`, `cors()`, `compress()`. An
|
|
341
|
+
error-handling middleware — a `try`/`catch` around `next()`, or
|
|
342
|
+
`janusErrors()` — goes **after** it. `secureHeaders()` settles `next()`: it
|
|
343
|
+
sets the headers on the response the error would be answered with (the
|
|
344
|
+
route's `onError` reply, its `HttpError`'s status, or a 500), then the error
|
|
345
|
+
goes on, so a `try`/`catch` catches it wherever it is declared; declared
|
|
346
|
+
before `secureHeaders()`, its reply does not carry the headers. A guard on the app (`bearer`, a required
|
|
347
|
+
session) declared before it answers its `401` without them.
|
|
348
|
+
|
|
349
|
+
The deprecated `onResponse` and `around` hooks still run outside every
|
|
350
|
+
middleware: an `onResponse` hook sees the headers `secureHeaders` set,
|
|
351
|
+
wherever it is declared.
|
|
352
|
+
|
|
353
|
+
## A header the middleware does not know
|
|
333
354
|
|
|
334
|
-
The
|
|
355
|
+
The middleware sends the twelve headers above and nothing else. Another one —
|
|
335
356
|
`Content-Security-Policy-Report-Only` while a policy is being tried, say —
|
|
336
|
-
is a
|
|
357
|
+
is a middleware of your own, with `settle` and `withHeaders` from
|
|
358
|
+
`@alxia/core`:
|
|
337
359
|
|
|
338
360
|
```ts
|
|
339
|
-
import { alxia, withHeaders } from '@alxia/core';
|
|
361
|
+
import { alxia, defineMiddleware, settle, withHeaders } from '@alxia/core';
|
|
340
362
|
import { secureHeaders } from '@alxia/secure-headers';
|
|
341
363
|
|
|
364
|
+
const reportOnly = defineMiddleware(async (ctx, next) =>
|
|
365
|
+
withHeaders(await settle(ctx, next()), (headers) => {
|
|
366
|
+
if (!headers.has('content-security-policy-report-only')) {
|
|
367
|
+
headers.set('content-security-policy-report-only', "default-src 'self'; report-to csp");
|
|
368
|
+
}
|
|
369
|
+
}),
|
|
370
|
+
);
|
|
371
|
+
|
|
342
372
|
const app = alxia()
|
|
343
373
|
.use(secureHeaders())
|
|
344
|
-
.
|
|
345
|
-
withHeaders(response, (headers) => {
|
|
346
|
-
if (!headers.has('content-security-policy-report-only')) {
|
|
347
|
-
headers.set(
|
|
348
|
-
'content-security-policy-report-only',
|
|
349
|
-
"default-src 'self'; report-to csp",
|
|
350
|
-
);
|
|
351
|
-
}
|
|
352
|
-
}),
|
|
353
|
-
);
|
|
374
|
+
.use(reportOnly);
|
|
354
375
|
```
|
|
355
376
|
|
|
356
|
-
See [
|
|
357
|
-
in the core's guide for what
|
|
377
|
+
See [Middleware](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/middleware.md)
|
|
378
|
+
in the core's guide for what a middleware may do.
|
|
358
379
|
|
|
359
380
|
## Recipes
|
|
360
381
|
|
|
@@ -481,6 +502,6 @@ test('a 404 is covered too', async () => {
|
|
|
481
502
|
|
|
482
503
|
- [Troubleshooting](troubleshooting.md): a browser refuses something, or
|
|
483
504
|
TypeScript does.
|
|
484
|
-
- [
|
|
505
|
+
- [Middleware](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/middleware.md)
|
|
485
506
|
and [Groups and plugins](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/groups-and-plugins.md)
|
|
486
507
|
in the core's guide.
|
package/docs/roadmap.md
CHANGED
|
@@ -7,7 +7,11 @@ number on it. Every release, with each change it made, is in
|
|
|
7
7
|
|
|
8
8
|
## Now
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
- **Secure headers as a middleware.** `app.use(secureHeaders())`, declared
|
|
11
|
+
first, sets the headers on every response that comes back through it, a
|
|
12
|
+
404's, an error's and a 500's included; `NonceMiddleware` gives `nonce` to the
|
|
13
|
+
routes after it and `SecureHeaders` is the plain one.
|
|
14
|
+
`app.plugin(secureHeaders())` keeps working as a deprecated alias.
|
|
11
15
|
|
|
12
16
|
## Next
|
|
13
17
|
|
|
@@ -37,7 +41,7 @@ Nothing scheduled yet.
|
|
|
37
41
|
### 0.1.0
|
|
38
42
|
|
|
39
43
|
- **Secure headers on every response.** `secureHeaders()` is a plugin for
|
|
40
|
-
`
|
|
44
|
+
`app.plugin` that sends `Content-Security-Policy`, `Strict-Transport-Security`,
|
|
41
45
|
`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`,
|
|
42
46
|
`Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`,
|
|
43
47
|
`Origin-Agent-Cluster`, `X-DNS-Prefetch-Control` and
|
package/docs/troubleshooting.md
CHANGED
|
@@ -11,14 +11,14 @@ or a header in the wrong place.
|
|
|
11
11
|
- [`Type 'true' is not assignable to type 'Setting | undefined'`](#type-true-is-not-assignable-to-type-setting--undefined)
|
|
12
12
|
- [`No overload matches this call` … `is not assignable to type 'SecureHeadersOptions' with 'exactOptionalPropertyTypes: true'`](#no-overload-matches-this-call--is-not-assignable-to-type-secureheadersoptions-with-exactoptionalpropertytypes-true)
|
|
13
13
|
- [`No overload matches this call` … `Object literal may only specify known properties, and '…' does not exist in type 'SecureHeadersOptions & …'`](#no-overload-matches-this-call--object-literal-may-only-specify-known-properties-and--does-not-exist-in-type-secureheadersoptions--)
|
|
14
|
-
- [`No overload matches this call` … `Property 'nonce' is missing in type 'Alxia<Empty,
|
|
15
|
-
- [`Property 'nonce' does not exist on type '
|
|
14
|
+
- [`No overload matches this call` … `Property 'nonce' is missing in type 'Alxia<Empty, "", never>'`](#no-overload-matches-this-call--property-nonce-is-missing-in-type-alxiaempty--never)
|
|
15
|
+
- [`Property 'nonce' does not exist on type 'RouteBase<…>'`](#property-nonce-does-not-exist-on-type-routebase)
|
|
16
16
|
- [`No overload matches this call` … `Type 'boolean' is not assignable to type 'true'`](#no-overload-matches-this-call--type-boolean-is-not-assignable-to-type-true)
|
|
17
17
|
|
|
18
18
|
**Runtime**
|
|
19
19
|
|
|
20
20
|
- [`TypeError: secureHeaders: … is empty; give false to leave the … header out`](#typeerror-secureheaders--is-empty-give-false-to-leave-the--header-out)
|
|
21
|
-
- [`TypeError:
|
|
21
|
+
- [`TypeError: use(): the plugin function returned function, not an app: …`](#typeerror-use-the-plugin-function-returned-function-not-an-app-)
|
|
22
22
|
- [`TypeError: secureHeaders: nonce is on, but the content-security-policy has no script-src to add it to: …`](#typeerror-secureheaders-nonce-is-on-but-the-content-security-policy-has-no-script-src-to-add-it-to-)
|
|
23
23
|
- [`TypeError: secureHeaders: nonce is on, but contentSecurityPolicy is false: …`](#typeerror-secureheaders-nonce-is-on-but-contentsecuritypolicy-is-false-)
|
|
24
24
|
- [`TypeError: secureHeaders: contentSecurityPolicy names NONCE, but nonce is off: give nonce: true`](#typeerror-secureheaders-contentsecuritypolicy-names-nonce-but-nonce-is-off-give-nonce-true)
|
|
@@ -39,7 +39,9 @@ or a header in the wrong place.
|
|
|
39
39
|
- [An option has no effect on a response](#an-option-has-no-effect-on-a-response)
|
|
40
40
|
- [`X-Powered-By` or `Server` is still sent](#x-powered-by-or-server-is-still-sent)
|
|
41
41
|
- [A response arrives without the headers](#a-response-arrives-without-the-headers)
|
|
42
|
-
- [
|
|
42
|
+
- [A 404 or a route outside a group has no headers](#a-404-or-a-route-outside-a-group-has-no-headers)
|
|
43
|
+
- [A `try`/`catch` middleware's reply has no headers](#a-trycatch-middlewares-reply-has-no-headers)
|
|
44
|
+
- [A route has no headers, though the others do](#a-route-has-no-headers-though-the-others-do)
|
|
43
45
|
|
|
44
46
|
## Types
|
|
45
47
|
|
|
@@ -49,9 +51,9 @@ or a header in the wrong place.
|
|
|
49
51
|
|
|
50
52
|
```text
|
|
51
53
|
error TS2769: No overload matches this call.
|
|
52
|
-
Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }):
|
|
54
|
+
Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }): NonceMiddleware', gave the following error.
|
|
53
55
|
Type 'true' is not assignable to type 'Setting'.
|
|
54
|
-
Overload 2 of 2, '(options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined):
|
|
56
|
+
Overload 2 of 2, '(options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined): SecureHeaders', gave the following error.
|
|
55
57
|
Type 'true' is not assignable to type 'Setting | undefined'.
|
|
56
58
|
```
|
|
57
59
|
|
|
@@ -84,10 +86,10 @@ app.use(
|
|
|
84
86
|
|
|
85
87
|
```text
|
|
86
88
|
error TS2769: No overload matches this call.
|
|
87
|
-
Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }):
|
|
89
|
+
Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }): NonceMiddleware', gave the following error.
|
|
88
90
|
Argument of type '{ xFrameOptions: undefined; }' is not assignable to parameter of type 'SecureHeadersOptions & { readonly nonce: true; }' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
|
|
89
91
|
Type '{ xFrameOptions: undefined; }' is not assignable to type 'SecureHeadersOptions' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
|
|
90
|
-
Overload 2 of 2, '(options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined):
|
|
92
|
+
Overload 2 of 2, '(options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined): SecureHeaders', gave the following error.
|
|
91
93
|
Argument of type '{ xFrameOptions: undefined; }' is not assignable to parameter of type 'SecureHeadersOptions & { readonly nonce?: false; }' with 'exactOptionalPropertyTypes: true'. …
|
|
92
94
|
```
|
|
93
95
|
|
|
@@ -109,9 +111,9 @@ another library's name for one.
|
|
|
109
111
|
|
|
110
112
|
```text
|
|
111
113
|
error TS2769: No overload matches this call.
|
|
112
|
-
Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }):
|
|
114
|
+
Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }): NonceMiddleware', gave the following error.
|
|
113
115
|
Object literal may only specify known properties, and 'xPoweredBy' does not exist in type 'SecureHeadersOptions & { readonly nonce: true; }'.
|
|
114
|
-
Overload 2 of 2, '(options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined):
|
|
116
|
+
Overload 2 of 2, '(options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined): SecureHeaders', gave the following error.
|
|
115
117
|
Object literal may only specify known properties, and 'xPoweredBy' does not exist in type 'SecureHeadersOptions & { readonly nonce?: false; }'.
|
|
116
118
|
```
|
|
117
119
|
|
|
@@ -119,39 +121,41 @@ error TS2769: No overload matches this call.
|
|
|
119
121
|
[guide](guide.md#the-options), plus `hidePoweredBy` and `nonce`. `X-Powered-By` and
|
|
120
122
|
`Server` are governed by `hidePoweredBy`; anything else —
|
|
121
123
|
`Content-Security-Policy-Report-Only`, `X-XSS-Protection` — is not sent by
|
|
122
|
-
the
|
|
124
|
+
the middleware.
|
|
123
125
|
|
|
124
|
-
**Fix:** `hidePoweredBy` for those two; a
|
|
126
|
+
**Fix:** `hidePoweredBy` for those two; a middleware of your own for another
|
|
125
127
|
header:
|
|
126
128
|
|
|
127
129
|
```ts
|
|
128
|
-
import { alxia, withHeaders } from '@alxia/core';
|
|
130
|
+
import { alxia, defineMiddleware, settle, withHeaders } from '@alxia/core';
|
|
129
131
|
import { secureHeaders } from '@alxia/secure-headers';
|
|
130
132
|
|
|
133
|
+
const reportOnly = defineMiddleware(async (ctx, next) =>
|
|
134
|
+
withHeaders(await settle(ctx, next()), (headers) =>
|
|
135
|
+
headers.set('content-security-policy-report-only', "default-src 'self'"),
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
|
|
131
139
|
const app = alxia()
|
|
132
140
|
.use(secureHeaders({ hidePoweredBy: true }))
|
|
133
|
-
.
|
|
134
|
-
withHeaders(response, (headers) =>
|
|
135
|
-
headers.set('content-security-policy-report-only', "default-src 'self'"),
|
|
136
|
-
),
|
|
137
|
-
);
|
|
141
|
+
.use(reportOnly);
|
|
138
142
|
```
|
|
139
143
|
|
|
140
|
-
### `No overload matches this call` … `Property 'nonce' is missing in type 'Alxia<Empty,
|
|
144
|
+
### `No overload matches this call` … `Property 'nonce' is missing in type 'Alxia<Empty, "", never>'`
|
|
141
145
|
|
|
142
|
-
**When:** `secureHeaders` is given to `use` without being called.
|
|
146
|
+
**When:** `secureHeaders` is given to `app.use` without being called.
|
|
143
147
|
|
|
144
148
|
```text
|
|
145
149
|
error TS2769: No overload matches this call.
|
|
146
|
-
Overload 1 of
|
|
147
|
-
Argument of type '{ (options: SecureHeadersOptions & { readonly nonce: true; }):
|
|
150
|
+
Overload 1 of 11, '(plugin: (app: Alxia<Empty, "", never>) => AnyAlxia): AnyAlxia', gave the following error.
|
|
151
|
+
Argument of type '{ (options: SecureHeadersOptions & { readonly nonce: true; }): NonceMiddleware; (options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined): Plugin; }' is not assignable to parameter of type '(app: Alxia<Empty, "", never>) => AnyAlxia'.
|
|
148
152
|
Types of parameters 'options' and 'app' are incompatible.
|
|
149
|
-
Type 'Alxia<Empty,
|
|
150
|
-
Property 'nonce' is missing in type 'Alxia<Empty,
|
|
153
|
+
Type 'Alxia<Empty, "", never>' is not assignable to type 'SecureHeadersOptions & { readonly nonce: true; }'.
|
|
154
|
+
Property 'nonce' is missing in type 'Alxia<Empty, "", never>' but required in type '{ readonly nonce: true; }'.
|
|
151
155
|
```
|
|
152
156
|
|
|
153
|
-
**Why:** `secureHeaders` makes the
|
|
154
|
-
what it returns. TypeScript tries the app as the options of its first
|
|
157
|
+
**Why:** `secureHeaders` makes the middleware from its options; the
|
|
158
|
+
middleware is what it returns. TypeScript tries the app as the options of its first
|
|
155
159
|
overload, the nonce one, and says what that lacks.
|
|
156
160
|
|
|
157
161
|
**Fix:** call it, with or without options:
|
|
@@ -160,16 +164,19 @@ overload, the nonce one, and says what that lacks.
|
|
|
160
164
|
app.use(secureHeaders());
|
|
161
165
|
```
|
|
162
166
|
|
|
163
|
-
### `Property 'nonce' does not exist on type '
|
|
167
|
+
### `Property 'nonce' does not exist on type 'RouteBase<…>'`
|
|
164
168
|
|
|
165
|
-
**When:** a route or a
|
|
169
|
+
**When:** a route or a middleware reads `nonce` from its context.
|
|
166
170
|
|
|
167
171
|
```text
|
|
168
|
-
error TS2339: Property 'nonce' does not exist on type '
|
|
172
|
+
error TS2339: Property 'nonce' does not exist on type 'RouteBase<RouteApp<"GET", Empty, "", never>, "/">'.
|
|
173
|
+
error TS2339: Property 'nonce' does not exist on type 'BaseContext & Empty'.
|
|
169
174
|
```
|
|
170
175
|
|
|
176
|
+
The second is a `derive` or a middleware.
|
|
177
|
+
|
|
171
178
|
**Why:** only `secureHeaders({ nonce: true })` adds it, and only to the
|
|
172
|
-
routes declared after its `use`. Without `nonce: true`, or on a route
|
|
179
|
+
routes declared after its `app.use`. Without `nonce: true`, or on a route
|
|
173
180
|
declared before it, there is none.
|
|
174
181
|
|
|
175
182
|
**Fix:** turn it on, and declare the routes that read it after it:
|
|
@@ -187,13 +194,13 @@ the environment, say.
|
|
|
187
194
|
|
|
188
195
|
```text
|
|
189
196
|
error TS2769: No overload matches this call.
|
|
190
|
-
Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }):
|
|
197
|
+
Overload 1 of 2, '(options: SecureHeadersOptions & { readonly nonce: true; }): NonceMiddleware', gave the following error.
|
|
191
198
|
Type 'boolean' is not assignable to type 'true'.
|
|
192
|
-
Overload 2 of 2, '(options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined):
|
|
199
|
+
Overload 2 of 2, '(options?: (SecureHeadersOptions & { readonly nonce?: false; }) | undefined): SecureHeaders', gave the following error.
|
|
193
200
|
Type 'boolean' is not assignable to type 'false'.
|
|
194
201
|
```
|
|
195
202
|
|
|
196
|
-
**Why:** `nonce: true` changes the
|
|
203
|
+
**Why:** `nonce: true` changes the middleware's type, since it adds `nonce`
|
|
197
204
|
to the context. A `boolean` leaves TypeScript unable to say whether the
|
|
198
205
|
routes read one.
|
|
199
206
|
|
|
@@ -229,14 +236,20 @@ Only `false` leaves a header out.
|
|
|
229
236
|
app.use(secureHeaders({ xFrameOptions: false }));
|
|
230
237
|
```
|
|
231
238
|
|
|
232
|
-
### `TypeError:
|
|
239
|
+
### `TypeError: use(): the plugin function returned function, not an app: …`
|
|
233
240
|
|
|
234
|
-
**When:** the same mistake as above in JavaScript, or past a cast:
|
|
235
|
-
|
|
241
|
+
**When:** the same mistake as above in JavaScript, or past a cast:
|
|
242
|
+
`alxia().use(secureHeaders)`, the factory given uncalled. It throws at
|
|
243
|
+
startup:
|
|
236
244
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
245
|
+
```text
|
|
246
|
+
TypeError: use(): the plugin function returned function, not an app: a plugin returns the app it is given; a middleware is made with defineMiddleware() and given to use()
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
**Why:** `app.use` takes a function that is not a middleware for a plugin:
|
|
250
|
+
it calls it with the app, and expects the app back. Uncalled,
|
|
251
|
+
`secureHeaders` reads the app as its options and returns a middleware, a
|
|
252
|
+
function, not the app.
|
|
240
253
|
|
|
241
254
|
**Fix:**
|
|
242
255
|
|
|
@@ -257,7 +270,7 @@ TypeError: secureHeaders: nonce is on, but the content-security-policy has no sc
|
|
|
257
270
|
```
|
|
258
271
|
|
|
259
272
|
**Why:** the nonce goes in a directive. Without `script-src`, scripts fall
|
|
260
|
-
back on `default-src`, and adding a `script-src` of the
|
|
273
|
+
back on `default-src`, and adding a `script-src` of the middleware's own would
|
|
261
274
|
change what else they may load.
|
|
262
275
|
|
|
263
276
|
**Fix:** write the `script-src` you want, or say where the nonce goes:
|
|
@@ -281,7 +294,7 @@ TypeError: secureHeaders: nonce is on, but contentSecurityPolicy is false: a non
|
|
|
281
294
|
no policy there is nothing for it to allow.
|
|
282
295
|
|
|
283
296
|
**Fix:** give a policy, or drop `nonce`. A route that writes its own policy
|
|
284
|
-
with `ctx.nonce` still needs the
|
|
297
|
+
with `ctx.nonce` still needs the middleware's: give the app one, and let the
|
|
285
298
|
route's replace it ([guide](guide.md#who-reads-it)).
|
|
286
299
|
|
|
287
300
|
### `TypeError: secureHeaders: contentSecurityPolicy names NONCE, but nonce is off: give nonce: true`
|
|
@@ -476,11 +489,11 @@ until it reads `max-age=0` from the host over HTTPS.
|
|
|
476
489
|
|
|
477
490
|
**When:** an option is set, and some responses still carry another value.
|
|
478
491
|
|
|
479
|
-
**Why:** the
|
|
480
|
-
A route set it on its reply or on `set.headers`,
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
`Content-Security-Policy` on purpose.
|
|
492
|
+
**Why:** the middleware never overwrites a header the response already has.
|
|
493
|
+
A route set it on its reply or on `set.headers`, a middleware declared
|
|
494
|
+
after `secureHeaders` set it on the way out, or one declared before it
|
|
495
|
+
overwrote it.
|
|
496
|
+
`@alxia/graphql`'s IDE sets its own `Content-Security-Policy` on purpose.
|
|
484
497
|
|
|
485
498
|
**Fix:** find what sets it on that route, and change it there:
|
|
486
499
|
|
|
@@ -494,22 +507,26 @@ console.log(response.headers.get('content-security-policy'));
|
|
|
494
507
|
**When:** `hidePoweredBy` is on (the default), and one of them is on the
|
|
495
508
|
response.
|
|
496
509
|
|
|
497
|
-
**Why:** the
|
|
498
|
-
declared
|
|
499
|
-
|
|
510
|
+
**Why:** the middleware deletes them when the response comes back through
|
|
511
|
+
it. A middleware declared **before** `secureHeaders` that adds one sees the
|
|
512
|
+
response after that, and keeps it; so does a deprecated `onResponse` hook,
|
|
513
|
+
which runs after every middleware. A proxy in front of the app may add its
|
|
514
|
+
own `Server` too.
|
|
500
515
|
|
|
501
|
-
**Fix:** declare `secureHeaders`
|
|
502
|
-
stop that
|
|
516
|
+
**Fix:** declare `secureHeaders` before the middleware that adds the header,
|
|
517
|
+
or stop that middleware adding it:
|
|
503
518
|
|
|
504
519
|
```ts
|
|
505
|
-
import { alxia, withHeaders } from '@alxia/core';
|
|
520
|
+
import { alxia, defineMiddleware, settle, withHeaders } from '@alxia/core';
|
|
506
521
|
import { secureHeaders } from '@alxia/secure-headers';
|
|
507
522
|
|
|
523
|
+
const poweredBy = defineMiddleware(async (ctx, next) =>
|
|
524
|
+
withHeaders(await settle(ctx, next()), (headers) => headers.set('x-powered-by', 'my-app')),
|
|
525
|
+
);
|
|
526
|
+
|
|
508
527
|
const app = alxia()
|
|
509
|
-
.
|
|
510
|
-
|
|
511
|
-
)
|
|
512
|
-
.use(secureHeaders()); // its hook runs after, and deletes it
|
|
528
|
+
.use(secureHeaders()) // outermost: deletes the header on the way out
|
|
529
|
+
.use(poweredBy);
|
|
513
530
|
```
|
|
514
531
|
|
|
515
532
|
### A response arrives without the headers
|
|
@@ -517,20 +534,20 @@ const app = alxia()
|
|
|
517
534
|
**When:** a response has none of the headers, though `secureHeaders` is
|
|
518
535
|
in use.
|
|
519
536
|
|
|
520
|
-
**Why:** the
|
|
521
|
-
|
|
537
|
+
**Why:** the middleware decorates what comes back through it, and three
|
|
538
|
+
responses do not: a WebSocket upgrade that succeeds (the `101`), a page
|
|
522
539
|
served with `page()`, which `Bun.serve` answers itself, and the 500 sent
|
|
523
|
-
when
|
|
540
|
+
when a deprecated `around` hook throws.
|
|
524
541
|
|
|
525
542
|
**Fix:** serve a page that needs the headers through a route, `file` or
|
|
526
543
|
`static` rather than `page()` — or give it its policy in its HTML with
|
|
527
544
|
`<meta http-equiv="Content-Security-Policy" content="…">`, where browsers
|
|
528
|
-
ignore `frame-ancestors`.
|
|
529
|
-
it and still returns what `next()`
|
|
530
|
-
headers:
|
|
545
|
+
ignore `frame-ancestors`. A middleware declared before `secureHeaders`,
|
|
546
|
+
whose own work may fail, catches it and still returns what `next()`
|
|
547
|
+
resolved to, which already carries the headers:
|
|
531
548
|
|
|
532
549
|
```ts
|
|
533
|
-
|
|
550
|
+
const timing = defineMiddleware(async (ctx, next) => {
|
|
534
551
|
const response = await next();
|
|
535
552
|
try {
|
|
536
553
|
recordTiming(response); // your own work
|
|
@@ -539,19 +556,26 @@ app.around(async (_ctx, next) => {
|
|
|
539
556
|
}
|
|
540
557
|
return response;
|
|
541
558
|
});
|
|
559
|
+
|
|
560
|
+
const app = alxia().use(timing).use(secureHeaders());
|
|
542
561
|
```
|
|
543
562
|
|
|
544
|
-
###
|
|
563
|
+
### A 404 or a route outside a group has no headers
|
|
545
564
|
|
|
546
|
-
**When:** `secureHeaders` is used inside a `group`,
|
|
547
|
-
|
|
565
|
+
**When:** `secureHeaders` is used inside a `group`, and a route outside the
|
|
566
|
+
group, or a request that matches no route, comes back without them.
|
|
548
567
|
|
|
549
|
-
**Why:**
|
|
568
|
+
**Why:** a middleware declared in a `group` stays inside the group: it runs
|
|
569
|
+
on its routes and on a request no route matches under its prefix, and on
|
|
570
|
+
nothing else: not on a route outside the group, nor declared after it.
|
|
550
571
|
|
|
551
572
|
**Fix:** use it on the app, and give the routes that differ their own
|
|
552
573
|
values on their replies:
|
|
553
574
|
|
|
554
575
|
```ts
|
|
576
|
+
import { alxia } from '@alxia/core';
|
|
577
|
+
import { secureHeaders } from '@alxia/secure-headers';
|
|
578
|
+
|
|
555
579
|
const app = alxia()
|
|
556
580
|
.use(secureHeaders())
|
|
557
581
|
.get('/embed', ({ reply }) =>
|
|
@@ -563,3 +587,55 @@ const app = alxia()
|
|
|
563
587
|
}),
|
|
564
588
|
);
|
|
565
589
|
```
|
|
590
|
+
|
|
591
|
+
### A `try`/`catch` middleware's reply has no headers
|
|
592
|
+
|
|
593
|
+
**When:** a middleware wraps `next()` in a `try`/`catch` to answer errors in
|
|
594
|
+
its own format, is declared before `secureHeaders()`, and its reply comes
|
|
595
|
+
back without the security headers.
|
|
596
|
+
|
|
597
|
+
**Why:** `secureHeaders` settles `next()`: it sets the headers on the response
|
|
598
|
+
the route boundary would give (`onError`, the `HttpError`'s status, or a
|
|
599
|
+
500), then the error goes on to the middlewares around it. A `try`/`catch`
|
|
600
|
+
declared **before** it catches the error, but its reply is made outside
|
|
601
|
+
`secureHeaders`, so it carries no headers; the 500 inside did.
|
|
602
|
+
|
|
603
|
+
**Fix:** declare the error-handling middleware after `secureHeaders`, so
|
|
604
|
+
the headers are set on its reply:
|
|
605
|
+
|
|
606
|
+
```ts
|
|
607
|
+
import { alxia, defineMiddleware } from '@alxia/core';
|
|
608
|
+
import { secureHeaders } from '@alxia/secure-headers';
|
|
609
|
+
|
|
610
|
+
const errors = defineMiddleware(async (_ctx, next) => {
|
|
611
|
+
try {
|
|
612
|
+
return await next();
|
|
613
|
+
} catch (error) {
|
|
614
|
+
return Response.json({ error: String(error) }, { status: 500 });
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
const app = alxia()
|
|
619
|
+
.use(secureHeaders()) // first
|
|
620
|
+
.use(errors)
|
|
621
|
+
.get('/boom', () => {
|
|
622
|
+
throw new Error('boom');
|
|
623
|
+
});
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
### A route has no headers, though the others do
|
|
627
|
+
|
|
628
|
+
**When:** one route's responses lack the headers, while a `404` or another
|
|
629
|
+
route's have them.
|
|
630
|
+
|
|
631
|
+
**Why:** a route declared **before** `app.use(secureHeaders())` does not run
|
|
632
|
+
it. A request no route matches runs every top-level middleware wherever it
|
|
633
|
+
is declared, which is why its `404` is covered.
|
|
634
|
+
|
|
635
|
+
**Fix:** declare `secureHeaders` before the routes:
|
|
636
|
+
|
|
637
|
+
```ts
|
|
638
|
+
const app = alxia()
|
|
639
|
+
.use(secureHeaders())
|
|
640
|
+
.get('/early', ({ reply }) => reply(200, 'now covered'));
|
|
641
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alxia/secure-headers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Secure HTTP headers for alxia: HSTS, CSP, nosniff and the rest, each one adjustable",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -41,13 +41,12 @@
|
|
|
41
41
|
]
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@alxia/
|
|
45
|
-
"@alxia/core": "^0.3.0",
|
|
44
|
+
"@alxia/core": "^0.4.0",
|
|
46
45
|
"@types/bun": "^1.4.2",
|
|
47
46
|
"zod": "^4.2.0"
|
|
48
47
|
},
|
|
49
48
|
"peerDependencies": {
|
|
50
|
-
"@alxia/core": "^0.
|
|
49
|
+
"@alxia/core": "^0.4.0",
|
|
51
50
|
"typescript": "^6.0.3 || ^7.0.0"
|
|
52
51
|
}
|
|
53
52
|
}
|