@dumbmice/pi-mailbridge 0.0.0-stage → 0.1.0

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 (111) hide show
  1. package/CHANGELOG.md +824 -0
  2. package/LICENSE +21 -0
  3. package/README.md +598 -2
  4. package/bin/pi-mailbridge.mjs +31 -0
  5. package/config.example.json +54 -0
  6. package/dist/core/attachments.js +87 -0
  7. package/dist/core/authz.js +65 -0
  8. package/dist/core/commands.js +72 -0
  9. package/dist/core/config-freshness.js +24 -0
  10. package/dist/core/config.js +286 -0
  11. package/dist/core/deliveries.js +180 -0
  12. package/dist/core/discover.js +282 -0
  13. package/dist/core/header-encoding.js +70 -0
  14. package/dist/core/html.js +48 -0
  15. package/dist/core/inline-images.js +355 -0
  16. package/dist/core/mail-command.js +62 -0
  17. package/dist/core/mailer.js +126 -0
  18. package/dist/core/markdown.js +96 -0
  19. package/dist/core/oauth-device.js +119 -0
  20. package/dist/core/oauth-openid.js +48 -0
  21. package/dist/core/oauth.js +137 -0
  22. package/dist/core/prime.js +51 -0
  23. package/dist/core/reply-parse.js +134 -0
  24. package/dist/core/sent-index.js +135 -0
  25. package/dist/core/settle.js +122 -0
  26. package/dist/core/setup.js +100 -0
  27. package/dist/core/smoke.js +164 -0
  28. package/dist/core/status.js +56 -0
  29. package/dist/core/token-source.js +27 -0
  30. package/dist/core/token.js +196 -0
  31. package/dist/core/version.js +44 -0
  32. package/dist/daemon/api.js +46 -0
  33. package/dist/daemon/imapflow.js +75 -0
  34. package/dist/daemon/main.js +802 -0
  35. package/dist/daemon/process.js +270 -0
  36. package/dist/daemon/scan-queue.js +38 -0
  37. package/dist/daemon/script.js +44 -0
  38. package/dist/daemon/send-budget.js +30 -0
  39. package/dist/daemon/service.js +455 -0
  40. package/dist/daemon/source-proxy.js +64 -0
  41. package/dist/daemon/spawn.js +227 -0
  42. package/dist/daemon/watcher.js +308 -0
  43. package/dist/extension.js +1233 -0
  44. package/dist/store/journal.js +92 -0
  45. package/dist/store/mode-store.js +47 -0
  46. package/dist/store/oauth-store.js +50 -0
  47. package/dist/store/paths.js +33 -0
  48. package/dist/store/pending-store.js +212 -0
  49. package/dist/store/registry.js +61 -0
  50. package/dist/store/routes.js +223 -0
  51. package/dist/store/schema.js +93 -0
  52. package/dist/store/secret.js +70 -0
  53. package/dist/store/threads.js +145 -0
  54. package/dist/store/uid-cursor.js +112 -0
  55. package/docs/ARCHITECTURE.md +356 -0
  56. package/docs/MAPPING.md +201 -0
  57. package/docs/OAUTH.md +448 -0
  58. package/docs/SECURITY.md +273 -0
  59. package/docs/images/flow.svg +124 -0
  60. package/docs/images/notification.png +0 -0
  61. package/package.json +85 -4
  62. package/scripts/render-teaser.ts +56 -0
  63. package/src/core/attachments.ts +129 -0
  64. package/src/core/authz.ts +87 -0
  65. package/src/core/commands.ts +99 -0
  66. package/src/core/config-freshness.ts +33 -0
  67. package/src/core/config.ts +373 -0
  68. package/src/core/deliveries.ts +233 -0
  69. package/src/core/discover.ts +333 -0
  70. package/src/core/header-encoding.ts +75 -0
  71. package/src/core/html.ts +62 -0
  72. package/src/core/inline-images.ts +421 -0
  73. package/src/core/mail-command.ts +76 -0
  74. package/src/core/mailer.ts +193 -0
  75. package/src/core/markdown.ts +114 -0
  76. package/src/core/oauth-device.ts +195 -0
  77. package/src/core/oauth-openid.ts +61 -0
  78. package/src/core/oauth.ts +182 -0
  79. package/src/core/prime.ts +74 -0
  80. package/src/core/reply-parse.ts +153 -0
  81. package/src/core/sent-index.ts +164 -0
  82. package/src/core/settle.ts +179 -0
  83. package/src/core/setup.ts +139 -0
  84. package/src/core/smoke.ts +207 -0
  85. package/src/core/status.ts +75 -0
  86. package/src/core/token-source.ts +34 -0
  87. package/src/core/token.ts +240 -0
  88. package/src/core/version.ts +45 -0
  89. package/src/daemon/api.ts +52 -0
  90. package/src/daemon/imapflow.ts +101 -0
  91. package/src/daemon/main.ts +832 -0
  92. package/src/daemon/process.ts +348 -0
  93. package/src/daemon/scan-queue.ts +45 -0
  94. package/src/daemon/script.ts +45 -0
  95. package/src/daemon/send-budget.ts +36 -0
  96. package/src/daemon/service.ts +550 -0
  97. package/src/daemon/source-proxy.ts +88 -0
  98. package/src/daemon/spawn.ts +332 -0
  99. package/src/daemon/watcher.ts +427 -0
  100. package/src/extension.ts +1381 -0
  101. package/src/store/journal.ts +104 -0
  102. package/src/store/mode-store.ts +60 -0
  103. package/src/store/oauth-store.ts +55 -0
  104. package/src/store/paths.ts +38 -0
  105. package/src/store/pending-store.ts +282 -0
  106. package/src/store/registry.ts +91 -0
  107. package/src/store/routes.ts +267 -0
  108. package/src/store/schema.ts +104 -0
  109. package/src/store/secret.ts +69 -0
  110. package/src/store/threads.ts +177 -0
  111. package/src/store/uid-cursor.ts +135 -0
