From 04c99295995993c93c0cb3596a55f049369601f7 Mon Sep 17 00:00:00 2001 From: AutoGenUI <231675956+AutoGenUI@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:10:58 +0800 Subject: [PATCH] Fix subscription confirmation experience for v1.0.0 --- components/locale-switch.tsx | 8 +- components/news/shared.tsx | 4 +- components/news/subscription.tsx | 107 +++++++++++++--------- docs/CURRENT_STATE.json | 64 +++++++++++-- docs/PROJECT_CONTEXT.md | 6 ++ docs/operations/deployment-maintenance.md | 2 + docs/operations/email-subscriptions.md | 12 ++- docs/releases/v1.0.0.md | 44 +++++++++ lib/news/mail.ts | 23 +++-- lib/news/subscription-ui.ts | 16 +++- package-lock.json | 4 +- package.json | 2 +- tests/news.test.mjs | 59 ++++++++++++ tests/subscription-ui.test.mjs | 31 ++++++- 14 files changed, 307 insertions(+), 75 deletions(-) create mode 100644 docs/releases/v1.0.0.md diff --git a/components/locale-switch.tsx b/components/locale-switch.tsx index 1db8fdb..504bfd9 100644 --- a/components/locale-switch.tsx +++ b/components/locale-switch.tsx @@ -1,11 +1,13 @@ "use client"; import Link from "next/link"; -import { usePathname } from "next/navigation"; +import { usePathname, useSearchParams } from "next/navigation"; import type { Locale } from "@/lib/types"; +import { subscriptionLocaleHref } from "@/lib/news/subscription-ui"; export function LocaleSwitch({ locale, alternate }: { locale: Locale; alternate: Locale }) { const pathname = usePathname(); - const href = pathname?.startsWith(`/${locale}`) ? pathname.replace(`/${locale}`, `/${alternate}`) : `/${alternate}`; - return {alternate === "zh" ? "中文" : "EN"}; + const query = useSearchParams(); + const href = subscriptionLocaleHref(pathname, locale, alternate, query.toString()); + return {alternate === "zh" ? "中文" : "EN"}; } diff --git a/components/news/shared.tsx b/components/news/shared.tsx index 9b07723..c4d8c47 100644 --- a/components/news/shared.tsx +++ b/components/news/shared.tsx @@ -94,8 +94,8 @@ export function SubscribeBand({ locale }: { locale: Locale }) {

{locale === "zh" - ? "用一份精选,串起生成式 UI 的一周。RSS 已开放,邮件周报待启用。" - : "A considered view of the week in Generative UI. RSS is live; email delivery is coming."} + ? "用一份精选,串起生成式 UI 的一周。通过 RSS 阅读,或确认邮箱后接收周报。" + : "A considered view of the week in Generative UI. Read with RSS, or confirm your email for the digest."}

