npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ricardoqmd/authz-http

v0.2.0

Published

A reference AuthorizationTransport over fetch, which rejects loudly when a backend does not echo the app and context it was asked about

Readme

@ricardoqmd/authz-http

A reference AuthorizationTransport over fetch, for @ricardoqmd/authz-core.

One function. It does not manage state, does not cache and does not decide anything — the core does all of that. This turns the core's two-method port into two HTTP calls, and enforces one contract on the answers.

pnpm add @ricardoqmd/authz-http @ricardoqmd/authz-core

Keep the two on the versions released together: this package declares as its peer the minor of @ricardoqmd/authz-core it is released with. Paired with another minor, an install ends one of four ways, depending on the installer and its configuration: refused, and nothing changes; installed, with a warning; installed, without a word; or it ends in an error, with the pair installed anyway. The package still on the older release then lacks what the newer release added: an option it does not know is ignored when the code runs — a contextField given to an older release of this package leaves the echo read from contextId, so an answer is used or refused on that field and not on the one you named. The types say so for an option only when it is written in an object literal where the configuration is expected.

import { createAuthorizationSession } from "@ricardoqmd/authz-core";
import { createHttpTransport } from "@ricardoqmd/authz-http";

const session = createAuthorizationSession({
  app: "app-a",
  maxPairsPerRequest: 200,
  transport: createHttpTransport({
    baseUrl: "https://api.example.com",
    getToken: () => auth.accessToken ?? null,
  }),
});

The contract it enforces on your backend

Every answer must echo the app it was asked about, and this adapter reads it from the body and rejects when it is missing. The core discards an answer whose app does not match, silently and fail-closed — which is right, because an answer about another application is worse than no answer, but it means an adapter that cast a body without that field would produce a fully denied application with no error state anywhere. Failing here is a message a developer reads; failing there is a screen a user cannot explain.

It is read from the body and not filled in from what was asked: doing that would make the core's guard tautological.

The permissions answer needs a permissions array and the decisions answer a decisions array.

| Method | Route by default | |---|---| | fetchPermissions(app) | GET {baseUrl}/me/apps/{app}/permissions | | fetchDecisions(app, request) | POST {baseUrl}/me/apps/{app}/decisions |

Those two paths are defaults, not constants — see The routes below. The methods and the body shape are not configurable.

The authorization context is optional, and there are exactly three modes

The core has no context concept. If your deployment has one, this adapter carries it — and checks that the answer is about it.

| contextId | contextHeader | What happens | |---|---|---| | absent | absent | No header is sent and no echo is required. A backend that has never heard of contexts works unchanged. This is the default. | | present | present | The header carries the id, and an answer whose echo field does not echo it is rejected loudly — as is one that omits the field. The echo field is contextId, or the name you give as contextField. | | one of the two | the other missing | RangeError at construction, naming which is missing. |

createHttpTransport({
  baseUrl: "https://api.example.com",
  getToken: () => auth.accessToken ?? null,
  // Optional, and only as a pair:
  contextId: currentContextId,
  contextHeader: "X-Context-Id",
});

A header name with nothing to put in it, or an id with nowhere to send it, is a configuration mistake. Rejecting it at construction costs one line; discovering it as a 401 in an environment costs an afternoon.

Two preconditions on contextId, because the symptom of breaking either is an opaque failure rather than a message: it is sent as a header value, so it must be a valid one; and leading or trailing whitespace is not preserved — the platform trims it on the wire, silently.

The rejection follows the same message discipline as every other one here: the route and the field, never the body, never the token, never the header value.

An application id must be a non-empty sequence of RFC 3986 unreserved characters — letters, digits, -, ., _, ~ — and must not be "." or "..". Anything else is refused with a RangeError and no request is sent; nothing is encoded into safety, because encoding a separator only hides it until something downstream decodes it back. What that guarantees is the segment in the URL this package constructs, and no accepted id contains a character any front door measured here transforms, so that segment now survives them too. See Application ids that are refused.

The echo field is configuration too

