You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit d284ff3
Browse filesBrowse the repository at this point in the historyBrowse files
fix(plugin-auth,plugin-email)!: the invitation mail warns once on an untranslated role label and states when it expires (#26072)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XGEtunNCqKwXhkVr8xm2ygt)_
Part of #25802 (the in-repo half; the card stays open for the cloud
control plane's i18n composition, routed to that lane)
Clause-②: no (narrowing: the built-in `auth.invitation` rows declare a
new required variable, `expiresAt`, so a `sendTemplate` of
`auth.invitation` that names no `expiresAt` is now refused with
`MISSING_VARIABLES` where it used to be sent)
Metadata-protocol: none (a built-in template row's declared variables,
read by the email service at send time; no spec key, authorable spelling
or stored shape moves)
## What changes
Two fixes to the `auth.invitation` mail. Both are named on the card (the
second is from its follow-up comment 6398750019).
1. **The role label: a warn when it stays untranslated.**
`invitationRoleLabel` (`plugin-auth/src/invitation-role-label.ts`) now
logs one `warn` per send when the mail's locale is not English and a
role token's label is still its authored (English) text. The warn names
the locale, the tokens and what to check: `[AuthManager] invitation
mail: no 'zh-CN' label was found for the membership role(s) 'member';
the mail names the authored (English) label instead. …`. Before this,
only a read that threw was reported. The read that answers but holds no
zh-CN entry, which is the reported shape (`以 Member 的身份`, no log line),
was silent. Each degradation gets one warn: a thrown read keeps its own
warn and gets no second one, and a token outside the option list keeps
its existing warn and is not counted again. An en-US or en-GB mail reads
the authored label as its own text and logs nothing. The mail is always
sent.
2. **The expiry.** The sender (`auth-manager-organization-plugin.ts`)
passes `invitation.expiresAt` as a new **required** template variable,
`expiresAt`, as an ISO-8601 instant. The engine has no conditionals, so
every body states the expiry, and a send without it is refused
(`MISSING_VARIABLES: expiresAt`) instead of being sent with a dangling
"expires on ." sentence. If the value does not read as a date, the
sender leaves it out and logs one `warn` that names the invitation id
and says the mail will be refused, so the invitation can be resent. Each
of the four built-in rows (en-US, zh-CN, ja-JP, es-ES), in both the HTML
and the text body, ends with one sentence that renders it as `{{
expiresAt | datetime:long }}`. This uses the date formatter the template
layer already has (ADR-0053 Phase 2). The date reads in the row's locale
and in the reference timezone the send names. The `long` style prints
the zone name, so a reader never has to guess the zone. With UTC the
sentences render as 「此邀请将于 2031年3月4日 UTC 05:06:07 过期。」, "This invitation
expires on March 4, 2031 at 5:06:07 AM UTC.", 「この招待の有効期限は 2031年3月4日
5:06:07 UTC です。」 and "Esta invitación caduca el 4 de marzo de 2031,
5:06:07 UTC.".
## BREAKING
This is for code that sends `auth.invitation` itself. The built-in
organization-invitation sender already passes the value, and it is the
only sender in this repository (`git grep` at `origin/main`).
- **Before.** A send naming `inviter`, `organization`, `role`,
`acceptUrl` and `appName` was delivered.
- **Now.** The same send is refused with `MISSING_VARIABLES: expiresAt`.
- **Fix (one line).** Add the invitation's expiry instant to the send's
`data` as `expiresAt`.
The changeset carries `adr-0087: not-required
(no-migration-prescription)`, and `check-adr-0087-registration` accepts
it.
## Measurement (the order's first step, at `origin/main` bbf7061)
- **objectstack ships the zh-CN labels:**
`platform-objects/src/apps/translations/zh-CN.objects.generated.ts` has
`sys_member.fields.role.options` = 所有者 / 管理员 / 受托管理员 / 成员 / 外部成员.
- **objectstack reads them through the file-based adapter.** A throwaway
probe (not committed) booted a `LiteKernel` with the real
`I18nServicePlugin` (the `registered file-based i18n adapter` the cloud
log names) and the real `PlatformObjectsPlugin`, then called
`invitationRoleLabel('member', 'zh-CN', { getI18n })`:
| i18n service configuration | `getLocales()` | label | warns on main |
|:---|:---|:---|:---|
| default | en, zh-CN, ja-JP, es-ES | 成员 | 0 |
| `defaultLocale: 'en', fallbackLocale: 'en'` | en, zh-CN, ja-JP, es-ES
| 成员 | 0 |
| same + `supportedLocales: ['en']` | en | **Member** | **0** |
| same + `supportedLocales: ['en', 'zh']` | en, zh | **Member** | **0**
|
| same + `supportedLocales: ['en', 'zh-CN']` | en, zh-CN | 成员 | 0 |
| `defaultLocale: 'zh-CN', fallbackLocale: 'en'` | en, zh-CN, ja-JP,
es-ES | 成员 | 0 |
The card's symptom (`Member` and no log line) reproduces only when the
service's reported locales do not name `zh-CN`. `translateMetaList`
builds its bundle from `getLocales()`. So the label read here is
correct. The cloud control plane's i18n composition is the likely cause:
a declared `supportedLocales`, or bundle registration. I could not read
that half from this repository, and I have reported it to the seat. This
PR adds the warn, which names exactly that check.
## Tests
Red first. The new and updated pins, run against the BASE implementation
(the three source files restored from bbf7061, the tests kept):
plugin-auth `invitation-role-label.test.ts` had **5 failed / 19
passed**, and plugin-email (`auth-templates-locales`,
`builtin-auth-render-identity`, `plain-text-faces`) had **11 failed / 90
passed**. The source was then restored from HEAD and proven by `git diff
HEAD` empty plus a blob-hash match on all three files.
Green, at head ee295b9 (after merging `origin/main`):
- `pnpm --filter @objectstack/plugin-auth --filter
@objectstack/plugin-email run test`: plugin-auth **197/197 files, 3753
passed, 10 skipped**; plugin-email **38/38 files, 699 passed**. The lock
VERDICT was command-exit 0.
- `run typecheck` on both packages (tsc, examples config,
`check:test-typecheck`): exit 0.
- `pnpm exec eslint --no-inline-config` over every `.ts` file this PR
touches: exit 0.
- **Dogfood battery** (owed by the `(narrowing)` arm): `pnpm --filter
@objectstack/dogfood test` at ee295b9, after its `^...` closure build.
The lock VERDICT was command-exit 0. **Test Files 296 passed, 3 skipped
(299); Tests 2523 passed, 18 skipped (2541)**. 0 failures.
What the pins hold:
- **The role label.** zh-CN with the platform bundles gives 成员 and no
warn. A service whose locales omit zh-CN gives `Member` and one warn,
whose meta names the role, the locale and that a service was present. No
service at all on a zh-CN mail gives one warn: the existing pin asserted
silence, and it now asserts the one warn. A list warns once, naming
every English token. An out-of-list token is not double-counted. A
thrown read produces exactly one invitation warn in total. en-US and
en-GB mails produce no warn.
- **The expiry.** A `Date` and a string both reach the template as the
ISO instant. A missing or unreadable expiry is left out of the data,
with one warn naming `inv1` and the refusal. A readable expiry logs no
expiry warn. In every locale, a send without `expiresAt` is refused with
`MISSING_VARIABLES: expiresAt` and nothing reaches the transport. Per
locale, both faces carry the expiry sentence for a UTC send. A send
naming `Asia/Shanghai` moves the wall-clock and names `GMT+8`. A zh-CN
mail carries no English expiry text. The existing
every-fact-in-both-faces pin now covers `expiresAt`.
- **The byte-identity pin** (`builtin-auth-render-identity.test.ts`) now
renders with `timezone: 'UTC'`, so its bytes never follow the machine's
zone. Only the four `auth.invitation` rows' html/text digests moved.
Every other template's 28 rows kept their digests under the UTC render.
## Gates
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 69 families at ee295b9 (the same
set as the first round). All were run there, with exit codes captured
before any pipe. `--ran` reconciles: **69 derived, 68 run, 1
NOT-MEASURED, 0 UNRUN**.
- NOT MEASURED: `pnpm check:dual-build-cjs-loads` (exit 3, PREREQUISITE
NOT MET: it needs a full-repo build). CI runs it.
- The changeset gates, read on the committed narrowing changeset:
`check-adr-0087-registration --base origin/main` exit 0 (it accepts the
`no-migration-prescription` exemption), `check-changeset-no-major` exit
0 and `check-empty-changeset` exit 0.
- CI families: see this PR's check-runs. Check Changeset reads this
body's `Clause-②` line.
## Acceptance notes
- **The same dangle in the TTL templates (follow-up observation, not
this PR).** `auth.password_reset`, `auth.magic_link` and
`auth.two_factor_otp` declare `expiresInMinutes` `required: false` and
print "expires in {{expiresInMinutes}} minutes" unconditionally. A send
without the value renders a dangling sentence. `auth.account_invitation`
already declares its own `required: true`.
- **No timezone is passed.** The invitation send passes no reference
timezone, like every other auth send today. So the wall-clock is the
server's zone, named in the sentence by the `long` style. Passing the
invitee's or the organization's timezone would be a separate change to
the locale ladder, which this card does not ask for.
- **The two i18n providers handle a base-language locale differently.**
This was observed while measuring and is not filed. With
`supportedLocales: ['en', 'zh']`,
`FileI18nAdapter.getTranslations('zh')` answers `{}`: it matches
exactly, and the bundles are keyed `zh-CN`. The kernel's in-memory
provider resolves `zh` to the zh-CN bundle. So the same declaration
translates on one provider and not on the other. The measurement is a
composed-kernel probe only, with no public-door reading, so per the
report contract it is a note here and not a card. Owner: none.
- `#24154` stays closed. Its ruling (the role in a Chinese invitation
reads 「成员」) is what the label read already delivers, and this PR makes
the residual miss loud.
---
_Generated by [Claude
Code](https://claude.ai/code/session_011Ed1XCLEcJnL1JmrzvGgmW)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(plugin-auth,plugin-email)!: the invitation mail says when its role label stayed English, and states when the invitation expires
7
+
8
+
**The role label.** The organization invitation mail names the invitee's role by its `sys_member.role` option label in the mail's locale (`成员`, `メンバー`, `Miembro`). When the i18n service answered without a translation for that locale, the mail named the English label (`Member`) inside a Chinese, Japanese or Spanish sentence, and logged nothing. Only a read that threw was reported. That happens when there is no i18n service, when the service holds no bundle for the locale, or when its reported locales (`i18n.supportedLocales`, when declared) do not name the locale as the bundles spell it. Now one `warn` is logged for the send: `[AuthManager] invitation mail: no 'zh-CN' label was found for the membership role(s) 'member'; …`. It names the locale, the roles and what to check. The mail is still sent as before. A mail in English, or a read that already warned because it threw, logs nothing extra.
9
+
10
+
**The expiry.** The `auth.invitation` mail never said when its link stops working (better-auth's window, 48 hours by default), so an invitation opened late failed at the accept page with no warning. The sender now passes `invitation.expiresAt` as a new template variable, `expiresAt`, as an ISO-8601 instant. Each of the four built-in bodies, HTML and text, ends with one sentence that renders it with the `datetime:long` formatter. The date reads in the mail's locale and the send's reference timezone, with the zone named: 「此邀请将于 2031年3月4日 UTC 05:06:07 过期。」 / "This invitation expires on March 4, 2031 at 5:06:07 AM UTC.". The template engine has no conditionals, so every body states the expiry, and the built-in rows declare `expiresAt` **required**. A send without it is refused (`MISSING_VARIABLES: expiresAt`) and is never sent with a dangling sentence. The built-in sender always has better-auth's expiry. If the value does not read as a date, the sender leaves it out and logs one `warn` naming the invitation id and saying the mail will be refused, so the invitation can be resent. A tenant or app override of `auth.invitation` keeps its own bodies and variables, and can use `{{ expiresAt | datetime:long }}`.
11
+
12
+
Clause-②: no (narrowing: the built-in `auth.invitation` rows declare a new required variable, `expiresAt`, so a `sendTemplate` of `auth.invitation` that names no `expiresAt` is now refused with `MISSING_VARIABLES` where it used to be sent)
13
+
14
+
Metadata-protocol: none (a built-in template row's declared variables, read by the email service at send time; no spec key, authorable spelling or stored shape moves)
15
+
16
+
**BREAKING** for code that sends `auth.invitation` itself (the built-in organization-invitation sender already passes the value).
17
+
18
+
-**Before.**`auth.invitation` declared no expiry, and a send naming `inviter`, `organization`, `role`, `acceptUrl` and `appName` was delivered.
19
+
-**Now.** The same send is refused with `MISSING_VARIABLES: expiresAt`.
20
+
-**Fix (one line).** Add the invitation's expiry instant to the send's `data` as `expiresAt`.
21
+
22
+
<!-- adr-0087: not-required (no-migration-prescription) a send-time refusal of an `auth.invitation` mail that names no `expiresAt`: no authorable key, spelling or stored shape moves, the email-template schema is unchanged, and no stored row is read, rewritten or converted. The repair is a value only the sender holds (the invitation's expiry), which no ledger entry can derive. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id covers this verdict (not already-registered); and the change is a template row's declaration, not a TS interface (not runtime-interface-only). -->
0 commit comments