Skip to content

Commit 8a88901

Browse files
fix(plugin-auth,plugin-email): the invitation mail names the invitee's role by its label in the mail's language (#24193)
Fixes #24154 Clause-②: yes (widening) The zh-CN, ja-JP and es-ES organization invitation mails printed the stored membership role inside a translated sentence, for example 「邀请您以 member 的身份加入」. This PR takes triage's route 1: the sender passes the role's label in the mail's locale, and the four bodies keep interpolating the one `role` variable. ## What changed - **Sender (`plugin-auth`).** `sendInvitationEmail` now passes `{{role}}` as the `sys_member.role` option label, in the locale the mail is written in: `成员`, `メンバー`, `Miembro` or `Member`, never `member`. That locale comes from the existing rule, unchanged: the invitee's stored `sys_user.locale` first, then the deployment default, and never the inviter's request. A new internal helper, `invitationRoleLabel` (`src/invitation-role-label.ts`), reads the label. The package entry does not export it. The helper passes the `sys_member` object's own `role` field (`SysMember.fields.role`, which is `BUILTIN_MEMBERSHIP_ROLE_OPTIONS`) to `@objectstack/rest`'s `translateMetaList`, over the runtime's i18n service. Both `/meta` transports run that same function before they serve an object document. So the mail shows the label the console shows: the platform bundle, a host bundle laid over it, or a tenant's Studio translation of the option. `en-US` is matched to the `en` bundle, as the console's object read does. There is no second label table; the label is read, and `platform-objects` is not edited. - **How the sender reaches the i18n service.** `AuthManager` held no handle on the i18n service. `AuthManagerOptions` gains `getI18n?: () => II18nService | undefined`, a lazy accessor shaped like `getTenancy` and `getAuditSink`. `AuthPlugin.init` wires it from `ctx.getService('i18n')`; if that throws, the accessor answers `undefined`. Resolving lazily means the order in which plugins register does not matter. - **Stated fallbacks.** - A stored role list (`admin,member`; better-auth joins an array of roles with `,`) gets one label per token, joined with `Intl.ListFormat` in the mail's locale: `管理员和成员`, `Admin and Member`. - A token with no option label is named as stored, and one `warn` names it. - With no i18n service, the mail names the authored label, `Member`. - If the i18n read throws, the mail names the authored labels and one `warn` reports it. - The label step never throws and never blocks the mail. - **Templates (`plugin-email`).** The four `auth.invitation` text bodies gain the role clause their HTML bodies already carry. I chose this by the card's Done-when ("the text and HTML bodies name the same facts") under route 1, rather than dropping the clause from the HTML bodies. The `role` variable keeps its name and type, and its declaration gains a `description` saying it carries the label. The HTML bodies are unchanged: their render-identity digests did not move. ## Landing sites, compared with the claim's file surface - `packages/plugins/plugin-auth/src/auth-manager.ts`: changes in the `sendInvitationEmail` region, which is in the claim. Two changes sit outside it: the `getI18n` member on `AuthManagerOptions` (near `getAuditSink`), and two import lines. In the region, the `emailLocaleArg(undefined, storedLocale)` call moved, with its #14319 comment, above the `try`, so that the label and the send read one locale value. The rule itself is unchanged. Nothing between `:2990` and `:3080` is touched, which is where #24061 is working. - `packages/plugins/plugin-auth/src/auth-plugin.ts` (**outside the claim's surface**): the `getI18n` accessor, 11 lines in `init()`'s `authConfig`, next to `getAuditSink`. The sender has no other way to reach the i18n service. - `packages/plugins/plugin-auth/src/invitation-role-label.ts` (**new file, outside the claim's surface**): the helper above, kept out of `auth-manager.ts` so that file's diff stays small. - `packages/plugins/plugin-email/src/templates/auth-templates.ts`: `auth.invitation` only (the four text bodies and the variable declaration). - Tests and one changeset. ## Clause-② — measured on the built entry declarations I built both packages before the change (at base `cb8756b00`) and after it, then compared their `dist/*.d.ts`: - `@objectstack/plugin-email` `dist/index.d.ts`: byte-identical (`diff` exit 0). The template text and the variable description are values, not types. - `@objectstack/plugin-auth` `dist/rate-limit-storage.d.ts`: identical. - `@objectstack/plugin-auth` `dist/index.d.ts`: one addition, `AuthManagerOptions.getI18n?: () => II18nService | undefined`, plus `II18nService` in the contracts import. `invitationRoleLabel` does not appear in it. The claim declared `Clause-②: no`. That holds for `role` carrying a label: the variable had no description, and no document says it is the stored value. The way the sender reaches the i18n service, though, adds one optional key to an exported options interface, and that widens the public surface. So this PR declares `Clause-②: yes (widening)`, grades `@objectstack/plugin-auth` `minor` and `@objectstack/plugin-email` `patch`, and the changeset says the same. The seat may rule otherwise. ## Tests The full suites ran at `bcf9a3331`, and the typecheck re-ran at that commit too. HEAD `bc00a6e50` adds only the changeset. - `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2` (the whole package): `Test Files 154 passed (154)`, `Tests 3056 passed | 10 skipped (3066)`. - `pnpm --filter @objectstack/plugin-auth typecheck`: exit 0, including `check:test-typecheck: OK`. - `pnpm --filter @objectstack/plugin-email test`: `Test Files 34 passed (34)`, `Tests 557 passed (557)`. `pnpm --filter @objectstack/plugin-email typecheck`: exit 0. - New pins: - `plugin-auth/src/invitation-role-label.test.ts`, 14 cases. It uses the kernel's in-memory i18n loaded with the real platform bundles, and every expected label is an independent literal: - a zh-CN mail names `成员`; ja-JP `メンバー`; es-ES `Miembro`; en-US `Member`; - the label follows the invitee's language, never the inviter's header; - a tenant's authored translation of the option reaches the mail; - a role list becomes `管理员和成员` and `Admin and Member`; - an unknown token is named as stored with one warn, and inside a list only that token falls back; - with no i18n service, the authored label; - with a throwing i18n service, the authored label and one warn; - an empty value stays empty; - `AuthPlugin` resolves `getI18n` lazily, and answers `undefined` when no i18n service is registered. - `plugin-email/src/auth-templates-locales.test.ts`: a zh-CN render carries the label in both the HTML and the text body and no raw role value. In each of the four locales, both bodies name every declared variable's value. - Updated pins: - `auth-email-locale.test.ts`: `data.role` is now `Member`. - `plain-text-faces.test.ts`: the English text body now ends `… on Acme as Member.` - `builtin-auth-render-identity.test.ts`: the four `auth.invitation` **text** digests were re-captured. I computed each one by hand from its expected string with `sha256`, and each matched the run's received digest. The subject and HTML digests did not change. ## Ablation (one run, on the sender) The fix was committed first. Then `node scripts/ablation-replace.mjs` rewrote `role: roleLabel,` back to `role: invitation.role || '',` in `auth-manager.ts`. It hit the anchor once, `x1 -> x0` and replacement `x0 -> x1`, and the blob changed from `9666c2511` to `5f40ebb6b`. With that change, `invitation-role-label.test.ts` went red: 10 failed and 4 passed of 14. The zh-CN pin failed with `expected 'member' to be '成员'`. The 4 that stayed green are the unknown-token case, the empty value, and the two wiring pins, all of which hold without the label step. After restoring, the blob matches HEAD (`9666c2511`), `git diff HEAD` is empty, `git status` is clean, and the file re-ran 14 of 14 green. The test imports `./auth-manager` from source, so no build stood between the change and the run. ## Acceptance notes - **Boundary: the label's locale and the template row's locale can differ.** The sender reads the label in the locale the send names. The email service then renders that locale's row if one exists, and the `en-US` row if not. For a locale with no shipped row (`fr-FR`, say), the mail renders `en-US`, while the label follows the i18n chain for `fr-FR`. That chain falls back to the deployment's declared fallback locale, which is English in the default boot. For the four shipped locales the two always agree. This is the same answer the console gives a `fr-FR` reader, so I did not narrow it here. - **Not merged with `main`.** Three commits landed on `origin/main` after the base (`030a7b83e`, `62c4441ef`, `727091c82`). None touches `plugin-auth`, `plugin-email`, `rest`'s translation helpers, `platform-objects` or `core`'s in-memory i18n. CI tests the merge ref. - Fixtures that pin rendering rather than the sender keep their stored-value role data, for example `role: 'admin'` in `auth-templates-locales.test.ts`'s `DATA` and in the render-identity `DATA`. They prove escaping and per-locale prose, not the sender's value. ## Gates I derived the list with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on this diff (69 commands) and ran it at HEAD `bc00a6e50`. 69 of 69 exit 0; `--ran` reconciliation: `69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN`. `pnpm check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET` (exit 3), because packages outside this diff had no `dist/`. After a full workspace build (`turbo run build --filter='!@objectstack/docs'`, 75 of 76 tasks cached), it exited 0. `test:repo` and the repo-wide scans (`pnpm lint`) were not run locally; CI runs them. --- _Generated by [Claude Code](https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1489952 commit 8a88901

10 files changed

Lines changed: 534 additions & 25 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/plugin-auth': minor
3+
'@objectstack/plugin-email': patch
4+
---
5+
6+
fix(plugin-auth,plugin-email): the organization invitation mail names the invitee's role by its label in the mail's language (`成员`, `メンバー`, `Miembro`, `Member`), not by the stored value `member`
7+
8+
Clause-②: yes (widening)
9+
10+
- **What changed.** The zh-CN, ja-JP and es-ES invitation mails printed the stored membership role inside a translated sentence: 「邀请您以 member 的身份加入」. The sender now passes `{{role}}` as the `sys_member.role` option label in the locale the mail is written in, the same label the console shows for that option. It reads the label through the runtime's i18n service and the console's own object translation path, so a tenant's translation of the option, made in Studio, reaches the mail too. The four bodies keep interpolating the one `role` variable; its name and type are unchanged, and its declaration now says it carries the label.
11+
- **The plain-text part names the role too.** The four text bodies gain the role clause their HTML bodies carry, so both parts of the mail name the same facts. For example, the English text is now `Dana (dana@example.com) invited you to join Northwind on Acme as Member.`
12+
- **Stated fallbacks.** A role list (`admin,member`) is labelled role by role and joined as a list in the mail's language (`管理员和成员`). A value with no option label is named as stored, and one warning names it. With no i18n service, or one that fails, the mail names the option's authored English label, never an empty string.
13+
- **New option (widening).** `AuthManagerOptions.getI18n?: () => II18nService | undefined` is a lazy accessor for the `i18n` service, read when the invitation is sent. `AuthPlugin` wires it from the kernel. An embedder that builds `AuthManager` itself and omits it gets the authored labels.
14+
- **What changes for you.** If you have overridden `auth.invitation` and interpolate `{{role}}`, it now receives the label, not the stored value.

‎packages/plugins/plugin-auth/src/auth-email-locale.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -827,7 +827,9 @@ describe("#14641 — an invitation reads the INVITEE's own sys_user.locale", ()
827827
expect(sent[0].relatedId).toBe('inv1');
828828
expect(sent[0].organizationId).toBe('o1');
829829
expect(sent[0].data.organization.name).toBe('Northwind');
830-
expect(sent[0].data.role).toBe('member');
830+
// #24154 — the role travels as its option LABEL; with no i18n service
831+
// wired here, the authored one (`invitation-role-label.test.ts` owns it).
832+
expect(sent[0].data.role).toBe('Member');
831833
});
832834

