switchroom 0.19.48 → 0.20.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.
Files changed (60) hide show
  1. package/bin/handoff-briefing.sh +213 -74
  2. package/dist/agent-scheduler/index.js +18 -1
  3. package/dist/auth-broker/index.js +19 -2
  4. package/dist/buzz-gateway/index.js +9367 -0
  5. package/dist/cli/notion-write-pretool.mjs +18 -1
  6. package/dist/cli/switchroom.js +24734 -16371
  7. package/dist/host-control/main.js +59 -9
  8. package/dist/vault/approvals/kernel-server.js +19 -2
  9. package/dist/vault/broker/server.js +19 -2
  10. package/package.json +6 -4
  11. package/profiles/_base/start.sh.hbs +148 -2
  12. package/profiles/default/CLAUDE.md.hbs +1 -1
  13. package/skills/dev-protocol/SKILL.md +30 -1
  14. package/skills/switchroom-architecture/SKILL.md +5 -0
  15. package/skills/switchroom-cli/SKILL.md +1 -1
  16. package/telegram-plugin/dist/bridge/bridge.js +7 -4
  17. package/telegram-plugin/dist/gateway/gateway.js +2376 -1039
  18. package/telegram-plugin/dist/server.js +7 -4
  19. package/telegram-plugin/gateway/access-store.test.ts +234 -0
  20. package/telegram-plugin/gateway/access-store.ts +194 -0
  21. package/telegram-plugin/gateway/boot-briefing-builder.ts +586 -0
  22. package/telegram-plugin/gateway/boot-briefing-capability.ts +31 -0
  23. package/telegram-plugin/gateway/boot-briefing-wiring.ts +332 -0
  24. package/telegram-plugin/gateway/buzz-mirror-correlation-store.ts +285 -0
  25. package/telegram-plugin/gateway/buzz-mirror.ts +494 -0
  26. package/telegram-plugin/gateway/buzz-type-guards.ts +34 -0
  27. package/telegram-plugin/gateway/channel-route.ts +272 -0
  28. package/telegram-plugin/gateway/gateway.ts +115 -203
  29. package/telegram-plugin/gateway/inbound-router.ts +93 -3
  30. package/telegram-plugin/gateway/inbound-spool.ts +33 -1
  31. package/telegram-plugin/gateway/ipc-protocol.ts +81 -2
  32. package/telegram-plugin/gateway/ipc-server.ts +197 -2
  33. package/telegram-plugin/gateway/outbound-send-path.ts +85 -2
  34. package/telegram-plugin/gateway/pending-turn-env.ts +70 -0
  35. package/telegram-plugin/gateway/stream-render.ts +21 -0
  36. package/telegram-plugin/gateway/subagent-handback-marker.ts +12 -0
  37. package/telegram-plugin/gateway/user-failure-notices.ts +172 -0
  38. package/telegram-plugin/history.ts +15 -0
  39. package/telegram-plugin/llm-error-present.ts +9 -4
  40. package/telegram-plugin/model-unavailable.ts +4 -0
  41. package/telegram-plugin/operator-events.fixtures.json +12 -12
  42. package/telegram-plugin/operator-events.ts +81 -9
  43. package/telegram-plugin/session-tail.ts +7 -1
  44. package/telegram-plugin/tests/boot-briefing-builder.test.ts +995 -0
  45. package/telegram-plugin/tests/buzz-mirror-correlation-store.test.ts +173 -0
  46. package/telegram-plugin/tests/buzz-mirror.test.ts +538 -0
  47. package/telegram-plugin/tests/buzz-origin-stamp-gate.test.ts +159 -0
  48. package/telegram-plugin/tests/channel-route.test.ts +306 -0
  49. package/telegram-plugin/tests/inbound-spool.test.ts +47 -0
  50. package/telegram-plugin/tests/ipc-server-buzz-dedup.test.ts +124 -0
  51. package/telegram-plugin/tests/ipc-server-buzz-peer.test.ts +269 -0
  52. package/telegram-plugin/tests/operator-events-session-tail.test.ts +63 -0
  53. package/telegram-plugin/tests/operator-events.test.ts +71 -7
  54. package/telegram-plugin/tests/outbound-send-path.test.ts +24 -0
  55. package/telegram-plugin/tests/reply-to-buffer-fallback.test.ts +273 -0
  56. package/telegram-plugin/tests/reply-to-buffer-history.test.ts +134 -0
  57. package/telegram-plugin/tests/user-failure-notices.test.ts +165 -0
  58. package/telegram-plugin/voice-normalize-text.ts +5 -0
  59. package/vendor/hindsight-memory/scripts/directive_verify.py +4 -0
  60. package/vendor/hindsight-memory/scripts/recall.py +7 -2
