Skip to content

Commit 0958e19

Browse files
committed
Merge origin/main into claude/issue-22474-preview-binds-parent
Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG Co-authored-by: Claude <noreply@anthropic.com>
2 parents 57c3ce9 + 446c8b2 commit 0958e19

90 files changed

Lines changed: 2830 additions & 398 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: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/core': minor
3+
'@objectstack/plugin-security': minor
4+
'@objectstack/plugin-auth': minor
5+
---
6+
7+
feat(core)!: the authorization resolver reads which permission set a user grant holds from the grant's name, `sys_user_permission_set.permission_set` (ADR-0131 D4)
8+
9+
Clause-②: no (narrowing)
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, export, type or stored shape is added, removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is runtime resolution and two write doors: a stored grant that names no permission set, or names one only another organization holds, stops conferring through the resolver; a write naming a grant after another organization's set is refused; and the break-glass guard judges one more column. The remedy is data (a grant re-pointed at a set of its own organization), never a rewrite of anyone's code or metadata, and the one-time backfill that names existing grants already ships. The other categories are closed on facts: every package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and no TypeScript declaration moves (not runtime-interface-only or type-surface-only). -->
12+
13+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
14+
15+
**What the resolver reads now.** `resolveUserAuthzGrants` (and every surface built on it: `resolveAuthzContext`, `hasPlatformAdminStanding`, the explain engine, `runAs: 'user'` automation) finds a user grant's permission set by the grant's `permission_set` name. The set row is the grant's own organization's row of that name, or else the organization-less row of that name. `permission_set_id` is no longer read for this. Deactivation is still read from the set row. Sets a principal holds through a position are still reached through the position binding's id.
16+
17+
- **Platform standing.** An unscoped grant is read against the organization-less `admin_full_access` row only. An organization's copy of `admin_full_access` gives that organization's grants its capabilities, never platform standing.
18+
- **Nobody's resolved permissions change** where a grant's name and id agree. The platform's grant writers store both, and the one-time backfill names grants stored before the name column existed. Goldens for the platform administrator, an organization administrator, a member and an agent are unchanged in `single`, `group` and `isolated`.
19+
20+
**What stops conferring.**
21+
22+
- **A grant that names nothing.** This is a grant the backfill has not named yet, or one it could not name: its id names no set row, its id names another organization's set row, or the name is not in the security catalog. Such a grant now confers nothing through the resolver. The backfill runs at `kernel:bootstrapped`, before any server opens its socket, so on the first boot after upgrading no request is answered before the grants it can name are named. The grants it cannot name are listed in its boot report.
23+
- **A grant whose id names another organization's set.** Before this change, such a grant conferred that set. It now confers nothing.
24+
- **Remedy.** Read the backfill's boot report, which lists each grant by row id. Re-point each listed grant at a permission set of its own organization, or at an organization-less one, with an update of `permission_set_id`. The platform stamps the name, and the grant confers again.
25+
26+
**What the grant name hook refuses now** (`@objectstack/plugin-security`). The name is taken only from a set row of the grant's own organization, or from an organization-less one. A tenant-less system writer can read every organization's sets, and it could name an organization-less grant after another organization's set. Now such a grant is stored without a name. A write that supplies that name is refused, from a system writer too, with the hook's usual `400 VALIDATION_FAILED` and `invalid_value` at `permission_set`. An update that re-points a grant at such a set, or moves a grant to an organization its set does not belong to, clears the name.
27+
28+
**What the last-administrator guard judges now** (`@objectstack/plugin-auth`). `organization_id` on `sys_permission_set` is a standing column. Moving the organization-less `admin_full_access` row into an organization takes away every grant-anchored platform administrator, as deleting it does, so the guard refuses it when it would leave none. An organization's copy of `admin_full_access` no longer counts as the set being in effect.
29+
30+
**Nothing to migrate** in code or metadata.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/connector-mcp": minor
3+
---
4+
5+
feat(connector-mcp): a declarative stdio MCP transport runs in the declaring app's root, so its relative command and args resolve there wherever the server was started
6+
7+
Clause-②: yes (widening)
8+
9+
- **What changes.** For a declarative `provider: 'mcp'` connector with a stdio transport, the `mcp` provider factory now sets the child process's working directory to the app's root. It reads that root from the host through `ConnectorProviderContext.resolvePackagePath('.')`. Relative paths in the entry now resolve against the same root as the app's other relative refs, such as the `openapi` provider's `providerConfig.spec`. That covers a relative `command` path (`./bin/server`) and a script the launched program opens itself (`args: ['./scripts/server.mjs']` with `command: 'node'`). Before this change they resolved against the directory the server was started from. A boot from any other directory, such as `os verify --app examples/app-showcase/objectstack.config.ts` from the repository root, therefore registered the connector degraded, with no actions.
10+
- **What does not change.** A bare executable (`node`, `npx`) is still looked up on `PATH`, and an absolute command or argument resolves as before. The `declarativeStdio` policy judges the `command` string exactly as written, and it runs before the root is resolved. `providerConfig.transport` gains no key: the working directory comes from the host, and an authored `cwd` there is not used. A host that does not provide `resolvePackagePath` starts the child exactly as before, in the host's current directory. A server that opens relative files of its own now opens them under the app's root.
11+
- **New on the exported type.** The stdio variant of `McpTransport` gains an optional `cwd`, which the default client passes to the MCP SDK's `StdioClientTransport`. A hand-wired transport (`new ConnectorMcpPlugin({ transport })` or `createMcpConnector`) may set it. Left out, the child inherits the host's current directory, as before.
12+
- **Nothing to migrate.** An app that started its server from its own directory sees no difference, because both anchors were the same directory.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@objectstack/formula": minor
3+
---
4+
5+
fix(formula)!: the unknown-field check judges every spelling of a `record` / `previous` member, not only the dot, so `os build` and the object save door refuse `record['typo']`, `previous['typo']`, `record.?typo` and `record[?'typo']` (#22428)
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) A refusal at `os build` and at the object save door of an expression that names an undeclared field in a non-dot member spelling: no authorable key, spelling, export or stored shape moves, and no stored row is read, rewritten or converted. A stored object whose expression is refused keeps loading until it is next saved, and the repair is the author's own (declare the field or fix the typo), which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this verdict and this diff adds none (not registered / already-registered); and no exported TypeScript declaration changes (not runtime-interface-only / type-surface-only). -->
10+
11+
**BREAKING**: an accept-set change, in both directions, of the `os build`, `os validate` and `os lint` verdicts and of the object save door, shipped as `minor` under the launch-window convention for accept-set narrowings. It narrows: an expression that names an undeclared field of its object in a non-dot member spelling (`record['FIELD']`, `record["FIELD"]`, `record.?FIELD`, `record[?'FIELD']`, or any of those on `previous`), or an undeclared leaf of a declared read attachment in such a spelling, is now refused where it was accepted. It widens: three shapes the dot-only regex misread as a member of the root, and refused as unknown fields, are now accepted, because none of them names a member. They are text inside a string literal (`record.name == 'record.typo'`), a root name after another root (`vars.record.x`), and a method call on the root itself (`record.size()`). No refusal code is added.
12+
13+
`validateExpression`'s field-existence pass judges each member an expression reads on `record` or `previous` against the object's fields, and refuses an undeclared one with the `unknown-field` code. It found those members with a regex over the dot spelling, so it judged `record.typo` and `has(record.typo)` and nothing else. `record['typo']`, `record["typo"]`, `previous['typo']`, `record.?typo` and `record[?'typo']` name the same column, and they reached the evaluator with no verdict at every slot the pass judges, among them a select option's `visibleWhen`, a field's `requiredWhen` and a validation rule's condition. There the expression faults on a key the record never carries, or reads a value that never exists.
14+
15+
The pass now reads members through the same AST member reader the relationship-traversal analysis is built on, so every spelling gets the dot spelling's verdict, did-you-mean included:
16+
17+
- `os build`, `os validate` and `os lint` refuse an expression that names an undeclared field of its object as `record['FIELD']`, `record["FIELD"]`, `record.?FIELD`, `record[?'FIELD']` or any of those on `previous`, at `error`, with the message they already give for `record.FIELD`.
18+
- The object save door runs the same pass, so an object write in publish mode that carries such an expression is now refused with an `expression-invalid` issue located at the slot, the build's own finding.
19+
- A declared read attachment's leaf (`ObjectSchema.attachedOnRead`) is judged in the same spellings: `record.viewer['can_actt']` and `record.viewer.?can_actt` are refused like `record.viewer.can_actt`. An earlier entry in this release says index access on a read attachment stays unjudged; that describes the check before this change.
20+
21+
**Remedy.** Declare the field on the object, or fix the typo to the field the message suggests. The spelling itself is not the defect: `record['status']`, `record.?status` and `record[?'status']` on a declared `status` are accepted, as before.
22+
23+
**Unchanged.**
24+
25+
- A computed key (`record[record.kind]`, `record[someVar]`) names no member before evaluation, so it is not judged.
26+
- A method call on a member (`record.name.startsWith('A')`) still judges the member (`name`).
27+
- Stored rows are not migrated or refused on read; an object stored before this change keeps loading until it is next saved, and that save is judged. Drafts are not gated.
28+
- Measured before crossing: the stacks this repository ships give the same expression findings before and after the change. That covers `examples/app-todo`, `examples/app-crm`, both `examples/app-multi-package` sub-stacks, the objects, actions, flows, views and pages of `examples/app-showcase`, the 51 objects `@objectstack/platform-objects` exports, and `plugin-approvals`' `sys_approval_request`, whose eight action predicates read the leaves of its declared `viewer` read attachment and pass. No in-repo producer writes a non-dot spelling of an undeclared field.
29+
- No public export or signature moves; only the doc comments of `ExprSchemaHint.fields` and `ExprSchemaHint.attachedOnRead` change. `analyzeRelationshipTraversals` answers exactly what it answered before: measured identical, set order included, on 4,528 analyses of the 2,264 `record` / `previous` expressions in this repository's TypeScript sources.

