v2.10.0: 邮件链路 401 修复 + 一账号一 token + 重装 Worker 按钮——workerAuth 同名脚本撞车时按候选逐一验签确定归属(修 itekmedia 收不到件);凭据 token(credential id)烤进 Worker 脚本、Console 按 token 精确鉴权(脚本名仅作旧版兼容);账号详情新增「重新安装 Worker 脚本」
This commit is contained in:
@@ -157,6 +157,10 @@ export const rotateAccount = (id: string) =>
|
|||||||
export const getAccountWorkerSecret = (id: string) =>
|
export const getAccountWorkerSecret = (id: string) =>
|
||||||
api<{ worker_id: string; secret: string }>(`/api/accounts/${id}/worker-secret`);
|
api<{ worker_id: string; secret: string }>(`/api/accounts/${id}/worker-secret`);
|
||||||
|
|
||||||
|
/** 重新安装 Worker 脚本:不轮换凭据,用当前模板(Console 地址 + 凭据 token + secret)重渲染上传 */
|
||||||
|
export const reinstallAccountWorker = (id: string) =>
|
||||||
|
api<{ ok: boolean; worker_id: string }>(`/api/accounts/${id}/reinstall-worker`, "POST");
|
||||||
|
|
||||||
/* ---- OAuth ---- */
|
/* ---- OAuth ---- */
|
||||||
|
|
||||||
/** OAuth 是否已配置(cloudflare = 可用;unconfigured = 缺少 client id/secret) */
|
/** OAuth 是否已配置(cloudflare = 可用;unconfigured = 缺少 client id/secret) */
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
getAccount,
|
getAccount,
|
||||||
listAccounts,
|
listAccounts,
|
||||||
oauthStart,
|
oauthStart,
|
||||||
|
reinstallAccountWorker,
|
||||||
rotateAccount,
|
rotateAccount,
|
||||||
updateAccount,
|
updateAccount,
|
||||||
type AccountDetail,
|
type AccountDetail,
|
||||||
@@ -428,6 +429,23 @@ function AccountDetailDrawer({
|
|||||||
Email Worker 以该凭据直连 Console(/internal/email/authorize 与 /ingest);轮换后已配置的域名会自动重新部署。
|
Email Worker 以该凭据直连 Console(/internal/email/authorize 与 /ingest);轮换后已配置的域名会自动重新部署。
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
modal.confirm({
|
||||||
|
title: `重新安装 ${detail.account.name} 的 Worker 脚本?`,
|
||||||
|
content: "用当前版本模板重新渲染并上传(凭据不变,已配置域名立即生效);Console 升级后建议执行一次。",
|
||||||
|
okText: "重新安装",
|
||||||
|
onOk: async () => {
|
||||||
|
const res = await reinstallAccountWorker(detail.account.id);
|
||||||
|
message.success(`Worker 脚本已重新安装(${res.worker_id})`);
|
||||||
|
onReload();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重新安装 Worker 脚本
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const res = await rotateAccount(detail.account.id);
|
const res = await rotateAccount(detail.account.id);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const ACTION_LABEL: Record<string, string> = {
|
|||||||
"account.delete": "删除账号",
|
"account.delete": "删除账号",
|
||||||
"account.batch_delete": "批量删除账号",
|
"account.batch_delete": "批量删除账号",
|
||||||
"account.rotate": "轮换 Worker 凭据",
|
"account.rotate": "轮换 Worker 凭据",
|
||||||
|
"account.reinstall_worker": "重新安装 Worker 脚本",
|
||||||
"domain.setup": "配置 Email Workers",
|
"domain.setup": "配置 Email Workers",
|
||||||
"domain.delete": "删除域名",
|
"domain.delete": "删除域名",
|
||||||
"domain.batch_delete": "批量删除域名",
|
"domain.batch_delete": "批量删除域名",
|
||||||
|
|||||||
@@ -50,16 +50,37 @@ export type EmailDecision =
|
|||||||
*/
|
*/
|
||||||
async function workerAuth(c: Ctx, next: Next) {
|
async function workerAuth(c: Ctx, next: Next) {
|
||||||
const raw = new Uint8Array(await c.req.arrayBuffer());
|
const raw = new Uint8Array(await c.req.arrayBuffer());
|
||||||
const cred = await c.env.DB.prepare(
|
// 一个账号一个 token(v2.10.0):凭据 id 即唯一 token,Worker 以它表明身份,先精确匹配。
|
||||||
`SELECT wc.id, wc.secret_enc, wc.cloudflare_account_id, ca.status AS account_status
|
// 兼容旧版 Worker:身份是 worker 脚本名——脚本名只在单个 Cloudflare 账号内唯一,多账号可能
|
||||||
|
// 同名(如都叫 wmailhub-email),此时按名取全部候选逐一验签确定归属;
|
||||||
|
// 只取最新一条会让先接入账号的 Worker 全部 401(v2.9.3~v2.10.0 修复)。
|
||||||
|
const token = (c.req.header("Authorization") ?? "").replace(/^WmailHub-Worker\s+/, "").trim();
|
||||||
|
type CredRow = { id: string; secret_enc: string; cloudflare_account_id: string };
|
||||||
|
let candidates: { results: CredRow[] } = { results: [] };
|
||||||
|
if (token) {
|
||||||
|
candidates = await c.env.DB.prepare(
|
||||||
|
`SELECT wc.id, wc.secret_enc, wc.cloudflare_account_id
|
||||||
FROM worker_credentials wc JOIN cloudflare_accounts ca ON ca.id = wc.cloudflare_account_id
|
FROM worker_credentials wc JOIN cloudflare_accounts ca ON ca.id = wc.cloudflare_account_id
|
||||||
WHERE wc.worker_id = ? AND wc.status = 'active' ORDER BY wc.created_at DESC LIMIT 1`,
|
WHERE wc.id = ? AND wc.status = 'active' AND ca.status != 'revoked'`,
|
||||||
)
|
)
|
||||||
.bind((c.req.header("Authorization") ?? "").replace(/^WmailHub-Worker\s+/, "").trim() || "\u0000")
|
.bind(token)
|
||||||
.first<{ id: string; secret_enc: string; cloudflare_account_id: string; account_status: string }>();
|
.all<CredRow>();
|
||||||
if (!cred) return c.json({ error: "unknown worker or credential revoked" }, 401);
|
if (candidates.results.length === 0) {
|
||||||
|
candidates = await c.env.DB.prepare(
|
||||||
const secret = await decryptSecret(c.env, cred.secret_enc);
|
`SELECT wc.id, wc.secret_enc, wc.cloudflare_account_id
|
||||||
|
FROM worker_credentials wc JOIN cloudflare_accounts ca ON ca.id = wc.cloudflare_account_id
|
||||||
|
WHERE wc.worker_id = ? AND wc.status = 'active' AND ca.status != 'revoked'
|
||||||
|
ORDER BY wc.created_at DESC`,
|
||||||
|
)
|
||||||
|
.bind(token)
|
||||||
|
.all<CredRow>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let matched: { id: string; cloudflare_account_id: string; nonce: string; timestamp: number } | null = null;
|
||||||
|
let lastError = "unknown worker or credential revoked";
|
||||||
|
let lastStatus: 400 | 401 = 401;
|
||||||
|
for (const cand of candidates.results) {
|
||||||
|
const secret = await decryptSecret(c.env, cand.secret_enc);
|
||||||
const verdict = await verifyWorkerRequest({
|
const verdict = await verifyWorkerRequest({
|
||||||
secret,
|
secret,
|
||||||
method: c.req.method,
|
method: c.req.method,
|
||||||
@@ -67,15 +88,22 @@ async function workerAuth(c: Ctx, next: Next) {
|
|||||||
body: raw,
|
body: raw,
|
||||||
headers: c.req.raw.headers,
|
headers: c.req.raw.headers,
|
||||||
});
|
});
|
||||||
if (!verdict.ok) return c.json({ error: verdict.error }, verdict.status);
|
if (verdict.ok) {
|
||||||
|
matched = { id: cand.id, cloudflare_account_id: cand.cloudflare_account_id, nonce: verdict.nonce, timestamp: verdict.timestamp };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
lastError = verdict.error;
|
||||||
|
lastStatus = verdict.status;
|
||||||
|
}
|
||||||
|
if (!matched) return c.json({ error: lastError }, lastStatus);
|
||||||
|
|
||||||
// nonce 防重放:主键冲突即拒绝
|
// nonce 防重放:主键冲突即拒绝
|
||||||
try {
|
try {
|
||||||
await c.env.DB.prepare("INSERT INTO nonces (nonce, account_id, expires_at) VALUES (?, ?, ?)")
|
await c.env.DB.prepare("INSERT INTO nonces (nonce, account_id, expires_at) VALUES (?, ?, ?)")
|
||||||
.bind(
|
.bind(
|
||||||
verdict.nonce,
|
matched.nonce,
|
||||||
cred.cloudflare_account_id,
|
matched.cloudflare_account_id,
|
||||||
new Date((verdict.timestamp + NONCE_TTL_SECONDS) * 1000).toISOString(),
|
new Date((matched.timestamp + NONCE_TTL_SECONDS) * 1000).toISOString(),
|
||||||
)
|
)
|
||||||
.run();
|
.run();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -88,15 +116,15 @@ async function workerAuth(c: Ctx, next: Next) {
|
|||||||
FROM worker_credentials wc JOIN cloudflare_accounts ca ON ca.id = wc.cloudflare_account_id
|
FROM worker_credentials wc JOIN cloudflare_accounts ca ON ca.id = wc.cloudflare_account_id
|
||||||
WHERE wc.id = ?`,
|
WHERE wc.id = ?`,
|
||||||
)
|
)
|
||||||
.bind(cred.id)
|
.bind(matched.id)
|
||||||
.first<{ cred_status: string; account_status: string }>();
|
.first<{ cred_status: string; account_status: string }>();
|
||||||
if (!fresh || fresh.cred_status !== "active" || fresh.account_status === "revoked") {
|
if (!fresh || fresh.cred_status !== "active" || fresh.account_status === "revoked") {
|
||||||
return c.json({ error: "credential revoked" }, 401);
|
return c.json({ error: "credential revoked" }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
c.set("rawBody", raw);
|
c.set("rawBody", raw);
|
||||||
c.set("workerCredentialId", cred.id);
|
c.set("workerCredentialId", matched.id);
|
||||||
c.set("workerAccountId", cred.cloudflare_account_id);
|
c.set("workerAccountId", matched.cloudflare_account_id);
|
||||||
await next();
|
await next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import type { Env, AppVars } from "../env.ts";
|
import type { Env, AppVars } from "../env.ts";
|
||||||
import { requireSession } from "../middleware.ts";
|
import { requireSession } from "../middleware.ts";
|
||||||
import { loadSteps, setupDomainEmail, upsertMailbox, createAccount, provisionAccount, teardownAccountResources, teardownDomainRouting, type TeardownResult } from "../services/provision.ts";
|
import { loadSteps, setupDomainEmail, upsertMailbox, createAccount, provisionAccount, deployAccountWorker, teardownAccountResources, teardownDomainRouting, type TeardownResult } from "../services/provision.ts";
|
||||||
import { getCFApi } from "../services/cfapi.ts";
|
import { getCFApi } from "../services/cfapi.ts";
|
||||||
import { logOperation, operatorName } from "../services/logs.ts";
|
import { logOperation, operatorName } from "../services/logs.ts";
|
||||||
import { encryptSecret, keyHash, decryptAccountToken, decryptSecret, saveConnection } from "../db.ts";
|
import { encryptSecret, keyHash, decryptAccountToken, decryptSecret, saveConnection } from "../db.ts";
|
||||||
@@ -279,6 +279,33 @@ function installAccountRoutes(app: App) {
|
|||||||
|
|
||||||
/** 轮换 Worker credential:撤销旧 credential 并签发新 secret(仅本次响应可见);
|
/** 轮换 Worker credential:撤销旧 credential 并签发新 secret(仅本次响应可见);
|
||||||
* 已配置 Email Workers 的域名自动重新部署脚本以携带新 secret。 */
|
* 已配置 Email Workers 的域名自动重新部署脚本以携带新 secret。 */
|
||||||
|
/** 重新安装 Worker 脚本:不轮换凭据,按当前模板(Console 地址 + 凭据 token + secret)重渲染并上传。
|
||||||
|
* 用于 Console 升级后把已有账号的 Worker 推到最新脚本(如 v2.10.0 一账号一 token 版);幂等。 */
|
||||||
|
app.post("/accounts/:id/reinstall-worker", async (c) => {
|
||||||
|
const id = c.req.param("id");
|
||||||
|
const account = await c.env.DB.prepare(
|
||||||
|
"SELECT id, name, worker_id FROM cloudflare_accounts WHERE id = ? AND status != 'revoked'",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.first<{ id: string; name: string; worker_id: string | null }>();
|
||||||
|
if (!account) return c.json({ error: "not found" }, 404);
|
||||||
|
if (!account.worker_id) return c.json({ error: "worker not provisioned yet" }, 400);
|
||||||
|
|
||||||
|
const accessToken = await decryptAccountToken(c.env, id);
|
||||||
|
if (!accessToken) return c.json({ error: "no active Cloudflare connection for this account" }, 400);
|
||||||
|
|
||||||
|
const workerId = await deployAccountWorker(c.env, id, accessToken);
|
||||||
|
await logOperation(c.env, {
|
||||||
|
action: "account.reinstall_worker",
|
||||||
|
targetType: "account",
|
||||||
|
targetId: id,
|
||||||
|
targetName: account.name ?? null,
|
||||||
|
detail: { worker_id: workerId },
|
||||||
|
operator: await operatorName(c.env, c.get("userId")),
|
||||||
|
});
|
||||||
|
return c.json({ ok: true, worker_id: workerId });
|
||||||
|
});
|
||||||
|
|
||||||
app.post("/accounts/:id/rotate", async (c) => {
|
app.post("/accounts/:id/rotate", async (c) => {
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const account = await c.env.DB.prepare(
|
const account = await c.env.DB.prepare(
|
||||||
|
|||||||
@@ -432,8 +432,8 @@ export interface CFApi {
|
|||||||
scriptName: string;
|
scriptName: string;
|
||||||
/** Console 对外地址(Worker -> Console) **/
|
/** Console 对外地址(Worker -> Console) **/
|
||||||
consoleUrl: string;
|
consoleUrl: string;
|
||||||
/** Worker 身份(与 WorkerCredential.worker_id 一致) **/
|
/** 凭据 token(= worker_credentials.id,一账号一个,Worker 以此表明身份) **/
|
||||||
workerId: string;
|
credentialToken: string;
|
||||||
/** Worker credential secret(HMAC 签名密钥) **/
|
/** Worker credential secret(HMAC 签名密钥) **/
|
||||||
workerSecret: string;
|
workerSecret: string;
|
||||||
}): Promise<{ id: string }>;
|
}): Promise<{ id: string }>;
|
||||||
@@ -590,12 +590,12 @@ export class RealCFApi implements CFApi {
|
|||||||
accountId: string;
|
accountId: string;
|
||||||
scriptName: string;
|
scriptName: string;
|
||||||
consoleUrl: string;
|
consoleUrl: string;
|
||||||
workerId: string;
|
credentialToken: string;
|
||||||
workerSecret: string;
|
workerSecret: string;
|
||||||
}) {
|
}) {
|
||||||
// Email Worker 即带 email handler 的 Worker 脚本;Console 地址与 Worker 凭据以明文常量打进脚本,
|
// Email Worker 即带 email handler 的 Worker 脚本;Console 地址与凭据(token + secret)以明文常量
|
||||||
// 避免给 Worker 配置额外的 secrets 绑定(docs/11 §8:凭据本身经 HMAC 保护 Console API)。
|
// 打进脚本,避免给 Worker 配置额外的 secrets 绑定(docs/11 §8:凭据本身经 HMAC 保护 Console API)。
|
||||||
const script = renderEmailWorkerScript(opts.consoleUrl, opts.workerId, opts.workerSecret);
|
const script = renderEmailWorkerScript(opts.consoleUrl, opts.credentialToken, opts.workerSecret);
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append(
|
form.append(
|
||||||
"metadata",
|
"metadata",
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ export async function provisionAccount(env: Env, input: ProvisionInput): Promise
|
|||||||
|
|
||||||
/** # 函数
|
/** # 函数
|
||||||
* 部署(或更新)账号级 Email Worker:
|
* 部署(或更新)账号级 Email Worker:
|
||||||
* 确保 Worker credential -> 渲染脚本(Console 地址 + Worker ID + secret)-> 上传 Cloudflare。
|
* 确保 Worker credential -> 渲染脚本(Console 地址 + 凭据 token(= credential id,一账号一 token)+ secret)-> 上传 Cloudflare。
|
||||||
* credential 已存在时复用旧 secret(重新上传脚本不轮换密钥);secret 仅在首次签发时用于部署。
|
* credential 已存在时复用旧 secret(重新上传脚本不轮换密钥);secret 仅在首次签发时用于部署。
|
||||||
*/
|
*/
|
||||||
export async function deployAccountWorker(env: Env, accountId: string, accessToken: string): Promise<string> {
|
export async function deployAccountWorker(env: Env, accountId: string, accessToken: string): Promise<string> {
|
||||||
@@ -215,19 +215,19 @@ export async function deployAccountWorker(env: Env, accountId: string, accessTok
|
|||||||
if (!account?.cloudflare_account_id) throw new Error("account not connected via OAuth");
|
if (!account?.cloudflare_account_id) throw new Error("account not connected via OAuth");
|
||||||
|
|
||||||
const workerId = await getOrCreateWorkerId(env, accountId);
|
const workerId = await getOrCreateWorkerId(env, accountId);
|
||||||
const { secret } = await ensureWorkerCredential(env, accountId, workerId);
|
const ensured = await ensureWorkerCredential(env, accountId, workerId);
|
||||||
if (!secret) {
|
if (!ensured.secret) {
|
||||||
// 已有 credential:重新上传脚本时需要原 secret,先解密
|
// 已有 credential:重新上传脚本时需要原 secret,先解密
|
||||||
const row = await env.DB.prepare(
|
const row = await env.DB.prepare(
|
||||||
"SELECT secret_enc FROM worker_credentials WHERE cloudflare_account_id = ? AND worker_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1",
|
"SELECT id, secret_enc FROM worker_credentials WHERE cloudflare_account_id = ? AND worker_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1",
|
||||||
)
|
)
|
||||||
.bind(accountId, workerId)
|
.bind(accountId, workerId)
|
||||||
.first<{ secret_enc: string }>();
|
.first<{ id: string; secret_enc: string }>();
|
||||||
const decrypted = row ? await decryptSecret(env, row.secret_enc) : null;
|
const decrypted = row ? await decryptSecret(env, row.secret_enc) : null;
|
||||||
await putWorkerScript(env, accessToken, account.cloudflare_account_id, workerId, decrypted ?? "");
|
await putWorkerScript(env, accessToken, account.cloudflare_account_id, workerId, row?.id ?? ensured.credentialId, decrypted ?? "");
|
||||||
return workerId;
|
return workerId;
|
||||||
}
|
}
|
||||||
await putWorkerScript(env, accessToken, account.cloudflare_account_id, workerId, secret);
|
await putWorkerScript(env, accessToken, account.cloudflare_account_id, workerId, ensured.credentialId, ensured.secret);
|
||||||
return workerId;
|
return workerId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,12 +254,13 @@ async function putWorkerScript(
|
|||||||
accessToken: string,
|
accessToken: string,
|
||||||
cfAccountId: string,
|
cfAccountId: string,
|
||||||
workerId: string,
|
workerId: string,
|
||||||
|
credentialToken: string,
|
||||||
workerSecret: string,
|
workerSecret: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const cf: CFApi = await getCFApi(env);
|
const cf: CFApi = await getCFApi(env);
|
||||||
const consoleUrl = env.PUBLIC_BASE_URL ?? "";
|
const consoleUrl = env.PUBLIC_BASE_URL ?? "";
|
||||||
if (!consoleUrl) throw new Error("console public url unknown (PUBLIC_BASE_URL)");
|
if (!consoleUrl) throw new Error("console public url unknown (PUBLIC_BASE_URL)");
|
||||||
await cf.putEmailWorker({ accessToken, accountId: cfAccountId, scriptName: workerId, consoleUrl, workerId, workerSecret });
|
await cf.putEmailWorker({ accessToken, accountId: cfAccountId, scriptName: workerId, consoleUrl, credentialToken, workerSecret });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** # 函数
|
/** # 函数
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
# Email Worker <-> Console 内部协议(docs/11 §8,协议版本 1)
|
# Email Worker <-> Console 内部协议(docs/11 §8,协议版本 1)
|
||||||
|
|
||||||
`/internal/email/authorize` 与 `/internal/email/ingest` 都是内部 API,不能匿名访问。
|
`/internal/email/authorize` 与 `/internal/email/ingest` 都是内部 API,不能匿名访问。
|
||||||
每个请求必须满足:HTTPS + Worker ID + timestamp + nonce + body SHA256 + HMAC-SHA256。
|
每个请求必须满足:HTTPS + 凭据 token + timestamp + nonce + body SHA256 + HMAC-SHA256。
|
||||||
|
|
||||||
|
**身份 = 凭据 token(v2.10.0 起)**:`Authorization` 里放 `worker_credentials.id`(一账号一个,
|
||||||
|
部署 Worker 时烤进脚本),Console 按它精确定位凭据与所属账号。兼容:旧版 Worker 放的是
|
||||||
|
worker 脚本名——脚本名只在单个 Cloudflare 账号内唯一,多账号同名时 Console 会取全部同名
|
||||||
|
凭据逐一验签确定归属(v2.9.3 引入;只取一条会让先接入账号全部 401)。
|
||||||
|
|
||||||
## 请求头
|
## 请求头
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Authorization: WmailHub-Worker <worker_id>
|
Authorization: WmailHub-Worker <credential_token>
|
||||||
X-WmailHub-Timestamp: <unix_seconds>
|
X-WmailHub-Timestamp: <unix_seconds>
|
||||||
X-WmailHub-Nonce: <random>
|
X-WmailHub-Nonce: <random>
|
||||||
X-WmailHub-Body-SHA256: <hex>
|
X-WmailHub-Body-SHA256: <hex>
|
||||||
@@ -24,7 +29,7 @@ signature = HMAC-SHA256(worker_credential_secret, canonical)
|
|||||||
|
|
||||||
## Console 必须校验
|
## Console 必须校验
|
||||||
|
|
||||||
1. Worker ID(存在且 credential active);
|
1. 凭据 token(存在且 credential active;旧脚本名按同名候选逐一验签);
|
||||||
2. timestamp(默认窗口 5 分钟);
|
2. timestamp(默认窗口 5 分钟);
|
||||||
3. nonce 不重复(唯一索引入库,主键冲突即 replay);
|
3. nonce 不重复(唯一索引入库,主键冲突即 replay);
|
||||||
4. body hash;
|
4. body hash;
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ Authorization 和 Ingest 都必须是内部 API,不能匿名访问。
|
|||||||
|
|
||||||
请求头:
|
请求头:
|
||||||
|
|
||||||
Authorization: WmailHub-Worker <worker_id>
|
Authorization: WmailHub-Worker <credential_token> # v2.10.0 起为凭据 id(一账号一个);旧版为脚本名
|
||||||
X-WmailHub-Timestamp: <unix_seconds>
|
X-WmailHub-Timestamp: <unix_seconds>
|
||||||
X-WmailHub-Nonce: <random>
|
X-WmailHub-Nonce: <random>
|
||||||
X-WmailHub-Body-SHA256: <sha256>
|
X-WmailHub-Body-SHA256: <sha256>
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wmailhub",
|
"name": "wmailhub",
|
||||||
"version": "2.9.2",
|
"version": "2.10.0",
|
||||||
"description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)",
|
"description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Console 在「Connect Cloudflare Account」/ 域名配置 Email Workers 时,通过 Cloudflare API
|
* Console 在「Connect Cloudflare Account」/ 域名配置 Email Workers 时,通过 Cloudflare API
|
||||||
* 把该脚本上传为账号级 Email Worker,并以占位符注入三组配置:
|
* 把该脚本上传为账号级 Email Worker,并以占位符注入三组配置:
|
||||||
* __CONSOLE_URL__ Console 对外地址(Worker -> Console 的目标)
|
* __CONSOLE_URL__ Console 对外地址(Worker -> Console 的目标)
|
||||||
* __WORKER_ID__ Worker 身份(与 WorkerCredential.worker_id 一致)
|
* __CREDENTIAL_TOKEN__ 凭据 token(= worker_credentials.id,一账号一个;Worker 以此表明身份)
|
||||||
* __WORKER_SECRET__ Worker credential secret(HMAC 签名密钥)
|
* __WORKER_SECRET__ Worker credential secret(HMAC 签名密钥)
|
||||||
*
|
*
|
||||||
* 邮件流程:authorize(接受/拒绝/临时错误) -> accept 时 ingest 原始 MIME -> reject 时 setReject。
|
* 邮件流程:authorize(接受/拒绝/临时错误) -> accept 时 ingest 原始 MIME -> reject 时 setReject。
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
export const EMAIL_WORKER_SCRIPT = String.raw`// WmailHub Email Worker (auto-generated by Console provisioning)
|
export const EMAIL_WORKER_SCRIPT = String.raw`// WmailHub Email Worker (auto-generated by Console provisioning)
|
||||||
// Cloudflare Email Routing -> Email Worker -> WmailHub Console(docs/11)
|
// Cloudflare Email Routing -> Email Worker -> WmailHub Console(docs/11)
|
||||||
const CONSOLE_URL = "__CONSOLE_URL__";
|
const CONSOLE_URL = "__CONSOLE_URL__";
|
||||||
const WORKER_ID = "__WORKER_ID__";
|
const CREDENTIAL_TOKEN = "__CREDENTIAL_TOKEN__";
|
||||||
const WORKER_SECRET = "__WORKER_SECRET__";
|
const WORKER_SECRET = "__WORKER_SECRET__";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -82,7 +82,7 @@ async function signedHeaders(method, path, body) {
|
|||||||
const bh = await sha256Hex(body);
|
const bh = await sha256Hex(body);
|
||||||
const signature = await hmac(WORKER_SECRET, [method, path, timestamp, nonce, bh].join("\n"));
|
const signature = await hmac(WORKER_SECRET, [method, path, timestamp, nonce, bh].join("\n"));
|
||||||
return {
|
return {
|
||||||
"Authorization": "WmailHub-Worker " + WORKER_ID,
|
"Authorization": "WmailHub-Worker " + CREDENTIAL_TOKEN,
|
||||||
"X-WmailHub-Timestamp": timestamp,
|
"X-WmailHub-Timestamp": timestamp,
|
||||||
"X-WmailHub-Nonce": nonce,
|
"X-WmailHub-Nonce": nonce,
|
||||||
"X-WmailHub-Body-SHA256": bh,
|
"X-WmailHub-Body-SHA256": bh,
|
||||||
@@ -169,15 +169,15 @@ async function handleEmail(env, message) {
|
|||||||
|
|
||||||
/** # 函数
|
/** # 函数
|
||||||
* 用实际 Console 地址与 Worker 凭据渲染 Email Worker 脚本
|
* 用实际 Console 地址与 Worker 凭据渲染 Email Worker 脚本
|
||||||
* 参数: consoleUrl - Console 对外地址;workerId - Worker 身份;workerSecret - credential secret
|
* 参数: consoleUrl - Console 对外地址;credentialToken - 凭据 token(worker_credentials.id,一账号一个);workerSecret - credential secret
|
||||||
* 返回值: 可直接上传 Cloudflare 的脚本字符串
|
* 返回值: 可直接上传 Cloudflare 的脚本字符串
|
||||||
*/
|
*/
|
||||||
export function renderEmailWorkerScript(
|
export function renderEmailWorkerScript(
|
||||||
consoleUrl: string,
|
consoleUrl: string,
|
||||||
workerId: string,
|
credentialToken: string,
|
||||||
workerSecret: string,
|
workerSecret: string,
|
||||||
): string {
|
): string {
|
||||||
return EMAIL_WORKER_SCRIPT.replace("__CONSOLE_URL__", consoleUrl)
|
return EMAIL_WORKER_SCRIPT.replace("__CONSOLE_URL__", consoleUrl)
|
||||||
.replace("__WORKER_ID__", workerId)
|
.replace("__CREDENTIAL_TOKEN__", credentialToken)
|
||||||
.replace("__WORKER_SECRET__", workerSecret);
|
.replace("__WORKER_SECRET__", workerSecret);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
* signature = HMAC-SHA256(worker_credential_secret, canonical)
|
* signature = HMAC-SHA256(worker_credential_secret, canonical)
|
||||||
*
|
*
|
||||||
* 请求头:
|
* 请求头:
|
||||||
* Authorization: WmailHub-Worker <worker_id>
|
* Authorization: WmailHub-Worker <credential_token>
|
||||||
|
* (v2.10.0 起身份是凭据 token = worker_credentials.id,一账号一个;旧版 Worker 传脚本名,
|
||||||
|
* Console 按名取全部同名候选逐一验签兼容)
|
||||||
* X-WmailHub-Timestamp / X-WmailHub-Nonce / X-WmailHub-Body-SHA256 /
|
* X-WmailHub-Timestamp / X-WmailHub-Nonce / X-WmailHub-Body-SHA256 /
|
||||||
* X-WmailHub-Signature / X-WmailHub-Protocol
|
* X-WmailHub-Signature / X-WmailHub-Protocol
|
||||||
*
|
*
|
||||||
@@ -65,13 +67,13 @@ export async function computeSignature(secret: string, c: SignatureComponents):
|
|||||||
|
|
||||||
/** # 函数
|
/** # 函数
|
||||||
* Worker 端:为发往 Console 内部 API 的请求生成全部签名请求头
|
* Worker 端:为发往 Console 内部 API 的请求生成全部签名请求头
|
||||||
* 参数: secret - worker credential secret;workerId - Email Worker 身份(脚本名);
|
* 参数: secret - worker credential secret;token - 凭据 token(worker_credentials.id,一账号一个;
|
||||||
* method/url 同 HTTP 语义;body - 原始请求体
|
* 旧版 Worker 传脚本名,Console 兼容);method/url 同 HTTP 语义;body - 原始请求体
|
||||||
* 返回值: 可直接放入 fetch headers 的对象
|
* 返回值: 可直接放入 fetch headers 的对象
|
||||||
*/
|
*/
|
||||||
export async function signWorkerRequest(
|
export async function signWorkerRequest(
|
||||||
secret: string,
|
secret: string,
|
||||||
workerId: string,
|
token: string,
|
||||||
method: string,
|
method: string,
|
||||||
url: string,
|
url: string,
|
||||||
body: Uint8Array,
|
body: Uint8Array,
|
||||||
@@ -88,7 +90,7 @@ export async function signWorkerRequest(
|
|||||||
bodyHash: bh,
|
bodyHash: bh,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
"Authorization": `WmailHub-Worker ${workerId}`,
|
"Authorization": `WmailHub-Worker ${token}`,
|
||||||
"X-WmailHub-Timestamp": timestamp,
|
"X-WmailHub-Timestamp": timestamp,
|
||||||
"X-WmailHub-Nonce": nonce,
|
"X-WmailHub-Nonce": nonce,
|
||||||
"X-WmailHub-Body-SHA256": bh,
|
"X-WmailHub-Body-SHA256": bh,
|
||||||
@@ -99,7 +101,7 @@ export async function signWorkerRequest(
|
|||||||
|
|
||||||
/** # 成员变量: verifyWorkerRequest 的输入 **/
|
/** # 成员变量: verifyWorkerRequest 的输入 **/
|
||||||
export interface VerifyWorkerRequestInput {
|
export interface VerifyWorkerRequestInput {
|
||||||
/** 请求方声明的 credential secret(Console 从库中按 worker_id 取出) **/
|
/** 请求方声明的 credential secret(Console 按请求身份(token/脚本名)取出) **/
|
||||||
secret: string;
|
secret: string;
|
||||||
/** HTTP 方法 **/
|
/** HTTP 方法 **/
|
||||||
method: string;
|
method: string;
|
||||||
|
|||||||
+55
-1
@@ -132,6 +132,60 @@ describe("POST /internal/email/authorize 决策矩阵", () => {
|
|||||||
expect(((await resp.json()) as Decision).decision).toBe("accept");
|
expect(((await resp.json()) as Decision).decision).toBe("accept");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("同名 worker 跨账号:凭据按签名归属,不得只取最新一条(v2.9.3 回归)", async () => {
|
||||||
|
const t = await makeTestEnv();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
// 第二个账号部署了与默认账号同名的 worker 脚本,凭据创建更晚
|
||||||
|
await t.env.DB.prepare(
|
||||||
|
"INSERT INTO cloudflare_accounts (id, name, cloudflare_account_id, worker_id, status, provision_steps, created_at, updated_at) VALUES ('acct_later', 'later-account', 'cfacct-2', ?, 'active', '{}', ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(t.workerId, now, now)
|
||||||
|
.run();
|
||||||
|
const laterSecret = "whs_later_account_secret";
|
||||||
|
await t.env.DB.prepare(
|
||||||
|
"INSERT INTO worker_credentials (id, cloudflare_account_id, worker_id, secret_hash, secret_enc, secret_last4, status, created_at) VALUES ('wcred_later', 'acct_later', ?, ?, ?, ?, 'active', ?)",
|
||||||
|
)
|
||||||
|
.bind(t.workerId, await sha256Hex(laterSecret), await encryptSecret(t.env, laterSecret), laterSecret.slice(-4), now)
|
||||||
|
.run();
|
||||||
|
await t.env.DB.prepare(
|
||||||
|
"INSERT INTO domains (id, cloudflare_account_id, account_id, name, status, created_at, updated_at) VALUES ('dom_later', 'cfacct-2', 'acct_later', 'later.test', 'active', ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(now, now)
|
||||||
|
.run();
|
||||||
|
await t.env.DB.prepare(
|
||||||
|
"INSERT INTO mailboxes (id, domain_id, address, status, is_catch_all, created_at, updated_at) VALUES ('mbx_later', 'dom_later', '*@later.test', 'active', 1, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(now, now)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// 先接入账号的 secret 签名(身份=脚本名,兼容路径) -> 必须验到先接入的凭据上(v2.9.3 前被最新凭据压住而 401)
|
||||||
|
const first = await authorize(t, "hello@example.test");
|
||||||
|
expect(first.status).toBe(200);
|
||||||
|
expect(first.body.decision).toBe("accept");
|
||||||
|
|
||||||
|
// 后接入账号的 secret 签名(身份=脚本名,兼容路径) -> 归属到后接入账号,它自己的域名 accept
|
||||||
|
const later = await t.app.fetch(
|
||||||
|
await signedAuthorizeRequest(t, "hello@later.test", "a@b.c", { workerId: t.workerId, secret: laterSecret }),
|
||||||
|
t.env,
|
||||||
|
);
|
||||||
|
expect(later.status).toBe(200);
|
||||||
|
expect(((await later.json()) as Decision).decision).toBe("accept");
|
||||||
|
|
||||||
|
// v2.10.0 一账号一 token:身份=凭据 id,精确匹配;token 钉死凭据,错 secret 直接 401
|
||||||
|
const tokenFirst = await t.app.fetch(
|
||||||
|
await signedAuthorizeRequest(t, "hello@example.test", "a@b.c", { workerId: "wcred1", secret: "wrong-secret" }),
|
||||||
|
t.env,
|
||||||
|
);
|
||||||
|
expect(tokenFirst.status).toBe(401);
|
||||||
|
|
||||||
|
const tokenLater = await t.app.fetch(
|
||||||
|
await signedAuthorizeRequest(t, "hello@later.test", "a@b.c", { workerId: "wcred_later", secret: laterSecret }),
|
||||||
|
t.env,
|
||||||
|
);
|
||||||
|
expect(tokenLater.status).toBe(200);
|
||||||
|
expect(((await tokenLater.json()) as Decision).decision).toBe("accept");
|
||||||
|
});
|
||||||
|
|
||||||
test("畸形收件人 -> reject MAILBOX_NOT_FOUND(不抛 500)", async () => {
|
test("畸形收件人 -> reject MAILBOX_NOT_FOUND(不抛 500)", async () => {
|
||||||
const t = await makeTestEnv();
|
const t = await makeTestEnv();
|
||||||
for (const bad of ["no-at-sign", "@example.test", "a@"]) {
|
for (const bad of ["no-at-sign", "@example.test", "a@"]) {
|
||||||
@@ -170,7 +224,7 @@ function makeWorkerEnv(opts: FakeConsoleOptions): WorkerEnv {
|
|||||||
}) as typeof fetch;
|
}) as typeof fetch;
|
||||||
return {
|
return {
|
||||||
CONSOLE_URL: "http://fake-console",
|
CONSOLE_URL: "http://fake-console",
|
||||||
WORKER_ID: "wmailhub-email-test",
|
CREDENTIAL_TOKEN: "wcred_test_token",
|
||||||
WORKER_SECRET: "whs_fake_secret",
|
WORKER_SECRET: "whs_fake_secret",
|
||||||
fetchImpl,
|
fetchImpl,
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -140,7 +140,7 @@ export async function loginCookie(test: TestEnv): Promise<string> {
|
|||||||
export interface SignOverrides {
|
export interface SignOverrides {
|
||||||
/** 换用错误 secret(签名必然不匹配) **/
|
/** 换用错误 secret(签名必然不匹配) **/
|
||||||
secret?: string;
|
secret?: string;
|
||||||
/** 声明其它 worker_id **/
|
/** 请求方声明的身份(token=凭据 id,或旧版 Worker 的脚本名) **/
|
||||||
workerId?: string;
|
workerId?: string;
|
||||||
/** 篡改 Authorization */
|
/** 篡改 Authorization */
|
||||||
tamperSignature?: boolean;
|
tamperSignature?: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/** 重新安装 Worker 脚本端点测试(v2.10.0):权限/前置校验,不轮换凭据 */
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { authedFetch, loginCookie, makeTestEnv } from "./helpers.ts";
|
||||||
|
|
||||||
|
describe("POST /api/accounts/:id/reinstall-worker", () => {
|
||||||
|
test("不存在的账号 -> 404", async () => {
|
||||||
|
const t = await makeTestEnv();
|
||||||
|
const cookie = await loginCookie(t);
|
||||||
|
const resp = await authedFetch(t, cookie, "http://localhost/api/accounts/acct_no_such/reinstall-worker", { method: "POST" });
|
||||||
|
expect(resp.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("无 Cloudflare 连接凭据 -> 400(不触碰 Cloudflare,不留半截状态)", async () => {
|
||||||
|
const t = await makeTestEnv();
|
||||||
|
const cookie = await loginCookie(t);
|
||||||
|
// 种子账号有 worker_id 但没有 oauth_connections/token -> 应明确报错而非 500
|
||||||
|
const resp = await authedFetch(t, cookie, `http://localhost/api/accounts/${t.accountId}/reinstall-worker`, { method: "POST" });
|
||||||
|
expect(resp.status).toBe(400);
|
||||||
|
expect(((await resp.json()) as { error: string }).error).toContain("no active Cloudflare connection");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -82,6 +82,19 @@ await page.waitForSelector("text=域名级 Webhook", { timeout: 8000 });
|
|||||||
check("域名详情抽屉(Email Workers + Webhook 配置)", true);
|
check("域名详情抽屉(Email Workers + Webhook 配置)", true);
|
||||||
await page.screenshot({ path: `${SHOT_DIR}/07-domain-drawer.png` });
|
await page.screenshot({ path: `${SHOT_DIR}/07-domain-drawer.png` });
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
|
||||||
|
// 4b. 账号详情抽屉:「重新安装 Worker 脚本」按钮(v2.10.0)
|
||||||
|
await page.goto(`${BASE}/accounts`, { waitUntil: "domcontentloaded" });
|
||||||
|
await page.waitForSelector(".ant-table-row", { timeout: 8000 });
|
||||||
|
await page.locator(".ant-table-row").first().click();
|
||||||
|
await page.waitForSelector(".ant-drawer-open", { timeout: 8000 });
|
||||||
|
const hasReinstall =
|
||||||
|
(await page.locator(".ant-drawer button:has-text('重新安装 Worker 脚本')").count()) > 0;
|
||||||
|
check("账号详情抽屉含「重新安装 Worker 脚本」按钮", hasReinstall);
|
||||||
|
await page.screenshot({ path: `${SHOT_DIR}/07b-account-drawer.png` });
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
|
||||||
// 5. 邮件详情(点击第一行)
|
// 5. 邮件详情(点击第一行)
|
||||||
await page.goto(`${BASE}/inboxs`, { waitUntil: "domcontentloaded" });
|
await page.goto(`${BASE}/inboxs`, { waitUntil: "domcontentloaded" });
|
||||||
|
|||||||
+2
-2
@@ -4,8 +4,8 @@
|
|||||||
export interface WorkerEnv {
|
export interface WorkerEnv {
|
||||||
/** Console 对外地址(Worker -> Console 的目标) **/
|
/** Console 对外地址(Worker -> Console 的目标) **/
|
||||||
CONSOLE_URL: string;
|
CONSOLE_URL: string;
|
||||||
/** Worker 身份(与 WorkerCredential.worker_id 一致) **/
|
/** 凭据 token(= worker_credentials.id,一账号一个;Worker 以此表明身份) **/
|
||||||
WORKER_ID: string;
|
CREDENTIAL_TOKEN: string;
|
||||||
/** Worker credential secret(HMAC 签名密钥) **/
|
/** Worker credential secret(HMAC 签名密钥) **/
|
||||||
WORKER_SECRET: string;
|
WORKER_SECRET: string;
|
||||||
/** 测试注入:替换 fetch 实现(单测用;缺省用全局 fetch) **/
|
/** 测试注入:替换 fetch 实现(单测用;缺省用全局 fetch) **/
|
||||||
|
|||||||
+2
-2
@@ -50,7 +50,7 @@ export interface HandleEmailResult {
|
|||||||
export async function authorizeEmail(env: WorkerEnv, recipient: string, sender: string): Promise<HandleEmailResult> {
|
export async function authorizeEmail(env: WorkerEnv, recipient: string, sender: string): Promise<HandleEmailResult> {
|
||||||
const doFetch = env.fetchImpl ?? fetch;
|
const doFetch = env.fetchImpl ?? fetch;
|
||||||
const body = new TextEncoder().encode(JSON.stringify({ recipient, sender }));
|
const body = new TextEncoder().encode(JSON.stringify({ recipient, sender }));
|
||||||
const headers = await signWorkerRequest(env.WORKER_SECRET, env.WORKER_ID, "POST", `${env.CONSOLE_URL}${EMAIL_AUTHORIZE_PATH}`, body);
|
const headers = await signWorkerRequest(env.WORKER_SECRET, env.CREDENTIAL_TOKEN, "POST", `${env.CONSOLE_URL}${EMAIL_AUTHORIZE_PATH}`, body);
|
||||||
let resp: Response;
|
let resp: Response;
|
||||||
try {
|
try {
|
||||||
resp = await doFetch(`${env.CONSOLE_URL}${EMAIL_AUTHORIZE_PATH}`, {
|
resp = await doFetch(`${env.CONSOLE_URL}${EMAIL_AUTHORIZE_PATH}`, {
|
||||||
@@ -94,7 +94,7 @@ export async function ingestEmail(
|
|||||||
): Promise<HandleEmailResult> {
|
): Promise<HandleEmailResult> {
|
||||||
const doFetch = env.fetchImpl ?? fetch;
|
const doFetch = env.fetchImpl ?? fetch;
|
||||||
const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw);
|
const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw);
|
||||||
const headers = await signWorkerRequest(env.WORKER_SECRET, env.WORKER_ID, "POST", `${env.CONSOLE_URL}${EMAIL_INGEST_PATH}`, bytes);
|
const headers = await signWorkerRequest(env.WORKER_SECRET, env.CREDENTIAL_TOKEN, "POST", `${env.CONSOLE_URL}${EMAIL_INGEST_PATH}`, bytes);
|
||||||
let resp: Response;
|
let resp: Response;
|
||||||
try {
|
try {
|
||||||
resp = await doFetch(`${env.CONSOLE_URL}${EMAIL_INGEST_PATH}`, {
|
resp = await doFetch(`${env.CONSOLE_URL}${EMAIL_INGEST_PATH}`, {
|
||||||
|
|||||||
+4
-4
@@ -11,12 +11,12 @@ import type { WorkerEnv } from "./env.ts";
|
|||||||
|
|
||||||
const env: WorkerEnv = {
|
const env: WorkerEnv = {
|
||||||
CONSOLE_URL: process.env.CONSOLE_URL ?? "http://127.0.0.1:18787",
|
CONSOLE_URL: process.env.CONSOLE_URL ?? "http://127.0.0.1:18787",
|
||||||
WORKER_ID: process.env.WORKER_ID ?? "",
|
CREDENTIAL_TOKEN: process.env.CREDENTIAL_TOKEN ?? "",
|
||||||
WORKER_SECRET: process.env.WORKER_SECRET ?? "",
|
WORKER_SECRET: process.env.WORKER_SECRET ?? "",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!env.WORKER_ID || !env.WORKER_SECRET) {
|
if (!env.CREDENTIAL_TOKEN || !env.WORKER_SECRET) {
|
||||||
console.error("missing WORKER_ID / WORKER_SECRET env");
|
console.error("missing CREDENTIAL_TOKEN / WORKER_SECRET env");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ console.log(
|
|||||||
lvl: "info",
|
lvl: "info",
|
||||||
msg: "email worker started",
|
msg: "email worker started",
|
||||||
port: PORT,
|
port: PORT,
|
||||||
worker_id: env.WORKER_ID,
|
credential_token: env.CREDENTIAL_TOKEN,
|
||||||
console_url: env.CONSOLE_URL,
|
console_url: env.CONSOLE_URL,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user