@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.
Files changed (160) hide show
  1. package/CHANGELOG.md +230 -0
  2. package/LICENSE +201 -0
  3. package/LTS-CALENDAR.md +29 -0
  4. package/MIGRATING.md +7 -0
  5. package/NOTICE +59 -0
  6. package/README.md +100 -0
  7. package/bin/blamejs.js +13 -0
  8. package/index.js +253 -0
  9. package/lib/api-key.js +705 -0
  10. package/lib/api-snapshot.js +335 -0
  11. package/lib/app-shutdown.js +381 -0
  12. package/lib/app.js +364 -0
  13. package/lib/atomic-file.js +525 -0
  14. package/lib/audit-chain.js +168 -0
  15. package/lib/audit-sign.js +319 -0
  16. package/lib/audit-tools.js +682 -0
  17. package/lib/audit.js +753 -0
  18. package/lib/auth/jwt.js +280 -0
  19. package/lib/auth/oauth.js +691 -0
  20. package/lib/auth/passkey.js +185 -0
  21. package/lib/auth/password.js +139 -0
  22. package/lib/auth/totp.js +17 -0
  23. package/lib/auth-header.js +81 -0
  24. package/lib/backup/bundle.js +219 -0
  25. package/lib/backup/crypto.js +174 -0
  26. package/lib/backup/index.js +490 -0
  27. package/lib/backup/manifest.js +275 -0
  28. package/lib/bundler.js +295 -0
  29. package/lib/cache.js +819 -0
  30. package/lib/chain-writer.js +234 -0
  31. package/lib/cli-helpers.js +201 -0
  32. package/lib/cli.js +1377 -0
  33. package/lib/cluster-provider-db.js +245 -0
  34. package/lib/cluster-storage.js +166 -0
  35. package/lib/cluster.js +691 -0
  36. package/lib/consent.js +222 -0
  37. package/lib/constants.js +186 -0
  38. package/lib/cookies.js +293 -0
  39. package/lib/credential-hash.js +303 -0
  40. package/lib/crypto-field.js +159 -0
  41. package/lib/crypto.js +250 -0
  42. package/lib/db-query.js +297 -0
  43. package/lib/db-schema.js +250 -0
  44. package/lib/db.js +1054 -0
  45. package/lib/deprecate.js +226 -0
  46. package/lib/dev.js +324 -0
  47. package/lib/error-page.js +424 -0
  48. package/lib/events.js +135 -0
  49. package/lib/external-db.js +422 -0
  50. package/lib/forms.js +378 -0
  51. package/lib/framework-error.js +189 -0
  52. package/lib/framework-schema.js +604 -0
  53. package/lib/handlers.js +350 -0
  54. package/lib/html-balance.js +227 -0
  55. package/lib/http-client.js +615 -0
  56. package/lib/i18n.js +780 -0
  57. package/lib/jobs.js +181 -0
  58. package/lib/lazy-require.js +48 -0
  59. package/lib/log-stream-local.js +137 -0
  60. package/lib/log-stream-webhook.js +170 -0
  61. package/lib/log-stream.js +211 -0
  62. package/lib/log.js +355 -0
  63. package/lib/mail-bounce.js +507 -0
  64. package/lib/mail.js +701 -0
  65. package/lib/metrics.js +647 -0
  66. package/lib/middleware/api-encrypt.js +553 -0
  67. package/lib/middleware/attach-user.js +156 -0
  68. package/lib/middleware/body-parser.js +883 -0
  69. package/lib/middleware/bot-guard.js +148 -0
  70. package/lib/middleware/compression.js +436 -0
  71. package/lib/middleware/cors.js +236 -0
  72. package/lib/middleware/csp-nonce.js +332 -0
  73. package/lib/middleware/csrf-protect.js +275 -0
  74. package/lib/middleware/error-handler.js +46 -0
  75. package/lib/middleware/health.js +358 -0
  76. package/lib/middleware/index.js +52 -0
  77. package/lib/middleware/rate-limit.js +319 -0
  78. package/lib/middleware/request-id.js +53 -0
  79. package/lib/middleware/require-auth.js +95 -0
  80. package/lib/middleware/security-headers.js +91 -0
  81. package/lib/migrations.js +353 -0
  82. package/lib/mtls-ca.js +333 -0
  83. package/lib/mtls-engine-default.js +285 -0
  84. package/lib/nonce-store.js +177 -0
  85. package/lib/notify.js +643 -0
  86. package/lib/ntp-check.js +178 -0
  87. package/lib/object-store/azure-blob.js +467 -0
  88. package/lib/object-store/gcs.js +469 -0
  89. package/lib/object-store/http-put.js +153 -0
  90. package/lib/object-store/index.js +140 -0
  91. package/lib/object-store/local.js +163 -0
  92. package/lib/object-store/retry.js +15 -0
  93. package/lib/object-store/sigv4.js +535 -0
  94. package/lib/observability.js +114 -0
  95. package/lib/pagination.js +371 -0
  96. package/lib/parsers/index.js +64 -0
  97. package/lib/parsers/safe-csv.js +224 -0
  98. package/lib/parsers/safe-env.js +614 -0
  99. package/lib/parsers/safe-toml.js +745 -0
  100. package/lib/parsers/safe-xml.js +379 -0
  101. package/lib/parsers/safe-yaml.js +977 -0
  102. package/lib/permissions.js +430 -0
  103. package/lib/pqc-agent.js +85 -0
  104. package/lib/pqc-gate.js +266 -0
  105. package/lib/protocol-dispatcher.js +144 -0
  106. package/lib/queue-local.js +327 -0
  107. package/lib/queue.js +430 -0
  108. package/lib/redact.js +192 -0
  109. package/lib/render.js +193 -0
  110. package/lib/request-helpers.js +178 -0
  111. package/lib/restore-bundle.js +239 -0
  112. package/lib/restore-rollback.js +254 -0
  113. package/lib/restore.js +301 -0
  114. package/lib/retry.js +329 -0
  115. package/lib/router.js +437 -0
  116. package/lib/safe-async.js +520 -0
  117. package/lib/safe-buffer.js +162 -0
  118. package/lib/safe-json.js +532 -0
  119. package/lib/safe-schema.js +1176 -0
  120. package/lib/safe-sql.js +157 -0
  121. package/lib/safe-url.js +109 -0
  122. package/lib/scheduler.js +680 -0
  123. package/lib/seeders.js +622 -0
  124. package/lib/session.js +304 -0
  125. package/lib/slug.js +243 -0
  126. package/lib/static.js +268 -0
  127. package/lib/storage.js +470 -0
  128. package/lib/subject.js +281 -0
  129. package/lib/template.js +781 -0
  130. package/lib/testing.js +621 -0
  131. package/lib/totp.js +285 -0
  132. package/lib/tracing.js +484 -0
  133. package/lib/validate-opts.js +56 -0
  134. package/lib/vault/index.js +299 -0
  135. package/lib/vault/passphrase-ops.js +311 -0
  136. package/lib/vault/passphrase-source.js +198 -0
  137. package/lib/vault/rotate.js +761 -0
  138. package/lib/vault/wrap.js +289 -0
  139. package/lib/vendor/MANIFEST.json +84 -0
  140. package/lib/vendor/argon2/argon2.cjs +466 -0
  141. package/lib/vendor/argon2/argon2.d.cts +62 -0
  142. package/lib/vendor/argon2/package.json +1 -0
  143. package/lib/vendor/argon2/prebuilds/darwin-arm64/argon2.armv8.glibc.node +0 -0
  144. package/lib/vendor/argon2/prebuilds/darwin-x64/argon2.glibc.node +0 -0
  145. package/lib/vendor/argon2/prebuilds/freebsd-arm64/argon2.armv8.glibc.node +0 -0
  146. package/lib/vendor/argon2/prebuilds/freebsd-x64/argon2.glibc.node +0 -0
  147. package/lib/vendor/argon2/prebuilds/linux-arm/argon2.armv7.glibc.node +0 -0
  148. package/lib/vendor/argon2/prebuilds/linux-arm/argon2.armv7.musl.node +0 -0
  149. package/lib/vendor/argon2/prebuilds/linux-arm64/argon2.armv8.glibc.node +0 -0
  150. package/lib/vendor/argon2/prebuilds/linux-arm64/argon2.armv8.musl.node +0 -0
  151. package/lib/vendor/argon2/prebuilds/linux-x64/argon2.glibc.node +0 -0
  152. package/lib/vendor/argon2/prebuilds/linux-x64/argon2.musl.node +0 -0
  153. package/lib/vendor/argon2/prebuilds/win32-x64/argon2.glibc.node +0 -0
  154. package/lib/vendor/noble-ciphers.cjs +9 -0
  155. package/lib/vendor/pki.cjs +181 -0
  156. package/lib/vendor/simplewebauthn-server.cjs +328 -0
  157. package/lib/webhook.js +632 -0
  158. package/lib/websocket-channels.js +413 -0
  159. package/lib/websocket.js +833 -0
  160. package/package.json +39 -0
