Skip to content

Commit 5f35aff

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-25234-pr2
2 parents 3c309af + 74496c6 commit 5f35aff

76 files changed

Lines changed: 4053 additions & 1177 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`.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/plugin-approvals': patch
3+
---
4+
5+
fix: the approval request page's Timeline shows each action's step by its label, not the node's machine id
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The Timeline tab of the `sys_approval_request` record page lists the request's `sys_approval_action` rows. Its step column asked for `step_name`, which holds the Approval node's id (for example `dual_signoff`), so every row showed that id. The column is now `step_label`, the step's label that the service writes on every row and the boot backfill fills on older rows. The strip's step chip (`current_step_label`) and the object's own lists already show this label.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/service-settings': patch
3+
---
4+
5+
fix: Setup → AI's "Reset to environment defaults" button is now translated in zh-CN, ja-JP and es-ES
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The `ai` settings manifest declares an `action_button` with id `reset`, but no locale bundle carried `settings.ai.actions.reset.label`, so the button rendered in English beside its translated siblings. The bundles now carry it (en, zh-CN, ja-JP, es-ES).
11+
12+
The settings translation coverage test now reads every manifest's `action_button` labels: each shipped non-English locale must carry `settings.<namespace>.actions.<id>.label` for every action button, and no bundle may carry action copy for a button no manifest declares.

‎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/permissions/system-context.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ that silently does not happen.
108108
| 7 | Write bypass = `true`, effective write scope = `org` | plugin-security | Get: widest write scope without holding any capability | `packages/plugins/plugin-security/src/security-plugin.ts#start` |
109109
| 8 | Metadata-plane schema masking exempt (ADR-0106 D4) | metadata-core | Get: unmasked object schema. Note: the exemption is a **caller** property — it short-circuits before the security service is consulted | `packages/metadata-core/src/object-schema-fls.ts#isObjectSchemaMaskExempt` |
110110
| 8b | Authoring-trail reads admitted | rest, runtime | Get: a metadata item's version history, audit rows and stored-version diff (`GET /meta/:type/:name/history`, `/audit`, `/diff`) without `studio.access` or `manage_metadata`, and a package's commit timeline (`GET /packages/:id/commits`) without `studio.access`. Note: narrower than the schema-mask exemption the draft switches ask (`isObjectSchemaMaskExempt`): neither door admits `setup.access`, which organization admins hold | `packages/rest/src/rest-server.ts#mayReadAuthoringTrail`, `packages/runtime/src/domains/packages.ts#requireCommitTrailReadCapability` |
111-
| 9 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `packages/plugins/plugin-security/src/security-plugin.ts#explainAccessForCaller` |
111+
| 9 | `explain()` may target a principal other than the caller | plugin-security | Get: no `manage_users` / delegated-admin check | `packages/plugins/plugin-security/src/security-plugin-explain-access.ts#explainAccessForCaller` |
112112
| 9b | The master-detail write check the `security` service serves (`checkControlledByParentWrite`, ADR-0055) answers `allow` | plugin-security | Get: `allow` for any record, with no master judged — the write path's own first exit, so a gate composing it admits exactly what the record's own system-context update would. Lose: nothing a caller reaches today: its two consumers, the `sys_attachment` and `sys_comment` parent gates, return before they ask for a system session | `packages/plugins/plugin-security/src/security-plugin-cbp-write-gate.ts#checkControlledByParentWrite` |
113113
| 10 | Anonymous-deny treats the caller as authenticated | core | Get: passes the 401 seam with no `userId` | `packages/core/src/security/anonymous-deny.ts#shouldDenyAnonymous` |
114114
| 12 | Session-resolution middleware skipped | plugin-auth | Get: no session lookup attempted | `packages/plugins/plugin-auth/src/auth-plugin.ts#start` |

‎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