A backend that echoes the context under another name is not wrong, so the name is yours to give, as the header's is:

createHttpTransport({
  baseUrl: "https://api.example.com",
  getToken: () => auth.accessToken ?? null,
  contextId: currentContextId,
  contextHeader: "X-Context-Id",
  contextField: "tenantId", // the answer carries { "app": ..., "tenantId": ..., ... }
});

The name changes where the echo is read, and nothing about how it is judged. An answer is used only when that field, read once, is a string equal to contextId; a missing field, one that is not a string, and one that echoes a different context are rejected, and the default name does not count once you give another. Measured through a session over HTTP, against a backend that echoes the context only under another name: without contextField the session is UNAVAILABLE and every decision DENY; with it, READY and what the backend permits.

contextField must be a non-empty string, or the constructor throws a RangeError — an empty name, or one that is not a string, names no field an answer could carry. Without the context pair, no answer is asked to carry it.

Configuration

| Field | | |---|---| | baseUrl | Where the two routes hang from. A trailing slash is fine. | | contextId | Optional. The authorization context to send and require an echo of. See the three modes above. | | contextHeader | Required only when contextId is given. Configuration, never a constant — this package does not know what your deployment calls its authorization context. | | contextField | Optional; "contextId" when omitted. The field of the answer that must echo contextId. See The echo field is configuration too. | | getToken | Sync or async. Returning null omits the Authorization header entirely; an empty one is a different statement to a backend, and not the one we mean. | | fetch | Optional; defaults to the global. Injectable so you can wrap it — retries, tracing, your own tests — without this package having an opinion about any of it. This package asks each request once. On a connection that drops requests, a fetch that asks a failed one once more is what keeps a large screen from being asked again, and shown partly DENY, draw after draw: the core README has what it costs without one. | | classifyError | Optional. See below. | | paths | Optional, both entries optional. Where the two routes live. Defaults to the paths in the table above. See below. | | onDiagnostic | Optional. Told which rule refused a call. See onDiagnostic. |

No environment reads. No process.env, no import.meta.env, no ambient globals: you know your bundler and this package must not.

The routes are yours

The defaults are a convenience, and the paths are configuration. They are two paths this package chose, and nothing ties them to what your backend serves. If your deployment depends on where the routes are, set paths — then the URL is the one you wrote, whatever the defaults are.

Pass nothing and nothing changes. The defaults are the two paths this package used to hardcode, so the call you write today is the call you keep writing:

createHttpTransport({ baseUrl: "https://api.example.com", getToken });
// GET  https://api.example.com/me/apps/app-a/permissions
// POST https://api.example.com/me/apps/app-a/decisions

Give paths when your backend puts them somewhere else. Either entry stands on its own:

createHttpTransport({
  baseUrl: "https://api.example.com",
  getToken,
  paths: {
    permissions: (encodedApp) => `/authz/${encodedApp}/menu`,
    // `decisions` omitted: it keeps /me/apps/{app}/decisions
  },
});
// GET https://api.example.com/authz/app-a/menu

encodedApp arrives percent-encoded, and it has already been through the rule. Interpolate it and nothing else. An id that is not a sequence of unreserved characters never reaches your function at all — the refusal runs first, so a configured path cannot walk around it. Passing you the raw id would have handed you that job without saying so, and forgetting to encode is the ordinary mistake.

Do not encode it again — though what that costs you has changed, and the honest version is worth the paragraph. Measured: over the 66 characters the rule admits, encodeURIComponent alters none, so for every id this package will ever hand you it is the identity, and encoding it a second time is the identity as well: measured over the whole unreserved set and over eleven accepted ids, 0 differ. The %252F a / used to produce cannot happen, because a / never gets here.

So the mistake is currently unreachable, not harmless. It is the rule that is keeping it unreachable, and it is one widening away from mattering again — the day an id is allowed to contain anything encoding touches, a double-encoded path becomes a route your backend does not recognise, and nothing announces it — measured through a session, a double-encoded permissions path shows UNAVAILABLE, or NO_ACCESS_IN_APP if your backend answers 403 for an application it does not know, and a double-encoded decisions path shows READY with everything denied. Nothing is printed in any of those cases. That is the failure the instruction is worth avoiding, and it is why the parameter is still named encodedApp.

