Skip to content

Commit d46ae39

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-24758-password-param-type
2 parents 61d7caf + 9a06f0b commit d46ae39

51 files changed

Lines changed: 1998 additions & 1077 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: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/plugin-sharing': minor
4+
'@objectstack/plugin-approvals': minor
5+
'@objectstack/metadata-protocol': minor
6+
---
7+
8+
feat(plugin-sharing,plugin-approvals,metadata-protocol)!: the organization-less readers of sharing rules, approval units and the seed loader retire (ADR-0131 D2/D8, 18.x step 7)
9+
10+
Clause-②: yes (narrowing): a declared sharing rule served under `single` gains the `deploymentWide: true` key on the wire in place of `organization_id: null` (widening); the sharing-rule admin reads, the approval unit screen and the scoped seed pre-load stop reading rows with no organization (narrowing). A declared rule keeps applying deployment-wide; nothing an author declares is refused.
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. The data migration a database with organization-less rows needs is the operator's ceremony, `os migrate organization-ownership`, stated below; the 18.x boot refusal already names it. The one wire change is a declared sharing rule's response row (`deploymentWide: true` in place of `organization_id: null`), not an authorable key. -->
13+
14+
**BREAKING** — readers narrow. On a certified database (every 18.x database: a fresh install, or one the organization-ownership ceremony upgraded) none of these readers has a row left to find; they matter only to a database that still holds organization-less rows, which 18.x refuses at boot.
15+
16+
- **A declared sharing rule is deployment-wide by a typed marker, never a NULL organization.** Under `single` it is served as one projection carrying `deploymentWide: true` and no `organization_id` key (was `organization_id: null`); its criteria sweep carries no tenant (the predicate ADR-0131 D8 computes under `single` is none) and each grant is stamped with its record's organization. Under `group` / `isolated` it is served once per organization, exactly as before. FROM `rule.organization_id === null` TO `rule.deploymentWide === true` for any client that told a declared rule apart by its NULL organization (`GET /api/v1/sharing/rules`, `GET /api/v1/sharing/rules/:idOrName`).
17+
- **The contract names the marker.** `SharingRuleRow` (`@objectstack/spec/contracts`) declares `deploymentWide?: true`, documented: set only on a declared rule's `single` projection, which carries no `organization_id`.
18+
- **A stored sharing rule with no organization sweeps nothing and grants nothing** where the deployment registers the organization object, reported once at `error` with the remedy (run the organization-ownership ceremony, or re-author the rule in its organization). A lean embedding with no organization object keeps sweeping the deployment: it has no organization to scope by.
19+
- **Sharing-rule administration reads the caller's own organization only.** `listRules`, `getRule` (by id and by name), `evaluateRule` and `deleteRule` no longer reach an `organization_id = null` row for an organization-bound admin; system contexts and platform operators keep the unfiltered read. Deleting a "platform-global" rule no longer needs platform authority, because that rule class no longer exists: the guard is removed.
20+
- **The approval unit screen is a strict equality.** A `department` approver resolves a `sys_business_unit` in the request's organization only (was: that organization or no organization). The ceremony attributes `sys_business_unit`, and a fresh install stamps it.
21+
- **The scoped seed pre-load reads one thing: its organization's rows.** The second read that collected keys held by organization-less rows, and refused to twin or adopt them, is deleted; a scoped upsert/ignore/update issues no `organization_id IS NULL` read on any path.
22+
23+
Remedy for a database that still holds organization-less rows: `os migrate organization-ownership --out PLAN`, review it, then `os migrate organization-ownership --apply --plan-file PLAN --backup-taken`.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/cli': minor
3+
'@objectstack/driver-sql': minor
4+
'@objectstack/driver-turso': minor
5+
'@objectstack/objectql': minor
6+
'@objectstack/lint': patch
7+
'@objectstack/spec': patch
8+
---
9+
10+
feat!: an organization-less row on a tenant table no longer draws its autonumber from the `__global__` counter, and a production server boot with no auth secret is refused
11+
12+
Clause-②: yes (narrowing)
13+
14+
<!-- adr-0087: not-required (runtime-export-retired @objectstack/objectql#GLOBAL_TENANT) The one author-reachable removal is a runtime export: the string constant `GLOBAL_TENANT` leaves `@objectstack/objectql`'s published entry. No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone; the two run-time narrowings (the driver's counter, `os serve`'s boot) carry their own remedy below. -->
15+
16+
**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D9, stage 8d).
17+
18+
- **`os serve`: a production server boot whose auth did not compose for a missing secret is refused.** With the `auth` tier on and no `OS_AUTH_SECRET` (outside `--dev`), `serve` used to skip AuthPlugin with a warning and serve anyway. That boot had no security model: anonymous object data is always denied, so its data API was unusable. It also had no organization object, so its inline seed wrote rows with `organization_id` NULL and drew their autonumbers from the `__global__` counter. Measured on `examples/app-showcase` before this change: health 200, two NULL-organization rows and a `__global__` counter row on a fresh install. The boot now stops before the kernel starts, so nothing is written, and the message names both fixes. Unchanged: `--no-server` (kernel only) still boots with the warning, a development boot still uses its fallback secret, and the cloud host kernel (`ObjectOSEnvironmentPlugin`, where auth is each project's) still only warns. `os start` never reaches the refusal: it always supplies a secret to the `serve` child it spawns.
19+
- **`@objectstack/driver-sql` (and `@objectstack/driver-turso`'s remote face, which calls the same rule): the counter refuses an organization-less row on a tenant table.** Wherever the driver registered the organization object (`sys_organization`), a `create` / `bulkCreate` / `upsert` on an object with a tenant column, whose row carries no organization and whose options carry no `tenantId`, is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (status 500), the code the engine already answers for the same write. It is refused before any counter row is read or written, and nothing is written. New exports: `OrganizationlessCounterRefusal`, `ORGANIZATIONLESS_COUNTER_REFUSAL_CODE`, `ORGANIZATIONLESS_COUNTER_REFUSAL_STATUS`, `renderOrganizationlessCounterRefusal`. Unchanged: `__global__` stays the counter key of an object with no tenant column, and of a composition that registers no organization object on the driver (a lean `@objectstack/objectql/core` embedding), where no organization can exist. Counter rows a 17.x install filed under `__global__` for a tenant table stay in `_objectstack_sequences`; the counter no longer reads them, and `os migrate duplicates` still reports them.
20+
- **`@objectstack/objectql`: `GLOBAL_TENANT` is no longer exported**, and `SystemWriteOrganizationRequiredError`'s message no longer describes the `__global__` counter as where the write would land. The code, status and fields are unchanged.
21+
- **Texts.** The lint explanation of `unique/legacy-organization-composite`, the `UniqueScopeSchema` TSDoc and the error-code ledger now say that a NULL-organization row is residue an earlier release wrote, not something a new write produces.
22+
23+
**The remedy.**
24+
- A production boot refused for the missing secret: set `OS_AUTH_SECRET` (a long random value, identical across restarts and nodes), or run kernel-only with `--no-server`.
25+
- A direct driver write refused by the counter: carry the organization — `driver.create(object, { ...row, organization_id: orgId })` or `driver.create(object, row, { tenantId: orgId })` — or write through the engine, which derives it under `single`.
26+
27+
The retired export:
28+
29+
- FROM `GLOBAL_TENANT` TO `GLOBAL_TENANT` imported from `@objectstack/driver-sql`, where the counter and the index emitters keep it (or the literal `'__global__'`).
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/plugin-auth': minor
3+
'@objectstack/spec': minor
4+
---
5+
6+
fix(plugin-auth): `POST /api/v1/auth/organization/invite-member` refuses a role outside the membership vocabulary with `code: 'ROLE_NOT_FOUND'`
7+
8+
Clause-②: yes (widening)
9+
10+
- **Before.** An invitation naming a role outside the closed membership vocabulary was refused `400` with only a message (`ROLE_NOT_FOUND: <role>`) and no `code`, so a client that branches on `error.code` (ADR-0112) could not recognise it. `update-member-role` already answered the same refusal with `code: 'ROLE_NOT_FOUND'`.
11+
- **Now.** invite-member answers `400` with `code: 'ROLE_NOT_FOUND'` and the same message, naming the role, the body `update-member-role` gives for the same condition. The refusal still happens before any invitation row is written. This is the answer the invitation schema's `role` docstring and the authentication docs already promise.
12+
- **The ledger.** `ROLE_NOT_FOUND` is registered under `@objectstack/plugin-auth` in the error-code ledger, so `ErrorCode` (and `ApiErrorSchema.code`) admits it.
13+
- **Unchanged.** A valid role invites as before, and every other refusal on invite-member keeps its answer.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/plugin-approvals': patch
3+
---
4+
5+
fix(plugin-approvals): a reminder's `notified` counts each approver address once, not once per slate slot
6+
7+
Clause-②: no
8+
9+
`POST /api/v1/approvals/requests/:id/remind` (`ApprovalService.remind()`) answers `notified` as the number of reminders it handed to messaging: one per distinct approver address on the pending slate. A `per_group` slate lists a person once per group they resolved into, and `pending_approvers` keeps every one of those slots until the first vote, so a person placed in two groups was counted twice while messaging delivered them one reminder. They are now reminded once and counted once, the rule the opening `approval.requested` notice already applies.
10+
11+
A user id and the same account's email are still two addresses: the person holds two slots and receives two reminders, exactly as at the opening. Unchanged: who is reminded, each reminder's topic, payload, deep link and dedup key, the throttle, the submitter check, the `remind` audit row, and `position:*`-style literals, which still notify no one and add nothing to the count.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
fix(plugin-auth): a refused OAuth application registration answers the status the refusal declared, not 500 (#24773)
6+
7+
Clause-②: no
8+
9+
`POST /api/v1/auth/sys-oauth-application/register` answered most refusals from better-auth as `500 OAUTH_REGISTER_FAILED`. better-auth raises each refusal as an `APIError` whose HTTP status is the number `statusCode` (`400`) and the name `status` (`'BAD_REQUEST'`). The route read only a numeric `status`, so a named refusal fell through to 500. Two examples are a redirect URI the provider refuses, such as `http://localhost:4001/callback` on a `web` client (`invalid_redirect_uri`), and a request with no session (`UNAUTHORIZED`).
10+
11+
- **Now.** The route answers the refusal's own status: `400` for the redirect-URI refusal and `401` for the missing session. The code stays `OAUTH_REGISTER_FAILED`, and `error.details.upstreamError` still carries the provider's discriminator (`invalid_redirect_uri`).
12+
- **Unchanged.** A throw that declares no status, such as a storage fault while the client is persisted, still answers `500`. The body-schema refusal keeps answering `400`. Which redirect URIs a client may register is unchanged: a `web` client still needs an `https` URI on a non-loopback host.

‎content/docs/permissions/system-context.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ One of the largest consumers of the flag;
159159
| 37 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `packages/plugins/plugin-sharing/src/sharing-plugin.ts#buildSharingMiddleware` |
160160
| 38 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `packages/plugins/plugin-sharing/src/share-link-service.ts#createLink`, `#revokeLink`, `#listLinks` |
161161
| 39 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `packages/plugins/plugin-sharing/src/sharing-rule-provenance.ts#bindRuleProvenanceStamp` |
162-
| 40 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, the platform-global-rule delete guard, and the declared-name guard on `sys_sharing_rule` writes (a new row or a rename taking a declared rule's name, 409) — `defineRule` makes that check itself before its own system write, and the other system writers are the platform's machinery (the ownership ceremony, migrations) | `packages/plugins/plugin-sharing/src/sharing-rule-service.ts#assertCanManageRules`, `#assertCanDeletePlatformGlobalRule`, `#bindDeclaredRuleNameGuard` |
162+
| 40 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface and the declared-name guard on `sys_sharing_rule` writes (a new row or a rename taking a declared rule's name, 409) — `defineRule` makes that check itself before its own system write, and the other system writers are the platform's machinery (the ownership ceremony, migrations) | `packages/plugins/plugin-sharing/src/sharing-rule-service.ts#assertCanManageRules`, `#bindDeclaredRuleNameGuard` |
163163

164164
### 4. Approvals, attachments, comments, knowledge
165165

‎content/docs/references/api/contract.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);
2828

2929
| Property | Type | Required | Description |
3030
| :--- | :--- | :--- | :--- |
31-
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +339 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
31+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +340 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
3232
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
3333
| **message** | `string` | ✅ | Readable error message |
3434
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
@@ -311,6 +311,7 @@ const result = ApiErrorSchema.parse(data);
311311
* `RESUME_FAILED`
312312
* `RESUME_IN_PROGRESS`
313313
* `RESUME_TARGET_LOST`
314+
* `ROLE_NOT_FOUND`
314315
* `ROLLED_BACK`
315316
* `ROUTE_NOT_FOUND`
316317
* `RULE_DEFINE_FAILED`

‎content/docs/references/api/error-code-ledger.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,7 @@ const result = ErrorCode.parse(data);
478478
* `RESUME_FAILED`
479479
* `RESUME_IN_PROGRESS`
480480
* `RESUME_TARGET_LOST`
481+
* `ROLE_NOT_FOUND`
481482
* `ROLLED_BACK`
482483
* `ROUTE_NOT_FOUND`
483484
* `RULE_DEFINE_FAILED`

‎packages/cli/src/commands/migrate/duplicates.ts‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,14 @@ import type { RuntimeIndexPreflight, SeedTenancyExec } from '@objectstack/metada
4343
* schema, no rows written, nothing left behind.
4444
* 4. **The live CONDITION is reported too** — an install still holding a
4545
* `__global__` counter beside an organization-scoped one for the same
46-
* object is about to mint more duplicates. The only forward-looking line in
47-
* the report.
46+
* object. Under 17.x that install was about to mint more duplicates, and
47+
* this was the only forward-looking line in the report. Since ADR-0131 D9
48+
* (stage 8d) the counter refuses an organization-less row on a tenant
49+
* table wherever the organization object is registered, so such a
50+
* `__global__` row is no longer drawn from: the line now names the residue
51+
* the split left in `_objectstack_sequences` — the evidence of which
52+
* counter minted the duplicates above — rather than a forecast. The report
53+
* shape is unchanged.
4854
* 5. **A data-side probe** — `GROUP BY <field> HAVING COUNT(*) > 1` within the
4955
* object's own table, never an enumeration of `_objectstack_sequences`. A
5056
* duplicate on an object whose counter was since merged is therefore still
@@ -154,10 +160,12 @@ export interface DuplicateIdentifier {
154160
}
155161

156162
/**
157-
* One object/field still running two counters — the live condition (point 4).
163+
* One object/field still holding two counters — the live condition (point 4).
158164
*
159-
* Not damage: a prediction. While both counters stand, each allocates within
160-
* its own partition and neither can see the other's numbers.
165+
* Not damage. Under 17.x it was a prediction: while both counters stood, each
166+
* allocated within its own partition and neither could see the other's
167+
* numbers. Since ADR-0131 D9 the `__global__` one is residue the counter no
168+
* longer draws from on a tenant table (point 4 above).
161169
*/
162170
export interface DuplicateLiveCondition {
163171
object: string;

‎packages/cli/src/commands/serve.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4674,6 +4674,29 @@ export default class Serve extends Command {
46744674
+ "view, a share link, or `book.audience: 'public'`.",
46754675
);
46764676
}
4677+
// [ADR-0131 D9] The same case reached the other way: the `auth` tier is
4678+
// on, but a production boot resolved no secret, so AuthPlugin is not
4679+
// composed (rule 4 of `resolvePlatformAuthComposition`). A1's conclusion
4680+
// holds unchanged — no security model, so no data API — and it holds for
4681+
// a second reason too: with no AuthPlugin there is no organization
4682+
// object, so every write the boot makes (the inline seed first) would land
4683+
// with no organization and draw its autonumbers from an organization-less
4684+
// counter. Refused here, while the kernel has only been composed: no
4685+
// plugin has started, so nothing has been written. The `host-kernel`
4686+
// reason (the cloud runtime, where auth is each project's) is not this
4687+
// case and still only warns above.
4688+
if (flags.server && !authComposition.composes && authComposition.reason === 'no-secret') {
4689+
throw new Error(
4690+
'This stack enables auth, but no auth secret is set, so AuthPlugin was not composed and no caller '
4691+
+ 'can authenticate — anonymous access to object data is always denied, so the data API would be '
4692+
+ 'unusable, and with no organization every row this boot writes would have no owner.\n'
4693+
+ 'Fix it one of two ways:\n'
4694+
+ ' • set OS_AUTH_SECRET (a long random value, identical across restarts and nodes), e.g. '
4695+
+ 'OS_AUTH_SECRET=$(openssl rand -hex 32);\n'
4696+
+ ' • or serve without the data API — run with --no-server, or drop the REST/dispatcher plugins.\n'
4697+
+ 'A local trial does not need the secret: `os dev` (or `os serve --dev`) uses a development fallback.',
4698+
);
4699+
}
46774700

46784701
try {
46794702
const { createRestApiPlugin } = await import('@objectstack/rest');

0 commit comments

Comments
 (0)