@dreamforgestudiollc/emet 0.0.0-stage → 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 (114) hide show
  1. package/.env.example +189 -0
  2. package/CHANGELOG.md +360 -0
  3. package/CHARTER.md +137 -0
  4. package/LICENSE +22 -0
  5. package/README.md +868 -2
  6. package/docs/AUTHORSHIP.md +26 -0
  7. package/docs/BENCHMARKS.md +261 -0
  8. package/docs/BOOTSTRAP.md +127 -0
  9. package/docs/BOTS.md +83 -0
  10. package/docs/COVERAGE.md +64 -0
  11. package/docs/DEPLOY.md +251 -0
  12. package/docs/DISCIPLINES.md +29 -0
  13. package/docs/GUARDRAILS-AND-MONORAILS.md +108 -0
  14. package/docs/GUIDED-SETUP.md +301 -0
  15. package/docs/PERMISSIONS.md +121 -0
  16. package/docs/ROADMAP.md +62 -0
  17. package/docs/SETUP.md +265 -0
  18. package/docs/SUBKEYS.md +59 -0
  19. package/gems/artifact-index.json +12 -0
  20. package/gems/bot-team-backup/floor.json +15 -0
  21. package/gems/bot-team-backup/gem.json +43 -0
  22. package/gems/bot-team-backup/guides/BOT_TEAM_BACKUP.md +27 -0
  23. package/gems/bot-team-backup/guides/BOT_TEAM_RECOVERY.md +23 -0
  24. package/gems/fieldnote/floor.json +14 -0
  25. package/gems/fieldnote/gem.json +70 -0
  26. package/gems/fieldnote/runes/fieldnote.js +4 -0
  27. package/gems/lean-six-sigma/CHANGELOG.md +10 -0
  28. package/gems/lean-six-sigma/README.md +92 -0
  29. package/gems/lean-six-sigma/floor.json +31 -0
  30. package/gems/lean-six-sigma/gem.json +1296 -0
  31. package/gems/lean-six-sigma/guides/ASSUMPTIONS.md +45 -0
  32. package/gems/lean-six-sigma/guides/BELTS.md +52 -0
  33. package/gems/lean-six-sigma/guides/DMADV.md +56 -0
  34. package/gems/lean-six-sigma/guides/DMAIC.md +127 -0
  35. package/gems/lean-six-sigma/guides/FORMULAS.md +330 -0
  36. package/gems/lean-six-sigma/guides/LEAN.md +97 -0
  37. package/gems/lean-six-sigma/guides/TEMPLATES.md +279 -0
  38. package/gems/lean-six-sigma/runes/lss_capability.js +132 -0
  39. package/gems/lean-six-sigma/runes/lss_control_limits.js +240 -0
  40. package/gems/lean-six-sigma/runes/lss_dpmo_sigma.js +81 -0
  41. package/gems/lean-six-sigma/runes/lss_hypothesis.js +298 -0
  42. package/gems/lean-six-sigma/runes/lss_sample_size.js +81 -0
  43. package/gems/neurendipity/floor.json +65 -0
  44. package/gems/neurendipity/gem.json +42 -0
  45. package/gems/neurendipity/guides/AUTISTIC_BURNOUT.md +81 -0
  46. package/gems/neurendipity/guides/NEURODIVERGENT_SUPPORT.md +287 -0
  47. package/guides/README.md +3 -0
  48. package/package.json +76 -4
  49. package/server.json +79 -0
  50. package/src/aleph.js +395 -0
  51. package/src/annotations.js +170 -0
  52. package/src/approve-throttle.js +54 -0
  53. package/src/atlas-role.js +58 -0
  54. package/src/attribution.js +555 -0
  55. package/src/conformance.js +688 -0
  56. package/src/content_analyzer.js +450 -0
  57. package/src/database.js +812 -0
  58. package/src/disciplines.js +26 -0
  59. package/src/dispatch.js +382 -0
  60. package/src/env.js +97 -0
  61. package/src/floor.js +228 -0
  62. package/src/gaps.js +509 -0
  63. package/src/gem-check.js +1359 -0
  64. package/src/guides.js +75 -0
  65. package/src/host-reach.js +23 -0
  66. package/src/http-remote.js +313 -0
  67. package/src/index.js +397 -0
  68. package/src/invites.js +167 -0
  69. package/src/layers.js +449 -0
  70. package/src/lessons-to-checks.js +366 -0
  71. package/src/logger.js +300 -0
  72. package/src/metadata.js +61 -0
  73. package/src/module-path.js +32 -0
  74. package/src/mongo-connect.js +91 -0
  75. package/src/named-items.js +208 -0
  76. package/src/oauth-provider.js +838 -0
  77. package/src/owner-approvals.js +281 -0
  78. package/src/persons.js +136 -0
  79. package/src/provenance.js +257 -0
  80. package/src/redact.js +237 -0
  81. package/src/request-log.js +124 -0
  82. package/src/rune-dispatch.js +360 -0
  83. package/src/rune-registry.js +71 -0
  84. package/src/rune-scope.js +42 -0
  85. package/src/scopes.js +100 -0
  86. package/src/security-headers.js +26 -0
  87. package/src/session-graph.js +947 -0
  88. package/src/session.js +1097 -0
  89. package/src/setup.js +1580 -0
  90. package/src/socket.js +545 -0
  91. package/src/startup-pages.js +371 -0
  92. package/src/startup-payload.js +129 -0
  93. package/src/subkey-store.js +357 -0
  94. package/src/subkeys.js +947 -0
  95. package/src/tools.js +4508 -0
  96. package/src/transcript-session.js +106 -0
  97. package/src/validation.js +1187 -0
  98. package/src/version.js +2 -0
  99. package/templates/BOARD.md +72 -0
  100. package/templates/BOTHANDOFF.md +57 -0
  101. package/templates/CHARACTER.md +30 -0
  102. package/templates/HANDOFF.md +63 -0
  103. package/templates/IDENTITY.md +29 -0
  104. package/templates/INDEX.md +82 -0
  105. package/templates/INSTALL.md +29 -0
  106. package/templates/PROCEDURE.md +39 -0
  107. package/templates/ROSTER.md +43 -0
  108. package/templates/SEED.md +65 -0
  109. package/templates/STAGING.md +37 -0
  110. package/templates/STANDARD.md +99 -0
  111. package/templates/THREAD.md +43 -0
  112. package/templates/USER.md +32 -0
  113. package/verification/load-gate/corpus.json +1765 -0
  114. package/verification/load-gate/load-gate.mjs +1246 -0
