@blamejs/core 0.7.86 → 0.7.88
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/CHANGELOG.md +4 -0
- package/lib/middleware/assetlinks.js +97 -0
- package/lib/middleware/index.js +12 -0
- package/lib/middleware/require-content-type.js +81 -0
- package/lib/middleware/require-methods.js +70 -0
- package/lib/middleware/web-app-manifest.js +111 -0
- package/lib/ntp-check.js +15 -3
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.7.x
|
|
10
10
|
|
|
11
|
+
- **0.7.88** (2026-05-06) — `b.middleware.webAppManifest` + `b.middleware.assetlinks` — two static-content middlewares for PWA + Trusted Web Activity support. **`b.middleware.webAppManifest({ name, start_url, icons, ... })`** serves the W3C Web App Manifest at `/manifest.webmanifest` (and `/manifest.json` when `alsoAtJsonPath: true`). The framework JSON-serializes once at create() and serves with `Content-Type: application/manifest+json` per the W3C spec + `Cache-Control: public, max-age=86400` + `X-Content-Type-Options: nosniff`. The W3C-spec attribute set is allowlisted (name / short_name / description / start_url / scope / display / display_override / orientation / theme_color / background_color / icons / screenshots / shortcuts / categories / lang / dir / id / prefer_related_applications / related_applications) — typos throw at create. `name`, `start_url`, and at least one icon are required (W3C — installability minimum). HEAD + GET only. **`b.middleware.assetlinks({ statements })`** serves Digital Asset Links at `/.well-known/assetlinks.json` per Google's spec — used by Trusted Web Activity, Android App Links, Smart Lock for Passwords, WebAuthn for Android. Validates each statement carries `relation` (non-empty array) and `target` (object). Same Content-Type / Cache-Control / X-Content-Type-Options posture as the security.txt + manifest emitters.
|
|
12
|
+
|
|
13
|
+
- **0.7.87** (2026-05-06) — Two route-guard middlewares for API hardening: `b.middleware.requireMethods` + `b.middleware.requireContentType`. **`b.middleware.requireMethods(["GET", "POST"])`** refuses any HTTP method outside the allowlist with `405 Method Not Allowed` + `Allow:` header listing the allowed methods (per RFC 9110 §15.5.6). Defends against unexpected verb routing — many CVE-class bugs trace to a route handler wired for GET that accidentally accepts arbitrary verbs (PROPFIND, OPTIONS, custom). **`b.middleware.requireContentType(["application/json"])`** refuses requests with a body (POST/PUT/PATCH by default) whose `Content-Type` isn't in the allowlist with `415 Unsupported Media Type` + `Accept:` header listing the allowed types (RFC 9110 §15.5.16). Defends against MIME-type confusion — a route that processes JSON shouldn't accept `application/x-www-form-urlencoded` even if the body parses. Both middlewares emit observability events (`middleware.requireMethods.denied` / `middleware.requireContentType.denied`) on every refusal for triage. Operators wanting to enforce content-type on idempotent verbs that DO carry bodies (rare DELETE-with-body shapes) override the default body-method list via `requireContentType(types, { methods })`.
|
|
14
|
+
|
|
11
15
|
- **0.7.86** (2026-05-06) — `b.middleware.csrfProtect({ requireJsonContentType: true })` — strict-fetch mode for JSON-only API surfaces. State-changing requests (POST/PUT/PATCH/DELETE) without `Content-Type: application/json` are refused before the token check with `CSRF: state-changing requests require Content-Type: application/json.` and audit emission `csrf.denied` with `reason: "non-JSON content-type: ..."`. The browser's form-encoded POST shape is the canonical CSRF vector — a malicious page can `<form action="/transfer" method=POST>` a victim into a state-changing request without a preflight. An `application/json` body forces a CORS preflight (the browser refuses to skip it for non-simple Content-Type values), so an attacker without an operator-allowlisted CORS origin can't reach the route at all. Default `false` — operators with HTML form submissions on the same routes (mixed SPA + classic form pages) keep current behavior; pure-fetch API operators opt in.
|
|
12
16
|
|
|
13
17
|
- **0.7.85** (2026-05-06) — `b.auth.statusList` — OAuth Token Status List (draft-ietf-oauth-status-list-20). The canonical credential-revocation mechanism for SD-JWT VC and OpenID for Verifiable Credentials. An issuer publishes a JWT-wrapped bitstring at a URL; relying parties fetch + check the bit at index N to determine if the credential whose `status_list` claim points at that URL+index is valid / invalid / suspended / application-specific. **`b.auth.statusList.create({ size, bits?, fill? })`** allocates a bit-packed buffer (`bits` ∈ {1, 2, 4, 8} per draft §6.1.1, default 1). The returned object exposes `.set(idx, status)` / `.get(idx)` / `.snapshot()` / `.toJwt({ issuer, subject, privateKey, algorithm, expiresInSec?, ... })`. **`b.auth.statusList.fromJwt(token, { publicKey | keyResolver, algorithms?, expectedIssuer?, ... })`** verifies the JWT through `b.auth.jwt.verify` and returns `{ list, claims }` — the list exposes `.get(idx)` to check status of an individual credential without decompressing into a separate Buffer. The bitstring is zlib-deflated (RFC 1951 raw deflate per draft §6.1.4) before base64url encoding so a million-entry list collapses to ~125 KB on the wire when most bits are zero. Caps the compressed payload at 1 MiB; operators publishing larger lists shard. Status constants exported as `b.auth.statusList.STATUS_{VALID,INVALID,SUSPENDED,APPLICATION_SPECIFIC}`. Foundational for the v0.7.58 EU AI Act + eIDAS 2.0 wallet slice.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* assetlinks middleware — emits Digital Asset Links at
|
|
4
|
+
* `/.well-known/assetlinks.json` per Google's Digital Asset Links
|
|
5
|
+
* spec (used by Trusted Web Activity / Android App Links / Smart
|
|
6
|
+
* Lock for Passwords / Web Authentication for Android, etc.).
|
|
7
|
+
*
|
|
8
|
+
* var al = b.middleware.assetlinks({
|
|
9
|
+
* statements: [
|
|
10
|
+
* {
|
|
11
|
+
* relation: ["delegate_permission/common.handle_all_urls"],
|
|
12
|
+
* target: {
|
|
13
|
+
* namespace: "android_app",
|
|
14
|
+
* package_name: "com.example.app",
|
|
15
|
+
* sha256_cert_fingerprints: ["AB:CD:..."],
|
|
16
|
+
* },
|
|
17
|
+
* },
|
|
18
|
+
* ],
|
|
19
|
+
* });
|
|
20
|
+
* router.use(al);
|
|
21
|
+
*
|
|
22
|
+
* The framework JSON-serializes the statements array once at
|
|
23
|
+
* create() and serves with `Content-Type: application/json` per
|
|
24
|
+
* Google's spec. Operators with multiple linked apps include
|
|
25
|
+
* multiple statement entries.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
var lazyRequire = require("../lazy-require");
|
|
29
|
+
var safeJson = require("../safe-json");
|
|
30
|
+
var validateOpts = require("../validate-opts");
|
|
31
|
+
var { defineClass } = require("../framework-error");
|
|
32
|
+
|
|
33
|
+
var AssetlinksError = defineClass("AssetlinksError", { alwaysPermanent: true });
|
|
34
|
+
|
|
35
|
+
var observability = lazyRequire(function () { return require("../observability"); });
|
|
36
|
+
|
|
37
|
+
function create(opts) {
|
|
38
|
+
validateOpts.requireObject(opts, "middleware.assetlinks", AssetlinksError);
|
|
39
|
+
validateOpts(opts, ["statements", "audit"], "middleware.assetlinks");
|
|
40
|
+
|
|
41
|
+
if (!Array.isArray(opts.statements) || opts.statements.length === 0) {
|
|
42
|
+
throw new AssetlinksError("assetlinks/no-statements",
|
|
43
|
+
"middleware.assetlinks: opts.statements must be a non-empty array of statement objects");
|
|
44
|
+
}
|
|
45
|
+
for (var i = 0; i < opts.statements.length; i += 1) {
|
|
46
|
+
var stmt = opts.statements[i];
|
|
47
|
+
if (!stmt || typeof stmt !== "object" || Array.isArray(stmt)) {
|
|
48
|
+
throw new AssetlinksError("assetlinks/bad-statement",
|
|
49
|
+
"middleware.assetlinks: statements[" + i + "] must be a plain object");
|
|
50
|
+
}
|
|
51
|
+
if (!Array.isArray(stmt.relation) || stmt.relation.length === 0) {
|
|
52
|
+
throw new AssetlinksError("assetlinks/bad-statement",
|
|
53
|
+
"middleware.assetlinks: statements[" + i + "].relation must be a non-empty array");
|
|
54
|
+
}
|
|
55
|
+
if (!stmt.target || typeof stmt.target !== "object") {
|
|
56
|
+
throw new AssetlinksError("assetlinks/bad-statement",
|
|
57
|
+
"middleware.assetlinks: statements[" + i + "].target must be an object");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
var body = safeJson.stringify(opts.statements, { space: 2 });
|
|
62
|
+
var bodyBuf = Buffer.from(body, "utf8");
|
|
63
|
+
var auditOn = opts.audit !== false;
|
|
64
|
+
|
|
65
|
+
return function assetlinksMiddleware(req, res, next) {
|
|
66
|
+
var url = req.url || "";
|
|
67
|
+
var qIdx = url.indexOf("?");
|
|
68
|
+
var path = qIdx === -1 ? url : url.slice(0, qIdx);
|
|
69
|
+
if (path !== "/.well-known/assetlinks.json") return next();
|
|
70
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
71
|
+
var bodyMsg = "Method Not Allowed";
|
|
72
|
+
res.writeHead(405, { // allow:raw-byte-literal — HTTP 405 status
|
|
73
|
+
"Allow": "GET, HEAD",
|
|
74
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
75
|
+
"Content-Length": Buffer.byteLength(bodyMsg),
|
|
76
|
+
});
|
|
77
|
+
res.end(bodyMsg);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
res.writeHead(200, { // allow:raw-byte-literal — HTTP 200 status
|
|
81
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
82
|
+
"Content-Length": bodyBuf.length,
|
|
83
|
+
"Cache-Control": "public, max-age=86400",
|
|
84
|
+
"X-Content-Type-Options": "nosniff",
|
|
85
|
+
});
|
|
86
|
+
if (req.method === "HEAD") { res.end(); return; }
|
|
87
|
+
res.end(bodyBuf);
|
|
88
|
+
if (auditOn) {
|
|
89
|
+
try { observability().safeEvent("middleware.assetlinks.served", 1, {}); }
|
|
90
|
+
catch (_e) { /* obs best-effort */ }
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = {
|
|
96
|
+
create: create,
|
|
97
|
+
};
|
package/lib/middleware/index.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* 7. errorHandler — must be LAST so it catches everything that throws
|
|
18
18
|
*/
|
|
19
19
|
var apiEncrypt = require("./api-encrypt");
|
|
20
|
+
var assetlinks = require("./assetlinks");
|
|
20
21
|
var attachUser = require("./attach-user");
|
|
21
22
|
var bearerAuth = require("./bearer-auth");
|
|
22
23
|
var bodyParser = require("./body-parser");
|
|
@@ -40,9 +41,12 @@ var requestId = require("./request-id");
|
|
|
40
41
|
var requestLog = require("./request-log");
|
|
41
42
|
var requireAal = require("./require-aal");
|
|
42
43
|
var requireAuth = require("./require-auth");
|
|
44
|
+
var requireContentType = require("./require-content-type");
|
|
45
|
+
var requireMethods = require("./require-methods");
|
|
43
46
|
var securityHeaders = require("./security-headers");
|
|
44
47
|
var securityTxt = require("./security-txt");
|
|
45
48
|
var sse = require("./sse");
|
|
49
|
+
var webAppManifest = require("./web-app-manifest");
|
|
46
50
|
|
|
47
51
|
module.exports = {
|
|
48
52
|
requestId: requestId.create,
|
|
@@ -55,6 +59,8 @@ module.exports = {
|
|
|
55
59
|
bearerAuth: bearerAuth.create,
|
|
56
60
|
requireAal: requireAal.create,
|
|
57
61
|
requireAuth: requireAuth.create,
|
|
62
|
+
requireContentType: requireContentType.create,
|
|
63
|
+
requireMethods: requireMethods.create,
|
|
58
64
|
csrfProtect: csrfProtect.create,
|
|
59
65
|
fetchMetadata: fetchMetadata.create,
|
|
60
66
|
gpc: gpc.create,
|
|
@@ -68,10 +74,12 @@ module.exports = {
|
|
|
68
74
|
sse: sse.create,
|
|
69
75
|
requestLog: requestLog.create,
|
|
70
76
|
apiEncrypt: apiEncrypt,
|
|
77
|
+
assetlinks: assetlinks.create,
|
|
71
78
|
dbRoleFor: dbRoleFor.create,
|
|
72
79
|
dpop: dpop.create,
|
|
73
80
|
hostAllowlist: hostAllowlist.create,
|
|
74
81
|
networkAllowlist: networkAllowlist.create,
|
|
82
|
+
webAppManifest: webAppManifest.create,
|
|
75
83
|
|
|
76
84
|
// Module exports for advanced use (constants, raw factory access)
|
|
77
85
|
_modules: {
|
|
@@ -85,6 +93,8 @@ module.exports = {
|
|
|
85
93
|
bearerAuth: bearerAuth,
|
|
86
94
|
requireAal: requireAal,
|
|
87
95
|
requireAuth: requireAuth,
|
|
96
|
+
requireContentType: requireContentType,
|
|
97
|
+
requireMethods: requireMethods,
|
|
88
98
|
csrfProtect: csrfProtect,
|
|
89
99
|
fetchMetadata: fetchMetadata,
|
|
90
100
|
bodyParser: bodyParser,
|
|
@@ -95,9 +105,11 @@ module.exports = {
|
|
|
95
105
|
sse: sse,
|
|
96
106
|
requestLog: requestLog,
|
|
97
107
|
apiEncrypt: apiEncrypt,
|
|
108
|
+
assetlinks: assetlinks,
|
|
98
109
|
dbRoleFor: dbRoleFor,
|
|
99
110
|
dpop: dpop,
|
|
100
111
|
hostAllowlist: hostAllowlist,
|
|
101
112
|
networkAllowlist: networkAllowlist,
|
|
113
|
+
webAppManifest: webAppManifest,
|
|
102
114
|
},
|
|
103
115
|
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* require-content-type middleware — refuses requests with a body
|
|
4
|
+
* (POST/PUT/PATCH) whose `Content-Type` header isn't in the
|
|
5
|
+
* operator-supplied allowlist.
|
|
6
|
+
*
|
|
7
|
+
* Defense against MIME-type confusion: a route that processes JSON
|
|
8
|
+
* shouldn't accept `application/x-www-form-urlencoded` even if the
|
|
9
|
+
* body parses (and vice versa). The middleware refuses with 415
|
|
10
|
+
* before the body parser runs, per RFC 9110 §15.5.16.
|
|
11
|
+
*
|
|
12
|
+
* router.use(b.middleware.requireContentType(["application/json"]));
|
|
13
|
+
*
|
|
14
|
+
* GET / HEAD / DELETE / OPTIONS without a body bypass the check by
|
|
15
|
+
* default. Operators wanting to enforce content-type on idempotent
|
|
16
|
+
* verbs that DO carry bodies (rare DELETE-with-body shapes) pass
|
|
17
|
+
* `methods` to override.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
var lazyRequire = require("../lazy-require");
|
|
21
|
+
var { defineClass } = require("../framework-error");
|
|
22
|
+
|
|
23
|
+
var RequireContentTypeError = defineClass("RequireContentTypeError", { alwaysPermanent: true });
|
|
24
|
+
|
|
25
|
+
var observability = lazyRequire(function () { return require("../observability"); });
|
|
26
|
+
|
|
27
|
+
var DEFAULT_BODY_METHODS = ["POST", "PUT", "PATCH"];
|
|
28
|
+
|
|
29
|
+
function _normalizeAllowed(types) {
|
|
30
|
+
if (!Array.isArray(types) || types.length === 0) return null;
|
|
31
|
+
var out = [];
|
|
32
|
+
for (var i = 0; i < types.length; i += 1) {
|
|
33
|
+
var t = types[i];
|
|
34
|
+
if (typeof t !== "string" || t.length === 0) return null;
|
|
35
|
+
var bare = t.split(";")[0].trim().toLowerCase();
|
|
36
|
+
if (bare.length === 0) return null;
|
|
37
|
+
out.push(bare);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function create(allowed, opts) {
|
|
43
|
+
var normalized = _normalizeAllowed(allowed);
|
|
44
|
+
if (!normalized) {
|
|
45
|
+
throw new RequireContentTypeError("require-content-type/no-allowlist",
|
|
46
|
+
"middleware.requireContentType: first argument must be a non-empty array of content-type strings");
|
|
47
|
+
}
|
|
48
|
+
opts = opts || {};
|
|
49
|
+
var methods = Array.isArray(opts.methods) && opts.methods.length > 0
|
|
50
|
+
? opts.methods.map(function (m) { return m.toUpperCase(); })
|
|
51
|
+
: DEFAULT_BODY_METHODS.slice();
|
|
52
|
+
var auditOn = opts.audit !== false;
|
|
53
|
+
|
|
54
|
+
return function requireContentTypeMiddleware(req, res, next) {
|
|
55
|
+
var m = (req.method || "").toUpperCase();
|
|
56
|
+
if (methods.indexOf(m) === -1) return next();
|
|
57
|
+
var ct = req.headers && req.headers["content-type"];
|
|
58
|
+
var bare = (typeof ct === "string" ? ct.split(";")[0].trim().toLowerCase() : "");
|
|
59
|
+
if (bare.length > 0 && normalized.indexOf(bare) !== -1) return next();
|
|
60
|
+
if (!res.headersSent) {
|
|
61
|
+
var body = "Unsupported Media Type";
|
|
62
|
+
res.writeHead(415, { // allow:raw-byte-literal — HTTP 415 status
|
|
63
|
+
"Accept": normalized.join(", "),
|
|
64
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
65
|
+
"Content-Length": Buffer.byteLength(body),
|
|
66
|
+
});
|
|
67
|
+
res.end(body);
|
|
68
|
+
}
|
|
69
|
+
if (auditOn) {
|
|
70
|
+
try {
|
|
71
|
+
observability().safeEvent("middleware.requireContentType.denied", 1, {
|
|
72
|
+
method: m, contentType: bare || "<absent>", route: req.url,
|
|
73
|
+
});
|
|
74
|
+
} catch (_e) { /* drop-silent */ }
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = {
|
|
80
|
+
create: create,
|
|
81
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* require-methods middleware — refuses HTTP methods outside an
|
|
4
|
+
* operator-supplied allowlist.
|
|
5
|
+
*
|
|
6
|
+
* Defense against unexpected verb routing. Many CVE-class bugs trace
|
|
7
|
+
* to a route handler that was wired for GET but accidentally also
|
|
8
|
+
* accepts arbitrary verbs (PROPFIND, OPTIONS, custom). Mounting
|
|
9
|
+
* `requireMethods(["GET", "POST"])` on the route blocks anything
|
|
10
|
+
* outside the allowlist before the handler sees the request.
|
|
11
|
+
*
|
|
12
|
+
* router.use(b.middleware.requireMethods(["GET", "POST"]));
|
|
13
|
+
*
|
|
14
|
+
* Refusal returns 405 with `Allow:` listing the allowed methods, per
|
|
15
|
+
* RFC 9110 §15.5.6.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
var lazyRequire = require("../lazy-require");
|
|
19
|
+
var { defineClass } = require("../framework-error");
|
|
20
|
+
|
|
21
|
+
var RequireMethodsError = defineClass("RequireMethodsError", { alwaysPermanent: true });
|
|
22
|
+
|
|
23
|
+
var observability = lazyRequire(function () { return require("../observability"); });
|
|
24
|
+
|
|
25
|
+
function create(allowed, opts) {
|
|
26
|
+
if (!Array.isArray(allowed) || allowed.length === 0) {
|
|
27
|
+
throw new RequireMethodsError("require-methods/no-allowlist",
|
|
28
|
+
"middleware.requireMethods: first argument must be a non-empty array of HTTP methods");
|
|
29
|
+
}
|
|
30
|
+
var normalized = [];
|
|
31
|
+
for (var i = 0; i < allowed.length; i += 1) {
|
|
32
|
+
if (typeof allowed[i] !== "string" || allowed[i].length === 0) {
|
|
33
|
+
throw new RequireMethodsError("require-methods/bad-method",
|
|
34
|
+
"middleware.requireMethods: method[" + i + "] must be a non-empty string");
|
|
35
|
+
}
|
|
36
|
+
if (/[\r\n\0\s,;]/.test(allowed[i])) {
|
|
37
|
+
throw new RequireMethodsError("require-methods/bad-method",
|
|
38
|
+
"middleware.requireMethods: method[" + i + "] contains forbidden whitespace / separator characters");
|
|
39
|
+
}
|
|
40
|
+
normalized.push(allowed[i].toUpperCase());
|
|
41
|
+
}
|
|
42
|
+
var allowHeader = normalized.join(", ");
|
|
43
|
+
opts = opts || {};
|
|
44
|
+
var auditOn = opts.audit !== false;
|
|
45
|
+
|
|
46
|
+
return function requireMethodsMiddleware(req, res, next) {
|
|
47
|
+
var m = (req.method || "").toUpperCase();
|
|
48
|
+
if (normalized.indexOf(m) !== -1) return next();
|
|
49
|
+
if (!res.headersSent) {
|
|
50
|
+
var body = "Method Not Allowed";
|
|
51
|
+
res.writeHead(405, { // allow:raw-byte-literal — HTTP 405 status
|
|
52
|
+
"Allow": allowHeader,
|
|
53
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
54
|
+
"Content-Length": Buffer.byteLength(body),
|
|
55
|
+
});
|
|
56
|
+
res.end(body);
|
|
57
|
+
}
|
|
58
|
+
if (auditOn) {
|
|
59
|
+
try {
|
|
60
|
+
observability().safeEvent("middleware.requireMethods.denied", 1, {
|
|
61
|
+
method: m, route: req.url,
|
|
62
|
+
});
|
|
63
|
+
} catch (_e) { /* drop-silent */ }
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = {
|
|
69
|
+
create: create,
|
|
70
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* web-app-manifest middleware — emits the W3C Web App Manifest at
|
|
4
|
+
* `/manifest.webmanifest` (and `/manifest.json` when `alsoAtJsonPath`
|
|
5
|
+
* is true) per the W3C Web App Manifest specification.
|
|
6
|
+
*
|
|
7
|
+
* var mf = b.middleware.webAppManifest({
|
|
8
|
+
* name: "Example App",
|
|
9
|
+
* short_name: "Example",
|
|
10
|
+
* start_url: "/",
|
|
11
|
+
* display: "standalone",
|
|
12
|
+
* theme_color: "#1976d2",
|
|
13
|
+
* background_color: "#ffffff",
|
|
14
|
+
* icons: [
|
|
15
|
+
* { src: "/icons/192.png", sizes: "192x192", type: "image/png" },
|
|
16
|
+
* { src: "/icons/512.png", sizes: "512x512", type: "image/png" },
|
|
17
|
+
* ],
|
|
18
|
+
* });
|
|
19
|
+
* router.use(mf);
|
|
20
|
+
*
|
|
21
|
+
* The manifest is JSON-serialized once at create() and served with
|
|
22
|
+
* `Content-Type: application/manifest+json` per the W3C spec.
|
|
23
|
+
*
|
|
24
|
+
* Per W3C — `name`, `start_url`, and at least one icon are required
|
|
25
|
+
* for an installable PWA. The framework throws at create() when any
|
|
26
|
+
* of those are missing.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
var lazyRequire = require("../lazy-require");
|
|
30
|
+
var safeJson = require("../safe-json");
|
|
31
|
+
var validateOpts = require("../validate-opts");
|
|
32
|
+
var { defineClass } = require("../framework-error");
|
|
33
|
+
|
|
34
|
+
var WebAppManifestError = defineClass("WebAppManifestError", { alwaysPermanent: true });
|
|
35
|
+
|
|
36
|
+
var observability = lazyRequire(function () { return require("../observability"); });
|
|
37
|
+
|
|
38
|
+
function _isPlainArray(x) { return Array.isArray(x); }
|
|
39
|
+
|
|
40
|
+
function create(opts) {
|
|
41
|
+
validateOpts.requireObject(opts, "middleware.webAppManifest", WebAppManifestError);
|
|
42
|
+
// Allowlist subset of W3C-spec attributes operators commonly set.
|
|
43
|
+
// Anything outside the list throws at create — typos surface at boot.
|
|
44
|
+
validateOpts(opts, [
|
|
45
|
+
"name", "short_name", "description", "start_url", "scope",
|
|
46
|
+
"display", "display_override", "orientation",
|
|
47
|
+
"theme_color", "background_color",
|
|
48
|
+
"icons", "screenshots", "shortcuts",
|
|
49
|
+
"categories", "lang", "dir", "id",
|
|
50
|
+
"prefer_related_applications", "related_applications",
|
|
51
|
+
"alsoAtJsonPath", "audit",
|
|
52
|
+
], "middleware.webAppManifest");
|
|
53
|
+
|
|
54
|
+
validateOpts.requireNonEmptyString(opts.name,
|
|
55
|
+
"middleware.webAppManifest: name", WebAppManifestError, "manifest/no-name");
|
|
56
|
+
validateOpts.requireNonEmptyString(opts.start_url,
|
|
57
|
+
"middleware.webAppManifest: start_url", WebAppManifestError, "manifest/no-start-url");
|
|
58
|
+
if (!_isPlainArray(opts.icons) || opts.icons.length === 0) {
|
|
59
|
+
throw new WebAppManifestError("manifest/no-icons",
|
|
60
|
+
"middleware.webAppManifest: icons array is required (W3C spec — at least one icon for installability)");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Build the JSON body once at create.
|
|
64
|
+
var manifest = {};
|
|
65
|
+
var keys = Object.keys(opts).filter(function (k) {
|
|
66
|
+
return k !== "alsoAtJsonPath" && k !== "audit";
|
|
67
|
+
});
|
|
68
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
69
|
+
var k = keys[i];
|
|
70
|
+
if (opts[k] !== undefined && opts[k] !== null) manifest[k] = opts[k];
|
|
71
|
+
}
|
|
72
|
+
var body = safeJson.stringify(manifest, { space: 2 });
|
|
73
|
+
var bodyBuf = Buffer.from(body, "utf8");
|
|
74
|
+
var alsoAtJsonPath = opts.alsoAtJsonPath === true;
|
|
75
|
+
var auditOn = opts.audit !== false;
|
|
76
|
+
|
|
77
|
+
return function webAppManifestMiddleware(req, res, next) {
|
|
78
|
+
var url = req.url || "";
|
|
79
|
+
var qIdx = url.indexOf("?");
|
|
80
|
+
var path = qIdx === -1 ? url : url.slice(0, qIdx);
|
|
81
|
+
var matches = (path === "/manifest.webmanifest") ||
|
|
82
|
+
(alsoAtJsonPath && path === "/manifest.json");
|
|
83
|
+
if (!matches) return next();
|
|
84
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
85
|
+
var bodyMsg = "Method Not Allowed";
|
|
86
|
+
res.writeHead(405, { // allow:raw-byte-literal — HTTP 405 status
|
|
87
|
+
"Allow": "GET, HEAD",
|
|
88
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
89
|
+
"Content-Length": Buffer.byteLength(bodyMsg),
|
|
90
|
+
});
|
|
91
|
+
res.end(bodyMsg);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
res.writeHead(200, { // allow:raw-byte-literal — HTTP 200 status
|
|
95
|
+
"Content-Type": "application/manifest+json",
|
|
96
|
+
"Content-Length": bodyBuf.length,
|
|
97
|
+
"Cache-Control": "public, max-age=86400",
|
|
98
|
+
"X-Content-Type-Options": "nosniff",
|
|
99
|
+
});
|
|
100
|
+
if (req.method === "HEAD") { res.end(); return; }
|
|
101
|
+
res.end(bodyBuf);
|
|
102
|
+
if (auditOn) {
|
|
103
|
+
try { observability().safeEvent("middleware.webAppManifest.served", 1, { path: path }); }
|
|
104
|
+
catch (_e) { /* obs best-effort */ }
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = {
|
|
110
|
+
create: create,
|
|
111
|
+
};
|
package/lib/ntp-check.js
CHANGED
|
@@ -124,10 +124,22 @@ function querySingle(server, opts) {
|
|
|
124
124
|
return done({ code: "ntp/bad-reply", message: "reply too short (" + (msg && msg.length) + " bytes)" });
|
|
125
125
|
}
|
|
126
126
|
// Bytes 40-47 = Transmit Timestamp (NTP epoch seconds.fraction)
|
|
127
|
-
var ntpSeconds = msg.readUInt32BE(40);
|
|
128
|
-
var ntpFraction = msg.readUInt32BE(44);
|
|
127
|
+
var ntpSeconds = msg.readUInt32BE(40); // allow:raw-byte-literal — NTP packet offset
|
|
128
|
+
var ntpFraction = msg.readUInt32BE(44); // allow:raw-byte-literal — NTP packet offset
|
|
129
|
+
// Refuse a reply whose Transmit Timestamp is zero or earlier than
|
|
130
|
+
// the NTP epoch (1900-01-01). RFC 5905 §7.3 — a Stratum-16
|
|
131
|
+
// unsynchronized server emits 0 here; fed to the Unix-offset
|
|
132
|
+
// subtraction it produces a large-negative serverUnixSeconds
|
|
133
|
+
// that crashes downstream C.TIME helpers (which require non-
|
|
134
|
+
// negative finite). Treat as "unsynchronized peer — no drift
|
|
135
|
+
// measurement possible" rather than throw out of the dgram
|
|
136
|
+
// 'message' handler.
|
|
137
|
+
if (ntpSeconds < NTP_TO_UNIX_OFFSET_SECONDS) {
|
|
138
|
+
return done({ code: "ntp/unsynchronized",
|
|
139
|
+
message: "server returned NTP transmit timestamp < Unix epoch (likely Stratum-16 unsynchronized)" });
|
|
140
|
+
}
|
|
129
141
|
var serverUnixSeconds = ntpSeconds - NTP_TO_UNIX_OFFSET_SECONDS;
|
|
130
|
-
var fracMs = Math.round(C.TIME.seconds(ntpFraction / 0x100000000));
|
|
142
|
+
var fracMs = Math.round(C.TIME.seconds(ntpFraction / 0x100000000)); // allow:raw-byte-literal — NTP fraction divisor (2^32)
|
|
131
143
|
var serverTimeMs = C.TIME.seconds(serverUnixSeconds) + fracMs;
|
|
132
144
|
|
|
133
145
|
// Round-trip-corrected drift: assume the server's reply transmit
|
package/package.json
CHANGED
package/sbom.cyclonedx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:5980b589-6ae2-43c3-88ad-31cb87e43594",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-06T06:
|
|
8
|
+
"timestamp": "2026-05-06T06:50:00.974Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.7.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.7.88",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.7.
|
|
25
|
+
"version": "0.7.88",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.7.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.7.88",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.7.
|
|
57
|
+
"ref": "@blamejs/core@0.7.88",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|