Skip to content

Commit 9019af9

Browse files
feat(security): an external organization role — portal users read their own identity rows and reach data only through explicit grants (#25515)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoFqCf6db2fM7iSSM3dJb5rf)_ Part of #25233 (stage 1 of the ruling of record 6355206139, letter B; stage 2 follows in its own PR). Clause-②: yes (widening) Metadata-protocol: added ## What this does A supplier's or customer's portal user can now be added to an organization with a new `external` role. Before this, such a user had to be a plain `member`. The member baseline's `sys_user_org_members` scope (`id in current_user.org_user_ids`) is OR-combined with any application policy. So the portal user read every employee and every other supplier's users through `GET /data/sys_user`, and better-auth's list-members and get-full-organization doors showed them too. | Clause | Where | What | |---|---|---| | role config | `plugin-auth` `auth-manager.ts` | better-auth registers `external` beside owner/admin/member, with an empty statement set. | | E1 stamp | `core` `resolve-authz-context.ts` | `sys_member.role = external` in the active organization projects to the new reserved identity `org_external`. Its holder does not get the `everyone` anchor, and `countPositionHolders` agrees. The stamp is per organization, and with no active organization an external membership withholds the anchor (fail closed). There is no `sys_user` column. | | E4 baseline | `plugin-security` | New shipped set `external_default`, bound by the `org_external` built-in position. It holds `member_default`'s own-row carve-outs without `sys_user_org_members`, plus a `sys_member` own-row policy. The plugin's additive human baseline (`[appDefault, member_default]`) is swapped for `[external_default]` on an external context. So neither an app's `isDefault` set nor an accepted package default reaches it, by either channel. It is not in `MANAGED_DENY_TARGET_SETS`, so no registry-driven read injection reaches it either. | | E2 doors | `plugin-auth` `external-directory-door.ts` | After-hook on `/organization/list-members` and `/organization/get-full-organization`. When the caller's membership in the answered organization is `external`, it gets its own member row only (`total: 1`) and only invitations addressed to its own email. Every other caller is untouched. | | E3 | `spec` `invitation-role-grant.ts` | `[external]: 'any'`, so the invite dialog offers it to every issuer. | | E5 | `plugin-auth` `external-membership.ts` | A role list carrying `external` with another role is refused at write, on `sys_member` and `sys_invitation`, for every context. The refusal is `400 VALIDATION_FAILED`, with `invalid_value` on `role`. | | E6 | same module | An external is never placed in the organization's business units. Refused: a `sys_business_unit_member` row, a `sys_user_position` anchored at one of that organization's units, an invitation conferring `external` with a `business_unit_id`, and a role change to `external` while a placement stands. An organization-wide position or a permission set is how an external gets access. | Spec additions: `MEMBERSHIP_ROLE_EXTERNAL` and `BUILTIN_IDENTITY_ORG_EXTERNAL` (with metadata), in the closed vocabulary and the reserved identity names. The i18n bundles carry the `External` label (zh-CN 外部成员, ja-JP 外部メンバー, es-ES Externo). The api-surface and export-origins files are regenerated. ## Acceptance notes The app-side acceptance (hotsrm 6366488714) is pinned on a real showcase boot in `packages/qa/dogfood/test/external-member-directory.dogfood.test.ts` (6/6). It boots with the showcase's own `isDefault` set wired, as `objectstack serve` does: - **An external holder reads only its own `sys_user` and `sys_member` rows.** Two suppliers' externals each get exactly their own id from `GET /data/sys_user` and `GET /data/sys_member`. `list-members` and `get-full-organization` answer each one its own member row. - **With a permission set granting one object, it reads that object and nothing else.** A supplier with a one-object grant reads `showcase_announcement` (200) and is refused `showcase_product` (403). The showcase's `isDefault` set opens `showcase_product` to every member. The supplier without a grant is refused both. - **An ordinary member is unchanged.** It still sees all four users at the data door and at `list-members`, and still reads the app-default object. - **E5 end to end.** An owner's `update-member-role` with `['external','member']` answers 400, and the stored role stays `member`. Armed controls guard each negative case. The member really reads its peers, the app default really opens the object to members, and the fixture roles really landed. Unit pins: - `core` `resolve-authz-context.external-population.test.ts` (5). Four of them were red before the resolver change. - `plugin-security` `external-population.test.ts` (11). These cover the set's shape, own-row filters, no directory scope, the structure tables closed, one grant only, no managed-deny injection, the position binding, and resolution through the service locator with an app default configured. - `plugin-auth` `external-membership.test.ts` (16) and `external-directory-door.test.ts` (8). - `spec` `membership-role-external.test.ts`. Existing pins moved deliberately: - The built-in position count goes from six to seven, and the reserved names from four to five. - `org_external` is the one built-in identity that names a set. A delegate whose allowlist lacks `external_default` therefore meets the delegated-admin gate's 403 before the reserved-name 400. Either refusal stores nothing, and the posture test says so. - `external_default` joins the field-withholding sets and the canonical set list. - The delegated_admin and org_admin invite offers now include `external`. - The closed better-auth role map and the dogfood vocabulary pin now include `external`. Notes for review: - **Clause-②:** reserving `org_external` refuses a new position by that name. No narrowing was measured for an existing holder. better-auth's doors refused `external` before this change, and no shipped set changed for an existing population. One case is unmeasured: a `sys_member` row written directly (not through better-auth) with the token `external` used to project to a position named `external`, and now projects to `org_external`. No such row exists in this repo's fixtures or seeds. - **Out of scope, reported here for stage 2 or follow-up:** - better-auth's `list-teams` and the `teams` member of get-full-organization still check organization membership only, so an external sees the organization's team names (not rosters: `list-team-members` also requires the caller to be on that team). - `member_default` reads `sys_team`, `sys_sso_provider` and the OAuth resource tables with no row scope. That is not an external issue now, since externals no longer hold it. - The explain/describe surfaces still name `baselinePermissionSets`. This is the stage 2 explainer work. - Externals have no `owner_only` OWD floor yet. That belongs to the OWD seam in stage 2. Verification: the plugin-security (4478), plugin-auth (3607) and core security (1092) suites pass after merging main. The role and identity pin tests in thirteen other packages pass. `typecheck` is clean for spec, core, plugin-security, plugin-auth, platform-objects and dogfood. All 109 derived gates were run and reconciled with `dispatch-gates --ran` (0 not measured). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01AZbN6PU5Xz4WTi6NKmyKhN --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b2411c0 commit 9019af9

50 files changed

Lines changed: 1667 additions & 74 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/core': minor
4+
'@objectstack/plugin-security': minor
5+
'@objectstack/plugin-auth': minor
6+
'@objectstack/platform-objects': minor
7+
---
8+
9+
feat: an `external` organization role for portal users, who read their own identity rows and reach data only through explicit grants
10+
11+
Clause-②: yes (widening)
12+
Metadata-protocol: added
13+
14+
A supplier's or a customer's portal user can now be added to an organization with the `external` role. It is a member of the organization (it has a `sys_member` row) but a separate population: before this change such a user had to be a plain `member`, and the member baseline's fellow-member scope let it read every employee and every other supplier's users through `GET /data/sys_user`.
15+
16+
- **`@objectstack/spec`:** `MEMBERSHIP_ROLE_EXTERNAL` (`'external'`) joins the closed membership-role vocabulary, with an `External` option. It projects to the new reserved identity `BUILTIN_IDENTITY_ORG_EXTERNAL` (`'org_external'`), and its invitation grant row is `'any'`, because it confers less than `member`.
17+
- **`@objectstack/core`:** the authorization resolver reads the stamp from `sys_member.role` in the active organization. An `external` holder holds `org_external` and not the `everyone` anchor, so the deployment's defaults bound there (`member_default`, an app's `isDefault` set, accepted package defaults) do not reach it. The stamp is per organization, and with no active organization an external membership withholds the anchor.
18+
- **`@objectstack/plugin-security`:** a new shipped set, `external_default`, is bound by the `org_external` position. It carries `member_default`'s own-row self-service grants without the fellow-member directory scope, plus the holder's own `sys_member` row. The plugin's additive baseline is swapped for it on an external context.
19+
- **`@objectstack/plugin-auth`:**
20+
- better-auth registers `external` beside owner/admin/member, with no organization statement.
21+
- A role list carrying `external` with another role is refused at write on `sys_member` and `sys_invitation` (`400 VALIDATION_FAILED`, `invalid_value` on `role`).
22+
- An external is never placed in the organization's business units. A business-unit membership, a position anchored at one of its units, an invitation conferring `external` with a `business_unit_id`, and a change to `external` while a placement stands are all refused. An organization-wide position or a permission set is how an external gets access.
23+
- `GET /organization/list-members` and `GET /organization/get-full-organization` answer an external caller its own member row and the invitations addressed to its own email only.
24+
- **`@objectstack/platform-objects`:** the `External` option label on the membership role pickers, translated.
25+
26+
`org_external` is a reserved name from this release on, so a position can no longer be named `org_external`.