The returned path is joined to baseUrl verbatim, so it must be a non-empty string beginning with /. A wrong URL built in silence is the one outcome this package refuses, so it is checked twice:

| when | what happens | |---|---| | at construction | each function you give is called once, with the probe id "probe". A returned string that does not begin with / is a RangeError naming the option. If it throws or returns something that is not a string, the probe concludes nothing — a path looked up by id is entitled not to know one it has never been given. | | at call time | every path is checked before it is used. A bad one is a RangeError naming the option — to whoever called the transport directly. See the warning below for what it looks like through a session. |

What a wrong path looks like from above, and why you must exercise both

Nothing prints anything. No published code of either package calls console. Through createAuthorizationSession the RangeError is caught and turned into state, and no diagnostic event names the option: this transport's onDiagnostic is told only of an AuthorizationTransportError, which this is not, and what a session's onDiagnostic hears of it is the reason rejected. And the two options do not produce the same state. Measured through a real core session, against a backend that permits read on r-1:

| wrong option | session state | decisionFor(read, r-1) | console calls | |---|---|---|---| | paths.permissions | UNAVAILABLE | PERMIT | 0 | | paths.decisions | READY | DENY | 0 |

A wrong paths.decisions is the one to fear. The menu loads, so the screen is READY and complete. The decision call's chunk rejects; a rejected chunk contributes nothing — that is the core's fail-closed contract for any decision failure — and what is absent reads as a denial. You get a working screen where everything is denied, and it is indistinguishable from a real denial.

A wrong paths.permissions is milder: the screen says unavailable while decide(), whose path is fine, keeps answering normally.

No state will tell you the decisions path is wrong, so check it in a way that can fail: call fetchPermissions and fetchDecisions on the transport directly, or assert through a session a PERMIT you know the subject has — a session shows a wrong decisions path as a denial — and do it for every application id you will use, not one. A lookup that knows app-a and forgot app-b passes construction, answers app-a correctly and fails for app-b alone.

A path is not a place to put a secret. The route travels into every message this transport throws, by design — see Errors. Whatever you put in the path is part of the route, so a token or a key does not belong there. This package cannot prevent it and does not try.

Application ids that are refused

An application id is refused with a RangeError, and no request is sent, unless it is a non-empty sequence of RFC 3986 unreserved characters — ALPHA / DIGIT / "-" / "." / "_" / "~" — and is neither "." nor "..". Nothing outside that set is encoded into safety, because encoding a separator only hides it until something downstream decodes it back.

A value that is not a string — undefined, null, a number, an array — is refused the same way. The rule is checked on the value itself and not on its conversion to a string: measured, before that check [".."] passed as ".." and the request reached /me/permissions with the Authorization header.

The rule says what is permitted, not what is dangerous, and that is the whole reason it is written this way. Its four ancestors each named a value that had been measured harmful, which meant every character nobody had thought to measure was permitted by default. unreserved is the set the standard already defines as safe anywhere in a URI with no encoding and no interpretation; a character outside it is refused whether or not anyone has measured what a front door does with it.

What the refused ids used to do

encodeURIComponent leaves a dot alone, and the URL parser resolves dot segments before the request goes out. Measured against a real HTTP server, on the default path:

