@c9up/goldstone 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 (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +238 -2
  3. package/dist/GoldstoneProvider.d.ts +33 -0
  4. package/dist/GoldstoneProvider.d.ts.map +1 -0
  5. package/dist/GoldstoneProvider.js +55 -0
  6. package/dist/GoldstoneProvider.js.map +1 -0
  7. package/dist/InboxManager.d.ts +17 -0
  8. package/dist/InboxManager.d.ts.map +1 -0
  9. package/dist/InboxManager.js +39 -0
  10. package/dist/InboxManager.js.map +1 -0
  11. package/dist/Mailbox.d.ts +83 -0
  12. package/dist/Mailbox.d.ts.map +1 -0
  13. package/dist/Mailbox.js +326 -0
  14. package/dist/Mailbox.js.map +1 -0
  15. package/dist/ReceivedMessage.d.ts +152 -0
  16. package/dist/ReceivedMessage.d.ts.map +1 -0
  17. package/dist/ReceivedMessage.js +179 -0
  18. package/dist/ReceivedMessage.js.map +1 -0
  19. package/dist/augmentations.d.ts +18 -0
  20. package/dist/augmentations.d.ts.map +1 -0
  21. package/dist/augmentations.js +10 -0
  22. package/dist/augmentations.js.map +1 -0
  23. package/dist/authenticity.d.ts +81 -0
  24. package/dist/authenticity.d.ts.map +1 -0
  25. package/dist/authenticity.js +102 -0
  26. package/dist/authenticity.js.map +1 -0
  27. package/dist/config.d.ts +85 -0
  28. package/dist/config.d.ts.map +1 -0
  29. package/dist/config.js +50 -0
  30. package/dist/config.js.map +1 -0
  31. package/dist/errors.d.ts +15 -0
  32. package/dist/errors.d.ts.map +1 -0
  33. package/dist/errors.js +50 -0
  34. package/dist/errors.js.map +1 -0
  35. package/dist/inbound/context.d.ts +43 -0
  36. package/dist/inbound/context.d.ts.map +1 -0
  37. package/dist/inbound/context.js +56 -0
  38. package/dist/inbound/context.js.map +1 -0
  39. package/dist/inbound/mailgun.d.ts +57 -0
  40. package/dist/inbound/mailgun.d.ts.map +1 -0
  41. package/dist/inbound/mailgun.js +132 -0
  42. package/dist/inbound/mailgun.js.map +1 -0
  43. package/dist/index.d.ts +11 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +11 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/native/generated.d.ts +106 -0
  48. package/dist/native/generated.d.ts.map +1 -0
  49. package/dist/native/generated.js +7 -0
  50. package/dist/native/generated.js.map +1 -0
  51. package/dist/native.d.ts +23 -0
  52. package/dist/native.d.ts.map +1 -0
  53. package/dist/native.js +45 -0
  54. package/dist/native.js.map +1 -0
  55. package/dist/services/main.d.ts +22 -0
  56. package/dist/services/main.d.ts.map +1 -0
  57. package/dist/services/main.js +43 -0
  58. package/dist/services/main.js.map +1 -0
  59. package/dist/vendor/nativeBinary.d.ts +67 -0
  60. package/dist/vendor/nativeBinary.d.ts.map +1 -0
  61. package/dist/vendor/nativeBinary.js +156 -0
  62. package/dist/vendor/nativeBinary.js.map +1 -0
  63. package/index.darwin-arm64.node +0 -0
  64. package/index.darwin-x64.node +0 -0
  65. package/index.linux-arm64-gnu.node +0 -0
  66. package/index.linux-x64-gnu.node +0 -0
  67. package/index.win32-x64-msvc.node +0 -0
  68. package/package.json +67 -3
  69. package/src/GoldstoneProvider.ts +75 -0
  70. package/src/InboxManager.ts +51 -0
  71. package/src/Mailbox.ts +433 -0
  72. package/src/ReceivedMessage.ts +287 -0
  73. package/src/augmentations.ts +22 -0
  74. package/src/authenticity.ts +183 -0
  75. package/src/config.ts +132 -0
  76. package/src/errors.ts +57 -0
  77. package/src/inbound/context.ts +92 -0
  78. package/src/inbound/mailgun.ts +201 -0
  79. package/src/index.ts +53 -0
  80. package/src/native/generated.ts +141 -0
  81. package/src/native.ts +62 -0
  82. package/src/services/main.ts +51 -0
  83. package/src/vendor/nativeBinary.ts +186 -0
@@ -0,0 +1,92 @@
1
+ /**
2
+ * What an inbound handler needs from the HTTP request, structurally — a
3
+ * Ream `HttpContext` fits, and so does a plain object in a test.
4
+ */
5
+
6
+ import { GoldstoneError } from "../errors.js";
7
+
8
+ export interface InboundResponse {
9
+ status(code: number): InboundResponse;
10
+ json(data: unknown): void;
11
+ }
12
+
13
+ export interface InboundHttpContext {
14
+ request: {
15
+ /**
16
+ * The parsed BODY only — never merged with the query string, where a
17
+ * caller could set a field the provider did not sign.
18
+ */
19
+ body(): unknown;
20
+ };
21
+ response: InboundResponse;
22
+ }
23
+
24
+ /**
25
+ * Remembers the tokens of deliveries already handled, so a captured
26
+ * request cannot be replayed. The in-memory default covers one process;
27
+ * several instances need a shared store (Redis…) behind this interface.
28
+ */
29
+ export interface ReplayGuard {
30
+ /**
31
+ * Take the token for `ttlSeconds`. `false` when it is already taken — a
32
+ * delivery handled, or being handled right now. Must be atomic: two
33
+ * requests racing with the same token must not both get `true`.
34
+ */
35
+ claim(token: string, ttlSeconds: number): boolean | Promise<boolean>;
36
+ /** Give a token back, so a delivery whose handling failed can be retried. */
37
+ release(token: string): void | Promise<void>;
38
+ }
39
+
40
+ /** One process's tokens, each kept for its TTL, at most `capacity` of them. */
41
+ export class MemoryReplayGuard implements ReplayGuard {
42
+ readonly #capacity: number;
43
+ readonly #tokens = new Map<string, number>();
44
+
45
+ constructor(capacity = 100_000) {
46
+ this.#capacity = capacity;
47
+ }
48
+
49
+ claim(token: string, ttlSeconds: number): boolean {
50
+ // NaN would store a time no comparison is ever true of: the token
51
+ // would be claimable again at once.
52
+ if (!(Number.isFinite(ttlSeconds) && ttlSeconds > 0)) {
53
+ throw new GoldstoneError(
54
+ "E_GOLDSTONE_INVALID_OPTIONS",
55
+ `MemoryReplayGuard: the TTL must be a positive number of seconds, got ${String(ttlSeconds)}`,
56
+ );
57
+ }
58
+ const until = this.#tokens.get(token);
59
+ if (until !== undefined && until > Date.now()) return false;
60
+ this.#store(token, ttlSeconds);
61
+ return true;
62
+ }
63
+
64
+ release(token: string): void {
65
+ this.#tokens.delete(token);
66
+ }
67
+
68
+ #store(token: string, ttlSeconds: number): void {
69
+ if (this.#tokens.size >= this.#capacity) {
70
+ const now = Date.now();
71
+ for (const [key, until] of this.#tokens) {
72
+ if (until <= now) this.#tokens.delete(key);
73
+ }
74
+ // Still full: drop the oldest. Map keeps insertion order.
75
+ while (this.#tokens.size >= this.#capacity) {
76
+ const oldest = this.#tokens.keys().next();
77
+ if (oldest.done) break;
78
+ this.#tokens.delete(oldest.value);
79
+ }
80
+ }
81
+ this.#tokens.set(token, Date.now() + ttlSeconds * 1000);
82
+ }
83
+ }
84
+
85
+ /** A body field as a single string, or `undefined`. */
86
+ export function field(body: unknown, name: string): string | undefined {
87
+ if (typeof body !== "object" || body === null) return undefined;
88
+ const value: unknown = Reflect.get(body, name);
89
+ if (typeof value === "string") return value;
90
+ // A field sent twice arrives as a list: ambiguous, refused.
91
+ return undefined;
92
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Mailgun inbound: a route that forwards to the application receives every
3
+ * mail as a form POST — the raw message in `body-mime`.
4
+ *
5
+ * router.post('/inbound/mailgun/mime', createMailgunInboundHandler({
6
+ * signingKey: env.get('MAILGUN_SIGNING_KEY'),
7
+ * async onMessage(message, { recipient }) { … },
8
+ * }))
9
+ *
10
+ * Configure the Mailgun route to forward to a URL ending in `mime`: that is
11
+ * what makes Mailgun send `body-mime`, the message as received.
12
+ *
13
+ * The fields are read from `ctx.request.body()`, which holds them only once
14
+ * Ream's body parser has read the form Mailgun posts (multipart/form-data or
15
+ * urlencoded): the route must run behind `BodyParserMiddleware`, with form
16
+ * and multipart limits above the largest message accepted. Without it every
17
+ * delivery is answered 401, its fields missing.
18
+ *
19
+ * Mailgun signs `timestamp` + `token` with the webhook signing key — and
20
+ * nothing else: the signature does not cover the message. So a signature is
21
+ * only worth what its freshness and uniqueness are worth, and both are
22
+ * enforced: a timestamp older than `maxAgeSeconds` is refused, and a token is
23
+ * handled once — claimed before handling, so two requests racing with it
24
+ * cannot both get through. A delivery whose handling failed gives its token
25
+ * back, so Mailgun's retry is handled; one already handled is acknowledged
26
+ * and not handled again.
27
+ */
28
+
29
+ import { Buffer } from "node:buffer";
30
+ import { createHmac, timingSafeEqual } from "node:crypto";
31
+ import type { AuthenticateOptions } from "../authenticity.js";
32
+ import { GoldstoneError } from "../errors.js";
33
+ import {
34
+ type Limits,
35
+ type ParseOptions,
36
+ type ReceivedMessage,
37
+ readMessage,
38
+ } from "../ReceivedMessage.js";
39
+ import {
40
+ field,
41
+ type InboundHttpContext,
42
+ MemoryReplayGuard,
43
+ type ReplayGuard,
44
+ } from "./context.js";
45
+
46
+ export interface InboundMeta {
47
+ /** The SMTP envelope recipient Mailgun received it for. */
48
+ recipient?: string;
49
+ /** The SMTP envelope sender (return path). */
50
+ sender?: string;
51
+ }
52
+
53
+ export interface MailgunInboundOptions {
54
+ /** The webhook signing key (Mailgun → Settings → Webhooks). */
55
+ signingKey: string;
56
+ onMessage: (message: ReceivedMessage, meta: InboundMeta) => unknown;
57
+ /**
58
+ * A signed request older than this is refused. An integer from 1 to
59
+ * 86 400; default 120 (Mailgun's own guidance).
60
+ */
61
+ maxAgeSeconds?: number;
62
+ /** Default: tokens kept in this process's memory. */
63
+ replayGuard?: ReplayGuard;
64
+ limits?: Limits;
65
+ /** DKIM / DMARC on each message (`message.authenticity`). Default `true`. */
66
+ authenticate?: boolean | Omit<AuthenticateOptions, "ip" | "helo">;
67
+ /** Remote content in `message.html`; see {@link ParseOptions}. Default `"block"`. */
68
+ remoteContent?: ParseOptions["remoteContent"];
69
+ }
70
+
71
+ type Handler = (
72
+ ctx: InboundHttpContext,
73
+ next?: () => Promise<void>,
74
+ ) => Promise<void>;
75
+
76
+ function expectedSignature(
77
+ signingKey: string,
78
+ timestamp: string,
79
+ token: string,
80
+ ): Buffer {
81
+ return createHmac("sha256", signingKey)
82
+ .update(timestamp + token)
83
+ .digest();
84
+ }
85
+
86
+ /** Constant time; a malformed hex string is simply not equal. */
87
+ function sameSignature(expected: Buffer, given: string): boolean {
88
+ if (!/^[0-9a-f]{64}$/i.test(given)) return false;
89
+ return timingSafeEqual(expected, Buffer.from(given, "hex"));
90
+ }
91
+
92
+ /** A day: a signature older than that is not a delivery, it is a replay. */
93
+ const MAX_AGE_LIMIT = 86_400;
94
+
95
+ export function createMailgunInboundHandler(
96
+ options: MailgunInboundOptions,
97
+ ): Handler {
98
+ if (!options.signingKey) {
99
+ throw new GoldstoneError(
100
+ "E_GOLDSTONE_INVALID_CONFIG",
101
+ "createMailgunInboundHandler: signingKey is required to verify Mailgun's signature",
102
+ );
103
+ }
104
+ const { signingKey, onMessage } = options;
105
+ const maxAge = options.maxAgeSeconds ?? 120;
106
+ // The freshness window is a security switch: NaN or Infinity would turn
107
+ // the timestamp check and the replay guard off without a word.
108
+ if (!(Number.isInteger(maxAge) && maxAge >= 1 && maxAge <= MAX_AGE_LIMIT)) {
109
+ throw new GoldstoneError(
110
+ "E_GOLDSTONE_INVALID_CONFIG",
111
+ `createMailgunInboundHandler: maxAgeSeconds must be an integer from 1 to ${MAX_AGE_LIMIT}, got ${String(maxAge)}`,
112
+ );
113
+ }
114
+ const guard = options.replayGuard ?? new MemoryReplayGuard();
115
+
116
+ return async (ctx) => {
117
+ const body = ctx.request.body();
118
+ const timestamp = field(body, "timestamp");
119
+ const token = field(body, "token");
120
+ const signature = field(body, "signature");
121
+ // One answer for every way a signature can be wrong, so the response
122
+ // does not say which check refused it.
123
+ const refuse = () =>
124
+ ctx.response.status(401).json({ error: "invalid_signature" });
125
+ if (!timestamp || !token || !signature) return refuse();
126
+
127
+ const seconds = Number(timestamp);
128
+ if (
129
+ !Number.isFinite(seconds) ||
130
+ Math.abs(Date.now() / 1000 - seconds) > maxAge
131
+ ) {
132
+ return refuse();
133
+ }
134
+ if (
135
+ !sameSignature(expectedSignature(signingKey, timestamp, token), signature)
136
+ ) {
137
+ return refuse();
138
+ }
139
+
140
+ const mime = field(body, "body-mime");
141
+ if (mime === undefined) {
142
+ ctx.response.status(422).json({ error: "missing_body_mime" });
143
+ return;
144
+ }
145
+
146
+ // Twice the window: a token older than that is refused on its
147
+ // timestamp anyway. Taken before handling, so a request racing with
148
+ // the same token is acknowledged instead of handled twice.
149
+ if (!(await guard.claim(token, maxAge * 2))) {
150
+ ctx.response.status(204).json({});
151
+ return;
152
+ }
153
+ try {
154
+ await handle(body, mime, ctx);
155
+ } catch (error) {
156
+ // Not handled: give the token back so Mailgun's retry gets through.
157
+ await guard.release(token);
158
+ throw error;
159
+ }
160
+ };
161
+
162
+ async function handle(
163
+ body: unknown,
164
+ mime: string,
165
+ ctx: InboundHttpContext,
166
+ ): Promise<void> {
167
+ let message: ReceivedMessage;
168
+ try {
169
+ const authenticate =
170
+ options.authenticate === false
171
+ ? false
172
+ : {
173
+ ...(options.authenticate === true ||
174
+ options.authenticate === undefined
175
+ ? {}
176
+ : options.authenticate),
177
+ mailFrom: field(body, "sender"),
178
+ };
179
+ message = await readMessage(Buffer.from(mime, "utf8"), {
180
+ limits: options.limits,
181
+ authenticate,
182
+ remoteContent: options.remoteContent,
183
+ });
184
+ } catch (error) {
185
+ if (error instanceof GoldstoneError) {
186
+ // 406 tells Mailgun not to retry: the same bytes would be refused
187
+ // again. The token stays taken.
188
+ ctx.response.status(406).json({ error: error.code });
189
+ return;
190
+ }
191
+ throw error;
192
+ }
193
+
194
+ // A handler that throws answers 500: Mailgun retries.
195
+ await onMessage(message, {
196
+ recipient: field(body, "recipient"),
197
+ sender: field(body, "sender"),
198
+ });
199
+ ctx.response.status(204).json({});
200
+ }
201
+ }
package/src/index.ts ADDED
@@ -0,0 +1,53 @@
1
+ import "./augmentations.js";
2
+
3
+ export {
4
+ type AuthenticateOptions,
5
+ type Authenticity,
6
+ type AuthResult,
7
+ authenticate,
8
+ type DkimVerdict,
9
+ type DmarcPolicy,
10
+ } from "./authenticity.js";
11
+ export {
12
+ defineConfig,
13
+ type InboxConfig,
14
+ type MailboxConfig,
15
+ type PasswordAuth,
16
+ type TokenAuth,
17
+ } from "./config.js";
18
+ export {
19
+ fromEngine,
20
+ GoldstoneError,
21
+ GoldstoneNativeRequiredError,
22
+ } from "./errors.js";
23
+ export { InboxManager } from "./InboxManager.js";
24
+ export {
25
+ type InboundHttpContext,
26
+ type InboundResponse,
27
+ MemoryReplayGuard,
28
+ type ReplayGuard,
29
+ } from "./inbound/context.js";
30
+ export {
31
+ createMailgunInboundHandler,
32
+ type InboundMeta,
33
+ type MailgunInboundOptions,
34
+ } from "./inbound/mailgun.js";
35
+ export {
36
+ Mailbox,
37
+ type MailboxOptions,
38
+ reconnectDelay,
39
+ type SearchCriteria,
40
+ type WatchOptions,
41
+ } from "./Mailbox.js";
42
+ export { isNativeAvailable } from "./native.js";
43
+ export {
44
+ type Attachment,
45
+ type Header,
46
+ type Limits,
47
+ type Mailbox as Address,
48
+ type ParseOptions,
49
+ parseMessage,
50
+ type ReadOptions,
51
+ ReceivedMessage,
52
+ readMessage,
53
+ } from "./ReceivedMessage.js";
@@ -0,0 +1,141 @@
1
+ // GENERATED FROM THE RUST — do not edit.
2
+ //
3
+ // Produced by scripts/generate-napi-types.mjs from napi-derive's type-def
4
+ // output. Editing this file by hand puts it back where it started: a
5
+ // description that can disagree with the code it describes.
6
+
7
+ /** Bounds on one message; an absent field keeps the engine's default. */
8
+
9
+ export interface JsLimits {
10
+ maxBytes?: number;
11
+ maxParts?: number;
12
+ maxDepth?: number;
13
+ maxHeaders?: number;
14
+ maxHeaderBytes?: number;
15
+ maxAttachments?: number;
16
+ maxAttachmentBytes?: number;
17
+ maxHtmlBytes?: number;
18
+ }
19
+
20
+ /** What the sanitized HTML may load by itself. */
21
+
22
+ export interface JsHtmlOptions {
23
+ /**
24
+ * Keep remote images and other URLs a browser loads unasked. Default
25
+ * false.
26
+ */
27
+ remoteContent?: boolean;
28
+ }
29
+
30
+ export interface JsMailbox {
31
+ name?: string;
32
+ address?: string;
33
+ }
34
+
35
+ export interface JsHeader {
36
+ name: string;
37
+ value: string;
38
+ }
39
+
40
+ export interface JsAttachment {
41
+ fileName: string;
42
+ originalName?: string;
43
+ contentType: string;
44
+ content: Buffer;
45
+ contentId?: string;
46
+ inline: boolean;
47
+ }
48
+
49
+ export interface JsMessage {
50
+ messageId?: string;
51
+ subject?: string;
52
+ date?: string;
53
+ from: Array<JsMailbox>;
54
+ to: Array<JsMailbox>;
55
+ cc: Array<JsMailbox>;
56
+ bcc: Array<JsMailbox>;
57
+ replyTo: Array<JsMailbox>;
58
+ inReplyTo: Array<string>;
59
+ references: Array<string>;
60
+ headers: Array<JsHeader>;
61
+ text?: string;
62
+ html?: string;
63
+ /** `E_GOLDSTONE_HTML_*: …` when the HTML body was not sanitized. */
64
+ htmlError?: string;
65
+ unsanitizedHtml?: string;
66
+ attachments: Array<JsAttachment>;
67
+ }
68
+
69
+ /** The envelope SPF checks, and the bounds on the whole check. */
70
+
71
+ export interface JsAuthOptions {
72
+ ip?: string;
73
+ helo?: string;
74
+ mailFrom?: string;
75
+ maxDkimSignatures?: number;
76
+ timeoutMs?: number;
77
+ maxBytes?: number;
78
+ }
79
+
80
+ export interface JsDkimVerdict {
81
+ result: string;
82
+ domain?: string;
83
+ selector?: string;
84
+ reason?: string;
85
+ }
86
+
87
+ export interface JsVerdict {
88
+ dkim: Array<JsDkimVerdict>;
89
+ spf: string;
90
+ dmarc: string;
91
+ dmarcPolicy: string;
92
+ dmarcDomain?: string;
93
+ fromDomain?: string;
94
+ arc: string;
95
+ summary: string;
96
+ }
97
+
98
+ export interface JsReadMessage {
99
+ message: JsMessage;
100
+ authenticity: JsVerdict;
101
+ }
102
+
103
+ /**
104
+ * Parse one received message, off the JavaScript thread: parsing and
105
+ * sanitizing are CPU work on bytes a stranger sent. Rejects with
106
+ * `E_GOLDSTONE_*: …` on a message that breaks a limit or is not a message.
107
+ *
108
+ * `raw`, here and in the two calls below, is read on a runtime thread while
109
+ * JavaScript runs on: it must be memory nothing writes to meanwhile, which
110
+ * the TypeScript side ensures by handing a copy (`snapshot` in native.ts).
111
+ * A copy made here would be made on the runtime thread, too late.
112
+ */
113
+
114
+ export declare function parseMessage(
115
+ raw: Buffer,
116
+ limits?: JsLimits | undefined | null,
117
+ html?: JsHtmlOptions | undefined | null,
118
+ ): Promise<JsMessage>;
119
+
120
+ /**
121
+ * DKIM, SPF (when the envelope is given), DMARC and ARC for one received
122
+ * message. Resolves with `temperror` verdicts when DNS cannot answer in
123
+ * time — never with a pass.
124
+ */
125
+
126
+ export declare function authenticateMessage(
127
+ raw: Buffer,
128
+ options?: JsAuthOptions | undefined | null,
129
+ ): Promise<JsVerdict>;
130
+
131
+ /**
132
+ * Parse one received message and check who sent it, from the same parse:
133
+ * the From the verdict speaks for is the From the message shows.
134
+ */
135
+
136
+ export declare function readMessage(
137
+ raw: Buffer,
138
+ limits?: JsLimits | undefined | null,
139
+ options?: JsAuthOptions | undefined | null,
140
+ html?: JsHtmlOptions | undefined | null,
141
+ ): Promise<JsReadMessage>;
package/src/native.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Loader for the Rust mail engine.
3
+ *
4
+ * The engine is not optional: received mail is hostile input, and its parser
5
+ * is the one place this package will not fall back to JavaScript. A missing
6
+ * binary is a hard failure with an actionable message.
7
+ */
8
+
9
+ import { GoldstoneNativeRequiredError } from "./errors.js";
10
+ import {
11
+ hasFunctions,
12
+ loadNativeBinary,
13
+ unavailableReason as vendorUnavailableReason,
14
+ } from "./vendor/nativeBinary.js";
15
+
16
+ /** What this package calls, as the Rust declares it (`pnpm build:napi-types`). */
17
+ const NATIVE_CALLS = [
18
+ "parseMessage",
19
+ "readMessage",
20
+ "authenticateMessage",
21
+ ] as const;
22
+
23
+ type NativeGoldstone = Pick<
24
+ typeof import("./native/generated.js"),
25
+ (typeof NATIVE_CALLS)[number]
26
+ >;
27
+
28
+ /** The same three calls, checked at load: a stale binary loads just as well. */
29
+ function isNativeGoldstone(value: unknown): value is NativeGoldstone {
30
+ return hasFunctions(value, NATIVE_CALLS);
31
+ }
32
+
33
+ const attempt = loadNativeBinary(isNativeGoldstone, NATIVE_CALLS);
34
+ const native = attempt.loaded ? attempt.binary : undefined;
35
+ const loadError = attempt.loaded ? undefined : attempt.cause;
36
+
37
+ export function isNativeAvailable(): boolean {
38
+ return native !== undefined;
39
+ }
40
+
41
+ /**
42
+ * The bytes handed to the engine: a copy, taken here on the JavaScript
43
+ * thread. The engine reads them later, off this thread, more than once (the
44
+ * walk that bounds the structure, then the parse); on a view of the caller's
45
+ * memory, a buffer reused or written meanwhile would make it bound one
46
+ * message and parse another.
47
+ */
48
+ export function snapshot(raw: Uint8Array): Buffer {
49
+ return Buffer.from(raw);
50
+ }
51
+
52
+ /** The engine, or a refusal explaining how to get one. */
53
+ export function engine(): NativeGoldstone {
54
+ if (native === undefined) {
55
+ throw new GoldstoneNativeRequiredError(
56
+ loadError === undefined
57
+ ? vendorUnavailableReason()
58
+ : `failed to load (${loadError instanceof Error ? loadError.message : String(loadError)})`,
59
+ );
60
+ }
61
+ return native;
62
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The {@link InboxManager}, reachable without threading it through every
3
+ * call site — mirroring `@c9up/quasar/services/main`.
4
+ *
5
+ * import inbox from "@c9up/goldstone/services/main"
6
+ *
7
+ * const unread = await inbox.mailbox().search({ seen: false })
8
+ *
9
+ * Importing this module loads nothing: the instance is resolved on the first
10
+ * member access, so a unit test that pulls it in transitively needs no booted
11
+ * provider.
12
+ */
13
+
14
+ import type { InboxManager } from "../InboxManager.js";
15
+
16
+ let instance: InboxManager | undefined;
17
+
18
+ /** Seat the instance — called by GoldstoneProvider, or by an app wiring its own. */
19
+ export function setInbox(inbox: InboxManager): void {
20
+ instance = inbox;
21
+ }
22
+
23
+ /** The seated instance, if there is one. */
24
+ export function getInbox(): InboxManager | undefined {
25
+ return instance;
26
+ }
27
+
28
+ /** Drop the instance IF it is still the one passed in (ownership guard). */
29
+ export function clearInbox(inbox: InboxManager): void {
30
+ if (instance === inbox) instance = undefined;
31
+ }
32
+
33
+ const inbox: InboxManager = new Proxy(Object.create(null), {
34
+ get(_target, property) {
35
+ // A module loader reads `then` and various symbols off what it imports
36
+ // before anyone uses it. They are not members, so answer undefined and
37
+ // let a genuine access be the one that reports.
38
+ if (typeof property === "symbol" || property === "then") {
39
+ return undefined;
40
+ }
41
+ if (!instance) {
42
+ throw new Error(
43
+ "[inbox] accessed before initialization — register GoldstoneProvider, or call setInbox() yourself.",
44
+ );
45
+ }
46
+ const value = Reflect.get(instance, property, instance);
47
+ return typeof value === "function" ? value.bind(instance) : value;
48
+ },
49
+ });
50
+
51
+ export default inbox;