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

@fepro/workhub-app-backend

v0.6.0

Published

Server-side SDK for WorkHub developer workspaces. defineWorkspace() to declare routes + scopes; run() boots a JWT-verifying HTTP server that the WorkHub proxy calls into.

Readme

@fepro/workhub-app-backend

Server-side SDK for WorkHub developer workspaces. Declare your routes and scopes; the SDK boots an HTTP server, verifies the WorkHub identity token on every request, and hands your handler a fully-authenticated context with a tenant-scoped database client.

Pairs with @fepro/workhub-app-sdk on the frontend, which calls into your routes via workhub.backend.fetch().

Install

npm install @fepro/workhub-app-backend
# or
pnpm add @fepro/workhub-app-backend

Quick start

// backend/workspace.config.ts
import { defineWorkspace, run } from '@fepro/workhub-app-backend';

const workspace = defineWorkspace({
  key: 'crm-x',
  scopes: [
    'crm-x:leads:read',
    'crm-x:leads:write',
  ],
  routes: {
    'GET /leads': {
      scope: 'crm-x:leads:read',
      handler: async (ctx) => {
        return ctx.db`
          select id, name, email
          from leads
          where tenant_id = ${ctx.tenantId}
        `;
      },
    },

    'POST /leads': {
      scope: 'crm-x:leads:write',
      handler: async (ctx) => {
        const body = ctx.request.body as { name: string; email: string };
        const [row] = await ctx.db`
          insert into leads (tenant_id, name, email)
          values (${ctx.tenantId}, ${body.name}, ${body.email})
          returning id, name, email
        `;
        return row;
      },
    },

    'GET /leads/:id': {
      scope: 'crm-x:leads:read',
      handler: async (ctx) => {
        const [row] = await ctx.db`
          select * from leads
          where tenant_id = ${ctx.tenantId} and id = ${ctx.request.params.id}
        `;
        if (!row) {
          throw new Error('not_found');
        }
        return row;
      },
    },
  },
});

run(workspace);

That's the whole backend. No Express, no manual JWT handling, no auth middleware — defineWorkspace declares the contract, run boots the HTTP server and hooks JWT verification + scope enforcement into every request.

What's in v0.2

This release pulls every cross-cutting concern that workspace teams were re-implementing on their own back into the SDK. Each item is opt-in (except the always-on request-id / JSON logging) — if you don't use it, nothing changes versus v0.1.

| feature | what it gives you | opt-in via | | --- | --- | --- | | AppError envelope | structured { code, message, requestId } 4xx/5xx | throw errors.notFound('...') | | Request-id correlation | ctx.requestId + x-request-id echoed on every response | always-on, honors X-Request-Id header | | Structured JSON logs | one JSON line per ctx.log call with stable fields | always-on (replaces text logger) | | Idempotency-Key | dedupe POST/PUT/PATCH retries with stored response | idempotent: true on the route | | Rate limit + body cap | 600 req/min/tenant token bucket; 1 MB body limit | limits.rateLimiter / limits.maxBodyBytes | | Nestable db.begin | inner db.begin(...) maps to savepoints automatically | use withTenantContext | | Statement timeout | pool-wide PG statement_timeout set at startup | DATABASE_STATEMENT_TIMEOUT_MS env or database.statementTimeoutMs | | Migration runner | NNN_*.sql files applied on boot with hash tracking | database.migrationsPath | | Job queue | Postgres-backed jobs with retries + FOR UPDATE SKIP LOCKED | import { JobQueue } | | Scheduler | interval + 5-field cron, in-process | import { Scheduler } | | Event emit | ctx.events.emit('customer.created', {...}) → outbox → bridge | always-on | | Secrets accessor | ctx.secrets.get('STRIPE_API_KEY') with audit + missing-key enforcement | declare in secrets: [...] | | Health probes | run user-defined checks on /healthz, returns 503 if any fails | health.checks: [...] | | Both-case JWT claims | accepts tenant_id AND tenantId (and scope-string) | always-on |

Error envelope

import { defineWorkspace, errors } from '@fepro/workhub-app-backend';

