@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
@@ -0,0 +1,838 @@
1
+ /**
2
+ * MongoOAuthProvider — a minimal single-user OAuth 2.1 authorization-server
3
+ * provider for the EMET remote MCP endpoint, implementing the MCP SDK's
4
+ * OAuthServerProvider interface. State (clients / codes / tokens) is stored in
5
+ * the SAME database as memory (collections: <prefix>oauth_clients,
6
+ * <prefix>oauth_codes, <prefix>oauth_tokens), so it survives restarts/redeploys.
7
+ *
8
+ * Client identification — TWO mechanisms, one deprecated (2026-09-07):
9
+ *
10
+ * CIMD — Client ID Metadata Documents. The client_id IS an https URL; the
11
+ * server fetches the JSON document at that URL, checks that the
12
+ * document names the same URL as its own client_id, and uses its
13
+ * redirect_uris. No registration call, no per-connection client rows
14
+ * piling up, and the binding between id and document is the URL
15
+ * itself. This is the mechanism the MCP 2026-07-28 specification
16
+ * keeps.
17
+ * DCR — Dynamic Client Registration (RFC 7591). Deprecated by the same
18
+ * specification with a twelve-month window (to ~2027-07). Kept mounted
19
+ * so hosts that have not moved yet keep working; removal is a separate,
20
+ * dated decision, not a side effect of adding CIMD.
21
+ *
22
+ * A client that advertises BOTH `client_id_metadata_document_supported: true`
23
+ * and "none" in `token_endpoint_auth_methods_supported` will be offered CIMD
24
+ * by Claude's connector client; see withCimdMetadata(). EMET_CIMD=0 turns the
25
+ * advertisement off without a redeploy — the rollback is a variable flip.
26
+ *
27
+ * Security model: PKCE (S256) is enforced by the SDK handlers. The
28
+ * user-consent step is gated by a single shared passphrase
29
+ * (EMET_OAUTH_PASSPHRASE) that only the user knows - even someone who finds
30
+ * the URL cannot authorize a device without it. Access tokens are opaque,
31
+ * random, short-lived, and validated against Mongo on every /mcp call. Refresh
32
+ * tokens ROTATE on use (OAuth 2.1 requirement for public clients, which both
33
+ * CIMD and DCR clients are) with a short grace window so a client that lost the
34
+ * response to its refresh is not locked out.
35
+ */
36
+ import crypto from 'crypto';
37
+ import dnsPromises from 'node:dns/promises';
38
+ import http from 'node:http';
39
+ import https from 'node:https';
40
+ import net from 'node:net';
41
+ import tls from 'node:tls';
42
+ import { COLLECTION_PREFIX } from './database.js';
43
+ import { setting } from './env.js';
44
+ import { InvalidGrantError, InvalidScopeError, InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
45
+ import { scopesNotGranted } from './scopes.js';
46
+
47
+ const CODE_TTL_MS = 5 * 60 * 1000; // authorization codes: 5 minutes
48
+ const ACCESS_TTL_S = 60 * 60; // access tokens: 1 hour
49
+ const REFRESH_TTL_S = 60 * 60 * 24 * 60; // refresh tokens: 60 days
50
+ const REFRESH_ROTATION_GRACE_S = 60; // a rotated refresh token stays usable this long
51
+ /** The marker on codes and tokens minted from an invite (R6). */
52
+ export const INVITE_GRANT = 'invite';
53
+ // EMET Member Access tokens carry their own marker, so refresh can check the member flag, not the guest one.
54
+ export const MEMBER_GRANT = 'member';
55
+ const DOOR_GRANTS = new Set([INVITE_GRANT, MEMBER_GRANT]);
56
+
57
+ /** How long a fetched Client ID Metadata Document is trusted before refetching. */
58
+ export const CIMD_TTL_MS = 60 * 60 * 1000; // 1 hour
59
+ const CIMD_FETCH_TIMEOUT_MS = 5000;
60
+ const CIMD_MAX_BYTES = 64 * 1024;
61
+ /** CIMD document fetches per host per minute. A metadata URL is not a general HTTP client. */
62
+ export const CIMD_FETCH_PER_MIN = 60;
63
+ const CIMD_FETCH_GLOBAL_PER_MIN = 120;
64
+ const cimdFetchTimes = [];
65
+ const cimdHostTimes = new Map();
66
+
67
+ const b64url = (buf) => Buffer.from(buf).toString('base64url');
68
+ const randTok = (n = 32) => b64url(crypto.randomBytes(n));
69
+
70
+ /**
71
+ * Tokens are stored as their SHA-256 digest, never in clear (security audit
72
+ * finding H2, 2026-09-19): a reader of the database must not hold a live
73
+ * bearer. The digest is what is looked up; the token itself exists only in
74
+ * the response that issued it and in the client that holds it.
75
+ *
76
+ * Rows written before this change carry the clear token. They are honoured
77
+ * ONCE: a lookup that misses by digest tries the clear value, and a hit is
78
+ * rewritten as its digest on the spot. No device re-authorizes.
79
+ */
80
+ export const tokenDigest = (token) => crypto.createHash('sha256').update(String(token)).digest('hex');
81
+
82
+ function escapeHtml(s) {
83
+ return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
84
+ }
85
+
86
+ export function timingSafeEqualStr(a, b) {
87
+ const ab = Buffer.from(String(a));
88
+ const bb = Buffer.from(String(b));
89
+ if (ab.length !== bb.length) {
90
+ try { crypto.timingSafeEqual(ab, Buffer.alloc(ab.length)); } catch { /* noop */ }
91
+ return false;
92
+ }
93
+ return crypto.timingSafeEqual(ab, bb);
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // CIMD — pure parts, exported so they can be tested without a network.
98
+ // ---------------------------------------------------------------------------
99
+
100
+ /**
101
+ * Is this client_id a reference to a Client ID Metadata Document?
102
+ * Only an https URL with a real path qualifies: no fragment, no userinfo, no
103
+ * plain-http. Anything else is an opaque (DCR) id and is looked up, not fetched.
104
+ */
105
+ export function isCimdClientId(clientId) {
106
+ if (typeof clientId !== 'string' || !/^https:\/\//i.test(clientId)) return false;
107
+ let u;
108
+ try { u = new URL(clientId); } catch { return false; }
109
+ if (u.protocol !== 'https:') return false;
110
+ if (u.hash) return false;
111
+ if (u.username || u.password) return false;
112
+ if (!u.pathname || u.pathname === '/') return false;
113
+ return true;
114
+ }
115
+
116
+ /**
117
+ * Validate a fetched metadata document against the URL it came from.
118
+ * The one check that carries the whole security argument: the document's
119
+ * client_id MUST equal the URL. A document hosted at claude.ai that claims to
120
+ * be some other client is not a client at all.
121
+ * Returns a sanitised client record; never lets a client_secret through.
122
+ */
123
+ export function validateCimdDocument(doc, url) {
124
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
125
+ throw new Error('CIMD document must be a JSON object');
126
+ }
127
+ if (doc.client_id !== url) {
128
+ throw new Error(`CIMD document client_id does not match its URL (expected ${url})`);
129
+ }
130
+ if (!Array.isArray(doc.redirect_uris) || doc.redirect_uris.length === 0 || !doc.redirect_uris.every(r => typeof r === 'string' && r.length > 0)) {
131
+ throw new Error('CIMD document must carry a non-empty redirect_uris array of strings');
132
+ }
133
+ const method = doc.token_endpoint_auth_method === undefined ? 'none' : doc.token_endpoint_auth_method;
134
+ if (method !== 'none') {
135
+ throw new Error(`CIMD clients are public; token_endpoint_auth_method must be "none" (got ${method})`);
136
+ }
137
+ const { client_secret, client_secret_expires_at, ...rest } = doc;
138
+ return {
139
+ ...rest,
140
+ client_id: url,
141
+ redirect_uris: [...doc.redirect_uris],
142
+ token_endpoint_auth_method: 'none'
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Decorate authorization-server metadata so a client can choose CIMD.
148
+ * Claude selects CIMD only when BOTH signals are present; either missing and it
149
+ * falls back to DCR. DCR's registration_endpoint is left exactly as it was.
150
+ */
151
+ /**
152
+ * Addresses the CIMD fetcher must not connect to, matched by parsed bits.
153
+ * IPv4 and IPv6 are separate lists: an IPv4-mapped subnet also matches every
154
+ * native IPv4 address, so a public IPv4 address is checked only against the
155
+ * IPv4 list.
156
+ */
157
+ const cimdBlock4 = new net.BlockList();
158
+ const cimdBlock6 = new net.BlockList();
159
+ for (const [addr, prefix] of [
160
+ ['0.0.0.0', 8], // unspecified
161
+ ['10.0.0.0', 8], // private
162
+ ['100.64.0.0', 10], // carrier-grade NAT
163
+ ['127.0.0.0', 8], // loopback
164
+ ['169.254.0.0', 16], // link-local, including metadata 169.254.169.254
165
+ ['172.16.0.0', 12], // private
166
+ ['192.168.0.0', 16], // private
167
+ ['224.0.0.0', 4], // multicast
168
+ ]) cimdBlock4.addSubnet(addr, prefix, 'ipv4');
169
+ // Metadata addresses that do not sit inside the ranges above.
170
+ cimdBlock4.addAddress('100.100.100.200', 'ipv4'); // Alibaba
171
+ cimdBlock4.addAddress('168.63.129.16', 'ipv4'); // Azure host
172
+ for (const [addr, prefix] of [
173
+ ['::', 128], // unspecified
174
+ ['::1', 128], // loopback
175
+ ['fc00::', 7], // unique-local, including fd00:ec2::254
176
+ ['fe80::', 10], // link-local: fe80:: through febf::
177
+ ['ff00::', 8], // multicast
178
+ ['::', 96], // IPv4-compatible
179
+ ['::ffff:0:0', 96], // IPv4-mapped
180
+ ['64:ff9b::', 96], // NAT64
181
+ ]) cimdBlock6.addSubnet(addr, prefix, 'ipv6');
182
+
183
+ /**
184
+ * True for an address the CIMD fetcher must not connect to. A hostname is
185
+ * not an address and returns false; the resolver result is checked later.
186
+ */
187
+ export function isBlockedCimdAddress(address) {
188
+ if (typeof address !== 'string' || !address.trim()) return true;
189
+ let ip = address.trim();
190
+ if (ip.startsWith('[') && ip.endsWith(']')) ip = ip.slice(1, -1);
191
+ const zone = ip.indexOf('%');
192
+ if (zone !== -1) ip = ip.slice(0, zone);
193
+ const family = net.isIP(ip);
194
+ if (family === 4) return cimdBlock4.check(ip, 'ipv4');
195
+ if (family === 6) return cimdBlock6.check(ip, 'ipv6');
196
+ return false;
197
+ }
198
+
199
+ function takeCimdFetchSlot(host) {
200
+ const now = Date.now();
201
+ const windowStart = now - 60_000;
202
+ while (cimdFetchTimes.length && cimdFetchTimes[0] <= windowStart) cimdFetchTimes.shift();
203
+ const hostHits = (cimdHostTimes.get(host) || []).filter((t) => t > windowStart);
204
+ if (cimdFetchTimes.length >= CIMD_FETCH_GLOBAL_PER_MIN || hostHits.length >= CIMD_FETCH_PER_MIN) return false;
205
+ cimdFetchTimes.push(now);
206
+ hostHits.push(now);
207
+ cimdHostTimes.set(host, hostHits);
208
+ return true;
209
+ }
210
+
211
+ /**
212
+ * Resolve the CIMD URL before any connection. Refuses a literal or resolved
213
+ * loopback, private, link-local, or metadata address. `lookup` is injectable.
214
+ */
215
+ export async function assertCimdTargetPublic(url, lookup) {
216
+ const u = new URL(url);
217
+ const host = u.hostname.replace(/^\[|\]$/g, '');
218
+ if (!host || host === 'localhost' || host.endsWith('.localhost') || host === 'metadata.google.internal') {
219
+ throw new Error('CIMD host is not a public address');
220
+ }
221
+ if (isBlockedCimdAddress(host)) throw new Error('CIMD host is not a public address');
222
+ const fn = lookup || dnsPromises.lookup;
223
+ const found = await fn(host, { all: true, verbatim: true });
224
+ const list = Array.isArray(found) ? found : (found ? [found] : []);
225
+ if (!list.length) throw new Error('CIMD host did not resolve');
226
+ const checked = [];
227
+ for (const item of list) {
228
+ const address = typeof item === 'string' ? item : item && item.address;
229
+ if (isBlockedCimdAddress(address)) throw new Error('CIMD host resolves to a blocked address');
230
+ const family = (item && item.family === 6) || String(address).includes(':') ? 6 : 4;
231
+ checked.push({ address, family });
232
+ }
233
+ return checked;
234
+ }
235
+
236
+ /**
237
+ * DNS lookup that always returns the address already checked. A later answer
238
+ * for the same name is never used, so a rebinding cannot move the connection
239
+ * onto a private, loopback, link-local, or metadata address.
240
+ */
241
+ export function cimdPinnedLookup(address, family) {
242
+ const fam = family === 6 ? 6 : 4;
243
+ return function lookup(_hostname, options, callback) {
244
+ if (typeof options === 'function') {
245
+ callback = options;
246
+ options = {};
247
+ }
248
+ if (options && options.all) callback(null, [{ address, family: fam }]);
249
+ else callback(null, address, fam);
250
+ };
251
+ }
252
+
253
+ function destroyQuiet(socket) {
254
+ if (!socket || socket.destroyed) return;
255
+ socket.removeAllListeners('error');
256
+ socket.on('error', () => {});
257
+ socket.destroy();
258
+ }
259
+
260
+ function cimdResult(status, chunks) {
261
+ if (status >= 300 && status < 400) {
262
+ return Promise.reject(new Error(`redirect refused (${status})`));
263
+ }
264
+ const text = Buffer.concat(chunks).toString('utf8');
265
+ return {
266
+ ok: status >= 200 && status < 300,
267
+ status,
268
+ text: async () => text
269
+ };
270
+ }
271
+
272
+ /**
273
+ * GET a CIMD document over TLS, connected only through `init.lookup`.
274
+ * `redirect: 'error'` is required: a 3xx is a failure and is not followed.
275
+ *
276
+ * The real client connects with `net.connect` first and attaches an error
277
+ * handler before TLS starts. Wrapping an unreachable IPv6 address in TLS
278
+ * throws on the socket (`setServername` / `ENETUNREACH`) where `req.on('error')`
279
+ * never runs, and that throw crashes the process. A test double still uses
280
+ * `httpsImpl.request` and does not dial.
281
+ */
282
+ export function pinnedHttpsGet(url, init, httpsImpl = https) {
283
+ if (!init || init.redirect !== 'error') {
284
+ return Promise.reject(new Error('CIMD fetch does not follow redirects'));
285
+ }
286
+ if (typeof init.lookup !== 'function') {
287
+ return Promise.reject(new Error('CIMD fetch has no pinned address'));
288
+ }
289
+ const u = new URL(url);
290
+ const hostname = u.hostname.replace(/^\[|\]$/g, '');
291
+ if (!httpsImpl || httpsImpl.request !== https.request) {
292
+ return pinnedHttpsGetInjected(u, hostname, init, httpsImpl);
293
+ }
294
+ return pinnedHttpsGetConnected(u, hostname, init);
295
+ }
296
+
297
+ function pinnedHttpsGetInjected(u, hostname, init, httpsImpl) {
298
+ return new Promise((resolve, reject) => {
299
+ let settled = false;
300
+ const fail = (err) => { if (!settled) { settled = true; reject(err); } };
301
+ const req = httpsImpl.request({
302
+ protocol: 'https:',
303
+ hostname,
304
+ servername: hostname,
305
+ port: u.port || 443,
306
+ method: 'GET',
307
+ path: `${u.pathname}${u.search}`,
308
+ headers: { ...(init.headers || {}), host: u.host },
309
+ lookup: init.lookup,
310
+ timeout: CIMD_FETCH_TIMEOUT_MS
311
+ }, (res) => {
312
+ const status = res.statusCode || 0;
313
+ const chunks = [];
314
+ res.on('data', (c) => chunks.push(c));
315
+ res.on('error', fail);
316
+ res.on('end', () => {
317
+ if (settled) return;
318
+ settled = true;
319
+ Promise.resolve(cimdResult(status, chunks)).then(resolve, reject);
320
+ });
321
+ });
322
+ const onSocket = (socket) => {
323
+ if (!socket || typeof socket.on !== 'function') return;
324
+ socket.on('error', (err) => {
325
+ destroyQuiet(socket);
326
+ fail(err);
327
+ });
328
+ };
329
+ req.on('socket', onSocket);
330
+ req.on('error', fail);
331
+ req.on('timeout', () => req.destroy(new Error('CIMD fetch timed out')));
332
+ if (init.signal) {
333
+ const abort = () => req.destroy(new Error('CIMD fetch aborted'));
334
+ if (init.signal.aborted) abort();
335
+ else init.signal.addEventListener('abort', abort, { once: true });
336
+ }
337
+ req.end();
338
+ });
339
+ }
340
+
341
+ function pinnedHttpsGetConnected(u, hostname, init) {
342
+ const port = Number(u.port || 443);
343
+ return new Promise((resolve, reject) => {
344
+ let settled = false;
345
+ let raw = null;
346
+ let secure = null;
347
+ let req = null;
348
+ const timer = setTimeout(() => fail(new Error('CIMD fetch timed out')), CIMD_FETCH_TIMEOUT_MS);
349
+ const onAbort = () => fail(new Error('CIMD fetch aborted'));
350
+ if (init.signal) {
351
+ if (init.signal.aborted) {
352
+ clearTimeout(timer);
353
+ reject(new Error('CIMD fetch aborted'));
354
+ return;
355
+ }
356
+ init.signal.addEventListener('abort', onAbort, { once: true });
357
+ }
358
+ function cleanup() {
359
+ clearTimeout(timer);
360
+ if (init.signal) init.signal.removeEventListener('abort', onAbort);
361
+ if (req) {
362
+ req.removeAllListeners('error');
363
+ req.on('error', () => {});
364
+ req.destroy();
365
+ req = null;
366
+ }
367
+ destroyQuiet(secure);
368
+ destroyQuiet(raw);
369
+ secure = null;
370
+ raw = null;
371
+ }
372
+ function fail(err) {
373
+ if (settled) return;
374
+ settled = true;
375
+ cleanup();
376
+ reject(err instanceof Error ? err : new Error(String(err)));
377
+ }
378
+ function succeed(status, chunks) {
379
+ if (settled) return;
380
+ settled = true;
381
+ cleanup();
382
+ Promise.resolve(cimdResult(status, chunks)).then(resolve, reject);
383
+ }
384
+ try {
385
+ raw = net.connect({ host: hostname, port, lookup: init.lookup });
386
+ } catch (err) {
387
+ fail(err);
388
+ return;
389
+ }
390
+ raw.on('error', fail);
391
+ raw.on('connect', () => {
392
+ if (settled) return;
393
+ // init.ca is only for a test server. Production does not pass it, so the
394
+ // default trust store still checks the certificate.
395
+ secure = tls.connect({
396
+ socket: raw,
397
+ servername: hostname,
398
+ ...(init.ca ? { ca: init.ca } : {})
399
+ });
400
+ secure.on('error', fail);
401
+ secure.on('secureConnect', () => {
402
+ if (settled) return;
403
+ req = http.request({
404
+ createConnection: () => secure,
405
+ method: 'GET',
406
+ host: hostname,
407
+ path: `${u.pathname}${u.search}`,
408
+ headers: { ...(init.headers || {}), host: u.host }
409
+ }, (res) => {
410
+ const status = res.statusCode || 0;
411
+ const chunks = [];
412
+ res.on('data', (c) => chunks.push(c));
413
+ res.on('error', fail);
414
+ res.on('end', () => succeed(status, chunks));
415
+ });
416
+ req.on('error', fail);
417
+ req.end();
418
+ });
419
+ });
420
+ });
421
+ }
422
+
423
+ export function withCimdMetadata(metadata, enabled) {
424
+ const out = { ...(metadata || {}) };
425
+ const methods = Array.isArray(out.token_endpoint_auth_methods_supported) ? [...out.token_endpoint_auth_methods_supported] : [];
426
+ if (!methods.includes('none')) methods.push('none');
427
+ out.token_endpoint_auth_methods_supported = methods;
428
+ out.client_id_metadata_document_supported = enabled === true;
429
+ return out;
430
+ }
431
+
432
+ // ---------------------------------------------------------------------------
433
+
434
+ export class MongoOAuthProvider {
435
+ /**
436
+ * @param {() => import('mongodb').Db} getDb returns the connected Mongo Db (dbManager.db)
437
+ * @param {{ passphrase?: string, issuerUrl?: string, fetch?: Function, cimd?: boolean }} opts
438
+ * fetch — injectable for tests. Production leaves it unset and connects
439
+ * with pinnedHttpsGet to the address already checked.
440
+ * cimd — whether URL client_ids are resolved by fetching. Defaults to
441
+ * EMET_CIMD !== '0' (on unless explicitly disabled).
442
+ */
443
+ constructor(getDb, opts = {}) {
444
+ this._getDb = getDb;
445
+ this.prefix = COLLECTION_PREFIX;
446
+ this.passphrase = opts.passphrase || setting('OAUTH_PASSPHRASE') || '';
447
+ this.issuerUrl = opts.issuerUrl || process.env.PUBLIC_URL || '';
448
+ // A caller-supplied fetch is the test double. It does not dial the network.
449
+ // Production passes neither and connects with pinnedHttpsGet, which uses
450
+ // only the address assertCimdTargetPublic already accepted.
451
+ this._fetch = opts.fetch || null;
452
+ this._https = opts.https || https;
453
+ this._lookup = opts.lookup !== undefined
454
+ ? opts.lookup
455
+ : (opts.fetch ? async () => [{ address: '1.1.1.1', family: 4 }] : dnsPromises.lookup);
456
+ this.cimdEnabled = opts.cimd !== undefined ? Boolean(opts.cimd) : (setting('CIMD') !== '0');
457
+ this._resolveSubject = opts.resolveSubject || null;
458
+ // Whether invites are frozen (invites.js INVITES_FROZEN, read at module
459
+ // load like the rest of the invite gate). A function so suites can pin it.
460
+ this._invitesFrozen = typeof opts.invitesFrozen === 'function' ? opts.invitesFrozen : null;
461
+ // The same for EMET Member Access (invites.js MEMBER_ACCESS_FROZEN).
462
+ this._memberAccessFrozen = typeof opts.memberAccessFrozen === 'function' ? opts.memberAccessFrozen : null;
463
+ this.clientsStore = {
464
+ getClient: (id) => this._getClient(id),
465
+ registerClient: (client) => this._registerClient(client),
466
+ };
467
+ }
468
+
469
+ col(name) { return this._getDb().collection(`${this.prefix}oauth_${name}`); }
470
+
471
+ /** Find a token row by digest, falling back once to a pre-H2 clear row and migrating it. */
472
+ async _findToken(token, kind, extra = {}) {
473
+ const tokens = this.col('tokens');
474
+ const digest = tokenDigest(token);
475
+ const byDigest = await tokens.findOne({ token: digest, kind, ...extra });
476
+ if (byDigest) return byDigest;
477
+ const clear = await tokens.findOne({ token, kind, ...extra });
478
+ // A row that is ALREADY a digest must never match here: otherwise the
479
+ // stored digest itself would work as a bearer, which is the exact thing
480
+ // hashing exists to prevent. Caught by the test, not by the author.
481
+ if (!clear || clear.hashed === true) return null;
482
+ await tokens.updateOne({ _id: clear._id }, { $set: { token: digest, hashed: true } });
483
+ console.error('[emet][oauth] migrated a clear-text', kind, 'token row to its digest');
484
+ return { ...clear, token: digest, hashed: true };
485
+ }
486
+
487
+ async _getClient(clientId) {
488
+ if (isCimdClientId(clientId)) {
489
+ if (!this.cimdEnabled) return undefined;
490
+ return this._getCimdClient(clientId);
491
+ }
492
+ const doc = await this.col('clients').findOne({ client_id: clientId });
493
+ if (!doc) return undefined;
494
+ const { _id, ...rest } = doc;
495
+ return rest;
496
+ }
497
+
498
+ /**
499
+ * Resolve a CIMD client: serve from cache inside the TTL, otherwise fetch,
500
+ * validate, and cache. A fetch failure falls back to a stale cached copy if
501
+ * one exists (the client is still who it was an hour ago); with no cache it
502
+ * yields undefined, which the SDK reports as an invalid client. A document
503
+ * that fails validation is never cached.
504
+ */
505
+ async _getCimdClient(url) {
506
+ const clients = this.col('clients');
507
+ const cached = await clients.findOne({ client_id: url, cimd: true });
508
+ const fresh = cached && typeof cached.fetched_at === 'number' && (Date.now() - cached.fetched_at) < CIMD_TTL_MS;
509
+ if (fresh) { const { _id, ...rest } = cached; return rest; }
510
+
511
+ let doc;
512
+ try {
513
+ doc = await this._fetchCimdDocument(url);
514
+ } catch (e) {
515
+ console.error('[emet][oauth] CIMD fetch failed', url, e.message);
516
+ if (cached) { const { _id, ...rest } = cached; return rest; }
517
+ return undefined;
518
+ }
519
+
520
+ let client;
521
+ try {
522
+ client = validateCimdDocument(doc, url);
523
+ } catch (e) {
524
+ console.error('[emet][oauth] CIMD document rejected', url, e.message);
525
+ return undefined;
526
+ }
527
+
528
+ const record = { ...client, cimd: true, fetched_at: Date.now() };
529
+ await clients.updateOne({ client_id: url }, { $set: record }, { upsert: true });
530
+ // The success line completes the picture the two failure lines above
531
+ // started: a fetched-and-cached document is the CIMD path proving itself.
532
+ console.error('[emet][oauth] CIMD document fetched and cached', url, cached ? '(refresh)' : '(first registration)');
533
+ return record;
534
+ }
535
+
536
+ async _fetchCimdDocument(url) {
537
+ const host = new URL(url).hostname;
538
+ if (!takeCimdFetchSlot(host)) throw new Error('CIMD fetch rate limit');
539
+ const checked = await assertCimdTargetPublic(url, this._lookup);
540
+ const chosen = checked[0];
541
+ const controller = new AbortController();
542
+ const timer = setTimeout(() => controller.abort(), CIMD_FETCH_TIMEOUT_MS);
543
+ const init = {
544
+ headers: { Accept: 'application/json' },
545
+ redirect: 'error',
546
+ signal: controller.signal,
547
+ lookup: cimdPinnedLookup(chosen.address, chosen.family)
548
+ };
549
+ try {
550
+ const res = this._fetch
551
+ ? await this._fetch(url, init)
552
+ : await pinnedHttpsGet(url, init, this._https);
553
+ if (!res || !res.ok) throw new Error(`HTTP ${res ? res.status : 'no response'}`);
554
+ const text = await res.text();
555
+ if (typeof text !== 'string' || Buffer.byteLength(text, 'utf8') > CIMD_MAX_BYTES) {
556
+ throw new Error(`document exceeds ${CIMD_MAX_BYTES} bytes`);
557
+ }
558
+ try { return JSON.parse(text); }
559
+ catch { throw new Error('document is not valid JSON'); }
560
+ } finally {
561
+ clearTimeout(timer);
562
+ }
563
+ }
564
+
565
+ async _registerClient(client) {
566
+ const client_id = randTok(16);
567
+ const now = Math.floor(Date.now() / 1000);
568
+ const full = { ...client, client_id, client_id_issued_at: now };
569
+ await this.col('clients').insertOne({ ...full });
570
+ return full;
571
+ }
572
+
573
+ checkPassphrase(input) {
574
+ if (!this.passphrase) return false;
575
+ return timingSafeEqualStr(input, this.passphrase);
576
+ }
577
+
578
+ /**
579
+ * SDK GET /authorize -> renders a passphrase-gated consent page that POSTs
580
+ * to /oauth/approve (mounted by http-remote.js). We do NOT mint the code here;
581
+ * the passphrase check happens on approve.
582
+ */
583
+ async authorize(client, params, res) {
584
+ const fields = {
585
+ client_id: client.client_id,
586
+ redirect_uri: params.redirectUri,
587
+ code_challenge: params.codeChallenge,
588
+ state: params.state || '',
589
+ scopes: (params.scopes || []).join(' '),
590
+ resource: params.resource ? params.resource.href : '',
591
+ };
592
+ const hidden = Object.entries(fields)
593
+ .map(([k, v]) => `<input type="hidden" name="${k}" value="${escapeHtml(v)}">`)
594
+ .join('\n');
595
+ // The MCP authorization spec requires the consent screen to show the
596
+ // redirect target clearly, with a warning when it is a loopback address
597
+ // (any local process can bind a port and claim to be the client).
598
+ let redirectHost = '';
599
+ let loopback = false;
600
+ try { const u = new URL(params.redirectUri); redirectHost = u.host; loopback = ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname); } catch { /* shown blank */ }
601
+ const clientLabel = client.client_name ? `${escapeHtml(client.client_name)}` : escapeHtml(client.client_id);
602
+ const scopeList = (params.scopes || []).map((s) => escapeHtml(s)).filter(Boolean);
603
+ const scopeLine = scopeList.length ? scopeList.join(', ') : '(none requested)';
604
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
605
+ res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'");
606
+ res.setHeader('X-Frame-Options', 'DENY');
607
+ res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
608
+ res.setHeader('Cache-Control', 'no-store');
609
+ res.end(`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>EMET &middot; EMET</title>
610
+ <style>body{font-family:system-ui,-apple-system,sans-serif;background:#12141a;color:#e8e6e0;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}form{background:#1b1e26;padding:30px 26px;border-radius:14px;max-width:340px;width:88%;box-shadow:0 10px 44px #0009}h1{font-size:17px;margin:0 0 6px}p{font-size:13px;line-height:1.5;color:#9aa0ab;margin:0 0 18px}input[type=password]{width:100%;box-sizing:border-box;padding:12px;border-radius:9px;border:1px solid #333;background:#0e1014;color:#fff;font-size:15px}button{margin-top:14px;width:100%;padding:12px;border:0;border-radius:9px;background:#3a6ea5;color:#fff;font-size:15px;font-weight:600;cursor:pointer}small{display:block;margin-top:14px;color:#5c626d;font-size:11px}.warn{color:#d9a441}</style></head>
611
+ <body><form method="POST" action="/oauth/approve">${hidden}
612
+ <h1>Connect to EMET</h1>
613
+ <p>Authorize <b>${clientLabel}</b> to reach this EMET memory store. After approval you will be sent to <code>${escapeHtml(redirectHost)}</code>.${loopback ? ' <span class="warn">That is a local address on this device; only approve if you started this connection yourself.</span>' : ''} This approval grants: <b>${scopeLine}</b>. Owner: enter the passphrase and the host source tag. Guest: enter the invite code you were given.</p>
614
+ <input type="text" name="source" autocomplete="off" placeholder="Source tag (owner)" style="margin-bottom:10px;width:100%;box-sizing:border-box;padding:12px;border-radius:9px;border:1px solid #333;background:#0e1014;color:#fff;font-size:15px">
615
+ <input type="password" name="passphrase" autofocus autocomplete="current-password" placeholder="Passphrase (owner)">
616
+ <input type="text" name="invite_code" autocomplete="off" placeholder="Invite code (guest)" style="margin-top:10px;width:100%;box-sizing:border-box;padding:12px;border-radius:9px;border:1px solid #333;background:#0e1014;color:#fff;font-size:15px">
617
+ <button type="submit">Authorize this device</button>
618
+ <small>The well is deep. Only you hold the passphrase.</small>
619
+ </form></body></html>`);
620
+ }
621
+
622
+ /** Called by /oauth/approve AFTER a successful passphrase check. */
623
+ async createAuthorizationCode({ client_id, redirect_uri, code_challenge, scopes, resource, subject, issued_via, source_tag }) {
624
+ const code = randTok(24);
625
+ await this.col('codes').insertOne({
626
+ code: tokenDigest(code), hashed: true, client_id, redirect_uri, code_challenge,
627
+ scopes: scopes || [], resource: resource || null,
628
+ subject: subject || null,
629
+ source_tag: source_tag || null,
630
+ ...(DOOR_GRANTS.has(issued_via) ? { issued_via } : {}),
631
+ expiresAtMs: Date.now() + CODE_TTL_MS, used: false, createdAt: new Date(),
632
+ });
633
+ return code;
634
+ }
635
+
636
+ async challengeForAuthorizationCode(client, authorizationCode) {
637
+ const doc = await this.col('codes').findOne({ code: tokenDigest(authorizationCode), client_id: client.client_id });
638
+ if (!doc) throw new InvalidGrantError('this authorization code is not recognised - start the connection again');
639
+ return doc.code_challenge;
640
+ }
641
+
642
+ async exchangeAuthorizationCode(client, authorizationCode, _codeVerifier, redirectUri, resource) {
643
+ const codes = this.col('codes');
644
+ const digest = tokenDigest(authorizationCode);
645
+ const existing = await codes.findOne({ code: digest, client_id: client.client_id });
646
+ if (!existing) throw new InvalidGrantError('this authorization code is not recognised - start the connection again');
647
+ if (existing.expiresAtMs < Date.now()) throw new InvalidGrantError('this authorization code has expired - start the connection again');
648
+ if (redirectUri && existing.redirect_uri && redirectUri !== existing.redirect_uri) throw new InvalidGrantError('redirect_uri does not match the one this code was issued for');
649
+ const claimed = await codes.findOneAndUpdate(
650
+ { code: digest, client_id: client.client_id, used: false },
651
+ { $set: { used: true } }
652
+ );
653
+ const doc = claimed && Object.prototype.hasOwnProperty.call(claimed, 'value') ? claimed.value : claimed;
654
+ if (!doc) throw new InvalidGrantError('this authorization code has already been used - start the connection again');
655
+ return this._issueTokens(client.client_id, doc.scopes || [], doc.resource || (resource ? resource.href : null), doc.subject || null, doc.issued_via || null, null, doc.source_tag || null);
656
+ }
657
+
658
+ /**
659
+ * Refresh with ROTATION: every refresh issues a new refresh token and marks
660
+ * the presented one rotated. Inside REFRESH_ROTATION_GRACE_S the old token is
661
+ * still honoured (the client may not have received the response); after that
662
+ * it is refused with invalid_grant - the RFC 6749 code Claude's client expects
663
+ * so it re-authorizes instead of retrying forever.
664
+ */
665
+ async exchangeRefreshToken(client, refreshToken, scopes, resource) {
666
+ const tokens = this.col('tokens');
667
+ const rt = await this._findToken(refreshToken, 'refresh', { client_id: client.client_id });
668
+ // Every refusal here is an OAuth error the SDK maps to 400 invalid_grant
669
+ // (RFC 6749 section 5.2). A plain Error became a 500 server_error, which
670
+ // told the client the server was broken instead of "sign in again".
671
+ if (!rt) throw new InvalidGrantError('this refresh token is not recognised (it may have been revoked, or issued to another client) - reconnect to sign in again');
672
+ const nowS = Math.floor(Date.now() / 1000);
673
+ if (rt.expiresAt && rt.expiresAt < nowS) throw new InvalidGrantError('this refresh token has expired - reconnect to sign in again');
674
+ // A token issued before source tags were stored must not mint another
675
+ // token with an empty tag. The client signs in again; the new consent
676
+ // binds a registered tag. Expiry is reported first, so an expired
677
+ // legacy token still says it has expired.
678
+ if (!rt.source_tag) {
679
+ throw new InvalidGrantError('this login has no source tag bound. Sign in again so the new consent binds a registered tag.');
680
+ }
681
+ if (typeof rt.rotated_at === 'number') {
682
+ const withinGrace = (nowS - rt.rotated_at) <= REFRESH_ROTATION_GRACE_S && rt.grace_replacement;
683
+ if (withinGrace) return { ...rt.grace_replacement };
684
+ await this._revokeFamily(rt);
685
+ throw new InvalidGrantError('this refresh token has already been replaced by a newer one - reconnect to sign in again');
686
+ }
687
+ // R6: a token minted from an invite stops refreshing while invites are
688
+ // frozen, so a re-freeze ends it within one access-token lifetime instead
689
+ // of letting rotation renew it for ever. Passphrase tokens are untouched.
690
+ // Each door is checked against its own flag: a member token stops when EMET Member Access is off
691
+ // (even with guest access on), and keeps refreshing when only EMET_MEMBER_ACCESS=1; a guest token
692
+ // likewise against EMET_INVITES.
693
+ const door = await this._tokenDoor(rt);
694
+ if (door === 'member' && await this._memberAccessIsFrozen()) {
695
+ throw new InvalidGrantError('this login came from EMET Member Access, and member access is off on this install - it can no longer be refreshed');
696
+ }
697
+ if (door === 'guest' && await this._invitesAreFrozen()) {
698
+ throw new InvalidGrantError('this login came from an invite, and invites are off on this install - it can no longer be refreshed');
699
+ }
700
+ // Defect 1: a refresh keeps or narrows the scopes granted, never widens
701
+ // them (RFC 6749 section 6: a requested scope not originally granted is
702
+ // invalid_scope). No scope requested keeps the stored scopes.
703
+ const requested = (scopes || []).filter((s) => typeof s === 'string' && s.trim() !== '');
704
+ const granted = rt.scopes || [];
705
+ const wider = scopesNotGranted(granted, requested);
706
+ if (wider.length) {
707
+ throw new InvalidScopeError(`a refresh cannot widen scope: ${wider.join(', ')} was not granted (granted: ${granted.join(', ')})`);
708
+ }
709
+ const useScopes = requested.length ? requested : granted;
710
+ const family = rt.family || randTok(16);
711
+ const out = await this._issueTokens(client.client_id, useScopes, rt.resource || (resource ? resource.href : null), rt.subject || null, rt.issued_via || null, family, rt.source_tag || null);
712
+ const claim = await tokens.findOneAndUpdate(
713
+ { _id: rt._id, rotated_at: { $exists: false } },
714
+ { $set: { rotated_at: nowS, replaced_by: tokenDigest(out.refresh_token), grace_replacement: out, family } }
715
+ );
716
+ const won = claim && (Object.prototype.hasOwnProperty.call(claim, 'value') ? claim.value : claim);
717
+ if (!won) {
718
+ await tokens.deleteOne({ token: tokenDigest(out.access_token), kind: 'access' });
719
+ await tokens.deleteOne({ token: tokenDigest(out.refresh_token), kind: 'refresh' });
720
+ const fresh = await tokens.findOne({ _id: rt._id });
721
+ if (fresh && fresh.grace_replacement && typeof fresh.rotated_at === 'number' && (nowS - fresh.rotated_at) <= REFRESH_ROTATION_GRACE_S) {
722
+ return { ...fresh.grace_replacement };
723
+ }
724
+ await this._revokeFamily(fresh || rt);
725
+ throw new InvalidGrantError('this refresh token has already been replaced by a newer one - reconnect to sign in again');
726
+ }
727
+ return out;
728
+ }
729
+
730
+ /** A reused refresh token outside the grace window ends every token in its family. */
731
+ async _revokeFamily(rt) {
732
+ const tokens = this.col('tokens');
733
+ if (rt && rt.family) {
734
+ await tokens.deleteMany({ family: rt.family });
735
+ return;
736
+ }
737
+ if (rt && rt._id) await tokens.deleteOne({ _id: rt._id });
738
+ }
739
+
740
+ /**
741
+ * Whether a stored token came from an invite. New tokens carry
742
+ * issued_via: 'invite' from the code the invite approval minted, and keep it
743
+ * through every refresh. Tokens issued before that field existed are judged
744
+ * by their subject: a person whose role is 'member' was created only by an
745
+ * invite redemption (invites.js), while a passphrase token's subject is the
746
+ * owner person (or the legacy 'owner'), so passphrase tokens never match.
747
+ */
748
+ async _isInviteToken(rt) {
749
+ return (await this._tokenDoor(rt)) !== null;
750
+ }
751
+
752
+ /**
753
+ * Which door a stored token came through: 'member' (EMET Member Access, issued_via 'member'),
754
+ * 'guest' (EMET Guest Access, issued_via 'invite'), or null (passphrase). A legacy token with no
755
+ * issued_via is judged by its subject's person row: role 'member' with door 'member' is a member,
756
+ * any other role 'member' is a guest (every such row before the member door was a guest).
757
+ */
758
+ async _tokenDoor(rt) {
759
+ if (rt.issued_via === MEMBER_GRANT) return 'member';
760
+ if (rt.issued_via === INVITE_GRANT) return 'guest';
761
+ if (rt.issued_via !== undefined && rt.issued_via !== null) return null;
762
+ if (!rt.subject || rt.subject === 'owner') return null;
763
+ const person = await this._getDb().collection('persons').findOne({ person_id: rt.subject });
764
+ if (!person || person.role !== 'member') return null;
765
+ return person.door === 'member' ? 'member' : 'guest';
766
+ }
767
+
768
+ async _memberAccessIsFrozen() {
769
+ if (this._memberAccessFrozen) return Boolean(await this._memberAccessFrozen());
770
+ const { MEMBER_ACCESS_FROZEN } = await import('./invites.js');
771
+ return MEMBER_ACCESS_FROZEN;
772
+ }
773
+
774
+ async _invitesAreFrozen() {
775
+ if (this._invitesFrozen) return Boolean(await this._invitesFrozen());
776
+ const { INVITES_FROZEN } = await import('./invites.js');
777
+ return INVITES_FROZEN;
778
+ }
779
+
780
+ async _subject() {
781
+ if (typeof this._resolveSubject === 'function') {
782
+ try { return (await this._resolveSubject()) || 'owner'; }
783
+ catch { return 'owner'; }
784
+ }
785
+ try {
786
+ const { resolveTokenSubject } = await import('./persons.js');
787
+ return await resolveTokenSubject(this._getDb());
788
+ } catch {
789
+ return 'owner';
790
+ }
791
+ }
792
+
793
+ async _issueTokens(clientId, scopes, resource, subjectOverride = null, issuedVia = null, family = null, sourceTag = null) {
794
+ const now = Math.floor(Date.now() / 1000);
795
+ const subject = subjectOverride || await this._subject();
796
+ const fam = family || randTok(16);
797
+ // Only a door grant (guest 'invite' or 'member') is marked; passphrase rows keep today's shape.
798
+ const via = DOOR_GRANTS.has(issuedVia) ? { issued_via: issuedVia } : {};
799
+ const access = randTok(32);
800
+ await this.col('tokens').insertOne({
801
+ token: tokenDigest(access), hashed: true, kind: 'access', client_id: clientId, scopes, resource: resource || null,
802
+ subject, ...via, family: fam, source_tag: sourceTag || null, expiresAt: now + ACCESS_TTL_S, createdAt: new Date(),
803
+ });
804
+ const refresh = randTok(32);
805
+ await this.col('tokens').insertOne({
806
+ token: tokenDigest(refresh), hashed: true, kind: 'refresh', client_id: clientId, scopes, resource: resource || null,
807
+ subject, ...via, family: fam, source_tag: sourceTag || null, expiresAt: now + REFRESH_TTL_S, createdAt: new Date(),
808
+ });
809
+ const out = { access_token: access, token_type: 'bearer', expires_in: ACCESS_TTL_S, refresh_token: refresh };
810
+ if (scopes && scopes.length) out.scope = scopes.join(' ');
811
+ return out;
812
+ }
813
+
814
+ async verifyAccessToken(token) {
815
+ // InvalidTokenError is what the SDK bearer middleware answers with 401 and a
816
+ // WWW-Authenticate header, so the client refreshes or re-authorizes. A plain
817
+ // Error here became a 500 server_error for every expired or revoked login.
818
+ // No double quotes in these messages: they are copied into that header.
819
+ const doc = await this._findToken(token, 'access');
820
+ if (!doc) throw new InvalidTokenError('this access token is not recognised (it may have been revoked or replaced) - sign in again');
821
+ if (doc.expiresAt && doc.expiresAt * 1000 < Date.now()) throw new InvalidTokenError('this access token has expired - refresh it or sign in again');
822
+ return {
823
+ token,
824
+ clientId: doc.client_id,
825
+ scopes: doc.scopes || [],
826
+ expiresAt: doc.expiresAt,
827
+ extra: { subject: doc.subject || 'owner', sourceTag: doc.source_tag || null, grantId: doc.family || null },
828
+ };
829
+ }
830
+
831
+ async revokeToken(client, request) {
832
+ const tokens = this.col('tokens');
833
+ const r = await tokens.deleteOne({ token: tokenDigest(request.token), client_id: client.client_id });
834
+ if (r.deletedCount) return;
835
+ const clear = await tokens.findOne({ token: request.token, client_id: client.client_id }); // a pre-H2 clear row
836
+ if (clear && clear.hashed !== true) await tokens.deleteOne({ _id: clear._id });
837
+ }
838
+ }