From 2bad47a1eec2e8001e93ff016c3849b64a465171 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 24 May 2026 14:05:00 +0200 Subject: [PATCH] chore(specs): normalize requirement headings to Form A per ADR-037 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1c migration for openbuilt (umbrella issue #137). Form A migration: 150 requirements across 17 specs migrated from Form D ("### Requirement: REQ-OBxxx-NNN ") to Form A ("### Requirement: <Title>" + "**ID:** REQ-OBxxx-NNN" in body, placed after the lead SHALL paragraph so the openspec validator's "SHALL on first body line" rule still holds). Per-spec migration counts (legacy heading → Form A): - app-icon-management: 4 - app-nav-entries: 4 - application-creation-wizard: 11 - application-detail-overview: 12 - application-insights: 7 - application-versions: 8 - green-field-migration: 4 - openbuilt-application-register: 8 - openbuilt-exporter: 0 (already Form C; Purpose updated only) - openbuilt-page-designer: 11 - openbuilt-rbac: 7 - openbuilt-runtime: 17 (includes duplicate-REQ disambiguation, see below) - openbuilt-schema-designer: 8 - openbuilt-template-catalogue: 10 - openbuilt-version-snapshots: 3 - version-promotion: 11 - version-routing: 9 Total: 134 Form D → Form A migrations + 16 Form C (exporter only Purpose). Duplicate-REQ disambiguation in openbuilt-runtime (10 requirements): The runtime spec's archived deltas from openbuilt-schema-editor, openbuilt-versioning, and openbuilt-rbac all introduced REQs that collided on REQ-OBR-006 through REQ-OBR-009. Disambiguated with single-letter suffixes mapped to source archive delta: - REQ-OBR-006a = Schema designer routes (from openbuilt-schema-editor) - REQ-OBR-006b = Publish action (from openbuilt-versioning) - REQ-OBR-006c = Manifest 403 RBAC gate (from openbuilt-rbac) - REQ-OBR-007a = Schemas menu entry (from openbuilt-schema-editor) - REQ-OBR-007b = Draft-vs-published indicator (from openbuilt-versioning) - REQ-OBR-007c = List filters by role (from openbuilt-rbac) - REQ-OBR-008a = VersionHistory panel (from openbuilt-versioning) - REQ-OBR-008b = Editor UIs gate destructive actions (from openbuilt-rbac) - REQ-OBR-009a = Rollback action (from openbuilt-versioning) - REQ-OBR-009b = Caller's group set via IInitialState (from openbuilt-rbac) Each suffixed REQ carries an inline disambiguation note citing the original archive delta source and the 2026-05-24 assignment date. Purpose-block rewrites: 17 (every spec had the placeholder "TBD - created by archiving change …" text; replaced with meaningful one-paragraph purpose synthesised from the originating archived change's proposal.md "Why" section). Validation: - npx openspec validate --specs --strict: 17/17 pass. - openspec validate --all --strict: 19/20 pass. The one failure (change/openbuilt-page-designer) is a pre-existing issue on an in-flight change unrelated to this PR (no delta sections); diff shows zero edits to that path under this branch. Refs ADR-037 (https://github.com/ConductionNL/hydra/pull/326). Closes part of #137. --- openspec/specs/app-icon-management/spec.md | 29 +++- openspec/specs/app-nav-entries/spec.md | 27 +++- .../specs/application-creation-wizard/spec.md | 58 +++++-- .../specs/application-detail-overview/spec.md | 63 ++++++-- openspec/specs/application-insights/spec.md | 43 ++++-- openspec/specs/application-versions/spec.md | 47 ++++-- openspec/specs/green-field-migration/spec.md | 29 +++- .../openbuilt-application-register/spec.md | 46 ++++-- openspec/specs/openbuilt-exporter/spec.md | 15 +- .../specs/openbuilt-page-designer/spec.md | 61 ++++++-- openspec/specs/openbuilt-rbac/spec.md | 45 ++++-- openspec/specs/openbuilt-runtime/spec.md | 143 +++++++++++++++--- .../specs/openbuilt-schema-designer/spec.md | 49 ++++-- .../openbuilt-template-catalogue/spec.md | 56 +++++-- .../specs/openbuilt-version-snapshots/spec.md | 22 ++- openspec/specs/version-promotion/spec.md | 63 ++++++-- openspec/specs/version-routing/spec.md | 54 +++++-- 17 files changed, 682 insertions(+), 168 deletions(-) diff --git a/openspec/specs/app-icon-management/spec.md b/openspec/specs/app-icon-management/spec.md index 0d9bfeea..7721d6b1 100644 --- a/openspec/specs/app-icon-management/spec.md +++ b/openspec/specs/app-icon-management/spec.md @@ -1,9 +1,19 @@ # app-icon-management Specification ## Purpose -TBD - created by archiving change openbuilt-nextcloud-nav. Update Purpose after archive. + +Lets an operator brand each published OpenBuilt virtual app with per-app SVG icons +(light + dark) so the published app surfaces with its own identity in the Nextcloud +top bar and in OpenBuilt's own card grid. Adds top-level `icon` / `iconDark` ref +fields to the `Application` schema (sibling to `slug`, `name`, `manifest`, +`permissions`), thin icon-serving endpoints with a clear fallback chain, and the +upload / preview / remove UX on the Application detail page — all routed through +OR's existing files-attached-to-object mechanism (ADR-001) so no new openbuilt-side +file storage is introduced. + ## Requirements -### Requirement: REQ-OBICON-001 Icon fields on Application schema (top-level) + +### Requirement: Icon fields on Application schema (top-level) The `Application` schema in `lib/Settings/openbuilt_register.json` SHALL declare two optional top-level properties — `icon` and `iconDark` — as siblings to `slug`, `name`, `manifest`, @@ -12,6 +22,8 @@ top-level properties — `icon` and `iconDark` — as siblings to `slug`, `name` files-attached-to-object mechanism (ADR-001). Both fields SHALL be optional; omitting them SHALL NOT cause schema validation failure. +**ID:** REQ-OBICON-001 + Icons live outside the `manifest` object deliberately: they are openbuilt-side admin metadata, not part of the manifest the citizen developer designs and the runtime serves to `CnAppRoot`. This keeps the change orthogonal to `app-manifest.schema.json` and avoids @@ -35,7 +47,7 @@ any upstream coupling with `@conduction/nextcloud-vue`. `ref` key) - **THEN** OR returns a 4xx validation error indicating `icon.ref` is required -### Requirement: REQ-OBICON-002 Icon-serving endpoint (light) +### Requirement: Icon-serving endpoint (light) The system SHALL expose `GET /index.php/apps/openbuilt/icons/{slug}.svg` backed by `IconController::iconLight`. The endpoint SHALL: @@ -49,6 +61,8 @@ The system SHALL expose `GET /index.php/apps/openbuilt/icons/{slug}.svg` backed 4. Set `Cache-Control: public, max-age=60` on every successful response. 5. Require any valid NC session (`#[NoAdminRequired]`); return `401` when no session exists. +**ID:** REQ-OBICON-002 + #### Scenario: Endpoint returns the attached light icon - **WHEN** an authenticated user requests `/icons/hello-world.svg` @@ -69,7 +83,7 @@ The system SHALL expose `GET /index.php/apps/openbuilt/icons/{slug}.svg` backed - **WHEN** a request arrives at `/icons/{slug}.svg` with no NC session cookie or token - **THEN** the response is `401` -### Requirement: REQ-OBICON-003 Icon-serving endpoint (dark) +### Requirement: Icon-serving endpoint (dark) The system SHALL expose `GET /index.php/apps/openbuilt/icons/{slug}-dark.svg` backed by `IconController::iconDark`. The endpoint SHALL apply the following fallback chain in order: @@ -81,6 +95,8 @@ The system SHALL expose `GET /index.php/apps/openbuilt/icons/{slug}-dark.svg` ba Cache and auth posture SHALL be identical to REQ-OBICON-002. +**ID:** REQ-OBICON-003 + #### Scenario: Endpoint returns the attached dark icon - **WHEN** an authenticated user requests `/icons/hello-world-dark.svg` @@ -102,7 +118,7 @@ Cache and auth posture SHALL be identical to REQ-OBICON-002. - **THEN** the response is `200 image/svg+xml` containing the contents of OpenBuilt's `/img/app-dark.svg` -### Requirement: REQ-OBICON-004 Icon section on Application detail page +### Requirement: Icon section on Application detail page The Application detail page SHALL include an **Icon** section exposing: @@ -120,6 +136,8 @@ The Application detail page SHALL include an **Icon** section exposing: The section SHALL NOT introduce a new openbuilt-side file-storage mechanism; all file I/O goes through OR's existing files-attached-to-object endpoint (ADR-001). +**ID:** REQ-OBICON-004 + #### Scenario: User uploads a light icon - **WHEN** a user with editor or owner role selects an SVG file in the light-icon picker @@ -138,4 +156,3 @@ goes through OR's existing files-attached-to-object endpoint (ADR-001). - **WHEN** a user attempts to upload a file with a non-`.svg` extension in either icon slot - **THEN** the uploader displays an inline error message and does not submit the file to OR - diff --git a/openspec/specs/app-nav-entries/spec.md b/openspec/specs/app-nav-entries/spec.md index 94282413..b7727810 100644 --- a/openspec/specs/app-nav-entries/spec.md +++ b/openspec/specs/app-nav-entries/spec.md @@ -1,9 +1,17 @@ # app-nav-entries Specification ## Purpose -TBD - created by archiving change openbuilt-nextcloud-nav. Update Purpose after archive. + +Makes every published OpenBuilt virtual app a first-class Nextcloud navigation citizen +by registering a per-app top-bar entry under `INavigationManager` on each request boot, +visibility-gated by the Application's `permissions` RBAC block (with a `group:*` wildcard +for universal visibility), and read live from OR with no writeback or cached nav-entry +table. Closes the gap that previously forced users to enter the OpenBuilt shell, find +the app, and click through to reach a published virtual app. + ## Requirements -### Requirement: REQ-OBNAV-001 Dynamic per-app top-bar entry for each published Application + +### Requirement: Dynamic per-app top-bar entry for each published Application The system SHALL register one `INavigationManager` entry per published Application in `Application::boot()` using `INavigationManager::add()` with a closure factory. Each entry @@ -20,6 +28,8 @@ SHALL carry: The entries SHALL be registered by `AppNavigationService`, which is lazily resolved from the DI container inside the `boot()` method. +**ID:** REQ-OBNAV-001 + #### Scenario: Published app appears in the Nextcloud top bar - **WHEN** the Nextcloud request cycle boots after an Application is transitioned to `published` @@ -37,7 +47,7 @@ DI container inside the `boot()` method. - **WHEN** an Application has `status: archived` - **THEN** no nav entry with `id = "openbuilt-app-{slug}"` appears for any user -### Requirement: REQ-OBNAV-002 Nav entry gated by permissions RBAC +### Requirement: Nav entry gated by permissions RBAC Each nav entry's visibility closure SHALL resolve the signed-in user's UID and group memberships via `IUserSession` and `IGroupManager` and return `true` only when the user @@ -53,6 +63,8 @@ satisfies at least one of the following: An Application whose `permissions.owners`, `permissions.editors`, and `permissions.viewers` are all empty (or absent) SHALL NOT be visible to non-admin users, regardless of status. +**ID:** REQ-OBNAV-002 + #### Scenario: Owner-role user sees the nav entry - **WHEN** user `alice` has UID `alice` and the Application has @@ -77,13 +89,15 @@ are all empty (or absent) SHALL NOT be visible to non-admin users, regardless of - **AND** the Application is published with empty permissions - **THEN** the admin's request cycle includes the nav entry -### Requirement: REQ-OBNAV-003 group-wildcard nav-entry visibility SHALL apply to all signed-in users +### Requirement: group-wildcard nav-entry visibility SHALL apply to all signed-in users The system SHALL make the nav entry visible to every signed-in Nextcloud user, regardless of their group memberships, when the literal string `group:*` appears in any of `permissions.owners`, `permissions.editors`, or `permissions.viewers` on a published Application. The wildcard SHALL be detected before the group-intersection check runs. +**ID:** REQ-OBNAV-003 + #### Scenario: `group:*` in owners makes entry universally visible - **WHEN** the Application has `permissions.owners = ["group:*"]` @@ -96,13 +110,15 @@ Application. The wildcard SHALL be detected before the group-intersection check - **AND** an arbitrary signed-in user with no matching group memberships requests a page - **THEN** that user's request cycle includes the nav entry -### Requirement: REQ-OBNAV-004 Nav entry list is re-evaluated per request without writeback +### Requirement: Nav entry list is re-evaluated per request without writeback The set of published Applications SHALL be read from OR on each boot-cycle evaluation inside `AppNavigationService`. No writeback to a separate nav-entry table or a cached register SHALL occur. The update from draft to published (or published to archived) is detected automatically because the service re-queries the `status == published` filter on every request boot cycle. +**ID:** REQ-OBNAV-004 + #### Scenario: Transitioning an Application to archived removes its nav entry - **WHEN** an Application is transitioned from `published` to `archived` @@ -115,4 +131,3 @@ because the service re-queries the `status == published` filter on every request - **WHEN** an Application is transitioned from `draft` to `published` - **THEN** on the next Nextcloud request boot cycle, the nav entry for that Application is present in `INavigationManager::getAll()` for eligible users - diff --git a/openspec/specs/application-creation-wizard/spec.md b/openspec/specs/application-creation-wizard/spec.md index 9b3f83e7..b5ab767e 100644 --- a/openspec/specs/application-creation-wizard/spec.md +++ b/openspec/specs/application-creation-wizard/spec.md @@ -1,27 +1,42 @@ # application-creation-wizard Specification ## Purpose -TBD - created by archiving change openbuilt-app-creation-wizard. Update Purpose after archive. + +Replaces the legacy single-form "Add Application" dialog with a four-step wizard +that provisions the full ADR-002 chain in one atomic backend call: an `Application` +row + N `ApplicationVersion` rows + N per-version registers (named +`openbuilt-{appSlug}-{versionSlug}`), each pre-seeded with the default `hello-message` +schema and the default manifest. Supports `single | dev-prod | dev-staging-prod | +custom` presets, enforces unique slugs per chain (leading-`_` reserved for openbuilt +system use), provides full rollback on any provisioning failure, sets the caller as +sole owner, and retires install-time auto-seed (`SeedHelloWorld` does not return) — +fresh installs are empty until the admin runs the wizard. + ## Requirements -### Requirement: REQ-OBWIZ-001 Wizard replaces the legacy Add-Application entry point + +### Requirement: Wizard replaces the legacy Add-Application entry point The Virtual apps index SHALL open the four-step `CreateApplicationWizard` dialog when the admin clicks the "Add Application" button. The legacy single-form Add-Application dialog SHALL be removed in the same change. No fallback / feature-flagged escape hatch SHALL exist. +**ID:** REQ-OBWIZ-001 + #### Scenario: Clicking Add Application opens the wizard - **WHEN** the admin clicks the "Add Application" button on the Virtual apps index - **THEN** the `CreateApplicationWizard` `NcModal` opens at step 1 (Basics) - **AND** no other dialog opens; the legacy single-form Add dialog is absent from the bundle -### Requirement: REQ-OBWIZ-002 Four-step wizard shape +### Requirement: Four-step wizard shape The wizard SHALL consist of four steps in fixed order: (1) **Basics** — name, slug, description, optional light + dark icon upload; (2) **Preset** — radio cards for `single`, `dev-prod`, `dev-staging-prod`, `custom`; (3) **Custom chain** — admin-defined version list, shown ONLY when preset is `custom`; (4) **Review** — read-only summary + Create button. Selecting any preset other than `custom` SHALL skip step 3 and jump straight to step 4. +**ID:** REQ-OBWIZ-002 + #### Scenario: Selecting a canned preset skips the custom step - **WHEN** the admin selects the `dev-prod` preset in step 2 and clicks Next @@ -40,7 +55,7 @@ Selecting any preset other than `custom` SHALL skip step 3 and jump straight to - **THEN** the previously-entered name, slug, and description from step 1 are still in place - **AND** the previously-selected preset is still highlighted -### Requirement: REQ-OBWIZ-003 Preset shapes +### Requirement: Preset shapes Each preset SHALL produce a deterministic version chain when reviewed and submitted: @@ -51,6 +66,8 @@ Each preset SHALL produce a deterministic version chain when reviewed and submit | `dev-staging-prod` | `development → staging → production` | `production` | | `custom` | Admin-defined in step 3 | Terminal (bottom) row | +**ID:** REQ-OBWIZ-003 + #### Scenario: dev-staging-prod preset produces a three-version chain - **WHEN** the admin completes the wizard with preset `dev-staging-prod`, app name `My App`, @@ -69,7 +86,7 @@ Each preset SHALL produce a deterministic version chain when reviewed and submit - **AND** the Application's `productionVersion` points at it - **AND** the version's `promotesTo` is null -### Requirement: REQ-OBWIZ-004 Custom-chain composer +### Requirement: Custom-chain composer When the admin selects the `custom` preset, step 3 SHALL present an add-row list where each row carries a `name` text input and an auto-derived `slug` chip. The composer SHALL support @@ -78,6 +95,8 @@ adding rows (`+ Add version` button), removing rows (`×` per row), and reorderi SHALL be interpreted as upstream-to-downstream. The composer SHALL enforce a minimum of one row. +**ID:** REQ-OBWIZ-004 + #### Scenario: Admin composes a 3-version chain by adding rows - **WHEN** the admin starts on step 3 (one default row `Production`) @@ -93,12 +112,14 @@ row. - **AND** clicks `×` on the `Production` row - **THEN** the row is NOT removed; an inline error appears: "At least one version is required" -### Requirement: REQ-OBWIZ-005 Slug derivation + leading-underscore rejection +### Requirement: Slug derivation + leading-underscore rejection The wizard SHALL auto-derive slugs from names client-side via a `toKebabCase` function: lowercase the input, replace spaces with `-`, strip characters outside `[a-z0-9-]`, collapse double `--`, trim leading/trailing `-`. The derived slug SHALL be displayed as an editable chip with an `Advanced` toggle that reveals the slug input. The slug pattern (enforced both client-side and server-side) SHALL be `^(?!_)[a-z0-9][a-z0-9-]*[a-z0-9]$`. Leading underscores SHALL be rejected with the user-facing message: "Version slugs cannot start with `_` (reserved for openbuilt system use)." +**ID:** REQ-OBWIZ-005 + #### Scenario: Slug auto-derives from app name - **WHEN** the admin types `My Cool App` in step 1's name field @@ -118,7 +139,7 @@ The slug pattern (enforced both client-side and server-side) SHALL be `^(?!_)[a- - **THEN** an inline error appears: "Slug must match `^(?!_)[a-z0-9][a-z0-9-]*[a-z0-9]$` — lowercase letters, digits, and hyphens only" -### Requirement: REQ-OBWIZ-006 No duplicate version slugs within a chain +### Requirement: No duplicate version slugs within a chain Within a single app's chain, two `ApplicationVersion` rows SHALL NOT share a slug. The wizard SHALL enforce this client-side as the admin types (inline error on the duplicating @@ -126,6 +147,8 @@ row) and server-side at the `/api/applications/wizard` endpoint (the endpoint re whole payload with `422 Unprocessable Entity` and a JSON body identifying both colliding rows). +**ID:** REQ-OBWIZ-006 + #### Scenario: Client-side duplicate-slug error - **WHEN** the admin's chain in step 3 contains two rows both named `Staging` (auto-derived @@ -149,7 +172,7 @@ rows). - **THEN** the wizard accepts the payload and creates `app-b`'s `production` version without error - **AND** `openbuilt-app-a-production` and `openbuilt-app-b-production` registers coexist -### Requirement: REQ-OBWIZ-007 Atomic creation with full rollback on failure +### Requirement: Atomic creation with full rollback on failure The wizard's backend endpoint SHALL provision the full chain atomically by sequencing: (1) validate payload, (2) create `Application`, (3) for each version create @@ -157,6 +180,8 @@ The wizard's backend endpoint SHALL provision the full chain atomically by seque (4) wire each non-terminal version's `promotesTo` to the next downstream UUID, (5) set `Application.productionVersion` to the terminal version's UUID. +**ID:** REQ-OBWIZ-007 + On ANY failure at any step, the endpoint SHALL roll back every successfully-created object in reverse creation order (registers first, then ApplicationVersion rows, then Application row), then return `500` with body @@ -193,13 +218,15 @@ so the admin can resolve manually. `orphanedResources: ["openbuilt-<slug>-development"]` - **AND** the message body advises the admin to remove the orphaned register manually -### Requirement: REQ-OBWIZ-008 Per-version registers + seed schema set +### Requirement: Per-version registers + seed schema set For each `ApplicationVersion` row created by the wizard, a corresponding OR register SHALL be provisioned with the name `openbuilt-{appSlug}-{versionSlug}`. Each freshly-provisioned register SHALL have the default schema set (the single `hello-message` schema from `lib/Resources/wizard/default-schemas.json`) installed and zero objects in it. +**ID:** REQ-OBWIZ-008 + #### Scenario: Each version gets its own register with the seed schema - **WHEN** the wizard successfully creates an app `helloworld` with preset `dev-prod` @@ -208,7 +235,7 @@ register SHALL have the default schema set (the single `hello-message` schema fr - **AND** each register has exactly one schema named `hello-message` - **AND** each register has zero objects -### Requirement: REQ-OBWIZ-009 Initial manifest, semver, status per version +### Requirement: Initial manifest, semver, status per version Each freshly-created `ApplicationVersion` row SHALL carry: - `manifest` — copy of `lib/Resources/wizard/default-manifest.json` with the per-version @@ -218,6 +245,8 @@ Each freshly-created `ApplicationVersion` row SHALL carry: - `status` — `draft`. - `application` relation — the new Application's UUID. +**ID:** REQ-OBWIZ-009 + #### Scenario: Versions start with the default Hello-World manifest - **WHEN** the wizard creates an app with preset `single` and slug `hello-world` @@ -226,13 +255,15 @@ Each freshly-created `ApplicationVersion` row SHALL carry: - **AND** the version's `semver` is `0.1.0` - **AND** the version's `status` is `draft` -### Requirement: REQ-OBWIZ-010 Caller becomes sole owner +### Requirement: Caller becomes sole owner The wizard endpoint SHALL set the new Application's `permissions.owners` to a single-element array containing the calling user's UID (in `user:<uid>` form). `permissions.editors` and `permissions.viewers` SHALL be empty arrays. The admin grants further roles via the permissions editor post-creation. +**ID:** REQ-OBWIZ-010 + #### Scenario: Caller becomes owner; no other principals - **WHEN** user `admin` POSTs a valid wizard payload @@ -240,15 +271,16 @@ permissions editor post-creation. - **AND** `permissions.editors` is `[]` - **AND** `permissions.viewers` is `[]` -### Requirement: REQ-OBWIZ-011 No install-time auto-seed +### Requirement: No install-time auto-seed The openbuilt app SHALL NOT create any virtual app at install / upgrade time. After `occ maintenance:repair`, the Virtual apps index SHALL be empty for a fresh install. +**ID:** REQ-OBWIZ-011 + #### Scenario: Fresh install has no virtual apps until admin creates one - **WHEN** openbuilt is installed on a fresh Nextcloud (no prior virtual apps) - **AND** `occ maintenance:repair` has run - **THEN** the Virtual apps index page shows the empty state - **AND** the Add Application button (opening the wizard) is the only call-to-action - diff --git a/openspec/specs/application-detail-overview/spec.md b/openspec/specs/application-detail-overview/spec.md index e0858f3a..24e576e4 100644 --- a/openspec/specs/application-detail-overview/spec.md +++ b/openspec/specs/application-detail-overview/spec.md @@ -1,9 +1,21 @@ # application-detail-overview Specification ## Purpose -TBD - created by archiving change openbuilt-app-detail-overview. Update Purpose after archive. + +Replaces the generic `CnDetailPage` main area on `/applications/:objectId` with a +purpose-built maintainer cockpit registered as the `headerComponent` on the +`VirtualAppDetail` page entry. Renders six stacked rows — hero strip (icon, name, +description, status, role, production semver), version pill tabs (chain order, +production starred, non-authorised hidden, Promote affordance on each non-terminal +pill), a 7d/30d/90d window toggle, a four-card KPI grid (active users, object count, +files count, audit events), an activity-graph card, and a five-card structural-widget +grid (Register / Schemas / Groups / Pages / Menu) that deep-links into the existing +builder views and OpenRegister. Consumes the insights endpoint owned by +`application-insights` for KPI + activity data. + ## Requirements -### Requirement: REQ-OBADO-001 Application detail main area renders six stacked rows + +### Requirement: Application detail main area renders six stacked rows The system SHALL replace the generic `CnDetailPage` main area on `/applications/:objectId` with `ApplicationDetailHeader.vue`, registered as the @@ -24,6 +36,8 @@ The sidebar (Manifest / Version history / Diff / Audit tabs) is unchanged by thi spec. The redundant Overview sidebar tab entry SHALL be removed from `sidebarTabs` on the `VirtualAppDetail` page entry in `src/manifest.json`. +**ID:** REQ-OBADO-001 + #### Scenario: Page renders six rows in order - **GIVEN** an Application `hello-world` with at least one ApplicationVersion @@ -40,7 +54,7 @@ spec. The redundant Overview sidebar tab entry SHALL be removed from - **THEN** the hero strip displays that icon (not a per-version icon) _(per ADR-001 — assets live on the Application, not the ApplicationVersion)_ -### Requirement: REQ-OBADO-002 Version pill tabs render chain order, production starred, non-authorised hidden +### Requirement: Version pill tabs render chain order, production starred, non-authorised hidden The pill strip SHALL render one pill per `ApplicationVersion` in the Application's `versions` relation, ordered by the `promotesTo` chain (most-upstream @@ -61,6 +75,8 @@ the `buildVersionedRoute` helper from `openbuilt-version-routing`. The hero stri KPI grid, activity-graph card, and structural-widget grid SHALL re-scope to the newly-selected version on the same render cycle. +**ID:** REQ-OBADO-002 + #### Scenario: Pill strip renders chain order - **GIVEN** an Application `hello-world` with three ApplicationVersion records whose @@ -89,7 +105,7 @@ newly-selected version on the same render cycle. data, and the structural-widget contents all re-fetch and re-render for the `staging` version -### Requirement: REQ-OBADO-003 Window toggle scopes time-windowed KPIs and activity graph +### Requirement: Window toggle scopes time-windowed KPIs and activity graph The window toggle SHALL offer three values: `7d`, `30d`, `90d`, with `7d` as the default. The selected window SHALL be passed to the insights endpoint as @@ -97,6 +113,8 @@ default. The selected window SHALL be passed to the insights endpoint as the activity-graph card SHALL scope to the selected window. The Object-count KPI and Files-count KPI SHALL NOT scope to the window — they are point-in-time totals. +**ID:** REQ-OBADO-003 + #### Scenario: Default window is 7d - **WHEN** the page first loads @@ -112,7 +130,7 @@ and Files-count KPI SHALL NOT scope to the window — they are point-in-time tot - **AND** the Object-count KPI and Files-count KPI values do not change _(they are point-in-time totals not affected by the window)_ -### Requirement: REQ-OBADO-004 KPI grid renders four cards +### Requirement: KPI grid renders four cards The KPI grid SHALL render four cards in a responsive grid (desktop: 4 columns; tablet: 2 columns; mobile: 1 column). Each card SHALL be presentational only @@ -129,6 +147,8 @@ The Files-count card SHALL be labelled "Files" (not "Storage") and SHALL carry a tooltip explaining it is a count of OR-attached files across all objects in the selected version's register. +**ID:** REQ-OBADO-004 + #### Scenario: KPI grid renders four cards with values from the insights response - **GIVEN** the insights endpoint returns @@ -143,7 +163,7 @@ selected version's register. - **THEN** a tooltip appears explaining "count of OR-attached files across all objects in this version's register; storage-bytes aggregation deferred" -### Requirement: REQ-OBADO-005 Activity-graph card renders the timeline from the insights response +### Requirement: Activity-graph card renders the timeline from the insights response The activity-graph card SHALL render an event timeline using the `activity[]` array from the insights response (REQ-OBAI-001). Each array entry has @@ -152,6 +172,8 @@ selected window's range on the X axis and event counts on the Y axis. Empty arra SHALL render an empty-state message ("No activity in the selected window") rather than an empty chart frame. +**ID:** REQ-OBADO-005 + #### Scenario: Activity graph renders the timeline - **GIVEN** the insights endpoint returns @@ -167,7 +189,7 @@ than an empty chart frame. - **THEN** the activity-graph card displays "No activity in the selected window" - **AND** no empty chart frame is shown -### Requirement: REQ-OBADO-006 Register widget renders read-only with an "Open in OpenRegister" deep-link +### Requirement: Register widget renders read-only with an "Open in OpenRegister" deep-link The `RegisterWidget.vue` component SHALL render a card with: @@ -182,6 +204,8 @@ The `RegisterWidget.vue` component SHALL render a card with: No inline create. No row click action. +**ID:** REQ-OBADO-006 + #### Scenario: Register widget deep-links to OpenRegister - **GIVEN** an Application `hello-world` with the `production` version selected @@ -189,7 +213,7 @@ No inline create. No row click action. - **THEN** the browser navigates to `/apps/openregister/registers/openbuilt-hello-world-production` -### Requirement: REQ-OBADO-007 Schemas widget renders rows with deep-link and inline "+ Add schema" +### Requirement: Schemas widget renders rows with deep-link and inline "+ Add schema" The `SchemasWidget.vue` component SHALL render a card listing the schemas in the selected version's register. Each row SHALL display the schema name, its object @@ -202,6 +226,8 @@ the existing create-schema dialog if a global registration exists; otherwise it SHALL log a deferred notice (the dialog itself is owned by a future schema-designer spec) and take no action. +**ID:** REQ-OBADO-007 + #### Scenario: Row click deep-links to the schema designer with the active version - **GIVEN** the user is viewing `hello-world` with `?_version=staging` @@ -224,7 +250,7 @@ spec) and take no action. registered — deferred to schema-designer spec") - **AND** no UI change occurs -### Requirement: REQ-OBADO-008 Groups widget renders permissions entries with role badges +### Requirement: Groups widget renders permissions entries with role badges The `GroupsWidget.vue` component SHALL render a card listing the entries in the Application's `permissions.{owners,editors,viewers}` arrays. Each row SHALL display @@ -233,6 +259,8 @@ the entry name (group name or user UID), a role badge (`owner` / `editor` / the existing permissions editor for the Application; the exact path is verified at apply time and recorded in the apply-time task notes. +**ID:** REQ-OBADO-008 + #### Scenario: Groups card lists permissions entries with role badges - **GIVEN** an Application with `permissions.owners=["g:admins"]`, @@ -241,7 +269,7 @@ apply time and recorded in the apply-time task notes. - **THEN** the Groups card lists four rows: `g:admins` (owner badge), `u:alice` (editor badge), `g:devs` (editor badge), `g:everyone` (viewer badge) -### Requirement: REQ-OBADO-009 Pages widget renders manifest pages with deep-link +### Requirement: Pages widget renders manifest pages with deep-link The `PagesWidget.vue` component SHALL render a card listing entries from the selected version's `manifest.pages[]`. Each row SHALL display the page id, route, @@ -249,6 +277,8 @@ type, and title. Row click SHALL navigate to `/builder/{slug}/pages?_version={versionSlug}&pageId={id}` via the `buildVersionedRoute` helper. +**ID:** REQ-OBADO-009 + #### Scenario: Row click deep-links to the page designer focused on the page - **GIVEN** the user is viewing `hello-world` with `?_version=development` @@ -258,7 +288,7 @@ type, and title. Row click SHALL navigate to - **THEN** the router navigates to `/builder/hello-world/pages?_version=development&pageId=customers-list` -### Requirement: REQ-OBADO-010 Menu widget renders manifest menu entries with deep-link +### Requirement: Menu widget renders manifest menu entries with deep-link The `MenuWidget.vue` component SHALL render a card listing entries from the selected version's `manifest.menu[]`. Each row SHALL display the label, route, @@ -266,6 +296,8 @@ order, and section. Row click SHALL navigate to `/builder/{slug}/pages?_version={versionSlug}&focus=menu` via the `buildVersionedRoute` helper. +**ID:** REQ-OBADO-010 + #### Scenario: Row click deep-links to the page designer with menu focus - **GIVEN** the user is viewing `hello-world` with `?_version=production` @@ -274,7 +306,7 @@ order, and section. Row click SHALL navigate to - **THEN** the router navigates to `/builder/hello-world/pages?_version=production&focus=menu` -### Requirement: REQ-OBADO-011 Manifest config: add headerComponent, drop Overview sidebar tab +### Requirement: Manifest config: add headerComponent, drop Overview sidebar tab The system SHALL update `src/manifest.json`'s `VirtualAppDetail` page entry to: @@ -289,6 +321,8 @@ whichever IDs the manifest carries at apply time) SHALL be preserved unchanged. The manifest update SHALL validate against the canonical manifest schema at `@conduction/nextcloud-vue/src/schemas/app-manifest.schema.json` after the edit. +**ID:** REQ-OBADO-011 + #### Scenario: Manifest carries the headerComponent and no Overview sidebar tab - **WHEN** the change is applied @@ -297,7 +331,7 @@ The manifest update SHALL validate against the canonical manifest schema at - **AND** no `sidebarTabs` entry with id `overview` is present - **AND** the remaining `sidebarTabs` entries are unchanged in count, id, and order -### Requirement: REQ-OBADO-012 Pill strip renders a Promote button on each non-terminal pill +### Requirement: Pill strip renders a Promote button on each non-terminal pill Each pill whose corresponding ApplicationVersion has a `promotesTo` target SHALL render a small "Promote" affordance (icon button or trailing chevron) on the pill. @@ -308,6 +342,8 @@ defines only the trigger surface. If no promotion dialog is registered (e.g. `openbuilt-version-promotion` not yet applied), the button SHALL render but click SHALL log a deferred notice and no-op. +**ID:** REQ-OBADO-012 + #### Scenario: Promote button renders on non-terminal pills - **GIVEN** an Application with chain `development → staging → production` @@ -322,4 +358,3 @@ applied), the button SHALL render but click SHALL log a deferred notice and no-o - **GIVEN** the promotion dialog from `openbuilt-version-promotion` is registered - **WHEN** the user clicks Promote on the `staging` pill - **THEN** the dialog opens, pre-targeted at the `staging` version - diff --git a/openspec/specs/application-insights/spec.md b/openspec/specs/application-insights/spec.md index 9b0ff410..387abb8a 100644 --- a/openspec/specs/application-insights/spec.md +++ b/openspec/specs/application-insights/spec.md @@ -1,15 +1,29 @@ # application-insights Specification ## Purpose -TBD - created by archiving change openbuilt-app-detail-overview. Update Purpose after archive. + +Exposes the version-scoped insights endpoint that powers the KPI grid and the +activity-graph card on the Application detail page. Returns four KPI scalars +(active users, object count, files count, audit-event count) plus an event-bucketed +activity timeline, sourced from OR aggregation calls over the version's per-version +register schema-set. Schema-set is derived server-side by walking +`manifest.pages[].config.{register,schema}` and unique-ing to the version's own +register; cross-register references are ignored. Auth gate mirrors +`openbuilt-version-routing` REQ-OBVR-003 exactly (production: viewers OK; +non-production: editors-or-better; no admin auto-grant; failure → 404 not 403); +responses carry `Cache-Control: public, max-age=60`. + ## Requirements -### Requirement: REQ-OBAI-001 Insights endpoint returns KPIs and activity timeline for a version + +### Requirement: Insights endpoint returns KPIs and activity timeline for a version The system SHALL expose `GET /index.php/apps/openbuilt/api/applications/{appUuid}/versions/{versionUuid}/insights?window=7d|30d|90d`, returning a single JSON payload containing four KPI scalars and an activity timeline scoped to the named ApplicationVersion's per-version register. +**ID:** REQ-OBAI-001 + Path parameters: - `appUuid` — Application UUID. Unknown UUID → `404`. @@ -79,7 +93,7 @@ The response SHALL carry the header `Cache-Control: public, max-age=60`. `/api/applications/<nil>/versions/<nil>/insights?window=7d` - **THEN** the response is `404 Not Found` -### Requirement: REQ-OBAI-002 Auth gate mirrors openbuilt-version-routing +### Requirement: Auth gate mirrors openbuilt-version-routing The endpoint SHALL apply the same RBAC gate as `openbuilt-version-routing` REQ-OBVR-003: @@ -97,6 +111,8 @@ The controller SHALL carry `#[NoAdminRequired]`. The RBAC check SHALL live insid the service layer (`ApplicationInsightsService`), not the controller, so the gate is testable in isolation and mirrors the shape of `ManifestResolverService`. +**ID:** REQ-OBAI-002 + #### Scenario: Viewer can read production insights - **GIVEN** the caller is in `permissions.viewers` on the Application @@ -127,7 +143,7 @@ testable in isolation and mirrors the shape of `ManifestResolverService`. - **THEN** the response is `404 Not Found` _(admins are not auto-granted — same policy as openbuilt-version-routing)_ -### Requirement: REQ-OBAI-003 Schema-set walk over the version's manifest.pages[].config +### Requirement: Schema-set walk over the version's manifest.pages[].config The system SHALL derive the schema-set for a version's insights aggregation by walking the version's `manifest.pages[].config.{register,schema}` entries @@ -147,6 +163,8 @@ The resulting schema-set drives all four KPI aggregations and the activity-chart call. An empty schema-set is a valid input — all four KPIs return `0` and `activity` is `[]`. +**ID:** REQ-OBAI-003 + #### Scenario: Walk derives unique schema IDs from manifest.pages - **GIVEN** a manifest with three page entries: @@ -172,7 +190,7 @@ call. An empty schema-set is a valid input — all four KPIs return `0` and `{"kpis":{"activeUsers":0,"objectCount":0,"filesCount":0,"auditEventCount":0}, "activity":[]}` -### Requirement: REQ-OBAI-004 KPI aggregations source +### Requirement: KPI aggregations source The four KPI scalars SHALL be computed from these OR-facing sources: @@ -193,6 +211,8 @@ to the window. The Files-count KPI is a v1 proxy for "storage" — it counts files, not bytes. The canonical storage-bytes aggregation is deferred (separate spec). +**ID:** REQ-OBAI-004 + #### Scenario: 7d window translates to 168 hours - **WHEN** the caller GETs the insights endpoint with `window=7d` @@ -207,7 +227,7 @@ canonical storage-bytes aggregation is deferred (separate spec). - **AND** `filesCount` is the current count of OR-attached files for the register (no window filter) -### Requirement: REQ-OBAI-005 Activity payload sourced from getActionChartData +### Requirement: Activity payload sourced from getActionChartData The `activity` array SHALL be sourced from `AuditTrailMapper::getActionChartData(schemaIds, hours)` called once per request @@ -217,6 +237,8 @@ Each element of `activity` SHALL have the shape `{ "timestamp": "<iso8601>", "eventCount": <int> }`. The bucket granularity SHALL match `getActionChartData`'s native granularity (no resampling in this spec). +**ID:** REQ-OBAI-005 + #### Scenario: Activity payload reflects getActionChartData buckets - **GIVEN** `getActionChartData` returns three daily buckets for the 7d window @@ -224,13 +246,15 @@ match `getActionChartData`'s native granularity (no resampling in this spec). - **THEN** the response's `activity` array contains exactly those three buckets with `timestamp` (ISO 8601) and `eventCount` (int) fields -### Requirement: REQ-OBAI-006 Cache-Control: public, max-age=60 on successful responses +### Requirement: Cache-Control: public, max-age=60 on successful responses Successful (`200`) responses SHALL carry the header `Cache-Control: public, max-age=60`. Error responses (`400`, `404`) SHALL NOT carry this header. The 60-second window is a fixed compile-time value in this spec; future tuning is out of scope. +**ID:** REQ-OBAI-006 + #### Scenario: 200 carries the cache header - **GIVEN** a valid authorised request @@ -242,7 +266,7 @@ tuning is out of scope. - **WHEN** the endpoint responds `404` (unknown appUuid) - **THEN** the response does NOT carry the `Cache-Control` header from this spec -### Requirement: REQ-OBAI-007 Route registration in appinfo/routes.php +### Requirement: Route registration in appinfo/routes.php The system SHALL register exactly one new route entry in `appinfo/routes.php` pointing at `ApplicationInsightsController::getInsights`, @@ -253,6 +277,8 @@ The route entry SHALL carry the auth posture attribute on the controller method (per hydra-gate-route-auth): `#[NoAdminRequired]` (the RBAC gate lives inside the service; the controller itself is authenticated-only). +**ID:** REQ-OBAI-007 + #### Scenario: routes.php declares the insights route - **GIVEN** the change is applied @@ -265,4 +291,3 @@ service; the controller itself is authenticated-only). - **WHEN** static-analysis reads `ApplicationInsightsController` - **THEN** the `getInsights` method is annotated with `#[NoAdminRequired]` - diff --git a/openspec/specs/application-versions/spec.md b/openspec/specs/application-versions/spec.md index 3725e543..0b3747b6 100644 --- a/openspec/specs/application-versions/spec.md +++ b/openspec/specs/application-versions/spec.md @@ -1,9 +1,21 @@ # application-versions Specification ## Purpose -TBD - created by archiving change openbuilt-versioning-model. Update Purpose after archive. + +Defines the `ApplicationVersion` schema and its lifecycle — the deployable runtime +half of ADR-002's two-object versioning model (`Application` logical + N +`ApplicationVersion` deployable). Each version owns its own per-version OR register +so production data is structurally isolated from dev/staging data, declares its own +`draft → published → archived` lifecycle (publishing now lives per-version, not on +Application), enforces a cycle-free linear `promotesTo` chain, auto-bumps `semver` +on manifest content changes, and exposes a CRUD endpoint family that the creation +wizard and detail page surfaces consume. `Application.productionVersion` becomes +the explicit production pointer, set by admin action and ownership-validated on +every save. + ## Requirements -### Requirement: REQ-OBV-101 ApplicationVersion schema declared in OpenRegister + +### Requirement: ApplicationVersion schema declared in OpenRegister The system SHALL declare an `ApplicationVersion` schema in `lib/Settings/openbuilt_register.json` under the `openbuilt` register namespace @@ -33,6 +45,8 @@ The system SHALL declare an `ApplicationVersion` schema in The schema SHALL be imported into OpenRegister at app install / post-migration time via `ConfigurationService::importFromApp()` in the existing repair step. +**ID:** REQ-OBV-101 + #### Scenario: Schema is available after install - **WHEN** the OpenBuilt repair step runs on a fresh install @@ -49,13 +63,15 @@ via `ConfigurationService::importFromApp()` in the existing repair step. - **THEN** OR persists the object, returns 201, and the returned object carries an OR-assigned `uuid` and the submitted fields -### Requirement: REQ-OBV-102 Initial semver is 0.1.0 on creation +### Requirement: Initial semver is 0.1.0 on creation The system SHALL set `semver` to the plain string `0.1.0` on every newly created `ApplicationVersion` row that does not supply a `semver` value at creation time. No prerelease tag, no build metadata. The default applies whether the row is created by the creation wizard, by the API directly, or by any other consumer. +**ID:** REQ-OBV-102 + #### Scenario: Fresh ApplicationVersion defaults to 0.1.0 - **GIVEN** a client creates a new ApplicationVersion without supplying `semver` @@ -68,7 +84,7 @@ the creation wizard, by the API directly, or by any other consumer. - **THEN** the persisted row's `semver` is `2.5.0` (the auto-bump does not override an explicitly-supplied value at creation) -### Requirement: REQ-OBV-103 Manifest content change auto-bumps the patch component +### Requirement: Manifest content change auto-bumps the patch component The system SHALL maintain `ApplicationVersion.semver` by patch-bumping it whenever the saved `manifest` content differs from the previously-saved `manifest` content. @@ -80,6 +96,8 @@ encoding). The previous hash SHALL be persisted on the row in a mapper-internal The bump increments the patch component (e.g. `0.1.0 → 0.1.1`, `1.4.7 → 1.4.8`). Minor and major bumps remain manual (explicit `semver` value on the save payload). +**ID:** REQ-OBV-103 + The hash-diff logic SHALL live in `ApplicationVersionService::onSave()`, called on the existing OR save path (per ADR-031 §Exceptions(2) — stateful diff is outside declarative calc vocabulary). @@ -107,7 +125,7 @@ declarative calc vocabulary). identical semantic content (the canonicalised JSON is byte-equal) - **THEN** the persisted `semver` remains `0.1.5` -### Requirement: REQ-OBV-104 Cycle prevention on the promotesTo chain +### Requirement: Cycle prevention on the promotesTo chain The system SHALL reject any `ApplicationVersion` save where setting `promotesTo` would create a cycle in the linear promotion chain. The check SHALL walk @@ -119,6 +137,8 @@ Broader cycle detection SHALL run as `ApplicationVersionService::guardNoCycle()` (per ADR-031 §Exceptions(1) — cross-row validation). +**ID:** REQ-OBV-104 + #### Scenario: Self-loop is rejected - **WHEN** a client saves an ApplicationVersion with `promotesTo` pointing at its @@ -141,7 +161,7 @@ cross-row validation). - **WHEN** a client saves B with `promotesTo = C` - **THEN** the save succeeds (`A → B → C`) -### Requirement: REQ-OBV-105 Production version is set explicitly on Application +### Requirement: Production version is set explicitly on Application The `Application.productionVersion` relation pointer SHALL be set explicitly by the admin (e.g. via the creation wizard's preset or the detail-page version switcher, @@ -155,6 +175,8 @@ Application. Mismatches SHALL be rejected with a 422 response. Implementation: `ApplicationVersionService::guardProductionVersionOwnership()`, invoked from the Application pre-save path (per ADR-031 §Exceptions(1) — cross-row). +**ID:** REQ-OBV-105 + #### Scenario: Setting productionVersion to a valid version succeeds - **GIVEN** an Application X and an ApplicationVersion V whose `application` points @@ -178,7 +200,7 @@ Application pre-save path (per ADR-031 §Exceptions(1) — cross-row). `V_dev.promotesTo = V_stage` and `V_stage.promotesTo = V_prod` - **THEN** `X.productionVersion` is still `V_prod` -### Requirement: REQ-OBV-106 Lifecycle on ApplicationVersion drives BuiltAppRoute upsert +### Requirement: Lifecycle on ApplicationVersion drives BuiltAppRoute upsert The `ApplicationVersion` schema SHALL declare its state machine via `x-openregister-lifecycle` with states (`draft`, `published`, `archived`) and @@ -189,6 +211,8 @@ parent Application's `uuid`. This action is **relocated** from the Application schema (chain spec `openbuilt-application-register` REQ-OBA-004) — published-ness is per-version under the new model. +**ID:** REQ-OBV-106 + #### Scenario: Publishing an ApplicationVersion upserts BuiltAppRoute - **GIVEN** an Application `<slug>` with at least one ApplicationVersion in @@ -206,7 +230,7 @@ is per-version under the new model. - **AND** the version's `status` is unchanged - **AND** no audit entry is recorded -### Requirement: REQ-OBV-107 ApplicationVersion CRUD endpoints +### Requirement: ApplicationVersion CRUD endpoints The system SHALL expose `ApplicationVersionsController` at `/index.php/apps/openbuilt/api/applications/{slug}/versions` with the following @@ -225,6 +249,8 @@ All endpoints SHALL carry `#[NoAdminRequired]` and SHALL respect the parent Application's `permissions` RBAC block (owners/editors for write, viewers for read). All endpoints SHALL be registered in `appinfo/routes.php`. +**ID:** REQ-OBV-107 + #### Scenario: List endpoint returns versions for one app - **WHEN** an authenticated user with viewer access GETs @@ -244,7 +270,7 @@ read). All endpoints SHALL be registered in `appinfo/routes.php`. `semver` - **THEN** the response is `201` and the returned row has `semver: "0.1.0"` -### Requirement: REQ-OBV-108 Version-deletion endpoint accepts a strategy +### Requirement: Version-deletion endpoint accepts a strategy The system SHALL accept the `?strategy=` query parameter on `DELETE` with three values: @@ -265,6 +291,8 @@ by `Application.productionVersion` with a 422 response naming the constraint. Strategy-branching logic lives in `ApplicationVersionService::deleteVersion($versionUuid, $strategy)`. +**ID:** REQ-OBV-108 + #### Scenario: delete-now drops the register and the version row - **GIVEN** an ApplicationVersion `V` whose `register` is `openbuilt-<slug>-staging` @@ -302,4 +330,3 @@ Strategy-branching logic lives in - **WHEN** a client sends `DELETE …/versions/staging` without a `strategy` query param (or with an unknown value) - **THEN** the response is `400` citing the missing/invalid strategy - diff --git a/openspec/specs/green-field-migration/spec.md b/openspec/specs/green-field-migration/spec.md index b29524ac..bf01d1c6 100644 --- a/openspec/specs/green-field-migration/spec.md +++ b/openspec/specs/green-field-migration/spec.md @@ -1,9 +1,19 @@ # green-field-migration Specification ## Purpose -TBD - created by archiving change openbuilt-versioning-model. Update Purpose after archive. + +Ships the one-shot destructive repair step that retires the pre-versioning +`Application` schema in favour of the two-object `Application` + `ApplicationVersion` +model introduced by `openbuilt-versioning-model` (ADR-002). Per ADR-002 existing +installs hold only test data; this step drops every pre-migration `Application` row +and its per-app register entirely, leaving the install in a clean state for the +creation-wizard to re-seed Hello World. Idempotent via a versioned-shape short-circuit, +observable via per-deletion log lines, and uses OR's register-delete API rather than +touching tables directly (ADR-022). + ## Requirements -### Requirement: REQ-OBGFM-001 Destructive migration repair step + +### Requirement: Destructive migration repair step The system SHALL ship a Nextcloud `\\OCP\\Migration\\IRepairStep` implementation at `lib/Repair/MigrateToVersionedModel.php`. The repair step SHALL be registered in @@ -13,6 +23,8 @@ migration: for every pre-migration `Application` row in the `openbuilt` register SHALL drop the corresponding per-app register (named `openbuilt-{slug}`) entirely (removing every object inside it) and then delete the `Application` row itself. +**ID:** REQ-OBGFM-001 + The destructive behaviour is intentional. ADR-002 records that existing OpenBuilt installs hold only test data and that the new versioning model re-seeds Hello World at install time via the creation-wizard capability (out of scope for this spec). @@ -35,7 +47,7 @@ at install time via the creation-wizard capability (out of scope for this spec). - **THEN** all three Application rows are gone - **AND** all three per-app registers are gone -### Requirement: REQ-OBGFM-002 Migration is idempotent via versioned-shape short-circuit +### Requirement: Migration is idempotent via versioned-shape short-circuit The repair step SHALL be safe to re-run. On every invocation, it SHALL first detect whether the OpenBuilt schema is already in versioned shape and SHALL short-circuit @@ -49,6 +61,8 @@ A short-circuit run SHALL produce no log output beyond a single info line indica the no-op (e.g. `Migrated-to-versioned-model: schema already in versioned shape, skipping`). +**ID:** REQ-OBGFM-002 + #### Scenario: Already-versioned install is a no-op - **GIVEN** an install whose `openbuilt` register exposes the `applicationVersion` @@ -66,7 +80,7 @@ skipping`). - **THEN** the run is a no-op via the short-circuit guard - **AND** the surviving data is unchanged -### Requirement: REQ-OBGFM-003 One log line per deleted Application +### Requirement: One log line per deleted Application The repair step SHALL emit exactly one `$output->info()` log line per deleted Application, with the literal format: @@ -78,6 +92,8 @@ Migrated-to-versioned-model: dropped Application '<slug>' and register 'openbuil where `<slug>` is the deleted Application's `slug` value. The line SHALL surface in standard OCC upgrade output so the migration is observable during deployment. +**ID:** REQ-OBGFM-003 + #### Scenario: Each deletion is logged individually - **GIVEN** a pre-migration install with three Applications whose slugs are @@ -89,7 +105,7 @@ standard OCC upgrade output so the migration is observable during deployment. - **AND** one line for `<slug-b>` - **AND** one line for `<slug-c>` -### Requirement: REQ-OBGFM-004 Per-app register deletion uses OR's register-delete API +### Requirement: Per-app register deletion uses OR's register-delete API The repair step SHALL drop each per-app register via OpenRegister's register-delete API (consume the existing OR abstraction per ADR-022 — do not @@ -99,6 +115,8 @@ repair step SHALL log the failure, SHALL NOT delete the corresponding Applicatio row, and SHALL continue with the next Application in the enumeration. The operator is expected to inspect the OCC log and retry on the next upgrade. +**ID:** REQ-OBGFM-004 + #### Scenario: Register-delete failure is logged and the Application row is preserved - **GIVEN** a pre-migration install where dropping the register `openbuilt-<slug>` @@ -107,4 +125,3 @@ operator is expected to inspect the OCC log and retry on the next upgrade. - **THEN** the failure is logged with the slug and an error message - **AND** the Application row for `<slug>` is NOT deleted - **AND** the repair step continues with the next pre-migration Application - diff --git a/openspec/specs/openbuilt-application-register/spec.md b/openspec/specs/openbuilt-application-register/spec.md index f65490b8..dd79f9bf 100644 --- a/openspec/specs/openbuilt-application-register/spec.md +++ b/openspec/specs/openbuilt-application-register/spec.md @@ -1,9 +1,20 @@ # openbuilt-application-register Specification ## Purpose -TBD - created by archiving change bootstrap-openbuilt. Update Purpose after archive. + +Declares the OR-backed registry that stores every virtual app's `Application` +record (logical half of the ADR-002 two-object versioning model — `productionVersion` +pointer, no manifest, no status), plus the `BuiltAppRoute` slug index that powers +runtime slug-to-Application lookup. Adds the optional top-level `icon`/`iconDark` +ref fields (per ADR-001, sibling to manifest), the `permissions` RBAC block +(populated for legacy seeds via an idempotent migration), and multi-tenant +scoping via OR's standard `organisation` field (ADR-022). Lifecycle relocates to +`ApplicationVersion` under the versioned model — Application carries no +`status` enum and no state machine. + ## Requirements -### Requirement: REQ-OBA-001 Application schema registered in OpenRegister + +### Requirement: Application schema registered in OpenRegister The system SHALL declare an `Application` schema in `lib/Settings/openbuilt_register.json` under the `openbuilt` register namespace. @@ -20,6 +31,8 @@ disappear entirely (`currentVersion` is retired per ADR-002 §Decision). The sch SHALL be imported into OpenRegister at app install / post-migration time via the existing repair step. +**ID:** REQ-OBA-001 + #### Scenario: Schema is available after install - **WHEN** the OpenBuilt app is installed and its repair step runs @@ -37,13 +50,15 @@ existing repair step. - **AND** the returned object has no `manifest`, `version`, `status`, or `currentVersion` field -### Requirement: REQ-OBA-002 Manifest blob is structurally valid +### Requirement: Manifest blob is structurally valid The `manifest` property of every `Application` object SHALL validate against the canonical app-manifest schema at `@conduction/nextcloud-vue/src/schemas/app-manifest.schema.json` (v1.4.0 or later). The system SHALL reject save operations whose `manifest` blob fails schema validation, returning a 4xx response that identifies the failing property path. +**ID:** REQ-OBA-002 + The `Application` schema SHALL additionally declare two optional top-level properties — `icon` and `iconDark` — each of shape `{ "ref": "<filename>" }` referencing an OR-attached SVG file on the Application record (per ADR-001). These properties live as siblings to @@ -86,7 +101,7 @@ spec does not touch `app-manifest.schema.json` and carries no upstream coupling. - **THEN** OR persists the object, returns 201, and the returned object carries an OR-assigned `uuid` and the submitted fields -### Requirement: REQ-OBA-003 Declarative lifecycle drives state transitions +### Requirement: Declarative lifecycle drives state transitions Under the versioned model, the `Application` schema SHALL NOT declare a `status`-based state machine in `x-openregister-lifecycle` — lifecycle is per-version @@ -98,6 +113,8 @@ The Application schema MAY retain `x-openregister-lifecycle` only for any cross- hooks (e.g. integrity guards) — it SHALL NOT carry a `states` block or `transitions` in v1 of this change. No `ApplicationLifecycleService` SHALL be written. +**ID:** REQ-OBA-003 + #### Scenario: Application has no status state machine - **WHEN** the OpenBuilt repair step runs and imports the Application schema @@ -105,7 +122,7 @@ in v1 of this change. No `ApplicationLifecycleService` SHALL be written. - **AND** the imported schema's `x-openregister-lifecycle` carries no `states` or `transitions` block -### Requirement: REQ-OBA-004 BuiltAppRoute index for slug lookup +### Requirement: BuiltAppRoute index for slug lookup The system SHALL declare a `BuiltAppRoute` schema in `lib/Settings/openbuilt_register.json` with properties `slug` (string, required, @@ -117,6 +134,8 @@ on `ApplicationVersion`'s lifecycle — see `application-versions`/REQ-OBV-106, `Application`'s). The `applicationUuid` field on the route record points at the parent Application (i.e. `ApplicationVersion.application.uuid`). +**ID:** REQ-OBA-004 + #### Scenario: Publishing the first version creates a BuiltAppRoute - **WHEN** an Application with `slug: hello-world` has its first ApplicationVersion @@ -131,7 +150,7 @@ parent Application (i.e. `ApplicationVersion.application.uuid`). - **THEN** OR returns a 4xx error citing the slug conflict - **AND** no second `BuiltAppRoute` is created -### Requirement: REQ-OBA-005 Multi-tenant scoping via OR organisation +### Requirement: Multi-tenant scoping via OR organisation Every `Application` and `BuiltAppRoute` object SHALL inherit OpenRegister's `organisation` field for multi-tenant scoping. List, @@ -139,6 +158,8 @@ read, write, and lifecycle operations SHALL only return / accept objects in the caller's organisation scope, enforced by OR's existing authorization layer (ADR-022 — no app-local RBAC duplication). +**ID:** REQ-OBA-005 + #### Scenario: Cross-organisation reads are blocked - **WHEN** a user in organisation A requests Applications owned by @@ -146,7 +167,7 @@ authorization layer (ADR-022 — no app-local RBAC duplication). - **THEN** OR returns an empty list (or a 403, per its standard contract) — the cross-org objects are not visible -### Requirement: REQ-OBA-006 Application schema carries a permissions block +### Requirement: Application schema carries a permissions block The system SHALL extend the `Application` schema in `lib/Settings/openbuilt_register.json` with an optional `permissions` property of shape: @@ -164,6 +185,8 @@ The system SHALL extend the `Application` schema in `lib/Settings/openbuilt_regi } ``` +**ID:** REQ-OBA-006 + Each array element is a Nextcloud group ID (`gid`) string. The property is optional in the schema so that legacy Applications created by spec #1's repair step (the seeded `hello-world` @@ -202,7 +225,7 @@ addition to `Application` per ADR-031 (no service class). - **THEN** OR rejects the save with a 4xx citing the unknown property under `permissions` -### Requirement: REQ-OBA-007 Migration populates permissions for pre-existing Applications +### Requirement: Migration populates permissions for pre-existing Applications The OpenBuilt repair step SHALL include an idempotent migration that, for every existing `Application` object whose `permissions` @@ -215,6 +238,8 @@ field) is the canonical case the migration covers; after this spec's apply phase, every Application in every installed instance has a populated `permissions` field. +**ID:** REQ-OBA-007 + #### Scenario: Pre-existing Application receives a default permissions block - **GIVEN** an existing Application with `slug: hello-world` and no @@ -231,7 +256,7 @@ has a populated `permissions` field. - **THEN** no Application is changed - **AND** no duplicate audit entries are produced -### Requirement: REQ-OBA-008 Application carries a productionVersion relation +### Requirement: Application carries a productionVersion relation The `Application` schema SHALL be extended with a `productionVersion` property of type relation (OR's first-class relation type — not a raw UUID string per ADR-002 @@ -245,6 +270,8 @@ When populated, `productionVersion` SHALL satisfy the integrity guard in `application` relation MUST point back at this Application. Mismatched pointers SHALL be rejected with a 422 response. +**ID:** REQ-OBA-008 + #### Scenario: Schema declares productionVersion as an optional relation - **WHEN** the OpenBuilt repair step runs and imports the Application schema @@ -259,4 +286,3 @@ SHALL be rejected with a 422 response. - **WHEN** a client saves `X.productionVersion = V` - **THEN** the response is `422` citing the back-reference mismatch - **AND** X's `productionVersion` is unchanged - diff --git a/openspec/specs/openbuilt-exporter/spec.md b/openspec/specs/openbuilt-exporter/spec.md index 8fbd2e8f..8b852c1c 100644 --- a/openspec/specs/openbuilt-exporter/spec.md +++ b/openspec/specs/openbuilt-exporter/spec.md @@ -1,8 +1,21 @@ # openbuilt-exporter Specification ## Purpose -TBD - created by archiving change openbuilt-export-to-real-app. Update Purpose after archive. + +Ships the graduation path that turns a published OpenBuilt virtual app into a +standalone Nextcloud app — its own `appinfo/info.xml`, its own namespace, its own +GitHub repo, its own CI / release pipeline — with zero runtime dependency on +OpenBuilt. Given an `Application` record + its companion schemas + sample data, the +exporter generates a complete nextcloud-app-template-shaped tree on disk and either +streams it as a ZIP or pushes it to a new GitHub repo. The exported app boots +Tier-4 (per ADR-024): one bundled `src/manifest.json`, one `<app>_register.json` +schema bundle, no per-slug endpoint workaround, no nested mount — the exported app +**is** the top-level app. Closes the loop on the hybrid model committed to in +`bootstrap-openbuilt`. + ## Requirements + + ### Requirement: ExportJob schema declaration The system SHALL declare an `ExportJob` schema in diff --git a/openspec/specs/openbuilt-page-designer/spec.md b/openspec/specs/openbuilt-page-designer/spec.md index 539b256b..25abcf36 100644 --- a/openspec/specs/openbuilt-page-designer/spec.md +++ b/openspec/specs/openbuilt-page-designer/spec.md @@ -1,9 +1,23 @@ # openbuilt-page-designer Specification ## Purpose -TBD - created by archiving change openbuilt-page-editor. Update Purpose after archive. + +Ships the visual Page Designer that replaces the textarea-only manifest editor +from `bootstrap-openbuilt`, giving citizen developers a structured UI for +authoring `manifest.menu[]` and `manifest.pages[]`. Provides one sub-editor per +canonical page type (`index | detail | dashboard | logs | settings | chat | files | +form | custom`), each authoring only its own `pages[].config` sub-shape per the +`@conduction/nextcloud-vue/src/schemas/app-manifest.schema.json` v1.4.0+ contract. +A two-level menu-tree editor enforces the canonical nesting depth and exactly-one-of +rules; a sandboxed `CnAppRoot` live-preview pane mounts the in-flight manifest +without saving when chain spec #2 (in-memory manifest loader) is detected. The +Raw JSON tab from spec #1 stays as the secondary fallback (shared in-flight +state). Save flows through OR REST — no app-local controller for manifest writes +(ADR-022). + ## Requirements -### Requirement: REQ-OBPD-001 Menu tree editor with two-level nesting + +### Requirement: Menu tree editor with two-level nesting The system SHALL provide a `MenuTreeEditor.vue` component that authors the manifest's `menu[]` array. The editor SHALL support @@ -17,6 +31,8 @@ optional). The editor MUST enforce the canonical schema rule that `action`, when set, makes `route` and `href` ignored, by surfacing a disabled-with-tooltip state on those fields. +**ID:** REQ-OBPD-001 + #### Scenario: Drag-reorder updates the manifest in order - **WHEN** the editor lists three menu entries and the user drags the @@ -41,7 +57,7 @@ disabled-with-tooltip state on those fields. - **AND** clears any pre-existing values from `route` and `href` in the manifest output -### Requirement: REQ-OBPD-002 Page list editor with uniqueness and route-pattern validation +### Requirement: Page list editor with uniqueness and route-pattern validation The system SHALL provide a `PageListEditor.vue` component that authors the manifest's `pages[]` array. The editor SHALL support @@ -56,6 +72,8 @@ following invariants before allowing a save: | `chat` | `files` | `form` | `custom`) before any other field is shown, so the correct per-type sub-editor mounts immediately. +**ID:** REQ-OBPD-002 + #### Scenario: Duplicate id blocks save with a marked error - **WHEN** two pages in the list share the same `id` value @@ -71,7 +89,7 @@ following invariants before allowing a save: - **AND** the new page is appended to `pages[]` with `type: 'form'` and a placeholder `id` + `route` the user is prompted to fill in -### Requirement: REQ-OBPD-003 Per-page-type config sub-editor for each of the nine canonical types +### Requirement: Per-page-type config sub-editor for each of the nine canonical types The system SHALL ship one Vue sub-editor component per canonical page type declared in the @@ -86,6 +104,8 @@ config sub-shape declared in the canonical schema's `pages[].config` description block. The page-list editor SHALL mount the sub-editor whose name matches the selected page's `type` field. +**ID:** REQ-OBPD-003 + #### Scenario: Switching page type swaps the sub-editor - **WHEN** the user edits an existing `type: index` page and changes @@ -97,7 +117,7 @@ whose name matches the selected page's `type` field. - **AND** the side-panel validator confirms the new shape against the canonical schema -### Requirement: REQ-OBPD-004 Index-page sub-editor: register, schema, columns, actions +### Requirement: Index-page sub-editor: register, schema, columns, actions `IndexPageEditor.vue` SHALL author the index-type `config` block per the canonical schema. It SHALL expose: @@ -117,6 +137,8 @@ the canonical schema. It SHALL expose: - Optional **sidebar** + **cardComponent** sub-blocks matching the canonical schema's index `sidebar` and `cardComponent` shapes. +**ID:** REQ-OBPD-004 + #### Scenario: Column picker offers @self.* metadata fields - **WHEN** the user opens the column-selector dropdown for an index @@ -126,7 +148,7 @@ the canonical schema. It SHALL expose: - **AND** selecting `@self.created` adds the column to `columns[]` in string shorthand `"@self.created"` -### Requirement: REQ-OBPD-005 Detail-page sub-editor: sidebar tabs and route param schema +### Requirement: Detail-page sub-editor: sidebar tabs and route param schema `DetailPageEditor.vue` SHALL author the detail-type `config` block. It SHALL expose: @@ -143,6 +165,8 @@ SHALL expose: open-enum tab definition (`{ id, label, icon?, widgets?, component?, order? }`). +**ID:** REQ-OBPD-005 + #### Scenario: Tab list overrides the built-in sidebar tabs - **WHEN** the user adds three tabs (`overview`, `audit`, `relations`) @@ -152,7 +176,7 @@ SHALL expose: - **AND** the validator confirms each tab declares exactly one of `widgets[]` OR `component` -### Requirement: REQ-OBPD-006 Form-page sub-editor with exactly-one-of submit handling +### Requirement: Form-page sub-editor with exactly-one-of submit handling `FormPageEditor.vue` SHALL author the form-type `config` block. It SHALL expose: @@ -171,6 +195,8 @@ SHALL expose: - Optional **submitLabel**, **successMessage**, **initialValue** inputs. +**ID:** REQ-OBPD-006 + #### Scenario: Setting submitHandler clears submitEndpoint - **WHEN** the user enters a `submitEndpoint` and then types a value @@ -188,7 +214,7 @@ SHALL expose: closed enum - **AND** the parent editor's Save button is disabled -### Requirement: REQ-OBPD-007 Custom-page sub-editor reads the customComponents registry +### Requirement: Custom-page sub-editor reads the customComponents registry `CustomPageEditor.vue` SHALL surface a **component-name picker** populated from the consuming app's `customComponents` registry — @@ -202,6 +228,8 @@ The sub-editor SHALL also expose a free-form JSON editor for the `type: custom` configs to be "any shape the custom component expects". +**ID:** REQ-OBPD-007 + #### Scenario: Registry-backed picker lists known components - **WHEN** the live-preview pane is active and the registry exposes @@ -218,7 +246,7 @@ expects". - **AND** an i18n warning explains the validation-deferral - **AND** the value writes through to `pages[].component` unchanged -### Requirement: REQ-OBPD-008 Live-preview pane mounts a sandboxed CnAppRoot when available +### Requirement: Live-preview pane mounts a sandboxed CnAppRoot when available The Page Designer SHALL provide an optional right-hand pane that mounts a **sandboxed** `CnAppRoot` instance configured from the @@ -231,6 +259,8 @@ collapse to a "save & reload" affordance that opens `/builder/:slug` in a new browser tab against the last saved manifest, with an inline i18n note explaining the limitation. +**ID:** REQ-OBPD-008 + The sandboxed `CnAppRoot` SHALL: - Use a unique `appId` of `openbuilt-preview-{slug}` so its state @@ -256,7 +286,7 @@ The sandboxed `CnAppRoot` SHALL: - **AND** clicking the button saves the manifest and opens `/builder/:slug` in a new tab -### Requirement: REQ-OBPD-009 Save flow PUTs the manifest via OpenRegister REST +### Requirement: Save flow PUTs the manifest via OpenRegister REST The Page Designer's Save action SHALL serialise the in-flight manifest, validate it via @@ -267,6 +297,8 @@ updated `Application` object via OpenRegister's existing REST API at designer MUST NOT introduce a new openbuilt-side controller for manifest writes (ADR-022). +**ID:** REQ-OBPD-009 + #### Scenario: Save persists via OR REST - **WHEN** the user clicks Save with a valid manifest @@ -284,7 +316,7 @@ manifest writes (ADR-022). - **AND** clicking the (disabled) button surfaces a tooltip enumerating the blocking error count -### Requirement: REQ-OBPD-010 Raw JSON fallback tab preserves the spec-1 textarea +### Requirement: Raw JSON fallback tab preserves the spec-1 textarea The Application edit view SHALL retain the textarea-based JSON manifest editor shipped by spec #1 (`bootstrap-openbuilt`) as a @@ -294,6 +326,8 @@ tabs SHALL share the same in-flight manifest state, so edits made in one tab are visible in the other when the user switches tabs without saving. +**ID:** REQ-OBPD-010 + #### Scenario: Switching tabs preserves unsaved edits - **WHEN** the user edits a page title in the Design tab and switches @@ -311,7 +345,7 @@ saving. in its side-panel error list and disables the Design tab inputs until the JSON is valid again -### Requirement: REQ-OBPD-011 Debounced validator surface decorates editor panes inline +### Requirement: Debounced validator surface decorates editor panes inline The system SHALL provide `useManifestValidator.js`, a composable that wraps `validateManifest` from `@conduction/nextcloud-vue` and @@ -323,6 +357,8 @@ specific editor field whose JSON path matches the error path. The composable MUST NOT block the editor on validation — the UI stays responsive and the validator output catches up asynchronously. +**ID:** REQ-OBPD-011 + #### Scenario: Error path maps to inline field mark - **WHEN** the validator reports an error at JSON path @@ -339,4 +375,3 @@ responsive and the validator output catches up asynchronously. - **THEN** the validator runs at most once during that window - **AND** the editor remains responsive (no input lag attributable to validation) - diff --git a/openspec/specs/openbuilt-rbac/spec.md b/openspec/specs/openbuilt-rbac/spec.md index faf0c8b7..73f817b9 100644 --- a/openspec/specs/openbuilt-rbac/spec.md +++ b/openspec/specs/openbuilt-rbac/spec.md @@ -1,9 +1,23 @@ # openbuilt-rbac Specification ## Purpose -TBD - created by archiving change openbuilt-rbac. Update Purpose after archive. + +Closes the per-built-app RBAC gap left open by `bootstrap-openbuilt`'s "auth-only" +posture. Introduces a per-virtual-app role model (`owner | editor | viewer`) +declaratively stored on the Application schema's `permissions` block keyed by +Nextcloud group IDs, layered on top of OR's existing organisation scoping +(ADR-022). Enforcement spans the manifest endpoint (403 before any payload leak), +the Application list (declarative OR filter preferred, frontend fallback via +`loadState`), the editor UIs (role → action mapping consumed via a single +`useRole(application)` composable), a no-orphan transfer-ownership flow (direct +declarative `permissions` PUT — no transfer service), the audited admin bypass, +and the global `openbuilt.use` nav-entry permission grantable through Nextcloud's +standard admin UI. Every permission change writes through OR's native +object-change audit trail. + ## Requirements -### Requirement: REQ-OBRBAC-001 Permissions field shape and default on creation + +### Requirement: Permissions field shape and default on creation The system SHALL extend the `Application` schema with an optional `permissions` property of shape @@ -18,6 +32,8 @@ default to empty arrays. If the creator has no group membership, the system SHALL fall back to the `admin` group as the sole owner so the Application is never created in an unreachable "no owner" state. +**ID:** REQ-OBRBAC-001 + #### Scenario: New Application gets creator's primary group as owner - **WHEN** a user whose primary group is `team-alpha` creates a new @@ -34,7 +50,7 @@ Application is never created in an unreachable "no owner" state. `permissions.owners = ["admin"]` - **AND** the user is recorded as the actor in the OR audit trail -### Requirement: REQ-OBRBAC-002 Manifest endpoint enforces role membership +### Requirement: Manifest endpoint enforces role membership The system SHALL augment `GET /index.php/apps/openbuilt/api/applications/{slug}/manifest` so @@ -49,6 +65,8 @@ controller SHALL respond `403 Forbidden` with a JSON error body. The check SHALL run before any other branch that would return the manifest payload — deny-by-default per ADR-005. +**ID:** REQ-OBRBAC-002 + #### Scenario: Member of viewer group reads the manifest - **WHEN** user `bob` whose groups include `viewers-alpha` requests @@ -75,7 +93,7 @@ manifest payload — deny-by-default per ADR-005. - **AND** the response body does not leak the Application's `name`, `description`, or any manifest content -### Requirement: REQ-OBRBAC-003 Application list filters out unauthorised entries +### Requirement: Application list filters out unauthorised entries The OpenBuilt shell's Application list view SHALL display only Applications on which the caller has at least one role @@ -96,6 +114,8 @@ order of preference: In both paths, the user-visible behaviour is identical: unauthorised Applications do not appear in the list. +**ID:** REQ-OBRBAC-003 + #### Scenario: List omits Applications without any role - **WHEN** user `bob` opens the OpenBuilt Application list @@ -105,7 +125,7 @@ Applications do not appear in the list. - **AND** the omitted 7 do not appear in the response payload consumed by the frontend -### Requirement: REQ-OBRBAC-004 Role-to-action mapping in editor UIs +### Requirement: Role-to-action mapping in editor UIs The system SHALL gate destructive and write actions in the OpenBuilt editor UIs according to the following role → action mapping. Buttons @@ -127,6 +147,8 @@ SHALL consume the same `useRole(application)` composable. | Transfer ownership | no | no | yes | | Delete Application | no | no | yes | +**ID:** REQ-OBRBAC-004 + #### Scenario: Viewer cannot save manifest edits - **WHEN** a user with only `viewer` role on an Application opens it @@ -145,7 +167,7 @@ SHALL consume the same `useRole(application)` composable. - **AND** the Publish button SHALL be hidden (or disabled with a tooltip explaining "owner role required") -### Requirement: REQ-OBRBAC-005 Transfer-ownership flow +### Requirement: Transfer-ownership flow The system SHALL support an owner replacing the `permissions.owners` list of an Application. The transfer SHALL be a single declarative @@ -157,6 +179,8 @@ that opens a group picker and PUTs the updated `permissions` block. The system SHALL reject (`4xx`) any transfer that would result in an empty `permissions.owners` array, preventing accidental orphaning. +**ID:** REQ-OBRBAC-005 + #### Scenario: Owner transfers ownership to a different group - **WHEN** a user with `owner` role transfers ownership from @@ -175,7 +199,7 @@ empty `permissions.owners` array, preventing accidental orphaning. - **THEN** the system returns a `4xx` error citing the orphan-check - **AND** the Application's `permissions` is unchanged -### Requirement: REQ-OBRBAC-006 Global `openbuilt.use` navigation-entry permission +### Requirement: Global `openbuilt.use` navigation-entry permission The system SHALL extend `appinfo/info.xml` to declare an `openbuilt.use` group-permission on the `<navigations>` entry. The @@ -194,6 +218,8 @@ permission SHALL be: user with `openbuilt.use` who has no role on any Application sees an empty list, not an error. +**ID:** REQ-OBRBAC-006 + A Nextcloud administrator MAY also bypass per-Application `permissions` checks for incident response, but ONLY when explicitly acting in admin mode (`IUserSession::isLoggedIn()` and the user is in @@ -219,7 +245,7 @@ exercised so the action is reviewable. - **AND** the OR audit trail contains a `rbac.admin_bypass` event naming the actor, the slug, and the timestamp -### Requirement: REQ-OBRBAC-007 Permission changes are recorded in the OR audit trail +### Requirement: Permission changes are recorded in the OR audit trail The system SHALL record every change to an Application's `permissions` property in OpenRegister's standard per-object audit trail, regardless of whether the change is made through the OpenBuilt frontend permissions panel, the textarea editor, OR REST directly, or the transfer-ownership flow. The audit entry SHALL be the OR-native object-change event (no app-local @@ -229,6 +255,8 @@ change-tracking per ADR-022. The OpenBuilt editor SHALL expose this audit trail in a "Permission history" panel visible to `owner` role holders only. +**ID:** REQ-OBRBAC-007 + #### Scenario: Permission change appears in the audit trail - **WHEN** an owner adds the group `qa-alpha` to @@ -244,4 +272,3 @@ holders only. - **THEN** the "Permission history" panel SHALL NOT be visible - **AND** any direct API call the panel would make SHALL be gated by the same owner-only check - diff --git a/openspec/specs/openbuilt-runtime/spec.md b/openspec/specs/openbuilt-runtime/spec.md index be4739b6..12ad61d0 100644 --- a/openspec/specs/openbuilt-runtime/spec.md +++ b/openspec/specs/openbuilt-runtime/spec.md @@ -1,9 +1,25 @@ # openbuilt-runtime Specification ## Purpose -TBD - created by archiving change bootstrap-openbuilt. Update Purpose after archive. + +The OpenBuilt runtime: foundational shell + per-slug manifest serving, plus every +delta later archived chains have layered on the same capability. Defines the +slug-keyed manifest endpoint backed by the `BuiltAppRoute` index, the nested +`CnAppRoot` mount under `/builder/:slug/*` (inner router resolves path segments +after the slug), the seeded hello-world Application (idempotent), and the tabbed +Application editor (Design / Raw JSON). Layers added by subsequent archives — +schema-designer routes mounted under the outer router (orthogonal to the +runtime-preview mount), a Publish action with version-snapshot toast, a +draft-vs-published indicator with "modified since last publish" marker, a +VersionHistory panel + audit-clean rollback, a ManifestDiff side-by-side view, +and the RBAC overlay (403-before-payload gate on the manifest endpoint, +group-filtered list view, role-gated editor controls, group set provided via +`IInitialState` per ADR-004) — all live here alongside the ApplicationCard +icon / no-Live-chip refinement. + ## Requirements -### Requirement: REQ-OBR-001 Manifest endpoint per virtual-app slug + +### Requirement: Manifest endpoint per virtual-app slug The system SHALL expose `GET /index.php/apps/openbuilt/api/applications/{slug}/manifest` @@ -16,6 +32,8 @@ scope. The endpoint SHALL be registered via `appinfo/routes.php` (ADR-016) with `#[NoAdminRequired]` and a route-auth posture that treats it as authenticated-user-readable. +**ID:** REQ-OBR-001 + #### Scenario: Endpoint returns the stored manifest - **WHEN** an authenticated user requests @@ -31,7 +49,7 @@ treats it as authenticated-user-readable. that has no matching `BuiltAppRoute` - **THEN** the response is `404` with a JSON error body -### Requirement: REQ-OBR-002 OpenBuilt shell mounts a nested CnAppRoot per virtual app +### Requirement: OpenBuilt shell mounts a nested CnAppRoot per virtual app The OpenBuilt frontend SHALL register a route `/builder/:slug/*` whose view (`BuilderHost.vue`) mounts a **nested** `CnAppRoot` instance. @@ -43,6 +61,8 @@ app inside the OpenBuilt shell. The outer OpenBuilt shell's `CnAppNav`, header, and chrome SHALL remain visible; the inner `CnAppRoot` SHALL render only into the OpenBuilt page area. +**ID:** REQ-OBR-002 + #### Scenario: Navigating into a virtual app renders its manifest pages - **WHEN** an authenticated user navigates to @@ -53,7 +73,7 @@ app inside the OpenBuilt shell. The outer OpenBuilt shell's - **AND** the index page declared in the `hello-world` manifest renders -### Requirement: REQ-OBR-003 Path segments after the slug forward to the inner router +### Requirement: Path segments after the slug forward to the inner router For routes matching `/builder/:slug/*`, the system SHALL forward the path segments after `/{slug}` to the **inner** manifest's vue-router @@ -62,6 +82,8 @@ resolve correctly. The outer OpenBuilt router SHALL treat everything after `/{slug}/` as opaque to the inner router; the inner router MUST match its own routes against that suffix. +**ID:** REQ-OBR-003 + #### Scenario: Detail route inside a virtual app resolves - **WHEN** an authenticated user navigates to @@ -70,7 +92,7 @@ MUST match its own routes against that suffix. for the `hello-message` schema - **AND** the detail page renders for the requested object id -### Requirement: REQ-OBR-004 Seeded hello-world Application exercises index, detail, form +### Requirement: Seeded hello-world Application exercises index, detail, form The repair step SHALL seed a single Application with `slug: hello-world`, `status: published`, a `manifest` declaring at least @@ -80,6 +102,8 @@ sample `hello-message` objects. The seed SHALL be idempotent (safe to re-run) and SHALL only run when no `Application` with `slug: hello-world` exists in the system organisation scope. +**ID:** REQ-OBR-004 + #### Scenario: Fresh install renders the seeded virtual app - **WHEN** the OpenBuilt app is installed on a fresh Nextcloud @@ -97,7 +121,7 @@ hello-world` exists in the system organisation scope. - **THEN** no duplicate `hello-world` Application is created - **AND** no duplicate `hello-message` objects are created -### Requirement: REQ-OBR-005 Textarea manifest editor saves to the Application object +### Requirement: Textarea manifest editor saves to the Application object The OpenBuilt shell SHALL render a **tabbed Application editor** for the `manifest` field of an `Application` object, composed of two @@ -122,6 +146,8 @@ The shared in-flight manifest state SHALL persist across tab switches without saving, so edits made in one tab are visible in the other on tab change. +**ID:** REQ-OBR-005 + #### Scenario: Invalid edit is blocked before save - **WHEN** an integrator pastes a manifest blob missing the required @@ -152,7 +178,7 @@ tab change. - **THEN** the textarea's JSON content reflects the unsaved page title - **AND** the dirty indicator persists across the tab switch -### Requirement: REQ-OBR-006 Schema designer routes mounted under the builder host +### Requirement: Schema designer routes mounted under the builder host The OpenBuilt frontend router SHALL register two new routes under the existing `/builder/:slug/*` host (from `bootstrap-openbuilt` @@ -173,6 +199,14 @@ from `bootstrap-openbuilt` SHALL continue to mount the nested CnAppRoot for the runtime preview and SHALL be unaffected by this addition. +**ID:** REQ-OBR-006a + +_Disambiguation note: original `REQ-OBR-006` from the +`openbuilt-schema-editor` archive delta. Suffix `a` assigned 2026-05-24 +to disambiguate from `REQ-OBR-006b` (Publish action, from +`openbuilt-versioning`) and `REQ-OBR-006c` (Manifest 403 RBAC gate, +from `openbuilt-rbac`) per ADR-037._ + #### Scenario: Schema list route renders the designer, not the virtual app - **WHEN** an authenticated user navigates to @@ -190,7 +224,7 @@ addition. - **AND** the Schemas menu entry is reachable from the outer shell's navigation -### Requirement: REQ-OBR-007 Schemas menu entry surfaced in the builder host +### Requirement: Schemas menu entry surfaced in the builder host `src/views/BuilderHost.vue` SHALL surface a **Schemas** menu entry in the OpenBuilt outer-shell secondary navigation while the user is in a @@ -201,6 +235,14 @@ authorised to read the virtual app's Application object; chain spec entry SHALL use a translation key (`openbuilt.builder.menu.schemas`) in both `l10n/en.json` and `l10n/nl.json`. +**ID:** REQ-OBR-007a + +_Disambiguation note: original `REQ-OBR-007` from the +`openbuilt-schema-editor` archive delta. Suffix `a` assigned 2026-05-24 +to disambiguate from `REQ-OBR-007b` (Draft-vs-published indicator, from +`openbuilt-versioning`) and `REQ-OBR-007c` (List filters by role, from +`openbuilt-rbac`) per ADR-037._ + #### Scenario: Schemas entry appears in the builder context - **WHEN** an authenticated user opens @@ -210,7 +252,7 @@ in both `l10n/en.json` and `l10n/nl.json`. - **AND** clicking the entry navigates to `/builder/hello-world/schemas` -### Requirement: REQ-OBR-006 Application editor exposes a Publish action +### Requirement: Application editor exposes a Publish action `ApplicationEditor.vue` (REQ-OBR-005) SHALL render a "Publish" action button alongside the existing Save action. Clicking Publish @@ -224,6 +266,13 @@ transition failure (e.g. slug-conflict per REQ-OBA-004), surface an inline error and leave the manifest in draft state. The button SHALL be disabled while the lifecycle call is in flight. +**ID:** REQ-OBR-006b + +_Disambiguation note: original `REQ-OBR-006` from the +`openbuilt-versioning` archive delta. Suffix `b` assigned 2026-05-24 +to disambiguate from `REQ-OBR-006a` (Schema designer routes) and +`REQ-OBR-006c` (Manifest 403 RBAC gate) per ADR-037._ + #### Scenario: Successful publish creates a snapshot - **WHEN** an integrator opens the editor for a draft Application, @@ -241,7 +290,7 @@ SHALL be disabled while the lifecycle call is in flight. - **AND** the editor surfaces the validation error inline (same contract as Save) -### Requirement: REQ-OBR-007 Draft-vs-published indicator surfaces lifecycle state +### Requirement: Draft-vs-published indicator surfaces lifecycle state The OpenBuilt shell SHALL surface the Application's current `status` (and a marker for "has unpublished draft changes") in two @@ -253,6 +302,13 @@ manifest differs from the most recent `ApplicationVersion.manifest`. The badge SHALL use Nextcloud CSS variables for colour (no hardcoded colour literals — per ADR-010). +**ID:** REQ-OBR-007b + +_Disambiguation note: original `REQ-OBR-007` from the +`openbuilt-versioning` archive delta. Suffix `b` assigned 2026-05-24 +to disambiguate from `REQ-OBR-007a` (Schemas menu entry) and +`REQ-OBR-007c` (List filters by role) per ADR-037._ + #### Scenario: Newly published Application shows published badge - **WHEN** an Application has been published and its draft has not @@ -270,7 +326,7 @@ hardcoded colour literals — per ADR-010). "modified since last publish" marker - **AND** the list row reflects the same state -### Requirement: REQ-OBR-008 VersionHistory.vue lists snapshots for an Application +### Requirement: VersionHistory.vue lists snapshots for an Application The OpenBuilt shell SHALL render a `VersionHistory.vue` panel inside `ApplicationEditor.vue` (collapsible / a sibling tab, @@ -280,6 +336,13 @@ first). Each row SHALL display `version`, `publishedAt` (localised), `publishedBy`, and any `notes`. The list SHALL be read from OR REST filtered by `applicationUuid` — no app-local wrapper service. +**ID:** REQ-OBR-008a + +_Disambiguation note: original `REQ-OBR-008` from the +`openbuilt-versioning` archive delta. Suffix `a` assigned 2026-05-24 +to disambiguate from `REQ-OBR-008b` (Editor UIs gate destructive +actions per role, from `openbuilt-rbac`) per ADR-037._ + #### Scenario: History panel renders snapshots - **WHEN** an integrator opens an Application that has three @@ -296,7 +359,7 @@ filtered by `applicationUuid` — no app-local wrapper service. - **THEN** the version-history panel renders an empty state - **AND** no console error is emitted from the empty-list fetch -### Requirement: REQ-OBR-009 Rollback action restores a chosen snapshot +### Requirement: Rollback action restores a chosen snapshot Each row in the `VersionHistory.vue` panel SHALL carry a "Roll back to this version" action. Clicking it SHALL: (a) prompt for @@ -311,6 +374,13 @@ audit-clean — it does **not** delete or mutate existing its own SFC under `src/modals/` per Hydra modal-isolation gate (ADR-004). +**ID:** REQ-OBR-009a + +_Disambiguation note: original `REQ-OBR-009` from the +`openbuilt-versioning` archive delta. Suffix `a` assigned 2026-05-24 +to disambiguate from `REQ-OBR-009b` (Caller's group set via +IInitialState, from `openbuilt-rbac`) per ADR-037._ + #### Scenario: Rollback restores manifest and stays in draft - **WHEN** an integrator clicks "Roll back to this version" on the @@ -327,7 +397,7 @@ its own SFC under `src/modals/` per Hydra modal-isolation gate - **THEN** no PUT is sent to OR - **AND** the textarea content is unchanged -### Requirement: REQ-OBR-010 ManifestDiff.vue renders a side-by-side diff +### Requirement: ManifestDiff.vue renders a side-by-side diff The OpenBuilt shell SHALL ship a `ManifestDiff.vue` component rendering a client-side side-by-side diff between two manifest @@ -341,6 +411,8 @@ colour-coded tokens using Nextcloud CSS variables. By default the editor SHALL preselect `from=draft` and `to=<currentVersion>` when the diff view is opened. +**ID:** REQ-OBR-010 + #### Scenario: Default diff shows current draft vs latest published - **WHEN** an integrator opens the diff view from the editor of an @@ -361,7 +433,7 @@ the diff view is opened. - **AND** the rendered diff matches what the diff endpoint returned for that pair -### Requirement: REQ-OBR-006 Manifest endpoint returns 403 for unauthorised callers +### Requirement: Manifest endpoint returns 403 for unauthorised callers `ApplicationsController::getManifest` SHALL be extended with a permissions check that runs after the organisation-scope resolution @@ -380,6 +452,13 @@ leak any Application metadata (no name, no description, no manifest fragment). Implementation is a single in-controller check — no new service class — per ADR-022 §Exceptions(1). +**ID:** REQ-OBR-006c + +_Disambiguation note: original `REQ-OBR-006` from the +`openbuilt-rbac` archive delta. Suffix `c` assigned 2026-05-24 to +disambiguate from `REQ-OBR-006a` (Schema designer routes) and +`REQ-OBR-006b` (Publish action) per ADR-037._ + #### Scenario: Caller without a role gets 403 (not 200, not 404) - **WHEN** an authenticated user requests @@ -399,7 +478,7 @@ service class — per ADR-022 §Exceptions(1). - **THEN** the response is `200 application/json` and the body is the stored `manifest` blob -### Requirement: REQ-OBR-007 Application list view filters by caller's roles +### Requirement: Application list view filters by caller's roles The system SHALL ensure the frontend Application list (the entry view of the OpenBuilt shell, currently `ApplicationEditor.vue`'s list mode) renders only Applications on which the caller has at least one role. @@ -414,6 +493,13 @@ frontend via `IInitialState::provideInitialState('openbuilt', 'currentUserGroups', [...])` consumed by `loadState` (per ADR-004 — no `document.getElementById().dataset` reads). +**ID:** REQ-OBR-007c + +_Disambiguation note: original `REQ-OBR-007` from the +`openbuilt-rbac` archive delta. Suffix `c` assigned 2026-05-24 to +disambiguate from `REQ-OBR-007a` (Schemas menu entry) and +`REQ-OBR-007b` (Draft-vs-published indicator) per ADR-037._ + #### Scenario: User sees only authorised applications - **WHEN** user `bob` (in groups `team-alpha`, `qa-shared`) opens @@ -432,7 +518,7 @@ no `document.getElementById().dataset` reads). - **AND** the empty-state UI explains "No applications available — ask an owner to grant you access" -### Requirement: REQ-OBR-008 Editor UIs gate destructive actions per role +### Requirement: Editor UIs gate destructive actions per role The system SHALL gate role-restricted actions in the OpenBuilt editor views (currently the textarea editor `ApplicationEditor.vue`; the visual editors arriving in chain specs #5 and #6 when they land) via a shared `useRole(application)` composable that returns the caller's effective role (`owner | editor | viewer | none`). The mapping in REQ-OBRBAC-004 is the canonical source. UI controls @@ -448,8 +534,15 @@ SHALL be: Permissions panel and the Permission history panel. A user whose role is `none` cannot reach the editor at all -(REQ-OBR-007 ensures the Application doesn't appear in their -list; REQ-OBR-006 ensures direct-URL access returns 403). +(REQ-OBR-007c ensures the Application doesn't appear in their +list; REQ-OBR-006c ensures direct-URL access returns 403). + +**ID:** REQ-OBR-008b + +_Disambiguation note: original `REQ-OBR-008` from the +`openbuilt-rbac` archive delta. Suffix `b` assigned 2026-05-24 to +disambiguate from `REQ-OBR-008a` (VersionHistory panel, from +`openbuilt-versioning`) per ADR-037._ #### Scenario: Editor sees Save but not Publish @@ -466,7 +559,7 @@ list; REQ-OBR-006 ensures direct-URL access returns 403). enabled - **AND** the Permission history panel is reachable -### Requirement: REQ-OBR-009 Caller's group set is provided via initial state +### Requirement: Caller's group set is provided via initial state The OpenBuilt PHP layer SHALL provide the caller's Nextcloud group IDs to the frontend via @@ -481,6 +574,13 @@ data-attribute, fetch endpoint, or `document.getElementById` pattern (ADR-004 hard rule; enforced by the `gate-initial-state` Hydra gate). +**ID:** REQ-OBR-009b + +_Disambiguation note: original `REQ-OBR-009` from the +`openbuilt-rbac` archive delta. Suffix `b` assigned 2026-05-24 to +disambiguate from `REQ-OBR-009a` (Rollback action, from +`openbuilt-versioning`) per ADR-037._ + #### Scenario: Frontend sees the caller's groups - **WHEN** the OpenBuilt shell boots for user `bob` (in groups @@ -490,7 +590,7 @@ pattern (ADR-004 hard rule; enforced by the - **AND** no DOM data-attribute access is needed to obtain the groups -### Requirement: REQ-OBR-013 ApplicationCard renders icon and omits redundant Live chip +### Requirement: ApplicationCard renders icon and omits redundant Live chip `ApplicationCard.vue` SHALL render the Application's icon in front of the app title using an `<img>` element whose `src` is the URL of the icon-serving light endpoint @@ -501,6 +601,8 @@ lifecycle-status pill (line 23) already communicates "Published" state to the us Live chip produces duplicate signalling. The `ob-app-card__chip--live` CSS rule and the `v-if="app.currentVersion"` conditional SHALL be removed. +**ID:** REQ-OBR-013 + #### Scenario: Published app card shows icon before the title - **WHEN** a user views the virtual apps index and a published Application has an icon @@ -526,4 +628,3 @@ Live chip produces duplicate signalling. The `ob-app-card__chip--live` CSS rule - **THEN** the title heading, description paragraph, version chip, role chip, and slug chip continue to render in their expected positions and the card's click navigation to VirtualAppDetail is unaffected - diff --git a/openspec/specs/openbuilt-schema-designer/spec.md b/openspec/specs/openbuilt-schema-designer/spec.md index c0db83db..05c73a65 100644 --- a/openspec/specs/openbuilt-schema-designer/spec.md +++ b/openspec/specs/openbuilt-schema-designer/spec.md @@ -1,9 +1,23 @@ # openbuilt-schema-designer Specification ## Purpose -TBD - created by archiving change openbuilt-schema-editor. Update Purpose after archive. + +Ships the visual Schema Designer that gives non-technical authors direct authoring +power over the data model of their virtual app — replacing the deploy-time +`lib/Settings/{app}_register.json` pattern. Scoped to the current virtual app's +register namespace, the designer composes a JSON Schema body with declarative +`x-openregister-*` extension blocks (lifecycle, aggregations, calculations, +notifications, relations, widgets) through typed sub-editors. Every +behaviour-shaping field is declarative — no free-text PHP, no JavaScript callbacks, +no service-class references; the editor is code, but the product is declarative +(canonical ADR-031 example). Persists via OR's runtime schema CRUD endpoint (chain +spec `openregister-runtime-schema-api`), surfaces confirm-before-destructive flows +for delete-field / delete-schema, and runs live client-side validation that +disables Save until the staged change is valid. + ## Requirements -### Requirement: REQ-OBSD-001 Schema list panel scoped to the virtual app's register namespace + +### Requirement: Schema list panel scoped to the virtual app's register namespace The OpenBuilt schema designer SHALL render a list of schemas scoped to the current virtual app's OpenRegister register namespace. The list @@ -18,6 +32,8 @@ that wraps OR's runtime schema list endpoint (chain spec `openregister-runtime-schema-api`) and SHALL NOT bypass that endpoint with direct DB reads. +**ID:** REQ-OBSD-001 + #### Scenario: Designer lists the schemas of the current virtual app - **WHEN** an authenticated user navigates to @@ -35,7 +51,7 @@ with direct DB reads. `customer` schema - **THEN** the schema list panel does NOT render the `customer` row -### Requirement: REQ-OBSD-002 Add Schema flow captures slug, title, description, version +### Requirement: Add Schema flow captures slug, title, description, version When the user activates the **Add Schema** action, the designer SHALL render a guided form via `SchemaHeaderForm.vue` capturing: @@ -54,6 +70,8 @@ and on success SHALL route the user to the schema's detail view returned by the runtime endpoint SHALL surface inline on the failing field. +**ID:** REQ-OBSD-002 + #### Scenario: Valid Add Schema submission persists and routes to the schema - **WHEN** the user submits the Add Schema form with @@ -71,7 +89,7 @@ field. - **AND** the form surfaces the conflict inline on the `slug` field - **AND** the router does NOT navigate away from the form -### Requirement: REQ-OBSD-003 Field editor manages property add, remove, reorder, type, and validation +### Requirement: Field editor manages property add, remove, reorder, type, and validation The schema detail view SHALL render the schema's `properties` map as an ordered list of `FieldRow.vue` rows. For each property the user @@ -99,6 +117,8 @@ Changes SHALL be staged in the Pinia store and persisted only when the user activates **Save** (REQ-OBSD-006). Live validation feedback (REQ-OBSD-006) SHALL apply to each row. +**ID:** REQ-OBSD-003 + #### Scenario: Adding a string property with a regex validation - **WHEN** the user adds a new property `email` of type `string` @@ -115,7 +135,7 @@ user activates **Save** (REQ-OBSD-006). Live validation feedback - **AND** the user reloads the schema detail view - **THEN** `body` is rendered above `title` -### Requirement: REQ-OBSD-004 Visual lifecycle editor authors x-openregister-lifecycle declaratively +### Requirement: Visual lifecycle editor authors x-openregister-lifecycle declaratively The schema detail view SHALL render a `LifecycleEditor.vue` panel that lets the user author the schema's `x-openregister-lifecycle` block in @@ -139,6 +159,8 @@ record. The editor's output, when serialised, SHALL match the declarative engine on schema reload (chain spec `openregister-runtime-schema-api`). +**ID:** REQ-OBSD-004 + #### Scenario: Author a draft → published → archived lifecycle - **WHEN** the user adds three states (`draft`, `published`, @@ -162,7 +184,7 @@ declarative engine on schema reload (chain spec - **AND** the Save button is disabled until the user designates a new initial state -### Requirement: REQ-OBSD-005 Sub-editors for aggregations, calculations, notifications, relations, widgets +### Requirement: Sub-editors for aggregations, calculations, notifications, relations, widgets The schema detail view SHALL render five further declarative sub-editors, each surfaced under a collapsible section: @@ -195,6 +217,8 @@ sub-editors, each surfaced under a collapsible section: All five editors SHALL produce declarative JSON output and SHALL NOT accept free-text code in any field that affects runtime behaviour. +**ID:** REQ-OBSD-005 + #### Scenario: Add a count aggregation over a related collection - **WHEN** the user opens the `AggregationEditor` on a `customer` @@ -216,7 +240,7 @@ accept free-text code in any field that affects runtime behaviour. formula DSL" - **AND** the Save button is disabled -### Requirement: REQ-OBSD-006 Live validation and explicit Save persist to OR's runtime schema CRUD +### Requirement: Live validation and explicit Save persist to OR's runtime schema CRUD The designer SHALL run **live client-side validation** on every edit: field name uniqueness, slug pattern, semver pattern, required-field @@ -241,6 +265,8 @@ The runtime endpoint SHALL trigger OR's declarative-engine reload and cache invalidation (per chain spec #3); the designer SHALL NOT duplicate that work. +**ID:** REQ-OBSD-006 + #### Scenario: Invalid staged state disables Save until corrected - **WHEN** the user removes the only `initial` lifecycle state @@ -255,7 +281,7 @@ duplicate that work. - **AND** on `200 OK` the local store is refreshed from the response - **AND** a success toast is surfaced -### Requirement: REQ-OBSD-007 Designer output is declarative-only (ADR-031 compliance) +### Requirement: Designer output is declarative-only (ADR-031 compliance) The schema designer's serialised output SHALL be valid JSON Schema with declarative `x-openregister-*` extension blocks only. The @@ -267,6 +293,8 @@ as a typed declarative record drawn from OR's declarative vocabulary applied to a code-only spec: the editor is code, but its product is declarative. +**ID:** REQ-OBSD-007 + #### Scenario: Designer output contains no imperative references - **WHEN** the user saves any schema authored in the designer @@ -275,7 +303,7 @@ is declarative. - **AND** every `x-openregister-*` block validates against the declarative-vocabulary JSON Schema published by OR (chain spec #3) -### Requirement: REQ-OBSD-008 Confirm-before-destructive on delete-field and delete-schema +### Requirement: Confirm-before-destructive on delete-field and delete-schema The designer SHALL surface a confirmation dialog before performing either of the following destructive actions: @@ -294,6 +322,8 @@ either of the following destructive actions: In both cases, cancelling the dialog SHALL leave the staged store state unchanged. +**ID:** REQ-OBSD-008 + #### Scenario: Delete-field requires a confirmation click - **WHEN** the user clicks the remove action on a `FieldRow.vue` @@ -309,4 +339,3 @@ state unchanged. - **THEN** the dialog's **Delete** button is disabled until the user types the schema's slug exactly - **AND** cancelling the dialog leaves the schema in the list - diff --git a/openspec/specs/openbuilt-template-catalogue/spec.md b/openspec/specs/openbuilt-template-catalogue/spec.md index f26e8f2f..07956b6b 100644 --- a/openspec/specs/openbuilt-template-catalogue/spec.md +++ b/openspec/specs/openbuilt-template-catalogue/spec.md @@ -1,9 +1,22 @@ # openbuilt-template-catalogue Specification ## Purpose -TBD - created by archiving change openbuilt-templates-marketplace. Update Purpose after archive. + +Ships the starter-template gallery that turns OpenBuilt's competitor-parity +"day-one templates" promise into a working surface. Declares an `ApplicationTemplate` +schema, seeds four Conduction-curated templates (permit-tracker, +stakeholder-consultation, employee-onboarding, incident-reporter) via an idempotent +repair step, renders a filterable gallery view, and one-click clones a chosen +template into a new draft Application — namespacing every cloned companion schema +under the new Application's slug to avoid collisions, recording the source +template + version on `templateOrigin` for traceability, and redirecting straight +into the page editor for customisation. Clones are one-shot snapshots (no +back-propagation); curated templates are read-only via UI; gallery and seed +content are fully i18n'd (nl/en minimum). + ## Requirements -### Requirement: REQ-OBTC-001 ApplicationTemplate schema declares the template record contract + +### Requirement: ApplicationTemplate schema declares the template record contract The system SHALL declare an `ApplicationTemplate` schema in `lib/Settings/openbuilt_register.json` under the existing `openbuilt` @@ -42,6 +55,8 @@ per organisation via OR's standard `organisation` field. No bespoke not have a draft/published/archived state machine of their own; they are either present or removed. +**ID:** REQ-OBTC-001 + #### Scenario: Schema validation rejects a template with no manifest - **WHEN** an API client posts an `ApplicationTemplate` with `title` @@ -57,7 +72,7 @@ are either present or removed. - **THEN** the second request is rejected with a 4xx error - **AND** the first template remains intact -### Requirement: REQ-OBTC-002 Four Conduction-curated templates seeded via repair step +### Requirement: Four Conduction-curated templates seeded via repair step The system SHALL seed at minimum four Conduction-curated `ApplicationTemplate` records on install via @@ -89,6 +104,8 @@ already-seeded install SHALL produce no duplicates and SHALL be guarded by per-template `slug` existence checks (matching the `SeedHelloWorld.php` guard pattern). +**ID:** REQ-OBTC-002 + #### Scenario: Fresh install seeds four templates - **WHEN** the OpenBuilt app is installed on a fresh Nextcloud @@ -104,7 +121,7 @@ guarded by per-template `slug` existence checks (matching the - **THEN** no duplicate templates are created - **AND** no existing template data is overwritten -### Requirement: REQ-OBTC-003 Gallery view lists templates with filter and detail +### Requirement: Gallery view lists templates with filter and detail The OpenBuilt frontend SHALL register a Vue route `/templates` whose view (`src/views/TemplateGallery.vue`) lists every @@ -124,6 +141,8 @@ The gallery SHALL render using `@conduction/nextcloud-vue`'s standard `CnAppRoot` chrome (no bespoke layout system) and SHALL use Nextcloud CSS variables only (per ADR-010 — no hardcoded colours). +**ID:** REQ-OBTC-003 + #### Scenario: Filtering by category narrows the gallery - **WHEN** a user opens `/index.php/apps/openbuilt/templates` and @@ -139,7 +158,7 @@ CSS variables only (per ADR-010 — no hardcoded colours). from template" CTA - **AND** clicking the CTA navigates to `/templates` -### Requirement: REQ-OBTC-004 "Use this template" clones into a new Application +### Requirement: "Use this template" clones into a new Application The system SHALL expose `POST /index.php/apps/openbuilt/api/applications/from-template/{templateSlug}` @@ -165,6 +184,8 @@ The route SHALL be registered in `appinfo/routes.php` (ADR-016) with PHP code surface in this spec beyond the seed step (≤30 LOC). No state-machine or "template service" class is introduced (ADR-031). +**ID:** REQ-OBTC-004 + #### Scenario: Clone produces a draft Application with the template manifest - **WHEN** an authenticated user POSTs `{ name: "My permits", slug: @@ -187,7 +208,7 @@ state-machine or "template service" class is introduced (ADR-031). - **AND** no Application is created - **AND** no companion schemas are cloned -### Requirement: REQ-OBTC-005 Cloned companion schemas are namespaced by Application slug +### Requirement: Cloned companion schemas are namespaced by Application slug When a template clone runs, the system SHALL prefix every cloned companion-schema `slug` with the new Application's slug joined by a @@ -202,6 +223,8 @@ This avoids slug collisions when multiple Applications are cloned from the same template into the same organisation, and keeps the original template's companion schemas untouched. +**ID:** REQ-OBTC-005 + #### Scenario: Two clones of the same template coexist - **WHEN** an authenticated user clones `permit-tracker` into @@ -212,7 +235,7 @@ template's companion schemas untouched. - **AND** each Application's manifest references its own prefixed schema slug -### Requirement: REQ-OBTC-006 Clone redirects to the page editor for customisation +### Requirement: Clone redirects to the page editor for customisation After a successful template clone, the frontend SHALL redirect the user to the page editor view (from chain spec #5, @@ -227,6 +250,8 @@ deployment), the frontend SHALL fall back to the textarea editor shipped in chain spec #1 (REQ-OBR-005) without breaking the clone flow. +**ID:** REQ-OBTC-006 + #### Scenario: Clone redirects into the page editor - **WHEN** a user successfully clones `permit-tracker` from the @@ -235,7 +260,7 @@ flow. Application - **AND** the editor surface shows the cloned manifest's first page -### Requirement: REQ-OBTC-007 Template clones are one-shot snapshots +### Requirement: Template clones are one-shot snapshots A cloned Application SHALL be a fully independent record from the source template. The system SHALL NOT propagate later changes to a @@ -247,6 +272,8 @@ template's `version` SHALL be recorded on the new Application under This decision is documented in `design.md` (Decision 5 — Versioning) and is explicitly deferred to a future versioning spec. +**ID:** REQ-OBTC-007 + #### Scenario: Updating a template does not change existing clones - **GIVEN** a user has cloned the `permit-tracker` template (version @@ -257,7 +284,7 @@ and is explicitly deferred to a future versioning spec. unchanged - **AND** the user's `templateOrigin.version` still reads `1.0.0` -### Requirement: REQ-OBTC-008 Conduction-curated templates are read-only via UI +### Requirement: Conduction-curated templates are read-only via UI The system SHALL present Conduction-shipped templates (records with `isSeeded: true`) as read-only in the gallery and SHALL NOT expose UI @@ -271,6 +298,8 @@ Org-local user-submitted templates (an explicit non-goal of this spec; deferred to a follow-up) will be `isSeeded: false` and editable; that flow lives in a separate change. +**ID:** REQ-OBTC-008 + #### Scenario: Gallery hides edit controls on a seeded template - **WHEN** a user views the `permit-tracker` template card in the @@ -279,7 +308,7 @@ flow lives in a separate change. rendered - **AND** only the "Use this template" action is shown -### Requirement: REQ-OBTC-009 Template manifests validate against the canonical app-manifest schema +### Requirement: Template manifests validate against the canonical app-manifest schema Every seeded template's `manifest` blob SHALL validate against the canonical `app-manifest.schema.json` pinned in `package.json` @@ -290,6 +319,8 @@ Cloned manifests (REQ-OBTC-004) inherit this guarantee transitively because they are byte-for-byte copies modulo the schema-slug rewrite in REQ-OBTC-005. +**ID:** REQ-OBTC-009 + #### Scenario: A broken seeded manifest fails install - **WHEN** a developer modifies the `permit-tracker` seed manifest to @@ -299,7 +330,7 @@ in REQ-OBTC-005. citing the offending page type - **AND** no `permit-tracker` template is seeded -### Requirement: REQ-OBTC-010 i18n keys for gallery and seeded templates +### Requirement: i18n keys for gallery and seeded templates The system SHALL ensure every user-visible string in the gallery view (gallery section title, filter labels, category labels, "Use this @@ -310,10 +341,11 @@ i18n keys (preferred) or as English strings with Dutch translations shipped in `l10n/nl.json` so the gallery is bilingual on install (per the project-wide nl/en minimum). +**ID:** REQ-OBTC-010 + #### Scenario: Dutch user sees Dutch gallery copy - **WHEN** an authenticated Dutch-locale user opens `/index.php/apps/openbuilt/templates` - **THEN** the page title, filter labels, and the four seeded template descriptions render in Dutch - diff --git a/openspec/specs/openbuilt-version-snapshots/spec.md b/openspec/specs/openbuilt-version-snapshots/spec.md index c76334d1..0bca8434 100644 --- a/openspec/specs/openbuilt-version-snapshots/spec.md +++ b/openspec/specs/openbuilt-version-snapshots/spec.md @@ -1,9 +1,17 @@ # openbuilt-version-snapshots Specification ## Purpose -TBD - created by archiving change openbuilt-versioning. Update Purpose after archive. + +Retires the append-only snapshot model originally proposed by `openbuilt-versioning` +and re-roots version history on OR's object-time-travel directly against each +`ApplicationVersion` row. Defines the diff endpoint contract that lets the OpenBuilt +shell compare two `ApplicationVersion` rows (or two historical states of the same row) +in a single round-trip, so the rollback and compare flows in +`application-detail-overview` have an authoritative server-side source. + ## Requirements -### Requirement: REQ-OBV-002 Snapshot is created on draft-to-published transition + +### Requirement: Snapshot is created on draft-to-published transition The system SHALL NOT spawn sibling `ApplicationVersion` rows on `draft → published` transitions. Snapshot-on-publish writeback is retired under @@ -11,6 +19,8 @@ ADR-002; the versioned model treats every `ApplicationVersion` row as a long-liv first-class object, not an append-only snapshot. History on a version is captured by OR's object-time-travel on the `ApplicationVersion` row itself. +**ID:** REQ-OBV-002 + The system SHALL NOT subscribe any PHP listener (`ApplicationVersionSnapshotListener` or any successor) to OR's `ObjectLifecycleTransitionedEvent` for the purpose of creating sibling @@ -36,7 +46,7 @@ else. - **THEN** no `ApplicationVersionSnapshotListener` (or successor) is registered as an event listener for `ObjectLifecycleTransitionedEvent` -### Requirement: REQ-OBV-003 Rollback restores a previous snapshot as the draft manifest +### Requirement: Rollback restores a previous snapshot as the draft manifest The system SHALL support rolling back any `ApplicationVersion` to a prior point in its OR object-history via OR's time-travel API on the version row itself — @@ -44,6 +54,8 @@ restoring a previous state of an `ApplicationVersion` MUST NOT be implemented by copying from a sibling snapshot row (the append-only snapshot model is retired under ADR-002). +**ID:** REQ-OBV-003 + The rollback action SHALL restore the chosen historical state of the row's `manifest` (and any other fields captured by OR's time-travel), SHALL leave the version's `status` at whatever the historical state recorded, and SHALL trigger @@ -59,11 +71,13 @@ the restored `manifest` differs from the immediately-prior saved state. - **AND** no sibling `ApplicationVersion` row is created - **AND** V's `manifest` matches t1's `manifest` -### Requirement: REQ-OBV-005 Diff endpoint returns two manifest blobs in one call +### Requirement: Diff endpoint returns two manifest blobs in one call The system SHALL expose `GET /index.php/apps/openbuilt/api/applications/{slug}/versions/diff?from={fromRef}&to={toRef}` +**ID:** REQ-OBV-005 + The diff endpoint changes shape under the versioned model: diffing two `ApplicationVersion` rows is the canonical case; comparing two historical states of one ApplicationVersion (time-travel diff on a single row) is the second diff --git a/openspec/specs/version-promotion/spec.md b/openspec/specs/version-promotion/spec.md index d710c76b..ef7e015c 100644 --- a/openspec/specs/version-promotion/spec.md +++ b/openspec/specs/version-promotion/spec.md @@ -1,9 +1,25 @@ # version-promotion Specification ## Purpose -TBD - created by archiving change openbuilt-version-promotion. Update Purpose after archive. + +Lands the promotion flow that moves a manifest + schema set + (optionally) data +from a source `ApplicationVersion` to its single downstream `promotesTo` +neighbour, completing the chain mechanics that `openbuilt-versioning-model` +defines but intentionally leaves out of scope. Ships the promotion endpoint, the +`<NcDialog>`-based `PromoteVersionDialog.vue` (modal-isolated per ADR-004), and +three admin-chosen data strategies — `start-with-source-data` (wipe target, +copy source rows), `migrate-existing-data` (keep target rows, apply source +schemas), and `empty-start` (wipe target rows, schema-only) — gated by OR object +lock on the target (409 on contention), editor-or-owner permission (no admin +auto-grant), uniform semver inheritance (target picks up source's value), and +on-failure target flip to `archived` with a `_self.promotionFailedAt` marker for +recovery. Default strategy is a pure function of chain position +(production-target → migrate; mid-chain → start-with-source-data; never +empty-start), implemented identically in PHP and JS. + ## Requirements -### Requirement: REQ-OBVP-001 Promotion endpoint accepts a strategy and targets `sourceVersion.promotesTo` + +### Requirement: Promotion endpoint accepts a strategy and targets `sourceVersion.promotesTo` The system SHALL expose `POST /index.php/apps/openbuilt/api/applications/{appUuid}/versions/{versionUuid}/promote` mounted on `VersionPromotionController::promote(string $appUuid, string $versionUuid)`, @@ -15,6 +31,8 @@ accept a JSON request body `{"strategy": "start-with-source-data" | "migrate-exi modify any data. The endpoint SHALL be registered in `appinfo/routes.php` and SHALL carry `#[NoAdminRequired]`. +**ID:** REQ-OBVP-001 + #### Scenario: Successful promotion returns 200 with the updated target - **GIVEN** an Application `app-1` with two ApplicationVersions: source `00000000-0000-0000-0000-000000000000` @@ -42,7 +60,7 @@ carry `#[NoAdminRequired]`. `"code": "invalid_strategy"` - **AND** no ApplicationVersion row is modified -### Requirement: REQ-OBVP-002 `start-with-source-data` replaces target rows + imports source schema set +### Requirement: `start-with-source-data` replaces target rows + imports source schema set The system SHALL, when invoked with `strategy: "start-with-source-data"`: @@ -60,6 +78,8 @@ The system SHALL, when invoked with `strategy: "start-with-source-data"`: On success the endpoint SHALL return `200 application/json` with the updated target ApplicationVersion. +**ID:** REQ-OBVP-002 + #### Scenario: start-with-source-data wipes target rows and copies from source - **GIVEN** a source register with 5 rows across 2 schemas and a target register with @@ -71,7 +91,7 @@ ApplicationVersion. - **AND** the target ApplicationVersion's `manifest` equals the source's `manifest` - **AND** the target ApplicationVersion's `semver` equals the source's `semver` -### Requirement: REQ-OBVP-003 `migrate-existing-data` keeps target rows + imports source schema set +### Requirement: `migrate-existing-data` keeps target rows + imports source schema set The system SHALL, when invoked with `strategy: "migrate-existing-data"`: @@ -87,6 +107,8 @@ The system SHALL, when invoked with `strategy: "migrate-existing-data"`: On success the endpoint SHALL return `200 application/json` with the updated target ApplicationVersion. +**ID:** REQ-OBVP-003 + #### Scenario: migrate-existing-data preserves target rows and applies source schemas - **GIVEN** a target register with 10 existing rows @@ -96,7 +118,7 @@ ApplicationVersion. - **AND** the target ApplicationVersion's `manifest` equals the source's `manifest` - **AND** the target ApplicationVersion's `semver` equals the source's `semver` -### Requirement: REQ-OBVP-004 `empty-start` drops target rows + imports source schema set +### Requirement: `empty-start` drops target rows + imports source schema set The system SHALL, when invoked with `strategy: "empty-start"`: @@ -113,6 +135,8 @@ The endpoint SHALL NOT enforce the dialog's destructive-confirmation gate that the client has obtained admin intent. On success the endpoint SHALL return `200 application/json` with the updated target ApplicationVersion. +**ID:** REQ-OBVP-004 + #### Scenario: empty-start wipes target rows and leaves the register schema-only - **GIVEN** a target register with 7 existing rows @@ -122,7 +146,7 @@ that the client has obtained admin intent. On success the endpoint SHALL return - **AND** the target ApplicationVersion's `manifest` equals the source's `manifest` - **AND** the target ApplicationVersion's `semver` equals the source's `semver` -### Requirement: REQ-OBVP-005 Schema diff handling deferred to OR +### Requirement: Schema diff handling deferred to OR The promotion endpoint SHALL invoke OR's schema-import / register-merge API for the target register with the source's schema set; OR's own breaking-change handling @@ -131,6 +155,8 @@ dry-run, or breaking-change preflight. If OR's API returns a failure response, t endpoint SHALL treat that as a promotion failure (REQ-OBVP-009) and the on-failure status flip applies. +**ID:** REQ-OBVP-005 + #### Scenario: OR's schema-import success continues the strategy step - **GIVEN** OR's schema-import API returns success for the target register @@ -148,7 +174,7 @@ status flip applies. - **AND** the endpoint returns `500 Internal Server Error` with OR's error payload preserved in `message` -### Requirement: REQ-OBVP-006 OR object lock acquisition on target + 409 on contention +### Requirement: OR object lock acquisition on target + 409 on contention The promotion endpoint SHALL acquire OR's object lock on the **target** ApplicationVersion row before performing any schema-import, data-copy, or manifest @@ -158,6 +184,8 @@ failure. If the lock is already held by another caller, the endpoint SHALL retur "expiresAt": "<ISO-8601 timestamp>"}` where `lockedBy` and `expiresAt` come from OR's lock metadata. The endpoint SHALL NOT modify any data on contention. +**ID:** REQ-OBVP-006 + #### Scenario: 409 returned on lock contention - **GIVEN** the target ApplicationVersion row is locked by user `<uid>` with the lock @@ -179,7 +207,7 @@ lock metadata. The endpoint SHALL NOT modify any data on contention. - **THEN** the OR object lock on the target ApplicationVersion is no longer held - **AND** the target's `status` is `archived` per REQ-OBVP-009 -### Requirement: REQ-OBVP-007 Permission: editor or owner on parent Application required +### Requirement: Permission: editor or owner on parent Application required The promotion endpoint SHALL resolve the caller's role against the parent Application's `permissions.{owners, editors}` blocks. If the caller is neither an @@ -189,6 +217,8 @@ SHALL NOT be auto-granted promotion permission — this is a deliberate constrai an admin who is not in `permissions.owners` or `permissions.editors` on the specific Application SHALL be rejected with `403`. +**ID:** REQ-OBVP-007 + #### Scenario: Viewer is rejected - **GIVEN** an authenticated user listed in `permissions.viewers` but not in @@ -217,7 +247,7 @@ Application SHALL be rejected with `403`. - **WHEN** the user POSTs a valid promote request (with no lock contention) - **THEN** the response is `200 application/json` with the updated target -### Requirement: REQ-OBVP-008 Semver: target inherits source's value uniformly +### Requirement: Semver: target inherits source's value uniformly The promotion endpoint SHALL set `targetVersion.semver` to `sourceVersion.semver` at the moment of promotion, regardless of whether the target is the production version @@ -225,6 +255,8 @@ or a mid-chain version. The endpoint SHALL NOT introduce any additional semver b on the target. The next manifest edit on the upstream source SHALL fire the existing spec-C semver auto-bump (REQ-OBV-103); this spec adds no new bump rule. +**ID:** REQ-OBVP-008 + #### Scenario: Target inherits source semver on promotion to production - **GIVEN** a source ApplicationVersion with `semver: 1.5.0` and a target that is the @@ -239,7 +271,7 @@ spec-C semver auto-bump (REQ-OBV-103); this spec adds no new bump rule. - **WHEN** an owner promotes with any strategy - **THEN** the target's `semver` is `0.3.4` after the promotion -### Requirement: REQ-OBVP-009 On-failure target flips to archived and endpoint returns 500 +### Requirement: On-failure target flips to archived and endpoint returns 500 The system SHALL handle any failure inside `VersionPromotionService::promote()` (e.g. OR schema-import failure, register-row copy failure, manifest save failure) by @@ -259,6 +291,8 @@ throughout the promotion flow. Re-promotion after the underlying issue is resolv is the prescribed recovery path; alternative recovery is deletion of the archived target via the spec-C deletion endpoint with `?strategy=delete-now`. +**ID:** REQ-OBVP-009 + #### Scenario: Failure during data copy archives the target - **GIVEN** an OR-side error occurs while copying rows from source to target during @@ -283,7 +317,7 @@ target via the spec-C deletion endpoint with `?strategy=delete-now`. promotion overwrites status alongside manifest + semver) - **AND** the target's state reflects the source's current state -### Requirement: REQ-OBVP-010 Dialog `PromoteVersionDialog.vue` ships with destructive-confirmation gate +### Requirement: Dialog `PromoteVersionDialog.vue` ships with destructive-confirmation gate The system SHALL ship a Vue component at `src/dialogs/PromoteVersionDialog.vue` implemented as a standalone `.vue` file using `<NcDialog>` (per ADR-004 @@ -311,6 +345,8 @@ SHALL: The dialog SHALL NOT call the backend endpoint itself — the parent surface (delivered by spec B) is responsible for the network call. +**ID:** REQ-OBVP-010 + #### Scenario: Dialog mounts with chain-position default - **GIVEN** a source ApplicationVersion whose `promotesTo` points at the Application's @@ -357,7 +393,7 @@ by spec B) is responsible for the network call. - **THEN** the dialog emits `cancel` with no payload - **AND** the dialog closes -### Requirement: REQ-OBVP-011 Default-strategy rule is a pure function of chain position +### Requirement: Default-strategy rule is a pure function of chain position The system SHALL implement a pure function `defaultStrategyFor(application: Application, target: ApplicationVersion): @@ -371,6 +407,8 @@ both PHP (`VersionPromotionService::defaultStrategyFor()`) and JS (inside `PromoteVersionDialog.vue` or a sibling helper imported by it). Both implementations SHALL be unit-tested. +**ID:** REQ-OBVP-011 + #### Scenario: Production target returns migrate-existing-data - **GIVEN** an Application X with `productionVersion.uuid = u-prod` and a target @@ -389,4 +427,3 @@ SHALL be unit-tested. - **WHEN** `defaultStrategyFor` is called with any valid `(Application, target)` pair - **THEN** the return value is never `"empty-start"` - diff --git a/openspec/specs/version-routing/spec.md b/openspec/specs/version-routing/spec.md index 91bfc9cb..22de7730 100644 --- a/openspec/specs/version-routing/spec.md +++ b/openspec/specs/version-routing/spec.md @@ -1,9 +1,24 @@ # version-routing Specification ## Purpose -TBD - created by archiving change openbuilt-version-routing. Update Purpose after archive. + +Defines the URL contract that makes the ADR-002 versioned model reachable from the +frontend: an optional `?_version=<versionSlug>` query parameter (the underscore +prefix is OpenBuilt's reserved-namespace marker, avoiding collision with +user-defined `?version=`) on both the manifest endpoint and the builder paths, +with server-side RBAC so end users always see only production. Encapsulates the +two-step slug resolution (Application by `slug` → ApplicationVersion by +`application` + `slug`) plus the editor/owner gate in `ManifestResolverService` +(viewers / non-members / NC admins without per-app role all receive 404 — not 403, +no existence leak). Ships a `useApplicationVersion(appSlug, versionSlug)` +composable as the single source of truth for frontend version resolution, a +`buildVersionedRoute` helper that forwards the active `_version` across in-app +links, and store-level register routing so OR calls automatically target the right +per-version register. + ## Requirements -### Requirement: REQ-OBVR-001 Manifest endpoint accepts optional `?_version=<versionSlug>` query param + +### Requirement: Manifest endpoint accepts optional `?_version=<versionSlug>` query param The system SHALL accept an optional query parameter `_version` (underscore-prefix form) on `GET /index.php/apps/openbuilt/api/applications/{slug}/manifest`. The underscore @@ -19,6 +34,8 @@ gate defined in REQ-OBVR-003. The endpoint SHALL NOT add new routes in `appinfo/routes.php` — the existing route entry gains a query-parameter contract only. +**ID:** REQ-OBVR-001 + #### Scenario: No `?_version=` param returns the production manifest - **GIVEN** an Application `hello-world` with `productionVersion` pointing at an @@ -55,7 +72,7 @@ entry gains a query-parameter contract only. - **THEN** the response is `404 Not Found` - **AND** the response body is `{"status": 404, "message": "Version not found"}` -### Requirement: REQ-OBVR-002 `ManifestResolverService` owns the two-step slug resolution +### Requirement: `ManifestResolverService` owns the two-step slug resolution The system SHALL implement a `ManifestResolverService` (new file or modify existing manifest service) that encapsulates the resolution of an application slug + optional @@ -79,6 +96,8 @@ maps `null` → `404` and non-null → `200`. The `ManifestController` method MUST carry `#[NoAdminRequired]` (the production manifest is publicly accessible; the RBAC gate lives inside the resolver service). +**ID:** REQ-OBVR-002 + #### Scenario: Service resolves production manifest without RBAC (no `versionSlug`) - **GIVEN** an Application with `productionVersion` record available @@ -100,7 +119,7 @@ is publicly accessible; the RBAC gate lives inside the resolver service). called - **THEN** the service returns null (causing the controller to return 404) -### Requirement: REQ-OBVR-003 RBAC gate: editor/owner required for non-production versions; 404 (not 403) on failure +### Requirement: RBAC gate: editor/owner required for non-production versions; 404 (not 403) on failure The system SHALL, when `?_version=<versionSlug>` resolves to a non-production version (i.e. `resolvedVersion.uuid !== Application.productionVersion.uuid`), check the caller's @@ -125,6 +144,8 @@ receive `404`, not `200`. (`version_access_denied` + caller uid) when it returns `null` for an RBAC failure. The log line is server-side only and SHALL NOT be exposed in the HTTP response. +**ID:** REQ-OBVR-003 + #### Scenario: Viewer receives 404 for non-production version - **GIVEN** an Application `hello-world` with ApplicationVersion `staging` (not the @@ -169,7 +190,7 @@ The log line is server-side only and SHALL NOT be exposed in the HTTP response. - **AND** the HTTP response body is `{"status": 404, "message": "Version not found"}` (no mention of authorisation) -### Requirement: REQ-OBVR-004 Builder paths read `?_version=` from `$route.query` +### Requirement: Builder paths read `?_version=` from `$route.query` The system SHALL read `$route.query._version` in the `created()` or `mounted()` hook (Options API) of each of the following builder views: @@ -185,6 +206,8 @@ Each view SHALL pass the resolved `versionSlug` (or `undefined` when absent) to existing routes with `:slug` retain their shape; `?_version=` is a query param that Vue Router already preserves across in-app navigation and on page reload. +**ID:** REQ-OBVR-004 + #### Scenario: SchemaDesignerView reads `?_version=staging` from the URL - **GIVEN** the admin navigates to @@ -209,7 +232,7 @@ Vue Router already preserves across in-app navigation and on page reload. - **THEN** `useApplicationVersion('hello-world', undefined)` resolves to the `production` ApplicationVersion (only version available) -### Requirement: REQ-OBVR-005 `useApplicationVersion(appSlug, versionSlug)` composable +### Requirement: `useApplicationVersion(appSlug, versionSlug)` composable The system SHALL provide a Vue composable at `src/composables/useApplicationVersion.js` with the signature: @@ -232,6 +255,8 @@ version qualifies. The composable SHALL expose the selected `ApplicationVersion` The composable is the single source of truth for version resolution on the frontend; all four builder views SHALL delegate to it rather than implementing their own lookup. +**ID:** REQ-OBVR-005 + #### Scenario: Composable resolves a named version - **WHEN** `useApplicationVersion('hello-world', 'staging')` is called @@ -257,7 +282,7 @@ all four builder views SHALL delegate to it rather than implementing their own l - **WHEN** the fetch fails - **THEN** `error.value` holds the caught error and `loading.value` is `false` -### Requirement: REQ-OBVR-006 `buildVersionedRoute(routeName, params, currentVersion)` helper +### Requirement: `buildVersionedRoute(routeName, params, currentVersion)` helper The system SHALL provide a helper function `buildVersionedRoute` in `src/router/index.js` (or a sibling file imported by it) with the following contract: @@ -276,6 +301,8 @@ All internal navigation that opens builder paths SHALL use `buildVersionedRoute` of constructing route objects directly. This prevents accidental strip of `?_version=` from the URL when the admin navigates between builder sub-sections. +**ID:** REQ-OBVR-006 + #### Scenario: Helper forwards the version param when present - **WHEN** `buildVersionedRoute('schemas', { slug: 'hello-world' }, 'staging')` is called @@ -288,7 +315,7 @@ from the URL when the admin navigates between builder sub-sections. - **THEN** the returned object is `{ name: 'schemas', params: { slug: 'hello-world' }, query: {} }` -### Requirement: REQ-OBVR-007 `schemas.js` store accepts `versionSlug` and routes the register name +### Requirement: `schemas.js` store accepts `versionSlug` and routes the register name The system SHALL modify `src/stores/schemas.js` so that every OR call that targets the per-app register name accepts an optional `versionSlug` parameter. When `versionSlug` @@ -301,6 +328,8 @@ The store SHALL expose `versionSlug` as a piece of reactive state so that views set it once (after resolving via `useApplicationVersion`) and subsequent store calls automatically target the correct register. +**ID:** REQ-OBVR-007 + #### Scenario: Store targets the staging register when versionSlug is 'staging' - **GIVEN** the store's `versionSlug` is set to `'staging'` for Application @@ -315,7 +344,7 @@ automatically target the correct register. - **WHEN** the store fetches schemas - **THEN** the OR call targets register `openbuilt-hello-world-production` -### Requirement: REQ-OBVR-008 Browser reload preserves `?_version=` (bookmarkability) +### Requirement: Browser reload preserves `?_version=` (bookmarkability) The system SHALL not add any logic to "clean" or redirect away from `?_version=` on page load. Vue Router's default query-param preservation behaviour is sufficient; this @@ -327,6 +356,8 @@ An authorised caller who bookmarks `/builder/hello-world/schemas?_version=stagin be able to reload the page and land on the same version without being redirected to the default. +**ID:** REQ-OBVR-008 + #### Scenario: Reload of a versioned builder URL stays on the same version - **GIVEN** an authorised editor has navigated to @@ -343,7 +374,7 @@ default. - **THEN** the URL becomes `/builder/hello-world/pages?_version=staging` - **AND** `PageDesigner.vue` resolves to the `staging` ApplicationVersion -### Requirement: REQ-OBVR-009 CnAppRoot shows a version-not-found message on 404 +### Requirement: CnAppRoot shows a version-not-found message on 404 The system SHALL ensure that when `CnAppRoot` (in `BuilderHostView` or `PageDesignerHostView`) receives a `404` response from the manifest endpoint for a @@ -356,10 +387,11 @@ this spec requires only that `BuilderHostView` / `PageDesignerHostView` propagat error state from `useApplicationVersion` to the slot or prop that `CnAppRoot` uses for its error display. +**ID:** REQ-OBVR-009 + #### Scenario: CnAppRoot shows version-not-found on 404 - **GIVEN** `useApplicationVersion('hello-world', 'nonexistent')` resolves to a 404 - **WHEN** `BuilderHostView` renders - **THEN** the view shows the "version not found" UI state (no manifest, no stack trace) - **AND** no HTTP 403 or 401 cue is visible to the caller -