@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.
Files changed (101) hide show
  1. package/.env.example +53 -0
  2. package/CHANGELOG.md +67 -3
  3. package/CHARTER.md +19 -7
  4. package/README.md +106 -21
  5. package/docs/AUTHORSHIP.md +3 -3
  6. package/docs/BOTS.md +4 -0
  7. package/docs/COVERAGE.md +2 -2
  8. package/docs/DEPLOY.md +9 -4
  9. package/docs/GUARDRAILS-AND-MONORAILS.md +2 -1
  10. package/docs/GUIDED-SETUP.md +8 -3
  11. package/docs/PERMISSIONS.md +6 -3
  12. package/docs/ROADMAP.md +25 -22
  13. package/docs/SETUP.md +8 -2
  14. package/docs/SUBKEYS.md +59 -0
  15. package/gems/artifact-index.json +12 -0
  16. package/gems/bot-team-backup/floor.json +15 -0
  17. package/gems/bot-team-backup/gem.json +43 -0
  18. package/gems/bot-team-backup/guides/BOT_TEAM_BACKUP.md +27 -0
  19. package/gems/bot-team-backup/guides/BOT_TEAM_RECOVERY.md +23 -0
  20. package/gems/fieldnote/floor.json +14 -0
  21. package/gems/fieldnote/gem.json +70 -0
  22. package/gems/fieldnote/runes/fieldnote.js +4 -0
  23. package/gems/lean-six-sigma/CHANGELOG.md +10 -0
  24. package/gems/lean-six-sigma/README.md +92 -0
  25. package/gems/lean-six-sigma/floor.json +31 -0
  26. package/gems/lean-six-sigma/gem.json +1296 -0
  27. package/gems/lean-six-sigma/guides/ASSUMPTIONS.md +45 -0
  28. package/gems/lean-six-sigma/guides/BELTS.md +52 -0
  29. package/gems/lean-six-sigma/guides/DMADV.md +56 -0
  30. package/gems/lean-six-sigma/guides/DMAIC.md +127 -0
  31. package/gems/lean-six-sigma/guides/FORMULAS.md +330 -0
  32. package/gems/lean-six-sigma/guides/LEAN.md +97 -0
  33. package/gems/lean-six-sigma/guides/TEMPLATES.md +279 -0
  34. package/gems/lean-six-sigma/runes/lss_capability.js +132 -0
  35. package/gems/lean-six-sigma/runes/lss_control_limits.js +240 -0
  36. package/gems/lean-six-sigma/runes/lss_dpmo_sigma.js +81 -0
  37. package/gems/lean-six-sigma/runes/lss_hypothesis.js +298 -0
  38. package/gems/lean-six-sigma/runes/lss_sample_size.js +81 -0
  39. package/gems/neurendipity/floor.json +65 -0
  40. package/gems/neurendipity/gem.json +42 -0
  41. package/gems/neurendipity/guides/AUTISTIC_BURNOUT.md +81 -0
  42. package/{guides → gems/neurendipity/guides}/NEURODIVERGENT_SUPPORT.md +17 -8
  43. package/guides/README.md +3 -0
  44. package/package.json +11 -5
  45. package/server.json +44 -4
  46. package/src/aleph.js +4 -4
  47. package/src/annotations.js +38 -3
  48. package/src/atlas-role.js +1 -1
  49. package/src/attribution.js +555 -0
  50. package/src/conformance.js +2 -2
  51. package/src/database.js +6 -5
  52. package/src/dispatch.js +116 -13
  53. package/src/env.js +54 -9
  54. package/src/floor.js +82 -41
  55. package/src/gaps.js +39 -6
  56. package/src/gem-check.js +1359 -0
  57. package/src/guides.js +31 -4
  58. package/src/host-reach.js +23 -0
  59. package/src/http-remote.js +46 -2
  60. package/src/index.js +9 -2
  61. package/src/layers.js +1 -1
  62. package/src/lessons-to-checks.js +3 -3
  63. package/src/logger.js +2 -1
  64. package/src/mongo-connect.js +45 -2
  65. package/src/oauth-provider.js +2 -2
  66. package/src/owner-approvals.js +281 -0
  67. package/src/persons.js +20 -0
  68. package/src/redact.js +10 -4
  69. package/src/request-log.js +1 -1
  70. package/src/rune-dispatch.js +360 -0
  71. package/src/rune-registry.js +71 -0
  72. package/src/rune-scope.js +42 -0
  73. package/src/scopes.js +3 -0
  74. package/src/session-graph.js +25 -23
  75. package/src/session.js +9 -9
  76. package/src/setup.js +297 -30
  77. package/src/socket.js +545 -0
  78. package/src/startup-pages.js +23 -6
  79. package/src/startup-payload.js +9 -6
  80. package/src/subkey-store.js +357 -0
  81. package/src/subkeys.js +947 -0
  82. package/src/tools.js +299 -62
  83. package/src/transcript-session.js +1 -1
  84. package/src/validation.js +26 -2
  85. package/src/version.js +1 -1
  86. package/templates/BOARD.md +2 -2
  87. package/templates/BOTHANDOFF.md +2 -2
  88. package/templates/CHARACTER.md +1 -1
  89. package/templates/HANDOFF.md +1 -1
  90. package/templates/IDENTITY.md +1 -1
  91. package/templates/INDEX.md +19 -19
  92. package/templates/INSTALL.md +1 -1
  93. package/templates/PROCEDURE.md +1 -1
  94. package/templates/ROSTER.md +2 -2
  95. package/templates/SEED.md +2 -2
  96. package/templates/STAGING.md +2 -2
  97. package/templates/STANDARD.md +5 -5
  98. package/templates/THREAD.md +1 -1
  99. package/templates/USER.md +4 -1
  100. package/verification/load-gate/corpus.json +1765 -0
  101. 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(path.join(dir, m[1]), 'utf8'); } catch { return null; }
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
- try { return fs.readdirSync(dir).filter(f => /\.md$/.test(f)).map(f => `guides/${f}`).sort(); }
47
- catch { return []; }
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';
@@ -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
- return { tools: t.TOOLS.filter((tool) => scopesAllow(scopes, tool.name, { http: true, authenticated: Boolean(auth), args: null })) };
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 (the owner, 2026-09-27, code audit 1.6).
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 (the owner, 2026-09-27, code audit 1.6): the next
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. The owner 2026-09-26. */
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'] },
@@ -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
- * the owner's store.
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, a decision record)
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
@@ -51,7 +51,8 @@ const AuditOperation = Object.freeze({
51
51
  VALIDATION_FAIL: 'VALIDATION_FAIL',
52
52
  SERVER_START: 'SERVER_START',
53
53
  SERVER_STOP: 'SERVER_STOP',
54
- CONFIG_CHANGE: 'CONFIG_CHANGE'
54
+ CONFIG_CHANGE: 'CONFIG_CHANGE',
55
+ GEM_SERVE: 'GEM_SERVE'
55
56
  });
56
57
 
57
58
  /**
@@ -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
- export function useTestClient(Client) { testClient = Client || null; }
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; };
@@ -83,7 +83,7 @@ function escapeHtml(s) {
83
83
  return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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();