‎.github/workflows/ci.yml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1460,7 +1460,9 @@ jobs:
14601460
# held by every later refresh; it rises only by a ruled entry in the
14611461
# script's RULED_CEILING_RAISES. A `provisional` package prints "no
14621462
# ceiling: provisional" and is not red; a package absent from the dataset
1463-
# is red (the new-package shape). See the script header for the ruling.
1463+
# prints "no ceiling: new package" and is not red until the first refresh
1464+
# after it lands gives it a ceiling, while a package the dataset knows
1465+
# that lost its row is refused, red. See the script header for the ruling.
14641466
#
14651467
# HERE, in the aggregator, rather than in each shard, because the CLI's
14661468
# file-level slices land on three shards: the three windows are SUMMED

‎content/docs/permissions/sso.mdx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -279,9 +279,10 @@ after DNS propagates.
279279
<Callout type="info">
280280
**The mounts are unconditional; the switch controls the endpoint behind them.**
281281
Both routes exist whether or not `OS_SSO_DOMAIN_VERIFICATION` is set — and with
282-
it unset the two halves report that differently, so match on the code rather than
283-
the status: step 1 answers **400 `DOMAIN_VERIFICATION_DISABLED`**, step 2 passes
284-
the inner **404** through with an explanatory message. Either way an anonymous
282+
it unset there is no endpoint behind them, so both steps answer **400
283+
`DOMAIN_VERIFICATION_DISABLED`**. With it on, either step answers an unknown
284+
`providerId` with **404 `RESOURCE_NOT_FOUND`**; step 2's `NO_PENDING_VERIFICATION`
285+
is a 404 too, so match on the code rather than the status. Either way an anonymous
285286
caller gets **401 `UNAUTHENTICATED`** and a signed-in non-platform-admin **403
286287
`PERMISSION_DENIED`** — identity is answered before capability, so a stranger
287288
cannot use these routes to probe which features an environment has enabled.

