@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,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 ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * EMET - persons
3
+ *
4
+ * A person is who logged in. A host-tag is which door they used.
5
+ * Tokens used to carry subject: "owner" for every connection. That cannot
6
+ * scale to a second human. This module is the first cut: one owner person
7
+ * in a `persons` collection, and a subject resolver the token issuer calls.
8
+ *
9
+ * Invitation, membership, and scopes on dispatch are later cuts.
10
+ */
11
+
12
+ import { AsyncLocalStorage } from 'node:async_hooks';
13
+
14
+ const COLLECTION = 'persons';
15
+ /** Stdio has no login. Every local write is stamped with this one subject. */
16
+ export const LOCAL_SUBJECT = 'local';
17
+ export const actorStore = new AsyncLocalStorage();
18
+ export function currentActor() {
19
+ const s = actorStore.getStore();
20
+ if (typeof s === 'string' && s.trim()) return { personId: s.trim(), scopes: [] };
21
+ if (s && typeof s === 'object') {
22
+ return {
23
+ personId: s.personId || null,
24
+ clientId: s.clientId || null,
25
+ scopes: Array.isArray(s.scopes) ? s.scopes : [],
26
+ transport: s.transport || null,
27
+ authenticated: s.authenticated === true
28
+ };
29
+ }
30
+ return { personId: null, clientId: null, scopes: [], transport: null, authenticated: false };
31
+ }
32
+ export function currentPersonId() {
33
+ return currentActor().personId;
34
+ }
35
+ export function currentClientId() {
36
+ const s = actorStore.getStore();
37
+ if (!s || typeof s === 'string') return 'stdio';
38
+ return s.clientId === undefined ? 'stdio' : s.clientId;
39
+ }
40
+
41
+ /** Server stamp only. A caller value is never read. Stdio is one fixed local subject. */
42
+ export function writerStamp() {
43
+ const s = actorStore.getStore();
44
+ if (!s || typeof s === 'string') {
45
+ const id = typeof s === 'string' && s.trim() ? s.trim() : LOCAL_SUBJECT;
46
+ return { person_id: id, client_id: 'stdio' };
47
+ }
48
+ if (s.transport === 'http') {
49
+ return { person_id: s.personId || null, client_id: s.clientId === undefined ? null : s.clientId };
50
+ }
51
+ return { person_id: s.personId || LOCAL_SUBJECT, client_id: s.clientId === undefined ? 'stdio' : s.clientId };
52
+ }
53
+
54
+ /**
55
+ * HTTP binds a source tag at approval. Returns that tag (or null when none
56
+ * was bound). Returns undefined on stdio, where the caller tag is checked
57
+ * against EMET_SOURCE_TAGS instead.
58
+ */
59
+ export function boundSourceTag() {
60
+ const s = actorStore.getStore();
61
+ if (!s || typeof s !== 'object' || s.transport !== 'http') return undefined;
62
+ const tag = typeof s.sourceTag === 'string' ? s.sourceTag.trim() : '';
63
+ return tag || null;
64
+ }
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
+
86
+ export function displayNameFromUserDoc(text) {
87
+ const heading = String(text || '').match(/^#\s+User Profile\s+[\u2014\u2013-]\s+(.+)$/im);
88
+ if (heading) return heading[1].trim();
89
+ return null;
90
+ }
91
+
92
+ export function slugPersonId(name) {
93
+ const s = String(name || '')
94
+ .normalize('NFKD')
95
+ .replace(/[\u0300-\u036f]/g, '')
96
+ .toLowerCase()
97
+ .replace(/[^a-z0-9]+/g, '-')
98
+ .replace(/^-+|-+$/g, '')
99
+ .slice(0, 40);
100
+ return s || 'owner';
101
+ }
102
+
103
+ export async function ensureOwnerPerson(db, { display_name } = {}) {
104
+ const col = db.collection(COLLECTION);
105
+ const existing = await col.findOne({ role: 'owner' });
106
+ if (existing) return existing;
107
+ const person_id = slugPersonId(display_name);
108
+ const row = {
109
+ person_id,
110
+ display_name: String(display_name || person_id).trim() || person_id,
111
+ role: 'owner',
112
+ created_at: new Date()
113
+ };
114
+ await col.insertOne(row);
115
+ return row;
116
+ }
117
+
118
+ /** Subject stamped on new tokens. Falls back to "owner" if the table is empty. */
119
+ export async function resolveTokenSubject(db) {
120
+ if (!db) return 'owner';
121
+ const owner = await db.collection(COLLECTION).findOne({ role: 'owner' });
122
+ return owner?.person_id || 'owner';
123
+ }
124
+
125
+ /** Person on this request, from the bearer token. Host-tags stay separate. */
126
+ export function personFromAuth(extra) {
127
+ const info = extra && extra.authInfo;
128
+ const s = (info && info.extra && info.extra.subject) || (info && info.subject);
129
+ if (typeof s === 'string' && s.trim()) return s.trim();
130
+ return null;
131
+ }
132
+
133
+ export async function getPerson(db, person_id) {
134
+ if (!db || !person_id) return null;
135
+ return db.collection(COLLECTION).findOne({ person_id });
136
+ }
@@ -0,0 +1,257 @@
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
+ * Provenance: how a tier-1 entry reaches the tier-2 record that justifies it.
8
+ *
9
+ * "Epistemic Tiers" names the pairing of a claim with its evidence. A layer
10
+ * entry that asserts something, with no reachable source, is a claim the system
11
+ * merely happens to hold - so the link between the tiers is not a convenience,
12
+ * it is the half of the name that makes the other half worth anything.
13
+ *
14
+ * TWO MECHANISMS, DELIBERATELY, AND THEY ARE NOT REDUNDANT COPIES.
15
+ *
16
+ * POINTER - the entry records the transcript span it came from: a session id
17
+ * and a range of exchange indices. Exact. Conditional: absent on
18
+ * everything written before pointers existed, and legitimately
19
+ * absent on entries consolidated from other entries rather than
20
+ * from a conversation.
21
+ *
22
+ * TIMESTAMP - every entry has one, unconditionally, so a window around it can
23
+ * always be searched. Universal. Approximate: it depends on
24
+ * choosing the window, it degrades when sessions overlap or run
25
+ * long, and when it is wrong it returns the NEIGHBOURING span
26
+ * rather than failing. A silent wrong answer is the worst possible
27
+ * failure shape for a provenance mechanism.
28
+ *
29
+ * PRECEDENCE IS WHAT KEEPS THEM FROM CONFLICTING. The pointer wins whenever it
30
+ * is present; the timestamp is the fallback when it is null. They are never
31
+ * averaged, never combined, and the timestamp is never preferred merely because
32
+ * it returned something. Without a stated precedence two links are ambiguity;
33
+ * with one they are a primary and a fallback.
34
+ *
35
+ * AND KEEPING BOTH BUYS SOMETHING NEITHER GIVES ALONE: they can check each
36
+ * other. If an entry's pointer names a session whose span sits nowhere near that
37
+ * entry's own timestamp, something is wrong - a mis-written pointer, a field
38
+ * copied from another entry, an entry attributed to the wrong session. Two
39
+ * independently derived answers that can disagree is redundancy in the
40
+ * engineering sense. A single link that can only be trusted is not.
41
+ *
42
+ * WHY THESE ARE TOP-LEVEL FIELDS. They sit beside the entry, not inside the
43
+ * metadata JSON string, for the same reason `superseded_by`, `valid_until` and
44
+ * `is_live` were promoted: the store cannot filter into a JSON string, so a
45
+ * pointer buried there could be written but never queried - and the reverse
46
+ * question, "what did we take from this session?", is exactly a query.
47
+ *
48
+ * NO BACKFILL. An entry written before pointers existed cannot be given a true
49
+ * one; the only way to invent it is to infer the span from the timestamp, which
50
+ * is precisely the weaker method the pointer exists to replace. Historical
51
+ * entries keep a null pointer and resolve by timestamp, and that remains correct
52
+ * for them - superseded, not wrong. Old records are not rewritten to look like
53
+ * new ones; the method that applies to them is written down instead.
54
+ */
55
+
56
+ import { ValidationError } from './validation.js';
57
+
58
+ /** Default half-width of the timestamp fallback window, in seconds. */
59
+ export const FALLBACK_WINDOW_SECONDS = 3600;
60
+
61
+ /** How far a pointer's session may sit from an entry's timestamp before the
62
+ * cross-check calls it suspicious. Generous: long sessions are normal, and a
63
+ * false alarm here trains its reader to ignore the check. */
64
+ const CROSS_CHECK_TOLERANCE_SECONDS = 24 * 3600;
65
+
66
+ function isPositiveInt(v) {
67
+ return Number.isInteger(v) && v > 0;
68
+ }
69
+
70
+ /**
71
+ * Validate a caller-supplied provenance object.
72
+ *
73
+ * Accepts either shape, and only one of them:
74
+ * { session_id, exchange_start, exchange_end } - derived from a conversation
75
+ * { entries: [id, ...] } - consolidated from other entries
76
+ *
77
+ * The second shape exists because a required field with no honest value invites
78
+ * fabrication. An entry consolidated from three others has no transcript span,
79
+ * and must be able to say so explicitly rather than carry a nearest-timestamp
80
+ * guess dressed as a citation.
81
+ */
82
+ export function validateProvenance(input) {
83
+ if (input === null || input === undefined) return null;
84
+ if (typeof input !== 'object' || Array.isArray(input)) {
85
+ throw new ValidationError('derived_from', 'must be an object');
86
+ }
87
+
88
+ const hasSpan = input.session_id !== undefined;
89
+ const hasEntries = input.entries !== undefined;
90
+
91
+ if (hasSpan && hasEntries) {
92
+ throw new ValidationError(
93
+ 'derived_from',
94
+ 'give a transcript span or a list of source entries, not both - an entry has one origin'
95
+ );
96
+ }
97
+ if (!hasSpan && !hasEntries) {
98
+ throw new ValidationError(
99
+ 'derived_from',
100
+ 'needs either session_id (with optional exchange_start/exchange_end) or entries'
101
+ );
102
+ }
103
+
104
+ if (hasEntries) {
105
+ if (!Array.isArray(input.entries) || input.entries.length === 0) {
106
+ throw new ValidationError('derived_from.entries', 'must be a non-empty array of entry ids');
107
+ }
108
+ if (!input.entries.every(isPositiveInt)) {
109
+ throw new ValidationError('derived_from.entries', 'entry ids must be positive integers');
110
+ }
111
+ return { entries: [...input.entries] };
112
+ }
113
+
114
+ if (typeof input.session_id !== 'string' || input.session_id.length === 0) {
115
+ throw new ValidationError('derived_from.session_id', 'must be a non-empty string');
116
+ }
117
+ const start = input.exchange_start;
118
+ const end = input.exchange_end;
119
+ if (start !== undefined && !isPositiveInt(start)) {
120
+ throw new ValidationError('derived_from.exchange_start', 'must be a positive integer');
121
+ }
122
+ if (end !== undefined && !isPositiveInt(end)) {
123
+ throw new ValidationError('derived_from.exchange_end', 'must be a positive integer');
124
+ }
125
+ if (isPositiveInt(start) && isPositiveInt(end) && end < start) {
126
+ throw new ValidationError('derived_from.exchange_end', 'must not be before exchange_start');
127
+ }
128
+
129
+ return {
130
+ session_id: input.session_id,
131
+ exchange_start: isPositiveInt(start) ? start : null,
132
+ exchange_end: isPositiveInt(end) ? end : (isPositiveInt(start) ? start : null)
133
+ };
134
+ }
135
+
136
+ /**
137
+ * The top-level columns to store for a validated provenance object.
138
+ * Always returns every key, so an entry without provenance stores explicit nulls
139
+ * rather than absent fields - absence and "no pointer" must not be the same
140
+ * shape as "this field was never part of the schema".
141
+ */
142
+ export function provenanceColumns(provenance) {
143
+ if (!provenance) {
144
+ return { source_session_id: null, source_exchange_start: null, source_exchange_end: null, derived_from_ids: null };
145
+ }
146
+ if (provenance.entries) {
147
+ return { source_session_id: null, source_exchange_start: null, source_exchange_end: null, derived_from_ids: provenance.entries };
148
+ }
149
+ return {
150
+ source_session_id: provenance.session_id,
151
+ source_exchange_start: provenance.exchange_start,
152
+ source_exchange_end: provenance.exchange_end,
153
+ derived_from_ids: null
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Resolve how to reach the evidence for an entry.
159
+ *
160
+ * This is the precedence rule, in one place, so no caller has to decide it.
161
+ */
162
+ export function resolveProvenance(entry, windowSeconds = FALLBACK_WINDOW_SECONDS) {
163
+ if (!entry) throw new ValidationError('entry', 'is required');
164
+
165
+ if (entry.source_session_id) {
166
+ return {
167
+ method: 'pointer',
168
+ exact: true,
169
+ session_id: entry.source_session_id,
170
+ exchange_start: entry.source_exchange_start ?? null,
171
+ exchange_end: entry.source_exchange_end ?? null,
172
+ how: 'Read this session\'s transcript and take the named exchange range.'
173
+ };
174
+ }
175
+
176
+ if (Array.isArray(entry.derived_from_ids) && entry.derived_from_ids.length > 0) {
177
+ return {
178
+ method: 'entries',
179
+ exact: true,
180
+ entries: entry.derived_from_ids,
181
+ how: 'This entry was consolidated from other entries. Read those; they carry their own provenance.'
182
+ };
183
+ }
184
+
185
+ const timestamp = typeof entry.timestamp === 'number' ? entry.timestamp : null;
186
+ if (timestamp === null) {
187
+ return {
188
+ method: 'none',
189
+ exact: false,
190
+ how: 'No pointer and no timestamp. The source of this entry cannot be reached; treat the claim as unsourced.'
191
+ };
192
+ }
193
+
194
+ return {
195
+ method: 'timestamp',
196
+ exact: false,
197
+ timestamp_start: timestamp - windowSeconds,
198
+ timestamp_end: timestamp + windowSeconds,
199
+ how:
200
+ 'No pointer on this entry, so fall back to searching transcripts over a window around its ' +
201
+ 'timestamp. This is approximate: it can return a neighbouring span rather than the real ' +
202
+ 'one, and an empty result is not proof that nothing was recorded. Do not cite a window ' +
203
+ 'result with the confidence of a pointer.'
204
+ };
205
+ }
206
+
207
+ /**
208
+ * Cross-check a pointer against the entry's own timestamp.
209
+ *
210
+ * The reason to keep both mechanisms rather than replacing one with the other:
211
+ * two independently derived answers can disagree, and a disagreement is
212
+ * information. Returns a finding; it never throws and never blocks a write,
213
+ * because a suspicious pointer is a thing to look at, not a thing to refuse.
214
+ */
215
+ export function crossCheckProvenance(entry, transcript, toleranceSeconds = CROSS_CHECK_TOLERANCE_SECONDS) {
216
+ if (!entry || !entry.source_session_id) return { checked: false, reason: 'no pointer to check' };
217
+ if (!transcript) {
218
+ return {
219
+ checked: true,
220
+ agrees: false,
221
+ finding: `Entry points at session "${entry.source_session_id}", which does not exist in the transcript store.`
222
+ };
223
+ }
224
+
225
+ const toSeconds = (v) => {
226
+ if (v instanceof Date) return v.getTime() / 1000;
227
+ if (typeof v === 'number') return v > 1e11 ? v / 1000 : v;
228
+ return null;
229
+ };
230
+ const start = toSeconds(transcript.session_start);
231
+ const end = toSeconds(transcript.session_end) ?? start;
232
+ const at = typeof entry.timestamp === 'number' ? entry.timestamp : null;
233
+
234
+ if (at === null || start === null) return { checked: false, reason: 'not enough timing information to compare' };
235
+
236
+ const distance = at < start ? start - at : (at > end ? at - end : 0);
237
+ if (distance <= toleranceSeconds) return { checked: true, agrees: true, distance_seconds: Math.round(distance) };
238
+
239
+ return {
240
+ checked: true,
241
+ agrees: false,
242
+ distance_seconds: Math.round(distance),
243
+ finding:
244
+ `Entry timestamp sits ${Math.round(distance / 3600)}h outside the session its pointer names. ` +
245
+ `The pointer and the timestamp disagree about where this entry came from - likely a field ` +
246
+ `copied from another entry, or an entry attributed to the wrong session. Report it; do not ` +
247
+ `silently prefer either one.`
248
+ };
249
+ }
250
+
251
+ export default {
252
+ validateProvenance,
253
+ provenanceColumns,
254
+ resolveProvenance,
255
+ crossCheckProvenance,
256
+ FALLBACK_WINDOW_SECONDS
257
+ };