@@ -0,0 +1,185 @@
1
+ "use strict";
2
+ /**
3
+ * Passkey / WebAuthn (FIDO2) — registration + authentication primitives.
4
+ *
5
+ * Built on the vendored @simplewebauthn/server (lib/vendor/simplewebauthn-
6
+ * server.cjs). This file is a thin wrapper that names the surface in the
7
+ * framework's auth-namespace style and validates inputs through
8
+ * AuthError so failures are framed consistently with auth.password and
9
+ * auth.totp. The vendor handles CBOR parsing, attestation statement
10
+ * validation, COSE key conversion, and signature verification across
11
+ * the WebAuthn algorithm set; we don't reimplement any of it.
12
+ *
13
+ * The four phases of a WebAuthn flow:
14
+ *
15
+ * Registration (user adds a passkey to their account):
16
+ * 1. server: startRegistration({ rpName, rpId, userName, userDisplayName,
17
+ * excludeCredentials? }) → { challenge, … }. Server stores the
18
+ * challenge in the session.
19
+ * 2. browser: navigator.credentials.create({ publicKey: <options> })
20
+ * → registration response with attestation.
21
+ * 3. server: verifyRegistration({ response, expectedChallenge,
22
+ * expectedOrigin, expectedRPID }) → { verified, registrationInfo:
23
+ * { credential: { id, publicKey, counter }, … } }. Persist
24
+ * credential.id (base64url) + credential.publicKey + counter.
25
+ *
26
+ * Authentication (user logs in with their passkey):
27
+ * 1. server: startAuthentication({ rpId, userVerification? })
28
+ * → { challenge, … }. Server stores challenge in session.
29
+ * 2. browser: navigator.credentials.get({ publicKey: <options> })
30
+ * → assertion response.
31
+ * 3. server: lookup the credential by response.id (base64url),
32
+ * then verifyAuthentication({ response, expectedChallenge,
33
+ * expectedOrigin, expectedRPID, credential: { id, publicKey,
34
+ * counter, transports? } }) → { verified, authenticationInfo:
35
+ * { newCounter } }. Persist newCounter (clone-detection).
36
+ *
37
+ * Public API (b.auth.passkey.*):
38
+ * await passkey.startRegistration(opts) → registration options
39
+ * await passkey.verifyRegistration(opts) → { verified, registrationInfo? }
40
+ * await passkey.startAuthentication(opts) → authentication options
41
+ * await passkey.verifyAuthentication(opts) → { verified, authenticationInfo? }
42
+ *
43
+ * Framework defaults:
44
+ * - attestationType: "none" — don't request attestation. Most apps
45
+ * don't need it, and "direct" or "enterprise" attestation has
46
+ * deployment friction (cert chains, MDS lookups). Operators who
47
+ * genuinely need attestation override.
48
+ * - residentKey: "preferred" — discoverable credentials when the
49
+ * authenticator supports them; falls back to non-discoverable.
50
+ * - userVerification: "preferred" — accept biometric / PIN when
51
+ * available; fall back to presence-only.
52
+ * - hints: ["client-device", "hybrid"] — surface platform
53
+ * authenticators (Touch ID, Windows Hello) AND cross-device
54
+ * (1Password / Bitwarden / mobile-as-roaming-authenticator).
55
+ *
56
+ * No middleware decisions made here — the wrapper does NOT touch
57
+ * sessions, audit, or DB. Routes integrate that themselves; the
58
+ * primitive stays the smallest correct surface.
59
+ */
60
+ var { AuthError } = require("../framework-error");
61
+
62
+ // Lazy require: the bundle is ~400 KB and not every blamejs deployment
63
+ // uses passkeys. Loading on first call keeps boot fast for non-passkey
64
+ // apps without forcing operators to gate the import behind a flag.
65
+ var _wa = null;
66
+ function _vendor() {
67
+ if (!_wa) _wa = require("../vendor/simplewebauthn-server.cjs");
68
+ return _wa;
69
+ }
70
+
71
+ function _requireString(v, name) {
72
+ if (typeof v !== "string" || v.length === 0) {
73
+ throw new AuthError("auth-passkey/missing-" + name,
74
+ name + " is required (non-empty string)");
75
+ }
76
+ }
77
+
78
+ // ---- Registration ----
79
+
80
+ async function startRegistration(opts) {
81
+ if (!opts) throw new AuthError("auth-passkey/missing-opts", "opts is required");
82
+ _requireString(opts.rpName, "rpName");
83
+ _requireString(opts.rpId, "rpId");
84
+ _requireString(opts.userName, "userName");
85
+
86
+ var sel = opts.authenticatorSelection || {};
87
+ var options = await _vendor().generateRegistrationOptions({
88
+ rpName: opts.rpName,
89
+ rpID: opts.rpId,
90
+ userName: opts.userName,
91
+ userDisplayName: opts.userDisplayName || opts.userName,
92
+ attestationType: opts.attestationType || "none",
93
+ excludeCredentials: opts.excludeCredentials || [],
94
+ authenticatorSelection: {
95
+ residentKey: sel.residentKey || "preferred",
96
+ userVerification: sel.userVerification || "preferred",
97
+ authenticatorAttachment: sel.authenticatorAttachment,
98
+ requireResidentKey: sel.requireResidentKey,
99
+ },
100
+ timeout: opts.timeout,
101
+ extensions: opts.extensions,
102
+ });
103
+ // Hint the browser to surface platform + cross-device authenticators
104
+ // (Touch ID / Windows Hello AND 1Password / Bitwarden / phone-as-key).
105
+ if (!opts.hints) {
106
+ options.hints = ["client-device", "hybrid"];
107
+ } else {
108
+ options.hints = opts.hints;
109
+ }
110
+ return options;
111
+ }
112
+
113
+ async function verifyRegistration(opts) {
114
+ if (!opts) throw new AuthError("auth-passkey/missing-opts", "opts is required");
115
+ if (!opts.response) {
116
+ throw new AuthError("auth-passkey/missing-response", "opts.response is required");
117
+ }
118
+ _requireString(opts.expectedChallenge, "expectedChallenge");
119
+ _requireString(opts.expectedOrigin, "expectedOrigin");
120
+ _requireString(opts.expectedRPID, "expectedRPID");
121
+
122
+ return await _vendor().verifyRegistrationResponse({
123
+ response: opts.response,
124
+ expectedChallenge: opts.expectedChallenge,
125
+ expectedOrigin: opts.expectedOrigin,
126
+ expectedRPID: opts.expectedRPID,
127
+ requireUserVerification: opts.requireUserVerification !== false,
128
+ });
129
+ }
130
+
131
+ // ---- Authentication ----
132
+
133
+ async function startAuthentication(opts) {
134
+ if (!opts) throw new AuthError("auth-passkey/missing-opts", "opts is required");
135
+ _requireString(opts.rpId, "rpId");
136
+
137
+ var options = await _vendor().generateAuthenticationOptions({
138
+ rpID: opts.rpId,
139
+ userVerification: opts.userVerification || "preferred",
140
+ allowCredentials: opts.allowCredentials || [],
141
+ timeout: opts.timeout,
142
+ extensions: opts.extensions,
143
+ });
144
+ if (!opts.hints) {
145
+ options.hints = ["client-device", "hybrid"];
146
+ } else {
147
+ options.hints = opts.hints;
148
+ }
149
+ return options;
150
+ }
151
+
152
+ async function verifyAuthentication(opts) {
153
+ if (!opts) throw new AuthError("auth-passkey/missing-opts", "opts is required");
154
+ if (!opts.response) {
155
+ throw new AuthError("auth-passkey/missing-response", "opts.response is required");
156
+ }
157
+ _requireString(opts.expectedChallenge, "expectedChallenge");
158
+ _requireString(opts.expectedOrigin, "expectedOrigin");
159
+ _requireString(opts.expectedRPID, "expectedRPID");
160
+ if (!opts.credential || !opts.credential.id || !opts.credential.publicKey) {
161
+ throw new AuthError("auth-passkey/missing-credential",
162
+ "opts.credential { id, publicKey, counter? } is required");
163
+ }
164
+
165
+ return await _vendor().verifyAuthenticationResponse({
166
+ response: opts.response,
167
+ expectedChallenge: opts.expectedChallenge,
168
+ expectedOrigin: opts.expectedOrigin,
169
+ expectedRPID: opts.expectedRPID,
170
+ credential: {
171
+ id: opts.credential.id,
172
+ publicKey: opts.credential.publicKey,
173
+ counter: opts.credential.counter || 0,
174
+ transports: opts.credential.transports,
175
+ },
176
+ requireUserVerification: opts.requireUserVerification !== false,
177
+ });
178
+ }
179
+
180
+ module.exports = {
181
+ startRegistration: startRegistration,
182
+ verifyRegistration: verifyRegistration,
183
+ startAuthentication: startAuthentication,
184
+ verifyAuthentication: verifyAuthentication,
185
+ };
@@ -0,0 +1,139 @@
1
+ "use strict";
2
+ /**
3
+ * Argon2id password hashing — public framework primitive.
4
+ *
5
+ * Different concern from lib/vault-wrap.js, which also uses Argon2id but
6
+ * for KEK derivation (the output is a KEY used to AEAD-wrap the vault
7
+ * file). This module is for application-layer password storage: the
8
+ * output is a verifiable digest in PHC format, never decrypted, used
9
+ * for "is this the password the user originally set?".
10
+ *
11
+ * Public API:
12
+ *
13
+ * await auth.password.hash(plain, opts?) → string (PHC format)
14
+ * await auth.password.verify(hash, plain) → boolean
15
+ * auth.password.needsRehash(hash, opts?) → boolean
16
+ *
17
+ * The PHC string captures the algorithm + parameters + salt + digest:
18
+ *
19
+ * $argon2id$v=19$m=65536,t=3,p=4$<base64-salt>$<base64-hash>
20
+ *
21
+ * That string is what callers store in the database. verify() parses
22
+ * it to recover the parameters, recomputes the digest with the
23
+ * supplied plaintext, and timing-safe compares.
24
+ *
25
+ * Defaults (memoryCost=64 MiB, timeCost=3, parallelism=4) target
26
+ * ~250–500ms on commodity 2026 hardware — painful for offline brute
27
+ * force, tolerable inside a login latency budget. Operators with
28
+ * tighter budgets (or beefier hardware) tune via opts; needsRehash
29
+ * surfaces when stored hashes lag behind the current defaults so the
30
+ * caller can transparently rehash on next successful login.
31
+ *
32
+ * Validation posture:
33
+ * - plain must be a non-empty string. Empty/whitespace passwords
34
+ * are operator bugs (UI should reject) — failing here surfaces
35
+ * them before they hit the DB.
36
+ * - plain length is capped at 4096 bytes (UTF-8). Same cap as the
37
+ * vault-wrap passphrase. A 5 GiB string fed to Argon2 would peg
38
+ * the box for minutes; the cap is sanity, not security.
39
+ * - hash must be a non-empty string starting with `$argon2id$`.
40
+ * Other Argon2 variants (`$argon2i$` / `$argon2d$`) are out of
41
+ * spec for this framework — verify() returns false rather than
42
+ * attempting to validate them. Callers using needsRehash on a
43
+ * non-id hash get true (forces rehash on next login).
44
+ *
45
+ * Errors are AuthError(code, message) with permanent=true. A failed
46
+ * verify is NOT an error — it returns false. Errors are reserved for
47
+ * "the call shape was wrong" (empty plain, oversize plain).
48
+ */
49
+ var argon2 = require("../vendor/argon2");
50
+ var { AuthError } = require("../framework-error");
51
+
52
+ // Tuning targets ~250–500ms on commodity 2026 hardware. memoryCost
53
+ // is in KiB per Argon2's parameter convention.
54
+ var DEFAULT_PARAMS = Object.freeze({
55
+ memoryCost: 65536, // 64 MiB
56
+ timeCost: 3,
57
+ parallelism: 4,
58
+ });
59
+
60
+ var MAX_PLAINTEXT_BYTES = 4096;
61
+
62
+ function _validatePlain(plain) {
63
+ if (typeof plain !== "string" || plain.length === 0) {
64
+ throw new AuthError("auth-password/invalid-plain",
65
+ "auth.password.hash requires a non-empty string");
66
+ }
67
+ if (Buffer.byteLength(plain, "utf8") > MAX_PLAINTEXT_BYTES) {
68
+ throw new AuthError("auth-password/plain-too-large",
69
+ "plaintext exceeds " + MAX_PLAINTEXT_BYTES + " bytes (UTF-8)");
70
+ }
71
+ }
72
+
73
+ function _resolveParams(opts) {
74
+ var p = Object.assign({}, DEFAULT_PARAMS, opts || {});
75
+ if (typeof p.memoryCost !== "number" || p.memoryCost < 1024) {
76
+ throw new AuthError("auth-password/bad-params",
77
+ "memoryCost must be >= 1024 KiB (1 MiB)");
78
+ }
79
+ if (typeof p.timeCost !== "number" || p.timeCost < 1) {
80
+ throw new AuthError("auth-password/bad-params", "timeCost must be >= 1");
81
+ }
82
+ if (typeof p.parallelism !== "number" || p.parallelism < 1) {
83
+ throw new AuthError("auth-password/bad-params", "parallelism must be >= 1");
84
+ }
85
+ return p;
86
+ }
87
+
88
+ async function hash(plain, opts) {
89
+ _validatePlain(plain);
90
+ var p = _resolveParams(opts);
91
+ return await argon2.hash(plain, {
92
+ type: argon2.argon2id,
93
+ memoryCost: p.memoryCost,
94
+ timeCost: p.timeCost,
95
+ parallelism: p.parallelism,
96
+ });
97
+ }
98
+
99
+ async function verify(stored, plain) {
100
+ // verify intentionally tolerates malformed input by returning false
101
+ // rather than throwing — login flows already treat false as "credentials
102
+ // didn't match" and shouldn't have to wrap each call in try/catch.
103
+ if (typeof stored !== "string" || stored.length === 0) return false;
104
+ if (typeof plain !== "string" || plain.length === 0) return false;
105
+ if (!stored.indexOf || stored.indexOf("$argon2id$") !== 0) return false;
106
+ if (Buffer.byteLength(plain, "utf8") > MAX_PLAINTEXT_BYTES) return false;
107
+ try {
108
+ return await argon2.verify(stored, plain);
109
+ } catch (_e) {
110
+ // PHC-string parse failures from the vendor surface as throws —
111
+ // treat as "doesn't match" so a corrupted DB column can't break
112
+ // login flows with an unexpected exception type.
113
+ return false;
114
+ }
115
+ }
116
+
117
+ function needsRehash(stored, opts) {
118
+ if (typeof stored !== "string" || stored.indexOf("$argon2id$") !== 0) {
119
+ // Non-id variant or malformed — force rehash on next successful login
120
+ return true;
121
+ }
122
+ var p = _resolveParams(opts);
123
+ try {
124
+ return argon2.needsRehash(stored, {
125
+ memoryCost: p.memoryCost,
126
+ timeCost: p.timeCost,
127
+ parallelism: p.parallelism,
128
+ });
129
+ } catch (_e) {
130
+ return true; // unparseable → rehash
131
+ }
132
+ }
133
+
134
+ module.exports = {
135
+ hash: hash,
136
+ verify: verify,
137
+ needsRehash: needsRehash,
138
+ DEFAULT_PARAMS: DEFAULT_PARAMS,
139
+ };
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ /**
3
+ * auth.totp — auth-namespace bridge to lib/totp.js.
4
+ *
5
+ * The TOTP primitive itself is in lib/totp.js so it can be used for
6
+ * non-auth time-based codes (idempotency tokens, signed-link freshness
7
+ * windows, etc.). This file exposes the same surface under the
8
+ * framework's `auth.*` namespace so application code reaches it via
9
+ * the natural auth-feature path: `b.auth.totp.generateSecret()`.
10
+ *
11
+ * No additional logic — this is a re-export. Keeping it as a separate
12
+ * module (rather than `auth.totp = require("../totp")`) makes the
13
+ * dependency graph explicit and gives a place to layer auth-specific
14
+ * defaults later (e.g. an auth-namespace-only step/algorithm policy)
15
+ * without touching the underlying primitive.
16
+ */
17
+ module.exports = require("../totp");
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+ /**
3
+ * auth-header — construct HTTP Authorization headers for the framework's
4
+ * outbound consumers.
5
+ *
6
+ * The previous shape was `_authHeaders(config)` reimplemented in three
7
+ * places (log-stream-webhook, object-store-http-put, object-store-gcs).
8
+ * Each handled the same { auth, token, username, password } shape with
9
+ * subtly different argument names and case semantics. This module
10
+ * collapses the construction.
11
+ *
12
+ * Public API:
13
+ *
14
+ * bearer(token) → { Authorization: "Bearer <token>" }
15
+ * basic(username, password) → { Authorization: "Basic <b64>" }
16
+ * fromConfig({ auth, token, ... }) → headers object for the named auth method
17
+ *
18
+ * fromConfig accepts:
19
+ *
20
+ * { auth: "none" } → {}
21
+ * { auth: "bearer", token } → bearer(token)
22
+ * { auth: "basic", username, password } → basic(username, password)
23
+ *
24
+ * Anything else throws AuthHeaderError. The "header" pass-through mode
25
+ * that consumers used to wedge into _authHeaders is NOT auth-header's job
26
+ * — that's just header merging. Consumers do `Object.assign({}, config.headers)`
27
+ * themselves and combine the two layers at the call site.
28
+ *
29
+ * Why this is a separate primitive vs. a helper inside http-client:
30
+ * - Some callers (log-stream-local) might emit auth headers without
31
+ * going through http-client (e.g. signed-URL inputs to a token).
32
+ * - Keeping it pure-string-construction with no I/O makes it test-only
33
+ * in 0ms and reusable from non-network contexts.
34
+ */
35
+
36
+ var { FrameworkError } = require("./framework-error");
37
+
38
+ class AuthHeaderError extends FrameworkError {
39
+ constructor(message, code) {
40
+ super(message, code || "auth-header/invalid");
41
+ this.name = "AuthHeaderError";
42
+ this.isAuthHeaderError = true;
43
+ }
44
+ }
45
+
46
+ function bearer(token) {
47
+ if (typeof token !== "string" || token.length === 0) {
48
+ throw new AuthHeaderError("bearer: token must be a non-empty string");
49
+ }
50
+ return { Authorization: "Bearer " + token };
51
+ }
52
+
53
+ function basic(username, password) {
54
+ // Accepting empty username + empty password reflects RFC 7617's
55
+ // tolerance — some legacy endpoints want literally "Basic <b64('::')>".
56
+ // We accept the cast to string but reject undefined-as-username
57
+ // (which would silently produce "Basic <b64('undefined:...')>").
58
+ if (username == null) {
59
+ throw new AuthHeaderError("basic: username is required (pass empty string for credential-less basic)");
60
+ }
61
+ var pwd = password == null ? "" : password;
62
+ var b64 = Buffer.from(String(username) + ":" + String(pwd), "utf8").toString("base64");
63
+ return { Authorization: "Basic " + b64 };
64
+ }
65
+
66
+ function fromConfig(config) {
67
+ if (!config || !config.auth || config.auth === "none") return {};
68
+ if (config.auth === "bearer") return bearer(config.token);
69
+ if (config.auth === "basic") return basic(config.username, config.password);
70
+ throw new AuthHeaderError(
71
+ "unknown auth method '" + config.auth + "' (expected 'none' | 'bearer' | 'basic')",
72
+ "auth-header/unknown-method"
73
+ );
74
+ }
75
+
76
+ module.exports = {
77
+ bearer: bearer,
78
+ basic: basic,
79
+ fromConfig: fromConfig,
80
+ AuthHeaderError: AuthHeaderError,
81
+ };
@@ -0,0 +1,219 @@
1
+ "use strict";
2
+ /**
3
+ * backup-bundle — produce an encrypted backup bundle on disk.
4
+ *
5
+ * Given a dataDir + file include list + passphrase, walks each file,
6
+ * encrypts its bytes via backup-crypto, computes a sha3-512 checksum
7
+ * of the plaintext, and emits a bundle directory:
8
+ *
9
+ * <outDir>/manifest.json — backup-manifest schema
10
+ * <outDir>/files/<path>.enc — per-file encrypted blob
11
+ *
12
+ * Where <path> mirrors the file's relativePath under dataDir (subdirs
13
+ * preserved). The manifest is the only authoritative description of
14
+ * the bundle's contents — a restorer reads it first, then streams
15
+ * each blob into staging.
16
+ *
17
+ * await b.backupBundle.create({
18
+ * dataDir: "./data",
19
+ * outDir: "./backups/2026-04-27.bundle", // must NOT exist
20
+ * passphrase: Buffer.from("operator passphrase"),
21
+ * vaultKeyJson: "<vault.key contents>", // string; encrypted into manifest
22
+ * files: [
23
+ * { relativePath: "db.enc", kind: "raw", required: true },
24
+ * { relativePath: "db.key.enc", kind: "raw", required: true },
25
+ * { relativePath: "vault.key", kind: "raw", required: false },
26
+ * { relativePath: "ca.key.sealed", kind: "vault-sealed", required: false },
27
+ * ],
28
+ * metadata: { reason: "scheduled-daily" },
29
+ * progressCallback: function (event) { ... },
30
+ * });
31
+ * // → { manifest, manifestPath, outDir, bundleSize, fileCount, durationMs }
32
+ *
33
+ * vaultKeyJson is encrypted with the operator passphrase + a fresh
34
+ * salt and stored in the manifest's vaultKeyEnc. With only the
35
+ * passphrase, a restorer on a different machine can recover the
36
+ * framework's vault keypair and unseal the bundle's vault-sealed
37
+ * files post-restore. Without the passphrase, the bundle is opaque.
38
+ *
39
+ * Per-file salts: each file gets its own fresh salt. Argon2id is
40
+ * memory-hard but per-file fresh-salt means an attacker who recovers
41
+ * one file's key from the passphrase has no leverage on other files
42
+ * — the salt rotation forces the full Argon2 computation per file.
43
+ *
44
+ * The bundler does NOT compress files. Operators with large datasets
45
+ * who want compression run their backup pipeline through their own
46
+ * compressor (gzip, zstd) downstream of the framework primitive.
47
+ */
48
+
49
+ var fs = require("fs");
50
+ var path = require("path");
51
+ var atomicFile = require("../atomic-file");
52
+ var backupCrypto = require("./crypto");
53
+ var backupManifest = require("./manifest");
54
+ var { defineClass } = require("../framework-error");
55
+
56
+ var BackupBundleError = defineClass("BackupBundleError", { alwaysPermanent: true });
57
+
58
+ function _emit(cb, ev) {
59
+ if (typeof cb === "function") {
60
+ try { cb(ev); } catch (_e) { /* progress-callback errors are non-fatal */ }
61
+ }
62
+ }
63
+
64
+ // Map relativePath → encryptedPath inside the bundle. Mirrors the
65
+ // directory structure under files/ and appends .enc so every blob
66
+ // has a clear stride and isn't confused with the source file.
67
+ function _encryptedPathFor(relativePath) {
68
+ // POSIX-normalize separators in the bundle so manifests written on
69
+ // Windows and Linux look the same on disk.
70
+ var posix = relativePath.split(path.sep).join("/");
71
+ return "files/" + posix + ".enc";
72
+ }
73
+
74
+ async function create(opts) {
75
+ var t0 = Date.now();
76
+ opts = opts || {};
77
+ if (typeof opts.dataDir !== "string" || !fs.existsSync(opts.dataDir)) {
78
+ throw new BackupBundleError("backup-bundle/no-datadir",
79
+ "create: opts.dataDir is required and must exist");
80
+ }
81
+ if (typeof opts.outDir !== "string" || opts.outDir.length === 0) {
82
+ throw new BackupBundleError("backup-bundle/no-outdir",
83
+ "create: opts.outDir is required");
84
+ }
85
+ if (fs.existsSync(opts.outDir)) {
86
+ throw new BackupBundleError("backup-bundle/outdir-exists",
87
+ "create: outDir already exists: " + opts.outDir +
88
+ " (refusing to overwrite — pick a fresh path)");
89
+ }
90
+ if (!Buffer.isBuffer(opts.passphrase) && typeof opts.passphrase !== "string") {
91
+ throw new BackupBundleError("backup-bundle/no-passphrase",
92
+ "create: opts.passphrase is required (Buffer or string)");
93
+ }
94
+ if (typeof opts.vaultKeyJson !== "string" || opts.vaultKeyJson.length === 0) {
95
+ throw new BackupBundleError("backup-bundle/no-vault-key-json",
96
+ "create: opts.vaultKeyJson is required (the in-memory vault keypair JSON; " +
97
+ "use vault.getKeysJson() or read vault.key from disk)");
98
+ }
99
+ if (!Array.isArray(opts.files) || opts.files.length === 0) {
100
+ throw new BackupBundleError("backup-bundle/no-files",
101
+ "create: opts.files must be a non-empty array of include entries");
102
+ }
103
+ var passphrase = opts.passphrase;
104
+ var dataDir = opts.dataDir;
105
+ var outDir = opts.outDir;
106
+ var progress = opts.progressCallback;
107
+
108
+ atomicFile.ensureDir(outDir);
109
+ atomicFile.ensureDir(path.join(outDir, "files"));
110
+
111
+ // 1. Encrypt the vault key JSON
112
+ _emit(progress, { phase: "wrap_vault_key" });
113
+ var wrappedVk = await backupCrypto.encryptWithFreshSalt(opts.vaultKeyJson, passphrase);
114
+
115
+ // 2. Walk each include entry, encrypt the bytes, emit a blob
116
+ var fileEntries = [];
117
+ var totalBytes = 0;
118
+
119
+ for (var i = 0; i < opts.files.length; i++) {
120
+ var entry = opts.files[i];
121
+ if (!entry || typeof entry.relativePath !== "string" || entry.relativePath.length === 0) {
122
+ throw new BackupBundleError("backup-bundle/bad-include",
123
+ "create: files[" + i + "] requires { relativePath: string }");
124
+ }
125
+ if (entry.relativePath.indexOf("..") !== -1 || /^[/\\]/.test(entry.relativePath)) {
126
+ throw new BackupBundleError("backup-bundle/bad-include",
127
+ "create: files[" + i + "].relativePath must be a relative path (got '" + entry.relativePath + "')");
128
+ }
129
+ var srcPath = path.join(dataDir, entry.relativePath);
130
+ if (!fs.existsSync(srcPath)) {
131
+ if (entry.required) {
132
+ throw new BackupBundleError("backup-bundle/missing-required",
133
+ "create: required file missing: " + entry.relativePath);
134
+ }
135
+ _emit(progress, { phase: "skip_missing", relativePath: entry.relativePath });
136
+ continue;
137
+ }
138
+ var stat = fs.statSync(srcPath);
139
+ if (!stat.isFile()) {
140
+ // Directories aren't supported in this slice — the bundler
141
+ // operates on a flat list of files. Operator wanting a recursive
142
+ // sweep walks the dir themselves and passes the resulting list.
143
+ throw new BackupBundleError("backup-bundle/not-a-file",
144
+ "create: '" + entry.relativePath + "' is not a regular file");
145
+ }
146
+
147
+ _emit(progress, { phase: "read", relativePath: entry.relativePath, size: stat.size });
148
+ var plain = fs.readFileSync(srcPath);
149
+ var checksum = backupCrypto.checksum(plain);
150
+ var encResult = await backupCrypto.encryptWithFreshSalt(plain, passphrase);
151
+ var encPath = _encryptedPathFor(entry.relativePath);
152
+ var destFull = path.join(outDir, encPath);
153
+ atomicFile.ensureDir(path.dirname(destFull));
154
+ atomicFile.writeSync(destFull, encResult.encrypted, { fileMode: 0o600 });
155
+
156
+ var kind = entry.kind || "raw";
157
+ if (!backupManifest.VALID_KINDS[kind]) {
158
+ throw new BackupBundleError("backup-bundle/bad-kind",
159
+ "create: files[" + i + "].kind must be one of raw, vault-sealed, plaintext (got '" + kind + "')");
160
+ }
161
+
162
+ fileEntries.push({
163
+ relativePath: entry.relativePath,
164
+ encryptedPath: encPath,
165
+ size: plain.length,
166
+ encryptedSize: encResult.encrypted.length,
167
+ checksum: checksum,
168
+ salt: encResult.salt,
169
+ kind: kind,
170
+ });
171
+ totalBytes += encResult.encrypted.length;
172
+ _emit(progress, {
173
+ phase: "encrypted",
174
+ relativePath: entry.relativePath,
175
+ encryptedSize: encResult.encrypted.length,
176
+ });
177
+ }
178
+
179
+ if (fileEntries.length === 0) {
180
+ // Nothing to write; refuse to emit an empty manifest. Operators
181
+ // who genuinely want an "empty backup" need to revisit their
182
+ // include list.
183
+ throw new BackupBundleError("backup-bundle/empty",
184
+ "create: no files included in bundle (every entry was missing or skipped)");
185
+ }
186
+
187
+ // 3. Build the manifest and write it last (so a half-written bundle
188
+ // can be detected by absence of manifest.json — an integrity tell)
189
+ _emit(progress, { phase: "write_manifest" });
190
+ var manifest = backupManifest.create({
191
+ vaultKeySalt: wrappedVk.salt,
192
+ vaultKeyEnc: wrappedVk.encrypted.toString("base64"),
193
+ files: fileEntries,
194
+ metadata: opts.metadata || undefined,
195
+ });
196
+ var manifestPath = path.join(outDir, "manifest.json");
197
+ atomicFile.writeSync(manifestPath, backupManifest.serialize(manifest), { fileMode: 0o600 });
198
+
199
+ var durationMs = Date.now() - t0;
200
+ _emit(progress, {
201
+ phase: "done",
202
+ fileCount: fileEntries.length,
203
+ bundleSize: totalBytes,
204
+ durationMs: durationMs,
205
+ });
206
+ return {
207
+ manifest: manifest,
208
+ manifestPath: manifestPath,
209
+ outDir: outDir,
210
+ bundleSize: totalBytes,
211
+ fileCount: fileEntries.length,
212
+ durationMs: durationMs,
213
+ };
214
+ }
215
+
216
+ module.exports = {
217
+ create: create,
218
+ BackupBundleError: BackupBundleError,
219
+ };