package/src/guides.js ADDED
@@ -0,0 +1,75 @@
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
+ * Shipped guides.
8
+ *
9
+ * Reference documents that ship with the release and are read on demand with
10
+ * read_doc('guides/<NAME>.md') - never pasted into emet_initialize. The floor
11
+ * carries the rules; a guide carries the understanding and method behind them.
12
+ * Like the charter, the shipped copy governs: an install's store cannot
13
+ * overwrite or lose it, and a brand-new store can read it before any setup.
14
+ *
15
+ * Only a bare file name under guides/ is accepted, so a doc_id cannot walk out
16
+ * of the folder.
17
+ */
18
+
19
+ import fs from 'fs';
20
+ import path from 'path';
21
+ import crypto from 'crypto';
22
+ import { resolveModulePath } from './module-path.js';
23
+
24
+ export const GUIDES_DIR = resolveModulePath(import.meta.url, ['..', 'guides']);
25
+ const GEMS_DIR = resolveModulePath(import.meta.url, ['..', 'gems']);
26
+ const GUIDE_ID = /^guides\/([A-Za-z0-9_-]+\.md)$/;
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
+
36
+ /** The shipped guide for this doc_id, or null when it is not a shipped guide. */
37
+ export function readShippedGuide(doc_id, { dir = GUIDES_DIR } = {}) {
38
+ const m = typeof doc_id === 'string' ? GUIDE_ID.exec(doc_id) : null;
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]);
41
+ let content;
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
+ }
55
+ return {
56
+ doc_id,
57
+ content,
58
+ sha256: crypto.createHash('sha256').update(content, 'utf8').digest('hex'),
59
+ size: content.length,
60
+ source: 'release',
61
+ governed_by: 'the copy shipped with this release',
62
+ note: 'A shipped guide: read on demand, never loaded at startup. The release copy governs.'
63
+ };
64
+ }
65
+
66
+ /** Names of the guides this release ships. The one-release alias is included. */
67
+ export function shippedGuides({ dir = GUIDES_DIR } = {}) {
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();
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';
@@ -0,0 +1,313 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * EMET remote MCP server — PRODUCTION entry (OAuth-protected).
4
+ * ------------------------------------------------------------------
5
+ * Streamable HTTP transport + full OAuth 2.1 (via the MCP SDK's mcpAuthRouter)
6
+ * guarding /mcp with bearer auth. Shares tools.js and database.js with the
7
+ * stdio entry point - one codebase, two transports.
8
+ *
9
+ * Required env for deploy:
10
+ * MONGODB_URI Atlas connection string (scoped user recommended)
11
+ * PUBLIC_URL the public https base URL of this host (issuer)
12
+ * EMET_OAUTH_PASSPHRASE the consent passphrase (set by the user)
13
+ * Optional:
14
+ * PORT (default 8849), HOST (default 0.0.0.0), EMET_SOURCE_TAG (e.g. "mobile"),
15
+ * The server trusts exactly one proxy hop (Railway). TRUST_PROXY is not read.
16
+ * EMET_CIMD (default on; set 0 to stop advertising Client ID Metadata Document
17
+ * support and fall back to Dynamic Client Registration only — a rollback that
18
+ * needs no redeploy; see oauth-provider.js)
19
+ *
20
+ * Configuration comes from the environment, or from a .env file in the repo root.
21
+ * PUBLIC_URL defaults to http://127.0.0.1:PORT for local development.
22
+ */
23
+ import './env.js';
24
+ import fs from 'fs';
25
+ import { setting } from './env.js';
26
+ import path from 'path';
27
+ import { fileURLToPath } from 'url';
28
+ import express from 'express';
29
+
30
+ import { mcpAuthRouter, createOAuthMetadata, getOAuthProtectedResourceMetadataUrl } from '@modelcontextprotocol/sdk/server/auth/router.js';
31
+ import { redirectUriMatches } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize.js';
32
+ import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
33
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
34
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
35
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
36
+ import { EMET_VERSION } from './version.js';
37
+
38
+
39
+ const db = await import('./database.js');
40
+ const t = await import('./tools.js');
41
+ const { dispatchTool } = await import('./dispatch.js');
42
+ const { MongoOAuthProvider, withCimdMetadata } = await import('./oauth-provider.js');
43
+ const { requestLogger } = await import('./request-log.js');
44
+ const { securityHeaders } = await import('./security-headers.js');
45
+ const { ApproveThrottle } = await import('./approve-throttle.js');
46
+ const { StructuredLogger, AuditOperation } = await import('./logger.js');
47
+
48
+ const PORT = Number(process.env.PORT) || 8849;
49
+ const HOST = process.env.HOST || '0.0.0.0';
50
+ const PUBLIC_URL = (process.env.PUBLIC_URL || `http://127.0.0.1:${PORT}`).replace(/\/+$/, '');
51
+ const PASSPHRASE = setting('OAUTH_PASSPHRASE') || '';
52
+ // One hop only. A larger TRUST_PROXY would let a client spoof req.ip.
53
+ const TRUST_PROXY = 1;
54
+ const RESOURCE_URL = new URL(PUBLIC_URL + '/mcp');
55
+ const PRM_URL = getOAuthProtectedResourceMetadataUrl(RESOURCE_URL);
56
+
57
+ // This used to be a stub whose debug/info/warn/audit were empty functions, with
58
+ // AuditOperation a Proxy returning its own key names. Every audit call on this
59
+ // transport - and this is the transport a hosted install runs - was discarded,
60
+ // so the server reported an audit trail it did not keep. Both transports now
61
+ // construct the same recorder. Output goes to stderr, which the host captures.
62
+ const logger = new StructuredLogger({
63
+ serviceName: 'emet',
64
+ version: EMET_VERSION,
65
+ minLevel: process.env.LOG_LEVEL || (process.env.DEBUG === 'true' ? 'debug' : 'info'),
66
+ jsonOutput: process.env.LOG_FORMAT !== 'text',
67
+ auditEnabled: true,
68
+ auditLogPath: setting('AUDIT_LOG') || null
69
+ });
70
+
71
+ const dbManager = new db.EmetDatabase(logger);
72
+ const rateLimiter = new t.RateLimiter(logger);
73
+
74
+ // Over HTTP the source tag is the one bound at approval (auth.extra.sourceTag).
75
+ // Stdio still resolves the caller's tag inside saveMemory. A token with no
76
+ // bound tag may name a registered tag until it is refreshed; that refresh is
77
+ // refused so the next consent binds a tag.
78
+
79
+ function buildServer() {
80
+ const server = new Server({ name: 'emet', version: EMET_VERSION }, { capabilities: { tools: {} } });
81
+ server.setRequestHandler(ListToolsRequestSchema, async (_request, extra) => {
82
+ const { scopesAllow } = await import('./scopes.js');
83
+ const auth = extra && extra.authInfo;
84
+ const scopes = (auth && auth.scopes) || [];
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 })) };
88
+ });
89
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
90
+ const { name, arguments: args = {} } = request.params;
91
+ const { actorStore, personFromAuth } = await import('./persons.js');
92
+ const auth = extra && extra.authInfo;
93
+ const clientId = (auth && auth.clientId) || 'http:visitor';
94
+ const actor = {
95
+ personId: personFromAuth(extra),
96
+ clientId,
97
+ scopes: (auth && auth.scopes) || [],
98
+ transport: 'http',
99
+ sourceTag: (auth && auth.extra && auth.extra.sourceTag) || null,
100
+ grantId: (auth && auth.extra && auth.extra.grantId) || null,
101
+ authenticated: Boolean(auth)
102
+ };
103
+ const bucket = t.rateBucket(actor);
104
+ const rl = rateLimiter.checkLimit(name, bucket);
105
+ if (!rl.allowed) return actorStore.run(actor, () => t.handleError(new t.RateLimitError(rl.reason, rl.retryAfterMs), name, logger));
106
+ rateLimiter.recordRequest(name, bucket);
107
+ // Every tool call leaves one audit entry (H6). callTool never throws: it
108
+ // returns either a success response or handleError's isError response.
109
+ const t0 = Date.now();
110
+ // Parameter NAMES, never values (Alex, 2026-09-27, code audit 1.6).
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
+ }
128
+ const result = await actorStore.run(actor, () => callTool(name, args));
129
+ logger.audit(AuditOperation.TOOL_CALL, { tool: name, success: result.isError !== true, durationMs: Date.now() - t0, params: paramNames });
130
+ return result;
131
+ });
132
+ return server;
133
+ }
134
+
135
+ async function callTool(name, args) {
136
+ // One dispatch table for both transports - src/dispatch.js (audit 2026-09-27).
137
+ return dispatchTool(name, args, { dbManager, logger, AuditOperation });
138
+ }
139
+
140
+ const provider = new MongoOAuthProvider(() => dbManager.db, { passphrase: PASSPHRASE, issuerUrl: PUBLIC_URL });
141
+ // Failed passphrase attempts per client address (audit finding H4, 2026-09-19).
142
+ // The tool-call limiter sits behind bearer auth; this is the one gate in front of it.
143
+ const approveThrottle = new ApproveThrottle();
144
+
145
+ const app = express();
146
+ app.set('trust proxy', TRUST_PROXY);
147
+
148
+ // On every response, including /mcp, the OAuth metadata routes, errors, and
149
+ // the consent page. The consent page still sets its own content security policy.
150
+ app.use(securityHeaders);
151
+
152
+ // One redacted line per OAuth/MCP request and per 4xx/5xx anywhere (H4,
153
+ // 2026-09-07). EMET_REQUEST_LOG=0 silences it. See request-log.js.
154
+ app.use(requestLogger({ enabled: process.env.EMET_REQUEST_LOG !== '0' }));
155
+
156
+ app.get('/health', (_req, res) => res.json({ ok: true, service: 'emet', tools: t.TOOLS.length, cimd: provider.cimdEnabled }));
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
+
172
+ // OAuth 2.1 authorization server + protected-resource metadata:
173
+ // /.well-known/oauth-authorization-server, /.well-known/oauth-protected-resource/mcp,
174
+ // /register, /authorize, /token, /revoke
175
+ const AUTH_OPTIONS = {
176
+ provider,
177
+ issuerUrl: new URL(PUBLIC_URL),
178
+ resourceServerUrl: RESOURCE_URL,
179
+ scopesSupported: ['emet.read', 'emet.write'],
180
+ resourceName: 'EMET Memory',
181
+ };
182
+
183
+ // Authorization-server metadata, served AHEAD of the SDK router so it can carry
184
+ // `client_id_metadata_document_supported` (SDK 1.30.0 builds this object
185
+ // internally with no option to extend it). Everything else in the document is
186
+ // the SDK's own; only the CIMD flag is added, and DCR stays advertised.
187
+ const AS_METADATA = withCimdMetadata(createOAuthMetadata(AUTH_OPTIONS), provider.cimdEnabled);
188
+ app.get('/.well-known/oauth-authorization-server', (_req, res) => {
189
+ res.setHeader('Cache-Control', 'no-store');
190
+ res.json(AS_METADATA);
191
+ });
192
+
193
+ app.use(mcpAuthRouter(AUTH_OPTIONS));
194
+
195
+ // Passphrase approval -> mint authorization code -> redirect back to the client
196
+ app.post('/oauth/approve', express.urlencoded({ extended: false }), async (req, res) => {
197
+ try {
198
+ const { client_id, redirect_uri, code_challenge, state, scopes, resource, passphrase, invite_code } = req.body || {};
199
+ const throttle = approveThrottle.check(req.ip);
200
+ if (!throttle.allowed) {
201
+ res.locals.reason = 'throttled';
202
+ res.status(429).setHeader('Retry-After', String(throttle.retryAfterS));
203
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
204
+ return res.end('<p style="font-family:system-ui;background:#12141a;color:#e8e6e0;padding:24px">Too many attempts. Try again later.</p>');
205
+ }
206
+ let inviteSubject = null;
207
+ let inviteScopes = null;
208
+ let inviteDoor = null;
209
+ let sourceTag = null;
210
+ if (invite_code && String(invite_code).trim()) {
211
+ await dbManager.getConnection('working');
212
+ const { redeemInvite } = await import('./invites.js');
213
+ const redeemed = await redeemInvite(dbManager.db, invite_code);
214
+ if (!redeemed.ok) {
215
+ approveThrottle.recordFailure(req.ip);
216
+ res.locals.reason = 'bad_invite';
217
+ res.status(401).setHeader('Content-Type', 'text/html; charset=utf-8');
218
+ return res.end('<p style="font-family:system-ui;background:#12141a;color:#e8e6e0;padding:24px">That invite code is not valid. Go back and try again.</p>');
219
+ }
220
+ inviteSubject = redeemed.person_id;
221
+ inviteDoor = redeemed.door === 'member' ? 'member' : 'guest';
222
+ sourceTag = redeemed.source_tag || null;
223
+ // An empty scope list is owner access; a guest must never get it
224
+ // (security audit 2026-09-29).
225
+ const { guestScopesOrNull } = await import('./scopes.js');
226
+ inviteScopes = guestScopesOrNull(redeemed.scopes);
227
+ if (!inviteScopes) {
228
+ res.locals.reason = 'invite_without_scopes';
229
+ res.status(401).setHeader('Content-Type', 'text/html; charset=utf-8');
230
+ return res.end('<p style="font-family:system-ui;background:#12141a;color:#e8e6e0;padding:24px">That invite grants no access. Ask for a new one.</p>');
231
+ }
232
+ } else if (!provider.checkPassphrase(passphrase || '')) {
233
+ approveThrottle.recordFailure(req.ip);
234
+ res.locals.reason = 'bad_passphrase';
235
+ res.status(401).setHeader('Content-Type', 'text/html; charset=utf-8');
236
+ return res.end('<p style="font-family:system-ui;background:#12141a;color:#e8e6e0;padding:24px">Incorrect passphrase. Go back and try again.</p>');
237
+ } else {
238
+ const requested = String((req.body && req.body.source) || '').trim();
239
+ const { registeredSourceTags } = await import('./setup.js');
240
+ const registry = registeredSourceTags();
241
+ if (registry.length && !registry.includes(requested)) {
242
+ res.locals.reason = 'bad_source';
243
+ return res.status(400).end('source tag is not registered');
244
+ }
245
+ sourceTag = requested || null;
246
+ }
247
+ const client = await provider.clientsStore.getClient(client_id);
248
+ if (!client) { res.locals.reason = 'unknown_client'; return res.status(400).end('unknown client'); }
249
+ // Same rule the SDK's /authorize applies: exact match, except that loopback
250
+ // redirects (localhost / 127.0.0.1 / [::1]) match with the port ignored, per
251
+ // RFC 8252 §7.3 — Claude Code binds an ephemeral port on every run.
252
+ if (!(client.redirect_uris || []).some((registered) => redirectUriMatches(redirect_uri, registered))) {
253
+ res.locals.reason = 'redirect_uri_not_registered';
254
+ return res.status(400).end('redirect_uri not registered');
255
+ }
256
+ approveThrottle.recordSuccess(req.ip);
257
+ res.locals.reason = client.cimd ? 'approved_cimd' : 'approved_dcr';
258
+ const requestedScopes = inviteScopes || (scopes ? String(scopes).split(' ').filter(Boolean) : []);
259
+ // A passphrase approval with no requested scopes is the owner. Store that
260
+ // as the full-access alias so an empty HTTP scope list never means owner.
261
+ const grantedScopes = inviteSubject ? requestedScopes : (requestedScopes.length ? requestedScopes : ['emet']);
262
+ const code = await provider.createAuthorizationCode({
263
+ client_id, redirect_uri, code_challenge,
264
+ scopes: grantedScopes,
265
+ resource: resource || null,
266
+ subject: inviteSubject || null,
267
+ // The door rides on the token: 'member' for EMET Member Access, 'invite' for EMET Guest Access.
268
+ issued_via: inviteSubject ? (inviteDoor === 'member' ? 'member' : 'invite') : null,
269
+ source_tag: sourceTag,
270
+ });
271
+ const u = new URL(redirect_uri);
272
+ u.searchParams.set('code', code);
273
+ if (state) u.searchParams.set('state', state);
274
+ res.redirect(302, u.href);
275
+ } catch (e) {
276
+ console.error('[emet] approve error', e);
277
+ res.locals.reason = 'approve_error';
278
+ res.status(500).end('approve failed');
279
+ }
280
+ });
281
+
282
+ // Protected MCP endpoint (bearer required -> body parse -> streamable HTTP)
283
+ app.post('/mcp', requireBearerAuth({ verifier: provider, resourceMetadataUrl: PRM_URL }), express.json({ limit: '256kb' }), async (req, res) => {
284
+ try {
285
+ const server = buildServer();
286
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
287
+ res.on('close', () => { try { transport.close(); } catch {} try { server.close(); } catch {} });
288
+ await server.connect(transport);
289
+ await transport.handleRequest(req, res, req.body);
290
+ } catch (e) {
291
+ console.error('[emet] mcp error', e);
292
+ res.locals.reason = 'mcp_error';
293
+ if (!res.headersSent) res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal error' }, id: null });
294
+ }
295
+ });
296
+
297
+ (async () => {
298
+ if (!PASSPHRASE) console.error('[emet] WARNING: EMET_OAUTH_PASSPHRASE unset — all authorization attempts will be rejected.');
299
+ try { await dbManager.ensureClient(); console.error('[emet] Mongo connected'); }
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'); }
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'));
313
+ })();