Skip to content

Commit fbafb85

Browse files
feat(plugin-email,plugin-auth): identity mail persists to sys_identity_email, the deployment-level sibling of sys_email (18.x step 3, 8b-0d′) (#24587)
Closes #24531 Clause-②: yes (widening) 18.x step 3 (8b-0d′), per ruling of record 6257510973 on #24377 (letter Q2 A) and ruling records 6266750393 and 6267391172 on #15194. **Merge gate:** this PR stays a draft until #24522's upgrade harness has merged. It then merges `main` and lands only when R1–R4 hold with the harness green. ## What changed - **New object `sys_identity_email`** (`packages/platform-objects/src/audit/sys-identity-email.object.ts`). It has the same fields and indexes as `sys_email`; a new test holds the two equal. It declares `systemFields: { tenant: false }`, so it has **no organization column**. It is `managedBy: 'append-only'`, exposes `apiMethods: ['get', 'list']`, and requires `manage_platform_settings` to read, like `sys_job`, because its rows carry reset and sign-in links. `EmailServicePlugin` registers it beside `sys_email`. - **Contract:** `SendEmailInput.identityMail?: boolean` and `SendTemplateInput.identityMail?: boolean` (`packages/spec/src/contracts/email-service.ts`). - `true` sends the row to `sys_identity_email`. - Absent sends it to `sys_email`, unchanged. - `identityMail: true` together with `organizationId` is refused as `VALIDATION_FAILED`. The refusal is recorded as a rejected row in the sibling, with no organization written. - **plugin-email: one pipeline over both logs.** `email-log-objects.ts` names the two logs once. The following now read and write the log the row lives in: - `send()` and `sendTemplate()`; - the persistence shim (`insert` / `update` / `readHeadersJson` take the log as an optional last argument); - the `email.send.async` worker; - the `afterInsert` outbox drain hook; - the boot outbox sweep, which sweeps each log on its own; - attachment reclamation and the internal-header readback. Job payloads work like this: - A delivery or reclaim job for a sibling row carries `object: 'sys_identity_email'`. - A `sys_email` job keeps the exact 18.0 payload (`{ rowId }`). A job with no `object` reads `sys_email`. - A job naming anything else is refused at `error` and touches no row. A reclaim job of that kind stalls; it is not re-armed. - **plugin-auth:** five identity sends now say `identityMail: true` and name no organization, in every posture: `auth.verify_email`, `auth.password_reset`, `auth.account_invitation`, `auth.magic_link` and `auth.email_change_notice`. The now-unused `authMailOrganizationArg` / `derivableWriteOrganizationId` are removed. `auth.invitation` (organization invitation) is unchanged tenant mail. - **Inventory:** `sys_identity_email` gets its row next to `sys_email`'s in `organization-ownership-inventory.ts` (`column-drop`, `PMAIN`). - **Regenerated with the repo's tooling:** - `scripts/platform-object-tenancy-census.json` (`gen:platform-object-tenancy-census`: 84 registered, 40 out of reach). - The tenant-audit census (`tenant-audit-census.mjs --write`). Three write sites in plugin-email now name their object at run time: the persistence insert, the persistence update and the reclaim update, so 160 decidable became 157 and 71 undecidable became 74. Two hand-written prose numbers on that page are updated to match. - **Docs:** `content/docs/plugins/packages.mdx` names the sibling. ## 18.0-row behaviour (expand-first) Nothing is moved, deleted or re-stamped. These keep working from `sys_email`: - organization-less identity rows an 18.0 install wrote there under a wall; - identity rows stamped with the Default Organization under `single`; - the `{ rowId }` jobs an 18.0 process queued for them. The boot sweep still delivers them, the worker still delivers a job with no `object` from `sys_email`, and reclamation of a job with no `object` still reads `sys_email`. Pinned against an 18.0-shaped fixture (rows seeded into `sys_email` with `related_object: 'sys_user'` and no organization, plus a raw `{ rowId }` job) in `email-plugin.outbox-sweep.test.ts`. The #24522 harness is not merged yet; the seat will ask for a `main` merge and a harness run when it is. ## What the step-5 ceremony owes (debt recorded, nothing done here) - **`sys_email`: move the identity rows** to `sys_identity_email`, without `organization_id`. These are rows about a person: `related_object = 'sys_user'`, or no related object (the magic link). Under a wall they are organization-less. Under `single` an 18.0 install stamped them with the Default Organization, so the ceremony must decide whether those move too. The ruling names "walled rows anchored on `sys_user` or on nothing". - Move only terminal rows (`sent` / `failed`). - A queued row's pending `{ rowId }` job reads `sys_email`. After a move it would find no row, and the message would never be delivered. - A pending reclaim job with no `object` reads `sys_email`. After a move it would take the row-gone branch and **delete** that row's attachment content. Either rewrite those jobs' payloads to add `object`, or move rows only after their reclaim window has passed. - **`sys_activity`:** rows whose `object_name` / `source_object` is `sys_email` for a moved row point at a missing record. Re-point them or delete them; Q3 A's null-activity deletion already covers the organization-less ones. - **`sys_email.organization_id` NOT NULL (8e):** only once the move leaves zero NULL rows. - **Step 7 (dual reads):** the "no `object` ⇒ `sys_email`" default stays correct for tenant jobs. Once no 18.0 job can still be pending, nothing identity-specific is left to remove. - **Outside this repo:** cloud's wall-test control is rewritten against the sibling (cloud lane, per the ruling). ADR-0131 D7's Q2 A note says "has not shipped" and needs its dated update; that is Tier H, and `docs/adr/**` is outside this card. ## Verification (head `3bc88f5f1`, after merging `main` at `ba4afcc23`) - `pnpm --filter @objectstack/plugin-email typecheck` + `vitest run`: 34 files, 571 passed. - `pnpm --filter @objectstack/plugin-auth typecheck` + `vitest run`: 165 files, 3168 passed, 10 skipped. That run was before the merge; `main`'s two commits do not touch plugin-auth. The targeted auth mail suites were re-run on the final head: 77 passed. - `pnpm --filter @objectstack/platform-objects typecheck` + `vitest run`: 78 files, 1188 passed. The `src/audit` subset was re-run on the final head: 27 passed. - `@objectstack/spec`: `platform-object-names.test.ts` and `email-service.test.ts` passed (15); `typecheck` passed. - `@objectstack/cli` unit: `organization-ownership-inventory.test.ts`, 25 passed. - Dogfood (real stack): `signup-organization-census.dogfood.test.ts`, 3 passed. It now asserts: - the sign-up's verification mail is in `sys_identity_email` and absent from `sys_email`; - the sibling's registered schema has no `organization_id`, with `sys_email`'s having it as the non-vacuity control. - **Fresh showcase boot** (`pnpm dev -- --fresh -p 38531`, sign-in as the seeded admin): - `POST /auth/request-password-reset` produced one `sys_identity_email` row, `Reset your ObjectStack password`, `sent`, `related_object: sys_user`, no `organization_id` key. - A sign-up after opening the audience produced one `sys_identity_email` row, `Verify your ObjectStack email address`, `sent`, no `organization_id` key. - `POST /auth/organization/invite-member` produced one `sys_email` row, `Dev Admin invited you to Default Organization`, `related_object: sys_invitation`, stamped `organization_id`. - `sys_email` held no identity mail. The server was stopped by its recorded PID and the port is free. - **Ablations.** Each mutation went through `scripts/ablation-replace.mjs`, then the tests ran, then the file was restored to the HEAD blob with `git diff HEAD` empty. - A1, route every send to `sys_email`: 4 red. - A2, the worker ignores the job's `object`: 2 red. - A3, a job with no `object` reads the sibling: 11 red, including the 18.0 `{ rowId }` pin. - A4, reclaim always reads `sys_email`: 2 red. - A5, drop `identityMail` from the verification send (plugin-auth): 3 red. The first A5 attempt was refused by the tool (the replacement was a substring of the anchor) and measured nothing. - A6, the same mutation in plugin-auth's `dist/`: the dogfood census went 2 red, then 3 green after the restore and rebuild, with `ablation-dist-preflight` present / absent. The first A6 attempt's mutated build failed on a TS no-overlap error, so that reading is void. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, 126 derived. All 126 were run on `3bc88f5f1` with exit codes captured before any pipe: 126 exit 0. `--ran` reconciliation: 126 derived, 126 run, 0 NOT MEASURED. Plus `check:dev-prereqs` exit 0. The 51 artifact-roster families, the CI-only jobs and the type-check lanes are CI's. ## Acceptance notes - `sys_identity_email` gets no translation bundle entries, like `sys_platform_setting`; its labels are English in every locale. - `check:keyed-text-bounds` reads only a literal `fields`, so the sibling spells its fields out instead of `fields: SysEmail.fields`. The equality test is what keeps the row shape single-sourced. - Out-of-row attachment storage keys keep the `sys_email/attachments/` prefix for sibling rows. Keys travel in the row and the job, so nothing reads the prefix as a table name. - Not measured: whether a non-administrator can list `sys_email` rows (it has no `requiredPermissions`). 18.0 identity rows there carry reset links. The sibling is gated. --- _Generated by [Claude Code](https://claude.ai/code/session_016CvQEdxxYYVa8C8AjKZBtL)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5e80009 commit fbafb85

27 files changed

Lines changed: 1111 additions & 291 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/platform-objects': minor
4+
'@objectstack/plugin-email': minor
5+
'@objectstack/plugin-auth': minor
6+
'@objectstack/cli': patch
7+
---
8+
9+
feat(plugin-email,plugin-auth): identity mail — verification, password reset, magic link, the email-change notice, an account invitation — is recorded in `sys_identity_email`, a deployment-level sibling of `sys_email` with no organization column
10+
11+
Clause-②: yes (widening)
12+
13+
ADR-0131 D7, ruling of record 6257510973 (letter Q2 A). Expand-first: this release adds the new log and its write path, and keeps reading every row and job an 18.0 install left behind. It moves and deletes nothing.
14+
15+
- **The new object.** `sys_identity_email` (`@objectstack/platform-objects`, registered by `EmailServicePlugin`) has `sys_email`'s fields, so the row shape is the same, and `systemFields: { tenant: false }`, so it has no organization column. It is append-only, readable over `get` / `list`, and gated by `manage_platform_settings` (like `sys_job`), because its rows carry password-reset and sign-in links.
16+
- **The contract.** `SendEmailInput` and `SendTemplateInput` (`@objectstack/spec/contracts`) gain `identityMail?: boolean`. With `true`, the mail service writes the row to `sys_identity_email`. Without it, the row goes to `sys_email`, as before. `identityMail: true` together with an `organizationId` is refused as `VALIDATION_FAILED` and recorded as a rejected row in `sys_identity_email`. The organization is not written.
17+
- **One pipeline, both logs.** The queue worker, the outbox drain hook, the boot outbox sweep, attachment reclamation and the internal-header readback read a row from the log it lives in. A delivery or reclaim job for a `sys_identity_email` row carries `object: 'sys_identity_email'` beside `rowId`. A job with no `object` reads `sys_email`, which covers every job an 18.0 process queued. A job naming anything else is refused at `error` and touches no row. `EmailPersistence.insert` / `update` / `readHeadersJson` take the log as an optional last argument. A custom implementation that ignores it keeps every row in its one store. `@objectstack/plugin-email` exports `TENANT_EMAIL_OBJECT`, `IDENTITY_EMAIL_OBJECT`, `EMAIL_LOG_OBJECTS`, `isEmailLogObject` and `EmailLogObject`.
18+
- **plugin-auth.** The five identity sends say `identityMail: true` and name no organization, under every posture. Before, they carried the Default Organization under `single` and nothing under a wall. The organization invitation (`auth.invitation`) is tenant mail. It still carries its organization and still lands in `sys_email`.
19+
- **What a reader of `sys_email` sees.**
20+
- **Before.** Identity mail was a `sys_email` row: stamped with the Default Organization under `single`, organization-less under a wall.
21+
- **Now.** New identity mail is a `sys_identity_email` row. Rows already in `sys_email` stay there and are still delivered, swept and reclaimed.
22+
- **Remedy.** A report, export or query that looks for verification or reset mail reads both logs. For example: `ql.find('sys_identity_email', { where: { related_object: 'sys_user', related_id: userId } })` beside the same query on `sys_email`.
23+
- **Inventory.** `os migrate organization-ownership`'s inventory lists `sys_identity_email` as deployment-level (column-drop fate, nothing to attribute). Moving the identity rows already in `sys_email` is an 18.x upgrade-ceremony step, not part of this release.

‎content/docs/permissions/tenant-audit-census.mdx‎

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ reproduce them. Where it disagrees, it disagrees on the page:
189189
| :--- | :--- | ---: |
190190
| 175 write call sites | quoted in the merged changeset | **231** |
191191
| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **31** more whose options argument is unreadable |
192-
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **160 of 231** decidable, **71** undecidable |
192+
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **157 of 231** decidable, **74** undecidable |
193193
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 128 decidably elevated, 0 decidably not, 95 undecidable |
194194
| 141 and 132, two independent re-derivations | the card that filed this work | — |
195195

@@ -201,7 +201,7 @@ at any commit.
201201

202202
Two structural facts do plausibly widen this reading against any hand or regex
203203
one, and both are counted in the generated tables below: the 40 sites reached
204-
through an erased (`any`) receiver, and the 52 that name their object through a
204+
through an erased (`any`) receiver, and the 51 that name their object through a
205205
`const` rather than inline. An instrument that read either the way a person does
206206
would report a smaller number and would not say so.
207207

@@ -224,9 +224,9 @@ cannot read, and they are neither in nor out.
224224
| what | count |
225225
| :--- | ---: |
226226
| write call sites on the application surface | **231** |
227-
| …whose object name is statically decidable | 160 |
228-
| …whose object name is chosen at run time | 71 |
229-
| …against an object with tenancy ENABLED | 159 |
227+
| …whose object name is statically decidable | 157 |
228+
| …whose object name is chosen at run time | 74 |
229+
| …against an object with tenancy ENABLED | 156 |
230230
| …against an object that declares tenancy off | 1 |
231231
| threading a tenant context | 171 |
232232
| PROVABLY carrying none (options read, no context key) | **8** |
@@ -244,10 +244,10 @@ cannot read, and they are neither in nor out.
244244
| receiver erased, placed by an `object: string` PARAMETER | 5 |
245245
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |
246246

247-
| object name spelled inline | 108 |
248-
| object name spelled through a `const` | 52 |
247+
| object name spelled inline | 106 |
248+
| object name spelled through a `const` | 51 |
249249
| object name is an `object: string` parameter | 7 |
250-
| object name is some other run-time expression | 64 |
250+
| object name is some other run-time expression | 67 |
251251

252252
### Subtractions the census could NOT defend — enforced
253253

@@ -297,13 +297,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
297297
their values are not compared. The reasoning, and the measurement behind it,
298298
are in `scripts/check-tenant-audit-census.mjs`.
299299

300-
Measured on 2026-10-19 at `2623d6d6c`.
300+
Measured on 2026-10-19 at `ebe85c6d3`.
301301

302302
| corpus scale (not enforced) | count |
303303
| :--- | ---: |
304-
| tracked non-test sources scanned | 670 |
304+
| tracked non-test sources scanned | 672 |
305305
| engine-shaped types recognised | 79 |
306-
| declared objects in the registry | 116 |
306+
| declared objects in the registry | 117 |
307307
| same-named calls subtracted as non-engine | 166 |
308308

309309
{/* END GENERATED: tenant-audit-census */}

‎content/docs/plugins/packages.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,7 @@ metadata it names.
438438

439439
**Email Plugin** — Outbound email channel and templates.
440440

441-
- **Features**: Provider transports (Postmark, Resend), a minimal mustache-style `{{placeholder}}` template renderer, delivery tracking on `sys_email`
441+
- **Features**: Provider transports (Postmark, Resend), a minimal mustache-style `{{placeholder}}` template renderer, delivery tracking on `sys_email` (identity mail — verification, password reset, sign-in links — on its deployment-level sibling `sys_identity_email`)
442442
- **When to use**: Transactional and workflow-driven email
443443

444444
### @objectstack/plugin-webhooks

‎docs/audits/2026-08-tenant-audit-write-call-sites.counts.md‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.
3434
| Measure | Value |
3535
|---|---:|
3636
| Write call sites | 231 |
37-
| Object name statically decidable | 160 |
38-
| Object name chosen at run time | 71 |
39-
| Against a tenancy-enabled object | 159 |
37+
| Object name statically decidable | 157 |
38+
| Object name chosen at run time | 74 |
39+
| Against a tenancy-enabled object | 156 |
4040
| Against an object declaring tenancy off | 1 |
4141
| Threading a tenant context | 171 |
4242
| Provably carrying none | 8 |
@@ -90,13 +90,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
9090
their values are not compared. The reasoning, and the measurement behind it,
9191
are in `scripts/check-tenant-audit-census.mjs`.
9292

93-
Measured on 2026-10-19 at `2623d6d6c`.
93+
Measured on 2026-10-19 at `ebe85c6d3`.
9494

9595
| corpus scale (not enforced) | count |
9696
| :--- | ---: |
97-
| tracked non-test sources scanned | 670 |
97+
| tracked non-test sources scanned | 672 |
9898
| engine-shaped types recognised | 79 |
99-
| declared objects in the registry | 116 |
99+
| declared objects in the registry | 117 |
100100
| same-named calls subtracted as non-engine | 166 |
101101

102102
## Every site
@@ -157,9 +157,9 @@ Measured on 2026-10-19 at `2623d6d6c`.
157157
| `packages/plugins/plugin-auth/src/scim-connection-service.ts` | `insert` | `sys_scim_connection_credential` | enabled | PROVABLY NONE | 1 |
158158
| `packages/plugins/plugin-auth/src/session-tombstone.ts` | `update` | `objectName` | undecidable | options unreadable | 1 |
159159
| `packages/plugins/plugin-auth/src/sso-client-secret.ts` | `update` | `sys_sso_provider` | disabled | elevated | 1 |
160-
| `packages/plugins/plugin-email/src/attachment-reclaim.ts` | `update` | `sys_email` | enabled | elevated | 1 |
161-
| `packages/plugins/plugin-email/src/email-plugin.ts` | `insert` | `sys_email` | enabled | elevated | 1 |
162-
| `packages/plugins/plugin-email/src/email-plugin.ts` | `update` | `sys_email` | enabled | elevated | 1 |
160+
| `packages/plugins/plugin-email/src/attachment-reclaim.ts` | `update` | `object` | undecidable | elevated | 1 |
161+
| `packages/plugins/plugin-email/src/email-plugin.ts` | `insert` | `object` | undecidable | elevated | 1 |
162+
| `packages/plugins/plugin-email/src/email-plugin.ts` | `update` | `object` | undecidable | elevated | 1 |
163163
| `packages/plugins/plugin-pinyin-search/src/companion-projection.ts` | `update` | `schema.name` | undecidable | elevated | 1 |
164164
| `packages/plugins/plugin-security/src/auto-org-admin-grant.ts` | `delete` | `object` | undecidable | elevated | 1 |
165165
| `packages/plugins/plugin-security/src/auto-org-admin-grant.ts` | `insert` | `object` | undecidable | elevated | 1 |

‎packages/cli/src/utils/organization-ownership-inventory.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,12 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [
544544
{ object: 'sys_comment', census: P1, fate: 'attribution', citation: `${D3_ORG_ROWS}; a reply's parent comment carries the organization`, anchors: [parent('parent_id', 'sys_comment')] },
545545
{ object: 'sys_comment_reaction', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach); the reaction's comment carries the organization`, anchors: [parent('comment_id', 'sys_comment')] },
546546
{ object: 'sys_email', census: P1, fate: 'attribution', citation: `${D7}: sys_email is stamped at its producers; the related record carries the organization`, anchors: [subject('related_object', 'related_id')] },
547+
{
548+
object: 'sys_identity_email',
549+
census: PMAIN,
550+
fate: 'column-drop',
551+
citation: `${CENSUS_MAIN}: systemFields.tenant: false; ${D7}: sys_email's sibling for mail about a person (ruling record 6257510973, Q2 A)`,
552+
},
547553
{ object: 'sys_http_delivery', census: P1, fate: 'attribution', citation: `${D7}: a delivery is stamped from its webhook's organization`, anchors: [parent('ref_id', 'sys_webhook')] },
548554
{
549555
object: 'sys_webhook',

‎packages/platform-objects/src/audit/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
export { SysNotification } from './sys-notification.object.js';
1313
export { SysAttachment } from './sys-attachment.object.js';
1414
export { SysEmail } from './sys-email.object.js';
15+
export { SysIdentityEmail } from './sys-identity-email.object.js';
1516
export { SysEmailTemplate } from './sys-email-template.object.js';
1617
// sys_saved_report / sys_report_schedule retired with the saved-report stack (#20102).
1718
// sys_approval_request / sys_approval_action moved to @objectstack/plugin-approvals (ADR-0029 K2.b).
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0131 D7, ruling of record 6257510973, Q2 A] `sys_identity_email` is
5+
* `sys_email`'s deployment-level sibling: the same row shape, so one mail
6+
* pipeline serves both, and no organization column.
7+
*/
8+
9+
import { describe, it, expect } from 'vitest';
10+
import { SysEmail } from './sys-email.object.js';
11+
import { SysIdentityEmail } from './sys-identity-email.object.js';
12+
13+
describe('sys_identity_email — the identity-mail sibling of sys_email', () => {
14+
it('declares exactly sys_email\'s fields — the row shape the pipeline reads and writes', () => {
15+
expect(Object.keys(SysIdentityEmail.fields)).toEqual(Object.keys(SysEmail.fields));
16+
expect(SysIdentityEmail.fields).toEqual(SysEmail.fields);
17+
});
18+
19+
it('keys the same indexes, so the by-status and by-id reads cost the same', () => {
20+
expect(SysIdentityEmail.indexes).toEqual(SysEmail.indexes);
21+
});
22+
23+
it('opts out of the injected organization column; sys_email keeps it', () => {
24+
expect(SysIdentityEmail.systemFields).toEqual({ tenant: false });
25+
// Non-vacuity: the tenant log does not opt out (neither `false` nor `{ tenant: false }`).
26+
const tenantLog = SysEmail.systemFields;
27+
expect(tenantLog === false || (typeof tenantLog === 'object' && tenantLog.tenant === false)).toBe(false);
28+
expect(Object.keys(SysIdentityEmail.fields)).not.toContain('organization_id');
29+
});
30+
31+
it('is append-only, readable by get/list, and behind the platform-only capability', () => {
32+
expect(SysIdentityEmail.managedBy).toBe('append-only');
33+
expect(SysIdentityEmail.enable?.apiMethods).toEqual(['get', 'list']);
34+
expect(SysIdentityEmail.requiredPermissions).toEqual(['manage_platform_settings']);
35+
});
36+
});

0 commit comments

Comments
 (0)