@blamejs/core 0.7.4 → 0.7.18
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 +423 -395
- package/README.md +150 -149
- package/bin/blamejs.js +0 -0
- package/index.js +308 -284
- package/lib/api-key.js +660 -663
- package/lib/api-snapshot.js +338 -338
- package/lib/app-shutdown.js +385 -385
- package/lib/app.js +365 -365
- package/lib/archive.js +250 -250
- package/lib/atomic-file.js +544 -544
- package/lib/audit-chain.js +177 -177
- package/lib/audit-sign.js +344 -344
- package/lib/audit-tools.js +677 -677
- package/lib/audit.js +766 -766
- package/lib/auth/jwt.js +311 -311
- package/lib/auth/lockout.js +436 -436
- package/lib/auth/oauth.js +721 -721
- package/lib/auth/passkey.js +181 -181
- package/lib/auth/password.js +594 -594
- package/lib/backup/bundle.js +217 -217
- package/lib/backup/crypto.js +176 -176
- package/lib/backup/index.js +515 -515
- package/lib/backup/manifest.js +282 -282
- package/lib/break-glass.js +1338 -1338
- package/lib/bundler.js +441 -441
- package/lib/cache-redis.js +256 -256
- package/lib/cache.js +1206 -1206
- package/lib/canonical-json.js +115 -115
- package/lib/chain-writer.js +234 -234
- package/lib/cli-helpers.js +206 -206
- package/lib/cli.js +2334 -2334
- package/lib/cluster-provider-db.js +317 -317
- package/lib/cluster-storage.js +226 -226
- package/lib/cluster.js +703 -703
- package/lib/codepoint-class.js +196 -0
- package/lib/config-drift.js +301 -301
- package/lib/consent.js +222 -222
- package/lib/constants.js +191 -191
- package/lib/cookies.js +315 -315
- package/lib/credential-hash.js +322 -322
- package/lib/crypto.js +266 -266
- package/lib/csv.js +275 -286
- package/lib/db-declare-row-policy.js +267 -267
- package/lib/db-declare-view.js +420 -421
- package/lib/db-query.js +406 -406
- package/lib/db-schema.js +319 -319
- package/lib/db.js +1288 -1288
- package/lib/deprecate.js +222 -222
- package/lib/dev.js +335 -335
- package/lib/dual-control.js +473 -473
- package/lib/error-page.js +420 -420
- package/lib/external-db-migrate.js +441 -441
- package/lib/external-db.js +1061 -1061
- package/lib/file-type.js +273 -273
- package/lib/file-upload.js +213 -10
- package/lib/forms.js +422 -422
- package/lib/framework-error.js +293 -215
- package/lib/framework-schema.js +717 -717
- package/lib/gate-contract.js +971 -0
- package/lib/guard-all.js +405 -0
- package/lib/guard-archive.js +739 -0
- package/lib/guard-csv.js +816 -0
- package/lib/guard-email.js +744 -0
- package/lib/guard-filename.js +724 -0
- package/lib/guard-html.js +976 -0
- package/lib/guard-json.js +729 -0
- package/lib/guard-markdown.js +586 -0
- package/lib/guard-svg.js +976 -0
- package/lib/guard-xml.js +405 -0
- package/lib/guard-yaml.js +529 -0
- package/lib/handlers.js +350 -350
- package/lib/http-client-cookie-jar.js +508 -508
- package/lib/http-client.js +1195 -1195
- package/lib/i18n.js +878 -878
- package/lib/jobs.js +185 -185
- package/lib/log-stream-cloudwatch.js +369 -369
- package/lib/log-stream-local.js +146 -146
- package/lib/log-stream-otlp-grpc.js +410 -410
- package/lib/log-stream-otlp.js +286 -286
- package/lib/log-stream-syslog.js +302 -302
- package/lib/log-stream-webhook.js +199 -199
- package/lib/log-stream.js +330 -330
- package/lib/log.js +500 -500
- package/lib/mail-bounce.js +528 -528
- package/lib/mail-dkim.js +369 -362
- package/lib/mail.js +981 -962
- package/lib/metrics.js +683 -683
- package/lib/middleware/api-encrypt.js +936 -936
- package/lib/middleware/attach-user.js +157 -157
- package/lib/middleware/body-parser.js +1170 -1091
- package/lib/middleware/bot-guard.js +178 -178
- package/lib/middleware/compression.js +452 -452
- package/lib/middleware/cors.js +314 -314
- package/lib/middleware/csp-nonce.js +348 -348
- package/lib/middleware/csrf-protect.js +316 -316
- package/lib/middleware/db-role-for.js +264 -264
- package/lib/middleware/health.js +392 -392
- package/lib/middleware/index.js +79 -79
- package/lib/middleware/rate-limit.js +358 -358
- package/lib/middleware/request-id.js +61 -61
- package/lib/middleware/request-log.js +168 -168
- package/lib/middleware/require-auth.js +104 -104
- package/lib/middleware/security-headers.js +116 -116
- package/lib/middleware/sse.js +166 -166
- package/lib/migrations.js +383 -383
- package/lib/mtls-ca.js +518 -518
- package/lib/mtls-engine-default.js +481 -481
- package/lib/network-dns.js +632 -632
- package/lib/network-heartbeat.js +290 -290
- package/lib/network-nts.js +574 -574
- package/lib/network-proxy.js +265 -265
- package/lib/network-tls.js +328 -328
- package/lib/network.js +233 -233
- package/lib/notify.js +612 -612
- package/lib/ntp-check.js +229 -229
- package/lib/numeric-bounds.js +111 -91
- package/lib/object-store/azure-blob-bucket-ops.js +349 -349
- package/lib/object-store/azure-blob.js +488 -488
- package/lib/object-store/gcs-bucket-ops.js +351 -351
- package/lib/object-store/gcs.js +519 -519
- package/lib/object-store/http-put.js +153 -153
- package/lib/object-store/index.js +197 -197
- package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
- package/lib/object-store/sigv4.js +903 -903
- package/lib/observability.js +151 -151
- package/lib/otel-export.js +269 -269
- package/lib/pagination.js +464 -464
- package/lib/parsers/index.js +80 -80
- package/lib/parsers/safe-env.js +642 -642
- package/lib/parsers/safe-ini.js +292 -292
- package/lib/parsers/safe-toml.js +784 -784
- package/lib/parsers/safe-xml.js +390 -390
- package/lib/parsers/safe-yaml.js +1015 -1015
- package/lib/permissions.js +708 -708
- package/lib/pqc-agent.js +87 -87
- package/lib/pqc-gate.js +279 -279
- package/lib/protobuf-encoder.js +190 -190
- package/lib/protocol-dispatcher.js +161 -161
- package/lib/pubsub-redis.js +167 -167
- package/lib/pubsub.js +429 -429
- package/lib/queue-local.js +476 -476
- package/lib/queue-redis.js +745 -745
- package/lib/queue-sqs.js +319 -319
- package/lib/queue.js +695 -695
- package/lib/redis-client.js +519 -519
- package/lib/request-helpers.js +340 -340
- package/lib/restore-bundle.js +237 -237
- package/lib/restore-rollback.js +259 -259
- package/lib/restore.js +409 -409
- package/lib/retry.js +376 -376
- package/lib/router.js +748 -748
- package/lib/safe-async.js +735 -735
- package/lib/safe-buffer.js +237 -237
- package/lib/safe-json.js +541 -541
- package/lib/safe-schema.js +1266 -1266
- package/lib/safe-url.js +159 -159
- package/lib/scheduler.js +706 -706
- package/lib/security-assert.js +373 -373
- package/lib/seeders.js +618 -618
- package/lib/session.js +478 -478
- package/lib/slug.js +269 -269
- package/lib/ssrf-guard.js +401 -401
- package/lib/static.js +184 -4
- package/lib/storage.js +471 -471
- package/lib/subject.js +281 -281
- package/lib/template.js +791 -791
- package/lib/testing.js +798 -798
- package/lib/time.js +310 -310
- package/lib/totp.js +302 -302
- package/lib/tracing.js +494 -494
- package/lib/uuid.js +132 -132
- package/lib/validate-opts.js +340 -319
- package/lib/vault/index.js +308 -308
- package/lib/vault/rotate.js +784 -784
- package/lib/vault/wrap.js +296 -296
- package/lib/vendor/noble-ciphers.cjs +9 -9
- package/lib/webhook.js +595 -595
- package/lib/websocket.js +1048 -1048
- package/package.json +77 -77
- package/sbom.cyclonedx.json +7 -7
package/lib/api-key.js
CHANGED
|
@@ -1,663 +1,660 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* b.apiKey — operator-facing API-key issuance, verification, revocation,
|
|
4
|
-
* and rotation.
|
|
5
|
-
*
|
|
6
|
-
* var keys = b.apiKey.create({
|
|
7
|
-
* namespace: "live",
|
|
8
|
-
* audit: b.audit, // optional
|
|
9
|
-
* trackLastUsedAt: false, // default
|
|
10
|
-
* });
|
|
11
|
-
*
|
|
12
|
-
* var issued = await keys.issue({
|
|
13
|
-
* ownerId: "user-42",
|
|
14
|
-
* scopes: ["read:users", "write:posts"],
|
|
15
|
-
* metadata: { name: "Mobile app v3" },
|
|
16
|
-
* expiresAt: Date.now() + b.constants.TIME.days(90),
|
|
17
|
-
* });
|
|
18
|
-
* // issued.key — "bk_live_<idHex>_<secretHex>" (returned ONCE)
|
|
19
|
-
* // issued.id — "<idHex>"
|
|
20
|
-
*
|
|
21
|
-
* var record = await keys.verify(req.headers["x-api-key"]);
|
|
22
|
-
* // → { id, ownerId, scopes, metadata, ... } or null
|
|
23
|
-
*
|
|
24
|
-
* await keys.revoke(id);
|
|
25
|
-
* var rotated = await keys.rotate(id); // new secret; old stops working
|
|
26
|
-
* var owned = await keys.listForOwner("user-42");
|
|
27
|
-
*
|
|
28
|
-
* Token format (Stripe-style, prefix-recognizable):
|
|
29
|
-
*
|
|
30
|
-
* <prefix>_<namespace>_<idHex>_<secretHex>
|
|
31
|
-
*
|
|
32
|
-
* Example: `bk_live_5b9e7c8a4f2d1e3a_8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d`
|
|
33
|
-
*
|
|
34
|
-
* - prefix operator-supplied; default "bk". Visual marker.
|
|
35
|
-
* - namespace operator-supplied; lets multiple key registries coexist
|
|
36
|
-
* (e.g. "live"/"test", "v1"/"v2") without collision.
|
|
37
|
-
* - idHex opaque random hex; PRIMARY KEY component (DB lookup).
|
|
38
|
-
* - secretHex opaque random hex; never re-derivable. Stored as
|
|
39
|
-
* SHA3-512 hash, constant-time-compared on verify.
|
|
40
|
-
*
|
|
41
|
-
* Storage: framework table `_blamejs_api_keys` (sealed columns:
|
|
42
|
-
* ownerId/scopes/metadata; ownerIdHash for indexed listForOwner).
|
|
43
|
-
* Same dual-storage pattern as sessions — local SQLite in single-node
|
|
44
|
-
* mode, external-db in cluster mode, dispatched via cluster-storage.
|
|
45
|
-
*
|
|
46
|
-
* Validation policy:
|
|
47
|
-
*
|
|
48
|
-
* - apiKey.create opts → throw at config time
|
|
49
|
-
* - registry.issue opts → throw ApiKeyError at call site
|
|
50
|
-
* - registry.rotate(id) on missing/revoked → throw ApiKeyError at call site
|
|
51
|
-
* - registry.verify(token) on any failure → return null (tolerant read)
|
|
52
|
-
* - registry.revoke(id) on missing → return false (tolerant read)
|
|
53
|
-
* - registry.getById(id) on missing → return null (tolerant read)
|
|
54
|
-
*/
|
|
55
|
-
|
|
56
|
-
var crypto = require("./crypto");
|
|
57
|
-
var credentialHash = require("./credential-hash");
|
|
58
|
-
var safeJson = require("./safe-json");
|
|
59
|
-
var lazyRequire = require("./lazy-require");
|
|
60
|
-
var clusterStorage = require("./cluster-storage");
|
|
61
|
-
var cluster = require("./cluster");
|
|
62
|
-
var cryptoField = require("./crypto-field");
|
|
63
|
-
var requestHelpers = require("./request-helpers");
|
|
64
|
-
var validateOpts = require("./validate-opts");
|
|
65
|
-
var C = require("./constants");
|
|
66
|
-
var numericChecks = require("./numeric-checks");
|
|
67
|
-
var { ApiKeyError } = require("./framework-error");
|
|
68
|
-
|
|
69
|
-
var observability = lazyRequire(function () { return require("./observability"); });
|
|
70
|
-
|
|
71
|
-
function _emitEvent(n, v, l) { observability().safeEvent(n, v, l || {}); }
|
|
72
|
-
|
|
73
|
-
var _err = ApiKeyError.factory;
|
|
74
|
-
|
|
75
|
-
var TABLE = "_blamejs_api_keys";
|
|
76
|
-
// Pre-quoted form for SQL interpolation. Defense-in-depth: even though
|
|
77
|
-
// our constant is bare-identifier-shaped, every interpolation site uses
|
|
78
|
-
// the wrapped form so a future rename to a reserved-word or
|
|
79
|
-
// whitespace-bearing name would still resolve correctly.
|
|
80
|
-
var Q_TABLE = '"' + TABLE + '"';
|
|
81
|
-
|
|
82
|
-
// Column order used for INSERT — kept as a constant so the placeholders
|
|
83
|
-
// list and the values list stay in sync. Must match _blamejs_api_keys'
|
|
84
|
-
// schema in db.js (single-node) and framework-schema.js (cluster mode).
|
|
85
|
-
var COLS = [
|
|
86
|
-
"id", "namespace", "ownerId", "ownerIdHash", "secretHash",
|
|
87
|
-
"secondarySecretHash", "secondaryExpiresAt",
|
|
88
|
-
"scopes", "metadata", "createdAt", "expiresAt", "revokedAt",
|
|
89
|
-
"lastUsedAt", "prefix",
|
|
90
|
-
];
|
|
91
|
-
|
|
92
|
-
// Default rotate grace period when caller passes { graceful: true }
|
|
93
|
-
// without an explicit gracePeriodMs. 7 days is enough to migrate the
|
|
94
|
-
// vast majority of clients without paging anyone, short enough that
|
|
95
|
-
// a forgotten old secret stops working before it becomes a long-tail
|
|
96
|
-
// liability.
|
|
97
|
-
var DEFAULT_ROTATE_GRACE_MS = C.TIME.days(7);
|
|
98
|
-
|
|
99
|
-
// Visibility defaults are ON. When an operator wires `audit: b.audit`,
|
|
100
|
-
// they're declaring "I want to know what happens with these credentials"
|
|
101
|
-
// — that includes verify success (every access to a credential), reads
|
|
102
|
-
// (listForOwner/getById), and all state changes. The compliance trail
|
|
103
|
-
// (HIPAA §164.312(b), PCI-DSS 10.2.1, GDPR Art. 32) needs WHO + WHAT +
|
|
104
|
-
// WHEN on every access, not just on writes.
|
|
105
|
-
//
|
|
106
|
-
// Operators with extreme verify-rate volume opt OUT explicitly via
|
|
107
|
-
// `auditSuccess: false`. Failures stay on regardless.
|
|
108
|
-
var DEFAULTS = Object.freeze({
|
|
109
|
-
prefix: "bk",
|
|
110
|
-
idBytes: C.BYTES.bytes(8), // 16 hex chars
|
|
111
|
-
secretBytes: C.BYTES.bytes(16), // 32 hex chars
|
|
112
|
-
trackLastUsedAt: true, // visibility on dormant / leaked keys
|
|
113
|
-
auditFailures: true, // failure events are actionable signals
|
|
114
|
-
auditSuccess: true, // compliance trail — opt out at extreme volume
|
|
115
|
-
purgeAfterMs: C.TIME.days(90),
|
|
116
|
-
// Credential hash algorithm for new issues. Falls through to
|
|
117
|
-
// credentialHash defaults; SHAKE256 is the active per-framework
|
|
118
|
-
// because api-key secrets are 128-bit random (memory-hard property
|
|
119
|
-
// buys nothing at that entropy) and SHAKE256 is an XOF — the
|
|
120
|
-
// envelope payload length itself drives the digest size, so a
|
|
121
|
-
// future operator can request 96-byte digests without an algorithm
|
|
122
|
-
// rotation. Operators with low-entropy or paranoia-mode storage
|
|
123
|
-
// pin "argon2id" per registry. The envelope ensures historical
|
|
124
|
-
// credentials always remain verifiable.
|
|
125
|
-
hashAlgo: "shake256",
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
// ---- Config-time validation helpers (throw on bad input) ----
|
|
129
|
-
|
|
130
|
-
var _isPositiveInt = numericChecks.isPositiveInt;
|
|
131
|
-
|
|
132
|
-
function _validateIdentifier(name, value) {
|
|
133
|
-
if (typeof value !== "string" || value.length === 0) {
|
|
134
|
-
throw _err("BAD_OPT", name + " must be a non-empty string, got " + typeof value);
|
|
135
|
-
}
|
|
136
|
-
if (/[_\s]/.test(value)) {
|
|
137
|
-
throw _err("BAD_OPT", name + " must not contain underscores or whitespace (collides with format separator), got " +
|
|
138
|
-
JSON.stringify(value));
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function _validateCreateOpts(opts) {
|
|
143
|
-
validateOpts.requireObject(opts, "apiKey.create", ApiKeyError);
|
|
144
|
-
_validateIdentifier("apiKey.create: namespace", opts.namespace);
|
|
145
|
-
if (opts.prefix !== undefined) _validateIdentifier("apiKey.create: prefix", opts.prefix);
|
|
146
|
-
validateOpts.optionalPositiveInt(opts.idBytes, "apiKey.create: idBytes", ApiKeyError);
|
|
147
|
-
validateOpts.optionalPositiveInt(opts.secretBytes, "apiKey.create: secretBytes", ApiKeyError);
|
|
148
|
-
validateOpts.optionalBoolean(opts.trackLastUsedAt, "apiKey.create: trackLastUsedAt", ApiKeyError);
|
|
149
|
-
validateOpts.optionalBoolean(opts.auditFailures, "apiKey.create: auditFailures", ApiKeyError);
|
|
150
|
-
validateOpts.optionalBoolean(opts.auditSuccess, "apiKey.create: auditSuccess", ApiKeyError);
|
|
151
|
-
validateOpts.optionalFiniteNonNegative(opts.purgeAfterMs, "apiKey.create: purgeAfterMs", ApiKeyError);
|
|
152
|
-
if (opts.hashAlgo !== undefined) {
|
|
153
|
-
if (typeof opts.hashAlgo !== "string" ||
|
|
154
|
-
(opts.hashAlgo !== "shake256" && opts.hashAlgo !== "argon2id")) {
|
|
155
|
-
throw _err("BAD_OPT", "apiKey.create: hashAlgo must be 'shake256' or 'argon2id', got " +
|
|
156
|
-
JSON.stringify(opts.hashAlgo));
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
validateOpts.auditShape(opts.audit, "apiKey.create", ApiKeyError);
|
|
160
|
-
validateOpts.optionalFunction(opts.clock, "apiKey.create: clock", ApiKeyError);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function _validateIssueOpts(opts) {
|
|
164
|
-
validateOpts.requireObject(opts, "apiKey.issue", ApiKeyError);
|
|
165
|
-
validateOpts.requireNonEmptyString(opts.ownerId, "apiKey.issue: ownerId", ApiKeyError, "MISSING_OWNER");
|
|
166
|
-
validateOpts.optionalNonEmptyStringArray(opts.scopes, "apiKey.issue: scopes", ApiKeyError, "BAD_SCOPES");
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
if (
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
var
|
|
224
|
-
var
|
|
225
|
-
var
|
|
226
|
-
var
|
|
227
|
-
var
|
|
228
|
-
var
|
|
229
|
-
var
|
|
230
|
-
var
|
|
231
|
-
var
|
|
232
|
-
|
|
233
|
-
var
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
if (
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
var
|
|
291
|
-
var
|
|
292
|
-
var
|
|
293
|
-
var
|
|
294
|
-
|
|
295
|
-
var
|
|
296
|
-
var
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
)
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
if (!primaryMatch &&
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
});
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
if (
|
|
492
|
-
throw _err("
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
//
|
|
557
|
-
//
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
)
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
ApiKeyError: ApiKeyError,
|
|
662
|
-
DEFAULTS: DEFAULTS,
|
|
663
|
-
};
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.apiKey — operator-facing API-key issuance, verification, revocation,
|
|
4
|
+
* and rotation.
|
|
5
|
+
*
|
|
6
|
+
* var keys = b.apiKey.create({
|
|
7
|
+
* namespace: "live",
|
|
8
|
+
* audit: b.audit, // optional
|
|
9
|
+
* trackLastUsedAt: false, // default
|
|
10
|
+
* });
|
|
11
|
+
*
|
|
12
|
+
* var issued = await keys.issue({
|
|
13
|
+
* ownerId: "user-42",
|
|
14
|
+
* scopes: ["read:users", "write:posts"],
|
|
15
|
+
* metadata: { name: "Mobile app v3" },
|
|
16
|
+
* expiresAt: Date.now() + b.constants.TIME.days(90),
|
|
17
|
+
* });
|
|
18
|
+
* // issued.key — "bk_live_<idHex>_<secretHex>" (returned ONCE)
|
|
19
|
+
* // issued.id — "<idHex>"
|
|
20
|
+
*
|
|
21
|
+
* var record = await keys.verify(req.headers["x-api-key"]);
|
|
22
|
+
* // → { id, ownerId, scopes, metadata, ... } or null
|
|
23
|
+
*
|
|
24
|
+
* await keys.revoke(id);
|
|
25
|
+
* var rotated = await keys.rotate(id); // new secret; old stops working
|
|
26
|
+
* var owned = await keys.listForOwner("user-42");
|
|
27
|
+
*
|
|
28
|
+
* Token format (Stripe-style, prefix-recognizable):
|
|
29
|
+
*
|
|
30
|
+
* <prefix>_<namespace>_<idHex>_<secretHex>
|
|
31
|
+
*
|
|
32
|
+
* Example: `bk_live_5b9e7c8a4f2d1e3a_8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d`
|
|
33
|
+
*
|
|
34
|
+
* - prefix operator-supplied; default "bk". Visual marker.
|
|
35
|
+
* - namespace operator-supplied; lets multiple key registries coexist
|
|
36
|
+
* (e.g. "live"/"test", "v1"/"v2") without collision.
|
|
37
|
+
* - idHex opaque random hex; PRIMARY KEY component (DB lookup).
|
|
38
|
+
* - secretHex opaque random hex; never re-derivable. Stored as
|
|
39
|
+
* SHA3-512 hash, constant-time-compared on verify.
|
|
40
|
+
*
|
|
41
|
+
* Storage: framework table `_blamejs_api_keys` (sealed columns:
|
|
42
|
+
* ownerId/scopes/metadata; ownerIdHash for indexed listForOwner).
|
|
43
|
+
* Same dual-storage pattern as sessions — local SQLite in single-node
|
|
44
|
+
* mode, external-db in cluster mode, dispatched via cluster-storage.
|
|
45
|
+
*
|
|
46
|
+
* Validation policy:
|
|
47
|
+
*
|
|
48
|
+
* - apiKey.create opts → throw at config time
|
|
49
|
+
* - registry.issue opts → throw ApiKeyError at call site
|
|
50
|
+
* - registry.rotate(id) on missing/revoked → throw ApiKeyError at call site
|
|
51
|
+
* - registry.verify(token) on any failure → return null (tolerant read)
|
|
52
|
+
* - registry.revoke(id) on missing → return false (tolerant read)
|
|
53
|
+
* - registry.getById(id) on missing → return null (tolerant read)
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
var crypto = require("./crypto");
|
|
57
|
+
var credentialHash = require("./credential-hash");
|
|
58
|
+
var safeJson = require("./safe-json");
|
|
59
|
+
var lazyRequire = require("./lazy-require");
|
|
60
|
+
var clusterStorage = require("./cluster-storage");
|
|
61
|
+
var cluster = require("./cluster");
|
|
62
|
+
var cryptoField = require("./crypto-field");
|
|
63
|
+
var requestHelpers = require("./request-helpers");
|
|
64
|
+
var validateOpts = require("./validate-opts");
|
|
65
|
+
var C = require("./constants");
|
|
66
|
+
var numericChecks = require("./numeric-checks");
|
|
67
|
+
var { ApiKeyError } = require("./framework-error");
|
|
68
|
+
|
|
69
|
+
var observability = lazyRequire(function () { return require("./observability"); });
|
|
70
|
+
|
|
71
|
+
function _emitEvent(n, v, l) { observability().safeEvent(n, v, l || {}); }
|
|
72
|
+
|
|
73
|
+
var _err = ApiKeyError.factory;
|
|
74
|
+
|
|
75
|
+
var TABLE = "_blamejs_api_keys";
|
|
76
|
+
// Pre-quoted form for SQL interpolation. Defense-in-depth: even though
|
|
77
|
+
// our constant is bare-identifier-shaped, every interpolation site uses
|
|
78
|
+
// the wrapped form so a future rename to a reserved-word or
|
|
79
|
+
// whitespace-bearing name would still resolve correctly.
|
|
80
|
+
var Q_TABLE = '"' + TABLE + '"';
|
|
81
|
+
|
|
82
|
+
// Column order used for INSERT — kept as a constant so the placeholders
|
|
83
|
+
// list and the values list stay in sync. Must match _blamejs_api_keys'
|
|
84
|
+
// schema in db.js (single-node) and framework-schema.js (cluster mode).
|
|
85
|
+
var COLS = [
|
|
86
|
+
"id", "namespace", "ownerId", "ownerIdHash", "secretHash",
|
|
87
|
+
"secondarySecretHash", "secondaryExpiresAt",
|
|
88
|
+
"scopes", "metadata", "createdAt", "expiresAt", "revokedAt",
|
|
89
|
+
"lastUsedAt", "prefix",
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
// Default rotate grace period when caller passes { graceful: true }
|
|
93
|
+
// without an explicit gracePeriodMs. 7 days is enough to migrate the
|
|
94
|
+
// vast majority of clients without paging anyone, short enough that
|
|
95
|
+
// a forgotten old secret stops working before it becomes a long-tail
|
|
96
|
+
// liability.
|
|
97
|
+
var DEFAULT_ROTATE_GRACE_MS = C.TIME.days(7);
|
|
98
|
+
|
|
99
|
+
// Visibility defaults are ON. When an operator wires `audit: b.audit`,
|
|
100
|
+
// they're declaring "I want to know what happens with these credentials"
|
|
101
|
+
// — that includes verify success (every access to a credential), reads
|
|
102
|
+
// (listForOwner/getById), and all state changes. The compliance trail
|
|
103
|
+
// (HIPAA §164.312(b), PCI-DSS 10.2.1, GDPR Art. 32) needs WHO + WHAT +
|
|
104
|
+
// WHEN on every access, not just on writes.
|
|
105
|
+
//
|
|
106
|
+
// Operators with extreme verify-rate volume opt OUT explicitly via
|
|
107
|
+
// `auditSuccess: false`. Failures stay on regardless.
|
|
108
|
+
var DEFAULTS = Object.freeze({
|
|
109
|
+
prefix: "bk",
|
|
110
|
+
idBytes: C.BYTES.bytes(8), // 16 hex chars
|
|
111
|
+
secretBytes: C.BYTES.bytes(16), // 32 hex chars
|
|
112
|
+
trackLastUsedAt: true, // visibility on dormant / leaked keys
|
|
113
|
+
auditFailures: true, // failure events are actionable signals
|
|
114
|
+
auditSuccess: true, // compliance trail — opt out at extreme volume
|
|
115
|
+
purgeAfterMs: C.TIME.days(90),
|
|
116
|
+
// Credential hash algorithm for new issues. Falls through to
|
|
117
|
+
// credentialHash defaults; SHAKE256 is the active per-framework
|
|
118
|
+
// because api-key secrets are 128-bit random (memory-hard property
|
|
119
|
+
// buys nothing at that entropy) and SHAKE256 is an XOF — the
|
|
120
|
+
// envelope payload length itself drives the digest size, so a
|
|
121
|
+
// future operator can request 96-byte digests without an algorithm
|
|
122
|
+
// rotation. Operators with low-entropy or paranoia-mode storage
|
|
123
|
+
// pin "argon2id" per registry. The envelope ensures historical
|
|
124
|
+
// credentials always remain verifiable.
|
|
125
|
+
hashAlgo: "shake256",
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ---- Config-time validation helpers (throw on bad input) ----
|
|
129
|
+
|
|
130
|
+
var _isPositiveInt = numericChecks.isPositiveInt;
|
|
131
|
+
|
|
132
|
+
function _validateIdentifier(name, value) {
|
|
133
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
134
|
+
throw _err("BAD_OPT", name + " must be a non-empty string, got " + typeof value);
|
|
135
|
+
}
|
|
136
|
+
if (/[_\s]/.test(value)) {
|
|
137
|
+
throw _err("BAD_OPT", name + " must not contain underscores or whitespace (collides with format separator), got " +
|
|
138
|
+
JSON.stringify(value));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function _validateCreateOpts(opts) {
|
|
143
|
+
validateOpts.requireObject(opts, "apiKey.create", ApiKeyError);
|
|
144
|
+
_validateIdentifier("apiKey.create: namespace", opts.namespace);
|
|
145
|
+
if (opts.prefix !== undefined) _validateIdentifier("apiKey.create: prefix", opts.prefix);
|
|
146
|
+
validateOpts.optionalPositiveInt(opts.idBytes, "apiKey.create: idBytes", ApiKeyError);
|
|
147
|
+
validateOpts.optionalPositiveInt(opts.secretBytes, "apiKey.create: secretBytes", ApiKeyError);
|
|
148
|
+
validateOpts.optionalBoolean(opts.trackLastUsedAt, "apiKey.create: trackLastUsedAt", ApiKeyError);
|
|
149
|
+
validateOpts.optionalBoolean(opts.auditFailures, "apiKey.create: auditFailures", ApiKeyError);
|
|
150
|
+
validateOpts.optionalBoolean(opts.auditSuccess, "apiKey.create: auditSuccess", ApiKeyError);
|
|
151
|
+
validateOpts.optionalFiniteNonNegative(opts.purgeAfterMs, "apiKey.create: purgeAfterMs", ApiKeyError);
|
|
152
|
+
if (opts.hashAlgo !== undefined) {
|
|
153
|
+
if (typeof opts.hashAlgo !== "string" ||
|
|
154
|
+
(opts.hashAlgo !== "shake256" && opts.hashAlgo !== "argon2id")) {
|
|
155
|
+
throw _err("BAD_OPT", "apiKey.create: hashAlgo must be 'shake256' or 'argon2id', got " +
|
|
156
|
+
JSON.stringify(opts.hashAlgo));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
validateOpts.auditShape(opts.audit, "apiKey.create", ApiKeyError);
|
|
160
|
+
validateOpts.optionalFunction(opts.clock, "apiKey.create: clock", ApiKeyError);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function _validateIssueOpts(opts) {
|
|
164
|
+
validateOpts.requireObject(opts, "apiKey.issue", ApiKeyError);
|
|
165
|
+
validateOpts.requireNonEmptyString(opts.ownerId, "apiKey.issue: ownerId", ApiKeyError, "MISSING_OWNER");
|
|
166
|
+
validateOpts.optionalNonEmptyStringArray(opts.scopes, "apiKey.issue: scopes", ApiKeyError, "BAD_SCOPES");
|
|
167
|
+
validateOpts.optionalPlainObject(opts.metadata, "apiKey.issue: metadata",
|
|
168
|
+
ApiKeyError, "BAD_METADATA");
|
|
169
|
+
if (opts.expiresAt !== undefined && opts.expiresAt !== null) {
|
|
170
|
+
if (typeof opts.expiresAt !== "number" || !isFinite(opts.expiresAt) || opts.expiresAt < 0) {
|
|
171
|
+
throw _err("BAD_OPT", "apiKey.issue: expiresAt must be a non-negative finite number (unix ms) or null");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---- Token format ----
|
|
177
|
+
|
|
178
|
+
// Format: <prefix>_<namespace>_<idHex>_<secretHex>
|
|
179
|
+
// Each part is alphanumeric so split-by-underscore is unambiguous as long
|
|
180
|
+
// as prefix/namespace are validated to contain no underscores. We verify
|
|
181
|
+
// that during create.
|
|
182
|
+
function parseFormat(token) {
|
|
183
|
+
if (typeof token !== "string" || token.length === 0) return null;
|
|
184
|
+
var parts = token.split("_");
|
|
185
|
+
if (parts.length !== 4) return null;
|
|
186
|
+
var prefix = parts[0], ns = parts[1], idHex = parts[2], secretHex = parts[3];
|
|
187
|
+
if (!prefix || !ns || !idHex || !secretHex) return null;
|
|
188
|
+
if (!/^[0-9a-f]+$/i.test(idHex) || !/^[0-9a-f]+$/i.test(secretHex)) return null;
|
|
189
|
+
return { prefix: prefix, namespace: ns, idHex: idHex, secretHex: secretHex };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function _composeKey(prefix, namespace, idHex, secretHex) {
|
|
193
|
+
return prefix + "_" + namespace + "_" + idHex + "_" + secretHex;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function _composedId(namespace, idHex) {
|
|
197
|
+
return namespace + ":" + idHex;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---- Sealed-row helpers ----
|
|
201
|
+
|
|
202
|
+
function _sealForInsert(row) {
|
|
203
|
+
var sealed = cryptoField.sealRow(TABLE, row);
|
|
204
|
+
for (var i = 0; i < COLS.length; i++) {
|
|
205
|
+
if (!(COLS[i] in sealed)) sealed[COLS[i]] = null;
|
|
206
|
+
}
|
|
207
|
+
return sealed;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---- Registry factory ----
|
|
211
|
+
|
|
212
|
+
function create(opts) {
|
|
213
|
+
opts = opts || {};
|
|
214
|
+
validateOpts(opts, [
|
|
215
|
+
"namespace", "prefix", "idBytes", "secretBytes",
|
|
216
|
+
"trackLastUsedAt", "auditFailures", "auditSuccess",
|
|
217
|
+
"purgeAfterMs", "hashAlgo", "audit", "clock",
|
|
218
|
+
], "apiKey");
|
|
219
|
+
_validateCreateOpts(opts);
|
|
220
|
+
var cfg = validateOpts.applyDefaults(opts, DEFAULTS);
|
|
221
|
+
var prefix = cfg.prefix;
|
|
222
|
+
var namespace = opts.namespace;
|
|
223
|
+
var idBytes = cfg.idBytes;
|
|
224
|
+
var secretBytes = cfg.secretBytes;
|
|
225
|
+
var trackLastUsedAt = cfg.trackLastUsedAt;
|
|
226
|
+
var auditFailures = cfg.auditFailures;
|
|
227
|
+
var auditSuccess = cfg.auditSuccess;
|
|
228
|
+
var purgeAfterMs = cfg.purgeAfterMs;
|
|
229
|
+
var hashAlgo = cfg.hashAlgo;
|
|
230
|
+
var audit = opts.audit || null;
|
|
231
|
+
var clock = opts.clock || function () { return Date.now(); };
|
|
232
|
+
|
|
233
|
+
var _emit = validateOpts.makeAuditEmitter(audit);
|
|
234
|
+
|
|
235
|
+
// Build the audit actor by extracting the 5 W's from the supplied
|
|
236
|
+
// request (WHO/WHERE/HOW), then layering caller-supplied context
|
|
237
|
+
// and an explicit userId on top so the most specific value wins.
|
|
238
|
+
// The audit chain treats null fields as "unknown", so partial
|
|
239
|
+
// context is always safe.
|
|
240
|
+
function _actor(callerOpts, userId) {
|
|
241
|
+
return requestHelpers.resolveActorWithOverride(
|
|
242
|
+
callerOpts,
|
|
243
|
+
userId ? { userId: userId } : null
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function _selectAll() {
|
|
248
|
+
return "SELECT id, namespace, ownerId, ownerIdHash, secretHash, " +
|
|
249
|
+
"secondarySecretHash, secondaryExpiresAt, " +
|
|
250
|
+
"scopes, metadata, createdAt, expiresAt, revokedAt, lastUsedAt, prefix FROM " + Q_TABLE;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function _scrubRecord(row) {
|
|
254
|
+
if (!row) return null;
|
|
255
|
+
var unsealed = cryptoField.unsealRow(TABLE, row);
|
|
256
|
+
var scopes = null;
|
|
257
|
+
if (unsealed.scopes) {
|
|
258
|
+
try { scopes = safeJson.parse(unsealed.scopes); } catch (_e) { scopes = null; }
|
|
259
|
+
}
|
|
260
|
+
var metadata = null;
|
|
261
|
+
if (unsealed.metadata) {
|
|
262
|
+
try { metadata = safeJson.parse(unsealed.metadata); } catch (_e) { metadata = null; }
|
|
263
|
+
}
|
|
264
|
+
var idParts = String(unsealed.id).split(":");
|
|
265
|
+
var idHexOnly = idParts.length === 2 ? idParts[1] : unsealed.id;
|
|
266
|
+
return {
|
|
267
|
+
id: idHexOnly,
|
|
268
|
+
namespace: unsealed.namespace,
|
|
269
|
+
ownerId: unsealed.ownerId,
|
|
270
|
+
scopes: scopes || [],
|
|
271
|
+
metadata: metadata || null,
|
|
272
|
+
createdAt: Number(unsealed.createdAt),
|
|
273
|
+
expiresAt: unsealed.expiresAt == null ? null : Number(unsealed.expiresAt),
|
|
274
|
+
revokedAt: unsealed.revokedAt == null ? null : Number(unsealed.revokedAt),
|
|
275
|
+
lastUsedAt: unsealed.lastUsedAt == null ? null : Number(unsealed.lastUsedAt),
|
|
276
|
+
// secondaryExpiresAt is operator-visible signal that a graceful
|
|
277
|
+
// rotation is in flight; secondarySecretHash itself is NEVER
|
|
278
|
+
// exposed.
|
|
279
|
+
secondaryExpiresAt: unsealed.secondaryExpiresAt == null ? null : Number(unsealed.secondaryExpiresAt),
|
|
280
|
+
prefix: unsealed.prefix,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function issue(issueOpts) {
|
|
285
|
+
cluster.requireLeader();
|
|
286
|
+
_validateIssueOpts(issueOpts);
|
|
287
|
+
var idHex = crypto.generateToken(idBytes);
|
|
288
|
+
var secretHex = crypto.generateToken(secretBytes);
|
|
289
|
+
var compositeId = _composedId(namespace, idHex);
|
|
290
|
+
var nowMs = clock();
|
|
291
|
+
var scopes = issueOpts.scopes || [];
|
|
292
|
+
var metadata = issueOpts.metadata || null;
|
|
293
|
+
var expiresAt = (issueOpts.expiresAt === undefined) ? null : issueOpts.expiresAt;
|
|
294
|
+
|
|
295
|
+
var secretEnvelope = await credentialHash.hash(secretHex, { algo: hashAlgo });
|
|
296
|
+
var sealed = _sealForInsert({
|
|
297
|
+
id: compositeId,
|
|
298
|
+
namespace: namespace,
|
|
299
|
+
ownerId: issueOpts.ownerId,
|
|
300
|
+
secretHash: secretEnvelope,
|
|
301
|
+
secondarySecretHash: null,
|
|
302
|
+
secondaryExpiresAt: null,
|
|
303
|
+
scopes: JSON.stringify(scopes),
|
|
304
|
+
metadata: metadata ? JSON.stringify(metadata) : null,
|
|
305
|
+
createdAt: nowMs,
|
|
306
|
+
expiresAt: expiresAt,
|
|
307
|
+
revokedAt: null,
|
|
308
|
+
lastUsedAt: null,
|
|
309
|
+
prefix: prefix,
|
|
310
|
+
});
|
|
311
|
+
var values = COLS.map(function (c) { return sealed[c]; });
|
|
312
|
+
var placeholders = COLS.map(function () { return "?"; }).join(", ");
|
|
313
|
+
var quoted = COLS.map(function (c) { return '"' + c + '"'; }).join(", ");
|
|
314
|
+
|
|
315
|
+
await clusterStorage.execute(
|
|
316
|
+
"INSERT INTO " + Q_TABLE + " (" + quoted + ") VALUES (" + placeholders + ")",
|
|
317
|
+
values
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
_emit("apikey.issue", {
|
|
321
|
+
actor: _actor(issueOpts, issueOpts.ownerId),
|
|
322
|
+
resource: { kind: "apikey", id: compositeId },
|
|
323
|
+
metadata: { namespace: namespace, scopes: scopes, expiresAt: expiresAt },
|
|
324
|
+
});
|
|
325
|
+
_emitEvent("apikey.issue", 1, { namespace: namespace });
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
id: idHex,
|
|
329
|
+
secret: secretHex,
|
|
330
|
+
key: _composeKey(prefix, namespace, idHex, secretHex),
|
|
331
|
+
scopes: scopes,
|
|
332
|
+
metadata: metadata,
|
|
333
|
+
createdAt: nowMs,
|
|
334
|
+
expiresAt: expiresAt,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function verify(token, verifyOpts) {
|
|
339
|
+
var parsed = parseFormat(token);
|
|
340
|
+
if (!parsed) return null;
|
|
341
|
+
if (parsed.prefix !== prefix || parsed.namespace !== namespace) return null;
|
|
342
|
+
|
|
343
|
+
var compositeId = _composedId(namespace, parsed.idHex);
|
|
344
|
+
var row = await clusterStorage.executeOne(
|
|
345
|
+
_selectAll() + " WHERE id = ?",
|
|
346
|
+
[compositeId]
|
|
347
|
+
);
|
|
348
|
+
if (!row) {
|
|
349
|
+
if (auditFailures) {
|
|
350
|
+
_emit("apikey.verify", {
|
|
351
|
+
actor: _actor(verifyOpts),
|
|
352
|
+
resource: { kind: "apikey", id: compositeId },
|
|
353
|
+
outcome: "failure",
|
|
354
|
+
reason: "not-found",
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
_emitEvent("apikey.verify", 1, { namespace: namespace, outcome: "failure", reason: "not-found" });
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
var nowMs = clock();
|
|
362
|
+
var rowOwnerId = null;
|
|
363
|
+
try {
|
|
364
|
+
var unsealedOwner = cryptoField.unsealRow(TABLE, row);
|
|
365
|
+
rowOwnerId = unsealedOwner.ownerId;
|
|
366
|
+
} catch (_e) { rowOwnerId = null; }
|
|
367
|
+
|
|
368
|
+
if (row.revokedAt != null) {
|
|
369
|
+
if (auditFailures) {
|
|
370
|
+
_emit("apikey.verify", {
|
|
371
|
+
actor: _actor(verifyOpts, rowOwnerId),
|
|
372
|
+
resource: { kind: "apikey", id: compositeId },
|
|
373
|
+
outcome: "failure", reason: "revoked",
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
_emitEvent("apikey.verify", 1, { namespace: namespace, outcome: "failure", reason: "revoked" });
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
if (row.expiresAt != null && Number(row.expiresAt) < nowMs) {
|
|
380
|
+
if (auditFailures) {
|
|
381
|
+
_emit("apikey.verify", {
|
|
382
|
+
actor: _actor(verifyOpts, rowOwnerId),
|
|
383
|
+
resource: { kind: "apikey", id: compositeId },
|
|
384
|
+
outcome: "failure", reason: "expired",
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
_emitEvent("apikey.verify", 1, { namespace: namespace, outcome: "failure", reason: "expired" });
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Hash dispatch goes through credentialHash so the persisted byte
|
|
392
|
+
// controls the verification algorithm. Both primary and secondary
|
|
393
|
+
// (graceful-rotation) slots are envelope-encoded.
|
|
394
|
+
var primaryMatch = await credentialHash.verify(parsed.secretHex, row.secretHash);
|
|
395
|
+
var secondaryMatch = false;
|
|
396
|
+
var secondaryActive = row.secondarySecretHash != null &&
|
|
397
|
+
row.secondaryExpiresAt != null &&
|
|
398
|
+
Number(row.secondaryExpiresAt) >= nowMs;
|
|
399
|
+
if (!primaryMatch && secondaryActive) {
|
|
400
|
+
secondaryMatch = await credentialHash.verify(parsed.secretHex, row.secondarySecretHash);
|
|
401
|
+
}
|
|
402
|
+
if (!primaryMatch && !secondaryMatch) {
|
|
403
|
+
if (auditFailures) {
|
|
404
|
+
_emit("apikey.verify", {
|
|
405
|
+
actor: _actor(verifyOpts, rowOwnerId),
|
|
406
|
+
resource: { kind: "apikey", id: compositeId },
|
|
407
|
+
outcome: "failure", reason: "bad-secret",
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
_emitEvent("apikey.verify", 1, { namespace: namespace, outcome: "failure", reason: "bad-secret" });
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (trackLastUsedAt && cluster.isLeader()) {
|
|
415
|
+
try {
|
|
416
|
+
await clusterStorage.execute(
|
|
417
|
+
"UPDATE " + Q_TABLE + " SET lastUsedAt = ? WHERE id = ?",
|
|
418
|
+
[nowMs, compositeId]
|
|
419
|
+
);
|
|
420
|
+
} catch (_e) { /* best-effort; verify success not blocked by lastUsed update */ }
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (auditSuccess) {
|
|
424
|
+
_emit("apikey.verify", {
|
|
425
|
+
actor: _actor(verifyOpts, rowOwnerId),
|
|
426
|
+
resource: { kind: "apikey", id: compositeId },
|
|
427
|
+
outcome: "success",
|
|
428
|
+
metadata: { secondary: secondaryMatch },
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
_emitEvent("apikey.verify", 1,
|
|
432
|
+
{ namespace: namespace, outcome: "success", secondary: secondaryMatch });
|
|
433
|
+
var record = _scrubRecord(row);
|
|
434
|
+
record.usedSecondary = secondaryMatch; // operator can detect grace-period usage
|
|
435
|
+
return record;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function revoke(idHex, revokeOpts) {
|
|
439
|
+
cluster.requireLeader();
|
|
440
|
+
if (typeof idHex !== "string" || idHex.length === 0) return false;
|
|
441
|
+
var compositeId = _composedId(namespace, idHex);
|
|
442
|
+
var nowMs = clock();
|
|
443
|
+
var result = await clusterStorage.execute(
|
|
444
|
+
"UPDATE " + Q_TABLE + " SET revokedAt = ? WHERE id = ? AND revokedAt IS NULL",
|
|
445
|
+
[nowMs, compositeId]
|
|
446
|
+
);
|
|
447
|
+
var changed = (result.rowCount || 0) > 0;
|
|
448
|
+
if (changed) {
|
|
449
|
+
_emit("apikey.revoke", {
|
|
450
|
+
actor: _actor(revokeOpts),
|
|
451
|
+
resource: { kind: "apikey", id: compositeId },
|
|
452
|
+
});
|
|
453
|
+
_emitEvent("apikey.revoke", 1, { namespace: namespace });
|
|
454
|
+
}
|
|
455
|
+
return changed;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function rotate(idHex, rotateOpts) {
|
|
459
|
+
cluster.requireLeader();
|
|
460
|
+
if (typeof idHex !== "string" || idHex.length === 0) {
|
|
461
|
+
throw _err("BAD_OPT", "apiKey.rotate: id must be a non-empty string");
|
|
462
|
+
}
|
|
463
|
+
rotateOpts = rotateOpts || {};
|
|
464
|
+
// Graceful rotation: the previous hash stays valid in the
|
|
465
|
+
// secondarySecretHash slot until secondaryExpiresAt. Operators
|
|
466
|
+
// pass either { graceful: true } (default DEFAULT_ROTATE_GRACE_MS)
|
|
467
|
+
// or { gracePeriodMs: <ms> } for an explicit window. Without
|
|
468
|
+
// either, rotation is immediate (old secret invalidated) — this
|
|
469
|
+
// preserves the original semantics for callers that explicitly
|
|
470
|
+
// want a hard cutover.
|
|
471
|
+
var gracePeriodMs = 0;
|
|
472
|
+
if (typeof rotateOpts.gracePeriodMs === "number") {
|
|
473
|
+
if (!isFinite(rotateOpts.gracePeriodMs) || rotateOpts.gracePeriodMs < 0) {
|
|
474
|
+
throw _err("BAD_OPT", "apiKey.rotate: gracePeriodMs must be a non-negative finite number");
|
|
475
|
+
}
|
|
476
|
+
gracePeriodMs = rotateOpts.gracePeriodMs;
|
|
477
|
+
} else if (rotateOpts.graceful === true) {
|
|
478
|
+
gracePeriodMs = DEFAULT_ROTATE_GRACE_MS;
|
|
479
|
+
} else if (rotateOpts.graceful !== undefined && rotateOpts.graceful !== false) {
|
|
480
|
+
throw _err("BAD_OPT", "apiKey.rotate: graceful must be a boolean");
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
var compositeId = _composedId(namespace, idHex);
|
|
484
|
+
var existing = await clusterStorage.executeOne(
|
|
485
|
+
_selectAll() + " WHERE id = ?",
|
|
486
|
+
[compositeId]
|
|
487
|
+
);
|
|
488
|
+
if (!existing) {
|
|
489
|
+
throw _err("NOT_FOUND", "apiKey.rotate: id '" + idHex + "' not found in namespace '" + namespace + "'");
|
|
490
|
+
}
|
|
491
|
+
if (existing.revokedAt != null) {
|
|
492
|
+
throw _err("REVOKED", "apiKey.rotate: id '" + idHex + "' is revoked");
|
|
493
|
+
}
|
|
494
|
+
var newSecretHex = crypto.generateToken(secretBytes);
|
|
495
|
+
var newHash = await credentialHash.hash(newSecretHex, { algo: hashAlgo });
|
|
496
|
+
var nowMs = clock();
|
|
497
|
+
|
|
498
|
+
if (gracePeriodMs > 0) {
|
|
499
|
+
// Move current hash → secondary slot, install new hash as primary.
|
|
500
|
+
await clusterStorage.execute(
|
|
501
|
+
"UPDATE " + Q_TABLE + " SET secretHash = ?, " +
|
|
502
|
+
"secondarySecretHash = ?, secondaryExpiresAt = ? WHERE id = ?",
|
|
503
|
+
[newHash, existing.secretHash, nowMs + gracePeriodMs, compositeId]
|
|
504
|
+
);
|
|
505
|
+
} else {
|
|
506
|
+
// Hard cutover — old secret stops working immediately. Clears
|
|
507
|
+
// any prior secondary slot too.
|
|
508
|
+
await clusterStorage.execute(
|
|
509
|
+
"UPDATE " + Q_TABLE + " SET secretHash = ?, " +
|
|
510
|
+
"secondarySecretHash = NULL, secondaryExpiresAt = NULL WHERE id = ?",
|
|
511
|
+
[newHash, compositeId]
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
_emit("apikey.rotate", {
|
|
516
|
+
actor: _actor(rotateOpts),
|
|
517
|
+
resource: { kind: "apikey", id: compositeId },
|
|
518
|
+
metadata: { gracePeriodMs: gracePeriodMs },
|
|
519
|
+
});
|
|
520
|
+
_emitEvent("apikey.rotate", 1, { namespace: namespace, graceful: gracePeriodMs > 0 });
|
|
521
|
+
return {
|
|
522
|
+
key: _composeKey(prefix, namespace, idHex, newSecretHex),
|
|
523
|
+
secret: newSecretHex,
|
|
524
|
+
secretHash: newHash,
|
|
525
|
+
gracePeriodMs: gracePeriodMs,
|
|
526
|
+
secondaryExpiresAt: gracePeriodMs > 0 ? (nowMs + gracePeriodMs) : null,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function listForOwner(ownerId, listOpts) {
|
|
531
|
+
if (typeof ownerId !== "string" || ownerId.length === 0) {
|
|
532
|
+
throw _err("BAD_OPT", "apiKey.listForOwner: ownerId must be a non-empty string");
|
|
533
|
+
}
|
|
534
|
+
listOpts = listOpts || {};
|
|
535
|
+
var includeRevoked = !!listOpts.includeRevoked;
|
|
536
|
+
var includeExpired = !!listOpts.includeExpired;
|
|
537
|
+
var lookup = cryptoField.lookupHash(TABLE, "ownerId", ownerId);
|
|
538
|
+
if (!lookup) {
|
|
539
|
+
throw _err("MISCONFIGURED",
|
|
540
|
+
"_blamejs_api_keys schema is missing the ownerIdHash derived hash — framework misconfigured");
|
|
541
|
+
}
|
|
542
|
+
var sql = _selectAll() + " WHERE namespace = ? AND ownerIdHash = ?";
|
|
543
|
+
var params = [namespace, lookup.value];
|
|
544
|
+
if (!includeRevoked) sql += " AND revokedAt IS NULL";
|
|
545
|
+
if (!includeExpired) {
|
|
546
|
+
sql += " AND (expiresAt IS NULL OR expiresAt >= ?)";
|
|
547
|
+
params.push(clock());
|
|
548
|
+
}
|
|
549
|
+
sql += " ORDER BY createdAt DESC";
|
|
550
|
+
var rows = await clusterStorage.execute(sql, params);
|
|
551
|
+
var list = (rows.rows || []).map(_scrubRecord);
|
|
552
|
+
_emitEvent("apikey.list", 1, { namespace: namespace, count: list.length });
|
|
553
|
+
// Read-access audit: "who listed whose keys at time T" — gated by
|
|
554
|
+
// auditSuccess so operators with admin tooling that polls heavily
|
|
555
|
+
// can opt out. ownerId is the audit subject; the listed IDs are
|
|
556
|
+
// included in metadata so a compliance auditor can reconstruct
|
|
557
|
+
// exactly which records were observed.
|
|
558
|
+
if (auditSuccess) {
|
|
559
|
+
_emit("apikey.list", {
|
|
560
|
+
actor: _actor(listOpts),
|
|
561
|
+
resource: { kind: "apikey-namespace", id: namespace },
|
|
562
|
+
metadata: {
|
|
563
|
+
ownerId: ownerId,
|
|
564
|
+
count: list.length,
|
|
565
|
+
observedIds: list.map(function (r) { return r.id; }),
|
|
566
|
+
includeRevoked: includeRevoked,
|
|
567
|
+
includeExpired: includeExpired,
|
|
568
|
+
},
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
return list;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async function getById(idHex, getOpts) {
|
|
575
|
+
if (typeof idHex !== "string" || idHex.length === 0) return null;
|
|
576
|
+
var compositeId = _composedId(namespace, idHex);
|
|
577
|
+
var row = await clusterStorage.executeOne(
|
|
578
|
+
_selectAll() + " WHERE id = ?",
|
|
579
|
+
[compositeId]
|
|
580
|
+
);
|
|
581
|
+
var record = _scrubRecord(row);
|
|
582
|
+
_emitEvent("apikey.get", 1,
|
|
583
|
+
{ namespace: namespace, found: record !== null });
|
|
584
|
+
if (auditSuccess) {
|
|
585
|
+
_emit("apikey.get", {
|
|
586
|
+
actor: _actor(getOpts),
|
|
587
|
+
resource: { kind: "apikey", id: compositeId },
|
|
588
|
+
metadata: { found: record !== null },
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
return record;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async function purgeExpired(purgeOpts) {
|
|
595
|
+
cluster.requireLeader();
|
|
596
|
+
var threshold = clock() - purgeAfterMs;
|
|
597
|
+
// SELECT-then-DELETE so we can audit the specific IDs being purged.
|
|
598
|
+
// Compliance auditors expect "key X was purged at time T" — a count-
|
|
599
|
+
// only audit is too coarse for forensic reconstruction. Cost is one
|
|
600
|
+
// extra round-trip per purge call which runs on a schedule (not
|
|
601
|
+
// request-rate), so the cost is irrelevant.
|
|
602
|
+
var idRows = await clusterStorage.execute(
|
|
603
|
+
"SELECT id FROM " + Q_TABLE + " WHERE namespace = ? AND " +
|
|
604
|
+
"((revokedAt IS NOT NULL AND revokedAt < ?) OR " +
|
|
605
|
+
" (expiresAt IS NOT NULL AND expiresAt < ?))",
|
|
606
|
+
[namespace, threshold, threshold]
|
|
607
|
+
);
|
|
608
|
+
var purgedCompositeIds = (idRows.rows || []).map(function (r) { return r.id; });
|
|
609
|
+
|
|
610
|
+
if (purgedCompositeIds.length === 0) {
|
|
611
|
+
_emitEvent("apikey.purge", 1, { namespace: namespace, count: 0 });
|
|
612
|
+
return 0;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
var result = await clusterStorage.execute(
|
|
616
|
+
"DELETE FROM " + Q_TABLE + " WHERE namespace = ? AND " +
|
|
617
|
+
"((revokedAt IS NOT NULL AND revokedAt < ?) OR " +
|
|
618
|
+
" (expiresAt IS NOT NULL AND expiresAt < ?))",
|
|
619
|
+
[namespace, threshold, threshold]
|
|
620
|
+
);
|
|
621
|
+
var count = result.rowCount || purgedCompositeIds.length;
|
|
622
|
+
|
|
623
|
+
_emit("apikey.purge", {
|
|
624
|
+
actor: _actor(purgeOpts),
|
|
625
|
+
resource: { kind: "apikey-namespace", id: namespace },
|
|
626
|
+
metadata: {
|
|
627
|
+
count: count,
|
|
628
|
+
// Strip the namespace prefix so the audit payload contains
|
|
629
|
+
// bare idHex values consistent with what callers receive
|
|
630
|
+
// from issue/verify/getById.
|
|
631
|
+
purgedIds: purgedCompositeIds.map(function (cid) {
|
|
632
|
+
var parts = cid.split(":");
|
|
633
|
+
return parts.length === 2 ? parts[1] : cid;
|
|
634
|
+
}),
|
|
635
|
+
thresholdMs: threshold,
|
|
636
|
+
},
|
|
637
|
+
});
|
|
638
|
+
_emitEvent("apikey.purge", 1, { namespace: namespace, count: count });
|
|
639
|
+
return count;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
return {
|
|
643
|
+
issue: issue,
|
|
644
|
+
verify: verify,
|
|
645
|
+
revoke: revoke,
|
|
646
|
+
rotate: rotate,
|
|
647
|
+
listForOwner: listForOwner,
|
|
648
|
+
getById: getById,
|
|
649
|
+
purgeExpired: purgeExpired,
|
|
650
|
+
namespace: namespace,
|
|
651
|
+
prefix: prefix,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
module.exports = {
|
|
656
|
+
create: create,
|
|
657
|
+
parseFormat: parseFormat,
|
|
658
|
+
ApiKeyError: ApiKeyError,
|
|
659
|
+
DEFAULTS: DEFAULTS,
|
|
660
|
+
};
|