‎content/docs/permissions/authentication.mdx‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -837,8 +837,15 @@ new AuthPlugin({
837837

838838
`sys_member.role` is better-auth's own column and the one place ObjectStack
839839
keeps that vocabulary (ADR-0090 D3's single boundary exception). It holds
840-
exactly four values — `owner`, `admin`, `delegated_admin`, `member` — and your
841-
stack cannot add to them.
840+
exactly five values — `owner`, `admin`, `delegated_admin`, `member`, `external` — and
841+
your stack cannot add to them.
842+
843+
`external` is for a portal user (a supplier's or a customer's person) who joins
844+
the organization without joining its staff. It is held alone, never beside
845+
another tier. Its holder reads only its own identity rows, does not receive the
846+
member defaults (`member_default` or an app's `isDefault` set), is never placed
847+
in a business unit, and reaches application data only through the positions and
848+
permission sets granted to it explicitly.
842849

843850
That is deliberate (ADR-0108). A membership tier answers *what is your standing
844851
in this organization*, which decides what you can **reach**: `delegated_admin`,

‎content/docs/permissions/positions.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ which sets*.
1919
> **permission set** = capability, **position** = distribution,
2020
> **business unit** = hierarchy. The single exception is better-auth's
2121
> internal `sys_member.role` — the org-membership tier, a **closed** list of
22-
> `owner`, `admin`, `delegated_admin`, `member` and nothing else (ADR-0108). A
22+
> `owner`, `admin`, `delegated_admin`, `member`, `external` and nothing else (ADR-0108). A
2323
> tier decides what you can *reach*; a position decides what you may *do*. To
2424
> invite someone straight into a position, use the invitation's `positions`
2525
> placement, not its membership tier — see

‎content/docs/references/identity/organization.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ const result = InvitationSchema.parse(data);
3838
| **id** | `string` | ✅ | Unique invitation identifier |
3939
| **organizationId** | `string` | ✅ | Organization ID |
4040
| **email** | `string` | ✅ | Invitee email address |
41-
| **role** | `string` | ✅ | Role to assign upon acceptance (owner, admin, delegated_admin, member — ADR-0108 closed vocabulary) |
41+
| **role** | `string` | ✅ | Role to assign upon acceptance (owner, admin, delegated_admin, member, external — ADR-0108 closed vocabulary) |
4242
| **status** | `Enum<'pending' \| 'accepted' \| 'rejected' \| 'expired' \| 'canceled'>` | optional (default: `"pending"`) | Invitation status |
4343
| **expiresAt** | `string` | ✅ | Invitation expiry timestamp |
4444
| **inviterId** | `string` | ✅ | User ID of the inviter |
@@ -70,7 +70,7 @@ const result = InvitationSchema.parse(data);
7070
| **id** | `string` | ✅ | Unique member identifier |
7171
| **organizationId** | `string` | ✅ | Organization ID |
7272
| **userId** | `string` | ✅ | User ID |
73-
| **role** | `string` | ✅ | Member role (owner, admin, delegated_admin, member — ADR-0108 closed vocabulary) |
73+
| **role** | `string` | ✅ | Member role (owner, admin, delegated_admin, member, external — ADR-0108 closed vocabulary) |
7474
| **createdAt** | `string` | ✅ | Member creation timestamp |
7575
| **updatedAt** | `string` | optional | Last update timestamp (no such column on sys_member; absent on the wire) |
7676

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,9 @@ const AUTHORED = [{ column: 'managed_by', notIn: ['package', 'platform', 'system
251251
export const SHIPPED_PERMISSION_SET_NAMES: readonly string[] = [
252252
'admin_full_access', 'organization_admin', 'organization_admin_no_bypass', 'member_default', 'viewer_readonly',
253253
'mcp_agent_data_read', 'mcp_agent_data_write', 'mcp_agent_restricted',
254+
// [#25233] Shipped since the external membership role; no install seeded it
255+
// before the `platform` stamp, so the legacy adoption below never meets it.
256+
'external_default',
254257
];
255258

256259
/**
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
// The external population's stamp at the resolver's one provisioning boundary
4+
// (ruling of record 6355206139, letter B, stage 1, E1): `sys_member.role =
5+
// external` in the ACTIVE organization projects to `org_external`, and its
6+
// holder does not hold the `everyone` audience anchor — so the deployment's
7+
// default sets bound there (the platform baseline, an app's `isDefault` set,
8+
// an accepted package default) never reach it. What reaches it is what its own
9+
// identity position names, plus its explicit grants. An ordinary member is
10+
// unchanged, and the stamp is per organization.
11+
12+
import { describe, it, expect } from 'vitest';
13+
import { countPositionHolders, resolveUserAuthzGrants } from './resolve-authz-context.js';
14+
import { bindStaticSecurityCatalog } from './__tests__/security-catalog.testkit.js';
15+
16+
const ALPHA = 'org_alpha';
17+
const BETA = 'org_beta';
18+
19+
function makeQl(tables: Record<string, any[]>) {
20+
return {
21+
async find(object: string, opts: any) {
22+
const rows = tables[object] ?? [];
23+
const where = opts?.where ?? {};
24+
const matched = rows.filter((r) =>
25+
Object.entries(where).every(([k, v]) => {
26+
if (k.startsWith('$')) throw new Error(`test double: combinator ${k} is not implemented`);
27+
if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(r[k]);
28+
if (v === null) return (r[k] ?? null) === null;
29+
return r[k] === v;
30+
}),
31+
);
32+
return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched;
33+
},
34+
};
35+
}
36+
37+
/** The catalog every case reads: the anchor's baseline, the external identity's own set, one portal grant. */
38+
const definitions = {
39+
positions: [
40+
{ name: 'everyone', permissionSets: ['member_default', 'app_default'] },
41+
{ name: 'org_external', permissionSets: ['external_default'] },
42+
{ name: 'supplier_a_portal', permissionSets: ['supplier_portal'] },
43+
],
44+
permissions: [
45+
{ name: 'member_default' },
46+
{ name: 'app_default' },
47+
{ name: 'external_default' },
48+
{ name: 'supplier_portal' },
49+
],
50+
};
51+
52+
const principal = (members: any[], positions: any[] = []) =>
53+
bindStaticSecurityCatalog(
54+
makeQl({
55+
sys_user: [{ id: 'u1', email: 'u1@x.test' }, { id: 'u2', email: 'u2@x.test' }, { id: 'u3', email: 'u3@x.test' }],
56+
sys_member: members,
57+
sys_user_position: positions,
58+
sys_user_permission_set: [],
59+
}),
60+
definitions,
61+
);
62+
63+
describe('the external population at the provisioning boundary', () => {
64+
it('an external member holds org_external and not everyone: only its own set and its explicit grants', async () => {
65+
const ql = principal(
66+
[{ user_id: 'u1', organization_id: ALPHA, role: 'external' }],
67+
[{ user_id: 'u1', position: 'supplier_a_portal', organization_id: ALPHA }],
68+
);
69+
const grants = await resolveUserAuthzGrants(ql, 'u1', { tenantId: ALPHA });
70+
expect(grants.positions).toContain('org_external');
71+
expect(grants.positions).toContain('supplier_a_portal');
72+
expect(grants.positions).not.toContain('everyone');
73+
expect(grants.positions).not.toContain('org_member');
74+
expect([...grants.permissions].sort()).toEqual(['external_default', 'supplier_portal']);
75+
});
76+
77+
it('an ordinary member is unchanged: everyone, and the baseline it names', async () => {
78+
const ql = principal([{ user_id: 'u2', organization_id: ALPHA, role: 'member' }]);
79+
const grants = await resolveUserAuthzGrants(ql, 'u2', { tenantId: ALPHA });
80+
expect(grants.positions).toEqual(expect.arrayContaining(['org_member', 'everyone']));
81+
expect(grants.positions).not.toContain('org_external');
82+
expect([...grants.permissions].sort()).toEqual(['app_default', 'member_default']);
83+
});
84+
85+
it('the stamp is per organization: external in one, a member in the other', async () => {
86+
const ql = principal([
87+
{ user_id: 'u3', organization_id: ALPHA, role: 'external' },
88+
{ user_id: 'u3', organization_id: BETA, role: 'member' },
89+
]);
90+
const inAlpha = await resolveUserAuthzGrants(ql, 'u3', { tenantId: ALPHA });
91+
expect(inAlpha.positions).not.toContain('everyone');
92+
expect([...inAlpha.permissions].sort()).toEqual(['external_default']);
93+
const inBeta = await resolveUserAuthzGrants(ql, 'u3', { tenantId: BETA });
94+
expect(inBeta.positions).toContain('everyone');
95+
expect(inBeta.positions).not.toContain('org_external');
96+
expect([...inBeta.permissions].sort()).toEqual(['app_default', 'member_default']);
97+
});
98+
99+
it('with no active organization an external membership withholds the anchor (fail closed)', async () => {
100+
const ql = principal([
101+
{ user_id: 'u3', organization_id: ALPHA, role: 'external' },
102+
{ user_id: 'u3', organization_id: BETA, role: 'member' },
103+
]);
104+
const grants = await resolveUserAuthzGrants(ql, 'u3', {});
105+
expect(grants.positions).toContain('org_external');
106+
expect(grants.positions).not.toContain('everyone');
107+
expect(grants.permissions).not.toContain('member_default');
108+
});
109+
110+
it('the holder count agrees: an external is counted under org_external, never under everyone', async () => {
111+
const ql = principal([
112+
{ user_id: 'u1', organization_id: ALPHA, role: 'external' },
113+
{ user_id: 'u2', organization_id: ALPHA, role: 'member' },
114+
]);
115+
const counts = await countPositionHolders(ql, ALPHA, ['everyone', 'org_external', 'org_member']);
116+
expect(counts.get('everyone')).toBe(1);
117+
expect(counts.get('org_external')).toBe(1);
118+
expect(counts.get('org_member')).toBe(1);
119+
});
120+
});

‎packages/core/src/security/resolve-authz-context.ts‎

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import { isMissingTableError, resolveTenancyPosture } from '@objectstack/types';
5454
import {
5555
mapMembershipRole,
5656
BUILTIN_IDENTITY_PLATFORM_ADMIN,
57+
BUILTIN_IDENTITY_ORG_EXTERNAL,
5758
ADMIN_FULL_ACCESS,
5859
ADMIN_FULL_ACCESS_CAPABILITIES,
5960
GUEST_POSITION,
@@ -1233,6 +1234,25 @@ function heldPositionNames(
12331234
return held;
12341235
}
12351236

1237+
/**
1238+
* [ruling of record 6355206139, E1] Does a principal holding `held` belong to
1239+
* the organization's EXTERNAL population — does it hold `org_external`, the
1240+
* projection of `sys_member.role = external`? Its holder is not given the
1241+
* `everyone` audience anchor (§5b), so the deployment's default sets bound
1242+
* there never reach it; what reaches it is what its own identity position
1243+
* names and its explicit grants.
1244+
*
1245+
* Read off the projected names, never the raw role: the role is read once, by
1246+
* {@link heldPositionNames}, and `org_external` is a reserved identity name no
1247+
* `sys_user_position` row can spell. With no active organization every
1248+
* membership projects, so one external membership withholds the anchor — the
1249+
* fail-closed reading, since that population's reach is then not decided by
1250+
* any one organization.
1251+
*/
1252+
function holdsExternalIdentity(held: readonly string[]): boolean {
1253+
return held.includes(BUILTIN_IDENTITY_ORG_EXTERNAL);
1254+
}
1255+
12361256
/**
12371257
* [ADR-0131 D3/D4, ADR-0049] The sets each of `positions` grants through its
12381258
* definition, IN FORCE: a position the activation ledger switched off grants
@@ -1302,7 +1322,8 @@ export async function readPositionGrantsInForce(
13021322
* organization `tenantId` — the principals whose own rows give them the name by
13031323
* {@link heldPositionNames}, the rule {@link resolveUserAuthzGrants} projects a
13041324
* caller's positions by, counted over the organization's `sys_member` and
1305-
* `sys_user_position` rows. `everyone` is held by every active member. Holding
1325+
* `sys_user_position` rows. `everyone` is held by every active member but an
1326+
* external one ({@link holdsExternalIdentity}). Holding
13061327
* is the assignment: a position the activation ledger switched off keeps its
13071328
* holders (ADR-0049) and grants them nothing ({@link readPositionGrantsInForce}).
13081329
*
@@ -1347,8 +1368,9 @@ export async function countPositionHolders(
13471368
for (const userId of new Set([...membersByUser.keys(), ...positionRowsByUser.keys()])) {
13481369
const memberRows = membersByUser.get(userId) ?? [];
13491370
const held = heldPositionNames(memberRows, positionRowsByUser.get(userId) ?? [], tenantId, nowMs);
1350-
// §5b: every authenticated member holds the `everyone` audience anchor.
1351-
if (memberRows.some((m) => isGrantActive(m, nowMs))) held.push(EVERYONE_POSITION);
1371+
// §5b: every authenticated member holds the `everyone` audience anchor —
1372+
// an external one excepted (ruling of record 6355206139, E1).
1373+
if (memberRows.some((m) => isGrantActive(m, nowMs)) && !holdsExternalIdentity(held)) held.push(EVERYONE_POSITION);
13521374
for (const name of held) {
13531375
if (!wanted.has(name)) continue;
13541376
if (!holders.has(name)) holders.set(name, new Set());
@@ -1787,7 +1809,14 @@ export async function resolveUserAuthzGrants(
17871809
// holds the built-in `everyone` position, so sets bound to it resolve
17881810
// below exactly like any other position-bound grant — ADDITIVE, with no
17891811
// "only when the user has nothing else" cliff.
1790-
if (!grants.positions.includes('everyone')) grants.positions.push('everyone');
1812+
//
1813+
// [ruling of record 6355206139, E1] …except the EXTERNAL population: a
1814+
// principal holding `org_external` (§3's projection of
1815+
// `sys_member.role = external`) does not hold the anchor, so neither the
1816+
// platform baseline nor an app's `isDefault` set nor an accepted package
1817+
// default bound to it reaches the holder. Its baseline is what its own
1818+
// identity position names (§6a); everything else is an explicit grant.
1819+
if (!holdsExternalIdentity(grants.positions) && !grants.positions.includes('everyone')) grants.positions.push('everyone');
17911820

17921821
// [#10757 leg 4] The Default Organization read (§6) and the activation-ledger
17931822
// read (§6a) are ONE wave. They used to be two serial round trips, because the

‎packages/platform-objects/src/apps/translations/en.objects.generated.ts‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,8 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
146146
owner: "Owner",
147147
admin: "Admin",
148148
delegated_admin: "Delegated Admin",
149-
member: "Member"
149+
member: "Member",
150+
external: "External"
150151
}
151152
}
152153
}
@@ -582,7 +583,8 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
582583
owner: "Owner",
583584
admin: "Admin",
584585
delegated_admin: "Delegated Admin",
585-
member: "Member"
586+
member: "Member",
587+
external: "External"
586588
}
587589
}
588590
}
@@ -643,7 +645,8 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
643645
owner: "Owner",
644646
admin: "Admin",
645647
delegated_admin: "Delegated Admin",
646-
member: "Member"
648+
member: "Member",
649+
external: "External"
647650
}
648651
}
649652
},
@@ -667,7 +670,8 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
667670
owner: "Owner",
668671
admin: "Admin",
669672
delegated_admin: "Delegated Admin",
670-
member: "Member"
673+
member: "Member",
674+
external: "External"
671675
}
672676
}
673677
}
@@ -721,7 +725,8 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
721725
owner: "Owner",
722726
admin: "Admin",
723727
delegated_admin: "Delegated Admin",
724-
member: "Member"
728+
member: "Member",
729+
external: "External"
725730
}
726731
},
727732
status: {
@@ -795,7 +800,8 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
795800
owner: "Owner",
796801
admin: "Admin",
797802
delegated_admin: "Delegated Admin",
798-
member: "Member"
803+
member: "Member",
804+
external: "External"
799805
}
800806
}
801807
}

0 commit comments

Comments
 (0)