@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.
- package/.env.example +189 -0
- package/CHANGELOG.md +360 -0
- package/CHARTER.md +137 -0
- package/LICENSE +22 -0
- package/README.md +868 -2
- package/docs/AUTHORSHIP.md +26 -0
- package/docs/BENCHMARKS.md +261 -0
- package/docs/BOOTSTRAP.md +127 -0
- package/docs/BOTS.md +83 -0
- package/docs/COVERAGE.md +64 -0
- package/docs/DEPLOY.md +251 -0
- package/docs/DISCIPLINES.md +29 -0
- package/docs/GUARDRAILS-AND-MONORAILS.md +108 -0
- package/docs/GUIDED-SETUP.md +301 -0
- package/docs/PERMISSIONS.md +121 -0
- package/docs/ROADMAP.md +62 -0
- package/docs/SETUP.md +265 -0
- package/docs/SUBKEYS.md +59 -0
- package/gems/artifact-index.json +12 -0
- package/gems/bot-team-backup/floor.json +15 -0
- package/gems/bot-team-backup/gem.json +43 -0
- package/gems/bot-team-backup/guides/BOT_TEAM_BACKUP.md +27 -0
- package/gems/bot-team-backup/guides/BOT_TEAM_RECOVERY.md +23 -0
- package/gems/fieldnote/floor.json +14 -0
- package/gems/fieldnote/gem.json +70 -0
- package/gems/fieldnote/runes/fieldnote.js +4 -0
- package/gems/lean-six-sigma/CHANGELOG.md +10 -0
- package/gems/lean-six-sigma/README.md +92 -0
- package/gems/lean-six-sigma/floor.json +31 -0
- package/gems/lean-six-sigma/gem.json +1296 -0
- package/gems/lean-six-sigma/guides/ASSUMPTIONS.md +45 -0
- package/gems/lean-six-sigma/guides/BELTS.md +52 -0
- package/gems/lean-six-sigma/guides/DMADV.md +56 -0
- package/gems/lean-six-sigma/guides/DMAIC.md +127 -0
- package/gems/lean-six-sigma/guides/FORMULAS.md +330 -0
- package/gems/lean-six-sigma/guides/LEAN.md +97 -0
- package/gems/lean-six-sigma/guides/TEMPLATES.md +279 -0
- package/gems/lean-six-sigma/runes/lss_capability.js +132 -0
- package/gems/lean-six-sigma/runes/lss_control_limits.js +240 -0
- package/gems/lean-six-sigma/runes/lss_dpmo_sigma.js +81 -0
- package/gems/lean-six-sigma/runes/lss_hypothesis.js +298 -0
- package/gems/lean-six-sigma/runes/lss_sample_size.js +81 -0
- package/gems/neurendipity/floor.json +65 -0
- package/gems/neurendipity/gem.json +42 -0
- package/gems/neurendipity/guides/AUTISTIC_BURNOUT.md +81 -0
- package/gems/neurendipity/guides/NEURODIVERGENT_SUPPORT.md +287 -0
- package/guides/README.md +3 -0
- package/package.json +76 -4
- package/server.json +79 -0
- package/src/aleph.js +395 -0
- package/src/annotations.js +170 -0
- package/src/approve-throttle.js +54 -0
- package/src/atlas-role.js +58 -0
- package/src/attribution.js +555 -0
- package/src/conformance.js +688 -0
- package/src/content_analyzer.js +450 -0
- package/src/database.js +812 -0
- package/src/disciplines.js +26 -0
- package/src/dispatch.js +382 -0
- package/src/env.js +97 -0
- package/src/floor.js +228 -0
- package/src/gaps.js +509 -0
- package/src/gem-check.js +1359 -0
- package/src/guides.js +75 -0
- package/src/host-reach.js +23 -0
- package/src/http-remote.js +313 -0
- package/src/index.js +397 -0
- package/src/invites.js +167 -0
- package/src/layers.js +449 -0
- package/src/lessons-to-checks.js +366 -0
- package/src/logger.js +300 -0
- package/src/metadata.js +61 -0
- package/src/module-path.js +32 -0
- package/src/mongo-connect.js +91 -0
- package/src/named-items.js +208 -0
- package/src/oauth-provider.js +838 -0
- package/src/owner-approvals.js +281 -0
- package/src/persons.js +136 -0
- package/src/provenance.js +257 -0
- package/src/redact.js +237 -0
- package/src/request-log.js +124 -0
- package/src/rune-dispatch.js +360 -0
- package/src/rune-registry.js +71 -0
- package/src/rune-scope.js +42 -0
- package/src/scopes.js +100 -0
- package/src/security-headers.js +26 -0
- package/src/session-graph.js +947 -0
- package/src/session.js +1097 -0
- package/src/setup.js +1580 -0
- package/src/socket.js +545 -0
- package/src/startup-pages.js +371 -0
- package/src/startup-payload.js +129 -0
- package/src/subkey-store.js +357 -0
- package/src/subkeys.js +947 -0
- package/src/tools.js +4508 -0
- package/src/transcript-session.js +106 -0
- package/src/validation.js +1187 -0
- package/src/version.js +2 -0
- package/templates/BOARD.md +72 -0
- package/templates/BOTHANDOFF.md +57 -0
- package/templates/CHARACTER.md +30 -0
- package/templates/HANDOFF.md +63 -0
- package/templates/IDENTITY.md +29 -0
- package/templates/INDEX.md +82 -0
- package/templates/INSTALL.md +29 -0
- package/templates/PROCEDURE.md +39 -0
- package/templates/ROSTER.md +43 -0
- package/templates/SEED.md +65 -0
- package/templates/STAGING.md +37 -0
- package/templates/STANDARD.md +99 -0
- package/templates/THREAD.md +43 -0
- package/templates/USER.md +32 -0
- package/verification/load-gate/corpus.json +1765 -0
- 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
+
};
|