@@ -0,0 +1,207 @@
1
+ // Live connectivity probe, shared by `/mailbridge smoke` and the headless
2
+ // `--smoke` entry point so the two can never drift.
3
+ //
4
+ // Deliberately does NOT touch the cursor, the journal or any pending record: a
5
+ // smoke test must never disturb real deliveries.
6
+
7
+ import type { ResolvedConfig } from "./config.ts"
8
+ import { sendMail } from "./mailer.ts"
9
+ import { createImapflowFactory } from "../daemon/imapflow.ts"
10
+ import { messageIdFromHeaders, stampedIdFromHeaders } from "./sent-index.ts"
11
+
12
+ export interface SmokeStep {
13
+ ok: boolean
14
+ /** What was attempted, e.g. "smtp". */
15
+ step: "smtp" | "imap" | "echo"
16
+ /** Human-readable outcome. */
17
+ detail: string
18
+ /** Hint shown when the failure looks like the personal-Outlook problem. */
19
+ hint?: string
20
+ }
21
+
22
+ export interface SmokeResult {
23
+ ok: boolean
24
+ steps: SmokeStep[]
25
+ }
26
+
27
+ export interface SmokeDeps {
28
+ getAccessToken?: () => Promise<string>
29
+ /** Injected for tests. */
30
+ send?: typeof sendMail
31
+ connect?: (config: ResolvedConfig, getAccessToken?: () => Promise<string>) => Promise<{
32
+ count(): Promise<number>
33
+ /** Raw source of the messages seen since `sinceMs`, for the echo probe. */
34
+ recentSources?(sinceMs: number): Promise<Buffer[]>
35
+ close(): Promise<void>
36
+ }>
37
+ /** Retry delays for the echo probe: the provider delivers the copy asynchronously. */
38
+ echoRetryDelaysMs?: readonly number[]
39
+ sleep?: (ms: number) => Promise<void>
40
+ }
41
+
42
+ const defaultConnect: NonNullable<SmokeDeps["connect"]> = async (config, getAccessToken) => {
43
+ const factory = createImapflowFactory({ getAccessToken })
44
+ const client = await factory(config, { idleRenewMs: config.tuning.idleRenewMs })
45
+ await client.connect()
46
+ await client.mailboxOpen(config.mail.imap.folder || "INBOX")
47
+ return {
48
+ count: async () => (await client.search({ uid: "1:*" }, { uid: true })).length,
49
+ recentSources: async (sinceMs: number) => {
50
+ const uids = await client.search({ since: new Date(sinceMs) }, { uid: true })
51
+ const newest = uids.slice().sort((a, b) => b - a).slice(0, 20)
52
+ const sources: Buffer[] = []
53
+ for (const uid of newest) {
54
+ const fetched = (await client.fetchOne(uid, { source: true }, { uid: true })) as
55
+ | { source?: Buffer }
56
+ | false
57
+ | undefined
58
+ if (fetched && fetched.source) sources.push(fetched.source)
59
+ }
60
+ return sources
61
+ },
62
+ close: async () => {
63
+ try {
64
+ await client.logout()
65
+ } catch {
66
+ /* already gone */
67
+ }
68
+ },
69
+ }
70
+ }
71
+
72
+ /** The personal-Outlook failure modes, which mean "use Graph", not "retry". */
73
+ export function looksLikeOutlookRefusal(message: string): boolean {
74
+ return /authenticated but not connected|smtpclientauthentication is disabled|535 authentication unsuccessful/i.test(message)
75
+ }
76
+
77
+ export async function runSmoke(config: ResolvedConfig, deps: SmokeDeps = {}): Promise<SmokeResult> {
78
+ const steps: SmokeStep[] = []
79
+ const send = deps.send ?? sendMail
80
+ /** The Message-ID we stamped, so the echo probe can look for it. */
81
+ let lastMessageId: string | undefined
82
+
83
+ // --- SMTP ---
84
+ try {
85
+ const stamp = new Date().toISOString()
86
+ const sent = await send(
87
+ config,
88
+ {
89
+ to: config.mail.to,
90
+ subject: `pi-mailbridge smoke test ${stamp}`,
91
+ text: `This is a smoke test from ${config.device.name}.\n\nIf you can read this, SMTP works.\n`,
92
+ },
93
+ { getAccessToken: deps.getAccessToken },
94
+ )
95
+ lastMessageId = sent.messageId
96
+ steps.push({
97
+ ok: true,
98
+ step: "smtp",
99
+ detail: `sent to ${config.mail.to}${sent.messageId ? ` (${sent.messageId})` : ""}`,
100
+ })
101
+ } catch (error) {
102
+ const detail = (error as Error).message
103
+ steps.push({
104
+ ok: false,
105
+ step: "smtp",
106
+ detail,
107
+ ...(looksLikeOutlookRefusal(detail) ? { hint: "the account refuses SMTP AUTH; the Graph transport is the fix — see docs/OAUTH.md" } : {}),
108
+ })
109
+ return { ok: false, steps }
110
+ }
111
+
112
+ // --- IMAP ---
113
+ const folder = config.mail.imap.folder || "INBOX"
114
+ let connection:
115
+ | { count(): Promise<number>; recentSources?(sinceMs: number): Promise<Buffer[]>; close(): Promise<void> }
116
+ | undefined
117
+ try {
118
+ connection = await (deps.connect ?? defaultConnect)(config, deps.getAccessToken)
119
+ const count = await connection.count()
120
+ steps.push({ ok: true, step: "imap", detail: `${count} message(s) visible in ${folder}` })
121
+
122
+ // --- echo probe ---
123
+ //
124
+ // Only meaningful in the single-mailbox layout: there the notification is delivered into
125
+ // the folder we watch, so we can see what the provider did to our headers. In layout A it
126
+ // goes to the human's mailbox and anything found here is unrelated.
127
+ //
128
+ // The copy arrives asynchronously (QQ: 20-30 s) and is identified by the
129
+ // `X-Pi-Mailbridge-Id` we stamped — reading "the newest message" once produced a false
130
+ // alarm on a live account.
131
+ const sameMailbox =
132
+ Boolean(config.mail.to) &&
133
+ config.mail.to.trim().toLowerCase() === (config.mail.imap.user ?? "").trim().toLowerCase()
134
+ if (sameMailbox && lastMessageId && connection.recentSources) {
135
+ const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)))
136
+ const delays = deps.echoRetryDelaysMs ?? [2_000, 5_000, 15_000, 30_000]
137
+ const wanted = lastMessageId.replace(/[<>]/g, "")
138
+ const since = Date.now() - 15 * 60_000
139
+ let seen: "none" | "marker" | "id" | "rewritten" = "none"
140
+ for (const delay of delays) {
141
+ await sleep(delay)
142
+ for (const source of await connection.recentSources(since)) {
143
+ const head = (source.toString("utf8").split(/\r?\n\r?\n/)[0] ?? "").replace(/\r?\n[ \t]+/g, " ")
144
+ // The stamp is how we find the copy, but a provider can strip it while leaving
145
+ // our marker. Treat that copy as ours too, or the probe reports "did not come
146
+ // back" for a notification the scanner would recognise perfectly well.
147
+ const stamped = stampedIdFromHeaders(head)
148
+ const marked = /^x-pi-mailbridge:\s*notification/im.test(head)
149
+ if (stamped !== wanted && !(marked && stamped === undefined)) continue
150
+ // Classify on what the scanner actually uses: the marker, then our
151
+ // Message-ID. Claiming the Message-ID survived without checking it was the
152
+ // bug the live account exposed.
153
+ if (/^x-pi-mailbridge:\s*notification/im.test(head)) seen = "marker"
154
+ else if (messageIdFromHeaders(head) === wanted) seen = "id"
155
+ else seen = "rewritten"
156
+ break
157
+ }
158
+ if (seen !== "none") break
159
+ }
160
+ steps.push(
161
+ (() => {
162
+ if (seen === "none") {
163
+ return {
164
+ ok: false,
165
+ step: "echo" as const,
166
+ detail:
167
+ "our notification did not come back to this folder — single-mailbox mode relies on it returning with a header it can recognise",
168
+ }
169
+ }
170
+ if (seen === "marker") {
171
+ return { ok: true, step: "echo" as const, detail: "our marker header survives delivery" }
172
+ }
173
+ if (seen === "id") {
174
+ return {
175
+ ok: true,
176
+ step: "echo" as const,
177
+ detail: "marker header was stripped, but our Message-ID survived — the guard still holds",
178
+ }
179
+ }
180
+ // The provider rewrote both. Whether that is safe depends entirely on the
181
+ // sent-copy read-back, which is what then records the delivered id.
182
+ const covered = Boolean(config.mail.imap.sentFolder)
183
+ return {
184
+ ok: covered,
185
+ step: "echo" as const,
186
+ detail: covered
187
+ ? "the provider rewrote both our headers; the sentFolder read-back records the delivered id, so the registry still catches our own mail"
188
+ : "the provider rewrote both our headers and mail.imap.sentFolder is not set — our own mail is not recognisable; set it",
189
+ }
190
+ })(),
191
+ )
192
+ }
193
+
194
+ } catch (error) {
195
+ const detail = (error as Error).message
196
+ steps.push({
197
+ ok: false,
198
+ step: "imap",
199
+ detail,
200
+ ...(looksLikeOutlookRefusal(detail) ? { hint: "the account refuses IMAP for this token; the Graph transport is the fix — see docs/OAUTH.md" } : {}),
201
+ })
202
+ } finally {
203
+ await connection?.close()
204
+ }
205
+
206
+ return { ok: steps.every((s) => s.ok), steps }
207
+ }
@@ -0,0 +1,75 @@
1
+ // Turning a scan summary into the lines `/mailbridge status` shows.
2
+ //
3
+ // Pure on purpose: this is the answer to "why did my reply not arrive", and it lived inline
4
+ // in the extension where no test could reach it - which is how an unconditional "queue full"
5
+ // line shipped in its first version.
6
+
7
+ export interface ScanSummary {
8
+ /** When the last scan that saw mail ran; absent until one has. */
9
+ at?: number
10
+ /** When the daemon last *checked*, which is later than `at` on a quiet mailbox. */
11
+ checkedAt?: number
12
+ examined?: number
13
+ persisted?: number
14
+ rejected?: number
15
+ reasons?: Record<string, number>
16
+ }
17
+
18
+ /**
19
+ * Counter keys that are not refusals.
20
+ *
21
+ * `routed` / `threaded` / `journaled` / `vanished` are successful paths. `route-miss` is a
22
+ * miss on the subject id that still routed by token or chain. `attachment-*` drops one file
23
+ * but keeps the reply.
24
+ */
25
+ const HANDLED = new Set([
26
+ "routed",
27
+ "threaded",
28
+ "journaled",
29
+ "vanished",
30
+ "route-miss",
31
+ // In single-mailbox mode our own notifications come back on every send. Rejecting them is
32
+ // the system working, so counting them as rejections made a healthy install look broken.
33
+ "self-echo",
34
+ ])
35
+
36
+ /** Deferred, not dropped: the queue was full and the same UID is retried. */
37
+ const DEFERRED = new Set(["pending-full"])
38
+
39
+ export function formatScan(summary: ScanSummary | null | undefined): string[] {
40
+ if (!summary) return ["last scan: (never — is the daemon running?)"]
41
+ if (summary.at === undefined) {
42
+ // No scan has seen mail yet. Say when we last looked: otherwise a healthy quiet
43
+ // mailbox is indistinguishable from a dead poll loop.
44
+ const checked = summary.checkedAt ? new Date(summary.checkedAt).toLocaleTimeString() : "unknown"
45
+ return [`no mail yet — last checked ${checked}`]
46
+ }
47
+
48
+ const entries = Object.entries(summary.reasons ?? {}).filter(([, n]) => Number(n) > 0)
49
+ const list = (pick: (reason: string) => boolean): string =>
50
+ entries
51
+ .filter(([reason]) => pick(reason))
52
+ .map(([reason, n]) => `${reason} ${n}`)
53
+ .join(", ")
54
+
55
+ const refused = list((r) => !HANDLED.has(r) && !DEFERRED.has(r) && !r.startsWith("attachment-"))
56
+ const handled = list((r) => HANDLED.has(r))
57
+ const dropped = list((r) => r.startsWith("attachment-"))
58
+ const deferred = entries.some(([reason]) => DEFERRED.has(reason))
59
+
60
+ // Freshness stays visible after the first message: "last scan with mail: 10:00" alone cannot
61
+ // tell a live poll loop from one that died at 10:05.
62
+ const checked =
63
+ summary.checkedAt && summary.checkedAt - summary.at > 60_000
64
+ ? ` (last checked ${new Date(summary.checkedAt).toLocaleTimeString()})`
65
+ : ""
66
+
67
+ return [
68
+ `last scan with mail: ${new Date(summary.at).toLocaleTimeString()}${checked} — examined ${summary.examined}, kept ${summary.persisted}, rejected ${summary.rejected}`,
69
+ `rejections: ${refused || "none"}`,
70
+ ...(handled ? [`handled: ${handled}`] : []),
71
+ ...(dropped ? [`attachments dropped: ${dropped}`] : []),
72
+ // Conditional, and it says what the reader should do about it.
73
+ ...(deferred ? ["queue full: replies are deferred, not dropped — start a session so they drain"] : []),
74
+ ]
75
+ }
@@ -0,0 +1,34 @@
1
+ // Which token source this installation uses.
2
+ //
3
+ // Shared by the daemon and the in-pi commands so `/mailbridge smoke` and the
4
+ // background daemon can never authenticate differently.
5
+
6
+ import type { ResolvedConfig } from "./config.ts"
7
+ import { createCommandTokenProvider, type TokenProvider } from "./oauth.ts"
8
+ import { createDeviceCodeProvider } from "./oauth-device.ts"
9
+ import { createOpenidClient } from "./oauth-openid.ts"
10
+ import { fileTokenStore } from "../store/oauth-store.ts"
11
+
12
+ /**
13
+ * Precedence: built-in device-code OAuth2 (self-refreshing) -> a configured
14
+ * command -> none (the account uses LOGIN).
15
+ * Returning undefined is not an error: it means XOAUTH2 is not in play.
16
+ */
17
+ export async function buildTokenProvider(
18
+ config: ResolvedConfig,
19
+ env: NodeJS.ProcessEnv = process.env,
20
+ ): Promise<TokenProvider | undefined> {
21
+ if (config.oauth?.issuer && config.oauth.clientId) {
22
+ const client = await createOpenidClient(config.oauth)
23
+ return createDeviceCodeProvider({ client, store: fileTokenStore(env) })
24
+ }
25
+
26
+ // An empty array is not nullish: the shipped example has `mail.smtp.tokenCommand: []`, so
27
+ // `??` picked the empty SMTP list and ignored a perfectly good IMAP command - XOAUTH2 then
28
+ // failed at connect time while `--check` said the config was fine.
29
+ const smtp = config.mail.smtp.tokenCommand ?? []
30
+ const command = smtp.length > 0 ? smtp : (config.mail.imap.tokenCommand ?? [])
31
+ if (command.length > 0) return createCommandTokenProvider({ command })
32
+
33
+ return undefined
34
+ }
@@ -0,0 +1,240 @@
1
+ // HMAC-signed routing tokens.
2
+ //
3
+ // A signed token is the fallback routing form: it is minted when no local route id is
4
+ // available (see store/routes.ts), and lifted back out of replies. It answers exactly one question: "which session did this mail come from?"
5
+ // It is ROUTING, not AUTHORIZATION — see docs/SECURITY.md §2.2.
6
+ //
7
+ // Two wire formats, because subjects are user-visible and the first one was ugly:
8
+ //
9
+ // v2 (current) [pi:<payload>.<mac>]
10
+ // payload = base64url( [0x02][16 session uuid][3 expiry minutes][2 device hash] )
11
+ // mac = base64url(HMAC-SHA256(secret, payload)) truncated to 6 bytes
12
+ // → 30 + 1 + 8 = 39 characters, so a whole subject stays under ~90
13
+ //
14
+ // v2-legacy (21-byte payload, minted before expiry went minute-granular)
15
+ // payload = base64url( [0x02][16 session uuid][2 expiry days][2 device hash] )
16
+ // → 28 + 1 + 8 = 37 characters. Still verified: a notification sent minutes before
17
+ // an upgrade keeps working.
18
+ //
19
+ // v1 (legacy) base64url(JSON {s,e,d}) + '.' + 16-byte mac
20
+ // → ~130 characters. Still verified, so tokens already in flight keep working.
21
+ //
22
+ // A non-UUID session id (tests, synthetic sessions) has no compact form, so those
23
+ // keep using v1.
24
+
25
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"
26
+
27
+ /** MAC length for v2. 48 bits is ample for an online-verifiable routing token. */
28
+ const MAC_BYTES_V2 = 6
29
+ /** MAC length for v1, kept for compatibility with tokens already sent. */
30
+ const MAC_BYTES_V1 = 16
31
+ /** Tokens older than this are refused even when the MAC is valid. */
32
+ export const DEFAULT_TOKEN_TTL_HOURS = 30 * 24
33
+
34
+ const V2 = 0x02
35
+ /**
36
+ * [version][16 session][3 expiry][2 device]
37
+ *
38
+ * Expiry is minutes since the epoch: three bytes covers ~31 years, and a day of
39
+ * granularity (two bytes) let a token expire up to 24 h earlier than configured.
40
+ */
41
+ const V2_PAYLOAD_BYTES = 1 + 16 + 3 + 2
42
+ /**
43
+ * Expiry is minutes since 2020, not since 1970: three bytes reaches ~2052 from here, where
44
+ * minutes since 1970 topped out in 2001 and silently clamped every token to "expired".
45
+ */
46
+ const MINUTE_EPOCH = Date.UTC(2020, 0, 1)
47
+
48
+ export interface TokenBody {
49
+ /** session id */
50
+ s: string
51
+ /** expiry, epoch ms */
52
+ e: number
53
+ /** minting device, when known */
54
+ d?: string
55
+ }
56
+
57
+ export const TOKEN_RE = /\[\s*pi:([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)\s*\]/i
58
+
59
+ function mac(secret: Buffer, payload: string, bytes: number): string {
60
+ return createHmac("sha256", secret).update(payload).digest().subarray(0, bytes).toString("base64url")
61
+ }
62
+
63
+ /** 32 hex characters (with or without dashes) → 16 bytes, else undefined. */
64
+ function uuidToBytes(sessionId: string): Buffer | undefined {
65
+ const hex = sessionId.replace(/-/g, "")
66
+ if (!/^[0-9a-fA-F]{32}$/.test(hex)) return undefined
67
+ return Buffer.from(hex, "hex")
68
+ }
69
+
70
+ function bytesToUuid(bytes: Buffer): string {
71
+ const hex = bytes.toString("hex")
72
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
73
+ }
74
+
75
+ /**
76
+ * Stable 2-byte fingerprint of a device name; enough to route, not to identify.
77
+ * All-zero means "no device claim", which must not read as a mismatch.
78
+ */
79
+ function deviceHash(device: string): Buffer {
80
+ return createHash("sha256").update(device.toLowerCase()).digest().subarray(0, 2)
81
+ }
82
+
83
+ const NO_DEVICE = Buffer.alloc(2)
84
+
85
+ export interface SignOptions {
86
+ ttlHours?: number
87
+ now?: number
88
+ device?: string
89
+ }
90
+
91
+ export function signToken(sessionId: string, secret: Buffer, opts: SignOptions = {}): string {
92
+ const now = opts.now ?? Date.now()
93
+ const expiresAt = now + (opts.ttlHours ?? DEFAULT_TOKEN_TTL_HOURS) * 3600_000
94
+
95
+ const compactSession = uuidToBytes(sessionId)
96
+ if (compactSession) {
97
+ const payload = Buffer.alloc(V2_PAYLOAD_BYTES)
98
+ payload[0] = V2
99
+ compactSession.copy(payload, 1)
100
+ const minutes = Math.floor((expiresAt - MINUTE_EPOCH) / 60_000)
101
+ payload.writeUIntBE(Math.max(0, Math.min(0xffffff, minutes)), 17, 3)
102
+ ;(opts.device ? deviceHash(opts.device) : NO_DEVICE).copy(payload, 20)
103
+ const encoded = payload.toString("base64url")
104
+ return `${encoded}.${mac(secret, encoded, MAC_BYTES_V2)}`
105
+ }
106
+
107
+ // v1: no compact form for an arbitrary session id.
108
+ const body: TokenBody = { s: sessionId, e: expiresAt, ...(opts.device ? { d: opts.device } : {}) }
109
+ const payload = Buffer.from(JSON.stringify(body)).toString("base64url")
110
+ return `${payload}.${mac(secret, payload, MAC_BYTES_V1)}`
111
+ }
112
+
113
+ export type VerifyResult =
114
+ | {
115
+ ok: true
116
+ sessionId: string
117
+ /** Full device name, when the format carries one (v1). */
118
+ device?: string
119
+ /**
120
+ * True/false when the caller supplied its own device to compare against;
121
+ * undefined when the token or the caller carries no device, meaning "no
122
+ * opinion" rather than "mismatch".
123
+ */
124
+ deviceMatches?: boolean
125
+ }
126
+ | { ok: false; reason: "malformed" | "bad-mac" | "expired" }
127
+
128
+ function splitToken(token: string): { payload: string; given: string } | undefined {
129
+ const dot = token.lastIndexOf(".")
130
+ if (dot <= 0 || dot === token.length - 1) return undefined
131
+ return { payload: token.slice(0, dot), given: token.slice(dot + 1) }
132
+ }
133
+
134
+ function macMatches(given: string, expected: string): boolean {
135
+ const a = Buffer.from(given)
136
+ const b = Buffer.from(expected)
137
+ return a.length === b.length && timingSafeEqual(a, b)
138
+ }
139
+
140
+ export function verifyToken(token: string, secret: Buffer, now: number = Date.now(), device?: string): VerifyResult {
141
+ const parts = splitToken(token)
142
+ if (!parts) return { ok: false, reason: "malformed" }
143
+ const { payload, given } = parts
144
+
145
+ let bytes: Buffer
146
+ try {
147
+ bytes = Buffer.from(payload, "base64url")
148
+ } catch {
149
+ return { ok: false, reason: "malformed" }
150
+ }
151
+
152
+ const fingerprintOf = (at: number): Buffer => bytes.subarray(at, at + 2)
153
+
154
+ // --- v2: compact binary payload ---
155
+ if (bytes.length === V2_PAYLOAD_BYTES && bytes[0] === V2) {
156
+ if (!macMatches(given, mac(secret, payload, MAC_BYTES_V2))) return { ok: false, reason: "bad-mac" }
157
+ const expiresAt = MINUTE_EPOCH + bytes.readUIntBE(17, 3) * 60_000
158
+ if (expiresAt <= now) return { ok: false, reason: "expired" }
159
+ const fingerprint = fingerprintOf(20)
160
+ const claimsDevice = !fingerprint.equals(NO_DEVICE)
161
+ const deviceMatches = device && claimsDevice ? fingerprint.equals(deviceHash(device)) : undefined
162
+ return {
163
+ ok: true,
164
+ sessionId: bytesToUuid(bytes.subarray(1, 17)),
165
+ ...(deviceMatches === undefined ? {} : { deviceMatches }),
166
+ }
167
+ }
168
+
169
+ // --- v2-legacy: same shape with two-byte day expiry, minted before the layout change ---
170
+ if (bytes.length === 21 && bytes[0] === V2) {
171
+ if (!macMatches(given, mac(secret, payload, MAC_BYTES_V2))) return { ok: false, reason: "bad-mac" }
172
+ const expiresAt = bytes.readUInt16BE(17) * 86_400_000
173
+ if (expiresAt <= now) return { ok: false, reason: "expired" }
174
+ const fingerprint = fingerprintOf(19)
175
+ const claimsDevice = !fingerprint.equals(NO_DEVICE)
176
+ const deviceMatches = device && claimsDevice ? fingerprint.equals(deviceHash(device)) : undefined
177
+ return {
178
+ ok: true,
179
+ sessionId: bytesToUuid(bytes.subarray(1, 17)),
180
+ ...(deviceMatches === undefined ? {} : { deviceMatches }),
181
+ }
182
+ }
183
+
184
+ // --- v1: JSON payload ---
185
+ if (!macMatches(given, mac(secret, payload, MAC_BYTES_V1))) return { ok: false, reason: "bad-mac" }
186
+ let body: TokenBody
187
+ try {
188
+ body = JSON.parse(bytes.toString("utf8")) as TokenBody
189
+ } catch {
190
+ return { ok: false, reason: "malformed" }
191
+ }
192
+ if (typeof body?.s !== "string" || typeof body?.e !== "number") return { ok: false, reason: "malformed" }
193
+ if (body.e <= now) return { ok: false, reason: "expired" }
194
+ const claimedDevice = typeof body.d === "string" && body.d ? body.d : undefined
195
+ const deviceMatches = claimedDevice && device ? claimedDevice === device : undefined
196
+ return {
197
+ ok: true,
198
+ sessionId: body.s,
199
+ ...(claimedDevice ? { device: claimedDevice } : {}),
200
+ ...(deviceMatches === undefined ? {} : { deviceMatches }),
201
+ }
202
+ }
203
+
204
+ /** Prefix a human subject with the signed token. */
205
+ export function stampSubject(sessionId: string, subject: string, secret: Buffer, opts?: SignOptions): string {
206
+ return `[pi:${signToken(sessionId, secret, opts)}] ${subject}`
207
+ }
208
+
209
+ /**
210
+ * Pull a verified routing token out of a subject line.
211
+ * Tolerates client prefixes (`Re:`, `RE:`, `AW:`, `Re[2]:`) because the token is
212
+ * matched anywhere in the subject, not anchored.
213
+ */
214
+ export function tokenFromSubject(
215
+ subject: string | undefined | null,
216
+ secret: Buffer,
217
+ now?: number,
218
+ device?: string,
219
+ ): { sessionId: string; device?: string; deviceMatches?: boolean } | undefined {
220
+ if (!subject) return undefined
221
+ const m = TOKEN_RE.exec(subject)
222
+ if (!m?.[1]) return undefined
223
+ const result = verifyToken(m[1], secret, now, device)
224
+ if (!result.ok) return undefined
225
+ return {
226
+ sessionId: result.sessionId,
227
+ ...(result.device ? { device: result.device } : {}),
228
+ ...(result.deviceMatches === undefined ? {} : { deviceMatches: result.deviceMatches }),
229
+ }
230
+ }
231
+
232
+ /** Convenience wrapper for callers that only need the session id. */
233
+ export function sessionIdFromSubject(subject: string | undefined | null, secret: Buffer, now?: number): string | undefined {
234
+ return tokenFromSubject(subject, secret, now)?.sessionId
235
+ }
236
+
237
+ /** Shared secret material. 32 bytes. */
238
+ export function newSecret(): Buffer {
239
+ return randomBytes(32)
240
+ }
@@ -0,0 +1,45 @@
1
+ // Which build is this? Diagnostic only - compatibility is `daemon/api.ts` (API_VERSION).
2
+ //
3
+ // The extension is loaded by pi and the daemon is a child process; after `pi update` the child
4
+ // can still be executing the previous build. `/health` carries this string so a session can
5
+ // notice and replace it, instead of talking to a daemon that mints routing ids it cannot resolve.
6
+
7
+ import { readFileSync } from "node:fs"
8
+ import { dirname, join } from "node:path"
9
+ import { fileURLToPath } from "node:url"
10
+
11
+ const cache = new Map<string, string>()
12
+
13
+ /**
14
+ * The version from the package.json that owns this module.
15
+ *
16
+ * `dist/daemon/main.js` and `src/daemon/main.ts` are both two levels below the package root, so
17
+ * one lookup works for a checkout and for an installed build. Unknown is reported as `"unknown"`
18
+ * rather than guessing: a caller that cannot compare should do nothing.
19
+ */
20
+ export function packageVersion(moduleUrl: string | URL): string {
21
+ const key = String(moduleUrl)
22
+ const cached = cache.get(key)
23
+ if (cached !== undefined) return cached
24
+
25
+ // Walk up rather than assuming a depth: `src/extension.ts` is one level below the package
26
+ // root, `src/daemon/main.ts` and its compiled twin are two, and a test file is one.
27
+ let version = "unknown"
28
+ let dir = dirname(fileURLToPath(moduleUrl))
29
+ for (let depth = 0; depth < 5; depth += 1) {
30
+ try {
31
+ const parsed = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as { version?: unknown }
32
+ if (typeof parsed.version === "string" && parsed.version.length > 0) {
33
+ version = parsed.version
34
+ break
35
+ }
36
+ } catch {
37
+ /* keep walking */
38
+ }
39
+ const parent = dirname(dir)
40
+ if (parent === dir) break
41
+ dir = parent
42
+ }
43
+ cache.set(key, version)
44
+ return version
45
+ }
@@ -0,0 +1,52 @@
1
+ // The wire contract between the extension and the daemon.
2
+ //
3
+ // Two numbers, two clocks, deliberately separate (AGENTS.md "Compatibility"):
4
+ //
5
+ // API_VERSION - the HTTP surface: endpoints, payload fields, semantics. Bumped by any
6
+ // breaking change to it; additive changes are gated by CAPABILITIES instead.
7
+ // package version (version.ts) - diagnostic only: which build someone is running.
8
+ //
9
+ // The daemon and the extension are spawned from one package, so a mismatch is recovered by
10
+ // replacing the process rather than negotiating - but the *number* compared must be the
11
+ // protocol, not the build, or a harmless patch bump would restart a working daemon.
12
+
13
+ /** Bumped only for a breaking change to an endpoint, a payload shape, or a meaning. */
14
+ export const API_VERSION = 1
15
+
16
+ /**
17
+ * Additive features inside API_VERSION.
18
+ *
19
+ * A client must check for a capability before using anything newer than the base contract, and
20
+ * must not fake it when absent (no fallback paths: tell the user to update, or skip the feature).
21
+ */
22
+ export const CAPABILITIES = ["health", "version", "pending", "claim", "ack", "send"] as const
23
+
24
+ export type Capability = (typeof CAPABILITIES)[number]
25
+
26
+ /** Every endpoint, in one place, so the schema test can freeze the surface. */
27
+ export const ENDPOINTS = [
28
+ "GET /events",
29
+ "GET /health",
30
+ "GET /pending",
31
+ "GET /mode",
32
+ "POST /claim",
33
+ "POST /ack",
34
+ "POST /register",
35
+ "DELETE /register",
36
+ "POST /ping",
37
+ "POST /bye",
38
+ "POST /shutdown",
39
+ "POST /reload",
40
+ "POST /mode",
41
+ "POST /send",
42
+ ] as const
43
+
44
+ /**
45
+ * Is a daemon speaking `theirs` usable by this build?
46
+ *
47
+ * Missing means "from before the field existed" - that is protocol 0, which this build cannot
48
+ * assume anything about.
49
+ */
50
+ export function apiCompatible(mine: number, theirs: number | undefined): boolean {
51
+ return typeof theirs === "number" && theirs === mine
52
+ }