Release 1.4.0: shared @simpleworkjs/* packages; fix discovery envelope drift + client_secret_hash leak

Rewire onto @simpleworkjs/directory-schema, /ldap, and /app-stack. The
directory discovery API now returns the {results} envelope via explicit
/resources, /resources/:slug, /graph, /me handlers and routes every read
through projectResource/projectResources, which unconditionally strips
client_secret_hash (and any /secret|password|privatekey/i key) and reduces
metadata to a public allowlist for non-admins — closing the leak where the ORM
serialized metadata wholesale. The dead routes/api_discovery.js (mounted after
the 404 catcher) is removed; ?group= now returns 200 instead of 404. user_ldap
+ group_ldap take escapeFilter/escapeDN + makeClient/withClient from the shared
ldap package (posix/write-side stays app-local; cert validation unchanged).
build_info unified to {buildVersion,buildHash,buildYear}; ldapts ^8.1.8. New
tests/discovery.test.js locks in the envelope + no-secrets guarantees. Lockfile
regenerated from the registry (no file:/link:).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-25 16:39:10 -04:00
parent 82da47cef7
commit fc0d9104d0
12 changed files with 291 additions and 149 deletions
+4 -1
View File
@@ -3,6 +3,7 @@ const router = require('express').Router();
const permission = require('../utils/permission');
const { Resource, ResourceEdge, ResourceGroup } = require('../models/resource');
const { Group } = require('../models/group_ldap');
const { projectResources } = require('@simpleworkjs/directory-schema');
// Require the admin group
router.use(async (req, res, next) => {
@@ -18,7 +19,9 @@ router.use(async (req, res, next) => {
router.get('/resources', async (req, res, next) => {
try {
const resources = await Resource.list();
res.json({ results: resources });
// Even admins never receive secret metadata (e.g. client_secret_hash) over
// the wire; projectResources strips it unconditionally.
res.json({ results: projectResources(resources, { fullMetadata: true }) });
} catch (err) { next(err); }
});
-36
View File
@@ -1,36 +0,0 @@
'use strict';
const router = require('express').Router();
const { Resource, ResourceGroup } = require('../models/resource');
// GET /api/discovery/me
// Returns the list of resources the current user has access to.
router.get('/me', async (req, res, next) => {
try {
const userGroups = req.user.groups || []; // array of LDAP group CNs
const accessibleResourceIds = new Set();
if (req.user.isMachine) {
// Machines only have access to themselves by default
accessibleResourceIds.add(req.resourceId);
} else {
// End users get access via groups
const allGroups = await ResourceGroup.list();
for (const rg of allGroups) {
if (userGroups.includes(rg.groupCn)) {
accessibleResourceIds.add(rg.resourceId);
}
}
}
// Fetch all resources and filter
const allResources = await Resource.list();
const accessible = allResources.filter(r => accessibleResourceIds.has(r.id) || r.metadata?.isPublic);
res.json({ results: accessible });
} catch (err) {
next(err);
}
});
module.exports = router;
+78 -3
View File
@@ -1,4 +1,79 @@
const autoRouter = require('./autoRouter');
const { Resource } = require('../models/resource');
'use strict';
module.exports = autoRouter(Resource);
// Public directory discovery API. Mounted at /api/discovery (app.js, before
// the 404 catcher). Every response uses the `{ results }` envelope and the
// security projection from @simpleworkjs/directory-schema, so secrets (e.g. an
// OAuth client's client_secret_hash) never leave the server and non-admins only
// see the public metadata allowlist.
//
// This replaces the autoRouter mount (which returned bare arrays — the shape
// jump-host's `data.results || []` silently collapsed to `[]`, so no user could
// bridge) and absorbs the dead /me handler that used to live in
// routes/api_discovery.js (mounted after the 404, so unreachable).
const router = require('express').Router();
const { Resource, ResourceGroup } = require('../models/resource');
const {
envelope,
projectResource,
projectResources,
isDirectoryAdmin,
} = require('@simpleworkjs/directory-schema');
// GET /api/discovery/resources[?kind=&group=&parent=]
router.get('/resources', async (req, res, next) => {
try {
const resources = await Resource.search(req.query);
res.json(envelope(projectResources(resources, { fullMetadata: isDirectoryAdmin(req.user) })));
} catch (err) { next(err); }
});
// GET /api/discovery/resources/:slug
router.get('/resources/:slug', async (req, res, next) => {
try {
const resource = await Resource.getBySlug(req.params.slug);
// parents/children are edges (no secrets); project only the resource body.
const projected = projectResource(resource, { fullMetadata: isDirectoryAdmin(req.user) });
projected.parents = resource.parents;
projected.children = resource.children;
res.json(envelope(projected));
} catch (err) { next(err); }
});
// GET /api/discovery/graph
router.get('/graph', async (req, res, next) => {
try {
const graph = await Resource.getGraph();
res.json(envelope({
resources: projectResources(graph.resources, { fullMetadata: isDirectoryAdmin(req.user) }),
edges: graph.edges,
}));
} catch (err) { next(err); }
});
// GET /api/discovery/me
// Returns the resources the current caller can reach. Machines see only their
// own resource; humans get the union of their LDAP groups' resources plus
// anything flagged isPublic. Uses req.user.groups (populated by the auth
// middleware for session/PAT callers) rather than re-querying LDAP by DN, so it
// works for every auth transport without assuming a .dn is present.
router.get('/me', async (req, res, next) => {
try {
let accessible;
if (req.user && req.user.isMachine) {
accessible = await Resource.list({ where: { id: req.resourceId } });
} else {
const userGroups = (req.user && req.user.groups) || [];
const ids = new Set();
if (userGroups.length) {
const rgs = await ResourceGroup.list({ where: { groupCn: { in: userGroups } } });
for (const rg of rgs) ids.add(rg.resourceId);
}
const all = await Resource.list();
accessible = all.filter(r => ids.has(r.id) || (r.metadata && r.metadata.isPublic));
}
res.json(envelope(projectResources(accessible, { fullMetadata: isDirectoryAdmin(req.user) })));
} catch (err) { next(err); }
});
module.exports = router;
+7 -14
View File
@@ -10,6 +10,7 @@ const {InviteToken, PasswordResetToken} = require('./../models/token');
const {Tos} = require('../models/tos');
const conf = require('@simpleworkjs/conf');
const buildInfo = require('../utils/build_info');
const { mountStaticModules } = require('@simpleworkjs/app-stack');
const values ={
title: conf.environment !== 'production' ? `dev` : '',
@@ -20,24 +21,16 @@ const values ={
}
// List of front end node modules to be served
const frontEndModules = ['bootstrap', 'mustache', 'jquery', '@fortawesome',
'moment', '@popper', 'jq-repeat',
];
// Server front end modules
// https://stackoverflow.com/a/55700773/3140931
// Vendor libraries only change when package versions are bumped (a rebuild),
// so they're safe to cache aggressively; ETag/Last-Modified (on by default)
// still cover that rare case with a cheap 304 instead of a stale asset.
frontEndModules.forEach(dep => {
router.use(`/static-modules/${dep}`, express.static(path.join(__dirname, `../node_modules/${dep}`), {maxAge: '7d'}))
// still cover that rare case with a cheap 304 instead of a stale asset. The
// app's own JS/CSS/img from public/ gets a shorter maxAge since it changes on
// every deploy and isn't cache-busted/fingerprinted.
mountStaticModules(router, {
root: path.join(__dirname, '..'),
deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', '@popper', 'jq-repeat'],
});
// Have express server static content( images, CSS, browser JS) from the public
// local folder. Shorter maxAge than /static-modules since this is the app's
// own JS/CSS, which changes on every deploy and isn't cache-busted/fingerprinted.
router.use('/static', express.static(path.join(__dirname, '../public'), {maxAge: '1h'}))
// Public health endpoint for container/orchestration healthchecks.
// Mounted at / (no auth) in app.js, so this is intentionally unauthenticated.
router.get('/health', function(req, res) {