| id | sent as | a server reached DIRECTLY received | an upstream behind nginx proxy_pass .../api/; received | |---|---|---|---| | ".." | /api/me/apps/../permissions | /api/me/permissions | /api/me/permissions | | "." | /api/me/apps/./permissions | /api/me/apps/permissions | /api/me/apps/permissions | | "" | /api/me/apps//permissions | /api/me/apps//permissions | /api/me/apps/permissions (merge_slashes) | | "a/b" | /api/me/apps/a%2Fb/permissions | /api/me/apps/a%2Fb/permissions | /api/me/apps/a/b/permissions | | "../secret" | /api/me/apps/..%2Fsecret/permissions | unchanged | /api/me/secret/permissions | | "../.." | /api/me/apps/..%2F../permissions | unchanged | /api/permissions | | "..;" | /api/me/apps/..%3B/permissions | unchanged | /api/me/apps/..;/permissions | | "a\b" | /api/me/apps/a%5Cb/permissions | unchanged | unchanged | | "..." | /api/me/apps/.../permissions | unchanged | unchanged | | "a..b" | /api/me/apps/a..b/permissions | unchanged | unchanged |

Every row above the last two is refused now. The last two are sent: "..." and "a..b" are unreserved, and the rule refuses only the exact values "." and "..", not dots in general.

A column that used to be here said escapes: yes/no, and it was measured against a direct server only. It read as a security statement, and behind proxy_pass with a URI part it was wrong for three of its rows. The columns now name the setup each one measured, and no cell is —.

Before the refusal, each of those requests left with your Authorization header, toward a route you did not write. The answer was discarded — the core rejects an app that does not match — but the request had already happened.

And encoding the dots is not a fix: measured, the parser decodes before it resolves, so %2e%2e and %2E%2E reach the same /api/me/permissions that .. does. The only faithful answer is to refuse.

The empty id is refused for a different and milder reason: it occupies no segment, so /me/apps//permissions is a differently shaped route rather than a route for an application — and what this package guarantees is that the id is exactly one segment.

What each front door does, measured

Measured against two reverse proxies, six configurations, with a client that still sent everything:

| id, as sent | nginx 1.29.3, proxy_pass .../api/; | nginx, proxy_pass with no URI part | Apache 2.4.67, default | AllowEncodedSlashes On | NoDecode | NoDecode + nocanon | |---|---|---|---|---|---|---| | %2F | decoded, dot segments re-resolved | intact | 404, never arrives | decoded, as nginx | intact | intact | | %3B | decoded to ; | intact | decoded to ; | decoded to ; | decoded to ; | intact | | %5C | intact | intact | intact | intact | intact | intact |

AllowEncodedSlashes NoDecode is not the way out, and this README used to offer it as one. It keeps %2F intact and decodes %3B anyway. Of everything measured, only nocanon on Apache and a proxy_pass without a URI part on nginx left every segment untouched.

And a decoded ; is not cosmetic, because a Servlet container strips ;parameters from each segment before normalising the path. Measured: "..;" through nginx (URI part) into Tomcat 11.0.22 arrives as pathInfo=/me/permissions — the escape, with the Authorization header — and it arrives the same way through Apache with NoDecode. Directly into Tomcat it stays the literal ..;. On Quarkus 3.15.0 the route does not move, but the @PathParam is a different id: "..;" → app="..", "a;b" → app="a", i.e. a question about another application. Neither is reachable now: ; is not unreserved.

And the bound, which the rule has changed. What this package guarantees is the segment in the URL it constructs; what a front door does afterwards has always been outside its reach. What has changed is what there is to act on: an accepted id cannot contain any of the three escapes above — nor a % to write one with, nor a space, nor anything outside ASCII — so none of the transformations measured here has an input. Measured: all 66 unreserved characters, in 30 accepted ids that include "...", "....", "a..b", "..~" and "~..", arrived unchanged through all six configurations in the table and through a direct connection, each with its Authorization header.

Read that as a consequence of the rule and not as a property of proxies: the guarantee did not get stronger, the input got narrower — and it holds only as long as the rule does. What is not measured: IIS, managed load balancers and CDNs. None of the six configurations transformed an unreserved character; a door that does is outside this bound.

Measured: %5C was decoded by neither door in any of the six configurations, and Tomcat 11 answers 400 to %5C and to a raw \. So \ is refused for the reason the rule gives — it is not unreserved — and not because anything measured turned it back into a separator.

The decisions request declares its content type