defineWorkspace({
  routes: {
    'GET /leads/:id': {
      scope: 'crm-x:leads:read',
      handler: async (ctx) => {
        const [row] = await ctx.db`select * from leads where id = ${ctx.request.params.id}`;
        if (!row) throw errors.notFound('lead not found', { id: ctx.request.params.id });
        return row;
      },
    },
  },
  // ...
});

The client gets:

{ "__workhub_error": true, "code": "not_found", "message": "lead not found",
  "details": { "id": "abc" }, "requestId": "req_b1f3..." }

The frontend SDK's workhub.backend.call() rethrows this as a typed WorkhubError so apps can branch on err.code.

Idempotency

'POST /payments': {
  scope: 'crm-x:payments:write',
  idempotent: true,                      // ← header is honored on this route
  handler: async (ctx) => { /* ... */ },
},

The SDK ships a memory store (single-instance) and a Postgres store (multi-instance). Switch via idempotency.store:

import { createPostgresIdempotencyStore, getDb } from '@fepro/workhub-app-backend';

const db = getDb();
defineWorkspace({
  idempotency: { store: createPostgresIdempotencyStore(db) },
  // ...
});

Rate limit + body size

Defaults are 600 req/min per (tenant, app) and 1 MB body. Override:

limits: {
  maxBodyBytes:  256 * 1024,
  rateLimiter:   createTokenBucketLimiter({ capacity: 120, refillPerSecond: 20 }),
}
// or to disable rate limiting:
limits: { rateLimiter: false }

Migrations

database: { migrationsPath: 'migrations' }

On boot the SDK creates workhub_schema_migrations, hashes every file in the directory, and applies any new ones in lexicographic order. Editing an already-applied file throws — silent drift is one of the production red lines.

Jobs + scheduler

import { JobQueue, Scheduler, getDb } from '@fepro/workhub-app-backend';

const db = getDb();
const jobs = new JobQueue(db);
const sched = new Scheduler();

jobs.define<{ leadId: string }>({
  name: 'send-welcome-email',
  handler: async ({ job, log }) => { /* ... */ },
});

sched.define({
  name: 'nightly-cleanup',
  cron: '17 3 * * *',                    // 03:17 UTC daily
  handler: () => jobs.enqueue('cleanup', { what: 'old-temp-files' }).then(() => undefined),
});

jobs.start();
sched.start();

Jobs use FOR UPDATE SKIP LOCKED so multiple workspace instances coordinate without ever double-running. Failed handlers retry with exponential backoff up to maxAttempts (default 5), then transition to failed — no silent drop.

Events

'POST /leads': {
  scope: 'crm-x:leads:write',
  handler: async (ctx) => {
    const [row] = await ctx.db`insert into leads ... returning *`;
    await ctx.events.emit('customer.created', { id: row.id, displayName: row.name });
    return row;
  },
},

emit writes to workhub_events_outbox inside the request's DB connection. The platform's bridge polls the table, forwards each row over NATS as tenant.<id>.app.<app-key>.<kind>, and the frontend SDK delivers it to iframes that called workhub.events.on('app:customer.created', cb).

Secrets

Declare in the workspace config:

defineWorkspace({
  secrets: [
    { key: 'STRIPE_API_KEY',     description: 'Stripe secret key',  auditAccess: true },
    { key: 'WEBHOOK_SLACK_URL',  required: false },
    { key: 'PII_ENCRYPTION_KEY', scope: 'tenant', auditAccess: true },
  ],
  // ...
});

The platform deploy pipeline injects env vars matching the declared keys (STRIPE_API_KEY=...). tenant-scoped secrets are looked up first as PII_ENCRYPTION_KEY__<tenantId> before falling back to the unscoped key.

const stripe = new Stripe(ctx.secrets.get('STRIPE_API_KEY'));
const slack  = ctx.secrets.tryGet('WEBHOOK_SLACK_URL');   // null if absent

get throws if a required secret isn't present — fail loud rather than ship a undefined API key downstream.

Integrations (0.5)

Call the tenant's installed cluster integrations (e.g. BMS ML document AI) without ever touching their credentials. Declare what you use:

defineWorkspace({
  integrations: [{ provider: 'bms_ml', required: false }],
  // ...
});

Then call it from a handler:

// List the tenant's extracted invoices/documents.
const page = await ctx.integrations.call('bms_ml', 'documentList', { page: 1 });
//   → { count, next, previous, results: [{ id, filename, status, ... }] }

// One document's detail:
const doc = await ctx.integrations.call('bms_ml', 'documentGet', { id: 42 });

The SDK forwards the current request's identity token to the platform, which verifies it, finds the tenant's active cluster for the provider, decrypts the BYOC credential server-side, dispatches through the cluster adapter, and returns the result — your app never sees the credential.

ctx.integrations.available() is false when the platform didn't inject WORKHUB_API_URL (standalone/dev); call() then throws integrations_unavailable. Platform-side failures throw IntegrationCallError carrying .code (e.g. integration_not_installed, unknown_operation), .status, and .detail.

bms_ml operations (read slice): documentList, documentGet, trainingList, modelVersionList, ocrStatus, health.

Health probes

health: {
  checks: [
    { name: 'db',     check: async () => ({ ok: !!(await ctx.db`select 1`) }) },
    { name: 'worker', check: () => ({ ok: workerHeartbeatRecent() }) },
  ],
}

GET /healthz runs every probe in parallel (default 2s timeout each) and returns 200 / status: 'ok' if all pass, else 503 / status: 'degraded' with per-probe detail.

Logging

ctx.log now emits JSON lines:

{"ts":"2026-05-19T18:11:24.183Z","level":"info","msg":"lead inserted",
 "request_id":"req_b1f3...","tenant_id":"t_acme","app_key":"leads",
 "install_id":"i_42","workspace":"crm-x","lead_id":"l_900"}

Pass extra fields as the second argument:

ctx.log.info('lead inserted', { lead_id: row.id });

Use log.child({...}) to make a long-running operation's logs all share extra fields (handy inside jobs).

Streaming responses (SSE / NDJSON) — 0.4

Return streamingResponse({headers, start}) from a handler to take over the HTTP response. Designed for SSE (text/event-stream), NDJSON, or any other streaming protocol. The SDK doesn't constrain the framing; you write bytes via the sink.

import { defineWorkspace, streamingResponse } from '@fepro/workhub-app-backend';

defineWorkspace({
  routes: {
    'GET /events/stream': {
      scope: 'crm-x:events:read',
      handler: (ctx) => streamingResponse({
        headers: {
          'content-type':      'text/event-stream',
          'cache-control':     'no-store',
          'x-accel-buffering': 'no',
        },
        start: async (sink) => {
          // Resume from where the client disconnected — EventSource sends
          // Last-Event-ID on every reconnect.
          const since = parseSince(ctx.request.headers['last-event-id']);

          for await (const ev of tailTenantEvents(ctx, since)) {
            sink.write(`id: ${ev.id}\nevent: ${ev.kind}\ndata: ${JSON.stringify(ev)}\n\n`);
            if (sink.aborted) return;        // client disconnected
          }
        },
      }),
    },
  },
});

The sink exposes:

  • write(chunk) — string or Uint8Array; returns false on backpressure.
  • flush() — flush whatever's currently buffered (useful before a long idle window so intermediaries don't time out).
  • end() — terminate. Called automatically when start resolves.
  • onClose(cb) — register a callback for client disconnect (use it to break out of for await loops that read external sources).
  • aborted — boolean, true once the client connection is closed.

All pre-handler middleware (auth, scope check, tenant context, rate limit, body cap, request id, stmt timeout) runs identically. Idempotency is intentionally skipped on streaming routes — declare idempotent: false (or omit it).

Headers are flushed immediately after writeHead so the first event reaches the client before any intermediary times the idle connection out. The platform's apps-runtime proxy already passes streams through unbuffered.

Pair with new EventSource(url, { withCredentials: true }) on the frontend (the per-installation bundle cookie must travel with the stream request, otherwise the proxy 401s).

What the platform injects

When WorkHub deploys your container, it sets these environment variables. The SDK reads them automatically; you don't reference them directly:

| variable | what it is | | --- | --- | | PORT | port to listen on (default 3000) | | WORKHUB_JWKS_URL | JWKS endpoint the SDK uses to verify identity tokens | | WORKHUB_AUDIENCE | expected aud claim, e.g. workspace:crm-x | | WORKHUB_ISSUER | expected iss claim, e.g. https://api.workhub.io | | DATABASE_URL | populated when your manifest declares database.mode: 'platform-shared' |

For local development, set these yourself or use a dev WorkHub instance that mints test tokens against your container.

Local development

The deploy pipeline injects the env vars above in production. Locally you set them yourself. The one thing that trips people up is JWKS reachability: the SDK verifies every identity token against WORKHUB_JWKS_URL, and the dev WorkHub API serves that at http://localhost:4000/.well-known/jwks.json. How your backend reaches that URL depends on where it runs:

Recommended — run your backend directly on your machine (not containerized). Your backend shares localhost with the dev API, so the default URL just works. Set the env vars and start it:

export PORT=3000
export DATABASE_URL=postgres://…            # your local DB
export WORKHUB_JWKS_URL=http://localhost:4000/.well-known/jwks.json
export WORKHUB_ISSUER=https://api.workhub.io   # must match the dev API's issuer
export WORKHUB_AUDIENCE=workspace:<your-key>
node dist/index.js

This is the fastest inner loop — no image rebuild between edits.

If you run your backend in a container (Docker), localhost inside the container is the container, not your host. Point JWKS at the host gateway:

# macOS / Windows Docker Desktop:
WORKHUB_JWKS_URL=http://host.docker.internal:4000/.well-known/jwks.json
# Linux (no host.docker.internal): use the docker bridge gateway IP, e.g.
#   docker network inspect bridge --format='{{range .IPAM.Config}}{{.Gateway}}{{end}}'
WORKHUB_JWKS_URL=http://172.17.0.1:4000/.well-known/jwks.json

Don't have the platform running at all? Point the env vars at a shared staging instance (https://stg-api.<domain>/.well-known/jwks.json + that instance's issuer/audience) and exercise your backend with tokens it mints.

The issuer/audience must match what the token was minted with, or verification fails with 401 invalid X-WorkHub-Identity token — that error almost always means a JWKS/issuer/audience mismatch, not a code bug.

The Ctx object

Every route handler receives a single Ctx argument, fully populated from the verified JWT and the request:

interface Ctx {
  // Identity, signed by WorkHub
  tenantId:           string;
  userId:             string;
  appKey:             string;       // which app surface initiated the call
  scopes:             string[];     // scopes granted to that app
  workspaceInstallId: string;
  workspaceKey:       string;       // your workspace key
  requestId:          string;       // X-Request-Id or minted by SDK

  // Database — postgres tagged-template client (statement_timeout set on pool)
  db:  Sql;

  // Structured JSON logger; auto-includes request_id + tenant_id + app_key
  log: { info, warn, error };

  // Tenant-event emit — writes to outbox, platform bridge forwards to iframes
  events: { emit(kind: string, payload: object): Promise<string> };

  // Workspace secrets — declared in `secrets: [...]`, injected as env vars
  secrets: { get(key: string): string; tryGet(key: string): string | null };

  // Request data
  request: {
    method:    string;
    path:      string;
    query:     URLSearchParams;
    headers:   Record<string, string>;
    body:      unknown;
    rawBody:   string;                  // exact bytes the client sent (pre-parse)
    params:    Record<string, string>;  // from `:name` in the route key
    requestId: string;                  // same id as ctx.requestId
  };
}

Use ctx.tenantId to filter every query — it comes from the JWT, not from the request body, so it can't be spoofed. On the platform-shared deployment tier, this is what isolates your tenants.

Verifying signed webhooks — ctx.request.rawBody

ctx.request.body is the parsed value; ctx.request.rawBody is the exact bytes the client sent (empty string for GET/HEAD or a body-less request). Use rawBody whenever a byte-for-byte match matters — HMAC-over-body webhook signatures (Stripe, GitHub, Shopify) are computed over the bytes sent, and re-serialising body does not reproduce them (whitespace, escapes, and key order all drift). The Stripe secret comes from a declared workspace secret:

