@dreamforgestudiollc/emet 0.2.4 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +53 -0
- package/CHANGELOG.md +67 -3
- package/CHARTER.md +19 -7
- package/README.md +106 -21
- package/docs/AUTHORSHIP.md +3 -3
- package/docs/BOTS.md +4 -0
- package/docs/COVERAGE.md +2 -2
- package/docs/DEPLOY.md +9 -4
- package/docs/GUARDRAILS-AND-MONORAILS.md +2 -1
- package/docs/GUIDED-SETUP.md +8 -3
- package/docs/PERMISSIONS.md +6 -3
- package/docs/ROADMAP.md +25 -22
- package/docs/SETUP.md +8 -2
- package/docs/SUBKEYS.md +59 -0
- package/gems/artifact-index.json +12 -0
- package/gems/bot-team-backup/floor.json +15 -0
- package/gems/bot-team-backup/gem.json +43 -0
- package/gems/bot-team-backup/guides/BOT_TEAM_BACKUP.md +27 -0
- package/gems/bot-team-backup/guides/BOT_TEAM_RECOVERY.md +23 -0
- package/gems/fieldnote/floor.json +14 -0
- package/gems/fieldnote/gem.json +70 -0
- package/gems/fieldnote/runes/fieldnote.js +4 -0
- package/gems/lean-six-sigma/CHANGELOG.md +10 -0
- package/gems/lean-six-sigma/README.md +92 -0
- package/gems/lean-six-sigma/floor.json +31 -0
- package/gems/lean-six-sigma/gem.json +1296 -0
- package/gems/lean-six-sigma/guides/ASSUMPTIONS.md +45 -0
- package/gems/lean-six-sigma/guides/BELTS.md +52 -0
- package/gems/lean-six-sigma/guides/DMADV.md +56 -0
- package/gems/lean-six-sigma/guides/DMAIC.md +127 -0
- package/gems/lean-six-sigma/guides/FORMULAS.md +330 -0
- package/gems/lean-six-sigma/guides/LEAN.md +97 -0
- package/gems/lean-six-sigma/guides/TEMPLATES.md +279 -0
- package/gems/lean-six-sigma/runes/lss_capability.js +132 -0
- package/gems/lean-six-sigma/runes/lss_control_limits.js +240 -0
- package/gems/lean-six-sigma/runes/lss_dpmo_sigma.js +81 -0
- package/gems/lean-six-sigma/runes/lss_hypothesis.js +298 -0
- package/gems/lean-six-sigma/runes/lss_sample_size.js +81 -0
- package/gems/neurendipity/floor.json +65 -0
- package/gems/neurendipity/gem.json +42 -0
- package/gems/neurendipity/guides/AUTISTIC_BURNOUT.md +81 -0
- package/{guides → gems/neurendipity/guides}/NEURODIVERGENT_SUPPORT.md +17 -8
- package/guides/README.md +3 -0
- package/package.json +11 -5
- package/server.json +44 -4
- package/src/aleph.js +4 -4
- package/src/annotations.js +38 -3
- package/src/atlas-role.js +1 -1
- package/src/attribution.js +555 -0
- package/src/conformance.js +2 -2
- package/src/database.js +6 -5
- package/src/dispatch.js +116 -13
- package/src/env.js +54 -9
- package/src/floor.js +82 -41
- package/src/gaps.js +39 -6
- package/src/gem-check.js +1359 -0
- package/src/guides.js +31 -4
- package/src/host-reach.js +23 -0
- package/src/http-remote.js +46 -2
- package/src/index.js +9 -2
- package/src/layers.js +1 -1
- package/src/lessons-to-checks.js +3 -3
- package/src/logger.js +2 -1
- package/src/mongo-connect.js +45 -2
- package/src/oauth-provider.js +2 -2
- package/src/owner-approvals.js +281 -0
- package/src/persons.js +20 -0
- package/src/redact.js +10 -4
- package/src/request-log.js +1 -1
- package/src/rune-dispatch.js +360 -0
- package/src/rune-registry.js +71 -0
- package/src/rune-scope.js +42 -0
- package/src/scopes.js +3 -0
- package/src/session-graph.js +25 -23
- package/src/session.js +9 -9
- package/src/setup.js +297 -30
- package/src/socket.js +545 -0
- package/src/startup-pages.js +23 -6
- package/src/startup-payload.js +9 -6
- package/src/subkey-store.js +357 -0
- package/src/subkeys.js +947 -0
- package/src/tools.js +299 -62
- package/src/transcript-session.js +1 -1
- package/src/validation.js +26 -2
- package/src/version.js +1 -1
- package/templates/BOARD.md +2 -2
- package/templates/BOTHANDOFF.md +2 -2
- package/templates/CHARACTER.md +1 -1
- package/templates/HANDOFF.md +1 -1
- package/templates/IDENTITY.md +1 -1
- package/templates/INDEX.md +19 -19
- package/templates/INSTALL.md +1 -1
- package/templates/PROCEDURE.md +1 -1
- package/templates/ROSTER.md +2 -2
- package/templates/SEED.md +2 -2
- package/templates/STAGING.md +2 -2
- package/templates/STANDARD.md +5 -5
- package/templates/THREAD.md +1 -1
- package/templates/USER.md +4 -1
- package/verification/load-gate/corpus.json +1765 -0
- package/verification/load-gate/load-gate.mjs +1246 -0
package/src/guides.js
CHANGED
|
@@ -22,14 +22,36 @@ import crypto from 'crypto';
|
|
|
22
22
|
import { resolveModulePath } from './module-path.js';
|
|
23
23
|
|
|
24
24
|
export const GUIDES_DIR = resolveModulePath(import.meta.url, ['..', 'guides']);
|
|
25
|
+
const GEMS_DIR = resolveModulePath(import.meta.url, ['..', 'gems']);
|
|
25
26
|
const GUIDE_ID = /^guides\/([A-Za-z0-9_-]+\.md)$/;
|
|
26
27
|
|
|
28
|
+
/**
|
|
29
|
+
* One release: the old neurodivergent guide id resolves to the Neurendipity
|
|
30
|
+
* copy. There is one file. This map is not a second copy of the text.
|
|
31
|
+
*/
|
|
32
|
+
const GUIDE_ALIAS = Object.freeze({
|
|
33
|
+
'guides/NEURODIVERGENT_SUPPORT.md': resolveModulePath(import.meta.url, ['..', 'gems', 'neurendipity', 'guides', 'NEURODIVERGENT_SUPPORT.md'])
|
|
34
|
+
});
|
|
35
|
+
|
|
27
36
|
/** The shipped guide for this doc_id, or null when it is not a shipped guide. */
|
|
28
37
|
export function readShippedGuide(doc_id, { dir = GUIDES_DIR } = {}) {
|
|
29
38
|
const m = typeof doc_id === 'string' ? GUIDE_ID.exec(doc_id) : null;
|
|
30
39
|
if (!m) return null;
|
|
40
|
+
const file = Object.prototype.hasOwnProperty.call(GUIDE_ALIAS, doc_id) ? GUIDE_ALIAS[doc_id] : path.join(dir, m[1]);
|
|
31
41
|
let content;
|
|
32
|
-
try { content = fs.readFileSync(
|
|
42
|
+
try { content = fs.readFileSync(file, 'utf8'); } catch {
|
|
43
|
+
// A bundled GEM's own guide (2026-10-12: Neurendipity's floor points to
|
|
44
|
+
// read_doc('guides/AUTISTIC_BURNOUT.md'), which no folder served). Only the
|
|
45
|
+
// default shipped dir falls through, and only to bundled gems/<id>/guides/.
|
|
46
|
+
if (dir !== GUIDES_DIR) return null;
|
|
47
|
+
content = null;
|
|
48
|
+
let gems = [];
|
|
49
|
+
try { gems = fs.readdirSync(GEMS_DIR).filter((g) => /^[a-z0-9-]+$/.test(g)).sort(); } catch { gems = []; }
|
|
50
|
+
for (const g of gems) {
|
|
51
|
+
try { content = fs.readFileSync(path.join(GEMS_DIR, g, 'guides', m[1]), 'utf8'); break; } catch { /* next */ }
|
|
52
|
+
}
|
|
53
|
+
if (content === null) return null;
|
|
54
|
+
}
|
|
33
55
|
return {
|
|
34
56
|
doc_id,
|
|
35
57
|
content,
|
|
@@ -41,8 +63,13 @@ export function readShippedGuide(doc_id, { dir = GUIDES_DIR } = {}) {
|
|
|
41
63
|
};
|
|
42
64
|
}
|
|
43
65
|
|
|
44
|
-
/** Names of the guides this release ships. */
|
|
66
|
+
/** Names of the guides this release ships. The one-release alias is included. */
|
|
45
67
|
export function shippedGuides({ dir = GUIDES_DIR } = {}) {
|
|
46
|
-
|
|
47
|
-
|
|
68
|
+
let names = [];
|
|
69
|
+
try { names = fs.readdirSync(dir).filter(f => /\.md$/.test(f)).map(f => `guides/${f}`); }
|
|
70
|
+
catch { names = []; }
|
|
71
|
+
for (const id of Object.keys(GUIDE_ALIAS)) {
|
|
72
|
+
if (!names.includes(id) && fs.existsSync(GUIDE_ALIAS[id])) names.push(id);
|
|
73
|
+
}
|
|
74
|
+
return names.sort();
|
|
48
75
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EMET - Enduring Memory & Epistemic Tiers
|
|
3
|
+
* Copyright (c) 2026 Dreamforge Studio LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the MIT License - see LICENSE
|
|
6
|
+
*
|
|
7
|
+
* The RUNE load gate lives in verification/load-gate/load-gate.mjs so
|
|
8
|
+
* EMET-GEMs can vendor that file and its corpus byte-for-byte. This module
|
|
9
|
+
* is the import path gem-check already uses. There is no second scan.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
refuseHostAccess,
|
|
14
|
+
scanSource,
|
|
15
|
+
hostRules,
|
|
16
|
+
runeSpecifierReason,
|
|
17
|
+
runeExtensionReason,
|
|
18
|
+
MAX_SOURCE_BYTES,
|
|
19
|
+
HOST_GLOBALS,
|
|
20
|
+
OBJECT_METHODS,
|
|
21
|
+
MEMBER_NAMES,
|
|
22
|
+
MATH_MEMBERS
|
|
23
|
+
} from '../verification/load-gate/load-gate.mjs';
|
package/src/http-remote.js
CHANGED
|
@@ -82,7 +82,9 @@ function buildServer() {
|
|
|
82
82
|
const { scopesAllow } = await import('./scopes.js');
|
|
83
83
|
const auth = extra && extra.authInfo;
|
|
84
84
|
const scopes = (auth && auth.scopes) || [];
|
|
85
|
-
|
|
85
|
+
const { runeToolList } = await import('./rune-registry.js');
|
|
86
|
+
const tools = [...t.TOOLS, ...runeToolList()];
|
|
87
|
+
return { tools: tools.filter((tool) => scopesAllow(scopes, tool.name, { http: true, authenticated: Boolean(auth), args: null })) };
|
|
86
88
|
});
|
|
87
89
|
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
88
90
|
const { name, arguments: args = {} } = request.params;
|
|
@@ -95,6 +97,7 @@ function buildServer() {
|
|
|
95
97
|
scopes: (auth && auth.scopes) || [],
|
|
96
98
|
transport: 'http',
|
|
97
99
|
sourceTag: (auth && auth.extra && auth.extra.sourceTag) || null,
|
|
100
|
+
grantId: (auth && auth.extra && auth.extra.grantId) || null,
|
|
98
101
|
authenticated: Boolean(auth)
|
|
99
102
|
};
|
|
100
103
|
const bucket = t.rateBucket(actor);
|
|
@@ -104,8 +107,24 @@ function buildServer() {
|
|
|
104
107
|
// Every tool call leaves one audit entry (H6). callTool never throws: it
|
|
105
108
|
// returns either a success response or handleError's isError response.
|
|
106
109
|
const t0 = Date.now();
|
|
107
|
-
// Parameter NAMES, never values (
|
|
110
|
+
// Parameter NAMES, never values (Alex, 2026-09-27, code audit 1.6).
|
|
108
111
|
const paramNames = Object.keys(args || {}).sort();
|
|
112
|
+
try {
|
|
113
|
+
const att = await import('./attribution.js');
|
|
114
|
+
const { resolveTokenSubject } = await import('./persons.js');
|
|
115
|
+
actor.ownerId = await resolveTokenSubject(dbManager.db).catch(() => null);
|
|
116
|
+
await actorStore.run(actor, () => att.preflight({
|
|
117
|
+
db: dbManager.db, prefix: provider.prefix, ownerId: actor.ownerId, deps: t.approvalsDeps,
|
|
118
|
+
layerCollections: ['episodic', 'semantic', 'procedural', 'meta', 'identity', 'working'].map((l) => { try { return dbManager.collectionFor(l); } catch { return null; } }).filter(Boolean),
|
|
119
|
+
registry: t.sourceRegistry()
|
|
120
|
+
}, actor, args, name));
|
|
121
|
+
} catch (e) {
|
|
122
|
+
if (e && e.attributionRefusal) {
|
|
123
|
+
const { ValidationError } = await import('./validation.js');
|
|
124
|
+
return actorStore.run(actor, () => t.handleError(new ValidationError('source', e.message), name, logger));
|
|
125
|
+
}
|
|
126
|
+
throw e;
|
|
127
|
+
}
|
|
109
128
|
const result = await actorStore.run(actor, () => callTool(name, args));
|
|
110
129
|
logger.audit(AuditOperation.TOOL_CALL, { tool: name, success: result.isError !== true, durationMs: Date.now() - t0, params: paramNames });
|
|
111
130
|
return result;
|
|
@@ -136,6 +155,20 @@ app.use(requestLogger({ enabled: process.env.EMET_REQUEST_LOG !== '0' }));
|
|
|
136
155
|
|
|
137
156
|
app.get('/health', (_req, res) => res.json({ ok: true, service: 'emet', tools: t.TOOLS.length, cimd: provider.cimdEnabled }));
|
|
138
157
|
|
|
158
|
+
// Pending sub-keys. Same passphrase and the same per-address failure throttle
|
|
159
|
+
// as /oauth/approve. The routes are not mounted unless EMET_SUBKEYS=1, so a
|
|
160
|
+
// flag-off process answers these paths the way it answers any other unknown path.
|
|
161
|
+
if (setting('SUBKEYS') === '1') {
|
|
162
|
+
const { mountOwnerApprovals } = await import('./owner-approvals.js');
|
|
163
|
+
const { resolveTokenSubject } = await import('./persons.js');
|
|
164
|
+
mountOwnerApprovals(app, {
|
|
165
|
+
checkPassphrase: (input) => provider.checkPassphrase(input),
|
|
166
|
+
throttle: approveThrottle,
|
|
167
|
+
deps: t.approvalsDeps,
|
|
168
|
+
ownerId: () => resolveTokenSubject(dbManager.db)
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
139
172
|
// OAuth 2.1 authorization server + protected-resource metadata:
|
|
140
173
|
// /.well-known/oauth-authorization-server, /.well-known/oauth-protected-resource/mcp,
|
|
141
174
|
// /register, /authorize, /token, /revoke
|
|
@@ -265,5 +298,16 @@ app.post('/mcp', requireBearerAuth({ verifier: provider, resourceMetadataUrl: PR
|
|
|
265
298
|
if (!PASSPHRASE) console.error('[emet] WARNING: EMET_OAUTH_PASSPHRASE unset — all authorization attempts will be rejected.');
|
|
266
299
|
try { await dbManager.ensureClient(); console.error('[emet] Mongo connected'); }
|
|
267
300
|
catch (e) { console.error('[emet] Mongo connect FAILED:', e.message); }
|
|
301
|
+
try {
|
|
302
|
+
const { seatGems } = await import('./socket.js');
|
|
303
|
+
const seated = await seatGems({ log: logger });
|
|
304
|
+
// The structured line keeps the id in the middle of a JSON object. This
|
|
305
|
+
// line ends with the id so the image boot can anchor it.
|
|
306
|
+
for (const row of seated.gems_available || []) {
|
|
307
|
+
if (row.loadable) console.error(`GEM seated: ${row.id}`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
catch (e) { console.error('[emet] GEM socket failed closed'); }
|
|
268
311
|
app.listen(PORT, HOST, () => console.error(`[emet] listening ${PUBLIC_URL} (bind ${HOST}:${PORT}); /mcp OAuth-protected; PRM ${PRM_URL}`));
|
|
312
|
+
import('./subkeys.js').then((m) => m.startFlagOffSweep((msg) => console.error(`[emet] ${msg}`))).catch(() => console.error('[emet] sub-key flag-off sweep failed'));
|
|
269
313
|
})();
|
package/src/index.js
CHANGED
|
@@ -57,6 +57,8 @@ import {
|
|
|
57
57
|
} from './database.js';
|
|
58
58
|
|
|
59
59
|
import { dispatchTool } from './dispatch.js';
|
|
60
|
+
import { seatGems } from './socket.js';
|
|
61
|
+
import { runeToolList } from './rune-registry.js';
|
|
60
62
|
// Import tools module (the per-tool handlers are reached through dispatch.js)
|
|
61
63
|
import {
|
|
62
64
|
TOOLS,
|
|
@@ -124,7 +126,7 @@ const server = new Server(
|
|
|
124
126
|
* List available tools
|
|
125
127
|
*/
|
|
126
128
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
127
|
-
return { tools: TOOLS };
|
|
129
|
+
return { tools: [...TOOLS, ...runeToolList()] };
|
|
128
130
|
});
|
|
129
131
|
|
|
130
132
|
/**
|
|
@@ -169,7 +171,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
|
169
171
|
// Every tool call leaves one audit entry (H6, 2026-09-19). callTool never
|
|
170
172
|
// throws: it returns either a success response or handleError's response.
|
|
171
173
|
const t0 = Date.now();
|
|
172
|
-
// Parameter NAMES, never values (
|
|
174
|
+
// Parameter NAMES, never values (Alex, 2026-09-27, code audit 1.6): the next
|
|
173
175
|
// audit can measure which parameters hosts use; nothing a host sent is stored.
|
|
174
176
|
const paramNames = Object.keys(args || {}).sort();
|
|
175
177
|
const result = await actorStore.run(actor, () => callTool(name, args));
|
|
@@ -256,6 +258,11 @@ async function main() {
|
|
|
256
258
|
}
|
|
257
259
|
}
|
|
258
260
|
|
|
261
|
+
try { await seatGems({ log: logger }); }
|
|
262
|
+
catch (e) { logger.error('GEM socket failed closed', { error: e && e.message }); }
|
|
263
|
+
|
|
264
|
+
import('./subkeys.js').then((m) => m.startFlagOffSweep((msg) => logger.error(msg))).catch((e) => logger.error('sub-key flag-off sweep failed', { error: e && e.message }));
|
|
265
|
+
|
|
259
266
|
const startupDurationMs = Date.now() - startTime;
|
|
260
267
|
|
|
261
268
|
logger.audit(AuditOperation.SERVER_START, {
|
package/src/layers.js
CHANGED
|
@@ -247,7 +247,7 @@ export function bandFor(layer) {
|
|
|
247
247
|
|
|
248
248
|
/** Named importance buckets. The word is the differentiator; the range is
|
|
249
249
|
* compatibility for the stored float. Degree (low/mid/high) is the only
|
|
250
|
-
* reason to pick a number other than the midpoint.
|
|
250
|
+
* reason to pick a number other than the midpoint. Alex 2026-09-26. */
|
|
251
251
|
export const IMPORTANCE_BUCKETS = Object.freeze({
|
|
252
252
|
constitutive: { range: [0.9, 1.0], layers: ['identity'] },
|
|
253
253
|
durable: { range: [0.6, 0.9], layers: ['semantic', 'meta'] },
|
package/src/lessons-to-checks.js
CHANGED
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
*
|
|
33
33
|
* Source of the three-attempt rule and the scope line: Hanako, "Loops and
|
|
34
34
|
* Graphs" (2026-08-23), read in full 2026-09-27; the user's decisions are in
|
|
35
|
-
*
|
|
35
|
+
* staging/emet-lessons-to-checks.md.
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
38
|
// ---------------------------------------------------------------------------
|
|
@@ -244,7 +244,7 @@ function familyEntry(members, key) {
|
|
|
244
244
|
* One detector, the explicit key (2026-09-29). Rows carrying metadata.lesson
|
|
245
245
|
* are grouped by that key - the writer says "same lesson" at save time and
|
|
246
246
|
* nothing is inferred. The tag-family heuristic that ran beside it in shadow
|
|
247
|
-
* from 2026-09-27 was DROPPED on the owner's ruling: "that's
|
|
247
|
+
* from 2026-09-27 was DROPPED on the owner's ruling (procedural ref 397): "that's
|
|
248
248
|
* just another instance of forking... I would rather the original be more
|
|
249
249
|
* heavily, better enforced, than having a backup that can rot or fork." The
|
|
250
250
|
* enforcement moved to the write: checkLessonFields below refuses a malformed
|
|
@@ -292,7 +292,7 @@ export function pendingLessons(rows, { threshold = LESSON_REPEAT_THRESHOLD } = {
|
|
|
292
292
|
}
|
|
293
293
|
|
|
294
294
|
// ---------------------------------------------------------------------------
|
|
295
|
-
// 4. The lesson fields are checked at the door (2026-09-29,
|
|
295
|
+
// 4. The lesson fields are checked at the door (2026-09-29, procedural ref 397)
|
|
296
296
|
// ---------------------------------------------------------------------------
|
|
297
297
|
|
|
298
298
|
/** A lesson key is a lowercase slug: letters and digits in hyphen-joined
|
package/src/logger.js
CHANGED
package/src/mongo-connect.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* EMET - the one way to open a MongoDB connection (2026-09-29).
|
|
3
3
|
*
|
|
4
|
-
* Some local resolvers (home routers, VPN DNS) refuse
|
|
4
|
+
* Some local resolvers (home routers, VPN DNS - STUDIO's among them) refuse
|
|
5
5
|
* the SRV queries a mongodb+srv:// URI needs, and the driver fails with
|
|
6
6
|
* "querySrv ECONNREFUSED". Until today the public-DNS fallback lived only in
|
|
7
7
|
* EmetDatabase.ensureClient; nine other connections in src/ and
|
|
@@ -31,9 +31,52 @@ export function isSrvRefusal(error) {
|
|
|
31
31
|
* with no database. Never set outside tests; null means the real driver.
|
|
32
32
|
*/
|
|
33
33
|
let testClient = null;
|
|
34
|
-
|
|
34
|
+
let sharedClient = null;
|
|
35
|
+
let sharedToken = null;
|
|
36
|
+
let sharedConnect = null;
|
|
37
|
+
|
|
38
|
+
/** The server's Mongo client, reused by stores that would otherwise connect per call. */
|
|
39
|
+
export function bindSharedClient(client) {
|
|
40
|
+
if (!client) return;
|
|
41
|
+
sharedClient = client;
|
|
42
|
+
sharedToken = testClient || 'live';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function resetSharedClient() {
|
|
46
|
+
sharedClient = null;
|
|
47
|
+
sharedToken = null;
|
|
48
|
+
sharedConnect = null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function useTestClient(Client) {
|
|
52
|
+
testClient = Client || null;
|
|
53
|
+
resetSharedClient();
|
|
54
|
+
}
|
|
35
55
|
export function testClientActive() { return testClient != null; }
|
|
36
56
|
|
|
57
|
+
/**
|
|
58
|
+
* One client for this process (or for the installed test client). A second
|
|
59
|
+
* call does not open another connection.
|
|
60
|
+
*/
|
|
61
|
+
export async function acquireSharedClient(uri, options = {}) {
|
|
62
|
+
const token = testClient || 'live';
|
|
63
|
+
if (sharedClient && sharedToken === token) return sharedClient;
|
|
64
|
+
if (!sharedConnect) {
|
|
65
|
+
let resolve;
|
|
66
|
+
let reject;
|
|
67
|
+
// Assign before connectMongo yields, so a second caller waits on this promise.
|
|
68
|
+
sharedConnect = new Promise((res, rej) => { resolve = res; reject = rej; });
|
|
69
|
+
connectMongo(uri, options).then((client) => {
|
|
70
|
+
if ((testClient || 'live') === token && sharedConnect) {
|
|
71
|
+
sharedClient = client;
|
|
72
|
+
sharedToken = token;
|
|
73
|
+
}
|
|
74
|
+
resolve(client);
|
|
75
|
+
}, reject).finally(() => { sharedConnect = null; });
|
|
76
|
+
}
|
|
77
|
+
return sharedConnect;
|
|
78
|
+
}
|
|
79
|
+
|
|
37
80
|
/** Connect and return the client. `onFallback(message)` is told when the retry happens. */
|
|
38
81
|
export async function connectMongo(uri, options = {}, { onFallback = null, _Client = testClient || MongoClient, _dns = dns } = {}) {
|
|
39
82
|
const attempt = async () => { const c = new _Client(uri, options); await c.connect(); return c; };
|
package/src/oauth-provider.js
CHANGED
|
@@ -83,7 +83,7 @@ function escapeHtml(s) {
|
|
|
83
83
|
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
function timingSafeEqualStr(a, b) {
|
|
86
|
+
export function timingSafeEqualStr(a, b) {
|
|
87
87
|
const ab = Buffer.from(String(a));
|
|
88
88
|
const bb = Buffer.from(String(b));
|
|
89
89
|
if (ab.length !== bb.length) {
|
|
@@ -824,7 +824,7 @@ export class MongoOAuthProvider {
|
|
|
824
824
|
clientId: doc.client_id,
|
|
825
825
|
scopes: doc.scopes || [],
|
|
826
826
|
expiresAt: doc.expiresAt,
|
|
827
|
-
extra: { subject: doc.subject || 'owner', sourceTag: doc.source_tag || null },
|
|
827
|
+
extra: { subject: doc.subject || 'owner', sourceTag: doc.source_tag || null, grantId: doc.family || null },
|
|
828
828
|
};
|
|
829
829
|
}
|
|
830
830
|
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner page for pending sub-key requests.
|
|
3
|
+
*
|
|
4
|
+
* Mounted only when EMET_SUBKEYS=1. The passphrase is the one the OAuth
|
|
5
|
+
* consent page already checks (MongoOAuthProvider.checkPassphrase against
|
|
6
|
+
* EMET_OAUTH_PASSPHRASE), and a wrong guess shares that form's per-address
|
|
7
|
+
* lockout. A correct passphrase opens a session that lasts 15 minutes.
|
|
8
|
+
* Approve and Deny post that session's CSRF token and call
|
|
9
|
+
* decideSubkeyIfPending for the posted key_id. The history event records
|
|
10
|
+
* the real owner id and via owner_page. emet_client_approve is unchanged.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import crypto from 'node:crypto';
|
|
14
|
+
import express from 'express';
|
|
15
|
+
import { timingSafeEqualStr } from './oauth-provider.js';
|
|
16
|
+
import { decideSubkeyIfPending, loadSubkeyRecords } from './subkeys.js';
|
|
17
|
+
|
|
18
|
+
const COOKIE_NAME = 'emet_owner';
|
|
19
|
+
export const SESSION_MS = 15 * 60 * 1000;
|
|
20
|
+
const SESSION_MAX = 200;
|
|
21
|
+
const REASON_MAX = 500;
|
|
22
|
+
const STORE_MESSAGE = 'The sub-key store is unavailable.';
|
|
23
|
+
const NOT_PENDING = 'That sub-key is not awaiting approval. Nothing was changed.';
|
|
24
|
+
|
|
25
|
+
function esc(s) {
|
|
26
|
+
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readCookie(req, name) {
|
|
30
|
+
const raw = req.headers.cookie;
|
|
31
|
+
if (!raw || typeof raw !== 'string') return null;
|
|
32
|
+
for (const part of raw.split(';')) {
|
|
33
|
+
const i = part.indexOf('=');
|
|
34
|
+
if (i < 0) continue;
|
|
35
|
+
if (part.slice(0, i).trim() !== name) continue;
|
|
36
|
+
return part.slice(i + 1).trim();
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function ownerActor(personId) {
|
|
42
|
+
return {
|
|
43
|
+
transport: 'http',
|
|
44
|
+
authenticated: true,
|
|
45
|
+
personId,
|
|
46
|
+
ownerId: personId,
|
|
47
|
+
sourceTag: 'owner',
|
|
48
|
+
sourceBasis: 'login',
|
|
49
|
+
clientId: null,
|
|
50
|
+
grantId: null
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function page(body) {
|
|
55
|
+
return `<!DOCTYPE html>
|
|
56
|
+
<html lang="en">
|
|
57
|
+
<head>
|
|
58
|
+
<meta charset="utf-8">
|
|
59
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
60
|
+
<title>EMET owner approvals</title>
|
|
61
|
+
<style>
|
|
62
|
+
body { font-family: system-ui, sans-serif; background: #12141a; color: #e8e6e0; margin: 0; padding: 24px; }
|
|
63
|
+
button { font: inherit; margin-right: 8px; }
|
|
64
|
+
</style>
|
|
65
|
+
</head>
|
|
66
|
+
<body>
|
|
67
|
+
${body}
|
|
68
|
+
</body>
|
|
69
|
+
</html>`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function sendHtml(res, status, body) {
|
|
73
|
+
res.status(status);
|
|
74
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
75
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
76
|
+
res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'");
|
|
77
|
+
res.send(page(body));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function pendingRows(data) {
|
|
81
|
+
return Object.values((data && data.keys) || {})
|
|
82
|
+
.filter((k) => k && k.status === 'pending' && k.key_id && k.tag)
|
|
83
|
+
.sort((a, b) => String(a.requested_at || '').localeCompare(String(b.requested_at || '')) || String(a.key_id).localeCompare(String(b.key_id)));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function listHtml(rows, csrf, note) {
|
|
87
|
+
const noteHtml = note ? `<p>${esc(note)}</p>` : '';
|
|
88
|
+
if (!rows.length) return `${noteHtml}<h1>Pending sub-keys</h1><p>No pending sub-key requests.</p>`;
|
|
89
|
+
const body = rows.map((row) => {
|
|
90
|
+
const prefix = esc(String(row.key_id).slice(0, 8));
|
|
91
|
+
return `<tr><td>${esc(row.tag)}</td><td>${prefix}</td><td>${esc(row.requested_at || '')}</td><td>
|
|
92
|
+
<form method="post" action="/owner/approvals/decide">
|
|
93
|
+
<input type="hidden" name="csrf" value="${esc(csrf)}">
|
|
94
|
+
<input type="hidden" name="key_id" value="${esc(row.key_id)}">
|
|
95
|
+
<input type="text" name="reason" maxlength="${REASON_MAX}" placeholder="Reason (optional)">
|
|
96
|
+
<button type="submit" name="decision" value="approve">Approve</button>
|
|
97
|
+
<button type="submit" name="decision" value="deny">Deny</button>
|
|
98
|
+
</form>
|
|
99
|
+
</td></tr>`;
|
|
100
|
+
}).join('\n');
|
|
101
|
+
return `${noteHtml}<h1>Pending sub-keys</h1>
|
|
102
|
+
<table>
|
|
103
|
+
<thead><tr><th>Tag</th><th>Key</th><th>Requested</th><th></th></tr></thead>
|
|
104
|
+
<tbody>
|
|
105
|
+
${body}
|
|
106
|
+
</tbody>
|
|
107
|
+
</table>`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function notPending(res) {
|
|
111
|
+
res.locals.reason = 'not_pending';
|
|
112
|
+
sendHtml(res, 400, `<p>${NOT_PENDING}</p>`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @param {import('express').Express} app
|
|
117
|
+
* @param {{ checkPassphrase: (input: string) => boolean, throttle: { check: Function, recordFailure: Function, recordSuccess: Function }, deps: object | Function, ownerId: string | Function, now?: () => number }} options
|
|
118
|
+
*/
|
|
119
|
+
export function mountOwnerApprovals(app, options = {}) {
|
|
120
|
+
const checkPassphrase = options.checkPassphrase;
|
|
121
|
+
const throttle = options.throttle;
|
|
122
|
+
const now = options.now || Date.now;
|
|
123
|
+
const sessions = new Map();
|
|
124
|
+
const parseBody = express.urlencoded({ extended: false });
|
|
125
|
+
const depsOf = () => (typeof options.deps === 'function' ? options.deps() : options.deps);
|
|
126
|
+
|
|
127
|
+
async function resolveOwner() {
|
|
128
|
+
try { const id = await (typeof options.ownerId === 'function' ? options.ownerId() : options.ownerId); return (typeof id === 'string' && id.trim()) || null; }
|
|
129
|
+
catch { return null; }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function sweep() {
|
|
133
|
+
const t = now();
|
|
134
|
+
for (const [id, row] of sessions) if (!row || row.expires <= t) sessions.delete(id);
|
|
135
|
+
while (sessions.size > SESSION_MAX) sessions.delete(sessions.keys().next().value);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function sessionOf(req) {
|
|
139
|
+
sweep();
|
|
140
|
+
const raw = readCookie(req, COOKIE_NAME);
|
|
141
|
+
if (!raw || !/^[0-9a-f]{64}$/.test(raw)) return null;
|
|
142
|
+
return sessions.get(raw) || null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function renderList(req, res, note) {
|
|
146
|
+
const session = sessionOf(req);
|
|
147
|
+
if (!session) {
|
|
148
|
+
sendHtml(res, 200, `<h1>Owner approvals</h1>
|
|
149
|
+
<p>Enter the owner passphrase.</p>
|
|
150
|
+
<form method="post" action="/owner/approvals/login">
|
|
151
|
+
<label>Passphrase <input type="password" name="passphrase" autocomplete="current-password"></label>
|
|
152
|
+
<button type="submit">Continue</button>
|
|
153
|
+
</form>`);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
let rows = [];
|
|
157
|
+
try {
|
|
158
|
+
rows = pendingRows(await loadSubkeyRecords(depsOf()));
|
|
159
|
+
} catch {
|
|
160
|
+
res.locals.reason = 'subkey_store';
|
|
161
|
+
sendHtml(res, 503, `<p>${STORE_MESSAGE}</p>`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
sendHtml(res, 200, listHtml(rows, session.csrf, note));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
app.get('/owner/approvals', (req, res) => {
|
|
168
|
+
renderList(req, res).catch((e) => {
|
|
169
|
+
console.error('[emet] owner approvals', e && e.message ? e.message : e);
|
|
170
|
+
if (!res.headersSent) {
|
|
171
|
+
res.locals.reason = 'owner_approvals_error';
|
|
172
|
+
sendHtml(res, 500, '<p>Something went wrong.</p>');
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
app.post('/owner/approvals/login', parseBody, (req, res) => {
|
|
178
|
+
const limit = throttle.check(req.ip);
|
|
179
|
+
if (!limit.allowed) {
|
|
180
|
+
res.locals.reason = 'throttled';
|
|
181
|
+
res.setHeader('Retry-After', String(limit.retryAfterS));
|
|
182
|
+
sendHtml(res, 429, '<p>Too many attempts. Try again later.</p>');
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const passphrase = (req.body && req.body.passphrase) || '';
|
|
186
|
+
let ok = false;
|
|
187
|
+
try { ok = checkPassphrase(passphrase) === true; }
|
|
188
|
+
catch (e) {
|
|
189
|
+
console.error('[emet] owner passphrase check', e && e.message ? e.message : e);
|
|
190
|
+
res.locals.reason = 'passphrase_check_error';
|
|
191
|
+
sendHtml(res, 500, '<p>Something went wrong.</p>');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (!ok) {
|
|
195
|
+
throttle.recordFailure(req.ip);
|
|
196
|
+
res.locals.reason = 'bad_passphrase';
|
|
197
|
+
sendHtml(res, 401, '<p>Incorrect passphrase.</p>');
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
throttle.recordSuccess(req.ip);
|
|
201
|
+
sweep();
|
|
202
|
+
const id = crypto.randomBytes(32).toString('hex');
|
|
203
|
+
const csrf = crypto.randomBytes(32).toString('hex');
|
|
204
|
+
sessions.set(id, { csrf, expires: now() + SESSION_MS });
|
|
205
|
+
res.cookie(COOKIE_NAME, id, {
|
|
206
|
+
httpOnly: true,
|
|
207
|
+
sameSite: 'strict',
|
|
208
|
+
path: '/owner',
|
|
209
|
+
maxAge: SESSION_MS,
|
|
210
|
+
secure: req.secure === true
|
|
211
|
+
});
|
|
212
|
+
res.locals.reason = 'owner_session';
|
|
213
|
+
res.redirect(303, '/owner/approvals');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
app.post('/owner/approvals/decide', parseBody, (req, res) => {
|
|
217
|
+
const session = sessionOf(req);
|
|
218
|
+
if (!session) {
|
|
219
|
+
res.locals.reason = 'no_owner_session';
|
|
220
|
+
sendHtml(res, 401, '<p>Sign in again.</p>');
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const posted = (req.body && req.body.csrf) || '';
|
|
224
|
+
if (!timingSafeEqualStr(posted, session.csrf)) {
|
|
225
|
+
res.locals.reason = 'bad_csrf';
|
|
226
|
+
sendHtml(res, 403, '<p>The form expired. Reload the page and try again.</p>');
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const decision = req.body && req.body.decision;
|
|
230
|
+
const keyId = typeof (req.body && req.body.key_id) === 'string' ? req.body.key_id.trim() : '';
|
|
231
|
+
const reason = typeof (req.body && req.body.reason) === 'string' ? req.body.reason.trim() : '';
|
|
232
|
+
if (decision !== 'approve' && decision !== 'deny') {
|
|
233
|
+
res.locals.reason = 'bad_decision';
|
|
234
|
+
sendHtml(res, 400, '<p>Decision must be approve or deny. Nothing was changed.</p>');
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (reason.length > REASON_MAX) {
|
|
238
|
+
res.locals.reason = 'reason_too_long';
|
|
239
|
+
sendHtml(res, 400, '<p>The reason is too long. Nothing was changed.</p>');
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
finishDecide(req, res, keyId, decision, reason).catch((e) => {
|
|
243
|
+
console.error('[emet] owner decide', e && e.message ? e.message : e);
|
|
244
|
+
if (!res.headersSent) {
|
|
245
|
+
res.locals.reason = 'owner_decide_error';
|
|
246
|
+
sendHtml(res, 500, '<p>Something went wrong. Nothing was changed.</p>');
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
async function finishDecide(req, res, keyId, decision, reason) {
|
|
252
|
+
const personId = await resolveOwner();
|
|
253
|
+
if (!personId) {
|
|
254
|
+
res.locals.reason = 'owner_unavailable';
|
|
255
|
+
sendHtml(res, 400, '<p>The owner id is unavailable. Nothing was changed.</p>');
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
let result = null;
|
|
259
|
+
try {
|
|
260
|
+
result = await decideSubkeyIfPending(depsOf(), ownerActor(personId), personId, {
|
|
261
|
+
key_id: keyId, decision, reason, via: 'owner_page'
|
|
262
|
+
}, new Date(now()));
|
|
263
|
+
} catch (e) {
|
|
264
|
+
if (e && e.attributionRefusal && !/sub-key store/.test(String(e.message))) {
|
|
265
|
+
res.locals.reason = 'decide_refused';
|
|
266
|
+
sendHtml(res, 400, `<p>${esc(e.message)}</p>`);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
res.locals.reason = 'subkey_store';
|
|
270
|
+
sendHtml(res, 503, `<p>${STORE_MESSAGE}</p>`);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (!result) {
|
|
274
|
+
notPending(res);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
res.locals.reason = decision === 'approve' ? 'subkey_approved' : 'subkey_denied';
|
|
278
|
+
const verb = decision === 'approve' ? 'Approved' : 'Denied';
|
|
279
|
+
await renderList(req, res, `${verb} ${result.tag}.`);
|
|
280
|
+
}
|
|
281
|
+
}
|
package/src/persons.js
CHANGED
|
@@ -63,6 +63,26 @@ export function boundSourceTag() {
|
|
|
63
63
|
return tag || null;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/** The raw per-request actor (HTTP object), or null. Read only. */
|
|
67
|
+
export function rawActor() {
|
|
68
|
+
const s = actorStore.getStore();
|
|
69
|
+
return s && typeof s === 'object' ? s : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Per-request attribution set by attribution.preflight (HTTP only). */
|
|
73
|
+
export function declaredAttribution() {
|
|
74
|
+
const s = actorStore.getStore();
|
|
75
|
+
if (!s || typeof s !== 'object' || s.transport !== 'http') return null;
|
|
76
|
+
const login = typeof s.sourceTag === 'string' && s.sourceTag.trim() ? s.sourceTag.trim() : null;
|
|
77
|
+
return {
|
|
78
|
+
declared: s.declaredTag || null, basis: s.sourceBasis || null, login,
|
|
79
|
+
effective: s.effectiveTag || null, clientId: s.clientId || null,
|
|
80
|
+
approval: s.approvalState || null,
|
|
81
|
+
rememberedTags: Array.isArray(s.rememberedTags) ? s.rememberedTags : [],
|
|
82
|
+
...(s.subkeyId ? { keyId: s.subkeyId } : {})
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
66
86
|
export function displayNameFromUserDoc(text) {
|
|
67
87
|
const heading = String(text || '').match(/^#\s+User Profile\s+[\u2014\u2013-]\s+(.+)$/im);
|
|
68
88
|
if (heading) return heading[1].trim();
|