The shared contract between the SSO directory (sso-manager-node) and its clients (jump-host, and anything else querying who-can-reach-what). Pure JS, no runtime dependencies.
It provides:
- The schema — the resource
kindenum (site | host | service | oauth), the metadata convention keys (withadmin/secretflags), and the ORM field definitions forResource/ResourceEdge/ResourceGroup. - The
{ results }envelope —envelope(x)/unwrapEnvelope(body). The discovery API returns{ results: [...] }for every list/detail endpoint; the client validates it on every call. - The security projection —
projectResource(r, {fullMetadata})/projectResources(arr, opts)/isDirectoryAdmin(user). Unconditionally strips secrets (e.g. an OAuth client'sclient_secret_hash) and, for non-admins, restrictsmetadatato a public allowlist. - The discovery client —
createDirectoryClient({baseUrl, apiToken, fetch?})withgetResourcesByGroup,getResourceBySlug,getGraph,getMyAccess,getAccess.
Two real problems this exists to fix:
- Security —
Resource.getGraph()/Resource.list()serializemetadatawholesale, so/api/discovery/*returned the OAuthclient_secret_hashto any authenticated caller.projectResourcestrips it (and any key flaggedsecret: true, plus unknown secret-ish keys) in every response path. - Contract drift — the discovery autoRouter returned a bare array, but jump-host did
data.results || [], which silently collapsed to[]: no user could bridge. The envelope + the validating client turn that drift into a loud error.
npm install @simpleworkjs/directory-schemaconst { projectResources, projectResource, isDirectoryAdmin } = require('@simpleworkjs/directory-schema');
// GET /api/discovery/resources
const full = isDirectoryAdmin(req.user); // admin? -> keep all non-secret metadata
const resources = await Resource.search(req.query);
res.json({ results: projectResources(resources, { fullMetadata: full }) });const { createDirectoryClient } = require('@simpleworkjs/directory-schema');
const conf = require('@simpleworkjs/conf');
const directory = createDirectoryClient({
baseUrl: conf.sso.url,
apiToken: conf.sso.apiToken,
});
// Resources granted to a group (validates the { results } envelope)
const hosts = await directory.getResourcesByGroup('host_web01_access', { kind: 'host' });Every metadata key must be declared in METADATA_KEYS (lib/schema.js), including admin-only ones.
An undeclared key is not "unspecified" — it is invisible to every non-admin caller, because the non-admin path keeps only the declared public allowlist. And isDirectoryAdmin() is false for isMachine, so that includes every service token and every machine consumer.
This has now bitten twice (v1.1.0, v1.2.0), and both times the symptom looked like a logic error at the consumer rather than a schema omission: a blank field in the portal, a firewall rule with no port, a filter that silently matched everything. test/projection.test.js holds the list of keys the theta-suite reconcilers write, so a new one fails the suite rather than going quiet in production.
| flag | who sees it | when to use it |
|---|---|---|
| (none) | everyone, including machines | the caller needs it to reach, render, or reason about the resource |
admin: true |
directory admins only | infra internals with no consumer outside the admin UI |
secret: true |
nobody — stripped in both paths | credentials and hashes. Unknown keys matching /secret|password|privatekey/i are also stripped as defense in depth |
admin: true changes no behaviour on its own — the admin path already passes through anything not secret-named, and the non-admin path already dropped anything undeclared. Declare it anyway: the point is that METADATA_KEYS is a complete map of the contract, so the next person can see what exists and decide, rather than discovering a key by its absence.
MIT © William Mantly