Repository navigation
Commit 8a88901
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
File tree
- .changeset
- packages/plugins
- plugin-auth/src
- plugin-email/src
- templates
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
827 | 827 | | |
828 | 828 | | |
829 | 829 | | |
830 | | - | |
| 830 | + | |
| 831 | + | |
| 832 | + | |
831 | 833 | | |
832 | 834 | | |
833 | 835 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
55 | 55 | | |
56 | 56 | | |
57 | 57 | | |
| 58 | + | |
58 | 59 | | |
59 | 60 | | |
60 | 61 | | |
| |||
159 | 160 | | |
160 | 161 | | |
161 | 162 | | |
| 163 | + | |
162 | 164 | | |
163 | 165 | | |
164 | 166 | | |
| |||
896 | 898 | | |
897 | 899 | | |
898 | 900 | | |
| 901 | + | |
| 902 | + | |
| 903 | + | |
| 904 | + | |
| 905 | + | |
| 906 | + | |
| 907 | + | |
| 908 | + | |
| 909 | + | |
| 910 | + | |
| 911 | + | |
| 912 | + | |
| 913 | + | |
| 914 | + | |
899 | 915 | | |
900 | 916 | | |
901 | 917 | | |
| |||
3620 | 3636 | | |
3621 | 3637 | | |
3622 | 3638 | | |
| 3639 | + | |
| 3640 | + | |
| 3641 | + | |
| 3642 | + | |
| 3643 | + | |
| 3644 | + | |
| 3645 | + | |
| 3646 | + | |
| 3647 | + | |
| 3648 | + | |
| 3649 | + | |
| 3650 | + | |
| 3651 | + | |
| 3652 | + | |
| 3653 | + | |
| 3654 | + | |
| 3655 | + | |
| 3656 | + | |
| 3657 | + | |
| 3658 | + | |
| 3659 | + | |
| 3660 | + | |
3623 | 3661 | | |
3624 | 3662 | | |
3625 | 3663 | | |
3626 | 3664 | | |
3627 | | - | |
3628 | | - | |
3629 | | - | |
3630 | | - | |
3631 | | - | |
3632 | | - | |
3633 | | - | |
3634 | | - | |
3635 | | - | |
3636 | | - | |
3637 | | - | |
3638 | | - | |
| 3665 | + | |
3639 | 3666 | | |
3640 | 3667 | | |
3641 | 3668 | | |
3642 | 3669 | | |
3643 | 3670 | | |
3644 | 3671 | | |
3645 | | - | |
| 3672 | + | |
3646 | 3673 | | |
3647 | 3674 | | |
3648 | 3675 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
511 | 511 | | |
512 | 512 | | |
513 | 513 | | |
| 514 | + | |
| 515 | + | |
| 516 | + | |
| 517 | + | |
| 518 | + | |
| 519 | + | |
| 520 | + | |
| 521 | + | |
| 522 | + | |
| 523 | + | |
| 524 | + | |
514 | 525 | | |
515 | 526 | | |
516 | 527 | | |
| |||
0 commit comments