The decisions call sends Content-Type: application/json. The permissions call, which has no body, sends none.

Before this, the body was a string with no content type, so the platform labelled it text/plain;charset=UTF-8. Measured against a real server that requires JSON on a POST: it answered 415, the chunk was dropped, and the session sat at READY with zero decisions and DENY for a pair the backend would have permitted — a working screen with everything denied, indistinguishable from a real denial.

If you call this from a browser across origins, your backend must allow Content-Type in its Access-Control-Allow-Headers before you update.

Measured in Chrome 152 and Firefox 151, page and backend on different origins, through a session:

  • In the first three configurations the preflight asks for content-type beside Authorization or your context header. A backend whose Access-Control-Allow-Headers names only those two fails it.
  • In the fourth — getToken returning null and no context pair — the content type is the only reason the decisions request is preflighted at all, and a backend that does not answer OPTIONS fails it.

Either way the browser never sends the POST, and nothing says so. The menu request carries no content type and still loads, so the screen is READY and every decision reads DENY, for pairs the backend permits too — a working screen with everything denied, the same one a strict JSON backend produces when the content type is missing. With Content-Type among the allowed headers, all four configurations answered what the backend said; with the page and the backend on one origin, neither browser sent an OPTIONS in any configuration.

The headers this package puts on a request, by configuration — the part that depends on this code:

| configuration | headers | |---|---| | token + context pair | Accept, Authorization, X-Context-Id, and Content-Type on the decisions call | | token, no context | Accept, Authorization, and Content-Type on the decisions call | | no token, with context | Accept, X-Context-Id, and Content-Type on the decisions call | | no token, no context | Accept, and Content-Type on the decisions call |

Errors

403 classifies as NO_ACCESS_IN_APP; every other non-2xx, a network failure and a body that is not JSON classify as UNAVAILABLE. Everything is thrown as the core's AuthorizationTransportError.

classifyError(status, body) overrides that, and returning undefined falls through to the default. It receives the parsed body when the body parsed, so a backend that distinguishes several kinds of 403 can be understood by its own error code without this package knowing any of them:

classifyError: (status, body) =>
  status === 403 && (body as { code?: string })?.code === "CONTEXT_EXPIRED"
    ? "UNAVAILABLE"
    : undefined,

Nothing from a response body ever reaches a thrown message — nor does the token, nor the header value. The message is built from the route and the status and from nothing else. The core removed its own reason field precisely so that server text could not arrive one render away from a screen; this does not reintroduce the leak from below.

onDiagnostic

createHttpTransport({ baseUrl, getToken, onDiagnostic: (event) => log(event) });

Optional, and nothing the transport sends, returns or rejects with depends on whether you give it. Every call that rejects with an AuthorizationTransportError raises one event, { kind: "call-failed", operation, reason }, a frozen object: operation is permissions for fetchPermissions and decisions for fetchDecisions, and reason is the rule that refused it.

| reason | the rule | |---|---| | no-response | no response arrived: the token could not be obtained, or the request did not complete | | status | the response status is not in the 2xx range | | not-json | the body is not JSON | | not-an-object | the body is not an object | | no-app | the body's app is missing or not a string | | no-list | the body's permissions or decisions is missing or not an array | | no-context-echo | a contextId was configured and the echo field is missing or not a string | | other-context | the echo field echoes a different context |

Every field holds a value this package built. No event holds a token, a header, a route, a status, a body, or anything else read out of a response. A RangeError — a path or an application id refused at call time — raises none.

It is called in a task of its own. Each event is handed over through setTimeout, never from inside a call of this package, and nothing this package does waits for it: what it returns is not read, so a promise it returns is not awaited and its rejection is not caught. What it throws is discarded, and the transport carries on as if it had not been called. Like any code, a callback that blocks the thread blocks everything that runs on it. It must be a function, or the constructor throws a RangeError.

A session built on this transport takes a callback of its own: the core's onDiagnostic tells what the session did with the rejection — a chunk that failed, a menu that became unavailable — and this one tells why the call was refused.