'POST /billing/webhook': {
  scope: 'bms-erp:billing:write',
  handler: async (ctx) => {
    const event = stripe.webhooks.constructEvent(
      ctx.request.rawBody,                          // not JSON.stringify(ctx.request.body)
      ctx.request.headers['stripe-signature'],      // headers are lower-cased
      ctx.secrets.get('STRIPE_WEBHOOK_SECRET'),
    );
    // event.type is now trustworthy …
    return { received: true };
  },
}

Outbound network (egress)

In production your container runs on an internet-isolated network — the only route out is the platform egress gateway, which permits only the destinations you declare (plus the platform's own: your database and the WorkHub API). Everything else is blocked at the network layer, so this is a real boundary, not a guardrail.

Declare what you need in workspace.json:

{
  "egress": {
    "policy": "allowlist",                 // or "deny-all" (the default)
    "allow": [
      { "host": "api.stripe.com",  "ports": [443] },
      { "host": "*.twilio.com",    "ports": [443] }   // single leading wildcard ok
    ]
  }
}

Hosts are domains (filtered by TLS SNI — no traffic decryption); IP literals aren't accepted (the platform adds your DB/API automatically). deny-all (the default when you omit the block) still reaches your database and the platform API — just nothing else.

Make outbound calls with ctx.fetch, not global fetch — it routes through the gateway (global fetch ignores proxy env and would fail):

const res = await ctx.fetch('https://api.stripe.com/v1/charges', { method: 'POST', /* … */ });

A call to a non-allowlisted host throws EgressDeniedError (with the blocked host) instead of hanging. Locally / standalone (no gateway configured), ctx.fetch is a transparent pass-through to global fetch.

Row-level security & the non-superuser role

On the platform-shared tier your database is one database shared by all tenants, isolated by your own RLS policies + ctx.tenantId filtering. RLS is only a real defense if the connection isn't a Postgres superuser — superusers (and BYPASSRLS roles) silently skip every policy, so a forgotten where tenant_id = … would leak across tenants instead of being blocked.

Two guarantees enforce this:

  • The platform provisions your role as NOSUPERUSER NOBYPASSRLS (also NOCREATEDB NOCREATEROLE). The role owns your database, so it can run any DDL inside it — create tables, indexes, your own RLS policies — but it is not a cluster superuser.
  • The SDK refuses to boot (in production) if ctx.db is connected as a superuser or a BYPASSRLS role. Set WORKHUB_ALLOW_SUPERUSER_DB=true to opt out (single-tenant apps that don't use RLS), or WORKHUB_REQUIRE_NON_SUPERUSER=true to enforce it in non-production too.

Extensions that need superuser

Because your role isn't a superuser, CREATE EXTENSION only works for trusted extensions (pg_trgm, btree_gin, citext, uuid-ossp, etc. on PG13+). For an untrusted extension, or a function that must be created SECURITY DEFINER as superuser, ask the platform to install it — the platform's admin role creates it and grants EXECUTE/USAGE to your workspace role. Your migration can then assume the extension exists (CREATE EXTENSION IF NOT EXISTS … is a no-op once the platform pre-installed it).

Routes

Route keys are "<METHOD> <PATH>":

routes: {
  'GET /leads':              { scope: '...', handler: ... },
  'GET /leads/:id':          { scope: '...', handler: ... },
  'POST /leads':             { scope: '...', handler: ... },
  'PATCH /leads/:id':        { scope: '...', handler: ... },
  'DELETE /leads/:id':       { scope: '...', handler: ... },
}

:name segments become ctx.request.params.name. The SDK matches exactly (no wildcards, no regex). For more complex routing, split into multiple workspaces.

Scopes

defineWorkspace enforces three rules at module-load time:

  1. Every scope listed in the workspace's top-level scopes array must start with <workspace.key>: (e.g. crm-x:*).
  2. Every route's scope must appear in the workspace's scopes array.
  3. Route keys must start with a valid HTTP verb.

Misconfiguration throws synchronously on import — you can't deploy a broken workspace.

At request time, the SDK rejects any call whose JWT doesn't carry the route's required scope (HTTP 403). The proxy controls which app surfaces get which scopes via the workspace + per-app manifest, so your backend only needs to declare what each route requires.

Database

If your workspace.json declares database: { mode: 'platform-shared' }, WorkHub provisions a dedicated database for the workspace and runs the SQL migrations from your bundle's migrations/ directory before starting your container. DATABASE_URL is set; ctx.db is a postgres client backed by it:

// Tagged template — values are parameterised, never interpolated.
const rows = await ctx.db`
  select * from leads
  where tenant_id = ${ctx.tenantId}
  order by created_at desc
  limit ${50}
`;

The client is shared across requests with a small connection pool (default max: 10, override with DATABASE_POOL_MAX).

For external databases, set database.mode: 'external' in your manifest and configure DATABASE_URL via the workspace's deploy_env.

Health

The SDK serves GET /healthz (or whatever manifest.backend.healthcheck specifies) returning 200 {"status":"ok","workspace":"<key>"} as soon as the server is listening. WorkHub's deploy pipeline polls this until 2xx before flipping the deployment to active.

Bundle layout

Your code lives in the workspace bundle. You don't write a Dockerfile — the platform compiles one from workspace.json#backend.runtime and runs the build for you. Letting tenants ship arbitrary Dockerfiles was a real production-time risk (root-as-build-user, secret exfil, arbitrary base images), so the platform owns it.

my-workspace.zip
├── workspace.json          ← declares runtime, port, scopes, apps
├── backend/                ← your source — platform builds this
│   ├── package.json
│   ├── package-lock.json   ← (or pnpm-lock.yaml / yarn.lock)
│   ├── workspace.config.ts ← exports defineWorkspace(...)
│   └── … your code
├── apps/
│   └── leads/
│       ├── workhub.json
│       └── dist/index.html
└── migrations/             ← optional, postgres only
    ├── 001_initial.sql
    └── 002_add_leads.sql

workspace.json declares how to build:

{
  "key":     "crm-x",
  "version": "1.0.0",
  "name":    "Acme CRM",
  "scopes":  ["crm-x:leads:read", "crm-x:leads:write"],
  "backend": {
    "runtime": "node:20",
    "build":   "npm run build",
    "start":   "node dist/index.js"
  },
  "apps": [{ "key": "leads", "manifest": "apps/leads/workhub.json" }]
}

Your server listens on process.env.PORT — the platform sets it. No need to specify a port, database engine, version, or any other infrastructure detail. Add "database": { "migrations_path": "migrations" } if you want SQL migrations applied on deploy.

Supported runtimes: node:20, node:22, bun:1, python:3.11, python:3.12. The platform pins each to a vetted alpine/slim base image, runs your install command (auto-detected from the lockfile — npm ci, pnpm install --frozen-lockfile, pip install -r ...), runs your optional build, then sets start as the container entry. The container always runs as a non-root workhub user.

After upload, the platform auto-builds the image, pushes to the registry, records the tag on workspace_version, and queues the version for operator review. You don't run docker build locally and you don't call mark-built by hand — that's automated.

Pushing the bundle: one call, backend + every frontend

The workhub_push MCP tool (or the same packer over POST /v1/developer/workspaces/upload) ships the backend AND every app in manifest.apps[] in a single upload — there is no separate route for the backend image and no per-app upload. Add an app by appending an { key, manifest } entry to workspace.json#apps and dropping its prebuilt assets under apps/<key>/ — the next workhub_push picks it up.

The packer runs each app's build script then includes its built surface (dist/ if present, else the app dir minus src/, lockfiles, config files, source maps). The backend ships as source — the platform compiles the Dockerfile from backend.runtime and builds the image — while each app ships prebuilt because the platform serves the static assets directly and does not run a frontend build. BMS-ERP (in-tree) is the reference: one bundle, one push, one backend + a POS app + an ERP app.

The API key must carry apps.develop_external (or platform.admin) to push a workspace bundle. apps.develop alone is rejected on this route.

License

MIT