@blamejs/core 0.4.1
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 +230 -0
- package/LICENSE +201 -0
- package/LTS-CALENDAR.md +29 -0
- package/MIGRATING.md +7 -0
- package/NOTICE +59 -0
- package/README.md +100 -0
- package/bin/blamejs.js +13 -0
- package/index.js +253 -0
- package/lib/api-key.js +705 -0
- package/lib/api-snapshot.js +335 -0
- package/lib/app-shutdown.js +381 -0
- package/lib/app.js +364 -0
- package/lib/atomic-file.js +525 -0
- package/lib/audit-chain.js +168 -0
- package/lib/audit-sign.js +319 -0
- package/lib/audit-tools.js +682 -0
- package/lib/audit.js +753 -0
- package/lib/auth/jwt.js +280 -0
- package/lib/auth/oauth.js +691 -0
- package/lib/auth/passkey.js +185 -0
- package/lib/auth/password.js +139 -0
- package/lib/auth/totp.js +17 -0
- package/lib/auth-header.js +81 -0
- package/lib/backup/bundle.js +219 -0
- package/lib/backup/crypto.js +174 -0
- package/lib/backup/index.js +490 -0
- package/lib/backup/manifest.js +275 -0
- package/lib/bundler.js +295 -0
- package/lib/cache.js +819 -0
- package/lib/chain-writer.js +234 -0
- package/lib/cli-helpers.js +201 -0
- package/lib/cli.js +1377 -0
- package/lib/cluster-provider-db.js +245 -0
- package/lib/cluster-storage.js +166 -0
- package/lib/cluster.js +691 -0
- package/lib/consent.js +222 -0
- package/lib/constants.js +186 -0
- package/lib/cookies.js +293 -0
- package/lib/credential-hash.js +303 -0
- package/lib/crypto-field.js +159 -0
- package/lib/crypto.js +250 -0
- package/lib/db-query.js +297 -0
- package/lib/db-schema.js +250 -0
- package/lib/db.js +1054 -0
- package/lib/deprecate.js +226 -0
- package/lib/dev.js +324 -0
- package/lib/error-page.js +424 -0
- package/lib/events.js +135 -0
- package/lib/external-db.js +422 -0
- package/lib/forms.js +378 -0
- package/lib/framework-error.js +189 -0
- package/lib/framework-schema.js +604 -0
- package/lib/handlers.js +350 -0
- package/lib/html-balance.js +227 -0
- package/lib/http-client.js +615 -0
- package/lib/i18n.js +780 -0
- package/lib/jobs.js +181 -0
- package/lib/lazy-require.js +48 -0
- package/lib/log-stream-local.js +137 -0
- package/lib/log-stream-webhook.js +170 -0
- package/lib/log-stream.js +211 -0
- package/lib/log.js +355 -0
- package/lib/mail-bounce.js +507 -0
- package/lib/mail.js +701 -0
- package/lib/metrics.js +647 -0
- package/lib/middleware/api-encrypt.js +553 -0
- package/lib/middleware/attach-user.js +156 -0
- package/lib/middleware/body-parser.js +883 -0
- package/lib/middleware/bot-guard.js +148 -0
- package/lib/middleware/compression.js +436 -0
- package/lib/middleware/cors.js +236 -0
- package/lib/middleware/csp-nonce.js +332 -0
- package/lib/middleware/csrf-protect.js +275 -0
- package/lib/middleware/error-handler.js +46 -0
- package/lib/middleware/health.js +358 -0
- package/lib/middleware/index.js +52 -0
- package/lib/middleware/rate-limit.js +319 -0
- package/lib/middleware/request-id.js +53 -0
- package/lib/middleware/require-auth.js +95 -0
- package/lib/middleware/security-headers.js +91 -0
- package/lib/migrations.js +353 -0
- package/lib/mtls-ca.js +333 -0
- package/lib/mtls-engine-default.js +285 -0
- package/lib/nonce-store.js +177 -0
- package/lib/notify.js +643 -0
- package/lib/ntp-check.js +178 -0
- package/lib/object-store/azure-blob.js +467 -0
- package/lib/object-store/gcs.js +469 -0
- package/lib/object-store/http-put.js +153 -0
- package/lib/object-store/index.js +140 -0
- package/lib/object-store/local.js +163 -0
- package/lib/object-store/retry.js +15 -0
- package/lib/object-store/sigv4.js +535 -0
- package/lib/observability.js +114 -0
- package/lib/pagination.js +371 -0
- package/lib/parsers/index.js +64 -0
- package/lib/parsers/safe-csv.js +224 -0
- package/lib/parsers/safe-env.js +614 -0
- package/lib/parsers/safe-toml.js +745 -0
- package/lib/parsers/safe-xml.js +379 -0
- package/lib/parsers/safe-yaml.js +977 -0
- package/lib/permissions.js +430 -0
- package/lib/pqc-agent.js +85 -0
- package/lib/pqc-gate.js +266 -0
- package/lib/protocol-dispatcher.js +144 -0
- package/lib/queue-local.js +327 -0
- package/lib/queue.js +430 -0
- package/lib/redact.js +192 -0
- package/lib/render.js +193 -0
- package/lib/request-helpers.js +178 -0
- package/lib/restore-bundle.js +239 -0
- package/lib/restore-rollback.js +254 -0
- package/lib/restore.js +301 -0
- package/lib/retry.js +329 -0
- package/lib/router.js +437 -0
- package/lib/safe-async.js +520 -0
- package/lib/safe-buffer.js +162 -0
- package/lib/safe-json.js +532 -0
- package/lib/safe-schema.js +1176 -0
- package/lib/safe-sql.js +157 -0
- package/lib/safe-url.js +109 -0
- package/lib/scheduler.js +680 -0
- package/lib/seeders.js +622 -0
- package/lib/session.js +304 -0
- package/lib/slug.js +243 -0
- package/lib/static.js +268 -0
- package/lib/storage.js +470 -0
- package/lib/subject.js +281 -0
- package/lib/template.js +781 -0
- package/lib/testing.js +621 -0
- package/lib/totp.js +285 -0
- package/lib/tracing.js +484 -0
- package/lib/validate-opts.js +56 -0
- package/lib/vault/index.js +299 -0
- package/lib/vault/passphrase-ops.js +311 -0
- package/lib/vault/passphrase-source.js +198 -0
- package/lib/vault/rotate.js +761 -0
- package/lib/vault/wrap.js +289 -0
- package/lib/vendor/MANIFEST.json +84 -0
- package/lib/vendor/argon2/argon2.cjs +466 -0
- package/lib/vendor/argon2/argon2.d.cts +62 -0
- package/lib/vendor/argon2/package.json +1 -0
- package/lib/vendor/argon2/prebuilds/darwin-arm64/argon2.armv8.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/darwin-x64/argon2.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/freebsd-arm64/argon2.armv8.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/freebsd-x64/argon2.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm/argon2.armv7.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm/argon2.armv7.musl.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm64/argon2.armv8.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm64/argon2.armv8.musl.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-x64/argon2.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-x64/argon2.musl.node +0 -0
- package/lib/vendor/argon2/prebuilds/win32-x64/argon2.glibc.node +0 -0
- package/lib/vendor/noble-ciphers.cjs +9 -0
- package/lib/vendor/pki.cjs +181 -0
- package/lib/vendor/simplewebauthn-server.cjs +328 -0
- package/lib/webhook.js +632 -0
- package/lib/websocket-channels.js +413 -0
- package/lib/websocket.js +833 -0
- package/package.json +39 -0
package/lib/forms.js
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Forms — CSRF tokens, HTML rendering, server-side validation.
|
|
4
|
+
*
|
|
5
|
+
* Three concerns the framework owns at the form layer:
|
|
6
|
+
*
|
|
7
|
+
* 1. CSRF tokens. Generation is a 32-byte random hex string;
|
|
8
|
+
* verification is constant-time. The middleware in
|
|
9
|
+
* lib/middleware/csrf-protect.js does the actual gating —
|
|
10
|
+
* this module provides the primitives the operator (or
|
|
11
|
+
* template) calls to issue a token and embed it.
|
|
12
|
+
*
|
|
13
|
+
* 2. Form HTML rendering. forms.render({ action, fields, csrfToken })
|
|
14
|
+
* produces a complete <form> element with auto-escaped attributes,
|
|
15
|
+
* a hidden CSRF input, and field-type dispatch (text / email /
|
|
16
|
+
* password / number / checkbox / radio / textarea / select /
|
|
17
|
+
* hidden / submit). All values pass through escapeAttribute so
|
|
18
|
+
* the form can't be hijacked by user-supplied attribute payloads.
|
|
19
|
+
*
|
|
20
|
+
* 3. Server-side validation. forms.validate(spec, body) walks the
|
|
21
|
+
* same field spec the renderer accepts and returns
|
|
22
|
+
* { valid, errors, values } — coerced types, required-field
|
|
23
|
+
* checks, length bounds, regex pattern, enum membership.
|
|
24
|
+
*
|
|
25
|
+
* The renderer + validator share their field spec on purpose: an
|
|
26
|
+
* operator's "this is what the form looks like" is also "this is
|
|
27
|
+
* what the form expects." A change to the spec adjusts both.
|
|
28
|
+
*
|
|
29
|
+
* Public API:
|
|
30
|
+
*
|
|
31
|
+
* forms.generateCsrfToken() → "<64 hex chars>"
|
|
32
|
+
* forms.verifyCsrfToken(submitted, expected) → boolean (timing-safe)
|
|
33
|
+
* forms.render(spec) → string (complete <form>…</form>)
|
|
34
|
+
* forms.validate(spec, body) → { valid, errors, values }
|
|
35
|
+
* forms.escapeAttribute(value) → string (double-quoted-attr context)
|
|
36
|
+
* forms.escapeHtml = template.escapeHtml (re-export for convenience)
|
|
37
|
+
*/
|
|
38
|
+
var nodeCrypto = require("crypto");
|
|
39
|
+
var template = require("./template");
|
|
40
|
+
|
|
41
|
+
// ============================================================
|
|
42
|
+
// CSRF token primitives
|
|
43
|
+
// ============================================================
|
|
44
|
+
|
|
45
|
+
var CSRF_TOKEN_BYTES = 32;
|
|
46
|
+
|
|
47
|
+
function generateCsrfToken() {
|
|
48
|
+
return nodeCrypto.randomBytes(CSRF_TOKEN_BYTES).toString("hex");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function verifyCsrfToken(submitted, expected) {
|
|
52
|
+
if (typeof submitted !== "string" || typeof expected !== "string") return false;
|
|
53
|
+
if (submitted.length === 0 || submitted.length !== expected.length) return false;
|
|
54
|
+
var a = Buffer.from(submitted, "utf8");
|
|
55
|
+
var b = Buffer.from(expected, "utf8");
|
|
56
|
+
if (a.length !== b.length) return false;
|
|
57
|
+
return nodeCrypto.timingSafeEqual(a, b);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ============================================================
|
|
61
|
+
// Attribute escaping
|
|
62
|
+
// ============================================================
|
|
63
|
+
//
|
|
64
|
+
// More strict than template.escapeHtml: escapes the same set plus
|
|
65
|
+
// backtick (some browsers treat ` as an attribute delimiter under
|
|
66
|
+
// quirks-mode parsing) and = (defense-in-depth for unquoted-attribute
|
|
67
|
+
// contexts the renderer happens to produce). Forms render attributes
|
|
68
|
+
// in double-quoted contexts only, so this is belt-and-suspenders.
|
|
69
|
+
|
|
70
|
+
var ATTR_ESCAPE_MAP = {
|
|
71
|
+
"&": "&", "<": "<", ">": ">",
|
|
72
|
+
'"': """, "'": "'",
|
|
73
|
+
"`": "`", "=": "=",
|
|
74
|
+
};
|
|
75
|
+
var ATTR_ESCAPE_RE = /[&<>"'`=]/g;
|
|
76
|
+
|
|
77
|
+
function escapeAttribute(value) {
|
|
78
|
+
if (value === null || value === undefined) return "";
|
|
79
|
+
var s = typeof value === "string" ? value : String(value);
|
|
80
|
+
return s.replace(ATTR_ESCAPE_RE, function (c) { return ATTR_ESCAPE_MAP[c]; });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ============================================================
|
|
84
|
+
// Form rendering
|
|
85
|
+
// ============================================================
|
|
86
|
+
//
|
|
87
|
+
// spec = {
|
|
88
|
+
// action: string — form action URL (required)
|
|
89
|
+
// method: "POST" (default) | "GET" | …
|
|
90
|
+
// csrfToken: string — embedded as <input name="_csrf">
|
|
91
|
+
// csrfFieldName: string (default "_csrf") — match what csrfProtect expects
|
|
92
|
+
// id, className: string — top-level form attributes
|
|
93
|
+
// fields: FieldSpec[]
|
|
94
|
+
// submitLabel: string (default "Submit") — auto-appended submit button
|
|
95
|
+
// if no field of type=submit present
|
|
96
|
+
// enctype: string — multipart/form-data etc.
|
|
97
|
+
// autocomplete: string — "off" / "on"
|
|
98
|
+
// }
|
|
99
|
+
//
|
|
100
|
+
// FieldSpec common keys:
|
|
101
|
+
// name: string (required)
|
|
102
|
+
// type: "text" | "email" | "password" | "number" | "tel" | "url" |
|
|
103
|
+
// "search" | "date" | "time" | "datetime-local" | "checkbox" |
|
|
104
|
+
// "radio" | "textarea" | "select" | "hidden" | "submit"
|
|
105
|
+
// value: initial value (auto-escaped)
|
|
106
|
+
// label: string — wraps a <label>
|
|
107
|
+
// placeholder, required, readonly, disabled, autocomplete
|
|
108
|
+
// min, max, step, pattern, minlength, maxlength
|
|
109
|
+
// options: [{ value, label, selected? }] — for select / radio
|
|
110
|
+
// rows, cols — for textarea
|
|
111
|
+
|
|
112
|
+
var INPUT_TYPES = {
|
|
113
|
+
"text": 1, "email": 1, "password": 1, "number": 1, "tel": 1, "url": 1,
|
|
114
|
+
"search": 1, "date": 1, "time": 1, "datetime-local": 1, "month": 1, "week": 1,
|
|
115
|
+
"checkbox": 1, "radio": 1, "hidden": 1, "submit": 1, "color": 1,
|
|
116
|
+
"file": 1, "range": 1, "image": 1, "reset": 1, "button": 1,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
function _renderInput(field) {
|
|
120
|
+
var attrs = [
|
|
121
|
+
'type="' + escapeAttribute(field.type) + '"',
|
|
122
|
+
'name="' + escapeAttribute(field.name) + '"',
|
|
123
|
+
];
|
|
124
|
+
if (field.value !== undefined && field.value !== null) {
|
|
125
|
+
attrs.push('value="' + escapeAttribute(field.value) + '"');
|
|
126
|
+
}
|
|
127
|
+
if (field.placeholder) attrs.push('placeholder="' + escapeAttribute(field.placeholder) + '"');
|
|
128
|
+
if (field.required) attrs.push("required");
|
|
129
|
+
if (field.readonly) attrs.push("readonly");
|
|
130
|
+
if (field.disabled) attrs.push("disabled");
|
|
131
|
+
if (field.checked) attrs.push("checked");
|
|
132
|
+
if (field.autocomplete) attrs.push('autocomplete="' + escapeAttribute(field.autocomplete) + '"');
|
|
133
|
+
if (field.pattern) attrs.push('pattern="' + escapeAttribute(field.pattern) + '"');
|
|
134
|
+
if (field.min !== undefined) attrs.push('min="' + escapeAttribute(field.min) + '"');
|
|
135
|
+
if (field.max !== undefined) attrs.push('max="' + escapeAttribute(field.max) + '"');
|
|
136
|
+
if (field.step !== undefined) attrs.push('step="' + escapeAttribute(field.step) + '"');
|
|
137
|
+
if (field.minlength !== undefined) attrs.push('minlength="' + escapeAttribute(field.minlength) + '"');
|
|
138
|
+
if (field.maxlength !== undefined) attrs.push('maxlength="' + escapeAttribute(field.maxlength) + '"');
|
|
139
|
+
if (field.id) attrs.push('id="' + escapeAttribute(field.id) + '"');
|
|
140
|
+
if (field.className) attrs.push('class="' + escapeAttribute(field.className) + '"');
|
|
141
|
+
return "<input " + attrs.join(" ") + ">";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function _renderTextarea(field) {
|
|
145
|
+
var attrs = ['name="' + escapeAttribute(field.name) + '"'];
|
|
146
|
+
if (field.placeholder) attrs.push('placeholder="' + escapeAttribute(field.placeholder) + '"');
|
|
147
|
+
if (field.required) attrs.push("required");
|
|
148
|
+
if (field.readonly) attrs.push("readonly");
|
|
149
|
+
if (field.disabled) attrs.push("disabled");
|
|
150
|
+
if (field.rows !== undefined) attrs.push('rows="' + escapeAttribute(field.rows) + '"');
|
|
151
|
+
if (field.cols !== undefined) attrs.push('cols="' + escapeAttribute(field.cols) + '"');
|
|
152
|
+
if (field.minlength !== undefined) attrs.push('minlength="' + escapeAttribute(field.minlength) + '"');
|
|
153
|
+
if (field.maxlength !== undefined) attrs.push('maxlength="' + escapeAttribute(field.maxlength) + '"');
|
|
154
|
+
if (field.id) attrs.push('id="' + escapeAttribute(field.id) + '"');
|
|
155
|
+
if (field.className) attrs.push('class="' + escapeAttribute(field.className) + '"');
|
|
156
|
+
var body = field.value !== undefined && field.value !== null ? template.escapeHtml(field.value) : "";
|
|
157
|
+
return "<textarea " + attrs.join(" ") + ">" + body + "</textarea>";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function _renderSelect(field) {
|
|
161
|
+
var attrs = ['name="' + escapeAttribute(field.name) + '"'];
|
|
162
|
+
if (field.required) attrs.push("required");
|
|
163
|
+
if (field.disabled) attrs.push("disabled");
|
|
164
|
+
if (field.multiple) attrs.push("multiple");
|
|
165
|
+
if (field.id) attrs.push('id="' + escapeAttribute(field.id) + '"');
|
|
166
|
+
if (field.className) attrs.push('class="' + escapeAttribute(field.className) + '"');
|
|
167
|
+
var options = field.options || [];
|
|
168
|
+
var optionsHtml = options.map(function (o) {
|
|
169
|
+
var optAttrs = ['value="' + escapeAttribute(o.value) + '"'];
|
|
170
|
+
var selected = (o.selected) || (field.value !== undefined && String(field.value) === String(o.value));
|
|
171
|
+
if (selected) optAttrs.push("selected");
|
|
172
|
+
if (o.disabled) optAttrs.push("disabled");
|
|
173
|
+
return "<option " + optAttrs.join(" ") + ">" + template.escapeHtml(o.label === undefined ? o.value : o.label) + "</option>";
|
|
174
|
+
}).join("");
|
|
175
|
+
return "<select " + attrs.join(" ") + ">" + optionsHtml + "</select>";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function _renderField(field) {
|
|
179
|
+
if (!field || !field.name || typeof field.name !== "string") {
|
|
180
|
+
throw new Error("forms.render: each field requires a name");
|
|
181
|
+
}
|
|
182
|
+
var type = field.type || "text";
|
|
183
|
+
var control;
|
|
184
|
+
if (type === "textarea") control = _renderTextarea(field);
|
|
185
|
+
else if (type === "select") control = _renderSelect(field);
|
|
186
|
+
else if (INPUT_TYPES[type]) control = _renderInput(field);
|
|
187
|
+
else throw new Error("forms.render: unsupported field type: " + type);
|
|
188
|
+
|
|
189
|
+
// Hidden + submit fields don't need a label wrapper
|
|
190
|
+
if (type === "hidden" || type === "submit") return control;
|
|
191
|
+
|
|
192
|
+
if (field.label) {
|
|
193
|
+
return "<label>" + template.escapeHtml(field.label) + " " + control + "</label>";
|
|
194
|
+
}
|
|
195
|
+
return control;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function render(spec) {
|
|
199
|
+
if (!spec || typeof spec.action !== "string" || spec.action.length === 0) {
|
|
200
|
+
throw new Error("forms.render: spec.action is required");
|
|
201
|
+
}
|
|
202
|
+
if (!Array.isArray(spec.fields)) {
|
|
203
|
+
throw new Error("forms.render: spec.fields must be an array");
|
|
204
|
+
}
|
|
205
|
+
var method = (spec.method || "POST").toUpperCase();
|
|
206
|
+
var attrs = [
|
|
207
|
+
'method="' + escapeAttribute(method) + '"',
|
|
208
|
+
'action="' + escapeAttribute(spec.action) + '"',
|
|
209
|
+
];
|
|
210
|
+
if (spec.id) attrs.push('id="' + escapeAttribute(spec.id) + '"');
|
|
211
|
+
if (spec.className) attrs.push('class="' + escapeAttribute(spec.className) + '"');
|
|
212
|
+
if (spec.enctype) attrs.push('enctype="' + escapeAttribute(spec.enctype) + '"');
|
|
213
|
+
if (spec.autocomplete) attrs.push('autocomplete="' + escapeAttribute(spec.autocomplete) + '"');
|
|
214
|
+
if (spec.target) attrs.push('target="' + escapeAttribute(spec.target) + '"');
|
|
215
|
+
|
|
216
|
+
var inner = "";
|
|
217
|
+
|
|
218
|
+
// CSRF hidden input. State-changing methods need it; safe methods
|
|
219
|
+
// (GET/HEAD) don't. We still render it on POST etc. when csrfToken
|
|
220
|
+
// is provided; the middleware decides whether it's required.
|
|
221
|
+
if (spec.csrfToken && method !== "GET" && method !== "HEAD") {
|
|
222
|
+
var csrfFieldName = spec.csrfFieldName || "_csrf";
|
|
223
|
+
inner += '<input type="hidden" name="' + escapeAttribute(csrfFieldName) +
|
|
224
|
+
'" value="' + escapeAttribute(spec.csrfToken) + '">';
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
for (var i = 0; i < spec.fields.length; i++) {
|
|
228
|
+
inner += _renderField(spec.fields[i]);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Auto-submit button if the operator didn't include one
|
|
232
|
+
var hasSubmit = spec.fields.some(function (f) { return f.type === "submit"; });
|
|
233
|
+
if (!hasSubmit) {
|
|
234
|
+
inner += '<button type="submit">' + template.escapeHtml(spec.submitLabel || "Submit") + "</button>";
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return "<form " + attrs.join(" ") + ">" + inner + "</form>";
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ============================================================
|
|
241
|
+
// Server-side validation
|
|
242
|
+
// ============================================================
|
|
243
|
+
//
|
|
244
|
+
// validate(spec, body) → { valid, errors, values }
|
|
245
|
+
//
|
|
246
|
+
// errors is a map fieldName → message (only failing fields). values is
|
|
247
|
+
// the coerced data: number/checkbox/etc. converted to their canonical
|
|
248
|
+
// types. body is whatever the operator passes — typically an object
|
|
249
|
+
// from URLSearchParams or JSON parse.
|
|
250
|
+
|
|
251
|
+
function _coerce(field, raw) {
|
|
252
|
+
// Checkbox special-case: browsers omit unchecked boxes from the
|
|
253
|
+
// submission entirely, so `raw === undefined` means "unchecked"
|
|
254
|
+
// (false), not "not provided." Coerce to false BEFORE the generic
|
|
255
|
+
// undefined-passthrough below.
|
|
256
|
+
if (field.type === "checkbox") {
|
|
257
|
+
if (raw === undefined || raw === null || raw === "" || raw === "false" || raw === "0") return false;
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
if (raw === undefined) return undefined;
|
|
261
|
+
switch (field.type) {
|
|
262
|
+
case "number":
|
|
263
|
+
case "range":
|
|
264
|
+
if (raw === "" || raw === null) return null;
|
|
265
|
+
var n = Number(raw);
|
|
266
|
+
return Number.isFinite(n) ? n : NaN;
|
|
267
|
+
case "date":
|
|
268
|
+
case "datetime-local":
|
|
269
|
+
case "time":
|
|
270
|
+
return typeof raw === "string" ? raw : String(raw);
|
|
271
|
+
default:
|
|
272
|
+
return typeof raw === "string" ? raw : String(raw);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function _isEmpty(v) {
|
|
277
|
+
return v === undefined || v === null || v === "";
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function validate(spec, body) {
|
|
281
|
+
if (!spec || !Array.isArray(spec.fields)) {
|
|
282
|
+
throw new Error("forms.validate: spec.fields must be an array");
|
|
283
|
+
}
|
|
284
|
+
body = body || {};
|
|
285
|
+
var errors = {};
|
|
286
|
+
var values = {};
|
|
287
|
+
|
|
288
|
+
for (var i = 0; i < spec.fields.length; i++) {
|
|
289
|
+
var f = spec.fields[i];
|
|
290
|
+
if (!f.name) continue;
|
|
291
|
+
if (f.type === "submit") continue; // submit buttons aren't data
|
|
292
|
+
|
|
293
|
+
var raw = body[f.name];
|
|
294
|
+
var coerced = _coerce(f, raw);
|
|
295
|
+
values[f.name] = coerced;
|
|
296
|
+
|
|
297
|
+
if (f.required && _isEmpty(coerced) && coerced !== false) {
|
|
298
|
+
errors[f.name] = f.errorMessages && f.errorMessages.required
|
|
299
|
+
? f.errorMessages.required
|
|
300
|
+
: (f.label || f.name) + " is required";
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (_isEmpty(coerced)) continue;
|
|
304
|
+
|
|
305
|
+
// Type-specific checks
|
|
306
|
+
if (f.type === "number" || f.type === "range") {
|
|
307
|
+
if (Number.isNaN(coerced)) {
|
|
308
|
+
errors[f.name] = (f.label || f.name) + " must be a number";
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (f.min !== undefined && coerced < Number(f.min)) {
|
|
312
|
+
errors[f.name] = (f.label || f.name) + " must be ≥ " + f.min;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (f.max !== undefined && coerced > Number(f.max)) {
|
|
316
|
+
errors[f.name] = (f.label || f.name) + " must be ≤ " + f.max;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (f.type === "email" && typeof coerced === "string") {
|
|
321
|
+
// Pragmatic email check — RFC 5322 is impractical to regex
|
|
322
|
+
// correctly; this catches obvious nonsense ("foo", "foo@",
|
|
323
|
+
// "@bar") without over-engineering.
|
|
324
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(coerced)) {
|
|
325
|
+
errors[f.name] = (f.label || f.name) + " must be a valid email address";
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (f.type === "url" && typeof coerced === "string") {
|
|
330
|
+
try { new URL(coerced); }
|
|
331
|
+
catch (_e) { errors[f.name] = (f.label || f.name) + " must be a valid URL"; continue; }
|
|
332
|
+
}
|
|
333
|
+
if (typeof coerced === "string") {
|
|
334
|
+
if (f.minlength !== undefined && coerced.length < Number(f.minlength)) {
|
|
335
|
+
errors[f.name] = (f.label || f.name) + " must be at least " + f.minlength + " characters";
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (f.maxlength !== undefined && coerced.length > Number(f.maxlength)) {
|
|
339
|
+
errors[f.name] = (f.label || f.name) + " must be at most " + f.maxlength + " characters";
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (f.pattern) {
|
|
343
|
+
var re;
|
|
344
|
+
try { re = new RegExp("^(?:" + f.pattern + ")$"); }
|
|
345
|
+
catch (_e) { /* invalid pattern in spec — operator bug; skip */ continue; }
|
|
346
|
+
if (!re.test(coerced)) {
|
|
347
|
+
errors[f.name] = f.errorMessages && f.errorMessages.pattern
|
|
348
|
+
? f.errorMessages.pattern
|
|
349
|
+
: (f.label || f.name) + " has an invalid format";
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if ((f.type === "select" || f.type === "radio") && Array.isArray(f.options)) {
|
|
355
|
+
var allowed = f.options.map(function (o) { return String(o.value); });
|
|
356
|
+
if (allowed.indexOf(String(coerced)) === -1) {
|
|
357
|
+
errors[f.name] = (f.label || f.name) + " has an invalid value";
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
valid: Object.keys(errors).length === 0,
|
|
365
|
+
errors: errors,
|
|
366
|
+
values: values,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
module.exports = {
|
|
371
|
+
generateCsrfToken: generateCsrfToken,
|
|
372
|
+
verifyCsrfToken: verifyCsrfToken,
|
|
373
|
+
render: render,
|
|
374
|
+
validate: validate,
|
|
375
|
+
escapeAttribute: escapeAttribute,
|
|
376
|
+
escapeHtml: template.escapeHtml,
|
|
377
|
+
CSRF_TOKEN_BYTES: CSRF_TOKEN_BYTES,
|
|
378
|
+
};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var observability = require("./observability");
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Framework error base class + cross-module operational error classes.
|
|
6
|
+
*
|
|
7
|
+
* Two scopes live here:
|
|
8
|
+
*
|
|
9
|
+
* 1. FrameworkError — base class every framework error class extends.
|
|
10
|
+
* Provides a single `instanceof FrameworkError` check (replacing the
|
|
11
|
+
* scattered `isXxxError` boolean flags) plus a stable shape: { name,
|
|
12
|
+
* code, message, isFrameworkError: true }.
|
|
13
|
+
*
|
|
14
|
+
* 2. Cross-module operational error classes — errors raised by more
|
|
15
|
+
* than one module that share a logical domain (e.g. ObjectStoreError
|
|
16
|
+
* raised by the 5 object-store adapters + the umbrella). These can't
|
|
17
|
+
* live in the umbrella module because adapters would need a circular
|
|
18
|
+
* require to access them. They live here, where every adapter can
|
|
19
|
+
* import from the same place.
|
|
20
|
+
*
|
|
21
|
+
* 3. defineClass(name, opts) — factory that produces a FrameworkError
|
|
22
|
+
* subclass with the standard shape. Eliminates the boilerplate that
|
|
23
|
+
* every per-domain error class was duplicating across lib/.
|
|
24
|
+
*
|
|
25
|
+
* Per-domain VALIDATION errors (SafeSqlError, SafeJsonError, SafeBufferError,
|
|
26
|
+
* SafeAsyncError, AtomicFileError, ChainWriterError, ClusterStorageError,
|
|
27
|
+
* NotLeaderError, FrameworkSchemaError, *SafeError parser families) stay
|
|
28
|
+
* co-located with their primitive module — they're single-owner, single-
|
|
29
|
+
* domain, and the *-safe filename convention already declares ownership.
|
|
30
|
+
* They extend FrameworkError so the unified `instanceof` check works.
|
|
31
|
+
*
|
|
32
|
+
* Operational error classes here all share:
|
|
33
|
+
* { name, code, message, permanent: bool, isFrameworkError: true }
|
|
34
|
+
* Adapters that talk over HTTP also carry `statusCode` for retry
|
|
35
|
+
* classification.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
class FrameworkError extends Error {
|
|
39
|
+
constructor(message, code) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = "FrameworkError";
|
|
42
|
+
this.code = code || "framework/invalid";
|
|
43
|
+
this.isFrameworkError = true;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// defineClass — factory for the standard FrameworkError-subclass shape
|
|
48
|
+
// every per-domain error followed by hand. Variants the factory covers:
|
|
49
|
+
//
|
|
50
|
+
// defineClass("MyError")
|
|
51
|
+
// constructor: (code, message, permanent)
|
|
52
|
+
// fields: name, permanent, isMyError
|
|
53
|
+
//
|
|
54
|
+
// defineClass("MyError", { withStatusCode: true })
|
|
55
|
+
// constructor: (code, message, permanent, statusCode)
|
|
56
|
+
// fields: + statusCode (HTTP-shaped operational errors)
|
|
57
|
+
//
|
|
58
|
+
// defineClass("MyError", { alwaysPermanent: true })
|
|
59
|
+
// constructor: (code, message)
|
|
60
|
+
// fields: permanent always true (auth failures, validation)
|
|
61
|
+
//
|
|
62
|
+
// defineClass("MyError", { withCause: true })
|
|
63
|
+
// constructor: (code, message, cause)
|
|
64
|
+
// fields: + cause (errors that wrap an upstream cause)
|
|
65
|
+
//
|
|
66
|
+
// Returns the constructor. Operators can attach extra static helpers
|
|
67
|
+
// to it after creation if they need to.
|
|
68
|
+
function defineClass(name, opts) {
|
|
69
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
70
|
+
throw new Error("defineClass: name must be a non-empty string");
|
|
71
|
+
}
|
|
72
|
+
opts = opts || {};
|
|
73
|
+
var alwaysPermanent = !!opts.alwaysPermanent;
|
|
74
|
+
var withStatusCode = !!opts.withStatusCode;
|
|
75
|
+
var withCause = !!opts.withCause;
|
|
76
|
+
if (alwaysPermanent && (withStatusCode || withCause)) {
|
|
77
|
+
throw new Error("defineClass: alwaysPermanent is mutually exclusive with withStatusCode / withCause");
|
|
78
|
+
}
|
|
79
|
+
var flagKey = "is" + name;
|
|
80
|
+
|
|
81
|
+
// Generated class — uses an anonymous class expression so we can set
|
|
82
|
+
// the constructor name explicitly via Object.defineProperty (matters
|
|
83
|
+
// for stack traces and instanceof debugging).
|
|
84
|
+
var GeneratedError = class extends FrameworkError {
|
|
85
|
+
constructor(code, message, arg3, arg4) {
|
|
86
|
+
super(message, code);
|
|
87
|
+
this.name = name;
|
|
88
|
+
this[flagKey] = true;
|
|
89
|
+
if (alwaysPermanent) {
|
|
90
|
+
this.permanent = true;
|
|
91
|
+
} else if (withCause) {
|
|
92
|
+
this.cause = arg3;
|
|
93
|
+
} else {
|
|
94
|
+
this.permanent = !!arg3;
|
|
95
|
+
if (withStatusCode) this.statusCode = arg4;
|
|
96
|
+
}
|
|
97
|
+
// Framework-error class counter — routed into framework_errors_total
|
|
98
|
+
// when a metrics registry is active. observability.event is safe to
|
|
99
|
+
// call here even during framework-error's own load: observability's
|
|
100
|
+
// dependencies on metrics + tracing are themselves lazy-required
|
|
101
|
+
// and only resolve at first call (post-load).
|
|
102
|
+
try { observability.event("error.construct", 1, { class: name }); }
|
|
103
|
+
catch (_e) { /* defensive — no observability sink should ever break a constructor */ }
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
Object.defineProperty(GeneratedError, "name", { value: name, configurable: true });
|
|
107
|
+
// Per-class factory — collapses the boilerplate every module used to
|
|
108
|
+
// write as `function _err(code, msg, perm) { return new XxxError(...); }`.
|
|
109
|
+
// Now: `var _err = XxxError.factory;` (one line, same call shape).
|
|
110
|
+
GeneratedError.factory = function (code, message, arg3, arg4) {
|
|
111
|
+
return new GeneratedError(code, message, arg3, arg4);
|
|
112
|
+
};
|
|
113
|
+
return GeneratedError;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---- Cross-module operational classes (defined via the factory) ----
|
|
117
|
+
|
|
118
|
+
var ObjectStoreError = defineClass("ObjectStoreError", { withStatusCode: true });
|
|
119
|
+
var LogStreamError = defineClass("LogStreamError", { withStatusCode: true });
|
|
120
|
+
var QueueError = defineClass("QueueError");
|
|
121
|
+
var ExternalDbError = defineClass("ExternalDbError");
|
|
122
|
+
var ClusterError = defineClass("ClusterError");
|
|
123
|
+
var ClusterProviderError = defineClass("ClusterProviderError");
|
|
124
|
+
var HandlerError = defineClass("HandlerError", { withCause: true });
|
|
125
|
+
var StorageError = defineClass("StorageError");
|
|
126
|
+
// AuthError covers password / passkey / TOTP failures at the framework
|
|
127
|
+
// layer (lib/auth/*). Always permanent — auth failures are not transient
|
|
128
|
+
// ("retry might work"); they're "this credential doesn't match" or
|
|
129
|
+
// "this input was malformed".
|
|
130
|
+
var AuthError = defineClass("AuthError", { alwaysPermanent: true });
|
|
131
|
+
var JobsError = defineClass("JobsError");
|
|
132
|
+
var SchedulerError = defineClass("SchedulerError");
|
|
133
|
+
var SessionError = defineClass("SessionError");
|
|
134
|
+
var SlugError = defineClass("SlugError", { alwaysPermanent: true });
|
|
135
|
+
var WebhookError = defineClass("WebhookError", { alwaysPermanent: true });
|
|
136
|
+
var ApiKeyError = defineClass("ApiKeyError", { alwaysPermanent: true });
|
|
137
|
+
var PermissionsError = defineClass("PermissionsError", { alwaysPermanent: true });
|
|
138
|
+
// CacheError is alwaysPermanent: bad opts / missing key / closed-state
|
|
139
|
+
// errors are programming bugs, not transient. Backend-level transient
|
|
140
|
+
// failures (cluster DB unavailable mid-fetch) become observability +
|
|
141
|
+
// audit signals; they don't escape as exceptions to the caller.
|
|
142
|
+
var CacheError = defineClass("CacheError", { alwaysPermanent: true });
|
|
143
|
+
// SeederError is alwaysPermanent: load failures, bad-shape seed files,
|
|
144
|
+
// missing deps, and cycle errors are programming bugs. Per-seed runtime
|
|
145
|
+
// failures get wrapped in this class with the seed name in the message
|
|
146
|
+
// — operators see "seeders/run-failed: 0042-x.js: <cause>" not a raw
|
|
147
|
+
// driver exception.
|
|
148
|
+
var SeederError = defineClass("SeederError", { alwaysPermanent: true });
|
|
149
|
+
// I18nError is alwaysPermanent: bad locale tags, malformed translation
|
|
150
|
+
// trees, missing-key in throw mode, and bad input to formatters are
|
|
151
|
+
// programming bugs. Missing keys in default ("return-key") mode return
|
|
152
|
+
// the key without throwing — runtime hot-path semantics, not error.
|
|
153
|
+
var I18nError = defineClass("I18nError", { alwaysPermanent: true });
|
|
154
|
+
// NotifyError is alwaysPermanent: bad opts, unknown channels, transport
|
|
155
|
+
// contract violations are programming bugs. Per-send transient failures
|
|
156
|
+
// (the kind retry can recover) are surfaced from the underlying transport
|
|
157
|
+
// with their own shape; only after retry exhaustion does notify wrap
|
|
158
|
+
// them into NotifyError SEND_FAILED — at that point they ARE permanent.
|
|
159
|
+
var NotifyError = defineClass("NotifyError", { alwaysPermanent: true });
|
|
160
|
+
// TestingError is alwaysPermanent: bad inputs to test helpers
|
|
161
|
+
// (NaN clock, non-fn predicate, path-traversal tempDir prefix) and
|
|
162
|
+
// waitFor timeouts are programming bugs at test-write time.
|
|
163
|
+
var TestingError = defineClass("TestingError", { alwaysPermanent: true });
|
|
164
|
+
|
|
165
|
+
module.exports = {
|
|
166
|
+
FrameworkError: FrameworkError,
|
|
167
|
+
defineClass: defineClass,
|
|
168
|
+
ObjectStoreError: ObjectStoreError,
|
|
169
|
+
LogStreamError: LogStreamError,
|
|
170
|
+
QueueError: QueueError,
|
|
171
|
+
ExternalDbError: ExternalDbError,
|
|
172
|
+
ClusterError: ClusterError,
|
|
173
|
+
ClusterProviderError: ClusterProviderError,
|
|
174
|
+
HandlerError: HandlerError,
|
|
175
|
+
StorageError: StorageError,
|
|
176
|
+
AuthError: AuthError,
|
|
177
|
+
JobsError: JobsError,
|
|
178
|
+
SchedulerError: SchedulerError,
|
|
179
|
+
SessionError: SessionError,
|
|
180
|
+
SlugError: SlugError,
|
|
181
|
+
WebhookError: WebhookError,
|
|
182
|
+
ApiKeyError: ApiKeyError,
|
|
183
|
+
PermissionsError: PermissionsError,
|
|
184
|
+
CacheError: CacheError,
|
|
185
|
+
SeederError: SeederError,
|
|
186
|
+
I18nError: I18nError,
|
|
187
|
+
NotifyError: NotifyError,
|
|
188
|
+
TestingError: TestingError,
|
|
189
|
+
};
|