‎packages/cloud-connection/src/install-local-principal.fixtures.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export function installerGrantRows(userId: string = INSTALLER_USER_ID): Record<s
5353
sys_position: [],
5454
sys_position_permission_set: [],
5555
sys_user_permission_set: [
56-
{ id: 'ups_installer', user_id: userId, permission_set_id: 'ps_installer', organization_id: null },
56+
{ id: 'ups_installer', user_id: userId, permission_set_id: 'ps_installer', permission_set: 'admin_full_access', organization_id: null },
5757
],
5858
sys_permission_set: [
5959
{

‎packages/cloud-connection/src/marketplace-install-local-capability-enumeration.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,10 @@ function grantRows(shape: Shape): Record<string, any[]> {
134134
sys_position: [],
135135
sys_position_permission_set: [],
136136
sys_user_permission_set: held.length
137-
? [{ id: 'ups1', user_id: `usr_${shape}`, permission_set_id: 'ps1', organization_id: null }]
137+
? [{
138+
id: 'ups1', user_id: `usr_${shape}`, permission_set_id: 'ps1',
139+
permission_set: shape === 'capable' ? 'admin_full_access' : 'organization_admin', organization_id: null,
140+
}]
138141
: [],
139142
sys_permission_set: held.length
140143
? [{ id: 'ps1', name: shape === 'capable' ? 'admin_full_access' : 'organization_admin', system_permissions: held }]

‎packages/cloud-connection/src/marketplace-install-local-list-posture.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,10 @@ function grantRows(shape: Shape): Record<string, any[]> {
111111
sys_position: [],
112112
sys_position_permission_set: [],
113113
sys_user_permission_set: held.length
114-
? [{ id: 'ups1', user_id: `usr_${shape}`, permission_set_id: 'ps1', organization_id: null }]
114+
? [{
115+
id: 'ups1', user_id: `usr_${shape}`, permission_set_id: 'ps1',
116+
permission_set: shape === 'operator' ? 'admin_full_access' : 'organization_admin', organization_id: null,
117+
}]
115118
: [],
116119
sys_permission_set: held.length
117120
? [{

‎packages/cloud-connection/src/marketplace-install-local-listing-not-loaded.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ const MEMBER_GRANTS: Record<string, unknown[]> = {
8989
sys_user_position: [],
9090
sys_position: [],
9191
sys_position_permission_set: [],
92-
sys_user_permission_set: [{ id: 'ups_member', user_id: MEMBER_ID, permission_set_id: 'ps_member', organization_id: null }],
92+
sys_user_permission_set: [{ id: 'ups_member', user_id: MEMBER_ID, permission_set_id: 'ps_member', permission_set: 'organization_admin', organization_id: null }],
9393
sys_permission_set: [{ id: 'ps_member', name: 'organization_admin', system_permissions: ['setup.access', 'manage_org_users'] }],
9494
};
9595

@@ -125,6 +125,8 @@ async function restartWith(manifests: Array<{ id: string; version: string }>, di
125125
if (k.startsWith('$') || (v !== null && typeof v === 'object')) {
126126
throw new Error(`only scalar equality is implemented here (got '${k}')`);
127127
}
128+
// An absent column reads as NULL, as it does in SQL (`organization_id: null`).
129+
if (v === null) return (row[k] ?? null) === null;
128130
return row[k] === v;
129131
}));
130132
return (typeof query?.limit === 'number' ? rows.slice(0, query.limit) : rows).map((row) => ({ ...row }));

‎packages/cloud-connection/src/marketplace-install-local-tenancy-admission.test.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,11 @@ function matchesWhere(row: any, where: any): boolean {
108108
if (!(cond as any).$in.includes(row[field])) return false;
109109
continue;
110110
}
111+
// An absent column reads as NULL, as it does in SQL (`organization_id: null`).
112+
if (cond === null) {
113+
if ((row[field] ?? null) !== null) return false;
114+
continue;
115+
}
111116
if (row[field] !== cond) return false;
112117
}
113118
return true;
@@ -143,10 +148,10 @@ function permissionStore(opts: { syncSchemas: () => Promise<void> }) {
143148
sys_position: [],
144149
sys_position_permission_set: [],
145150
sys_user_permission_set: [
146-
{ id: 'ups_member', user_id: 'u_member', permission_set_id: 'ps_install', organization_id: null },
147-
{ id: 'ups_exmember', user_id: 'u_exmember', permission_set_id: 'ps_install', organization_id: null },
148-
{ id: 'ups_orgless', user_id: 'u_orgless', permission_set_id: 'ps_install', organization_id: null },
149-
{ id: 'ups_session', user_id: 'u_session', permission_set_id: 'ps_install', organization_id: null },
151+
{ id: 'ups_member', user_id: 'u_member', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: null },
152+
{ id: 'ups_exmember', user_id: 'u_exmember', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: null },
153+
{ id: 'ups_orgless', user_id: 'u_orgless', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: null },
154+
{ id: 'ups_session', user_id: 'u_session', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: null },
150155
],
151156
sys_permission_set: [
152157
{ id: 'ps_install', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access'] },

0 commit comments

Comments
 (0)