diff --git a/components/news/subscription.tsx b/components/news/subscription.tsx index 1d1ba65..a7c7f6c 100644 --- a/components/news/subscription.tsx +++ b/components/news/subscription.tsx @@ -1,5 +1,5 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useSearchParams } from "next/navigation"; import { categories, categoryNames, type Category, type Locale } from "@/lib/news/types"; import { @@ -40,7 +40,6 @@ function SubscriptionView({ locale, initialLink, onManagementUrl }: { [capacity, setCapacity] = useState(null), [capacityState, setCapacityState] = useState<"loading" | "ready" | "error">("loading"), [readState, setReadState] = useState<"loading" | "ready" | "error">(initialLink?.action === "preferences" ? "loading" : "ready"), - [capacityRetry, setCapacityRetry] = useState(0), [readRetry, setReadRetry] = useState(0), [email, setEmail] = useState(""), [language, setLanguage] = useState(locale), @@ -52,22 +51,37 @@ function SubscriptionView({ locale, initialLink, onManagementUrl }: { [copyingFeed, setCopyingFeed] = useState(null), [feedNotice, setFeedNotice] = useState<{ language: Locale; copied: boolean } | null>(null); const generation = useRef(0), readLink = useRef(initialLink), + capacityGeneration = useRef(0), capacityAbort = useRef(null), submitting = useRef(false), submissionAbort = useRef(null), noticeRef = useRef(null), formRef = useRef(null); - useEffect(() => { + const refreshCapacity = useCallback(() => { + const requestGeneration = ++capacityGeneration.current; + capacityAbort.current?.abort(); const controller = new AbortController(); - void fetch("/api/subscription/status", { signal: controller.signal }) + capacityAbort.current = controller; + void fetch("/api/subscription/status", { cache: "no-store", signal: controller.signal }) .then(async response => { const data = parseSubscriptionCapacity(await response.json()); if (!response.ok || !data) throw new Error("Unavailable"); - if (controller.signal.aborted) return; + if (controller.signal.aborted || requestGeneration !== capacityGeneration.current) return; setCapacity(data); setCapacityState("ready"); setNotice(current => current?.kind === "capacity-full" && !data.full ? null : current); }) - .catch(() => { if (!controller.signal.aborted) setCapacityState("error"); }); - return () => controller.abort(); - }, [capacityRetry]); + .catch(() => { if (!controller.signal.aborted && requestGeneration === capacityGeneration.current) setCapacityState("error"); }); + }, []); + useEffect(() => { + refreshCapacity(); + // Returning from confirmation on another device refreshes the public count. + const resume = () => { if (document.visibilityState === "visible") { setCapacityState("loading"); refreshCapacity(); } }; + window.addEventListener("focus", resume); + document.addEventListener("visibilitychange", resume); + return () => { + capacityGeneration.current += 1; capacityAbort.current?.abort(); + window.removeEventListener("focus", resume); + document.removeEventListener("visibilitychange", resume); + }; + }, [refreshCapacity]); useEffect(() => { const next = readLink.current; @@ -95,7 +109,6 @@ function SubscriptionView({ locale, initialLink, onManagementUrl }: { }, [attention]); useEffect(() => () => { generation.current += 1; submissionAbort.current?.abort(); }, []); - const refreshCapacity = () => { setCapacityState("loading"); setCapacityRetry(value => value + 1); }; const retryRead = () => { setReadState("loading"); setNotice(null); setReadRetry(value => value + 1); }; const copyFeed = async (feedLanguage: Locale) => { const requestGeneration = generation.current; @@ -118,8 +131,9 @@ function SubscriptionView({ locale, initialLink, onManagementUrl }: { ((!manage || confirmationMode) && capacityState !== "ready") || (!manage && capacity?.full === true); const submit = async (requested?: SubscriptionAction) => { const action = requested || (manage ? confirmationMode ? "confirm" : "preferences" : "signup"); - if (submitting.current || formLocked || terminal || action !== "unsubscribe" && !topics.length) return; - if (action !== "unsubscribe" && !formRef.current?.reportValidity()) return; + if (submitting.current || formLocked || terminal) return; + if (action === "confirm" && !confirmationMode) return; + if ((action === "signup" || action === "preferences") && (!topics.length || !formRef.current?.reportValidity())) return; if (action === "preferences" && subscriber?.status !== "confirmed") return; const requestGeneration = generation.current, link = manage; const controller = new AbortController(); @@ -128,12 +142,15 @@ function SubscriptionView({ locale, initialLink, onManagementUrl }: { const response = await fetch(link ? "/api/subscription/manage" : "/api/subscription", { method: "POST", headers: { "Content-Type": "application/json" }, signal: controller.signal, body: JSON.stringify(link - ? { ...link, action, locale: language, topics } + ? action === "confirm" ? { ...link, action } : { ...link, action, locale: language, topics } : { email, locale: language, topics, consent, website: "" }), }); const result = subscriptionOutcome(action, response.status, await response.json()); if (controller.signal.aborted || requestGeneration !== generation.current) return; - if (result.capacity) { setCapacity(result.capacity); setCapacityState("ready"); } + if (result.capacity) { + ++capacityGeneration.current; capacityAbort.current?.abort(); + setCapacity(result.capacity); setCapacityState("ready"); + } if (result.subscriber) { setSubscriber(result.subscriber); setLanguage(result.subscriber.locale); setTopics(result.subscriber.topics); } @@ -148,7 +165,7 @@ function SubscriptionView({ locale, initialLink, onManagementUrl }: { setManage(next); history.replaceState(null, "", `${location.pathname}?${new URLSearchParams({ id: next.id, token: next.token })}`); } - if (result.kind === "confirmed" || result.kind === "unsubscribed") refreshCapacity(); + if (result.kind === "confirmed" || result.kind === "unsubscribed") { setCapacityState("loading"); refreshCapacity(); } setAttention(value => value + 1); } catch { if (!controller.signal.aborted && requestGeneration === generation.current) { @@ -177,28 +194,31 @@ function SubscriptionView({ locale, initialLink, onManagementUrl }: { : zh ? "提交订阅申请" : "submitting your request"; const content = (current: Notice): [string, string, string] => { switch (current.kind) { - case "loading": return ["↻", current.action ? zh ? `正在${actionName(current.action)}…` : `We’re ${actionName(current.action)}…` : zh ? "正在读取订阅状态…" : "Loading your subscription…", zh ? "请稍候,当前操作完成前不会重复提交。" : "Please wait. Another request cannot be submitted while this one is in progress."]; - case "confirm-ready": return ["✉", zh ? "请确认你的邮箱" : "Confirm your email", zh ? "点击下方“确认订阅”才会生效。打开本页不会自动确认;名额以确认成功为准。" : "Select “Confirm subscription” below to activate it. Opening this page does not confirm your email; a place is allocated only after successful confirmation."]; - case "active": return ["✓", zh ? "订阅已生效" : "Your subscription is active", zh ? "可以修改语言与主题,也可随时退订所有邮件。" : "You can change your language and topics, or unsubscribe from all email at any time."]; - case "check-inbox": return ["✉", zh ? "订阅申请已受理" : "Your subscription request was received", zh ? "如果该邮箱需要确认且当前允许投递,请留意收件箱与垃圾邮件,并通过邮件中的入口主动确认。此提示不能证明邮件已发出或订阅已生效。待确认不占名额,邮件可能受当日额度影响而延迟。" : "If this address needs confirmation and delivery is currently allowed, check your inbox and spam folder and actively confirm through the email. This message does not prove that an email was sent or a subscription is active. Pending requests do not occupy a place; the daily allowance may delay email."]; - case "confirmed": return ["✓", zh ? "邮箱确认成功,订阅已生效" : "Email confirmed — your subscription is active", zh ? "现在可以设置语言与主题。每封周报都提供偏好与退订入口。" : "You can now set your language and topics. Every digest includes preference and unsubscribe links."]; - case "preferences-saved": return ["✓", zh ? "订阅偏好已保存" : "Your preferences are saved", zh ? "下方显示服务器保存的语言与主题,后续周报按这些偏好筛选。" : "The language and topics shown below are the saved values. Future digests use these preferences."]; + case "loading": return ["↻", current.action ? zh ? `正在${actionName(current.action)}…` : `We’re ${actionName(current.action)}…` : zh ? "正在读取订阅状态…" : "Loading your subscription…", zh ? "请稍候,不会重复提交。" : "Please wait; duplicate submission is blocked."]; + case "confirm-ready": return ["✉", zh ? "最后一步:确认订阅" : "One last step: confirm your subscription", zh ? "请点击下方按钮确认。打开邮件链接不会自动生效;确认成功才占名额。" : "Select the button below. Opening the email link does not confirm your subscription; successful confirmation allocates a place."]; + case "active": + case "confirmed": + if (subscriber?.subscriptionType === "test") return ["✓", zh ? "测试邮箱已确认" : "Test email confirmed", zh ? "仅供邮件验收,不占正式名额,也不接收正式周报。下方可管理测试偏好或退订。" : "For email testing only: no public place or public digest. You can manage test preferences or unsubscribe below."]; + return current.kind === "active" + ? ["✓", zh ? "订阅已生效" : "Your subscription is active", zh ? "可以修改语言与主题,也可随时退订所有邮件。" : "You can change your language and topics, or unsubscribe from all email at any time."] + : ["✓", zh ? "邮箱确认成功,订阅已生效" : "Email confirmed — your subscription is active", zh ? "现在可以设置语言与主题。每封周报都提供偏好与退订入口。" : "You can now set your language and topics. Every digest includes preference and unsubscribe links."]; + case "check-inbox": return ["✉", zh ? "下一步:打开邮件,再确认订阅" : "Next: open the email, then confirm on the page", zh ? "请查看收件箱与垃圾邮件:① 打开邮件链接;② 在页面点击“确认订阅”。成功提示才表示生效。此提示不证明发信,待确认不占名额,当日额度可能延迟邮件。" : "Check your inbox/spam: 1. Open the email link. 2. Select “Confirm subscription” on the page. Only its success message confirms activation. This notice does not prove email was sent. Pending requests do not reserve a place; the daily allowance may delay delivery."]; + case "preferences-saved": return ["✓", zh ? "订阅偏好已保存" : "Your preferences are saved", zh ? "服务器已保存下方语言与主题。" : "The language and topics below are saved on the server."]; case "unsubscribed": return ["✓", zh ? "已退订,后续邮件已停止" : "You are unsubscribed — future email is stopped", zh ? "此邮箱已停止后续确认邮件与周报投递。偏好编辑与重复退订已关闭;仍可使用 RSS 阅读新闻。" : "Future confirmation and digest sends to this address are stopped. Preference editing and repeated unsubscribe actions are closed. You can continue reading through RSS."]; case "suppressed": return ["⊘", zh ? "此邮箱已停止投递" : "Email delivery to this address is stopped", zh ? "此订阅当前已被停止投递,不能保存偏好或重新发信。可继续使用 RSS 阅读新闻。" : "This subscription is stopped. Preferences cannot be saved and email cannot be restarted here. You can continue reading through RSS."]; case "unconfirmed": return ["✉", zh ? "邮箱尚未确认,偏好暂不能保存" : "Confirm your email before saving preferences", zh ? "请使用确认邮件中的入口完成确认。如果链接已过期,可以重新申请确认邮件。" : "Use the link in your confirmation email to confirm. If the link has expired, you can request a fresh confirmation."]; - case "capacity-full": return ["!", zh ? "当前订阅名额已满" : "All subscription places are currently taken", zh ? "本站当前容量为 250 位已确认订阅者。此次没有完成新的订阅确认;待确认不保留名额。可刷新名额后再试,或先使用 RSS。" : "The site currently allows 250 confirmed subscribers. No new subscription was confirmed by this request; pending requests do not reserve a place. Refresh availability and try again, or use RSS."]; + case "capacity-full": return ["!", zh ? "当前订阅名额已满" : "All subscription places are currently taken", zh ? "250 个正式名额已满,本次未确认。待确认不占位;刷新名额后重试,或使用 RSS。" : "All 250 public places are taken. This request was not confirmed; pending requests reserve no place. Refresh availability and retry, or use RSS."]; case "unavailable": return ["i", zh ? "邮件订阅暂未开放" : "Email subscriptions are currently unavailable", zh ? "当前不接受新的公众订阅。RSS 仍开放,可以直接阅读全部新闻。" : "New public subscriptions are not being accepted. RSS remains available with the complete news feed."]; - case "capacity-error": return ["!", zh ? "暂时无法读取订阅名额" : "Availability could not be loaded", zh ? "当前人数尚未确认,不会显示为零。请重试读取;也可先使用 RSS。" : "The current count is unknown; it is not zero. Retry loading availability, or use RSS for now."]; - case "read-error": return ["!", zh ? "未能读取你的订阅状态" : "Your subscription could not be loaded", zh ? "此次读取没有执行保存或退订。请重试读取;若链接无效或过期,请从邮件重新打开,或申请新的确认邮件。" : "This read request did not save preferences or unsubscribe. Retry loading the subscription. If the link is invalid or expired, reopen it from your email or request a fresh confirmation."]; - default: return ["!", zh ? `未能确认${actionName(current.action)}的结果` : "The result could not be verified", zh ? "没有获得成功确认。请检查下方信息后重试;已有管理链接也可重新读取当前状态。若邮件链接无效或过期,请重新打开邮件或申请新的确认邮件。" : "Success was not confirmed. Check the fields below and try again; an existing management link can also reload the current state. If the email link is invalid or expired, reopen the email or request a fresh confirmation."]; + case "capacity-error": return ["!", zh ? "暂时无法读取订阅名额" : "Availability could not be loaded", zh ? "人数未知,不等于零。请刷新名额,或使用 RSS。" : "The count is unknown, not zero. Refresh availability or use RSS."]; + case "read-error": return ["!", zh ? "未能读取你的订阅状态" : "Your subscription could not be loaded", zh ? "未执行保存或退订。请重试读取;链接无效或过期时,重新打开邮件或申请确认。" : "Nothing was saved or unsubscribed. Retry loading; for an invalid or expired link, reopen the email or request a fresh confirmation."]; + default: return ["!", zh ? "未能核验操作结果" : "The result could not be verified", zh ? "未确认成功,请重试或重新读取管理状态。链接无效或过期时,重新打开邮件或申请确认。" : "Success was not verified. Retry or reload your management link. For an invalid or expired link, reopen the email or request a fresh confirmation."]; } }; - const showForm = !terminal && readState === "ready" && (manage - ? confirmationMode ? capacityState === "ready" : subscriber?.status === "confirmed" && notice?.kind !== "unconfirmed" + const showForm = !terminal && !confirmationMode && readState === "ready" && (manage + ? subscriber?.status === "confirmed" && notice?.kind !== "unconfirmed" : capacityState === "ready" && capacity?.enabled === true && !capacity.full && !["check-inbox", "unavailable"].includes(notice?.kind || "")); - const currentAction: SubscriptionAction = manage ? confirmationMode ? "confirm" : "preferences" : "signup"; + const currentAction: SubscriptionAction = manage ? "preferences" : "signup"; const buttonText = busy ? zh ? `正在${actionName(busy)}…` : `We’re ${actionName(busy)}…` - : confirmationMode ? zh ? "确认订阅" : "Confirm subscription" : manage ? zh ? "保存偏好" : "Save preferences" : zh ? "发送确认邮件" : "Send confirmation"; return ( @@ -209,23 +229,12 @@ function SubscriptionView({ locale, initialLink, onManagementUrl }: {

{manage ? zh ? "你的邮件订阅" : "Your email subscription" : zh ? "每周精选,送达邮箱。" : "A weekly perspective, by email."}

{zh ? "选择语言与主题,精选事件、研究与值得关注的方向。只发送有实质更新的周报。" : "Choose a language and topics for a considered selection of events, research and emerging directions. No empty editions."}

-
- {capacity ? <> -
-
{zh ? "已确认订阅 / 当前容量" : "Confirmed / capacity"}{capacity.confirmedCount} / {capacity.capacity}{zh ? "位订阅者 · 本站设置" : "subscribers · site limit"}
- {capacity.full ? zh ? "名额已满" : "Capacity reached" : zh ? `剩余 ${capacity.remaining} 个名额` : `${capacity.remaining} places remaining`} -
- -

{zh ? `名额以邮箱确认成功为准,待确认不占名额。免费发信上限 ${capacity.dailyLimit} 封/天,周报发送预算 ${capacity.weeklyLimit} 封/天;当日额度也用于确认邮件,不能保证同日送达。` : `Places are allocated only after email confirmation; pending requests do not occupy places. The free allowance is ${capacity.dailyLimit} emails/day, with a ${capacity.weeklyLimit}/day digest budget. Confirmations share the daily allowance; delivery on the same day is not guaranteed.`}

- :

{capacityState === "loading" ? zh ? "正在读取已确认人数与剩余名额…" : "Loading confirmed subscribers and remaining places…" : zh ? "订阅人数暂时未知,请重试读取。" : "Subscriber counts are temporarily unknown. Please retry."}

} - {capacityState === "error" &&

{zh ? "名额信息未能更新;已有数字是上次读取结果。已生效订阅仍可退订或管理。" : "Availability could not be updated; any figures shown are the last loaded values. Active subscriptions can still be managed or unsubscribed."}

} - -
{display && (() => { const [icon, title, body] = content(display); return

{title}

{body}

+ {confirmationMode && !terminal &&
} {(display.kind === "read-error" || display.kind === "error" && manage && !confirmationMode) && } {display.kind === "unconfirmed" || display.kind === "read-error" || display.kind === "error" && manage ? {zh ? "重新申请确认邮件" : "Request a fresh confirmation"} : null} {display.kind === "check-inbox" && } @@ -233,10 +242,22 @@ function SubscriptionView({ locale, initialLink, onManagementUrl }: {
; })()} +
+ {capacity ? <> +
+
{zh ? "已确认订阅 / 当前容量" : "Confirmed / capacity"}{capacity.confirmedCount} / {capacity.capacity}{zh ? "位订阅者 · 本站设置" : "subscribers · site limit"}
+ {capacity.full ? zh ? "名额已满" : "Capacity reached" : zh ? `剩余 ${capacity.remaining} 个名额` : `${capacity.remaining} places remaining`} +
+ +

{zh ? `仅确认成功占名额。免费限额 ${capacity.dailyLimit} 封/天,周报预算 ${capacity.weeklyLimit} 封/天;确认邮件共用额度,投递可能延迟。` : `Only confirmed subscriptions occupy places. Daily allowance: ${capacity.dailyLimit} emails, including confirmations; digest budget: ${capacity.weeklyLimit}/day. Delivery may be delayed.`}

+ :

{capacityState === "loading" ? zh ? "正在读取已确认人数与剩余名额…" : "Loading confirmed subscribers and remaining places…" : zh ? "订阅人数暂时未知,请重试读取。" : "Subscriber counts are temporarily unknown. Please retry."}

} + {capacityState === "error" &&

{zh ? "名额未能更新,数字为上次结果;仍可管理已生效订阅。" : "Availability failed to refresh; figures are from the last check. Active subscriptions remain manageable."}

} + +
{showForm &&
{ event.preventDefault(); void submit(currentAction); }}>
{zh ? "订阅信息" : "Subscription details"} -

{confirmationMode ? zh ? "确认周报订阅" : "Confirm your digest subscription" : manage ? zh ? "调整阅读偏好" : "Your reading preferences" : zh ? "设置你的周报" : "Make the digest yours"}

{zh ? "只留下你关心的内容。" : "Keep the subjects that matter to you."}

+

{manage ? zh ? "调整阅读偏好" : "Your reading preferences" : zh ? "设置你的周报" : "Make the digest yours"}

{zh ? "只留下你关心的内容。" : "Keep the subjects that matter to you."}

{!manage && } @@ -244,7 +265,7 @@ function SubscriptionView({ locale, initialLink, onManagementUrl }: {
{zh ? "关注主题" : "Your topics"}

{zh ? "至少选择一项,可随时调整。" : "Choose at least one. You can update this anytime."}

{categories.map(category => )}
{!manage && }
{zh ? "无需注册账号" : "No account required"}
- {manage && !confirmationMode && subscriber?.status === "confirmed" &&

{zh ? "不再接收邮件?" : "Prefer fewer emails?"}

{zh ? "退订将停止此邮箱的后续邮件,RSS 仍可继续使用。" : "Unsubscribing stops future email to this address. RSS remains available."}

} + {manage && subscriber?.status === "confirmed" &&

{zh ? "不再接收邮件?" : "Prefer fewer emails?"}

{zh ? "退订将停止此邮箱的后续邮件,RSS 仍可继续使用。" : "Unsubscribing stops future email to this address. RSS remains available."}

}
}

{zh ? "邮件由 Brevo 投递。当前匿名打开与点击统计不关联具体联系人,但仍可能包含像素与链接跟踪;本站不依据这些统计决定发信或判断已读。" : "Email is delivered by Brevo. Anonymous open and click statistics are not associated with individual contacts, but tracking pixels and tracked links may remain. We do not use these statistics to decide whether to send email or as proof that an email was read."}

@@ -253,7 +274,7 @@ function SubscriptionView({ locale, initialLink, onManagementUrl }: {
OPEN FEEDS

{zh ? "阅读,不止一种方式。" : "Read on your terms."}

-

{zh ? "全部已发布新闻,直接进入你的 RSS 阅读器。无需邮箱,也不占订阅名额。" : "All published stories, directly in your RSS reader. No email address or subscription place needed."}

+

{zh ? "全部已发布新闻,直接进入 RSS 阅读器。无需邮箱,不占名额。" : "All published stories in your RSS reader. No email or subscription place needed."}

{(["zh", "en"] as const).map(feedLanguage => { const feedUrl = `https://ag-ui.ai/${feedLanguage}/feed.xml`; const feedName = feedLanguage === "zh" ? "中文 RSS" : "English RSS"; diff --git a/docs/CURRENT_STATE.json b/docs/CURRENT_STATE.json index 6e760c2..c171483 100644 --- a/docs/CURRENT_STATE.json +++ b/docs/CURRENT_STATE.json @@ -13,7 +13,7 @@ "documentsAndCodeIntegrityVerified": true }, "production": { - "evidence": "Verified handoff and user statement; not re-read in this synchronization", + "evidence": "Sites metadata and source refreshed on 2026-10-13; private environment values were not read", "url": "https://ag-ui.ai", "publishedVersion": 12, "deploymentStatusAtHandoff": "succeeded", @@ -25,15 +25,16 @@ "confirmedSubscriberCapacity": 250, "dailySubmissionLimitAtHandoff": 300, "weeklySubmissionBudgetAtHandoff": 250, - "existingSchedulesPreserved": true + "existingSchedulesPreserved": true, + "metadataObservedAt": "2026-10-13T10:10:32Z" }, "github": { - "observedAt": "2026-10-13T09:18:00Z", + "observedAt": "2026-10-13T10:10:32Z", "repository": "AutoGenUI/ag-ui.ai", "defaultBranch": "main", - "mainAtObservation": "24edc498f5c135e283d773d946fcab3e99d239bd", + "mainAtObservation": "f670b23d32e6de1e4497f46113ef277fc3fc97cc", "syncBranch": null, - "pullRequest": "https://github.com/AutoGenUI/ag-ui.ai/pull/3", + "pullRequest": "https://github.com/AutoGenUI/ag-ui.ai/pull/4", "draftAtObservation": false, "mergedAtObservation": true, "codeAndCiVerificationCommit": "b0f0a40748474e3ce1f5918a7f9c4acff6f1a4f3", @@ -44,11 +45,11 @@ "requiredCheck": "verify", "branchProtectionPreserved": true, "historicalSyncBranch": "codex/ag-ui-ai-v12-sync", - "mergedAt": "2026-10-13T09:03:38Z", - "validatedPullRequestHead": "800cf6202bffc2ce2952b65bb813377f5398169b", - "validatedTree": "46d9bda9cead65231bb02bf7bded7792374a5b44", + "mergedAt": "2026-10-13T09:47:47Z", + "validatedPullRequestHead": "219dd12bbac3662662f942257c6c6b292f37f577", + "validatedTree": "e36d9422315db154c01f4f9eaa44ac0230506ded", "primaryMainBeforeSynchronization": "525ec78904ab7e2b97717adc7ac13bfd3f5f3db0", - "primaryMainAtObservation": "24edc498f5c135e283d773d946fcab3e99d239bd", + "primaryMainAtObservation": "f670b23d32e6de1e4497f46113ef277fc3fc97cc", "branchPolicy": "Keep only main after completed iterations; temporary PR branches allowed and cleaned after verified merge", "branchesAtLastCompletedCleanup": [ "main" @@ -260,7 +261,7 @@ }, "openEngineeringWork": [ "Track 8 moderate dependencies without weakening the audit gate", - "Preserve bundle budgets; baseline raw JavaScript margin is 1169 bytes", + "Preserve bundle budgets; raw JavaScript remains close to the unchanged 920000-byte gate", "Verify complete Brevo tracking disablement and keep bilingual disclosures accurate", "Recheck restricted-network accessibility without promising global access", "Observe real queued cancellation and additional bounce scenarios only under authorized acceptance", @@ -283,5 +284,48 @@ "accountDnsInventoryAndDkimSelectorVerified": false, "managedD1BackupRestoreCapabilitiesVerified": false, "documentationCommit": "Query GitHub; this snapshot does not self-reference its own commit" + }, + "releaseCandidate": { + "version": "1.0.0", + "phase": "local-verification-complete-awaiting-required-CI-and-publication", + "baseMain": "f670b23d32e6de1e4497f46113ef277fc3fc97cc", + "recordedAt": "2026-10-13T10:10:32Z", + "notes": "docs/releases/v1.0.0.md", + "formalReleaseEvidence": "Query the remote annotated v1.0.0 tag and GitHub Release; this dated candidate record does not assert deployment completion", + "tests": { + "base": 35, + "newsAndMail": 42, + "subscriptionUi": 10, + "localHttp": 8, + "total": 95 + }, + "browser": { + "viewports": [ + "390x844", + "1440x900" + ], + "syntheticLocalFlows": [ + "GET remains pending", + "language switch preserves signed confirmation and management links", + "active public confirmation 0 to 1", + "replayed confirmation does not allocate another place", + "test confirmation does not allocate a public place", + "expired confirmation shows error", + "preferences save", + "unsubscribe releases place", + "focus event refreshes previous tab aggregate" + ], + "focusTest": "Temporary CDP focus emulation; restored after verification", + "realMailSent": false, + "productionSubscriberDataRead": false + }, + "userConfirmationReport": { + "date": "2026-10-13", + "evidence": "User reported a new inbox and active webpage confirmation with public count 1; no email or signed URL retained" + }, + "schemaChanged": false, + "productionSecretsRead": false, + "productionConfigurationChanges": false, + "rolloutLimit": "New client requires subscriptionType; use matching client and Worker and refresh open pages after rollback" } } diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 6b3b332..e2c9a27 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -2,6 +2,12 @@ 本说明用于在同一个项目中持续迭代。它仅保留可公开的工程事实,未复制私人运营审计、账户、验收邮箱、生产数据或秘密值。机器可读记录见 [CURRENT_STATE.json](CURRENT_STATE.json)。 +## v1.0.0 发布接续 + +2026-10-13 用户报告已在邮件落地页主动确认,新邮箱正式人数显示 1。本轮修复最后一步可见性、邮件两步说明、签名页语言切换与回到原页刷新,并以隔离 D1/合成身份完成 95 项测试及手机/桌面浏览器验收。发布候选从 main `f670b23d32e6de1e4497f46113ef277fc3fc97cc`(维护 PR #4 已合并)继续,尚需 required CI、正常 PR 合并及 Sites 终态成功。 + +[版本说明](releases/v1.0.0.md) 规定最终凭据:GitHub 的带注释 `v1.0.0` tag/Release 与实际 Sites deployment。文档为时间快照,不能将候选版本号解释为已上线。旧章节中的“本轮”“尚未部署”描述此前 GitHub 同步/维护阶段;本次用户已授权正式发布,生产秘密、DNS、现有邮件开关/任务均保留,不额外发送验收邮件。Sites 元数据已重新核对生产仍为数字版本 12,源码 `6027589c…`;没有读取私有环境值。 + ## 证据时间与状态区分 - **生产交接快照**:2026-10-13 05:26:10 UTC(新加坡时间 13:26:10),来自交接版本 28。本轮读取了经过校验的上下文文件,没有重新读取生产环境或邮件账户。 diff --git a/docs/operations/deployment-maintenance.md b/docs/operations/deployment-maintenance.md index 76dbc62..a7b06d8 100644 --- a/docs/operations/deployment-maintenance.md +++ b/docs/operations/deployment-maintenance.md @@ -140,6 +140,8 @@ Sites 在上传新 Worker 前应用未执行的托管 schema 迁移。**部署 代码回退优先选择该项目已有、带完整 archive 的已知成功版本,由 Sites 正常部署;source-only 旧版本需先恢复其匹配产物。回退前验证它与当前 schema、测试隔离字段、队列状态、签名 generation 和邮件门槛兼容。现有两份 SQL 没有 down migration,切换 Worker **不会回滚 D1**。 +v1.0.0 候选没有 schema 迁移,但签名订阅响应新增 `subscriptionType`,新客户端遇到旧 API 缺少字段会保守拒绝成功提示。正式发布与回退都应使用同一个已验证版本的客户端和 Worker,完成后刷新浏览器再验收;混用缓存中的新客户端与旧 Worker 可能显示“未能确认结果”,并不证明数据库未更新。不要为了兼容提示解除测试隔离或恢复 GET 自动确认。GitHub `v1.0.0` tag 与 Sites 的递增数字版本是独立标识,按 [发布记录](../releases/v1.0.0.md) 查询实际部署,不能由 tag 名推断生产版本。 + 对于不兼容 schema,优先追加兼容修复并向前发布;确需恢复数据库时,由 owner 授权协调停止受影响的写入/投递任务、恢复与版本配套的数据、核对抑制和已提交邮件状态,再恢复运行。不得盲目清库、回放旧投递或恢复旧签名链接;不确定邮件必须向 provider 对账。切勿以“回滚”为理由应用 [2026-07-20 首发记录](2026-07-20-public-launch.md) 中删除域名、恢复私有 audience 的历史首发撤销流程。正常源码回退应保留现有域名、公开权限和生产配置。 ## 部署后与日常验收 diff --git a/docs/operations/email-subscriptions.md b/docs/operations/email-subscriptions.md index ea8ac2a..2d0d231 100644 --- a/docs/operations/email-subscriptions.md +++ b/docs/operations/email-subscriptions.md @@ -1,6 +1,6 @@ # 邮件订阅与投递维护 -核对日期:2026-10-13;实现依据 main `24edc498f5c135e283d773d946fcab3e99d239bd`。2026-10-13 05:26 UTC 的 v12 交接记录公众邮件、发信域名认证和真实验收已完成,发件地址 `news@ag-ui.ai`。本次只读代码/官方资料和文档更新没有重验收、发真实邮件或读取生产秘密。交接状态不是实时账户状态;不要用早期“待启用”示例把生产邮件关掉,也不要重复开启。 +核对日期:2026-10-13;历史维护基线为 main `24edc498f5c135e283d773d946fcab3e99d239bd`,本文同时说明从 `f670b23d32e6de1e4497f46113ef277fc3fc97cc` 继续的 [v1.0.0 候选修复](../releases/v1.0.0.md)。2026-10-13 05:26 UTC 的 v12 交接记录公众邮件、发信域名认证和真实验收已完成,发件地址 `news@ag-ui.ai`。本次文档更新没有读取生产秘密或代发真实邮件;用户后续确认反馈见下文。交接状态不是实时账户状态;不要用早期“待启用”示例把生产邮件关掉,也不要重复开启。 先读 [维护入口](README.md)、[owner 管理](owner-management.md);域名认证记录见 [DNS 维护](dns-maintenance.md),构建、绑定与发布见 [部署维护](deployment-maintenance.md)。 @@ -27,6 +27,12 @@ 持有签名链接的读者通过同源 `POST /api/subscription/manage` 执行 `confirm/preferences/unsubscribe/read`。打开 GET 页面或邮箱扫描不确认、不退订;读者主动点击动作才提交。确认原子取得名额,确认成功后改为管理链接;偏好只允许 confirmed 保存;退订取消仍为 queued 的邮件并释放名额。管理链接目前不设独立到期时间,其签名绑定 subscriber ID、创建时间与密钥;不能把真实链接放进日志、截图、工单或公开文档。 +v1.0.0 候选将中英邮件按钮明确为“打开确认页”,HTML/纯文本都说明两步:打开链接,再在页面点击“确认订阅”并等待成功提示。页面把主动确认按钮放在状态说明旁,确认前不展示偏好编辑大表单;成功后再展示服务器保存的偏好。签名不绑定原设备、IP 或登录会话,可在另一设备操作;仅打开链接不代表完成确认。 + +返回原标签页时,focus/visibility 事件只重新读取公开容量接口,不能自动确认、退订或发信。中英切换在订阅页保留 `id/token/action` 查询参数,不将这些参数带到新闻等其他路由。保留原网址文本后备和 24 小时确认时限,不因本次界面修复改变签名或代际。 + +有效签名管理/确认响应新增 `subscriptionType: "public"|"test"`,仅向对应链接持有人解释身份,不改变公开聚合或暴露邮箱。public 确认占正式名额;test 确认明确显示“测试邮箱已确认”,不占名额、不进入正式周报。新客户端要求身份字段合法,遇到旧 API 缺少字段时保守显示未能确认结果,不把测试确认说成正式订阅。发布或回退后应刷新页面,使客户端与 Worker 来自匹配版本;错误提示不代表数据库操作一定没有执行,重试前按有效管理链接核对状态,不重复申请来绕过测试隔离。 + 已确认重放保持幂等。pending 有效确认代际和到期时间不因重复申请跨日改变;已知到期代际允许开始下一周期并取消旧 queued,`sending/uncertain` 阻止自动轮换。测试 restart 改变创建 epoch 和代际,旧确认、偏好、退订请求不能覆盖新代际;前端 generation/abort 也防止旧请求覆盖现页面。维护时保留这些检查,不通过删除记录、换 run ID 或新建订阅来解除抑制。 ## 容量、免费额度与看板 @@ -61,7 +67,7 @@ 提供商事务回调目标 `https://ag-ui.ai/api/brevo/webhook`,认证方式 Bearer,与服务端 `BREVO_WEBHOOK_SECRET` 一致。官方 [安全 webhook 文档](https://developers.brevo.com/docs/secured-webhooks) 支持这一配置;回调事件名和 payload 以 [事务 webhook 文档](https://developers.brevo.com/docs/transactional-webhooks) 为准(2026-10-13 核对)。不把回调密钥写入 URL,不将 Brevo API key 复用为 webhook 认证。 -现有代码归一化大小写/下划线及 `invalid_email`,处理 Delivered、hardBounce、softBounce、spam、invalid、blocked、unsubscribed(也识别 error)。hard bounce、spam、invalid 立即 suppressed;unsubscribed 设置退订;取消相应 queued。soft bounce、blocked、error 标失败但不自动将订阅者永久抑制。原始 mailbox 与 provider message ID 或 `X-Mailin-custom: delivery:…` 必须关联既有记录,不凭任意 Delivered 建新记录;失败状态不能被迟到 Delivered 覆盖。回调处理在 [mail.ts](../../lib/news/mail.ts) 的 `mailWebhook`(第 550 行)。 +现有代码归一化大小写/下划线及 `invalid_email`,处理 Delivered、hardBounce、softBounce、spam、invalid、blocked、unsubscribed(也识别 error)。hard bounce、spam、invalid 立即 suppressed;unsubscribed 设置退订;取消相应 queued。soft bounce、blocked、error 标失败但不自动将订阅者永久抑制。原始 mailbox 与 provider message ID 或 `X-Mailin-custom: delivery:…` 必须关联既有记录,不凭任意 Delivered 建新记录;失败状态不能被迟到 Delivered 覆盖。回调处理见 [mail.ts](../../lib/news/mail.ts) 的 `mailWebhook`。 回调 401 先查安全认证配置是否匹配及近期轮换,不关闭验证;400 查事件格式;503 查 D1/运行故障。不要向正式 endpoint 投递人工“Delivered”去伪造验收。重复回调、回调早于 API 响应、错误拼写、错配记录等可用本地 fixture 测试;真实投诉/退信和带邮箱原始 payload 只留在安全运营记录。 @@ -77,6 +83,8 @@ 交接记录两轮真人验收、4 封测试邮件、用户退订、鉴权 Delivered 与退订后停发已通过;本轮没有重复,也没有真实 queued cancellation 或全部退信场景的证据。仓库 fixture 通过不能补成真实验收。需保持 [项目上下文](../PROJECT_CONTEXT.md) 中这些边界。 +2026-10-13 用户另行报告:用新邮箱完成公众确认后,页面已确认人数为 1。这是用户提供的生产行为证据,不是本次 agent 读取私有订阅库的结果,也不证明候选代码已部署或周报/全部回调已复验;不保存该邮箱、签名链接或邮件内容。 + ## 隐私、密钥轮换与故障处理 邮件记录含个人信息;管理员聚合不等于数据库匿名。签名管理链接本身具有访问权限,所有公开记录、PR、任务提示、截图和日志必须排除邮箱、密钥、会话与真实签名 URL。浏览统计和邮箱不关联;不利用 opens/clicks 作阅读证明或是否继续发信依据。交接接受并公开披露了 Brevo 匿名跟踪;匿名化不证明像素或改写链接已关闭。HTML/纯文本提供原网址文本可复制,但直接点击/自动识别仍可能经跟踪。全面关闭尚未验证,修改跟踪须同步中英订阅及隐私披露并重新验证,不能只删说明。 diff --git a/docs/releases/v1.0.0.md b/docs/releases/v1.0.0.md new file mode 100644 index 0000000..94490de --- /dev/null +++ b/docs/releases/v1.0.0.md @@ -0,0 +1,44 @@ +# AG-UI.AI v1.0.0 发布说明与验收条件 + +记录日期:2026-10-13。本文编写时为从 main `f670b23d32e6de1e4497f46113ef277fc3fc97cc` 继续的发布候选;用户已授权完成修复后正式发布并推送 tag。本文不预写尚未发生的合并 SHA、CI 或 Sites 部署成功。最终凭据见 GitHub 远端 tag、对应 PR/CI 和 [公开状态](../CURRENT_STATE.json),接续前重新查询。 + +## 本版解决的问题 + +此前邮件里的“确认邮箱”按钮实际只打开页面,仍需页面主动确认;原申请标签页的容量也可能仍显示旧值。邮件测试身份可以确认成功,却按设计不占正式名额。三者容易被误解为确认失效,设备切换本身不会使签名失效。 + +- 中英 HTML/纯文本邮件明确“打开确认页 → 点击确认订阅 → 等待成功”,保留 24 小时有效期及可复制的原网址。 +- 确认页突出最后一步按钮,成功后才提供偏好编辑;GET、预取或邮件扫描仍无确认/退订副作用。 +- 返回原标签页的 focus/visibility 只刷新公开名额,防止容量停留在确认前结果。 +- 订阅页切换中英文保留签名参数;离开该页不向其他页面传播参数。 +- 签名响应区分 public/test,测试页明确不进入正式周报、不占名额;不自动将测试身份转正式。 +- 保留容量原子控制、代际/epoch、防重复投递、unknown/uncertain 不自动重发、退订与抑制,以及现有生产配置与任务。 + +2026-10-13 用户报告用新邮箱完成公众确认后,页面已确认人数为 1。该报告说明公众确认路径有实际成功案例,不证明此候选已上线或取代完整验收;公开记录不保留邮箱、签名链接、邮件内容或订阅行。 + +## 发布前与发布后必须留下的证据 + +| 检查 | 验收条件 | +| --- | --- | +| 工程门槛 | 最新候选的测试、TypeScript、内容、lint、构建、链接和 required `verify`;保留 high 安全门槛及原 JS/CSS 预算,剩余告警准确记录 | +| 真实页面到本地 API | 隔离本地 D1、无真实密钥/投递;合成确认链接在另一浏览器上下文落地,GET 保持 pending,主动确认后正式计数 `0→1`、名额 `250→249`,重放不重复计数 | +| 测试与异常边界 | test 确认始终不占正式名额并显示测试身份;坏签名、到期、满额、代际更新、退订/抑制及未知提交不产生虚假成功或重发 | +| 页面交互 | 桌面/手机双语确认、签名页语言切换、原标签页重新聚焦刷新、偏好与退订;旧 API 缺少身份字段显示保守错误,刷新匹配版本后恢复 | +| GitHub | 经必需检查正常合入 main,远端 `v1.0.0` 指向已验证的 release commit;记录 PR、CI 和 tag 解析结果,不改写已有 tag | +| Sites | 原项目、原域名、公开权限、DB 与安全配置保持;记录真实 source commit、数字版本 ID、deployment ID/最终状态,以及 GitHub SHA 对应关系 | +| 上线验证 | 查询部署最终成功后,检查双语页面、RSS、公开容量、匿名管理隔离和网络边界;不将首页 200 当作数据库、真人投递或全球可访问性证明 | + +本地 mock 与用户既有反馈不能补成新增真实周报、退信或 webhook 验收。发布检查不主动申请订阅、drain、mail-test 或伪造 Delivered;需要额外真实投递时按 owner 明确授权和 [邮件维护](../operations/email-subscriptions.md) 执行。 + +## 版本与接续 + +GitHub 远端 `v1.0.0` tag 是本版源码的正式权威标识;`package.json` 的版本号只是工程元数据。可在 [仓库 tags](https://github.com/AutoGenUI/ag-ui.ai/tags) 查询,或只读执行: + +```sh +git ls-remote --tags origin 'refs/tags/v1.0.0' 'refs/tags/v1.0.0^{}' +``` + +带注释 tag 的原始 tag 对象 SHA 与解析后的 commit SHA不同,记录 release commit 时用解析结果。tag 还未创建时没有输出,不能因此假设已发布。 + +Sites 的数字版本序号由平台生成,与语义版本独立;v1.0.0 不意味着 Sites version 1。使用原项目的 `get_site` 和对应 `get_deployment_status` 查询实际部署;保存版本不等于部署成功,GitHub push 也不等于上线。完整步骤见 [部署维护](../operations/deployment-maintenance.md)。 + +本版不迁移 schema。新客户端要求签名响应中的身份字段,回退时必须恢复匹配的客户端/Worker并刷新页面;混合版本报错不代表确认事务未执行,不重复发信或修改 `is_test` 来消除提示。日常接手先核对实际远端、工作区、Sites 版本、运行状态和授权范围,继续以 main 迭代,保留公开 [维护手册](../operations/README.md) 与私有配置的边界。 diff --git a/lib/news/mail.ts b/lib/news/mail.ts index 53cdca0..3eb43f9 100644 --- a/lib/news/mail.ts +++ b/lib/news/mail.ts @@ -169,17 +169,19 @@ export async function manageSubscription( preferenceApplied = changed.meta.changes > 0; } const current = await db - .prepare(`SELECT locale,topics,status FROM subscribers WHERE id=? AND created_at=?${action === "confirm" ? " AND token_expires=?" : ""}`) + .prepare(`SELECT locale,topics,status,is_test FROM subscribers WHERE id=? AND created_at=?${action === "confirm" ? " AND token_expires=?" : ""}`) .bind(id, s.created_at, ...(action === "confirm" ? [s.token_expires] : [])) - .first<{ locale: string; topics: string; status: string }>(); + .first<{ locale: string; topics: string; status: string; is_test: number }>(); if (!current) throw new Error("Invalid or expired link"); + const { is_test, ...subscriber } = current; + const subscriptionType = is_test === 1 ? "test" : "public"; if (action === "confirm" && current.status === "pending" && s.is_test !== 1) - return capacityFull(db, c); + return { ...(await capacityFull(db, c)), subscriptionType }; if (action === "confirm" && current.status !== "confirmed") - return { status: "confirmation-unavailable", subscriberStatus: current.status }; + return { status: "confirmation-unavailable", subscriberStatus: current.status, subscriptionType }; if (action === "preferences" && !preferenceApplied) - return { status: "preferences-not-saved", subscriberStatus: current.status }; - return { ...current, token: await tokenFor(s, c.secret) }; + return { status: "preferences-not-saved", subscriberStatus: current.status, subscriptionType }; + return { ...subscriber, subscriptionType, token: await tokenFor(s, c.secret) }; } export async function queueDigest(db: Database, week: string) { if ( @@ -469,9 +471,12 @@ async function deliverQueued( ? "确认订阅 AG-UI.AI" : "Confirm your AG-UI.AI subscription"; const link = `https://ag-ui.ai/${d.locale}/subscribe?action=confirm&id=${s.id}&token=${await tokenFor(s, c.secret, "confirm")}`; - const confirmLabel = d.locale === "zh" ? "确认邮箱(24 小时内有效)" : "Confirm email (valid for 24 hours)"; - html = `

${subject}

${confirmLabel}

${copyableUrl(link)}`; - text = `${subject}\n\n${confirmLabel}\n${copyInstruction}\n${link}`; + const confirmLabel = d.locale === "zh" ? "打开确认页(24 小时内有效)" : "Open the confirmation page (valid for 24 hours)"; + const confirmSteps = d.locale === "zh" + ? "第一步:打开下面的确认页。第二步:在页面点击“确认订阅”,看到成功提示后才完成邮箱确认。仅点击邮件链接或打开页面不会确认订阅;无需在同一设备操作。" + : "Step 1: Open the confirmation page below. Step 2: Select “Confirm subscription” on that page and wait for the success message. Clicking this email link or opening the page does not confirm your subscription. You can complete both steps on another device."; + html = `

${subject}

${escapeHtml(confirmSteps)}

${confirmLabel}

${copyableUrl(link)}`; + text = `${subject}\n\n${confirmSteps}\n\n${confirmLabel}\n${copyInstruction}\n${link}`; } else { const ed = await db .prepare("SELECT payload FROM editions WHERE id=?") diff --git a/lib/news/subscription-ui.ts b/lib/news/subscription-ui.ts index 88eeda0..79f572f 100644 --- a/lib/news/subscription-ui.ts +++ b/lib/news/subscription-ui.ts @@ -11,6 +11,7 @@ export type SubscriptionCapacity = { }; export type SubscriberView = { status: "pending" | "confirmed" | "unsubscribed" | "suppressed"; + subscriptionType: "public" | "test"; locale: Locale; topics: Category[]; token?: string; @@ -27,6 +28,18 @@ function record(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; } +// Signed subscription links survive a language change only on their own page. +export function subscriptionLocaleHref(pathname: string | null, locale: Locale, alternate: Locale, search: string) { + const href = pathname === `/${locale}` || pathname?.startsWith(`/${locale}/`) + ? pathname.replace(`/${locale}`, `/${alternate}`) : `/${alternate}`; + if (pathname !== `/${locale}/subscribe`) return href; + const query = new URLSearchParams(search), signed = new URLSearchParams(); + for (const key of ["id", "token", "action"]) { + const value = query.get(key); + if (value) signed.set(key, value); + } + return signed.size ? `${href}?${signed}` : href; +} export function parseSubscriptionCapacity(value: unknown): SubscriptionCapacity | null { const v = record(value); if (!v || typeof v.enabled !== "boolean" || typeof v.full !== "boolean") return null; @@ -42,6 +55,7 @@ export function parseSubscriptionCapacity(value: unknown): SubscriptionCapacity export function parseSubscriberView(value: unknown): SubscriberView | null { const v = record(value); if (!v || !["pending", "confirmed", "unsubscribed", "suppressed"].includes(String(v.status)) || + (v.subscriptionType !== "public" && v.subscriptionType !== "test") || (v.locale !== "zh" && v.locale !== "en")) return null; let topics: unknown = v.topics; if (typeof topics === "string") { @@ -49,7 +63,7 @@ export function parseSubscriberView(value: unknown): SubscriberView | null { } if (!Array.isArray(topics) || !topics.length || topics.some(topic => !categories.includes(topic))) return null; return { - status: v.status as SubscriberView["status"], locale: v.locale, + status: v.status as SubscriberView["status"], subscriptionType: v.subscriptionType, locale: v.locale, topics: [...new Set(topics)] as Category[], ...(typeof v.token === "string" && /^[a-f0-9]{64}$/.test(v.token) ? { token: v.token } : {}), }; diff --git a/package-lock.json b/package-lock.json index ba7938b..79cceb5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ag-ui-ai", - "version": "0.1.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ag-ui-ai", - "version": "0.1.0", + "version": "1.0.0", "dependencies": { "@ag-ui/client": "0.0.57", "@ag-ui/core": "0.0.57", diff --git a/package.json b/package.json index 4642364..188ad89 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ag-ui-ai", - "version": "0.1.0", + "version": "1.0.0", "private": true, "engines": { "node": ">=22.13.0" diff --git a/tests/news.test.mjs b/tests/news.test.mjs index d0f73d8..7028d66 100644 --- a/tests/news.test.mjs +++ b/tests/news.test.mjs @@ -59,6 +59,65 @@ test('public capacity aggregates confirmed production subscribers only without p const dashboard=await operate(db,'dashboard',{},active); for(const key of Object.keys(summary)) assert.equal(dashboard.email[key],summary[key]); }); +test('public confirmation from the generated two-step email claims one place and reports its subscription type',async()=>{ + for(const locale of ['zh','en']) { + const db=database(); + await mockedMail(async sent=>{ + const signup=await subscribe(db,publicInput(`fixture-two-step-${locale}@example.test`,{locale}),active,`fixture-two-step-${locale}`); + assert.equal(signup.status,'check-inbox');assert.equal(sent.length,1); + const mail=sent[0],link=confirmationLink(mail); + const expectedCopy=locale==='zh' + ? [/第一步/,/第二步/,/打开确认页(24 小时内有效)/,/点击“确认订阅”/,/仅点击邮件链接或打开页面不会确认订阅/,/无需在同一设备操作/] + : [/Step 1:/,/Step 2:/,/Open the confirmation page \(valid for 24 hours\)/,/Select “Confirm subscription”/,/opening the page does not confirm your subscription/,/another device/]; + for(const copy of expectedCopy) { + assert.match(decodeHtml(mail.htmlContent),copy);assert.match(mail.textContent,copy); + } + assert.ok(mail.textContent.split('\n').includes(link.href)); + const pending=await db.prepare('SELECT status,is_test FROM subscribers').first(); + assert.equal(pending.status,'pending');assert.equal(pending.is_test,0); + assert.equal((await mailCapacity(db,active)).confirmedCount,0); + const input={id:link.searchParams.get('id'),token:link.searchParams.get('token'),action:'confirm'}; + const confirmed=await manageSubscription(db,input,active); + assert.equal(confirmed.status,'confirmed');assert.equal(confirmed.subscriptionType,'public'); + assert.doesNotMatch(JSON.stringify(confirmed),/email|is_test|@example\.test/); + const capacity=await mailCapacity(db,active); + assert.equal(capacity.confirmedCount,1);assert.equal(capacity.remaining,249); + assert.equal((await manageSubscription(db,input,active)).subscriptionType,'public'); + assert.equal((await mailCapacity(db,active)).confirmedCount,1); + const read=await manageSubscription(db,{id:input.id,token:confirmed.token,action:'read'},active); + assert.equal(read.status,'confirmed');assert.equal(read.subscriptionType,'public');assert.equal(read.locale,locale); + const unsubscribed=await manageSubscription(db,{id:input.id,token:confirmed.token,action:'unsubscribe'},active); + assert.equal(unsubscribed.subscriptionType,'public');assert.equal(unsubscribed.status,'unsubscribed'); + assert.equal((await mailCapacity(db,active)).remaining,250); + assert.equal(sent.length,1); + }); + } +}); +test('signed test confirmation and management report test identity without enrolling it publicly',async(t)=>{ + t.mock.timers.enable({apis:['Date'],now:Date.parse('2026-10-13T01:00:00.000Z')}); + const db=database();await seedDatabase(db); + await mockedMail(async sent=>{ + await mailTest(db,{action:'confirmation',runId:'fixture-type-isolation',locale:'zh',topics:['product']},acceptance); + assert.equal(sent.length,1); + const link=confirmationLink(sent[0]),id=link.searchParams.get('id'); + const confirmed=await manageSubscription(db,{id,token:link.searchParams.get('token'),action:'confirm'},active); + assert.equal(confirmed.status,'confirmed');assert.equal(confirmed.subscriptionType,'test'); + assert.doesNotMatch(JSON.stringify(confirmed),/email|is_test|@example\.test/); + const capacity=await mailCapacity(db,active); + assert.equal(capacity.confirmedCount,0);assert.equal(capacity.remaining,250); + const read=await manageSubscription(db,{id,token:confirmed.token,action:'read'},active); + assert.equal(read.subscriptionType,'test'); + const saved=await manageSubscription(db,{id,token:confirmed.token,action:'preferences',locale:'en',topics:['research']},active); + assert.equal(saved.subscriptionType,'test');assert.equal(saved.locale,'en'); + assert.equal((await queueDigest(db,'2026-10-12')).queued,0); + await subscribe(db,publicInput(acceptance.testRecipient),active,'fixture-type-public-retry'); + assert.equal(sent.length,1); + assert.equal((await db.prepare('SELECT is_test FROM subscribers WHERE id=?').bind(id).first()).is_test,1); + const optedOut=await manageSubscription(db,{id,token:confirmed.token,action:'unsubscribe'},active); + assert.equal(optedOut.subscriptionType,'test');assert.equal(optedOut.status,'unsubscribed'); + assert.equal((await mailCapacity(db,active)).remaining,250); + }); +}); test('full public signup collects nothing and the transactional insert closes a concurrent capacity race',async()=>{ for(const fillDuringBatch of [false,true]) { const db=database();await capacityFixture(db,fillDuringBatch?249:250); diff --git a/tests/subscription-ui.test.mjs b/tests/subscription-ui.test.mjs index f9b72fc..87d238e 100644 --- a/tests/subscription-ui.test.mjs +++ b/tests/subscription-ui.test.mjs @@ -1,9 +1,9 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { parseSubscriptionCapacity, parseSubscriberView, subscriptionOutcome } from '../lib/news/subscription-ui.ts'; +import { parseSubscriptionCapacity, parseSubscriberView, subscriptionOutcome, subscriptionLocaleHref } from '../lib/news/subscription-ui.ts'; const capacity={enabled:true,confirmedCount:250,capacity:250,remaining:0,full:true,dailyLimit:300,weeklyLimit:250}; -const subscriber=(status,patch={})=>({status,locale:'en',topics:'["research"]',token:'a'.repeat(64),...patch}); +const subscriber=(status,patch={})=>({status,subscriptionType:'public',locale:'en',topics:'["research"]',token:'a'.repeat(64),...patch}); test('capacity rejection never becomes a successful signup or confirmation',()=>{ for(const action of ['signup','confirm']) { @@ -55,3 +55,30 @@ test('server reads must contain recognized status and usable saved preferences', assert.equal(parseSubscriptionCapacity({enabled:true}),null); assert.deepEqual(parseSubscriberView(subscriber('unsubscribed')).topics,['research']); }); +test('signed subscription identity distinguishes tests from public subscriptions',()=>{ + const result=subscriptionOutcome('confirm',200,subscriber('confirmed',{subscriptionType:'test'})); + assert.equal(result.kind,'confirmed');assert.equal(result.subscriber.subscriptionType,'test'); + assert.equal(parseSubscriberView(subscriber('confirmed')).subscriptionType,'public'); + for(const subscriptionType of [undefined,'unknown',true]) + assert.equal(subscriptionOutcome('confirm',200,subscriber('confirmed',{subscriptionType})).kind,'error'); +}); +test('confirmation and management links preserve their capability when switching languages',()=>{ + const id='00000000-0000-4000-8000-000000000001',token='a'.repeat(64); + for(const action of ['confirm',null]) { + const query=new URLSearchParams({id,token});if(action)query.set('action',action); + const href=subscriptionLocaleHref('/en/subscribe','en','zh',query.toString()); + const url=new URL(href,'https://example.test'); + assert.equal(url.pathname,'/zh/subscribe'); + assert.equal(url.searchParams.get('id'),id);assert.equal(url.searchParams.get('token'),token); + assert.equal(url.searchParams.get('action'),action); + assert.equal(subscriptionLocaleHref('/zh/subscribe','zh','en',url.searchParams.toString()),`/en/subscribe?${query}`); + } +}); +test('language navigation never carries signed subscription query to another page',()=>{ + const query='id=synthetic-reader&token=synthetic-token&action=confirm&source=synthetic'; + for(const path of ['/en','/en/news','/en/news/example','/en/subscribe/other']) + assert.equal(subscriptionLocaleHref(path,'en','zh',query),path.replace('/en','/zh')); + assert.equal(subscriptionLocaleHref('/enough','en','zh',query),'/zh'); + assert.equal(subscriptionLocaleHref(null,'en','zh',query),'/zh'); + assert.equal(subscriptionLocaleHref('/en/subscribe','en','zh','source=synthetic'),'/zh/subscribe'); +});