@dreamforgestudiollc/emet 0.0.0-stage → 0.2.4
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 +136 -0
- package/CHANGELOG.md +296 -0
- package/CHARTER.md +125 -0
- package/LICENSE +22 -0
- package/README.md +783 -2
- package/docs/AUTHORSHIP.md +26 -0
- package/docs/BENCHMARKS.md +261 -0
- package/docs/BOOTSTRAP.md +127 -0
- package/docs/BOTS.md +79 -0
- package/docs/COVERAGE.md +64 -0
- package/docs/DEPLOY.md +246 -0
- package/docs/DISCIPLINES.md +29 -0
- package/docs/GUARDRAILS-AND-MONORAILS.md +107 -0
- package/docs/GUIDED-SETUP.md +296 -0
- package/docs/PERMISSIONS.md +118 -0
- package/docs/ROADMAP.md +59 -0
- package/docs/SETUP.md +259 -0
- package/guides/NEURODIVERGENT_SUPPORT.md +278 -0
- package/package.json +70 -4
- package/server.json +39 -0
- package/src/aleph.js +395 -0
- package/src/annotations.js +135 -0
- package/src/approve-throttle.js +54 -0
- package/src/atlas-role.js +58 -0
- package/src/conformance.js +688 -0
- package/src/content_analyzer.js +450 -0
- package/src/database.js +811 -0
- package/src/disciplines.js +26 -0
- package/src/dispatch.js +279 -0
- package/src/env.js +52 -0
- package/src/floor.js +187 -0
- package/src/gaps.js +476 -0
- package/src/guides.js +48 -0
- package/src/http-remote.js +269 -0
- package/src/index.js +390 -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 +299 -0
- package/src/metadata.js +61 -0
- package/src/module-path.js +32 -0
- package/src/mongo-connect.js +48 -0
- package/src/named-items.js +208 -0
- package/src/oauth-provider.js +838 -0
- package/src/persons.js +116 -0
- package/src/provenance.js +257 -0
- package/src/redact.js +231 -0
- package/src/request-log.js +124 -0
- package/src/scopes.js +97 -0
- package/src/security-headers.js +26 -0
- package/src/session-graph.js +945 -0
- package/src/session.js +1097 -0
- package/src/setup.js +1313 -0
- package/src/startup-pages.js +354 -0
- package/src/startup-payload.js +126 -0
- package/src/tools.js +4271 -0
- package/src/transcript-session.js +106 -0
- package/src/validation.js +1163 -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 +29 -0
package/src/persons.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
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
|
+
export function displayNameFromUserDoc(text) {
|
|
67
|
+
const heading = String(text || '').match(/^#\s+User Profile\s+[\u2014\u2013-]\s+(.+)$/im);
|
|
68
|
+
if (heading) return heading[1].trim();
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function slugPersonId(name) {
|
|
73
|
+
const s = String(name || '')
|
|
74
|
+
.normalize('NFKD')
|
|
75
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
76
|
+
.toLowerCase()
|
|
77
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
78
|
+
.replace(/^-+|-+$/g, '')
|
|
79
|
+
.slice(0, 40);
|
|
80
|
+
return s || 'owner';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function ensureOwnerPerson(db, { display_name } = {}) {
|
|
84
|
+
const col = db.collection(COLLECTION);
|
|
85
|
+
const existing = await col.findOne({ role: 'owner' });
|
|
86
|
+
if (existing) return existing;
|
|
87
|
+
const person_id = slugPersonId(display_name);
|
|
88
|
+
const row = {
|
|
89
|
+
person_id,
|
|
90
|
+
display_name: String(display_name || person_id).trim() || person_id,
|
|
91
|
+
role: 'owner',
|
|
92
|
+
created_at: new Date()
|
|
93
|
+
};
|
|
94
|
+
await col.insertOne(row);
|
|
95
|
+
return row;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Subject stamped on new tokens. Falls back to "owner" if the table is empty. */
|
|
99
|
+
export async function resolveTokenSubject(db) {
|
|
100
|
+
if (!db) return 'owner';
|
|
101
|
+
const owner = await db.collection(COLLECTION).findOne({ role: 'owner' });
|
|
102
|
+
return owner?.person_id || 'owner';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Person on this request, from the bearer token. Host-tags stay separate. */
|
|
106
|
+
export function personFromAuth(extra) {
|
|
107
|
+
const info = extra && extra.authInfo;
|
|
108
|
+
const s = (info && info.extra && info.extra.subject) || (info && info.subject);
|
|
109
|
+
if (typeof s === 'string' && s.trim()) return s.trim();
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function getPerson(db, person_id) {
|
|
114
|
+
if (!db || !person_id) return null;
|
|
115
|
+
return db.collection(COLLECTION).findOne({ person_id });
|
|
116
|
+
}
|
|
@@ -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
|
+
};
|
package/src/redact.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
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
|
+
* Secret redaction at the write path.
|
|
8
|
+
*
|
|
9
|
+
* Why this exists (2026-09-07): an OAuth passphrase was pasted into a chat and a
|
|
10
|
+
* screenshot during a connector re-add, and the session transcript that would
|
|
11
|
+
* carry it verbatim was minutes away from being written to the store. A rule
|
|
12
|
+
* that says "do not paste secrets" is an instruction; a scrubber the content
|
|
13
|
+
* passes through before it lands anywhere is a control. This module is the
|
|
14
|
+
* control. It sits inside the single write path (THE DOOR, a decision record):
|
|
15
|
+
* memory saves, transcript saves, document writes, and log output all pass
|
|
16
|
+
* through it, so an instance cannot opt out by forgetting.
|
|
17
|
+
*
|
|
18
|
+
* What it masks, in order:
|
|
19
|
+
* 1. Literal values of secret-bearing environment variables (anything whose
|
|
20
|
+
* name contains PASSPHRASE, PASSWORD, SECRET, TOKEN, API_KEY, PRIVATE_KEY,
|
|
21
|
+
* or MONGODB_URI), plus the user:password pair inside any URI among them.
|
|
22
|
+
* These are exact-string matches and cannot false-positive.
|
|
23
|
+
* 2. Credentials embedded in URIs: scheme://user:password@host.
|
|
24
|
+
* 3. Bearer tokens and JWTs.
|
|
25
|
+
* 4. key/value pairs whose key names a secret (passphrase, password, secret,
|
|
26
|
+
* token, api_key, client_secret, access_token, refresh_token, authorization)
|
|
27
|
+
* followed by : or = and a value. The key survives, the value does not.
|
|
28
|
+
*
|
|
29
|
+
* What it deliberately does NOT mask: hex digests (sha256 values are 64 hex
|
|
30
|
+
* chars and are part of the integrity record), ids, and ordinary prose. Rule 4
|
|
31
|
+
* can mask a word in prose that follows "token:" - that is accepted. Masking a
|
|
32
|
+
* word costs a word; missing a secret costs the secret. (An English prefix
|
|
33
|
+
* hyphenated onto a key word - "re-authorization:" - is not treated as a key;
|
|
34
|
+
* that one masked prose on 2026-09-07 and is excluded.)
|
|
35
|
+
*
|
|
36
|
+
* The mask is visible on purpose: '*****' says "something was here and was
|
|
37
|
+
* removed", which is the honest record. A silent drop would be a summary.
|
|
38
|
+
*
|
|
39
|
+
* The hit count reports CHANGES. Text that already carries the mask is matched
|
|
40
|
+
* and left as it is, uncounted - otherwise every read-modify-write of a
|
|
41
|
+
* document re-reports the masks it already held (observed 2026-09-07).
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
export const MASK = '*****';
|
|
45
|
+
|
|
46
|
+
const SECRET_ENV_NAME = /(PASSPHRASE|PASSWORD|PASSWD|SECRET|TOKEN|API[_-]?KEY|PRIVATE[_-]?KEY|MONGODB_URI)/i;
|
|
47
|
+
const MIN_LITERAL_LENGTH = 6;
|
|
48
|
+
|
|
49
|
+
// Registered literal secrets. A Set so re-registering is a no-op.
|
|
50
|
+
const literals = new Set();
|
|
51
|
+
|
|
52
|
+
function escapeRegex(s) {
|
|
53
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Registers one literal secret value. Anything shorter than MIN_LITERAL_LENGTH
|
|
58
|
+
* is ignored - masking "abc" everywhere would shred ordinary text.
|
|
59
|
+
* If the value is a URI with credentials, the user:password pair is registered
|
|
60
|
+
* as well, so a URI quoted with a different host or query still masks.
|
|
61
|
+
*/
|
|
62
|
+
export function registerSecret(value) {
|
|
63
|
+
if (typeof value !== 'string') return false;
|
|
64
|
+
const v = value.trim();
|
|
65
|
+
if (v.length < MIN_LITERAL_LENGTH) return false;
|
|
66
|
+
literals.add(v);
|
|
67
|
+
literalOrder = null;
|
|
68
|
+
const m = v.match(/^[a-z][a-z0-9+.-]*:\/\/([^/@\s]+)@/i);
|
|
69
|
+
if (m && m[1].includes(':')) {
|
|
70
|
+
const [, pass] = m[1].split(/:(.*)/s);
|
|
71
|
+
if (pass && pass.length >= MIN_LITERAL_LENGTH) literals.add(pass);
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Registers every secret-bearing value in an env object (defaults to process.env). */
|
|
77
|
+
export function registerSecretsFromEnv(env = process.env) {
|
|
78
|
+
let n = 0;
|
|
79
|
+
for (const [k, v] of Object.entries(env)) {
|
|
80
|
+
if (SECRET_ENV_NAME.test(k) && registerSecret(v)) n++;
|
|
81
|
+
}
|
|
82
|
+
return n;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Test seam. Not for production use. */
|
|
86
|
+
export function _clearRegisteredSecrets() {
|
|
87
|
+
literals.clear();
|
|
88
|
+
literalOrder = null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Longest-first order, computed once per registry change rather than once per
|
|
92
|
+
// write. The registry changes at startup and effectively never again; sorting
|
|
93
|
+
// it on every scrub was work repeated for no reason.
|
|
94
|
+
let literalOrder = null;
|
|
95
|
+
function orderedLiterals() {
|
|
96
|
+
if (literalOrder === null) literalOrder = [...literals].sort((a, b) => b.length - a.length);
|
|
97
|
+
return literalOrder;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function registeredSecretCount() {
|
|
101
|
+
return literals.size;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// A necessary-condition prefilter for the key/value rule. Every alternative in
|
|
105
|
+
// KEY_NAMES contains one of these substrings, so text without any of them
|
|
106
|
+
// cannot match - and skipping the full pattern then is free correctness.
|
|
107
|
+
const KEY_HINT = /pass|secret|token|key|authorization|credential/i;
|
|
108
|
+
|
|
109
|
+
// Pattern rules. Each replaces with a function so the hit count is exact.
|
|
110
|
+
// `pre` is a cheap NECESSARY condition: a substring the full pattern cannot
|
|
111
|
+
// match without. It never decides a match - it only decides whether the
|
|
112
|
+
// expensive scan is worth starting.
|
|
113
|
+
const KEY_NAMES = '(?:pass(?:phrase|word|wd)|secret|token|api[_-]?key|client_secret|access_token|refresh_token|authorization|credential)';
|
|
114
|
+
const RULES = [
|
|
115
|
+
// scheme://user:password@host -> scheme://*****:*****@host
|
|
116
|
+
{ name: 'uri-credentials', pre: (t) => t.includes('://'),
|
|
117
|
+
re: /([a-z][a-z0-9+.-]*:\/\/)([^/@\s:]+):([^/@\s]+)@/gi, sub: (_m, scheme) => `${scheme}${MASK}:${MASK}@` },
|
|
118
|
+
// Bearer <token>
|
|
119
|
+
{ name: 'bearer', pre: (t) => t.includes('Bearer'),
|
|
120
|
+
re: /\b(Bearer)\s+[A-Za-z0-9._~+/=-]{8,}/g, sub: (_m, w) => `${w} ${MASK}` },
|
|
121
|
+
// JWT
|
|
122
|
+
{ name: 'jwt', pre: (t) => t.includes('eyJ'),
|
|
123
|
+
re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, sub: () => MASK },
|
|
124
|
+
// key: value / key = value / "key": "value" (key survives, value is masked)
|
|
125
|
+
{ name: 'key-value',
|
|
126
|
+
// Every alternative in KEY_NAMES contains one of these substrings, and the
|
|
127
|
+
// rule also requires a ':' or '='. Both are NECESSARY conditions, so
|
|
128
|
+
// failing this check cannot skip a match the full pattern would have found.
|
|
129
|
+
pre: (t) => KEY_HINT.test(t) && (t.includes(':') || t.includes('=')),
|
|
130
|
+
// The scheme words of an Authorization header are not the secret; the
|
|
131
|
+
// bearer rule above has already masked what followed them.
|
|
132
|
+
// G5 (2026-09-07): the prefix class lets a header name like x-api-key
|
|
133
|
+
// through, but it also let "re-authorization:" in prose count as a key and
|
|
134
|
+
// mask the word after it (thread §32). An English prefix joined by a hyphen
|
|
135
|
+
// is not a key name: re-, pre-, de-, un-, non- are excluded right before
|
|
136
|
+
// the key word.
|
|
137
|
+
// G6 (2026-09-07): the value stops at a backtick and does not swallow a
|
|
138
|
+
// trailing period or closing parenthesis - prose like `passphrase: x`. kept
|
|
139
|
+
// losing its closing backtick and full stop. A secret that itself ends in
|
|
140
|
+
// "." or ")" keeps that one character visible; accepted.
|
|
141
|
+
re: new RegExp(`(["']?\\b[\\w.-]*(?<!\\b(?:re|pre|de|un|non)-)${KEY_NAMES}["']?\\s*[:=]\\s*)(["']?)(?!(?:Bearer|Basic|Digest)\\b)(?=[^\\s"'\`,;}\\]]{4})([^\\s"'\`,;}\\]]*[^\\s"'\`,;}\\].)])([.)]*)(\\2)`, 'gi'),
|
|
142
|
+
sub: (_m, key, q, _v, trail, q2) => `${key}${q}${MASK}${trail}${q2}` }
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Redacts a string. Returns { text, hits } where hits is the number of
|
|
147
|
+
* replacements made. Non-strings are returned unchanged with hits 0.
|
|
148
|
+
*/
|
|
149
|
+
export function redactWithReport(input) {
|
|
150
|
+
if (typeof input !== 'string' || input.length === 0) return { text: input, hits: 0 };
|
|
151
|
+
let text = input;
|
|
152
|
+
let hits = 0;
|
|
153
|
+
// Literals first: exact, longest first so a value that contains another is
|
|
154
|
+
// masked whole rather than leaving a fragment.
|
|
155
|
+
//
|
|
156
|
+
// These are literal strings, so they need no regex at all. The previous
|
|
157
|
+
// version compiled one RegExp per literal PER CALL and scanned the whole body
|
|
158
|
+
// with it whether or not the literal was present. indexOf answers "is it
|
|
159
|
+
// here" far more cheaply than a regex scan, and split/join is the same
|
|
160
|
+
// operation as a global replace on an escaped literal - identical output,
|
|
161
|
+
// identical hit count.
|
|
162
|
+
for (const lit of orderedLiterals()) {
|
|
163
|
+
if (!text.includes(lit)) continue;
|
|
164
|
+
const parts = text.split(lit);
|
|
165
|
+
hits += parts.length - 1;
|
|
166
|
+
text = parts.join(MASK);
|
|
167
|
+
}
|
|
168
|
+
// G4 (2026-09-07): a hit is a CHANGE. Text that already carries the mask
|
|
169
|
+
// ("passphrase: *****") matches the key/value rule and is replaced with
|
|
170
|
+
// itself; counting that made every read-modify-write of a document report
|
|
171
|
+
// the masks it already held as new redactions. The receipt's `redacted` is
|
|
172
|
+
// the number of spans this write changed - nothing else.
|
|
173
|
+
for (const rule of RULES) {
|
|
174
|
+
// The prefilter is a necessary condition for the pattern, so skipping on a
|
|
175
|
+
// miss cannot change the output. It exists because the scrubber was paying
|
|
176
|
+
// nearly full price on documents containing no secrets at all: measured
|
|
177
|
+
// 2026-09-08, a clean 300 KB body cost 6.40 ms against 7.25 ms for a
|
|
178
|
+
// secret-bearing one. The cost was scanning, not replacing.
|
|
179
|
+
if (rule.pre && !rule.pre(text)) continue;
|
|
180
|
+
text = text.replace(rule.re, (...args) => {
|
|
181
|
+
const out = rule.sub(...args);
|
|
182
|
+
if (out !== args[0]) hits++;
|
|
183
|
+
return out;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return { text, hits };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Convenience: the redacted string only. */
|
|
190
|
+
export function redactText(input) {
|
|
191
|
+
return redactWithReport(input).text;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const SECRET_KEY = new RegExp(`^[\\w.-]*${KEY_NAMES}$`, 'i');
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Deep-redacts any value: strings are scrubbed; object keys that name a secret
|
|
198
|
+
* have their value masked outright regardless of content; arrays and plain
|
|
199
|
+
* objects are walked. Other types pass through. Returns { value, hits }.
|
|
200
|
+
*/
|
|
201
|
+
export function redactDeep(value, _seen = new WeakSet()) {
|
|
202
|
+
if (typeof value === 'string') {
|
|
203
|
+
const r = redactWithReport(value);
|
|
204
|
+
return { value: r.text, hits: r.hits };
|
|
205
|
+
}
|
|
206
|
+
if (Array.isArray(value)) {
|
|
207
|
+
let hits = 0;
|
|
208
|
+
const out = value.map(v => { const r = redactDeep(v, _seen); hits += r.hits; return r.value; });
|
|
209
|
+
return { value: out, hits };
|
|
210
|
+
}
|
|
211
|
+
if (value && typeof value === 'object') {
|
|
212
|
+
if (_seen.has(value)) return { value, hits: 0 };
|
|
213
|
+
_seen.add(value);
|
|
214
|
+
if (value instanceof Date || Buffer.isBuffer?.(value)) return { value, hits: 0 };
|
|
215
|
+
let hits = 0;
|
|
216
|
+
const out = {};
|
|
217
|
+
for (const [k, v] of Object.entries(value)) {
|
|
218
|
+
if (SECRET_KEY.test(k) && v !== null && v !== undefined && v !== '' && typeof v !== 'object') {
|
|
219
|
+
// G4: an already-masked value is left as it is and not counted.
|
|
220
|
+
if (v === MASK) { out[k] = v; } else { out[k] = MASK; hits++; }
|
|
221
|
+
} else {
|
|
222
|
+
const r = redactDeep(v, _seen); hits += r.hits; out[k] = r.value;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return { value: out, hits };
|
|
226
|
+
}
|
|
227
|
+
return { value, hits: 0 };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Load-time registration: the process's own secrets are always known to it.
|
|
231
|
+
registerSecretsFromEnv();
|