@secondlayer/shared 11.15.3 → 11.16.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.
- package/dist/src/email/index.d.ts +47 -0
- package/dist/src/email/index.js +230 -0
- package/dist/src/email/index.js.map +13 -0
- package/package.json +5 -1
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one HTML/text layout every customer email renders through. Table-based
|
|
3
|
+
* HTML, inline styles only (email clients strip <style>), 560px max width,
|
|
4
|
+
* white background, #111 text, one black button — same look as the login
|
|
5
|
+
* email this was lifted from. Text is the same content, plainer.
|
|
6
|
+
*/
|
|
7
|
+
interface FactRow {
|
|
8
|
+
label: string;
|
|
9
|
+
value: string;
|
|
10
|
+
}
|
|
11
|
+
interface EmailCta {
|
|
12
|
+
label: string;
|
|
13
|
+
url: string;
|
|
14
|
+
}
|
|
15
|
+
interface RenderEmailInput {
|
|
16
|
+
heading: string;
|
|
17
|
+
paragraphs: string[];
|
|
18
|
+
/** A code shown in a large, letter-spaced box (login codes). */
|
|
19
|
+
code?: string;
|
|
20
|
+
facts?: FactRow[];
|
|
21
|
+
cta?: EmailCta;
|
|
22
|
+
footnote?: string;
|
|
23
|
+
}
|
|
24
|
+
interface RenderedEmail {
|
|
25
|
+
html: string;
|
|
26
|
+
text: string;
|
|
27
|
+
}
|
|
28
|
+
declare function renderEmail(input: RenderEmailInput): RenderedEmail;
|
|
29
|
+
/**
|
|
30
|
+
* The one Resend call every customer email goes through. Never throws when
|
|
31
|
+
* `RESEND_API_KEY` is unset — that's a dev/CI/self-host state, not a send
|
|
32
|
+
* failure — it logs a warning and reports `{ skipped: true }` so a caller
|
|
33
|
+
* can still record its own "notification sent" bookkeeping accurately.
|
|
34
|
+
*/
|
|
35
|
+
interface SendEmailInput {
|
|
36
|
+
to: string;
|
|
37
|
+
subject: string;
|
|
38
|
+
html: string;
|
|
39
|
+
text: string;
|
|
40
|
+
}
|
|
41
|
+
type SendEmailResult = {
|
|
42
|
+
skipped: true
|
|
43
|
+
} | {
|
|
44
|
+
skipped: false
|
|
45
|
+
};
|
|
46
|
+
declare function sendEmail(input: SendEmailInput): Promise<SendEmailResult>;
|
|
47
|
+
export { EmailCta, FactRow, RenderEmailInput, RenderedEmail, SendEmailInput, SendEmailResult, renderEmail, sendEmail };
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// src/email/render.ts
|
|
2
|
+
function escapeHtml(value) {
|
|
3
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
4
|
+
}
|
|
5
|
+
function renderHtml(input) {
|
|
6
|
+
const paragraphsHtml = input.paragraphs.map((p) => `<p style="color: #555; font-size: 14px; line-height: 1.6; margin: 0 0 16px;">${escapeHtml(p)}</p>`).join(`
|
|
7
|
+
`);
|
|
8
|
+
const codeHtml = input.code ? `<div style="background: #f5f5f5; border: 1px solid #e5e5e5; border-radius: 8px; padding: 20px; text-align: center; margin: 0 0 24px;">
|
|
9
|
+
<span style="font-size: 32px; font-weight: 600; letter-spacing: 8px; color: #111;">${escapeHtml(input.code)}</span>
|
|
10
|
+
</div>` : "";
|
|
11
|
+
const factsHtml = input.facts?.length ? `<table role="presentation" cellpadding="0" cellspacing="0" style="width: 100%; margin: 0 0 24px; border-collapse: collapse;">
|
|
12
|
+
${input.facts.map((fact) => `<tr><td style="padding: 6px 0; color: #888; font-size: 13px;">${escapeHtml(fact.label)}</td><td style="padding: 6px 0; color: #111; font-size: 13px; text-align: right; font-weight: 600;">${escapeHtml(fact.value)}</td></tr>`).join(`
|
|
13
|
+
`)}
|
|
14
|
+
</table>` : "";
|
|
15
|
+
const ctaHtml = input.cta ? `<a href="${escapeHtml(input.cta.url)}" style="display: inline-block; background: #111; color: #fff; text-decoration: none; padding: 10px 20px; border-radius: 6px; font-size: 14px; margin: 0 0 24px;">${escapeHtml(input.cta.label)}</a>` : "";
|
|
16
|
+
const footnoteHtml = input.footnote ? `<p style="color: #aaa; font-size: 12px; margin: 24px 0 0;">${escapeHtml(input.footnote)}</p>` : "";
|
|
17
|
+
return `
|
|
18
|
+
<table role="presentation" cellpadding="0" cellspacing="0" style="width: 100%; background: #fff;">
|
|
19
|
+
<tr>
|
|
20
|
+
<td align="center">
|
|
21
|
+
<table role="presentation" cellpadding="0" cellspacing="0" style="width: 100%; max-width: 560px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 40px 20px;">
|
|
22
|
+
<tr>
|
|
23
|
+
<td>
|
|
24
|
+
<p style="color: #888; font-size: 14px; margin: 0 0 24px;">secondlayer</p>
|
|
25
|
+
<p style="color: #111; font-size: 18px; font-weight: 600; margin: 0 0 16px;">${escapeHtml(input.heading)}</p>
|
|
26
|
+
${paragraphsHtml}
|
|
27
|
+
${codeHtml}
|
|
28
|
+
${factsHtml}
|
|
29
|
+
${ctaHtml}
|
|
30
|
+
${footnoteHtml}
|
|
31
|
+
</td>
|
|
32
|
+
</tr>
|
|
33
|
+
</table>
|
|
34
|
+
</td>
|
|
35
|
+
</tr>
|
|
36
|
+
</table>`.trim();
|
|
37
|
+
}
|
|
38
|
+
function renderText(input) {
|
|
39
|
+
const lines = [input.heading, ""];
|
|
40
|
+
for (const p of input.paragraphs)
|
|
41
|
+
lines.push(p, "");
|
|
42
|
+
if (input.code) {
|
|
43
|
+
lines.push(`Code: ${input.code}`, "");
|
|
44
|
+
}
|
|
45
|
+
if (input.facts?.length) {
|
|
46
|
+
for (const fact of input.facts)
|
|
47
|
+
lines.push(`${fact.label}: ${fact.value}`);
|
|
48
|
+
lines.push("");
|
|
49
|
+
}
|
|
50
|
+
if (input.cta) {
|
|
51
|
+
lines.push(`${input.cta.label}: ${input.cta.url}`, "");
|
|
52
|
+
}
|
|
53
|
+
if (input.footnote) {
|
|
54
|
+
lines.push(input.footnote);
|
|
55
|
+
}
|
|
56
|
+
while (lines.length && lines[lines.length - 1] === "")
|
|
57
|
+
lines.pop();
|
|
58
|
+
return lines.join(`
|
|
59
|
+
`);
|
|
60
|
+
}
|
|
61
|
+
function renderEmail(input) {
|
|
62
|
+
return { html: renderHtml(input), text: renderText(input) };
|
|
63
|
+
}
|
|
64
|
+
// src/env.ts
|
|
65
|
+
import { z } from "zod";
|
|
66
|
+
var networksSchema = z.string().transform((val) => {
|
|
67
|
+
const networks = val.split(",").map((n) => n.trim()).filter(Boolean);
|
|
68
|
+
const valid = ["mainnet", "testnet"];
|
|
69
|
+
for (const n of networks) {
|
|
70
|
+
if (!valid.includes(n)) {
|
|
71
|
+
throw new Error(`Invalid network: ${n}. Must be one of: ${valid.join(", ")}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return networks;
|
|
75
|
+
});
|
|
76
|
+
var envSchema = z.object({
|
|
77
|
+
DATABASE_URL: z.preprocess((val) => typeof val === "string" && val.length === 0 ? undefined : val, z.string().url().optional()),
|
|
78
|
+
SOURCE_DATABASE_URL: z.preprocess((val) => typeof val === "string" && val.length === 0 ? undefined : val, z.string().url().optional()),
|
|
79
|
+
TARGET_DATABASE_URL: z.preprocess((val) => typeof val === "string" && val.length === 0 ? undefined : val, z.string().url().optional()),
|
|
80
|
+
NETWORK: z.enum(["mainnet", "testnet"]).optional(),
|
|
81
|
+
NETWORKS: networksSchema.optional(),
|
|
82
|
+
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
|
|
83
|
+
NODE_ENV: z.enum(["development", "production", "test"]).default("development")
|
|
84
|
+
});
|
|
85
|
+
var cachedEnv = null;
|
|
86
|
+
function getEnv() {
|
|
87
|
+
if (cachedEnv) {
|
|
88
|
+
return cachedEnv;
|
|
89
|
+
}
|
|
90
|
+
const result = envSchema.safeParse(process.env);
|
|
91
|
+
if (!result.success) {
|
|
92
|
+
console.error("❌ Invalid environment configuration:");
|
|
93
|
+
console.error(z.treeifyError(result.error));
|
|
94
|
+
throw new Error("Invalid environment configuration");
|
|
95
|
+
}
|
|
96
|
+
let enabledNetworks;
|
|
97
|
+
if (result.data.NETWORKS && result.data.NETWORKS.length > 0) {
|
|
98
|
+
enabledNetworks = result.data.NETWORKS;
|
|
99
|
+
} else if (result.data.NETWORK) {
|
|
100
|
+
enabledNetworks = [result.data.NETWORK];
|
|
101
|
+
} else {
|
|
102
|
+
enabledNetworks = ["mainnet"];
|
|
103
|
+
}
|
|
104
|
+
cachedEnv = { ...result.data, enabledNetworks };
|
|
105
|
+
return cachedEnv;
|
|
106
|
+
}
|
|
107
|
+
function isProductionEnv() {
|
|
108
|
+
return process.env["NODE_ENV"] === "production";
|
|
109
|
+
}
|
|
110
|
+
function isPox4DecoderEnabled(env = process.env) {
|
|
111
|
+
return env.POX4_DECODER_ENABLED !== "false";
|
|
112
|
+
}
|
|
113
|
+
function isPox5DecoderEnabled(env = process.env) {
|
|
114
|
+
return env.POX5_DECODER_ENABLED !== "false";
|
|
115
|
+
}
|
|
116
|
+
function isSbtcDecoderEnabled(env = process.env) {
|
|
117
|
+
return env.SBTC_DECODER_ENABLED !== "false";
|
|
118
|
+
}
|
|
119
|
+
function isBnsDecoderEnabled(env = process.env) {
|
|
120
|
+
return env.BNS_DECODER_ENABLED === "true";
|
|
121
|
+
}
|
|
122
|
+
// src/logger.ts
|
|
123
|
+
var LOG_LEVELS = {
|
|
124
|
+
debug: 0,
|
|
125
|
+
info: 1,
|
|
126
|
+
warn: 2,
|
|
127
|
+
error: 3
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
class Logger {
|
|
131
|
+
_level;
|
|
132
|
+
_isProduction;
|
|
133
|
+
_initialized = false;
|
|
134
|
+
init() {
|
|
135
|
+
if (this._initialized)
|
|
136
|
+
return;
|
|
137
|
+
this._initialized = true;
|
|
138
|
+
try {
|
|
139
|
+
const env = getEnv();
|
|
140
|
+
this._level = env.LOG_LEVEL;
|
|
141
|
+
this._isProduction = env.NODE_ENV === "production";
|
|
142
|
+
} catch {
|
|
143
|
+
this._level = "info";
|
|
144
|
+
this._isProduction = false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
get level() {
|
|
148
|
+
this.init();
|
|
149
|
+
return this._level;
|
|
150
|
+
}
|
|
151
|
+
get isProduction() {
|
|
152
|
+
this.init();
|
|
153
|
+
return this._isProduction;
|
|
154
|
+
}
|
|
155
|
+
shouldLog(level) {
|
|
156
|
+
return LOG_LEVELS[level] >= LOG_LEVELS[this.level];
|
|
157
|
+
}
|
|
158
|
+
formatMessage(level, message, meta) {
|
|
159
|
+
const timestamp = new Date().toISOString();
|
|
160
|
+
if (this.isProduction) {
|
|
161
|
+
return JSON.stringify({
|
|
162
|
+
timestamp,
|
|
163
|
+
level,
|
|
164
|
+
message,
|
|
165
|
+
...meta
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
const metaStr = meta ? ` ${JSON.stringify(meta)}` : "";
|
|
169
|
+
return `[${timestamp}] ${level.toUpperCase()}: ${message}${metaStr}`;
|
|
170
|
+
}
|
|
171
|
+
debug(message, meta) {
|
|
172
|
+
if (this.shouldLog("debug")) {
|
|
173
|
+
console.debug(this.formatMessage("debug", message, meta));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
info(message, meta) {
|
|
177
|
+
if (this.shouldLog("info")) {
|
|
178
|
+
console.info(this.formatMessage("info", message, meta));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
warn(message, meta) {
|
|
182
|
+
if (this.shouldLog("warn")) {
|
|
183
|
+
console.warn(this.formatMessage("warn", message, meta));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
error(message, meta) {
|
|
187
|
+
if (this.shouldLog("error")) {
|
|
188
|
+
console.error(this.formatMessage("error", message, meta));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
var logger = new Logger;
|
|
193
|
+
|
|
194
|
+
// src/email/send.ts
|
|
195
|
+
async function sendEmail(input) {
|
|
196
|
+
const apiKey = process.env.RESEND_API_KEY;
|
|
197
|
+
if (!apiKey) {
|
|
198
|
+
logger.warn("RESEND_API_KEY unset — skipping email", {
|
|
199
|
+
subject: input.subject
|
|
200
|
+
});
|
|
201
|
+
return { skipped: true };
|
|
202
|
+
}
|
|
203
|
+
const from = process.env.EMAIL_FROM ?? "secondlayer <noreply@secondlayer.tools>";
|
|
204
|
+
const response = await fetch("https://api.resend.com/emails", {
|
|
205
|
+
method: "POST",
|
|
206
|
+
headers: {
|
|
207
|
+
"Content-Type": "application/json",
|
|
208
|
+
Authorization: `Bearer ${apiKey}`
|
|
209
|
+
},
|
|
210
|
+
body: JSON.stringify({
|
|
211
|
+
from,
|
|
212
|
+
to: [input.to],
|
|
213
|
+
subject: input.subject,
|
|
214
|
+
html: input.html,
|
|
215
|
+
text: input.text
|
|
216
|
+
})
|
|
217
|
+
});
|
|
218
|
+
if (!response.ok) {
|
|
219
|
+
const body = await response.text().catch(() => "");
|
|
220
|
+
throw new Error(`Resend API error (${response.status}): ${body.slice(0, 200)}`);
|
|
221
|
+
}
|
|
222
|
+
return { skipped: false };
|
|
223
|
+
}
|
|
224
|
+
export {
|
|
225
|
+
renderEmail,
|
|
226
|
+
sendEmail
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
//# debugId=E2163B20AB81801464756E2164756E21
|
|
230
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/email/render.ts", "../src/env.ts", "../src/logger.ts", "../src/email/send.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"/**\n * The one HTML/text layout every customer email renders through. Table-based\n * HTML, inline styles only (email clients strip <style>), 560px max width,\n * white background, #111 text, one black button — same look as the login\n * email this was lifted from. Text is the same content, plainer.\n */\n\nexport interface FactRow {\n\tlabel: string;\n\tvalue: string;\n}\n\nexport interface EmailCta {\n\tlabel: string;\n\turl: string;\n}\n\nexport interface RenderEmailInput {\n\theading: string;\n\tparagraphs: string[];\n\t/** A code shown in a large, letter-spaced box (login codes). */\n\tcode?: string;\n\tfacts?: FactRow[];\n\tcta?: EmailCta;\n\tfootnote?: string;\n}\n\nexport interface RenderedEmail {\n\thtml: string;\n\ttext: string;\n}\n\nfunction escapeHtml(value: string): string {\n\treturn value\n\t\t.replace(/&/g, \"&\")\n\t\t.replace(/</g, \"<\")\n\t\t.replace(/>/g, \">\")\n\t\t.replace(/\"/g, \""\")\n\t\t.replace(/'/g, \"'\");\n}\n\nfunction renderHtml(input: RenderEmailInput): string {\n\tconst paragraphsHtml = input.paragraphs\n\t\t.map(\n\t\t\t(p) =>\n\t\t\t\t`<p style=\"color: #555; font-size: 14px; line-height: 1.6; margin: 0 0 16px;\">${escapeHtml(p)}</p>`,\n\t\t)\n\t\t.join(\"\\n \");\n\n\tconst codeHtml = input.code\n\t\t? `<div style=\"background: #f5f5f5; border: 1px solid #e5e5e5; border-radius: 8px; padding: 20px; text-align: center; margin: 0 0 24px;\">\n <span style=\"font-size: 32px; font-weight: 600; letter-spacing: 8px; color: #111;\">${escapeHtml(input.code)}</span>\n </div>`\n\t\t: \"\";\n\n\tconst factsHtml = input.facts?.length\n\t\t? `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" style=\"width: 100%; margin: 0 0 24px; border-collapse: collapse;\">\n ${input.facts\n\t\t\t.map(\n\t\t\t\t(fact) =>\n\t\t\t\t\t`<tr><td style=\"padding: 6px 0; color: #888; font-size: 13px;\">${escapeHtml(fact.label)}</td><td style=\"padding: 6px 0; color: #111; font-size: 13px; text-align: right; font-weight: 600;\">${escapeHtml(fact.value)}</td></tr>`,\n\t\t\t)\n\t\t\t.join(\"\\n \")}\n </table>`\n\t\t: \"\";\n\n\tconst ctaHtml = input.cta\n\t\t? `<a href=\"${escapeHtml(input.cta.url)}\" style=\"display: inline-block; background: #111; color: #fff; text-decoration: none; padding: 10px 20px; border-radius: 6px; font-size: 14px; margin: 0 0 24px;\">${escapeHtml(input.cta.label)}</a>`\n\t\t: \"\";\n\n\tconst footnoteHtml = input.footnote\n\t\t? `<p style=\"color: #aaa; font-size: 12px; margin: 24px 0 0;\">${escapeHtml(input.footnote)}</p>`\n\t\t: \"\";\n\n\treturn `\n<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" style=\"width: 100%; background: #fff;\">\n <tr>\n <td align=\"center\">\n <table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" style=\"width: 100%; max-width: 560px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 40px 20px;\">\n <tr>\n <td>\n <p style=\"color: #888; font-size: 14px; margin: 0 0 24px;\">secondlayer</p>\n <p style=\"color: #111; font-size: 18px; font-weight: 600; margin: 0 0 16px;\">${escapeHtml(input.heading)}</p>\n ${paragraphsHtml}\n ${codeHtml}\n ${factsHtml}\n ${ctaHtml}\n ${footnoteHtml}\n </td>\n </tr>\n </table>\n </td>\n </tr>\n</table>`.trim();\n}\n\nfunction renderText(input: RenderEmailInput): string {\n\tconst lines: string[] = [input.heading, \"\"];\n\tfor (const p of input.paragraphs) lines.push(p, \"\");\n\tif (input.code) {\n\t\tlines.push(`Code: ${input.code}`, \"\");\n\t}\n\tif (input.facts?.length) {\n\t\tfor (const fact of input.facts) lines.push(`${fact.label}: ${fact.value}`);\n\t\tlines.push(\"\");\n\t}\n\tif (input.cta) {\n\t\tlines.push(`${input.cta.label}: ${input.cta.url}`, \"\");\n\t}\n\tif (input.footnote) {\n\t\tlines.push(input.footnote);\n\t}\n\t// Trim trailing blank lines.\n\twhile (lines.length && lines[lines.length - 1] === \"\") lines.pop();\n\treturn lines.join(\"\\n\");\n}\n\nexport function renderEmail(input: RenderEmailInput): RenderedEmail {\n\treturn { html: renderHtml(input), text: renderText(input) };\n}\n",
|
|
6
|
+
"import { z } from \"zod\";\n\n// Parse comma-separated networks\nconst networksSchema = z.string().transform((val) => {\n\tconst networks = val\n\t\t.split(\",\")\n\t\t.map((n) => n.trim())\n\t\t.filter(Boolean);\n\tconst valid = [\"mainnet\", \"testnet\"];\n\tfor (const n of networks) {\n\t\tif (!valid.includes(n)) {\n\t\t\tthrow new Error(\n\t\t\t\t`Invalid network: ${n}. Must be one of: ${valid.join(\", \")}`,\n\t\t\t);\n\t\t}\n\t}\n\treturn networks as (\"mainnet\" | \"testnet\")[];\n});\n\ninterface EnvSchemaOutput {\n\tDATABASE_URL?: string;\n\t/**\n\t * Shared indexer DB (blocks/txs/events). Falls back to DATABASE_URL.\n\t * Set this alongside TARGET_DATABASE_URL to enable dual-DB mode.\n\t */\n\tSOURCE_DATABASE_URL?: string;\n\t/**\n\t * Tenant DB (subgraph schemas + subgraphs table). Falls back to DATABASE_URL.\n\t * Set this alongside SOURCE_DATABASE_URL to enable dual-DB mode.\n\t */\n\tTARGET_DATABASE_URL?: string;\n\tNETWORK?: \"mainnet\" | \"testnet\";\n\tNETWORKS?: (\"mainnet\" | \"testnet\")[];\n\tLOG_LEVEL: \"debug\" | \"info\" | \"warn\" | \"error\";\n\tNODE_ENV: \"development\" | \"production\" | \"test\";\n}\n\n// Cast needed: z.preprocess / z.default create different _input vs _output types\n// that z.ZodType<T> can't represent without explicit input type param\nconst envSchema: z.ZodType<EnvSchemaOutput> = z.object({\n\tDATABASE_URL: z.preprocess(\n\t\t(val) => (typeof val === \"string\" && val.length === 0 ? undefined : val),\n\t\tz.string().url().optional(),\n\t),\n\tSOURCE_DATABASE_URL: z.preprocess(\n\t\t(val) => (typeof val === \"string\" && val.length === 0 ? undefined : val),\n\t\tz.string().url().optional(),\n\t),\n\tTARGET_DATABASE_URL: z.preprocess(\n\t\t(val) => (typeof val === \"string\" && val.length === 0 ? undefined : val),\n\t\tz.string().url().optional(),\n\t),\n\tNETWORK: z.enum([\"mainnet\", \"testnet\"]).optional(),\n\tNETWORKS: networksSchema.optional(),\n\tLOG_LEVEL: z.enum([\"debug\", \"info\", \"warn\", \"error\"]).default(\"info\"),\n\tNODE_ENV: z\n\t\t.enum([\"development\", \"production\", \"test\"])\n\t\t.default(\"development\"),\n}) as unknown as z.ZodType<EnvSchemaOutput>;\n\nexport type Env = EnvSchemaOutput & {\n\tenabledNetworks: (\"mainnet\" | \"testnet\")[];\n};\n\nlet cachedEnv: Env | null = null;\n\nexport function getEnv(): Env {\n\tif (cachedEnv) {\n\t\treturn cachedEnv;\n\t}\n\n\tconst result = envSchema.safeParse(process.env);\n\n\tif (!result.success) {\n\t\tconsole.error(\"❌ Invalid environment configuration:\");\n\t\tconsole.error(z.treeifyError(result.error));\n\t\tthrow new Error(\"Invalid environment configuration\");\n\t}\n\n\t// Compute enabled networks from NETWORKS or NETWORK\n\tlet enabledNetworks: (\"mainnet\" | \"testnet\")[];\n\tif (result.data.NETWORKS && result.data.NETWORKS.length > 0) {\n\t\tenabledNetworks = result.data.NETWORKS;\n\t} else if (result.data.NETWORK) {\n\t\tenabledNetworks = [result.data.NETWORK];\n\t} else {\n\t\tenabledNetworks = [\"mainnet\"]; // Default\n\t}\n\n\tcachedEnv = { ...result.data, enabledNetworks };\n\treturn cachedEnv;\n}\n\n/**\n * True when `NODE_ENV=production`, read at RUNTIME — the single source of truth\n * for prod-vs-dev branching in this package.\n *\n * Why a helper instead of inlining `process.env.NODE_ENV === \"production\"`:\n * bunup/esbuild constant-folds the dot-access `process.env.NODE_ENV` to its\n * BUILD-time value. `@secondlayer/shared` is consumed by other services as its\n * built `dist`, so an inlined check freezes to a literal in the shipped bundle\n * (e.g. `const isProd = false`) and silently ignores the container's real\n * NODE_ENV. The bracket access below is NOT folded, so it stays a runtime read.\n * Route every prod check through here so the footgun lives in exactly one place.\n */\nexport function isProductionEnv(): boolean {\n\t// biome-ignore lint/complexity/useLiteralKeys: bracket access is deliberate — dot-access gets constant-folded by the bundler, freezing this to a build-time literal in the shipped dist.\n\treturn process.env[\"NODE_ENV\"] === \"production\";\n}\n\ntype EnvMap = NodeJS.ProcessEnv;\n\n/**\n * PoX-4 stacking decoder is ON by default — `/v1/index/stacking` is part of the\n * public surface, so the decoder that fills `pox4_calls` runs unless explicitly\n * opted out with `POX4_DECODER_ENABLED=false` (mirrors the sBTC decoder policy).\n */\nexport function isPox4DecoderEnabled(env: EnvMap = process.env): boolean {\n\treturn env.POX4_DECODER_ENABLED !== \"false\";\n}\n\n/**\n * PoX-5 print-event decoder is ON by default (mirrors pox-4/sBTC policy).\n * No activation gating needed: pox-5 events cannot exist before the Epoch 4.0\n * hard fork (Bitcoin block 960,230), so pre-activation the decoder idles at\n * tip. Opt out with `POX5_DECODER_ENABLED=false`.\n */\nexport function isPox5DecoderEnabled(env: EnvMap = process.env): boolean {\n\treturn env.POX5_DECODER_ENABLED !== \"false\";\n}\n\n/** Default ON. Opt out with `SBTC_DECODER_ENABLED=false`. */\nexport function isSbtcDecoderEnabled(env: EnvMap = process.env): boolean {\n\treturn env.SBTC_DECODER_ENABLED !== \"false\";\n}\n\n/** Default OFF. Opt in with `BNS_DECODER_ENABLED=true`. */\nexport function isBnsDecoderEnabled(env: EnvMap = process.env): boolean {\n\treturn env.BNS_DECODER_ENABLED === \"true\";\n}\n\n// Export for testing\nexport { envSchema };\n",
|
|
7
|
+
"import { getEnv } from \"./env.ts\";\n\ntype LogLevel = \"debug\" | \"info\" | \"warn\" | \"error\";\n\nconst LOG_LEVELS: Record<LogLevel, number> = {\n\tdebug: 0,\n\tinfo: 1,\n\twarn: 2,\n\terror: 3,\n};\n\nclass Logger {\n\tprivate _level?: LogLevel;\n\tprivate _isProduction?: boolean;\n\tprivate _initialized = false;\n\n\tprivate init() {\n\t\tif (this._initialized) return;\n\t\tthis._initialized = true;\n\t\ttry {\n\t\t\tconst env = getEnv();\n\t\t\tthis._level = env.LOG_LEVEL;\n\t\t\tthis._isProduction = env.NODE_ENV === \"production\";\n\t\t} catch {\n\t\t\t// Fallback when env is unavailable (e.g. tests without DATABASE_URL)\n\t\t\tthis._level = \"info\";\n\t\t\tthis._isProduction = false;\n\t\t}\n\t}\n\n\tprivate get level(): LogLevel {\n\t\tthis.init();\n\t\t// biome-ignore lint/style/noNonNullAssertion: value is non-null after preceding check or by construction; TS narrowing limitation\n\t\treturn this._level!;\n\t}\n\n\tprivate get isProduction(): boolean {\n\t\tthis.init();\n\t\t// biome-ignore lint/style/noNonNullAssertion: value is non-null after preceding check or by construction; TS narrowing limitation\n\t\treturn this._isProduction!;\n\t}\n\n\tprivate shouldLog(level: LogLevel): boolean {\n\t\treturn LOG_LEVELS[level] >= LOG_LEVELS[this.level];\n\t}\n\n\tprivate formatMessage(\n\t\tlevel: LogLevel,\n\t\tmessage: string,\n\t\t// biome-ignore lint/suspicious/noExplicitAny: interop boundary or dynamic-shape value where typing adds friction without runtime safety\n\t\tmeta?: Record<string, any>,\n\t) {\n\t\tconst timestamp = new Date().toISOString();\n\n\t\tif (this.isProduction) {\n\t\t\t// JSON output for production\n\t\t\treturn JSON.stringify({\n\t\t\t\ttimestamp,\n\t\t\t\tlevel,\n\t\t\t\tmessage,\n\t\t\t\t...meta,\n\t\t\t});\n\t\t}\n\n\t\t// Human-readable output for development\n\t\tconst metaStr = meta ? ` ${JSON.stringify(meta)}` : \"\";\n\t\treturn `[${timestamp}] ${level.toUpperCase()}: ${message}${metaStr}`;\n\t}\n\n\t// biome-ignore lint/suspicious/noExplicitAny: interop boundary or dynamic-shape value where typing adds friction without runtime safety\n\tdebug(message: string, meta?: Record<string, any>): void {\n\t\tif (this.shouldLog(\"debug\")) {\n\t\t\tconsole.debug(this.formatMessage(\"debug\", message, meta));\n\t\t}\n\t}\n\n\t// biome-ignore lint/suspicious/noExplicitAny: interop boundary or dynamic-shape value where typing adds friction without runtime safety\n\tinfo(message: string, meta?: Record<string, any>): void {\n\t\tif (this.shouldLog(\"info\")) {\n\t\t\tconsole.info(this.formatMessage(\"info\", message, meta));\n\t\t}\n\t}\n\n\t// biome-ignore lint/suspicious/noExplicitAny: interop boundary or dynamic-shape value where typing adds friction without runtime safety\n\twarn(message: string, meta?: Record<string, any>): void {\n\t\tif (this.shouldLog(\"warn\")) {\n\t\t\tconsole.warn(this.formatMessage(\"warn\", message, meta));\n\t\t}\n\t}\n\n\t// biome-ignore lint/suspicious/noExplicitAny: interop boundary or dynamic-shape value where typing adds friction without runtime safety\n\terror(message: string, meta?: Record<string, any>): void {\n\t\tif (this.shouldLog(\"error\")) {\n\t\t\tconsole.error(this.formatMessage(\"error\", message, meta));\n\t\t}\n\t}\n}\n\n// Export singleton instance\nexport const logger: Logger = new Logger();\n",
|
|
8
|
+
"import { logger } from \"../logger.ts\";\n\n/**\n * The one Resend call every customer email goes through. Never throws when\n * `RESEND_API_KEY` is unset — that's a dev/CI/self-host state, not a send\n * failure — it logs a warning and reports `{ skipped: true }` so a caller\n * can still record its own \"notification sent\" bookkeeping accurately.\n */\n\nexport interface SendEmailInput {\n\tto: string;\n\tsubject: string;\n\thtml: string;\n\ttext: string;\n}\n\nexport type SendEmailResult = { skipped: true } | { skipped: false };\n\nexport async function sendEmail(\n\tinput: SendEmailInput,\n): Promise<SendEmailResult> {\n\tconst apiKey = process.env.RESEND_API_KEY;\n\tif (!apiKey) {\n\t\tlogger.warn(\"RESEND_API_KEY unset — skipping email\", {\n\t\t\tsubject: input.subject,\n\t\t});\n\t\treturn { skipped: true };\n\t}\n\n\tconst from =\n\t\tprocess.env.EMAIL_FROM ?? \"secondlayer <noreply@secondlayer.tools>\";\n\n\tconst response = await fetch(\"https://api.resend.com/emails\", {\n\t\tmethod: \"POST\",\n\t\theaders: {\n\t\t\t\"Content-Type\": \"application/json\",\n\t\t\tAuthorization: `Bearer ${apiKey}`,\n\t\t},\n\t\tbody: JSON.stringify({\n\t\t\tfrom,\n\t\t\tto: [input.to],\n\t\t\tsubject: input.subject,\n\t\t\thtml: input.html,\n\t\t\ttext: input.text,\n\t\t}),\n\t});\n\n\tif (!response.ok) {\n\t\tconst body = await response.text().catch(() => \"\");\n\t\tthrow new Error(\n\t\t\t`Resend API error (${response.status}): ${body.slice(0, 200)}`,\n\t\t);\n\t}\n\n\treturn { skipped: false };\n}\n"
|
|
9
|
+
],
|
|
10
|
+
"mappings": "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",
|
|
11
|
+
"debugId": "E2163B20AB81801464756E2164756E21",
|
|
12
|
+
"names": []
|
|
13
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@secondlayer/shared",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.16.0",
|
|
4
4
|
"description": "Internal building blocks shared across Secondlayer packages.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -220,6 +220,10 @@
|
|
|
220
220
|
"./node/archive-client": {
|
|
221
221
|
"types": "./dist/src/node/archive-client.d.ts",
|
|
222
222
|
"import": "./dist/src/node/archive-client.js"
|
|
223
|
+
},
|
|
224
|
+
"./email": {
|
|
225
|
+
"types": "./dist/src/email/index.d.ts",
|
|
226
|
+
"import": "./dist/src/email/index.js"
|
|
223
227
|
}
|
|
224
228
|
},
|
|
225
229
|
"files": ["dist", "migrations"],
|