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

@portalsdk/config

v0.2.1

Published

Type-safe authoring for your Portal config file — channels, authorization, message middleware, and notifications.

Readme

@portalsdk/config

Type-safe authoring for your Portal config file — channels, authorization, message middleware, and notifications, all checked and auto-completed as you write them.

npm install -D @portalsdk/config

Quick start

Create a portal.config.ts at the root of your project and export a config from defineConfig:

// portal.config.ts
import { defineConfig } from "@portalsdk/config";

export default defineConfig({
  channels: {
    "room-*": { anonymous: false },
  },
});

Every channel works with no configuration at all — a channel with no entry uses the defaults (standard mode, anonymous access allowed, no authorization, no middleware). Config entries exist only to override specific channels, so an empty config, or no config file at all, is perfectly valid.

Channels

Keys are either an exact channel id or a template ending in *:

export default defineConfig({
  channels: {
    announcements: { mode: "broadcast" }, // exact id
    "room-vip-*": { anonymous: false }, // most specific template wins
    "room-*": { anonymous: true },
  },
});

When more than one key could match a channel, the exact id wins; otherwise the most specific template does (the one with the longest fixed prefix).

Each channel accepts:

| Field | Meaning | | --- | --- | | mode | "standard" (default) or "broadcast". Fixed when a channel is first created. | | anonymous | Whether anonymous users may connect. Defaults to true. | | access | Who may connect: "open", "membership", or "authz". See Access. | | authz | Authorize each connection and assign its capabilities. | | onPublish | Middleware run on every published message. | | onDisconnect | Callbacks run when a connection ends. | | notify | Turn selected messages into notifications. | | extensions | Attach extensions to the channel. |

Authentication

By default, tokens are minted by Portal. To verify JWTs you issue yourself, add an auth block and map your token's claims onto Portal identity fields:

export default defineConfig({
  auth: {
    issuer: "https://your-app.example.com",
    jwksUrl: "https://your-app.example.com/.well-known/jwks.json",
    claimMap: {
      userId: "sub", // required
      username: "name",
      anon: "public_metadata.guest",
    },
  },
});

Only userId is required. Add further entries to map additional claims by dotted path ("public_metadata.role"); each becomes a claim of the same name on the connected session, readable from ctx.claims in authz and middleware.

The block is per project, and it is a switch, not an addition: once an environment deploys an auth block, its users authenticate only with your JWTs. Portal-minted tokens stop being accepted there, so deploy the block and cut over together.

What your tokens must look like

| Requirement | Value | | --- | --- | | Signing algorithm | RS256, RS512, or ES256 | | RSA key size | 2048 bits or larger | | alg header | Required | | kid header | Optional — sent, it selects the key; omitted, the JWKS must hold exactly one usable key | | iss claim | Must equal issuer | | exp claim | Required. A token with no expiry is rejected | | jwksUrl | Must be https and publicly resolvable |

Symmetric algorithms (HS256 and friends) are not accepted: Portal never holds your signing key, so there is nothing to verify them with.

Presenting the token

Your client passes its JWT wherever it would have passed a Portal-minted one. It must also send your publishable key (pk_…), which the SDK does automatically — that key is what tells Portal which environment, and therefore which issuer and JWKS, to verify against. Your kid is yours, so it cannot carry that.

Rotation and availability

Key rotation needs no deploy: Portal caches your JWKS for ten minutes and refetches immediately on a kid it has not seen. If your JWKS endpoint becomes unreachable, connections to that environment are refused while it is down — the verification is not skipped and no session is admitted unverified.

Access

access decides who may connect to a channel:

| Value | Who gets in | | --- | --- | | "open" | Anyone who passes the platform's checks. | | "membership" | Only users you have added with members.add. Everyone else is refused not_member before your code runs. | | "authz" | Whoever your authz callback admits — and admitting them joins them to the channel. |

export default defineConfig({
  channels: {
    "chn_*": {
      anonymous: false,     // no anonymous users
      access: "authz",      // your callback is the gate
      authz: (ctx) => (canRead(ctx.claims.userId) ? allow({ publish: true }) : block("No access.")),
    },
  },
});

access: "authz" is the option to reach for when your own system already knows who may see what. Nothing has to be synchronised into Portal ahead of time: the first time a user connects, your callback decides, and a user it admits becomes a member — so they appear in the roster, get an inbox entry for the channel, and can receive send({to}) messages and @everyone.

Three things to know about it:

  • It needs an authz callback. Deploying access: "authz" without one is an error — otherwise nothing would decide.
  • Removing a member does not keep them out. Their next connection re-joins them if authz still allows it. To keep someone out, ban them.
  • Broadcast channels have no membership, so they do not take access at all. An authz callback still gates their connections.

