instar 1.1.3 → 1.2.1
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/README.md +270 -1
- package/dist/server/SecretDrop.d.ts +56 -3
- package/dist/server/SecretDrop.d.ts.map +1 -1
- package/dist/server/SecretDrop.js +116 -6
- package/dist/server/SecretDrop.js.map +1 -1
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +46 -5
- package/dist/server/routes.js.map +1 -1
- package/package.json +2 -1
- package/scripts/check-repo-invariants.mjs +68 -0
- package/scripts/lib/telegram-historian.mjs +224 -0
- package/scripts/lib/topic-memory-importer.mjs +132 -0
- package/scripts/pre-push-fixture-guard.mjs +155 -0
- package/scripts/telegram-history-backfill.mjs +353 -0
- package/src/data/builtin-manifest.json +46 -46
- package/upgrades/1.1.4.md +143 -0
- package/upgrades/1.2.1.md +71 -0
- package/upgrades/side-effects/secret-drop-hardening.md +64 -0
- package/upgrades/side-effects/telegram-history-backfill.md +131 -0
- package/upgrades/side-effects/test-env-isolation.md +84 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "instar",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "Persistent autonomy infrastructure for AI agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -105,6 +105,7 @@
|
|
|
105
105
|
"moltbridge": "^0.1.6",
|
|
106
106
|
"picocolors": "^1.0.0",
|
|
107
107
|
"proper-lockfile": "^4.1.2",
|
|
108
|
+
"telegram": "^2.26.22",
|
|
108
109
|
"ws": "^8.19.0",
|
|
109
110
|
"zod": "^4.3.6"
|
|
110
111
|
},
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Repository-state invariants — CI safety net (Layer 4 of the
|
|
4
|
+
* test-env-isolation defense).
|
|
5
|
+
*
|
|
6
|
+
* Asserts properties of the checked-out tree that would otherwise be
|
|
7
|
+
* easy to clobber without anyone noticing on a code-review pass:
|
|
8
|
+
*
|
|
9
|
+
* 1. README.md is at least MIN_README_LINES (default: 100). The
|
|
10
|
+
* "# Test Project" stub-on-main incident (PRs #130/#277) cut it
|
|
11
|
+
* to 1 line and shipped to npm.
|
|
12
|
+
* 2. No fixture stowaway files at the repo root. file-0.txt and
|
|
13
|
+
* seed are signature artifacts of the test-fixture pollution
|
|
14
|
+
* class; they should never exist on main.
|
|
15
|
+
*
|
|
16
|
+
* Exit 0 = invariants hold. Exit 1 = at least one violated; the
|
|
17
|
+
* specific failures are printed to stderr.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
|
|
23
|
+
const ROOT = process.cwd();
|
|
24
|
+
const MIN_README_LINES = Number(process.env.INSTAR_README_MIN_LINES || 100);
|
|
25
|
+
|
|
26
|
+
const failures = [];
|
|
27
|
+
|
|
28
|
+
// ── README.md size floor ──────────────────────────────────────────
|
|
29
|
+
const readmePath = path.join(ROOT, 'README.md');
|
|
30
|
+
if (!fs.existsSync(readmePath)) {
|
|
31
|
+
failures.push(`README.md is missing from repo root.`);
|
|
32
|
+
} else {
|
|
33
|
+
const lines = fs.readFileSync(readmePath, 'utf-8').split('\n').length;
|
|
34
|
+
if (lines < MIN_README_LINES) {
|
|
35
|
+
failures.push(
|
|
36
|
+
`README.md has ${lines} lines, below the ${MIN_README_LINES}-line floor. ` +
|
|
37
|
+
`Likely test-fixture pollution clobbered the file. Restore from git history.`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Fixture stowaway files at repo root ───────────────────────────
|
|
43
|
+
const FIXTURE_STOWAWAYS = ['file-0.txt', 'seed'];
|
|
44
|
+
for (const name of FIXTURE_STOWAWAYS) {
|
|
45
|
+
const p = path.join(ROOT, name);
|
|
46
|
+
if (fs.existsSync(p)) {
|
|
47
|
+
const stat = fs.statSync(p);
|
|
48
|
+
if (stat.isFile()) {
|
|
49
|
+
failures.push(
|
|
50
|
+
`Fixture stowaway "${name}" exists at repo root. ` +
|
|
51
|
+
`This is the signature of a test committing into the real repo. ` +
|
|
52
|
+
`Delete the file and rebuild the test that produced it with sanitizedGitEnv().`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (failures.length === 0) {
|
|
59
|
+
console.log('✅ Repository invariants hold.');
|
|
60
|
+
process.exit(0);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
process.stderr.write('\n🚫 Repository invariants violated:\n\n');
|
|
64
|
+
for (const f of failures) {
|
|
65
|
+
process.stderr.write(` - ${f}\n`);
|
|
66
|
+
}
|
|
67
|
+
process.stderr.write('\n');
|
|
68
|
+
process.exit(1);
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TelegramHistorian — gramjs-based MTProto backfill for instar topic-memory.
|
|
3
|
+
*
|
|
4
|
+
* Built 2026-05-20 in response to topic 10873 incident: Echo's
|
|
5
|
+
* topic-memory.db was truncated during a Node 22→25 native-module cascade,
|
|
6
|
+
* losing the local mirror of multiple Telegram topics. Telegram's Bot API
|
|
7
|
+
* is forward-only — bots cannot fetch chat history. A user-account MTProto
|
|
8
|
+
* session can. This module is the gramjs half of the recovery path.
|
|
9
|
+
*
|
|
10
|
+
* Authority posture (read carefully before extending):
|
|
11
|
+
* - This module authenticates AS the operator's Telegram account. It is
|
|
12
|
+
* not a bot. It can read everything the operator's account can read,
|
|
13
|
+
* including DMs and private groups. That power requires discipline.
|
|
14
|
+
* - Permitted operations: iterMessages (read), getDialogs (read),
|
|
15
|
+
* getMessages (read). No send/edit/delete is ever issued from here.
|
|
16
|
+
* - The session string is the long-lived credential. It must live behind
|
|
17
|
+
* mode-0600 file permissions, outside any git-tracked path, and be
|
|
18
|
+
* migrated into a real secret store as soon as one is available.
|
|
19
|
+
* - Auth requires interactive SMS code entry by the operator. The
|
|
20
|
+
* onCode/onPassword callbacks here are the only interactive surface.
|
|
21
|
+
*
|
|
22
|
+
* Forum topics: in a Telegram forum supergroup, each topic is identified
|
|
23
|
+
* by the message id of its topic-header message. Replies and posts in a
|
|
24
|
+
* topic carry reply_to.forum_topic = true and reply_to.reply_to_top_id =
|
|
25
|
+
* <topic_id>. gramjs surfaces this via the `replyTo` option to
|
|
26
|
+
* iterMessages — a value matching the topic id filters to that topic.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { TelegramClient } from 'telegram';
|
|
30
|
+
import { StringSession } from 'telegram/sessions/index.js';
|
|
31
|
+
import { Api } from 'telegram';
|
|
32
|
+
import fs from 'node:fs';
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {Object} HistorianCredentials
|
|
37
|
+
* @property {number} apiId
|
|
38
|
+
* @property {string} apiHash
|
|
39
|
+
* @property {string} sessionFilePath Absolute path to the session string file
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @typedef {Object} AuthPrompts
|
|
44
|
+
* @property {() => Promise<string>} onPhoneNumber Required if no saved session
|
|
45
|
+
* @property {() => Promise<string>} onCode Required if no saved session
|
|
46
|
+
* @property {() => Promise<string>} [onPassword] Optional 2FA password
|
|
47
|
+
* @property {(error: Error) => void} [onError]
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
const CONNECTION_RETRIES = 5;
|
|
51
|
+
|
|
52
|
+
export class TelegramHistorian {
|
|
53
|
+
/**
|
|
54
|
+
* @param {HistorianCredentials} creds
|
|
55
|
+
* @param {{ verbose?: boolean }} [opts]
|
|
56
|
+
*/
|
|
57
|
+
constructor(creds, opts = {}) {
|
|
58
|
+
this.creds = creds;
|
|
59
|
+
this.verbose = opts.verbose ?? false;
|
|
60
|
+
this.client = null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
_loadSavedSession() {
|
|
64
|
+
if (!this.creds.sessionFilePath) return '';
|
|
65
|
+
if (!fs.existsSync(this.creds.sessionFilePath)) return '';
|
|
66
|
+
return fs.readFileSync(this.creds.sessionFilePath, 'utf8').trim();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
_saveSession(sessionStr) {
|
|
70
|
+
if (!this.creds.sessionFilePath) return;
|
|
71
|
+
fs.mkdirSync(path.dirname(this.creds.sessionFilePath), { recursive: true, mode: 0o700 });
|
|
72
|
+
fs.writeFileSync(this.creds.sessionFilePath, sessionStr, { mode: 0o600 });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Connect and authenticate. If a saved session string exists at
|
|
77
|
+
* creds.sessionFilePath, reuses it (no SMS needed). Otherwise drives
|
|
78
|
+
* the interactive auth flow through the provided callbacks.
|
|
79
|
+
*
|
|
80
|
+
* @param {AuthPrompts} prompts
|
|
81
|
+
*/
|
|
82
|
+
async connect(prompts) {
|
|
83
|
+
const saved = this._loadSavedSession();
|
|
84
|
+
const session = new StringSession(saved);
|
|
85
|
+
this.client = new TelegramClient(session, this.creds.apiId, this.creds.apiHash, {
|
|
86
|
+
connectionRetries: CONNECTION_RETRIES,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await this.client.start({
|
|
90
|
+
phoneNumber: prompts.onPhoneNumber,
|
|
91
|
+
phoneCode: prompts.onCode,
|
|
92
|
+
password: prompts.onPassword ?? (async () => {
|
|
93
|
+
throw new Error('2FA password required but no onPassword callback provided');
|
|
94
|
+
}),
|
|
95
|
+
onError: prompts.onError ?? ((err) => {
|
|
96
|
+
// eslint-disable-next-line no-console
|
|
97
|
+
console.error('[TelegramHistorian] auth error:', err.message);
|
|
98
|
+
}),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const sessionStr = this.client.session.save();
|
|
102
|
+
if (sessionStr && sessionStr !== saved) {
|
|
103
|
+
this._saveSession(sessionStr);
|
|
104
|
+
if (this.verbose) {
|
|
105
|
+
// eslint-disable-next-line no-console
|
|
106
|
+
console.error('[TelegramHistorian] session saved to', this.creds.sessionFilePath);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async disconnect() {
|
|
112
|
+
if (this.client) {
|
|
113
|
+
await this.client.disconnect();
|
|
114
|
+
this.client = null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
_ensureConnected() {
|
|
119
|
+
if (!this.client) throw new Error('TelegramHistorian: not connected — call connect() first');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Resolve a supergroup reference (numeric id like -1003742343280, or
|
|
124
|
+
* @username) to a gramjs entity.
|
|
125
|
+
* @param {string | number | bigint} chatRef
|
|
126
|
+
*/
|
|
127
|
+
async resolveChat(chatRef) {
|
|
128
|
+
this._ensureConnected();
|
|
129
|
+
return await this.client.getEntity(chatRef);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* List forum topics in a supergroup. Returns each topic's id, title, and
|
|
134
|
+
* top_message id.
|
|
135
|
+
* @param {string | number | bigint} chatRef
|
|
136
|
+
* @returns {Promise<Array<{ id: number, title: string, topMessageId: number, closed: boolean }>>}
|
|
137
|
+
*/
|
|
138
|
+
async listForumTopics(chatRef) {
|
|
139
|
+
this._ensureConnected();
|
|
140
|
+
const entity = await this.resolveChat(chatRef);
|
|
141
|
+
const result = await this.client.invoke(
|
|
142
|
+
new Api.channels.GetForumTopics({
|
|
143
|
+
channel: entity,
|
|
144
|
+
offsetDate: 0,
|
|
145
|
+
offsetId: 0,
|
|
146
|
+
offsetTopic: 0,
|
|
147
|
+
limit: 100,
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
const topics = [];
|
|
151
|
+
for (const t of result.topics) {
|
|
152
|
+
// ForumTopic vs ForumTopicDeleted
|
|
153
|
+
if (t.className === 'ForumTopic') {
|
|
154
|
+
topics.push({
|
|
155
|
+
id: Number(t.id),
|
|
156
|
+
title: t.title,
|
|
157
|
+
topMessageId: Number(t.topMessage ?? t.id),
|
|
158
|
+
closed: Boolean(t.closed),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return topics;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Iterate all messages in a single forum topic, oldest-first. Yields
|
|
167
|
+
* one BackfillMessage at a time. Handles FloodWait via gramjs's
|
|
168
|
+
* built-in retry. Caller is responsible for importing.
|
|
169
|
+
*
|
|
170
|
+
* @param {string | number | bigint} chatRef
|
|
171
|
+
* @param {number} topicId forum topic id
|
|
172
|
+
* @param {{ minMessageId?: number, batchSize?: number }} [opts]
|
|
173
|
+
*/
|
|
174
|
+
async *iterTopicMessages(chatRef, topicId, opts = {}) {
|
|
175
|
+
this._ensureConnected();
|
|
176
|
+
const entity = await this.resolveChat(chatRef);
|
|
177
|
+
const batchSize = opts.batchSize ?? 100;
|
|
178
|
+
const minMessageId = opts.minMessageId ?? 0;
|
|
179
|
+
|
|
180
|
+
const iter = this.client.iterMessages(entity, {
|
|
181
|
+
replyTo: topicId,
|
|
182
|
+
limit: undefined,
|
|
183
|
+
reverse: true,
|
|
184
|
+
minId: minMessageId,
|
|
185
|
+
filter: undefined,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
let pageCount = 0;
|
|
189
|
+
let yielded = 0;
|
|
190
|
+
for await (const msg of iter) {
|
|
191
|
+
if (!msg) continue;
|
|
192
|
+
const text = typeof msg.message === 'string' ? msg.message : '';
|
|
193
|
+
const sender = msg.sender ?? null;
|
|
194
|
+
const fromUser = msg.out === true ? 1 : 0;
|
|
195
|
+
const ts = new Date(Number(msg.date) * 1000).toISOString();
|
|
196
|
+
|
|
197
|
+
yield {
|
|
198
|
+
messageId: Number(msg.id),
|
|
199
|
+
topicId,
|
|
200
|
+
text,
|
|
201
|
+
timestamp: ts,
|
|
202
|
+
fromUser,
|
|
203
|
+
senderName: sender?.firstName
|
|
204
|
+
? [sender.firstName, sender.lastName].filter(Boolean).join(' ')
|
|
205
|
+
: null,
|
|
206
|
+
senderUsername: sender?.username ?? null,
|
|
207
|
+
telegramUserId: sender?.id != null ? Number(sender.id) : null,
|
|
208
|
+
userId: null,
|
|
209
|
+
sessionName: null,
|
|
210
|
+
privacyScope: 'private',
|
|
211
|
+
};
|
|
212
|
+
yielded++;
|
|
213
|
+
if (yielded % batchSize === 0) {
|
|
214
|
+
pageCount++;
|
|
215
|
+
if (this.verbose) {
|
|
216
|
+
// eslint-disable-next-line no-console
|
|
217
|
+
console.error(
|
|
218
|
+
`[TelegramHistorian] topic ${topicId}: page ${pageCount} (${yielded} so far)`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* topic-memory-importer — idempotent import of Telegram messages into
|
|
3
|
+
* .instar/topic-memory.db.
|
|
4
|
+
*
|
|
5
|
+
* Built 2026-05-20 after the local topic-memory.db was truncated to 4
|
|
6
|
+
* messages during the Node 22→25 better-sqlite3 cascade recovery (incident
|
|
7
|
+
* recorded in topic 10873). Bots cannot fetch Telegram history; only a
|
|
8
|
+
* user-account MTProto session can. This module is the storage half of
|
|
9
|
+
* that backfill path — gramjs fetches, this writes.
|
|
10
|
+
*
|
|
11
|
+
* The schema (read 2026-05-20 from a live agent home):
|
|
12
|
+
*
|
|
13
|
+
* messages(id PK auto, message_id, topic_id, text, from_user, timestamp,
|
|
14
|
+
* session_name, sender_name, sender_username, telegram_user_id,
|
|
15
|
+
* user_id, privacy_scope, UNIQUE(message_id, topic_id))
|
|
16
|
+
*
|
|
17
|
+
* The UNIQUE(message_id, topic_id) constraint is the idempotency primitive.
|
|
18
|
+
* `INSERT OR IGNORE` is the only sanctioned write path here — re-running
|
|
19
|
+
* the importer against the same input is a no-op.
|
|
20
|
+
*
|
|
21
|
+
* Concurrency: the instar server (echo's own process) holds topic-memory.db
|
|
22
|
+
* open under WAL mode. SQLite serializes writes across processes via WAL,
|
|
23
|
+
* so this importer can write while the server is reading without
|
|
24
|
+
* corruption. The FTS triggers maintain the FTS index automatically.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import Database from 'better-sqlite3';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {Object} BackfillMessage
|
|
31
|
+
* @property {number} messageId Telegram message id (per chat)
|
|
32
|
+
* @property {number} topicId Forum topic id (= message id of the
|
|
33
|
+
* topic-header message in the supergroup)
|
|
34
|
+
* @property {string} text Message text. Empty string for
|
|
35
|
+
* service messages / media-only.
|
|
36
|
+
* @property {string} timestamp ISO 8601 UTC
|
|
37
|
+
* @property {number} [fromUser] 1 if message was sent by the user
|
|
38
|
+
* (justin), 0 if from any agent
|
|
39
|
+
* @property {string} [senderName] Display name
|
|
40
|
+
* @property {string} [senderUsername] @handle, no leading @
|
|
41
|
+
* @property {number} [telegramUserId] Numeric Telegram user id
|
|
42
|
+
* @property {string} [userId] instar user id, if known
|
|
43
|
+
* @property {string} [sessionName] instar session name, if known
|
|
44
|
+
* @property {string} [privacyScope] Defaults to 'private'
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @typedef {Object} ImportResult
|
|
49
|
+
* @property {number} inserted
|
|
50
|
+
* @property {number} skipped
|
|
51
|
+
* @property {Set<number>} touchedTopics
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
export class TopicMemoryImporter {
|
|
55
|
+
/**
|
|
56
|
+
* @param {string} dbPath absolute path to topic-memory.db
|
|
57
|
+
* @param {{ readonly?: boolean, journalMode?: 'wal' | 'delete' }} [opts]
|
|
58
|
+
*/
|
|
59
|
+
constructor(dbPath, opts = {}) {
|
|
60
|
+
this.db = new Database(dbPath, { readonly: opts.readonly ?? false });
|
|
61
|
+
this.db.pragma(`journal_mode = ${opts.journalMode ?? 'wal'}`);
|
|
62
|
+
this.db.pragma('busy_timeout = 5000');
|
|
63
|
+
|
|
64
|
+
this._insert = this.db.prepare(`
|
|
65
|
+
INSERT OR IGNORE INTO messages (
|
|
66
|
+
message_id, topic_id, text, from_user, timestamp,
|
|
67
|
+
session_name, sender_name, sender_username, telegram_user_id,
|
|
68
|
+
user_id, privacy_scope
|
|
69
|
+
) VALUES (
|
|
70
|
+
@messageId, @topicId, @text, @fromUser, @timestamp,
|
|
71
|
+
@sessionName, @senderName, @senderUsername, @telegramUserId,
|
|
72
|
+
@userId, @privacyScope
|
|
73
|
+
)
|
|
74
|
+
`);
|
|
75
|
+
|
|
76
|
+
this._countByTopic = this.db.prepare(
|
|
77
|
+
`SELECT topic_id, COUNT(*) AS n FROM messages GROUP BY topic_id`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
close() {
|
|
82
|
+
this.db.close();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Import a batch of messages. All-or-nothing per call (wrapped in a
|
|
87
|
+
* transaction). Safe to retry — the UNIQUE(message_id, topic_id)
|
|
88
|
+
* constraint plus INSERT OR IGNORE means duplicates are silently dropped.
|
|
89
|
+
*
|
|
90
|
+
* @param {BackfillMessage[]} messages
|
|
91
|
+
* @returns {ImportResult}
|
|
92
|
+
*/
|
|
93
|
+
importBatch(messages) {
|
|
94
|
+
const touched = new Set();
|
|
95
|
+
let inserted = 0;
|
|
96
|
+
let skipped = 0;
|
|
97
|
+
const tx = this.db.transaction((batch) => {
|
|
98
|
+
for (const m of batch) {
|
|
99
|
+
const row = this._insert.run({
|
|
100
|
+
messageId: m.messageId,
|
|
101
|
+
topicId: m.topicId,
|
|
102
|
+
text: m.text ?? '',
|
|
103
|
+
fromUser: m.fromUser ?? 0,
|
|
104
|
+
timestamp: m.timestamp,
|
|
105
|
+
sessionName: m.sessionName ?? null,
|
|
106
|
+
senderName: m.senderName ?? null,
|
|
107
|
+
senderUsername: m.senderUsername ?? null,
|
|
108
|
+
telegramUserId: m.telegramUserId ?? null,
|
|
109
|
+
userId: m.userId ?? null,
|
|
110
|
+
privacyScope: m.privacyScope ?? 'private',
|
|
111
|
+
});
|
|
112
|
+
if (row.changes === 1) inserted++;
|
|
113
|
+
else skipped++;
|
|
114
|
+
touched.add(m.topicId);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
tx(messages);
|
|
118
|
+
return { inserted, skipped, touchedTopics: touched };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Current message counts keyed by topic_id.
|
|
123
|
+
* @returns {Map<number, number>}
|
|
124
|
+
*/
|
|
125
|
+
countsByTopic() {
|
|
126
|
+
const m = new Map();
|
|
127
|
+
for (const row of this._countByTopic.all()) {
|
|
128
|
+
m.set(row.topic_id, row.n);
|
|
129
|
+
}
|
|
130
|
+
return m;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// safe-git-allow: pre-push critical path — must stay lightweight, no SafeGitExecutor audit-log writes from the hook.
|
|
3
|
+
/**
|
|
4
|
+
* Pre-push guard: reject test-fixture commits before they ship.
|
|
5
|
+
*
|
|
6
|
+
* Git invokes pre-push hooks with GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE
|
|
7
|
+
* set in the env. When the hook runs `npm run test:smoke`, the test
|
|
8
|
+
* suite inherits those overrides, and any test that spawns `git init`
|
|
9
|
+
* + `git commit` in a tmpdir without sanitizing env ends up committing
|
|
10
|
+
* into the real repo on whichever branch is currently checked out.
|
|
11
|
+
*
|
|
12
|
+
* That's how PRs #130 and #277 picked up "Initial commit" / "seed" /
|
|
13
|
+
* "Worktree commit N" stowaways that overwrote README.md on main.
|
|
14
|
+
*
|
|
15
|
+
* Layer 1 (vitest-setup.ts strip) closes the root cause. This guard is
|
|
16
|
+
* the second line of defense: even if a future test reintroduces a raw
|
|
17
|
+
* git call without sanitizing env, the resulting commit can never reach
|
|
18
|
+
* the remote because this hook refuses to push it.
|
|
19
|
+
*
|
|
20
|
+
* Behavior:
|
|
21
|
+
* - Scans commits on HEAD that aren't yet on origin/main.
|
|
22
|
+
* - Fails the push if any of them match the fixture-pollution signature
|
|
23
|
+
* (author email test@instar.local / t@t.com / t@e.com, or commit
|
|
24
|
+
* messages "Initial commit" / "seed" / "Worktree commit <N>").
|
|
25
|
+
* - Prints what it found so the author can `git reset` past the bad
|
|
26
|
+
* commits and re-push.
|
|
27
|
+
*
|
|
28
|
+
* Escape hatch (for legitimate fixture commits in test-only branches
|
|
29
|
+
* that will never merge): INSTAR_PRE_PUSH_FIXTURE_GUARD_SKIP=1
|
|
30
|
+
*
|
|
31
|
+
* Exit codes: 0 = clean (or skipped), 1 = pollution detected.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { execFileSync } from 'node:child_process';
|
|
35
|
+
|
|
36
|
+
const SKIP = process.env.INSTAR_PRE_PUSH_FIXTURE_GUARD_SKIP === '1';
|
|
37
|
+
if (SKIP) {
|
|
38
|
+
console.log('⏭️ INSTAR_PRE_PUSH_FIXTURE_GUARD_SKIP=1 — fixture guard bypassed.');
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Strip the same env overrides we accuse of causing the bug. Otherwise
|
|
43
|
+
// our own `git log` here would be subject to the same redirection.
|
|
44
|
+
const env = { ...process.env };
|
|
45
|
+
delete env.GIT_DIR;
|
|
46
|
+
delete env.GIT_WORK_TREE;
|
|
47
|
+
delete env.GIT_INDEX_FILE;
|
|
48
|
+
delete env.GIT_OBJECT_DIRECTORY;
|
|
49
|
+
delete env.GIT_COMMON_DIR;
|
|
50
|
+
|
|
51
|
+
function git(args) {
|
|
52
|
+
return execFileSync('git', args, { encoding: 'utf-8', env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let baseRef;
|
|
56
|
+
try {
|
|
57
|
+
// Prefer upstream tracking branch if one is configured.
|
|
58
|
+
baseRef = git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']).trim();
|
|
59
|
+
} catch {
|
|
60
|
+
// No upstream — fall back to whichever remote main we can resolve.
|
|
61
|
+
for (const candidate of ['upstream/main', 'origin/main']) {
|
|
62
|
+
try {
|
|
63
|
+
git(['rev-parse', '--verify', '--quiet', candidate]);
|
|
64
|
+
baseRef = candidate;
|
|
65
|
+
break;
|
|
66
|
+
} catch { /* try next */ }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!baseRef) {
|
|
71
|
+
// No remote main to compare against — likely a fresh repo. Skip silently.
|
|
72
|
+
process.exit(0);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// `<base>..HEAD` lists commits on HEAD that aren't on base. NUL-separated
|
|
76
|
+
// records of "<sha>\t<author_email>\t<author_name>\t<subject>" — using \0
|
|
77
|
+
// between records lets us survive commit messages that contain newlines.
|
|
78
|
+
let log;
|
|
79
|
+
try {
|
|
80
|
+
log = git([
|
|
81
|
+
'log',
|
|
82
|
+
`${baseRef}..HEAD`,
|
|
83
|
+
'--pretty=format:%H%x09%ae%x09%an%x09%s%x00',
|
|
84
|
+
]);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
// Couldn't compute the range. Don't block the push — a real failure
|
|
87
|
+
// would surface elsewhere; we'd rather miss a guard than wedge pushes
|
|
88
|
+
// on every fresh repo / unborn branch.
|
|
89
|
+
process.exit(0);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const records = log.split('\0').map((r) => r.trim()).filter(Boolean);
|
|
93
|
+
|
|
94
|
+
const BAD_EMAILS = new Set([
|
|
95
|
+
'test@instar.local',
|
|
96
|
+
't@t.com',
|
|
97
|
+
't@e.com',
|
|
98
|
+
'test@test.com',
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
const BAD_SUBJECT_PATTERNS = [
|
|
102
|
+
/^Initial commit$/,
|
|
103
|
+
/^seed$/,
|
|
104
|
+
/^Worktree commit \d+$/,
|
|
105
|
+
/^init$/,
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
const violations = [];
|
|
109
|
+
for (const record of records) {
|
|
110
|
+
const [sha, email, name, subject] = record.split('\t');
|
|
111
|
+
const reasons = [];
|
|
112
|
+
if (BAD_EMAILS.has(email)) {
|
|
113
|
+
reasons.push(`fixture-identity author <${email}>`);
|
|
114
|
+
}
|
|
115
|
+
for (const pattern of BAD_SUBJECT_PATTERNS) {
|
|
116
|
+
if (pattern.test(subject)) {
|
|
117
|
+
reasons.push(`fixture commit message "${subject}"`);
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (reasons.length > 0) {
|
|
122
|
+
violations.push({ sha: sha.slice(0, 10), name, email, subject, reasons });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (violations.length === 0) {
|
|
127
|
+
process.exit(0);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const lines = [];
|
|
131
|
+
lines.push('');
|
|
132
|
+
lines.push('🚫 pre-push-fixture-guard: refusing to push test-fixture commits.');
|
|
133
|
+
lines.push('');
|
|
134
|
+
lines.push('These commits look like test-fixture pollution (test wrote into');
|
|
135
|
+
lines.push('the real repo because GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE');
|
|
136
|
+
lines.push('leaked from this pre-push hook into a child test process):');
|
|
137
|
+
lines.push('');
|
|
138
|
+
for (const v of violations) {
|
|
139
|
+
lines.push(` ${v.sha} ${v.name} <${v.email}> "${v.subject}"`);
|
|
140
|
+
for (const r of v.reasons) {
|
|
141
|
+
lines.push(` → ${r}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
lines.push('');
|
|
145
|
+
lines.push('How to recover:');
|
|
146
|
+
lines.push(` 1. Inspect the commits: git log ${baseRef}..HEAD`);
|
|
147
|
+
lines.push(` 2. Drop them: git reset --hard <last good sha>`);
|
|
148
|
+
lines.push(' 3. Force-push the cleaned branch.');
|
|
149
|
+
lines.push('');
|
|
150
|
+
lines.push('If this is intentional (e.g. a test-only branch you do not plan');
|
|
151
|
+
lines.push('to merge), bypass with INSTAR_PRE_PUSH_FIXTURE_GUARD_SKIP=1.');
|
|
152
|
+
lines.push('');
|
|
153
|
+
|
|
154
|
+
process.stderr.write(lines.join('\n'));
|
|
155
|
+
process.exit(1);
|