833835
it('a placeholder address is still refused BEFORE any recipient read', async () => {

‎packages/plugins/plugin-auth/src/auth-manager.ts‎

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import type { ExecutionContext } from '@objectstack/spec/kernel';
5555
import type {
5656
ApiKeyLifecyclePolicy,
5757
IEmailService,
58+
II18nService,
5859
ISmsService,
5960
PrincipalSession,
6061
PrincipalSessionMintOptions,
@@ -159,6 +160,7 @@ import {
159160
interpolatePhoneSms,
160161
resolvePhoneSmsTemplateBody,
161162
} from './phone-sms-texts.js';
163+
import { invitationRoleLabel } from './invitation-role-label.js';
162164
// commit 35e94c96b — the stored rung of the ruled locale ladder reuses the messaging
163165
// seam's normalizer rather than growing a second one. `normalizeRecipientLocale`
164166
// is the platform's ONE reader of a value at rest in `sys_user.locale`, and its
@@ -896,6 +898,20 @@ export interface AuthManagerOptions extends Partial<AuthConfig> {
896898
*/
897899
getAuditSink?: () => AuthEventAuditSurface | undefined;
898900

901+
/**
902+
* Accessor for the runtime's `i18n` service, read by the organization
903+
* invitation mail to name the invitee's membership role by its option LABEL
904+
* in the mail's locale (`成员`, not `member`) — the label the console shows
905+
* for the same option, read through the same service, so a tenant's authored
906+
* translation reaches the mail too.
907+
*
908+
* Lazy for the same reason `getTenancy` is: the service is registered on the
909+
* kernel after the AuthManager is constructed, and every call happens at
910+
* send time. Omitted, or resolving to `undefined` (no i18n service) → the mail
911+
* names the option's authored label (`Member`), never the stored value.
912+
*/
913+
getI18n?: () => II18nService | undefined;
914+
899915
/**
900916
* Optional structured logger (the kernel `ctx.logger`) for best-effort
901917
* bookkeeping surfaces such as the ADR-0093 membership reconciler. Omitted →
@@ -3620,29 +3636,40 @@ export class AuthManager {
36203636
// the deployment default, which is the documented floor rather than a
36213637
// failure.
36223638
const storedLocale = await this.storedRecipientLocale({ email: recipientEmail });
3639+
// #14319 — DELIBERATELY no request argument, and the only one of
3640+
// the five sends without one. better-auth DOES hand this callback
3641+
// a `ctx.request`, but it is the INVITER's: stamping their
3642+
// browser language onto the invitee's mail would recreate this
3643+
// very card one seat over. The 2026-09-02 ruling enumerates
3644+
// signup, sign-in and password reset — sends where the requester
3645+
// IS the recipient — and the superseded 2026-08-13 ruling named
3646+
// invitations as its own counterexample. #13881's ruling item 3
3647+
// fixes the chain as RECIPIENT locale → deployment default, so
3648+
// the inviter direction stays rejected: what #14641 added above
3649+
// is the invitee's own column, never the inviter's header.
3650+
const mailLocale = this.emailLocaleArg(undefined, storedLocale);
3651+
// #24154 — `{{role}}` names the membership role by its option LABEL
3652+
// in the mail's own locale (`成员`, not `member`): every body of the
3653+
// template interpolates this one value, so the zh-CN mail stops
3654+
// printing the stored enum inside a Chinese sentence. Never throws;
3655+
// a value with no label is named as stored, with one warn.
3656+
const roleLabel = await invitationRoleLabel(invitation.role || '', mailLocale.locale, {
3657+
getI18n: this.config.getI18n,
3658+
warn: (msg, meta) =>
3659+
this.config.logger ? this.config.logger.warn(msg, meta) : console.warn(msg, meta),
3660+
});
36233661
try {
36243662
await emailService.sendTemplate({
36253663
template: 'auth.invitation',
36263664
to: recipientEmail,
3627-
// #14319 — DELIBERATELY no request argument, and the only one of
3628-
// the five sends without one. better-auth DOES hand this callback
3629-
// a `ctx.request`, but it is the INVITER's: stamping their
3630-
// browser language onto the invitee's mail would recreate this
3631-
// very card one seat over. The 2026-09-02 ruling enumerates
3632-
// signup, sign-in and password reset — sends where the requester
3633-
// IS the recipient — and the superseded 2026-08-13 ruling named
3634-
// invitations as its own counterexample. #13881's ruling item 3
3635-
// fixes the chain as RECIPIENT locale → deployment default, so
3636-
// the inviter direction stays rejected: what #14641 added above
3637-
// is the invitee's own column, never the inviter's header.
3638-
...this.emailLocaleArg(undefined, storedLocale),
3665+
...mailLocale,
36393666
data: {
36403667
inviter: {
36413668
name: inviter?.user?.name ?? inviter?.user?.email ?? 'A teammate',
36423669
email: inviter?.user?.email ?? '',
36433670
},
36443671
organization: { name: org?.name ?? invitation.organizationId },
3645-
role: invitation.role || '',
3672+
role: roleLabel,
36463673
acceptUrl,
36473674
appName: this.getAppName(),
36483675
},

‎packages/plugins/plugin-auth/src/auth-plugin.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,17 @@ export class AuthPlugin implements Plugin {
511511
return undefined;
512512
}
513513
},
514+
// [#24154] The `i18n` service, resolved LAZILY at send time: the
515+
// invitation mail names the membership role by the option label the
516+
// console reads through this same service. `getService` throws for an
517+
// unregistered service; the mail then names the authored label.
518+
getI18n: () => {
519+
try {
520+
return ctx.getService<II18nService>('i18n');
521+
} catch {
522+
return undefined;
523+
}
524+
},
514525
};
515526

516527
// ADR-0069 D2 — cross-node rate-limit counters, backed by the kernel

0 commit comments

Comments
 (0)