@blamejs/blamejs-shop 0.4.3 → 0.4.4

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 (28) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/README.md +8 -7
  3. package/lib/admin.js +376 -0
  4. package/lib/asset-manifest.json +3 -3
  5. package/lib/storefront.js +252 -6
  6. package/lib/vendor/MANIFEST.json +23 -23
  7. package/lib/vendor/blamejs/.pinact.yaml +1 -1
  8. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  9. package/lib/vendor/blamejs/SECURITY.md +1 -1
  10. package/lib/vendor/blamejs/api-snapshot.json +15 -2
  11. package/lib/vendor/blamejs/index.js +5 -1
  12. package/lib/vendor/blamejs/lib/auth/jar.js +190 -28
  13. package/lib/vendor/blamejs/lib/auth/jwt-external.js +213 -0
  14. package/lib/vendor/blamejs/lib/auth/oauth.js +115 -101
  15. package/lib/vendor/blamejs/lib/http-client.js +3 -4
  16. package/lib/vendor/blamejs/lib/lro.js +3 -4
  17. package/lib/vendor/blamejs/lib/middleware/deny-response.js +2 -10
  18. package/lib/vendor/blamejs/lib/middleware/health.js +1 -4
  19. package/lib/vendor/blamejs/lib/middleware/trace-log-correlation.js +3 -6
  20. package/lib/vendor/blamejs/lib/validate-opts.js +34 -0
  21. package/lib/vendor/blamejs/package.json +1 -1
  22. package/lib/vendor/blamejs/release-notes/v0.14.22.json +91 -0
  23. package/lib/vendor/blamejs/test/layer-0-primitives/auth-jar.test.js +226 -6
  24. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +122 -14
  25. package/lib/vendor/blamejs/test/layer-0-primitives/jwt-external.test.js +104 -2
  26. package/lib/vendor/blamejs/test/layer-0-primitives/oauth-callback.test.js +127 -0
  27. package/package.json +1 -1
  28. package/lib/vendor/blamejs/memory/specs/node-26-map-getorinsert-migration.md +0 -165
@@ -8,6 +8,8 @@
8
8
  * - authorizationUrl / exchangeCode authorization_details (RFC 9396 RAR)
9
9
  * - buildClientAttestation / buildClientAttestationPop /
10
10
  * verifyClientAttestation (draft-ietf-oauth-attestation-based-client-auth)
11
+ * - pushAuthorizationRequest signed-request-object opt (RFC 9101 +
12
+ * RFC 9126 §3 — request= in the PAR body, params as JAR claims)
11
13
  */
12
14
 
13
15
  var http = require("node:http");
@@ -39,6 +41,46 @@ function _spawnDiscoveryServer(methods) {
39
41
  return server;
40
42
  }
41
43
 