Omit access and it follows anonymous: "membership" when you set anonymous: false, "open" otherwise. That is only for backwards compatibility with configs written before access existed — say which you want.

Authorization

An authz callback runs once, when a user connects. Return allow(capabilities) to admit them with a fixed set of permissions, or block(reason) to refuse:

import { defineConfig, allow, block } from "@portalsdk/config";

export default defineConfig({
  channels: {
    "room-*": {
      authz: (ctx) => {
        if (ctx.claims.anon) return block("Sign in to join this room.");
        return allow({ publish: true, sendDirect: true });
      },
    },
  },
});

Capabilities are your source of truth for what a session may do. Alongside the built-in publish and sendDirect flags you can add your own named capabilities and read them later in middleware — Portal carries them for you. Roles and permissions are entirely yours: they live only inside this callback and in the capabilities you return.

If an authz callback throws or times out, the connection is refused.

Message middleware

onPublish middleware run in order on every published message. Each step returns allow(), block(reason), or mask(content), and the first step that does not allow() ends the chain:

import { defineConfig, defineMiddleware, allow, block, mask } from "@portalsdk/config";

interface ChatMessage {
  body: string;
}

const moderate = defineMiddleware<ChatMessage>("publish", (ctx) => {
  if (!ctx.capabilities.publish) {
    return block("You do not have permission to post here.");
  }

  const text = ctx.message.content.body;
  if (text.includes("badword")) {
    return mask<ChatMessage>({ body: text.replaceAll("badword", "****") });
  }

  return allow();
});

export default defineConfig({
  channels: {
    "room-*": { onPublish: [moderate] },
  },
});
  • block(reason) stops the message. The reason is shown to the sender, so write it as end-user copy.
  • mask(content) lets the message through but replaces its content before anyone sees it. The replacement flows to the rest of the chain and every recipient; the original content is not stored.

Deferred work and retracting

Register defer() work to run after a message is delivered. A deferred callback may return retract() to take the message back — recipients replace it in place, and it is left out of history and replay:

defineMiddleware("publish", (ctx) => {
  ctx.defer(async () => {
    if (await isSpam(ctx.message)) return retract("Removed after review.");
  });
  return allow();
});

Use notify() for fire-and-forget follow-up work once the outcome is final:

ctx.notify(async (outcome) => {
  if (outcome.action === "block") await log(outcome.reason);
});

Disconnect callbacks

onDisconnect callbacks run when a connection ends. They observe only — they cannot reject anything:

const onLeave = defineMiddleware("disconnect", (ctx) => {
  ctx.notify(async () => track(ctx.sender.id, ctx.reason));
});

Notifications

A notify bridge turns selected messages into notifications for their recipients. Return a descriptor to create one, or null to leave the message as an ordinary message:

export default defineConfig({
  channels: {
    "room-*": {
      notify: (ctx) => {
        const mentions = ctx.message.mentions ?? [];
        if (mentions.length === 0) return null;
        return {
          title: "You were mentioned",
          data: { messageId: ctx.message.id },
          to: mentions.map((m) => m.userId),
        };
      },
    },
  },
});

By default the recipient is the message's to; set to on the descriptor to override or fan out to several users.

Secrets

Read a project secret from inside a callback with env(). Set secrets with portal secrets set NAME; the value is resolved when your deployed callbacks run and is never written into your configuration:

import { defineMiddleware, allow, block, env } from "@portalsdk/config";

defineMiddleware("publish", async (ctx) => {
  const flagged = await moderate(ctx.message.content, env("MODERATION_API_KEY"));
  return flagged ? block("This message was held for review.") : allow();
});

env() throws MissingSecretError if the named secret has not been set.

Extensions

Extensions add their own message types to a channel. Attach one by mapping a handle you choose to the source file that implements it:

export default defineConfig({
  channels: {
    "room-*": {
      extensions: {
        polls: "src/extensions/polls.ts",
      },
    },
  },
});

An extension declares what it owns through a static manifest. Use defineExtension so that manifest is type-checked:

import { defineExtension, type ExtensionManifest } from "@portalsdk/config";

class Polls {
  static manifest: ExtensionManifest = {
    namespace: "poll.", // every message type this extension owns starts with "poll."
    transport: "ws",
  };
  // ...
}

export default defineExtension(Polls);

Typing message content

Pass your message type to defineMiddleware to type ctx.message.content:

interface ChatMessage {
  body: string;
  attachments?: string[];
}

defineMiddleware<ChatMessage>("publish", (ctx) => {
  ctx.message.content.body; // typed as string
  return allow();
});

License

MIT