@nodii/role-catalog
v0.14.0
Published
Boot-time permission-catalog publisher for Nodii module-services. Type-safe definePermissions + publishCatalogAtBoot wrapper (cold-start retry, idempotency, non-boot-blocking degrade + background repair per D410(3)) on top of the tenant-service RoleCatalo
Maintainers
Readme
@nodii/role-catalog
Boot-time permission-catalog publisher for Nodii module-services. Type-safe
definePermissions + publishCatalogAtBoot (cold-start retry, idempotency,
non-boot-blocking degrade + background repair) over the tenant-service
RoleCatalogService gRPC contract.
Spec: planning-hub feature_doc serviceId=nodii-libs docKey=role-catalog,
07-rbac-doctrine, D410(3).
A publish failure does not kill your process. Per D410(3) the catalog publish is best-effort and non-boot-blocking.
publishCatalogAtBootrecords a queryabledegradedstate, logs at ERROR, keeps retrying in the background, and lets boot continue. See Failure behaviour.Note:
07-rbac-doctrine.md§ 5 and therole-catalog.mdfeature_doc § 5.6 still contain the pre-D410 sentence "Service refuses to start…". That text is stale and contradicts the locked decision — driftb8c94370-0133-40fb-b645-0721c88e4fe7is open against both. Build to D410(3).
domain_key is NOT module — read this first
A permission key is <domain>.<resource>.<verb>[.<qualifier>], e.g.
crm.lead.view. That leading crm is the domain key: an RBAC permission
NAMESPACE.
It is not the sellable commerce module a tenant buys, even though the two frequently share a spelling. Two distinct things:
| Concept | What it is | Where it lives |
| --- | --- | --- |
| domain_key | RBAC permission namespace — first segment of every permission key | this library; PublishCatalogRequest.domain_key |
| module (commerce) | the sellable module / add-on a tenant subscribes to | the commerce catalog; surfaced here only as PermissionDef.entitlementKey |
They are wired together, deliberately and in exactly one place:
entitlementKey names the commerce module that UNLOCKS a permission.
Since 0.11.0 it is REQUIRED and there is no default (D655, implementing
D649 §3). The old rule — omitted ⇒ defaults to the permission's own
domain_key — is exactly why the two concepts got conflated, and why, before
0.7.0, both were called module_key. It is dead: a permission with no commerce
gate now says so explicitly with SUBSTRATE_ENTITLEMENT ("substrate"), and
"substrate" is a reserved namespace so nothing can publish a substrate.*
domain that aliases it.
If you are adding a permission, you are choosing a domain_key. If you are
deciding what a tenant has to pay for, you are choosing an entitlementKey.
Quick start
import {
definePermissions,
defineStarterRoles,
publishCatalogAtBoot,
} from "@nodii/role-catalog";
// entitlementKey is REQUIRED since 0.11.0 (D655 / D649 §3): name the sellable
// commerce module/add-on that unlocks the key, or SUBSTRATE_ENTITLEMENT if the
// key has no commerce gate at all. There is no default and no escape hatch.
export const CRM_CATALOG = definePermissions("crm", {
leadView: { resource: "lead", verb: "view", entitlementKey: "crm" },
leadCreate: { resource: "lead", verb: "create", entitlementKey: "crm" },
leadDelete: {
resource: "lead",
verb: "delete",
isCritical: true,
entitlementKey: "crm",
},
// Enforced by crm, but UNLOCKED by an add-on a tenant buys separately.
collectorRun: {
resource: "collector",
verb: "run",
entitlementKey: "crm.collector",
},
});
export const CRM_STARTER_ROLES = defineStarterRoles("crm", CRM_CATALOG, [
{
// `key` is the MACHINE IDENTITY (required since 0.11.0, D654). Rename the
// display name freely — identity does not move.
key: "crm_admin",
// `name` is DISPLAY-ONLY and must be Title Case. A slug here is rejected
// with a targeted error telling you to put it in `key`.
name: "CRM Administrator",
description: "Full access to CRM.",
// Concrete keys only — wildcards are rejected (NEW-BM-D7).
permissionKeys: [
CRM_CATALOG.leadView.key,
CRM_CATALOG.leadCreate.key,
CRM_CATALOG.leadDelete.key,
],
roleClass: "admin",
},
]);
// Does NOT throw on a publish failure. Awaiting it is correct: it awaits ONE
// attempt so a healthy boot is fully published, then degrades + retries in the
// background if that attempt failed.
const state = await publishCatalogAtBoot({
domainKey: "crm",
catalogVersion: "1.0.0",
serviceOwner: "nodii-crm-service",
permissions: CRM_CATALOG,
starterRoles: CRM_STARTER_ROLES,
tenantServiceUrl: process.env.TENANT_SERVICE_GRPC_URL!,
logger, // recommended — routes the ERROR lines into your log pipeline
});
// state.status: "published" | "degraded"Scaffold the above into a service with
bunx @nodii/role-catalog migrate-gen <service-name>.
Failure behaviour
Default (onPublishFailure: "degrade") — a publish failure is loud but
non-fatal, per D410(3):
- the failure is recorded as durable, queryable state,
- every failed attempt logs at ERROR (the default logger is console-backed — there is no configuration in which this goes quiet),
- a bounded retry ladder (
5s, 15s, 30s, 60s, 120s) then a slow refresh (15 min) keeps re-attempting onunref'd timers, so an out-of-band repair heals the process with no restart and no redeploy, - boot continues and the service serves.
Surface the state from /health so a degraded catalog is visible without
scraping boot logs:
import { getCatalogPublishState } from "@nodii/role-catalog";
const catalog = getCatalogPublishState("crm");
// { status: "published" | "degraded" | "pending", attempts, lastError,
// terminal, retrying, publishedAt, response }Why degrading is safe — it is fail-CLOSED
A published catalog is what lets a tenant hold a permission key. If the publish never lands, the domain's keys are absent from tenant-service, the D544 roles→perms resolution yields nothing for them, and guarded routes 403. There is no path where skipping the publish grants access that a successful publish would have withheld.
Crashing instead would convert a partial, fail-closed degradation into a total
outage of surfaces that have nothing to do with RBAC — and it cannot fix a
FAILED_PRECONDITION, which is a data condition in tenant-service. That is
exactly how nodii-task-tracking ended up crash-looping 12 times on ECS with
its deploy pipeline blocked.
Do not wrap the call in a try/catch that exits. If you want the old behaviour, ask for it explicitly:
await publishCatalogAtBoot({ ...opts, onPublishFailure: "throw" });
// throws CatalogPublishBootFailed (carries the recorded state)A local authoring error (bad key grammar, reserved namespace, duplicate key,
oversize catalog) is recorded with terminal: true and is not retried — the
catalog is compiled into the build, so re-sending identical bytes can never
succeed. Fix it and redeploy.
Migrating 0.8.x → 0.9.0 (non-boot-blocking by default)
publishCatalogAtBoot now returns CatalogPublishState instead of
PublishCatalogResponse, and no longer throws by default. The return-type
change is deliberate: it makes the behaviour flip a compile-time migration
rather than a silent runtime one.
| 0.8.x | 0.9.0 |
| --- | --- |
| const res = await publishCatalogAtBoot(o) | const st = await publishCatalogAtBoot(o); st.response |
| throws on failure (default) | degrades; pass onPublishFailure: "throw" to keep throwing |
| failFastOnVersionMismatch: true | onPublishFailure: "throw" (old spelling still honoured) |
| failFastOnVersionMismatch: false — still threw, with the raw grpc error | genuinely degrades |
| — | publishCatalogOnce — the raw single-shot publish, always throws a typed error |
Passing both onPublishFailure and failFastOnVersionMismatch with conflicting
meanings throws — this library will not guess whether a publish failure
should kill your process.
If your service hand-rolled a try/catch around the publish to keep boot alive,
delete it — the library owns that now, and your catch is likely narrower
(most only absorbed cold-start exhaustion, not the FAILED_PRECONDITION that
actually took task-tracking down).
0.11.0 DELETED the 0.6.x → 0.7.0 module_key compat layer
src/compat.ts is gone, and with it the {domainKey} | {moduleKey} option
unions, the warn-once deprecation shim, resetDeprecationWarnings, the
NODII_ROLE_CATALOG_SUPPRESS_DEPRECATIONS env lever, and the deprecated aliases
(validateModuleKey, isSubstrateModuleKey, isModuleKeyReserved,
serviceNameToModuleKey, SUBSTRATE_MODULE_KEYS, SubstrateModuleKey,
InvalidModuleKey).
domainKey is the only spelling. This was grep-verified before deletion: every
live publish call site in the fleet already passes it, and the remaining
moduleKey: hits are structured-LOG fields with their own deprecation comments
(kyc, notification, task-tracking), not options.
The two 0.7.0 changes that a shim could never have rescued are unchanged and still worth knowing about, because they change by VALUE and nothing flags them:
ValidationFailureKind"invalid_module_key"→"invalid_domain_key"is a TELEMETRY LABEL. A panel filtering the old literal renders fine and matches nothing.emitLegacyValidationFailureKind: truestill dual-emits while you migrate (off by default — it double-counts).InvalidPermissionSegment.which"module_key"→"domain_key". The old literal is retained in the union soe.which === "module_key"still compiles; it just stops matching.
By contrast, the 0.11.0 labels (invalid_entitlement_key,
starter_role_invalid_key, starter_role_name_not_title_case,
starter_role_duplicate_key, namespace_validation_rejected) are purely
ADDITIVE — nothing is renamed, so no existing panel goes dark.
Dev commands
bun run --cwd ts/role-catalog typecheck
bun run --cwd ts/role-catalog test
bun run --cwd ts/role-catalog build
# cross-language byte-equivalence gate (needs the local stack: bun run stack:up)
bash synthetic-consumers/role-catalog/parity-fence/run-parity-fence.sh