‎examples/app-showcase/src/automation/flows/index.ts‎

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1747,13 +1747,37 @@ export const InquiryPurgeFlow = defineFlow({
17471747
* pattern (钉钉/Salesforce style) that needs no parallel branches. Set
17481748
* `minApprovals` > 1 for "two from each group"; use `behavior: 'quorum'` +
17491749
* `minApprovals` for M-of-N collective sign-off.
1750+
*
1751+
* **The decision is written back onto the report** — an explicit
1752+
* `update_record` on each outcome branch: approve → `status: 'approved'`,
1753+
* reject → `status: 'draft'` (back to the submitter, who can rework and
1754+
* resubmit — the same revert-on-reject {@link InvoiceDualSignoffFlow} does).
1755+
* Without these two nodes both outcomes were bare `end`s, so an expense report
1756+
* both groups had approved kept reading "Submitted" forever.
1757+
*
1758+
* Why not the approval node's `approvalStatusField` mirror instead: the mirror
1759+
* writes the REQUEST's status vocabulary verbatim (`pending` on open,
1760+
* `approved` / `rejected` on decide, `recalled`, `returned`), has no value
1761+
* mapping, and writes only values the target field declares among its options
1762+
* (anything else is skipped with a warn). `status` here is the report's own
1763+
* lifecycle select (`draft` / `submitted` / `approved` / `reimbursed`) — it
1764+
* declares neither `pending` nor `rejected`, so a mirror onto it would warn on
1765+
* every submit and leave a REJECTED report reading "Submitted", the very
1766+
* defect being fixed. The spec also advises a mirror target be readonly, and
1767+
* this one is the field the submitter edits. A business-lifecycle field
1768+
* driven by the decision is what `update_record` per branch is for.
1769+
*
1770+
* `runAs: 'system'` for the same reason as the invoice flow: the write-back is
1771+
* an outcome of the approval process, not an act of the submitter, so it must
1772+
* land whatever the submitter's edit rights on a submitted report are.
17501773
*/
17511774
export const ExpenseSignoffFlow = defineFlow({
17521775
name: 'showcase_expense_signoff',
17531776
label: 'Expense Report Sign-off',
17541777
description: 'Manager + finance per-group sign-off (会签) on a submitted expense report (#3266).',
17551778
type: 'autolaunched',
17561779
status: 'active',
1780+
runAs: 'system',
17571781
nodes: [
17581782
{
17591783
id: 'start',
@@ -1779,13 +1803,35 @@ export const ExpenseSignoffFlow = defineFlow({
17791803
lockRecord: true,
17801804
},
17811805
},
1806+
{
1807+
id: 'mark_approved',
1808+
type: 'update_record',
1809+
label: 'Mark Report Approved',
1810+
config: {
1811+
objectName: 'showcase_expense_report',
1812+
filter: { id: { dialect: 'cel', source: 'record.id' } },
1813+
fields: { status: 'approved' },
1814+
},
1815+
},
1816+
{
1817+
id: 'return_to_draft',
1818+
type: 'update_record',
1819+
label: 'Return Report to Draft',
1820+
config: {
1821+
objectName: 'showcase_expense_report',
1822+
filter: { id: { dialect: 'cel', source: 'record.id' } },
1823+
fields: { status: 'draft' },
1824+
},
1825+
},
17821826
{ id: 'approved', type: 'end', label: 'Approved' },
17831827
{ id: 'rejected', type: 'end', label: 'Rejected' },
17841828
],
17851829
edges: [
17861830
{ id: 'e1', source: 'start', target: 'dual_signoff' },
1787-
{ id: 'e2', source: 'dual_signoff', target: 'approved', label: 'approve' },
1788-
{ id: 'e3', source: 'dual_signoff', target: 'rejected', label: 'reject' },
1831+
{ id: 'e2', source: 'dual_signoff', target: 'mark_approved', label: 'approve' },
1832+
{ id: 'e3', source: 'dual_signoff', target: 'return_to_draft', label: 'reject' },
1833+
{ id: 'e4', source: 'mark_approved', target: 'approved' },
1834+
{ id: 'e5', source: 'return_to_draft', target: 'rejected' },
17891835
],
17901836
});
17911837

@@ -1797,13 +1843,19 @@ export const ExpenseSignoffFlow = defineFlow({
17971843
* `minApprovals` clamps to the approver count so a misconfiguration can never
17981844
* deadlock. Where `per_group` requires one from EACH group, `quorum` counts a
17991845
* flat tally across all approvers.
1846+
*
1847+
* The decision is written back onto the report exactly as in
1848+
* {@link ExpenseSignoffFlow} (approve → `approved`, reject → `draft`), and for
1849+
* the same reasons — see that flow's docblock for why this is an
1850+
* `update_record` per branch and not an `approvalStatusField` mirror.
18001851
*/
18011852
export const CommitteeQuorumFlow = defineFlow({
18021853
name: 'showcase_committee_quorum',
18031854
label: 'High-Value Expense — Committee Quorum',
18041855
description: 'Any 2-of-3 committee members approve a high-value expense report (#3266 quorum / M-of-N).',
18051856
type: 'autolaunched',
18061857
status: 'active',
1858+
runAs: 'system',
18071859
nodes: [
18081860
{
18091861
id: 'start',
@@ -1830,13 +1882,35 @@ export const CommitteeQuorumFlow = defineFlow({
18301882
lockRecord: true,
18311883
},
18321884
},
1885+
{
1886+
id: 'mark_approved',
1887+
type: 'update_record',
1888+
label: 'Mark Report Approved',
1889+
config: {
1890+
objectName: 'showcase_expense_report',
1891+
filter: { id: { dialect: 'cel', source: 'record.id' } },
1892+
fields: { status: 'approved' },
1893+
},
1894+
},
1895+
{
1896+
id: 'return_to_draft',
1897+
type: 'update_record',
1898+
label: 'Return Report to Draft',
1899+
config: {
1900+
objectName: 'showcase_expense_report',
1901+
filter: { id: { dialect: 'cel', source: 'record.id' } },
1902+
fields: { status: 'draft' },
1903+
},
1904+
},
18331905
{ id: 'approved', type: 'end', label: 'Approved' },
18341906
{ id: 'rejected', type: 'end', label: 'Rejected' },
18351907
],
18361908
edges: [
18371909
{ id: 'e1', source: 'start', target: 'committee' },
1838-
{ id: 'e2', source: 'committee', target: 'approved', label: 'approve' },
1839-
{ id: 'e3', source: 'committee', target: 'rejected', label: 'reject' },
1910+
{ id: 'e2', source: 'committee', target: 'mark_approved', label: 'approve' },
1911+
{ id: 'e3', source: 'committee', target: 'return_to_draft', label: 'reject' },
1912+
{ id: 'e4', source: 'mark_approved', target: 'approved' },
1913+
{ id: 'e5', source: 'return_to_draft', target: 'rejected' },
18401914
],
18411915
});
18421916

0 commit comments

Comments
 (0)