@@ -17188,12 +17188,12 @@ function classifyClaudeError(raw) {
17188
17188
  try {
17189
17189
  return classifyInner(raw);
17190
17190
  } catch {
17191
- return "unknown-4xx";
17191
+ return "unknown-5xx";
17192
17192
  }
17193
17193
  }
17194
17194
  function classifyInner(raw) {
17195
17195
  if (raw == null)
17196
- return "unknown-4xx";
17196
+ return "unknown-5xx";
17197
17197
  const obj = typeof raw === "object" ? raw : {};
17198
17198
  const errorType = extractString(obj, "error_type") ?? extractString(obj, "type") ?? extractString(getNestedObj(obj, "error"), "type") ?? "";
17199
17199
  const errorCode = extractString(obj, "code") ?? extractString(getNestedObj(obj, "error"), "code") ?? "";
@@ -17238,13 +17238,16 @@ ${message}`;
17238
17238
  if (errorType === "agent-restarted-unexpectedly" || errorCode === "agent-restarted-unexpectedly") {
17239
17239
  return "agent-restarted-unexpectedly";
17240
17240
  }
17241
+ if ((status == null || status >= 500) && (errorType === "server_error" || errorCode === "server_error" || sdkCode === "server_error" || errorType === "api_error" || errorCode === "api_error" || sdkCode === "api_error")) {
17242
+ return "transport-transient";
17243
+ }
17241
17244
  if (status != null) {
17242
17245
  if (status >= 400 && status < 500)
17243
17246
  return "unknown-4xx";
17244
17247
  if (status >= 500 && status < 600)
17245
17248
  return "unknown-5xx";
17246
17249
  }
17247
- return "unknown-4xx";
17250
+ return "unknown-5xx";
17248
17251
  }
17249
17252
  function extractString(obj, key) {
17250
17253
  const v = obj[key];
@@ -17792,7 +17795,7 @@ ${errStr}`) ? "rate-limited" : "quota-exhausted" : classifyClaudeError({ type: e
17792
17795
  const raw = embeddedError ?? obj;
17793
17796
  const kind = classifyClaudeError(embeddedError ?? obj);
17794
17797
  const detail = extractDetailMessage(embeddedError) ?? extractDetailMessage(obj) ?? String(type ?? "");
17795
- const transient = kind === "rate-limited";
17798
+ const transient = kind === "rate-limited" || kind === "transport-transient";
17796
17799
  const retry = extractRetryState(obj);
17797
17800
  const terminal = !transient ? true : retry.retryAttempt != null && retry.maxRetries != null ? retry.retryAttempt >= retry.maxRetries : isErrorLine;
17798
17801
  return { kind, raw, detail, transient, terminal };
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Unit tests for access-store.ts — the access/allowlist file layer extracted
3
+ * from gateway.ts (switchroom#4248).
4
+ *
5
+ * These lock in the behavior that used to be inline in gateway.ts, in
6
+ * particular the init-time-only static-mode snapshot (BOOT_ACCESS): in static
7
+ * mode the allowlist is read ONCE when the store is built and frozen for the
8
+ * life of the store, so a later edit to access.json on disk is NOT observed.
9
+ * That timing is the whole reason the store is a factory rather than a set of
10
+ * lazy free functions — this suite is the outcome guard for it.
11
+ *
12
+ * Run with: npx vitest run telegram-plugin/gateway/access-store.test.ts
13
+ */
14
+
15
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
16
+ import { mkdtempSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync } from 'node:fs'
17
+ import { tmpdir } from 'node:os'
18
+ import { join } from 'node:path'
19
+ import { createAccessStore, type AccessStoreDeps } from './access-store.js'
20
+ import type { Access } from './gateway.js'
21
+
22
+ function makeDeps(dir: string, isStatic: boolean): AccessStoreDeps {
23
+ return {
24
+ accessFile: join(dir, 'access.json'),
25
+ peopleFile: join(dir, 'people.json'),
26
+ stateDir: dir,
27
+ isStatic,
28
+ }
29
+ }
30
+
31
+ describe('access-store', () => {
32
+ let dir: string
33
+ let stderrSpy: ReturnType<typeof vi.spyOn>
34
+
35
+ beforeEach(() => {
36
+ dir = mkdtempSync(join(tmpdir(), 'access-store-'))
37
+ stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
38
+ })
39
+
40
+ afterEach(() => {
41
+ stderrSpy.mockRestore()
42
+ rmSync(dir, { recursive: true, force: true })
43
+ })
44
+
45
+ // ─── defaultAccess ─────────────────────────────────────────────────────────
46
+
47
+ it('defaultAccess returns a pairing-mode empty allowlist', () => {
48
+ const store = createAccessStore(makeDeps(dir, false))
49
+ expect(store.defaultAccess()).toEqual({
50
+ dmPolicy: 'pairing',
51
+ allowFrom: [],
52
+ groups: {},
53
+ pending: {},
54
+ })
55
+ })
56
+
57
+ // ─── readAccessFile ────────────────────────────────────────────────────────
58
+
59
+ it('readAccessFile returns defaultAccess when the file is missing (ENOENT)', () => {
60
+ const store = createAccessStore(makeDeps(dir, false))
61
+ expect(store.readAccessFile()).toEqual(store.defaultAccess())
62
+ })
63
+
64
+ it('readAccessFile parses a valid access.json and projects known fields', () => {
65
+ writeFileSync(
66
+ join(dir, 'access.json'),
67
+ JSON.stringify({
68
+ dmPolicy: 'allowlist',
69
+ allowFrom: ['111'],
70
+ groups: { '-100': { requireMention: true, allowFrom: ['222'] } },
71
+ pending: {},
72
+ parseMode: 'text',
73
+ historyEnabled: false,
74
+ }),
75
+ )
76
+ const store = createAccessStore(makeDeps(dir, false))
77
+ const a = store.readAccessFile()
78
+ expect(a.dmPolicy).toBe('allowlist')
79
+ expect(a.allowFrom).toEqual(['111'])
80
+ expect(a.groups['-100']).toEqual({ requireMention: true, allowFrom: ['222'] })
81
+ expect(a.parseMode).toBe('text')
82
+ expect(a.historyEnabled).toBe(false)
83
+ })
84
+
85
+ it('readAccessFile fails closed on number-array fields (the hand-edit bug)', () => {
86
+ // Unquoted IDs parse as numbers; validateStringArray must drop them.
87
+ writeFileSync(
88
+ join(dir, 'access.json'),
89
+ JSON.stringify({ dmPolicy: 'allowlist', allowFrom: [12345], groups: {}, pending: {} }),
90
+ )
91
+ const store = createAccessStore(makeDeps(dir, false))
92
+ expect(store.readAccessFile().allowFrom).toEqual([])
93
+ expect(stderrSpy).toHaveBeenCalled()
94
+ })
95
+
96
+ it('readAccessFile moves a corrupt file aside and returns defaultAccess', () => {
97
+ writeFileSync(join(dir, 'access.json'), '{ not valid json')
98
+ const store = createAccessStore(makeDeps(dir, false))
99
+ expect(store.readAccessFile()).toEqual(store.defaultAccess())
100
+ // Original replaced by a .corrupt-* sibling.
101
+ expect(existsSync(join(dir, 'access.json'))).toBe(false)
102
+ const corrupt = readdirSync(dir).filter((f) => f.startsWith('access.json.corrupt-'))
103
+ expect(corrupt.length).toBe(1)
104
+ })
105
+
106
+ // ─── loadAccess — non-static re-reads live ─────────────────────────────────
107
+
108
+ it('loadAccess re-reads the file on every call in non-static mode', () => {
109
+ writeFileSync(
110
+ join(dir, 'access.json'),
111
+ JSON.stringify({ dmPolicy: 'allowlist', allowFrom: ['111'], groups: {}, pending: {} }),
112
+ )
113
+ const store = createAccessStore(makeDeps(dir, false))
114
+ expect(store.loadAccess().allowFrom).toEqual(['111'])
115
+ // Edit on disk is observed immediately (no snapshot in non-static mode).
116
+ writeFileSync(
117
+ join(dir, 'access.json'),
118
+ JSON.stringify({ dmPolicy: 'allowlist', allowFrom: ['222'], groups: {}, pending: {} }),
119
+ )
120
+ expect(store.loadAccess().allowFrom).toEqual(['222'])
121
+ })
122
+
123
+ // ─── Static-mode BOOT_ACCESS snapshot (init-time semantics) ────────────────
124
+
125
+ it('static mode snapshots at build time: downgrades pairing→allowlist and clears pending', () => {
126
+ writeFileSync(
127
+ join(dir, 'access.json'),
128
+ JSON.stringify({
129
+ dmPolicy: 'pairing',
130
+ allowFrom: ['111'],
131
+ groups: {},
132
+ pending: { code123: { senderId: 's', chatId: 'c', createdAt: 1, expiresAt: 2, replies: 0 } },
133
+ }),
134
+ )
135
+ const store = createAccessStore(makeDeps(dir, true))
136
+ const a = store.loadAccess()
137
+ expect(a.dmPolicy).toBe('allowlist')
138
+ expect(a.pending).toEqual({})
139
+ expect(a.allowFrom).toEqual(['111'])
140
+ })
141
+
142
+ it('static mode freezes the allowlist: an on-disk edit after build is NOT observed', () => {
143
+ writeFileSync(
144
+ join(dir, 'access.json'),
145
+ JSON.stringify({ dmPolicy: 'allowlist', allowFrom: ['111'], groups: {}, pending: {} }),
146
+ )
147
+ const store = createAccessStore(makeDeps(dir, true))
148
+ expect(store.loadAccess().allowFrom).toEqual(['111'])
149
+ // Change the file after the store was built.
150
+ writeFileSync(
151
+ join(dir, 'access.json'),
152
+ JSON.stringify({ dmPolicy: 'allowlist', allowFrom: ['999'], groups: {}, pending: {} }),
153
+ )
154
+ // Still the boot snapshot — this is the init-time-only guarantee.
155
+ expect(store.loadAccess().allowFrom).toEqual(['111'])
156
+ })
157
+
158
+ // ─── saveAccess ────────────────────────────────────────────────────────────
159
+
160
+ it('saveAccess writes access.json atomically in non-static mode', () => {
161
+ const store = createAccessStore(makeDeps(dir, false))
162
+ const a: Access = { dmPolicy: 'allowlist', allowFrom: ['abc'], groups: {}, pending: {} }
163
+ store.saveAccess(a)
164
+ const written = JSON.parse(readFileSync(join(dir, 'access.json'), 'utf8'))
165
+ expect(written.allowFrom).toEqual(['abc'])
166
+ // No leftover temp file.
167
+ expect(existsSync(join(dir, 'access.json.tmp'))).toBe(false)
168
+ })
169
+
170
+ it('saveAccess is a no-op in static mode', () => {
171
+ const store = createAccessStore(makeDeps(dir, true))
172
+ store.saveAccess({ dmPolicy: 'allowlist', allowFrom: ['abc'], groups: {}, pending: {} })
173
+ expect(existsSync(join(dir, 'access.json'))).toBe(false)
174
+ })
175
+
176
+ // ─── pruneExpired ──────────────────────────────────────────────────────────
177
+
178
+ it('pruneExpired removes expired pending entries and reports whether it changed', () => {
179
+ const now = Date.now()
180
+ const store = createAccessStore(makeDeps(dir, false))
181
+ const a: Access = {
182
+ dmPolicy: 'pairing',
183
+ allowFrom: [],
184
+ groups: {},
185
+ pending: {
186
+ old: { senderId: 's', chatId: 'c', createdAt: 1, expiresAt: now - 1000, replies: 0 },
187
+ fresh: { senderId: 's', chatId: 'c', createdAt: 1, expiresAt: now + 100000, replies: 0 },
188
+ },
189
+ }
190
+ expect(store.pruneExpired(a)).toBe(true)
191
+ expect(Object.keys(a.pending)).toEqual(['fresh'])
192
+ // Second pass: nothing expired now → no change.
193
+ expect(store.pruneExpired(a)).toBe(false)
194
+ })
195
+
196
+ // ─── assertAllowedChat ─────────────────────────────────────────────────────
197
+
198
+ it('assertAllowedChat permits an allowFrom chat, a group chat, and rejects others', () => {
199
+ writeFileSync(
200
+ join(dir, 'access.json'),
201
+ JSON.stringify({
202
+ dmPolicy: 'allowlist',
203
+ allowFrom: ['111'],
204
+ groups: { '-100': { requireMention: false, allowFrom: [] } },
205
+ pending: {},
206
+ }),
207
+ )
208
+ const store = createAccessStore(makeDeps(dir, false))
209
+ expect(() => store.assertAllowedChat('111')).not.toThrow()
210
+ expect(() => store.assertAllowedChat(111)).not.toThrow() // number coerced
211
+ expect(() => store.assertAllowedChat('-100')).not.toThrow()
212
+ expect(() => store.assertAllowedChat('404')).toThrow(/not allowlisted/)
213
+ })
214
+
215
+ // ─── readPeopleFile ────────────────────────────────────────────────────────
216
+
217
+ it('readPeopleFile returns entries for a valid people.json', () => {
218
+ writeFileSync(
219
+ join(dir, 'people.json'),
220
+ JSON.stringify({ entries: [{ telegramUserId: '5', personId: 'p1' }] }),
221
+ )
222
+ const store = createAccessStore(makeDeps(dir, false))
223
+ expect(store.readPeopleFile()).toEqual([{ telegramUserId: '5', personId: 'p1' }])
224
+ })
225
+
226
+ it('readPeopleFile fails open to [] on missing or corrupt file', () => {
227
+ const store = createAccessStore(makeDeps(dir, false))
228
+ expect(store.readPeopleFile()).toEqual([]) // ENOENT
229
+ writeFileSync(join(dir, 'people.json'), 'not json')
230
+ expect(store.readPeopleFile()).toEqual([]) // corrupt
231
+ writeFileSync(join(dir, 'people.json'), JSON.stringify({ entries: 'nope' }))
232
+ expect(store.readPeopleFile()).toEqual([]) // entries not an array
233
+ })
234
+ })
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Access / allowlist file layer for the Telegram gateway.
3
+ *
4
+ * This module owns the read/write side of `access.json` (the per-agent DM
5
+ * allowlist + group policy + pairing state) and the read-only `people.json`
6
+ * projection. It was extracted verbatim out of `gateway.ts` (switchroom#4248)
7
+ * to relieve the gateway line-ratchet; the behavior is byte-identical to the
8
+ * inline version.
9
+ *
10
+ * Init-time semantics preserved: in static-access mode the gateway snapshots
11
+ * `access.json` ONCE at module-init time (the `BOOT_ACCESS` constant below),
12
+ * downgrades a `pairing` dmPolicy to `allowlist`, and clears pending pairings —
13
+ * so a static agent's allowlist is frozen for the life of the process. That
14
+ * snapshot must happen at the same point in startup as before, so the store is
15
+ * built by a factory (`createAccessStore`) that the gateway calls at the
16
+ * original `BOOT_ACCESS` site; the snapshot runs eagerly inside the factory,
17
+ * NOT lazily on first `loadAccess()`.
18
+ *
19
+ * The gateway-internal dependencies (`ACCESS_FILE`, `PEOPLE_FILE`, `STATE_DIR`
20
+ * path constants and the `STATIC` mode flag) are injected as explicit params so
21
+ * this module holds no runtime import of gateway.ts. The only back-reference is
22
+ * the `Access` / `GroupPolicy` types, imported type-only (erased under
23
+ * `isolatedModules`, so no runtime cycle) — the same seam
24
+ * `turn-start-surfaces.ts` already uses.
25
+ */
26
+
27
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
28
+ import { validateStringArray } from './access-validator.js'
29
+ import type { RawPersonEntry } from './resolve-person.js'
30
+ import type { Access } from './gateway.js'
31
+
32
+ /** The per-group policy shape, derived from the exported `Access` type so the
33
+ * store needs no extra symbol export from gateway.ts. */
34
+ type GroupPolicy = Access['groups'][string]
35
+
36
+ /** Gateway-internal deps the store needs, injected at build time. */
37
+ export interface AccessStoreDeps {
38
+ /** Absolute path to `access.json` (gateway's `ACCESS_FILE`). */
39
+ accessFile: string
40
+ /** Absolute path to `people.json` (gateway's `PEOPLE_FILE`). */
41
+ peopleFile: string
42
+ /** State dir created (mode 0o700) before an atomic access.json write. */
43
+ stateDir: string
44
+ /** Static-access mode (`TELEGRAM_ACCESS_MODE === 'static'`). Freezes the
45
+ * allowlist at init and turns `saveAccess` into a no-op. */
46
+ isStatic: boolean
47
+ }
48
+
49
+ /** The access/allowlist file layer, bound to one set of paths + mode. */
50
+ export interface AccessStore {
51
+ defaultAccess(): Access
52
+ readAccessFile(): Access
53
+ loadAccess(): Access
54
+ readPeopleFile(): RawPersonEntry[]
55
+ assertAllowedChat(chat_id: string | number): void
56
+ saveAccess(a: Access): void
57
+ pruneExpired(a: Access): boolean
58
+ }
59
+
60
+ /**
61
+ * Build the access store. The `BOOT_ACCESS` snapshot happens eagerly here, so
62
+ * call this at the same point in gateway startup where `BOOT_ACCESS` used to be
63
+ * initialized — that keeps the static-mode freeze timing identical.
64
+ */
65
+ export function createAccessStore(deps: AccessStoreDeps): AccessStore {
66
+ const { accessFile, peopleFile, stateDir, isStatic } = deps
67
+
68
+ function defaultAccess(): Access {
69
+ return { dmPolicy: 'pairing', allowFrom: [], groups: {}, pending: {} }
70
+ }
71
+
72
+ function readAccessFile(): Access {
73
+ try {
74
+ const raw = readFileSync(accessFile, 'utf8')
75
+ const parsed = JSON.parse(raw) as Partial<Access>
76
+ const allowFrom = validateStringArray('allowFrom', parsed.allowFrom ?? [])
77
+ const groups: Record<string, GroupPolicy> = {}
78
+ for (const [chatId, policy] of Object.entries(parsed.groups ?? {})) {
79
+ groups[chatId] = {
80
+ ...policy,
81
+ allowFrom: validateStringArray(`groups.${chatId}.allowFrom`, policy.allowFrom ?? []),
82
+ }
83
+ }
84
+ return {
85
+ dmPolicy: parsed.dmPolicy ?? 'pairing',
86
+ allowFrom,
87
+ groups,
88
+ pending: parsed.pending ?? {},
89
+ mentionPatterns: parsed.mentionPatterns,
90
+ ackReaction: parsed.ackReaction,
91
+ replyToMode: parsed.replyToMode,
92
+ textChunkLimit: parsed.textChunkLimit,
93
+ chunkMode: parsed.chunkMode,
94
+ parseMode: parsed.parseMode,
95
+ disableLinkPreview: parsed.disableLinkPreview,
96
+ coalescingGapMs: parsed.coalescingGapMs,
97
+ litellmNoticeWindowMs: parsed.litellmNoticeWindowMs,
98
+ coalesceMaxAttachments: parsed.coalesceMaxAttachments,
99
+ interruptSafeBoundary: parsed.interruptSafeBoundary,
100
+ interruptMaxWaitMs: parsed.interruptMaxWaitMs,
101
+ statusReactions: parsed.statusReactions,
102
+ historyEnabled: parsed.historyEnabled,
103
+ historyRetentionDays: parsed.historyRetentionDays,
104
+ // #596: telegram features projected into access.json by scaffold.
105
+ // Without these passthroughs, gateway readers (`access.voice_in`,
106
+ // `access.telegraph`, `access.stickers`) silently see undefined.
107
+ stickers: parsed.stickers,
108
+ voice_in: parsed.voice_in,
109
+ voice_out: parsed.voice_out,
110
+ telegraph: parsed.telegraph,
111
+ // #789: button-choice-confirmation config projected by scaffold.
112
+ button_choice_confirmation: parsed.button_choice_confirmation,
113
+ }
114
+ } catch (err) {
115
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return defaultAccess()
116
+ try { renameSync(accessFile, `${accessFile}.corrupt-${Date.now()}`) } catch {}
117
+ process.stderr.write(`telegram gateway: access.json is corrupt, moved aside. Starting fresh.\n`)
118
+ return defaultAccess()
119
+ }
120
+ }
121
+
122
+ const BOOT_ACCESS: Access | null = isStatic
123
+ ? (() => {
124
+ const a = readAccessFile()
125
+ if (a.dmPolicy === 'pairing') {
126
+ process.stderr.write('telegram gateway: static mode — dmPolicy "pairing" downgraded to "allowlist"\n')
127
+ a.dmPolicy = 'allowlist'
128
+ }
129
+ a.pending = {}
130
+ return a
131
+ })()
132
+ : null
133
+
134
+ function loadAccess(): Access {
135
+ return BOOT_ACCESS ?? readAccessFile()
136
+ }
137
+
138
+ /**
139
+ * Read `people.json` (the scaffold's plain projection of `users:` entries
140
+ * that carry a `person_id`). Fail-open: ENOENT or corrupt/malformed JSON
141
+ * returns an empty array rather than throwing — this feature must never
142
+ * block startup. Unlike `access.json` this file is never gateway-mutated,
143
+ * so there's no "move corrupt file aside" concern; the scaffold owns and
144
+ * regenerates it on every reconcile.
145
+ */
146
+ function readPeopleFile(): RawPersonEntry[] {
147
+ try {
148
+ const raw = readFileSync(peopleFile, 'utf8')
149
+ const parsed = JSON.parse(raw) as { entries?: unknown }
150
+ if (!Array.isArray(parsed.entries)) return []
151
+ return parsed.entries as RawPersonEntry[]
152
+ } catch {
153
+ return []
154
+ }
155
+ }
156
+
157
+ function assertAllowedChat(chat_id: string | number): void {
158
+ const id = String(chat_id)
159
+ const access = loadAccess()
160
+ if (access.allowFrom.includes(id)) return
161
+ if (id in access.groups) return
162
+ throw new Error(`chat ${id} is not allowlisted — add via /telegram:access`)
163
+ }
164
+
165
+ function saveAccess(a: Access): void {
166
+ if (isStatic) return
167
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 })
168
+ const tmp = accessFile + '.tmp'
169
+ writeFileSync(tmp, JSON.stringify(a, null, 2) + '\n', { mode: 0o600 })
170
+ renameSync(tmp, accessFile)
171
+ }
172
+
173
+ function pruneExpired(a: Access): boolean {
174
+ const now = Date.now()
175
+ let changed = false
176
+ for (const [code, p] of Object.entries(a.pending)) {
177
+ if (p.expiresAt < now) {
178
+ delete a.pending[code]
179
+ changed = true
180
+ }
181
+ }
182
+ return changed
183
+ }
184
+
185
+ return {
186
+ defaultAccess,
187
+ readAccessFile,
188
+ loadAccess,
189
+ readPeopleFile,
190
+ assertAllowedChat,
191
+ saveAccess,
192
+ pruneExpired,
193
+ }
194
+ }