44
+ // Mock authorization server with discovery + a PAR endpoint that records
45
+ // the posted form body. Discovery advertises S256 so the PKCE-downgrade
46
+ // gate passes; the PAR endpoint returns a fixed request_uri.
47
+ function _spawnParServer() {
48
+ var captured = { body: null };
49
+ var holder = { value: null };
50
+ var server = http.createServer(function (req, res) {
51
+ var u = new URL(req.url, "http://localhost");
52
+ if (u.pathname === "/.well-known/openid-configuration") {
53
+ var doc = {
54
+ issuer: holder.value,
55
+ authorization_endpoint: holder.value + "/auth",
56
+ token_endpoint: holder.value + "/token",
57
+ jwks_uri: holder.value + "/jwks",
58
+ pushed_authorization_request_endpoint: holder.value + "/par",
59
+ code_challenge_methods_supported: ["S256"],
60
+ };
61
+ var body = JSON.stringify(doc);
62
+ res.writeHead(200, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) });
63
+ res.end(body);
64
+ return;
65
+ }
66
+ if (u.pathname === "/par" && req.method === "POST") {
67
+ var chunks = [];
68
+ req.on("data", function (c) { chunks.push(c); });
69
+ req.on("end", function () {
70
+ captured.body = Buffer.concat(chunks).toString("utf8");
71
+ var out = JSON.stringify({ request_uri: "urn:ietf:params:oauth:request_uri:abc123", expires_in: 90 });
72
+ res.writeHead(201, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(out) });
73
+ res.end(out);
74
+ });
75
+ return;
76
+ }
77
+ res.writeHead(404); res.end();
78
+ });
79
+ server._holder = holder;
80
+ server._captured = captured;
81
+ return server;
82
+ }
83
+
42
84
  async function _pkceDowngradeCase(methods, expectRefusal, label) {
43
85
  var server = _spawnDiscoveryServer(methods);
44
86
  await new Promise(function (r) { server.listen(0, "127.0.0.1", r); });
@@ -409,6 +451,91 @@ async function run() {
409
451
  check("oauth.clientAttestationHeaders: emits both header fields",
410
452
  typeof hdrs.headers["OAuth-Client-Attestation"] === "string" &&
411
453
  typeof hdrs.headers["OAuth-Client-Attestation-PoP"] === "string");
454
+
455
+ // ---- RFC 9101 + RFC 9126 §3 — PAR with a signed request object ----
456
+ await _testParRequestObject();
457
+ await _testParPlainUnchanged();
458
+ }
459
+
460
+ // PAR carrying a signed request object: the form body MUST hold `request=`
461
+ // + client auth ONLY, and the authorization parameters travel as request-
462
+ // object claims, NOT as bare form params. The pushed JWT is verified
463
+ // in-test against the client's public JWK via b.auth.jar.parse.
464
+ async function _testParRequestObject() {
465
+ var server = _spawnParServer();
466
+ await new Promise(function (r) { server.listen(0, "127.0.0.1", r); });
467
+ var issuer = "http://127.0.0.1:" + server.address().port;
468
+ server._holder.value = issuer;
469
+ try {
470
+ var kp = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
471
+ var pubJwk = Object.assign(kp.publicKey.export({ format: "jwk" }), { kid: "c1", use: "sig", alg: "ES256" });
472
+ var oa = b.auth.oauth.create({
473
+ issuer: issuer,
474
+ clientId: "par-client",
475
+ clientSecret: "par-secret",
476
+ redirectUri: "https://rp.example/cb",
477
+ scope: ["openid", "profile"],
478
+ isOidc: true,
479
+ allowHttp: true,
480
+ allowInternal: true,
481
+ });
482
+ var rv = await oa.pushAuthorizationRequest({
483
+ signedRequestObject: { key: kp.privateKey, kid: "c1" },
484
+ });
485
+ check("oauth.PAR+RO: returns a request_uri", rv.requestUri === "urn:ietf:params:oauth:request_uri:abc123");
486
+ check("oauth.PAR+RO: flags requestObjectSent", rv.requestObjectSent === true);
487
+
488
+ var posted = new URLSearchParams(server._captured.body);
489
+ check("oauth.PAR+RO: form body carries request=", typeof posted.get("request") === "string" && posted.get("request").length > 0);
490
+ check("oauth.PAR+RO: form body carries client_id (client auth)", posted.get("client_id") === "par-client");
491
+ check("oauth.PAR+RO: form body carries client_secret (client auth)", posted.get("client_secret") === "par-secret");
492
+ // RFC 9126 §3 — the authorization parameters MUST NOT appear as bare
493
+ // form params alongside the request object.
494
+ check("oauth.PAR+RO: response_type NOT a bare form param", posted.get("response_type") === null);
495
+ check("oauth.PAR+RO: redirect_uri NOT a bare form param", posted.get("redirect_uri") === null);
496
+ check("oauth.PAR+RO: scope NOT a bare form param", posted.get("scope") === null);
497
+ check("oauth.PAR+RO: code_challenge NOT a bare form param", posted.get("code_challenge") === null);
498
+
499
+ // The pushed request object verifies against the client's public key and
500
+ // carries the authorization parameters as claims (round-trip via jar.parse).
501
+ var parsed = await b.auth.jar.parse(posted.get("request"), {
502
+ clientId: "par-client", audience: issuer, algorithms: ["ES256"], jwks: [pubJwk],
503
+ });
504
+ check("oauth.PAR+RO: request object verifies + carries response_type claim", parsed.params.response_type === "code");
505
+ check("oauth.PAR+RO: request object carries redirect_uri + scope + S256 claims",
506
+ parsed.params.redirect_uri === "https://rp.example/cb" && parsed.params.scope === "openid profile" &&
507
+ parsed.params.code_challenge_method === "S256" && typeof parsed.params.code_challenge === "string");
508
+ check("oauth.PAR+RO: request object aud is the AS issuer", parsed.claims.aud === issuer);
509
+ } finally { server.close(); }
510
+ }
511
+
512
+ // PAR WITHOUT a signed request object: byte-for-byte the prior plain-form
513
+ // behavior — authorization parameters are bare form params, no `request=`.
514
+ async function _testParPlainUnchanged() {
515
+ var server = _spawnParServer();
516
+ await new Promise(function (r) { server.listen(0, "127.0.0.1", r); });
517
+ var issuer = "http://127.0.0.1:" + server.address().port;
518
+ server._holder.value = issuer;
519
+ try {
520
+ var oa = b.auth.oauth.create({
521
+ issuer: issuer,
522
+ clientId: "par-client",
523
+ clientSecret: "par-secret",
524
+ redirectUri: "https://rp.example/cb",
525
+ scope: ["openid"],
526
+ isOidc: true,
527
+ allowHttp: true,
528
+ allowInternal: true,
529
+ });
530
+ var rv = await oa.pushAuthorizationRequest();
531
+ check("oauth.PAR plain: requestObjectSent is false", rv.requestObjectSent === false);
532
+ var posted = new URLSearchParams(server._captured.body);
533
+ check("oauth.PAR plain: NO request= param", posted.get("request") === null);
534
+ check("oauth.PAR plain: response_type bare form param present", posted.get("response_type") === "code");
535
+ check("oauth.PAR plain: redirect_uri bare form param present", posted.get("redirect_uri") === "https://rp.example/cb");
536
+ check("oauth.PAR plain: code_challenge_method S256 present", posted.get("code_challenge_method") === "S256");
537
+ check("oauth.PAR plain: client_secret present", posted.get("client_secret") === "par-secret");
538
+ } finally { server.close(); }
412
539
  }
413
540
 
414
541
  module.exports = { run: run };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {
@@ -1,165 +0,0 @@
1
- # Node 26 — `Map.prototype.getOrInsertComputed` migration plan
2
-
3
- **Status:** detector landed; sweep deferred to the Node 26 floor-bump
4
- (eligible Oct 2026 per the LTS calendar).
5
-
6
- **Floor today:** `engines.node: ">=24"`. Do NOT do the sweep yet.
7
-
8
- ## What changes when the floor moves
9
-
10
- Node 26 ships
11
- [`Map.prototype.getOrInsertComputed(key, factory)`](https://github.com/tc39/proposal-upsert)
12
- (TC39 stage-4, V8 13.x). It replaces two distinct framework-internal
13
- shapes:
14
-
15
- **Variant A** — `var X = M.get(k); if (!X) { ... ; M.set(k, ...); }`:
16
-
17
- ```js
18
- // before
19
- var s = tagIndex.get(tags[i]);
20
- if (!s) { s = new Set(); tagIndex.set(tags[i], s); }
21
- s.add(key);
22
-
23
- // after
24
- var s = tagIndex.getOrInsertComputed(tags[i], function () { return new Set(); });
25
- s.add(key);
26
- ```
27
-
28
- **Variant B** — `if (!M.has(k)) { ... M.set(k, ...); }`:
29
-
30
- ```js
31
- // before
32
- if (!channelToConns.has(channel)) {
33
- channelToConns.set(channel, new Set());
34
- var token = ps.subscribe(channel, _onPubsubMessage);
35
- channelToToken.set(channel, token);
36
- }
37
- channelToConns.get(channel).add(conn);
38
-
39
- // after — single lookup, no half-built-state observer window
40
- var conns = channelToConns.getOrInsertComputed(channel, function () {
41
- channelToToken.set(channel, ps.subscribe(channel, _onPubsubMessage));
42
- return new Set();
43
- });
44
- conns.add(conn);
45
- ```
46
-
47
- Two wins:
48
-
49
- 1. **One lookup instead of two.** `has` + `set` (or `get` + `set`) is a
50
- double hash probe. `getOrInsertComputed` is one probe with an
51
- on-miss factory call.
52
- 2. **Race-window closure in cluster-shared registries.** Between
53
- `M.has(k) === false` and `M.set(k, v)`, an interleaved observer
54
- (debug tooling, registry-snapshot, audit-chain walker) could see
55
- the key as absent OR halfway-built. `getOrInsertComputed`
56
- collapses the gap to a single engine-internal step; no
57
- intermediate state is observable.
58
-
59
- ## Call-site survey (lib/ ground truth, vendor/ excluded)
60
-
61
- Detector survey at v0.11.2. Counts are *call sites*, not files —
62
- several files house multiple sites in different methods of the same
63
- closure-built object (e.g. metrics counters/gauges/histograms).
64
-
65
- ### Agent substrate / cluster-shared state
66
-
67
- None. (`lib/agent-event-bus.js`, `lib/agent-orchestrator.js`,
68
- `lib/agent-snapshot.js`, `lib/agent-idempotency.js`,
69
- `lib/audit-chain.js`, `lib/audit.js`, `lib/break-glass.js`,
70
- `lib/cms-codec.js` audited — only guard-throw / presence-assertion
71
- shapes, no get-or-insert.)
72
-
73
- ### Cache / memoization
74
-
75
- | File | Lines | Map | Factory |
76
- | --------------------------------- | ----- | ------------------------- | --------------------- |
77
- | `lib/cache.js` | 318 | `tagIndex` | `new Set()` |
78
- | `lib/i18n-messageformat.js` | 317 | `_pluralRulesCache` | `new Intl.PluralRules`|
79
- | `lib/i18n.js` | 360 | formatter cache (closure) | closure-`make()` |
80
- | `lib/deprecate.js` | 134 | `_seen` | object-literal |
81
-
82
- ### Observability / metrics
83
-
84
- | File | Lines | Map | Factory |
85
- | ------------------------------------- | ------------------ | ----------------- | ---------------- |
86
- | `lib/metrics.js` | 390, 430, 526 | `values` (×3) | object-literal (counter + gauge `_ensure` + histogram observe; each with cardinality-cap early-return) |
87
- | `lib/observability-otlp-exporter.js` | 164 | `byResource` | object-literal |
88
- | `lib/otel-export.js` | 138, 151 | `counters` / `observations` | object-literal |
89
-
90
- ### Rate-limiting / quotas
91
-
92
- | File | Lines | Map | Factory |
93
- | --------------------------------- | ------------------ | ---------------------------------------------------------- | ---------------- |
94
- | `lib/mail-server-rate-limit.js` | 209, 261, 291 | `connectionTimes` / `authFailureTimes` / `rcptFailureTimes` | `[]` (array) |
95
- | `lib/middleware/rate-limit.js` | 130 | `buckets` | object-literal |
96
- | `lib/network-byte-quota.js` | 82 | `store` | `_newEntry()` |
97
- | `lib/crypto-field.js` | 592 | `_rateFailWindows` (in `_rateNoteFailure`) | `[]` (array) |
98
-
99
- ### Pubsub / websocket-channels
100
-
101
- | File | Lines | Map | Factory | Race-window callout |
102
- | --------------------------------- | ----- | ----------------- | ------------- | ------------------- |
103
- | `lib/pubsub.js` | 350 | `exactSubs` | `new Set()` | local-only; sub-record under same-process closure — race-window benign |
104
- | `lib/websocket-channels.js` | 193 | `channelToConns` | `new Set()` | **cluster-shared via `ps.subscribe` — race-window non-trivial.** Between `has(channel) === false` and `set(channel, new Set())`, the pubsub backend opens a remote subscription; a concurrent `subscribe(otherConn, sameChannel)` can see `channel`-as-known and skip the pubsub `subscribe()` even though the new Set is empty. `getOrInsertComputed` collapses the gap; the factory closure runs once, the pubsub subscribe call lifts inside it. |
105
-
106
- ### Edge cases — flagged structurally, do NOT migrate cleanly
107
-
108
- | File | Lines | Why it doesn't migrate |
109
- | --------------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
110
- | `lib/mail-greylist.js` | 405 | `memoryStore.put` is *put-or-replace-with-insertion-order-side-effect*. `data.set(key, value)` runs unconditionally (always overwrites the value); the `if (!data.has(key))` block manages an evict-oldest `insertionOrder` sidecar. `getOrInsertComputed` only runs the factory on miss — wrong semantics for an always-write. **Skip during sweep; keep inline.** |
111
- | `lib/dsr.js` | 875 | `memoryTicketStore.update` is a *presence assertion*: `if (!byId.has(id)) throw new DsrError(...)` then `byId.set(id, ...)` runs OUTSIDE the if-block as an UPDATE. False positive — detector window crosses the closing `}`. **Skip during sweep; no rewrite.** |
112
-
113
- ### Totals
114
-
115
- - **Migratable call sites:** ~18 across 12 files (variants A + B; metrics + rate-limit + otel each have multiple sites per file).
116
- - **Files allowlisted (migratable):** 13 (`cache.js`, `crypto-field.js`, `deprecate.js`, `i18n-messageformat.js`, `i18n.js`, `mail-server-rate-limit.js`, `metrics.js`, `middleware/rate-limit.js`, `network-byte-quota.js`, `observability-otlp-exporter.js`, `otel-export.js`, `pubsub.js`, `websocket-channels.js`).
117
- - **Files allowlisted (do-not-migrate edge cases):** 2 (`mail-greylist.js`, `dsr.js`).
118
-
119
- The user-supplied "~137 sites across 80+ files" figure was an estimate
120
- that included WeakMap / plain-object / `has`-as-allowlist-membership
121
- shapes; the actual `getOrInsertComputed`-migratable surface is the
122
- ~17 sites above.
123
-
124
- ## Floor-bump sweep plan
125
-
126
- When `engines.node` advances to `>=26` (eligible Oct 2026 per LTS
127
- calendar; do NOT bump earlier):
128
-
129
- 1. **One commit per domain group** (cache, observability, rate-limit,
130
- pubsub/websocket). Each commit:
131
- - Walks every call site in the matching allowlist entry.
132
- - Converts `var X = M.get(k); if (!X) { X = factory(); M.set(k, X); }`
133
- to `var X = M.getOrInsertComputed(k, function () { return factory(); });`.
134
- - Verifies the factory closure is pure (or its side-effects are
135
- intentional in the on-miss-only path). For `websocket-channels`
136
- specifically, the pubsub-subscribe side-effect MUST move into
137
- the factory — that's the race-window fix.
138
- - Removes the file from the detector's allowlist.
139
- - Runs the full release-gate sequence (codebase-patterns, smoke,
140
- wiki-e2e, container-smoke).
141
- 2. **Final commit** flips both detector entries' `reason` field from
142
- "documentation" framing to enforcement framing and removes any
143
- remaining edge-case entries (after auditing whether
144
- `mail-greylist.js` / `dsr.js` actually need a different rewrite or
145
- are genuinely no-ops).
146
- 3. **CHANGELOG entry** describes the perf win (single-lookup) and the
147
- `websocket-channels` race-window closure as operator-visible
148
- improvements. No internal narrative.
149
-
150
- ## How the detector behaves between now and the floor bump
151
-
152
- `test/layer-0-primitives/codebase-patterns.test.js` has two sibling
153
- entries in `KNOWN_ANTIPATTERNS`:
154
-
155
- - `map-get-or-insert-pre-node-26` — variant A, prefix `var X = M.get(k); if (!X) {`.
156
- - `map-has-then-set-pre-node-26` — variant B, prefix `if (!M.has(k)) {`.
157
-
158
- Both detectors run as part of `node test/layer-0-primitives/codebase-patterns.test.js`
159
- on every release. Existing call sites are allowlisted (the gate is
160
- green at v0.11.2). **New code introduced before the floor bump trips
161
- the detector** and gets a clear message pointing at this spec — at
162
- which point the operator either waits for the floor bump or adds the
163
- new file to BOTH the allowlist AND this spec's call-site table in
164
- the SAME patch (per the framework's audit-existing-code discipline,
165
- rule §7).