@blamejs/blamejs-shop 0.1.23 → 0.1.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/lib/admin.js +8 -15
- package/lib/asset-manifest.json +9 -0
- package/lib/storefront.js +21 -26
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/README.md +1 -1
- package/lib/vendor/blamejs/api-snapshot.json +55 -2
- package/lib/vendor/blamejs/lib/backup/manifest.js +1 -1
- package/lib/vendor/blamejs/lib/cloud-events.js +455 -0
- package/lib/vendor/blamejs/lib/csp.js +3 -2
- package/lib/vendor/blamejs/lib/jtd.js +4 -15
- package/lib/vendor/blamejs/lib/rfc3339.js +37 -0
- package/lib/vendor/blamejs/lib/safe-buffer.js +8 -0
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.63.json +27 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cloud-events.test.js +129 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/csp-builder.test.js +26 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.1.x
|
|
10
10
|
|
|
11
|
+
- v0.1.26 (2026-05-25) — **Fix the container build gate that rejected a manifest absent from the image.** The asset-integrity drift gate added last release checked both the container and edge copies of the manifest. The container image excludes the edge worker directory from its build context, so when the gate runs inside the image build it can't see the worker copy and read it as empty — failing the build. The gate now skips a manifest that isn't present in the current build context; its drift is still caught wherever the file exists (the full-tree CI run checks both). **Fixed:** *Asset-manifest drift gate no longer fails the container build* — `generate-asset-manifest.js --check` compared both manifest copies (`lib/` for the container, `worker/` for the edge) against a fresh rebuild. The container image's build context excludes the `worker/` tree, so inside the image the worker copy is absent and the gate read it as empty — reporting false drift and failing `node test/smoke.js`, which is a build gate, so the container image never built. The check now treats a manifest that isn't present in the current context as not-applicable and skips it (requiring at least one copy to be present), so the same drift detection still runs in CI and at the edge build where both files exist, without breaking the container image build.
|
|
12
|
+
|
|
13
|
+
- v0.1.25 (2026-05-25) — **Asset integrity and the cache-buster are correct in production now.** The renderers stamped a Subresource Integrity digest and a `?v=` cache-buster onto the theme stylesheet and island scripts by hashing the files at render time — which silently produced nothing in production, where the container image doesn't ship the theme files and the edge has no filesystem to read them. So the integrity attribute was absent live and edge-rendered pages pinned a `0.0.0` version that never changed between releases (a stylesheet update would render against a browser-cached copy). Both now come from a manifest built at release time and read directly by both runtimes. **Fixed:** *Subresource Integrity now applied on server-rendered pages* — The `integrity="sha384-…"` attribute on the default theme's stylesheet and island scripts was computed by reading the asset off disk during the render. That read fails in the deployed container (its image carries the code, not the `themes/` tree) and at the edge (no filesystem), so the attribute was silently dropped — the tamper-evidence it's meant to provide wasn't active. The digests are now computed once at release time into `lib/asset-manifest.json` (bundled for both the container and the edge worker) and read from there, so the browser actually verifies the served bytes against the pinned hash. · *Edge-rendered pages no longer pin a 0.0.0 cache-buster* — Pages rendered at the edge (home, product, search, the empty cart, blog) appended `?v=0.0.0` to the stylesheet URL because the worker had no release version to stamp — so the cache-buster never changed across deploys and a browser could keep serving an old stylesheet after a CSS update (markup against stale styling). The version now comes from the release manifest, so each release invalidates the cached asset as intended. The drift between the manifest and the on-disk assets is caught by a smoke gate (`generate-asset-manifest.js --check`).
|
|
14
|
+
|
|
11
15
|
- v0.1.23 (2026-05-25) — **The admin console renders styled again, and both surfaces now self-host their fonts.** The admin console shipped its design system as an inline <style> block, inline style="" attributes, and a cross-origin Google Fonts link — all three of which the strict `style-src 'self'` / `font-src 'self'` CSP that governs container-served routes refuses, so the console rendered as an unstyled page in production. Its styling now lives in an external stylesheet served from /assets (like the storefront's), and the typeface is self-hosted, so the console renders as designed under the same CSP that protects it. Both the storefront and the console now serve Inter from /assets rather than naming it and hoping the visitor has it installed. **Changed:** *Inter is self-hosted on both the storefront and the console* — The default theme named Inter / Inter Tight first in its font stack but shipped no font files, so the intended typography only rendered for visitors who happened to have Inter installed locally — everyone else fell back to a system sans-serif. The latin subset of Inter (400/500/600) and Inter Tight (600/700) now ships under `themes/default/assets/fonts` and is referenced via `@font-face`, so the typeface renders for every visitor and loads same-origin under `font-src 'self'` — no cross-origin font CDN on any page. The fonts are SIL OFL 1.1 (license alongside the files); `font-display:swap` paints text immediately in the system fallback and swaps when the file arrives. · *Admin console visual refresh* — With the stylesheet external it also got a pass: a proper centered sign-in card (replacing the bare form that was the first thing an operator saw), softer surfaces and shadows on cards and panels, a sticky console nav, and consistent spacing. The brand palette (ink + the storefront's accent) and every screen's structure are unchanged — this is presentation only. **Fixed:** *Admin console renders unstyled in production* — Every `/admin` screen built its design system three ways the strict CSP refuses: an inline `<style>` block, inline `style=""` attributes, and a `<link>` to fonts.googleapis.com. Container-served routes run under `style-src 'self'` / `font-src 'self'`, which drops all three — so the console arrived as unstyled HTML (a plain page) once deployed, even though it looked correct in isolation. The styling now ships as `/assets/themes/default/css/admin.css`, an external file `'self'` allows, linked with a `sha384` Subresource Integrity digest so the browser rejects a tampered or stale object; the inline attributes are gone, folded into that stylesheet. A render-shell test asserts every admin screen links the external stylesheet, carries no inline styles, and pulls no third-party font host, so the regression can't return. Operators redeploy to pick this up — `npm run deploy` uploads the new stylesheet (and the fonts below) to R2 as part of the asset sync.
|
|
12
16
|
|
|
13
17
|
- v0.1.22 (2026-05-25) — **Fix the container boot crash-loop, plus gift cards, subscription self-management, and a customers roster.** The deployable container could crash-loop on startup, taking every write route (add-to-cart, checkout, account, admin) down while edge-rendered reads kept working — this release fixes that boot path. It also adds gift cards (issue / balance / redeem-at-checkout with an operator ledger), customer subscription self-management at `/account/subscriptions`, and a read-only customers roster in the admin console. **Added:** *Gift cards — redeem at checkout, balance check, operator ledger* — A gift-card code can be applied at checkout as a credit against the order total: the amount due drops by the available balance (never below zero), the order still records the full total it owed, and the debit is recorded once per order behind an atomic `balance >= amount` guard so concurrent spends can't overdraw — a card that fully covers the order is marked paid with no card charge. Customers check a balance at `GET /gift-cards` (which is not a code-existence oracle — unknown / malformed / expired all return the same generic result). Operators work `/admin/gift-cards`: list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a card (the bearer code shown once, right after creation), and open a card to read its full credit / debit / expire ledger. Codes are stored as a `namespaceHash` digest plus a 4-char hint — never in the clear. · *Customer subscription self-management* — Signed-in customers view and cancel their own subscriptions at `/account/subscriptions` — each row shows the plan summary, status, and renew / end date, with a Cancel control on cancelable subscriptions. Every action is ownership-checked: a subscription that doesn't belong to the signed-in customer is a 404, never another account's row. Cancellation runs through the payment provider, so the cancel control mounts only when the payment handle is wired; without it the list renders read-only. Customer-facing subscription creation — a subscription-checkout flow — remains a separate follow-up; this surface is for managing existing subscriptions. · *Customers admin roster* — `/admin/customers` renders a read-only account roster (newest first): display name, a short id, the join date, the sign-in method (passkey count plus any linked OAuth providers), and the order count, with cursor pagination in the browser and a JSON list (with `next_cursor`) for a bearer-token client. Account creation and changes stay with the storefront's passkey / OIDC ceremonies. Order counts and sign-in methods are resolved with bounded aggregate queries over each page's ids — no per-row database trips. Raw email is never stored, so it is never shown. **Changed:** *Console nav gains Customers and Gift cards* — The signed-in admin nav now includes Customers and Gift cards alongside the existing screens, each shown when its primitive is wired. All new endpoints content-negotiate like the rest of the console: a bearer-token client gets the JSON API, a signed-in browser gets HTML; a request without the bearer token returns the sign-in form on a GET and redirects on a write. The customers roster, the subscription cancel, and the gift-card balance page all degrade gracefully when an optional dependency (payment, or the primitive itself) isn't configured. · *Deploy now syncs theme assets to R2* — `npm run deploy` chains `wrangler deploy` with a new `scripts/sync-r2-assets.js` that uploads the repo's theme stylesheets to the R2 bucket the Worker serves `/assets/*` from. `wrangler deploy` alone ships the Worker + container but never touches R2, so a CSS change would silently drift from the deployed HTML — new markup against a stale stylesheet. Run `npm run sync-assets` to push the stylesheets without a full redeploy. · *Theme stylesheet and island scripts served with Subresource Integrity* — The default theme's stylesheet `<link>` and the externalized passkey island `<script>` tags now carry a `sha384` `integrity` attribute computed from the on-disk asset, so a browser refuses any `/assets/*` response whose bytes don't match — closing the window where a tampered or stale CDN object could execute against the page. The digests are computed once at boot and cached; a missing or unreadable asset degrades to no integrity attribute rather than blocking the render. **Fixed:** *Passkey sign-in / registration no longer blocked by CSP* — The WebAuthn passkey ceremonies on `/account/login` and `/account/register` were inline `<script>` blocks, which the storefront's strict `script-src 'self'` CSP refuses to execute — so on the deploy the sign-in and registration buttons did nothing (accounts are passkey-only, so this blocked all customer auth). The ceremonies now ship as external assets (`/assets/themes/default/js/passkey-login.js` + `passkey-register.js`) that `'self'` allows, served from R2 and uploaded by the deploy's asset sync. Same effect, CSP-clean — and a clearer split between the server-rendered form and its opt-in enhancement. · *Container no longer crash-loops on startup* — The container boot had three ways to fail before it could serve a request, each of which left edge-rendered reads working while every container-served route (add-to-cart, cart update / remove, checkout, `/account`, `/admin`) was unreachable. (1) The secure-default boot unlocks a wrapped vault **and** a wrapped audit-signing keypair, which the framework reads from `BLAMEJS_VAULT_PASSPHRASE` / `BLAMEJS_AUDIT_SIGNING_PASSPHRASE`, but the deploy contract documents a single `VAULT_PASSPHRASE` the framework never read — so the container had no passphrase source, no TTY to prompt, and exited. The entry point now bridges the documented secret onto the vault passphrase and derives a domain-separated audit-signing passphrase from it. (2) Encrypted-at-rest requires a tmpfs; the image now points it at `/dev/shm` rather than failing to find one. (3) The Docker liveness probe was `wget`, which bot-guard blocks as automation (403), so a healthy container was marked unhealthy and stopped — the probe is now a browser-shaped Node request (`scripts/healthcheck.js`) that passes bot-guard like real traffic, without exempting the health path from the security middleware. A boot gate (`test/layer-2-integration/server-boot.test.js`) spawns the real entry point and asserts it listens, answers the probe, and still blocks header-less clients. Operators must redeploy the container to pick this up.
|
package/lib/admin.js
CHANGED
|
@@ -44,22 +44,15 @@ var AUDIT_NAMESPACE = "shop_admin";
|
|
|
44
44
|
// allows this file; the deploy's R2 asset sync uploads it. The `?v=` is
|
|
45
45
|
// the per-release cache-buster; the sha384 Subresource Integrity digest
|
|
46
46
|
// (b.crypto.sri, W3C SRI 1.0) makes the browser reject a tampered or
|
|
47
|
-
// stale object. Same-origin, so no `crossorigin` is needed.
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
var
|
|
47
|
+
// stale object. Same-origin, so no `crossorigin` is needed. The digest +
|
|
48
|
+
// version come from the build-time manifest (lib/asset-manifest.json)
|
|
49
|
+
// rather than hashing the file at render time, so they're present in the
|
|
50
|
+
// container image too — which doesn't ship the theme asset files. A key
|
|
51
|
+
// absent from the manifest yields no attribute.
|
|
52
|
+
var _assetManifest = require("./asset-manifest.json");
|
|
53
|
+
var _ASSET_VERSION = _assetManifest.version;
|
|
53
54
|
function _assetSri(relUnderThemeAssets) {
|
|
54
|
-
|
|
55
|
-
return _SRI_CACHE[relUnderThemeAssets];
|
|
56
|
-
}
|
|
57
|
-
var sri = null;
|
|
58
|
-
try {
|
|
59
|
-
sri = b.crypto.sri(_assetFs.readFileSync(__dirname + "/../themes/default/assets/" + relUnderThemeAssets), { algorithm: "sha384" });
|
|
60
|
-
} catch (_e) { sri = null; }
|
|
61
|
-
_SRI_CACHE[relUnderThemeAssets] = sri;
|
|
62
|
-
return sri;
|
|
55
|
+
return _assetManifest.assets[relUnderThemeAssets] || null;
|
|
63
56
|
}
|
|
64
57
|
function _adminStylesheetLink() {
|
|
65
58
|
var sri = _assetSri("css/admin.css");
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "0.1.26",
|
|
3
|
+
"assets": {
|
|
4
|
+
"css/admin.css": "sha384-rvjL1QySBgNJGEUDusbbMCkL3fsjmzcwUvTp9pljo/0Nn/lErxq2AbY8EYvDF9Ch",
|
|
5
|
+
"css/main.css": "sha384-qBY97FWZDGOploZRppwrDi0A/+R4R3iCFtyS1dfrAlqHNEPluPmY0R3HB9cJntyV",
|
|
6
|
+
"js/passkey-login.js": "sha384-65i1wVHIkiS2e0YiOV6oUOF9+tNR7s6QRvpnWaEne43P+B8UvKLTpDgp4MSKNGqX",
|
|
7
|
+
"js/passkey-register.js": "sha384-RaSkJbTpc6ClfPy5QyaY96doIqBqa9CLeIB1CzWnL05pSSJEA9IrIK0S7V7igRxY"
|
|
8
|
+
}
|
|
9
|
+
}
|
package/lib/storefront.js
CHANGED
|
@@ -203,13 +203,21 @@ var LAYOUT =
|
|
|
203
203
|
// each render call to override; absent that, every storefront page
|
|
204
204
|
// references the shipped default theme — so a fresh install renders
|
|
205
205
|
// styled out of the box without any wiring.
|
|
206
|
+
// Asset integrity + version manifest — sha384 digests + the release
|
|
207
|
+
// version, baked at build time (scripts/generate-asset-manifest.js) and
|
|
208
|
+
// committed. Read here instead of hashing the files at render time so the
|
|
209
|
+
// integrity attribute and the cache-buster are present in every runtime,
|
|
210
|
+
// including the container image and the edge Worker, neither of which
|
|
211
|
+
// ships the theme asset files to hash live. See lib/asset-manifest.json.
|
|
212
|
+
var _assetManifest = require("./asset-manifest.json");
|
|
213
|
+
|
|
206
214
|
// The default theme stylesheet ships from R2 at this path. The
|
|
207
215
|
// `?v=` query is a build-time cache-buster — operator uploads to R2
|
|
208
216
|
// rewrite the bytes at the same path, and without the version
|
|
209
217
|
// param browsers happily serve the previous cached CSS for the
|
|
210
|
-
// five-minute default TTL.
|
|
211
|
-
//
|
|
212
|
-
var DEFAULT_THEME_CSS_URL = "/assets/themes/default/css/main.css?v=" +
|
|
218
|
+
// five-minute default TTL. The manifest version bumps each release,
|
|
219
|
+
// forcing every active session to re-fetch.
|
|
220
|
+
var DEFAULT_THEME_CSS_URL = "/assets/themes/default/css/main.css?v=" + _assetManifest.version;
|
|
213
221
|
|
|
214
222
|
// Footer copyright year — resolved once at module load. It's a near-static
|
|
215
223
|
// value (changes once a year); a `new Date()` allocation on every page
|
|
@@ -222,30 +230,17 @@ var _COPYRIGHT_YEAR = String(new Date().getUTCFullYear());
|
|
|
222
230
|
// 'self'` CSP blocks inline <script>, so an inline island silently fails
|
|
223
231
|
// in production. `'self'` allows these /assets/ files; the R2 asset sync
|
|
224
232
|
// (npm run sync-assets) uploads them alongside the stylesheets.
|
|
225
|
-
var _ASSET_JS_VERSION =
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
// Same-origin, so no `crossorigin` is required for the check to run.
|
|
235
|
-
var _SRI_CACHE = {};
|
|
233
|
+
var _ASSET_JS_VERSION = _assetManifest.version;
|
|
234
|
+
|
|
235
|
+
// Subresource Integrity for the static assets — the browser refuses to
|
|
236
|
+
// run/apply a resource whose served bytes don't match (defense against an
|
|
237
|
+
// R2/edge compromise or on-path injection). The sha384 digest (W3C SRI
|
|
238
|
+
// 1.0) is read from the build-time manifest keyed by the path under the
|
|
239
|
+
// default theme's asset root; an absent key (a custom operator theme,
|
|
240
|
+
// whose bytes aren't ours to hash) yields no attribute. Same-origin, so
|
|
241
|
+
// no `crossorigin` is required for the check to run.
|
|
236
242
|
function _assetSri(relUnderThemeAssets) {
|
|
237
|
-
|
|
238
|
-
return _SRI_CACHE[relUnderThemeAssets];
|
|
239
|
-
}
|
|
240
|
-
var sri = null;
|
|
241
|
-
try {
|
|
242
|
-
// Forward slashes resolve cross-platform for fs reads; avoids a
|
|
243
|
-
// node:path require for one join.
|
|
244
|
-
var p = __dirname + "/../themes/default/assets/" + relUnderThemeAssets;
|
|
245
|
-
sri = b.crypto.sri(_assetFs.readFileSync(p), { algorithm: "sha384" });
|
|
246
|
-
} catch (_e) { sri = null; } // asset absent (e.g. unit-test cwd) → omit integrity, don't crash render
|
|
247
|
-
_SRI_CACHE[relUnderThemeAssets] = sri;
|
|
248
|
-
return sri;
|
|
243
|
+
return _assetManifest.assets[relUnderThemeAssets] || null;
|
|
249
244
|
}
|
|
250
245
|
function _islandScript(name) {
|
|
251
246
|
var sri = _assetSri("js/" + name);
|
package/lib/vendor/MANIFEST.json
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
"_about": "blamejs.shop vendors a single framework — blamejs — which itself bundles every server-side crypto/identity dependency. The transitive packages blamejs ships are surfaced in its own MANIFEST.json at lib/vendor/blamejs/lib/vendor/MANIFEST.json — Trivy / Grype rely on that nested data for CVE attribution.",
|
|
4
4
|
"packages": {
|
|
5
5
|
"blamejs": {
|
|
6
|
-
"version": "0.12.
|
|
7
|
-
"tag": "v0.12.
|
|
6
|
+
"version": "0.12.63",
|
|
7
|
+
"tag": "v0.12.63",
|
|
8
8
|
"license": "Apache-2.0",
|
|
9
9
|
"author": "blamejs contributors",
|
|
10
10
|
"source": "https://github.com/blamejs/blamejs",
|
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.12.x
|
|
10
10
|
|
|
11
|
+
- v0.12.63 (2026-05-25) — **`b.cloudEvents` gains the JSON event format, batch, and the HTTP binding.** b.cloudEvents grows beyond wrap / parse into a full CloudEvents 1.0.2 surface. b.cloudEvents.validate / isValid check an envelope against the spec without throwing (the non-throwing companion to parse). toJSON / fromJSON serialize and parse the JSON event format, and toJSONBatch / fromJSONBatch handle the JSON batch format; untrusted bodies parse through the framework's bounded, prototype-pollution-safe JSON reader. The new http.* binding speaks both content modes the spec defines — binary mode spreads context attributes across percent-encoded ce-* headers with the data in the body, structured mode carries the whole event as application/cloudevents+json — plus the batch mode, and http.decode auto-detects the incoming mode from Content-Type exactly as a conformant receiver does. Verified against the spec's normative example events. **Added:** *`b.cloudEvents` JSON event format, batch, and HTTP binding* — `validate` / `isValid` report spec violations without throwing (the non-throwing companion to `parse`). `toJSON` / `fromJSON` and `toJSONBatch` / `fromJSONBatch` serialize and parse the JSON event and batch formats over the existing envelope shape. `http.encodeBinary` / `http.encodeStructured` / `http.encodeBatch` render the three HTTP content modes — binary spreads attributes across percent-encoded `ce-*` headers, structured and batched carry the event(s) as `application/cloudevents+json` / `application/cloudevents-batch+json` — and `http.decode` parses a request back into an envelope (or array) by auto-detecting the mode from `Content-Type`. `b.jtd` or `b.safeSchema` still validate the event's `data` payload. **Fixed:** *`b.csp.build` accepts `fenced-frame-src` and `webrtc`* — The CSP3 `fenced-frame-src` directive — which the default security-headers policy emits to block `<fencedframe>` embeds — was missing from the builder's recognized-directive set, so the default policy could not round-trip through `b.csp.build` (it threw `csp/unknown-directive`). Both `fenced-frame-src` and the CSP3 `webrtc` directive are now recognized.
|
|
12
|
+
|
|
11
13
|
- v0.12.62 (2026-05-26) — **`b.jtd` — JSON Type Definition validation (RFC 8927).** Validate JSON against a JSON Type Definition schema (RFC 8927) — a small, portable, cross-implementation schema language, the interop-friendly companion to the framework's fluent b.safeSchema builder. b.jtd.validate(schema, instance) returns an array of { instancePath, schemaPath } errors (empty = valid); b.jtd.isValid is the boolean form. All eight schema forms are supported — empty, type, enum, elements, properties (with optional / additional properties and nullable), values, discriminator (with mapping), and ref (with definitions) — including the integer-range and RFC 3339 timestamp types. A malformed schema is rejected at compile time with jtd/bad-schema rather than silently mis-validating. Verified against the official json-typedef-spec suites: all 316 validation cases and all 49 invalid-schema cases. **Added:** *`b.jtd.validate(schema, instance)` / `b.jtd.isValid(schema, instance)`* — `validate` returns the RFC 8927 error list — each `{ instancePath, schemaPath }` naming the offending value and the broken schema rule — and `isValid` is the boolean convenience form. Supports every JTD form and type: the numeric types enforce their exact ranges (int8 … uint32, float32 / float64), `timestamp` requires an RFC 3339 date-time, `properties` honours `optionalProperties` / `additionalProperties` / `nullable`, and `discriminator` selects a `mapping` schema by a tag property. The schema is checked for well-formedness before validation, so unknown keywords, multiple forms, bad refs, or a discriminator over a non-properties mapping all throw `jtd/bad-schema`. Use JTD for schemas you share across implementations or generate code from; use `b.safeSchema` for in-process fluent validation.
|
|
12
14
|
|
|
13
15
|
- v0.12.61 (2026-05-26) — **`b.jsonPath` — JSONPath query (RFC 9535).** A full RFC 9535 JSONPath query evaluator, complementing the framework's JSONPath guards (which screen path strings). b.jsonPath.query(doc, path) compiles a path and returns the matched node values; b.jsonPath.paths returns their normalized locations. The complete surface is implemented: name / wildcard / index / slice selectors, descendant segments (..), filter selectors (?) with comparison and logical operators, relative (@) and absolute ($) embedded queries, and the five standard functions length / count / match / search / value — with the spec's well-typedness rules enforced at compile time so a malformed or ill-typed query is rejected rather than silently mis-evaluated. Descendant walks are node-capped to bound work on hostile input. Verified against all 703 cases of the official jsonpath-compliance-test-suite. **Added:** *`b.jsonPath.query(doc, path)` / `b.jsonPath.paths(doc, path)`* — `query` returns the array of node values selected by an RFC 9535 JSONPath; `paths` returns the normalized-path string of each match (e.g. `$['a'][1]['p']`). Supports every selector (name, wildcard `*`, index incl. negative, slice `start:end:step` incl. negative step, comma-separated selections), child and descendant (`..`) segments, and filter expressions with `==` / `!=` / `<` / `<=` / `>` / `>=`, `&&` / `||` / `!`, existence tests, and the standard functions. The well-typedness rules are checked when the path is compiled — a non-singular query used as a comparison operand, an ill-typed function argument, or a value-typed function used as a test all throw `json-path/invalid`. Pairs with `b.guardJsonPath`, which screens operator-supplied path strings before they reach the evaluator.
|
|
@@ -158,7 +158,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
158
158
|
- **WebSockets (server)** — channel/room fan-out across cluster replicas; RFC 6455 §5.5 control-frame size + FIN enforcement on inbound (defends 1 MiB-PING-as-PONG amplification) (`b.websocket`, `b.websocketChannels`)
|
|
159
159
|
- **WebSockets (client)** — `b.wsClient` with PQC-TLS handshake, permessage-deflate negotiation with decompression-bomb cap, fatal UTF-8 validation, permanent-error classifier (skips reconnect on 4xx / accept mismatch / bad-subprotocol), exponential-backoff with full jitter
|
|
160
160
|
- **Pub/sub + events** — distributed pub/sub with cluster-table / Redis PUB/SUB / custom backends (`b.pubsub`); framework-emitted signal bus for breach / integrity events (`b.events`)
|
|
161
|
-
- **CloudEvents + SSE** — CloudEvents 1.0
|
|
161
|
+
- **CloudEvents + SSE** — CloudEvents 1.0.2 for AWS EventBridge / Knative / Azure Event Grid / Google Eventarc / CNCF: `wrap` / `parse` envelopes, non-throwing `validate` / `isValid`, the JSON event + batch formats (`toJSON` / `fromJSON` / `toJSONBatch` / `fromJSONBatch`), and the HTTP binding in both binary and structured content modes with auto-detecting `http.decode` (`b.cloudEvents`); Server-Sent Events with newline-injection refusal in `event:` / `id:` / `data:` / `Last-Event-ID` (CVE-2026-33128 / 29085 / 44217 class) (`b.sse`, `b.middleware.sse`)
|
|
162
162
|
- **Mail (outbound)** — multipart + attachments + DKIM + calendar invites; bounce intake (`b.mail`, `b.mailBounce`)
|
|
163
163
|
- **Mail (outbound delivery)** — turnkey MX-lookup → MTA-STS-fetch → DANE-TLSA → REQUIRETLS handshake → SMTP wire layer → RFC 3464 DSN-on-permanent-failure → deferred-retry scheduling, all wired once (`b.mail.send.deliver`)
|
|
164
164
|
- **Mail (inbound auth)** — SPF / DMARC / ARC verify + ARC chain signing for relays (`b.mail.spf`, `b.mail.dmarc`, `b.mail.arc`)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 1,
|
|
3
|
-
"frameworkVersion": "0.12.
|
|
4
|
-
"createdAt": "2026-05-
|
|
3
|
+
"frameworkVersion": "0.12.63",
|
|
4
|
+
"createdAt": "2026-05-26T04:30:08.809Z",
|
|
5
5
|
"exports": {
|
|
6
6
|
"a2a": {
|
|
7
7
|
"type": "object",
|
|
@@ -6233,10 +6233,59 @@
|
|
|
6233
6233
|
"type": "primitive",
|
|
6234
6234
|
"valueType": "string"
|
|
6235
6235
|
},
|
|
6236
|
+
"fromJSON": {
|
|
6237
|
+
"type": "function",
|
|
6238
|
+
"arity": 2
|
|
6239
|
+
},
|
|
6240
|
+
"fromJSONBatch": {
|
|
6241
|
+
"type": "function",
|
|
6242
|
+
"arity": 2
|
|
6243
|
+
},
|
|
6244
|
+
"http": {
|
|
6245
|
+
"type": "object",
|
|
6246
|
+
"members": {
|
|
6247
|
+
"decode": {
|
|
6248
|
+
"type": "function",
|
|
6249
|
+
"arity": 3
|
|
6250
|
+
},
|
|
6251
|
+
"decodeBinary": {
|
|
6252
|
+
"type": "function",
|
|
6253
|
+
"arity": 3
|
|
6254
|
+
},
|
|
6255
|
+
"encodeBatch": {
|
|
6256
|
+
"type": "function",
|
|
6257
|
+
"arity": 1
|
|
6258
|
+
},
|
|
6259
|
+
"encodeBinary": {
|
|
6260
|
+
"type": "function",
|
|
6261
|
+
"arity": 1
|
|
6262
|
+
},
|
|
6263
|
+
"encodeStructured": {
|
|
6264
|
+
"type": "function",
|
|
6265
|
+
"arity": 1
|
|
6266
|
+
}
|
|
6267
|
+
}
|
|
6268
|
+
},
|
|
6269
|
+
"isValid": {
|
|
6270
|
+
"type": "function",
|
|
6271
|
+
"arity": 1
|
|
6272
|
+
},
|
|
6236
6273
|
"parse": {
|
|
6237
6274
|
"type": "function",
|
|
6238
6275
|
"arity": 1
|
|
6239
6276
|
},
|
|
6277
|
+
"toJSON": {
|
|
6278
|
+
"type": "function",
|
|
6279
|
+
"arity": 2
|
|
6280
|
+
},
|
|
6281
|
+
"toJSONBatch": {
|
|
6282
|
+
"type": "function",
|
|
6283
|
+
"arity": 2
|
|
6284
|
+
},
|
|
6285
|
+
"validate": {
|
|
6286
|
+
"type": "function",
|
|
6287
|
+
"arity": 1
|
|
6288
|
+
},
|
|
6240
6289
|
"wrap": {
|
|
6241
6290
|
"type": "function",
|
|
6242
6291
|
"arity": 1
|
|
@@ -46100,6 +46149,10 @@
|
|
|
46100
46149
|
"type": "instance",
|
|
46101
46150
|
"ctorName": "RegExp"
|
|
46102
46151
|
},
|
|
46152
|
+
"BASE64_RE": {
|
|
46153
|
+
"type": "instance",
|
|
46154
|
+
"ctorName": "RegExp"
|
|
46155
|
+
},
|
|
46103
46156
|
"CRLF_RE": {
|
|
46104
46157
|
"type": "instance",
|
|
46105
46158
|
"ctorName": "RegExp"
|
|
@@ -95,7 +95,7 @@ var VALID_KINDS = { "raw": 1, "vault-sealed": 1, "plaintext": 1 };
|
|
|
95
95
|
// in the manifest, so the hex string is 128 chars long.
|
|
96
96
|
var SHA3_512_HEX_LENGTH = 128;
|
|
97
97
|
var HEX_RE = safeBuffer.HEX_RE;
|
|
98
|
-
var BASE64_RE =
|
|
98
|
+
var BASE64_RE = safeBuffer.BASE64_RE;
|
|
99
99
|
|
|
100
100
|
function _isHex(s, evenLength) {
|
|
101
101
|
if (typeof s !== "string" || s.length === 0) return false;
|
|
@@ -36,11 +36,16 @@
|
|
|
36
36
|
|
|
37
37
|
var nodeCrypto = require("node:crypto");
|
|
38
38
|
var validateOpts = require("./validate-opts");
|
|
39
|
+
var rfc3339 = require("./rfc3339");
|
|
40
|
+
var safeJson = require("./safe-json");
|
|
41
|
+
var safeBuffer = require("./safe-buffer");
|
|
42
|
+
var C = require("./constants");
|
|
39
43
|
var { defineClass } = require("./framework-error");
|
|
40
44
|
|
|
41
45
|
var CloudEventsError = defineClass("CloudEventsError", { alwaysPermanent: true });
|
|
42
46
|
|
|
43
47
|
var SPECVERSION = "1.0";
|
|
48
|
+
var DEFAULT_MAX_BYTES = C.BYTES.mib(1); // fromJSON / http.decode input cap
|
|
44
49
|
|
|
45
50
|
// CloudEvents §3.1 — required string attributes.
|
|
46
51
|
var REQUIRED_ATTRS = ["id", "source", "specversion", "type"];
|
|
@@ -268,9 +273,459 @@ function parse(envelope) {
|
|
|
268
273
|
};
|
|
269
274
|
}
|
|
270
275
|
|
|
276
|
+
// ---- validate / isValid (non-throwing spec check) ----
|
|
277
|
+
|
|
278
|
+
var INT_MIN = -2147483648; // allow:raw-byte-literal — CloudEvents Integer type range
|
|
279
|
+
var INT_MAX = 2147483647; // allow:raw-byte-literal — CloudEvents Integer type range
|
|
280
|
+
// JSON-formatted media type essence (after the parameters are stripped):
|
|
281
|
+
// type/json or type/anything+json. Each run is bounded by the single "/"
|
|
282
|
+
// separator so the match is linear (no overlapping quantifiers → no
|
|
283
|
+
// polynomial backtracking on hostile media-type strings).
|
|
284
|
+
var JSON_MEDIA_RE = /^[^/]+\/(?:[^/]+\+)?json$/i;
|
|
285
|
+
// Extension name MUST be lowercase ASCII alnum (the §3.1 ≤20 length is a
|
|
286
|
+
// SHOULD, so validate enforces only the MUST; wrap is stricter on emit).
|
|
287
|
+
var VALIDATE_EXT_NAME_RE = /^[a-z0-9]+$/;
|
|
288
|
+
|
|
289
|
+
function _isPlainObject(v) { return v !== null && typeof v === "object" && !Array.isArray(v) && !Buffer.isBuffer(v); }
|
|
290
|
+
function _isNonEmptyString(v) { return typeof v === "string" && v.length > 0; }
|
|
291
|
+
function _isCanonicalBase64(s) { return typeof s === "string" && s.length % 4 === 0 && safeBuffer.BASE64_RE.test(s); }
|
|
292
|
+
function _isJsonMedia(ct) {
|
|
293
|
+
if (ct == null) return true; // absent datacontenttype defaults to application/json
|
|
294
|
+
var essence = String(ct).split(";")[0].trim(); // drop media-type parameters
|
|
295
|
+
return JSON_MEDIA_RE.test(essence); // allow:regex-no-length-cap — slash-bounded classes, linear match
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function _extIssue(name, v) {
|
|
299
|
+
if (v === null) return null;
|
|
300
|
+
if (typeof v === "string" || typeof v === "boolean") return null;
|
|
301
|
+
if (typeof v === "number") {
|
|
302
|
+
if (!isFinite(v) || Math.floor(v) !== v) return "extension '" + name + "' must be an integer (CloudEvents has no float type)";
|
|
303
|
+
if (v < INT_MIN || v > INT_MAX) return "extension '" + name + "' integer out of 32-bit range";
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
return "extension '" + name + "' must be a string, integer, or boolean";
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* @primitive b.cloudEvents.validate
|
|
311
|
+
* @signature b.cloudEvents.validate(event)
|
|
312
|
+
* @since 0.12.63
|
|
313
|
+
* @status stable
|
|
314
|
+
* @related b.cloudEvents.parse, b.cloudEvents.fromJSON
|
|
315
|
+
*
|
|
316
|
+
* Check an in-memory CloudEvents v1.0 envelope against the §3.1 spec and
|
|
317
|
+
* return an array of <code>{ attribute, message }</code> issues — an empty
|
|
318
|
+
* array means the event is conformant. Unlike <code>parse</code> (which
|
|
319
|
+
* throws and decodes), this never throws, so it suits inspecting events of
|
|
320
|
+
* unknown provenance before deciding what to do with them.
|
|
321
|
+
*
|
|
322
|
+
* @example
|
|
323
|
+
* b.cloudEvents.validate({ specversion: "1.0", id: "1",
|
|
324
|
+
* source: "/x", type: "com.example.t" });
|
|
325
|
+
* // → []
|
|
326
|
+
*/
|
|
327
|
+
function validate(event) {
|
|
328
|
+
var issues = [];
|
|
329
|
+
function bad(attribute, message) { issues.push({ attribute: attribute, message: message }); }
|
|
330
|
+
if (!_isPlainObject(event)) { bad("", "event must be an object"); return issues; }
|
|
331
|
+
|
|
332
|
+
if (event.specversion !== SPECVERSION) bad("specversion", "specversion must be the string \"" + SPECVERSION + "\"");
|
|
333
|
+
if (!_isNonEmptyString(event.id)) bad("id", "id must be a non-empty string");
|
|
334
|
+
if (!_isNonEmptyString(event.source)) bad("source", "source must be a non-empty URI-reference string");
|
|
335
|
+
if (!_isNonEmptyString(event.type)) bad("type", "type must be a non-empty string");
|
|
336
|
+
|
|
337
|
+
if (event.datacontenttype != null && !_isNonEmptyString(event.datacontenttype)) bad("datacontenttype", "datacontenttype, if present, must be a non-empty string");
|
|
338
|
+
if (event.dataschema != null && !_isNonEmptyString(event.dataschema)) bad("dataschema", "dataschema, if present, must be a non-empty URI string");
|
|
339
|
+
if (event.subject != null && !_isNonEmptyString(event.subject)) bad("subject", "subject, if present, must be a non-empty string");
|
|
340
|
+
if (event.time != null && !rfc3339.isValidDateTime(event.time)) bad("time", "time, if present, must be an RFC 3339 date-time");
|
|
341
|
+
|
|
342
|
+
if (Object.prototype.hasOwnProperty.call(event, "data") && Object.prototype.hasOwnProperty.call(event, "data_base64")) {
|
|
343
|
+
bad("data", "data and data_base64 are mutually exclusive (CloudEvents §3.1.1)");
|
|
344
|
+
}
|
|
345
|
+
if (event.data_base64 != null && !_isCanonicalBase64(event.data_base64)) bad("data_base64", "data_base64 must be canonical RFC 4648 base64");
|
|
346
|
+
|
|
347
|
+
Object.keys(event).forEach(function (k) {
|
|
348
|
+
if (REQUIRED_ATTRS.indexOf(k) !== -1 || KNOWN_OPTIONAL_ATTRS[k]) return;
|
|
349
|
+
if (!VALIDATE_EXT_NAME_RE.test(k)) { bad(k, "attribute name must match [a-z0-9]+ (lower-case letters and digits)"); return; } // allow:regex-no-length-cap — linear class, key bounded by maxBytes
|
|
350
|
+
var ei = _extIssue(k, event[k]);
|
|
351
|
+
if (ei) bad(k, ei);
|
|
352
|
+
});
|
|
353
|
+
return issues;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* @primitive b.cloudEvents.isValid
|
|
358
|
+
* @signature b.cloudEvents.isValid(event)
|
|
359
|
+
* @since 0.12.63
|
|
360
|
+
* @status stable
|
|
361
|
+
* @related b.cloudEvents.validate
|
|
362
|
+
*
|
|
363
|
+
* Boolean convenience form of <code>validate</code> — <code>true</code>
|
|
364
|
+
* when the event has zero conformance issues.
|
|
365
|
+
*
|
|
366
|
+
* @example
|
|
367
|
+
* b.cloudEvents.isValid(evt); // → true
|
|
368
|
+
*/
|
|
369
|
+
function isValid(event) { return validate(event).length === 0; }
|
|
370
|
+
|
|
371
|
+
function _assertValid(event, label) {
|
|
372
|
+
var issues = validate(event);
|
|
373
|
+
if (issues.length) {
|
|
374
|
+
throw new CloudEventsError("cloud-events/invalid",
|
|
375
|
+
label + ": " + issues.map(function (i) { return i.message; }).join("; "));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ---- JSON event format ----
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* @primitive b.cloudEvents.toJSON
|
|
383
|
+
* @signature b.cloudEvents.toJSON(event, opts?)
|
|
384
|
+
* @since 0.12.63
|
|
385
|
+
* @status stable
|
|
386
|
+
* @related b.cloudEvents.fromJSON, b.cloudEvents.toJSONBatch
|
|
387
|
+
*
|
|
388
|
+
* Serialize a CloudEvents envelope (as produced by <code>wrap</code>) to a
|
|
389
|
+
* JSON event-format string — media type
|
|
390
|
+
* <code>application/cloudevents+json</code>. The envelope is already in
|
|
391
|
+
* wire shape (JSON <code>data</code> inline, binary as a
|
|
392
|
+
* <code>data_base64</code> string), so this validates it and renders the
|
|
393
|
+
* JSON. Throws <code>CloudEventsError</code> on a non-conformant event.
|
|
394
|
+
*
|
|
395
|
+
* @opts
|
|
396
|
+
* space: number | string, // JSON.stringify indentation (default: none)
|
|
397
|
+
*
|
|
398
|
+
* @example
|
|
399
|
+
* var json = b.cloudEvents.toJSON(b.cloudEvents.wrap({ source: "/x", type: "t" }));
|
|
400
|
+
*/
|
|
401
|
+
function toJSON(event, opts) {
|
|
402
|
+
opts = opts || {};
|
|
403
|
+
_assertValid(event, "cloudEvents.toJSON");
|
|
404
|
+
return JSON.stringify(event, null, opts.space);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function _coerceInput(input, label, maxBytes) {
|
|
408
|
+
if (!Buffer.isBuffer(input) && typeof input !== "string") {
|
|
409
|
+
throw new CloudEventsError("cloud-events/bad-input", label + ": input must be a string or Buffer");
|
|
410
|
+
}
|
|
411
|
+
try {
|
|
412
|
+
return safeJson.parse(input, { maxBytes: maxBytes });
|
|
413
|
+
} catch (e) {
|
|
414
|
+
if (e && e.code === "json/too-large") throw new CloudEventsError("cloud-events/too-large", label + ": input exceeds maxBytes (" + maxBytes + ")");
|
|
415
|
+
throw new CloudEventsError("cloud-events/bad-json", label + ": body is not valid JSON");
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* @primitive b.cloudEvents.fromJSON
|
|
421
|
+
* @signature b.cloudEvents.fromJSON(input, opts?)
|
|
422
|
+
* @since 0.12.63
|
|
423
|
+
* @status stable
|
|
424
|
+
* @related b.cloudEvents.toJSON, b.cloudEvents.parse
|
|
425
|
+
*
|
|
426
|
+
* Parse a single JSON event-format document (string or Buffer) into a
|
|
427
|
+
* validated CloudEvents envelope. Untrusted bytes route through the
|
|
428
|
+
* framework's bounded, prototype-pollution-safe JSON reader. The envelope
|
|
429
|
+
* is returned in wire shape (binary stays a <code>data_base64</code>
|
|
430
|
+
* string); call <code>parse</code> instead when you want the
|
|
431
|
+
* Buffer-decoded record. Throws <code>CloudEventsError</code> on malformed
|
|
432
|
+
* or non-conformant input.
|
|
433
|
+
*
|
|
434
|
+
* @opts
|
|
435
|
+
* maxBytes: number, // default: 1 MiB — reject larger inputs
|
|
436
|
+
*
|
|
437
|
+
* @example
|
|
438
|
+
* var evt = b.cloudEvents.fromJSON(req.rawBody);
|
|
439
|
+
*/
|
|
440
|
+
function fromJSON(input, opts) {
|
|
441
|
+
opts = opts || {};
|
|
442
|
+
var maxBytes = opts.maxBytes == null ? DEFAULT_MAX_BYTES : opts.maxBytes;
|
|
443
|
+
var obj = _coerceInput(input, "cloudEvents.fromJSON", maxBytes);
|
|
444
|
+
if (!_isPlainObject(obj)) throw new CloudEventsError("cloud-events/invalid", "cloudEvents.fromJSON: event must be a JSON object");
|
|
445
|
+
_assertValid(obj, "cloudEvents.fromJSON");
|
|
446
|
+
return obj;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* @primitive b.cloudEvents.toJSONBatch
|
|
451
|
+
* @signature b.cloudEvents.toJSONBatch(events, opts?)
|
|
452
|
+
* @since 0.12.63
|
|
453
|
+
* @status stable
|
|
454
|
+
* @related b.cloudEvents.fromJSONBatch, b.cloudEvents.toJSON
|
|
455
|
+
*
|
|
456
|
+
* Serialize an array of CloudEvents envelopes to the JSON batch format
|
|
457
|
+
* (media type <code>application/cloudevents-batch+json</code>) — a JSON
|
|
458
|
+
* array of events, each rendered as by <code>toJSON</code>. An empty array
|
|
459
|
+
* yields <code>"[]"</code>.
|
|
460
|
+
*
|
|
461
|
+
* @opts
|
|
462
|
+
* space: number | string, // JSON.stringify indentation (default: none)
|
|
463
|
+
*
|
|
464
|
+
* @example
|
|
465
|
+
* var body = b.cloudEvents.toJSONBatch([evtA, evtB]);
|
|
466
|
+
*/
|
|
467
|
+
function toJSONBatch(events, opts) {
|
|
468
|
+
opts = opts || {};
|
|
469
|
+
if (!Array.isArray(events)) throw new CloudEventsError("cloud-events/bad-input", "cloudEvents.toJSONBatch: events must be an array");
|
|
470
|
+
events.forEach(function (event, idx) { _assertValid(event, "cloudEvents.toJSONBatch: event[" + idx + "]"); });
|
|
471
|
+
return JSON.stringify(events, null, opts.space);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* @primitive b.cloudEvents.fromJSONBatch
|
|
476
|
+
* @signature b.cloudEvents.fromJSONBatch(input, opts?)
|
|
477
|
+
* @since 0.12.63
|
|
478
|
+
* @status stable
|
|
479
|
+
* @related b.cloudEvents.toJSONBatch, b.cloudEvents.fromJSON
|
|
480
|
+
*
|
|
481
|
+
* Parse a JSON batch (a JSON array of events) from a string or Buffer into
|
|
482
|
+
* an array of validated CloudEvents envelopes. Each element is validated as
|
|
483
|
+
* by <code>fromJSON</code>; an empty array is valid. A non-array body,
|
|
484
|
+
* over-size input, or any non-conformant element throws
|
|
485
|
+
* <code>CloudEventsError</code>.
|
|
486
|
+
*
|
|
487
|
+
* @opts
|
|
488
|
+
* maxBytes: number, // default: 1 MiB — reject larger inputs
|
|
489
|
+
*
|
|
490
|
+
* @example
|
|
491
|
+
* var events = b.cloudEvents.fromJSONBatch(req.rawBody);
|
|
492
|
+
*/
|
|
493
|
+
function fromJSONBatch(input, opts) {
|
|
494
|
+
opts = opts || {};
|
|
495
|
+
var maxBytes = opts.maxBytes == null ? DEFAULT_MAX_BYTES : opts.maxBytes;
|
|
496
|
+
var arr = _coerceInput(input, "cloudEvents.fromJSONBatch", maxBytes);
|
|
497
|
+
if (!Array.isArray(arr)) throw new CloudEventsError("cloud-events/invalid", "cloudEvents.fromJSONBatch: body must be a JSON array");
|
|
498
|
+
arr.forEach(function (obj, idx) {
|
|
499
|
+
if (!_isPlainObject(obj)) throw new CloudEventsError("cloud-events/invalid", "cloudEvents.fromJSONBatch: event[" + idx + "] must be a JSON object");
|
|
500
|
+
_assertValid(obj, "cloudEvents.fromJSONBatch: event[" + idx + "]");
|
|
501
|
+
});
|
|
502
|
+
return arr;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// ---- HTTP protocol binding ----
|
|
506
|
+
|
|
507
|
+
var STRUCTURED_CT = "application/cloudevents+json; charset=UTF-8";
|
|
508
|
+
var BATCH_CT = "application/cloudevents-batch+json; charset=UTF-8";
|
|
509
|
+
|
|
510
|
+
// Percent-encode a header value: everything outside printable ASCII plus
|
|
511
|
+
// the spec-named space / double-quote / percent (HTTP binding §3.1). `s` is
|
|
512
|
+
// always the already-stringified value from _headerValueFor.
|
|
513
|
+
function _pctEncode(s) {
|
|
514
|
+
var bytes = Buffer.from(s, "utf8");
|
|
515
|
+
var out = "";
|
|
516
|
+
for (var i = 0; i < bytes.length; i += 1) {
|
|
517
|
+
var by = bytes[i];
|
|
518
|
+
if (by < 0x21 || by > 0x7E || by === 0x22 || by === 0x25) { // allow:raw-byte-literal — printable-ASCII bounds + double-quote and percent (HTTP binding header rule)
|
|
519
|
+
out += "%" + bytes[i].toString(16).toUpperCase().padStart(2, "0"); // allow:raw-byte-literal — 16 is the hex radix
|
|
520
|
+
} else {
|
|
521
|
+
out += String.fromCharCode(by);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return out;
|
|
525
|
+
}
|
|
526
|
+
function _pctDecode(s) {
|
|
527
|
+
var bytes = [];
|
|
528
|
+
var i = 0;
|
|
529
|
+
while (i < s.length) {
|
|
530
|
+
if (s[i] === "%" && /^[0-9A-Fa-f]{2}$/.test(s.slice(i + 1, i + 3))) {
|
|
531
|
+
bytes.push(parseInt(s.slice(i + 1, i + 3), 16)); // allow:raw-byte-literal — 16 is the hex radix
|
|
532
|
+
i += 3;
|
|
533
|
+
} else {
|
|
534
|
+
var ch = Buffer.from(s[i], "utf8");
|
|
535
|
+
for (var j = 0; j < ch.length; j += 1) bytes.push(ch[j]);
|
|
536
|
+
i += 1;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return Buffer.from(bytes).toString("utf8");
|
|
540
|
+
}
|
|
541
|
+
function _headerValueFor(v) { return typeof v === "boolean" ? (v ? "true" : "false") : String(v); }
|
|
542
|
+
function _lowerHeaders(headers) {
|
|
543
|
+
var out = {};
|
|
544
|
+
Object.keys(headers || {}).forEach(function (k) {
|
|
545
|
+
var v = headers[k];
|
|
546
|
+
out[k.toLowerCase()] = Array.isArray(v) ? v.join(",") : v;
|
|
547
|
+
});
|
|
548
|
+
return out;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* @primitive b.cloudEvents.http.encodeBinary
|
|
553
|
+
* @signature b.cloudEvents.http.encodeBinary(event)
|
|
554
|
+
* @since 0.12.63
|
|
555
|
+
* @status stable
|
|
556
|
+
* @related b.cloudEvents.http.decode, b.cloudEvents.http.encodeStructured
|
|
557
|
+
*
|
|
558
|
+
* Render a CloudEvents envelope in HTTP <em>binary</em> content mode: each
|
|
559
|
+
* context attribute (and extension) becomes a <code>ce-</code>-prefixed
|
|
560
|
+
* header with a percent-encoded value, <code>datacontenttype</code> maps to
|
|
561
|
+
* the plain <code>Content-Type</code> header (never
|
|
562
|
+
* <code>ce-datacontenttype</code>), and the payload becomes the body.
|
|
563
|
+
* Returns <code>{ headers, body }</code> where <code>body</code> is a
|
|
564
|
+
* Buffer (for <code>data_base64</code> payloads) or a string.
|
|
565
|
+
*
|
|
566
|
+
* @example
|
|
567
|
+
* var enc = b.cloudEvents.http.encodeBinary(evt);
|
|
568
|
+
* // enc.headers["ce-id"], enc.headers["content-type"], enc.body
|
|
569
|
+
*/
|
|
570
|
+
function encodeBinary(event) {
|
|
571
|
+
_assertValid(event, "cloudEvents.http.encodeBinary");
|
|
572
|
+
var headers = {};
|
|
573
|
+
Object.keys(event).forEach(function (k) {
|
|
574
|
+
if (k === "data" || k === "data_base64" || k === "datacontenttype") return;
|
|
575
|
+
if (event[k] === undefined || event[k] === null) return;
|
|
576
|
+
headers["ce-" + k] = _pctEncode(_headerValueFor(event[k]));
|
|
577
|
+
});
|
|
578
|
+
var body;
|
|
579
|
+
if (event.data_base64 != null) {
|
|
580
|
+
body = Buffer.from(event.data_base64, "base64");
|
|
581
|
+
} else if (Object.prototype.hasOwnProperty.call(event, "data")) {
|
|
582
|
+
// JSON-media payloads (including a bare string under application/json or
|
|
583
|
+
// an absent content type, which defaults to JSON) must be JSON-encoded
|
|
584
|
+
// so the body re-parses; a non-JSON media type carries the string as-is.
|
|
585
|
+
if (_isJsonMedia(event.datacontenttype)) body = JSON.stringify(event.data);
|
|
586
|
+
else body = typeof event.data === "string" ? event.data : JSON.stringify(event.data);
|
|
587
|
+
} else body = "";
|
|
588
|
+
if (event.datacontenttype != null) headers["content-type"] = event.datacontenttype;
|
|
589
|
+
else if (_isPlainObject(event.data) || Array.isArray(event.data)) headers["content-type"] = "application/json";
|
|
590
|
+
return { headers: headers, body: body };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* @primitive b.cloudEvents.http.encodeStructured
|
|
595
|
+
* @signature b.cloudEvents.http.encodeStructured(event)
|
|
596
|
+
* @since 0.12.63
|
|
597
|
+
* @status stable
|
|
598
|
+
* @related b.cloudEvents.http.decode, b.cloudEvents.http.encodeBinary
|
|
599
|
+
*
|
|
600
|
+
* Render a CloudEvents envelope in HTTP <em>structured</em> content mode:
|
|
601
|
+
* the whole event is serialized via the JSON event format into the body,
|
|
602
|
+
* with <code>Content-Type: application/cloudevents+json</code>. Returns
|
|
603
|
+
* <code>{ headers, body }</code>.
|
|
604
|
+
*
|
|
605
|
+
* @example
|
|
606
|
+
* var enc = b.cloudEvents.http.encodeStructured(evt);
|
|
607
|
+
*/
|
|
608
|
+
function encodeStructured(event) {
|
|
609
|
+
return { headers: { "content-type": STRUCTURED_CT }, body: toJSON(event) };
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* @primitive b.cloudEvents.http.encodeBatch
|
|
614
|
+
* @signature b.cloudEvents.http.encodeBatch(events)
|
|
615
|
+
* @since 0.12.63
|
|
616
|
+
* @status stable
|
|
617
|
+
* @related b.cloudEvents.http.decode, b.cloudEvents.toJSONBatch
|
|
618
|
+
*
|
|
619
|
+
* Render an array of CloudEvents in HTTP <em>batched</em> content mode: the
|
|
620
|
+
* JSON batch format in the body with <code>Content-Type:
|
|
621
|
+
* application/cloudevents-batch+json</code>. Returns
|
|
622
|
+
* <code>{ headers, body }</code>.
|
|
623
|
+
*
|
|
624
|
+
* @example
|
|
625
|
+
* var enc = b.cloudEvents.http.encodeBatch([evtA, evtB]);
|
|
626
|
+
*/
|
|
627
|
+
function encodeBatch(events) {
|
|
628
|
+
return { headers: { "content-type": BATCH_CT }, body: toJSONBatch(events) };
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* @primitive b.cloudEvents.http.decodeBinary
|
|
633
|
+
* @signature b.cloudEvents.http.decodeBinary(headers, body, opts?)
|
|
634
|
+
* @since 0.12.63
|
|
635
|
+
* @status stable
|
|
636
|
+
* @related b.cloudEvents.http.decode, b.cloudEvents.http.encodeBinary
|
|
637
|
+
*
|
|
638
|
+
* Parse an HTTP binary-mode request into a CloudEvents envelope. Headers are
|
|
639
|
+
* matched case-insensitively; each <code>ce-*</code> header is
|
|
640
|
+
* percent-decoded into the matching attribute, <code>Content-Type</code>
|
|
641
|
+
* becomes <code>datacontenttype</code>, and the body becomes the payload
|
|
642
|
+
* (parsed as JSON when the content type is JSON, kept as a
|
|
643
|
+
* <code>data_base64</code> string for opaque bytes). The result is
|
|
644
|
+
* validated. Binary-mode header values are strings, so extension types
|
|
645
|
+
* other than String are not recovered.
|
|
646
|
+
*
|
|
647
|
+
* @opts
|
|
648
|
+
* maxBytes: number, // default: 1 MiB — reject larger bodies
|
|
649
|
+
*
|
|
650
|
+
* @example
|
|
651
|
+
* var evt = b.cloudEvents.http.decodeBinary(req.headers, req.rawBody);
|
|
652
|
+
*/
|
|
653
|
+
function decodeBinary(headers, body, opts) {
|
|
654
|
+
opts = opts || {};
|
|
655
|
+
var maxBytes = opts.maxBytes == null ? DEFAULT_MAX_BYTES : opts.maxBytes;
|
|
656
|
+
var h = _lowerHeaders(headers);
|
|
657
|
+
var event = {};
|
|
658
|
+
Object.keys(h).forEach(function (k) {
|
|
659
|
+
if (k.indexOf("ce-") !== 0) return;
|
|
660
|
+
event[k.slice(3)] = _pctDecode(String(h[k]));
|
|
661
|
+
});
|
|
662
|
+
var ct = h["content-type"] != null ? h["content-type"] : null;
|
|
663
|
+
if (ct != null) event.datacontenttype = ct;
|
|
664
|
+
var raw;
|
|
665
|
+
if (body == null) raw = Buffer.alloc(0);
|
|
666
|
+
else if (Buffer.isBuffer(body)) raw = body;
|
|
667
|
+
else if (typeof body === "string") raw = Buffer.from(body, "utf8");
|
|
668
|
+
else throw new CloudEventsError("cloud-events/bad-input", "cloudEvents.http.decodeBinary: body must be a string or Buffer");
|
|
669
|
+
if (raw.length > maxBytes) throw new CloudEventsError("cloud-events/too-large", "cloudEvents.http.decodeBinary: body exceeds maxBytes (" + maxBytes + ")");
|
|
670
|
+
if (raw.length > 0) {
|
|
671
|
+
if (_isJsonMedia(ct)) {
|
|
672
|
+
try { event.data = safeJson.parse(raw, { maxBytes: maxBytes }); }
|
|
673
|
+
catch (_e) { throw new CloudEventsError("cloud-events/bad-json", "cloudEvents.http.decodeBinary: JSON body is not valid JSON"); }
|
|
674
|
+
} else if (typeof ct === "string" && /^text\//i.test(ct)) {
|
|
675
|
+
event.data = raw.toString("utf8");
|
|
676
|
+
} else {
|
|
677
|
+
event.data_base64 = raw.toString("base64");
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
_assertValid(event, "cloudEvents.http.decodeBinary");
|
|
681
|
+
return event;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* @primitive b.cloudEvents.http.decode
|
|
686
|
+
* @signature b.cloudEvents.http.decode(headers, body, opts?)
|
|
687
|
+
* @since 0.12.63
|
|
688
|
+
* @status stable
|
|
689
|
+
* @related b.cloudEvents.http.decodeBinary, b.cloudEvents.http.encodeStructured
|
|
690
|
+
*
|
|
691
|
+
* Parse an HTTP request into a CloudEvents envelope (or array, for a batch),
|
|
692
|
+
* auto-detecting the content mode exactly as a conformant receiver does: a
|
|
693
|
+
* <code>Content-Type</code> beginning
|
|
694
|
+
* <code>application/cloudevents-batch</code> is batched, one beginning
|
|
695
|
+
* <code>application/cloudevents</code> is structured, and anything else is
|
|
696
|
+
* binary mode. Returns a single envelope for binary/structured modes and an
|
|
697
|
+
* array for batched mode.
|
|
698
|
+
*
|
|
699
|
+
* @opts
|
|
700
|
+
* maxBytes: number, // default: 1 MiB — reject larger bodies
|
|
701
|
+
*
|
|
702
|
+
* @example
|
|
703
|
+
* var evt = b.cloudEvents.http.decode(req.headers, req.rawBody);
|
|
704
|
+
*/
|
|
705
|
+
function decode(headers, body, opts) {
|
|
706
|
+
var h = _lowerHeaders(headers);
|
|
707
|
+
var ct = (h["content-type"] != null ? h["content-type"] : "") || "";
|
|
708
|
+
if (/^application\/cloudevents-batch\b/i.test(ct)) return fromJSONBatch(body, opts);
|
|
709
|
+
if (/^application\/cloudevents\b/i.test(ct)) return fromJSON(body, opts);
|
|
710
|
+
return decodeBinary(headers, body, opts);
|
|
711
|
+
}
|
|
712
|
+
|
|
271
713
|
module.exports = {
|
|
272
714
|
wrap: wrap,
|
|
273
715
|
parse: parse,
|
|
716
|
+
validate: validate,
|
|
717
|
+
isValid: isValid,
|
|
718
|
+
toJSON: toJSON,
|
|
719
|
+
fromJSON: fromJSON,
|
|
720
|
+
toJSONBatch: toJSONBatch,
|
|
721
|
+
fromJSONBatch: fromJSONBatch,
|
|
722
|
+
http: {
|
|
723
|
+
encodeBinary: encodeBinary,
|
|
724
|
+
encodeStructured: encodeStructured,
|
|
725
|
+
encodeBatch: encodeBatch,
|
|
726
|
+
decodeBinary: decodeBinary,
|
|
727
|
+
decode: decode,
|
|
728
|
+
},
|
|
274
729
|
SPECVERSION: SPECVERSION,
|
|
275
730
|
REQUIRED_ATTRS: REQUIRED_ATTRS,
|
|
276
731
|
CloudEventsError: CloudEventsError,
|
|
@@ -67,9 +67,10 @@ var ALL_DIRECTIVES = [
|
|
|
67
67
|
"default-src", "script-src", "script-src-elem", "script-src-attr",
|
|
68
68
|
"style-src", "style-src-elem", "style-src-attr",
|
|
69
69
|
"img-src", "media-src", "font-src", "connect-src", "object-src",
|
|
70
|
-
"frame-src", "child-src", "worker-src", "
|
|
70
|
+
"frame-src", "child-src", "worker-src", "fenced-frame-src",
|
|
71
|
+
"manifest-src", "prefetch-src",
|
|
71
72
|
"form-action", "frame-ancestors", "navigate-to", "base-uri", "sandbox",
|
|
72
|
-
"report-to", "report-uri",
|
|
73
|
+
"webrtc", "report-to", "report-uri",
|
|
73
74
|
"require-trusted-types-for", "trusted-types",
|
|
74
75
|
"upgrade-insecure-requests", "block-all-mixed-content",
|
|
75
76
|
];
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
var { defineClass } = require("./framework-error");
|
|
32
|
+
var rfc3339 = require("./rfc3339");
|
|
32
33
|
|
|
33
34
|
var JtdError = defineClass("JtdError", { alwaysPermanent: true });
|
|
34
35
|
|
|
@@ -48,21 +49,9 @@ var SHARED_KEYWORDS = { definitions: 1, nullable: 1, metadata: 1 };
|
|
|
48
49
|
function _isPlainObject(v) { return v !== null && typeof v === "object" && !Array.isArray(v); }
|
|
49
50
|
function _isInteger(v) { return typeof v === "number" && isFinite(v) && Math.floor(v) === v; }
|
|
50
51
|
|
|
51
|
-
// RFC 3339 date-time (the JTD "timestamp" type)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
var m = RFC3339.exec(s);
|
|
55
|
-
if (!m) return false;
|
|
56
|
-
var mo = +m[2], d = +m[3], h = +m[4], mi = +m[5], se = +m[6];
|
|
57
|
-
if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59 || se > 60) return false; // allow:raw-time-literal — RFC 3339 field ranges (60 = leap second)
|
|
58
|
-
var days = [31, ((+m[1] % 4 === 0 && +m[1] % 100 !== 0) || +m[1] % 400 === 0) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; // allow:raw-time-literal — days per month
|
|
59
|
-
if (d > days[mo - 1]) return false;
|
|
60
|
-
var tz = m[8];
|
|
61
|
-
if (tz !== "Z" && tz !== "z") { // numeric offset must be in range
|
|
62
|
-
if (+tz.slice(1, 3) > 23 || +tz.slice(4, 6) > 59) return false; // allow:raw-time-literal — RFC 3339 offset hour/minute ranges
|
|
63
|
-
}
|
|
64
|
-
return true;
|
|
65
|
-
}
|
|
52
|
+
// RFC 3339 date-time (the JTD "timestamp" type) — strict form shared with
|
|
53
|
+
// the other spec-driven consumers via lib/rfc3339.js.
|
|
54
|
+
var _validTimestamp = rfc3339.isValidDateTime;
|
|
66
55
|
|
|
67
56
|
// --- compile-time well-formedness (RFC 8927 section 2.2) ---
|
|
68
57
|
function _checkSchema(schema, root, isRoot) {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* rfc3339 — strict RFC 3339 date-time validation, shared by the primitives
|
|
4
|
+
* whose specs require the full "internet date/time" form (a mandatory
|
|
5
|
+
* "T"/"t" separator and a mandatory "Z" or numeric UTC offset). b.jtd's
|
|
6
|
+
* `timestamp` type and b.cloudevents' `time` attribute both point at
|
|
7
|
+
* RFC 3339, so the field-range + leap-year + offset-range checks live here
|
|
8
|
+
* once instead of drifting between them.
|
|
9
|
+
*
|
|
10
|
+
* This is intentionally NOT the lenient validator b.guardTime ships: that
|
|
11
|
+
* one accepts a space separator and an absent offset by design (a content-
|
|
12
|
+
* safety guard tuned per profile), whereas these consumers must reject
|
|
13
|
+
* anything the spec disallows.
|
|
14
|
+
*
|
|
15
|
+
* var rfc3339 = require("./rfc3339");
|
|
16
|
+
* rfc3339.isValidDateTime("2018-04-05T17:31:00Z"); // → true
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// "T" separator required; offset ("Z"/"z" or ±HH:MM) required.
|
|
20
|
+
var RFC3339_RE = /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
|
|
21
|
+
|
|
22
|
+
function isValidDateTime(s) {
|
|
23
|
+
if (typeof s !== "string") return false;
|
|
24
|
+
var m = RFC3339_RE.exec(s);
|
|
25
|
+
if (!m) return false;
|
|
26
|
+
var mo = +m[2], d = +m[3], h = +m[4], mi = +m[5], se = +m[6];
|
|
27
|
+
if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59 || se > 60) return false; // allow:raw-time-literal — RFC 3339 field ranges (60 = leap second)
|
|
28
|
+
var days = [31, ((+m[1] % 4 === 0 && +m[1] % 100 !== 0) || +m[1] % 400 === 0) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; // allow:raw-time-literal — days per month (Gregorian)
|
|
29
|
+
if (d > days[mo - 1]) return false;
|
|
30
|
+
var tz = m[8];
|
|
31
|
+
if (tz !== "Z" && tz !== "z") {
|
|
32
|
+
if (+tz.slice(1, 3) > 23 || +tz.slice(4, 6) > 59) return false; // allow:raw-time-literal — RFC 3339 offset hour/minute ranges
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { isValidDateTime: isValidDateTime, RFC3339_RE: RFC3339_RE };
|
|
@@ -372,6 +372,13 @@ var HEX_RE = /^[0-9a-fA-F]+$/;
|
|
|
372
372
|
// is length-agnostic — callers cap length per protocol contract.
|
|
373
373
|
var BASE64URL_RE = /^[A-Za-z0-9_-]+$/;
|
|
374
374
|
|
|
375
|
+
// BASE64_RE matches standard base64 (RFC 4648 §4) with the `+` / `/`
|
|
376
|
+
// alphabet and canonical 0-2 chars of `=` padding (empty string allowed).
|
|
377
|
+
// Shared by callers that validate padded base64 fields (backup manifest
|
|
378
|
+
// digests, CloudEvents data_base64) so the alphabet check isn't reinvented.
|
|
379
|
+
// Length-agnostic — callers cap length per their own contract / maxBytes.
|
|
380
|
+
var BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
381
|
+
|
|
375
382
|
// Fixed-length hex predicates used by trace-context primitives (W3C
|
|
376
383
|
// trace-id is 16 bytes = 32 hex chars; span-id / parent-id is 8
|
|
377
384
|
// bytes = 16 hex chars). Extracted to keep callers length-bounded
|
|
@@ -552,6 +559,7 @@ module.exports = {
|
|
|
552
559
|
stripTrailingHspace: stripTrailingHspace,
|
|
553
560
|
HEX_RE: HEX_RE,
|
|
554
561
|
BASE64URL_RE: BASE64URL_RE,
|
|
562
|
+
BASE64_RE: BASE64_RE,
|
|
555
563
|
IPV6_HEXTET_RE: IPV6_HEXTET_RE,
|
|
556
564
|
TRACE_ID_HEX_RE: TRACE_ID_HEX_RE,
|
|
557
565
|
SPAN_ID_HEX_RE: SPAN_ID_HEX_RE,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.12.63",
|
|
4
|
+
"date": "2026-05-25",
|
|
5
|
+
"headline": "`b.cloudEvents` gains the JSON event format, batch, and the HTTP binding",
|
|
6
|
+
"summary": "b.cloudEvents grows beyond wrap / parse into a full CloudEvents 1.0.2 surface. b.cloudEvents.validate / isValid check an envelope against the spec without throwing (the non-throwing companion to parse). toJSON / fromJSON serialize and parse the JSON event format, and toJSONBatch / fromJSONBatch handle the JSON batch format; untrusted bodies parse through the framework's bounded, prototype-pollution-safe JSON reader. The new http.* binding speaks both content modes the spec defines — binary mode spreads context attributes across percent-encoded ce-* headers with the data in the body, structured mode carries the whole event as application/cloudevents+json — plus the batch mode, and http.decode auto-detects the incoming mode from Content-Type exactly as a conformant receiver does. Verified against the spec's normative example events.",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Added",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "`b.cloudEvents` JSON event format, batch, and HTTP binding",
|
|
13
|
+
"body": "`validate` / `isValid` report spec violations without throwing (the non-throwing companion to `parse`). `toJSON` / `fromJSON` and `toJSONBatch` / `fromJSONBatch` serialize and parse the JSON event and batch formats over the existing envelope shape. `http.encodeBinary` / `http.encodeStructured` / `http.encodeBatch` render the three HTTP content modes — binary spreads attributes across percent-encoded `ce-*` headers, structured and batched carry the event(s) as `application/cloudevents+json` / `application/cloudevents-batch+json` — and `http.decode` parses a request back into an envelope (or array) by auto-detecting the mode from `Content-Type`. `b.jtd` or `b.safeSchema` still validate the event's `data` payload."
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"heading": "Fixed",
|
|
19
|
+
"items": [
|
|
20
|
+
{
|
|
21
|
+
"title": "`b.csp.build` accepts `fenced-frame-src` and `webrtc`",
|
|
22
|
+
"body": "The CSP3 `fenced-frame-src` directive — which the default security-headers policy emits to block `<fencedframe>` embeds — was missing from the builder's recognized-directive set, so the default policy could not round-trip through `b.csp.build` (it threw `csp/unknown-directive`). Both `fenced-frame-src` and the CSP3 `webrtc` directive are now recognized."
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Layer 0 — b.cloudEvents (CloudEvents 1.0.2).
|
|
4
|
+
* Covers the existing wrap / parse envelope helpers plus the JSON event
|
|
5
|
+
* format (toJSON / fromJSON + batch), the non-throwing validate / isValid
|
|
6
|
+
* check, and the HTTP protocol binding (binary + structured + batch +
|
|
7
|
+
* auto-detect decode). Oracle: the normative example events from the
|
|
8
|
+
* CloudEvents JSON Event Format 1.0.2 spec and the HTTP binding's
|
|
9
|
+
* binary-mode example request.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
var helpers = require("../helpers");
|
|
13
|
+
var check = helpers.check;
|
|
14
|
+
var b = helpers.b;
|
|
15
|
+
function code(fn) { try { fn(); return "NO-THROW"; } catch (e) { return e.code; } }
|
|
16
|
+
|
|
17
|
+
// Spec JSON-format example events (json-format.md), with data_base64
|
|
18
|
+
// replaced by real canonical base64 for byte-exact round-trips.
|
|
19
|
+
var EX_JSON_OBJECT = {
|
|
20
|
+
specversion: "1.0", type: "com.example.someevent", source: "/mycontext",
|
|
21
|
+
id: "C234-1234-1234", time: "2018-04-05T17:31:00Z",
|
|
22
|
+
comexampleextension1: "value", comexampleothervalue: 5,
|
|
23
|
+
datacontenttype: "application/json",
|
|
24
|
+
data: { appinfoA: "abc", appinfoB: 123, appinfoC: true },
|
|
25
|
+
};
|
|
26
|
+
var EX_XML_STRING = {
|
|
27
|
+
specversion: "1.0", type: "com.example.someevent", source: "/mycontext",
|
|
28
|
+
id: "B234-1234-1234", time: "2018-04-05T17:31:00Z",
|
|
29
|
+
comexampleextension1: "value", unsetextension: null,
|
|
30
|
+
datacontenttype: "application/xml", data: "<much wow=\"xml\"/>",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function testWrapParse() {
|
|
34
|
+
var ce = b.cloudEvents.wrap({ source: "/services/orders", type: "com.example.order.created", subject: "order/o-1234", data: { id: "o-1234" } });
|
|
35
|
+
check("wrap sets specversion 1.0", ce.specversion === "1.0");
|
|
36
|
+
check("wrap auto-fills UUID id", /^[0-9a-f-]{36}$/.test(ce.id));
|
|
37
|
+
check("wrap sets application/json for object data", ce.datacontenttype === "application/json");
|
|
38
|
+
var bin = b.cloudEvents.wrap({ source: "/x", type: "t", data: Buffer.from([1, 2, 3]) });
|
|
39
|
+
check("wrap routes Buffer to data_base64", bin.data_base64 === Buffer.from([1, 2, 3]).toString("base64"));
|
|
40
|
+
var rec = b.cloudEvents.parse(EX_JSON_OBJECT);
|
|
41
|
+
check("parse surfaces extensions separately", rec.extensions.comexampleothervalue === 5 && rec.data.appinfoA === "abc");
|
|
42
|
+
check("parse rejects missing required", code(function () { b.cloudEvents.parse({ specversion: "1.0", id: "1", source: "/x" }); }) === "cloud-events/missing-required");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function testValidate() {
|
|
46
|
+
check("isValid true for conformant", b.cloudEvents.isValid(EX_JSON_OBJECT));
|
|
47
|
+
check("validate flags bad specversion", b.cloudEvents.validate({ specversion: "0.3", id: "1", source: "/x", type: "t" }).some(function (i) { return i.attribute === "specversion"; }));
|
|
48
|
+
check("validate flags bad time", b.cloudEvents.validate({ specversion: "1.0", id: "1", source: "/x", type: "t", time: "not-a-time" }).some(function (i) { return i.attribute === "time"; }));
|
|
49
|
+
check("validate flags float extension", b.cloudEvents.validate({ specversion: "1.0", id: "1", source: "/x", type: "t", frac: 1.5 }).some(function (i) { return i.attribute === "frac"; }));
|
|
50
|
+
check("validate flags uppercase ext name", b.cloudEvents.validate({ specversion: "1.0", id: "1", source: "/x", type: "t", Foo: "v" }).some(function (i) { return i.attribute === "Foo"; }));
|
|
51
|
+
check("validate flags data + data_base64", b.cloudEvents.validate({ specversion: "1.0", id: "1", source: "/x", type: "t", data: 1, data_base64: "AA==" }).some(function (i) { return i.attribute === "data"; }));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function testJsonFormat() {
|
|
55
|
+
var rt = b.cloudEvents.fromJSON(b.cloudEvents.toJSON(EX_JSON_OBJECT));
|
|
56
|
+
check("toJSON/fromJSON round-trip JSON data", JSON.stringify(rt.data) === JSON.stringify(EX_JSON_OBJECT.data));
|
|
57
|
+
var rtx = b.cloudEvents.fromJSON(b.cloudEvents.toJSON(EX_XML_STRING));
|
|
58
|
+
check("xml-string data round-trips", rtx.data === "<much wow=\"xml\"/>" && rtx.unsetextension === null);
|
|
59
|
+
var binEvt = b.cloudEvents.wrap({ source: "/x", type: "t", data: Buffer.from([0, 1, 254, 255]) });
|
|
60
|
+
var binRt = b.cloudEvents.fromJSON(b.cloudEvents.toJSON(binEvt));
|
|
61
|
+
check("binary data_base64 round-trips", binRt.data_base64 === Buffer.from([0, 1, 254, 255]).toString("base64"));
|
|
62
|
+
check("fromJSON rejects non-canonical base64", code(function () { b.cloudEvents.fromJSON(JSON.stringify({ specversion: "1.0", id: "1", source: "/x", type: "t", data_base64: "!!!!" })); }) === "cloud-events/invalid");
|
|
63
|
+
check("fromJSON rejects malformed JSON", code(function () { b.cloudEvents.fromJSON("{nope"); }) === "cloud-events/bad-json");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function testBatch() {
|
|
67
|
+
var body = b.cloudEvents.toJSONBatch([EX_JSON_OBJECT, EX_XML_STRING]);
|
|
68
|
+
check("batch serializes a JSON array", Array.isArray(JSON.parse(body)) && JSON.parse(body).length === 2);
|
|
69
|
+
var evts = b.cloudEvents.fromJSONBatch(body);
|
|
70
|
+
check("batch round-trips two events", evts.length === 2 && evts[0].id === "C234-1234-1234");
|
|
71
|
+
check("empty batch valid both ways", b.cloudEvents.fromJSONBatch("[]").length === 0 && b.cloudEvents.toJSONBatch([]) === "[]");
|
|
72
|
+
check("non-array batch refused", code(function () { b.cloudEvents.fromJSONBatch("{}"); }) === "cloud-events/invalid");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function testHttpBinary() {
|
|
76
|
+
var enc = b.cloudEvents.http.encodeBinary(EX_JSON_OBJECT);
|
|
77
|
+
check("binary maps id to ce-id", enc.headers["ce-id"] === "C234-1234-1234");
|
|
78
|
+
check("binary maps specversion to ce-specversion", enc.headers["ce-specversion"] === "1.0");
|
|
79
|
+
check("binary does NOT prefix datacontenttype", enc.headers["content-type"] === "application/json" && enc.headers["ce-datacontenttype"] === undefined);
|
|
80
|
+
check("binary maps extension int to ce header string", enc.headers["ce-comexampleothervalue"] === "5");
|
|
81
|
+
check("binary body is JSON data", JSON.parse(enc.body).appinfoA === "abc");
|
|
82
|
+
// Spec HTTP binding binary-mode example request.
|
|
83
|
+
var dec = b.cloudEvents.http.decodeBinary({
|
|
84
|
+
"ce-specversion": "1.0", "ce-type": "com.example.someevent",
|
|
85
|
+
"ce-time": "2018-04-05T03:56:24Z", "ce-id": "1234-1234-1234",
|
|
86
|
+
"ce-source": "/mycontext/subcontext", "Content-Type": "application/json; charset=utf-8",
|
|
87
|
+
}, "{\"hello\":\"world\"}");
|
|
88
|
+
check("binary decode reads ce-id + body", dec.id === "1234-1234-1234" && dec.data.hello === "world");
|
|
89
|
+
check("binary decode maps Content-Type to datacontenttype", dec.datacontenttype === "application/json; charset=utf-8");
|
|
90
|
+
// Percent-encoding round-trip (space / quote / non-ASCII).
|
|
91
|
+
var pe = b.cloudEvents.http.encodeBinary(b.cloudEvents.wrap({ source: "/x", type: "t", subject: "a b\"cé" }));
|
|
92
|
+
check("header percent-encodes space/quote/unicode", /%20/.test(pe.headers["ce-subject"]) && /%22/.test(pe.headers["ce-subject"]) && /%C3%A9/.test(pe.headers["ce-subject"]));
|
|
93
|
+
check("percent-decode round-trips", b.cloudEvents.http.decodeBinary(pe.headers, "").subject === "a b\"cé");
|
|
94
|
+
// JSON-media string payloads must be JSON-encoded in the body so they
|
|
95
|
+
// re-parse — a bare string under application/json (or absent, which
|
|
96
|
+
// defaults to JSON) round-trips through binary mode.
|
|
97
|
+
var strEvt = b.cloudEvents.wrap({ source: "/x", type: "t", datacontenttype: "application/json", data: "hello" });
|
|
98
|
+
var strEnc = b.cloudEvents.http.encodeBinary(strEvt);
|
|
99
|
+
check("json string payload is JSON-encoded in body", strEnc.body === "\"hello\"");
|
|
100
|
+
check("json string payload round-trips through binary", b.cloudEvents.http.decodeBinary(strEnc.headers, strEnc.body).data === "hello");
|
|
101
|
+
// Opaque binary body becomes data_base64.
|
|
102
|
+
var ob = b.cloudEvents.http.decodeBinary({ "ce-specversion": "1.0", "ce-id": "1", "ce-source": "/x", "ce-type": "t", "content-type": "application/octet-stream" }, Buffer.from([9, 8, 7]));
|
|
103
|
+
check("opaque body decodes to data_base64", ob.data_base64 === Buffer.from([9, 8, 7]).toString("base64"));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function testHttpStructuredAndDetect() {
|
|
107
|
+
var s = b.cloudEvents.http.encodeStructured(EX_JSON_OBJECT);
|
|
108
|
+
check("structured uses cloudevents+json", /^application\/cloudevents\+json/.test(s.headers["content-type"]));
|
|
109
|
+
check("auto-detect decodes structured", b.cloudEvents.http.decode(s.headers, s.body).id === "C234-1234-1234");
|
|
110
|
+
var bn = b.cloudEvents.http.encodeBinary(EX_JSON_OBJECT);
|
|
111
|
+
check("auto-detect decodes binary", !Array.isArray(b.cloudEvents.http.decode(bn.headers, bn.body)) && b.cloudEvents.http.decode(bn.headers, bn.body).id === "C234-1234-1234");
|
|
112
|
+
var ba = b.cloudEvents.http.encodeBatch([EX_JSON_OBJECT, EX_XML_STRING]);
|
|
113
|
+
check("batch uses cloudevents-batch+json", /^application\/cloudevents-batch\+json/.test(ba.headers["content-type"]));
|
|
114
|
+
check("auto-detect decodes batch to array", Array.isArray(b.cloudEvents.http.decode(ba.headers, ba.body)) && b.cloudEvents.http.decode(ba.headers, ba.body).length === 2);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function run() {
|
|
118
|
+
testWrapParse();
|
|
119
|
+
testValidate();
|
|
120
|
+
testJsonFormat();
|
|
121
|
+
testBatch();
|
|
122
|
+
testHttpBinary();
|
|
123
|
+
testHttpStructuredAndDetect();
|
|
124
|
+
}
|
|
125
|
+
if (require.main === module) {
|
|
126
|
+
try { run(); console.log("[cloud-events] OK — " + helpers.getChecks() + " checks passed"); }
|
|
127
|
+
catch (e) { console.error("FAIL:", e && e.stack || e); process.exit(1); }
|
|
128
|
+
}
|
|
129
|
+
module.exports = { run: run };
|
|
@@ -4,6 +4,30 @@
|
|
|
4
4
|
var helpers = require("../helpers");
|
|
5
5
|
var check = helpers.check;
|
|
6
6
|
var b = helpers.b;
|
|
7
|
+
var DEFAULT_CSP = require("../../lib/middleware/security-headers").DEFAULT_CSP;
|
|
8
|
+
|
|
9
|
+
// Every directive the framework's own DEFAULT_CSP emits must be a
|
|
10
|
+
// directive b.csp.build recognizes — otherwise the default policy can't
|
|
11
|
+
// round-trip through the builder (regression: fenced-frame-src shipped in
|
|
12
|
+
// DEFAULT_CSP but was missing from ALL_DIRECTIVES).
|
|
13
|
+
function testDefaultCspRoundTrips() {
|
|
14
|
+
var directives = {};
|
|
15
|
+
DEFAULT_CSP.split(";").forEach(function (part) {
|
|
16
|
+
var tokens = part.trim().split(/\s+/);
|
|
17
|
+
if (!tokens[0]) return;
|
|
18
|
+
directives[tokens[0]] = tokens.slice(1);
|
|
19
|
+
});
|
|
20
|
+
check("DEFAULT_CSP names fenced-frame-src", Object.prototype.hasOwnProperty.call(directives, "fenced-frame-src"));
|
|
21
|
+
var threw = null;
|
|
22
|
+
try { b.csp.build(directives, { acknowledgeUnsafe: true, allowDataImages: true, trustedTypesPolicies: ["default"] }); }
|
|
23
|
+
catch (e) { threw = e.code + ": " + e.message; }
|
|
24
|
+
check("DEFAULT_CSP round-trips through b.csp.build", threw === null);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function testWebrtcDirective() {
|
|
28
|
+
var policy = b.csp.build({ "default-src": ["'self'"], "webrtc": ["'block'"] });
|
|
29
|
+
check("csp.build accepts webrtc directive", policy.indexOf("webrtc 'block'") !== -1);
|
|
30
|
+
}
|
|
7
31
|
|
|
8
32
|
function testBuild() {
|
|
9
33
|
var policy = b.csp.build({
|
|
@@ -79,6 +103,8 @@ function testCspErrorClass() {
|
|
|
79
103
|
}
|
|
80
104
|
|
|
81
105
|
function run() {
|
|
106
|
+
testDefaultCspRoundTrips();
|
|
107
|
+
testWebrtcDirective();
|
|
82
108
|
testBuild();
|
|
83
109
|
testRefusesUnsafeKeywords();
|
|
84
110
|
testRefusesCatchAll();
|
package/package.json
CHANGED