← The Store

Command Registry

One declarative list of every destination and action on the site.

RegistriesLiveJavaScript
60commands

What it is

A single source of truth for the site’s stable destinations and actions, role-gated and grouped. The nav, footer sitemap, omnibar and concierge all read it — so they never re-derive their own lists.

Take it with you

The real, committed source behind this system — copy it or download the file. Plus a portable spec of everything on this page.

registry.mjsjavascript151 lines

The generic, domain-free command-registry core (createCommandRegistry) — one action list, many surfaces.

// Generic, domain-free command-registry core.
//
// This is the reusable machinery behind a "command registry" — one declarative
// source of truth for "things a user can do" that several surfaces (menus, a
// Cmd-K palette, an agent) read from instead of each re-deriving an action list.
// Heddle (`src/lib/heddle/commands.mjs`) was the first consumer; the main site
// (`src/lib/site/commands.mjs`) is the second. Built three times, an action list
// drifts three ways — so the algorithm lives here once and each domain supplies
// only its command set, its role lattice, and (optionally) its scope rule.
//
// Design rules (carried over from the Heddle UX-014 substrate):
//   - Declarative & pure. No server-only imports, so a client surface can import
//     a registry without bundling a database client. Role filtering here is for
//     *display* — real enforcement still happens server-side in each route.
//   - Handlers are thin. A handler only describes the route to hit or where to
//     navigate; it contains no business logic.
//   - Role filtering is centralized. Surfaces call `commandsForContext` and get
//     an already-filtered list; they must not re-check `minRole`.
//   - `write` commands declare a `consequencePreview` so every mutating command
//     can route through a shared confirmation component.
//
// Each command declares:
//   id                 stable identifier
//   label              user vocabulary, never machine idiom
//   keywords           extra search terms for a palette
//   scope              a domain scope string ("global" always shows)
//   minRole            a key of the domain's role lattice
//   kind               one of COMMAND_KINDS
//   costClass          one of COST_CLASSES (AI spend class)
//   destructive        optional bool — revokes access or takes work offline
//   handler(ctx)       thin wrapper → a request/navigate/client descriptor
//   consequencePreview(data)  optional (required for write) → preview object

// "write" is a server mutation (an HTTP request descriptor); "action" is a
// client-side effect (e.g. a theme toggle) that hits no route. Both are
// non-navigation, so a menu groups them together unless `destructive`.
export const COMMAND_KINDS = ["navigate", "read", "write", "action"];
export const COST_CLASSES = ["none", "light", "heavy"];

// Build an id → command lookup, rejecting duplicate ids defensively.
export function indexById(commands = []) {
  const byId = new Map();
  for (const command of commands) {
    if (byId.has(command.id)) {
      throw new Error(`Duplicate command id in registry: "${command.id}"`);
    }
    byId.set(command.id, command);
  }
  return byId;
}

// Compare a user's role against a command's minimum, using a domain lattice
// (e.g. { viewer: 0, editor: 1, owner: 2 } or { guest: 0, admin: 1 }).
export function roleSatisfies(roleRank, role, minRole) {
  if (!roleRank || !(role in roleRank) || !(minRole in roleRank)) return false;
  return roleRank[role] >= roleRank[minRole];
}

// Default scope visibility: a "global" command shows in every context; any other
// scope must match the surface's context scope exactly. This faithfully
// reproduces Heddle's original rule (exam-scoped commands hidden outside an exam)
// while generalising to any scope vocabulary. Domains may inject an override.
export function defaultScopeMatches(commandScope, contextScope) {
  return commandScope === "global" || commandScope === contextScope;
}

/**
 * Bind the generic algorithm to a domain's command set + role lattice.
 *
 * @param {object} config
 * @param {Array}  config.commands     the domain's command array
 * @param {object} config.roleRank     role → numeric rank lattice
 * @param {(commandScope: string, contextScope: string) => boolean} [config.scopeMatches]
 * @returns the bound registry API (same surface every consumer relies on)
 */
export function createCommandRegistry({
  commands = [],
  roleRank,
  scopeMatches = defaultScopeMatches,
} = {}) {
  const byId = indexById(commands);

  function getCommandById(id) {
    return byId.get(id) || null;
  }

  /**
   * Is this command available to a user with `role` in the given `scope`?
   * Surfaces must not re-check `minRole` — this is the single gate.
   */
  function canRunCommand(command, { role, scope } = {}) {
    if (!command) return false;
    if (!scopeMatches(command.scope, scope)) return false;
    return roleSatisfies(roleRank, role, command.minRole);
  }

  /**
   * The role-filtered command list for a surface — the single place permission
   * filtering happens.
   *
   * @param {object} context        { role, scope }
   * @param {object} [opts]
   * @param {string} [opts.search]  case-insensitive match over label + keywords
   * @param {string} [opts.kind]    restrict to one kind
   */
  function commandsForContext(context = {}, opts = {}) {
    const { search, kind } = opts;
    const needle = typeof search === "string" ? search.trim().toLowerCase() : "";

    return commands.filter((command) => {
      if (!canRunCommand(command, context)) return false;
      if (kind && command.kind !== kind) return false;
      if (needle) {
        const haystack = [command.label, ...(command.keywords || [])]
          .join(" ")
          .toLowerCase();
        if (!haystack.includes(needle)) return false;
      }
      return true;
    });
  }

  /**
   * Resolve a command's thin handler into a request/navigate/client descriptor.
   * `ctx` supplies route params and an optional body.
   */
  function resolveCommand(command, ctx = {}) {
    if (!command || typeof command.handler !== "function") {
      throw new Error(`Command "${command?.id ?? "?"}" has no handler`);
    }
    return command.handler(ctx);
  }

  /**
   * Build a command's consequence preview from the data it needs, or null if the
   * command declares none. Write commands always declare one.
   */
  function commandConsequence(command, data = {}) {
    if (!command || typeof command.consequencePreview !== "function") return null;
    return command.consequencePreview(data);
  }

  return {
    COMMANDS: commands,
    getCommandById,
    canRunCommand,
    commandsForContext,
    resolveCommand,
    commandConsequence,
  };
}

Where it lives

  • src/lib/site/commands.mjs
  • src/lib/command-registry/registry.mjs

See it in action

Architecture map

FAQ

What reads the command registry?

The top nav, the footer sitemap, a Cmd-K omnibar and the concierge — all from one declarative list, so they stay in sync.

Part of these stacks

Related systems

Add-ons RegistryOne catalog that powers the marketplace and the public tools page.Utilities RegistrySixty-plus client-side browser tools, each its own SEO page.Architecture MapA living, interactive map of how the whole system communicates.

Explore the full catalog →

Want a system like this built for you?Work with me →