diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8236ef37f1..8ae16472fc 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -72,6 +72,13 @@ jobs: - name: Single authz resolver guard run: pnpm check:authz-resolver + # Release-notes drift guard: the platform is one version-locked train, so + # every released @objectstack/spec major must have a curated, navigable + # release page at content/docs/releases/v.mdx. Catches the gap that + # let v10–v14 ship with no page while spec was already at 14.x. + - name: Release-notes drift guard + run: pnpm check:release-notes + typecheck: name: TypeScript Type Check runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index c1262d07bb..cbd85a5236 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to the ObjectStack Protocol will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +> **Where release history now lives.** The platform ships as one version-locked +> train (changesets `fixed` group), so the update history is maintained in three +> layers rather than by hand-editing this file: +> +> - **Per-package detail** — each package's `CHANGELOG.md` is generated by +> [changesets](https://github.com/changesets/changesets) from the `.changeset/` +> entries every PR adds (including `@objectstack/console`, which now carries a +> changeset for each bundled objectui/frontend bump). +> - **Platform, developer-facing narrative** — the curated per-major pages under +> [`content/docs/releases/`](content/docs/releases) lead with breaking changes + +> migration and cover backend **and** Console (frontend). Start there. +> - **This file** is retained for its historical entries. The `[Unreleased]` +> backlog below predates the per-major release pages; its items are released in +> 12.x–14.x and are being migrated into those pages — prefer them for anything +> new. See `docs/releases-maintenance.md` for the full maintenance model. + ## [Unreleased] ### Changed — Security: anonymous access denied by default (BREAKING) + destructive-op RBAC gates diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index df4ab2a260..b2e0c7bec9 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,13 @@ # Release Notes +> **📓 This file is historical.** Current, developer-facing release notes live in +> the curated per-major pages under [`content/docs/releases/`](content/docs/releases) +> — they lead with breaking changes + migration and cover backend **and** Console +> (frontend) per platform version. For exhaustive per-package detail, see each +> package's changeset-generated `CHANGELOG.md`. The entries below are kept for +> historical reference; see `docs/releases-maintenance.md` for how release history +> is maintained. + > **v5.0 (2026): `project` → `environment`.** The runtime concept formerly called *Project* (per-tenant business workspace; Org/Project/Branch hierarchy) is now called *Environment* throughout the codebase: CLI flags (`--environment`/`-e`), HTTP paths (`/api/v1/environments/:environmentId/...`), headers (`X-Environment-Id`), env vars (`OS_ENVIRONMENT_ID`), exported symbols (`createSystemEnvironmentPlugin`, `SYSTEM_ENVIRONMENT_ID`), DB columns (`environment_id`), and JSON schemas (`EnvironmentArtifact`) all use the new term with no aliases or deprecation shims. The word "project" elsewhere in this document still refers to the npm/monorepo sense (i.e., the framework as a software project). See `.changeset/v5-project-to-environment-rename.md` for the full breaking-change list and ADR-0006 for the rationale. diff --git a/content/docs/releases/index.mdx b/content/docs/releases/index.mdx index 20101135fa..47a8fb8d88 100644 --- a/content/docs/releases/index.mdx +++ b/content/docs/releases/index.mdx @@ -18,8 +18,14 @@ migration steps, then covers new capabilities and notable fixes. ## Versions +- [v14.0.0](/docs/releases/v14) — ADR-0090 vocabulary convergence completed, object `enable.*` flags become real gates, admin user management, phone/SMS auth, book-audience enforcement, data-lifecycle contract, and effective-dated grants (current series: 14.4.0). +- [v13.0.0](/docs/releases/v13) — Permission Model v2 (ADR-0090): Roles and Profiles converge on Positions, custom objects default to private, plus an explain engine, delegated administration, and self-serve MCP OAuth. +- [v12.0.0](/docs/releases/v12) — Anonymous data access denied by default (ADR-0056 D2), adaptive record surfaces, an enforced protocol-version handshake, and build-gating author-time lints. - [v9.0.0](/docs/releases/v9) — Analytics single-form cutover (ADR-0021), honest chart taxonomy, canonical `OS_*` settings env vars, Google sign-in, AI build experience. +> Curated notes for v10 and v11 are not yet written; consult the per-package +> `CHANGELOG.md` files for those versions in the meantime. + ## Where the fine-grained changelogs live These pages are curated summaries. For exhaustive, per-package detail: diff --git a/content/docs/releases/meta.json b/content/docs/releases/meta.json index 4df96025f5..b790063320 100644 --- a/content/docs/releases/meta.json +++ b/content/docs/releases/meta.json @@ -1,5 +1,5 @@ { "title": "Releases", "icon": "Tag", - "pages": ["index", "v9", "implementation-status"] + "pages": ["index", "v14", "v13", "v12", "v9", "implementation-status"] } diff --git a/content/docs/releases/v12.mdx b/content/docs/releases/v12.mdx new file mode 100644 index 0000000000..482ccff73e --- /dev/null +++ b/content/docs/releases/v12.mdx @@ -0,0 +1,222 @@ +--- +title: v12.0.0 +description: Anonymous data access denied by default (ADR-0056 D2), adaptive record surfaces, an enforced protocol-version handshake, and a wave of author-time correctness lints. +--- + +**Released July 2026.** All `@objectstack/*` packages are published at 12.0.0. +This major flips the platform's default security posture — anonymous requests +to the data API are now denied — and lands a wave of author-time correctness +features: adaptive record surfaces derived from metadata, package-shipped +permission sets, an enforced `engines.protocol` handshake, and build-gating +lints for view references, form layouts, and import mappings. + +**TL;DR for upgraders:** if any deployment serves data anonymously (a demo, +kiosk, or public playground), you must now set `api: { requireAuth: false }` +on the stack or every `/data/*` call returns HTTP 401. Everything else in this +release is additive — existing metadata keeps loading. + +## Breaking changes + +### `api.requireAuth` defaults to `true` — anonymous data access is denied (ADR-0056 D2) + +The global `requireAuth` default flipped from `false` to `true` +(`RestApiConfigSchema.requireAuth` in `@objectstack/spec`, mirrored by +`RestServer.normalizeConfig` in `@objectstack/rest`). Anonymous requests to the +`/data/*` CRUD and batch endpoints are now rejected with **HTTP 401** unless +the deployment explicitly opts out. + +**Migration (one line).** A deployment that intentionally serves data publicly +sets the flag on the stack config — now a declared +`ObjectStackDefinitionSchema.api` field, so it survives `defineStack` strict +parsing (an undeclared top-level `api` key was previously stripped silently): + +```ts +export default defineStack({ + // … + api: { requireAuth: false }, +}); +``` + +The REST plugin logs a boot warning for the explicit opt-out, so a fail-open +posture is always visible. A misplaced `api.requireAuth` at the plugin level +(one nesting short) is also called out with a boot warning instead of being +ignored. + +**What keeps working with no action:** + +- **Share links** — validate their token, then read under a system context. +- **Public forms** — self-authorizing via the declaration-derived + `publicFormGrant` (create + read-back on the declared target object only). +- **Control plane** — `/auth`, `/health`, `/discovery` are exempt. +- **`objectstack serve` with an auth-less stack** — the CLI passes an explicit + `requireAuth: false` for stacks whose tier set has no `auth` (nothing could + authenticate against them), with the boot warning. + +> Scope note: this gate covers the REST `/data/*` surface. The metadata +> read/write endpoints and the dispatcher GraphQL route have their own +> pre-existing anonymous posture, tracked separately. + +## New in the framework + +### Adaptive record surfaces and semantic field spans (#2578) + +Because all metadata is AI-authored, the design goal is to make the model +unable to get layout wrong — so these features are **derived**, not authored: + +- **`deriveRecordSurface` (ADR-0085 §5)** — a record's default surface (full + `page` vs. `drawer`/`modal` overlay) is derived from how heavy the record is + (visible, non-system field count; mobile always pages). No new object key; an + explicit form/navigation config still wins. +- **`FormField.span: 'auto' | 'full'`** — replaces absolute `colSpan` as the + primary primitive. Under a per-surface derived column count (mobile 1 / + modal 2 / page 3–4), `span` is decoupled from the count so a field lays out + correctly at any width. `colSpan` is retained and clamped for back-compat. +- **`NavigationConfig.size`** — a T-shirt bucket (`auto`/`sm`/`md`/`lg`/`xl`/ + `full`) replacing the pixel `width`/`drawerWidth`, which an AI author cannot + choose blind. `auto` lets the renderer size from field count and clamp to the + viewport. +- **`validateFormLayout` lint** — advisory `form-field-unknown` (a section + references a field not on the bound object) and `absolute-colspan-discouraged`. + +`deriveRecordFlowSurface` (12.2.0) extends this to be flow-aware: `view` keeps +the shipped behavior, while the `create`/`edit`/`child-*` task flows are always +overlays (a task's URL is a false promise — a refresh loses the draft). + +### Detail-page related lists: `'primary'` prominence (#2579) + +`Field.relatedList` on a child's `lookup`/`master_detail` FK becomes a +tri-state `boolean | 'primary'`. `'primary'` marks a core relationship that the +detail page promotes to its own tab; non-primary children collapse into a +single shared **Related** tab. `RecordRelatedListProps.columns` becomes +optional — when omitted, columns derive from the child object's +`highlightFields`. Purely additive and opt-in per relationship. + +### Package-shipped permission sets (ADR-0086 P1) + +Packages now ship working default access for their own objects, with a +machine-checkable metadata↔config boundary: + +- `PermissionSetSchema.packageId` (owning package) and per-record provenance + `managedBy: 'package' | 'platform' | 'user'`, persisted on + `sys_permission_set` as `package_id` / `managed_by`. +- **`bootstrapDeclaredPermissions`** materializes `stack.permissions` into + `sys_permission_set` at boot (`managed_by: 'package'`). Idempotent and + upgrade-aware: seeder-owned rows are re-seeded to the shipped declaration; + rows owned by a different package are refused loudly; env-authored rows are + never clobbered. Closes the inert-metadata gap where declared sets were + enforced at runtime but never materialized (invisible to the admin surface). + +### The protocol-version handshake is now enforced (ADR-0087 P0) + +`PluginEnginesSchema.protocol` was declared, documented, and checked by no +loader or installer. Now: + +- `@objectstack/spec` exports **`PROTOCOL_VERSION` / `PROTOCOL_MAJOR`** — the + single source of truth, kept in lockstep with the package major by a drift + test. +- `@objectstack/metadata-core` adds `checkProtocolCompat()` / + `assertProtocolCompat()` and the structured `ProtocolIncompatibleError` + (`OS_PROTOCOL_INCOMPATIBLE`, carrying both versions and the + `objectstack migrate meta --from N` command). +- `installPackage` runs the handshake **before** writing to the registry — an + incompatible package is refused with a machine-actionable diagnostic instead + of crashing later in a schema `.parse()`. + +Additive: packages that declare no `engines.protocol` range keep loading (with +a warning). + +### Named import mappings (#2611) + +`POST /data/:object/import` accepts `mappingName`, resolving a registered +`defineMapping` artifact (stack `mappings:`) and applying its `fieldMapping` +pipeline (rename + constant/map/split/join; lookups delegate to the built-in +reference resolution) as a strict projection before coercion. Errors are loud +and specific (`MAPPING_NOT_FOUND`, `MAPPING_TARGET_MISMATCH`, +`UNSUPPORTED_TRANSFORM` for `javascript`, …). `defineStack` cross-reference +validation rejects mappings targeting undefined objects at build time. + +### Smaller declarative additions + +- **`ObjectNavItem.filters`** — declarative URL filter conditions + (`filter[field]=value`) targeting the parameterized bare data surface, for + one-off / parameterized slices (dashboard drill-throughs, "assigned to me"). + Values support the `{current_user_id}` / `{current_org_id}` template + variables. Combining `filters` with `recordId`/`viewName` is now unwritable + (correct-by-construction). +- **`SelectOption.visibleWhen`** — a per-option CEL visibility predicate for + `select`/`multiselect`/`radio`, expressing cascading / dependent options + (`record.country == 'cn'`) and context gating without a bespoke matrix. +- **`Action.order`** — an explicit ordering lever (lower = more prominent) for + which action holds the record-header primary-button slot, instead of relying + on fragile cross-file registration order. Fully backward compatible (unset = + `0`, stable sort). +- **Retired placeholder metadata kinds** (ADR-0088) — `trigger`, `router`, + `function`, `service` are removed from `MetadataTypeSchema` (30 → 26 kinds); + each had no authoring surface. The delivered replacements are `hook` / + `record_change` flows, plugin `contributes.routes` + declarative `apis:`, + `defineStack({ functions })`, and the plugin/service registry. + +## Author-time correctness (lints that gate the build) + +- **`lint-view-refs`** (#2554) — a `type: 'form'` action whose `target` + resolves to a list view now **fails `os compile`** (the concrete breakage: a + blank form, a silently no-op submit). Silent `._2` rename + collisions are surfaced as warnings. Shifts objectui's runtime `viewKind` + guard left to compile time. +- **`validateListViewMode`** (ADR-0053 phase 4) — `userFilters` on an object + list view is now a `tsc` error via `ObjectListViewSchema`, stripped at parse + for back-compat, and reported pre-parse with a fix hint. +- **`transfer` / `restore` / `purge` pre-mapped to RBAC bits** (#1883) — the + permission evaluator maps the destructive lifecycle operations to + `allowTransfer` / `allowRestore` / `allowPurge` (with the `modifyAllRecords` + bypass), so the gate exists ahead of the operations themselves (roadmap M2). + Descriptions moved from `[EXPERIMENTAL — not enforced]` to `[RBAC-gated; + operation pending M2]`. + +## New in Console (Studio) + +The Console build frozen into `@objectstack/console` 12.x renders the adaptive +surfaces and related-list model this release derives: + +- **Records open at the right size automatically** — the renderer consumes + `deriveRecordSurface` / `deriveRecordFlowSurface`: field-heavy records open as + a full page, light ones as a drawer/modal, and create/edit tasks always as an + overlay that returns losslessly to its origin. An explicit form/navigation + config still wins. +- **Related lists gain structure** — a `relatedList: 'primary'` child gets its + own detail-page tab while the rest collapse into one shared **Related** tab; + every eligible FK surfaces its own list (a child referencing the parent + through several relationships now shows each), self-referential hierarchies + surface a "child" list, and the lookup picker and related list of the same + object share one `highlightFields` column source. +- **Parameterized nav landings** — an `ObjectNavItem.filters` entry resolves to + the bare data surface (`/:object/data?filter[…]`) with removable filter chips, + for drill-throughs and "assigned to me" links. +- **Dependent picklists** — `SelectOption.visibleWhen` hides options client-side + as a UX affordance (the value is still authorized server-side). + +For exhaustive, per-commit frontend detail, see the +[objectui CHANGELOG](https://github.com/objectstack-ai/objectui/blob/main/CHANGELOG.md). + +## Notable fixes + +- **Package seed can no longer wedge the platform via record-change + automation** (12.5.0). A seeded record whose lifecycle flow self-triggered + looped forever because a boolean re-entry guard never tripped (booleans + persist as integer `1` on SQLite/libsql and CEL `1 != true`). Fixed at three + layers: `ExecutionContext.skipTriggers` for seed/bulk writes, + `coerceBooleanFields()` on the after-hook view, and an `activeRecordFlows` + self-trigger backstop in the automation engine. +- **`Field.relatedList` JSDoc corrected** (12.1.0) — non-primary related lists + stack under one shared "Related" tab; there is no count-based auto-split. + +## Upgrade checklist + +1. Update all `@objectstack/*` dependencies to `^12.0.0` (they are + version-locked — upgrade them together). +2. If any deployment serves data anonymously, add `api: { requireAuth: false }` + to its `defineStack` config and confirm the boot warning appears. +3. Re-run `os compile` / `os validate` — the new view-reference, list-view-mode, + and form-layout lints may flag pre-existing authoring mistakes. +4. Declare `engines.protocol` on any package you ship so the handshake + advertises a real range instead of grandfathering with a warning. diff --git a/content/docs/releases/v13.mdx b/content/docs/releases/v13.mdx new file mode 100644 index 0000000000..b814af843a --- /dev/null +++ b/content/docs/releases/v13.mdx @@ -0,0 +1,212 @@ +--- +title: v13.0.0 +description: Permission Model v2 (ADR-0090) — Roles and Profiles converge on Positions, custom objects default to private, and the model gains an explain engine, delegated administration, and self-serve MCP OAuth. +--- + +**Released July 2026.** All `@objectstack/*` packages are published at 13.0.0. +This is the **Permission Model v2** release (ADR-0090): the authorization +vocabulary converges on a single concept — **Positions** — replacing the +overlapping Role and Profile ideas, and the row/field-security model gains a +secure-by-default posture, an explain engine, delegated administration, and +self-serve OAuth for MCP. + +**TL;DR for upgraders:** this is a one-step, no-alias rename wave (launch-window +discipline — breaking changes do not burn a major while the whole stack is in +lockstep). If your metadata authors roles, profiles, RLS role context, or +role-based sharing recipients, or if your code imports `sys_role*` tables or the +`SysRole*` exports, you must rename them to their **position** equivalents. And: +a custom object with an owner field and **no explicit `sharingModel`** now +defaults to **private** — previously it was fully public. + +## Breaking changes + +### 1. Roles and Profiles → Positions (ADR-0090 P1) + +Positions are **flat** — hierarchy lives on the business-unit tree, not on the +position graph — so `parent` is gone. The Profile concept is removed entirely. +Rename across metadata, config, and code: + +| Before | After | +|:---|:---| +| `sys_role` / `sys_user_role` (field `role`) / `sys_role_permission_set` (`role_id`) | `sys_position` / `sys_user_position` (`position`) / `sys_position_permission_set` (`position_id`) | +| `RoleSchema` / `defineRole` (with `parent`) | `PositionSchema` / `definePosition` (flat, no `parent`) | +| `ExecutionContext.roles[]`; CEL `current_user.roles` | `ExecutionContext.positions[]`; `current_user.positions` | +| stack property `roles:` | stack property `positions:` | +| metadata kinds `role` / `profile` | `position` (`profile` kind removed) | +| `PermissionSetSchema.isProfile` | removed; `isDefault` narrows to an install-time suggestion | +| `appDefaultProfileName` | `appDefaultPermissionSetName` | +| sharing recipient `role` / `role_and_subordinates` | `position` / `unit_and_subordinates` | +| `SysRole` / `SysUserRole` / `SysRolePermissionSet` (plugin-security exports) | `SysPosition` / `SysUserPosition` / `SysPositionPermissionSet` | +| `sys_business_unit_member.role_in_business_unit` | `function_in_business_unit` (values member/lead/deputy unchanged) | + +Sharing recipients now expand a `position` via `sys_user_position` ∪ the +better-auth membership transition source; `unit_and_subordinates` expands the +business-unit subtree. + +### 2. Secure OWD default — custom objects are private (ADR-0090 D1) + +A custom object with an owner field and no `sharingModel` now resolves +**`private`** (was fully public). System objects keep their explicit posture, +and unrecognised stored values fail closed. The OWD enum drops the legacy +aliases `read` / `read_write` / `full`. A new optional +`externalSharingModel` (external-audience dial, `private` default) lands as P1 +spec shape. + +If you relied on the implicit public default, declare it explicitly: + +```ts +defineObject({ + name: 'crm_lead', + sharingModel: 'public_read_write', // make the previous behavior explicit + // … +}); +``` + +### 3. RBAC link-table writes require a delegated-admin scope + +Writes to `sys_user_position`, `sys_position_permission_set`, +`sys_user_permission_set`, and `sys_permission_set` are now governed by the new +`DelegatedAdminGate` (see below). Tenant-level admins pass through; a plain CRUD +grant on those tables **no longer suffices** for anyone else. The `everyone` / +`guest` anchors stay tenant-level only. + +## New capabilities + +### Audience anchors: built-in `everyone` / `guest` positions (P2) + +`EVERYONE_POSITION` / `GUEST_POSITION` are seeded, system-managed positions. +Every authenticated principal implicitly holds `everyone`, so a set bound to it +resolves as an ordinary position-bound grant — **additive**. The old fallback +"cliff" is abolished: the configured baseline (`fallbackPermissionSet`, default +`member_default`) now applies **in addition to** explicit grants, and is seeded +as an `everyone` binding on the same table/audit/explain path. Sessionless HTTP +principals resolve as `principalKind: 'guest'` holding exactly `['guest']`. An +anchor-binding gate rejects binding a high-privilege set (View/Modify-All, +delete/purge/transfer, system permissions, `'*'`) to `everyone`/`guest`. + +### Delegated administration (P3, D12) + +`PermissionSetSchema.adminScope` (persisted as `sys_permission_set.admin_scope`) +declares **where** a delegate may act (a `sys_business_unit` subtree), **what** +(`manageAssignments` / `manageBindings` / `authorEnvironmentSets`), and **which** +sets they may hand out (`assignablePermissionSets`). Delegates operate inside +their subtree, with allowlisted sets only, single-row writes, and +`granted_by`-stamped audit. Granting or authoring a set that itself carries an +`adminScope` requires a held scope that strictly contains it. + +### The explain engine (P4, D6) + +`explain(principal, object, operation)` reports the verdict of **every** +evaluation-pipeline layer in order (principal → required_permissions → +object_crud → fls → owd_baseline → depth → sharing → vama_bypass → rls), with +per-layer contributor attribution and — for reads — the composed row filter as +a machine artifact. It is **"explained by construction"**: it calls the same +resolution, evaluator, FLS mask, and RLS composition the enforcement middleware +calls, so the report cannot drift from enforcement. Exposed on the `security` +kernel service; explaining another user requires `manage_users`. + +### Access-matrix snapshot gate + +`buildAccessMatrix(stack)` derives the (permission set × object) capability +matrix purely from metadata; `diffAccessMatrix` renders semantic review lines +("'crm_admin' gains delete on 'crm_lead'"). `os compile` gains an opt-in gate: +with `access-matrix.json` committed next to the config, any drift fails the +build until re-snapshotted via `--update-access-matrix` — every capability +change becomes a reviewable diff. + +### Self-serve OAuth 2.1 for MCP (#2698) + +Any OAuth-capable MCP client (claude.ai custom connectors, Claude Desktop, +Claude Code) can now connect to a deployment **self-serve** — no admin-minted +API key, no central registry. You sign in through the browser as yourself and +every tool call runs under your own permissions and row-level security. + +- **Each deployment is its own authorization server**, backed by the embedded + better-auth instance. +- **Discovery** via RFC 9728 / RFC 8414 metadata; 401s advertise resource + metadata through `WWW-Authenticate` so clients bootstrap automatically. +- **Dynamic Client Registration (RFC 7591)**, **authorization-code + PKCE**, and + **RFC 8707 resource binding** (`aud=/api/v1/mcp`, verified locally + against the deployment's JWKS, fail-closed). +- **Coarse scopes → tool families** enforced at dispatch: `data:read`, + `data:write`, `actions:execute`. +- **TLS required, localhost exempt** (OAuth 2.1). + +API keys are unchanged (dual-track): `x-api-key` / `Authorization: Bearer osk_…` +keep working for CI and headless agents. + +### Author-time authorization lints + +- **Security-domain publish linter** (`validateSecurityPosture`, D7), wired into + `os compile` (errors gate the build) and `os lint`: + `security-owd-unset`, `security-owd-alias`, + `security-external-wider-than-internal`, `security-wildcard-vama`, + `security-anchor-high-privilege`, `security-role-word` (D3 vocabulary + freeze), and advisory `security-private-no-readscope`. +- **Capability-reference lint** (ADR-0066 ⑨) — `validateCapabilityReferences` + warns when a `requiredPermissions` names a capability registered nowhere (a + typo like `mange_users` is schema-valid and fails closed silently at runtime). + Built-in capabilities are sourced from one canonical list in + `@objectstack/spec` (`security/capabilities.ts`). + +### Per-operation object `requiredPermissions` (ADR-0066 ⑤) + +`Object.requiredPermissions` now accepts either the original `string[]` (gate +every operation) **or** a `{ read?, create?, update?, delete? }` map that gates +each operation class independently — so an object can be read-open / write-gated. +`transfer`/`restore` fold into `update`, `purge` into `delete`. The `string[]` +form keeps its semantics, so existing objects are unaffected; the map's keys are +`.strict()`-validated. + +### Package namespace-prefix enforcement + +Studio/runtime-authored packages were created without a namespace, so the +protocol's per-object prefix rule (`crm_account`) was silently inert. +`protocol.installPackage` now derives a default namespace from the package id, +and `protocol.publishPackageDrafts` rejects any object draft whose name lacks +the package prefix, atomically, with an actionable message. + +## New in Console (Studio) + +Permission Model v2 is a backend-heavy release; the matching Console surfaces — +the Roles → Positions relabel across Setup, the Studio cross-reference picker +moving its `role` kind to `position`, and the standalone **Explain**, **Access +Matrix**, and **Delegated Administration** admin pages that expose the D6/D12 +engines — roll out across the v13–v14 line. See +[v14](/docs/releases/v14)'s Console notes for the shipped surfaces, and the +[objectui CHANGELOG](https://github.com/objectstack-ai/objectui/blob/main/CHANGELOG.md) +for per-commit detail. + +## Removed: declared-but-never-enforced subsystems (ADR-0056 D8) + +Compliance-grade configuration must never merely look live. These schemas were +read by no runtime path and are removed: + +- `ComplianceConfigSchema` / `GDPRConfigSchema` / `HIPAAConfigSchema` — there is + no data-subject-rights engine, retention enforcer, or BAA gate. +- `MaskingConfigSchema` / `MaskingRuleSchema` — use field-level security + (permission-set field rules, enforced by the field masker). +- Global `RLSConfigSchema` / `RLSAuditConfigSchema` — the enforced RLS path + reads the per-policy `RowLevelSecurityPolicySchema`, never the global config. + +Kept `[EXPERIMENTAL]`: `EncryptionConfigSchema` (at-rest field encryption). +Marked `[EXPERIMENTAL — NOT ENFORCED]`: `AgentSchema.visibility` — it does not +hide an agent; use `access` / `permissions` for real gating. + +## Notable fixes + +- **`PROTOCOL_VERSION` re-synced to the spec major** — the version roll left the + handshake constant behind, reddening the lockstep test on `main`. The release + pipeline now regenerates it at version time (finished in v14). + +## Upgrade checklist + +1. Update all `@objectstack/*` dependencies to `^13.0.0` (version-locked). +2. Run a repo-wide rename from the role/profile vocabulary to positions using + the table above; `os lint`'s `security-role-word` rule flags the leftovers. +3. Declare `sharingModel` explicitly on any custom object that should stay + readable to everyone — the new default is `private`. +4. Move RBAC link-table administration to a `PermissionSet.adminScope` (or a + tenant-level admin); a bare CRUD grant on those tables no longer works. +5. Re-run `os compile` — `validateSecurityPosture` errors now gate the build. +6. Remove imports of the deleted compliance/masking/global-RLS schemas. diff --git a/content/docs/releases/v14.mdx b/content/docs/releases/v14.mdx new file mode 100644 index 0000000000..902b0c3660 --- /dev/null +++ b/content/docs/releases/v14.mdx @@ -0,0 +1,232 @@ +--- +title: v14.0.0 +description: The v14 series completes the ADR-0090 vocabulary convergence, makes object enable.* capability flags real gates, and adds admin user management, phone/SMS auth, book-audience enforcement, a data-lifecycle contract, and effective-dated grants. +--- + +**Released July 2026 — current: 14.4.0 (July 11, 2026).** All `@objectstack/*` +packages are version-locked, so one number describes the whole platform. This +page covers the **v14 series**: it opens with the 14.0.0 breaking change, then +folds in the capabilities that shipped across 14.1 → 14.4. + +The v14 line finishes the ADR-0090 permission-model convergence (the last +role/profile vocabulary leftovers), turns the object `enable.*` capability flags +into real opt-in / opt-out gates, and — across the minors — ships admin-driven +user management, phone and SMS authentication, book-audience enforcement at the +read layer, a data-lifecycle retention contract, and effective-dated grants with +self-service delegation. + +**TL;DR for upgraders:** one type-level breaking rename — +`book.audience: { profile }` → `{ permissionSet }`. Two behavior changes to know: +members no longer get **delete** as a baseline right, and `enable.feeds` / +`enable.activities` are now default-on opt-out gates (behavior-preserving) while +`enable.files` is opt-in. Everything else is additive. + +## Breaking changes (14.0.0) + +### `BookAudience` gated arm renamed: `{ profile }` → `{ permissionSet }` (ADR-0090 D2) + +ADR-0090 removed the Profile concept, but `book.audience` (docs-as-metadata, +ADR-0046 §6.7) still modelled its gated arm as a profile reference. Books ship +in packages, and packages own permission sets but never positions — so the gate +is a **capability reference**, a permission-set name the reader must hold: + +```ts +// before +book({ name: 'crm_admin_guide', audience: { profile: 'crm_admin' } }) +// after +book({ name: 'crm_admin_guide', audience: { permissionSet: 'crm_admin' } }) +``` + +Pre-launch one-step rename, no alias — the zod union rejects `{ profile }` at +parse time. The `'org'` and `'public'` literals are unchanged (`'public'` ≡ the +built-in `guest` position). + +### Vocabulary leftovers renamed, no alias + +The last "role" / "profile" surfaces from ADR-0090 are renamed one-step: + +| Before | After | +|:---|:---| +| `PortalSchema.profiles: ['client_portal_user']` | `positions: ['client_portal_user']` — portal admission is position-scoped; use `guest` for anonymous-only portals | +| `RLSUserContextSchema.role: string \| string[]` | `positions: string[]` — matches the `current_user.positions` the RLS compiler resolves | +| `sys_record_share.recipient_type: 'role'` | `'position'` — no stored-data migration needed (no reader expanded non-`user` share rows) | + +## Behavior changes + +### Members no longer get delete as a baseline right (14.2.0) + +`member_default` carried `allowDelete` on its `'*'` grant. That bit is dropped: +members keep **create / read / edit-own**, and deleting records is no longer a +baseline right. Grant deletes per object via an ordinary position-distributed +set. (This landed alongside calibrating `describeHighPrivilegeBits` to the exact +ADR-0090 D5 bit list so the baseline could finally bind to the `everyone` +anchor — "what new users get" is now literally "what is bound to `everyone`".) + +### Object `enable.*` flags become real gates (14.0.0) + +The `ObjectCapabilities` flags were parsed but mostly unconsumed. Each now has a +defined enforcement contract — `ObjectCapabilities` is **100% live**: + +- **`enable.activities`** — opt-OUT (default flips `false → true`). CRUD keeps + mirroring into the `sys_activity` timeline unless the object declares + `activities: false`. Behavior-preserving; the compliance `sys_audit_log` row is + never gated. +- **`enable.feeds`** — opt-OUT with server-side enforcement (default flips + `false → true`). An explicit `feeds: false` rejects `sys_comment` creation on + that object at the engine hook (403 `FEEDS_DISABLED`, fail-closed). +- **`enable.trackHistory`** — reclassified live (the console has gated the record + History tab on it since 2026-05): object flag = History-tab master switch, + per-field flag = diff selector; audit capture stays unconditional. +- **`enable.files`** — opt-IN (default stays `false`). The generic record + **Attachments** panel is a new surface; a `sys_attachment` join row may only + target an object that declares `enable: { files: true }` (else 403 + `FILES_DISABLED`). `Field.file` / `Field.image` are independent and work + regardless of this flag. + +## New capabilities across the v14 line + +### Approvals route to positions (14.0.0, ADR-0090 D3) + +A new `position` approver type — `{ type: 'position', value: '' }` expands +to the position's holders via `sys_user_position`. Keep `type: 'role'` only for +membership tiers (owner/admin/member); the `department` approver type is now +honored by its spec spelling. New lint rules (`approval-role-not-membership-tier`, +`approval-approver-type-unknown`, `approval-escalation-reassign-no-target`) catch +the mismatches, and SLA escalation `escalateTo` is documented position-first +(14.1.0). + +### Admin user management, phone sign-in, and bulk import (14.3.0, #2766) + +`sys_user` is managed by better-auth and its generic CRUD is suppressed, so the +only way to add a teammate used to be the email invite flow. Three staged +capabilities land: + +- **Admin direct user management** — `POST /api/v1/auth/admin/create-user` and a + wrapped `set-user-password` (platform-admin gate; real better-auth pipeline). + Optional generated one-time password (returned once, never persisted) and a + `sys_user.must_change_password` flag enforced through the auth gate (403 + `PASSWORD_EXPIRED`). +- **Phone sign-in** (opt-in `auth.plugins.phoneNumber`) — phone+password via + `POST /sign-in/phone-number`. Adds `sys_user.phone_number` (unique) / + `phone_number_verified`; phone-only accounts get an undeliverable placeholder + email, never derived from the number. +- **Identity bulk import** — `POST /api/v1/auth/admin/import-users` (rows/csv/xlsx, + dryRun, upsert by email or phone) writes every row through better-auth with an + `invite` or `temporary` password policy. Sync, ≤500 rows, no undo; upsert never + resets an existing user's password. + +### SMS infrastructure and phone OTP (14.3.0, #2780) + +The platform had no SMS delivery; this adds it end to end: + +- **New `@objectstack/plugin-sms`** — `ISmsService` / `ISmsTransport` contracts + with Aliyun SMS and Twilio transports plus a dev log fallback, configured + through a new `sms` settings namespace. Deliberately no message persistence and + no body logging (SMS bodies carry OTP codes). +- **Messaging `sms` channel** — `notify(channels: ['sms'])` resolves + `sys_user.phone_number`, renders `(topic, 'sms', locale)` templates, and + inherits outbox retry / dead-letter. +- **Phone OTP flows** — sign-in/verification and self-service password reset, + with abuse hardening (explicit attempt cap, per-number cooldown + rolling-hour + cap shared across nodes, per-IP rate-limit rules). OTP codes never reach logs. +- **SMS invites** — the import `invite` policy supports phone-only rows. + +### Book audience enforced at the read layer (14.3.0) + +`/meta/book`, `/meta/doc`, and `/meta/book/:name/tree` now **enforce** the +audience model: anonymous callers see only `public` books/docs; a +`{ permissionSet }`-gated book requires the caller to hold the named set; a +doc's effective audience is the union over the books that claim it. Gated +evaluation fails closed when holdings cannot be resolved. + +### Data-lifecycle contract (14.4.0, ADR-0057) + +Platform-generated data is now bounded by construction. A new `lifecycle` object +property (`class: record | audit | telemetry | transient | event` plus +`retention` / `ttl` / `storage(rotation)` / `archive`) is enforced by a +platform-owned **LifecycleService** (default-on; disable via +`OS_LIFECYCLE_DISABLED=1`). The **Reaper** batch-deletes rows past their window +and reclaims space; **rotation** time-shards a table so expiry is an O(1) +`DROP`; the **Archiver** does retain → archive → delete for audit objects once a +`telemetry` datasource is registered. **11 platform objects now carry +lifecycle declarations** (e.g. `sys_activity` 14d rotated, `sys_audit_log` 90d +hot → archive), tunable per environment/tenant via a new `lifecycle` settings +namespace. + +### Effective-dated grants and delegation of duty (14.4.0, ADR-0091 L1 + L2) + +- **Grant validity windows (L1)** — `sys_user_position` and + `sys_user_permission_set` gain `valid_from` / `valid_until` (half-open UTC; + null = unbounded), plus `reason` / `delegated_from` / certification columns. + `resolveAuthzContext` filters both grant tables through the shared + `isGrantActive` predicate, **fail-closed** — an expired `admin_full_access` + grant no longer derives `platform_admin`. Explain surfaces the "held until … — + expired" contributor state, so "why did access disappear" is self-answering. + No background job: an expired grant simply stops resolving. +- **Delegation of duty / 职务代理 (L2)** — `PositionSchema.delegatable` lets a + non-admin holder self-service delegate their own position to a delegate for a + bounded window (mandatory future `valid_until` within a 30-day ceiling, + mandatory `reason`, insert-only so it is not self-renewable). Administration is + never self-delegated — a `delegatable` position that distributes an + `adminScope` set is rejected. + +### Position assignment panels as pure SDUI (14.2.0) + +`RecordRelatedListProps.relationshipValueField` (the generic affordance for +name-keyed junctions) powers a **Positions** tab on `sys_user` and a new +`sys_position` detail page (**Holders** / **Permission Sets** tabs) with zero +bespoke UI. `GET /me/apps` now sources the engine registry (the authority the +meta routes use) so `tabPermissions` and `AppSchema.requiredPermissions` have a +real enforced consumer. + +## Notable fixes + +- **FLS keys must be object-qualified** (14.4.0) — the evaluator matches + field-permission keys by `.`, so a bare `budget` key matched + nothing and the declared masking silently never enforced. A new + `security-fls-unqualified-key` lint rejects bare keys at compile time with a + fix-it. +- **`PROTOCOL_VERSION` auto-sync at version time** (14.4.0) — `changeset version` + now regenerates the handshake constant from the spec major, closing the drift + class that reddened `main` after the 14.0.0 release (release PRs opened by the + changesets action never trigger CI, so the lockstep guard could only fire after + a merge). +- **`externalSharingModel` honestly marked** (14.4.0) — the dial has no runtime + consumer yet (authoring lint + Studio badges only); its liveness entry moves to + `planned` + `authorWarn`, and the docs say external-principal evaluation lands + with the principal-taxonomy phase. + +## New in Console (Studio) + +The version-locked `@objectstack/console` bundle carries the frontend for this +line, including the Permission Model v2 alignment that spans v13–v14: + +- **Position assignment panels** — the generic `record:related_list` Add-picker + and `relationshipValueField` support render the `sys_user` **Positions** tab + and the `sys_position` **Holders** / **Permission Sets** tabs. With older + renderers these degrade to read-only lists. +- **Record Attachments** — `RecordAttachmentsPanel` (upload / list / download / + delete over the three-step presigned storage flow) renders on record pages + when the object sets `enable: { files: true }`. +- **Permission Model v2 surfaces** — Setup relabels Roles → Positions, the + Studio cross-reference picker moves its `role` kind to `position`, and + standalone **Explain**, **Access Matrix**, and **Delegated Administration** + pages expose the D6/D12 engines. + +For exhaustive, per-commit frontend detail, see the +[objectui CHANGELOG](https://github.com/objectstack-ai/objectui/blob/main/CHANGELOG.md). + +## Upgrade checklist + +1. Update all `@objectstack/*` dependencies to `^14.0.0` (version-locked). +2. Rename `book.audience: { profile }` → `{ permissionSet }`, and migrate any + `PortalSchema.profiles` / `RLSUserContextSchema.role` authoring to `positions`. +3. Re-grant **delete** per object where members previously relied on the baseline + right. +4. Set `enable: { feeds: false }` / `activities: false` on any object that should + stay off (the defaults are now on); set `enable: { files: true }` to turn on + the Attachments panel. +5. Object-qualify any field-permission (FLS) keys — `os compile` now rejects bare + keys. +6. Optionally adopt the `lifecycle` contract on high-volume custom objects and + the `sms` namespace + `auth.plugins.phoneNumber` for phone auth. diff --git a/docs/releases-maintenance.md b/docs/releases-maintenance.md new file mode 100644 index 0000000000..d1e4724c97 --- /dev/null +++ b/docs/releases-maintenance.md @@ -0,0 +1,101 @@ +# Maintaining the release history + +ObjectStack ships as a **single version-locked train**: every `@objectstack/*` +package (and the bundled `@objectstack/console` frontend) is in the changesets +`fixed` group, so one number describes the whole platform. That number is the +`@objectstack/spec` **major** — the same value as `PROTOCOL_VERSION`. + +Because the release history spans ~70 backend packages *and* a separately-developed +frontend, it is maintained in **three layers**. Each layer has exactly one owner +(generated or curated) — never a hand-maintained aggregate, which is what drifts. + +``` +write time: backend PR → .changeset/*.md + objectui bump → .changeset/console-*.md (auto, see below) + │ pnpm changeset version (release pipeline) + ▼ +detail layer: packages/*/CHANGELOG.md ← generated, never hand-edited + │ curate per major (backend + frontend) + ▼ +platform layer: content/docs/releases/vN.mdx ← the whole-platform big picture +``` + +## 1. Atomic layer — changesets (written at change time) + +Every behaviour-changing PR adds a `.changeset/*.md` describing the change and its +migration **while the context is fresh**. This is the only place humans write +release history for the backend. `pnpm changeset version` fans these out into the +per-package changelogs and computes the next platform version. + +## 2. Detail layer — per-package `CHANGELOG.md` (generated) + +Generated by changesets, with PR links. **Never hand-edit these.** They are the +exhaustive, per-package record for integrators and npm. + +### The frontend is a package too + +The Console UI is developed in the sibling [`objectui`](https://github.com/objectstack-ai/objectui) +repo and bundled into `@objectstack/console` at a pinned commit recorded in +[`.objectui-sha`](../.objectui-sha). A SHA bump alone used to leave **no trace** in +the history. `scripts/bump-objectui.sh` now closes that gap: on every bump it emits +a `@objectstack/console` changeset summarising the objectui `OLD_SHA..NEW_SHA` +commit range (auto-typed `minor` if the range contains any `feat`, else `patch`). +So frontend deltas flow through the *same* pipeline as the backend — they land in +`@objectstack/console`'s changelog and roll up into the platform version. + +```bash +scripts/bump-objectui.sh # bump to ../objectui HEAD, write the changeset, commit +scripts/bump-objectui.sh # bump to an explicit commit +CONSOLE_BUMP=patch scripts/bump-objectui.sh # force the bump type +scripts/bump-objectui.sh --no-changeset # opt out (rarely) +``` + +## 3. Platform layer — `content/docs/releases/vN.mdx` (curated) + +The curated, developer-facing "big picture", written for third parties building on +ObjectStack. One page **per major**, leading with breaking changes + migration, +then new capabilities, then notable fixes — and a **Console (Studio)** section +covering the frontend changes bundled in that line. This is the layer the whole +platform version maps to. + +**Cadence that scales with rapid iteration:** + +- **Major (breaking):** a hand-curated `vN.mdx` is mandatory — migration guidance + can't be generated. +- **Minor / patch:** do **not** add a page each. Fold them into the current major's + page under a "What's new in N.x" running section, or leave them to the generated + per-package changelogs. + +### Sourcing the Console section for a release + +Backend content comes from the spec/package changesets. Frontend content comes from +objectui's history for the SHA range bundled in that release. Because `.objectui-sha` +is version-controlled, `scripts/objectui-range.mjs` computes that range from any two +framework revisions and prints the feat/fix commits grouped by type + the largest +touched areas — ready to paste into a Console section: + +```bash +# frontend delta bundled between two framework revisions (needs ../objectui) +node scripts/objectui-range.mjs # e.g. the two release commits +node scripts/objectui-range.mjs --from --to --json # explicit SHAs / tooling +``` + +Without an objectui checkout it still prints the SHA range to inspect. The framework +changesets also embed companion frontend notes inline ("Companion objectui PR +ships…", renderer notes), which are enough to write an accurate Console section on +their own. + +## Drift guard + +`scripts/check-release-notes.mjs` (run in CI as `pnpm check:release-notes`) fails the +build when a released spec major has no `content/docs/releases/v.mdx` wired +into that section's `meta.json`. This is what prevents the "docs stuck at v9 while +the platform shipped v14" gap from recurring. Curated pages started at v9; v10/v11 +were never backfilled and are the documented exceptions in the guard. + +## The older aggregate files + +Root `CHANGELOG.md` and `RELEASE_NOTES.md` were hand-maintained platform aggregates +and drifted. They are retained for their historical entries but are **superseded** +by the three layers above — prefer the per-package changelogs (detail) and the +release pages (narrative) for anything new. diff --git a/package.json b/package.json index 1364e2d4b9..0390d69e86 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "check:doc-authoring": "node scripts/check-doc-authoring.mjs", "check:role-word": "node scripts/check-role-word.mjs", "check:authz-resolver": "node scripts/check-single-authz-resolver.mjs", - "check:console-sha": "node scripts/check-console-sha.mjs" + "check:console-sha": "node scripts/check-console-sha.mjs", + "check:release-notes": "node scripts/check-release-notes.mjs" }, "keywords": [ "objectstack", diff --git a/scripts/bump-objectui.sh b/scripts/bump-objectui.sh index 42b99e3c0c..2744bb9588 100755 --- a/scripts/bump-objectui.sh +++ b/scripts/bump-objectui.sh @@ -4,7 +4,12 @@ # Usage: # scripts/bump-objectui.sh # bump to current HEAD of ../objectui # scripts/bump-objectui.sh # bump to an explicit SHA (or ref) -# scripts/bump-objectui.sh --no-commit # update file only, don't commit +# scripts/bump-objectui.sh --no-commit # update files only, don't commit +# scripts/bump-objectui.sh --no-changeset # skip the @objectstack/console changeset +# +# Env: +# CONSOLE_BUMP=minor|patch # force the changeset bump type (default: auto — +# # `minor` if the objectui range has any feat, else patch) # # Assumes sibling layout: # ~/work/objectui @@ -15,6 +20,13 @@ # builds @object-ui/console, and copies dist/ into # packages/console/ so @objectstack/console publishes a frozen, # version-matched build alongside the rest of the framework. +# +# The frontend is a version-locked package too, but a SHA bump alone left no +# trace in the release history — @objectstack/console's CHANGELOG stayed empty +# across frontend-only updates. So this bump also emits a changeset summarizing +# the objectui commit range, routing the frontend delta through the SAME +# changesets pipeline as the backend: it lands in @objectstack/console's +# CHANGELOG and rolls up into the platform version + the curated release notes. set -euo pipefail @@ -22,12 +34,14 @@ FRAMEWORK_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" OBJECTUI_ROOT="${OBJECTUI_ROOT:-$(cd "${FRAMEWORK_ROOT}/../objectui" 2>/dev/null && pwd || true)}" NO_COMMIT=0 +NO_CHANGESET=0 EXPLICIT_SHA="" for arg in "$@"; do case "$arg" in --no-commit) NO_COMMIT=1 ;; + --no-changeset) NO_CHANGESET=1 ;; -h|--help) - sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//' + sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//' exit 0 ;; *) EXPLICIT_SHA="$arg" ;; @@ -56,17 +70,64 @@ fi echo "$NEW_SHA" > "${FRAMEWORK_ROOT}/.objectui-sha" echo "→ objectui pin: ${OLD_SHA:0:12} → ${NEW_SHA:0:12}" +SHORT="${NEW_SHA:0:12}" +SUBJECT_LINE="$(git -C "$OBJECTUI_ROOT" log -1 --format=%s "$NEW_SHA")" + +# --- Emit the @objectstack/console changeset for the frontend delta ---------- +CS_FILE="" +if [[ "$NO_CHANGESET" -eq 0 ]]; then + # Can we walk the OLD..NEW range in the objectui checkout? (A shallow clone or + # a first-ever pin may not have OLD reachable — degrade to the tip subject.) + RANGE_OK=0 + if [[ "$OLD_SHA" != "" ]] && git -C "$OBJECTUI_ROOT" cat-file -e "${OLD_SHA}^{commit}" 2>/dev/null; then + RANGE_OK=1 + fi + + CHANGES="" + if [[ "$RANGE_OK" -eq 1 ]]; then + CHANGES="$(git -C "$OBJECTUI_ROOT" log --no-merges --format='- %s' "${OLD_SHA}..${NEW_SHA}" \ + | grep -iE '^- (feat|fix)' | head -40 || true)" + fi + [[ -z "$CHANGES" ]] && CHANGES="- ${SUBJECT_LINE}" + + BUMP="${CONSOLE_BUMP:-}" + if [[ -z "$BUMP" ]]; then + if [[ "$RANGE_OK" -eq 1 ]] && \ + git -C "$OBJECTUI_ROOT" log --format=%s "${OLD_SHA}..${NEW_SHA}" | grep -qiE '^feat'; then + BUMP=minor + else + BUMP=patch + fi + fi + + RANGE_LABEL="${OLD_SHA:0:12}...${NEW_SHA:0:12}" + [[ "$OLD_SHA" == "" ]] && RANGE_LABEL="(initial pin) → ${NEW_SHA:0:12}" + + CS_FILE="${FRAMEWORK_ROOT}/.changeset/console-${SHORT}.md" + cat > "$CS_FILE" <.mdx, and that page must be wired into the +// section's meta.json so it is actually navigable. +// +// Released majors are read from the spec package CHANGELOG (the changesets +// source of truth). Curated pages only began at v9, and v10/v11 were never +// backfilled — those are the documented, intentional exceptions below. Anything +// else missing is a real gap and fails the build. +// +// node scripts/check-release-notes.mjs +// +// When a new major ships: write content/docs/releases/v.mdx (lead with +// breaking changes + migration; fold minors into a "What's new in N.x" section) +// and add it to content/docs/releases/meta.json. +import { readFileSync, existsSync } from 'node:fs'; + +const SPEC_CHANGELOG = 'packages/spec/CHANGELOG.md'; +const RELEASES_DIR = 'content/docs/releases'; +const META_PATH = `${RELEASES_DIR}/meta.json`; + +// Curated release pages started at v9; v10/v11 were never backfilled (see +// content/docs/releases/index.mdx). Everything from v12 on must have a page. +const FLOOR_MAJOR = 9; +const KNOWN_MISSING = new Set([10, 11]); + +function releasedMajors() { + const text = readFileSync(SPEC_CHANGELOG, 'utf8'); + const majors = new Set(); + for (const m of text.matchAll(/^##\s+(\d+)\.\d+\.\d+/gm)) { + majors.add(Number.parseInt(m[1], 10)); + } + return [...majors].sort((a, b) => a - b); +} + +const meta = JSON.parse(readFileSync(META_PATH, 'utf8')); +const metaPages = new Set(meta.pages || []); + +const problems = []; +for (const major of releasedMajors()) { + if (major < FLOOR_MAJOR || KNOWN_MISSING.has(major)) continue; + const slug = `v${major}`; + if (!existsSync(`${RELEASES_DIR}/${slug}.mdx`)) { + problems.push( + `${RELEASES_DIR}/${slug}.mdx is missing — @objectstack/spec shipped a ${major}.x ` + + `release but there is no curated release page. Write it (lead with breaking ` + + `changes + migration), then add "${slug}" to ${META_PATH}.`, + ); + } else if (!metaPages.has(slug)) { + problems.push( + `${slug}.mdx exists but is not listed in ${META_PATH} "pages" — the page is ` + + `unreachable in the docs nav. Add "${slug}" to the pages array.`, + ); + } +} + +if (problems.length > 0) { + console.error(`check-release-notes: ${problems.length} problem(s)\n`); + for (const p of problems) console.error(` • ${p}`); + process.exit(1); +} + +console.log('check-release-notes: OK — every released major has a curated, navigable release page.'); diff --git a/scripts/objectui-range.mjs b/scripts/objectui-range.mjs new file mode 100644 index 0000000000..400d9b7f84 --- /dev/null +++ b/scripts/objectui-range.mjs @@ -0,0 +1,156 @@ +#!/usr/bin/env node +// objectui-range — summarize the frontend (objectui) delta bundled between two +// framework revisions, ready to paste into a release page's Console section. +// +// The platform is one version-locked train and the Console UI is frozen into +// @objectstack/console at the objectui commit pinned in .objectui-sha. To learn +// "what did the frontend change between platform version A and B", you diff the +// pinned SHA at two framework revisions and read objectui's log for that range. +// This script does exactly that — the aggregation layer described in +// docs/releases-maintenance.md. +// +// Usage: +// node scripts/objectui-range.mjs [new-rev] +// old-rev / new-rev are FRAMEWORK git refs (tags, branches, SHAs). +// new-rev defaults to the working-tree .objectui-sha (the current pin). +// node scripts/objectui-range.mjs --from --to +// skip the framework lookup; use explicit objectui SHAs. +// … --json emit structured JSON instead of markdown +// … --all include every conventional-commit type (default: feat + fix) +// +// Env: +// OBJECTUI_ROOT=/path/to/objectui (default: ../objectui, like bump-objectui.sh) +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { existsSync, readFileSync } from 'node:fs'; + +const FRAMEWORK_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); +const OBJECTUI_ROOT = + process.env.OBJECTUI_ROOT || join(FRAMEWORK_ROOT, '..', 'objectui'); + +const argv = process.argv.slice(2); +const has = (f) => argv.includes(f); +const val = (f) => { + const i = argv.indexOf(f); + return i >= 0 ? argv[i + 1] : undefined; +}; +const positional = argv.filter( + (a, i) => !a.startsWith('--') && argv[i - 1] !== '--from' && argv[i - 1] !== '--to', +); + +const JSON_OUT = has('--json'); +const ALL_TYPES = has('--all'); + +if (has('-h') || has('--help')) { + console.log(readFileSync(fileURLToPath(import.meta.url), 'utf8').split('\n') + .filter((l) => l.startsWith('//')).map((l) => l.slice(3)).join('\n')); + process.exit(0); +} + +function die(msg) { + console.error(`✗ objectui-range: ${msg}`); + process.exit(1); +} + +function git(cwd, args) { + return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8' }).trim(); +} + +// Resolve the objectui SHA pinned at a given framework rev (or the working tree). +function pinAt(rev) { + if (rev === undefined) { + const p = join(FRAMEWORK_ROOT, '.objectui-sha'); + if (!existsSync(p)) die('.objectui-sha not found in the working tree'); + return readFileSync(p, 'utf8').trim(); + } + try { + return git(FRAMEWORK_ROOT, ['show', `${rev}:.objectui-sha`]).trim(); + } catch { + return die(`cannot read .objectui-sha at framework rev '${rev}'`); + } +} + +let fromSha = val('--from'); +let toSha = val('--to'); +if (!fromSha || !toSha) { + if (positional.length < 1) { + die('need [new-rev], or --from --to . See --help/header.'); + } + fromSha = pinAt(positional[0]); + toSha = pinAt(positional[1]); // undefined → working-tree pin +} + +if (!existsSync(join(OBJECTUI_ROOT, '.git'))) { + die( + `no objectui checkout at ${OBJECTUI_ROOT}.\n` + + ` Clone it as a sibling, or set OBJECTUI_ROOT=/path/to/objectui.\n` + + ` Range to inspect once available: ${fromSha.slice(0, 12)}..${toSha.slice(0, 12)}`, + ); +} + +if (fromSha === toSha) { + console.log(`objectui unchanged (${fromSha.slice(0, 12)}) — no frontend delta in this range.`); + process.exit(0); +} + +let rawLog; +try { + rawLog = git(OBJECTUI_ROOT, [ + 'log', '--no-merges', '--format=%H%x09%s', `${fromSha}..${toSha}`, + ]); +} catch { + die( + `cannot walk ${fromSha.slice(0, 12)}..${toSha.slice(0, 12)} in ${OBJECTUI_ROOT}.\n` + + ` Fetch it: git -C ${OBJECTUI_ROOT} fetch --all`, + ); +} + +const CC = /^(feat|fix|refactor|perf|docs|test|chore|build|ci|style|revert)(?:\(([^)]+)\))?(!)?:\s*(.+)$/; +const KEEP = ALL_TYPES ? null : new Set(['feat', 'fix']); + +const commits = []; +for (const line of rawLog.split('\n').filter(Boolean)) { + const tab = line.indexOf('\t'); + const sha = line.slice(0, tab); + const subject = line.slice(tab + 1); + const m = subject.match(CC); + const type = m ? m[1] : 'other'; + const scope = m ? m[2] || '' : ''; + const desc = m ? m[4] : subject; + if (KEEP && !KEEP.has(type)) continue; + commits.push({ sha, type, scope, desc, subject }); +} + +// Top touched areas (by scope) across the kept commits — the "big picture" line. +const areaCounts = {}; +for (const c of commits) if (c.scope) areaCounts[c.scope] = (areaCounts[c.scope] || 0) + 1; +const topAreas = Object.entries(areaCounts).sort((a, b) => b[1] - a[1]).slice(0, 10); + +const byType = { feat: [], fix: [] }; +for (const c of commits) (byType[c.type] || (byType[c.type] = [])).push(c); + +if (JSON_OUT) { + console.log(JSON.stringify( + { from: fromSha, to: toSha, count: commits.length, topAreas, commits }, null, 2, + )); + process.exit(0); +} + +const line = (c) => `- ${c.scope ? `**${c.scope}** — ` : ''}${c.desc}`; +const out = []; +out.push(``); +if (topAreas.length) { + out.push('', `_Largest areas: ${topAreas.map(([a, n]) => `${a} (${n})`).join(', ')}_`); +} +if (byType.feat?.length) { + out.push('', '### Features', ...byType.feat.map(line)); +} +if (byType.fix?.length) { + out.push('', '### Fixes', ...byType.fix.map(line)); +} +const extra = Object.keys(byType).filter((t) => t !== 'feat' && t !== 'fix' && byType[t].length); +for (const t of extra) out.push('', `### ${t}`, ...byType[t].map(line)); +if (commits.length === 0) out.push('', '_No feat/fix commits in range (try --all)._'); + +console.log(out.join('\n')); diff --git a/scripts/role-word-baseline.json b/scripts/role-word-baseline.json index 4b7a634826..ec85c13204 100644 --- a/scripts/role-word-baseline.json +++ b/scripts/role-word-baseline.json @@ -36,6 +36,9 @@ "content/docs/protocol/objectui/record-alert.mdx": 1, "content/docs/protocol/objectui/widget-contract.mdx": 3, "content/docs/releases/implementation-status.mdx": 1, + "content/docs/releases/index.mdx": 1, + "content/docs/releases/v13.mdx": 17, + "content/docs/releases/v14.mdx": 9, "content/docs/ui/forms.mdx": 3, "skills/objectstack-ai/SKILL.md": 8, "skills/objectstack-api/SKILL.md": 1,